From 56eb2cdcb5d96552b2aa0d5c22fa2fb4883639a5 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 8 Nov 2020 14:32:27 +0100 Subject: [PATCH 001/892] Simplify device class detection, fix hardcoded timeout for discovery (#112) --- kasa/discover.py | 39 ++++++++++++++------------------------- 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index 7aaf85245..e4091512e 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -33,13 +33,11 @@ def __init__( *, on_discovered: OnDiscoveredCallable = None, target: str = "255.255.255.255", - timeout: int = 5, discovery_packets: int = 3, interface: Optional[str] = None, ): self.transport = None - self.tries = discovery_packets - self.timeout = timeout + self.discovery_packets = discovery_packets self.interface = interface self.on_discovered = on_discovered self.protocol = TPLinkSmartHomeProtocol() @@ -65,7 +63,7 @@ def do_discover(self) -> None: req = json.dumps(Discover.DISCOVERY_QUERY) _LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY) encrypted_req = self.protocol.encrypt(req) - for i in range(self.tries): + for i in range(self.discovery_packets): self.transport.sendto(encrypted_req[4:], self.target) # type: ignore def datagram_received(self, data, addr) -> None: @@ -176,7 +174,6 @@ async def discover( lambda: _DiscoverProtocol( target=target, on_discovered=on_discovered, - timeout=timeout, discovery_packets=discovery_packets, interface=interface, ), @@ -186,7 +183,7 @@ async def discover( try: _LOGGER.debug("Waiting %s seconds for responses...", timeout) - await asyncio.sleep(5) + await asyncio.sleep(timeout) finally: transport.close() @@ -220,32 +217,24 @@ async def discover_single(host: str) -> SmartDevice: @staticmethod def _get_device_class(info: dict) -> Type[SmartDevice]: """Find SmartDevice subclass for device described by passed data.""" - if "system" in info and "get_sysinfo" in info["system"]: - sysinfo = info["system"]["get_sysinfo"] - if "type" in sysinfo: - type_ = sysinfo["type"] - elif "mic_type" in sysinfo: - type_ = sysinfo["mic_type"] - else: - raise SmartDeviceException("Unable to find the device type field!") - else: - raise SmartDeviceException("No 'system' nor 'get_sysinfo' in response") + if "system" not in info or "get_sysinfo" not in info["system"]: + raise SmartDeviceException("No 'system' or 'get_sysinfo' in response") - if ( - "smartlife.iot.dimmer" in info - and "get_dimmer_parameters" in info["smartlife.iot.dimmer"] - ): - return SmartDimmer + sysinfo = info["system"]["get_sysinfo"] + type_ = sysinfo.get("type", sysinfo.get("mic_type")) + if type_ is None: + raise SmartDeviceException("Unable to find the device type field!") - elif "smartplug" in type_.lower() and "children" in sysinfo: - return SmartStrip + if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: + return SmartDimmer - elif "smartplug" in type_.lower(): + if "smartplug" in type_.lower(): if "children" in sysinfo: return SmartStrip return SmartPlug - elif "smartbulb" in type_.lower(): + + if "smartbulb" in type_.lower(): if "length" in sysinfo: # strips have length return SmartLightStrip From a926ff5980a9078493608e51567c2e7531b24566 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 21 Nov 2020 22:52:13 +0100 Subject: [PATCH 002/892] Release 0.4.0.dev2 (#118) --- CHANGELOG.md | 33 ++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74fe8175d..56ddced1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [0.4.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev2) (2020-11-21) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev1...0.4.0.dev2) + +**Implemented enhancements:** + +- 'Interface' parameter added to discovery process [\#79](https://github.com/python-kasa/python-kasa/pull/79) ([dmitryelj](https://github.com/dmitryelj)) + +**Fixed bugs:** + +- Simplify device class detection for discovery, fix hardcoded timeout [\#112](https://github.com/python-kasa/python-kasa/pull/112) ([rytilahti](https://github.com/rytilahti)) +- Update cli.py to addresss crash on year/month calls and improve output formatting [\#103](https://github.com/python-kasa/python-kasa/pull/103) ([BuongiornoTexas](https://github.com/BuongiornoTexas)) + +**Closed issues:** + +- TPLINK HS100 firmware 4.1 no longer has TCP 9999 available [\#114](https://github.com/python-kasa/python-kasa/issues/114) +- 7.1.2 Update to asyncclick breaks github install of python-kasa [\#106](https://github.com/python-kasa/python-kasa/issues/106) +- cli emeter year and month functions fail [\#102](https://github.com/python-kasa/python-kasa/issues/102) +- how to know the duration for which the plug was ON? [\#99](https://github.com/python-kasa/python-kasa/issues/99) +- problem controlling the smartplug through a controller [\#98](https://github.com/python-kasa/python-kasa/issues/98) +- unable to install [\#97](https://github.com/python-kasa/python-kasa/issues/97) +- Install on Ubuntu 18.04 no luck [\#96](https://github.com/python-kasa/python-kasa/issues/96) +- issue with installation [\#95](https://github.com/python-kasa/python-kasa/issues/95) +- Running via Crontab [\#92](https://github.com/python-kasa/python-kasa/issues/92) +- Issues with setup [\#91](https://github.com/python-kasa/python-kasa/issues/91) + +**Merged pull requests:** + +- Pin dependencies on major versions, add poetry.lock [\#94](https://github.com/python-kasa/python-kasa/pull/94) ([rytilahti](https://github.com/rytilahti)) + ## [0.4.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev1) (2020-07-28) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev0...0.4.0.dev1) @@ -24,10 +54,12 @@ **Merged pull requests:** +- Release 0.4.0.dev1 [\#93](https://github.com/python-kasa/python-kasa/pull/93) ([rytilahti](https://github.com/rytilahti)) - add a small example script to show library usage [\#90](https://github.com/python-kasa/python-kasa/pull/90) ([rytilahti](https://github.com/rytilahti)) - add .readthedocs.yml required for poetry builds [\#89](https://github.com/python-kasa/python-kasa/pull/89) ([rytilahti](https://github.com/rytilahti)) - Improve installation instructions [\#86](https://github.com/python-kasa/python-kasa/pull/86) ([rytilahti](https://github.com/rytilahti)) - cli: Fix incorrect use of asyncio.run for temperature command [\#85](https://github.com/python-kasa/python-kasa/pull/85) ([rytilahti](https://github.com/rytilahti)) +- Add parse\_pcap to devtools, improve readme on contributing [\#84](https://github.com/python-kasa/python-kasa/pull/84) ([rytilahti](https://github.com/rytilahti)) - Add --transition to bulb-specific cli commands, fix turn\_{on,off} signatures [\#81](https://github.com/python-kasa/python-kasa/pull/81) ([rytilahti](https://github.com/rytilahti)) - Improve bulb API, force turn on for all light changes as offline changes are not supported [\#76](https://github.com/python-kasa/python-kasa/pull/76) ([rytilahti](https://github.com/rytilahti)) - Simplify API documentation by using doctests [\#73](https://github.com/python-kasa/python-kasa/pull/73) ([rytilahti](https://github.com/rytilahti)) @@ -66,7 +98,6 @@ **Merged pull requests:** -- Add parse\_pcap to devtools, improve readme on contributing [\#84](https://github.com/python-kasa/python-kasa/pull/84) ([rytilahti](https://github.com/rytilahti)) - Add retries to protocol queries [\#65](https://github.com/python-kasa/python-kasa/pull/65) ([rytilahti](https://github.com/rytilahti)) - General cleanups all around \(janitoring\) [\#63](https://github.com/python-kasa/python-kasa/pull/63) ([rytilahti](https://github.com/rytilahti)) - Improve dimmer support [\#62](https://github.com/python-kasa/python-kasa/pull/62) ([rytilahti](https://github.com/rytilahti)) diff --git a/pyproject.toml b/pyproject.toml index 1bdf1716a..2411bed40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.4.0.dev1" +version = "0.4.0.dev2" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["Your Name "] From 98b40b5072692805950a3a36d50ca69ac240fc50 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 9 Dec 2020 10:13:14 +0100 Subject: [PATCH 003/892] Improve cli documentation for bulbs and power strips (#123) --- README.md | 1 + docs/source/cli.rst | 19 +++++++----- docs/source/conf.py | 3 ++ docs/source/index.rst | 3 -- docs/source/smartbulb.rst | 53 +++++++++++++++++++++++++++++++++ docs/source/smartdevice.rst | 4 +++ docs/source/smartdimmer.rst | 7 +++++ docs/source/smartlightstrip.rst | 7 +++++ docs/source/smartplug.rst | 8 +++++ docs/source/smartstrip.rst | 31 +++++++++++++++++++ 10 files changed, 126 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 09329ef18..fcc089ce3 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * HS300 * KP303 +* KP400 ### Wall switches diff --git a/docs/source/cli.rst b/docs/source/cli.rst index 0d1989dbf..1e656997c 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -2,18 +2,23 @@ Command-line usage ================== The package is shipped with a console tool named kasa, please refer to ``kasa --help`` for detailed usage. -The device to which the commands are sent is chosen by `KASA_HOST` environment variable or passing ``--host
`` as an option. +The device to which the commands are sent is chosen by ``KASA_HOST`` environment variable or passing ``--host
`` as an option. To see what is being sent to and received from the device, specify option ``--debug``. -To avoid discovering the devices when executing commands its type can be passed by specifying either ``--plug`` or ``--bulb``, -if no type is given its type will be discovered automatically with a small delay. -Some commands (such as reading energy meter values and setting color of bulbs) additional parameters are required, -which you can find by adding ``--help`` after the command, e.g. ``kasa emeter --help`` or ``kasa hsv --help``. +To avoid discovering the devices when executing commands its type can be passed as an option (e.g., ``--plug`` for plugs, ``--bulb`` for bulbs, ..). +If no type is manually given, its type will be discovered automatically which causes a short delay. If no command is given, the ``state`` command will be executed to query the device state. +.. note:: + + Some commands (such as reading energy meter values, changing bulb settings, or accessing individual sockets on smart strips) additional parameters are required, + which you can find by adding ``--help`` after the command, e.g. ``kasa emeter --help`` or ``kasa hsv --help``. + Refer to the device type specific documentation for more details. + + Provisioning -~~~~~~~~~~~~ +************ You can provision your device without any extra apps by using the ``kasa wifi`` command: @@ -23,6 +28,6 @@ You can provision your device without any extra apps by using the ``kasa wifi`` 4. Join/change the network using ``kasa wifi join`` command, see ``--help`` for details. ``kasa --help`` -~~~~~~~~~~~~~~~ +*************** .. program-output:: kasa --help diff --git a/docs/source/conf.py b/docs/source/conf.py index 7e718402d..6090e9246 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -31,6 +31,7 @@ "sphinx.ext.autodoc", "sphinx.ext.coverage", "sphinx.ext.viewcode", + "sphinx.ext.todo", "sphinxcontrib.programoutput", ] @@ -55,6 +56,8 @@ # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] +todo_include_todos = True + def setup(app): # add copybutton to hide the >>> prompts, see https://github.com/readthedocs/sphinx_rtd_theme/issues/167 diff --git a/docs/source/index.rst b/docs/source/index.rst index 59897b394..2804757e2 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,6 +1,3 @@ -python-kasa documentation -========================= - .. mdinclude:: ../../README.md .. toctree:: diff --git a/docs/source/smartbulb.rst b/docs/source/smartbulb.rst index 76f66224e..bf58ecbf5 100644 --- a/docs/source/smartbulb.rst +++ b/docs/source/smartbulb.rst @@ -1,6 +1,59 @@ Bulbs =========== +Supported features +****************** + +* Turning on and off +* Setting brightness, color temperature, and color (in HSV) +* Querying emeter information +* Transitions + +Currently unsupported +********************* + +* Setting the default transitions +* Timers + +.. note:: + + Feel free to open a pull request to add support for more features! + +Transitions +*********** + +All commands changing the bulb state can be accompanied with a transition, e.g., to slowly fade the light off. +The transition time is in milliseconds, 0 means immediate change. +If no transition value is given, the default setting as configured for the bulb will be used. + +.. note:: + + Accepted values are command (and potentially bulb) specific, feel free to improve the documentation on accepted values. + + **Example:** While KL130 allows at least up to 15 second transitions for smooth turning off transitions, turning it on will not be so smooth. + +Command-line usage +****************** + +All command-line commands can be used with transition period for smooth changes. + + +**Example:** Turn the bulb off over a 15 second time period. + +.. code:: + + $ kasa --bulb --host off --transition 15000 + +**Example:** Change the bulb to red with 20% brightness over 15 seconds: + +.. code:: + + $ kasa --bulb --host hsv 0 100 20 --transition 15000 + + +API documentation +***************** + .. autoclass:: kasa.SmartBulb :members: :undoc-members: diff --git a/docs/source/smartdevice.rst b/docs/source/smartdevice.rst index dd08ac911..6b83c1a57 100644 --- a/docs/source/smartdevice.rst +++ b/docs/source/smartdevice.rst @@ -40,6 +40,10 @@ Refer to device type specific classes for more examples: * :class:`SmartDimmer` * :class:`SmartLightStrip` + +API documentation +~~~~~~~~~~~~~~~~~ + .. autoclass:: kasa.SmartDevice :members: :undoc-members: diff --git a/docs/source/smartdimmer.rst b/docs/source/smartdimmer.rst index f55d571cf..fa4e1ece1 100644 --- a/docs/source/smartdimmer.rst +++ b/docs/source/smartdimmer.rst @@ -1,6 +1,13 @@ Dimmers ======= +.. note:: + + Feel free to open a pull request to improve the documentation! + +API documentation +***************** + .. autoclass:: kasa.SmartDimmer :members: :undoc-members: diff --git a/docs/source/smartlightstrip.rst b/docs/source/smartlightstrip.rst index b02342ed9..4d34efbbd 100644 --- a/docs/source/smartlightstrip.rst +++ b/docs/source/smartlightstrip.rst @@ -1,6 +1,13 @@ Light strips ============ +.. note:: + + Feel free to open a pull request to improve the documentation! + +API documentation +***************** + .. autoclass:: kasa.SmartLightStrip :members: :undoc-members: diff --git a/docs/source/smartplug.rst b/docs/source/smartplug.rst index 75b342cb0..e9b8ccdfd 100644 --- a/docs/source/smartplug.rst +++ b/docs/source/smartplug.rst @@ -1,6 +1,14 @@ Plugs ===== +.. note:: + + Feel free to open a pull request to improve the documentation! + + +API documentation +***************** + .. autoclass:: kasa.SmartPlug :members: :undoc-members: diff --git a/docs/source/smartstrip.rst b/docs/source/smartstrip.rst index b6c9ff903..460f211e2 100644 --- a/docs/source/smartstrip.rst +++ b/docs/source/smartstrip.rst @@ -1,6 +1,37 @@ Smart strips ============ + +.. note:: + + The emeter feature is currently not implemented for smart strips. See https://github.com/python-kasa/python-kasa/issues/64 for details. + +.. note:: + + Feel free to open a pull request to improve the documentation! + +Command-line usage +****************** + +To command a single socket of a strip, you will need to specify it either by using ``--index`` or by using ``--name``. +If not specified, the commands will act on the parent device: turning the strip off will turn off all sockets. + +**Example:** Turn off the first socket (the indexing starts from zero): + +.. code:: + + $ kasa --strip --host on --index 0 + +**Example:** Turn on the socket by name: + +.. code:: + + $ kasa --strip --host off --name "Maybe Kitchen" + + +API documentation +***************** + .. autoclass:: kasa.SmartStrip :members: :undoc-members: From d4a361dd3e3aad1aa92fd4758e23ff7af23f1122 Mon Sep 17 00:00:00 2001 From: dlee1j1 <53192782+dlee1j1@users.noreply.github.com> Date: Sat, 6 Feb 2021 07:14:36 -0800 Subject: [PATCH 004/892] Leverage data from UDP discovery to initialize device structure (#132) * avoid talking to devices after UDP discovery * formatting fix * more formatting * more formatting changes * undo gitignore changes * fixing git ignore for black Co-authored-by: dlee1j1 --- kasa/cli.py | 3 +-- kasa/discover.py | 2 +- kasa/smartdevice.py | 5 +++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 167179e36..5efd6be6d 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -174,13 +174,12 @@ async def dump_discover(ctx, scrub): async def discover(ctx, timeout, discover_only, dump_raw): """Discover devices in the network.""" target = ctx.parent.params["target"] - click.echo(f"Discovering devices for {timeout} seconds") + click.echo(f"Discovering devices on {target} for {timeout} seconds") found_devs = await Discover.discover( target=target, timeout=timeout, return_raw=dump_raw ) if not discover_only: for ip, dev in found_devs.items(): - await dev.update() if dump_raw: click.echo(dev) continue diff --git a/kasa/discover.py b/kasa/discover.py index e4091512e..3e7f70207 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -77,7 +77,7 @@ def datagram_received(self, data, addr) -> None: device_class = Discover._get_device_class(info) device = device_class(ip) - asyncio.ensure_future(device.update()) + device.update_from_discover_info(info) self.discovered_devices[ip] = device self.discovered_devices_raw[ip] = info diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 19589bbad..da5c0f65a 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -304,6 +304,11 @@ async def update(self): # TODO: keep accessible for tests self._sys_info = self._last_update["system"]["get_sysinfo"] + def update_from_discover_info(self, info): + """Update state from info from the discover call.""" + self._last_update = info + self._sys_info = info["system"]["get_sysinfo"] + @property # type: ignore @requires_update def sys_info(self) -> Dict[str, Any]: From 8a5c5507c86a470cad1ff81a178ee506cad0ab8c Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 7 Feb 2021 22:25:42 +0100 Subject: [PATCH 005/892] add tapo link, fix tplink-smarthome-simulator link (#133) * add link to tapo p100 project * fix tplink-smarthome-simulator link --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fcc089ce3..4e9b18545 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,15 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. **Contributions (be it adding missing features, fixing bugs or improving documentation) are more than welcome, feel free to submit pull requests!** -### Resources +## Resources + +### Links * [softScheck's github contains lot of information and wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) -* [https://github.com/plasticrake/tplink-smarthome-simulator](tplink-smarthome-simulator) +* [TP-Link Smart Home Device Simulator](https://github.com/plasticrake/tplink-smarthome-simulator) * [Unofficial API documentation](https://github.com/plasticrake/tplink-smarthome-api/blob/master/API.md) + +### TP-Link Tapo support + +* [Tapo P100 (Tapo P105/P100 plugs, Tapo L510E bulbs)](https://github.com/fishbigger/TapoP100) + * [Home Assistant integration](https://github.com/fishbigger/HomeAssistant-Tapo-P100-Control) From 87730e6c4eeec580a4267bbb9aeb105dbdbb7ea1 Mon Sep 17 00:00:00 2001 From: Flaviof Date: Mon, 15 Feb 2021 11:59:17 -0500 Subject: [PATCH 006/892] Fix documentation on Smart strips (#136) Trivial Fix Fix documentation and the corresponding command when turning on/off example. --- docs/source/smartstrip.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/smartstrip.rst b/docs/source/smartstrip.rst index 460f211e2..edd4953bc 100644 --- a/docs/source/smartstrip.rst +++ b/docs/source/smartstrip.rst @@ -16,13 +16,13 @@ Command-line usage To command a single socket of a strip, you will need to specify it either by using ``--index`` or by using ``--name``. If not specified, the commands will act on the parent device: turning the strip off will turn off all sockets. -**Example:** Turn off the first socket (the indexing starts from zero): +**Example:** Turn on the first socket (the indexing starts from zero): .. code:: $ kasa --strip --host on --index 0 -**Example:** Turn on the socket by name: +**Example:** Turn off the socket by name: .. code:: From 848c38117ba38524753601d42da9821ea6cb9f92 Mon Sep 17 00:00:00 2001 From: Flaviof Date: Thu, 18 Feb 2021 17:24:53 -0500 Subject: [PATCH 007/892] README.md: Add link to MQTT interface for python-kasa (#140) Add reference to https://github.com/flavio-fernandes/mqtt2kasa for users who may be interested in using MQTT to control/monitor devices managed by python-kasa --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4e9b18545..5abe452f0 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * [softScheck's github contains lot of information and wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) * [TP-Link Smart Home Device Simulator](https://github.com/plasticrake/tplink-smarthome-simulator) * [Unofficial API documentation](https://github.com/plasticrake/tplink-smarthome-api/blob/master/API.md) +* [MQTT access to TP-Link devices, using python-kasa](https://github.com/flavio-fernandes/mqtt2kasa) ### TP-Link Tapo support From 2fe1b209d09e8c976d680dfaa1384ab4f9e3e086 Mon Sep 17 00:00:00 2001 From: mdarnol <60674442+mdarnol@users.noreply.github.com> Date: Sat, 27 Feb 2021 09:09:33 -0500 Subject: [PATCH 008/892] Add KL125 bulb definition (#143) Co-authored-by: Mark Arnold --- README.md | 1 + kasa/smartbulb.py | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 5abe452f0..9cb1b3c68 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * KL60 * KL110 * KL120 +* KL125 * KL130 ### Light strips diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index be81c1346..351f916b9 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -15,6 +15,7 @@ "LB230": (2500, 9000), "KB130": (2500, 9000), "KL130": (2500, 9000), + "KL125": (2500, 6500), r"KL120\(EU\)": (2700, 6500), r"KL120\(US\)": (2700, 5000), r"KL430\(US\)": (2500, 9000), From 1ee4757fdbb1e6dd5cc4a7b3b21c22b73c7bede1 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 18 Mar 2021 19:22:10 +0100 Subject: [PATCH 009/892] Return None instead of raising an exception on missing, valid emeter keys (#146) Fixes #142 Also, update the pre-commit hooks to their newest versions --- .pre-commit-config.yaml | 14 +++++++------- devtools/parse_pcap.py | 1 + kasa/__init__.py | 1 + kasa/smartdevice.py | 3 ++- kasa/tests/test_emeter.py | 16 ++++++++++++++++ kasa/tests/test_readme_examples.py | 2 +- 6 files changed, 28 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7606de0b1..6c8b8befe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.4.0 + rev: v3.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -10,30 +10,30 @@ repos: - id: check-ast - repo: https://github.com/asottile/pyupgrade - rev: v1.25.2 + rev: v2.10.1 hooks: - id: pyupgrade args: ['--py36-plus'] - repo: https://github.com/python/black - rev: stable + rev: 20.8b1 hooks: - id: black -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 +- repo: https://gitlab.com/pycqa/flake8 + rev: 3.9.0 hooks: - id: flake8 additional_dependencies: [flake8-docstrings] - repo: https://github.com/pre-commit/mirrors-isort - rev: v4.3.21 + rev: v5.7.0 hooks: - id: isort additional_dependencies: [toml] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.740 + rev: v0.812 hooks: - id: mypy # args: [--no-strict-optional, --ignore-missing-imports] diff --git a/devtools/parse_pcap.py b/devtools/parse_pcap.py index f9a55c88d..305fcc57b 100644 --- a/devtools/parse_pcap.py +++ b/devtools/parse_pcap.py @@ -8,6 +8,7 @@ import click import dpkt from dpkt.ethernet import ETH_TYPE_IP, Ethernet + from kasa.protocol import TPLinkSmartHomeProtocol diff --git a/kasa/__init__.py b/kasa/__init__.py index 911a7dc39..51b5291b4 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -12,6 +12,7 @@ to be handled by the user of the library. """ from importlib_metadata import version # type: ignore + from kasa.discover import Discover from kasa.exceptions import SmartDeviceException from kasa.protocol import TPLinkSmartHomeProtocol diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index da5c0f65a..a3722b2c3 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -87,7 +87,8 @@ def __getitem__(self, item): if i.startswith(item): return self.__getitem__(i) / 1000 - raise SmartDeviceException("Unable to find a value for '%s'" % item) + _LOGGER.debug(f"Unable to find value for '{item}'") + return None def requires_update(f): diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 5cdd50677..907f24787 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -115,3 +115,19 @@ async def test_current_consumption(dev): assert x >= 0.0 else: assert await dev.current_consumption() is None + + +async def test_emeterstatus_missing_current(): + """KL125 does not report 'current' for emeter.""" + from kasa import EmeterStatus + + regular = EmeterStatus( + {"err_code": 0, "power_mw": 0, "total_wh": 13, "current_ma": 123} + ) + assert regular["current"] == 0.123 + + with pytest.raises(KeyError): + regular["invalid_key"] + + missing_current = EmeterStatus({"err_code": 0, "power_mw": 0, "total_wh": 13}) + assert missing_current["current"] is None diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index c4d9f693e..27455dd84 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -1,8 +1,8 @@ import sys import pytest - import xdoctest + from kasa.tests.conftest import get_device_for_file From 0471e1a5a8f56d833a3c4b1c9724744a63b82105 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 10 May 2021 02:19:00 +0200 Subject: [PATCH 010/892] Simplify discovery query, refactor dump-devinfo (#147) * Use only system:get_sysinfo for discovery * move dump-devinfo into a separate tool under devtools * Fix linting --- devtools/dump_devinfo.py | 141 +++++++++++++++++++++++++++++++++++++++ kasa/cli.py | 38 +---------- kasa/discover.py | 4 -- 3 files changed, 144 insertions(+), 39 deletions(-) create mode 100644 devtools/dump_devinfo.py diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py new file mode 100644 index 000000000..433846d34 --- /dev/null +++ b/devtools/dump_devinfo.py @@ -0,0 +1,141 @@ +"""This script generates devinfo files for the test suite. + +If you have new, yet unsupported device or a device with no devinfo file under kasa/tests/fixtures, +feel free to run this script and create a PR to add the file to the repository. + +Executing this script will several modules and methods one by one, +and finally execute a query to query all of them at once. +""" +import asyncio +import collections.abc +import json +import logging +import re +from collections import defaultdict, namedtuple +from pprint import pprint + +import click + +from kasa import TPLinkSmartHomeProtocol + +Call = namedtuple("Call", "module method") + + +def scrub(res): + """Remove identifiers from the given dict.""" + keys_to_scrub = [ + "deviceId", + "fwId", + "hwId", + "oemId", + "mac", + "mic_mac", + "latitude_i", + "longitude_i", + "latitude", + "longitude", + ] + + for k, v in res.items(): + if isinstance(v, collections.abc.Mapping): + res[k] = scrub(res.get(k)) + else: + if k in keys_to_scrub: + if k in ["latitude_i", "longitude_i"]: + v = 0 + else: + v = re.sub(r"\w", "0", v) + + res[k] = v + return res + + +def default_to_regular(d): + """Convert nested defaultdicts to regular ones. + + From https://stackoverflow.com/a/26496899 + """ + if isinstance(d, defaultdict): + d = {k: default_to_regular(v) for k, v in d.items()} + return d + + +@click.command() +@click.argument("host") +@click.option("--debug") +def cli(host, debug): + """Generate devinfo file for given device.""" + if debug: + logging.basicConfig(level=logging.DEBUG) + + items = [ + Call(module="system", method="get_sysinfo"), + Call(module="emeter", method="get_realtime"), + Call(module="smartlife.iot.dimmer", method="get_dimmer_parameters"), + Call(module="smartlife.iot.common.emeter", method="get_realtime"), + Call( + module="smartlife.iot.smartbulb.lightingservice", method="get_light_state" + ), + ] + + protocol = TPLinkSmartHomeProtocol() + + successes = [] + + for test_call in items: + try: + click.echo(f"Testing {test_call}..", nl=False) + info = asyncio.run( + protocol.query(host, {test_call.module: {test_call.method: None}}) + ) + resp = info[test_call.module] + except Exception as ex: + click.echo(click.style(f"FAIL {ex}", fg="red")) + else: + if "err_msg" in resp: + click.echo(click.style(f"FAIL {resp['err_msg']}", fg="red")) + else: + click.echo(click.style("OK", fg="green")) + successes.append((test_call, info)) + + final_query = defaultdict(defaultdict) + final = defaultdict(defaultdict) + + for succ, resp in successes: + final_query[succ.module][succ.method] = None + final[succ.module][succ.method] = resp + + final = default_to_regular(final) + + try: + final = asyncio.run(protocol.query(host, final_query)) + except Exception as ex: + click.echo( + click.style( + f"Unable to query all successes at once: {ex}", bold=True, fg="red" + ) + ) + + click.echo("Got %s successes" % len(successes)) + click.echo(click.style("## device info file ##", bold=True)) + + sysinfo = final["system"]["get_sysinfo"] + model = sysinfo["model"] + hw_version = sysinfo["hw_ver"] + sw_version = sysinfo["sw_ver"] + sw_version = sw_version.split(" ", maxsplit=1)[0] + save_to = f"{model}_{hw_version}_{sw_version}.json" + pprint(scrub(final)) + save = click.prompt(f"Do you want to save the above content to {save_to} (y/n)") + if save == "y": + click.echo(f"Saving info to {save_to}") + + with open(save_to, "w") as f: + json.dump(final, f, sort_keys=True, indent=4) + f.write("\n") + else: + click.echo("Not saving.") + + +if __name__ == "__main__": + cli() diff --git a/kasa/cli.py b/kasa/cli.py index 5efd6be6d..0473dc97b 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1,7 +1,5 @@ """python-kasa cli tool.""" -import json import logging -import re from pprint import pformat as pf from typing import cast @@ -131,39 +129,9 @@ async def dump_discover(ctx, scrub): Useful for dumping into a file to be added to the test suite. """ - target = ctx.parent.params["target"] - keys_to_scrub = [ - "deviceId", - "fwId", - "hwId", - "oemId", - "mac", - "latitude_i", - "longitude_i", - "latitude", - "longitude", - ] - devs = await Discover.discover(target=target, return_raw=True) - if scrub: - click.echo("Scrubbing personal data before writing") - for dev in devs.values(): - if scrub: - for key in keys_to_scrub: - if key in dev["system"]["get_sysinfo"]: - val = dev["system"]["get_sysinfo"][key] - if key in ["latitude_i", "longitude_i"]: - val = 0 - else: - val = re.sub(r"\w", "0", val) - dev["system"]["get_sysinfo"][key] = val - - model = dev["system"]["get_sysinfo"]["model"] - hw_version = dev["system"]["get_sysinfo"]["hw_ver"] - save_to = f"{model}_{hw_version}.json" - click.echo(f"Saving info to {save_to}") - with open(save_to, "w") as f: - json.dump(dev, f, sort_keys=True, indent=4) - f.write("\n") + click.echo( + "This is deprecated, use the script inside devtools to generate a devinfo file." + ) @cli.command() diff --git a/kasa/discover.py b/kasa/discover.py index 3e7f70207..2ee2079ba 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -135,10 +135,6 @@ class Discover: DISCOVERY_QUERY = { "system": {"get_sysinfo": None}, - "emeter": {"get_realtime": None}, - "smartlife.iot.dimmer": {"get_dimmer_parameters": None}, - "smartlife.iot.common.emeter": {"get_realtime": None}, - "smartlife.iot.smartbulb.lightingservice": {"get_light_state": None}, } @staticmethod From 28a902c485b66d86f807bd7851446bf6cca841d7 Mon Sep 17 00:00:00 2001 From: Brian Davis Date: Tue, 11 May 2021 09:47:52 -0600 Subject: [PATCH 011/892] Added KL125 and HS200 fixture dumps and updated tests to run on new format (#160) * Added new fixtures * Refactored bulb categories and check for emeter on run * linting changes --- kasa/tests/conftest.py | 6 +- kasa/tests/fixtures/HS200(US)_5.0_1.0.2.json | 31 +++++++ kasa/tests/fixtures/KL125(US)_1.20_1.0.5.json | 88 +++++++++++++++++++ kasa/tests/newfakes.py | 4 +- 4 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 kasa/tests/fixtures/HS200(US)_5.0_1.0.2.json create mode 100644 kasa/tests/fixtures/KL125(US)_1.20_1.0.5.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 69f1f3b72..456fe3ab5 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -25,9 +25,9 @@ LIGHT_STRIPS = {"KL430"} -BULBS = {"KL60", "LB100", "LB120", "LB130", "KL120", "KL130", *LIGHT_STRIPS} -VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL130", "KL430", *LIGHT_STRIPS} -COLOR_BULBS = {"LB130", "KL130", *LIGHT_STRIPS} +VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL125", "KL130", "KL430", *LIGHT_STRIPS} +COLOR_BULBS = {"LB130", "KL125", "KL130", *LIGHT_STRIPS} +BULBS = {"KL60", "LB100", *VARIABLE_TEMP, *COLOR_BULBS, *LIGHT_STRIPS} PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210"} diff --git a/kasa/tests/fixtures/HS200(US)_5.0_1.0.2.json b/kasa/tests/fixtures/HS200(US)_5.0_1.0.2.json new file mode 100644 index 000000000..fc09e6f55 --- /dev/null +++ b/kasa/tests/fixtures/HS200(US)_5.0_1.0.2.json @@ -0,0 +1,31 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "House Fan", + "dev_name": "Smart Wi-Fi Light Switch", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "5.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS200(US)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -50, + "status": "new", + "sw_ver": "1.0.2 Build 200819 Rel.105309", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/KL125(US)_1.20_1.0.5.json b/kasa/tests/fixtures/KL125(US)_1.20_1.0.5.json new file mode 100644 index 000000000..1fca69246 --- /dev/null +++ b/kasa/tests/fixtures/KL125(US)_1.20_1.0.5.json @@ -0,0 +1,88 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 10800, + "total_wh": 40 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "brightness": 100, + "color_temp": 4000, + "err_code": 0, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "kasa-bc01", + "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": "1.20", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "light_state": { + "brightness": 100, + "color_temp": 4000, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "longitude_i": 0, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL125(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 4000, + "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": -59, + "status": "new", + "sw_ver": "1.0.5 Build 200831 Rel.141525" + } + } +} diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 55c3e00cb..73eed0f37 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -265,7 +265,9 @@ def __init__(self, info): # if we have emeter support, we need to add the missing pieces for module in ["emeter", "smartlife.iot.common.emeter"]: for etype in ["get_realtime", "get_daystat", "get_monthstat"]: - if etype in info[module]: # if the fixture has the data, use it + if ( + module in info and etype in info[module] + ): # if the fixture has the data, use it # print("got %s %s from fixture: %s" % (module, etype, info[module][etype])) proto[module][etype] = info[module][etype] else: # otherwise fall back to the static one From c7a47ea1bf547026204f08dfab387777ec55aa72 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 12 May 2021 15:07:53 +0200 Subject: [PATCH 012/892] Simplify mac address handling (#162) --- kasa/smartdevice.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index a3722b2c3..3b21aa010 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -411,16 +411,16 @@ def mac(self) -> str: """ sys_info = self.sys_info - if "mac" in sys_info: - return str(sys_info["mac"]) - elif "mic_mac" in sys_info: - return ":".join( - format(s, "02x") for s in bytes.fromhex(sys_info["mic_mac"]) + mac = sys_info.get("mac", sys_info.get("mic_mac")) + if not mac: + raise SmartDeviceException( + "Unknown mac, please submit a bug report with sys_info output." ) - raise SmartDeviceException( - "Unknown mac, please submit a bug report with sys_info output." - ) + if ":" not in mac: + mac = ":".join(format(s, "02x") for s in bytes.fromhex(mac)) + + return mac async def set_mac(self, mac): """Set the mac address. From 8a3ebbff6d9e1e1c1d864c7cf3986e1d0b0e59d2 Mon Sep 17 00:00:00 2001 From: Appleguru Date: Wed, 12 May 2021 12:04:17 -0400 Subject: [PATCH 013/892] Add HS220 hw 2.0 fixture (#107) * Add HS220 hw 2.0 fixture * Update HS220 v2 profile from dump-discover * fix linting Co-authored-by: Teemu Rytilahti --- kasa/tests/fixtures/HS220(US)_2.0.json | 64 ++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 kasa/tests/fixtures/HS220(US)_2.0.json diff --git a/kasa/tests/fixtures/HS220(US)_2.0.json b/kasa/tests/fixtures/HS220(US)_2.0.json new file mode 100644 index 000000000..d8ca213ef --- /dev/null +++ b/kasa/tests/fixtures/HS220(US)_2.0.json @@ -0,0 +1,64 @@ +{ + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "err_code": 0, + "fadeOffTime": 1000, + "fadeOnTime": 1000, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 0, + "rampRate": 30 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "err_code": -1, + "err_msg": "module not support" + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Living Room Lights", + "brightness": 100, + "dev_name": "Wi-Fi Smart Dimmer", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS220(US)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "preferred_state": [ + { + "brightness": 100, + "index": 0 + }, + { + "brightness": 75, + "index": 1 + }, + { + "brightness": 50, + "index": 2 + }, + { + "brightness": 25, + "index": 3 + } + ], + "relay_state": 0, + "rssi": -45, + "sw_ver": "1.0.3 Build 200326 Rel.082355", + "updating": 0 + } + } +} From 0aa20f6cf94d6e73472c20986beefa6c8e7d8f80 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 16 Jun 2021 17:16:45 +0200 Subject: [PATCH 014/892] Prepare 0.4.0.dev3 (#172) * Prepare 0.4.0.dev3 Most notable changes: * Devices initialized by discovery are pre-initialized using the discovery response data, so no need for update() directly after discovery * Only the basic information is requested during discovery, as some HS110 and HS220 devices do not respond to multi-module queries * Fix mac address parsing for KL430 * Add support for KL125 color temperature ranges * Documentation updates! * add types-click for mypy hook * use generator expression for sum --- .pre-commit-config.yaml | 14 +- CHANGELOG.md | 41 +- kasa/smartstrip.py | 2 +- poetry.lock | 874 ++++++++++++++++++++-------------------- pyproject.toml | 2 +- 5 files changed, 496 insertions(+), 437 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6c8b8befe..39b02f975 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 + rev: v4.0.1 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -10,30 +10,30 @@ repos: - id: check-ast - repo: https://github.com/asottile/pyupgrade - rev: v2.10.1 + rev: v2.19.4 hooks: - id: pyupgrade args: ['--py36-plus'] - repo: https://github.com/python/black - rev: 20.8b1 + rev: 21.6b0 hooks: - id: black - repo: https://gitlab.com/pycqa/flake8 - rev: 3.9.0 + rev: 3.9.2 hooks: - id: flake8 additional_dependencies: [flake8-docstrings] - repo: https://github.com/pre-commit/mirrors-isort - rev: v5.7.0 + rev: v5.8.0 hooks: - id: isort additional_dependencies: [toml] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.812 + rev: v0.902 hooks: - id: mypy -# args: [--no-strict-optional, --ignore-missing-imports] + additional_dependencies: [types-click] diff --git a/CHANGELOG.md b/CHANGELOG.md index 56ddced1e..6f0b089b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## [0.4.0.dev3](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev3) (2021-06-14) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev2...0.4.0.dev3) + +**Fixed bugs:** + +- `Unable to find a value for 'current'` error when attempting to query KL125 bulb emeter [\#142](https://github.com/python-kasa/python-kasa/issues/142) +- `Unknown color temperature range` error when attempting to query KL125 bulb state [\#141](https://github.com/python-kasa/python-kasa/issues/141) +- Simplify discovery query, refactor dump-devinfo [\#147](https://github.com/python-kasa/python-kasa/pull/147) ([rytilahti](https://github.com/rytilahti)) +- Return None instead of raising an exception on missing, valid emeter keys [\#146](https://github.com/python-kasa/python-kasa/pull/146) ([rytilahti](https://github.com/rytilahti)) + +**Closed issues:** + +- After installing, command `kasa` not found [\#165](https://github.com/python-kasa/python-kasa/issues/165) +- KL430 causing "non-hexadecimal number found in fromhex\(\) arg at position 2" error in smartdevice.py [\#159](https://github.com/python-kasa/python-kasa/issues/159) +- Cant get smart strip children to work [\#144](https://github.com/python-kasa/python-kasa/issues/144) +- `kasa --host 192.168.1.67 wifi join ` does not change network [\#139](https://github.com/python-kasa/python-kasa/issues/139) +- Poetry returns error when installing dependencies [\#131](https://github.com/python-kasa/python-kasa/issues/131) +- 'kasa wifi scan' raises RuntimeError [\#127](https://github.com/python-kasa/python-kasa/issues/127) +- Runtime Error when I execute Kasa emeter command [\#124](https://github.com/python-kasa/python-kasa/issues/124) +- Add ability to control individual sockets on KP400 [\#121](https://github.com/python-kasa/python-kasa/issues/121) +- HS105\(US\) HW 5.0/SW 1.0.2 Not Working [\#119](https://github.com/python-kasa/python-kasa/issues/119) +- HS110\(UK\) not discoverable [\#113](https://github.com/python-kasa/python-kasa/issues/113) +- Stopping Kasa SmartDevices from phoning home [\#111](https://github.com/python-kasa/python-kasa/issues/111) +- TP Link Dimmer switch \(HS220\) hardware version 2.0 not being discovered [\#105](https://github.com/python-kasa/python-kasa/issues/105) +- Support for P100 Smart Plug [\#83](https://github.com/python-kasa/python-kasa/issues/83) + +**Merged pull requests:** + +- Simplify mac address handling [\#162](https://github.com/python-kasa/python-kasa/pull/162) ([rytilahti](https://github.com/rytilahti)) +- Added KL125 and HS200 fixture dumps and updated tests to run on new format [\#160](https://github.com/python-kasa/python-kasa/pull/160) ([brianthedavis](https://github.com/brianthedavis)) +- Add KL125 bulb definition [\#143](https://github.com/python-kasa/python-kasa/pull/143) ([mdarnol](https://github.com/mdarnol)) +- README.md: Add link to MQTT interface for python-kasa [\#140](https://github.com/python-kasa/python-kasa/pull/140) ([flavio-fernandes](https://github.com/flavio-fernandes)) +- Fix documentation on Smart strips [\#136](https://github.com/python-kasa/python-kasa/pull/136) ([flavio-fernandes](https://github.com/flavio-fernandes)) +- add tapo link, fix tplink-smarthome-simulator link [\#133](https://github.com/python-kasa/python-kasa/pull/133) ([rytilahti](https://github.com/rytilahti)) +- Leverage data from UDP discovery to initialize device structure [\#132](https://github.com/python-kasa/python-kasa/pull/132) ([dlee1j1](https://github.com/dlee1j1)) +- Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) ([rytilahti](https://github.com/rytilahti)) +- Add HS220 hw 2.0 fixture [\#107](https://github.com/python-kasa/python-kasa/pull/107) ([appleguru](https://github.com/appleguru)) + ## [0.4.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev2) (2020-11-21) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev1...0.4.0.dev2) @@ -28,6 +67,7 @@ **Merged pull requests:** +- Release 0.4.0.dev2 [\#118](https://github.com/python-kasa/python-kasa/pull/118) ([rytilahti](https://github.com/rytilahti)) - Pin dependencies on major versions, add poetry.lock [\#94](https://github.com/python-kasa/python-kasa/pull/94) ([rytilahti](https://github.com/rytilahti)) ## [0.4.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev1) (2020-07-28) @@ -44,7 +84,6 @@ - I don't python... how do I make this executable? [\#88](https://github.com/python-kasa/python-kasa/issues/88) - ImportError: cannot import name 'smartplug' [\#87](https://github.com/python-kasa/python-kasa/issues/87) -- Support for P100 Smart Plug [\#83](https://github.com/python-kasa/python-kasa/issues/83) - not able to pip install the library [\#82](https://github.com/python-kasa/python-kasa/issues/82) - Discover.discover\(\) add selecting network interface \[pull request\] [\#78](https://github.com/python-kasa/python-kasa/issues/78) - LB100 unable to turn on or off the lights [\#68](https://github.com/python-kasa/python-kasa/issues/68) diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 222c73e45..9c8064dd5 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -143,7 +143,7 @@ def state_information(self) -> Dict[str, Any]: async def current_consumption(self) -> float: """Get the current power consumption in watts.""" - consumption = sum([await plug.current_consumption() for plug in self.children]) + consumption = sum(await plug.current_consumption() for plug in self.children) return consumption diff --git a/poetry.lock b/poetry.lock index f27a54d06..534afae8b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,434 +1,416 @@ [[package]] -category = "main" -description = "A configurable sidebar-enabled Sphinx theme" name = "alabaster" +version = "0.7.12" +description = "A configurable sidebar-enabled Sphinx theme" +category = "main" optional = true python-versions = "*" -version = "0.7.12" [[package]] -category = "main" -description = "High level compatibility layer for multiple asynchronous event loop implementations" name = "anyio" +version = "3.1.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "main" optional = false -python-versions = ">=3.5.3" -version = "1.4.0" +python-versions = ">=3.6.2" [package.dependencies] -async-generator = "*" idna = ">=2.8" sniffio = ">=1.1" +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} [package.extras] -curio = ["curio (>=0.9)"] doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] -test = ["coverage (>=4.5)", "hypothesis (>=4.0)", "pytest (>=3.7.2)", "uvloop"] -trio = ["trio (>=0.12)"] +test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] +trio = ["trio (>=0.16)"] [[package]] -category = "dev" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." name = "appdirs" -optional = false -python-versions = "*" version = "1.4.4" - -[[package]] -category = "main" -description = "Async generators and context managers for Python 3.5+" -name = "async-generator" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" optional = false -python-versions = ">=3.5" -version = "1.10" +python-versions = "*" [[package]] -category = "main" -description = "A simple anyio-compatible fork of Click, for powerful command line utilities." name = "asyncclick" +version = "7.1.2.3" +description = "A simple anyio-compatible fork of Click, for powerful command line utilities." +category = "main" optional = false python-versions = ">=3.6" -version = "7.0.9" [package.dependencies] -anyio = "*" +anyio = ">=2" [package.extras] dev = ["coverage", "pytest-runner", "pytest-trio", "pytest (>=3)", "sphinx", "tox"] docs = ["sphinx"] [[package]] -category = "dev" -description = "Atomic file writes." -marker = "sys_platform == \"win32\"" name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.4.0" [[package]] -category = "dev" -description = "Classes Without Boilerplate" name = "attrs" +version = "21.2.0" +description = "Classes Without Boilerplate" +category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "19.3.0" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] -dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] -docs = ["sphinx", "zope.interface"] -tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] [[package]] -category = "main" -description = "Internationalization utilities" name = "babel" +version = "2.9.1" +description = "Internationalization utilities" +category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.8.0" [package.dependencies] pytz = ">=2015.7" [[package]] -category = "main" -description = "Python package for providing Mozilla's CA Bundle." name = "certifi" +version = "2021.5.30" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" optional = false python-versions = "*" -version = "2020.6.20" [[package]] -category = "dev" -description = "Validate configuration and produce human readable error messages." name = "cfgv" +version = "3.3.0" +description = "Validate configuration and produce human readable error messages." +category = "dev" optional = false python-versions = ">=3.6.1" -version = "3.1.0" [[package]] -category = "main" -description = "Universal encoding detector for Python 2 and 3" name = "chardet" +version = "4.0.0" +description = "Universal encoding detector for Python 2 and 3" +category = "main" optional = false -python-versions = "*" -version = "3.0.4" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] -category = "dev" -description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" name = "codecov" +version = "2.1.11" +description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.1.8" [package.dependencies] coverage = "*" requests = ">=2.7.9" [[package]] -category = "main" -description = "Cross-platform colored terminal text." -marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.4.3" [[package]] -category = "dev" -description = "Code coverage measurement for Python" name = "coverage" +version = "5.5" +description = "Code coverage measurement for Python" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "5.2.1" [package.extras] toml = ["toml"] [[package]] -category = "dev" -description = "Distribution utilities" name = "distlib" +version = "0.3.2" +description = "Distribution utilities" +category = "dev" optional = false python-versions = "*" -version = "0.3.1" [[package]] -category = "main" -description = "Docutils -- Python Documentation Utilities" name = "docutils" +version = "0.16" +description = "Docutils -- Python Documentation Utilities" +category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.16" [[package]] -category = "dev" -description = "A platform independent file lock." name = "filelock" +version = "3.0.12" +description = "A platform independent file lock." +category = "dev" optional = false python-versions = "*" -version = "3.0.12" [[package]] -category = "dev" -description = "File identification library for Python" name = "identify" +version = "2.2.10" +description = "File identification library for Python" +category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -version = "1.4.25" +python-versions = ">=3.6.1" [package.extras] -license = ["editdistance"] +license = ["editdistance-s"] [[package]] -category = "main" -description = "Internationalized Domain Names in Applications (IDNA)" name = "idna" +version = "2.10" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.10" [[package]] -category = "main" -description = "Getting image size from png/jpeg/jpeg2000/gif file" name = "imagesize" +version = "1.2.0" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.2.0" [[package]] -category = "main" -description = "Read metadata from Python packages" name = "importlib-metadata" +version = "4.5.0" +description = "Read metadata from Python packages" +category = "main" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "1.7.0" +python-versions = ">=3.6" [package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["sphinx", "rst.linker"] -testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] [[package]] -category = "main" -description = "A very fast and expressive template engine." name = "jinja2" +version = "3.0.1" +description = "A very fast and expressive template engine." +category = "main" optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.11.2" +python-versions = ">=3.6" [package.dependencies] -MarkupSafe = ">=0.23" +MarkupSafe = ">=2.0" [package.extras] -i18n = ["Babel (>=0.8)"] +i18n = ["Babel (>=2.7)"] [[package]] -category = "main" -description = "Markdown and reStructuredText in a single file." name = "m2r" +version = "0.2.1" +description = "Markdown and reStructuredText in a single file." +category = "main" optional = true python-versions = "*" -version = "0.2.1" [package.dependencies] docutils = "*" mistune = "*" [[package]] -category = "main" -description = "Safely add untrusted strings to HTML/XML markup." name = "markupsafe" +version = "2.0.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" optional = true -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" -version = "1.1.1" +python-versions = ">=3.6" [[package]] -category = "main" -description = "The fastest markdown parser in pure Python" name = "mistune" +version = "0.8.4" +description = "The fastest markdown parser in pure Python" +category = "main" optional = true python-versions = "*" -version = "0.8.4" [[package]] -category = "dev" -description = "More routines for operating on iterables, beyond itertools" name = "more-itertools" +version = "8.8.0" +description = "More routines for operating on iterables, beyond itertools" +category = "dev" optional = false python-versions = ">=3.5" -version = "8.4.0" [[package]] -category = "dev" -description = "Node.js virtual environment builder" name = "nodeenv" +version = "1.6.0" +description = "Node.js virtual environment builder" +category = "dev" optional = false python-versions = "*" -version = "1.4.0" [[package]] -category = "main" -description = "Core utilities for Python packages" name = "packaging" +version = "20.9" +description = "Core utilities for Python packages" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "20.4" [package.dependencies] pyparsing = ">=2.0.2" -six = "*" [[package]] -category = "dev" -description = "plugin and hook calling mechanisms for python" name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.13.1" [package.dependencies] -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12" +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} [package.extras] dev = ["pre-commit", "tox"] [[package]] -category = "dev" -description = "A framework for managing and maintaining multi-language pre-commit hooks." name = "pre-commit" +version = "2.13.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" optional = false python-versions = ">=3.6.1" -version = "2.6.0" [package.dependencies] cfgv = ">=2.0.0" identify = ">=1.0.0" +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} nodeenv = ">=0.11.1" pyyaml = ">=5.1" toml = "*" virtualenv = ">=20.0.8" -[package.dependencies.importlib-metadata] -python = "<3.8" -version = "*" - [[package]] -category = "dev" -description = "library with cross-python path, ini-parsing, io, code, log facilities" name = "py" +version = "1.10.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.9.0" [[package]] -category = "main" -description = "Pygments is a syntax highlighting package written in Python." name = "pygments" +version = "2.9.0" +description = "Pygments is a syntax highlighting package written in Python." +category = "main" optional = true python-versions = ">=3.5" -version = "2.6.1" [[package]] -category = "main" -description = "Python parsing module" name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "2.4.7" [[package]] -category = "dev" -description = "pytest: simple powerful testing with Python" name = "pytest" +version = "5.4.3" +description = "pytest: simple powerful testing with Python" +category = "dev" optional = false python-versions = ">=3.5" -version = "5.4.3" [package.dependencies] -atomicwrites = ">=1.0" +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=17.4.0" -colorama = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} more-itertools = ">=4.0.0" packaging = "*" pluggy = ">=0.12,<1.0" py = ">=1.5.0" wcwidth = "*" -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12" - [package.extras] -checkqa-mypy = ["mypy (v0.761)"] +checkqa-mypy = ["mypy (==v0.761)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] -category = "dev" -description = "Pytest support for asyncio." name = "pytest-asyncio" +version = "0.15.1" +description = "Pytest support for asyncio." +category = "dev" optional = false -python-versions = ">= 3.5" -version = "0.14.0" +python-versions = ">= 3.6" [package.dependencies] pytest = ">=5.4.0" [package.extras] -testing = ["async-generator (>=1.3)", "coverage", "hypothesis (>=5.7.1)"] +testing = ["coverage", "hypothesis (>=5.7.1)"] [[package]] -category = "dev" -description = "Formatting PyTest output for Azure Pipelines UI" name = "pytest-azurepipelines" +version = "0.8.0" +description = "Formatting PyTest output for Azure Pipelines UI" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.8.0" [package.dependencies] pytest = ">=3.5.0" [[package]] -category = "dev" -description = "Pytest plugin for measuring coverage." name = "pytest-cov" +version = "2.12.1" +description = "Pytest plugin for measuring coverage." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.10.0" [package.dependencies] -coverage = ">=4.4" +coverage = ">=5.2.1" pytest = ">=4.6" +toml = "*" [package.extras] -testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "pytest-xdist", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] [[package]] -category = "dev" -description = "Thin-wrapper around the mock package for easier use with pytest" name = "pytest-mock" +version = "3.6.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +category = "dev" optional = false -python-versions = ">=3.5" -version = "3.2.0" +python-versions = ">=3.6" [package.dependencies] -pytest = ">=2.7" +pytest = ">=5.0" [package.extras] dev = ["pre-commit", "tox", "pytest-asyncio"] [[package]] -category = "dev" -description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." name = "pytest-sugar" +version = "0.9.4" +description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." +category = "dev" optional = false python-versions = "*" -version = "0.9.4" [package.dependencies] packaging = ">=14.1" @@ -436,82 +418,81 @@ pytest = ">=2.9" termcolor = ">=1.1.0" [[package]] -category = "main" -description = "World timezone definitions, modern and historical" name = "pytz" +version = "2021.1" +description = "World timezone definitions, modern and historical" +category = "main" optional = true python-versions = "*" -version = "2020.1" [[package]] -category = "dev" -description = "YAML parser and emitter for Python" name = "pyyaml" +version = "5.4.1" +description = "YAML parser and emitter for Python" +category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "5.3.1" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] -category = "main" -description = "Python HTTP for Humans." name = "requests" +version = "2.25.1" +description = "Python HTTP for Humans." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.24.0" [package.dependencies] certifi = ">=2017.4.17" -chardet = ">=3.0.2,<4" +chardet = ">=3.0.2,<5" idna = ">=2.5,<3" -urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" +urllib3 = ">=1.21.1,<1.27" [package.extras] security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] [[package]] -category = "main" -description = "Python 2 and 3 compatibility utilities" name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -version = "1.15.0" [[package]] -category = "main" -description = "Sniff out which async library your code is running under" name = "sniffio" +version = "1.2.0" +description = "Sniff out which async library your code is running under" +category = "main" optional = false python-versions = ">=3.5" -version = "1.1.0" [[package]] -category = "main" -description = "This package provides 26 stemmers for 25 languages generated from Snowball algorithms." name = "snowballstemmer" +version = "2.1.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +category = "main" optional = true python-versions = "*" -version = "2.0.0" [[package]] -category = "main" -description = "Python documentation generator" name = "sphinx" +version = "3.5.4" +description = "Python documentation generator" +category = "main" optional = true python-versions = ">=3.5" -version = "3.1.2" [package.dependencies] -Jinja2 = ">=2.3" -Pygments = ">=2.0" alabaster = ">=0.7,<0.8" babel = ">=1.3" -colorama = ">=0.3.5" -docutils = ">=0.12" +colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} +docutils = ">=0.12,<0.17" imagesize = "*" +Jinja2 = ">=2.3" packaging = "*" +Pygments = ">=2.0" requests = ">=2.5.0" -setuptools = "*" snowballstemmer = ">=1.1" sphinxcontrib-applehelp = "*" sphinxcontrib-devhelp = "*" @@ -522,132 +503,134 @@ sphinxcontrib-serializinghtml = "*" [package.extras] docs = ["sphinxcontrib-websupport"] -lint = ["flake8 (>=3.5.0)", "flake8-import-order", "mypy (>=0.780)", "docutils-stubs"] -test = ["pytest", "pytest-cov", "html5lib", "typed-ast", "cython"] +lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.800)", "docutils-stubs"] +test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"] [[package]] -category = "main" -description = "Read the Docs theme for Sphinx" name = "sphinx-rtd-theme" +version = "0.5.2" +description = "Read the Docs theme for Sphinx" +category = "main" optional = true python-versions = "*" -version = "0.5.0" [package.dependencies] +docutils = "<0.17" sphinx = "*" [package.extras] dev = ["transifex-client", "sphinxcontrib-httpdomain", "bump2version"] [[package]] -category = "main" -description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" name = "sphinxcontrib-applehelp" +version = "1.0.2" +description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" +category = "main" optional = true python-versions = ">=3.5" -version = "1.0.2" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] [[package]] -category = "main" -description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." name = "sphinxcontrib-devhelp" +version = "1.0.2" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +category = "main" optional = true python-versions = ">=3.5" -version = "1.0.2" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] [[package]] -category = "main" -description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" name = "sphinxcontrib-htmlhelp" +version = "2.0.0" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +category = "main" optional = true -python-versions = ">=3.5" -version = "1.0.3" +python-versions = ">=3.6" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest", "html5lib"] [[package]] -category = "main" -description = "A sphinx extension which renders display math in HTML via JavaScript" name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +category = "main" optional = true python-versions = ">=3.5" -version = "1.0.1" [package.extras] test = ["pytest", "flake8", "mypy"] [[package]] -category = "main" -description = "Sphinx extension to include program output" name = "sphinxcontrib-programoutput" +version = "0.17" +description = "Sphinx extension to include program output" +category = "main" optional = true python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" -version = "0.16" [package.dependencies] Sphinx = ">=1.7.0" [[package]] -category = "main" -description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." name = "sphinxcontrib-qthelp" +version = "1.0.3" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." +category = "main" optional = true python-versions = ">=3.5" -version = "1.0.3" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] [[package]] -category = "main" -description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." name = "sphinxcontrib-serializinghtml" +version = "1.1.5" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." +category = "main" optional = true python-versions = ">=3.5" -version = "1.1.4" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] [[package]] -category = "dev" -description = "ANSII Color formatting for output in terminal." name = "termcolor" +version = "1.1.0" +description = "ANSII Color formatting for output in terminal." +category = "dev" optional = false python-versions = "*" -version = "1.1.0" [[package]] -category = "dev" -description = "Python Library for Tom's Obvious, Minimal Language" name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" optional = false -python-versions = "*" -version = "0.10.1" +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] -category = "dev" -description = "tox is a generic virtualenv management and test command line tool" name = "tox" +version = "3.23.1" +description = "tox is a generic virtualenv management and test command line tool" +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "3.18.1" [package.dependencies] -colorama = ">=0.4.1" +colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""} filelock = ">=3.0.0" +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} packaging = ">=14" pluggy = ">=0.12.0" py = ">=1.4.17" @@ -655,99 +638,103 @@ six = ">=1.14.0" toml = ">=0.9.4" virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12,<2" - [package.extras] docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] -testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)"] +testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)", "pathlib2 (>=2.3.3)"] [[package]] +name = "typing-extensions" +version = "3.10.0.0" +description = "Backported and Experimental Type Hints for Python 3.5+" category = "main" -description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = "*" + +[[package]] name = "urllib3" +version = "1.26.5" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "1.25.10" [package.extras] brotli = ["brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] -category = "dev" -description = "Virtual Python Environment builder" name = "virtualenv" +version = "20.4.7" +description = "Virtual Python Environment builder" +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -version = "20.0.28" [package.dependencies] appdirs = ">=1.4.3,<2" distlib = ">=0.3.1,<1" filelock = ">=3.0.0,<4" +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} six = ">=1.9.0,<2" -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12,<2" - [package.extras] docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] -testing = ["coverage (>=5)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] [[package]] -category = "dev" -description = "# Voluptuous is a Python data validation library" name = "voluptuous" +version = "0.12.1" +description = "" +category = "dev" optional = false python-versions = "*" -version = "0.11.7" [[package]] -category = "dev" -description = "Measures the displayed width of unicode strings in a terminal" name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" optional = false python-versions = "*" -version = "0.2.5" [[package]] -category = "dev" -description = "A rewrite of the builtin doctest module" name = "xdoctest" +version = "0.15.4" +description = "A rewrite of the builtin doctest module" +category = "dev" optional = false python-versions = "*" -version = "0.13.0" [package.dependencies] six = "*" [package.extras] -all = ["six", "pytest", "pytest-cov", "codecov", "scikit-build", "cmake", "ninja", "pybind11", "pygments", "colorama"] -optional = ["pygments", "colorama"] -tests = ["pytest", "pytest-cov", "codecov", "scikit-build", "cmake", "ninja", "pybind11"] +all = ["six", "pytest", "pytest-cov", "codecov", "scikit-build", "cmake", "ninja", "pybind11", "pygments", "colorama", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] +colors = ["pygments", "colorama"] +jupyter = ["nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] +optional = ["pygments", "colorama", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] +tests = ["pytest", "pytest-cov", "codecov", "scikit-build", "cmake", "ninja", "pybind11", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] [[package]] -category = "main" -description = "Backport of pathlib-compatible object wrapper for zip files" name = "zipp" +version = "3.4.1" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" optional = false python-versions = ">=3.6" -version = "3.1.0" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] -testing = ["jaraco.itertools", "func-timeout"] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [extras] docs = ["sphinx", "sphinx_rtd_theme", "m2r", "sphinxcontrib-programoutput"] [metadata] -content-hash = "c73c14c7f8588e3be3cd04a1b8cdcbcc32f2d042d8e30b58b7084b2b544ddb90" +lock-version = "1.1" python-versions = "^3.7" +content-hash = "c73c14c7f8588e3be3cd04a1b8cdcbcc32f2d042d8e30b58b7084b2b544ddb90" [metadata.files] alabaster = [ @@ -755,92 +742,106 @@ alabaster = [ {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, ] anyio = [ - {file = "anyio-1.4.0-py3-none-any.whl", hash = "sha256:9ee67e8131853f42957e214d4531cee6f2b66dda164a298d9686a768b7161a4f"}, - {file = "anyio-1.4.0.tar.gz", hash = "sha256:95f60964fc4583f3f226f8dc275dfb02aefe7b39b85a999c6d14f4ec5323c1d8"}, + {file = "anyio-3.1.0-py3-none-any.whl", hash = "sha256:5e335cef65fbd1a422bbfbb4722e8e9a9fadbd8c06d5afe9cd614d12023f6e5a"}, + {file = "anyio-3.1.0.tar.gz", hash = "sha256:43e20711a9d003d858d694c12356dc44ab82c03ccc5290313c3392fa349dad0e"}, ] appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, ] -async-generator = [ - {file = "async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b"}, - {file = "async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"}, -] asyncclick = [ - {file = "asyncclick-7.0.9.tar.gz", hash = "sha256:62cebf3eca36d973802e2dd521ca1db11c5bf4544e9795e093d1a53cb688a8c2"}, + {file = "asyncclick-7.1.2.3.tar.gz", hash = "sha256:c26962b9957abe7ae09c058afbfea199dedea1b54343c1cc2ae1a6a291fab333"}, ] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, - {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, + {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, + {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, ] babel = [ - {file = "Babel-2.8.0-py2.py3-none-any.whl", hash = "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"}, - {file = "Babel-2.8.0.tar.gz", hash = "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38"}, + {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, + {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, ] certifi = [ - {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"}, - {file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"}, + {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, + {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, ] cfgv = [ - {file = "cfgv-3.1.0-py2.py3-none-any.whl", hash = "sha256:1ccf53320421aeeb915275a196e23b3b8ae87dea8ac6698b1638001d4a486d53"}, - {file = "cfgv-3.1.0.tar.gz", hash = "sha256:c8e8f552ffcc6194f4e18dd4f68d9aef0c0d58ae7e7be8c82bee3c5e9edfa513"}, + {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, + {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"}, ] chardet = [ - {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, - {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, + {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, + {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, ] codecov = [ - {file = "codecov-2.1.8-py2.py3-none-any.whl", hash = "sha256:65e8a8008e43eb45a9404bf68f8d4a60d36de3827ef2287971c94940128eba1e"}, - {file = "codecov-2.1.8-py3.8.egg", hash = "sha256:fa7985ac6a3886cf68e3420ee1b5eb4ed30c4bdceec0f332d17ab69f545fbc90"}, - {file = "codecov-2.1.8.tar.gz", hash = "sha256:0be9cd6358cc6a3c01a1586134b0fb524dfa65ccbec3a40e9f28d5f976676ba2"}, + {file = "codecov-2.1.11-py2.py3-none-any.whl", hash = "sha256:ba8553a82942ce37d4da92b70ffd6d54cf635fc1793ab0a7dc3fecd6ebfb3df8"}, + {file = "codecov-2.1.11-py3.8.egg", hash = "sha256:e95901d4350e99fc39c8353efa450050d2446c55bac91d90fcfd2354e19a6aef"}, + {file = "codecov-2.1.11.tar.gz", hash = "sha256:6cde272454009d27355f9434f4e49f238c0273b216beda8472a65dc4957f473b"}, ] colorama = [ - {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, - {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] coverage = [ - {file = "coverage-5.2.1-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4"}, - {file = "coverage-5.2.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01"}, - {file = "coverage-5.2.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8"}, - {file = "coverage-5.2.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59"}, - {file = "coverage-5.2.1-cp27-cp27m-win32.whl", hash = "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3"}, - {file = "coverage-5.2.1-cp27-cp27m-win_amd64.whl", hash = "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f"}, - {file = "coverage-5.2.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd"}, - {file = "coverage-5.2.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651"}, - {file = "coverage-5.2.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b"}, - {file = "coverage-5.2.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d"}, - {file = "coverage-5.2.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3"}, - {file = "coverage-5.2.1-cp35-cp35m-win32.whl", hash = "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0"}, - {file = "coverage-5.2.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962"}, - {file = "coverage-5.2.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082"}, - {file = "coverage-5.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716"}, - {file = "coverage-5.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb"}, - {file = "coverage-5.2.1-cp36-cp36m-win32.whl", hash = "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d"}, - {file = "coverage-5.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546"}, - {file = "coverage-5.2.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811"}, - {file = "coverage-5.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258"}, - {file = "coverage-5.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034"}, - {file = "coverage-5.2.1-cp37-cp37m-win32.whl", hash = "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46"}, - {file = "coverage-5.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8"}, - {file = "coverage-5.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0"}, - {file = "coverage-5.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd"}, - {file = "coverage-5.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b"}, - {file = "coverage-5.2.1-cp38-cp38-win32.whl", hash = "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd"}, - {file = "coverage-5.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d"}, - {file = "coverage-5.2.1-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3"}, - {file = "coverage-5.2.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4"}, - {file = "coverage-5.2.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4"}, - {file = "coverage-5.2.1-cp39-cp39-win32.whl", hash = "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89"}, - {file = "coverage-5.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b"}, - {file = "coverage-5.2.1.tar.gz", hash = "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b"}, + {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, + {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, + {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, + {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, + {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, + {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, + {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, + {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, + {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, + {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, + {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, + {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, + {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, + {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, + {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, + {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, + {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, + {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, + {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, + {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, + {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, + {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, + {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, + {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, + {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, + {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, + {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, + {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, + {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, + {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, + {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, + {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, + {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, + {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, + {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, + {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, + {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, + {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, + {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, + {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, + {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, + {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, + {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, + {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, + {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, + {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, + {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, + {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] distlib = [ - {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, - {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, + {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, + {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, ] docutils = [ {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, @@ -851,8 +852,8 @@ filelock = [ {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, ] identify = [ - {file = "identify-1.4.25-py2.py3-none-any.whl", hash = "sha256:ccd88716b890ecbe10920659450a635d2d25de499b9a638525a48b48261d989b"}, - {file = "identify-1.4.25.tar.gz", hash = "sha256:110ed090fec6bce1aabe3c72d9258a9de82207adeaa5a05cd75c635880312f9a"}, + {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"}, + {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"}, ] idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, @@ -863,81 +864,83 @@ imagesize = [ {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, ] importlib-metadata = [ - {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"}, - {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"}, + {file = "importlib_metadata-4.5.0-py3-none-any.whl", hash = "sha256:833b26fb89d5de469b24a390e9df088d4e52e4ba33b01dc5e0e4f41b81a16c00"}, + {file = "importlib_metadata-4.5.0.tar.gz", hash = "sha256:b142cc1dd1342f31ff04bb7d022492b09920cb64fed867cd3ea6f80fe3ebd139"}, ] jinja2 = [ - {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, - {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, + {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, + {file = "Jinja2-3.0.1.tar.gz", hash = "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"}, ] m2r = [ {file = "m2r-0.2.1.tar.gz", hash = "sha256:bf90bad66cda1164b17e5ba4a037806d2443f2a4d5ddc9f6a5554a0322aaed99"}, ] markupsafe = [ - {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, - {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, + {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, ] mistune = [ {file = "mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4"}, {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, ] more-itertools = [ - {file = "more-itertools-8.4.0.tar.gz", hash = "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5"}, - {file = "more_itertools-8.4.0-py3-none-any.whl", hash = "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"}, + {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"}, + {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"}, ] nodeenv = [ - {file = "nodeenv-1.4.0-py2.py3-none-any.whl", hash = "sha256:4b0b77afa3ba9b54f4b6396e60b0c83f59eaeb2d63dc3cc7a70f7f4af96c82bc"}, + {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, + {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, ] packaging = [ - {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, - {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, + {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, + {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] pre-commit = [ - {file = "pre_commit-2.6.0-py2.py3-none-any.whl", hash = "sha256:e8b1315c585052e729ab7e99dcca5698266bedce9067d21dc909c23e3ceed626"}, - {file = "pre_commit-2.6.0.tar.gz", hash = "sha256:1657663fdd63a321a4a739915d7d03baedd555b25054449090f97bb0cb30a915"}, + {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"}, + {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"}, ] py = [ - {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, - {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, + {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, + {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, ] pygments = [ - {file = "Pygments-2.6.1-py3-none-any.whl", hash = "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"}, - {file = "Pygments-2.6.1.tar.gz", hash = "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44"}, + {file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"}, + {file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"}, ] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, @@ -948,64 +951,74 @@ pytest = [ {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, ] pytest-asyncio = [ - {file = "pytest-asyncio-0.14.0.tar.gz", hash = "sha256:9882c0c6b24429449f5f969a5158b528f39bde47dc32e85b9f0403965017e700"}, - {file = "pytest_asyncio-0.14.0-py3-none-any.whl", hash = "sha256:2eae1e34f6c68fc0a9dc12d4bea190483843ff4708d24277c41568d6b6044f1d"}, + {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"}, + {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, ] pytest-azurepipelines = [ {file = "pytest-azurepipelines-0.8.0.tar.gz", hash = "sha256:944ae2c0790b792d123aa7312fe307bc35214dd26531728923ae5085a1d1feab"}, {file = "pytest_azurepipelines-0.8.0-py3-none-any.whl", hash = "sha256:38b841a90e88d1966715966d7ea35619ed710386138a6a0b8fb5954c991ca4f1"}, ] pytest-cov = [ - {file = "pytest-cov-2.10.0.tar.gz", hash = "sha256:1a629dc9f48e53512fcbfda6b07de490c374b0c83c55ff7a1720b3fccff0ac87"}, - {file = "pytest_cov-2.10.0-py2.py3-none-any.whl", hash = "sha256:6e6d18092dce6fad667cd7020deed816f858ad3b49d5b5e2b1cc1c97a4dba65c"}, + {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, + {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, ] pytest-mock = [ - {file = "pytest-mock-3.2.0.tar.gz", hash = "sha256:7122d55505d5ed5a6f3df940ad174b3f606ecae5e9bc379569cdcbd4cd9d2b83"}, - {file = "pytest_mock-3.2.0-py3-none-any.whl", hash = "sha256:5564c7cd2569b603f8451ec77928083054d8896046830ca763ed68f4112d17c7"}, + {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"}, + {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"}, ] pytest-sugar = [ {file = "pytest-sugar-0.9.4.tar.gz", hash = "sha256:b1b2186b0a72aada6859bea2a5764145e3aaa2c1cfbb23c3a19b5f7b697563d3"}, ] pytz = [ - {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, - {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, + {file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"}, + {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"}, ] pyyaml = [ - {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"}, - {file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"}, - {file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"}, - {file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"}, - {file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"}, - {file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"}, - {file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"}, - {file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"}, - {file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"}, - {file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"}, - {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, + {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, + {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, + {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"}, + {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, + {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, + {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, + {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, + {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, + {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, + {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, + {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, + {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, + {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, + {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, + {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, + {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, + {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] requests = [ - {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"}, - {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"}, + {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, + {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, ] six = [ - {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, - {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] sniffio = [ - {file = "sniffio-1.1.0-py3-none-any.whl", hash = "sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5"}, - {file = "sniffio-1.1.0.tar.gz", hash = "sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21"}, + {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"}, + {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, ] snowballstemmer = [ - {file = "snowballstemmer-2.0.0-py2.py3-none-any.whl", hash = "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0"}, - {file = "snowballstemmer-2.0.0.tar.gz", hash = "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"}, + {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, + {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, ] sphinx = [ - {file = "Sphinx-3.1.2-py3-none-any.whl", hash = "sha256:97dbf2e31fc5684bb805104b8ad34434ed70e6c588f6896991b2fdfd2bef8c00"}, - {file = "Sphinx-3.1.2.tar.gz", hash = "sha256:b9daeb9b39aa1ffefc2809b43604109825300300b987a24f45976c001ba1a8fd"}, + {file = "Sphinx-3.5.4-py3-none-any.whl", hash = "sha256:2320d4e994a191f4b4be27da514e46b3d6b420f2ff895d064f52415d342461e8"}, + {file = "Sphinx-3.5.4.tar.gz", hash = "sha256:19010b7b9fa0dc7756a6e105b2aacd3a80f798af3c25c273be64d7beeb482cb1"}, ] sphinx-rtd-theme = [ - {file = "sphinx_rtd_theme-0.5.0-py2.py3-none-any.whl", hash = "sha256:373413d0f82425aaa28fb288009bf0d0964711d347763af2f1b65cafcb028c82"}, - {file = "sphinx_rtd_theme-0.5.0.tar.gz", hash = "sha256:22c795ba2832a169ca301cd0a083f7a434e09c538c70beb42782c073651b707d"}, + {file = "sphinx_rtd_theme-0.5.2-py2.py3-none-any.whl", hash = "sha256:4a05bdbe8b1446d77a01e20a23ebc6777c74f43237035e76be89699308987d6f"}, + {file = "sphinx_rtd_theme-0.5.2.tar.gz", hash = "sha256:32bd3b5d13dc8186d7a42fc816a23d32e83a4827d7d9882948e7b837c232da5a"}, ] sphinxcontrib-applehelp = [ {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, @@ -1016,56 +1029,63 @@ sphinxcontrib-devhelp = [ {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, ] sphinxcontrib-htmlhelp = [ - {file = "sphinxcontrib-htmlhelp-1.0.3.tar.gz", hash = "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"}, - {file = "sphinxcontrib_htmlhelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f"}, + {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, + {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, ] sphinxcontrib-jsmath = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, ] sphinxcontrib-programoutput = [ - {file = "sphinxcontrib-programoutput-0.16.tar.gz", hash = "sha256:0caaa216d0ad8d2cfa90a9a9dba76820e376da6e3152be28d10aedc09f82a3b0"}, - {file = "sphinxcontrib_programoutput-0.16-py2.py3-none-any.whl", hash = "sha256:8009d1326b89cd029ee477ce32b45c58d92b8504d48811461c3117014a8f4b1e"}, + {file = "sphinxcontrib-programoutput-0.17.tar.gz", hash = "sha256:300ee9b8caee8355d25cc74b4d1c7efd12e608d2ad165e3141d31e6fbc152b7f"}, + {file = "sphinxcontrib_programoutput-0.17-py2.py3-none-any.whl", hash = "sha256:0ef1c1d9159dbe7103077748214305eb4e0138e861feb71c0c346afc5fe97f84"}, ] sphinxcontrib-qthelp = [ {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, ] sphinxcontrib-serializinghtml = [ - {file = "sphinxcontrib-serializinghtml-1.1.4.tar.gz", hash = "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc"}, - {file = "sphinxcontrib_serializinghtml-1.1.4-py2.py3-none-any.whl", hash = "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"}, + {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, + {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, ] termcolor = [ {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"}, ] toml = [ - {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, - {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] tox = [ - {file = "tox-3.18.1-py2.py3-none-any.whl", hash = "sha256:3d914480c46232c2d1a035482242535a26d76cc299e4fd28980c858463206f45"}, - {file = "tox-3.18.1.tar.gz", hash = "sha256:5c82e40046a91dbc80b6bd08321b13b4380d8ce3bcb5b62616cb17aaddefbb3a"}, + {file = "tox-3.23.1-py2.py3-none-any.whl", hash = "sha256:b0b5818049a1c1997599d42012a637a33f24c62ab8187223fdd318fa8522637b"}, + {file = "tox-3.23.1.tar.gz", hash = "sha256:307a81ddb82bd463971a273f33e9533a24ed22185f27db8ce3386bff27d324e3"}, +] +typing-extensions = [ + {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, + {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, + {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, ] urllib3 = [ - {file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"}, - {file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"}, + {file = "urllib3-1.26.5-py2.py3-none-any.whl", hash = "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c"}, + {file = "urllib3-1.26.5.tar.gz", hash = "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098"}, ] virtualenv = [ - {file = "virtualenv-20.0.28-py2.py3-none-any.whl", hash = "sha256:8f582a030156282a9ee9d319984b759a232b07f86048c1d6a9e394afa44e78c8"}, - {file = "virtualenv-20.0.28.tar.gz", hash = "sha256:688a61d7976d82b92f7906c367e83bb4b3f0af96f8f75bfcd3da95608fe8ac6c"}, + {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"}, + {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"}, ] voluptuous = [ - {file = "voluptuous-0.11.7.tar.gz", hash = "sha256:2abc341dbc740c5e2302c7f9b8e2e243194fb4772585b991931cb5b22e9bf456"}, + {file = "voluptuous-0.12.1-py3-none-any.whl", hash = "sha256:8ace33fcf9e6b1f59406bfaf6b8ec7bcc44266a9f29080b4deb4fe6ff2492386"}, + {file = "voluptuous-0.12.1.tar.gz", hash = "sha256:663572419281ddfaf4b4197fd4942d181630120fb39b333e3adad70aeb56444b"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, ] xdoctest = [ - {file = "xdoctest-0.13.0-py2.py3-none-any.whl", hash = "sha256:de861fd5230a46bd26c054b4981169dd963f813768cb62b62e104e4d2644ac94"}, - {file = "xdoctest-0.13.0.tar.gz", hash = "sha256:4f113a430076561a9d7f31af65b5d5acda62ee06b05cb6894264cb9efb8196ac"}, + {file = "xdoctest-0.15.4-py2.py3-none-any.whl", hash = "sha256:4b91eb67e45e51a254ff9370adb72a0c82b08289844c95cfd3a1186d7ec4f694"}, + {file = "xdoctest-0.15.4-py3-none-any.whl", hash = "sha256:33d4a12cf70da245ca3f71be9ef03e0615fa862826bf6a08e8f025ce693e496d"}, + {file = "xdoctest-0.15.4.tar.gz", hash = "sha256:ef1f93d2147988d3cb6f35c026ec32aca971923f86945a775f61e2f8de8505d1"}, ] zipp = [ - {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, - {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, + {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"}, + {file = "zipp-3.4.1.tar.gz", hash = "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76"}, ] diff --git a/pyproject.toml b/pyproject.toml index 2411bed40..f35605993 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.4.0.dev2" +version = "0.4.0.dev3" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["Your Name "] From 799032a8dfd00de7c9c08e07c149d5966ad63a34 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 19 Jun 2021 21:46:36 +0200 Subject: [PATCH 015/892] dump_devinfo: handle latitude/longitude keys properly (#175) * also, -d/--debug is now a flag --- devtools/dump_devinfo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 433846d34..9d30f9674 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -41,7 +41,7 @@ def scrub(res): res[k] = scrub(res.get(k)) else: if k in keys_to_scrub: - if k in ["latitude_i", "longitude_i"]: + if k in ["latitude", "latitude_i", "longitude", "longitude_i"]: v = 0 else: v = re.sub(r"\w", "0", v) @@ -62,7 +62,7 @@ def default_to_regular(d): @click.command() @click.argument("host") -@click.option("--debug") +@click.option("-d", "--debug", is_flag=True) def cli(host, debug): """Generate devinfo file for given device.""" if debug: From 6184c0c72e897da52538733c819f294f08e8df77 Mon Sep 17 00:00:00 2001 From: Nathan Hyde Date: Thu, 24 Jun 2021 10:43:54 -0700 Subject: [PATCH 016/892] Add EP10(US) 1.0 1.0.2 fixture (#174) * Add EP10(US) 1.0 1.0.2 fixture * Add EP10 fixture to conftest PLUGS list. * Add EP10 to the list of supported plugs in README * Revert "Add EP10 to the list of supported plugs in README" This reverts commit e8bf6551c39e879a771944d5b18e3b2fd1f68675. --- kasa/tests/conftest.py | 2 +- kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json | 32 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100755 kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 456fe3ab5..ba1179cd4 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -30,7 +30,7 @@ BULBS = {"KL60", "LB100", *VARIABLE_TEMP, *COLOR_BULBS, *LIGHT_STRIPS} -PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210"} +PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210", "EP10"} STRIPS = {"HS107", "HS300", "KP303", "KP400"} DIMMERS = {"HS220"} diff --git a/kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json b/kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json new file mode 100755 index 000000000..e40543d6b --- /dev/null +++ b/kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json @@ -0,0 +1,32 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "167 lamp", + "dev_name": "Smart Wi-Fi Plug Mini", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "EP10(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -64, + "status": "new", + "sw_ver": "1.0.2 Build 200915 Rel.085940", + "updating": 0 + } + } +} From 4e8a3185fb5bff9879c1c5133263f8f0fb3c0dfb Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 16 Aug 2021 16:49:28 +0200 Subject: [PATCH 017/892] Use less strict matcher for kl430 color temperature (#190) --- kasa/smartbulb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index 351f916b9..caaec89e2 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -18,7 +18,7 @@ "KL125": (2500, 6500), r"KL120\(EU\)": (2700, 6500), r"KL120\(US\)": (2700, 5000), - r"KL430\(US\)": (2500, 9000), + r"KL430": (2500, 9000), } From 7c9d21af7a596bc8542e356e182a2ac320d61226 Mon Sep 17 00:00:00 2001 From: Ivan Prodanov Date: Mon, 16 Aug 2021 21:16:29 +0300 Subject: [PATCH 018/892] Add real kasa KL430(UN) device dump (#192) * Add real kasa KL430(UN) device dump * Adjust hue&sat max values * light strips, as bulbs, have only power for emeter Co-authored-by: Teemu Rytilahti --- kasa/tests/fixtures/KL430(UN)_2.0_1.0.8.json | 90 ++++++++++++++++++++ kasa/tests/newfakes.py | 4 +- kasa/tests/test_emeter.py | 2 +- 3 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 kasa/tests/fixtures/KL430(UN)_2.0_1.0.8.json diff --git a/kasa/tests/fixtures/KL430(UN)_2.0_1.0.8.json b/kasa/tests/fixtures/KL430(UN)_2.0_1.0.8.json new file mode 100644 index 000000000..a956575be --- /dev/null +++ b/kasa/tests/fixtures/KL430(UN)_2.0_1.0.8.json @@ -0,0 +1,90 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 16760, + "total_wh": 120 + } + }, + "system": { + "get_sysinfo": { + "LEF": 1, + "active_mode": "none", + "alias": "Bedroom light strip", + "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": { + "brightness": 100, + "color_temp": 9000, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "lighting_effect_state": { + "brightness": 100, + "custom": 1, + "enable": 1, + "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", + "name": "Aurora 1" + }, + "longitude_i": 0, + "mic_mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL430(UN)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 9000, + "hue": 0, + "index": 0, + "mode": 1, + "saturation": 0 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 217, + "index": 1, + "mode": 1, + "saturation": 99 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 194, + "index": 2, + "mode": 1, + "saturation": 50 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 277, + "index": 3, + "mode": 1, + "saturation": 86 + } + ], + "rssi": -43, + "status": "new", + "sw_ver": "1.0.8 Build 210121 Rel.084339" + } + } +} diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 73eed0f37..1e0536210 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -150,9 +150,9 @@ def lb_dev_state(x): { "brightness": All(int, Range(min=0, max=100)), "color_temp": int, - "hue": All(int, Range(min=0, max=255)), + "hue": All(int, Range(min=0, max=360)), "index": int, - "saturation": All(int, Range(min=0, max=255)), + "saturation": All(int, Range(min=0, max=100)), } ], } diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 907f24787..75afe7dd7 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -89,7 +89,7 @@ async def test_emeter_status(dev): assert d["power_mw"] == d["power"] * 1000 # bulbs have only power according to tplink simulator. - if not dev.is_bulb: + if not dev.is_bulb and not dev.is_light_strip: assert d["voltage_mv"] == d["voltage"] * 1000 assert d["current_ma"] == d["current"] * 1000 From f8e7258b9326e0b4fba646d1cda3b58d8c85df36 Mon Sep 17 00:00:00 2001 From: JaydenRA <75145665+JaydenRA@users.noreply.github.com> Date: Sat, 4 Sep 2021 01:18:21 +0100 Subject: [PATCH 019/892] cli: add human-friendly printout when calling temperature on non-supported devices (#196) * Bug Fix Temperature is not supported on plugs * Efficiency on support errors * Update kasa/cli.py Co-authored-by: Teemu R. Co-authored-by: Teemu R. --- kasa/cli.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/kasa/cli.py b/kasa/cli.py index 0473dc97b..f9f17bceb 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -345,6 +345,9 @@ async def brightness(dev: SmartBulb, brightness: int, transition: int): async def temperature(dev: SmartBulb, temperature: int, transition: int): """Get or set color temperature.""" await dev.update() + if not dev.is_variable_color_temp: + click.echo("Device does not support color temperature") + return if temperature is None: click.echo(f"Color temperature: {dev.color_temp}") valid_temperature_range = dev.valid_temperature_range From b0885962054066ff5670b3b4694a58ece1ccb998 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 19 Sep 2021 23:43:17 +0200 Subject: [PATCH 020/892] Perform initial update only using the sysinfo query (#199) Some devices are known to fail when trying to query non-supported modules like emeter information. This commit makes the initial update() only request the sysinfo, followed up by a second query if emeter is supported by the device. --- kasa/smartdevice.py | 25 ++++++++++++++++++++----- kasa/tests/conftest.py | 2 +- kasa/tests/test_smartdevice.py | 21 +++++++++++++++++++-- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 3b21aa010..cfca4c44f 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -291,18 +291,33 @@ async def get_sys_info(self) -> Dict[str, Any]: return await self._query_helper("system", "get_sysinfo") async def update(self): - """Update some of the attributes. + """Query the device to update the data. - Needed for methods that are decorated with `requires_update`. + Needed for properties that are decorated with `requires_update`. """ req = {} req.update(self._create_request("system", "get_sysinfo")) - # Check for emeter if we were never updated, or if the device has emeter - if self._last_update is None or self.has_emeter: + # If this is the initial update, check only for the sysinfo + # This is necessary as some devices crash on unexpected modules + # See #105, #120, #161 + if self._last_update is None: + _LOGGER.debug("Performing the initial update to obtain sysinfo") + self._last_update = await self.protocol.query(self.host, req) + self._sys_info = self._last_update["system"]["get_sysinfo"] + # If the device has no emeter, we are done for the initial update + # Otherwise we will follow the regular code path to also query + # the emeter data also during the initial update + if not self.has_emeter: + return + + if self.has_emeter: + _LOGGER.debug( + "The device has emeter, querying its information along sysinfo" + ) req.update(self._create_emeter_request()) + self._last_update = await self.protocol.query(self.host, req) - # TODO: keep accessible for tests self._sys_info = self._last_update["system"]["get_sysinfo"] def update_from_discover_info(self, info): diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index ba1179cd4..c3cb683fd 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -35,7 +35,7 @@ DIMMERS = {"HS220"} DIMMABLE = {*BULBS, *DIMMERS} -WITH_EMETER = {"HS110", "HS300", *BULBS, *STRIPS} +WITH_EMETER = {"HS110", "HS300", *BULBS} ALL_DEVICES = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 67081b034..622a21267 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -5,7 +5,7 @@ from kasa import SmartDeviceException -from .conftest import handle_turn_on, pytestmark, turn_on +from .conftest import handle_turn_on, has_emeter, no_emeter, pytestmark, turn_on from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol @@ -17,7 +17,24 @@ async def test_invalid_connection(dev): with patch.object(FakeTransportProtocol, "query", side_effect=SmartDeviceException): with pytest.raises(SmartDeviceException): await dev.update() - dev.is_on + + +@has_emeter +async def test_initial_update_emeter(dev, mocker): + """Test that the initial update performs second query if emeter is available.""" + dev._last_update = None + spy = mocker.spy(dev.protocol, "query") + await dev.update() + assert spy.call_count == 2 + + +@no_emeter +async def test_initial_update_no_emeter(dev, mocker): + """Test that the initial update performs second query if emeter is available.""" + dev._last_update = None + spy = mocker.spy(dev.protocol, "query") + await dev.update() + assert spy.call_count == 1 async def test_query_helper(dev): From 1803a83ae628f7db3b2c76582dd26cd6f3c029d4 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 19 Sep 2021 23:45:48 +0200 Subject: [PATCH 021/892] Improve testing harness to allow tests on real devices (#197) * test_cli: provide return values to patched objects to avoid warning about non-awaited calls * test_cli: restore alias after testing * smartstrip: remove internal update() calls for turn_{on,off}, set_led * Make sure power is always a float * Fix discovery tests * Make tests runnable on real devices * Add a note about running tests on a real device * test_strip: run update against the parent device --- README.md | 9 +++++++++ kasa/cli.py | 1 + kasa/smartdevice.py | 5 +++-- kasa/smartstrip.py | 3 --- kasa/tests/conftest.py | 16 ++++++++++++---- kasa/tests/newfakes.py | 2 +- kasa/tests/test_bulb.py | 4 ++++ kasa/tests/test_cli.py | 8 +++++++- kasa/tests/test_discovery.py | 10 +++++----- kasa/tests/test_emeter.py | 2 ++ kasa/tests/test_plug.py | 2 ++ kasa/tests/test_smartdevice.py | 7 +++++++ kasa/tests/test_strip.py | 20 ++++++++++++-------- pyproject.toml | 5 +++++ 14 files changed, 70 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 9cb1b3c68..f8aa82507 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,15 @@ This will make sure that the checks are passing when you do a commit. You can also execute the checks by running either `tox -e lint` to only do the linting checks, or `tox` to also execute the tests. +### Running tests + +You can run tests on the library by executing `pytest` in the source directory. +This will run the tests against contributed example responses, but you can also execute the tests against a real device: +``` +pytest --ip
+``` +Note that this will perform state changes on the device. + ### Analyzing network captures The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. diff --git a/kasa/cli.py b/kasa/cli.py index f9f17bceb..626eadc2b 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -247,6 +247,7 @@ async def alias(dev, new_alias, index): if new_alias is not None: click.echo(f"Setting alias to {new_alias}") click.echo(await dev.set_alias(new_alias)) + await dev.update() click.echo(f"Alias: {dev.alias}") if dev.is_strip: diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index cfca4c44f..0a722c5da 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -603,7 +603,7 @@ async def current_consumption(self) -> float: raise SmartDeviceException("Device has no emeter") response = EmeterStatus(await self.get_emeter_realtime()) - return response["power"] + return float(response["power"]) async def reboot(self, delay: int = 1) -> None: """Reboot the device. @@ -658,7 +658,8 @@ def state_information(self) -> Dict[str, Any]: def device_id(self) -> str: """Return unique ID for the device. - This is the MAC address of the device. + If not overridden, this is the MAC address of the device. + Individual sockets on strips will override this. """ return self.mac diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 9c8064dd5..a052c0d0e 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -100,12 +100,10 @@ async def update(self): async def turn_on(self, **kwargs): """Turn the strip on.""" await self._query_helper("system", "set_relay_state", {"state": 1}) - await self.update() async def turn_off(self, **kwargs): """Turn the strip off.""" await self._query_helper("system", "set_relay_state", {"state": 0}) - await self.update() @property # type: ignore @requires_update @@ -126,7 +124,6 @@ def led(self) -> bool: async def set_led(self, state: bool): """Set the state of the led (night mode).""" await self._query_helper("system", "set_led_off", {"off": int(not state)}) - await self.update() @property # type: ignore @requires_update diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index c3cb683fd..857aa373c 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -147,7 +147,7 @@ def get_device_for_file(file): with open(p) as f: sysinfo = json.load(f) model = basename(file) - p = device_for_file(model)(host="123.123.123.123") + p = device_for_file(model)(host="127.0.0.123") p.protocol = FakeTransportProtocol(sysinfo) asyncio.run(p.update()) return p @@ -168,21 +168,29 @@ def dev(request): asyncio.run(d.update()) if d.model in file: return d - raise Exception("Unable to find type for %s" % ip) + else: + pytest.skip(f"skipping file {file}") return get_device_for_file(file) def pytest_addoption(parser): - parser.addoption("--ip", action="store", default=None, help="run against device") + parser.addoption( + "--ip", action="store", default=None, help="run against device on given ip" + ) def pytest_collection_modifyitems(config, items): if not config.getoption("--ip"): print("Testing against fixtures.") - return else: print("Running against ip %s" % config.getoption("--ip")) + requires_dummy = pytest.mark.skip( + reason="test requires to be run against dummy data" + ) + for item in items: + if "requires_dummy" in item.keywords: + item.add_marker(requires_dummy) # allow mocks to be awaited diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 1e0536210..02c8cdded 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -124,7 +124,7 @@ def lb_dev_state(x): "dft_on_state": Optional( { "brightness": All(int, Range(min=0, max=100)), - "color_temp": All(int, Range(min=2000, max=9000)), + "color_temp": All(int, Range(min=0, max=9000)), "hue": All(int, Range(min=0, max=255)), "mode": str, "saturation": All(int, Range(min=0, max=255)), diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 7d6e45e02..c7beb1abb 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -66,6 +66,7 @@ async def test_hsv(dev, turn_on): await dev.set_hsv(hue=1, saturation=1, value=1) + await dev.update() hue, saturation, brightness = dev.hsv assert hue == 1 assert saturation == 1 @@ -134,6 +135,7 @@ async def test_variable_temp_state_information(dev): async def test_try_set_colortemp(dev, turn_on): await handle_turn_on(dev, turn_on) await dev.set_color_temp(2700) + await dev.update() assert dev.color_temp == 2700 @@ -179,9 +181,11 @@ async def test_dimmable_brightness(dev, turn_on): assert dev.is_dimmable await dev.set_brightness(50) + await dev.update() assert dev.brightness == 50 await dev.set_brightness(10) + await dev.update() assert dev.brightness == 10 with pytest.raises(ValueError): diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 1b94d4897..864adb21d 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -18,7 +18,7 @@ async def test_state(dev, turn_on): await handle_turn_on(dev, turn_on) runner = CliRunner() res = await runner.invoke(state, obj=dev) - print(res.output) + await dev.update() if dev.is_on: assert "Device state: ON" in res.output @@ -32,6 +32,8 @@ async def test_alias(dev): res = await runner.invoke(alias, obj=dev) assert f"Alias: {dev.alias}" in res.output + old_alias = dev.alias + new_alias = "new alias" res = await runner.invoke(alias, [new_alias], obj=dev) assert f"Setting alias to {new_alias}" in res.output @@ -39,6 +41,8 @@ async def test_alias(dev): res = await runner.invoke(alias, obj=dev) assert f"Alias: {new_alias}" in res.output + await dev.set_alias(old_alias) + async def test_raw_command(dev): runner = CliRunner() @@ -63,11 +67,13 @@ async def test_emeter(dev: SmartDevice, mocker): assert "== Emeter ==" in res.output monthly = mocker.patch.object(dev, "get_emeter_monthly") + monthly.return_value = [] res = await runner.invoke(emeter, ["--year", "1900"], obj=dev) assert "For year" in res.output monthly.assert_called() daily = mocker.patch.object(dev, "get_emeter_daily") + daily.return_value = [] res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev) assert "For month" in res.output daily.assert_called() diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 529ad8d63..1356892e9 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -8,14 +8,14 @@ @plug async def test_type_detection_plug(dev: SmartDevice): - d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") + d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_plug assert d.device_type == DeviceType.Plug @bulb async def test_type_detection_bulb(dev: SmartDevice): - d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") + d = Discover._get_device_class(dev._last_update)("localhost") # TODO: light_strip is a special case for now to force bulb tests on it if not d.is_light_strip: assert d.is_bulb @@ -24,21 +24,21 @@ async def test_type_detection_bulb(dev: SmartDevice): @strip async def test_type_detection_strip(dev: SmartDevice): - d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") + d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_strip assert d.device_type == DeviceType.Strip @dimmer async def test_type_detection_dimmer(dev: SmartDevice): - d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") + d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_dimmer assert d.device_type == DeviceType.Dimmer @lightstrip async def test_type_detection_lightstrip(dev: SmartDevice): - d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") + d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_light_strip assert d.device_type == DeviceType.LightStrip diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 75afe7dd7..388a42d40 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -32,6 +32,7 @@ async def test_get_emeter_realtime(dev): @has_emeter +@pytest.mark.requires_dummy async def test_get_emeter_daily(dev): if dev.is_strip: pytest.skip("Disabled for strips temporarily") @@ -54,6 +55,7 @@ async def test_get_emeter_daily(dev): @has_emeter +@pytest.mark.requires_dummy async def test_get_emeter_monthly(dev): if dev.is_strip: pytest.skip("Disabled for strips temporarily") diff --git a/kasa/tests/test_plug.py b/kasa/tests/test_plug.py index a301095e6..3de3a1461 100644 --- a/kasa/tests/test_plug.py +++ b/kasa/tests/test_plug.py @@ -20,9 +20,11 @@ async def test_led(dev): original = dev.led await dev.set_led(False) + await dev.update() assert not dev.led await dev.set_led(True) + await dev.update() assert dev.led await dev.set_led(original) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 622a21267..002adb901 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -13,6 +13,7 @@ async def test_state_info(dev): assert isinstance(dev.state_information, dict) +@pytest.mark.requires_dummy async def test_invalid_connection(dev): with patch.object(FakeTransportProtocol, "query", side_effect=SmartDeviceException): with pytest.raises(SmartDeviceException): @@ -49,18 +50,22 @@ async def test_state(dev, turn_on): orig_state = dev.is_on if orig_state: await dev.turn_off() + await dev.update() assert not dev.is_on assert dev.is_off await dev.turn_on() + await dev.update() assert dev.is_on assert not dev.is_off else: await dev.turn_on() + await dev.update() assert dev.is_on assert not dev.is_off await dev.turn_off() + await dev.update() assert not dev.is_on assert dev.is_off @@ -71,9 +76,11 @@ async def test_alias(dev): assert isinstance(original, str) await dev.set_alias(test_alias) + await dev.update() assert dev.alias == test_alias await dev.set_alias(original) + await dev.update() assert dev.alias == original diff --git a/kasa/tests/test_strip.py b/kasa/tests/test_strip.py index 861b56ed3..21a11e372 100644 --- a/kasa/tests/test_strip.py +++ b/kasa/tests/test_strip.py @@ -15,20 +15,24 @@ async def test_children_change_state(dev, turn_on): orig_state = plug.is_on if orig_state: await plug.turn_off() - assert not plug.is_on - assert plug.is_off + await dev.update() + assert plug.is_on is False + assert plug.is_off is True await plug.turn_on() - assert plug.is_on - assert not plug.is_off + await dev.update() + assert plug.is_on is True + assert plug.is_off is False else: await plug.turn_on() - assert plug.is_on - assert not plug.is_off + await dev.update() + assert plug.is_on is True + assert plug.is_off is False await plug.turn_off() - assert not plug.is_on - assert plug.is_off + await dev.update() + assert plug.is_on is False + assert plug.is_off is True @strip diff --git a/pyproject.toml b/pyproject.toml index f35605993..c87dbd964 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,11 @@ fail-under = 100 exclude = ['kasa/tests/*'] verbose = 2 +[tool.pytest.ini_options] +markers = [ + "requires_dummy: test requires dummy data to pass, skipped on real devices", +] + [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" From 016d030245c10596a47bab5312feb2ec6c9551f2 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 19 Sep 2021 23:53:17 +0200 Subject: [PATCH 022/892] Improve bulb support (alias, time settings) (#198) * Fix set_alias and time related functions for bulbs * Fix tests for smartlife.iot.common.timesetting and smartlife.iot.common.system --- kasa/smartbulb.py | 10 ++++++++++ kasa/smartdevice.py | 6 ++++-- kasa/tests/conftest.py | 2 +- kasa/tests/newfakes.py | 41 +++++++++++++++++++++++++---------------- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index caaec89e2..3625cf567 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -91,6 +91,7 @@ class SmartBulb(SmartDevice): """ LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice" + TIME_SERVICE = "smartlife.iot.common.timesetting" SET_LIGHT_METHOD = "transition_light_state" def __init__(self, host: str) -> None: @@ -359,3 +360,12 @@ async def turn_on(self, *, transition: int = None, **kwargs) -> Dict: def has_emeter(self) -> bool: """Return that the bulb has an emeter.""" return True + + async def set_alias(self, alias: str) -> None: + """Set the device name (alias). + + Overridden to use a different module name. + """ + return await self._query_helper( + "smartlife.iot.common.system", "set_dev_alias", {"alias": alias} + ) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 0a722c5da..44eff056c 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -214,6 +214,8 @@ class SmartDevice: """ + TIME_SERVICE = "time" + def __init__(self, host: str) -> None: """Create a new SmartDevice instance. @@ -352,7 +354,7 @@ async def set_alias(self, alias: str) -> None: async def get_time(self) -> Optional[datetime]: """Return current time from the device, if available.""" try: - res = await self._query_helper("time", "get_time") + res = await self._query_helper(self.TIME_SERVICE, "get_time") return datetime( res["year"], res["month"], @@ -366,7 +368,7 @@ async def get_time(self) -> Optional[datetime]: async def get_timezone(self) -> Dict: """Return timezone information.""" - return await self._query_helper("time", "get_timezone") + return await self._query_helper(self.TIME_SERVICE, "get_timezone") @property # type: ignore @requires_update diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 857aa373c..5b35c75c0 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -153,7 +153,7 @@ def get_device_for_file(file): return p -@pytest.fixture(params=SUPPORTED_DEVICES) +@pytest.fixture(params=SUPPORTED_DEVICES, scope="session") def dev(request): """Device fixture. diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 02c8cdded..a37bb4147 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -252,6 +252,27 @@ def success(res): return res +# plugs and bulbs use a different module for time information, +# so we define the contents here to avoid repeating ourselves +TIME_MODULE = { + "get_time": { + "year": 2017, + "month": 1, + "mday": 2, + "hour": 3, + "min": 4, + "sec": 5, + }, + "get_timezone": { + "zone_str": "test", + "dst_offset": -1, + "index": 12, + "tz_str": "test2", + }, + "set_timezone": None, +} + + class FakeTransportProtocol(TPLinkSmartHomeProtocol): def __init__(self, info): self.discovery_data = info @@ -393,23 +414,11 @@ def light_state(self, x, *args): "set_light_state": transition_light_state, "get_light_state": light_state, }, - "time": { - "get_time": { - "year": 2017, - "month": 1, - "mday": 2, - "hour": 3, - "min": 4, - "sec": 5, - }, - "get_timezone": { - "zone_str": "test", - "dst_offset": -1, - "index": 12, - "tz_str": "test2", - }, - "set_timezone": None, + "smartlife.iot.common.system": { + "set_dev_alias": set_alias, }, + "time": TIME_MODULE, + "smartlife.iot.common.timesetting": TIME_MODULE, # HS220 brightness, different setter and getter "smartlife.iot.dimmer": { "set_brightness": set_hs220_brightness, From 7565d03c8e08221e5148e53f08939abad6270474 Mon Sep 17 00:00:00 2001 From: Leandro Reox Date: Sun, 19 Sep 2021 19:01:06 -0300 Subject: [PATCH 023/892] Add a note about using the discovery target parameter (#168) * Update discover.py Updated discovery documentation for multiple interfaces explanation * revise Co-authored-by: Teemu R --- kasa/discover.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kasa/discover.py b/kasa/discover.py index 2ee2079ba..b12b79264 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -152,13 +152,14 @@ async def discover( Sends discovery message to 255.255.255.255:9999 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, you can use target parameter to specify the network for discovery. If given, `on_discovered` coroutine will get passed with the :class:`SmartDevice`-derived object as parameter. The results of the discovery are returned either as a list of :class:`SmartDevice`-derived objects or as raw response dictionaries objects (if `return_raw` is True). - :param target: The target broadcast address (e.g. 192.168.xxx.255). + :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_packets: Number of discovery packets are broadcasted. From 2c83d8ee6d6cfd6defd9140e1167e93d477846f0 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 21 Sep 2021 13:23:56 +0200 Subject: [PATCH 024/892] bulb: allow set_hsv without v, add fallback ct range (#200) * bulb: allow set_hsv without v, add fallback ct range * add ColorTempRange and HSV named tuples * add a fallback color temp range if unknown, log a warning * set_hsv: the value is now optional * Fix tests, change fallback range to 2700-5000 --- kasa/smartbulb.py | 66 +++++++++++++++++++++++++++-------------- kasa/tests/test_bulb.py | 9 +++--- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index 3625cf567..74d5c1cf9 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -1,6 +1,7 @@ """Module for bulbs (LB*, KL*, KB*).""" +import logging import re -from typing import Any, Dict, Tuple, cast +from typing import Any, Dict, NamedTuple, cast from kasa.smartdevice import ( DeviceType, @@ -9,18 +10,36 @@ requires_update, ) + +class ColorTempRange(NamedTuple): + """Color temperature range.""" + + min: int + max: int + + +class HSV(NamedTuple): + """Hue-saturation-value.""" + + hue: int + saturation: int + value: int + + TPLINK_KELVIN = { - "LB130": (2500, 9000), - "LB120": (2700, 6500), - "LB230": (2500, 9000), - "KB130": (2500, 9000), - "KL130": (2500, 9000), - "KL125": (2500, 6500), - r"KL120\(EU\)": (2700, 6500), - r"KL120\(US\)": (2700, 5000), - r"KL430": (2500, 9000), + "LB130": ColorTempRange(2500, 9000), + "LB120": ColorTempRange(2700, 6500), + "LB230": ColorTempRange(2500, 9000), + "KB130": ColorTempRange(2500, 9000), + "KL130": ColorTempRange(2500, 9000), + "KL125": ColorTempRange(2500, 6500), + r"KL120\(EU\)": ColorTempRange(2700, 6500), + r"KL120\(US\)": ColorTempRange(2700, 5000), + r"KL430": ColorTempRange(2500, 9000), } +_LOGGER = logging.getLogger(__name__) + class SmartBulb(SmartDevice): """Representation of a TP-Link Smart Bulb. @@ -69,7 +88,7 @@ class SmartBulb(SmartDevice): Bulbs supporting color temperature can be queried to know which range is accepted: >>> bulb.valid_temperature_range - (2500, 9000) + ColorTempRange(min=2500, max=9000) >>> asyncio.run(bulb.set_color_temp(3000)) >>> asyncio.run(bulb.update()) >>> bulb.color_temp @@ -80,7 +99,7 @@ class SmartBulb(SmartDevice): >>> asyncio.run(bulb.set_hsv(180, 100, 80)) >>> asyncio.run(bulb.update()) >>> bulb.hsv - (180, 100, 80) + HSV(hue=180, saturation=100, value=80) If you don't want to use the default transitions, you can pass `transition` in milliseconds. This applies to all transitions (turn_on, turn_off, set_hsv, set_color_temp, set_brightness). @@ -122,21 +141,21 @@ def is_variable_color_temp(self) -> bool: @property # type: ignore @requires_update - def valid_temperature_range(self) -> Tuple[int, int]: + def valid_temperature_range(self) -> ColorTempRange: """Return the device-specific white temperature range (in Kelvin). :return: White temperature range in Kelvin (minimum, maximum) """ if not self.is_variable_color_temp: raise SmartDeviceException("Color temperature not supported") + for model, temp_range in TPLINK_KELVIN.items(): sys_info = self.sys_info if re.match(model, sys_info["model"]): return temp_range - raise SmartDeviceException( - "Unknown color temperature range, please open an issue on github" - ) + _LOGGER.warning("Unknown color temperature range, fallback to 2700-5000") + return ColorTempRange(2700, 5000) @property # type: ignore @requires_update @@ -200,7 +219,7 @@ async def set_light_state(self, state: Dict, *, transition: int = None) -> Dict: @property # type: ignore @requires_update - def hsv(self) -> Tuple[int, int, int]: + def hsv(self) -> HSV: """Return the current HSV state of the bulb. :return: hue, saturation and value (degrees, %, %) @@ -214,7 +233,7 @@ def hsv(self) -> Tuple[int, int, int]: saturation = light_state["saturation"] value = light_state["brightness"] - return hue, saturation, value + return HSV(hue, saturation, value) def _raise_for_invalid_brightness(self, value): if not isinstance(value, int) or not (0 <= value <= 100): @@ -224,7 +243,7 @@ def _raise_for_invalid_brightness(self, value): @requires_update async def set_hsv( - self, hue: int, saturation: int, value: int, *, transition: int = None + self, hue: int, saturation: int, value: int = None, *, transition: int = None ) -> Dict: """Set new HSV. @@ -247,15 +266,16 @@ async def set_hsv( "(valid range: 0-100%)".format(saturation) ) - self._raise_for_invalid_brightness(value) - light_state = { "hue": hue, "saturation": saturation, - "brightness": value, "color_temp": 0, } + if value is not None: + self._raise_for_invalid_brightness(value) + light_state["brightness"] = value + return await self.set_light_state(light_state, transition=transition) @property # type: ignore @@ -284,7 +304,7 @@ async def set_color_temp( if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: raise ValueError( "Temperature should be between {} " - "and {}".format(*valid_temperature_range) + "and {}, was {}".format(*valid_temperature_range, temp) ) light_state = {"color_temp": temp} diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index c7beb1abb..28fcd4cb7 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -148,10 +148,11 @@ async def test_set_color_temp_transition(dev, mocker): @variable_temp -async def test_unknown_temp_range(dev, monkeypatch): - with pytest.raises(SmartDeviceException): - monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") - dev.valid_temperature_range() +async def test_unknown_temp_range(dev, monkeypatch, caplog): + monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") + + assert dev.valid_temperature_range == (2700, 5000) + assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text @variable_temp From 151976bb043a07085f5fc20e297312721192882c Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 21 Sep 2021 13:25:14 +0200 Subject: [PATCH 025/892] Add own device type for smartstrip children (#201) --- kasa/smartdevice.py | 18 ++++++++++++------ kasa/smartstrip.py | 1 + 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 44eff056c..fa2273581 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -16,7 +16,7 @@ import logging from dataclasses import dataclass from datetime import datetime, timedelta -from enum import Enum +from enum import Enum, auto from typing import Any, Dict, List, Optional from .exceptions import SmartDeviceException @@ -28,11 +28,12 @@ class DeviceType(Enum): """Device type enum.""" - Plug = 1 - Bulb = 2 - Strip = 3 - Dimmer = 4 - LightStrip = 5 + Plug = auto() + Bulb = auto() + Strip = auto() + StripSocket = auto() + Dimmer = auto() + LightStrip = auto() Unknown = -1 @@ -743,6 +744,11 @@ def is_strip(self) -> bool: """Return True if the device is a strip.""" return self._device_type == DeviceType.Strip + @property + def is_strip_socket(self) -> bool: + """Return True if the device is a strip socket.""" + return self._device_type == DeviceType.StripSocket + @property def is_dimmer(self) -> bool: """Return True if the device is a dimmer.""" diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index a052c0d0e..a5351c5b5 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -211,6 +211,7 @@ def __init__(self, host: str, parent: "SmartStrip", child_id: str) -> None: self.child_id = child_id self._last_update = parent._last_update self._sys_info = parent._sys_info + self._device_type = DeviceType.StripSocket async def update(self): """Override the update to no-op and inform the user.""" From 47a1405bd27faf569eab33ca3dc1733789073378 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 21 Sep 2021 19:20:59 +0200 Subject: [PATCH 026/892] Add KP115 fixture (#202) --- README.md | 1 + kasa/tests/conftest.py | 4 +- kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json | 42 +++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json diff --git a/README.md b/README.md index f8aa82507..1d84eca06 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * HS105 * HS107 * HS110 +* KP115 ### Power Strips diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 5b35c75c0..46cb86216 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -30,12 +30,12 @@ BULBS = {"KL60", "LB100", *VARIABLE_TEMP, *COLOR_BULBS, *LIGHT_STRIPS} -PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210", "EP10"} +PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210", "EP10", "KP115"} STRIPS = {"HS107", "HS300", "KP303", "KP400"} DIMMERS = {"HS220"} DIMMABLE = {*BULBS, *DIMMERS} -WITH_EMETER = {"HS110", "HS300", *BULBS} +WITH_EMETER = {"HS110", "HS300", "KP115", *BULBS} ALL_DEVICES = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS) diff --git a/kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json b/kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json new file mode 100644 index 000000000..790597dc0 --- /dev/null +++ b/kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json @@ -0,0 +1,42 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 0, + "total_wh": 0, + "voltage_mv": 231561 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "TP-LINK_Smart Plug_330B", + "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": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP115(EU)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 197, + "relay_state": 1, + "rssi": -70, + "status": "new", + "sw_ver": "1.0.16 Build 210205 Rel.163735", + "updating": 0 + } + } +} From 36c412a9c22442da7244d86127cd5f1cede3b433 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 23 Sep 2021 17:58:19 +0200 Subject: [PATCH 027/892] Improve emeterstatus API, move into own module (#205) Adds the following properties to EmeterStatus for saner API: * voltage (in V) * power (in W) * current (in A) * total (in kWh) --- kasa/__init__.py | 3 +- kasa/emeterstatus.py | 82 +++++++++++++++++++++++++++++++++++++++ kasa/smartbulb.py | 7 +--- kasa/smartdevice.py | 45 +-------------------- kasa/tests/test_emeter.py | 4 +- 5 files changed, 88 insertions(+), 53 deletions(-) create mode 100644 kasa/emeterstatus.py diff --git a/kasa/__init__.py b/kasa/__init__.py index 51b5291b4..fc798fb37 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -14,10 +14,11 @@ from importlib_metadata import version # type: ignore from kasa.discover import Discover +from kasa.emeterstatus import EmeterStatus from kasa.exceptions import SmartDeviceException from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb -from kasa.smartdevice import DeviceType, EmeterStatus, SmartDevice +from kasa.smartdevice import DeviceType, SmartDevice from kasa.smartdimmer import SmartDimmer from kasa.smartlightstrip import SmartLightStrip from kasa.smartplug import SmartPlug diff --git a/kasa/emeterstatus.py b/kasa/emeterstatus.py new file mode 100644 index 000000000..da636551d --- /dev/null +++ b/kasa/emeterstatus.py @@ -0,0 +1,82 @@ +"""Module for emeter container.""" +import logging +from typing import Optional + +_LOGGER = logging.getLogger(__name__) + + +class EmeterStatus(dict): + """Container for converting different representations of emeter data. + + Newer FW/HW versions postfix the variable names with the used units, + where-as the olders do not have this feature. + + This class automatically converts between these two to allow + backwards and forwards compatibility. + """ + + @property + def voltage(self) -> Optional[float]: + """Return voltage in V.""" + try: + return self["voltage"] + except ValueError: + return None + + @property + def power(self) -> Optional[float]: + """Return power in W.""" + try: + return self["power"] + except ValueError: + return None + + @property + def current(self) -> Optional[float]: + """Return current in A.""" + try: + return self["current"] + except ValueError: + return None + + @property + def total(self) -> Optional[float]: + """Return total in kWh.""" + try: + return self["total"] + except ValueError: + return None + + def __repr__(self): + return f"" + + def __getitem__(self, item): + valid_keys = [ + "voltage_mv", + "power_mw", + "current_ma", + "energy_wh", + "total_wh", + "voltage", + "power", + "current", + "total", + "energy", + ] + + # 1. if requested data is available, return it + if item in super().keys(): + return super().__getitem__(item) + # otherwise decide how to convert it + else: + if item not in valid_keys: + raise KeyError(item) + if "_" in item: # upscale + return super().__getitem__(item[: item.find("_")]) * 1000 + else: # downscale + for i in super().keys(): + if i.startswith(item): + return self.__getitem__(i) / 1000 + + _LOGGER.debug(f"Unable to find value for '{item}'") + return None diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index 74d5c1cf9..aad2ce8ce 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -3,12 +3,7 @@ import re from typing import Any, Dict, NamedTuple, cast -from kasa.smartdevice import ( - DeviceType, - SmartDevice, - SmartDeviceException, - requires_update, -) +from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update class ColorTempRange(NamedTuple): diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index fa2273581..1efc0773e 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -19,6 +19,7 @@ from enum import Enum, auto from typing import Any, Dict, List, Optional +from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException from .protocol import TPLinkSmartHomeProtocol @@ -50,48 +51,6 @@ class WifiNetwork: rssi: Optional[int] = None -class EmeterStatus(dict): - """Container for converting different representations of emeter data. - - Newer FW/HW versions postfix the variable names with the used units, - where-as the olders do not have this feature. - - This class automatically converts between these two to allow - backwards and forwards compatibility. - """ - - def __getitem__(self, item): - valid_keys = [ - "voltage_mv", - "power_mw", - "current_ma", - "energy_wh", - "total_wh", - "voltage", - "power", - "current", - "total", - "energy", - ] - - # 1. if requested data is available, return it - if item in super().keys(): - return super().__getitem__(item) - # otherwise decide how to convert it - else: - if item not in valid_keys: - raise KeyError(item) - if "_" in item: # upscale - return super().__getitem__(item[: item.find("_")]) * 1000 - else: # downscale - for i in super().keys(): - if i.startswith(item): - return self.__getitem__(i) / 1000 - - _LOGGER.debug(f"Unable to find value for '{item}'") - return None - - def requires_update(f): """Indicate that `update` should be called before accessing this method.""" # noqa: D202 if inspect.iscoroutinefunction(f): @@ -202,7 +161,7 @@ class SmartDevice: >>> dev.has_emeter True >>> dev.emeter_realtime - {'current': 0.015342, 'err_code': 0, 'power': 0.983971, 'total': 32.448, 'voltage': 235.595234} + >>> dev.emeter_today >>> dev.emeter_this_month diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 388a42d40..7f0f95aca 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -1,6 +1,6 @@ import pytest -from kasa import SmartDeviceException +from kasa import EmeterStatus, SmartDeviceException from .conftest import has_emeter, no_emeter, pytestmark from .newfakes import CURRENT_CONSUMPTION_SCHEMA @@ -121,8 +121,6 @@ async def test_current_consumption(dev): async def test_emeterstatus_missing_current(): """KL125 does not report 'current' for emeter.""" - from kasa import EmeterStatus - regular = EmeterStatus( {"err_code": 0, "power_mw": 0, "total_wh": 13, "current_ma": 123} ) From b3c8f9769c2a4c62b6b5b4d24646a571eaa0ab6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Sep 2021 11:11:16 -0500 Subject: [PATCH 028/892] Avoid temp array during encrypt and decrypt (#204) * Avoid temp array during encrypt * black * Update kasa/protocol.py Co-authored-by: Teemu R. * Update kasa/protocol.py * update decrypt as well Co-authored-by: Teemu R. --- kasa/protocol.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/kasa/protocol.py b/kasa/protocol.py index 6ee6f72d6..bbf13b995 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -89,6 +89,13 @@ async def query(host: str, request: Union[str, Dict], retry_count: int = 3) -> D # make mypy happy, this should never be reached.. raise SmartDeviceException("Query reached somehow to unreachable") + @staticmethod + def _xor_payload(unencrypted): + key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR + for unencryptedbyte in unencrypted: + key = key ^ unencryptedbyte + yield key + @staticmethod def encrypt(request: str) -> bytes: """Encrypt a request for a TP-Link Smart Home Device. @@ -96,17 +103,18 @@ def encrypt(request: str) -> bytes: :param request: plaintext request data :return: ciphertext to be send over wire, in bytes """ - key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR - plainbytes = request.encode() - buffer = bytearray(struct.pack(">I", len(plainbytes))) + return struct.pack(">I", len(plainbytes)) + bytes( + TPLinkSmartHomeProtocol._xor_payload(plainbytes) + ) - for plainbyte in plainbytes: - cipherbyte = key ^ plainbyte + @staticmethod + def _xor_encrypted_payload(ciphertext): + key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR + for cipherbyte in ciphertext: + plainbyte = key ^ cipherbyte key = cipherbyte - buffer.append(cipherbyte) - - return bytes(buffer) + yield plainbyte @staticmethod def decrypt(ciphertext: bytes) -> str: @@ -115,14 +123,6 @@ def decrypt(ciphertext: bytes) -> str: :param ciphertext: encrypted response data :return: plaintext response """ - key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR - buffer = [] - - for cipherbyte in ciphertext: - plainbyte = key ^ cipherbyte - key = cipherbyte - buffer.append(plainbyte) - - plaintext = bytes(buffer) - - return plaintext.decode() + return bytes( + TPLinkSmartHomeProtocol._xor_encrypted_payload(ciphertext) + ).decode() From 41bed35e01e09481d193a5a397681a57a30241e3 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 23 Sep 2021 18:25:41 +0200 Subject: [PATCH 029/892] Use github actions instead of azure pipelines (#206) * Use github actions instead of azure pipelines * add codecov badge --- .github/workflows/ci.yml | 84 ++++++++++++++++++++++++++++ README.md | 4 +- azure-pipelines.yml | 118 --------------------------------------- 3 files changed, 86 insertions(+), 120 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 azure-pipelines.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..0452fc340 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: ["master"] + pull_request: + branches: ["master"] + workflow_dispatch: # to allow manual re-runs + + +jobs: + linting: + name: "Perform linting checks" + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.9"] + + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v2" + with: + python-version: "${{ matrix.python-version }}" + - name: "Install dependencies" + run: | + python -m pip install --upgrade pip poetry + - name: "Code formating (black)" + run: | + poetry run pre-commit run black --all-files + - name: "Code formating (flake8)" + run: | + poetry run pre-commit run flake8 --all-files + - name: "Order of imports (isort)" + run: | + poetry run pre-commit run isort --all-files + - name: "Typing checks (mypy)" + run: | + poetry run pre-commit run mypy --all-files + - name: "Run trailing-whitespace" + run: | + poetry run pre-commit run trailing-whitespace --all-files + - name: "Run end-of-file-fixer" + run: | + poetry run pre-commit run end-of-file-fixer --all-files + - name: "Run check-docstring-first" + run: | + poetry run pre-commit run check-docstring-first --all-files + - name: "Run debug-statements" + run: | + poetry run pre-commit run debug-statements --all-files + - name: "Run check-ast" + run: | + poetry run pre-commit run check-ast --all-files + - name: "Potential security issues (bandit)" + run: | + poetry run pre-commit run bandit --all-files + + + tests: + name: "Python ${{ matrix.python-version}} on ${{ matrix.os }}" + needs: linting + runs-on: ${{ matrix.os }} + + strategy: + matrix: + python-version: ["3.6", "3.7", "3.8", "3.9", "pypy3"] + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v2" + with: + python-version: "${{ matrix.python-version }}" + - name: "Install dependencies" + run: | + python -m pip install --upgrade pip poetry + - name: "Run tests" + run: | + poetry run pytest --cov kasa --cov-report xml + - name: "Upload coverage to Codecov" + uses: "codecov/codecov-action@v1" + with: + fail_ci_if_error: true diff --git a/README.md b/README.md index 1d84eca06..e1136d1f5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # python-kasa [![PyPI version](https://badge.fury.io/py/python-kasa.svg)](https://badge.fury.io/py/python-kasa) -[![Build Status](https://dev.azure.com/python-kasa/python-kasa/_apis/build/status/python-kasa.python-kasa?branchName=master)](https://dev.azure.com/python-kasa/python-kasa/_build/latest?definitionId=2&branchName=master) -[![Coverage Status](https://coveralls.io/repos/github/python-kasa/python-kasa/badge.svg?branch=master)](https://coveralls.io/github/python-kasa/python-kasa?branch=master) +[![Build Status](https://github.com/python-kasa/python-kasa/actions/workflows/ci.yml/badge.svg)](https://github.com/python-kasa/python-kasa/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/python-kasa/python-kasa/branch/master/graph/badge.svg?token=5K7rtN5OmS)](https://codecov.io/gh/python-kasa/python-kasa) [![Documentation Status](https://readthedocs.org/projects/python-kasa/badge/?version=latest)](https://python-kasa.readthedocs.io/en/latest/?badge=latest) python-kasa is a Python library to control TPLink smart home devices (plugs, wall switches, power strips, and bulbs) using asyncio. diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index 2b201ae08..000000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,118 +0,0 @@ -trigger: -- master -pr: -- master - - -stages: -- stage: "Linting" - jobs: - - job: "LintChecks" - pool: - vmImage: "ubuntu-latest" - strategy: - matrix: - Python 3.8: - python.version: '3.8' - steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: '$(python.version)' - displayName: 'Use Python $(python.version)' - - - script: | - python -m pip install --upgrade pip poetry - poetry install - displayName: 'Install dependencies' - - - script: | - poetry run pre-commit run black --all-files - displayName: 'Code formating (black)' - - - script: | - poetry run pre-commit run flake8 --all-files - displayName: 'Code formating (flake8)' - - - script: | - poetry run pre-commit run mypy --all-files - displayName: 'Typing checks (mypy)' - - - script: | - poetry run pre-commit run isort --all-files - displayName: 'Order of imports (isort)' - - - script: | - poetry run pre-commit run trailing-whitespace --all-files - displayName: 'Run trailing-whitespace' - - - script: | - poetry run pre-commit run end-of-file-fixer --all-files - displayName: 'Run end-of-file-fixer' - - - script: | - poetry run pre-commit run check-docstring-first --all-files - displayName: 'Run check-docstring-first' - - - script: | - poetry run pre-commit run check-yaml --all-files - displayName: 'Run check-yaml' - - - script: | - poetry run pre-commit run debug-statements --all-files - displayName: 'Run debug-statements' - - - script: | - poetry run pre-commit run check-ast --all-files - displayName: 'Run check-ast' - - -- stage: "Tests" - jobs: - - job: "Tests" - strategy: - matrix: - Python 3.7 Ubuntu: - python.version: '3.7' - vmImage: 'ubuntu-latest' - - Python 3.8 Ubuntu: - python.version: '3.8' - vmImage: 'ubuntu-latest' - - Python 3.7 Windows: - python.version: '3.7' - vmImage: 'windows-latest' - - Python 3.8 Windows: - python.version: '3.8' - vmImage: 'windows-latest' - - Python 3.7 OSX: - python.version: '3.7' - vmImage: 'macOS-latest' - - Python 3.8 OSX: - python.version: '3.8' - vmImage: 'macOS-latest' - - pool: - vmImage: $(vmImage) - - steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: '$(python.version)' - displayName: 'Use Python $(python.version)' - - - script: | - python -m pip install --upgrade pip poetry - poetry install - displayName: 'Install dependencies' - - - script: | - poetry run pytest --cov kasa --cov-report=xml --cov-report=html - displayName: 'Run tests' - - - script: | - poetry run codecov -t $(codecov.token) - displayName: 'Report code coverage' From 202d107d39e80cc63d4c11a045af0814badc0507 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 23 Sep 2021 18:29:45 +0200 Subject: [PATCH 030/892] Fix CI dep installation (#207) --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0452fc340..493d25de6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,7 @@ jobs: - name: "Install dependencies" run: | python -m pip install --upgrade pip poetry + poetry install - name: "Code formating (black)" run: | poetry run pre-commit run black --all-files From d7202883e9d2b9d49708b64e8ddd3f06dbd3aed0 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 23 Sep 2021 19:09:19 +0200 Subject: [PATCH 031/892] More CI fixes (#208) * Remove bandit from CI, update poetry.lock&pre-commit-config.yaml * We don't support python 3.6 * poetry install also on tests flow * remove pytest-azurepipelines --- .github/workflows/ci.yml | 7 +- .pre-commit-config.yaml | 8 +- poetry.lock | 228 ++++++++++++++++++++------------------- pyproject.toml | 1 - 4 files changed, 126 insertions(+), 118 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 493d25de6..9e68bc1ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,10 +53,6 @@ jobs: - name: "Run check-ast" run: | poetry run pre-commit run check-ast --all-files - - name: "Potential security issues (bandit)" - run: | - poetry run pre-commit run bandit --all-files - tests: name: "Python ${{ matrix.python-version}} on ${{ matrix.os }}" @@ -65,7 +61,7 @@ jobs: strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "pypy3"] + python-version: ["3.7", "3.8", "3.9", "pypy3"] os: [ubuntu-latest, macos-latest, windows-latest] steps: @@ -76,6 +72,7 @@ jobs: - name: "Install dependencies" run: | python -m pip install --upgrade pip poetry + poetry install - name: "Run tests" run: | poetry run pytest --cov kasa --cov-report xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 39b02f975..a0e1ced0b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,13 +10,13 @@ repos: - id: check-ast - repo: https://github.com/asottile/pyupgrade - rev: v2.19.4 + rev: v2.27.0 hooks: - id: pyupgrade args: ['--py36-plus'] - repo: https://github.com/python/black - rev: 21.6b0 + rev: 21.9b0 hooks: - id: black @@ -27,13 +27,13 @@ repos: additional_dependencies: [flake8-docstrings] - repo: https://github.com/pre-commit/mirrors-isort - rev: v5.8.0 + rev: v5.9.3 hooks: - id: isort additional_dependencies: [toml] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.902 + rev: v0.910 hooks: - id: mypy additional_dependencies: [types-click] diff --git a/poetry.lock b/poetry.lock index 534afae8b..63bbc2c22 100644 --- a/poetry.lock +++ b/poetry.lock @@ -8,7 +8,7 @@ python-versions = "*" [[package]] name = "anyio" -version = "3.1.0" +version = "3.3.1" description = "High level compatibility layer for multiple asynchronous event loop implementations" category = "main" optional = false @@ -24,14 +24,6 @@ doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] trio = ["trio (>=0.16)"] -[[package]] -name = "appdirs" -version = "1.4.4" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "asyncclick" version = "7.1.2.3" @@ -80,6 +72,21 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] pytz = ">=2015.7" +[[package]] +name = "backports.entry-points-selectable" +version = "1.1.0" +description = "Compatibility shim providing selectable entry points for older implementations" +category = "dev" +optional = false +python-versions = ">=2.7" + +[package.dependencies] +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] + [[package]] name = "certifi" version = "2021.5.30" @@ -90,23 +97,26 @@ python-versions = "*" [[package]] name = "cfgv" -version = "3.3.0" +version = "3.3.1" description = "Validate configuration and produce human readable error messages." category = "dev" optional = false python-versions = ">=3.6.1" [[package]] -name = "chardet" -version = "4.0.0" -description = "Universal encoding detector for Python 2 and 3" +name = "charset-normalizer" +version = "2.0.6" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] [[package]] name = "codecov" -version = "2.1.11" +version = "2.1.12" description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" category = "dev" optional = false @@ -137,7 +147,7 @@ toml = ["toml"] [[package]] name = "distlib" -version = "0.3.2" +version = "0.3.3" description = "Distribution utilities" category = "dev" optional = false @@ -161,7 +171,7 @@ python-versions = "*" [[package]] name = "identify" -version = "2.2.10" +version = "2.2.15" description = "File identification library for Python" category = "dev" optional = false @@ -172,11 +182,11 @@ license = ["editdistance-s"] [[package]] name = "idna" -version = "2.10" +version = "3.2" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.5" [[package]] name = "imagesize" @@ -188,7 +198,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.5.0" +version = "4.8.1" description = "Read metadata from Python packages" category = "main" optional = false @@ -200,7 +210,8 @@ zipp = ">=0.5" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +perf = ["ipython"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] [[package]] name = "jinja2" @@ -246,7 +257,7 @@ python-versions = "*" [[package]] name = "more-itertools" -version = "8.8.0" +version = "8.10.0" description = "More routines for operating on iterables, beyond itertools" category = "dev" optional = false @@ -262,15 +273,27 @@ python-versions = "*" [[package]] name = "packaging" -version = "20.9" +version = "21.0" description = "Core utilities for Python packages" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.dependencies] pyparsing = ">=2.0.2" +[[package]] +name = "platformdirs" +version = "2.3.0" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] + [[package]] name = "pluggy" version = "0.13.1" @@ -287,7 +310,7 @@ dev = ["pre-commit", "tox"] [[package]] name = "pre-commit" -version = "2.13.0" +version = "2.15.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -312,7 +335,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pygments" -version = "2.9.0" +version = "2.10.0" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = true @@ -363,17 +386,6 @@ pytest = ">=5.4.0" [package.extras] testing = ["coverage", "hypothesis (>=5.7.1)"] -[[package]] -name = "pytest-azurepipelines" -version = "0.8.0" -description = "Formatting PyTest output for Azure Pipelines UI" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[package.dependencies] -pytest = ">=3.5.0" - [[package]] name = "pytest-cov" version = "2.12.1" @@ -435,21 +447,21 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] name = "requests" -version = "2.25.1" +version = "2.26.0" description = "Python HTTP for Humans." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [package.dependencies] certifi = ">=2017.4.17" -chardet = ">=3.0.2,<5" -idna = ">=2.5,<3" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} urllib3 = ">=1.21.1,<1.27" [package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] [[package]] name = "six" @@ -621,7 +633,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "tox" -version = "3.23.1" +version = "3.24.4" description = "tox is a generic virtualenv management and test command line tool" category = "dev" optional = false @@ -644,7 +656,7 @@ testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "psutil (>=5.6.1)", "pytes [[package]] name = "typing-extensions" -version = "3.10.0.0" +version = "3.10.0.2" description = "Backported and Experimental Type Hints for Python 3.5+" category = "main" optional = false @@ -652,7 +664,7 @@ python-versions = "*" [[package]] name = "urllib3" -version = "1.26.5" +version = "1.26.7" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false @@ -665,26 +677,27 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.4.7" +version = "20.8.0" description = "Virtual Python Environment builder" category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] -appdirs = ">=1.4.3,<2" +"backports.entry-points-selectable" = ">=1.0.4" distlib = ">=0.3.1,<1" filelock = ">=3.0.0,<4" importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +platformdirs = ">=2,<3" six = ">=1.9.0,<2" [package.extras] docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] [[package]] name = "voluptuous" -version = "0.12.1" +version = "0.12.2" description = "" category = "dev" optional = false @@ -700,7 +713,7 @@ python-versions = "*" [[package]] name = "xdoctest" -version = "0.15.4" +version = "0.15.8" description = "A rewrite of the builtin doctest module" category = "dev" optional = false @@ -710,15 +723,15 @@ python-versions = "*" six = "*" [package.extras] -all = ["six", "pytest", "pytest-cov", "codecov", "scikit-build", "cmake", "ninja", "pybind11", "pygments", "colorama", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] +all = ["six", "codecov", "scikit-build", "cmake", "ninja", "pybind11", "pygments", "colorama", "pytest", "pytest", "pytest-cov", "pytest", "pytest", "pytest-cov", "typing", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel", "pytest", "pytest-cov"] colors = ["pygments", "colorama"] jupyter = ["nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] optional = ["pygments", "colorama", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] -tests = ["pytest", "pytest-cov", "codecov", "scikit-build", "cmake", "ninja", "pybind11", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] +tests = ["codecov", "scikit-build", "cmake", "ninja", "pybind11", "pytest", "pytest", "pytest-cov", "pytest", "pytest", "pytest-cov", "typing", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel", "pytest", "pytest-cov"] [[package]] name = "zipp" -version = "3.4.1" +version = "3.5.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false @@ -726,7 +739,7 @@ python-versions = ">=3.6" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [extras] docs = ["sphinx", "sphinx_rtd_theme", "m2r", "sphinxcontrib-programoutput"] @@ -734,7 +747,7 @@ docs = ["sphinx", "sphinx_rtd_theme", "m2r", "sphinxcontrib-programoutput"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "c73c14c7f8588e3be3cd04a1b8cdcbcc32f2d042d8e30b58b7084b2b544ddb90" +content-hash = "e388fa366e9423e60697bfa77c37151094ca0367eb3ed441d61bb8cc7f055675" [metadata.files] alabaster = [ @@ -742,12 +755,8 @@ alabaster = [ {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, ] anyio = [ - {file = "anyio-3.1.0-py3-none-any.whl", hash = "sha256:5e335cef65fbd1a422bbfbb4722e8e9a9fadbd8c06d5afe9cd614d12023f6e5a"}, - {file = "anyio-3.1.0.tar.gz", hash = "sha256:43e20711a9d003d858d694c12356dc44ab82c03ccc5290313c3392fa349dad0e"}, -] -appdirs = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, + {file = "anyio-3.3.1-py3-none-any.whl", hash = "sha256:d7c604dd491eca70e19c78664d685d5e4337612d574419d503e76f5d7d1590bd"}, + {file = "anyio-3.3.1.tar.gz", hash = "sha256:85913b4e2fec030e8c72a8f9f98092eeb9e25847a6e00d567751b77e34f856fe"}, ] asyncclick = [ {file = "asyncclick-7.1.2.3.tar.gz", hash = "sha256:c26962b9957abe7ae09c058afbfea199dedea1b54343c1cc2ae1a6a291fab333"}, @@ -764,22 +773,26 @@ babel = [ {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, ] +"backports.entry-points-selectable" = [ + {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"}, + {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"}, +] certifi = [ {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, ] cfgv = [ - {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, - {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"}, + {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, + {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] -chardet = [ - {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, - {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, +charset-normalizer = [ + {file = "charset-normalizer-2.0.6.tar.gz", hash = "sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f"}, + {file = "charset_normalizer-2.0.6-py3-none-any.whl", hash = "sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6"}, ] codecov = [ - {file = "codecov-2.1.11-py2.py3-none-any.whl", hash = "sha256:ba8553a82942ce37d4da92b70ffd6d54cf635fc1793ab0a7dc3fecd6ebfb3df8"}, - {file = "codecov-2.1.11-py3.8.egg", hash = "sha256:e95901d4350e99fc39c8353efa450050d2446c55bac91d90fcfd2354e19a6aef"}, - {file = "codecov-2.1.11.tar.gz", hash = "sha256:6cde272454009d27355f9434f4e49f238c0273b216beda8472a65dc4957f473b"}, + {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"}, + {file = "codecov-2.1.12-py3.8.egg", hash = "sha256:782a8e5352f22593cbc5427a35320b99490eb24d9dcfa2155fd99d2b75cfb635"}, + {file = "codecov-2.1.12.tar.gz", hash = "sha256:a0da46bb5025426da895af90938def8ee12d37fcbcbbbc15b6dc64cf7ebc51c1"}, ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, @@ -840,8 +853,8 @@ coverage = [ {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] distlib = [ - {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, - {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, + {file = "distlib-0.3.3-py2.py3-none-any.whl", hash = "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31"}, + {file = "distlib-0.3.3.zip", hash = "sha256:d982d0751ff6eaaab5e2ec8e691d949ee80eddf01a62eaa96ddb11531fe16b05"}, ] docutils = [ {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, @@ -852,20 +865,20 @@ filelock = [ {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, ] identify = [ - {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"}, - {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"}, + {file = "identify-2.2.15-py2.py3-none-any.whl", hash = "sha256:de83a84d774921669774a2000bf87ebba46b4d1c04775f4a5d37deff0cf39f73"}, + {file = "identify-2.2.15.tar.gz", hash = "sha256:528a88021749035d5a39fe2ba67c0642b8341aaf71889da0e1ed669a429b87f0"}, ] idna = [ - {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, - {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, + {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, + {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, ] imagesize = [ {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"}, {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.5.0-py3-none-any.whl", hash = "sha256:833b26fb89d5de469b24a390e9df088d4e52e4ba33b01dc5e0e4f41b81a16c00"}, - {file = "importlib_metadata-4.5.0.tar.gz", hash = "sha256:b142cc1dd1342f31ff04bb7d022492b09920cb64fed867cd3ea6f80fe3ebd139"}, + {file = "importlib_metadata-4.8.1-py3-none-any.whl", hash = "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15"}, + {file = "importlib_metadata-4.8.1.tar.gz", hash = "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"}, ] jinja2 = [ {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, @@ -915,32 +928,36 @@ mistune = [ {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, ] more-itertools = [ - {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"}, - {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"}, + {file = "more-itertools-8.10.0.tar.gz", hash = "sha256:1debcabeb1df793814859d64a81ad7cb10504c24349368ccf214c664c474f41f"}, + {file = "more_itertools-8.10.0-py3-none-any.whl", hash = "sha256:56ddac45541718ba332db05f464bebfb0768110111affd27f66e0051f276fa43"}, ] nodeenv = [ {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, ] packaging = [ - {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, - {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, + {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, + {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, +] +platformdirs = [ + {file = "platformdirs-2.3.0-py3-none-any.whl", hash = "sha256:8003ac87717ae2c7ee1ea5a84a1a61e87f3fbd16eb5aadba194ea30a9019f648"}, + {file = "platformdirs-2.3.0.tar.gz", hash = "sha256:15b056538719b1c94bdaccb29e5f81879c7f7f0f4a153f46086d155dffcd4f0f"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] pre-commit = [ - {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"}, - {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"}, + {file = "pre_commit-2.15.0-py2.py3-none-any.whl", hash = "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6"}, + {file = "pre_commit-2.15.0.tar.gz", hash = "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7"}, ] py = [ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, ] pygments = [ - {file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"}, - {file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"}, + {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, + {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, ] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, @@ -954,10 +971,6 @@ pytest-asyncio = [ {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"}, {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, ] -pytest-azurepipelines = [ - {file = "pytest-azurepipelines-0.8.0.tar.gz", hash = "sha256:944ae2c0790b792d123aa7312fe307bc35214dd26531728923ae5085a1d1feab"}, - {file = "pytest_azurepipelines-0.8.0-py3-none-any.whl", hash = "sha256:38b841a90e88d1966715966d7ea35619ed710386138a6a0b8fb5954c991ca4f1"}, -] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, @@ -997,8 +1010,8 @@ pyyaml = [ {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] requests = [ - {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, - {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, + {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, + {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, ] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, @@ -1056,36 +1069,35 @@ toml = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] tox = [ - {file = "tox-3.23.1-py2.py3-none-any.whl", hash = "sha256:b0b5818049a1c1997599d42012a637a33f24c62ab8187223fdd318fa8522637b"}, - {file = "tox-3.23.1.tar.gz", hash = "sha256:307a81ddb82bd463971a273f33e9533a24ed22185f27db8ce3386bff27d324e3"}, + {file = "tox-3.24.4-py2.py3-none-any.whl", hash = "sha256:5e274227a53dc9ef856767c21867377ba395992549f02ce55eb549f9fb9a8d10"}, + {file = "tox-3.24.4.tar.gz", hash = "sha256:c30b57fa2477f1fb7c36aa1d83292d5c2336cd0018119e1b1c17340e2c2708ca"}, ] typing-extensions = [ - {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, - {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, - {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, + {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, + {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, + {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, ] urllib3 = [ - {file = "urllib3-1.26.5-py2.py3-none-any.whl", hash = "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c"}, - {file = "urllib3-1.26.5.tar.gz", hash = "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098"}, + {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, + {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, ] virtualenv = [ - {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"}, - {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"}, + {file = "virtualenv-20.8.0-py2.py3-none-any.whl", hash = "sha256:a4b987ec31c3c9996cf1bc865332f967fe4a0512c41b39652d6224f696e69da5"}, + {file = "virtualenv-20.8.0.tar.gz", hash = "sha256:4da4ac43888e97de9cf4fdd870f48ed864bbfd133d2c46cbdec941fed4a25aef"}, ] voluptuous = [ - {file = "voluptuous-0.12.1-py3-none-any.whl", hash = "sha256:8ace33fcf9e6b1f59406bfaf6b8ec7bcc44266a9f29080b4deb4fe6ff2492386"}, - {file = "voluptuous-0.12.1.tar.gz", hash = "sha256:663572419281ddfaf4b4197fd4942d181630120fb39b333e3adad70aeb56444b"}, + {file = "voluptuous-0.12.2.tar.gz", hash = "sha256:4db1ac5079db9249820d49c891cb4660a6f8cae350491210abce741fabf56513"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, ] xdoctest = [ - {file = "xdoctest-0.15.4-py2.py3-none-any.whl", hash = "sha256:4b91eb67e45e51a254ff9370adb72a0c82b08289844c95cfd3a1186d7ec4f694"}, - {file = "xdoctest-0.15.4-py3-none-any.whl", hash = "sha256:33d4a12cf70da245ca3f71be9ef03e0615fa862826bf6a08e8f025ce693e496d"}, - {file = "xdoctest-0.15.4.tar.gz", hash = "sha256:ef1f93d2147988d3cb6f35c026ec32aca971923f86945a775f61e2f8de8505d1"}, + {file = "xdoctest-0.15.8-py2.py3-none-any.whl", hash = "sha256:566e2bb2135e144e66ccd390affbe4504a2e96c25ef16260843b9680326cadc9"}, + {file = "xdoctest-0.15.8-py3-none-any.whl", hash = "sha256:80a57af2f8ca709ab9da111ab3b16ec474f11297b9efcc34709a2c3e56ed9ce6"}, + {file = "xdoctest-0.15.8.tar.gz", hash = "sha256:ddd128780593161a7398fcfefc49f5f6dfe4c2eb2816942cb53768d43bcab7b9"}, ] zipp = [ - {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"}, - {file = "zipp-3.4.1.tar.gz", hash = "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76"}, + {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"}, + {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"}, ] diff --git a/pyproject.toml b/pyproject.toml index c87dbd964..d70bfbfa6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,6 @@ sphinxcontrib-programoutput = { version = "^0", optional = true } [tool.poetry.dev-dependencies] pytest = "^5" -pytest-azurepipelines = "^0" pytest-cov = "^2" pytest-asyncio = "^0" pytest-sugar = "*" From 94e5a90ac468faf189603d3912186c61dc0c9bdd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Sep 2021 17:24:44 -0500 Subject: [PATCH 032/892] Add emeter support for strip sockets (#203) * Add support for plugs with emeters. * Tweaks for emeter * black * tweaks * tweaks * more tweaks * dry * flake8 * flake8 * legacy typing * Update kasa/smartstrip.py Co-authored-by: Teemu R. * reduce * remove useless delegation * tweaks * tweaks * dry * tweak * tweak * tweak * tweak * update tests * wrap * preen * prune * prune * prune * guard * adjust * robust * prune * prune * reduce dict lookups by 1 * Update kasa/smartstrip.py Co-authored-by: Teemu R. * delete utils * isort Co-authored-by: Brendan Burns Co-authored-by: Teemu R. --- kasa/smartdevice.py | 83 +++++++++++------------- kasa/smartstrip.py | 112 +++++++++++++++++++++------------ kasa/tests/test_emeter.py | 15 ----- kasa/tests/test_smartdevice.py | 3 +- 4 files changed, 112 insertions(+), 101 deletions(-) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 1efc0773e..11c7d1c96 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -11,13 +11,14 @@ You may obtain a copy of the license at http://www.apache.org/licenses/LICENSE-2.0 """ +import collections.abc import functools import inspect import logging from dataclasses import dataclass from datetime import datetime, timedelta from enum import Enum, auto -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Set from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException @@ -51,6 +52,16 @@ class WifiNetwork: rssi: Optional[int] = None +def merge(d, u): + """Update dict recursively.""" + for k, v in u.items(): + if isinstance(v, collections.abc.Mapping): + d[k] = merge(d.get(k, {}), v) + else: + d[k] = v + return d + + def requires_update(f): """Indicate that `update` should be called before accessing this method.""" # noqa: D202 if inspect.iscoroutinefunction(f): @@ -204,6 +215,11 @@ def _create_request( return request + def _verify_emeter(self) -> None: + """Raise an exception if there is no emeter.""" + if not self.has_emeter: + raise SmartDeviceException("Device has no emeter") + async def _query_helper( self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None ) -> Any: @@ -240,13 +256,17 @@ async def _query_helper( return result + @property # type: ignore + @requires_update + def features(self) -> Set[str]: + """Return a set of features that the device supports.""" + return set(self.sys_info["feature"].split(":")) + @property # type: ignore @requires_update def has_emeter(self) -> bool: """Return True if device has an energy meter.""" - sys_info = self.sys_info - features = sys_info["feature"].split(":") - return "ENE" in features + return "ENE" in self.features async def get_sys_info(self) -> Dict[str, Any]: """Retrieve system information.""" @@ -374,10 +394,8 @@ def location(self) -> Dict: @requires_update def rssi(self) -> Optional[int]: """Return WiFi signal strenth (rssi).""" - sys_info = self.sys_info - if "rssi" in sys_info: - return int(sys_info["rssi"]) - return None + rssi = self.sys_info.get("rssi") + return None if rssi is None else int(rssi) @property # type: ignore @requires_update @@ -410,16 +428,12 @@ async def set_mac(self, mac): @requires_update def emeter_realtime(self) -> EmeterStatus: """Return current energy readings.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() return EmeterStatus(self._last_update[self.emeter_type]["get_realtime"]) async def get_emeter_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() return EmeterStatus(await self._query_helper(self.emeter_type, "get_realtime")) def _create_emeter_request(self, year: int = None, month: int = None): @@ -429,23 +443,12 @@ def _create_emeter_request(self, year: int = None, month: int = None): if month is None: month = datetime.now().month - import collections.abc - - def update(d, u): - """Update dict recursively.""" - for k, v in u.items(): - if isinstance(v, collections.abc.Mapping): - d[k] = update(d.get(k, {}), v) - else: - d[k] = v - return d - req: Dict[str, Any] = {} - update(req, self._create_request(self.emeter_type, "get_realtime")) - update( + merge(req, self._create_request(self.emeter_type, "get_realtime")) + merge( req, self._create_request(self.emeter_type, "get_monthstat", {"year": year}) ) - update( + merge( req, self._create_request( self.emeter_type, "get_daystat", {"month": month, "year": year} @@ -458,9 +461,7 @@ def update(d, u): @requires_update def emeter_today(self) -> Optional[float]: """Return today's energy consumption in kWh.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() raw_data = self._last_update[self.emeter_type]["get_daystat"]["day_list"] data = self._emeter_convert_emeter_data(raw_data) today = datetime.now().day @@ -474,9 +475,7 @@ def emeter_today(self) -> Optional[float]: @requires_update def emeter_this_month(self) -> Optional[float]: """Return this month's energy consumption in kWh.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() raw_data = self._last_update[self.emeter_type]["get_monthstat"]["month_list"] data = self._emeter_convert_emeter_data(raw_data) current_month = datetime.now().month @@ -516,9 +515,7 @@ async def get_emeter_daily( :param kwh: return usage in kWh (default: True) :return: mapping of day of month to value """ - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() if year is None: year = datetime.now().year if month is None: @@ -538,9 +535,7 @@ async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: :param kwh: return usage in kWh (default: True) :return: dict: mapping of month to value """ - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() if year is None: year = datetime.now().year @@ -553,17 +548,13 @@ async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: @requires_update async def erase_emeter_stats(self) -> Dict: """Erase energy meter statistics.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() return await self._query_helper(self.emeter_type, "erase_emeter_stat", None) @requires_update async def current_consumption(self) -> float: """Get the current power consumption in Watt.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() response = EmeterStatus(await self.get_emeter_realtime()) return float(response["power"]) diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index a5351c5b5..c1235920d 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -6,6 +6,7 @@ from kasa.smartdevice import ( DeviceType, + EmeterStatus, SmartDevice, SmartDeviceException, requires_update, @@ -15,6 +16,15 @@ _LOGGER = logging.getLogger(__name__) +def merge_sums(dicts): + """Merge the sum of dicts.""" + total_dict: DefaultDict[int, float] = defaultdict(lambda: 0.0) + for sum_dict in dicts: + for day, value in sum_dict.items(): + total_dict[day] += value + return total_dict + + class SmartStrip(SmartDevice): """Representation of a TP-Link Smart Power Strip. @@ -75,11 +85,7 @@ def __init__(self, host: str) -> None: @requires_update def is_on(self) -> bool: """Return if any of the outlets are on.""" - for plug in self.children: - is_on = plug.is_on - if is_on: - return True - return False + return any(plug.is_on for plug in self.children) async def update(self): """Update some of the attributes. @@ -97,6 +103,10 @@ async def update(self): SmartStripPlug(self.host, parent=self, child_id=child["id"]) ) + if self.has_emeter: + for plug in self.children: + await plug.update() + async def turn_on(self, **kwargs): """Turn the strip on.""" await self._query_helper("system", "set_relay_state", {"state": 1}) @@ -140,16 +150,16 @@ def state_information(self) -> Dict[str, Any]: async def current_consumption(self) -> float: """Get the current power consumption in watts.""" - consumption = sum(await plug.current_consumption() for plug in self.children) - - return consumption + return sum([await plug.current_consumption() for plug in self.children]) - async def set_alias(self, alias: str) -> None: - """Set the alias for the strip. - - :param alias: new alias - """ - return await super().set_alias(alias) + @requires_update + async def get_emeter_realtime(self) -> EmeterStatus: + """Retrieve current energy readings.""" + emeter_rt = await self._async_get_emeter_sum("get_emeter_realtime", {}) + # Voltage is averaged since each read will result + # in a slightly different voltage since they are not atomic + emeter_rt["voltage_mv"] = int(emeter_rt["voltage_mv"] / len(self.children)) + return EmeterStatus(emeter_rt) @requires_update async def get_emeter_daily( @@ -163,14 +173,9 @@ async def get_emeter_daily( :param kwh: return usage in kWh (default: True) :return: mapping of day of month to value """ - emeter_daily: DefaultDict[int, float] = defaultdict(lambda: 0.0) - for plug in self.children: - plug_emeter_daily = await plug.get_emeter_daily( - year=year, month=month, kwh=kwh - ) - for day, value in plug_emeter_daily.items(): - emeter_daily[day] += value - return emeter_daily + return await self._async_get_emeter_sum( + "get_emeter_daily", {"year": year, "month": month, "kwh": kwh} + ) @requires_update async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: @@ -179,13 +184,16 @@ async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: :param year: year for which to retrieve statistics (default: this year) :param kwh: return usage in kWh (default: True) """ - emeter_monthly: DefaultDict[int, float] = defaultdict(lambda: 0.0) - for plug in self.children: - plug_emeter_monthly = await plug.get_emeter_monthly(year=year, kwh=kwh) - for month, value in plug_emeter_monthly: - emeter_monthly[month] += value + return await self._async_get_emeter_sum( + "get_emeter_monthly", {"year": year, "kwh": kwh} + ) - return emeter_monthly + async def _async_get_emeter_sum(self, func: str, kwargs: Dict[str, Any]) -> Dict: + """Retreive emeter stats for a time period from children.""" + self._verify_emeter() + return merge_sums( + [await getattr(plug, func)(**kwargs) for plug in self.children] + ) @requires_update async def erase_emeter_stats(self): @@ -193,6 +201,28 @@ async def erase_emeter_stats(self): for plug in self.children: await plug.erase_emeter_stats() + @property # type: ignore + @requires_update + def emeter_this_month(self) -> Optional[float]: + """Return this month's energy consumption in kWh.""" + return sum([plug.emeter_this_month for plug in self.children]) + + @property # type: ignore + @requires_update + def emeter_today(self) -> Optional[float]: + """Return this month's energy consumption in kWh.""" + return sum([plug.emeter_today for plug in self.children]) + + @property # type: ignore + @requires_update + def emeter_realtime(self) -> EmeterStatus: + """Return current energy readings.""" + emeter = merge_sums([plug.emeter_realtime for plug in self.children]) + # Voltage is averaged since each read will result + # in a slightly different voltage since they are not atomic + emeter["voltage_mv"] = int(emeter["voltage_mv"] / len(self.children)) + return EmeterStatus(emeter) + class SmartStripPlug(SmartPlug): """Representation of a single socket in a power strip. @@ -214,12 +244,22 @@ def __init__(self, host: str, parent: "SmartStrip", child_id: str) -> None: self._device_type = DeviceType.StripSocket async def update(self): - """Override the update to no-op and inform the user.""" - _LOGGER.warning( - "You called update() on a child device, which has no effect." - "Call update() on the parent device instead." + """Query the device to update the data. + + Needed for properties that are decorated with `requires_update`. + """ + self._last_update = await self.parent.protocol.query( + self.host, self._create_emeter_request() ) - return + + def _create_request( + self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None + ): + request: Dict[str, Any] = { + "context": {"child_ids": [self.child_id]}, + target: {cmd: arg}, + } + return request async def _query_helper( self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None @@ -245,12 +285,6 @@ def led(self) -> bool: """ return False - @property # type: ignore - @requires_update - def has_emeter(self) -> bool: - """Children have no emeter to my knowledge.""" - return False - @property # type: ignore @requires_update def device_id(self) -> str: diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 7f0f95aca..b3d567dd6 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -22,9 +22,6 @@ async def test_no_emeter(dev): @has_emeter async def test_get_emeter_realtime(dev): - if dev.is_strip: - pytest.skip("Disabled for strips temporarily") - assert dev.has_emeter current_emeter = await dev.get_emeter_realtime() @@ -34,9 +31,6 @@ async def test_get_emeter_realtime(dev): @has_emeter @pytest.mark.requires_dummy async def test_get_emeter_daily(dev): - if dev.is_strip: - pytest.skip("Disabled for strips temporarily") - assert dev.has_emeter assert await dev.get_emeter_daily(year=1900, month=1) == {} @@ -57,9 +51,6 @@ async def test_get_emeter_daily(dev): @has_emeter @pytest.mark.requires_dummy async def test_get_emeter_monthly(dev): - if dev.is_strip: - pytest.skip("Disabled for strips temporarily") - assert dev.has_emeter assert await dev.get_emeter_monthly(year=1900) == {} @@ -79,9 +70,6 @@ async def test_get_emeter_monthly(dev): @has_emeter async def test_emeter_status(dev): - if dev.is_strip: - pytest.skip("Disabled for strips temporarily") - assert dev.has_emeter d = await dev.get_emeter_realtime() @@ -108,9 +96,6 @@ async def test_erase_emeter_stats(dev): @has_emeter async def test_current_consumption(dev): - if dev.is_strip: - pytest.skip("Disabled for strips temporarily") - if dev.has_emeter: x = await dev.current_consumption() assert isinstance(x, float) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 002adb901..380cdd1fb 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -4,6 +4,7 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import SmartDeviceException +from kasa.smartstrip import SmartStripPlug from .conftest import handle_turn_on, has_emeter, no_emeter, pytestmark, turn_on from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol @@ -26,7 +27,7 @@ async def test_initial_update_emeter(dev, mocker): dev._last_update = None spy = mocker.spy(dev.protocol, "query") await dev.update() - assert spy.call_count == 2 + assert spy.call_count == 2 + len(dev.children) @no_emeter From bdb07a749c4710207c8514007e55932c6c0573c8 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 24 Sep 2021 01:44:22 +0200 Subject: [PATCH 033/892] Release 0.4.0.dev4 (#210) * Release 0.4.0.dev4 The most important enhancements in this release are: * Support for emeter on strip sockets * Fix discovery and update() on some devices that do not support multi-module queries (e.g., HS200) * Improved support for bulbs [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev3...0.4.0.dev4) **Implemented enhancements:** - HS300 Children plugs have emeter [\#64](https://github.com/python-kasa/python-kasa/issues/64) - Improve emeterstatus API, move into own module [\#205](https://github.com/python-kasa/python-kasa/pull/205) ([rytilahti](https://github.com/rytilahti)) - Avoid temp array during encrypt and decrypt [\#204](https://github.com/python-kasa/python-kasa/pull/204) ([bdraco](https://github.com/bdraco)) - Add emeter support for strip sockets [\#203](https://github.com/python-kasa/python-kasa/pull/203) ([bdraco](https://github.com/bdraco)) - Add own device type for smartstrip children [\#201](https://github.com/python-kasa/python-kasa/pull/201) ([rytilahti](https://github.com/rytilahti)) - bulb: allow set\_hsv without v, add fallback ct range [\#200](https://github.com/python-kasa/python-kasa/pull/200) ([rytilahti](https://github.com/rytilahti)) - Improve bulb support \(alias, time settings\) [\#198](https://github.com/python-kasa/python-kasa/pull/198) ([rytilahti](https://github.com/rytilahti)) - Improve testing harness to allow tests on real devices [\#197](https://github.com/python-kasa/python-kasa/pull/197) ([rytilahti](https://github.com/rytilahti)) - cli: add human-friendly printout when calling temperature on non-supported devices [\#196](https://github.com/python-kasa/python-kasa/pull/196) ([JaydenRA](https://github.com/JaydenRA)) **Fixed bugs:** - KL430: Throw error for Device specific information [\#189](https://github.com/python-kasa/python-kasa/issues/189) - dump\_devinfo: handle latitude/longitude keys properly [\#175](https://github.com/python-kasa/python-kasa/pull/175) ([rytilahti](https://github.com/rytilahti)) **Closed issues:** - Feature Request - Toggle Command [\#188](https://github.com/python-kasa/python-kasa/issues/188) - Is It Compatible With HS105? [\#186](https://github.com/python-kasa/python-kasa/issues/186) - Cannot use some functions with KP303 [\#181](https://github.com/python-kasa/python-kasa/issues/181) - Help needed - awaiting game [\#179](https://github.com/python-kasa/python-kasa/issues/179) - Version inconsistency between CLI and pip [\#177](https://github.com/python-kasa/python-kasa/issues/177) - Release 0.4.0.dev3? [\#169](https://github.com/python-kasa/python-kasa/issues/169) - Discover does not support specifying network interface [\#167](https://github.com/python-kasa/python-kasa/issues/167) - Can't command or query HS200 v5 switch [\#161](https://github.com/python-kasa/python-kasa/issues/161) **Merged pull requests:** - More CI fixes [\#208](https://github.com/python-kasa/python-kasa/pull/208) ([rytilahti](https://github.com/rytilahti)) - Fix CI dep installation [\#207](https://github.com/python-kasa/python-kasa/pull/207) ([rytilahti](https://github.com/rytilahti)) - Use github actions instead of azure pipelines [\#206](https://github.com/python-kasa/python-kasa/pull/206) ([rytilahti](https://github.com/rytilahti)) - Add KP115 fixture [\#202](https://github.com/python-kasa/python-kasa/pull/202) ([rytilahti](https://github.com/rytilahti)) - Perform initial update only using the sysinfo query [\#199](https://github.com/python-kasa/python-kasa/pull/199) ([rytilahti](https://github.com/rytilahti)) - Add real kasa KL430\(UN\) device dump [\#192](https://github.com/python-kasa/python-kasa/pull/192) ([iprodanovbg](https://github.com/iprodanovbg)) - Use less strict matcher for kl430 color temperature [\#190](https://github.com/python-kasa/python-kasa/pull/190) ([rytilahti](https://github.com/rytilahti)) - Add EP10\(US\) 1.0 1.0.2 fixture [\#174](https://github.com/python-kasa/python-kasa/pull/174) ([nbrew](https://github.com/nbrew)) - Add a note about using the discovery target parameter [\#168](https://github.com/python-kasa/python-kasa/pull/168) ([leandroreox](https://github.com/leandroreox)) * replace pypy3 with pypy-3.7 as we do not support python3.6 anyway * skip pypy-3.7 on windows to avoid flaky ci --- .github/workflows/ci.yml | 8 ++++++- CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++++++++- RELEASING.md | 2 +- pyproject.toml | 2 +- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e68bc1ec..e2cb6d82a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,8 +61,14 @@ jobs: strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "pypy3"] + python-version: ["3.7", "3.8", "3.9", "pypy-3.7"] os: [ubuntu-latest, macos-latest, windows-latest] + # exclude pypy on windows, as the poetry install seems to be very flaky: + # PermissionError(13, 'The process cannot access the file because it is being used by another process')) + # at C:\hostedtoolcache\windows\PyPy\3.7.10\x86\site-packages\requests\models.py:761 in generate + exclude: + - python-version: pypy-3.7 + os: windows-latest steps: - uses: "actions/checkout@v2" diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f0b089b4..437c756dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,50 @@ # Changelog -## [0.4.0.dev3](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev3) (2021-06-14) +## [0.4.0.dev4](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev4) (2021-09-23) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev3...0.4.0.dev4) + +**Implemented enhancements:** + +- HS300 Children plugs have emeter [\#64](https://github.com/python-kasa/python-kasa/issues/64) +- Improve emeterstatus API, move into own module [\#205](https://github.com/python-kasa/python-kasa/pull/205) ([rytilahti](https://github.com/rytilahti)) +- Avoid temp array during encrypt and decrypt [\#204](https://github.com/python-kasa/python-kasa/pull/204) ([bdraco](https://github.com/bdraco)) +- Add emeter support for strip sockets [\#203](https://github.com/python-kasa/python-kasa/pull/203) ([bdraco](https://github.com/bdraco)) +- Add own device type for smartstrip children [\#201](https://github.com/python-kasa/python-kasa/pull/201) ([rytilahti](https://github.com/rytilahti)) +- bulb: allow set\_hsv without v, add fallback ct range [\#200](https://github.com/python-kasa/python-kasa/pull/200) ([rytilahti](https://github.com/rytilahti)) +- Improve bulb support \(alias, time settings\) [\#198](https://github.com/python-kasa/python-kasa/pull/198) ([rytilahti](https://github.com/rytilahti)) +- Improve testing harness to allow tests on real devices [\#197](https://github.com/python-kasa/python-kasa/pull/197) ([rytilahti](https://github.com/rytilahti)) +- cli: add human-friendly printout when calling temperature on non-supported devices [\#196](https://github.com/python-kasa/python-kasa/pull/196) ([JaydenRA](https://github.com/JaydenRA)) + +**Fixed bugs:** + +- KL430: Throw error for Device specific information [\#189](https://github.com/python-kasa/python-kasa/issues/189) +- dump\_devinfo: handle latitude/longitude keys properly [\#175](https://github.com/python-kasa/python-kasa/pull/175) ([rytilahti](https://github.com/rytilahti)) + +**Closed issues:** + +- Feature Request - Toggle Command [\#188](https://github.com/python-kasa/python-kasa/issues/188) +- Is It Compatible With HS105? [\#186](https://github.com/python-kasa/python-kasa/issues/186) +- Cannot use some functions with KP303 [\#181](https://github.com/python-kasa/python-kasa/issues/181) +- Help needed - awaiting game [\#179](https://github.com/python-kasa/python-kasa/issues/179) +- Version inconsistency between CLI and pip [\#177](https://github.com/python-kasa/python-kasa/issues/177) +- Release 0.4.0.dev3? [\#169](https://github.com/python-kasa/python-kasa/issues/169) +- Discover does not support specifying network interface [\#167](https://github.com/python-kasa/python-kasa/issues/167) +- Can't command or query HS200 v5 switch [\#161](https://github.com/python-kasa/python-kasa/issues/161) + +**Merged pull requests:** + +- More CI fixes [\#208](https://github.com/python-kasa/python-kasa/pull/208) ([rytilahti](https://github.com/rytilahti)) +- Fix CI dep installation [\#207](https://github.com/python-kasa/python-kasa/pull/207) ([rytilahti](https://github.com/rytilahti)) +- Use github actions instead of azure pipelines [\#206](https://github.com/python-kasa/python-kasa/pull/206) ([rytilahti](https://github.com/rytilahti)) +- Add KP115 fixture [\#202](https://github.com/python-kasa/python-kasa/pull/202) ([rytilahti](https://github.com/rytilahti)) +- Perform initial update only using the sysinfo query [\#199](https://github.com/python-kasa/python-kasa/pull/199) ([rytilahti](https://github.com/rytilahti)) +- Add real kasa KL430\(UN\) device dump [\#192](https://github.com/python-kasa/python-kasa/pull/192) ([iprodanovbg](https://github.com/iprodanovbg)) +- Use less strict matcher for kl430 color temperature [\#190](https://github.com/python-kasa/python-kasa/pull/190) ([rytilahti](https://github.com/rytilahti)) +- Add EP10\(US\) 1.0 1.0.2 fixture [\#174](https://github.com/python-kasa/python-kasa/pull/174) ([nbrew](https://github.com/nbrew)) +- Add a note about using the discovery target parameter [\#168](https://github.com/python-kasa/python-kasa/pull/168) ([leandroreox](https://github.com/leandroreox)) + +## [0.4.0.dev3](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev3) (2021-06-16) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev2...0.4.0.dev3) @@ -29,6 +73,7 @@ **Merged pull requests:** +- Prepare 0.4.0.dev3 [\#172](https://github.com/python-kasa/python-kasa/pull/172) ([rytilahti](https://github.com/rytilahti)) - Simplify mac address handling [\#162](https://github.com/python-kasa/python-kasa/pull/162) ([rytilahti](https://github.com/rytilahti)) - Added KL125 and HS200 fixture dumps and updated tests to run on new format [\#160](https://github.com/python-kasa/python-kasa/pull/160) ([brianthedavis](https://github.com/brianthedavis)) - Add KL125 bulb definition [\#143](https://github.com/python-kasa/python-kasa/pull/143) ([mdarnol](https://github.com/mdarnol)) diff --git a/RELEASING.md b/RELEASING.md index 75a775edb..fc01a87de 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -3,7 +3,7 @@ ```bash # export PREVIOUS_RELEASE=$(git describe --abbrev=0) export PREVIOUS_RELEASE=0.3.5 # generate the full changelog since last pyhs100 release -export NEW_RELEASE=0.4.0.pre1 +export NEW_RELEASE=0.4.0.dev4 ``` 2. Update the version number diff --git a/pyproject.toml b/pyproject.toml index d70bfbfa6..87ddba9d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.4.0.dev3" +version = "0.4.0.dev4" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["Your Name "] From acb221b1e08c6467fed7fcd3beb8d6cbef83c071 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 24 Sep 2021 17:18:11 +0200 Subject: [PATCH 034/892] Cleanup discovery & add tests (#212) * Cleanup discovery & add tests * discovered_devices_raw is not anymore available, as that can be accessed directly from the device objects * test most of the discovery code paths * some minor cleanups to test handling * update discovery docs * Move category check to be after the definitions * skip a couple of tests requiring asyncmock not available on py37 * Remove return_raw usage from cli.discover --- kasa/cli.py | 6 ++-- kasa/discover.py | 56 +++++++++--------------------- kasa/tests/conftest.py | 67 +++++++++++++++++++++--------------- kasa/tests/test_discovery.py | 57 ++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 72 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 626eadc2b..209fcf965 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -143,13 +143,11 @@ async def discover(ctx, timeout, discover_only, dump_raw): """Discover devices in the network.""" target = ctx.parent.params["target"] click.echo(f"Discovering devices on {target} for {timeout} seconds") - found_devs = await Discover.discover( - target=target, timeout=timeout, return_raw=dump_raw - ) + found_devs = await Discover.discover(target=target, timeout=timeout) if not discover_only: for ip, dev in found_devs.items(): if dump_raw: - click.echo(dev) + click.echo(dev.sys_info) continue ctx.obj = dev await ctx.invoke(state) diff --git a/kasa/discover.py b/kasa/discover.py index b12b79264..f452c54ae 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -3,7 +3,7 @@ import json import logging import socket -from typing import Awaitable, Callable, Dict, Mapping, Optional, Type, Union, cast +from typing import Awaitable, Callable, Dict, Optional, Type, cast from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb @@ -17,6 +17,7 @@ OnDiscoveredCallable = Callable[[SmartDevice], Awaitable[None]] +DeviceDict = Dict[str, SmartDevice] class _DiscoverProtocol(asyncio.DatagramProtocol): @@ -25,8 +26,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol): This is internal class, use :func:`Discover.discover`: instead. """ - discovered_devices: Dict[str, SmartDevice] - discovered_devices_raw: Dict[str, Dict] + discovered_devices: DeviceDict def __init__( self, @@ -43,7 +43,6 @@ def __init__( self.protocol = TPLinkSmartHomeProtocol() self.target = (target, Discover.DISCOVERY_PORT) self.discovered_devices = {} - self.discovered_devices_raw = {} def connection_made(self, transport) -> None: """Set socket options for broadcasting.""" @@ -80,13 +79,9 @@ def datagram_received(self, data, addr) -> None: device.update_from_discover_info(info) self.discovered_devices[ip] = device - self.discovered_devices_raw[ip] = info - if device_class is not None: - if self.on_discovered is not None: - asyncio.ensure_future(self.on_discovered(device)) - else: - _LOGGER.error("Received invalid response: %s", info) + if self.on_discovered is not None: + asyncio.ensure_future(self.on_discovered(device)) def error_received(self, ex): """Handle asyncio.Protocol errors.""" @@ -144,9 +139,8 @@ async def discover( on_discovered=None, timeout=5, discovery_packets=3, - return_raw=False, interface=None, - ) -> Mapping[str, Union[SmartDevice, Dict]]: + ) -> DeviceDict: """Discover supported devices. Sends discovery message to 255.255.255.255:9999 in order @@ -154,17 +148,17 @@ async def discover( and waits for given timeout for answers from devices. If you have multiple interfaces, you can use target parameter to specify the network for discovery. - If given, `on_discovered` coroutine will get passed with the :class:`SmartDevice`-derived object as parameter. + If given, `on_discovered` coroutine will get awaited with a :class:`SmartDevice`-derived object as parameter. - The results of the discovery are returned either as a list of :class:`SmartDevice`-derived objects - or as raw response dictionaries objects (if `return_raw` is True). + The results of the discovery are returned as a dict of :class:`SmartDevice`-derived objects keyed with IP addresses. + The devices are already initialized and all but emeter-related properties can be accessed directly. :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_packets: Number of discovery packets are broadcasted. - :param return_raw: True to return JSON objects instead of Devices. - :return: + :param discovery_packets: Number of discovery packets to broadcast + :param interface: Bind to specific interface + :return: dictionary with discovered devices """ loop = asyncio.get_event_loop() transport, protocol = await loop.create_datagram_endpoint( @@ -186,9 +180,6 @@ async def discover( _LOGGER.debug("Discovered %s devices", len(protocol.discovered_devices)) - if return_raw: - return protocol.discovered_devices_raw - return protocol.discovered_devices @staticmethod @@ -204,12 +195,10 @@ async def discover_single(host: str) -> SmartDevice: info = await protocol.query(host, Discover.DISCOVERY_QUERY) device_class = Discover._get_device_class(info) - if device_class is not None: - dev = device_class(host) - await dev.update() - return dev + dev = device_class(host) + await dev.update() - raise SmartDeviceException("Unable to discover device, received: %s" % info) + return dev @staticmethod def _get_device_class(info: dict) -> Type[SmartDevice]: @@ -237,17 +226,4 @@ def _get_device_class(info: dict) -> Type[SmartDevice]: return SmartBulb - raise SmartDeviceException("Unknown device type: %s", type_) - - -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) - loop = asyncio.get_event_loop() - - async def _on_device(dev): - await dev.update() - _LOGGER.info("Got device: %s", dev) - - devices = loop.run_until_complete(Discover.discover(on_discovered=_on_device)) - for ip, dev in devices.items(): - print(f"[{ip}] {dev}") + raise SmartDeviceException("Unknown device type: %s" % type_) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 46cb86216..df253e5d5 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -53,8 +53,6 @@ def filter_model(desc, filter): def parametrize(desc, devices, ids=None): - # if ids is None: - # ids = ["on", "off"] return pytest.mark.parametrize( "dev", filter_model(desc, devices), indirect=True, ids=ids ) @@ -63,32 +61,11 @@ def parametrize(desc, devices, ids=None): has_emeter = parametrize("has emeter", WITH_EMETER) no_emeter = parametrize("no emeter", ALL_DEVICES - WITH_EMETER) - -def name_for_filename(x): - from os.path import basename - - return basename(x) - - -bulb = parametrize("bulbs", BULBS, ids=name_for_filename) -plug = parametrize("plugs", PLUGS, ids=name_for_filename) -strip = parametrize("strips", STRIPS, ids=name_for_filename) -dimmer = parametrize("dimmers", DIMMERS, ids=name_for_filename) -lightstrip = parametrize("lightstrips", LIGHT_STRIPS, ids=name_for_filename) - -# This ensures that every single file inside fixtures/ is being placed in some category -categorized_fixtures = set( - dimmer.args[1] + strip.args[1] + plug.args[1] + bulb.args[1] + lightstrip.args[1] -) -diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures) -if diff: - for file in diff: - print( - "No category for file %s, add to the corresponding set (BULBS, PLUGS, ..)" - % file - ) - raise Exception("Missing category for %s" % diff) - +bulb = parametrize("bulbs", BULBS, ids=basename) +plug = parametrize("plugs", PLUGS, ids=basename) +strip = parametrize("strips", STRIPS, ids=basename) +dimmer = parametrize("dimmers", DIMMERS, ids=basename) +lightstrip = parametrize("lightstrips", LIGHT_STRIPS, ids=basename) # bulb types dimmable = parametrize("dimmable", DIMMABLE) @@ -98,6 +75,28 @@ def name_for_filename(x): color_bulb = parametrize("color bulbs", COLOR_BULBS) non_color_bulb = parametrize("non-color bulbs", BULBS - COLOR_BULBS) + +def check_categories(): + """Check that every fixture file is categorized.""" + categorized_fixtures = set( + dimmer.args[1] + + strip.args[1] + + plug.args[1] + + bulb.args[1] + + lightstrip.args[1] + ) + diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures) + if diff: + for file in diff: + print( + "No category for file %s, add to the corresponding set (BULBS, PLUGS, ..)" + % file + ) + raise Exception("Missing category for %s" % diff) + + +check_categories() + # Parametrize tests to run with device both on and off turn_on = pytest.mark.parametrize("turn_on", [True, False]) @@ -174,6 +173,18 @@ def dev(request): return get_device_for_file(file) +@pytest.fixture(params=SUPPORTED_DEVICES, scope="session") +def discovery_data(request): + """Return raw discovery file contents as JSON. Used for discovery tests.""" + file = request.param + p = Path(file) + if not p.is_absolute(): + p = Path(__file__).parent / "fixtures" / file + + with open(p) as f: + return json.load(f) + + def pytest_addoption(parser): parser.addoption( "--ip", action="store", default=None, help="run against device on given ip" diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 1356892e9..13ba38099 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -1,7 +1,10 @@ # type: ignore +import sys + import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import DeviceType, Discover, SmartDevice, SmartDeviceException +from kasa.discover import _DiscoverProtocol from .conftest import bulb, dimmer, lightstrip, plug, pytestmark, strip @@ -47,3 +50,57 @@ async def test_type_unknown(): invalid_info = {"system": {"get_sysinfo": {"type": "nosuchtype"}}} with pytest.raises(SmartDeviceException): Discover._get_device_class(invalid_info) + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="3.8 is first one with asyncmock") +async def test_discover_single(discovery_data: dict, mocker): + """Make sure that discover_single returns an initialized SmartDevice instance.""" + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) + x = await Discover.discover_single("127.0.0.1") + assert issubclass(x.__class__, SmartDevice) + assert x._sys_info is not None + + +INVALIDS = [ + ("No 'system' or 'get_sysinfo' in response", {"no": "data"}), + ( + "Unable to find the device type field", + {"system": {"get_sysinfo": {"missing_type": 1}}}, + ), + ("Unknown device type: foo", {"system": {"get_sysinfo": {"type": "foo"}}}), +] + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="3.8 is first one with asyncmock") +@pytest.mark.parametrize("msg, data", INVALIDS) +async def test_discover_invalid_info(msg, data, mocker): + """Make sure that invalid discovery information raises an exception.""" + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=data) + with pytest.raises(SmartDeviceException, match=msg): + await Discover.discover_single("127.0.0.1") + + +async def test_discover_send(mocker): + """Test discovery parameters.""" + proto = _DiscoverProtocol() + assert proto.discovery_packets == 3 + assert proto.target == ("255.255.255.255", 9999) + sendto = mocker.patch.object(proto, "transport") + proto.do_discover() + assert sendto.sendto.call_count == proto.discovery_packets + + +async def test_discover_datagram_received(mocker, discovery_data): + """Verify that datagram received fills discovered_devices.""" + proto = _DiscoverProtocol() + mocker.patch("json.loads", return_value=discovery_data) + mocker.patch.object(proto, "protocol") + + addr = "127.0.0.1" + proto.datagram_received("", (addr, 1234)) + + # Check that device in discovered_devices is initialized correctly + assert len(proto.discovered_devices) == 1 + dev = proto.discovered_devices[addr] + assert issubclass(dev.__class__, SmartDevice) + assert dev.host == addr From f1b28e79b9ddd54a7f4debde9d2c93166ed869e7 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 24 Sep 2021 22:26:21 +0200 Subject: [PATCH 035/892] Add KL130 fixture, initial lightstrip tests (#214) --- kasa/tests/fixtures/KL130(EU)_1.0_1.8.8.json | 85 ++++++++++++++++++++ kasa/tests/test_lightstrip.py | 17 ++++ 2 files changed, 102 insertions(+) create mode 100644 kasa/tests/fixtures/KL130(EU)_1.0_1.8.8.json create mode 100644 kasa/tests/test_lightstrip.py diff --git a/kasa/tests/fixtures/KL130(EU)_1.0_1.8.8.json b/kasa/tests/fixtures/KL130(EU)_1.0_1.8.8.json new file mode 100644 index 000000000..b57a01d28 --- /dev/null +++ b/kasa/tests/fixtures/KL130(EU)_1.0_1.8.8.json @@ -0,0 +1,85 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 1300 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "brightness": 5, + "color_temp": 2700, + "err_code": 0, + "hue": 1, + "mode": "normal", + "on_off": 1, + "saturation": 1 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "schedule", + "alias": "bedroom", + "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, + "heapsize": 332316, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "light_state": { + "brightness": 5, + "color_temp": 2700, + "hue": 1, + "mode": "normal", + "on_off": 1, + "saturation": 1 + }, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL130(EU)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 10, + "color_temp": 2500, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 299, + "index": 1, + "saturation": 95 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "index": 2, + "saturation": 75 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "index": 3, + "saturation": 75 + } + ], + "rssi": -62, + "sw_ver": "1.8.8 Build 190613 Rel.123436" + } + } +} diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py new file mode 100644 index 000000000..7a8d8726a --- /dev/null +++ b/kasa/tests/test_lightstrip.py @@ -0,0 +1,17 @@ +from kasa import DeviceType, SmartLightStrip + +from .conftest import lightstrip, pytestmark + + +@lightstrip +async def test_lightstrip_length(dev: SmartLightStrip): + assert dev.is_light_strip + assert dev.device_type == DeviceType.LightStrip + assert dev.length == dev.sys_info["length"] + + +@lightstrip +async def test_lightstrip_effect(dev: SmartLightStrip): + assert isinstance(dev.effect, dict) + for k in ["brightness", "custom", "enable", "id", "name"]: + assert k in dev.effect From e31cc6662c8b3da672732773d27140faa58122aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Sep 2021 16:25:43 -0500 Subject: [PATCH 036/892] Keep connection open and lock to prevent duplicate requests (#213) * Keep connection open and lock to prevent duplicate requests * option to not update children * tweaks * typing * tweaks * run tests in the same event loop * memorize model * Update kasa/protocol.py Co-authored-by: Teemu R. * Update kasa/protocol.py Co-authored-by: Teemu R. * Update kasa/protocol.py Co-authored-by: Teemu R. * Update kasa/protocol.py Co-authored-by: Teemu R. * dry * tweaks * warn when the event loop gets switched out from under us * raise on unable to connect multiple times * fix patch target * tweaks * isrot * reconnect test * prune * fix mocking * fix mocking * fix test under python 3.7 * fix test under python 3.7 * less patching * isort * use mocker to patch * disable on old python since mocking doesnt work * avoid disconnect/reconnect cycles * isort * Fix hue validation * Fix latitude_i/longitude_i units Co-authored-by: Teemu R. --- devtools/dump_devinfo.py | 17 ++-- kasa/discover.py | 9 +- kasa/protocol.py | 140 +++++++++++++++++++++-------- kasa/smartdevice.py | 14 +-- kasa/smartstrip.py | 10 +-- kasa/tests/conftest.py | 53 +++++++---- kasa/tests/newfakes.py | 26 ++++-- kasa/tests/test_bulb.py | 2 +- kasa/tests/test_discovery.py | 5 +- kasa/tests/test_protocol.py | 40 ++++++++- kasa/tests/test_readme_examples.py | 15 ++-- 11 files changed, 238 insertions(+), 93 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 9d30f9674..1108e7fb4 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -78,16 +78,17 @@ def cli(host, debug): ), ] - protocol = TPLinkSmartHomeProtocol() - successes = [] for test_call in items: + + async def _run_query(): + protocol = TPLinkSmartHomeProtocol(host) + return await protocol.query({test_call.module: {test_call.method: None}}) + try: click.echo(f"Testing {test_call}..", nl=False) - info = asyncio.run( - protocol.query(host, {test_call.module: {test_call.method: None}}) - ) + info = asyncio.run(_run_query()) resp = info[test_call.module] except Exception as ex: click.echo(click.style(f"FAIL {ex}", fg="red")) @@ -107,8 +108,12 @@ def cli(host, debug): final = default_to_regular(final) + async def _run_final_query(): + protocol = TPLinkSmartHomeProtocol(host) + return await protocol.query(final_query) + try: - final = asyncio.run(protocol.query(host, final_query)) + final = asyncio.run(_run_final_query()) except Exception as ex: click.echo( click.style( diff --git a/kasa/discover.py b/kasa/discover.py index f452c54ae..a408c2de9 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -40,7 +40,6 @@ def __init__( self.discovery_packets = discovery_packets self.interface = interface self.on_discovered = on_discovered - self.protocol = TPLinkSmartHomeProtocol() self.target = (target, Discover.DISCOVERY_PORT) self.discovered_devices = {} @@ -61,7 +60,7 @@ 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 = self.protocol.encrypt(req) + encrypted_req = TPLinkSmartHomeProtocol.encrypt(req) for i in range(self.discovery_packets): self.transport.sendto(encrypted_req[4:], self.target) # type: ignore @@ -71,7 +70,7 @@ def datagram_received(self, data, addr) -> None: if ip in self.discovered_devices: return - info = json.loads(self.protocol.decrypt(data)) + info = json.loads(TPLinkSmartHomeProtocol.decrypt(data)) _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) device_class = Discover._get_device_class(info) @@ -190,9 +189,9 @@ async def discover_single(host: str) -> SmartDevice: :rtype: SmartDevice :return: Object for querying/controlling found device. """ - protocol = TPLinkSmartHomeProtocol() + protocol = TPLinkSmartHomeProtocol(host) - info = await protocol.query(host, Discover.DISCOVERY_QUERY) + info = await protocol.query(Discover.DISCOVERY_QUERY) device_class = Discover._get_device_class(info) dev = device_class(host) diff --git a/kasa/protocol.py b/kasa/protocol.py index bbf13b995..b54029c66 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -10,11 +10,12 @@ http://www.apache.org/licenses/LICENSE-2.0 """ import asyncio +import contextlib import json import logging import struct from pprint import pformat as pf -from typing import Dict, Union +from typing import Dict, Optional, Union from .exceptions import SmartDeviceException @@ -28,8 +29,26 @@ class TPLinkSmartHomeProtocol: DEFAULT_PORT = 9999 DEFAULT_TIMEOUT = 5 - @staticmethod - async def query(host: str, request: Union[str, Dict], retry_count: int = 3) -> Dict: + BLOCK_SIZE = 4 + + def __init__(self, host: str) -> None: + """Create a protocol object.""" + self.host = host + self.reader: Optional[asyncio.StreamReader] = None + self.writer: Optional[asyncio.StreamWriter] = None + self.query_lock: Optional[asyncio.Lock] = None + self.loop: Optional[asyncio.AbstractEventLoop] = None + + def _detect_event_loop_change(self) -> None: + """Check if this object has been reused betwen event loops.""" + loop = asyncio.get_running_loop() + if not self.loop: + self.loop = loop + elif self.loop != loop: + _LOGGER.warning("Detected protocol reuse between different event loop") + self._reset() + + async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: """Request information from a TP-Link SmartHome Device. :param str host: host name or ip address of the device @@ -38,57 +57,106 @@ async def query(host: str, request: Union[str, Dict], retry_count: int = 3) -> D :param retry_count: how many retries to do in case of failure :return: response dict """ + self._detect_event_loop_change() + + if not self.query_lock: + self.query_lock = asyncio.Lock() + if isinstance(request, dict): request = json.dumps(request) + assert isinstance(request, str) timeout = TPLinkSmartHomeProtocol.DEFAULT_TIMEOUT - writer = None + + async with self.query_lock: + return await self._query(request, retry_count, timeout) + + async def _connect(self, timeout: int) -> bool: + """Try to connect or reconnect to the device.""" + if self.writer: + return True + + with contextlib.suppress(Exception): + self.reader = self.writer = None + task = asyncio.open_connection( + self.host, TPLinkSmartHomeProtocol.DEFAULT_PORT + ) + self.reader, self.writer = await asyncio.wait_for(task, timeout=timeout) + return True + + return False + + async def _execute_query(self, request: str) -> Dict: + """Execute a query on the device and wait for the response.""" + assert self.writer is not None + assert self.reader is not None + + _LOGGER.debug("> (%i) %s", len(request), request) + self.writer.write(TPLinkSmartHomeProtocol.encrypt(request)) + await self.writer.drain() + + packed_block_size = await self.reader.readexactly(self.BLOCK_SIZE) + length = struct.unpack(">I", packed_block_size)[0] + + buffer = await self.reader.readexactly(length) + response = TPLinkSmartHomeProtocol.decrypt(buffer) + json_payload = json.loads(response) + _LOGGER.debug("< (%i) %s", len(response), pf(json_payload)) + return json_payload + + async def close(self): + """Close the connection.""" + writer = self.writer + self._reset() + if writer: + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() + + def _reset(self): + """Clear any varibles that should not survive between loops.""" + self.writer = None + self.reader = None + self.query_lock = None + self.loop = None + + async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: + """Try to query a device.""" for retry in range(retry_count + 1): + if not await self._connect(timeout): + await self.close() + if retry >= retry_count: + _LOGGER.debug("Giving up after %s retries", retry) + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}" + ) + continue + try: - task = asyncio.open_connection( - host, TPLinkSmartHomeProtocol.DEFAULT_PORT + assert self.reader is not None + assert self.writer is not None + return await asyncio.wait_for( + self._execute_query(request), timeout=timeout ) - reader, writer = await asyncio.wait_for(task, timeout=timeout) - _LOGGER.debug("> (%i) %s", len(request), request) - writer.write(TPLinkSmartHomeProtocol.encrypt(request)) - await writer.drain() - - buffer = bytes() - # Some devices send responses with a length header of 0 and - # terminate with a zero size chunk. Others send the length and - # will hang if we attempt to read more data. - length = -1 - while True: - chunk = await reader.read(4096) - if length == -1: - length = struct.unpack(">I", chunk[0:4])[0] - buffer += chunk - if (length > 0 and len(buffer) >= length + 4) or not chunk: - break - - response = TPLinkSmartHomeProtocol.decrypt(buffer[4:]) - json_payload = json.loads(response) - _LOGGER.debug("< (%i) %s", len(response), pf(json_payload)) - - return json_payload - except Exception as ex: + await self.close() if retry >= retry_count: _LOGGER.debug("Giving up after %s retries", retry) raise SmartDeviceException( - "Unable to query the device: %s" % ex + f"Unable to query the device: {ex}" ) from ex _LOGGER.debug("Unable to query the device, retrying: %s", ex) - finally: - if writer: - writer.close() - await writer.wait_closed() - # make mypy happy, this should never be reached.. + await self.close() raise SmartDeviceException("Query reached somehow to unreachable") + def __del__(self): + if self.writer and self.loop and self.loop.is_running(): + self.writer.close() + self._reset() + @staticmethod def _xor_payload(unencrypted): key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 11c7d1c96..fabf26b32 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -194,7 +194,7 @@ def __init__(self, host: str) -> None: """ self.host = host - self.protocol = TPLinkSmartHomeProtocol() + self.protocol = TPLinkSmartHomeProtocol(host) self.emeter_type = "emeter" _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) self._device_type = DeviceType.Unknown @@ -234,7 +234,7 @@ async def _query_helper( request = self._create_request(target, cmd, arg, child_ids) try: - response = await self.protocol.query(host=self.host, request=request) + response = await self.protocol.query(request=request) except Exception as ex: raise SmartDeviceException(f"Communication error on {target}:{cmd}") from ex @@ -272,7 +272,7 @@ async def get_sys_info(self) -> Dict[str, Any]: """Retrieve system information.""" return await self._query_helper("system", "get_sysinfo") - async def update(self): + async def update(self, update_children: bool = True): """Query the device to update the data. Needed for properties that are decorated with `requires_update`. @@ -285,7 +285,7 @@ async def update(self): # See #105, #120, #161 if self._last_update is None: _LOGGER.debug("Performing the initial update to obtain sysinfo") - self._last_update = await self.protocol.query(self.host, req) + self._last_update = await self.protocol.query(req) self._sys_info = self._last_update["system"]["get_sysinfo"] # If the device has no emeter, we are done for the initial update # Otherwise we will follow the regular code path to also query @@ -299,7 +299,7 @@ async def update(self): ) req.update(self._create_emeter_request()) - self._last_update = await self.protocol.query(self.host, req) + self._last_update = await self.protocol.query(req) self._sys_info = self._last_update["system"]["get_sysinfo"] def update_from_discover_info(self, info): @@ -383,8 +383,8 @@ def location(self) -> Dict: loc["latitude"] = sys_info["latitude"] loc["longitude"] = sys_info["longitude"] elif "latitude_i" in sys_info and "longitude_i" in sys_info: - loc["latitude"] = sys_info["latitude_i"] - loc["longitude"] = sys_info["longitude_i"] + loc["latitude"] = sys_info["latitude_i"] / 10000 + loc["longitude"] = sys_info["longitude_i"] / 10000 else: _LOGGER.warning("Unsupported device location.") diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index c1235920d..71373a7a9 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -87,12 +87,12 @@ def is_on(self) -> bool: """Return if any of the outlets are on.""" return any(plug.is_on for plug in self.children) - async def update(self): + async def update(self, update_children: bool = True): """Update some of the attributes. Needed for methods that are decorated with `requires_update`. """ - await super().update() + await super().update(update_children) # Initialize the child devices during the first update. if not self.children: @@ -103,7 +103,7 @@ async def update(self): SmartStripPlug(self.host, parent=self, child_id=child["id"]) ) - if self.has_emeter: + if update_children and self.has_emeter: for plug in self.children: await plug.update() @@ -243,13 +243,13 @@ def __init__(self, host: str, parent: "SmartStrip", child_id: str) -> None: self._sys_info = parent._sys_info self._device_type = DeviceType.StripSocket - async def update(self): + async def update(self, update_children: bool = True): """Query the device to update the data. Needed for properties that are decorated with `requires_update`. """ self._last_update = await self.parent.protocol.query( - self.host, self._create_emeter_request() + self._create_emeter_request() ) def _create_request( diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index df253e5d5..a7ab5d13a 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -4,6 +4,7 @@ import os from os.path import basename from pathlib import Path, PurePath +from typing import Dict from unittest.mock import MagicMock import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 @@ -39,6 +40,8 @@ ALL_DEVICES = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS) +IP_MODEL_CACHE: Dict[str, str] = {} + def filter_model(desc, filter): filtered = list() @@ -137,23 +140,39 @@ def device_for_file(model): raise Exception("Unable to find type for %s", model) -def get_device_for_file(file): +async def _update_and_close(d): + await d.update() + await d.protocol.close() + return d + + +async def _discover_update_and_close(ip): + d = await Discover.discover_single(ip) + return await _update_and_close(d) + + +async def get_device_for_file(file): # if the wanted file is not an absolute path, prepend the fixtures directory p = Path(file) if not p.is_absolute(): p = Path(__file__).parent / "fixtures" / file - with open(p) as f: - sysinfo = json.load(f) - model = basename(file) - p = device_for_file(model)(host="127.0.0.123") - p.protocol = FakeTransportProtocol(sysinfo) - asyncio.run(p.update()) - return p + def load_file(): + with open(p) as f: + return json.load(f) + loop = asyncio.get_running_loop() + sysinfo = await loop.run_in_executor(None, load_file) -@pytest.fixture(params=SUPPORTED_DEVICES, scope="session") -def dev(request): + model = basename(file) + d = device_for_file(model)(host="127.0.0.123") + d.protocol = FakeTransportProtocol(sysinfo) + await _update_and_close(d) + return d + + +@pytest.fixture(params=SUPPORTED_DEVICES) +async def dev(request): """Device fixture. Provides a device (given --ip) or parametrized fixture for the supported devices. @@ -163,14 +182,16 @@ def dev(request): ip = request.config.getoption("--ip") if ip: - d = asyncio.run(Discover.discover_single(ip)) - asyncio.run(d.update()) - if d.model in file: - return d - else: + model = IP_MODEL_CACHE.get(ip) + d = None + if not model: + d = await _discover_update_and_close(ip) + 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) - return get_device_for_file(file) + return await get_device_for_file(file) @pytest.fixture(params=SUPPORTED_DEVICES, scope="session") diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index a37bb4147..a4764b660 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -83,9 +83,19 @@ def lb_dev_state(x): "icon_hash": str, "led_off": check_int_bool, "latitude": Any(All(float, Range(min=-90, max=90)), 0, None), - "latitude_i": Any(All(float, Range(min=-90, max=90)), 0, None), + "latitude_i": Any( + All(int, Range(min=-900000, max=900000)), + All(float, Range(min=-900000, max=900000)), + 0, + None, + ), "longitude": Any(All(float, Range(min=-180, max=180)), 0, None), - "longitude_i": Any(All(float, Range(min=-180, max=180)), 0, None), + "longitude_i": Any( + All(int, Range(min=-18000000, max=18000000)), + All(float, Range(min=-18000000, max=18000000)), + 0, + None, + ), "mac": check_mac, "model": str, "oemId": str, @@ -117,17 +127,17 @@ def lb_dev_state(x): { "brightness": All(int, Range(min=0, max=100)), "color_temp": int, - "hue": All(int, Range(min=0, max=255)), + "hue": All(int, Range(min=0, max=360)), "mode": str, "on_off": check_int_bool, - "saturation": All(int, Range(min=0, max=255)), + "saturation": All(int, Range(min=0, max=100)), "dft_on_state": Optional( { "brightness": All(int, Range(min=0, max=100)), "color_temp": All(int, Range(min=0, max=9000)), - "hue": All(int, Range(min=0, max=255)), + "hue": All(int, Range(min=0, max=360)), "mode": str, - "saturation": All(int, Range(min=0, max=255)), + "saturation": All(int, Range(min=0, max=100)), } ), "err_code": int, @@ -276,6 +286,8 @@ def success(res): class FakeTransportProtocol(TPLinkSmartHomeProtocol): def __init__(self, info): self.discovery_data = info + self.writer = None + self.reader = None proto = FakeTransportProtocol.baseproto for target in info: @@ -426,7 +438,7 @@ def light_state(self, x, *args): }, } - async def query(self, host, request, port=9999): + async def query(self, request, port=9999): proto = self.proto # collect child ids from context diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 28fcd4cb7..ea8a28cb8 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -60,7 +60,7 @@ async def test_hsv(dev, turn_on): assert dev.is_color hue, saturation, brightness = dev.hsv - assert 0 <= hue <= 255 + assert 0 <= hue <= 360 assert 0 <= saturation <= 100 assert 0 <= brightness <= 100 diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 13ba38099..c933cb124 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -3,7 +3,7 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 -from kasa import DeviceType, Discover, SmartDevice, SmartDeviceException +from kasa import DeviceType, Discover, SmartDevice, SmartDeviceException, protocol from kasa.discover import _DiscoverProtocol from .conftest import bulb, dimmer, lightstrip, plug, pytestmark, strip @@ -94,7 +94,8 @@ async def test_discover_datagram_received(mocker, discovery_data): """Verify that datagram received fills discovered_devices.""" proto = _DiscoverProtocol() mocker.patch("json.loads", return_value=discovery_data) - mocker.patch.object(proto, "protocol") + mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "encrypt") + mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "decrypt") addr = "127.0.0.1" proto.datagram_received("", (addr, 1234)) diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 51c01d49d..bc0da1833 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -1,4 +1,6 @@ import json +import struct +import sys import pytest @@ -21,11 +23,47 @@ def aio_mock_writer(_, __): conn = mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol.query("127.0.0.1", {}, retry_count=retry_count) + await TPLinkSmartHomeProtocol("127.0.0.1").query({}, retry_count=retry_count) assert conn.call_count == retry_count + 1 +@pytest.mark.skipif(sys.version_info < (3, 8), reason="3.8 is first one with asyncmock") +@pytest.mark.parametrize("retry_count", [1, 3, 5]) +async def test_protocol_reconnect(mocker, retry_count): + remaining = retry_count + encrypted = TPLinkSmartHomeProtocol.encrypt('{"great":"success"}')[ + TPLinkSmartHomeProtocol.BLOCK_SIZE : + ] + + def _fail_one_less_than_retry_count(*_): + nonlocal remaining + remaining -= 1 + if remaining: + raise Exception("Simulated write failure") + + async def _mock_read(byte_count): + nonlocal encrypted + if byte_count == TPLinkSmartHomeProtocol.BLOCK_SIZE: + return struct.pack(">I", len(encrypted)) + if byte_count == len(encrypted): + return encrypted + + raise ValueError(f"No mock for {byte_count}") + + def aio_mock_writer(_, __): + reader = mocker.patch("asyncio.StreamReader") + writer = mocker.patch("asyncio.StreamWriter") + mocker.patch.object(writer, "write", _fail_one_less_than_retry_count) + mocker.patch.object(reader, "readexactly", _mock_read) + return reader, writer + + protocol = TPLinkSmartHomeProtocol("127.0.0.1") + mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + response = await protocol.query({}, retry_count=retry_count) + assert response == {"great": "success"} + + def test_encrypt(): d = json.dumps({"foo": 1, "bar": 2}) encrypted = TPLinkSmartHomeProtocol.encrypt(d) diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index 27455dd84..a64c824c1 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -1,3 +1,4 @@ +import asyncio import sys import pytest @@ -8,7 +9,7 @@ def test_bulb_examples(mocker): """Use KL130 (bulb with all features) to test the doctests.""" - p = get_device_for_file("KL130(US)_1.0.json") + p = asyncio.run(get_device_for_file("KL130(US)_1.0.json")) mocker.patch("kasa.smartbulb.SmartBulb", return_value=p) mocker.patch("kasa.smartbulb.SmartBulb.update") res = xdoctest.doctest_module("kasa.smartbulb", "all") @@ -17,7 +18,7 @@ def test_bulb_examples(mocker): def test_smartdevice_examples(mocker): """Use HS110 for emeter examples.""" - p = get_device_for_file("HS110(EU)_1.0_real.json") + p = asyncio.run(get_device_for_file("HS110(EU)_1.0_real.json")) mocker.patch("kasa.smartdevice.SmartDevice", return_value=p) mocker.patch("kasa.smartdevice.SmartDevice.update") res = xdoctest.doctest_module("kasa.smartdevice", "all") @@ -26,7 +27,7 @@ def test_smartdevice_examples(mocker): def test_plug_examples(mocker): """Test plug examples.""" - p = get_device_for_file("HS110(EU)_1.0_real.json") + p = asyncio.run(get_device_for_file("HS110(EU)_1.0_real.json")) mocker.patch("kasa.smartplug.SmartPlug", return_value=p) mocker.patch("kasa.smartplug.SmartPlug.update") res = xdoctest.doctest_module("kasa.smartplug", "all") @@ -35,7 +36,7 @@ def test_plug_examples(mocker): def test_strip_examples(mocker): """Test strip examples.""" - p = get_device_for_file("KP303(UK)_1.0.json") + p = asyncio.run(get_device_for_file("KP303(UK)_1.0.json")) mocker.patch("kasa.smartstrip.SmartStrip", return_value=p) mocker.patch("kasa.smartstrip.SmartStrip.update") res = xdoctest.doctest_module("kasa.smartstrip", "all") @@ -44,7 +45,7 @@ def test_strip_examples(mocker): def test_dimmer_examples(mocker): """Test dimmer examples.""" - p = get_device_for_file("HS220(US)_1.0_real.json") + p = asyncio.run(get_device_for_file("HS220(US)_1.0_real.json")) mocker.patch("kasa.smartdimmer.SmartDimmer", return_value=p) mocker.patch("kasa.smartdimmer.SmartDimmer.update") res = xdoctest.doctest_module("kasa.smartdimmer", "all") @@ -53,7 +54,7 @@ def test_dimmer_examples(mocker): def test_lightstrip_examples(mocker): """Test lightstrip examples.""" - p = get_device_for_file("KL430(US)_1.0.json") + p = asyncio.run(get_device_for_file("KL430(US)_1.0.json")) mocker.patch("kasa.smartlightstrip.SmartLightStrip", return_value=p) mocker.patch("kasa.smartlightstrip.SmartLightStrip.update") res = xdoctest.doctest_module("kasa.smartlightstrip", "all") @@ -65,7 +66,7 @@ def test_lightstrip_examples(mocker): ) def test_discovery_examples(mocker): """Test discovery examples.""" - p = get_device_for_file("KP303(UK)_1.0.json") + p = asyncio.run(get_device_for_file("KP303(UK)_1.0.json")) # This succeeds on python 3.8 but fails on 3.7 # ValueError: a coroutine was expected, got [ Date: Fri, 24 Sep 2021 23:38:30 +0200 Subject: [PATCH 037/892] Release 0.4.0.dev5 (#215) This release introduces re-using the device connection to get rid of (sometimes slow) connection establishment. This is especially useful for emeter-enabled smart strips or any other usecases requiring consecutive I/O requests. [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev4...0.4.0.dev5) **Merged pull requests:** - Add KL130 fixture, initial lightstrip tests [\#214](https://github.com/python-kasa/python-kasa/pull/214) ([rytilahti](https://github.com/rytilahti)) - Keep connection open and lock to prevent duplicate requests [\#213](https://github.com/python-kasa/python-kasa/pull/213) ([bdraco](https://github.com/bdraco)) - Cleanup discovery & add tests [\#212](https://github.com/python-kasa/python-kasa/pull/212) ([rytilahti](https://github.com/rytilahti)) --- CHANGELOG.md | 13 ++++++++++++- pyproject.toml | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 437c756dd..18e0289c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,21 @@ # Changelog +## [0.4.0.dev5](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev5) (2021-09-24) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev4...0.4.0.dev5) + +**Merged pull requests:** + +- Add KL130 fixture, initial lightstrip tests [\#214](https://github.com/python-kasa/python-kasa/pull/214) ([rytilahti](https://github.com/rytilahti)) +- Keep connection open and lock to prevent duplicate requests [\#213](https://github.com/python-kasa/python-kasa/pull/213) ([bdraco](https://github.com/bdraco)) +- Cleanup discovery & add tests [\#212](https://github.com/python-kasa/python-kasa/pull/212) ([rytilahti](https://github.com/rytilahti)) + ## [0.4.0.dev4](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev4) (2021-09-23) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev3...0.4.0.dev4) **Implemented enhancements:** -- HS300 Children plugs have emeter [\#64](https://github.com/python-kasa/python-kasa/issues/64) - Improve emeterstatus API, move into own module [\#205](https://github.com/python-kasa/python-kasa/pull/205) ([rytilahti](https://github.com/rytilahti)) - Avoid temp array during encrypt and decrypt [\#204](https://github.com/python-kasa/python-kasa/pull/204) ([bdraco](https://github.com/bdraco)) - Add emeter support for strip sockets [\#203](https://github.com/python-kasa/python-kasa/pull/203) ([bdraco](https://github.com/bdraco)) @@ -19,6 +28,7 @@ **Fixed bugs:** - KL430: Throw error for Device specific information [\#189](https://github.com/python-kasa/python-kasa/issues/189) +- HS300 Children plugs have emeter [\#64](https://github.com/python-kasa/python-kasa/issues/64) - dump\_devinfo: handle latitude/longitude keys properly [\#175](https://github.com/python-kasa/python-kasa/pull/175) ([rytilahti](https://github.com/rytilahti)) **Closed issues:** @@ -34,6 +44,7 @@ **Merged pull requests:** +- Release 0.4.0.dev4 [\#210](https://github.com/python-kasa/python-kasa/pull/210) ([rytilahti](https://github.com/rytilahti)) - More CI fixes [\#208](https://github.com/python-kasa/python-kasa/pull/208) ([rytilahti](https://github.com/rytilahti)) - Fix CI dep installation [\#207](https://github.com/python-kasa/python-kasa/pull/207) ([rytilahti](https://github.com/rytilahti)) - Use github actions instead of azure pipelines [\#206](https://github.com/python-kasa/python-kasa/pull/206) ([rytilahti](https://github.com/rytilahti)) diff --git a/pyproject.toml b/pyproject.toml index 87ddba9d6..efcf92bdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.4.0.dev4" +version = "0.4.0.dev5" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["Your Name "] From 76c1264dc90077407f43157097bc978ad60300ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Sep 2021 09:50:58 -0500 Subject: [PATCH 038/892] Avoid calling pformat unless debug logging is enabled (#217) * Avoid calling pformat unless debug logging is enabled * add logging test * isort * check for debug logging * formatting --- kasa/protocol.py | 7 +++++-- kasa/tests/test_protocol.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/kasa/protocol.py b/kasa/protocol.py index b54029c66..f4e1585ef 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -90,8 +90,10 @@ async def _execute_query(self, request: str) -> Dict: """Execute a query on the device and wait for the response.""" assert self.writer is not None assert self.reader is not None + debug_log = _LOGGER.isEnabledFor(logging.DEBUG) - _LOGGER.debug("> (%i) %s", len(request), request) + if debug_log: + _LOGGER.debug("> (%i) %s", len(request), request) self.writer.write(TPLinkSmartHomeProtocol.encrypt(request)) await self.writer.drain() @@ -101,7 +103,8 @@ async def _execute_query(self, request: str) -> Dict: buffer = await self.reader.readexactly(length) response = TPLinkSmartHomeProtocol.decrypt(buffer) json_payload = json.loads(response) - _LOGGER.debug("< (%i) %s", len(response), pf(json_payload)) + if debug_log: + _LOGGER.debug("< (%i) %s", len(response), pf(json_payload)) return json_payload async def close(self): diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index bc0da1833..5fe4763d5 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -1,4 +1,5 @@ import json +import logging import struct import sys @@ -64,6 +65,39 @@ def aio_mock_writer(_, __): assert response == {"great": "success"} +@pytest.mark.skipif(sys.version_info < (3, 8), reason="3.8 is first one with asyncmock") +@pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG]) +async def test_protocol_logging(mocker, caplog, log_level): + caplog.set_level(log_level) + logging.getLogger("kasa").setLevel(log_level) + encrypted = TPLinkSmartHomeProtocol.encrypt('{"great":"success"}')[ + TPLinkSmartHomeProtocol.BLOCK_SIZE : + ] + + async def _mock_read(byte_count): + nonlocal encrypted + if byte_count == TPLinkSmartHomeProtocol.BLOCK_SIZE: + return struct.pack(">I", len(encrypted)) + if byte_count == len(encrypted): + return encrypted + raise ValueError(f"No mock for {byte_count}") + + def aio_mock_writer(_, __): + reader = mocker.patch("asyncio.StreamReader") + writer = mocker.patch("asyncio.StreamWriter") + mocker.patch.object(reader, "readexactly", _mock_read) + return reader, writer + + protocol = TPLinkSmartHomeProtocol("127.0.0.1") + mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + response = await protocol.query({}) + assert response == {"great": "success"} + if log_level == logging.DEBUG: + assert "success" in caplog.text + else: + assert "success" not in caplog.text + + def test_encrypt(): d = json.dumps({"foo": 1, "bar": 2}) encrypted = TPLinkSmartHomeProtocol.encrypt(d) From 3cf549e32e29271f59a416c3e6e2da026eda6fe1 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 26 Sep 2021 19:16:12 +0200 Subject: [PATCH 039/892] Add host information to protocol debug logs (#219) --- kasa/protocol.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/kasa/protocol.py b/kasa/protocol.py index f4e1585ef..f08281070 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -93,7 +93,7 @@ async def _execute_query(self, request: str) -> Dict: debug_log = _LOGGER.isEnabledFor(logging.DEBUG) if debug_log: - _LOGGER.debug("> (%i) %s", len(request), request) + _LOGGER.debug("%s >> %s", self.host, request) self.writer.write(TPLinkSmartHomeProtocol.encrypt(request)) await self.writer.drain() @@ -104,7 +104,8 @@ async def _execute_query(self, request: str) -> Dict: response = TPLinkSmartHomeProtocol.decrypt(buffer) json_payload = json.loads(response) if debug_log: - _LOGGER.debug("< (%i) %s", len(response), pf(json_payload)) + _LOGGER.debug("%s << %s", self.host, pf(json_payload)) + return json_payload async def close(self): @@ -129,7 +130,7 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: if not await self._connect(timeout): await self.close() if retry >= retry_count: - _LOGGER.debug("Giving up after %s retries", retry) + _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) raise SmartDeviceException( f"Unable to connect to the device: {self.host}" ) @@ -144,12 +145,14 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: except Exception as ex: await self.close() if retry >= retry_count: - _LOGGER.debug("Giving up after %s retries", retry) + _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) raise SmartDeviceException( - f"Unable to query the device: {ex}" + f"Unable to query the device {self.host}: {ex}" ) from ex - _LOGGER.debug("Unable to query the device, retrying: %s", ex) + _LOGGER.debug( + "Unable to query the device %s, retrying: %s", self.host, ex + ) # make mypy happy, this should never be reached.. await self.close() From 33bc38b57f4a87d83f051e9636fffb491a43ac10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Sep 2021 12:38:33 -0500 Subject: [PATCH 040/892] Fix lock being unexpectedly reset on close (#218) * Implement a backoff for legacy devices * do not clear self.query_lock at close() * revert backoff --- kasa/protocol.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/kasa/protocol.py b/kasa/protocol.py index f08281070..54fea0803 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -28,7 +28,6 @@ class TPLinkSmartHomeProtocol: INITIALIZATION_VECTOR = 171 DEFAULT_PORT = 9999 DEFAULT_TIMEOUT = 5 - BLOCK_SIZE = 4 def __init__(self, host: str) -> None: @@ -111,7 +110,7 @@ async def _execute_query(self, request: str) -> Dict: async def close(self): """Close the connection.""" writer = self.writer - self._reset() + self.reader = self.writer = None if writer: writer.close() with contextlib.suppress(Exception): @@ -119,10 +118,7 @@ async def close(self): def _reset(self): """Clear any varibles that should not survive between loops.""" - self.writer = None - self.reader = None - self.query_lock = None - self.loop = None + self.reader = self.writer = self.loop = self.query_lock = None async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: """Try to query a device.""" From 194aa8607b1eac2746d539b9e16512f78df89048 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 26 Sep 2021 19:56:40 +0200 Subject: [PATCH 041/892] Add github workflow for pypi publishing (#220) --- .github/workflows/publish.yml | 43 +++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..64cb7ebd4 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,43 @@ +name: Publish packages +on: + push: + branches: + - master + +jobs: + build-n-publish: + name: Build release packages + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@master + - name: Setup python + uses: actions/setup-python@v1 + with: + python-version: 3.9 + + - name: Install pypa/build + run: >- + python -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: >- + python -m + build + --sdist + --wheel + --outdir dist/ + . + - name: Publish on test pypi + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + repository_url: https://test.pypi.org/legacy/ + + - name: Publish release on pypi + if: startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.PYPI_API_TOKEN }} From 1a3c73e42fa441a67b0e86a83cd01c27d124fbc5 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 27 Sep 2021 19:10:05 +0200 Subject: [PATCH 042/892] Release 0.4.0 (#221) This is the first proper python-kasa release since forking from pyhs100. * Improved I/O handling, including asyncio interface, request merging & connection sharing * API improvements throughout the whole package * Support for LED strips * Improved bulb support (transitions, support for more models) * Onboarding is now possible without a mobile app * Improved documentation * And various other improvements, see the full changelog for details! Thanks to all contributors, from testers, and issue reporters to those who have submitted pull requests! Thanks also to those who donated test devices to help to make this release happen! Special thanks for this release go to @basnijholt (initial asyncio port, push to make this fork happen) and @bdraco (fixing the last release blocker, emeter support for powerstrips). If you are using python-kasa in your projects, we would be happy to hear about it. Feel free to post a note on Github discussions! If it is a project that could be interesting for other users and/or developers, feel also free to create a PR to add a short note to the README file. --- CHANGELOG.md | 24 +++++++++++++++++++++++- README.md | 4 ++-- RELEASING.md | 16 ++-------------- pyproject.toml | 3 ++- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e0289c0..618607160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,35 @@ # Changelog +## [0.4.0](https://github.com/python-kasa/python-kasa/tree/0.4.0) (2021-09-26) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev5...0.4.0) + +**Implemented enhancements:** + +- Fix lock being unexpectedly reset on close [\#218](https://github.com/python-kasa/python-kasa/pull/218) ([bdraco](https://github.com/bdraco)) +- Avoid calling pformat unless debug logging is enabled [\#217](https://github.com/python-kasa/python-kasa/pull/217) ([bdraco](https://github.com/bdraco)) + +**Closed issues:** + +- Debug logging in protocol.py is the majority of the execution time [\#216](https://github.com/python-kasa/python-kasa/issues/216) + +**Merged pull requests:** + +- Add github workflow for pypi publishing [\#220](https://github.com/python-kasa/python-kasa/pull/220) ([rytilahti](https://github.com/rytilahti)) +- Add host information to protocol debug logs [\#219](https://github.com/python-kasa/python-kasa/pull/219) ([rytilahti](https://github.com/rytilahti)) + ## [0.4.0.dev5](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev5) (2021-09-24) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev4...0.4.0.dev5) +**Implemented enhancements:** + +- Keep connection open and lock to prevent duplicate requests [\#213](https://github.com/python-kasa/python-kasa/pull/213) ([bdraco](https://github.com/bdraco)) + **Merged pull requests:** +- Release 0.4.0.dev5 [\#215](https://github.com/python-kasa/python-kasa/pull/215) ([rytilahti](https://github.com/rytilahti)) - Add KL130 fixture, initial lightstrip tests [\#214](https://github.com/python-kasa/python-kasa/pull/214) ([rytilahti](https://github.com/rytilahti)) -- Keep connection open and lock to prevent duplicate requests [\#213](https://github.com/python-kasa/python-kasa/pull/213) ([bdraco](https://github.com/bdraco)) - Cleanup discovery & add tests [\#212](https://github.com/python-kasa/python-kasa/pull/212) ([rytilahti](https://github.com/rytilahti)) ## [0.4.0.dev4](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev4) (2021-09-23) diff --git a/README.md b/README.md index e1136d1f5..89e4f630a 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ This project is a maintainer-made fork of [pyHS100](https://github.com/GadgetRea ## Getting started -You can install the most recent release using pip. Until +You can install the most recent release using pip: ``` -pip install python-kasa --pre +pip install python-kasa ``` Alternatively, you can clone this repository and use poetry to install the development version: diff --git a/RELEASING.md b/RELEASING.md index fc01a87de..adc8a05e5 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -39,25 +39,13 @@ git fetch upstream git rebase upstream/master ``` -5. Tag the release (add short changelog as a tag commit message), push the tag to git +7. Tag the release (add short changelog as a tag commit message), push the tag to git ```bash git tag -a $NEW_RELEASE git push upstream $NEW_RELEASE ``` -7. Upload new version to pypi - -If not done already, create an API key for pypi (https://pypi.org/manage/account/token/) and configure it: -``` -poetry config pypi-token.pypi -``` - -To build & release: - -```bash -poetry build -poetry publish -``` +All tags on master branch will trigger a new release on pypi. 8. Click the "Draft a new release" button on github, select the new tag and copy & paste the changelog into the description. diff --git a/pyproject.toml b/pyproject.toml index efcf92bdb..375429065 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.4.0.dev5" +version = "0.4.0" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["Your Name "] @@ -9,6 +9,7 @@ readme = "README.md" packages = [ { include = "kasa" } ] +include = ["CHANGELOG.md"] [tool.poetry.scripts] kasa = "kasa.cli:cli" From 7b99f7e9a4a7a44aabd5104f8e43672f634de722 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 2 Oct 2021 00:51:16 +0200 Subject: [PATCH 043/892] Switch to poetry-core (#226) --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 375429065..0671d41b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,5 +78,5 @@ markers = [ ] [build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" From 0bcab39e60874ba2c17ffe1322c5205b2c44a53c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Oct 2021 08:41:14 -0500 Subject: [PATCH 044/892] Add fixtures for LB110, KL110, EP40, KL430, KP115 (#224) * Add fixtures for LB110, KL110, EP40 * update schema * kl430 fixture * Add KP115 US fixture --- kasa/tests/conftest.py | 4 +- kasa/tests/fixtures/EP40(US)_1.0_1.0.2.json | 45 +++++++++ kasa/tests/fixtures/HS103(US)_2.1_1.1.4.json | 31 ++++++ kasa/tests/fixtures/HS300(US)_2.0_1.0.3.json | 90 ++++++++++++++++++ kasa/tests/fixtures/KL110(US)_1.0_1.8.11.json | 89 ++++++++++++++++++ kasa/tests/fixtures/KL125(US)_2.0_1.0.7.json | 92 ++++++++++++++++++ kasa/tests/fixtures/KL130(US)_1.0_1.8.11.json | 89 ++++++++++++++++++ kasa/tests/fixtures/KL430(US)_2.0_1.0.8.json | 59 ++++++++++++ kasa/tests/fixtures/KL60(US)_1.0_1.1.13.json | 94 +++++++++++++++++++ kasa/tests/fixtures/KP115(US)_1.0_1.0.17.json | 42 +++++++++ kasa/tests/fixtures/KP303(US)_2.0_1.0.3.json | 54 +++++++++++ kasa/tests/fixtures/LB110(US)_1.0_1.8.11.json | 89 ++++++++++++++++++ kasa/tests/newfakes.py | 1 + 13 files changed, 777 insertions(+), 2 deletions(-) create mode 100644 kasa/tests/fixtures/EP40(US)_1.0_1.0.2.json create mode 100644 kasa/tests/fixtures/HS103(US)_2.1_1.1.4.json create mode 100644 kasa/tests/fixtures/HS300(US)_2.0_1.0.3.json create mode 100644 kasa/tests/fixtures/KL110(US)_1.0_1.8.11.json create mode 100644 kasa/tests/fixtures/KL125(US)_2.0_1.0.7.json create mode 100644 kasa/tests/fixtures/KL130(US)_1.0_1.8.11.json create mode 100644 kasa/tests/fixtures/KL430(US)_2.0_1.0.8.json create mode 100644 kasa/tests/fixtures/KL60(US)_1.0_1.1.13.json create mode 100644 kasa/tests/fixtures/KP115(US)_1.0_1.0.17.json create mode 100644 kasa/tests/fixtures/KP303(US)_2.0_1.0.3.json create mode 100644 kasa/tests/fixtures/LB110(US)_1.0_1.8.11.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index a7ab5d13a..a10be415f 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -28,11 +28,11 @@ LIGHT_STRIPS = {"KL430"} VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL125", "KL130", "KL430", *LIGHT_STRIPS} COLOR_BULBS = {"LB130", "KL125", "KL130", *LIGHT_STRIPS} -BULBS = {"KL60", "LB100", *VARIABLE_TEMP, *COLOR_BULBS, *LIGHT_STRIPS} +BULBS = {"KL60", "LB100", "LB110", "KL110", *VARIABLE_TEMP, *COLOR_BULBS, *LIGHT_STRIPS} PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210", "EP10", "KP115"} -STRIPS = {"HS107", "HS300", "KP303", "KP400"} +STRIPS = {"HS107", "HS300", "KP303", "KP400", "EP40"} DIMMERS = {"HS220"} DIMMABLE = {*BULBS, *DIMMERS} diff --git a/kasa/tests/fixtures/EP40(US)_1.0_1.0.2.json b/kasa/tests/fixtures/EP40(US)_1.0_1.0.2.json new file mode 100644 index 000000000..238265a2a --- /dev/null +++ b/kasa/tests/fixtures/EP40(US)_1.0_1.0.2.json @@ -0,0 +1,45 @@ +{ + "system": { + "get_sysinfo": { + "alias": "TP-LINK_Smart Plug_004F", + "child_num": 2, + "children": [ + { + "alias": "Zombie", + "id": "8006231E1499BAC4D4BC7EFCD4B075181E6393F200", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "Magic", + "id": "8006231E1499BAC4D4BC7EFCD4B075181E6393F201", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "EP40(US)", + "ntc_state": 0, + "oemId": "00000000000000000000000000000000", + "rssi": -47, + "status": "new", + "sw_ver": "1.0.2 Build 210105 Rel.165938", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/HS103(US)_2.1_1.1.4.json b/kasa/tests/fixtures/HS103(US)_2.1_1.1.4.json new file mode 100644 index 000000000..819c5bdd6 --- /dev/null +++ b/kasa/tests/fixtures/HS103(US)_2.1_1.1.4.json @@ -0,0 +1,31 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Plug", + "dev_name": "Smart Wi-Fi Plug Lite", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.1", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS103(US)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -48, + "status": "new", + "sw_ver": "1.1.4 Build 210409 Rel.113427", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/HS300(US)_2.0_1.0.3.json b/kasa/tests/fixtures/HS300(US)_2.0_1.0.3.json new file mode 100644 index 000000000..3b99cf36e --- /dev/null +++ b/kasa/tests/fixtures/HS300(US)_2.0_1.0.3.json @@ -0,0 +1,90 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 0, + "slot_id": 0, + "total_wh": 0, + "voltage_mv": 121302 + } + }, + "system": { + "get_sysinfo": { + "alias": "TP-LINK_Power Strip_5C33", + "child_num": 6, + "children": [ + { + "alias": "Plug 1", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031900", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "Plug 2", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031901", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "Plug 3", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031902", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "Plug 4", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031903", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "Plug 5", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031904", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "Plug 6", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031905", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS300(US)", + "oemId": "00000000000000000000000000000000", + "rssi": -55, + "status": "new", + "sw_ver": "1.0.3 Build 201203 Rel.165457", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/KL110(US)_1.0_1.8.11.json b/kasa/tests/fixtures/KL110(US)_1.0_1.8.11.json new file mode 100644 index 000000000..94c388580 --- /dev/null +++ b/kasa/tests/fixtures/KL110(US)_1.0_1.8.11.json @@ -0,0 +1,89 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 0 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "dft_on_state": { + "brightness": 95, + "color_temp": 2700, + "hue": 0, + "mode": "normal", + "saturation": 0 + }, + "err_code": 0, + "on_off": 0 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Bulb3", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Smart Wi-Fi LED Bulb with Dimmable Light", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "heapsize": 291620, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 0, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "light_state": { + "dft_on_state": { + "brightness": 95, + "color_temp": 2700, + "hue": 0, + "mode": "normal", + "saturation": 0 + }, + "on_off": 0 + }, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL110(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 75, + "color_temp": 2700, + "hue": 0, + "index": 1, + "saturation": 0 + }, + { + "brightness": 25, + "color_temp": 2700, + "hue": 0, + "index": 2, + "saturation": 0 + }, + { + "brightness": 1, + "color_temp": 2700, + "hue": 0, + "index": 3, + "saturation": 0 + } + ], + "rssi": -64, + "sw_ver": "1.8.11 Build 191113 Rel.105336" + } + } +} diff --git a/kasa/tests/fixtures/KL125(US)_2.0_1.0.7.json b/kasa/tests/fixtures/KL125(US)_2.0_1.0.7.json new file mode 100644 index 000000000..b7fa640bf --- /dev/null +++ b/kasa/tests/fixtures/KL125(US)_2.0_1.0.7.json @@ -0,0 +1,92 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 0, + "total_wh": 238 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "dft_on_state": { + "brightness": 100, + "color_temp": 2500, + "hue": 255, + "mode": "normal", + "saturation": 100 + }, + "err_code": 0, + "on_off": 0 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Test bulb 6", + "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": "2.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "light_state": { + "dft_on_state": { + "brightness": 100, + "color_temp": 2500, + "hue": 255, + "mode": "normal", + "saturation": 100 + }, + "on_off": 0 + }, + "longitude_i": 0, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL125(US)", + "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": -63, + "status": "new", + "sw_ver": "1.0.7 Build 210811 Rel.171439" + } + } +} diff --git a/kasa/tests/fixtures/KL130(US)_1.0_1.8.11.json b/kasa/tests/fixtures/KL130(US)_1.0_1.8.11.json new file mode 100644 index 000000000..3ee4cb2e7 --- /dev/null +++ b/kasa/tests/fixtures/KL130(US)_1.0_1.8.11.json @@ -0,0 +1,89 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 0 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "dft_on_state": { + "brightness": 30, + "color_temp": 0, + "hue": 240, + "mode": "normal", + "saturation": 100 + }, + "err_code": 0, + "on_off": 0 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Bulb2", + "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, + "heapsize": 305252, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "light_state": { + "dft_on_state": { + "brightness": 30, + "color_temp": 0, + "hue": 240, + "mode": "normal", + "saturation": 100 + }, + "on_off": 0 + }, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL130(US)", + "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": 75 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "index": 2, + "saturation": 75 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "index": 3, + "saturation": 75 + } + ], + "rssi": -52, + "sw_ver": "1.8.11 Build 191113 Rel.105336" + } + } +} diff --git a/kasa/tests/fixtures/KL430(US)_2.0_1.0.8.json b/kasa/tests/fixtures/KL430(US)_2.0_1.0.8.json new file mode 100644 index 000000000..e69a9dc1f --- /dev/null +++ b/kasa/tests/fixtures/KL430(US)_2.0_1.0.8.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": "89 strip", + "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": 50, + "color_temp": 0, + "hue": 30, + "mode": "normal", + "saturation": 100 + }, + "on_off": 0 + }, + "lighting_effect_state": { + "brightness": 50, + "custom": 0, + "enable": 0, + "id": "", + "name": "station" + }, + "longitude_i": 0, + "mic_mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL430(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -61, + "status": "new", + "sw_ver": "1.0.8 Build 210121 Rel.084339" + } + } +} diff --git a/kasa/tests/fixtures/KL60(US)_1.0_1.1.13.json b/kasa/tests/fixtures/KL60(US)_1.0_1.1.13.json new file mode 100644 index 000000000..a6ec2b910 --- /dev/null +++ b/kasa/tests/fixtures/KL60(US)_1.0_1.1.13.json @@ -0,0 +1,94 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 0, + "total_wh": 0, + "voltage_mv": 0 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "dft_on_state": { + "brightness": 69, + "color_temp": 2000, + "hue": 0, + "mode": "normal", + "saturation": 0 + }, + "err_code": 0, + "on_off": 0 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Fil", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Kasa Smart Edison Bulb, Dimmable", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 0, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "latitude_i": 0, + "light_state": { + "dft_on_state": { + "brightness": 69, + "color_temp": 2000, + "hue": 0, + "mode": "normal", + "saturation": 0 + }, + "on_off": 0 + }, + "longitude_i": 0, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL60(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 2000, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 75, + "color_temp": 2000, + "hue": 0, + "index": 1, + "saturation": 0 + }, + { + "brightness": 25, + "color_temp": 2000, + "hue": 0, + "index": 2, + "saturation": 0 + }, + { + "brightness": 1, + "color_temp": 2000, + "hue": 0, + "index": 3, + "saturation": 0 + } + ], + "rssi": -55, + "status": "new", + "sw_ver": "1.1.13 Build 210524 Rel.082619" + } + } +} diff --git a/kasa/tests/fixtures/KP115(US)_1.0_1.0.17.json b/kasa/tests/fixtures/KP115(US)_1.0_1.0.17.json new file mode 100644 index 000000000..afb5a5fe4 --- /dev/null +++ b/kasa/tests/fixtures/KP115(US)_1.0_1.0.17.json @@ -0,0 +1,42 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 0, + "total_wh": 0, + "voltage_mv": 121148 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Test plug", + "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": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP115(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 301, + "relay_state": 1, + "rssi": -59, + "status": "new", + "sw_ver": "1.0.17 Build 210506 Rel.075231", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/KP303(US)_2.0_1.0.3.json b/kasa/tests/fixtures/KP303(US)_2.0_1.0.3.json new file mode 100644 index 000000000..96c2f8c96 --- /dev/null +++ b/kasa/tests/fixtures/KP303(US)_2.0_1.0.3.json @@ -0,0 +1,54 @@ +{ + "system": { + "get_sysinfo": { + "alias": "TP-LINK_Power Strip_BDF6", + "child_num": 3, + "children": [ + { + "alias": "Plug 1", + "id": "800681855E0E9AEF096F4891B3DC88C71E59F42E00", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "Plug 2", + "id": "800681855E0E9AEF096F4891B3DC88C71E59F42E01", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "Plug 3", + "id": "800681855E0E9AEF096F4891B3DC88C71E59F42E02", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP303(US)", + "ntc_state": 0, + "oemId": "00000000000000000000000000000000", + "rssi": -56, + "status": "new", + "sw_ver": "1.0.3 Build 201015 Rel.173920", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/LB110(US)_1.0_1.8.11.json b/kasa/tests/fixtures/LB110(US)_1.0_1.8.11.json new file mode 100644 index 000000000..ec49e91bf --- /dev/null +++ b/kasa/tests/fixtures/LB110(US)_1.0_1.8.11.json @@ -0,0 +1,89 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 0 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "dft_on_state": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "mode": "normal", + "saturation": 0 + }, + "err_code": 0, + "on_off": 0 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "TP-LINK_Smart Bulb_43EC", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Smart Wi-Fi LED Bulb with Dimmable Light", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "heapsize": 290048, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 0, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "light_state": { + "dft_on_state": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "mode": "normal", + "saturation": 0 + }, + "on_off": 0 + }, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "LB110(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 75, + "color_temp": 2700, + "hue": 0, + "index": 1, + "saturation": 0 + }, + { + "brightness": 25, + "color_temp": 2700, + "hue": 0, + "index": 2, + "saturation": 0 + }, + { + "brightness": 1, + "color_temp": 2700, + "hue": 0, + "index": 3, + "saturation": 0 + } + ], + "rssi": -59, + "sw_ver": "1.8.11 Build 191113 Rel.105336" + } + } +} diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index a4764b660..2afae2aa3 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -62,6 +62,7 @@ def lb_dev_state(x): "current_ma": Any( All(float, Range(min=0)), int, None ), # TODO can this be int? + "slot_id": Any(Coerce(int, Range(min=0)), None), }, None, ) From 98b4155c117649bfab342a861f071b3a3d0be366 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Oct 2021 08:40:31 -1000 Subject: [PATCH 045/892] Add fixture for newer KP400 firmware (#227) --- kasa/tests/fixtures/KP400(US)_2.0_1.0.6.json | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 kasa/tests/fixtures/KP400(US)_2.0_1.0.6.json diff --git a/kasa/tests/fixtures/KP400(US)_2.0_1.0.6.json b/kasa/tests/fixtures/KP400(US)_2.0_1.0.6.json new file mode 100644 index 000000000..23cd22d11 --- /dev/null +++ b/kasa/tests/fixtures/KP400(US)_2.0_1.0.6.json @@ -0,0 +1,45 @@ +{ + "system": { + "get_sysinfo": { + "alias": "TP-LINK_Smart Plug_DC2A", + "child_num": 2, + "children": [ + { + "alias": "Anc ", + "id": "8006B8E953CC4149E2B13AA27E0D18EF1DCFBF3400", + "next_action": { + "type": -1 + }, + "on_time": 313, + "state": 1 + }, + { + "alias": "Plug 2", + "id": "8006B8E953CC4149E2B13AA27E0D18EF1DCFBF3401", + "next_action": { + "type": -1 + }, + "on_time": 313, + "state": 1 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP400(US)", + "ntc_state": 0, + "oemId": "00000000000000000000000000000000", + "rssi": -46, + "status": "new", + "sw_ver": "1.0.6 Build 200821 Rel.090909", + "updating": 0 + } + } +} From c65705bbbf66d8283040e14fdb61558e563b451f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Oct 2021 12:15:32 -1000 Subject: [PATCH 046/892] Add KL400, KL50 fixtures (#231) * Add KL400 fixture * Add KL400 fixture * Add KL50 fixture * tweaks --- README.md | 1 + kasa/tests/conftest.py | 15 +++- .../tests/fixtures/KL400L5(US)_1.0_1.0.5.json | 57 ++++++++++++ kasa/tests/fixtures/KL50(US)_1.0_1.1.13.json | 90 +++++++++++++++++++ 4 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 kasa/tests/fixtures/KL400L5(US)_1.0_1.0.5.json create mode 100644 kasa/tests/fixtures/KL50(US)_1.0_1.1.13.json diff --git a/README.md b/README.md index 89e4f630a..3b13f7196 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. ### Light strips +* KL400 * KL430 **Contributions (be it adding missing features, fixing bugs or improving documentation) are more than welcome, feel free to submit pull requests!** diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index a10be415f..14229215a 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -25,10 +25,19 @@ ) -LIGHT_STRIPS = {"KL430"} -VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL125", "KL130", "KL430", *LIGHT_STRIPS} +LIGHT_STRIPS = {"KL400", "KL430"} +VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL125", "KL130", "KL430"} COLOR_BULBS = {"LB130", "KL125", "KL130", *LIGHT_STRIPS} -BULBS = {"KL60", "LB100", "LB110", "KL110", *VARIABLE_TEMP, *COLOR_BULBS, *LIGHT_STRIPS} +BULBS = { + "KL50", + "KL60", + "LB100", + "LB110", + "KL110", + *VARIABLE_TEMP, + *COLOR_BULBS, + *LIGHT_STRIPS, +} PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210", "EP10", "KP115"} diff --git a/kasa/tests/fixtures/KL400L5(US)_1.0_1.0.5.json b/kasa/tests/fixtures/KL400L5(US)_1.0_1.0.5.json new file mode 100644 index 000000000..64adf5555 --- /dev/null +++ b/kasa/tests/fixtures/KL400L5(US)_1.0_1.0.5.json @@ -0,0 +1,57 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 10800, + "total_wh": 0 + } + }, + "system": { + "get_sysinfo": { + "LEF": 0, + "active_mode": "none", + "alias": "Kl400", + "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": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "latitude_i": 0, + "length": 16, + "light_state": { + "brightness": 100, + "color_temp": 6500, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "lighting_effect_state": { + "brightness": 50, + "custom": 0, + "enable": 0, + "id": "", + "name": "station" + }, + "longitude_i": 0, + "mic_mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL400L5(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -58, + "status": "new", + "sw_ver": "1.0.5 Build 210616 Rel.122727" + } + } +} diff --git a/kasa/tests/fixtures/KL50(US)_1.0_1.1.13.json b/kasa/tests/fixtures/KL50(US)_1.0_1.1.13.json new file mode 100644 index 000000000..f3e43c9a5 --- /dev/null +++ b/kasa/tests/fixtures/KL50(US)_1.0_1.1.13.json @@ -0,0 +1,90 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 998, + "total_wh": 1, + "voltage_mv": 0 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "brightness": 12, + "color_temp": 2700, + "err_code": 0, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Kl50", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Kasa Smart Edison Bulb, Dimmable", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 0, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "latitude_i": 0, + "light_state": { + "brightness": 12, + "color_temp": 2700, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "longitude_i": 0, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL50(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 75, + "color_temp": 2700, + "hue": 0, + "index": 1, + "saturation": 0 + }, + { + "brightness": 25, + "color_temp": 2700, + "hue": 0, + "index": 2, + "saturation": 0 + }, + { + "brightness": 1, + "color_temp": 2700, + "hue": 0, + "index": 3, + "saturation": 0 + } + ], + "rssi": -68, + "status": "new", + "sw_ver": "1.1.13 Build 210524 Rel.082619" + } + } +} From e06b9a71e5444b6e21de5a6d2ac140d23304460d Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 9 Oct 2021 16:36:36 +0200 Subject: [PATCH 047/892] Make cli interface more consistent (#232) --- .gitchangelog.rc | 1 - .hound.yml | 5 ---- kasa/cli.py | 72 +++++++++++++++++++++--------------------------- 3 files changed, 32 insertions(+), 46 deletions(-) delete mode 100644 .gitchangelog.rc delete mode 100644 .hound.yml diff --git a/.gitchangelog.rc b/.gitchangelog.rc deleted file mode 100644 index 8d6e74ec7..000000000 --- a/.gitchangelog.rc +++ /dev/null @@ -1 +0,0 @@ -include_merge = False diff --git a/.hound.yml b/.hound.yml deleted file mode 100644 index 974239e02..000000000 --- a/.hound.yml +++ /dev/null @@ -1,5 +0,0 @@ -python: - enabled: true - config_file: tox.ini -flake8: - enabled: true diff --git a/kasa/cli.py b/kasa/cli.py index 209fcf965..c23019ecf 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -84,6 +84,8 @@ async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip): else: click.echo("Unable to detect type, use --strip or --bulb or --plug!") return + + await dev.update() ctx.obj = dev if ctx.invoked_subcommand is None: @@ -106,6 +108,8 @@ async def scan(dev): for dev in devs: click.echo(f"\t {dev}") + return devs + @wifi.command() @click.argument("ssid") @@ -120,18 +124,7 @@ async def join(dev: SmartDevice, ssid, password, keytype): f"Response: {res} - if the device is not able to join the network, it will revert back to its previous state." ) - -@cli.command() -@click.option("--scrub/--no-scrub", default=True) -@click.pass_context -async def dump_discover(ctx, scrub): - """Dump discovery information. - - Useful for dumping into a file to be added to the test suite. - """ - click.echo( - "This is deprecated, use the script inside devtools to generate a devinfo file." - ) + return res @cli.command() @@ -158,17 +151,13 @@ async def discover(ctx, timeout, discover_only, dump_raw): async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3): """Discover a device identified by its alias.""" - click.echo( - f"Trying to discover {alias} using {attempts} attempts of {timeout} seconds" - ) for attempt in range(1, attempts): - click.echo(f"Attempt {attempt} of {attempts}") found_devs = await Discover.discover(target=target, timeout=timeout) - found_devs = found_devs.items() - for ip, dev in found_devs: + for ip, dev in found_devs.items(): if dev.alias.lower() == alias.lower(): host = dev.host return host + return None @@ -176,9 +165,9 @@ async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attem @pass_dev async def sysinfo(dev): """Print out full system information.""" - await dev.update() click.echo(click.style("== System info ==", bold=True)) click.echo(pf(dev.sys_info)) + return dev.sys_info @cli.command() @@ -186,7 +175,6 @@ async def sysinfo(dev): @click.pass_context async def state(ctx, dev: SmartDevice): """Print out device state and versions.""" - await dev.update() click.echo(click.style(f"== {dev.alias} - {dev.model} ==", bold=True)) click.echo(f"\tHost: {dev.host}") click.echo( @@ -234,7 +222,6 @@ async def state(ctx, dev: SmartDevice): @click.option("--index", type=int) async def alias(dev, new_alias, index): """Get or set the device (or plug) alias.""" - await dev.update() if index is not None: if not dev.is_strip: click.echo("Index can only used for power strips!") @@ -244,8 +231,8 @@ async def alias(dev, new_alias, index): if new_alias is not None: click.echo(f"Setting alias to {new_alias}") - click.echo(await dev.set_alias(new_alias)) - await dev.update() + res = await dev.set_alias(new_alias) + return res click.echo(f"Alias: {dev.alias}") if dev.is_strip: @@ -264,9 +251,11 @@ async def raw_command(dev: SmartDevice, module, command, parameters): if parameters is not None: parameters = ast.literal_eval(parameters) + res = await dev._query_helper(module, command, parameters) - await dev.update() # TODO: is this needed? + click.echo(res) + return res @cli.command() @@ -280,7 +269,6 @@ async def emeter(dev: SmartDevice, year, month, erase): Daily and monthly data provided in CSV format. """ click.echo(click.style("== Emeter ==", bold=True)) - await dev.update() if not dev.has_emeter: click.echo("Device has no emeter") return @@ -324,15 +312,15 @@ async def emeter(dev: SmartDevice, year, month, erase): @pass_dev async def brightness(dev: SmartBulb, brightness: int, transition: int): """Get or set brightness.""" - await dev.update() if not dev.is_dimmable: click.echo("This device does not support brightness.") return + if brightness is None: click.echo(f"Brightness: {dev.brightness}") else: click.echo(f"Setting brightness to {brightness}") - click.echo(await dev.set_brightness(brightness, transition=transition)) + return await dev.set_brightness(brightness, transition=transition) @cli.command() @@ -343,10 +331,10 @@ async def brightness(dev: SmartBulb, brightness: int, transition: int): @pass_dev async def temperature(dev: SmartBulb, temperature: int, transition: int): """Get or set color temperature.""" - await dev.update() if not dev.is_variable_color_temp: click.echo("Device does not support color temperature") return + if temperature is None: click.echo(f"Color temperature: {dev.color_temp}") valid_temperature_range = dev.valid_temperature_range @@ -359,7 +347,7 @@ async def temperature(dev: SmartBulb, temperature: int, transition: int): ) else: click.echo(f"Setting color temperature to {temperature}") - await dev.set_color_temp(temperature, transition=transition) + return await dev.set_color_temp(temperature, transition=transition) @cli.command() @@ -370,15 +358,18 @@ async def temperature(dev: SmartBulb, temperature: int, transition: int): @click.pass_context @pass_dev async def hsv(dev, ctx, h, s, v, transition): - """Get or set color in HSV. (Bulb only).""" - await dev.update() + """Get or set color in HSV.""" + if not dev.is_color: + click.echo("Device does not support colors") + return + if h is None or s is None or v is None: click.echo(f"Current HSV: {dev.hsv}") elif s is None or v is None: raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx) else: click.echo(f"Setting HSV: {h} {s} {v}") - click.echo(await dev.set_hsv(h, s, v, transition=transition)) + return await dev.set_hsv(h, s, v, transition=transition) @cli.command() @@ -386,10 +377,9 @@ async def hsv(dev, ctx, h, s, v, transition): @pass_dev async def led(dev, state): """Get or set (Plug's) led state.""" - await dev.update() if state is not None: click.echo(f"Turning led to {state}") - click.echo(await dev.set_led(state)) + return await dev.set_led(state) else: click.echo(f"LED state: {dev.led}") @@ -398,7 +388,9 @@ async def led(dev, state): @pass_dev async def time(dev): """Get the device time.""" - click.echo(await dev.get_time()) + res = await dev.get_time() + click.echo(f"Current time: {res}") + return res @cli.command() @@ -408,11 +400,11 @@ async def time(dev): @pass_dev async def on(dev: SmartDevice, index: int, name: str, transition: int): """Turn the device on.""" - await dev.update() if index is not None or name is not None: if not dev.is_strip: click.echo("Index and name are only for power strips!") return + dev = cast(SmartStrip, dev) if index is not None: dev = dev.get_plug_by_index(index) @@ -420,7 +412,7 @@ async def on(dev: SmartDevice, index: int, name: str, transition: int): dev = dev.get_plug_by_name(name) click.echo(f"Turning on {dev.alias}") - await dev.turn_on(transition=transition) + return await dev.turn_on(transition=transition) @cli.command() @@ -430,11 +422,11 @@ async def on(dev: SmartDevice, index: int, name: str, transition: int): @pass_dev async def off(dev: SmartDevice, index: int, name: str, transition: int): """Turn the device off.""" - await dev.update() if index is not None or name is not None: if not dev.is_strip: click.echo("Index and name are only for power strips!") return + dev = cast(SmartStrip, dev) if index is not None: dev = dev.get_plug_by_index(index) @@ -442,7 +434,7 @@ async def off(dev: SmartDevice, index: int, name: str, transition: int): dev = dev.get_plug_by_name(name) click.echo(f"Turning off {dev.alias}") - await dev.turn_off(transition=transition) + return await dev.turn_off(transition=transition) @cli.command() @@ -451,7 +443,7 @@ async def off(dev: SmartDevice, index: int, name: str, transition: int): async def reboot(plug, delay): """Reboot the device.""" click.echo("Rebooting the device..") - click.echo(await plug.reboot(delay)) + return await plug.reboot(delay) if __name__ == "__main__": From cf151ead4ace2037243dc68d5281adb16d654997 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Oct 2021 04:44:32 -1000 Subject: [PATCH 048/892] Add KL60 US KP105 UK fixture (#233) * Add KL60 US fixture * Add KP105 UK fixture * update test --- kasa/tests/conftest.py | 2 +- kasa/tests/fixtures/KL60(US)_1.0_1.1.13.json | 34 +++++++++----------- kasa/tests/fixtures/KP105(UK)_1.0_1.0.7.json | 33 +++++++++++++++++++ 3 files changed, 49 insertions(+), 20 deletions(-) create mode 100644 kasa/tests/fixtures/KP105(UK)_1.0_1.0.7.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 14229215a..4f50f8da4 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -40,7 +40,7 @@ } -PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210", "EP10", "KP115"} +PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210", "EP10", "KP115", "KP105"} STRIPS = {"HS107", "HS300", "KP303", "KP400", "EP40"} DIMMERS = {"HS220"} diff --git a/kasa/tests/fixtures/KL60(US)_1.0_1.1.13.json b/kasa/tests/fixtures/KL60(US)_1.0_1.1.13.json index a6ec2b910..e52cb85c5 100644 --- a/kasa/tests/fixtures/KL60(US)_1.0_1.1.13.json +++ b/kasa/tests/fixtures/KL60(US)_1.0_1.1.13.json @@ -3,28 +3,26 @@ "get_realtime": { "current_ma": 0, "err_code": 0, - "power_mw": 0, + "power_mw": 5200, "total_wh": 0, "voltage_mv": 0 } }, "smartlife.iot.smartbulb.lightingservice": { "get_light_state": { - "dft_on_state": { - "brightness": 69, - "color_temp": 2000, - "hue": 0, - "mode": "normal", - "saturation": 0 - }, + "brightness": 100, + "color_temp": 2000, "err_code": 0, - "on_off": 0 + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 } }, "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Fil", + "alias": "Gold fil", "ctrl_protocols": { "name": "Linkie", "version": "1.0" @@ -42,14 +40,12 @@ "is_variable_color_temp": 0, "latitude_i": 0, "light_state": { - "dft_on_state": { - "brightness": 69, - "color_temp": 2000, - "hue": 0, - "mode": "normal", - "saturation": 0 - }, - "on_off": 0 + "brightness": 100, + "color_temp": 2000, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 }, "longitude_i": 0, "mic_mac": "000000000000", @@ -86,7 +82,7 @@ "saturation": 0 } ], - "rssi": -55, + "rssi": -41, "status": "new", "sw_ver": "1.1.13 Build 210524 Rel.082619" } diff --git a/kasa/tests/fixtures/KP105(UK)_1.0_1.0.7.json b/kasa/tests/fixtures/KP105(UK)_1.0_1.0.7.json new file mode 100644 index 000000000..b4a12131a --- /dev/null +++ b/kasa/tests/fixtures/KP105(UK)_1.0_1.0.7.json @@ -0,0 +1,33 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Uk plug", + "dev_name": "Smart Wi-Fi Plug", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP105(UK)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -48, + "status": "new", + "sw_ver": "1.0.7 Build 210506 Rel.153510", + "updating": 0 + } + } +} From 85a618f7c69eee285fed3590b5e0ecf228bcb543 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Oct 2021 05:13:00 -1000 Subject: [PATCH 049/892] Add KP401 fixture (#234) --- kasa/tests/conftest.py | 13 +++++++- kasa/tests/fixtures/KP401(US)_1.0_1.0.0.json | 32 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/KP401(US)_1.0_1.0.0.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 4f50f8da4..ba84504a0 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -40,7 +40,18 @@ } -PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210", "EP10", "KP115", "KP105"} +PLUGS = { + "HS100", + "HS103", + "HS105", + "HS110", + "HS200", + "HS210", + "EP10", + "KP115", + "KP105", + "KP401", +} STRIPS = {"HS107", "HS300", "KP303", "KP400", "EP40"} DIMMERS = {"HS220"} diff --git a/kasa/tests/fixtures/KP401(US)_1.0_1.0.0.json b/kasa/tests/fixtures/KP401(US)_1.0_1.0.0.json new file mode 100644 index 000000000..644c4e5f4 --- /dev/null +++ b/kasa/tests/fixtures/KP401(US)_1.0_1.0.0.json @@ -0,0 +1,32 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Kp401", + "dev_name": "Smart Outdoor Plug", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP401(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "oemId": "00000000000000000000000000000000", + "on_time": 127, + "relay_state": 1, + "rssi": -56, + "status": "new", + "sw_ver": "1.0.0 Build 201221 Rel.090515", + "updating": 0 + } + } +} From d75e1adabac6caa18399b9145e55da3b8a0016c7 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 13 Oct 2021 19:22:24 +0200 Subject: [PATCH 050/892] Add perftest to devtools (#236) * Add perftest to devtools * Add example output from the perf script * Rename to avoid pytest collection * Fix git mv failing to remove the original file.. --- devtools/README.md | 61 +++++++++++++++++++++++++++++ devtools/perftest.py | 93 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 devtools/README.md create mode 100644 devtools/perftest.py diff --git a/devtools/README.md b/devtools/README.md new file mode 100644 index 000000000..fc3ce8834 --- /dev/null +++ b/devtools/README.md @@ -0,0 +1,61 @@ +# Tools for developers + +This directory contains some simple scripts that can be useful for developers. + +## dump_devinfo +* Queries the device and returns a fixture that can be added to the test suite + +```shell +Usage: dump_devinfo.py [OPTIONS] HOST + + Generate devinfo file for given device. + +Options: + -d, --debug + --help Show this message and exit. +``` + +## parse_pcap + +* Requires dpkt (pip install dpkt) +* Reads a pcap file and prints out the device communications + +```shell +Usage: parse_pcap.py [OPTIONS] FILE + + Parse pcap file and pretty print the communications and some statistics. + +Options: + --help Show this message and exit. +``` + +## perftest + +* Runs several rounds of update cycles for the given list of addresses, and prints out statistics about the performance + +```shell +Usage: perf_test.py [OPTIONS] [ADDRS]... + +Options: + --rounds INTEGER + --help Show this message and exit. +``` + +```shell +$ python perf_test.py 192.168.xx.x 192.168.xx.y 192.168.xx.z 192.168.xx.f +Running 5 rounds on ('192.168.xx.x', '192.168.xx.y', '192.168.xx.z', '192.168.xx.f') +=== Testing using gather on all devices === + took + count mean std min 25% 50% 75% max +type +concurrently 5.0 0.097161 0.045544 0.05260 0.055332 0.088811 0.143082 0.145981 +sequential 5.0 0.150506 0.005798 0.14162 0.149065 0.150499 0.155579 0.155768 +=== Testing per-device performance === + took + count mean std min 25% 50% 75% max +id +-HS110(EU) 5.0 0.044917 0.014984 0.035836 0.037728 0.037950 0.041610 0.071458 +-KL130(EU) 5.0 0.067626 0.032027 0.046451 0.046797 0.048406 0.076136 0.120342 +-HS110(EU) 5.0 0.055700 0.016174 0.042086 0.045578 0.048905 0.059869 0.082064 +-KP303(UK) 5.0 0.010298 0.003765 0.007773 0.007968 0.008546 0.010439 0.016763 +``` diff --git a/devtools/perftest.py b/devtools/perftest.py new file mode 100644 index 000000000..5babc75f6 --- /dev/null +++ b/devtools/perftest.py @@ -0,0 +1,93 @@ +"""Script for testing update performance on devices.""" +import asyncio +import time + +import asyncclick as click +import pandas as pd + +from kasa import Discover + + +async def _update(dev, lock=None): + if lock is not None: + await lock.acquire() + await asyncio.sleep(2) + try: + start_time = time.time() + # print("%s >> Updating" % id(dev)) + await dev.update() + # print("%s >> done in %s" % (id(dev), time.time() - start_time)) + return {"id": f"{id(dev)}-{dev.model}", "took": (time.time() - start_time)} + finally: + if lock is not None: + lock.release() + + +async def _update_concurrently(devs): + start_time = time.time() + update_futures = [asyncio.ensure_future(_update(dev)) for dev in devs] + await asyncio.gather(*update_futures) + return {"type": "concurrently", "took": (time.time() - start_time)} + + +async def _update_sequentially(devs): + start_time = time.time() + + for dev in devs: + await _update(dev) + + return {"type": "sequential", "took": (time.time() - start_time)} + + +@click.command() +@click.argument("addrs", nargs=-1) +@click.option("--rounds", default=5) +async def main(addrs, rounds): + """Test update performance on given devices.""" + print(f"Running {rounds} rounds on {addrs}") + devs = [] + + for addr in addrs: + try: + dev = await Discover.discover_single(addr) + devs.append(dev) + except Exception as ex: + print(f"unable to add {addr}: {ex}") + + data = [] + test_gathered = True + + if test_gathered: + print("=== Testing using gather on all devices ===") + for i in range(rounds): + data.append(await _update_concurrently(devs)) + await asyncio.sleep(2) + + await asyncio.sleep(5) + + for i in range(rounds): + data.append(await _update_sequentially(devs)) + await asyncio.sleep(2) + + df = pd.DataFrame(data) + print(df.groupby("type").describe()) + + print("=== Testing per-device performance ===") + + futs = [] + data = [] + locks = {dev: asyncio.Lock() for dev in devs} + for i in range(rounds): + for dev in devs: + futs.append(asyncio.ensure_future(_update(dev, locks[dev]))) + + for fut in asyncio.as_completed(futs): + res = await fut + data.append(res) + + df = pd.DataFrame(data) + print(df.groupby("id").describe()) + + +if __name__ == "__main__": + main(_anyio_backend="asyncio") From 8a4068c62393b3eaf53af1be416a6c28a81dc09e Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 25 Oct 2021 09:17:35 +0200 Subject: [PATCH 051/892] Add script to check supported devices, update README (#242) * check_readme_vs_fixtures.py checks if a device with a fixture is listed in the README * Add missing entries to README.md --- README.md | 5 +++++ devtools/check_readme_vs_fixtures.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 devtools/check_readme_vs_fixtures.py diff --git a/README.md b/README.md index 3b13f7196..3b173ae6f 100644 --- a/README.md +++ b/README.md @@ -119,10 +119,13 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * HS105 * HS107 * HS110 +* KP105 * KP115 +* KP401 ### Power Strips +* EP40 * HS300 * KP303 * KP400 @@ -135,11 +138,13 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. ### Bulbs +* EP40 * LB100 * LB110 * LB120 * LB130 * LB230 +* KL50 * KL60 * KL110 * KL120 diff --git a/devtools/check_readme_vs_fixtures.py b/devtools/check_readme_vs_fixtures.py new file mode 100644 index 000000000..b91b9fa90 --- /dev/null +++ b/devtools/check_readme_vs_fixtures.py @@ -0,0 +1,25 @@ +"""Script that checks if README.md is missing devices that have fixtures.""" +from kasa.tests.conftest import ALL_DEVICES, BULBS, DIMMERS, LIGHT_STRIPS, PLUGS, STRIPS + +readme = open("README.md").read() + +typemap = { + "light strips": LIGHT_STRIPS, + "bulbs": BULBS, + "plugs": PLUGS, + "strips": STRIPS, + "dimmers": DIMMERS, +} + + +def _get_device_type(dev, typemap): + for typename, devs in typemap.items(): + if dev in devs: + return typename + else: + return "Unknown type" + + +for dev in ALL_DEVICES: + if dev not in readme: + print(f"{dev} not listed in {_get_device_type(dev, typemap)}") From 9cda52932981f5b1ac3f943c771f587d4e4e8d3f Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 29 Oct 2021 02:44:51 +0200 Subject: [PATCH 052/892] Catch exceptions raised on unknown devices during discovery (#240) --- kasa/discover.py | 7 ++++++- kasa/tests/test_discovery.py | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index a408c2de9..5269f3d0b 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -73,7 +73,12 @@ def datagram_received(self, data, addr) -> None: info = json.loads(TPLinkSmartHomeProtocol.decrypt(data)) _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) - device_class = Discover._get_device_class(info) + try: + device_class = Discover._get_device_class(info) + except SmartDeviceException as ex: + _LOGGER.debug("Unable to find device type from %s: %s", info, ex) + return + device = device_class(ip) device.update_from_discover_info(info) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index c933cb124..e83561a25 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -85,9 +85,9 @@ async def test_discover_send(mocker): proto = _DiscoverProtocol() assert proto.discovery_packets == 3 assert proto.target == ("255.255.255.255", 9999) - sendto = mocker.patch.object(proto, "transport") + transport = mocker.patch.object(proto, "transport") proto.do_discover() - assert sendto.sendto.call_count == proto.discovery_packets + assert transport.sendto.call_count == proto.discovery_packets async def test_discover_datagram_received(mocker, discovery_data): @@ -105,3 +105,15 @@ async def test_discover_datagram_received(mocker, discovery_data): dev = proto.discovered_devices[addr] assert issubclass(dev.__class__, SmartDevice) assert dev.host == addr + + +@pytest.mark.parametrize("msg, data", INVALIDS) +async def test_discover_invalid_responses(msg, data, mocker): + """Verify that we don't crash whole discovery if some devices in the network are sending unexpected data.""" + proto = _DiscoverProtocol() + mocker.patch("json.loads", return_value=data) + mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "encrypt") + mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "decrypt") + + proto.datagram_received(data, ("127.0.0.1", 1234)) + assert len(proto.discovered_devices) == 0 From 5aaadaff3932daf540c56c2aa4e68912b52daee5 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 3 Nov 2021 01:55:49 +0100 Subject: [PATCH 053/892] Allow publish on test pypi workflow to fail (#248) --- .github/workflows/publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 64cb7ebd4..4ff4c3b64 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,6 +32,7 @@ jobs: . - name: Publish on test pypi uses: pypa/gh-action-pypi-publish@master + continue-on-error: true with: password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository_url: https://test.pypi.org/legacy/ From 351e86bfa35043be33cfa2acf429ed3f89e1a74c Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 15 Nov 2021 18:21:24 +0100 Subject: [PATCH 054/892] Add py.typed to flag that the package is typed (#251) --- kasa/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 kasa/py.typed diff --git a/kasa/py.typed b/kasa/py.typed new file mode 100644 index 000000000..e69de29bb From a468d520c0856debe171ceeb99aeb3d8ef91ba02 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 19 Nov 2021 18:08:20 +0100 Subject: [PATCH 055/892] Add KL135 color temperature range (#256) --- README.md | 1 + kasa/smartbulb.py | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 3b173ae6f..126b4afcc 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * KL120 * KL125 * KL130 +* KL135 ### Light strips diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index aad2ce8ce..8ccce15a8 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -28,6 +28,7 @@ class HSV(NamedTuple): "KB130": ColorTempRange(2500, 9000), "KL130": ColorTempRange(2500, 9000), "KL125": ColorTempRange(2500, 6500), + "KL135": ColorTempRange(2500, 6500), r"KL120\(EU\)": ColorTempRange(2700, 6500), r"KL120\(US\)": ColorTempRange(2700, 5000), r"KL430": ColorTempRange(2500, 9000), From 6b18c5cd55cf69f6c6e3c31bc24ca3d3232e3121 Mon Sep 17 00:00:00 2001 From: ErikSGross <64566054+ErikSGross@users.noreply.github.com> Date: Mon, 6 Dec 2021 07:31:27 -0800 Subject: [PATCH 056/892] Add fixture file for KL135 (#263) * Create new fixture file for KL135 * Add KL135 to COLOR_BULBS and VARIABLE_TEMP lists --- kasa/tests/conftest.py | 4 +- kasa/tests/fixtures/KL135(US)_1.0_1.0.6.json | 89 ++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 kasa/tests/fixtures/KL135(US)_1.0_1.0.6.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index ba84504a0..2c1d1e49c 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -26,8 +26,8 @@ LIGHT_STRIPS = {"KL400", "KL430"} -VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL125", "KL130", "KL430"} -COLOR_BULBS = {"LB130", "KL125", "KL130", *LIGHT_STRIPS} +VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL125", "KL130", "KL135", "KL430"} +COLOR_BULBS = {"LB130", "KL125", "KL130", "KL135", *LIGHT_STRIPS} BULBS = { "KL50", "KL60", diff --git a/kasa/tests/fixtures/KL135(US)_1.0_1.0.6.json b/kasa/tests/fixtures/KL135(US)_1.0_1.0.6.json new file mode 100644 index 000000000..dc0ef45ab --- /dev/null +++ b/kasa/tests/fixtures/KL135(US)_1.0_1.0.6.json @@ -0,0 +1,89 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 900, + "total_wh": 0 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "brightness": 1, + "color_temp": 0, + "err_code": 0, + "hue": 37, + "mode": "normal", + "on_off": 1, + "saturation": 100 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "KL135 Bulb", + "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": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "light_state": { + "brightness": 1, + "color_temp": 0, + "hue": 37, + "mode": "normal", + "on_off": 1, + "saturation": 100 + }, + "longitude_i": 0, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL135(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": 1, + "color_temp": 0, + "hue": 37, + "index": 3, + "saturation": 100 + } + ], + "rssi": -69, + "status": "new", + "sw_ver": "1.0.6 Build 210330 Rel.173743" + } + } +} From 8554a1db29df8a643f18ce431464f63ce0b31558 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 8 Dec 2021 15:31:55 +0100 Subject: [PATCH 057/892] Pin mistune to <2.0.0 to fix doc builds (#270) --- poetry.lock | 338 +++++++++++++++++++++++++------------------------ pyproject.toml | 3 +- 2 files changed, 177 insertions(+), 164 deletions(-) diff --git a/poetry.lock b/poetry.lock index 63bbc2c22..d24d5d491 100644 --- a/poetry.lock +++ b/poetry.lock @@ -8,7 +8,7 @@ python-versions = "*" [[package]] name = "anyio" -version = "3.3.1" +version = "3.4.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" category = "main" optional = false @@ -21,7 +21,7 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""} [package.extras] doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] -test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] +test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] trio = ["trio (>=0.16)"] [[package]] @@ -74,7 +74,7 @@ pytz = ">=2015.7" [[package]] name = "backports.entry-points-selectable" -version = "1.1.0" +version = "1.1.1" description = "Compatibility shim providing selectable entry points for older implementations" category = "dev" optional = false @@ -85,11 +85,11 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] +testing = ["pytest", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] [[package]] name = "certifi" -version = "2021.5.30" +version = "2021.10.8" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false @@ -105,7 +105,7 @@ python-versions = ">=3.6.1" [[package]] name = "charset-normalizer" -version = "2.0.6" +version = "2.0.9" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false @@ -136,14 +136,14 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "coverage" -version = "5.5" +version = "6.2" description = "Code coverage measurement for Python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +python-versions = ">=3.6" [package.extras] -toml = ["toml"] +toml = ["tomli"] [[package]] name = "distlib" @@ -163,26 +163,30 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "filelock" -version = "3.0.12" +version = "3.4.0" description = "A platform independent file lock." category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.6" + +[package.extras] +docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] +testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] [[package]] name = "identify" -version = "2.2.15" +version = "2.4.0" description = "File identification library for Python" category = "dev" optional = false python-versions = ">=3.6.1" [package.extras] -license = ["editdistance-s"] +license = ["ukkonen"] [[package]] name = "idna" -version = "3.2" +version = "3.3" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false @@ -190,7 +194,7 @@ python-versions = ">=3.5" [[package]] name = "imagesize" -version = "1.2.0" +version = "1.3.0" description = "Getting image size from png/jpeg/jpeg2000/gif file" category = "main" optional = true @@ -198,7 +202,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.8.1" +version = "4.8.2" description = "Read metadata from Python packages" category = "main" optional = false @@ -211,11 +215,11 @@ zipp = ">=0.5" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] perf = ["ipython"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] [[package]] name = "jinja2" -version = "3.0.1" +version = "3.0.3" description = "A very fast and expressive template engine." category = "main" optional = true @@ -257,7 +261,7 @@ python-versions = "*" [[package]] name = "more-itertools" -version = "8.10.0" +version = "8.12.0" description = "More routines for operating on iterables, beyond itertools" category = "dev" optional = false @@ -273,18 +277,18 @@ python-versions = "*" [[package]] name = "packaging" -version = "21.0" +version = "21.3" description = "Core utilities for Python packages" category = "main" optional = false python-versions = ">=3.6" [package.dependencies] -pyparsing = ">=2.0.2" +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "platformdirs" -version = "2.3.0" +version = "2.4.0" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false @@ -310,7 +314,7 @@ dev = ["pre-commit", "tox"] [[package]] name = "pre-commit" -version = "2.15.0" +version = "2.16.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -327,11 +331,11 @@ virtualenv = ">=20.0.8" [[package]] name = "py" -version = "1.10.0" +version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pygments" @@ -343,11 +347,14 @@ python-versions = ">=3.5" [[package]] name = "pyparsing" -version = "2.4.7" +version = "3.0.6" description = "Python parsing module" category = "main" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.6" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" @@ -374,7 +381,7 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm [[package]] name = "pytest-asyncio" -version = "0.15.1" +version = "0.16.0" description = "Pytest support for asyncio." category = "dev" optional = false @@ -431,7 +438,7 @@ termcolor = ">=1.1.0" [[package]] name = "pytz" -version = "2021.1" +version = "2021.3" description = "World timezone definitions, modern and historical" category = "main" optional = true @@ -439,11 +446,11 @@ python-versions = "*" [[package]] name = "pyyaml" -version = "5.4.1" +version = "6.0" description = "YAML parser and emitter for Python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=3.6" [[package]] name = "requests" @@ -481,7 +488,7 @@ python-versions = ">=3.5" [[package]] name = "snowballstemmer" -version = "2.1.0" +version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." category = "main" optional = true @@ -656,11 +663,11 @@ testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "psutil (>=5.6.1)", "pytes [[package]] name = "typing-extensions" -version = "3.10.0.2" -description = "Backported and Experimental Type Hints for Python 3.5+" +version = "4.0.1" +description = "Backported and Experimental Type Hints for Python 3.6+" category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "urllib3" @@ -677,7 +684,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.8.0" +version = "20.10.0" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -686,13 +693,13 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] "backports.entry-points-selectable" = ">=1.0.4" distlib = ">=0.3.1,<1" -filelock = ">=3.0.0,<4" +filelock = ">=3.2,<4" importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} platformdirs = ">=2,<3" six = ">=1.9.0,<2" [package.extras] -docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] +docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] [[package]] @@ -713,7 +720,7 @@ python-versions = "*" [[package]] name = "xdoctest" -version = "0.15.8" +version = "0.15.10" description = "A rewrite of the builtin doctest module" category = "dev" optional = false @@ -731,7 +738,7 @@ tests = ["codecov", "scikit-build", "cmake", "ninja", "pybind11", "pytest", "pyt [[package]] name = "zipp" -version = "3.5.0" +version = "3.6.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false @@ -742,12 +749,12 @@ docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [extras] -docs = ["sphinx", "sphinx_rtd_theme", "m2r", "sphinxcontrib-programoutput"] +docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programoutput"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "e388fa366e9423e60697bfa77c37151094ca0367eb3ed441d61bb8cc7f055675" +content-hash = "17223bb140646ca65f1781c993d807d7434df6ebf2123cc0a7e34a6f22aea7e1" [metadata.files] alabaster = [ @@ -755,8 +762,8 @@ alabaster = [ {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, ] anyio = [ - {file = "anyio-3.3.1-py3-none-any.whl", hash = "sha256:d7c604dd491eca70e19c78664d685d5e4337612d574419d503e76f5d7d1590bd"}, - {file = "anyio-3.3.1.tar.gz", hash = "sha256:85913b4e2fec030e8c72a8f9f98092eeb9e25847a6e00d567751b77e34f856fe"}, + {file = "anyio-3.4.0-py3-none-any.whl", hash = "sha256:2855a9423524abcdd652d942f8932fda1735210f77a6b392eafd9ff34d3fe020"}, + {file = "anyio-3.4.0.tar.gz", hash = "sha256:24adc69309fb5779bc1e06158e143e0b6d2c56b302a3ac3de3083c705a6ed39d"}, ] asyncclick = [ {file = "asyncclick-7.1.2.3.tar.gz", hash = "sha256:c26962b9957abe7ae09c058afbfea199dedea1b54343c1cc2ae1a6a291fab333"}, @@ -774,20 +781,20 @@ babel = [ {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, ] "backports.entry-points-selectable" = [ - {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"}, - {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"}, + {file = "backports.entry_points_selectable-1.1.1-py2.py3-none-any.whl", hash = "sha256:7fceed9532a7aa2bd888654a7314f864a3c16a4e710b34a58cfc0f08114c663b"}, + {file = "backports.entry_points_selectable-1.1.1.tar.gz", hash = "sha256:914b21a479fde881635f7af5adc7f6e38d6b274be32269070c53b698c60d5386"}, ] certifi = [ - {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, - {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, ] cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] charset-normalizer = [ - {file = "charset-normalizer-2.0.6.tar.gz", hash = "sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f"}, - {file = "charset_normalizer-2.0.6-py3-none-any.whl", hash = "sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6"}, + {file = "charset-normalizer-2.0.9.tar.gz", hash = "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c"}, + {file = "charset_normalizer-2.0.9-py3-none-any.whl", hash = "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721"}, ] codecov = [ {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"}, @@ -799,58 +806,53 @@ colorama = [ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] coverage = [ - {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, - {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, - {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, - {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, - {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, - {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, - {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, - {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, - {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, - {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, - {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, - {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, - {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, - {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, - {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, - {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, - {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, - {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, - {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, - {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, - {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, - {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, - {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, - {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, - {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, - {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, - {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, - {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, - {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, - {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, - {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, - {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, - {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, - {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, - {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, - {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, - {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, - {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, - {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, - {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, - {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, - {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, - {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, - {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, - {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, - {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, - {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, - {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, + {file = "coverage-6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b"}, + {file = "coverage-6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0"}, + {file = "coverage-6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da"}, + {file = "coverage-6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d"}, + {file = "coverage-6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739"}, + {file = "coverage-6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971"}, + {file = "coverage-6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840"}, + {file = "coverage-6.2-cp310-cp310-win32.whl", hash = "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c"}, + {file = "coverage-6.2-cp310-cp310-win_amd64.whl", hash = "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f"}, + {file = "coverage-6.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76"}, + {file = "coverage-6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47"}, + {file = "coverage-6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64"}, + {file = "coverage-6.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f467bbb837691ab5a8ca359199d3429a11a01e6dfb3d9dcc676dc035ca93c0a9"}, + {file = "coverage-6.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2641f803ee9f95b1f387f3e8f3bf28d83d9b69a39e9911e5bfee832bea75240d"}, + {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1219d760ccfafc03c0822ae2e06e3b1248a8e6d1a70928966bafc6838d3c9e48"}, + {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9a2b5b52be0a8626fcbffd7e689781bf8c2ac01613e77feda93d96184949a98e"}, + {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8e2c35a4c1f269704e90888e56f794e2d9c0262fb0c1b1c8c4ee44d9b9e77b5d"}, + {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5d6b09c972ce9200264c35a1d53d43ca55ef61836d9ec60f0d44273a31aa9f17"}, + {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e3db840a4dee542e37e09f30859f1612da90e1c5239a6a2498c473183a50e781"}, + {file = "coverage-6.2-cp36-cp36m-win32.whl", hash = "sha256:4e547122ca2d244f7c090fe3f4b5a5861255ff66b7ab6d98f44a0222aaf8671a"}, + {file = "coverage-6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:01774a2c2c729619760320270e42cd9e797427ecfddd32c2a7b639cdc481f3c0"}, + {file = "coverage-6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb8b8ee99b3fffe4fd86f4c81b35a6bf7e4462cba019997af2fe679365db0c49"}, + {file = "coverage-6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:619346d57c7126ae49ac95b11b0dc8e36c1dd49d148477461bb66c8cf13bb521"}, + {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0a7726f74ff63f41e95ed3a89fef002916c828bb5fcae83b505b49d81a066884"}, + {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cfd9386c1d6f13b37e05a91a8583e802f8059bebfccde61a418c5808dea6bbfa"}, + {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:17e6c11038d4ed6e8af1407d9e89a2904d573be29d51515f14262d7f10ef0a64"}, + {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c254b03032d5a06de049ce8bca8338a5185f07fb76600afff3c161e053d88617"}, + {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dca38a21e4423f3edb821292e97cec7ad38086f84313462098568baedf4331f8"}, + {file = "coverage-6.2-cp37-cp37m-win32.whl", hash = "sha256:600617008aa82032ddeace2535626d1bc212dfff32b43989539deda63b3f36e4"}, + {file = "coverage-6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:bf154ba7ee2fd613eb541c2bc03d3d9ac667080a737449d1a3fb342740eb1a74"}, + {file = "coverage-6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e"}, + {file = "coverage-6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58"}, + {file = "coverage-6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc"}, + {file = "coverage-6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd"}, + {file = "coverage-6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953"}, + {file = "coverage-6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475"}, + {file = "coverage-6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57"}, + {file = "coverage-6.2-cp38-cp38-win32.whl", hash = "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c"}, + {file = "coverage-6.2-cp38-cp38-win_amd64.whl", hash = "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2"}, + {file = "coverage-6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd"}, + {file = "coverage-6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685"}, + {file = "coverage-6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c"}, + {file = "coverage-6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3"}, + {file = "coverage-6.2-cp39-cp39-win32.whl", hash = "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282"}, + {file = "coverage-6.2-cp39-cp39-win_amd64.whl", hash = "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644"}, + {file = "coverage-6.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de"}, + {file = "coverage-6.2.tar.gz", hash = "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8"}, ] distlib = [ {file = "distlib-0.3.3-py2.py3-none-any.whl", hash = "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31"}, @@ -861,28 +863,28 @@ docutils = [ {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, ] filelock = [ - {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, - {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, + {file = "filelock-3.4.0-py3-none-any.whl", hash = "sha256:2e139a228bcf56dd8b2274a65174d005c4a6b68540ee0bdbb92c76f43f29f7e8"}, + {file = "filelock-3.4.0.tar.gz", hash = "sha256:93d512b32a23baf4cac44ffd72ccf70732aeff7b8050fcaf6d3ec406d954baf4"}, ] identify = [ - {file = "identify-2.2.15-py2.py3-none-any.whl", hash = "sha256:de83a84d774921669774a2000bf87ebba46b4d1c04775f4a5d37deff0cf39f73"}, - {file = "identify-2.2.15.tar.gz", hash = "sha256:528a88021749035d5a39fe2ba67c0642b8341aaf71889da0e1ed669a429b87f0"}, + {file = "identify-2.4.0-py2.py3-none-any.whl", hash = "sha256:eba31ca80258de6bb51453084bff4a923187cd2193b9c13710f2516ab30732cc"}, + {file = "identify-2.4.0.tar.gz", hash = "sha256:a33ae873287e81651c7800ca309dc1f84679b763c9c8b30680e16fbfa82f0107"}, ] idna = [ - {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, - {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, ] imagesize = [ - {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"}, - {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, + {file = "imagesize-1.3.0-py2.py3-none-any.whl", hash = "sha256:1db2f82529e53c3e929e8926a1fa9235aa82d0bd0c580359c67ec31b2fddaa8c"}, + {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.8.1-py3-none-any.whl", hash = "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15"}, - {file = "importlib_metadata-4.8.1.tar.gz", hash = "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"}, + {file = "importlib_metadata-4.8.2-py3-none-any.whl", hash = "sha256:53ccfd5c134223e497627b9815d5030edf77d2ed573922f7a0b8f8bb81a1c100"}, + {file = "importlib_metadata-4.8.2.tar.gz", hash = "sha256:75bdec14c397f528724c1bfd9709d660b33a4d2e77387a3358f20b848bb5e5fb"}, ] jinja2 = [ - {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, - {file = "Jinja2-3.0.1.tar.gz", hash = "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"}, + {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, + {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, ] m2r = [ {file = "m2r-0.2.1.tar.gz", hash = "sha256:bf90bad66cda1164b17e5ba4a037806d2443f2a4d5ddc9f6a5554a0322aaed99"}, @@ -928,48 +930,48 @@ mistune = [ {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, ] more-itertools = [ - {file = "more-itertools-8.10.0.tar.gz", hash = "sha256:1debcabeb1df793814859d64a81ad7cb10504c24349368ccf214c664c474f41f"}, - {file = "more_itertools-8.10.0-py3-none-any.whl", hash = "sha256:56ddac45541718ba332db05f464bebfb0768110111affd27f66e0051f276fa43"}, + {file = "more-itertools-8.12.0.tar.gz", hash = "sha256:7dc6ad46f05f545f900dd59e8dfb4e84a4827b97b3cfecb175ea0c7d247f6064"}, + {file = "more_itertools-8.12.0-py3-none-any.whl", hash = "sha256:43e6dd9942dffd72661a2c4ef383ad7da1e6a3e968a927ad7a6083ab410a688b"}, ] nodeenv = [ {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, ] packaging = [ - {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, - {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] platformdirs = [ - {file = "platformdirs-2.3.0-py3-none-any.whl", hash = "sha256:8003ac87717ae2c7ee1ea5a84a1a61e87f3fbd16eb5aadba194ea30a9019f648"}, - {file = "platformdirs-2.3.0.tar.gz", hash = "sha256:15b056538719b1c94bdaccb29e5f81879c7f7f0f4a153f46086d155dffcd4f0f"}, + {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, + {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] pre-commit = [ - {file = "pre_commit-2.15.0-py2.py3-none-any.whl", hash = "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6"}, - {file = "pre_commit-2.15.0.tar.gz", hash = "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7"}, + {file = "pre_commit-2.16.0-py2.py3-none-any.whl", hash = "sha256:758d1dc9b62c2ed8881585c254976d66eae0889919ab9b859064fc2fe3c7743e"}, + {file = "pre_commit-2.16.0.tar.gz", hash = "sha256:fe9897cac830aa7164dbd02a4e7b90cae49630451ce88464bca73db486ba9f65"}, ] py = [ - {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, - {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] pygments = [ {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, ] pyparsing = [ - {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, - {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, + {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, + {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, ] pytest = [ {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, ] pytest-asyncio = [ - {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"}, - {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, + {file = "pytest-asyncio-0.16.0.tar.gz", hash = "sha256:7496c5977ce88c34379df64a66459fe395cd05543f0a2f837016e7144391fcfb"}, + {file = "pytest_asyncio-0.16.0-py3-none-any.whl", hash = "sha256:5f2a21273c47b331ae6aa5b36087047b4899e40f03f18397c0e65fa5cca54e9b"}, ] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, @@ -983,31 +985,43 @@ pytest-sugar = [ {file = "pytest-sugar-0.9.4.tar.gz", hash = "sha256:b1b2186b0a72aada6859bea2a5764145e3aaa2c1cfbb23c3a19b5f7b697563d3"}, ] pytz = [ - {file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"}, - {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"}, + {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"}, + {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"}, ] pyyaml = [ - {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"}, - {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, - {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, - {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, - {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, - {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, - {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, - {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, - {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, - {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, - {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, + {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, + {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, + {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, + {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, + {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, + {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, + {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, + {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, + {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, + {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, + {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, + {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, + {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, ] requests = [ {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, @@ -1022,8 +1036,8 @@ sniffio = [ {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, ] snowballstemmer = [ - {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, - {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, ] sphinx = [ {file = "Sphinx-3.5.4-py3-none-any.whl", hash = "sha256:2320d4e994a191f4b4be27da514e46b3d6b420f2ff895d064f52415d342461e8"}, @@ -1073,17 +1087,16 @@ tox = [ {file = "tox-3.24.4.tar.gz", hash = "sha256:c30b57fa2477f1fb7c36aa1d83292d5c2336cd0018119e1b1c17340e2c2708ca"}, ] typing-extensions = [ - {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, - {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, - {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, + {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, + {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, ] urllib3 = [ {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, ] virtualenv = [ - {file = "virtualenv-20.8.0-py2.py3-none-any.whl", hash = "sha256:a4b987ec31c3c9996cf1bc865332f967fe4a0512c41b39652d6224f696e69da5"}, - {file = "virtualenv-20.8.0.tar.gz", hash = "sha256:4da4ac43888e97de9cf4fdd870f48ed864bbfd133d2c46cbdec941fed4a25aef"}, + {file = "virtualenv-20.10.0-py2.py3-none-any.whl", hash = "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814"}, + {file = "virtualenv-20.10.0.tar.gz", hash = "sha256:576d05b46eace16a9c348085f7d0dc8ef28713a2cabaa1cf0aea41e8f12c9218"}, ] voluptuous = [ {file = "voluptuous-0.12.2.tar.gz", hash = "sha256:4db1ac5079db9249820d49c891cb4660a6f8cae350491210abce741fabf56513"}, @@ -1093,11 +1106,10 @@ wcwidth = [ {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, ] xdoctest = [ - {file = "xdoctest-0.15.8-py2.py3-none-any.whl", hash = "sha256:566e2bb2135e144e66ccd390affbe4504a2e96c25ef16260843b9680326cadc9"}, - {file = "xdoctest-0.15.8-py3-none-any.whl", hash = "sha256:80a57af2f8ca709ab9da111ab3b16ec474f11297b9efcc34709a2c3e56ed9ce6"}, - {file = "xdoctest-0.15.8.tar.gz", hash = "sha256:ddd128780593161a7398fcfefc49f5f6dfe4c2eb2816942cb53768d43bcab7b9"}, + {file = "xdoctest-0.15.10-py3-none-any.whl", hash = "sha256:7666bd0511df59275dfe94ef94b0fde9654afd14f00bf88902fdc9bcee77d527"}, + {file = "xdoctest-0.15.10.tar.gz", hash = "sha256:5f16438f2b203860e75ec594dbc38020df7524db0b41bb88467ea0a6030e6685"}, ] zipp = [ - {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"}, - {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"}, + {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, + {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, ] diff --git a/pyproject.toml b/pyproject.toml index 0671d41b9..5232396a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ asyncclick = "^7" # required only for docs sphinx = { version = "^3", optional = true } m2r = { version = "^0", optional = true } +mistune = { version = "<2.0.0", optional = true } sphinx_rtd_theme = { version = "^0", optional = true } sphinxcontrib-programoutput = { version = "^0", optional = true } @@ -39,7 +40,7 @@ codecov = "^2" xdoctest = "^0" [tool.poetry.extras] -docs = ["sphinx", "sphinx_rtd_theme", "m2r", "sphinxcontrib-programoutput"] +docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programoutput"] [tool.isort] From 6aea09fc447d247859d53435940a0dd85177f7d6 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 8 Dec 2021 15:32:57 +0100 Subject: [PATCH 058/892] Minor improvements to onboarding doc (#264) This makes the commands more copy&pastable on new devices --- docs/source/cli.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index 1e656997c..340a64e6f 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -23,9 +23,11 @@ Provisioning You can provision your device without any extra apps by using the ``kasa wifi`` command: 1. If the device is unprovisioned, connect to its open network -2. Use ``kasa discover`` (or check the routes) to locate the IP address of the device (likely 192.168.0.1) -3. Scan for available networks using ``kasa wifi scan`` -4. Join/change the network using ``kasa wifi join`` command, see ``--help`` for details. +2. Use ``kasa discover`` (or check the routes) to locate the IP address of the device (likely 192.168.0.1, if unprovisioned) +3. Scan for available networks using ``kasa --host 192.168.0.1 wifi scan`` see which networks are visible to the device +4. Join/change the network using ``kasa --host 192.168.0.1 wifi join `` + +As with all other commands, you can also pass ``--help`` to both ``join`` and ``scan`` commands to see the available options. ``kasa --help`` *************** From 62c9f0ae64a25cc645ca8f77e1506ee4187d2bcb Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 13 Dec 2021 18:40:50 +0100 Subject: [PATCH 059/892] Add coverage[toml] dependency to fix coverage on CI (#271) --- poetry.lock | 17 ++++++++++++++++- pyproject.toml | 1 + 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index d24d5d491..d9501eadd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -142,6 +142,9 @@ category = "dev" optional = false python-versions = ">=3.6" +[package.dependencies] +tomli = {version = "*", optional = true, markers = "extra == \"toml\""} + [package.extras] toml = ["tomli"] @@ -638,6 +641,14 @@ category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "tomli" +version = "1.2.2" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.6" + [[package]] name = "tox" version = "3.24.4" @@ -754,7 +765,7 @@ docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programou [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "17223bb140646ca65f1781c993d807d7434df6ebf2123cc0a7e34a6f22aea7e1" +content-hash = "45ff80e61743682fdf21021f3c75758c703bfe453a4ab96235f7b71d7a61a237" [metadata.files] alabaster = [ @@ -1082,6 +1093,10 @@ toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] +tomli = [ + {file = "tomli-1.2.2-py3-none-any.whl", hash = "sha256:f04066f68f5554911363063a30b108d2b5a5b1a010aa8b6132af78489fe3aade"}, + {file = "tomli-1.2.2.tar.gz", hash = "sha256:c6ce0015eb38820eaf32b5db832dbc26deb3dd427bd5f6556cf0acac2c214fee"}, +] tox = [ {file = "tox-3.24.4-py2.py3-none-any.whl", hash = "sha256:5e274227a53dc9ef856767c21867377ba395992549f02ce55eb549f9fb9a8d10"}, {file = "tox-3.24.4.tar.gz", hash = "sha256:c30b57fa2477f1fb7c36aa1d83292d5c2336cd0018119e1b1c17340e2c2708ca"}, diff --git a/pyproject.toml b/pyproject.toml index 5232396a0..60bf29ebd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ tox = "*" pytest-mock = "^3" codecov = "^2" xdoctest = "^0" +coverage = {version = "^6", extras = ["toml"]} [tool.poetry.extras] docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programoutput"] From cf98674c3a59fc0ca3c144752cabe1da929dabab Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 13 Dec 2021 18:58:46 +0100 Subject: [PATCH 060/892] Use codecov-action@v2 for CI (#277) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2cb6d82a..38075041e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,6 @@ jobs: run: | poetry run pytest --cov kasa --cov-report xml - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v1" + uses: "codecov/codecov-action@v2" with: fail_ci_if_error: true From d2efaf5090e02b88635058efec6677bf6e352d9e Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 13 Dec 2021 20:17:54 +0100 Subject: [PATCH 061/892] Add --type option to cli (#269) * Add support for controlling dimmers * Deprecate --bulb, --plug, --strip, --lightstrip --- kasa/cli.py | 46 ++++++++++++++++++++++++++++-------------- kasa/tests/test_cli.py | 27 ++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index c23019ecf..b8f1eed5c 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -9,11 +9,20 @@ Discover, SmartBulb, SmartDevice, + SmartDimmer, SmartLightStrip, SmartPlug, SmartStrip, ) +TYPE_TO_CLASS = { + "plug": SmartPlug, + "bulb": SmartBulb, + "dimmer": SmartDimmer, + "strip": SmartStrip, + "lightstrip": SmartLightStrip, +} + click.anyio_backend = "asyncio" @@ -44,9 +53,12 @@ @click.option("--plug", default=False, is_flag=True) @click.option("--lightstrip", default=False, is_flag=True) @click.option("--strip", default=False, is_flag=True) +@click.option( + "--type", default=None, type=click.Choice(TYPE_TO_CLASS, case_sensitive=False) +) @click.version_option() @click.pass_context -async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip): +async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip, type): """A tool for controlling TP-Link smart home devices.""" # noqa if debug: logging.basicConfig(level=logging.DEBUG) @@ -69,24 +81,28 @@ async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip): click.echo("No host name given, trying discovery..") await ctx.invoke(discover) return - else: - if not bulb and not plug and not strip and not lightstrip: - click.echo("No --strip nor --bulb nor --plug given, discovering..") - dev = await Discover.discover_single(host) - elif bulb: - dev = SmartBulb(host) + + if bulb or plug or strip or lightstrip: + click.echo( + "Using --bulb, --plug, --strip, and --lightstrip is deprecated. Use --type instead to define the type" + ) + if bulb: + type = "bulb" elif plug: - dev = SmartPlug(host) + type = "plug" elif strip: - dev = SmartStrip(host) + type = "strip" elif lightstrip: - dev = SmartLightStrip(host) - else: - click.echo("Unable to detect type, use --strip or --bulb or --plug!") - return + type = "lightstrip" + + if type is not None: + dev = TYPE_TO_CLASS[type](host) + else: + click.echo("No --type defined, discovering..") + dev = await Discover.discover_single(host) - await dev.update() - ctx.obj = dev + await dev.update() + ctx.obj = dev if ctx.invoked_subcommand is None: await ctx.invoke(state) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 864adb21d..499b85520 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -1,7 +1,17 @@ +import pytest from asyncclick.testing import CliRunner from kasa import SmartDevice -from kasa.cli import alias, brightness, emeter, raw_command, state, sysinfo +from kasa.cli import ( + TYPE_TO_CLASS, + alias, + brightness, + cli, + emeter, + raw_command, + state, + sysinfo, +) from .conftest import handle_turn_on, pytestmark, turn_on @@ -96,6 +106,21 @@ async def test_brightness(dev): assert "Brightness: 12" in res.output +def _generate_type_class_pairs(): + yield from TYPE_TO_CLASS.items() + + +@pytest.mark.parametrize("type_class", _generate_type_class_pairs()) +async def test_deprecated_type(dev, type_class): + """Make sure that using deprecated types yields a warning.""" + type, cls = type_class + if type == "dimmer": + return + runner = CliRunner() + res = await runner.invoke(cli, ["--host", "127.0.0.2", f"--{type}"]) + assert "Using --bulb, --plug, --strip, and --lightstrip is deprecated" in res.output + + async def test_temperature(dev): pass From a817d9cab1e04f9404bb314efb9760bbbef9ea81 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 17 Dec 2021 17:48:03 +0100 Subject: [PATCH 062/892] Add python 3.10 to CI (#279) * Add python 3.10 to CI * Require pytest >=6.2.5 Required for running on python 3.10 (https://github.com/pytest-dev/pytest/pull/8540) * Update lockfile * Update pre-commit hooks --- .github/workflows/ci.yml | 4 +- .pre-commit-config.yaml | 8 ++-- kasa/smartstrip.py | 4 +- poetry.lock | 88 +++++++++++++++++----------------------- pyproject.toml | 2 +- 5 files changed, 47 insertions(+), 59 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38075041e..f2f5e6324 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: - python-version: ["3.9"] + python-version: ["3.10"] steps: - uses: "actions/checkout@v2" @@ -61,7 +61,7 @@ jobs: strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "pypy-3.7"] + python-version: ["3.7", "3.8", "3.9", "3.10", "pypy-3.7"] os: [ubuntu-latest, macos-latest, windows-latest] # exclude pypy on windows, as the poetry install seems to be very flaky: # PermissionError(13, 'The process cannot access the file because it is being used by another process')) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a0e1ced0b..cf8dd7867 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,13 +10,13 @@ repos: - id: check-ast - repo: https://github.com/asottile/pyupgrade - rev: v2.27.0 + rev: v2.29.1 hooks: - id: pyupgrade - args: ['--py36-plus'] + args: ['--py37-plus'] - repo: https://github.com/python/black - rev: 21.9b0 + rev: 21.12b0 hooks: - id: black @@ -33,7 +33,7 @@ repos: additional_dependencies: [toml] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.910 + rev: v0.920 hooks: - id: mypy additional_dependencies: [types-click] diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 71373a7a9..391381bcc 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -205,13 +205,13 @@ async def erase_emeter_stats(self): @requires_update def emeter_this_month(self) -> Optional[float]: """Return this month's energy consumption in kWh.""" - return sum([plug.emeter_this_month for plug in self.children]) + return sum(plug.emeter_this_month for plug in self.children) @property # type: ignore @requires_update def emeter_today(self) -> Optional[float]: """Return this month's energy consumption in kWh.""" - return sum([plug.emeter_today for plug in self.children]) + return sum(plug.emeter_today for plug in self.children) @property # type: ignore @requires_update diff --git a/poetry.lock b/poetry.lock index d9501eadd..83966fd3c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -150,7 +150,7 @@ toml = ["tomli"] [[package]] name = "distlib" -version = "0.3.3" +version = "0.3.4" description = "Distribution utilities" category = "dev" optional = false @@ -205,11 +205,11 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.8.2" +version = "4.9.0" description = "Read metadata from Python packages" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} @@ -220,6 +220,14 @@ docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] perf = ["ipython"] testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "jinja2" version = "3.0.3" @@ -262,14 +270,6 @@ category = "main" optional = true python-versions = "*" -[[package]] -name = "more-itertools" -version = "8.12.0" -description = "More routines for operating on iterables, beyond itertools" -category = "dev" -optional = false -python-versions = ">=3.5" - [[package]] name = "nodeenv" version = "1.6.0" @@ -303,17 +303,18 @@ test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock [[package]] name = "pluggy" -version = "0.13.1" +version = "1.0.0" description = "plugin and hook calling mechanisms for python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.dependencies] importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} [package.extras] dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" @@ -361,25 +362,24 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "5.4.3" +version = "6.2.5" description = "pytest: simple powerful testing with Python" category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.dependencies] atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} -attrs = ">=17.4.0" +attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} -more-itertools = ">=4.0.0" +iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<1.0" -py = ">=1.5.0" -wcwidth = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +toml = "*" [package.extras] -checkqa-mypy = ["mypy (==v0.761)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] @@ -643,11 +643,11 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "tomli" -version = "1.2.2" +version = "2.0.0" description = "A lil' TOML parser" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "tox" @@ -721,14 +721,6 @@ category = "dev" optional = false python-versions = "*" -[[package]] -name = "wcwidth" -version = "0.2.5" -description = "Measures the displayed width of unicode strings in a terminal" -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "xdoctest" version = "0.15.10" @@ -765,7 +757,7 @@ docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programou [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "45ff80e61743682fdf21021f3c75758c703bfe453a4ab96235f7b71d7a61a237" +content-hash = "be2a7aea5fa2ddbc1710791761c73638e4d39217fbb32f2193a728b3f4f2cc05" [metadata.files] alabaster = [ @@ -866,8 +858,8 @@ coverage = [ {file = "coverage-6.2.tar.gz", hash = "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8"}, ] distlib = [ - {file = "distlib-0.3.3-py2.py3-none-any.whl", hash = "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31"}, - {file = "distlib-0.3.3.zip", hash = "sha256:d982d0751ff6eaaab5e2ec8e691d949ee80eddf01a62eaa96ddb11531fe16b05"}, + {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, + {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, ] docutils = [ {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, @@ -890,8 +882,12 @@ imagesize = [ {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.8.2-py3-none-any.whl", hash = "sha256:53ccfd5c134223e497627b9815d5030edf77d2ed573922f7a0b8f8bb81a1c100"}, - {file = "importlib_metadata-4.8.2.tar.gz", hash = "sha256:75bdec14c397f528724c1bfd9709d660b33a4d2e77387a3358f20b848bb5e5fb"}, + {file = "importlib_metadata-4.9.0-py3-none-any.whl", hash = "sha256:e8b45564028bc25f8c99f546616112a6df5de6655893d7eb74c9a99680dc9751"}, + {file = "importlib_metadata-4.9.0.tar.gz", hash = "sha256:ee50794eccb0ec340adbc838344ebb9a6ff2bcba78f752d31fc716497e2149d6"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] jinja2 = [ {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, @@ -940,10 +936,6 @@ mistune = [ {file = "mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4"}, {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, ] -more-itertools = [ - {file = "more-itertools-8.12.0.tar.gz", hash = "sha256:7dc6ad46f05f545f900dd59e8dfb4e84a4827b97b3cfecb175ea0c7d247f6064"}, - {file = "more_itertools-8.12.0-py3-none-any.whl", hash = "sha256:43e6dd9942dffd72661a2c4ef383ad7da1e6a3e968a927ad7a6083ab410a688b"}, -] nodeenv = [ {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, @@ -957,8 +949,8 @@ platformdirs = [ {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, ] pluggy = [ - {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, - {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] pre-commit = [ {file = "pre_commit-2.16.0-py2.py3-none-any.whl", hash = "sha256:758d1dc9b62c2ed8881585c254976d66eae0889919ab9b859064fc2fe3c7743e"}, @@ -977,8 +969,8 @@ pyparsing = [ {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, ] pytest = [ - {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, - {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, + {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, + {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, ] pytest-asyncio = [ {file = "pytest-asyncio-0.16.0.tar.gz", hash = "sha256:7496c5977ce88c34379df64a66459fe395cd05543f0a2f837016e7144391fcfb"}, @@ -1094,8 +1086,8 @@ toml = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] tomli = [ - {file = "tomli-1.2.2-py3-none-any.whl", hash = "sha256:f04066f68f5554911363063a30b108d2b5a5b1a010aa8b6132af78489fe3aade"}, - {file = "tomli-1.2.2.tar.gz", hash = "sha256:c6ce0015eb38820eaf32b5db832dbc26deb3dd427bd5f6556cf0acac2c214fee"}, + {file = "tomli-2.0.0-py3-none-any.whl", hash = "sha256:b5bde28da1fed24b9bd1d4d2b8cba62300bfb4ec9a6187a957e8ddb9434c5224"}, + {file = "tomli-2.0.0.tar.gz", hash = "sha256:c292c34f58502a1eb2bbb9f5bbc9a5ebc37bee10ffb8c2d6bbdfa8eb13cc14e1"}, ] tox = [ {file = "tox-3.24.4-py2.py3-none-any.whl", hash = "sha256:5e274227a53dc9ef856767c21867377ba395992549f02ce55eb549f9fb9a8d10"}, @@ -1116,10 +1108,6 @@ virtualenv = [ voluptuous = [ {file = "voluptuous-0.12.2.tar.gz", hash = "sha256:4db1ac5079db9249820d49c891cb4660a6f8cae350491210abce741fabf56513"}, ] -wcwidth = [ - {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, - {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, -] xdoctest = [ {file = "xdoctest-0.15.10-py3-none-any.whl", hash = "sha256:7666bd0511df59275dfe94ef94b0fde9654afd14f00bf88902fdc9bcee77d527"}, {file = "xdoctest-0.15.10.tar.gz", hash = "sha256:5f16438f2b203860e75ec594dbc38020df7524db0b41bb88467ea0a6030e6685"}, diff --git a/pyproject.toml b/pyproject.toml index 60bf29ebd..30dc3db82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ sphinx_rtd_theme = { version = "^0", optional = true } sphinxcontrib-programoutput = { version = "^0", optional = true } [tool.poetry.dev-dependencies] -pytest = "^5" +pytest = ">=6.2.5" pytest-cov = "^2" pytest-asyncio = "^0" pytest-sugar = "*" From 723fca9d086aaec5768cc4cc0e662a55ef1e9f6a Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 8 Jan 2022 17:48:01 +0100 Subject: [PATCH 063/892] Do not crash on discovery on WSL (#283) --- kasa/discover.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/kasa/discover.py b/kasa/discover.py index 5269f3d0b..c09010efc 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -46,9 +46,14 @@ def __init__( def connection_made(self, transport) -> None: """Set socket options for broadcasting.""" self.transport = transport + sock = transport.get_extra_info("socket") sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + except OSError as ex: # WSL does not support SO_REUSEADDR, see #246 + _LOGGER.debug("Unable to set SO_REUSEADDR: %s", ex) + if self.interface is not None: sock.setsockopt( socket.SOL_SOCKET, socket.SO_BINDTODEVICE, self.interface.encode() From 6ece506a3bf3b76e1c6f3a9a11b93f47ceb9a3e6 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 14 Jan 2022 16:32:32 +0100 Subject: [PATCH 064/892] Relax asyncclick version requirement (#286) * Add package_name to version_option(), breaks --version on click < 8 --- kasa/cli.py | 2 +- poetry.lock | 145 +++++++++++++++++++++---------------------------- pyproject.toml | 3 +- 3 files changed, 64 insertions(+), 86 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index b8f1eed5c..9c0b50a2e 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -56,7 +56,7 @@ @click.option( "--type", default=None, type=click.Choice(TYPE_TO_CLASS, case_sensitive=False) ) -@click.version_option() +@click.version_option(package_name="python-kasa") @click.pass_context async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip, type): """A tool for controlling TP-Link smart home devices.""" # noqa diff --git a/poetry.lock b/poetry.lock index 83966fd3c..88d46723e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -8,7 +8,7 @@ python-versions = "*" [[package]] name = "anyio" -version = "3.4.0" +version = "3.5.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" category = "main" optional = false @@ -20,24 +20,21 @@ sniffio = ">=1.1" typing-extensions = {version = "*", markers = "python_version < \"3.8\""} [package.extras] -doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] +doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] trio = ["trio (>=0.16)"] [[package]] name = "asyncclick" -version = "7.1.2.3" -description = "A simple anyio-compatible fork of Click, for powerful command line utilities." +version = "8.0.3.1" +description = "Composable command line interface toolkit, async version" category = "main" optional = false python-versions = ">=3.6" [package.dependencies] -anyio = ">=2" - -[package.extras] -dev = ["coverage", "pytest-runner", "pytest-trio", "pytest (>=3)", "sphinx", "tox"] -docs = ["sphinx"] +colorama = {version = "*", markers = "platform_system == \"Windows\""} +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "atomicwrites" @@ -49,17 +46,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "21.2.0" +version = "21.4.0" description = "Classes Without Boilerplate" category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] [[package]] name = "babel" @@ -72,21 +69,6 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] pytz = ">=2015.7" -[[package]] -name = "backports.entry-points-selectable" -version = "1.1.1" -description = "Compatibility shim providing selectable entry points for older implementations" -category = "dev" -optional = false -python-versions = ">=2.7" - -[package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} - -[package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] - [[package]] name = "certifi" version = "2021.10.8" @@ -105,7 +87,7 @@ python-versions = ">=3.6.1" [[package]] name = "charset-normalizer" -version = "2.0.9" +version = "2.0.10" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false @@ -166,11 +148,11 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "filelock" -version = "3.4.0" +version = "3.4.2" description = "A platform independent file lock." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] @@ -178,7 +160,7 @@ testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-co [[package]] name = "identify" -version = "2.4.0" +version = "2.4.4" description = "File identification library for Python" category = "dev" optional = false @@ -205,7 +187,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.9.0" +version = "4.10.0" description = "Read metadata from Python packages" category = "main" optional = false @@ -218,7 +200,7 @@ zipp = ">=0.5" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] perf = ["ipython"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] [[package]] name = "iniconfig" @@ -291,11 +273,11 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "platformdirs" -version = "2.4.0" +version = "2.4.1" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] @@ -343,7 +325,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pygments" -version = "2.10.0" +version = "2.11.2" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = true @@ -384,17 +366,17 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm [[package]] name = "pytest-asyncio" -version = "0.16.0" -description = "Pytest support for asyncio." +version = "0.17.0" +description = "Pytest support for asyncio" category = "dev" optional = false -python-versions = ">= 3.6" +python-versions = ">=3.7" [package.dependencies] pytest = ">=5.4.0" [package.extras] -testing = ["coverage", "hypothesis (>=5.7.1)"] +testing = ["coverage (==6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)"] [[package]] name = "pytest-cov" @@ -457,7 +439,7 @@ python-versions = ">=3.6" [[package]] name = "requests" -version = "2.26.0" +version = "2.27.1" description = "Python HTTP for Humans." category = "main" optional = false @@ -651,7 +633,7 @@ python-versions = ">=3.7" [[package]] name = "tox" -version = "3.24.4" +version = "3.24.5" description = "tox is a generic virtualenv management and test command line tool" category = "dev" optional = false @@ -670,7 +652,7 @@ virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2, [package.extras] docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] -testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)", "pathlib2 (>=2.3.3)"] +testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "psutil (>=5.6.1)", "pathlib2 (>=2.3.3)"] [[package]] name = "typing-extensions" @@ -682,7 +664,7 @@ python-versions = ">=3.6" [[package]] name = "urllib3" -version = "1.26.7" +version = "1.26.8" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false @@ -695,14 +677,13 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.10.0" +version = "20.13.0" description = "Virtual Python Environment builder" category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] -"backports.entry-points-selectable" = ">=1.0.4" distlib = ">=0.3.1,<1" filelock = ">=3.2,<4" importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} @@ -741,15 +722,15 @@ tests = ["codecov", "scikit-build", "cmake", "ninja", "pybind11", "pytest", "pyt [[package]] name = "zipp" -version = "3.6.0" +version = "3.7.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [extras] docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programoutput"] @@ -757,7 +738,7 @@ docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programou [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "be2a7aea5fa2ddbc1710791761c73638e4d39217fbb32f2193a728b3f4f2cc05" +content-hash = "e4ae6318d9d43109a5f6f0c8f3e6f233bb353f8945665a7cca2a1ddea6a705bf" [metadata.files] alabaster = [ @@ -765,28 +746,24 @@ alabaster = [ {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, ] anyio = [ - {file = "anyio-3.4.0-py3-none-any.whl", hash = "sha256:2855a9423524abcdd652d942f8932fda1735210f77a6b392eafd9ff34d3fe020"}, - {file = "anyio-3.4.0.tar.gz", hash = "sha256:24adc69309fb5779bc1e06158e143e0b6d2c56b302a3ac3de3083c705a6ed39d"}, + {file = "anyio-3.5.0-py3-none-any.whl", hash = "sha256:b5fa16c5ff93fa1046f2eeb5bbff2dad4d3514d6cda61d02816dba34fa8c3c2e"}, + {file = "anyio-3.5.0.tar.gz", hash = "sha256:a0aeffe2fb1fdf374a8e4b471444f0f3ac4fb9f5a5b542b48824475e0042a5a6"}, ] asyncclick = [ - {file = "asyncclick-7.1.2.3.tar.gz", hash = "sha256:c26962b9957abe7ae09c058afbfea199dedea1b54343c1cc2ae1a6a291fab333"}, + {file = "asyncclick-8.0.3.1.tar.gz", hash = "sha256:564bdb9e90c8ec06e535db10068624644b4ad76ff723ead1b7e1755d6b4a6c32"}, ] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, - {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, ] babel = [ {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, ] -"backports.entry-points-selectable" = [ - {file = "backports.entry_points_selectable-1.1.1-py2.py3-none-any.whl", hash = "sha256:7fceed9532a7aa2bd888654a7314f864a3c16a4e710b34a58cfc0f08114c663b"}, - {file = "backports.entry_points_selectable-1.1.1.tar.gz", hash = "sha256:914b21a479fde881635f7af5adc7f6e38d6b274be32269070c53b698c60d5386"}, -] certifi = [ {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, @@ -796,8 +773,8 @@ cfgv = [ {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] charset-normalizer = [ - {file = "charset-normalizer-2.0.9.tar.gz", hash = "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c"}, - {file = "charset_normalizer-2.0.9-py3-none-any.whl", hash = "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721"}, + {file = "charset-normalizer-2.0.10.tar.gz", hash = "sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd"}, + {file = "charset_normalizer-2.0.10-py3-none-any.whl", hash = "sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455"}, ] codecov = [ {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"}, @@ -866,12 +843,12 @@ docutils = [ {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, ] filelock = [ - {file = "filelock-3.4.0-py3-none-any.whl", hash = "sha256:2e139a228bcf56dd8b2274a65174d005c4a6b68540ee0bdbb92c76f43f29f7e8"}, - {file = "filelock-3.4.0.tar.gz", hash = "sha256:93d512b32a23baf4cac44ffd72ccf70732aeff7b8050fcaf6d3ec406d954baf4"}, + {file = "filelock-3.4.2-py3-none-any.whl", hash = "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"}, + {file = "filelock-3.4.2.tar.gz", hash = "sha256:38b4f4c989f9d06d44524df1b24bd19e167d851f19b50bf3e3559952dddc5b80"}, ] identify = [ - {file = "identify-2.4.0-py2.py3-none-any.whl", hash = "sha256:eba31ca80258de6bb51453084bff4a923187cd2193b9c13710f2516ab30732cc"}, - {file = "identify-2.4.0.tar.gz", hash = "sha256:a33ae873287e81651c7800ca309dc1f84679b763c9c8b30680e16fbfa82f0107"}, + {file = "identify-2.4.4-py2.py3-none-any.whl", hash = "sha256:aa68609c7454dbcaae60a01ff6b8df1de9b39fe6e50b1f6107ec81dcda624aa6"}, + {file = "identify-2.4.4.tar.gz", hash = "sha256:6b4b5031f69c48bf93a646b90de9b381c6b5f560df4cbe0ed3cf7650ae741e4d"}, ] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, @@ -882,8 +859,8 @@ imagesize = [ {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.9.0-py3-none-any.whl", hash = "sha256:e8b45564028bc25f8c99f546616112a6df5de6655893d7eb74c9a99680dc9751"}, - {file = "importlib_metadata-4.9.0.tar.gz", hash = "sha256:ee50794eccb0ec340adbc838344ebb9a6ff2bcba78f752d31fc716497e2149d6"}, + {file = "importlib_metadata-4.10.0-py3-none-any.whl", hash = "sha256:b7cf7d3fef75f1e4c80a96ca660efbd51473d7e8f39b5ab9210febc7809012a4"}, + {file = "importlib_metadata-4.10.0.tar.gz", hash = "sha256:92a8b58ce734b2a4494878e0ecf7d79ccd7a128b5fc6014c401e0b61f006f0f6"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -945,8 +922,8 @@ packaging = [ {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] platformdirs = [ - {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, - {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, + {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"}, + {file = "platformdirs-2.4.1.tar.gz", hash = "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, @@ -961,8 +938,8 @@ py = [ {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] pygments = [ - {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, - {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, + {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, + {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, ] pyparsing = [ {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, @@ -973,8 +950,8 @@ pytest = [ {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, ] pytest-asyncio = [ - {file = "pytest-asyncio-0.16.0.tar.gz", hash = "sha256:7496c5977ce88c34379df64a66459fe395cd05543f0a2f837016e7144391fcfb"}, - {file = "pytest_asyncio-0.16.0-py3-none-any.whl", hash = "sha256:5f2a21273c47b331ae6aa5b36087047b4899e40f03f18397c0e65fa5cca54e9b"}, + {file = "pytest-asyncio-0.17.0.tar.gz", hash = "sha256:c98e0e04ae5910bbcc219f52bdf871bd1d392f624ef77c49c236613c0b6d8ee1"}, + {file = "pytest_asyncio-0.17.0-py3-none-any.whl", hash = "sha256:b41c3ff0ec5b5b144459aa1c53a866f67278177f6d4f3ef6874bd864fc82834d"}, ] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, @@ -1027,8 +1004,8 @@ pyyaml = [ {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, ] requests = [ - {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, - {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, + {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, + {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, ] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, @@ -1090,20 +1067,20 @@ tomli = [ {file = "tomli-2.0.0.tar.gz", hash = "sha256:c292c34f58502a1eb2bbb9f5bbc9a5ebc37bee10ffb8c2d6bbdfa8eb13cc14e1"}, ] tox = [ - {file = "tox-3.24.4-py2.py3-none-any.whl", hash = "sha256:5e274227a53dc9ef856767c21867377ba395992549f02ce55eb549f9fb9a8d10"}, - {file = "tox-3.24.4.tar.gz", hash = "sha256:c30b57fa2477f1fb7c36aa1d83292d5c2336cd0018119e1b1c17340e2c2708ca"}, + {file = "tox-3.24.5-py2.py3-none-any.whl", hash = "sha256:be3362472a33094bce26727f5f771ca0facf6dafa217f65875314e9a6600c95c"}, + {file = "tox-3.24.5.tar.gz", hash = "sha256:67e0e32c90e278251fea45b696d0fef3879089ccbe979b0c556d35d5a70e2993"}, ] typing-extensions = [ {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, ] urllib3 = [ - {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, - {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, + {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, + {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"}, ] virtualenv = [ - {file = "virtualenv-20.10.0-py2.py3-none-any.whl", hash = "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814"}, - {file = "virtualenv-20.10.0.tar.gz", hash = "sha256:576d05b46eace16a9c348085f7d0dc8ef28713a2cabaa1cf0aea41e8f12c9218"}, + {file = "virtualenv-20.13.0-py2.py3-none-any.whl", hash = "sha256:339f16c4a86b44240ba7223d0f93a7887c3ca04b5f9c8129da7958447d079b09"}, + {file = "virtualenv-20.13.0.tar.gz", hash = "sha256:d8458cf8d59d0ea495ad9b34c2599487f8a7772d796f9910858376d1600dd2dd"}, ] voluptuous = [ {file = "voluptuous-0.12.2.tar.gz", hash = "sha256:4db1ac5079db9249820d49c891cb4660a6f8cae350491210abce741fabf56513"}, @@ -1113,6 +1090,6 @@ xdoctest = [ {file = "xdoctest-0.15.10.tar.gz", hash = "sha256:5f16438f2b203860e75ec594dbc38020df7524db0b41bb88467ea0a6030e6685"}, ] zipp = [ - {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, - {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, + {file = "zipp-3.7.0-py3-none-any.whl", hash = "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"}, + {file = "zipp-3.7.0.tar.gz", hash = "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d"}, ] diff --git a/pyproject.toml b/pyproject.toml index 30dc3db82..597474578 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,8 +16,9 @@ kasa = "kasa.cli:cli" [tool.poetry.dependencies] python = "^3.7" +anyio = "*" # see https://github.com/python-trio/asyncclick/issues/18 importlib-metadata = "*" -asyncclick = "^7" +asyncclick = ">=7" # required only for docs sphinx = { version = "^3", optional = true } From 255c0c9a25cbc0a4f99d77415b9e6eebccceb6b8 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 14 Jan 2022 16:32:48 +0100 Subject: [PATCH 065/892] Publish to pypi on github release published (#287) * Publish to pypi on github release published, remove testpypi * Remove release tag check --- .github/workflows/publish.yml | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4ff4c3b64..2f7ec9cad 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,8 +1,7 @@ name: Publish packages on: - push: - branches: - - master + release: + types: [published] jobs: build-n-publish: @@ -30,15 +29,7 @@ jobs: --wheel --outdir dist/ . - - name: Publish on test pypi - uses: pypa/gh-action-pypi-publish@master - continue-on-error: true - with: - password: ${{ secrets.TEST_PYPI_API_TOKEN }} - repository_url: https://test.pypi.org/legacy/ - - name: Publish release on pypi - if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@master with: password: ${{ secrets.PYPI_API_TOKEN }} From b4036e55acd6137366ee4d8a93867e5b90dc8461 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 14 Jan 2022 16:47:49 +0100 Subject: [PATCH 066/892] Prepare 0.4.1 (#288) This minor release fixes issues that were found after homeassistant integration got converted over from pyhs100. [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0...0.4.1) **Implemented enhancements:** - Add --type option to cli [\#269](https://github.com/python-kasa/python-kasa/pull/269) ([rytilahti](https://github.com/rytilahti)) - Minor improvements to onboarding doc [\#264](https://github.com/python-kasa/python-kasa/pull/264) ([rytilahti](https://github.com/rytilahti)) - Add fixture file for KL135 [\#263](https://github.com/python-kasa/python-kasa/pull/263) ([ErikSGross](https://github.com/ErikSGross)) - Add KL135 color temperature range [\#256](https://github.com/python-kasa/python-kasa/pull/256) ([rytilahti](https://github.com/rytilahti)) - Add py.typed to flag that the package is typed [\#251](https://github.com/python-kasa/python-kasa/pull/251) ([rytilahti](https://github.com/rytilahti)) - Add script to check supported devices, update README [\#242](https://github.com/python-kasa/python-kasa/pull/242) ([rytilahti](https://github.com/rytilahti)) - Add perftest to devtools [\#236](https://github.com/python-kasa/python-kasa/pull/236) ([rytilahti](https://github.com/rytilahti)) - Add KP401 US fixture [\#234](https://github.com/python-kasa/python-kasa/pull/234) ([bdraco](https://github.com/bdraco)) - Add KL60 US KP105 UK fixture [\#233](https://github.com/python-kasa/python-kasa/pull/233) ([bdraco](https://github.com/bdraco)) - Make cli interface more consistent [\#232](https://github.com/python-kasa/python-kasa/pull/232) ([rytilahti](https://github.com/rytilahti)) - Add KL400, KL50 fixtures [\#231](https://github.com/python-kasa/python-kasa/pull/231) ([bdraco](https://github.com/bdraco)) - Add fixture for newer KP400 firmware [\#227](https://github.com/python-kasa/python-kasa/pull/227) ([bdraco](https://github.com/bdraco)) - Switch to poetry-core [\#226](https://github.com/python-kasa/python-kasa/pull/226) ([fabaff](https://github.com/fabaff)) - Add fixtures for LB110, KL110, EP40, KL430, KP115 [\#224](https://github.com/python-kasa/python-kasa/pull/224) ([bdraco](https://github.com/bdraco)) **Fixed bugs:** - Discovery on WSL results in OSError: \[Errno 22\] Invalid argument [\#246](https://github.com/python-kasa/python-kasa/issues/246) - New firmware for HS103 blocking local access? [\#42](https://github.com/python-kasa/python-kasa/issues/42) - Pin mistune to \<2.0.0 to fix doc builds [\#270](https://github.com/python-kasa/python-kasa/pull/270) ([rytilahti](https://github.com/rytilahti)) - Catch exceptions raised on unknown devices during discovery [\#240](https://github.com/python-kasa/python-kasa/pull/240) ([rytilahti](https://github.com/rytilahti)) **Closed issues:** - Control device with alias via python api? [\#285](https://github.com/python-kasa/python-kasa/issues/285) - Can't install using pip install python-kasa [\#255](https://github.com/python-kasa/python-kasa/issues/255) - Kasa Smart Bulb KL135 - Unknown color temperature range error [\#252](https://github.com/python-kasa/python-kasa/issues/252) - KL400 Support [\#247](https://github.com/python-kasa/python-kasa/issues/247) - Cloud support? [\#245](https://github.com/python-kasa/python-kasa/issues/245) - Support for kp401 [\#241](https://github.com/python-kasa/python-kasa/issues/241) - LB130 Bulb stopped working [\#237](https://github.com/python-kasa/python-kasa/issues/237) - Unable to constantly query bulb in loop [\#225](https://github.com/python-kasa/python-kasa/issues/225) - HS103: Unable to query the device: unpack requires a buffer of 4 bytes [\#187](https://github.com/python-kasa/python-kasa/issues/187) - Help request - query value [\#171](https://github.com/python-kasa/python-kasa/issues/171) - Can't Discover Devices [\#164](https://github.com/python-kasa/python-kasa/issues/164) - Concurrency performance question [\#110](https://github.com/python-kasa/python-kasa/issues/110) - Define the port by self? [\#108](https://github.com/python-kasa/python-kasa/issues/108) - Convert homeassistant integration to use the library [\#9](https://github.com/python-kasa/python-kasa/issues/9) **Merged pull requests:** - Publish to pypi on github release published [\#287](https://github.com/python-kasa/python-kasa/pull/287) ([rytilahti](https://github.com/rytilahti)) - Relax asyncclick version requirement [\#286](https://github.com/python-kasa/python-kasa/pull/286) ([rytilahti](https://github.com/rytilahti)) - Do not crash on discovery on WSL [\#283](https://github.com/python-kasa/python-kasa/pull/283) ([rytilahti](https://github.com/rytilahti)) - Add python 3.10 to CI [\#279](https://github.com/python-kasa/python-kasa/pull/279) ([rytilahti](https://github.com/rytilahti)) - Use codecov-action@v2 for CI [\#277](https://github.com/python-kasa/python-kasa/pull/277) ([rytilahti](https://github.com/rytilahti)) - Add coverage\[toml\] dependency to fix coverage on CI [\#271](https://github.com/python-kasa/python-kasa/pull/271) ([rytilahti](https://github.com/rytilahti)) - Allow publish on test pypi workflow to fail [\#248](https://github.com/python-kasa/python-kasa/pull/248) ([rytilahti](https://github.com/rytilahti)) --- CHANGELOG.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 618607160..84aa4e942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,61 @@ # Changelog -## [0.4.0](https://github.com/python-kasa/python-kasa/tree/0.4.0) (2021-09-26) +## [0.4.1](https://github.com/python-kasa/python-kasa/tree/0.4.1) (2022-01-14) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0...0.4.1) + +**Implemented enhancements:** + +- Add --type option to cli [\#269](https://github.com/python-kasa/python-kasa/pull/269) ([rytilahti](https://github.com/rytilahti)) +- Minor improvements to onboarding doc [\#264](https://github.com/python-kasa/python-kasa/pull/264) ([rytilahti](https://github.com/rytilahti)) +- Add fixture file for KL135 [\#263](https://github.com/python-kasa/python-kasa/pull/263) ([ErikSGross](https://github.com/ErikSGross)) +- Add KL135 color temperature range [\#256](https://github.com/python-kasa/python-kasa/pull/256) ([rytilahti](https://github.com/rytilahti)) +- Add py.typed to flag that the package is typed [\#251](https://github.com/python-kasa/python-kasa/pull/251) ([rytilahti](https://github.com/rytilahti)) +- Add script to check supported devices, update README [\#242](https://github.com/python-kasa/python-kasa/pull/242) ([rytilahti](https://github.com/rytilahti)) +- Add perftest to devtools [\#236](https://github.com/python-kasa/python-kasa/pull/236) ([rytilahti](https://github.com/rytilahti)) +- Add KP401 US fixture [\#234](https://github.com/python-kasa/python-kasa/pull/234) ([bdraco](https://github.com/bdraco)) +- Add KL60 US KP105 UK fixture [\#233](https://github.com/python-kasa/python-kasa/pull/233) ([bdraco](https://github.com/bdraco)) +- Make cli interface more consistent [\#232](https://github.com/python-kasa/python-kasa/pull/232) ([rytilahti](https://github.com/rytilahti)) +- Add KL400, KL50 fixtures [\#231](https://github.com/python-kasa/python-kasa/pull/231) ([bdraco](https://github.com/bdraco)) +- Add fixture for newer KP400 firmware [\#227](https://github.com/python-kasa/python-kasa/pull/227) ([bdraco](https://github.com/bdraco)) +- Switch to poetry-core [\#226](https://github.com/python-kasa/python-kasa/pull/226) ([fabaff](https://github.com/fabaff)) +- Add fixtures for LB110, KL110, EP40, KL430, KP115 [\#224](https://github.com/python-kasa/python-kasa/pull/224) ([bdraco](https://github.com/bdraco)) + +**Fixed bugs:** + +- Discovery on WSL results in OSError: \[Errno 22\] Invalid argument [\#246](https://github.com/python-kasa/python-kasa/issues/246) +- New firmware for HS103 blocking local access? [\#42](https://github.com/python-kasa/python-kasa/issues/42) +- Pin mistune to \<2.0.0 to fix doc builds [\#270](https://github.com/python-kasa/python-kasa/pull/270) ([rytilahti](https://github.com/rytilahti)) +- Catch exceptions raised on unknown devices during discovery [\#240](https://github.com/python-kasa/python-kasa/pull/240) ([rytilahti](https://github.com/rytilahti)) + +**Closed issues:** + +- Control device with alias via python api? [\#285](https://github.com/python-kasa/python-kasa/issues/285) +- Can't install using pip install python-kasa [\#255](https://github.com/python-kasa/python-kasa/issues/255) +- Kasa Smart Bulb KL135 - Unknown color temperature range error [\#252](https://github.com/python-kasa/python-kasa/issues/252) +- KL400 Support [\#247](https://github.com/python-kasa/python-kasa/issues/247) +- Cloud support? [\#245](https://github.com/python-kasa/python-kasa/issues/245) +- Support for kp401 [\#241](https://github.com/python-kasa/python-kasa/issues/241) +- LB130 Bulb stopped working [\#237](https://github.com/python-kasa/python-kasa/issues/237) +- Unable to constantly query bulb in loop [\#225](https://github.com/python-kasa/python-kasa/issues/225) +- HS103: Unable to query the device: unpack requires a buffer of 4 bytes [\#187](https://github.com/python-kasa/python-kasa/issues/187) +- Help request - query value [\#171](https://github.com/python-kasa/python-kasa/issues/171) +- Can't Discover Devices [\#164](https://github.com/python-kasa/python-kasa/issues/164) +- Concurrency performance question [\#110](https://github.com/python-kasa/python-kasa/issues/110) +- Define the port by self? [\#108](https://github.com/python-kasa/python-kasa/issues/108) +- Convert homeassistant integration to use the library [\#9](https://github.com/python-kasa/python-kasa/issues/9) + +**Merged pull requests:** + +- Publish to pypi on github release published [\#287](https://github.com/python-kasa/python-kasa/pull/287) ([rytilahti](https://github.com/rytilahti)) +- Relax asyncclick version requirement [\#286](https://github.com/python-kasa/python-kasa/pull/286) ([rytilahti](https://github.com/rytilahti)) +- Do not crash on discovery on WSL [\#283](https://github.com/python-kasa/python-kasa/pull/283) ([rytilahti](https://github.com/rytilahti)) +- Add python 3.10 to CI [\#279](https://github.com/python-kasa/python-kasa/pull/279) ([rytilahti](https://github.com/rytilahti)) +- Use codecov-action@v2 for CI [\#277](https://github.com/python-kasa/python-kasa/pull/277) ([rytilahti](https://github.com/rytilahti)) +- Add coverage\[toml\] dependency to fix coverage on CI [\#271](https://github.com/python-kasa/python-kasa/pull/271) ([rytilahti](https://github.com/rytilahti)) +- Allow publish on test pypi workflow to fail [\#248](https://github.com/python-kasa/python-kasa/pull/248) ([rytilahti](https://github.com/rytilahti)) + +## [0.4.0](https://github.com/python-kasa/python-kasa/tree/0.4.0) (2021-09-27) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev5...0.4.0) @@ -15,6 +70,7 @@ **Merged pull requests:** +- Release 0.4.0 [\#221](https://github.com/python-kasa/python-kasa/pull/221) ([rytilahti](https://github.com/rytilahti)) - Add github workflow for pypi publishing [\#220](https://github.com/python-kasa/python-kasa/pull/220) ([rytilahti](https://github.com/rytilahti)) - Add host information to protocol debug logs [\#219](https://github.com/python-kasa/python-kasa/pull/219) ([rytilahti](https://github.com/rytilahti)) diff --git a/pyproject.toml b/pyproject.toml index 597474578..618375f26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.4.0" +version = "0.4.1" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["Your Name "] From bcb9fe18ab2f81f6f0c28c3643b70751792852d6 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 14 Jan 2022 23:08:25 +0100 Subject: [PATCH 067/892] Improve typing for protocol class (#289) --- kasa/protocol.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/kasa/protocol.py b/kasa/protocol.py index 54fea0803..14de9c6c6 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -15,7 +15,7 @@ import logging import struct from pprint import pformat as pf -from typing import Dict, Optional, Union +from typing import Dict, Generator, Optional, Union from .exceptions import SmartDeviceException @@ -107,7 +107,7 @@ async def _execute_query(self, request: str) -> Dict: return json_payload - async def close(self): + async def close(self) -> None: """Close the connection.""" writer = self.writer self.reader = self.writer = None @@ -116,7 +116,7 @@ async def close(self): with contextlib.suppress(Exception): await writer.wait_closed() - def _reset(self): + def _reset(self) -> None: """Clear any varibles that should not survive between loops.""" self.reader = self.writer = self.loop = self.query_lock = None @@ -154,13 +154,13 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: await self.close() raise SmartDeviceException("Query reached somehow to unreachable") - def __del__(self): + def __del__(self) -> None: if self.writer and self.loop and self.loop.is_running(): self.writer.close() self._reset() @staticmethod - def _xor_payload(unencrypted): + def _xor_payload(unencrypted: bytes) -> Generator[int, None, None]: key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR for unencryptedbyte in unencrypted: key = key ^ unencryptedbyte @@ -179,7 +179,7 @@ def encrypt(request: str) -> bytes: ) @staticmethod - def _xor_encrypted_payload(ciphertext): + def _xor_encrypted_payload(ciphertext: bytes) -> Generator[int, None, None]: key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR for cipherbyte in ciphertext: plainbyte = key ^ cipherbyte From 6a31de5381bf7fedc5a54b75293744a3b94a5393 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 29 Jan 2022 17:02:05 +0100 Subject: [PATCH 068/892] Drop microsecond precision for on_since (#296) --- kasa/smartdevice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index fabf26b32..30468c45f 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -598,7 +598,7 @@ def on_since(self) -> Optional[datetime]: on_time = self.sys_info["on_time"] - return datetime.now() - timedelta(seconds=on_time) + return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) @property # type: ignore @requires_update From 5bf6fda7eef22f7cab3915b2cfc9553f6dd6c808 Mon Sep 17 00:00:00 2001 From: mrbetta <39884347+mrbetta@users.noreply.github.com> Date: Sat, 29 Jan 2022 10:28:14 -0700 Subject: [PATCH 069/892] Added a fixture file for KS220M (#273) * Added motion and light sensor for KS220M * Added fixture file for ks220m * Remove dump_devinfo and add the extra queries to devtools/dump_devinfo * Test KS220M as a dimmer * Add empty modules to baseproto to make the tests pass Co-authored-by: mrbetta Co-authored-by: Teemu Rytilahti --- devtools/dump_devinfo.py | 2 + kasa/tests/conftest.py | 2 +- kasa/tests/fixtures/KS220M(US)_1.0_1.0.4.json | 126 ++++++++++++++++++ kasa/tests/newfakes.py | 2 + 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/KS220M(US)_1.0_1.0.4.json diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 1108e7fb4..2f3c20e01 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -76,6 +76,8 @@ def cli(host, debug): Call( module="smartlife.iot.smartbulb.lightingservice", method="get_light_state" ), + Call(module="smartlife.iot.LAS", method="get_config"), + Call(module="smartlife.iot.PIR", method="get_config"), ] successes = [] diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 2c1d1e49c..f9fc917f6 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -53,7 +53,7 @@ "KP401", } STRIPS = {"HS107", "HS300", "KP303", "KP400", "EP40"} -DIMMERS = {"HS220"} +DIMMERS = {"HS220", "KS220M"} DIMMABLE = {*BULBS, *DIMMERS} WITH_EMETER = {"HS110", "HS300", "KP115", *BULBS} diff --git a/kasa/tests/fixtures/KS220M(US)_1.0_1.0.4.json b/kasa/tests/fixtures/KS220M(US)_1.0_1.0.4.json new file mode 100644 index 000000000..40da46fdd --- /dev/null +++ b/kasa/tests/fixtures/KS220M(US)_1.0_1.0.4.json @@ -0,0 +1,126 @@ +{ + "smartlife.iot.LAS": { + "get_config": { + "devs": [ + { + "dark_index": 0, + "enable": 1, + "hw_id": 0, + "level_array": [ + { + "adc": 490, + "name": "cloudy", + "value": 20 + }, + { + "adc": 294, + "name": "overcast", + "value": 12 + }, + { + "adc": 222, + "name": "dawn", + "value": 9 + }, + { + "adc": 222, + "name": "twilight", + "value": 9 + }, + { + "adc": 111, + "name": "total darkness", + "value": 4 + }, + { + "adc": 2400, + "name": "custom", + "value": 97 + } + ], + "max_adc": 2450, + "min_adc": 0 + } + ], + "err_code": 0, + "ver": "1.0" + } + }, + "smartlife.iot.PIR": { + "get_config": { + "array": [ + 80, + 50, + 20, + 61 + ], + "cold_time": 60000, + "enable": 1, + "err_code": 0, + "max_adc": 4095, + "min_adc": 0, + "trigger_index": 2, + "version": "1.0" + } + }, + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "bulb_type": 1, + "err_code": 0, + "fadeOffTime": 0, + "fadeOnTime": 0, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 1, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Garage Entryway Lights", + "brightness": 100, + "dev_name": "Wi-Fi Smart Dimmer with sensor", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS220M(US)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "preferred_state": [ + { + "brightness": 100, + "index": 0 + }, + { + "brightness": 75, + "index": 1 + }, + { + "brightness": 50, + "index": 2 + }, + { + "brightness": 25, + "index": 3 + } + ], + "relay_state": 0, + "rssi": -42, + "status": "new", + "sw_ver": "1.0.4 Build 210616 Rel.193517", + "updating": 0 + } + } +} diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 2afae2aa3..2e859e362 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -437,6 +437,8 @@ def light_state(self, x, *args): "set_brightness": set_hs220_brightness, "set_dimmer_transition": set_hs220_dimmer_transition, }, + "smartlife.iot.LAS": {}, + "smartlife.iot.PIR": {}, } async def query(self, request, port=9999): From c865d3f02c34fbbcd310f97087699e819a30b892 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Jan 2022 16:00:00 -0600 Subject: [PATCH 070/892] Fix unsafe __del__ in TPLinkSmartHomeProtocol (#300) * Fix unsafe __del__ in TPLinkSmartHomeProtocol Fixes ``` Exception ignored in: Traceback (most recent call last): File "/Users/bdraco/home-assistant/venv/lib/python3.9/site-packages/kasa/protocol.py", line 159, in __del__ self.writer.close() File "/opt/homebrew/Cellar/python@3.9/3.9.6/Frameworks/Python.framework/Versions/3.9/lib/python3.9/asyncio/streams.py", line 353, in close return self._transport.close() File "/opt/homebrew/Cellar/python@3.9/3.9.6/Frameworks/Python.framework/Versions/3.9/lib/python3.9/asyncio/selector_events.py", line 700, in close self._loop.call_soon(self._call_connection_lost, None) File "/opt/homebrew/Cellar/python@3.9/3.9.6/Frameworks/Python.framework/Versions/3.9/lib/python3.9/asyncio/base_events.py", line 748, in call_soon self._check_thread() File "/opt/homebrew/Cellar/python@3.9/3.9.6/Frameworks/Python.framework/Versions/3.9/lib/python3.9/asyncio/base_events.py", line 785, in _check_thread raise RuntimeError( RuntimeError: Non-thread-safe operation invoked on an event loop other than the current one ``` * comment * comment * comment --- kasa/protocol.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/kasa/protocol.py b/kasa/protocol.py index 14de9c6c6..e2f946269 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -156,7 +156,11 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: def __del__(self) -> None: if self.writer and self.loop and self.loop.is_running(): - self.writer.close() + # Since __del__ will be called when python does + # garbage collection is can happen in the event loop thread + # 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 From 9ea83388acb31f5f62588a5531c60072707d1ec0 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 2 Feb 2022 19:30:48 +0100 Subject: [PATCH 071/892] cli: cleanup discover, fetch update prior device access (#303) * Use on_discovered for smoother user experience * Remove --discover-only as unnecessary --- kasa/cli.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 9c0b50a2e..fc7286e1d 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1,4 +1,5 @@ """python-kasa cli tool.""" +import asyncio import logging from pprint import pformat as pf from typing import cast @@ -145,24 +146,24 @@ async def join(dev: SmartDevice, ssid, password, keytype): @cli.command() @click.option("--timeout", default=3, required=False) -@click.option("--discover-only", default=False) @click.option("--dump-raw", is_flag=True) @click.pass_context -async def discover(ctx, timeout, discover_only, dump_raw): +async def discover(ctx, timeout, dump_raw): """Discover devices in the network.""" target = ctx.parent.params["target"] click.echo(f"Discovering devices on {target} for {timeout} seconds") - found_devs = await Discover.discover(target=target, timeout=timeout) - if not discover_only: - for ip, dev in found_devs.items(): - if dump_raw: - click.echo(dev.sys_info) - continue + sem = asyncio.Semaphore() + + async def print_discovered(dev: SmartDevice): + await dev.update() + async with sem: ctx.obj = dev await ctx.invoke(state) click.echo() - return found_devs + await Discover.discover( + target=target, timeout=timeout, on_discovered=print_discovered + ) async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3): @@ -224,7 +225,6 @@ async def state(ctx, dev: SmartDevice): click.echo(click.style("\n\t== Device specific information ==", bold=True)) for k, v in dev.state_information.items(): click.echo(f"\t{k}: {v}") - click.echo() if dev.has_emeter: click.echo(click.style("\n\t== Current State ==", bold=True)) From 700f3859c2ec07a74bbe79f9507686c332fb53c0 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 2 Feb 2022 19:31:11 +0100 Subject: [PATCH 072/892] Guard emeter accesses to avoid keyerrors (#304) Raise an exception to inform the caller that update() is needed --- kasa/smartdevice.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 30468c45f..bf43cf174 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -219,6 +219,8 @@ def _verify_emeter(self) -> None: """Raise an exception if there is no emeter.""" if not self.has_emeter: raise SmartDeviceException("Device has no emeter") + if self.emeter_type not in self._last_update: + raise SmartDeviceException("update() required prior accessing emeter") async def _query_helper( self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None From b61c0feea9fa48a3e94c79d4bbf4446be7fc77ce Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 7 Feb 2022 09:13:47 +0100 Subject: [PATCH 073/892] Add 'internal_state' to return the results from the last update query (#306) This can be useful for debugging purposes, e.g., for homeassistant diagnostics --- kasa/smartdevice.py | 9 +++++++++ kasa/tests/test_smartdevice.py | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index bf43cf174..25b916318 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -721,6 +721,15 @@ def is_color(self) -> bool: """Return True if the device supports color changes.""" return False + @property + def internal_state(self) -> Any: + """Return the internal state of the instance. + + The returned object contains the raw results from the last update call. + This should only be used for debugging purposes. + """ + return self._last_update + def __repr__(self): if self._last_update is None: return f"<{self._device_type} at {self.host} - update() needed>" diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 380cdd1fb..2dfd96340 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -134,3 +134,8 @@ async def test_childrens(dev): assert len(dev.children) > 0 else: assert len(dev.children) == 0 + + +async def test_internal_state(dev): + """Make sure the internal state returns the last update results.""" + assert dev.internal_state == dev._last_update From 15906ec2326f86d3baaee81023049ca5d180747e Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 14 Feb 2022 18:26:51 +0100 Subject: [PATCH 074/892] Depend on asyncclick >= 8 (#312) --- .pre-commit-config.yaml | 10 +-- poetry.lock | 183 +++++++++++++++++++--------------------- pyproject.toml | 2 +- 3 files changed, 95 insertions(+), 100 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cf8dd7867..0a05343d9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.1.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -10,13 +10,13 @@ repos: - id: check-ast - repo: https://github.com/asottile/pyupgrade - rev: v2.29.1 + rev: v2.31.0 hooks: - id: pyupgrade args: ['--py37-plus'] - repo: https://github.com/python/black - rev: 21.12b0 + rev: 22.1.0 hooks: - id: black @@ -27,13 +27,13 @@ repos: additional_dependencies: [flake8-docstrings] - repo: https://github.com/pre-commit/mirrors-isort - rev: v5.9.3 + rev: v5.10.1 hooks: - id: isort additional_dependencies: [toml] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.920 + rev: v0.931 hooks: - id: mypy additional_dependencies: [types-click] diff --git a/poetry.lock b/poetry.lock index 88d46723e..9b5346c36 100644 --- a/poetry.lock +++ b/poetry.lock @@ -26,7 +26,7 @@ trio = ["trio (>=0.16)"] [[package]] name = "asyncclick" -version = "8.0.3.1" +version = "8.0.3.2" description = "Composable command line interface toolkit, async version" category = "main" optional = false @@ -87,7 +87,7 @@ python-versions = ">=3.6.1" [[package]] name = "charset-normalizer" -version = "2.0.10" +version = "2.0.12" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false @@ -118,11 +118,11 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "coverage" -version = "6.2" +version = "6.3.1" description = "Code coverage measurement for Python" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] tomli = {version = "*", optional = true, markers = "extra == \"toml\""} @@ -160,11 +160,11 @@ testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-co [[package]] name = "identify" -version = "2.4.4" +version = "2.4.9" description = "File identification library for Python" category = "dev" optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.7" [package.extras] license = ["ukkonen"] @@ -187,7 +187,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.10.0" +version = "4.11.0" description = "Read metadata from Python packages" category = "main" optional = false @@ -273,7 +273,7 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "platformdirs" -version = "2.4.1" +version = "2.5.0" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false @@ -300,7 +300,7 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "2.16.0" +version = "2.17.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -333,7 +333,7 @@ python-versions = ">=3.5" [[package]] name = "pyparsing" -version = "3.0.6" +version = "3.0.7" description = "Python parsing module" category = "main" optional = false @@ -344,7 +344,7 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "6.2.5" +version = "7.0.1" description = "pytest: simple powerful testing with Python" category = "dev" optional = false @@ -359,24 +359,25 @@ iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" py = ">=1.8.2" -toml = "*" +tomli = ">=1.0.0" [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.17.0" +version = "0.18.1" description = "Pytest support for asyncio" category = "dev" optional = false python-versions = ">=3.7" [package.dependencies] -pytest = ">=5.4.0" +pytest = ">=6.1.0" +typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} [package.extras] -testing = ["coverage (==6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)"] +testing = ["coverage (==6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (==0.931)"] [[package]] name = "pytest-cov" @@ -396,11 +397,11 @@ testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtuale [[package]] name = "pytest-mock" -version = "3.6.1" +version = "3.7.0" description = "Thin-wrapper around the mock package for easier use with pytest" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] pytest = ">=5.0" @@ -625,7 +626,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "tomli" -version = "2.0.0" +version = "2.0.1" description = "A lil' TOML parser" category = "dev" optional = false @@ -656,7 +657,7 @@ testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pytest (>=4.0.0)", "pytes [[package]] name = "typing-extensions" -version = "4.0.1" +version = "4.1.0" description = "Backported and Experimental Type Hints for Python 3.6+" category = "main" optional = false @@ -677,7 +678,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.13.0" +version = "20.13.1" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -738,7 +739,7 @@ docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programou [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "e4ae6318d9d43109a5f6f0c8f3e6f233bb353f8945665a7cca2a1ddea6a705bf" +content-hash = "9c4aaea750c8c2cb4ed6d37c53ec3884a10d698ceb77716c33b04eed12a08506" [metadata.files] alabaster = [ @@ -750,7 +751,7 @@ anyio = [ {file = "anyio-3.5.0.tar.gz", hash = "sha256:a0aeffe2fb1fdf374a8e4b471444f0f3ac4fb9f5a5b542b48824475e0042a5a6"}, ] asyncclick = [ - {file = "asyncclick-8.0.3.1.tar.gz", hash = "sha256:564bdb9e90c8ec06e535db10068624644b4ad76ff723ead1b7e1755d6b4a6c32"}, + {file = "asyncclick-8.0.3.2.tar.gz", hash = "sha256:251030d8497c139a09d51f8c4b9b8c261a2be0b7d5722f1b7916cc6770368684"}, ] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, @@ -773,8 +774,8 @@ cfgv = [ {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] charset-normalizer = [ - {file = "charset-normalizer-2.0.10.tar.gz", hash = "sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd"}, - {file = "charset_normalizer-2.0.10-py3-none-any.whl", hash = "sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455"}, + {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, + {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, ] codecov = [ {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"}, @@ -786,53 +787,47 @@ colorama = [ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] coverage = [ - {file = "coverage-6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b"}, - {file = "coverage-6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0"}, - {file = "coverage-6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da"}, - {file = "coverage-6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d"}, - {file = "coverage-6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739"}, - {file = "coverage-6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971"}, - {file = "coverage-6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840"}, - {file = "coverage-6.2-cp310-cp310-win32.whl", hash = "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c"}, - {file = "coverage-6.2-cp310-cp310-win_amd64.whl", hash = "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f"}, - {file = "coverage-6.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76"}, - {file = "coverage-6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47"}, - {file = "coverage-6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64"}, - {file = "coverage-6.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f467bbb837691ab5a8ca359199d3429a11a01e6dfb3d9dcc676dc035ca93c0a9"}, - {file = "coverage-6.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2641f803ee9f95b1f387f3e8f3bf28d83d9b69a39e9911e5bfee832bea75240d"}, - {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1219d760ccfafc03c0822ae2e06e3b1248a8e6d1a70928966bafc6838d3c9e48"}, - {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9a2b5b52be0a8626fcbffd7e689781bf8c2ac01613e77feda93d96184949a98e"}, - {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8e2c35a4c1f269704e90888e56f794e2d9c0262fb0c1b1c8c4ee44d9b9e77b5d"}, - {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5d6b09c972ce9200264c35a1d53d43ca55ef61836d9ec60f0d44273a31aa9f17"}, - {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e3db840a4dee542e37e09f30859f1612da90e1c5239a6a2498c473183a50e781"}, - {file = "coverage-6.2-cp36-cp36m-win32.whl", hash = "sha256:4e547122ca2d244f7c090fe3f4b5a5861255ff66b7ab6d98f44a0222aaf8671a"}, - {file = "coverage-6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:01774a2c2c729619760320270e42cd9e797427ecfddd32c2a7b639cdc481f3c0"}, - {file = "coverage-6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb8b8ee99b3fffe4fd86f4c81b35a6bf7e4462cba019997af2fe679365db0c49"}, - {file = "coverage-6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:619346d57c7126ae49ac95b11b0dc8e36c1dd49d148477461bb66c8cf13bb521"}, - {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0a7726f74ff63f41e95ed3a89fef002916c828bb5fcae83b505b49d81a066884"}, - {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cfd9386c1d6f13b37e05a91a8583e802f8059bebfccde61a418c5808dea6bbfa"}, - {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:17e6c11038d4ed6e8af1407d9e89a2904d573be29d51515f14262d7f10ef0a64"}, - {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c254b03032d5a06de049ce8bca8338a5185f07fb76600afff3c161e053d88617"}, - {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dca38a21e4423f3edb821292e97cec7ad38086f84313462098568baedf4331f8"}, - {file = "coverage-6.2-cp37-cp37m-win32.whl", hash = "sha256:600617008aa82032ddeace2535626d1bc212dfff32b43989539deda63b3f36e4"}, - {file = "coverage-6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:bf154ba7ee2fd613eb541c2bc03d3d9ac667080a737449d1a3fb342740eb1a74"}, - {file = "coverage-6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e"}, - {file = "coverage-6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58"}, - {file = "coverage-6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc"}, - {file = "coverage-6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd"}, - {file = "coverage-6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953"}, - {file = "coverage-6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475"}, - {file = "coverage-6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57"}, - {file = "coverage-6.2-cp38-cp38-win32.whl", hash = "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c"}, - {file = "coverage-6.2-cp38-cp38-win_amd64.whl", hash = "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2"}, - {file = "coverage-6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd"}, - {file = "coverage-6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685"}, - {file = "coverage-6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c"}, - {file = "coverage-6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3"}, - {file = "coverage-6.2-cp39-cp39-win32.whl", hash = "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282"}, - {file = "coverage-6.2-cp39-cp39-win_amd64.whl", hash = "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644"}, - {file = "coverage-6.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de"}, - {file = "coverage-6.2.tar.gz", hash = "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8"}, + {file = "coverage-6.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eeffd96882d8c06d31b65dddcf51db7c612547babc1c4c5db6a011abe9798525"}, + {file = "coverage-6.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:621f6ea7260ea2ffdaec64fe5cb521669984f567b66f62f81445221d4754df4c"}, + {file = "coverage-6.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84f2436d6742c01136dd940ee158bfc7cf5ced3da7e4c949662b8703b5cd8145"}, + {file = "coverage-6.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de73fca6fb403dd72d4da517cfc49fcf791f74eee697d3219f6be29adf5af6ce"}, + {file = "coverage-6.3.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78fbb2be068a13a5d99dce9e1e7d168db880870f7bc73f876152130575bd6167"}, + {file = "coverage-6.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f5a4551dfd09c3bd12fca8144d47fe7745275adf3229b7223c2f9e29a975ebda"}, + {file = "coverage-6.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7bff3a98f63b47464480de1b5bdd80c8fade0ba2832c9381253c9b74c4153c27"}, + {file = "coverage-6.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a06c358f4aed05fa1099c39decc8022261bb07dfadc127c08cfbd1391b09689e"}, + {file = "coverage-6.3.1-cp310-cp310-win32.whl", hash = "sha256:9fff3ff052922cb99f9e52f63f985d4f7a54f6b94287463bc66b7cdf3eb41217"}, + {file = "coverage-6.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:276b13cc085474e482566c477c25ed66a097b44c6e77132f3304ac0b039f83eb"}, + {file = "coverage-6.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:56c4a409381ddd7bbff134e9756077860d4e8a583d310a6f38a2315b9ce301d0"}, + {file = "coverage-6.3.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9eb494070aa060ceba6e4bbf44c1bc5fa97bfb883a0d9b0c9049415f9e944793"}, + {file = "coverage-6.3.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e15d424b8153756b7c903bde6d4610be0c3daca3986173c18dd5c1a1625e4cd"}, + {file = "coverage-6.3.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d47a897c1e91f33f177c21de897267b38fbb45f2cd8e22a710bcef1df09ac1"}, + {file = "coverage-6.3.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:25e73d4c81efa8ea3785274a2f7f3bfbbeccb6fcba2a0bdd3be9223371c37554"}, + {file = "coverage-6.3.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:fac0bcc5b7e8169bffa87f0dcc24435446d329cbc2b5486d155c2e0f3b493ae1"}, + {file = "coverage-6.3.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:72128176fea72012063200b7b395ed8a57849282b207321124d7ff14e26988e8"}, + {file = "coverage-6.3.1-cp37-cp37m-win32.whl", hash = "sha256:1bc6d709939ff262fd1432f03f080c5042dc6508b6e0d3d20e61dd045456a1a0"}, + {file = "coverage-6.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:618eeba986cea7f621d8607ee378ecc8c2504b98b3fdc4952b30fe3578304687"}, + {file = "coverage-6.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ed164af5c9078596cfc40b078c3b337911190d3faeac830c3f1274f26b8320"}, + {file = "coverage-6.3.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:352c68e233409c31048a3725c446a9e48bbff36e39db92774d4f2380d630d8f8"}, + {file = "coverage-6.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:448d7bde7ceb6c69e08474c2ddbc5b4cd13c9e4aa4a717467f716b5fc938a734"}, + {file = "coverage-6.3.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9fde6b90889522c220dd56a670102ceef24955d994ff7af2cb786b4ba8fe11e4"}, + {file = "coverage-6.3.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e647a0be741edbb529a72644e999acb09f2ad60465f80757da183528941ff975"}, + {file = "coverage-6.3.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a5cdc3adb4f8bb8d8f5e64c2e9e282bc12980ef055ec6da59db562ee9bdfefa"}, + {file = "coverage-6.3.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:2dd70a167843b4b4b2630c0c56f1b586fe965b4f8ac5da05b6690344fd065c6b"}, + {file = "coverage-6.3.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9ad0a117b8dc2061ce9461ea4c1b4799e55edceb236522c5b8f958ce9ed8fa9a"}, + {file = "coverage-6.3.1-cp38-cp38-win32.whl", hash = "sha256:e92c7a5f7d62edff50f60a045dc9542bf939758c95b2fcd686175dd10ce0ed10"}, + {file = "coverage-6.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:482fb42eea6164894ff82abbcf33d526362de5d1a7ed25af7ecbdddd28fc124f"}, + {file = "coverage-6.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c5b81fb37db76ebea79aa963b76d96ff854e7662921ce742293463635a87a78d"}, + {file = "coverage-6.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a4f923b9ab265136e57cc14794a15b9dcea07a9c578609cd5dbbfff28a0d15e6"}, + {file = "coverage-6.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56d296cbc8254a7dffdd7bcc2eb70be5a233aae7c01856d2d936f5ac4e8ac1f1"}, + {file = "coverage-6.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1245ab82e8554fa88c4b2ab1e098ae051faac5af829efdcf2ce6b34dccd5567c"}, + {file = "coverage-6.3.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f2b05757c92ad96b33dbf8e8ec8d4ccb9af6ae3c9e9bd141c7cc44d20c6bcba"}, + {file = "coverage-6.3.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9e3dd806f34de38d4c01416344e98eab2437ac450b3ae39c62a0ede2f8b5e4ed"}, + {file = "coverage-6.3.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d651fde74a4d3122e5562705824507e2f5b2d3d57557f1916c4b27635f8fbe3f"}, + {file = "coverage-6.3.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:704f89b87c4f4737da2860695a18c852b78ec7279b24eedacab10b29067d3a38"}, + {file = "coverage-6.3.1-cp39-cp39-win32.whl", hash = "sha256:2aed4761809640f02e44e16b8b32c1a5dee5e80ea30a0ff0912158bde9c501f2"}, + {file = "coverage-6.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:9976fb0a5709988778ac9bc44f3d50fccd989987876dfd7716dee28beed0a9fa"}, + {file = "coverage-6.3.1-pp36.pp37.pp38-none-any.whl", hash = "sha256:463e52616ea687fd323888e86bf25e864a3cc6335a043fad6bbb037dbf49bbe2"}, + {file = "coverage-6.3.1.tar.gz", hash = "sha256:6c3f6158b02ac403868eea390930ae64e9a9a2a5bbfafefbb920d29258d9f2f8"}, ] distlib = [ {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, @@ -847,8 +842,8 @@ filelock = [ {file = "filelock-3.4.2.tar.gz", hash = "sha256:38b4f4c989f9d06d44524df1b24bd19e167d851f19b50bf3e3559952dddc5b80"}, ] identify = [ - {file = "identify-2.4.4-py2.py3-none-any.whl", hash = "sha256:aa68609c7454dbcaae60a01ff6b8df1de9b39fe6e50b1f6107ec81dcda624aa6"}, - {file = "identify-2.4.4.tar.gz", hash = "sha256:6b4b5031f69c48bf93a646b90de9b381c6b5f560df4cbe0ed3cf7650ae741e4d"}, + {file = "identify-2.4.9-py2.py3-none-any.whl", hash = "sha256:bff7c4959d68510bc28b99d664b6a623e36c6eadc933f89a4e0a9ddff9b4fee4"}, + {file = "identify-2.4.9.tar.gz", hash = "sha256:e926ae3b3dc142b6a7a9c65433eb14ccac751b724ee255f7c2ed3b5970d764fb"}, ] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, @@ -859,8 +854,8 @@ imagesize = [ {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.10.0-py3-none-any.whl", hash = "sha256:b7cf7d3fef75f1e4c80a96ca660efbd51473d7e8f39b5ab9210febc7809012a4"}, - {file = "importlib_metadata-4.10.0.tar.gz", hash = "sha256:92a8b58ce734b2a4494878e0ecf7d79ccd7a128b5fc6014c401e0b61f006f0f6"}, + {file = "importlib_metadata-4.11.0-py3-none-any.whl", hash = "sha256:6affcdb3aec542dd98df8211e730bba6c5f2bec8288d47bacacde898f548c9ad"}, + {file = "importlib_metadata-4.11.0.tar.gz", hash = "sha256:9e5e553bbba1843cb4a00823014b907616be46ee503d2b9ba001d214a8da218f"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -922,16 +917,16 @@ packaging = [ {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] platformdirs = [ - {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"}, - {file = "platformdirs-2.4.1.tar.gz", hash = "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda"}, + {file = "platformdirs-2.5.0-py3-none-any.whl", hash = "sha256:30671902352e97b1eafd74ade8e4a694782bd3471685e78c32d0fdfd3aa7e7bb"}, + {file = "platformdirs-2.5.0.tar.gz", hash = "sha256:8ec11dfba28ecc0715eb5fb0147a87b1bf325f349f3da9aab2cd6b50b96b692b"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] pre-commit = [ - {file = "pre_commit-2.16.0-py2.py3-none-any.whl", hash = "sha256:758d1dc9b62c2ed8881585c254976d66eae0889919ab9b859064fc2fe3c7743e"}, - {file = "pre_commit-2.16.0.tar.gz", hash = "sha256:fe9897cac830aa7164dbd02a4e7b90cae49630451ce88464bca73db486ba9f65"}, + {file = "pre_commit-2.17.0-py2.py3-none-any.whl", hash = "sha256:725fa7459782d7bec5ead072810e47351de01709be838c2ce1726b9591dad616"}, + {file = "pre_commit-2.17.0.tar.gz", hash = "sha256:c1a8040ff15ad3d648c70cc3e55b93e4d2d5b687320955505587fd79bbaed06a"}, ] py = [ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, @@ -942,24 +937,24 @@ pygments = [ {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, ] pyparsing = [ - {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, - {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, + {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, + {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, ] pytest = [ - {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, - {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, + {file = "pytest-7.0.1-py3-none-any.whl", hash = "sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db"}, + {file = "pytest-7.0.1.tar.gz", hash = "sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171"}, ] pytest-asyncio = [ - {file = "pytest-asyncio-0.17.0.tar.gz", hash = "sha256:c98e0e04ae5910bbcc219f52bdf871bd1d392f624ef77c49c236613c0b6d8ee1"}, - {file = "pytest_asyncio-0.17.0-py3-none-any.whl", hash = "sha256:b41c3ff0ec5b5b144459aa1c53a866f67278177f6d4f3ef6874bd864fc82834d"}, + {file = "pytest-asyncio-0.18.1.tar.gz", hash = "sha256:c43fcdfea2335dd82ffe0f2774e40285ddfea78a8e81e56118d47b6a90fbb09e"}, + {file = "pytest_asyncio-0.18.1-py3-none-any.whl", hash = "sha256:c9ec48e8bbf5cc62755e18c4d8bc6907843ec9c5f4ac8f61464093baeba24a7e"}, ] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, ] pytest-mock = [ - {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"}, - {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"}, + {file = "pytest-mock-3.7.0.tar.gz", hash = "sha256:5112bd92cc9f186ee96e1a92efc84969ea494939c3aead39c50f421c4cc69534"}, + {file = "pytest_mock-3.7.0-py3-none-any.whl", hash = "sha256:6cff27cec936bf81dc5ee87f07132b807bcda51106b5ec4b90a04331cba76231"}, ] pytest-sugar = [ {file = "pytest-sugar-0.9.4.tar.gz", hash = "sha256:b1b2186b0a72aada6859bea2a5764145e3aaa2c1cfbb23c3a19b5f7b697563d3"}, @@ -1063,24 +1058,24 @@ toml = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] tomli = [ - {file = "tomli-2.0.0-py3-none-any.whl", hash = "sha256:b5bde28da1fed24b9bd1d4d2b8cba62300bfb4ec9a6187a957e8ddb9434c5224"}, - {file = "tomli-2.0.0.tar.gz", hash = "sha256:c292c34f58502a1eb2bbb9f5bbc9a5ebc37bee10ffb8c2d6bbdfa8eb13cc14e1"}, + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] tox = [ {file = "tox-3.24.5-py2.py3-none-any.whl", hash = "sha256:be3362472a33094bce26727f5f771ca0facf6dafa217f65875314e9a6600c95c"}, {file = "tox-3.24.5.tar.gz", hash = "sha256:67e0e32c90e278251fea45b696d0fef3879089ccbe979b0c556d35d5a70e2993"}, ] typing-extensions = [ - {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, - {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, + {file = "typing_extensions-4.1.0-py3-none-any.whl", hash = "sha256:c13180fbaa7cd97065a4915ceba012bdb31dc34743e63ddee16360161d358414"}, + {file = "typing_extensions-4.1.0.tar.gz", hash = "sha256:ba97c5143e5bb067b57793c726dd857b1671d4b02ced273ca0538e71ff009095"}, ] urllib3 = [ {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"}, ] virtualenv = [ - {file = "virtualenv-20.13.0-py2.py3-none-any.whl", hash = "sha256:339f16c4a86b44240ba7223d0f93a7887c3ca04b5f9c8129da7958447d079b09"}, - {file = "virtualenv-20.13.0.tar.gz", hash = "sha256:d8458cf8d59d0ea495ad9b34c2599487f8a7772d796f9910858376d1600dd2dd"}, + {file = "virtualenv-20.13.1-py2.py3-none-any.whl", hash = "sha256:45e1d053cad4cd453181ae877c4ffc053546ae99e7dd049b9ff1d9be7491abf7"}, + {file = "virtualenv-20.13.1.tar.gz", hash = "sha256:e0621bcbf4160e4e1030f05065c8834b4e93f4fcc223255db2a823440aca9c14"}, ] voluptuous = [ {file = "voluptuous-0.12.2.tar.gz", hash = "sha256:4db1ac5079db9249820d49c891cb4660a6f8cae350491210abce741fabf56513"}, diff --git a/pyproject.toml b/pyproject.toml index 618375f26..2a89a7ca1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ kasa = "kasa.cli:cli" python = "^3.7" anyio = "*" # see https://github.com/python-trio/asyncclick/issues/18 importlib-metadata = "*" -asyncclick = ">=7" +asyncclick = ">=8" # required only for docs sphinx = { version = "^3", optional = true } From db170cf1f51b2d198be689982b45f5f758b6994e Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 15 Feb 2022 16:59:36 +0100 Subject: [PATCH 075/892] Allow using environment variables for discovery target, device type and debug (#313) * KASA_TYPE defines the device type (bulb, plug, dimmer, strip, lightstrip) * KASA_TARGET to define discovery target * KASA_DEBUG to enable debugging --- kasa/cli.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index fc7286e1d..e9724ec53 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -45,17 +45,22 @@ ) @click.option( "--target", + envvar="KASA_TARGET", default="255.255.255.255", required=False, + show_default=True, help="The broadcast address to be used for discovery.", ) -@click.option("-d", "--debug", default=False, is_flag=True) +@click.option("-d", "--debug", envvar="KASA_DEBUG", default=False, is_flag=True) @click.option("--bulb", default=False, is_flag=True) @click.option("--plug", default=False, is_flag=True) @click.option("--lightstrip", default=False, is_flag=True) @click.option("--strip", default=False, is_flag=True) @click.option( - "--type", default=None, type=click.Choice(TYPE_TO_CLASS, case_sensitive=False) + "--type", + envvar="KASA_TYPE", + default=None, + type=click.Choice(TYPE_TO_CLASS, case_sensitive=False), ) @click.version_option(package_name="python-kasa") @click.pass_context From e3d76bea7557616a7a9e8f967368ce1d9009db5a Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 21 Feb 2022 00:56:18 +0100 Subject: [PATCH 076/892] Add pyupgrade to CI runs (#314) --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2f5e6324..a184a51e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,9 @@ jobs: run: | python -m pip install --upgrade pip poetry poetry install + - name: "Run pyupgrade" + run: | + poetry run pre-commit run pyupgrade --all-files - name: "Code formating (black)" run: | poetry run pre-commit run black --all-files From b22f6b4eefa36cb44758c0393817a233554b70e7 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 2 Mar 2022 16:29:20 +0100 Subject: [PATCH 077/892] Don't crash on devices not reporting features (#317) Returns an empty set if no feature information is available --- kasa/smartdevice.py | 6 +++++- kasa/tests/test_smartdevice.py | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 25b916318..7a66a864d 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -262,7 +262,11 @@ async def _query_helper( @requires_update def features(self) -> Set[str]: """Return a set of features that the device supports.""" - return set(self.sys_info["feature"].split(":")) + try: + return set(self.sys_info["feature"].split(":")) + except KeyError: + _LOGGER.debug("Device does not have feature information") + return set() @property # type: ignore @requires_update diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 2dfd96340..d977daeb3 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -139,3 +139,12 @@ async def test_childrens(dev): async def test_internal_state(dev): """Make sure the internal state returns the last update results.""" assert dev.internal_state == dev._last_update + + +async def test_features(dev): + """Make sure features is always accessible.""" + sysinfo = dev._last_update["system"]["get_sysinfo"] + if "feature" in sysinfo: + assert dev.features == set(sysinfo["feature"].split(":")) + else: + assert dev.features == set() From 58f6517445541df0db299a2b9c19847814d33949 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Mar 2022 11:10:12 -1000 Subject: [PATCH 078/892] Add effect support for light strips (#293) * Add effect support for KL430 * KL400 supports effects * Add KL400 fixture * Comments from review * actually commit the remove --- kasa/cli.py | 21 ++ kasa/effects.py | 296 ++++++++++++++++++ kasa/smartbulb.py | 6 + kasa/smartlightstrip.py | 47 ++- .../tests/fixtures/KL400L5(US)_1.0_1.0.8.json | 57 ++++ kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json | 57 ++++ kasa/tests/newfakes.py | 7 + kasa/tests/test_lightstrip.py | 19 ++ 8 files changed, 508 insertions(+), 2 deletions(-) create mode 100644 kasa/effects.py create mode 100644 kasa/tests/fixtures/KL400L5(US)_1.0_1.0.8.json create mode 100644 kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json diff --git a/kasa/cli.py b/kasa/cli.py index e9724ec53..696dd9aab 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -371,6 +371,27 @@ async def temperature(dev: SmartBulb, temperature: int, transition: int): return await dev.set_color_temp(temperature, transition=transition) +@cli.command() +@click.argument("effect", type=click.STRING, default=None, required=False) +@click.pass_context +@pass_dev +async def effect(dev, ctx, effect): + """Set an effect.""" + if not dev.has_effects: + click.echo("Device does not support effects") + return + if effect is None: + raise click.BadArgumentUsage( + f"Setting an effect requires a named built-in effect: {dev.effect_list}", + ctx, + ) + if effect not in dev.effect_list: + raise click.BadArgumentUsage(f"Effect must be one of: {dev.effect_list}", ctx) + + click.echo(f"Setting Effect: {effect}") + return await dev.set_effect(effect) + + @cli.command() @click.argument("h", type=click.IntRange(0, 360), default=None, required=False) @click.argument("s", type=click.IntRange(0, 100), default=None, required=False) diff --git a/kasa/effects.py b/kasa/effects.py new file mode 100644 index 000000000..cf72bb8d8 --- /dev/null +++ b/kasa/effects.py @@ -0,0 +1,296 @@ +"""Module for light strip effects (LB*, KL*, KB*).""" + +from typing import List, cast + +EFFECT_AURORA = { + "custom": 0, + "id": "xqUxDhbAhNLqulcuRMyPBmVGyTOyEMEu", + "brightness": 100, + "name": "Aurora", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "sequence", + "duration": 0, + "transition": 1500, + "direction": 4, + "spread": 7, + "repeat_times": 0, + "sequence": [[120, 100, 100], [240, 100, 100], [260, 100, 100], [280, 100, 100]], +} +EFFECT_BUBBLING_CAULDRON = { + "custom": 0, + "id": "tIwTRQBqJpeNKbrtBMFCgkdPTbAQGfRP", + "brightness": 100, + "name": "Bubbling Cauldron", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [100, 270], + "saturation_range": [80, 100], + "brightness_range": [50, 100], + "duration": 0, + "transition": 200, + "init_states": [[270, 100, 100]], + "fadeoff": 1000, + "random_seed": 24, + "backgrounds": [[270, 40, 50]], +} +EFFECT_CANDY_CANE = { + "custom": 0, + "id": "HCOttllMkNffeHjEOLEgrFJjbzQHoxEJ", + "brightness": 100, + "name": "Candy Cane", + "segments": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + "expansion_strategy": 1, + "enable": 1, + "type": "sequence", + "duration": 700, + "transition": 500, + "direction": 1, + "spread": 1, + "repeat_times": 0, + "sequence": [ + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [360, 81, 100], + [360, 81, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + ], +} +EFFECT_CHRISTMAS = { + "custom": 0, + "id": "bwTatyinOUajKrDwzMmqxxJdnInQUgvM", + "brightness": 100, + "name": "Christmas", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [136, 146], + "saturation_range": [90, 100], + "brightness_range": [50, 100], + "duration": 5000, + "transition": 0, + "init_states": [[136, 0, 100]], + "fadeoff": 2000, + "random_seed": 100, + "backgrounds": [[136, 98, 75], [136, 0, 0], [350, 0, 100], [350, 97, 94]], +} +EFFECT_FLICKER = { + "custom": 0, + "id": "bCTItKETDFfrKANolgldxfgOakaarARs", + "brightness": 100, + "name": "Flicker", + "segments": [1], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [30, 40], + "saturation_range": [100, 100], + "brightness_range": [50, 100], + "duration": 0, + "transition": 0, + "transition_range": [375, 500], + "init_states": [[30, 81, 80]], +} +EFFECT_HANUKKAH = { + "custom": 0, + "id": "CdLeIgiKcQrLKMINRPTMbylATulQewLD", + "brightness": 100, + "name": "Hanukkah", + "segments": [1], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [200, 210], + "saturation_range": [0, 100], + "brightness_range": [50, 100], + "duration": 1500, + "transition": 0, + "transition_range": [400, 500], + "init_states": [[35, 81, 80]], +} +EFFECT_HAUNTED_MANSION = { + "custom": 0, + "id": "oJnFHsVQzFUTeIOBAhMRfVeujmSauhjJ", + "brightness": 80, + "name": "Haunted Mansion", + "segments": [80], + "expansion_strategy": 2, + "enable": 1, + "type": "random", + "hue_range": [45, 45], + "saturation_range": [10, 10], + "brightness_range": [0, 80], + "duration": 0, + "transition": 0, + "transition_range": [50, 1500], + "init_states": [[45, 10, 100]], + "fadeoff": 200, + "random_seed": 1, + "backgrounds": [[45, 10, 100]], +} +EFFECT_ICICLE = { + "custom": 0, + "id": "joqVjlaTsgzmuQQBAlHRkkPAqkBUiqeb", + "brightness": 70, + "name": "Icicle", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "sequence", + "duration": 0, + "transition": 400, + "direction": 4, + "spread": 3, + "repeat_times": 0, + "sequence": [ + [190, 100, 70], + [190, 100, 70], + [190, 30, 50], + [190, 100, 70], + [190, 100, 70], + ], +} +EFFECT_LIGHTNING = { + "custom": 0, + "id": "ojqpUUxdGHoIugGPknrUcRoyJiItsjuE", + "brightness": 100, + "name": "Lightning", + "segments": [7, 20, 23, 32, 34, 35, 49, 65, 66, 74, 80], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [240, 240], + "saturation_range": [10, 11], + "brightness_range": [90, 100], + "duration": 0, + "transition": 50, + "init_states": [[240, 30, 100]], + "fadeoff": 150, + "random_seed": 600, + "backgrounds": [[200, 100, 100], [200, 50, 10], [210, 10, 50], [240, 10, 0]], +} +EFFECT_OCEAN = { + "custom": 0, + "id": "oJjUMosgEMrdumfPANKbkFmBcAdEQsPy", + "brightness": 30, + "name": "Ocean", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "sequence", + "duration": 0, + "transition": 2000, + "direction": 3, + "spread": 16, + "repeat_times": 0, + "sequence": [[198, 84, 30], [198, 70, 30], [198, 10, 30]], +} +EFFECT_RAINBOW = { + "custom": 0, + "id": "izRhLCQNcDzIKdpMPqSTtBMuAIoreAuT", + "brightness": 100, + "name": "Rainbow", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "sequence", + "duration": 0, + "transition": 1500, + "direction": 1, + "spread": 12, + "repeat_times": 0, + "sequence": [[0, 100, 100], [100, 100, 100], [200, 100, 100], [300, 100, 100]], +} +EFFECT_RAINDROP = { + "custom": 0, + "id": "QbDFwiSFmLzQenUOPnJrsGqyIVrJrRsl", + "brightness": 30, + "name": "Raindrop", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [200, 200], + "saturation_range": [10, 20], + "brightness_range": [10, 30], + "duration": 0, + "transition": 1000, + "init_states": [[200, 40, 100]], + "fadeoff": 1000, + "random_seed": 24, + "backgrounds": [[200, 40, 0]], +} +EFFECT_SPRING = { + "custom": 0, + "id": "URdUpEdQbnOOechDBPMkKrwhSupLyvAg", + "brightness": 100, + "name": "Spring", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [0, 90], + "saturation_range": [30, 100], + "brightness_range": [90, 100], + "duration": 600, + "transition": 0, + "transition_range": [2000, 6000], + "init_states": [[80, 30, 100]], + "fadeoff": 1000, + "random_seed": 20, + "backgrounds": [[130, 100, 40]], +} +EFFECT_VALENTINES = { + "custom": 0, + "id": "QglBhMShPHUAuxLqzNEefFrGiJwahOmz", + "brightness": 100, + "name": "Valentines", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [340, 340], + "saturation_range": [30, 40], + "brightness_range": [90, 100], + "duration": 600, + "transition": 2000, + "init_states": [[340, 30, 100]], + "fadeoff": 3000, + "random_seed": 100, + "backgrounds": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], +} + +EFFECTS_LIST_V1 = [ + EFFECT_AURORA, + EFFECT_BUBBLING_CAULDRON, + EFFECT_CANDY_CANE, + EFFECT_CHRISTMAS, + EFFECT_FLICKER, + EFFECT_HANUKKAH, + EFFECT_HAUNTED_MANSION, + EFFECT_ICICLE, + EFFECT_LIGHTNING, + EFFECT_OCEAN, + EFFECT_RAINBOW, + EFFECT_RAINDROP, + EFFECT_SPRING, + EFFECT_VALENTINES, +] + +EFFECT_NAMES_V1: List[str] = [cast(str, effect["name"]) for effect in EFFECTS_LIST_V1] +EFFECT_MAPPING_V1 = {effect["name"]: effect for effect in EFFECTS_LIST_V1} diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index 8ccce15a8..e5dcfbe90 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -172,6 +172,12 @@ def light_state(self) -> Dict[str, str]: return light_state + @property # type: ignore + @requires_update + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + return "lighting_effect_state" in self.sys_info + async def get_light_details(self) -> Dict[str, int]: """Return light details. diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py index c579fec20..087234538 100644 --- a/kasa/smartlightstrip.py +++ b/kasa/smartlightstrip.py @@ -1,8 +1,9 @@ """Module for light strips (KL430).""" -from typing import Any, Dict +from typing import Any, Dict, List, Optional +from .effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from .smartbulb import SmartBulb -from .smartdevice import DeviceType, requires_update +from .smartdevice import DeviceType, SmartDeviceException, requires_update class SmartLightStrip(SmartBulb): @@ -64,6 +65,16 @@ def effect(self) -> Dict: """ return self.sys_info["lighting_effect_state"] + @property # type: ignore + @requires_update + def effect_list(self) -> Optional[List[str]]: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + return EFFECT_NAMES_V1 if self.has_effects else None + @property # type: ignore @requires_update def state_information(self) -> Dict[str, Any]: @@ -71,5 +82,37 @@ def state_information(self) -> Dict[str, Any]: info = super().state_information info["Length"] = self.length + if self.has_effects: + info["Effect"] = self.effect["name"] return info + + @requires_update + async def set_effect( + self, + effect: str, + ) -> None: + """Set an effect on the device. + + :param str effect: The effect to set + """ + if effect not in EFFECT_MAPPING_V1: + raise SmartDeviceException(f"The effect {effect} is not a built in effect.") + await self.set_custom_effect(EFFECT_MAPPING_V1[effect]) + + @requires_update + async def set_custom_effect( + self, + effect_dict: Dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ + if not self.has_effects: + raise SmartDeviceException("Bulb does not support effects.") + await self._query_helper( + "smartlife.iot.lighting_effect", + "set_lighting_effect", + effect_dict, + ) diff --git a/kasa/tests/fixtures/KL400L5(US)_1.0_1.0.8.json b/kasa/tests/fixtures/KL400L5(US)_1.0_1.0.8.json new file mode 100644 index 000000000..a737cd2a1 --- /dev/null +++ b/kasa/tests/fixtures/KL400L5(US)_1.0_1.0.8.json @@ -0,0 +1,57 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 10800, + "total_wh": 1 + } + }, + "system": { + "get_sysinfo": { + "LEF": 0, + "active_mode": "none", + "alias": "Kl400", + "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": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "latitude_i": 0, + "length": 1, + "light_state": { + "brightness": 100, + "color_temp": 6500, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "lighting_effect_state": { + "brightness": 100, + "custom": 0, + "enable": 1, + "id": "CdLeIgiKcQrLKMINRPTMbylATulQewLD", + "name": "Hanukkah" + }, + "longitude_i": 0, + "mic_mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL400L5(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -44, + "status": "new", + "sw_ver": "1.0.8 Build 211018 Rel.162056" + } + } +} diff --git a/kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json b/kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json new file mode 100644 index 000000000..d5f2eafbc --- /dev/null +++ b/kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json @@ -0,0 +1,57 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 11150, + "total_wh": 18 + } + }, + "system": { + "get_sysinfo": { + "LEF": 1, + "active_mode": "none", + "alias": "kl430 updated", + "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": { + "brightness": 100, + "color_temp": 0, + "hue": 194, + "mode": "normal", + "on_off": 1, + "saturation": 50 + }, + "lighting_effect_state": { + "brightness": 100, + "custom": 0, + "enable": 1, + "id": "izRhLCQNcDzIKdpMPqSTtBMuAIoreAuT", + "name": "Rainbow" + }, + "longitude_i": 0, + "mic_mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL430(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -58, + "status": "new", + "sw_ver": "1.0.9 Build 210915 Rel.170534" + } + } +} diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 2e859e362..904d45c79 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -359,6 +359,10 @@ def set_hs220_dimmer_transition(self, x, *args): self.proto["system"]["get_sysinfo"]["relay_state"] = 1 self.proto["system"]["get_sysinfo"]["brightness"] = x["brightness"] + def set_lighting_effect(self, effect, *args): + _LOGGER.debug("Setting light effect to %s", effect) + self.proto["system"]["get_sysinfo"]["lighting_effect_state"] = dict(effect) + def transition_light_state(self, state_changes, *args): _LOGGER.debug("Setting light state to %s", state_changes) light_state = self.proto["system"]["get_sysinfo"]["light_state"] @@ -422,6 +426,9 @@ def light_state(self, x, *args): "get_light_state": light_state, "transition_light_state": transition_light_state, }, + "smartlife.iot.lighting_effect": { + "set_lighting_effect": set_lighting_effect, + }, # lightstrip follows the same payloads but uses different module & method "smartlife.iot.lightStrip": { "set_light_state": transition_light_state, diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index 7a8d8726a..e53bb1f76 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -1,4 +1,7 @@ +import pytest + from kasa import DeviceType, SmartLightStrip +from kasa.exceptions import SmartDeviceException from .conftest import lightstrip, pytestmark @@ -15,3 +18,19 @@ async def test_lightstrip_effect(dev: SmartLightStrip): assert isinstance(dev.effect, dict) for k in ["brightness", "custom", "enable", "id", "name"]: assert k in dev.effect + + +@lightstrip +async def test_effects_lightstrip_set_effect(dev: SmartLightStrip): + with pytest.raises(SmartDeviceException): + await dev.set_effect("Not real") + + await dev.set_effect("Candy Cane") + assert dev.effect["name"] == "Candy Cane" + assert dev.state_information["Effect"] == "Candy Cane" + + +@lightstrip +async def test_effects_lightstrip_has_effects(dev: SmartLightStrip): + assert dev.has_effects is True + assert dev.effect_list From 6f5a60ad436ff924469f2228891f2aba34e6b5a5 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 21 Mar 2022 22:34:44 +0100 Subject: [PATCH 079/892] Release 0.4.2 (#321) This is the last release prior restructuring the code to enable easier extendability by moving towards more modular architecture. The most prominent change in this release is the support for effects on light strips. [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.1...0.4.2) **Implemented enhancements:** - Allow environment variables for discovery target, device type and debug [\#313](https://github.com/python-kasa/python-kasa/pull/313) (@rytilahti) - Add 'internal\_state' to return the results from the last update query [\#306](https://github.com/python-kasa/python-kasa/pull/306) (@rytilahti) - Drop microsecond precision for on\_since [\#296](https://github.com/python-kasa/python-kasa/pull/296) (@rytilahti) - Add effect support for light strips [\#293](https://github.com/python-kasa/python-kasa/pull/293) (@bdraco) **Fixed bugs:** - TypeError: \_\_init\_\_\(\) got an unexpected keyword argument 'package\_name' [\#311](https://github.com/python-kasa/python-kasa/issues/311) - RuntimeError: Event loop is closed on WSL [\#294](https://github.com/python-kasa/python-kasa/issues/294) - Don't crash on devices not reporting features [\#317](https://github.com/python-kasa/python-kasa/pull/317) (@rytilahti) **Closed issues:** - SmartDeviceException: Communication error on system:set\_relay\_state [\#309](https://github.com/python-kasa/python-kasa/issues/309) - Add Support: ES20M and KS200M motion/light switches [\#308](https://github.com/python-kasa/python-kasa/issues/308) - New problem with installing on Ubuntu 20.04.3 LTS [\#305](https://github.com/python-kasa/python-kasa/issues/305) - KeyError: 'emeter' when discovering [\#302](https://github.com/python-kasa/python-kasa/issues/302) - RuntimeError: Event loop is closed [\#291](https://github.com/python-kasa/python-kasa/issues/291) - provisioning format [\#290](https://github.com/python-kasa/python-kasa/issues/290) - Fix CI publishing on pypi [\#222](https://github.com/python-kasa/python-kasa/issues/222) - LED strips effects are not supported \(was LEDs is not turning on after switching on\) [\#191](https://github.com/python-kasa/python-kasa/issues/191) **Merged pull requests:** - Add pyupgrade to CI runs [\#314](https://github.com/python-kasa/python-kasa/pull/314) (@rytilahti) - Depend on asyncclick \>= 8 [\#312](https://github.com/python-kasa/python-kasa/pull/312) (@rytilahti) - Guard emeter accesses to avoid keyerrors [\#304](https://github.com/python-kasa/python-kasa/pull/304) (@rytilahti) - cli: cleanup discover, fetch update prior device access [\#303](https://github.com/python-kasa/python-kasa/pull/303) (@rytilahti) - Fix unsafe \_\_del\_\_ in TPLinkSmartHomeProtocol [\#300](https://github.com/python-kasa/python-kasa/pull/300) (@bdraco) - Improve typing for protocol class [\#289](https://github.com/python-kasa/python-kasa/pull/289) (@rytilahti) - Added a fixture file for KS220M [\#273](https://github.com/python-kasa/python-kasa/pull/273) (@mrbetta) --- .github_changelog_generator | 1 + CHANGELOG.md | 284 +++++++++++++++++++++--------------- poetry.lock | 230 +++++++++++++++-------------- pyproject.toml | 2 +- 4 files changed, 285 insertions(+), 232 deletions(-) create mode 100644 .github_changelog_generator diff --git a/.github_changelog_generator b/.github_changelog_generator new file mode 100644 index 000000000..db89ad20c --- /dev/null +++ b/.github_changelog_generator @@ -0,0 +1 @@ +usernames-as-github-logins=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 84aa4e942..c94510725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,32 +1,73 @@ # Changelog +## [0.4.2](https://github.com/python-kasa/python-kasa/tree/0.4.2) (2022-03-21) + +This is the last release prior restructuring the code to enable easier extendability by moving towards more modular architecture. +The most prominent change in this release is the support for effects on light strips. + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.1...0.4.2) + +**Implemented enhancements:** + +- Allow environment variables for discovery target, device type and debug [\#313](https://github.com/python-kasa/python-kasa/pull/313) (@rytilahti) +- Add 'internal\_state' to return the results from the last update query [\#306](https://github.com/python-kasa/python-kasa/pull/306) (@rytilahti) +- Drop microsecond precision for on\_since [\#296](https://github.com/python-kasa/python-kasa/pull/296) (@rytilahti) +- Add effect support for light strips [\#293](https://github.com/python-kasa/python-kasa/pull/293) (@bdraco) + +**Fixed bugs:** + +- TypeError: \_\_init\_\_\(\) got an unexpected keyword argument 'package\_name' [\#311](https://github.com/python-kasa/python-kasa/issues/311) +- RuntimeError: Event loop is closed on WSL [\#294](https://github.com/python-kasa/python-kasa/issues/294) +- Don't crash on devices not reporting features [\#317](https://github.com/python-kasa/python-kasa/pull/317) (@rytilahti) + +**Closed issues:** + +- SmartDeviceException: Communication error on system:set\_relay\_state [\#309](https://github.com/python-kasa/python-kasa/issues/309) +- Add Support: ES20M and KS200M motion/light switches [\#308](https://github.com/python-kasa/python-kasa/issues/308) +- New problem with installing on Ubuntu 20.04.3 LTS [\#305](https://github.com/python-kasa/python-kasa/issues/305) +- KeyError: 'emeter' when discovering [\#302](https://github.com/python-kasa/python-kasa/issues/302) +- RuntimeError: Event loop is closed [\#291](https://github.com/python-kasa/python-kasa/issues/291) +- provisioning format [\#290](https://github.com/python-kasa/python-kasa/issues/290) +- Fix CI publishing on pypi [\#222](https://github.com/python-kasa/python-kasa/issues/222) +- LED strips effects are not supported \(was LEDs is not turning on after switching on\) [\#191](https://github.com/python-kasa/python-kasa/issues/191) + +**Merged pull requests:** + +- Add pyupgrade to CI runs [\#314](https://github.com/python-kasa/python-kasa/pull/314) (@rytilahti) +- Depend on asyncclick \>= 8 [\#312](https://github.com/python-kasa/python-kasa/pull/312) (@rytilahti) +- Guard emeter accesses to avoid keyerrors [\#304](https://github.com/python-kasa/python-kasa/pull/304) (@rytilahti) +- cli: cleanup discover, fetch update prior device access [\#303](https://github.com/python-kasa/python-kasa/pull/303) (@rytilahti) +- Fix unsafe \_\_del\_\_ in TPLinkSmartHomeProtocol [\#300](https://github.com/python-kasa/python-kasa/pull/300) (@bdraco) +- Improve typing for protocol class [\#289](https://github.com/python-kasa/python-kasa/pull/289) (@rytilahti) +- Added a fixture file for KS220M [\#273](https://github.com/python-kasa/python-kasa/pull/273) (@mrbetta) + ## [0.4.1](https://github.com/python-kasa/python-kasa/tree/0.4.1) (2022-01-14) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0...0.4.1) **Implemented enhancements:** -- Add --type option to cli [\#269](https://github.com/python-kasa/python-kasa/pull/269) ([rytilahti](https://github.com/rytilahti)) -- Minor improvements to onboarding doc [\#264](https://github.com/python-kasa/python-kasa/pull/264) ([rytilahti](https://github.com/rytilahti)) -- Add fixture file for KL135 [\#263](https://github.com/python-kasa/python-kasa/pull/263) ([ErikSGross](https://github.com/ErikSGross)) -- Add KL135 color temperature range [\#256](https://github.com/python-kasa/python-kasa/pull/256) ([rytilahti](https://github.com/rytilahti)) -- Add py.typed to flag that the package is typed [\#251](https://github.com/python-kasa/python-kasa/pull/251) ([rytilahti](https://github.com/rytilahti)) -- Add script to check supported devices, update README [\#242](https://github.com/python-kasa/python-kasa/pull/242) ([rytilahti](https://github.com/rytilahti)) -- Add perftest to devtools [\#236](https://github.com/python-kasa/python-kasa/pull/236) ([rytilahti](https://github.com/rytilahti)) -- Add KP401 US fixture [\#234](https://github.com/python-kasa/python-kasa/pull/234) ([bdraco](https://github.com/bdraco)) -- Add KL60 US KP105 UK fixture [\#233](https://github.com/python-kasa/python-kasa/pull/233) ([bdraco](https://github.com/bdraco)) -- Make cli interface more consistent [\#232](https://github.com/python-kasa/python-kasa/pull/232) ([rytilahti](https://github.com/rytilahti)) -- Add KL400, KL50 fixtures [\#231](https://github.com/python-kasa/python-kasa/pull/231) ([bdraco](https://github.com/bdraco)) -- Add fixture for newer KP400 firmware [\#227](https://github.com/python-kasa/python-kasa/pull/227) ([bdraco](https://github.com/bdraco)) -- Switch to poetry-core [\#226](https://github.com/python-kasa/python-kasa/pull/226) ([fabaff](https://github.com/fabaff)) -- Add fixtures for LB110, KL110, EP40, KL430, KP115 [\#224](https://github.com/python-kasa/python-kasa/pull/224) ([bdraco](https://github.com/bdraco)) +- Add --type option to cli [\#269](https://github.com/python-kasa/python-kasa/pull/269) (@rytilahti) +- Minor improvements to onboarding doc [\#264](https://github.com/python-kasa/python-kasa/pull/264) (@rytilahti) +- Add fixture file for KL135 [\#263](https://github.com/python-kasa/python-kasa/pull/263) (@ErikSGross) +- Add KL135 color temperature range [\#256](https://github.com/python-kasa/python-kasa/pull/256) (@rytilahti) +- Add py.typed to flag that the package is typed [\#251](https://github.com/python-kasa/python-kasa/pull/251) (@rytilahti) +- Add script to check supported devices, update README [\#242](https://github.com/python-kasa/python-kasa/pull/242) (@rytilahti) +- Add perftest to devtools [\#236](https://github.com/python-kasa/python-kasa/pull/236) (@rytilahti) +- Add KP401 US fixture [\#234](https://github.com/python-kasa/python-kasa/pull/234) (@bdraco) +- Add KL60 US KP105 UK fixture [\#233](https://github.com/python-kasa/python-kasa/pull/233) (@bdraco) +- Make cli interface more consistent [\#232](https://github.com/python-kasa/python-kasa/pull/232) (@rytilahti) +- Add KL400, KL50 fixtures [\#231](https://github.com/python-kasa/python-kasa/pull/231) (@bdraco) +- Add fixture for newer KP400 firmware [\#227](https://github.com/python-kasa/python-kasa/pull/227) (@bdraco) +- Switch to poetry-core [\#226](https://github.com/python-kasa/python-kasa/pull/226) (@fabaff) +- Add fixtures for LB110, KL110, EP40, KL430, KP115 [\#224](https://github.com/python-kasa/python-kasa/pull/224) (@bdraco) **Fixed bugs:** - Discovery on WSL results in OSError: \[Errno 22\] Invalid argument [\#246](https://github.com/python-kasa/python-kasa/issues/246) - New firmware for HS103 blocking local access? [\#42](https://github.com/python-kasa/python-kasa/issues/42) -- Pin mistune to \<2.0.0 to fix doc builds [\#270](https://github.com/python-kasa/python-kasa/pull/270) ([rytilahti](https://github.com/rytilahti)) -- Catch exceptions raised on unknown devices during discovery [\#240](https://github.com/python-kasa/python-kasa/pull/240) ([rytilahti](https://github.com/rytilahti)) +- Pin mistune to \<2.0.0 to fix doc builds [\#270](https://github.com/python-kasa/python-kasa/pull/270) (@rytilahti) +- Catch exceptions raised on unknown devices during discovery [\#240](https://github.com/python-kasa/python-kasa/pull/240) (@rytilahti) **Closed issues:** @@ -47,13 +88,14 @@ **Merged pull requests:** -- Publish to pypi on github release published [\#287](https://github.com/python-kasa/python-kasa/pull/287) ([rytilahti](https://github.com/rytilahti)) -- Relax asyncclick version requirement [\#286](https://github.com/python-kasa/python-kasa/pull/286) ([rytilahti](https://github.com/rytilahti)) -- Do not crash on discovery on WSL [\#283](https://github.com/python-kasa/python-kasa/pull/283) ([rytilahti](https://github.com/rytilahti)) -- Add python 3.10 to CI [\#279](https://github.com/python-kasa/python-kasa/pull/279) ([rytilahti](https://github.com/rytilahti)) -- Use codecov-action@v2 for CI [\#277](https://github.com/python-kasa/python-kasa/pull/277) ([rytilahti](https://github.com/rytilahti)) -- Add coverage\[toml\] dependency to fix coverage on CI [\#271](https://github.com/python-kasa/python-kasa/pull/271) ([rytilahti](https://github.com/rytilahti)) -- Allow publish on test pypi workflow to fail [\#248](https://github.com/python-kasa/python-kasa/pull/248) ([rytilahti](https://github.com/rytilahti)) +- Prepare 0.4.1 [\#288](https://github.com/python-kasa/python-kasa/pull/288) (@rytilahti) +- Publish to pypi on github release published [\#287](https://github.com/python-kasa/python-kasa/pull/287) (@rytilahti) +- Relax asyncclick version requirement [\#286](https://github.com/python-kasa/python-kasa/pull/286) (@rytilahti) +- Do not crash on discovery on WSL [\#283](https://github.com/python-kasa/python-kasa/pull/283) (@rytilahti) +- Add python 3.10 to CI [\#279](https://github.com/python-kasa/python-kasa/pull/279) (@rytilahti) +- Use codecov-action@v2 for CI [\#277](https://github.com/python-kasa/python-kasa/pull/277) (@rytilahti) +- Add coverage\[toml\] dependency to fix coverage on CI [\#271](https://github.com/python-kasa/python-kasa/pull/271) (@rytilahti) +- Allow publish on test pypi workflow to fail [\#248](https://github.com/python-kasa/python-kasa/pull/248) (@rytilahti) ## [0.4.0](https://github.com/python-kasa/python-kasa/tree/0.4.0) (2021-09-27) @@ -61,8 +103,8 @@ **Implemented enhancements:** -- Fix lock being unexpectedly reset on close [\#218](https://github.com/python-kasa/python-kasa/pull/218) ([bdraco](https://github.com/bdraco)) -- Avoid calling pformat unless debug logging is enabled [\#217](https://github.com/python-kasa/python-kasa/pull/217) ([bdraco](https://github.com/bdraco)) +- Fix lock being unexpectedly reset on close [\#218](https://github.com/python-kasa/python-kasa/pull/218) (@bdraco) +- Avoid calling pformat unless debug logging is enabled [\#217](https://github.com/python-kasa/python-kasa/pull/217) (@bdraco) **Closed issues:** @@ -70,9 +112,9 @@ **Merged pull requests:** -- Release 0.4.0 [\#221](https://github.com/python-kasa/python-kasa/pull/221) ([rytilahti](https://github.com/rytilahti)) -- Add github workflow for pypi publishing [\#220](https://github.com/python-kasa/python-kasa/pull/220) ([rytilahti](https://github.com/rytilahti)) -- Add host information to protocol debug logs [\#219](https://github.com/python-kasa/python-kasa/pull/219) ([rytilahti](https://github.com/rytilahti)) +- Release 0.4.0 [\#221](https://github.com/python-kasa/python-kasa/pull/221) (@rytilahti) +- Add github workflow for pypi publishing [\#220](https://github.com/python-kasa/python-kasa/pull/220) (@rytilahti) +- Add host information to protocol debug logs [\#219](https://github.com/python-kasa/python-kasa/pull/219) (@rytilahti) ## [0.4.0.dev5](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev5) (2021-09-24) @@ -80,13 +122,13 @@ **Implemented enhancements:** -- Keep connection open and lock to prevent duplicate requests [\#213](https://github.com/python-kasa/python-kasa/pull/213) ([bdraco](https://github.com/bdraco)) +- Keep connection open and lock to prevent duplicate requests [\#213](https://github.com/python-kasa/python-kasa/pull/213) (@bdraco) **Merged pull requests:** -- Release 0.4.0.dev5 [\#215](https://github.com/python-kasa/python-kasa/pull/215) ([rytilahti](https://github.com/rytilahti)) -- Add KL130 fixture, initial lightstrip tests [\#214](https://github.com/python-kasa/python-kasa/pull/214) ([rytilahti](https://github.com/rytilahti)) -- Cleanup discovery & add tests [\#212](https://github.com/python-kasa/python-kasa/pull/212) ([rytilahti](https://github.com/rytilahti)) +- Release 0.4.0.dev5 [\#215](https://github.com/python-kasa/python-kasa/pull/215) (@rytilahti) +- Add KL130 fixture, initial lightstrip tests [\#214](https://github.com/python-kasa/python-kasa/pull/214) (@rytilahti) +- Cleanup discovery & add tests [\#212](https://github.com/python-kasa/python-kasa/pull/212) (@rytilahti) ## [0.4.0.dev4](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev4) (2021-09-23) @@ -94,20 +136,20 @@ **Implemented enhancements:** -- Improve emeterstatus API, move into own module [\#205](https://github.com/python-kasa/python-kasa/pull/205) ([rytilahti](https://github.com/rytilahti)) -- Avoid temp array during encrypt and decrypt [\#204](https://github.com/python-kasa/python-kasa/pull/204) ([bdraco](https://github.com/bdraco)) -- Add emeter support for strip sockets [\#203](https://github.com/python-kasa/python-kasa/pull/203) ([bdraco](https://github.com/bdraco)) -- Add own device type for smartstrip children [\#201](https://github.com/python-kasa/python-kasa/pull/201) ([rytilahti](https://github.com/rytilahti)) -- bulb: allow set\_hsv without v, add fallback ct range [\#200](https://github.com/python-kasa/python-kasa/pull/200) ([rytilahti](https://github.com/rytilahti)) -- Improve bulb support \(alias, time settings\) [\#198](https://github.com/python-kasa/python-kasa/pull/198) ([rytilahti](https://github.com/rytilahti)) -- Improve testing harness to allow tests on real devices [\#197](https://github.com/python-kasa/python-kasa/pull/197) ([rytilahti](https://github.com/rytilahti)) -- cli: add human-friendly printout when calling temperature on non-supported devices [\#196](https://github.com/python-kasa/python-kasa/pull/196) ([JaydenRA](https://github.com/JaydenRA)) +- Improve emeterstatus API, move into own module [\#205](https://github.com/python-kasa/python-kasa/pull/205) (@rytilahti) +- Avoid temp array during encrypt and decrypt [\#204](https://github.com/python-kasa/python-kasa/pull/204) (@bdraco) +- Add emeter support for strip sockets [\#203](https://github.com/python-kasa/python-kasa/pull/203) (@bdraco) +- Add own device type for smartstrip children [\#201](https://github.com/python-kasa/python-kasa/pull/201) (@rytilahti) +- bulb: allow set\_hsv without v, add fallback ct range [\#200](https://github.com/python-kasa/python-kasa/pull/200) (@rytilahti) +- Improve bulb support \(alias, time settings\) [\#198](https://github.com/python-kasa/python-kasa/pull/198) (@rytilahti) +- Improve testing harness to allow tests on real devices [\#197](https://github.com/python-kasa/python-kasa/pull/197) (@rytilahti) +- cli: add human-friendly printout when calling temperature on non-supported devices [\#196](https://github.com/python-kasa/python-kasa/pull/196) (@JaydenRA) **Fixed bugs:** - KL430: Throw error for Device specific information [\#189](https://github.com/python-kasa/python-kasa/issues/189) - HS300 Children plugs have emeter [\#64](https://github.com/python-kasa/python-kasa/issues/64) -- dump\_devinfo: handle latitude/longitude keys properly [\#175](https://github.com/python-kasa/python-kasa/pull/175) ([rytilahti](https://github.com/rytilahti)) +- dump\_devinfo: handle latitude/longitude keys properly [\#175](https://github.com/python-kasa/python-kasa/pull/175) (@rytilahti) **Closed issues:** @@ -122,16 +164,16 @@ **Merged pull requests:** -- Release 0.4.0.dev4 [\#210](https://github.com/python-kasa/python-kasa/pull/210) ([rytilahti](https://github.com/rytilahti)) -- More CI fixes [\#208](https://github.com/python-kasa/python-kasa/pull/208) ([rytilahti](https://github.com/rytilahti)) -- Fix CI dep installation [\#207](https://github.com/python-kasa/python-kasa/pull/207) ([rytilahti](https://github.com/rytilahti)) -- Use github actions instead of azure pipelines [\#206](https://github.com/python-kasa/python-kasa/pull/206) ([rytilahti](https://github.com/rytilahti)) -- Add KP115 fixture [\#202](https://github.com/python-kasa/python-kasa/pull/202) ([rytilahti](https://github.com/rytilahti)) -- Perform initial update only using the sysinfo query [\#199](https://github.com/python-kasa/python-kasa/pull/199) ([rytilahti](https://github.com/rytilahti)) -- Add real kasa KL430\(UN\) device dump [\#192](https://github.com/python-kasa/python-kasa/pull/192) ([iprodanovbg](https://github.com/iprodanovbg)) -- Use less strict matcher for kl430 color temperature [\#190](https://github.com/python-kasa/python-kasa/pull/190) ([rytilahti](https://github.com/rytilahti)) -- Add EP10\(US\) 1.0 1.0.2 fixture [\#174](https://github.com/python-kasa/python-kasa/pull/174) ([nbrew](https://github.com/nbrew)) -- Add a note about using the discovery target parameter [\#168](https://github.com/python-kasa/python-kasa/pull/168) ([leandroreox](https://github.com/leandroreox)) +- Release 0.4.0.dev4 [\#210](https://github.com/python-kasa/python-kasa/pull/210) (@rytilahti) +- More CI fixes [\#208](https://github.com/python-kasa/python-kasa/pull/208) (@rytilahti) +- Fix CI dep installation [\#207](https://github.com/python-kasa/python-kasa/pull/207) (@rytilahti) +- Use github actions instead of azure pipelines [\#206](https://github.com/python-kasa/python-kasa/pull/206) (@rytilahti) +- Add KP115 fixture [\#202](https://github.com/python-kasa/python-kasa/pull/202) (@rytilahti) +- Perform initial update only using the sysinfo query [\#199](https://github.com/python-kasa/python-kasa/pull/199) (@rytilahti) +- Add real kasa KL430\(UN\) device dump [\#192](https://github.com/python-kasa/python-kasa/pull/192) (@iprodanovbg) +- Use less strict matcher for kl430 color temperature [\#190](https://github.com/python-kasa/python-kasa/pull/190) (@rytilahti) +- Add EP10\(US\) 1.0 1.0.2 fixture [\#174](https://github.com/python-kasa/python-kasa/pull/174) (@nbrew) +- Add a note about using the discovery target parameter [\#168](https://github.com/python-kasa/python-kasa/pull/168) (@leandroreox) ## [0.4.0.dev3](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev3) (2021-06-16) @@ -141,8 +183,8 @@ - `Unable to find a value for 'current'` error when attempting to query KL125 bulb emeter [\#142](https://github.com/python-kasa/python-kasa/issues/142) - `Unknown color temperature range` error when attempting to query KL125 bulb state [\#141](https://github.com/python-kasa/python-kasa/issues/141) -- Simplify discovery query, refactor dump-devinfo [\#147](https://github.com/python-kasa/python-kasa/pull/147) ([rytilahti](https://github.com/rytilahti)) -- Return None instead of raising an exception on missing, valid emeter keys [\#146](https://github.com/python-kasa/python-kasa/pull/146) ([rytilahti](https://github.com/rytilahti)) +- Simplify discovery query, refactor dump-devinfo [\#147](https://github.com/python-kasa/python-kasa/pull/147) (@rytilahti) +- Return None instead of raising an exception on missing, valid emeter keys [\#146](https://github.com/python-kasa/python-kasa/pull/146) (@rytilahti) **Closed issues:** @@ -162,16 +204,16 @@ **Merged pull requests:** -- Prepare 0.4.0.dev3 [\#172](https://github.com/python-kasa/python-kasa/pull/172) ([rytilahti](https://github.com/rytilahti)) -- Simplify mac address handling [\#162](https://github.com/python-kasa/python-kasa/pull/162) ([rytilahti](https://github.com/rytilahti)) -- Added KL125 and HS200 fixture dumps and updated tests to run on new format [\#160](https://github.com/python-kasa/python-kasa/pull/160) ([brianthedavis](https://github.com/brianthedavis)) -- Add KL125 bulb definition [\#143](https://github.com/python-kasa/python-kasa/pull/143) ([mdarnol](https://github.com/mdarnol)) -- README.md: Add link to MQTT interface for python-kasa [\#140](https://github.com/python-kasa/python-kasa/pull/140) ([flavio-fernandes](https://github.com/flavio-fernandes)) -- Fix documentation on Smart strips [\#136](https://github.com/python-kasa/python-kasa/pull/136) ([flavio-fernandes](https://github.com/flavio-fernandes)) -- add tapo link, fix tplink-smarthome-simulator link [\#133](https://github.com/python-kasa/python-kasa/pull/133) ([rytilahti](https://github.com/rytilahti)) -- Leverage data from UDP discovery to initialize device structure [\#132](https://github.com/python-kasa/python-kasa/pull/132) ([dlee1j1](https://github.com/dlee1j1)) -- Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) ([rytilahti](https://github.com/rytilahti)) -- Add HS220 hw 2.0 fixture [\#107](https://github.com/python-kasa/python-kasa/pull/107) ([appleguru](https://github.com/appleguru)) +- Prepare 0.4.0.dev3 [\#172](https://github.com/python-kasa/python-kasa/pull/172) (@rytilahti) +- Simplify mac address handling [\#162](https://github.com/python-kasa/python-kasa/pull/162) (@rytilahti) +- Added KL125 and HS200 fixture dumps and updated tests to run on new format [\#160](https://github.com/python-kasa/python-kasa/pull/160) (@brianthedavis) +- Add KL125 bulb definition [\#143](https://github.com/python-kasa/python-kasa/pull/143) (@mdarnol) +- README.md: Add link to MQTT interface for python-kasa [\#140](https://github.com/python-kasa/python-kasa/pull/140) (@flavio-fernandes) +- Fix documentation on Smart strips [\#136](https://github.com/python-kasa/python-kasa/pull/136) (@flavio-fernandes) +- add tapo link, fix tplink-smarthome-simulator link [\#133](https://github.com/python-kasa/python-kasa/pull/133) (@rytilahti) +- Leverage data from UDP discovery to initialize device structure [\#132](https://github.com/python-kasa/python-kasa/pull/132) (@dlee1j1) +- Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) (@rytilahti) +- Add HS220 hw 2.0 fixture [\#107](https://github.com/python-kasa/python-kasa/pull/107) (@appleguru) ## [0.4.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev2) (2020-11-21) @@ -179,12 +221,12 @@ **Implemented enhancements:** -- 'Interface' parameter added to discovery process [\#79](https://github.com/python-kasa/python-kasa/pull/79) ([dmitryelj](https://github.com/dmitryelj)) +- 'Interface' parameter added to discovery process [\#79](https://github.com/python-kasa/python-kasa/pull/79) (@dmitryelj) **Fixed bugs:** -- Simplify device class detection for discovery, fix hardcoded timeout [\#112](https://github.com/python-kasa/python-kasa/pull/112) ([rytilahti](https://github.com/rytilahti)) -- Update cli.py to addresss crash on year/month calls and improve output formatting [\#103](https://github.com/python-kasa/python-kasa/pull/103) ([BuongiornoTexas](https://github.com/BuongiornoTexas)) +- Simplify device class detection for discovery, fix hardcoded timeout [\#112](https://github.com/python-kasa/python-kasa/pull/112) (@rytilahti) +- Update cli.py to addresss crash on year/month calls and improve output formatting [\#103](https://github.com/python-kasa/python-kasa/pull/103) (@BuongiornoTexas) **Closed issues:** @@ -201,8 +243,8 @@ **Merged pull requests:** -- Release 0.4.0.dev2 [\#118](https://github.com/python-kasa/python-kasa/pull/118) ([rytilahti](https://github.com/rytilahti)) -- Pin dependencies on major versions, add poetry.lock [\#94](https://github.com/python-kasa/python-kasa/pull/94) ([rytilahti](https://github.com/rytilahti)) +- Release 0.4.0.dev2 [\#118](https://github.com/python-kasa/python-kasa/pull/118) (@rytilahti) +- Pin dependencies on major versions, add poetry.lock [\#94](https://github.com/python-kasa/python-kasa/pull/94) (@rytilahti) ## [0.4.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev1) (2020-07-28) @@ -212,7 +254,7 @@ - KL430 support [\#67](https://github.com/python-kasa/python-kasa/issues/67) - Improve retry logic for discovery, messaging \(was: Handle empty responses\) [\#38](https://github.com/python-kasa/python-kasa/issues/38) -- Add support for lightstrips \(KL430\) [\#74](https://github.com/python-kasa/python-kasa/pull/74) ([rytilahti](https://github.com/rytilahti)) +- Add support for lightstrips \(KL430\) [\#74](https://github.com/python-kasa/python-kasa/pull/74) (@rytilahti) **Closed issues:** @@ -227,25 +269,29 @@ **Merged pull requests:** -- Release 0.4.0.dev1 [\#93](https://github.com/python-kasa/python-kasa/pull/93) ([rytilahti](https://github.com/rytilahti)) -- add a small example script to show library usage [\#90](https://github.com/python-kasa/python-kasa/pull/90) ([rytilahti](https://github.com/rytilahti)) -- add .readthedocs.yml required for poetry builds [\#89](https://github.com/python-kasa/python-kasa/pull/89) ([rytilahti](https://github.com/rytilahti)) -- Improve installation instructions [\#86](https://github.com/python-kasa/python-kasa/pull/86) ([rytilahti](https://github.com/rytilahti)) -- cli: Fix incorrect use of asyncio.run for temperature command [\#85](https://github.com/python-kasa/python-kasa/pull/85) ([rytilahti](https://github.com/rytilahti)) -- Add parse\_pcap to devtools, improve readme on contributing [\#84](https://github.com/python-kasa/python-kasa/pull/84) ([rytilahti](https://github.com/rytilahti)) -- Add --transition to bulb-specific cli commands, fix turn\_{on,off} signatures [\#81](https://github.com/python-kasa/python-kasa/pull/81) ([rytilahti](https://github.com/rytilahti)) -- Improve bulb API, force turn on for all light changes as offline changes are not supported [\#76](https://github.com/python-kasa/python-kasa/pull/76) ([rytilahti](https://github.com/rytilahti)) -- Simplify API documentation by using doctests [\#73](https://github.com/python-kasa/python-kasa/pull/73) ([rytilahti](https://github.com/rytilahti)) -- Bulbs: allow specifying transition for state changes [\#70](https://github.com/python-kasa/python-kasa/pull/70) ([rytilahti](https://github.com/rytilahti)) -- Add transition support for SmartDimmer [\#69](https://github.com/python-kasa/python-kasa/pull/69) ([connorproctor](https://github.com/connorproctor)) +- Release 0.4.0.dev1 [\#93](https://github.com/python-kasa/python-kasa/pull/93) (@rytilahti) +- add a small example script to show library usage [\#90](https://github.com/python-kasa/python-kasa/pull/90) (@rytilahti) +- add .readthedocs.yml required for poetry builds [\#89](https://github.com/python-kasa/python-kasa/pull/89) (@rytilahti) +- Improve installation instructions [\#86](https://github.com/python-kasa/python-kasa/pull/86) (@rytilahti) +- cli: Fix incorrect use of asyncio.run for temperature command [\#85](https://github.com/python-kasa/python-kasa/pull/85) (@rytilahti) +- Add parse\_pcap to devtools, improve readme on contributing [\#84](https://github.com/python-kasa/python-kasa/pull/84) (@rytilahti) +- Add --transition to bulb-specific cli commands, fix turn\_{on,off} signatures [\#81](https://github.com/python-kasa/python-kasa/pull/81) (@rytilahti) +- Improve bulb API, force turn on for all light changes as offline changes are not supported [\#76](https://github.com/python-kasa/python-kasa/pull/76) (@rytilahti) +- Simplify API documentation by using doctests [\#73](https://github.com/python-kasa/python-kasa/pull/73) (@rytilahti) +- Bulbs: allow specifying transition for state changes [\#70](https://github.com/python-kasa/python-kasa/pull/70) (@rytilahti) +- Add transition support for SmartDimmer [\#69](https://github.com/python-kasa/python-kasa/pull/69) (@connorproctor) ## [0.4.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev0) (2020-05-27) -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.3.5...0.4.0.dev0) +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.pre0...0.4.0.dev0) + +## [0.4.0.pre0](https://github.com/python-kasa/python-kasa/tree/0.4.0.pre0) (2020-05-27) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.3.5...0.4.0.pre0) **Implemented enhancements:** -- Add commands to control the wifi settings [\#45](https://github.com/python-kasa/python-kasa/pull/45) ([rytilahti](https://github.com/rytilahti)) +- Add commands to control the wifi settings [\#45](https://github.com/python-kasa/python-kasa/pull/45) (@rytilahti) **Fixed bugs:** @@ -271,43 +317,43 @@ **Merged pull requests:** -- Add retries to protocol queries [\#65](https://github.com/python-kasa/python-kasa/pull/65) ([rytilahti](https://github.com/rytilahti)) -- General cleanups all around \(janitoring\) [\#63](https://github.com/python-kasa/python-kasa/pull/63) ([rytilahti](https://github.com/rytilahti)) -- Improve dimmer support [\#62](https://github.com/python-kasa/python-kasa/pull/62) ([rytilahti](https://github.com/rytilahti)) -- Optimize I/O access [\#59](https://github.com/python-kasa/python-kasa/pull/59) ([rytilahti](https://github.com/rytilahti)) -- Remove unnecessary f-string definition to make tests pass [\#58](https://github.com/python-kasa/python-kasa/pull/58) ([rytilahti](https://github.com/rytilahti)) -- Convert to use poetry & pyproject.toml for dep & build management [\#54](https://github.com/python-kasa/python-kasa/pull/54) ([rytilahti](https://github.com/rytilahti)) -- Add fixture for KL60 [\#52](https://github.com/python-kasa/python-kasa/pull/52) ([rytilahti](https://github.com/rytilahti)) -- Ignore D202 where necessary [\#50](https://github.com/python-kasa/python-kasa/pull/50) ([rytilahti](https://github.com/rytilahti)) -- Support wifi scan & join for bulbs using a different interface [\#49](https://github.com/python-kasa/python-kasa/pull/49) ([rytilahti](https://github.com/rytilahti)) -- Return on\_since only when its available and the device is on [\#48](https://github.com/python-kasa/python-kasa/pull/48) ([rytilahti](https://github.com/rytilahti)) -- Allow 0 brightness for smartdimmer [\#47](https://github.com/python-kasa/python-kasa/pull/47) ([rytilahti](https://github.com/rytilahti)) -- async++, small powerstrip improvements [\#46](https://github.com/python-kasa/python-kasa/pull/46) ([rytilahti](https://github.com/rytilahti)) -- Check for emeter support on power strips/multiple plug outlets [\#41](https://github.com/python-kasa/python-kasa/pull/41) ([acmay](https://github.com/acmay)) -- Remove unnecessary cache [\#40](https://github.com/python-kasa/python-kasa/pull/40) ([rytilahti](https://github.com/rytilahti)) -- Add in some new device types [\#39](https://github.com/python-kasa/python-kasa/pull/39) ([acmay](https://github.com/acmay)) -- Add test fixture for KL130 [\#35](https://github.com/python-kasa/python-kasa/pull/35) ([bdraco](https://github.com/bdraco)) -- Move dimmer support to its own class [\#34](https://github.com/python-kasa/python-kasa/pull/34) ([rytilahti](https://github.com/rytilahti)) -- Fix azure pipeline badge [\#32](https://github.com/python-kasa/python-kasa/pull/32) ([rytilahti](https://github.com/rytilahti)) -- Enable Windows & OSX builds [\#31](https://github.com/python-kasa/python-kasa/pull/31) ([rytilahti](https://github.com/rytilahti)) -- Depend on py3.7+ for tox, add python 3.8 to azure pipeline targets [\#29](https://github.com/python-kasa/python-kasa/pull/29) ([rytilahti](https://github.com/rytilahti)) -- Add KP303 to the list of powerstrips [\#28](https://github.com/python-kasa/python-kasa/pull/28) ([rytilahti](https://github.com/rytilahti)) -- Move child socket handling to its own SmartStripPlug class [\#26](https://github.com/python-kasa/python-kasa/pull/26) ([rytilahti](https://github.com/rytilahti)) -- Adding KP303 to supported devices [\#25](https://github.com/python-kasa/python-kasa/pull/25) ([epicalex](https://github.com/epicalex)) -- use pytestmark to avoid repeating asyncio mark [\#24](https://github.com/python-kasa/python-kasa/pull/24) ([rytilahti](https://github.com/rytilahti)) -- Cleanup constructors by removing ioloop and protocol arguments [\#23](https://github.com/python-kasa/python-kasa/pull/23) ([rytilahti](https://github.com/rytilahti)) -- Add \(some\) tests to the cli tool [\#22](https://github.com/python-kasa/python-kasa/pull/22) ([rytilahti](https://github.com/rytilahti)) -- Test against the newly added device fixtures [\#21](https://github.com/python-kasa/python-kasa/pull/21) ([rytilahti](https://github.com/rytilahti)) -- move testing reqs to requirements\_test.txt, add pytest-asyncio for pipelines [\#20](https://github.com/python-kasa/python-kasa/pull/20) ([rytilahti](https://github.com/rytilahti)) -- Remove unused save option and add scrubbing [\#19](https://github.com/python-kasa/python-kasa/pull/19) ([TheGardenMonkey](https://github.com/TheGardenMonkey)) -- Add real kasa device dumps [\#18](https://github.com/python-kasa/python-kasa/pull/18) ([TheGardenMonkey](https://github.com/TheGardenMonkey)) -- Fix dump-discover to use asyncio.run [\#16](https://github.com/python-kasa/python-kasa/pull/16) ([rytilahti](https://github.com/rytilahti)) -- Add device\_id property, rename context to child\_id [\#15](https://github.com/python-kasa/python-kasa/pull/15) ([rytilahti](https://github.com/rytilahti)) -- Remove sync interface, add asyncio discovery [\#14](https://github.com/python-kasa/python-kasa/pull/14) ([rytilahti](https://github.com/rytilahti)) -- Remove --ip which was just an alias to --host [\#6](https://github.com/python-kasa/python-kasa/pull/6) ([rytilahti](https://github.com/rytilahti)) -- Set minimum requirement to python 3.7 [\#5](https://github.com/python-kasa/python-kasa/pull/5) ([rytilahti](https://github.com/rytilahti)) -- change ID of Azure Pipeline [\#3](https://github.com/python-kasa/python-kasa/pull/3) ([basnijholt](https://github.com/basnijholt)) -- Mass rename to \(python-\)kasa [\#1](https://github.com/python-kasa/python-kasa/pull/1) ([rytilahti](https://github.com/rytilahti)) +- Add retries to protocol queries [\#65](https://github.com/python-kasa/python-kasa/pull/65) (@rytilahti) +- General cleanups all around \(janitoring\) [\#63](https://github.com/python-kasa/python-kasa/pull/63) (@rytilahti) +- Improve dimmer support [\#62](https://github.com/python-kasa/python-kasa/pull/62) (@rytilahti) +- Optimize I/O access [\#59](https://github.com/python-kasa/python-kasa/pull/59) (@rytilahti) +- Remove unnecessary f-string definition to make tests pass [\#58](https://github.com/python-kasa/python-kasa/pull/58) (@rytilahti) +- Convert to use poetry & pyproject.toml for dep & build management [\#54](https://github.com/python-kasa/python-kasa/pull/54) (@rytilahti) +- Add fixture for KL60 [\#52](https://github.com/python-kasa/python-kasa/pull/52) (@rytilahti) +- Ignore D202 where necessary [\#50](https://github.com/python-kasa/python-kasa/pull/50) (@rytilahti) +- Support wifi scan & join for bulbs using a different interface [\#49](https://github.com/python-kasa/python-kasa/pull/49) (@rytilahti) +- Return on\_since only when its available and the device is on [\#48](https://github.com/python-kasa/python-kasa/pull/48) (@rytilahti) +- Allow 0 brightness for smartdimmer [\#47](https://github.com/python-kasa/python-kasa/pull/47) (@rytilahti) +- async++, small powerstrip improvements [\#46](https://github.com/python-kasa/python-kasa/pull/46) (@rytilahti) +- Check for emeter support on power strips/multiple plug outlets [\#41](https://github.com/python-kasa/python-kasa/pull/41) (@acmay) +- Remove unnecessary cache [\#40](https://github.com/python-kasa/python-kasa/pull/40) (@rytilahti) +- Add in some new device types [\#39](https://github.com/python-kasa/python-kasa/pull/39) (@acmay) +- Add test fixture for KL130 [\#35](https://github.com/python-kasa/python-kasa/pull/35) (@bdraco) +- Move dimmer support to its own class [\#34](https://github.com/python-kasa/python-kasa/pull/34) (@rytilahti) +- Fix azure pipeline badge [\#32](https://github.com/python-kasa/python-kasa/pull/32) (@rytilahti) +- Enable Windows & OSX builds [\#31](https://github.com/python-kasa/python-kasa/pull/31) (@rytilahti) +- Depend on py3.7+ for tox, add python 3.8 to azure pipeline targets [\#29](https://github.com/python-kasa/python-kasa/pull/29) (@rytilahti) +- Add KP303 to the list of powerstrips [\#28](https://github.com/python-kasa/python-kasa/pull/28) (@rytilahti) +- Move child socket handling to its own SmartStripPlug class [\#26](https://github.com/python-kasa/python-kasa/pull/26) (@rytilahti) +- Adding KP303 to supported devices [\#25](https://github.com/python-kasa/python-kasa/pull/25) (@epicalex) +- use pytestmark to avoid repeating asyncio mark [\#24](https://github.com/python-kasa/python-kasa/pull/24) (@rytilahti) +- Cleanup constructors by removing ioloop and protocol arguments [\#23](https://github.com/python-kasa/python-kasa/pull/23) (@rytilahti) +- Add \(some\) tests to the cli tool [\#22](https://github.com/python-kasa/python-kasa/pull/22) (@rytilahti) +- Test against the newly added device fixtures [\#21](https://github.com/python-kasa/python-kasa/pull/21) (@rytilahti) +- move testing reqs to requirements\_test.txt, add pytest-asyncio for pipelines [\#20](https://github.com/python-kasa/python-kasa/pull/20) (@rytilahti) +- Remove unused save option and add scrubbing [\#19](https://github.com/python-kasa/python-kasa/pull/19) (@TheGardenMonkey) +- Add real kasa device dumps [\#18](https://github.com/python-kasa/python-kasa/pull/18) (@TheGardenMonkey) +- Fix dump-discover to use asyncio.run [\#16](https://github.com/python-kasa/python-kasa/pull/16) (@rytilahti) +- Add device\_id property, rename context to child\_id [\#15](https://github.com/python-kasa/python-kasa/pull/15) (@rytilahti) +- Remove sync interface, add asyncio discovery [\#14](https://github.com/python-kasa/python-kasa/pull/14) (@rytilahti) +- Remove --ip which was just an alias to --host [\#6](https://github.com/python-kasa/python-kasa/pull/6) (@rytilahti) +- Set minimum requirement to python 3.7 [\#5](https://github.com/python-kasa/python-kasa/pull/5) (@rytilahti) +- change ID of Azure Pipeline [\#3](https://github.com/python-kasa/python-kasa/pull/3) (@basnijholt) +- Mass rename to \(python-\)kasa [\#1](https://github.com/python-kasa/python-kasa/pull/1) (@rytilahti) Historical pyHS100 changelog ============================ diff --git a/poetry.lock b/poetry.lock index 9b5346c36..d3af30c3f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -118,7 +118,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "coverage" -version = "6.3.1" +version = "6.3.2" description = "Code coverage measurement for Python" category = "dev" optional = false @@ -148,7 +148,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "filelock" -version = "3.4.2" +version = "3.6.0" description = "A platform independent file lock." category = "dev" optional = false @@ -160,7 +160,7 @@ testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-co [[package]] name = "identify" -version = "2.4.9" +version = "2.4.12" description = "File identification library for Python" category = "dev" optional = false @@ -187,7 +187,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.11.0" +version = "4.11.3" description = "Read metadata from Python packages" category = "main" optional = false @@ -198,9 +198,9 @@ typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] perf = ["ipython"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] [[package]] name = "iniconfig" @@ -238,11 +238,11 @@ mistune = "*" [[package]] name = "markupsafe" -version = "2.0.1" +version = "2.1.1" description = "Safely add untrusted strings to HTML/XML markup." category = "main" optional = true -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "mistune" @@ -273,7 +273,7 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "platformdirs" -version = "2.5.0" +version = "2.5.1" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false @@ -344,11 +344,11 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "7.0.1" +version = "7.1.1" description = "pytest: simple powerful testing with Python" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} @@ -366,7 +366,7 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2. [[package]] name = "pytest-asyncio" -version = "0.18.1" +version = "0.18.2" description = "Pytest support for asyncio" category = "dev" optional = false @@ -424,7 +424,7 @@ termcolor = ">=1.1.0" [[package]] name = "pytz" -version = "2021.3" +version = "2022.1" description = "World timezone definitions, modern and historical" category = "main" optional = true @@ -657,7 +657,7 @@ testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pytest (>=4.0.0)", "pytes [[package]] name = "typing-extensions" -version = "4.1.0" +version = "4.1.1" description = "Backported and Experimental Type Hints for Python 3.6+" category = "main" optional = false @@ -665,20 +665,20 @@ python-versions = ">=3.6" [[package]] name = "urllib3" -version = "1.26.8" +version = "1.26.9" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] -brotli = ["brotlipy (>=0.6.0)"] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.13.1" +version = "20.13.4" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -787,47 +787,47 @@ colorama = [ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] coverage = [ - {file = "coverage-6.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eeffd96882d8c06d31b65dddcf51db7c612547babc1c4c5db6a011abe9798525"}, - {file = "coverage-6.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:621f6ea7260ea2ffdaec64fe5cb521669984f567b66f62f81445221d4754df4c"}, - {file = "coverage-6.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84f2436d6742c01136dd940ee158bfc7cf5ced3da7e4c949662b8703b5cd8145"}, - {file = "coverage-6.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de73fca6fb403dd72d4da517cfc49fcf791f74eee697d3219f6be29adf5af6ce"}, - {file = "coverage-6.3.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78fbb2be068a13a5d99dce9e1e7d168db880870f7bc73f876152130575bd6167"}, - {file = "coverage-6.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f5a4551dfd09c3bd12fca8144d47fe7745275adf3229b7223c2f9e29a975ebda"}, - {file = "coverage-6.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7bff3a98f63b47464480de1b5bdd80c8fade0ba2832c9381253c9b74c4153c27"}, - {file = "coverage-6.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a06c358f4aed05fa1099c39decc8022261bb07dfadc127c08cfbd1391b09689e"}, - {file = "coverage-6.3.1-cp310-cp310-win32.whl", hash = "sha256:9fff3ff052922cb99f9e52f63f985d4f7a54f6b94287463bc66b7cdf3eb41217"}, - {file = "coverage-6.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:276b13cc085474e482566c477c25ed66a097b44c6e77132f3304ac0b039f83eb"}, - {file = "coverage-6.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:56c4a409381ddd7bbff134e9756077860d4e8a583d310a6f38a2315b9ce301d0"}, - {file = "coverage-6.3.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9eb494070aa060ceba6e4bbf44c1bc5fa97bfb883a0d9b0c9049415f9e944793"}, - {file = "coverage-6.3.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e15d424b8153756b7c903bde6d4610be0c3daca3986173c18dd5c1a1625e4cd"}, - {file = "coverage-6.3.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d47a897c1e91f33f177c21de897267b38fbb45f2cd8e22a710bcef1df09ac1"}, - {file = "coverage-6.3.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:25e73d4c81efa8ea3785274a2f7f3bfbbeccb6fcba2a0bdd3be9223371c37554"}, - {file = "coverage-6.3.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:fac0bcc5b7e8169bffa87f0dcc24435446d329cbc2b5486d155c2e0f3b493ae1"}, - {file = "coverage-6.3.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:72128176fea72012063200b7b395ed8a57849282b207321124d7ff14e26988e8"}, - {file = "coverage-6.3.1-cp37-cp37m-win32.whl", hash = "sha256:1bc6d709939ff262fd1432f03f080c5042dc6508b6e0d3d20e61dd045456a1a0"}, - {file = "coverage-6.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:618eeba986cea7f621d8607ee378ecc8c2504b98b3fdc4952b30fe3578304687"}, - {file = "coverage-6.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ed164af5c9078596cfc40b078c3b337911190d3faeac830c3f1274f26b8320"}, - {file = "coverage-6.3.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:352c68e233409c31048a3725c446a9e48bbff36e39db92774d4f2380d630d8f8"}, - {file = "coverage-6.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:448d7bde7ceb6c69e08474c2ddbc5b4cd13c9e4aa4a717467f716b5fc938a734"}, - {file = "coverage-6.3.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9fde6b90889522c220dd56a670102ceef24955d994ff7af2cb786b4ba8fe11e4"}, - {file = "coverage-6.3.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e647a0be741edbb529a72644e999acb09f2ad60465f80757da183528941ff975"}, - {file = "coverage-6.3.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a5cdc3adb4f8bb8d8f5e64c2e9e282bc12980ef055ec6da59db562ee9bdfefa"}, - {file = "coverage-6.3.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:2dd70a167843b4b4b2630c0c56f1b586fe965b4f8ac5da05b6690344fd065c6b"}, - {file = "coverage-6.3.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9ad0a117b8dc2061ce9461ea4c1b4799e55edceb236522c5b8f958ce9ed8fa9a"}, - {file = "coverage-6.3.1-cp38-cp38-win32.whl", hash = "sha256:e92c7a5f7d62edff50f60a045dc9542bf939758c95b2fcd686175dd10ce0ed10"}, - {file = "coverage-6.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:482fb42eea6164894ff82abbcf33d526362de5d1a7ed25af7ecbdddd28fc124f"}, - {file = "coverage-6.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c5b81fb37db76ebea79aa963b76d96ff854e7662921ce742293463635a87a78d"}, - {file = "coverage-6.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a4f923b9ab265136e57cc14794a15b9dcea07a9c578609cd5dbbfff28a0d15e6"}, - {file = "coverage-6.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56d296cbc8254a7dffdd7bcc2eb70be5a233aae7c01856d2d936f5ac4e8ac1f1"}, - {file = "coverage-6.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1245ab82e8554fa88c4b2ab1e098ae051faac5af829efdcf2ce6b34dccd5567c"}, - {file = "coverage-6.3.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f2b05757c92ad96b33dbf8e8ec8d4ccb9af6ae3c9e9bd141c7cc44d20c6bcba"}, - {file = "coverage-6.3.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9e3dd806f34de38d4c01416344e98eab2437ac450b3ae39c62a0ede2f8b5e4ed"}, - {file = "coverage-6.3.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d651fde74a4d3122e5562705824507e2f5b2d3d57557f1916c4b27635f8fbe3f"}, - {file = "coverage-6.3.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:704f89b87c4f4737da2860695a18c852b78ec7279b24eedacab10b29067d3a38"}, - {file = "coverage-6.3.1-cp39-cp39-win32.whl", hash = "sha256:2aed4761809640f02e44e16b8b32c1a5dee5e80ea30a0ff0912158bde9c501f2"}, - {file = "coverage-6.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:9976fb0a5709988778ac9bc44f3d50fccd989987876dfd7716dee28beed0a9fa"}, - {file = "coverage-6.3.1-pp36.pp37.pp38-none-any.whl", hash = "sha256:463e52616ea687fd323888e86bf25e864a3cc6335a043fad6bbb037dbf49bbe2"}, - {file = "coverage-6.3.1.tar.gz", hash = "sha256:6c3f6158b02ac403868eea390930ae64e9a9a2a5bbfafefbb920d29258d9f2f8"}, + {file = "coverage-6.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf"}, + {file = "coverage-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac"}, + {file = "coverage-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1"}, + {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4"}, + {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903"}, + {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c"}, + {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f"}, + {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05"}, + {file = "coverage-6.3.2-cp310-cp310-win32.whl", hash = "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39"}, + {file = "coverage-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1"}, + {file = "coverage-6.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa"}, + {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518"}, + {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7"}, + {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6"}, + {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad"}, + {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359"}, + {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4"}, + {file = "coverage-6.3.2-cp37-cp37m-win32.whl", hash = "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca"}, + {file = "coverage-6.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3"}, + {file = "coverage-6.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d"}, + {file = "coverage-6.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059"}, + {file = "coverage-6.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512"}, + {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca"}, + {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d"}, + {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0"}, + {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6"}, + {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"}, + {file = "coverage-6.3.2-cp38-cp38-win32.whl", hash = "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e"}, + {file = "coverage-6.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1"}, + {file = "coverage-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620"}, + {file = "coverage-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d"}, + {file = "coverage-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536"}, + {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7"}, + {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2"}, + {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4"}, + {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69"}, + {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684"}, + {file = "coverage-6.3.2-cp39-cp39-win32.whl", hash = "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4"}, + {file = "coverage-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92"}, + {file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"}, + {file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"}, ] distlib = [ {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, @@ -838,12 +838,12 @@ docutils = [ {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, ] filelock = [ - {file = "filelock-3.4.2-py3-none-any.whl", hash = "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"}, - {file = "filelock-3.4.2.tar.gz", hash = "sha256:38b4f4c989f9d06d44524df1b24bd19e167d851f19b50bf3e3559952dddc5b80"}, + {file = "filelock-3.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"}, + {file = "filelock-3.6.0.tar.gz", hash = "sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85"}, ] identify = [ - {file = "identify-2.4.9-py2.py3-none-any.whl", hash = "sha256:bff7c4959d68510bc28b99d664b6a623e36c6eadc933f89a4e0a9ddff9b4fee4"}, - {file = "identify-2.4.9.tar.gz", hash = "sha256:e926ae3b3dc142b6a7a9c65433eb14ccac751b724ee255f7c2ed3b5970d764fb"}, + {file = "identify-2.4.12-py2.py3-none-any.whl", hash = "sha256:5f06b14366bd1facb88b00540a1de05b69b310cbc2654db3c7e07fa3a4339323"}, + {file = "identify-2.4.12.tar.gz", hash = "sha256:3f3244a559290e7d3deb9e9adc7b33594c1bc85a9dd82e0f1be519bf12a1ec17"}, ] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, @@ -854,8 +854,8 @@ imagesize = [ {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.11.0-py3-none-any.whl", hash = "sha256:6affcdb3aec542dd98df8211e730bba6c5f2bec8288d47bacacde898f548c9ad"}, - {file = "importlib_metadata-4.11.0.tar.gz", hash = "sha256:9e5e553bbba1843cb4a00823014b907616be46ee503d2b9ba001d214a8da218f"}, + {file = "importlib_metadata-4.11.3-py3-none-any.whl", hash = "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6"}, + {file = "importlib_metadata-4.11.3.tar.gz", hash = "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -869,40 +869,46 @@ m2r = [ {file = "m2r-0.2.1.tar.gz", hash = "sha256:bf90bad66cda1164b17e5ba4a037806d2443f2a4d5ddc9f6a5554a0322aaed99"}, ] markupsafe = [ - {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, - {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, + {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, ] mistune = [ {file = "mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4"}, @@ -917,8 +923,8 @@ packaging = [ {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] platformdirs = [ - {file = "platformdirs-2.5.0-py3-none-any.whl", hash = "sha256:30671902352e97b1eafd74ade8e4a694782bd3471685e78c32d0fdfd3aa7e7bb"}, - {file = "platformdirs-2.5.0.tar.gz", hash = "sha256:8ec11dfba28ecc0715eb5fb0147a87b1bf325f349f3da9aab2cd6b50b96b692b"}, + {file = "platformdirs-2.5.1-py3-none-any.whl", hash = "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"}, + {file = "platformdirs-2.5.1.tar.gz", hash = "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, @@ -941,12 +947,12 @@ pyparsing = [ {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, ] pytest = [ - {file = "pytest-7.0.1-py3-none-any.whl", hash = "sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db"}, - {file = "pytest-7.0.1.tar.gz", hash = "sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171"}, + {file = "pytest-7.1.1-py3-none-any.whl", hash = "sha256:92f723789a8fdd7180b6b06483874feca4c48a5c76968e03bb3e7f806a1869ea"}, + {file = "pytest-7.1.1.tar.gz", hash = "sha256:841132caef6b1ad17a9afde46dc4f6cfa59a05f9555aae5151f73bdf2820ca63"}, ] pytest-asyncio = [ - {file = "pytest-asyncio-0.18.1.tar.gz", hash = "sha256:c43fcdfea2335dd82ffe0f2774e40285ddfea78a8e81e56118d47b6a90fbb09e"}, - {file = "pytest_asyncio-0.18.1-py3-none-any.whl", hash = "sha256:c9ec48e8bbf5cc62755e18c4d8bc6907843ec9c5f4ac8f61464093baeba24a7e"}, + {file = "pytest-asyncio-0.18.2.tar.gz", hash = "sha256:fc8e4190f33fee7797cc7f1829f46a82c213f088af5d1bb5d4e454fe87e6cdc2"}, + {file = "pytest_asyncio-0.18.2-py3-none-any.whl", hash = "sha256:20db0bdd3d7581b2e11f5858a5d9541f2db9cd8c5853786f94ad273d466c8c6d"}, ] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, @@ -960,8 +966,8 @@ pytest-sugar = [ {file = "pytest-sugar-0.9.4.tar.gz", hash = "sha256:b1b2186b0a72aada6859bea2a5764145e3aaa2c1cfbb23c3a19b5f7b697563d3"}, ] pytz = [ - {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"}, - {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"}, + {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, + {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, ] pyyaml = [ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, @@ -1066,16 +1072,16 @@ tox = [ {file = "tox-3.24.5.tar.gz", hash = "sha256:67e0e32c90e278251fea45b696d0fef3879089ccbe979b0c556d35d5a70e2993"}, ] typing-extensions = [ - {file = "typing_extensions-4.1.0-py3-none-any.whl", hash = "sha256:c13180fbaa7cd97065a4915ceba012bdb31dc34743e63ddee16360161d358414"}, - {file = "typing_extensions-4.1.0.tar.gz", hash = "sha256:ba97c5143e5bb067b57793c726dd857b1671d4b02ced273ca0538e71ff009095"}, + {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, + {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, ] urllib3 = [ - {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, - {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"}, + {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, + {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, ] virtualenv = [ - {file = "virtualenv-20.13.1-py2.py3-none-any.whl", hash = "sha256:45e1d053cad4cd453181ae877c4ffc053546ae99e7dd049b9ff1d9be7491abf7"}, - {file = "virtualenv-20.13.1.tar.gz", hash = "sha256:e0621bcbf4160e4e1030f05065c8834b4e93f4fcc223255db2a823440aca9c14"}, + {file = "virtualenv-20.13.4-py2.py3-none-any.whl", hash = "sha256:c3e01300fb8495bc00ed70741f5271fc95fed067eb7106297be73d30879af60c"}, + {file = "virtualenv-20.13.4.tar.gz", hash = "sha256:ce8901d3bbf3b90393498187f2d56797a8a452fb2d0d7efc6fd837554d6f679c"}, ] voluptuous = [ {file = "voluptuous-0.12.2.tar.gz", hash = "sha256:4db1ac5079db9249820d49c891cb4660a6f8cae350491210abce741fabf56513"}, diff --git a/pyproject.toml b/pyproject.toml index 2a89a7ca1..04adb76f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.4.1" +version = "0.4.2" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["Your Name "] From 2b05751aa74de849513dbdca6c01e93c0662aa96 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Mar 2022 12:59:53 -1000 Subject: [PATCH 080/892] Fix test_deprecated_type stalling (#325) --- kasa/tests/test_cli.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 499b85520..ae4fa1759 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -111,13 +111,14 @@ def _generate_type_class_pairs(): @pytest.mark.parametrize("type_class", _generate_type_class_pairs()) -async def test_deprecated_type(dev, type_class): +async def test_deprecated_type(dev, type_class, mocker): """Make sure that using deprecated types yields a warning.""" type, cls = type_class if type == "dimmer": return runner = CliRunner() - res = await runner.invoke(cli, ["--host", "127.0.0.2", f"--{type}"]) + with mocker.patch("kasa.SmartDevice.update"): + res = await runner.invoke(cli, ["--host", "127.0.0.2", f"--{type}"]) assert "Using --bulb, --plug, --strip, and --lightstrip is deprecated" in res.output From a744af46abf98d709f4e88f3157e6c4eca655dea Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 5 Apr 2022 17:48:24 +0200 Subject: [PATCH 081/892] Update pre-commit deps to fix CI (#331) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0a05343d9..c99d152d9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,13 +10,13 @@ repos: - id: check-ast - repo: https://github.com/asottile/pyupgrade - rev: v2.31.0 + rev: v2.31.1 hooks: - id: pyupgrade args: ['--py37-plus'] - repo: https://github.com/python/black - rev: 22.1.0 + rev: 22.3.0 hooks: - id: black @@ -33,7 +33,7 @@ repos: additional_dependencies: [toml] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.931 + rev: v0.942 hooks: - id: mypy additional_dependencies: [types-click] From 766819a2a4a4a764854b905597c44cb60182e1e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Apr 2022 06:51:36 -1000 Subject: [PATCH 082/892] Ensure bulb state is restored when turning back on (#330) * Ensure state is restored when turning back on Fixes https://github.com/home-assistant/core/issues/69039 * Update kasa/tests/test_bulb.py Co-authored-by: Teemu R. Co-authored-by: Teemu R. --- kasa/smartbulb.py | 13 +++++++++++-- kasa/tests/test_bulb.py | 13 +++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index e5dcfbe90..c2d095395 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -34,6 +34,9 @@ class HSV(NamedTuple): r"KL430": ColorTempRange(2500, 9000), } + +NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"} + _LOGGER = logging.getLogger(__name__) @@ -211,8 +214,14 @@ async def set_light_state(self, state: Dict, *, transition: int = None) -> Dict: if "on_off" not in state: state["on_off"] = 1 - # This is necessary to allow turning on into a specific state - state["ignore_default"] = 1 + # If we are turning on without any color mode flags, + # we do not want to set ignore_default to ensure + # we restore the previous state. + if state["on_off"] and NON_COLOR_MODE_FLAGS.issuperset(state): + state["ignore_default"] = 0 + else: + # This is necessary to allow turning on into a specific state + state["ignore_default"] = 1 light_state = await self._query_helper( self.LIGHT_SERVICE, self.SET_LIGHT_METHOD, state diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index ea8a28cb8..032154424 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -232,3 +232,16 @@ async def test_non_dimmable(dev): assert dev.brightness == 0 with pytest.raises(SmartDeviceException): await dev.set_brightness(100) + + +@bulb +async def test_ignore_default_not_set_without_color_mode_change_turn_on(dev, mocker): + query_helper = mocker.patch("kasa.SmartBulb._query_helper") + # When turning back without settings, ignore default to restore the state + await dev.turn_on() + args, kwargs = query_helper.call_args_list[0] + assert args[2] == {"on_off": 1, "ignore_default": 0} + + await dev.turn_off() + args, kwargs = query_helper.call_args_list[1] + assert args[2] == {"on_off": 0, "ignore_default": 1} From 7b9e3aae8a98ae78b368ec7470d3217d0fd4866c Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 5 Apr 2022 19:02:00 +0200 Subject: [PATCH 083/892] Release 0.4.3 (#332) This release fixes an issue where the bulb state on led strips was not restored properly when turned back on. [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.2...0.4.3) **Fixed bugs:** - Divide by zero when HS300 powerstrip is discovered [\#292](https://github.com/python-kasa/python-kasa/issues/292) - Ensure bulb state is restored when turning back on [\#330](https://github.com/python-kasa/python-kasa/pull/330) (@bdraco) **Closed issues:** - KL420L5 controls [\#327](https://github.com/python-kasa/python-kasa/issues/327) **Merged pull requests:** - Update pre-commit hooks to fix black in CI [\#331](https://github.com/python-kasa/python-kasa/pull/331) (@rytilahti) - Fix test\_deprecated\_type stalling [\#325](https://github.com/python-kasa/python-kasa/pull/325) (@bdraco) --- CHANGELOG.md | 22 +++++++++++++++++++--- pyproject.toml | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c94510725..89058f1f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,24 @@ # Changelog -## [0.4.2](https://github.com/python-kasa/python-kasa/tree/0.4.2) (2022-03-21) +## [0.4.3](https://github.com/python-kasa/python-kasa/tree/0.4.3) (2022-04-05) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.2...0.4.3) + +**Fixed bugs:** + +- Divide by zero when HS300 powerstrip is discovered [\#292](https://github.com/python-kasa/python-kasa/issues/292) +- Ensure bulb state is restored when turning back on [\#330](https://github.com/python-kasa/python-kasa/pull/330) (@bdraco) + +**Closed issues:** + +- KL420L5 controls [\#327](https://github.com/python-kasa/python-kasa/issues/327) + +**Merged pull requests:** + +- Update pre-commit hooks to fix black in CI [\#331](https://github.com/python-kasa/python-kasa/pull/331) (@rytilahti) +- Fix test\_deprecated\_type stalling [\#325](https://github.com/python-kasa/python-kasa/pull/325) (@bdraco) -This is the last release prior restructuring the code to enable easier extendability by moving towards more modular architecture. -The most prominent change in this release is the support for effects on light strips. +## [0.4.2](https://github.com/python-kasa/python-kasa/tree/0.4.2) (2022-03-21) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.1...0.4.2) @@ -33,6 +48,7 @@ The most prominent change in this release is the support for effects on light st **Merged pull requests:** +- Release 0.4.2 [\#321](https://github.com/python-kasa/python-kasa/pull/321) (@rytilahti) - Add pyupgrade to CI runs [\#314](https://github.com/python-kasa/python-kasa/pull/314) (@rytilahti) - Depend on asyncclick \>= 8 [\#312](https://github.com/python-kasa/python-kasa/pull/312) (@rytilahti) - Guard emeter accesses to avoid keyerrors [\#304](https://github.com/python-kasa/python-kasa/pull/304) (@rytilahti) diff --git a/pyproject.toml b/pyproject.toml index 04adb76f8..9d7e084af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.4.2" +version = "0.4.3" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["Your Name "] From 3926f3224f1d9146e24fd53e8b14fa5565c4b9ce Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 7 Nov 2021 02:41:12 +0100 Subject: [PATCH 084/892] Add module support & query their information during update cycle (#243) * Add module support & modularize existing query This creates a base to expose more features on the supported devices. At the moment, the most visible change is that each update cycle gets information from all available modules: * Basic system info * Cloud (new) * Countdown (new) * Antitheft (new) * Schedule (new) * Time (existing, implements the time/timezone handling) * Emeter (existing, partially separated from smartdevice) * Fix imports * Fix linting * Use device host instead of alias in module repr * Add property to list available modules, print them in cli state report * usage: fix the get_realtime query * separate usage from schedule to avoid multi-inheritance * Fix module querying * Add is_supported property to modules --- kasa/cli.py | 29 +++- kasa/modules/__init__.py | 10 ++ kasa/modules/antitheft.py | 9 ++ kasa/modules/cloud.py | 50 ++++++ kasa/modules/countdown.py | 6 + kasa/modules/emeter.py | 20 +++ kasa/modules/module.py | 59 +++++++ kasa/modules/rulemodule.py | 83 ++++++++++ kasa/modules/schedule.py | 6 + kasa/modules/time.py | 34 +++++ kasa/modules/usage.py | 40 +++++ kasa/smartbulb.py | 11 +- kasa/smartdevice.py | 46 +++++- kasa/smartplug.py | 6 + kasa/smartstrip.py | 7 + poetry.lock | 305 +++++++++++++++++++++---------------- pyproject.toml | 3 +- 17 files changed, 587 insertions(+), 137 deletions(-) create mode 100644 kasa/modules/__init__.py create mode 100644 kasa/modules/antitheft.py create mode 100644 kasa/modules/cloud.py create mode 100644 kasa/modules/countdown.py create mode 100644 kasa/modules/emeter.py create mode 100644 kasa/modules/module.py create mode 100644 kasa/modules/rulemodule.py create mode 100644 kasa/modules/schedule.py create mode 100644 kasa/modules/time.py create mode 100644 kasa/modules/usage.py diff --git a/kasa/cli.py b/kasa/cli.py index 696dd9aab..f2fd66d12 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -221,7 +221,7 @@ async def state(ctx, dev: SmartDevice): click.echo() click.echo(click.style("\t== Generic information ==", bold=True)) - click.echo(f"\tTime: {await dev.get_time()}") + click.echo(f"\tTime: {dev.time} (tz: {dev.timezone}") click.echo(f"\tHardware: {dev.hw_info['hw_ver']}") click.echo(f"\tSoftware: {dev.hw_info['sw_ver']}") click.echo(f"\tMAC (rssi): {dev.mac} ({dev.rssi})") @@ -236,6 +236,13 @@ async def state(ctx, dev: SmartDevice): emeter_status = dev.emeter_realtime click.echo(f"\t{emeter_status}") + click.echo(click.style("\n\t== Modules ==", bold=True)) + for module in dev.modules.values(): + if module.is_supported: + click.echo(click.style(f"\t+ {module}", fg="green")) + else: + click.echo(click.style(f"\t- {module}", fg="red")) + @cli.command() @pass_dev @@ -430,7 +437,7 @@ async def led(dev, state): @pass_dev async def time(dev): """Get the device time.""" - res = await dev.get_time() + res = dev.time click.echo(f"Current time: {res}") return res @@ -488,5 +495,23 @@ async def reboot(plug, delay): return await plug.reboot(delay) +@cli.group() +@pass_dev +async def schedule(dev): + """Scheduling commands.""" + + +@schedule.command(name="list") +@pass_dev +@click.argument("type", default="schedule") +def _schedule_list(dev, type): + """Return the list of schedule actions for the given type.""" + sched = dev.modules[type] + for rule in sched.rules: + print(rule) + else: + click.echo(f"No rules of type {type}") + + if __name__ == "__main__": cli() diff --git a/kasa/modules/__init__.py b/kasa/modules/__init__.py new file mode 100644 index 000000000..dd9d1072f --- /dev/null +++ b/kasa/modules/__init__.py @@ -0,0 +1,10 @@ +# flake8: noqa +from .antitheft import Antitheft +from .cloud import Cloud +from .countdown import Countdown +from .emeter import Emeter +from .module import Module +from .rulemodule import Rule, RuleModule +from .schedule import Schedule +from .time import Time +from .usage import Usage diff --git a/kasa/modules/antitheft.py b/kasa/modules/antitheft.py new file mode 100644 index 000000000..c885a70c2 --- /dev/null +++ b/kasa/modules/antitheft.py @@ -0,0 +1,9 @@ +"""Implementation of the antitheft module.""" +from .rulemodule import RuleModule + + +class Antitheft(RuleModule): + """Implementation of the antitheft module. + + This shares the functionality among other rule-based modules. + """ diff --git a/kasa/modules/cloud.py b/kasa/modules/cloud.py new file mode 100644 index 000000000..32d3a26d0 --- /dev/null +++ b/kasa/modules/cloud.py @@ -0,0 +1,50 @@ +"""Cloud module implementation.""" +from pydantic import BaseModel + +from .module import Module + + +class CloudInfo(BaseModel): + """Container for cloud settings.""" + + binded: bool + cld_connection: int + fwDlPage: str + fwNotifyType: int + illegalType: int + server: str + stopConnect: int + tcspInfo: str + tcspStatus: int + username: str + + +class Cloud(Module): + """Module implementing support for cloud services.""" + + def query(self): + """Request cloud connectivity info.""" + return self.query_for_command("get_info") + + @property + def info(self) -> CloudInfo: + """Return information about the cloud connectivity.""" + return CloudInfo.parse_obj(self.data["get_info"]) + + def get_available_firmwares(self): + """Return list of available firmwares.""" + return self.query_for_command("get_intl_fw_list") + + def set_server(self, url: str): + """Set the update server URL.""" + return self.query_for_command("set_server_url", {"server": url}) + + def connect(self, username: str, password: str): + """Login to the cloud using given information.""" + return self.query_for_command( + "bind", {"username": username, "password": password} + ) + + def disconnect(self): + """Disconnect from the cloud.""" + return self.query_for_command("unbind") diff --git a/kasa/modules/countdown.py b/kasa/modules/countdown.py new file mode 100644 index 000000000..9f3e59c16 --- /dev/null +++ b/kasa/modules/countdown.py @@ -0,0 +1,6 @@ +"""Implementation for the countdown timer.""" +from .rulemodule import RuleModule + + +class Countdown(RuleModule): + """Implementation of countdown module.""" diff --git a/kasa/modules/emeter.py b/kasa/modules/emeter.py new file mode 100644 index 000000000..f8144e39e --- /dev/null +++ b/kasa/modules/emeter.py @@ -0,0 +1,20 @@ +"""Implementation of the emeter module.""" +from ..emeterstatus import EmeterStatus +from .usage import Usage + + +class Emeter(Usage): + """Emeter module.""" + + def query(self): + """Prepare query for emeter data.""" + return self._device._create_emeter_request() + + @property # type: ignore + def realtime(self) -> EmeterStatus: + """Return current energy readings.""" + return EmeterStatus(self.data["get_realtime"]) + + async def erase_stats(self): + """Erase all stats.""" + return await self.call("erase_emeter_stat") diff --git a/kasa/modules/module.py b/kasa/modules/module.py new file mode 100644 index 000000000..1f7f3829f --- /dev/null +++ b/kasa/modules/module.py @@ -0,0 +1,59 @@ +"""Base class for all module implementations.""" +import collections +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from kasa import SmartDevice + + +# TODO: This is used for query construcing +def merge(d, u): + """Update dict recursively.""" + for k, v in u.items(): + if isinstance(v, collections.abc.Mapping): + d[k] = merge(d.get(k, {}), v) + else: + d[k] = v + return d + + +class Module(ABC): + """Base class implemention for all modules. + + The base classes should implement `query` to return the query they want to be + executed during the regular update cycle. + """ + + def __init__(self, device: "SmartDevice", module: str): + self._device: "SmartDevice" = device + self._module = module + + @abstractmethod + def query(self): + """Query to execute during the update cycle. + + The inheriting modules implement this to include their wanted + queries to the query that gets executed when Device.update() gets called. + """ + + @property + def data(self): + """Return the module specific raw data from the last update.""" + return self._device._last_update[self._module] + + @property + def is_supported(self) -> bool: + """Return whether the module is supported by the device.""" + return "err_code" not in self.data + + def call(self, method, params=None): + """Call the given method with the given parameters.""" + return self._device._query_helper(self._module, method, params) + + def query_for_command(self, query, params=None): + """Create a request object for the given parameters.""" + return self._device._create_request(self._module, query, params) + + def __repr__(self) -> str: + return f"" diff --git a/kasa/modules/rulemodule.py b/kasa/modules/rulemodule.py new file mode 100644 index 000000000..e73b2d03e --- /dev/null +++ b/kasa/modules/rulemodule.py @@ -0,0 +1,83 @@ +"""Base implementation for all rule-based modules.""" +import logging +from enum import Enum +from typing import Dict, List, Optional + +from pydantic import BaseModel + +from .module import Module, merge + + +class Action(Enum): + """Action to perform.""" + + Disabled = -1 + TurnOff = 0 + TurnOn = 1 + Unknown = 2 + + +class TimeOption(Enum): + """Time when the action is executed.""" + + Disabled = -1 + Enabled = 0 + AtSunrise = 1 + AtSunset = 2 + + +class Rule(BaseModel): + """Representation of a rule.""" + + id: str + name: str + enable: bool + wday: List[int] + repeat: bool + + # start action + sact: Optional[Action] + stime_opt: TimeOption + smin: int + + eact: Optional[Action] + etime_opt: TimeOption + emin: int + + # Only on bulbs + s_light: Optional[Dict] + + +_LOGGER = logging.getLogger(__name__) + + +class RuleModule(Module): + """Base class for rule-based modules, such as countdown and antitheft.""" + + def query(self): + """Prepare the query for rules.""" + q = self.query_for_command("get_rules") + return merge(q, self.query_for_command("get_next_action")) + + @property + def rules(self) -> List[Rule]: + """Return the list of rules for the service.""" + try: + return [ + Rule.parse_obj(rule) for rule in self.data["get_rules"]["rule_list"] + ] + except Exception as ex: + _LOGGER.error("Unable to read rule list: %s (data: %s)", ex, self.data) + return [] + + async def set_enabled(self, state: bool): + """Enable or disable the service.""" + return await self.call("set_overall_enable", state) + + async def delete_rule(self, rule: Rule): + """Delete the given rule.""" + return await self.call("delete_rule", {"id": rule.id}) + + async def delete_all_rules(self): + """Delete all rules.""" + return await self.call("delete_all_rules") diff --git a/kasa/modules/schedule.py b/kasa/modules/schedule.py new file mode 100644 index 000000000..62371692b --- /dev/null +++ b/kasa/modules/schedule.py @@ -0,0 +1,6 @@ +"""Schedule module implementation.""" +from .rulemodule import RuleModule + + +class Schedule(RuleModule): + """Implements the scheduling interface.""" diff --git a/kasa/modules/time.py b/kasa/modules/time.py new file mode 100644 index 000000000..0bd3f1714 --- /dev/null +++ b/kasa/modules/time.py @@ -0,0 +1,34 @@ +"""Provides the current time and timezone information.""" +from datetime import datetime + +from .module import Module, merge + + +class Time(Module): + """Implements the timezone settings.""" + + def query(self): + """Request time and timezone.""" + q = self.query_for_command("get_time") + + merge(q, self.query_for_command("get_timezone")) + return q + + @property + def time(self) -> datetime: + """Return current device time.""" + res = self.data["get_time"] + return datetime( + res["year"], + res["month"], + res["mday"], + res["hour"], + res["min"], + res["sec"], + ) + + @property + def timezone(self): + """Return current timezone.""" + res = self.data["get_timezone"] + return res diff --git a/kasa/modules/usage.py b/kasa/modules/usage.py new file mode 100644 index 000000000..2a5b6dc0b --- /dev/null +++ b/kasa/modules/usage.py @@ -0,0 +1,40 @@ +"""Implementation of the usage interface.""" +from datetime import datetime + +from .module import Module, merge + + +class Usage(Module): + """Baseclass for emeter/usage interfaces.""" + + def query(self): + """Return the base query.""" + year = datetime.now().year + month = datetime.now().month + + req = self.query_for_command("get_realtime") + req = merge( + req, self.query_for_command("get_daystat", {"year": year, "month": month}) + ) + req = merge(req, self.query_for_command("get_monthstat", {"year": year})) + req = merge(req, self.query_for_command("get_next_action")) + + return req + + async def get_daystat(self, year, month): + """Return stats for the current day.""" + if year is None: + year = datetime.now().year + if month is None: + month = datetime.now().month + return await self.call("get_daystat", {"year": year, "month": month}) + + async def get_monthstat(self, year): + """Return stats for the current month.""" + if year is None: + year = datetime.now().year + return await self.call("get_monthstat", {"year": year}) + + async def erase_stats(self): + """Erase all stats.""" + return await self.call("erase_runtime_stat") diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index c2d095395..f941dcf1f 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -3,6 +3,7 @@ import re from typing import Any, Dict, NamedTuple, cast +from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update @@ -109,13 +110,19 @@ class SmartBulb(SmartDevice): """ LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice" - TIME_SERVICE = "smartlife.iot.common.timesetting" SET_LIGHT_METHOD = "transition_light_state" + emeter_type = "smartlife.iot.common.emeter" def __init__(self, host: str) -> None: super().__init__(host=host) - self.emeter_type = "smartlife.iot.common.emeter" self._device_type = DeviceType.Bulb + self.add_module("schedule", Schedule(self, "smartlife.iot.common.schedule")) + self.add_module("usage", Usage(self, "smartlife.iot.common.schedule")) + self.add_module("antitheft", Antitheft(self, "smartlife.iot.common.anti_theft")) + self.add_module("time", Time(self, "smartlife.iot.common.timesetting")) + self.add_module("emeter", Emeter(self, self.emeter_type)) + self.add_module("countdown", Countdown(self, "countdown")) + self.add_module("cloud", Cloud(self, "smartlife.iot.common.cloud")) @property # type: ignore @requires_update diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 7a66a864d..5e6877417 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -22,6 +22,7 @@ from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException +from .modules import Emeter, Module from .protocol import TPLinkSmartHomeProtocol _LOGGER = logging.getLogger(__name__) @@ -186,6 +187,7 @@ class SmartDevice: """ TIME_SERVICE = "time" + emeter_type = "emeter" def __init__(self, host: str) -> None: """Create a new SmartDevice instance. @@ -195,7 +197,6 @@ def __init__(self, host: str) -> None: self.host = host self.protocol = TPLinkSmartHomeProtocol(host) - self.emeter_type = "emeter" _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) self._device_type = DeviceType.Unknown # TODO: typing Any is just as using Optional[Dict] would require separate checks in @@ -203,9 +204,21 @@ def __init__(self, host: str) -> None: # are not accessed incorrectly. self._last_update: Any = None self._sys_info: Any = None # TODO: this is here to avoid changing tests + self.modules: Dict[str, Any] = {} self.children: List["SmartDevice"] = [] + def add_module(self, name: str, module: Module): + """Register a module.""" + if name in self.modules: + _LOGGER.debug("Module %s already registered, ignoring..." % name) + return + + assert name not in self.modules + + _LOGGER.debug("Adding module %s", module) + self.modules[name] = module + def _create_request( self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None ): @@ -268,6 +281,14 @@ def features(self) -> Set[str]: _LOGGER.debug("Device does not have feature information") return set() + @property # type: ignore + @requires_update + def supported_modules(self) -> List[str]: + """Return a set of modules supported by the device.""" + # TODO: this should rather be called `features`, but we don't want to break + # the API now. Maybe just deprecate it and point the users to use this? + return list(self.modules.keys()) + @property # type: ignore @requires_update def has_emeter(self) -> bool: @@ -303,7 +324,12 @@ async def update(self, update_children: bool = True): _LOGGER.debug( "The device has emeter, querying its information along sysinfo" ) - req.update(self._create_emeter_request()) + self.add_module("emeter", Emeter(self, self.emeter_type)) + + for module in self.modules.values(): + q = module.query() + _LOGGER.debug("Adding query for %s: %s", module, q) + req = merge(req, module.query()) self._last_update = await self.protocol.query(req) self._sys_info = self._last_update["system"]["get_sysinfo"] @@ -337,6 +363,18 @@ async def set_alias(self, alias: str) -> None: """Set the device name (alias).""" return await self._query_helper("system", "set_dev_alias", {"alias": alias}) + @property # type: ignore + @requires_update + def time(self) -> datetime: + """Return current time from the device.""" + return self.modules["time"].time + + @property # type: ignore + @requires_update + def timezone(self) -> Dict: + """Return the current timezone.""" + return self.modules["time"].timezone + async def get_time(self) -> Optional[datetime]: """Return current time from the device, if available.""" try: @@ -435,7 +473,7 @@ async def set_mac(self, mac): def emeter_realtime(self) -> EmeterStatus: """Return current energy readings.""" self._verify_emeter() - return EmeterStatus(self._last_update[self.emeter_type]["get_realtime"]) + return EmeterStatus(self.modules["emeter"].realtime) async def get_emeter_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" @@ -555,7 +593,7 @@ async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: async def erase_emeter_stats(self) -> Dict: """Erase energy meter statistics.""" self._verify_emeter() - return await self._query_helper(self.emeter_type, "erase_emeter_stat", None) + return await self.modules["emeter"].erase_stats() @requires_update async def current_consumption(self) -> float: diff --git a/kasa/smartplug.py b/kasa/smartplug.py index d23bc9396..58144b58a 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -2,6 +2,7 @@ import logging from typing import Any, Dict +from kasa.modules import Antitheft, Cloud, Schedule, Time, Usage from kasa.smartdevice import DeviceType, SmartDevice, requires_update _LOGGER = logging.getLogger(__name__) @@ -40,6 +41,11 @@ def __init__(self, host: str) -> None: super().__init__(host) self.emeter_type = "emeter" self._device_type = DeviceType.Plug + self.add_module("schedule", Schedule(self, "schedule")) + self.add_module("usage", Usage(self, "schedule")) + self.add_module("antitheft", Antitheft(self, "anti_theft")) + self.add_module("time", Time(self, "time")) + self.add_module("cloud", Cloud(self, "cnCloud")) @property # type: ignore @requires_update diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 391381bcc..bbdf2a3fb 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -13,6 +13,8 @@ ) from kasa.smartplug import SmartPlug +from .modules import Antitheft, Countdown, Schedule, Time, Usage + _LOGGER = logging.getLogger(__name__) @@ -80,6 +82,11 @@ def __init__(self, host: str) -> None: super().__init__(host=host) self.emeter_type = "emeter" self._device_type = DeviceType.Strip + self.add_module("antitheft", Antitheft(self, "anti_theft")) + self.add_module("schedule", Schedule(self, "schedule")) + self.add_module("usage", Usage(self, "schedule")) + self.add_module("time", Time(self, "time")) + self.add_module("countdown", Countdown(self, "countdown")) @property # type: ignore @requires_update diff --git a/poetry.lock b/poetry.lock index d3af30c3f..3ce2e28bf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -87,7 +87,7 @@ python-versions = ">=3.6.1" [[package]] name = "charset-normalizer" -version = "2.0.12" +version = "2.0.10" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false @@ -118,7 +118,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "coverage" -version = "6.3.2" +version = "6.3" description = "Code coverage measurement for Python" category = "dev" optional = false @@ -148,7 +148,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "filelock" -version = "3.6.0" +version = "3.4.2" description = "A platform independent file lock." category = "dev" optional = false @@ -160,7 +160,7 @@ testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-co [[package]] name = "identify" -version = "2.4.12" +version = "2.4.6" description = "File identification library for Python" category = "dev" optional = false @@ -187,7 +187,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.11.3" +version = "4.10.1" description = "Read metadata from Python packages" category = "main" optional = false @@ -198,9 +198,9 @@ typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] perf = ["ipython"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] [[package]] name = "iniconfig" @@ -238,11 +238,11 @@ mistune = "*" [[package]] name = "markupsafe" -version = "2.1.1" +version = "2.0.1" description = "Safely add untrusted strings to HTML/XML markup." category = "main" optional = true -python-versions = ">=3.7" +python-versions = ">=3.6" [[package]] name = "mistune" @@ -273,7 +273,7 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "platformdirs" -version = "2.5.1" +version = "2.4.1" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false @@ -323,6 +323,21 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "pydantic" +version = "1.9.0" +description = "Data validation and settings management using python 3.6 type hinting" +category = "main" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +typing-extensions = ">=3.7.4.3" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + [[package]] name = "pygments" version = "2.11.2" @@ -344,11 +359,11 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "7.1.1" +version = "6.2.5" description = "pytest: simple powerful testing with Python" category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.6" [package.dependencies] atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} @@ -359,14 +374,14 @@ iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" py = ">=1.8.2" -tomli = ">=1.0.0" +toml = "*" [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.18.2" +version = "0.17.2" description = "Pytest support for asyncio" category = "dev" optional = false @@ -374,7 +389,7 @@ python-versions = ">=3.7" [package.dependencies] pytest = ">=6.1.0" -typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.8\""} [package.extras] testing = ["coverage (==6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (==0.931)"] @@ -424,7 +439,7 @@ termcolor = ">=1.1.0" [[package]] name = "pytz" -version = "2022.1" +version = "2021.3" description = "World timezone definitions, modern and historical" category = "main" optional = true @@ -626,7 +641,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "tomli" -version = "2.0.1" +version = "2.0.0" description = "A lil' TOML parser" category = "dev" optional = false @@ -657,7 +672,7 @@ testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pytest (>=4.0.0)", "pytes [[package]] name = "typing-extensions" -version = "4.1.1" +version = "4.0.1" description = "Backported and Experimental Type Hints for Python 3.6+" category = "main" optional = false @@ -665,20 +680,20 @@ python-versions = ">=3.6" [[package]] name = "urllib3" -version = "1.26.9" +version = "1.26.8" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] -brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +brotli = ["brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.13.4" +version = "20.13.0" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -739,7 +754,7 @@ docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programou [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "9c4aaea750c8c2cb4ed6d37c53ec3884a10d698ceb77716c33b04eed12a08506" +content-hash = "eb7bb96b826fec8ad7207e838f02a68333d806f5908f3e07675cbc151a165b25" [metadata.files] alabaster = [ @@ -774,8 +789,8 @@ cfgv = [ {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] charset-normalizer = [ - {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, - {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, + {file = "charset-normalizer-2.0.10.tar.gz", hash = "sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd"}, + {file = "charset_normalizer-2.0.10-py3-none-any.whl", hash = "sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455"}, ] codecov = [ {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"}, @@ -787,47 +802,50 @@ colorama = [ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] coverage = [ - {file = "coverage-6.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf"}, - {file = "coverage-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac"}, - {file = "coverage-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1"}, - {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4"}, - {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903"}, - {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c"}, - {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f"}, - {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05"}, - {file = "coverage-6.3.2-cp310-cp310-win32.whl", hash = "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39"}, - {file = "coverage-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1"}, - {file = "coverage-6.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa"}, - {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518"}, - {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7"}, - {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6"}, - {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad"}, - {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359"}, - {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4"}, - {file = "coverage-6.3.2-cp37-cp37m-win32.whl", hash = "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca"}, - {file = "coverage-6.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3"}, - {file = "coverage-6.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d"}, - {file = "coverage-6.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059"}, - {file = "coverage-6.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512"}, - {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca"}, - {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d"}, - {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0"}, - {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6"}, - {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"}, - {file = "coverage-6.3.2-cp38-cp38-win32.whl", hash = "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e"}, - {file = "coverage-6.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1"}, - {file = "coverage-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620"}, - {file = "coverage-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d"}, - {file = "coverage-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536"}, - {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7"}, - {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2"}, - {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4"}, - {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69"}, - {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684"}, - {file = "coverage-6.3.2-cp39-cp39-win32.whl", hash = "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4"}, - {file = "coverage-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92"}, - {file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"}, - {file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"}, + {file = "coverage-6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e8071e7d9ba9f457fc674afc3de054450be2c9b195c470147fbbc082468d8ff7"}, + {file = "coverage-6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86c91c511853dfda81c2cf2360502cb72783f4b7cebabef27869f00cbe1db07d"}, + {file = "coverage-6.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4ce3b647bd1792d4394f5690d9df6dc035b00bcdbc5595099c01282a59ae01"}, + {file = "coverage-6.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a491e159294d756e7fc8462f98175e2d2225e4dbe062cca7d3e0d5a75ba6260"}, + {file = "coverage-6.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d008e0f67ac800b0ca04d7914b8501312c8c6c00ad8c7ba17754609fae1231a"}, + {file = "coverage-6.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4578728c36de2801c1deb1c6b760d31883e62e33f33c7ba8f982e609dc95167d"}, + {file = "coverage-6.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7ee317486593193e066fc5e98ac0ce712178c21529a85c07b7cb978171f25d53"}, + {file = "coverage-6.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2bc85664b06ba42d14bb74d6ddf19d8bfc520cb660561d2d9ce5786ae72f71b5"}, + {file = "coverage-6.3-cp310-cp310-win32.whl", hash = "sha256:27a94db5dc098c25048b0aca155f5fac674f2cf1b1736c5272ba28ead2fc267e"}, + {file = "coverage-6.3-cp310-cp310-win_amd64.whl", hash = "sha256:bde4aeabc0d1b2e52c4036c54440b1ad05beeca8113f47aceb4998bb7471e2c2"}, + {file = "coverage-6.3-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:509c68c3e2015022aeda03b003dd68fa19987cdcf64e9d4edc98db41cfc45d30"}, + {file = "coverage-6.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e4ff163602c5c77e7bb4ea81ba5d3b793b4419f8acd296aae149370902cf4e92"}, + {file = "coverage-6.3-cp311-cp311-win_amd64.whl", hash = "sha256:d1675db48490e5fa0b300f6329ecb8a9a37c29b9ab64fa9c964d34111788ca2d"}, + {file = "coverage-6.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7eed8459a2b81848cafb3280b39d7d49950d5f98e403677941c752e7e7ee47cb"}, + {file = "coverage-6.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b4285fde5286b946835a1a53bba3ad41ef74285ba9e8013e14b5ea93deaeafc"}, + {file = "coverage-6.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4748349734110fd32d46ff8897b561e6300d8989a494ad5a0a2e4f0ca974fc7"}, + {file = "coverage-6.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:823f9325283dc9565ba0aa2d240471a93ca8999861779b2b6c7aded45b58ee0f"}, + {file = "coverage-6.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:fff16a30fdf57b214778eff86391301c4509e327a65b877862f7c929f10a4253"}, + {file = "coverage-6.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:da1a428bdbe71f9a8c270c7baab29e9552ac9d0e0cba5e7e9a4c9ee6465d258d"}, + {file = "coverage-6.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7d82c610a2e10372e128023c5baf9ce3d270f3029fe7274ff5bc2897c68f1318"}, + {file = "coverage-6.3-cp37-cp37m-win32.whl", hash = "sha256:11e61c5548ecf74ea1f8b059730b049871f0e32b74f88bd0d670c20c819ad749"}, + {file = "coverage-6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:8e0c3525b1a182c8ffc9bca7e56b521e0c2b8b3e82f033c8e16d6d721f1b54d6"}, + {file = "coverage-6.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a189036c50dcd56100746139a459f0d27540fef95b09aba03e786540b8feaa5f"}, + {file = "coverage-6.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:32168001f33025fd756884d56d01adebb34e6c8c0b3395ca8584cdcee9c7c9d2"}, + {file = "coverage-6.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5d79c9af3f410a2b5acad91258b4ae179ee9c83897eb9de69151b179b0227f5"}, + {file = "coverage-6.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:85c5fc9029043cf8b07f73fbb0a7ab6d3b717510c3b5642b77058ea55d7cacde"}, + {file = "coverage-6.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7596aa2f2b8fa5604129cfc9a27ad9beec0a96f18078cb424d029fdd707468d"}, + {file = "coverage-6.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ce443a3e6df90d692c38762f108fc4c88314bf477689f04de76b3f252e7a351c"}, + {file = "coverage-6.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:012157499ec4f135fc36cd2177e3d1a1840af9b236cbe80e9a5ccfc83d912a69"}, + {file = "coverage-6.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0a34d313105cdd0d3644c56df2d743fe467270d6ab93b5d4a347eb9fec8924d6"}, + {file = "coverage-6.3-cp38-cp38-win32.whl", hash = "sha256:6e78b1e25e5c5695dea012be473e442f7094d066925604be20b30713dbd47f89"}, + {file = "coverage-6.3-cp38-cp38-win_amd64.whl", hash = "sha256:433b99f7b0613bdcdc0b00cc3d39ed6d756797e3b078d2c43f8a38288520aec6"}, + {file = "coverage-6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ed3244b415725f08ca3bdf02ed681089fd95e9465099a21c8e2d9c5d6ca2606"}, + {file = "coverage-6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab4fc4b866b279740e0d917402f0e9a08683e002f43fa408e9655818ed392196"}, + {file = "coverage-6.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8582e9280f8d0f38114fe95a92ae8d0790b56b099d728cc4f8a2e14b1c4a18c"}, + {file = "coverage-6.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c72bb4679283c6737f452eeb9b2a0e570acaef2197ad255fb20162adc80bea76"}, + {file = "coverage-6.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca29c352389ea27a24c79acd117abdd8a865c6eb01576b6f0990cd9a4e9c9f48"}, + {file = "coverage-6.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:152cc2624381df4e4e604e21bd8e95eb8059535f7b768c1fb8b8ae0b26f47ab0"}, + {file = "coverage-6.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:51372e24b1f7143ee2df6b45cff6a721f3abe93b1e506196f3ffa4155c2497f7"}, + {file = "coverage-6.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:72d9d186508325a456475dd05b1756f9a204c7086b07fffb227ef8cee03b1dc2"}, + {file = "coverage-6.3-cp39-cp39-win32.whl", hash = "sha256:649df3641eb351cdfd0d5533c92fc9df507b6b2bf48a7ef8c71ab63cbc7b5c3c"}, + {file = "coverage-6.3-cp39-cp39-win_amd64.whl", hash = "sha256:e67ccd53da5958ea1ec833a160b96357f90859c220a00150de011b787c27b98d"}, + {file = "coverage-6.3-pp36.pp37.pp38-none-any.whl", hash = "sha256:27ac7cb84538e278e07569ceaaa6f807a029dc194b1c819a9820b9bb5dbf63ab"}, + {file = "coverage-6.3.tar.gz", hash = "sha256:987a84ff98a309994ca77ed3cc4b92424f824278e48e4bf7d1bb79a63cfe2099"}, ] distlib = [ {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, @@ -838,12 +856,12 @@ docutils = [ {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, ] filelock = [ - {file = "filelock-3.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"}, - {file = "filelock-3.6.0.tar.gz", hash = "sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85"}, + {file = "filelock-3.4.2-py3-none-any.whl", hash = "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"}, + {file = "filelock-3.4.2.tar.gz", hash = "sha256:38b4f4c989f9d06d44524df1b24bd19e167d851f19b50bf3e3559952dddc5b80"}, ] identify = [ - {file = "identify-2.4.12-py2.py3-none-any.whl", hash = "sha256:5f06b14366bd1facb88b00540a1de05b69b310cbc2654db3c7e07fa3a4339323"}, - {file = "identify-2.4.12.tar.gz", hash = "sha256:3f3244a559290e7d3deb9e9adc7b33594c1bc85a9dd82e0f1be519bf12a1ec17"}, + {file = "identify-2.4.6-py2.py3-none-any.whl", hash = "sha256:cf06b1639e0dca0c184b1504d8b73448c99a68e004a80524c7923b95f7b6837c"}, + {file = "identify-2.4.6.tar.gz", hash = "sha256:233679e3f61a02015d4293dbccf16aa0e4996f868bd114688b8c124f18826706"}, ] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, @@ -854,8 +872,8 @@ imagesize = [ {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.11.3-py3-none-any.whl", hash = "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6"}, - {file = "importlib_metadata-4.11.3.tar.gz", hash = "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539"}, + {file = "importlib_metadata-4.10.1-py3-none-any.whl", hash = "sha256:899e2a40a8c4a1aec681feef45733de8a6c58f3f6a0dbed2eb6574b4387a77b6"}, + {file = "importlib_metadata-4.10.1.tar.gz", hash = "sha256:951f0d8a5b7260e9db5e41d429285b5f451e928479f19d80818878527d36e95e"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -869,46 +887,40 @@ m2r = [ {file = "m2r-0.2.1.tar.gz", hash = "sha256:bf90bad66cda1164b17e5ba4a037806d2443f2a4d5ddc9f6a5554a0322aaed99"}, ] markupsafe = [ - {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, - {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, + {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, ] mistune = [ {file = "mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4"}, @@ -923,8 +935,8 @@ packaging = [ {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] platformdirs = [ - {file = "platformdirs-2.5.1-py3-none-any.whl", hash = "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"}, - {file = "platformdirs-2.5.1.tar.gz", hash = "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d"}, + {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"}, + {file = "platformdirs-2.4.1.tar.gz", hash = "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, @@ -938,6 +950,43 @@ py = [ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] +pydantic = [ + {file = "pydantic-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"}, + {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c"}, + {file = "pydantic-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398"}, + {file = "pydantic-1.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed"}, + {file = "pydantic-1.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1"}, + {file = "pydantic-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6"}, + {file = "pydantic-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034"}, + {file = "pydantic-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721"}, + {file = "pydantic-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16"}, + {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"}, + {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"}, +] pygments = [ {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, @@ -947,12 +996,12 @@ pyparsing = [ {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, ] pytest = [ - {file = "pytest-7.1.1-py3-none-any.whl", hash = "sha256:92f723789a8fdd7180b6b06483874feca4c48a5c76968e03bb3e7f806a1869ea"}, - {file = "pytest-7.1.1.tar.gz", hash = "sha256:841132caef6b1ad17a9afde46dc4f6cfa59a05f9555aae5151f73bdf2820ca63"}, + {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, + {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, ] pytest-asyncio = [ - {file = "pytest-asyncio-0.18.2.tar.gz", hash = "sha256:fc8e4190f33fee7797cc7f1829f46a82c213f088af5d1bb5d4e454fe87e6cdc2"}, - {file = "pytest_asyncio-0.18.2-py3-none-any.whl", hash = "sha256:20db0bdd3d7581b2e11f5858a5d9541f2db9cd8c5853786f94ad273d466c8c6d"}, + {file = "pytest-asyncio-0.17.2.tar.gz", hash = "sha256:6d895b02432c028e6957d25fc936494e78c6305736e785d9fee408b1efbc7ff4"}, + {file = "pytest_asyncio-0.17.2-py3-none-any.whl", hash = "sha256:e0fe5dbea40516b661ef1bcfe0bd9461c2847c4ef4bb40012324f2454fb7d56d"}, ] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, @@ -966,8 +1015,8 @@ pytest-sugar = [ {file = "pytest-sugar-0.9.4.tar.gz", hash = "sha256:b1b2186b0a72aada6859bea2a5764145e3aaa2c1cfbb23c3a19b5f7b697563d3"}, ] pytz = [ - {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, - {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, + {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"}, + {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"}, ] pyyaml = [ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, @@ -1064,24 +1113,24 @@ toml = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] tomli = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, + {file = "tomli-2.0.0-py3-none-any.whl", hash = "sha256:b5bde28da1fed24b9bd1d4d2b8cba62300bfb4ec9a6187a957e8ddb9434c5224"}, + {file = "tomli-2.0.0.tar.gz", hash = "sha256:c292c34f58502a1eb2bbb9f5bbc9a5ebc37bee10ffb8c2d6bbdfa8eb13cc14e1"}, ] tox = [ {file = "tox-3.24.5-py2.py3-none-any.whl", hash = "sha256:be3362472a33094bce26727f5f771ca0facf6dafa217f65875314e9a6600c95c"}, {file = "tox-3.24.5.tar.gz", hash = "sha256:67e0e32c90e278251fea45b696d0fef3879089ccbe979b0c556d35d5a70e2993"}, ] typing-extensions = [ - {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, - {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, + {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, + {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, ] urllib3 = [ - {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, - {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, + {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, + {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"}, ] virtualenv = [ - {file = "virtualenv-20.13.4-py2.py3-none-any.whl", hash = "sha256:c3e01300fb8495bc00ed70741f5271fc95fed067eb7106297be73d30879af60c"}, - {file = "virtualenv-20.13.4.tar.gz", hash = "sha256:ce8901d3bbf3b90393498187f2d56797a8a452fb2d0d7efc6fd837554d6f679c"}, + {file = "virtualenv-20.13.0-py2.py3-none-any.whl", hash = "sha256:339f16c4a86b44240ba7223d0f93a7887c3ca04b5f9c8129da7958447d079b09"}, + {file = "virtualenv-20.13.0.tar.gz", hash = "sha256:d8458cf8d59d0ea495ad9b34c2599487f8a7772d796f9910858376d1600dd2dd"}, ] voluptuous = [ {file = "voluptuous-0.12.2.tar.gz", hash = "sha256:4db1ac5079db9249820d49c891cb4660a6f8cae350491210abce741fabf56513"}, diff --git a/pyproject.toml b/pyproject.toml index 9d7e084af..ac5618a4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,8 @@ kasa = "kasa.cli:cli" python = "^3.7" anyio = "*" # see https://github.com/python-trio/asyncclick/issues/18 importlib-metadata = "*" -asyncclick = ">=8" +asyncclick = ">=7" +pydantic = "^1" # required only for docs sphinx = { version = "^3", optional = true } From e3588047fc3edb1dd7c157280af4cbecffaa8387 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 19 Nov 2021 16:41:49 +0100 Subject: [PATCH 085/892] Improve usage module, consolidate API with emeter (#249) * Consolidate API for both emeter&usage modules * Add new cli command 'usage' to query usage --- kasa/cli.py | 39 +++++++++++++++++++++++++++- kasa/modules/emeter.py | 59 ++++++++++++++++++++++++++++++++++++++---- kasa/modules/usage.py | 41 +++++++++++++++++++++++++---- kasa/smartdevice.py | 41 +++++------------------------ 4 files changed, 134 insertions(+), 46 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index f2fd66d12..c9cab4b50 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -316,7 +316,6 @@ async def emeter(dev: SmartDevice, year, month, erase): usage_data = await dev.get_emeter_daily(year=month.year, month=month.month) else: # Call with no argument outputs summary data and returns - usage_data = {} emeter_status = dev.emeter_realtime click.echo("Current: %s A" % emeter_status["current"]) @@ -334,6 +333,44 @@ async def emeter(dev: SmartDevice, year, month, erase): click.echo(f"{index}, {usage}") +@cli.command() +@pass_dev +@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) +@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) +@click.option("--erase", is_flag=True) +async def usage(dev: SmartDevice, year, month, erase): + """Query usage for historical consumption. + + Daily and monthly data provided in CSV format. + """ + click.echo(click.style("== Usage ==", bold=True)) + usage = dev.modules["usage"] + + if erase: + click.echo("Erasing usage statistics..") + click.echo(await usage.erase_stats()) + return + + if year: + click.echo(f"== For year {year.year} ==") + click.echo("Month, usage (minutes)") + usage_data = await usage.get_monthstat(year.year) + elif month: + click.echo(f"== For month {month.month} of {month.year} ==") + click.echo("Day, usage (minutes)") + usage_data = await usage.get_daystat(year=month.year, month=month.month) + else: + # Call with no argument outputs summary data and returns + click.echo("Today: %s minutes" % usage.usage_today) + click.echo("This month: %s minutes" % usage.usage_this_month) + + return + + # output any detailed usage data + for index, usage in usage_data.items(): + click.echo(f"{index}, {usage}") + + @cli.command() @click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) @click.option("--transition", type=int, required=False) diff --git a/kasa/modules/emeter.py b/kasa/modules/emeter.py index f8144e39e..bb161ce61 100644 --- a/kasa/modules/emeter.py +++ b/kasa/modules/emeter.py @@ -1,4 +1,7 @@ """Implementation of the emeter module.""" +from datetime import datetime +from typing import Dict, Optional + from ..emeterstatus import EmeterStatus from .usage import Usage @@ -6,15 +9,61 @@ class Emeter(Usage): """Emeter module.""" - def query(self): - """Prepare query for emeter data.""" - return self._device._create_emeter_request() - @property # type: ignore def realtime(self) -> EmeterStatus: """Return current energy readings.""" return EmeterStatus(self.data["get_realtime"]) + @property + def emeter_today(self) -> Optional[float]: + """Return today's energy consumption in kWh.""" + raw_data = self.daily_data + today = datetime.now().day + data = self._emeter_convert_emeter_data(raw_data) + + return data.get(today) + + @property + def emeter_this_month(self) -> Optional[float]: + """Return this month's energy consumption in kWh.""" + raw_data = self.monthly_data + current_month = datetime.now().month + data = self._emeter_convert_emeter_data(raw_data) + + return data.get(current_month) + async def erase_stats(self): - """Erase all stats.""" + """Erase all stats. + + Uses different query than usage meter. + """ return await self.call("erase_emeter_stat") + + async def get_daystat(self, *, year, month, kwh=True): + """Return daily stats for the given year & month.""" + raw_data = await super().get_daystat(year=year, month=month) + return self._emeter_convert_emeter_data(raw_data["day_list"], kwh) + + async def get_monthstat(self, *, year, kwh=True): + """Return monthly stats for the given year.""" + raw_data = await super().get_monthstat(year=year) + return self._emeter_convert_emeter_data(raw_data["month_list"], kwh) + + def _emeter_convert_emeter_data(self, data, kwh=True) -> Dict: + """Return emeter information keyed with the day/month..""" + response = [EmeterStatus(**x) for x in data] + + if not response: + return {} + + energy_key = "energy_wh" + if kwh: + energy_key = "energy" + + entry_key = "month" + if "day" in response[0]: + entry_key = "day" + + data = {entry[entry_key]: entry[energy_key] for entry in response} + + return data diff --git a/kasa/modules/usage.py b/kasa/modules/usage.py index 2a5b6dc0b..5aecb9a75 100644 --- a/kasa/modules/usage.py +++ b/kasa/modules/usage.py @@ -17,22 +17,53 @@ def query(self): req, self.query_for_command("get_daystat", {"year": year, "month": month}) ) req = merge(req, self.query_for_command("get_monthstat", {"year": year})) - req = merge(req, self.query_for_command("get_next_action")) return req - async def get_daystat(self, year, month): - """Return stats for the current day.""" + @property + def daily_data(self): + """Return statistics on daily basis.""" + return self.data["get_daystat"]["day_list"] + + @property + def monthly_data(self): + """Return statistics on monthly basis.""" + return self.data["get_monthstat"]["month_list"] + + @property + def usage_today(self): + """Return today's usage in minutes.""" + today = datetime.now().day + converted = [x["time"] for x in self.daily_data if x["day"] == today] + if not converted: + return None + + return converted.pop() + + @property + def usage_this_month(self): + """Return usage in this month in minutes.""" + this_month = datetime.now().month + converted = [x["time"] for x in self.monthly_data if x["month"] == this_month] + if not converted: + return None + + return converted.pop() + + async def get_daystat(self, *, year=None, month=None): + """Return daily stats for the given year & month.""" if year is None: year = datetime.now().year if month is None: month = datetime.now().month + return await self.call("get_daystat", {"year": year, "month": month}) - async def get_monthstat(self, year): - """Return stats for the current month.""" + async def get_monthstat(self, *, year=None): + """Return monthly stats for the given year.""" if year is None: year = datetime.now().year + return await self.call("get_monthstat", {"year": year}) async def erase_stats(self): diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 5e6877417..d2cf80cc6 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -482,6 +482,7 @@ async def get_emeter_realtime(self) -> EmeterStatus: def _create_emeter_request(self, year: int = None, month: int = None): """Create a Internal method for building a request for all emeter statistics at once.""" + # TODO: this is currently only here for smartstrip plug support, move it there? if year is None: year = datetime.now().year if month is None: @@ -506,28 +507,14 @@ def _create_emeter_request(self, year: int = None, month: int = None): def emeter_today(self) -> Optional[float]: """Return today's energy consumption in kWh.""" self._verify_emeter() - raw_data = self._last_update[self.emeter_type]["get_daystat"]["day_list"] - data = self._emeter_convert_emeter_data(raw_data) - today = datetime.now().day - - if today in data: - return data[today] - - return None + return self.modules["emeter"].emeter_today @property # type: ignore @requires_update def emeter_this_month(self) -> Optional[float]: """Return this month's energy consumption in kWh.""" self._verify_emeter() - raw_data = self._last_update[self.emeter_type]["get_monthstat"]["month_list"] - data = self._emeter_convert_emeter_data(raw_data) - current_month = datetime.now().month - - if current_month in data: - return data[current_month] - - return None + return self.modules["emeter"].emeter_this_month def _emeter_convert_emeter_data(self, data, kwh=True) -> Dict: """Return emeter information keyed with the day/month..""" @@ -560,16 +547,7 @@ async def get_emeter_daily( :return: mapping of day of month to value """ self._verify_emeter() - if year is None: - year = datetime.now().year - if month is None: - month = datetime.now().month - - response = await self._query_helper( - self.emeter_type, "get_daystat", {"month": month, "year": year} - ) - - return self._emeter_convert_emeter_data(response["day_list"], kwh) + return await self.modules["emeter"].get_daystat(year=year, month=month, kwh=kwh) @requires_update async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: @@ -580,14 +558,7 @@ async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: :return: dict: mapping of month to value """ self._verify_emeter() - if year is None: - year = datetime.now().year - - response = await self._query_helper( - self.emeter_type, "get_monthstat", {"year": year} - ) - - return self._emeter_convert_emeter_data(response["month_list"], kwh) + return await self.modules["emeter"].get_monthstat(year=year, kwh=kwh) @requires_update async def erase_emeter_stats(self) -> Dict: @@ -599,7 +570,7 @@ async def erase_emeter_stats(self) -> Dict: async def current_consumption(self) -> float: """Get the current power consumption in Watt.""" self._verify_emeter() - response = EmeterStatus(await self.get_emeter_realtime()) + response = self.emeter_realtime return float(response["power"]) async def reboot(self, delay: int = 1) -> None: From 8c7b1b4a684c34a47e079500d30d27d8d287579a Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 29 Jan 2022 17:53:18 +0100 Subject: [PATCH 086/892] Implement motion & ambient light sensor modules for dimmers (#278) --- kasa/modules/__init__.py | 2 ++ kasa/modules/ambientlight.py | 47 +++++++++++++++++++++++++++ kasa/modules/motion.py | 62 ++++++++++++++++++++++++++++++++++++ kasa/smartdimmer.py | 5 +++ 4 files changed, 116 insertions(+) create mode 100644 kasa/modules/ambientlight.py create mode 100644 kasa/modules/motion.py diff --git a/kasa/modules/__init__.py b/kasa/modules/__init__.py index dd9d1072f..e5cb83d66 100644 --- a/kasa/modules/__init__.py +++ b/kasa/modules/__init__.py @@ -1,9 +1,11 @@ # flake8: noqa +from .ambientlight import AmbientLight from .antitheft import Antitheft from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter from .module import Module +from .motion import Motion from .rulemodule import Rule, RuleModule from .schedule import Schedule from .time import Time diff --git a/kasa/modules/ambientlight.py b/kasa/modules/ambientlight.py new file mode 100644 index 000000000..963c73a3f --- /dev/null +++ b/kasa/modules/ambientlight.py @@ -0,0 +1,47 @@ +"""Implementation of the ambient light (LAS) module found in some dimmers.""" +from .module import Module + +# TODO create tests and use the config reply there +# [{"hw_id":0,"enable":0,"dark_index":1,"min_adc":0,"max_adc":2450, +# "level_array":[{"name":"cloudy","adc":490,"value":20}, +# {"name":"overcast","adc":294,"value":12}, +# {"name":"dawn","adc":222,"value":9}, +# {"name":"twilight","adc":222,"value":9}, +# {"name":"total darkness","adc":111,"value":4}, +# {"name":"custom","adc":2400,"value":97}]}] + + +class AmbientLight(Module): + """Implements ambient light controls for the motion sensor.""" + + def query(self): + """Request configuration.""" + return self.query_for_command("get_config") + + @property + def presets(self) -> dict: + """Return device-defined presets for brightness setting.""" + return self.data["level_array"] + + @property + def enabled(self) -> bool: + """Return True if the module is enabled.""" + return bool(self.data["enable"]) + + async def set_enabled(self, state: bool): + """Enable/disable LAS.""" + return await self.call("set_enable", {"enable": int(state)}) + + async def current_brightness(self) -> int: + """Return current brightness. + + Return value units. + """ + return await self.call("get_current_brt") + + async def set_brightness_limit(self, value: int): + """Set the limit when the motion sensor is inactive. + + See `presets` for preset values. Custom values are also likely allowed. + """ + return await self.call("set_brt_level", {"index": 0, "value": value}) diff --git a/kasa/modules/motion.py b/kasa/modules/motion.py new file mode 100644 index 000000000..d839ca98e --- /dev/null +++ b/kasa/modules/motion.py @@ -0,0 +1,62 @@ +"""Implementation of the motion detection (PIR) module found in some dimmers.""" +from enum import Enum +from typing import Optional + +from kasa.smartdevice import SmartDeviceException + +from .module import Module + + +class Range(Enum): + """Range for motion detection.""" + + Far = 0 + Mid = 1 + Near = 2 + Custom = 3 + + +# TODO: use the config reply in tests +# {"enable":0,"version":"1.0","trigger_index":2,"cold_time":60000, +# "min_adc":0,"max_adc":4095,"array":[80,50,20,0],"err_code":0}}} + + +class Motion(Module): + """Implements the motion detection (PIR) module.""" + + def query(self): + """Request PIR configuration.""" + return self.query_for_command("get_config") + + @property + def range(self) -> Range: + """Return motion detection range.""" + return Range(self.data["trigger_index"]) + + @property + def enabled(self) -> bool: + """Return True if module is enabled.""" + return bool(self.data["enable"]) + + async def set_enabled(self, state: bool): + """Enable/disable PIR.""" + return await self.call("set_enable", {"enable": int(state)}) + + async def set_range( + self, *, range: Optional[Range] = None, custom_range: Optional[int] = None + ): + """Set the range for the sensor. + + :param range: for using standard ranges + :param custom_range: range in decimeters, overrides the range parameter + """ + if custom_range is not None: + payload = {"index": Range.Custom.value, "value": custom_range} + elif range is not None: + payload = {"index": range.value} + else: + raise SmartDeviceException( + "Either range or custom_range need to be defined" + ) + + return await self.call("set_trigger_sens", payload) diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py index 8e5cb1527..5c06b8b94 100644 --- a/kasa/smartdimmer.py +++ b/kasa/smartdimmer.py @@ -1,6 +1,7 @@ """Module for dimmers (currently only HS220).""" from typing import Any, Dict +from kasa.modules import AmbientLight, Motion from kasa.smartdevice import DeviceType, SmartDeviceException, requires_update from kasa.smartplug import SmartPlug @@ -40,6 +41,10 @@ class SmartDimmer(SmartPlug): def __init__(self, host: str) -> None: super().__init__(host) self._device_type = DeviceType.Dimmer + # TODO: need to be verified if it's okay to call these on HS220 w/o these + # TODO: need to be figured out what's the best approach to detect support for these + self.add_module("motion", Motion(self, "smartlife.iot.PIR")) + self.add_module("ambient", AmbientLight(self, "smartlife.iot.LAS")) @property # type: ignore @requires_update From 3a7836cd33d0536545436a5164dbfdfcdacc60b7 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 29 Jan 2022 18:15:59 +0100 Subject: [PATCH 087/892] Do not request unsupported modules after the initial update (#298) * Do not request unsupported modules after the initial update * debugify logging --- kasa/modules/module.py | 8 ++++++++ kasa/smartdevice.py | 3 +++ 2 files changed, 11 insertions(+) diff --git a/kasa/modules/module.py b/kasa/modules/module.py index 1f7f3829f..dc4c1bfae 100644 --- a/kasa/modules/module.py +++ b/kasa/modules/module.py @@ -1,5 +1,6 @@ """Base class for all module implementations.""" import collections +import logging from abc import ABC, abstractmethod from typing import TYPE_CHECKING @@ -7,6 +8,9 @@ from kasa import SmartDevice +_LOGGER = logging.getLogger(__name__) + + # TODO: This is used for query construcing def merge(d, u): """Update dict recursively.""" @@ -45,6 +49,10 @@ def data(self): @property def is_supported(self) -> bool: """Return whether the module is supported by the device.""" + if self._module not in self._device._last_update: + _LOGGER.debug("Initial update, so consider supported: %s", self._module) + return True + return "err_code" not in self.data def call(self, method, params=None): diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index d2cf80cc6..812656c87 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -327,6 +327,9 @@ async def update(self, update_children: bool = True): self.add_module("emeter", Emeter(self, self.emeter_type)) for module in self.modules.values(): + if not module.is_supported: + _LOGGER.debug("Module %s not supported, skipping" % module) + continue q = module.query() _LOGGER.debug("Adding query for %s: %s", module, q) req = merge(req, module.query()) From bb013e75da311a3a5fdf941c3a6edce7c1ba3b67 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 29 Jan 2022 20:33:35 +0100 Subject: [PATCH 088/892] Raise an exception when trying to access data prior updating --- kasa/modules/module.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/kasa/modules/module.py b/kasa/modules/module.py index dc4c1bfae..7340d7e11 100644 --- a/kasa/modules/module.py +++ b/kasa/modules/module.py @@ -4,6 +4,8 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING +from ..exceptions import SmartDeviceException + if TYPE_CHECKING: from kasa import SmartDevice @@ -44,6 +46,11 @@ def query(self): @property def data(self): """Return the module specific raw data from the last update.""" + if self._module not in self._device._last_update: + raise SmartDeviceException( + f"You need to call update() prior accessing module data for '{self._module}'" + ) + return self._device._last_update[self._module] @property From c8ad99abcbc6bbef8cd2360d8821e1774d26f1de Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 29 Jan 2022 20:35:10 +0100 Subject: [PATCH 089/892] Use device time for on_since for smartstripplugs --- kasa/smartstrip.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index bbdf2a3fb..a0502125b 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -13,7 +13,7 @@ ) from kasa.smartplug import SmartPlug -from .modules import Antitheft, Countdown, Schedule, Time, Usage +from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) @@ -87,6 +87,7 @@ def __init__(self, host: str) -> None: self.add_module("usage", Usage(self, "schedule")) self.add_module("time", Time(self, "time")) self.add_module("countdown", Countdown(self, "countdown")) + self.add_module("emeter", Emeter(self, "emeter")) @property # type: ignore @requires_update @@ -255,10 +256,32 @@ async def update(self, update_children: bool = True): Needed for properties that are decorated with `requires_update`. """ + # TODO: it needs to be checked if this still works after modularization self._last_update = await self.parent.protocol.query( self._create_emeter_request() ) + def _create_emeter_request(self, year: int = None, month: int = None): + """Create a request for requesting all emeter statistics at once.""" + if year is None: + year = datetime.now().year + if month is None: + month = datetime.now().month + + req: Dict[str, Any] = {} + from .smartdevice import merge + + merge(req, self._create_request("emeter", "get_realtime")) + merge(req, self._create_request("emeter", "get_monthstat", {"year": year})) + merge( + req, + self._create_request( + "emeter", "get_daystat", {"month": month, "year": year} + ), + ) + + return req + def _create_request( self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None ): @@ -325,7 +348,7 @@ def on_since(self) -> Optional[datetime]: info = self._get_child_info() on_time = info["on_time"] - return datetime.now() - timedelta(seconds=on_time) + return self.time - timedelta(seconds=on_time) @property # type: ignore @requires_update From f0d66e4195079c24378c0ce36b85617188514de3 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 29 Jan 2022 20:36:08 +0100 Subject: [PATCH 090/892] move get_time{zone} out from smartdevice + some minor cleanups --- kasa/modules/emeter.py | 4 ++++ kasa/modules/motion.py | 3 +-- kasa/modules/time.py | 20 ++++++++++++++++ kasa/smartdevice.py | 53 ++++++++---------------------------------- kasa/smartplug.py | 1 - 5 files changed, 35 insertions(+), 46 deletions(-) diff --git a/kasa/modules/emeter.py b/kasa/modules/emeter.py index bb161ce61..cd92c3cce 100644 --- a/kasa/modules/emeter.py +++ b/kasa/modules/emeter.py @@ -39,6 +39,10 @@ async def erase_stats(self): """ return await self.call("erase_emeter_stat") + async def get_realtime(self): + """Return real-time statistics.""" + return await self.call("get_realtime") + async def get_daystat(self, *, year, month, kwh=True): """Return daily stats for the given year & month.""" raw_data = await super().get_daystat(year=year, month=month) diff --git a/kasa/modules/motion.py b/kasa/modules/motion.py index d839ca98e..45e272bed 100644 --- a/kasa/modules/motion.py +++ b/kasa/modules/motion.py @@ -2,8 +2,7 @@ from enum import Enum from typing import Optional -from kasa.smartdevice import SmartDeviceException - +from ..exceptions import SmartDeviceException from .module import Module diff --git a/kasa/modules/time.py b/kasa/modules/time.py index 0bd3f1714..d72e2d600 100644 --- a/kasa/modules/time.py +++ b/kasa/modules/time.py @@ -1,6 +1,7 @@ """Provides the current time and timezone information.""" from datetime import datetime +from ..exceptions import SmartDeviceException from .module import Module, merge @@ -32,3 +33,22 @@ def timezone(self): """Return current timezone.""" res = self.data["get_timezone"] return res + + async def get_time(self): + """Return current device time.""" + try: + res = await self.call("get_time") + return datetime( + res["year"], + res["month"], + res["mday"], + res["hour"], + res["min"], + res["sec"], + ) + except SmartDeviceException: + return None + + async def get_timezone(self): + """Request timezone information from the device.""" + return await self.call("get_timezone") diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 812656c87..b589d86a9 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -186,7 +186,6 @@ class SmartDevice: """ - TIME_SERVICE = "time" emeter_type = "emeter" def __init__(self, host: str) -> None: @@ -314,11 +313,6 @@ async def update(self, update_children: bool = True): _LOGGER.debug("Performing the initial update to obtain sysinfo") self._last_update = await self.protocol.query(req) self._sys_info = self._last_update["system"]["get_sysinfo"] - # If the device has no emeter, we are done for the initial update - # Otherwise we will follow the regular code path to also query - # the emeter data also during the initial update - if not self.has_emeter: - return if self.has_emeter: _LOGGER.debug( @@ -380,22 +374,17 @@ def timezone(self) -> Dict: async def get_time(self) -> Optional[datetime]: """Return current time from the device, if available.""" - try: - res = await self._query_helper(self.TIME_SERVICE, "get_time") - return datetime( - res["year"], - res["month"], - res["mday"], - res["hour"], - res["min"], - res["sec"], - ) - except SmartDeviceException: - return None + _LOGGER.warning( + "Use `time` property instead, this call will be removed in the future." + ) + return await self.modules["time"].get_time() async def get_timezone(self) -> Dict: """Return timezone information.""" - return await self._query_helper(self.TIME_SERVICE, "get_timezone") + _LOGGER.warning( + "Use `timezone` property instead, this call will be removed in the future." + ) + return await self.modules["time"].get_timezone() @property # type: ignore @requires_update @@ -433,7 +422,7 @@ def location(self) -> Dict: loc["latitude"] = sys_info["latitude_i"] / 10000 loc["longitude"] = sys_info["longitude_i"] / 10000 else: - _LOGGER.warning("Unsupported device location.") + _LOGGER.debug("Unsupported device location.") return loc @@ -481,29 +470,7 @@ def emeter_realtime(self) -> EmeterStatus: async def get_emeter_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" self._verify_emeter() - return EmeterStatus(await self._query_helper(self.emeter_type, "get_realtime")) - - def _create_emeter_request(self, year: int = None, month: int = None): - """Create a Internal method for building a request for all emeter statistics at once.""" - # TODO: this is currently only here for smartstrip plug support, move it there? - if year is None: - year = datetime.now().year - if month is None: - month = datetime.now().month - - req: Dict[str, Any] = {} - merge(req, self._create_request(self.emeter_type, "get_realtime")) - merge( - req, self._create_request(self.emeter_type, "get_monthstat", {"year": year}) - ) - merge( - req, - self._create_request( - self.emeter_type, "get_daystat", {"month": month, "year": year} - ), - ) - - return req + return EmeterStatus(await self.modules["emeter"].get_realtime()) @property # type: ignore @requires_update diff --git a/kasa/smartplug.py b/kasa/smartplug.py index 58144b58a..b636c3e11 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -39,7 +39,6 @@ class SmartPlug(SmartDevice): def __init__(self, host: str) -> None: super().__init__(host) - self.emeter_type = "emeter" self._device_type = DeviceType.Plug self.add_module("schedule", Schedule(self, "schedule")) self.add_module("usage", Usage(self, "schedule")) From 1e4df7ec1bbc81f72f7ca0023266318174e3e5ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Apr 2022 06:16:36 -1000 Subject: [PATCH 091/892] Fix modularize with strips (#326) * Fix test_deprecated_type stalling * Fix strips with modularize * Fix test_deprecated_type stalling (#325) --- kasa/protocol.py | 23 +++++++++-------------- kasa/smartdevice.py | 8 ++++++-- kasa/smartstrip.py | 11 ++++++----- kasa/tests/test_smartdevice.py | 4 +++- 4 files changed, 24 insertions(+), 22 deletions(-) diff --git a/kasa/protocol.py b/kasa/protocol.py index e2f946269..24c2cd056 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -70,20 +70,13 @@ async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: async with self.query_lock: return await self._query(request, retry_count, timeout) - async def _connect(self, timeout: int) -> bool: + async def _connect(self, timeout: int) -> None: """Try to connect or reconnect to the device.""" if self.writer: - return True - - with contextlib.suppress(Exception): - self.reader = self.writer = None - task = asyncio.open_connection( - self.host, TPLinkSmartHomeProtocol.DEFAULT_PORT - ) - self.reader, self.writer = await asyncio.wait_for(task, timeout=timeout) - return True - - return False + return + self.reader = self.writer = None + task = asyncio.open_connection(self.host, TPLinkSmartHomeProtocol.DEFAULT_PORT) + self.reader, self.writer = await asyncio.wait_for(task, timeout=timeout) async def _execute_query(self, request: str) -> Dict: """Execute a query on the device and wait for the response.""" @@ -123,12 +116,14 @@ def _reset(self) -> None: async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: """Try to query a device.""" for retry in range(retry_count + 1): - if not await self._connect(timeout): + try: + await self._connect(timeout) + except Exception as ex: await self.close() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) raise SmartDeviceException( - f"Unable to connect to the device: {self.host}" + f"Unable to connect to the device: {self.host}: {ex}" ) continue diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index b589d86a9..93e01758c 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -314,6 +314,11 @@ async def update(self, update_children: bool = True): self._last_update = await self.protocol.query(req) self._sys_info = self._last_update["system"]["get_sysinfo"] + await self._modular_update(req) + self._sys_info = self._last_update["system"]["get_sysinfo"] + + async def _modular_update(self, req: dict) -> None: + """Execute an update query.""" if self.has_emeter: _LOGGER.debug( "The device has emeter, querying its information along sysinfo" @@ -326,10 +331,9 @@ async def update(self, update_children: bool = True): continue q = module.query() _LOGGER.debug("Adding query for %s: %s", module, q) - req = merge(req, module.query()) + req = merge(req, q) self._last_update = await self.protocol.query(req) - self._sys_info = self._last_update["system"]["get_sysinfo"] def update_from_discover_info(self, info): """Update state from info from the discover call.""" diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index a0502125b..353a9c44b 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -3,13 +3,14 @@ from collections import defaultdict from datetime import datetime, timedelta from typing import Any, DefaultDict, Dict, Optional - +import asyncio from kasa.smartdevice import ( DeviceType, EmeterStatus, SmartDevice, SmartDeviceException, requires_update, + merge, ) from kasa.smartplug import SmartPlug @@ -250,16 +251,16 @@ def __init__(self, host: str, parent: "SmartStrip", child_id: str) -> None: self._last_update = parent._last_update self._sys_info = parent._sys_info self._device_type = DeviceType.StripSocket + self.modules = {} + self.protocol = parent.protocol # Must use the same connection as the parent + self.add_module("time", Time(self, "time")) async def update(self, update_children: bool = True): """Query the device to update the data. Needed for properties that are decorated with `requires_update`. """ - # TODO: it needs to be checked if this still works after modularization - self._last_update = await self.parent.protocol.query( - self._create_emeter_request() - ) + await self._modular_update({}) def _create_emeter_request(self, year: int = None, month: int = None): """Create a request for requesting all emeter statistics at once.""" diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index d977daeb3..9138a7e5c 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -36,7 +36,9 @@ async def test_initial_update_no_emeter(dev, mocker): dev._last_update = None spy = mocker.spy(dev.protocol, "query") await dev.update() - assert spy.call_count == 1 + # 2 calls are necessary as some devices crash on unexpected modules + # See #105, #120, #161 + assert spy.call_count == 2 async def test_query_helper(dev): From 2a0919efd568a2c4fac848fab4a1d4b7c9abe0ff Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 5 Apr 2022 18:25:41 +0200 Subject: [PATCH 092/892] Fix linting --- kasa/smartstrip.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 353a9c44b..ba863d059 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -3,14 +3,14 @@ from collections import defaultdict from datetime import datetime, timedelta from typing import Any, DefaultDict, Dict, Optional -import asyncio + from kasa.smartdevice import ( DeviceType, EmeterStatus, SmartDevice, SmartDeviceException, - requires_update, merge, + requires_update, ) from kasa.smartplug import SmartPlug @@ -270,7 +270,6 @@ def _create_emeter_request(self, year: int = None, month: int = None): month = datetime.now().month req: Dict[str, Any] = {} - from .smartdevice import merge merge(req, self._create_request("emeter", "get_realtime")) merge(req, self._create_request("emeter", "get_monthstat", {"year": year})) From 68038c93dfbb88fde1e2e1cc6e4dccb7e9ce0173 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 5 Apr 2022 18:36:57 +0200 Subject: [PATCH 093/892] Bump version to 0.5.0.dev0 --- poetry.lock | 287 +++++++++++++++++++++++++------------------------ pyproject.toml | 4 +- 2 files changed, 148 insertions(+), 143 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3ce2e28bf..aa1231d90 100644 --- a/poetry.lock +++ b/poetry.lock @@ -87,7 +87,7 @@ python-versions = ">=3.6.1" [[package]] name = "charset-normalizer" -version = "2.0.10" +version = "2.0.12" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false @@ -118,7 +118,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "coverage" -version = "6.3" +version = "6.3.2" description = "Code coverage measurement for Python" category = "dev" optional = false @@ -148,7 +148,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "filelock" -version = "3.4.2" +version = "3.6.0" description = "A platform independent file lock." category = "dev" optional = false @@ -160,7 +160,7 @@ testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-co [[package]] name = "identify" -version = "2.4.6" +version = "2.4.12" description = "File identification library for Python" category = "dev" optional = false @@ -187,7 +187,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.10.1" +version = "4.11.3" description = "Read metadata from Python packages" category = "main" optional = false @@ -198,9 +198,9 @@ typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] perf = ["ipython"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] [[package]] name = "iniconfig" @@ -212,11 +212,11 @@ python-versions = "*" [[package]] name = "jinja2" -version = "3.0.3" +version = "3.1.1" description = "A very fast and expressive template engine." category = "main" optional = true -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] MarkupSafe = ">=2.0" @@ -238,11 +238,11 @@ mistune = "*" [[package]] name = "markupsafe" -version = "2.0.1" +version = "2.1.1" description = "Safely add untrusted strings to HTML/XML markup." category = "main" optional = true -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "mistune" @@ -273,7 +273,7 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "platformdirs" -version = "2.4.1" +version = "2.5.1" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false @@ -300,11 +300,11 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "2.17.0" +version = "2.18.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.7" [package.dependencies] cfgv = ">=2.0.0" @@ -359,11 +359,11 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "6.2.5" +version = "7.1.1" description = "pytest: simple powerful testing with Python" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} @@ -374,14 +374,14 @@ iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" py = ">=1.8.2" -toml = "*" +tomli = ">=1.0.0" [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.17.2" +version = "0.18.3" description = "Pytest support for asyncio" category = "dev" optional = false @@ -389,10 +389,10 @@ python-versions = ">=3.7" [package.dependencies] pytest = ">=6.1.0" -typing-extensions = {version = ">=4.0", markers = "python_version < \"3.8\""} +typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} [package.extras] -testing = ["coverage (==6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (==0.931)"] +testing = ["coverage (==6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (==0.931)", "pytest-trio (>=0.7.0)"] [[package]] name = "pytest-cov" @@ -439,7 +439,7 @@ termcolor = ">=1.1.0" [[package]] name = "pytz" -version = "2021.3" +version = "2022.1" description = "World timezone definitions, modern and historical" category = "main" optional = true @@ -641,7 +641,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "tomli" -version = "2.0.0" +version = "2.0.1" description = "A lil' TOML parser" category = "dev" optional = false @@ -672,7 +672,7 @@ testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pytest (>=4.0.0)", "pytes [[package]] name = "typing-extensions" -version = "4.0.1" +version = "4.1.1" description = "Backported and Experimental Type Hints for Python 3.6+" category = "main" optional = false @@ -680,20 +680,20 @@ python-versions = ">=3.6" [[package]] name = "urllib3" -version = "1.26.8" +version = "1.26.9" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] -brotli = ["brotlipy (>=0.6.0)"] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.13.0" +version = "20.14.0" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -712,7 +712,7 @@ testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", [[package]] name = "voluptuous" -version = "0.12.2" +version = "0.13.0" description = "" category = "dev" optional = false @@ -738,15 +738,15 @@ tests = ["codecov", "scikit-build", "cmake", "ninja", "pybind11", "pytest", "pyt [[package]] name = "zipp" -version = "3.7.0" +version = "3.8.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false python-versions = ">=3.7" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] [extras] docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programoutput"] @@ -754,7 +754,7 @@ docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programou [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "eb7bb96b826fec8ad7207e838f02a68333d806f5908f3e07675cbc151a165b25" +content-hash = "cbc8eb721e3b498c25eef73c95b2aa309419fa075b878c18cac0b148113c25f9" [metadata.files] alabaster = [ @@ -789,8 +789,8 @@ cfgv = [ {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] charset-normalizer = [ - {file = "charset-normalizer-2.0.10.tar.gz", hash = "sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd"}, - {file = "charset_normalizer-2.0.10-py3-none-any.whl", hash = "sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455"}, + {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, + {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, ] codecov = [ {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"}, @@ -802,50 +802,47 @@ colorama = [ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] coverage = [ - {file = "coverage-6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e8071e7d9ba9f457fc674afc3de054450be2c9b195c470147fbbc082468d8ff7"}, - {file = "coverage-6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86c91c511853dfda81c2cf2360502cb72783f4b7cebabef27869f00cbe1db07d"}, - {file = "coverage-6.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4ce3b647bd1792d4394f5690d9df6dc035b00bcdbc5595099c01282a59ae01"}, - {file = "coverage-6.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a491e159294d756e7fc8462f98175e2d2225e4dbe062cca7d3e0d5a75ba6260"}, - {file = "coverage-6.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d008e0f67ac800b0ca04d7914b8501312c8c6c00ad8c7ba17754609fae1231a"}, - {file = "coverage-6.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4578728c36de2801c1deb1c6b760d31883e62e33f33c7ba8f982e609dc95167d"}, - {file = "coverage-6.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7ee317486593193e066fc5e98ac0ce712178c21529a85c07b7cb978171f25d53"}, - {file = "coverage-6.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2bc85664b06ba42d14bb74d6ddf19d8bfc520cb660561d2d9ce5786ae72f71b5"}, - {file = "coverage-6.3-cp310-cp310-win32.whl", hash = "sha256:27a94db5dc098c25048b0aca155f5fac674f2cf1b1736c5272ba28ead2fc267e"}, - {file = "coverage-6.3-cp310-cp310-win_amd64.whl", hash = "sha256:bde4aeabc0d1b2e52c4036c54440b1ad05beeca8113f47aceb4998bb7471e2c2"}, - {file = "coverage-6.3-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:509c68c3e2015022aeda03b003dd68fa19987cdcf64e9d4edc98db41cfc45d30"}, - {file = "coverage-6.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e4ff163602c5c77e7bb4ea81ba5d3b793b4419f8acd296aae149370902cf4e92"}, - {file = "coverage-6.3-cp311-cp311-win_amd64.whl", hash = "sha256:d1675db48490e5fa0b300f6329ecb8a9a37c29b9ab64fa9c964d34111788ca2d"}, - {file = "coverage-6.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7eed8459a2b81848cafb3280b39d7d49950d5f98e403677941c752e7e7ee47cb"}, - {file = "coverage-6.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b4285fde5286b946835a1a53bba3ad41ef74285ba9e8013e14b5ea93deaeafc"}, - {file = "coverage-6.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4748349734110fd32d46ff8897b561e6300d8989a494ad5a0a2e4f0ca974fc7"}, - {file = "coverage-6.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:823f9325283dc9565ba0aa2d240471a93ca8999861779b2b6c7aded45b58ee0f"}, - {file = "coverage-6.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:fff16a30fdf57b214778eff86391301c4509e327a65b877862f7c929f10a4253"}, - {file = "coverage-6.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:da1a428bdbe71f9a8c270c7baab29e9552ac9d0e0cba5e7e9a4c9ee6465d258d"}, - {file = "coverage-6.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7d82c610a2e10372e128023c5baf9ce3d270f3029fe7274ff5bc2897c68f1318"}, - {file = "coverage-6.3-cp37-cp37m-win32.whl", hash = "sha256:11e61c5548ecf74ea1f8b059730b049871f0e32b74f88bd0d670c20c819ad749"}, - {file = "coverage-6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:8e0c3525b1a182c8ffc9bca7e56b521e0c2b8b3e82f033c8e16d6d721f1b54d6"}, - {file = "coverage-6.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a189036c50dcd56100746139a459f0d27540fef95b09aba03e786540b8feaa5f"}, - {file = "coverage-6.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:32168001f33025fd756884d56d01adebb34e6c8c0b3395ca8584cdcee9c7c9d2"}, - {file = "coverage-6.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5d79c9af3f410a2b5acad91258b4ae179ee9c83897eb9de69151b179b0227f5"}, - {file = "coverage-6.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:85c5fc9029043cf8b07f73fbb0a7ab6d3b717510c3b5642b77058ea55d7cacde"}, - {file = "coverage-6.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7596aa2f2b8fa5604129cfc9a27ad9beec0a96f18078cb424d029fdd707468d"}, - {file = "coverage-6.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ce443a3e6df90d692c38762f108fc4c88314bf477689f04de76b3f252e7a351c"}, - {file = "coverage-6.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:012157499ec4f135fc36cd2177e3d1a1840af9b236cbe80e9a5ccfc83d912a69"}, - {file = "coverage-6.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0a34d313105cdd0d3644c56df2d743fe467270d6ab93b5d4a347eb9fec8924d6"}, - {file = "coverage-6.3-cp38-cp38-win32.whl", hash = "sha256:6e78b1e25e5c5695dea012be473e442f7094d066925604be20b30713dbd47f89"}, - {file = "coverage-6.3-cp38-cp38-win_amd64.whl", hash = "sha256:433b99f7b0613bdcdc0b00cc3d39ed6d756797e3b078d2c43f8a38288520aec6"}, - {file = "coverage-6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ed3244b415725f08ca3bdf02ed681089fd95e9465099a21c8e2d9c5d6ca2606"}, - {file = "coverage-6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab4fc4b866b279740e0d917402f0e9a08683e002f43fa408e9655818ed392196"}, - {file = "coverage-6.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8582e9280f8d0f38114fe95a92ae8d0790b56b099d728cc4f8a2e14b1c4a18c"}, - {file = "coverage-6.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c72bb4679283c6737f452eeb9b2a0e570acaef2197ad255fb20162adc80bea76"}, - {file = "coverage-6.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca29c352389ea27a24c79acd117abdd8a865c6eb01576b6f0990cd9a4e9c9f48"}, - {file = "coverage-6.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:152cc2624381df4e4e604e21bd8e95eb8059535f7b768c1fb8b8ae0b26f47ab0"}, - {file = "coverage-6.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:51372e24b1f7143ee2df6b45cff6a721f3abe93b1e506196f3ffa4155c2497f7"}, - {file = "coverage-6.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:72d9d186508325a456475dd05b1756f9a204c7086b07fffb227ef8cee03b1dc2"}, - {file = "coverage-6.3-cp39-cp39-win32.whl", hash = "sha256:649df3641eb351cdfd0d5533c92fc9df507b6b2bf48a7ef8c71ab63cbc7b5c3c"}, - {file = "coverage-6.3-cp39-cp39-win_amd64.whl", hash = "sha256:e67ccd53da5958ea1ec833a160b96357f90859c220a00150de011b787c27b98d"}, - {file = "coverage-6.3-pp36.pp37.pp38-none-any.whl", hash = "sha256:27ac7cb84538e278e07569ceaaa6f807a029dc194b1c819a9820b9bb5dbf63ab"}, - {file = "coverage-6.3.tar.gz", hash = "sha256:987a84ff98a309994ca77ed3cc4b92424f824278e48e4bf7d1bb79a63cfe2099"}, + {file = "coverage-6.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf"}, + {file = "coverage-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac"}, + {file = "coverage-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1"}, + {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4"}, + {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903"}, + {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c"}, + {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f"}, + {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05"}, + {file = "coverage-6.3.2-cp310-cp310-win32.whl", hash = "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39"}, + {file = "coverage-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1"}, + {file = "coverage-6.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa"}, + {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518"}, + {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7"}, + {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6"}, + {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad"}, + {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359"}, + {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4"}, + {file = "coverage-6.3.2-cp37-cp37m-win32.whl", hash = "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca"}, + {file = "coverage-6.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3"}, + {file = "coverage-6.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d"}, + {file = "coverage-6.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059"}, + {file = "coverage-6.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512"}, + {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca"}, + {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d"}, + {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0"}, + {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6"}, + {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"}, + {file = "coverage-6.3.2-cp38-cp38-win32.whl", hash = "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e"}, + {file = "coverage-6.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1"}, + {file = "coverage-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620"}, + {file = "coverage-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d"}, + {file = "coverage-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536"}, + {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7"}, + {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2"}, + {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4"}, + {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69"}, + {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684"}, + {file = "coverage-6.3.2-cp39-cp39-win32.whl", hash = "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4"}, + {file = "coverage-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92"}, + {file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"}, + {file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"}, ] distlib = [ {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, @@ -856,12 +853,12 @@ docutils = [ {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, ] filelock = [ - {file = "filelock-3.4.2-py3-none-any.whl", hash = "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"}, - {file = "filelock-3.4.2.tar.gz", hash = "sha256:38b4f4c989f9d06d44524df1b24bd19e167d851f19b50bf3e3559952dddc5b80"}, + {file = "filelock-3.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"}, + {file = "filelock-3.6.0.tar.gz", hash = "sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85"}, ] identify = [ - {file = "identify-2.4.6-py2.py3-none-any.whl", hash = "sha256:cf06b1639e0dca0c184b1504d8b73448c99a68e004a80524c7923b95f7b6837c"}, - {file = "identify-2.4.6.tar.gz", hash = "sha256:233679e3f61a02015d4293dbccf16aa0e4996f868bd114688b8c124f18826706"}, + {file = "identify-2.4.12-py2.py3-none-any.whl", hash = "sha256:5f06b14366bd1facb88b00540a1de05b69b310cbc2654db3c7e07fa3a4339323"}, + {file = "identify-2.4.12.tar.gz", hash = "sha256:3f3244a559290e7d3deb9e9adc7b33594c1bc85a9dd82e0f1be519bf12a1ec17"}, ] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, @@ -872,55 +869,61 @@ imagesize = [ {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.10.1-py3-none-any.whl", hash = "sha256:899e2a40a8c4a1aec681feef45733de8a6c58f3f6a0dbed2eb6574b4387a77b6"}, - {file = "importlib_metadata-4.10.1.tar.gz", hash = "sha256:951f0d8a5b7260e9db5e41d429285b5f451e928479f19d80818878527d36e95e"}, + {file = "importlib_metadata-4.11.3-py3-none-any.whl", hash = "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6"}, + {file = "importlib_metadata-4.11.3.tar.gz", hash = "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] jinja2 = [ - {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, - {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, + {file = "Jinja2-3.1.1-py3-none-any.whl", hash = "sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119"}, + {file = "Jinja2-3.1.1.tar.gz", hash = "sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9"}, ] m2r = [ {file = "m2r-0.2.1.tar.gz", hash = "sha256:bf90bad66cda1164b17e5ba4a037806d2443f2a4d5ddc9f6a5554a0322aaed99"}, ] markupsafe = [ - {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, - {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, + {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, ] mistune = [ {file = "mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4"}, @@ -935,16 +938,16 @@ packaging = [ {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] platformdirs = [ - {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"}, - {file = "platformdirs-2.4.1.tar.gz", hash = "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda"}, + {file = "platformdirs-2.5.1-py3-none-any.whl", hash = "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"}, + {file = "platformdirs-2.5.1.tar.gz", hash = "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] pre-commit = [ - {file = "pre_commit-2.17.0-py2.py3-none-any.whl", hash = "sha256:725fa7459782d7bec5ead072810e47351de01709be838c2ce1726b9591dad616"}, - {file = "pre_commit-2.17.0.tar.gz", hash = "sha256:c1a8040ff15ad3d648c70cc3e55b93e4d2d5b687320955505587fd79bbaed06a"}, + {file = "pre_commit-2.18.1-py2.py3-none-any.whl", hash = "sha256:02226e69564ebca1a070bd1f046af866aa1c318dbc430027c50ab832ed2b73f2"}, + {file = "pre_commit-2.18.1.tar.gz", hash = "sha256:5d445ee1fa8738d506881c5d84f83c62bb5be6b2838e32207433647e8e5ebe10"}, ] py = [ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, @@ -996,12 +999,13 @@ pyparsing = [ {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, ] pytest = [ - {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, - {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, + {file = "pytest-7.1.1-py3-none-any.whl", hash = "sha256:92f723789a8fdd7180b6b06483874feca4c48a5c76968e03bb3e7f806a1869ea"}, + {file = "pytest-7.1.1.tar.gz", hash = "sha256:841132caef6b1ad17a9afde46dc4f6cfa59a05f9555aae5151f73bdf2820ca63"}, ] pytest-asyncio = [ - {file = "pytest-asyncio-0.17.2.tar.gz", hash = "sha256:6d895b02432c028e6957d25fc936494e78c6305736e785d9fee408b1efbc7ff4"}, - {file = "pytest_asyncio-0.17.2-py3-none-any.whl", hash = "sha256:e0fe5dbea40516b661ef1bcfe0bd9461c2847c4ef4bb40012324f2454fb7d56d"}, + {file = "pytest-asyncio-0.18.3.tar.gz", hash = "sha256:7659bdb0a9eb9c6e3ef992eef11a2b3e69697800ad02fb06374a210d85b29f91"}, + {file = "pytest_asyncio-0.18.3-1-py3-none-any.whl", hash = "sha256:16cf40bdf2b4fb7fc8e4b82bd05ce3fbcd454cbf7b92afc445fe299dabb88213"}, + {file = "pytest_asyncio-0.18.3-py3-none-any.whl", hash = "sha256:8fafa6c52161addfd41ee7ab35f11836c5a16ec208f93ee388f752bea3493a84"}, ] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, @@ -1015,8 +1019,8 @@ pytest-sugar = [ {file = "pytest-sugar-0.9.4.tar.gz", hash = "sha256:b1b2186b0a72aada6859bea2a5764145e3aaa2c1cfbb23c3a19b5f7b697563d3"}, ] pytz = [ - {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"}, - {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"}, + {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, + {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, ] pyyaml = [ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, @@ -1113,33 +1117,34 @@ toml = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] tomli = [ - {file = "tomli-2.0.0-py3-none-any.whl", hash = "sha256:b5bde28da1fed24b9bd1d4d2b8cba62300bfb4ec9a6187a957e8ddb9434c5224"}, - {file = "tomli-2.0.0.tar.gz", hash = "sha256:c292c34f58502a1eb2bbb9f5bbc9a5ebc37bee10ffb8c2d6bbdfa8eb13cc14e1"}, + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] tox = [ {file = "tox-3.24.5-py2.py3-none-any.whl", hash = "sha256:be3362472a33094bce26727f5f771ca0facf6dafa217f65875314e9a6600c95c"}, {file = "tox-3.24.5.tar.gz", hash = "sha256:67e0e32c90e278251fea45b696d0fef3879089ccbe979b0c556d35d5a70e2993"}, ] typing-extensions = [ - {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, - {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, + {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, + {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, ] urllib3 = [ - {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, - {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"}, + {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, + {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, ] virtualenv = [ - {file = "virtualenv-20.13.0-py2.py3-none-any.whl", hash = "sha256:339f16c4a86b44240ba7223d0f93a7887c3ca04b5f9c8129da7958447d079b09"}, - {file = "virtualenv-20.13.0.tar.gz", hash = "sha256:d8458cf8d59d0ea495ad9b34c2599487f8a7772d796f9910858376d1600dd2dd"}, + {file = "virtualenv-20.14.0-py2.py3-none-any.whl", hash = "sha256:1e8588f35e8b42c6ec6841a13c5e88239de1e6e4e4cedfd3916b306dc826ec66"}, + {file = "virtualenv-20.14.0.tar.gz", hash = "sha256:8e5b402037287126e81ccde9432b95a8be5b19d36584f64957060a3488c11ca8"}, ] voluptuous = [ - {file = "voluptuous-0.12.2.tar.gz", hash = "sha256:4db1ac5079db9249820d49c891cb4660a6f8cae350491210abce741fabf56513"}, + {file = "voluptuous-0.13.0-py3-none-any.whl", hash = "sha256:e3b5f6cb68fcb0230701b5c756db4caa6766223fc0eaf613931fdba51025981b"}, + {file = "voluptuous-0.13.0.tar.gz", hash = "sha256:cae6a4526b434b642816b34a00e1186d5a5f5e0c948ab94d2a918e01e5874066"}, ] xdoctest = [ {file = "xdoctest-0.15.10-py3-none-any.whl", hash = "sha256:7666bd0511df59275dfe94ef94b0fde9654afd14f00bf88902fdc9bcee77d527"}, {file = "xdoctest-0.15.10.tar.gz", hash = "sha256:5f16438f2b203860e75ec594dbc38020df7524db0b41bb88467ea0a6030e6685"}, ] zipp = [ - {file = "zipp-3.7.0-py3-none-any.whl", hash = "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"}, - {file = "zipp-3.7.0.tar.gz", hash = "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d"}, + {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"}, + {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"}, ] diff --git a/pyproject.toml b/pyproject.toml index ac5618a4d..43e9f6ab0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.4.3" +version = "0.5.0.dev0" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["Your Name "] @@ -18,7 +18,7 @@ kasa = "kasa.cli:cli" python = "^3.7" anyio = "*" # see https://github.com/python-trio/asyncclick/issues/18 importlib-metadata = "*" -asyncclick = ">=7" +asyncclick = ">=8" pydantic = "^1" # required only for docs From d8481173843a49e47bdee139e86840b7498c58d0 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 6 Apr 2022 01:13:27 +0200 Subject: [PATCH 094/892] Various documentation updates (#333) * Add a note about socket sharing * Show inherited members for apidocs * Remove outdated note of emeters not being supported on smartstrips * Describe emeter and usage modules, add note about NTP for time sync * Describe lib design and modules * Bump sphinx version, ignore d001 (line-length) for doc8 * demote energy & usage to 3rd level, promote api for 2nd --- .pre-commit-config.yaml | 6 +++ docs/source/design.rst | 50 ++++++++++++++++++++++ docs/source/index.rst | 1 + docs/source/smartbulb.rst | 1 + docs/source/smartdevice.rst | 76 +++++++++++++++++++++++++++------ docs/source/smartdimmer.rst | 1 + docs/source/smartlightstrip.rst | 1 + docs/source/smartplug.rst | 1 + docs/source/smartstrip.rst | 6 +-- poetry.lock | 19 +++++---- pyproject.toml | 6 ++- 11 files changed, 140 insertions(+), 28 deletions(-) create mode 100644 docs/source/design.rst diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c99d152d9..0fe7bdc44 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,3 +37,9 @@ repos: hooks: - id: mypy additional_dependencies: [types-click] + +- repo: https://github.com/PyCQA/doc8 + rev: '0.11.1' + hooks: + - id: doc8 + additional_dependencies: [tomli] diff --git a/docs/source/design.rst b/docs/source/design.rst new file mode 100644 index 000000000..b3d6b3591 --- /dev/null +++ b/docs/source/design.rst @@ -0,0 +1,50 @@ +.. py:module:: kasa.modules + +.. _library_design: + +Library Design & Modules +======================== + +This page aims to provide some details on the design and internals of this library. +You might be interested in this if you want to improve this library, +or if you are just looking to access some information that is not currently exposed. + +.. _update_cycle: + +Update Cycle +************ + +When :meth:`~kasa.SmartDevice.update()` is called, +the library constructs a query to send to the device based on :ref:`supported modules `. +Internally, each module defines :meth:`~kasa.modules.Module.query()` to describe what they want query during the update. + +The returned data is cached internally to avoid I/O on property accesses. +All properties defined both in the device class and in the module classes follow this principle. + +While the properties are designed to provide a nice API to use for common use cases, +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 +******* + +The functionality provided by all :class:`~kasa.SmartDevice` instances is (mostly) done inside separate modules. +While the individual device-type specific classes provide an easy access for the most import features, +you can also access individual modules through :attr:`kasa.SmartDevice.modules`. +You can get the list of supported modules for a given device instance using :attr:`~kasa.SmartDevice.supported_modules`. + +.. note:: + + If you only need some module-specific information, + you can call the wanted method on the module to avoid using :meth:`~kasa.SmartDevice.update`. + + +API documentation for modules +***************************** + +.. automodule:: kasa.modules + :members: + :inherited-members: + :undoc-members: diff --git a/docs/source/index.rst b/docs/source/index.rst index 2804757e2..711d1474a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -8,6 +8,7 @@ cli discover smartdevice + design smartbulb smartplug smartdimmer diff --git a/docs/source/smartbulb.rst b/docs/source/smartbulb.rst index bf58ecbf5..3f2baa407 100644 --- a/docs/source/smartbulb.rst +++ b/docs/source/smartbulb.rst @@ -56,4 +56,5 @@ API documentation .. autoclass:: kasa.SmartBulb :members: + :inherited-members: :undoc-members: diff --git a/docs/source/smartdevice.rst b/docs/source/smartdevice.rst index 6b83c1a57..42b838568 100644 --- a/docs/source/smartdevice.rst +++ b/docs/source/smartdevice.rst @@ -1,12 +1,20 @@ +.. py:module:: kasa + Common API -====================== +========== The basic functionalities of all supported devices are accessible using the common :class:`SmartDevice` base class. -The property accesses use the data obtained before by awaiting :func:`update()`. +The property accesses use the data obtained before by awaiting :func:`SmartDevice.update()`. The values are cached until the next update call. In practice this means that property accesses do no I/O and are dependent, while I/O producing methods need to be awaited. +See :ref:`library_design` for more detailed information. + +.. note:: + The device instances share the communication socket in background to optimize I/O accesses. + This means that you need to use the same event loop for subsequent requests. + The library gives a warning ("Detected protocol reuse between different event loop") to hint if you are accessing the device incorrectly. -Methods changing the state of the device do not invalidate the cache (i.e., there is no implicit `update()`). +Methods changing the state of the device do not invalidate the cache (i.e., there is no implicit :func:`SmartDevice.update()` call made by the library). You can assume that the operation has succeeded if no exception is raised. These methods will return the device response, which can be useful for some use cases. @@ -22,28 +30,70 @@ Simple example script showing some functionality: async def main(): p = SmartPlug("127.0.0.1") - await p.update() - print(p.alias) + await p.update() # Request the update + print(p.alias) # Print out the alias + print(p.emeter_realtime) # Print out current emeter status + + await p.turn_off() # Turn the device off - await p.turn_off() + if __name__ == "__main__": + asyncio.run(main()) +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 + + import asyncio + from kasa import SmartPlug + + async def main(): + dev = SmartPlug("127.0.0.1") # We create the instance inside the main loop + while True: + await dev.update() # Request an update + print(dev.emeter_realtime) + await asyncio.sleep(0.5) # Sleep some time between updates if __name__ == "__main__": asyncio.run(main()) Refer to device type specific classes for more examples: +:class:`SmartPlug`, :class:`SmartBulb`, :class:`SmartStrip`, +:class:`SmartDimmer`, :class:`SmartLightStrip`. + +Energy Consumption and Usage Statistics +*************************************** + +.. note:: + In order to use the helper methods to calculate the statistics correctly, your devices need to have correct time set. + The devices use NTP and public servers from `NTP Pool Project `_ to synchronize their time. + +Energy Consumption +~~~~~~~~~~~~~~~~~~ + +The availability of energy consumption sensors depend on the device. +While most of the bulbs support it, only specific switches (e.g., HS110) or strips (e.g., HS300) support it. +You can use :attr:`~SmartDevice.has_emeter` to check for the availability. + + +Usage statistics +~~~~~~~~~~~~~~~~ + +You can use :attr:`~SmartDevice.on_since` to query for the time the device has been turned on. +Some devices also support reporting the usage statistics on daily or monthly basis. +You can access this information using through the usage module (:class:`kasa.modules.Usage`): + +.. code-block:: python -* :class:`SmartPlug` -* :class:`SmartBulb` -* :class:`SmartStrip` -* :class:`SmartDimmer` -* :class:`SmartLightStrip` + dev = SmartPlug("127.0.0.1") + usage = dev.modules["usage"] + print(f"Minutes on this month: {usage.usage_this_month}") + print(f"Minutes on today: {usage.usage_today}") API documentation -~~~~~~~~~~~~~~~~~ +***************** -.. autoclass:: kasa.SmartDevice +.. autoclass:: SmartDevice :members: :undoc-members: diff --git a/docs/source/smartdimmer.rst b/docs/source/smartdimmer.rst index fa4e1ece1..b44d8e0c8 100644 --- a/docs/source/smartdimmer.rst +++ b/docs/source/smartdimmer.rst @@ -10,4 +10,5 @@ API documentation .. autoclass:: kasa.SmartDimmer :members: + :inherited-members: :undoc-members: diff --git a/docs/source/smartlightstrip.rst b/docs/source/smartlightstrip.rst index 4d34efbbd..b961be5bb 100644 --- a/docs/source/smartlightstrip.rst +++ b/docs/source/smartlightstrip.rst @@ -10,4 +10,5 @@ API documentation .. autoclass:: kasa.SmartLightStrip :members: + :inherited-members: :undoc-members: diff --git a/docs/source/smartplug.rst b/docs/source/smartplug.rst index e9b8ccdfd..94305ca87 100644 --- a/docs/source/smartplug.rst +++ b/docs/source/smartplug.rst @@ -11,4 +11,5 @@ API documentation .. autoclass:: kasa.SmartPlug :members: + :inherited-members: :undoc-members: diff --git a/docs/source/smartstrip.rst b/docs/source/smartstrip.rst index edd4953bc..66d78f9c6 100644 --- a/docs/source/smartstrip.rst +++ b/docs/source/smartstrip.rst @@ -1,11 +1,6 @@ Smart strips ============ - -.. note:: - - The emeter feature is currently not implemented for smart strips. See https://github.com/python-kasa/python-kasa/issues/64 for details. - .. note:: Feel free to open a pull request to improve the documentation! @@ -34,4 +29,5 @@ API documentation .. autoclass:: kasa.SmartStrip :members: + :inherited-members: :undoc-members: diff --git a/poetry.lock b/poetry.lock index aa1231d90..4521b423d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -497,18 +497,19 @@ python-versions = "*" [[package]] name = "sphinx" -version = "3.5.4" +version = "4.5.0" description = "Python documentation generator" category = "main" optional = true -python-versions = ">=3.5" +python-versions = ">=3.6" [package.dependencies] alabaster = ">=0.7,<0.8" babel = ">=1.3" colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.12,<0.17" +docutils = ">=0.14,<0.18" imagesize = "*" +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} Jinja2 = ">=2.3" packaging = "*" Pygments = ">=2.0" @@ -516,14 +517,14 @@ requests = ">=2.5.0" snowballstemmer = ">=1.1" sphinxcontrib-applehelp = "*" sphinxcontrib-devhelp = "*" -sphinxcontrib-htmlhelp = "*" +sphinxcontrib-htmlhelp = ">=2.0.0" sphinxcontrib-jsmath = "*" sphinxcontrib-qthelp = "*" -sphinxcontrib-serializinghtml = "*" +sphinxcontrib-serializinghtml = ">=1.1.5" [package.extras] docs = ["sphinxcontrib-websupport"] -lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.800)", "docutils-stubs"] +lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.931)", "docutils-stubs", "types-typed-ast", "types-requests"] test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"] [[package]] @@ -754,7 +755,7 @@ docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programou [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "cbc8eb721e3b498c25eef73c95b2aa309419fa075b878c18cac0b148113c25f9" +content-hash = "6577513a016c329bc825369761eae9971cb6a18a13c96ac0669c1f51ab3de87d" [metadata.files] alabaster = [ @@ -1074,8 +1075,8 @@ snowballstemmer = [ {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, ] sphinx = [ - {file = "Sphinx-3.5.4-py3-none-any.whl", hash = "sha256:2320d4e994a191f4b4be27da514e46b3d6b420f2ff895d064f52415d342461e8"}, - {file = "Sphinx-3.5.4.tar.gz", hash = "sha256:19010b7b9fa0dc7756a6e105b2aacd3a80f798af3c25c273be64d7beeb482cb1"}, + {file = "Sphinx-4.5.0-py3-none-any.whl", hash = "sha256:ebf612653238bcc8f4359627a9b7ce44ede6fdd75d9d30f68255c7383d3a6226"}, + {file = "Sphinx-4.5.0.tar.gz", hash = "sha256:7bf8ca9637a4ee15af412d1a1d9689fec70523a68ca9bb9127c2f3eeb344e2e6"}, ] sphinx-rtd-theme = [ {file = "sphinx_rtd_theme-0.5.2-py2.py3-none-any.whl", hash = "sha256:4a05bdbe8b1446d77a01e20a23ebc6777c74f43237035e76be89699308987d6f"}, diff --git a/pyproject.toml b/pyproject.toml index 43e9f6ab0..daf648b5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ asyncclick = ">=8" pydantic = "^1" # required only for docs -sphinx = { version = "^3", optional = true } +sphinx = { version = "^4", optional = true } m2r = { version = "^0", optional = true } mistune = { version = "<2.0.0", optional = true } sphinx_rtd_theme = { version = "^0", optional = true } @@ -81,6 +81,10 @@ markers = [ "requires_dummy: test requires dummy data to pass, skipped on real devices", ] +[tool.doc8] +paths = ["docs"] +ignore = ["D001"] + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" From a39cef9a8cb597fd2ae1d8b7fcd498ddf17ecd14 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 6 Apr 2022 01:41:08 +0200 Subject: [PATCH 095/892] Export modules & make sphinx happy (#334) --- docs/source/design.rst | 1 + kasa/modules/__init__.py | 17 ++++++++++++++++- kasa/smartbulb.py | 6 +++--- kasa/smartdimmer.py | 4 ++-- kasa/smartplug.py | 4 ++-- kasa/smartstrip.py | 4 ++-- 6 files changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/source/design.rst b/docs/source/design.rst index b3d6b3591..140c83d8f 100644 --- a/docs/source/design.rst +++ b/docs/source/design.rst @@ -45,6 +45,7 @@ API documentation for modules ***************************** .. automodule:: kasa.modules + :noindex: :members: :inherited-members: :undoc-members: diff --git a/kasa/modules/__init__.py b/kasa/modules/__init__.py index e5cb83d66..8ad5088d5 100644 --- a/kasa/modules/__init__.py +++ b/kasa/modules/__init__.py @@ -1,4 +1,4 @@ -# flake8: noqa +"""Module for individual feature modules.""" from .ambientlight import AmbientLight from .antitheft import Antitheft from .cloud import Cloud @@ -10,3 +10,18 @@ from .schedule import Schedule from .time import Time from .usage import Usage + +__all__ = [ + "AmbientLight", + "Antitheft", + "Cloud", + "Countdown", + "Emeter", + "Module", + "Motion", + "Rule", + "RuleModule", + "Schedule", + "Time", + "Usage", +] diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index f941dcf1f..f060d2563 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -42,7 +42,7 @@ class HSV(NamedTuple): class SmartBulb(SmartDevice): - """Representation of a TP-Link Smart Bulb. + r"""Representation of a TP-Link Smart Bulb. To initialize, you have to await :func:`update()` at least once. This will allow accessing the properties using the exposed properties. @@ -50,7 +50,7 @@ class SmartBulb(SmartDevice): All changes to the device are done using awaitable methods, which will not change the cached values, but you must await :func:`update()` separately. - Errors reported by the device are raised as :class:`SmartDeviceException`s, + Errors reported by the device are raised as :class:`SmartDeviceException`\s, and should be handled by the user of the library. Examples: @@ -68,7 +68,7 @@ class SmartBulb(SmartDevice): >>> print(bulb.is_on) True - You can use the is_-prefixed properties to check for supported features + You can use the ``is_``-prefixed properties to check for supported features >>> bulb.is_dimmable True >>> bulb.is_color diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py index 5c06b8b94..cb830d233 100644 --- a/kasa/smartdimmer.py +++ b/kasa/smartdimmer.py @@ -7,7 +7,7 @@ class SmartDimmer(SmartPlug): - """Representation of a TP-Link Smart Dimmer. + r"""Representation of a TP-Link Smart Dimmer. Dimmers work similarly to plugs, but provide also support for adjusting the brightness. This class extends :class:`SmartPlug` interface. @@ -18,7 +18,7 @@ class SmartDimmer(SmartPlug): All changes to the device are done using awaitable methods, which will not change the cached values, but you must await :func:`update()` separately. - Errors reported by the device are raised as :class:`SmartDeviceException`s, + Errors reported by the device are raised as :class:`SmartDeviceException`\s, and should be handled by the user of the library. Examples: diff --git a/kasa/smartplug.py b/kasa/smartplug.py index b636c3e11..d49e40542 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -9,7 +9,7 @@ class SmartPlug(SmartDevice): - """Representation of a TP-Link Smart Switch. + r"""Representation of a TP-Link Smart Switch. To initialize, you have to await :func:`update()` at least once. This will allow accessing the properties using the exposed properties. @@ -17,7 +17,7 @@ class SmartPlug(SmartDevice): All changes to the device are done using awaitable methods, which will not change the cached values, but you must await :func:`update()` separately. - Errors reported by the device are raised as :class:`SmartDeviceException`s, + Errors reported by the device are raised as :class:`SmartDeviceException`\s, and should be handled by the user of the library. Examples: diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index ba863d059..47ada6723 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -29,7 +29,7 @@ def merge_sums(dicts): class SmartStrip(SmartDevice): - """Representation of a TP-Link Smart Power Strip. + r"""Representation of a TP-Link Smart Power Strip. A strip consists of the parent device and its children. All methods of the parent act on all children, while the child devices @@ -41,7 +41,7 @@ class SmartStrip(SmartDevice): All changes to the device are done using awaitable methods, which will not change the cached values, but you must await :func:`update()` separately. - Errors reported by the device are raised as :class:`SmartDeviceException`s, + Errors reported by the device are raised as :class:`SmartDeviceException`\s, and should be handled by the user of the library. Examples: From 6e988bd9a953294450b63d0b547866bee6999bf7 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 6 Apr 2022 02:25:47 +0200 Subject: [PATCH 096/892] Avoid discovery on --help (#335) --- kasa/cli.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/kasa/cli.py b/kasa/cli.py index c9cab4b50..5c1e18f64 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1,6 +1,7 @@ """python-kasa cli tool.""" import asyncio import logging +import sys from pprint import pformat as pf from typing import cast @@ -66,6 +67,12 @@ @click.pass_context async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip, type): """A tool for controlling TP-Link smart home devices.""" # noqa + # no need to perform any checks if we are just displaying the help + if sys.argv[-1] == "--help": + # Context object is required to avoid crashing on sub-groups + ctx.obj = SmartDevice(None) + return + if debug: logging.basicConfig(level=logging.DEBUG) else: From 631762b50c60c77e7dbe1d13d4e0c34d3f9e8b86 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 6 Apr 2022 03:39:50 +0200 Subject: [PATCH 097/892] Drop deprecated, type-specific options in favor of --type (#336) * Drop deprecated, type-specific options in favor of --type * Fix tests --- kasa/cli.py | 25 +++---------------------- kasa/tests/test_cli.py | 16 ---------------- 2 files changed, 3 insertions(+), 38 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 5c1e18f64..40e84e77f 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -53,10 +53,6 @@ help="The broadcast address to be used for discovery.", ) @click.option("-d", "--debug", envvar="KASA_DEBUG", default=False, is_flag=True) -@click.option("--bulb", default=False, is_flag=True) -@click.option("--plug", default=False, is_flag=True) -@click.option("--lightstrip", default=False, is_flag=True) -@click.option("--strip", default=False, is_flag=True) @click.option( "--type", envvar="KASA_TYPE", @@ -65,7 +61,7 @@ ) @click.version_option(package_name="python-kasa") @click.pass_context -async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip, type): +async def cli(ctx, host, alias, target, debug, type): """A tool for controlling TP-Link smart home devices.""" # noqa # no need to perform any checks if we are just displaying the help if sys.argv[-1] == "--help": @@ -95,19 +91,6 @@ async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip, ty await ctx.invoke(discover) return - if bulb or plug or strip or lightstrip: - click.echo( - "Using --bulb, --plug, --strip, and --lightstrip is deprecated. Use --type instead to define the type" - ) - if bulb: - type = "bulb" - elif plug: - type = "plug" - elif strip: - type = "strip" - elif lightstrip: - type = "lightstrip" - if type is not None: dev = TYPE_TO_CLASS[type](host) else: @@ -158,9 +141,8 @@ async def join(dev: SmartDevice, ssid, password, keytype): @cli.command() @click.option("--timeout", default=3, required=False) -@click.option("--dump-raw", is_flag=True) @click.pass_context -async def discover(ctx, timeout, dump_raw): +async def discover(ctx, timeout): """Discover devices in the network.""" target = ctx.parent.params["target"] click.echo(f"Discovering devices on {target} for {timeout} seconds") @@ -201,8 +183,7 @@ async def sysinfo(dev): @cli.command() @pass_dev -@click.pass_context -async def state(ctx, dev: SmartDevice): +async def state(dev: SmartDevice): """Print out device state and versions.""" click.echo(click.style(f"== {dev.alias} - {dev.model} ==", bold=True)) click.echo(f"\tHost: {dev.host}") diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index ae4fa1759..289c5b58d 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -106,22 +106,6 @@ async def test_brightness(dev): assert "Brightness: 12" in res.output -def _generate_type_class_pairs(): - yield from TYPE_TO_CLASS.items() - - -@pytest.mark.parametrize("type_class", _generate_type_class_pairs()) -async def test_deprecated_type(dev, type_class, mocker): - """Make sure that using deprecated types yields a warning.""" - type, cls = type_class - if type == "dimmer": - return - runner = CliRunner() - with mocker.patch("kasa.SmartDevice.update"): - res = await runner.invoke(cli, ["--host", "127.0.0.2", f"--{type}"]) - assert "Using --bulb, --plug, --strip, and --lightstrip is deprecated" in res.output - - async def test_temperature(dev): pass From d2581bf07739e5ac5dc2883ec3d4806524d302a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Apr 2022 14:51:15 -1000 Subject: [PATCH 098/892] Add fixtures for kl420 (#339) * Add fixtures for kl420 * readme --- README.md | 1 + kasa/tests/conftest.py | 2 +- .../tests/fixtures/KL420L5(US)_1.0_1.0.2.json | 57 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/KL420L5(US)_1.0_1.0.2.json diff --git a/README.md b/README.md index 126b4afcc..9d62cb818 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. ### Light strips * KL400 +* KL420 * KL430 **Contributions (be it adding missing features, fixing bugs or improving documentation) are more than welcome, feel free to submit pull requests!** diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index f9fc917f6..93d374734 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -25,7 +25,7 @@ ) -LIGHT_STRIPS = {"KL400", "KL430"} +LIGHT_STRIPS = {"KL400", "KL430", "KL420"} VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL125", "KL130", "KL135", "KL430"} COLOR_BULBS = {"LB130", "KL125", "KL130", "KL135", *LIGHT_STRIPS} BULBS = { diff --git a/kasa/tests/fixtures/KL420L5(US)_1.0_1.0.2.json b/kasa/tests/fixtures/KL420L5(US)_1.0_1.0.2.json new file mode 100644 index 000000000..0d19e7949 --- /dev/null +++ b/kasa/tests/fixtures/KL420L5(US)_1.0_1.0.2.json @@ -0,0 +1,57 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 1503, + "total_wh": 0 + } + }, + "system": { + "get_sysinfo": { + "LEF": 1, + "active_mode": "none", + "alias": "Kl420 test", + "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": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "latitude_i": 0, + "length": 50, + "light_state": { + "brightness": 100, + "color_temp": 6500, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "lighting_effect_state": { + "brightness": 50, + "custom": 0, + "enable": 0, + "id": "", + "name": "station" + }, + "longitude_i": 0, + "mic_mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL420L5(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -44, + "status": "new", + "sw_ver": "1.0.2 Build 211009 Rel.164949" + } + } +} From d908a5ab2a58e3883309b05303c779eee0092558 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Apr 2022 07:38:42 -1000 Subject: [PATCH 099/892] Avoid retrying open_connection on unrecoverable errors (#340) * Avoid retrying open_connection on unrecoverable errors - We can retry so hard that we block the event loop Fixes ``` 2022-04-16 22:18:51 WARNING (MainThread) [asyncio] Executing exception=ConnectionRefusedError(61, "Connect call failed (192.168.107.200, 9999)") created at /opt/homebrew/Cellar/python@3.9/3.9.12/Frameworks/Python.framework/Versions/3.9/lib/python3.9/asyncio/tasks.py:460> took 1.001 seconds ``` * comment --- kasa/protocol.py | 23 +++++++++++++++++++++++ kasa/tests/test_protocol.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/kasa/protocol.py b/kasa/protocol.py index 24c2cd056..b6d44be90 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -11,6 +11,7 @@ """ import asyncio import contextlib +import errno import json import logging import struct @@ -20,6 +21,7 @@ from .exceptions import SmartDeviceException _LOGGER = logging.getLogger(__name__) +_NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} class TPLinkSmartHomeProtocol: @@ -115,9 +117,30 @@ def _reset(self) -> None: async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: """Try to query a device.""" + # + # Most of the time we will already be connected if the device is online + # and the connect call will do nothing and return right away + # + # However, if we get an unrecoverable error (_NO_RETRY_ERRORS and ConnectionRefusedError) + # we do not want to keep trying since many connection open/close operations + # in the same time frame can block the event loop. This is especially + # import when there are multiple tplink devices being polled. + # for retry in range(retry_count + 1): try: await self._connect(timeout) + except ConnectionRefusedError as ex: + await self.close() + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {ex}" + ) + except OSError as ex: + await self.close() + if ex.errno in _NO_RETRY_ERRORS or retry >= retry_count: + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {ex}" + ) + continue except Exception as ex: await self.close() if retry >= retry_count: diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 5fe4763d5..f8931c11e 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -1,3 +1,4 @@ +import errno import json import logging import struct @@ -29,6 +30,39 @@ def aio_mock_writer(_, __): assert conn.call_count == retry_count + 1 +async def test_protocol_no_retry_on_unreachable(mocker): + conn = mocker.patch( + "asyncio.open_connection", + side_effect=OSError(errno.EHOSTUNREACH, "No route to host"), + ) + with pytest.raises(SmartDeviceException): + await TPLinkSmartHomeProtocol("127.0.0.1").query({}, retry_count=5) + + assert conn.call_count == 1 + + +async def test_protocol_no_retry_connection_refused(mocker): + conn = mocker.patch( + "asyncio.open_connection", + side_effect=ConnectionRefusedError, + ) + with pytest.raises(SmartDeviceException): + await TPLinkSmartHomeProtocol("127.0.0.1").query({}, retry_count=5) + + assert conn.call_count == 1 + + +async def test_protocol_retry_recoverable_error(mocker): + conn = mocker.patch( + "asyncio.open_connection", + side_effect=OSError(errno.ECONNRESET, "Connection reset by peer"), + ) + with pytest.raises(SmartDeviceException): + await TPLinkSmartHomeProtocol("127.0.0.1").query({}, retry_count=5) + + assert conn.call_count == 6 + + @pytest.mark.skipif(sys.version_info < (3, 8), reason="3.8 is first one with asyncmock") @pytest.mark.parametrize("retry_count", [1, 3, 5]) async def test_protocol_reconnect(mocker, retry_count): From 51fb908d8b64cc675a96abfcdea0847d50c2a1ba Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 25 Apr 2022 00:13:24 +0200 Subject: [PATCH 100/892] Prepare 0.5.0 (#342) * Prepare 0.5.0 * Add note about how to include release summary to changelog --- .github_changelog_generator | 3 ++ CHANGELOG.md | 76 +++++++++++++++++++++++++++++++++++-- RELEASING.md | 8 +++- pyproject.toml | 2 +- 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/.github_changelog_generator b/.github_changelog_generator index db89ad20c..0341d4088 100644 --- a/.github_changelog_generator +++ b/.github_changelog_generator @@ -1 +1,4 @@ +breaking_labels=breaking change +add-sections={"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]}} +release_branch=master usernames-as-github-logins=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 89058f1f8..57b921061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,63 @@ # Changelog +## [0.5.0](https://github.com/python-kasa/python-kasa/tree/0.5.0) (2022-04-24) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.3...0.5.0) + +This is the first release of 0.5 series which includes converting the code base towards more modular approach where device-exposed modules (e.g., emeter, antitheft, or schedule) are implemented in their separate python modules to decouple them from the device-specific classes. + +There should be no API breaking changes, but some previous issues hint that there may be as information from all supported modules are now requested during each update cycle (depending on the device type): +* Basic system info +* Emeter +* Time - properties (like `on_since`) use now time from the device for calculation to avoid jitter caused by different time between the host and the device +* Usage statistics - similar interface to emeter, but reports on-time statistics instead of energy consumption (new) +* Countdown (new) +* Antitheft (new) +* Schedule (new) +* Motion - for configuring motion settings on some dimmers (new) +* Ambientlight - for configuring brightness limits when motion sensor actuates on some dimmers (new) +* Cloud - information about cloud connectivity (new) + +For developers, the new functionalities are currently only exposed through the implementation modules accessible through `modules` property. +Pull requests improving the functionality of modules as well as adding better interfaces to device classes are welcome! + +**Breaking changes:** + +- Drop deprecated, type-specific options in favor of --type [\#336](https://github.com/python-kasa/python-kasa/pull/336) (@rytilahti) +- Convert the codebase to be more modular [\#299](https://github.com/python-kasa/python-kasa/pull/299) (@rytilahti) + +**Implemented enhancements:** + +- Improve HS220 support [\#44](https://github.com/python-kasa/python-kasa/issues/44) + +**Fixed bugs:** + +- Skip running discovery on --help on subcommands [\#122](https://github.com/python-kasa/python-kasa/issues/122) +- Avoid retrying open\_connection on unrecoverable errors [\#340](https://github.com/python-kasa/python-kasa/pull/340) (@bdraco) +- Avoid discovery on --help [\#335](https://github.com/python-kasa/python-kasa/pull/335) (@rytilahti) + +**Documentation updates:** + +- Trying to poll device every 5 seconds but getting asyncio errors [\#316](https://github.com/python-kasa/python-kasa/issues/316) +- Docs: Smart Strip - Emeter feature Note [\#257](https://github.com/python-kasa/python-kasa/issues/257) +- Documentation addition: Smartplug access to internet ntp server pool. [\#129](https://github.com/python-kasa/python-kasa/issues/129) +- Export modules & make sphinx happy [\#334](https://github.com/python-kasa/python-kasa/pull/334) (@rytilahti) +- Various documentation updates [\#333](https://github.com/python-kasa/python-kasa/pull/333) (@rytilahti) + +**Closed issues:** + +- "on since" changes [\#295](https://github.com/python-kasa/python-kasa/issues/295) +- How to access KP115 runtime data? [\#244](https://github.com/python-kasa/python-kasa/issues/244) +- How to resolve "Detected protocol reuse between different event loop" warning? [\#238](https://github.com/python-kasa/python-kasa/issues/238) +- Handle discovery where multiple LAN interfaces exist [\#104](https://github.com/python-kasa/python-kasa/issues/104) +- Hyper-V \(and probably virtualbox\) break UDP discovery [\#101](https://github.com/python-kasa/python-kasa/issues/101) +- Trying to get extended lightstrip functionality [\#100](https://github.com/python-kasa/python-kasa/issues/100) +- Can the HS105 be controlled without internet? [\#72](https://github.com/python-kasa/python-kasa/issues/72) + +**Merged pull requests:** + +- Add fixtures for kl420 [\#339](https://github.com/python-kasa/python-kasa/pull/339) (@bdraco) + ## [0.4.3](https://github.com/python-kasa/python-kasa/tree/0.4.3) (2022-04-05) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.2...0.4.3) @@ -15,6 +73,7 @@ **Merged pull requests:** +- Release 0.4.3 [\#332](https://github.com/python-kasa/python-kasa/pull/332) (@rytilahti) - Update pre-commit hooks to fix black in CI [\#331](https://github.com/python-kasa/python-kasa/pull/331) (@rytilahti) - Fix test\_deprecated\_type stalling [\#325](https://github.com/python-kasa/python-kasa/pull/325) (@bdraco) @@ -167,6 +226,10 @@ - HS300 Children plugs have emeter [\#64](https://github.com/python-kasa/python-kasa/issues/64) - dump\_devinfo: handle latitude/longitude keys properly [\#175](https://github.com/python-kasa/python-kasa/pull/175) (@rytilahti) +**Documentation updates:** + +- Discover does not support specifying network interface [\#167](https://github.com/python-kasa/python-kasa/issues/167) + **Closed issues:** - Feature Request - Toggle Command [\#188](https://github.com/python-kasa/python-kasa/issues/188) @@ -175,7 +238,6 @@ - Help needed - awaiting game [\#179](https://github.com/python-kasa/python-kasa/issues/179) - Version inconsistency between CLI and pip [\#177](https://github.com/python-kasa/python-kasa/issues/177) - Release 0.4.0.dev3? [\#169](https://github.com/python-kasa/python-kasa/issues/169) -- Discover does not support specifying network interface [\#167](https://github.com/python-kasa/python-kasa/issues/167) - Can't command or query HS200 v5 switch [\#161](https://github.com/python-kasa/python-kasa/issues/161) **Merged pull requests:** @@ -202,6 +264,11 @@ - Simplify discovery query, refactor dump-devinfo [\#147](https://github.com/python-kasa/python-kasa/pull/147) (@rytilahti) - Return None instead of raising an exception on missing, valid emeter keys [\#146](https://github.com/python-kasa/python-kasa/pull/146) (@rytilahti) +**Documentation updates:** + +- Add ability to control individual sockets on KP400 [\#121](https://github.com/python-kasa/python-kasa/issues/121) +- Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) (@rytilahti) + **Closed issues:** - After installing, command `kasa` not found [\#165](https://github.com/python-kasa/python-kasa/issues/165) @@ -211,7 +278,6 @@ - Poetry returns error when installing dependencies [\#131](https://github.com/python-kasa/python-kasa/issues/131) - 'kasa wifi scan' raises RuntimeError [\#127](https://github.com/python-kasa/python-kasa/issues/127) - Runtime Error when I execute Kasa emeter command [\#124](https://github.com/python-kasa/python-kasa/issues/124) -- Add ability to control individual sockets on KP400 [\#121](https://github.com/python-kasa/python-kasa/issues/121) - HS105\(US\) HW 5.0/SW 1.0.2 Not Working [\#119](https://github.com/python-kasa/python-kasa/issues/119) - HS110\(UK\) not discoverable [\#113](https://github.com/python-kasa/python-kasa/issues/113) - Stopping Kasa SmartDevices from phoning home [\#111](https://github.com/python-kasa/python-kasa/issues/111) @@ -228,7 +294,6 @@ - Fix documentation on Smart strips [\#136](https://github.com/python-kasa/python-kasa/pull/136) (@flavio-fernandes) - add tapo link, fix tplink-smarthome-simulator link [\#133](https://github.com/python-kasa/python-kasa/pull/133) (@rytilahti) - Leverage data from UDP discovery to initialize device structure [\#132](https://github.com/python-kasa/python-kasa/pull/132) (@dlee1j1) -- Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) (@rytilahti) - Add HS220 hw 2.0 fixture [\#107](https://github.com/python-kasa/python-kasa/pull/107) (@appleguru) ## [0.4.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev2) (2020-11-21) @@ -272,6 +337,10 @@ - Improve retry logic for discovery, messaging \(was: Handle empty responses\) [\#38](https://github.com/python-kasa/python-kasa/issues/38) - Add support for lightstrips \(KL430\) [\#74](https://github.com/python-kasa/python-kasa/pull/74) (@rytilahti) +**Documentation updates:** + +- Improve poetry usage documentation [\#60](https://github.com/python-kasa/python-kasa/issues/60) + **Closed issues:** - I don't python... how do I make this executable? [\#88](https://github.com/python-kasa/python-kasa/issues/88) @@ -279,7 +348,6 @@ - not able to pip install the library [\#82](https://github.com/python-kasa/python-kasa/issues/82) - Discover.discover\(\) add selecting network interface \[pull request\] [\#78](https://github.com/python-kasa/python-kasa/issues/78) - LB100 unable to turn on or off the lights [\#68](https://github.com/python-kasa/python-kasa/issues/68) -- Improve poetry usage documentation [\#60](https://github.com/python-kasa/python-kasa/issues/60) - sys\_info not None fails assertion [\#55](https://github.com/python-kasa/python-kasa/issues/55) - Upload pre-release to pypi for easier testing [\#17](https://github.com/python-kasa/python-kasa/issues/17) diff --git a/RELEASING.md b/RELEASING.md index adc8a05e5..37d2e4ca6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -12,6 +12,12 @@ export NEW_RELEASE=0.4.0.dev4 poetry version $NEW_RELEASE ``` +3. Write a short and understandable summary for the release. + +* Create a new issue and label it with release-summary +* Create $NEW_RELEASE milestone in github, and assign the issue to that +* Close the issue + 3. Generate changelog ```bash @@ -21,8 +27,6 @@ export CHANGELOG_GITHUB_TOKEN=token github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md ``` -3. Write a short and understandable summary for the release. - 4. Commit the changed files ```bash diff --git a/pyproject.toml b/pyproject.toml index daf648b5e..688b80ab9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.5.0.dev0" +version = "0.5.0" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["Your Name "] From 604520dcaf1d99707601effe2511f8fae2ea7d88 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 25 Apr 2022 14:45:53 +0200 Subject: [PATCH 101/892] Add codeql checks (#338) --- .github/workflows/codeql-analysis.yml | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/codeql-analysis.yml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 000000000..b8d5f3968 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,35 @@ +name: "CodeQL checks" + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + schedule: + - cron: '44 17 * * 3' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 From 8e6cfd003edf9656c1c2b09862b37d96dc8b98d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 May 2022 09:02:17 -0500 Subject: [PATCH 102/892] Add fixtures for KP100 (#343) * Add fixtures for KP100 * readme --- README.md | 1 + kasa/tests/conftest.py | 1 + kasa/tests/fixtures/KP100(US)_3.0_1.0.1.json | 31 ++++++++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 kasa/tests/fixtures/KP100(US)_3.0_1.0.1.json diff --git a/README.md b/README.md index 9d62cb818..d47ba1139 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * HS105 * HS107 * HS110 +* KP100 * KP105 * KP115 * KP401 diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 93d374734..8b4e5bc95 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -48,6 +48,7 @@ "HS200", "HS210", "EP10", + "KP100", "KP115", "KP105", "KP401", diff --git a/kasa/tests/fixtures/KP100(US)_3.0_1.0.1.json b/kasa/tests/fixtures/KP100(US)_3.0_1.0.1.json new file mode 100644 index 000000000..fb62654b5 --- /dev/null +++ b/kasa/tests/fixtures/KP100(US)_3.0_1.0.1.json @@ -0,0 +1,31 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Kasa", + "dev_name": "Smart Wi-Fi Plug Mini", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "3.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP100(US)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 523, + "relay_state": 1, + "rssi": -60, + "status": "new", + "sw_ver": "1.0.1 Build 200831 Rel.142128", + "updating": 0 + } + } +} From 91ba1d5ac6b95a797cec2f2c42f7f2bd11bff5e3 Mon Sep 17 00:00:00 2001 From: James Alseth Date: Sun, 26 Jun 2022 18:39:57 -0700 Subject: [PATCH 103/892] Add KP125 test fixture and support note. (#350) * Add KP125 test fixture and support note. Signed-off-by: James Alseth * mark KP125 having an emeter Co-authored-by: Teemu R --- README.md | 1 + kasa/tests/conftest.py | 5 ++- kasa/tests/fixtures/KP125(US)_1.0_1.0.6.json | 42 ++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 kasa/tests/fixtures/KP125(US)_1.0_1.0.6.json diff --git a/README.md b/README.md index d47ba1139..213b93f66 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * KP100 * KP105 * KP115 +* KP125 * KP401 ### Power Strips diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 8b4e5bc95..85455e024 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -49,15 +49,16 @@ "HS210", "EP10", "KP100", - "KP115", "KP105", + "KP115", + "KP125", "KP401", } STRIPS = {"HS107", "HS300", "KP303", "KP400", "EP40"} DIMMERS = {"HS220", "KS220M"} DIMMABLE = {*BULBS, *DIMMERS} -WITH_EMETER = {"HS110", "HS300", "KP115", *BULBS} +WITH_EMETER = {"HS110", "HS300", "KP115", "KP125", *BULBS} ALL_DEVICES = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS) diff --git a/kasa/tests/fixtures/KP125(US)_1.0_1.0.6.json b/kasa/tests/fixtures/KP125(US)_1.0_1.0.6.json new file mode 100644 index 000000000..cb32e7c6c --- /dev/null +++ b/kasa/tests/fixtures/KP125(US)_1.0_1.0.6.json @@ -0,0 +1,42 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 978, + "err_code": 0, + "power_mw": 100277, + "total_wh": 12170, + "voltage_mv": 119425 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Test plug", + "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": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP125(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 301, + "relay_state": 1, + "rssi": -41, + "status": "configured", + "sw_ver": "1.0.6 Build 210928 Rel.185924", + "updating": 0 + } + } +} From 8c93c444407f2b2d968b15c2035746b8a3f37bdd Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 27 Jun 2022 17:26:45 +0200 Subject: [PATCH 104/892] Update README to add missing models and fix a link (#351) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 213b93f66..8ab9e3576 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * KP115 * KP125 * KP401 +* EP10 ### Power Strips @@ -137,6 +138,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * HS200 * HS210 * HS220 +* KS220M ### Bulbs @@ -168,7 +170,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * [softScheck's github contains lot of information and wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) * [TP-Link Smart Home Device Simulator](https://github.com/plasticrake/tplink-smarthome-simulator) -* [Unofficial API documentation](https://github.com/plasticrake/tplink-smarthome-api/blob/master/API.md) +* [Unofficial API documentation](https://github.com/plasticrake/tplink-smarthome-api) * [MQTT access to TP-Link devices, using python-kasa](https://github.com/flavio-fernandes/mqtt2kasa) ### TP-Link Tapo support From 4c552892552f48812a842d8155945ddc7ac547d3 Mon Sep 17 00:00:00 2001 From: gritstub Date: Tue, 28 Jun 2022 03:56:36 -0700 Subject: [PATCH 105/892] Add fixtures for KS230 (#355) --- README.md | 1 + kasa/tests/conftest.py | 2 +- kasa/tests/fixtures/KS230(US)_1.0_1.0.14.json | 64 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/KS230(US)_1.0_1.0.14.json diff --git a/README.md b/README.md index 8ab9e3576..6c2d543ab 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * HS210 * HS220 * KS220M +* KS230 ### Bulbs diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 85455e024..da8dbce0b 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -55,7 +55,7 @@ "KP401", } STRIPS = {"HS107", "HS300", "KP303", "KP400", "EP40"} -DIMMERS = {"HS220", "KS220M"} +DIMMERS = {"HS220", "KS220M", "KS230"} DIMMABLE = {*BULBS, *DIMMERS} WITH_EMETER = {"HS110", "HS300", "KP115", "KP125", *BULBS} diff --git a/kasa/tests/fixtures/KS230(US)_1.0_1.0.14.json b/kasa/tests/fixtures/KS230(US)_1.0_1.0.14.json new file mode 100644 index 000000000..a9e529bcc --- /dev/null +++ b/kasa/tests/fixtures/KS230(US)_1.0_1.0.14.json @@ -0,0 +1,64 @@ +{ + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "bulb_type": 1, + "err_code": 0, + "fadeOffTime": 1000, + "fadeOnTime": 1000, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 11, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Test KS230", + "brightness": 60, + "dc_state": 0, + "dev_name": "Wi-Fi Smart 3-Way Dimmer", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS230(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "preferred_state": [ + { + "brightness": 100, + "index": 0 + }, + { + "brightness": 75, + "index": 1 + }, + { + "brightness": 50, + "index": 2 + }, + { + "brightness": 25, + "index": 3 + } + ], + "relay_state": 0, + "rssi": -52, + "status": "new", + "sw_ver": "1.0.14 Build 220127 Rel.124555", + "updating": 0 + } + } +} From d7295bdf6dc53b9332dbb0f7c18930e86dba49d2 Mon Sep 17 00:00:00 2001 From: gritstub Date: Tue, 28 Jun 2022 04:06:34 -0700 Subject: [PATCH 106/892] Add fixtures for ES20M (#353) (#354) Co-authored-by: Teemu R --- README.md | 1 + kasa/tests/conftest.py | 2 +- kasa/tests/fixtures/ES20M(US)_1.0_1.0.8.json | 126 +++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/ES20M(US)_1.0_1.0.8.json diff --git a/README.md b/README.md index 6c2d543ab..edcb7da6d 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. ### Wall switches +* ES20M * HS200 * HS210 * HS220 diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index da8dbce0b..721efa851 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -55,7 +55,7 @@ "KP401", } STRIPS = {"HS107", "HS300", "KP303", "KP400", "EP40"} -DIMMERS = {"HS220", "KS220M", "KS230"} +DIMMERS = {"ES20M", "HS220", "KS220M", "KS230"} DIMMABLE = {*BULBS, *DIMMERS} WITH_EMETER = {"HS110", "HS300", "KP115", "KP125", *BULBS} diff --git a/kasa/tests/fixtures/ES20M(US)_1.0_1.0.8.json b/kasa/tests/fixtures/ES20M(US)_1.0_1.0.8.json new file mode 100644 index 000000000..bb316b830 --- /dev/null +++ b/kasa/tests/fixtures/ES20M(US)_1.0_1.0.8.json @@ -0,0 +1,126 @@ +{ + "smartlife.iot.LAS": { + "get_config": { + "devs": [ + { + "dark_index": 0, + "enable": 1, + "hw_id": 0, + "level_array": [ + { + "adc": 367, + "name": "cloudy", + "value": 14 + }, + { + "adc": 300, + "name": "overcast", + "value": 12 + }, + { + "adc": 222, + "name": "dawn", + "value": 9 + }, + { + "adc": 222, + "name": "twilight", + "value": 9 + }, + { + "adc": 111, + "name": "total darkness", + "value": 4 + }, + { + "adc": 2400, + "name": "custom", + "value": 97 + } + ], + "max_adc": 2450, + "min_adc": 0 + } + ], + "err_code": 0, + "ver": "1.0" + } + }, + "smartlife.iot.PIR": { + "get_config": { + "array": [ + 80, + 50, + 20, + 0 + ], + "cold_time": 600000, + "enable": 1, + "err_code": 0, + "max_adc": 4095, + "min_adc": 0, + "trigger_index": 0, + "version": "1.0" + } + }, + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "bulb_type": 1, + "err_code": 0, + "fadeOffTime": 2000, + "fadeOnTime": 1000, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 14, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Test ES20M", + "brightness": 35, + "dev_name": "Wi-Fi Smart Dimmer with sensor", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "ES20M(US)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "preferred_state": [ + { + "brightness": 100, + "index": 0 + }, + { + "brightness": 75, + "index": 1 + }, + { + "brightness": 50, + "index": 2 + }, + { + "brightness": 25, + "index": 3 + } + ], + "relay_state": 0, + "rssi": -59, + "status": "new", + "sw_ver": "1.0.8 Build 211201 Rel.123822", + "updating": 0 + } + } +} From 57fac9a156eb34f01f236e620be3449b5f04d927 Mon Sep 17 00:00:00 2001 From: gritstub Date: Tue, 28 Jun 2022 09:53:23 -0700 Subject: [PATCH 107/892] Add fixtures for KS200M (#356) --- README.md | 1 + kasa/tests/conftest.py | 1 + kasa/tests/fixtures/KS200M(US)_1.0_1.0.8.json | 95 +++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 kasa/tests/fixtures/KS200M(US)_1.0_1.0.8.json diff --git a/README.md b/README.md index edcb7da6d..ac8d8c05d 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * HS200 * HS210 * HS220 +* KS200M * KS220M * KS230 diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 721efa851..faa5267e6 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -53,6 +53,7 @@ "KP115", "KP125", "KP401", + "KS200M", } STRIPS = {"HS107", "HS300", "KP303", "KP400", "EP40"} DIMMERS = {"ES20M", "HS220", "KS220M", "KS230"} diff --git a/kasa/tests/fixtures/KS200M(US)_1.0_1.0.8.json b/kasa/tests/fixtures/KS200M(US)_1.0_1.0.8.json new file mode 100644 index 000000000..3806895bb --- /dev/null +++ b/kasa/tests/fixtures/KS200M(US)_1.0_1.0.8.json @@ -0,0 +1,95 @@ +{ + "smartlife.iot.LAS": { + "get_config": { + "devs": [ + { + "dark_index": 4, + "enable": 1, + "hw_id": 0, + "level_array": [ + { + "adc": 390, + "name": "cloudy", + "value": 15 + }, + { + "adc": 300, + "name": "overcast", + "value": 12 + }, + { + "adc": 222, + "name": "dawn", + "value": 9 + }, + { + "adc": 220, + "name": "twilight", + "value": 8 + }, + { + "adc": 98, + "name": "total darkness", + "value": 4 + }, + { + "adc": 2400, + "name": "custom", + "value": 97 + } + ], + "max_adc": 2450, + "min_adc": 0 + } + ], + "err_code": 0, + "ver": "1.0" + } + }, + "smartlife.iot.PIR": { + "get_config": { + "array": [ + 80, + 50, + 20, + 0 + ], + "cold_time": 600000, + "enable": 1, + "err_code": 0, + "max_adc": 4095, + "min_adc": 0, + "trigger_index": 1, + "version": "1.0" + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Test KS200M", + "dev_name": "Smart Light Switch with PIR", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS200M(US)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -66, + "status": "new", + "sw_ver": "1.0.8 Build 211201 Rel.125056", + "updating": 0 + } + } +} From 7aebef56ca6378cb09d337b8832c0128079c2ee0 Mon Sep 17 00:00:00 2001 From: Felix Yan Date: Sun, 17 Jul 2022 20:19:09 +0300 Subject: [PATCH 108/892] Correct typos in smartdevice.py (#358) --- kasa/smartdevice.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 93e01758c..cf3c53849 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -128,7 +128,7 @@ class SmartDevice: >>> dev.mac 50:C7:BF:01:F8:CD - Some information can also be changed programatically: + Some information can also be changed programmatically: >>> asyncio.run(dev.set_alias("new alias")) >>> asyncio.run(dev.set_mac("01:23:45:67:89:ab")) @@ -433,7 +433,7 @@ def location(self) -> Dict: @property # type: ignore @requires_update def rssi(self) -> Optional[int]: - """Return WiFi signal strenth (rssi).""" + """Return WiFi signal strength (rssi).""" rssi = self.sys_info.get("rssi") return None if rssi is None else int(rssi) From 13052ac7a1205d9cd277bb651e0978bb3b01056d Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 3 Oct 2022 20:28:05 +0200 Subject: [PATCH 109/892] Fix year emeter for cli by using kwarg for year parameter (#372) * Fix year emeter for cli by using kwarg for year parameter * Improve tests * Skip test_emeter on python3.7 --- kasa/cli.py | 2 +- kasa/tests/test_cli.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 40e84e77f..48a2f3420 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -297,7 +297,7 @@ async def emeter(dev: SmartDevice, year, month, erase): if year: click.echo(f"== For year {year.year} ==") click.echo("Month, usage (kWh)") - usage_data = await dev.get_emeter_monthly(year.year) + usage_data = await dev.get_emeter_monthly(year=year.year) elif month: click.echo(f"== For month {month.month} of {month.year} ==") click.echo("Day, usage (kWh)") diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 289c5b58d..6ea71326b 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -1,3 +1,5 @@ +import sys + import pytest from asyncclick.testing import CliRunner @@ -66,6 +68,7 @@ async def test_raw_command(dev): assert "Usage" in res.output +@pytest.mark.skipif(sys.version_info < (3, 8), reason="3.8 is first one with asyncmock") async def test_emeter(dev: SmartDevice, mocker): runner = CliRunner() @@ -77,16 +80,18 @@ async def test_emeter(dev: SmartDevice, mocker): assert "== Emeter ==" in res.output monthly = mocker.patch.object(dev, "get_emeter_monthly") - monthly.return_value = [] + monthly.return_value = {1: 1234} res = await runner.invoke(emeter, ["--year", "1900"], obj=dev) assert "For year" in res.output - monthly.assert_called() + assert "1, 1234" in res.output + monthly.assert_called_with(year=1900) daily = mocker.patch.object(dev, "get_emeter_daily") - daily.return_value = [] + daily.return_value = {1: 1234} res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev) assert "For month" in res.output - daily.assert_called() + assert "1, 1234" in res.output + daily.assert_called_with(year=1900, month=12) async def test_brightness(dev): From cece84352a2e67e90d19f2145284b1505755ac13 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 17 Oct 2022 18:08:26 +0200 Subject: [PATCH 110/892] Manually pass the codecov token in CI (#378) --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a184a51e0..3bce730b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,3 +89,4 @@ jobs: uses: "codecov/codecov-action@v2" with: fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} From f493fa1dca7baffe31e4998cdcb792a8c7252eee Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 17 Oct 2022 18:16:40 +0200 Subject: [PATCH 111/892] Clarify information about supported devices (#377) * Clarify information about supported devices * Use single backticks --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index ac8d8c05d..a1fbaaa2d 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,10 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. ## Supported devices +In principle all devices that are locally controllable using the official Kasa mobile app should work with this library. +The following lists merely 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 (generated by `devtools/dump_devinfo.py`). + ### Plugs * HS100 From 2eecf39baeecc4ba6fde508cb8d6ccacdd88bab8 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 18 Oct 2022 19:08:10 +0200 Subject: [PATCH 112/892] Add ToCs for doc pages (#380) --- docs/source/cli.rst | 2 +- docs/source/design.rst | 3 +++ docs/source/discover.rst | 13 ++++--------- docs/source/smartbulb.rst | 3 +++ docs/source/smartdevice.rst | 6 ++++++ docs/source/smartdimmer.rst | 4 ++++ docs/source/smartlightstrip.rst | 3 +++ docs/source/smartplug.rst | 3 +++ docs/source/smartstrip.rst | 3 +++ 9 files changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index 340a64e6f..b09ae11d8 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -1,7 +1,7 @@ Command-line usage ================== -The package is shipped with a console tool named kasa, please refer to ``kasa --help`` for detailed usage. +The package is shipped with a console tool named ``kasa``, refer to ``kasa --help`` for detailed usage. The device to which the commands are sent is chosen by ``KASA_HOST`` environment variable or passing ``--host
`` as an option. To see what is being sent to and received from the device, specify option ``--debug``. diff --git a/docs/source/design.rst b/docs/source/design.rst index 140c83d8f..8acbfea69 100644 --- a/docs/source/design.rst +++ b/docs/source/design.rst @@ -9,6 +9,9 @@ This page aims to provide some details on the design and internals of this libra You might be interested in this if you want to improve this library, or if you are just looking to access some information that is not currently exposed. +.. contents:: Contents + :local: + .. _update_cycle: Update Cycle diff --git a/docs/source/discover.rst b/docs/source/discover.rst index f47f50d72..87b14ee7c 100644 --- a/docs/source/discover.rst +++ b/docs/source/discover.rst @@ -1,16 +1,11 @@ Discovering devices =================== -.. code-block:: - - import asyncio - from kasa import Discover - - devices = asyncio.run(Discover.discover()) - for addr, dev in devices.items(): - asyncio.run(dev.update()) - print(f"{addr} >> {dev}") +.. contents:: Contents + :local: +API documentation +***************** .. autoclass:: kasa.Discover :members: diff --git a/docs/source/smartbulb.rst b/docs/source/smartbulb.rst index 3f2baa407..6261cb7b6 100644 --- a/docs/source/smartbulb.rst +++ b/docs/source/smartbulb.rst @@ -1,6 +1,9 @@ Bulbs =========== +.. contents:: Contents + :local: + Supported features ****************** diff --git a/docs/source/smartdevice.rst b/docs/source/smartdevice.rst index 42b838568..d8ef58b21 100644 --- a/docs/source/smartdevice.rst +++ b/docs/source/smartdevice.rst @@ -3,6 +3,12 @@ Common API ========== +.. contents:: Contents + :local: + +SmartDevice class +***************** + The basic functionalities of all supported devices are accessible using the common :class:`SmartDevice` base class. The property accesses use the data obtained before by awaiting :func:`SmartDevice.update()`. diff --git a/docs/source/smartdimmer.rst b/docs/source/smartdimmer.rst index b44d8e0c8..62d701422 100644 --- a/docs/source/smartdimmer.rst +++ b/docs/source/smartdimmer.rst @@ -1,6 +1,10 @@ Dimmers ======= +.. contents:: Contents + :local: + + .. note:: Feel free to open a pull request to improve the documentation! diff --git a/docs/source/smartlightstrip.rst b/docs/source/smartlightstrip.rst index b961be5bb..d0d99ce48 100644 --- a/docs/source/smartlightstrip.rst +++ b/docs/source/smartlightstrip.rst @@ -1,6 +1,9 @@ Light strips ============ +.. contents:: Contents + :local: + .. note:: Feel free to open a pull request to improve the documentation! diff --git a/docs/source/smartplug.rst b/docs/source/smartplug.rst index 94305ca87..ff562c93d 100644 --- a/docs/source/smartplug.rst +++ b/docs/source/smartplug.rst @@ -1,6 +1,9 @@ Plugs ===== +.. contents:: Contents + :local: + .. note:: Feel free to open a pull request to improve the documentation! diff --git a/docs/source/smartstrip.rst b/docs/source/smartstrip.rst index 66d78f9c6..411ccde15 100644 --- a/docs/source/smartstrip.rst +++ b/docs/source/smartstrip.rst @@ -1,6 +1,9 @@ Smart strips ============ +.. contents:: Contents + :local: + .. note:: Feel free to open a pull request to improve the documentation! From e1a30f92e450196885f6c0cde3bd0c929d81a324 Mon Sep 17 00:00:00 2001 From: HankB Date: Tue, 18 Oct 2022 16:37:54 -0500 Subject: [PATCH 113/892] fix more outdated CLI examples, remove EP40 from bulb list (#383) * Fix more outdated cli examples * remove EP40 (smart strip) from bulb list --- README.md | 1 - docs/source/cli.rst | 4 ++-- docs/source/smartbulb.rst | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a1fbaaa2d..f56cdc9d3 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,6 @@ If your device is unlisted but working, please open a pull request to update the ### Bulbs -* EP40 * LB100 * LB110 * LB120 diff --git a/docs/source/cli.rst b/docs/source/cli.rst index b09ae11d8..2bab9216c 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -5,7 +5,7 @@ The package is shipped with a console tool named ``kasa``, refer to ``kasa --hel The device to which the commands are sent is chosen by ``KASA_HOST`` environment variable or passing ``--host
`` as an option. To see what is being sent to and received from the device, specify option ``--debug``. -To avoid discovering the devices when executing commands its type can be passed as an option (e.g., ``--plug`` for plugs, ``--bulb`` for bulbs, ..). +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. If no command is given, the ``state`` command will be executed to query the device state. @@ -13,7 +13,7 @@ If no command is given, the ``state`` command will be executed to query the devi .. note:: Some commands (such as reading energy meter values, changing bulb settings, or accessing individual sockets on smart strips) additional parameters are required, - which you can find by adding ``--help`` after the command, e.g. ``kasa emeter --help`` or ``kasa hsv --help``. + which you can find by adding ``--help`` after the command, e.g. ``kasa --type emeter --help`` or ``kasa --type hsv --help``. Refer to the device type specific documentation for more details. diff --git a/docs/source/smartbulb.rst b/docs/source/smartbulb.rst index 6261cb7b6..8e02f89f8 100644 --- a/docs/source/smartbulb.rst +++ b/docs/source/smartbulb.rst @@ -45,13 +45,13 @@ All command-line commands can be used with transition period for smooth changes. .. code:: - $ kasa --bulb --host off --transition 15000 + $ kasa --type bulb --host off --transition 15000 **Example:** Change the bulb to red with 20% brightness over 15 seconds: .. code:: - $ kasa --bulb --host hsv 0 100 20 --transition 15000 + $ kasa --type bulb --host hsv 0 100 20 --transition 15000 API documentation From b386485ab09aaa536dc3595cd30caf06adcae422 Mon Sep 17 00:00:00 2001 From: HankB Date: Tue, 18 Oct 2022 18:16:11 -0500 Subject: [PATCH 114/892] Update smartstrip.rst (#382) ```text hbarta@pilog3b:~$ kasa --type strip --host 192.168.20.215 on Turning on TP-LINK_Smart Plug_83A6 hbarta@pilog3b:~$ kasa --strip --host 192.168.20.215 on Usage: kasa [OPTIONS] COMMAND [ARGS]... Try 'kasa --help' for help. Error: No such option: --strip (Possible options: --host, --type) hbarta@pilog3b:~$ ``` --- docs/source/smartstrip.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/smartstrip.rst b/docs/source/smartstrip.rst index 411ccde15..5c41be51d 100644 --- a/docs/source/smartstrip.rst +++ b/docs/source/smartstrip.rst @@ -18,13 +18,13 @@ If not specified, the commands will act on the parent device: turning the strip .. code:: - $ kasa --strip --host on --index 0 + $ kasa --type strip --host on --index 0 **Example:** Turn off the socket by name: .. code:: - $ kasa --strip --host off --name "Maybe Kitchen" + $ kasa --type strip --host off --name "Maybe Kitchen" API documentation From f32f7f3925718e435eeb1471013f2be13875aa58 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 23 Oct 2022 00:15:47 +0200 Subject: [PATCH 115/892] Add support for bulb presets (#379) * Add support for bulb presets * Update docs --- docs/source/smartbulb.rst | 5 +++ kasa/__init__.py | 3 +- kasa/cli.py | 61 ++++++++++++++++++++++++++- kasa/smartbulb.py | 61 ++++++++++++++++++++++++--- kasa/tests/newfakes.py | 8 ++++ kasa/tests/test_bulb.py | 89 +++++++++++++++++++++++++++++---------- 6 files changed, 197 insertions(+), 30 deletions(-) diff --git a/docs/source/smartbulb.rst b/docs/source/smartbulb.rst index 8e02f89f8..ec9636537 100644 --- a/docs/source/smartbulb.rst +++ b/docs/source/smartbulb.rst @@ -11,6 +11,7 @@ Supported features * Setting brightness, color temperature, and color (in HSV) * Querying emeter information * Transitions +* Presets Currently unsupported ********************* @@ -61,3 +62,7 @@ API documentation :members: :inherited-members: :undoc-members: + +.. autoclass:: kasa.SmartBulbPreset + :members: + :undoc-members: diff --git a/kasa/__init__.py b/kasa/__init__.py index fc798fb37..e17cb2e60 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -17,7 +17,7 @@ from kasa.emeterstatus import EmeterStatus from kasa.exceptions import SmartDeviceException from kasa.protocol import TPLinkSmartHomeProtocol -from kasa.smartbulb import SmartBulb +from kasa.smartbulb import SmartBulb, SmartBulbPreset from kasa.smartdevice import DeviceType, SmartDevice from kasa.smartdimmer import SmartDimmer from kasa.smartlightstrip import SmartLightStrip @@ -31,6 +31,7 @@ "Discover", "TPLinkSmartHomeProtocol", "SmartBulb", + "SmartBulbPreset", "DeviceType", "EmeterStatus", "SmartDevice", diff --git a/kasa/cli.py b/kasa/cli.py index 48a2f3420..ea1c0be33 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -216,8 +216,13 @@ async def state(dev: SmartDevice): click.echo(f"\tLocation: {dev.location}") click.echo(click.style("\n\t== Device specific information ==", bold=True)) - for k, v in dev.state_information.items(): - click.echo(f"\t{k}: {v}") + for info_name, info_data in dev.state_information.items(): + if isinstance(info_data, list): + click.echo(f"\t{info_name}:") + for item in info_data: + click.echo(f"\t\t{item}") + else: + click.echo(f"\t{info_name}: {info_data}") if dev.has_emeter: click.echo(click.style("\n\t== Current State ==", bold=True)) @@ -538,5 +543,57 @@ def _schedule_list(dev, type): click.echo(f"No rules of type {type}") +@cli.group(invoke_without_command=True) +@click.pass_context +async def presets(ctx): + """List and modify bulb setting presets.""" + if ctx.invoked_subcommand is None: + return await ctx.invoke(presets_list) + + +@presets.command(name="list") +@pass_dev +def presets_list(dev: SmartBulb): + """List presets.""" + if not dev.is_bulb: + click.echo("Presets only supported on bulbs") + return + + for preset in dev.presets: + print(preset) + + +@presets.command(name="modify") +@click.argument("index", type=int) +@click.option("--brightness", type=int) +@click.option("--hue", type=int) +@click.option("--saturation", type=int) +@click.option("--temperature", type=int) +@pass_dev +async def presets_modify( + dev: SmartBulb, index, brightness, hue, saturation, temperature +): + """Modify a preset.""" + for preset in dev.presets: + if preset.index == index: + break + else: + click.echo(f"No preset found for index {index}") + return + + if brightness is not None: + preset.brightness = brightness + if hue is not None: + preset.hue = hue + if saturation is not None: + preset.saturation = saturation + if temperature is not None: + preset.color_temp = temperature + + click.echo(f"Going to save preset: {preset}") + + await dev.save_preset(preset) + + if __name__ == "__main__": cli() diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index f060d2563..e14fc0ee9 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -1,7 +1,9 @@ """Module for bulbs (LB*, KL*, KB*).""" import logging import re -from typing import Any, Dict, NamedTuple, cast +from typing import Any, Dict, List, NamedTuple, cast + +from pydantic import BaseModel from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update @@ -22,6 +24,16 @@ class HSV(NamedTuple): value: int +class SmartBulbPreset(BaseModel): + """Bulb configuration preset.""" + + index: int + brightness: int + hue: int + saturation: int + color_temp: int + + TPLINK_KELVIN = { "LB130": ColorTempRange(2500, 9000), "LB120": ColorTempRange(2700, 6500), @@ -50,7 +62,7 @@ class SmartBulb(SmartDevice): All changes to the device are done using awaitable methods, which will not change the cached values, but you must await :func:`update()` separately. - Errors reported by the device are raised as :class:`SmartDeviceException`\s, + Errors reported by the device are raised as :class:`SmartDeviceExceptions `, and should be handled by the user of the library. Examples: @@ -68,7 +80,8 @@ class SmartBulb(SmartDevice): >>> print(bulb.is_on) True - You can use the ``is_``-prefixed properties to check for supported features + You can use the ``is_``-prefixed properties to check for supported features: + >>> bulb.is_dimmable True >>> bulb.is_color @@ -102,11 +115,26 @@ class SmartBulb(SmartDevice): HSV(hue=180, saturation=100, value=80) If you don't want to use the default transitions, you can pass `transition` in milliseconds. - This applies to all transitions (turn_on, turn_off, set_hsv, set_color_temp, set_brightness). + This applies to all transitions (:func:`turn_on`, :func:`turn_off`, :func:`set_hsv`, :func:`set_color_temp`, :func:`set_brightness`). The following changes the brightness over a period of 10 seconds: >>> asyncio.run(bulb.set_brightness(100, transition=10_000)) + Bulb configuration presets can be accessed using the :func:`presets` property: + + >>> bulb.presets + [SmartBulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700), SmartBulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0), SmartBulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0), SmartBulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0)] + + To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset` instance to :func:`save_preset` method: + + >>> preset = bulb.presets[0] + >>> preset.brightness + 50 + >>> preset.brightness = 100 + >>> asyncio.run(bulb.save_preset(preset)) + >>> bulb.presets[0].brightness + 100 + """ LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice" @@ -167,7 +195,7 @@ def valid_temperature_range(self) -> ColorTempRange: @requires_update def light_state(self) -> Dict[str, str]: """Query the light state.""" - light_state = self._last_update["system"]["get_sysinfo"]["light_state"] + light_state = self.sys_info["light_state"] if light_state is None: raise SmartDeviceException( "The device has no light_state or you have not called update()" @@ -369,6 +397,7 @@ def state_information(self) -> Dict[str, Any]: info["Valid temperature range"] = self.valid_temperature_range if self.is_color: info["HSV"] = self.hsv + info["Presets"] = self.presets return info @@ -407,3 +436,25 @@ async def set_alias(self, alias: str) -> None: return await self._query_helper( "smartlife.iot.common.system", "set_dev_alias", {"alias": alias} ) + + @property # type: ignore + @requires_update + def presets(self) -> List[SmartBulbPreset]: + """Return a list of available bulb setting presets.""" + return [SmartBulbPreset(**vals) for vals in self.sys_info["preferred_state"]] + + async def save_preset(self, preset: SmartBulbPreset): + """Save a setting preset. + + You can either construct a preset object manually, or pass an existing one obtained + obtained using :func:`presets`. + """ + if len(self.presets) == 0: + raise SmartDeviceException("Device does not supported saving presets") + + if preset.index >= len(self.presets): + raise SmartDeviceException("Invalid preset index") + + return await self._query_helper( + self.LIGHT_SERVICE, "set_preferred_state", preset.dict() + ) diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 904d45c79..18f52e173 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -391,6 +391,12 @@ def transition_light_state(self, state_changes, *args): _LOGGER.debug("New light state: %s", new_state) self.proto["system"]["get_sysinfo"]["light_state"] = new_state + def set_preferred_state(self, new_state, *args): + """Implementation of set_preferred_state.""" + self.proto["system"]["get_sysinfo"]["preferred_state"][ + new_state["index"] + ] = new_state + def light_state(self, x, *args): light_state = self.proto["system"]["get_sysinfo"]["light_state"] # Our tests have light state off, so we simply return the dft_on_state when device is on. @@ -425,6 +431,7 @@ def light_state(self, x, *args): "smartlife.iot.smartbulb.lightingservice": { "get_light_state": light_state, "transition_light_state": transition_light_state, + "set_preferred_state": set_preferred_state, }, "smartlife.iot.lighting_effect": { "set_lighting_effect": set_lighting_effect, @@ -433,6 +440,7 @@ def light_state(self, x, *args): "smartlife.iot.lightStrip": { "set_light_state": transition_light_state, "get_light_state": light_state, + "set_preferred_state": set_preferred_state, }, "smartlife.iot.common.system": { "set_dev_alias": set_alias, diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 032154424..9012f5b71 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -1,6 +1,6 @@ import pytest -from kasa import DeviceType, SmartDeviceException +from kasa import DeviceType, SmartBulb, SmartBulbPreset, SmartDeviceException from .conftest import ( bulb, @@ -18,7 +18,7 @@ @bulb -async def test_bulb_sysinfo(dev): +async def test_bulb_sysinfo(dev: SmartBulb): assert dev.sys_info is not None BULB_SCHEMA(dev.sys_info) @@ -31,7 +31,7 @@ async def test_bulb_sysinfo(dev): @bulb -async def test_state_attributes(dev): +async def test_state_attributes(dev: SmartBulb): assert "Brightness" in dev.state_information assert dev.state_information["Brightness"] == dev.brightness @@ -40,7 +40,7 @@ async def test_state_attributes(dev): @bulb -async def test_light_state_without_update(dev, monkeypatch): +async def test_light_state_without_update(dev: SmartBulb, monkeypatch): with pytest.raises(SmartDeviceException): monkeypatch.setitem( dev._last_update["system"]["get_sysinfo"], "light_state", None @@ -49,13 +49,13 @@ async def test_light_state_without_update(dev, monkeypatch): @bulb -async def test_get_light_state(dev): +async def test_get_light_state(dev: SmartBulb): LIGHT_STATE_SCHEMA(await dev.get_light_state()) @color_bulb @turn_on -async def test_hsv(dev, turn_on): +async def test_hsv(dev: SmartBulb, turn_on): await handle_turn_on(dev, turn_on) assert dev.is_color @@ -74,7 +74,7 @@ async def test_hsv(dev, turn_on): @color_bulb -async def test_set_hsv_transition(dev, mocker): +async def test_set_hsv_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") await dev.set_hsv(10, 10, 100, transition=1000) @@ -86,7 +86,7 @@ async def test_set_hsv_transition(dev, mocker): @color_bulb @turn_on -async def test_invalid_hsv(dev, turn_on): +async def test_invalid_hsv(dev: SmartBulb, turn_on): await handle_turn_on(dev, turn_on) assert dev.is_color @@ -104,13 +104,13 @@ async def test_invalid_hsv(dev, turn_on): @color_bulb -async def test_color_state_information(dev): +async def test_color_state_information(dev: SmartBulb): assert "HSV" in dev.state_information assert dev.state_information["HSV"] == dev.hsv @non_color_bulb -async def test_hsv_on_non_color(dev): +async def test_hsv_on_non_color(dev: SmartBulb): assert not dev.is_color with pytest.raises(SmartDeviceException): @@ -120,7 +120,7 @@ async def test_hsv_on_non_color(dev): @variable_temp -async def test_variable_temp_state_information(dev): +async def test_variable_temp_state_information(dev: SmartBulb): assert "Color temperature" in dev.state_information assert dev.state_information["Color temperature"] == dev.color_temp @@ -132,7 +132,7 @@ async def test_variable_temp_state_information(dev): @variable_temp @turn_on -async def test_try_set_colortemp(dev, turn_on): +async def test_try_set_colortemp(dev: SmartBulb, turn_on): await handle_turn_on(dev, turn_on) await dev.set_color_temp(2700) await dev.update() @@ -140,7 +140,7 @@ async def test_try_set_colortemp(dev, turn_on): @variable_temp -async def test_set_color_temp_transition(dev, mocker): +async def test_set_color_temp_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") await dev.set_color_temp(2700, transition=100) @@ -148,7 +148,7 @@ async def test_set_color_temp_transition(dev, mocker): @variable_temp -async def test_unknown_temp_range(dev, monkeypatch, caplog): +async def test_unknown_temp_range(dev: SmartBulb, monkeypatch, caplog): monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") assert dev.valid_temperature_range == (2700, 5000) @@ -156,7 +156,7 @@ async def test_unknown_temp_range(dev, monkeypatch, caplog): @variable_temp -async def test_out_of_range_temperature(dev): +async def test_out_of_range_temperature(dev: SmartBulb): with pytest.raises(ValueError): await dev.set_color_temp(1000) with pytest.raises(ValueError): @@ -164,7 +164,7 @@ async def test_out_of_range_temperature(dev): @non_variable_temp -async def test_non_variable_temp(dev): +async def test_non_variable_temp(dev: SmartBulb): with pytest.raises(SmartDeviceException): await dev.set_color_temp(2700) @@ -177,7 +177,7 @@ async def test_non_variable_temp(dev): @dimmable @turn_on -async def test_dimmable_brightness(dev, turn_on): +async def test_dimmable_brightness(dev: SmartBulb, turn_on): await handle_turn_on(dev, turn_on) assert dev.is_dimmable @@ -194,7 +194,7 @@ async def test_dimmable_brightness(dev, turn_on): @bulb -async def test_turn_on_transition(dev, mocker): +async def test_turn_on_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") await dev.turn_on(transition=1000) @@ -206,7 +206,7 @@ async def test_turn_on_transition(dev, mocker): @bulb -async def test_dimmable_brightness_transition(dev, mocker): +async def test_dimmable_brightness_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") await dev.set_brightness(10, transition=1000) @@ -214,7 +214,7 @@ async def test_dimmable_brightness_transition(dev, mocker): @dimmable -async def test_invalid_brightness(dev): +async def test_invalid_brightness(dev: SmartBulb): assert dev.is_dimmable with pytest.raises(ValueError): @@ -225,7 +225,7 @@ async def test_invalid_brightness(dev): @non_dimmable -async def test_non_dimmable(dev): +async def test_non_dimmable(dev: SmartBulb): assert not dev.is_dimmable with pytest.raises(SmartDeviceException): @@ -235,7 +235,9 @@ async def test_non_dimmable(dev): @bulb -async def test_ignore_default_not_set_without_color_mode_change_turn_on(dev, mocker): +async def test_ignore_default_not_set_without_color_mode_change_turn_on( + dev: SmartBulb, mocker +): query_helper = mocker.patch("kasa.SmartBulb._query_helper") # When turning back without settings, ignore default to restore the state await dev.turn_on() @@ -245,3 +247,46 @@ async def test_ignore_default_not_set_without_color_mode_change_turn_on(dev, moc await dev.turn_off() args, kwargs = query_helper.call_args_list[1] assert args[2] == {"on_off": 0, "ignore_default": 1} + + +@bulb +async def test_list_presets(dev: SmartBulb): + presets = dev.presets + assert len(presets) == len(dev.sys_info["preferred_state"]) + + for preset, raw in zip(presets, dev.sys_info["preferred_state"]): + assert preset.index == raw["index"] + assert preset.hue == raw["hue"] + assert preset.brightness == raw["brightness"] + assert preset.saturation == raw["saturation"] + assert preset.color_temp == raw["color_temp"] + + +@bulb +async def test_modify_preset(dev: SmartBulb, mocker): + """Verify that modifying preset calls the and exceptions are raised properly.""" + if not dev.presets: + pytest.skip("Some strips do not support presets") + + data = { + "index": 0, + "brightness": 10, + "hue": 0, + "saturation": 0, + "color_temp": 0, + } + preset = SmartBulbPreset(**data) + + assert preset.index == 0 + assert preset.brightness == 10 + assert preset.hue == 0 + assert preset.saturation == 0 + assert preset.color_temp == 0 + + await dev.save_preset(preset) + assert dev.presets[0].brightness == 10 + + with pytest.raises(SmartDeviceException): + await dev.save_preset( + SmartBulbPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) + ) From 1ac6c66277907b52d7be234148846b32c33a7f8f Mon Sep 17 00:00:00 2001 From: Julian Davis Date: Thu, 27 Oct 2022 16:40:24 +0100 Subject: [PATCH 116/892] Fix type hinting issue with call to click.Choice (#387) * Fix type hinting issue with call to click.Choice which takes a Sequence not dictionary. Convert TYPE_TO_CLASS keys to a list to pass in. * Update kasa/cli.py Co-authored-by: Teemu R. Co-authored-by: Jules Davis Co-authored-by: Teemu R. --- kasa/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/cli.py b/kasa/cli.py index ea1c0be33..30874e30f 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -57,7 +57,7 @@ "--type", envvar="KASA_TYPE", default=None, - type=click.Choice(TYPE_TO_CLASS, case_sensitive=False), + type=click.Choice(list(TYPE_TO_CLASS), case_sensitive=False), ) @click.version_option(package_name="python-kasa") @click.pass_context From ef98c2aed9283b3f6ba7af49eedca700413f5f01 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 27 Oct 2022 17:40:54 +0200 Subject: [PATCH 117/892] Implement changing the bulb turn-on behavior (#381) * Implement changing the bulb turn-on behavior * Improve docstrings a bit * Improve docs and expose TurnOnBehavior(s) * fix typing --- docs/source/smartbulb.rst | 7 ++++ kasa/__init__.py | 4 ++- kasa/cli.py | 31 +++++++++++++++++ kasa/smartbulb.py | 71 ++++++++++++++++++++++++++++++++++----- 4 files changed, 104 insertions(+), 9 deletions(-) diff --git a/docs/source/smartbulb.rst b/docs/source/smartbulb.rst index ec9636537..980aff134 100644 --- a/docs/source/smartbulb.rst +++ b/docs/source/smartbulb.rst @@ -66,3 +66,10 @@ API documentation .. autoclass:: kasa.SmartBulbPreset :members: :undoc-members: + +.. autoclass:: kasa.TurnOnBehaviors + :undoc-members: + + +.. autoclass:: kasa.TurnOnBehavior + :undoc-members: diff --git a/kasa/__init__.py b/kasa/__init__.py index e17cb2e60..5e5e57ee0 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -17,7 +17,7 @@ from kasa.emeterstatus import EmeterStatus from kasa.exceptions import SmartDeviceException from kasa.protocol import TPLinkSmartHomeProtocol -from kasa.smartbulb import SmartBulb, SmartBulbPreset +from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.smartdevice import DeviceType, SmartDevice from kasa.smartdimmer import SmartDimmer from kasa.smartlightstrip import SmartLightStrip @@ -32,6 +32,8 @@ "TPLinkSmartHomeProtocol", "SmartBulb", "SmartBulbPreset", + "TurnOnBehaviors", + "TurnOnBehavior", "DeviceType", "EmeterStatus", "SmartDevice", diff --git a/kasa/cli.py b/kasa/cli.py index 30874e30f..ee22edbcb 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -595,5 +595,36 @@ async def presets_modify( await dev.save_preset(preset) +@cli.command() +@pass_dev +@click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False)) +@click.option("--last", is_flag=True) +@click.option("--preset", type=int) +async def turn_on_behavior(dev: SmartBulb, type, last, preset): + """Modify bulb turn-on behavior.""" + settings = await dev.get_turn_on_behavior() + click.echo(f"Current turn on behavior: {settings}") + + # Return if we are not setting the value + if not type and not last and not preset: + return + + # If we are setting the value, the type has to be specified + if (last or preset) and type is None: + click.echo("To set the behavior, you need to define --type") + return + + behavior = getattr(settings, type) + + if last: + click.echo(f"Going to set {type} to last") + behavior.preset = None + elif preset is not None: + click.echo(f"Going to set {type} to preset {preset}") + behavior.preset = preset + + await dev.set_turn_on_behavior(settings) + + if __name__ == "__main__": cli() diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index e14fc0ee9..b29c82f16 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -1,9 +1,10 @@ """Module for bulbs (LB*, KL*, KB*).""" import logging import re -from typing import Any, Dict, List, NamedTuple, cast +from enum import Enum +from typing import Any, Dict, List, NamedTuple, Optional, cast -from pydantic import BaseModel +from pydantic import BaseModel, Field, root_validator from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update @@ -34,6 +35,53 @@ class SmartBulbPreset(BaseModel): color_temp: int +class BehaviorMode(str, Enum): + """Enum to present type of turn on behavior.""" + + Last = "last_status" + Preset = "customize_preset" + + +class TurnOnBehavior(BaseModel): + """Model to present a single turn on behavior. + + :param int preset: the index number of wanted preset. + :param BehaviorMode mode: last status or preset mode. If you are changing existing settings, you should not set this manually. + + To change the behavior, it is only necessary to change the :ref:`preset` field + to contain either the preset index, or ``None`` for the last known state. + """ + + preset: Optional[int] = Field(alias="index", default=None) + mode: BehaviorMode + + @root_validator + def mode_based_on_preset(cls, values): + """Set the mode based on the preset value.""" + if values["preset"] is not None: + values["mode"] = BehaviorMode.Preset + else: + values["mode"] = BehaviorMode.Last + + return values + + class Config: + """Configuration to make the validator run when changing the values.""" + + validate_assignment = True + + +class TurnOnBehaviors(BaseModel): + """Model to contain turn on behaviors. + + :param TurnOnBehavior soft: the default setting to turn the bulb programmatically on + :param TurnOnBehavior hard: default setting when the bulb has been off from mains power. + """ + + soft: TurnOnBehavior = Field(alias="soft_on") + hard: TurnOnBehavior = Field(alias="hard_on") + + TPLINK_KELVIN = { "LB130": ColorTempRange(2500, 9000), "LB120": ColorTempRange(2700, 6500), @@ -226,14 +274,21 @@ async def get_light_details(self) -> Dict[str, int]: """ return await self._query_helper(self.LIGHT_SERVICE, "get_light_details") - async def get_turn_on_behavior(self) -> Dict: - """Return the behavior for turning the bulb on. + async def get_turn_on_behavior(self) -> TurnOnBehaviors: + """Return the behavior for turning the bulb on.""" + return TurnOnBehaviors.parse_obj( + await self._query_helper(self.LIGHT_SERVICE, "get_default_behavior") + ) - Example: - {'soft_on': {'mode': 'last_status'}, - 'hard_on': {'mode': 'last_status'}} + async def set_turn_on_behavior(self, behavior: TurnOnBehaviors): + """Set the behavior for turning the bulb on. + + If you do not want to manually construct the behavior object, + you should use :func:`get_turn_on_behavior` to get the current settings. """ - return await self._query_helper(self.LIGHT_SERVICE, "get_default_behavior") + return await self._query_helper( + self.LIGHT_SERVICE, "set_default_behavior", behavior.dict(by_alias=True) + ) async def get_light_state(self) -> Dict[str, Dict]: """Query the light state.""" From ec06331737d75b37aa46ba37b8b7e9811f12d467 Mon Sep 17 00:00:00 2001 From: Aric Forrest Date: Wed, 2 Nov 2022 18:11:24 -0700 Subject: [PATCH 118/892] Adding cli command to delete a schedule rule (#391) * adding cli option to delete rule * resolving black linting issue * simplifying command name Co-authored-by: Teemu R. * updating rule filter Co-authored-by: Teemu R. --- kasa/cli.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/kasa/cli.py b/kasa/cli.py index ee22edbcb..4fd3990bc 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -543,6 +543,20 @@ def _schedule_list(dev, type): click.echo(f"No rules of type {type}") +@schedule.command(name="delete") +@pass_dev +@click.option("--id", type=str, required=True) +async def delete_rule(dev, id): + """Delete rule from device.""" + schedule = dev.modules["schedule"] + rule_to_delete = next(filter(lambda rule: (rule.id == id), schedule.rules), None) + if rule_to_delete: + click.echo(f"Deleting rule id {id}") + await schedule.delete_rule(rule_to_delete) + else: + click.echo(f"No rule with id {id} was found") + + @cli.group(invoke_without_command=True) @click.pass_context async def presets(ctx): From 9cb2a5640528d9223813caee6e21ac0e42f443c2 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 11 Nov 2022 21:06:54 +0100 Subject: [PATCH 119/892] Add a note that transition is not supported by all devices (#398) --- kasa/smartbulb.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index b29c82f16..af79d7de2 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -163,7 +163,8 @@ class SmartBulb(SmartDevice): HSV(hue=180, saturation=100, value=80) If you don't want to use the default transitions, you can pass `transition` in milliseconds. - This applies to all transitions (:func:`turn_on`, :func:`turn_off`, :func:`set_hsv`, :func:`set_color_temp`, :func:`set_brightness`). + This applies to all transitions (:func:`turn_on`, :func:`turn_off`, :func:`set_hsv`, :func:`set_color_temp`, :func:`set_brightness`) if supported by the device. + Light strips (e.g., KL420L5) do not support this feature, but silently ignore the parameter. The following changes the brightness over a period of 10 seconds: >>> asyncio.run(bulb.set_brightness(100, transition=10_000)) From 866c8d6db53f2bb9fc18b17b7a69662517a74dff Mon Sep 17 00:00:00 2001 From: Julian Davis Date: Sun, 13 Nov 2022 22:34:47 +0000 Subject: [PATCH 120/892] Fix pytest warnings about asyncio (#397) Turn on ayncio auto mode for pytest and remove the global async marking flag --- kasa/tests/conftest.py | 4 ---- kasa/tests/test_bulb.py | 1 - kasa/tests/test_cli.py | 2 +- kasa/tests/test_dimmer.py | 2 +- kasa/tests/test_discovery.py | 2 +- kasa/tests/test_emeter.py | 2 +- kasa/tests/test_lightstrip.py | 2 +- kasa/tests/test_plug.py | 2 +- kasa/tests/test_protocol.py | 1 - kasa/tests/test_smartdevice.py | 2 +- kasa/tests/test_strip.py | 2 +- pyproject.toml | 1 + 12 files changed, 9 insertions(+), 14 deletions(-) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index faa5267e6..fa925630b 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -134,10 +134,6 @@ async def handle_turn_on(dev, turn_on): await dev.turn_off() -# to avoid adding this for each async function separately -pytestmark = pytest.mark.asyncio - - def device_for_file(model): for d in STRIPS: if d in model: diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 9012f5b71..019afaf56 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -10,7 +10,6 @@ non_color_bulb, non_dimmable, non_variable_temp, - pytestmark, turn_on, variable_temp, ) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 6ea71326b..762f05033 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -15,7 +15,7 @@ sysinfo, ) -from .conftest import handle_turn_on, pytestmark, turn_on +from .conftest import handle_turn_on, turn_on async def test_sysinfo(dev): diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index 96a1021a6..b5e98b787 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -2,7 +2,7 @@ from kasa import SmartDimmer -from .conftest import dimmer, handle_turn_on, pytestmark, turn_on +from .conftest import dimmer, handle_turn_on, turn_on @dimmer diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index e83561a25..b941654b8 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -6,7 +6,7 @@ from kasa import DeviceType, Discover, SmartDevice, SmartDeviceException, protocol from kasa.discover import _DiscoverProtocol -from .conftest import bulb, dimmer, lightstrip, plug, pytestmark, strip +from .conftest import bulb, dimmer, lightstrip, plug, strip @plug diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index b3d567dd6..75375230a 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -2,7 +2,7 @@ from kasa import EmeterStatus, SmartDeviceException -from .conftest import has_emeter, no_emeter, pytestmark +from .conftest import has_emeter, no_emeter from .newfakes import CURRENT_CONSUMPTION_SCHEMA diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index e53bb1f76..c90cedeed 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -3,7 +3,7 @@ from kasa import DeviceType, SmartLightStrip from kasa.exceptions import SmartDeviceException -from .conftest import lightstrip, pytestmark +from .conftest import lightstrip @lightstrip diff --git a/kasa/tests/test_plug.py b/kasa/tests/test_plug.py index 3de3a1461..e97043101 100644 --- a/kasa/tests/test_plug.py +++ b/kasa/tests/test_plug.py @@ -1,6 +1,6 @@ from kasa import DeviceType -from .conftest import plug, pytestmark +from .conftest import plug from .newfakes import PLUG_SCHEMA diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index f8931c11e..e540a9fbf 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -8,7 +8,6 @@ from ..exceptions import SmartDeviceException from ..protocol import TPLinkSmartHomeProtocol -from .conftest import pytestmark @pytest.mark.parametrize("retry_count", [1, 3, 5]) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 9138a7e5c..dd97b081d 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -6,7 +6,7 @@ from kasa import SmartDeviceException from kasa.smartstrip import SmartStripPlug -from .conftest import handle_turn_on, has_emeter, no_emeter, pytestmark, turn_on +from .conftest import handle_turn_on, has_emeter, no_emeter, turn_on from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol diff --git a/kasa/tests/test_strip.py b/kasa/tests/test_strip.py index 21a11e372..03199904b 100644 --- a/kasa/tests/test_strip.py +++ b/kasa/tests/test_strip.py @@ -4,7 +4,7 @@ from kasa import SmartDeviceException, SmartStrip -from .conftest import handle_turn_on, pytestmark, strip, turn_on +from .conftest import handle_turn_on, strip, turn_on @strip diff --git a/pyproject.toml b/pyproject.toml index 688b80ab9..476376d11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ verbose = 2 markers = [ "requires_dummy: test requires dummy data to pass, skipped on real devices", ] +asyncio_mode = "auto" [tool.doc8] paths = ["docs"] From ad5b5c223031961b8d7de17be13c1cf54905e785 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 15 Nov 2022 18:26:23 +0100 Subject: [PATCH 121/892] Update pre-commit url for flake8 (#400) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0fe7bdc44..5e52e5398 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: hooks: - id: black -- repo: https://gitlab.com/pycqa/flake8 +- repo: https://github.com/pycqa/flake8 rev: 3.9.2 hooks: - id: flake8 From 327efb6c65a762a6aec1d7c3bf3244a4b18ead85 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 15 Nov 2022 19:05:08 +0100 Subject: [PATCH 122/892] Update pre-commit hooks (#401) * Update pre-commit hooks * Fix implicit optionals --- .pre-commit-config.yaml | 12 ++++++------ kasa/discover.py | 2 +- kasa/smartbulb.py | 21 +++++++++++++++------ kasa/smartdevice.py | 6 ++++-- kasa/smartdimmer.py | 10 ++++++---- kasa/smartstrip.py | 10 +++++++--- 6 files changed, 39 insertions(+), 22 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e52e5398..6887d059b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v4.3.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -10,18 +10,18 @@ repos: - id: check-ast - repo: https://github.com/asottile/pyupgrade - rev: v2.31.1 + rev: v3.2.2 hooks: - id: pyupgrade args: ['--py37-plus'] - repo: https://github.com/python/black - rev: 22.3.0 + rev: 22.10.0 hooks: - id: black - repo: https://github.com/pycqa/flake8 - rev: 3.9.2 + rev: 5.0.4 hooks: - id: flake8 additional_dependencies: [flake8-docstrings] @@ -33,13 +33,13 @@ repos: additional_dependencies: [toml] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.942 + rev: v0.991 hooks: - id: mypy additional_dependencies: [types-click] - repo: https://github.com/PyCQA/doc8 - rev: '0.11.1' + rev: 'v1.0.0' hooks: - id: doc8 additional_dependencies: [tomli] diff --git a/kasa/discover.py b/kasa/discover.py index c09010efc..06285d1bc 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -31,7 +31,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol): def __init__( self, *, - on_discovered: OnDiscoveredCallable = None, + on_discovered: Optional[OnDiscoveredCallable] = None, target: str = "255.255.255.255", discovery_packets: int = 3, interface: Optional[str] = None, diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index af79d7de2..7d7db8686 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -296,7 +296,9 @@ async def get_light_state(self) -> Dict[str, Dict]: # TODO: add warning and refer to use light.state? return await self._query_helper(self.LIGHT_SERVICE, "get_light_state") - async def set_light_state(self, state: Dict, *, transition: int = None) -> Dict: + async def set_light_state( + self, state: Dict, *, transition: Optional[int] = None + ) -> Dict: """Set the light state.""" if transition is not None: state["transition_period"] = transition @@ -345,7 +347,12 @@ def _raise_for_invalid_brightness(self, value): @requires_update async def set_hsv( - self, hue: int, saturation: int, value: int = None, *, transition: int = None + self, + hue: int, + saturation: int, + value: Optional[int] = None, + *, + transition: Optional[int] = None ) -> Dict: """Set new HSV. @@ -392,7 +399,7 @@ def color_temp(self) -> int: @requires_update async def set_color_temp( - self, temp: int, *, brightness=None, transition: int = None + self, temp: int, *, brightness=None, transition: Optional[int] = None ) -> Dict: """Set the color temperature of the device in kelvin. @@ -426,7 +433,9 @@ def brightness(self) -> int: return int(light_state["brightness"]) @requires_update - async def set_brightness(self, brightness: int, *, transition: int = None) -> Dict: + async def set_brightness( + self, brightness: int, *, transition: Optional[int] = None + ) -> Dict: """Set the brightness in percentage. :param int brightness: brightness in percent @@ -464,14 +473,14 @@ def is_on(self) -> bool: light_state = self.light_state return bool(light_state["on_off"]) - async def turn_off(self, *, transition: int = None, **kwargs) -> Dict: + async def turn_off(self, *, transition: Optional[int] = None, **kwargs) -> Dict: """Turn the bulb off. :param int transition: transition in milliseconds. """ return await self.set_light_state({"on_off": 0}, transition=transition) - async def turn_on(self, *, transition: int = None, **kwargs) -> Dict: + async def turn_on(self, *, transition: Optional[int] = None, **kwargs) -> Dict: """Turn the bulb on. :param int transition: transition in milliseconds. diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index cf3c53849..253853f8f 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -510,7 +510,7 @@ def _emeter_convert_emeter_data(self, data, kwh=True) -> Dict: return data async def get_emeter_daily( - self, year: int = None, month: int = None, kwh: bool = True + self, year: Optional[int] = None, month: Optional[int] = None, kwh: bool = True ) -> Dict: """Retrieve daily statistics for a given month. @@ -524,7 +524,9 @@ async def get_emeter_daily( return await self.modules["emeter"].get_daystat(year=year, month=month, kwh=kwh) @requires_update - async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: + async def get_emeter_monthly( + self, year: Optional[int] = None, kwh: bool = True + ) -> Dict: """Retrieve monthly statistics for a given year. :param year: year for which to retrieve statistics (default: this year) diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py index cb830d233..74d9221ac 100644 --- a/kasa/smartdimmer.py +++ b/kasa/smartdimmer.py @@ -1,5 +1,5 @@ """Module for dimmers (currently only HS220).""" -from typing import Any, Dict +from typing import Any, Dict, Optional from kasa.modules import AmbientLight, Motion from kasa.smartdevice import DeviceType, SmartDeviceException, requires_update @@ -60,7 +60,9 @@ def brightness(self) -> int: return int(sys_info["brightness"]) @requires_update - async def set_brightness(self, brightness: int, *, transition: int = None): + async def set_brightness( + self, brightness: int, *, transition: Optional[int] = None + ): """Set the new dimmer brightness level in percentage. :param int transition: transition duration in milliseconds. @@ -89,7 +91,7 @@ async def set_brightness(self, brightness: int, *, transition: int = None): self.DIMMER_SERVICE, "set_brightness", {"brightness": brightness} ) - async def turn_off(self, *, transition: int = None, **kwargs): + async def turn_off(self, *, transition: Optional[int] = None, **kwargs): """Turn the bulb off. :param int transition: transition duration in milliseconds. @@ -100,7 +102,7 @@ async def turn_off(self, *, transition: int = None, **kwargs): return await super().turn_off() @requires_update - async def turn_on(self, *, transition: int = None, **kwargs): + async def turn_on(self, *, transition: Optional[int] = None, **kwargs): """Turn the bulb on. :param int transition: transition duration in milliseconds. diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 47ada6723..69ea03e59 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -172,7 +172,7 @@ async def get_emeter_realtime(self) -> EmeterStatus: @requires_update async def get_emeter_daily( - self, year: int = None, month: int = None, kwh: bool = True + self, year: Optional[int] = None, month: Optional[int] = None, kwh: bool = True ) -> Dict: """Retrieve daily statistics for a given month. @@ -187,7 +187,9 @@ async def get_emeter_daily( ) @requires_update - async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: + async def get_emeter_monthly( + self, year: Optional[int] = None, kwh: bool = True + ) -> Dict: """Retrieve monthly statistics for a given year. :param year: year for which to retrieve statistics (default: this year) @@ -262,7 +264,9 @@ async def update(self, update_children: bool = True): """ await self._modular_update({}) - def _create_emeter_request(self, year: int = None, month: int = None): + def _create_emeter_request( + self, year: Optional[int] = None, month: Optional[int] = None + ): """Create a request for requesting all emeter statistics at once.""" if year is None: year = datetime.now().year From 362c60d7b108c884fdad0667ab746e5a6fba0b71 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 15 Nov 2022 19:12:38 +0100 Subject: [PATCH 123/892] Add FUNDING.yml (#402) --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..51396acef --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [rytilahti] From bfafbf951244782e6b7db9012b65ce69436dd973 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Jan 2023 19:28:28 +0100 Subject: [PATCH 124/892] Bump certifi from 2021.10.8 to 2022.12.7 (#409) Bumps [certifi](https://github.com/certifi/python-certifi) from 2021.10.8 to 2022.12.7. - [Release notes](https://github.com/certifi/python-certifi/releases) - [Commits](https://github.com/certifi/python-certifi/compare/2021.10.08...2022.12.07) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 84 ++++++++++++++++++++++++++++------------------------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4521b423d..ce335c03c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -20,8 +20,8 @@ sniffio = ">=1.1" typing-extensions = {version = "*", markers = "python_version < \"3.8\""} [package.extras] -doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] -test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] +doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] trio = ["trio (>=0.16)"] [[package]] @@ -53,10 +53,10 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] -docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] +dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "sphinx", "sphinx-notfound-page", "zope.interface"] +docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] +tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "zope.interface"] +tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six"] [[package]] name = "babel" @@ -71,11 +71,11 @@ pytz = ">=2015.7" [[package]] name = "certifi" -version = "2021.10.8" +version = "2022.12.7" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "cfgv" @@ -94,7 +94,7 @@ optional = false python-versions = ">=3.5.0" [package.extras] -unicode_backport = ["unicodedata2"] +unicode-backport = ["unicodedata2"] [[package]] name = "codecov" @@ -198,9 +198,9 @@ typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +docs = ["jaraco.packaging (>=9)", "rst.linker (>=1.9)", "sphinx"] perf = ["ipython"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] [[package]] name = "iniconfig" @@ -392,7 +392,7 @@ pytest = ">=6.1.0" typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} [package.extras] -testing = ["coverage (==6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (==0.931)", "pytest-trio (>=0.7.0)"] +testing = ["coverage (==6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (==0.931)", "pytest-trio (>=0.7.0)"] [[package]] name = "pytest-cov" @@ -408,7 +408,7 @@ pytest = ">=4.6" toml = "*" [package.extras] -testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] [[package]] name = "pytest-mock" @@ -422,7 +422,7 @@ python-versions = ">=3.7" pytest = ">=5.0" [package.extras] -dev = ["pre-commit", "tox", "pytest-asyncio"] +dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] name = "pytest-sugar" @@ -469,7 +469,7 @@ urllib3 = ">=1.21.1,<1.27" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] -use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<5)"] [[package]] name = "six" @@ -524,8 +524,8 @@ sphinxcontrib-serializinghtml = ">=1.1.5" [package.extras] docs = ["sphinxcontrib-websupport"] -lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.931)", "docutils-stubs", "types-typed-ast", "types-requests"] -test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"] +lint = ["docutils-stubs", "flake8 (>=3.5.0)", "isort", "mypy (>=0.931)", "types-requests", "types-typed-ast"] +test = ["cython", "html5lib", "pytest", "pytest-cov", "typed-ast"] [[package]] name = "sphinx-rtd-theme" @@ -540,7 +540,7 @@ docutils = "<0.17" sphinx = "*" [package.extras] -dev = ["transifex-client", "sphinxcontrib-httpdomain", "bump2version"] +dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client"] [[package]] name = "sphinxcontrib-applehelp" @@ -551,7 +551,7 @@ optional = true python-versions = ">=3.5" [package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] +lint = ["docutils-stubs", "flake8", "mypy"] test = ["pytest"] [[package]] @@ -563,7 +563,7 @@ optional = true python-versions = ">=3.5" [package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] +lint = ["docutils-stubs", "flake8", "mypy"] test = ["pytest"] [[package]] @@ -575,8 +575,8 @@ optional = true python-versions = ">=3.6" [package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest", "html5lib"] +lint = ["docutils-stubs", "flake8", "mypy"] +test = ["html5lib", "pytest"] [[package]] name = "sphinxcontrib-jsmath" @@ -587,7 +587,7 @@ optional = true python-versions = ">=3.5" [package.extras] -test = ["pytest", "flake8", "mypy"] +test = ["flake8", "mypy", "pytest"] [[package]] name = "sphinxcontrib-programoutput" @@ -609,7 +609,7 @@ optional = true python-versions = ">=3.5" [package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] +lint = ["docutils-stubs", "flake8", "mypy"] test = ["pytest"] [[package]] @@ -621,7 +621,7 @@ optional = true python-versions = ">=3.5" [package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] +lint = ["docutils-stubs", "flake8", "mypy"] test = ["pytest"] [[package]] @@ -669,7 +669,7 @@ virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2, [package.extras] docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] -testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "psutil (>=5.6.1)", "pathlib2 (>=2.3.3)"] +testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)"] [[package]] name = "typing-extensions" @@ -688,8 +688,8 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] -brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] @@ -709,7 +709,7 @@ six = ">=1.9.0,<2" [package.extras] docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "packaging (>=20.0)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)"] [[package]] name = "voluptuous" @@ -731,11 +731,11 @@ python-versions = "*" six = "*" [package.extras] -all = ["six", "codecov", "scikit-build", "cmake", "ninja", "pybind11", "pygments", "colorama", "pytest", "pytest", "pytest-cov", "pytest", "pytest", "pytest-cov", "typing", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel", "pytest", "pytest-cov"] -colors = ["pygments", "colorama"] -jupyter = ["nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] -optional = ["pygments", "colorama", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] -tests = ["codecov", "scikit-build", "cmake", "ninja", "pybind11", "pytest", "pytest", "pytest-cov", "pytest", "pytest", "pytest-cov", "typing", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel", "pytest", "pytest-cov"] +all = ["IPython", "Pygments", "cmake", "codecov", "colorama", "ipykernel", "jupyter-client", "nbconvert", "nbformat", "ninja", "pybind11", "pytest", "pytest", "pytest", "pytest", "pytest", "pytest-cov", "pytest-cov", "pytest-cov", "scikit-build", "six", "typing"] +colors = ["Pygments", "colorama"] +jupyter = ["IPython", "ipykernel", "jupyter-client", "nbconvert", "nbformat"] +optional = ["IPython", "Pygments", "colorama", "ipykernel", "jupyter-client", "nbconvert", "nbformat"] +tests = ["IPython", "cmake", "codecov", "ipykernel", "jupyter-client", "nbconvert", "nbformat", "ninja", "pybind11", "pytest", "pytest", "pytest", "pytest", "pytest", "pytest-cov", "pytest-cov", "pytest-cov", "scikit-build", "typing"] [[package]] name = "zipp" @@ -746,8 +746,8 @@ optional = false python-versions = ">=3.7" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] +docs = ["jaraco.packaging (>=9)", "rst.linker (>=1.9)", "sphinx"] +testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [extras] docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programoutput"] @@ -782,8 +782,8 @@ babel = [ {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, ] certifi = [ - {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, - {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, + {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, + {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, ] cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, @@ -795,7 +795,6 @@ charset-normalizer = [ ] codecov = [ {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"}, - {file = "codecov-2.1.12-py3.8.egg", hash = "sha256:782a8e5352f22593cbc5427a35320b99490eb24d9dcfa2155fd99d2b75cfb635"}, {file = "codecov-2.1.12.tar.gz", hash = "sha256:a0da46bb5025426da895af90938def8ee12d37fcbcbbbc15b6dc64cf7ebc51c1"}, ] colorama = [ @@ -1031,6 +1030,13 @@ pyyaml = [ {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, + {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, + {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, From 6e7a588d40396fd3696593cad4394c1008917193 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 21 Jan 2023 00:25:59 +0100 Subject: [PATCH 125/892] Add brightness to lightstrip's set_effect (#415) * Add brightness parameter to lightstrip's set_effect * Use None as default as effects have different default brightnesses --- kasa/smartlightstrip.py | 9 ++++++--- kasa/tests/test_lightstrip.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py index 087234538..ae16aa75e 100644 --- a/kasa/smartlightstrip.py +++ b/kasa/smartlightstrip.py @@ -89,16 +89,19 @@ def state_information(self) -> Dict[str, Any]: @requires_update async def set_effect( - self, - effect: str, + self, effect: str, *, brightness: Optional[int] = None ) -> None: """Set an effect on the device. :param str effect: The effect to set + :param int brightness: The wanted brightness """ if effect not in EFFECT_MAPPING_V1: raise SmartDeviceException(f"The effect {effect} is not a built in effect.") - await self.set_custom_effect(EFFECT_MAPPING_V1[effect]) + effect_dict = EFFECT_MAPPING_V1[effect] + if brightness is not None: + effect_dict["brightness"] = brightness + await self.set_custom_effect(effect_dict) @requires_update async def set_custom_effect( diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index c90cedeed..962bb150c 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -30,6 +30,23 @@ async def test_effects_lightstrip_set_effect(dev: SmartLightStrip): assert dev.state_information["Effect"] == "Candy Cane" +@lightstrip +@pytest.mark.parametrize("brightness", [100, 50]) +async def test_effects_lightstrip_set_effect_brightness( + dev: SmartLightStrip, brightness, mocker +): + query_helper = mocker.patch("kasa.SmartLightStrip._query_helper") + + if brightness == 100: # test that default brightness works + await dev.set_effect("Candy Cane") + else: + await dev.set_effect("Candy Cane", brightness=brightness) + + args, kwargs = query_helper.call_args_list[0] + payload = args[2] + assert payload["brightness"] == brightness + + @lightstrip async def test_effects_lightstrip_has_effects(dev: SmartLightStrip): assert dev.has_effects is True From 334ba1713a03544cfefbde360dbad44b8489bdf4 Mon Sep 17 00:00:00 2001 From: Julian Davis Date: Sat, 18 Feb 2023 16:20:06 +0000 Subject: [PATCH 126/892] Added .gitattributes file to retain LF only EOL markers when checking out on Windows (#399) --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..f1815500b --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf From dd044130d402193db3d630b6f89c7ddbc4369042 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 18 Feb 2023 17:31:06 +0100 Subject: [PATCH 127/892] Use rich for prettier output, if available (#403) Use rich for prettier output, if available. This does not add a new dependency, but rather uses rich if it's installed. --- devtools/parse_pcap.py | 37 +++--- kasa/cli.py | 252 ++++++++++++++++++++++------------------- kasa/tests/test_cli.py | 4 +- 3 files changed, 152 insertions(+), 141 deletions(-) diff --git a/devtools/parse_pcap.py b/devtools/parse_pcap.py index 305fcc57b..3044aee37 100644 --- a/devtools/parse_pcap.py +++ b/devtools/parse_pcap.py @@ -3,12 +3,12 @@ import json from collections import Counter, defaultdict from pprint import pformat as pf -from pprint import pprint as pp import click import dpkt from dpkt.ethernet import ETH_TYPE_IP, Ethernet +from kasa.cli import echo from kasa.protocol import TPLinkSmartHomeProtocol @@ -36,21 +36,20 @@ def read_payloads_from_file(file): try: decrypted = TPLinkSmartHomeProtocol.decrypt(data[4:]) except Exception as ex: - click.echo( - click.style(f"Unable to decrypt the data, ignoring: {ex}", fg="red") - ) + echo(f"[red]Unable to decrypt the data, ignoring: {ex}[/red]") + continue + + if not decrypted: # skip empty payloads continue try: json_payload = json.loads(decrypted) - except Exception as ex: - click.echo( - click.style(f"Unable to parse payload, ignoring: {ex}", fg="red") - ) + except Exception as ex: # this can happen when the response is split into multiple tcp segments + echo(f"[red]Unable to parse payload '{decrypted}', ignoring: {ex}[/red]") continue if not json_payload: # ignore empty payloads - click.echo(click.style("Got empty payload, ignoring", fg="red")) + echo("[red]Got empty payload, ignoring[/red]") continue yield json_payload @@ -67,7 +66,7 @@ def parse_pcap(file): for module, cmds in json_payload.items(): seen_items["modules"][module] += 1 if "err_code" in cmds: - click.echo(click.style("Got error for module: %s" % cmds, fg="red")) + echo("[red]Got error for module: %s[/red]" % cmds) continue for cmd, response in cmds.items(): @@ -76,30 +75,24 @@ def parse_pcap(file): if response is None: continue direction = ">>" - style = {} if response is None: - print("got none as response for %s, weird?" % (cmd)) + echo(f"got none as response for {cmd} %s, weird?") continue + is_success = "[green]+[/green]" if "err_code" in response: direction = "<<" if response["err_code"] != 0: seen_items["errorcodes"][response["err_code"]] += 1 seen_items["errors"][response["err_msg"]] += 1 - print(response) - style = {"bold": True, "fg": "red"} - else: - style = {"fg": "green"} + is_success = "[red]![/red]" context_str = f" [ctx: {context}]" if context else "" - click.echo( - click.style( - f"{direction}{context_str} {module}.{cmd}: {pf(response)}", - **style, - ) + echo( + f"[{is_success}] {direction}{context_str} {module}.{cmd}: {pf(response)}" ) - pp(seen_items) + echo(pf(seen_items)) if __name__ == "__main__": diff --git a/kasa/cli.py b/kasa/cli.py index 4fd3990bc..f72759a59 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1,12 +1,33 @@ """python-kasa cli tool.""" import asyncio +import json import logging +import re import sys +from functools import wraps from pprint import pformat as pf -from typing import cast +from typing import Any, Dict, cast import asyncclick as click +try: + from rich import print as echo +except ImportError: + + def _strip_rich_formatting(echo_func): + """Strip rich formatting from messages.""" + + @wraps(echo_func) + def wrapper(message=None, *args, **kwargs): + if message is not None: + message = re.sub(r"\[/?.+?]", "", message) + echo_func(message, *args, **kwargs) + + return wrapper + + echo = _strip_rich_formatting(click.echo) + + from kasa import ( Discover, SmartBulb, @@ -69,32 +90,44 @@ async def cli(ctx, host, alias, target, debug, type): ctx.obj = SmartDevice(None) return - if debug: - logging.basicConfig(level=logging.DEBUG) - else: - logging.basicConfig(level=logging.INFO) + logging_config: Dict[str, Any] = { + "level": logging.DEBUG if debug > 0 else logging.INFO + } + try: + from rich.logging import RichHandler + + rich_config = { + "show_time": False, + } + logging_config["handlers"] = [RichHandler(**rich_config)] + logging_config["format"] = "%(message)s" + except ImportError: + pass + + # The configuration should be converted to use dictConfig, but this keeps mypy happy for now + logging.basicConfig(**logging_config) # type: ignore if ctx.invoked_subcommand == "discover": return if alias is not None and host is None: - click.echo(f"Alias is given, using discovery to find host {alias}") + echo(f"Alias is given, using discovery to find host {alias}") host = await find_host_from_alias(alias=alias, target=target) if host: - click.echo(f"Found hostname is {host}") + echo(f"Found hostname is {host}") else: - click.echo(f"No device with name {alias} found") + echo(f"No device with name {alias} found") return if host is None: - click.echo("No host name given, trying discovery..") + echo("No host name given, trying discovery..") await ctx.invoke(discover) return if type is not None: dev = TYPE_TO_CLASS[type](host) else: - click.echo("No --type defined, discovering..") + echo("No --type defined, discovering..") dev = await Discover.discover_single(host) await dev.update() @@ -114,11 +147,11 @@ def wifi(dev): @pass_dev async def scan(dev): """Scan for available wifi networks.""" - click.echo("Scanning for wifi networks, wait a second..") + echo("Scanning for wifi networks, wait a second..") devs = await dev.wifi_scan() - click.echo(f"Found {len(devs)} wifi networks!") + echo(f"Found {len(devs)} wifi networks!") for dev in devs: - click.echo(f"\t {dev}") + echo(f"\t {dev}") return devs @@ -130,9 +163,9 @@ async def scan(dev): @pass_dev async def join(dev: SmartDevice, ssid, password, keytype): """Join the given wifi network.""" - click.echo(f"Asking the device to connect to {ssid}..") + echo(f"Asking the device to connect to {ssid}..") res = await dev.wifi_join(ssid, password, keytype=keytype) - click.echo( + echo( f"Response: {res} - if the device is not able to join the network, it will revert back to its previous state." ) @@ -145,7 +178,7 @@ async def join(dev: SmartDevice, ssid, password, keytype): async def discover(ctx, timeout): """Discover devices in the network.""" target = ctx.parent.params["target"] - click.echo(f"Discovering devices on {target} for {timeout} seconds") + echo(f"Discovering devices on {target} for {timeout} seconds") sem = asyncio.Semaphore() async def print_discovered(dev: SmartDevice): @@ -153,7 +186,7 @@ async def print_discovered(dev: SmartDevice): async with sem: ctx.obj = dev await ctx.invoke(state) - click.echo() + echo() await Discover.discover( target=target, timeout=timeout, on_discovered=print_discovered @@ -176,8 +209,8 @@ async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attem @pass_dev async def sysinfo(dev): """Print out full system information.""" - click.echo(click.style("== System info ==", bold=True)) - click.echo(pf(dev.sys_info)) + echo("== System info ==") + echo(pf(dev.sys_info)) return dev.sys_info @@ -185,56 +218,42 @@ async def sysinfo(dev): @pass_dev async def state(dev: SmartDevice): """Print out device state and versions.""" - click.echo(click.style(f"== {dev.alias} - {dev.model} ==", bold=True)) - click.echo(f"\tHost: {dev.host}") - click.echo( - click.style( - "\tDevice state: {}\n".format("ON" if dev.is_on else "OFF"), - fg="green" if dev.is_on else "red", - ) - ) + echo(f"[bold]== {dev.alias} - {dev.model} ==[/bold]") + echo(f"\tHost: {dev.host}") + echo(f"\tDevice state: {dev.is_on}") if dev.is_strip: - click.echo(click.style("\t== Plugs ==", bold=True)) + echo("\t[bold]== Plugs ==[/bold]") for plug in dev.children: # type: ignore - is_on = plug.is_on - alias = plug.alias - click.echo( - click.style( - "\t* Socket '{}' state: {} on_since: {}".format( - alias, ("ON" if is_on else "OFF"), plug.on_since - ), - fg="green" if is_on else "red", - ) - ) - click.echo() + echo(f"\t* Socket '{plug.alias}' state: {plug.is_on} since {plug.on_since}") + echo() - click.echo(click.style("\t== Generic information ==", bold=True)) - click.echo(f"\tTime: {dev.time} (tz: {dev.timezone}") - click.echo(f"\tHardware: {dev.hw_info['hw_ver']}") - click.echo(f"\tSoftware: {dev.hw_info['sw_ver']}") - click.echo(f"\tMAC (rssi): {dev.mac} ({dev.rssi})") - click.echo(f"\tLocation: {dev.location}") + echo("\t[bold]== Generic information ==[/bold]") + echo(f"\tTime: {dev.time} (tz: {dev.timezone}") + echo(f"\tHardware: {dev.hw_info['hw_ver']}") + echo(f"\tSoftware: {dev.hw_info['sw_ver']}") + echo(f"\tMAC (rssi): {dev.mac} ({dev.rssi})") + echo(f"\tLocation: {dev.location}") - click.echo(click.style("\n\t== Device specific information ==", bold=True)) + echo("\n\t[bold]== Device specific information ==[/bold]") for info_name, info_data in dev.state_information.items(): if isinstance(info_data, list): - click.echo(f"\t{info_name}:") + echo(f"\t{info_name}:") for item in info_data: - click.echo(f"\t\t{item}") + echo(f"\t\t{item}") else: - click.echo(f"\t{info_name}: {info_data}") + echo(f"\t{info_name}: {info_data}") if dev.has_emeter: - click.echo(click.style("\n\t== Current State ==", bold=True)) + echo("\n\t[bold]== Current State ==[/bold]") emeter_status = dev.emeter_realtime - click.echo(f"\t{emeter_status}") + echo(f"\t{emeter_status}") - click.echo(click.style("\n\t== Modules ==", bold=True)) + echo("\n\t[bold]== Modules ==[/bold]") for module in dev.modules.values(): if module.is_supported: - click.echo(click.style(f"\t+ {module}", fg="green")) + echo(f"\t[green]+ {module}[/green]") else: - click.echo(click.style(f"\t- {module}", fg="red")) + echo(f"\t[red]- {module}[/red]") @cli.command() @@ -245,20 +264,20 @@ async def alias(dev, new_alias, index): """Get or set the device (or plug) alias.""" if index is not None: if not dev.is_strip: - click.echo("Index can only used for power strips!") + echo("Index can only used for power strips!") return dev = cast(SmartStrip, dev) dev = dev.get_plug_by_index(index) if new_alias is not None: - click.echo(f"Setting alias to {new_alias}") + echo(f"Setting alias to {new_alias}") res = await dev.set_alias(new_alias) return res - click.echo(f"Alias: {dev.alias}") + echo(f"Alias: {dev.alias}") if dev.is_strip: for plug in dev.children: - click.echo(f" * {plug.alias}") + echo(f" * {plug.alias}") @cli.command() @@ -274,8 +293,7 @@ async def raw_command(dev: SmartDevice, module, command, parameters): parameters = ast.literal_eval(parameters) res = await dev._query_helper(module, command, parameters) - - click.echo(res) + echo(json.dumps(res)) return res @@ -289,41 +307,41 @@ async def emeter(dev: SmartDevice, year, month, erase): Daily and monthly data provided in CSV format. """ - click.echo(click.style("== Emeter ==", bold=True)) + echo("[bold]== Emeter ==[/bold]") if not dev.has_emeter: - click.echo("Device has no emeter") + echo("Device has no emeter") return if erase: - click.echo("Erasing emeter statistics..") - click.echo(await dev.erase_emeter_stats()) + echo("Erasing emeter statistics..") + echo(await dev.erase_emeter_stats()) return if year: - click.echo(f"== For year {year.year} ==") - click.echo("Month, usage (kWh)") + echo(f"== For year {year.year} ==") + echo("Month, usage (kWh)") usage_data = await dev.get_emeter_monthly(year=year.year) elif month: - click.echo(f"== For month {month.month} of {month.year} ==") - click.echo("Day, usage (kWh)") + echo(f"== For month {month.month} of {month.year} ==") + echo("Day, usage (kWh)") usage_data = await dev.get_emeter_daily(year=month.year, month=month.month) else: # Call with no argument outputs summary data and returns emeter_status = dev.emeter_realtime - click.echo("Current: %s A" % emeter_status["current"]) - click.echo("Voltage: %s V" % emeter_status["voltage"]) - click.echo("Power: %s W" % emeter_status["power"]) - click.echo("Total consumption: %s kWh" % emeter_status["total"]) + echo("Current: %s A" % emeter_status["current"]) + echo("Voltage: %s V" % emeter_status["voltage"]) + echo("Power: %s W" % emeter_status["power"]) + echo("Total consumption: %s kWh" % emeter_status["total"]) - click.echo("Today: %s kWh" % dev.emeter_today) - click.echo("This month: %s kWh" % dev.emeter_this_month) + echo("Today: %s kWh" % dev.emeter_today) + echo("This month: %s kWh" % dev.emeter_this_month) return # output any detailed usage data for index, usage in usage_data.items(): - click.echo(f"{index}, {usage}") + echo(f"{index}, {usage}") @cli.command() @@ -336,32 +354,32 @@ async def usage(dev: SmartDevice, year, month, erase): Daily and monthly data provided in CSV format. """ - click.echo(click.style("== Usage ==", bold=True)) + echo("[bold]== Usage ==[/bold]") usage = dev.modules["usage"] if erase: - click.echo("Erasing usage statistics..") - click.echo(await usage.erase_stats()) + echo("Erasing usage statistics..") + echo(await usage.erase_stats()) return if year: - click.echo(f"== For year {year.year} ==") - click.echo("Month, usage (minutes)") - usage_data = await usage.get_monthstat(year.year) + echo(f"== For year {year.year} ==") + echo("Month, usage (minutes)") + usage_data = await usage.get_monthstat(year=year.year) elif month: - click.echo(f"== For month {month.month} of {month.year} ==") - click.echo("Day, usage (minutes)") + echo(f"== For month {month.month} of {month.year} ==") + echo("Day, usage (minutes)") usage_data = await usage.get_daystat(year=month.year, month=month.month) else: # Call with no argument outputs summary data and returns - click.echo("Today: %s minutes" % usage.usage_today) - click.echo("This month: %s minutes" % usage.usage_this_month) + echo("Today: %s minutes" % usage.usage_today) + echo("This month: %s minutes" % usage.usage_this_month) return # output any detailed usage data for index, usage in usage_data.items(): - click.echo(f"{index}, {usage}") + echo(f"{index}, {usage}") @cli.command() @@ -371,13 +389,13 @@ async def usage(dev: SmartDevice, year, month, erase): async def brightness(dev: SmartBulb, brightness: int, transition: int): """Get or set brightness.""" if not dev.is_dimmable: - click.echo("This device does not support brightness.") + echo("This device does not support brightness.") return if brightness is None: - click.echo(f"Brightness: {dev.brightness}") + echo(f"Brightness: {dev.brightness}") else: - click.echo(f"Setting brightness to {brightness}") + echo(f"Setting brightness to {brightness}") return await dev.set_brightness(brightness, transition=transition) @@ -390,21 +408,21 @@ async def brightness(dev: SmartBulb, brightness: int, transition: int): async def temperature(dev: SmartBulb, temperature: int, transition: int): """Get or set color temperature.""" if not dev.is_variable_color_temp: - click.echo("Device does not support color temperature") + echo("Device does not support color temperature") return if temperature is None: - click.echo(f"Color temperature: {dev.color_temp}") + echo(f"Color temperature: {dev.color_temp}") valid_temperature_range = dev.valid_temperature_range if valid_temperature_range != (0, 0): - click.echo("(min: {}, max: {})".format(*valid_temperature_range)) + echo("(min: {}, max: {})".format(*valid_temperature_range)) else: - click.echo( + echo( "Temperature range unknown, please open a github issue" f" or a pull request for model '{dev.model}'" ) else: - click.echo(f"Setting color temperature to {temperature}") + echo(f"Setting color temperature to {temperature}") return await dev.set_color_temp(temperature, transition=transition) @@ -415,7 +433,7 @@ async def temperature(dev: SmartBulb, temperature: int, transition: int): async def effect(dev, ctx, effect): """Set an effect.""" if not dev.has_effects: - click.echo("Device does not support effects") + echo("Device does not support effects") return if effect is None: raise click.BadArgumentUsage( @@ -425,7 +443,7 @@ async def effect(dev, ctx, effect): if effect not in dev.effect_list: raise click.BadArgumentUsage(f"Effect must be one of: {dev.effect_list}", ctx) - click.echo(f"Setting Effect: {effect}") + echo(f"Setting Effect: {effect}") return await dev.set_effect(effect) @@ -439,15 +457,15 @@ async def effect(dev, ctx, effect): async def hsv(dev, ctx, h, s, v, transition): """Get or set color in HSV.""" if not dev.is_color: - click.echo("Device does not support colors") + echo("Device does not support colors") return if h is None or s is None or v is None: - click.echo(f"Current HSV: {dev.hsv}") + echo(f"Current HSV: {dev.hsv}") elif s is None or v is None: raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx) else: - click.echo(f"Setting HSV: {h} {s} {v}") + echo(f"Setting HSV: {h} {s} {v}") return await dev.set_hsv(h, s, v, transition=transition) @@ -457,10 +475,10 @@ async def hsv(dev, ctx, h, s, v, transition): async def led(dev, state): """Get or set (Plug's) led state.""" if state is not None: - click.echo(f"Turning led to {state}") + echo(f"Turning led to {state}") return await dev.set_led(state) else: - click.echo(f"LED state: {dev.led}") + echo(f"LED state: {dev.led}") @cli.command() @@ -468,7 +486,7 @@ async def led(dev, state): async def time(dev): """Get the device time.""" res = dev.time - click.echo(f"Current time: {res}") + echo(f"Current time: {res}") return res @@ -481,7 +499,7 @@ async def on(dev: SmartDevice, index: int, name: str, transition: int): """Turn the device on.""" if index is not None or name is not None: if not dev.is_strip: - click.echo("Index and name are only for power strips!") + echo("Index and name are only for power strips!") return dev = cast(SmartStrip, dev) @@ -490,7 +508,7 @@ async def on(dev: SmartDevice, index: int, name: str, transition: int): elif name: dev = dev.get_plug_by_name(name) - click.echo(f"Turning on {dev.alias}") + echo(f"Turning on {dev.alias}") return await dev.turn_on(transition=transition) @@ -503,7 +521,7 @@ async def off(dev: SmartDevice, index: int, name: str, transition: int): """Turn the device off.""" if index is not None or name is not None: if not dev.is_strip: - click.echo("Index and name are only for power strips!") + echo("Index and name are only for power strips!") return dev = cast(SmartStrip, dev) @@ -512,7 +530,7 @@ async def off(dev: SmartDevice, index: int, name: str, transition: int): elif name: dev = dev.get_plug_by_name(name) - click.echo(f"Turning off {dev.alias}") + echo(f"Turning off {dev.alias}") return await dev.turn_off(transition=transition) @@ -521,7 +539,7 @@ async def off(dev: SmartDevice, index: int, name: str, transition: int): @pass_dev async def reboot(plug, delay): """Reboot the device.""" - click.echo("Rebooting the device..") + echo("Rebooting the device..") return await plug.reboot(delay) @@ -540,7 +558,7 @@ def _schedule_list(dev, type): for rule in sched.rules: print(rule) else: - click.echo(f"No rules of type {type}") + echo(f"No rules of type {type}") @schedule.command(name="delete") @@ -551,10 +569,10 @@ async def delete_rule(dev, id): schedule = dev.modules["schedule"] rule_to_delete = next(filter(lambda rule: (rule.id == id), schedule.rules), None) if rule_to_delete: - click.echo(f"Deleting rule id {id}") + echo(f"Deleting rule id {id}") await schedule.delete_rule(rule_to_delete) else: - click.echo(f"No rule with id {id} was found") + echo(f"No rule with id {id} was found") @cli.group(invoke_without_command=True) @@ -570,7 +588,7 @@ async def presets(ctx): def presets_list(dev: SmartBulb): """List presets.""" if not dev.is_bulb: - click.echo("Presets only supported on bulbs") + echo("Presets only supported on bulbs") return for preset in dev.presets: @@ -592,7 +610,7 @@ async def presets_modify( if preset.index == index: break else: - click.echo(f"No preset found for index {index}") + echo(f"No preset found for index {index}") return if brightness is not None: @@ -604,7 +622,7 @@ async def presets_modify( if temperature is not None: preset.color_temp = temperature - click.echo(f"Going to save preset: {preset}") + echo(f"Going to save preset: {preset}") await dev.save_preset(preset) @@ -617,7 +635,7 @@ async def presets_modify( async def turn_on_behavior(dev: SmartBulb, type, last, preset): """Modify bulb turn-on behavior.""" settings = await dev.get_turn_on_behavior() - click.echo(f"Current turn on behavior: {settings}") + echo(f"Current turn on behavior: {settings}") # Return if we are not setting the value if not type and not last and not preset: @@ -625,16 +643,16 @@ async def turn_on_behavior(dev: SmartBulb, type, last, preset): # If we are setting the value, the type has to be specified if (last or preset) and type is None: - click.echo("To set the behavior, you need to define --type") + echo("To set the behavior, you need to define --type") return behavior = getattr(settings, type) if last: - click.echo(f"Going to set {type} to last") + echo(f"Going to set {type} to last") behavior.preset = None elif preset is not None: - click.echo(f"Going to set {type} to preset {preset}") + echo(f"Going to set {type} to preset {preset}") behavior.preset = preset await dev.set_turn_on_behavior(settings) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 762f05033..420583780 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -33,9 +33,9 @@ async def test_state(dev, turn_on): await dev.update() if dev.is_on: - assert "Device state: ON" in res.output + assert "Device state: True" in res.output else: - assert "Device state: OFF" in res.output + assert "Device state: False" in res.output async def test_alias(dev): From 92636fe82db7c113983b0915f375cef09a8b09ad Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 18 Feb 2023 17:32:03 +0100 Subject: [PATCH 128/892] Pretty-print all exceptions from cli commands (#428) Print out the repr of captured exception instead of full traceback --- kasa/cli.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/kasa/cli.py b/kasa/cli.py index f72759a59..100821d99 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -52,7 +52,21 @@ def wrapper(message=None, *args, **kwargs): pass_dev = click.make_pass_decorator(SmartDevice) -@click.group(invoke_without_command=True) +class ExceptionHandlerGroup(click.Group): + """Group to capture all exceptions and print them nicely. + + Idea from https://stackoverflow.com/a/44347763 + """ + + def __call__(self, *args, **kwargs): + """Run the coroutine in the event loop and print any exceptions.""" + try: + asyncio.get_event_loop().run_until_complete(self.main(*args, **kwargs)) + except Exception as ex: + click.echo(f"Got error: {ex!r}") + + +@click.group(invoke_without_command=True, cls=ExceptionHandlerGroup) @click.option( "--host", envvar="KASA_HOST", From 12c98eb58dc1799c0069ef04158949496559dedf Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 18 Feb 2023 18:03:54 +0100 Subject: [PATCH 129/892] Add transition parameter to lightstrip's set_effect (#416) --- kasa/smartlightstrip.py | 14 +++++++++++++- kasa/tests/test_lightstrip.py | 21 ++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py index ae16aa75e..566bf0a7e 100644 --- a/kasa/smartlightstrip.py +++ b/kasa/smartlightstrip.py @@ -89,18 +89,30 @@ def state_information(self) -> Dict[str, Any]: @requires_update async def set_effect( - self, effect: str, *, brightness: Optional[int] = None + self, + effect: str, + *, + brightness: Optional[int] = None, + transition: Optional[int] = None, ) -> None: """Set an effect on the device. + If brightness or transition is defined, its value will be used instead of the effect-specific default. + + See :meth:`effect_list` for available effects, or use :meth:`set_custom_effect` for custom effects. + :param str effect: The effect to set :param int brightness: The wanted brightness + :param int transition: The wanted transition time """ if effect not in EFFECT_MAPPING_V1: raise SmartDeviceException(f"The effect {effect} is not a built in effect.") effect_dict = EFFECT_MAPPING_V1[effect] if brightness is not None: effect_dict["brightness"] = brightness + if transition is not None: + effect_dict["transition"] = transition + await self.set_custom_effect(effect_dict) @requires_update diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index 962bb150c..109b9d7c3 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -37,7 +37,8 @@ async def test_effects_lightstrip_set_effect_brightness( ): query_helper = mocker.patch("kasa.SmartLightStrip._query_helper") - if brightness == 100: # test that default brightness works + # test that default brightness works (100 for candy cane) + if brightness == 100: await dev.set_effect("Candy Cane") else: await dev.set_effect("Candy Cane", brightness=brightness) @@ -47,6 +48,24 @@ async def test_effects_lightstrip_set_effect_brightness( assert payload["brightness"] == brightness +@lightstrip +@pytest.mark.parametrize("transition", [500, 1000]) +async def test_effects_lightstrip_set_effect_transition( + dev: SmartLightStrip, transition, mocker +): + query_helper = mocker.patch("kasa.SmartLightStrip._query_helper") + + # test that default (500 for candy cane) transition works + if transition == 500: + await dev.set_effect("Candy Cane") + else: + await dev.set_effect("Candy Cane", transition=transition) + + args, kwargs = query_helper.call_args_list[0] + payload = args[2] + assert payload["transition"] == transition + + @lightstrip async def test_effects_lightstrip_has_effects(dev: SmartLightStrip): assert dev.has_effects is True From 43ed47eca8b1b05f8f6f2ec8b2d19c12d1f6dd27 Mon Sep 17 00:00:00 2001 From: Julian Davis Date: Sat, 18 Feb 2023 19:53:02 +0000 Subject: [PATCH 130/892] Return usage.get_{monthstat,daystat} in expected format (#394) * Basic fix for issue: https://github.com/python-kasa/python-kasa/issues/373 Change usage module get_daystat and get_monthat to return dictionaries of date index: time values as spec'd instead of raw usage data. Output matches emeter module get_daystat and get_monthstat * Fixed some formatting and lint warnings to comply with black/flake8 Use the new _convert function in emeter for all conversions rather than the one in smartdevice.py Removed unused function _emeter_convert_emeter_data from smartdevice.py * Added a first pass test module for testing the new usage conversion function * Changes based on PR feedback Tidied up some doc string comments Added a check for explicit values from conversion function * Rebase on top of current master, fix docstrings --------- Co-authored-by: Teemu Rytilahti --- kasa/modules/emeter.py | 57 +++++++++++++++++++++++++--------------- kasa/modules/usage.py | 41 ++++++++++++++++++++++++++--- kasa/smartdevice.py | 19 -------------- kasa/tests/test_usage.py | 22 ++++++++++++++++ 4 files changed, 95 insertions(+), 44 deletions(-) create mode 100644 kasa/tests/test_usage.py diff --git a/kasa/modules/emeter.py b/kasa/modules/emeter.py index cd92c3cce..831210c37 100644 --- a/kasa/modules/emeter.py +++ b/kasa/modules/emeter.py @@ -19,7 +19,7 @@ def emeter_today(self) -> Optional[float]: """Return today's energy consumption in kWh.""" raw_data = self.daily_data today = datetime.now().day - data = self._emeter_convert_emeter_data(raw_data) + data = self._convert_stat_data(raw_data, entry_key="day") return data.get(today) @@ -28,7 +28,7 @@ def emeter_this_month(self) -> Optional[float]: """Return this month's energy consumption in kWh.""" raw_data = self.monthly_data current_month = datetime.now().month - data = self._emeter_convert_emeter_data(raw_data) + data = self._convert_stat_data(raw_data, entry_key="month") return data.get(current_month) @@ -43,31 +43,46 @@ async def get_realtime(self): """Return real-time statistics.""" return await self.call("get_realtime") - async def get_daystat(self, *, year, month, kwh=True): - """Return daily stats for the given year & month.""" - raw_data = await super().get_daystat(year=year, month=month) - return self._emeter_convert_emeter_data(raw_data["day_list"], kwh) + async def get_daystat(self, *, year=None, month=None, kwh=True) -> Dict: + """Return daily stats for the given year & month as a dictionary of {day: energy, ...}.""" + data = await self.get_raw_daystat(year=year, month=month) + data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh) + return data + + async def get_monthstat(self, *, year=None, kwh=True) -> Dict: + """Return monthly stats for the given year as a dictionary of {month: energy, ...}.""" + data = await self.get_raw_monthstat(year=year) + data = self._convert_stat_data(data["month_list"], entry_key="month", kwh=kwh) + return data - async def get_monthstat(self, *, year, kwh=True): - """Return monthly stats for the given year.""" - raw_data = await super().get_monthstat(year=year) - return self._emeter_convert_emeter_data(raw_data["month_list"], kwh) + def _convert_stat_data(self, data, entry_key, kwh=True) -> Dict: + """Return emeter information keyed with the day/month. - def _emeter_convert_emeter_data(self, data, kwh=True) -> Dict: - """Return emeter information keyed with the day/month..""" - response = [EmeterStatus(**x) for x in data] + The incoming data is a list of dictionaries:: - if not response: + [{'year': int, + 'month': int, + 'day': int, <-- for get_daystat not get_monthstat + 'energy_wh': int, <-- for emeter in some versions (wh) + 'energy': float <-- for emeter in other versions (kwh) + }, ...] + + :return: a dictionary keyed by day or month with energy as the value. + """ + if not data: return {} - energy_key = "energy_wh" - if kwh: - energy_key = "energy" + scale: float = 1 - entry_key = "month" - if "day" in response[0]: - entry_key = "day" + if "energy_wh" in data[0]: + value_key = "energy_wh" + if kwh: + scale = 1 / 1000 + else: + value_key = "energy" + if not kwh: + scale = 1000 - data = {entry[entry_key]: entry[energy_key] for entry in response} + data = {entry[entry_key]: entry[value_key] * scale for entry in data} return data diff --git a/kasa/modules/usage.py b/kasa/modules/usage.py index 5aecb9a75..d1f96e7e4 100644 --- a/kasa/modules/usage.py +++ b/kasa/modules/usage.py @@ -1,5 +1,6 @@ """Implementation of the usage interface.""" from datetime import datetime +from typing import Dict from .module import Module, merge @@ -50,8 +51,8 @@ def usage_this_month(self): return converted.pop() - async def get_daystat(self, *, year=None, month=None): - """Return daily stats for the given year & month.""" + async def get_raw_daystat(self, *, year=None, month=None) -> Dict: + """Return raw daily stats for the given year & month.""" if year is None: year = datetime.now().year if month is None: @@ -59,13 +60,45 @@ async def get_daystat(self, *, year=None, month=None): return await self.call("get_daystat", {"year": year, "month": month}) - async def get_monthstat(self, *, year=None): - """Return monthly stats for the given year.""" + async def get_raw_monthstat(self, *, year=None) -> Dict: + """Return raw monthly stats for the given year.""" if year is None: year = datetime.now().year return await self.call("get_monthstat", {"year": year}) + async def get_daystat(self, *, year=None, month=None) -> Dict: + """Return daily stats for the given year & month as a dictionary of {day: time, ...}.""" + data = await self.get_raw_daystat(year=year, month=month) + data = self._convert_stat_data(data["day_list"], entry_key="day") + return data + + async def get_monthstat(self, *, year=None) -> Dict: + """Return monthly stats for the given year as a dictionary of {month: time, ...}.""" + data = await self.get_raw_monthstat(year=year) + data = self._convert_stat_data(data["month_list"], entry_key="month") + return data + async def erase_stats(self): """Erase all stats.""" return await self.call("erase_runtime_stat") + + def _convert_stat_data(self, data, entry_key) -> Dict: + """Return usage information keyed with the day/month. + + The incoming data is a list of dictionaries:: + + [{'year': int, + 'month': int, + 'day': int, <-- for get_daystat not get_monthstat + 'time': int, <-- for usage (mins) + }, ...] + + :return: return a dictionary keyed by day or month with time as the value. + """ + if not data: + return {} + + data = {entry[entry_key]: entry["time"] for entry in data} + + return data diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 253853f8f..e817958f7 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -490,25 +490,6 @@ def emeter_this_month(self) -> Optional[float]: self._verify_emeter() return self.modules["emeter"].emeter_this_month - def _emeter_convert_emeter_data(self, data, kwh=True) -> Dict: - """Return emeter information keyed with the day/month..""" - response = [EmeterStatus(**x) for x in data] - - if not response: - return {} - - energy_key = "energy_wh" - if kwh: - energy_key = "energy" - - entry_key = "month" - if "day" in response[0]: - entry_key = "day" - - data = {entry[entry_key]: entry[energy_key] for entry in response} - - return data - async def get_emeter_daily( self, year: Optional[int] = None, month: Optional[int] = None, kwh: bool = True ) -> Dict: diff --git a/kasa/tests/test_usage.py b/kasa/tests/test_usage.py new file mode 100644 index 000000000..95002f894 --- /dev/null +++ b/kasa/tests/test_usage.py @@ -0,0 +1,22 @@ +import pytest + +from kasa.modules import Usage + + +def test_usage_convert_stat_data(): + usage = Usage(None, module="usage") + + test_data = [] + assert usage._convert_stat_data(test_data, "day") == {} + + test_data = [ + {"year": 2016, "month": 5, "day": 2, "time": 20}, + {"year": 2016, "month": 5, "day": 4, "time": 30}, + ] + d = usage._convert_stat_data(test_data, "day") + assert len(d) == len(test_data) + assert isinstance(d, dict) + k, v = d.popitem() + assert isinstance(k, int) + assert isinstance(v, int) + assert k == 4 and v == 30 From 1212715ddee65033b87648a42ce310b1e9d115dc Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 18 Feb 2023 21:17:19 +0100 Subject: [PATCH 131/892] Minor fixes to smartbulb docs (#431) --- docs/source/smartbulb.rst | 6 +++++- kasa/smartbulb.py | 19 +++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/source/smartbulb.rst b/docs/source/smartbulb.rst index 980aff134..5f8fd7eef 100644 --- a/docs/source/smartbulb.rst +++ b/docs/source/smartbulb.rst @@ -67,9 +67,13 @@ API documentation :members: :undoc-members: +.. autoclass:: kasa.BehaviorMode + :members: + .. autoclass:: kasa.TurnOnBehaviors - :undoc-members: + :members: .. autoclass:: kasa.TurnOnBehavior :undoc-members: + :members: diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index 7d7db8686..ff94e1697 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -38,7 +38,9 @@ class SmartBulbPreset(BaseModel): class BehaviorMode(str, Enum): """Enum to present type of turn on behavior.""" + #: Return to the last state known state. Last = "last_status" + #: Use chosen preset. Preset = "customize_preset" @@ -48,15 +50,17 @@ class TurnOnBehavior(BaseModel): :param int preset: the index number of wanted preset. :param BehaviorMode mode: last status or preset mode. If you are changing existing settings, you should not set this manually. - To change the behavior, it is only necessary to change the :ref:`preset` field + To change the behavior, it is only necessary to change the :attr:`preset` field to contain either the preset index, or ``None`` for the last known state. """ + #: Index of preset to use, or ``None`` for the last known state. preset: Optional[int] = Field(alias="index", default=None) + #: Wanted behavior mode: BehaviorMode @root_validator - def mode_based_on_preset(cls, values): + def _mode_based_on_preset(cls, values): """Set the mode based on the preset value.""" if values["preset"] is not None: values["mode"] = BehaviorMode.Preset @@ -72,13 +76,11 @@ class Config: class TurnOnBehaviors(BaseModel): - """Model to contain turn on behaviors. - - :param TurnOnBehavior soft: the default setting to turn the bulb programmatically on - :param TurnOnBehavior hard: default setting when the bulb has been off from mains power. - """ + """Model to contain turn on behaviors.""" + #: The behavior when the bulb is turned on programmatically. soft: TurnOnBehavior = Field(alias="soft_on") + #: The behavior when the bulb has been off from mains power. hard: TurnOnBehavior = Field(alias="hard_on") @@ -268,7 +270,8 @@ def has_effects(self) -> bool: async def get_light_details(self) -> Dict[str, int]: """Return light details. - Example: + Example:: + {'lamp_beam_angle': 290, 'min_voltage': 220, 'max_voltage': 240, 'wattage': 5, 'incandescent_equivalent': 40, 'max_lumens': 450, 'color_rendering_index': 80} From 016f4dfd195b6c4592fd67a7aca1df03fd48e6ba Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 18 Feb 2023 21:41:08 +0100 Subject: [PATCH 132/892] Add support for json output (#430) * Add json support * Add tests * Check if test_json_output works on ci using py3.8+ * Add a proper note why py3.7 test for json_output are disabled --- kasa/cli.py | 84 ++++++++++++++++++++++++++++++++++-------- kasa/tests/test_cli.py | 44 ++++++---------------- 2 files changed, 80 insertions(+), 48 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 100821d99..355994769 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -4,7 +4,7 @@ import logging import re import sys -from functools import wraps +from functools import singledispatch, wraps from pprint import pformat as pf from typing import Any, Dict, cast @@ -63,10 +63,36 @@ def __call__(self, *args, **kwargs): try: asyncio.get_event_loop().run_until_complete(self.main(*args, **kwargs)) except Exception as ex: - click.echo(f"Got error: {ex!r}") + echo(f"Got error: {ex!r}") -@click.group(invoke_without_command=True, cls=ExceptionHandlerGroup) +def json_formatter_cb(result, **kwargs): + """Format and output the result as JSON, if requested.""" + if not kwargs.get("json"): + return + + @singledispatch + def to_serializable(val): + """Regular obj-to-string for json serialization. + + The singledispatch trick is from hynek: https://hynek.me/articles/serialization/ + """ + return str(val) + + @to_serializable.register(SmartDevice) + def _device_to_serializable(val: SmartDevice): + """Serialize smart device data, just using the last update raw payload.""" + return val.internal_state + + json_content = json.dumps(result, indent=4, default=to_serializable) + print(json_content) + + +@click.group( + invoke_without_command=True, + cls=ExceptionHandlerGroup, + result_callback=json_formatter_cb, +) @click.option( "--host", envvar="KASA_HOST", @@ -94,9 +120,12 @@ def __call__(self, *args, **kwargs): default=None, type=click.Choice(list(TYPE_TO_CLASS), case_sensitive=False), ) +@click.option( + "--json", default=False, is_flag=True, help="Output raw device response as JSON." +) @click.version_option(package_name="python-kasa") @click.pass_context -async def cli(ctx, host, alias, target, debug, type): +async def cli(ctx, host, alias, target, debug, type, json): """A tool for controlling TP-Link smart home devices.""" # noqa # no need to perform any checks if we are just displaying the help if sys.argv[-1] == "--help": @@ -104,6 +133,15 @@ async def cli(ctx, host, alias, target, debug, type): ctx.obj = SmartDevice(None) return + # If JSON output is requested, disable echo + if json: + global echo + + def _nop_echo(*args, **kwargs): + pass + + echo = _nop_echo + logging_config: Dict[str, Any] = { "level": logging.DEBUG if debug > 0 else logging.INFO } @@ -202,7 +240,7 @@ async def print_discovered(dev: SmartDevice): await ctx.invoke(state) echo() - await Discover.discover( + return await Discover.discover( target=target, timeout=timeout, on_discovered=print_discovered ) @@ -269,6 +307,8 @@ async def state(dev: SmartDevice): else: echo(f"\t[red]- {module}[/red]") + return dev.internal_state + @cli.command() @pass_dev @@ -293,6 +333,8 @@ async def alias(dev, new_alias, index): for plug in dev.children: echo(f" * {plug.alias}") + return dev.alias + @cli.command() @pass_dev @@ -328,8 +370,7 @@ async def emeter(dev: SmartDevice, year, month, erase): if erase: echo("Erasing emeter statistics..") - echo(await dev.erase_emeter_stats()) - return + return await dev.erase_emeter_stats() if year: echo(f"== For year {year.year} ==") @@ -351,12 +392,14 @@ async def emeter(dev: SmartDevice, year, month, erase): echo("Today: %s kWh" % dev.emeter_today) echo("This month: %s kWh" % dev.emeter_this_month) - return + return emeter_status # output any detailed usage data for index, usage in usage_data.items(): echo(f"{index}, {usage}") + return usage_data + @cli.command() @pass_dev @@ -373,8 +416,7 @@ async def usage(dev: SmartDevice, year, month, erase): if erase: echo("Erasing usage statistics..") - echo(await usage.erase_stats()) - return + return await usage.erase_stats() if year: echo(f"== For year {year.year} ==") @@ -389,12 +431,14 @@ async def usage(dev: SmartDevice, year, month, erase): echo("Today: %s minutes" % usage.usage_today) echo("This month: %s minutes" % usage.usage_this_month) - return + return usage # output any detailed usage data for index, usage in usage_data.items(): echo(f"{index}, {usage}") + return usage_data + @cli.command() @click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) @@ -408,6 +452,7 @@ async def brightness(dev: SmartBulb, brightness: int, transition: int): if brightness is None: echo(f"Brightness: {dev.brightness}") + return dev.brightness else: echo(f"Setting brightness to {brightness}") return await dev.set_brightness(brightness, transition=transition) @@ -435,6 +480,7 @@ async def temperature(dev: SmartBulb, temperature: int, transition: int): "Temperature range unknown, please open a github issue" f" or a pull request for model '{dev.model}'" ) + return dev.valid_temperature_range else: echo(f"Setting color temperature to {temperature}") return await dev.set_color_temp(temperature, transition=transition) @@ -476,6 +522,7 @@ async def hsv(dev, ctx, h, s, v, transition): if h is None or s is None or v is None: echo(f"Current HSV: {dev.hsv}") + return dev.hsv elif s is None or v is None: raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx) else: @@ -493,6 +540,7 @@ async def led(dev, state): return await dev.set_led(state) else: echo(f"LED state: {dev.led}") + return dev.led @cli.command() @@ -574,6 +622,8 @@ def _schedule_list(dev, type): else: echo(f"No rules of type {type}") + return sched.rules + @schedule.command(name="delete") @pass_dev @@ -584,7 +634,7 @@ async def delete_rule(dev, id): rule_to_delete = next(filter(lambda rule: (rule.id == id), schedule.rules), None) if rule_to_delete: echo(f"Deleting rule id {id}") - await schedule.delete_rule(rule_to_delete) + return await schedule.delete_rule(rule_to_delete) else: echo(f"No rule with id {id} was found") @@ -606,7 +656,9 @@ def presets_list(dev: SmartBulb): return for preset in dev.presets: - print(preset) + echo(preset) + + return dev.presets @presets.command(name="modify") @@ -638,7 +690,7 @@ async def presets_modify( echo(f"Going to save preset: {preset}") - await dev.save_preset(preset) + return await dev.save_preset(preset) @cli.command() @@ -653,7 +705,7 @@ async def turn_on_behavior(dev: SmartBulb, type, last, preset): # Return if we are not setting the value if not type and not last and not preset: - return + return settings # If we are setting the value, the type has to be specified if (last or preset) and type is None: @@ -669,7 +721,7 @@ async def turn_on_behavior(dev: SmartBulb, type, last, preset): echo(f"Going to set {type} to preset {preset}") behavior.preset = preset - await dev.set_turn_on_behavior(settings) + return await dev.set_turn_on_behavior(settings) if __name__ == "__main__": diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 420583780..319429d34 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -1,19 +1,11 @@ +import json import sys import pytest from asyncclick.testing import CliRunner from kasa import SmartDevice -from kasa.cli import ( - TYPE_TO_CLASS, - alias, - brightness, - cli, - emeter, - raw_command, - state, - sysinfo, -) +from kasa.cli import alias, brightness, cli, emeter, raw_command, state, sysinfo from .conftest import handle_turn_on, turn_on @@ -111,25 +103,13 @@ async def test_brightness(dev): assert "Brightness: 12" in res.output -async def test_temperature(dev): - pass - - -async def test_hsv(dev): - pass - - -async def test_led(dev): - pass - - -async def test_on(dev): - pass - - -async def test_off(dev): - pass - - -async def test_reboot(dev): - pass +# Invoke fails when run on py3.7 with the following error: +# E + where 1 = .exit_code +@pytest.mark.skipif(sys.version_info < (3, 8), reason="fails on python3.7") +async def test_json_output(dev: SmartDevice, mocker): + """Test that the json output produces correct output.""" + mocker.patch("kasa.Discover.discover", return_value=[dev]) + runner = CliRunner() + res = await runner.invoke(cli, ["--json", "state"], obj=dev) + assert res.exit_code == 0 + assert json.loads(res.output) == dev.internal_state From 02c857d472f984b16c9403b53bf1fb2991484add Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 18 Feb 2023 22:09:35 +0100 Subject: [PATCH 133/892] Some release preparation janitoring (#432) * Use myst-parser for readme.md doc injection * Relax version pins * Define bug tracker and doc links for pypi * Update pre-commit hooks --- .pre-commit-config.yaml | 12 +- devtools/parse_pcap.py | 4 +- docs/source/conf.py | 10 +- docs/source/index.rst | 3 +- kasa/tests/newfakes.py | 13 +- kasa/tests/test_strip.py | 1 - poetry.lock | 985 ++++++++++++++++++++++++--------------- pyproject.toml | 25 +- 8 files changed, 630 insertions(+), 423 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6887d059b..d09e3d543 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -10,18 +10,18 @@ repos: - id: check-ast - repo: https://github.com/asottile/pyupgrade - rev: v3.2.2 + rev: v3.3.1 hooks: - id: pyupgrade args: ['--py37-plus'] - repo: https://github.com/python/black - rev: 22.10.0 + rev: 23.1.0 hooks: - id: black - repo: https://github.com/pycqa/flake8 - rev: 5.0.4 + rev: 6.0.0 hooks: - id: flake8 additional_dependencies: [flake8-docstrings] @@ -33,13 +33,13 @@ repos: additional_dependencies: [toml] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.991 + rev: v1.0.1 hooks: - id: mypy additional_dependencies: [types-click] - repo: https://github.com/PyCQA/doc8 - rev: 'v1.0.0' + rev: 'v1.1.1' hooks: - id: doc8 additional_dependencies: [tomli] diff --git a/devtools/parse_pcap.py b/devtools/parse_pcap.py index 3044aee37..b3ba2fae3 100644 --- a/devtools/parse_pcap.py +++ b/devtools/parse_pcap.py @@ -44,7 +44,9 @@ def read_payloads_from_file(file): try: json_payload = json.loads(decrypted) - except Exception as ex: # this can happen when the response is split into multiple tcp segments + except ( + Exception + ) as ex: # this can happen when the response is split into multiple tcp segments echo(f"[red]Unable to parse payload '{decrypted}', ignoring: {ex}[/red]") continue diff --git a/docs/source/conf.py b/docs/source/conf.py index 6090e9246..cc7a725e4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -33,6 +33,7 @@ "sphinx.ext.viewcode", "sphinx.ext.todo", "sphinxcontrib.programoutput", + "myst_parser", ] # Add any paths that contain templates here, relative to this directory. @@ -62,12 +63,3 @@ def setup(app): # add copybutton to hide the >>> prompts, see https://github.com/readthedocs/sphinx_rtd_theme/issues/167 app.add_js_file("copybutton.js") - - # see https://github.com/readthedocs/recommonmark/issues/191#issuecomment-622369992 - from m2r import MdInclude - - app.add_config_value("no_underscore_emphasis", False, "env") - app.add_config_value("m2r_parse_relative_links", False, "env") - app.add_config_value("m2r_anonymous_references", False, "env") - app.add_config_value("m2r_disable_inline_math", False, "env") - app.add_directive("mdinclude", MdInclude) diff --git a/docs/source/index.rst b/docs/source/index.rst index 711d1474a..346c53d08 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,4 +1,5 @@ -.. mdinclude:: ../../README.md +.. include:: ../../README.md + :parser: myst_parser.sphinx_ .. toctree:: :maxdepth: 2 diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 18f52e173..3c2b4e4f7 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -1,16 +1,8 @@ import logging import re -from voluptuous import ( # type: ignore - REMOVE_EXTRA, - All, - Any, - Coerce, - Invalid, - Optional, - Range, - Schema, -) +from voluptuous import Coerce # type: ignore +from voluptuous import REMOVE_EXTRA, All, Any, Invalid, Optional, Range, Schema from ..protocol import TPLinkSmartHomeProtocol @@ -467,7 +459,6 @@ async def query(self, request, port=9999): child_ids = [] def get_response_for_module(target): - if target not in proto.keys(): return error(msg="target not found") diff --git a/kasa/tests/test_strip.py b/kasa/tests/test_strip.py index 03199904b..eb0c848cc 100644 --- a/kasa/tests/test_strip.py +++ b/kasa/tests/test_strip.py @@ -108,7 +108,6 @@ async def test_all_binary_states(dev): # toggle each outlet with state map applied for plug_index in range(len(dev.children)): - # toggle state if state_map[plug_index]: await dev.turn_off(index=plug_index) diff --git a/poetry.lock b/poetry.lock index ce335c03c..9bff1e2b8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,14 +1,14 @@ [[package]] name = "alabaster" -version = "0.7.12" +version = "0.7.13" description = "A configurable sidebar-enabled Sphinx theme" category = "main" optional = true -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "anyio" -version = "3.5.0" +version = "3.6.2" description = "High level compatibility layer for multiple asynchronous event loop implementations" category = "main" optional = false @@ -21,54 +21,56 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""} [package.extras] doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] -trio = ["trio (>=0.16)"] +test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] +trio = ["trio (>=0.16,<0.22)"] [[package]] name = "asyncclick" -version = "8.0.3.2" +version = "8.1.3.4" description = "Composable command line interface toolkit, async version" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} -[[package]] -name = "atomicwrites" -version = "1.4.0" -description = "Atomic file writes." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - [[package]] name = "attrs" -version = "21.4.0" +version = "22.2.0" description = "Classes Without Boilerplate" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" [package.extras] -dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "sphinx", "sphinx-notfound-page", "zope.interface"] -docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] -tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "zope.interface"] -tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six"] +cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] +dev = ["attrs[docs,tests]"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] +tests = ["attrs[tests-no-zope]", "zope.interface"] +tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=0.971,<0.990)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests_no_zope = ["cloudpickle", "hypothesis", "mypy (>=0.971,<0.990)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] [[package]] -name = "babel" -version = "2.9.1" +name = "Babel" +version = "2.11.0" description = "Internationalization utilities" category = "main" optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.dependencies] pytz = ">=2015.7" +[[package]] +name = "cachetools" +version = "5.3.0" +description = "Extensible memoizing collections and decorators" +category = "dev" +optional = false +python-versions = "~=3.7" + [[package]] name = "certifi" version = "2022.12.7" @@ -85,16 +87,21 @@ category = "dev" optional = false python-versions = ">=3.6.1" +[[package]] +name = "chardet" +version = "5.1.0" +description = "Universal encoding detector for Python 3" +category = "dev" +optional = false +python-versions = ">=3.7" + [[package]] name = "charset-normalizer" -version = "2.0.12" +version = "3.0.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false -python-versions = ">=3.5.0" - -[package.extras] -unicode-backport = ["unicodedata2"] +python-versions = "*" [[package]] name = "codecov" @@ -110,29 +117,29 @@ requests = ">=2.7.9" [[package]] name = "colorama" -version = "0.4.4" +version = "0.4.6" description = "Cross-platform colored terminal text." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" [[package]] name = "coverage" -version = "6.3.2" +version = "7.1.0" description = "Code coverage measurement for Python" category = "dev" optional = false python-versions = ">=3.7" [package.dependencies] -tomli = {version = "*", optional = true, markers = "extra == \"toml\""} +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] toml = ["tomli"] [[package]] name = "distlib" -version = "0.3.4" +version = "0.3.6" description = "Distribution utilities" category = "dev" optional = false @@ -140,27 +147,38 @@ python-versions = "*" [[package]] name = "docutils" -version = "0.16" +version = "0.17.1" description = "Docutils -- Python Documentation Utilities" category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "exceptiongroup" +version = "1.1.0" +description = "Backport of PEP 654 (exception groups)" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "filelock" -version = "3.6.0" +version = "3.9.0" description = "A platform independent file lock." category = "dev" optional = false python-versions = ">=3.7" [package.extras] -docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] -testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] +docs = ["furo (>=2022.12.7)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] +testing = ["covdefaults (>=2.2.2)", "coverage (>=7.0.1)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-timeout (>=2.1)"] [[package]] name = "identify" -version = "2.4.12" +version = "2.5.18" description = "File identification library for Python" category = "dev" optional = false @@ -171,7 +189,7 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.3" +version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false @@ -179,7 +197,7 @@ python-versions = ">=3.5" [[package]] name = "imagesize" -version = "1.3.0" +version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" category = "main" optional = true @@ -187,7 +205,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.11.3" +version = "6.0.0" description = "Read metadata from Python packages" category = "main" optional = false @@ -198,21 +216,21 @@ typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["jaraco.packaging (>=9)", "rst.linker (>=1.9)", "sphinx"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] [[package]] name = "iniconfig" -version = "1.1.1" -description = "iniconfig: brain-dead simple config-ini parsing" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.7" [[package]] -name = "jinja2" -version = "3.1.1" +name = "Jinja2" +version = "3.1.2" description = "A very fast and expressive template engine." category = "main" optional = true @@ -225,63 +243,115 @@ MarkupSafe = ">=2.0" i18n = ["Babel (>=2.7)"] [[package]] -name = "m2r" -version = "0.2.1" -description = "Markdown and reStructuredText in a single file." +name = "markdown-it-py" +version = "2.1.0" +description = "Python port of markdown-it. Markdown parsing, done right!" category = "main" optional = true -python-versions = "*" +python-versions = ">=3.7" [package.dependencies] -docutils = "*" -mistune = "*" +mdurl = ">=0.1,<1.0" +typing_extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark (>=3.2,<4.0)"] +code_style = ["pre-commit (==2.6)"] +compare = ["commonmark (>=0.9.1,<0.10.0)", "markdown (>=3.3.6,<3.4.0)", "mistletoe (>=0.8.1,<0.9.0)", "mistune (>=2.0.2,<2.1.0)", "panflute (>=2.1.3,<2.2.0)"] +linkify = ["linkify-it-py (>=1.0,<2.0)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] -name = "markupsafe" -version = "2.1.1" +name = "MarkupSafe" +version = "2.1.2" description = "Safely add untrusted strings to HTML/XML markup." category = "main" optional = true python-versions = ">=3.7" [[package]] -name = "mistune" -version = "0.8.4" -description = "The fastest markdown parser in pure Python" +name = "mdit-py-plugins" +version = "0.3.4" +description = "Collection of plugins for markdown-it-py" category = "main" optional = true -python-versions = "*" +python-versions = ">=3.7" + +[package.dependencies] +markdown-it-py = ">=1.0.0,<3.0.0" + +[package.extras] +code_style = ["pre-commit"] +rtd = ["attrs", "myst-parser (>=0.16.1,<0.17.0)", "sphinx-book-theme (>=0.1.0,<0.2.0)"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +category = "main" +optional = true +python-versions = ">=3.7" + +[[package]] +name = "myst-parser" +version = "0.18.1" +description = "An extended commonmark compliant parser, with bridges to docutils & sphinx." +category = "main" +optional = true +python-versions = ">=3.7" + +[package.dependencies] +docutils = ">=0.15,<0.20" +jinja2 = "*" +markdown-it-py = ">=1.0.0,<3.0.0" +mdit-py-plugins = ">=0.3.1,<0.4.0" +pyyaml = "*" +sphinx = ">=4,<6" +typing-extensions = "*" + +[package.extras] +code_style = ["pre-commit (>=2.12,<3.0)"] +linkify = ["linkify-it-py (>=1.0,<2.0)"] +rtd = ["ipython", "sphinx-book-theme", "sphinx-design", "sphinxcontrib.mermaid (>=0.7.1,<0.8.0)", "sphinxext-opengraph (>=0.6.3,<0.7.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] +testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=6,<7)", "pytest-cov", "pytest-param-files (>=0.3.4,<0.4.0)", "pytest-regressions", "sphinx (<5.2)", "sphinx-pytest"] [[package]] name = "nodeenv" -version = "1.6.0" +version = "1.7.0" description = "Node.js virtual environment builder" category = "dev" optional = false -python-versions = "*" +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" + +[package.dependencies] +setuptools = "*" [[package]] name = "packaging" -version = "21.3" +version = "23.0" description = "Core utilities for Python packages" category = "main" optional = false -python-versions = ">=3.6" - -[package.dependencies] -pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" +python-versions = ">=3.7" [[package]] name = "platformdirs" -version = "2.5.1" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +version = "3.0.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false python-versions = ">=3.7" +[package.dependencies] +typing-extensions = {version = ">=4.4", markers = "python_version < \"3.8\""} + [package.extras] -docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] -test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] +docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] [[package]] name = "pluggy" @@ -300,7 +370,7 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "2.18.1" +version = "2.21.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -312,76 +382,74 @@ identify = ">=1.0.0" importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} nodeenv = ">=0.11.1" pyyaml = ">=5.1" -toml = "*" -virtualenv = ">=20.0.8" - -[[package]] -name = "py" -version = "1.11.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +virtualenv = ">=20.10.0" [[package]] name = "pydantic" -version = "1.9.0" -description = "Data validation and settings management using python 3.6 type hinting" +version = "1.10.5" +description = "Data validation and settings management using python type hints" category = "main" optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.7" [package.dependencies] -typing-extensions = ">=3.7.4.3" +typing-extensions = ">=4.2.0" [package.extras] dotenv = ["python-dotenv (>=0.10.4)"] email = ["email-validator (>=1.0.3)"] [[package]] -name = "pygments" -version = "2.11.2" +name = "Pygments" +version = "2.14.0" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = true -python-versions = ">=3.5" +python-versions = ">=3.6" + +[package.extras] +plugins = ["importlib-metadata"] [[package]] -name = "pyparsing" -version = "3.0.7" -description = "Python parsing module" -category = "main" +name = "pyproject-api" +version = "1.5.0" +description = "API to interact with the python pyproject.toml based projects" +category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" + +[package.dependencies] +packaging = ">=21.3" +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} [package.extras] -diagrams = ["jinja2", "railroad-diagrams"] +docs = ["furo (>=2022.9.29)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] +testing = ["covdefaults (>=2.2.2)", "importlib-metadata (>=5.1)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "virtualenv (>=20.17)", "wheel (>=0.38.4)"] [[package]] name = "pytest" -version = "7.1.1" +version = "7.2.1" description = "pytest: simple powerful testing with Python" category = "dev" optional = false python-versions = ">=3.7" [package.dependencies] -atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" -py = ">=1.8.2" -tomli = ">=1.0.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.18.3" +version = "0.20.3" description = "Pytest support for asyncio" category = "dev" optional = false @@ -392,27 +460,27 @@ pytest = ">=6.1.0" typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} [package.extras] -testing = ["coverage (==6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (==0.931)", "pytest-trio (>=0.7.0)"] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] [[package]] name = "pytest-cov" -version = "2.12.1" +version = "4.0.0" description = "Pytest plugin for measuring coverage." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" [package.dependencies] -coverage = ">=5.2.1" +coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" -toml = "*" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] [[package]] name = "pytest-mock" -version = "3.7.0" +version = "3.10.0" description = "Thin-wrapper around the mock package for easier use with pytest" category = "dev" optional = false @@ -426,7 +494,7 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] name = "pytest-sugar" -version = "0.9.4" +version = "0.9.6" description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." category = "dev" optional = false @@ -439,37 +507,50 @@ termcolor = ">=1.1.0" [[package]] name = "pytz" -version = "2022.1" +version = "2022.7.1" description = "World timezone definitions, modern and historical" category = "main" optional = true python-versions = "*" [[package]] -name = "pyyaml" +name = "PyYAML" version = "6.0" description = "YAML parser and emitter for Python" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" [[package]] name = "requests" -version = "2.27.1" +version = "2.28.2" description = "Python HTTP for Humans." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=3.7, <4" [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} -idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" urllib3 = ">=1.21.1,<1.27" [package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<5)"] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "setuptools" +version = "67.3.2" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "six" @@ -481,11 +562,11 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "sniffio" -version = "1.2.0" +version = "1.3.0" description = "Sniff out which async library your code is running under" category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=3.7" [[package]] name = "snowballstemmer" @@ -496,7 +577,7 @@ optional = true python-versions = "*" [[package]] -name = "sphinx" +name = "Sphinx" version = "4.5.0" description = "Python documentation generator" category = "main" @@ -529,14 +610,13 @@ test = ["cython", "html5lib", "pytest", "pytest-cov", "typed-ast"] [[package]] name = "sphinx-rtd-theme" -version = "0.5.2" +version = "0.5.1" description = "Read the Docs theme for Sphinx" category = "main" optional = true python-versions = "*" [package.dependencies] -docutils = "<0.17" sphinx = "*" [package.extras] @@ -626,11 +706,14 @@ test = ["pytest"] [[package]] name = "termcolor" -version = "1.1.0" -description = "ANSII Color formatting for output in terminal." +version = "2.2.0" +description = "ANSI color formatting for output in terminal" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.7" + +[package.extras] +tests = ["pytest", "pytest-cov"] [[package]] name = "toml" @@ -650,70 +733,72 @@ python-versions = ">=3.7" [[package]] name = "tox" -version = "3.24.5" +version = "4.4.5" description = "tox is a generic virtualenv management and test command line tool" category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +python-versions = ">=3.7" [package.dependencies] -colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""} -filelock = ">=3.0.0" -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} -packaging = ">=14" -pluggy = ">=0.12.0" -py = ">=1.4.17" -six = ">=1.14.0" -toml = ">=0.9.4" -virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" +cachetools = ">=5.3" +chardet = ">=5.1" +colorama = ">=0.4.6" +filelock = ">=3.9" +importlib-metadata = {version = ">=6", markers = "python_version < \"3.8\""} +packaging = ">=23" +platformdirs = ">=2.6.2" +pluggy = ">=1" +pyproject-api = ">=1.5" +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.4", markers = "python_version < \"3.8\""} +virtualenv = ">=20.17.1" [package.extras] -docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] -testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)"] +docs = ["furo (>=2022.12.7)", "sphinx (>=6.1.3)", "sphinx-argparse-cli (>=1.11)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)", "sphinx-copybutton (>=0.5.1)", "sphinx-inline-tabs (>=2022.1.2b11)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] +testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.2.2)", "devpi-process (>=0.3)", "diff-cover (>=7.4)", "distlib (>=0.3.6)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.12.2)", "psutil (>=5.9.4)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-xdist (>=3.1)", "re-assert (>=1.1)", "time-machine (>=2.9)", "wheel (>=0.38.4)"] [[package]] name = "typing-extensions" -version = "4.1.1" -description = "Backported and Experimental Type Hints for Python 3.6+" +version = "4.5.0" +description = "Backported and Experimental Type Hints for Python 3.7+" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "urllib3" -version = "1.26.9" +version = "1.26.14" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.14.0" +version = "20.19.0" description = "Virtual Python Environment builder" category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +python-versions = ">=3.7" [package.dependencies] -distlib = ">=0.3.1,<1" -filelock = ">=3.2,<4" -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} -platformdirs = ">=2,<3" -six = ">=1.9.0,<2" +distlib = ">=0.3.6,<1" +filelock = ">=3.4.1,<4" +importlib-metadata = {version = ">=4.8.3", markers = "python_version < \"3.8\""} +platformdirs = ">=2.4,<4" [package.extras] -docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "packaging (>=20.0)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)"] +docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] +test = ["covdefaults (>=2.2.2)", "coverage (>=7.1)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23)", "pytest (>=7.2.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)"] [[package]] name = "voluptuous" -version = "0.13.0" +version = "0.13.1" description = "" category = "dev" optional = false @@ -721,65 +806,72 @@ python-versions = "*" [[package]] name = "xdoctest" -version = "0.15.10" +version = "1.1.1" description = "A rewrite of the builtin doctest module" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.6" [package.dependencies] six = "*" [package.extras] -all = ["IPython", "Pygments", "cmake", "codecov", "colorama", "ipykernel", "jupyter-client", "nbconvert", "nbformat", "ninja", "pybind11", "pytest", "pytest", "pytest", "pytest", "pytest", "pytest-cov", "pytest-cov", "pytest-cov", "scikit-build", "six", "typing"] -colors = ["Pygments", "colorama"] -jupyter = ["IPython", "ipykernel", "jupyter-client", "nbconvert", "nbformat"] -optional = ["IPython", "Pygments", "colorama", "ipykernel", "jupyter-client", "nbconvert", "nbformat"] -tests = ["IPython", "cmake", "codecov", "ipykernel", "jupyter-client", "nbconvert", "nbformat", "ninja", "pybind11", "pytest", "pytest", "pytest", "pytest", "pytest", "pytest-cov", "pytest-cov", "pytest-cov", "scikit-build", "typing"] +all = ["IPython", "IPython", "Pygments", "Pygments", "attrs", "codecov", "colorama", "debugpy", "debugpy", "debugpy", "debugpy", "debugpy", "ipykernel", "ipykernel", "ipython-genutils", "jedi", "jinja2", "jupyter-client", "jupyter-client", "jupyter-core", "nbconvert", "pyflakes", "pytest", "pytest", "pytest", "pytest-cov", "six", "tomli", "typing"] +all-strict = ["IPython (==7.10.0)", "IPython (==7.23.1)", "Pygments (==2.0.0)", "Pygments (==2.4.1)", "attrs (==19.2.0)", "codecov (==2.0.15)", "colorama (==0.4.1)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==5.2.0)", "ipykernel (==6.0.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==6.1.5)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "pyflakes (==2.2.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)", "six (==1.11.0)", "tomli (==0.2.0)", "typing (==3.7.4)"] +colors = ["Pygments", "Pygments", "colorama"] +jupyter = ["IPython", "IPython", "attrs", "debugpy", "debugpy", "debugpy", "debugpy", "debugpy", "ipykernel", "ipykernel", "ipython-genutils", "jedi", "jinja2", "jupyter-client", "jupyter-client", "jupyter-core", "nbconvert"] +optional = ["IPython", "IPython", "Pygments", "Pygments", "attrs", "colorama", "debugpy", "debugpy", "debugpy", "debugpy", "debugpy", "ipykernel", "ipykernel", "ipython-genutils", "jedi", "jinja2", "jupyter-client", "jupyter-client", "jupyter-core", "nbconvert", "pyflakes", "tomli"] +optional-strict = ["IPython (==7.10.0)", "IPython (==7.23.1)", "Pygments (==2.0.0)", "Pygments (==2.4.1)", "attrs (==19.2.0)", "colorama (==0.4.1)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==5.2.0)", "ipykernel (==6.0.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==6.1.5)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "pyflakes (==2.2.0)", "tomli (==0.2.0)"] +runtime-strict = ["six (==1.11.0)"] +tests = ["codecov", "pytest", "pytest", "pytest", "pytest-cov", "typing"] +tests-binary = ["cmake", "cmake", "ninja", "ninja", "pybind11", "pybind11", "scikit-build", "scikit-build"] +tests-binary-strict = ["cmake (==3.21.2)", "cmake (==3.25.0)", "ninja (==1.10.2)", "ninja (==1.11.1)", "pybind11 (==2.10.3)", "pybind11 (==2.7.1)", "scikit-build (==0.11.1)", "scikit-build (==0.16.1)"] +tests-strict = ["codecov (==2.0.15)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)", "typing (==3.7.4)"] [[package]] name = "zipp" -version = "3.8.0" +version = "3.14.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false python-versions = ">=3.7" [package.extras] -docs = ["jaraco.packaging (>=9)", "rst.linker (>=1.9)", "sphinx"] -testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [extras] -docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programoutput"] +docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "6577513a016c329bc825369761eae9971cb6a18a13c96ac0669c1f51ab3de87d" +content-hash = "2170092ba4286081c160d2fd0162b8bf54fb42ba9e5b63f6e2f0a6eb1c95c4d5" [metadata.files] alabaster = [ - {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, - {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, + {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, + {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, ] anyio = [ - {file = "anyio-3.5.0-py3-none-any.whl", hash = "sha256:b5fa16c5ff93fa1046f2eeb5bbff2dad4d3514d6cda61d02816dba34fa8c3c2e"}, - {file = "anyio-3.5.0.tar.gz", hash = "sha256:a0aeffe2fb1fdf374a8e4b471444f0f3ac4fb9f5a5b542b48824475e0042a5a6"}, + {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"}, + {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"}, ] asyncclick = [ - {file = "asyncclick-8.0.3.2.tar.gz", hash = "sha256:251030d8497c139a09d51f8c4b9b8c261a2be0b7d5722f1b7916cc6770368684"}, -] -atomicwrites = [ - {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, - {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, + {file = "asyncclick-8.1.3.4-py3-none-any.whl", hash = "sha256:f8db604e37dabd43922d58f857817b1dfd8f88695b75c4cc1afe7ff1cc238a7b"}, + {file = "asyncclick-8.1.3.4.tar.gz", hash = "sha256:81d98cbf6c8813f9cd5599f586d56cfc532e9e6441391974d10827abb90fe833"}, ] attrs = [ - {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, - {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, + {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, + {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, +] +Babel = [ + {file = "Babel-2.11.0-py3-none-any.whl", hash = "sha256:1ad3eca1c885218f6dce2ab67291178944f810a10a9b5f3cb8382a5a232b64fe"}, + {file = "Babel-2.11.0.tar.gz", hash = "sha256:5ef4b3226b0180dedded4229651c8b0e1a3a6a2837d45a073272f313e4cf97f6"}, ] -babel = [ - {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, - {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, +cachetools = [ + {file = "cachetools-5.3.0-py3-none-any.whl", hash = "sha256:429e1a1e845c008ea6c85aa35d4b98b65d6a9763eeef3e37e92728a12d1de9d4"}, + {file = "cachetools-5.3.0.tar.gz", hash = "sha256:13dfddc7b8df938c21a940dfa6557ce6e94a2f1cdfa58eb90c805721d58f2c14"}, ] certifi = [ {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, @@ -789,240 +881,360 @@ cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] +chardet = [ + {file = "chardet-5.1.0-py3-none-any.whl", hash = "sha256:362777fb014af596ad31334fde1e8c327dfdb076e1960d1694662d46a6917ab9"}, + {file = "chardet-5.1.0.tar.gz", hash = "sha256:0d62712b956bc154f85fb0a266e2a3c5913c2967e00348701b32411d6def31e5"}, +] charset-normalizer = [ - {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, - {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, + {file = "charset-normalizer-3.0.1.tar.gz", hash = "sha256:ebea339af930f8ca5d7a699b921106c6e29c617fe9606fa7baa043c1cdae326f"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88600c72ef7587fe1708fd242b385b6ed4b8904976d5da0893e31df8b3480cb6"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c75ffc45f25324e68ab238cb4b5c0a38cd1c3d7f1fb1f72b5541de469e2247db"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db72b07027db150f468fbada4d85b3b2729a3db39178abf5c543b784c1254539"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62595ab75873d50d57323a91dd03e6966eb79c41fa834b7a1661ed043b2d404d"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff6f3db31555657f3163b15a6b7c6938d08df7adbfc9dd13d9d19edad678f1e8"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:772b87914ff1152b92a197ef4ea40efe27a378606c39446ded52c8f80f79702e"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70990b9c51340e4044cfc394a81f614f3f90d41397104d226f21e66de668730d"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:292d5e8ba896bbfd6334b096e34bffb56161c81408d6d036a7dfa6929cff8783"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2edb64ee7bf1ed524a1da60cdcd2e1f6e2b4f66ef7c077680739f1641f62f555"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:31a9ddf4718d10ae04d9b18801bd776693487cbb57d74cc3458a7673f6f34639"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:44ba614de5361b3e5278e1241fda3dc1838deed864b50a10d7ce92983797fa76"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:12db3b2c533c23ab812c2b25934f60383361f8a376ae272665f8e48b88e8e1c6"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c512accbd6ff0270939b9ac214b84fb5ada5f0409c44298361b2f5e13f9aed9e"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-win32.whl", hash = "sha256:502218f52498a36d6bf5ea77081844017bf7982cdbe521ad85e64cabee1b608b"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:601f36512f9e28f029d9481bdaf8e89e5148ac5d89cffd3b05cd533eeb423b59"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0298eafff88c99982a4cf66ba2efa1128e4ddaca0b05eec4c456bbc7db691d8d"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a8d0fc946c784ff7f7c3742310cc8a57c5c6dc31631269876a88b809dbeff3d3"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:87701167f2a5c930b403e9756fab1d31d4d4da52856143b609e30a1ce7160f3c"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e76c0f23218b8f46c4d87018ca2e441535aed3632ca134b10239dfb6dadd6b"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0a590235ccd933d9892c627dec5bc7511ce6ad6c1011fdf5b11363022746c1"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c7fe7afa480e3e82eed58e0ca89f751cd14d767638e2550c77a92a9e749c317"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79909e27e8e4fcc9db4addea88aa63f6423ebb171db091fb4373e3312cb6d603"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7b6a045b814cf0c47f3623d21ebd88b3e8cf216a14790b455ea7ff0135d18"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:72966d1b297c741541ca8cf1223ff262a6febe52481af742036a0b296e35fa5a"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f9d0c5c045a3ca9bedfc35dca8526798eb91a07aa7a2c0fee134c6c6f321cbd7"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5995f0164fa7df59db4746112fec3f49c461dd6b31b841873443bdb077c13cfc"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4a8fcf28c05c1f6d7e177a9a46a1c52798bfe2ad80681d275b10dcf317deaf0b"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:761e8904c07ad053d285670f36dd94e1b6ab7f16ce62b9805c475b7aa1cffde6"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-win32.whl", hash = "sha256:71140351489970dfe5e60fc621ada3e0f41104a5eddaca47a7acb3c1b851d6d3"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ab77acb98eba3fd2a85cd160851816bfce6871d944d885febf012713f06659c"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:84c3990934bae40ea69a82034912ffe5a62c60bbf6ec5bc9691419641d7d5c9a"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74292fc76c905c0ef095fe11e188a32ebd03bc38f3f3e9bcb85e4e6db177b7ea"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c95a03c79bbe30eec3ec2b7f076074f4281526724c8685a42872974ef4d36b72"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c39b0e3eac288fedc2b43055cfc2ca7a60362d0e5e87a637beac5d801ef478"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df2c707231459e8a4028eabcd3cfc827befd635b3ef72eada84ab13b52e1574d"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93ad6d87ac18e2a90b0fe89df7c65263b9a99a0eb98f0a3d2e079f12a0735837"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:59e5686dd847347e55dffcc191a96622f016bc0ad89105e24c14e0d6305acbc6"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:cd6056167405314a4dc3c173943f11249fa0f1b204f8b51ed4bde1a9cd1834dc"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:083c8d17153ecb403e5e1eb76a7ef4babfc2c48d58899c98fcaa04833e7a2f9a"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:f5057856d21e7586765171eac8b9fc3f7d44ef39425f85dbcccb13b3ebea806c"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:7eb33a30d75562222b64f569c642ff3dc6689e09adda43a082208397f016c39a"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-win32.whl", hash = "sha256:95dea361dd73757c6f1c0a1480ac499952c16ac83f7f5f4f84f0658a01b8ef41"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:eaa379fcd227ca235d04152ca6704c7cb55564116f8bc52545ff357628e10602"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3e45867f1f2ab0711d60c6c71746ac53537f1684baa699f4f668d4c6f6ce8e14"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cadaeaba78750d58d3cc6ac4d1fd867da6fc73c88156b7a3212a3cd4819d679d"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:911d8a40b2bef5b8bbae2e36a0b103f142ac53557ab421dc16ac4aafee6f53dc"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:503e65837c71b875ecdd733877d852adbc465bd82c768a067badd953bf1bc5a3"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a60332922359f920193b1d4826953c507a877b523b2395ad7bc716ddd386d866"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16a8663d6e281208d78806dbe14ee9903715361cf81f6d4309944e4d1e59ac5b"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a16418ecf1329f71df119e8a65f3aa68004a3f9383821edcb20f0702934d8087"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d9153257a3f70d5f69edf2325357251ed20f772b12e593f3b3377b5f78e7ef8"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:02a51034802cbf38db3f89c66fb5d2ec57e6fe7ef2f4a44d070a593c3688667b"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:2e396d70bc4ef5325b72b593a72c8979999aa52fb8bcf03f701c1b03e1166918"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:11b53acf2411c3b09e6af37e4b9005cba376c872503c8f28218c7243582df45d"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-win32.whl", hash = "sha256:0bf2dae5291758b6f84cf923bfaa285632816007db0330002fa1de38bfcb7154"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:2c03cc56021a4bd59be889c2b9257dae13bf55041a3372d3295416f86b295fb5"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:024e606be3ed92216e2b6952ed859d86b4cfa52cd5bc5f050e7dc28f9b43ec42"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4b0d02d7102dd0f997580b51edc4cebcf2ab6397a7edf89f1c73b586c614272c"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:358a7c4cb8ba9b46c453b1dd8d9e431452d5249072e4f56cfda3149f6ab1405e"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81d6741ab457d14fdedc215516665050f3822d3e56508921cc7239f8c8e66a58"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b8af03d2e37866d023ad0ddea594edefc31e827fee64f8de5611a1dbc373174"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cf4e8ad252f7c38dd1f676b46514f92dc0ebeb0db5552f5f403509705e24753"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e696f0dd336161fca9adbb846875d40752e6eba585843c768935ba5c9960722b"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c22d3fe05ce11d3671297dc8973267daa0f938b93ec716e12e0f6dee81591dc1"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:109487860ef6a328f3eec66f2bf78b0b72400280d8f8ea05f69c51644ba6521a"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:37f8febc8ec50c14f3ec9637505f28e58d4f66752207ea177c1d67df25da5aed"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:f97e83fa6c25693c7a35de154681fcc257c1c41b38beb0304b9c4d2d9e164479"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a152f5f33d64a6be73f1d30c9cc82dfc73cec6477ec268e7c6e4c7d23c2d2291"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:39049da0ffb96c8cbb65cbf5c5f3ca3168990adf3551bd1dee10c48fce8ae820"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-win32.whl", hash = "sha256:4457ea6774b5611f4bed5eaa5df55f70abde42364d498c5134b7ef4c6958e20e"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:e62164b50f84e20601c1ff8eb55620d2ad25fb81b59e3cd776a1902527a788af"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8eade758719add78ec36dc13201483f8e9b5d940329285edcd5f70c0a9edbd7f"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8499ca8f4502af841f68135133d8258f7b32a53a1d594aa98cc52013fff55678"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fc1c4a2ffd64890aebdb3f97e1278b0cc72579a08ca4de8cd2c04799a3a22be"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00d3ffdaafe92a5dc603cb9bd5111aaa36dfa187c8285c543be562e61b755f6b"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2ac1b08635a8cd4e0cbeaf6f5e922085908d48eb05d44c5ae9eabab148512ca"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6f45710b4459401609ebebdbcfb34515da4fc2aa886f95107f556ac69a9147e"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ae1de54a77dc0d6d5fcf623290af4266412a7c4be0b1ff7444394f03f5c54e3"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b590df687e3c5ee0deef9fc8c547d81986d9a1b56073d82de008744452d6541"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab5de034a886f616a5668aa5d098af2b5385ed70142090e2a31bcbd0af0fdb3d"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9cb3032517f1627cc012dbc80a8ec976ae76d93ea2b5feaa9d2a5b8882597579"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:608862a7bf6957f2333fc54ab4399e405baad0163dc9f8d99cb236816db169d4"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f438ae3532723fb6ead77e7c604be7c8374094ef4ee2c5e03a3a17f1fca256c"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:356541bf4381fa35856dafa6a965916e54bed415ad8a24ee6de6e37deccf2786"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-win32.whl", hash = "sha256:39cf9ed17fe3b1bc81f33c9ceb6ce67683ee7526e65fde1447c772afc54a1bb8"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:0a11e971ed097d24c534c037d298ad32c6ce81a45736d31e0ff0ad37ab437d59"}, + {file = "charset_normalizer-3.0.1-py3-none-any.whl", hash = "sha256:7e189e2e1d3ed2f4aebabd2d5b0f931e883676e51c7624826e0a4e5fe8a0bf24"}, ] codecov = [ {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"}, {file = "codecov-2.1.12.tar.gz", hash = "sha256:a0da46bb5025426da895af90938def8ee12d37fcbcbbbc15b6dc64cf7ebc51c1"}, ] colorama = [ - {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, - {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] coverage = [ - {file = "coverage-6.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf"}, - {file = "coverage-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac"}, - {file = "coverage-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1"}, - {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4"}, - {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903"}, - {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c"}, - {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f"}, - {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05"}, - {file = "coverage-6.3.2-cp310-cp310-win32.whl", hash = "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39"}, - {file = "coverage-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1"}, - {file = "coverage-6.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa"}, - {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518"}, - {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7"}, - {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6"}, - {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad"}, - {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359"}, - {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4"}, - {file = "coverage-6.3.2-cp37-cp37m-win32.whl", hash = "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca"}, - {file = "coverage-6.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3"}, - {file = "coverage-6.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d"}, - {file = "coverage-6.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059"}, - {file = "coverage-6.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512"}, - {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca"}, - {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d"}, - {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0"}, - {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6"}, - {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"}, - {file = "coverage-6.3.2-cp38-cp38-win32.whl", hash = "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e"}, - {file = "coverage-6.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1"}, - {file = "coverage-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620"}, - {file = "coverage-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d"}, - {file = "coverage-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536"}, - {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7"}, - {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2"}, - {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4"}, - {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69"}, - {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684"}, - {file = "coverage-6.3.2-cp39-cp39-win32.whl", hash = "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4"}, - {file = "coverage-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92"}, - {file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"}, - {file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"}, + {file = "coverage-7.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3b946bbcd5a8231383450b195cfb58cb01cbe7f8949f5758566b881df4b33baf"}, + {file = "coverage-7.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ec8e767f13be637d056f7e07e61d089e555f719b387a7070154ad80a0ff31801"}, + {file = "coverage-7.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4a5a5879a939cb84959d86869132b00176197ca561c664fc21478c1eee60d75"}, + {file = "coverage-7.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b643cb30821e7570c0aaf54feaf0bfb630b79059f85741843e9dc23f33aaca2c"}, + {file = "coverage-7.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32df215215f3af2c1617a55dbdfb403b772d463d54d219985ac7cd3bf124cada"}, + {file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:33d1ae9d4079e05ac4cc1ef9e20c648f5afabf1a92adfaf2ccf509c50b85717f"}, + {file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:29571503c37f2ef2138a306d23e7270687c0efb9cab4bd8038d609b5c2393a3a"}, + {file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:63ffd21aa133ff48c4dff7adcc46b7ec8b565491bfc371212122dd999812ea1c"}, + {file = "coverage-7.1.0-cp310-cp310-win32.whl", hash = "sha256:4b14d5e09c656de5038a3f9bfe5228f53439282abcab87317c9f7f1acb280352"}, + {file = "coverage-7.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:8361be1c2c073919500b6601220a6f2f98ea0b6d2fec5014c1d9cfa23dd07038"}, + {file = "coverage-7.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:da9b41d4539eefd408c46725fb76ecba3a50a3367cafb7dea5f250d0653c1040"}, + {file = "coverage-7.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5b15ed7644ae4bee0ecf74fee95808dcc34ba6ace87e8dfbf5cb0dc20eab45a"}, + {file = "coverage-7.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d12d076582507ea460ea2a89a8c85cb558f83406c8a41dd641d7be9a32e1274f"}, + {file = "coverage-7.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2617759031dae1bf183c16cef8fcfb3de7617f394c813fa5e8e46e9b82d4222"}, + {file = "coverage-7.1.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4e4881fa9e9667afcc742f0c244d9364d197490fbc91d12ac3b5de0bf2df146"}, + {file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9d58885215094ab4a86a6aef044e42994a2bd76a446dc59b352622655ba6621b"}, + {file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ffeeb38ee4a80a30a6877c5c4c359e5498eec095878f1581453202bfacc8fbc2"}, + {file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3baf5f126f30781b5e93dbefcc8271cb2491647f8283f20ac54d12161dff080e"}, + {file = "coverage-7.1.0-cp311-cp311-win32.whl", hash = "sha256:ded59300d6330be27bc6cf0b74b89ada58069ced87c48eaf9344e5e84b0072f7"}, + {file = "coverage-7.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a43c7823cd7427b4ed763aa7fb63901ca8288591323b58c9cd6ec31ad910f3c"}, + {file = "coverage-7.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7a726d742816cb3a8973c8c9a97539c734b3a309345236cd533c4883dda05b8d"}, + {file = "coverage-7.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc7c85a150501286f8b56bd8ed3aa4093f4b88fb68c0843d21ff9656f0009d6a"}, + {file = "coverage-7.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b4198d85a3755d27e64c52f8c95d6333119e49fd001ae5798dac872c95e0f8"}, + {file = "coverage-7.1.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddb726cb861c3117a553f940372a495fe1078249ff5f8a5478c0576c7be12050"}, + {file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:51b236e764840a6df0661b67e50697aaa0e7d4124ca95e5058fa3d7cbc240b7c"}, + {file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:7ee5c9bb51695f80878faaa5598040dd6c9e172ddcf490382e8aedb8ec3fec8d"}, + {file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c31b75ae466c053a98bf26843563b3b3517b8f37da4d47b1c582fdc703112bc3"}, + {file = "coverage-7.1.0-cp37-cp37m-win32.whl", hash = "sha256:3b155caf3760408d1cb903b21e6a97ad4e2bdad43cbc265e3ce0afb8e0057e73"}, + {file = "coverage-7.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2a60d6513781e87047c3e630b33b4d1e89f39836dac6e069ffee28c4786715f5"}, + {file = "coverage-7.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f2cba5c6db29ce991029b5e4ac51eb36774458f0a3b8d3137241b32d1bb91f06"}, + {file = "coverage-7.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beeb129cacea34490ffd4d6153af70509aa3cda20fdda2ea1a2be870dfec8d52"}, + {file = "coverage-7.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c45948f613d5d18c9ec5eaa203ce06a653334cf1bd47c783a12d0dd4fd9c851"}, + {file = "coverage-7.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef382417db92ba23dfb5864a3fc9be27ea4894e86620d342a116b243ade5d35d"}, + {file = "coverage-7.1.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c7c0d0827e853315c9bbd43c1162c006dd808dbbe297db7ae66cd17b07830f0"}, + {file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e5cdbb5cafcedea04924568d990e20ce7f1945a1dd54b560f879ee2d57226912"}, + {file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9817733f0d3ea91bea80de0f79ef971ae94f81ca52f9b66500c6a2fea8e4b4f8"}, + {file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:218fe982371ac7387304153ecd51205f14e9d731b34fb0568181abaf7b443ba0"}, + {file = "coverage-7.1.0-cp38-cp38-win32.whl", hash = "sha256:04481245ef966fbd24ae9b9e537ce899ae584d521dfbe78f89cad003c38ca2ab"}, + {file = "coverage-7.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8ae125d1134bf236acba8b83e74c603d1b30e207266121e76484562bc816344c"}, + {file = "coverage-7.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2bf1d5f2084c3932b56b962a683074a3692bce7cabd3aa023c987a2a8e7612f6"}, + {file = "coverage-7.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:98b85dd86514d889a2e3dd22ab3c18c9d0019e696478391d86708b805f4ea0fa"}, + {file = "coverage-7.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38da2db80cc505a611938d8624801158e409928b136c8916cd2e203970dde4dc"}, + {file = "coverage-7.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3164d31078fa9efe406e198aecd2a02d32a62fecbdef74f76dad6a46c7e48311"}, + {file = "coverage-7.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db61a79c07331e88b9a9974815c075fbd812bc9dbc4dc44b366b5368a2936063"}, + {file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ccb092c9ede70b2517a57382a601619d20981f56f440eae7e4d7eaafd1d1d09"}, + {file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:33ff26d0f6cc3ca8de13d14fde1ff8efe1456b53e3f0273e63cc8b3c84a063d8"}, + {file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d47dd659a4ee952e90dc56c97d78132573dc5c7b09d61b416a9deef4ebe01a0c"}, + {file = "coverage-7.1.0-cp39-cp39-win32.whl", hash = "sha256:d248cd4a92065a4d4543b8331660121b31c4148dd00a691bfb7a5cdc7483cfa4"}, + {file = "coverage-7.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7ed681b0f8e8bcbbffa58ba26fcf5dbc8f79e7997595bf071ed5430d8c08d6f3"}, + {file = "coverage-7.1.0-pp37.pp38.pp39-none-any.whl", hash = "sha256:755e89e32376c850f826c425ece2c35a4fc266c081490eb0a841e7c1cb0d3bda"}, + {file = "coverage-7.1.0.tar.gz", hash = "sha256:10188fe543560ec4874f974b5305cd1a8bdcfa885ee00ea3a03733464c4ca265"}, ] distlib = [ - {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, - {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, + {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, + {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, ] docutils = [ - {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, - {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, + {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, + {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, +] +exceptiongroup = [ + {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, + {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, ] filelock = [ - {file = "filelock-3.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"}, - {file = "filelock-3.6.0.tar.gz", hash = "sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85"}, + {file = "filelock-3.9.0-py3-none-any.whl", hash = "sha256:f58d535af89bb9ad5cd4df046f741f8553a418c01a7856bf0d173bbc9f6bd16d"}, + {file = "filelock-3.9.0.tar.gz", hash = "sha256:7b319f24340b51f55a2bf7a12ac0755a9b03e718311dac567a0f4f7fabd2f5de"}, ] identify = [ - {file = "identify-2.4.12-py2.py3-none-any.whl", hash = "sha256:5f06b14366bd1facb88b00540a1de05b69b310cbc2654db3c7e07fa3a4339323"}, - {file = "identify-2.4.12.tar.gz", hash = "sha256:3f3244a559290e7d3deb9e9adc7b33594c1bc85a9dd82e0f1be519bf12a1ec17"}, + {file = "identify-2.5.18-py2.py3-none-any.whl", hash = "sha256:93aac7ecf2f6abf879b8f29a8002d3c6de7086b8c28d88e1ad15045a15ab63f9"}, + {file = "identify-2.5.18.tar.gz", hash = "sha256:89e144fa560cc4cffb6ef2ab5e9fb18ed9f9b3cb054384bab4b95c12f6c309fe"}, ] idna = [ - {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, - {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] imagesize = [ - {file = "imagesize-1.3.0-py2.py3-none-any.whl", hash = "sha256:1db2f82529e53c3e929e8926a1fa9235aa82d0bd0c580359c67ec31b2fddaa8c"}, - {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, + {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, + {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.11.3-py3-none-any.whl", hash = "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6"}, - {file = "importlib_metadata-4.11.3.tar.gz", hash = "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539"}, + {file = "importlib_metadata-6.0.0-py3-none-any.whl", hash = "sha256:7efb448ec9a5e313a57655d35aa54cd3e01b7e1fbcf72dce1bf06119420f5bad"}, + {file = "importlib_metadata-6.0.0.tar.gz", hash = "sha256:e354bedeb60efa6affdcc8ae121b73544a7aa74156d047311948f6d711cd378d"}, ] iniconfig = [ - {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, - {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, -] -jinja2 = [ - {file = "Jinja2-3.1.1-py3-none-any.whl", hash = "sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119"}, - {file = "Jinja2-3.1.1.tar.gz", hash = "sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9"}, -] -m2r = [ - {file = "m2r-0.2.1.tar.gz", hash = "sha256:bf90bad66cda1164b17e5ba4a037806d2443f2a4d5ddc9f6a5554a0322aaed99"}, -] -markupsafe = [ - {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, - {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, -] -mistune = [ - {file = "mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4"}, - {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] +Jinja2 = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] +markdown-it-py = [ + {file = "markdown-it-py-2.1.0.tar.gz", hash = "sha256:cf7e59fed14b5ae17c0006eff14a2d9a00ed5f3a846148153899a0224e2c07da"}, + {file = "markdown_it_py-2.1.0-py3-none-any.whl", hash = "sha256:93de681e5c021a432c63147656fe21790bc01231e0cd2da73626f1aa3ac0fe27"}, +] +MarkupSafe = [ + {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-win32.whl", hash = "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-win32.whl", hash = "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-win32.whl", hash = "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-win32.whl", hash = "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-win32.whl", hash = "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed"}, + {file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"}, +] +mdit-py-plugins = [ + {file = "mdit-py-plugins-0.3.4.tar.gz", hash = "sha256:3278aab2e2b692539082f05e1243f24742194ffd92481f48844f057b51971283"}, + {file = "mdit_py_plugins-0.3.4-py3-none-any.whl", hash = "sha256:4f1441264ac5cb39fa40a5901921c2acf314ea098d75629750c138f80d552cdf"}, +] +mdurl = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] +myst-parser = [ + {file = "myst-parser-0.18.1.tar.gz", hash = "sha256:79317f4bb2c13053dd6e64f9da1ba1da6cd9c40c8a430c447a7b146a594c246d"}, + {file = "myst_parser-0.18.1-py3-none-any.whl", hash = "sha256:61b275b85d9f58aa327f370913ae1bec26ebad372cc99f3ab85c8ec3ee8d9fb8"}, ] nodeenv = [ - {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, - {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, + {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, + {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, ] packaging = [ - {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, - {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, + {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, + {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, ] platformdirs = [ - {file = "platformdirs-2.5.1-py3-none-any.whl", hash = "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"}, - {file = "platformdirs-2.5.1.tar.gz", hash = "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d"}, + {file = "platformdirs-3.0.0-py3-none-any.whl", hash = "sha256:b1d5eb14f221506f50d6604a561f4c5786d9e80355219694a1b244bcd96f4567"}, + {file = "platformdirs-3.0.0.tar.gz", hash = "sha256:8a1228abb1ef82d788f74139988b137e78692984ec7b08eaa6c65f1723af28f9"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] pre-commit = [ - {file = "pre_commit-2.18.1-py2.py3-none-any.whl", hash = "sha256:02226e69564ebca1a070bd1f046af866aa1c318dbc430027c50ab832ed2b73f2"}, - {file = "pre_commit-2.18.1.tar.gz", hash = "sha256:5d445ee1fa8738d506881c5d84f83c62bb5be6b2838e32207433647e8e5ebe10"}, -] -py = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, + {file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"}, + {file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"}, ] pydantic = [ - {file = "pydantic-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"}, - {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"}, - {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37"}, - {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25"}, - {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6"}, - {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c"}, - {file = "pydantic-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398"}, - {file = "pydantic-1.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65"}, - {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46"}, - {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c"}, - {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054"}, - {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed"}, - {file = "pydantic-1.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1"}, - {file = "pydantic-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070"}, - {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2"}, - {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1"}, - {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032"}, - {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6"}, - {file = "pydantic-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d"}, - {file = "pydantic-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7"}, - {file = "pydantic-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77"}, - {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9"}, - {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6"}, - {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145"}, - {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034"}, - {file = "pydantic-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f"}, - {file = "pydantic-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b"}, - {file = "pydantic-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c"}, - {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce"}, - {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3"}, - {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d"}, - {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721"}, - {file = "pydantic-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16"}, - {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"}, - {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"}, -] -pygments = [ - {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, - {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, -] -pyparsing = [ - {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, - {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, + {file = "pydantic-1.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5920824fe1e21cbb3e38cf0f3dd24857c8959801d1031ce1fac1d50857a03bfb"}, + {file = "pydantic-1.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3bb99cf9655b377db1a9e47fa4479e3330ea96f4123c6c8200e482704bf1eda2"}, + {file = "pydantic-1.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2185a3b3d98ab4506a3f6707569802d2d92c3a7ba3a9a35683a7709ea6c2aaa2"}, + {file = "pydantic-1.10.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f582cac9d11c227c652d3ce8ee223d94eb06f4228b52a8adaafa9fa62e73d5c9"}, + {file = "pydantic-1.10.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c9e5b778b6842f135902e2d82624008c6a79710207e28e86966cd136c621bfee"}, + {file = "pydantic-1.10.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:72ef3783be8cbdef6bca034606a5de3862be6b72415dc5cb1fb8ddbac110049a"}, + {file = "pydantic-1.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:45edea10b75d3da43cfda12f3792833a3fa70b6eee4db1ed6aed528cef17c74e"}, + {file = "pydantic-1.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:63200cd8af1af2c07964546b7bc8f217e8bda9d0a2ef0ee0c797b36353914984"}, + {file = "pydantic-1.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:305d0376c516b0dfa1dbefeae8c21042b57b496892d721905a6ec6b79494a66d"}, + {file = "pydantic-1.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fd326aff5d6c36f05735c7c9b3d5b0e933b4ca52ad0b6e4b38038d82703d35b"}, + {file = "pydantic-1.10.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6bb0452d7b8516178c969d305d9630a3c9b8cf16fcf4713261c9ebd465af0d73"}, + {file = "pydantic-1.10.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9a9d9155e2a9f38b2eb9374c88f02fd4d6851ae17b65ee786a87d032f87008f8"}, + {file = "pydantic-1.10.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f836444b4c5ece128b23ec36a446c9ab7f9b0f7981d0d27e13a7c366ee163f8a"}, + {file = "pydantic-1.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:8481dca324e1c7b715ce091a698b181054d22072e848b6fc7895cd86f79b4449"}, + {file = "pydantic-1.10.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:87f831e81ea0589cd18257f84386bf30154c5f4bed373b7b75e5cb0b5d53ea87"}, + {file = "pydantic-1.10.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ce1612e98c6326f10888df951a26ec1a577d8df49ddcaea87773bfbe23ba5cc"}, + {file = "pydantic-1.10.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58e41dd1e977531ac6073b11baac8c013f3cd8706a01d3dc74e86955be8b2c0c"}, + {file = "pydantic-1.10.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6a4b0aab29061262065bbdede617ef99cc5914d1bf0ddc8bcd8e3d7928d85bd6"}, + {file = "pydantic-1.10.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:36e44a4de37b8aecffa81c081dbfe42c4d2bf9f6dff34d03dce157ec65eb0f15"}, + {file = "pydantic-1.10.5-cp37-cp37m-win_amd64.whl", hash = "sha256:261f357f0aecda005934e413dfd7aa4077004a174dafe414a8325e6098a8e419"}, + {file = "pydantic-1.10.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b429f7c457aebb7fbe7cd69c418d1cd7c6fdc4d3c8697f45af78b8d5a7955760"}, + {file = "pydantic-1.10.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:663d2dd78596c5fa3eb996bc3f34b8c2a592648ad10008f98d1348be7ae212fb"}, + {file = "pydantic-1.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51782fd81f09edcf265823c3bf43ff36d00db246eca39ee765ef58dc8421a642"}, + {file = "pydantic-1.10.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c428c0f64a86661fb4873495c4fac430ec7a7cef2b8c1c28f3d1a7277f9ea5ab"}, + {file = "pydantic-1.10.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:76c930ad0746c70f0368c4596020b736ab65b473c1f9b3872310a835d852eb19"}, + {file = "pydantic-1.10.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3257bd714de9db2102b742570a56bf7978e90441193acac109b1f500290f5718"}, + {file = "pydantic-1.10.5-cp38-cp38-win_amd64.whl", hash = "sha256:f5bee6c523d13944a1fdc6f0525bc86dbbd94372f17b83fa6331aabacc8fd08e"}, + {file = "pydantic-1.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:532e97c35719f137ee5405bd3eeddc5c06eb91a032bc755a44e34a712420daf3"}, + {file = "pydantic-1.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ca9075ab3de9e48b75fa8ccb897c34ccc1519177ad8841d99f7fd74cf43be5bf"}, + {file = "pydantic-1.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd46a0e6296346c477e59a954da57beaf9c538da37b9df482e50f836e4a7d4bb"}, + {file = "pydantic-1.10.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3353072625ea2a9a6c81ad01b91e5c07fa70deb06368c71307529abf70d23325"}, + {file = "pydantic-1.10.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:3f9d9b2be177c3cb6027cd67fbf323586417868c06c3c85d0d101703136e6b31"}, + {file = "pydantic-1.10.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b473d00ccd5c2061fd896ac127b7755baad233f8d996ea288af14ae09f8e0d1e"}, + {file = "pydantic-1.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:5f3bc8f103b56a8c88021d481410874b1f13edf6e838da607dcb57ecff9b4594"}, + {file = "pydantic-1.10.5-py3-none-any.whl", hash = "sha256:7c5b94d598c90f2f46b3a983ffb46ab806a67099d118ae0da7ef21a2a4033b28"}, + {file = "pydantic-1.10.5.tar.gz", hash = "sha256:9e337ac83686645a46db0e825acceea8e02fca4062483f40e9ae178e8bd1103a"}, +] +Pygments = [ + {file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"}, + {file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"}, +] +pyproject-api = [ + {file = "pyproject_api-1.5.0-py3-none-any.whl", hash = "sha256:4c111277dfb96bcd562c6245428f27250b794bfe3e210b8714c4f893952f2c17"}, + {file = "pyproject_api-1.5.0.tar.gz", hash = "sha256:0962df21f3e633b8ddb9567c011e6c1b3dcdfc31b7860c0ede7e24c5a1200fbe"}, ] pytest = [ - {file = "pytest-7.1.1-py3-none-any.whl", hash = "sha256:92f723789a8fdd7180b6b06483874feca4c48a5c76968e03bb3e7f806a1869ea"}, - {file = "pytest-7.1.1.tar.gz", hash = "sha256:841132caef6b1ad17a9afde46dc4f6cfa59a05f9555aae5151f73bdf2820ca63"}, + {file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"}, + {file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"}, ] pytest-asyncio = [ - {file = "pytest-asyncio-0.18.3.tar.gz", hash = "sha256:7659bdb0a9eb9c6e3ef992eef11a2b3e69697800ad02fb06374a210d85b29f91"}, - {file = "pytest_asyncio-0.18.3-1-py3-none-any.whl", hash = "sha256:16cf40bdf2b4fb7fc8e4b82bd05ce3fbcd454cbf7b92afc445fe299dabb88213"}, - {file = "pytest_asyncio-0.18.3-py3-none-any.whl", hash = "sha256:8fafa6c52161addfd41ee7ab35f11836c5a16ec208f93ee388f752bea3493a84"}, + {file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"}, + {file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"}, ] pytest-cov = [ - {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, - {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, + {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, + {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, ] pytest-mock = [ - {file = "pytest-mock-3.7.0.tar.gz", hash = "sha256:5112bd92cc9f186ee96e1a92efc84969ea494939c3aead39c50f421c4cc69534"}, - {file = "pytest_mock-3.7.0-py3-none-any.whl", hash = "sha256:6cff27cec936bf81dc5ee87f07132b807bcda51106b5ec4b90a04331cba76231"}, + {file = "pytest-mock-3.10.0.tar.gz", hash = "sha256:fbbdb085ef7c252a326fd8cdcac0aa3b1333d8811f131bdcc701002e1be7ed4f"}, + {file = "pytest_mock-3.10.0-py3-none-any.whl", hash = "sha256:f4c973eeae0282963eb293eb173ce91b091a79c1334455acfac9ddee8a1c784b"}, ] pytest-sugar = [ - {file = "pytest-sugar-0.9.4.tar.gz", hash = "sha256:b1b2186b0a72aada6859bea2a5764145e3aaa2c1cfbb23c3a19b5f7b697563d3"}, + {file = "pytest-sugar-0.9.6.tar.gz", hash = "sha256:c4793495f3c32e114f0f5416290946c316eb96ad5a3684dcdadda9267e59b2b8"}, + {file = "pytest_sugar-0.9.6-py2.py3-none-any.whl", hash = "sha256:30e5225ed2b3cc988a8a672f8bda0fc37bcd92d62e9273937f061112b3f2186d"}, ] pytz = [ - {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, - {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, + {file = "pytz-2022.7.1-py2.py3-none-any.whl", hash = "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"}, + {file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"}, ] -pyyaml = [ +PyYAML = [ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, @@ -1065,28 +1277,32 @@ pyyaml = [ {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, ] requests = [ - {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, - {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, + {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"}, + {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"}, +] +setuptools = [ + {file = "setuptools-67.3.2-py3-none-any.whl", hash = "sha256:bb6d8e508de562768f2027902929f8523932fcd1fb784e6d573d2cafac995a48"}, + {file = "setuptools-67.3.2.tar.gz", hash = "sha256:95f00380ef2ffa41d9bba85d95b27689d923c93dfbafed4aecd7cf988a25e012"}, ] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] sniffio = [ - {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"}, - {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, + {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, ] snowballstemmer = [ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, ] -sphinx = [ +Sphinx = [ {file = "Sphinx-4.5.0-py3-none-any.whl", hash = "sha256:ebf612653238bcc8f4359627a9b7ce44ede6fdd75d9d30f68255c7383d3a6226"}, {file = "Sphinx-4.5.0.tar.gz", hash = "sha256:7bf8ca9637a4ee15af412d1a1d9689fec70523a68ca9bb9127c2f3eeb344e2e6"}, ] sphinx-rtd-theme = [ - {file = "sphinx_rtd_theme-0.5.2-py2.py3-none-any.whl", hash = "sha256:4a05bdbe8b1446d77a01e20a23ebc6777c74f43237035e76be89699308987d6f"}, - {file = "sphinx_rtd_theme-0.5.2.tar.gz", hash = "sha256:32bd3b5d13dc8186d7a42fc816a23d32e83a4827d7d9882948e7b837c232da5a"}, + {file = "sphinx_rtd_theme-0.5.1-py2.py3-none-any.whl", hash = "sha256:fa6bebd5ab9a73da8e102509a86f3fcc36dec04a0b52ea80e5a033b2aba00113"}, + {file = "sphinx_rtd_theme-0.5.1.tar.gz", hash = "sha256:eda689eda0c7301a80cf122dad28b1861e5605cbf455558f3775e1e8200e83a5"}, ] sphinxcontrib-applehelp = [ {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, @@ -1117,7 +1333,8 @@ sphinxcontrib-serializinghtml = [ {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, ] termcolor = [ - {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"}, + {file = "termcolor-2.2.0-py3-none-any.whl", hash = "sha256:91ddd848e7251200eac969846cbae2dacd7d71c2871e92733289e7e3666f48e7"}, + {file = "termcolor-2.2.0.tar.gz", hash = "sha256:dfc8ac3f350788f23b2947b3e6cfa5a53b630b612e6cd8965a015a776020b99a"}, ] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, @@ -1128,30 +1345,30 @@ tomli = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] tox = [ - {file = "tox-3.24.5-py2.py3-none-any.whl", hash = "sha256:be3362472a33094bce26727f5f771ca0facf6dafa217f65875314e9a6600c95c"}, - {file = "tox-3.24.5.tar.gz", hash = "sha256:67e0e32c90e278251fea45b696d0fef3879089ccbe979b0c556d35d5a70e2993"}, + {file = "tox-4.4.5-py3-none-any.whl", hash = "sha256:1081864f1a1393ffa11ebe9beaa280349020579310d217a594a4e7b6124c5425"}, + {file = "tox-4.4.5.tar.gz", hash = "sha256:f9bc83c5da8666baa2a4d4e884bbbda124fe646e4b1c0e412949cecc2b6e8f90"}, ] typing-extensions = [ - {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, - {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, + {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, + {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, ] urllib3 = [ - {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, - {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, + {file = "urllib3-1.26.14-py2.py3-none-any.whl", hash = "sha256:75edcdc2f7d85b137124a6c3c9fc3933cdeaa12ecb9a6a959f22797a0feca7e1"}, + {file = "urllib3-1.26.14.tar.gz", hash = "sha256:076907bf8fd355cde77728471316625a4d2f7e713c125f51953bb5b3eecf4f72"}, ] virtualenv = [ - {file = "virtualenv-20.14.0-py2.py3-none-any.whl", hash = "sha256:1e8588f35e8b42c6ec6841a13c5e88239de1e6e4e4cedfd3916b306dc826ec66"}, - {file = "virtualenv-20.14.0.tar.gz", hash = "sha256:8e5b402037287126e81ccde9432b95a8be5b19d36584f64957060a3488c11ca8"}, + {file = "virtualenv-20.19.0-py3-none-any.whl", hash = "sha256:54eb59e7352b573aa04d53f80fc9736ed0ad5143af445a1e539aada6eb947dd1"}, + {file = "virtualenv-20.19.0.tar.gz", hash = "sha256:37a640ba82ed40b226599c522d411e4be5edb339a0c0de030c0dc7b646d61590"}, ] voluptuous = [ - {file = "voluptuous-0.13.0-py3-none-any.whl", hash = "sha256:e3b5f6cb68fcb0230701b5c756db4caa6766223fc0eaf613931fdba51025981b"}, - {file = "voluptuous-0.13.0.tar.gz", hash = "sha256:cae6a4526b434b642816b34a00e1186d5a5f5e0c948ab94d2a918e01e5874066"}, + {file = "voluptuous-0.13.1-py3-none-any.whl", hash = "sha256:4b838b185f5951f2d6e8752b68fcf18bd7a9c26ded8f143f92d6d28f3921a3e6"}, + {file = "voluptuous-0.13.1.tar.gz", hash = "sha256:e8d31c20601d6773cb14d4c0f42aee29c6821bbd1018039aac7ac5605b489723"}, ] xdoctest = [ - {file = "xdoctest-0.15.10-py3-none-any.whl", hash = "sha256:7666bd0511df59275dfe94ef94b0fde9654afd14f00bf88902fdc9bcee77d527"}, - {file = "xdoctest-0.15.10.tar.gz", hash = "sha256:5f16438f2b203860e75ec594dbc38020df7524db0b41bb88467ea0a6030e6685"}, + {file = "xdoctest-1.1.1-py3-none-any.whl", hash = "sha256:d59d4ed91cb92e4430ef0ad1b134a2bef02adff7d2fb9c9f057547bee44081a2"}, + {file = "xdoctest-1.1.1.tar.gz", hash = "sha256:2eac8131bdcdf2781b4e5a62d6de87f044b730cc8db8af142a51bb29c245e779"}, ] zipp = [ - {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"}, - {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"}, + {file = "zipp-3.14.0-py3-none-any.whl", hash = "sha256:188834565033387710d046e3fe96acfc9b5e86cbca7f39ff69cf21a4128198b7"}, + {file = "zipp-3.14.0.tar.gz", hash = "sha256:9e5421e176ef5ab4c0ad896624e87a7b2f07aca746c9b2aa305952800cb8eecb"}, ] diff --git a/pyproject.toml b/pyproject.toml index 476376d11..2ab5a1910 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,10 @@ packages = [ ] include = ["CHANGELOG.md"] +[tool.poetry.urls] +"Bug Tracker" = "https://github.com/python-kasa/python-kasa/issues" +"Documentation" = "https://python-kasa.readthedocs.io" + [tool.poetry.scripts] kasa = "kasa.cli:cli" @@ -23,27 +27,27 @@ pydantic = "^1" # required only for docs sphinx = { version = "^4", optional = true } -m2r = { version = "^0", optional = true } -mistune = { version = "<2.0.0", optional = true } sphinx_rtd_theme = { version = "^0", optional = true } sphinxcontrib-programoutput = { version = "^0", optional = true } +myst-parser = { version = "*", optional = true } +docutils = { version = ">=0.17", optional = true } [tool.poetry.dev-dependencies] -pytest = ">=6.2.5" -pytest-cov = "^2" -pytest-asyncio = "^0" +pytest = "*" +pytest-cov = "*" +pytest-asyncio = "*" pytest-sugar = "*" pre-commit = "*" voluptuous = "*" toml = "*" tox = "*" -pytest-mock = "^3" -codecov = "^2" -xdoctest = "^0" -coverage = {version = "^6", extras = ["toml"]} +pytest-mock = "*" +codecov = "*" +xdoctest = "*" +coverage = {version = "*", extras = ["toml"]} [tool.poetry.extras] -docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programoutput"] +docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] [tool.isort] @@ -85,6 +89,7 @@ asyncio_mode = "auto" [tool.doc8] paths = ["docs"] ignore = ["D001"] +ignore-path-errors = ["docs/source/index.rst;D000"] [build-system] requires = ["poetry-core>=1.0.0"] From 6be2f387f04867b1af554cf25c674425f8d0e1d9 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 18 Feb 2023 22:40:42 +0100 Subject: [PATCH 134/892] Prepare 0.5.1 (#434) --- CHANGELOG.md | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 10 ++++-- pyproject.toml | 4 +-- 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b921061..466f3c28b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,97 @@ # Changelog +## [0.5.1](https://github.com/python-kasa/python-kasa/tree/0.5.1) (2023-02-18) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.0...0.5.1) + +This minor release contains mostly small UX fine-tuning and documentation improvements alongside with bug fixes: +* Improved console tool (JSON output, colorized output if rich is installed) +* Pretty, colorized console output, if `rich` is installed +* Support for configuring bulb presets +* Usage data is now reported in the expected format +* Dependency pinning is relaxed to give downstreams more control + +**Breaking changes:** + +- Implement changing the bulb turn-on behavior [\#381](https://github.com/python-kasa/python-kasa/pull/381) (@rytilahti) + +**Implemented enhancements:** + +- Add support for json output [\#430](https://github.com/python-kasa/python-kasa/pull/430) (@rytilahti) +- Pretty-print all exceptions from cli commands [\#428](https://github.com/python-kasa/python-kasa/pull/428) (@rytilahti) +- Add transition parameter to lightstrip's set\_effect [\#416](https://github.com/python-kasa/python-kasa/pull/416) (@rytilahti) +- Add brightness to lightstrip's set\_effect [\#415](https://github.com/python-kasa/python-kasa/pull/415) (@rytilahti) +- Use rich for prettier output, if available [\#403](https://github.com/python-kasa/python-kasa/pull/403) (@rytilahti) +- Adding cli command to delete a schedule rule [\#391](https://github.com/python-kasa/python-kasa/pull/391) (@aricforrest) +- Add support for bulb presets [\#379](https://github.com/python-kasa/python-kasa/pull/379) (@rytilahti) + +**Fixed bugs:** + +- cli.py usage year and month options do not output data as expected [\#373](https://github.com/python-kasa/python-kasa/issues/373) +- cli.py usage --year command passes year argument incorrectly [\#371](https://github.com/python-kasa/python-kasa/issues/371) +- KP303 reporting as device off [\#319](https://github.com/python-kasa/python-kasa/issues/319) +- HS210 not updating the state correctly [\#193](https://github.com/python-kasa/python-kasa/issues/193) +- Return usage.get\_{monthstat,daystat} in expected format [\#394](https://github.com/python-kasa/python-kasa/pull/394) (@jules43) +- Fix year emeter for cli by using kwarg for year parameter [\#372](https://github.com/python-kasa/python-kasa/pull/372) (@rytilahti) + +**Documentation updates:** + +- Update misleading docs about supported devices \(was: add support for EP25 plug\) [\#367](https://github.com/python-kasa/python-kasa/issues/367) +- Minor fixes to smartbulb docs [\#431](https://github.com/python-kasa/python-kasa/pull/431) (@rytilahti) +- Add a note that transition is not supported by all devices [\#398](https://github.com/python-kasa/python-kasa/pull/398) (@rytilahti) +- fix more outdated CLI examples, remove EP40 from bulb list [\#383](https://github.com/python-kasa/python-kasa/pull/383) (@HankB) +- Fix outdated smartstrip cli examples [\#382](https://github.com/python-kasa/python-kasa/pull/382) (@HankB) +- Add ToCs for doc pages [\#380](https://github.com/python-kasa/python-kasa/pull/380) (@rytilahti) +- Clarify information about supported devices [\#377](https://github.com/python-kasa/python-kasa/pull/377) (@rytilahti) +- Update README to add missing models and fix a link [\#351](https://github.com/python-kasa/python-kasa/pull/351) (@rytilahti) +- Add KP125 test fixture and support note. [\#350](https://github.com/python-kasa/python-kasa/pull/350) (@jalseth) + +**Closed issues:** + +- detecting when a switch changes state [\#427](https://github.com/python-kasa/python-kasa/issues/427) +- discovery fails for aliases [\#426](https://github.com/python-kasa/python-kasa/issues/426) +- traceback when no devices exist [\#425](https://github.com/python-kasa/python-kasa/issues/425) +- Discover.discover\(\) in a cron that runs every 1 min [\#421](https://github.com/python-kasa/python-kasa/issues/421) +- add Schedule rule? [\#418](https://github.com/python-kasa/python-kasa/issues/418) +- Cannot find EP10 using kasa discover [\#417](https://github.com/python-kasa/python-kasa/issues/417) +- modulenotfound error [\#414](https://github.com/python-kasa/python-kasa/issues/414) +- Issue enabling motion sensor, ES20M\(US\) [\#408](https://github.com/python-kasa/python-kasa/issues/408) +- HS103 not discovered by kasa CLI [\#406](https://github.com/python-kasa/python-kasa/issues/406) +- Multiple warnings from running pytest due to asyncio issues [\#396](https://github.com/python-kasa/python-kasa/issues/396) +- Transition ignored with KL420L5 light strips [\#389](https://github.com/python-kasa/python-kasa/issues/389) +- cli.py passes a dictionary \(TYPE\_TO\_CLASS\) to click.Choice which takes a Sequence\[str\] [\#384](https://github.com/python-kasa/python-kasa/issues/384) +- Error running `kasa wifi scan` [\#376](https://github.com/python-kasa/python-kasa/issues/376) +- Unable to connect to brand new EP40 v1.8 [\#366](https://github.com/python-kasa/python-kasa/issues/366) +- Add support for setting default behaviors for a soft or hard power on of the bulb [\#365](https://github.com/python-kasa/python-kasa/issues/365) +- Set bulb hue using variable [\#361](https://github.com/python-kasa/python-kasa/issues/361) +- Help with SmartLightStrip set\_custom\_effect [\#360](https://github.com/python-kasa/python-kasa/issues/360) +- Import "kasa" could not be resolved [\#357](https://github.com/python-kasa/python-kasa/issues/357) +- Wall switch ES20M \(--type dimmer\) is working [\#353](https://github.com/python-kasa/python-kasa/issues/353) +- HS107 reports `state` not `relay_state` throwing a `KeyError` [\#349](https://github.com/python-kasa/python-kasa/issues/349) +- Error Installing On Windows 10 [\#347](https://github.com/python-kasa/python-kasa/issues/347) +- Error using Kasa [\#346](https://github.com/python-kasa/python-kasa/issues/346) +- KS220M\(US\) support [\#268](https://github.com/python-kasa/python-kasa/issues/268) +- Add machine-readable output [\#209](https://github.com/python-kasa/python-kasa/issues/209) +- Can we donate? [\#77](https://github.com/python-kasa/python-kasa/issues/77) + +**Merged pull requests:** + +- Some release preparation janitoring [\#432](https://github.com/python-kasa/python-kasa/pull/432) (@rytilahti) +- Bump certifi from 2021.10.8 to 2022.12.7 [\#409](https://github.com/python-kasa/python-kasa/pull/409) (@dependabot[bot]) +- Add FUNDING.yml [\#402](https://github.com/python-kasa/python-kasa/pull/402) (@rytilahti) +- Update pre-commit hooks [\#401](https://github.com/python-kasa/python-kasa/pull/401) (@rytilahti) +- Update pre-commit url for flake8 [\#400](https://github.com/python-kasa/python-kasa/pull/400) (@rytilahti) +- Added .gitattributes file to retain LF only EOL markers when checking out on Windows [\#399](https://github.com/python-kasa/python-kasa/pull/399) (@jules43) +- Fix pytest warnings about asyncio [\#397](https://github.com/python-kasa/python-kasa/pull/397) (@jules43) +- Fix type hinting issue with call to click.Choice [\#387](https://github.com/python-kasa/python-kasa/pull/387) (@jules43) +- Manually pass the codecov token in CI [\#378](https://github.com/python-kasa/python-kasa/pull/378) (@rytilahti) +- Correct typos in smartdevice.py [\#358](https://github.com/python-kasa/python-kasa/pull/358) (@felixonmars) +- Add fixtures for KS200M [\#356](https://github.com/python-kasa/python-kasa/pull/356) (@gritstub) +- Add fixtures for KS230 [\#355](https://github.com/python-kasa/python-kasa/pull/355) (@gritstub) +- Add fixtures for ES20M \(\#353\) [\#354](https://github.com/python-kasa/python-kasa/pull/354) (@gritstub) +- Add fixtures for KP100 [\#343](https://github.com/python-kasa/python-kasa/pull/343) (@bdraco) +- Add codeql checks [\#338](https://github.com/python-kasa/python-kasa/pull/338) (@rytilahti) + ## [0.5.0](https://github.com/python-kasa/python-kasa/tree/0.5.0) (2022-04-24) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.3...0.5.0) @@ -56,6 +148,7 @@ Pull requests improving the functionality of modules as well as adding better in **Merged pull requests:** +- Prepare 0.5.0 [\#342](https://github.com/python-kasa/python-kasa/pull/342) (@rytilahti) - Add fixtures for kl420 [\#339](https://github.com/python-kasa/python-kasa/pull/339) (@bdraco) ## [0.4.3](https://github.com/python-kasa/python-kasa/tree/0.4.3) (2022-04-05) diff --git a/README.md b/README.md index f56cdc9d3..57789630f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,15 @@ -# python-kasa +

python-kasa

[![PyPI version](https://badge.fury.io/py/python-kasa.svg)](https://badge.fury.io/py/python-kasa) [![Build Status](https://github.com/python-kasa/python-kasa/actions/workflows/ci.yml/badge.svg)](https://github.com/python-kasa/python-kasa/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/python-kasa/python-kasa/branch/master/graph/badge.svg?token=5K7rtN5OmS)](https://codecov.io/gh/python-kasa/python-kasa) [![Documentation Status](https://readthedocs.org/projects/python-kasa/badge/?version=latest)](https://python-kasa.readthedocs.io/en/latest/?badge=latest) -python-kasa is a Python library to control TPLink smart home devices (plugs, wall switches, power strips, and bulbs) using asyncio. -This project is a maintainer-made fork of [pyHS100](https://github.com/GadgetReactor/pyHS100) project. +python-kasa is a Python library to control TPLink's kasa-branded smart home devices (plugs, wall switches, power strips, and bulbs) using asyncio. + +This is a voluntary, community-driven effort and is not affiliated, sponsored, or endorsed by TPLink. + +--- ## Getting started @@ -174,6 +177,7 @@ If your device is unlisted but working, please open a pull request to update the ### Links +* [pyHS100](https://github.com/GadgetReactor/pyHS100) provides synchronous interface and is the unmaintained predecessor of this library. * [softScheck's github contains lot of information and wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) * [TP-Link Smart Home Device Simulator](https://github.com/plasticrake/tplink-smarthome-simulator) * [Unofficial API documentation](https://github.com/plasticrake/tplink-smarthome-api) diff --git a/pyproject.toml b/pyproject.toml index 2ab5a1910..eb4c27681 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [tool.poetry] name = "python-kasa" -version = "0.5.0" +version = "0.5.1" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" -authors = ["Your Name "] +authors = ["python-kasa developers"] repository = "https://github.com/python-kasa/python-kasa" readme = "README.md" packages = [ From 4d514f983bbe7c3a060abc67bddcf6056df6320e Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 30 Mar 2023 01:53:38 +0200 Subject: [PATCH 135/892] Return result objects for cli discover and implicit 'state' (#446) This will make --json to output relevant contents even when no command is defined (i.e., when calling 'kasa --host --json' or 'kasa --target --json'. --- kasa/cli.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 355994769..48ce039c5 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -173,8 +173,7 @@ def _nop_echo(*args, **kwargs): if host is None: echo("No host name given, trying discovery..") - await ctx.invoke(discover) - return + return await ctx.invoke(discover) if type is not None: dev = TYPE_TO_CLASS[type](host) @@ -186,7 +185,7 @@ def _nop_echo(*args, **kwargs): ctx.obj = dev if ctx.invoked_subcommand is None: - await ctx.invoke(state) + return await ctx.invoke(state) @cli.group() @@ -232,18 +231,22 @@ async def discover(ctx, timeout): target = ctx.parent.params["target"] echo(f"Discovering devices on {target} for {timeout} seconds") sem = asyncio.Semaphore() + discovered = dict() async def print_discovered(dev: SmartDevice): await dev.update() async with sem: + discovered[dev.host] = dev.internal_state ctx.obj = dev await ctx.invoke(state) echo() - return await Discover.discover( + await Discover.discover( target=target, timeout=timeout, on_discovered=print_discovered ) + return discovered + async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3): """Discover a device identified by its alias.""" From 505b63dd5545123ce9e69379b90626a07f508e13 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 1 Apr 2023 16:15:58 +0200 Subject: [PATCH 136/892] Allow effect presets seen on light strips (#440) * Make hue, saturation and color_temp optional for smartbulbpresets * Adjust bulb preset attributes for effect mode * Don't send None values on save_preset * Add tests for save_preset payloads --- kasa/smartbulb.py | 19 +++++++++++++------ kasa/tests/test_bulb.py | 26 +++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index ff94e1697..ece659bea 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -30,9 +30,16 @@ class SmartBulbPreset(BaseModel): index: int brightness: int - hue: int - saturation: int - color_temp: int + + # These are not available for effect mode presets on light strips + hue: Optional[int] + saturation: Optional[int] + color_temp: Optional[int] + + # Variables for effect mode presets + custom: Optional[int] + id: Optional[str] + mode: Optional[int] class BehaviorMode(str, Enum): @@ -174,7 +181,7 @@ class SmartBulb(SmartDevice): Bulb configuration presets can be accessed using the :func:`presets` property: >>> bulb.presets - [SmartBulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700), SmartBulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0), SmartBulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0), SmartBulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0)] + [SmartBulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), SmartBulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset` instance to :func:`save_preset` method: @@ -514,7 +521,7 @@ def presets(self) -> List[SmartBulbPreset]: async def save_preset(self, preset: SmartBulbPreset): """Save a setting preset. - You can either construct a preset object manually, or pass an existing one obtained + You can either construct a preset object manually, or pass an existing one obtained using :func:`presets`. """ if len(self.presets) == 0: @@ -524,5 +531,5 @@ async def save_preset(self, preset: SmartBulbPreset): raise SmartDeviceException("Invalid preset index") return await self._query_helper( - self.LIGHT_SERVICE, "set_preferred_state", preset.dict() + self.LIGHT_SERVICE, "set_preferred_state", preset.dict(exclude_none=True) ) diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 019afaf56..f73a948b2 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -263,7 +263,7 @@ async def test_list_presets(dev: SmartBulb): @bulb async def test_modify_preset(dev: SmartBulb, mocker): - """Verify that modifying preset calls the and exceptions are raised properly.""" + """Verify that modifying preset calls the and exceptions are raised properly.""" if not dev.presets: pytest.skip("Some strips do not support presets") @@ -289,3 +289,27 @@ async def test_modify_preset(dev: SmartBulb, mocker): await dev.save_preset( SmartBulbPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) ) + + +@bulb +@pytest.mark.parametrize( + ("preset", "payload"), + [ + ( + SmartBulbPreset(index=0, hue=0, brightness=1, saturation=0), + {"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, + ), + ( + SmartBulbPreset(index=0, brightness=1, id="testid", mode=2, custom=0), + {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, + ), + ], +) +async def test_modify_preset_payloads(dev: SmartBulb, preset, payload, mocker): + """Test that modify preset payloads ignore none values.""" + if not dev.presets: + pytest.skip("Some strips do not support presets") + + query_helper = mocker.patch("kasa.SmartBulb._query_helper") + await dev.save_preset(preset) + query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload) From 233f1c9534dfff661dbe98ea4c74003c2b8a9e42 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 1 Apr 2023 17:10:46 +0200 Subject: [PATCH 137/892] Cleanup fixture filenames (#448) * Mark mocked fixtures as such * Use consistent filenames including the swver * Remove executable bit * Remove duplicate KL130(US) * Remove unnecessary mocks where we have real ones available * Fix filenames in tests --- kasa/smartbulb.py | 2 +- kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json | 0 kasa/tests/fixtures/HS100(US)_1.0.json | 47 -------- ...1.0_real.json => HS100(US)_1.0_1.2.5.json} | 0 ...2.0_real.json => HS100(US)_2.0_1.5.6.json} | 0 ...(US)_1.0.json => HS103(US)_1.0_1.5.7.json} | 0 ...(US)_2.1.json => HS103(US)_2.1_1.1.2.json} | 0 ...1.0_real.json => HS105(US)_1.0_1.5.6.json} | 0 ...US)_1.0.json => HS105(US)_1.0_mocked.json} | 0 ...1.0_real.json => HS107(US)_1.0_1.0.8.json} | 0 ...1.0_real.json => HS110(EU)_1.0_1.2.5.json} | 0 ...EU)_2.0.json => HS110(EU)_2.0_mocked.json} | 0 ...US)_1.0.json => HS110(US)_1.0_mocked.json} | 0 ...US)_1.0.json => HS200(US)_1.0_mocked.json} | 0 ...2.0_real.json => HS200(US)_2.0_1.5.7.json} | 0 ...1.0_real.json => HS210(US)_1.0_1.5.8.json} | 0 ...1.0_real.json => HS220(US)_1.0_1.5.7.json} | 0 ...US)_1.0.json => HS220(US)_1.0_mocked.json} | 0 ...(US)_2.0.json => HS220(US)_2.0_1.0.3.json} | 0 kasa/tests/fixtures/HS300(US)_1.0.json | 102 ------------------ ....0_real.json => HS300(US)_1.0_1.0.10.json} | 0 ...1.0_real.json => KL120(US)_1.0_1.8.6.json} | 0 kasa/tests/fixtures/KL130(US)_1.0.json | 93 ---------------- ...US)_1.0.json => KL430(US)_1.0_1.0.10.json} | 0 ...0(UN)_1.0.json => KL60(UN)_1.0_1.1.4.json} | 0 ...(UK)_1.0.json => KP303(UK)_1.0_1.0.3.json} | 0 ...US)_1.0.json => KP400(US)_1.0_1.0.10.json} | 0 ...US)_1.0.json => LB100(US)_1.0_mocked.json} | 0 ...US)_1.0.json => LB120(US)_1.0_mocked.json} | 0 ...US)_1.0.json => LB130(US)_1.0_mocked.json} | 0 kasa/tests/test_readme_examples.py | 14 +-- 31 files changed, 8 insertions(+), 250 deletions(-) mode change 100755 => 100644 kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json delete mode 100644 kasa/tests/fixtures/HS100(US)_1.0.json rename kasa/tests/fixtures/{HS100(US)_1.0_real.json => HS100(US)_1.0_1.2.5.json} (100%) rename kasa/tests/fixtures/{HS100(US)_2.0_real.json => HS100(US)_2.0_1.5.6.json} (100%) rename kasa/tests/fixtures/{HS103(US)_1.0.json => HS103(US)_1.0_1.5.7.json} (100%) rename kasa/tests/fixtures/{HS103(US)_2.1.json => HS103(US)_2.1_1.1.2.json} (100%) rename kasa/tests/fixtures/{HS105(US)_1.0_real.json => HS105(US)_1.0_1.5.6.json} (100%) rename kasa/tests/fixtures/{HS105(US)_1.0.json => HS105(US)_1.0_mocked.json} (100%) rename kasa/tests/fixtures/{HS107(US)_1.0_real.json => HS107(US)_1.0_1.0.8.json} (100%) rename kasa/tests/fixtures/{HS110(EU)_1.0_real.json => HS110(EU)_1.0_1.2.5.json} (100%) rename kasa/tests/fixtures/{HS110(EU)_2.0.json => HS110(EU)_2.0_mocked.json} (100%) rename kasa/tests/fixtures/{HS110(US)_1.0.json => HS110(US)_1.0_mocked.json} (100%) rename kasa/tests/fixtures/{HS200(US)_1.0.json => HS200(US)_1.0_mocked.json} (100%) rename kasa/tests/fixtures/{HS200(US)_2.0_real.json => HS200(US)_2.0_1.5.7.json} (100%) rename kasa/tests/fixtures/{HS210(US)_1.0_real.json => HS210(US)_1.0_1.5.8.json} (100%) rename kasa/tests/fixtures/{HS220(US)_1.0_real.json => HS220(US)_1.0_1.5.7.json} (100%) rename kasa/tests/fixtures/{HS220(US)_1.0.json => HS220(US)_1.0_mocked.json} (100%) rename kasa/tests/fixtures/{HS220(US)_2.0.json => HS220(US)_2.0_1.0.3.json} (100%) delete mode 100644 kasa/tests/fixtures/HS300(US)_1.0.json rename kasa/tests/fixtures/{HS300(US)_1.0_real.json => HS300(US)_1.0_1.0.10.json} (100%) rename kasa/tests/fixtures/{KL120(US)_1.0_real.json => KL120(US)_1.0_1.8.6.json} (100%) delete mode 100644 kasa/tests/fixtures/KL130(US)_1.0.json rename kasa/tests/fixtures/{KL430(US)_1.0.json => KL430(US)_1.0_1.0.10.json} (100%) rename kasa/tests/fixtures/{KL60(UN)_1.0.json => KL60(UN)_1.0_1.1.4.json} (100%) rename kasa/tests/fixtures/{KP303(UK)_1.0.json => KP303(UK)_1.0_1.0.3.json} (100%) rename kasa/tests/fixtures/{KP400(US)_1.0.json => KP400(US)_1.0_1.0.10.json} (100%) rename kasa/tests/fixtures/{LB100(US)_1.0.json => LB100(US)_1.0_mocked.json} (100%) rename kasa/tests/fixtures/{LB120(US)_1.0.json => LB120(US)_1.0_mocked.json} (100%) rename kasa/tests/fixtures/{LB130(US)_1.0.json => LB130(US)_1.0_mocked.json} (100%) diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index ece659bea..b28edab1b 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -127,7 +127,7 @@ class SmartBulb(SmartDevice): >>> bulb = SmartBulb("127.0.0.1") >>> asyncio.run(bulb.update()) >>> print(bulb.alias) - KL130 office bulb + Bulb2 Bulbs, like any other supported devices, can be turned on and off: diff --git a/kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json b/kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json old mode 100755 new mode 100644 diff --git a/kasa/tests/fixtures/HS100(US)_1.0.json b/kasa/tests/fixtures/HS100(US)_1.0.json deleted file mode 100644 index cf9b0ba0d..000000000 --- a/kasa/tests/fixtures/HS100(US)_1.0.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "emeter": { - "get_realtime": { - "err_code": -1, - "err_msg": "module not support" - } - }, - "smartlife.iot.common.emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.smartbulb.lightingservice": { - "err_code": -1, - "err_msg": "module not support" - }, - "system": { - "get_sysinfo": { - "active_mode": "schedule", - "alias": "Mock hs100", - "dev_name": "Wi-Fi Smart Plug", - "deviceId": "048F069965D3230FD1382F0B78EAE68D42CAA2DE", - "err_code": 0, - "feature": "TIM", - "hwId": "92688D028799C60F926049D1C9EFD9E8", - "hw_ver": "1.0", - "icon_hash": "", - "latitude": 63.5442, - "latitude_i": 63.5442, - "led_off": 0, - "longitude": -148.2817, - "longitude_i": -148.2817, - "mac": "50:c7:bf:a3:71:c0", - "model": "HS100(US)", - "oemId": "149C8A24AA3A1445DE84F00DFB210D60", - "on_time": 0, - "relay_state": 0, - "rssi": -65, - "sw_ver": "1.2.5 Build 171129 Rel.174814", - "type": "IOT.SMARTPLUGSWITCH", - "updating": 0 - } - } -} diff --git a/kasa/tests/fixtures/HS100(US)_1.0_real.json b/kasa/tests/fixtures/HS100(US)_1.0_1.2.5.json similarity index 100% rename from kasa/tests/fixtures/HS100(US)_1.0_real.json rename to kasa/tests/fixtures/HS100(US)_1.0_1.2.5.json diff --git a/kasa/tests/fixtures/HS100(US)_2.0_real.json b/kasa/tests/fixtures/HS100(US)_2.0_1.5.6.json similarity index 100% rename from kasa/tests/fixtures/HS100(US)_2.0_real.json rename to kasa/tests/fixtures/HS100(US)_2.0_1.5.6.json diff --git a/kasa/tests/fixtures/HS103(US)_1.0.json b/kasa/tests/fixtures/HS103(US)_1.0_1.5.7.json similarity index 100% rename from kasa/tests/fixtures/HS103(US)_1.0.json rename to kasa/tests/fixtures/HS103(US)_1.0_1.5.7.json diff --git a/kasa/tests/fixtures/HS103(US)_2.1.json b/kasa/tests/fixtures/HS103(US)_2.1_1.1.2.json similarity index 100% rename from kasa/tests/fixtures/HS103(US)_2.1.json rename to kasa/tests/fixtures/HS103(US)_2.1_1.1.2.json diff --git a/kasa/tests/fixtures/HS105(US)_1.0_real.json b/kasa/tests/fixtures/HS105(US)_1.0_1.5.6.json similarity index 100% rename from kasa/tests/fixtures/HS105(US)_1.0_real.json rename to kasa/tests/fixtures/HS105(US)_1.0_1.5.6.json diff --git a/kasa/tests/fixtures/HS105(US)_1.0.json b/kasa/tests/fixtures/HS105(US)_1.0_mocked.json similarity index 100% rename from kasa/tests/fixtures/HS105(US)_1.0.json rename to kasa/tests/fixtures/HS105(US)_1.0_mocked.json diff --git a/kasa/tests/fixtures/HS107(US)_1.0_real.json b/kasa/tests/fixtures/HS107(US)_1.0_1.0.8.json similarity index 100% rename from kasa/tests/fixtures/HS107(US)_1.0_real.json rename to kasa/tests/fixtures/HS107(US)_1.0_1.0.8.json diff --git a/kasa/tests/fixtures/HS110(EU)_1.0_real.json b/kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.json similarity index 100% rename from kasa/tests/fixtures/HS110(EU)_1.0_real.json rename to kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.json diff --git a/kasa/tests/fixtures/HS110(EU)_2.0.json b/kasa/tests/fixtures/HS110(EU)_2.0_mocked.json similarity index 100% rename from kasa/tests/fixtures/HS110(EU)_2.0.json rename to kasa/tests/fixtures/HS110(EU)_2.0_mocked.json diff --git a/kasa/tests/fixtures/HS110(US)_1.0.json b/kasa/tests/fixtures/HS110(US)_1.0_mocked.json similarity index 100% rename from kasa/tests/fixtures/HS110(US)_1.0.json rename to kasa/tests/fixtures/HS110(US)_1.0_mocked.json diff --git a/kasa/tests/fixtures/HS200(US)_1.0.json b/kasa/tests/fixtures/HS200(US)_1.0_mocked.json similarity index 100% rename from kasa/tests/fixtures/HS200(US)_1.0.json rename to kasa/tests/fixtures/HS200(US)_1.0_mocked.json diff --git a/kasa/tests/fixtures/HS200(US)_2.0_real.json b/kasa/tests/fixtures/HS200(US)_2.0_1.5.7.json similarity index 100% rename from kasa/tests/fixtures/HS200(US)_2.0_real.json rename to kasa/tests/fixtures/HS200(US)_2.0_1.5.7.json diff --git a/kasa/tests/fixtures/HS210(US)_1.0_real.json b/kasa/tests/fixtures/HS210(US)_1.0_1.5.8.json similarity index 100% rename from kasa/tests/fixtures/HS210(US)_1.0_real.json rename to kasa/tests/fixtures/HS210(US)_1.0_1.5.8.json diff --git a/kasa/tests/fixtures/HS220(US)_1.0_real.json b/kasa/tests/fixtures/HS220(US)_1.0_1.5.7.json similarity index 100% rename from kasa/tests/fixtures/HS220(US)_1.0_real.json rename to kasa/tests/fixtures/HS220(US)_1.0_1.5.7.json diff --git a/kasa/tests/fixtures/HS220(US)_1.0.json b/kasa/tests/fixtures/HS220(US)_1.0_mocked.json similarity index 100% rename from kasa/tests/fixtures/HS220(US)_1.0.json rename to kasa/tests/fixtures/HS220(US)_1.0_mocked.json diff --git a/kasa/tests/fixtures/HS220(US)_2.0.json b/kasa/tests/fixtures/HS220(US)_2.0_1.0.3.json similarity index 100% rename from kasa/tests/fixtures/HS220(US)_2.0.json rename to kasa/tests/fixtures/HS220(US)_2.0_1.0.3.json diff --git a/kasa/tests/fixtures/HS300(US)_1.0.json b/kasa/tests/fixtures/HS300(US)_1.0.json deleted file mode 100644 index 997919c17..000000000 --- a/kasa/tests/fixtures/HS300(US)_1.0.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "emeter": { - "get_realtime": { - "current_ma": 125, - "err_code": 0, - "power_mw": 3140, - "total_wh": 51493, - "voltage_mv": 122049 - } - }, - "smartlife.iot.common.emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.smartbulb.lightingservice": { - "err_code": -1, - "err_msg": "module not support" - }, - "system": { - "get_sysinfo": { - "alias": "Mock hs300", - "child_num": 6, - "children": [ - { - "alias": "Mock One", - "id": "00", - "next_action": { - "type": -1 - }, - "on_time": 123, - "state": 1 - }, - { - "alias": "Mock Two", - "id": "01", - "next_action": { - "type": -1 - }, - "on_time": 333, - "state": 1 - }, - { - "alias": "Mock Three", - "id": "02", - "next_action": { - "type": -1 - }, - "on_time": 0, - "state": 0 - }, - { - "alias": "Mock Four", - "id": "03", - "next_action": { - "type": -1 - }, - "on_time": 0, - "state": 0 - }, - { - "alias": "Mock Five", - "id": "04", - "next_action": { - "type": -1 - }, - "on_time": 0, - "state": 0 - }, - { - "alias": "Mock Six", - "id": "05", - "next_action": { - "type": -1 - }, - "on_time": 0, - "state": 0 - } - ], - "deviceId": "4BFC2F2C8678FE623700FD3737EC4E245196F3CF", - "err_code": 0, - "feature": "TIM:ENE", - "hwId": "1B63E5DF21B5AFB52F364DE66BFAAF8A", - "hw_ver": "1.0", - "latitude": -68.9980, - "latitude_i": -68.9980, - "led_off": 0, - "longitude": -109.4400, - "longitude_i": -109.4400, - "mac": "50:c7:bf:c2:75:88", - "mic_type": "IOT.SMARTPLUGSWITCH", - "model": "HS300(US)", - "oemId": "FC71DAAB004326F9369EDEF4353E4FE1", - "rssi": -68, - "sw_ver": "1.0.6 Build 180627 Rel.081000", - "updating": 0 - } - } -} diff --git a/kasa/tests/fixtures/HS300(US)_1.0_real.json b/kasa/tests/fixtures/HS300(US)_1.0_1.0.10.json similarity index 100% rename from kasa/tests/fixtures/HS300(US)_1.0_real.json rename to kasa/tests/fixtures/HS300(US)_1.0_1.0.10.json diff --git a/kasa/tests/fixtures/KL120(US)_1.0_real.json b/kasa/tests/fixtures/KL120(US)_1.0_1.8.6.json similarity index 100% rename from kasa/tests/fixtures/KL120(US)_1.0_real.json rename to kasa/tests/fixtures/KL120(US)_1.0_1.8.6.json diff --git a/kasa/tests/fixtures/KL130(US)_1.0.json b/kasa/tests/fixtures/KL130(US)_1.0.json deleted file mode 100644 index b07044a65..000000000 --- a/kasa/tests/fixtures/KL130(US)_1.0.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "emeter": { - "err_code": -2001, - "err_msg": "Module not support" - }, - "smartlife.iot.common.emeter": { - "get_realtime": { - "err_code": 0, - "power_mw": 800 - } - }, - "smartlife.iot.dimmer": { - "err_code": -2001, - "err_msg": "Module not support" - }, - "smartlife.iot.smartbulb.lightingservice": { - "get_light_state": { - "brightness": 0, - "color_temp": 0, - "err_code": 0, - "hue": 15, - "mode": "normal", - "on_off": 1, - "saturation": 100 - } - }, - "system": { - "get_sysinfo": { - "active_mode": "none", - "alias": "KL130 office bulb", - "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, - "heapsize": 306332, - "hwId": "00000000000000000000000000000000", - "hw_ver": "1.0", - "is_color": 1, - "is_dimmable": 1, - "is_factory": false, - "is_variable_color_temp": 1, - "light_state": { - "brightness": 30, - "color_temp": 0, - "hue": 15, - "mode": "normal", - "on_off": 1, - "saturation": 100 - }, - "mic_mac": "1C3BF373CA4E", - "mic_type": "IOT.SMARTBULB", - "model": "KL130(US)", - "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": 75 - }, - { - "brightness": 100, - "color_temp": 0, - "hue": 120, - "index": 2, - "saturation": 75 - }, - { - "brightness": 100, - "color_temp": 0, - "hue": 240, - "index": 3, - "saturation": 75 - } - ], - "rssi": -62, - "sw_ver": "1.8.11 Build 191113 Rel.105336" - } - } -} diff --git a/kasa/tests/fixtures/KL430(US)_1.0.json b/kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json similarity index 100% rename from kasa/tests/fixtures/KL430(US)_1.0.json rename to kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json diff --git a/kasa/tests/fixtures/KL60(UN)_1.0.json b/kasa/tests/fixtures/KL60(UN)_1.0_1.1.4.json similarity index 100% rename from kasa/tests/fixtures/KL60(UN)_1.0.json rename to kasa/tests/fixtures/KL60(UN)_1.0_1.1.4.json diff --git a/kasa/tests/fixtures/KP303(UK)_1.0.json b/kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.json similarity index 100% rename from kasa/tests/fixtures/KP303(UK)_1.0.json rename to kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.json diff --git a/kasa/tests/fixtures/KP400(US)_1.0.json b/kasa/tests/fixtures/KP400(US)_1.0_1.0.10.json similarity index 100% rename from kasa/tests/fixtures/KP400(US)_1.0.json rename to kasa/tests/fixtures/KP400(US)_1.0_1.0.10.json diff --git a/kasa/tests/fixtures/LB100(US)_1.0.json b/kasa/tests/fixtures/LB100(US)_1.0_mocked.json similarity index 100% rename from kasa/tests/fixtures/LB100(US)_1.0.json rename to kasa/tests/fixtures/LB100(US)_1.0_mocked.json diff --git a/kasa/tests/fixtures/LB120(US)_1.0.json b/kasa/tests/fixtures/LB120(US)_1.0_mocked.json similarity index 100% rename from kasa/tests/fixtures/LB120(US)_1.0.json rename to kasa/tests/fixtures/LB120(US)_1.0_mocked.json diff --git a/kasa/tests/fixtures/LB130(US)_1.0.json b/kasa/tests/fixtures/LB130(US)_1.0_mocked.json similarity index 100% rename from kasa/tests/fixtures/LB130(US)_1.0.json rename to kasa/tests/fixtures/LB130(US)_1.0_mocked.json diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index a64c824c1..f3c9efdd9 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -9,7 +9,7 @@ def test_bulb_examples(mocker): """Use KL130 (bulb with all features) to test the doctests.""" - p = asyncio.run(get_device_for_file("KL130(US)_1.0.json")) + p = asyncio.run(get_device_for_file("KL130(US)_1.0_1.8.11.json")) mocker.patch("kasa.smartbulb.SmartBulb", return_value=p) mocker.patch("kasa.smartbulb.SmartBulb.update") res = xdoctest.doctest_module("kasa.smartbulb", "all") @@ -18,7 +18,7 @@ def test_bulb_examples(mocker): def test_smartdevice_examples(mocker): """Use HS110 for emeter examples.""" - p = asyncio.run(get_device_for_file("HS110(EU)_1.0_real.json")) + p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json")) mocker.patch("kasa.smartdevice.SmartDevice", return_value=p) mocker.patch("kasa.smartdevice.SmartDevice.update") res = xdoctest.doctest_module("kasa.smartdevice", "all") @@ -27,7 +27,7 @@ def test_smartdevice_examples(mocker): def test_plug_examples(mocker): """Test plug examples.""" - p = asyncio.run(get_device_for_file("HS110(EU)_1.0_real.json")) + p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json")) mocker.patch("kasa.smartplug.SmartPlug", return_value=p) mocker.patch("kasa.smartplug.SmartPlug.update") res = xdoctest.doctest_module("kasa.smartplug", "all") @@ -36,7 +36,7 @@ def test_plug_examples(mocker): def test_strip_examples(mocker): """Test strip examples.""" - p = asyncio.run(get_device_for_file("KP303(UK)_1.0.json")) + p = asyncio.run(get_device_for_file("KP303(UK)_1.0_1.0.3.json")) mocker.patch("kasa.smartstrip.SmartStrip", return_value=p) mocker.patch("kasa.smartstrip.SmartStrip.update") res = xdoctest.doctest_module("kasa.smartstrip", "all") @@ -45,7 +45,7 @@ def test_strip_examples(mocker): def test_dimmer_examples(mocker): """Test dimmer examples.""" - p = asyncio.run(get_device_for_file("HS220(US)_1.0_real.json")) + p = asyncio.run(get_device_for_file("HS220(US)_1.0_1.5.7.json")) mocker.patch("kasa.smartdimmer.SmartDimmer", return_value=p) mocker.patch("kasa.smartdimmer.SmartDimmer.update") res = xdoctest.doctest_module("kasa.smartdimmer", "all") @@ -54,7 +54,7 @@ def test_dimmer_examples(mocker): def test_lightstrip_examples(mocker): """Test lightstrip examples.""" - p = asyncio.run(get_device_for_file("KL430(US)_1.0.json")) + p = asyncio.run(get_device_for_file("KL430(US)_1.0_1.0.10.json")) mocker.patch("kasa.smartlightstrip.SmartLightStrip", return_value=p) mocker.patch("kasa.smartlightstrip.SmartLightStrip.update") res = xdoctest.doctest_module("kasa.smartlightstrip", "all") @@ -66,7 +66,7 @@ def test_lightstrip_examples(mocker): ) def test_discovery_examples(mocker): """Test discovery examples.""" - p = asyncio.run(get_device_for_file("KP303(UK)_1.0.json")) + p = asyncio.run(get_device_for_file("KP303(UK)_1.0_1.0.3.json")) # This succeeds on python 3.8 but fails on 3.7 # ValueError: a coroutine was expected, got [ Date: Wed, 17 May 2023 19:15:52 +0200 Subject: [PATCH 138/892] Update dependencies to fix CI (#454) * Update dependencies * Update pre-commit dependencies --- .pre-commit-config.yaml | 6 +- poetry.lock | 602 +++++++++++++++++++--------------------- 2 files changed, 289 insertions(+), 319 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d09e3d543..ba59069dc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,13 +10,13 @@ repos: - id: check-ast - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 + rev: v3.4.0 hooks: - id: pyupgrade args: ['--py37-plus'] - repo: https://github.com/python/black - rev: 23.1.0 + rev: 23.3.0 hooks: - id: black @@ -33,7 +33,7 @@ repos: additional_dependencies: [toml] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.0.1 + rev: v1.3.0 hooks: - id: mypy additional_dependencies: [types-click] diff --git a/poetry.lock b/poetry.lock index 9bff1e2b8..f52926e2e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -36,32 +36,16 @@ python-versions = ">=3.7" colorama = {version = "*", markers = "platform_system == \"Windows\""} importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} -[[package]] -name = "attrs" -version = "22.2.0" -description = "Classes Without Boilerplate" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.extras] -cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] -dev = ["attrs[docs,tests]"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] -tests = ["attrs[tests-no-zope]", "zope.interface"] -tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=0.971,<0.990)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests_no_zope = ["cloudpickle", "hypothesis", "mypy (>=0.971,<0.990)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] - [[package]] name = "Babel" -version = "2.11.0" +version = "2.12.1" description = "Internationalization utilities" category = "main" optional = true -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] -pytz = ">=2015.7" +pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} [[package]] name = "cachetools" @@ -73,7 +57,7 @@ python-versions = "~=3.7" [[package]] name = "certifi" -version = "2022.12.7" +version = "2023.5.7" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false @@ -97,15 +81,15 @@ python-versions = ">=3.7" [[package]] name = "charset-normalizer" -version = "3.0.1" +version = "3.1.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false -python-versions = "*" +python-versions = ">=3.7.0" [[package]] name = "codecov" -version = "2.1.12" +version = "2.1.13" description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" category = "dev" optional = false @@ -125,7 +109,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7 [[package]] name = "coverage" -version = "7.1.0" +version = "7.2.5" description = "Code coverage measurement for Python" category = "dev" optional = false @@ -155,7 +139,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "exceptiongroup" -version = "1.1.0" +version = "1.1.1" description = "Backport of PEP 654 (exception groups)" category = "dev" optional = false @@ -166,19 +150,19 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.9.0" +version = "3.12.0" description = "A platform independent file lock." category = "dev" optional = false python-versions = ">=3.7" [package.extras] -docs = ["furo (>=2022.12.7)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] -testing = ["covdefaults (>=2.2.2)", "coverage (>=7.0.1)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-timeout (>=2.1)"] +docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] [[package]] name = "identify" -version = "2.5.18" +version = "2.5.24" description = "File identification library for Python" category = "dev" optional = false @@ -205,7 +189,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "6.0.0" +version = "6.6.0" description = "Read metadata from Python packages" category = "main" optional = false @@ -244,7 +228,7 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "markdown-it-py" -version = "2.1.0" +version = "2.2.0" description = "Python port of markdown-it. Markdown parsing, done right!" category = "main" optional = true @@ -255,10 +239,10 @@ mdurl = ">=0.1,<1.0" typing_extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} [package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark (>=3.2,<4.0)"] -code_style = ["pre-commit (==2.6)"] -compare = ["commonmark (>=0.9.1,<0.10.0)", "markdown (>=3.3.6,<3.4.0)", "mistletoe (>=0.8.1,<0.9.0)", "mistune (>=2.0.2,<2.1.0)", "panflute (>=2.1.3,<2.2.0)"] -linkify = ["linkify-it-py (>=1.0,<2.0)"] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code_style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] plugins = ["mdit-py-plugins"] profiling = ["gprof2dot"] rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] @@ -274,7 +258,7 @@ python-versions = ">=3.7" [[package]] name = "mdit-py-plugins" -version = "0.3.4" +version = "0.3.5" description = "Collection of plugins for markdown-it-py" category = "main" optional = true @@ -321,7 +305,7 @@ testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=6,<7)", "pytest-cov", [[package]] name = "nodeenv" -version = "1.7.0" +version = "1.8.0" description = "Node.js virtual environment builder" category = "dev" optional = false @@ -332,7 +316,7 @@ setuptools = "*" [[package]] name = "packaging" -version = "23.0" +version = "23.1" description = "Core utilities for Python packages" category = "main" optional = false @@ -340,18 +324,18 @@ python-versions = ">=3.7" [[package]] name = "platformdirs" -version = "3.0.0" +version = "3.5.1" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false python-versions = ">=3.7" [package.dependencies] -typing-extensions = {version = ">=4.4", markers = "python_version < \"3.8\""} +typing-extensions = {version = ">=4.5", markers = "python_version < \"3.8\""} [package.extras] -docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] +docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.2.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] [[package]] name = "pluggy" @@ -386,7 +370,7 @@ virtualenv = ">=20.10.0" [[package]] name = "pydantic" -version = "1.10.5" +version = "1.10.7" description = "Data validation and settings management using python type hints" category = "main" optional = false @@ -401,41 +385,40 @@ email = ["email-validator (>=1.0.3)"] [[package]] name = "Pygments" -version = "2.14.0" +version = "2.15.1" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = true -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] plugins = ["importlib-metadata"] [[package]] name = "pyproject-api" -version = "1.5.0" +version = "1.5.1" description = "API to interact with the python pyproject.toml based projects" category = "dev" optional = false python-versions = ">=3.7" [package.dependencies] -packaging = ">=21.3" +packaging = ">=23" tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} [package.extras] -docs = ["furo (>=2022.9.29)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] -testing = ["covdefaults (>=2.2.2)", "importlib-metadata (>=5.1)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "virtualenv (>=20.17)", "wheel (>=0.38.4)"] +docs = ["furo (>=2022.12.7)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] +testing = ["covdefaults (>=2.2.2)", "importlib-metadata (>=6)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "virtualenv (>=20.17.1)", "wheel (>=0.38.4)"] [[package]] name = "pytest" -version = "7.2.1" +version = "7.3.1" description = "pytest: simple powerful testing with Python" category = "dev" optional = false python-versions = ">=3.7" [package.dependencies] -attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} @@ -445,18 +428,18 @@ pluggy = ">=0.12,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.20.3" +version = "0.21.0" description = "Pytest support for asyncio" category = "dev" optional = false python-versions = ">=3.7" [package.dependencies] -pytest = ">=6.1.0" +pytest = ">=7.0.0" typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} [package.extras] @@ -494,20 +477,23 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] name = "pytest-sugar" -version = "0.9.6" +version = "0.9.7" description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." category = "dev" optional = false python-versions = "*" [package.dependencies] -packaging = ">=14.1" -pytest = ">=2.9" -termcolor = ">=1.1.0" +packaging = ">=21.3" +pytest = ">=6.2.0" +termcolor = ">=2.1.0" + +[package.extras] +dev = ["black", "flake8", "pre-commit"] [[package]] name = "pytz" -version = "2022.7.1" +version = "2023.3" description = "World timezone definitions, modern and historical" category = "main" optional = true @@ -523,17 +509,17 @@ python-versions = ">=3.6" [[package]] name = "requests" -version = "2.28.2" +version = "2.30.0" description = "Python HTTP for Humans." category = "main" optional = false -python-versions = ">=3.7, <4" +python-versions = ">=3.7" [package.dependencies] certifi = ">=2017.4.17" charset-normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<1.27" +urllib3 = ">=1.21.1,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] @@ -541,7 +527,7 @@ use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "setuptools" -version = "67.3.2" +version = "67.7.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" category = "dev" optional = false @@ -706,7 +692,7 @@ test = ["pytest"] [[package]] name = "termcolor" -version = "2.2.0" +version = "2.3.0" description = "ANSI color formatting for output in terminal" category = "dev" optional = false @@ -733,7 +719,7 @@ python-versions = ">=3.7" [[package]] name = "tox" -version = "4.4.5" +version = "4.5.1" description = "tox is a generic virtualenv management and test command line tool" category = "dev" optional = false @@ -743,19 +729,19 @@ python-versions = ">=3.7" cachetools = ">=5.3" chardet = ">=5.1" colorama = ">=0.4.6" -filelock = ">=3.9" -importlib-metadata = {version = ">=6", markers = "python_version < \"3.8\""} -packaging = ">=23" -platformdirs = ">=2.6.2" +filelock = ">=3.11" +importlib-metadata = {version = ">=6.4.1", markers = "python_version < \"3.8\""} +packaging = ">=23.1" +platformdirs = ">=3.2" pluggy = ">=1" -pyproject-api = ">=1.5" +pyproject-api = ">=1.5.1" tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.4", markers = "python_version < \"3.8\""} -virtualenv = ">=20.17.1" +typing-extensions = {version = ">=4.5", markers = "python_version < \"3.8\""} +virtualenv = ">=20.21" [package.extras] -docs = ["furo (>=2022.12.7)", "sphinx (>=6.1.3)", "sphinx-argparse-cli (>=1.11)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)", "sphinx-copybutton (>=0.5.1)", "sphinx-inline-tabs (>=2022.1.2b11)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] -testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.2.2)", "devpi-process (>=0.3)", "diff-cover (>=7.4)", "distlib (>=0.3.6)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.12.2)", "psutil (>=5.9.4)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-xdist (>=3.1)", "re-assert (>=1.1)", "time-machine (>=2.9)", "wheel (>=0.38.4)"] +docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-argparse-cli (>=1.11)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2022.1.2b11)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] +testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "devpi-process (>=0.3)", "diff-cover (>=7.5)", "distlib (>=0.3.6)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.14)", "psutil (>=5.9.4)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-xdist (>=3.2.1)", "re-assert (>=1.1)", "time-machine (>=2.9)", "wheel (>=0.40)"] [[package]] name = "typing-extensions" @@ -767,20 +753,21 @@ python-versions = ">=3.7" [[package]] name = "urllib3" -version = "1.26.14" +version = "2.0.2" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=3.7" [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.19.0" +version = "20.23.0" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -788,13 +775,13 @@ python-versions = ">=3.7" [package.dependencies] distlib = ">=0.3.6,<1" -filelock = ">=3.4.1,<4" -importlib-metadata = {version = ">=4.8.3", markers = "python_version < \"3.8\""} -platformdirs = ">=2.4,<4" +filelock = ">=3.11,<4" +importlib-metadata = {version = ">=6.4.1", markers = "python_version < \"3.8\""} +platformdirs = ">=3.2,<4" [package.extras] -docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] -test = ["covdefaults (>=2.2.2)", "coverage (>=7.1)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23)", "pytest (>=7.2.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)"] +docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.7.1)", "time-machine (>=2.9)"] [[package]] name = "voluptuous" @@ -830,7 +817,7 @@ tests-strict = ["codecov (==2.0.15)", "pytest (==4.6.0)", "pytest (==4.6.0)", "p [[package]] name = "zipp" -version = "3.14.0" +version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false @@ -838,7 +825,7 @@ python-versions = ">=3.7" [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [extras] docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] @@ -861,21 +848,17 @@ asyncclick = [ {file = "asyncclick-8.1.3.4-py3-none-any.whl", hash = "sha256:f8db604e37dabd43922d58f857817b1dfd8f88695b75c4cc1afe7ff1cc238a7b"}, {file = "asyncclick-8.1.3.4.tar.gz", hash = "sha256:81d98cbf6c8813f9cd5599f586d56cfc532e9e6441391974d10827abb90fe833"}, ] -attrs = [ - {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, - {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, -] Babel = [ - {file = "Babel-2.11.0-py3-none-any.whl", hash = "sha256:1ad3eca1c885218f6dce2ab67291178944f810a10a9b5f3cb8382a5a232b64fe"}, - {file = "Babel-2.11.0.tar.gz", hash = "sha256:5ef4b3226b0180dedded4229651c8b0e1a3a6a2837d45a073272f313e4cf97f6"}, + {file = "Babel-2.12.1-py3-none-any.whl", hash = "sha256:b4246fb7677d3b98f501a39d43396d3cafdc8eadb045f4a31be01863f655c610"}, + {file = "Babel-2.12.1.tar.gz", hash = "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455"}, ] cachetools = [ {file = "cachetools-5.3.0-py3-none-any.whl", hash = "sha256:429e1a1e845c008ea6c85aa35d4b98b65d6a9763eeef3e37e92728a12d1de9d4"}, {file = "cachetools-5.3.0.tar.gz", hash = "sha256:13dfddc7b8df938c21a940dfa6557ce6e94a2f1cdfa58eb90c805721d58f2c14"}, ] certifi = [ - {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, - {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, + {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, + {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, ] cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, @@ -886,155 +869,142 @@ chardet = [ {file = "chardet-5.1.0.tar.gz", hash = "sha256:0d62712b956bc154f85fb0a266e2a3c5913c2967e00348701b32411d6def31e5"}, ] charset-normalizer = [ - {file = "charset-normalizer-3.0.1.tar.gz", hash = "sha256:ebea339af930f8ca5d7a699b921106c6e29c617fe9606fa7baa043c1cdae326f"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88600c72ef7587fe1708fd242b385b6ed4b8904976d5da0893e31df8b3480cb6"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c75ffc45f25324e68ab238cb4b5c0a38cd1c3d7f1fb1f72b5541de469e2247db"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db72b07027db150f468fbada4d85b3b2729a3db39178abf5c543b784c1254539"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62595ab75873d50d57323a91dd03e6966eb79c41fa834b7a1661ed043b2d404d"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff6f3db31555657f3163b15a6b7c6938d08df7adbfc9dd13d9d19edad678f1e8"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:772b87914ff1152b92a197ef4ea40efe27a378606c39446ded52c8f80f79702e"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70990b9c51340e4044cfc394a81f614f3f90d41397104d226f21e66de668730d"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:292d5e8ba896bbfd6334b096e34bffb56161c81408d6d036a7dfa6929cff8783"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2edb64ee7bf1ed524a1da60cdcd2e1f6e2b4f66ef7c077680739f1641f62f555"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:31a9ddf4718d10ae04d9b18801bd776693487cbb57d74cc3458a7673f6f34639"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:44ba614de5361b3e5278e1241fda3dc1838deed864b50a10d7ce92983797fa76"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:12db3b2c533c23ab812c2b25934f60383361f8a376ae272665f8e48b88e8e1c6"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c512accbd6ff0270939b9ac214b84fb5ada5f0409c44298361b2f5e13f9aed9e"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-win32.whl", hash = "sha256:502218f52498a36d6bf5ea77081844017bf7982cdbe521ad85e64cabee1b608b"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:601f36512f9e28f029d9481bdaf8e89e5148ac5d89cffd3b05cd533eeb423b59"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0298eafff88c99982a4cf66ba2efa1128e4ddaca0b05eec4c456bbc7db691d8d"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a8d0fc946c784ff7f7c3742310cc8a57c5c6dc31631269876a88b809dbeff3d3"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:87701167f2a5c930b403e9756fab1d31d4d4da52856143b609e30a1ce7160f3c"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e76c0f23218b8f46c4d87018ca2e441535aed3632ca134b10239dfb6dadd6b"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0a590235ccd933d9892c627dec5bc7511ce6ad6c1011fdf5b11363022746c1"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c7fe7afa480e3e82eed58e0ca89f751cd14d767638e2550c77a92a9e749c317"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79909e27e8e4fcc9db4addea88aa63f6423ebb171db091fb4373e3312cb6d603"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7b6a045b814cf0c47f3623d21ebd88b3e8cf216a14790b455ea7ff0135d18"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:72966d1b297c741541ca8cf1223ff262a6febe52481af742036a0b296e35fa5a"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f9d0c5c045a3ca9bedfc35dca8526798eb91a07aa7a2c0fee134c6c6f321cbd7"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5995f0164fa7df59db4746112fec3f49c461dd6b31b841873443bdb077c13cfc"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4a8fcf28c05c1f6d7e177a9a46a1c52798bfe2ad80681d275b10dcf317deaf0b"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:761e8904c07ad053d285670f36dd94e1b6ab7f16ce62b9805c475b7aa1cffde6"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-win32.whl", hash = "sha256:71140351489970dfe5e60fc621ada3e0f41104a5eddaca47a7acb3c1b851d6d3"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ab77acb98eba3fd2a85cd160851816bfce6871d944d885febf012713f06659c"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:84c3990934bae40ea69a82034912ffe5a62c60bbf6ec5bc9691419641d7d5c9a"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74292fc76c905c0ef095fe11e188a32ebd03bc38f3f3e9bcb85e4e6db177b7ea"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c95a03c79bbe30eec3ec2b7f076074f4281526724c8685a42872974ef4d36b72"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c39b0e3eac288fedc2b43055cfc2ca7a60362d0e5e87a637beac5d801ef478"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df2c707231459e8a4028eabcd3cfc827befd635b3ef72eada84ab13b52e1574d"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93ad6d87ac18e2a90b0fe89df7c65263b9a99a0eb98f0a3d2e079f12a0735837"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:59e5686dd847347e55dffcc191a96622f016bc0ad89105e24c14e0d6305acbc6"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:cd6056167405314a4dc3c173943f11249fa0f1b204f8b51ed4bde1a9cd1834dc"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:083c8d17153ecb403e5e1eb76a7ef4babfc2c48d58899c98fcaa04833e7a2f9a"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:f5057856d21e7586765171eac8b9fc3f7d44ef39425f85dbcccb13b3ebea806c"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:7eb33a30d75562222b64f569c642ff3dc6689e09adda43a082208397f016c39a"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-win32.whl", hash = "sha256:95dea361dd73757c6f1c0a1480ac499952c16ac83f7f5f4f84f0658a01b8ef41"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:eaa379fcd227ca235d04152ca6704c7cb55564116f8bc52545ff357628e10602"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3e45867f1f2ab0711d60c6c71746ac53537f1684baa699f4f668d4c6f6ce8e14"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cadaeaba78750d58d3cc6ac4d1fd867da6fc73c88156b7a3212a3cd4819d679d"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:911d8a40b2bef5b8bbae2e36a0b103f142ac53557ab421dc16ac4aafee6f53dc"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:503e65837c71b875ecdd733877d852adbc465bd82c768a067badd953bf1bc5a3"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a60332922359f920193b1d4826953c507a877b523b2395ad7bc716ddd386d866"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16a8663d6e281208d78806dbe14ee9903715361cf81f6d4309944e4d1e59ac5b"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a16418ecf1329f71df119e8a65f3aa68004a3f9383821edcb20f0702934d8087"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d9153257a3f70d5f69edf2325357251ed20f772b12e593f3b3377b5f78e7ef8"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:02a51034802cbf38db3f89c66fb5d2ec57e6fe7ef2f4a44d070a593c3688667b"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:2e396d70bc4ef5325b72b593a72c8979999aa52fb8bcf03f701c1b03e1166918"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:11b53acf2411c3b09e6af37e4b9005cba376c872503c8f28218c7243582df45d"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-win32.whl", hash = "sha256:0bf2dae5291758b6f84cf923bfaa285632816007db0330002fa1de38bfcb7154"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:2c03cc56021a4bd59be889c2b9257dae13bf55041a3372d3295416f86b295fb5"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:024e606be3ed92216e2b6952ed859d86b4cfa52cd5bc5f050e7dc28f9b43ec42"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4b0d02d7102dd0f997580b51edc4cebcf2ab6397a7edf89f1c73b586c614272c"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:358a7c4cb8ba9b46c453b1dd8d9e431452d5249072e4f56cfda3149f6ab1405e"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81d6741ab457d14fdedc215516665050f3822d3e56508921cc7239f8c8e66a58"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b8af03d2e37866d023ad0ddea594edefc31e827fee64f8de5611a1dbc373174"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cf4e8ad252f7c38dd1f676b46514f92dc0ebeb0db5552f5f403509705e24753"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e696f0dd336161fca9adbb846875d40752e6eba585843c768935ba5c9960722b"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c22d3fe05ce11d3671297dc8973267daa0f938b93ec716e12e0f6dee81591dc1"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:109487860ef6a328f3eec66f2bf78b0b72400280d8f8ea05f69c51644ba6521a"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:37f8febc8ec50c14f3ec9637505f28e58d4f66752207ea177c1d67df25da5aed"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:f97e83fa6c25693c7a35de154681fcc257c1c41b38beb0304b9c4d2d9e164479"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a152f5f33d64a6be73f1d30c9cc82dfc73cec6477ec268e7c6e4c7d23c2d2291"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:39049da0ffb96c8cbb65cbf5c5f3ca3168990adf3551bd1dee10c48fce8ae820"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-win32.whl", hash = "sha256:4457ea6774b5611f4bed5eaa5df55f70abde42364d498c5134b7ef4c6958e20e"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:e62164b50f84e20601c1ff8eb55620d2ad25fb81b59e3cd776a1902527a788af"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8eade758719add78ec36dc13201483f8e9b5d940329285edcd5f70c0a9edbd7f"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8499ca8f4502af841f68135133d8258f7b32a53a1d594aa98cc52013fff55678"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fc1c4a2ffd64890aebdb3f97e1278b0cc72579a08ca4de8cd2c04799a3a22be"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00d3ffdaafe92a5dc603cb9bd5111aaa36dfa187c8285c543be562e61b755f6b"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2ac1b08635a8cd4e0cbeaf6f5e922085908d48eb05d44c5ae9eabab148512ca"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6f45710b4459401609ebebdbcfb34515da4fc2aa886f95107f556ac69a9147e"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ae1de54a77dc0d6d5fcf623290af4266412a7c4be0b1ff7444394f03f5c54e3"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b590df687e3c5ee0deef9fc8c547d81986d9a1b56073d82de008744452d6541"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab5de034a886f616a5668aa5d098af2b5385ed70142090e2a31bcbd0af0fdb3d"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9cb3032517f1627cc012dbc80a8ec976ae76d93ea2b5feaa9d2a5b8882597579"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:608862a7bf6957f2333fc54ab4399e405baad0163dc9f8d99cb236816db169d4"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f438ae3532723fb6ead77e7c604be7c8374094ef4ee2c5e03a3a17f1fca256c"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:356541bf4381fa35856dafa6a965916e54bed415ad8a24ee6de6e37deccf2786"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-win32.whl", hash = "sha256:39cf9ed17fe3b1bc81f33c9ceb6ce67683ee7526e65fde1447c772afc54a1bb8"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:0a11e971ed097d24c534c037d298ad32c6ce81a45736d31e0ff0ad37ab437d59"}, - {file = "charset_normalizer-3.0.1-py3-none-any.whl", hash = "sha256:7e189e2e1d3ed2f4aebabd2d5b0f931e883676e51c7624826e0a4e5fe8a0bf24"}, + {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, + {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, ] codecov = [ - {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"}, - {file = "codecov-2.1.12.tar.gz", hash = "sha256:a0da46bb5025426da895af90938def8ee12d37fcbcbbbc15b6dc64cf7ebc51c1"}, + {file = "codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5"}, + {file = "codecov-2.1.13.tar.gz", hash = "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c"}, ] colorama = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] coverage = [ - {file = "coverage-7.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3b946bbcd5a8231383450b195cfb58cb01cbe7f8949f5758566b881df4b33baf"}, - {file = "coverage-7.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ec8e767f13be637d056f7e07e61d089e555f719b387a7070154ad80a0ff31801"}, - {file = "coverage-7.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4a5a5879a939cb84959d86869132b00176197ca561c664fc21478c1eee60d75"}, - {file = "coverage-7.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b643cb30821e7570c0aaf54feaf0bfb630b79059f85741843e9dc23f33aaca2c"}, - {file = "coverage-7.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32df215215f3af2c1617a55dbdfb403b772d463d54d219985ac7cd3bf124cada"}, - {file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:33d1ae9d4079e05ac4cc1ef9e20c648f5afabf1a92adfaf2ccf509c50b85717f"}, - {file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:29571503c37f2ef2138a306d23e7270687c0efb9cab4bd8038d609b5c2393a3a"}, - {file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:63ffd21aa133ff48c4dff7adcc46b7ec8b565491bfc371212122dd999812ea1c"}, - {file = "coverage-7.1.0-cp310-cp310-win32.whl", hash = "sha256:4b14d5e09c656de5038a3f9bfe5228f53439282abcab87317c9f7f1acb280352"}, - {file = "coverage-7.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:8361be1c2c073919500b6601220a6f2f98ea0b6d2fec5014c1d9cfa23dd07038"}, - {file = "coverage-7.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:da9b41d4539eefd408c46725fb76ecba3a50a3367cafb7dea5f250d0653c1040"}, - {file = "coverage-7.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5b15ed7644ae4bee0ecf74fee95808dcc34ba6ace87e8dfbf5cb0dc20eab45a"}, - {file = "coverage-7.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d12d076582507ea460ea2a89a8c85cb558f83406c8a41dd641d7be9a32e1274f"}, - {file = "coverage-7.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2617759031dae1bf183c16cef8fcfb3de7617f394c813fa5e8e46e9b82d4222"}, - {file = "coverage-7.1.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4e4881fa9e9667afcc742f0c244d9364d197490fbc91d12ac3b5de0bf2df146"}, - {file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9d58885215094ab4a86a6aef044e42994a2bd76a446dc59b352622655ba6621b"}, - {file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ffeeb38ee4a80a30a6877c5c4c359e5498eec095878f1581453202bfacc8fbc2"}, - {file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3baf5f126f30781b5e93dbefcc8271cb2491647f8283f20ac54d12161dff080e"}, - {file = "coverage-7.1.0-cp311-cp311-win32.whl", hash = "sha256:ded59300d6330be27bc6cf0b74b89ada58069ced87c48eaf9344e5e84b0072f7"}, - {file = "coverage-7.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a43c7823cd7427b4ed763aa7fb63901ca8288591323b58c9cd6ec31ad910f3c"}, - {file = "coverage-7.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7a726d742816cb3a8973c8c9a97539c734b3a309345236cd533c4883dda05b8d"}, - {file = "coverage-7.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc7c85a150501286f8b56bd8ed3aa4093f4b88fb68c0843d21ff9656f0009d6a"}, - {file = "coverage-7.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b4198d85a3755d27e64c52f8c95d6333119e49fd001ae5798dac872c95e0f8"}, - {file = "coverage-7.1.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddb726cb861c3117a553f940372a495fe1078249ff5f8a5478c0576c7be12050"}, - {file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:51b236e764840a6df0661b67e50697aaa0e7d4124ca95e5058fa3d7cbc240b7c"}, - {file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:7ee5c9bb51695f80878faaa5598040dd6c9e172ddcf490382e8aedb8ec3fec8d"}, - {file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c31b75ae466c053a98bf26843563b3b3517b8f37da4d47b1c582fdc703112bc3"}, - {file = "coverage-7.1.0-cp37-cp37m-win32.whl", hash = "sha256:3b155caf3760408d1cb903b21e6a97ad4e2bdad43cbc265e3ce0afb8e0057e73"}, - {file = "coverage-7.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2a60d6513781e87047c3e630b33b4d1e89f39836dac6e069ffee28c4786715f5"}, - {file = "coverage-7.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f2cba5c6db29ce991029b5e4ac51eb36774458f0a3b8d3137241b32d1bb91f06"}, - {file = "coverage-7.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beeb129cacea34490ffd4d6153af70509aa3cda20fdda2ea1a2be870dfec8d52"}, - {file = "coverage-7.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c45948f613d5d18c9ec5eaa203ce06a653334cf1bd47c783a12d0dd4fd9c851"}, - {file = "coverage-7.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef382417db92ba23dfb5864a3fc9be27ea4894e86620d342a116b243ade5d35d"}, - {file = "coverage-7.1.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c7c0d0827e853315c9bbd43c1162c006dd808dbbe297db7ae66cd17b07830f0"}, - {file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e5cdbb5cafcedea04924568d990e20ce7f1945a1dd54b560f879ee2d57226912"}, - {file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9817733f0d3ea91bea80de0f79ef971ae94f81ca52f9b66500c6a2fea8e4b4f8"}, - {file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:218fe982371ac7387304153ecd51205f14e9d731b34fb0568181abaf7b443ba0"}, - {file = "coverage-7.1.0-cp38-cp38-win32.whl", hash = "sha256:04481245ef966fbd24ae9b9e537ce899ae584d521dfbe78f89cad003c38ca2ab"}, - {file = "coverage-7.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8ae125d1134bf236acba8b83e74c603d1b30e207266121e76484562bc816344c"}, - {file = "coverage-7.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2bf1d5f2084c3932b56b962a683074a3692bce7cabd3aa023c987a2a8e7612f6"}, - {file = "coverage-7.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:98b85dd86514d889a2e3dd22ab3c18c9d0019e696478391d86708b805f4ea0fa"}, - {file = "coverage-7.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38da2db80cc505a611938d8624801158e409928b136c8916cd2e203970dde4dc"}, - {file = "coverage-7.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3164d31078fa9efe406e198aecd2a02d32a62fecbdef74f76dad6a46c7e48311"}, - {file = "coverage-7.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db61a79c07331e88b9a9974815c075fbd812bc9dbc4dc44b366b5368a2936063"}, - {file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ccb092c9ede70b2517a57382a601619d20981f56f440eae7e4d7eaafd1d1d09"}, - {file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:33ff26d0f6cc3ca8de13d14fde1ff8efe1456b53e3f0273e63cc8b3c84a063d8"}, - {file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d47dd659a4ee952e90dc56c97d78132573dc5c7b09d61b416a9deef4ebe01a0c"}, - {file = "coverage-7.1.0-cp39-cp39-win32.whl", hash = "sha256:d248cd4a92065a4d4543b8331660121b31c4148dd00a691bfb7a5cdc7483cfa4"}, - {file = "coverage-7.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7ed681b0f8e8bcbbffa58ba26fcf5dbc8f79e7997595bf071ed5430d8c08d6f3"}, - {file = "coverage-7.1.0-pp37.pp38.pp39-none-any.whl", hash = "sha256:755e89e32376c850f826c425ece2c35a4fc266c081490eb0a841e7c1cb0d3bda"}, - {file = "coverage-7.1.0.tar.gz", hash = "sha256:10188fe543560ec4874f974b5305cd1a8bdcfa885ee00ea3a03733464c4ca265"}, + {file = "coverage-7.2.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:883123d0bbe1c136f76b56276074b0c79b5817dd4238097ffa64ac67257f4b6c"}, + {file = "coverage-7.2.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fbc2a127e857d2f8898aaabcc34c37771bf78a4d5e17d3e1f5c30cd0cbc62a"}, + {file = "coverage-7.2.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f3671662dc4b422b15776cdca89c041a6349b4864a43aa2350b6b0b03bbcc7f"}, + {file = "coverage-7.2.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780551e47d62095e088f251f5db428473c26db7829884323e56d9c0c3118791a"}, + {file = "coverage-7.2.5-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:066b44897c493e0dcbc9e6a6d9f8bbb6607ef82367cf6810d387c09f0cd4fe9a"}, + {file = "coverage-7.2.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9a4ee55174b04f6af539218f9f8083140f61a46eabcaa4234f3c2a452c4ed11"}, + {file = "coverage-7.2.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:706ec567267c96717ab9363904d846ec009a48d5f832140b6ad08aad3791b1f5"}, + {file = "coverage-7.2.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ae453f655640157d76209f42c62c64c4d4f2c7f97256d3567e3b439bd5c9b06c"}, + {file = "coverage-7.2.5-cp310-cp310-win32.whl", hash = "sha256:f81c9b4bd8aa747d417407a7f6f0b1469a43b36a85748145e144ac4e8d303cb5"}, + {file = "coverage-7.2.5-cp310-cp310-win_amd64.whl", hash = "sha256:dc945064a8783b86fcce9a0a705abd7db2117d95e340df8a4333f00be5efb64c"}, + {file = "coverage-7.2.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:40cc0f91c6cde033da493227797be2826cbf8f388eaa36a0271a97a332bfd7ce"}, + {file = "coverage-7.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a66e055254a26c82aead7ff420d9fa8dc2da10c82679ea850d8feebf11074d88"}, + {file = "coverage-7.2.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c10fbc8a64aa0f3ed136b0b086b6b577bc64d67d5581acd7cc129af52654384e"}, + {file = "coverage-7.2.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a22cbb5ede6fade0482111fa7f01115ff04039795d7092ed0db43522431b4f2"}, + {file = "coverage-7.2.5-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292300f76440651529b8ceec283a9370532f4ecba9ad67d120617021bb5ef139"}, + {file = "coverage-7.2.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7ff8f3fb38233035028dbc93715551d81eadc110199e14bbbfa01c5c4a43f8d8"}, + {file = "coverage-7.2.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:a08c7401d0b24e8c2982f4e307124b671c6736d40d1c39e09d7a8687bddf83ed"}, + {file = "coverage-7.2.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef9659d1cda9ce9ac9585c045aaa1e59223b143f2407db0eaee0b61a4f266fb6"}, + {file = "coverage-7.2.5-cp311-cp311-win32.whl", hash = "sha256:30dcaf05adfa69c2a7b9f7dfd9f60bc8e36b282d7ed25c308ef9e114de7fc23b"}, + {file = "coverage-7.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:97072cc90f1009386c8a5b7de9d4fc1a9f91ba5ef2146c55c1f005e7b5c5e068"}, + {file = "coverage-7.2.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bebea5f5ed41f618797ce3ffb4606c64a5de92e9c3f26d26c2e0aae292f015c1"}, + {file = "coverage-7.2.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:828189fcdda99aae0d6bf718ea766b2e715eabc1868670a0a07bf8404bf58c33"}, + {file = "coverage-7.2.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e8a95f243d01ba572341c52f89f3acb98a3b6d1d5d830efba86033dd3687ade"}, + {file = "coverage-7.2.5-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8834e5f17d89e05697c3c043d3e58a8b19682bf365048837383abfe39adaed5"}, + {file = "coverage-7.2.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d1f25ee9de21a39b3a8516f2c5feb8de248f17da7eead089c2e04aa097936b47"}, + {file = "coverage-7.2.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1637253b11a18f453e34013c665d8bf15904c9e3c44fbda34c643fbdc9d452cd"}, + {file = "coverage-7.2.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8e575a59315a91ccd00c7757127f6b2488c2f914096077c745c2f1ba5b8c0969"}, + {file = "coverage-7.2.5-cp37-cp37m-win32.whl", hash = "sha256:509ecd8334c380000d259dc66feb191dd0a93b21f2453faa75f7f9cdcefc0718"}, + {file = "coverage-7.2.5-cp37-cp37m-win_amd64.whl", hash = "sha256:12580845917b1e59f8a1c2ffa6af6d0908cb39220f3019e36c110c943dc875b0"}, + {file = "coverage-7.2.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b5016e331b75310610c2cf955d9f58a9749943ed5f7b8cfc0bb89c6134ab0a84"}, + {file = "coverage-7.2.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:373ea34dca98f2fdb3e5cb33d83b6d801007a8074f992b80311fc589d3e6b790"}, + {file = "coverage-7.2.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a063aad9f7b4c9f9da7b2550eae0a582ffc7623dca1c925e50c3fbde7a579771"}, + {file = "coverage-7.2.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38c0a497a000d50491055805313ed83ddba069353d102ece8aef5d11b5faf045"}, + {file = "coverage-7.2.5-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2b3b05e22a77bb0ae1a3125126a4e08535961c946b62f30985535ed40e26614"}, + {file = "coverage-7.2.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0342a28617e63ad15d96dca0f7ae9479a37b7d8a295f749c14f3436ea59fdcb3"}, + {file = "coverage-7.2.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cf97ed82ca986e5c637ea286ba2793c85325b30f869bf64d3009ccc1a31ae3fd"}, + {file = "coverage-7.2.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c2c41c1b1866b670573657d584de413df701f482574bad7e28214a2362cb1fd1"}, + {file = "coverage-7.2.5-cp38-cp38-win32.whl", hash = "sha256:10b15394c13544fce02382360cab54e51a9e0fd1bd61ae9ce012c0d1e103c813"}, + {file = "coverage-7.2.5-cp38-cp38-win_amd64.whl", hash = "sha256:a0b273fe6dc655b110e8dc89b8ec7f1a778d78c9fd9b4bda7c384c8906072212"}, + {file = "coverage-7.2.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c587f52c81211d4530fa6857884d37f514bcf9453bdeee0ff93eaaf906a5c1b"}, + {file = "coverage-7.2.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4436cc9ba5414c2c998eaedee5343f49c02ca93b21769c5fdfa4f9d799e84200"}, + {file = "coverage-7.2.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6599bf92f33ab041e36e06d25890afbdf12078aacfe1f1d08c713906e49a3fe5"}, + {file = "coverage-7.2.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:857abe2fa6a4973f8663e039ead8d22215d31db613ace76e4a98f52ec919068e"}, + {file = "coverage-7.2.5-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6f5cab2d7f0c12f8187a376cc6582c477d2df91d63f75341307fcdcb5d60303"}, + {file = "coverage-7.2.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:aa387bd7489f3e1787ff82068b295bcaafbf6f79c3dad3cbc82ef88ce3f48ad3"}, + {file = "coverage-7.2.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:156192e5fd3dbbcb11cd777cc469cf010a294f4c736a2b2c891c77618cb1379a"}, + {file = "coverage-7.2.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bd3b4b8175c1db502adf209d06136c000df4d245105c8839e9d0be71c94aefe1"}, + {file = "coverage-7.2.5-cp39-cp39-win32.whl", hash = "sha256:ddc5a54edb653e9e215f75de377354e2455376f416c4378e1d43b08ec50acc31"}, + {file = "coverage-7.2.5-cp39-cp39-win_amd64.whl", hash = "sha256:338aa9d9883aaaad53695cb14ccdeb36d4060485bb9388446330bef9c361c252"}, + {file = "coverage-7.2.5-pp37.pp38.pp39-none-any.whl", hash = "sha256:8877d9b437b35a85c18e3c6499b23674684bf690f5d96c1006a1ef61f9fdf0f3"}, + {file = "coverage-7.2.5.tar.gz", hash = "sha256:f99ef080288f09ffc687423b8d60978cf3a465d3f404a18d1a05474bd8575a47"}, ] distlib = [ {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, @@ -1045,16 +1015,16 @@ docutils = [ {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, ] exceptiongroup = [ - {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, - {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, + {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, + {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, ] filelock = [ - {file = "filelock-3.9.0-py3-none-any.whl", hash = "sha256:f58d535af89bb9ad5cd4df046f741f8553a418c01a7856bf0d173bbc9f6bd16d"}, - {file = "filelock-3.9.0.tar.gz", hash = "sha256:7b319f24340b51f55a2bf7a12ac0755a9b03e718311dac567a0f4f7fabd2f5de"}, + {file = "filelock-3.12.0-py3-none-any.whl", hash = "sha256:ad98852315c2ab702aeb628412cbf7e95b7ce8c3bf9565670b4eaecf1db370a9"}, + {file = "filelock-3.12.0.tar.gz", hash = "sha256:fc03ae43288c013d2ea83c8597001b1129db351aad9c57fe2409327916b8e718"}, ] identify = [ - {file = "identify-2.5.18-py2.py3-none-any.whl", hash = "sha256:93aac7ecf2f6abf879b8f29a8002d3c6de7086b8c28d88e1ad15045a15ab63f9"}, - {file = "identify-2.5.18.tar.gz", hash = "sha256:89e144fa560cc4cffb6ef2ab5e9fb18ed9f9b3cb054384bab4b95c12f6c309fe"}, + {file = "identify-2.5.24-py2.py3-none-any.whl", hash = "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d"}, + {file = "identify-2.5.24.tar.gz", hash = "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4"}, ] idna = [ {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, @@ -1065,8 +1035,8 @@ imagesize = [ {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, ] importlib-metadata = [ - {file = "importlib_metadata-6.0.0-py3-none-any.whl", hash = "sha256:7efb448ec9a5e313a57655d35aa54cd3e01b7e1fbcf72dce1bf06119420f5bad"}, - {file = "importlib_metadata-6.0.0.tar.gz", hash = "sha256:e354bedeb60efa6affdcc8ae121b73544a7aa74156d047311948f6d711cd378d"}, + {file = "importlib_metadata-6.6.0-py3-none-any.whl", hash = "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed"}, + {file = "importlib_metadata-6.6.0.tar.gz", hash = "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705"}, ] iniconfig = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, @@ -1077,8 +1047,8 @@ Jinja2 = [ {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, ] markdown-it-py = [ - {file = "markdown-it-py-2.1.0.tar.gz", hash = "sha256:cf7e59fed14b5ae17c0006eff14a2d9a00ed5f3a846148153899a0224e2c07da"}, - {file = "markdown_it_py-2.1.0-py3-none-any.whl", hash = "sha256:93de681e5c021a432c63147656fe21790bc01231e0cd2da73626f1aa3ac0fe27"}, + {file = "markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1"}, + {file = "markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30"}, ] MarkupSafe = [ {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"}, @@ -1133,8 +1103,8 @@ MarkupSafe = [ {file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"}, ] mdit-py-plugins = [ - {file = "mdit-py-plugins-0.3.4.tar.gz", hash = "sha256:3278aab2e2b692539082f05e1243f24742194ffd92481f48844f057b51971283"}, - {file = "mdit_py_plugins-0.3.4-py3-none-any.whl", hash = "sha256:4f1441264ac5cb39fa40a5901921c2acf314ea098d75629750c138f80d552cdf"}, + {file = "mdit-py-plugins-0.3.5.tar.gz", hash = "sha256:eee0adc7195e5827e17e02d2a258a2ba159944a0748f59c5099a4a27f78fcf6a"}, + {file = "mdit_py_plugins-0.3.5-py3-none-any.whl", hash = "sha256:ca9a0714ea59a24b2b044a1831f48d817dd0c817e84339f20e7889f392d77c4e"}, ] mdurl = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, @@ -1145,16 +1115,16 @@ myst-parser = [ {file = "myst_parser-0.18.1-py3-none-any.whl", hash = "sha256:61b275b85d9f58aa327f370913ae1bec26ebad372cc99f3ab85c8ec3ee8d9fb8"}, ] nodeenv = [ - {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, - {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, ] packaging = [ - {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, - {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, + {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, + {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, ] platformdirs = [ - {file = "platformdirs-3.0.0-py3-none-any.whl", hash = "sha256:b1d5eb14f221506f50d6604a561f4c5786d9e80355219694a1b244bcd96f4567"}, - {file = "platformdirs-3.0.0.tar.gz", hash = "sha256:8a1228abb1ef82d788f74139988b137e78692984ec7b08eaa6c65f1723af28f9"}, + {file = "platformdirs-3.5.1-py3-none-any.whl", hash = "sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5"}, + {file = "platformdirs-3.5.1.tar.gz", hash = "sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, @@ -1165,58 +1135,58 @@ pre-commit = [ {file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"}, ] pydantic = [ - {file = "pydantic-1.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5920824fe1e21cbb3e38cf0f3dd24857c8959801d1031ce1fac1d50857a03bfb"}, - {file = "pydantic-1.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3bb99cf9655b377db1a9e47fa4479e3330ea96f4123c6c8200e482704bf1eda2"}, - {file = "pydantic-1.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2185a3b3d98ab4506a3f6707569802d2d92c3a7ba3a9a35683a7709ea6c2aaa2"}, - {file = "pydantic-1.10.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f582cac9d11c227c652d3ce8ee223d94eb06f4228b52a8adaafa9fa62e73d5c9"}, - {file = "pydantic-1.10.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c9e5b778b6842f135902e2d82624008c6a79710207e28e86966cd136c621bfee"}, - {file = "pydantic-1.10.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:72ef3783be8cbdef6bca034606a5de3862be6b72415dc5cb1fb8ddbac110049a"}, - {file = "pydantic-1.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:45edea10b75d3da43cfda12f3792833a3fa70b6eee4db1ed6aed528cef17c74e"}, - {file = "pydantic-1.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:63200cd8af1af2c07964546b7bc8f217e8bda9d0a2ef0ee0c797b36353914984"}, - {file = "pydantic-1.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:305d0376c516b0dfa1dbefeae8c21042b57b496892d721905a6ec6b79494a66d"}, - {file = "pydantic-1.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fd326aff5d6c36f05735c7c9b3d5b0e933b4ca52ad0b6e4b38038d82703d35b"}, - {file = "pydantic-1.10.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6bb0452d7b8516178c969d305d9630a3c9b8cf16fcf4713261c9ebd465af0d73"}, - {file = "pydantic-1.10.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9a9d9155e2a9f38b2eb9374c88f02fd4d6851ae17b65ee786a87d032f87008f8"}, - {file = "pydantic-1.10.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f836444b4c5ece128b23ec36a446c9ab7f9b0f7981d0d27e13a7c366ee163f8a"}, - {file = "pydantic-1.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:8481dca324e1c7b715ce091a698b181054d22072e848b6fc7895cd86f79b4449"}, - {file = "pydantic-1.10.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:87f831e81ea0589cd18257f84386bf30154c5f4bed373b7b75e5cb0b5d53ea87"}, - {file = "pydantic-1.10.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ce1612e98c6326f10888df951a26ec1a577d8df49ddcaea87773bfbe23ba5cc"}, - {file = "pydantic-1.10.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58e41dd1e977531ac6073b11baac8c013f3cd8706a01d3dc74e86955be8b2c0c"}, - {file = "pydantic-1.10.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6a4b0aab29061262065bbdede617ef99cc5914d1bf0ddc8bcd8e3d7928d85bd6"}, - {file = "pydantic-1.10.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:36e44a4de37b8aecffa81c081dbfe42c4d2bf9f6dff34d03dce157ec65eb0f15"}, - {file = "pydantic-1.10.5-cp37-cp37m-win_amd64.whl", hash = "sha256:261f357f0aecda005934e413dfd7aa4077004a174dafe414a8325e6098a8e419"}, - {file = "pydantic-1.10.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b429f7c457aebb7fbe7cd69c418d1cd7c6fdc4d3c8697f45af78b8d5a7955760"}, - {file = "pydantic-1.10.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:663d2dd78596c5fa3eb996bc3f34b8c2a592648ad10008f98d1348be7ae212fb"}, - {file = "pydantic-1.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51782fd81f09edcf265823c3bf43ff36d00db246eca39ee765ef58dc8421a642"}, - {file = "pydantic-1.10.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c428c0f64a86661fb4873495c4fac430ec7a7cef2b8c1c28f3d1a7277f9ea5ab"}, - {file = "pydantic-1.10.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:76c930ad0746c70f0368c4596020b736ab65b473c1f9b3872310a835d852eb19"}, - {file = "pydantic-1.10.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3257bd714de9db2102b742570a56bf7978e90441193acac109b1f500290f5718"}, - {file = "pydantic-1.10.5-cp38-cp38-win_amd64.whl", hash = "sha256:f5bee6c523d13944a1fdc6f0525bc86dbbd94372f17b83fa6331aabacc8fd08e"}, - {file = "pydantic-1.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:532e97c35719f137ee5405bd3eeddc5c06eb91a032bc755a44e34a712420daf3"}, - {file = "pydantic-1.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ca9075ab3de9e48b75fa8ccb897c34ccc1519177ad8841d99f7fd74cf43be5bf"}, - {file = "pydantic-1.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd46a0e6296346c477e59a954da57beaf9c538da37b9df482e50f836e4a7d4bb"}, - {file = "pydantic-1.10.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3353072625ea2a9a6c81ad01b91e5c07fa70deb06368c71307529abf70d23325"}, - {file = "pydantic-1.10.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:3f9d9b2be177c3cb6027cd67fbf323586417868c06c3c85d0d101703136e6b31"}, - {file = "pydantic-1.10.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b473d00ccd5c2061fd896ac127b7755baad233f8d996ea288af14ae09f8e0d1e"}, - {file = "pydantic-1.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:5f3bc8f103b56a8c88021d481410874b1f13edf6e838da607dcb57ecff9b4594"}, - {file = "pydantic-1.10.5-py3-none-any.whl", hash = "sha256:7c5b94d598c90f2f46b3a983ffb46ab806a67099d118ae0da7ef21a2a4033b28"}, - {file = "pydantic-1.10.5.tar.gz", hash = "sha256:9e337ac83686645a46db0e825acceea8e02fca4062483f40e9ae178e8bd1103a"}, + {file = "pydantic-1.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e79e999e539872e903767c417c897e729e015872040e56b96e67968c3b918b2d"}, + {file = "pydantic-1.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:01aea3a42c13f2602b7ecbbea484a98169fb568ebd9e247593ea05f01b884b2e"}, + {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:516f1ed9bc2406a0467dd777afc636c7091d71f214d5e413d64fef45174cfc7a"}, + {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae150a63564929c675d7f2303008d88426a0add46efd76c3fc797cd71cb1b46f"}, + {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ecbbc51391248116c0a055899e6c3e7ffbb11fb5e2a4cd6f2d0b93272118a209"}, + {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f4a2b50e2b03d5776e7f21af73e2070e1b5c0d0df255a827e7c632962f8315af"}, + {file = "pydantic-1.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:a7cd2251439988b413cb0a985c4ed82b6c6aac382dbaff53ae03c4b23a70e80a"}, + {file = "pydantic-1.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:68792151e174a4aa9e9fc1b4e653e65a354a2fa0fed169f7b3d09902ad2cb6f1"}, + {file = "pydantic-1.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe2507b8ef209da71b6fb5f4e597b50c5a34b78d7e857c4f8f3115effaef5fe"}, + {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10a86d8c8db68086f1e30a530f7d5f83eb0685e632e411dbbcf2d5c0150e8dcd"}, + {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75ae19d2a3dbb146b6f324031c24f8a3f52ff5d6a9f22f0683694b3afcb16fb"}, + {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:464855a7ff7f2cc2cf537ecc421291b9132aa9c79aef44e917ad711b4a93163b"}, + {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:193924c563fae6ddcb71d3f06fa153866423ac1b793a47936656e806b64e24ca"}, + {file = "pydantic-1.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:b4a849d10f211389502059c33332e91327bc154acc1845f375a99eca3afa802d"}, + {file = "pydantic-1.10.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cc1dde4e50a5fc1336ee0581c1612215bc64ed6d28d2c7c6f25d2fe3e7c3e918"}, + {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0cfe895a504c060e5d36b287ee696e2fdad02d89e0d895f83037245218a87fe"}, + {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:670bb4683ad1e48b0ecb06f0cfe2178dcf74ff27921cdf1606e527d2617a81ee"}, + {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:950ce33857841f9a337ce07ddf46bc84e1c4946d2a3bba18f8280297157a3fd1"}, + {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c15582f9055fbc1bfe50266a19771bbbef33dd28c45e78afbe1996fd70966c2a"}, + {file = "pydantic-1.10.7-cp37-cp37m-win_amd64.whl", hash = "sha256:82dffb306dd20bd5268fd6379bc4bfe75242a9c2b79fec58e1041fbbdb1f7914"}, + {file = "pydantic-1.10.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c7f51861d73e8b9ddcb9916ae7ac39fb52761d9ea0df41128e81e2ba42886cd"}, + {file = "pydantic-1.10.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6434b49c0b03a51021ade5c4daa7d70c98f7a79e95b551201fff682fc1661245"}, + {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d34ab766fa056df49013bb6e79921a0265204c071984e75a09cbceacbbdd5d"}, + {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:701daea9ffe9d26f97b52f1d157e0d4121644f0fcf80b443248434958fd03dc3"}, + {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cf135c46099ff3f919d2150a948ce94b9ce545598ef2c6c7bf55dca98a304b52"}, + {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0f85904f73161817b80781cc150f8b906d521fa11e3cdabae19a581c3606209"}, + {file = "pydantic-1.10.7-cp38-cp38-win_amd64.whl", hash = "sha256:9f6f0fd68d73257ad6685419478c5aece46432f4bdd8d32c7345f1986496171e"}, + {file = "pydantic-1.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c230c0d8a322276d6e7b88c3f7ce885f9ed16e0910354510e0bae84d54991143"}, + {file = "pydantic-1.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:976cae77ba6a49d80f461fd8bba183ff7ba79f44aa5cfa82f1346b5626542f8e"}, + {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d45fc99d64af9aaf7e308054a0067fdcd87ffe974f2442312372dfa66e1001d"}, + {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2a5ebb48958754d386195fe9e9c5106f11275867051bf017a8059410e9abf1f"}, + {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:abfb7d4a7cd5cc4e1d1887c43503a7c5dd608eadf8bc615413fc498d3e4645cd"}, + {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:80b1fab4deb08a8292d15e43a6edccdffa5377a36a4597bb545b93e79c5ff0a5"}, + {file = "pydantic-1.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:d71e69699498b020ea198468e2480a2f1e7433e32a3a99760058c6520e2bea7e"}, + {file = "pydantic-1.10.7-py3-none-any.whl", hash = "sha256:0cd181f1d0b1d00e2b705f1bf1ac7799a2d938cce3376b8007df62b29be3c2c6"}, + {file = "pydantic-1.10.7.tar.gz", hash = "sha256:cfc83c0678b6ba51b0532bea66860617c4cd4251ecf76e9846fa5a9f3454e97e"}, ] Pygments = [ - {file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"}, - {file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"}, + {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"}, + {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"}, ] pyproject-api = [ - {file = "pyproject_api-1.5.0-py3-none-any.whl", hash = "sha256:4c111277dfb96bcd562c6245428f27250b794bfe3e210b8714c4f893952f2c17"}, - {file = "pyproject_api-1.5.0.tar.gz", hash = "sha256:0962df21f3e633b8ddb9567c011e6c1b3dcdfc31b7860c0ede7e24c5a1200fbe"}, + {file = "pyproject_api-1.5.1-py3-none-any.whl", hash = "sha256:4698a3777c2e0f6b624f8a4599131e2a25376d90fe8d146d7ac74c67c6f97c43"}, + {file = "pyproject_api-1.5.1.tar.gz", hash = "sha256:435f46547a9ff22cf4208ee274fca3e2869aeb062a4834adfc99a4dd64af3cf9"}, ] pytest = [ - {file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"}, - {file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"}, + {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, + {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, ] pytest-asyncio = [ - {file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"}, - {file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"}, + {file = "pytest-asyncio-0.21.0.tar.gz", hash = "sha256:2b38a496aef56f56b0e87557ec313e11e1ab9276fc3863f6a7be0f1d0e415e1b"}, + {file = "pytest_asyncio-0.21.0-py3-none-any.whl", hash = "sha256:f2b3366b7cd501a4056858bd39349d5af19742aed2d81660b7998b6341c7eb9c"}, ] pytest-cov = [ {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, @@ -1227,12 +1197,12 @@ pytest-mock = [ {file = "pytest_mock-3.10.0-py3-none-any.whl", hash = "sha256:f4c973eeae0282963eb293eb173ce91b091a79c1334455acfac9ddee8a1c784b"}, ] pytest-sugar = [ - {file = "pytest-sugar-0.9.6.tar.gz", hash = "sha256:c4793495f3c32e114f0f5416290946c316eb96ad5a3684dcdadda9267e59b2b8"}, - {file = "pytest_sugar-0.9.6-py2.py3-none-any.whl", hash = "sha256:30e5225ed2b3cc988a8a672f8bda0fc37bcd92d62e9273937f061112b3f2186d"}, + {file = "pytest-sugar-0.9.7.tar.gz", hash = "sha256:f1e74c1abfa55f7241cf7088032b6e378566f16b938f3f08905e2cf4494edd46"}, + {file = "pytest_sugar-0.9.7-py2.py3-none-any.whl", hash = "sha256:8cb5a4e5f8bbcd834622b0235db9e50432f4cbd71fef55b467fe44e43701e062"}, ] pytz = [ - {file = "pytz-2022.7.1-py2.py3-none-any.whl", hash = "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"}, - {file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"}, + {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"}, + {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, ] PyYAML = [ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, @@ -1277,12 +1247,12 @@ PyYAML = [ {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, ] requests = [ - {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"}, - {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"}, + {file = "requests-2.30.0-py3-none-any.whl", hash = "sha256:10e94cc4f3121ee6da529d358cdaeaff2f1c409cd377dbc72b825852f2f7e294"}, + {file = "requests-2.30.0.tar.gz", hash = "sha256:239d7d4458afcb28a692cdd298d87542235f4ca8d36d03a15bfc128a6559a2f4"}, ] setuptools = [ - {file = "setuptools-67.3.2-py3-none-any.whl", hash = "sha256:bb6d8e508de562768f2027902929f8523932fcd1fb784e6d573d2cafac995a48"}, - {file = "setuptools-67.3.2.tar.gz", hash = "sha256:95f00380ef2ffa41d9bba85d95b27689d923c93dfbafed4aecd7cf988a25e012"}, + {file = "setuptools-67.7.2-py3-none-any.whl", hash = "sha256:23aaf86b85ca52ceb801d32703f12d77517b2556af839621c641fca11287952b"}, + {file = "setuptools-67.7.2.tar.gz", hash = "sha256:f104fa03692a2602fa0fec6c6a9e63b6c8a968de13e17c026957dd1f53d80990"}, ] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, @@ -1333,8 +1303,8 @@ sphinxcontrib-serializinghtml = [ {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, ] termcolor = [ - {file = "termcolor-2.2.0-py3-none-any.whl", hash = "sha256:91ddd848e7251200eac969846cbae2dacd7d71c2871e92733289e7e3666f48e7"}, - {file = "termcolor-2.2.0.tar.gz", hash = "sha256:dfc8ac3f350788f23b2947b3e6cfa5a53b630b612e6cd8965a015a776020b99a"}, + {file = "termcolor-2.3.0-py3-none-any.whl", hash = "sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475"}, + {file = "termcolor-2.3.0.tar.gz", hash = "sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a"}, ] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, @@ -1345,20 +1315,20 @@ tomli = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] tox = [ - {file = "tox-4.4.5-py3-none-any.whl", hash = "sha256:1081864f1a1393ffa11ebe9beaa280349020579310d217a594a4e7b6124c5425"}, - {file = "tox-4.4.5.tar.gz", hash = "sha256:f9bc83c5da8666baa2a4d4e884bbbda124fe646e4b1c0e412949cecc2b6e8f90"}, + {file = "tox-4.5.1-py3-none-any.whl", hash = "sha256:d25a2e6cb261adc489604fafd76cd689efeadfa79709965e965668d6d3f63046"}, + {file = "tox-4.5.1.tar.gz", hash = "sha256:5a2eac5fb816779dfdf5cb00fecbc27eb0524e4626626bb1de84747b24cacc56"}, ] typing-extensions = [ {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, ] urllib3 = [ - {file = "urllib3-1.26.14-py2.py3-none-any.whl", hash = "sha256:75edcdc2f7d85b137124a6c3c9fc3933cdeaa12ecb9a6a959f22797a0feca7e1"}, - {file = "urllib3-1.26.14.tar.gz", hash = "sha256:076907bf8fd355cde77728471316625a4d2f7e713c125f51953bb5b3eecf4f72"}, + {file = "urllib3-2.0.2-py3-none-any.whl", hash = "sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e"}, + {file = "urllib3-2.0.2.tar.gz", hash = "sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc"}, ] virtualenv = [ - {file = "virtualenv-20.19.0-py3-none-any.whl", hash = "sha256:54eb59e7352b573aa04d53f80fc9736ed0ad5143af445a1e539aada6eb947dd1"}, - {file = "virtualenv-20.19.0.tar.gz", hash = "sha256:37a640ba82ed40b226599c522d411e4be5edb339a0c0de030c0dc7b646d61590"}, + {file = "virtualenv-20.23.0-py3-none-any.whl", hash = "sha256:6abec7670e5802a528357fdc75b26b9f57d5d92f29c5462ba0fbe45feacc685e"}, + {file = "virtualenv-20.23.0.tar.gz", hash = "sha256:a85caa554ced0c0afbd0d638e7e2d7b5f92d23478d05d17a76daeac8f279f924"}, ] voluptuous = [ {file = "voluptuous-0.13.1-py3-none-any.whl", hash = "sha256:4b838b185f5951f2d6e8752b68fcf18bd7a9c26ded8f143f92d6d28f3921a3e6"}, @@ -1369,6 +1339,6 @@ xdoctest = [ {file = "xdoctest-1.1.1.tar.gz", hash = "sha256:2eac8131bdcdf2781b4e5a62d6de87f044b730cc8db8af142a51bb29c245e779"}, ] zipp = [ - {file = "zipp-3.14.0-py3-none-any.whl", hash = "sha256:188834565033387710d046e3fe96acfc9b5e86cbca7f39ff69cf21a4128198b7"}, - {file = "zipp-3.14.0.tar.gz", hash = "sha256:9e5421e176ef5ab4c0ad896624e87a7b2f07aca746c9b2aa305952800cb8eecb"}, + {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, + {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, ] From ce5821a35fcc3e99f0986d6d2486e3a392a873c6 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 17 May 2023 20:03:08 +0200 Subject: [PATCH 139/892] Drop python 3.7 support (#455) * Drop python 3.7 support * CI: drop 3.7 and add 3.11 * Remove skipifs that were required for <3.8 * Use pypy-3.8 for CI, re-enable pypy for windows to see if it works now * Bump readthedocs to use py3.8 * Remove py3.7 failure comment --- .github/workflows/ci.yml | 10 +++++----- .pre-commit-config.yaml | 2 +- .readthedocs.yml | 2 +- kasa/tests/test_cli.py | 4 ---- kasa/tests/test_discovery.py | 2 -- kasa/tests/test_protocol.py | 2 -- kasa/tests/test_readme_examples.py | 5 ----- pyproject.toml | 2 +- 8 files changed, 8 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bce730b7..b5a3b310d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: - python-version: ["3.10"] + python-version: ["3.11"] steps: - uses: "actions/checkout@v2" @@ -64,14 +64,14 @@ jobs: strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "pypy-3.7"] + python-version: ["3.8", "3.9", "3.10", "3.11", "pypy-3.8"] os: [ubuntu-latest, macos-latest, windows-latest] # exclude pypy on windows, as the poetry install seems to be very flaky: # PermissionError(13, 'The process cannot access the file because it is being used by another process')) # at C:\hostedtoolcache\windows\PyPy\3.7.10\x86\site-packages\requests\models.py:761 in generate - exclude: - - python-version: pypy-3.7 - os: windows-latest + # exclude: + # - python-version: pypy-3.8 + # os: windows-latest steps: - uses: "actions/checkout@v2" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ba59069dc..3b429e4d7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: rev: v3.4.0 hooks: - id: pyupgrade - args: ['--py37-plus'] + args: ['--py38-plus'] - repo: https://github.com/python/black rev: 23.3.0 diff --git a/.readthedocs.yml b/.readthedocs.yml index 0413384f0..f2f8fd70d 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -2,7 +2,7 @@ build: image: latest python: - version: 3.7 + version: 3.8 pip_install: true extra_requirements: - docs diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 319429d34..953c3bd4c 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -60,7 +60,6 @@ async def test_raw_command(dev): assert "Usage" in res.output -@pytest.mark.skipif(sys.version_info < (3, 8), reason="3.8 is first one with asyncmock") async def test_emeter(dev: SmartDevice, mocker): runner = CliRunner() @@ -103,9 +102,6 @@ async def test_brightness(dev): assert "Brightness: 12" in res.output -# Invoke fails when run on py3.7 with the following error: -# E + where 1 = .exit_code -@pytest.mark.skipif(sys.version_info < (3, 8), reason="fails on python3.7") async def test_json_output(dev: SmartDevice, mocker): """Test that the json output produces correct output.""" mocker.patch("kasa.Discover.discover", return_value=[dev]) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index b941654b8..30a9c4c23 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -52,7 +52,6 @@ async def test_type_unknown(): Discover._get_device_class(invalid_info) -@pytest.mark.skipif(sys.version_info < (3, 8), reason="3.8 is first one with asyncmock") async def test_discover_single(discovery_data: dict, mocker): """Make sure that discover_single returns an initialized SmartDevice instance.""" mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) @@ -71,7 +70,6 @@ async def test_discover_single(discovery_data: dict, mocker): ] -@pytest.mark.skipif(sys.version_info < (3, 8), reason="3.8 is first one with asyncmock") @pytest.mark.parametrize("msg, data", INVALIDS) async def test_discover_invalid_info(msg, data, mocker): """Make sure that invalid discovery information raises an exception.""" diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index e540a9fbf..7aa95a5a2 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -62,7 +62,6 @@ async def test_protocol_retry_recoverable_error(mocker): assert conn.call_count == 6 -@pytest.mark.skipif(sys.version_info < (3, 8), reason="3.8 is first one with asyncmock") @pytest.mark.parametrize("retry_count", [1, 3, 5]) async def test_protocol_reconnect(mocker, retry_count): remaining = retry_count @@ -98,7 +97,6 @@ def aio_mock_writer(_, __): assert response == {"great": "success"} -@pytest.mark.skipif(sys.version_info < (3, 8), reason="3.8 is first one with asyncmock") @pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG]) async def test_protocol_logging(mocker, caplog, log_level): caplog.set_level(log_level) diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index f3c9efdd9..13c6e9944 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -61,15 +61,10 @@ def test_lightstrip_examples(mocker): assert not res["failed"] -@pytest.mark.skipif( - sys.version_info < (3, 8), reason="3.7 handles asyncio.run differently" -) def test_discovery_examples(mocker): """Test discovery examples.""" p = asyncio.run(get_device_for_file("KP303(UK)_1.0_1.0.3.json")) - # This succeeds on python 3.8 but fails on 3.7 - # ValueError: a coroutine was expected, got [ Date: Wed, 17 May 2023 20:08:05 +0200 Subject: [PATCH 140/892] Add inactivity setting for the motion module (#453) * Add inactivity setting for the motion module * Fix set_cold_time payload Co-authored-by: Matt Whitlock * Add mention about "smart control" --------- Co-authored-by: Matt Whitlock --- kasa/modules/motion.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/kasa/modules/motion.py b/kasa/modules/motion.py index 45e272bed..71d1a617b 100644 --- a/kasa/modules/motion.py +++ b/kasa/modules/motion.py @@ -59,3 +59,16 @@ async def set_range( ) return await self.call("set_trigger_sens", payload) + + @property + def inactivity_timeout(self) -> int: + """Return inactivity timeout in milliseconds.""" + return self.data["cold_time"] + + async def set_inactivity_timeout(self, timeout: int): + """Set inactivity timeout in milliseconds. + + Note, that you need to delete the default "Smart Control" rule in the app + to avoid reverting this back to 60 seconds after a period of time. + """ + return await self.call("set_cold_time", {"cold_time": timeout}) From ce58cc1a6ac298f8dd9c06b5b0b4687f70ceeeb8 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 17 May 2023 20:10:39 +0200 Subject: [PATCH 141/892] Add methods to configure dimmer settings (#429) --- kasa/smartdimmer.py | 58 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py index 74d9221ac..9565437dd 100644 --- a/kasa/smartdimmer.py +++ b/kasa/smartdimmer.py @@ -1,4 +1,5 @@ """Module for dimmers (currently only HS220).""" +from enum import Enum from typing import Any, Dict, Optional from kasa.modules import AmbientLight, Motion @@ -6,6 +7,29 @@ from kasa.smartplug import SmartPlug +class ButtonAction(Enum): + """Button action.""" + + NoAction = "none" + Instant = "instant_on_off" + Gentle = "gentle_on_off" + Preset = "customize_preset" + + +class ActionType(Enum): + """Button action.""" + + DoubleClick = "double_click_action" + LongPress = "long_press_action" + + +class FadeType(Enum): + """Fade on/off setting.""" + + FadeOn = "fade_on" + FadeOff = "fade_off" + + class SmartDimmer(SmartPlug): r"""Representation of a TP-Link Smart Dimmer. @@ -140,6 +164,40 @@ async def set_dimmer_transition(self, brightness: int, transition: int): {"brightness": brightness, "duration": transition}, ) + @requires_update + async def get_behaviors(self): + """Return button behavior settings.""" + behaviors = await self._query_helper( + self.DIMMER_SERVICE, "get_default_behavior", {} + ) + return behaviors + + @requires_update + async def set_button_action( + self, action_type: ActionType, action: ButtonAction, index: Optional[int] = None + ): + """Set action to perform on button click/hold. + + :param action_type ActionType: whether to control double click or hold action. + :param action ButtonAction: what should the button do (nothing, instant, gentle, change preset) + :param index int: in case of preset change, the preset to select + """ + action_type_setter = f"set_{action_type}" + + payload: Dict[str, Any] = {"mode": str(action)} + if index is not None: + payload["index"] = index + + await self._query_helper(self.DIMMER_SERVICE, action_type_setter, payload) + + @requires_update + async def set_fade_time(self, fade_type: FadeType, time: int): + """Set time for fade in / fade out.""" + fade_type_setter = f"set_{fade_type}_time" + payload = {"fadeTime": time} + + await self._query_helper(self.DIMMER_SERVICE, fade_type_setter, payload) + @property # type: ignore @requires_update def is_dimmable(self) -> bool: From 39310f3f02ee321a416866072a4a382849b32c6f Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 17 May 2023 20:33:02 +0200 Subject: [PATCH 142/892] Remove importlib-metadata dependency (#457) This is no longer needed, as python 3.8 has native importlib.metadata --- kasa/__init__.py | 2 +- poetry.lock | 50 ++++++++++++++++-------------------------------- pyproject.toml | 1 - 3 files changed, 18 insertions(+), 35 deletions(-) diff --git a/kasa/__init__.py b/kasa/__init__.py index 5e5e57ee0..d8cb08258 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -11,7 +11,7 @@ Module-specific errors are raised as `SmartDeviceException` and are expected to be handled by the user of the library. """ -from importlib_metadata import version # type: ignore +from importlib.metadata import version from kasa.discover import Discover from kasa.emeterstatus import EmeterStatus diff --git a/poetry.lock b/poetry.lock index f52926e2e..6309a3cec 100644 --- a/poetry.lock +++ b/poetry.lock @@ -17,7 +17,6 @@ python-versions = ">=3.6.2" [package.dependencies] idna = ">=2.8" sniffio = ">=1.1" -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} [package.extras] doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] @@ -34,7 +33,6 @@ python-versions = ">=3.7" [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "Babel" @@ -192,11 +190,10 @@ name = "importlib-metadata" version = "6.6.0" description = "Read metadata from Python packages" category = "main" -optional = false +optional = true python-versions = ">=3.7" [package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] @@ -236,7 +233,6 @@ python-versions = ">=3.7" [package.dependencies] mdurl = ">=0.1,<1.0" -typing_extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} [package.extras] benchmarking = ["psutil", "pytest", "pytest-benchmark"] @@ -330,9 +326,6 @@ category = "dev" optional = false python-versions = ">=3.7" -[package.dependencies] -typing-extensions = {version = ">=4.5", markers = "python_version < \"3.8\""} - [package.extras] docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.2.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] @@ -345,25 +338,21 @@ category = "dev" optional = false python-versions = ">=3.6" -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} - [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "2.21.0" +version = "3.3.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" [package.dependencies] cfgv = ">=2.0.0" identify = ">=1.0.0" -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" @@ -421,7 +410,6 @@ python-versions = ">=3.7" [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" @@ -440,7 +428,6 @@ python-versions = ">=3.7" [package.dependencies] pytest = ">=7.0.0" -typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] @@ -610,11 +597,11 @@ dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client"] [[package]] name = "sphinxcontrib-applehelp" -version = "1.0.2" -description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" +version = "1.0.4" +description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" category = "main" optional = true -python-versions = ">=3.5" +python-versions = ">=3.8" [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] @@ -634,11 +621,11 @@ test = ["pytest"] [[package]] name = "sphinxcontrib-htmlhelp" -version = "2.0.0" +version = "2.0.1" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" category = "main" optional = true -python-versions = ">=3.6" +python-versions = ">=3.8" [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] @@ -730,13 +717,11 @@ cachetools = ">=5.3" chardet = ">=5.1" colorama = ">=0.4.6" filelock = ">=3.11" -importlib-metadata = {version = ">=6.4.1", markers = "python_version < \"3.8\""} packaging = ">=23.1" platformdirs = ">=3.2" pluggy = ">=1" pyproject-api = ">=1.5.1" tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.5", markers = "python_version < \"3.8\""} virtualenv = ">=20.21" [package.extras] @@ -776,7 +761,6 @@ python-versions = ">=3.7" [package.dependencies] distlib = ">=0.3.6,<1" filelock = ">=3.11,<4" -importlib-metadata = {version = ">=6.4.1", markers = "python_version < \"3.8\""} platformdirs = ">=3.2,<4" [package.extras] @@ -820,7 +804,7 @@ name = "zipp" version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" -optional = false +optional = true python-versions = ">=3.7" [package.extras] @@ -832,8 +816,8 @@ docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parse [metadata] lock-version = "1.1" -python-versions = "^3.7" -content-hash = "2170092ba4286081c160d2fd0162b8bf54fb42ba9e5b63f6e2f0a6eb1c95c4d5" +python-versions = "^3.8" +content-hash = "c18c381e1911a1ce74cdcb4fe0f0912228a8eefaa97be5a468c082e5747c5375" [metadata.files] alabaster = [ @@ -1131,8 +1115,8 @@ pluggy = [ {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] pre-commit = [ - {file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"}, - {file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"}, + {file = "pre_commit-3.3.1-py2.py3-none-any.whl", hash = "sha256:218e9e3f7f7f3271ebc355a15598a4d3893ad9fc7b57fe446db75644543323b9"}, + {file = "pre_commit-3.3.1.tar.gz", hash = "sha256:733f78c9a056cdd169baa6cd4272d51ecfda95346ef8a89bf93712706021b907"}, ] pydantic = [ {file = "pydantic-1.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e79e999e539872e903767c417c897e729e015872040e56b96e67968c3b918b2d"}, @@ -1275,16 +1259,16 @@ sphinx-rtd-theme = [ {file = "sphinx_rtd_theme-0.5.1.tar.gz", hash = "sha256:eda689eda0c7301a80cf122dad28b1861e5605cbf455558f3775e1e8200e83a5"}, ] sphinxcontrib-applehelp = [ - {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, - {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, + {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"}, + {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"}, ] sphinxcontrib-devhelp = [ {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, ] sphinxcontrib-htmlhelp = [ - {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, - {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, + {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, + {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, ] sphinxcontrib-jsmath = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, diff --git a/pyproject.toml b/pyproject.toml index fe4038532..2d77eeaf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,6 @@ kasa = "kasa.cli:cli" [tool.poetry.dependencies] python = "^3.8" anyio = "*" # see https://github.com/python-trio/asyncclick/issues/18 -importlib-metadata = "*" asyncclick = ">=8" pydantic = "^1" From 9550cbd2f772af8e3b3b70ee8d812de6e874771e Mon Sep 17 00:00:00 2001 From: Brian Davis Date: Thu, 18 May 2023 09:04:24 -0600 Subject: [PATCH 143/892] Exclude querying certain modules for KL125(US) which cause crashes (#451) * commented out modules that break * added exclusion logic to smartdevice.py * cleaning up a name * removing test fixture that isn't related to this PR * incorporating PR feedback * fixed an if statement * reduced exclusion list to just 'cloud' * Tidy up the issue comment Co-authored-by: Teemu R. * this seems to be what the linter whats --------- Co-authored-by: Teemu R. --- kasa/smartdevice.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index e817958f7..75efcded5 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -27,6 +27,9 @@ _LOGGER = logging.getLogger(__name__) +# Certain module queries will crash devices; this list skips those queries +MODEL_MODULE_SKIPLIST = {"KL125(US)": ["cloud"]} # Issue #345 + class DeviceType(Enum): """Device type enum.""" @@ -325,10 +328,15 @@ async def _modular_update(self, req: dict) -> None: ) self.add_module("emeter", Emeter(self, self.emeter_type)) - for module in self.modules.values(): + for module_name, module in self.modules.items(): if not module.is_supported: _LOGGER.debug("Module %s not supported, skipping" % module) continue + modules_to_skip = MODEL_MODULE_SKIPLIST.get(self.model, []) + if module_name in modules_to_skip: + _LOGGER.debug(f"Module {module} is excluded for {self.model}, skipping") + continue + q = module.query() _LOGGER.debug("Adding query for %s: %s", module, q) req = merge(req, q) From 2d42ca301f341112e1c75e888f48e445a22c7129 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jun 2023 18:03:04 -0500 Subject: [PATCH 144/892] Use orjson when already installed or with speedups extra (#466) * Use orjson when already installed * Use orjson when already installed * fix patch target * fix patch target * add speedups extra * Update README.md * Update README.md --- README.md | 5 + kasa/discover.py | 7 +- kasa/json.py | 15 + kasa/protocol.py | 7 +- kasa/tests/test_discovery.py | 4 +- poetry.lock | 1200 ++++++++++++++++++---------------- pyproject.toml | 3 + 7 files changed, 666 insertions(+), 575 deletions(-) create mode 100755 kasa/json.py diff --git a/README.md b/README.md index 57789630f..d18d956e9 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ You can install the most recent release using pip: pip install python-kasa ``` +If you are using cpython, it is recommended to install with `[speedups]` to enable orjson (faster json support): +``` +pip install python-kasa[speedups] +``` + Alternatively, you can clone this repository and use poetry to install the development version: ``` git clone https://github.com/python-kasa/python-kasa.git diff --git a/kasa/discover.py b/kasa/discover.py index 06285d1bc..217ec32c0 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -1,10 +1,11 @@ """Discovery module for TP-Link Smart Home devices.""" import asyncio -import json import logging import socket from typing import Awaitable, Callable, Dict, Optional, Type, cast +from kasa.json import dumps as json_dumps +from kasa.json import loads as json_loads from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb from kasa.smartdevice import SmartDevice, SmartDeviceException @@ -63,7 +64,7 @@ def connection_made(self, transport) -> None: def do_discover(self) -> None: """Send number of discovery datagrams.""" - req = json.dumps(Discover.DISCOVERY_QUERY) + 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): @@ -75,7 +76,7 @@ def datagram_received(self, data, addr) -> None: if ip in self.discovered_devices: return - info = json.loads(TPLinkSmartHomeProtocol.decrypt(data)) + info = json_loads(TPLinkSmartHomeProtocol.decrypt(data)) _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) try: diff --git a/kasa/json.py b/kasa/json.py new file mode 100755 index 000000000..4acc865f5 --- /dev/null +++ b/kasa/json.py @@ -0,0 +1,15 @@ +"""JSON abstraction.""" + +try: + import orjson + + def dumps(obj, *, default=None): + """Dump JSON.""" + return orjson.dumps(obj).decode() + + loads = orjson.loads +except ImportError: + import json + + dumps = json.dumps + loads = json.loads diff --git a/kasa/protocol.py b/kasa/protocol.py index b6d44be90..bcb8b7f54 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -12,13 +12,14 @@ import asyncio import contextlib import errno -import json import logging import struct from pprint import pformat as pf from typing import Dict, Generator, Optional, Union from .exceptions import SmartDeviceException +from .json import dumps as json_dumps +from .json import loads as json_loads _LOGGER = logging.getLogger(__name__) _NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} @@ -64,7 +65,7 @@ async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: self.query_lock = asyncio.Lock() if isinstance(request, dict): - request = json.dumps(request) + request = json_dumps(request) assert isinstance(request, str) timeout = TPLinkSmartHomeProtocol.DEFAULT_TIMEOUT @@ -96,7 +97,7 @@ async def _execute_query(self, request: str) -> Dict: buffer = await self.reader.readexactly(length) response = TPLinkSmartHomeProtocol.decrypt(buffer) - json_payload = json.loads(response) + json_payload = json_loads(response) if debug_log: _LOGGER.debug("%s << %s", self.host, pf(json_payload)) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 30a9c4c23..52325f393 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -91,7 +91,7 @@ async def test_discover_send(mocker): async def test_discover_datagram_received(mocker, discovery_data): """Verify that datagram received fills discovered_devices.""" proto = _DiscoverProtocol() - mocker.patch("json.loads", return_value=discovery_data) + mocker.patch("kasa.discover.json_loads", return_value=discovery_data) mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "encrypt") mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "decrypt") @@ -109,7 +109,7 @@ async def test_discover_datagram_received(mocker, discovery_data): async def test_discover_invalid_responses(msg, data, mocker): """Verify that we don't crash whole discovery if some devices in the network are sending unexpected data.""" proto = _DiscoverProtocol() - mocker.patch("json.loads", return_value=data) + mocker.patch("kasa.discover.json_loads", return_value=data) mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "encrypt") mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "decrypt") diff --git a/poetry.lock b/poetry.lock index 6309a3cec..065c64497 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,5 @@ +# This file is automatically @generated by Poetry and should not be changed by hand. + [[package]] name = "alabaster" version = "0.7.13" @@ -5,23 +7,32 @@ description = "A configurable sidebar-enabled Sphinx theme" category = "main" optional = true python-versions = ">=3.6" +files = [ + {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, + {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, +] [[package]] name = "anyio" -version = "3.6.2" +version = "3.7.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" category = "main" optional = false -python-versions = ">=3.6.2" +python-versions = ">=3.7" +files = [ + {file = "anyio-3.7.0-py3-none-any.whl", hash = "sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0"}, + {file = "anyio-3.7.0.tar.gz", hash = "sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce"}, +] [package.dependencies] +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" [package.extras] -doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] -trio = ["trio (>=0.16,<0.22)"] +doc = ["Sphinx (>=6.1.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme", "sphinxcontrib-jquery"] +test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (<0.22)"] [[package]] name = "asyncclick" @@ -30,28 +41,40 @@ description = "Composable command line interface toolkit, async version" category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "asyncclick-8.1.3.4-py3-none-any.whl", hash = "sha256:f8db604e37dabd43922d58f857817b1dfd8f88695b75c4cc1afe7ff1cc238a7b"}, + {file = "asyncclick-8.1.3.4.tar.gz", hash = "sha256:81d98cbf6c8813f9cd5599f586d56cfc532e9e6441391974d10827abb90fe833"}, +] [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} [[package]] -name = "Babel" +name = "babel" version = "2.12.1" description = "Internationalization utilities" category = "main" optional = true python-versions = ">=3.7" +files = [ + {file = "Babel-2.12.1-py3-none-any.whl", hash = "sha256:b4246fb7677d3b98f501a39d43396d3cafdc8eadb045f4a31be01863f655c610"}, + {file = "Babel-2.12.1.tar.gz", hash = "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455"}, +] [package.dependencies] pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} [[package]] name = "cachetools" -version = "5.3.0" +version = "5.3.1" description = "Extensible memoizing collections and decorators" category = "dev" optional = false -python-versions = "~=3.7" +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.3.1-py3-none-any.whl", hash = "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590"}, + {file = "cachetools-5.3.1.tar.gz", hash = "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b"}, +] [[package]] name = "certifi" @@ -60,6 +83,10 @@ description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, + {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, +] [[package]] name = "cfgv" @@ -68,6 +95,10 @@ description = "Validate configuration and produce human readable error messages. category = "dev" optional = false python-versions = ">=3.6.1" +files = [ + {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, + {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, +] [[package]] name = "chardet" @@ -76,6 +107,10 @@ description = "Universal encoding detector for Python 3" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "chardet-5.1.0-py3-none-any.whl", hash = "sha256:362777fb014af596ad31334fde1e8c327dfdb076e1960d1694662d46a6917ab9"}, + {file = "chardet-5.1.0.tar.gz", hash = "sha256:0d62712b956bc154f85fb0a266e2a3c5913c2967e00348701b32411d6def31e5"}, +] [[package]] name = "charset-normalizer" @@ -84,6 +119,83 @@ description = "The Real First Universal Charset Detector. Open, modern and activ category = "main" optional = false python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, + {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, +] [[package]] name = "codecov" @@ -92,6 +204,10 @@ description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5"}, + {file = "codecov-2.1.13.tar.gz", hash = "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c"}, +] [package.dependencies] coverage = "*" @@ -104,14 +220,80 @@ description = "Cross-platform colored terminal text." category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] [[package]] name = "coverage" -version = "7.2.5" +version = "7.2.7" description = "Code coverage measurement for Python" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, + {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, + {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, + {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, + {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, + {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, + {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, + {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, + {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, + {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, + {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, + {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, + {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, + {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, + {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, + {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, + {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, + {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, +] [package.dependencies] tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} @@ -126,6 +308,10 @@ description = "Distribution utilities" category = "dev" optional = false python-versions = "*" +files = [ + {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, + {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, +] [[package]] name = "docutils" @@ -134,29 +320,41 @@ description = "Docutils -- Python Documentation Utilities" category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, + {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, +] [[package]] name = "exceptiongroup" version = "1.1.1" description = "Backport of PEP 654 (exception groups)" -category = "dev" +category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, + {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, +] [package.extras] test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.12.0" +version = "3.12.2" description = "A platform independent file lock." category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "filelock-3.12.2-py3-none-any.whl", hash = "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec"}, + {file = "filelock-3.12.2.tar.gz", hash = "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81"}, +] [package.extras] -docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] +docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] [[package]] name = "identify" @@ -165,6 +363,10 @@ description = "File identification library for Python" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "identify-2.5.24-py2.py3-none-any.whl", hash = "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d"}, + {file = "identify-2.5.24.tar.gz", hash = "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4"}, +] [package.extras] license = ["ukkonen"] @@ -176,6 +378,10 @@ description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false python-versions = ">=3.5" +files = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] [[package]] name = "imagesize" @@ -184,6 +390,10 @@ description = "Getting image size from png/jpeg/jpeg2000/gif file" category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, + {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, +] [[package]] name = "importlib-metadata" @@ -192,6 +402,10 @@ description = "Read metadata from Python packages" category = "main" optional = true python-versions = ">=3.7" +files = [ + {file = "importlib_metadata-6.6.0-py3-none-any.whl", hash = "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed"}, + {file = "importlib_metadata-6.6.0.tar.gz", hash = "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705"}, +] [package.dependencies] zipp = ">=0.5" @@ -208,14 +422,22 @@ description = "brain-dead simple config-ini parsing" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] [[package]] -name = "Jinja2" +name = "jinja2" version = "3.1.2" description = "A very fast and expressive template engine." category = "main" optional = true python-versions = ">=3.7" +files = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] [package.dependencies] MarkupSafe = ">=2.0" @@ -230,13 +452,17 @@ description = "Python port of markdown-it. Markdown parsing, done right!" category = "main" optional = true python-versions = ">=3.7" +files = [ + {file = "markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1"}, + {file = "markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30"}, +] [package.dependencies] mdurl = ">=0.1,<1.0" [package.extras] benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code_style = ["pre-commit (>=3.0,<4.0)"] +code-style = ["pre-commit (>=3.0,<4.0)"] compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] linkify = ["linkify-it-py (>=1,<3)"] plugins = ["mdit-py-plugins"] @@ -245,12 +471,64 @@ rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx- testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] -name = "MarkupSafe" -version = "2.1.2" +name = "markupsafe" +version = "2.1.3" description = "Safely add untrusted strings to HTML/XML markup." category = "main" optional = true python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, + {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, +] [[package]] name = "mdit-py-plugins" @@ -259,12 +537,16 @@ description = "Collection of plugins for markdown-it-py" category = "main" optional = true python-versions = ">=3.7" +files = [ + {file = "mdit-py-plugins-0.3.5.tar.gz", hash = "sha256:eee0adc7195e5827e17e02d2a258a2ba159944a0748f59c5099a4a27f78fcf6a"}, + {file = "mdit_py_plugins-0.3.5-py3-none-any.whl", hash = "sha256:ca9a0714ea59a24b2b044a1831f48d817dd0c817e84339f20e7889f392d77c4e"}, +] [package.dependencies] markdown-it-py = ">=1.0.0,<3.0.0" [package.extras] -code_style = ["pre-commit"] +code-style = ["pre-commit"] rtd = ["attrs", "myst-parser (>=0.16.1,<0.17.0)", "sphinx-book-theme (>=0.1.0,<0.2.0)"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] @@ -275,6 +557,10 @@ description = "Markdown URL utilities" category = "main" optional = true python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] [[package]] name = "myst-parser" @@ -283,6 +569,10 @@ description = "An extended commonmark compliant parser, with bridges to docutils category = "main" optional = true python-versions = ">=3.7" +files = [ + {file = "myst-parser-0.18.1.tar.gz", hash = "sha256:79317f4bb2c13053dd6e64f9da1ba1da6cd9c40c8a430c447a7b146a594c246d"}, + {file = "myst_parser-0.18.1-py3-none-any.whl", hash = "sha256:61b275b85d9f58aa327f370913ae1bec26ebad372cc99f3ab85c8ec3ee8d9fb8"}, +] [package.dependencies] docutils = ">=0.15,<0.20" @@ -294,7 +584,7 @@ sphinx = ">=4,<6" typing-extensions = "*" [package.extras] -code_style = ["pre-commit (>=2.12,<3.0)"] +code-style = ["pre-commit (>=2.12,<3.0)"] linkify = ["linkify-it-py (>=1.0,<2.0)"] rtd = ["ipython", "sphinx-book-theme", "sphinx-design", "sphinxcontrib.mermaid (>=0.7.1,<0.8.0)", "sphinxext-opengraph (>=0.6.3,<0.7.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=6,<7)", "pytest-cov", "pytest-param-files (>=0.3.4,<0.4.0)", "pytest-regressions", "sphinx (<5.2)", "sphinx-pytest"] @@ -306,10 +596,70 @@ description = "Node.js virtual environment builder" category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] [package.dependencies] setuptools = "*" +[[package]] +name = "orjson" +version = "3.9.1" +description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" +category = "main" +optional = true +python-versions = ">=3.7" +files = [ + {file = "orjson-3.9.1-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c4434b7b786fdc394b95d029fb99949d7c2b05bbd4bf5cb5e3906be96ffeee3b"}, + {file = "orjson-3.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09faf14f74ed47e773fa56833be118e04aa534956f661eb491522970b7478e3b"}, + {file = "orjson-3.9.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:503eb86a8d53a187fe66aa80c69295a3ca35475804da89a9547e4fce5f803822"}, + {file = "orjson-3.9.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20f2804b5a1dbd3609c086041bd243519224d47716efd7429db6c03ed28b7cc3"}, + {file = "orjson-3.9.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fd828e0656615a711c4cc4da70f3cac142e66a6703ba876c20156a14e28e3fa"}, + {file = "orjson-3.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec53d648176f873203b9c700a0abacab33ca1ab595066e9d616f98cdc56f4434"}, + {file = "orjson-3.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e186ae76b0d97c505500664193ddf508c13c1e675d9b25f1f4414a7606100da6"}, + {file = "orjson-3.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d4edee78503016f4df30aeede0d999b3cb11fb56f47e9db0e487bce0aaca9285"}, + {file = "orjson-3.9.1-cp310-none-win_amd64.whl", hash = "sha256:a4cc5d21e68af982d9a2528ac61e604f092c60eed27aef3324969c68f182ec7e"}, + {file = "orjson-3.9.1-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:761b6efd33c49de20dd73ce64cc59da62c0dab10aa6015f582680e0663cc792c"}, + {file = "orjson-3.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31229f9d0b8dc2ef7ee7e4393f2e4433a28e16582d4b25afbfccc9d68dc768f8"}, + {file = "orjson-3.9.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b7ab18d55ecb1de543d452f0a5f8094b52282b916aa4097ac11a4c79f317b86"}, + {file = "orjson-3.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db774344c39041f4801c7dfe03483df9203cbd6c84e601a65908e5552228dd25"}, + {file = "orjson-3.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae47ef8c0fe89c4677db7e9e1fb2093ca6e66c3acbee5442d84d74e727edad5e"}, + {file = "orjson-3.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:103952c21575b9805803c98add2eaecd005580a1e746292ed2ec0d76dd3b9746"}, + {file = "orjson-3.9.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2cb0121e6f2c9da3eddf049b99b95fef0adf8480ea7cb544ce858706cdf916eb"}, + {file = "orjson-3.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:24d4ddaa2876e657c0fd32902b5c451fd2afc35159d66a58da7837357044b8c2"}, + {file = "orjson-3.9.1-cp311-none-win_amd64.whl", hash = "sha256:0b53b5f72cf536dd8aa4fc4c95e7e09a7adb119f8ff8ee6cc60f735d7740ad6a"}, + {file = "orjson-3.9.1-cp37-cp37m-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4b68d01a506242316a07f1d2f29fb0a8b36cee30a7c35076f1ef59dce0890c1"}, + {file = "orjson-3.9.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9dd4abe6c6fd352f00f4246d85228f6a9847d0cc14f4d54ee553718c225388f"}, + {file = "orjson-3.9.1-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e20bca5e13041e31ceba7a09bf142e6d63c8a7467f5a9c974f8c13377c75af2"}, + {file = "orjson-3.9.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ae0467d01eb1e4bcffef4486d964bfd1c2e608103e75f7074ed34be5df48cc"}, + {file = "orjson-3.9.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06f6ab4697fab090517f295915318763a97a12ee8186054adf21c1e6f6abbd3d"}, + {file = "orjson-3.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8515867713301fa065c58ec4c9053ba1a22c35113ab4acad555317b8fd802e50"}, + {file = "orjson-3.9.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:393d0697d1dfa18d27d193e980c04fdfb672c87f7765b87952f550521e21b627"}, + {file = "orjson-3.9.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d96747662d3666f79119e5d28c124e7d356c7dc195cd4b09faea4031c9079dc9"}, + {file = "orjson-3.9.1-cp37-none-win_amd64.whl", hash = "sha256:6d173d3921dd58a068c88ec22baea7dbc87a137411501618b1292a9d6252318e"}, + {file = "orjson-3.9.1-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d1c2b0b4246c992ce2529fc610a446b945f1429445ece1c1f826a234c829a918"}, + {file = "orjson-3.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19f70ba1f441e1c4bb1a581f0baa092e8b3e3ce5b2aac2e1e090f0ac097966da"}, + {file = "orjson-3.9.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:375d65f002e686212aac42680aed044872c45ee4bc656cf63d4a215137a6124a"}, + {file = "orjson-3.9.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4751cee4a7b1daeacb90a7f5adf2170ccab893c3ab7c5cea58b45a13f89b30b3"}, + {file = "orjson-3.9.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78d9a2a4b2302d5ebc3695498ebc305c3568e5ad4f3501eb30a6405a32d8af22"}, + {file = "orjson-3.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46b4facc32643b2689dfc292c0c463985dac4b6ab504799cf51fc3c6959ed668"}, + {file = "orjson-3.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec7c8a0f1bf35da0d5fd14f8956f3b82a9a6918a3c6963d718dfd414d6d3b604"}, + {file = "orjson-3.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d3a40b0fbe06ccd4d6a99e523d20b47985655bcada8d1eba485b1b32a43e4904"}, + {file = "orjson-3.9.1-cp38-none-win_amd64.whl", hash = "sha256:402f9d3edfec4560a98880224ec10eba4c5f7b4791e4bc0d4f4d8df5faf2a006"}, + {file = "orjson-3.9.1-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:49c0d78dcd34626e2e934f1192d7c052b94e0ecadc5f386fd2bda6d2e03dadf5"}, + {file = "orjson-3.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:125f63e56d38393daa0a1a6dc6fedefca16c538614b66ea5997c3bd3af35ef26"}, + {file = "orjson-3.9.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:08927970365d2e1f3ce4894f9ff928a7b865d53f26768f1bbdd85dd4fee3e966"}, + {file = "orjson-3.9.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9a744e212d4780ecd67f4b6b128b2e727bee1df03e7059cddb2dfe1083e7dc4"}, + {file = "orjson-3.9.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d1dbf36db7240c61eec98c8d21545d671bce70be0730deb2c0d772e06b71af3"}, + {file = "orjson-3.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80a1e384626f76b66df615f7bb622a79a25c166d08c5d2151ffd41f24c4cc104"}, + {file = "orjson-3.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:15d28872fb055bf17ffca913826e618af61b2f689d2b170f72ecae1a86f80d52"}, + {file = "orjson-3.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1e4d905338f9ef32c67566929dfbfbb23cc80287af8a2c38930fb0eda3d40b76"}, + {file = "orjson-3.9.1-cp39-none-win_amd64.whl", hash = "sha256:48a27da6c7306965846565cc385611d03382bbd84120008653aa2f6741e2105d"}, + {file = "orjson-3.9.1.tar.gz", hash = "sha256:db373a25ec4a4fccf8186f9a72a1b3442837e40807a736a815ab42481e83b7d0"}, +] + [[package]] name = "packaging" version = "23.1" @@ -317,18 +667,26 @@ description = "Core utilities for Python packages" category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, + {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, +] [[package]] name = "platformdirs" -version = "3.5.1" +version = "3.5.3" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "platformdirs-3.5.3-py3-none-any.whl", hash = "sha256:0ade98a4895e87dc51d47151f7d2ec290365a585151d97b4d8d6312ed6132fed"}, + {file = "platformdirs-3.5.3.tar.gz", hash = "sha256:e48fabd87db8f3a7df7150a4a5ea22c546ee8bc39bc2473244730d4b56d2cc4e"}, +] [package.extras] -docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.2.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] +docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"] [[package]] name = "pluggy" @@ -337,6 +695,10 @@ description = "plugin and hook calling mechanisms for python" category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] [package.extras] dev = ["pre-commit", "tox"] @@ -344,11 +706,15 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "3.3.1" +version = "3.3.3" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false python-versions = ">=3.8" +files = [ + {file = "pre_commit-3.3.3-py2.py3-none-any.whl", hash = "sha256:10badb65d6a38caff29703362271d7dca483d01da88f9d7e05d0b97171c136cb"}, + {file = "pre_commit-3.3.3.tar.gz", hash = "sha256:a2256f489cd913d575c145132ae196fe335da32d91a8294b7afe6622335dd023"}, +] [package.dependencies] cfgv = ">=2.0.0" @@ -359,11 +725,49 @@ virtualenv = ">=20.10.0" [[package]] name = "pydantic" -version = "1.10.7" +version = "1.10.9" description = "Data validation and settings management using python type hints" category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "pydantic-1.10.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e692dec4a40bfb40ca530e07805b1208c1de071a18d26af4a2a0d79015b352ca"}, + {file = "pydantic-1.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3c52eb595db83e189419bf337b59154bdcca642ee4b2a09e5d7797e41ace783f"}, + {file = "pydantic-1.10.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:939328fd539b8d0edf244327398a667b6b140afd3bf7e347cf9813c736211896"}, + {file = "pydantic-1.10.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b48d3d634bca23b172f47f2335c617d3fcb4b3ba18481c96b7943a4c634f5c8d"}, + {file = "pydantic-1.10.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:f0b7628fb8efe60fe66fd4adadd7ad2304014770cdc1f4934db41fe46cc8825f"}, + {file = "pydantic-1.10.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e1aa5c2410769ca28aa9a7841b80d9d9a1c5f223928ca8bec7e7c9a34d26b1d4"}, + {file = "pydantic-1.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:eec39224b2b2e861259d6f3c8b6290d4e0fbdce147adb797484a42278a1a486f"}, + {file = "pydantic-1.10.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d111a21bbbfd85c17248130deac02bbd9b5e20b303338e0dbe0faa78330e37e0"}, + {file = "pydantic-1.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e9aec8627a1a6823fc62fb96480abe3eb10168fd0d859ee3d3b395105ae19a7"}, + {file = "pydantic-1.10.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07293ab08e7b4d3c9d7de4949a0ea571f11e4557d19ea24dd3ae0c524c0c334d"}, + {file = "pydantic-1.10.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ee829b86ce984261d99ff2fd6e88f2230068d96c2a582f29583ed602ef3fc2c"}, + {file = "pydantic-1.10.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4b466a23009ff5cdd7076eb56aca537c745ca491293cc38e72bf1e0e00de5b91"}, + {file = "pydantic-1.10.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7847ca62e581e6088d9000f3c497267868ca2fa89432714e21a4fb33a04d52e8"}, + {file = "pydantic-1.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:7845b31959468bc5b78d7b95ec52fe5be32b55d0d09983a877cca6aedc51068f"}, + {file = "pydantic-1.10.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:517a681919bf880ce1dac7e5bc0c3af1e58ba118fd774da2ffcd93c5f96eaece"}, + {file = "pydantic-1.10.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67195274fd27780f15c4c372f4ba9a5c02dad6d50647b917b6a92bf00b3d301a"}, + {file = "pydantic-1.10.9-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2196c06484da2b3fded1ab6dbe182bdabeb09f6318b7fdc412609ee2b564c49a"}, + {file = "pydantic-1.10.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6257bb45ad78abacda13f15bde5886efd6bf549dd71085e64b8dcf9919c38b60"}, + {file = "pydantic-1.10.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3283b574b01e8dbc982080d8287c968489d25329a463b29a90d4157de4f2baaf"}, + {file = "pydantic-1.10.9-cp37-cp37m-win_amd64.whl", hash = "sha256:5f8bbaf4013b9a50e8100333cc4e3fa2f81214033e05ac5aa44fa24a98670a29"}, + {file = "pydantic-1.10.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b9cd67fb763248cbe38f0593cd8611bfe4b8ad82acb3bdf2b0898c23415a1f82"}, + {file = "pydantic-1.10.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f50e1764ce9353be67267e7fd0da08349397c7db17a562ad036aa7c8f4adfdb6"}, + {file = "pydantic-1.10.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73ef93e5e1d3c8e83f1ff2e7fdd026d9e063c7e089394869a6e2985696693766"}, + {file = "pydantic-1.10.9-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:128d9453d92e6e81e881dd7e2484e08d8b164da5507f62d06ceecf84bf2e21d3"}, + {file = "pydantic-1.10.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ad428e92ab68798d9326bb3e5515bc927444a3d71a93b4a2ca02a8a5d795c572"}, + {file = "pydantic-1.10.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fab81a92f42d6d525dd47ced310b0c3e10c416bbfae5d59523e63ea22f82b31e"}, + {file = "pydantic-1.10.9-cp38-cp38-win_amd64.whl", hash = "sha256:963671eda0b6ba6926d8fc759e3e10335e1dc1b71ff2a43ed2efd6996634dafb"}, + {file = "pydantic-1.10.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:970b1bdc6243ef663ba5c7e36ac9ab1f2bfecb8ad297c9824b542d41a750b298"}, + {file = "pydantic-1.10.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7e1d5290044f620f80cf1c969c542a5468f3656de47b41aa78100c5baa2b8276"}, + {file = "pydantic-1.10.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83fcff3c7df7adff880622a98022626f4f6dbce6639a88a15a3ce0f96466cb60"}, + {file = "pydantic-1.10.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0da48717dc9495d3a8f215e0d012599db6b8092db02acac5e0d58a65248ec5bc"}, + {file = "pydantic-1.10.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0a2aabdc73c2a5960e87c3ffebca6ccde88665616d1fd6d3db3178ef427b267a"}, + {file = "pydantic-1.10.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9863b9420d99dfa9c064042304868e8ba08e89081428a1c471858aa2af6f57c4"}, + {file = "pydantic-1.10.9-cp39-cp39-win_amd64.whl", hash = "sha256:e7c9900b43ac14110efa977be3da28931ffc74c27e96ee89fbcaaf0b0fe338e1"}, + {file = "pydantic-1.10.9-py3-none-any.whl", hash = "sha256:6cafde02f6699ce4ff643417d1a9223716ec25e228ddc3b436fe7e2d25a1f305"}, + {file = "pydantic-1.10.9.tar.gz", hash = "sha256:95c70da2cd3b6ddf3b9645ecaa8d98f3d80c606624b6d245558d202cd23ea3be"}, +] [package.dependencies] typing-extensions = ">=4.2.0" @@ -373,39 +777,51 @@ dotenv = ["python-dotenv (>=0.10.4)"] email = ["email-validator (>=1.0.3)"] [[package]] -name = "Pygments" +name = "pygments" version = "2.15.1" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = true python-versions = ">=3.7" +files = [ + {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"}, + {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"}, +] [package.extras] plugins = ["importlib-metadata"] [[package]] name = "pyproject-api" -version = "1.5.1" +version = "1.5.2" description = "API to interact with the python pyproject.toml based projects" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "pyproject_api-1.5.2-py3-none-any.whl", hash = "sha256:9cffcbfb64190f207444d7579d315f3278f2c04ba46d685fad93197b5326d348"}, + {file = "pyproject_api-1.5.2.tar.gz", hash = "sha256:999f58fa3c92b23ebd31a6bad5d1f87d456744d75e05391be7f5c729015d3d91"}, +] [package.dependencies] -packaging = ">=23" +packaging = ">=23.1" tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} [package.extras] -docs = ["furo (>=2022.12.7)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] -testing = ["covdefaults (>=2.2.2)", "importlib-metadata (>=6)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "virtualenv (>=20.17.1)", "wheel (>=0.38.4)"] +docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +testing = ["covdefaults (>=2.3)", "importlib-metadata (>=6.6)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "setuptools (>=67.8)", "wheel (>=0.40)"] [[package]] name = "pytest" -version = "7.3.1" +version = "7.3.2" description = "pytest: simple powerful testing with Python" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "pytest-7.3.2-py3-none-any.whl", hash = "sha256:cdcbd012c9312258922f8cd3f1b62a6580fdced17db6014896053d47cddf9295"}, + {file = "pytest-7.3.2.tar.gz", hash = "sha256:ee990a3cc55ba808b80795a79944756f315c67c12b56abd3ac993a7b8c17030b"}, +] [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} @@ -416,7 +832,7 @@ pluggy = ">=0.12,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" @@ -425,6 +841,10 @@ description = "Pytest support for asyncio" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "pytest-asyncio-0.21.0.tar.gz", hash = "sha256:2b38a496aef56f56b0e87557ec313e11e1ab9276fc3863f6a7be0f1d0e415e1b"}, + {file = "pytest_asyncio-0.21.0-py3-none-any.whl", hash = "sha256:f2b3366b7cd501a4056858bd39349d5af19742aed2d81660b7998b6341c7eb9c"}, +] [package.dependencies] pytest = ">=7.0.0" @@ -435,11 +855,15 @@ testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy [[package]] name = "pytest-cov" -version = "4.0.0" +version = "4.1.0" description = "Pytest plugin for measuring coverage." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] [package.dependencies] coverage = {version = ">=5.2.1", extras = ["toml"]} @@ -450,11 +874,15 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale [[package]] name = "pytest-mock" -version = "3.10.0" +version = "3.11.1" description = "Thin-wrapper around the mock package for easier use with pytest" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "pytest-mock-3.11.1.tar.gz", hash = "sha256:7f6b125602ac6d743e523ae0bfa71e1a697a2f5534064528c6ff84c2f7c2fc7f"}, + {file = "pytest_mock-3.11.1-py3-none-any.whl", hash = "sha256:21c279fff83d70763b05f8874cc9cfb3fcacd6d354247a976f9529d19f9acf39"}, +] [package.dependencies] pytest = ">=5.0" @@ -469,6 +897,10 @@ description = "pytest-sugar is a plugin for pytest that changes the default look category = "dev" optional = false python-versions = "*" +files = [ + {file = "pytest-sugar-0.9.7.tar.gz", hash = "sha256:f1e74c1abfa55f7241cf7088032b6e378566f16b938f3f08905e2cf4494edd46"}, + {file = "pytest_sugar-0.9.7-py2.py3-none-any.whl", hash = "sha256:8cb5a4e5f8bbcd834622b0235db9e50432f4cbd71fef55b467fe44e43701e062"}, +] [package.dependencies] packaging = ">=21.3" @@ -485,22 +917,72 @@ description = "World timezone definitions, modern and historical" category = "main" optional = true python-versions = "*" +files = [ + {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"}, + {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, +] [[package]] -name = "PyYAML" +name = "pyyaml" version = "6.0" description = "YAML parser and emitter for Python" category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, + {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, + {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, + {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, + {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, + {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, + {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, + {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, + {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, + {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, + {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, + {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, + {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, + {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, + {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, + {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, + {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, +] [[package]] name = "requests" -version = "2.30.0" +version = "2.31.0" description = "Python HTTP for Humans." category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] [package.dependencies] certifi = ">=2017.4.17" @@ -510,19 +992,23 @@ urllib3 = ">=1.21.1,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "setuptools" -version = "67.7.2" +version = "67.8.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "setuptools-67.8.0-py3-none-any.whl", hash = "sha256:5df61bf30bb10c6f756eb19e7c9f3b473051f48db77fddbe06ff2ca307df9a6f"}, + {file = "setuptools-67.8.0.tar.gz", hash = "sha256:62642358adc77ffa87233bc4d2354c4b2682d214048f500964dbe760ccedf102"}, +] [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] @@ -532,6 +1018,10 @@ description = "Python 2 and 3 compatibility utilities" category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] [[package]] name = "sniffio" @@ -540,6 +1030,10 @@ description = "Sniff out which async library your code is running under" category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, +] [[package]] name = "snowballstemmer" @@ -548,14 +1042,22 @@ description = "This package provides 29 stemmers for 28 languages generated from category = "main" optional = true python-versions = "*" +files = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] [[package]] -name = "Sphinx" +name = "sphinx" version = "4.5.0" description = "Python documentation generator" category = "main" optional = true python-versions = ">=3.6" +files = [ + {file = "Sphinx-4.5.0-py3-none-any.whl", hash = "sha256:ebf612653238bcc8f4359627a9b7ce44ede6fdd75d9d30f68255c7383d3a6226"}, + {file = "Sphinx-4.5.0.tar.gz", hash = "sha256:7bf8ca9637a4ee15af412d1a1d9689fec70523a68ca9bb9127c2f3eeb344e2e6"}, +] [package.dependencies] alabaster = ">=0.7,<0.8" @@ -588,6 +1090,10 @@ description = "Read the Docs theme for Sphinx" category = "main" optional = true python-versions = "*" +files = [ + {file = "sphinx_rtd_theme-0.5.1-py2.py3-none-any.whl", hash = "sha256:fa6bebd5ab9a73da8e102509a86f3fcc36dec04a0b52ea80e5a033b2aba00113"}, + {file = "sphinx_rtd_theme-0.5.1.tar.gz", hash = "sha256:eda689eda0c7301a80cf122dad28b1861e5605cbf455558f3775e1e8200e83a5"}, +] [package.dependencies] sphinx = "*" @@ -602,6 +1108,10 @@ description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple category = "main" optional = true python-versions = ">=3.8" +files = [ + {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"}, + {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"}, +] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] @@ -614,6 +1124,10 @@ description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp category = "main" optional = true python-versions = ">=3.5" +files = [ + {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, + {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, +] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] @@ -626,6 +1140,10 @@ description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML h category = "main" optional = true python-versions = ">=3.8" +files = [ + {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, + {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, +] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] @@ -638,8 +1156,12 @@ description = "A sphinx extension which renders display math in HTML via JavaScr category = "main" optional = true python-versions = ">=3.5" - -[package.extras] +files = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] + +[package.extras] test = ["flake8", "mypy", "pytest"] [[package]] @@ -649,6 +1171,10 @@ description = "Sphinx extension to include program output" category = "main" optional = true python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" +files = [ + {file = "sphinxcontrib-programoutput-0.17.tar.gz", hash = "sha256:300ee9b8caee8355d25cc74b4d1c7efd12e608d2ad165e3141d31e6fbc152b7f"}, + {file = "sphinxcontrib_programoutput-0.17-py2.py3-none-any.whl", hash = "sha256:0ef1c1d9159dbe7103077748214305eb4e0138e861feb71c0c346afc5fe97f84"}, +] [package.dependencies] Sphinx = ">=1.7.0" @@ -660,6 +1186,10 @@ description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp d category = "main" optional = true python-versions = ">=3.5" +files = [ + {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, + {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, +] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] @@ -672,6 +1202,10 @@ description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs category = "main" optional = true python-versions = ">=3.5" +files = [ + {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, + {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, +] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] @@ -684,6 +1218,10 @@ description = "ANSI color formatting for output in terminal" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "termcolor-2.3.0-py3-none-any.whl", hash = "sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475"}, + {file = "termcolor-2.3.0.tar.gz", hash = "sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a"}, +] [package.extras] tests = ["pytest", "pytest-cov"] @@ -695,6 +1233,10 @@ description = "Python Library for Tom's Obvious, Minimal Language" category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] [[package]] name = "tomli" @@ -703,46 +1245,62 @@ description = "A lil' TOML parser" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] [[package]] name = "tox" -version = "4.5.1" +version = "4.6.2" description = "tox is a generic virtualenv management and test command line tool" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "tox-4.6.2-py3-none-any.whl", hash = "sha256:52241851a7b0cd7de07d6ef067a13b092d2a4f82fe9048efb2444aed1708d713"}, + {file = "tox-4.6.2.tar.gz", hash = "sha256:58c7c2acce2f3d44cd1b359349557162336288ecf19ef53ccda89c9cee0ad9c4"}, +] [package.dependencies] -cachetools = ">=5.3" +cachetools = ">=5.3.1" chardet = ">=5.1" colorama = ">=0.4.6" -filelock = ">=3.11" +filelock = ">=3.12.2" packaging = ">=23.1" -platformdirs = ">=3.2" +platformdirs = ">=3.5.3" pluggy = ">=1" -pyproject-api = ">=1.5.1" +pyproject-api = ">=1.5.2" tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} -virtualenv = ">=20.21" +virtualenv = ">=20.23.1" [package.extras] -docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-argparse-cli (>=1.11)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2022.1.2b11)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] -testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "devpi-process (>=0.3)", "diff-cover (>=7.5)", "distlib (>=0.3.6)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.14)", "psutil (>=5.9.4)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-xdist (>=3.2.1)", "re-assert (>=1.1)", "time-machine (>=2.9)", "wheel (>=0.40)"] +docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.23.2,!=1.23.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.1.1)", "devpi-process (>=0.3.1)", "diff-cover (>=7.6)", "distlib (>=0.3.6)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.17.1)", "psutil (>=5.9.5)", "pytest (>=7.3.2)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-xdist (>=3.3.1)", "re-assert (>=1.1)", "time-machine (>=2.10)", "wheel (>=0.40)"] [[package]] name = "typing-extensions" -version = "4.5.0" +version = "4.6.3" description = "Backported and Experimental Type Hints for Python 3.7+" category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "typing_extensions-4.6.3-py3-none-any.whl", hash = "sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26"}, + {file = "typing_extensions-4.6.3.tar.gz", hash = "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5"}, +] [[package]] name = "urllib3" -version = "2.0.2" +version = "2.0.3" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "urllib3-2.0.3-py3-none-any.whl", hash = "sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1"}, + {file = "urllib3-2.0.3.tar.gz", hash = "sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825"}, +] [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] @@ -752,20 +1310,24 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.23.0" +version = "20.23.1" description = "Virtual Python Environment builder" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.23.1-py3-none-any.whl", hash = "sha256:34da10f14fea9be20e0fd7f04aba9732f84e593dac291b757ce42e3368a39419"}, + {file = "virtualenv-20.23.1.tar.gz", hash = "sha256:8ff19a38c1021c742148edc4f81cb43d7f8c6816d2ede2ab72af5b84c749ade1"}, +] [package.dependencies] distlib = ">=0.3.6,<1" -filelock = ">=3.11,<4" -platformdirs = ">=3.2,<4" +filelock = ">=3.12,<4" +platformdirs = ">=3.5.1,<4" [package.extras] -docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.7.1)", "time-machine (>=2.9)"] +docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezer (>=0.4.6)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.8)", "time-machine (>=2.9)"] [[package]] name = "voluptuous" @@ -774,6 +1336,10 @@ description = "" category = "dev" optional = false python-versions = "*" +files = [ + {file = "voluptuous-0.13.1-py3-none-any.whl", hash = "sha256:4b838b185f5951f2d6e8752b68fcf18bd7a9c26ded8f143f92d6d28f3921a3e6"}, + {file = "voluptuous-0.13.1.tar.gz", hash = "sha256:e8d31c20601d6773cb14d4c0f42aee29c6821bbd1018039aac7ac5605b489723"}, +] [[package]] name = "xdoctest" @@ -782,6 +1348,10 @@ description = "A rewrite of the builtin doctest module" category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "xdoctest-1.1.1-py3-none-any.whl", hash = "sha256:d59d4ed91cb92e4430ef0ad1b134a2bef02adff7d2fb9c9f057547bee44081a2"}, + {file = "xdoctest-1.1.1.tar.gz", hash = "sha256:2eac8131bdcdf2781b4e5a62d6de87f044b730cc8db8af142a51bb29c245e779"}, +] [package.dependencies] six = "*" @@ -806,6 +1376,10 @@ description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = true python-versions = ">=3.7" +files = [ + {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, + {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, +] [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] @@ -815,514 +1389,6 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] [metadata] -lock-version = "1.1" +lock-version = "2.0" python-versions = "^3.8" -content-hash = "c18c381e1911a1ce74cdcb4fe0f0912228a8eefaa97be5a468c082e5747c5375" - -[metadata.files] -alabaster = [ - {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, - {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, -] -anyio = [ - {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"}, - {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"}, -] -asyncclick = [ - {file = "asyncclick-8.1.3.4-py3-none-any.whl", hash = "sha256:f8db604e37dabd43922d58f857817b1dfd8f88695b75c4cc1afe7ff1cc238a7b"}, - {file = "asyncclick-8.1.3.4.tar.gz", hash = "sha256:81d98cbf6c8813f9cd5599f586d56cfc532e9e6441391974d10827abb90fe833"}, -] -Babel = [ - {file = "Babel-2.12.1-py3-none-any.whl", hash = "sha256:b4246fb7677d3b98f501a39d43396d3cafdc8eadb045f4a31be01863f655c610"}, - {file = "Babel-2.12.1.tar.gz", hash = "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455"}, -] -cachetools = [ - {file = "cachetools-5.3.0-py3-none-any.whl", hash = "sha256:429e1a1e845c008ea6c85aa35d4b98b65d6a9763eeef3e37e92728a12d1de9d4"}, - {file = "cachetools-5.3.0.tar.gz", hash = "sha256:13dfddc7b8df938c21a940dfa6557ce6e94a2f1cdfa58eb90c805721d58f2c14"}, -] -certifi = [ - {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, - {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, -] -cfgv = [ - {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, - {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, -] -chardet = [ - {file = "chardet-5.1.0-py3-none-any.whl", hash = "sha256:362777fb014af596ad31334fde1e8c327dfdb076e1960d1694662d46a6917ab9"}, - {file = "chardet-5.1.0.tar.gz", hash = "sha256:0d62712b956bc154f85fb0a266e2a3c5913c2967e00348701b32411d6def31e5"}, -] -charset-normalizer = [ - {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, - {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, -] -codecov = [ - {file = "codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5"}, - {file = "codecov-2.1.13.tar.gz", hash = "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c"}, -] -colorama = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] -coverage = [ - {file = "coverage-7.2.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:883123d0bbe1c136f76b56276074b0c79b5817dd4238097ffa64ac67257f4b6c"}, - {file = "coverage-7.2.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fbc2a127e857d2f8898aaabcc34c37771bf78a4d5e17d3e1f5c30cd0cbc62a"}, - {file = "coverage-7.2.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f3671662dc4b422b15776cdca89c041a6349b4864a43aa2350b6b0b03bbcc7f"}, - {file = "coverage-7.2.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780551e47d62095e088f251f5db428473c26db7829884323e56d9c0c3118791a"}, - {file = "coverage-7.2.5-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:066b44897c493e0dcbc9e6a6d9f8bbb6607ef82367cf6810d387c09f0cd4fe9a"}, - {file = "coverage-7.2.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9a4ee55174b04f6af539218f9f8083140f61a46eabcaa4234f3c2a452c4ed11"}, - {file = "coverage-7.2.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:706ec567267c96717ab9363904d846ec009a48d5f832140b6ad08aad3791b1f5"}, - {file = "coverage-7.2.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ae453f655640157d76209f42c62c64c4d4f2c7f97256d3567e3b439bd5c9b06c"}, - {file = "coverage-7.2.5-cp310-cp310-win32.whl", hash = "sha256:f81c9b4bd8aa747d417407a7f6f0b1469a43b36a85748145e144ac4e8d303cb5"}, - {file = "coverage-7.2.5-cp310-cp310-win_amd64.whl", hash = "sha256:dc945064a8783b86fcce9a0a705abd7db2117d95e340df8a4333f00be5efb64c"}, - {file = "coverage-7.2.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:40cc0f91c6cde033da493227797be2826cbf8f388eaa36a0271a97a332bfd7ce"}, - {file = "coverage-7.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a66e055254a26c82aead7ff420d9fa8dc2da10c82679ea850d8feebf11074d88"}, - {file = "coverage-7.2.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c10fbc8a64aa0f3ed136b0b086b6b577bc64d67d5581acd7cc129af52654384e"}, - {file = "coverage-7.2.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a22cbb5ede6fade0482111fa7f01115ff04039795d7092ed0db43522431b4f2"}, - {file = "coverage-7.2.5-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292300f76440651529b8ceec283a9370532f4ecba9ad67d120617021bb5ef139"}, - {file = "coverage-7.2.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7ff8f3fb38233035028dbc93715551d81eadc110199e14bbbfa01c5c4a43f8d8"}, - {file = "coverage-7.2.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:a08c7401d0b24e8c2982f4e307124b671c6736d40d1c39e09d7a8687bddf83ed"}, - {file = "coverage-7.2.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef9659d1cda9ce9ac9585c045aaa1e59223b143f2407db0eaee0b61a4f266fb6"}, - {file = "coverage-7.2.5-cp311-cp311-win32.whl", hash = "sha256:30dcaf05adfa69c2a7b9f7dfd9f60bc8e36b282d7ed25c308ef9e114de7fc23b"}, - {file = "coverage-7.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:97072cc90f1009386c8a5b7de9d4fc1a9f91ba5ef2146c55c1f005e7b5c5e068"}, - {file = "coverage-7.2.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bebea5f5ed41f618797ce3ffb4606c64a5de92e9c3f26d26c2e0aae292f015c1"}, - {file = "coverage-7.2.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:828189fcdda99aae0d6bf718ea766b2e715eabc1868670a0a07bf8404bf58c33"}, - {file = "coverage-7.2.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e8a95f243d01ba572341c52f89f3acb98a3b6d1d5d830efba86033dd3687ade"}, - {file = "coverage-7.2.5-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8834e5f17d89e05697c3c043d3e58a8b19682bf365048837383abfe39adaed5"}, - {file = "coverage-7.2.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d1f25ee9de21a39b3a8516f2c5feb8de248f17da7eead089c2e04aa097936b47"}, - {file = "coverage-7.2.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1637253b11a18f453e34013c665d8bf15904c9e3c44fbda34c643fbdc9d452cd"}, - {file = "coverage-7.2.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8e575a59315a91ccd00c7757127f6b2488c2f914096077c745c2f1ba5b8c0969"}, - {file = "coverage-7.2.5-cp37-cp37m-win32.whl", hash = "sha256:509ecd8334c380000d259dc66feb191dd0a93b21f2453faa75f7f9cdcefc0718"}, - {file = "coverage-7.2.5-cp37-cp37m-win_amd64.whl", hash = "sha256:12580845917b1e59f8a1c2ffa6af6d0908cb39220f3019e36c110c943dc875b0"}, - {file = "coverage-7.2.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b5016e331b75310610c2cf955d9f58a9749943ed5f7b8cfc0bb89c6134ab0a84"}, - {file = "coverage-7.2.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:373ea34dca98f2fdb3e5cb33d83b6d801007a8074f992b80311fc589d3e6b790"}, - {file = "coverage-7.2.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a063aad9f7b4c9f9da7b2550eae0a582ffc7623dca1c925e50c3fbde7a579771"}, - {file = "coverage-7.2.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38c0a497a000d50491055805313ed83ddba069353d102ece8aef5d11b5faf045"}, - {file = "coverage-7.2.5-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2b3b05e22a77bb0ae1a3125126a4e08535961c946b62f30985535ed40e26614"}, - {file = "coverage-7.2.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0342a28617e63ad15d96dca0f7ae9479a37b7d8a295f749c14f3436ea59fdcb3"}, - {file = "coverage-7.2.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cf97ed82ca986e5c637ea286ba2793c85325b30f869bf64d3009ccc1a31ae3fd"}, - {file = "coverage-7.2.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c2c41c1b1866b670573657d584de413df701f482574bad7e28214a2362cb1fd1"}, - {file = "coverage-7.2.5-cp38-cp38-win32.whl", hash = "sha256:10b15394c13544fce02382360cab54e51a9e0fd1bd61ae9ce012c0d1e103c813"}, - {file = "coverage-7.2.5-cp38-cp38-win_amd64.whl", hash = "sha256:a0b273fe6dc655b110e8dc89b8ec7f1a778d78c9fd9b4bda7c384c8906072212"}, - {file = "coverage-7.2.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c587f52c81211d4530fa6857884d37f514bcf9453bdeee0ff93eaaf906a5c1b"}, - {file = "coverage-7.2.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4436cc9ba5414c2c998eaedee5343f49c02ca93b21769c5fdfa4f9d799e84200"}, - {file = "coverage-7.2.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6599bf92f33ab041e36e06d25890afbdf12078aacfe1f1d08c713906e49a3fe5"}, - {file = "coverage-7.2.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:857abe2fa6a4973f8663e039ead8d22215d31db613ace76e4a98f52ec919068e"}, - {file = "coverage-7.2.5-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6f5cab2d7f0c12f8187a376cc6582c477d2df91d63f75341307fcdcb5d60303"}, - {file = "coverage-7.2.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:aa387bd7489f3e1787ff82068b295bcaafbf6f79c3dad3cbc82ef88ce3f48ad3"}, - {file = "coverage-7.2.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:156192e5fd3dbbcb11cd777cc469cf010a294f4c736a2b2c891c77618cb1379a"}, - {file = "coverage-7.2.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bd3b4b8175c1db502adf209d06136c000df4d245105c8839e9d0be71c94aefe1"}, - {file = "coverage-7.2.5-cp39-cp39-win32.whl", hash = "sha256:ddc5a54edb653e9e215f75de377354e2455376f416c4378e1d43b08ec50acc31"}, - {file = "coverage-7.2.5-cp39-cp39-win_amd64.whl", hash = "sha256:338aa9d9883aaaad53695cb14ccdeb36d4060485bb9388446330bef9c361c252"}, - {file = "coverage-7.2.5-pp37.pp38.pp39-none-any.whl", hash = "sha256:8877d9b437b35a85c18e3c6499b23674684bf690f5d96c1006a1ef61f9fdf0f3"}, - {file = "coverage-7.2.5.tar.gz", hash = "sha256:f99ef080288f09ffc687423b8d60978cf3a465d3f404a18d1a05474bd8575a47"}, -] -distlib = [ - {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, - {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, -] -docutils = [ - {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, - {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, -] -exceptiongroup = [ - {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, - {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, -] -filelock = [ - {file = "filelock-3.12.0-py3-none-any.whl", hash = "sha256:ad98852315c2ab702aeb628412cbf7e95b7ce8c3bf9565670b4eaecf1db370a9"}, - {file = "filelock-3.12.0.tar.gz", hash = "sha256:fc03ae43288c013d2ea83c8597001b1129db351aad9c57fe2409327916b8e718"}, -] -identify = [ - {file = "identify-2.5.24-py2.py3-none-any.whl", hash = "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d"}, - {file = "identify-2.5.24.tar.gz", hash = "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4"}, -] -idna = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, -] -imagesize = [ - {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, - {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, -] -importlib-metadata = [ - {file = "importlib_metadata-6.6.0-py3-none-any.whl", hash = "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed"}, - {file = "importlib_metadata-6.6.0.tar.gz", hash = "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705"}, -] -iniconfig = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] -Jinja2 = [ - {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, - {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, -] -markdown-it-py = [ - {file = "markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1"}, - {file = "markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30"}, -] -MarkupSafe = [ - {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-win32.whl", hash = "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-win32.whl", hash = "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-win32.whl", hash = "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859"}, - {file = "MarkupSafe-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-win32.whl", hash = "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2"}, - {file = "MarkupSafe-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-win32.whl", hash = "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed"}, - {file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"}, -] -mdit-py-plugins = [ - {file = "mdit-py-plugins-0.3.5.tar.gz", hash = "sha256:eee0adc7195e5827e17e02d2a258a2ba159944a0748f59c5099a4a27f78fcf6a"}, - {file = "mdit_py_plugins-0.3.5-py3-none-any.whl", hash = "sha256:ca9a0714ea59a24b2b044a1831f48d817dd0c817e84339f20e7889f392d77c4e"}, -] -mdurl = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] -myst-parser = [ - {file = "myst-parser-0.18.1.tar.gz", hash = "sha256:79317f4bb2c13053dd6e64f9da1ba1da6cd9c40c8a430c447a7b146a594c246d"}, - {file = "myst_parser-0.18.1-py3-none-any.whl", hash = "sha256:61b275b85d9f58aa327f370913ae1bec26ebad372cc99f3ab85c8ec3ee8d9fb8"}, -] -nodeenv = [ - {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, - {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, -] -packaging = [ - {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, - {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, -] -platformdirs = [ - {file = "platformdirs-3.5.1-py3-none-any.whl", hash = "sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5"}, - {file = "platformdirs-3.5.1.tar.gz", hash = "sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f"}, -] -pluggy = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, -] -pre-commit = [ - {file = "pre_commit-3.3.1-py2.py3-none-any.whl", hash = "sha256:218e9e3f7f7f3271ebc355a15598a4d3893ad9fc7b57fe446db75644543323b9"}, - {file = "pre_commit-3.3.1.tar.gz", hash = "sha256:733f78c9a056cdd169baa6cd4272d51ecfda95346ef8a89bf93712706021b907"}, -] -pydantic = [ - {file = "pydantic-1.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e79e999e539872e903767c417c897e729e015872040e56b96e67968c3b918b2d"}, - {file = "pydantic-1.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:01aea3a42c13f2602b7ecbbea484a98169fb568ebd9e247593ea05f01b884b2e"}, - {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:516f1ed9bc2406a0467dd777afc636c7091d71f214d5e413d64fef45174cfc7a"}, - {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae150a63564929c675d7f2303008d88426a0add46efd76c3fc797cd71cb1b46f"}, - {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ecbbc51391248116c0a055899e6c3e7ffbb11fb5e2a4cd6f2d0b93272118a209"}, - {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f4a2b50e2b03d5776e7f21af73e2070e1b5c0d0df255a827e7c632962f8315af"}, - {file = "pydantic-1.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:a7cd2251439988b413cb0a985c4ed82b6c6aac382dbaff53ae03c4b23a70e80a"}, - {file = "pydantic-1.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:68792151e174a4aa9e9fc1b4e653e65a354a2fa0fed169f7b3d09902ad2cb6f1"}, - {file = "pydantic-1.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe2507b8ef209da71b6fb5f4e597b50c5a34b78d7e857c4f8f3115effaef5fe"}, - {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10a86d8c8db68086f1e30a530f7d5f83eb0685e632e411dbbcf2d5c0150e8dcd"}, - {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75ae19d2a3dbb146b6f324031c24f8a3f52ff5d6a9f22f0683694b3afcb16fb"}, - {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:464855a7ff7f2cc2cf537ecc421291b9132aa9c79aef44e917ad711b4a93163b"}, - {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:193924c563fae6ddcb71d3f06fa153866423ac1b793a47936656e806b64e24ca"}, - {file = "pydantic-1.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:b4a849d10f211389502059c33332e91327bc154acc1845f375a99eca3afa802d"}, - {file = "pydantic-1.10.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cc1dde4e50a5fc1336ee0581c1612215bc64ed6d28d2c7c6f25d2fe3e7c3e918"}, - {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0cfe895a504c060e5d36b287ee696e2fdad02d89e0d895f83037245218a87fe"}, - {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:670bb4683ad1e48b0ecb06f0cfe2178dcf74ff27921cdf1606e527d2617a81ee"}, - {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:950ce33857841f9a337ce07ddf46bc84e1c4946d2a3bba18f8280297157a3fd1"}, - {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c15582f9055fbc1bfe50266a19771bbbef33dd28c45e78afbe1996fd70966c2a"}, - {file = "pydantic-1.10.7-cp37-cp37m-win_amd64.whl", hash = "sha256:82dffb306dd20bd5268fd6379bc4bfe75242a9c2b79fec58e1041fbbdb1f7914"}, - {file = "pydantic-1.10.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c7f51861d73e8b9ddcb9916ae7ac39fb52761d9ea0df41128e81e2ba42886cd"}, - {file = "pydantic-1.10.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6434b49c0b03a51021ade5c4daa7d70c98f7a79e95b551201fff682fc1661245"}, - {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d34ab766fa056df49013bb6e79921a0265204c071984e75a09cbceacbbdd5d"}, - {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:701daea9ffe9d26f97b52f1d157e0d4121644f0fcf80b443248434958fd03dc3"}, - {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cf135c46099ff3f919d2150a948ce94b9ce545598ef2c6c7bf55dca98a304b52"}, - {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0f85904f73161817b80781cc150f8b906d521fa11e3cdabae19a581c3606209"}, - {file = "pydantic-1.10.7-cp38-cp38-win_amd64.whl", hash = "sha256:9f6f0fd68d73257ad6685419478c5aece46432f4bdd8d32c7345f1986496171e"}, - {file = "pydantic-1.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c230c0d8a322276d6e7b88c3f7ce885f9ed16e0910354510e0bae84d54991143"}, - {file = "pydantic-1.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:976cae77ba6a49d80f461fd8bba183ff7ba79f44aa5cfa82f1346b5626542f8e"}, - {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d45fc99d64af9aaf7e308054a0067fdcd87ffe974f2442312372dfa66e1001d"}, - {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2a5ebb48958754d386195fe9e9c5106f11275867051bf017a8059410e9abf1f"}, - {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:abfb7d4a7cd5cc4e1d1887c43503a7c5dd608eadf8bc615413fc498d3e4645cd"}, - {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:80b1fab4deb08a8292d15e43a6edccdffa5377a36a4597bb545b93e79c5ff0a5"}, - {file = "pydantic-1.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:d71e69699498b020ea198468e2480a2f1e7433e32a3a99760058c6520e2bea7e"}, - {file = "pydantic-1.10.7-py3-none-any.whl", hash = "sha256:0cd181f1d0b1d00e2b705f1bf1ac7799a2d938cce3376b8007df62b29be3c2c6"}, - {file = "pydantic-1.10.7.tar.gz", hash = "sha256:cfc83c0678b6ba51b0532bea66860617c4cd4251ecf76e9846fa5a9f3454e97e"}, -] -Pygments = [ - {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"}, - {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"}, -] -pyproject-api = [ - {file = "pyproject_api-1.5.1-py3-none-any.whl", hash = "sha256:4698a3777c2e0f6b624f8a4599131e2a25376d90fe8d146d7ac74c67c6f97c43"}, - {file = "pyproject_api-1.5.1.tar.gz", hash = "sha256:435f46547a9ff22cf4208ee274fca3e2869aeb062a4834adfc99a4dd64af3cf9"}, -] -pytest = [ - {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, - {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, -] -pytest-asyncio = [ - {file = "pytest-asyncio-0.21.0.tar.gz", hash = "sha256:2b38a496aef56f56b0e87557ec313e11e1ab9276fc3863f6a7be0f1d0e415e1b"}, - {file = "pytest_asyncio-0.21.0-py3-none-any.whl", hash = "sha256:f2b3366b7cd501a4056858bd39349d5af19742aed2d81660b7998b6341c7eb9c"}, -] -pytest-cov = [ - {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, - {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, -] -pytest-mock = [ - {file = "pytest-mock-3.10.0.tar.gz", hash = "sha256:fbbdb085ef7c252a326fd8cdcac0aa3b1333d8811f131bdcc701002e1be7ed4f"}, - {file = "pytest_mock-3.10.0-py3-none-any.whl", hash = "sha256:f4c973eeae0282963eb293eb173ce91b091a79c1334455acfac9ddee8a1c784b"}, -] -pytest-sugar = [ - {file = "pytest-sugar-0.9.7.tar.gz", hash = "sha256:f1e74c1abfa55f7241cf7088032b6e378566f16b938f3f08905e2cf4494edd46"}, - {file = "pytest_sugar-0.9.7-py2.py3-none-any.whl", hash = "sha256:8cb5a4e5f8bbcd834622b0235db9e50432f4cbd71fef55b467fe44e43701e062"}, -] -pytz = [ - {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"}, - {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, -] -PyYAML = [ - {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, - {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, - {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, - {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, - {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, - {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, - {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, - {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, - {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, - {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, - {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, - {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, - {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, - {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, - {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, - {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, - {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, - {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, - {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, - {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, -] -requests = [ - {file = "requests-2.30.0-py3-none-any.whl", hash = "sha256:10e94cc4f3121ee6da529d358cdaeaff2f1c409cd377dbc72b825852f2f7e294"}, - {file = "requests-2.30.0.tar.gz", hash = "sha256:239d7d4458afcb28a692cdd298d87542235f4ca8d36d03a15bfc128a6559a2f4"}, -] -setuptools = [ - {file = "setuptools-67.7.2-py3-none-any.whl", hash = "sha256:23aaf86b85ca52ceb801d32703f12d77517b2556af839621c641fca11287952b"}, - {file = "setuptools-67.7.2.tar.gz", hash = "sha256:f104fa03692a2602fa0fec6c6a9e63b6c8a968de13e17c026957dd1f53d80990"}, -] -six = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] -sniffio = [ - {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, - {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, -] -snowballstemmer = [ - {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, - {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, -] -Sphinx = [ - {file = "Sphinx-4.5.0-py3-none-any.whl", hash = "sha256:ebf612653238bcc8f4359627a9b7ce44ede6fdd75d9d30f68255c7383d3a6226"}, - {file = "Sphinx-4.5.0.tar.gz", hash = "sha256:7bf8ca9637a4ee15af412d1a1d9689fec70523a68ca9bb9127c2f3eeb344e2e6"}, -] -sphinx-rtd-theme = [ - {file = "sphinx_rtd_theme-0.5.1-py2.py3-none-any.whl", hash = "sha256:fa6bebd5ab9a73da8e102509a86f3fcc36dec04a0b52ea80e5a033b2aba00113"}, - {file = "sphinx_rtd_theme-0.5.1.tar.gz", hash = "sha256:eda689eda0c7301a80cf122dad28b1861e5605cbf455558f3775e1e8200e83a5"}, -] -sphinxcontrib-applehelp = [ - {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"}, - {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"}, -] -sphinxcontrib-devhelp = [ - {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, - {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, -] -sphinxcontrib-htmlhelp = [ - {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, - {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, -] -sphinxcontrib-jsmath = [ - {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, - {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, -] -sphinxcontrib-programoutput = [ - {file = "sphinxcontrib-programoutput-0.17.tar.gz", hash = "sha256:300ee9b8caee8355d25cc74b4d1c7efd12e608d2ad165e3141d31e6fbc152b7f"}, - {file = "sphinxcontrib_programoutput-0.17-py2.py3-none-any.whl", hash = "sha256:0ef1c1d9159dbe7103077748214305eb4e0138e861feb71c0c346afc5fe97f84"}, -] -sphinxcontrib-qthelp = [ - {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, - {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, -] -sphinxcontrib-serializinghtml = [ - {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, - {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, -] -termcolor = [ - {file = "termcolor-2.3.0-py3-none-any.whl", hash = "sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475"}, - {file = "termcolor-2.3.0.tar.gz", hash = "sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a"}, -] -toml = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] -tomli = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] -tox = [ - {file = "tox-4.5.1-py3-none-any.whl", hash = "sha256:d25a2e6cb261adc489604fafd76cd689efeadfa79709965e965668d6d3f63046"}, - {file = "tox-4.5.1.tar.gz", hash = "sha256:5a2eac5fb816779dfdf5cb00fecbc27eb0524e4626626bb1de84747b24cacc56"}, -] -typing-extensions = [ - {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, - {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, -] -urllib3 = [ - {file = "urllib3-2.0.2-py3-none-any.whl", hash = "sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e"}, - {file = "urllib3-2.0.2.tar.gz", hash = "sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc"}, -] -virtualenv = [ - {file = "virtualenv-20.23.0-py3-none-any.whl", hash = "sha256:6abec7670e5802a528357fdc75b26b9f57d5d92f29c5462ba0fbe45feacc685e"}, - {file = "virtualenv-20.23.0.tar.gz", hash = "sha256:a85caa554ced0c0afbd0d638e7e2d7b5f92d23478d05d17a76daeac8f279f924"}, -] -voluptuous = [ - {file = "voluptuous-0.13.1-py3-none-any.whl", hash = "sha256:4b838b185f5951f2d6e8752b68fcf18bd7a9c26ded8f143f92d6d28f3921a3e6"}, - {file = "voluptuous-0.13.1.tar.gz", hash = "sha256:e8d31c20601d6773cb14d4c0f42aee29c6821bbd1018039aac7ac5605b489723"}, -] -xdoctest = [ - {file = "xdoctest-1.1.1-py3-none-any.whl", hash = "sha256:d59d4ed91cb92e4430ef0ad1b134a2bef02adff7d2fb9c9f057547bee44081a2"}, - {file = "xdoctest-1.1.1.tar.gz", hash = "sha256:2eac8131bdcdf2781b4e5a62d6de87f044b730cc8db8af142a51bb29c245e779"}, -] -zipp = [ - {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, - {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, -] +content-hash = "ea746265dddc60d13ef0edb476d02181631519af39fa90574e60f8eabe35bbe0" diff --git a/pyproject.toml b/pyproject.toml index 2d77eeaf2..62b8f4b09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ anyio = "*" # see https://github.com/python-trio/asyncclick/issues/18 asyncclick = ">=8" pydantic = "^1" +# speed ups +orjson = { "version" = ">=3.9.1", optional = true, extras = ["speedups"] } + # required only for docs sphinx = { version = "^4", optional = true } sphinx_rtd_theme = { version = "^0", optional = true } From afd54d11d3a663300e9e895aacdbc16ecf914a9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jun 2023 19:43:01 -0500 Subject: [PATCH 145/892] Add optional kasa-crypt dependency for speedups (#464) If installed, use the optimized protocol encryption procedures implemented as a C extension by kasa-crypt (https://pypi.org/project/kasa-crypt/ --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++-- kasa/protocol.py | 10 ++++++++++ poetry.lock | 15 ++++++++++++++- pyproject.toml | 4 +++- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5a3b310d..d0dce418d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: poetry run pre-commit run check-ast --all-files tests: - name: "Python ${{ matrix.python-version}} on ${{ matrix.os }}" + name: Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} needs: linting runs-on: ${{ matrix.os }} @@ -66,6 +66,24 @@ jobs: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "pypy-3.8"] os: [ubuntu-latest, macos-latest, windows-latest] + extras: [false, true] + exclude: + - os: macos-latest + extras: true + - os: windows-latest + extras: true + - os: ubuntu-latest + python-version: "pypy-3.8" + extras: true + - os: ubuntu-latest + python-version: "3.8" + extras: true + - os: ubuntu-latest + python-version: "3.9" + extras: true + - os: ubuntu-latest + python-version: "3.10" + extras: true # exclude pypy on windows, as the poetry install seems to be very flaky: # PermissionError(13, 'The process cannot access the file because it is being used by another process')) # at C:\hostedtoolcache\windows\PyPy\3.7.10\x86\site-packages\requests\models.py:761 in generate @@ -78,10 +96,16 @@ jobs: - uses: "actions/setup-python@v2" with: python-version: "${{ matrix.python-version }}" - - name: "Install dependencies" + - name: "Install dependencies (no speedups)" + if: matrix.extras == false run: | python -m pip install --upgrade pip poetry poetry install + - name: "Install dependencies (with speedups)" + if: matrix.extras == true + run: | + python -m pip install --upgrade pip poetry + poetry install --extras speedups - name: "Run tests" run: | poetry run pytest --cov kasa --cov-report xml diff --git a/kasa/protocol.py b/kasa/protocol.py index bcb8b7f54..e21866e33 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -219,3 +219,13 @@ def decrypt(ciphertext: bytes) -> str: return bytes( TPLinkSmartHomeProtocol._xor_encrypted_payload(ciphertext) ).decode() + + +# Try to load the kasa_crypt module and if it is available +try: + from kasa_crypt import decrypt, encrypt + + TPLinkSmartHomeProtocol.decrypt = decrypt # type: ignore[method-assign] + TPLinkSmartHomeProtocol.encrypt = encrypt # type: ignore[method-assign] +except ImportError: + pass diff --git a/poetry.lock b/poetry.lock index 065c64497..57149e115 100644 --- a/poetry.lock +++ b/poetry.lock @@ -445,6 +445,18 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "kasa-crypt" +version = "0.2.0" +description = "Fast kasa crypt" +category = "main" +optional = true +python-versions = ">=3.7,<4.0" +files = [ + {file = "kasa_crypt-0.2.0-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:9676ac702aa62252fb4de3d5c9ee4895dd93610b9c37e732213b0914fbc0e255"}, + {file = "kasa_crypt-0.2.0.tar.gz", hash = "sha256:ad2e73276f09ed035d53006985b08eb78869f73e60ac5d66547d9ddc35cb8cc4"}, +] + [[package]] name = "markdown-it-py" version = "2.2.0" @@ -1387,8 +1399,9 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [extras] docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] +speedups = ["orjson", "kasa-crypt"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "ea746265dddc60d13ef0edb476d02181631519af39fa90574e60f8eabe35bbe0" +content-hash = "b85f55f0ca928b1f3510da37196c21f40eb07cd4d07b3a9c3dd29215ba9777fe" diff --git a/pyproject.toml b/pyproject.toml index 62b8f4b09..57c1f21c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,8 @@ asyncclick = ">=8" pydantic = "^1" # speed ups -orjson = { "version" = ">=3.9.1", optional = true, extras = ["speedups"] } +orjson = { "version" = ">=3.9.1", optional = true } +kasa-crypt = { "version" = ">=0.2.0", optional = true } # required only for docs sphinx = { version = "^4", optional = true } @@ -50,6 +51,7 @@ coverage = {version = "*", extras = ["toml"]} [tool.poetry.extras] docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] +speedups = ["orjson", "kasa-crypt"] [tool.isort] From b83986bd51e4c37249045468e312dc5da9564641 Mon Sep 17 00:00:00 2001 From: xinud190 <54327371+xinud190@users.noreply.github.com> Date: Thu, 29 Jun 2023 20:53:23 -0400 Subject: [PATCH 146/892] Add fixture for KP405 Smart Dimmer Plug (#470) * Add files via upload * Add to KP405 to dimmers, update README --------- Co-authored-by: Teemu Rytilahti --- README.md | 1 + kasa/tests/conftest.py | 2 +- kasa/tests/fixtures/KP405(US)_1.0_1.0.5.json | 65 ++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/KP405(US)_1.0_1.0.5.json diff --git a/README.md b/README.md index d18d956e9..258d06e11 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ If your device is unlisted but working, please open a pull request to update the * HS300 * KP303 * KP400 +* KP405 (dimmer) ### Wall switches diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index fa925630b..4ea9846ef 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -56,7 +56,7 @@ "KS200M", } STRIPS = {"HS107", "HS300", "KP303", "KP400", "EP40"} -DIMMERS = {"ES20M", "HS220", "KS220M", "KS230"} +DIMMERS = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} DIMMABLE = {*BULBS, *DIMMERS} WITH_EMETER = {"HS110", "HS300", "KP115", "KP125", *BULBS} diff --git a/kasa/tests/fixtures/KP405(US)_1.0_1.0.5.json b/kasa/tests/fixtures/KP405(US)_1.0_1.0.5.json new file mode 100644 index 000000000..ad6357f3c --- /dev/null +++ b/kasa/tests/fixtures/KP405(US)_1.0_1.0.5.json @@ -0,0 +1,65 @@ +{ + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "bulb_type": 1, + "calibration_type": 0, + "err_code": 0, + "fadeOffTime": 1000, + "fadeOnTime": 1000, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 1, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Porch Lights", + "brightness": 50, + "dev_name": "Kasa Smart Wi-Fi Outdoor Plug-In Dimmer", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 1, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP405(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "preferred_state": [ + { + "brightness": 100, + "index": 0 + }, + { + "brightness": 75, + "index": 1 + }, + { + "brightness": 50, + "index": 2 + }, + { + "brightness": 25, + "index": 3 + } + ], + "relay_state": 0, + "rssi": -64, + "status": "new", + "sw_ver": "1.0.5 Build 221108 Rel.181739", + "updating": 0 + } + } +} From fde156c859a70f5cf02de8611b7636f9d8da26bb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Jul 2023 18:03:50 -0500 Subject: [PATCH 147/892] Add benchmarks for speedups (#473) * Add benchmarks for speedups * Update README.md * Update README.md Co-authored-by: Teemu R. * relo * Update README.md * document benchmark * Update README.md --------- Co-authored-by: Teemu R. --- README.md | 2 + devtools/README.md | 10 +++ devtools/bench/benchmark.py | 30 +++++++ devtools/bench/utils/__init__.py | 1 + devtools/bench/utils/data.py | 141 +++++++++++++++++++++++++++++++ devtools/bench/utils/original.py | 47 +++++++++++ 6 files changed, 231 insertions(+) create mode 100644 devtools/bench/benchmark.py create mode 100644 devtools/bench/utils/__init__.py create mode 100644 devtools/bench/utils/data.py create mode 100644 devtools/bench/utils/original.py diff --git a/README.md b/README.md index 258d06e11..0232eee1f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ If you are using cpython, it is recommended to install with `[speedups]` to enab pip install python-kasa[speedups] ``` +With `[speedups]`, the protocol overhead is roughly an order of magnitude lower (benchmarks available in devtools). + Alternatively, you can clone this repository and use poetry to install the development version: ``` git clone https://github.com/python-kasa/python-kasa.git diff --git a/devtools/README.md b/devtools/README.md index fc3ce8834..92e91fd79 100644 --- a/devtools/README.md +++ b/devtools/README.md @@ -59,3 +59,13 @@ id -HS110(EU) 5.0 0.055700 0.016174 0.042086 0.045578 0.048905 0.059869 0.082064 -KP303(UK) 5.0 0.010298 0.003765 0.007773 0.007968 0.008546 0.010439 0.016763 ``` + +## benchmark + +* Benchmark the protocol + +```shell +% python3 devtools/bench/benchmark.py +New parser, parsing 100000 messages took 0.6339647499989951 seconds +Old parser, parsing 100000 messages took 9.473990250000497 seconds +``` diff --git a/devtools/bench/benchmark.py b/devtools/bench/benchmark.py new file mode 100644 index 000000000..2cdbd43e0 --- /dev/null +++ b/devtools/bench/benchmark.py @@ -0,0 +1,30 @@ +"""Benchmark the new parser against the old parser.""" + +import json +import timeit + +import orjson +from kasa_crypt import decrypt, encrypt +from utils.data import REQUEST, WIRE_RESPONSE +from utils.original import OriginalTPLinkSmartHomeProtocol + + +def original_request_response() -> None: + """Benchmark the original parser.""" + OriginalTPLinkSmartHomeProtocol.encrypt(json.dumps(REQUEST)) + json.loads(OriginalTPLinkSmartHomeProtocol.decrypt(WIRE_RESPONSE[4:])) + + +def new_request_response() -> None: + """Benchmark the new parser.""" + encrypt(orjson.dumps(REQUEST).decode()) + orjson.loads(decrypt(WIRE_RESPONSE[4:])) + + +count = 100000 + +time = timeit.Timer(new_request_response).timeit(count) +print(f"New parser, parsing {count} messages took {time} seconds") + +time = timeit.Timer(original_request_response).timeit(count) +print(f"Old parser, parsing {count} messages took {time} seconds") diff --git a/devtools/bench/utils/__init__.py b/devtools/bench/utils/__init__.py new file mode 100644 index 000000000..d49281d90 --- /dev/null +++ b/devtools/bench/utils/__init__.py @@ -0,0 +1 @@ +"""Benchmark utils.""" diff --git a/devtools/bench/utils/data.py b/devtools/bench/utils/data.py new file mode 100644 index 000000000..13a49e87a --- /dev/null +++ b/devtools/bench/utils/data.py @@ -0,0 +1,141 @@ +"""Test data for benchmarks.""" + + +import json + +from .original import OriginalTPLinkSmartHomeProtocol + +REQUEST = { + "system": {"get_sysinfo": None}, + "anti_theft": {"get_rules": None, "get_next_action": None}, + "schedule": { + "get_rules": None, + "get_next_action": None, + "get_realtime": None, + "get_daystat": {"year": 2023, "month": 6}, + "get_monthstat": {"year": 2023}, + }, + "time": {"get_time": None, "get_timezone": None}, + "emeter": { + "get_realtime": None, + "get_daystat": {"year": 2023, "month": 6}, + "get_monthstat": {"year": 2023}, + }, +} +RESPONSE = { + "anti_theft": { + "get_next_action": {"err_code": -2, "err_msg": "member not support"}, + "get_rules": {"enable": 0, "err_code": 0, "rule_list": [], "version": 2}, + }, + "emeter": { + "get_daystat": { + "day_list": [{"day": 30, "energy_wh": 0, "month": 6, "year": 2023}], + "err_code": 0, + }, + "get_monthstat": { + "err_code": 0, + "month_list": [{"energy_wh": 0, "month": 6, "year": 2023}], + }, + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 0, + "slot_id": 0, + "total_wh": 0, + "voltage_mv": 119390, + }, + }, + "schedule": { + "get_daystat": { + "day_list": [{"day": 30, "month": 6, "time": 3, "year": 2023}], + "err_code": 0, + }, + "get_monthstat": { + "err_code": 0, + "month_list": [{"month": 6, "time": 3, "year": 2023}], + }, + "get_next_action": {"err_code": 0, "type": -1}, + "get_realtime": {"err_code": -2, "err_msg": "member not support"}, + "get_rules": {"enable": 1, "err_code": 0, "rule_list": [], "version": 2}, + }, + "system": { + "get_sysinfo": { + "alias": "TP-LINK_Power Strip_5C33", + "child_num": 6, + "children": [ + { + "alias": "Plug 1", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031900", + "next_action": {"type": -1}, + "on_time": 231, + "state": 1, + }, + { + "alias": "Plug 2", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031901", + "next_action": {"type": -1}, + "on_time": 231, + "state": 1, + }, + { + "alias": "Plug 3", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031902", + "next_action": {"type": -1}, + "on_time": 231, + "state": 1, + }, + { + "alias": "Plug 4", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031903", + "next_action": {"type": -1}, + "on_time": 231, + "state": 1, + }, + { + "alias": "Plug 5", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031904", + "next_action": {"type": -1}, + "on_time": 231, + "state": 1, + }, + { + "alias": "Plug 6", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031905", + "next_action": {"type": -1}, + "on_time": 231, + "state": 1, + }, + ], + "deviceId": "8006AF35494E7DB13DDE9B8F40BF2E001E770319", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "955F433CBA24823A248A59AA64571A73", + "hw_ver": "2.0", + "latitude_i": 297852, + "led_off": 0, + "longitude_i": -954074, + "mac": "C0:06:C3:42:5C:33", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS300(US)", + "oemId": "32BD0B21AA9BF8E84737D1DB1C66E883", + "rssi": -41, + "status": "new", + "sw_ver": "1.0.3 Build 201203 Rel.165457", + "updating": 0, + } + }, + "time": { + "get_time": { + "err_code": 0, + "hour": 9, + "mday": 30, + "min": 32, + "month": 6, + "sec": 54, + "year": 2023, + }, + "get_timezone": {"err_code": 0, "index": 13}, + }, +} + +WIRE_RESPONSE = OriginalTPLinkSmartHomeProtocol.encrypt(json.dumps(RESPONSE)) diff --git a/devtools/bench/utils/original.py b/devtools/bench/utils/original.py new file mode 100644 index 000000000..67aeaa33f --- /dev/null +++ b/devtools/bench/utils/original.py @@ -0,0 +1,47 @@ +"""Original implementation of the TP-Link Smart Home protocol.""" +import struct +from typing import Generator + + +class OriginalTPLinkSmartHomeProtocol: + """Original implementation of the TP-Link Smart Home protocol.""" + + INITIALIZATION_VECTOR = 171 + + @staticmethod + def _xor_payload(unencrypted: bytes) -> Generator[int, None, None]: + key = OriginalTPLinkSmartHomeProtocol.INITIALIZATION_VECTOR + for unencryptedbyte in unencrypted: + key = key ^ unencryptedbyte + yield key + + @staticmethod + def encrypt(request: str) -> bytes: + """Encrypt a request for a TP-Link Smart Home Device. + + :param request: plaintext request data + :return: ciphertext to be send over wire, in bytes + """ + plainbytes = request.encode() + return struct.pack(">I", len(plainbytes)) + bytes( + OriginalTPLinkSmartHomeProtocol._xor_payload(plainbytes) + ) + + @staticmethod + def _xor_encrypted_payload(ciphertext: bytes) -> Generator[int, None, None]: + key = OriginalTPLinkSmartHomeProtocol.INITIALIZATION_VECTOR + for cipherbyte in ciphertext: + plainbyte = key ^ cipherbyte + key = cipherbyte + yield plainbyte + + @staticmethod + def decrypt(ciphertext: bytes) -> str: + """Decrypt a response of a TP-Link Smart Home Device. + + :param ciphertext: encrypted response data + :return: plaintext response + """ + return bytes( + OriginalTPLinkSmartHomeProtocol._xor_encrypted_payload(ciphertext) + ).decode() From 61995212698a4694681d2f209bcfbed37e59ce14 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 2 Jul 2023 16:26:29 +0200 Subject: [PATCH 148/892] Release 0.5.2 (#475) Besides some small improvements, this release: * Adds optional dependency for for `orjson` and `kasa-crypt` to speed-up protocol handling by an order of magnitude. * Drops Python 3.7 support as it is no longer maintained. [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.1...0.5.2) --- CHANGELOG.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++-- pyproject.toml | 2 +- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 466f3c28b..184d3a09a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # Changelog +## [0.5.2](https://github.com/python-kasa/python-kasa/tree/0.5.2) (2023-07-02) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.1...0.5.2) + +Besides some small improvements, this release: +* Adds optional dependency for for `orjson` and `kasa-crypt` to speed-up protocol handling by an order of magnitude. +* Drops Python 3.7 support as it is no longer maintained. + +**Breaking changes:** + +- Drop python 3.7 support [\#455](https://github.com/python-kasa/python-kasa/pull/455) (@rytilahti) + +**Implemented enhancements:** + +- Use orjson when already installed or with speedups extra [\#466](https://github.com/python-kasa/python-kasa/pull/466) (@bdraco) +- Add optional kasa-crypt dependency for speedups [\#464](https://github.com/python-kasa/python-kasa/pull/464) (@bdraco) +- Add inactivity setting for the motion module [\#453](https://github.com/python-kasa/python-kasa/pull/453) (@rytilahti) +- Add methods to configure dimmer settings [\#429](https://github.com/python-kasa/python-kasa/pull/429) (@rytilahti) + +**Fixed bugs:** + +- Request for KP405 Support - Dimmable Plug [\#469](https://github.com/python-kasa/python-kasa/issues/469) +- Issue printing device in on\_discovered: pydantic.error\_wrappers.ValidationError: 3 validation errors for SmartBulbPreset [\#439](https://github.com/python-kasa/python-kasa/issues/439) +- Possible firmware issue with KL125 \(1.0.7 Build 211009 Rel.172044\) [\#345](https://github.com/python-kasa/python-kasa/issues/345) +- Exclude querying certain modules for KL125\(US\) which cause crashes [\#451](https://github.com/python-kasa/python-kasa/pull/451) (@brianthedavis) +- Return result objects for cli discover and implicit 'state' [\#446](https://github.com/python-kasa/python-kasa/pull/446) (@rytilahti) +- Allow effect presets seen on light strips [\#440](https://github.com/python-kasa/python-kasa/pull/440) (@rytilahti) + +**Closed issues:** + +- Powershell version? [\#461](https://github.com/python-kasa/python-kasa/issues/461) +- Add `set_cold_time` to Motion module [\#452](https://github.com/python-kasa/python-kasa/issues/452) +- Discover.discover\(\) only returning ip adress on ep10 outlet [\#447](https://github.com/python-kasa/python-kasa/issues/447) +- Query current wifi config? [\#445](https://github.com/python-kasa/python-kasa/issues/445) +- bulb.turn\_off making device undiscoverable [\#444](https://github.com/python-kasa/python-kasa/issues/444) +- best privacy practices for Kasa devices [\#438](https://github.com/python-kasa/python-kasa/issues/438) +- Access device from different network [\#424](https://github.com/python-kasa/python-kasa/issues/424) +- Lots of test failure with 0.5.0 [\#411](https://github.com/python-kasa/python-kasa/issues/411) + +**Merged pull requests:** + +- Add benchmarks for speedups [\#473](https://github.com/python-kasa/python-kasa/pull/473) (@bdraco) +- Add fixture for KP405 Smart Dimmer Plug [\#470](https://github.com/python-kasa/python-kasa/pull/470) (@xinud190) +- Remove importlib-metadata dependency [\#457](https://github.com/python-kasa/python-kasa/pull/457) (@rytilahti) +- Update dependencies to fix CI [\#454](https://github.com/python-kasa/python-kasa/pull/454) (@rytilahti) +- Cleanup fixture filenames [\#448](https://github.com/python-kasa/python-kasa/pull/448) (@rytilahti) + ## [0.5.1](https://github.com/python-kasa/python-kasa/tree/0.5.1) (2023-02-18) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.0...0.5.1) @@ -17,13 +64,13 @@ This minor release contains mostly small UX fine-tuning and documentation improv **Implemented enhancements:** -- Add support for json output [\#430](https://github.com/python-kasa/python-kasa/pull/430) (@rytilahti) - Pretty-print all exceptions from cli commands [\#428](https://github.com/python-kasa/python-kasa/pull/428) (@rytilahti) - Add transition parameter to lightstrip's set\_effect [\#416](https://github.com/python-kasa/python-kasa/pull/416) (@rytilahti) - Add brightness to lightstrip's set\_effect [\#415](https://github.com/python-kasa/python-kasa/pull/415) (@rytilahti) - Use rich for prettier output, if available [\#403](https://github.com/python-kasa/python-kasa/pull/403) (@rytilahti) - Adding cli command to delete a schedule rule [\#391](https://github.com/python-kasa/python-kasa/pull/391) (@aricforrest) - Add support for bulb presets [\#379](https://github.com/python-kasa/python-kasa/pull/379) (@rytilahti) +- Add support for json output [\#430](https://github.com/python-kasa/python-kasa/pull/430) (@rytilahti) **Fixed bugs:** @@ -31,8 +78,8 @@ This minor release contains mostly small UX fine-tuning and documentation improv - cli.py usage --year command passes year argument incorrectly [\#371](https://github.com/python-kasa/python-kasa/issues/371) - KP303 reporting as device off [\#319](https://github.com/python-kasa/python-kasa/issues/319) - HS210 not updating the state correctly [\#193](https://github.com/python-kasa/python-kasa/issues/193) -- Return usage.get\_{monthstat,daystat} in expected format [\#394](https://github.com/python-kasa/python-kasa/pull/394) (@jules43) - Fix year emeter for cli by using kwarg for year parameter [\#372](https://github.com/python-kasa/python-kasa/pull/372) (@rytilahti) +- Return usage.get\_{monthstat,daystat} in expected format [\#394](https://github.com/python-kasa/python-kasa/pull/394) (@jules43) **Documentation updates:** @@ -76,6 +123,7 @@ This minor release contains mostly small UX fine-tuning and documentation improv **Merged pull requests:** +- Prepare 0.5.1 [\#434](https://github.com/python-kasa/python-kasa/pull/434) (@rytilahti) - Some release preparation janitoring [\#432](https://github.com/python-kasa/python-kasa/pull/432) (@rytilahti) - Bump certifi from 2021.10.8 to 2022.12.7 [\#409](https://github.com/python-kasa/python-kasa/pull/409) (@dependabot[bot]) - Add FUNDING.yml [\#402](https://github.com/python-kasa/python-kasa/pull/402) (@rytilahti) diff --git a/pyproject.toml b/pyproject.toml index 57c1f21c7..68a2159d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.5.1" +version = "0.5.2" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 9b039d8374b221dc58bf11c8532370e35a60a594 Mon Sep 17 00:00:00 2001 From: Viktar Karpach Date: Sun, 9 Jul 2023 18:55:27 -0500 Subject: [PATCH 149/892] Make device port configurable (#471) --- kasa/cli.py | 11 +++++++++-- kasa/discover.py | 6 +++--- kasa/protocol.py | 13 +++++++------ kasa/smartbulb.py | 4 ++-- kasa/smartdevice.py | 5 +++-- kasa/smartdimmer.py | 4 ++-- kasa/smartlightstrip.py | 4 ++-- kasa/smartplug.py | 6 +++--- kasa/smartstrip.py | 4 ++-- kasa/tests/test_discovery.py | 6 ++++-- kasa/tests/test_protocol.py | 30 ++++++++++++++++++++++++++++++ 11 files changed, 67 insertions(+), 26 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 48ce039c5..b2d9d91a2 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -99,6 +99,12 @@ def _device_to_serializable(val: SmartDevice): required=False, help="The host name or IP address of the device to connect to.", ) +@click.option( + "--port", + envvar="KASA_PORT", + required=False, + help="The port of the device to connect to.", +) @click.option( "--alias", envvar="KASA_NAME", @@ -125,7 +131,7 @@ def _device_to_serializable(val: SmartDevice): ) @click.version_option(package_name="python-kasa") @click.pass_context -async def cli(ctx, host, alias, target, debug, type, json): +async def cli(ctx, host, port, alias, target, debug, type, json): """A tool for controlling TP-Link smart home devices.""" # noqa # no need to perform any checks if we are just displaying the help if sys.argv[-1] == "--help": @@ -179,7 +185,7 @@ def _nop_echo(*args, **kwargs): dev = TYPE_TO_CLASS[type](host) else: echo("No --type defined, discovering..") - dev = await Discover.discover_single(host) + dev = await Discover.discover_single(host, port=port) await dev.update() ctx.obj = dev @@ -275,6 +281,7 @@ async def state(dev: SmartDevice): """Print out device state and versions.""" echo(f"[bold]== {dev.alias} - {dev.model} ==[/bold]") echo(f"\tHost: {dev.host}") + echo(f"\tPort: {dev.port}") echo(f"\tDevice state: {dev.is_on}") if dev.is_strip: echo("\t[bold]== Plugs ==[/bold]") diff --git a/kasa/discover.py b/kasa/discover.py index 217ec32c0..f7b5fbbfb 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -193,19 +193,19 @@ async def discover( return protocol.discovered_devices @staticmethod - async def discover_single(host: str) -> SmartDevice: + async def discover_single(host: str, *, port: Optional[int] = None) -> SmartDevice: """Discover a single device by the given IP address. :param host: Hostname of device to query :rtype: SmartDevice :return: Object for querying/controlling found device. """ - protocol = TPLinkSmartHomeProtocol(host) + protocol = TPLinkSmartHomeProtocol(host, port=port) info = await protocol.query(Discover.DISCOVERY_QUERY) device_class = Discover._get_device_class(info) - dev = device_class(host) + dev = device_class(host, port=port) await dev.update() return dev diff --git a/kasa/protocol.py b/kasa/protocol.py index e21866e33..cd9066c6f 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -33,9 +33,10 @@ class TPLinkSmartHomeProtocol: DEFAULT_TIMEOUT = 5 BLOCK_SIZE = 4 - def __init__(self, host: str) -> None: + def __init__(self, host: str, *, port: Optional[int] = None) -> None: """Create a protocol object.""" self.host = host + self.port = port or TPLinkSmartHomeProtocol.DEFAULT_PORT self.reader: Optional[asyncio.StreamReader] = None self.writer: Optional[asyncio.StreamWriter] = None self.query_lock: Optional[asyncio.Lock] = None @@ -78,7 +79,7 @@ async def _connect(self, timeout: int) -> None: if self.writer: return self.reader = self.writer = None - task = asyncio.open_connection(self.host, TPLinkSmartHomeProtocol.DEFAULT_PORT) + task = asyncio.open_connection(self.host, self.port) self.reader, self.writer = await asyncio.wait_for(task, timeout=timeout) async def _execute_query(self, request: str) -> Dict: @@ -133,13 +134,13 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: except ConnectionRefusedError as ex: await self.close() raise SmartDeviceException( - f"Unable to connect to the device: {self.host}: {ex}" + f"Unable to connect to the device: {self.host}:{self.port}: {ex}" ) except OSError as ex: await self.close() if ex.errno in _NO_RETRY_ERRORS or retry >= retry_count: raise SmartDeviceException( - f"Unable to connect to the device: {self.host}: {ex}" + f"Unable to connect to the device: {self.host}:{self.port}: {ex}" ) continue except Exception as ex: @@ -147,7 +148,7 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) raise SmartDeviceException( - f"Unable to connect to the device: {self.host}: {ex}" + f"Unable to connect to the device: {self.host}:{self.port}: {ex}" ) continue @@ -162,7 +163,7 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) raise SmartDeviceException( - f"Unable to query the device {self.host}: {ex}" + f"Unable to query the device {self.host}:{self.port}: {ex}" ) from ex _LOGGER.debug( diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index b28edab1b..1d6ba31e3 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -199,8 +199,8 @@ class SmartBulb(SmartDevice): SET_LIGHT_METHOD = "transition_light_state" emeter_type = "smartlife.iot.common.emeter" - def __init__(self, host: str) -> None: - super().__init__(host=host) + def __init__(self, host: str, *, port: Optional[int] = None) -> None: + super().__init__(host=host, port=port) self._device_type = DeviceType.Bulb self.add_module("schedule", Schedule(self, "smartlife.iot.common.schedule")) self.add_module("usage", Usage(self, "smartlife.iot.common.schedule")) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 75efcded5..fd8d3768b 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -191,14 +191,15 @@ class SmartDevice: emeter_type = "emeter" - def __init__(self, host: str) -> None: + def __init__(self, host: str, *, port: Optional[int] = None) -> None: """Create a new SmartDevice instance. :param str host: host name or ip address on which the device listens """ self.host = host + self.port = port - self.protocol = TPLinkSmartHomeProtocol(host) + self.protocol = TPLinkSmartHomeProtocol(host, port=port) _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) self._device_type = DeviceType.Unknown # TODO: typing Any is just as using Optional[Dict] would require separate checks in diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py index 9565437dd..247455e38 100644 --- a/kasa/smartdimmer.py +++ b/kasa/smartdimmer.py @@ -62,8 +62,8 @@ class SmartDimmer(SmartPlug): DIMMER_SERVICE = "smartlife.iot.dimmer" - def __init__(self, host: str) -> None: - super().__init__(host) + def __init__(self, host: str, *, port: Optional[int] = None) -> None: + super().__init__(host, port=port) self._device_type = DeviceType.Dimmer # TODO: need to be verified if it's okay to call these on HS220 w/o these # TODO: need to be figured out what's the best approach to detect support for these diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py index 566bf0a7e..6afe5d115 100644 --- a/kasa/smartlightstrip.py +++ b/kasa/smartlightstrip.py @@ -41,8 +41,8 @@ class SmartLightStrip(SmartBulb): LIGHT_SERVICE = "smartlife.iot.lightStrip" SET_LIGHT_METHOD = "set_light_state" - def __init__(self, host: str) -> None: - super().__init__(host) + def __init__(self, host: str, *, port: Optional[int] = None) -> None: + super().__init__(host, port=port) self._device_type = DeviceType.LightStrip @property # type: ignore diff --git a/kasa/smartplug.py b/kasa/smartplug.py index d49e40542..94a5e3500 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -1,6 +1,6 @@ """Module for smart plugs (HS100, HS110, ..).""" import logging -from typing import Any, Dict +from typing import Any, Dict, Optional from kasa.modules import Antitheft, Cloud, Schedule, Time, Usage from kasa.smartdevice import DeviceType, SmartDevice, requires_update @@ -37,8 +37,8 @@ class SmartPlug(SmartDevice): For more examples, see the :class:`SmartDevice` class. """ - def __init__(self, host: str) -> None: - super().__init__(host) + def __init__(self, host: str, *, port: Optional[int] = None) -> None: + super().__init__(host, port=port) self._device_type = DeviceType.Plug self.add_module("schedule", Schedule(self, "schedule")) self.add_module("usage", Usage(self, "schedule")) diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 69ea03e59..a970925ba 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -79,8 +79,8 @@ class SmartStrip(SmartDevice): For more examples, see the :class:`SmartDevice` class. """ - def __init__(self, host: str) -> None: - super().__init__(host=host) + def __init__(self, host: str, *, port: Optional[int] = None) -> None: + super().__init__(host=host, port=port) self.emeter_type = "emeter" self._device_type = DeviceType.Strip self.add_module("antitheft", Antitheft(self, "anti_theft")) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 52325f393..bbdaf8a84 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -52,12 +52,14 @@ async def test_type_unknown(): Discover._get_device_class(invalid_info) -async def test_discover_single(discovery_data: dict, mocker): +@pytest.mark.parametrize("custom_port", [123, None]) +async def test_discover_single(discovery_data: dict, mocker, custom_port): """Make sure that discover_single returns an initialized SmartDevice instance.""" mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) - x = await Discover.discover_single("127.0.0.1") + x = await Discover.discover_single("127.0.0.1", port=custom_port) assert issubclass(x.__class__, SmartDevice) assert x._sys_info is not None + assert x.port == custom_port INVALIDS = [ diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 7aa95a5a2..b438f498e 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -129,6 +129,36 @@ def aio_mock_writer(_, __): assert "success" not in caplog.text +@pytest.mark.parametrize("custom_port", [123, None]) +async def test_protocol_custom_port(mocker, custom_port): + encrypted = TPLinkSmartHomeProtocol.encrypt('{"great":"success"}')[ + TPLinkSmartHomeProtocol.BLOCK_SIZE : + ] + + async def _mock_read(byte_count): + nonlocal encrypted + if byte_count == TPLinkSmartHomeProtocol.BLOCK_SIZE: + return struct.pack(">I", len(encrypted)) + if byte_count == len(encrypted): + return encrypted + raise ValueError(f"No mock for {byte_count}") + + def aio_mock_writer(_, port): + reader = mocker.patch("asyncio.StreamReader") + writer = mocker.patch("asyncio.StreamWriter") + if custom_port is None: + assert port == 9999 + else: + assert port == custom_port + mocker.patch.object(reader, "readexactly", _mock_read) + return reader, writer + + protocol = TPLinkSmartHomeProtocol("127.0.0.1", port=custom_port) + mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + response = await protocol.query({}) + assert response == {"great": "success"} + + def test_encrypt(): d = json.dumps({"foo": 1, "bar": 2}) encrypted = TPLinkSmartHomeProtocol.encrypt(d) From c9faa1f78f4fd15e52e2bb160de1c7bbd3f6ff24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 21 Jul 2023 04:13:36 -0500 Subject: [PATCH 150/892] Only update pyyaml instead of pinning it as a dep (#482) --- poetry.lock | 82 ++++++++++++++++++++++++++--------------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/poetry.lock b/poetry.lock index 57149e115..c846dc1a8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -936,52 +936,52 @@ files = [ [[package]] name = "pyyaml" -version = "6.0" +version = "6.0.1" description = "YAML parser and emitter for Python" category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, - {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, - {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, - {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, - {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, - {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, - {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, - {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, - {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, - {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, - {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, - {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, - {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, - {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, - {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, - {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, - {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, - {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, - {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, - {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] [[package]] From 117a7ac64a2b7d419692970fba09f27c3a4290c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 21 Jul 2023 04:50:54 -0500 Subject: [PATCH 151/892] Replace asyncio.wait_for with async-timeout (#480) asyncio.wait_for has some underlying problems that are only fixed in cpython 3.12. Use async_timeout instead until the minimum supported version is 3.11+ and it can be replaced with asyncio.timeout See https://github.com/python/cpython/pull/98518 --- kasa/protocol.py | 13 +++++++++---- poetry.lock | 14 +++++++++++++- pyproject.toml | 1 + 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/kasa/protocol.py b/kasa/protocol.py index cd9066c6f..461dd85ad 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -17,6 +17,10 @@ from pprint import pformat as pf from typing import Dict, Generator, Optional, 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 .exceptions import SmartDeviceException from .json import dumps as json_dumps from .json import loads as json_loads @@ -79,8 +83,10 @@ async def _connect(self, timeout: int) -> None: if self.writer: return self.reader = self.writer = None + task = asyncio.open_connection(self.host, self.port) - self.reader, self.writer = await asyncio.wait_for(task, timeout=timeout) + async with asyncio_timeout(timeout): + self.reader, self.writer = await task async def _execute_query(self, request: str) -> Dict: """Execute a query on the device and wait for the response.""" @@ -155,9 +161,8 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: try: assert self.reader is not None assert self.writer is not None - return await asyncio.wait_for( - self._execute_query(request), timeout=timeout - ) + async with asyncio_timeout(timeout): + return await self._execute_query(request) except Exception as ex: await self.close() if retry >= retry_count: diff --git a/poetry.lock b/poetry.lock index c846dc1a8..23e4b3638 100644 --- a/poetry.lock +++ b/poetry.lock @@ -34,6 +34,18 @@ doc = ["Sphinx (>=6.1.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "s test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] trio = ["trio (<0.22)"] +[[package]] +name = "async-timeout" +version = "4.0.2" +description = "Timeout context manager for asyncio programs" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, + {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, +] + [[package]] name = "asyncclick" version = "8.1.3.4" @@ -1404,4 +1416,4 @@ speedups = ["orjson", "kasa-crypt"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "b85f55f0ca928b1f3510da37196c21f40eb07cd4d07b3a9c3dd29215ba9777fe" +content-hash = "fcb657fbabe28548021f5f6a1fcb7b60aa82d60f3de015b4b0c7b37260a6a29f" diff --git a/pyproject.toml b/pyproject.toml index 68a2159d3..c905cf915 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ sphinx_rtd_theme = { version = "^0", optional = true } sphinxcontrib-programoutput = { version = "^0", optional = true } myst-parser = { version = "*", optional = true } docutils = { version = ">=0.17", optional = true } +async-timeout = ">=3.0.0" [tool.poetry.dev-dependencies] pytest = "*" From 677ef9c3ef621e2782694ef4ab88446bd580d5ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Jul 2023 16:55:42 -0500 Subject: [PATCH 152/892] Add tests for KP200 (#483) * Add tests for KP200 This one worked out of the box sans the OUI not being in the list https://github.com/home-assistant/core/pull/97062 * it is a strip --- README.md | 1 + kasa/tests/conftest.py | 2 +- kasa/tests/fixtures/KP200(US)_3.0_1.0.3.json | 46 ++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/KP200(US)_3.0_1.0.3.json diff --git a/README.md b/README.md index 0232eee1f..00063dc12 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ If your device is unlisted but working, please open a pull request to update the * EP40 * HS300 * KP303 +* KP200 (in wall) * KP400 * KP405 (dimmer) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 4ea9846ef..9b5a394da 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -55,7 +55,7 @@ "KP401", "KS200M", } -STRIPS = {"HS107", "HS300", "KP303", "KP400", "EP40"} +STRIPS = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} DIMMERS = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} DIMMABLE = {*BULBS, *DIMMERS} diff --git a/kasa/tests/fixtures/KP200(US)_3.0_1.0.3.json b/kasa/tests/fixtures/KP200(US)_3.0_1.0.3.json new file mode 100644 index 000000000..fef495d65 --- /dev/null +++ b/kasa/tests/fixtures/KP200(US)_3.0_1.0.3.json @@ -0,0 +1,46 @@ +{ + "system": { + "get_sysinfo": { + "alias": "TP-LINK_Smart Plug_C2D6", + "child_num": 2, + "children": [ + { + "alias": "One ", + "id": "80066788DFFFD572D9F2E4A5A6847669213E039F00", + "next_action": { + "type": -1 + }, + "on_time": 43, + "state": 1 + }, + { + "alias": "Two ", + "id": "80066788DFFFD572D9F2E4A5A6847669213E039F01", + "next_action": { + "type": -1 + }, + "on_time": 44, + "state": 1 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "3.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP200(US)", + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "rssi": -50, + "status": "new", + "sw_ver": "1.0.3 Build 221021 Rel.183354", + "updating": 0 + } + } +} From e98475a1b2c4f3d1eb10556aa24657da2add5c42 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 23 Jul 2023 15:49:27 +0200 Subject: [PATCH 153/892] Release 0.5.3 (#485) This release adds support for defining the device port and introduces dependency on async-timeout which improves timeout handling. [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.2...0.5.3) --- CHANGELOG.md | 20 ++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 184d3a09a..d8b56fef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [0.5.3](https://github.com/python-kasa/python-kasa/tree/0.5.3) (2023-07-23) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.2...0.5.3) + +This release adds support for defining the device port and introduces dependency on async-timeout which improves timeout handling. + +**Implemented enhancements:** + +- Make device port configurable [\#471](https://github.com/python-kasa/python-kasa/pull/471) (@karpach) + +**Fixed bugs:** + +- Replace asyncio.wait\_for with async-timeout [\#480](https://github.com/python-kasa/python-kasa/pull/480) (@bdraco) + +**Merged pull requests:** + +- Add tests for KP200 [\#483](https://github.com/python-kasa/python-kasa/pull/483) (@bdraco) +- Update pyyaml to fix CI [\#482](https://github.com/python-kasa/python-kasa/pull/482) (@bdraco) + ## [0.5.2](https://github.com/python-kasa/python-kasa/tree/0.5.2) (2023-07-02) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.1...0.5.2) @@ -41,6 +60,7 @@ Besides some small improvements, this release: **Merged pull requests:** +- Release 0.5.2 [\#475](https://github.com/python-kasa/python-kasa/pull/475) (@rytilahti) - Add benchmarks for speedups [\#473](https://github.com/python-kasa/python-kasa/pull/473) (@bdraco) - Add fixture for KP405 Smart Dimmer Plug [\#470](https://github.com/python-kasa/python-kasa/pull/470) (@xinud190) - Remove importlib-metadata dependency [\#457](https://github.com/python-kasa/python-kasa/pull/457) (@rytilahti) diff --git a/pyproject.toml b/pyproject.toml index c905cf915..728bd1a9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.5.2" +version = "0.5.3" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From ceb42e55833371aa717f4a5eaa51ddad01013164 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Thu, 3 Aug 2023 12:19:31 +0100 Subject: [PATCH 154/892] Update pyproject.toml isort profile, dev group header and poetry.lock (#487) --- poetry.lock | 91 ++++++++++---------------------------------------- pyproject.toml | 8 ++--- 2 files changed, 20 insertions(+), 79 deletions(-) diff --git a/poetry.lock b/poetry.lock index 23e4b3638..5681d8e2e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "alabaster" version = "0.7.13" description = "A configurable sidebar-enabled Sphinx theme" -category = "main" optional = true python-versions = ">=3.6" files = [ @@ -16,7 +15,6 @@ files = [ name = "anyio" version = "3.7.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -38,7 +36,6 @@ trio = ["trio (<0.22)"] name = "async-timeout" version = "4.0.2" description = "Timeout context manager for asyncio programs" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -50,7 +47,6 @@ files = [ name = "asyncclick" version = "8.1.3.4" description = "Composable command line interface toolkit, async version" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -65,7 +61,6 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "babel" version = "2.12.1" description = "Internationalization utilities" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -80,7 +75,6 @@ pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} name = "cachetools" version = "5.3.1" description = "Extensible memoizing collections and decorators" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -92,7 +86,6 @@ files = [ name = "certifi" version = "2023.5.7" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -104,7 +97,6 @@ files = [ name = "cfgv" version = "3.3.1" description = "Validate configuration and produce human readable error messages." -category = "dev" optional = false python-versions = ">=3.6.1" files = [ @@ -116,7 +108,6 @@ files = [ name = "chardet" version = "5.1.0" description = "Universal encoding detector for Python 3" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -128,7 +119,6 @@ files = [ name = "charset-normalizer" version = "3.1.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -213,7 +203,6 @@ files = [ name = "codecov" version = "2.1.13" description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -229,7 +218,6 @@ requests = ">=2.7.9" name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -241,7 +229,6 @@ files = [ name = "coverage" version = "7.2.7" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -317,7 +304,6 @@ toml = ["tomli"] name = "distlib" version = "0.3.6" description = "Distribution utilities" -category = "dev" optional = false python-versions = "*" files = [ @@ -329,7 +315,6 @@ files = [ name = "docutils" version = "0.17.1" description = "Docutils -- Python Documentation Utilities" -category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -341,7 +326,6 @@ files = [ name = "exceptiongroup" version = "1.1.1" description = "Backport of PEP 654 (exception groups)" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -356,7 +340,6 @@ test = ["pytest (>=6)"] name = "filelock" version = "3.12.2" description = "A platform independent file lock." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -372,7 +355,6 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "p name = "identify" version = "2.5.24" description = "File identification library for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -387,7 +369,6 @@ license = ["ukkonen"] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -399,7 +380,6 @@ files = [ name = "imagesize" version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" -category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -411,7 +391,6 @@ files = [ name = "importlib-metadata" version = "6.6.0" description = "Read metadata from Python packages" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -431,7 +410,6 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -443,7 +421,6 @@ files = [ name = "jinja2" version = "3.1.2" description = "A very fast and expressive template engine." -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -461,11 +438,24 @@ i18n = ["Babel (>=2.7)"] name = "kasa-crypt" version = "0.2.0" description = "Fast kasa crypt" -category = "main" optional = true python-versions = ">=3.7,<4.0" files = [ + {file = "kasa_crypt-0.2.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:62e9f1e7b8c1cb420522ddfd752a81d387a7097f640fa752d848360680f926e3"}, + {file = "kasa_crypt-0.2.0-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:a9708ee85bc7a2978fe70170de6e6210f9e913c601c14baa2ce61955b0fa24e0"}, + {file = "kasa_crypt-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27aece9fb1b8b52ccb3e73a54a3e5236bfd6aec4bc3699215dcc546f2ddf8e26"}, {file = "kasa_crypt-0.2.0-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:9676ac702aa62252fb4de3d5c9ee4895dd93610b9c37e732213b0914fbc0e255"}, + {file = "kasa_crypt-0.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:d7851e34e3216c6b3c982fb864fc166056476179d8ceea0b06138f7f86b8a657"}, + {file = "kasa_crypt-0.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ea5ed45e6c78f4b486dc2dfa04f4c82da1b836a8b1e244787a885f13c7f89cca"}, + {file = "kasa_crypt-0.2.0-cp310-cp310-win32.whl", hash = "sha256:5417fa89664918625e0b949fdee30247a944274e61451f24789f2245b3b403d2"}, + {file = "kasa_crypt-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:462f7dbca8eab86507158bcd73005f8953f23f2bf5717f8cea1becd1e237b890"}, + {file = "kasa_crypt-0.2.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:21c02f3d502adf7394bc4b306588b1d6f62d371c1218673b6fa22df158fc8b2b"}, + {file = "kasa_crypt-0.2.0-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:3b8c6a3c544e5f947892858807862f036eada4974a20e73f627de75ea23d53a8"}, + {file = "kasa_crypt-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf9725613f3512eb35d4ac517afe2128157a3becfae1e663b8f6e2fa027bd891"}, + {file = "kasa_crypt-0.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4ff483c0bd9dd6a7b504c0d57669653941a8b1bcdcfeaba504fc02b40fa4057e"}, + {file = "kasa_crypt-0.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e2e60330baebb14ead03af156e2f22a88bdc06f879e5876f31a6940d9b51b333"}, + {file = "kasa_crypt-0.2.0-cp311-cp311-win32.whl", hash = "sha256:4ba8c6661232204dabe60cd3d592b702d43359a4a4947cf805f5d8cbec5164ae"}, + {file = "kasa_crypt-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:7db9b111c76838e518fb040f1d6cc87a1aba4bf6cab44e834dadc924587ff199"}, {file = "kasa_crypt-0.2.0.tar.gz", hash = "sha256:ad2e73276f09ed035d53006985b08eb78869f73e60ac5d66547d9ddc35cb8cc4"}, ] @@ -473,7 +463,6 @@ files = [ name = "markdown-it-py" version = "2.2.0" description = "Python port of markdown-it. Markdown parsing, done right!" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -498,7 +487,6 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "markupsafe" version = "2.1.3" description = "Safely add untrusted strings to HTML/XML markup." -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -558,7 +546,6 @@ files = [ name = "mdit-py-plugins" version = "0.3.5" description = "Collection of plugins for markdown-it-py" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -578,7 +565,6 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -590,7 +576,6 @@ files = [ name = "myst-parser" version = "0.18.1" description = "An extended commonmark compliant parser, with bridges to docutils & sphinx." -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -617,7 +602,6 @@ testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=6,<7)", "pytest-cov", name = "nodeenv" version = "1.8.0" description = "Node.js virtual environment builder" -category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ @@ -632,7 +616,6 @@ setuptools = "*" name = "orjson" version = "3.9.1" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -688,7 +671,6 @@ files = [ name = "packaging" version = "23.1" description = "Core utilities for Python packages" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -700,7 +682,6 @@ files = [ name = "platformdirs" version = "3.5.3" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -716,7 +697,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest- name = "pluggy" version = "1.0.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -732,7 +712,6 @@ testing = ["pytest", "pytest-benchmark"] name = "pre-commit" version = "3.3.3" description = "A framework for managing and maintaining multi-language pre-commit hooks." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -751,7 +730,6 @@ virtualenv = ">=20.10.0" name = "pydantic" version = "1.10.9" description = "Data validation and settings management using python type hints" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -804,7 +782,6 @@ email = ["email-validator (>=1.0.3)"] name = "pygments" version = "2.15.1" description = "Pygments is a syntax highlighting package written in Python." -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -819,7 +796,6 @@ plugins = ["importlib-metadata"] name = "pyproject-api" version = "1.5.2" description = "API to interact with the python pyproject.toml based projects" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -839,7 +815,6 @@ testing = ["covdefaults (>=2.3)", "importlib-metadata (>=6.6)", "pytest (>=7.3.1 name = "pytest" version = "7.3.2" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -862,7 +837,6 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "pytest-asyncio" version = "0.21.0" description = "Pytest support for asyncio" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -881,7 +855,6 @@ testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy name = "pytest-cov" version = "4.1.0" description = "Pytest plugin for measuring coverage." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -900,7 +873,6 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "pytest-mock" version = "3.11.1" description = "Thin-wrapper around the mock package for easier use with pytest" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -918,7 +890,6 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] name = "pytest-sugar" version = "0.9.7" description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." -category = "dev" optional = false python-versions = "*" files = [ @@ -938,7 +909,6 @@ dev = ["black", "flake8", "pre-commit"] name = "pytz" version = "2023.3" description = "World timezone definitions, modern and historical" -category = "main" optional = true python-versions = "*" files = [ @@ -950,7 +920,6 @@ files = [ name = "pyyaml" version = "6.0.1" description = "YAML parser and emitter for Python" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1000,7 +969,6 @@ files = [ name = "requests" version = "2.31.0" description = "Python HTTP for Humans." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1022,7 +990,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "setuptools" version = "67.8.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1039,7 +1006,6 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs ( name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -1051,7 +1017,6 @@ files = [ name = "sniffio" version = "1.3.0" description = "Sniff out which async library your code is running under" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1063,7 +1028,6 @@ files = [ name = "snowballstemmer" version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -category = "main" optional = true python-versions = "*" files = [ @@ -1075,7 +1039,6 @@ files = [ name = "sphinx" version = "4.5.0" description = "Python documentation generator" -category = "main" optional = true python-versions = ">=3.6" files = [ @@ -1111,7 +1074,6 @@ test = ["cython", "html5lib", "pytest", "pytest-cov", "typed-ast"] name = "sphinx-rtd-theme" version = "0.5.1" description = "Read the Docs theme for Sphinx" -category = "main" optional = true python-versions = "*" files = [ @@ -1129,7 +1091,6 @@ dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client"] name = "sphinxcontrib-applehelp" version = "1.0.4" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" -category = "main" optional = true python-versions = ">=3.8" files = [ @@ -1145,7 +1106,6 @@ test = ["pytest"] name = "sphinxcontrib-devhelp" version = "1.0.2" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." -category = "main" optional = true python-versions = ">=3.5" files = [ @@ -1161,7 +1121,6 @@ test = ["pytest"] name = "sphinxcontrib-htmlhelp" version = "2.0.1" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -category = "main" optional = true python-versions = ">=3.8" files = [ @@ -1177,7 +1136,6 @@ test = ["html5lib", "pytest"] name = "sphinxcontrib-jsmath" version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" -category = "main" optional = true python-versions = ">=3.5" files = [ @@ -1192,7 +1150,6 @@ test = ["flake8", "mypy", "pytest"] name = "sphinxcontrib-programoutput" version = "0.17" description = "Sphinx extension to include program output" -category = "main" optional = true python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" files = [ @@ -1207,7 +1164,6 @@ Sphinx = ">=1.7.0" name = "sphinxcontrib-qthelp" version = "1.0.3" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." -category = "main" optional = true python-versions = ">=3.5" files = [ @@ -1223,7 +1179,6 @@ test = ["pytest"] name = "sphinxcontrib-serializinghtml" version = "1.1.5" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." -category = "main" optional = true python-versions = ">=3.5" files = [ @@ -1239,7 +1194,6 @@ test = ["pytest"] name = "termcolor" version = "2.3.0" description = "ANSI color formatting for output in terminal" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1254,7 +1208,6 @@ tests = ["pytest", "pytest-cov"] name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -1266,7 +1219,6 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1278,7 +1230,6 @@ files = [ name = "tox" version = "4.6.2" description = "tox is a generic virtualenv management and test command line tool" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1306,7 +1257,6 @@ testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pol name = "typing-extensions" version = "4.6.3" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1318,7 +1268,6 @@ files = [ name = "urllib3" version = "2.0.3" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1336,7 +1285,6 @@ zstd = ["zstandard (>=0.18.0)"] name = "virtualenv" version = "20.23.1" description = "Virtual Python Environment builder" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1357,7 +1305,6 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess name = "voluptuous" version = "0.13.1" description = "" -category = "dev" optional = false python-versions = "*" files = [ @@ -1369,7 +1316,6 @@ files = [ name = "xdoctest" version = "1.1.1" description = "A rewrite of the builtin doctest module" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1397,7 +1343,6 @@ tests-strict = ["codecov (==2.0.15)", "pytest (==4.6.0)", "pytest (==4.6.0)", "p name = "zipp" version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -1410,10 +1355,10 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [extras] -docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] -speedups = ["orjson", "kasa-crypt"] +docs = ["docutils", "myst-parser", "sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput"] +speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "fcb657fbabe28548021f5f6a1fcb7b60aa82d60f3de015b4b0c7b37260a6a29f" +content-hash = "44a2ba5f0bc7e12f018b34d58f3246a74a98c7c094e54e769998d6fbd6da1ee5" diff --git a/pyproject.toml b/pyproject.toml index 728bd1a9e..193b5beee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ myst-parser = { version = "*", optional = true } docutils = { version = ">=0.17", optional = true } async-timeout = ">=3.0.0" -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] pytest = "*" pytest-cov = "*" pytest-asyncio = "*" @@ -56,11 +56,7 @@ speedups = ["orjson", "kasa-crypt"] [tool.isort] -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -use_parentheses = true -line_length = 88 +profile = "black" known_first_party = "kasa" known_third_party = ["asyncclick", "pytest", "setuptools", "voluptuous"] From 176ced9e6e843b326c949a462d484d70799f0bca Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Thu, 3 Aug 2023 12:20:09 +0100 Subject: [PATCH 155/892] Add new HS100(UK) fixture (#489) --- kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json | 44 ++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json diff --git a/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json b/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json new file mode 100644 index 000000000..448d6f2cc --- /dev/null +++ b/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json @@ -0,0 +1,44 @@ +{ + "emeter": { + "err_code": -1, + "err_msg": "module not support" + }, + "smartlife.iot.common.emeter": { + "err_code": -1, + "err_msg": "module not support" + }, + "smartlife.iot.dimmer": { + "err_code": -1, + "err_msg": "module not support" + }, + "smartlife.iot.smartbulb.lightingservice": { + "err_code": -1, + "err_msg": "module not support" + }, + "system": { + "get_sysinfo": { + "active_mode": "schedule", + "alias": "Unused 3", + "dev_name": "Smart Wi-Fi Plug", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "fwId": "00000000000000000000000000000000", + "hwId": "00000000000000000000000000000000", + "hw_ver": "4.1", + "icon_hash": "", + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "00:00:00:00:00:00", + "model": "HS100(UK)", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -63, + "sw_ver": "1.1.0 Build 201016 Rel.175121", + "type": "IOT.SMARTPLUGSWITCH", + "updating": 0 + } + } +} From 064e3fe560789c0eb832002b4513124f114e1075 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Thu, 3 Aug 2023 13:24:46 +0100 Subject: [PATCH 156/892] Add discovery timeout parameter (#486) * Add discovery timeout parameter * Rename variable to be more pythonic --- kasa/cli.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index b2d9d91a2..d2ac9589d 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -129,9 +129,16 @@ def _device_to_serializable(val: SmartDevice): @click.option( "--json", default=False, is_flag=True, help="Output raw device response as JSON." ) +@click.option( + "--discovery-timeout", + envvar="KASA_DISCOVERY_TIMEOUT", + default=3, + required=False, + help="Timeout for discovery.", +) @click.version_option(package_name="python-kasa") @click.pass_context -async def cli(ctx, host, port, alias, target, debug, type, json): +async def cli(ctx, host, port, alias, target, debug, type, json, discovery_timeout): """A tool for controlling TP-Link smart home devices.""" # noqa # no need to perform any checks if we are just displaying the help if sys.argv[-1] == "--help": @@ -179,7 +186,7 @@ def _nop_echo(*args, **kwargs): if host is None: echo("No host name given, trying discovery..") - return await ctx.invoke(discover) + return await ctx.invoke(discover, timeout=discovery_timeout) if type is not None: dev = TYPE_TO_CLASS[type](host) From 24da24efad0074e5732773c5b94a59133eb2839a Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 14 Aug 2023 14:32:24 +0200 Subject: [PATCH 157/892] Document cli tool --target for discovery (#497) This adds rudimentary documentation on the `--target` option of the cli tool. --- docs/source/cli.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index 2bab9216c..e939a3992 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -16,6 +16,18 @@ If no command is given, the ``state`` command will be executed to query the devi which you can find by adding ``--help`` after the command, e.g. ``kasa --type emeter --help`` or ``kasa --type hsv --help``. Refer to the device type specific documentation for more details. +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``). + +On multihomed systems, you can use ``--target`` option to specify the broadcsat 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. + +.. note:: + + When no command is specified when invoking ``kasa``, a discovery is performed and the ``state`` command is executed on each discovered device. Provisioning ************ From 4b99351dd6e95cf299cfdca560cf1a1602d119e3 Mon Sep 17 00:00:00 2001 From: Norman Rasmussen Date: Sat, 26 Aug 2023 05:21:38 -0700 Subject: [PATCH 158/892] Add toggle command to cli (#498) --- kasa/cli.py | 26 ++++++++++++++++++++++++++ kasa/tests/test_cli.py | 14 +++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/kasa/cli.py b/kasa/cli.py index d2ac9589d..47f53d181 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -613,6 +613,32 @@ async def off(dev: SmartDevice, index: int, name: str, transition: int): return await dev.turn_off(transition=transition) +@cli.command() +@click.option("--index", type=int, required=False) +@click.option("--name", type=str, required=False) +@click.option("--transition", type=int, required=False) +@pass_dev +async def toggle(dev: SmartDevice, index: int, name: str, transition: int): + """Toggle the device on/off.""" + if index is not None or name is not None: + if not dev.is_strip: + echo("Index and name are only for power strips!") + return + + dev = cast(SmartStrip, dev) + if index is not None: + dev = dev.get_plug_by_index(index) + elif name: + dev = dev.get_plug_by_name(name) + + if dev.is_on: + echo(f"Turning off {dev.alias}") + return await dev.turn_off(transition=transition) + + echo(f"Turning on {dev.alias}") + return await dev.turn_on(transition=transition) + + @cli.command() @click.option("--delay", default=1) @pass_dev diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 953c3bd4c..f7e046197 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -5,7 +5,7 @@ from asyncclick.testing import CliRunner from kasa import SmartDevice -from kasa.cli import alias, brightness, cli, emeter, raw_command, state, sysinfo +from kasa.cli import alias, brightness, cli, emeter, raw_command, state, sysinfo, toggle from .conftest import handle_turn_on, turn_on @@ -30,6 +30,18 @@ async def test_state(dev, turn_on): assert "Device state: False" in res.output +@turn_on +async def test_toggle(dev, turn_on, mocker): + await handle_turn_on(dev, turn_on) + runner = CliRunner() + await runner.invoke(toggle, obj=dev) + + if turn_on: + assert not dev.is_on + else: + assert dev.is_on + + async def test_alias(dev): runner = CliRunner() From fad6ae5d10a15672dfea28a54e61f2cb2f47c1db Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 27 Aug 2023 19:53:36 +0200 Subject: [PATCH 159/892] Add devtools script to create module fixtures (#404) --- devtools/README.md | 22 +++++++++++ devtools/create_module_fixtures.py | 60 ++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 devtools/create_module_fixtures.py diff --git a/devtools/README.md b/devtools/README.md index 92e91fd79..c7da859f8 100644 --- a/devtools/README.md +++ b/devtools/README.md @@ -15,6 +15,28 @@ Options: --help Show this message and exit. ``` +## create_module_fixtures + +* Queries the device for all supported modules and outputs module-based fixture files for each device. +* This could be used to create fixture files for module-specific tests, but it might also be useful for other use-cases. + +```shell +Usage: create_module_fixtures.py [OPTIONS] OUTPUTDIR + + Create module fixtures for given host/network. + +Arguments: + OUTPUTDIR [required] + +Options: + --host TEXT + --network TEXT + --install-completion Install completion for the current shell. + --show-completion Show completion for the current shell, to copy it or + customize the installation. + --help Show this message and exit. +``` + ## parse_pcap * Requires dpkt (pip install dpkt) diff --git a/devtools/create_module_fixtures.py b/devtools/create_module_fixtures.py new file mode 100644 index 000000000..1e0f17f72 --- /dev/null +++ b/devtools/create_module_fixtures.py @@ -0,0 +1,60 @@ +"""Create fixture files for modules supported by a device. + +This script can be used to create fixture files for individual modules. +""" + +import asyncio +import json +from pathlib import Path + +import typer + +from kasa import Discover, SmartDevice + +app = typer.Typer() + + +def create_fixtures(dev: SmartDevice, outputdir: Path): + """Iterate over supported modules and create version-specific fixture files.""" + for name, module in dev.modules.items(): + module_dir = outputdir / name + if not module_dir.exists(): + module_dir.mkdir(exist_ok=True, parents=True) + + sw_version = dev.hw_info["sw_ver"] + sw_version = sw_version.split(" ", maxsplit=1)[0] + filename = f"{dev.model}_{dev.hw_info['hw_ver']}_{sw_version}.json" + module_file = module_dir / filename + + if module_file.exists(): + continue + + typer.echo(f"Creating {module_file} for {dev.model}") + with module_file.open("w") as f: + json.dump(module.data, f, indent=4) + + +@app.command() +def create_module_fixtures( + outputdir: Path, + host: str = typer.Option(default=None), + network: str = typer.Option(default=None), +): + """Create module fixtures for given host/network.""" + devs = [] + if host is not None: + dev: SmartDevice = asyncio.run(Discover.discover_single(host)) + devs.append(dev) + else: + if network is None: + network = "255.255.255.255" + devs = asyncio.run(Discover.discover(target=network)).values() + for dev in devs: + asyncio.run(dev.update()) + + for dev in devs: + create_fixtures(dev, outputdir) + + +if __name__ == "__main__": + app() From 0cb6f21d36478c6a6497e20d7c283bcdaeb94794 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 28 Aug 2023 17:43:12 +0200 Subject: [PATCH 160/892] Convert readthedocs config to v2 (#505) --- .readthedocs.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index f2f8fd70d..e79a0598b 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,8 +1,15 @@ +version: 2 + +formats: all + build: - image: latest + os: ubuntu-22.04 + tools: + python: "3" python: - version: 3.8 - pip_install: true - extra_requirements: - - docs + install: + - method: pip + path: . + extra_requirements: + - docs From 53021f07fe11f33e4a5d4a71ecd1ae7e2011baf5 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 28 Aug 2023 17:48:49 +0200 Subject: [PATCH 161/892] Add support for pydantic v2 using v1 shims (#504) --- kasa/modules/cloud.py | 5 +- kasa/modules/rulemodule.py | 6 +- kasa/smartbulb.py | 5 +- poetry.lock | 779 +++++++++++++++++++++---------------- pyproject.toml | 2 +- 5 files changed, 456 insertions(+), 341 deletions(-) diff --git a/kasa/modules/cloud.py b/kasa/modules/cloud.py index 32d3a26d0..b4eface55 100644 --- a/kasa/modules/cloud.py +++ b/kasa/modules/cloud.py @@ -1,5 +1,8 @@ """Cloud module implementation.""" -from pydantic import BaseModel +try: + from pydantic.v1 import BaseModel +except ImportError: + from pydantic import BaseModel from .module import Module diff --git a/kasa/modules/rulemodule.py b/kasa/modules/rulemodule.py index e73b2d03e..05ef500f0 100644 --- a/kasa/modules/rulemodule.py +++ b/kasa/modules/rulemodule.py @@ -3,7 +3,11 @@ from enum import Enum from typing import Dict, List, Optional -from pydantic import BaseModel +try: + from pydantic.v1 import BaseModel +except ImportError: + from pydantic import BaseModel + from .module import Module, merge diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index 1d6ba31e3..ad72701a5 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -4,7 +4,10 @@ from enum import Enum from typing import Any, Dict, List, NamedTuple, Optional, cast -from pydantic import BaseModel, Field, root_validator +try: + from pydantic.v1 import BaseModel, Field, root_validator +except ImportError: + from pydantic import BaseModel, Field, root_validator from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update diff --git a/poetry.lock b/poetry.lock index 5681d8e2e..180b6cd08 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,15 +11,29 @@ files = [ {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, ] +[[package]] +name = "annotated-types" +version = "0.5.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.7" +files = [ + {file = "annotated_types-0.5.0-py3-none-any.whl", hash = "sha256:58da39888f92c276ad970249761ebea80ba544b77acddaa1a4d6cf78287d45fd"}, + {file = "annotated_types-0.5.0.tar.gz", hash = "sha256:47cdc3490d9ac1506ce92c7aaa76c579dc3509ff11e098fc867e5130ab7be802"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} + [[package]] name = "anyio" -version = "3.7.0" +version = "3.7.1" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.7" files = [ - {file = "anyio-3.7.0-py3-none-any.whl", hash = "sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0"}, - {file = "anyio-3.7.0.tar.gz", hash = "sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce"}, + {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, + {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, ] [package.dependencies] @@ -28,19 +42,19 @@ idna = ">=2.8" sniffio = ">=1.1" [package.extras] -doc = ["Sphinx (>=6.1.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme", "sphinxcontrib-jquery"] +doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] trio = ["trio (<0.22)"] [[package]] name = "async-timeout" -version = "4.0.2" +version = "4.0.3" description = "Timeout context manager for asyncio programs" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, - {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, ] [[package]] @@ -84,119 +98,119 @@ files = [ [[package]] name = "certifi" -version = "2023.5.7" +version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, - {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, + {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, + {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] [[package]] name = "cfgv" -version = "3.3.1" +version = "3.4.0" description = "Validate configuration and produce human readable error messages." optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.8" files = [ - {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, - {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, ] [[package]] name = "chardet" -version = "5.1.0" +version = "5.2.0" description = "Universal encoding detector for Python 3" optional = false python-versions = ">=3.7" files = [ - {file = "chardet-5.1.0-py3-none-any.whl", hash = "sha256:362777fb014af596ad31334fde1e8c327dfdb076e1960d1694662d46a6917ab9"}, - {file = "chardet-5.1.0.tar.gz", hash = "sha256:0d62712b956bc154f85fb0a266e2a3c5913c2967e00348701b32411d6def31e5"}, + {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, + {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, ] [[package]] name = "charset-normalizer" -version = "3.1.0" +version = "3.2.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" files = [ - {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, - {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, + {file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-win32.whl", hash = "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-win32.whl", hash = "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-win32.whl", hash = "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-win32.whl", hash = "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-win32.whl", hash = "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80"}, + {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, ] [[package]] @@ -227,71 +241,63 @@ files = [ [[package]] name = "coverage" -version = "7.2.7" +version = "7.3.0" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, - {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, - {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, - {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, - {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, - {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, - {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, - {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, - {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, - {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, - {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, - {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, - {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, - {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, - {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, - {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, - {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, - {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, + {file = "coverage-7.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:db76a1bcb51f02b2007adacbed4c88b6dee75342c37b05d1822815eed19edee5"}, + {file = "coverage-7.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c02cfa6c36144ab334d556989406837336c1d05215a9bdf44c0bc1d1ac1cb637"}, + {file = "coverage-7.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:477c9430ad5d1b80b07f3c12f7120eef40bfbf849e9e7859e53b9c93b922d2af"}, + {file = "coverage-7.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce2ee86ca75f9f96072295c5ebb4ef2a43cecf2870b0ca5e7a1cbdd929cf67e1"}, + {file = "coverage-7.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68d8a0426b49c053013e631c0cdc09b952d857efa8f68121746b339912d27a12"}, + {file = "coverage-7.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b3eb0c93e2ea6445b2173da48cb548364f8f65bf68f3d090404080d338e3a689"}, + {file = "coverage-7.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:90b6e2f0f66750c5a1178ffa9370dec6c508a8ca5265c42fbad3ccac210a7977"}, + {file = "coverage-7.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:96d7d761aea65b291a98c84e1250cd57b5b51726821a6f2f8df65db89363be51"}, + {file = "coverage-7.3.0-cp310-cp310-win32.whl", hash = "sha256:63c5b8ecbc3b3d5eb3a9d873dec60afc0cd5ff9d9f1c75981d8c31cfe4df8527"}, + {file = "coverage-7.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:97c44f4ee13bce914272589b6b41165bbb650e48fdb7bd5493a38bde8de730a1"}, + {file = "coverage-7.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:74c160285f2dfe0acf0f72d425f3e970b21b6de04157fc65adc9fd07ee44177f"}, + {file = "coverage-7.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b543302a3707245d454fc49b8ecd2c2d5982b50eb63f3535244fd79a4be0c99d"}, + {file = "coverage-7.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad0f87826c4ebd3ef484502e79b39614e9c03a5d1510cfb623f4a4a051edc6fd"}, + {file = "coverage-7.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13c6cbbd5f31211d8fdb477f0f7b03438591bdd077054076eec362cf2207b4a7"}, + {file = "coverage-7.3.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fac440c43e9b479d1241fe9d768645e7ccec3fb65dc3a5f6e90675e75c3f3e3a"}, + {file = "coverage-7.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3c9834d5e3df9d2aba0275c9f67989c590e05732439b3318fa37a725dff51e74"}, + {file = "coverage-7.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4c8e31cf29b60859876474034a83f59a14381af50cbe8a9dbaadbf70adc4b214"}, + {file = "coverage-7.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7a9baf8e230f9621f8e1d00c580394a0aa328fdac0df2b3f8384387c44083c0f"}, + {file = "coverage-7.3.0-cp311-cp311-win32.whl", hash = "sha256:ccc51713b5581e12f93ccb9c5e39e8b5d4b16776d584c0f5e9e4e63381356482"}, + {file = "coverage-7.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:887665f00ea4e488501ba755a0e3c2cfd6278e846ada3185f42d391ef95e7e70"}, + {file = "coverage-7.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d000a739f9feed900381605a12a61f7aaced6beae832719ae0d15058a1e81c1b"}, + {file = "coverage-7.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:59777652e245bb1e300e620ce2bef0d341945842e4eb888c23a7f1d9e143c446"}, + {file = "coverage-7.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9737bc49a9255d78da085fa04f628a310c2332b187cd49b958b0e494c125071"}, + {file = "coverage-7.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5247bab12f84a1d608213b96b8af0cbb30d090d705b6663ad794c2f2a5e5b9fe"}, + {file = "coverage-7.3.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2ac9a1de294773b9fa77447ab7e529cf4fe3910f6a0832816e5f3d538cfea9a"}, + {file = "coverage-7.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:85b7335c22455ec12444cec0d600533a238d6439d8d709d545158c1208483873"}, + {file = "coverage-7.3.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:36ce5d43a072a036f287029a55b5c6a0e9bd73db58961a273b6dc11a2c6eb9c2"}, + {file = "coverage-7.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:211a4576e984f96d9fce61766ffaed0115d5dab1419e4f63d6992b480c2bd60b"}, + {file = "coverage-7.3.0-cp312-cp312-win32.whl", hash = "sha256:56afbf41fa4a7b27f6635bc4289050ac3ab7951b8a821bca46f5b024500e6321"}, + {file = "coverage-7.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f297e0c1ae55300ff688568b04ff26b01c13dfbf4c9d2b7d0cb688ac60df479"}, + {file = "coverage-7.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac0dec90e7de0087d3d95fa0533e1d2d722dcc008bc7b60e1143402a04c117c1"}, + {file = "coverage-7.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:438856d3f8f1e27f8e79b5410ae56650732a0dcfa94e756df88c7e2d24851fcd"}, + {file = "coverage-7.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1084393c6bda8875c05e04fce5cfe1301a425f758eb012f010eab586f1f3905e"}, + {file = "coverage-7.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49ab200acf891e3dde19e5aa4b0f35d12d8b4bd805dc0be8792270c71bd56c54"}, + {file = "coverage-7.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67e6bbe756ed458646e1ef2b0778591ed4d1fcd4b146fc3ba2feb1a7afd4254"}, + {file = "coverage-7.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8f39c49faf5344af36042b293ce05c0d9004270d811c7080610b3e713251c9b0"}, + {file = "coverage-7.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7df91fb24c2edaabec4e0eee512ff3bc6ec20eb8dccac2e77001c1fe516c0c84"}, + {file = "coverage-7.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:34f9f0763d5fa3035a315b69b428fe9c34d4fc2f615262d6be3d3bf3882fb985"}, + {file = "coverage-7.3.0-cp38-cp38-win32.whl", hash = "sha256:bac329371d4c0d456e8d5f38a9b0816b446581b5f278474e416ea0c68c47dcd9"}, + {file = "coverage-7.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:b859128a093f135b556b4765658d5d2e758e1fae3e7cc2f8c10f26fe7005e543"}, + {file = "coverage-7.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed8d310afe013db1eedd37176d0839dc66c96bcfcce8f6607a73ffea2d6ba"}, + {file = "coverage-7.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61260ec93f99f2c2d93d264b564ba912bec502f679793c56f678ba5251f0393"}, + {file = "coverage-7.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97af9554a799bd7c58c0179cc8dbf14aa7ab50e1fd5fa73f90b9b7215874ba28"}, + {file = "coverage-7.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3558e5b574d62f9c46b76120a5c7c16c4612dc2644c3d48a9f4064a705eaee95"}, + {file = "coverage-7.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37d5576d35fcb765fca05654f66aa71e2808d4237d026e64ac8b397ffa66a56a"}, + {file = "coverage-7.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:07ea61bcb179f8f05ffd804d2732b09d23a1238642bf7e51dad62082b5019b34"}, + {file = "coverage-7.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:80501d1b2270d7e8daf1b64b895745c3e234289e00d5f0e30923e706f110334e"}, + {file = "coverage-7.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4eddd3153d02204f22aef0825409091a91bf2a20bce06fe0f638f5c19a85de54"}, + {file = "coverage-7.3.0-cp39-cp39-win32.whl", hash = "sha256:2d22172f938455c156e9af2612650f26cceea47dc86ca048fa4e0b2d21646ad3"}, + {file = "coverage-7.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:60f64e2007c9144375dd0f480a54d6070f00bb1a28f65c408370544091c9bc9e"}, + {file = "coverage-7.3.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:5492a6ce3bdb15c6ad66cb68a0244854d9917478877a25671d70378bdc8562d0"}, + {file = "coverage-7.3.0.tar.gz", hash = "sha256:49dbb19cdcafc130f597d9e04a29d0a032ceedf729e41b181f51cd170e6ee865"}, ] [package.dependencies] @@ -302,13 +308,13 @@ toml = ["tomli"] [[package]] name = "distlib" -version = "0.3.6" +version = "0.3.7" description = "Distribution utilities" optional = false python-versions = "*" files = [ - {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, - {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, + {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, + {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, ] [[package]] @@ -324,13 +330,13 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.1.1" +version = "1.1.3" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, - {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, + {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, + {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, ] [package.extras] @@ -353,13 +359,13 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "p [[package]] name = "identify" -version = "2.5.24" +version = "2.5.27" description = "File identification library for Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "identify-2.5.24-py2.py3-none-any.whl", hash = "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d"}, - {file = "identify-2.5.24.tar.gz", hash = "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4"}, + {file = "identify-2.5.27-py2.py3-none-any.whl", hash = "sha256:fdb527b2dfe24602809b2201e033c2a113d7bdf716db3ca8e3243f735dcecaba"}, + {file = "identify-2.5.27.tar.gz", hash = "sha256:287b75b04a0e22d727bc9a41f0d4f3c1bcada97490fa6eabb5b28f0e9097e733"}, ] [package.extras] @@ -389,13 +395,13 @@ files = [ [[package]] name = "importlib-metadata" -version = "6.6.0" +version = "6.8.0" description = "Read metadata from Python packages" optional = true -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "importlib_metadata-6.6.0-py3-none-any.whl", hash = "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed"}, - {file = "importlib_metadata-6.6.0.tar.gz", hash = "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705"}, + {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, + {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, ] [package.dependencies] @@ -404,7 +410,7 @@ zipp = ">=0.5" [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] [[package]] name = "iniconfig" @@ -436,27 +442,27 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "kasa-crypt" -version = "0.2.0" +version = "0.3.0" description = "Fast kasa crypt" optional = true python-versions = ">=3.7,<4.0" files = [ - {file = "kasa_crypt-0.2.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:62e9f1e7b8c1cb420522ddfd752a81d387a7097f640fa752d848360680f926e3"}, - {file = "kasa_crypt-0.2.0-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:a9708ee85bc7a2978fe70170de6e6210f9e913c601c14baa2ce61955b0fa24e0"}, - {file = "kasa_crypt-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27aece9fb1b8b52ccb3e73a54a3e5236bfd6aec4bc3699215dcc546f2ddf8e26"}, - {file = "kasa_crypt-0.2.0-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:9676ac702aa62252fb4de3d5c9ee4895dd93610b9c37e732213b0914fbc0e255"}, - {file = "kasa_crypt-0.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:d7851e34e3216c6b3c982fb864fc166056476179d8ceea0b06138f7f86b8a657"}, - {file = "kasa_crypt-0.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ea5ed45e6c78f4b486dc2dfa04f4c82da1b836a8b1e244787a885f13c7f89cca"}, - {file = "kasa_crypt-0.2.0-cp310-cp310-win32.whl", hash = "sha256:5417fa89664918625e0b949fdee30247a944274e61451f24789f2245b3b403d2"}, - {file = "kasa_crypt-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:462f7dbca8eab86507158bcd73005f8953f23f2bf5717f8cea1becd1e237b890"}, - {file = "kasa_crypt-0.2.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:21c02f3d502adf7394bc4b306588b1d6f62d371c1218673b6fa22df158fc8b2b"}, - {file = "kasa_crypt-0.2.0-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:3b8c6a3c544e5f947892858807862f036eada4974a20e73f627de75ea23d53a8"}, - {file = "kasa_crypt-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf9725613f3512eb35d4ac517afe2128157a3becfae1e663b8f6e2fa027bd891"}, - {file = "kasa_crypt-0.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4ff483c0bd9dd6a7b504c0d57669653941a8b1bcdcfeaba504fc02b40fa4057e"}, - {file = "kasa_crypt-0.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e2e60330baebb14ead03af156e2f22a88bdc06f879e5876f31a6940d9b51b333"}, - {file = "kasa_crypt-0.2.0-cp311-cp311-win32.whl", hash = "sha256:4ba8c6661232204dabe60cd3d592b702d43359a4a4947cf805f5d8cbec5164ae"}, - {file = "kasa_crypt-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:7db9b111c76838e518fb040f1d6cc87a1aba4bf6cab44e834dadc924587ff199"}, - {file = "kasa_crypt-0.2.0.tar.gz", hash = "sha256:ad2e73276f09ed035d53006985b08eb78869f73e60ac5d66547d9ddc35cb8cc4"}, + {file = "kasa_crypt-0.3.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:17f543d6952d3cd8aa094429870f9e3241f6035df2ecfd1b937cd6e7da5902c6"}, + {file = "kasa_crypt-0.3.0-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:4a6b15fd4832051b5f75db1eec8c273ba6e5a3122cd7030e0f92d0a90babc5ed"}, + {file = "kasa_crypt-0.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1558b81cb36be015f211d88c69ead8f8708add1206e89672ffc7f06449c682"}, + {file = "kasa_crypt-0.3.0-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:e7525a9770b0df0cde5f2b764dc5415eb5f136159477ffc85759f9dba21a1aff"}, + {file = "kasa_crypt-0.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:dfa84ee1449939d04e5e4a1c6931a2d429f7c1236a6c99eb3970afdf4723fe76"}, + {file = "kasa_crypt-0.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c7e9f3852f087bc5af2077aa95c31a96a6e2a1f89198a4474dd641e578cd1086"}, + {file = "kasa_crypt-0.3.0-cp310-cp310-win32.whl", hash = "sha256:da1f03dcc12261c10ae8c65bb02d809273ecdf1fc31a9dba58af1ae70cae970e"}, + {file = "kasa_crypt-0.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:ccd10995596e746521a6c7be6ca39a87fae74ddd46558f1d6eea5ab221791107"}, + {file = "kasa_crypt-0.3.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:acc3fbb9adf7b80c310cf4bd7334d8bea8e19478b3a24447064093091acef93f"}, + {file = "kasa_crypt-0.3.0-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:34ea41e7788062fc782bcfbe998f8c8d75308785e50c4e3f338dd4c2e488881f"}, + {file = "kasa_crypt-0.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f5231874d7973036b7afce432bb5b7404cdafbbb4d46363580aafcc5d26fde5"}, + {file = "kasa_crypt-0.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:17c7938af96b30416eac6899689e9126c1d17f8f9a0f9dcf9f5cda86f084c60d"}, + {file = "kasa_crypt-0.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c497dbaf1a76b190d025753f146af3e8c948d037b7ca293f4eeebb9f9721f4f8"}, + {file = "kasa_crypt-0.3.0-cp311-cp311-win32.whl", hash = "sha256:0ede1c2e460e8481705a159e13b6e437fa09ac24993e4a55edc26a962ffa436e"}, + {file = "kasa_crypt-0.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:90f6c66b48db56631e7fe391f4e4934b0ddf6f41d31aa834c1baedc6c94b40d4"}, + {file = "kasa_crypt-0.3.0.tar.gz", hash = "sha256:80c866a1f5d4ad419fcd454b2343a6ecfff8814195ab2caf108941971150ccd8"}, ] [[package]] @@ -614,57 +620,71 @@ setuptools = "*" [[package]] name = "orjson" -version = "3.9.1" +version = "3.9.5" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = true python-versions = ">=3.7" files = [ - {file = "orjson-3.9.1-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c4434b7b786fdc394b95d029fb99949d7c2b05bbd4bf5cb5e3906be96ffeee3b"}, - {file = "orjson-3.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09faf14f74ed47e773fa56833be118e04aa534956f661eb491522970b7478e3b"}, - {file = "orjson-3.9.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:503eb86a8d53a187fe66aa80c69295a3ca35475804da89a9547e4fce5f803822"}, - {file = "orjson-3.9.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20f2804b5a1dbd3609c086041bd243519224d47716efd7429db6c03ed28b7cc3"}, - {file = "orjson-3.9.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fd828e0656615a711c4cc4da70f3cac142e66a6703ba876c20156a14e28e3fa"}, - {file = "orjson-3.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec53d648176f873203b9c700a0abacab33ca1ab595066e9d616f98cdc56f4434"}, - {file = "orjson-3.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e186ae76b0d97c505500664193ddf508c13c1e675d9b25f1f4414a7606100da6"}, - {file = "orjson-3.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d4edee78503016f4df30aeede0d999b3cb11fb56f47e9db0e487bce0aaca9285"}, - {file = "orjson-3.9.1-cp310-none-win_amd64.whl", hash = "sha256:a4cc5d21e68af982d9a2528ac61e604f092c60eed27aef3324969c68f182ec7e"}, - {file = "orjson-3.9.1-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:761b6efd33c49de20dd73ce64cc59da62c0dab10aa6015f582680e0663cc792c"}, - {file = "orjson-3.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31229f9d0b8dc2ef7ee7e4393f2e4433a28e16582d4b25afbfccc9d68dc768f8"}, - {file = "orjson-3.9.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b7ab18d55ecb1de543d452f0a5f8094b52282b916aa4097ac11a4c79f317b86"}, - {file = "orjson-3.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db774344c39041f4801c7dfe03483df9203cbd6c84e601a65908e5552228dd25"}, - {file = "orjson-3.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae47ef8c0fe89c4677db7e9e1fb2093ca6e66c3acbee5442d84d74e727edad5e"}, - {file = "orjson-3.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:103952c21575b9805803c98add2eaecd005580a1e746292ed2ec0d76dd3b9746"}, - {file = "orjson-3.9.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2cb0121e6f2c9da3eddf049b99b95fef0adf8480ea7cb544ce858706cdf916eb"}, - {file = "orjson-3.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:24d4ddaa2876e657c0fd32902b5c451fd2afc35159d66a58da7837357044b8c2"}, - {file = "orjson-3.9.1-cp311-none-win_amd64.whl", hash = "sha256:0b53b5f72cf536dd8aa4fc4c95e7e09a7adb119f8ff8ee6cc60f735d7740ad6a"}, - {file = "orjson-3.9.1-cp37-cp37m-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4b68d01a506242316a07f1d2f29fb0a8b36cee30a7c35076f1ef59dce0890c1"}, - {file = "orjson-3.9.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9dd4abe6c6fd352f00f4246d85228f6a9847d0cc14f4d54ee553718c225388f"}, - {file = "orjson-3.9.1-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e20bca5e13041e31ceba7a09bf142e6d63c8a7467f5a9c974f8c13377c75af2"}, - {file = "orjson-3.9.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ae0467d01eb1e4bcffef4486d964bfd1c2e608103e75f7074ed34be5df48cc"}, - {file = "orjson-3.9.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06f6ab4697fab090517f295915318763a97a12ee8186054adf21c1e6f6abbd3d"}, - {file = "orjson-3.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8515867713301fa065c58ec4c9053ba1a22c35113ab4acad555317b8fd802e50"}, - {file = "orjson-3.9.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:393d0697d1dfa18d27d193e980c04fdfb672c87f7765b87952f550521e21b627"}, - {file = "orjson-3.9.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d96747662d3666f79119e5d28c124e7d356c7dc195cd4b09faea4031c9079dc9"}, - {file = "orjson-3.9.1-cp37-none-win_amd64.whl", hash = "sha256:6d173d3921dd58a068c88ec22baea7dbc87a137411501618b1292a9d6252318e"}, - {file = "orjson-3.9.1-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d1c2b0b4246c992ce2529fc610a446b945f1429445ece1c1f826a234c829a918"}, - {file = "orjson-3.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19f70ba1f441e1c4bb1a581f0baa092e8b3e3ce5b2aac2e1e090f0ac097966da"}, - {file = "orjson-3.9.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:375d65f002e686212aac42680aed044872c45ee4bc656cf63d4a215137a6124a"}, - {file = "orjson-3.9.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4751cee4a7b1daeacb90a7f5adf2170ccab893c3ab7c5cea58b45a13f89b30b3"}, - {file = "orjson-3.9.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78d9a2a4b2302d5ebc3695498ebc305c3568e5ad4f3501eb30a6405a32d8af22"}, - {file = "orjson-3.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46b4facc32643b2689dfc292c0c463985dac4b6ab504799cf51fc3c6959ed668"}, - {file = "orjson-3.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec7c8a0f1bf35da0d5fd14f8956f3b82a9a6918a3c6963d718dfd414d6d3b604"}, - {file = "orjson-3.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d3a40b0fbe06ccd4d6a99e523d20b47985655bcada8d1eba485b1b32a43e4904"}, - {file = "orjson-3.9.1-cp38-none-win_amd64.whl", hash = "sha256:402f9d3edfec4560a98880224ec10eba4c5f7b4791e4bc0d4f4d8df5faf2a006"}, - {file = "orjson-3.9.1-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:49c0d78dcd34626e2e934f1192d7c052b94e0ecadc5f386fd2bda6d2e03dadf5"}, - {file = "orjson-3.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:125f63e56d38393daa0a1a6dc6fedefca16c538614b66ea5997c3bd3af35ef26"}, - {file = "orjson-3.9.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:08927970365d2e1f3ce4894f9ff928a7b865d53f26768f1bbdd85dd4fee3e966"}, - {file = "orjson-3.9.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9a744e212d4780ecd67f4b6b128b2e727bee1df03e7059cddb2dfe1083e7dc4"}, - {file = "orjson-3.9.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d1dbf36db7240c61eec98c8d21545d671bce70be0730deb2c0d772e06b71af3"}, - {file = "orjson-3.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80a1e384626f76b66df615f7bb622a79a25c166d08c5d2151ffd41f24c4cc104"}, - {file = "orjson-3.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:15d28872fb055bf17ffca913826e618af61b2f689d2b170f72ecae1a86f80d52"}, - {file = "orjson-3.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1e4d905338f9ef32c67566929dfbfbb23cc80287af8a2c38930fb0eda3d40b76"}, - {file = "orjson-3.9.1-cp39-none-win_amd64.whl", hash = "sha256:48a27da6c7306965846565cc385611d03382bbd84120008653aa2f6741e2105d"}, - {file = "orjson-3.9.1.tar.gz", hash = "sha256:db373a25ec4a4fccf8186f9a72a1b3442837e40807a736a815ab42481e83b7d0"}, + {file = "orjson-3.9.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ad6845912a71adcc65df7c8a7f2155eba2096cf03ad2c061c93857de70d699ad"}, + {file = "orjson-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e298e0aacfcc14ef4476c3f409e85475031de24e5b23605a465e9bf4b2156273"}, + {file = "orjson-3.9.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83c9939073281ef7dd7c5ca7f54cceccb840b440cec4b8a326bda507ff88a0a6"}, + {file = "orjson-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e174cc579904a48ee1ea3acb7045e8a6c5d52c17688dfcb00e0e842ec378cabf"}, + {file = "orjson-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f8d51702f42c785b115401e1d64a27a2ea767ae7cf1fb8edaa09c7cf1571c660"}, + {file = "orjson-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f13d61c0c7414ddee1ef4d0f303e2222f8cced5a2e26d9774751aecd72324c9e"}, + {file = "orjson-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d748cc48caf5a91c883d306ab648df1b29e16b488c9316852844dd0fd000d1c2"}, + {file = "orjson-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bd19bc08fa023e4c2cbf8294ad3f2b8922f4de9ba088dbc71e6b268fdf54591c"}, + {file = "orjson-3.9.5-cp310-none-win32.whl", hash = "sha256:5793a21a21bf34e1767e3d61a778a25feea8476dcc0bdf0ae1bc506dc34561ea"}, + {file = "orjson-3.9.5-cp310-none-win_amd64.whl", hash = "sha256:2bcec0b1024d0031ab3eab7a8cb260c8a4e4a5e35993878a2da639d69cdf6a65"}, + {file = "orjson-3.9.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8547b95ca0e2abd17e1471973e6d676f1d8acedd5f8fb4f739e0612651602d66"}, + {file = "orjson-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87ce174d6a38d12b3327f76145acbd26f7bc808b2b458f61e94d83cd0ebb4d76"}, + {file = "orjson-3.9.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a960bb1bc9a964d16fcc2d4af5a04ce5e4dfddca84e3060c35720d0a062064fe"}, + {file = "orjson-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a7aa5573a949760d6161d826d34dc36db6011926f836851fe9ccb55b5a7d8e8"}, + {file = "orjson-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b2852afca17d7eea85f8e200d324e38c851c96598ac7b227e4f6c4e59fbd3df"}, + {file = "orjson-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa185959c082475288da90f996a82e05e0c437216b96f2a8111caeb1d54ef926"}, + {file = "orjson-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:89c9332695b838438ea4b9a482bce8ffbfddde4df92750522d928fb00b7b8dce"}, + {file = "orjson-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2493f1351a8f0611bc26e2d3d407efb873032b4f6b8926fed8cfed39210ca4ba"}, + {file = "orjson-3.9.5-cp311-none-win32.whl", hash = "sha256:ffc544e0e24e9ae69301b9a79df87a971fa5d1c20a6b18dca885699709d01be0"}, + {file = "orjson-3.9.5-cp311-none-win_amd64.whl", hash = "sha256:89670fe2732e3c0c54406f77cad1765c4c582f67b915c74fda742286809a0cdc"}, + {file = "orjson-3.9.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:15df211469625fa27eced4aa08dc03e35f99c57d45a33855cc35f218ea4071b8"}, + {file = "orjson-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9f17c59fe6c02bc5f89ad29edb0253d3059fe8ba64806d789af89a45c35269a"}, + {file = "orjson-3.9.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca6b96659c7690773d8cebb6115c631f4a259a611788463e9c41e74fa53bf33f"}, + {file = "orjson-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26fafe966e9195b149950334bdbe9026eca17fe8ffe2d8fa87fdc30ca925d30"}, + {file = "orjson-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9006b1eb645ecf460da067e2dd17768ccbb8f39b01815a571bfcfab7e8da5e52"}, + {file = "orjson-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebfdbf695734b1785e792a1315e41835ddf2a3e907ca0e1c87a53f23006ce01d"}, + {file = "orjson-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4a3943234342ab37d9ed78fb0a8f81cd4b9532f67bf2ac0d3aa45fa3f0a339f3"}, + {file = "orjson-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e6762755470b5c82f07b96b934af32e4d77395a11768b964aaa5eb092817bc31"}, + {file = "orjson-3.9.5-cp312-none-win_amd64.whl", hash = "sha256:c74df28749c076fd6e2157190df23d43d42b2c83e09d79b51694ee7315374ad5"}, + {file = "orjson-3.9.5-cp37-cp37m-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:88e18a74d916b74f00d0978d84e365c6bf0e7ab846792efa15756b5fb2f7d49d"}, + {file = "orjson-3.9.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d28514b5b6dfaf69097be70d0cf4f1407ec29d0f93e0b4131bf9cc8fd3f3e374"}, + {file = "orjson-3.9.5-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b81aca8c7be61e2566246b6a0ca49f8aece70dd3f38c7f5c837f398c4cb142"}, + {file = "orjson-3.9.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:385c1c713b1e47fd92e96cf55fd88650ac6dfa0b997e8aa7ecffd8b5865078b1"}, + {file = "orjson-3.9.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9850c03a8e42fba1a508466e6a0f99472fd2b4a5f30235ea49b2a1b32c04c11"}, + {file = "orjson-3.9.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4449f84bbb13bcef493d8aa669feadfced0f7c5eea2d0d88b5cc21f812183af8"}, + {file = "orjson-3.9.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:86127bf194f3b873135e44ce5dc9212cb152b7e06798d5667a898a00f0519be4"}, + {file = "orjson-3.9.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0abcd039f05ae9ab5b0ff11624d0b9e54376253b7d3217a358d09c3edf1d36f7"}, + {file = "orjson-3.9.5-cp37-none-win32.whl", hash = "sha256:10cc8ad5ff7188efcb4bec196009d61ce525a4e09488e6d5db41218c7fe4f001"}, + {file = "orjson-3.9.5-cp37-none-win_amd64.whl", hash = "sha256:ff27e98532cb87379d1a585837d59b187907228268e7b0a87abe122b2be6968e"}, + {file = "orjson-3.9.5-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5bfa79916ef5fef75ad1f377e54a167f0de334c1fa4ebb8d0224075f3ec3d8c0"}, + {file = "orjson-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e87dfa6ac0dae764371ab19b35eaaa46dfcb6ef2545dfca03064f21f5d08239f"}, + {file = "orjson-3.9.5-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50ced24a7b23058b469ecdb96e36607fc611cbaee38b58e62a55c80d1b3ad4e1"}, + {file = "orjson-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b1b74ea2a3064e1375da87788897935832e806cc784de3e789fd3c4ab8eb3fa5"}, + {file = "orjson-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7cb961efe013606913d05609f014ad43edfaced82a576e8b520a5574ce3b2b9"}, + {file = "orjson-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1225d2d5ee76a786bda02f8c5e15017462f8432bb960de13d7c2619dba6f0275"}, + {file = "orjson-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f39f4b99199df05c7ecdd006086259ed25886cdbd7b14c8cdb10c7675cfcca7d"}, + {file = "orjson-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a461dc9fb60cac44f2d3218c36a0c1c01132314839a0e229d7fb1bba69b810d8"}, + {file = "orjson-3.9.5-cp38-none-win32.whl", hash = "sha256:dedf1a6173748202df223aea29de814b5836732a176b33501375c66f6ab7d822"}, + {file = "orjson-3.9.5-cp38-none-win_amd64.whl", hash = "sha256:fa504082f53efcbacb9087cc8676c163237beb6e999d43e72acb4bb6f0db11e6"}, + {file = "orjson-3.9.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6900f0248edc1bec2a2a3095a78a7e3ef4e63f60f8ddc583687eed162eedfd69"}, + {file = "orjson-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17404333c40047888ac40bd8c4d49752a787e0a946e728a4e5723f111b6e55a5"}, + {file = "orjson-3.9.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0eefb7cfdd9c2bc65f19f974a5d1dfecbac711dae91ed635820c6b12da7a3c11"}, + {file = "orjson-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:68c78b2a3718892dc018adbc62e8bab6ef3c0d811816d21e6973dee0ca30c152"}, + {file = "orjson-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:591ad7d9e4a9f9b104486ad5d88658c79ba29b66c5557ef9edf8ca877a3f8d11"}, + {file = "orjson-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cc2cbf302fbb2d0b2c3c142a663d028873232a434d89ce1b2604ebe5cc93ce8"}, + {file = "orjson-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b26b5aa5e9ee1bad2795b925b3adb1b1b34122cb977f30d89e0a1b3f24d18450"}, + {file = "orjson-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ef84724f7d29dcfe3aafb1fc5fc7788dca63e8ae626bb9298022866146091a3e"}, + {file = "orjson-3.9.5-cp39-none-win32.whl", hash = "sha256:664cff27f85939059472afd39acff152fbac9a091b7137092cb651cf5f7747b5"}, + {file = "orjson-3.9.5-cp39-none-win_amd64.whl", hash = "sha256:91dda66755795ac6100e303e206b636568d42ac83c156547634256a2e68de694"}, + {file = "orjson-3.9.5.tar.gz", hash = "sha256:6daf5ee0b3cf530b9978cdbf71024f1c16ed4a67d05f6ec435c6e7fe7a52724c"}, ] [[package]] @@ -680,28 +700,28 @@ files = [ [[package]] name = "platformdirs" -version = "3.5.3" +version = "3.10.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.5.3-py3-none-any.whl", hash = "sha256:0ade98a4895e87dc51d47151f7d2ec290365a585151d97b4d8d6312ed6132fed"}, - {file = "platformdirs-3.5.3.tar.gz", hash = "sha256:e48fabd87db8f3a7df7150a4a5ea22c546ee8bc39bc2473244730d4b56d2cc4e"}, + {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, + {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, ] [package.extras] -docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] [[package]] name = "pluggy" -version = "1.0.0" +version = "1.3.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, + {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, + {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, ] [package.extras] @@ -728,65 +748,150 @@ virtualenv = ">=20.10.0" [[package]] name = "pydantic" -version = "1.10.9" -description = "Data validation and settings management using python type hints" +version = "2.3.0" +description = "Data validation using Python type hints" optional = false python-versions = ">=3.7" files = [ - {file = "pydantic-1.10.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e692dec4a40bfb40ca530e07805b1208c1de071a18d26af4a2a0d79015b352ca"}, - {file = "pydantic-1.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3c52eb595db83e189419bf337b59154bdcca642ee4b2a09e5d7797e41ace783f"}, - {file = "pydantic-1.10.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:939328fd539b8d0edf244327398a667b6b140afd3bf7e347cf9813c736211896"}, - {file = "pydantic-1.10.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b48d3d634bca23b172f47f2335c617d3fcb4b3ba18481c96b7943a4c634f5c8d"}, - {file = "pydantic-1.10.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:f0b7628fb8efe60fe66fd4adadd7ad2304014770cdc1f4934db41fe46cc8825f"}, - {file = "pydantic-1.10.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e1aa5c2410769ca28aa9a7841b80d9d9a1c5f223928ca8bec7e7c9a34d26b1d4"}, - {file = "pydantic-1.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:eec39224b2b2e861259d6f3c8b6290d4e0fbdce147adb797484a42278a1a486f"}, - {file = "pydantic-1.10.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d111a21bbbfd85c17248130deac02bbd9b5e20b303338e0dbe0faa78330e37e0"}, - {file = "pydantic-1.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e9aec8627a1a6823fc62fb96480abe3eb10168fd0d859ee3d3b395105ae19a7"}, - {file = "pydantic-1.10.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07293ab08e7b4d3c9d7de4949a0ea571f11e4557d19ea24dd3ae0c524c0c334d"}, - {file = "pydantic-1.10.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ee829b86ce984261d99ff2fd6e88f2230068d96c2a582f29583ed602ef3fc2c"}, - {file = "pydantic-1.10.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4b466a23009ff5cdd7076eb56aca537c745ca491293cc38e72bf1e0e00de5b91"}, - {file = "pydantic-1.10.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7847ca62e581e6088d9000f3c497267868ca2fa89432714e21a4fb33a04d52e8"}, - {file = "pydantic-1.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:7845b31959468bc5b78d7b95ec52fe5be32b55d0d09983a877cca6aedc51068f"}, - {file = "pydantic-1.10.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:517a681919bf880ce1dac7e5bc0c3af1e58ba118fd774da2ffcd93c5f96eaece"}, - {file = "pydantic-1.10.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67195274fd27780f15c4c372f4ba9a5c02dad6d50647b917b6a92bf00b3d301a"}, - {file = "pydantic-1.10.9-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2196c06484da2b3fded1ab6dbe182bdabeb09f6318b7fdc412609ee2b564c49a"}, - {file = "pydantic-1.10.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6257bb45ad78abacda13f15bde5886efd6bf549dd71085e64b8dcf9919c38b60"}, - {file = "pydantic-1.10.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3283b574b01e8dbc982080d8287c968489d25329a463b29a90d4157de4f2baaf"}, - {file = "pydantic-1.10.9-cp37-cp37m-win_amd64.whl", hash = "sha256:5f8bbaf4013b9a50e8100333cc4e3fa2f81214033e05ac5aa44fa24a98670a29"}, - {file = "pydantic-1.10.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b9cd67fb763248cbe38f0593cd8611bfe4b8ad82acb3bdf2b0898c23415a1f82"}, - {file = "pydantic-1.10.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f50e1764ce9353be67267e7fd0da08349397c7db17a562ad036aa7c8f4adfdb6"}, - {file = "pydantic-1.10.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73ef93e5e1d3c8e83f1ff2e7fdd026d9e063c7e089394869a6e2985696693766"}, - {file = "pydantic-1.10.9-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:128d9453d92e6e81e881dd7e2484e08d8b164da5507f62d06ceecf84bf2e21d3"}, - {file = "pydantic-1.10.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ad428e92ab68798d9326bb3e5515bc927444a3d71a93b4a2ca02a8a5d795c572"}, - {file = "pydantic-1.10.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fab81a92f42d6d525dd47ced310b0c3e10c416bbfae5d59523e63ea22f82b31e"}, - {file = "pydantic-1.10.9-cp38-cp38-win_amd64.whl", hash = "sha256:963671eda0b6ba6926d8fc759e3e10335e1dc1b71ff2a43ed2efd6996634dafb"}, - {file = "pydantic-1.10.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:970b1bdc6243ef663ba5c7e36ac9ab1f2bfecb8ad297c9824b542d41a750b298"}, - {file = "pydantic-1.10.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7e1d5290044f620f80cf1c969c542a5468f3656de47b41aa78100c5baa2b8276"}, - {file = "pydantic-1.10.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83fcff3c7df7adff880622a98022626f4f6dbce6639a88a15a3ce0f96466cb60"}, - {file = "pydantic-1.10.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0da48717dc9495d3a8f215e0d012599db6b8092db02acac5e0d58a65248ec5bc"}, - {file = "pydantic-1.10.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0a2aabdc73c2a5960e87c3ffebca6ccde88665616d1fd6d3db3178ef427b267a"}, - {file = "pydantic-1.10.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9863b9420d99dfa9c064042304868e8ba08e89081428a1c471858aa2af6f57c4"}, - {file = "pydantic-1.10.9-cp39-cp39-win_amd64.whl", hash = "sha256:e7c9900b43ac14110efa977be3da28931ffc74c27e96ee89fbcaaf0b0fe338e1"}, - {file = "pydantic-1.10.9-py3-none-any.whl", hash = "sha256:6cafde02f6699ce4ff643417d1a9223716ec25e228ddc3b436fe7e2d25a1f305"}, - {file = "pydantic-1.10.9.tar.gz", hash = "sha256:95c70da2cd3b6ddf3b9645ecaa8d98f3d80c606624b6d245558d202cd23ea3be"}, + {file = "pydantic-2.3.0-py3-none-any.whl", hash = "sha256:45b5e446c6dfaad9444819a293b921a40e1db1aa61ea08aede0522529ce90e81"}, + {file = "pydantic-2.3.0.tar.gz", hash = "sha256:1607cc106602284cd4a00882986570472f193fde9cb1259bceeaedb26aa79a6d"}, ] [package.dependencies] -typing-extensions = ">=4.2.0" +annotated-types = ">=0.4.0" +pydantic-core = "2.6.3" +typing-extensions = ">=4.6.1" [package.extras] -dotenv = ["python-dotenv (>=0.10.4)"] -email = ["email-validator (>=1.0.3)"] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.6.3" +description = "" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic_core-2.6.3-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:1a0ddaa723c48af27d19f27f1c73bdc615c73686d763388c8683fe34ae777bad"}, + {file = "pydantic_core-2.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5cfde4fab34dd1e3a3f7f3db38182ab6c95e4ea91cf322242ee0be5c2f7e3d2f"}, + {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5493a7027bfc6b108e17c3383959485087d5942e87eb62bbac69829eae9bc1f7"}, + {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84e87c16f582f5c753b7f39a71bd6647255512191be2d2dbf49458c4ef024588"}, + {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:522a9c4a4d1924facce7270c84b5134c5cabcb01513213662a2e89cf28c1d309"}, + {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aaafc776e5edc72b3cad1ccedb5fd869cc5c9a591f1213aa9eba31a781be9ac1"}, + {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a750a83b2728299ca12e003d73d1264ad0440f60f4fc9cee54acc489249b728"}, + {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e8b374ef41ad5c461efb7a140ce4730661aadf85958b5c6a3e9cf4e040ff4bb"}, + {file = "pydantic_core-2.6.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b594b64e8568cf09ee5c9501ede37066b9fc41d83d58f55b9952e32141256acd"}, + {file = "pydantic_core-2.6.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2a20c533cb80466c1d42a43a4521669ccad7cf2967830ac62c2c2f9cece63e7e"}, + {file = "pydantic_core-2.6.3-cp310-none-win32.whl", hash = "sha256:04fe5c0a43dec39aedba0ec9579001061d4653a9b53a1366b113aca4a3c05ca7"}, + {file = "pydantic_core-2.6.3-cp310-none-win_amd64.whl", hash = "sha256:6bf7d610ac8f0065a286002a23bcce241ea8248c71988bda538edcc90e0c39ad"}, + {file = "pydantic_core-2.6.3-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:6bcc1ad776fffe25ea5c187a028991c031a00ff92d012ca1cc4714087e575973"}, + {file = "pydantic_core-2.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:df14f6332834444b4a37685810216cc8fe1fe91f447332cd56294c984ecbff1c"}, + {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0b7486d85293f7f0bbc39b34e1d8aa26210b450bbd3d245ec3d732864009819"}, + {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a892b5b1871b301ce20d40b037ffbe33d1407a39639c2b05356acfef5536d26a"}, + {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:883daa467865e5766931e07eb20f3e8152324f0adf52658f4d302242c12e2c32"}, + {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4eb77df2964b64ba190eee00b2312a1fd7a862af8918ec70fc2d6308f76ac64"}, + {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ce8c84051fa292a5dc54018a40e2a1926fd17980a9422c973e3ebea017aa8da"}, + {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22134a4453bd59b7d1e895c455fe277af9d9d9fbbcb9dc3f4a97b8693e7e2c9b"}, + {file = "pydantic_core-2.6.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:02e1c385095efbd997311d85c6021d32369675c09bcbfff3b69d84e59dc103f6"}, + {file = "pydantic_core-2.6.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d79f1f2f7ebdb9b741296b69049ff44aedd95976bfee38eb4848820628a99b50"}, + {file = "pydantic_core-2.6.3-cp311-none-win32.whl", hash = "sha256:430ddd965ffd068dd70ef4e4d74f2c489c3a313adc28e829dd7262cc0d2dd1e8"}, + {file = "pydantic_core-2.6.3-cp311-none-win_amd64.whl", hash = "sha256:84f8bb34fe76c68c9d96b77c60cef093f5e660ef8e43a6cbfcd991017d375950"}, + {file = "pydantic_core-2.6.3-cp311-none-win_arm64.whl", hash = "sha256:5a2a3c9ef904dcdadb550eedf3291ec3f229431b0084666e2c2aa8ff99a103a2"}, + {file = "pydantic_core-2.6.3-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:8421cf496e746cf8d6b677502ed9a0d1e4e956586cd8b221e1312e0841c002d5"}, + {file = "pydantic_core-2.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bb128c30cf1df0ab78166ded1ecf876620fb9aac84d2413e8ea1594b588c735d"}, + {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37a822f630712817b6ecc09ccc378192ef5ff12e2c9bae97eb5968a6cdf3b862"}, + {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:240a015102a0c0cc8114f1cba6444499a8a4d0333e178bc504a5c2196defd456"}, + {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f90e5e3afb11268628c89f378f7a1ea3f2fe502a28af4192e30a6cdea1e7d5e"}, + {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:340e96c08de1069f3d022a85c2a8c63529fd88709468373b418f4cf2c949fb0e"}, + {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1480fa4682e8202b560dcdc9eeec1005f62a15742b813c88cdc01d44e85308e5"}, + {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f14546403c2a1d11a130b537dda28f07eb6c1805a43dae4617448074fd49c282"}, + {file = "pydantic_core-2.6.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a87c54e72aa2ef30189dc74427421e074ab4561cf2bf314589f6af5b37f45e6d"}, + {file = "pydantic_core-2.6.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f93255b3e4d64785554e544c1c76cd32f4a354fa79e2eeca5d16ac2e7fdd57aa"}, + {file = "pydantic_core-2.6.3-cp312-none-win32.whl", hash = "sha256:f70dc00a91311a1aea124e5f64569ea44c011b58433981313202c46bccbec0e1"}, + {file = "pydantic_core-2.6.3-cp312-none-win_amd64.whl", hash = "sha256:23470a23614c701b37252618e7851e595060a96a23016f9a084f3f92f5ed5881"}, + {file = "pydantic_core-2.6.3-cp312-none-win_arm64.whl", hash = "sha256:1ac1750df1b4339b543531ce793b8fd5c16660a95d13aecaab26b44ce11775e9"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:a53e3195f134bde03620d87a7e2b2f2046e0e5a8195e66d0f244d6d5b2f6d31b"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:f2969e8f72c6236c51f91fbb79c33821d12a811e2a94b7aa59c65f8dbdfad34a"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:672174480a85386dd2e681cadd7d951471ad0bb028ed744c895f11f9d51b9ebe"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:002d0ea50e17ed982c2d65b480bd975fc41086a5a2f9c924ef8fc54419d1dea3"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ccc13afee44b9006a73d2046068d4df96dc5b333bf3509d9a06d1b42db6d8bf"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:439a0de139556745ae53f9cc9668c6c2053444af940d3ef3ecad95b079bc9987"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d63b7545d489422d417a0cae6f9898618669608750fc5e62156957e609e728a5"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b44c42edc07a50a081672e25dfe6022554b47f91e793066a7b601ca290f71e42"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1c721bfc575d57305dd922e6a40a8fe3f762905851d694245807a351ad255c58"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5e4a2cf8c4543f37f5dc881de6c190de08096c53986381daebb56a355be5dfe6"}, + {file = "pydantic_core-2.6.3-cp37-none-win32.whl", hash = "sha256:d9b4916b21931b08096efed090327f8fe78e09ae8f5ad44e07f5c72a7eedb51b"}, + {file = "pydantic_core-2.6.3-cp37-none-win_amd64.whl", hash = "sha256:a8acc9dedd304da161eb071cc7ff1326aa5b66aadec9622b2574ad3ffe225525"}, + {file = "pydantic_core-2.6.3-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:5e9c068f36b9f396399d43bfb6defd4cc99c36215f6ff33ac8b9c14ba15bdf6b"}, + {file = "pydantic_core-2.6.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e61eae9b31799c32c5f9b7be906be3380e699e74b2db26c227c50a5fc7988698"}, + {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85463560c67fc65cd86153a4975d0b720b6d7725cf7ee0b2d291288433fc21b"}, + {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9616567800bdc83ce136e5847d41008a1d602213d024207b0ff6cab6753fe645"}, + {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e9b65a55bbabda7fccd3500192a79f6e474d8d36e78d1685496aad5f9dbd92c"}, + {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f468d520f47807d1eb5d27648393519655eadc578d5dd862d06873cce04c4d1b"}, + {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9680dd23055dd874173a3a63a44e7f5a13885a4cfd7e84814be71be24fba83db"}, + {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a718d56c4d55efcfc63f680f207c9f19c8376e5a8a67773535e6f7e80e93170"}, + {file = "pydantic_core-2.6.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8ecbac050856eb6c3046dea655b39216597e373aa8e50e134c0e202f9c47efec"}, + {file = "pydantic_core-2.6.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:788be9844a6e5c4612b74512a76b2153f1877cd845410d756841f6c3420230eb"}, + {file = "pydantic_core-2.6.3-cp38-none-win32.whl", hash = "sha256:07a1aec07333bf5adebd8264047d3dc518563d92aca6f2f5b36f505132399efc"}, + {file = "pydantic_core-2.6.3-cp38-none-win_amd64.whl", hash = "sha256:621afe25cc2b3c4ba05fff53525156d5100eb35c6e5a7cf31d66cc9e1963e378"}, + {file = "pydantic_core-2.6.3-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:813aab5bfb19c98ae370952b6f7190f1e28e565909bfc219a0909db168783465"}, + {file = "pydantic_core-2.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:50555ba3cb58f9861b7a48c493636b996a617db1a72c18da4d7f16d7b1b9952b"}, + {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19e20f8baedd7d987bd3f8005c146e6bcbda7cdeefc36fad50c66adb2dd2da48"}, + {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b0a5d7edb76c1c57b95df719af703e796fc8e796447a1da939f97bfa8a918d60"}, + {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f06e21ad0b504658a3a9edd3d8530e8cea5723f6ea5d280e8db8efc625b47e49"}, + {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea053cefa008fda40f92aab937fb9f183cf8752e41dbc7bc68917884454c6362"}, + {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:171a4718860790f66d6c2eda1d95dd1edf64f864d2e9f9115840840cf5b5713f"}, + {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ed7ceca6aba5331ece96c0e328cd52f0dcf942b8895a1ed2642de50800b79d3"}, + {file = "pydantic_core-2.6.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:acafc4368b289a9f291e204d2c4c75908557d4f36bd3ae937914d4529bf62a76"}, + {file = "pydantic_core-2.6.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1aa712ba150d5105814e53cb141412217146fedc22621e9acff9236d77d2a5ef"}, + {file = "pydantic_core-2.6.3-cp39-none-win32.whl", hash = "sha256:44b4f937b992394a2e81a5c5ce716f3dcc1237281e81b80c748b2da6dd5cf29a"}, + {file = "pydantic_core-2.6.3-cp39-none-win_amd64.whl", hash = "sha256:9b33bf9658cb29ac1a517c11e865112316d09687d767d7a0e4a63d5c640d1b17"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:d7050899026e708fb185e174c63ebc2c4ee7a0c17b0a96ebc50e1f76a231c057"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:99faba727727b2e59129c59542284efebbddade4f0ae6a29c8b8d3e1f437beb7"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fa159b902d22b283b680ef52b532b29554ea2a7fc39bf354064751369e9dbd7"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:046af9cfb5384f3684eeb3f58a48698ddab8dd870b4b3f67f825353a14441418"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:930bfe73e665ebce3f0da2c6d64455098aaa67e1a00323c74dc752627879fc67"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:85cc4d105747d2aa3c5cf3e37dac50141bff779545ba59a095f4a96b0a460e70"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b25afe9d5c4f60dcbbe2b277a79be114e2e65a16598db8abee2a2dcde24f162b"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e49ce7dc9f925e1fb010fc3d555250139df61fa6e5a0a95ce356329602c11ea9"}, + {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:2dd50d6a1aef0426a1d0199190c6c43ec89812b1f409e7fe44cb0fbf6dfa733c"}, + {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6595b0d8c8711e8e1dc389d52648b923b809f68ac1c6f0baa525c6440aa0daa"}, + {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ef724a059396751aef71e847178d66ad7fc3fc969a1a40c29f5aac1aa5f8784"}, + {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3c8945a105f1589ce8a693753b908815e0748f6279959a4530f6742e1994dcb6"}, + {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c8c6660089a25d45333cb9db56bb9e347241a6d7509838dbbd1931d0e19dbc7f"}, + {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:692b4ff5c4e828a38716cfa92667661a39886e71136c97b7dac26edef18767f7"}, + {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:f1a5d8f18877474c80b7711d870db0eeef9442691fcdb00adabfc97e183ee0b0"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:3796a6152c545339d3b1652183e786df648ecdf7c4f9347e1d30e6750907f5bb"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:b962700962f6e7a6bd77e5f37320cabac24b4c0f76afeac05e9f93cf0c620014"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56ea80269077003eaa59723bac1d8bacd2cd15ae30456f2890811efc1e3d4413"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75c0ebbebae71ed1e385f7dfd9b74c1cff09fed24a6df43d326dd7f12339ec34"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:252851b38bad3bfda47b104ffd077d4f9604a10cb06fe09d020016a25107bf98"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6656a0ae383d8cd7cc94e91de4e526407b3726049ce8d7939049cbfa426518c8"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d9140ded382a5b04a1c030b593ed9bf3088243a0a8b7fa9f071a5736498c5483"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d38bbcef58220f9c81e42c255ef0bf99735d8f11edef69ab0b499da77105158a"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:c9d469204abcca28926cbc28ce98f28e50e488767b084fb3fbdf21af11d3de26"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:48c1ed8b02ffea4d5c9c220eda27af02b8149fe58526359b3c07eb391cb353a2"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b2b1bfed698fa410ab81982f681f5b1996d3d994ae8073286515ac4d165c2e7"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf9d42a71a4d7a7c1f14f629e5c30eac451a6fc81827d2beefd57d014c006c4a"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4292ca56751aebbe63a84bbfc3b5717abb09b14d4b4442cc43fd7c49a1529efd"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:7dc2ce039c7290b4ef64334ec7e6ca6494de6eecc81e21cb4f73b9b39991408c"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:615a31b1629e12445c0e9fc8339b41aaa6cc60bd53bf802d5fe3d2c0cda2ae8d"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1fa1f6312fb84e8c281f32b39affe81984ccd484da6e9d65b3d18c202c666149"}, + {file = "pydantic_core-2.6.3.tar.gz", hash = "sha256:1508f37ba9e3ddc0189e6ff4e2228bd2d3c3a4641cbe8c07177162f76ed696c7"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pygments" -version = "2.15.1" +version = "2.16.1" description = "Pygments is a syntax highlighting package written in Python." optional = true python-versions = ">=3.7" files = [ - {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"}, - {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"}, + {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, + {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, ] [package.extras] @@ -794,13 +899,13 @@ plugins = ["importlib-metadata"] [[package]] name = "pyproject-api" -version = "1.5.2" +version = "1.5.4" description = "API to interact with the python pyproject.toml based projects" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pyproject_api-1.5.2-py3-none-any.whl", hash = "sha256:9cffcbfb64190f207444d7579d315f3278f2c04ba46d685fad93197b5326d348"}, - {file = "pyproject_api-1.5.2.tar.gz", hash = "sha256:999f58fa3c92b23ebd31a6bad5d1f87d456744d75e05391be7f5c729015d3d91"}, + {file = "pyproject_api-1.5.4-py3-none-any.whl", hash = "sha256:ca462d457880340ceada078678a296ac500061cef77a040e1143004470ab0046"}, + {file = "pyproject_api-1.5.4.tar.gz", hash = "sha256:8d41f3f0c04f0f6a830c27b1c425fa66699715ae06d8a054a1c5eeaaf8bfb145"}, ] [package.dependencies] @@ -808,18 +913,18 @@ packaging = ">=23.1" tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} [package.extras] -docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -testing = ["covdefaults (>=2.3)", "importlib-metadata (>=6.6)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "setuptools (>=67.8)", "wheel (>=0.40)"] +docs = ["furo (>=2023.7.26)", "sphinx (<7.2)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "setuptools (>=68)", "wheel (>=0.41.1)"] [[package]] name = "pytest" -version = "7.3.2" +version = "7.4.0" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.3.2-py3-none-any.whl", hash = "sha256:cdcbd012c9312258922f8cd3f1b62a6580fdced17db6014896053d47cddf9295"}, - {file = "pytest-7.3.2.tar.gz", hash = "sha256:ee990a3cc55ba808b80795a79944756f315c67c12b56abd3ac993a7b8c17030b"}, + {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, + {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, ] [package.dependencies] @@ -835,13 +940,13 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no [[package]] name = "pytest-asyncio" -version = "0.21.0" +version = "0.21.1" description = "Pytest support for asyncio" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-asyncio-0.21.0.tar.gz", hash = "sha256:2b38a496aef56f56b0e87557ec313e11e1ab9276fc3863f6a7be0f1d0e415e1b"}, - {file = "pytest_asyncio-0.21.0-py3-none-any.whl", hash = "sha256:f2b3366b7cd501a4056858bd39349d5af19742aed2d81660b7998b6341c7eb9c"}, + {file = "pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d"}, + {file = "pytest_asyncio-0.21.1-py3-none-any.whl", hash = "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b"}, ] [package.dependencies] @@ -988,18 +1093,18 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "setuptools" -version = "67.8.0" +version = "68.1.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "setuptools-67.8.0-py3-none-any.whl", hash = "sha256:5df61bf30bb10c6f756eb19e7c9f3b473051f48db77fddbe06ff2ca307df9a6f"}, - {file = "setuptools-67.8.0.tar.gz", hash = "sha256:62642358adc77ffa87233bc4d2354c4b2682d214048f500964dbe760ccedf102"}, + {file = "setuptools-68.1.2-py3-none-any.whl", hash = "sha256:3d8083eed2d13afc9426f227b24fd1659489ec107c0e86cec2ffdde5c92e790b"}, + {file = "setuptools-68.1.2.tar.gz", hash = "sha256:3d4dfa6d95f1b101d695a6160a7626e15583af71a5f52176efa5d39a054d475d"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5,<=7.1.2)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] @@ -1228,51 +1333,51 @@ files = [ [[package]] name = "tox" -version = "4.6.2" +version = "4.10.0" description = "tox is a generic virtualenv management and test command line tool" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "tox-4.6.2-py3-none-any.whl", hash = "sha256:52241851a7b0cd7de07d6ef067a13b092d2a4f82fe9048efb2444aed1708d713"}, - {file = "tox-4.6.2.tar.gz", hash = "sha256:58c7c2acce2f3d44cd1b359349557162336288ecf19ef53ccda89c9cee0ad9c4"}, + {file = "tox-4.10.0-py3-none-any.whl", hash = "sha256:e4a1b1438955a6da548d69a52350054350cf6a126658c20943261c48ed6d4c92"}, + {file = "tox-4.10.0.tar.gz", hash = "sha256:e041b2165375be690aca0ec4d96360c6906451380520e4665bf274f66112be35"}, ] [package.dependencies] cachetools = ">=5.3.1" -chardet = ">=5.1" +chardet = ">=5.2" colorama = ">=0.4.6" filelock = ">=3.12.2" packaging = ">=23.1" -platformdirs = ">=3.5.3" -pluggy = ">=1" -pyproject-api = ">=1.5.2" +platformdirs = ">=3.10" +pluggy = ">=1.2" +pyproject-api = ">=1.5.3" tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} -virtualenv = ">=20.23.1" +virtualenv = ">=20.24.3" [package.extras] -docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.23.2,!=1.23.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.1.1)", "devpi-process (>=0.3.1)", "diff-cover (>=7.6)", "distlib (>=0.3.6)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.17.1)", "psutil (>=5.9.5)", "pytest (>=7.3.2)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-xdist (>=3.3.1)", "re-assert (>=1.1)", "time-machine (>=2.10)", "wheel (>=0.40)"] +docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.24)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.1.1)", "devpi-process (>=0.3.1)", "diff-cover (>=7.7)", "distlib (>=0.3.7)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.18)", "psutil (>=5.9.5)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-xdist (>=3.3.1)", "re-assert (>=1.1)", "time-machine (>=2.12)", "wheel (>=0.41.1)"] [[package]] name = "typing-extensions" -version = "4.6.3" +version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" optional = false python-versions = ">=3.7" files = [ - {file = "typing_extensions-4.6.3-py3-none-any.whl", hash = "sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26"}, - {file = "typing_extensions-4.6.3.tar.gz", hash = "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5"}, + {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, + {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, ] [[package]] name = "urllib3" -version = "2.0.3" +version = "2.0.4" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.7" files = [ - {file = "urllib3-2.0.3-py3-none-any.whl", hash = "sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1"}, - {file = "urllib3-2.0.3.tar.gz", hash = "sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825"}, + {file = "urllib3-2.0.4-py3-none-any.whl", hash = "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4"}, + {file = "urllib3-2.0.4.tar.gz", hash = "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11"}, ] [package.extras] @@ -1283,23 +1388,23 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.23.1" +version = "20.24.3" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.23.1-py3-none-any.whl", hash = "sha256:34da10f14fea9be20e0fd7f04aba9732f84e593dac291b757ce42e3368a39419"}, - {file = "virtualenv-20.23.1.tar.gz", hash = "sha256:8ff19a38c1021c742148edc4f81cb43d7f8c6816d2ede2ab72af5b84c749ade1"}, + {file = "virtualenv-20.24.3-py3-none-any.whl", hash = "sha256:95a6e9398b4967fbcb5fef2acec5efaf9aa4972049d9ae41f95e0972a683fd02"}, + {file = "virtualenv-20.24.3.tar.gz", hash = "sha256:e5c3b4ce817b0b328af041506a2a299418c98747c4b1e68cb7527e74ced23efc"}, ] [package.dependencies] -distlib = ">=0.3.6,<1" -filelock = ">=3.12,<4" -platformdirs = ">=3.5.1,<4" +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<4" [package.extras] docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezer (>=0.4.6)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.8)", "time-machine (>=2.9)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [[package]] name = "voluptuous" @@ -1341,18 +1446,18 @@ tests-strict = ["codecov (==2.0.15)", "pytest (==4.6.0)", "pytest (==4.6.0)", "p [[package]] name = "zipp" -version = "3.15.0" +version = "3.16.2" description = "Backport of pathlib-compatible object wrapper for zip files" optional = true -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, - {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, + {file = "zipp-3.16.2-py3-none-any.whl", hash = "sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0"}, + {file = "zipp-3.16.2.tar.gz", hash = "sha256:ebc15946aa78bd63458992fc81ec3b6f7b1e92d51c35e6de1c3804e73b799147"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] [extras] docs = ["docutils", "myst-parser", "sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput"] @@ -1361,4 +1466,4 @@ speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "44a2ba5f0bc7e12f018b34d58f3246a74a98c7c094e54e769998d6fbd6da1ee5" +content-hash = "888a000414d6140156c0f878af06470505ed6edaab936af8a607d396c6252bf9" diff --git a/pyproject.toml b/pyproject.toml index 193b5beee..f8adeeed4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ kasa = "kasa.cli:cli" python = "^3.8" anyio = "*" # see https://github.com/python-trio/asyncclick/issues/18 asyncclick = ">=8" -pydantic = "^1" +pydantic = ">=1" # speed ups orjson = { "version" = ">=3.9.1", optional = true } From 6055c29d74d5b2628e8e7d2ea52049a8250fc802 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Tue, 29 Aug 2023 14:04:28 +0100 Subject: [PATCH 162/892] Add support for alternative discovery protocol (20002/udp) (#488) This will broadcast the new discovery message on the new port and log any responses received as unsupported devices. --- kasa/cli.py | 49 ++++++++++++++++-- kasa/discover.py | 99 +++++++++++++++++++++++++++++++----- kasa/exceptions.py | 4 ++ kasa/tests/test_discovery.py | 92 ++++++++++++++++++++++++++++++--- 4 files changed, 219 insertions(+), 25 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 47f53d181..cc7824329 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -103,6 +103,7 @@ def _device_to_serializable(val: SmartDevice): "--port", envvar="KASA_PORT", required=False, + type=int, help="The port of the device to connect to.", ) @click.option( @@ -138,7 +139,17 @@ def _device_to_serializable(val: SmartDevice): ) @click.version_option(package_name="python-kasa") @click.pass_context -async def cli(ctx, host, port, alias, target, debug, type, json, discovery_timeout): +async def cli( + ctx, + host, + port, + alias, + target, + debug, + type, + json, + discovery_timeout, +): """A tool for controlling TP-Link smart home devices.""" # noqa # no need to perform any checks if we are just displaying the help if sys.argv[-1] == "--help": @@ -238,13 +249,29 @@ async def join(dev: SmartDevice, ssid, password, keytype): @cli.command() @click.option("--timeout", default=3, required=False) +@click.option( + "--show-unsupported", + envvar="KASA_SHOW_UNSUPPORTED", + required=False, + default=False, + is_flag=True, + help="Print out discovered unsupported devices", +) @click.pass_context -async def discover(ctx, timeout): +async def discover(ctx, timeout, show_unsupported): """Discover devices in the network.""" target = ctx.parent.params["target"] - echo(f"Discovering devices on {target} for {timeout} seconds") sem = asyncio.Semaphore() discovered = dict() + unsupported = [] + + async def print_unsupported(data: Dict): + unsupported.append(data) + if show_unsupported: + echo(f"Found unsupported device (tapo/unknown encryption): {data}") + echo() + + echo(f"Discovering devices on {target} for {timeout} seconds") async def print_discovered(dev: SmartDevice): await dev.update() @@ -255,9 +282,23 @@ async def print_discovered(dev: SmartDevice): echo() await Discover.discover( - target=target, timeout=timeout, on_discovered=print_discovered + target=target, + timeout=timeout, + on_discovered=print_discovered, + on_unsupported=print_unsupported, ) + echo(f"Found {len(discovered)} devices") + if unsupported: + echo( + f"Found {len(unsupported)} unsupported devices" + + ( + "" + if show_unsupported + else ", to show them use: kasa discover --show-unsupported" + ) + ) + return discovered diff --git a/kasa/discover.py b/kasa/discover.py index f7b5fbbfb..5a78d1936 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -1,9 +1,15 @@ """Discovery module for TP-Link Smart Home devices.""" import asyncio +import binascii import logging import socket from typing import Awaitable, Callable, Dict, Optional, Type, cast +# 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 kasa.exceptions import UnsupportedDeviceException from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads from kasa.protocol import TPLinkSmartHomeProtocol @@ -36,13 +42,22 @@ def __init__( target: str = "255.255.255.255", discovery_packets: int = 3, interface: Optional[str] = None, + on_unsupported: Optional[Callable[[Dict], Awaitable[None]]] = None, + port: Optional[int] = None, + discovered_event: Optional[asyncio.Event] = None, ): self.transport = None self.discovery_packets = discovery_packets self.interface = interface self.on_discovered = on_discovered - self.target = (target, Discover.DISCOVERY_PORT) + self.discovery_port = port or Discover.DISCOVERY_PORT + self.target = (target, self.discovery_port) + self.target_2 = (target, Discover.DISCOVERY_PORT_2) self.discovered_devices = {} + self.unsupported_devices: Dict = {} + self.invalid_device_exceptions: Dict = {} + self.on_unsupported = on_unsupported + self.discovered_event = discovered_event def connection_made(self, transport) -> None: """Set socket options for broadcasting.""" @@ -69,23 +84,48 @@ def do_discover(self) -> None: encrypted_req = TPLinkSmartHomeProtocol.encrypt(req) for i in range(self.discovery_packets): self.transport.sendto(encrypted_req[4:], self.target) # type: ignore + self.transport.sendto(Discover.DISCOVERY_QUERY_2, self.target_2) # type: ignore def datagram_received(self, data, addr) -> None: """Handle discovery responses.""" ip, port = addr - if ip in self.discovered_devices: + if ( + ip in self.discovered_devices + or ip in self.unsupported_devices + or ip in self.invalid_device_exceptions + ): return - info = json_loads(TPLinkSmartHomeProtocol.decrypt(data)) - _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) + if port == self.discovery_port: + info = json_loads(TPLinkSmartHomeProtocol.decrypt(data)) + _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) + + elif port == Discover.DISCOVERY_PORT_2: + info = json_loads(data[16:]) + self.unsupported_devices[ip] = info + if self.on_unsupported is not None: + asyncio.ensure_future(self.on_unsupported(info)) + _LOGGER.debug("[DISCOVERY] Unsupported device found at %s << %s", ip, info) + if self.discovered_event is not None and "255" not in self.target[0].split( + "." + ): + self.discovered_event.set() + return try: device_class = Discover._get_device_class(info) except SmartDeviceException as ex: - _LOGGER.debug("Unable to find device type from %s: %s", info, ex) + _LOGGER.debug( + "[DISCOVERY] Unable to find device type from %s: %s", info, ex + ) + self.invalid_device_exceptions[ip] = ex + if self.discovered_event is not None and "255" not in self.target[0].split( + "." + ): + self.discovered_event.set() return - device = device_class(ip) + device = device_class(ip, port=port) device.update_from_discover_info(info) self.discovered_devices[ip] = device @@ -93,6 +133,9 @@ def datagram_received(self, data, addr) -> None: if self.on_discovered is not None: asyncio.ensure_future(self.on_discovered(device)) + if self.discovered_event is not None and "255" not in self.target[0].split("."): + self.discovered_event.set() + def error_received(self, ex): """Handle asyncio.Protocol errors.""" _LOGGER.error("Got error: %s", ex) @@ -142,6 +185,9 @@ class Discover: "system": {"get_sysinfo": None}, } + DISCOVERY_PORT_2 = 20002 + DISCOVERY_QUERY_2 = binascii.unhexlify("020000010000000000000000463cb5d3") + @staticmethod async def discover( *, @@ -150,6 +196,7 @@ async def discover( timeout=5, discovery_packets=3, interface=None, + on_unsupported=None, ) -> DeviceDict: """Discover supported devices. @@ -177,6 +224,7 @@ async def discover( on_discovered=on_discovered, discovery_packets=discovery_packets, interface=interface, + on_unsupported=on_unsupported, ), local_addr=("0.0.0.0", 0), ) @@ -193,22 +241,47 @@ async def discover( return protocol.discovered_devices @staticmethod - async def discover_single(host: str, *, port: Optional[int] = None) -> SmartDevice: + async def discover_single( + host: str, *, port: Optional[int] = None, timeout=5 + ) -> SmartDevice: """Discover a single device by the given IP address. :param host: Hostname of device to query :rtype: SmartDevice :return: Object for querying/controlling found device. """ - protocol = TPLinkSmartHomeProtocol(host, port=port) + loop = asyncio.get_event_loop() + event = asyncio.Event() + transport, protocol = await loop.create_datagram_endpoint( + lambda: _DiscoverProtocol(target=host, port=port, discovered_event=event), + local_addr=("0.0.0.0", 0), + ) + protocol = cast(_DiscoverProtocol, protocol) - info = await protocol.query(Discover.DISCOVERY_QUERY) + try: + _LOGGER.debug("Waiting a total of %s seconds for responses...", timeout) - device_class = Discover._get_device_class(info) - dev = device_class(host, port=port) - await dev.update() + async with asyncio_timeout(timeout): + await event.wait() + except asyncio.TimeoutError: + raise SmartDeviceException( + f"Timed out getting discovery response for {host}" + ) + finally: + transport.close() - return dev + if host in protocol.discovered_devices: + dev = protocol.discovered_devices[host] + await dev.update() + return dev + elif host in protocol.unsupported_devices: + raise UnsupportedDeviceException( + f"Unsupported device {host}: {protocol.unsupported_devices[host]}" + ) + elif host in protocol.invalid_device_exceptions: + raise protocol.invalid_device_exceptions[host] + else: + raise SmartDeviceException(f"Unable to get discovery response for {host}") @staticmethod def _get_device_class(info: dict) -> Type[SmartDevice]: diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 90d36c9a0..0d2ff8265 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -3,3 +3,7 @@ class SmartDeviceException(Exception): """Base exception for device errors.""" + + +class UnsupportedDeviceException(SmartDeviceException): + """Exception for trying to connect to unsupported devices.""" diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index bbdaf8a84..41578a2ce 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -1,10 +1,12 @@ # type: ignore +import re import sys import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import DeviceType, Discover, SmartDevice, SmartDeviceException, protocol -from kasa.discover import _DiscoverProtocol +from kasa.discover import _DiscoverProtocol, json_dumps +from kasa.exceptions import UnsupportedDeviceException from .conftest import bulb, dimmer, lightstrip, plug, strip @@ -55,11 +57,73 @@ async def test_type_unknown(): @pytest.mark.parametrize("custom_port", [123, None]) async def test_discover_single(discovery_data: dict, mocker, custom_port): """Make sure that discover_single returns an initialized SmartDevice instance.""" + host = "127.0.0.1" + + def mock_discover(self): + self.datagram_received( + protocol.TPLinkSmartHomeProtocol.encrypt(json_dumps(discovery_data))[4:], + (host, custom_port or 9999), + ) + + mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) - x = await Discover.discover_single("127.0.0.1", port=custom_port) + + x = await Discover.discover_single(host, port=custom_port) assert issubclass(x.__class__, SmartDevice) assert x._sys_info is not None - assert x.port == custom_port + assert x.port == custom_port or 9999 + + +UNSUPPORTED = { + "result": { + "device_id": "xx", + "owner": "xx", + "device_type": "SMART.TAPOPLUG", + "device_model": "P110(EU)", + "ip": "127.0.0.1", + "mac": "48-22xxx", + "is_support_iot_cloud": True, + "obd_src": "tplink", + "factory_default": False, + "mgt_encrypt_schm": { + "is_support_https": False, + "encrypt_type": "AES", + "http_port": 80, + "lv": 2, + }, + }, + "error_code": 0, +} + + +async def test_discover_single_unsupported(mocker): + """Make sure that discover_single handles unsupported devices correctly.""" + host = "127.0.0.1" + + def mock_discover(self): + if discovery_data: + data = ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(discovery_data).encode() + ) + self.datagram_received(data, (host, 20002)) + + mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) + + # Test with a valid unsupported response + discovery_data = UNSUPPORTED + with pytest.raises( + UnsupportedDeviceException, + match=f"Unsupported device {host}: {re.escape(str(UNSUPPORTED))}", + ): + await Discover.discover_single(host) + + # Test with no response + discovery_data = None + with pytest.raises( + SmartDeviceException, match=f"Timed out getting discovery response for {host}" + ): + await Discover.discover_single(host, timeout=0.001) INVALIDS = [ @@ -75,9 +139,17 @@ async def test_discover_single(discovery_data: dict, mocker, custom_port): @pytest.mark.parametrize("msg, data", INVALIDS) async def test_discover_invalid_info(msg, data, mocker): """Make sure that invalid discovery information raises an exception.""" - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=data) + host = "127.0.0.1" + + def mock_discover(self): + self.datagram_received( + protocol.TPLinkSmartHomeProtocol.encrypt(json_dumps(data))[4:], (host, 9999) + ) + + mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) + with pytest.raises(SmartDeviceException, match=msg): - await Discover.discover_single("127.0.0.1") + await Discover.discover_single(host) async def test_discover_send(mocker): @@ -87,7 +159,7 @@ async def test_discover_send(mocker): assert proto.target == ("255.255.255.255", 9999) transport = mocker.patch.object(proto, "transport") proto.do_discover() - assert transport.sendto.call_count == proto.discovery_packets + assert transport.sendto.call_count == proto.discovery_packets * 2 async def test_discover_datagram_received(mocker, discovery_data): @@ -98,10 +170,14 @@ async def test_discover_datagram_received(mocker, discovery_data): mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "decrypt") addr = "127.0.0.1" - proto.datagram_received("", (addr, 1234)) + proto.datagram_received("", (addr, 9999)) + addr2 = "127.0.0.2" + proto.datagram_received("", (addr2, 20002)) # Check that device in discovered_devices is initialized correctly assert len(proto.discovered_devices) == 1 + # Check that unsupported device is 1 + assert len(proto.unsupported_devices) == 1 dev = proto.discovered_devices[addr] assert issubclass(dev.__class__, SmartDevice) assert dev.host == addr @@ -115,5 +191,5 @@ async def test_discover_invalid_responses(msg, data, mocker): mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "encrypt") mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "decrypt") - proto.datagram_received(data, ("127.0.0.1", 1234)) + proto.datagram_received(data, ("127.0.0.1", 9999)) assert len(proto.discovered_devices) == 0 From f7c22f0a0c63c0ce25c3465e7fe748f2685b3c25 Mon Sep 17 00:00:00 2001 From: Chip Schweiss Date: Wed, 13 Sep 2023 06:58:19 -0500 Subject: [PATCH 163/892] Mark KS2{20}M as partially supported (#508) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 00063dc12..97525a283 100644 --- a/README.md +++ b/README.md @@ -155,8 +155,8 @@ If your device is unlisted but working, please open a pull request to update the * HS200 * HS210 * HS220 -* KS200M -* KS220M +* KS200M (partial support, no motion, no daylight detection) +* KS220M (partial support, no motion, no daylight detection) * KS230 ### Bulbs From 7bb4a456a22c427789972f7cdc47f93ff366d028 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Wed, 13 Sep 2023 14:46:38 +0100 Subject: [PATCH 164/892] Add plumbing for passing credentials to devices (#507) * Add plumbing for passing credentials as far as discovery * Pass credentials to Smart devices * Rename authentication exception * Fix tests failure due to test_json_output leaving echo as nop * Fix test_credentials test * Do not print credentials, fix echo function bug and improve get type parameter * Add device class constructor test * Add comment for echo handling and move assignment --- kasa/__init__.py | 10 +++- kasa/cli.py | 71 +++++++++++++++++++++------- kasa/credentials.py | 12 +++++ kasa/discover.py | 27 +++++++---- kasa/exceptions.py | 4 ++ kasa/smartbulb.py | 11 ++++- kasa/smartdevice.py | 10 +++- kasa/smartdimmer.py | 11 ++++- kasa/smartlightstrip.py | 11 ++++- kasa/smartplug.py | 11 ++++- kasa/smartstrip.py | 11 ++++- kasa/tests/test_cli.py | 84 +++++++++++++++++++++++++++++++++- kasa/tests/test_smartdevice.py | 26 ++++++++++- 13 files changed, 258 insertions(+), 41 deletions(-) create mode 100644 kasa/credentials.py diff --git a/kasa/__init__.py b/kasa/__init__.py index d8cb08258..4ccf6286b 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -13,9 +13,14 @@ """ from importlib.metadata import version +from kasa.credentials import Credentials from kasa.discover import Discover from kasa.emeterstatus import EmeterStatus -from kasa.exceptions import SmartDeviceException +from kasa.exceptions import ( + AuthenticationException, + SmartDeviceException, + UnsupportedDeviceException, +) from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.smartdevice import DeviceType, SmartDevice @@ -42,4 +47,7 @@ "SmartStrip", "SmartDimmer", "SmartLightStrip", + "AuthenticationException", + "UnsupportedDeviceException", + "Credentials", ] diff --git a/kasa/cli.py b/kasa/cli.py index cc7824329..f0c180c57 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -10,8 +10,19 @@ import asyncclick as click +from kasa import ( + Credentials, + Discover, + SmartBulb, + SmartDevice, + SmartDimmer, + SmartLightStrip, + SmartPlug, + SmartStrip, +) + try: - from rich import print as echo + from rich import print as _do_echo except ImportError: def _strip_rich_formatting(echo_func): @@ -25,18 +36,11 @@ def wrapper(message=None, *args, **kwargs): return wrapper - echo = _strip_rich_formatting(click.echo) - + _do_echo = _strip_rich_formatting(click.echo) -from kasa import ( - Discover, - SmartBulb, - SmartDevice, - SmartDimmer, - SmartLightStrip, - SmartPlug, - SmartStrip, -) +# echo is set to _do_echo so that it can be reset to _do_echo later after +# --json has set it to _nop_echo +echo = _do_echo TYPE_TO_CLASS = { "plug": SmartPlug, @@ -48,7 +52,6 @@ def wrapper(message=None, *args, **kwargs): click.anyio_backend = "asyncio" - pass_dev = click.make_pass_decorator(SmartDevice) @@ -137,6 +140,20 @@ def _device_to_serializable(val: SmartDevice): required=False, help="Timeout for discovery.", ) +@click.option( + "--username", + default=None, + required=False, + envvar="TPLINK_CLOUD_USERNAME", + help="Username/email address to authenticate to device.", +) +@click.option( + "--password", + default=None, + required=False, + envvar="TPLINK_CLOUD_PASSWORD", + help="Password to use to authenticate to device.", +) @click.version_option(package_name="python-kasa") @click.pass_context async def cli( @@ -149,6 +166,8 @@ async def cli( type, json, discovery_timeout, + username, + password, ): """A tool for controlling TP-Link smart home devices.""" # noqa # no need to perform any checks if we are just displaying the help @@ -158,13 +177,17 @@ async def cli( return # If JSON output is requested, disable echo + global echo if json: - global echo def _nop_echo(*args, **kwargs): pass echo = _nop_echo + else: + # Set back to default is required if running tests with CliRunner + global _do_echo + echo = _do_echo logging_config: Dict[str, Any] = { "level": logging.DEBUG if debug > 0 else logging.INFO @@ -195,15 +218,25 @@ def _nop_echo(*args, **kwargs): echo(f"No device with name {alias} found") return + if bool(password) != bool(username): + echo("Using authentication requires both --username and --password") + return + + credentials = Credentials(username=username, password=password) + if host is None: echo("No host name given, trying discovery..") return await ctx.invoke(discover, timeout=discovery_timeout) if type is not None: - dev = TYPE_TO_CLASS[type](host) + dev = TYPE_TO_CLASS[type](host, credentials=credentials) else: echo("No --type defined, discovering..") - dev = await Discover.discover_single(host, port=port) + dev = await Discover.discover_single( + host, + port=port, + credentials=credentials, + ) await dev.update() ctx.obj = dev @@ -261,6 +294,11 @@ async def join(dev: SmartDevice, ssid, password, keytype): async def discover(ctx, timeout, show_unsupported): """Discover devices in the network.""" target = ctx.parent.params["target"] + username = ctx.parent.params["username"] + password = ctx.parent.params["password"] + + credentials = Credentials(username, password) + sem = asyncio.Semaphore() discovered = dict() unsupported = [] @@ -286,6 +324,7 @@ async def print_discovered(dev: SmartDevice): timeout=timeout, on_discovered=print_discovered, on_unsupported=print_unsupported, + credentials=credentials, ) echo(f"Found {len(discovered)} devices") diff --git a/kasa/credentials.py b/kasa/credentials.py new file mode 100644 index 000000000..a56f5710d --- /dev/null +++ b/kasa/credentials.py @@ -0,0 +1,12 @@ +"""Credentials class for username / passwords.""" + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class Credentials: + """Credentials for authentication.""" + + username: Optional[str] = field(default=None, repr=False) + password: Optional[str] = field(default=None, repr=False) diff --git a/kasa/discover.py b/kasa/discover.py index 5a78d1936..f8e11a62b 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -9,6 +9,7 @@ # async_timeout can be replaced with asyncio.timeout from async_timeout import timeout as asyncio_timeout +from kasa.credentials import Credentials from kasa.exceptions import UnsupportedDeviceException from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads @@ -45,6 +46,7 @@ def __init__( on_unsupported: Optional[Callable[[Dict], Awaitable[None]]] = None, port: Optional[int] = None, discovered_event: Optional[asyncio.Event] = None, + credentials: Optional[Credentials] = None, ): self.transport = None self.discovery_packets = discovery_packets @@ -58,6 +60,7 @@ def __init__( self.invalid_device_exceptions: Dict = {} self.on_unsupported = on_unsupported self.discovered_event = discovered_event + self.credentials = credentials def connection_made(self, transport) -> None: """Set socket options for broadcasting.""" @@ -106,9 +109,7 @@ def datagram_received(self, data, addr) -> None: if self.on_unsupported is not None: asyncio.ensure_future(self.on_unsupported(info)) _LOGGER.debug("[DISCOVERY] Unsupported device found at %s << %s", ip, info) - if self.discovered_event is not None and "255" not in self.target[0].split( - "." - ): + if self.discovered_event is not None: self.discovered_event.set() return @@ -119,13 +120,11 @@ def datagram_received(self, data, addr) -> None: "[DISCOVERY] Unable to find device type from %s: %s", info, ex ) self.invalid_device_exceptions[ip] = ex - if self.discovered_event is not None and "255" not in self.target[0].split( - "." - ): + if self.discovered_event is not None: self.discovered_event.set() return - device = device_class(ip, port=port) + device = device_class(ip, port=port, credentials=self.credentials) device.update_from_discover_info(info) self.discovered_devices[ip] = device @@ -133,7 +132,7 @@ def datagram_received(self, data, addr) -> None: if self.on_discovered is not None: asyncio.ensure_future(self.on_discovered(device)) - if self.discovered_event is not None and "255" not in self.target[0].split("."): + if self.discovered_event is not None: self.discovered_event.set() def error_received(self, ex): @@ -197,6 +196,7 @@ async def discover( discovery_packets=3, interface=None, on_unsupported=None, + credentials=None, ) -> DeviceDict: """Discover supported devices. @@ -225,6 +225,7 @@ async def discover( discovery_packets=discovery_packets, interface=interface, on_unsupported=on_unsupported, + credentials=credentials, ), local_addr=("0.0.0.0", 0), ) @@ -242,7 +243,11 @@ async def discover( @staticmethod async def discover_single( - host: str, *, port: Optional[int] = None, timeout=5 + host: str, + *, + port: Optional[int] = None, + timeout=5, + credentials: Optional[Credentials] = None, ) -> SmartDevice: """Discover a single device by the given IP address. @@ -253,7 +258,9 @@ async def discover_single( loop = asyncio.get_event_loop() event = asyncio.Event() transport, protocol = await loop.create_datagram_endpoint( - lambda: _DiscoverProtocol(target=host, port=port, discovered_event=event), + lambda: _DiscoverProtocol( + target=host, port=port, discovered_event=event, credentials=credentials + ), local_addr=("0.0.0.0", 0), ) protocol = cast(_DiscoverProtocol, protocol) diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 0d2ff8265..35870d1f1 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -7,3 +7,7 @@ class SmartDeviceException(Exception): class UnsupportedDeviceException(SmartDeviceException): """Exception for trying to connect to unsupported devices.""" + + +class AuthenticationException(SmartDeviceException): + """Base exception for device authentication errors.""" diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index ad72701a5..2d2f28ca0 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -9,6 +9,7 @@ except ImportError: from pydantic import BaseModel, Field, root_validator +from .credentials import Credentials from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update @@ -202,8 +203,14 @@ class SmartBulb(SmartDevice): SET_LIGHT_METHOD = "transition_light_state" emeter_type = "smartlife.iot.common.emeter" - def __init__(self, host: str, *, port: Optional[int] = None) -> None: - super().__init__(host=host, port=port) + def __init__( + self, + host: str, + *, + port: Optional[int] = None, + credentials: Optional[Credentials] = None + ) -> None: + super().__init__(host=host, port=port, credentials=credentials) self._device_type = DeviceType.Bulb self.add_module("schedule", Schedule(self, "smartlife.iot.common.schedule")) self.add_module("usage", Usage(self, "smartlife.iot.common.schedule")) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index fd8d3768b..5c24c943e 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -20,6 +20,7 @@ from enum import Enum, auto from typing import Any, Dict, List, Optional, Set +from .credentials import Credentials from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException from .modules import Emeter, Module @@ -191,7 +192,13 @@ class SmartDevice: emeter_type = "emeter" - def __init__(self, host: str, *, port: Optional[int] = None) -> None: + def __init__( + self, + host: str, + *, + port: Optional[int] = None, + credentials: Optional[Credentials] = None, + ) -> None: """Create a new SmartDevice instance. :param str host: host name or ip address on which the device listens @@ -200,6 +207,7 @@ def __init__(self, host: str, *, port: Optional[int] = None) -> None: self.port = port self.protocol = TPLinkSmartHomeProtocol(host, port=port) + self.credentials = credentials _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) self._device_type = DeviceType.Unknown # TODO: typing Any is just as using Optional[Dict] would require separate checks in diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py index 247455e38..05fb75ac9 100644 --- a/kasa/smartdimmer.py +++ b/kasa/smartdimmer.py @@ -2,6 +2,7 @@ from enum import Enum from typing import Any, Dict, Optional +from kasa.credentials import Credentials from kasa.modules import AmbientLight, Motion from kasa.smartdevice import DeviceType, SmartDeviceException, requires_update from kasa.smartplug import SmartPlug @@ -62,8 +63,14 @@ class SmartDimmer(SmartPlug): DIMMER_SERVICE = "smartlife.iot.dimmer" - def __init__(self, host: str, *, port: Optional[int] = None) -> None: - super().__init__(host, port=port) + def __init__( + self, + host: str, + *, + port: Optional[int] = None, + credentials: Optional[Credentials] = None, + ) -> None: + super().__init__(host, port=port, credentials=credentials) self._device_type = DeviceType.Dimmer # TODO: need to be verified if it's okay to call these on HS220 w/o these # TODO: need to be figured out what's the best approach to detect support for these diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py index 6afe5d115..34e581155 100644 --- a/kasa/smartlightstrip.py +++ b/kasa/smartlightstrip.py @@ -1,6 +1,7 @@ """Module for light strips (KL430).""" from typing import Any, Dict, List, Optional +from .credentials import Credentials from .effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from .smartbulb import SmartBulb from .smartdevice import DeviceType, SmartDeviceException, requires_update @@ -41,8 +42,14 @@ class SmartLightStrip(SmartBulb): LIGHT_SERVICE = "smartlife.iot.lightStrip" SET_LIGHT_METHOD = "set_light_state" - def __init__(self, host: str, *, port: Optional[int] = None) -> None: - super().__init__(host, port=port) + def __init__( + self, + host: str, + *, + port: Optional[int] = None, + credentials: Optional[Credentials] = None, + ) -> None: + super().__init__(host, port=port, credentials=credentials) self._device_type = DeviceType.LightStrip @property # type: ignore diff --git a/kasa/smartplug.py b/kasa/smartplug.py index 94a5e3500..f3d635d9f 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -2,6 +2,7 @@ import logging from typing import Any, Dict, Optional +from kasa.credentials import Credentials from kasa.modules import Antitheft, Cloud, Schedule, Time, Usage from kasa.smartdevice import DeviceType, SmartDevice, requires_update @@ -37,8 +38,14 @@ class SmartPlug(SmartDevice): For more examples, see the :class:`SmartDevice` class. """ - def __init__(self, host: str, *, port: Optional[int] = None) -> None: - super().__init__(host, port=port) + def __init__( + self, + host: str, + *, + port: Optional[int] = None, + credentials: Optional[Credentials] = None + ) -> None: + super().__init__(host, port=port, credentials=credentials) self._device_type = DeviceType.Plug self.add_module("schedule", Schedule(self, "schedule")) self.add_module("usage", Usage(self, "schedule")) diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index a970925ba..479b0e569 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -14,6 +14,7 @@ ) from kasa.smartplug import SmartPlug +from .credentials import Credentials from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) @@ -79,8 +80,14 @@ class SmartStrip(SmartDevice): For more examples, see the :class:`SmartDevice` class. """ - def __init__(self, host: str, *, port: Optional[int] = None) -> None: - super().__init__(host=host, port=port) + def __init__( + self, + host: str, + *, + port: Optional[int] = None, + credentials: Optional[Credentials] = None, + ) -> None: + super().__init__(host=host, port=port, credentials=credentials) self.emeter_type = "emeter" self._device_type = DeviceType.Strip self.add_module("antitheft", Antitheft(self, "anti_theft")) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index f7e046197..4a47284b9 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -1,13 +1,26 @@ import json import sys +import asyncclick as click import pytest from asyncclick.testing import CliRunner -from kasa import SmartDevice -from kasa.cli import alias, brightness, cli, emeter, raw_command, state, sysinfo, toggle +from kasa import SmartDevice, TPLinkSmartHomeProtocol +from kasa.cli import ( + TYPE_TO_CLASS, + alias, + brightness, + cli, + emeter, + raw_command, + state, + sysinfo, + toggle, +) +from kasa.discover import Discover from .conftest import handle_turn_on, turn_on +from .newfakes import FakeTransportProtocol async def test_sysinfo(dev): @@ -121,3 +134,70 @@ async def test_json_output(dev: SmartDevice, mocker): res = await runner.invoke(cli, ["--json", "state"], obj=dev) assert res.exit_code == 0 assert json.loads(res.output) == dev.internal_state + + +async def test_credentials(discovery_data: dict, mocker): + """Test credentials are passed correctly from cli to device.""" + # As this is testing the device constructor need to explicitly wire in + # the FakeTransportProtocol + ftp = FakeTransportProtocol(discovery_data) + mocker.patch.object(TPLinkSmartHomeProtocol, "query", ftp.query) + + # Patch state to echo username and password + pass_dev = click.make_pass_decorator(SmartDevice) + + @pass_dev + async def _state(dev: SmartDevice): + if dev.credentials: + click.echo( + f"Username:{dev.credentials.username} Password:{dev.credentials.password}" + ) + + mocker.patch("kasa.cli.state", new=_state) + + # Get the type string parameter from the discovery_info + for cli_device_type in { + i + for i in TYPE_TO_CLASS + if TYPE_TO_CLASS[i] == Discover._get_device_class(discovery_data) + }: + break + + runner = CliRunner() + res = await runner.invoke( + cli, + [ + "--host", + "127.0.0.1", + "--type", + cli_device_type, + "--username", + "foo", + "--password", + "bar", + ], + ) + assert res.exit_code == 0 + assert res.output == "Username:foo Password:bar\n" + + +@pytest.mark.parametrize("auth_param", ["--username", "--password"]) +async def test_invalid_credential_params(auth_param): + runner = CliRunner() + + # Test for handling only one of username or passowrd supplied. + res = await runner.invoke( + cli, + [ + "--host", + "127.0.0.1", + "--type", + "plug", + auth_param, + "foo", + ], + ) + assert res.exit_code == 0 + assert ( + res.output == "Using authentication requires both --username and --password\n" + ) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index dd97b081d..0839bc06f 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -1,14 +1,26 @@ +import inspect from datetime import datetime from unittest.mock import patch import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 -from kasa import SmartDeviceException +import kasa +from kasa import Credentials, SmartDevice, SmartDeviceException from kasa.smartstrip import SmartStripPlug from .conftest import handle_turn_on, has_emeter, no_emeter, turn_on from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol +# List of all SmartXXX classes including the SmartDevice base class +smart_device_classes = [ + dc + for (mn, dc) in inspect.getmembers( + kasa, + lambda member: inspect.isclass(member) + and (member == SmartDevice or issubclass(member, SmartDevice)), + ) +] + async def test_state_info(dev): assert isinstance(dev.state_information, dict) @@ -150,3 +162,15 @@ async def test_features(dev): assert dev.features == set(sysinfo["feature"].split(":")) else: assert dev.features == set() + + +@pytest.mark.parametrize("device_class", smart_device_classes) +def test_device_class_ctors(device_class): + """Make sure constructor api not broken for new and existing SmartDevices.""" + host = "127.0.0.2" + port = 1234 + credentials = Credentials("foo", "bar") + dev = device_class(host, port=port, credentials=credentials) + assert dev.host == host + assert dev.port == port + assert dev.credentials == credentials From a2444da9df9177118261e4332187d4f262cd4fc7 Mon Sep 17 00:00:00 2001 From: cobryan05 <13701027+cobryan05@users.noreply.github.com> Date: Thu, 14 Sep 2023 13:51:40 -0500 Subject: [PATCH 165/892] Split queries to avoid overflowing device buffers (#502) Several KASA devices seem to have pretty strict buffer size limitations on incoming/outgoing data transfers. Testing on KL125-US and HL103 has shown that sending a request size larger than about ~768 bytes will immediately crash the device. Additionally, a query that generates a response larger than ~4096 bytes will crash the KL125-US. I was unable to generate such a large response to test the HL103. The KL125-US will only return such large queries when its monthly usage stats have been populated. This means that a new bulb would work fine, but after a month of data collection the bulb would break the 4K limit and start to crash. To work around this issue, an estimated worst-case response size is calculated before sending a request by summing up all modules estimated response size. If the estimated size is greater than the device's max_response_payload_size then the query will be split into multiple queries. This PR implements splitting queries expected to have large responses and also removes the module 'skip list' which was a previous workaround to the crash (which worked by simply reducing the number of modules queried, which prevented the overflow) since it is no longer necessary. This PR does not attempt to address the "input buffer size limit." Thus far this limit has not been an issue. --- kasa/modules/module.py | 9 +++++++++ kasa/modules/usage.py | 5 +++++ kasa/smartbulb.py | 5 +++++ kasa/smartdevice.py | 30 ++++++++++++++++++++++-------- kasa/tests/test_smartdevice.py | 15 ++++++++++++++- 5 files changed, 55 insertions(+), 9 deletions(-) diff --git a/kasa/modules/module.py b/kasa/modules/module.py index 7340d7e11..ff806a997 100644 --- a/kasa/modules/module.py +++ b/kasa/modules/module.py @@ -43,6 +43,15 @@ def query(self): queries to the query that gets executed when Device.update() gets called. """ + @property + def estimated_query_response_size(self): + """Estimated maximum size of query response. + + The inheriting modules implement this to estimate how large a query response + will be so that queries can be split should an estimated response be too large + """ + return 256 # Estimate for modules that don't specify + @property def data(self): """Return the module specific raw data from the last update.""" diff --git a/kasa/modules/usage.py b/kasa/modules/usage.py index d1f96e7e4..9877795dd 100644 --- a/kasa/modules/usage.py +++ b/kasa/modules/usage.py @@ -21,6 +21,11 @@ def query(self): return req + @property + def estimated_query_response_size(self): + """Estimated maximum query response size.""" + return 2048 + @property def daily_data(self): """Return statistics on daily basis.""" diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index 2d2f28ca0..a09487d26 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -543,3 +543,8 @@ async def save_preset(self, preset: SmartBulbPreset): return await self._query_helper( self.LIGHT_SERVICE, "set_preferred_state", preset.dict(exclude_none=True) ) + + @property + def max_device_response_size(self) -> int: + """Returns the maximum response size the device can safely construct.""" + return 4096 diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 5c24c943e..81ea135be 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -28,9 +28,6 @@ _LOGGER = logging.getLogger(__name__) -# Certain module queries will crash devices; this list skips those queries -MODEL_MODULE_SKIPLIST = {"KL125(US)": ["cloud"]} # Issue #345 - class DeviceType(Enum): """Device type enum.""" @@ -337,20 +334,32 @@ async def _modular_update(self, req: dict) -> None: ) self.add_module("emeter", Emeter(self, self.emeter_type)) + request_list = [] + est_response_size = 1024 if "system" in req else 0 for module_name, module in self.modules.items(): if not module.is_supported: _LOGGER.debug("Module %s not supported, skipping" % module) continue - modules_to_skip = MODEL_MODULE_SKIPLIST.get(self.model, []) - if module_name in modules_to_skip: - _LOGGER.debug(f"Module {module} is excluded for {self.model}, skipping") - continue + + est_response_size += module.estimated_query_response_size + if est_response_size > self.max_device_response_size: + request_list.append(req) + req = {} + est_response_size = module.estimated_query_response_size q = module.query() _LOGGER.debug("Adding query for %s: %s", module, q) req = merge(req, q) + request_list.append(req) - self._last_update = await self.protocol.query(req) + responses = [ + await self.protocol.query(request) for request in request_list if request + ] + + update: Dict = {} + for response in responses: + update = {**update, **response} + self._last_update = update def update_from_discover_info(self, info): """Update state from info from the discover call.""" @@ -658,6 +667,11 @@ def get_plug_by_index(self, index: int) -> "SmartDevice": ) return self.children[index] + @property + def max_device_response_size(self) -> int: + """Returns the maximum response size the device can safely construct.""" + return 16 * 1024 + @property def device_type(self) -> DeviceType: """Return the device type.""" diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 0839bc06f..c70f544b2 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -39,7 +39,9 @@ async def test_initial_update_emeter(dev, mocker): dev._last_update = None spy = mocker.spy(dev.protocol, "query") await dev.update() - assert spy.call_count == 2 + len(dev.children) + # Devices with small buffers may require 3 queries + expected_queries = 2 if dev.max_device_response_size > 4096 else 3 + assert spy.call_count == expected_queries + len(dev.children) @no_emeter @@ -164,6 +166,17 @@ async def test_features(dev): assert dev.features == set() +async def test_max_device_response_size(dev): + """Make sure every device return has a set max response size.""" + assert dev.max_device_response_size > 0 + + +async def test_estimated_response_sizes(dev): + """Make sure every module has an estimated response size set.""" + for mod in dev.modules.values(): + assert mod.estimated_query_response_size > 0 + + @pytest.mark.parametrize("device_class", smart_device_classes) def test_device_class_ctors(device_class): """Make sure constructor api not broken for new and existing SmartDevices.""" From 84a501bcdc97bedce0d87689ff9db6af880344ae Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 4 Oct 2023 23:35:26 +0200 Subject: [PATCH 166/892] Show an error if both --alias and --host are defined (#513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display an error if both --alias and --host are defined to avoid ambiguous target device: ``` ❯ kasa --host 123 --alias 123 state Usage: kasa [OPTIONS] COMMAND [ARGS]... Try 'kasa --help' for help. Error: Use either --alias or --host, not both. ``` Also, use `click.BadOptionUsage` consistently for other errors, like when only `--username` or `--password` is given. --- kasa/cli.py | 8 ++++++-- kasa/tests/test_cli.py | 24 +++++++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index f0c180c57..17b8b3664 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -209,6 +209,9 @@ def _nop_echo(*args, **kwargs): if ctx.invoked_subcommand == "discover": return + if alias is not None and host is not None: + raise click.BadOptionUsage("alias", "Use either --alias or --host, not both.") + if alias is not None and host is None: echo(f"Alias is given, using discovery to find host {alias}") host = await find_host_from_alias(alias=alias, target=target) @@ -219,8 +222,9 @@ def _nop_echo(*args, **kwargs): return if bool(password) != bool(username): - echo("Using authentication requires both --username and --password") - return + raise click.BadOptionUsage( + "username", "Using authentication requires both --username and --password" + ) credentials = Credentials(username=username, password=password) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 4a47284b9..7f6fa0f23 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -183,9 +183,9 @@ async def _state(dev: SmartDevice): @pytest.mark.parametrize("auth_param", ["--username", "--password"]) async def test_invalid_credential_params(auth_param): + """Test for handling only one of username or password supplied.""" runner = CliRunner() - # Test for handling only one of username or passowrd supplied. res = await runner.invoke( cli, [ @@ -197,7 +197,25 @@ async def test_invalid_credential_params(auth_param): "foo", ], ) - assert res.exit_code == 0 + assert res.exit_code == 2 assert ( - res.output == "Using authentication requires both --username and --password\n" + "Error: Using authentication requires both --username and --password" + in res.output + ) + + +async def test_duplicate_target_device(): + """Test that defining both --host or --alias gives an error.""" + runner = CliRunner() + + res = await runner.invoke( + cli, + [ + "--host", + "127.0.0.1", + "--alias", + "foo", + ], ) + assert res.exit_code == 2 + assert "Error: Use either --alias or --host, not both." in res.output From 20b3f7a771ccfc6f14c36d6bb17d333fd8657436 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Oct 2023 15:50:54 -0500 Subject: [PATCH 167/892] Fix every other query tries to fetch known unsupported features (#520) * Fix every other query tries to fetch known unsupported features * ensure modules not being updated are preserved --- kasa/smartdevice.py | 8 ++++++-- kasa/tests/test_smartdevice.py | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 81ea135be..4c1a3b936 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -336,7 +336,7 @@ async def _modular_update(self, req: dict) -> None: request_list = [] est_response_size = 1024 if "system" in req else 0 - for module_name, module in self.modules.items(): + for module in self.modules.values(): if not module.is_supported: _LOGGER.debug("Module %s not supported, skipping" % module) continue @@ -356,7 +356,11 @@ async def _modular_update(self, req: dict) -> None: await self.protocol.query(request) for request in request_list if request ] - update: Dict = {} + # Preserve the last update and merge + # responses on top of it so we remember + # which modules are not supported, otherwise + # every other update will query for them + update: Dict = self._last_update.copy() if self._last_update else {} for response in responses: update = {**update, **response} self._last_update = update diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index c70f544b2..283fcfef6 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -187,3 +187,10 @@ def test_device_class_ctors(device_class): assert dev.host == host assert dev.port == port assert dev.credentials == credentials + + +async def test_modules_preserved(dev: SmartDevice): + """Make modules that are not being updated are preserved between updates.""" + dev._last_update["some_module_not_being_updated"] = "should_be_kept" + await dev.update() + assert dev._last_update["some_module_not_being_updated"] == "should_be_kept" From 0ec0826cc770a85fc2ed5a52b69551872b5c27f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Oct 2023 08:58:00 -1000 Subject: [PATCH 168/892] Make timeout adjustable (#494) --- kasa/discover.py | 15 ++++++++++++--- kasa/protocol.py | 9 +++++---- kasa/smartbulb.py | 7 ++++--- kasa/smartdevice.py | 3 ++- kasa/smartdimmer.py | 3 ++- kasa/smartlightstrip.py | 3 ++- kasa/smartplug.py | 5 +++-- kasa/smartstrip.py | 3 ++- kasa/tests/conftest.py | 2 +- kasa/tests/test_smartdevice.py | 6 ++++++ 10 files changed, 39 insertions(+), 17 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index f8e11a62b..a39b27902 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -47,7 +47,8 @@ def __init__( port: Optional[int] = None, discovered_event: Optional[asyncio.Event] = None, credentials: Optional[Credentials] = None, - ): + timeout: Optional[int] = None, + ) -> None: self.transport = None self.discovery_packets = discovery_packets self.interface = interface @@ -61,6 +62,7 @@ def __init__( self.on_unsupported = on_unsupported self.discovered_event = discovered_event self.credentials = credentials + self.timeout = timeout def connection_made(self, transport) -> None: """Set socket options for broadcasting.""" @@ -124,7 +126,9 @@ def datagram_received(self, data, addr) -> None: self.discovered_event.set() return - device = device_class(ip, port=port, credentials=self.credentials) + device = device_class( + ip, port=port, credentials=self.credentials, timeout=self.timeout + ) device.update_from_discover_info(info) self.discovered_devices[ip] = device @@ -226,6 +230,7 @@ async def discover( interface=interface, on_unsupported=on_unsupported, credentials=credentials, + timeout=timeout, ), local_addr=("0.0.0.0", 0), ) @@ -259,7 +264,11 @@ async def discover_single( event = asyncio.Event() transport, protocol = await loop.create_datagram_endpoint( lambda: _DiscoverProtocol( - target=host, port=port, discovered_event=event, credentials=credentials + target=host, + port=port, + discovered_event=event, + credentials=credentials, + timeout=timeout, ), local_addr=("0.0.0.0", 0), ) diff --git a/kasa/protocol.py b/kasa/protocol.py index 461dd85ad..3558b820d 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -37,7 +37,9 @@ class TPLinkSmartHomeProtocol: DEFAULT_TIMEOUT = 5 BLOCK_SIZE = 4 - def __init__(self, host: str, *, port: Optional[int] = None) -> None: + def __init__( + self, host: str, *, port: Optional[int] = None, timeout: Optional[int] = None + ) -> None: """Create a protocol object.""" self.host = host self.port = port or TPLinkSmartHomeProtocol.DEFAULT_PORT @@ -45,6 +47,7 @@ def __init__(self, host: str, *, port: Optional[int] = None) -> None: self.writer: Optional[asyncio.StreamWriter] = None self.query_lock: Optional[asyncio.Lock] = None self.loop: Optional[asyncio.AbstractEventLoop] = None + self.timeout = timeout or TPLinkSmartHomeProtocol.DEFAULT_TIMEOUT def _detect_event_loop_change(self) -> None: """Check if this object has been reused betwen event loops.""" @@ -73,10 +76,8 @@ async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: request = json_dumps(request) assert isinstance(request, str) - timeout = TPLinkSmartHomeProtocol.DEFAULT_TIMEOUT - async with self.query_lock: - return await self._query(request, retry_count, timeout) + return await self._query(request, retry_count, self.timeout) async def _connect(self, timeout: int) -> None: """Try to connect or reconnect to the device.""" diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index a09487d26..09d420538 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -208,9 +208,10 @@ def __init__( host: str, *, port: Optional[int] = None, - credentials: Optional[Credentials] = None + credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, ) -> None: - super().__init__(host=host, port=port, credentials=credentials) + super().__init__(host=host, port=port, credentials=credentials, timeout=timeout) self._device_type = DeviceType.Bulb self.add_module("schedule", Schedule(self, "smartlife.iot.common.schedule")) self.add_module("usage", Usage(self, "smartlife.iot.common.schedule")) @@ -372,7 +373,7 @@ async def set_hsv( saturation: int, value: Optional[int] = None, *, - transition: Optional[int] = None + transition: Optional[int] = None, ) -> Dict: """Set new HSV. diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 4c1a3b936..bdef809af 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -195,6 +195,7 @@ def __init__( *, port: Optional[int] = None, credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, ) -> None: """Create a new SmartDevice instance. @@ -203,7 +204,7 @@ def __init__( self.host = host self.port = port - self.protocol = TPLinkSmartHomeProtocol(host, port=port) + self.protocol = TPLinkSmartHomeProtocol(host, port=port, timeout=timeout) self.credentials = credentials _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) self._device_type = DeviceType.Unknown diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py index 05fb75ac9..a412021c9 100644 --- a/kasa/smartdimmer.py +++ b/kasa/smartdimmer.py @@ -69,8 +69,9 @@ def __init__( *, port: Optional[int] = None, credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, ) -> None: - super().__init__(host, port=port, credentials=credentials) + super().__init__(host, port=port, credentials=credentials, timeout=timeout) self._device_type = DeviceType.Dimmer # TODO: need to be verified if it's okay to call these on HS220 w/o these # TODO: need to be figured out what's the best approach to detect support for these diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py index 34e581155..e3dfc15f5 100644 --- a/kasa/smartlightstrip.py +++ b/kasa/smartlightstrip.py @@ -48,8 +48,9 @@ def __init__( *, port: Optional[int] = None, credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, ) -> None: - super().__init__(host, port=port, credentials=credentials) + super().__init__(host, port=port, credentials=credentials, timeout=timeout) self._device_type = DeviceType.LightStrip @property # type: ignore diff --git a/kasa/smartplug.py b/kasa/smartplug.py index f3d635d9f..cd323c8dd 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -43,9 +43,10 @@ def __init__( host: str, *, port: Optional[int] = None, - credentials: Optional[Credentials] = None + credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, ) -> None: - super().__init__(host, port=port, credentials=credentials) + super().__init__(host, port=port, credentials=credentials, timeout=timeout) self._device_type = DeviceType.Plug self.add_module("schedule", Schedule(self, "schedule")) self.add_module("usage", Usage(self, "schedule")) diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 479b0e569..2a55b2a8f 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -86,8 +86,9 @@ def __init__( *, port: Optional[int] = None, credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, ) -> None: - super().__init__(host=host, port=port, credentials=credentials) + super().__init__(host=host, port=port, credentials=credentials, timeout=timeout) self.emeter_type = "emeter" self._device_type = DeviceType.Strip self.add_module("antitheft", Antitheft(self, "anti_theft")) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 9b5a394da..2b2adc7dd 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -166,7 +166,7 @@ async def _update_and_close(d): async def _discover_update_and_close(ip): - d = await Discover.discover_single(ip) + d = await Discover.discover_single(ip, timeout=10) return await _update_and_close(d) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 283fcfef6..ec4e3d568 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -194,3 +194,9 @@ async def test_modules_preserved(dev: SmartDevice): dev._last_update["some_module_not_being_updated"] = "should_be_kept" await dev.update() assert dev._last_update["some_module_not_being_updated"] == "should_be_kept" + + +async def test_create_smart_device_with_timeout(): + """Make sure timeout is passed to the protocol.""" + dev = SmartDevice(host="127.0.0.1", timeout=100) + assert dev.protocol.timeout == 100 From 9930311b542c2a8b4d153d880a863956c7c605af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Oct 2023 09:18:47 -1000 Subject: [PATCH 169/892] Parse features only during updates (#527) Every time emeter functions were called features had to be re-parsed. For power strips, thats a lot of re-parses. Only parse them when we update. --- kasa/smartdevice.py | 32 ++++++++++++++++++++++---------- kasa/smartstrip.py | 2 +- kasa/tests/test_smartdevice.py | 2 ++ 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index bdef809af..7463c6dbf 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -92,6 +92,12 @@ def wrapped(*args, **kwargs): return wrapped +@functools.lru_cache +def _parse_features(features: str) -> Set[str]: + """Parse features string.""" + return set(features.split(":")) + + class SmartDevice: """Base class for all supported device types. @@ -213,6 +219,7 @@ def __init__( # are not accessed incorrectly. self._last_update: Any = None self._sys_info: Any = None # TODO: this is here to avoid changing tests + self._features: Set[str] = set() self.modules: Dict[str, Any] = {} self.children: List["SmartDevice"] = [] @@ -284,11 +291,7 @@ async def _query_helper( @requires_update def features(self) -> Set[str]: """Return a set of features that the device supports.""" - try: - return set(self.sys_info["feature"].split(":")) - except KeyError: - _LOGGER.debug("Device does not have feature information") - return set() + return self._features @property # type: ignore @requires_update @@ -321,11 +324,12 @@ async def update(self, update_children: bool = True): # See #105, #120, #161 if self._last_update is None: _LOGGER.debug("Performing the initial update to obtain sysinfo") - self._last_update = await self.protocol.query(req) - self._sys_info = self._last_update["system"]["get_sysinfo"] + response = await self.protocol.query(req) + self._last_update = response + self._set_sys_info(response["system"]["get_sysinfo"]) await self._modular_update(req) - self._sys_info = self._last_update["system"]["get_sysinfo"] + self._set_sys_info(self._last_update["system"]["get_sysinfo"]) async def _modular_update(self, req: dict) -> None: """Execute an update query.""" @@ -366,10 +370,18 @@ async def _modular_update(self, req: dict) -> None: update = {**update, **response} self._last_update = update - def update_from_discover_info(self, info): + def update_from_discover_info(self, info: Dict[str, Any]) -> None: """Update state from info from the discover call.""" self._last_update = info - self._sys_info = info["system"]["get_sysinfo"] + self._set_sys_info(info["system"]["get_sysinfo"]) + + def _set_sys_info(self, sys_info: Dict[str, Any]) -> None: + """Set sys_info.""" + self._sys_info = sys_info + if features := sys_info.get("feature"): + self._features = _parse_features(features) + else: + self._features = set() @property # type: ignore @requires_update diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 2a55b2a8f..2e5d80a4b 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -259,7 +259,7 @@ def __init__(self, host: str, parent: "SmartStrip", child_id: str) -> None: self.parent = parent self.child_id = child_id self._last_update = parent._last_update - self._sys_info = parent._sys_info + self._set_sys_info(parent.sys_info) self._device_type = DeviceType.StripSocket self.modules = {} self.protocol = parent.protocol # Must use the same connection as the parent diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index ec4e3d568..aeb1fe03e 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -37,6 +37,7 @@ async def test_invalid_connection(dev): async def test_initial_update_emeter(dev, mocker): """Test that the initial update performs second query if emeter is available.""" dev._last_update = None + dev._features = set() spy = mocker.spy(dev.protocol, "query") await dev.update() # Devices with small buffers may require 3 queries @@ -48,6 +49,7 @@ async def test_initial_update_emeter(dev, mocker): async def test_initial_update_no_emeter(dev, mocker): """Test that the initial update performs second query if emeter is available.""" dev._last_update = None + dev._features = set() spy = mocker.spy(dev.protocol, "query") await dev.update() # 2 calls are necessary as some devices crash on unexpected modules From 528f5e9e0756c1bb12a5e26811877ad29df99bee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Oct 2023 12:36:51 -1000 Subject: [PATCH 170/892] Remove code to detect event loop change (#526) The code should always be called from the same thread that created the object or we have a thread safety problem. --- kasa/protocol.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/kasa/protocol.py b/kasa/protocol.py index 3558b820d..e695510a9 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -45,19 +45,10 @@ def __init__( self.port = port or TPLinkSmartHomeProtocol.DEFAULT_PORT self.reader: Optional[asyncio.StreamReader] = None self.writer: Optional[asyncio.StreamWriter] = None - self.query_lock: Optional[asyncio.Lock] = None + self.query_lock = asyncio.Lock() self.loop: Optional[asyncio.AbstractEventLoop] = None self.timeout = timeout or TPLinkSmartHomeProtocol.DEFAULT_TIMEOUT - def _detect_event_loop_change(self) -> None: - """Check if this object has been reused betwen event loops.""" - loop = asyncio.get_running_loop() - if not self.loop: - self.loop = loop - elif self.loop != loop: - _LOGGER.warning("Detected protocol reuse between different event loop") - self._reset() - async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: """Request information from a TP-Link SmartHome Device. @@ -67,11 +58,6 @@ async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: :param retry_count: how many retries to do in case of failure :return: response dict """ - self._detect_event_loop_change() - - if not self.query_lock: - self.query_lock = asyncio.Lock() - if isinstance(request, dict): request = json_dumps(request) assert isinstance(request, str) @@ -122,7 +108,7 @@ async def close(self) -> None: def _reset(self) -> None: """Clear any varibles that should not survive between loops.""" - self.reader = self.writer = self.loop = self.query_lock = None + self.reader = self.writer = None async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: """Try to query a device.""" From 85c8410c3d4965c1084390c27641f02deafbd960 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Oct 2023 14:29:22 -1000 Subject: [PATCH 171/892] Add a connect_single method to Discover to avoid the need for UDP (#528) This should equate to a significant reliability improvement for networks with poor wifi (edge of range)/udp. --- kasa/discover.py | 42 ++++++++++++++++++++++++++++++++++++ kasa/tests/test_discovery.py | 20 +++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/kasa/discover.py b/kasa/discover.py index a39b27902..4cb7a5329 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -256,6 +256,11 @@ async def discover_single( ) -> SmartDevice: """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 + the WiFi network is congested or the device is not responding + to discovery requests. + :param host: Hostname of device to query :rtype: SmartDevice :return: Object for querying/controlling found device. @@ -299,6 +304,43 @@ async def discover_single( else: raise SmartDeviceException(f"Unable to get discovery response for {host}") + @staticmethod + async def connect_single( + host: str, + *, + port: Optional[int] = None, + timeout=5, + credentials: Optional[Credentials] = None, + ) -> SmartDevice: + """Connect to a single device by the given IP address. + + This method avoids the UDP based discovery process and + will connect directly to the device to query its type. + + It is generally preferred to avoid :func:`discover_single()` and + use this function instead as it should perform better when + the WiFi network is congested or the device is not responding + to discovery requests. + + The device type is discovered by querying the device. + + :param host: Hostname of device to query + :rtype: SmartDevice + :return: Object for querying/controlling found device. + """ + unknown_dev = SmartDevice( + host=host, port=port, credentials=credentials, timeout=timeout + ) + await unknown_dev.update() + device_class = Discover._get_device_class(unknown_dev.internal_state) + dev = device_class( + host=host, port=port, credentials=credentials, timeout=timeout + ) + # Reuse the connection from the unknown device + # so we don't have to reconnect + dev.protocol = unknown_dev.protocol + return dev + @staticmethod def _get_device_class(info: dict) -> Type[SmartDevice]: """Find SmartDevice subclass for device described by passed data.""" diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 41578a2ce..2aa10f1cc 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -74,6 +74,26 @@ def mock_discover(self): assert x.port == custom_port or 9999 +@pytest.mark.parametrize("custom_port", [123, None]) +async def test_connect_single(discovery_data: dict, mocker, custom_port): + """Make sure that connect_single returns an initialized SmartDevice instance.""" + host = "127.0.0.1" + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) + + dev = await Discover.connect_single(host, port=custom_port) + assert issubclass(dev.__class__, SmartDevice) + assert dev.port == custom_port or 9999 + + +async def test_connect_single_query_fails(discovery_data: dict, mocker): + """Make sure that connect_single fails when query fails.""" + host = "127.0.0.1" + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=SmartDeviceException) + + with pytest.raises(SmartDeviceException): + await Discover.connect_single(host) + + UNSUPPORTED = { "result": { "device_id": "xx", From af37e83db1aa620d3bf6bb5fc2cf4913e9636485 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 16 Oct 2023 19:37:55 +0200 Subject: [PATCH 172/892] Fix on_since for smartstrip sockets (#529) --- kasa/smartstrip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 2e5d80a4b..a02d2f896 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -360,7 +360,7 @@ def on_since(self) -> Optional[datetime]: info = self._get_child_info() on_time = info["on_time"] - return self.time - timedelta(seconds=on_time) + return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) @property # type: ignore @requires_update From 0061668c9fbbaf7795b2213c0501d2022d78ed95 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 29 Oct 2023 16:30:37 +0100 Subject: [PATCH 173/892] Use trusted publisher for publishing to pypi (#531) --- .github/workflows/publish.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2f7ec9cad..e48066bb3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,13 +7,15 @@ jobs: build-n-publish: name: Build release packages runs-on: ubuntu-latest + permissions: # for trusted publishing + id-token: write steps: - uses: actions/checkout@master - name: Setup python - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.x" - name: Install pypa/build run: >- @@ -30,6 +32,4 @@ jobs: --outdir dist/ . - name: Publish release on pypi - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{ secrets.PYPI_API_TOKEN }} + uses: pypa/gh-action-pypi-publish@release/v1 From c431dbb832c2259d1c43dd093dd44ef272880a5b Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 29 Oct 2023 23:15:42 +0100 Subject: [PATCH 174/892] Use ruff and ruff format (#534) Replaces the previously used linting and code formatting tools with ruff. --- .flake8 | 8 ----- .github/workflows/ci.yml | 13 ++----- .pre-commit-config.yaml | 26 +++----------- devtools/check_readme_vs_fixtures.py | 3 +- devtools/dump_devinfo.py | 9 ++--- devtools/parse_pcap.py | 10 +++--- devtools/perftest.py | 6 ++-- kasa/cli.py | 10 +++--- kasa/discover.py | 28 +++++++++------ kasa/emeterstatus.py | 10 ++++-- kasa/modules/emeter.py | 10 ++++-- kasa/modules/module.py | 8 +++-- kasa/modules/usage.py | 10 ++++-- kasa/protocol.py | 32 +++++++++-------- kasa/smartbulb.py | 47 ++++++++++++++----------- kasa/smartdevice.py | 30 +++++++++------- kasa/smartdimmer.py | 8 +++-- kasa/smartlightstrip.py | 12 ++++--- kasa/smartplug.py | 3 +- kasa/smartstrip.py | 3 +- kasa/tests/newfakes.py | 36 +++++++++++++------- kasa/tests/test_cli.py | 2 +- kasa/tests/test_discovery.py | 4 +-- kasa/tests/test_smartdevice.py | 7 ++-- pyproject.toml | 51 ++++++++++++++++++++-------- tox.ini | 8 +---- 26 files changed, 220 insertions(+), 174 deletions(-) delete mode 100644 .flake8 diff --git a/.flake8 b/.flake8 deleted file mode 100644 index d4e8f681b..000000000 --- a/.flake8 +++ /dev/null @@ -1,8 +0,0 @@ -[flake8] -exclude = .git,.tox,__pycache__,kasa/tests/newfakes.py,kasa/tests/test_fixtures.py -max-line-length = 88 -per-file-ignores = - kasa/tests/*.py:D100,D101,D102,D103,D104,F401 - docs/source/conf.py:D100,D103 -ignore = D105, D107, E203, E501, W503 -max-complexity = 18 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0dce418d..9f855248f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,18 +26,9 @@ jobs: run: | python -m pip install --upgrade pip poetry poetry install - - name: "Run pyupgrade" + - name: "Linting and code formating (ruff)" run: | - poetry run pre-commit run pyupgrade --all-files - - name: "Code formating (black)" - run: | - poetry run pre-commit run black --all-files - - name: "Code formating (flake8)" - run: | - poetry run pre-commit run flake8 --all-files - - name: "Order of imports (isort)" - run: | - poetry run pre-commit run isort --all-files + poetry run pre-commit run ruff --all-files - name: "Typing checks (mypy)" run: | poetry run pre-commit run mypy --all-files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b429e4d7..4bbfd8c51 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,28 +9,12 @@ repos: - id: debug-statements - id: check-ast -- repo: https://github.com/asottile/pyupgrade - rev: v3.4.0 +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.3 hooks: - - id: pyupgrade - args: ['--py38-plus'] - -- repo: https://github.com/python/black - rev: 23.3.0 - hooks: - - id: black - -- repo: https://github.com/pycqa/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - additional_dependencies: [flake8-docstrings] - -- repo: https://github.com/pre-commit/mirrors-isort - rev: v5.10.1 - hooks: - - id: isort - additional_dependencies: [toml] + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.3.0 diff --git a/devtools/check_readme_vs_fixtures.py b/devtools/check_readme_vs_fixtures.py index b91b9fa90..2c1e7d95e 100644 --- a/devtools/check_readme_vs_fixtures.py +++ b/devtools/check_readme_vs_fixtures.py @@ -1,7 +1,8 @@ """Script that checks if README.md is missing devices that have fixtures.""" from kasa.tests.conftest import ALL_DEVICES, BULBS, DIMMERS, LIGHT_STRIPS, PLUGS, STRIPS -readme = open("README.md").read() +with open("README.md") as f: + readme = f.read() typemap = { "light strips": LIGHT_STRIPS, diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 2f3c20e01..eff3a7b60 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -1,7 +1,8 @@ """This script generates devinfo files for the test suite. -If you have new, yet unsupported device or a device with no devinfo file under kasa/tests/fixtures, -feel free to run this script and create a PR to add the file to the repository. +If you have new, yet unsupported device or a device with no devinfo file under + kasa/tests/fixtures, feel free to run this script and create a PR to add the file + to the repository. Executing this script will several modules and methods one by one, and finally execute a query to query all of them at once. @@ -84,13 +85,13 @@ def cli(host, debug): for test_call in items: - async def _run_query(): + async def _run_query(test_call): protocol = TPLinkSmartHomeProtocol(host) return await protocol.query({test_call.module: {test_call.method: None}}) try: click.echo(f"Testing {test_call}..", nl=False) - info = asyncio.run(_run_query()) + info = asyncio.run(_run_query(test_call)) resp = info[test_call.module] except Exception as ex: click.echo(click.style(f"FAIL {ex}", fg="red")) diff --git a/devtools/parse_pcap.py b/devtools/parse_pcap.py index b3ba2fae3..5e7416238 100644 --- a/devtools/parse_pcap.py +++ b/devtools/parse_pcap.py @@ -15,7 +15,7 @@ def read_payloads_from_file(file): """Read the given pcap file and yield json payloads.""" pcap = dpkt.pcap.Reader(file) - for ts, pkt in pcap: + for _ts, pkt in pcap: eth = Ethernet(pkt) if eth.type != ETH_TYPE_IP: continue @@ -44,9 +44,8 @@ def read_payloads_from_file(file): try: json_payload = json.loads(decrypted) - except ( - Exception - ) as ex: # this can happen when the response is split into multiple tcp segments + except Exception as ex: + # this can happen when the response is split into multiple tcp segments echo(f"[red]Unable to parse payload '{decrypted}', ignoring: {ex}[/red]") continue @@ -91,7 +90,8 @@ def parse_pcap(file): context_str = f" [ctx: {context}]" if context else "" echo( - f"[{is_success}] {direction}{context_str} {module}.{cmd}: {pf(response)}" + f"[{is_success}] {direction}{context_str} {module}.{cmd}:" + f" {pf(response)}" ) echo(pf(seen_items)) diff --git a/devtools/perftest.py b/devtools/perftest.py index 5babc75f6..55c57f145 100644 --- a/devtools/perftest.py +++ b/devtools/perftest.py @@ -59,13 +59,13 @@ async def main(addrs, rounds): if test_gathered: print("=== Testing using gather on all devices ===") - for i in range(rounds): + for _i in range(rounds): data.append(await _update_concurrently(devs)) await asyncio.sleep(2) await asyncio.sleep(5) - for i in range(rounds): + for _i in range(rounds): data.append(await _update_sequentially(devs)) await asyncio.sleep(2) @@ -77,7 +77,7 @@ async def main(addrs, rounds): futs = [] data = [] locks = {dev: asyncio.Lock() for dev in devs} - for i in range(rounds): + for _i in range(rounds): for dev in devs: futs.append(asyncio.ensure_future(_update(dev, locks[dev]))) diff --git a/kasa/cli.py b/kasa/cli.py index 17b8b3664..3bc779346 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -203,7 +203,8 @@ def _nop_echo(*args, **kwargs): except ImportError: pass - # The configuration should be converted to use dictConfig, but this keeps mypy happy for now + # The configuration should be converted to use dictConfig, + # but this keeps mypy happy for now logging.basicConfig(**logging_config) # type: ignore if ctx.invoked_subcommand == "discover": @@ -278,7 +279,8 @@ async def join(dev: SmartDevice, ssid, password, keytype): echo(f"Asking the device to connect to {ssid}..") res = await dev.wifi_join(ssid, password, keytype=keytype) echo( - f"Response: {res} - if the device is not able to join the network, it will revert back to its previous state." + f"Response: {res} - if the device is not able to join the network, " + f"it will revert back to its previous state." ) return res @@ -347,9 +349,9 @@ async def print_discovered(dev: SmartDevice): async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3): """Discover a device identified by its alias.""" - for attempt in range(1, attempts): + for _attempt in range(1, attempts): found_devs = await Discover.discover(target=target, timeout=timeout) - for ip, dev in found_devs.items(): + for _ip, dev in found_devs.items(): if dev.alias.lower() == alias.lower(): host = dev.host return host diff --git a/kasa/discover.py b/kasa/discover.py index 4cb7a5329..b43df57b3 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -87,7 +87,7 @@ def do_discover(self) -> None: 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): + for _i in range(self.discovery_packets): self.transport.sendto(encrypted_req[4:], self.target) # type: ignore self.transport.sendto(Discover.DISCOVERY_QUERY_2, self.target_2) # type: ignore @@ -169,7 +169,8 @@ class Discover: >>> [dev.alias for dev in found_devices] ['TP-LINK_Power Strip_CF69'] - Discovery can also be targeted to a specific broadcast address instead of the 255.255.255.255: + Discovery can also be targeted to a specific broadcast address instead of + the default 255.255.255.255: >>> asyncio.run(Discover.discover(target="192.168.8.255")) @@ -207,14 +208,19 @@ async def discover( Sends discovery message to 255.255.255.255:9999 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, you can use target parameter to specify the network for discovery. + If you have multiple interfaces, + you can use *target* parameter to specify the network for discovery. - If given, `on_discovered` coroutine will get awaited with a :class:`SmartDevice`-derived object as parameter. + If given, `on_discovered` coroutine will get awaited with + a :class:`SmartDevice`-derived object as parameter. - The results of the discovery are returned as a dict of :class:`SmartDevice`-derived objects keyed with IP addresses. - The devices are already initialized and all but emeter-related properties can be accessed directly. + The results of the discovery are returned as a dict of + :class:`SmartDevice`-derived objects keyed with IP addresses. + The devices are already initialized and all but emeter-related properties + can be accessed directly. - :param target: The target address where to send the broadcast discovery queries if multi-homing (e.g. 192.168.xxx.255). + :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_packets: Number of discovery packets to broadcast @@ -232,7 +238,7 @@ async def discover( credentials=credentials, timeout=timeout, ), - local_addr=("0.0.0.0", 0), + local_addr=("0.0.0.0", 0), # noqa: S104 ) protocol = cast(_DiscoverProtocol, protocol) @@ -275,7 +281,7 @@ async def discover_single( credentials=credentials, timeout=timeout, ), - local_addr=("0.0.0.0", 0), + local_addr=("0.0.0.0", 0), # noqa: S104 ) protocol = cast(_DiscoverProtocol, protocol) @@ -284,10 +290,10 @@ async def discover_single( async with asyncio_timeout(timeout): await event.wait() - except asyncio.TimeoutError: + except asyncio.TimeoutError as ex: raise SmartDeviceException( f"Timed out getting discovery response for {host}" - ) + ) from ex finally: transport.close() diff --git a/kasa/emeterstatus.py b/kasa/emeterstatus.py index da636551d..48d6e2410 100644 --- a/kasa/emeterstatus.py +++ b/kasa/emeterstatus.py @@ -48,9 +48,13 @@ def total(self) -> Optional[float]: return None def __repr__(self): - return f"" + return ( + f"" + ) def __getitem__(self, item): + """Return value in wanted units.""" valid_keys = [ "voltage_mv", "power_mw", @@ -65,7 +69,7 @@ def __getitem__(self, item): ] # 1. if requested data is available, return it - if item in super().keys(): + if item in super().keys(): # noqa: SIM118 return super().__getitem__(item) # otherwise decide how to convert it else: @@ -74,7 +78,7 @@ def __getitem__(self, item): if "_" in item: # upscale return super().__getitem__(item[: item.find("_")]) * 1000 else: # downscale - for i in super().keys(): + for i in super().keys(): # noqa: SIM118 if i.startswith(item): return self.__getitem__(i) / 1000 diff --git a/kasa/modules/emeter.py b/kasa/modules/emeter.py index 831210c37..1fe6e679d 100644 --- a/kasa/modules/emeter.py +++ b/kasa/modules/emeter.py @@ -44,13 +44,19 @@ async def get_realtime(self): return await self.call("get_realtime") async def get_daystat(self, *, year=None, month=None, kwh=True) -> Dict: - """Return daily stats for the given year & month as a dictionary of {day: energy, ...}.""" + """Return daily stats for the given year & month. + + The return value is a dictionary of {day: energy, ...}. + """ data = await self.get_raw_daystat(year=year, month=month) data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh) return data async def get_monthstat(self, *, year=None, kwh=True) -> Dict: - """Return monthly stats for the given year as a dictionary of {month: energy, ...}.""" + """Return monthly stats for the given year. + + The return value is a dictionary of {month: energy, ...}. + """ data = await self.get_raw_monthstat(year=year) data = self._convert_stat_data(data["month_list"], entry_key="month", kwh=kwh) return data diff --git a/kasa/modules/module.py b/kasa/modules/module.py index ff806a997..40890f297 100644 --- a/kasa/modules/module.py +++ b/kasa/modules/module.py @@ -57,7 +57,8 @@ def data(self): """Return the module specific raw data from the last update.""" if self._module not in self._device._last_update: raise SmartDeviceException( - f"You need to call update() prior accessing module data for '{self._module}'" + f"You need to call update() prior accessing module data" + f" for '{self._module}'" ) return self._device._last_update[self._module] @@ -80,4 +81,7 @@ def query_for_command(self, query, params=None): return self._device._create_request(self._module, query, params) def __repr__(self) -> str: - return f"" + return ( + f"" + ) diff --git a/kasa/modules/usage.py b/kasa/modules/usage.py index 9877795dd..f33c71f11 100644 --- a/kasa/modules/usage.py +++ b/kasa/modules/usage.py @@ -73,13 +73,19 @@ async def get_raw_monthstat(self, *, year=None) -> Dict: return await self.call("get_monthstat", {"year": year}) async def get_daystat(self, *, year=None, month=None) -> Dict: - """Return daily stats for the given year & month as a dictionary of {day: time, ...}.""" + """Return daily stats for the given year & month. + + The return value is a dictionary of {day: time, ...}. + """ data = await self.get_raw_daystat(year=year, month=month) data = self._convert_stat_data(data["day_list"], entry_key="day") return data async def get_monthstat(self, *, year=None) -> Dict: - """Return monthly stats for the given year as a dictionary of {month: time, ...}.""" + """Return monthly stats for the given year. + + The return value is a dictionary of {month: time, ...}. + """ data = await self.get_raw_monthstat(year=year) data = self._convert_stat_data(data["month_list"], entry_key="month") return data diff --git a/kasa/protocol.py b/kasa/protocol.py index e695510a9..7ab2c47fa 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -60,7 +60,7 @@ async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: """ if isinstance(request, dict): request = json_dumps(request) - assert isinstance(request, str) + assert isinstance(request, str) # noqa: S101 async with self.query_lock: return await self._query(request, retry_count, self.timeout) @@ -77,8 +77,8 @@ async def _connect(self, timeout: int) -> None: async def _execute_query(self, request: str) -> Dict: """Execute a query on the device and wait for the response.""" - assert self.writer is not None - assert self.reader is not None + assert self.writer is not None # noqa: S101 + assert self.reader is not None # noqa: S101 debug_log = _LOGGER.isEnabledFor(logging.DEBUG) if debug_log: @@ -116,11 +116,11 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: # Most of the time we will already be connected if the device is online # and the connect call will do nothing and return right away # - # However, if we get an unrecoverable error (_NO_RETRY_ERRORS and ConnectionRefusedError) - # we do not want to keep trying since many connection open/close operations - # in the same time frame can block the event loop. This is especially - # import when there are multiple tplink devices being polled. - # + # However, if we get an unrecoverable error (_NO_RETRY_ERRORS and + # ConnectionRefusedError) we do not want to keep trying since many + # connection open/close operations in the same time frame can block + # the event loop. + # This is especially import when there are multiple tplink devices being polled. for retry in range(retry_count + 1): try: await self._connect(timeout) @@ -128,26 +128,28 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: await self.close() raise SmartDeviceException( f"Unable to connect to the device: {self.host}:{self.port}: {ex}" - ) + ) from ex except OSError as ex: await self.close() if ex.errno in _NO_RETRY_ERRORS or retry >= retry_count: raise SmartDeviceException( - f"Unable to connect to the device: {self.host}:{self.port}: {ex}" - ) + f"Unable to connect to the device:" + f" {self.host}:{self.port}: {ex}" + ) from ex continue except Exception as ex: await self.close() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) raise SmartDeviceException( - f"Unable to connect to the device: {self.host}:{self.port}: {ex}" - ) + f"Unable to connect to the device:" + f" {self.host}:{self.port}: {ex}" + ) from ex continue try: - assert self.reader is not None - assert self.writer is not None + assert self.reader is not None # noqa: S101 + assert self.writer is not None # noqa: S101 async with asyncio_timeout(timeout): return await self._execute_query(request) except Exception as ex: diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index 09d420538..6dd4513c6 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -59,7 +59,8 @@ class TurnOnBehavior(BaseModel): """Model to present a single turn on behavior. :param int preset: the index number of wanted preset. - :param BehaviorMode mode: last status or preset mode. If you are changing existing settings, you should not set this manually. + :param BehaviorMode mode: last status or preset mode. + If you are changing existing settings, you should not set this manually. To change the behavior, it is only necessary to change the :attr:`preset` field to contain either the preset index, or ``None`` for the last known state. @@ -121,9 +122,11 @@ class SmartBulb(SmartDevice): This will allow accessing the properties using the exposed properties. All changes to the device are done using awaitable methods, - which will not change the cached values, but you must await :func:`update()` separately. + which will not change the cached values, + so you must await :func:`update()` to fetch updates values from the device. - Errors reported by the device are raised as :class:`SmartDeviceExceptions `, + Errors reported by the device are raised as + :class:`SmartDeviceExceptions `, and should be handled by the user of the library. Examples: @@ -159,7 +162,7 @@ class SmartBulb(SmartDevice): >>> bulb.brightness 50 - Bulbs supporting color temperature can be queried to know which range is accepted: + Bulbs supporting color temperature can be queried for the supported range: >>> bulb.valid_temperature_range ColorTempRange(min=2500, max=9000) @@ -175,9 +178,18 @@ class SmartBulb(SmartDevice): >>> bulb.hsv HSV(hue=180, saturation=100, value=80) - If you don't want to use the default transitions, you can pass `transition` in milliseconds. - This applies to all transitions (:func:`turn_on`, :func:`turn_off`, :func:`set_hsv`, :func:`set_color_temp`, :func:`set_brightness`) if supported by the device. - Light strips (e.g., KL420L5) do not support this feature, but silently ignore the parameter. + If you don't want to use the default transitions, + you can pass `transition` in milliseconds. + All methods changing the state of the device support this parameter: + + * :func:`turn_on` + * :func:`turn_off` + * :func:`set_hsv` + * :func:`set_color_temp` + * :func:`set_brightness` + + Light strips (e.g., KL420L5) do not support this feature, + but silently ignore the parameter. The following changes the brightness over a period of 10 seconds: >>> asyncio.run(bulb.set_brightness(100, transition=10_000)) @@ -187,7 +199,8 @@ class SmartBulb(SmartDevice): >>> bulb.presets [SmartBulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), SmartBulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] - To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset` instance to :func:`save_preset` method: + To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset` + instance to :func:`save_preset` method: >>> preset = bulb.presets[0] >>> preset.brightness @@ -197,7 +210,7 @@ class SmartBulb(SmartDevice): >>> bulb.presets[0].brightness 100 - """ + """ # noqa: E501 LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice" SET_LIGHT_METHOD = "transition_light_state" @@ -362,9 +375,7 @@ def hsv(self) -> HSV: def _raise_for_invalid_brightness(self, value): if not isinstance(value, int) or not (0 <= value <= 100): - raise ValueError( - "Invalid brightness value: {} " "(valid range: 0-100%)".format(value) - ) + raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") @requires_update async def set_hsv( @@ -386,14 +397,11 @@ async def set_hsv( raise SmartDeviceException("Bulb does not support color.") if not isinstance(hue, int) or not (0 <= hue <= 360): - raise ValueError( - "Invalid hue value: {} " "(valid range: 0-360)".format(hue) - ) + raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") if not isinstance(saturation, int) or not (0 <= saturation <= 100): raise ValueError( - "Invalid saturation value: {} " - "(valid range: 0-100%)".format(saturation) + f"Invalid saturation value: {saturation} (valid range: 0-100%)" ) light_state = { @@ -433,8 +441,9 @@ async def set_color_temp( valid_temperature_range = self.valid_temperature_range if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: raise ValueError( - "Temperature should be between {} " - "and {}, was {}".format(*valid_temperature_range, temp) + "Temperature should be between {} and {}, was {}".format( + *valid_temperature_range, temp + ) ) light_state = {"color_temp": temp} diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 7463c6dbf..3e9bd9532 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -101,8 +101,8 @@ def _parse_features(features: str) -> Set[str]: class SmartDevice: """Base class for all supported device types. - You don't usually want to construct this class which implements the shared common interfaces. - The recommended way is to either use the discovery functionality, or construct one of the subclasses: + You don't usually want to initialize this class manually, + but either use :class:`Discover` class, or use one of the subclasses: * :class:`SmartPlug` * :class:`SmartBulb` @@ -145,7 +145,8 @@ class SmartDevice: >>> dev.mac 01:23:45:67:89:ab - When initialized using discovery or using a subclass, you can check the type of the device: + When initialized using discovery or using a subclass, + you can check the type of the device: >>> dev.is_bulb False @@ -154,7 +155,8 @@ class SmartDevice: >>> dev.is_plug True - You can also get the hardware and software as a dict, or access the full device response: + You can also get the hardware and software as a dict, + or access the full device response: >>> dev.hw_info {'sw_ver': '1.2.5 Build 171213 Rel.101523', @@ -175,7 +177,8 @@ class SmartDevice: >>> dev.is_on True - Some devices provide energy consumption meter, and regular update will already fetch some information: + Some devices provide energy consumption meter, + and regular update will already fetch some information: >>> dev.has_emeter True @@ -184,7 +187,8 @@ class SmartDevice: >>> dev.emeter_today >>> dev.emeter_this_month - You can also query the historical data (note that these needs to be awaited), keyed with month/day: + You can also query the historical data (note that these needs to be awaited), + keyed with month/day: >>> asyncio.run(dev.get_emeter_monthly(year=2016)) {11: 1.089, 12: 1.582} @@ -214,9 +218,9 @@ def __init__( self.credentials = credentials _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) self._device_type = DeviceType.Unknown - # TODO: typing Any is just as using Optional[Dict] would require separate checks in - # accessors. the @updated_required decorator does not ensure mypy that these - # are not accessed incorrectly. + # TODO: typing Any is just as using Optional[Dict] would require separate + # checks in accessors. the @updated_required decorator does not ensure + # mypy that these are not accessed incorrectly. self._last_update: Any = None self._sys_info: Any = None # TODO: this is here to avoid changing tests self._features: Set[str] = set() @@ -230,8 +234,6 @@ def add_module(self, name: str, module: Module): _LOGGER.debug("Module %s already registered, ignoring..." % name) return - assert name not in self.modules - _LOGGER.debug("Adding module %s", module) self.modules[name] = module @@ -751,4 +753,8 @@ def internal_state(self) -> Any: def __repr__(self): if self._last_update is None: return f"<{self._device_type} at {self.host} - update() needed>" - return f"<{self._device_type} model {self.model} at {self.host} ({self.alias}), is_on: {self.is_on} - dev specific: {self.state_information}>" + return ( + f"<{self._device_type} model {self.model} at {self.host}" + f" ({self.alias}), is_on: {self.is_on}" + f" - dev specific: {self.state_information}>" + ) diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py index a412021c9..7980319c7 100644 --- a/kasa/smartdimmer.py +++ b/kasa/smartdimmer.py @@ -41,7 +41,8 @@ class SmartDimmer(SmartPlug): This will allow accessing the properties using the exposed properties. All changes to the device are done using awaitable methods, - which will not change the cached values, but you must await :func:`update()` separately. + which will not change the cached values, + but you must await :func:`update()` separately. Errors reported by the device are raised as :class:`SmartDeviceException`\s, and should be handled by the user of the library. @@ -74,7 +75,7 @@ def __init__( super().__init__(host, port=port, credentials=credentials, timeout=timeout) self._device_type = DeviceType.Dimmer # TODO: need to be verified if it's okay to call these on HS220 w/o these - # TODO: need to be figured out what's the best approach to detect support for these + # TODO: need to be figured out what's the best approach to detect support self.add_module("motion", Motion(self, "smartlife.iot.PIR")) self.add_module("ambient", AmbientLight(self, "smartlife.iot.LAS")) @@ -187,7 +188,8 @@ async def set_button_action( """Set action to perform on button click/hold. :param action_type ActionType: whether to control double click or hold action. - :param action ButtonAction: what should the button do (nothing, instant, gentle, change preset) + :param action ButtonAction: what should the button do + (nothing, instant, gentle, change preset) :param index int: in case of preset change, the preset to select """ action_type_setter = f"set_{action_type}" diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py index e3dfc15f5..2990e1fa4 100644 --- a/kasa/smartlightstrip.py +++ b/kasa/smartlightstrip.py @@ -11,10 +11,10 @@ class SmartLightStrip(SmartBulb): """Representation of a TP-Link Smart light strip. Light strips work similarly to bulbs, but use a different service for controlling, - and expose some extra information (such as length and active effect). - This class extends :class:`SmartBulb` interface. + and expose some extra information (such as length and active effect). + This class extends :class:`SmartBulb` interface. - Examples: + Examples: >>> import asyncio >>> strip = SmartLightStrip("127.0.0.1") >>> asyncio.run(strip.update()) @@ -105,9 +105,11 @@ async def set_effect( ) -> None: """Set an effect on the device. - If brightness or transition is defined, its value will be used instead of the effect-specific default. + If brightness or transition is defined, + its value will be used instead of the effect-specific default. - See :meth:`effect_list` for available effects, or use :meth:`set_custom_effect` for custom effects. + See :meth:`effect_list` for available effects, + or use :meth:`set_custom_effect` for custom effects. :param str effect: The effect to set :param int brightness: The wanted brightness diff --git a/kasa/smartplug.py b/kasa/smartplug.py index cd323c8dd..4ba230b49 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -16,7 +16,8 @@ class SmartPlug(SmartDevice): This will allow accessing the properties using the exposed properties. All changes to the device are done using awaitable methods, - which will not change the cached values, but you must await :func:`update()` separately. + which will not change the cached values, + but you must await :func:`update()` separately. Errors reported by the device are raised as :class:`SmartDeviceException`\s, and should be handled by the user of the library. diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index a02d2f896..80aa27d1b 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -40,7 +40,8 @@ class SmartStrip(SmartDevice): This will allow accessing the properties using the exposed properties. All changes to the device are done using awaitable methods, - which will not change the cached values, but you must await :func:`update()` separately. + which will not change the cached values, + but you must await :func:`update()` separately. Errors reported by the device are raised as :class:`SmartDeviceException`\s, and should be handled by the user of the library. diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 3c2b4e4f7..b849d0807 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -1,8 +1,16 @@ import logging import re -from voluptuous import Coerce # type: ignore -from voluptuous import REMOVE_EXTRA, All, Any, Invalid, Optional, Range, Schema +from voluptuous import ( + REMOVE_EXTRA, + All, + Any, + Coerce, # type: ignore + Invalid, + Optional, + Range, + Schema, +) from ..protocol import TPLinkSmartHomeProtocol @@ -305,7 +313,9 @@ def __init__(self, info): self.proto = proto - def set_alias(self, x, child_ids=[]): + def set_alias(self, x, child_ids=None): + if child_ids is None: + child_ids = [] _LOGGER.debug("Setting alias to %s, child_ids: %s", x["alias"], child_ids) if child_ids: for child in self.proto["system"]["get_sysinfo"]["children"]: @@ -314,7 +324,9 @@ def set_alias(self, x, child_ids=[]): else: self.proto["system"]["get_sysinfo"]["alias"] = x["alias"] - def set_relay_state(self, x, child_ids=[]): + def set_relay_state(self, x, child_ids=None): + if child_ids is None: + child_ids = [] _LOGGER.debug("Setting relay state to %s", x["state"]) if not child_ids and "children" in self.proto["system"]["get_sysinfo"]: @@ -362,12 +374,10 @@ def transition_light_state(self, state_changes, *args): _LOGGER.debug("Current light state: %s", light_state) new_state = light_state - if state_changes["on_off"] == 1: # turn on requested - if not light_state[ - "on_off" - ]: # if we were off, use the dft_on_state as a base - _LOGGER.debug("Bulb was off, using dft_on_state") - new_state = light_state["dft_on_state"] + # turn on requested, if we were off, use the dft_on_state as a base + if state_changes["on_off"] == 1 and not light_state["on_off"]: + _LOGGER.debug("Bulb was off, using dft_on_state") + new_state = light_state["dft_on_state"] # override the existing settings new_state.update(state_changes) @@ -384,7 +394,7 @@ def transition_light_state(self, state_changes, *args): self.proto["system"]["get_sysinfo"]["light_state"] = new_state def set_preferred_state(self, new_state, *args): - """Implementation of set_preferred_state.""" + """Implement set_preferred_state.""" self.proto["system"]["get_sysinfo"]["preferred_state"][ new_state["index"] ] = new_state @@ -459,11 +469,11 @@ async def query(self, request, port=9999): child_ids = [] def get_response_for_module(target): - if target not in proto.keys(): + if target not in proto: return error(msg="target not found") def get_response_for_command(cmd): - if cmd not in proto[target].keys(): + if cmd not in proto[target]: return error(msg=f"command {cmd} not found") params = request[target][cmd] diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 7f6fa0f23..009632d7b 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -156,7 +156,7 @@ async def _state(dev: SmartDevice): mocker.patch("kasa.cli.state", new=_state) # Get the type string parameter from the discovery_info - for cli_device_type in { + for cli_device_type in { # noqa: B007 i for i in TYPE_TO_CLASS if TYPE_TO_CLASS[i] == Discover._get_device_class(discovery_data) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 2aa10f1cc..3039f30cf 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -71,7 +71,7 @@ def mock_discover(self): x = await Discover.discover_single(host, port=custom_port) assert issubclass(x.__class__, SmartDevice) assert x._sys_info is not None - assert x.port == custom_port or 9999 + assert x.port == custom_port or x.port == 9999 @pytest.mark.parametrize("custom_port", [123, None]) @@ -82,7 +82,7 @@ async def test_connect_single(discovery_data: dict, mocker, custom_port): dev = await Discover.connect_single(host, port=custom_port) assert issubclass(dev.__class__, SmartDevice) - assert dev.port == custom_port or 9999 + assert dev.port == custom_port or dev.port == 9999 async def test_connect_single_query_fails(discovery_data: dict, mocker): diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index aeb1fe03e..f6f470b82 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -28,9 +28,10 @@ async def test_state_info(dev): @pytest.mark.requires_dummy async def test_invalid_connection(dev): - with patch.object(FakeTransportProtocol, "query", side_effect=SmartDeviceException): - with pytest.raises(SmartDeviceException): - await dev.update() + with patch.object( + FakeTransportProtocol, "query", side_effect=SmartDeviceException + ), pytest.raises(SmartDeviceException): + await dev.update() @has_emeter diff --git a/pyproject.toml b/pyproject.toml index f8adeeed4..b6604f3e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,12 +54,6 @@ coverage = {version = "*", extras = ["toml"]} docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] speedups = ["orjson", "kasa-crypt"] - -[tool.isort] -profile = "black" -known_first_party = "kasa" -known_third_party = ["asyncclick", "pytest", "setuptools", "voluptuous"] - [tool.coverage.run] source = ["kasa"] branch = true @@ -72,15 +66,6 @@ exclude_lines = [ "def __repr__" ] -[tool.interrogate] -ignore-init-method = true -ignore-magic = true -ignore-private = true -ignore-semiprivate = true -fail-under = 100 -exclude = ['kasa/tests/*'] -verbose = 2 - [tool.pytest.ini_options] markers = [ "requires_dummy: test requires dummy data to pass, skipped on real devices", @@ -95,3 +80,39 @@ ignore-path-errors = ["docs/source/index.rst;D000"] [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[tool.ruff] +target-version = "py38" +select = [ + "E", # pycodestyle + "D", # pydocstyle + "F", # pyflakes + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "I", # isort + "S", # bandit +] +ignore = [ + "D105", # Missing docstring in magic method + "D107", # Missing docstring in `__init__` +] + +[tool.ruff.pydocstyle] +convention = "pep257" + +[tool.ruff.per-file-ignores] +"kasa/tests/*.py" = [ + "D100", + "D101", + "D102", + "D103", + "D104", + "F401", + "S101", # allow asserts + "E501", # ignore line-too-longs +] +"docs/source/conf.py" = [ + "D100", + "D103", +] diff --git a/tox.ini b/tox.ini index 7cc957ed6..5843142c3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist=py37,py38,flake8,lint,coverage +envlist=py37,py38,lint,coverage skip_missing_interpreters = True isolated_build = True @@ -31,12 +31,6 @@ commands = coverage report coverage html -[testenv:flake8] -deps= - flake8 - flake8-docstrings -commands=flake8 kasa - [testenv:lint] deps = pre-commit skip_install = true From 87dcd4286117c4b41117803fae7b820a24b0fb5f Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 30 Oct 2023 00:22:30 +0100 Subject: [PATCH 175/892] Add python3.12 and pypy-3.10 to CI (#532) * Add python3.12 and pypy-3.10 to CI Also, cleanup the action file a bit: * Update action versions * Remove commented out yaml * Disable fail_ci_if_error for codecov * Fix typo --- .github/workflows/ci.yml | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f855248f..1dcd091eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: - python-version: ["3.11"] + python-version: ["3.12"] steps: - uses: "actions/checkout@v2" @@ -55,7 +55,7 @@ jobs: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "pypy-3.8"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.8", "pypy-3.10"] os: [ubuntu-latest, macos-latest, windows-latest] extras: [false, true] exclude: @@ -66,6 +66,9 @@ jobs: - os: ubuntu-latest python-version: "pypy-3.8" extras: true + - os: ubuntu-latest + python-version: "pypy-3.10" + extras: true - os: ubuntu-latest python-version: "3.8" extras: true @@ -75,16 +78,10 @@ jobs: - os: ubuntu-latest python-version: "3.10" extras: true - # exclude pypy on windows, as the poetry install seems to be very flaky: - # PermissionError(13, 'The process cannot access the file because it is being used by another process')) - # at C:\hostedtoolcache\windows\PyPy\3.7.10\x86\site-packages\requests\models.py:761 in generate - # exclude: - # - python-version: pypy-3.8 - # os: windows-latest steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v2" + - uses: "actions/checkout@v3" + - uses: "actions/setup-python@v4" with: python-version: "${{ matrix.python-version }}" - name: "Install dependencies (no speedups)" @@ -101,7 +98,6 @@ jobs: run: | poetry run pytest --cov kasa --cov-report xml - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v2" + uses: "codecov/codecov-action@v3" with: - fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} From 805e4b85884c1687cbd9158ddd5110b3205d19f2 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 30 Oct 2023 00:57:29 +0100 Subject: [PATCH 176/892] Release 0.5.4 (#536) The highlights of this maintenance release: * Support to the alternative discovery protocol and foundational work to support other communication protocols, thanks to @sdb9696. * Reliability improvements by avoiding overflowing device buffers, thanks to @cobryan05. * Optimizations for downstream device accesses, thanks to @bdraco. * Support for both pydantic v1 and v2. --- CHANGELOG.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b56fef2..b254daf07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,66 @@ # Changelog +## [0.5.4](https://github.com/python-kasa/python-kasa/tree/0.5.4) (2023-10-29) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.3...0.5.4) + +The highlights of this maintenance release: + +* Support to the alternative discovery protocol and foundational work to support other communication protocols, thanks to @sdb9696. +* Reliability improvements by avoiding overflowing device buffers, thanks to @cobryan05. +* Optimizations for downstream device accesses, thanks to @bdraco. +* Support for both pydantic v1 and v2. + +As always, see the full changelog for details. + +**Implemented enhancements:** + +- Add a connect\_single method to Discover to avoid the need for UDP [\#528](https://github.com/python-kasa/python-kasa/pull/528) (@bdraco) +- Parse features only during updates [\#527](https://github.com/python-kasa/python-kasa/pull/527) (@bdraco) +- Show an error if both --alias and --host are defined [\#513](https://github.com/python-kasa/python-kasa/pull/513) (@rytilahti) +- Add plumbing for passing credentials to devices [\#507](https://github.com/python-kasa/python-kasa/pull/507) (@sdb9696) +- Add support for pydantic v2 using v1 shims [\#504](https://github.com/python-kasa/python-kasa/pull/504) (@rytilahti) +- Split queries to avoid overflowing device buffers [\#502](https://github.com/python-kasa/python-kasa/pull/502) (@cobryan05) +- Add toggle command to cli [\#498](https://github.com/python-kasa/python-kasa/pull/498) (@normanr) +- Make timeout adjustable [\#494](https://github.com/python-kasa/python-kasa/pull/494) (@bdraco) +- Add support for alternative discovery protocol \(20002/udp\) [\#488](https://github.com/python-kasa/python-kasa/pull/488) (@sdb9696) +- Add discovery timeout parameter [\#486](https://github.com/python-kasa/python-kasa/pull/486) (@sdb9696) +- Add devtools script to create module fixtures [\#404](https://github.com/python-kasa/python-kasa/pull/404) (@rytilahti) + +**Fixed bugs:** + +- Fix on\_since for smartstrip sockets [\#529](https://github.com/python-kasa/python-kasa/pull/529) (@rytilahti) +- Fix every other query tries to fetch known unsupported features [\#520](https://github.com/python-kasa/python-kasa/pull/520) (@bdraco) + +**Documentation updates:** + +- Mark KS2{20}M as partially supported [\#508](https://github.com/python-kasa/python-kasa/pull/508) (@lschweiss) +- Document cli tool --target for discovery [\#497](https://github.com/python-kasa/python-kasa/pull/497) (@rytilahti) + +**Closed issues:** + +- Error running kasa command on the Raspberry PI [\#525](https://github.com/python-kasa/python-kasa/issues/525) +- Installation Problems \(Python Version?\) [\#523](https://github.com/python-kasa/python-kasa/issues/523) +- What are the units in the emeter readings? [\#514](https://github.com/python-kasa/python-kasa/issues/514) +- Set Alias via Command Line [\#511](https://github.com/python-kasa/python-kasa/issues/511) +- How do I know if my device supports emeter? [\#510](https://github.com/python-kasa/python-kasa/issues/510) +- Getting Invalid KeyError when getting sysinfo on an EP40 device [\#500](https://github.com/python-kasa/python-kasa/issues/500) +- Running kasa discover on subnet broadcasts only [\#496](https://github.com/python-kasa/python-kasa/issues/496) +- Failed to discover kasa switchs on the network [\#495](https://github.com/python-kasa/python-kasa/issues/495) +- \[Feature Request\] Add a toggle command [\#492](https://github.com/python-kasa/python-kasa/issues/492) +- \[Feature Request\] Pydantic 2.0+ Support [\#491](https://github.com/python-kasa/python-kasa/issues/491) +- Support for EP10 Plug [\#170](https://github.com/python-kasa/python-kasa/issues/170) + +**Merged pull requests:** + +- Use ruff and ruff format [\#534](https://github.com/python-kasa/python-kasa/pull/534) (@rytilahti) +- Add python3.12 and pypy-3.10 to CI [\#532](https://github.com/python-kasa/python-kasa/pull/532) (@rytilahti) +- Use trusted publisher for publishing to pypi [\#531](https://github.com/python-kasa/python-kasa/pull/531) (@rytilahti) +- Remove code to detect event loop change [\#526](https://github.com/python-kasa/python-kasa/pull/526) (@bdraco) +- Convert readthedocs config to v2 [\#505](https://github.com/python-kasa/python-kasa/pull/505) (@rytilahti) +- Add new HS100\(UK\) fixture [\#489](https://github.com/python-kasa/python-kasa/pull/489) (@sdb9696) +- Update pyproject.toml isort profile, dev group header and poetry.lock [\#487](https://github.com/python-kasa/python-kasa/pull/487) (@sdb9696) + ## [0.5.3](https://github.com/python-kasa/python-kasa/tree/0.5.3) (2023-07-23) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.2...0.5.3) @@ -16,6 +77,7 @@ This release adds support for defining the device port and introduces dependency **Merged pull requests:** +- Release 0.5.3 [\#485](https://github.com/python-kasa/python-kasa/pull/485) (@rytilahti) - Add tests for KP200 [\#483](https://github.com/python-kasa/python-kasa/pull/483) (@bdraco) - Update pyyaml to fix CI [\#482](https://github.com/python-kasa/python-kasa/pull/482) (@bdraco) diff --git a/pyproject.toml b/pyproject.toml index b6604f3e9..b41b242a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.5.3" +version = "0.5.4" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 26502982a0d41bf0be8cc2d825633cfbacd70bec Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Tue, 7 Nov 2023 01:15:41 +0000 Subject: [PATCH 177/892] Update discover single to handle hostnames (#539) --- kasa/discover.py | 41 ++++++++++++++++++++++++++++++------ kasa/tests/test_discovery.py | 26 +++++++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index b43df57b3..5b11bed5e 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -1,6 +1,7 @@ """Discovery module for TP-Link Smart Home devices.""" import asyncio import binascii +import ipaddress import logging import socket from typing import Awaitable, Callable, Dict, Optional, Type, cast @@ -273,9 +274,34 @@ async def discover_single( """ loop = asyncio.get_event_loop() event = asyncio.Event() + + try: + ipaddress.ip_address(host) + ip = host + except ValueError: + try: + adrrinfo = await loop.getaddrinfo( + host, + 0, + type=socket.SOCK_DGRAM, + family=socket.AF_INET, + ) + # getaddrinfo returns a list of 5 tuples with the following structure: + # (family, type, proto, canonname, sockaddr) + # where sockaddr is 2 tuple (ip, port). + # hence [0][4][0] is a stable array access because if no socket + # address matches the host for SOCK_DGRAM AF_INET the gaierror + # would be raised. + # https://docs.python.org/3/library/socket.html#socket.getaddrinfo + ip = adrrinfo[0][4][0] + except socket.gaierror as gex: + raise SmartDeviceException( + f"Could not resolve hostname {host}" + ) from gex + transport, protocol = await loop.create_datagram_endpoint( lambda: _DiscoverProtocol( - target=host, + target=ip, port=port, discovered_event=event, credentials=credentials, @@ -297,16 +323,17 @@ async def discover_single( finally: transport.close() - if host in protocol.discovered_devices: - dev = protocol.discovered_devices[host] + if ip in protocol.discovered_devices: + dev = protocol.discovered_devices[ip] + dev.host = host await dev.update() return dev - elif host in protocol.unsupported_devices: + elif ip in protocol.unsupported_devices: raise UnsupportedDeviceException( - f"Unsupported device {host}: {protocol.unsupported_devices[host]}" + f"Unsupported device {host}: {protocol.unsupported_devices[ip]}" ) - elif host in protocol.invalid_device_exceptions: - raise protocol.invalid_device_exceptions[host] + elif ip in protocol.invalid_device_exceptions: + raise protocol.invalid_device_exceptions[ip] else: raise SmartDeviceException(f"Unable to get discovery response for {host}") diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 3039f30cf..7aeabe2fc 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -1,5 +1,6 @@ # type: ignore import re +import socket import sys import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 @@ -74,6 +75,31 @@ def mock_discover(self): assert x.port == custom_port or x.port == 9999 +async def test_discover_single_hostname(discovery_data: dict, mocker): + """Make sure that discover_single returns an initialized SmartDevice instance.""" + host = "foobar" + ip = "127.0.0.1" + + def mock_discover(self): + self.datagram_received( + protocol.TPLinkSmartHomeProtocol.encrypt(json_dumps(discovery_data))[4:], + (ip, 9999), + ) + + mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) + mocker.patch("socket.getaddrinfo", return_value=[(None, None, None, None, (ip, 0))]) + + x = await Discover.discover_single(host) + assert issubclass(x.__class__, SmartDevice) + assert x._sys_info is not None + assert x.host == host + + mocker.patch("socket.getaddrinfo", side_effect=socket.gaierror()) + with pytest.raises(SmartDeviceException): + x = await Discover.discover_single(host) + + @pytest.mark.parametrize("custom_port", [123, None]) async def test_connect_single(discovery_data: dict, mocker, custom_port): """Make sure that connect_single returns an initialized SmartDevice instance.""" From bde07d117fcae6170bdb4d8b375f138aae6fde60 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 7 Nov 2023 02:15:57 +0100 Subject: [PATCH 178/892] Add some more external links to README (#541) This updates the README to include more resources for developers intersted as well as some tapo-related links. --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 97525a283..f65747782 100644 --- a/README.md +++ b/README.md @@ -184,15 +184,24 @@ If your device is unlisted but working, please open a pull request to update the ## Resources -### Links +### Developer Resources -* [pyHS100](https://github.com/GadgetReactor/pyHS100) provides synchronous interface and is the unmaintained predecessor of this library. * [softScheck's github contains lot of information and wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) * [TP-Link Smart Home Device Simulator](https://github.com/plasticrake/tplink-smarthome-simulator) * [Unofficial API documentation](https://github.com/plasticrake/tplink-smarthome-api) +* [Another unofficial API documentation](https://github.com/whitslack/kasa) +* [pyHS100](https://github.com/GadgetReactor/pyHS100) provides synchronous interface and is the unmaintained predecessor of this library. + + +### Library Users + +* [Home Assistant](https://www.home-assistant.io/integrations/tplink/) * [MQTT access to TP-Link devices, using python-kasa](https://github.com/flavio-fernandes/mqtt2kasa) ### TP-Link Tapo support +* [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) * [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) From 30f217b8ab2f4970dac313244956181cc97debc6 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Mon, 20 Nov 2023 13:17:10 +0000 Subject: [PATCH 179/892] Add klap protocol (#509) * Add support for the new encryption protocol This adds support for the new TP-Link discovery and encryption protocols. It is currently incomplete - only devices without username and password are current supported, and single device discovery is not implemented. Discovery should find both old and new devices. When accessing a device by IP the --klap option can be specified on the command line to active the new connection protocol. sdb9696 - This commit also contains 16 later commits from Simon Wilkinson squashed into the original * Update klap changes 2023 to fix encryption, deal with kasa credential switching and work with new discovery changes * Move from aiohttp to httpx * Changes following review comments --------- Co-authored-by: Simon Wilkinson --- kasa/__init__.py | 5 +- kasa/cli.py | 23 +- kasa/discover.py | 161 +++++++++-- kasa/klapprotocol.py | 485 ++++++++++++++++++++++++++++++++ kasa/protocol.py | 41 ++- kasa/smartdevice.py | 49 ++-- kasa/tests/test_discovery.py | 76 ++++- kasa/tests/test_klapprotocol.py | 306 ++++++++++++++++++++ poetry.lock | 212 +++++++++++++- pyproject.toml | 4 +- 10 files changed, 1297 insertions(+), 65 deletions(-) create mode 100755 kasa/klapprotocol.py create mode 100644 kasa/tests/test_klapprotocol.py diff --git a/kasa/__init__.py b/kasa/__init__.py index 4ccf6286b..989e507f2 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -21,7 +21,8 @@ SmartDeviceException, UnsupportedDeviceException, ) -from kasa.protocol import TPLinkSmartHomeProtocol +from kasa.klapprotocol import TPLinkKlap +from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.smartdevice import DeviceType, SmartDevice from kasa.smartdimmer import SmartDimmer @@ -35,6 +36,8 @@ __all__ = [ "Discover", "TPLinkSmartHomeProtocol", + "TPLinkProtocol", + "TPLinkKlap", "SmartBulb", "SmartBulbPreset", "TurnOnBehaviors", diff --git a/kasa/cli.py b/kasa/cli.py index 3bc779346..7280dd330 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -11,6 +11,7 @@ import asyncclick as click from kasa import ( + AuthenticationException, Credentials, Discover, SmartBulb, @@ -308,8 +309,9 @@ async def discover(ctx, timeout, show_unsupported): sem = asyncio.Semaphore() discovered = dict() unsupported = [] + auth_failed = [] - async def print_unsupported(data: Dict): + async def print_unsupported(data: str): unsupported.append(data) if show_unsupported: echo(f"Found unsupported device (tapo/unknown encryption): {data}") @@ -318,12 +320,15 @@ async def print_unsupported(data: Dict): echo(f"Discovering devices on {target} for {timeout} seconds") async def print_discovered(dev: SmartDevice): - await dev.update() - async with sem: - discovered[dev.host] = dev.internal_state - ctx.obj = dev - await ctx.invoke(state) - echo() + try: + await dev.update() + async with sem: + discovered[dev.host] = dev.internal_state + ctx.obj = dev + await ctx.invoke(state) + echo() + except AuthenticationException as aex: + auth_failed.append(str(aex)) await Discover.discover( target=target, @@ -343,6 +348,10 @@ async def print_discovered(dev: SmartDevice): else ", to show them use: kasa discover --show-unsupported" ) ) + if auth_failed: + echo(f"Found {len(auth_failed)} devices that failed to authenticate") + for fail in auth_failed: + echo(fail) return discovered diff --git a/kasa/discover.py b/kasa/discover.py index 5b11bed5e..9625f7c38 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -4,17 +4,23 @@ import ipaddress import logging import socket -from typing import Awaitable, Callable, Dict, Optional, Type, cast +from typing import Awaitable, Callable, Dict, Optional, Set, Type, cast # 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 +try: + from pydantic.v1 import BaseModel, Field +except ImportError: + from pydantic import BaseModel, Field + from kasa.credentials import Credentials from kasa.exceptions import UnsupportedDeviceException from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads -from kasa.protocol import TPLinkSmartHomeProtocol +from kasa.klapprotocol import TPLinkKlap +from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb from kasa.smartdevice import SmartDevice, SmartDeviceException from kasa.smartdimmer import SmartDimmer @@ -44,7 +50,7 @@ def __init__( target: str = "255.255.255.255", discovery_packets: int = 3, interface: Optional[str] = None, - on_unsupported: Optional[Callable[[Dict], Awaitable[None]]] = None, + on_unsupported: Optional[Callable[[str], Awaitable[None]]] = None, port: Optional[int] = None, discovered_event: Optional[asyncio.Event] = None, credentials: Optional[Credentials] = None, @@ -64,6 +70,7 @@ def __init__( self.discovered_event = discovered_event self.credentials = credentials self.timeout = timeout + self.seen_hosts: Set[str] = set() def connection_made(self, transport) -> None: """Set socket options for broadcasting.""" @@ -95,43 +102,36 @@ def do_discover(self) -> None: def datagram_received(self, data, addr) -> None: """Handle discovery responses.""" ip, port = addr - if ( - ip in self.discovered_devices - or ip in self.unsupported_devices - or ip in self.invalid_device_exceptions - ): + # Prevent multiple entries due multiple broadcasts + if ip in self.seen_hosts: return + self.seen_hosts.add(ip) - if port == self.discovery_port: - info = json_loads(TPLinkSmartHomeProtocol.decrypt(data)) - _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) - - elif port == Discover.DISCOVERY_PORT_2: - info = json_loads(data[16:]) - self.unsupported_devices[ip] = info + device = None + try: + if port == self.discovery_port: + device = Discover._get_device_instance_legacy(data, ip, port) + elif port == Discover.DISCOVERY_PORT_2: + device = Discover._get_device_instance( + data, ip, port, self.credentials or Credentials() + ) + else: + return + except UnsupportedDeviceException as udex: + _LOGGER.debug("Unsupported device found at %s << %s", ip, udex) + self.unsupported_devices[ip] = str(udex) if self.on_unsupported is not None: - asyncio.ensure_future(self.on_unsupported(info)) - _LOGGER.debug("[DISCOVERY] Unsupported device found at %s << %s", ip, info) + asyncio.ensure_future(self.on_unsupported(str(udex))) if self.discovered_event is not None: self.discovered_event.set() return - - try: - device_class = Discover._get_device_class(info) except SmartDeviceException as ex: - _LOGGER.debug( - "[DISCOVERY] Unable to find device type from %s: %s", info, ex - ) + _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() return - device = device_class( - ip, port=port, credentials=self.credentials, timeout=self.timeout - ) - device.update_from_discover_info(info) - self.discovered_devices[ip] = device if self.on_discovered is not None: @@ -269,6 +269,10 @@ async def discover_single( 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 credentials: Optionally provide credentials for + devices requiring them :rtype: SmartDevice :return: Object for querying/controlling found device. """ @@ -344,6 +348,7 @@ async def connect_single( port: Optional[int] = None, timeout=5, credentials: Optional[Credentials] = None, + protocol_class: Optional[Type[TPLinkProtocol]] = None, ) -> SmartDevice: """Connect to a single device by the given IP address. @@ -358,12 +363,20 @@ async def connect_single( The device type is discovered by querying the device. :param host: Hostname of device to query + :param port: Optionally set a different port for the device + :param timeout: Timeout for discovery + :param credentials: Optionally provide credentials for + devices requiring them + :param protocol_class: Optionally provide the protocol class + to use. :rtype: SmartDevice :return: Object for querying/controlling found device. """ unknown_dev = SmartDevice( host=host, port=port, credentials=credentials, timeout=timeout ) + if protocol_class is not None: + unknown_dev.protocol = protocol_class(host, credentials=credentials) await unknown_dev.update() device_class = Discover._get_device_class(unknown_dev.internal_state) dev = device_class( @@ -399,5 +412,95 @@ def _get_device_class(info: dict) -> Type[SmartDevice]: return SmartLightStrip return SmartBulb + raise UnsupportedDeviceException("Unknown device type: %s" % type_) + + @staticmethod + def _get_device_instance_legacy(data: bytes, ip: str, port: int) -> SmartDevice: + """Get SmartDevice from legacy 9999 response.""" + try: + info = json_loads(TPLinkSmartHomeProtocol.decrypt(data)) + except Exception as ex: + raise SmartDeviceException( + f"Unable to read response from device: {ip}: {ex}" + ) from ex + + _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) + + device_class = Discover._get_device_class(info) + device = device_class(ip, port=port) + device.update_from_discover_info(info) + return device - raise SmartDeviceException("Unknown device type: %s" % type_) + @staticmethod + def _get_device_instance( + data: bytes, ip: str, port: int, credentials: Credentials + ) -> SmartDevice: + """Get SmartDevice from the new 20002 response.""" + try: + info = json_loads(data[16:]) + discovery_result = DiscoveryResult(**info["result"]) + except Exception as ex: + raise UnsupportedDeviceException( + f"Unable to read response from device: {ip}: {ex}" + ) from ex + + if ( + discovery_result.mgt_encrypt_schm.encrypt_type == "KLAP" + and discovery_result.mgt_encrypt_schm.lv is None + ): + type_ = discovery_result.device_type + device_class = None + if type_.upper() == "IOT.SMARTPLUGSWITCH": + device_class = SmartPlug + + if device_class: + _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) + device = device_class(ip, port=port, credentials=credentials) + device.update_from_discover_info(discovery_result.get_dict()) + device.protocol = TPLinkKlap(ip, credentials=credentials) + return device + else: + raise UnsupportedDeviceException( + f"Unsupported device {ip} of type {type_}: {info}" + ) + else: + raise UnsupportedDeviceException(f"Unsupported device {ip}: {info}") + + +class DiscoveryResult(BaseModel): + """Base model for discovery result.""" + + class Config: + """Class for configuring model behaviour.""" + + allow_population_by_field_name = True + + class EncryptionScheme(BaseModel): + """Base model for encryption scheme of discovery result.""" + + is_support_https: Optional[bool] = None + encrypt_type: Optional[str] = None + http_port: Optional[int] = None + lv: Optional[int] = None + + device_type: str = Field(alias="device_type_text") + device_model: str = Field(alias="model") + ip: str = Field(alias="alias") + mac: str + mgt_encrypt_schm: EncryptionScheme + + device_id: Optional[str] = Field(default=None, alias="device_type_hash") + owner: Optional[str] = Field(default=None, alias="device_owner_hash") + hw_ver: Optional[str] = None + is_support_iot_cloud: Optional[bool] = None + obd_src: Optional[str] = None + factory_default: Optional[bool] = None + + def get_dict(self) -> dict: + """Return a dict for this discovery result. + + containing only the values actually set and with aliases as field names. + """ + return self.dict( + by_alias=True, exclude_unset=True, exclude_none=True, exclude_defaults=True + ) diff --git a/kasa/klapprotocol.py b/kasa/klapprotocol.py new file mode 100755 index 000000000..36a42c589 --- /dev/null +++ b/kasa/klapprotocol.py @@ -0,0 +1,485 @@ +"""Implementation of the TP-Link Klap Home Protocol. + +Encryption/Decryption methods based on the works of +Simon Wilkinson and Chris Weeldon + +Klap devices that have never been connected to the kasa +cloud should work with blank credentials. +Devices that have been connected to the kasa cloud will +switch intermittently between the users cloud credentials +and default kasa credentials that are hardcoded. +This appears to be an issue with the devices. + +The protocol works by doing a two stage handshake to obtain +and encryption key and session id cookie. + +Authentication uses an auth_hash which is +md5(md5(username),md5(password)) + +handshake1: client sends a random 16 byte local_seed to the +device and receives a random 16 bytes remote_seed, followed +by sha256(local_seed + auth_hash). It also returns a +TP_SESSIONID in the cookie header. This implementation +then checks this value against the possible auth_hashes +described above (user cloud, kasa hardcoded, blank). If it +finds a match it moves onto handshake2 + +handshake2: client sends sha25(remote_seed + auth_hash) to +the device along with the TP_SESSIONID. Device responds with +200 if succesful. It generally will be because this +implemenation checks the auth_hash it recevied during handshake1 + +encryption: local_seed, remote_seed and auth_hash are now used +for encryption. The last 4 bytes of the initialisation vector +are used as a sequence number that increments every time the +client calls encrypt and this sequence number is sent as a +url parameter to the device along with the encrypted payload + +https://gist.github.com/chriswheeldon/3b17d974db3817613c69191c0480fe55 +https://github.com/python-kasa/python-kasa/pull/117 + +""" + +import asyncio +import datetime +import hashlib +import logging +import secrets +import time +from pprint import pformat as pf +from typing import Any, Dict, Optional, Tuple, Union + +import httpx +from cryptography.hazmat.primitives import hashes, padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from .credentials import Credentials +from .exceptions import AuthenticationException, SmartDeviceException +from .json import dumps as json_dumps +from .json import loads as json_loads +from .protocol import TPLinkProtocol + +_LOGGER = logging.getLogger(__name__) +logging.getLogger("httpx").propagate = False + + +def _sha256(payload: bytes) -> bytes: + return hashlib.sha256(payload).digest() + + +def _md5(payload: bytes) -> bytes: + digest = hashes.Hash(hashes.MD5()) # noqa: S303 + digest.update(payload) + hash = digest.finalize() + return hash + + +class TPLinkKlap(TPLinkProtocol): + """Implementation of the KLAP encryption protocol. + + KLAP is the name used in device discovery for TP-Link's new encryption + protocol, used by newer firmware versions. + """ + + DEFAULT_PORT = 80 + DEFAULT_TIMEOUT = 5 + DISCOVERY_QUERY = {"system": {"get_sysinfo": None}} + KASA_SETUP_EMAIL = "kasa@tp-link.net" + KASA_SETUP_PASSWORD = "kasaSetup" # noqa: S105 + SESSION_COOKIE_NAME = "TP_SESSIONID" + + def __init__( + self, + host: str, + *, + credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, + ) -> None: + super().__init__(host=host, port=self.DEFAULT_PORT) + + self.credentials = ( + credentials + if credentials and credentials.username and credentials.password + else Credentials(username="", password="") + ) + + self._local_seed: Optional[bytes] = None + self.local_auth_hash = self.generate_auth_hash(self.credentials) + self.local_auth_owner = self.generate_owner_hash(self.credentials).hex() + self.kasa_setup_auth_hash = None + self.blank_auth_hash = None + self.handshake_lock = asyncio.Lock() + self.query_lock = asyncio.Lock() + self.handshake_done = False + + self.encryption_session: Optional[KlapEncryptionSession] = None + self.session_expire_at: Optional[float] = None + + self.timeout = timeout if timeout else self.DEFAULT_TIMEOUT + self.session_cookie = None + self.http_client: Optional[httpx.AsyncClient] = None + + _LOGGER.debug("Created KLAP object for %s", self.host) + + async def client_post(self, url, params=None, data=None): + """Send an http post request to the device.""" + response_data = None + cookies = None + if self.session_cookie: + cookies = httpx.Cookies() + cookies.set(self.SESSION_COOKIE_NAME, self.session_cookie) + self.http_client.cookies.clear() + resp = await self.http_client.post( + url, + params=params, + data=data, + timeout=self.timeout, + cookies=cookies, + ) + if resp.status_code == 200: + response_data = resp.content + + return resp.status_code, response_data + + async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: + """Perform handshake1.""" + local_seed: bytes = secrets.token_bytes(16) + + # Handshake 1 has a payload of local_seed + # and a response of 16 bytes, followed by + # sha256(remote_seed | auth_hash) + + payload = local_seed + + url = f"http://{self.host}/app/handshake1" + + response_status, response_data = await self.client_post(url, data=payload) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Handshake1 posted at %s. Host is %s, Response" + + "status is %s, Request was %s", + datetime.datetime.now(), + self.host, + response_status, + payload.hex(), + ) + + if response_status != 200: + raise AuthenticationException( + f"Device {self.host} responded with {response_status} to handshake1" + ) + + remote_seed: bytes = response_data[0:16] + server_hash = response_data[16:] + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Handshake1 success at %s. Host is %s, " + + "Server remote_seed is: %s, server hash is: %s", + datetime.datetime.now(), + self.host, + remote_seed.hex(), + server_hash.hex(), + ) + + local_seed_auth_hash = _sha256(local_seed + self.local_auth_hash) + + # Check the response from the device with local credentials + if local_seed_auth_hash == server_hash: + _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=TPLinkKlap.KASA_SETUP_EMAIL, + password=TPLinkKlap.KASA_SETUP_PASSWORD, + ) + self.kasa_setup_auth_hash = TPLinkKlap.generate_auth_hash(kasa_setup_creds) + + kasa_setup_seed_auth_hash = _sha256( + local_seed + self.kasa_setup_auth_hash # type: ignore + ) + 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, + ) + return local_seed, remote_seed, self.kasa_setup_auth_hash # type: ignore + + # Finally check against blank credentials if not already blank + if self.credentials != (blank_creds := Credentials(username="", password="")): + if not self.blank_auth_hash: + self.blank_auth_hash = TPLinkKlap.generate_auth_hash(blank_creds) + blank_seed_auth_hash = _sha256(local_seed + self.blank_auth_hash) # type: ignore + if blank_seed_auth_hash == server_hash: + _LOGGER.debug( + "Server response doesn't match our expected hash on ip %s" + + " but an authentication with blank credentials matched", + self.host, + ) + return local_seed, remote_seed, self.blank_auth_hash # type: ignore + + msg = f"Server response doesn't match our challenge on ip {self.host}" + _LOGGER.debug(msg) + raise AuthenticationException(msg) + + async def perform_handshake2( + self, local_seed, remote_seed, auth_hash + ) -> "KlapEncryptionSession": + """Perform handshake2.""" + # Handshake 2 has the following payload: + # sha256(serverBytes | authenticator) + + url = f"http://{self.host}/app/handshake2" + + payload = _sha256(remote_seed + auth_hash) + + response_status, response_data = await self.client_post(url, data=payload) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Handshake2 posted %s. Host is %s, Response status is %s, " + + "Request was %s", + datetime.datetime.now(), + self.host, + response_status, + payload.hex(), + ) + + if response_status != 200: + raise AuthenticationException( + f"Device {self.host} responded with {response_status} to handshake2" + ) + + return KlapEncryptionSession(local_seed, remote_seed, auth_hash) + + async def perform_handshake(self) -> Any: + """Perform handshake1 and handshake2. + + Sets the encryption_session if successful. + """ + _LOGGER.debug("Starting handshake with %s", self.host) + self.handshake_done = False + self.session_expire_at = None + self.session_cookie = None + + local_seed, remote_seed, auth_hash = await self.perform_handshake1() + self.session_cookie = self.http_client.cookies.get( # type: ignore + TPLinkKlap.SESSION_COOKIE_NAME + ) + # 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 + self.encryption_session = await self.perform_handshake2( + local_seed, remote_seed, auth_hash + ) + self.handshake_done = True + + _LOGGER.debug("Handshake with %s complete", self.host) + + def handshake_session_expired(self): + """Return true if session has expired.""" + return ( + self.session_expire_at is None or self.session_expire_at - time.time() <= 0 + ) + + @staticmethod + def generate_auth_hash(creds: Credentials): + """Generate an md5 auth hash for the protocol on the supplied credentials.""" + un = creds.username or "" + pw = creds.password or "" + return _md5(_md5(un.encode()) + _md5(pw.encode())) + + @staticmethod + def generate_owner_hash(creds: Credentials): + """Return the MD5 hash of the username in this object.""" + un = creds.username or "" + return _md5(un.encode()) + + async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: + """Query the device retrying for retry_count on failure.""" + if isinstance(request, dict): + request = json_dumps(request) + assert isinstance(request, str) # noqa: S101 + + async with self.query_lock: + return await self._query(request, retry_count) + + async def _query(self, request: str, retry_count: int = 3) -> Dict: + for retry in range(retry_count + 1): + try: + return await self._execute_query(request, retry) + except httpx.CloseError as sdex: + await self.close() + if retry >= retry_count: + _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {sdex}" + ) from sdex + continue + except httpx.ConnectError as cex: + await self.close() + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {cex}" + ) from cex + except TimeoutError as tex: + await self.close() + raise SmartDeviceException( + f"Unable to connect to the device, timed out: {self.host}: {tex}" + ) from tex + except AuthenticationException as auex: + _LOGGER.debug("Unable to authenticate with %s, not retrying", self.host) + raise auex + except Exception as ex: + await self.close() + if retry >= retry_count: + _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {ex}" + ) from ex + continue + + # make mypy happy, this should never be reached.. + raise SmartDeviceException("Query reached somehow to unreachable") + + async def _execute_query(self, request: str, retry_count: int) -> Dict: + if not self.http_client: + self.http_client = httpx.AsyncClient() + + if not self.handshake_done or self.handshake_session_expired(): + try: + await self.perform_handshake() + + except AuthenticationException as auex: + _LOGGER.debug( + "Unable to complete handshake for device %s, " + + "authentication failed", + self.host, + ) + raise auex + + # Check for mypy + if self.encryption_session is not None: + payload, seq = self.encryption_session.encrypt(request.encode()) + + url = f"http://{self.host}/app/request" + + response_status, response_data = await self.client_post( + url, + params={"seq": seq}, + data=payload, + ) + + msg = ( + f"at {datetime.datetime.now()}. Host is {self.host}, " + + f"Retry count is {retry_count}, Sequence is {seq}, " + + f"Response status is {response_status}, Request was {request}" + ) + if response_status != 200: + _LOGGER.error("Query failed after succesful authentication " + msg) + # If we failed with a security error, force a new handshake next time. + if response_status == 403: + self.handshake_done = False + raise AuthenticationException( + f"Got a security error from {self.host} after handshake " + + "completed" + ) + else: + raise SmartDeviceException( + f"Device {self.host} responded with {response_status} to" + + f"request with seq {seq}" + ) + else: + _LOGGER.debug("Query posted " + msg) + + # Check for mypy + if self.encryption_session is not None: + decrypted_response = self.encryption_session.decrypt(response_data) + + json_payload = json_loads(decrypted_response) + + _LOGGER.debug( + "%s << %s", + self.host, + _LOGGER.isEnabledFor(logging.DEBUG) and pf(json_payload), + ) + + return json_payload + + async def close(self) -> None: + """Close the protocol.""" + client = self.http_client + self.http_client = None + if client: + await client.aclose() + + +class KlapEncryptionSession: + """Class to represent an encryption session and it's internal state. + + i.e. sequence number which the device expects to increment. + """ + + def __init__(self, local_seed, remote_seed, user_hash): + self.local_seed = local_seed + self.remote_seed = remote_seed + self.user_hash = user_hash + self._key = self._key_derive(local_seed, remote_seed, user_hash) + (self._iv, self._seq) = self._iv_derive(local_seed, remote_seed, user_hash) + self._sig = self._sig_derive(local_seed, remote_seed, user_hash) + + def _key_derive(self, local_seed, remote_seed, user_hash): + payload = b"lsk" + local_seed + remote_seed + user_hash + return hashlib.sha256(payload).digest()[:16] + + def _iv_derive(self, local_seed, remote_seed, user_hash): + # iv is first 16 bytes of sha256, where the last 4 bytes forms the + # sequence number used in requests and is incremented on each request + payload = b"iv" + local_seed + remote_seed + user_hash + fulliv = hashlib.sha256(payload).digest() + seq = int.from_bytes(fulliv[-4:], "big", signed=True) + return (fulliv[:12], seq) + + def _sig_derive(self, local_seed, remote_seed, user_hash): + # used to create a hash with which to prefix each request + payload = b"ldk" + local_seed + remote_seed + user_hash + return hashlib.sha256(payload).digest()[:28] + + def _iv_seq(self): + seq = self._seq.to_bytes(4, "big", signed=True) + iv = self._iv + seq + return iv + + def encrypt(self, msg): + """Encrypt the data and increment the sequence number.""" + self._seq = self._seq + 1 + if isinstance(msg, str): + msg = msg.encode("utf-8") + + cipher = Cipher(algorithms.AES(self._key), modes.CBC(self._iv_seq())) + encryptor = cipher.encryptor() + padder = padding.PKCS7(128).padder() + padded_data = padder.update(msg) + padder.finalize() + ciphertext = encryptor.update(padded_data) + encryptor.finalize() + + digest = hashes.Hash(hashes.SHA256()) + digest.update( + self._sig + self._seq.to_bytes(4, "big", signed=True) + ciphertext + ) + signature = digest.finalize() + + return (signature + ciphertext, self._seq) + + def decrypt(self, msg): + """Decrypt the data.""" + cipher = Cipher(algorithms.AES(self._key), modes.CBC(self._iv_seq())) + decryptor = cipher.decryptor() + dp = decryptor.update(msg[32:]) + decryptor.finalize() + unpadder = padding.PKCS7(128).unpadder() + plaintextbytes = unpadder.update(dp) + unpadder.finalize() + + return plaintextbytes.decode() diff --git a/kasa/protocol.py b/kasa/protocol.py index 7ab2c47fa..c49ab2239 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -14,6 +14,7 @@ import errno import logging import struct +from abc import ABC, abstractmethod from pprint import pformat as pf from typing import Dict, Generator, Optional, Union @@ -21,6 +22,7 @@ # async_timeout can be replaced with asyncio.timeout from async_timeout import timeout as asyncio_timeout +from .credentials import Credentials from .exceptions import SmartDeviceException from .json import dumps as json_dumps from .json import loads as json_loads @@ -29,7 +31,31 @@ _NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} -class TPLinkSmartHomeProtocol: +class TPLinkProtocol(ABC): + """Base class for all TP-Link Smart Home communication.""" + + def __init__( + self, + host: str, + *, + port: Optional[int] = None, + credentials: Optional[Credentials] = None, + ) -> None: + """Create a protocol object.""" + self.host = host + self.port = port + self.credentials = credentials + + @abstractmethod + async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: + """Query the device for the protocol. Abstract method to be overriden.""" + + @abstractmethod + async def close(self) -> None: + """Close the protocol. Abstract method to be overriden.""" + + +class TPLinkSmartHomeProtocol(TPLinkProtocol): """Implementation of the TP-Link Smart Home protocol.""" INITIALIZATION_VECTOR = 171 @@ -38,11 +64,18 @@ class TPLinkSmartHomeProtocol: BLOCK_SIZE = 4 def __init__( - self, host: str, *, port: Optional[int] = None, timeout: Optional[int] = None + self, + host: str, + *, + port: Optional[int] = None, + timeout: Optional[int] = None, + credentials: Optional[Credentials] = None, ) -> None: """Create a protocol object.""" - self.host = host - self.port = port or TPLinkSmartHomeProtocol.DEFAULT_PORT + super().__init__( + host=host, port=port or self.DEFAULT_PORT, credentials=credentials + ) + self.reader: Optional[asyncio.StreamReader] = None self.writer: Optional[asyncio.StreamWriter] = None self.query_lock = asyncio.Lock() diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 3e9bd9532..1ae86b4f5 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -24,7 +24,7 @@ from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException from .modules import Emeter, Module -from .protocol import TPLinkSmartHomeProtocol +from .protocol import TPLinkProtocol, TPLinkSmartHomeProtocol _LOGGER = logging.getLogger(__name__) @@ -71,7 +71,7 @@ def requires_update(f): @functools.wraps(f) async def wrapped(*args, **kwargs): self = args[0] - if self._last_update is None: + if self._last_update is None and f.__name__ not in self._sys_info: raise SmartDeviceException( "You need to await update() to access the data" ) @@ -82,7 +82,7 @@ async def wrapped(*args, **kwargs): @functools.wraps(f) def wrapped(*args, **kwargs): self = args[0] - if self._last_update is None: + if self._last_update is None and f.__name__ not in self._sys_info: raise SmartDeviceException( "You need to await update() to access the data" ) @@ -213,8 +213,9 @@ def __init__( """ self.host = host self.port = port - - self.protocol = TPLinkSmartHomeProtocol(host, port=port, timeout=timeout) + self.protocol: TPLinkProtocol = TPLinkSmartHomeProtocol( + host, port=port, timeout=timeout + ) self.credentials = credentials _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) self._device_type = DeviceType.Unknown @@ -222,6 +223,7 @@ def __init__( # checks in accessors. the @updated_required decorator does not ensure # mypy that these are not accessed incorrectly. self._last_update: Any = None + self._sys_info: Any = None # TODO: this is here to avoid changing tests self._features: Set[str] = set() self.modules: Dict[str, Any] = {} @@ -374,8 +376,14 @@ async def _modular_update(self, req: dict) -> None: def update_from_discover_info(self, info: Dict[str, Any]) -> None: """Update state from info from the discover call.""" - self._last_update = info - self._set_sys_info(info["system"]["get_sysinfo"]) + if "system" in info and (sys_info := info["system"].get("get_sysinfo")): + self._last_update = info + self._set_sys_info(sys_info) + else: + # This allows setting of some info properties directly + # from partial discovery info that will then be found + # by the requires_update decorator + self._set_sys_info(info) def _set_sys_info(self, sys_info: Dict[str, Any]) -> None: """Set sys_info.""" @@ -388,21 +396,26 @@ def _set_sys_info(self, sys_info: Dict[str, Any]) -> None: @property # type: ignore @requires_update def sys_info(self) -> Dict[str, Any]: - """Return system information.""" + """ + Return system information. + + Do not call this function from within the SmartDevice + class itself as @requires_update will be affected for other properties. + """ return self._sys_info # type: ignore @property # type: ignore @requires_update def model(self) -> str: """Return device model.""" - sys_info = self.sys_info + sys_info = self._sys_info return str(sys_info["model"]) @property # type: ignore @requires_update def alias(self) -> str: """Return device name (alias).""" - sys_info = self.sys_info + sys_info = self._sys_info return str(sys_info["alias"]) async def set_alias(self, alias: str) -> None: @@ -454,14 +467,14 @@ def hw_info(self) -> Dict: "oemId", "dev_name", ] - sys_info = self.sys_info + sys_info = self._sys_info return {key: sys_info[key] for key in keys if key in sys_info} @property # type: ignore @requires_update def location(self) -> Dict: """Return geographical location.""" - sys_info = self.sys_info + sys_info = self._sys_info loc = {"latitude": None, "longitude": None} if "latitude" in sys_info and "longitude" in sys_info: @@ -479,7 +492,7 @@ def location(self) -> Dict: @requires_update def rssi(self) -> Optional[int]: """Return WiFi signal strength (rssi).""" - rssi = self.sys_info.get("rssi") + rssi = self._sys_info.get("rssi") return None if rssi is None else int(rssi) @property # type: ignore @@ -489,14 +502,14 @@ def mac(self) -> str: :return: mac address in hexadecimal with colons, e.g. 01:23:45:67:89:ab """ - sys_info = self.sys_info - + sys_info = self._sys_info mac = sys_info.get("mac", sys_info.get("mic_mac")) if not mac: raise SmartDeviceException( "Unknown mac, please submit a bug report with sys_info output." ) - + mac = mac.replace("-", ":") + # Format a mac that has no colons (usually from mic_mac field) if ":" not in mac: mac = ":".join(format(s, "02x") for s in bytes.fromhex(mac)) @@ -607,13 +620,13 @@ def is_on(self) -> bool: @requires_update def on_since(self) -> Optional[datetime]: """Return pretty-printed on-time, or None if not available.""" - if "on_time" not in self.sys_info: + if "on_time" not in self._sys_info: return None if self.is_off: return None - on_time = self.sys_info["on_time"] + on_time = self._sys_info["on_time"] return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 7aeabe2fc..148e567c2 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -6,8 +6,8 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import DeviceType, Discover, SmartDevice, SmartDeviceException, protocol -from kasa.discover import _DiscoverProtocol, json_dumps -from kasa.exceptions import UnsupportedDeviceException +from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps +from kasa.exceptions import AuthenticationException, UnsupportedDeviceException from .conftest import bulb, dimmer, lightstrip, plug, strip @@ -51,7 +51,7 @@ async def test_type_detection_lightstrip(dev: SmartDevice): async def test_type_unknown(): invalid_info = {"system": {"get_sysinfo": {"type": "nosuchtype"}}} - with pytest.raises(SmartDeviceException): + with pytest.raises(UnsupportedDeviceException): Discover._get_device_class(invalid_info) @@ -239,3 +239,73 @@ async def test_discover_invalid_responses(msg, data, mocker): proto.datagram_received(data, ("127.0.0.1", 9999)) assert len(proto.discovered_devices) == 0 + + +AUTHENTICATION_DATA_KLAP = { + "result": { + "device_id": "xx", + "owner": "xx", + "device_type": "IOT.SMARTPLUGSWITCH", + "device_model": "HS100(UK)", + "ip": "127.0.0.1", + "mac": "12-34-56-78-90-AB", + "is_support_iot_cloud": True, + "obd_src": "tplink", + "factory_default": False, + "mgt_encrypt_schm": { + "is_support_https": False, + "encrypt_type": "KLAP", + "http_port": 80, + }, + }, + "error_code": 0, +} + + +async def test_discover_single_authentication(mocker): + """Make sure that discover_single handles authenticating devices correctly.""" + host = "127.0.0.1" + + def mock_discover(self): + if discovery_data: + data = ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(discovery_data).encode() + ) + self.datagram_received(data, (host, 20002)) + + mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) + mocker.patch.object( + SmartDevice, + "update", + side_effect=AuthenticationException("Failed to authenticate"), + ) + + # Test with a valid unsupported response + discovery_data = AUTHENTICATION_DATA_KLAP + with pytest.raises( + AuthenticationException, + match="Failed to authenticate", + ): + await Discover.discover_single(host) + + mocker.patch.object(SmartDevice, "update") + device = await Discover.discover_single(host) + assert device.device_type == DeviceType.Plug + + +async def test_device_update_from_new_discovery_info(): + device = SmartDevice("127.0.0.7") + discover_info = DiscoveryResult(**AUTHENTICATION_DATA_KLAP["result"]) + discover_dump = discover_info.get_dict() + device.update_from_discover_info(discover_dump) + + assert device.alias == discover_dump["alias"] + assert device.mac == discover_dump["mac"].replace("-", ":") + assert device.model == discover_dump["model"] + + with pytest.raises( + SmartDeviceException, + match=re.escape("You need to await update() to access the data"), + ): + assert device.supported_modules diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py new file mode 100644 index 000000000..991dbe6fa --- /dev/null +++ b/kasa/tests/test_klapprotocol.py @@ -0,0 +1,306 @@ +import errno +import json +import logging +import secrets +import struct +import sys +import time +from contextlib import nullcontext as does_not_raise + +import httpx +import pytest + +from ..credentials import Credentials +from ..exceptions import AuthenticationException, SmartDeviceException +from ..klapprotocol import KlapEncryptionSession, TPLinkKlap, _sha256 + + +class _mock_response: + def __init__(self, status_code, content: bytes): + self.status_code = status_code + self.content = content + + +@pytest.mark.parametrize("retry_count", [1, 3, 5]) +async def test_protocol_retries(mocker, retry_count): + conn = mocker.patch.object( + TPLinkKlap, "client_post", side_effect=Exception("dummy exception") + ) + with pytest.raises(SmartDeviceException): + await TPLinkKlap("127.0.0.1").query({}, retry_count=retry_count) + + assert conn.call_count == retry_count + 1 + + +async def test_protocol_no_retry_on_connection_error(mocker): + conn = mocker.patch.object( + TPLinkKlap, + "client_post", + side_effect=httpx.ConnectError("foo"), + ) + with pytest.raises(SmartDeviceException): + await TPLinkKlap("127.0.0.1").query({}, retry_count=5) + + assert conn.call_count == 1 + + +async def test_protocol_retry_recoverable_error(mocker): + conn = mocker.patch.object( + TPLinkKlap, + "client_post", + side_effect=httpx.CloseError("foo"), + ) + with pytest.raises(SmartDeviceException): + await TPLinkKlap("127.0.0.1").query({}, retry_count=5) + + assert conn.call_count == 6 + + +@pytest.mark.parametrize("retry_count", [1, 3, 5]) +async def test_protocol_reconnect(mocker, retry_count): + remaining = retry_count + + def _fail_one_less_than_retry_count(*_, **__): + nonlocal remaining, encryption_session + remaining -= 1 + if remaining: + raise Exception("Simulated post failure") + # Do the encrypt just before returning the value so the incrementing sequence number is correct + encrypted, seq = encryption_session.encrypt('{"great":"success"}') + return 200, encrypted + + seed = secrets.token_bytes(16) + auth_hash = TPLinkKlap.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + protocol = TPLinkKlap("127.0.0.1") + protocol.handshake_done = True + protocol.session_expire_at = time.time() + 86400 + protocol.encryption_session = encryption_session + mocker.patch.object( + TPLinkKlap, "client_post", side_effect=_fail_one_less_than_retry_count + ) + + response = await protocol.query({}, retry_count=retry_count) + assert response == {"great": "success"} + + +@pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG]) +async def test_protocol_logging(mocker, caplog, log_level): + caplog.set_level(log_level) + logging.getLogger("kasa").setLevel(log_level) + + def _return_encrypted(*_, **__): + nonlocal encryption_session + # Do the encrypt just before returning the value so the incrementing sequence number is correct + encrypted, seq = encryption_session.encrypt('{"great":"success"}') + return 200, encrypted + + seed = secrets.token_bytes(16) + auth_hash = TPLinkKlap.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + protocol = TPLinkKlap("127.0.0.1") + + protocol.handshake_done = True + protocol.session_expire_at = time.time() + 86400 + protocol.encryption_session = encryption_session + mocker.patch.object(TPLinkKlap, "client_post", side_effect=_return_encrypted) + + response = await protocol.query({}) + assert response == {"great": "success"} + if log_level == logging.DEBUG: + assert "success" in caplog.text + else: + assert "success" not in caplog.text + + +def test_encrypt(): + d = json.dumps({"foo": 1, "bar": 2}) + + seed = secrets.token_bytes(16) + auth_hash = TPLinkKlap.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + + encrypted, seq = encryption_session.encrypt(d) + + assert d == encryption_session.decrypt(encrypted) + + +def test_encrypt_unicode(): + d = "{'snowman': '\u2603'}" + + seed = secrets.token_bytes(16) + auth_hash = TPLinkKlap.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + + encrypted, seq = encryption_session.encrypt(d) + + decrypted = encryption_session.decrypt(encrypted) + + assert d == decrypted + + +@pytest.mark.parametrize( + "device_credentials, expectation", + [ + (Credentials("foo", "bar"), does_not_raise()), + (Credentials("", ""), does_not_raise()), + ( + Credentials(TPLinkKlap.KASA_SETUP_EMAIL, TPLinkKlap.KASA_SETUP_PASSWORD), + does_not_raise(), + ), + ( + Credentials("shouldfail", "shouldfail"), + pytest.raises(AuthenticationException), + ), + ], + ids=("client", "blank", "kasa_setup", "shouldfail"), +) +async def test_handshake1(mocker, device_credentials, expectation): + async def _return_handshake1_response(url, params=None, data=None, *_, **__): + nonlocal client_seed, server_seed, device_auth_hash + + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) + + return _mock_response(200, server_seed + client_seed_auth_hash) + + client_seed = None + server_seed = secrets.token_bytes(16) + client_credentials = Credentials("foo", "bar") + device_auth_hash = TPLinkKlap.generate_auth_hash(device_credentials) + + mocker.patch.object( + httpx.AsyncClient, "post", side_effect=_return_handshake1_response + ) + + protocol = TPLinkKlap("127.0.0.1", credentials=client_credentials) + + protocol.http_client = httpx.AsyncClient() + with expectation: + ( + local_seed, + device_remote_seed, + auth_hash, + ) = await protocol.perform_handshake1() + + assert local_seed == client_seed + assert device_remote_seed == server_seed + assert device_auth_hash == auth_hash + await protocol.close() + + +async def test_handshake(mocker): + async def _return_handshake_response(url, params=None, data=None, *_, **__): + nonlocal response_status, client_seed, server_seed, device_auth_hash + + if url == "http://127.0.0.1/app/handshake1": + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) + + return _mock_response(200, server_seed + client_seed_auth_hash) + elif url == "http://127.0.0.1/app/handshake2": + return _mock_response(response_status, b"") + + client_seed = None + server_seed = secrets.token_bytes(16) + client_credentials = Credentials("foo", "bar") + device_auth_hash = TPLinkKlap.generate_auth_hash(client_credentials) + + mocker.patch.object( + httpx.AsyncClient, "post", side_effect=_return_handshake_response + ) + + protocol = TPLinkKlap("127.0.0.1", credentials=client_credentials) + protocol.http_client = httpx.AsyncClient() + + response_status = 200 + await protocol.perform_handshake() + assert protocol.handshake_done is True + + response_status = 403 + with pytest.raises(AuthenticationException): + await protocol.perform_handshake() + assert protocol.handshake_done is False + await protocol.close() + + +async def test_query(mocker): + async def _return_response(url, params=None, data=None, *_, **__): + nonlocal client_seed, server_seed, device_auth_hash, protocol, seq + + if url == "http://127.0.0.1/app/handshake1": + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) + + return _mock_response(200, server_seed + client_seed_auth_hash) + elif url == "http://127.0.0.1/app/handshake2": + return _mock_response(200, b"") + elif url == "http://127.0.0.1/app/request": + encryption_session = KlapEncryptionSession( + protocol.encryption_session.local_seed, + protocol.encryption_session.remote_seed, + protocol.encryption_session.user_hash, + ) + seq = params.get("seq") + encryption_session._seq = seq - 1 + encrypted, seq = encryption_session.encrypt('{"great": "success"}') + seq = seq + return _mock_response(200, encrypted) + + client_seed = None + last_seq = None + seq = None + server_seed = secrets.token_bytes(16) + client_credentials = Credentials("foo", "bar") + device_auth_hash = TPLinkKlap.generate_auth_hash(client_credentials) + + mocker.patch.object(httpx.AsyncClient, "post", side_effect=_return_response) + + protocol = TPLinkKlap("127.0.0.1", credentials=client_credentials) + + for _ in range(10): + resp = await protocol.query({}) + assert resp == {"great": "success"} + # Check the protocol is incrementing the sequence number + assert last_seq is None or last_seq + 1 == seq + last_seq = seq + + +@pytest.mark.parametrize( + "response_status, expectation", + [ + ((403, 403, 403), pytest.raises(AuthenticationException)), + ((200, 403, 403), pytest.raises(AuthenticationException)), + ((200, 200, 403), pytest.raises(AuthenticationException)), + ((200, 200, 400), pytest.raises(SmartDeviceException)), + ], + ids=("handshake1", "handshake2", "request", "non_auth_error"), +) +async def test_authentication_failures(mocker, response_status, expectation): + async def _return_response(url, params=None, data=None, *_, **__): + nonlocal client_seed, server_seed, device_auth_hash, response_status + + if url == "http://127.0.0.1/app/handshake1": + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) + + return _mock_response( + response_status[0], server_seed + client_seed_auth_hash + ) + elif url == "http://127.0.0.1/app/handshake2": + return _mock_response(response_status[1], b"") + elif url == "http://127.0.0.1/app/request": + return _mock_response(response_status[2], None) + + client_seed = None + + server_seed = secrets.token_bytes(16) + client_credentials = Credentials("foo", "bar") + device_auth_hash = TPLinkKlap.generate_auth_hash(client_credentials) + + mocker.patch.object(httpx.AsyncClient, "post", side_effect=_return_response) + + protocol = TPLinkKlap("127.0.0.1", credentials=client_credentials) + + with expectation: + await protocol.query({}) diff --git a/poetry.lock b/poetry.lock index 180b6cd08..4224f1885 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -107,6 +107,82 @@ files = [ {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] +[[package]] +name = "cffi" +version = "1.15.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = "*" +files = [ + {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, + {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, + {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, + {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, + {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, + {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, + {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, + {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, + {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, + {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, + {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, + {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, + {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, + {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, + {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, + {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, + {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, + {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, + {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "cfgv" version = "3.4.0" @@ -306,6 +382,51 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "cryptography" +version = "41.0.2" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:01f1d9e537f9a15b037d5d9ee442b8c22e3ae11ce65ea1f3316a41c78756b711"}, + {file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:079347de771f9282fbfe0e0236c716686950c19dee1b76240ab09ce1624d76d7"}, + {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:439c3cc4c0d42fa999b83ded80a9a1fb54d53c58d6e59234cfe97f241e6c781d"}, + {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f14ad275364c8b4e525d018f6716537ae7b6d369c094805cae45300847e0894f"}, + {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:84609ade00a6ec59a89729e87a503c6e36af98ddcd566d5f3be52e29ba993182"}, + {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:49c3222bb8f8e800aead2e376cbef687bc9e3cb9b58b29a261210456a7783d83"}, + {file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d73f419a56d74fef257955f51b18d046f3506270a5fd2ac5febbfa259d6c0fa5"}, + {file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:2a034bf7d9ca894720f2ec1d8b7b5832d7e363571828037f9e0c4f18c1b58a58"}, + {file = "cryptography-41.0.2-cp37-abi3-win32.whl", hash = "sha256:d124682c7a23c9764e54ca9ab5b308b14b18eba02722b8659fb238546de83a76"}, + {file = "cryptography-41.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:9c3fe6534d59d071ee82081ca3d71eed3210f76ebd0361798c74abc2bcf347d4"}, + {file = "cryptography-41.0.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a719399b99377b218dac6cf547b6ec54e6ef20207b6165126a280b0ce97e0d2a"}, + {file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:182be4171f9332b6741ee818ec27daff9fb00349f706629f5cbf417bd50e66fd"}, + {file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7a9a3bced53b7f09da251685224d6a260c3cb291768f54954e28f03ef14e3766"}, + {file = "cryptography-41.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f0dc40e6f7aa37af01aba07277d3d64d5a03dc66d682097541ec4da03cc140ee"}, + {file = "cryptography-41.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:674b669d5daa64206c38e507808aae49904c988fa0a71c935e7006a3e1e83831"}, + {file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7af244b012711a26196450d34f483357e42aeddb04128885d95a69bd8b14b69b"}, + {file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9b6d717393dbae53d4e52684ef4f022444fc1cce3c48c38cb74fca29e1f08eaa"}, + {file = "cryptography-41.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:192255f539d7a89f2102d07d7375b1e0a81f7478925b3bc2e0549ebf739dae0e"}, + {file = "cryptography-41.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f772610fe364372de33d76edcd313636a25684edb94cee53fd790195f5989d14"}, + {file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:b332cba64d99a70c1e0836902720887fb4529ea49ea7f5462cf6640e095e11d2"}, + {file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9a6673c1828db6270b76b22cc696f40cde9043eb90373da5c2f8f2158957f42f"}, + {file = "cryptography-41.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:342f3767e25876751e14f8459ad85e77e660537ca0a066e10e75df9c9e9099f0"}, + {file = "cryptography-41.0.2.tar.gz", hash = "sha256:7d230bf856164de164ecb615ccc14c7fc6de6906ddd5b491f3af90d3514c925c"}, +] + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +nox = ["nox"] +pep8test = ["black", "check-sdist", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "distlib" version = "0.3.7" @@ -357,6 +478,62 @@ files = [ docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.1" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.1-py3-none-any.whl", hash = "sha256:c5e97ef177dca2023d0b9aad98e49507ef5423e9f1d94ffe2cfe250aa28e63b0"}, + {file = "httpcore-1.0.1.tar.gz", hash = "sha256:fce1ddf9b606cfb98132ab58865c3728c52c8e4c3c46e2aabb3674464a186e92"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.23.0)"] + +[[package]] +name = "httpx" +version = "0.25.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.25.1-py3-none-any.whl", hash = "sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a"}, + {file = "httpx-0.25.1.tar.gz", hash = "sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + [[package]] name = "identify" version = "2.5.27" @@ -516,6 +693,16 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -746,6 +933,17 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] + [[package]] name = "pydantic" version = "2.3.0" @@ -1033,6 +1231,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1040,8 +1239,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1058,6 +1264,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1065,6 +1272,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -1466,4 +1674,4 @@ speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "888a000414d6140156c0f878af06470505ed6edaab936af8a607d396c6252bf9" +content-hash = "097b5cdfc1d2ccf3e89d306242f0f3a9a84e53c039f939df4e55d13c471f6084" diff --git a/pyproject.toml b/pyproject.toml index b41b242a4..24682df2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,9 @@ python = "^3.8" anyio = "*" # see https://github.com/python-trio/asyncclick/issues/18 asyncclick = ">=8" pydantic = ">=1" +cryptography = ">=1.9" +async-timeout = ">=3.0.0" +httpx = ">=0.25.0" # speed ups orjson = { "version" = ">=3.9.1", optional = true } @@ -34,7 +37,6 @@ sphinx_rtd_theme = { version = "^0", optional = true } sphinxcontrib-programoutput = { version = "^0", optional = true } myst-parser = { version = "*", optional = true } docutils = { version = ">=0.17", optional = true } -async-timeout = ">=3.0.0" [tool.poetry.group.dev.dependencies] pytest = "*" From 27c4799adc51c6d848757555608a0581e0c56afd Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Tue, 21 Nov 2023 20:58:41 +0000 Subject: [PATCH 180/892] Do not do update() in discover_single (#542) --- kasa/discover.py | 10 +++++++--- kasa/smartdevice.py | 9 +++++++++ kasa/tests/newfakes.py | 32 +++++++++++++++++++++----------- kasa/tests/test_discovery.py | 33 ++++++++++++++++++++++++--------- kasa/tests/test_smartdevice.py | 19 +++++++++++++++++++ 5 files changed, 80 insertions(+), 23 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index 9625f7c38..151aae826 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -260,6 +260,7 @@ async def discover_single( port: Optional[int] = None, timeout=5, credentials: Optional[Credentials] = None, + update_parent_devices: bool = True, ) -> SmartDevice: """Discover a single device by the given IP address. @@ -271,8 +272,9 @@ async def discover_single( :param host: Hostname of device to query :param port: Optionally set a different port for the device :param timeout: Timeout for discovery - :param credentials: Optionally provide credentials for - devices requiring them + :param credentials: Credentials for devices that require authentication + :param update_parent_devices: Automatically call device.update() on + devices that have children :rtype: SmartDevice :return: Object for querying/controlling found device. """ @@ -330,7 +332,9 @@ async def discover_single( if ip in protocol.discovered_devices: dev = protocol.discovered_devices[ip] dev.host = host - await dev.update() + # Call device update on devices that have children + if update_parent_devices and dev.has_children: + await dev.update() return dev elif ip in protocol.unsupported_devices: raise UnsupportedDeviceException( diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 1ae86b4f5..b081ac3f0 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -411,6 +411,15 @@ def model(self) -> str: sys_info = self._sys_info return str(sys_info["model"]) + @property + def has_children(self) -> bool: + """Return true if the device has children devices.""" + # Ideally we would check for the 'child_num' key in sys_info, + # but devices that speak klap do not populate this key via + # update_from_discover_info so we check for the devices + # we know have children instead. + return self.is_strip + @property # type: ignore @requires_update def alias(self) -> str: diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index b849d0807..acd9de1ee 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -1,3 +1,4 @@ +import copy import logging import re @@ -289,7 +290,7 @@ def __init__(self, info): self.discovery_data = info self.writer = None self.reader = None - proto = FakeTransportProtocol.baseproto + proto = copy.deepcopy(FakeTransportProtocol.baseproto) for target in info: # print("target %s" % target) @@ -298,16 +299,23 @@ def __init__(self, info): proto[target][cmd] = info[target][cmd] # if we have emeter support, we need to add the missing pieces for module in ["emeter", "smartlife.iot.common.emeter"]: - for etype in ["get_realtime", "get_daystat", "get_monthstat"]: - if ( - module in info and etype in info[module] - ): # if the fixture has the data, use it - # print("got %s %s from fixture: %s" % (module, etype, info[module][etype])) - proto[module][etype] = info[module][etype] - else: # otherwise fall back to the static one - dummy_data = emeter_commands[module][etype] - # print("got %s %s from dummy: %s" % (module, etype, dummy_data)) - proto[module][etype] = dummy_data + if ( + module in info + and "err_code" in info[module] + and info[module]["err_code"] != 0 + ): + proto[module] = info[module] + else: + for etype in ["get_realtime", "get_daystat", "get_monthstat"]: + if ( + module in info and etype in info[module] + ): # if the fixture has the data, use it + # print("got %s %s from fixture: %s" % (module, etype, info[module][etype])) + proto[module][etype] = info[module][etype] + else: # otherwise fall back to the static one + dummy_data = emeter_commands[module][etype] + # print("got %s %s from dummy: %s" % (module, etype, dummy_data)) + proto[module][etype] = dummy_data # print("initialized: %s" % proto[module]) @@ -471,6 +479,8 @@ async def query(self, request, port=9999): def get_response_for_module(target): if target not in proto: return error(msg="target not found") + if "err_code" in proto[target] and proto[target]["err_code"] != 0: + return {target: proto[target]} def get_response_for_command(cmd): if cmd not in proto[target]: diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 148e567c2..f2e125990 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -5,7 +5,14 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 -from kasa import DeviceType, Discover, SmartDevice, SmartDeviceException, protocol +from kasa import ( + DeviceType, + Discover, + SmartDevice, + SmartDeviceException, + SmartStrip, + protocol, +) from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps from kasa.exceptions import AuthenticationException, UnsupportedDeviceException @@ -59,41 +66,45 @@ async def test_type_unknown(): async def test_discover_single(discovery_data: dict, mocker, custom_port): """Make sure that discover_single returns an initialized SmartDevice instance.""" host = "127.0.0.1" + info = {"system": {"get_sysinfo": discovery_data["system"]["get_sysinfo"]}} + query_mock = mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=info) def mock_discover(self): self.datagram_received( - protocol.TPLinkSmartHomeProtocol.encrypt(json_dumps(discovery_data))[4:], + protocol.TPLinkSmartHomeProtocol.encrypt(json_dumps(info))[4:], (host, custom_port or 9999), ) mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) x = await Discover.discover_single(host, port=custom_port) assert issubclass(x.__class__, SmartDevice) assert x._sys_info is not None assert x.port == custom_port or x.port == 9999 + assert (query_mock.call_count > 0) == isinstance(x, SmartStrip) async def test_discover_single_hostname(discovery_data: dict, mocker): """Make sure that discover_single returns an initialized SmartDevice instance.""" host = "foobar" ip = "127.0.0.1" + info = {"system": {"get_sysinfo": discovery_data["system"]["get_sysinfo"]}} + query_mock = mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=info) def mock_discover(self): self.datagram_received( - protocol.TPLinkSmartHomeProtocol.encrypt(json_dumps(discovery_data))[4:], + protocol.TPLinkSmartHomeProtocol.encrypt(json_dumps(info))[4:], (ip, 9999), ) mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) mocker.patch("socket.getaddrinfo", return_value=[(None, None, None, None, (ip, 0))]) x = await Discover.discover_single(host) assert issubclass(x.__class__, SmartDevice) assert x._sys_info is not None assert x.host == host + assert (query_mock.call_count > 0) == isinstance(x, SmartStrip) mocker.patch("socket.getaddrinfo", side_effect=socket.gaierror()) with pytest.raises(SmartDeviceException): @@ -104,14 +115,15 @@ def mock_discover(self): async def test_connect_single(discovery_data: dict, mocker, custom_port): """Make sure that connect_single returns an initialized SmartDevice instance.""" host = "127.0.0.1" - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) + info = {"system": {"get_sysinfo": discovery_data["system"]["get_sysinfo"]}} + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=info) dev = await Discover.connect_single(host, port=custom_port) assert issubclass(dev.__class__, SmartDevice) assert dev.port == custom_port or dev.port == 9999 -async def test_connect_single_query_fails(discovery_data: dict, mocker): +async def test_connect_single_query_fails(mocker): """Make sure that connect_single fails when query fails.""" host = "127.0.0.1" mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=SmartDeviceException) @@ -211,7 +223,8 @@ async def test_discover_send(mocker): async def test_discover_datagram_received(mocker, discovery_data): """Verify that datagram received fills discovered_devices.""" proto = _DiscoverProtocol() - mocker.patch("kasa.discover.json_loads", return_value=discovery_data) + info = {"system": {"get_sysinfo": discovery_data["system"]["get_sysinfo"]}} + mocker.patch("kasa.discover.json_loads", return_value=info) mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "encrypt") mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "decrypt") @@ -287,10 +300,12 @@ def mock_discover(self): AuthenticationException, match="Failed to authenticate", ): - await Discover.discover_single(host) + device = await Discover.discover_single(host) + await device.update() mocker.patch.object(SmartDevice, "update") device = await Discover.discover_single(host) + await device.update() assert device.device_type == DeviceType.Plug diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index f6f470b82..ae6886b87 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -155,6 +155,16 @@ async def test_childrens(dev): assert len(dev.children) == 0 +async def test_children(dev): + """Make sure that children property is exposed by every device.""" + if dev.is_strip: + assert len(dev.children) > 0 + assert dev.has_children is True + else: + assert len(dev.children) == 0 + assert dev.has_children is False + + async def test_internal_state(dev): """Make sure the internal state returns the last update results.""" assert dev.internal_state == dev._last_update @@ -203,3 +213,12 @@ async def test_create_smart_device_with_timeout(): """Make sure timeout is passed to the protocol.""" dev = SmartDevice(host="127.0.0.1", timeout=100) assert dev.protocol.timeout == 100 + + +async def test_modules_not_supported(dev: SmartDevice): + """Test that unsupported modules do not break the device.""" + for module in dev.modules.values(): + assert module.is_supported is not None + await dev.update() + for module in dev.modules.values(): + assert module.is_supported is not None From e98252ff17e75307e1f7f00a26a86704027951e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Nov 2023 23:48:53 +0100 Subject: [PATCH 181/892] Move connect_single to SmartDevice.connect (#538) This refactors `Discover.connect_single` by moving device instance construction into a separate device factory module. New `SmartDevice.connect(host, *, port, timeout, credentials, device_type)` class method replaces the functionality of `connect_single`, and also now allows constructing device instances without relying on UDP discovery for type discovery if `device_type` parameter is set. --------- Co-authored-by: Teemu R. --- docs/source/design.rst | 15 ++++ kasa/cli.py | 26 +++---- kasa/device_factory.py | 121 ++++++++++++++++++++++++++++++ kasa/device_type.py | 25 ++++++ kasa/discover.py | 78 +------------------ kasa/smartdevice.py | 53 +++++++++---- kasa/tests/test_cli.py | 42 ++++++----- kasa/tests/test_device_factory.py | 74 ++++++++++++++++++ kasa/tests/test_device_type.py | 23 ++++++ kasa/tests/test_discovery.py | 22 ------ kasa/tests/test_smartdevice.py | 26 ++++++- 11 files changed, 361 insertions(+), 144 deletions(-) create mode 100755 kasa/device_factory.py create mode 100755 kasa/device_type.py create mode 100644 kasa/tests/test_device_factory.py create mode 100644 kasa/tests/test_device_type.py diff --git a/docs/source/design.rst b/docs/source/design.rst index 8acbfea69..5679943d2 100644 --- a/docs/source/design.rst +++ b/docs/source/design.rst @@ -12,6 +12,21 @@ or if you are just looking to access some information that is not currently expo .. contents:: Contents :local: +.. _initialization: + +Initialization +************** + +Use :func:`~kasa.Discover.discover` to perform udp-based broadcast discovery on the network. +This will return you a list of device instances based on the discovery replies. + +If the device's host is already known, you can use to construct a device instance with +:meth:`~kasa.SmartDevice.connect()`. + +When connecting a device with the :meth:`~kasa.SmartDevice.connect()` method, it is recommended to +pass the device type as well as this allows the library to use the correct device class for the +device without having to query the device. + .. _update_cycle: Update Cycle diff --git a/kasa/cli.py b/kasa/cli.py index 7280dd330..e71c7b9fd 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -13,14 +13,13 @@ from kasa import ( AuthenticationException, Credentials, + DeviceType, Discover, SmartBulb, SmartDevice, - SmartDimmer, - SmartLightStrip, - SmartPlug, SmartStrip, ) +from kasa.device_factory import DEVICE_TYPE_TO_CLASS try: from rich import print as _do_echo @@ -43,13 +42,11 @@ def wrapper(message=None, *args, **kwargs): # --json has set it to _nop_echo echo = _do_echo -TYPE_TO_CLASS = { - "plug": SmartPlug, - "bulb": SmartBulb, - "dimmer": SmartDimmer, - "strip": SmartStrip, - "lightstrip": SmartLightStrip, -} +DEVICE_TYPES = [ + device_type.value + for device_type in DeviceType + if device_type in DEVICE_TYPE_TO_CLASS +] click.anyio_backend = "asyncio" @@ -129,7 +126,7 @@ def _device_to_serializable(val: SmartDevice): "--type", envvar="KASA_TYPE", default=None, - type=click.Choice(list(TYPE_TO_CLASS), case_sensitive=False), + type=click.Choice(DEVICE_TYPES, case_sensitive=False), ) @click.option( "--json", default=False, is_flag=True, help="Output raw device response as JSON." @@ -235,7 +232,10 @@ def _nop_echo(*args, **kwargs): return await ctx.invoke(discover, timeout=discovery_timeout) if type is not None: - dev = TYPE_TO_CLASS[type](host, credentials=credentials) + device_type = DeviceType.from_value(type) + dev = await SmartDevice.connect( + host, credentials=credentials, device_type=device_type + ) else: echo("No --type defined, discovering..") dev = await Discover.discover_single( @@ -243,8 +243,8 @@ def _nop_echo(*args, **kwargs): port=port, credentials=credentials, ) + await dev.update() - await dev.update() ctx.obj = dev if ctx.invoked_subcommand is None: diff --git a/kasa/device_factory.py b/kasa/device_factory.py new file mode 100755 index 000000000..c3ed4de3b --- /dev/null +++ b/kasa/device_factory.py @@ -0,0 +1,121 @@ +"""Device creation by type.""" + +import logging +import time +from typing import Any, Dict, Optional, Type + +from .credentials import Credentials +from .device_type import DeviceType +from .exceptions import UnsupportedDeviceException +from .smartbulb import SmartBulb +from .smartdevice import SmartDevice, SmartDeviceException +from .smartdimmer import SmartDimmer +from .smartlightstrip import SmartLightStrip +from .smartplug import SmartPlug +from .smartstrip import SmartStrip + +DEVICE_TYPE_TO_CLASS = { + DeviceType.Plug: SmartPlug, + DeviceType.Bulb: SmartBulb, + DeviceType.Strip: SmartStrip, + DeviceType.Dimmer: SmartDimmer, + DeviceType.LightStrip: SmartLightStrip, +} + +_LOGGER = logging.getLogger(__name__) + + +async def connect( + host: str, + *, + port: Optional[int] = None, + timeout=5, + credentials: Optional[Credentials] = None, + device_type: Optional[DeviceType] = None, +) -> "SmartDevice": + """Connect to a single device by the given IP address. + + This method avoids the UDP based discovery process and + will connect directly to the device to query its type. + + It is generally preferred to avoid :func:`discover_single()` and + use this function instead as it should perform better when + the WiFi network is congested or the device is not responding + to discovery requests. + + The device type is discovered by querying the device. + + :param host: Hostname of device to query + :param device_type: Device type to use for the device. + If not given, the device type is discovered by querying the device. + If the device type is already known, it is preferred to pass it + to avoid the extra query to the device to discover its type. + :rtype: SmartDevice + :return: Object for querying/controlling found device. + """ + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + + if debug_enabled: + start_time = time.perf_counter() + + if device_type and (klass := DEVICE_TYPE_TO_CLASS.get(device_type)): + dev: SmartDevice = klass( + host=host, port=port, credentials=credentials, timeout=timeout + ) + await dev.update() + if debug_enabled: + end_time = time.perf_counter() + _LOGGER.debug( + "Device %s with known type (%s) took %.2f seconds to connect", + host, + device_type.value, + end_time - start_time, + ) + return dev + + unknown_dev = SmartDevice( + host=host, port=port, credentials=credentials, timeout=timeout + ) + await unknown_dev.update() + device_class = get_device_class_from_info(unknown_dev.internal_state) + dev = device_class(host=host, port=port, credentials=credentials, timeout=timeout) + # Reuse the connection from the unknown device + # so we don't have to reconnect + dev.protocol = unknown_dev.protocol + await dev.update() + if debug_enabled: + end_time = time.perf_counter() + _LOGGER.debug( + "Device %s with unknown type (%s) took %.2f seconds to connect", + host, + dev.device_type.value, + end_time - start_time, + ) + return dev + + +def get_device_class_from_info(info: Dict[str, Any]) -> Type[SmartDevice]: + """Find SmartDevice subclass for device described by passed data.""" + if "system" not in info or "get_sysinfo" not in info["system"]: + raise SmartDeviceException("No 'system' or 'get_sysinfo' in response") + + sysinfo: Dict[str, Any] = info["system"]["get_sysinfo"] + type_: Optional[str] = sysinfo.get("type", sysinfo.get("mic_type")) + if type_ is None: + raise SmartDeviceException("Unable to find the device type field!") + + if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: + return SmartDimmer + + if "smartplug" in type_.lower(): + if "children" in sysinfo: + return SmartStrip + + return SmartPlug + + if "smartbulb" in type_.lower(): + if "length" in sysinfo: # strips have length + return SmartLightStrip + + return SmartBulb + raise UnsupportedDeviceException("Unknown device type: %s" % type_) diff --git a/kasa/device_type.py b/kasa/device_type.py new file mode 100755 index 000000000..162fc4f27 --- /dev/null +++ b/kasa/device_type.py @@ -0,0 +1,25 @@ +"""TP-Link device types.""" + + +from enum import Enum + + +class DeviceType(Enum): + """Device type enum.""" + + # The values match what the cli has historically used + Plug = "plug" + Bulb = "bulb" + Strip = "strip" + StripSocket = "stripsocket" + Dimmer = "dimmer" + LightStrip = "lightstrip" + Unknown = "unknown" + + @staticmethod + def from_value(name: str) -> "DeviceType": + """Return device type from string value.""" + for device_type in DeviceType: + if device_type.value == name: + return device_type + return DeviceType.Unknown diff --git a/kasa/discover.py b/kasa/discover.py index 151aae826..2523ba1ad 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -20,13 +20,11 @@ from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads from kasa.klapprotocol import TPLinkKlap -from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol -from kasa.smartbulb import SmartBulb +from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartdevice import SmartDevice, SmartDeviceException -from kasa.smartdimmer import SmartDimmer -from kasa.smartlightstrip import SmartLightStrip from kasa.smartplug import SmartPlug -from kasa.smartstrip import SmartStrip + +from .device_factory import get_device_class_from_info _LOGGER = logging.getLogger(__name__) @@ -345,78 +343,10 @@ async def discover_single( else: raise SmartDeviceException(f"Unable to get discovery response for {host}") - @staticmethod - async def connect_single( - host: str, - *, - port: Optional[int] = None, - timeout=5, - credentials: Optional[Credentials] = None, - protocol_class: Optional[Type[TPLinkProtocol]] = None, - ) -> SmartDevice: - """Connect to a single device by the given IP address. - - This method avoids the UDP based discovery process and - will connect directly to the device to query its type. - - It is generally preferred to avoid :func:`discover_single()` and - use this function instead as it should perform better when - the WiFi network is congested or the device is not responding - to discovery requests. - - The device type is discovered by querying the device. - - :param host: Hostname of device to query - :param port: Optionally set a different port for the device - :param timeout: Timeout for discovery - :param credentials: Optionally provide credentials for - devices requiring them - :param protocol_class: Optionally provide the protocol class - to use. - :rtype: SmartDevice - :return: Object for querying/controlling found device. - """ - unknown_dev = SmartDevice( - host=host, port=port, credentials=credentials, timeout=timeout - ) - if protocol_class is not None: - unknown_dev.protocol = protocol_class(host, credentials=credentials) - await unknown_dev.update() - device_class = Discover._get_device_class(unknown_dev.internal_state) - dev = device_class( - host=host, port=port, credentials=credentials, timeout=timeout - ) - # Reuse the connection from the unknown device - # so we don't have to reconnect - dev.protocol = unknown_dev.protocol - return dev - @staticmethod def _get_device_class(info: dict) -> Type[SmartDevice]: """Find SmartDevice subclass for device described by passed data.""" - if "system" not in info or "get_sysinfo" not in info["system"]: - raise SmartDeviceException("No 'system' or 'get_sysinfo' in response") - - sysinfo = info["system"]["get_sysinfo"] - type_ = sysinfo.get("type", sysinfo.get("mic_type")) - if type_ is None: - raise SmartDeviceException("Unable to find the device type field!") - - if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: - return SmartDimmer - - if "smartplug" in type_.lower(): - if "children" in sysinfo: - return SmartStrip - - return SmartPlug - - if "smartbulb" in type_.lower(): - if "length" in sysinfo: # strips have length - return SmartLightStrip - - return SmartBulb - raise UnsupportedDeviceException("Unknown device type: %s" % type_) + return get_device_class_from_info(info) @staticmethod def _get_device_instance_legacy(data: bytes, ip: str, port: int) -> SmartDevice: diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index b081ac3f0..f1995db82 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -17,10 +17,10 @@ import logging from dataclasses import dataclass from datetime import datetime, timedelta -from enum import Enum, auto from typing import Any, Dict, List, Optional, Set from .credentials import Credentials +from .device_type import DeviceType from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException from .modules import Emeter, Module @@ -29,18 +29,6 @@ _LOGGER = logging.getLogger(__name__) -class DeviceType(Enum): - """Device type enum.""" - - Plug = auto() - Bulb = auto() - Strip = auto() - StripSocket = auto() - Dimmer = auto() - LightStrip = auto() - Unknown = -1 - - @dataclass class WifiNetwork: """Wifi network container.""" @@ -780,3 +768,42 @@ def __repr__(self): f" ({self.alias}), is_on: {self.is_on}" f" - dev specific: {self.state_information}>" ) + + @staticmethod + async def connect( + host: str, + *, + port: Optional[int] = None, + timeout=5, + credentials: Optional[Credentials] = None, + device_type: Optional[DeviceType] = None, + ) -> "SmartDevice": + """Connect to a single device by the given IP address. + + This method avoids the UDP based discovery process and + will connect directly to the device to query its type. + + It is generally preferred to avoid :func:`discover_single()` and + use this function instead as it should perform better when + the WiFi network is congested or the device is not responding + to discovery requests. + + The device type is discovered by querying the device. + + :param host: Hostname of device to query + :param device_type: Device type to use for the device. + If not given, the device type is discovered by querying the device. + If the device type is already known, it is preferred to pass it + to avoid the extra query to the device to discover its type. + :rtype: SmartDevice + :return: Object for querying/controlling found device. + """ + from .device_factory import connect # pylint: disable=import-outside-toplevel + + return await connect( + host=host, + port=port, + timeout=timeout, + credentials=credentials, + device_type=device_type, + ) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 009632d7b..f590808f8 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -1,22 +1,11 @@ import json -import sys import asyncclick as click import pytest from asyncclick.testing import CliRunner from kasa import SmartDevice, TPLinkSmartHomeProtocol -from kasa.cli import ( - TYPE_TO_CLASS, - alias, - brightness, - cli, - emeter, - raw_command, - state, - sysinfo, - toggle, -) +from kasa.cli import alias, brightness, cli, emeter, raw_command, state, sysinfo, toggle from kasa.discover import Discover from .conftest import handle_turn_on, turn_on @@ -154,14 +143,9 @@ async def _state(dev: SmartDevice): ) mocker.patch("kasa.cli.state", new=_state) - - # Get the type string parameter from the discovery_info - for cli_device_type in { # noqa: B007 - i - for i in TYPE_TO_CLASS - if TYPE_TO_CLASS[i] == Discover._get_device_class(discovery_data) - }: - break + cli_device_type = Discover._get_device_class(discovery_data)( + "any" + ).device_type.value runner = CliRunner() res = await runner.invoke( @@ -181,6 +165,24 @@ async def _state(dev: SmartDevice): assert res.output == "Username:foo Password:bar\n" +async def test_without_device_type(discovery_data: dict, dev, mocker): + """Test connecting without the device type.""" + runner = CliRunner() + 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 + + @pytest.mark.parametrize("auth_param", ["--username", "--password"]) async def test_invalid_credential_params(auth_param): """Test for handling only one of username or password supplied.""" diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py new file mode 100644 index 000000000..3a08857a9 --- /dev/null +++ b/kasa/tests/test_device_factory.py @@ -0,0 +1,74 @@ +# type: ignore +import logging +from typing import Type + +import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 + +from kasa import ( + DeviceType, + SmartBulb, + SmartDevice, + SmartDeviceException, + SmartDimmer, + SmartLightStrip, + SmartPlug, +) +from kasa.device_factory import connect + + +@pytest.mark.parametrize("custom_port", [123, None]) +async def test_connect(discovery_data: dict, mocker, custom_port): + """Make sure that connect returns an initialized SmartDevice instance.""" + host = "127.0.0.1" + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) + + dev = await connect(host, port=custom_port) + assert issubclass(dev.__class__, SmartDevice) + assert dev.port == custom_port or dev.port == 9999 + + +@pytest.mark.parametrize("custom_port", [123, None]) +@pytest.mark.parametrize( + ("device_type", "klass"), + ( + (DeviceType.Plug, SmartPlug), + (DeviceType.Bulb, SmartBulb), + (DeviceType.Dimmer, SmartDimmer), + (DeviceType.LightStrip, SmartLightStrip), + (DeviceType.Unknown, SmartDevice), + ), +) +async def test_connect_passed_device_type( + discovery_data: dict, + mocker, + device_type: DeviceType, + klass: Type[SmartDevice], + custom_port, +): + """Make sure that connect with a passed device type.""" + host = "127.0.0.1" + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) + + dev = await connect(host, port=custom_port, device_type=device_type) + assert isinstance(dev, klass) + assert dev.port == custom_port or dev.port == 9999 + + +async def test_connect_query_fails(discovery_data: dict, mocker): + """Make sure that connect fails when query fails.""" + host = "127.0.0.1" + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=SmartDeviceException) + + with pytest.raises(SmartDeviceException): + await connect(host) + + +async def test_connect_logs_connect_time( + discovery_data: dict, caplog: pytest.LogCaptureFixture, mocker +): + """Test that the connect time is logged when debug logging is enabled.""" + host = "127.0.0.1" + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) + logging.getLogger("kasa").setLevel(logging.DEBUG) + await connect(host) + assert "seconds to connect" in caplog.text diff --git a/kasa/tests/test_device_type.py b/kasa/tests/test_device_type.py new file mode 100644 index 000000000..da1707dc7 --- /dev/null +++ b/kasa/tests/test_device_type.py @@ -0,0 +1,23 @@ +from kasa.smartdevice import DeviceType + + +async def test_device_type_from_value(): + """Make sure that every device type can be created from its value.""" + for name in DeviceType: + assert DeviceType.from_value(name.value) is not None + + assert DeviceType.from_value("nonexistent") is DeviceType.Unknown + assert DeviceType.from_value("plug") is DeviceType.Plug + assert DeviceType.Plug.value == "plug" + + assert DeviceType.from_value("bulb") is DeviceType.Bulb + assert DeviceType.Bulb.value == "bulb" + + assert DeviceType.from_value("dimmer") is DeviceType.Dimmer + assert DeviceType.Dimmer.value == "dimmer" + + assert DeviceType.from_value("strip") is DeviceType.Strip + assert DeviceType.Strip.value == "strip" + + assert DeviceType.from_value("lightstrip") is DeviceType.LightStrip + assert DeviceType.LightStrip.value == "lightstrip" diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index f2e125990..7e1dabc0b 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -1,7 +1,6 @@ # type: ignore import re import socket -import sys import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 @@ -111,27 +110,6 @@ def mock_discover(self): x = await Discover.discover_single(host) -@pytest.mark.parametrize("custom_port", [123, None]) -async def test_connect_single(discovery_data: dict, mocker, custom_port): - """Make sure that connect_single returns an initialized SmartDevice instance.""" - host = "127.0.0.1" - info = {"system": {"get_sysinfo": discovery_data["system"]["get_sysinfo"]}} - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=info) - - dev = await Discover.connect_single(host, port=custom_port) - assert issubclass(dev.__class__, SmartDevice) - assert dev.port == custom_port or dev.port == 9999 - - -async def test_connect_single_query_fails(mocker): - """Make sure that connect_single fails when query fails.""" - host = "127.0.0.1" - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=SmartDeviceException) - - with pytest.raises(SmartDeviceException): - await Discover.connect_single(host) - - UNSUPPORTED = { "result": { "device_id": "xx", diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index ae6886b87..85dc358df 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -1,12 +1,12 @@ import inspect from datetime import datetime -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 import kasa from kasa import Credentials, SmartDevice, SmartDeviceException -from kasa.smartstrip import SmartStripPlug +from kasa.smartdevice import DeviceType from .conftest import handle_turn_on, has_emeter, no_emeter, turn_on from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol @@ -215,6 +215,28 @@ async def test_create_smart_device_with_timeout(): assert dev.protocol.timeout == 100 +async def test_create_thin_wrapper(): + """Make sure thin wrapper is created with the correct device type.""" + mock = Mock() + with patch("kasa.device_factory.connect", return_value=mock) as connect: + dev = await SmartDevice.connect( + host="test_host", + port=1234, + timeout=100, + credentials=Credentials("username", "password"), + device_type=DeviceType.Strip, + ) + assert dev is mock + + connect.assert_called_once_with( + host="test_host", + port=1234, + timeout=100, + credentials=Credentials("username", "password"), + device_type=DeviceType.Strip, + ) + + async def test_modules_not_supported(dev: SmartDevice): """Test that unsupported modules do not break the device.""" for module in dev.modules.values(): From d3c2861e4acf12e38338bf2d3d52561f23ed2673 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Nov 2023 09:25:49 -0600 Subject: [PATCH 182/892] Set TCP_NODELAY to avoid needless buffering (#554) --- kasa/protocol.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/kasa/protocol.py b/kasa/protocol.py index c49ab2239..6413ba5de 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -13,6 +13,7 @@ import contextlib import errno import logging +import socket import struct from abc import ABC, abstractmethod from pprint import pformat as pf @@ -107,6 +108,12 @@ async def _connect(self, timeout: int) -> None: task = asyncio.open_connection(self.host, self.port) async with asyncio_timeout(timeout): self.reader, self.writer = await task + sock: socket.socket = self.writer.get_extra_info("socket") + # Ensure our packets get sent without delay as we do all + # our writes in a single go and we do not want any buffering + # which would needlessly delay the request or risk overloading + # the buffer on the device + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) async def _execute_query(self, request: str) -> Dict: """Execute a query on the device and wait for the response.""" From 9728866afb5539e5d5e6b5371d012ad50658eaa7 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Tue, 28 Nov 2023 19:13:15 +0000 Subject: [PATCH 183/892] Re-add protocol_class parameter to connect (#551) Co-authored-by: J. Nick Koston --- kasa/device_factory.py | 8 ++++++++ kasa/tests/test_device_factory.py | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index c3ed4de3b..049969fba 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -7,6 +7,7 @@ from .credentials import Credentials from .device_type import DeviceType from .exceptions import UnsupportedDeviceException +from .protocol import TPLinkProtocol from .smartbulb import SmartBulb from .smartdevice import SmartDevice, SmartDeviceException from .smartdimmer import SmartDimmer @@ -32,6 +33,7 @@ async def connect( timeout=5, credentials: Optional[Credentials] = None, device_type: Optional[DeviceType] = None, + protocol_class: Optional[Type[TPLinkProtocol]] = None, ) -> "SmartDevice": """Connect to a single device by the given IP address. @@ -50,6 +52,8 @@ async def connect( If not given, the device type is discovered by querying the device. If the device type is already known, it is preferred to pass it to avoid the extra query to the device to discover its type. + :param protocol_class: Optionally provide the protocol class + to use. :rtype: SmartDevice :return: Object for querying/controlling found device. """ @@ -62,6 +66,8 @@ async def connect( dev: SmartDevice = klass( host=host, port=port, credentials=credentials, timeout=timeout ) + if protocol_class is not None: + dev.protocol = protocol_class(host, credentials=credentials) await dev.update() if debug_enabled: end_time = time.perf_counter() @@ -76,6 +82,8 @@ async def connect( unknown_dev = SmartDevice( host=host, port=port, credentials=credentials, timeout=timeout ) + if protocol_class is not None: + unknown_dev.protocol = protocol_class(host, credentials=credentials) await unknown_dev.update() device_class = get_device_class_from_info(unknown_dev.internal_state) dev = device_class(host=host, port=port, credentials=credentials, timeout=timeout) diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index 3a08857a9..aca38e19d 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -14,6 +14,8 @@ SmartPlug, ) from kasa.device_factory import connect +from kasa.klapprotocol import TPLinkKlap +from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol @pytest.mark.parametrize("custom_port", [123, None]) @@ -72,3 +74,28 @@ async def test_connect_logs_connect_time( logging.getLogger("kasa").setLevel(logging.DEBUG) await connect(host) assert "seconds to connect" in caplog.text + + +@pytest.mark.parametrize("device_type", [DeviceType.Plug, None]) +@pytest.mark.parametrize( + ("protocol_in", "protocol_result"), + ( + (None, TPLinkSmartHomeProtocol), + (TPLinkKlap, TPLinkKlap), + (TPLinkSmartHomeProtocol, TPLinkSmartHomeProtocol), + ), +) +async def test_connect_pass_protocol( + discovery_data: dict, + mocker, + device_type: DeviceType, + protocol_in: Type[TPLinkProtocol], + protocol_result: Type[TPLinkProtocol], +): + """Test that if the protocol is passed in it's gets set correctly.""" + host = "127.0.0.1" + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) + mocker.patch("kasa.TPLinkKlap.query", return_value=discovery_data) + + dev = await connect(host, device_type=device_type, protocol_class=protocol_in) + assert isinstance(dev.protocol, protocol_result) From 9de3f69033e5d99ead79af854966a6d681acba62 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Wed, 29 Nov 2023 19:01:20 +0000 Subject: [PATCH 184/892] Update dump_devinfo to include 20002 discovery results (#556) * Fix dump_devinfo and add discovery_result to json * Update following review. Do not serialize aliases. * Delete kasa/tests/fixtures/HS100(UK)_1.0_1.2.6.json --- devtools/dump_devinfo.py | 57 ++++++++++++++------ kasa/discover.py | 2 +- kasa/smartdevice.py | 2 + kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json | 47 ++++++++-------- kasa/tests/newfakes.py | 7 +-- 5 files changed, 74 insertions(+), 41 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index eff3a7b60..0c33ac0b4 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -7,7 +7,6 @@ Executing this script will several modules and methods one by one, and finally execute a query to query all of them at once. """ -import asyncio import collections.abc import json import logging @@ -15,9 +14,10 @@ from collections import defaultdict, namedtuple from pprint import pprint -import click +import asyncclick as click -from kasa import TPLinkSmartHomeProtocol +from kasa import Credentials, Discover +from kasa.discover import DiscoveryResult Call = namedtuple("Call", "module method") @@ -35,6 +35,13 @@ def scrub(res): "longitude_i", "latitude", "longitude", + "owner", + "device_id", + "ip", + "ssid", + "hw_id", + "fw_id", + "oem_id", ] for k, v in res.items(): @@ -44,6 +51,8 @@ def scrub(res): if k in keys_to_scrub: if k in ["latitude", "latitude_i", "longitude", "longitude_i"]: v = 0 + elif k in ["ip"]: + v = "127.0.0.123" else: v = re.sub(r"\w", "0", v) @@ -63,8 +72,22 @@ def default_to_regular(d): @click.command() @click.argument("host") +@click.option( + "--username", + default=None, + required=False, + envvar="TPLINK_CLOUD_USERNAME", + help="Username/email address to authenticate to device.", +) +@click.option( + "--password", + default=None, + required=False, + envvar="TPLINK_CLOUD_PASSWORD", + help="Password to use to authenticate to device.", +) @click.option("-d", "--debug", is_flag=True) -def cli(host, debug): +async def cli(host, debug, username, password): """Generate devinfo file for given device.""" if debug: logging.basicConfig(level=logging.DEBUG) @@ -83,15 +106,15 @@ def cli(host, debug): successes = [] - for test_call in items: - - async def _run_query(test_call): - protocol = TPLinkSmartHomeProtocol(host) - return await protocol.query({test_call.module: {test_call.method: None}}) + credentials = Credentials(username=username, password=password) + device = await Discover.discover_single(host, credentials=credentials) + for test_call in items: try: click.echo(f"Testing {test_call}..", nl=False) - info = asyncio.run(_run_query(test_call)) + info = await device.protocol.query( + {test_call.module: {test_call.method: None}} + ) resp = info[test_call.module] except Exception as ex: click.echo(click.style(f"FAIL {ex}", fg="red")) @@ -111,12 +134,8 @@ async def _run_query(test_call): final = default_to_regular(final) - async def _run_final_query(): - protocol = TPLinkSmartHomeProtocol(host) - return await protocol.query(final_query) - try: - final = asyncio.run(_run_final_query()) + final = await device.protocol.query(final_query) except Exception as ex: click.echo( click.style( @@ -124,6 +143,14 @@ async def _run_final_query(): ) ) + if device._discovery_info: + # 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. + dr = DiscoveryResult(**device._discovery_info) + final["discovery_result"] = dr.dict( + by_alias=False, exclude_unset=True, exclude_none=True, exclude_defaults=True + ) + click.echo("Got %s successes" % len(successes)) click.echo(click.style("## device info file ##", bold=True)) diff --git a/kasa/discover.py b/kasa/discover.py index 2523ba1ad..61faeaff6 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -423,7 +423,7 @@ class EncryptionScheme(BaseModel): mac: str mgt_encrypt_schm: EncryptionScheme - device_id: Optional[str] = Field(default=None, alias="device_type_hash") + device_id: Optional[str] = Field(default=None, alias="device_id_hash") owner: Optional[str] = Field(default=None, alias="device_owner_hash") hw_ver: Optional[str] = None is_support_iot_cloud: Optional[bool] = None diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index f1995db82..af6a2c7f0 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -211,6 +211,7 @@ def __init__( # checks in accessors. the @updated_required decorator does not ensure # mypy that these are not accessed incorrectly. self._last_update: Any = None + self._discovery_info: Optional[Dict[str, Any]] = None self._sys_info: Any = None # TODO: this is here to avoid changing tests self._features: Set[str] = set() @@ -371,6 +372,7 @@ def update_from_discover_info(self, info: Dict[str, Any]) -> None: # This allows setting of some info properties directly # from partial discovery info that will then be found # by the requires_update decorator + self._discovery_info = info self._set_sys_info(info) def _set_sys_info(self, sys_info: Dict[str, Any]) -> None: diff --git a/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json b/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json index 448d6f2cc..2425c17f3 100644 --- a/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json +++ b/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json @@ -1,43 +1,46 @@ { - "emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.common.emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.smartbulb.lightingservice": { - "err_code": -1, - "err_msg": "module not support" + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "HS100(UK)", + "device_type": "IOT.SMARTPLUGSWITCH", + "factory_default": true, + "hw_ver": "4.1", + "ip": "127.0.0.123", + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false + }, + "owner": "00000000000000000000000000000000" }, "system": { "get_sysinfo": { - "active_mode": "schedule", - "alias": "Unused 3", + "active_mode": "none", + "alias": "Bedroom Lamp 2", "dev_name": "Smart Wi-Fi Plug", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, "feature": "TIM", - "fwId": "00000000000000000000000000000000", "hwId": "00000000000000000000000000000000", "hw_ver": "4.1", "icon_hash": "", - "latitude": 0, + "latitude_i": 0, "led_off": 0, - "longitude": 0, + "longitude_i": 0, "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", "model": "HS100(UK)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, "oemId": "00000000000000000000000000000000", "on_time": 0, "relay_state": 0, - "rssi": -63, + "rssi": -66, + "status": "new", "sw_ver": "1.1.0 Build 201016 Rel.175121", - "type": "IOT.SMARTPLUGSWITCH", "updating": 0 } } diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index acd9de1ee..ee679cae8 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -294,9 +294,10 @@ def __init__(self, info): for target in info: # print("target %s" % target) - for cmd in info[target]: - # print("initializing tgt %s cmd %s" % (target, cmd)) - proto[target][cmd] = info[target][cmd] + if target != "discovery_result": + for cmd in info[target]: + # print("initializing tgt %s cmd %s" % (target, cmd)) + proto[target][cmd] = info[target][cmd] # if we have emeter support, we need to add the missing pieces for module in ["emeter", "smartlife.iot.common.emeter"]: if ( From 63d64ad9204cea3470cc2249bdcfc7ff7ff9cbef Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Thu, 30 Nov 2023 12:10:49 +0000 Subject: [PATCH 185/892] Add support for the protocol used by TAPO devices and some newer KASA devices. (#552) * Add Tapo protocol support * Update get_device_instance and test_unsupported following review --- kasa/aesprotocol.py | 498 +++++++++++++++++++++++++++++++++++ kasa/device_factory.py | 2 + kasa/device_type.py | 1 + kasa/discover.py | 59 +++-- kasa/tapo/tapodevice.py | 164 ++++++++++++ kasa/tapo/tapoplug.py | 73 +++++ kasa/tests/test_discovery.py | 4 +- 7 files changed, 776 insertions(+), 25 deletions(-) create mode 100644 kasa/aesprotocol.py create mode 100644 kasa/tapo/tapodevice.py create mode 100644 kasa/tapo/tapoplug.py diff --git a/kasa/aesprotocol.py b/kasa/aesprotocol.py new file mode 100644 index 000000000..98776ce2a --- /dev/null +++ b/kasa/aesprotocol.py @@ -0,0 +1,498 @@ +"""Implementation of the TP-Link AES Protocol. + +Based on the work of https://github.com/petretiandrea/plugp100 +under compatible GNU GPL3 license. +""" + +import asyncio +import base64 +import hashlib +import logging +import time +import uuid +from pprint import pformat as pf +from typing import Dict, Optional, Union + +import httpx +from cryptography.hazmat.primitives import hashes, padding, serialization +from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from .credentials import Credentials +from .exceptions import AuthenticationException, SmartDeviceException +from .json import dumps as json_dumps +from .json import loads as json_loads +from .protocol import TPLinkProtocol + +_LOGGER = logging.getLogger(__name__) +logging.getLogger("httpx").propagate = False + + +def _md5(payload: bytes) -> bytes: + digest = hashes.Hash(hashes.MD5()) # noqa: S303 + digest.update(payload) + hash = digest.finalize() + return hash + + +def _sha1(payload: bytes) -> str: + sha1_algo = hashlib.sha1() # noqa: S324 + sha1_algo.update(payload) + return sha1_algo.hexdigest() + + +class TPLinkAes(TPLinkProtocol): + """Implementation of the AES encryption protocol. + + AES is the name used in device discovery for TP-Link's TAPO encryption + protocol, sometimes used by newer firmware versions on kasa devices. + """ + + DEFAULT_PORT = 80 + DEFAULT_TIMEOUT = 5 + SESSION_COOKIE_NAME = "TP_SESSIONID" + COMMON_HEADERS = { + "Content-Type": "application/json", + "requestByApp": "true", + "Accept": "application/json", + } + + def __init__( + self, + host: str, + *, + credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, + ) -> None: + super().__init__(host=host, port=self.DEFAULT_PORT) + + self.credentials = ( + credentials + if credentials and credentials.username and credentials.password + else Credentials(username="", password="") + ) + + self._local_seed: Optional[bytes] = None + self.local_auth_hash = self.generate_auth_hash(self.credentials) + self.local_auth_owner = self.generate_owner_hash(self.credentials).hex() + self.kasa_setup_auth_hash = None + self.blank_auth_hash = None + self.handshake_lock = asyncio.Lock() + self.query_lock = asyncio.Lock() + self.handshake_done = False + + self.encryption_session: Optional[AesEncyptionSession] = None + self.session_expire_at: Optional[float] = None + + self.timeout = timeout if timeout else self.DEFAULT_TIMEOUT + self.session_cookie = None + self.terminal_uuid = None + self.http_client: Optional[httpx.AsyncClient] = None + self.request_id_generator = SnowflakeId(1, 1) + self.login_token = None + + _LOGGER.debug("Created AES object for %s", self.host) + + def hash_credentials(self, credentials, try_login_version2): + """Hash the credentials.""" + if try_login_version2: + un = base64.b64encode( + _sha1(credentials.username.encode()).encode() + ).decode() + pw = base64.b64encode( + _sha1(credentials.password.encode()).encode() + ).decode() + else: + un = base64.b64encode( + _sha1(credentials.username.encode()).encode() + ).decode() + pw = base64.b64encode(credentials.password.encode()).decode() + return un, pw + + async def client_post(self, url, params=None, data=None, json=None, headers=None): + """Send an http post request to the device.""" + response_data = None + cookies = None + if self.session_cookie: + cookies = httpx.Cookies() + cookies.set(self.SESSION_COOKIE_NAME, self.session_cookie) + self.http_client.cookies.clear() + resp = await self.http_client.post( + url, + params=params, + data=data, + json=json, + timeout=self.timeout, + cookies=cookies, + headers=self.COMMON_HEADERS, + ) + if resp.status_code == 200: + response_data = resp.json() + + return resp.status_code, response_data + + async def send_secure_passthrough(self, request): + """Send encrypted message as passthrough.""" + url = f"http://{self.host}/app" + if self.login_token: + url += f"?token={self.login_token}" + raw_request = json_dumps(request) + encrypted_payload = self.encryption_session.encrypt(raw_request.encode()) + passthrough_request = { + "method": "securePassthrough", + "params": {"request": encrypted_payload.decode()}, + } + status_code, resp_dict = await self.client_post(url, json=passthrough_request) + if status_code == 200 and resp_dict["error_code"] == 0: + response = self.encryption_session.decrypt( + resp_dict["result"]["response"].encode() + ) + resp_dict = json_loads(response) + if resp_dict["error_code"] != 0: + raise SmartDeviceException( + f"Could not complete send, response was {resp_dict}", + ) + if "result" in resp_dict: + return resp_dict["result"] + else: + raise AuthenticationException("Could not complete send") + + def get_aes_request(self, method, params=None): + """Get a request message.""" + request = { + "method": method, + "params": params, + "requestID": self.request_id_generator.generate_id(), + "request_time_milis": round(time.time() * 1000), + "terminal_uuid": self.terminal_uuid, + } + return request + + async def perform_login(self, login_v2): + """Login to the device.""" + self.login_token = None + + un, pw = self.hash_credentials(self.credentials, login_v2) + params = {"password": pw, "username": un} + request = self.get_aes_request("login_device", params) + try: + result = await self.send_secure_passthrough(request) + except SmartDeviceException as ex: + raise AuthenticationException(ex) from ex + self.login_token = result["token"] + + async def perform_handshake(self): + """Perform the handshake.""" + _LOGGER.debug("Will perform handshaking...") + _LOGGER.debug("Generating keypair") + + self.handshake_done = False + self.session_expire_at = None + self.session_cookie = None + + url = f"http://{self.host}/app" + key_pair = KeyPair.create_key_pair() + + pub_key = ( + "-----BEGIN PUBLIC KEY-----\n" + + key_pair.get_public_key() + + "\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}") + + status_code, resp_dict = await self.client_post(url, json=request_body) + + _LOGGER.debug(f"Device responded with: {resp_dict}") + + if status_code == 200 and resp_dict["error_code"] == 0: + _LOGGER.debug("Decoding handshake key...") + handshake_key = resp_dict["result"]["key"] + + self.session_cookie = self.http_client.cookies.get( # type: ignore + self.SESSION_COOKIE_NAME + ) + if not self.session_cookie: + self.session_cookie = self.http_client.cookies.get( # type: ignore + "SESSIONID" + ) + + self.session_expire_at = time.time() + 86400 + self.encryption_session = AesEncyptionSession.create_from_keypair( + handshake_key, key_pair + ) + + self.terminal_uuid = base64.b64encode(_md5(uuid.uuid4().bytes)).decode( + "UTF-8" + ) + self.handshake_done = True + + _LOGGER.debug("Handshake with %s complete", self.host) + + else: + raise AuthenticationException("Could not complete handshake") + + def handshake_session_expired(self): + """Return true if session has expired.""" + return ( + self.session_expire_at is None or self.session_expire_at - time.time() <= 0 + ) + + @staticmethod + def generate_auth_hash(creds: Credentials): + """Generate an md5 auth hash for the protocol on the supplied credentials.""" + un = creds.username or "" + pw = creds.password or "" + return _md5(_md5(un.encode()) + _md5(pw.encode())) + + @staticmethod + def generate_owner_hash(creds: Credentials): + """Return the MD5 hash of the username in this object.""" + un = creds.username or "" + return _md5(un.encode()) + + async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: + """Query the device retrying for retry_count on failure.""" + async with self.query_lock: + return await self._query(request, retry_count) + + async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: + for retry in range(retry_count + 1): + try: + return await self._execute_query(request, retry) + except httpx.CloseError as sdex: + await self.close() + if retry >= retry_count: + _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {sdex}" + ) from sdex + continue + except httpx.ConnectError as cex: + await self.close() + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {cex}" + ) from cex + except TimeoutError as tex: + await self.close() + raise SmartDeviceException( + f"Unable to connect to the device, timed out: {self.host}: {tex}" + ) from tex + except AuthenticationException as auex: + _LOGGER.debug("Unable to authenticate with %s, not retrying", self.host) + raise auex + except Exception as ex: + await self.close() + if retry >= retry_count: + _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {ex}" + ) from ex + continue + + # make mypy happy, this should never be reached.. + raise SmartDeviceException("Query reached somehow to unreachable") + + async def _execute_query(self, request: Union[str, Dict], retry_count: int) -> Dict: + _LOGGER.debug( + "%s >> %s", + self.host, + _LOGGER.isEnabledFor(logging.DEBUG) and pf(request), + ) + + if not self.http_client: + self.http_client = httpx.AsyncClient() + + if not self.handshake_done or self.handshake_session_expired(): + try: + await self.perform_handshake() + await self.perform_login(False) + except AuthenticationException: + await self.perform_handshake() + await self.perform_login(True) + + if isinstance(request, dict): + aes_method = next(iter(request)) + aes_params = request[aes_method] + else: + aes_method = request + aes_params = None + + aes_request = self.get_aes_request(aes_method, aes_params) + response_data = await self.send_secure_passthrough(aes_request) + + _LOGGER.debug( + "%s << %s", + self.host, + _LOGGER.isEnabledFor(logging.DEBUG) and pf(response_data), + ) + + return response_data + + async def close(self) -> None: + """Close the protocol.""" + client = self.http_client + self.http_client = None + if client: + await client.aclose() + + +class AesEncyptionSession: + """Class for an AES encryption session.""" + + @staticmethod + def create_from_keypair(handshake_key: str, keypair): + """Create the encryption session.""" + handshake_key_bytes: bytes = base64.b64decode(handshake_key.encode("UTF-8")) + private_key_data = base64.b64decode(keypair.get_private_key().encode("UTF-8")) + + private_key = serialization.load_der_private_key(private_key_data, None, None) + key_and_iv = private_key.decrypt( + handshake_key_bytes, asymmetric_padding.PKCS1v15() + ) + if key_and_iv is None: + raise ValueError("Decryption failed!") + + return AesEncyptionSession(key_and_iv[:16], key_and_iv[16:]) + + def __init__(self, key, iv): + self.cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + self.padding_strategy = padding.PKCS7(algorithms.AES.block_size) + + def encrypt(self, data) -> bytes: + """Encrypt the message.""" + encryptor = self.cipher.encryptor() + padder = self.padding_strategy.padder() + padded_data = padder.update(data) + padder.finalize() + encrypted = encryptor.update(padded_data) + encryptor.finalize() + return base64.b64encode(encrypted) + + def decrypt(self, data) -> str: + """Decrypt the message.""" + decryptor = self.cipher.decryptor() + unpadder = self.padding_strategy.unpadder() + decrypted = decryptor.update(base64.b64decode(data)) + decryptor.finalize() + unpadded_data = unpadder.update(decrypted) + unpadder.finalize() + return unpadded_data.decode() + + +class KeyPair: + """Class for generating key pairs.""" + + @staticmethod + def create_key_pair(key_size: int = 1024): + """Create a key pair.""" + private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size) + public_key = private_key.public_key() + + private_key_bytes = private_key.private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + public_key_bytes = public_key.public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + return KeyPair( + private_key=base64.b64encode(private_key_bytes).decode("UTF-8"), + public_key=base64.b64encode(public_key_bytes).decode("UTF-8"), + ) + + def __init__(self, private_key: str, public_key: str): + self.private_key = private_key + self.public_key = public_key + + def get_private_key(self) -> str: + """Get the private key.""" + return self.private_key + + def get_public_key(self) -> str: + """Get the public key.""" + return self.public_key + + +class SnowflakeId: + """Class for generating snowflake ids.""" + + EPOCH = 1420041600000 # Custom epoch (in milliseconds) + WORKER_ID_BITS = 5 + DATA_CENTER_ID_BITS = 5 + SEQUENCE_BITS = 12 + + MAX_WORKER_ID = (1 << WORKER_ID_BITS) - 1 + MAX_DATA_CENTER_ID = (1 << DATA_CENTER_ID_BITS) - 1 + + SEQUENCE_MASK = (1 << SEQUENCE_BITS) - 1 + + def __init__(self, worker_id, data_center_id): + if worker_id > SnowflakeId.MAX_WORKER_ID or worker_id < 0: + raise ValueError( + "Worker ID can't be greater than " + + str(SnowflakeId.MAX_WORKER_ID) + + " or less than 0" + ) + if data_center_id > SnowflakeId.MAX_DATA_CENTER_ID or data_center_id < 0: + raise ValueError( + "Data center ID can't be greater than " + + str(SnowflakeId.MAX_DATA_CENTER_ID) + + " or less than 0" + ) + + self.worker_id = worker_id + self.data_center_id = data_center_id + self.sequence = 0 + self.last_timestamp = -1 + + def generate_id(self): + """Generate a snowflake id.""" + timestamp = self._current_millis() + + if timestamp < self.last_timestamp: + raise ValueError("Clock moved backwards. Refusing to generate ID.") + + if timestamp == self.last_timestamp: + # Within the same millisecond, increment the sequence number + self.sequence = (self.sequence + 1) & SnowflakeId.SEQUENCE_MASK + if self.sequence == 0: + # Sequence exceeds its bit range, wait until the next millisecond + timestamp = self._wait_next_millis(self.last_timestamp) + else: + # New millisecond, reset the sequence number + self.sequence = 0 + + # Update the last timestamp + self.last_timestamp = timestamp + + # Generate and return the final ID + return ( + ( + (timestamp - SnowflakeId.EPOCH) + << ( + SnowflakeId.WORKER_ID_BITS + + SnowflakeId.SEQUENCE_BITS + + SnowflakeId.DATA_CENTER_ID_BITS + ) + ) + | ( + self.data_center_id + << (SnowflakeId.SEQUENCE_BITS + SnowflakeId.WORKER_ID_BITS) + ) + | (self.worker_id << SnowflakeId.SEQUENCE_BITS) + | self.sequence + ) + + def _current_millis(self): + return round(time.time() * 1000) + + def _wait_next_millis(self, last_timestamp): + timestamp = self._current_millis() + while timestamp <= last_timestamp: + timestamp = self._current_millis() + return timestamp diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 049969fba..9122003c8 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -14,6 +14,7 @@ from .smartlightstrip import SmartLightStrip from .smartplug import SmartPlug from .smartstrip import SmartStrip +from .tapo.tapoplug import TapoPlug DEVICE_TYPE_TO_CLASS = { DeviceType.Plug: SmartPlug, @@ -21,6 +22,7 @@ DeviceType.Strip: SmartStrip, DeviceType.Dimmer: SmartDimmer, DeviceType.LightStrip: SmartLightStrip, + DeviceType.TapoPlug: TapoPlug, } _LOGGER = logging.getLogger(__name__) diff --git a/kasa/device_type.py b/kasa/device_type.py index 162fc4f27..c86573065 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -14,6 +14,7 @@ class DeviceType(Enum): StripSocket = "stripsocket" Dimmer = "dimmer" LightStrip = "lightstrip" + TapoPlug = "tapoplug" Unknown = "unknown" @staticmethod diff --git a/kasa/discover.py b/kasa/discover.py index 61faeaff6..59849bc0e 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -15,14 +15,16 @@ except ImportError: from pydantic import BaseModel, Field +from kasa.aesprotocol import TPLinkAes from kasa.credentials import Credentials from kasa.exceptions import UnsupportedDeviceException from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads from kasa.klapprotocol import TPLinkKlap -from kasa.protocol import TPLinkSmartHomeProtocol +from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol from kasa.smartdevice import SmartDevice, SmartDeviceException from kasa.smartplug import SmartPlug +from kasa.tapo.tapoplug import TapoPlug from .device_factory import get_device_class_from_info @@ -378,27 +380,38 @@ def _get_device_instance( f"Unable to read response from device: {ip}: {ex}" ) from ex - if ( - discovery_result.mgt_encrypt_schm.encrypt_type == "KLAP" - and discovery_result.mgt_encrypt_schm.lv is None - ): - type_ = discovery_result.device_type - device_class = None - if type_.upper() == "IOT.SMARTPLUGSWITCH": - device_class = SmartPlug - - if device_class: - _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) - device = device_class(ip, port=port, credentials=credentials) - device.update_from_discover_info(discovery_result.get_dict()) - device.protocol = TPLinkKlap(ip, credentials=credentials) - return device - else: - raise UnsupportedDeviceException( - f"Unsupported device {ip} of type {type_}: {info}" - ) - else: - raise UnsupportedDeviceException(f"Unsupported device {ip}: {info}") + type_ = discovery_result.device_type + encrypt_type_ = ( + f"{type_.split('.')[0]}.{discovery_result.mgt_encrypt_schm.encrypt_type}" + ) + device_class = None + + supported_device_types: dict[str, Type[SmartDevice]] = { + "SMART.TAPOPLUG": TapoPlug, + "SMART.KASAPLUG": TapoPlug, + "IOT.SMARTPLUGSWITCH": SmartPlug, + } + supported_device_protocols: dict[str, Type[TPLinkProtocol]] = { + "IOT.KLAP": TPLinkKlap, + "SMART.AES": TPLinkAes, + } + + if (device_class := supported_device_types.get(type_)) is None: + _LOGGER.warning("Got unsupported device type: %s", type_) + raise UnsupportedDeviceException( + f"Unsupported device {ip} of type {type_}: {info}" + ) + if (protocol_class := supported_device_protocols.get(encrypt_type_)) is None: + _LOGGER.warning("Got unsupported device type: %s", encrypt_type_) + raise UnsupportedDeviceException( + f"Unsupported encryption scheme {ip} of type {encrypt_type_}: {info}" + ) + + _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) + device = device_class(ip, port=port, credentials=credentials) + device.protocol = protocol_class(ip, credentials=credentials) + device.update_from_discover_info(discovery_result.get_dict()) + return device class DiscoveryResult(BaseModel): @@ -415,7 +428,7 @@ class EncryptionScheme(BaseModel): is_support_https: Optional[bool] = None encrypt_type: Optional[str] = None http_port: Optional[int] = None - lv: Optional[int] = None + lv: Optional[int] = 1 device_type: str = Field(alias="device_type_text") device_model: str = Field(alias="model") diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py new file mode 100644 index 000000000..e02983d39 --- /dev/null +++ b/kasa/tapo/tapodevice.py @@ -0,0 +1,164 @@ +"""Module for a TAPO device.""" +import base64 +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional, Set, cast + +from ..aesprotocol import TPLinkAes +from ..credentials import Credentials +from ..exceptions import AuthenticationException +from ..smartdevice import SmartDevice + +_LOGGER = logging.getLogger(__name__) + + +class TapoDevice(SmartDevice): + """Base class to represent a TAPO device.""" + + def __init__( + self, + host: str, + *, + port: Optional[int] = None, + credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, + ) -> None: + super().__init__(host, port=port, credentials=credentials, timeout=timeout) + self._state_information: Dict[str, Any] = {} + self._discovery_info: Optional[Dict[str, Any]] = None + self.protocol = TPLinkAes(host, credentials=credentials, timeout=timeout) + + async def update(self, update_children: bool = True): + """Update the device.""" + if self.credentials is None or self.credentials.username is None: + raise AuthenticationException("Tapo plug requires authentication.") + + self._info = await self.protocol.query("get_device_info") + self._usage = await self.protocol.query("get_device_usage") + self._time = await self.protocol.query("get_device_time") + + self._last_update = self._data = { + "info": self._info, + "usage": self._usage, + "time": self._time, + } + + _LOGGER.debug("Got an update: %s", self._data) + + @property + def sys_info(self) -> Dict[str, Any]: + """Returns the device info.""" + return self._info + + @property + def model(self) -> str: + """Returns the device model.""" + return str(self._info.get("model")) + + @property + def alias(self) -> str: + """Returns the device alias or nickname.""" + return base64.b64decode(str(self._info.get("nickname"))).decode() + + @property + def time(self) -> datetime: + """Return the time.""" + td = timedelta(minutes=cast(float, self._time.get("time_diff"))) + if self._time.get("region"): + tz = timezone(td, str(self._time.get("region"))) + else: + # in case the device returns a blank region this will result in the + # tzname being a UTC offset + tz = timezone(td) + return datetime.fromtimestamp( + cast(float, self._time.get("timestamp")), + tz=tz, + ) + + @property + def timezone(self) -> Dict: + """Return the timezone and time_difference.""" + ti = self.time + return {"timezone": ti.tzname()} + + @property + def hw_info(self) -> Dict: + """Return hardware info for the device.""" + return { + "sw_ver": self._info.get("fw_ver"), + "hw_ver": self._info.get("hw_ver"), + "mac": self._info.get("mac"), + "type": self._info.get("type"), + "hwId": self._info.get("device_id"), + "dev_name": self.alias, + "oemId": self._info.get("oem_id"), + } + + @property + 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, + } + return loc + + @property + def rssi(self) -> Optional[int]: + """Return the rssi.""" + rssi = self._info.get("rssi") + return int(rssi) if rssi else None + + @property + def mac(self) -> str: + """Return the mac formatted with colons.""" + return str(self._info.get("mac")).replace("-", ":") + + @property + def device_id(self) -> str: + """Return the device id.""" + return str(self._info.get("device_id")) + + @property + def internal_state(self) -> Any: + """Return all the internal state data.""" + return self._data + + async def _query_helper( + self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None + ) -> Any: + res = await self.protocol.query({cmd: arg}) + + return res + + @property + def state_information(self) -> Dict[str, Any]: + """Return the key state information.""" + return { + "overheated": self._info.get("overheated"), + "signal_level": self._info.get("signal_level"), + "SSID": base64.b64decode(str(self._info.get("ssid"))).decode(), + } + + @property + def features(self) -> Set[str]: + """Return the list of supported features.""" + # TODO: + return set() + + @property + def is_on(self) -> bool: + """Return true if the device is on.""" + return bool(self._info.get("device_on")) + + async def turn_on(self, **kwargs): + """Turn on the device.""" + await self.protocol.query({"set_device_info": {"device_on": True}}) + + async def turn_off(self, **kwargs): + """Turn off the device.""" + await self.protocol.query({"set_device_info": {"device_on": False}}) + + def update_from_discover_info(self, info): + """Update state from info from the discover call.""" + self._discovery_info = info diff --git a/kasa/tapo/tapoplug.py b/kasa/tapo/tapoplug.py new file mode 100644 index 000000000..c291c7d1b --- /dev/null +++ b/kasa/tapo/tapoplug.py @@ -0,0 +1,73 @@ +"""Module for a TAPO Plug.""" +import logging +from datetime import datetime, timedelta +from typing import Any, Dict, Optional, cast + +from ..credentials import Credentials +from ..emeterstatus import EmeterStatus +from ..smartdevice import DeviceType +from .tapodevice import TapoDevice + +_LOGGER = logging.getLogger(__name__) + + +class TapoPlug(TapoDevice): + """Class to represent a TAPO Plug.""" + + def __init__( + self, + host: str, + *, + port: Optional[int] = None, + credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, + ) -> None: + super().__init__(host, port=port, credentials=credentials, timeout=timeout) + self._device_type = DeviceType.Plug + + async def update(self, update_children: bool = True): + """Call the device endpoint and update the device data.""" + await super().update(update_children) + + self._energy = await self.protocol.query("get_energy_usage") + self._emeter = await self.protocol.query("get_current_power") + + self._data["energy"] = self._energy + self._data["emeter"] = self._emeter + + _LOGGER.debug("Got an update: %s %s", self._energy, self._emeter) + + @property + def state_information(self) -> Dict[str, Any]: + """Return the key state information.""" + return { + **super().state_information, + **{ + "On since": self.on_since, + "auto_off_status": self._info.get("auto_off_status"), + "auto_off_remain_time": self._info.get("auto_off_remain_time"), + }, + } + + @property + def emeter_realtime(self) -> EmeterStatus: + """Get the emeter status.""" + return EmeterStatus({"power_mw": self._energy.get("current_power")}) + + @property + def emeter_today(self) -> Optional[float]: + """Get the emeter value for today.""" + return None + + @property + def emeter_this_month(self) -> Optional[float]: + """Get the emeter value for this month.""" + return None + + @property + def on_since(self) -> Optional[datetime]: + """Return the time that the device was turned on or None if turned off.""" + if not self._info.get("device_on"): + return None + on_time = cast(float, self._info.get("on_time")) + return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 7e1dabc0b..626afd180 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -114,7 +114,7 @@ def mock_discover(self): "result": { "device_id": "xx", "owner": "xx", - "device_type": "SMART.TAPOPLUG", + "device_type": "SMART.TAPOXMASTREE", "device_model": "P110(EU)", "ip": "127.0.0.1", "mac": "48-22xxx", @@ -150,7 +150,7 @@ def mock_discover(self): discovery_data = UNSUPPORTED with pytest.raises( UnsupportedDeviceException, - match=f"Unsupported device {host}: {re.escape(str(UNSUPPORTED))}", + match=f"Unsupported device {host} of type SMART.TAPOXMASTREE: {re.escape(str(UNSUPPORTED))}", ): await Discover.discover_single(host) From a6b7d73d79aba03c38296e575dbdef021d3c170f Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Sat, 2 Dec 2023 16:33:35 +0000 Subject: [PATCH 186/892] Update dump_devinfo to produce new TAPO/SMART fixtures (#561) --- devtools/dump_devinfo.py | 109 ++++++++++++++++++++++++++++++++++----- kasa/tapo/__init__.py | 5 ++ kasa/tapo/tapodevice.py | 2 + 3 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 kasa/tapo/__init__.py diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 0c33ac0b4..777ee1050 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -7,6 +7,7 @@ Executing this script will several modules and methods one by one, and finally execute a query to query all of them at once. """ +import base64 import collections.abc import json import logging @@ -16,8 +17,9 @@ import asyncclick as click -from kasa import Credentials, Discover +from kasa import Credentials, Discover, SmartDevice from kasa.discover import DiscoveryResult +from kasa.tapo.tapodevice import TapoDevice Call = namedtuple("Call", "module method") @@ -53,6 +55,9 @@ def scrub(res): v = 0 elif k in ["ip"]: v = "127.0.0.123" + elif k in ["ssid"]: + # Need a valid base64 value here + v = base64.b64encode(b"##MASKEDNAME##").decode() else: v = re.sub(r"\w", "0", v) @@ -92,6 +97,28 @@ async def cli(host, debug, username, password): if debug: logging.basicConfig(level=logging.DEBUG) + credentials = Credentials(username=username, password=password) + device = await Discover.discover_single(host, credentials=credentials) + + if isinstance(device, TapoDevice): + save_to, final = await get_smart_fixture(device) + else: + save_to, final = await get_legacy_fixture(device) + + pprint(scrub(final)) + save = click.prompt(f"Do you want to save the above content to {save_to} (y/n)") + if save == "y": + click.echo(f"Saving info to {save_to}") + + with open(save_to, "w") as f: + json.dump(final, f, sort_keys=True, indent=4) + f.write("\n") + else: + click.echo("Not saving.") + + +async def get_legacy_fixture(device): + """Get fixture for legacy IOT style protocol.""" items = [ Call(module="system", method="get_sysinfo"), Call(module="emeter", method="get_realtime"), @@ -106,9 +133,6 @@ async def cli(host, debug, username, password): successes = [] - credentials = Credentials(username=username, password=password) - device = await Discover.discover_single(host, credentials=credentials) - for test_call in items: try: click.echo(f"Testing {test_call}..", nl=False) @@ -127,7 +151,6 @@ async def cli(host, debug, username, password): final_query = defaultdict(defaultdict) final = defaultdict(defaultdict) - for succ, resp in successes: final_query[succ.module][succ.method] = None final[succ.module][succ.method] = resp @@ -160,16 +183,74 @@ async def cli(host, debug, username, password): sw_version = sysinfo["sw_ver"] sw_version = sw_version.split(" ", maxsplit=1)[0] save_to = f"{model}_{hw_version}_{sw_version}.json" - pprint(scrub(final)) - save = click.prompt(f"Do you want to save the above content to {save_to} (y/n)") - if save == "y": - click.echo(f"Saving info to {save_to}") + return save_to, final - with open(save_to, "w") as f: - json.dump(final, f, sort_keys=True, indent=4) - f.write("\n") - else: - click.echo("Not saving.") + +async def get_smart_fixture(device: SmartDevice): + """Get fixture for new TAPO style protocol.""" + items = [ + Call(module="component_nego", method="component_nego"), + Call(module="device_info", method="get_device_info"), + Call(module="device_usage", method="get_device_usage"), + Call(module="device_time", method="get_device_time"), + Call(module="energy_usage", method="get_energy_usage"), + Call(module="current_power", method="get_current_power"), + Call( + module="child_device_component_list", + method="get_child_device_component_list", + ), + ] + + successes = [] + + for test_call in items: + try: + click.echo(f"Testing {test_call}..", nl=False) + response = await device.protocol.query(test_call.method) + except Exception as ex: + click.echo(click.style(f"FAIL {ex}", fg="red")) + else: + if not response: + click.echo(click.style("FAIL not suported", fg="red")) + else: + click.echo(click.style("OK", fg="green")) + successes.append(test_call) + + requests = [] + for succ in successes: + requests.append({"method": succ.method}) + + final_query = {"multipleRequest": {"requests": requests}} + + try: + responses = 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" + ) + ) + final = {} + for response in responses["responses"]: + final[response["method"]] = response["result"] + + if device._discovery_info: + # 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. + dr = DiscoveryResult(**device._discovery_info) + final["discovery_result"] = dr.dict( + by_alias=False, exclude_unset=True, exclude_none=True, exclude_defaults=True + ) + + click.echo("Got %s successes" % len(successes)) + click.echo(click.style("## device info file ##", bold=True)) + + hw_version = final["get_device_info"]["hw_ver"] + sw_version = final["get_device_info"]["fw_ver"] + model = final["get_device_info"]["model"] + sw_version = sw_version.split(" ", maxsplit=1)[0] + + return f"{model}.smart_{hw_version}_{sw_version}.json", final if __name__ == "__main__": diff --git a/kasa/tapo/__init__.py b/kasa/tapo/__init__.py new file mode 100644 index 000000000..0ec72f3dc --- /dev/null +++ b/kasa/tapo/__init__.py @@ -0,0 +1,5 @@ +"""Package for supporting tapo-branded and newer kasa devices.""" +from .tapodevice import TapoDevice +from .tapoplug import TapoPlug + +__all__ = ["TapoDevice", "TapoPlug"] diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index e02983d39..2ba039565 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -33,11 +33,13 @@ async def update(self, update_children: bool = True): if self.credentials is None or self.credentials.username is None: raise AuthenticationException("Tapo plug requires authentication.") + self._components = await self.protocol.query("component_nego") self._info = await self.protocol.query("get_device_info") self._usage = await self.protocol.query("get_device_usage") self._time = await self.protocol.query("get_device_time") self._last_update = self._data = { + "components": self._components, "info": self._info, "usage": self._usage, "time": self._time, From bfd1d6ae0aa2cee0a051bf473a9dd080fad0626b Mon Sep 17 00:00:00 2001 From: Steven Bytnar Date: Sun, 3 Dec 2023 08:41:46 -0600 Subject: [PATCH 187/892] Kasa KP125M basic emeter support (#560) * Add KP125M basic emeter support. * Reduce diff. * PR Comments --- README.md | 1 + kasa/tapo/tapoplug.py | 29 +++++++++++++++++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f65747782..f2dcf46c8 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ If your device is unlisted but working, please open a pull request to update the * KP105 * KP115 * KP125 +* KP125M * KP401 * EP10 diff --git a/kasa/tapo/tapoplug.py b/kasa/tapo/tapoplug.py index c291c7d1b..84d00bc8c 100644 --- a/kasa/tapo/tapoplug.py +++ b/kasa/tapo/tapoplug.py @@ -5,7 +5,8 @@ from ..credentials import Credentials from ..emeterstatus import EmeterStatus -from ..smartdevice import DeviceType +from ..modules import Emeter +from ..smartdevice import DeviceType, requires_update from .tapodevice import TapoDevice _LOGGER = logging.getLogger(__name__) @@ -24,6 +25,15 @@ def __init__( ) -> None: super().__init__(host, port=port, credentials=credentials, timeout=timeout) self._device_type = DeviceType.Plug + self.modules: Dict[str, Any] = {} + self.emeter_type = "emeter" + self.modules["emeter"] = Emeter(self, self.emeter_type) + + @property # type: ignore + @requires_update + def has_emeter(self) -> bool: + """Return that the plug has an emeter.""" + return True async def update(self, update_children: bool = True): """Call the device endpoint and update the device data.""" @@ -52,17 +62,24 @@ def state_information(self) -> Dict[str, Any]: @property def emeter_realtime(self) -> EmeterStatus: """Get the emeter status.""" - return EmeterStatus({"power_mw": self._energy.get("current_power")}) + return EmeterStatus( + { + "power_mw": self._energy.get("current_power"), + "total": self._convert_energy_data( + self._energy.get("today_energy"), 1 / 1000 + ), + } + ) @property def emeter_today(self) -> Optional[float]: """Get the emeter value for today.""" - return None + return self._convert_energy_data(self._energy.get("today_energy"), 1 / 1000) @property def emeter_this_month(self) -> Optional[float]: """Get the emeter value for this month.""" - return None + return self._convert_energy_data(self._energy.get("month_energy"), 1 / 1000) @property def on_since(self) -> Optional[datetime]: @@ -71,3 +88,7 @@ def on_since(self) -> Optional[datetime]: return None on_time = cast(float, self._info.get("on_time")) return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) + + def _convert_energy_data(self, data, scale) -> Optional[float]: + """Return adjusted emeter information.""" + return data if not data else data * scale From 347cbfe3bdaa4c492ef74508bbe47bdfdc49e587 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 4 Dec 2023 16:44:27 +0100 Subject: [PATCH 188/892] Make timeout configurable for cli (#564) --- kasa/cli.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/kasa/cli.py b/kasa/cli.py index e71c7b9fd..c0a380dc9 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -131,6 +131,13 @@ def _device_to_serializable(val: SmartDevice): @click.option( "--json", default=False, is_flag=True, help="Output raw device response as JSON." ) +@click.option( + "--timeout", + envvar="KASA_TIMEOUT", + default=5, + required=False, + help="Timeout for device communications.", +) @click.option( "--discovery-timeout", envvar="KASA_DISCOVERY_TIMEOUT", @@ -163,6 +170,7 @@ async def cli( debug, type, json, + timeout, discovery_timeout, username, password, @@ -234,7 +242,7 @@ def _nop_echo(*args, **kwargs): if type is not None: device_type = DeviceType.from_value(type) dev = await SmartDevice.connect( - host, credentials=credentials, device_type=device_type + host, credentials=credentials, device_type=device_type, timeout=timeout ) else: echo("No --type defined, discovering..") From 4a0019950661116ccf36d45c5c43b29a6f918daa Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Mon, 4 Dec 2023 18:50:05 +0000 Subject: [PATCH 189/892] Add klap support for TAPO protocol by splitting out Transports and Protocols (#557) * Add support for TAPO/SMART KLAP and seperate transports from protocols * Add tests and some review changes * Update following review * Updates following review --- kasa/__init__.py | 6 +- kasa/aesprotocol.py | 498 ------------------ kasa/aestransport.py | 338 ++++++++++++ kasa/device_factory.py | 44 +- kasa/discover.py | 46 +- kasa/iotprotocol.py | 100 ++++ kasa/{klapprotocol.py => klaptransport.py} | 299 ++++++----- kasa/protocol.py | 52 ++ kasa/smartdevice.py | 2 +- kasa/smartprotocol.py | 219 ++++++++ kasa/tapo/tapodevice.py | 4 +- kasa/tests/conftest.py | 251 +++++++-- kasa/tests/fixtures/smart/P110_1.0_1.3.0.json | 180 +++++++ kasa/tests/newfakes.py | 39 +- kasa/tests/test_cli.py | 35 +- kasa/tests/test_device_factory.py | 99 ++-- kasa/tests/test_discovery.py | 94 ++-- kasa/tests/test_klapprotocol.py | 137 +++-- kasa/tests/test_plug.py | 13 +- kasa/tests/test_readme_examples.py | 14 +- kasa/tests/test_smartdevice.py | 23 +- 21 files changed, 1605 insertions(+), 888 deletions(-) delete mode 100644 kasa/aesprotocol.py create mode 100644 kasa/aestransport.py create mode 100755 kasa/iotprotocol.py rename kasa/{klapprotocol.py => klaptransport.py} (66%) mode change 100755 => 100644 create mode 100644 kasa/smartprotocol.py create mode 100644 kasa/tests/fixtures/smart/P110_1.0_1.3.0.json diff --git a/kasa/__init__.py b/kasa/__init__.py index 989e507f2..7de394c11 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -21,13 +21,14 @@ SmartDeviceException, UnsupportedDeviceException, ) -from kasa.klapprotocol import TPLinkKlap +from kasa.iotprotocol import IotProtocol from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.smartdevice import DeviceType, SmartDevice from kasa.smartdimmer import SmartDimmer from kasa.smartlightstrip import SmartLightStrip from kasa.smartplug import SmartPlug +from kasa.smartprotocol import SmartProtocol from kasa.smartstrip import SmartStrip __version__ = version("python-kasa") @@ -37,7 +38,8 @@ "Discover", "TPLinkSmartHomeProtocol", "TPLinkProtocol", - "TPLinkKlap", + "IotProtocol", + "SmartProtocol", "SmartBulb", "SmartBulbPreset", "TurnOnBehaviors", diff --git a/kasa/aesprotocol.py b/kasa/aesprotocol.py deleted file mode 100644 index 98776ce2a..000000000 --- a/kasa/aesprotocol.py +++ /dev/null @@ -1,498 +0,0 @@ -"""Implementation of the TP-Link AES Protocol. - -Based on the work of https://github.com/petretiandrea/plugp100 -under compatible GNU GPL3 license. -""" - -import asyncio -import base64 -import hashlib -import logging -import time -import uuid -from pprint import pformat as pf -from typing import Dict, Optional, Union - -import httpx -from cryptography.hazmat.primitives import hashes, padding, serialization -from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - -from .credentials import Credentials -from .exceptions import AuthenticationException, SmartDeviceException -from .json import dumps as json_dumps -from .json import loads as json_loads -from .protocol import TPLinkProtocol - -_LOGGER = logging.getLogger(__name__) -logging.getLogger("httpx").propagate = False - - -def _md5(payload: bytes) -> bytes: - digest = hashes.Hash(hashes.MD5()) # noqa: S303 - digest.update(payload) - hash = digest.finalize() - return hash - - -def _sha1(payload: bytes) -> str: - sha1_algo = hashlib.sha1() # noqa: S324 - sha1_algo.update(payload) - return sha1_algo.hexdigest() - - -class TPLinkAes(TPLinkProtocol): - """Implementation of the AES encryption protocol. - - AES is the name used in device discovery for TP-Link's TAPO encryption - protocol, sometimes used by newer firmware versions on kasa devices. - """ - - DEFAULT_PORT = 80 - DEFAULT_TIMEOUT = 5 - SESSION_COOKIE_NAME = "TP_SESSIONID" - COMMON_HEADERS = { - "Content-Type": "application/json", - "requestByApp": "true", - "Accept": "application/json", - } - - def __init__( - self, - host: str, - *, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, - ) -> None: - super().__init__(host=host, port=self.DEFAULT_PORT) - - self.credentials = ( - credentials - if credentials and credentials.username and credentials.password - else Credentials(username="", password="") - ) - - self._local_seed: Optional[bytes] = None - self.local_auth_hash = self.generate_auth_hash(self.credentials) - self.local_auth_owner = self.generate_owner_hash(self.credentials).hex() - self.kasa_setup_auth_hash = None - self.blank_auth_hash = None - self.handshake_lock = asyncio.Lock() - self.query_lock = asyncio.Lock() - self.handshake_done = False - - self.encryption_session: Optional[AesEncyptionSession] = None - self.session_expire_at: Optional[float] = None - - self.timeout = timeout if timeout else self.DEFAULT_TIMEOUT - self.session_cookie = None - self.terminal_uuid = None - self.http_client: Optional[httpx.AsyncClient] = None - self.request_id_generator = SnowflakeId(1, 1) - self.login_token = None - - _LOGGER.debug("Created AES object for %s", self.host) - - def hash_credentials(self, credentials, try_login_version2): - """Hash the credentials.""" - if try_login_version2: - un = base64.b64encode( - _sha1(credentials.username.encode()).encode() - ).decode() - pw = base64.b64encode( - _sha1(credentials.password.encode()).encode() - ).decode() - else: - un = base64.b64encode( - _sha1(credentials.username.encode()).encode() - ).decode() - pw = base64.b64encode(credentials.password.encode()).decode() - return un, pw - - async def client_post(self, url, params=None, data=None, json=None, headers=None): - """Send an http post request to the device.""" - response_data = None - cookies = None - if self.session_cookie: - cookies = httpx.Cookies() - cookies.set(self.SESSION_COOKIE_NAME, self.session_cookie) - self.http_client.cookies.clear() - resp = await self.http_client.post( - url, - params=params, - data=data, - json=json, - timeout=self.timeout, - cookies=cookies, - headers=self.COMMON_HEADERS, - ) - if resp.status_code == 200: - response_data = resp.json() - - return resp.status_code, response_data - - async def send_secure_passthrough(self, request): - """Send encrypted message as passthrough.""" - url = f"http://{self.host}/app" - if self.login_token: - url += f"?token={self.login_token}" - raw_request = json_dumps(request) - encrypted_payload = self.encryption_session.encrypt(raw_request.encode()) - passthrough_request = { - "method": "securePassthrough", - "params": {"request": encrypted_payload.decode()}, - } - status_code, resp_dict = await self.client_post(url, json=passthrough_request) - if status_code == 200 and resp_dict["error_code"] == 0: - response = self.encryption_session.decrypt( - resp_dict["result"]["response"].encode() - ) - resp_dict = json_loads(response) - if resp_dict["error_code"] != 0: - raise SmartDeviceException( - f"Could not complete send, response was {resp_dict}", - ) - if "result" in resp_dict: - return resp_dict["result"] - else: - raise AuthenticationException("Could not complete send") - - def get_aes_request(self, method, params=None): - """Get a request message.""" - request = { - "method": method, - "params": params, - "requestID": self.request_id_generator.generate_id(), - "request_time_milis": round(time.time() * 1000), - "terminal_uuid": self.terminal_uuid, - } - return request - - async def perform_login(self, login_v2): - """Login to the device.""" - self.login_token = None - - un, pw = self.hash_credentials(self.credentials, login_v2) - params = {"password": pw, "username": un} - request = self.get_aes_request("login_device", params) - try: - result = await self.send_secure_passthrough(request) - except SmartDeviceException as ex: - raise AuthenticationException(ex) from ex - self.login_token = result["token"] - - async def perform_handshake(self): - """Perform the handshake.""" - _LOGGER.debug("Will perform handshaking...") - _LOGGER.debug("Generating keypair") - - self.handshake_done = False - self.session_expire_at = None - self.session_cookie = None - - url = f"http://{self.host}/app" - key_pair = KeyPair.create_key_pair() - - pub_key = ( - "-----BEGIN PUBLIC KEY-----\n" - + key_pair.get_public_key() - + "\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}") - - status_code, resp_dict = await self.client_post(url, json=request_body) - - _LOGGER.debug(f"Device responded with: {resp_dict}") - - if status_code == 200 and resp_dict["error_code"] == 0: - _LOGGER.debug("Decoding handshake key...") - handshake_key = resp_dict["result"]["key"] - - self.session_cookie = self.http_client.cookies.get( # type: ignore - self.SESSION_COOKIE_NAME - ) - if not self.session_cookie: - self.session_cookie = self.http_client.cookies.get( # type: ignore - "SESSIONID" - ) - - self.session_expire_at = time.time() + 86400 - self.encryption_session = AesEncyptionSession.create_from_keypair( - handshake_key, key_pair - ) - - self.terminal_uuid = base64.b64encode(_md5(uuid.uuid4().bytes)).decode( - "UTF-8" - ) - self.handshake_done = True - - _LOGGER.debug("Handshake with %s complete", self.host) - - else: - raise AuthenticationException("Could not complete handshake") - - def handshake_session_expired(self): - """Return true if session has expired.""" - return ( - self.session_expire_at is None or self.session_expire_at - time.time() <= 0 - ) - - @staticmethod - def generate_auth_hash(creds: Credentials): - """Generate an md5 auth hash for the protocol on the supplied credentials.""" - un = creds.username or "" - pw = creds.password or "" - return _md5(_md5(un.encode()) + _md5(pw.encode())) - - @staticmethod - def generate_owner_hash(creds: Credentials): - """Return the MD5 hash of the username in this object.""" - un = creds.username or "" - return _md5(un.encode()) - - async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: - """Query the device retrying for retry_count on failure.""" - async with self.query_lock: - return await self._query(request, retry_count) - - async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: - for retry in range(retry_count + 1): - try: - return await self._execute_query(request, retry) - except httpx.CloseError as sdex: - await self.close() - if retry >= retry_count: - _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) - raise SmartDeviceException( - f"Unable to connect to the device: {self.host}: {sdex}" - ) from sdex - continue - except httpx.ConnectError as cex: - await self.close() - raise SmartDeviceException( - f"Unable to connect to the device: {self.host}: {cex}" - ) from cex - except TimeoutError as tex: - await self.close() - raise SmartDeviceException( - f"Unable to connect to the device, timed out: {self.host}: {tex}" - ) from tex - except AuthenticationException as auex: - _LOGGER.debug("Unable to authenticate with %s, not retrying", self.host) - raise auex - except Exception as ex: - await self.close() - if retry >= retry_count: - _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) - raise SmartDeviceException( - f"Unable to connect to the device: {self.host}: {ex}" - ) from ex - continue - - # make mypy happy, this should never be reached.. - raise SmartDeviceException("Query reached somehow to unreachable") - - async def _execute_query(self, request: Union[str, Dict], retry_count: int) -> Dict: - _LOGGER.debug( - "%s >> %s", - self.host, - _LOGGER.isEnabledFor(logging.DEBUG) and pf(request), - ) - - if not self.http_client: - self.http_client = httpx.AsyncClient() - - if not self.handshake_done or self.handshake_session_expired(): - try: - await self.perform_handshake() - await self.perform_login(False) - except AuthenticationException: - await self.perform_handshake() - await self.perform_login(True) - - if isinstance(request, dict): - aes_method = next(iter(request)) - aes_params = request[aes_method] - else: - aes_method = request - aes_params = None - - aes_request = self.get_aes_request(aes_method, aes_params) - response_data = await self.send_secure_passthrough(aes_request) - - _LOGGER.debug( - "%s << %s", - self.host, - _LOGGER.isEnabledFor(logging.DEBUG) and pf(response_data), - ) - - return response_data - - async def close(self) -> None: - """Close the protocol.""" - client = self.http_client - self.http_client = None - if client: - await client.aclose() - - -class AesEncyptionSession: - """Class for an AES encryption session.""" - - @staticmethod - def create_from_keypair(handshake_key: str, keypair): - """Create the encryption session.""" - handshake_key_bytes: bytes = base64.b64decode(handshake_key.encode("UTF-8")) - private_key_data = base64.b64decode(keypair.get_private_key().encode("UTF-8")) - - private_key = serialization.load_der_private_key(private_key_data, None, None) - key_and_iv = private_key.decrypt( - handshake_key_bytes, asymmetric_padding.PKCS1v15() - ) - if key_and_iv is None: - raise ValueError("Decryption failed!") - - return AesEncyptionSession(key_and_iv[:16], key_and_iv[16:]) - - def __init__(self, key, iv): - self.cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) - self.padding_strategy = padding.PKCS7(algorithms.AES.block_size) - - def encrypt(self, data) -> bytes: - """Encrypt the message.""" - encryptor = self.cipher.encryptor() - padder = self.padding_strategy.padder() - padded_data = padder.update(data) + padder.finalize() - encrypted = encryptor.update(padded_data) + encryptor.finalize() - return base64.b64encode(encrypted) - - def decrypt(self, data) -> str: - """Decrypt the message.""" - decryptor = self.cipher.decryptor() - unpadder = self.padding_strategy.unpadder() - decrypted = decryptor.update(base64.b64decode(data)) + decryptor.finalize() - unpadded_data = unpadder.update(decrypted) + unpadder.finalize() - return unpadded_data.decode() - - -class KeyPair: - """Class for generating key pairs.""" - - @staticmethod - def create_key_pair(key_size: int = 1024): - """Create a key pair.""" - private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size) - public_key = private_key.public_key() - - private_key_bytes = private_key.private_bytes( - encoding=serialization.Encoding.DER, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - public_key_bytes = public_key.public_bytes( - encoding=serialization.Encoding.DER, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) - - return KeyPair( - private_key=base64.b64encode(private_key_bytes).decode("UTF-8"), - public_key=base64.b64encode(public_key_bytes).decode("UTF-8"), - ) - - def __init__(self, private_key: str, public_key: str): - self.private_key = private_key - self.public_key = public_key - - def get_private_key(self) -> str: - """Get the private key.""" - return self.private_key - - def get_public_key(self) -> str: - """Get the public key.""" - return self.public_key - - -class SnowflakeId: - """Class for generating snowflake ids.""" - - EPOCH = 1420041600000 # Custom epoch (in milliseconds) - WORKER_ID_BITS = 5 - DATA_CENTER_ID_BITS = 5 - SEQUENCE_BITS = 12 - - MAX_WORKER_ID = (1 << WORKER_ID_BITS) - 1 - MAX_DATA_CENTER_ID = (1 << DATA_CENTER_ID_BITS) - 1 - - SEQUENCE_MASK = (1 << SEQUENCE_BITS) - 1 - - def __init__(self, worker_id, data_center_id): - if worker_id > SnowflakeId.MAX_WORKER_ID or worker_id < 0: - raise ValueError( - "Worker ID can't be greater than " - + str(SnowflakeId.MAX_WORKER_ID) - + " or less than 0" - ) - if data_center_id > SnowflakeId.MAX_DATA_CENTER_ID or data_center_id < 0: - raise ValueError( - "Data center ID can't be greater than " - + str(SnowflakeId.MAX_DATA_CENTER_ID) - + " or less than 0" - ) - - self.worker_id = worker_id - self.data_center_id = data_center_id - self.sequence = 0 - self.last_timestamp = -1 - - def generate_id(self): - """Generate a snowflake id.""" - timestamp = self._current_millis() - - if timestamp < self.last_timestamp: - raise ValueError("Clock moved backwards. Refusing to generate ID.") - - if timestamp == self.last_timestamp: - # Within the same millisecond, increment the sequence number - self.sequence = (self.sequence + 1) & SnowflakeId.SEQUENCE_MASK - if self.sequence == 0: - # Sequence exceeds its bit range, wait until the next millisecond - timestamp = self._wait_next_millis(self.last_timestamp) - else: - # New millisecond, reset the sequence number - self.sequence = 0 - - # Update the last timestamp - self.last_timestamp = timestamp - - # Generate and return the final ID - return ( - ( - (timestamp - SnowflakeId.EPOCH) - << ( - SnowflakeId.WORKER_ID_BITS - + SnowflakeId.SEQUENCE_BITS - + SnowflakeId.DATA_CENTER_ID_BITS - ) - ) - | ( - self.data_center_id - << (SnowflakeId.SEQUENCE_BITS + SnowflakeId.WORKER_ID_BITS) - ) - | (self.worker_id << SnowflakeId.SEQUENCE_BITS) - | self.sequence - ) - - def _current_millis(self): - return round(time.time() * 1000) - - def _wait_next_millis(self, last_timestamp): - timestamp = self._current_millis() - while timestamp <= last_timestamp: - timestamp = self._current_millis() - return timestamp diff --git a/kasa/aestransport.py b/kasa/aestransport.py new file mode 100644 index 000000000..6757013da --- /dev/null +++ b/kasa/aestransport.py @@ -0,0 +1,338 @@ +"""Implementation of the TP-Link AES transport. + +Based on the work of https://github.com/petretiandrea/plugp100 +under compatible GNU GPL3 license. +""" + +import base64 +import hashlib +import logging +import time +from typing import Optional, Union + +import httpx +from cryptography.hazmat.primitives import padding, serialization +from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from .credentials import Credentials +from .exceptions import AuthenticationException, SmartDeviceException +from .json import dumps as json_dumps +from .json import loads as json_loads +from .protocol import BaseTransport + +_LOGGER = logging.getLogger(__name__) + + +def _sha1(payload: bytes) -> str: + sha1_algo = hashlib.sha1() # noqa: S324 + sha1_algo.update(payload) + return sha1_algo.hexdigest() + + +class AesTransport(BaseTransport): + """Implementation of the AES encryption protocol. + + AES is the name used in device discovery for TP-Link's TAPO encryption + protocol, sometimes used by newer firmware versions on kasa devices. + """ + + DEFAULT_TIMEOUT = 5 + SESSION_COOKIE_NAME = "TP_SESSIONID" + COMMON_HEADERS = { + "Content-Type": "application/json", + "requestByApp": "true", + "Accept": "application/json", + } + + def __init__( + self, + host: str, + *, + credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, + ) -> None: + super().__init__(host=host) + + self._credentials = credentials or Credentials(username="", password="") + + self._handshake_done = False + + self._encryption_session: Optional[AesEncyptionSession] = None + self._session_expire_at: Optional[float] = None + + self._timeout = timeout if timeout else self.DEFAULT_TIMEOUT + self._session_cookie = None + + self._http_client: httpx.AsyncClient = httpx.AsyncClient() + self._login_token = None + + _LOGGER.debug("Created AES object for %s", self.host) + + def hash_credentials(self, login_v2): + """Hash the credentials.""" + if login_v2: + un = base64.b64encode( + _sha1(self._credentials.username.encode()).encode() + ).decode() + pw = base64.b64encode( + _sha1(self._credentials.password.encode()).encode() + ).decode() + else: + un = base64.b64encode( + _sha1(self._credentials.username.encode()).encode() + ).decode() + pw = base64.b64encode(self._credentials.password.encode()).decode() + return un, pw + + async def client_post(self, url, params=None, data=None, json=None, headers=None): + """Send an http post request to the device.""" + response_data = None + cookies = None + if self._session_cookie: + cookies = httpx.Cookies() + cookies.set(self.SESSION_COOKIE_NAME, self._session_cookie) + self._http_client.cookies.clear() + resp = await self._http_client.post( + url, + params=params, + data=data, + json=json, + timeout=self._timeout, + cookies=cookies, + headers=self.COMMON_HEADERS, + ) + if resp.status_code == 200: + response_data = resp.json() + + return resp.status_code, response_data + + async def send_secure_passthrough(self, request: str): + """Send encrypted message as passthrough.""" + url = f"http://{self.host}/app" + if self._login_token: + url += f"?token={self._login_token}" + + encrypted_payload = self._encryption_session.encrypt(request.encode()) # type: ignore + passthrough_request = { + "method": "securePassthrough", + "params": {"request": encrypted_payload.decode()}, + } + status_code, resp_dict = await self.client_post(url, json=passthrough_request) + _LOGGER.debug(f"secure_passthrough response is {status_code}: {resp_dict}") + if status_code == 200 and resp_dict["error_code"] == 0: + response = self._encryption_session.decrypt( # type: ignore + resp_dict["result"]["response"].encode() + ) + _LOGGER.debug(f"decrypted secure_passthrough response is {response}") + resp_dict = json_loads(response) + return resp_dict + else: + self._handshake_done = False + self._login_token = None + raise AuthenticationException("Could not complete send") + + async def perform_login(self, login_request: Union[str, dict], *, login_v2: bool): + """Login to the device.""" + self._login_token = None + + if isinstance(login_request, str): + login_request_dict: dict = json_loads(login_request) + else: + login_request_dict = login_request + + un, pw = self.hash_credentials(login_v2) + login_request_dict["params"] = {"password": pw, "username": un} + request = json_dumps(login_request_dict) + try: + resp_dict = await self.send_secure_passthrough(request) + except SmartDeviceException as ex: + raise AuthenticationException(ex) from ex + self._login_token = resp_dict["result"]["token"] + + @property + def needs_login(self) -> bool: + """Return true if the transport needs to do a login.""" + return self._login_token is None + + async def login(self, request: str) -> None: + """Login to the device.""" + try: + if self.needs_handshake: + raise SmartDeviceException( + "Handshake must be complete before trying to login" + ) + await self.perform_login(request, login_v2=False) + except AuthenticationException: + await self.perform_handshake() + await self.perform_login(request, login_v2=True) + + @property + def needs_handshake(self) -> bool: + """Return true if the transport needs to do a handshake.""" + return not self._handshake_done or self._handshake_session_expired() + + async def handshake(self) -> None: + """Perform the encryption handshake.""" + await self.perform_handshake() + + 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() + + pub_key = ( + "-----BEGIN PUBLIC KEY-----\n" + + key_pair.get_public_key() + + "\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}") + + status_code, resp_dict = await self.client_post(url, json=request_body) + + _LOGGER.debug(f"Device responded with: {resp_dict}") + + if status_code == 200 and resp_dict["error_code"] == 0: + _LOGGER.debug("Decoding handshake key...") + handshake_key = resp_dict["result"]["key"] + + self._session_cookie = self._http_client.cookies.get( # type: ignore + self.SESSION_COOKIE_NAME + ) + if not self._session_cookie: + self._session_cookie = self._http_client.cookies.get( # type: ignore + "SESSIONID" + ) + + self._session_expire_at = time.time() + 86400 + self._encryption_session = AesEncyptionSession.create_from_keypair( + handshake_key, key_pair + ) + + self._handshake_done = True + + _LOGGER.debug("Handshake with %s complete", self.host) + + else: + raise AuthenticationException("Could not complete handshake") + + def _handshake_session_expired(self): + """Return true if session has expired.""" + return ( + self._session_expire_at is None + or self._session_expire_at - time.time() <= 0 + ) + + async def send(self, request: str): + """Send the request.""" + if self.needs_handshake: + raise SmartDeviceException( + "Handshake must be complete before trying to send" + ) + if self.needs_login: + raise SmartDeviceException("Login must be complete before trying to send") + + resp_dict = await self.send_secure_passthrough(request) + if resp_dict["error_code"] != 0: + self._handshake_done = False + self._login_token = None + raise SmartDeviceException( + f"Could not complete send, response was {resp_dict}", + ) + return resp_dict + + async def close(self) -> None: + """Close the protocol.""" + client = self._http_client + self._http_client = None + if client: + await client.aclose() + + +class AesEncyptionSession: + """Class for an AES encryption session.""" + + @staticmethod + def create_from_keypair(handshake_key: str, keypair): + """Create the encryption session.""" + handshake_key_bytes: bytes = base64.b64decode(handshake_key.encode("UTF-8")) + private_key_data = base64.b64decode(keypair.get_private_key().encode("UTF-8")) + + private_key = serialization.load_der_private_key(private_key_data, None, None) + key_and_iv = private_key.decrypt( + handshake_key_bytes, asymmetric_padding.PKCS1v15() + ) + if key_and_iv is None: + raise ValueError("Decryption failed!") + + return AesEncyptionSession(key_and_iv[:16], key_and_iv[16:]) + + def __init__(self, key, iv): + self.cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + self.padding_strategy = padding.PKCS7(algorithms.AES.block_size) + + def encrypt(self, data) -> bytes: + """Encrypt the message.""" + encryptor = self.cipher.encryptor() + padder = self.padding_strategy.padder() + padded_data = padder.update(data) + padder.finalize() + encrypted = encryptor.update(padded_data) + encryptor.finalize() + return base64.b64encode(encrypted) + + def decrypt(self, data) -> str: + """Decrypt the message.""" + decryptor = self.cipher.decryptor() + unpadder = self.padding_strategy.unpadder() + decrypted = decryptor.update(base64.b64decode(data)) + decryptor.finalize() + unpadded_data = unpadder.update(decrypted) + unpadder.finalize() + return unpadded_data.decode() + + +class KeyPair: + """Class for generating key pairs.""" + + @staticmethod + def create_key_pair(key_size: int = 1024): + """Create a key pair.""" + private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size) + public_key = private_key.public_key() + + private_key_bytes = private_key.private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + public_key_bytes = public_key.public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + return KeyPair( + private_key=base64.b64encode(private_key_bytes).decode("UTF-8"), + public_key=base64.b64encode(public_key_bytes).decode("UTF-8"), + ) + + def __init__(self, private_key: str, public_key: str): + self.private_key = private_key + self.public_key = public_key + + def get_private_key(self) -> str: + """Get the private key.""" + return self.private_key + + def get_public_key(self) -> str: + """Get the public key.""" + return self.public_key diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 9122003c8..be293ee27 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -2,17 +2,21 @@ import logging import time -from typing import Any, Dict, Optional, Type +from typing import Any, Dict, Optional, Tuple, Type +from .aestransport import AesTransport from .credentials import Credentials from .device_type import DeviceType from .exceptions import UnsupportedDeviceException -from .protocol import TPLinkProtocol +from .iotprotocol import IotProtocol +from .klaptransport import KlapTransport, TPlinkKlapTransportV2 +from .protocol import BaseTransport, TPLinkProtocol from .smartbulb import SmartBulb from .smartdevice import SmartDevice, SmartDeviceException from .smartdimmer import SmartDimmer from .smartlightstrip import SmartLightStrip from .smartplug import SmartPlug +from .smartprotocol import SmartProtocol from .smartstrip import SmartStrip from .tapo.tapoplug import TapoPlug @@ -87,7 +91,7 @@ async def connect( if protocol_class is not None: unknown_dev.protocol = protocol_class(host, credentials=credentials) await unknown_dev.update() - device_class = get_device_class_from_info(unknown_dev.internal_state) + device_class = get_device_class_from_sys_info(unknown_dev.internal_state) dev = device_class(host=host, port=port, credentials=credentials, timeout=timeout) # Reuse the connection from the unknown device # so we don't have to reconnect @@ -104,7 +108,7 @@ async def connect( return dev -def get_device_class_from_info(info: Dict[str, Any]) -> Type[SmartDevice]: +def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[SmartDevice]: """Find SmartDevice subclass for device described by passed data.""" if "system" not in info or "get_sysinfo" not in info["system"]: raise SmartDeviceException("No 'system' or 'get_sysinfo' in response") @@ -129,3 +133,35 @@ def get_device_class_from_info(info: Dict[str, Any]) -> Type[SmartDevice]: return SmartBulb raise UnsupportedDeviceException("Unknown device type: %s" % type_) + + +def get_device_class_from_type_name(device_type: str) -> Optional[Type[SmartDevice]]: + """Return the device class from the type name.""" + supported_device_types: dict[str, Type[SmartDevice]] = { + "SMART.TAPOPLUG": TapoPlug, + "SMART.KASAPLUG": TapoPlug, + "IOT.SMARTPLUGSWITCH": SmartPlug, + } + return supported_device_types.get(device_type) + + +def get_protocol_from_connection_name( + connection_name: str, host: str, credentials: Optional[Credentials] = None +) -> Optional[TPLinkProtocol]: + """Return the protocol from the connection name.""" + supported_device_protocols: dict[ + str, Tuple[Type[TPLinkProtocol], Type[BaseTransport]] + ] = { + "IOT.KLAP": (IotProtocol, KlapTransport), + "SMART.AES": (SmartProtocol, AesTransport), + "SMART.KLAP": (SmartProtocol, TPlinkKlapTransportV2), + } + if connection_name not in supported_device_protocols: + return None + + protocol_class, transport_class = supported_device_protocols.get(connection_name) # type: ignore + transport: BaseTransport = transport_class(host, credentials=credentials) + protocol: TPLinkProtocol = protocol_class( + host, credentials=credentials, transport=transport + ) + return protocol diff --git a/kasa/discover.py b/kasa/discover.py index 59849bc0e..2038369b4 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -15,18 +15,18 @@ except ImportError: from pydantic import BaseModel, Field -from kasa.aesprotocol import TPLinkAes from kasa.credentials import Credentials from kasa.exceptions import UnsupportedDeviceException from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads -from kasa.klapprotocol import TPLinkKlap -from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol +from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartdevice import SmartDevice, SmartDeviceException -from kasa.smartplug import SmartPlug -from kasa.tapo.tapoplug import TapoPlug -from .device_factory import get_device_class_from_info +from .device_factory import ( + get_device_class_from_sys_info, + get_device_class_from_type_name, + get_protocol_from_connection_name, +) _LOGGER = logging.getLogger(__name__) @@ -348,7 +348,16 @@ async def discover_single( @staticmethod def _get_device_class(info: dict) -> Type[SmartDevice]: """Find SmartDevice subclass for device described by passed data.""" - return get_device_class_from_info(info) + if "result" in info: + discovery_result = DiscoveryResult(**info["result"]) + dev_class = get_device_class_from_type_name(discovery_result.device_type) + if not dev_class: + raise UnsupportedDeviceException( + "Unknown device type: %s" % discovery_result.device_type + ) + return dev_class + else: + return get_device_class_from_sys_info(info) @staticmethod def _get_device_instance_legacy(data: bytes, ip: str, port: int) -> SmartDevice: @@ -384,24 +393,17 @@ def _get_device_instance( encrypt_type_ = ( f"{type_.split('.')[0]}.{discovery_result.mgt_encrypt_schm.encrypt_type}" ) - device_class = None - - supported_device_types: dict[str, Type[SmartDevice]] = { - "SMART.TAPOPLUG": TapoPlug, - "SMART.KASAPLUG": TapoPlug, - "IOT.SMARTPLUGSWITCH": SmartPlug, - } - supported_device_protocols: dict[str, Type[TPLinkProtocol]] = { - "IOT.KLAP": TPLinkKlap, - "SMART.AES": TPLinkAes, - } - - if (device_class := supported_device_types.get(type_)) is None: + + if (device_class := get_device_class_from_type_name(type_)) is None: _LOGGER.warning("Got unsupported device type: %s", type_) raise UnsupportedDeviceException( f"Unsupported device {ip} of type {type_}: {info}" ) - if (protocol_class := supported_device_protocols.get(encrypt_type_)) is None: + if ( + protocol := get_protocol_from_connection_name( + encrypt_type_, ip, credentials=credentials + ) + ) is None: _LOGGER.warning("Got unsupported device type: %s", encrypt_type_) raise UnsupportedDeviceException( f"Unsupported encryption scheme {ip} of type {encrypt_type_}: {info}" @@ -409,7 +411,7 @@ def _get_device_instance( _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) device = device_class(ip, port=port, credentials=credentials) - device.protocol = protocol_class(ip, credentials=credentials) + device.protocol = protocol device.update_from_discover_info(discovery_result.get_dict()) return device diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py new file mode 100755 index 000000000..2b7f422db --- /dev/null +++ b/kasa/iotprotocol.py @@ -0,0 +1,100 @@ +"""Module for the IOT legacy IOT KASA protocol.""" +import asyncio +import logging +from typing import Dict, Optional, Union + +import httpx + +from .credentials import Credentials +from .exceptions import AuthenticationException, SmartDeviceException +from .json import dumps as json_dumps +from .klaptransport import KlapTransport +from .protocol import BaseTransport, TPLinkProtocol + +_LOGGER = logging.getLogger(__name__) + + +class IotProtocol(TPLinkProtocol): + """Class for the legacy TPLink IOT KASA Protocol.""" + + DEFAULT_PORT = 80 + + def __init__( + self, + host: str, + *, + transport: Optional[BaseTransport] = None, + credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, + ) -> None: + super().__init__(host=host, port=self.DEFAULT_PORT) + + self._credentials: Credentials = credentials or Credentials( + username="", password="" + ) + self._transport: BaseTransport = transport or KlapTransport( + host, credentials=self._credentials, timeout=timeout + ) + + self._query_lock = asyncio.Lock() + + async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: + """Query the device retrying for retry_count on failure.""" + if isinstance(request, dict): + request = json_dumps(request) + assert isinstance(request, str) # noqa: S101 + + async with self._query_lock: + return await self._query(request, retry_count) + + async def _query(self, request: str, retry_count: int = 3) -> Dict: + for retry in range(retry_count + 1): + try: + return await self._execute_query(request, retry) + except httpx.CloseError as sdex: + await self.close() + if retry >= retry_count: + _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {sdex}" + ) from sdex + continue + except httpx.ConnectError as cex: + await self.close() + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {cex}" + ) from cex + except TimeoutError as tex: + await self.close() + raise SmartDeviceException( + f"Unable to connect to the device, timed out: {self.host}: {tex}" + ) from tex + except AuthenticationException as auex: + _LOGGER.debug("Unable to authenticate with %s, not retrying", self.host) + raise auex + except Exception as ex: + await self.close() + if retry >= retry_count: + _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {ex}" + ) from ex + continue + + # make mypy happy, this should never be reached.. + raise SmartDeviceException("Query reached somehow to unreachable") + + async def _execute_query(self, request: str, retry_count: int) -> Dict: + if self._transport.needs_handshake: + await self._transport.handshake() + + if self._transport.needs_login: # This shouln't happen + raise SmartDeviceException( + "IOT Protocol needs to login to transport but is not login aware" + ) + + return await self._transport.send(request) + + async def close(self) -> None: + """Close the protocol.""" + await self._transport.close() diff --git a/kasa/klapprotocol.py b/kasa/klaptransport.py old mode 100755 new mode 100644 similarity index 66% rename from kasa/klapprotocol.py rename to kasa/klaptransport.py index 36a42c589..c28cb0354 --- a/kasa/klapprotocol.py +++ b/kasa/klaptransport.py @@ -47,7 +47,7 @@ import secrets import time from pprint import pformat as pf -from typing import Any, Dict, Optional, Tuple, Union +from typing import Any, Optional, Tuple import httpx from cryptography.hazmat.primitives import hashes, padding @@ -55,33 +55,33 @@ from .credentials import Credentials from .exceptions import AuthenticationException, SmartDeviceException -from .json import dumps as json_dumps from .json import loads as json_loads -from .protocol import TPLinkProtocol +from .protocol import BaseTransport, md5 _LOGGER = logging.getLogger(__name__) logging.getLogger("httpx").propagate = False def _sha256(payload: bytes) -> bytes: - return hashlib.sha256(payload).digest() - - -def _md5(payload: bytes) -> bytes: - digest = hashes.Hash(hashes.MD5()) # noqa: S303 + digest = hashes.Hash(hashes.SHA256()) # noqa: S303 digest.update(payload) hash = digest.finalize() return hash -class TPLinkKlap(TPLinkProtocol): +def _sha1(payload: bytes) -> bytes: + digest = hashes.Hash(hashes.SHA1()) # noqa: S303 + digest.update(payload) + return digest.finalize() + + +class KlapTransport(BaseTransport): """Implementation of the KLAP encryption protocol. KLAP is the name used in device discovery for TP-Link's new encryption protocol, used by newer firmware versions. """ - DEFAULT_PORT = 80 DEFAULT_TIMEOUT = 5 DISCOVERY_QUERY = {"system": {"get_sysinfo": None}} KASA_SETUP_EMAIL = "kasa@tp-link.net" @@ -95,29 +95,24 @@ def __init__( credentials: Optional[Credentials] = None, timeout: Optional[int] = None, ) -> None: - super().__init__(host=host, port=self.DEFAULT_PORT) - - self.credentials = ( - credentials - if credentials and credentials.username and credentials.password - else Credentials(username="", password="") - ) + super().__init__(host=host) + self._credentials = credentials or Credentials(username="", password="") self._local_seed: Optional[bytes] = None - self.local_auth_hash = self.generate_auth_hash(self.credentials) - self.local_auth_owner = self.generate_owner_hash(self.credentials).hex() - self.kasa_setup_auth_hash = None - self.blank_auth_hash = None - self.handshake_lock = asyncio.Lock() - self.query_lock = asyncio.Lock() - self.handshake_done = False + self._local_auth_hash = self.generate_auth_hash(self._credentials) + self._local_auth_owner = self.generate_owner_hash(self._credentials).hex() + self._kasa_setup_auth_hash = None + self._blank_auth_hash = None + self._handshake_lock = asyncio.Lock() + self._query_lock = asyncio.Lock() + self._handshake_done = False - self.encryption_session: Optional[KlapEncryptionSession] = None - self.session_expire_at: Optional[float] = None + self._encryption_session: Optional[KlapEncryptionSession] = None + self._session_expire_at: Optional[float] = None - self.timeout = timeout if timeout else self.DEFAULT_TIMEOUT - self.session_cookie = None - self.http_client: Optional[httpx.AsyncClient] = None + self._timeout = timeout if timeout else self.DEFAULT_TIMEOUT + self._session_cookie = None + self._http_client: httpx.AsyncClient = httpx.AsyncClient() _LOGGER.debug("Created KLAP object for %s", self.host) @@ -125,15 +120,15 @@ async def client_post(self, url, params=None, data=None): """Send an http post request to the device.""" response_data = None cookies = None - if self.session_cookie: + if self._session_cookie: cookies = httpx.Cookies() - cookies.set(self.SESSION_COOKIE_NAME, self.session_cookie) - self.http_client.cookies.clear() - resp = await self.http_client.post( + cookies.set(self.SESSION_COOKIE_NAME, self._session_cookie) + self._http_client.cookies.clear() + resp = await self._http_client.post( url, params=params, data=data, - timeout=self.timeout, + timeout=self._timeout, cookies=cookies, ) if resp.status_code == 200: @@ -183,44 +178,55 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: server_hash.hex(), ) - local_seed_auth_hash = _sha256(local_seed + self.local_auth_hash) + local_seed_auth_hash = self.handshake1_seed_auth_hash( + local_seed, remote_seed, self._local_auth_hash + ) # type: ignore # Check the response from the device with local credentials if local_seed_auth_hash == server_hash: _LOGGER.debug("handshake1 hashes match with expected credentials") - return local_seed, remote_seed, self.local_auth_hash # type: ignore + 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: + if not self._kasa_setup_auth_hash: kasa_setup_creds = Credentials( - username=TPLinkKlap.KASA_SETUP_EMAIL, - password=TPLinkKlap.KASA_SETUP_PASSWORD, + username=self.KASA_SETUP_EMAIL, + password=self.KASA_SETUP_PASSWORD, ) - self.kasa_setup_auth_hash = TPLinkKlap.generate_auth_hash(kasa_setup_creds) + self._kasa_setup_auth_hash = self.generate_auth_hash(kasa_setup_creds) - kasa_setup_seed_auth_hash = _sha256( - local_seed + self.kasa_setup_auth_hash # type: ignore + kasa_setup_seed_auth_hash = self.handshake1_seed_auth_hash( + local_seed, + remote_seed, + self._kasa_setup_auth_hash, # type: ignore ) + 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, ) - return local_seed, remote_seed, self.kasa_setup_auth_hash # type: ignore + return local_seed, remote_seed, self._kasa_setup_auth_hash # type: ignore # Finally check against blank credentials if not already blank - if self.credentials != (blank_creds := Credentials(username="", password="")): - if not self.blank_auth_hash: - self.blank_auth_hash = TPLinkKlap.generate_auth_hash(blank_creds) - blank_seed_auth_hash = _sha256(local_seed + self.blank_auth_hash) # type: ignore + if self._credentials != (blank_creds := Credentials(username="", password="")): + if not self._blank_auth_hash: + self._blank_auth_hash = self.generate_auth_hash(blank_creds) + + blank_seed_auth_hash = self.handshake1_seed_auth_hash( + local_seed, + remote_seed, + self._blank_auth_hash, # type: ignore + ) + if blank_seed_auth_hash == server_hash: _LOGGER.debug( "Server response doesn't match our expected hash on ip %s" + " but an authentication with blank credentials matched", self.host, ) - return local_seed, remote_seed, self.blank_auth_hash # type: ignore + return local_seed, remote_seed, self._blank_auth_hash # type: ignore msg = f"Server response doesn't match our challenge on ip {self.host}" _LOGGER.debug(msg) @@ -235,7 +241,7 @@ async def perform_handshake2( url = f"http://{self.host}/app/handshake2" - payload = _sha256(remote_seed + auth_hash) + payload = self.handshake2_seed_auth_hash(local_seed, remote_seed, auth_hash) response_status, response_data = await self.client_post(url, data=payload) @@ -256,115 +262,70 @@ async def perform_handshake2( return KlapEncryptionSession(local_seed, remote_seed, auth_hash) + @property + def needs_login(self) -> bool: + """Will return false as KLAP does not do a login.""" + return False + + async def login(self, request: str) -> None: + """Will raise and exception as KLAP does not do a login.""" + raise SmartDeviceException( + "KLAP does not perform logins and return needs_login == False" + ) + + @property + def needs_handshake(self) -> bool: + """Return true if the transport needs to do a handshake.""" + return not self._handshake_done or self._handshake_session_expired() + + async def handshake(self) -> None: + """Perform the encryption handshake.""" + await self.perform_handshake() + async def perform_handshake(self) -> Any: """Perform handshake1 and handshake2. Sets the encryption_session if successful. """ _LOGGER.debug("Starting handshake with %s", self.host) - self.handshake_done = False - self.session_expire_at = None - self.session_cookie = None + self._handshake_done = False + self._session_expire_at = None + self._session_cookie = None local_seed, remote_seed, auth_hash = await self.perform_handshake1() - self.session_cookie = self.http_client.cookies.get( # type: ignore - TPLinkKlap.SESSION_COOKIE_NAME + self._session_cookie = self._http_client.cookies.get( # type: ignore + self.SESSION_COOKIE_NAME ) # 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 - self.encryption_session = await self.perform_handshake2( + self._session_expire_at = time.time() + 86400 + self._encryption_session = await self.perform_handshake2( local_seed, remote_seed, auth_hash ) - self.handshake_done = True + self._handshake_done = True _LOGGER.debug("Handshake with %s complete", self.host) - def handshake_session_expired(self): + def _handshake_session_expired(self): """Return true if session has expired.""" return ( - self.session_expire_at is None or self.session_expire_at - time.time() <= 0 + self._session_expire_at is None + or self._session_expire_at - time.time() <= 0 ) - @staticmethod - def generate_auth_hash(creds: Credentials): - """Generate an md5 auth hash for the protocol on the supplied credentials.""" - un = creds.username or "" - pw = creds.password or "" - return _md5(_md5(un.encode()) + _md5(pw.encode())) - - @staticmethod - def generate_owner_hash(creds: Credentials): - """Return the MD5 hash of the username in this object.""" - un = creds.username or "" - return _md5(un.encode()) - - async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: - """Query the device retrying for retry_count on failure.""" - if isinstance(request, dict): - request = json_dumps(request) - assert isinstance(request, str) # noqa: S101 - - async with self.query_lock: - return await self._query(request, retry_count) - - async def _query(self, request: str, retry_count: int = 3) -> Dict: - for retry in range(retry_count + 1): - try: - return await self._execute_query(request, retry) - except httpx.CloseError as sdex: - await self.close() - if retry >= retry_count: - _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) - raise SmartDeviceException( - f"Unable to connect to the device: {self.host}: {sdex}" - ) from sdex - continue - except httpx.ConnectError as cex: - await self.close() - raise SmartDeviceException( - f"Unable to connect to the device: {self.host}: {cex}" - ) from cex - except TimeoutError as tex: - await self.close() - raise SmartDeviceException( - f"Unable to connect to the device, timed out: {self.host}: {tex}" - ) from tex - except AuthenticationException as auex: - _LOGGER.debug("Unable to authenticate with %s, not retrying", self.host) - raise auex - except Exception as ex: - await self.close() - if retry >= retry_count: - _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) - raise SmartDeviceException( - f"Unable to connect to the device: {self.host}: {ex}" - ) from ex - continue - - # make mypy happy, this should never be reached.. - raise SmartDeviceException("Query reached somehow to unreachable") - - async def _execute_query(self, request: str, retry_count: int) -> Dict: - if not self.http_client: - self.http_client = httpx.AsyncClient() - - if not self.handshake_done or self.handshake_session_expired(): - try: - await self.perform_handshake() - - except AuthenticationException as auex: - _LOGGER.debug( - "Unable to complete handshake for device %s, " - + "authentication failed", - self.host, - ) - raise auex + async def send(self, request: str): + """Send the request.""" + if self.needs_handshake: + raise SmartDeviceException( + "Handshake must be complete before trying to send" + ) + if self.needs_login: + raise SmartDeviceException("Login must be complete before trying to send") # Check for mypy - if self.encryption_session is not None: - payload, seq = self.encryption_session.encrypt(request.encode()) + if self._encryption_session is not None: + payload, seq = self._encryption_session.encrypt(request.encode()) url = f"http://{self.host}/app/request" @@ -376,14 +337,14 @@ async def _execute_query(self, request: str, retry_count: int) -> Dict: msg = ( f"at {datetime.datetime.now()}. Host is {self.host}, " - + f"Retry count is {retry_count}, Sequence is {seq}, " + + f"Sequence is {seq}, " + f"Response status is {response_status}, Request was {request}" ) if response_status != 200: _LOGGER.error("Query failed after succesful authentication " + msg) # If we failed with a security error, force a new handshake next time. if response_status == 403: - self.handshake_done = False + self._handshake_done = False raise AuthenticationException( f"Got a security error from {self.host} after handshake " + "completed" @@ -397,8 +358,8 @@ async def _execute_query(self, request: str, retry_count: int) -> Dict: _LOGGER.debug("Query posted " + msg) # Check for mypy - if self.encryption_session is not None: - decrypted_response = self.encryption_session.decrypt(response_data) + if self._encryption_session is not None: + decrypted_response = self._encryption_session.decrypt(response_data) json_payload = json_loads(decrypted_response) @@ -411,12 +372,66 @@ async def _execute_query(self, request: str, retry_count: int) -> Dict: return json_payload async def close(self) -> None: - """Close the protocol.""" - client = self.http_client - self.http_client = None + """Close the transport.""" + client = self._http_client + self._http_client = None if client: await client.aclose() + @staticmethod + def generate_auth_hash(creds: Credentials): + """Generate an md5 auth hash for the protocol on the supplied credentials.""" + un = creds.username or "" + pw = creds.password or "" + + return md5(md5(un.encode()) + md5(pw.encode())) + + @staticmethod + def handshake1_seed_auth_hash( + local_seed: bytes, remote_seed: bytes, auth_hash: bytes + ): + """Generate an md5 auth hash for the protocol on the supplied credentials.""" + return _sha256(local_seed + auth_hash) + + @staticmethod + def handshake2_seed_auth_hash( + local_seed: bytes, remote_seed: bytes, auth_hash: bytes + ): + """Generate an md5 auth hash for the protocol on the supplied credentials.""" + return _sha256(remote_seed + auth_hash) + + @staticmethod + def generate_owner_hash(creds: Credentials): + """Return the MD5 hash of the username in this object.""" + un = creds.username or "" + return md5(un.encode()) + + +class TPlinkKlapTransportV2(KlapTransport): + """Implementation of the KLAP encryption protocol with v2 hanshake hashes.""" + + @staticmethod + def generate_auth_hash(creds: Credentials): + """Generate an md5 auth hash for the protocol on the supplied credentials.""" + un = creds.username or "" + pw = creds.password or "" + + return _sha256(_sha1(un.encode()) + _sha1(pw.encode())) + + @staticmethod + def handshake1_seed_auth_hash( + local_seed: bytes, remote_seed: bytes, auth_hash: bytes + ): + """Generate an md5 auth hash for the protocol on the supplied credentials.""" + return _sha256(local_seed + remote_seed + auth_hash) + + @staticmethod + def handshake2_seed_auth_hash( + local_seed: bytes, remote_seed: bytes, auth_hash: bytes + ): + """Generate an md5 auth hash for the protocol on the supplied credentials.""" + return _sha256(remote_seed + local_seed + auth_hash) + class KlapEncryptionSession: """Class to represent an encryption session and it's internal state. diff --git a/kasa/protocol.py b/kasa/protocol.py index 6413ba5de..62cd5fb63 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -22,6 +22,7 @@ # 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 .exceptions import SmartDeviceException @@ -32,6 +33,56 @@ _NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} +def md5(payload: bytes) -> bytes: + """Return an md5 hash of the payload.""" + digest = hashes.Hash(hashes.MD5()) # noqa: S303 + digest.update(payload) + hash = digest.finalize() + return hash + + +class BaseTransport(ABC): + """Base class for all TP-Link protocol transports.""" + + def __init__( + self, + host: str, + *, + port: Optional[int] = None, + credentials: Optional[Credentials] = None, + ) -> None: + """Create a protocol object.""" + self.host = host + self.port = port + self.credentials = credentials + + @property + @abstractmethod + def needs_handshake(self) -> bool: + """Return true if the transport needs to do a handshake.""" + + @property + @abstractmethod + def needs_login(self) -> bool: + """Return true if the transport needs to do a login.""" + + @abstractmethod + async def login(self, request: str) -> None: + """Login to the device.""" + + @abstractmethod + async def handshake(self) -> None: + """Perform the encryption handshake.""" + + @abstractmethod + async def send(self, request: str) -> Dict: + """Send a message to the device and return a response.""" + + @abstractmethod + async def close(self) -> None: + """Close the transport. Abstract method to be overriden.""" + + class TPLinkProtocol(ABC): """Base class for all TP-Link Smart Home communication.""" @@ -41,6 +92,7 @@ def __init__( *, port: Optional[int] = None, credentials: Optional[Credentials] = None, + transport: Optional[BaseTransport] = None, ) -> None: """Create a protocol object.""" self.host = host diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index af6a2c7f0..342d1c4a6 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -365,6 +365,7 @@ async def _modular_update(self, req: dict) -> None: def update_from_discover_info(self, info: Dict[str, Any]) -> None: """Update state from info from the discover call.""" + self._discovery_info = info if "system" in info and (sys_info := info["system"].get("get_sysinfo")): self._last_update = info self._set_sys_info(sys_info) @@ -372,7 +373,6 @@ def update_from_discover_info(self, info: Dict[str, Any]) -> None: # This allows setting of some info properties directly # from partial discovery info that will then be found # by the requires_update decorator - self._discovery_info = info self._set_sys_info(info) def _set_sys_info(self, sys_info: Dict[str, Any]) -> None: diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py new file mode 100644 index 000000000..98d1a86d8 --- /dev/null +++ b/kasa/smartprotocol.py @@ -0,0 +1,219 @@ +"""Implementation of the TP-Link AES Protocol. + +Based on the work of https://github.com/petretiandrea/plugp100 +under compatible GNU GPL3 license. +""" + +import asyncio +import base64 +import logging +import time +import uuid +from pprint import pformat as pf +from typing import Dict, Optional, Union + +import httpx + +from .aestransport import AesTransport +from .credentials import Credentials +from .exceptions import AuthenticationException, SmartDeviceException +from .json import dumps as json_dumps +from .protocol import BaseTransport, TPLinkProtocol, md5 + +_LOGGER = logging.getLogger(__name__) +logging.getLogger("httpx").propagate = False + + +class SmartProtocol(TPLinkProtocol): + """Class for the new TPLink SMART protocol.""" + + DEFAULT_PORT = 80 + + def __init__( + self, + host: str, + *, + transport: Optional[BaseTransport] = None, + credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, + ) -> None: + super().__init__(host=host, port=self.DEFAULT_PORT) + + self._credentials: Credentials = credentials or Credentials( + username="", password="" + ) + self._transport: BaseTransport = transport or AesTransport( + host, credentials=self._credentials, timeout=timeout + ) + self._terminal_uuid: Optional[str] = None + self._request_id_generator = SnowflakeId(1, 1) + self._query_lock = asyncio.Lock() + + def get_smart_request(self, method, params=None) -> str: + """Get a request message as a string.""" + request = { + "method": method, + "params": params, + "requestID": self._request_id_generator.generate_id(), + "request_time_milis": round(time.time() * 1000), + "terminal_uuid": self._terminal_uuid, + } + return json_dumps(request) + + async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: + """Query the device retrying for retry_count on failure.""" + async with self._query_lock: + resp_dict = await self._query(request, retry_count) + if "result" in resp_dict: + return resp_dict["result"] + return {} + + async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: + for retry in range(retry_count + 1): + try: + return await self._execute_query(request, retry) + except httpx.CloseError as sdex: + await self.close() + if retry >= retry_count: + _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {sdex}" + ) from sdex + continue + except httpx.ConnectError as cex: + await self.close() + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {cex}" + ) from cex + except TimeoutError as tex: + await self.close() + raise SmartDeviceException( + f"Unable to connect to the device, timed out: {self.host}: {tex}" + ) from tex + except AuthenticationException as auex: + _LOGGER.debug("Unable to authenticate with %s, not retrying", self.host) + raise auex + except Exception as ex: + await self.close() + if retry >= retry_count: + _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {ex}" + ) from ex + continue + + # make mypy happy, this should never be reached.. + raise SmartDeviceException("Query reached somehow to unreachable") + + async def _execute_query(self, request: Union[str, Dict], retry_count: int) -> Dict: + if isinstance(request, dict): + smart_method = next(iter(request)) + smart_params = request[smart_method] + else: + smart_method = request + smart_params = None + + if self._transport.needs_handshake: + await self._transport.handshake() + + if self._transport.needs_login: + self._terminal_uuid = base64.b64encode(md5(uuid.uuid4().bytes)).decode( + "UTF-8" + ) + login_request = self.get_smart_request("login_device") + await self._transport.login(login_request) + + smart_request = self.get_smart_request(smart_method, smart_params) + response_data = await self._transport.send(smart_request) + + _LOGGER.debug( + "%s << %s", + self.host, + _LOGGER.isEnabledFor(logging.DEBUG) and pf(response_data), + ) + + return response_data + + async def close(self) -> None: + """Close the protocol.""" + await self._transport.close() + + +class SnowflakeId: + """Class for generating snowflake ids.""" + + EPOCH = 1420041600000 # Custom epoch (in milliseconds) + WORKER_ID_BITS = 5 + DATA_CENTER_ID_BITS = 5 + SEQUENCE_BITS = 12 + + MAX_WORKER_ID = (1 << WORKER_ID_BITS) - 1 + MAX_DATA_CENTER_ID = (1 << DATA_CENTER_ID_BITS) - 1 + + SEQUENCE_MASK = (1 << SEQUENCE_BITS) - 1 + + def __init__(self, worker_id, data_center_id): + if worker_id > SnowflakeId.MAX_WORKER_ID or worker_id < 0: + raise ValueError( + "Worker ID can't be greater than " + + str(SnowflakeId.MAX_WORKER_ID) + + " or less than 0" + ) + if data_center_id > SnowflakeId.MAX_DATA_CENTER_ID or data_center_id < 0: + raise ValueError( + "Data center ID can't be greater than " + + str(SnowflakeId.MAX_DATA_CENTER_ID) + + " or less than 0" + ) + + self.worker_id = worker_id + self.data_center_id = data_center_id + self.sequence = 0 + self.last_timestamp = -1 + + def generate_id(self): + """Generate a snowflake id.""" + timestamp = self._current_millis() + + if timestamp < self.last_timestamp: + raise ValueError("Clock moved backwards. Refusing to generate ID.") + + if timestamp == self.last_timestamp: + # Within the same millisecond, increment the sequence number + self.sequence = (self.sequence + 1) & SnowflakeId.SEQUENCE_MASK + if self.sequence == 0: + # Sequence exceeds its bit range, wait until the next millisecond + timestamp = self._wait_next_millis(self.last_timestamp) + else: + # New millisecond, reset the sequence number + self.sequence = 0 + + # Update the last timestamp + self.last_timestamp = timestamp + + # Generate and return the final ID + return ( + ( + (timestamp - SnowflakeId.EPOCH) + << ( + SnowflakeId.WORKER_ID_BITS + + SnowflakeId.SEQUENCE_BITS + + SnowflakeId.DATA_CENTER_ID_BITS + ) + ) + | ( + self.data_center_id + << (SnowflakeId.SEQUENCE_BITS + SnowflakeId.WORKER_ID_BITS) + ) + | (self.worker_id << SnowflakeId.SEQUENCE_BITS) + | self.sequence + ) + + def _current_millis(self): + return round(time.time() * 1000) + + def _wait_next_millis(self, last_timestamp): + timestamp = self._current_millis() + while timestamp <= last_timestamp: + timestamp = self._current_millis() + return timestamp diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 2ba039565..6c643a6af 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -4,10 +4,10 @@ from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional, Set, cast -from ..aesprotocol import TPLinkAes from ..credentials import Credentials from ..exceptions import AuthenticationException from ..smartdevice import SmartDevice +from ..smartprotocol import SmartProtocol _LOGGER = logging.getLogger(__name__) @@ -26,7 +26,7 @@ def __init__( super().__init__(host, port=port, credentials=credentials, timeout=timeout) self._state_information: Dict[str, Any] = {} self._discovery_info: Optional[Dict[str, Any]] = None - self.protocol = TPLinkAes(host, credentials=credentials, timeout=timeout) + self.protocol = SmartProtocol(host, credentials=credentials, timeout=timeout) async def update(self, update_children: bool = True): """Update the device.""" diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 2b2adc7dd..50d2f0def 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -2,27 +2,45 @@ import glob import json import os +from dataclasses import dataclass +from json import dumps as json_dumps from os.path import basename from pathlib import Path, PurePath -from typing import Dict +from typing import Dict, Optional from unittest.mock import MagicMock import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( + Credentials, Discover, SmartBulb, SmartDimmer, SmartLightStrip, SmartPlug, SmartStrip, + TPLinkSmartHomeProtocol, ) +from kasa.tapo import TapoDevice, TapoPlug -from .newfakes import FakeTransportProtocol +from .newfakes import FakeSmartProtocol, FakeTransportProtocol -SUPPORTED_DEVICES = glob.glob( - os.path.dirname(os.path.abspath(__file__)) + "/fixtures/*.json" -) +SUPPORTED_IOT_DEVICES = [ + (device, "IOT") + for device in glob.glob( + os.path.dirname(os.path.abspath(__file__)) + "/fixtures/*.json" + ) +] + +SUPPORTED_SMART_DEVICES = [ + (device, "SMART") + for device in glob.glob( + os.path.dirname(os.path.abspath(__file__)) + "/fixtures/smart/*.json" + ) +] + + +SUPPORTED_DEVICES = SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES LIGHT_STRIPS = {"KL400", "KL430", "KL420"} @@ -55,43 +73,59 @@ "KP401", "KS200M", } + STRIPS = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} DIMMERS = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} DIMMABLE = {*BULBS, *DIMMERS} WITH_EMETER = {"HS110", "HS300", "KP115", "KP125", *BULBS} -ALL_DEVICES = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS) +ALL_DEVICES_IOT = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS) + +PLUGS_SMART = {"P110"} +ALL_DEVICES_SMART = PLUGS_SMART + +ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) IP_MODEL_CACHE: Dict[str, str] = {} -def filter_model(desc, filter): - filtered = list() - for dev in SUPPORTED_DEVICES: - for filt in filter: - if filt in basename(dev): - filtered.append(dev) +def idgenerator(paramtuple): + return basename(paramtuple[0]) + ( + "" if paramtuple[1] == "IOT" else "-" + paramtuple[1] + ) + - filtered_basenames = [basename(f) for f in filtered] +def filter_model(desc, model_filter, protocol_filter=None): + if not protocol_filter: + protocol_filter = {"IOT"} + filtered = list() + for file, protocol in SUPPORTED_DEVICES: + if protocol in protocol_filter: + file_model = basename(file).split("_")[0] + for model in model_filter: + if model in file_model: + filtered.append((file, protocol)) + + filtered_basenames = [basename(f) + "-" + p for f, p in filtered] print(f"{desc}: {filtered_basenames}") return filtered -def parametrize(desc, devices, ids=None): +def parametrize(desc, devices, protocol_filter=None, ids=None): return pytest.mark.parametrize( - "dev", filter_model(desc, devices), indirect=True, ids=ids + "dev", filter_model(desc, devices, protocol_filter), indirect=True, ids=ids ) has_emeter = parametrize("has emeter", WITH_EMETER) -no_emeter = parametrize("no emeter", ALL_DEVICES - WITH_EMETER) +no_emeter = parametrize("no emeter", ALL_DEVICES_IOT - WITH_EMETER) -bulb = parametrize("bulbs", BULBS, ids=basename) -plug = parametrize("plugs", PLUGS, ids=basename) -strip = parametrize("strips", STRIPS, ids=basename) -dimmer = parametrize("dimmers", DIMMERS, ids=basename) -lightstrip = parametrize("lightstrips", LIGHT_STRIPS, ids=basename) +bulb = parametrize("bulbs", BULBS, ids=idgenerator) +plug = parametrize("plugs", PLUGS, ids=idgenerator) +strip = parametrize("strips", STRIPS, ids=idgenerator) +dimmer = parametrize("dimmers", DIMMERS, ids=idgenerator) +lightstrip = parametrize("lightstrips", LIGHT_STRIPS, ids=idgenerator) # bulb types dimmable = parametrize("dimmable", DIMMABLE) @@ -101,6 +135,58 @@ def parametrize(desc, devices, ids=None): color_bulb = parametrize("color bulbs", COLOR_BULBS) non_color_bulb = parametrize("non-color bulbs", BULBS - COLOR_BULBS) +plug_smart = parametrize( + "plug devices smart", PLUGS_SMART, protocol_filter={"SMART"}, ids=idgenerator +) +device_smart = parametrize( + "devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"}, ids=idgenerator +) +device_iot = parametrize( + "devices iot", ALL_DEVICES_IOT, protocol_filter={"IOT"}, ids=idgenerator +) + + +def get_fixture_data(): + """Return raw discovery file contents as JSON. Used for discovery tests.""" + fixture_data = {} + for file, protocol in SUPPORTED_DEVICES: + p = Path(file) + if not p.is_absolute(): + folder = Path(__file__).parent / "fixtures" + if protocol == "SMART": + folder = folder / "smart" + p = folder / file + + with open(p) as f: + fixture_data[basename(p)] = json.load(f) + return fixture_data + + +FIXTURE_DATA = get_fixture_data() + + +def filter_fixtures(desc, root_filter): + filtered = {} + for key, val in FIXTURE_DATA.items(): + if root_filter in val: + filtered[key] = val + + print(f"{desc}: {filtered.keys()}") + return filtered + + +def parametrize_discovery(desc, root_key): + filtered_fixtures = filter_fixtures(desc, root_key) + return pytest.mark.parametrize( + "discovery_data", + filtered_fixtures.values(), + indirect=True, + ids=filtered_fixtures.keys(), + ) + + +new_discovery = parametrize_discovery("new discovery", "discovery_result") + def check_categories(): """Check that every fixture file is categorized.""" @@ -110,15 +196,15 @@ def check_categories(): + plug.args[1] + bulb.args[1] + lightstrip.args[1] + + plug_smart.args[1] ) diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures) if diff: - for file in diff: + for file, protocol in diff: print( - "No category for file %s, add to the corresponding set (BULBS, PLUGS, ..)" - % file + f"No category for file {file} protocol {protocol}, add to the corresponding set (BULBS, PLUGS, ..)" ) - raise Exception("Missing category for %s" % diff) + raise Exception(f"Missing category for {diff}") check_categories() @@ -134,27 +220,32 @@ async def handle_turn_on(dev, turn_on): await dev.turn_off() -def device_for_file(model): - for d in STRIPS: - if d in model: - return SmartStrip +def device_for_file(model, protocol): + if protocol == "SMART": + for d in PLUGS_SMART: + if d in model: + return TapoPlug + else: + for d in STRIPS: + if d in model: + return SmartStrip - for d in PLUGS: - if d in model: - return SmartPlug + for d in PLUGS: + if d in model: + return SmartPlug - # Light strips are recognized also as bulbs, so this has to go first - for d in LIGHT_STRIPS: - if d in model: - return SmartLightStrip + # Light strips are recognized also as bulbs, so this has to go first + for d in LIGHT_STRIPS: + if d in model: + return SmartLightStrip - for d in BULBS: - if d in model: - return SmartBulb + for d in BULBS: + if d in model: + return SmartBulb - for d in DIMMERS: - if d in model: - return SmartDimmer + for d in DIMMERS: + if d in model: + return SmartDimmer raise Exception("Unable to find type for %s", model) @@ -170,11 +261,14 @@ async def _discover_update_and_close(ip): return await _update_and_close(d) -async def get_device_for_file(file): +async def get_device_for_file(file, protocol): # if the wanted file is not an absolute path, prepend the fixtures directory p = Path(file) if not p.is_absolute(): - p = Path(__file__).parent / "fixtures" / file + folder = Path(__file__).parent / "fixtures" + if protocol == "SMART": + folder = folder / "smart" + p = folder / file def load_file(): with open(p) as f: @@ -184,8 +278,12 @@ def load_file(): sysinfo = await loop.run_in_executor(None, load_file) model = basename(file) - d = device_for_file(model)(host="127.0.0.123") - d.protocol = FakeTransportProtocol(sysinfo) + d = device_for_file(model, protocol)(host="127.0.0.123") + if protocol == "SMART": + d.protocol = FakeSmartProtocol(sysinfo) + d.credentials = Credentials("", "") + else: + d.protocol = FakeTransportProtocol(sysinfo) await _update_and_close(d) return d @@ -197,7 +295,7 @@ async def dev(request): Provides a device (given --ip) or parametrized fixture for the supported devices. The initial update is called automatically before returning the device. """ - file = request.param + file, protocol = request.param ip = request.config.getoption("--ip") if ip: @@ -210,19 +308,62 @@ async def dev(request): pytest.skip(f"skipping file {file}") return d if d else await _discover_update_and_close(ip) - return await get_device_for_file(file) + return await get_device_for_file(file, protocol) + +@pytest.fixture +def discovery_mock(discovery_data, mocker): + @dataclass + class _DiscoveryMock: + ip: str + default_port: int + discovery_data: dict + port_override: Optional[int] = None -@pytest.fixture(params=SUPPORTED_DEVICES, scope="session") + if "result" in discovery_data: + datagram = ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(discovery_data).encode() + ) + dm = _DiscoveryMock("127.0.0.123", 20002, discovery_data) + else: + datagram = TPLinkSmartHomeProtocol.encrypt(json_dumps(discovery_data))[4:] + dm = _DiscoveryMock("127.0.0.123", 9999, discovery_data) + + def mock_discover(self): + port = ( + dm.port_override + if dm.port_override and dm.default_port != 20002 + else dm.default_port + ) + self.datagram_received( + datagram, + (dm.ip, port), + ) + + mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) + mocker.patch( + "socket.getaddrinfo", + side_effect=lambda *_, **__: [(None, None, None, None, (dm.ip, 0))], + ) + yield dm + + +@pytest.fixture(params=FIXTURE_DATA.values(), ids=FIXTURE_DATA.keys(), scope="session") def discovery_data(request): """Return raw discovery file contents as JSON. Used for discovery tests.""" - file = request.param - p = Path(file) - if not p.is_absolute(): - p = Path(__file__).parent / "fixtures" / file + fixture_data = request.param + if "discovery_result" in fixture_data: + return {"result": fixture_data["discovery_result"]} + else: + return {"system": {"get_sysinfo": fixture_data["system"]["get_sysinfo"]}} + - with open(p) as f: - return json.load(f) +@pytest.fixture(params=FIXTURE_DATA.values(), ids=FIXTURE_DATA.keys(), scope="session") +def all_fixture_data(request): + """Return raw fixture file contents as JSON. Used for discovery tests.""" + fixture_data = request.param + return fixture_data def pytest_addoption(parser): diff --git a/kasa/tests/fixtures/smart/P110_1.0_1.3.0.json b/kasa/tests/fixtures/smart/P110_1.0_1.3.0.json new file mode 100644 index 000000000..99fd3f133 --- /dev/null +++ b/kasa/tests/fixtures/smart/P110_1.0_1.3.0.json @@ -0,0 +1,180 @@ +{ + "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": "localSmart", + "ver_code": 1 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "current_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110(UK)", + "device_type": "SMART.TAPOPLUG", + "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": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "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.3.0 Build 230905 Rel.152200", + "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", + "model": "P110", + "nickname": "VGFwaSBTbWFydCBQbHVnIDE=", + "oem_id": "00000000000000000000000000000000", + "on_time": 119335, + "overcurrent_status": "normal", + "overheated": false, + "power_protection_status": "normal", + "region": "Europe/London", + "rssi": -57, + "signal_level": 2, + "specs": "", + "ssid": "IyNNQVNLRUROQU1FIyM=", + "time_diff": 0, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1701370224 + }, + "get_device_usage": { + "power_usage": { + "past30": 75, + "past7": 69, + "today": 0 + }, + "saved_power": { + "past30": 2029, + "past7": 1964, + "today": 1130 + }, + "time_usage": { + "past30": 2104, + "past7": 2033, + "today": 1130 + } + }, + "get_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2023-11-30 18:50:24", + "month_energy": 75, + "month_runtime": 2104, + "today_energy": 0, + "today_runtime": 1130 + } +} diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index ee679cae8..c5bf238f8 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -1,6 +1,7 @@ import copy import logging import re +from json import loads as json_loads from voluptuous import ( REMOVE_EXTRA, @@ -13,7 +14,8 @@ Schema, ) -from ..protocol import TPLinkSmartHomeProtocol +from ..protocol import BaseTransport, TPLinkSmartHomeProtocol +from ..smartprotocol import SmartProtocol _LOGGER = logging.getLogger(__name__) @@ -285,6 +287,41 @@ def success(res): } +class FakeSmartProtocol(SmartProtocol): + def __init__(self, info): + super().__init__("127.0.0.123", transport=FakeSmartTransport(info)) + + +class FakeSmartTransport(BaseTransport): + def __init__(self, info): + self.info = info + + @property + def needs_handshake(self) -> bool: + return False + + @property + def needs_login(self) -> bool: + return False + + async def login(self, request: str) -> None: + pass + + async def handshake(self) -> None: + pass + + async def send(self, request: str): + request_dict = json_loads(request) + method = request_dict["method"] + if method == "component_nego" or method[:4] == "get_": + return self.info[method] + elif method[:4] == "set_": + _LOGGER.debug("Call %s not implemented, doing nothing", method) + + async def close(self) -> None: + pass + + class FakeTransportProtocol(TPLinkSmartHomeProtocol): def __init__(self, info): self.discovery_data = info diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index f590808f8..55e3977af 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -6,12 +6,15 @@ from kasa import SmartDevice, TPLinkSmartHomeProtocol from kasa.cli import alias, brightness, cli, emeter, raw_command, state, sysinfo, toggle +from kasa.device_factory import DEVICE_TYPE_TO_CLASS from kasa.discover import Discover +from kasa.smartprotocol import SmartProtocol -from .conftest import handle_turn_on, turn_on -from .newfakes import FakeTransportProtocol +from .conftest import device_iot, handle_turn_on, new_discovery, turn_on +from .newfakes import FakeSmartProtocol, FakeTransportProtocol +@device_iot async def test_sysinfo(dev): runner = CliRunner() res = await runner.invoke(sysinfo, obj=dev) @@ -19,6 +22,7 @@ async def test_sysinfo(dev): assert dev.alias in res.output +@device_iot @turn_on async def test_state(dev, turn_on): await handle_turn_on(dev, turn_on) @@ -32,6 +36,7 @@ async def test_state(dev, turn_on): assert "Device state: False" in res.output +@device_iot @turn_on async def test_toggle(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) @@ -44,6 +49,7 @@ async def test_toggle(dev, turn_on, mocker): assert dev.is_on +@device_iot async def test_alias(dev): runner = CliRunner() @@ -62,6 +68,7 @@ async def test_alias(dev): await dev.set_alias(old_alias) +@device_iot async def test_raw_command(dev): runner = CliRunner() res = await runner.invoke(raw_command, ["system", "get_sysinfo"], obj=dev) @@ -74,6 +81,7 @@ async def test_raw_command(dev): assert "Usage" in res.output +@device_iot async def test_emeter(dev: SmartDevice, mocker): runner = CliRunner() @@ -99,6 +107,7 @@ async def test_emeter(dev: SmartDevice, mocker): daily.assert_called_with(year=1900, month=12) +@device_iot async def test_brightness(dev): runner = CliRunner() res = await runner.invoke(brightness, obj=dev) @@ -116,6 +125,7 @@ async def test_brightness(dev): assert "Brightness: 12" in res.output +@device_iot async def test_json_output(dev: SmartDevice, mocker): """Test that the json output produces correct output.""" mocker.patch("kasa.Discover.discover", return_value=[dev]) @@ -125,13 +135,9 @@ async def test_json_output(dev: SmartDevice, mocker): assert json.loads(res.output) == dev.internal_state -async def test_credentials(discovery_data: dict, mocker): +@new_discovery +async def test_credentials(discovery_mock, mocker): """Test credentials are passed correctly from cli to device.""" - # As this is testing the device constructor need to explicitly wire in - # the FakeTransportProtocol - ftp = FakeTransportProtocol(discovery_data) - mocker.patch.object(TPLinkSmartHomeProtocol, "query", ftp.query) - # Patch state to echo username and password pass_dev = click.make_pass_decorator(SmartDevice) @@ -143,18 +149,15 @@ async def _state(dev: SmartDevice): ) mocker.patch("kasa.cli.state", new=_state) - cli_device_type = Discover._get_device_class(discovery_data)( - "any" - ).device_type.value + for subclass in DEVICE_TYPE_TO_CLASS.values(): + mocker.patch.object(subclass, "update") runner = CliRunner() res = await runner.invoke( cli, [ "--host", - "127.0.0.1", - "--type", - cli_device_type, + "127.0.0.123", "--username", "foo", "--password", @@ -162,9 +165,11 @@ async def _state(dev: SmartDevice): ], ) assert res.exit_code == 0 - assert res.output == "Username:foo Password:bar\n" + + assert "Username:foo Password:bar\n" in res.output +@device_iot async def test_without_device_type(discovery_data: dict, dev, mocker): """Test connecting without the device type.""" runner = CliRunner() diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index aca38e19d..eb12b3b0d 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -5,7 +5,9 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( + Credentials, DeviceType, + Discover, SmartBulb, SmartDevice, SmartDeviceException, @@ -13,8 +15,13 @@ SmartLightStrip, SmartPlug, ) -from kasa.device_factory import connect -from kasa.klapprotocol import TPLinkKlap +from kasa.device_factory import ( + DEVICE_TYPE_TO_CLASS, + connect, + get_protocol_from_connection_name, +) +from kasa.discover import DiscoveryResult +from kasa.iotprotocol import IotProtocol from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol @@ -22,11 +29,15 @@ async def test_connect(discovery_data: dict, mocker, custom_port): """Make sure that connect returns an initialized SmartDevice instance.""" host = "127.0.0.1" - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) - dev = await connect(host, port=custom_port) - assert issubclass(dev.__class__, SmartDevice) - assert dev.port == custom_port or dev.port == 9999 + if "result" in discovery_data: + with pytest.raises(SmartDeviceException): + dev = await connect(host, port=custom_port) + else: + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) + dev = await connect(host, port=custom_port) + assert issubclass(dev.__class__, SmartDevice) + assert dev.port == custom_port or dev.port == 9999 @pytest.mark.parametrize("custom_port", [123, None]) @@ -49,11 +60,15 @@ async def test_connect_passed_device_type( ): """Make sure that connect with a passed device type.""" host = "127.0.0.1" - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) - dev = await connect(host, port=custom_port, device_type=device_type) - assert isinstance(dev, klass) - assert dev.port == custom_port or dev.port == 9999 + if "result" in discovery_data: + with pytest.raises(SmartDeviceException): + dev = await connect(host, port=custom_port) + else: + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) + dev = await connect(host, port=custom_port, device_type=device_type) + assert isinstance(dev, klass) + assert dev.port == custom_port or dev.port == 9999 async def test_connect_query_fails(discovery_data: dict, mocker): @@ -70,32 +85,52 @@ async def test_connect_logs_connect_time( ): """Test that the connect time is logged when debug logging is enabled.""" host = "127.0.0.1" - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) - logging.getLogger("kasa").setLevel(logging.DEBUG) - await connect(host) - assert "seconds to connect" in caplog.text + if "result" in discovery_data: + with pytest.raises(SmartDeviceException): + await connect(host) + else: + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) + logging.getLogger("kasa").setLevel(logging.DEBUG) + await connect(host) + assert "seconds to connect" in caplog.text -@pytest.mark.parametrize("device_type", [DeviceType.Plug, None]) -@pytest.mark.parametrize( - ("protocol_in", "protocol_result"), - ( - (None, TPLinkSmartHomeProtocol), - (TPLinkKlap, TPLinkKlap), - (TPLinkSmartHomeProtocol, TPLinkSmartHomeProtocol), - ), -) async def test_connect_pass_protocol( - discovery_data: dict, + all_fixture_data: dict, mocker, - device_type: DeviceType, - protocol_in: Type[TPLinkProtocol], - protocol_result: Type[TPLinkProtocol], ): """Test that if the protocol is passed in it's gets set correctly.""" + if "discovery_result" in all_fixture_data: + discovery_info = {"result": all_fixture_data["discovery_result"]} + device_class = Discover._get_device_class(discovery_info) + else: + device_class = Discover._get_device_class(all_fixture_data) + + device_type = list(DEVICE_TYPE_TO_CLASS.keys())[ + list(DEVICE_TYPE_TO_CLASS.values()).index(device_class) + ] host = "127.0.0.1" - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) - mocker.patch("kasa.TPLinkKlap.query", return_value=discovery_data) - - dev = await connect(host, device_type=device_type, protocol_class=protocol_in) - assert isinstance(dev.protocol, protocol_result) + if "discovery_result" in all_fixture_data: + mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) + mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) + + dr = DiscoveryResult(**discovery_info["result"]) + connection_name = ( + dr.device_type.split(".")[0] + "." + dr.mgt_encrypt_schm.encrypt_type + ) + protocol_class = get_protocol_from_connection_name( + connection_name, host + ).__class__ + else: + mocker.patch( + "kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data + ) + protocol_class = TPLinkSmartHomeProtocol + + dev = await connect( + host, + device_type=device_type, + protocol_class=protocol_class, + credentials=Credentials("", ""), + ) + assert isinstance(dev.protocol, protocol_class) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 626afd180..ea97d94ad 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -17,6 +17,27 @@ from .conftest import bulb, dimmer, lightstrip, plug, strip +UNSUPPORTED = { + "result": { + "device_id": "xx", + "owner": "xx", + "device_type": "SMART.TAPOXMASTREE", + "device_model": "P110(EU)", + "ip": "127.0.0.1", + "mac": "48-22xxx", + "is_support_iot_cloud": True, + "obd_src": "tplink", + "factory_default": False, + "mgt_encrypt_schm": { + "is_support_https": False, + "encrypt_type": "AES", + "http_port": 80, + "lv": 2, + }, + }, + "error_code": 0, +} + @plug async def test_type_detection_plug(dev: SmartDevice): @@ -62,76 +83,40 @@ async def test_type_unknown(): @pytest.mark.parametrize("custom_port", [123, None]) -async def test_discover_single(discovery_data: dict, mocker, custom_port): +# @pytest.mark.parametrize("discovery_mock", [("127.0.0.1",123), ("127.0.0.1",None)], indirect=True) +async def test_discover_single(discovery_mock, custom_port, mocker): """Make sure that discover_single returns an initialized SmartDevice instance.""" host = "127.0.0.1" - info = {"system": {"get_sysinfo": discovery_data["system"]["get_sysinfo"]}} - query_mock = mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=info) - - def mock_discover(self): - self.datagram_received( - protocol.TPLinkSmartHomeProtocol.encrypt(json_dumps(info))[4:], - (host, custom_port or 9999), - ) - - mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) + discovery_mock.ip = host + discovery_mock.port_override = custom_port + update_mock = mocker.patch.object(SmartStrip, "update") x = await Discover.discover_single(host, port=custom_port) assert issubclass(x.__class__, SmartDevice) - assert x._sys_info is not None - assert x.port == custom_port or x.port == 9999 - assert (query_mock.call_count > 0) == isinstance(x, SmartStrip) + assert x._discovery_info is not None + assert x.port == custom_port or x.port == discovery_mock.default_port + assert (update_mock.call_count > 0) == isinstance(x, SmartStrip) -async def test_discover_single_hostname(discovery_data: dict, mocker): +async def test_discover_single_hostname(discovery_mock, mocker): """Make sure that discover_single returns an initialized SmartDevice instance.""" host = "foobar" ip = "127.0.0.1" - info = {"system": {"get_sysinfo": discovery_data["system"]["get_sysinfo"]}} - query_mock = mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=info) - - def mock_discover(self): - self.datagram_received( - protocol.TPLinkSmartHomeProtocol.encrypt(json_dumps(info))[4:], - (ip, 9999), - ) - mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) - mocker.patch("socket.getaddrinfo", return_value=[(None, None, None, None, (ip, 0))]) + discovery_mock.ip = ip + update_mock = mocker.patch.object(SmartStrip, "update") x = await Discover.discover_single(host) assert issubclass(x.__class__, SmartDevice) - assert x._sys_info is not None + assert x._discovery_info is not None assert x.host == host - assert (query_mock.call_count > 0) == isinstance(x, SmartStrip) + assert (update_mock.call_count > 0) == isinstance(x, SmartStrip) mocker.patch("socket.getaddrinfo", side_effect=socket.gaierror()) with pytest.raises(SmartDeviceException): x = await Discover.discover_single(host) -UNSUPPORTED = { - "result": { - "device_id": "xx", - "owner": "xx", - "device_type": "SMART.TAPOXMASTREE", - "device_model": "P110(EU)", - "ip": "127.0.0.1", - "mac": "48-22xxx", - "is_support_iot_cloud": True, - "obd_src": "tplink", - "factory_default": False, - "mgt_encrypt_schm": { - "is_support_https": False, - "encrypt_type": "AES", - "http_port": 80, - "lv": 2, - }, - }, - "error_code": 0, -} - - async def test_discover_single_unsupported(mocker): """Make sure that discover_single handles unsupported devices correctly.""" host = "127.0.0.1" @@ -201,14 +186,17 @@ async def test_discover_send(mocker): async def test_discover_datagram_received(mocker, discovery_data): """Verify that datagram received fills discovered_devices.""" proto = _DiscoverProtocol() - info = {"system": {"get_sysinfo": discovery_data["system"]["get_sysinfo"]}} - mocker.patch("kasa.discover.json_loads", return_value=info) - mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "encrypt") + mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "decrypt") addr = "127.0.0.1" - proto.datagram_received("", (addr, 9999)) + port = 20002 if "result" in discovery_data else 9999 + + mocker.patch("kasa.discover.json_loads", return_value=discovery_data) + proto.datagram_received("", (addr, port)) + addr2 = "127.0.0.2" + mocker.patch("kasa.discover.json_loads", return_value=UNSUPPORTED) proto.datagram_received("", (addr2, 20002)) # Check that device in discovered_devices is initialized correctly diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 991dbe6fa..fe4d1a6ca 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -10,9 +10,14 @@ import httpx import pytest +from ..aestransport import AesTransport from ..credentials import Credentials from ..exceptions import AuthenticationException, SmartDeviceException -from ..klapprotocol import KlapEncryptionSession, TPLinkKlap, _sha256 +from ..iotprotocol import IotProtocol +from ..klaptransport import KlapEncryptionSession, KlapTransport, _sha256 +from ..smartprotocol import SmartProtocol + +DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} class _mock_response: @@ -21,67 +26,92 @@ def __init__(self, status_code, content: bytes): self.content = content +@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) +@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) @pytest.mark.parametrize("retry_count", [1, 3, 5]) -async def test_protocol_retries(mocker, retry_count): +async def test_protocol_retries(mocker, retry_count, protocol_class, transport_class): + host = "127.0.0.1" conn = mocker.patch.object( - TPLinkKlap, "client_post", side_effect=Exception("dummy exception") + transport_class, "client_post", side_effect=Exception("dummy exception") ) with pytest.raises(SmartDeviceException): - await TPLinkKlap("127.0.0.1").query({}, retry_count=retry_count) + await protocol_class(host, transport=transport_class(host)).query( + DUMMY_QUERY, retry_count=retry_count + ) assert conn.call_count == retry_count + 1 -async def test_protocol_no_retry_on_connection_error(mocker): +@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) +@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) +async def test_protocol_no_retry_on_connection_error( + mocker, protocol_class, transport_class +): + host = "127.0.0.1" conn = mocker.patch.object( - TPLinkKlap, + transport_class, "client_post", side_effect=httpx.ConnectError("foo"), ) with pytest.raises(SmartDeviceException): - await TPLinkKlap("127.0.0.1").query({}, retry_count=5) + await protocol_class(host, transport=transport_class(host)).query( + DUMMY_QUERY, retry_count=5 + ) assert conn.call_count == 1 -async def test_protocol_retry_recoverable_error(mocker): +@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) +@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) +async def test_protocol_retry_recoverable_error( + mocker, protocol_class, transport_class +): + host = "127.0.0.1" conn = mocker.patch.object( - TPLinkKlap, + transport_class, "client_post", side_effect=httpx.CloseError("foo"), ) with pytest.raises(SmartDeviceException): - await TPLinkKlap("127.0.0.1").query({}, retry_count=5) + await protocol_class(host, transport=transport_class(host)).query( + DUMMY_QUERY, retry_count=5 + ) assert conn.call_count == 6 +@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) +@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) @pytest.mark.parametrize("retry_count", [1, 3, 5]) -async def test_protocol_reconnect(mocker, retry_count): +async def test_protocol_reconnect(mocker, retry_count, protocol_class, transport_class): + host = "127.0.0.1" remaining = retry_count + mock_response = {"result": {"great": "success"}} def _fail_one_less_than_retry_count(*_, **__): - nonlocal remaining, encryption_session + nonlocal remaining remaining -= 1 if remaining: raise Exception("Simulated post failure") - # Do the encrypt just before returning the value so the incrementing sequence number is correct - encrypted, seq = encryption_session.encrypt('{"great":"success"}') - return 200, encrypted - seed = secrets.token_bytes(16) - auth_hash = TPLinkKlap.generate_auth_hash(Credentials("foo", "bar")) - encryption_session = KlapEncryptionSession(seed, seed, auth_hash) - protocol = TPLinkKlap("127.0.0.1") - protocol.handshake_done = True - protocol.session_expire_at = time.time() + 86400 - protocol.encryption_session = encryption_session + return mock_response + mocker.patch.object( - TPLinkKlap, "client_post", side_effect=_fail_one_less_than_retry_count + transport_class, "needs_handshake", property(lambda self: False) ) + mocker.patch.object(transport_class, "needs_login", property(lambda self: False)) - response = await protocol.query({}, retry_count=retry_count) - assert response == {"great": "success"} + send_mock = mocker.patch.object( + transport_class, + "send", + side_effect=_fail_one_less_than_retry_count, + ) + + response = await protocol_class(host, transport=transport_class(host)).query( + DUMMY_QUERY, retry_count=retry_count + ) + assert "result" in response or "great" in response + assert send_mock.call_count == retry_count @pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG]) @@ -96,14 +126,14 @@ def _return_encrypted(*_, **__): return 200, encrypted seed = secrets.token_bytes(16) - auth_hash = TPLinkKlap.generate_auth_hash(Credentials("foo", "bar")) + auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) encryption_session = KlapEncryptionSession(seed, seed, auth_hash) - protocol = TPLinkKlap("127.0.0.1") + protocol = IotProtocol("127.0.0.1") - protocol.handshake_done = True - protocol.session_expire_at = time.time() + 86400 - protocol.encryption_session = encryption_session - mocker.patch.object(TPLinkKlap, "client_post", side_effect=_return_encrypted) + protocol._transport._handshake_done = True + protocol._transport._session_expire_at = time.time() + 86400 + protocol._transport._encryption_session = encryption_session + mocker.patch.object(KlapTransport, "client_post", side_effect=_return_encrypted) response = await protocol.query({}) assert response == {"great": "success"} @@ -117,7 +147,7 @@ def test_encrypt(): d = json.dumps({"foo": 1, "bar": 2}) seed = secrets.token_bytes(16) - auth_hash = TPLinkKlap.generate_auth_hash(Credentials("foo", "bar")) + auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) encryption_session = KlapEncryptionSession(seed, seed, auth_hash) encrypted, seq = encryption_session.encrypt(d) @@ -129,7 +159,7 @@ def test_encrypt_unicode(): d = "{'snowman': '\u2603'}" seed = secrets.token_bytes(16) - auth_hash = TPLinkKlap.generate_auth_hash(Credentials("foo", "bar")) + auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) encryption_session = KlapEncryptionSession(seed, seed, auth_hash) encrypted, seq = encryption_session.encrypt(d) @@ -145,7 +175,10 @@ def test_encrypt_unicode(): (Credentials("foo", "bar"), does_not_raise()), (Credentials("", ""), does_not_raise()), ( - Credentials(TPLinkKlap.KASA_SETUP_EMAIL, TPLinkKlap.KASA_SETUP_PASSWORD), + Credentials( + KlapTransport.KASA_SETUP_EMAIL, + KlapTransport.KASA_SETUP_PASSWORD, + ), does_not_raise(), ), ( @@ -167,21 +200,21 @@ async def _return_handshake1_response(url, params=None, data=None, *_, **__): client_seed = None server_seed = secrets.token_bytes(16) client_credentials = Credentials("foo", "bar") - device_auth_hash = TPLinkKlap.generate_auth_hash(device_credentials) + device_auth_hash = KlapTransport.generate_auth_hash(device_credentials) mocker.patch.object( httpx.AsyncClient, "post", side_effect=_return_handshake1_response ) - protocol = TPLinkKlap("127.0.0.1", credentials=client_credentials) + protocol = IotProtocol("127.0.0.1", credentials=client_credentials) - protocol.http_client = httpx.AsyncClient() + protocol._transport.http_client = httpx.AsyncClient() with expectation: ( local_seed, device_remote_seed, auth_hash, - ) = await protocol.perform_handshake1() + ) = await protocol._transport.perform_handshake1() assert local_seed == client_seed assert device_remote_seed == server_seed @@ -204,23 +237,23 @@ async def _return_handshake_response(url, params=None, data=None, *_, **__): client_seed = None server_seed = secrets.token_bytes(16) client_credentials = Credentials("foo", "bar") - device_auth_hash = TPLinkKlap.generate_auth_hash(client_credentials) + device_auth_hash = KlapTransport.generate_auth_hash(client_credentials) mocker.patch.object( httpx.AsyncClient, "post", side_effect=_return_handshake_response ) - protocol = TPLinkKlap("127.0.0.1", credentials=client_credentials) - protocol.http_client = httpx.AsyncClient() + protocol = IotProtocol("127.0.0.1", credentials=client_credentials) + protocol._transport.http_client = httpx.AsyncClient() response_status = 200 - await protocol.perform_handshake() - assert protocol.handshake_done is True + await protocol._transport.perform_handshake() + assert protocol._transport._handshake_done is True response_status = 403 with pytest.raises(AuthenticationException): - await protocol.perform_handshake() - assert protocol.handshake_done is False + await protocol._transport.perform_handshake() + assert protocol._transport._handshake_done is False await protocol.close() @@ -237,9 +270,9 @@ async def _return_response(url, params=None, data=None, *_, **__): return _mock_response(200, b"") elif url == "http://127.0.0.1/app/request": encryption_session = KlapEncryptionSession( - protocol.encryption_session.local_seed, - protocol.encryption_session.remote_seed, - protocol.encryption_session.user_hash, + protocol._transport._encryption_session.local_seed, + protocol._transport._encryption_session.remote_seed, + protocol._transport._encryption_session.user_hash, ) seq = params.get("seq") encryption_session._seq = seq - 1 @@ -252,11 +285,11 @@ async def _return_response(url, params=None, data=None, *_, **__): seq = None server_seed = secrets.token_bytes(16) client_credentials = Credentials("foo", "bar") - device_auth_hash = TPLinkKlap.generate_auth_hash(client_credentials) + device_auth_hash = KlapTransport.generate_auth_hash(client_credentials) mocker.patch.object(httpx.AsyncClient, "post", side_effect=_return_response) - protocol = TPLinkKlap("127.0.0.1", credentials=client_credentials) + protocol = IotProtocol("127.0.0.1", credentials=client_credentials) for _ in range(10): resp = await protocol.query({}) @@ -296,11 +329,11 @@ async def _return_response(url, params=None, data=None, *_, **__): server_seed = secrets.token_bytes(16) client_credentials = Credentials("foo", "bar") - device_auth_hash = TPLinkKlap.generate_auth_hash(client_credentials) + device_auth_hash = KlapTransport.generate_auth_hash(client_credentials) mocker.patch.object(httpx.AsyncClient, "post", side_effect=_return_response) - protocol = TPLinkKlap("127.0.0.1", credentials=client_credentials) + protocol = IotProtocol("127.0.0.1", credentials=client_credentials) with expectation: await protocol.query({}) diff --git a/kasa/tests/test_plug.py b/kasa/tests/test_plug.py index e97043101..e9e1592f9 100644 --- a/kasa/tests/test_plug.py +++ b/kasa/tests/test_plug.py @@ -1,6 +1,6 @@ from kasa import DeviceType -from .conftest import plug +from .conftest import plug, plug_smart from .newfakes import PLUG_SCHEMA @@ -28,3 +28,14 @@ async def test_led(dev): assert dev.led await dev.set_led(original) + + +@plug_smart +async def test_plug_device_info(dev): + assert dev._info is not None + # PLUG_SCHEMA(dev.sys_info) + + assert dev.model is not None + + assert dev.device_type == DeviceType.Plug or dev.device_type == DeviceType.Strip + # assert dev.is_plug or dev.is_strip diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index 13c6e9944..5772ba42c 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -9,7 +9,7 @@ def test_bulb_examples(mocker): """Use KL130 (bulb with all features) to test the doctests.""" - p = asyncio.run(get_device_for_file("KL130(US)_1.0_1.8.11.json")) + p = asyncio.run(get_device_for_file("KL130(US)_1.0_1.8.11.json", "IOT")) mocker.patch("kasa.smartbulb.SmartBulb", return_value=p) mocker.patch("kasa.smartbulb.SmartBulb.update") res = xdoctest.doctest_module("kasa.smartbulb", "all") @@ -18,7 +18,7 @@ def test_bulb_examples(mocker): def test_smartdevice_examples(mocker): """Use HS110 for emeter examples.""" - p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json")) + p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json", "IOT")) mocker.patch("kasa.smartdevice.SmartDevice", return_value=p) mocker.patch("kasa.smartdevice.SmartDevice.update") res = xdoctest.doctest_module("kasa.smartdevice", "all") @@ -27,7 +27,7 @@ def test_smartdevice_examples(mocker): def test_plug_examples(mocker): """Test plug examples.""" - p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json")) + p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json", "IOT")) mocker.patch("kasa.smartplug.SmartPlug", return_value=p) mocker.patch("kasa.smartplug.SmartPlug.update") res = xdoctest.doctest_module("kasa.smartplug", "all") @@ -36,7 +36,7 @@ def test_plug_examples(mocker): def test_strip_examples(mocker): """Test strip examples.""" - p = asyncio.run(get_device_for_file("KP303(UK)_1.0_1.0.3.json")) + p = asyncio.run(get_device_for_file("KP303(UK)_1.0_1.0.3.json", "IOT")) mocker.patch("kasa.smartstrip.SmartStrip", return_value=p) mocker.patch("kasa.smartstrip.SmartStrip.update") res = xdoctest.doctest_module("kasa.smartstrip", "all") @@ -45,7 +45,7 @@ def test_strip_examples(mocker): def test_dimmer_examples(mocker): """Test dimmer examples.""" - p = asyncio.run(get_device_for_file("HS220(US)_1.0_1.5.7.json")) + p = asyncio.run(get_device_for_file("HS220(US)_1.0_1.5.7.json", "IOT")) mocker.patch("kasa.smartdimmer.SmartDimmer", return_value=p) mocker.patch("kasa.smartdimmer.SmartDimmer.update") res = xdoctest.doctest_module("kasa.smartdimmer", "all") @@ -54,7 +54,7 @@ def test_dimmer_examples(mocker): def test_lightstrip_examples(mocker): """Test lightstrip examples.""" - p = asyncio.run(get_device_for_file("KL430(US)_1.0_1.0.10.json")) + p = asyncio.run(get_device_for_file("KL430(US)_1.0_1.0.10.json", "IOT")) mocker.patch("kasa.smartlightstrip.SmartLightStrip", return_value=p) mocker.patch("kasa.smartlightstrip.SmartLightStrip.update") res = xdoctest.doctest_module("kasa.smartlightstrip", "all") @@ -63,7 +63,7 @@ def test_lightstrip_examples(mocker): def test_discovery_examples(mocker): """Test discovery examples.""" - p = asyncio.run(get_device_for_file("KP303(UK)_1.0_1.0.3.json")) + p = asyncio.run(get_device_for_file("KP303(UK)_1.0_1.0.3.json", "IOT")) mocker.patch("kasa.discover.Discover.discover", return_value=[p]) res = xdoctest.doctest_module("kasa.discover", "all") diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 85dc358df..33c9f4483 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -8,7 +8,7 @@ from kasa import Credentials, SmartDevice, SmartDeviceException from kasa.smartdevice import DeviceType -from .conftest import handle_turn_on, has_emeter, no_emeter, turn_on +from .conftest import device_iot, handle_turn_on, has_emeter, no_emeter, turn_on from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol # List of all SmartXXX classes including the SmartDevice base class @@ -22,11 +22,13 @@ ] +@device_iot async def test_state_info(dev): assert isinstance(dev.state_information, dict) @pytest.mark.requires_dummy +@device_iot async def test_invalid_connection(dev): with patch.object( FakeTransportProtocol, "query", side_effect=SmartDeviceException @@ -58,12 +60,14 @@ async def test_initial_update_no_emeter(dev, mocker): assert spy.call_count == 2 +@device_iot async def test_query_helper(dev): with pytest.raises(SmartDeviceException): await dev._query_helper("test", "testcmd", {}) # TODO check for unwrapping? +@device_iot @turn_on async def test_state(dev, turn_on): await handle_turn_on(dev, turn_on) @@ -90,6 +94,7 @@ async def test_state(dev, turn_on): assert dev.is_off +@device_iot async def test_alias(dev): test_alias = "TEST1234" original = dev.alias @@ -104,6 +109,7 @@ async def test_alias(dev): assert dev.alias == original +@device_iot @turn_on async def test_on_since(dev, turn_on): await handle_turn_on(dev, turn_on) @@ -116,30 +122,37 @@ async def test_on_since(dev, turn_on): assert dev.on_since is None +@device_iot async def test_time(dev): assert isinstance(await dev.get_time(), datetime) +@device_iot async def test_timezone(dev): TZ_SCHEMA(await dev.get_timezone()) +@device_iot async def test_hw_info(dev): PLUG_SCHEMA(dev.hw_info) +@device_iot async def test_location(dev): PLUG_SCHEMA(dev.location) +@device_iot async def test_rssi(dev): PLUG_SCHEMA({"rssi": dev.rssi}) # wrapping for vol +@device_iot async def test_mac(dev): PLUG_SCHEMA({"mac": dev.mac}) # wrapping for val +@device_iot async def test_representation(dev): import re @@ -147,6 +160,7 @@ async def test_representation(dev): assert pattern.match(str(dev)) +@device_iot async def test_childrens(dev): """Make sure that children property is exposed by every device.""" if dev.is_strip: @@ -155,6 +169,7 @@ async def test_childrens(dev): assert len(dev.children) == 0 +@device_iot async def test_children(dev): """Make sure that children property is exposed by every device.""" if dev.is_strip: @@ -165,11 +180,13 @@ async def test_children(dev): assert dev.has_children is False +@device_iot async def test_internal_state(dev): """Make sure the internal state returns the last update results.""" assert dev.internal_state == dev._last_update +@device_iot async def test_features(dev): """Make sure features is always accessible.""" sysinfo = dev._last_update["system"]["get_sysinfo"] @@ -179,11 +196,13 @@ async def test_features(dev): assert dev.features == set() +@device_iot async def test_max_device_response_size(dev): """Make sure every device return has a set max response size.""" assert dev.max_device_response_size > 0 +@device_iot async def test_estimated_response_sizes(dev): """Make sure every module has an estimated response size set.""" for mod in dev.modules.values(): @@ -202,6 +221,7 @@ def test_device_class_ctors(device_class): assert dev.credentials == credentials +@device_iot async def test_modules_preserved(dev: SmartDevice): """Make modules that are not being updated are preserved between updates.""" dev._last_update["some_module_not_being_updated"] = "should_be_kept" @@ -237,6 +257,7 @@ async def test_create_thin_wrapper(): ) +@device_iot async def test_modules_not_supported(dev: SmartDevice): """Test that unsupported modules do not break the device.""" for module in dev.modules.values(): From 01f3827d733ef822685f7a99042301f5ea89367a Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Tue, 5 Dec 2023 14:56:29 +0000 Subject: [PATCH 190/892] Fix transport retries after close (#568) --- kasa/aestransport.py | 2 ++ kasa/klaptransport.py | 2 ++ kasa/tests/test_klapprotocol.py | 10 +++++----- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 6757013da..aefda4222 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -88,6 +88,8 @@ def hash_credentials(self, login_v2): async def client_post(self, url, params=None, data=None, json=None, headers=None): """Send an http post request to the device.""" + if not self._http_client: + self._http_client = httpx.AsyncClient() response_data = None cookies = None if self._session_cookie: diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index c28cb0354..8784d5e99 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -118,6 +118,8 @@ def __init__( async def client_post(self, url, params=None, data=None): """Send an http post request to the device.""" + if not self._http_client: + self._http_client = httpx.AsyncClient() response_data = None cookies = None if self._session_cookie: diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index fe4d1a6ca..0b884d16d 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -32,7 +32,7 @@ def __init__(self, status_code, content: bytes): async def test_protocol_retries(mocker, retry_count, protocol_class, transport_class): host = "127.0.0.1" conn = mocker.patch.object( - transport_class, "client_post", side_effect=Exception("dummy exception") + httpx.AsyncClient, "post", side_effect=Exception("dummy exception") ) with pytest.raises(SmartDeviceException): await protocol_class(host, transport=transport_class(host)).query( @@ -49,8 +49,8 @@ async def test_protocol_no_retry_on_connection_error( ): host = "127.0.0.1" conn = mocker.patch.object( - transport_class, - "client_post", + httpx.AsyncClient, + "post", side_effect=httpx.ConnectError("foo"), ) with pytest.raises(SmartDeviceException): @@ -68,8 +68,8 @@ async def test_protocol_retry_recoverable_error( ): host = "127.0.0.1" conn = mocker.patch.object( - transport_class, - "client_post", + httpx.AsyncClient, + "post", side_effect=httpx.CloseError("foo"), ) with pytest.raises(SmartDeviceException): From 5e2fcd2cca192d451109d5448a5b27324c0b431e Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Tue, 5 Dec 2023 15:45:09 +0000 Subject: [PATCH 191/892] Re-add regional suffix to TAPO/SMART fixtures (#566) --- devtools/dump_devinfo.py | 41 +++++++++++-------- ...1.0_1.3.0.json => P110(UK)_1.0_1.3.0.json} | 0 2 files changed, 25 insertions(+), 16 deletions(-) rename kasa/tests/fixtures/smart/{P110_1.0_1.3.0.json => P110(UK)_1.0_1.3.0.json} (100%) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 777ee1050..30f4ba1f4 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -101,18 +101,25 @@ async def cli(host, debug, username, password): device = await Discover.discover_single(host, credentials=credentials) if isinstance(device, TapoDevice): - save_to, final = await get_smart_fixture(device) + save_filename, copy_folder, final = await get_smart_fixture(device) else: - save_to, final = await get_legacy_fixture(device) + save_filename, copy_folder, final = await get_legacy_fixture(device) pprint(scrub(final)) - save = click.prompt(f"Do you want to save the above content to {save_to} (y/n)") + save = click.prompt( + f"Do you want to save the above content to {save_filename} (y/n)" + ) if save == "y": - click.echo(f"Saving info to {save_to}") + click.echo(f"Saving info to {save_filename}") - with open(save_to, "w") as f: + with open(save_filename, "w") as f: json.dump(final, f, sort_keys=True, indent=4) f.write("\n") + + click.echo( + f"Saved. Copy/Move {save_filename} to " + + f"{copy_folder} to add it to the test suite" + ) else: click.echo("Not saving.") @@ -182,8 +189,9 @@ async def get_legacy_fixture(device): hw_version = sysinfo["hw_ver"] sw_version = sysinfo["sw_ver"] sw_version = sw_version.split(" ", maxsplit=1)[0] - save_to = f"{model}_{hw_version}_{sw_version}.json" - return save_to, final + save_filename = f"{model}_{hw_version}_{sw_version}.json" + copy_folder = "kasa/tests/fixtures/" + return save_filename, copy_folder, final async def get_smart_fixture(device: SmartDevice): @@ -234,23 +242,24 @@ async def get_smart_fixture(device: SmartDevice): for response in responses["responses"]: final[response["method"]] = response["result"] - if device._discovery_info: - # 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. - dr = DiscoveryResult(**device._discovery_info) - final["discovery_result"] = dr.dict( - by_alias=False, exclude_unset=True, exclude_none=True, exclude_defaults=True - ) + # 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. + dr = DiscoveryResult(**device._discovery_info) # type: ignore + final["discovery_result"] = dr.dict( + by_alias=False, exclude_unset=True, exclude_none=True, exclude_defaults=True + ) click.echo("Got %s successes" % len(successes)) click.echo(click.style("## device info file ##", bold=True)) hw_version = final["get_device_info"]["hw_ver"] sw_version = final["get_device_info"]["fw_ver"] - model = final["get_device_info"]["model"] + model = final["discovery_result"]["device_model"] sw_version = sw_version.split(" ", maxsplit=1)[0] - return f"{model}.smart_{hw_version}_{sw_version}.json", final + save_filename = f"{model}_{hw_version}_{sw_version}.json" + copy_folder = "kasa/tests/fixtures/smart/" + return save_filename, copy_folder, final if __name__ == "__main__": diff --git a/kasa/tests/fixtures/smart/P110_1.0_1.3.0.json b/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json similarity index 100% rename from kasa/tests/fixtures/smart/P110_1.0_1.3.0.json rename to kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json From 5febd300caf29e579223f6fa03ccde4f3a6e8c58 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 5 Dec 2023 16:58:25 +0100 Subject: [PATCH 192/892] Add P110 fixture (#562) * Add P110 fixture * Move the fixture file to smart subdir * Update fixture to have the region info * Rename the fixture file to follow the convention --- .../fixtures/smart/P110(EU)_1.0_1.2.3.json | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json diff --git a/kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json b/kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json new file mode 100644 index 000000000..8c45a971b --- /dev/null +++ b/kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json @@ -0,0 +1,171 @@ +{ + "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 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110(EU)", + "device_type": "SMART.TAPOPLUG", + "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_current_power": { + "current_power": 2 + }, + "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.2.3 Build 230425 Rel.142542", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "de_DE", + "latitude": 0, + "longitude": 0, + "mac": "00-00-00-00-00-00", + "model": "P110", + "nickname": "VGFwb3BsdWc=", + "oem_id": "00000000000000000000000000000000", + "on_time": 147207, + "overheated": false, + "power_protection_status": "normal", + "region": "Europe/Berlin", + "rssi": -45, + "signal_level": 3, + "specs": "", + "ssid": "IyNNQVNLRUROQU1FIyM=", + "time_diff": 60, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1701788347 + }, + "get_device_usage": { + "power_usage": { + "past30": 91, + "past7": 91, + "today": 36 + }, + "saved_power": { + "past30": 2380, + "past7": 2363, + "today": 923 + }, + "time_usage": { + "past30": 2471, + "past7": 2454, + "today": 959 + } + }, + "get_energy_usage": { + "current_power": 2225, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2023-12-05 15:59:07", + "month_energy": 91, + "month_runtime": 2454, + "today_energy": 36, + "today_runtime": 959 + } +} From f9b5003da2eeb9d80ee3594764f278ec83a60e15 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 5 Dec 2023 20:07:10 +0100 Subject: [PATCH 193/892] Add support for tapo bulbs (#558) * Add support for tapo light bulbs * Use TapoDevice for on/off * Add tapobulbs to discovery * Add partial support for effects Activating the effect does not work as I thought it would, but this implements rest of the interface from SmartLightStrip. * Add missing __init__ for tapo package * Make mypy happy * Add docstrings to make ruff happy * Implement state_information and has_emeter * Import tapoplug from kasa.tapo package * Add tapo L530 fixture * Enable tests for L530 fixture * Make ruff happy * Update fixture filename * Raise exceptions on invalid parameters * Return results in a wrapped dict * Implement set_* * Reorganize bulbs to iot&smart, fix tests for smarts * Fix linting * Fix BULBS_LIGHT_STRIP back to LIGHT_STRIPS --- devtools/check_readme_vs_fixtures.py | 9 +- kasa/device_factory.py | 4 +- kasa/device_type.py | 1 + kasa/tapo/__init__.py | 3 +- kasa/tapo/tapobulb.py | 267 ++++++++++++++++++ kasa/tests/conftest.py | 111 +++++--- .../fixtures/smart/L530E(EU)_3.0_1.0.6.json | 186 ++++++++++++ kasa/tests/newfakes.py | 7 +- kasa/tests/test_bulb.py | 25 +- kasa/tests/test_discovery.py | 4 +- 10 files changed, 564 insertions(+), 53 deletions(-) create mode 100644 kasa/tapo/tapobulb.py create mode 100644 kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json diff --git a/devtools/check_readme_vs_fixtures.py b/devtools/check_readme_vs_fixtures.py index 2c1e7d95e..1f55eea87 100644 --- a/devtools/check_readme_vs_fixtures.py +++ b/devtools/check_readme_vs_fixtures.py @@ -1,5 +1,12 @@ """Script that checks if README.md is missing devices that have fixtures.""" -from kasa.tests.conftest import ALL_DEVICES, BULBS, DIMMERS, LIGHT_STRIPS, PLUGS, STRIPS +from kasa.tests.conftest import ( + ALL_DEVICES, + BULBS, + DIMMERS, + LIGHT_STRIPS, + PLUGS, + STRIPS, +) with open("README.md") as f: readme = f.read() diff --git a/kasa/device_factory.py b/kasa/device_factory.py index be293ee27..15896e06b 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -18,7 +18,7 @@ from .smartplug import SmartPlug from .smartprotocol import SmartProtocol from .smartstrip import SmartStrip -from .tapo.tapoplug import TapoPlug +from .tapo import TapoBulb, TapoPlug DEVICE_TYPE_TO_CLASS = { DeviceType.Plug: SmartPlug, @@ -27,6 +27,7 @@ DeviceType.Dimmer: SmartDimmer, DeviceType.LightStrip: SmartLightStrip, DeviceType.TapoPlug: TapoPlug, + DeviceType.TapoBulb: TapoBulb, } _LOGGER = logging.getLogger(__name__) @@ -139,6 +140,7 @@ def get_device_class_from_type_name(device_type: str) -> Optional[Type[SmartDevi """Return the device class from the type name.""" supported_device_types: dict[str, Type[SmartDevice]] = { "SMART.TAPOPLUG": TapoPlug, + "SMART.TAPOBULB": TapoBulb, "SMART.KASAPLUG": TapoPlug, "IOT.SMARTPLUGSWITCH": SmartPlug, } diff --git a/kasa/device_type.py b/kasa/device_type.py index c86573065..8373d730c 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -15,6 +15,7 @@ class DeviceType(Enum): Dimmer = "dimmer" LightStrip = "lightstrip" TapoPlug = "tapoplug" + TapoBulb = "tapobulb" Unknown = "unknown" @staticmethod diff --git a/kasa/tapo/__init__.py b/kasa/tapo/__init__.py index 0ec72f3dc..eeb3670cf 100644 --- a/kasa/tapo/__init__.py +++ b/kasa/tapo/__init__.py @@ -1,5 +1,6 @@ """Package for supporting tapo-branded and newer kasa devices.""" +from .tapobulb import TapoBulb from .tapodevice import TapoDevice from .tapoplug import TapoPlug -__all__ = ["TapoDevice", "TapoPlug"] +__all__ = ["TapoDevice", "TapoPlug", "TapoBulb"] diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py new file mode 100644 index 000000000..e01c69b0a --- /dev/null +++ b/kasa/tapo/tapobulb.py @@ -0,0 +1,267 @@ +"""Module for tapo-branded smart bulbs (L5**).""" +from typing import Any, Dict, List, Optional + +from ..exceptions import SmartDeviceException +from ..smartbulb import HSV, ColorTempRange, SmartBulb, SmartBulbPreset +from .tapodevice import TapoDevice + +AVAILABLE_EFFECTS = { + "L1": "Party", + "L2": "Relax", +} + + +class TapoBulb(TapoDevice, SmartBulb): + """Representation of a TP-Link Tapo Bulb. + + Documentation TBD. See :class:`~kasa.smartbulb.SmartBulb` for now. + """ + + @property + def has_emeter(self) -> bool: + """Bulbs have only historical emeter. + + {'usage': + 'power_usage': {'today': 6, 'past7': 106, 'past30': 106}, + 'saved_power': {'today': 35, 'past7': 529, 'past30': 529}, + } + """ + return False + + @property + def is_color(self) -> bool: + """Whether the bulb supports color changes.""" + # TODO: this makes an assumption that only color bulbs report this + return "hue" in self._info + + @property + def is_dimmable(self) -> bool: + """Whether the bulb supports brightness changes.""" + # TODO: this makes an assumption that only dimmables report this + return "brightness" in self._info + + @property + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + # TODO: this makes an assumption, that only ct bulbs report this + return bool(self._info.get("color_temp_range", False)) + + @property + def valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + ct_range = self._info.get("color_temp_range", [0, 0]) + return ColorTempRange(min=ct_range[0], max=ct_range[1]) + + @property + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + return "dynamic_light_effect_enable" in self._info + + @property + def effect(self) -> Dict: + """Return effect state. + + This follows the format used by SmartLightStrip. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + # If no effect is active, dynamic_light_effect_id does not appear in info + current_effect = self._info.get("dynamic_light_effect_id", "") + data = { + "brightness": self.brightness, + "enable": current_effect != "", + "id": current_effect, + "name": AVAILABLE_EFFECTS.get(current_effect, ""), + } + + return data + + @property + def effect_list(self) -> Optional[List[str]]: + """Return built-in effects list. + + Example: + ['Party', 'Relax', ...] + """ + return list(AVAILABLE_EFFECTS.keys()) if self.has_effects else None + + @property + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if not self.is_color: + raise SmartDeviceException("Bulb does not support color.") + + h, s, v = ( + self._info.get("hue", 0), + self._info.get("saturation", 0), + self._info.get("brightness", 0), + ) + + return HSV(hue=h, saturation=s, value=v) + + @property + def color_temp(self) -> int: + """Whether the bulb supports color temperature changes.""" + if not self.is_variable_color_temp: + raise SmartDeviceException("Bulb does not support colortemp.") + + return self._info.get("color_temp", -1) + + @property + def brightness(self) -> int: + """Return the current brightness in percentage.""" + if not self.is_dimmable: # pragma: no cover + raise SmartDeviceException("Bulb is not dimmable.") + + return self._info.get("brightness", -1) + + async def set_hsv( + self, + hue: int, + saturation: int, + value: Optional[int] = None, + *, + transition: Optional[int] = None, + ) -> Dict: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value in percentage [0, 100] + :param int transition: transition in milliseconds. + """ + if not self.is_color: + raise SmartDeviceException("Bulb does not support color.") + + if not isinstance(hue, int) or not (0 <= hue <= 360): + raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") + + if not isinstance(saturation, int) or not (0 <= saturation <= 100): + raise ValueError( + f"Invalid saturation value: {saturation} (valid range: 0-100%)" + ) + + if value is not None: + self._raise_for_invalid_brightness(value) + + return await self.protocol.query( + { + "set_device_info": { + "hue": hue, + "saturation": saturation, + "brightness": value, + } + } + ) + + async def set_color_temp( + self, temp: int, *, brightness=None, transition: Optional[int] = None + ) -> Dict: + """Set the color temperature of the device in kelvin. + + Note, transition is not supported and will be ignored. + + :param int temp: The new color temperature, in Kelvin + :param int transition: transition in milliseconds. + """ + # TODO: Note, trying to set brightness at the same time + # with color_temp causes error -1008 + if not self.is_variable_color_temp: + raise SmartDeviceException("Bulb does not support colortemp.") + + valid_temperature_range = self.valid_temperature_range + if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: + raise ValueError( + "Temperature should be between {} and {}, was {}".format( + *valid_temperature_range, temp + ) + ) + + return await self.protocol.query({"set_device_info": {"color_temp": temp}}) + + async def set_brightness( + self, brightness: int, *, transition: Optional[int] = None + ) -> Dict: + """Set the brightness in percentage. + + Note, transition is not supported and will be ignored. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + if not self.is_dimmable: # pragma: no cover + raise SmartDeviceException("Bulb is not dimmable.") + + return await self.protocol.query( + {"set_device_info": {"brightness": brightness}} + ) + + # Default state information, should be made to settings + """ + "info": { + "default_states": { + "re_power_type": "always_on", + "type": "last_states", + "state": { + "brightness": 36, + "hue": 0, + "saturation": 0, + "color_temp": 2700, + }, + }, + """ + + async def set_effect( + self, + effect: str, + *, + brightness: Optional[int] = None, + transition: Optional[int] = None, + ) -> None: + """Set an effect on the device.""" + raise NotImplementedError() + # TODO: the code below does to activate the effect but gives no error + return await self.protocol.query( + { + "set_device_info": { + "dynamic_light_effect_enable": 1, + "dynamic_light_effect_id": effect, + } + } + ) + + @property # type: ignore + def state_information(self) -> Dict[str, Any]: + """Return bulb-specific state information.""" + info: Dict[str, Any] = { + # TODO: re-enable after we don't inherit from smartbulb + # **super().state_information + "Brightness": self.brightness, + "Is dimmable": self.is_dimmable, + } + if self.is_variable_color_temp: + info["Color temperature"] = self.color_temp + info["Valid temperature range"] = self.valid_temperature_range + if self.is_color: + info["HSV"] = self.hsv + info["Presets"] = self.presets + + return info + + @property + def presets(self) -> List[SmartBulbPreset]: + """Return a list of available bulb setting presets.""" + return [] diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 50d2f0def..0d5180cdd 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -6,7 +6,7 @@ from json import dumps as json_dumps from os.path import basename from pathlib import Path, PurePath -from typing import Dict, Optional +from typing import Dict, Optional, Set from unittest.mock import MagicMock import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 @@ -21,7 +21,7 @@ SmartStrip, TPLinkSmartHomeProtocol, ) -from kasa.tapo import TapoDevice, TapoPlug +from kasa.tapo import TapoBulb, TapoDevice, TapoPlug from .newfakes import FakeSmartProtocol, FakeTransportProtocol @@ -42,19 +42,44 @@ SUPPORTED_DEVICES = SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES +# Tapo bulbs +BULBS_SMART_VARIABLE_TEMP = {"L530"} +BULBS_SMART_COLOR = {"L530"} +BULBS_SMART_LIGHT_STRIP: Set[str] = set() +BULBS_SMART_DIMMABLE: Set[str] = set() +BULBS_SMART = ( + BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) + .union(BULBS_SMART_DIMMABLE) + .union(BULBS_SMART_LIGHT_STRIP) +) + +# Kasa (IOT-prefixed) bulbs +BULBS_IOT_LIGHT_STRIP = {"KL400", "KL430", "KL420"} +BULBS_IOT_VARIABLE_TEMP = { + "LB120", + "LB130", + "KL120", + "KL125", + "KL130", + "KL135", + "KL430", +} +BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} +BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"} +BULBS_IOT = ( + BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR) + .union(BULBS_IOT_DIMMABLE) + .union(BULBS_IOT_LIGHT_STRIP) +) -LIGHT_STRIPS = {"KL400", "KL430", "KL420"} -VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL125", "KL130", "KL135", "KL430"} -COLOR_BULBS = {"LB130", "KL125", "KL130", "KL135", *LIGHT_STRIPS} +BULBS_VARIABLE_TEMP = {*BULBS_SMART_VARIABLE_TEMP, *BULBS_IOT_VARIABLE_TEMP} +BULBS_COLOR = {*BULBS_SMART_COLOR, *BULBS_IOT_COLOR} + + +LIGHT_STRIPS = {*BULBS_SMART_LIGHT_STRIP, *BULBS_IOT_LIGHT_STRIP} BULBS = { - "KL50", - "KL60", - "LB100", - "LB110", - "KL110", - *VARIABLE_TEMP, - *COLOR_BULBS, - *LIGHT_STRIPS, + *BULBS_IOT, + *BULBS_SMART, } @@ -83,7 +108,7 @@ ALL_DEVICES_IOT = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS) PLUGS_SMART = {"P110"} -ALL_DEVICES_SMART = PLUGS_SMART +ALL_DEVICES_SMART = BULBS_SMART.union(PLUGS_SMART) ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) @@ -91,9 +116,12 @@ def idgenerator(paramtuple): - return basename(paramtuple[0]) + ( - "" if paramtuple[1] == "IOT" else "-" + paramtuple[1] - ) + try: + return basename(paramtuple[0]) + ( + "" if paramtuple[1] == "IOT" else "-" + paramtuple[1] + ) + except: # TODO: HACK as idgenerator is now used by default # noqa: E722 + return None def filter_model(desc, model_filter, protocol_filter=None): @@ -108,11 +136,15 @@ def filter_model(desc, model_filter, protocol_filter=None): filtered.append((file, protocol)) filtered_basenames = [basename(f) + "-" + p for f, p in filtered] - print(f"{desc}: {filtered_basenames}") + print(f"# {desc}") + for file in filtered_basenames: + print(f"\t{file}") return filtered def parametrize(desc, devices, protocol_filter=None, ids=None): + if ids is None: + ids = idgenerator return pytest.mark.parametrize( "dev", filter_model(desc, devices, protocol_filter), indirect=True, ids=ids ) @@ -121,29 +153,34 @@ def parametrize(desc, devices, protocol_filter=None, ids=None): has_emeter = parametrize("has emeter", WITH_EMETER) no_emeter = parametrize("no emeter", ALL_DEVICES_IOT - WITH_EMETER) -bulb = parametrize("bulbs", BULBS, ids=idgenerator) -plug = parametrize("plugs", PLUGS, ids=idgenerator) -strip = parametrize("strips", STRIPS, ids=idgenerator) -dimmer = parametrize("dimmers", DIMMERS, ids=idgenerator) -lightstrip = parametrize("lightstrips", LIGHT_STRIPS, ids=idgenerator) +bulb = parametrize("bulbs", BULBS, protocol_filter={"SMART", "IOT"}) +plug = parametrize("plugs", PLUGS) +strip = parametrize("strips", STRIPS) +dimmer = parametrize("dimmers", DIMMERS) +lightstrip = parametrize("lightstrips", LIGHT_STRIPS) # bulb types dimmable = parametrize("dimmable", DIMMABLE) non_dimmable = parametrize("non-dimmable", BULBS - DIMMABLE) -variable_temp = parametrize("variable color temp", VARIABLE_TEMP) -non_variable_temp = parametrize("non-variable color temp", BULBS - VARIABLE_TEMP) -color_bulb = parametrize("color bulbs", COLOR_BULBS) -non_color_bulb = parametrize("non-color bulbs", BULBS - COLOR_BULBS) - -plug_smart = parametrize( - "plug devices smart", PLUGS_SMART, protocol_filter={"SMART"}, ids=idgenerator +variable_temp = parametrize( + "variable color temp", BULBS_VARIABLE_TEMP, {"SMART", "IOT"} ) -device_smart = parametrize( - "devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"}, ids=idgenerator +non_variable_temp = parametrize( + "non-variable color temp", BULBS - BULBS_VARIABLE_TEMP, {"SMART", "IOT"} ) -device_iot = parametrize( - "devices iot", ALL_DEVICES_IOT, protocol_filter={"IOT"}, ids=idgenerator +color_bulb = parametrize("color bulbs", BULBS_COLOR, {"SMART", "IOT"}) +non_color_bulb = parametrize("non-color bulbs", BULBS - BULBS_COLOR, {"SMART", "IOT"}) + +color_bulb_iot = parametrize("color bulbs iot", BULBS_COLOR, {"IOT"}) +variable_temp_iot = parametrize("variable color temp iot", BULBS_VARIABLE_TEMP, {"IOT"}) +bulb_iot = parametrize("bulb devices iot", BULBS_IOT) + +plug_smart = parametrize("plug devices smart", PLUGS_SMART, protocol_filter={"SMART"}) +bulb_smart = parametrize("bulb devices smart", BULBS_SMART, protocol_filter={"SMART"}) +device_smart = parametrize( + "devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"} ) +device_iot = parametrize("devices iot", ALL_DEVICES_IOT, protocol_filter={"IOT"}) def get_fixture_data(): @@ -197,6 +234,7 @@ def check_categories(): + bulb.args[1] + lightstrip.args[1] + plug_smart.args[1] + + bulb_smart.args[1] ) diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures) if diff: @@ -225,6 +263,9 @@ def device_for_file(model, protocol): for d in PLUGS_SMART: if d in model: return TapoPlug + for d in BULBS_SMART: + if d in model: + return TapoBulb else: for d in STRIPS: if d in model: @@ -235,7 +276,7 @@ def device_for_file(model, protocol): return SmartPlug # Light strips are recognized also as bulbs, so this has to go first - for d in LIGHT_STRIPS: + for d in BULBS_IOT_LIGHT_STRIP: if d in model: return SmartLightStrip diff --git a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json new file mode 100644 index 000000000..06e7cded6 --- /dev/null +++ b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json @@ -0,0 +1,186 @@ +{ + "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 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530E(EU)", + "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_device_info": { + "avatar": "bulb", + "brightness": 100, + "color_temp": 2500, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 100, + "color_temp": 2500, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.6 Build 230509 Rel.195312", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "de_DE", + "latitude": 0, + "longitude": 0, + "mac": "00-00-00-00-00-00", + "model": "L530", + "nickname": "c21hcnRlIFdMQU4tR2zDvGhiaXJuZQ==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -38, + "saturation": 100, + "signal_level": 3, + "specs": "", + "ssid": "IyNNQVNLRUROQU1FIyM=", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1701618972 + }, + "get_device_usage": { + "power_usage": { + "past30": 107, + "past7": 107, + "today": 7 + }, + "saved_power": { + "past30": 535, + "past7": 535, + "today": 41 + }, + "time_usage": { + "past30": 642, + "past7": 642, + "today": 48 + } + } +} diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index c5bf238f8..76faae339 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -313,10 +313,13 @@ async def handshake(self) -> None: async def send(self, request: str): request_dict = json_loads(request) method = request_dict["method"] + params = request_dict["params"] if method == "component_nego" or method[:4] == "get_": - return self.info[method] + return {"result": self.info[method]} elif method[:4] == "set_": - _LOGGER.debug("Call %s not implemented, doing nothing", method) + target_method = f"get_{method[4:]}" + self.info[target_method].update(params) + return {"result": ""} async def close(self) -> None: pass diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index f73a948b2..5bacf3cc5 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -4,7 +4,9 @@ from .conftest import ( bulb, + bulb_iot, color_bulb, + color_bulb_iot, dimmable, handle_turn_on, non_color_bulb, @@ -12,6 +14,7 @@ non_variable_temp, turn_on, variable_temp, + variable_temp_iot, ) from .newfakes import BULB_SCHEMA, LIGHT_STATE_SCHEMA @@ -38,7 +41,7 @@ async def test_state_attributes(dev: SmartBulb): assert dev.state_information["Is dimmable"] == dev.is_dimmable -@bulb +@bulb_iot async def test_light_state_without_update(dev: SmartBulb, monkeypatch): with pytest.raises(SmartDeviceException): monkeypatch.setitem( @@ -47,7 +50,7 @@ async def test_light_state_without_update(dev: SmartBulb, monkeypatch): print(dev.light_state) -@bulb +@bulb_iot async def test_get_light_state(dev: SmartBulb): LIGHT_STATE_SCHEMA(await dev.get_light_state()) @@ -72,7 +75,7 @@ async def test_hsv(dev: SmartBulb, turn_on): assert brightness == 1 -@color_bulb +@color_bulb_iot async def test_set_hsv_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") await dev.set_hsv(10, 10, 100, transition=1000) @@ -138,7 +141,7 @@ async def test_try_set_colortemp(dev: SmartBulb, turn_on): assert dev.color_temp == 2700 -@variable_temp +@variable_temp_iot async def test_set_color_temp_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") await dev.set_color_temp(2700, transition=100) @@ -146,7 +149,7 @@ async def test_set_color_temp_transition(dev: SmartBulb, mocker): set_light_state.assert_called_with({"color_temp": 2700}, transition=100) -@variable_temp +@variable_temp_iot async def test_unknown_temp_range(dev: SmartBulb, monkeypatch, caplog): monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") @@ -192,7 +195,7 @@ async def test_dimmable_brightness(dev: SmartBulb, turn_on): await dev.set_brightness("foo") -@bulb +@bulb_iot async def test_turn_on_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") await dev.turn_on(transition=1000) @@ -204,7 +207,7 @@ async def test_turn_on_transition(dev: SmartBulb, mocker): set_light_state.assert_called_with({"on_off": 0}, transition=100) -@bulb +@bulb_iot async def test_dimmable_brightness_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") await dev.set_brightness(10, transition=1000) @@ -233,7 +236,7 @@ async def test_non_dimmable(dev: SmartBulb): await dev.set_brightness(100) -@bulb +@bulb_iot async def test_ignore_default_not_set_without_color_mode_change_turn_on( dev: SmartBulb, mocker ): @@ -248,7 +251,7 @@ async def test_ignore_default_not_set_without_color_mode_change_turn_on( assert args[2] == {"on_off": 0, "ignore_default": 1} -@bulb +@bulb_iot async def test_list_presets(dev: SmartBulb): presets = dev.presets assert len(presets) == len(dev.sys_info["preferred_state"]) @@ -261,7 +264,7 @@ async def test_list_presets(dev: SmartBulb): assert preset.color_temp == raw["color_temp"] -@bulb +@bulb_iot async def test_modify_preset(dev: SmartBulb, mocker): """Verify that modifying preset calls the and exceptions are raised properly.""" if not dev.presets: @@ -291,7 +294,7 @@ async def test_modify_preset(dev: SmartBulb, mocker): ) -@bulb +@bulb_iot @pytest.mark.parametrize( ("preset", "payload"), [ diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index ea97d94ad..72555c7ea 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -15,7 +15,7 @@ from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps from kasa.exceptions import AuthenticationException, UnsupportedDeviceException -from .conftest import bulb, dimmer, lightstrip, plug, strip +from .conftest import bulb, bulb_iot, dimmer, lightstrip, plug, strip UNSUPPORTED = { "result": { @@ -46,7 +46,7 @@ async def test_type_detection_plug(dev: SmartDevice): assert d.device_type == DeviceType.Plug -@bulb +@bulb_iot async def test_type_detection_bulb(dev: SmartDevice): d = Discover._get_device_class(dev._last_update)("localhost") # TODO: light_strip is a special case for now to force bulb tests on it From 8cdd4f59f87e9fafd4ef05731fc42fe13679b581 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 5 Dec 2023 23:20:29 +0100 Subject: [PATCH 194/892] Use consistent naming for cli envvars (#570) * Use consistent naming for cli envvars * Fix linting --- kasa/cli.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index c0a380dc9..3557bf4ed 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -129,7 +129,11 @@ def _device_to_serializable(val: SmartDevice): type=click.Choice(DEVICE_TYPES, case_sensitive=False), ) @click.option( - "--json", default=False, is_flag=True, help="Output raw device response as JSON." + "--json/--no-json", + envvar="KASA_JSON", + default=False, + is_flag=True, + help="Output raw device response as JSON.", ) @click.option( "--timeout", @@ -149,14 +153,14 @@ def _device_to_serializable(val: SmartDevice): "--username", default=None, required=False, - envvar="TPLINK_CLOUD_USERNAME", + envvar="KASA_USERNAME", help="Username/email address to authenticate to device.", ) @click.option( "--password", default=None, required=False, - envvar="TPLINK_CLOUD_PASSWORD", + envvar="KASA_PASSWORD", help="Password to use to authenticate to device.", ) @click.version_option(package_name="python-kasa") From bd23d616879b9ea9b8b84eb17590ffb439fba63b Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 6 Dec 2023 22:54:14 +0100 Subject: [PATCH 195/892] Update readme with clearer instructions, tapo support (#571) * Update readme with clearer instructions, tapo support All in all, making the readme more approachable landing page: * Link to provisioning docs from the beginning * Hints about support for tapo devices * Add some documentation about how to pass credentials * Clearer usage instructions and links to find more information * Lint --- README.md | 176 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 138 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index f2dcf46c8..6c151b34d 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,12 @@ [![codecov](https://codecov.io/gh/python-kasa/python-kasa/branch/master/graph/badge.svg?token=5K7rtN5OmS)](https://codecov.io/gh/python-kasa/python-kasa) [![Documentation Status](https://readthedocs.org/projects/python-kasa/badge/?version=latest)](https://python-kasa.readthedocs.io/en/latest/?badge=latest) -python-kasa is a Python library to control TPLink's kasa-branded smart home devices (plugs, wall switches, power strips, and bulbs) using asyncio. +python-kasa is a Python library to control TPLink's smart home devices (plugs, wall switches, power strips, and bulbs). This is a voluntary, community-driven effort and is not affiliated, sponsored, or endorsed by TPLink. +**Contributions in any form (adding missing features, reporting issues, fixing or triaging existing ones, improving the documentation, or device donations) are more than welcome!** + --- ## Getting started @@ -32,44 +34,126 @@ cd python-kasa/ poetry install ``` +If you have not yet provisioned your device, [you can do so using the cli tool](https://python-kasa.readthedocs.io/en/latest/cli.html#provisioning). + ## Discovering devices -After installation, the devices can be discovered either by using `kasa discover` or by calling `kasa` without any parameters. - -``` -$ kasa -No --bulb nor --plug given, discovering.. -Discovering devices for 3 seconds -== My Smart Plug - HS110(EU) == -Device state: ON -IP address: 192.168.x.x -LED state: False -On since: 2017-03-26 18:29:17.242219 -== Generic information == -Time: 1970-06-22 02:39:41 -Hardware: 1.0 -Software: 1.0.8 Build 151101 Rel.24452 -MAC (rssi): 50:C7:BF:XX:XX:XX (-77) -Location: {'latitude': XXXX, 'longitude': XXXX} -== Emeter == -Current state: {'total': 133.082, 'power': 100.418681, 'current': 0.510967, 'voltage': 225.600477} +Running `kasa discover` will send discovery packets to the default broadcast address (`255.255.255.255`) to discover supported devices. +If your system has multiple network interfaces, you can specify the broadcast address using the `--target` option. + +The `discover` command will automatically execute the `state` command on all the discovered devices: + +``` +$ kasa discover +Discovering devices on 255.255.255.255 for 3 seconds + +== Bulb McBulby - KL130(EU) == + Host: 192.168.xx.xx + Port: 9999 + Device state: True + == Generic information == + Time: 2023-12-05 14:33:23 (tz: {'index': 6, 'err_code': 0} + Hardware: 1.0 + Software: 1.8.8 Build 190613 Rel.123436 + MAC (rssi): 1c:3b:f3:xx:xx:xx (-56) + Location: {'latitude': None, 'longitude': None} + + == Device specific information == + Brightness: 16 + Is dimmable: True + Color temperature: 2500 + Valid temperature range: ColorTempRange(min=2500, max=9000) + HSV: HSV(hue=0, saturation=0, value=16) + Presets: + index=0 brightness=50 hue=0 saturation=0 color_temp=2500 custom=None id=None mode=None + index=1 brightness=100 hue=299 saturation=95 color_temp=0 custom=None id=None mode=None + index=2 brightness=100 hue=120 saturation=75 color_temp=0 custom=None id=None mode=None + index=3 brightness=100 hue=240 saturation=75 color_temp=0 custom=None id=None mode=None + + == Current State == + + + == Modules == + + + + + + + + + + + - + + ``` -Use `kasa --help` to get list of all available commands, or alternatively, [consult the documentation](https://python-kasa.readthedocs.io/en/latest/cli.html). +If your device requires authentication to control it, +you need to pass the credentials using `--username` and `--password` options. -## Basic controls +## Basic functionalities All devices support a variety of common commands, including: - * `state` which returns state information - * `on` and `off` for turning the device on or off - * `emeter` (where applicable) to return energy consumption information - * `sysinfo` to return raw system information + +* `state` which returns state information +* `on` and `off` for turning the device on or off +* `emeter` (where applicable) to return energy consumption information +* `sysinfo` to return raw system information + +The syntax to control device is `kasa --host `. +Use `kasa --help` ([or consult the documentation](https://python-kasa.readthedocs.io/en/latest/cli.html#kasa-help)) to get a list of all available commands and options. +Some examples of available options include JSON output (`--json`), defining timeouts (`--timeout` and `--discovery-timeout`). + +Each individual command may also have additional options, which are shown when called with the `--help` option. +For example, `--transition` on bulbs requests a smooth state change, while `--name` and `--index` are used on power strips to select the socket to act on: + +``` +$ kasa on --help + +Usage: kasa on [OPTIONS] + + Turn the device on. + +Options: + --index INTEGER + --name TEXT + --transition INTEGER + --help Show this message and exit. +``` + + +### Bulbs + +Common commands for bulbs and light strips include: + +* `brightness` to control the brightness +* `hsv` to control the colors +* `temperature` to control the color temperatures + +When executed without parameters, these commands will report the current state. + +Some devices support `--transition` option to perform a smooth state change. +For example, the following turns the light to 30% brightness over a period of five seconds: +``` +$ kasa --host brightness --transition 5000 30 +``` + +See `--help` for additional options and [the documentation](https://python-kasa.readthedocs.io/en/latest/smartbulb.html) for more details about supported features and limitations. + +### Power strips + +Each individual socket can be controlled separately by passing `--index` or `--name` to the command. +If neither option is defined, the commands act on the whole power strip. + +For example: +``` +$ kasa --host off # turns off all sockets +$ kasa --host off --name 'Socket1' # turns off socket named 'Socket1' +``` + +See `--help` for additional options and [the documentation](https://python-kasa.readthedocs.io/en/latest/smartstrip.html) for more details about supported features and limitations. + ## Energy meter -Passing no options to `emeter` command will return the current consumption. +Running `kasa emeter` command will return the current consumption. Possible options include `--year` and `--month` for retrieving historical state, -and reseting the counters is done with `--erase`. +and reseting the counters can be done with `--erase`. ``` $ kasa emeter @@ -77,14 +161,19 @@ $ kasa emeter Current state: {'total': 133.105, 'power': 108.223577, 'current': 0.54463, 'voltage': 225.296283} ``` -## Bulb-specific commands +# Library usage -At the moment setting brightness, color temperature and color (in HSV) are supported depending on the device. -The commands are straightforward, so feel free to check `--help` for instructions how to use them. +If you want to use this library in your own project, a good starting point is to check [the documentation on discovering devices](https://python-kasa.readthedocs.io/en/latest/discover.html). +You can find several code examples in the API documentation of each of the implementation base classes, check out the [documentation for the base class shared by all supported devices](https://python-kasa.readthedocs.io/en/latest/smartdevice.html). -# Library usage +[The library design and module structure is described in a separate page](https://python-kasa.readthedocs.io/en/latest/design.html). -You can find several code examples in [the API documentation](https://python-kasa.readthedocs.io). +The device type specific documentation can be found in their separate pages: +* [Plugs](https://python-kasa.readthedocs.io/en/latest/smartplug.html) +* [Bulbs](https://python-kasa.readthedocs.io/en/latest/smartbulb.html) +* [Dimmers](https://python-kasa.readthedocs.io/en/latest/smartdimmer.html) +* [Power strips](https://python-kasa.readthedocs.io/en/latest/smartstrip.html) +* [Light strips](https://python-kasa.readthedocs.io/en/latest/smartlightstrip.html) ## Contributing @@ -109,7 +198,7 @@ You can also execute the checks by running either `tox -e lint` to only do the l You can run tests on the library by executing `pytest` in the source directory. This will run the tests against contributed example responses, but you can also execute the tests against a real device: ``` -pytest --ip
+$ pytest --ip
``` Note that this will perform state changes on the device. @@ -118,13 +207,15 @@ Note that this will perform state changes on the device. The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. After capturing the traffic, you can either use the [softScheck's wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) or the `parse_pcap.py` script contained inside the `devtools` directory. +Note, that this works currently only on kasa-branded devices which use port 9999 for communications. ## Supported devices -In principle all devices that are locally controllable using the official Kasa mobile app should work with this library. -The following lists merely 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 (generated by `devtools/dump_devinfo.py`). +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).** ### Plugs @@ -181,7 +272,16 @@ If your device is unlisted but working, please open a pull request to update the * KL420 * KL430 -**Contributions (be it adding missing features, fixing bugs or improving documentation) are more than welcome, feel free to submit pull requests!** +### 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 L530 (bulb) + +**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).** ## Resources From be289a57518504708881a8e4dcbe50e354051ae7 Mon Sep 17 00:00:00 2001 From: Steven Bytnar Date: Thu, 7 Dec 2023 17:04:50 -0600 Subject: [PATCH 196/892] Add KP125M fixture and allow passing credentials for tests (#567) * Add KP125M fixture. Enable tapo auth in pytest. * authentication is not just for tapo * Use "##MASKEDNAME##" base64 for nickname and ssid. --------- Co-authored-by: Teemu R. --- kasa/tests/conftest.py | 25 ++- .../fixtures/smart/KP125M(US)_1.0_1.1.3.json | 175 ++++++++++++++++++ 2 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 kasa/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 0d5180cdd..53e2c4944 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -95,6 +95,7 @@ "KP105", "KP115", "KP125", + "KP125M", "KP401", "KS200M", } @@ -103,11 +104,11 @@ DIMMERS = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} DIMMABLE = {*BULBS, *DIMMERS} -WITH_EMETER = {"HS110", "HS300", "KP115", "KP125", *BULBS} +WITH_EMETER = {"HS110", "HS300", "KP115", "KP125", "KP125M", *BULBS} ALL_DEVICES_IOT = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS) -PLUGS_SMART = {"P110"} +PLUGS_SMART = {"P110", "KP125M"} ALL_DEVICES_SMART = BULBS_SMART.union(PLUGS_SMART) ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) @@ -297,8 +298,12 @@ async def _update_and_close(d): return d -async def _discover_update_and_close(ip): - d = await Discover.discover_single(ip, timeout=10) +async def _discover_update_and_close(ip, username, password): + if username and password: + credentials = Credentials(username=username, password=password) + else: + credentials = None + d = await Discover.discover_single(ip, timeout=10, credentials=credentials) return await _update_and_close(d) @@ -339,15 +344,17 @@ async def dev(request): file, protocol = request.param ip = request.config.getoption("--ip") + username = request.config.getoption("--username") + password = request.config.getoption("--password") if ip: model = IP_MODEL_CACHE.get(ip) d = None if not model: - d = await _discover_update_and_close(ip) + d = await _discover_update_and_close(ip, username, password) 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) + return d if d else await _discover_update_and_close(ip, username, password) return await get_device_for_file(file, protocol) @@ -411,6 +418,12 @@ def pytest_addoption(parser): parser.addoption( "--ip", action="store", default=None, help="run against device on given ip" ) + parser.addoption( + "--username", action="store", default=None, help="authentication username" + ) + parser.addoption( + "--password", action="store", default=None, help="authentication password" + ) def pytest_collection_modifyitems(config, items): diff --git a/kasa/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json b/kasa/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json new file mode 100644 index 000000000..c7b6ecb9d --- /dev/null +++ b/kasa/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json @@ -0,0 +1,175 @@ +{ + "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": "matter", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KP125M(US)", + "device_type": "SMART.KASAPLUG", + "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_current_power": { + "current_power": 17 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "coffee_maker", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.3 Build 230801 Rel.092557", + "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", + "model": "KP125M", + "nickname": "IyNNQVNLRUROQU1FIyM=", + "oem_id": "00000000000000000000000000000000", + "on_time": 5332, + "overheated": false, + "power_protection_status": "normal", + "region": "America/Chicago", + "rssi": -62, + "signal_level": 2, + "specs": "", + "ssid": "IyNNQVNLRUROQU1FIyM=", + "time_diff": -360, + "type": "SMART.KASAPLUG" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1701569348 + }, + "get_device_usage": { + "power_usage": { + "past30": 6757, + "past7": 4101, + "today": 513 + }, + "saved_power": { + "past30": 9516, + "past7": 5748, + "today": 696 + }, + "time_usage": { + "past30": 16273, + "past7": 9849, + "today": 1209 + } + }, + "get_energy_usage": { + "current_power": 17654, + "electricity_charge": [ + 0, + 64, + 111 + ], + "local_time": "2023-12-02 20:09:08", + "month_energy": 1020, + "month_runtime": 2649, + "today_energy": 513, + "today_runtime": 1209 + } +} From b27a31a8a96cbd88d2bed77c95695f5da9eb5209 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Fri, 8 Dec 2023 13:29:07 +0000 Subject: [PATCH 197/892] Add new methods to dump_devinfo and mask aliases (#574) --- devtools/dump_devinfo.py | 14 +++++++++++++- kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json | 2 +- kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json | 4 ++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 30f4ba1f4..c9f34fcb1 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -44,6 +44,8 @@ def scrub(res): "hw_id", "fw_id", "oem_id", + "nickname", + "alias", ] for k, v in res.items(): @@ -57,7 +59,11 @@ def scrub(res): v = "127.0.0.123" elif k in ["ssid"]: # Need a valid base64 value here - v = base64.b64encode(b"##MASKEDNAME##").decode() + v = base64.b64encode(b"#MASKED_SSID#").decode() + elif k in ["nickname"]: + v = base64.b64encode(b"#MASKED_NAME#").decode() + elif k in ["alias"]: + v = "#MASKED_NAME#" else: v = re.sub(r"\w", "0", v) @@ -203,6 +209,12 @@ async def get_smart_fixture(device: SmartDevice): Call(module="device_time", method="get_device_time"), Call(module="energy_usage", method="get_energy_usage"), Call(module="current_power", method="get_current_power"), + Call(module="temp_humidity_records", method="get_temp_humidity_records"), + Call(module="child_device_list", method="get_child_device_list"), + Call( + module="trigger_logs", + method={"get_trigger_logs": {"page_size": 5, "start_id": 0}}, + ), Call( module="child_device_component_list", method="get_child_device_component_list", diff --git a/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json b/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json index 2425c17f3..7caf6aebf 100644 --- a/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json +++ b/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json @@ -17,7 +17,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Bedroom Lamp 2", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json b/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json index 99fd3f133..9033e8002 100644 --- a/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json +++ b/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json @@ -128,7 +128,7 @@ "longitude": 0, "mac": "00-00-00-00-00-00", "model": "P110", - "nickname": "VGFwaSBTbWFydCBQbHVnIDE=", + "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "on_time": 119335, "overcurrent_status": "normal", @@ -138,7 +138,7 @@ "rssi": -57, "signal_level": 2, "specs": "", - "ssid": "IyNNQVNLRUROQU1FIyM=", + "ssid": "I01BU0tFRF9TU0lEIw==", "time_diff": 0, "type": "SMART.TAPOPLUG" }, From 16ba87378d2c21953417ae5d82b45ab89a1a3ddd Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Fri, 8 Dec 2023 13:55:14 +0000 Subject: [PATCH 198/892] Add EP25 smart fixture and improve test framework for SMART devices (#572) --- README.md | 26 ++- kasa/tests/conftest.py | 100 ++++++---- .../fixtures/smart/EP25(US)_2.6_1.0.1.json | 175 ++++++++++++++++++ 3 files changed, 258 insertions(+), 43 deletions(-) create mode 100644 kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json diff --git a/README.md b/README.md index 6c151b34d..64450aa45 100644 --- a/README.md +++ b/README.md @@ -228,9 +228,10 @@ The following lists the devices that have been manually verified to work. * KP105 * KP115 * KP125 -* KP125M +* KP125M [See note below](#tapo-and-newer-kasa-branded-devices) * KP401 * EP10 +* EP25 [See note below](#tapo-and-newer-kasa-branded-devices) ### Power Strips @@ -268,18 +269,25 @@ The following lists the devices that have been manually verified to work. ### Light strips -* KL400 -* KL420 +* KL400L5 +* KL420L5 * KL430 -### Tapo-branded devices +### Tapo and newer Kasa branded devices -The library has recently added a limited supported for devices that carry tapo branding. +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 L530 (bulb) +* Tapo L530E (bulb) + +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 +devices: + +* Kasa EP25 (plug) hw_version 2.6 +* Kasa KP125M (plug) **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).** @@ -301,6 +309,12 @@ At the moment, the following devices have been confirmed to work: ### TP-Link Tapo support +This library has recently added a limited supported for devices that carry Tapo branding. +That support is currently limited to the cli. The package `kasa.tapo` is in flux and if you +use it directly you should expect it could break in future releases until this statement is removed. + +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) * [Home Assistant integration](https://github.com/fishbigger/HomeAssistant-Tapo-P100-Control) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 53e2c4944..f84083e2a 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -43,8 +43,8 @@ SUPPORTED_DEVICES = SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES # Tapo bulbs -BULBS_SMART_VARIABLE_TEMP = {"L530"} -BULBS_SMART_COLOR = {"L530"} +BULBS_SMART_VARIABLE_TEMP = {"L530E"} +BULBS_SMART_COLOR = {"L530E"} BULBS_SMART_LIGHT_STRIP: Set[str] = set() BULBS_SMART_DIMMABLE: Set[str] = set() BULBS_SMART = ( @@ -54,7 +54,7 @@ ) # Kasa (IOT-prefixed) bulbs -BULBS_IOT_LIGHT_STRIP = {"KL400", "KL430", "KL420"} +BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL430", "KL420L5"} BULBS_IOT_VARIABLE_TEMP = { "LB120", "LB130", @@ -83,7 +83,7 @@ } -PLUGS = { +PLUGS_IOT = { "HS100", "HS103", "HS105", @@ -95,22 +95,35 @@ "KP105", "KP115", "KP125", - "KP125M", "KP401", "KS200M", } +PLUGS_SMART = {"P110", "KP125M", "EP25"} +PLUGS = { + *PLUGS_IOT, + *PLUGS_SMART, +} +STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} +STRIPS_SMART = {} # type: ignore[var-annotated] +STRIPS = {*STRIPS_IOT, *STRIPS_SMART} + +DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} +DIMMERS_SMART = {} # type: ignore[var-annotated] +DIMMERS = { + *DIMMERS_IOT, + *DIMMERS_SMART, +} -STRIPS = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} -DIMMERS = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} +WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} +WITH_EMETER_SMART = {*PLUGS_SMART} +WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} DIMMABLE = {*BULBS, *DIMMERS} -WITH_EMETER = {"HS110", "HS300", "KP115", "KP125", "KP125M", *BULBS} - -ALL_DEVICES_IOT = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS) - -PLUGS_SMART = {"P110", "KP125M"} -ALL_DEVICES_SMART = BULBS_SMART.union(PLUGS_SMART) +ALL_DEVICES_IOT = BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT) +ALL_DEVICES_SMART = ( + BULBS_SMART.union(PLUGS_SMART).union(STRIPS_SMART).union(DIMMERS_SMART) +) ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) IP_MODEL_CACHE: Dict[str, str] = {} @@ -126,14 +139,15 @@ def idgenerator(paramtuple): def filter_model(desc, model_filter, protocol_filter=None): - if not protocol_filter: - protocol_filter = {"IOT"} + if protocol_filter is None: + protocol_filter = {"IOT", "SMART"} filtered = list() for file, protocol in SUPPORTED_DEVICES: if protocol in protocol_filter: - file_model = basename(file).split("_")[0] + file_model_region = basename(file).split("_")[0] + file_model = file_model_region.split("(")[0] for model in model_filter: - if model in file_model: + if model == file_model: filtered.append((file, protocol)) filtered_basenames = [basename(f) + "-" + p for f, p in filtered] @@ -151,30 +165,40 @@ def parametrize(desc, devices, protocol_filter=None, ids=None): ) -has_emeter = parametrize("has emeter", WITH_EMETER) -no_emeter = parametrize("no emeter", ALL_DEVICES_IOT - WITH_EMETER) +has_emeter = parametrize("has emeter", WITH_EMETER_IOT, protocol_filter={"IOT"}) +no_emeter = parametrize( + "no emeter", ALL_DEVICES_IOT - WITH_EMETER_IOT, protocol_filter={"SMART", "IOT"} +) bulb = parametrize("bulbs", BULBS, protocol_filter={"SMART", "IOT"}) -plug = parametrize("plugs", PLUGS) -strip = parametrize("strips", STRIPS) -dimmer = parametrize("dimmers", DIMMERS) -lightstrip = parametrize("lightstrips", LIGHT_STRIPS) +plug = parametrize("plugs", PLUGS, protocol_filter={"IOT"}) +strip = parametrize("strips", STRIPS, protocol_filter={"IOT"}) +dimmer = parametrize("dimmers", DIMMERS, protocol_filter={"IOT"}) +lightstrip = parametrize("lightstrips", LIGHT_STRIPS, protocol_filter={"IOT"}) # bulb types -dimmable = parametrize("dimmable", DIMMABLE) -non_dimmable = parametrize("non-dimmable", BULBS - DIMMABLE) +dimmable = parametrize("dimmable", DIMMABLE, protocol_filter={"IOT"}) +non_dimmable = parametrize("non-dimmable", BULBS - DIMMABLE, protocol_filter={"IOT"}) variable_temp = parametrize( - "variable color temp", BULBS_VARIABLE_TEMP, {"SMART", "IOT"} + "variable color temp", BULBS_VARIABLE_TEMP, protocol_filter={"SMART", "IOT"} ) non_variable_temp = parametrize( - "non-variable color temp", BULBS - BULBS_VARIABLE_TEMP, {"SMART", "IOT"} + "non-variable color temp", + BULBS - BULBS_VARIABLE_TEMP, + protocol_filter={"SMART", "IOT"}, +) +color_bulb = parametrize("color bulbs", BULBS_COLOR, protocol_filter={"SMART", "IOT"}) +non_color_bulb = parametrize( + "non-color bulbs", BULBS - BULBS_COLOR, protocol_filter={"SMART", "IOT"} ) -color_bulb = parametrize("color bulbs", BULBS_COLOR, {"SMART", "IOT"}) -non_color_bulb = parametrize("non-color bulbs", BULBS - BULBS_COLOR, {"SMART", "IOT"}) -color_bulb_iot = parametrize("color bulbs iot", BULBS_COLOR, {"IOT"}) -variable_temp_iot = parametrize("variable color temp iot", BULBS_VARIABLE_TEMP, {"IOT"}) -bulb_iot = parametrize("bulb devices iot", BULBS_IOT) +color_bulb_iot = parametrize( + "color bulbs iot", BULBS_IOT_COLOR, protocol_filter={"IOT"} +) +variable_temp_iot = parametrize( + "variable color temp iot", BULBS_IOT_VARIABLE_TEMP, protocol_filter={"IOT"} +) +bulb_iot = parametrize("bulb devices iot", BULBS_IOT, protocol_filter={"IOT"}) plug_smart = parametrize("plug devices smart", PLUGS_SMART, protocol_filter={"SMART"}) bulb_smart = parametrize("bulb devices smart", BULBS_SMART, protocol_filter={"SMART"}) @@ -209,7 +233,9 @@ def filter_fixtures(desc, root_filter): if root_filter in val: filtered[key] = val - print(f"{desc}: {filtered.keys()}") + print(f"# {desc}") + for key in filtered: + print(f"\t{key}") return filtered @@ -268,11 +294,11 @@ def device_for_file(model, protocol): if d in model: return TapoBulb else: - for d in STRIPS: + for d in STRIPS_IOT: if d in model: return SmartStrip - for d in PLUGS: + for d in PLUGS_IOT: if d in model: return SmartPlug @@ -281,11 +307,11 @@ def device_for_file(model, protocol): if d in model: return SmartLightStrip - for d in BULBS: + for d in BULBS_IOT: if d in model: return SmartBulb - for d in DIMMERS: + for d in DIMMERS_IOT: if d in model: return SmartDimmer diff --git a/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json b/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json new file mode 100644 index 000000000..a9c412284 --- /dev/null +++ b/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json @@ -0,0 +1,175 @@ +{ + "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 + } + ] + }, + "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": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "apple", + "owner": "00000000000000000000000000000000" + }, + "get_current_power": { + "current_power": 715 + }, + "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.1 Build 230614 Rel.150219", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "2.6", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "00-00-00-00-00-00", + "model": "EP25", + "nickname": "emVlaw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 177938, + "overheated": false, + "power_protection_status": "normal", + "region": "America/Los_Angeles", + "rssi": -58, + "signal_level": 2, + "specs": "", + "ssid": "IyNNQVNLRUROQU1FIyM=", + "time_diff": -480, + "type": "SMART.KASAPLUG" + }, + "get_device_time": { + "region": "America/Los_Angeles", + "time_diff": -480, + "timestamp": 1701455103 + }, + "get_device_usage": { + "power_usage": { + "past30": 100856, + "past7": 56030, + "today": 3965 + }, + "saved_power": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "time_usage": { + "past30": 19678, + "past7": 9265, + "today": 625 + } + }, + "get_energy_usage": { + "current_power": 715092, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2023-12-01 10:25:03", + "month_energy": 3965, + "month_runtime": 625, + "today_energy": 3965, + "today_runtime": 625 + } +} From 1e2241ee95fe07d60a7566e1624b55bd68401762 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 8 Dec 2023 15:16:45 +0100 Subject: [PATCH 199/892] Fix hsv setting for tapobulb (#573) This fixes changing the color for L530: * If color temp is set on the device, it overrides any hue/sat settings. We override it to zero which seems to work. * L530 does not allow None/null for brightness, so we avoid passing it on to the device. --- kasa/tapo/tapobulb.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py index e01c69b0a..7fc9ece9e 100644 --- a/kasa/tapo/tapobulb.py +++ b/kasa/tapo/tapobulb.py @@ -157,12 +157,19 @@ async def set_hsv( if value is not None: self._raise_for_invalid_brightness(value) + request_payload = { + "color_temp": 0, # If set, color_temp takes precedence over hue&sat + "hue": hue, + "saturation": saturation, + } + # The device errors on invalid brightness values. + if value is not None: + request_payload["brightness"] = value + return await self.protocol.query( { "set_device_info": { - "hue": hue, - "saturation": saturation, - "brightness": value, + **request_payload } } ) From 35a452168a94a63653e550182d3bdcc085ae9bea Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 8 Dec 2023 15:22:58 +0100 Subject: [PATCH 200/892] Log smartprotocol requests (#575) * Log smartprotocol requests Also, comment out encrypted secure_passthrough response for the time being * Fix linting --- kasa/aestransport.py | 2 +- kasa/smartprotocol.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index aefda4222..13263b5b1 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -122,7 +122,7 @@ async def send_secure_passthrough(self, request: str): "params": {"request": encrypted_payload.decode()}, } status_code, resp_dict = await self.client_post(url, json=passthrough_request) - _LOGGER.debug(f"secure_passthrough response is {status_code}: {resp_dict}") + # _LOGGER.debug(f"secure_passthrough response is {status_code}: {resp_dict}") if status_code == 200 and resp_dict["error_code"] == 0: response = self._encryption_session.decrypt( # type: ignore resp_dict["result"]["response"].encode() diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 98d1a86d8..0284015f3 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -124,6 +124,11 @@ async def _execute_query(self, request: Union[str, Dict], retry_count: int) -> D await self._transport.login(login_request) smart_request = self.get_smart_request(smart_method, smart_params) + _LOGGER.debug( + "%s >> %s", + self.host, + _LOGGER.isEnabledFor(logging.DEBUG) and pf(smart_request), + ) response_data = await self._transport.send(smart_request) _LOGGER.debug( From a77af5fb3bae23b624901d762e8ab3123a2a4bbe Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 10 Dec 2023 00:32:30 +0100 Subject: [PATCH 201/892] Request component_nego only once for tapodevice (#576) Optimizes the update cycle a bit, as it's doubtful the components change over time --- kasa/tapo/tapodevice.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 6c643a6af..291e17442 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -24,6 +24,7 @@ def __init__( timeout: Optional[int] = None, ) -> None: super().__init__(host, port=port, credentials=credentials, timeout=timeout) + self._components: Optional[Dict[str, Any]] = None self._state_information: Dict[str, Any] = {} self._discovery_info: Optional[Dict[str, Any]] = None self.protocol = SmartProtocol(host, credentials=credentials, timeout=timeout) @@ -33,7 +34,9 @@ async def update(self, update_children: bool = True): if self.credentials is None or self.credentials.username is None: raise AuthenticationException("Tapo plug requires authentication.") - self._components = await self.protocol.query("component_nego") + if self._components is None: + self._components = await self.protocol.query("component_nego") + self._info = await self.protocol.query("get_device_info") self._usage = await self.protocol.query("get_device_usage") self._time = await self.protocol.query("get_device_time") From 2e6c41d0399529d2921778b9a6b729dd5b65149e Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Sun, 10 Dec 2023 15:41:53 +0000 Subject: [PATCH 202/892] Improve smartprotocol error handling and retries (#578) * Improve smartprotocol error handling and retries * Update after review * Enum to IntEnum and SLEEP_SECONDS_AFTER_TIMEOUT --- kasa/aestransport.py | 99 +++++++++++++++++++++------------ kasa/exceptions.py | 85 ++++++++++++++++++++++++++++ kasa/klaptransport.py | 1 + kasa/smartprotocol.py | 61 +++++++++++++++++--- kasa/tapo/tapobulb.py | 8 +-- kasa/tests/newfakes.py | 4 +- kasa/tests/test_klapprotocol.py | 2 +- 7 files changed, 207 insertions(+), 53 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 13263b5b1..dc982b617 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -17,7 +17,16 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from .credentials import Credentials -from .exceptions import AuthenticationException, SmartDeviceException +from .exceptions import ( + SMART_AUTHENTICATION_ERRORS, + SMART_RETRYABLE_ERRORS, + SMART_TIMEOUT_ERRORS, + AuthenticationException, + RetryableException, + SmartDeviceException, + SmartErrorCode, + TimeoutException, +) from .json import dumps as json_dumps from .json import loads as json_loads from .protocol import BaseTransport @@ -110,6 +119,21 @@ async def client_post(self, url, params=None, data=None, json=None, headers=None return resp.status_code, response_data + def _handle_response_error_code(self, resp_dict: dict, msg: str): + if ( + error_code := SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] + ) != SmartErrorCode.SUCCESS: + msg = f"{msg}: {self.host}: {error_code.name}({error_code.value})" + if error_code in SMART_TIMEOUT_ERRORS: + raise TimeoutException(msg) + if error_code in SMART_RETRYABLE_ERRORS: + raise RetryableException(msg) + if error_code in SMART_AUTHENTICATION_ERRORS: + self._handshake_done = False + self._login_token = None + raise AuthenticationException(msg) + raise SmartDeviceException(msg) + async def send_secure_passthrough(self, request: str): """Send encrypted message as passthrough.""" url = f"http://{self.host}/app" @@ -123,17 +147,22 @@ async def send_secure_passthrough(self, request: str): } status_code, resp_dict = await self.client_post(url, json=passthrough_request) # _LOGGER.debug(f"secure_passthrough response is {status_code}: {resp_dict}") - if status_code == 200 and resp_dict["error_code"] == 0: - response = self._encryption_session.decrypt( # type: ignore - resp_dict["result"]["response"].encode() + + if status_code != 200: + raise SmartDeviceException( + f"{self.host} responded with an unexpected " + + f"status code {status_code} to passthrough" ) - _LOGGER.debug(f"decrypted secure_passthrough response is {response}") - resp_dict = json_loads(response) - return resp_dict - else: - self._handshake_done = False - self._login_token = None - raise AuthenticationException("Could not complete send") + + 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 async def perform_login(self, login_request: Union[str, dict], *, login_v2: bool): """Login to the device.""" @@ -207,29 +236,32 @@ async def perform_handshake(self): _LOGGER.debug(f"Device responded with: {resp_dict}") - if status_code == 200 and resp_dict["error_code"] == 0: - _LOGGER.debug("Decoding handshake key...") - handshake_key = resp_dict["result"]["key"] - - self._session_cookie = self._http_client.cookies.get( # type: ignore - self.SESSION_COOKIE_NAME + if status_code != 200: + raise SmartDeviceException( + f"{self.host} responded with an unexpected " + + f"status code {status_code} to handshake" ) - if not self._session_cookie: - self._session_cookie = self._http_client.cookies.get( # type: ignore - "SESSIONID" - ) - self._session_expire_at = time.time() + 86400 - self._encryption_session = AesEncyptionSession.create_from_keypair( - handshake_key, key_pair + self._handle_response_error_code(resp_dict, "Unable to complete handshake") + + handshake_key = resp_dict["result"]["key"] + + self._session_cookie = self._http_client.cookies.get( # type: ignore + self.SESSION_COOKIE_NAME + ) + if not self._session_cookie: + self._session_cookie = self._http_client.cookies.get( # type: ignore + "SESSIONID" ) - self._handshake_done = True + self._session_expire_at = time.time() + 86400 + self._encryption_session = AesEncyptionSession.create_from_keypair( + handshake_key, key_pair + ) - _LOGGER.debug("Handshake with %s complete", self.host) + self._handshake_done = True - else: - raise AuthenticationException("Could not complete handshake") + _LOGGER.debug("Handshake with %s complete", self.host) def _handshake_session_expired(self): """Return true if session has expired.""" @@ -247,19 +279,14 @@ async def send(self, request: str): if self.needs_login: raise SmartDeviceException("Login must be complete before trying to send") - resp_dict = await self.send_secure_passthrough(request) - if resp_dict["error_code"] != 0: - self._handshake_done = False - self._login_token = None - raise SmartDeviceException( - f"Could not complete send, response was {resp_dict}", - ) - return resp_dict + return await self.send_secure_passthrough(request) async def close(self) -> None: """Close the protocol.""" client = self._http_client self._http_client = None + self._handshake_done = False + self._login_token = None if client: await client.aclose() diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 35870d1f1..22b3c1ac5 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -1,4 +1,5 @@ """python-kasa exceptions.""" +from enum import IntEnum class SmartDeviceException(Exception): @@ -11,3 +12,87 @@ class UnsupportedDeviceException(SmartDeviceException): class AuthenticationException(SmartDeviceException): """Base exception for device authentication errors.""" + + +class RetryableException(SmartDeviceException): + """Retryable exception for device errors.""" + + +class TimeoutException(SmartDeviceException): + """Timeout exception for device errors.""" + + +class SmartErrorCode(IntEnum): + """Enum for SMART Error Codes.""" + + SUCCESS = 0 + + # Transport Errors + SESSION_TIMEOUT_ERROR = 9999 + MULTI_REQUEST_FAILED_ERROR = 1200 + HTTP_TRANSPORT_FAILED_ERROR = 1112 + LOGIN_FAILED_ERROR = 1111 + HAND_SHAKE_FAILED_ERROR = 1100 + TRANSPORT_NOT_AVAILABLE_ERROR = 1002 + CMD_COMMAND_CANCEL_ERROR = 1001 + NULL_TRANSPORT_ERROR = 1000 + + # Common Method Errors + COMMON_FAILED_ERROR = -1 + UNSPECIFIC_ERROR = -1001 + UNKNOWN_METHOD_ERROR = -1002 + JSON_DECODE_FAIL_ERROR = -1003 + JSON_ENCODE_FAIL_ERROR = -1004 + AES_DECODE_FAIL_ERROR = -1005 + REQUEST_LEN_ERROR_ERROR = -1006 + CLOUD_FAILED_ERROR = -1007 + PARAMS_ERROR = -1008 + INVALID_PUBLIC_KEY_ERROR = -1010 # Unverified + SESSION_PARAM_ERROR = -1101 + + # Method Specific Errors + QUICK_SETUP_ERROR = -1201 + DEVICE_ERROR = -1301 + DEVICE_NEXT_EVENT_ERROR = -1302 + FIRMWARE_ERROR = -1401 + FIRMWARE_VER_ERROR_ERROR = -1402 + LOGIN_ERROR = -1501 + TIME_ERROR = -1601 + TIME_SYS_ERROR = -1602 + TIME_SAVE_ERROR = -1603 + WIRELESS_ERROR = -1701 + WIRELESS_UNSUPPORTED_ERROR = -1702 + SCHEDULE_ERROR = -1801 + SCHEDULE_FULL_ERROR = -1802 + SCHEDULE_CONFLICT_ERROR = -1803 + SCHEDULE_SAVE_ERROR = -1804 + SCHEDULE_INDEX_ERROR = -1805 + COUNTDOWN_ERROR = -1901 + COUNTDOWN_CONFLICT_ERROR = -1902 + COUNTDOWN_SAVE_ERROR = -1903 + ANTITHEFT_ERROR = -2001 + ANTITHEFT_CONFLICT_ERROR = -2002 + ANTITHEFT_SAVE_ERROR = -2003 + ACCOUNT_ERROR = -2101 + STAT_ERROR = -2201 + STAT_SAVE_ERROR = -2202 + DST_ERROR = -2301 + DST_SAVE_ERROR = -2302 + + +SMART_RETRYABLE_ERRORS = [ + SmartErrorCode.TRANSPORT_NOT_AVAILABLE_ERROR, + SmartErrorCode.HTTP_TRANSPORT_FAILED_ERROR, + SmartErrorCode.UNSPECIFIC_ERROR, +] + +SMART_AUTHENTICATION_ERRORS = [ + SmartErrorCode.LOGIN_ERROR, + SmartErrorCode.LOGIN_FAILED_ERROR, + SmartErrorCode.AES_DECODE_FAIL_ERROR, + SmartErrorCode.HAND_SHAKE_FAILED_ERROR, +] + +SMART_TIMEOUT_ERRORS = [ + SmartErrorCode.SESSION_TIMEOUT_ERROR, +] diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 8784d5e99..d578ef847 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -377,6 +377,7 @@ async def close(self) -> None: """Close the transport.""" client = self._http_client self._http_client = None + self._handshake_done = False if client: await client.aclose() diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 0284015f3..eb661317d 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -16,7 +16,16 @@ from .aestransport import AesTransport from .credentials import Credentials -from .exceptions import AuthenticationException, SmartDeviceException +from .exceptions import ( + SMART_AUTHENTICATION_ERRORS, + SMART_RETRYABLE_ERRORS, + SMART_TIMEOUT_ERRORS, + AuthenticationException, + RetryableException, + SmartDeviceException, + SmartErrorCode, + TimeoutException, +) from .json import dumps as json_dumps from .protocol import BaseTransport, TPLinkProtocol, md5 @@ -28,6 +37,7 @@ class SmartProtocol(TPLinkProtocol): """Class for the new TPLink SMART protocol.""" DEFAULT_PORT = 80 + SLEEP_SECONDS_AFTER_TIMEOUT = 1 def __init__( self, @@ -64,6 +74,22 @@ async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: """Query the device retrying for retry_count on failure.""" async with self._query_lock: resp_dict = await self._query(request, retry_count) + + if ( + error_code := SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] + ) != SmartErrorCode.SUCCESS: + msg = ( + f"Error querying device: {self.host}: " + + f"{error_code.name}({error_code.value})" + ) + if error_code in SMART_TIMEOUT_ERRORS: + raise TimeoutException(msg) + if error_code in SMART_RETRYABLE_ERRORS: + raise RetryableException(msg) + if error_code in SMART_AUTHENTICATION_ERRORS: + raise AuthenticationException(msg) + raise SmartDeviceException(msg) + if "result" in resp_dict: return resp_dict["result"] return {} @@ -86,20 +112,41 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: f"Unable to connect to the device: {self.host}: {cex}" ) from cex except TimeoutError as tex: - await self.close() - raise SmartDeviceException( - f"Unable to connect to the device, timed out: {self.host}: {tex}" - ) from tex + if retry >= retry_count: + await self.close() + raise SmartDeviceException( + "Unable to connect to the device, " + + f"timed out: {self.host}: {tex}" + ) from tex + await asyncio.sleep(self.SLEEP_SECONDS_AFTER_TIMEOUT) + continue except AuthenticationException as auex: + await self.close() _LOGGER.debug("Unable to authenticate with %s, not retrying", self.host) raise auex + except RetryableException as ex: + if retry >= retry_count: + await self.close() + _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + raise ex + continue + except TimeoutException as ex: + if retry >= retry_count: + await self.close() + _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + raise ex + await asyncio.sleep(self.SLEEP_SECONDS_AFTER_TIMEOUT) + continue except Exception as ex: - await self.close() if retry >= retry_count: + await self.close() _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) raise SmartDeviceException( - f"Unable to connect to the device: {self.host}: {ex}" + f"Unable to query the device {self.host}:{self.port}: {ex}" ) from ex + _LOGGER.debug( + "Unable to query the device %s, retrying: %s", self.host, ex + ) continue # make mypy happy, this should never be reached.. diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py index 7fc9ece9e..3640074f4 100644 --- a/kasa/tapo/tapobulb.py +++ b/kasa/tapo/tapobulb.py @@ -166,13 +166,7 @@ async def set_hsv( if value is not None: request_payload["brightness"] = value - return await self.protocol.query( - { - "set_device_info": { - **request_payload - } - } - ) + return await self.protocol.query({"set_device_info": {**request_payload}}) async def set_color_temp( self, temp: int, *, brightness=None, transition: Optional[int] = None diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 76faae339..05064c111 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -315,11 +315,11 @@ async def send(self, request: str): method = request_dict["method"] params = request_dict["params"] if method == "component_nego" or method[:4] == "get_": - return {"result": self.info[method]} + return {"result": self.info[method], "error_code": 0} elif method[:4] == "set_": target_method = f"get_{method[4:]}" self.info[target_method].update(params) - return {"result": ""} + return {"error_code": 0} async def close(self) -> None: pass diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 0b884d16d..8ad46b6ea 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -86,7 +86,7 @@ async def test_protocol_retry_recoverable_error( async def test_protocol_reconnect(mocker, retry_count, protocol_class, transport_class): host = "127.0.0.1" remaining = retry_count - mock_response = {"result": {"great": "success"}} + mock_response = {"result": {"great": "success"}, "error_code": 0} def _fail_one_less_than_retry_count(*_, **__): nonlocal remaining From 209391c42212372f78d1c98afa47506bf148800e Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Tue, 19 Dec 2023 12:50:33 +0000 Subject: [PATCH 203/892] Improve CLI Discovery output (#583) - Show discovery results for unsupported devices and devices that fail to authenticate. - Rename `--show-unsupported` to `--verbose`. - Remove separate `--timeout` parameter from cli discovery so it's not confused with `--timeout` now added to cli command. - Add tests. --- kasa/cli.py | 102 ++++++++++++++++++++-------- kasa/discover.py | 22 +++--- kasa/exceptions.py | 4 ++ kasa/tests/conftest.py | 90 +++++++++++++++++++++---- kasa/tests/newfakes.py | 7 ++ kasa/tests/test_cli.py | 126 +++++++++++++++++++++++++++++++++-- kasa/tests/test_discovery.py | 2 +- 7 files changed, 298 insertions(+), 55 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 3557bf4ed..600494df1 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -18,8 +18,15 @@ SmartBulb, SmartDevice, SmartStrip, + UnsupportedDeviceException, ) from kasa.device_factory import DEVICE_TYPE_TO_CLASS +from kasa.discover import DiscoveryResult + +try: + from pydantic.v1 import ValidationError +except ImportError: + from pydantic import ValidationError try: from rich import print as _do_echo @@ -241,7 +248,7 @@ def _nop_echo(*args, **kwargs): if host is None: echo("No host name given, trying discovery..") - return await ctx.invoke(discover, timeout=discovery_timeout) + return await ctx.invoke(discover) if type is not None: device_type = DeviceType.from_value(type) @@ -300,21 +307,21 @@ async def join(dev: SmartDevice, ssid, password, keytype): @cli.command() -@click.option("--timeout", default=3, required=False) @click.option( - "--show-unsupported", - envvar="KASA_SHOW_UNSUPPORTED", + "--verbose", + envvar="KASA_VERBOSE", required=False, default=False, is_flag=True, - help="Print out discovered unsupported devices", + help="Be more verbose on output", ) @click.pass_context -async def discover(ctx, timeout, show_unsupported): +async def discover(ctx, verbose): """Discover devices in the network.""" target = ctx.parent.params["target"] username = ctx.parent.params["username"] password = ctx.parent.params["password"] + timeout = ctx.parent.params["discovery_timeout"] credentials = Credentials(username, password) @@ -323,24 +330,37 @@ async def discover(ctx, timeout, show_unsupported): unsupported = [] auth_failed = [] - async def print_unsupported(data: str): - unsupported.append(data) - if show_unsupported: - echo(f"Found unsupported device (tapo/unknown encryption): {data}") - echo() + async def print_unsupported(unsupported_exception: UnsupportedDeviceException): + unsupported.append(unsupported_exception) + async with sem: + if unsupported_exception.discovery_result: + echo("== Unsupported device ==") + _echo_discovery_info(unsupported_exception.discovery_result) + echo() + else: + echo("== Unsupported device ==") + echo(f"\t{unsupported_exception}") + echo() echo(f"Discovering devices on {target} for {timeout} seconds") async def print_discovered(dev: SmartDevice): - try: - await dev.update() - async with sem: + async with sem: + try: + await dev.update() + except AuthenticationException: + auth_failed.append(dev._discovery_info) + echo("== Authentication failed for device ==") + _echo_discovery_info(dev._discovery_info) + echo() + else: discovered[dev.host] = dev.internal_state ctx.obj = dev await ctx.invoke(state) - echo() - except AuthenticationException as aex: - auth_failed.append(str(aex)) + if verbose: + echo() + _echo_discovery_info(dev._discovery_info) + echo() await Discover.discover( target=target, @@ -352,22 +372,50 @@ async def print_discovered(dev: SmartDevice): echo(f"Found {len(discovered)} devices") if unsupported: - echo( - f"Found {len(unsupported)} unsupported devices" - + ( - "" - if show_unsupported - else ", to show them use: kasa discover --show-unsupported" - ) - ) + echo(f"Found {len(unsupported)} unsupported devices") if auth_failed: echo(f"Found {len(auth_failed)} devices that failed to authenticate") - for fail in auth_failed: - echo(fail) return discovered +def _echo_dictionary(discovery_info: dict): + echo("\t[bold]== Discovery information ==[/bold]") + for key, value in discovery_info.items(): + key_name = " ".join(x.capitalize() or "_" for x in key.split("_")) + key_name_and_spaces = "{:<15}".format(key_name + ":") + echo(f"\t{key_name_and_spaces}{value}") + + +def _echo_discovery_info(discovery_info): + if "system" in discovery_info and "get_sysinfo" in discovery_info["system"]: + _echo_dictionary(discovery_info["system"]["get_sysinfo"]) + return + + try: + dr = DiscoveryResult(**discovery_info) + except ValidationError: + _echo_dictionary(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}") + + async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3): """Discover a device identified by its alias.""" for _attempt in range(1, attempts): diff --git a/kasa/discover.py b/kasa/discover.py index 2038369b4..4ec3775e9 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -50,7 +50,9 @@ def __init__( target: str = "255.255.255.255", discovery_packets: int = 3, interface: Optional[str] = None, - on_unsupported: Optional[Callable[[str], Awaitable[None]]] = None, + on_unsupported: Optional[ + Callable[[UnsupportedDeviceException], Awaitable[None]] + ] = None, port: Optional[int] = None, discovered_event: Optional[asyncio.Event] = None, credentials: Optional[Credentials] = None, @@ -64,7 +66,7 @@ def __init__( self.target = (target, self.discovery_port) self.target_2 = (target, Discover.DISCOVERY_PORT_2) self.discovered_devices = {} - self.unsupported_devices: Dict = {} + self.unsupported_device_exceptions: Dict = {} self.invalid_device_exceptions: Dict = {} self.on_unsupported = on_unsupported self.discovered_event = discovered_event @@ -119,9 +121,9 @@ def datagram_received(self, data, addr) -> None: return except UnsupportedDeviceException as udex: _LOGGER.debug("Unsupported device found at %s << %s", ip, udex) - self.unsupported_devices[ip] = str(udex) + self.unsupported_device_exceptions[ip] = udex if self.on_unsupported is not None: - asyncio.ensure_future(self.on_unsupported(str(udex))) + asyncio.ensure_future(self.on_unsupported(udex)) if self.discovered_event is not None: self.discovered_event.set() return @@ -336,10 +338,8 @@ async def discover_single( if update_parent_devices and dev.has_children: await dev.update() return dev - elif ip in protocol.unsupported_devices: - raise UnsupportedDeviceException( - f"Unsupported device {host}: {protocol.unsupported_devices[ip]}" - ) + elif ip in protocol.unsupported_device_exceptions: + raise protocol.unsupported_device_exceptions[ip] elif ip in protocol.invalid_device_exceptions: raise protocol.invalid_device_exceptions[ip] else: @@ -397,7 +397,8 @@ def _get_device_instance( if (device_class := get_device_class_from_type_name(type_)) is None: _LOGGER.warning("Got unsupported device type: %s", type_) raise UnsupportedDeviceException( - f"Unsupported device {ip} of type {type_}: {info}" + f"Unsupported device {ip} of type {type_}: {info}", + discovery_result=discovery_result.get_dict(), ) if ( protocol := get_protocol_from_connection_name( @@ -406,7 +407,8 @@ def _get_device_instance( ) is None: _LOGGER.warning("Got unsupported device type: %s", encrypt_type_) raise UnsupportedDeviceException( - f"Unsupported encryption scheme {ip} of type {encrypt_type_}: {info}" + f"Unsupported encryption scheme {ip} of type {encrypt_type_}: {info}", + discovery_result=discovery_result.get_dict(), ) _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 22b3c1ac5..e83c92375 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -9,6 +9,10 @@ class SmartDeviceException(Exception): class UnsupportedDeviceException(SmartDeviceException): """Exception for trying to connect to unsupported devices.""" + def __init__(self, *args, discovery_result=None): + self.discovery_result = discovery_result + super().__init__(args) + class AuthenticationException(SmartDeviceException): """Base exception for device authentication errors.""" diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index f84083e2a..095971de8 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -129,6 +129,37 @@ IP_MODEL_CACHE: Dict[str, str] = {} +def _make_unsupported(device_family, encrypt_type): + return { + "result": { + "device_id": "xx", + "owner": "xx", + "device_type": device_family, + "device_model": "P110(EU)", + "ip": "127.0.0.1", + "mac": "48-22xxx", + "is_support_iot_cloud": True, + "obd_src": "tplink", + "factory_default": False, + "mgt_encrypt_schm": { + "is_support_https": False, + "encrypt_type": encrypt_type, + "http_port": 80, + "lv": 2, + }, + }, + "error_code": 0, + } + + +UNSUPPORTED_DEVICES = { + "unknown_device_family": _make_unsupported("SMART.TAPOXMASTREE", "AES"), + "wrong_encryption_iot": _make_unsupported("IOT.SMARTPLUGSWITCH", "AES"), + "wrong_encryption_smart": _make_unsupported("SMART.TAPOBULB", "IOT"), + "unknown_encryption": _make_unsupported("IOT.SMARTPLUGSWITCH", "FOO"), +} + + def idgenerator(paramtuple): try: return basename(paramtuple[0]) + ( @@ -242,7 +273,7 @@ def filter_fixtures(desc, root_filter): def parametrize_discovery(desc, root_key): filtered_fixtures = filter_fixtures(desc, root_key) return pytest.mark.parametrize( - "discovery_data", + "all_fixture_data", filtered_fixtures.values(), indirect=True, ids=filtered_fixtures.keys(), @@ -360,7 +391,7 @@ def load_file(): return d -@pytest.fixture(params=SUPPORTED_DEVICES) +@pytest.fixture(params=SUPPORTED_DEVICES, ids=idgenerator) async def dev(request): """Device fixture. @@ -386,23 +417,27 @@ async def dev(request): @pytest.fixture -def discovery_mock(discovery_data, mocker): +def discovery_mock(all_fixture_data, mocker): @dataclass class _DiscoveryMock: ip: str default_port: int discovery_data: dict + query_data: dict port_override: Optional[int] = None - if "result" in discovery_data: + if "discovery_result" in all_fixture_data: + discovery_data = {"result": all_fixture_data["discovery_result"]} datagram = ( b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + json_dumps(discovery_data).encode() ) - dm = _DiscoveryMock("127.0.0.123", 20002, discovery_data) + dm = _DiscoveryMock("127.0.0.123", 20002, discovery_data, all_fixture_data) else: + sys_info = all_fixture_data["system"]["get_sysinfo"] + discovery_data = {"system": {"get_sysinfo": sys_info}} datagram = TPLinkSmartHomeProtocol.encrypt(json_dumps(discovery_data))[4:] - dm = _DiscoveryMock("127.0.0.123", 9999, discovery_data) + dm = _DiscoveryMock("127.0.0.123", 9999, discovery_data, all_fixture_data) def mock_discover(self): port = ( @@ -420,17 +455,29 @@ def mock_discover(self): "socket.getaddrinfo", side_effect=lambda *_, **__: [(None, None, None, None, (dm.ip, 0))], ) + + if "component_nego" in dm.query_data: + proto = FakeSmartProtocol(dm.query_data) + else: + proto = FakeTransportProtocol(dm.query_data) + + async def _query(request, retry_count: int = 3): + return await proto.query(request) + + mocker.patch("kasa.IotProtocol.query", side_effect=_query) + mocker.patch("kasa.SmartProtocol.query", side_effect=_query) + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=_query) + yield dm -@pytest.fixture(params=FIXTURE_DATA.values(), ids=FIXTURE_DATA.keys(), scope="session") -def discovery_data(request): +@pytest.fixture +def discovery_data(all_fixture_data): """Return raw discovery file contents as JSON. Used for discovery tests.""" - fixture_data = request.param - if "discovery_result" in fixture_data: - return {"result": fixture_data["discovery_result"]} + if "discovery_result" in all_fixture_data: + return {"result": all_fixture_data["discovery_result"]} else: - return {"system": {"get_sysinfo": fixture_data["system"]["get_sysinfo"]}} + return {"system": {"get_sysinfo": all_fixture_data["system"]["get_sysinfo"]}} @pytest.fixture(params=FIXTURE_DATA.values(), ids=FIXTURE_DATA.keys(), scope="session") @@ -440,6 +487,25 @@ def all_fixture_data(request): return fixture_data +@pytest.fixture(params=UNSUPPORTED_DEVICES.values(), ids=UNSUPPORTED_DEVICES.keys()) +def unsupported_device_info(request, mocker): + """Return unsupported devices for cli and discovery tests.""" + discovery_data = request.param + host = "127.0.0.1" + + def mock_discover(self): + if discovery_data: + data = ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(discovery_data).encode() + ) + self.datagram_received(data, (host, 20002)) + + mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) + + yield discovery_data + + def pytest_addoption(parser): parser.addoption( "--ip", action="store", default=None, help="run against device on given ip" diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 05064c111..284f4e2b7 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -291,6 +291,13 @@ class FakeSmartProtocol(SmartProtocol): def __init__(self, info): super().__init__("127.0.0.123", transport=FakeSmartTransport(info)) + async def query(self, request, retry_count: int = 3): + """Implement query here so can still patch SmartProtocol.query.""" + resp_dict = await self._query(request, retry_count) + if "result" in resp_dict: + return resp_dict["result"] + return {} + class FakeSmartTransport(BaseTransport): def __init__(self, info): diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 55e3977af..5add2b582 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -4,14 +4,12 @@ import pytest from asyncclick.testing import CliRunner -from kasa import SmartDevice, TPLinkSmartHomeProtocol +from kasa import AuthenticationException, SmartDevice, UnsupportedDeviceException from kasa.cli import alias, brightness, cli, emeter, raw_command, state, sysinfo, toggle from kasa.device_factory import DEVICE_TYPE_TO_CLASS from kasa.discover import Discover -from kasa.smartprotocol import SmartProtocol from .conftest import device_iot, handle_turn_on, new_discovery, turn_on -from .newfakes import FakeSmartProtocol, FakeTransportProtocol @device_iot @@ -22,7 +20,6 @@ async def test_sysinfo(dev): assert dev.alias in res.output -@device_iot @turn_on async def test_state(dev, turn_on): await handle_turn_on(dev, turn_on) @@ -36,7 +33,6 @@ async def test_state(dev, turn_on): assert "Device state: False" in res.output -@device_iot @turn_on async def test_toggle(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) @@ -226,3 +222,123 @@ async def test_duplicate_target_device(): ) assert res.exit_code == 2 assert "Error: Use either --alias or --host, not both." in res.output + + +async def test_discover(discovery_mock, mocker): + """Test discovery output.""" + runner = CliRunner() + res = await runner.invoke( + cli, + [ + "--discovery-timeout", + 0, + "--username", + "foo", + "--password", + "bar", + "discover", + "--verbose", + ], + ) + assert res.exit_code == 0 + + +async def test_discover_unsupported(unsupported_device_info): + """Test discovery output.""" + runner = CliRunner() + res = await runner.invoke( + cli, + [ + "--discovery-timeout", + 0, + "--username", + "foo", + "--password", + "bar", + "discover", + "--verbose", + ], + ) + assert res.exit_code == 0 + assert "== Unsupported device ==" in res.output + assert "== Discovery Result ==" in res.output + + +async def test_host_unsupported(unsupported_device_info): + """Test discovery output.""" + runner = CliRunner() + host = "127.0.0.1" + + res = await runner.invoke( + cli, + [ + "--host", + host, + "--username", + "foo", + "--password", + "bar", + ], + ) + + assert res.exit_code != 0 + assert isinstance(res.exception, UnsupportedDeviceException) + + +@new_discovery +async def test_discover_auth_failed(discovery_mock, mocker): + """Test discovery output.""" + runner = CliRunner() + host = "127.0.0.1" + discovery_mock.ip = host + device_class = Discover._get_device_class(discovery_mock.discovery_data) + mocker.patch.object( + device_class, + "update", + side_effect=AuthenticationException("Failed to authenticate"), + ) + res = await runner.invoke( + cli, + [ + "--discovery-timeout", + 0, + "--username", + "foo", + "--password", + "bar", + "discover", + "--verbose", + ], + ) + + assert res.exit_code == 0 + assert "== Authentication failed for device ==" in res.output + assert "== Discovery Result ==" in res.output + + +@new_discovery +async def test_host_auth_failed(discovery_mock, mocker): + """Test discovery output.""" + runner = CliRunner() + host = "127.0.0.1" + discovery_mock.ip = host + device_class = Discover._get_device_class(discovery_mock.discovery_data) + mocker.patch.object( + device_class, + "update", + side_effect=AuthenticationException("Failed to authenticate"), + ) + res = await runner.invoke( + cli, + [ + "--host", + host, + "--username", + "foo", + "--password", + "bar", + ], + ) + + assert res.exit_code != 0 + assert isinstance(res.exception, AuthenticationException) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 72555c7ea..18798ab90 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -202,7 +202,7 @@ async def test_discover_datagram_received(mocker, discovery_data): # Check that device in discovered_devices is initialized correctly assert len(proto.discovered_devices) == 1 # Check that unsupported device is 1 - assert len(proto.unsupported_devices) == 1 + assert len(proto.unsupported_device_exceptions) == 1 dev = proto.discovered_devices[addr] assert issubclass(dev.__class__, SmartDevice) assert dev.host == addr From 20ea6700a51c8476bb68b56d33558921b1d45890 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Tue, 19 Dec 2023 14:11:59 +0000 Subject: [PATCH 204/892] Do login entirely within AesTransport (#580) * Do login entirely within AesTransport * Remove login and handshake attributes from BaseTransport * Add AesTransport tests * Synchronise transport and protocol __init__ signatures and rename internal variables * Update after review --- kasa/aestransport.py | 81 ++++++--------- kasa/device_factory.py | 19 +++- kasa/iotprotocol.py | 44 +++----- kasa/klaptransport.py | 76 +++++--------- kasa/protocol.py | 108 +++++++++++--------- kasa/smartdevice.py | 4 +- kasa/smartprotocol.py | 59 ++++------- kasa/tapo/tapodevice.py | 8 +- kasa/tests/newfakes.py | 3 + kasa/tests/test_aestransport.py | 174 ++++++++++++++++++++++++++++++++ kasa/tests/test_klapprotocol.py | 29 ++++-- kasa/tests/test_protocol.py | 98 ++++++++++++++++-- kasa/tests/test_smartdevice.py | 2 +- 13 files changed, 468 insertions(+), 237 deletions(-) create mode 100644 kasa/tests/test_aestransport.py diff --git a/kasa/aestransport.py b/kasa/aestransport.py index dc982b617..9db0db4f3 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -8,7 +8,7 @@ import hashlib import logging import time -from typing import Optional, Union +from typing import Optional import httpx from cryptography.hazmat.primitives import padding, serialization @@ -47,6 +47,7 @@ class AesTransport(BaseTransport): protocol, sometimes used by newer firmware versions on kasa devices. """ + DEFAULT_PORT = 80 DEFAULT_TIMEOUT = 5 SESSION_COOKIE_NAME = "TP_SESSIONID" COMMON_HEADERS = { @@ -59,12 +60,16 @@ def __init__( self, host: str, *, + port: Optional[int] = None, credentials: Optional[Credentials] = None, timeout: Optional[int] = None, ) -> None: - super().__init__(host=host) - - self._credentials = credentials or Credentials(username="", password="") + super().__init__( + host, + port=port or self.DEFAULT_PORT, + credentials=credentials, + timeout=timeout, + ) self._handshake_done = False @@ -77,7 +82,7 @@ def __init__( self._http_client: httpx.AsyncClient = httpx.AsyncClient() self._login_token = None - _LOGGER.debug("Created AES object for %s", self.host) + _LOGGER.debug("Created AES transport for %s", self._host) def hash_credentials(self, login_v2): """Hash the credentials.""" @@ -123,7 +128,7 @@ def _handle_response_error_code(self, resp_dict: dict, msg: str): if ( error_code := SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] ) != SmartErrorCode.SUCCESS: - msg = f"{msg}: {self.host}: {error_code.name}({error_code.value})" + msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})" if error_code in SMART_TIMEOUT_ERRORS: raise TimeoutException(msg) if error_code in SMART_RETRYABLE_ERRORS: @@ -136,7 +141,7 @@ def _handle_response_error_code(self, resp_dict: dict, msg: str): async def send_secure_passthrough(self, request: str): """Send encrypted message as passthrough.""" - url = f"http://{self.host}/app" + url = f"http://{self._host}/app" if self._login_token: url += f"?token={self._login_token}" @@ -150,7 +155,7 @@ async def send_secure_passthrough(self, request: str): if status_code != 200: raise SmartDeviceException( - f"{self.host} responded with an unexpected " + f"{self._host} responded with an unexpected " + f"status code {status_code} to passthrough" ) @@ -164,49 +169,31 @@ async def send_secure_passthrough(self, request: str): resp_dict = json_loads(response) return resp_dict - async def perform_login(self, login_request: Union[str, dict], *, login_v2: bool): + async def _perform_login_for_version(self, *, login_version: int = 1): """Login to the device.""" self._login_token = None - - if isinstance(login_request, str): - login_request_dict: dict = json_loads(login_request) - else: - login_request_dict = login_request - - un, pw = self.hash_credentials(login_v2) - login_request_dict["params"] = {"password": pw, "username": un} - request = json_dumps(login_request_dict) + un, pw = self.hash_credentials(login_version == 2) + password_field_name = "password2" if login_version == 2 else "password" + login_request = { + "method": "login_device", + "params": {password_field_name: pw, "username": un}, + "request_time_milis": round(time.time() * 1000), + } + request = json_dumps(login_request) try: resp_dict = await self.send_secure_passthrough(request) except SmartDeviceException as ex: raise AuthenticationException(ex) from ex self._login_token = resp_dict["result"]["token"] - @property - def needs_login(self) -> bool: - """Return true if the transport needs to do a login.""" - return self._login_token is None - - async def login(self, request: str) -> None: + async def perform_login(self) -> None: """Login to the device.""" try: - if self.needs_handshake: - raise SmartDeviceException( - "Handshake must be complete before trying to login" - ) - await self.perform_login(request, login_v2=False) + await self._perform_login_for_version(login_version=2) except AuthenticationException: + _LOGGER.warning("Login version 2 failed, trying version 1") await self.perform_handshake() - await self.perform_login(request, login_v2=True) - - @property - def needs_handshake(self) -> bool: - """Return true if the transport needs to do a handshake.""" - return not self._handshake_done or self._handshake_session_expired() - - async def handshake(self) -> None: - """Perform the encryption handshake.""" - await self.perform_handshake() + await self._perform_login_for_version(login_version=1) async def perform_handshake(self): """Perform the handshake.""" @@ -217,7 +204,7 @@ async def perform_handshake(self): self._session_expire_at = None self._session_cookie = None - url = f"http://{self.host}/app" + url = f"http://{self._host}/app" key_pair = KeyPair.create_key_pair() pub_key = ( @@ -238,7 +225,7 @@ async def perform_handshake(self): if status_code != 200: raise SmartDeviceException( - f"{self.host} responded with an unexpected " + f"{self._host} responded with an unexpected " + f"status code {status_code} to handshake" ) @@ -261,7 +248,7 @@ async def perform_handshake(self): self._handshake_done = True - _LOGGER.debug("Handshake with %s complete", self.host) + _LOGGER.debug("Handshake with %s complete", self._host) def _handshake_session_expired(self): """Return true if session has expired.""" @@ -272,12 +259,10 @@ def _handshake_session_expired(self): async def send(self, request: str): """Send the request.""" - if self.needs_handshake: - raise SmartDeviceException( - "Handshake must be complete before trying to send" - ) - if self.needs_login: - raise SmartDeviceException("Login must be complete before trying to send") + if not self._handshake_done or self._handshake_session_expired(): + await self.perform_handshake() + if not self._login_token: + await self.perform_login() return await self.send_secure_passthrough(request) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 15896e06b..d8a07beee 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -74,7 +74,12 @@ async def connect( host=host, port=port, credentials=credentials, timeout=timeout ) if protocol_class is not None: - dev.protocol = protocol_class(host, credentials=credentials) + dev.protocol = protocol_class( + host, + transport=AesTransport( + host, port=port, credentials=credentials, timeout=timeout + ), + ) await dev.update() if debug_enabled: end_time = time.perf_counter() @@ -90,7 +95,13 @@ async def connect( host=host, port=port, credentials=credentials, timeout=timeout ) if protocol_class is not None: - unknown_dev.protocol = protocol_class(host, credentials=credentials) + # TODO this will be replaced with connection params + unknown_dev.protocol = protocol_class( + host, + transport=AesTransport( + host, port=port, credentials=credentials, timeout=timeout + ), + ) await unknown_dev.update() device_class = get_device_class_from_sys_info(unknown_dev.internal_state) dev = device_class(host=host, port=port, credentials=credentials, timeout=timeout) @@ -163,7 +174,5 @@ def get_protocol_from_connection_name( protocol_class, transport_class = supported_device_protocols.get(connection_name) # type: ignore transport: BaseTransport = transport_class(host, credentials=credentials) - protocol: TPLinkProtocol = protocol_class( - host, credentials=credentials, transport=transport - ) + protocol: TPLinkProtocol = protocol_class(host, transport=transport) return protocol diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index 2b7f422db..d942d0609 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -1,14 +1,12 @@ """Module for the IOT legacy IOT KASA protocol.""" import asyncio import logging -from typing import Dict, Optional, Union +from typing import Dict, Union import httpx -from .credentials import Credentials from .exceptions import AuthenticationException, SmartDeviceException from .json import dumps as json_dumps -from .klaptransport import KlapTransport from .protocol import BaseTransport, TPLinkProtocol _LOGGER = logging.getLogger(__name__) @@ -17,24 +15,14 @@ class IotProtocol(TPLinkProtocol): """Class for the legacy TPLink IOT KASA Protocol.""" - DEFAULT_PORT = 80 - def __init__( self, host: str, *, - transport: Optional[BaseTransport] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, + transport: BaseTransport, ) -> None: - super().__init__(host=host, port=self.DEFAULT_PORT) - - self._credentials: Credentials = credentials or Credentials( - username="", password="" - ) - self._transport: BaseTransport = transport or KlapTransport( - host, credentials=self._credentials, timeout=timeout - ) + """Create a protocol object.""" + super().__init__(host, transport=transport) self._query_lock = asyncio.Lock() @@ -54,30 +42,32 @@ async def _query(self, request: str, retry_count: int = 3) -> Dict: except httpx.CloseError as sdex: await self.close() if retry >= retry_count: - _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise SmartDeviceException( - f"Unable to connect to the device: {self.host}: {sdex}" + f"Unable to connect to the device: {self._host}: {sdex}" ) from sdex continue except httpx.ConnectError as cex: await self.close() raise SmartDeviceException( - f"Unable to connect to the device: {self.host}: {cex}" + f"Unable to connect to the device: {self._host}: {cex}" ) from cex except TimeoutError as tex: await self.close() raise SmartDeviceException( - f"Unable to connect to the device, timed out: {self.host}: {tex}" + f"Unable to connect to the device, timed out: {self._host}: {tex}" ) from tex except AuthenticationException as auex: - _LOGGER.debug("Unable to authenticate with %s, not retrying", self.host) + _LOGGER.debug( + "Unable to authenticate with %s, not retrying", self._host + ) raise auex except Exception as ex: await self.close() if retry >= retry_count: - _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise SmartDeviceException( - f"Unable to connect to the device: {self.host}: {ex}" + f"Unable to connect to the device: {self._host}: {ex}" ) from ex continue @@ -85,14 +75,6 @@ async def _query(self, request: str, retry_count: int = 3) -> Dict: raise SmartDeviceException("Query reached somehow to unreachable") async def _execute_query(self, request: str, retry_count: int) -> Dict: - if self._transport.needs_handshake: - await self._transport.handshake() - - if self._transport.needs_login: # This shouln't happen - raise SmartDeviceException( - "IOT Protocol needs to login to transport but is not login aware" - ) - return await self._transport.send(request) async def close(self) -> None: diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index d578ef847..e7bb8ae6c 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -82,7 +82,7 @@ class KlapTransport(BaseTransport): protocol, used by newer firmware versions. """ - DEFAULT_TIMEOUT = 5 + DEFAULT_PORT = 80 DISCOVERY_QUERY = {"system": {"get_sysinfo": None}} KASA_SETUP_EMAIL = "kasa@tp-link.net" KASA_SETUP_PASSWORD = "kasaSetup" # noqa: S105 @@ -92,12 +92,17 @@ def __init__( self, host: str, *, + port: Optional[int] = None, credentials: Optional[Credentials] = None, timeout: Optional[int] = None, ) -> None: - super().__init__(host=host) + super().__init__( + host, + port=port or self.DEFAULT_PORT, + credentials=credentials, + timeout=timeout, + ) - self._credentials = credentials or Credentials(username="", password="") self._local_seed: Optional[bytes] = None self._local_auth_hash = self.generate_auth_hash(self._credentials) self._local_auth_owner = self.generate_owner_hash(self._credentials).hex() @@ -110,11 +115,10 @@ def __init__( self._encryption_session: Optional[KlapEncryptionSession] = None self._session_expire_at: Optional[float] = None - self._timeout = timeout if timeout else self.DEFAULT_TIMEOUT self._session_cookie = None self._http_client: httpx.AsyncClient = httpx.AsyncClient() - _LOGGER.debug("Created KLAP object for %s", self.host) + _LOGGER.debug("Created KLAP transport for %s", self._host) async def client_post(self, url, params=None, data=None): """Send an http post request to the device.""" @@ -148,7 +152,7 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: payload = local_seed - url = f"http://{self.host}/app/handshake1" + url = f"http://{self._host}/app/handshake1" response_status, response_data = await self.client_post(url, data=payload) @@ -157,14 +161,14 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: "Handshake1 posted at %s. Host is %s, Response" + "status is %s, Request was %s", datetime.datetime.now(), - self.host, + self._host, response_status, payload.hex(), ) if response_status != 200: raise AuthenticationException( - f"Device {self.host} responded with {response_status} to handshake1" + f"Device {self._host} responded with {response_status} to handshake1" ) remote_seed: bytes = response_data[0:16] @@ -175,7 +179,7 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: "Handshake1 success at %s. Host is %s, " + "Server remote_seed is: %s, server hash is: %s", datetime.datetime.now(), - self.host, + self._host, remote_seed.hex(), server_hash.hex(), ) @@ -207,7 +211,7 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: _LOGGER.debug( "Server response doesn't match our expected hash on ip %s" + " but an authentication with kasa setup credentials matched", - self.host, + self._host, ) return local_seed, remote_seed, self._kasa_setup_auth_hash # type: ignore @@ -226,11 +230,11 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: _LOGGER.debug( "Server response doesn't match our expected hash on ip %s" + " but an authentication with blank credentials matched", - self.host, + self._host, ) return local_seed, remote_seed, self._blank_auth_hash # type: ignore - msg = f"Server response doesn't match our challenge on ip {self.host}" + msg = f"Server response doesn't match our challenge on ip {self._host}" _LOGGER.debug(msg) raise AuthenticationException(msg) @@ -241,7 +245,7 @@ async def perform_handshake2( # Handshake 2 has the following payload: # sha256(serverBytes | authenticator) - url = f"http://{self.host}/app/handshake2" + url = f"http://{self._host}/app/handshake2" payload = self.handshake2_seed_auth_hash(local_seed, remote_seed, auth_hash) @@ -252,44 +256,24 @@ async def perform_handshake2( "Handshake2 posted %s. Host is %s, Response status is %s, " + "Request was %s", datetime.datetime.now(), - self.host, + self._host, response_status, payload.hex(), ) if response_status != 200: raise AuthenticationException( - f"Device {self.host} responded with {response_status} to handshake2" + f"Device {self._host} responded with {response_status} to handshake2" ) return KlapEncryptionSession(local_seed, remote_seed, auth_hash) - @property - def needs_login(self) -> bool: - """Will return false as KLAP does not do a login.""" - return False - - async def login(self, request: str) -> None: - """Will raise and exception as KLAP does not do a login.""" - raise SmartDeviceException( - "KLAP does not perform logins and return needs_login == False" - ) - - @property - def needs_handshake(self) -> bool: - """Return true if the transport needs to do a handshake.""" - return not self._handshake_done or self._handshake_session_expired() - - async def handshake(self) -> None: - """Perform the encryption handshake.""" - await self.perform_handshake() - async def perform_handshake(self) -> Any: """Perform handshake1 and handshake2. Sets the encryption_session if successful. """ - _LOGGER.debug("Starting handshake with %s", self.host) + _LOGGER.debug("Starting handshake with %s", self._host) self._handshake_done = False self._session_expire_at = None self._session_cookie = None @@ -307,7 +291,7 @@ async def perform_handshake(self) -> Any: ) self._handshake_done = True - _LOGGER.debug("Handshake with %s complete", self.host) + _LOGGER.debug("Handshake with %s complete", self._host) def _handshake_session_expired(self): """Return true if session has expired.""" @@ -318,18 +302,14 @@ def _handshake_session_expired(self): async def send(self, request: str): """Send the request.""" - if self.needs_handshake: - raise SmartDeviceException( - "Handshake must be complete before trying to send" - ) - if self.needs_login: - raise SmartDeviceException("Login must be complete before trying to send") + if not self._handshake_done or self._handshake_session_expired(): + await self.perform_handshake() # Check for mypy if self._encryption_session is not None: payload, seq = self._encryption_session.encrypt(request.encode()) - url = f"http://{self.host}/app/request" + url = f"http://{self._host}/app/request" response_status, response_data = await self.client_post( url, @@ -338,7 +318,7 @@ async def send(self, request: str): ) msg = ( - f"at {datetime.datetime.now()}. Host is {self.host}, " + f"at {datetime.datetime.now()}. Host is {self._host}, " + f"Sequence is {seq}, " + f"Response status is {response_status}, Request was {request}" ) @@ -348,12 +328,12 @@ async def send(self, request: str): if response_status == 403: self._handshake_done = False raise AuthenticationException( - f"Got a security error from {self.host} after handshake " + f"Got a security error from {self._host} after handshake " + "completed" ) else: raise SmartDeviceException( - f"Device {self.host} responded with {response_status} to" + f"Device {self._host} responded with {response_status} to" + f"request with seq {seq}" ) else: @@ -367,7 +347,7 @@ async def send(self, request: str): _LOGGER.debug( "%s << %s", - self.host, + self._host, _LOGGER.isEnabledFor(logging.DEBUG) and pf(json_payload), ) diff --git a/kasa/protocol.py b/kasa/protocol.py index 62cd5fb63..f73260bf0 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -44,35 +44,21 @@ def md5(payload: bytes) -> bytes: class BaseTransport(ABC): """Base class for all TP-Link protocol transports.""" + DEFAULT_TIMEOUT = 5 + def __init__( self, host: str, *, port: Optional[int] = None, credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, ) -> None: """Create a protocol object.""" - self.host = host - self.port = port - self.credentials = credentials - - @property - @abstractmethod - def needs_handshake(self) -> bool: - """Return true if the transport needs to do a handshake.""" - - @property - @abstractmethod - def needs_login(self) -> bool: - """Return true if the transport needs to do a login.""" - - @abstractmethod - async def login(self, request: str) -> None: - """Login to the device.""" - - @abstractmethod - async def handshake(self) -> None: - """Perform the encryption handshake.""" + self._host = host + self._port = port + self._credentials = credentials or Credentials(username="", password="") + self._timeout = timeout or self.DEFAULT_TIMEOUT @abstractmethod async def send(self, request: str) -> Dict: @@ -90,14 +76,14 @@ def __init__( self, host: str, *, - port: Optional[int] = None, - credentials: Optional[Credentials] = None, - transport: Optional[BaseTransport] = None, + transport: BaseTransport, ) -> None: """Create a protocol object.""" - self.host = host - self.port = port - self.credentials = credentials + self._transport = transport + + @property + def _host(self): + return self._transport._host @abstractmethod async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: @@ -108,6 +94,40 @@ async def close(self) -> None: """Close the protocol. Abstract method to be overriden.""" +class _XorTransport(BaseTransport): + """Implementation of the Xor encryption transport. + + WIP, currently only to ensure consistent __init__ method signatures + for protocol classes. Will eventually incorporate the logic from + TPLinkSmartHomeProtocol to simplify the API and re-use the IotProtocol + class. + """ + + DEFAULT_PORT = 9999 + + def __init__( + self, + host: str, + *, + port: Optional[int] = None, + credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, + ) -> None: + super().__init__( + host, + port=port or self.DEFAULT_PORT, + credentials=credentials, + timeout=timeout, + ) + + async def send(self, request: str) -> Dict: + """Send a message to the device and return a response.""" + return {} + + async def close(self) -> None: + """Close the transport. Abstract method to be overriden.""" + + class TPLinkSmartHomeProtocol(TPLinkProtocol): """Implementation of the TP-Link Smart Home protocol.""" @@ -120,20 +140,18 @@ def __init__( self, host: str, *, - port: Optional[int] = None, - timeout: Optional[int] = None, - credentials: Optional[Credentials] = None, + transport: BaseTransport, ) -> None: """Create a protocol object.""" - super().__init__( - host=host, port=port or self.DEFAULT_PORT, credentials=credentials - ) + super().__init__(host, transport=transport) self.reader: Optional[asyncio.StreamReader] = None self.writer: Optional[asyncio.StreamWriter] = None self.query_lock = asyncio.Lock() self.loop: Optional[asyncio.AbstractEventLoop] = None - self.timeout = timeout or TPLinkSmartHomeProtocol.DEFAULT_TIMEOUT + + self._timeout = self._transport._timeout + self._port = self._transport._port async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: """Request information from a TP-Link SmartHome Device. @@ -149,7 +167,7 @@ async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: assert isinstance(request, str) # noqa: S101 async with self.query_lock: - return await self._query(request, retry_count, self.timeout) + return await self._query(request, retry_count, self._timeout) async def _connect(self, timeout: int) -> None: """Try to connect or reconnect to the device.""" @@ -157,7 +175,7 @@ async def _connect(self, timeout: int) -> None: return self.reader = self.writer = None - task = asyncio.open_connection(self.host, self.port) + task = asyncio.open_connection(self._host, self._port) async with asyncio_timeout(timeout): self.reader, self.writer = await task sock: socket.socket = self.writer.get_extra_info("socket") @@ -174,7 +192,7 @@ async def _execute_query(self, request: str) -> Dict: debug_log = _LOGGER.isEnabledFor(logging.DEBUG) if debug_log: - _LOGGER.debug("%s >> %s", self.host, request) + _LOGGER.debug("%s >> %s", self._host, request) self.writer.write(TPLinkSmartHomeProtocol.encrypt(request)) await self.writer.drain() @@ -185,7 +203,7 @@ async def _execute_query(self, request: str) -> Dict: response = TPLinkSmartHomeProtocol.decrypt(buffer) json_payload = json_loads(response) if debug_log: - _LOGGER.debug("%s << %s", self.host, pf(json_payload)) + _LOGGER.debug("%s << %s", self._host, pf(json_payload)) return json_payload @@ -219,23 +237,23 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: except ConnectionRefusedError as ex: await self.close() raise SmartDeviceException( - f"Unable to connect to the device: {self.host}:{self.port}: {ex}" + f"Unable to connect to the device: {self._host}:{self._port}: {ex}" ) from ex except OSError as ex: await self.close() if ex.errno in _NO_RETRY_ERRORS or retry >= retry_count: raise SmartDeviceException( f"Unable to connect to the device:" - f" {self.host}:{self.port}: {ex}" + f" {self._host}:{self._port}: {ex}" ) from ex continue except Exception as ex: await self.close() if retry >= retry_count: - _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise SmartDeviceException( f"Unable to connect to the device:" - f" {self.host}:{self.port}: {ex}" + f" {self._host}:{self._port}: {ex}" ) from ex continue @@ -247,13 +265,13 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: except Exception as ex: await self.close() if retry >= retry_count: - _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise SmartDeviceException( - f"Unable to query the device {self.host}:{self.port}: {ex}" + f"Unable to query the device {self._host}:{self._port}: {ex}" ) from ex _LOGGER.debug( - "Unable to query the device %s, retrying: %s", self.host, ex + "Unable to query the device %s, retrying: %s", self._host, ex ) # make mypy happy, this should never be reached.. diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 342d1c4a6..5ad94a9f4 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -24,7 +24,7 @@ from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException from .modules import Emeter, Module -from .protocol import TPLinkProtocol, TPLinkSmartHomeProtocol +from .protocol import TPLinkProtocol, TPLinkSmartHomeProtocol, _XorTransport _LOGGER = logging.getLogger(__name__) @@ -202,7 +202,7 @@ def __init__( self.host = host self.port = port self.protocol: TPLinkProtocol = TPLinkSmartHomeProtocol( - host, port=port, timeout=timeout + host, transport=_XorTransport(host, port=port, timeout=timeout) ) self.credentials = credentials _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index eb661317d..a344cf66c 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -10,12 +10,10 @@ import time import uuid from pprint import pformat as pf -from typing import Dict, Optional, Union +from typing import Dict, Union import httpx -from .aestransport import AesTransport -from .credentials import Credentials from .exceptions import ( SMART_AUTHENTICATION_ERRORS, SMART_RETRYABLE_ERRORS, @@ -36,26 +34,17 @@ class SmartProtocol(TPLinkProtocol): """Class for the new TPLink SMART protocol.""" - DEFAULT_PORT = 80 SLEEP_SECONDS_AFTER_TIMEOUT = 1 def __init__( self, host: str, *, - transport: Optional[BaseTransport] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, + transport: BaseTransport, ) -> None: - super().__init__(host=host, port=self.DEFAULT_PORT) - - self._credentials: Credentials = credentials or Credentials( - username="", password="" - ) - self._transport: BaseTransport = transport or AesTransport( - host, credentials=self._credentials, timeout=timeout - ) - self._terminal_uuid: Optional[str] = None + """Create a protocol object.""" + super().__init__(host, transport=transport) + self._terminal_uuid: str = base64.b64encode(md5(uuid.uuid4().bytes)).decode() self._request_id_generator = SnowflakeId(1, 1) self._query_lock = asyncio.Lock() @@ -79,7 +68,7 @@ async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: error_code := SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] ) != SmartErrorCode.SUCCESS: msg = ( - f"Error querying device: {self.host}: " + f"Error querying device: {self._host}: " + f"{error_code.name}({error_code.value})" ) if error_code in SMART_TIMEOUT_ERRORS: @@ -101,51 +90,53 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: except httpx.CloseError as sdex: await self.close() if retry >= retry_count: - _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise SmartDeviceException( - f"Unable to connect to the device: {self.host}: {sdex}" + f"Unable to connect to the device: {self._host}: {sdex}" ) from sdex continue except httpx.ConnectError as cex: await self.close() raise SmartDeviceException( - f"Unable to connect to the device: {self.host}: {cex}" + f"Unable to connect to the device: {self._host}: {cex}" ) from cex except TimeoutError as tex: if retry >= retry_count: await self.close() raise SmartDeviceException( "Unable to connect to the device, " - + f"timed out: {self.host}: {tex}" + + f"timed out: {self._host}: {tex}" ) from tex await asyncio.sleep(self.SLEEP_SECONDS_AFTER_TIMEOUT) continue except AuthenticationException as auex: await self.close() - _LOGGER.debug("Unable to authenticate with %s, not retrying", self.host) + _LOGGER.debug( + "Unable to authenticate with %s, not retrying", self._host + ) raise auex except RetryableException as ex: if retry >= retry_count: await self.close() - _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex continue except TimeoutException as ex: if retry >= retry_count: await self.close() - _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex await asyncio.sleep(self.SLEEP_SECONDS_AFTER_TIMEOUT) continue except Exception as ex: if retry >= retry_count: await self.close() - _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise SmartDeviceException( - f"Unable to query the device {self.host}:{self.port}: {ex}" + f"Unable to connect to the device: {self._host}: {ex}" ) from ex _LOGGER.debug( - "Unable to query the device %s, retrying: %s", self.host, ex + "Unable to query the device %s, retrying: %s", self._host, ex ) continue @@ -160,27 +151,17 @@ async def _execute_query(self, request: Union[str, Dict], retry_count: int) -> D smart_method = request smart_params = None - if self._transport.needs_handshake: - await self._transport.handshake() - - if self._transport.needs_login: - self._terminal_uuid = base64.b64encode(md5(uuid.uuid4().bytes)).decode( - "UTF-8" - ) - login_request = self.get_smart_request("login_device") - await self._transport.login(login_request) - smart_request = self.get_smart_request(smart_method, smart_params) _LOGGER.debug( "%s >> %s", - self.host, + self._host, _LOGGER.isEnabledFor(logging.DEBUG) and pf(smart_request), ) response_data = await self._transport.send(smart_request) _LOGGER.debug( "%s << %s", - self.host, + self._host, _LOGGER.isEnabledFor(logging.DEBUG) and pf(response_data), ) diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 291e17442..e5d9effe0 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional, Set, cast +from ..aestransport import AesTransport from ..credentials import Credentials from ..exceptions import AuthenticationException from ..smartdevice import SmartDevice @@ -27,7 +28,12 @@ def __init__( self._components: Optional[Dict[str, Any]] = None self._state_information: Dict[str, Any] = {} self._discovery_info: Optional[Dict[str, Any]] = None - self.protocol = SmartProtocol(host, credentials=credentials, timeout=timeout) + self.protocol = SmartProtocol( + host, + transport=AesTransport( + host, credentials=credentials, timeout=timeout, port=port + ), + ) async def update(self, update_children: bool = True): """Update the device.""" diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 284f4e2b7..c01c8ee3e 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -301,6 +301,9 @@ async def query(self, request, retry_count: int = 3): class FakeSmartTransport(BaseTransport): def __init__(self, info): + super().__init__( + "127.0.0.123", + ) self.info = info @property diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py new file mode 100644 index 000000000..b018b4975 --- /dev/null +++ b/kasa/tests/test_aestransport.py @@ -0,0 +1,174 @@ +import base64 +import json +import time +from contextlib import nullcontext as does_not_raise +from json import dumps as json_dumps +from json import loads as json_loads + +import httpx +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding + +from ..aestransport import AesEncyptionSession, AesTransport +from ..credentials import Credentials +from ..exceptions import SmartDeviceException + +DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} + +key = b"8\x89\x02\xfa\xf5Xs\x1c\xa1 H\x9a\x82\xc7\xd9\t" +iv = b"9=\xf8\x1bS\xcd0\xb5\x89i\xba\xfd^9\x9f\xfa" +KEY_IV = key + iv + + +def test_encrypt(): + encryption_session = AesEncyptionSession(KEY_IV[:16], KEY_IV[16:]) + + d = json.dumps({"foo": 1, "bar": 2}) + encrypted = encryption_session.encrypt(d.encode()) + assert d == encryption_session.decrypt(encrypted) + + # test encrypt unicode + d = "{'snowman': '\u2603'}" + encrypted = encryption_session.encrypt(d.encode()) + assert d == encryption_session.decrypt(encrypted) + + +status_parameters = pytest.mark.parametrize( + "status_code, error_code, inner_error_code, expectation", + [ + (200, 0, 0, does_not_raise()), + (400, 0, 0, pytest.raises(SmartDeviceException)), + (200, -1, 0, pytest.raises(SmartDeviceException)), + ], + ids=("success", "status_code", "error_code"), +) + + +@status_parameters +async def test_handshake( + mocker, status_code, error_code, inner_error_code, expectation +): + host = "127.0.0.1" + mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code) + mocker.patch.object(httpx.AsyncClient, "post", side_effect=mock_aes_device.post) + + transport = AesTransport(host=host, credentials=Credentials("foo", "bar")) + + assert transport._encryption_session is None + assert transport._handshake_done is False + with expectation: + await transport.perform_handshake() + assert transport._encryption_session is not None + assert transport._handshake_done is True + + +@status_parameters +async def test_login(mocker, status_code, error_code, inner_error_code, expectation): + host = "127.0.0.1" + mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code) + mocker.patch.object(httpx.AsyncClient, "post", side_effect=mock_aes_device.post) + + transport = AesTransport(host=host, credentials=Credentials("foo", "bar")) + transport._handshake_done = True + transport._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + + assert transport._login_token is None + with expectation: + await transport.perform_login() + assert transport._login_token == mock_aes_device.token + + +@status_parameters +async def test_send(mocker, status_code, error_code, inner_error_code, expectation): + host = "127.0.0.1" + mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code) + mocker.patch.object(httpx.AsyncClient, "post", side_effect=mock_aes_device.post) + + transport = AesTransport(host=host, credentials=Credentials("foo", "bar")) + transport._handshake_done = True + transport._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + transport._login_token = mock_aes_device.token + + un, pw = transport.hash_credentials(True) + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + with expectation: + res = await transport.send(json_dumps(request)) + assert "result" in res + + +class MockAesDevice: + class _mock_response: + def __init__(self, status_code, json: dict): + self.status_code = status_code + self._json = json + + def json(self): + return self._json + + 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 + + async def post(self, url, params=None, json=None, *_, **__): + return await self._post(url, json) + + async def _post(self, url, json): + if json["method"] == "handshake": + return await self._return_handshake_response(url, json) + elif json["method"] == "securePassthrough": + return await self._return_secure_passthrough_response(url, json) + elif json["method"] == "login_device": + return await self._return_login_response(url, json) + else: + 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): + start = len("-----BEGIN PUBLIC KEY-----\n") + end = len("\n-----END PUBLIC KEY-----\n") + client_pub_key = json["params"]["key"][start:-end] + + client_pub_key_data = base64.b64decode(client_pub_key.encode()) + client_pub_key = serialization.load_der_public_key(client_pub_key_data, None) + encrypted_key = client_pub_key.encrypt(KEY_IV, asymmetric_padding.PKCS1v15()) + key_64 = base64.b64encode(encrypted_key).decode() + return self._mock_response( + self.status_code, {"result": {"key": key_64}, "error_code": self.error_code} + ) + + async def _return_secure_passthrough_response(self, url, json): + encrypted_request = json["params"]["request"] + decrypted_request = self.encryption_session.decrypt(encrypted_request.encode()) + decrypted_request_dict = json_loads(decrypted_request) + decrypted_response = await self._post(url, decrypted_request_dict) + decrypted_response_dict = decrypted_response.json() + encrypted_response = self.encryption_session.encrypt( + json_dumps(decrypted_response_dict).encode() + ) + result = { + "result": {"response": encrypted_response.decode()}, + "error_code": self.error_code, + } + return self._mock_response(self.status_code, result) + + async def _return_login_response(self, url, json): + result = {"result": {"token": self.token}, "error_code": self.inner_error_code} + return self._mock_response(self.status_code, result) + + async def _return_send_response(self, url, json): + result = {"result": {"method": None}, "error_code": self.inner_error_code} + return self._mock_response(self.status_code, result) diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 8ad46b6ea..d29f4e302 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -96,10 +96,9 @@ def _fail_one_less_than_retry_count(*_, **__): return mock_response - mocker.patch.object( - transport_class, "needs_handshake", property(lambda self: False) - ) - mocker.patch.object(transport_class, "needs_login", property(lambda self: False)) + mocker.patch.object(transport_class, "perform_handshake") + if hasattr(transport_class, "perform_login"): + mocker.patch.object(transport_class, "perform_login") send_mock = mocker.patch.object( transport_class, @@ -128,7 +127,7 @@ def _return_encrypted(*_, **__): seed = secrets.token_bytes(16) auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) encryption_session = KlapEncryptionSession(seed, seed, auth_hash) - protocol = IotProtocol("127.0.0.1") + protocol = IotProtocol("127.0.0.1", transport=KlapTransport("127.0.0.1")) protocol._transport._handshake_done = True protocol._transport._session_expire_at = time.time() + 86400 @@ -206,7 +205,10 @@ async def _return_handshake1_response(url, params=None, data=None, *_, **__): httpx.AsyncClient, "post", side_effect=_return_handshake1_response ) - protocol = IotProtocol("127.0.0.1", credentials=client_credentials) + protocol = IotProtocol( + "127.0.0.1", + transport=KlapTransport("127.0.0.1", credentials=client_credentials), + ) protocol._transport.http_client = httpx.AsyncClient() with expectation: @@ -243,7 +245,10 @@ async def _return_handshake_response(url, params=None, data=None, *_, **__): httpx.AsyncClient, "post", side_effect=_return_handshake_response ) - protocol = IotProtocol("127.0.0.1", credentials=client_credentials) + protocol = IotProtocol( + "127.0.0.1", + transport=KlapTransport("127.0.0.1", credentials=client_credentials), + ) protocol._transport.http_client = httpx.AsyncClient() response_status = 200 @@ -289,7 +294,10 @@ async def _return_response(url, params=None, data=None, *_, **__): mocker.patch.object(httpx.AsyncClient, "post", side_effect=_return_response) - protocol = IotProtocol("127.0.0.1", credentials=client_credentials) + protocol = IotProtocol( + "127.0.0.1", + transport=KlapTransport("127.0.0.1", credentials=client_credentials), + ) for _ in range(10): resp = await protocol.query({}) @@ -333,7 +341,10 @@ async def _return_response(url, params=None, data=None, *_, **__): mocker.patch.object(httpx.AsyncClient, "post", side_effect=_return_response) - protocol = IotProtocol("127.0.0.1", credentials=client_credentials) + protocol = IotProtocol( + "127.0.0.1", + transport=KlapTransport("127.0.0.1", credentials=client_credentials), + ) with expectation: await protocol.query({}) diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index b438f498e..7bd6342b4 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -1,13 +1,21 @@ import errno +import importlib +import inspect import json import logging +import pkgutil import struct import sys import pytest from ..exceptions import SmartDeviceException -from ..protocol import TPLinkSmartHomeProtocol +from ..protocol import ( + BaseTransport, + TPLinkProtocol, + TPLinkSmartHomeProtocol, + _XorTransport, +) @pytest.mark.parametrize("retry_count", [1, 3, 5]) @@ -24,7 +32,9 @@ def aio_mock_writer(_, __): conn = mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol("127.0.0.1").query({}, retry_count=retry_count) + await TPLinkSmartHomeProtocol( + "127.0.0.1", transport=_XorTransport("127.0.0.1") + ).query({}, retry_count=retry_count) assert conn.call_count == retry_count + 1 @@ -35,7 +45,9 @@ async def test_protocol_no_retry_on_unreachable(mocker): side_effect=OSError(errno.EHOSTUNREACH, "No route to host"), ) with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol("127.0.0.1").query({}, retry_count=5) + await TPLinkSmartHomeProtocol( + "127.0.0.1", transport=_XorTransport("127.0.0.1") + ).query({}, retry_count=5) assert conn.call_count == 1 @@ -46,7 +58,9 @@ async def test_protocol_no_retry_connection_refused(mocker): side_effect=ConnectionRefusedError, ) with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol("127.0.0.1").query({}, retry_count=5) + await TPLinkSmartHomeProtocol( + "127.0.0.1", transport=_XorTransport("127.0.0.1") + ).query({}, retry_count=5) assert conn.call_count == 1 @@ -57,7 +71,9 @@ async def test_protocol_retry_recoverable_error(mocker): side_effect=OSError(errno.ECONNRESET, "Connection reset by peer"), ) with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol("127.0.0.1").query({}, retry_count=5) + await TPLinkSmartHomeProtocol( + "127.0.0.1", transport=_XorTransport("127.0.0.1") + ).query({}, retry_count=5) assert conn.call_count == 6 @@ -91,7 +107,9 @@ def aio_mock_writer(_, __): mocker.patch.object(reader, "readexactly", _mock_read) return reader, writer - protocol = TPLinkSmartHomeProtocol("127.0.0.1") + protocol = TPLinkSmartHomeProtocol( + "127.0.0.1", transport=_XorTransport("127.0.0.1") + ) mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) response = await protocol.query({}, retry_count=retry_count) assert response == {"great": "success"} @@ -119,7 +137,9 @@ def aio_mock_writer(_, __): mocker.patch.object(reader, "readexactly", _mock_read) return reader, writer - protocol = TPLinkSmartHomeProtocol("127.0.0.1") + protocol = TPLinkSmartHomeProtocol( + "127.0.0.1", transport=_XorTransport("127.0.0.1") + ) mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) response = await protocol.query({}) assert response == {"great": "success"} @@ -153,7 +173,9 @@ def aio_mock_writer(_, port): mocker.patch.object(reader, "readexactly", _mock_read) return reader, writer - protocol = TPLinkSmartHomeProtocol("127.0.0.1", port=custom_port) + protocol = TPLinkSmartHomeProtocol( + "127.0.0.1", transport=_XorTransport("127.0.0.1", port=custom_port) + ) mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) response = await protocol.query({}) assert response == {"great": "success"} @@ -227,3 +249,63 @@ def test_decrypt_unicode(): d = "{'snowman': '\u2603'}" assert d == TPLinkSmartHomeProtocol.decrypt(e) + + +def _get_subclasses(of_class): + import kasa + + package = sys.modules["kasa"] + subclasses = set() + for _, modname, _ in pkgutil.iter_modules(package.__path__): + importlib.import_module("." + modname, package="kasa") + module = sys.modules["kasa." + modname] + for name, obj in inspect.getmembers(module): + if inspect.isclass(obj) and issubclass(obj, of_class): + subclasses.add((name, obj)) + return subclasses + + +@pytest.mark.parametrize( + "class_name_obj", _get_subclasses(TPLinkProtocol), ids=lambda t: t[0] +) +def test_protocol_init_signature(class_name_obj): + params = list(inspect.signature(class_name_obj[1].__init__).parameters.values()) + + assert len(params) == 3 + assert ( + params[0].name == "self" + and params[0].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + ) + assert ( + params[1].name == "host" + and params[1].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + ) + assert ( + params[2].name == "transport" + and params[2].kind == inspect.Parameter.KEYWORD_ONLY + ) + + +@pytest.mark.parametrize( + "class_name_obj", _get_subclasses(BaseTransport), ids=lambda t: t[0] +) +def test_transport_init_signature(class_name_obj): + params = list(inspect.signature(class_name_obj[1].__init__).parameters.values()) + + assert len(params) == 5 + assert ( + params[0].name == "self" + and params[0].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + ) + assert ( + params[1].name == "host" + and params[1].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + ) + assert params[2].name == "port" and params[2].kind == inspect.Parameter.KEYWORD_ONLY + assert ( + params[3].name == "credentials" + and params[3].kind == inspect.Parameter.KEYWORD_ONLY + ) + assert ( + params[4].name == "timeout" and params[4].kind == inspect.Parameter.KEYWORD_ONLY + ) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 33c9f4483..90eae16f2 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -232,7 +232,7 @@ async def test_modules_preserved(dev: SmartDevice): async def test_create_smart_device_with_timeout(): """Make sure timeout is passed to the protocol.""" dev = SmartDevice(host="127.0.0.1", timeout=100) - assert dev.protocol.timeout == 100 + assert dev.protocol._transport._timeout == 100 async def test_create_thin_wrapper(): From 6819c746d7616f7444a7797ba12c6c9f3b5b86ee Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Wed, 20 Dec 2023 17:08:04 +0000 Subject: [PATCH 205/892] Enable multiple requests in smartprotocol (#584) * Enable multiple requests in smartprotocol * Update following review * Remove error_code parameter in exceptions --- kasa/aestransport.py | 26 +++++----- kasa/iotprotocol.py | 7 +++ kasa/smartprotocol.py | 72 +++++++++++++++++++--------- kasa/tapo/tapodevice.py | 15 ++++-- kasa/tapo/tapoplug.py | 16 ++++++- kasa/tests/conftest.py | 8 +++- kasa/tests/newfakes.py | 43 ++++++++++------- kasa/tests/test_aestransport.py | 33 ++++++++++++- kasa/tests/test_emeter.py | 10 ++-- kasa/tests/test_klapprotocol.py | 21 ++++++--- kasa/tests/test_smartdevice.py | 4 +- kasa/tests/test_smartprotocol.py | 81 ++++++++++++++++++++++++++++++++ 12 files changed, 260 insertions(+), 76 deletions(-) create mode 100644 kasa/tests/test_smartprotocol.py diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 9db0db4f3..e79d0651d 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -125,19 +125,19 @@ async def client_post(self, url, params=None, data=None, json=None, headers=None return resp.status_code, response_data def _handle_response_error_code(self, resp_dict: dict, msg: str): - if ( - error_code := SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] - ) != SmartErrorCode.SUCCESS: - msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})" - if error_code in SMART_TIMEOUT_ERRORS: - raise TimeoutException(msg) - if error_code in SMART_RETRYABLE_ERRORS: - raise RetryableException(msg) - if error_code in SMART_AUTHENTICATION_ERRORS: - self._handshake_done = False - self._login_token = None - raise AuthenticationException(msg) - raise SmartDeviceException(msg) + error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] + if error_code == SmartErrorCode.SUCCESS: + return + msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})" + if error_code in SMART_TIMEOUT_ERRORS: + raise TimeoutException(msg) + if error_code in SMART_RETRYABLE_ERRORS: + raise RetryableException(msg) + if error_code in SMART_AUTHENTICATION_ERRORS: + self._handshake_done = False + self._login_token = None + raise AuthenticationException(msg) + raise SmartDeviceException(msg) async def send_secure_passthrough(self, request: str): """Send encrypted message as passthrough.""" diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index d942d0609..fbb37b15a 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -62,6 +62,13 @@ async def _query(self, request: str, retry_count: int = 3) -> Dict: "Unable to authenticate with %s, not retrying", self._host ) raise auex + except SmartDeviceException as ex: + _LOGGER.debug( + "Unable to connect to the device: %s, not retrying: %s", + self._host, + ex, + ) + raise ex except Exception as ex: await self.close() if retry >= retry_count: diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index a344cf66c..a9266174a 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -62,26 +62,7 @@ def get_smart_request(self, method, params=None) -> str: async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: """Query the device retrying for retry_count on failure.""" async with self._query_lock: - resp_dict = await self._query(request, retry_count) - - if ( - error_code := SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] - ) != SmartErrorCode.SUCCESS: - msg = ( - f"Error querying device: {self._host}: " - + f"{error_code.name}({error_code.value})" - ) - if error_code in SMART_TIMEOUT_ERRORS: - raise TimeoutException(msg) - if error_code in SMART_RETRYABLE_ERRORS: - raise RetryableException(msg) - if error_code in SMART_AUTHENTICATION_ERRORS: - raise AuthenticationException(msg) - raise SmartDeviceException(msg) - - if "result" in resp_dict: - return resp_dict["result"] - return {} + return await self._query(request, retry_count) async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: for retry in range(retry_count + 1): @@ -128,6 +109,11 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: raise ex await asyncio.sleep(self.SLEEP_SECONDS_AFTER_TIMEOUT) continue + except SmartDeviceException as ex: + # Transport would have raised RetryableException if retry makes sense. + await self.close() + _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) + raise ex except Exception as ex: if retry >= retry_count: await self.close() @@ -145,8 +131,15 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: async def _execute_query(self, request: Union[str, Dict], retry_count: int) -> Dict: if isinstance(request, dict): - smart_method = next(iter(request)) - smart_params = request[smart_method] + if len(request) == 1: + smart_method = next(iter(request)) + smart_params = request[smart_method] + else: + requests = [] + for method, params in request.items(): + requests.append({"method": method, "params": params}) + smart_method = "multipleRequest" + smart_params = {"requests": requests} else: smart_method = request smart_params = None @@ -165,7 +158,40 @@ async def _execute_query(self, request: Union[str, Dict], retry_count: int) -> D _LOGGER.isEnabledFor(logging.DEBUG) and pf(response_data), ) - return response_data + self._handle_response_error_code(response_data) + + if (result := response_data.get("result")) is None: + # Single set_ requests do not return a result + return {smart_method: None} + + if (responses := result.get("responses")) is None: + return {smart_method: result} + + # responses is returned for multipleRequest + multi_result = {} + for response in responses: + self._handle_response_error_code(response) + result = response.get("result", None) + multi_result[response["method"]] = result + return multi_result + + def _handle_response_error_code(self, resp_dict: dict): + error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] + if error_code == SmartErrorCode.SUCCESS: + return + msg = ( + f"Error querying device: {self._host}: " + + f"{error_code.name}({error_code.value})" + ) + if method := resp_dict.get("method"): + msg += f" for method: {method}" + if error_code in SMART_TIMEOUT_ERRORS: + raise TimeoutException(msg) + if error_code in SMART_RETRYABLE_ERRORS: + raise RetryableException(msg) + if error_code in SMART_AUTHENTICATION_ERRORS: + raise AuthenticationException(msg) + raise SmartDeviceException(msg) async def close(self) -> None: """Close the protocol.""" diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index e5d9effe0..97405b3f1 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -41,11 +41,18 @@ async def update(self, update_children: bool = True): raise AuthenticationException("Tapo plug requires authentication.") if self._components is None: - self._components = await self.protocol.query("component_nego") + resp = await self.protocol.query("component_nego") + self._components = resp["component_nego"] - self._info = await self.protocol.query("get_device_info") - self._usage = await self.protocol.query("get_device_usage") - self._time = await self.protocol.query("get_device_time") + req = { + "get_device_info": None, + "get_device_usage": None, + "get_device_time": None, + } + resp = await self.protocol.query(req) + self._info = resp["get_device_info"] + self._usage = resp["get_device_usage"] + self._time = resp["get_device_time"] self._last_update = self._data = { "components": self._components, diff --git a/kasa/tapo/tapoplug.py b/kasa/tapo/tapoplug.py index 84d00bc8c..9d868253e 100644 --- a/kasa/tapo/tapoplug.py +++ b/kasa/tapo/tapoplug.py @@ -39,8 +39,13 @@ async def update(self, update_children: bool = True): """Call the device endpoint and update the device data.""" await super().update(update_children) - self._energy = await self.protocol.query("get_energy_usage") - self._emeter = await self.protocol.query("get_current_power") + req = { + "get_energy_usage": None, + "get_current_power": None, + } + resp = await self.protocol.query(req) + self._energy = resp["get_energy_usage"] + self._emeter = resp["get_current_power"] self._data["energy"] = self._energy self._data["emeter"] = self._emeter @@ -71,6 +76,13 @@ def emeter_realtime(self) -> EmeterStatus: } ) + async def get_emeter_realtime(self) -> EmeterStatus: + """Retrieve current energy readings.""" + self._verify_emeter() + resp = await self.protocol.query("get_energy_usage") + self._energy = resp["get_energy_usage"] + return self.emeter_realtime + @property def emeter_today(self) -> Optional[float]: """Get the emeter value for today.""" diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 095971de8..43bba825b 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -196,9 +196,13 @@ def parametrize(desc, devices, protocol_filter=None, ids=None): ) -has_emeter = parametrize("has emeter", WITH_EMETER_IOT, protocol_filter={"IOT"}) +has_emeter = parametrize("has emeter", WITH_EMETER, protocol_filter={"SMART", "IOT"}) no_emeter = parametrize( - "no emeter", ALL_DEVICES_IOT - WITH_EMETER_IOT, protocol_filter={"SMART", "IOT"} + "no emeter", ALL_DEVICES - WITH_EMETER, protocol_filter={"SMART", "IOT"} +) +has_emeter_iot = parametrize("has emeter iot", WITH_EMETER_IOT, protocol_filter={"IOT"}) +no_emeter_iot = parametrize( + "no emeter iot", ALL_DEVICES_IOT - WITH_EMETER_IOT, protocol_filter={"IOT"} ) bulb = parametrize("bulbs", BULBS, protocol_filter={"SMART", "IOT"}) diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index c01c8ee3e..cd7ad4fd9 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -1,6 +1,7 @@ import copy import logging import re +import warnings from json import loads as json_loads from voluptuous import ( @@ -294,9 +295,7 @@ def __init__(self, info): async def query(self, request, retry_count: int = 3): """Implement query here so can still patch SmartProtocol.query.""" resp_dict = await self._query(request, retry_count) - if "result" in resp_dict: - return resp_dict["result"] - return {} + return resp_dict class FakeSmartTransport(BaseTransport): @@ -306,26 +305,34 @@ def __init__(self, info): ) self.info = info - @property - def needs_handshake(self) -> bool: - return False - - @property - def needs_login(self) -> bool: - return False - - async def login(self, request: str) -> None: - pass - - async def handshake(self) -> None: - pass - async def send(self, request: str): request_dict = json_loads(request) + method = request_dict["method"] + params = request_dict["params"] + if method == "multipleRequest": + responses = [] + for request in params["requests"]: + response = self._send_request(request) # type: ignore[arg-type] + response["method"] = request["method"] # type: ignore[index] + responses.append(response) + return {"result": {"responses": responses}, "error_code": 0} + else: + return self._send_request(request_dict) + + def _send_request(self, request_dict: dict): method = request_dict["method"] params = request_dict["params"] if method == "component_nego" or method[:4] == "get_": - return {"result": self.info[method], "error_code": 0} + if method in self.info: + return {"result": self.info[method], "error_code": 0} + else: + warnings.warn( + UserWarning( + f"Fixture missing expected method {method}, try to regenerate" + ), + stacklevel=1, + ) + return {"result": {}, "error_code": 0} elif method[:4] == "set_": target_method = f"get_{method[4:]}" self.info[target_method].update(params) diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index b018b4975..198e8f39e 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -12,7 +12,12 @@ from ..aestransport import AesEncyptionSession, AesTransport from ..credentials import Credentials -from ..exceptions import SmartDeviceException +from ..exceptions import ( + SMART_RETRYABLE_ERRORS, + SMART_TIMEOUT_ERRORS, + SmartDeviceException, + SmartErrorCode, +) DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} @@ -105,6 +110,32 @@ async def test_send(mocker, status_code, error_code, inner_error_code, expectati assert "result" in res +ERRORS = [e for e in SmartErrorCode if e != 0] + + +@pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) +async def test_passthrough_errors(mocker, error_code): + host = "127.0.0.1" + mock_aes_device = MockAesDevice(host, 200, error_code, 0) + mocker.patch.object(httpx.AsyncClient, "post", side_effect=mock_aes_device.post) + + transport = AesTransport(host=host, credentials=Credentials("foo", "bar")) + transport._handshake_done = True + transport._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + transport._login_token = mock_aes_device.token + + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + with pytest.raises(SmartDeviceException): + await transport.send(json_dumps(request)) + + class MockAesDevice: class _mock_response: def __init__(self, status_code, json: dict): diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 75375230a..9bc70bbaf 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -2,7 +2,7 @@ from kasa import EmeterStatus, SmartDeviceException -from .conftest import has_emeter, no_emeter +from .conftest import has_emeter, has_emeter_iot, no_emeter from .newfakes import CURRENT_CONSUMPTION_SCHEMA @@ -20,7 +20,7 @@ async def test_no_emeter(dev): await dev.erase_emeter_stats() -@has_emeter +@has_emeter_iot async def test_get_emeter_realtime(dev): assert dev.has_emeter @@ -28,7 +28,7 @@ async def test_get_emeter_realtime(dev): CURRENT_CONSUMPTION_SCHEMA(current_emeter) -@has_emeter +@has_emeter_iot @pytest.mark.requires_dummy async def test_get_emeter_daily(dev): assert dev.has_emeter @@ -48,7 +48,7 @@ async def test_get_emeter_daily(dev): assert v * 1000 == v2 -@has_emeter +@has_emeter_iot @pytest.mark.requires_dummy async def test_get_emeter_monthly(dev): assert dev.has_emeter @@ -68,7 +68,7 @@ async def test_get_emeter_monthly(dev): assert v * 1000 == v2 -@has_emeter +@has_emeter_iot async def test_emeter_status(dev): assert dev.has_emeter diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index d29f4e302..1ed57ef22 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -26,20 +26,29 @@ def __init__(self, status_code, content: bytes): self.content = content +@pytest.mark.parametrize( + "error, retry_expectation", + [ + (Exception("dummy exception"), True), + (SmartDeviceException("dummy exception"), False), + ], + ids=("Exception", "SmartDeviceException"), +) @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) @pytest.mark.parametrize("retry_count", [1, 3, 5]) -async def test_protocol_retries(mocker, retry_count, protocol_class, transport_class): +async def test_protocol_retries( + mocker, retry_count, protocol_class, transport_class, error, retry_expectation +): host = "127.0.0.1" - conn = mocker.patch.object( - httpx.AsyncClient, "post", side_effect=Exception("dummy exception") - ) + conn = mocker.patch.object(httpx.AsyncClient, "post", side_effect=error) with pytest.raises(SmartDeviceException): await protocol_class(host, transport=transport_class(host)).query( DUMMY_QUERY, retry_count=retry_count ) - assert conn.call_count == retry_count + 1 + expected_count = retry_count + 1 if retry_expectation else 1 + assert conn.call_count == expected_count @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @@ -109,7 +118,7 @@ def _fail_one_less_than_retry_count(*_, **__): response = await protocol_class(host, transport=transport_class(host)).query( DUMMY_QUERY, retry_count=retry_count ) - assert "result" in response or "great" in response + assert "result" in response or "foobar" in response assert send_mock.call_count == retry_count diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 90eae16f2..47f523d00 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -8,7 +8,7 @@ from kasa import Credentials, SmartDevice, SmartDeviceException from kasa.smartdevice import DeviceType -from .conftest import device_iot, handle_turn_on, has_emeter, no_emeter, turn_on +from .conftest import device_iot, handle_turn_on, has_emeter, no_emeter_iot, turn_on from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol # List of all SmartXXX classes including the SmartDevice base class @@ -48,7 +48,7 @@ async def test_initial_update_emeter(dev, mocker): assert spy.call_count == expected_queries + len(dev.children) -@no_emeter +@no_emeter_iot async def test_initial_update_no_emeter(dev, mocker): """Test that the initial update performs second query if emeter is available.""" dev._last_update = None diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py new file mode 100644 index 000000000..5dbbed279 --- /dev/null +++ b/kasa/tests/test_smartprotocol.py @@ -0,0 +1,81 @@ +import errno +import json +import logging +import secrets +import struct +import sys +import time +from contextlib import nullcontext as does_not_raise +from itertools import chain + +import httpx +import pytest + +from ..aestransport import AesTransport +from ..credentials import Credentials +from ..exceptions import ( + SMART_RETRYABLE_ERRORS, + SMART_TIMEOUT_ERRORS, + SmartDeviceException, + SmartErrorCode, +) +from ..iotprotocol import IotProtocol +from ..klaptransport import KlapEncryptionSession, KlapTransport, _sha256 +from ..smartprotocol import SmartProtocol + +DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} +ERRORS = [e for e in SmartErrorCode if e != 0] + + +@pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) +async def test_smart_device_errors(mocker, error_code): + host = "127.0.0.1" + mock_response = {"result": {"great": "success"}, "error_code": error_code.value} + + mocker.patch.object(AesTransport, "perform_handshake") + mocker.patch.object(AesTransport, "perform_login") + + send_mock = mocker.patch.object(AesTransport, "send", return_value=mock_response) + + protocol = SmartProtocol(host, transport=AesTransport(host)) + with pytest.raises(SmartDeviceException): + await protocol.query(DUMMY_QUERY, retry_count=2) + + if error_code in chain(SMART_TIMEOUT_ERRORS, SMART_RETRYABLE_ERRORS): + expected_calls = 3 + else: + expected_calls = 1 + assert send_mock.call_count == expected_calls + + +@pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) +async def test_smart_device_errors_in_multiple_request(mocker, error_code): + host = "127.0.0.1" + mock_response = { + "result": { + "responses": [ + {"method": "foobar1", "result": {"great": "success"}, "error_code": 0}, + { + "method": "foobar2", + "result": {"great": "success"}, + "error_code": error_code.value, + }, + {"method": "foobar3", "result": {"great": "success"}, "error_code": 0}, + ] + }, + "error_code": 0, + } + + mocker.patch.object(AesTransport, "perform_handshake") + mocker.patch.object(AesTransport, "perform_login") + + send_mock = mocker.patch.object(AesTransport, "send", return_value=mock_response) + + protocol = SmartProtocol(host, transport=AesTransport(host)) + with pytest.raises(SmartDeviceException): + await protocol.query(DUMMY_QUERY, retry_count=2) + if error_code in chain(SMART_TIMEOUT_ERRORS, SMART_RETRYABLE_ERRORS): + expected_calls = 3 + else: + expected_calls = 1 + assert send_mock.call_count == expected_calls From b66347116f3d535447c89b722672fba3347886f8 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Wed, 20 Dec 2023 19:16:23 +0000 Subject: [PATCH 206/892] Add optional error code to exceptions (#585) --- kasa/aestransport.py | 8 ++++---- kasa/exceptions.py | 14 ++++++++++++++ kasa/smartprotocol.py | 8 ++++---- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index e79d0651d..e7dd53568 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -130,14 +130,14 @@ def _handle_response_error_code(self, resp_dict: dict, msg: str): return msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})" if error_code in SMART_TIMEOUT_ERRORS: - raise TimeoutException(msg) + raise TimeoutException(msg, error_code=error_code) if error_code in SMART_RETRYABLE_ERRORS: - raise RetryableException(msg) + raise RetryableException(msg, error_code=error_code) if error_code in SMART_AUTHENTICATION_ERRORS: self._handshake_done = False self._login_token = None - raise AuthenticationException(msg) - raise SmartDeviceException(msg) + raise AuthenticationException(msg, error_code=error_code) + raise SmartDeviceException(msg, error_code=error_code) async def send_secure_passthrough(self, request: str): """Send encrypted message as passthrough.""" diff --git a/kasa/exceptions.py b/kasa/exceptions.py index e83c92375..ff91a7b0e 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -1,10 +1,15 @@ """python-kasa exceptions.""" from enum import IntEnum +from typing import Optional class SmartDeviceException(Exception): """Base exception for device errors.""" + def __init__(self, *args, error_code: Optional["SmartErrorCode"] = None): + self.error_code = error_code + super().__init__(args) + class UnsupportedDeviceException(SmartDeviceException): """Exception for trying to connect to unsupported devices.""" @@ -17,14 +22,23 @@ def __init__(self, *args, discovery_result=None): class AuthenticationException(SmartDeviceException): """Base exception for device authentication errors.""" + def __init__(self, *args, error_code: Optional["SmartErrorCode"] = None): + super().__init__(args, error_code) + class RetryableException(SmartDeviceException): """Retryable exception for device errors.""" + def __init__(self, *args, error_code: Optional["SmartErrorCode"] = None): + super().__init__(args, error_code) + class TimeoutException(SmartDeviceException): """Timeout exception for device errors.""" + def __init__(self, *args, error_code: Optional["SmartErrorCode"] = None): + super().__init__(args, error_code) + class SmartErrorCode(IntEnum): """Enum for SMART Error Codes.""" diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index a9266174a..443d1def1 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -186,12 +186,12 @@ def _handle_response_error_code(self, resp_dict: dict): if method := resp_dict.get("method"): msg += f" for method: {method}" if error_code in SMART_TIMEOUT_ERRORS: - raise TimeoutException(msg) + raise TimeoutException(msg, error_code=error_code) if error_code in SMART_RETRYABLE_ERRORS: - raise RetryableException(msg) + raise RetryableException(msg, error_code=error_code) if error_code in SMART_AUTHENTICATION_ERRORS: - raise AuthenticationException(msg) - raise SmartDeviceException(msg) + raise AuthenticationException(msg, error_code=error_code) + raise SmartDeviceException(msg, error_code=error_code) async def close(self) -> None: """Close the protocol.""" From 1d5a9c35f41852eeb843bb022190d185cd799e04 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 29 Dec 2023 16:04:41 +0100 Subject: [PATCH 207/892] Elevate --verbose to top-level option (#590) * Elevate --verbose to be usable for all commands * Fix tests --- kasa/cli.py | 32 ++++++++++++++++++++++---------- kasa/tests/test_cli.py | 6 +++--- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 600494df1..3478c35a5 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -128,7 +128,23 @@ def _device_to_serializable(val: SmartDevice): show_default=True, help="The broadcast address to be used for discovery.", ) -@click.option("-d", "--debug", envvar="KASA_DEBUG", default=False, is_flag=True) +@click.option( + "-v", + "--verbose", + envvar="KASA_VERBOSE", + required=False, + default=False, + is_flag=True, + help="Be more verbose on output", +) +@click.option( + "-d", + "--debug", + envvar="KASA_DEBUG", + default=False, + is_flag=True, + help="Print debug output", +) @click.option( "--type", envvar="KASA_TYPE", @@ -147,6 +163,7 @@ def _device_to_serializable(val: SmartDevice): envvar="KASA_TIMEOUT", default=5, required=False, + show_default=True, help="Timeout for device communications.", ) @click.option( @@ -154,6 +171,7 @@ def _device_to_serializable(val: SmartDevice): envvar="KASA_DISCOVERY_TIMEOUT", default=3, required=False, + show_default=True, help="Timeout for discovery.", ) @click.option( @@ -178,6 +196,7 @@ async def cli( port, alias, target, + verbose, debug, type, json, @@ -307,21 +326,14 @@ async def join(dev: SmartDevice, ssid, password, keytype): @cli.command() -@click.option( - "--verbose", - envvar="KASA_VERBOSE", - required=False, - default=False, - is_flag=True, - help="Be more verbose on output", -) @click.pass_context -async def discover(ctx, verbose): +async def discover(ctx): """Discover devices in the network.""" target = ctx.parent.params["target"] username = ctx.parent.params["username"] password = ctx.parent.params["password"] timeout = ctx.parent.params["discovery_timeout"] + verbose = ctx.parent.params["verbose"] credentials = Credentials(username, password) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 5add2b582..c46015ea0 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -236,8 +236,8 @@ async def test_discover(discovery_mock, mocker): "foo", "--password", "bar", - "discover", "--verbose", + "discover", ], ) assert res.exit_code == 0 @@ -255,8 +255,8 @@ async def test_discover_unsupported(unsupported_device_info): "foo", "--password", "bar", - "discover", "--verbose", + "discover", ], ) assert res.exit_code == 0 @@ -306,8 +306,8 @@ async def test_discover_auth_failed(discovery_mock, mocker): "foo", "--password", "bar", - "discover", "--verbose", + "discover", ], ) From ec3ea39a375445df80c3d3c060381a67dcde4ac0 Mon Sep 17 00:00:00 2001 From: alanblake Date: Sat, 30 Dec 2023 02:05:47 +1100 Subject: [PATCH 208/892] Fix typo in cli.rst (#581) --- docs/source/cli.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index e939a3992..603c7f486 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -22,7 +22,7 @@ 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``). -On multihomed systems, you can use ``--target`` option to specify the broadcsat target. +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. .. note:: From f6fd898faffc5df829e6197070197643710b6886 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Fri, 29 Dec 2023 19:17:15 +0000 Subject: [PATCH 209/892] Add DeviceConfig to allow specifying configuration parameters (#569) * Add DeviceConfig handling * Update post review * Further update post latest review * Update following latest review * Update docstrings and docs --- docs/source/design.rst | 9 +- docs/source/deviceconfig.rst | 18 +++ docs/source/index.rst | 1 + kasa/__init__.py | 10 ++ kasa/aestransport.py | 40 +++--- kasa/cli.py | 67 +++++++--- kasa/credentials.py | 4 +- kasa/device_factory.py | 161 +++++++++++------------- kasa/deviceconfig.py | 148 ++++++++++++++++++++++ kasa/discover.py | 160 ++++++++++++++---------- kasa/iotprotocol.py | 15 +-- kasa/klaptransport.py | 39 +++--- kasa/protocol.py | 56 ++++----- kasa/smartbulb.py | 10 +- kasa/smartdevice.py | 73 ++++++----- kasa/smartdimmer.py | 10 +- kasa/smartlightstrip.py | 10 +- kasa/smartplug.py | 10 +- kasa/smartprotocol.py | 12 +- kasa/smartstrip.py | 10 +- kasa/tapo/tapodevice.py | 22 ++-- kasa/tapo/tapoplug.py | 10 +- kasa/tests/conftest.py | 34 +++++- kasa/tests/newfakes.py | 20 ++- kasa/tests/test_aestransport.py | 17 ++- kasa/tests/test_cli.py | 60 +++++++-- kasa/tests/test_device_factory.py | 197 ++++++++++++++++-------------- kasa/tests/test_deviceconfig.py | 21 ++++ kasa/tests/test_discovery.py | 137 ++++++++++++++------- kasa/tests/test_klapprotocol.py | 111 +++++++++++------ kasa/tests/test_protocol.py | 67 +++++----- kasa/tests/test_smartdevice.py | 30 +++-- kasa/tests/test_smartprotocol.py | 8 +- 33 files changed, 1020 insertions(+), 577 deletions(-) create mode 100644 docs/source/deviceconfig.rst create mode 100644 kasa/deviceconfig.py create mode 100644 kasa/tests/test_deviceconfig.py diff --git a/docs/source/design.rst b/docs/source/design.rst index 5679943d2..6538c8b80 100644 --- a/docs/source/design.rst +++ b/docs/source/design.rst @@ -23,9 +23,12 @@ This will return you a list of device instances based on the discovery replies. If the device's host is already known, you can use to construct a device instance with :meth:`~kasa.SmartDevice.connect()`. -When connecting a device with the :meth:`~kasa.SmartDevice.connect()` method, it is recommended to -pass the device type as well as this allows the library to use the correct device class for the -device without having to query the device. +The :meth:`~kasa.SmartDevice.connect()` also enables support for connecting to new +KASA SMART protocol and TAPO devices directly using the parameter :class:`~kasa.DeviceConfig`. +Simply serialize the :attr:`~kasa.SmartDevice.config` property via :meth:`~kasa.DeviceConfig.to_dict()` +and then deserialize it later with :func:`~kasa.DeviceConfig.from_dict()` +and then pass it into :meth:`~kasa.SmartDevice.connect()`. + .. _update_cycle: diff --git a/docs/source/deviceconfig.rst b/docs/source/deviceconfig.rst new file mode 100644 index 000000000..25bf077ba --- /dev/null +++ b/docs/source/deviceconfig.rst @@ -0,0 +1,18 @@ +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/index.rst b/docs/source/index.rst index 346c53d08..16e7cbd07 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -15,3 +15,4 @@ smartdimmer smartstrip smartlightstrip + deviceconfig diff --git a/kasa/__init__.py b/kasa/__init__.py index 7de394c11..f5b795bdc 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -14,6 +14,12 @@ from importlib.metadata import version from kasa.credentials import Credentials +from kasa.deviceconfig import ( + ConnectionType, + DeviceConfig, + DeviceFamilyType, + EncryptType, +) from kasa.discover import Discover from kasa.emeterstatus import EmeterStatus from kasa.exceptions import ( @@ -55,4 +61,8 @@ "AuthenticationException", "UnsupportedDeviceException", "Credentials", + "DeviceConfig", + "ConnectionType", + "EncryptType", + "DeviceFamilyType", ] diff --git a/kasa/aestransport.py b/kasa/aestransport.py index e7dd53568..b6fa34723 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -16,7 +16,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes -from .credentials import Credentials +from .deviceconfig import DeviceConfig from .exceptions import ( SMART_AUTHENTICATION_ERRORS, SMART_RETRYABLE_ERRORS, @@ -47,8 +47,7 @@ class AesTransport(BaseTransport): protocol, sometimes used by newer firmware versions on kasa devices. """ - DEFAULT_PORT = 80 - DEFAULT_TIMEOUT = 5 + DEFAULT_PORT: int = 80 SESSION_COOKIE_NAME = "TP_SESSIONID" COMMON_HEADERS = { "Content-Type": "application/json", @@ -58,32 +57,37 @@ class AesTransport(BaseTransport): def __init__( self, - host: str, *, - port: Optional[int] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, + config: DeviceConfig, ) -> None: - super().__init__( - host, - port=port or self.DEFAULT_PORT, - credentials=credentials, - timeout=timeout, - ) + super().__init__(config=config) + + self._default_http_client: Optional[httpx.AsyncClient] = None self._handshake_done = False self._encryption_session: Optional[AesEncyptionSession] = None self._session_expire_at: Optional[float] = None - self._timeout = timeout if timeout else self.DEFAULT_TIMEOUT self._session_cookie = None - self._http_client: httpx.AsyncClient = httpx.AsyncClient() self._login_token = None _LOGGER.debug("Created AES transport for %s", self._host) + @property + def default_port(self): + """Default port for the transport.""" + return self.DEFAULT_PORT + + @property + def _http_client(self) -> httpx.AsyncClient: + if self._config.http_client: + return self._config.http_client + if not self._default_http_client: + self._default_http_client = httpx.AsyncClient() + return self._default_http_client + def hash_credentials(self, login_v2): """Hash the credentials.""" if login_v2: @@ -102,8 +106,6 @@ def hash_credentials(self, login_v2): async def client_post(self, url, params=None, data=None, json=None, headers=None): """Send an http post request to the device.""" - if not self._http_client: - self._http_client = httpx.AsyncClient() response_data = None cookies = None if self._session_cookie: @@ -268,8 +270,8 @@ async def send(self, request: str): async def close(self) -> None: """Close the protocol.""" - client = self._http_client - self._http_client = None + client = self._default_http_client + self._default_http_client = None self._handshake_done = False self._login_token = None if client: diff --git a/kasa/cli.py b/kasa/cli.py index 3478c35a5..13458b0e0 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -12,15 +12,20 @@ from kasa import ( AuthenticationException, + ConnectionType, Credentials, - DeviceType, + DeviceConfig, + DeviceFamilyType, Discover, + EncryptType, SmartBulb, SmartDevice, + SmartDimmer, + SmartLightStrip, + SmartPlug, SmartStrip, UnsupportedDeviceException, ) -from kasa.device_factory import DEVICE_TYPE_TO_CLASS from kasa.discover import DiscoveryResult try: @@ -49,10 +54,19 @@ def wrapper(message=None, *args, **kwargs): # --json has set it to _nop_echo echo = _do_echo -DEVICE_TYPES = [ - device_type.value - for device_type in DeviceType - if device_type in DEVICE_TYPE_TO_CLASS + +TYPE_TO_CLASS = { + "plug": SmartPlug, + "bulb": SmartBulb, + "dimmer": SmartDimmer, + "strip": SmartStrip, + "lightstrip": SmartLightStrip, +} + +ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in EncryptType] + +DEVICE_FAMILY_TYPES = [ + device_family_type.value for device_family_type in DeviceFamilyType ] click.anyio_backend = "asyncio" @@ -149,7 +163,7 @@ def _device_to_serializable(val: SmartDevice): "--type", envvar="KASA_TYPE", default=None, - type=click.Choice(DEVICE_TYPES, case_sensitive=False), + type=click.Choice(list(TYPE_TO_CLASS), case_sensitive=False), ) @click.option( "--json/--no-json", @@ -158,6 +172,18 @@ def _device_to_serializable(val: SmartDevice): is_flag=True, help="Output raw device response as JSON.", ) +@click.option( + "--encrypt-type", + envvar="KASA_ENCRYPT_TYPE", + default=None, + type=click.Choice(ENCRYPT_TYPES, case_sensitive=False), +) +@click.option( + "--device-family", + envvar="KASA_DEVICE_FAMILY", + default=None, + type=click.Choice(DEVICE_FAMILY_TYPES, case_sensitive=False), +) @click.option( "--timeout", envvar="KASA_TIMEOUT", @@ -199,6 +225,8 @@ async def cli( verbose, debug, type, + encrypt_type, + device_family, json, timeout, discovery_timeout, @@ -270,12 +298,19 @@ def _nop_echo(*args, **kwargs): return await ctx.invoke(discover) if type is not None: - device_type = DeviceType.from_value(type) - dev = await SmartDevice.connect( - host, credentials=credentials, device_type=device_type, timeout=timeout + dev = TYPE_TO_CLASS[type](host) + await dev.update() + elif device_family or encrypt_type: + ctype = ConnectionType( + DeviceFamilyType(device_family), + EncryptType(encrypt_type), ) + config = DeviceConfig( + host=host, credentials=credentials, timeout=timeout, connection_type=ctype + ) + dev = await SmartDevice.connect(config=config) else: - echo("No --type defined, discovering..") + echo("No --type or --device-family and --encrypt-type defined, discovering..") dev = await Discover.discover_single( host, port=port, @@ -332,8 +367,10 @@ async def discover(ctx): target = ctx.parent.params["target"] username = ctx.parent.params["username"] password = ctx.parent.params["password"] - timeout = ctx.parent.params["discovery_timeout"] verbose = ctx.parent.params["verbose"] + discovery_timeout = ctx.parent.params["discovery_timeout"] + timeout = ctx.parent.params["timeout"] + port = ctx.parent.params["port"] credentials = Credentials(username, password) @@ -354,7 +391,7 @@ async def print_unsupported(unsupported_exception: UnsupportedDeviceException): echo(f"\t{unsupported_exception}") echo() - echo(f"Discovering devices on {target} for {timeout} seconds") + echo(f"Discovering devices on {target} for {discovery_timeout} seconds") async def print_discovered(dev: SmartDevice): async with sem: @@ -376,9 +413,11 @@ async def print_discovered(dev: SmartDevice): await Discover.discover( target=target, - timeout=timeout, + discovery_timeout=discovery_timeout, on_discovered=print_discovered, on_unsupported=print_unsupported, + port=port, + timeout=timeout, credentials=credentials, ) diff --git a/kasa/credentials.py b/kasa/credentials.py index a56f5710d..4ae4df356 100644 --- a/kasa/credentials.py +++ b/kasa/credentials.py @@ -8,5 +8,5 @@ class Credentials: """Credentials for authentication.""" - username: Optional[str] = field(default=None, repr=False) - password: Optional[str] = field(default=None, repr=False) + username: Optional[str] = field(default="", repr=False) + password: Optional[str] = field(default="", repr=False) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index d8a07beee..505b64870 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -1,18 +1,21 @@ -"""Device creation by type.""" - +"""Device creation via DeviceConfig.""" import logging import time from typing import Any, Dict, Optional, Tuple, Type from .aestransport import AesTransport -from .credentials import Credentials -from .device_type import DeviceType -from .exceptions import UnsupportedDeviceException +from .deviceconfig import DeviceConfig +from .exceptions import SmartDeviceException, UnsupportedDeviceException from .iotprotocol import IotProtocol -from .klaptransport import KlapTransport, TPlinkKlapTransportV2 -from .protocol import BaseTransport, TPLinkProtocol +from .klaptransport import KlapTransport, KlapTransportV2 +from .protocol import ( + BaseTransport, + TPLinkProtocol, + TPLinkSmartHomeProtocol, + _XorTransport, +) from .smartbulb import SmartBulb -from .smartdevice import SmartDevice, SmartDeviceException +from .smartdevice import SmartDevice from .smartdimmer import SmartDimmer from .smartlightstrip import SmartLightStrip from .smartplug import SmartPlug @@ -20,104 +23,80 @@ from .smartstrip import SmartStrip from .tapo import TapoBulb, TapoPlug -DEVICE_TYPE_TO_CLASS = { - DeviceType.Plug: SmartPlug, - DeviceType.Bulb: SmartBulb, - DeviceType.Strip: SmartStrip, - DeviceType.Dimmer: SmartDimmer, - DeviceType.LightStrip: SmartLightStrip, - DeviceType.TapoPlug: TapoPlug, - DeviceType.TapoBulb: TapoBulb, -} - _LOGGER = logging.getLogger(__name__) +GET_SYSINFO_QUERY = { + "system": {"get_sysinfo": None}, +} -async def connect( - host: str, - *, - port: Optional[int] = None, - timeout=5, - credentials: Optional[Credentials] = None, - device_type: Optional[DeviceType] = None, - protocol_class: Optional[Type[TPLinkProtocol]] = None, -) -> "SmartDevice": - """Connect to a single device by the given IP address. + +async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "SmartDevice": + """Connect to a single device by the given hostname or device configuration. This method avoids the UDP based discovery process and - will connect directly to the device to query its type. + will connect directly to the device. It is generally preferred to avoid :func:`discover_single()` and use this function instead as it should perform better when the WiFi network is congested or the device is not responding to discovery requests. - The device type is discovered by querying the device. + Do not use this function directly, use SmartDevice.connect() :param host: Hostname of device to query - :param device_type: Device type to use for the device. - If not given, the device type is discovered by querying the device. - If the device type is already known, it is preferred to pass it - to avoid the extra query to the device to discover its type. - :param protocol_class: Optionally provide the protocol class - to use. + :param config: Connection parameters to ensure the correct protocol + and connection options are used. :rtype: SmartDevice :return: Object for querying/controlling found device. """ - debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + if host and config or (not host and not config): + raise SmartDeviceException("One of host or config must be provded and not both") + if host: + config = DeviceConfig(host=host) + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) if debug_enabled: start_time = time.perf_counter() - if device_type and (klass := DEVICE_TYPE_TO_CLASS.get(device_type)): - dev: SmartDevice = klass( - host=host, port=port, credentials=credentials, timeout=timeout - ) - if protocol_class is not None: - dev.protocol = protocol_class( - host, - transport=AesTransport( - host, port=port, credentials=credentials, timeout=timeout - ), - ) - await dev.update() + def _perf_log(has_params, perf_type): + nonlocal start_time if debug_enabled: end_time = time.perf_counter() _LOGGER.debug( - "Device %s with known type (%s) took %.2f seconds to connect", - host, - device_type.value, - end_time - start_time, + f"Device {config.host} with connection params {has_params} " + + f"took {end_time - start_time:.2f} seconds to {perf_type}", ) - return dev + start_time = time.perf_counter() - unknown_dev = SmartDevice( - host=host, port=port, credentials=credentials, timeout=timeout - ) - if protocol_class is not None: - # TODO this will be replaced with connection params - unknown_dev.protocol = protocol_class( - host, - transport=AesTransport( - host, port=port, credentials=credentials, timeout=timeout - ), + if (protocol := get_protocol(config=config)) is None: + raise UnsupportedDeviceException( + f"Unsupported device for {config.host}: " + + f"{config.connection_type.device_family.value}" ) - await unknown_dev.update() - device_class = get_device_class_from_sys_info(unknown_dev.internal_state) - dev = device_class(host=host, port=port, credentials=credentials, timeout=timeout) - # Reuse the connection from the unknown device - # so we don't have to reconnect - dev.protocol = unknown_dev.protocol - await dev.update() - if debug_enabled: - end_time = time.perf_counter() - _LOGGER.debug( - "Device %s with unknown type (%s) took %.2f seconds to connect", - host, - dev.device_type.value, - end_time - start_time, + + device_class: Optional[Type[SmartDevice]] + + if isinstance(protocol, TPLinkSmartHomeProtocol): + info = await protocol.query(GET_SYSINFO_QUERY) + _perf_log(True, "get_sysinfo") + device_class = get_device_class_from_sys_info(info) + device = device_class(config.host, protocol=protocol) + device.update_from_discover_info(info) + await device.update() + _perf_log(True, "update") + return device + elif device_class := get_device_class_from_family( + config.connection_type.device_family.value + ): + device = device_class(host=config.host, protocol=protocol) + await device.update() + _perf_log(True, "update") + return device + else: + raise UnsupportedDeviceException( + f"Unsupported device for {config.host}: " + + f"{config.connection_type.device_family.value}" ) - return dev def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[SmartDevice]: @@ -147,32 +126,38 @@ def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[SmartDevice]: raise UnsupportedDeviceException("Unknown device type: %s" % type_) -def get_device_class_from_type_name(device_type: str) -> Optional[Type[SmartDevice]]: +def get_device_class_from_family(device_type: str) -> Optional[Type[SmartDevice]]: """Return the device class from the type name.""" supported_device_types: dict[str, Type[SmartDevice]] = { "SMART.TAPOPLUG": TapoPlug, "SMART.TAPOBULB": TapoBulb, "SMART.KASAPLUG": TapoPlug, "IOT.SMARTPLUGSWITCH": SmartPlug, + "IOT.SMARTBULB": SmartBulb, } return supported_device_types.get(device_type) -def get_protocol_from_connection_name( - connection_name: str, host: str, credentials: Optional[Credentials] = None +def get_protocol( + config: DeviceConfig, ) -> Optional[TPLinkProtocol]: """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]] ] = { + "IOT.XOR": (TPLinkSmartHomeProtocol, _XorTransport), "IOT.KLAP": (IotProtocol, KlapTransport), "SMART.AES": (SmartProtocol, AesTransport), - "SMART.KLAP": (SmartProtocol, TPlinkKlapTransportV2), + "SMART.KLAP": (SmartProtocol, KlapTransportV2), } - if connection_name not in supported_device_protocols: + if protocol_transport_key not in supported_device_protocols: return None - protocol_class, transport_class = supported_device_protocols.get(connection_name) # type: ignore - transport: BaseTransport = transport_class(host, credentials=credentials) - protocol: TPLinkProtocol = protocol_class(host, transport=transport) - return protocol + protocol_class, transport_class = supported_device_protocols.get( + protocol_transport_key + ) # type: ignore + return protocol_class(transport=transport_class(config=config)) diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py new file mode 100644 index 000000000..7a774b2ea --- /dev/null +++ b/kasa/deviceconfig.py @@ -0,0 +1,148 @@ +"""Module for holding connection parameters.""" +import logging +from dataclasses import asdict, dataclass, field, fields, is_dataclass +from enum import Enum +from typing import Dict, Optional + +import httpx + +from .credentials import Credentials +from .exceptions import SmartDeviceException + +_LOGGER = logging.getLogger(__name__) + + +class EncryptType(Enum): + """Encrypt type enum.""" + + Klap = "KLAP" + Aes = "AES" + Xor = "XOR" + + +class DeviceFamilyType(Enum): + """Encrypt type enum.""" + + IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH" + IotSmartBulb = "IOT.SMARTBULB" + SmartKasaPlug = "SMART.KASAPLUG" + SmartTapoPlug = "SMART.TAPOPLUG" + SmartTapoBulb = "SMART.TAPOBULB" + + +def _dataclass_from_dict(klass, in_val): + if is_dataclass(klass): + fieldtypes = {f.name: f.type for f in fields(klass)} + val = {} + for dict_key in in_val: + if dict_key in fieldtypes and hasattr(fieldtypes[dict_key], "from_dict"): + val[dict_key] = fieldtypes[dict_key].from_dict(in_val[dict_key]) + else: + val[dict_key] = _dataclass_from_dict( + fieldtypes[dict_key], in_val[dict_key] + ) + return klass(**val) + else: + return in_val + + +def _dataclass_to_dict(in_val): + fieldtypes = {f.name: f.type for f in fields(in_val) if f.compare} + out_val = {} + for field_name in fieldtypes: + val = getattr(in_val, field_name) + if val is None: + continue + elif hasattr(val, "to_dict"): + out_val[field_name] = val.to_dict() + elif is_dataclass(fieldtypes[field_name]): + out_val[field_name] = asdict(val) + else: + out_val[field_name] = val + return out_val + + +@dataclass +class ConnectionType: + """Class to hold the the parameters determining connection type.""" + + device_family: DeviceFamilyType + encryption_type: EncryptType + + @staticmethod + def from_values( + device_family: str, + encryption_type: str, + ) -> "ConnectionType": + """Return connection parameters from string values.""" + try: + return ConnectionType( + DeviceFamilyType(device_family), + EncryptType(encryption_type), + ) + except ValueError as ex: + raise SmartDeviceException( + f"Invalid connection parameters for {device_family}.{encryption_type}" + ) from ex + + @staticmethod + def from_dict(connection_type_dict: Dict[str, str]) -> "ConnectionType": + """Return connection parameters from dict.""" + if ( + isinstance(connection_type_dict, dict) + and (device_family := connection_type_dict.get("device_family")) + and (encryption_type := connection_type_dict.get("encryption_type")) + ): + return ConnectionType.from_values(device_family, encryption_type) + + raise SmartDeviceException( + f"Invalid connection type data for {connection_type_dict}" + ) + + def to_dict(self) -> Dict[str, str]: + """Convert connection params to dict.""" + result = { + "device_family": self.device_family.value, + "encryption_type": self.encryption_type.value, + } + return result + + +@dataclass +class DeviceConfig: + """Class to represent paramaters that determine how to connect to devices.""" + + DEFAULT_TIMEOUT = 5 + + host: str + timeout: Optional[int] = DEFAULT_TIMEOUT + port_override: Optional[int] = None + credentials: Credentials = field( + default_factory=lambda: Credentials(username="", password="") + ) + connection_type: ConnectionType = field( + default_factory=lambda: ConnectionType( + DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Xor + ) + ) + + uses_http: bool = False + # compare=False will be excluded from the serialization and object comparison. + http_client: Optional[httpx.AsyncClient] = field(default=None, compare=False) + + def __post_init__(self): + if self.credentials is None: + self.credentials = Credentials(username="", password="") + if self.connection_type is None: + self.connection_type = ConnectionType( + DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Xor + ) + + def to_dict(self) -> Dict[str, Dict[str, str]]: + """Convert connection params to dict.""" + return _dataclass_to_dict(self) + + @staticmethod + def from_dict(cparam_dict: Dict[str, Dict[str, str]]) -> "DeviceConfig": + """Return connection parameters from dict.""" + return _dataclass_from_dict(DeviceConfig, cparam_dict) diff --git a/kasa/discover.py b/kasa/discover.py index 4ec3775e9..e39122f3b 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -1,5 +1,6 @@ """Discovery module for TP-Link Smart Home devices.""" import asyncio +import base64 import binascii import ipaddress import logging @@ -11,29 +12,32 @@ from async_timeout import timeout as asyncio_timeout try: - from pydantic.v1 import BaseModel, Field + from pydantic.v1 import BaseModel, ValidationError # pragma: no cover except ImportError: - from pydantic import BaseModel, Field + from pydantic import BaseModel, ValidationError # pragma: no cover from kasa.credentials import Credentials +from kasa.device_factory import ( + get_device_class_from_family, + get_device_class_from_sys_info, + get_protocol, +) +from kasa.deviceconfig import ConnectionType, DeviceConfig, EncryptType from kasa.exceptions import UnsupportedDeviceException from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartdevice import SmartDevice, SmartDeviceException -from .device_factory import ( - get_device_class_from_sys_info, - get_device_class_from_type_name, - get_protocol_from_connection_name, -) - _LOGGER = logging.getLogger(__name__) OnDiscoveredCallable = Callable[[SmartDevice], Awaitable[None]] DeviceDict = Dict[str, SmartDevice] +UNAVAILABLE_ALIAS = "Authentication required" +UNAVAILABLE_NICKNAME = base64.b64encode(UNAVAILABLE_ALIAS.encode()).decode() + class _DiscoverProtocol(asyncio.DatagramProtocol): """Implementation of the discovery protocol handler. @@ -62,9 +66,12 @@ def __init__( self.discovery_packets = discovery_packets self.interface = interface self.on_discovered = on_discovered + + self.port = port self.discovery_port = port or Discover.DISCOVERY_PORT self.target = (target, self.discovery_port) self.target_2 = (target, Discover.DISCOVERY_PORT_2) + self.discovered_devices = {} self.unsupported_device_exceptions: Dict = {} self.invalid_device_exceptions: Dict = {} @@ -110,13 +117,18 @@ def datagram_received(self, data, addr) -> None: self.seen_hosts.add(ip) device = None + + config = DeviceConfig(host=ip, port_override=self.port) + if self.credentials: + config.credentials = self.credentials + if self.timeout: + config.timeout = self.timeout try: if port == self.discovery_port: - device = Discover._get_device_instance_legacy(data, ip, port) + device = Discover._get_device_instance_legacy(data, config) elif port == Discover.DISCOVERY_PORT_2: - device = Discover._get_device_instance( - data, ip, port, self.credentials or Credentials() - ) + config.uses_http = True + device = Discover._get_device_instance(data, config) else: return except UnsupportedDeviceException as udex: @@ -200,11 +212,13 @@ async def discover( *, target="255.255.255.255", on_discovered=None, - timeout=5, + discovery_timeout=5, discovery_packets=3, interface=None, on_unsupported=None, credentials=None, + port=None, + timeout=None, ) -> DeviceDict: """Discover supported devices. @@ -240,14 +254,15 @@ async def discover( on_unsupported=on_unsupported, credentials=credentials, timeout=timeout, + port=port, ), local_addr=("0.0.0.0", 0), # noqa: S104 ) protocol = cast(_DiscoverProtocol, protocol) try: - _LOGGER.debug("Waiting %s seconds for responses...", timeout) - await asyncio.sleep(timeout) + _LOGGER.debug("Waiting %s seconds for responses...", discovery_timeout) + await asyncio.sleep(discovery_timeout) finally: transport.close() @@ -259,10 +274,10 @@ async def discover( async def discover_single( host: str, *, + discovery_timeout: int = 5, port: Optional[int] = None, - timeout=5, + timeout: Optional[int] = None, credentials: Optional[Credentials] = None, - update_parent_devices: bool = True, ) -> SmartDevice: """Discover a single device by the given IP address. @@ -275,8 +290,6 @@ async def discover_single( :param port: Optionally set a different port for the device :param timeout: Timeout for discovery :param credentials: Credentials for devices that require authentication - :param update_parent_devices: Automatically call device.update() on - devices that have children :rtype: SmartDevice :return: Object for querying/controlling found device. """ @@ -320,9 +333,11 @@ async def discover_single( protocol = cast(_DiscoverProtocol, protocol) try: - _LOGGER.debug("Waiting a total of %s seconds for responses...", timeout) + _LOGGER.debug( + "Waiting a total of %s seconds for responses...", discovery_timeout + ) - async with asyncio_timeout(timeout): + async with asyncio_timeout(discovery_timeout): await event.wait() except asyncio.TimeoutError as ex: raise SmartDeviceException( @@ -334,9 +349,6 @@ async def discover_single( if ip in protocol.discovered_devices: dev = protocol.discovered_devices[ip] dev.host = host - # Call device update on devices that have children - if update_parent_devices and dev.has_children: - await dev.update() return dev elif ip in protocol.unsupported_device_exceptions: raise protocol.unsupported_device_exceptions[ip] @@ -350,99 +362,121 @@ def _get_device_class(info: dict) -> Type[SmartDevice]: """Find SmartDevice subclass for device described by passed data.""" if "result" in info: discovery_result = DiscoveryResult(**info["result"]) - dev_class = get_device_class_from_type_name(discovery_result.device_type) + dev_class = get_device_class_from_family(discovery_result.device_type) if not dev_class: raise UnsupportedDeviceException( - "Unknown device type: %s" % discovery_result.device_type + "Unknown device type: %s" % discovery_result.device_type, + discovery_result=info, ) return dev_class else: return get_device_class_from_sys_info(info) @staticmethod - def _get_device_instance_legacy(data: bytes, ip: str, port: int) -> SmartDevice: + def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> SmartDevice: """Get SmartDevice from legacy 9999 response.""" try: info = json_loads(TPLinkSmartHomeProtocol.decrypt(data)) except Exception as ex: raise SmartDeviceException( - f"Unable to read response from device: {ip}: {ex}" + f"Unable to read response from device: {config.host}: {ex}" ) from ex - _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) + _LOGGER.debug("[DISCOVERY] %s << %s", config.host, info) device_class = Discover._get_device_class(info) - device = device_class(ip, port=port) + device = device_class(config.host, config=config) + sys_info = info["system"]["get_sysinfo"] + if device_type := sys_info.get("mic_type", sys_info.get("type")): + config.connection_type = ConnectionType.from_values( + device_family=device_type, encryption_type=EncryptType.Xor.value + ) + device.protocol = get_protocol(config) # type: ignore[assignment] device.update_from_discover_info(info) return device @staticmethod def _get_device_instance( - data: bytes, ip: str, port: int, credentials: Credentials + data: bytes, + config: DeviceConfig, ) -> SmartDevice: """Get SmartDevice from the new 20002 response.""" try: info = json_loads(data[16:]) - discovery_result = DiscoveryResult(**info["result"]) except Exception as ex: + _LOGGER.debug("Got invalid response from device %s: %s", config.host, data) + raise SmartDeviceException( + f"Unable to read response from device: {config.host}: {ex}" + ) from ex + try: + discovery_result = DiscoveryResult(**info["result"]) + except ValidationError as ex: + _LOGGER.debug( + "Unable to parse discovery from device %s: %s", config.host, info + ) raise UnsupportedDeviceException( - f"Unable to read response from device: {ip}: {ex}" + f"Unable to parse discovery from device: {config.host}: {ex}" ) from ex type_ = discovery_result.device_type - encrypt_type_ = ( - f"{type_.split('.')[0]}.{discovery_result.mgt_encrypt_schm.encrypt_type}" - ) - if (device_class := get_device_class_from_type_name(type_)) is None: + try: + config.connection_type = ConnectionType.from_values( + type_, discovery_result.mgt_encrypt_schm.encrypt_type + ) + except SmartDeviceException as ex: + raise UnsupportedDeviceException( + f"Unsupported device {config.host} of type {type_} " + + f"with encrypt_type {discovery_result.mgt_encrypt_schm.encrypt_type}", + discovery_result=discovery_result.get_dict(), + ) from ex + if (device_class := get_device_class_from_family(type_)) is None: _LOGGER.warning("Got unsupported device type: %s", type_) raise UnsupportedDeviceException( - f"Unsupported device {ip} of type {type_}: {info}", + f"Unsupported device {config.host} of type {type_}: {info}", discovery_result=discovery_result.get_dict(), ) - if ( - protocol := get_protocol_from_connection_name( - encrypt_type_, ip, credentials=credentials + if (protocol := get_protocol(config)) is None: + _LOGGER.warning( + "Got unsupported connection type: %s", config.connection_type.to_dict() ) - ) is None: - _LOGGER.warning("Got unsupported device type: %s", encrypt_type_) raise UnsupportedDeviceException( - f"Unsupported encryption scheme {ip} of type {encrypt_type_}: {info}", + f"Unsupported encryption scheme {config.host} of " + + f"type {config.connection_type.to_dict()}: {info}", discovery_result=discovery_result.get_dict(), ) - _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) - device = device_class(ip, port=port, credentials=credentials) - device.protocol = protocol - device.update_from_discover_info(discovery_result.get_dict()) + _LOGGER.debug("[DISCOVERY] %s << %s", config.host, info) + device = device_class(config.host, protocol=protocol) + + di = discovery_result.get_dict() + di["model"] = discovery_result.device_model + di["alias"] = UNAVAILABLE_ALIAS + di["nickname"] = UNAVAILABLE_NICKNAME + device.update_from_discover_info(di) return device class DiscoveryResult(BaseModel): """Base model for discovery result.""" - class Config: - """Class for configuring model behaviour.""" - - allow_population_by_field_name = True - class EncryptionScheme(BaseModel): """Base model for encryption scheme of discovery result.""" - is_support_https: Optional[bool] = None - encrypt_type: Optional[str] = None - http_port: Optional[int] = None - lv: Optional[int] = 1 + is_support_https: bool + encrypt_type: str + http_port: int + lv: Optional[int] = None - device_type: str = Field(alias="device_type_text") - device_model: str = Field(alias="model") - ip: str = Field(alias="alias") + device_type: str + device_model: str + ip: str mac: str mgt_encrypt_schm: EncryptionScheme + device_id: str - device_id: Optional[str] = Field(default=None, alias="device_id_hash") - owner: Optional[str] = Field(default=None, alias="device_owner_hash") hw_ver: Optional[str] = None + owner: Optional[str] = None is_support_iot_cloud: Optional[bool] = None obd_src: Optional[str] = None factory_default: Optional[bool] = None @@ -453,5 +487,5 @@ def get_dict(self) -> dict: containing only the values actually set and with aliases as field names. """ return self.dict( - by_alias=True, exclude_unset=True, exclude_none=True, exclude_defaults=True + by_alias=False, exclude_unset=True, exclude_none=True, exclude_defaults=True ) diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index fbb37b15a..470f40552 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -17,12 +17,11 @@ class IotProtocol(TPLinkProtocol): def __init__( self, - host: str, *, transport: BaseTransport, ) -> None: """Create a protocol object.""" - super().__init__(host, transport=transport) + super().__init__(transport=transport) self._query_lock = asyncio.Lock() @@ -39,25 +38,21 @@ async def _query(self, request: str, retry_count: int = 3) -> Dict: for retry in range(retry_count + 1): try: return await self._execute_query(request, retry) - except httpx.CloseError as sdex: - await self.close() + except httpx.ConnectError as sdex: if retry >= retry_count: + await self.close() _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise SmartDeviceException( f"Unable to connect to the device: {self._host}: {sdex}" ) from sdex continue - except httpx.ConnectError as cex: - await self.close() - raise SmartDeviceException( - f"Unable to connect to the device: {self._host}: {cex}" - ) from cex except TimeoutError as tex: await self.close() raise SmartDeviceException( f"Unable to connect to the device, timed out: {self._host}: {tex}" ) from tex except AuthenticationException as auex: + await self.close() _LOGGER.debug( "Unable to authenticate with %s, not retrying", self._host ) @@ -70,8 +65,8 @@ async def _query(self, request: str, retry_count: int = 3) -> Dict: ) raise ex except Exception as ex: - await self.close() if retry >= retry_count: + await self.close() _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise SmartDeviceException( f"Unable to connect to the device: {self._host}: {ex}" diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index e7bb8ae6c..0e7ef565a 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -54,6 +54,7 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from .credentials import Credentials +from .deviceconfig import DeviceConfig from .exceptions import AuthenticationException, SmartDeviceException from .json import loads as json_loads from .protocol import BaseTransport, md5 @@ -82,27 +83,21 @@ class KlapTransport(BaseTransport): protocol, used by newer firmware versions. """ - DEFAULT_PORT = 80 + DEFAULT_PORT: int = 80 DISCOVERY_QUERY = {"system": {"get_sysinfo": None}} + KASA_SETUP_EMAIL = "kasa@tp-link.net" KASA_SETUP_PASSWORD = "kasaSetup" # noqa: S105 SESSION_COOKIE_NAME = "TP_SESSIONID" def __init__( self, - host: str, *, - port: Optional[int] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, + config: DeviceConfig, ) -> None: - super().__init__( - host, - port=port or self.DEFAULT_PORT, - credentials=credentials, - timeout=timeout, - ) + super().__init__(config=config) + self._default_http_client: Optional[httpx.AsyncClient] = None self._local_seed: Optional[bytes] = None self._local_auth_hash = self.generate_auth_hash(self._credentials) self._local_auth_owner = self.generate_owner_hash(self._credentials).hex() @@ -116,14 +111,24 @@ def __init__( self._session_expire_at: Optional[float] = None self._session_cookie = None - self._http_client: httpx.AsyncClient = httpx.AsyncClient() _LOGGER.debug("Created KLAP transport for %s", self._host) + @property + def default_port(self): + """Default port for the transport.""" + return self.DEFAULT_PORT + + @property + def _http_client(self) -> httpx.AsyncClient: + if self._config.http_client: + return self._config.http_client + if not self._default_http_client: + self._default_http_client = httpx.AsyncClient() + return self._default_http_client + async def client_post(self, url, params=None, data=None): """Send an http post request to the device.""" - if not self._http_client: - self._http_client = httpx.AsyncClient() response_data = None cookies = None if self._session_cookie: @@ -355,8 +360,8 @@ async def send(self, request: str): async def close(self) -> None: """Close the transport.""" - client = self._http_client - self._http_client = None + client = self._default_http_client + self._default_http_client = None self._handshake_done = False if client: await client.aclose() @@ -390,7 +395,7 @@ def generate_owner_hash(creds: Credentials): return md5(un.encode()) -class TPlinkKlapTransportV2(KlapTransport): +class KlapTransportV2(KlapTransport): """Implementation of the KLAP encryption protocol with v2 hanshake hashes.""" @staticmethod diff --git a/kasa/protocol.py b/kasa/protocol.py index f73260bf0..c998807c5 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -24,7 +24,7 @@ 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 from .json import loads as json_loads @@ -48,17 +48,20 @@ class BaseTransport(ABC): def __init__( self, - host: str, *, - port: Optional[int] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, + config: DeviceConfig, ) -> None: """Create a protocol object.""" - self._host = host - self._port = port - self._credentials = credentials or Credentials(username="", password="") - self._timeout = timeout or self.DEFAULT_TIMEOUT + self._config = config + self._host = config.host + self._port = config.port_override or self.default_port + self._credentials = config.credentials + self._timeout = config.timeout + + @property + @abstractmethod + def default_port(self) -> int: + """The default port for the transport.""" @abstractmethod async def send(self, request: str) -> Dict: @@ -74,7 +77,6 @@ class TPLinkProtocol(ABC): def __init__( self, - host: str, *, transport: BaseTransport, ) -> None: @@ -85,6 +87,11 @@ def __init__( def _host(self): return self._transport._host + @property + def config(self) -> DeviceConfig: + """Return the connection parameters the device is using.""" + return self._transport._config + @abstractmethod async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: """Query the device for the protocol. Abstract method to be overriden.""" @@ -103,22 +110,15 @@ class _XorTransport(BaseTransport): class. """ - DEFAULT_PORT = 9999 + DEFAULT_PORT: int = 9999 - def __init__( - self, - host: str, - *, - port: Optional[int] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, - ) -> None: - super().__init__( - host, - port=port or self.DEFAULT_PORT, - credentials=credentials, - timeout=timeout, - ) + def __init__(self, *, config: DeviceConfig) -> None: + super().__init__(config=config) + + @property + def default_port(self): + """Default port for the transport.""" + return self.DEFAULT_PORT async def send(self, request: str) -> Dict: """Send a message to the device and return a response.""" @@ -133,17 +133,15 @@ class TPLinkSmartHomeProtocol(TPLinkProtocol): INITIALIZATION_VECTOR = 171 DEFAULT_PORT = 9999 - DEFAULT_TIMEOUT = 5 BLOCK_SIZE = 4 def __init__( self, - host: str, *, transport: BaseTransport, ) -> None: """Create a protocol object.""" - super().__init__(host, transport=transport) + super().__init__(transport=transport) self.reader: Optional[asyncio.StreamReader] = None self.writer: Optional[asyncio.StreamWriter] = None @@ -167,7 +165,7 @@ async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: assert isinstance(request, str) # noqa: S101 async with self.query_lock: - return await self._query(request, retry_count, self._timeout) + return await self._query(request, retry_count, self._timeout) # type: ignore[arg-type] async def _connect(self, timeout: int) -> None: """Try to connect or reconnect to the device.""" diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index 6dd4513c6..8897ceceb 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -9,8 +9,9 @@ except ImportError: from pydantic import BaseModel, Field, root_validator -from .credentials import Credentials +from .deviceconfig import DeviceConfig from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage +from .protocol import TPLinkProtocol from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update @@ -220,11 +221,10 @@ def __init__( self, host: str, *, - port: Optional[int] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, + config: Optional[DeviceConfig] = None, + protocol: Optional[TPLinkProtocol] = None, ) -> None: - super().__init__(host=host, port=port, credentials=credentials, timeout=timeout) + super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Bulb self.add_module("schedule", Schedule(self, "smartlife.iot.common.schedule")) self.add_module("usage", Usage(self, "smartlife.iot.common.schedule")) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 5ad94a9f4..97b46ddca 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -21,6 +21,7 @@ from .credentials import Credentials from .device_type import DeviceType +from .deviceconfig import DeviceConfig from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException from .modules import Emeter, Module @@ -191,20 +192,18 @@ def __init__( self, host: str, *, - port: Optional[int] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, + config: Optional[DeviceConfig] = None, + protocol: Optional[TPLinkProtocol] = None, ) -> None: """Create a new SmartDevice instance. :param str host: host name or ip address on which the device listens """ - self.host = host - self.port = port - self.protocol: TPLinkProtocol = TPLinkSmartHomeProtocol( - host, transport=_XorTransport(host, port=port, timeout=timeout) + if config and protocol: + protocol._transport._config = config + self.protocol: TPLinkProtocol = protocol or TPLinkSmartHomeProtocol( + transport=_XorTransport(config=config or DeviceConfig(host=host)), ) - self.credentials = credentials _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) self._device_type = DeviceType.Unknown # TODO: typing Any is just as using Optional[Dict] would require separate @@ -219,6 +218,30 @@ def __init__( self.children: List["SmartDevice"] = [] + @property + def host(self) -> str: + """The device host.""" + return self.protocol._transport._host + + @host.setter + def host(self, value): + """Set the device host. + + Generally used by discovery to set the hostname after ip discovery. + """ + self.protocol._transport._host = value + self.protocol._transport._config.host = value + + @property + def port(self) -> int: + """The device port.""" + return self.protocol._transport._port + + @property + def credentials(self) -> Optional[Credentials]: + """The device credentials.""" + return self.protocol._transport._credentials + def add_module(self, name: str, module: Module): """Register a module.""" if name in self.modules: @@ -760,7 +783,7 @@ def internal_state(self) -> Any: The returned object contains the raw results from the last update call. This should only be used for debugging purposes. """ - return self._last_update + return self._last_update or self._discovery_info def __repr__(self): if self._last_update is None: @@ -771,41 +794,33 @@ def __repr__(self): f" - dev specific: {self.state_information}>" ) + @property + def config(self) -> DeviceConfig: + """Return the connection parameters the device is using.""" + return self.protocol.config + @staticmethod async def connect( - host: str, *, - port: Optional[int] = None, - timeout=5, - credentials: Optional[Credentials] = None, - device_type: Optional[DeviceType] = None, + host: Optional[str] = None, + config: Optional[DeviceConfig] = None, ) -> "SmartDevice": - """Connect to a single device by the given IP address. + """Connect to a single device by the given hostname or device configuration. This method avoids the UDP based discovery process and - will connect directly to the device to query its type. + will connect directly to the device. It is generally preferred to avoid :func:`discover_single()` and use this function instead as it should perform better when the WiFi network is congested or the device is not responding to discovery requests. - The device type is discovered by querying the device. - :param host: Hostname of device to query - :param device_type: Device type to use for the device. - If not given, the device type is discovered by querying the device. - If the device type is already known, it is preferred to pass it - to avoid the extra query to the device to discover its type. + :param config: Connection parameters to ensure the correct protocol + and connection options are used. :rtype: SmartDevice :return: Object for querying/controlling found device. """ from .device_factory import connect # pylint: disable=import-outside-toplevel - return await connect( - host=host, - port=port, - timeout=timeout, - credentials=credentials, - device_type=device_type, - ) + return await connect(host=host, config=config) # type: ignore[arg-type] diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py index 7980319c7..ca0960f11 100644 --- a/kasa/smartdimmer.py +++ b/kasa/smartdimmer.py @@ -2,8 +2,9 @@ from enum import Enum from typing import Any, Dict, Optional -from kasa.credentials import Credentials +from kasa.deviceconfig import DeviceConfig from kasa.modules import AmbientLight, Motion +from kasa.protocol import TPLinkProtocol from kasa.smartdevice import DeviceType, SmartDeviceException, requires_update from kasa.smartplug import SmartPlug @@ -68,11 +69,10 @@ def __init__( self, host: str, *, - port: Optional[int] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, + config: Optional[DeviceConfig] = None, + protocol: Optional[TPLinkProtocol] = None, ) -> None: - super().__init__(host, port=port, credentials=credentials, timeout=timeout) + super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Dimmer # TODO: need to be verified if it's okay to call these on HS220 w/o these # TODO: need to be figured out what's the best approach to detect support diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py index 2990e1fa4..27ebf8381 100644 --- a/kasa/smartlightstrip.py +++ b/kasa/smartlightstrip.py @@ -1,8 +1,9 @@ """Module for light strips (KL430).""" from typing import Any, Dict, List, Optional -from .credentials import Credentials +from .deviceconfig import DeviceConfig from .effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 +from .protocol import TPLinkProtocol from .smartbulb import SmartBulb from .smartdevice import DeviceType, SmartDeviceException, requires_update @@ -46,11 +47,10 @@ def __init__( self, host: str, *, - port: Optional[int] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, + config: Optional[DeviceConfig] = None, + protocol: Optional[TPLinkProtocol] = None, ) -> None: - super().__init__(host, port=port, credentials=credentials, timeout=timeout) + super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.LightStrip @property # type: ignore diff --git a/kasa/smartplug.py b/kasa/smartplug.py index 4ba230b49..d9ac0c863 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -2,8 +2,9 @@ import logging from typing import Any, Dict, Optional -from kasa.credentials import Credentials +from kasa.deviceconfig import DeviceConfig from kasa.modules import Antitheft, Cloud, Schedule, Time, Usage +from kasa.protocol import TPLinkProtocol from kasa.smartdevice import DeviceType, SmartDevice, requires_update _LOGGER = logging.getLogger(__name__) @@ -43,11 +44,10 @@ def __init__( self, host: str, *, - port: Optional[int] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, + config: Optional[DeviceConfig] = None, + protocol: Optional[TPLinkProtocol] = None, ) -> None: - super().__init__(host, port=port, credentials=credentials, timeout=timeout) + super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug self.add_module("schedule", Schedule(self, "schedule")) self.add_module("usage", Usage(self, "schedule")) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 443d1def1..97573d933 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -38,12 +38,11 @@ class SmartProtocol(TPLinkProtocol): def __init__( self, - host: str, *, transport: BaseTransport, ) -> None: """Create a protocol object.""" - super().__init__(host, transport=transport) + super().__init__(transport=transport) self._terminal_uuid: str = base64.b64encode(md5(uuid.uuid4().bytes)).decode() self._request_id_generator = SnowflakeId(1, 1) self._query_lock = asyncio.Lock() @@ -68,19 +67,14 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: for retry in range(retry_count + 1): try: return await self._execute_query(request, retry) - except httpx.CloseError as sdex: - await self.close() + except httpx.ConnectError as sdex: if retry >= retry_count: + await self.close() _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise SmartDeviceException( f"Unable to connect to the device: {self._host}: {sdex}" ) from sdex continue - except httpx.ConnectError as cex: - await self.close() - raise SmartDeviceException( - f"Unable to connect to the device: {self._host}: {cex}" - ) from cex except TimeoutError as tex: if retry >= retry_count: await self.close() diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 80aa27d1b..793931325 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -14,8 +14,9 @@ ) from kasa.smartplug import SmartPlug -from .credentials import Credentials +from .deviceconfig import DeviceConfig from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage +from .protocol import TPLinkProtocol _LOGGER = logging.getLogger(__name__) @@ -85,11 +86,10 @@ def __init__( self, host: str, *, - port: Optional[int] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, + config: Optional[DeviceConfig] = None, + protocol: Optional[TPLinkProtocol] = None, ) -> None: - super().__init__(host=host, port=port, credentials=credentials, timeout=timeout) + super().__init__(host=host, config=config, protocol=protocol) self.emeter_type = "emeter" self._device_type = DeviceType.Strip self.add_module("antitheft", Antitheft(self, "anti_theft")) diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 97405b3f1..717de7ef4 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -5,8 +5,9 @@ from typing import Any, Dict, Optional, Set, cast from ..aestransport import AesTransport -from ..credentials import Credentials +from ..deviceconfig import DeviceConfig from ..exceptions import AuthenticationException +from ..protocol import TPLinkProtocol from ..smartdevice import SmartDevice from ..smartprotocol import SmartProtocol @@ -20,20 +21,16 @@ def __init__( self, host: str, *, - port: Optional[int] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, + config: Optional[DeviceConfig] = None, + protocol: Optional[TPLinkProtocol] = None, ) -> None: - super().__init__(host, port=port, credentials=credentials, timeout=timeout) + _protocol = protocol or SmartProtocol( + transport=AesTransport(config=config or DeviceConfig(host=host)), + ) + super().__init__(host=host, config=config, protocol=_protocol) self._components: Optional[Dict[str, Any]] = None self._state_information: Dict[str, Any] = {} self._discovery_info: Optional[Dict[str, Any]] = None - self.protocol = SmartProtocol( - host, - transport=AesTransport( - host, credentials=credentials, timeout=timeout, port=port - ), - ) async def update(self, update_children: bool = True): """Update the device.""" @@ -66,7 +63,7 @@ async def update(self, update_children: bool = True): @property def sys_info(self) -> Dict[str, Any]: """Returns the device info.""" - return self._info + return self._info # type: ignore @property def model(self) -> str: @@ -180,3 +177,4 @@ async def turn_off(self, **kwargs): def update_from_discover_info(self, info): """Update state from info from the discover call.""" self._discovery_info = info + self._info = info diff --git a/kasa/tapo/tapoplug.py b/kasa/tapo/tapoplug.py index 9d868253e..67aed565a 100644 --- a/kasa/tapo/tapoplug.py +++ b/kasa/tapo/tapoplug.py @@ -3,9 +3,10 @@ from datetime import datetime, timedelta from typing import Any, Dict, Optional, cast -from ..credentials import Credentials +from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..modules import Emeter +from ..protocol import TPLinkProtocol from ..smartdevice import DeviceType, requires_update from .tapodevice import TapoDevice @@ -19,11 +20,10 @@ def __init__( self, host: str, *, - port: Optional[int] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, + config: Optional[DeviceConfig] = None, + protocol: Optional[TPLinkProtocol] = None, ) -> None: - super().__init__(host, port=port, credentials=credentials, timeout=timeout) + super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug self.modules: Dict[str, Any] = {} self.emeter_type = "emeter" diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 43bba825b..11efe6937 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -388,7 +388,6 @@ def load_file(): d = device_for_file(model, protocol)(host="127.0.0.123") if protocol == "SMART": d.protocol = FakeSmartProtocol(sysinfo) - d.credentials = Credentials("", "") else: d.protocol = FakeTransportProtocol(sysinfo) await _update_and_close(d) @@ -426,28 +425,53 @@ def discovery_mock(all_fixture_data, mocker): class _DiscoveryMock: ip: str default_port: int + discovery_port: int discovery_data: dict query_data: dict + device_type: str + encrypt_type: str port_override: Optional[int] = None if "discovery_result" in all_fixture_data: discovery_data = {"result": all_fixture_data["discovery_result"]} + device_type = all_fixture_data["discovery_result"]["device_type"] + encrypt_type = all_fixture_data["discovery_result"]["mgt_encrypt_schm"][ + "encrypt_type" + ] datagram = ( b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + json_dumps(discovery_data).encode() ) - dm = _DiscoveryMock("127.0.0.123", 20002, discovery_data, all_fixture_data) + dm = _DiscoveryMock( + "127.0.0.123", + 80, + 20002, + discovery_data, + all_fixture_data, + device_type, + encrypt_type, + ) else: sys_info = all_fixture_data["system"]["get_sysinfo"] discovery_data = {"system": {"get_sysinfo": sys_info}} + device_type = sys_info.get("mic_type") or sys_info.get("type") + encrypt_type = "XOR" datagram = TPLinkSmartHomeProtocol.encrypt(json_dumps(discovery_data))[4:] - dm = _DiscoveryMock("127.0.0.123", 9999, discovery_data, all_fixture_data) + dm = _DiscoveryMock( + "127.0.0.123", + 9999, + 9999, + discovery_data, + all_fixture_data, + device_type, + encrypt_type, + ) def mock_discover(self): port = ( dm.port_override - if dm.port_override and dm.default_port != 20002 - else dm.default_port + if dm.port_override and dm.discovery_port != 20002 + else dm.discovery_port ) self.datagram_received( datagram, diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index cd7ad4fd9..13d11d3d9 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -15,7 +15,9 @@ Schema, ) -from ..protocol import BaseTransport, TPLinkSmartHomeProtocol +from ..credentials import Credentials +from ..deviceconfig import DeviceConfig +from ..protocol import BaseTransport, TPLinkSmartHomeProtocol, _XorTransport from ..smartprotocol import SmartProtocol _LOGGER = logging.getLogger(__name__) @@ -290,7 +292,9 @@ def success(res): class FakeSmartProtocol(SmartProtocol): def __init__(self, info): - super().__init__("127.0.0.123", transport=FakeSmartTransport(info)) + super().__init__( + transport=FakeSmartTransport(info), + ) async def query(self, request, retry_count: int = 3): """Implement query here so can still patch SmartProtocol.query.""" @@ -301,10 +305,15 @@ async def query(self, request, retry_count: int = 3): class FakeSmartTransport(BaseTransport): def __init__(self, info): super().__init__( - "127.0.0.123", + config=DeviceConfig("127.0.0.123", credentials=Credentials()), ) self.info = info + @property + def default_port(self): + """Default port for the transport.""" + return 80 + async def send(self, request: str): request_dict = json_loads(request) method = request_dict["method"] @@ -344,6 +353,11 @@ async def close(self) -> None: class FakeTransportProtocol(TPLinkSmartHomeProtocol): def __init__(self, info): + super().__init__( + transport=_XorTransport( + config=DeviceConfig("127.0.0.123"), + ) + ) self.discovery_data = info self.writer = None self.reader = None diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 198e8f39e..faf47a75e 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -12,6 +12,7 @@ from ..aestransport import AesEncyptionSession, AesTransport from ..credentials import Credentials +from ..deviceconfig import DeviceConfig from ..exceptions import ( SMART_RETRYABLE_ERRORS, SMART_TIMEOUT_ERRORS, @@ -58,7 +59,9 @@ async def test_handshake( mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code) mocker.patch.object(httpx.AsyncClient, "post", side_effect=mock_aes_device.post) - transport = AesTransport(host=host, credentials=Credentials("foo", "bar")) + transport = AesTransport( + config=DeviceConfig(host, credentials=Credentials("foo", "bar")) + ) assert transport._encryption_session is None assert transport._handshake_done is False @@ -74,7 +77,9 @@ async def test_login(mocker, status_code, error_code, inner_error_code, expectat mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code) mocker.patch.object(httpx.AsyncClient, "post", side_effect=mock_aes_device.post) - transport = AesTransport(host=host, credentials=Credentials("foo", "bar")) + transport = AesTransport( + config=DeviceConfig(host, credentials=Credentials("foo", "bar")) + ) transport._handshake_done = True transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session @@ -91,13 +96,14 @@ async def test_send(mocker, status_code, error_code, inner_error_code, expectati mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code) mocker.patch.object(httpx.AsyncClient, "post", side_effect=mock_aes_device.post) - transport = AesTransport(host=host, credentials=Credentials("foo", "bar")) + transport = AesTransport( + config=DeviceConfig(host, credentials=Credentials("foo", "bar")) + ) transport._handshake_done = True transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session transport._login_token = mock_aes_device.token - un, pw = transport.hash_credentials(True) request = { "method": "get_device_info", "params": None, @@ -119,7 +125,8 @@ async def test_passthrough_errors(mocker, error_code): mock_aes_device = MockAesDevice(host, 200, error_code, 0) mocker.patch.object(httpx.AsyncClient, "post", side_effect=mock_aes_device.post) - transport = AesTransport(host=host, credentials=Credentials("foo", "bar")) + config = DeviceConfig(host, credentials=Credentials("foo", "bar")) + transport = AesTransport(config=config) transport._handshake_done = True transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index c46015ea0..1983b6ccb 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -4,10 +4,26 @@ import pytest from asyncclick.testing import CliRunner -from kasa import AuthenticationException, SmartDevice, UnsupportedDeviceException -from kasa.cli import alias, brightness, cli, emeter, raw_command, state, sysinfo, toggle -from kasa.device_factory import DEVICE_TYPE_TO_CLASS -from kasa.discover import Discover +from kasa import ( + AuthenticationException, + Credentials, + SmartDevice, + TPLinkSmartHomeProtocol, + UnsupportedDeviceException, +) +from kasa.cli import ( + TYPE_TO_CLASS, + alias, + brightness, + cli, + emeter, + raw_command, + state, + sysinfo, + toggle, +) +from kasa.discover import Discover, DiscoveryResult +from kasa.smartprotocol import SmartProtocol from .conftest import device_iot, handle_turn_on, new_discovery, turn_on @@ -145,9 +161,11 @@ async def _state(dev: SmartDevice): ) mocker.patch("kasa.cli.state", new=_state) - for subclass in DEVICE_TYPE_TO_CLASS.values(): - mocker.patch.object(subclass, "update") + mocker.patch("kasa.IotProtocol.query", return_value=discovery_mock.query_data) + mocker.patch("kasa.SmartProtocol.query", return_value=discovery_mock.query_data) + + dr = DiscoveryResult(**discovery_mock.discovery_data["result"]) runner = CliRunner() res = await runner.invoke( cli, @@ -158,6 +176,10 @@ async def _state(dev: SmartDevice): "foo", "--password", "bar", + "--device-family", + dr.device_type, + "--encrypt-type", + dr.mgt_encrypt_schm.encrypt_type, ], ) assert res.exit_code == 0 @@ -166,7 +188,7 @@ async def _state(dev: SmartDevice): @device_iot -async def test_without_device_type(discovery_data: dict, dev, mocker): +async def test_without_device_type(dev, mocker): """Test connecting without the device type.""" runner = CliRunner() mocker.patch("kasa.discover.Discover.discover_single", return_value=dev) @@ -342,3 +364,27 @@ async def test_host_auth_failed(discovery_mock, mocker): assert res.exit_code != 0 assert isinstance(res.exception, AuthenticationException) + + +@pytest.mark.parametrize("device_type", list(TYPE_TO_CLASS)) +async def test_type_param(device_type, mocker): + """Test for handling only one of username or password supplied.""" + runner = CliRunner() + + result_device = FileNotFoundError + pass_dev = click.make_pass_decorator(SmartDevice) + + @pass_dev + async def _state(dev: SmartDevice): + nonlocal result_device + result_device = dev + + mocker.patch("kasa.cli.state", new=_state) + expected_type = TYPE_TO_CLASS[device_type] + mocker.patch.object(expected_type, "update") + res = await runner.invoke( + cli, + ["--type", device_type, "--host", "127.0.0.1"], + ) + assert res.exit_code == 0 + assert isinstance(result_device, expected_type) diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index eb12b3b0d..666bd9e95 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -2,6 +2,7 @@ import logging from typing import Type +import httpx import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( @@ -15,122 +16,138 @@ SmartLightStrip, SmartPlug, ) -from kasa.device_factory import ( - DEVICE_TYPE_TO_CLASS, - connect, - get_protocol_from_connection_name, +from kasa.device_factory import connect, get_protocol +from kasa.deviceconfig import ( + ConnectionType, + DeviceConfig, + DeviceFamilyType, + EncryptType, ) from kasa.discover import DiscoveryResult -from kasa.iotprotocol import IotProtocol -from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol -@pytest.mark.parametrize("custom_port", [123, None]) -async def test_connect(discovery_data: dict, mocker, custom_port): - """Make sure that connect returns an initialized SmartDevice instance.""" - host = "127.0.0.1" +def _get_connection_type_device_class(the_fixture_data): + if "discovery_result" in the_fixture_data: + discovery_info = {"result": the_fixture_data["discovery_result"]} + device_class = Discover._get_device_class(discovery_info) + dr = DiscoveryResult(**discovery_info["result"]) - if "result" in discovery_data: - with pytest.raises(SmartDeviceException): - dev = await connect(host, port=custom_port) + connection_type = ConnectionType.from_values( + dr.device_type, dr.mgt_encrypt_schm.encrypt_type + ) else: - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) - dev = await connect(host, port=custom_port) - assert issubclass(dev.__class__, SmartDevice) - assert dev.port == custom_port or dev.port == 9999 + connection_type = ConnectionType.from_values( + DeviceFamilyType.IotSmartPlugSwitch.value, EncryptType.Xor.value + ) + device_class = Discover._get_device_class(the_fixture_data) + return connection_type, device_class -@pytest.mark.parametrize("custom_port", [123, None]) -@pytest.mark.parametrize( - ("device_type", "klass"), - ( - (DeviceType.Plug, SmartPlug), - (DeviceType.Bulb, SmartBulb), - (DeviceType.Dimmer, SmartDimmer), - (DeviceType.LightStrip, SmartLightStrip), - (DeviceType.Unknown, SmartDevice), - ), -) -async def test_connect_passed_device_type( - discovery_data: dict, + +async def test_connect( + all_fixture_data: dict, mocker, - device_type: DeviceType, - klass: Type[SmartDevice], - custom_port, ): - """Make sure that connect with a passed device type.""" + """Test that if the protocol is passed in it gets set correctly.""" host = "127.0.0.1" + ctype, device_class = _get_connection_type_device_class(all_fixture_data) - if "result" in discovery_data: - with pytest.raises(SmartDeviceException): - dev = await connect(host, port=custom_port) - else: - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) - dev = await connect(host, port=custom_port, device_type=device_type) - assert isinstance(dev, klass) - assert dev.port == custom_port or dev.port == 9999 + mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) + mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data) + config = DeviceConfig( + host=host, credentials=Credentials("foor", "bar"), connection_type=ctype + ) + protocol_class = get_protocol(config).__class__ -async def test_connect_query_fails(discovery_data: dict, mocker): - """Make sure that connect fails when query fails.""" + dev = await connect( + config=config, + ) + assert isinstance(dev, device_class) + assert isinstance(dev.protocol, protocol_class) + + assert dev.config == config + + +@pytest.mark.parametrize("custom_port", [123, None]) +async def test_connect_custom_port(all_fixture_data: dict, mocker, custom_port): + """Make sure that connect returns an initialized SmartDevice instance.""" host = "127.0.0.1" - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=SmartDeviceException) - with pytest.raises(SmartDeviceException): - await connect(host) + ctype, _ = _get_connection_type_device_class(all_fixture_data) + config = DeviceConfig(host=host, port_override=custom_port, connection_type=ctype) + default_port = 80 if "discovery_result" in all_fixture_data else 9999 + + ctype, _ = _get_connection_type_device_class(all_fixture_data) + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data) + mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) + mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) + dev = await connect(config=config) + assert issubclass(dev.__class__, SmartDevice) + assert dev.port == custom_port or dev.port == default_port async def test_connect_logs_connect_time( - discovery_data: dict, caplog: pytest.LogCaptureFixture, mocker + all_fixture_data: dict, caplog: pytest.LogCaptureFixture, mocker ): """Test that the connect time is logged when debug logging is enabled.""" + ctype, _ = _get_connection_type_device_class(all_fixture_data) + mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) + mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data) + host = "127.0.0.1" - if "result" in discovery_data: - with pytest.raises(SmartDeviceException): - await connect(host) - else: - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) - logging.getLogger("kasa").setLevel(logging.DEBUG) - await connect(host) - assert "seconds to connect" in caplog.text + config = DeviceConfig( + host=host, credentials=Credentials("foor", "bar"), connection_type=ctype + ) + logging.getLogger("kasa").setLevel(logging.DEBUG) + await connect( + config=config, + ) + assert "seconds to update" in caplog.text -async def test_connect_pass_protocol( - all_fixture_data: dict, - mocker, -): - """Test that if the protocol is passed in it's gets set correctly.""" - if "discovery_result" in all_fixture_data: - discovery_info = {"result": all_fixture_data["discovery_result"]} - device_class = Discover._get_device_class(discovery_info) - else: - device_class = Discover._get_device_class(all_fixture_data) +async def test_connect_query_fails(all_fixture_data: dict, mocker): + """Make sure that connect fails when query fails.""" + host = "127.0.0.1" + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=SmartDeviceException) + mocker.patch("kasa.IotProtocol.query", side_effect=SmartDeviceException) + mocker.patch("kasa.SmartProtocol.query", side_effect=SmartDeviceException) - device_type = list(DEVICE_TYPE_TO_CLASS.keys())[ - list(DEVICE_TYPE_TO_CLASS.values()).index(device_class) - ] + ctype, _ = _get_connection_type_device_class(all_fixture_data) + config = DeviceConfig( + host=host, credentials=Credentials("foor", "bar"), connection_type=ctype + ) + with pytest.raises(SmartDeviceException): + await connect(config=config) + + +async def test_connect_http_client(all_fixture_data, mocker): + """Make sure that discover_single returns an initialized SmartDevice instance.""" host = "127.0.0.1" - if "discovery_result" in all_fixture_data: - mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) - dr = DiscoveryResult(**discovery_info["result"]) - connection_name = ( - dr.device_type.split(".")[0] + "." + dr.mgt_encrypt_schm.encrypt_type - ) - protocol_class = get_protocol_from_connection_name( - connection_name, host - ).__class__ - else: - mocker.patch( - "kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data - ) - protocol_class = TPLinkSmartHomeProtocol + ctype, _ = _get_connection_type_device_class(all_fixture_data) - dev = await connect( - host, - device_type=device_type, - protocol_class=protocol_class, - credentials=Credentials("", ""), + mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) + mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data) + + http_client = httpx.AsyncClient() + + config = DeviceConfig( + host=host, credentials=Credentials("foor", "bar"), connection_type=ctype ) - assert isinstance(dev.protocol, protocol_class) + dev = await connect(config=config) + if ctype.encryption_type != EncryptType.Xor: + assert dev.protocol._transport._http_client != http_client + + config = DeviceConfig( + host=host, + credentials=Credentials("foor", "bar"), + connection_type=ctype, + http_client=http_client, + ) + dev = await connect(config=config) + if ctype.encryption_type != EncryptType.Xor: + assert dev.protocol._transport._http_client == http_client diff --git a/kasa/tests/test_deviceconfig.py b/kasa/tests/test_deviceconfig.py new file mode 100644 index 000000000..7970449dd --- /dev/null +++ b/kasa/tests/test_deviceconfig.py @@ -0,0 +1,21 @@ +from json import dumps as json_dumps +from json import loads as json_loads + +import httpx + +from kasa.credentials import Credentials +from kasa.deviceconfig import ( + ConnectionType, + DeviceConfig, + DeviceFamilyType, + EncryptType, +) + + +def test_serialization(): + config = DeviceConfig(host="Foo", http_client=httpx.AsyncClient()) + config_dict = config.to_dict() + config_json = json_dumps(config_dict) + config2_dict = json_loads(config_json) + config2 = DeviceConfig.from_dict(config2_dict) + assert config == config2 diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 18798ab90..396ef2f2e 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -1,21 +1,29 @@ # type: ignore +import logging import re import socket +import httpx import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( + Credentials, DeviceType, Discover, SmartDevice, SmartDeviceException, - SmartStrip, protocol, ) +from kasa.deviceconfig import ( + ConnectionType, + DeviceConfig, + DeviceFamilyType, + EncryptType, +) from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps from kasa.exceptions import AuthenticationException, UnsupportedDeviceException -from .conftest import bulb, bulb_iot, dimmer, lightstrip, plug, strip +from .conftest import bulb, bulb_iot, dimmer, lightstrip, new_discovery, plug, strip UNSUPPORTED = { "result": { @@ -89,13 +97,26 @@ async def test_discover_single(discovery_mock, custom_port, mocker): host = "127.0.0.1" discovery_mock.ip = host discovery_mock.port_override = custom_port - update_mock = mocker.patch.object(SmartStrip, "update") - x = await Discover.discover_single(host, port=custom_port) + device_class = Discover._get_device_class(discovery_mock.discovery_data) + update_mock = mocker.patch.object(device_class, "update") + + x = await Discover.discover_single( + host, port=custom_port, credentials=Credentials() + ) assert issubclass(x.__class__, SmartDevice) assert x._discovery_info is not None assert x.port == custom_port or x.port == discovery_mock.default_port - assert (update_mock.call_count > 0) == isinstance(x, SmartStrip) + assert update_mock.call_count == 0 + + ct = ConnectionType.from_values( + discovery_mock.device_type, discovery_mock.encrypt_type + ) + uses_http = discovery_mock.default_port == 80 + config = DeviceConfig( + host=host, port_override=custom_port, connection_type=ct, uses_http=uses_http + ) + assert x.config == config async def test_discover_single_hostname(discovery_mock, mocker): @@ -104,47 +125,39 @@ async def test_discover_single_hostname(discovery_mock, mocker): ip = "127.0.0.1" discovery_mock.ip = ip - update_mock = mocker.patch.object(SmartStrip, "update") + device_class = Discover._get_device_class(discovery_mock.discovery_data) + update_mock = mocker.patch.object(device_class, "update") - x = await Discover.discover_single(host) + x = await Discover.discover_single(host, credentials=Credentials()) assert issubclass(x.__class__, SmartDevice) assert x._discovery_info is not None assert x.host == host - assert (update_mock.call_count > 0) == isinstance(x, SmartStrip) + assert update_mock.call_count == 0 mocker.patch("socket.getaddrinfo", side_effect=socket.gaierror()) with pytest.raises(SmartDeviceException): - x = await Discover.discover_single(host) + x = await Discover.discover_single(host, credentials=Credentials()) -async def test_discover_single_unsupported(mocker): +async def test_discover_single_unsupported(unsupported_device_info, mocker): """Make sure that discover_single handles unsupported devices correctly.""" host = "127.0.0.1" - def mock_discover(self): - if discovery_data: - data = ( - b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" - + json_dumps(discovery_data).encode() - ) - self.datagram_received(data, (host, 20002)) - - mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) - # Test with a valid unsupported response - discovery_data = UNSUPPORTED with pytest.raises( UnsupportedDeviceException, - match=f"Unsupported device {host} of type SMART.TAPOXMASTREE: {re.escape(str(UNSUPPORTED))}", ): await Discover.discover_single(host) - # Test with no response - discovery_data = None + +async def test_discover_single_no_response(mocker): + """Make sure that discover_single handles no response correctly.""" + host = "127.0.0.1" + mocker.patch.object(_DiscoverProtocol, "do_discover") with pytest.raises( SmartDeviceException, match=f"Timed out getting discovery response for {host}" ): - await Discover.discover_single(host, timeout=0.001) + await Discover.discover_single(host, discovery_timeout=0) INVALIDS = [ @@ -241,52 +254,82 @@ async def test_discover_invalid_responses(msg, data, mocker): } -async def test_discover_single_authentication(mocker): +@new_discovery +async def test_discover_single_authentication(discovery_mock, mocker): """Make sure that discover_single handles authenticating devices correctly.""" host = "127.0.0.1" - - def mock_discover(self): - if discovery_data: - data = ( - b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" - + json_dumps(discovery_data).encode() - ) - self.datagram_received(data, (host, 20002)) - - mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) + discovery_mock.ip = host + device_class = Discover._get_device_class(discovery_mock.discovery_data) mocker.patch.object( - SmartDevice, + device_class, "update", side_effect=AuthenticationException("Failed to authenticate"), ) - # Test with a valid unsupported response - discovery_data = AUTHENTICATION_DATA_KLAP with pytest.raises( AuthenticationException, match="Failed to authenticate", ): - device = await Discover.discover_single(host) + device = await Discover.discover_single( + host, credentials=Credentials("foo", "bar") + ) await device.update() - mocker.patch.object(SmartDevice, "update") - device = await Discover.discover_single(host) + mocker.patch.object(device_class, "update") + device = await Discover.discover_single(host, credentials=Credentials("foo", "bar")) await device.update() - assert device.device_type == DeviceType.Plug + assert isinstance(device, device_class) -async def test_device_update_from_new_discovery_info(): +@new_discovery +async def test_device_update_from_new_discovery_info(discovery_data): device = SmartDevice("127.0.0.7") - discover_info = DiscoveryResult(**AUTHENTICATION_DATA_KLAP["result"]) + discover_info = DiscoveryResult(**discovery_data["result"]) discover_dump = discover_info.get_dict() + discover_dump["alias"] = "foobar" + discover_dump["model"] = discover_dump["device_model"] device.update_from_discover_info(discover_dump) - assert device.alias == discover_dump["alias"] + assert device.alias == "foobar" assert device.mac == discover_dump["mac"].replace("-", ":") - assert device.model == discover_dump["model"] + assert device.model == discover_dump["device_model"] with pytest.raises( SmartDeviceException, match=re.escape("You need to await update() to access the data"), ): assert device.supported_modules + + +async def test_discover_single_http_client(discovery_mock, mocker): + """Make sure that discover_single returns an initialized SmartDevice instance.""" + host = "127.0.0.1" + discovery_mock.ip = host + + http_client = httpx.AsyncClient() + + x: SmartDevice = await Discover.discover_single(host) + + assert x.config.uses_http == (discovery_mock.default_port == 80) + + if discovery_mock.default_port == 80: + assert x.protocol._transport._http_client != http_client + x.config.http_client = http_client + assert x.protocol._transport._http_client == http_client + + +async def test_discover_http_client(discovery_mock, mocker): + """Make sure that discover_single returns an initialized SmartDevice instance.""" + host = "127.0.0.1" + discovery_mock.ip = host + + http_client = httpx.AsyncClient() + + devices = await Discover.discover(discovery_timeout=0) + x: SmartDevice = devices[host] + assert x.config.uses_http == (discovery_mock.default_port == 80) + + if discovery_mock.default_port == 80: + assert x.protocol._transport._http_client != http_client + x.config.http_client = http_client + assert x.protocol._transport._http_client == http_client diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 1ed57ef22..5108fef05 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -12,9 +12,15 @@ from ..aestransport import AesTransport from ..credentials import Credentials +from ..deviceconfig import DeviceConfig from ..exceptions import AuthenticationException, SmartDeviceException from ..iotprotocol import IotProtocol -from ..klaptransport import KlapEncryptionSession, KlapTransport, _sha256 +from ..klaptransport import ( + KlapEncryptionSession, + KlapTransport, + KlapTransportV2, + _sha256, +) from ..smartprotocol import SmartProtocol DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} @@ -31,8 +37,9 @@ def __init__(self, status_code, content: bytes): [ (Exception("dummy exception"), True), (SmartDeviceException("dummy exception"), False), + (httpx.ConnectError("dummy exception"), True), ], - ids=("Exception", "SmartDeviceException"), + ids=("Exception", "SmartDeviceException", "httpx.ConnectError"), ) @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) @@ -42,8 +49,10 @@ async def test_protocol_retries( ): host = "127.0.0.1" conn = mocker.patch.object(httpx.AsyncClient, "post", side_effect=error) + + config = DeviceConfig(host) with pytest.raises(SmartDeviceException): - await protocol_class(host, transport=transport_class(host)).query( + await protocol_class(transport=transport_class(config=config)).query( DUMMY_QUERY, retry_count=retry_count ) @@ -60,10 +69,11 @@ async def test_protocol_no_retry_on_connection_error( conn = mocker.patch.object( httpx.AsyncClient, "post", - side_effect=httpx.ConnectError("foo"), + side_effect=AuthenticationException("foo"), ) + config = DeviceConfig(host) with pytest.raises(SmartDeviceException): - await protocol_class(host, transport=transport_class(host)).query( + await protocol_class(transport=transport_class(config=config)).query( DUMMY_QUERY, retry_count=5 ) @@ -81,8 +91,9 @@ async def test_protocol_retry_recoverable_error( "post", side_effect=httpx.CloseError("foo"), ) + config = DeviceConfig(host) with pytest.raises(SmartDeviceException): - await protocol_class(host, transport=transport_class(host)).query( + await protocol_class(transport=transport_class(config=config)).query( DUMMY_QUERY, retry_count=5 ) @@ -115,7 +126,8 @@ def _fail_one_less_than_retry_count(*_, **__): side_effect=_fail_one_less_than_retry_count, ) - response = await protocol_class(host, transport=transport_class(host)).query( + config = DeviceConfig(host) + response = await protocol_class(transport=transport_class(config=config)).query( DUMMY_QUERY, retry_count=retry_count ) assert "result" in response or "foobar" in response @@ -136,7 +148,9 @@ def _return_encrypted(*_, **__): seed = secrets.token_bytes(16) auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) encryption_session = KlapEncryptionSession(seed, seed, auth_hash) - protocol = IotProtocol("127.0.0.1", transport=KlapTransport("127.0.0.1")) + + config = DeviceConfig("127.0.0.1") + protocol = IotProtocol(transport=KlapTransport(config=config)) protocol._transport._handshake_done = True protocol._transport._session_expire_at = time.time() + 86400 @@ -181,7 +195,7 @@ def test_encrypt_unicode(): "device_credentials, expectation", [ (Credentials("foo", "bar"), does_not_raise()), - (Credentials("", ""), does_not_raise()), + (Credentials(), does_not_raise()), ( Credentials( KlapTransport.KASA_SETUP_EMAIL, @@ -196,30 +210,37 @@ def test_encrypt_unicode(): ], ids=("client", "blank", "kasa_setup", "shouldfail"), ) -async def test_handshake1(mocker, device_credentials, expectation): +@pytest.mark.parametrize( + "transport_class, seed_auth_hash_calc", + [ + pytest.param(KlapTransport, lambda c, s, a: c + a, id="KLAP"), + pytest.param(KlapTransportV2, lambda c, s, a: c + s + a, id="KLAPV2"), + ], +) +async def test_handshake1( + mocker, device_credentials, expectation, transport_class, seed_auth_hash_calc +): async def _return_handshake1_response(url, params=None, data=None, *_, **__): nonlocal client_seed, server_seed, device_auth_hash client_seed = data - client_seed_auth_hash = _sha256(data + device_auth_hash) - - return _mock_response(200, server_seed + client_seed_auth_hash) + seed_auth_hash = _sha256( + seed_auth_hash_calc(client_seed, server_seed, device_auth_hash) + ) + return _mock_response(200, server_seed + seed_auth_hash) client_seed = None server_seed = secrets.token_bytes(16) client_credentials = Credentials("foo", "bar") - device_auth_hash = KlapTransport.generate_auth_hash(device_credentials) + device_auth_hash = transport_class.generate_auth_hash(device_credentials) mocker.patch.object( httpx.AsyncClient, "post", side_effect=_return_handshake1_response ) - protocol = IotProtocol( - "127.0.0.1", - transport=KlapTransport("127.0.0.1", credentials=client_credentials), - ) + config = DeviceConfig("127.0.0.1", credentials=client_credentials) + protocol = IotProtocol(transport=transport_class(config=config)) - protocol._transport.http_client = httpx.AsyncClient() with expectation: ( local_seed, @@ -233,31 +254,51 @@ async def _return_handshake1_response(url, params=None, data=None, *_, **__): await protocol.close() -async def test_handshake(mocker): +@pytest.mark.parametrize( + "transport_class, seed_auth_hash_calc1, seed_auth_hash_calc2", + [ + pytest.param( + KlapTransport, lambda c, s, a: c + a, lambda c, s, a: s + a, id="KLAP" + ), + pytest.param( + KlapTransportV2, + lambda c, s, a: c + s + a, + lambda c, s, a: s + c + a, + id="KLAPV2", + ), + ], +) +async def test_handshake( + mocker, transport_class, seed_auth_hash_calc1, seed_auth_hash_calc2 +): async def _return_handshake_response(url, params=None, data=None, *_, **__): - nonlocal response_status, client_seed, server_seed, device_auth_hash + nonlocal client_seed, server_seed, device_auth_hash if url == "http://127.0.0.1/app/handshake1": client_seed = data - client_seed_auth_hash = _sha256(data + device_auth_hash) + seed_auth_hash = _sha256( + seed_auth_hash_calc1(client_seed, server_seed, device_auth_hash) + ) - return _mock_response(200, server_seed + client_seed_auth_hash) + return _mock_response(200, server_seed + seed_auth_hash) elif url == "http://127.0.0.1/app/handshake2": + seed_auth_hash = _sha256( + seed_auth_hash_calc2(client_seed, server_seed, device_auth_hash) + ) + assert data == seed_auth_hash return _mock_response(response_status, b"") client_seed = None server_seed = secrets.token_bytes(16) client_credentials = Credentials("foo", "bar") - device_auth_hash = KlapTransport.generate_auth_hash(client_credentials) + device_auth_hash = transport_class.generate_auth_hash(client_credentials) mocker.patch.object( httpx.AsyncClient, "post", side_effect=_return_handshake_response ) - protocol = IotProtocol( - "127.0.0.1", - transport=KlapTransport("127.0.0.1", credentials=client_credentials), - ) + config = DeviceConfig("127.0.0.1", credentials=client_credentials) + protocol = IotProtocol(transport=transport_class(config=config)) protocol._transport.http_client = httpx.AsyncClient() response_status = 200 @@ -273,7 +314,7 @@ async def _return_handshake_response(url, params=None, data=None, *_, **__): async def test_query(mocker): async def _return_response(url, params=None, data=None, *_, **__): - nonlocal client_seed, server_seed, device_auth_hash, protocol, seq + nonlocal client_seed, server_seed, device_auth_hash, seq if url == "http://127.0.0.1/app/handshake1": client_seed = data @@ -303,10 +344,8 @@ async def _return_response(url, params=None, data=None, *_, **__): mocker.patch.object(httpx.AsyncClient, "post", side_effect=_return_response) - protocol = IotProtocol( - "127.0.0.1", - transport=KlapTransport("127.0.0.1", credentials=client_credentials), - ) + config = DeviceConfig("127.0.0.1", credentials=client_credentials) + protocol = IotProtocol(transport=KlapTransport(config=config)) for _ in range(10): resp = await protocol.query({}) @@ -350,10 +389,8 @@ async def _return_response(url, params=None, data=None, *_, **__): mocker.patch.object(httpx.AsyncClient, "post", side_effect=_return_response) - protocol = IotProtocol( - "127.0.0.1", - transport=KlapTransport("127.0.0.1", credentials=client_credentials), - ) + config = DeviceConfig("127.0.0.1", credentials=client_credentials) + protocol = IotProtocol(transport=KlapTransport(config=config)) with expectation: await protocol.query({}) diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 7bd6342b4..0e74da3b8 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -9,6 +9,7 @@ import pytest +from ..deviceconfig import DeviceConfig from ..exceptions import SmartDeviceException from ..protocol import ( BaseTransport, @@ -31,10 +32,11 @@ def aio_mock_writer(_, __): return reader, writer conn = mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + config = DeviceConfig("127.0.0.1") with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol( - "127.0.0.1", transport=_XorTransport("127.0.0.1") - ).query({}, retry_count=retry_count) + await TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)).query( + {}, retry_count=retry_count + ) assert conn.call_count == retry_count + 1 @@ -44,10 +46,11 @@ async def test_protocol_no_retry_on_unreachable(mocker): "asyncio.open_connection", side_effect=OSError(errno.EHOSTUNREACH, "No route to host"), ) + config = DeviceConfig("127.0.0.1") with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol( - "127.0.0.1", transport=_XorTransport("127.0.0.1") - ).query({}, retry_count=5) + await TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)).query( + {}, retry_count=5 + ) assert conn.call_count == 1 @@ -57,10 +60,11 @@ async def test_protocol_no_retry_connection_refused(mocker): "asyncio.open_connection", side_effect=ConnectionRefusedError, ) + config = DeviceConfig("127.0.0.1") with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol( - "127.0.0.1", transport=_XorTransport("127.0.0.1") - ).query({}, retry_count=5) + await TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)).query( + {}, retry_count=5 + ) assert conn.call_count == 1 @@ -70,10 +74,11 @@ async def test_protocol_retry_recoverable_error(mocker): "asyncio.open_connection", side_effect=OSError(errno.ECONNRESET, "Connection reset by peer"), ) + config = DeviceConfig("127.0.0.1") with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol( - "127.0.0.1", transport=_XorTransport("127.0.0.1") - ).query({}, retry_count=5) + await TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)).query( + {}, retry_count=5 + ) assert conn.call_count == 6 @@ -107,9 +112,8 @@ def aio_mock_writer(_, __): mocker.patch.object(reader, "readexactly", _mock_read) return reader, writer - protocol = TPLinkSmartHomeProtocol( - "127.0.0.1", transport=_XorTransport("127.0.0.1") - ) + config = DeviceConfig("127.0.0.1") + protocol = TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)) mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) response = await protocol.query({}, retry_count=retry_count) assert response == {"great": "success"} @@ -137,9 +141,8 @@ def aio_mock_writer(_, __): mocker.patch.object(reader, "readexactly", _mock_read) return reader, writer - protocol = TPLinkSmartHomeProtocol( - "127.0.0.1", transport=_XorTransport("127.0.0.1") - ) + config = DeviceConfig("127.0.0.1") + protocol = TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)) mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) response = await protocol.query({}) assert response == {"great": "success"} @@ -173,9 +176,8 @@ def aio_mock_writer(_, port): mocker.patch.object(reader, "readexactly", _mock_read) return reader, writer - protocol = TPLinkSmartHomeProtocol( - "127.0.0.1", transport=_XorTransport("127.0.0.1", port=custom_port) - ) + config = DeviceConfig("127.0.0.1", port_override=custom_port) + protocol = TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)) mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) response = await protocol.query({}) assert response == {"great": "success"} @@ -271,18 +273,14 @@ def _get_subclasses(of_class): def test_protocol_init_signature(class_name_obj): params = list(inspect.signature(class_name_obj[1].__init__).parameters.values()) - assert len(params) == 3 + assert len(params) == 2 assert ( params[0].name == "self" and params[0].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD ) assert ( - params[1].name == "host" - and params[1].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD - ) - assert ( - params[2].name == "transport" - and params[2].kind == inspect.Parameter.KEYWORD_ONLY + params[1].name == "transport" + and params[1].kind == inspect.Parameter.KEYWORD_ONLY ) @@ -292,20 +290,11 @@ def test_protocol_init_signature(class_name_obj): def test_transport_init_signature(class_name_obj): params = list(inspect.signature(class_name_obj[1].__init__).parameters.values()) - assert len(params) == 5 + assert len(params) == 2 assert ( params[0].name == "self" and params[0].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD ) assert ( - params[1].name == "host" - and params[1].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD - ) - assert params[2].name == "port" and params[2].kind == inspect.Parameter.KEYWORD_ONLY - assert ( - params[3].name == "credentials" - and params[3].kind == inspect.Parameter.KEYWORD_ONLY - ) - assert ( - params[4].name == "timeout" and params[4].kind == inspect.Parameter.KEYWORD_ONLY + params[1].name == "config" and params[1].kind == inspect.Parameter.KEYWORD_ONLY ) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 47f523d00..a3019bff6 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -5,8 +5,7 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 import kasa -from kasa import Credentials, SmartDevice, SmartDeviceException -from kasa.smartdevice import DeviceType +from kasa import Credentials, DeviceConfig, SmartDevice, SmartDeviceException from .conftest import device_iot, handle_turn_on, has_emeter, no_emeter_iot, turn_on from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol @@ -215,7 +214,8 @@ def test_device_class_ctors(device_class): host = "127.0.0.2" port = 1234 credentials = Credentials("foo", "bar") - dev = device_class(host, port=port, credentials=credentials) + config = DeviceConfig(host, port_override=port, credentials=credentials) + dev = device_class(host, config=config) assert dev.host == host assert dev.port == port assert dev.credentials == credentials @@ -231,29 +231,27 @@ async def test_modules_preserved(dev: SmartDevice): async def test_create_smart_device_with_timeout(): """Make sure timeout is passed to the protocol.""" - dev = SmartDevice(host="127.0.0.1", timeout=100) + host = "127.0.0.1" + dev = SmartDevice(host, config=DeviceConfig(host, timeout=100)) assert dev.protocol._transport._timeout == 100 async def test_create_thin_wrapper(): """Make sure thin wrapper is created with the correct device type.""" mock = Mock() + config = DeviceConfig( + host="test_host", + port_override=1234, + timeout=100, + credentials=Credentials("username", "password"), + ) with patch("kasa.device_factory.connect", return_value=mock) as connect: - dev = await SmartDevice.connect( - host="test_host", - port=1234, - timeout=100, - credentials=Credentials("username", "password"), - device_type=DeviceType.Strip, - ) + dev = await SmartDevice.connect(config=config) assert dev is mock connect.assert_called_once_with( - host="test_host", - port=1234, - timeout=100, - credentials=Credentials("username", "password"), - device_type=DeviceType.Strip, + host=None, + config=config, ) diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 5dbbed279..301e367f5 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -13,6 +13,7 @@ from ..aestransport import AesTransport from ..credentials import Credentials +from ..deviceconfig import DeviceConfig from ..exceptions import ( SMART_RETRYABLE_ERRORS, SMART_TIMEOUT_ERRORS, @@ -37,7 +38,8 @@ async def test_smart_device_errors(mocker, error_code): send_mock = mocker.patch.object(AesTransport, "send", return_value=mock_response) - protocol = SmartProtocol(host, transport=AesTransport(host)) + config = DeviceConfig(host, credentials=Credentials("foo", "bar")) + protocol = SmartProtocol(transport=AesTransport(config=config)) with pytest.raises(SmartDeviceException): await protocol.query(DUMMY_QUERY, retry_count=2) @@ -70,8 +72,8 @@ async def test_smart_device_errors_in_multiple_request(mocker, error_code): mocker.patch.object(AesTransport, "perform_login") send_mock = mocker.patch.object(AesTransport, "send", return_value=mock_response) - - protocol = SmartProtocol(host, transport=AesTransport(host)) + config = DeviceConfig(host, credentials=Credentials("foo", "bar")) + protocol = SmartProtocol(transport=AesTransport(config=config)) with pytest.raises(SmartDeviceException): await protocol.query(DUMMY_QUERY, retry_count=2) if error_code in chain(SMART_TIMEOUT_ERRORS, SMART_RETRYABLE_ERRORS): From 1b7914277d3d6db37a122aeccca50bdfc9731794 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Fri, 29 Dec 2023 19:42:02 +0000 Subject: [PATCH 210/892] Fix dump_devinfo for unauthenticated (#593) --- devtools/README.md | 2 ++ devtools/dump_devinfo.py | 29 ++++++++++++++++++++++++----- kasa/aestransport.py | 7 +++---- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/devtools/README.md b/devtools/README.md index c7da859f8..50425c254 100644 --- a/devtools/README.md +++ b/devtools/README.md @@ -13,6 +13,8 @@ Usage: dump_devinfo.py [OPTIONS] HOST Options: -d, --debug --help Show this message and exit. + --username For authenticating devices. + --password ``` ## create_module_fixtures diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index c9f34fcb1..c03e97d55 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -17,7 +17,7 @@ import asyncclick as click -from kasa import Credentials, Discover, SmartDevice +from kasa import AuthenticationException, Credentials, Discover, SmartDevice from kasa.discover import DiscoveryResult from kasa.tapo.tapodevice import TapoDevice @@ -85,14 +85,14 @@ def default_to_regular(d): @click.argument("host") @click.option( "--username", - default=None, + default="", required=False, envvar="TPLINK_CLOUD_USERNAME", help="Username/email address to authenticate to device.", ) @click.option( "--password", - default=None, + default="", required=False, envvar="TPLINK_CLOUD_PASSWORD", help="Password to use to authenticate to device.", @@ -227,6 +227,15 @@ async def get_smart_fixture(device: SmartDevice): try: click.echo(f"Testing {test_call}..", nl=False) response = await device.protocol.query(test_call.method) + except AuthenticationException as ex: + click.echo( + click.style( + f"Unable to query the device due to an authentication error: {ex}", + bold=True, + fg="red", + ) + ) + exit(1) except Exception as ex: click.echo(click.style(f"FAIL {ex}", fg="red")) else: @@ -244,15 +253,25 @@ async def get_smart_fixture(device: SmartDevice): try: responses = await device.protocol.query(final_query) + except AuthenticationException as ex: + click.echo( + click.style( + f"Unable to query the device due to an authentication error: {ex}", + bold=True, + fg="red", + ) + ) + exit(1) except Exception as ex: click.echo( click.style( f"Unable to query all successes at once: {ex}", bold=True, fg="red" ) ) + exit(1) final = {} - for response in responses["responses"]: - final[response["method"]] = response["result"] + for method, result in responses.items(): + final[method] = result # 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/kasa/aestransport.py b/kasa/aestransport.py index b6fa34723..df26c4c49 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -182,10 +182,9 @@ async def _perform_login_for_version(self, *, login_version: int = 1): "request_time_milis": round(time.time() * 1000), } request = json_dumps(login_request) - try: - resp_dict = await self.send_secure_passthrough(request) - except SmartDeviceException as ex: - raise AuthenticationException(ex) from ex + + 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"] async def perform_login(self) -> None: From fd9b3cd04ce704cfed4f2171fb5cbe29a1dd867d Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Sun, 31 Dec 2023 14:35:43 +0000 Subject: [PATCH 211/892] Add L530(EU) klap fixture (#598) --- .../fixtures/smart/L530E(EU)_3.0_1.1.0.json | 440 ++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json diff --git a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json new file mode 100644 index 000000000..db3e38494 --- /dev/null +++ b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json @@ -0,0 +1,440 @@ +{ + "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(EU)", + "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": "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": 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": "bulb", + "brightness": 50, + "color_temp": 2700, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "last_states", + "state": { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 230823 Rel.163903", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "00-00-00-00-00-00", + "model": "L530", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/London", + "rssi": -70, + "saturation": 100, + "signal_level": 1, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1703934078 + }, + "get_device_usage": { + "power_usage": { + "past30": 221, + "past7": 54, + "today": 0 + }, + "saved_power": { + "past30": 1452, + "past7": 503, + "today": 0 + }, + "time_usage": { + "past30": 1673, + "past7": 557, + "today": 0 + } + }, + "get_dynamic_light_effect_rules": { + "enable": false, + "max_count": 2, + "rule_list": [ + { + "change_mode": "direct", + "change_time": 3000, + "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": "cGFydHky" + }, + { + "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 230823 Rel.163903", + "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", + "is_klap": true + } + } +} From 7646bc4542593ed7a5f9bb6f6698ed265a514c77 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Sun, 31 Dec 2023 14:36:15 +0000 Subject: [PATCH 212/892] Update P110(UK) fixture (#596) --- .../fixtures/smart/P110(UK)_1.0_1.3.0.json | 305 ++++++++++++++++-- 1 file changed, 286 insertions(+), 19 deletions(-) diff --git a/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json b/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json index 9033e8002..22deff8ce 100644 --- a/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json +++ b/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json @@ -104,8 +104,40 @@ "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": [ + { + "delay": 300, + "desired_states": { + "on": true + }, + "enable": false, + "id": "C1", + "remain": 0 + } + ] + }, "get_current_power": { - "current_power": 0 + "current_power": 11 }, "get_device_info": { "auto_off_remain_time": 0, @@ -130,12 +162,12 @@ "model": "P110", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", - "on_time": 119335, + "on_time": 3087, "overcurrent_status": "normal", "overheated": false, "power_protection_status": "normal", "region": "Europe/London", - "rssi": -57, + "rssi": -50, "signal_level": 2, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", @@ -145,36 +177,271 @@ "get_device_time": { "region": "Europe/London", "time_diff": 0, - "timestamp": 1701370224 + "timestamp": 1703934043 }, "get_device_usage": { "power_usage": { - "past30": 75, - "past7": 69, - "today": 0 + "past30": 2090, + "past7": 1171, + "today": 9 }, "saved_power": { - "past30": 2029, - "past7": 1964, - "today": 1130 + "past30": 31616, + "past7": 5033, + "today": 42 }, "time_usage": { - "past30": 2104, - "past7": 2033, - "today": 1130 + "past30": 33706, + "past7": 6204, + "today": 51 } }, + "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, + "current_power": 11300, "electricity_charge": [ 0, 0, 0 ], - "local_time": "2023-11-30 18:50:24", - "month_energy": 75, - "month_runtime": 2104, - "today_energy": 0, - "today_runtime": 1130 + "local_time": "2023-12-30 11:00:43", + "month_energy": 2090, + "month_runtime": 33706, + "today_energy": 9, + "today_runtime": 51 + }, + "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.3.0 Build 230905 Rel.152200", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "night_mode", + "led_status": true, + "night_mode": { + "end_time": 550, + "night_mode_type": "sunrise_sunset", + "start_time": 961, + "sunrise_offset": 62, + "sunset_offset": 0 + } + }, + "get_max_power": { + "max_power": 3342 + }, + "get_next_event": { + "desired_states": { + "on": true + }, + "e_time": 0, + "id": "S1", + "s_time": 1703937000, + "type": 1 + }, + "get_protection_power": { + "enabled": true, + "protection_power": 2960 + }, + "get_schedule_rules": { + "enable": true, + "rule_list": [ + { + "day": 30, + "desired_states": { + "on": true + }, + "e_action": "none", + "e_min": 0, + "e_type": "normal", + "enable": true, + "id": "S1", + "mode": "repeat", + "month": 12, + "s_min": 710, + "s_type": "normal", + "time_offset": 0, + "week_day": 127, + "year": 2023 + } + ], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 1 + }, + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P110", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } } } From 5dafc1d1ed8916057bdfcfe6c1ab4971e7a6765d Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 2 Jan 2024 16:24:09 +0100 Subject: [PATCH 213/892] Cleanup custom exception kwarg handling (#602) * Cleanup custom exceptions * Read custom keyword arguments from kwargs * Pass all input args to the super Earlier behavior: Got error: AuthenticationException((('Error logging in: 192.168.xx.xx: LOGIN_ERROR(-1501)',), )) New behavior: Got error: AuthenticationException('Error logging in: 192.168.xx.xx: LOGIN_ERROR(-1501)') * Pass UnsupportedDeviceException kwargs to parent, too --- kasa/exceptions.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/kasa/exceptions.py b/kasa/exceptions.py index ff91a7b0e..97fabb041 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -6,39 +6,29 @@ class SmartDeviceException(Exception): """Base exception for device errors.""" - def __init__(self, *args, error_code: Optional["SmartErrorCode"] = None): - self.error_code = error_code - super().__init__(args) + def __init__(self, *args, **kwargs): + self.error_code: Optional["SmartErrorCode"] = kwargs.get("error_code", None) + super().__init__(*args) class UnsupportedDeviceException(SmartDeviceException): """Exception for trying to connect to unsupported devices.""" - def __init__(self, *args, discovery_result=None): - self.discovery_result = discovery_result - super().__init__(args) + def __init__(self, *args, **kwargs): + self.discovery_result = kwargs.get("discovery_result") + super().__init__(*args, **kwargs) class AuthenticationException(SmartDeviceException): """Base exception for device authentication errors.""" - def __init__(self, *args, error_code: Optional["SmartErrorCode"] = None): - super().__init__(args, error_code) - class RetryableException(SmartDeviceException): """Retryable exception for device errors.""" - def __init__(self, *args, error_code: Optional["SmartErrorCode"] = None): - super().__init__(args, error_code) - - class TimeoutException(SmartDeviceException): """Timeout exception for device errors.""" - def __init__(self, *args, error_code: Optional["SmartErrorCode"] = None): - super().__init__(args, error_code) - class SmartErrorCode(IntEnum): """Enum for SMART Error Codes.""" From ae5ad3e8c62150520b2772969d771b8231804c96 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Tue, 2 Jan 2024 17:20:53 +0000 Subject: [PATCH 214/892] Add known smart requests to dump_devinfo (#597) * Add known smart requests to dump_devinfo * Move smartrequest.py to devtools * Update post-review --- devtools/__init__.py | 1 + devtools/dump_devinfo.py | 165 ++++++++++---- devtools/helpers/__init__.py | 1 + devtools/helpers/smartrequests.py | 350 ++++++++++++++++++++++++++++++ 4 files changed, 472 insertions(+), 45 deletions(-) create mode 100644 devtools/__init__.py create mode 100644 devtools/helpers/__init__.py create mode 100644 devtools/helpers/smartrequests.py diff --git a/devtools/__init__.py b/devtools/__init__.py new file mode 100644 index 000000000..49189835e --- /dev/null +++ b/devtools/__init__.py @@ -0,0 +1 @@ +"""Devtools package.""" diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index c03e97d55..985ce669f 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -14,14 +14,18 @@ import re from collections import defaultdict, namedtuple from pprint import pprint +from typing import Dict, List import asyncclick as click +from devtools.helpers.smartrequests import COMPONENT_REQUESTS, SmartRequest from kasa import AuthenticationException, Credentials, Discover, SmartDevice from kasa.discover import DiscoveryResult +from kasa.exceptions import SmartErrorCode from kasa.tapo.tapodevice import TapoDevice Call = namedtuple("Call", "module method") +SmartCall = namedtuple("SmartCall", "module request should_succeed") def scrub(res): @@ -46,11 +50,19 @@ def scrub(res): "oem_id", "nickname", "alias", + "bssid", + "channel", ] for k, v in res.items(): if isinstance(v, collections.abc.Mapping): res[k] = scrub(res.get(k)) + elif ( + isinstance(v, list) + and len(v) > 0 + and isinstance(v[0], collections.abc.Mapping) + ): + res[k] = [scrub(vi) for vi in v] else: if k in keys_to_scrub: if k in ["latitude", "latitude_i", "longitude", "longitude_i"]: @@ -64,6 +76,8 @@ def scrub(res): v = base64.b64encode(b"#MASKED_NAME#").decode() elif k in ["alias"]: v = "#MASKED_NAME#" + elif isinstance(res[k], int): + v = 0 else: v = re.sub(r"\w", "0", v) @@ -179,7 +193,7 @@ async def get_legacy_fixture(device): ) ) - if device._discovery_info: + if device._discovery_info and not device._discovery_info.get("system"): # 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. dr = DiscoveryResult(**device._discovery_info) @@ -200,33 +214,106 @@ async def get_legacy_fixture(device): return save_filename, copy_folder, final -async def get_smart_fixture(device: SmartDevice): +async def _make_requests_or_exit( + device: SmartDevice, requests: List[SmartRequest], name: str +) -> Dict[str, Dict]: + final = {} + try: + end = len(requests) + step = 10 # 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] + responses = await device.protocol.query( + SmartRequest._create_request_dict(requests_step) + ) + 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", + ) + ) + exit(1) + except Exception as ex: + click.echo( + click.style(f"Unable to query {name} at once: {ex}", bold=True, fg="red") + ) + exit(1) + + +async def get_smart_fixture(device: TapoDevice): """Get fixture for new TAPO style protocol.""" - items = [ - Call(module="component_nego", method="component_nego"), - Call(module="device_info", method="get_device_info"), - Call(module="device_usage", method="get_device_usage"), - Call(module="device_time", method="get_device_time"), - Call(module="energy_usage", method="get_energy_usage"), - Call(module="current_power", method="get_current_power"), - Call(module="temp_humidity_records", method="get_temp_humidity_records"), - Call(module="child_device_list", method="get_child_device_list"), - Call( - module="trigger_logs", - method={"get_trigger_logs": {"page_size": 5, "start_id": 0}}, + extra_test_calls = [ + SmartCall( + module="temp_humidity_records", + request=SmartRequest.get_raw_request("get_temp_humidity_records"), + should_succeed=False, ), - Call( + SmartCall( + module="child_device_list", + request=SmartRequest.get_raw_request("get_child_device_list"), + should_succeed=False, + ), + SmartCall( module="child_device_component_list", - method="get_child_device_component_list", + request=SmartRequest.get_raw_request("get_child_device_component_list"), + should_succeed=False, + ), + SmartCall( + module="trigger_logs", + request=SmartRequest.get_raw_request( + "get_trigger_logs", SmartRequest.GetTriggerLogsParams(5, 0) + ), + should_succeed=False, ), ] successes = [] - for test_call in items: + click.echo("Testing component_nego call ..", nl=False) + responses = await _make_requests_or_exit( + device, [SmartRequest.component_nego()], "component_nego call" + ) + component_info_response = responses["component_nego"] + click.echo(click.style("OK", fg="green")) + successes.append( + SmartCall( + module="component_nego", + request=SmartRequest("component_nego"), + should_succeed=True, + ) + ) + + test_calls = [] + should_succeed = [] + + for item in component_info_response["component_list"]: + component_id = item["id"] + if requests := COMPONENT_REQUESTS.get(component_id): + component_test_calls = [ + SmartCall(module=component_id, request=request, should_succeed=True) + for request in requests + ] + test_calls.extend(component_test_calls) + should_succeed.extend(component_test_calls) + elif component_id not in COMPONENT_REQUESTS: + click.echo(f"Skipping {component_id}..", nl=False) + click.echo(click.style("UNSUPPORTED", fg="yellow")) + + test_calls.extend(extra_test_calls) + + for test_call in test_calls: + click.echo(f"Testing {test_call.module}..", nl=False) try: click.echo(f"Testing {test_call}..", nl=False) - response = await device.protocol.query(test_call.method) + response = await device.protocol.query( + SmartRequest._create_request_dict(test_call.request) + ) except AuthenticationException as ex: click.echo( click.style( @@ -237,41 +324,29 @@ async def get_smart_fixture(device: SmartDevice): ) exit(1) except Exception as ex: - click.echo(click.style(f"FAIL {ex}", fg="red")) + if ( + not test_call.should_succeed + and hasattr(ex, "error_code") + and ex.error_code == SmartErrorCode.UNKNOWN_METHOD_ERROR + ): + click.echo(click.style("FAIL - EXPECTED", fg="green")) + else: + click.echo(click.style(f"FAIL {ex}", fg="red")) else: if not response: - click.echo(click.style("FAIL not suported", fg="red")) + click.echo(click.style("FAIL no response", fg="red")) else: - click.echo(click.style("OK", fg="green")) + if not test_call.should_succeed: + click.echo(click.style("OK - EXPECTED FAIL", fg="red")) + else: + click.echo(click.style("OK", fg="green")) successes.append(test_call) requests = [] for succ in successes: - requests.append({"method": succ.method}) - - final_query = {"multipleRequest": {"requests": requests}} + requests.append(succ.request) - try: - responses = await device.protocol.query(final_query) - except AuthenticationException as ex: - click.echo( - click.style( - f"Unable to query the device due to an authentication error: {ex}", - bold=True, - fg="red", - ) - ) - exit(1) - except Exception as ex: - click.echo( - click.style( - f"Unable to query all successes at once: {ex}", bold=True, fg="red" - ) - ) - exit(1) - final = {} - for method, result in responses.items(): - final[method] = result + final = await _make_requests_or_exit(device, requests, "all successes at once") # 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/devtools/helpers/__init__.py b/devtools/helpers/__init__.py new file mode 100644 index 000000000..182958c66 --- /dev/null +++ b/devtools/helpers/__init__.py @@ -0,0 +1 @@ +"""Helpers package.""" diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py new file mode 100644 index 000000000..4bb50ae3d --- /dev/null +++ b/devtools/helpers/smartrequests.py @@ -0,0 +1,350 @@ +"""SmartRequest helper classes and functions for new SMART/TAPO devices. + +List of known requests with associated parameter classes. + +Other requests that are known but not currently implemented +or tested are: + +get_child_device_component_list +get_child_device_list +control_child +get_device_running_info - seems to be a subset of get_device_info + +get_tss_info +get_raw_dvi +get_homekit_info + +fw_download + +sync_env +account_sync + +device_reset +close_device_ble +heart_beat + +""" + +import logging +from dataclasses import asdict, dataclass +from typing import List, Optional, Union + +_LOGGER = logging.getLogger(__name__) +logging.getLogger("httpx").propagate = False + + +class SmartRequest: + """Class to represent a smart protocol request.""" + + def __init__(self, method_name: str, params: Optional["SmartRequestParams"] = None): + self.method_name = method_name + if params: + self.params = params.to_dict() + else: + self.params = None + + def __repr__(self): + return f"SmartRequest({self.method_name})" + + def to_dict(self): + """Return the request as a dict suitable for passing to query().""" + return {self.method_name: self.params} + + @dataclass + class SmartRequestParams: + """Base class for Smart request params. + + The to_dict() method of this class omits null values which + is required by the devices. + """ + + def to_dict(self): + """Return the params as a dict with values of None ommited.""" + return asdict( + self, dict_factory=lambda x: {k: v for (k, v) in x if v is not None} + ) + + @dataclass + class DeviceOnParams(SmartRequestParams): + """Get Rules Params.""" + + device_on: bool + + @dataclass + class GetRulesParams(SmartRequestParams): + """Get Rules Params.""" + + start_index: int = 0 + + @dataclass + class GetTriggerLogsParams(SmartRequestParams): + """Trigger Logs params.""" + + page_size: int = 5 + start_id: int = 0 + + @dataclass + class LedStatusParams(SmartRequestParams): + """LED Status params.""" + + led_rule: Optional[str] = None + + @staticmethod + def from_bool(state: bool): + """Set the led_rule from the state.""" + rule = "always" if state else "never" + return SmartRequest.LedStatusParams(led_rule=rule) + + @dataclass + class LightInfoParams(SmartRequestParams): + """LightInfo params.""" + + brightness: Optional[int] = None + color_temp: Optional[int] = None + hue: Optional[int] = None + saturation: Optional[int] = None + + @dataclass + class DynamicLightEffectParams(SmartRequestParams): + """LightInfo params.""" + + enable: bool + id: Optional[str] = None + + @staticmethod + def get_raw_request( + method: str, params: Optional[SmartRequestParams] = None + ) -> "SmartRequest": + """Send a raw request to the device.""" + return SmartRequest(method, params) + + @staticmethod + def component_nego() -> "SmartRequest": + """Get quick setup component info.""" + return SmartRequest("component_nego") + + @staticmethod + def get_device_info() -> "SmartRequest": + """Get device info.""" + return SmartRequest("get_device_info") + + @staticmethod + def get_device_usage() -> "SmartRequest": + """Get device usage.""" + return SmartRequest("get_device_usage") + + @staticmethod + def device_info_list() -> List["SmartRequest"]: + """Get device info list.""" + return [ + SmartRequest.get_device_info(), + SmartRequest.get_device_usage(), + ] + + @staticmethod + def get_auto_update_info() -> "SmartRequest": + """Get auto update info.""" + return SmartRequest("get_auto_update_info") + + @staticmethod + def firmware_info_list() -> List["SmartRequest"]: + """Get info list.""" + return [ + SmartRequest.get_auto_update_info(), + SmartRequest.get_raw_request("get_fw_download_state"), + SmartRequest.get_raw_request("get_latest_fw"), + ] + + @staticmethod + def qs_component_nego() -> "SmartRequest": + """Get quick setup component info.""" + return SmartRequest("qs_component_nego") + + @staticmethod + def get_device_time() -> "SmartRequest": + """Get device time.""" + return SmartRequest("get_device_time") + + @staticmethod + def get_wireless_scan_info() -> "SmartRequest": + """Get wireless scan info.""" + return SmartRequest("get_wireless_scan_info") + + @staticmethod + def get_schedule_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest": + """Get schedule rules.""" + return SmartRequest( + "get_schedule_rules", params or SmartRequest.GetRulesParams() + ) + + @staticmethod + def get_next_event(params: Optional[GetRulesParams] = None) -> "SmartRequest": + """Get next scheduled event.""" + return SmartRequest("get_next_event", params or SmartRequest.GetRulesParams()) + + @staticmethod + def schedule_info_list() -> List["SmartRequest"]: + """Get schedule info list.""" + return [ + SmartRequest.get_schedule_rules(), + SmartRequest.get_next_event(), + ] + + @staticmethod + def get_countdown_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest": + """Get countdown rules.""" + return SmartRequest( + "get_countdown_rules", params or SmartRequest.GetRulesParams() + ) + + @staticmethod + def get_antitheft_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest": + """Get antitheft rules.""" + return SmartRequest( + "get_antitheft_rules", params or SmartRequest.GetRulesParams() + ) + + @staticmethod + def get_led_info(params: Optional[LedStatusParams] = None) -> "SmartRequest": + """Get led info.""" + return SmartRequest("get_led_info", params or SmartRequest.LedStatusParams()) + + @staticmethod + def get_auto_off_config(params: Optional[GetRulesParams] = None) -> "SmartRequest": + """Get auto off config.""" + return SmartRequest( + "get_auto_off_config", params or SmartRequest.GetRulesParams() + ) + + @staticmethod + def get_delay_action_info() -> "SmartRequest": + """Get delay action info.""" + return SmartRequest("get_delay_action_info") + + @staticmethod + def auto_off_list() -> List["SmartRequest"]: + """Get energy usage.""" + return [ + SmartRequest.get_auto_off_config(), + SmartRequest.get_delay_action_info(), # May not live here + ] + + @staticmethod + def get_energy_usage() -> "SmartRequest": + """Get energy usage.""" + return SmartRequest("get_energy_usage") + + @staticmethod + def energy_monitoring_list() -> List["SmartRequest"]: + """Get energy usage.""" + return [ + SmartRequest("get_energy_usage"), + SmartRequest.get_raw_request("get_electricity_price_config"), + ] + + @staticmethod + def get_current_power() -> "SmartRequest": + """Get current power.""" + return SmartRequest("get_current_power") + + @staticmethod + def power_protection_list() -> List["SmartRequest"]: + """Get power protection info list.""" + return [ + SmartRequest.get_current_power(), + SmartRequest.get_raw_request("get_max_power"), + SmartRequest.get_raw_request("get_protection_power"), + ] + + @staticmethod + def get_preset_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest": + """Get preset rules.""" + return SmartRequest("get_preset_rules", params or SmartRequest.GetRulesParams()) + + @staticmethod + def get_auto_light_info() -> "SmartRequest": + """Get auto light info.""" + return SmartRequest("get_auto_light_info") + + @staticmethod + def get_dynamic_light_effect_rules( + params: Optional[GetRulesParams] = None + ) -> "SmartRequest": + """Get dynamic light effect rules.""" + return SmartRequest( + "get_dynamic_light_effect_rules", params or SmartRequest.GetRulesParams() + ) + + @staticmethod + def set_device_on(params: DeviceOnParams) -> "SmartRequest": + """Set device on state.""" + return SmartRequest("set_device_info", params) + + @staticmethod + def set_light_info(params: LightInfoParams) -> "SmartRequest": + """Set color temperature.""" + return SmartRequest("set_device_info", params) + + @staticmethod + def set_dynamic_light_effect_rule_enable( + params: DynamicLightEffectParams + ) -> "SmartRequest": + """Enable dynamic light effect rule.""" + return SmartRequest("set_dynamic_light_effect_rule_enable", params) + + @staticmethod + def get_component_info_requests(component_nego_response) -> List["SmartRequest"]: + """Get a list of requests based on the component info response.""" + request_list = [] + for component in component_nego_response["component_list"]: + if requests := COMPONENT_REQUESTS.get(component["id"]): + request_list.extend(requests) + return request_list + + @staticmethod + def _create_request_dict( + smart_request: Union["SmartRequest", List["SmartRequest"]] + ) -> dict: + """Create request dict to be passed to SmartProtocol.query().""" + if isinstance(smart_request, list): + request = {} + for sr in smart_request: + request[sr.method_name] = sr.params + else: + request = smart_request.to_dict() + return request + + +COMPONENT_REQUESTS = { + "device": SmartRequest.device_info_list(), + "firmware": SmartRequest.firmware_info_list(), + "quick_setup": [SmartRequest.qs_component_nego()], + "inherit": [SmartRequest.get_raw_request("get_inherit_info")], + "time": [SmartRequest.get_device_time()], + "wireless": [SmartRequest.get_wireless_scan_info()], + "schedule": SmartRequest.schedule_info_list(), + "countdown": [SmartRequest.get_countdown_rules()], + "antitheft": [SmartRequest.get_antitheft_rules()], + "account": None, + "synchronize": None, # sync_env + "sunrise_sunset": None, # for schedules + "led": [SmartRequest.get_led_info()], + "cloud_connect": [SmartRequest.get_raw_request("get_connect_cloud_state")], + "iot_cloud": None, + "device_local_time": None, + "default_states": None, # in device_info + "auto_off": [SmartRequest.get_auto_off_config()], + "localSmart": None, + "energy_monitoring": SmartRequest.energy_monitoring_list(), + "power_protection": SmartRequest.power_protection_list(), + "current_protection": None, # overcurrent in device_info + "matter": None, + "preset": [SmartRequest.get_preset_rules()], + "brightness": None, # in device_info + "color": None, # in device_info + "color_temperature": None, # in device_info + "auto_light": [SmartRequest.get_auto_light_info()], + "light_effect": [SmartRequest.get_dynamic_light_effect_rules()], + "bulb_quick_control": None, + "on_off_gradually": [SmartRequest.get_raw_request("get_on_off_gradually_info")], +} From 9a2b513e6a5634b36f6eee0158f4d0328939f785 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 2 Jan 2024 18:49:52 +0100 Subject: [PATCH 215/892] Update L530 aes fixture (#603) --- .../fixtures/smart/L530E(EU)_3.0_1.0.6.json | 277 ++++++++++++++++-- 1 file changed, 258 insertions(+), 19 deletions(-) diff --git a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json index 06e7cded6..09fd8edb9 100644 --- a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json +++ b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json @@ -116,6 +116,28 @@ "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": 1 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, "get_device_info": { "avatar": "bulb", "brightness": 100, @@ -129,58 +151,275 @@ "state": { "brightness": 100, "color_temp": 2500, - "hue": 0, - "saturation": 100 + "hue": 31, + "saturation": 88 }, "type": "last_states" }, "device_id": "0000000000000000000000000000000000000000", - "device_on": true, + "device_on": false, "dynamic_light_effect_enable": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.6 Build 230509 Rel.195312", "has_set_location_info": true, - "hue": 0, + "hue": 31, "hw_id": "00000000000000000000000000000000", "hw_ver": "3.0", "ip": "127.0.0.123", - "lang": "de_DE", + "lang": "en_US", "latitude": 0, "longitude": 0, "mac": "00-00-00-00-00-00", "model": "L530", - "nickname": "c21hcnRlIFdMQU4tR2zDvGhiaXJuZQ==", + "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Europe/Berlin", - "rssi": -38, - "saturation": 100, + "rssi": -48, + "saturation": 88, "signal_level": 3, "specs": "", - "ssid": "IyNNQVNLRUROQU1FIyM=", + "ssid": "I01BU0tFRF9TU0lEIw==", "time_diff": 60, "type": "SMART.TAPOBULB" }, "get_device_time": { "region": "Europe/Berlin", "time_diff": 60, - "timestamp": 1701618972 + "timestamp": 1704212283 }, "get_device_usage": { "power_usage": { - "past30": 107, - "past7": 107, - "today": 7 + "past30": 53, + "past7": 12, + "today": 0 }, "saved_power": { - "past30": 535, - "past7": 535, - "today": 41 + "past30": 494, + "past7": 112, + "today": 0 }, "time_usage": { - "past30": 642, - "past7": 642, - "today": 48 + "past30": 547, + "past7": 124, + "today": 0 + } + }, + "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_next_event": {}, + "get_on_off_gradually_info": { + "enable": false + }, + "get_preset_rules": { + "states": [ + { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 277, + "saturation": 86 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 60, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 300, + "saturation": 100 + } + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L530", + "device_type": "SMART.TAPOBULB" } } } From 864ea92eceb4bf74a040f9b009dba07adc02e13c Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 2 Jan 2024 19:34:39 +0100 Subject: [PATCH 216/892] Update P110(EU) fixture (#604) --- .../fixtures/smart/P110(EU)_1.0_1.2.3.json | 265 ++++++++++++++++-- 1 file changed, 242 insertions(+), 23 deletions(-) diff --git a/kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json b/kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json index 8c45a971b..e73742b77 100644 --- a/kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json +++ b/kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json @@ -96,8 +96,30 @@ "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": 1 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, "get_current_power": { - "current_power": 2 + "current_power": 0 }, "get_device_info": { "auto_off_remain_time": 0, @@ -108,64 +130,261 @@ "type": "last_states" }, "device_id": "0000000000000000000000000000000000000000", - "device_on": true, + "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.2.3 Build 230425 Rel.142542", "has_set_location_info": false, "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", "ip": "127.0.0.123", - "lang": "de_DE", + "lang": "en_US", "latitude": 0, "longitude": 0, "mac": "00-00-00-00-00-00", "model": "P110", - "nickname": "VGFwb3BsdWc=", + "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", - "on_time": 147207, + "on_time": 0, "overheated": false, "power_protection_status": "normal", "region": "Europe/Berlin", - "rssi": -45, + "rssi": -42, "signal_level": 3, "specs": "", - "ssid": "IyNNQVNLRUROQU1FIyM=", + "ssid": "I01BU0tFRF9TU0lEIw==", "time_diff": 60, "type": "SMART.TAPOPLUG" }, "get_device_time": { "region": "Europe/Berlin", "time_diff": 60, - "timestamp": 1701788347 + "timestamp": 1704212614 }, "get_device_usage": { "power_usage": { - "past30": 91, - "past7": 91, - "today": 36 + "past30": 896, + "past7": 259, + "today": 0 }, "saved_power": { - "past30": 2380, - "past7": 2363, - "today": 923 + "past30": 23138, + "past7": 6684, + "today": 0 }, "time_usage": { - "past30": 2471, - "past7": 2454, - "today": 959 + "past30": 24034, + "past7": 6943, + "today": 0 } }, + "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": 2225, + "current_power": 0, "electricity_charge": [ 0, 0, 0 ], - "local_time": "2023-12-05 15:59:07", - "month_energy": 91, - "month_runtime": 2454, - "today_energy": 36, - "today_runtime": 959 + "local_time": "2024-01-02 17:23:34", + "month_energy": 0, + "month_runtime": 0, + "today_energy": 0, + "today_runtime": 0 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_led_info": { + "led_rule": "always", + "led_status": false, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_max_power": { + "max_power": 3847 + }, + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P110", + "device_type": "SMART.TAPOPLUG" + } } } From 10fc2c3c5427fe3089e031ef27198ec79e4a0763 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 3 Jan 2024 19:04:34 +0100 Subject: [PATCH 217/892] Pull up emeter handling to tapodevice base class (#601) * Pull has_emeter property up to tapodevice base class This will also use the existence of energy_monitoring in the component_nego query to decide if the device has the service. * Move emeter related functions to tapodevice * Remove supported_modules override for now This should be done in a separate PR, if we want to expose the available components to cli and downstreams * Dedent extra reqs * Move extra_reqs initialization * Fix tests --- kasa/tapo/tapobulb.py | 11 ----- kasa/tapo/tapodevice.py | 77 ++++++++++++++++++++++++++++++++-- kasa/tapo/tapoplug.py | 63 +--------------------------- kasa/tests/test_smartdevice.py | 4 +- 4 files changed, 76 insertions(+), 79 deletions(-) diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py index 3640074f4..d1e953d72 100644 --- a/kasa/tapo/tapobulb.py +++ b/kasa/tapo/tapobulb.py @@ -17,17 +17,6 @@ class TapoBulb(TapoDevice, SmartBulb): Documentation TBD. See :class:`~kasa.smartbulb.SmartBulb` for now. """ - @property - def has_emeter(self) -> bool: - """Bulbs have only historical emeter. - - {'usage': - 'power_usage': {'today': 6, 'past7': 106, 'past30': 106}, - 'saved_power': {'today': 35, 'past7': 529, 'past30': 529}, - } - """ - return False - @property def is_color(self) -> bool: """Whether the bulb supports color changes.""" diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 717de7ef4..24848843b 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -6,7 +6,9 @@ from ..aestransport import AesTransport from ..deviceconfig import DeviceConfig +from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationException +from ..modules import Emeter from ..protocol import TPLinkProtocol from ..smartdevice import SmartDevice from ..smartprotocol import SmartProtocol @@ -28,38 +30,67 @@ def __init__( transport=AesTransport(config=config or DeviceConfig(host=host)), ) super().__init__(host=host, config=config, protocol=_protocol) - self._components: Optional[Dict[str, Any]] = None + self._components_raw: Optional[Dict[str, Any]] = None + self._components: Dict[str, int] self._state_information: Dict[str, Any] = {} self._discovery_info: Optional[Dict[str, Any]] = None + self.modules: Dict[str, Any] = {} async def update(self, update_children: bool = True): """Update the device.""" if self.credentials is None or self.credentials.username is None: raise AuthenticationException("Tapo plug requires authentication.") - if self._components is None: + if self._components_raw is None: resp = await self.protocol.query("component_nego") - self._components = resp["component_nego"] + self._components_raw = resp["component_nego"] + self._components = { + comp["id"]: comp["ver_code"] + for comp in self._components_raw["component_list"] + } + await self._initialize_modules() + + extra_reqs: Dict[str, Any] = {} + if "energy_monitoring" in self._components: + extra_reqs = { + **extra_reqs, + "get_energy_usage": None, + "get_current_power": None, + } req = { "get_device_info": None, "get_device_usage": None, "get_device_time": None, + **extra_reqs, } + resp = await self.protocol.query(req) + self._info = resp["get_device_info"] self._usage = resp["get_device_usage"] self._time = resp["get_device_time"] + # Emeter is not always available, but we set them still for now. + self._energy = resp.get("get_energy_usage", {}) + self._emeter = resp.get("get_current_power", {}) self._last_update = self._data = { - "components": self._components, + "components": self._components_raw, "info": self._info, "usage": self._usage, "time": self._time, + "energy": self._energy, + "emeter": self._emeter, } _LOGGER.debug("Got an update: %s", self._data) + async def _initialize_modules(self): + """Initialize modules based on component negotiation response.""" + if "energy_monitoring" in self._components: + self.emeter_type = "emeter" + self.modules["emeter"] = Emeter(self, self.emeter_type) + @property def sys_info(self) -> Dict[str, Any]: """Returns the device info.""" @@ -161,6 +192,11 @@ def features(self) -> Set[str]: # TODO: return set() + @property + def has_emeter(self) -> bool: + """Return if the device has emeter.""" + return "energy_monitoring" in self._components + @property def is_on(self) -> bool: """Return true if the device is on.""" @@ -178,3 +214,36 @@ def update_from_discover_info(self, info): """Update state from info from the discover call.""" self._discovery_info = info self._info = info + + async def get_emeter_realtime(self) -> EmeterStatus: + """Retrieve current energy readings.""" + self._verify_emeter() + resp = await self.protocol.query("get_energy_usage") + self._energy = resp["get_energy_usage"] + return self.emeter_realtime + + def _convert_energy_data(self, data, scale) -> Optional[float]: + """Return adjusted emeter information.""" + return data if not data else data * scale + + @property + def emeter_realtime(self) -> EmeterStatus: + """Get the emeter status.""" + return EmeterStatus( + { + "power_mw": self._energy.get("current_power"), + "total": self._convert_energy_data( + self._energy.get("today_energy"), 1 / 1000 + ), + } + ) + + @property + def emeter_this_month(self) -> Optional[float]: + """Get the emeter value for this month.""" + return self._convert_energy_data(self._energy.get("month_energy"), 1 / 1000) + + @property + def emeter_today(self) -> Optional[float]: + """Get the emeter value for today.""" + return self._convert_energy_data(self._energy.get("today_energy"), 1 / 1000) diff --git a/kasa/tapo/tapoplug.py b/kasa/tapo/tapoplug.py index 67aed565a..bb20f5cc5 100644 --- a/kasa/tapo/tapoplug.py +++ b/kasa/tapo/tapoplug.py @@ -4,10 +4,8 @@ from typing import Any, Dict, Optional, cast from ..deviceconfig import DeviceConfig -from ..emeterstatus import EmeterStatus -from ..modules import Emeter from ..protocol import TPLinkProtocol -from ..smartdevice import DeviceType, requires_update +from ..smartdevice import DeviceType from .tapodevice import TapoDevice _LOGGER = logging.getLogger(__name__) @@ -25,32 +23,6 @@ def __init__( ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug - self.modules: Dict[str, Any] = {} - self.emeter_type = "emeter" - self.modules["emeter"] = Emeter(self, self.emeter_type) - - @property # type: ignore - @requires_update - def has_emeter(self) -> bool: - """Return that the plug has an emeter.""" - return True - - async def update(self, update_children: bool = True): - """Call the device endpoint and update the device data.""" - await super().update(update_children) - - req = { - "get_energy_usage": None, - "get_current_power": None, - } - resp = await self.protocol.query(req) - self._energy = resp["get_energy_usage"] - self._emeter = resp["get_current_power"] - - self._data["energy"] = self._energy - self._data["emeter"] = self._emeter - - _LOGGER.debug("Got an update: %s %s", self._energy, self._emeter) @property def state_information(self) -> Dict[str, Any]: @@ -64,35 +36,6 @@ def state_information(self) -> Dict[str, Any]: }, } - @property - def emeter_realtime(self) -> EmeterStatus: - """Get the emeter status.""" - return EmeterStatus( - { - "power_mw": self._energy.get("current_power"), - "total": self._convert_energy_data( - self._energy.get("today_energy"), 1 / 1000 - ), - } - ) - - async def get_emeter_realtime(self) -> EmeterStatus: - """Retrieve current energy readings.""" - self._verify_emeter() - resp = await self.protocol.query("get_energy_usage") - self._energy = resp["get_energy_usage"] - return self.emeter_realtime - - @property - def emeter_today(self) -> Optional[float]: - """Get the emeter value for today.""" - return self._convert_energy_data(self._energy.get("today_energy"), 1 / 1000) - - @property - def emeter_this_month(self) -> Optional[float]: - """Get the emeter value for this month.""" - return self._convert_energy_data(self._energy.get("month_energy"), 1 / 1000) - @property def on_since(self) -> Optional[datetime]: """Return the time that the device was turned on or None if turned off.""" @@ -100,7 +43,3 @@ def on_since(self) -> Optional[datetime]: return None on_time = cast(float, self._info.get("on_time")) return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) - - def _convert_energy_data(self, data, scale) -> Optional[float]: - """Return adjusted emeter information.""" - return data if not data else data * scale diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index a3019bff6..b2ae9c33f 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -7,7 +7,7 @@ import kasa from kasa import Credentials, DeviceConfig, SmartDevice, SmartDeviceException -from .conftest import device_iot, handle_turn_on, has_emeter, no_emeter_iot, turn_on +from .conftest import device_iot, handle_turn_on, has_emeter_iot, no_emeter_iot, turn_on from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol # List of all SmartXXX classes including the SmartDevice base class @@ -35,7 +35,7 @@ async def test_invalid_connection(dev): await dev.update() -@has_emeter +@has_emeter_iot async def test_initial_update_emeter(dev, mocker): """Test that the initial update performs second query if emeter is available.""" dev._last_update = None From 30c4e6a6a3f44007f7bfeb22fee307a66c3ebc62 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 3 Jan 2024 19:26:52 +0100 Subject: [PATCH 218/892] Cleanup credentials handling (#605) * credentials: don't allow none to simplify checks * Implement __bool__ for credentials * Cleanup klaptransport cred usage * Cleanup deviceconfig and tapodevice * fix linting * Pass dummy credentials for tests * Remove __bool__ dunder and add docs to credentials * Check for cred noneness in tapodevice.update() --- kasa/credentials.py | 7 ++++--- kasa/deviceconfig.py | 6 ++---- kasa/exceptions.py | 1 + kasa/klaptransport.py | 13 +++++++------ kasa/tapo/tapodevice.py | 4 ++-- kasa/tests/newfakes.py | 8 +++++++- kasa/tests/test_device_factory.py | 7 ++++++- 7 files changed, 29 insertions(+), 17 deletions(-) diff --git a/kasa/credentials.py b/kasa/credentials.py index 4ae4df356..3cc0b0162 100644 --- a/kasa/credentials.py +++ b/kasa/credentials.py @@ -1,12 +1,13 @@ """Credentials class for username / passwords.""" from dataclasses import dataclass, field -from typing import Optional @dataclass class Credentials: """Credentials for authentication.""" - username: Optional[str] = field(default="", repr=False) - password: Optional[str] = field(default="", repr=False) + #: Username (email address) of the cloud account + username: str = field(default="", repr=False) + #: Password of the cloud account + password: str = field(default="", repr=False) diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 7a774b2ea..63b08d3c5 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -117,9 +117,7 @@ class DeviceConfig: host: str timeout: Optional[int] = DEFAULT_TIMEOUT port_override: Optional[int] = None - credentials: Credentials = field( - default_factory=lambda: Credentials(username="", password="") - ) + credentials: Credentials = field(default_factory=lambda: Credentials()) connection_type: ConnectionType = field( default_factory=lambda: ConnectionType( DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Xor @@ -132,7 +130,7 @@ class DeviceConfig: def __post_init__(self): if self.credentials is None: - self.credentials = Credentials(username="", password="") + self.credentials = Credentials() if self.connection_type is None: self.connection_type = ConnectionType( DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Xor diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 97fabb041..99b7974d2 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -26,6 +26,7 @@ class AuthenticationException(SmartDeviceException): class RetryableException(SmartDeviceException): """Retryable exception for device errors.""" + class TimeoutException(SmartDeviceException): """Timeout exception for device errors.""" diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 0e7ef565a..945346eeb 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -221,7 +221,8 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: return local_seed, remote_seed, self._kasa_setup_auth_hash # type: ignore # Finally check against blank credentials if not already blank - if self._credentials != (blank_creds := Credentials(username="", password="")): + blank_creds = Credentials() + if self._credentials != blank_creds: if not self._blank_auth_hash: self._blank_auth_hash = self.generate_auth_hash(blank_creds) @@ -369,8 +370,8 @@ async def close(self) -> None: @staticmethod def generate_auth_hash(creds: Credentials): """Generate an md5 auth hash for the protocol on the supplied credentials.""" - un = creds.username or "" - pw = creds.password or "" + un = creds.username + pw = creds.password return md5(md5(un.encode()) + md5(pw.encode())) @@ -391,7 +392,7 @@ def handshake2_seed_auth_hash( @staticmethod def generate_owner_hash(creds: Credentials): """Return the MD5 hash of the username in this object.""" - un = creds.username or "" + un = creds.username return md5(un.encode()) @@ -401,8 +402,8 @@ class KlapTransportV2(KlapTransport): @staticmethod def generate_auth_hash(creds: Credentials): """Generate an md5 auth hash for the protocol on the supplied credentials.""" - un = creds.username or "" - pw = creds.password or "" + un = creds.username + pw = creds.password return _sha256(_sha1(un.encode()) + _sha1(pw.encode())) diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 24848843b..9a7731d35 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -38,8 +38,8 @@ def __init__( async def update(self, update_children: bool = True): """Update the device.""" - if self.credentials is None or self.credentials.username is None: - raise AuthenticationException("Tapo plug requires authentication.") + if self.credentials is None: + raise AuthenticationException("Device requires authentication.") if self._components_raw is None: resp = await self.protocol.query("component_nego") diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 13d11d3d9..ec50321c9 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -305,7 +305,13 @@ async def query(self, request, retry_count: int = 3): class FakeSmartTransport(BaseTransport): def __init__(self, info): super().__init__( - config=DeviceConfig("127.0.0.123", credentials=Credentials()), + config=DeviceConfig( + "127.0.0.123", + credentials=Credentials( + username="dummy_user", + password="dummy_password", # noqa: S106 + ), + ), ) self.info = info diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index 666bd9e95..82802c40e 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -76,7 +76,12 @@ async def test_connect_custom_port(all_fixture_data: dict, mocker, custom_port): host = "127.0.0.1" ctype, _ = _get_connection_type_device_class(all_fixture_data) - config = DeviceConfig(host=host, port_override=custom_port, connection_type=ctype) + config = DeviceConfig( + host=host, + port_override=custom_port, + connection_type=ctype, + credentials=Credentials("dummy_user", "dummy_password"), + ) default_port = 80 if "discovery_result" in all_fixture_data else 9999 ctype, _ = _get_connection_type_device_class(all_fixture_data) From c810298b04a44bed3e0013d505a751197e015715 Mon Sep 17 00:00:00 2001 From: gimpy88 <64541114+gimpy88@users.noreply.github.com> Date: Wed, 3 Jan 2024 13:31:42 -0500 Subject: [PATCH 219/892] Add support for KS205 and KS225 wall switches (#594) * KS205 Fixture * KS225 Fixture * Added Smart.KasaSwitch device type * Added KS225 to test * Added variable color temp check * Added supported devices to readme * Removed parenthesis * Updated fixtures * Fixed for ruff --- README.md | 2 + kasa/device_factory.py | 1 + kasa/deviceconfig.py | 1 + kasa/tapo/tapobulb.py | 3 + kasa/tests/conftest.py | 6 +- .../fixtures/smart/KS205(US)_1.0_1.0.2.json | 243 ++++++++++++++++ .../fixtures/smart/KS225(US)_1.0_1.0.2.json | 269 ++++++++++++++++++ kasa/tests/test_bulb.py | 2 +- 8 files changed, 523 insertions(+), 4 deletions(-) create mode 100644 kasa/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json create mode 100644 kasa/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json diff --git a/README.md b/README.md index 64450aa45..5c96b0baf 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,8 @@ At the moment, the following devices have been confirmed to work: * Tapo P110 (plug) * Tapo L530E (bulb) +* Kasa KS205 (Wifi/Matter Wall Switch) +* Kasa KS225 (Wifi/Matter Wall Dimmer Switch) 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 diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 505b64870..5ffabda0d 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -132,6 +132,7 @@ def get_device_class_from_family(device_type: str) -> Optional[Type[SmartDevice] "SMART.TAPOPLUG": TapoPlug, "SMART.TAPOBULB": TapoBulb, "SMART.KASAPLUG": TapoPlug, + "SMART.KASASWITCH": TapoBulb, "IOT.SMARTPLUGSWITCH": SmartPlug, "IOT.SMARTBULB": SmartBulb, } diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 63b08d3c5..c753c2bc1 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -26,6 +26,7 @@ class DeviceFamilyType(Enum): IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH" IotSmartBulb = "IOT.SMARTBULB" SmartKasaPlug = "SMART.KASAPLUG" + SmartKasaSwitch = "SMART.KASASWITCH" SmartTapoPlug = "SMART.TAPOPLUG" SmartTapoBulb = "SMART.TAPOBULB" diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py index d1e953d72..416658bf7 100644 --- a/kasa/tapo/tapobulb.py +++ b/kasa/tapo/tapobulb.py @@ -41,6 +41,9 @@ def valid_temperature_range(self) -> ColorTempRange: :return: White temperature range in Kelvin (minimum, maximum) """ + if not self.is_variable_color_temp: + raise SmartDeviceException("Color temperature not supported") + ct_range = self._info.get("color_temp_range", [0, 0]) return ColorTempRange(min=ct_range[0], max=ct_range[1]) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 11efe6937..fc5a1b9eb 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -46,7 +46,7 @@ BULBS_SMART_VARIABLE_TEMP = {"L530E"} BULBS_SMART_COLOR = {"L530E"} BULBS_SMART_LIGHT_STRIP: Set[str] = set() -BULBS_SMART_DIMMABLE: Set[str] = set() +BULBS_SMART_DIMMABLE = {"KS225"} BULBS_SMART = ( BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) .union(BULBS_SMART_DIMMABLE) @@ -98,7 +98,7 @@ "KP401", "KS200M", } -PLUGS_SMART = {"P110", "KP125M", "EP25"} +PLUGS_SMART = {"P110", "KP125M", "EP25", "KS205"} PLUGS = { *PLUGS_IOT, *PLUGS_SMART, @@ -115,7 +115,7 @@ } WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} -WITH_EMETER_SMART = {*PLUGS_SMART} +WITH_EMETER_SMART = {"P110", "KP125M", "EP25"} WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} DIMMABLE = {*BULBS, *DIMMERS} diff --git a/kasa/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json b/kasa/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json new file mode 100644 index 000000000..c94d4f2a8 --- /dev/null +++ b/kasa/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json @@ -0,0 +1,243 @@ +{ + "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": 2 + }, + { + "id": "delay_action", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS205(US)", + "device_type": "SMART.KASASWITCH", + "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_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": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.2 Build 230810 Rel.140202", + "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", + "model": "KS205", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "America/Toronto", + "rssi": -49, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Toronto", + "time_diff": -300, + "timestamp": 1704216652 + }, + "get_device_usage": { + "time_usage": { + "past30": 10908, + "past7": 3476, + "today": 10 + } + }, + "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 230810 Rel.140202", + "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": 476, + "night_mode_type": "sunrise_sunset", + "start_time": 1020, + "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": [], + "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": "KS205", + "device_type": "SMART.KASASWITCH", + "is_klap": false + } + } +} diff --git a/kasa/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json b/kasa/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json new file mode 100644 index 000000000..e6945cb88 --- /dev/null +++ b/kasa/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json @@ -0,0 +1,269 @@ +{ + "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": "KS225(US)", + "device_type": "SMART.KASASWITCH", + "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": "switch_s500d", + "brightness": 34, + "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.2 Build 230810 Rel.141013", + "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", + "model": "KS225", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/Toronto", + "rssi": -51, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Toronto", + "time_diff": -300, + "timestamp": 1704216678 + }, + "get_device_usage": { + "time_usage": { + "past30": 7410, + "past7": 2190, + "today": 1 + } + }, + "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.2 Build 230810 Rel.141013", + "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": 476, + "night_mode_type": "sunrise_sunset", + "start_time": 1020, + "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": [], + "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": "KS225", + "device_type": "SMART.KASASWITCH", + "is_klap": false + } + } +} diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 5bacf3cc5..0676022ba 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -171,7 +171,7 @@ async def test_non_variable_temp(dev: SmartBulb): await dev.set_color_temp(2700) with pytest.raises(SmartDeviceException): - dev.valid_temperature_range() + print(dev.valid_temperature_range) with pytest.raises(SmartDeviceException): print(dev.color_temp) From 3692e4812ff4207f2c30d122287d6f9132d1a7a4 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 3 Jan 2024 22:45:16 +0100 Subject: [PATCH 220/892] Implement wifi interface for tapodevice (#606) * Implement wifi interface for tapodevice * Implement wifi_join Tested to work on P110 * Fix linting --- kasa/cli.py | 4 +-- kasa/smartdevice.py | 7 ++-- kasa/tapo/tapodevice.py | 80 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 13458b0e0..1fb522cf1 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -345,10 +345,10 @@ async def scan(dev): @wifi.command() @click.argument("ssid") +@click.option("--keytype", prompt=True) @click.option("--password", prompt=True, hide_input=True) -@click.option("--keytype", default=3) @pass_dev -async def join(dev: SmartDevice, ssid, password, keytype): +async def join(dev: SmartDevice, ssid: str, password: str, keytype: str): """Join the given wifi network.""" echo(f"Asking the device to connect to {ssid}..") res = await dev.wifi_join(ssid, password, keytype=keytype) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 97b46ddca..c35618122 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -42,6 +42,9 @@ class WifiNetwork: channel: Optional[int] = None rssi: Optional[int] = None + # For SMART devices + signal_level: Optional[int] = None + def merge(d, u): """Update dict recursively.""" @@ -687,7 +690,7 @@ async def _scan(target): return [WifiNetwork(**x) for x in info["ap_list"]] - async def wifi_join(self, ssid, password, keytype=3): # noqa: D202 + async def wifi_join(self, ssid: str, password: str, keytype: str = "3"): # noqa: D202 """Join the given wifi network. If joining the network fails, the device will return to AP mode after a while. @@ -696,7 +699,7 @@ async def wifi_join(self, ssid, password, keytype=3): # noqa: D202 async def _join(target, payload): return await self._query_helper(target, "set_stainfo", payload) - payload = {"ssid": ssid, "password": password, "key_type": keytype} + payload = {"ssid": ssid, "password": password, "key_type": int(keytype)} try: return await _join("netif", payload) except SmartDeviceException as ex: diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 9a7731d35..785269a39 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -2,15 +2,15 @@ import base64 import logging from datetime import datetime, timedelta, timezone -from typing import Any, Dict, Optional, Set, cast +from typing import Any, Dict, List, Optional, Set, cast from ..aestransport import AesTransport from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus -from ..exceptions import AuthenticationException +from ..exceptions import AuthenticationException, SmartDeviceException from ..modules import Emeter from ..protocol import TPLinkProtocol -from ..smartdevice import SmartDevice +from ..smartdevice import SmartDevice, WifiNetwork from ..smartprotocol import SmartProtocol _LOGGER = logging.getLogger(__name__) @@ -247,3 +247,77 @@ def emeter_this_month(self) -> Optional[float]: def emeter_today(self) -> Optional[float]: """Get the emeter value for today.""" return self._convert_energy_data(self._energy.get("today_energy"), 1 / 1000) + + async def wifi_scan(self) -> List[WifiNetwork]: + """Scan for available wifi networks.""" + + def _net_for_scan_info(res): + return WifiNetwork( + ssid=base64.b64decode(res["ssid"]).decode(), + cipher_type=res["cipher_type"], + key_type=res["key_type"], + channel=res["channel"], + signal_level=res["signal_level"], + bssid=res["bssid"], + ) + + async def _query_networks(networks=None, start_index=0): + _LOGGER.debug("Querying networks using start_index=%s", start_index) + if networks is None: + networks = [] + + resp = await self.protocol.query( + {"get_wireless_scan_info": {"start_index": start_index}} + ) + network_list = [ + _net_for_scan_info(net) + for net in resp["get_wireless_scan_info"]["ap_list"] + ] + networks.extend(network_list) + + if resp["get_wireless_scan_info"]["sum"] > start_index + 10: + return await _query_networks(networks, start_index=start_index + 10) + + return networks + + return await _query_networks() + + async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): + """Join the given wifi network. + + This method returns nothing as the device tries to activate the new + settings immediately instead of responding to the request. + + If joining the network fails, the device will return to the previous state + after some delay. + """ + if not self.credentials: + raise AuthenticationException("Device requires authentication.") + + payload = { + "account": { + "username": base64.b64encode( + self.credentials.username.encode() + ).decode(), + "password": base64.b64encode( + self.credentials.password.encode() + ).decode(), + }, + "wireless": { + "key_type": keytype, + "password": base64.b64encode(password.encode()).decode(), + "ssid": base64.b64encode(ssid.encode()).decode(), + }, + "time": self.internal_state["time"], + } + + # The device does not respond to the request but changes the settings + # immediately which causes us to timeout. + # Thus, We limit retries and suppress the raised exception as useless. + try: + return await self.protocol.query({"set_qs_info": payload}, retry_count=0) + except SmartDeviceException as ex: + if ex.error_code: # Re-raise on device-reported errors + raise + + _LOGGER.debug("Received an expected for wifi join, but this is expected") From e9bf9f58ee86e6110600aabee34507aba401d143 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Wed, 3 Jan 2024 21:46:08 +0000 Subject: [PATCH 221/892] Allow serializing and passing of credentials_hashes in DeviceConfig (#607) * Allow passing of credentials_hashes in DeviceConfig * Update following review --- kasa/aestransport.py | 37 +++++++++++++++++++---------- kasa/cli.py | 30 ++++++++++++++++++++--- kasa/deviceconfig.py | 42 ++++++++++++++++++++++++--------- kasa/discover.py | 4 +++- kasa/klaptransport.py | 15 ++++++++++-- kasa/protocol.py | 11 +++++++++ kasa/smartdevice.py | 5 ++++ kasa/tapo/tapodevice.py | 4 ++-- kasa/tests/conftest.py | 7 ++++++ kasa/tests/newfakes.py | 6 +++++ kasa/tests/test_deviceconfig.py | 27 +++++++++++++++++++++ kasa/tests/test_discovery.py | 10 ++++++-- kasa/tests/test_protocol.py | 19 +++++++++++++++ 13 files changed, 183 insertions(+), 34 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index df26c4c49..919732cce 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -16,6 +16,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from .credentials import Credentials from .deviceconfig import DeviceConfig from .exceptions import ( SMART_AUTHENTICATION_ERRORS, @@ -62,6 +63,16 @@ def __init__( ) -> None: super().__init__(config=config) + self._login_version = config.connection_type.login_version + if not self._credentials and not self._credentials_hash: + self._credentials = Credentials() + if self._credentials: + self._login_params = self._get_login_params() + else: + self._login_params = json_loads( + base64.b64decode(self._credentials_hash.encode()).decode() # type: ignore[union-attr] + ) + self._default_http_client: Optional[httpx.AsyncClient] = None self._handshake_done = False @@ -80,6 +91,11 @@ def default_port(self): """Default port for the transport.""" return self.DEFAULT_PORT + @property + def credentials_hash(self) -> str: + """The hashed credentials used by the transport.""" + return base64.b64encode(json_dumps(self._login_params).encode()).decode() + @property def _http_client(self) -> httpx.AsyncClient: if self._config.http_client: @@ -88,6 +104,12 @@ def _http_client(self) -> httpx.AsyncClient: self._default_http_client = httpx.AsyncClient() return self._default_http_client + def _get_login_params(self): + """Get the login parameters based on the login_version.""" + un, pw = self.hash_credentials(self._login_version == 2) + password_field_name = "password2" if self._login_version == 2 else "password" + return {password_field_name: pw, "username": un} + def hash_credentials(self, login_v2): """Hash the credentials.""" if login_v2: @@ -171,14 +193,12 @@ async def send_secure_passthrough(self, request: str): resp_dict = json_loads(response) return resp_dict - async def _perform_login_for_version(self, *, login_version: int = 1): + async def perform_login(self): """Login to the device.""" self._login_token = None - un, pw = self.hash_credentials(login_version == 2) - password_field_name = "password2" if login_version == 2 else "password" login_request = { "method": "login_device", - "params": {password_field_name: pw, "username": un}, + "params": self._login_params, "request_time_milis": round(time.time() * 1000), } request = json_dumps(login_request) @@ -187,15 +207,6 @@ async def _perform_login_for_version(self, *, login_version: int = 1): self._handle_response_error_code(resp_dict, "Error logging in") self._login_token = resp_dict["result"]["token"] - async def perform_login(self) -> None: - """Login to the device.""" - try: - await self._perform_login_for_version(login_version=2) - except AuthenticationException: - _LOGGER.warning("Login version 2 failed, trying version 1") - await self.perform_handshake() - await self._perform_login_for_version(login_version=1) - async def perform_handshake(self): """Perform the handshake.""" _LOGGER.debug("Will perform handshaking...") diff --git a/kasa/cli.py b/kasa/cli.py index 1fb522cf1..6c60332da 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -184,6 +184,12 @@ def _device_to_serializable(val: SmartDevice): default=None, type=click.Choice(DEVICE_FAMILY_TYPES, case_sensitive=False), ) +@click.option( + "--login-version", + envvar="KASA_LOGIN_VERSION", + default=None, + type=int, +) @click.option( "--timeout", envvar="KASA_TIMEOUT", @@ -214,6 +220,13 @@ def _device_to_serializable(val: SmartDevice): envvar="KASA_PASSWORD", help="Password to use to authenticate to device.", ) +@click.option( + "--credentials-hash", + default=None, + required=False, + envvar="KASA_CREDENTIALS_HASH", + help="Hashed credentials used to authenticate to the device.", +) @click.version_option(package_name="python-kasa") @click.pass_context async def cli( @@ -227,11 +240,13 @@ async def cli( type, encrypt_type, device_family, + login_version, json, timeout, discovery_timeout, username, password, + credentials_hash, ): """A tool for controlling TP-Link smart home devices.""" # noqa # no need to perform any checks if we are just displaying the help @@ -291,7 +306,10 @@ def _nop_echo(*args, **kwargs): "username", "Using authentication requires both --username and --password" ) - credentials = Credentials(username=username, password=password) + if username: + credentials = Credentials(username=username, password=password) + else: + credentials = None if host is None: echo("No host name given, trying discovery..") @@ -300,13 +318,18 @@ def _nop_echo(*args, **kwargs): if type is not None: dev = TYPE_TO_CLASS[type](host) await dev.update() - elif device_family or encrypt_type: + elif device_family and encrypt_type: ctype = ConnectionType( DeviceFamilyType(device_family), EncryptType(encrypt_type), + login_version, ) config = DeviceConfig( - host=host, credentials=credentials, timeout=timeout, connection_type=ctype + host=host, + credentials=credentials, + credentials_hash=credentials_hash, + timeout=timeout, + connection_type=ctype, ) dev = await SmartDevice.connect(config=config) else: @@ -495,6 +518,7 @@ async def state(dev: SmartDevice): echo(f"[bold]== {dev.alias} - {dev.model} ==[/bold]") echo(f"\tHost: {dev.host}") echo(f"\tPort: {dev.port}") + echo(f"\tCredentials hash: {dev.credentials_hash}") echo(f"\tDevice state: {dev.is_on}") if dev.is_strip: echo("\t[bold]== Plugs ==[/bold]") diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index c753c2bc1..5235868f9 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -2,7 +2,7 @@ import logging from dataclasses import asdict, dataclass, field, fields, is_dataclass from enum import Enum -from typing import Dict, Optional +from typing import Dict, Optional, Union import httpx @@ -69,21 +69,25 @@ class ConnectionType: device_family: DeviceFamilyType encryption_type: EncryptType + login_version: Optional[int] = None @staticmethod def from_values( device_family: str, encryption_type: str, + login_version: Optional[int] = None, ) -> "ConnectionType": """Return connection parameters from string values.""" try: return ConnectionType( DeviceFamilyType(device_family), EncryptType(encryption_type), + login_version, ) - except ValueError as ex: + except (ValueError, TypeError) as ex: raise SmartDeviceException( - f"Invalid connection parameters for {device_family}.{encryption_type}" + f"Invalid connection parameters for {device_family}." + + f"{encryption_type}.{login_version}" ) from ex @staticmethod @@ -94,18 +98,26 @@ def from_dict(connection_type_dict: Dict[str, str]) -> "ConnectionType": and (device_family := connection_type_dict.get("device_family")) and (encryption_type := connection_type_dict.get("encryption_type")) ): - return ConnectionType.from_values(device_family, encryption_type) + if login_version := connection_type_dict.get("login_version"): + login_version = int(login_version) # type: ignore[assignment] + return ConnectionType.from_values( + device_family, + encryption_type, + login_version, # type: ignore[arg-type] + ) raise SmartDeviceException( f"Invalid connection type data for {connection_type_dict}" ) - def to_dict(self) -> Dict[str, str]: + def to_dict(self) -> Dict[str, Union[str, int]]: """Convert connection params to dict.""" - result = { + result: Dict[str, Union[str, int]] = { "device_family": self.device_family.value, "encryption_type": self.encryption_type.value, } + if self.login_version: + result["login_version"] = self.login_version return result @@ -118,10 +130,11 @@ class DeviceConfig: host: str timeout: Optional[int] = DEFAULT_TIMEOUT port_override: Optional[int] = None - credentials: Credentials = field(default_factory=lambda: Credentials()) + credentials: Optional[Credentials] = None + credentials_hash: Optional[str] = None connection_type: ConnectionType = field( default_factory=lambda: ConnectionType( - DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Xor + DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Xor, 1 ) ) @@ -130,15 +143,22 @@ class DeviceConfig: http_client: Optional[httpx.AsyncClient] = field(default=None, compare=False) def __post_init__(self): - if self.credentials is None: - self.credentials = Credentials() if self.connection_type is None: self.connection_type = ConnectionType( DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Xor ) - def to_dict(self) -> Dict[str, Dict[str, str]]: + def to_dict( + self, + *, + credentials_hash: Optional[str] = None, + exclude_credentials: bool = False, + ) -> Dict[str, Dict[str, str]]: """Convert connection params to dict.""" + if credentials_hash or exclude_credentials: + self.credentials = None + if credentials_hash: + self.credentials_hash = credentials_hash return _dataclass_to_dict(self) @staticmethod diff --git a/kasa/discover.py b/kasa/discover.py index e39122f3b..8fbd6ff0a 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -422,7 +422,9 @@ def _get_device_instance( try: config.connection_type = ConnectionType.from_values( - type_, discovery_result.mgt_encrypt_schm.encrypt_type + type_, + discovery_result.mgt_encrypt_schm.encrypt_type, + discovery_result.mgt_encrypt_schm.lv, ) except SmartDeviceException as ex: raise UnsupportedDeviceException( diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 945346eeb..8a77a7751 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -41,6 +41,7 @@ """ import asyncio +import base64 import datetime import hashlib import logging @@ -99,8 +100,13 @@ def __init__( self._default_http_client: Optional[httpx.AsyncClient] = None self._local_seed: Optional[bytes] = None - self._local_auth_hash = self.generate_auth_hash(self._credentials) - self._local_auth_owner = self.generate_owner_hash(self._credentials).hex() + if not self._credentials and not self._credentials_hash: + self._credentials = Credentials() + if self._credentials: + self._local_auth_hash = self.generate_auth_hash(self._credentials) + 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._blank_auth_hash = None self._handshake_lock = asyncio.Lock() @@ -119,6 +125,11 @@ def default_port(self): """Default port for the transport.""" return self.DEFAULT_PORT + @property + def credentials_hash(self) -> str: + """The hashed credentials used by the transport.""" + return base64.b64encode(self._local_auth_hash).decode() + @property def _http_client(self) -> httpx.AsyncClient: if self._config.http_client: diff --git a/kasa/protocol.py b/kasa/protocol.py index c998807c5..47d4a90b4 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -56,6 +56,7 @@ def __init__( self._host = config.host self._port = config.port_override or self.default_port self._credentials = config.credentials + self._credentials_hash = config.credentials_hash self._timeout = config.timeout @property @@ -63,6 +64,11 @@ def __init__( def default_port(self) -> int: """The default port for the transport.""" + @property + @abstractmethod + def credentials_hash(self) -> str: + """The hashed credentials used by the transport.""" + @abstractmethod async def send(self, request: str) -> Dict: """Send a message to the device and return a response.""" @@ -120,6 +126,11 @@ def default_port(self): """Default port for the transport.""" return self.DEFAULT_PORT + @property + def credentials_hash(self) -> str: + """The hashed credentials used by the transport.""" + return "" + async def send(self, request: str) -> Dict: """Send a message to the device and return a response.""" return {} diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index c35618122..912f7cd99 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -245,6 +245,11 @@ def credentials(self) -> Optional[Credentials]: """The device credentials.""" return self.protocol._transport._credentials + @property + def credentials_hash(self) -> Optional[str]: + """Return the connection parameters the device is using.""" + return self.protocol._transport.credentials_hash + def add_module(self, name: str, module: Module): """Register a module.""" if name in self.modules: diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 785269a39..4e5a96cdc 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -38,8 +38,8 @@ def __init__( async def update(self, update_children: bool = True): """Update the device.""" - if self.credentials is None: - raise AuthenticationException("Device requires authentication.") + if self.credentials is None and self.credentials_hash is None: + raise AuthenticationException("Tapo plug requires authentication.") if self._components_raw is None: resp = await self.protocol.query("component_nego") diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index fc5a1b9eb..8ef470001 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -430,6 +430,7 @@ class _DiscoveryMock: query_data: dict device_type: str encrypt_type: str + login_version: Optional[int] = None port_override: Optional[int] = None if "discovery_result" in all_fixture_data: @@ -438,6 +439,9 @@ class _DiscoveryMock: encrypt_type = all_fixture_data["discovery_result"]["mgt_encrypt_schm"][ "encrypt_type" ] + login_version = all_fixture_data["discovery_result"]["mgt_encrypt_schm"].get( + "lv" + ) datagram = ( b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + json_dumps(discovery_data).encode() @@ -450,12 +454,14 @@ class _DiscoveryMock: all_fixture_data, device_type, encrypt_type, + login_version, ) else: sys_info = all_fixture_data["system"]["get_sysinfo"] discovery_data = {"system": {"get_sysinfo": sys_info}} device_type = sys_info.get("mic_type") or sys_info.get("type") encrypt_type = "XOR" + login_version = None datagram = TPLinkSmartHomeProtocol.encrypt(json_dumps(discovery_data))[4:] dm = _DiscoveryMock( "127.0.0.123", @@ -465,6 +471,7 @@ class _DiscoveryMock: all_fixture_data, device_type, encrypt_type, + login_version, ) def mock_discover(self): diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index ec50321c9..064dbaeb1 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -1,3 +1,4 @@ +import base64 import copy import logging import re @@ -320,6 +321,11 @@ def default_port(self): """Default port for the transport.""" return 80 + @property + def credentials_hash(self): + """The hashed credentials used by the transport.""" + return self._credentials.username + self._credentials.password + "hash" + async def send(self, request: str): request_dict = json_loads(request) method = request_dict["method"] diff --git a/kasa/tests/test_deviceconfig.py b/kasa/tests/test_deviceconfig.py index 7970449dd..22d42b81a 100644 --- a/kasa/tests/test_deviceconfig.py +++ b/kasa/tests/test_deviceconfig.py @@ -19,3 +19,30 @@ def test_serialization(): config2_dict = json_loads(config_json) config2 = DeviceConfig.from_dict(config2_dict) assert config == config2 + + +def test_credentials_hash(): + config = DeviceConfig( + host="Foo", + http_client=httpx.AsyncClient(), + credentials=Credentials("foo", "bar"), + ) + config_dict = config.to_dict(credentials_hash="credhash") + config_json = json_dumps(config_dict) + config2_dict = json_loads(config_json) + config2 = DeviceConfig.from_dict(config2_dict) + assert config2.credentials_hash == "credhash" + assert config2.credentials is None + + +def test_no_credentials_serialization(): + config = DeviceConfig( + host="Foo", + http_client=httpx.AsyncClient(), + credentials=Credentials("foo", "bar"), + ) + config_dict = config.to_dict(exclude_credentials=True) + config_json = json_dumps(config_dict) + config2_dict = json_loads(config_json) + config2 = DeviceConfig.from_dict(config2_dict) + assert config2.credentials is None diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 396ef2f2e..51aedfb71 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -110,11 +110,17 @@ async def test_discover_single(discovery_mock, custom_port, mocker): assert update_mock.call_count == 0 ct = ConnectionType.from_values( - discovery_mock.device_type, discovery_mock.encrypt_type + discovery_mock.device_type, + discovery_mock.encrypt_type, + discovery_mock.login_version, ) uses_http = discovery_mock.default_port == 80 config = DeviceConfig( - host=host, port_override=custom_port, connection_type=ct, uses_http=uses_http + host=host, + port_override=custom_port, + connection_type=ct, + uses_http=uses_http, + credentials=Credentials(), ) assert x.config == config diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 0e74da3b8..05ae40f3d 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -9,8 +9,11 @@ import pytest +from ..aestransport import AesTransport +from ..credentials import Credentials from ..deviceconfig import DeviceConfig from ..exceptions import SmartDeviceException +from ..klaptransport import KlapTransport, KlapTransportV2 from ..protocol import ( BaseTransport, TPLinkProtocol, @@ -298,3 +301,19 @@ def test_transport_init_signature(class_name_obj): assert ( params[1].name == "config" and params[1].kind == inspect.Parameter.KEYWORD_ONLY ) + + +@pytest.mark.parametrize( + "transport_class", [AesTransport, KlapTransport, KlapTransportV2, _XorTransport] +) +async def test_transport_credentials_hash(mocker, transport_class): + host = "127.0.0.1" + + credentials = Credentials("Foo", "Bar") + config = DeviceConfig(host, credentials=credentials) + transport = transport_class(config=config) + credentials_hash = transport.credentials_hash + config = DeviceConfig(host, credentials_hash=credentials_hash) + transport = transport_class(config=config) + + assert transport.credentials_hash == credentials_hash From 047a84b60af2039524d7b314e4cc14e98f642dc0 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 4 Jan 2024 00:28:29 +0100 Subject: [PATCH 222/892] Release 0.6.0.dev0 (#609) --- CHANGELOG.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b254daf07..28ad5c53b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,85 @@ # Changelog +## [0.6.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev0) (2024-01-03) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.4...0.6.0.dev0) + +**Breaking changes:** + +- Add DeviceConfig to allow specifying configuration parameters [\#569](https://github.com/python-kasa/python-kasa/pull/569) (@sdb9696) +- Move connect\_single to SmartDevice.connect [\#538](https://github.com/python-kasa/python-kasa/pull/538) (@bdraco) + +**Implemented enhancements:** + +- Support for KS225\(US\) Light Dimmer and KS205\(US\) Light Switch [\#589](https://github.com/python-kasa/python-kasa/issues/589) +- Set timeout using command line parameters [\#310](https://github.com/python-kasa/python-kasa/issues/310) +- Implement the new protocol \(HTTP over 80/tcp, 20002/udp for discovery\) [\#115](https://github.com/python-kasa/python-kasa/issues/115) +- Allow serializing and passing of credentials\_hashes in DeviceConfig [\#607](https://github.com/python-kasa/python-kasa/pull/607) (@sdb9696) +- Implement wifi interface for tapodevice [\#606](https://github.com/python-kasa/python-kasa/pull/606) (@rytilahti) +- Add support for KS205 and KS225 wall switches [\#594](https://github.com/python-kasa/python-kasa/pull/594) (@gimpy88) +- Enable multiple requests in smartprotocol [\#584](https://github.com/python-kasa/python-kasa/pull/584) (@sdb9696) +- Improve CLI Discovery output [\#583](https://github.com/python-kasa/python-kasa/pull/583) (@sdb9696) +- Improve smartprotocol error handling and retries [\#578](https://github.com/python-kasa/python-kasa/pull/578) (@sdb9696) +- Request component\_nego only once for tapodevice [\#576](https://github.com/python-kasa/python-kasa/pull/576) (@rytilahti) +- Use consistent naming for cli envvars [\#570](https://github.com/python-kasa/python-kasa/pull/570) (@rytilahti) +- Add KP125M fixture and allow passing credentials for tests [\#567](https://github.com/python-kasa/python-kasa/pull/567) (@sbytnar) +- Make timeout configurable for cli [\#564](https://github.com/python-kasa/python-kasa/pull/564) (@rytilahti) +- Update dump\_devinfo to produce new TAPO/SMART fixtures [\#561](https://github.com/python-kasa/python-kasa/pull/561) (@sdb9696) +- Kasa KP125M basic emeter support [\#560](https://github.com/python-kasa/python-kasa/pull/560) (@sbytnar) +- Add support for tapo bulbs [\#558](https://github.com/python-kasa/python-kasa/pull/558) (@rytilahti) +- Add klap support for TAPO protocol by splitting out Transports and Protocols [\#557](https://github.com/python-kasa/python-kasa/pull/557) (@sdb9696) +- Update dump\_devinfo to include 20002 discovery results [\#556](https://github.com/python-kasa/python-kasa/pull/556) (@sdb9696) +- Set TCP\_NODELAY to avoid needless buffering [\#554](https://github.com/python-kasa/python-kasa/pull/554) (@bdraco) +- Add support for the protocol used by TAPO devices and some newer KASA devices. [\#552](https://github.com/python-kasa/python-kasa/pull/552) (@sdb9696) +- Re-add protocol\_class parameter to connect [\#551](https://github.com/python-kasa/python-kasa/pull/551) (@sdb9696) +- Update discover single to handle hostnames [\#539](https://github.com/python-kasa/python-kasa/pull/539) (@sdb9696) +- Add klap protocol [\#509](https://github.com/python-kasa/python-kasa/pull/509) (@sdb9696) + +**Fixed bugs:** + +- dump\_devinfo crashes when credentials are not given [\#591](https://github.com/python-kasa/python-kasa/issues/591) +- Fix hsv setting for tapobulb [\#573](https://github.com/python-kasa/python-kasa/pull/573) (@rytilahti) +- Fix transport retries after close [\#568](https://github.com/python-kasa/python-kasa/pull/568) (@sdb9696) + +**Documentation updates:** + +- Update readme with clearer instructions, tapo support [\#571](https://github.com/python-kasa/python-kasa/pull/571) (@rytilahti) +- Add some more external links to README [\#541](https://github.com/python-kasa/python-kasa/pull/541) (@rytilahti) + +**Closed issues:** + +- Discover returns dictionary with no 'alias' property [\#592](https://github.com/python-kasa/python-kasa/issues/592) +- Sending with the legacy protocol is needlessly delayed [\#553](https://github.com/python-kasa/python-kasa/issues/553) +- Issues adding a KP405 device [\#549](https://github.com/python-kasa/python-kasa/issues/549) +- Support for L510E bulb [\#547](https://github.com/python-kasa/python-kasa/issues/547) +- Support for tapo L530E bulbs? [\#546](https://github.com/python-kasa/python-kasa/issues/546) +- Unable to connect to host on different subnet with 0.5.4 [\#545](https://github.com/python-kasa/python-kasa/issues/545) +- Discovery/Connect broken when upgrading from 0.5.3 -\> 0.5.4 [\#543](https://github.com/python-kasa/python-kasa/issues/543) +- PydanticUserError, If you use `@root_validator` with pre=False \(the default\) you MUST specify `skip_on_failure=True` [\#516](https://github.com/python-kasa/python-kasa/issues/516) +- KP 125M / support for matter devices [\#450](https://github.com/python-kasa/python-kasa/issues/450) + +**Merged pull requests:** + +- Cleanup credentials handling [\#605](https://github.com/python-kasa/python-kasa/pull/605) (@rytilahti) +- Update P110\(EU\) fixture [\#604](https://github.com/python-kasa/python-kasa/pull/604) (@rytilahti) +- Update L530 aes fixture [\#603](https://github.com/python-kasa/python-kasa/pull/603) (@rytilahti) +- Cleanup custom exception kwarg handling [\#602](https://github.com/python-kasa/python-kasa/pull/602) (@rytilahti) +- Pull up emeter handling to tapodevice base class [\#601](https://github.com/python-kasa/python-kasa/pull/601) (@rytilahti) +- Add L530\(EU\) klap fixture [\#598](https://github.com/python-kasa/python-kasa/pull/598) (@sdb9696) +- Add known smart requests to dump\_devinfo [\#597](https://github.com/python-kasa/python-kasa/pull/597) (@sdb9696) +- Update P110\(UK\) fixture [\#596](https://github.com/python-kasa/python-kasa/pull/596) (@sdb9696) +- Fix dump\_devinfo for unauthenticated [\#593](https://github.com/python-kasa/python-kasa/pull/593) (@sdb9696) +- Elevate --verbose to top-level option [\#590](https://github.com/python-kasa/python-kasa/pull/590) (@rytilahti) +- Add optional error code to exceptions [\#585](https://github.com/python-kasa/python-kasa/pull/585) (@sdb9696) +- Fix typo in cli.rst [\#581](https://github.com/python-kasa/python-kasa/pull/581) (@alanblake) +- Do login entirely within AesTransport [\#580](https://github.com/python-kasa/python-kasa/pull/580) (@sdb9696) +- Log smartprotocol requests [\#575](https://github.com/python-kasa/python-kasa/pull/575) (@rytilahti) +- Add new methods to dump\_devinfo and mask aliases [\#574](https://github.com/python-kasa/python-kasa/pull/574) (@sdb9696) +- Add EP25 smart fixture and improve test framework for SMART devices [\#572](https://github.com/python-kasa/python-kasa/pull/572) (@sdb9696) +- Re-add regional suffix to TAPO/SMART fixtures [\#566](https://github.com/python-kasa/python-kasa/pull/566) (@sdb9696) +- Add P110 fixture [\#562](https://github.com/python-kasa/python-kasa/pull/562) (@rytilahti) +- Do not do update\(\) in discover\_single [\#542](https://github.com/python-kasa/python-kasa/pull/542) (@sdb9696) + ## [0.5.4](https://github.com/python-kasa/python-kasa/tree/0.5.4) (2023-10-29) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.3...0.5.4) @@ -22,10 +102,10 @@ As always, see the full changelog for details. - Add support for pydantic v2 using v1 shims [\#504](https://github.com/python-kasa/python-kasa/pull/504) (@rytilahti) - Split queries to avoid overflowing device buffers [\#502](https://github.com/python-kasa/python-kasa/pull/502) (@cobryan05) - Add toggle command to cli [\#498](https://github.com/python-kasa/python-kasa/pull/498) (@normanr) -- Make timeout adjustable [\#494](https://github.com/python-kasa/python-kasa/pull/494) (@bdraco) - Add support for alternative discovery protocol \(20002/udp\) [\#488](https://github.com/python-kasa/python-kasa/pull/488) (@sdb9696) - Add discovery timeout parameter [\#486](https://github.com/python-kasa/python-kasa/pull/486) (@sdb9696) - Add devtools script to create module fixtures [\#404](https://github.com/python-kasa/python-kasa/pull/404) (@rytilahti) +- Make timeout adjustable [\#494](https://github.com/python-kasa/python-kasa/pull/494) (@bdraco) **Fixed bugs:** @@ -50,9 +130,11 @@ As always, see the full changelog for details. - \[Feature Request\] Add a toggle command [\#492](https://github.com/python-kasa/python-kasa/issues/492) - \[Feature Request\] Pydantic 2.0+ Support [\#491](https://github.com/python-kasa/python-kasa/issues/491) - Support for EP10 Plug [\#170](https://github.com/python-kasa/python-kasa/issues/170) +- \[Request\] New release to pip? [\#518](https://github.com/python-kasa/python-kasa/issues/518) **Merged pull requests:** +- Release 0.5.4 [\#536](https://github.com/python-kasa/python-kasa/pull/536) (@rytilahti) - Use ruff and ruff format [\#534](https://github.com/python-kasa/python-kasa/pull/534) (@rytilahti) - Add python3.12 and pypy-3.10 to CI [\#532](https://github.com/python-kasa/python-kasa/pull/532) (@rytilahti) - Use trusted publisher for publishing to pypi [\#531](https://github.com/python-kasa/python-kasa/pull/531) (@rytilahti) diff --git a/pyproject.toml b/pyproject.toml index 24682df2c..6e4c9f4f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.5.4" +version = "0.6.0.dev0" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 1bb930b096dc48da48548d1e808a84f8afbc4707 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 4 Jan 2024 19:14:14 +0100 Subject: [PATCH 223/892] Ship CHANGELOG only in sdist (#610) Otherwise, the file would be extracted in the main site-packages which is rather unexpected.. Uninstalling python-kasa-0.6.0.dev0: Would remove: /home/tpr/.virtualenvs/default/bin/kasa /home/tpr/.virtualenvs/default/lib/python3.11/site-packages/CHANGELOG.md /home/tpr/.virtualenvs/default/lib/python3.11/site-packages/kasa/* /home/tpr/.virtualenvs/default/lib/python3.11/site-packages/python_kasa-0.6.0.dev0.dist-info/* Proceed (Y/n)? --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6e4c9f4f3..2a03bf69d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,9 @@ readme = "README.md" packages = [ { include = "kasa" } ] -include = ["CHANGELOG.md"] +include = [ + { path= "CHANGELOG.md", format = "sdist" } +] [tool.poetry.urls] "Bug Tracker" = "https://github.com/python-kasa/python-kasa/issues" From b156defc3cdc4a128b82fab8b4c1af82ba33f619 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Thu, 4 Jan 2024 18:17:48 +0000 Subject: [PATCH 224/892] Fix cli discover bug with None username/password (#615) --- kasa/aestransport.py | 4 +++- kasa/cli.py | 2 +- kasa/klaptransport.py | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 919732cce..bdae00d08 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -64,7 +64,9 @@ def __init__( super().__init__(config=config) self._login_version = config.connection_type.login_version - if not self._credentials and not self._credentials_hash: + if ( + not self._credentials or self._credentials.username is None + ) and not self._credentials_hash: self._credentials = Credentials() if self._credentials: self._login_params = self._get_login_params() diff --git a/kasa/cli.py b/kasa/cli.py index 6c60332da..f96bc6e70 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -395,7 +395,7 @@ async def discover(ctx): timeout = ctx.parent.params["timeout"] port = ctx.parent.params["port"] - credentials = Credentials(username, password) + credentials = Credentials(username, password) if username and password else None sem = asyncio.Semaphore() discovered = dict() diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 8a77a7751..220844a29 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -100,7 +100,9 @@ def __init__( self._default_http_client: Optional[httpx.AsyncClient] = None self._local_seed: Optional[bytes] = None - if not self._credentials and not self._credentials_hash: + if ( + not self._credentials or self._credentials.username is None + ) and not self._credentials_hash: self._credentials = Credentials() if self._credentials: self._local_auth_hash = self.generate_auth_hash(self._credentials) From 7a3eedeee9dc9c052418dd701743777c60cee6fa Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 4 Jan 2024 19:28:48 +0100 Subject: [PATCH 225/892] Check the ct range for color temp support (#619) --- kasa/tapo/tapobulb.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py index 416658bf7..bbaf093d6 100644 --- a/kasa/tapo/tapobulb.py +++ b/kasa/tapo/tapobulb.py @@ -32,8 +32,9 @@ def is_dimmable(self) -> bool: @property def is_variable_color_temp(self) -> bool: """Whether the bulb supports color temperature changes.""" - # TODO: this makes an assumption, that only ct bulbs report this - return bool(self._info.get("color_temp_range", False)) + ct = self._info.get("color_temp_range") + # L900 reports [9000, 9000] even when it doesn't support changing the ct + return ct is not None and ct[0] != ct[1] @property def valid_temperature_range(self) -> ColorTempRange: From 7e6eaf4ab2599452c995a0132f549dedf5405268 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 4 Jan 2024 19:28:59 +0100 Subject: [PATCH 226/892] Use consistent envvars for dump_devinfo credentials (#618) --- devtools/dump_devinfo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 985ce669f..30c9a3d2b 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -101,14 +101,14 @@ def default_to_regular(d): "--username", default="", required=False, - envvar="TPLINK_CLOUD_USERNAME", + envvar="KASA_USERNAME", help="Username/email address to authenticate to device.", ) @click.option( "--password", default="", required=False, - envvar="TPLINK_CLOUD_PASSWORD", + envvar="KASA_PASSWORD", help="Password to use to authenticate to device.", ) @click.option("-d", "--debug", is_flag=True) From 17d96064c224b0474f161cbc45e600a9b1cd8616 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 4 Jan 2024 19:52:11 +0100 Subject: [PATCH 227/892] Mark L900-5 as supported (#617) * Add fixture for L900-5 * Update readme --- README.md | 1 + kasa/tests/conftest.py | 4 +- .../fixtures/smart/L900-5(EU)_1.0_1.0.17.json | 306 ++++++++++++++++++ 3 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json diff --git a/README.md b/README.md index 5c96b0baf..b1d961a15 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,7 @@ At the moment, the following devices have been confirmed to work: * Tapo P110 (plug) * Tapo L530E (bulb) +* Tapo L900-5 (led strip) * Kasa KS205 (Wifi/Matter Wall Switch) * Kasa KS225 (Wifi/Matter Wall Dimmer Switch) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 8ef470001..6edf1b33d 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -44,8 +44,8 @@ # Tapo bulbs BULBS_SMART_VARIABLE_TEMP = {"L530E"} -BULBS_SMART_COLOR = {"L530E"} -BULBS_SMART_LIGHT_STRIP: Set[str] = set() +BULBS_SMART_LIGHT_STRIP = {"L900-5"} +BULBS_SMART_COLOR = {"L530E", *BULBS_SMART_LIGHT_STRIP} BULBS_SMART_DIMMABLE = {"KS225"} BULBS_SMART = ( BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) diff --git a/kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json b/kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json new file mode 100644 index 000000000..acf96268d --- /dev/null +++ b/kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json @@ -0,0 +1,306 @@ +{ + "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 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L900-5(EU)", + "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": 1 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "", + "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.17 Build 230426 Rel.153230", + "has_set_location_info": false, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "lighting_effect": { + "brightness": 50, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "longitude": 0, + "mac": "00-00-00-00-00-00", + "model": "L900", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -50, + "saturation": 100, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 0, + "timestamp": 1704389578 + }, + "get_device_usage": { + "power_usage": { + "past30": 5, + "past7": 5, + "today": 5 + }, + "saved_power": { + "past30": 17, + "past7": 17, + "today": 17 + }, + "time_usage": { + "past30": 22, + "past7": 22, + "today": 22 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "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": 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 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L900", + "device_type": "SMART.TAPOBULB" + } + } +} From efd67b92613dc1cfbbded9f71d7970b0e490134a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jan 2024 13:01:34 -1000 Subject: [PATCH 228/892] Add P125M and update EP25 fixtures (#621) * Add P125M and update EP25 fixtures * fix: adjust tests --- kasa/tests/conftest.py | 2 +- .../fixtures/smart/EP25(US)_2.6_1.0.1.json | 334 ++++++++++++++++-- .../fixtures/smart/P125M(US)_1.0_1.1.0.json | 243 +++++++++++++ 3 files changed, 552 insertions(+), 27 deletions(-) create mode 100644 kasa/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 6edf1b33d..1ee2f31f6 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -98,7 +98,7 @@ "KP401", "KS200M", } -PLUGS_SMART = {"P110", "KP125M", "EP25", "KS205"} +PLUGS_SMART = {"P110", "KP125M", "EP25", "KS205", "P125M"} PLUGS = { *PLUGS_IOT, *PLUGS_SMART, diff --git a/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json b/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json index a9c412284..61e12b253 100644 --- a/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json +++ b/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json @@ -97,11 +97,33 @@ "is_support_https": false, "lv": 2 }, - "obd_src": "apple", + "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": 715 + "current_power": 0 }, "get_device_info": { "auto_off_remain_time": 0, @@ -115,7 +137,7 @@ "device_on": true, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.1 Build 230614 Rel.150219", - "has_set_location_info": false, + "has_set_location_info": true, "hw_id": "00000000000000000000000000000000", "hw_ver": "2.6", "ip": "127.0.0.123", @@ -124,52 +146,312 @@ "longitude": 0, "mac": "00-00-00-00-00-00", "model": "EP25", - "nickname": "emVlaw==", + "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", - "on_time": 177938, + "on_time": 36, "overheated": false, "power_protection_status": "normal", - "region": "America/Los_Angeles", - "rssi": -58, + "region": "America/Chicago", + "rssi": -51, "signal_level": 2, "specs": "", - "ssid": "IyNNQVNLRUROQU1FIyM=", - "time_diff": -480, + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, "type": "SMART.KASAPLUG" }, "get_device_time": { - "region": "America/Los_Angeles", - "time_diff": -480, - "timestamp": 1701455103 + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1704406778 }, "get_device_usage": { "power_usage": { - "past30": 100856, - "past7": 56030, - "today": 3965 - }, - "saved_power": { "past30": 0, "past7": 0, "today": 0 }, + "saved_power": { + "past30": 38793, + "past7": 9619, + "today": 979 + }, "time_usage": { - "past30": 19678, - "past7": 9265, - "today": 625 + "past30": 38793, + "past7": 9619, + "today": 979 } }, + "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": 715092, + "current_power": 0, "electricity_charge": [ 0, 0, 0 ], - "local_time": "2023-12-01 10:25:03", - "month_energy": 3965, - "month_runtime": 625, - "today_energy": 3965, - "today_runtime": 625 + "local_time": "2024-01-04 16:19:38", + "month_energy": 0, + "month_runtime": 5299, + "today_energy": 0, + "today_runtime": 979 + }, + "get_fw_download_state": { + "auto_upgrade": true, + "download_progress": 100, + "reboot_time": 5, + "status": 3, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.0.2 Build 231108 Rel.163012", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-12-05", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced device security.\n2. Optimized Homekit setup.\n3. Improved time synchronization accuracy.\n4. Fixed some minor bugs.", + "type": 1 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 438, + "night_mode_type": "sunrise_sunset", + "start_time": 1056, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_max_power": { + "max_power": 1883 + }, + "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": [ + { + "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==" + } + ], + "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" + } } } 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 new file mode 100644 index 000000000..812cd1ea1 --- /dev/null +++ b/kasa/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json @@ -0,0 +1,243 @@ +{ + "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": "localSmart", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P125M(US)", + "device_type": "SMART.TAPOPLUG", + "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": "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_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_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.1.0 Build 231009 Rel.155831", + "has_set_location_info": false, + "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", + "model": "P125M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 76, + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -49, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1704406945 + }, + "get_device_usage": { + "time_usage": { + "past30": 16892, + "past7": 4, + "today": 4 + } + }, + "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.1.0 Build 231009 Rel.155831", + "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": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "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": [], + "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": "P125M", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} From 554fe0a96e68024a9cf893c58a92b83b707b78a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jan 2024 15:01:00 -1000 Subject: [PATCH 229/892] Avoid linear search for emeter realtime and emeter_today (#622) * Avoid linear search for emeter realtime and emeter_today Most of the time the data we want is at the end of the list so we now search backwards to avoid having to scale all the data and throw most of it away * more tweaks * coverage * coverage * preen * coverage * branch cover --- kasa/modules/emeter.py | 35 +++++++++++++++------ kasa/modules/usage.py | 25 +++++++-------- kasa/tests/test_emeter.py | 30 ++++++++++++++++++ kasa/tests/test_usage.py | 65 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 21 deletions(-) diff --git a/kasa/modules/emeter.py b/kasa/modules/emeter.py index 1fe6e679d..a205396ed 100644 --- a/kasa/modules/emeter.py +++ b/kasa/modules/emeter.py @@ -1,6 +1,6 @@ """Implementation of the emeter module.""" from datetime import datetime -from typing import Dict, Optional +from typing import Dict, List, Optional, Union from ..emeterstatus import EmeterStatus from .usage import Usage @@ -19,8 +19,7 @@ def emeter_today(self) -> Optional[float]: """Return today's energy consumption in kWh.""" raw_data = self.daily_data today = datetime.now().day - data = self._convert_stat_data(raw_data, entry_key="day") - + data = self._convert_stat_data(raw_data, entry_key="day", key=today) return data.get(today) @property @@ -28,8 +27,7 @@ def emeter_this_month(self) -> Optional[float]: """Return this month's energy consumption in kWh.""" raw_data = self.monthly_data current_month = datetime.now().month - data = self._convert_stat_data(raw_data, entry_key="month") - + data = self._convert_stat_data(raw_data, entry_key="month", key=current_month) return data.get(current_month) async def erase_stats(self): @@ -61,7 +59,13 @@ async def get_monthstat(self, *, year=None, kwh=True) -> Dict: data = self._convert_stat_data(data["month_list"], entry_key="month", kwh=kwh) return data - def _convert_stat_data(self, data, entry_key, kwh=True) -> Dict: + def _convert_stat_data( + self, + data: List[Dict[str, Union[int, float]]], + entry_key: str, + kwh: bool=True, + key: Optional[int] = None, + ) -> Dict[Union[int, float], Union[int, float]]: """Return emeter information keyed with the day/month. The incoming data is a list of dictionaries:: @@ -89,6 +93,19 @@ def _convert_stat_data(self, data, entry_key, kwh=True) -> Dict: if not kwh: scale = 1000 - data = {entry[entry_key]: entry[value_key] * scale for entry in data} - - return data + if key is None: + # Return all the data + return {entry[entry_key]: entry[value_key] * scale for entry in data} + + # In this case we want a specific key in the data + # i.e. the current day or month. + # + # Since we usually want the data at the end of the list so we can + # optimize the search by starting at the end and avoid scaling + # the data we don't need. + # + for entry in reversed(data): + if entry[entry_key] == key: + return {entry[entry_key]: entry[value_key] * scale} + + return {} diff --git a/kasa/modules/usage.py b/kasa/modules/usage.py index f33c71f11..10b9689d3 100644 --- a/kasa/modules/usage.py +++ b/kasa/modules/usage.py @@ -10,8 +10,9 @@ class Usage(Module): def query(self): """Return the base query.""" - year = datetime.now().year - month = datetime.now().month + now = datetime.now() + year = now.year + month = now.month req = self.query_for_command("get_realtime") req = merge( @@ -40,21 +41,21 @@ def monthly_data(self): def usage_today(self): """Return today's usage in minutes.""" today = datetime.now().day - converted = [x["time"] for x in self.daily_data if x["day"] == today] - if not converted: - return None - - return converted.pop() + # Traverse the list in reverse order to find the latest entry. + for entry in reversed(self.daily_data): + if entry["day"] == today: + return entry["time"] + return None @property def usage_this_month(self): """Return usage in this month in minutes.""" this_month = datetime.now().month - converted = [x["time"] for x in self.monthly_data if x["month"] == this_month] - if not converted: - return None - - return converted.pop() + # Traverse the list in reverse order to find the latest entry. + for entry in reversed(self.monthly_data): + if entry["month"] == this_month: + return entry["time"] + return None async def get_raw_daystat(self, *, year=None, month=None) -> Dict: """Return raw daily stats for the given year & month.""" diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 9bc70bbaf..240aa6c2d 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -1,6 +1,10 @@ +import datetime +from unittest.mock import Mock + import pytest from kasa import EmeterStatus, SmartDeviceException +from kasa.modules.emeter import Emeter from .conftest import has_emeter, has_emeter_iot, no_emeter from .newfakes import CURRENT_CONSUMPTION_SCHEMA @@ -116,3 +120,29 @@ async def test_emeterstatus_missing_current(): missing_current = EmeterStatus({"err_code": 0, "power_mw": 0, "total_wh": 13}) assert missing_current["current"] is None + + +async def test_emeter_daily(): + """Test fetching the emeter for today. + + This test uses inline data since the fixtures + will not have data for the current day. + """ + emeter_data = { + "get_daystat": { + "day_list": [{"day": 1, "energy_wh": 8, "month": 1, "year": 2023}], + "err_code": 0, + } + } + + class MockEmeter(Emeter): + @property + def data(self): + return emeter_data + + emeter = MockEmeter(Mock(), "emeter") + now = datetime.datetime.now() + emeter_data["get_daystat"]["day_list"].append( + {"day": now.day, "energy_wh": 500, "month": now.month, "year": now.year} + ) + assert emeter.emeter_today == 0.500 diff --git a/kasa/tests/test_usage.py b/kasa/tests/test_usage.py index 95002f894..61672ffb7 100644 --- a/kasa/tests/test_usage.py +++ b/kasa/tests/test_usage.py @@ -1,3 +1,6 @@ +import datetime +from unittest.mock import Mock + import pytest from kasa.modules import Usage @@ -20,3 +23,65 @@ def test_usage_convert_stat_data(): assert isinstance(k, int) assert isinstance(v, int) assert k == 4 and v == 30 + + +def test_usage_today(): + """Test fetching the usage for today. + + This test uses inline data since the fixtures + will not have data for the current day. + """ + emeter_data = { + "get_daystat": { + "day_list": [], + "err_code": 0, + } + } + + class MockUsage(Usage): + @property + def data(self): + return emeter_data + + usage = MockUsage(Mock(), "usage") + assert usage.usage_today is None + now = datetime.datetime.now() + emeter_data["get_daystat"]["day_list"].extend( + [ + {"day": now.day - 1, "time": 200, "month": now.month - 1, "year": now.year}, + {"day": now.day, "time": 500, "month": now.month, "year": now.year}, + {"day": now.day + 1, "time": 100, "month": now.month + 1, "year": now.year}, + ] + ) + assert usage.usage_today == 500 + + +def test_usage_this_month(): + """Test fetching the usage for this month. + + This test uses inline data since the fixtures + will not have data for the current month. + """ + emeter_data = { + "get_monthstat": { + "month_list": [], + "err_code": 0, + } + } + + class MockUsage(Usage): + @property + def data(self): + return emeter_data + + usage = MockUsage(Mock(), "usage") + assert usage.usage_this_month is None + now = datetime.datetime.now() + emeter_data["get_monthstat"]["month_list"].extend( + [ + {"time": 200, "month": now.month - 1, "year": now.year}, + {"time": 500, "month": now.month, "year": now.year}, + {"time": 100, "month": now.month + 1, "year": now.year}, + ] + ) + assert usage.usage_this_month == 500 From 2d8a8d95115cfcc08008fa284536eba62df53466 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 5 Jan 2024 02:25:15 +0100 Subject: [PATCH 230/892] Add update-credentials command (#620) * Add change credentials command * Rename command and add prompts for credential update --- kasa/cli.py | 23 +++++++++++++++++++++++ kasa/tapo/tapodevice.py | 15 +++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/kasa/cli.py b/kasa/cli.py index f96bc6e70..62992bfae 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -997,5 +997,28 @@ async def turn_on_behavior(dev: SmartBulb, type, last, preset): return await dev.set_turn_on_behavior(settings) +@cli.command() +@pass_dev +@click.option( + "--username", required=True, prompt=True, help="New username to set on the device" +) +@click.option( + "--password", required=True, prompt=True, help="New password to set on the device" +) +async def update_credentials(dev, username, password): + """Update device credentials for authenticated devices.""" + # Importing here as this is not really a public interface for now + from kasa.tapo import TapoDevice + + if not isinstance(dev, TapoDevice): + raise NotImplementedError( + "Credentials can only be updated on authenticated devices." + ) + + click.confirm("Do you really want to replace the existing credentials?", abort=True) + + return await dev.update_credentials(username, password) + + if __name__ == "__main__": cli() diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 4e5a96cdc..7b539f733 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -321,3 +321,18 @@ async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): raise _LOGGER.debug("Received an expected for wifi join, but this is expected") + + async def update_credentials(self, username: str, password: str): + """Update device credentials. + + This will replace the existing authentication credentials on the device. + """ + t = self.internal_state["time"] + payload = { + "account": { + "username": base64.b64encode(username.encode()).decode(), + "password": base64.b64encode(password.encode()).decode(), + }, + "time": t, + } + return await self.protocol.query({"set_qs_info": payload}) From cfe694e5dea454b3ec901afeb9e0fa770f810549 Mon Sep 17 00:00:00 2001 From: Nathan Wreggit Date: Thu, 4 Jan 2024 17:25:24 -0800 Subject: [PATCH 231/892] Get child emeters with CLI (#623) * Get child emeters with CLI * Avoid extra IO when not que querying the child emeter --- kasa/cli.py | 20 ++++++++++++++++++-- kasa/device_factory.py | 4 ++-- kasa/tests/test_cli.py | 20 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 62992bfae..dcd097322 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -602,14 +602,27 @@ async def raw_command(dev: SmartDevice, module, command, parameters): @cli.command() @pass_dev +@click.option("--index", type=int, required=False) +@click.option("--name", type=str, required=False) @click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) @click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) @click.option("--erase", is_flag=True) -async def emeter(dev: SmartDevice, year, month, erase): +async def emeter(dev: SmartDevice, index: int, name: str, year, month, erase): """Query emeter for historical consumption. Daily and monthly data provided in CSV format. """ + if index is not None or name is not None: + if not dev.is_strip: + echo("Index and name are only for power strips!") + return + + dev = cast(SmartStrip, dev) + if index is not None: + dev = dev.get_plug_by_index(index) + elif name: + dev = dev.get_plug_by_name(name) + echo("[bold]== Emeter ==[/bold]") if not dev.has_emeter: echo("Device has no emeter") @@ -629,7 +642,10 @@ async def emeter(dev: SmartDevice, year, month, erase): usage_data = await dev.get_emeter_daily(year=month.year, month=month.month) else: # Call with no argument outputs summary data and returns - emeter_status = dev.emeter_realtime + if index is not None or name is not None: + emeter_status = await dev.get_emeter_realtime() + else: + emeter_status = dev.emeter_realtime echo("Current: %s A" % emeter_status["current"]) echo("Voltage: %s V" % emeter_status["voltage"]) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 5ffabda0d..757f0c337 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -128,7 +128,7 @@ def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[SmartDevice]: def get_device_class_from_family(device_type: str) -> Optional[Type[SmartDevice]]: """Return the device class from the type name.""" - supported_device_types: dict[str, Type[SmartDevice]] = { + supported_device_types: Dict[str, Type[SmartDevice]] = { "SMART.TAPOPLUG": TapoPlug, "SMART.TAPOBULB": TapoBulb, "SMART.KASAPLUG": TapoPlug, @@ -147,7 +147,7 @@ def get_protocol( protocol_transport_key = ( protocol_name + "." + config.connection_type.encryption_type.value ) - supported_device_protocols: dict[ + supported_device_protocols: Dict[ str, Tuple[Type[TPLinkProtocol], Type[BaseTransport]] ] = { "IOT.XOR": (TPLinkSmartHomeProtocol, _XorTransport), diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 1983b6ccb..3e3fc8b89 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -7,6 +7,7 @@ from kasa import ( AuthenticationException, Credentials, + EmeterStatus, SmartDevice, TPLinkSmartHomeProtocol, UnsupportedDeviceException, @@ -104,6 +105,25 @@ async def test_emeter(dev: SmartDevice, mocker): assert "== Emeter ==" in res.output + if not dev.is_strip: + res = await runner.invoke(emeter, ["--index", "0"], obj=dev) + assert "Index and name are only for power strips!" in res.output + res = await runner.invoke(emeter, ["--name", "mock"], obj=dev) + assert "Index and name are only for power strips!" in res.output + + if dev.is_strip and len(dev.children) > 0: + realtime_emeter = mocker.patch.object(dev.children[0], "get_emeter_realtime") + realtime_emeter.return_value = EmeterStatus({"voltage_mv": 122066}) + + res = await runner.invoke(emeter, ["--index", "0"], obj=dev) + assert "Voltage: 122.066 V" in res.output + realtime_emeter.assert_called() + assert realtime_emeter.call_count == 1 + + res = await runner.invoke(emeter, ["--name", dev.children[0].alias], obj=dev) + assert "Voltage: 122.066 V" in res.output + assert realtime_emeter.call_count == 2 + monthly = mocker.patch.object(dev, "get_emeter_monthly") monthly.return_value = {1: 1234} res = await runner.invoke(emeter, ["--year", "1900"], obj=dev) From 510aea7207111ef25750ed854ecd4d916801f72c Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 5 Jan 2024 02:34:56 +0100 Subject: [PATCH 232/892] Release 0.6.0.dev1 (#624) --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ad5c53b..8d5d95616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## [0.6.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev1) (2024-01-05) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.dev0...0.6.0.dev1) + +**Implemented enhancements:** + +- Get child emeters with CLI [\#623](https://github.com/python-kasa/python-kasa/pull/623) (@Obbay2) +- Avoid linear search for emeter realtime and emeter\_today [\#622](https://github.com/python-kasa/python-kasa/pull/622) (@bdraco) +- Add update-credentials command [\#620](https://github.com/python-kasa/python-kasa/pull/620) (@rytilahti) + +**Fixed bugs:** + +- Check the ct range for color temp support [\#619](https://github.com/python-kasa/python-kasa/pull/619) (@rytilahti) +- Fix cli discover bug with None username/password [\#615](https://github.com/python-kasa/python-kasa/pull/615) (@sdb9696) + +**Closed issues:** + +- Need to do error code checking for new protocols [\#612](https://github.com/python-kasa/python-kasa/issues/612) +- Support of last firmware update version 1.3.0 [\#611](https://github.com/python-kasa/python-kasa/issues/611) +- Implement energy and usage for individual plugs in HS300 [\#462](https://github.com/python-kasa/python-kasa/issues/462) + +**Merged pull requests:** + +- Add P125M and update EP25 fixtures [\#621](https://github.com/python-kasa/python-kasa/pull/621) (@bdraco) +- Use consistent envvars for dump\_devinfo credentials [\#618](https://github.com/python-kasa/python-kasa/pull/618) (@rytilahti) +- Mark L900-5 as supported [\#617](https://github.com/python-kasa/python-kasa/pull/617) (@rytilahti) +- Ship CHANGELOG only in sdist [\#610](https://github.com/python-kasa/python-kasa/pull/610) (@rytilahti) + ## [0.6.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev0) (2024-01-03) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.4...0.6.0.dev0) @@ -60,6 +88,7 @@ **Merged pull requests:** +- Release 0.6.0.dev0 [\#609](https://github.com/python-kasa/python-kasa/pull/609) (@rytilahti) - Cleanup credentials handling [\#605](https://github.com/python-kasa/python-kasa/pull/605) (@rytilahti) - Update P110\(EU\) fixture [\#604](https://github.com/python-kasa/python-kasa/pull/604) (@rytilahti) - Update L530 aes fixture [\#603](https://github.com/python-kasa/python-kasa/pull/603) (@rytilahti) diff --git a/pyproject.toml b/pyproject.toml index 2a03bf69d..a4a348846 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.6.0.dev0" +version = "0.6.0.dev1" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 460054ced7f9b4c13002fb96b8ef349aaa8f5770 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Jan 2024 13:51:04 -1000 Subject: [PATCH 233/892] Avoid recreating struct each request in legacy protocol (#628) --- kasa/protocol.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/kasa/protocol.py b/kasa/protocol.py index 47d4a90b4..e86c07aac 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -31,6 +31,7 @@ _LOGGER = logging.getLogger(__name__) _NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} +_UNSIGNED_INT_NETWORK_ORDER = struct.Struct(">I") def md5(payload: bytes) -> bytes: @@ -206,7 +207,7 @@ async def _execute_query(self, request: str) -> Dict: await self.writer.drain() packed_block_size = await self.reader.readexactly(self.BLOCK_SIZE) - length = struct.unpack(">I", packed_block_size)[0] + length = _UNSIGNED_INT_NETWORK_ORDER.unpack(packed_block_size)[0] buffer = await self.reader.readexactly(length) response = TPLinkSmartHomeProtocol.decrypt(buffer) @@ -311,7 +312,7 @@ def encrypt(request: str) -> bytes: :return: ciphertext to be send over wire, in bytes """ plainbytes = request.encode() - return struct.pack(">I", len(plainbytes)) + bytes( + return _UNSIGNED_INT_NETWORK_ORDER.pack(len(plainbytes)) + bytes( TPLinkSmartHomeProtocol._xor_payload(plainbytes) ) From 897db674c2725bbb998660a701b5d26cb659671d Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 10 Jan 2024 18:40:36 +0000 Subject: [PATCH 234/892] Add L900-10 fixture and it's additional component requests (#629) --- README.md | 1 + devtools/dump_devinfo.py | 2 +- devtools/helpers/smartrequests.py | 7 + kasa/tests/conftest.py | 2 +- .../smart/L900-10(EU)_1.0_1.0.17.json | 441 ++++++++++++++++++ 5 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 kasa/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json diff --git a/README.md b/README.md index b1d961a15..fdc9a4b83 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,7 @@ 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) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 30c9a3d2b..1fb147c4e 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -220,7 +220,7 @@ async def _make_requests_or_exit( final = {} try: end = len(requests) - step = 10 # Break the requests down as there seems to be a size limit + step = 5 # 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] diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 4bb50ae3d..6d7a366ae 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -347,4 +347,11 @@ def _create_request_dict( "light_effect": [SmartRequest.get_dynamic_light_effect_rules()], "bulb_quick_control": None, "on_off_gradually": [SmartRequest.get_raw_request("get_on_off_gradually_info")], + "light_strip": None, + "light_strip_lighting_effect": [ + SmartRequest.get_raw_request("get_lighting_effect") + ], + "music_rhythm": None, # music_rhythm_enable in device_info + "segment": [SmartRequest.get_raw_request("get_device_segment")], + "segment_effect": [SmartRequest.get_raw_request("get_segment_effect_rule")], } diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 1ee2f31f6..af90c809c 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -44,7 +44,7 @@ # Tapo bulbs BULBS_SMART_VARIABLE_TEMP = {"L530E"} -BULBS_SMART_LIGHT_STRIP = {"L900-5"} +BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10"} BULBS_SMART_COLOR = {"L530E", *BULBS_SMART_LIGHT_STRIP} BULBS_SMART_DIMMABLE = {"KS225"} BULBS_SMART = ( diff --git a/kasa/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json b/kasa/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json new file mode 100644 index 000000000..2900accf8 --- /dev/null +++ b/kasa/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json @@ -0,0 +1,441 @@ +{ + "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 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L900-10(EU)", + "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": 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": "light_strip", + "brightness": 100, + "color_temp": 0, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 0, + "hue": 230, + "saturation": 96 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.17 Build 230426 Rel.153230", + "has_set_location_info": true, + "hue": 230, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "lighting_effect": { + "brightness": 100, + "custom": 0, + "display_colors": [ + [ + 0, + 100, + 100 + ], + [ + 30, + 95, + 100 + ], + [ + 0, + 0, + 100 + ] + ], + "enable": 0, + "id": "TapoStrip_5NiN0Y8GAUD78p4neKk9EL", + "name": "Sunset" + }, + "longitude": 0, + "mac": "00-00-00-00-00-00", + "model": "L900", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/London", + "rssi": -58, + "saturation": 96, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1704909087 + }, + "get_device_usage": { + "power_usage": { + "past30": 15, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 69, + "past7": 1, + "today": 0 + }, + "time_usage": { + "past30": 84, + "past7": 1, + "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 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": 100, + "custom": 0, + "direction": 1, + "display_colors": [ + [ + 0, + 100, + 100 + ], + [ + 30, + 95, + 100 + ], + [ + 0, + 0, + 100 + ] + ], + "duration": 600, + "enable": 0, + "expansion_strategy": 2, + "id": "TapoStrip_5NiN0Y8GAUD78p4neKk9EL", + "name": "Sunset", + "repeat_times": 1, + "run_time": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 30, + 0, + 100 + ], + [ + 30, + 20, + 100 + ], + [ + 30, + 50, + 99 + ], + [ + 30, + 60, + 98 + ], + [ + 30, + 70, + 97 + ], + [ + 30, + 75, + 95 + ], + [ + 30, + 80, + 93 + ], + [ + 30, + 90, + 90 + ], + [ + 30, + 95, + 85 + ], + [ + 30, + 100, + 80 + ], + [ + 20, + 100, + 70 + ], + [ + 20, + 100, + 60 + ], + [ + 15, + 100, + 50 + ], + [ + 10, + 100, + 40 + ], + [ + 0, + 100, + 30 + ], + [ + 0, + 100, + 0 + ] + ], + "spread": 1, + "trans_sequence": [], + "transition": 60000, + "type": "pulse" + }, + "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": [], + "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": "L900", + "device_type": "SMART.TAPOBULB" + } + } +} From 3e0cd07b7c13187bf15a160379fff094ad7e6a45 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 10 Jan 2024 19:13:14 +0000 Subject: [PATCH 235/892] Update docs for newer devices and DeviceConfig (#614) * Update docs for newer devices and DeviceConfig * Tweak docs post review * Move sentence to newline * Update post review * Update following review --- .gitignore | 1 + docs/source/cli.rst | 12 ++++++- docs/source/conf.py | 1 + docs/source/deviceconfig.rst | 18 ---------- docs/source/discover.rst | 50 +++++++++++++++++++++++++++ docs/source/index.rst | 1 - docs/source/smartbulb.rst | 2 +- docs/source/smartdevice.rst | 65 +++++++++++++++++++++++++++++++++++- kasa/deviceconfig.py | 18 +++++++--- kasa/discover.py | 27 ++++++++++----- kasa/smartdevice.py | 4 +-- 11 files changed, 163 insertions(+), 36 deletions(-) delete mode 100644 docs/source/deviceconfig.rst diff --git a/.gitignore b/.gitignore index 9da1e2353..573a4c08f 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 603c7f486..b75cc85b2 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 cc7a725e4..017249431 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -58,6 +58,7 @@ 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 25bf077ba..000000000 --- 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 87b14ee7c..b89178a38 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 16e7cbd07..346c53d08 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 5f8fd7eef..aa0e27e57 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 d8ef58b21..2a29a8d90 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 5235868f9..f317ccb1d 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 @@ def to_dict( 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 @@ def to_dict( @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 8fbd6ff0a..9bdae46b0 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 @@ async def 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 @@ async def 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 @@ async def discover_single( """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 912f7cd99..144c28943 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -247,7 +247,7 @@ def credentials(self) -> Optional[Credentials]: @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 @@ def __repr__(self): @property def config(self) -> DeviceConfig: - """Return the connection parameters the device is using.""" + """Return the device configuration.""" return self.protocol.config @staticmethod From d5a6fd8e734676b9d26bb7fc70b716c76c831c0f Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 10 Jan 2024 19:37:43 +0000 Subject: [PATCH 236/892] Improve test coverage (#625) --- kasa/cli.py | 16 +++++-- kasa/tapo/tapodevice.py | 2 +- kasa/tests/newfakes.py | 19 ++++++++- kasa/tests/test_cli.py | 87 ++++++++++++++++++++++++++++++++++++--- kasa/tests/test_emeter.py | 2 +- 5 files changed, 113 insertions(+), 13 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index dcd097322..282273162 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -427,8 +427,8 @@ async def print_discovered(dev: SmartDevice): echo() else: discovered[dev.host] = dev.internal_state - ctx.obj = dev - await ctx.invoke(state) + ctx.parent.obj = dev + await ctx.parent.invoke(state) if verbose: echo() _echo_discovery_info(dev._discovery_info) @@ -513,12 +513,14 @@ async def sysinfo(dev): @cli.command() @pass_dev -async def state(dev: SmartDevice): +@click.pass_context +async def state(ctx, dev: SmartDevice): """Print out device state and versions.""" + verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False + echo(f"[bold]== {dev.alias} - {dev.model} ==[/bold]") echo(f"\tHost: {dev.host}") echo(f"\tPort: {dev.port}") - echo(f"\tCredentials hash: {dev.credentials_hash}") echo(f"\tDevice state: {dev.is_on}") if dev.is_strip: echo("\t[bold]== Plugs ==[/bold]") @@ -554,6 +556,12 @@ async def state(dev: SmartDevice): else: echo(f"\t[red]- {module}[/red]") + if verbose: + echo("\n\t[bold]== Verbose information ==[/bold]") + echo(f"\tCredentials hash: {dev.credentials_hash}") + echo(f"\tDevice ID: {dev.device_id}") + for feature in dev.features: + echo(f"\tFeature: {feature}") return dev.internal_state diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 7b539f733..86f939123 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -275,7 +275,7 @@ async def _query_networks(networks=None, start_index=0): ] networks.extend(network_list) - if resp["get_wireless_scan_info"]["sum"] > start_index + 10: + if resp["get_wireless_scan_info"].get("sum", 0) > start_index + 10: return await _query_networks(networks, start_index=start_index + 10) return networks diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 064dbaeb1..78bea3340 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -18,6 +18,7 @@ from ..credentials import Credentials from ..deviceconfig import DeviceConfig +from ..exceptions import SmartDeviceException from ..protocol import BaseTransport, TPLinkSmartHomeProtocol, _XorTransport from ..smartprotocol import SmartProtocol @@ -315,6 +316,10 @@ def __init__(self, info): ), ) self.info = info + self.components = { + comp["id"]: comp["ver_code"] + for comp in self.info["component_nego"]["component_list"] + } @property def default_port(self): @@ -326,6 +331,10 @@ def credentials_hash(self): """The hashed credentials used by the transport.""" return self._credentials.username + self._credentials.password + "hash" + FIXTURE_MISSING_MAP = { + "get_wireless_scan_info": ("wireless", {"ap_list": [], "wep_supported": False}), + } + async def send(self, request: str): request_dict = json_loads(request) method = request_dict["method"] @@ -346,14 +355,20 @@ def _send_request(self, request_dict: dict): if method == "component_nego" or method[:4] == "get_": if method in self.info: return {"result": self.info[method], "error_code": 0} - else: + elif ( + missing_result := self.FIXTURE_MISSING_MAP.get(method) + ) and missing_result[0] in self.components: warnings.warn( UserWarning( f"Fixture missing expected method {method}, try to regenerate" ), stacklevel=1, ) - return {"result": {}, "error_code": 0} + return {"result": missing_result[1], "error_code": 0} + else: + raise SmartDeviceException(f"Fixture doesn't support {method}") + elif method == "set_qs_info": + return {"error_code": 0} elif method[:4] == "set_": target_method = f"get_{method[4:]}" self.info[target_method].update(params) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 3e3fc8b89..b1db15e19 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -1,4 +1,5 @@ import json +import re import asyncclick as click import pytest @@ -9,6 +10,7 @@ Credentials, EmeterStatus, SmartDevice, + SmartDeviceException, TPLinkSmartHomeProtocol, UnsupportedDeviceException, ) @@ -22,11 +24,13 @@ state, sysinfo, toggle, + update_credentials, + wifi, ) from kasa.discover import Discover, DiscoveryResult from kasa.smartprotocol import SmartProtocol -from .conftest import device_iot, handle_turn_on, new_discovery, turn_on +from .conftest import device_iot, device_smart, handle_turn_on, new_discovery, turn_on @device_iot @@ -81,20 +85,93 @@ async def test_alias(dev): await dev.set_alias(old_alias) -@device_iot async def test_raw_command(dev): runner = CliRunner() - res = await runner.invoke(raw_command, ["system", "get_sysinfo"], obj=dev) + from kasa.tapo import TapoDevice + + if isinstance(dev, TapoDevice): + params = ["na", "get_device_info"] + else: + params = ["system", "get_sysinfo"] + res = await runner.invoke(raw_command, params, obj=dev) assert res.exit_code == 0 - assert dev.alias in res.output + assert dev.model in res.output res = await runner.invoke(raw_command, obj=dev) assert res.exit_code != 0 assert "Usage" in res.output -@device_iot +@device_smart +async def test_wifi_scan(dev): + runner = CliRunner() + res = await runner.invoke(wifi, ["scan"], obj=dev) + + assert res.exit_code == 0 + assert re.search(r"Found \d wifi networks!", res.output) + + +@device_smart +async def test_wifi_join(dev): + runner = CliRunner() + res = await runner.invoke( + wifi, + ["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"], + obj=dev, + ) + + assert res.exit_code == 0 + assert "Asking the device to connect to FOOBAR" in res.output + + +@device_smart +async def test_wifi_join_no_creds(dev): + runner = CliRunner() + dev.protocol._transport._credentials = None + res = await runner.invoke( + wifi, + ["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"], + obj=dev, + ) + + assert res.exit_code != 0 + assert isinstance(res.exception, AuthenticationException) + + +@device_smart +async def test_wifi_join_exception(dev, mocker): + runner = CliRunner() + mocker.patch.object( + dev.protocol, "query", side_effect=SmartDeviceException(error_code=9999) + ) + res = await runner.invoke( + wifi, + ["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"], + obj=dev, + ) + + assert res.exit_code != 0 + assert isinstance(res.exception, SmartDeviceException) + + +@device_smart +async def test_update_credentials(dev): + runner = CliRunner() + res = await runner.invoke( + update_credentials, + ["--username", "foo", "--password", "bar"], + input="y\n", + obj=dev, + ) + + assert res.exit_code == 0 + assert ( + "Do you really want to replace the existing credentials? [y/N]: y\n" + in res.output + ) + + async def test_emeter(dev: SmartDevice, mocker): runner = CliRunner() diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 240aa6c2d..fdb219b5f 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -24,7 +24,7 @@ async def test_no_emeter(dev): await dev.erase_emeter_stats() -@has_emeter_iot +@has_emeter async def test_get_emeter_realtime(dev): assert dev.has_emeter From fd2170c82c6392142e4790f4e9bf097cf3c41324 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 10 Jan 2024 19:47:30 +0000 Subject: [PATCH 237/892] Update config to_dict to exclude credentials if the hash is empty string (#626) * Update config to_dict to exclude credentials if the hash is empty string * Add test --- kasa/deviceconfig.py | 2 +- kasa/tests/test_deviceconfig.py | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index f317ccb1d..4ea255a4e 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -165,7 +165,7 @@ def to_dict( exclude_credentials: bool = False, ) -> Dict[str, Dict[str, str]]: """Convert device config to dict.""" - if credentials_hash or exclude_credentials: + if credentials_hash is not None or exclude_credentials: self.credentials = None if credentials_hash: self.credentials_hash = credentials_hash diff --git a/kasa/tests/test_deviceconfig.py b/kasa/tests/test_deviceconfig.py index 22d42b81a..432829158 100644 --- a/kasa/tests/test_deviceconfig.py +++ b/kasa/tests/test_deviceconfig.py @@ -35,7 +35,21 @@ def test_credentials_hash(): assert config2.credentials is None -def test_no_credentials_serialization(): +def test_blank_credentials_hash(): + config = DeviceConfig( + host="Foo", + http_client=httpx.AsyncClient(), + credentials=Credentials("foo", "bar"), + ) + config_dict = config.to_dict(credentials_hash="") + config_json = json_dumps(config_dict) + config2_dict = json_loads(config_json) + config2 = DeviceConfig.from_dict(config2_dict) + assert config2.credentials_hash is None + assert config2.credentials is None + + +def test_exclude_credentials(): config = DeviceConfig( host="Foo", http_client=httpx.AsyncClient(), From 5b8280a8d9f1d7aa4f0b3c725fa75ca580f8a574 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 11 Jan 2024 15:12:02 +0000 Subject: [PATCH 238/892] Return alias as None for new discovery devices before update (#627) * Trim the length of the unavailable device alias * Update to use short mac as auth required alias * Update to return alias as none --- kasa/discover.py | 8 +------- kasa/smartdevice.py | 5 ++--- kasa/tapo/tapodevice.py | 7 +++++-- kasa/tests/test_discovery.py | 2 ++ kasa/tests/test_strip.py | 2 +- 5 files changed, 11 insertions(+), 13 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index 9bdae46b0..36daeaa93 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -1,6 +1,5 @@ """Discovery module for TP-Link Smart Home devices.""" import asyncio -import base64 import binascii import ipaddress import logging @@ -35,9 +34,6 @@ OnDiscoveredCallable = Callable[[SmartDevice], Awaitable[None]] DeviceDict = Dict[str, SmartDevice] -UNAVAILABLE_ALIAS = "Authentication required" -UNAVAILABLE_NICKNAME = base64.b64encode(UNAVAILABLE_ALIAS.encode()).decode() - class _DiscoverProtocol(asyncio.DatagramProtocol): """Implementation of the discovery protocol handler. @@ -463,9 +459,7 @@ def _get_device_instance( device = device_class(config.host, protocol=protocol) di = discovery_result.get_dict() - di["model"] = discovery_result.device_model - di["alias"] = UNAVAILABLE_ALIAS - di["nickname"] = UNAVAILABLE_NICKNAME + di["model"], _, _ = discovery_result.device_model.partition("(") device.update_from_discover_info(di) return device diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 144c28943..4249807f1 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -442,11 +442,10 @@ def has_children(self) -> bool: return self.is_strip @property # type: ignore - @requires_update - def alias(self) -> str: + def alias(self) -> Optional[str]: """Return device name (alias).""" sys_info = self._sys_info - return str(sys_info["alias"]) + return sys_info.get("alias") if sys_info else None async def set_alias(self, alias: str) -> None: """Set the device name (alias).""" diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 86f939123..8edec611c 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -102,9 +102,12 @@ def model(self) -> str: return str(self._info.get("model")) @property - def alias(self) -> str: + def alias(self) -> Optional[str]: """Returns the device alias or nickname.""" - return base64.b64decode(str(self._info.get("nickname"))).decode() + if self._info and (nickname := self._info.get("nickname")): + return base64.b64decode(nickname).decode() + else: + return None @property def time(self) -> datetime: diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 51aedfb71..f0cce517c 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -108,6 +108,8 @@ async def test_discover_single(discovery_mock, custom_port, mocker): assert x._discovery_info is not None assert x.port == custom_port or x.port == discovery_mock.default_port assert update_mock.call_count == 0 + if discovery_mock.default_port == 80: + assert x.alias is None ct = ConnectionType.from_values( discovery_mock.device_type, diff --git a/kasa/tests/test_strip.py b/kasa/tests/test_strip.py index eb0c848cc..451b7e34e 100644 --- a/kasa/tests/test_strip.py +++ b/kasa/tests/test_strip.py @@ -70,7 +70,7 @@ async def test_children_on_since(dev): @strip async def test_get_plug_by_name(dev: SmartStrip): name = dev.children[0].alias - assert dev.get_plug_by_name(name) == dev.children[0] + assert dev.get_plug_by_name(name) == dev.children[0] # type: ignore[arg-type] with pytest.raises(SmartDeviceException): dev.get_plug_by_name("NONEXISTING NAME") From fbce755544a7485fc526bb47f931ace547528367 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 11 Jan 2024 15:13:44 +0000 Subject: [PATCH 239/892] Raise TimeoutException on discover_single timeout (#632) --- kasa/__init__.py | 2 ++ kasa/discover.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/kasa/__init__.py b/kasa/__init__.py index f5b795bdc..3465147aa 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -25,6 +25,7 @@ from kasa.exceptions import ( AuthenticationException, SmartDeviceException, + TimeoutException, UnsupportedDeviceException, ) from kasa.iotprotocol import IotProtocol @@ -60,6 +61,7 @@ "SmartLightStrip", "AuthenticationException", "UnsupportedDeviceException", + "TimeoutException", "Credentials", "DeviceConfig", "ConnectionType", diff --git a/kasa/discover.py b/kasa/discover.py index 36daeaa93..fca578a31 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -22,7 +22,7 @@ get_protocol, ) from kasa.deviceconfig import ConnectionType, DeviceConfig, EncryptType -from kasa.exceptions import UnsupportedDeviceException +from kasa.exceptions import TimeoutException, UnsupportedDeviceException from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads from kasa.protocol import TPLinkSmartHomeProtocol @@ -347,7 +347,7 @@ async def discover_single( async with asyncio_timeout(discovery_timeout): await event.wait() except asyncio.TimeoutError as ex: - raise SmartDeviceException( + raise TimeoutException( f"Timed out getting discovery response for {host}" ) from ex finally: From 816053fc6eb190fbe5cc58e48cd5287b02919342 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 11 Jan 2024 16:29:15 +0100 Subject: [PATCH 240/892] Release 0.6.0.dev2 (#633) --- CHANGELOG.md | 18 ++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d5d95616..d2100da39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.6.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev2) (2024-01-11) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.dev1...0.6.0.dev2) + +**Documentation updates:** + +- Update docs for newer devices and DeviceConfig [\#614](https://github.com/python-kasa/python-kasa/pull/614) (@sdb9696) + +**Merged pull requests:** + +- Raise TimeoutException on discover\_single timeout [\#632](https://github.com/python-kasa/python-kasa/pull/632) (@sdb9696) +- Add L900-10 fixture and it's additional component requests [\#629](https://github.com/python-kasa/python-kasa/pull/629) (@sdb9696) +- Avoid recreating struct each request in legacy protocol [\#628](https://github.com/python-kasa/python-kasa/pull/628) (@bdraco) +- Return alias as None for new discovery devices before update [\#627](https://github.com/python-kasa/python-kasa/pull/627) (@sdb9696) +- Update config to\_dict to exclude credentials if the hash is empty string [\#626](https://github.com/python-kasa/python-kasa/pull/626) (@sdb9696) +- Improve test coverage [\#625](https://github.com/python-kasa/python-kasa/pull/625) (@sdb9696) + ## [0.6.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev1) (2024-01-05) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.dev0...0.6.0.dev1) @@ -23,6 +40,7 @@ **Merged pull requests:** +- Release 0.6.0.dev1 [\#624](https://github.com/python-kasa/python-kasa/pull/624) (@rytilahti) - Add P125M and update EP25 fixtures [\#621](https://github.com/python-kasa/python-kasa/pull/621) (@bdraco) - Use consistent envvars for dump\_devinfo credentials [\#618](https://github.com/python-kasa/python-kasa/pull/618) (@rytilahti) - Mark L900-5 as supported [\#617](https://github.com/python-kasa/python-kasa/pull/617) (@rytilahti) diff --git a/pyproject.toml b/pyproject.toml index a4a348846..7dcbd0947 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.6.0.dev1" +version = "0.6.0.dev2" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From aed67dad16fb59c111d14558b7786d72979979f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 07:37:24 -1000 Subject: [PATCH 241/892] Fix connection indeterminate state on cancellation (#636) * Fix connection indeterminate state on cancellation If the task the query is running in it cancelled, we do know the state of the connection so we must close. Previously we would not close on BaseException which could result in reading the previous response if the previous query was cancelled after the request had been sent * add test for cancellation --- kasa/protocol.py | 31 ++++++++++++++-- kasa/tests/test_protocol.py | 74 +++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/kasa/protocol.py b/kasa/protocol.py index e86c07aac..74023e017 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -200,7 +200,6 @@ async def _execute_query(self, request: str) -> Dict: assert self.writer is not None # noqa: S101 assert self.reader is not None # noqa: S101 debug_log = _LOGGER.isEnabledFor(logging.DEBUG) - if debug_log: _LOGGER.debug("%s >> %s", self._host, request) self.writer.write(TPLinkSmartHomeProtocol.encrypt(request)) @@ -220,12 +219,18 @@ async def _execute_query(self, request: str) -> Dict: async def close(self) -> None: """Close the connection.""" writer = self.writer - self.reader = self.writer = None + self.close_without_wait() if writer: - writer.close() with contextlib.suppress(Exception): await writer.wait_closed() + def close_without_wait(self) -> None: + """Close the connection without waiting for the connection to close.""" + writer = self.writer + self.reader = self.writer = None + if writer: + writer.close() + def _reset(self) -> None: """Clear any varibles that should not survive between loops.""" self.reader = self.writer = None @@ -266,6 +271,16 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: f" {self._host}:{self._port}: {ex}" ) from ex continue + except BaseException as ex: + # Likely something cancelled the task so we need to close the connection + # as we are not in an indeterminate state + self.close_without_wait() + _LOGGER.debug( + "%s: BaseException during connect, closing connection: %s", + self._host, + ex, + ) + raise try: assert self.reader is not None # noqa: S101 @@ -283,6 +298,16 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: _LOGGER.debug( "Unable to query the device %s, retrying: %s", self._host, ex ) + except BaseException as ex: + # Likely something cancelled the task so we need to close the connection + # as we are not in an indeterminate state + self.close_without_wait() + _LOGGER.debug( + "%s: BaseException during query, closing connection: %s", + self._host, + ex, + ) + raise # make mypy happy, this should never be reached.. await self.close() diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 05ae40f3d..563b8176f 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -1,3 +1,4 @@ +import asyncio import errno import importlib import inspect @@ -122,6 +123,79 @@ def aio_mock_writer(_, __): assert response == {"great": "success"} +async def test_protocol_handles_cancellation_during_write(mocker): + attempts = 0 + encrypted = TPLinkSmartHomeProtocol.encrypt('{"great":"success"}')[ + TPLinkSmartHomeProtocol.BLOCK_SIZE : + ] + + def _cancel_first_attempt(*_): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise asyncio.CancelledError("Simulated task cancel") + + async def _mock_read(byte_count): + nonlocal encrypted + if byte_count == TPLinkSmartHomeProtocol.BLOCK_SIZE: + return struct.pack(">I", len(encrypted)) + if byte_count == len(encrypted): + return encrypted + + raise ValueError(f"No mock for {byte_count}") + + def aio_mock_writer(_, __): + reader = mocker.patch("asyncio.StreamReader") + writer = mocker.patch("asyncio.StreamWriter") + mocker.patch.object(writer, "write", _cancel_first_attempt) + mocker.patch.object(reader, "readexactly", _mock_read) + return reader, writer + + config = DeviceConfig("127.0.0.1") + protocol = TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)) + mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + with pytest.raises(asyncio.CancelledError): + await protocol.query({}) + assert protocol.writer is None + response = await protocol.query({}) + assert response == {"great": "success"} + + +async def test_protocol_handles_cancellation_during_connection(mocker): + attempts = 0 + encrypted = TPLinkSmartHomeProtocol.encrypt('{"great":"success"}')[ + TPLinkSmartHomeProtocol.BLOCK_SIZE : + ] + + async def _mock_read(byte_count): + nonlocal encrypted + if byte_count == TPLinkSmartHomeProtocol.BLOCK_SIZE: + return struct.pack(">I", len(encrypted)) + if byte_count == len(encrypted): + return encrypted + + raise ValueError(f"No mock for {byte_count}") + + def aio_mock_writer(_, __): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise asyncio.CancelledError("Simulated task cancel") + reader = mocker.patch("asyncio.StreamReader") + writer = mocker.patch("asyncio.StreamWriter") + mocker.patch.object(reader, "readexactly", _mock_read) + return reader, writer + + config = DeviceConfig("127.0.0.1") + protocol = TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)) + mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + with pytest.raises(asyncio.CancelledError): + await protocol.query({}) + assert protocol.writer is None + response = await protocol.query({}) + assert response == {"great": "success"} + + @pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG]) async def test_protocol_logging(mocker, caplog, log_level): caplog.set_level(log_level) From eadf1203fcc252605b15fcbe277d00f31e918b6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Jan 2024 12:49:31 -1000 Subject: [PATCH 242/892] Add fixture for L920 (#638) --- kasa/tests/conftest.py | 6 +- .../fixtures/smart/L920-5(US)_1.0_1.1.0.json | 397 ++++++++++++++++++ 2 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index af90c809c..dc2444022 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -44,7 +44,7 @@ # Tapo bulbs BULBS_SMART_VARIABLE_TEMP = {"L530E"} -BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10"} +BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5"} BULBS_SMART_COLOR = {"L530E", *BULBS_SMART_LIGHT_STRIP} BULBS_SMART_DIMMABLE = {"KS225"} BULBS_SMART = ( @@ -104,11 +104,11 @@ *PLUGS_SMART, } STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} -STRIPS_SMART = {} # type: ignore[var-annotated] +STRIPS_SMART: Set[str] = set() STRIPS = {*STRIPS_IOT, *STRIPS_SMART} DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} -DIMMERS_SMART = {} # type: ignore[var-annotated] +DIMMERS_SMART: Set[str] = set() DIMMERS = { *DIMMERS_IOT, *DIMMERS_SMART, diff --git a/kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json b/kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json new file mode 100644 index 000000000..2ea0c69f5 --- /dev/null +++ b/kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json @@ -0,0 +1,397 @@ +{ + "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 + } + ] + }, + "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": "00-00-00-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": 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.1.0 Build 230905 Rel.190143", + "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": "00-00-00-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": -34, + "saturation": 100, + "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": 1705270860 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 4, + "past7": 4, + "today": 4 + }, + "time_usage": { + "past30": 4, + "past7": 4, + "today": 4 + } + }, + "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 230905 Rel.190143", + "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_wireless_scan_info": { + "ap_list": [], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L920", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} From 4623434eb43f92b62c8a4453a71e16456a6dbf24 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 18 Jan 2024 08:21:52 +0000 Subject: [PATCH 243/892] Fix broken docs due to applehelp dependency (#641) --- poetry.lock | 30 +++++++++++++++--------------- pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4224f1885..afab01378 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1350,27 +1350,27 @@ files = [ [[package]] name = "sphinx" -version = "4.5.0" +version = "5.3.0" description = "Python documentation generator" optional = true python-versions = ">=3.6" files = [ - {file = "Sphinx-4.5.0-py3-none-any.whl", hash = "sha256:ebf612653238bcc8f4359627a9b7ce44ede6fdd75d9d30f68255c7383d3a6226"}, - {file = "Sphinx-4.5.0.tar.gz", hash = "sha256:7bf8ca9637a4ee15af412d1a1d9689fec70523a68ca9bb9127c2f3eeb344e2e6"}, + {file = "Sphinx-5.3.0.tar.gz", hash = "sha256:51026de0a9ff9fc13c05d74913ad66047e104f56a129ff73e174eb5c3ee794b5"}, + {file = "sphinx-5.3.0-py3-none-any.whl", hash = "sha256:060ca5c9f7ba57a08a1219e547b269fadf125ae25b06b9fa7f66768efb652d6d"}, ] [package.dependencies] alabaster = ">=0.7,<0.8" -babel = ">=1.3" -colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.14,<0.18" -imagesize = "*" -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} -Jinja2 = ">=2.3" -packaging = "*" -Pygments = ">=2.0" +babel = ">=2.9" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +docutils = ">=0.14,<0.20" +imagesize = ">=1.3" +importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""} +Jinja2 = ">=3.0" +packaging = ">=21.0" +Pygments = ">=2.12" requests = ">=2.5.0" -snowballstemmer = ">=1.1" +snowballstemmer = ">=2.0" sphinxcontrib-applehelp = "*" sphinxcontrib-devhelp = "*" sphinxcontrib-htmlhelp = ">=2.0.0" @@ -1380,8 +1380,8 @@ sphinxcontrib-serializinghtml = ">=1.1.5" [package.extras] docs = ["sphinxcontrib-websupport"] -lint = ["docutils-stubs", "flake8 (>=3.5.0)", "isort", "mypy (>=0.931)", "types-requests", "types-typed-ast"] -test = ["cython", "html5lib", "pytest", "pytest-cov", "typed-ast"] +lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-bugbear", "flake8-comprehensions", "flake8-simplify", "isort", "mypy (>=0.981)", "sphinx-lint", "types-requests", "types-typed-ast"] +test = ["cython", "html5lib", "pytest (>=4.6)", "typed_ast"] [[package]] name = "sphinx-rtd-theme" @@ -1674,4 +1674,4 @@ speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "097b5cdfc1d2ccf3e89d306242f0f3a9a84e53c039f939df4e55d13c471f6084" +content-hash = "4c579a1245e1412f1f591c5c1baf41e14c534de6d32450fd631e5885c1afd4d4" diff --git a/pyproject.toml b/pyproject.toml index 7dcbd0947..4e44e4546 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ orjson = { "version" = ">=3.9.1", optional = true } kasa-crypt = { "version" = ">=0.2.0", optional = true } # required only for docs -sphinx = { version = "^4", optional = true } +sphinx = { version = "^5", optional = true } sphinx_rtd_theme = { version = "^0", optional = true } sphinxcontrib-programoutput = { version = "^0", optional = true } myst-parser = { version = "*", optional = true } From 3b1b0a3c219b8b753b1464a3fa885d4b170d113f Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 18 Jan 2024 09:57:33 +0000 Subject: [PATCH 244/892] Encapsulate http client dependency (#642) * Encapsulate http client dependency * Store cookie dict as variable * Update post-review --- devtools/helpers/smartrequests.py | 1 - kasa/aestransport.py | 73 ++++++++++--------------- kasa/deviceconfig.py | 9 ++-- kasa/exceptions.py | 4 ++ kasa/httpclient.py | 89 +++++++++++++++++++++++++++++++ kasa/iotprotocol.py | 47 ++++++++-------- kasa/klaptransport.py | 59 ++++++-------------- kasa/smartprotocol.py | 39 ++++---------- kasa/tests/test_device_factory.py | 4 +- kasa/tests/test_discovery.py | 8 +-- kasa/tests/test_klapprotocol.py | 17 +++--- 11 files changed, 194 insertions(+), 156 deletions(-) create mode 100644 kasa/httpclient.py diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 6d7a366ae..e4941713a 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -30,7 +30,6 @@ from typing import List, Optional, Union _LOGGER = logging.getLogger(__name__) -logging.getLogger("httpx").propagate = False class SmartRequest: diff --git a/kasa/aestransport.py b/kasa/aestransport.py index bdae00d08..c19ead5bd 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -8,9 +8,8 @@ import hashlib import logging import time -from typing import Optional +from typing import Dict, Optional, cast -import httpx from cryptography.hazmat.primitives import padding, serialization from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding from cryptography.hazmat.primitives.asymmetric import rsa @@ -28,6 +27,7 @@ SmartErrorCode, TimeoutException, ) +from .httpclient import HttpClient from .json import dumps as json_dumps from .json import loads as json_loads from .protocol import BaseTransport @@ -75,14 +75,14 @@ def __init__( base64.b64decode(self._credentials_hash.encode()).decode() # type: ignore[union-attr] ) - self._default_http_client: Optional[httpx.AsyncClient] = None + self._http_client: HttpClient = HttpClient(config) self._handshake_done = False self._encryption_session: Optional[AesEncyptionSession] = None self._session_expire_at: Optional[float] = None - self._session_cookie = None + self._session_cookie: Optional[Dict[str, str]] = None self._login_token = None @@ -98,14 +98,6 @@ def credentials_hash(self) -> str: """The hashed credentials used by the transport.""" return base64.b64encode(json_dumps(self._login_params).encode()).decode() - @property - def _http_client(self) -> httpx.AsyncClient: - if self._config.http_client: - return self._config.http_client - if not self._default_http_client: - self._default_http_client = httpx.AsyncClient() - return self._default_http_client - def _get_login_params(self): """Get the login parameters based on the login_version.""" un, pw = self.hash_credentials(self._login_version == 2) @@ -128,28 +120,6 @@ def hash_credentials(self, login_v2): pw = base64.b64encode(self._credentials.password.encode()).decode() return un, pw - async def client_post(self, url, params=None, data=None, json=None, headers=None): - """Send an http post request to the device.""" - response_data = None - cookies = None - if self._session_cookie: - cookies = httpx.Cookies() - cookies.set(self.SESSION_COOKIE_NAME, self._session_cookie) - self._http_client.cookies.clear() - resp = await self._http_client.post( - url, - params=params, - data=data, - json=json, - timeout=self._timeout, - cookies=cookies, - headers=self.COMMON_HEADERS, - ) - if resp.status_code == 200: - response_data = resp.json() - - return resp.status_code, response_data - def _handle_response_error_code(self, resp_dict: dict, msg: str): error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] if error_code == SmartErrorCode.SUCCESS: @@ -176,7 +146,12 @@ async def send_secure_passthrough(self, request: str): "method": "securePassthrough", "params": {"request": encrypted_payload.decode()}, } - status_code, resp_dict = await self.client_post(url, json=passthrough_request) + status_code, resp_dict = await self._http_client.post( + url, + json=passthrough_request, + headers=self.COMMON_HEADERS, + cookies_dict=self._session_cookie, + ) # _LOGGER.debug(f"secure_passthrough response is {status_code}: {resp_dict}") if status_code != 200: @@ -185,6 +160,7 @@ 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" ) @@ -233,7 +209,12 @@ async def perform_handshake(self): _LOGGER.debug(f"Request {request_body}") - status_code, resp_dict = await self.client_post(url, json=request_body) + status_code, resp_dict = await self._http_client.post( + url, + json=request_body, + headers=self.COMMON_HEADERS, + cookies_dict=self._session_cookie, + ) _LOGGER.debug(f"Device responded with: {resp_dict}") @@ -247,13 +228,16 @@ async def perform_handshake(self): handshake_key = resp_dict["result"]["key"] - self._session_cookie = self._http_client.cookies.get( # type: ignore - self.SESSION_COOKIE_NAME - ) - if not self._session_cookie: - self._session_cookie = self._http_client.cookies.get( # type: ignore + if ( + cookie := self._http_client.get_cookie( # type: ignore + self.SESSION_COOKIE_NAME + ) + ) or ( + cookie := self._http_client.get_cookie( # type: ignore "SESSIONID" ) + ): + self._session_cookie = {self.SESSION_COOKIE_NAME: cookie} self._session_expire_at = time.time() + 86400 self._encryption_session = AesEncyptionSession.create_from_keypair( @@ -281,13 +265,10 @@ async def send(self, request: str): return await self.send_secure_passthrough(request) async def close(self) -> None: - """Close the protocol.""" - client = self._default_http_client - self._default_http_client = None + """Close the transport.""" self._handshake_done = False self._login_token = None - if client: - await client.aclose() + await self._http_client.close() class AesEncyptionSession: diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 4ea255a4e..1e1ce7efa 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -2,13 +2,14 @@ import logging from dataclasses import asdict, dataclass, field, fields, is_dataclass from enum import Enum -from typing import Dict, Optional, Union - -import httpx +from typing import TYPE_CHECKING, Dict, Optional, Union from .credentials import Credentials from .exceptions import SmartDeviceException +if TYPE_CHECKING: + from httpx import AsyncClient + _LOGGER = logging.getLogger(__name__) @@ -150,7 +151,7 @@ class DeviceConfig: # 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) + http_client: Optional["AsyncClient"] = field(default=None, compare=False) def __post_init__(self): if self.connection_type is None: diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 99b7974d2..49e4e2c8c 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -31,6 +31,10 @@ class TimeoutException(SmartDeviceException): """Timeout exception for device errors.""" +class ConnectionException(SmartDeviceException): + """Connection exception for device errors.""" + + class SmartErrorCode(IntEnum): """Enum for SMART Error Codes.""" diff --git a/kasa/httpclient.py b/kasa/httpclient.py new file mode 100644 index 000000000..8d6e0cae6 --- /dev/null +++ b/kasa/httpclient.py @@ -0,0 +1,89 @@ +"""Module for HttpClientSession class.""" +import logging +from typing import Any, Dict, Optional, Tuple, Type, Union + +import httpx + +from .deviceconfig import DeviceConfig +from .exceptions import ConnectionException, SmartDeviceException, TimeoutException + +logging.getLogger("httpx").propagate = False + +InnerHttpType = Type[httpx.AsyncClient] + + +class HttpClient: + """HttpClient Class.""" + + def __init__(self, config: DeviceConfig) -> None: + self._config = config + self._client: httpx.AsyncClient = None + + @property + def client(self) -> httpx.AsyncClient: + """Return the underlying http client.""" + if self._config.http_client and issubclass( + self._config.http_client.__class__, httpx.AsyncClient + ): + return self._config.http_client + + if not self._client: + self._client = httpx.AsyncClient() + return self._client + + async def post( + self, + url: str, + *, + params: Optional[Dict[str, Any]] = None, + data: Optional[bytes] = None, + json: Optional[Dict] = 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.""" + response_data = None + cookies = None + if cookies_dict: + cookies = httpx.Cookies() + for name, value in cookies_dict.items(): + cookies.set(name, value) + self.client.cookies.clear() + try: + resp = await self.client.post( + url, + params=params, + data=data, + json=json, + timeout=self._config.timeout, + cookies=cookies, + headers=headers, + ) + except httpx.ConnectError as ex: + raise ConnectionException( + f"Unable to connect to the device: {self._config.host}: {ex}" + ) from ex + except httpx.TimeoutException as ex: + raise TimeoutException( + "Unable to query the device, " + f"timed out: {self._config.host}: {ex}" + ) from ex + except Exception as ex: + raise SmartDeviceException( + f"Unable to query the device: {self._config.host}: {ex}" + ) from ex + + if resp.status_code == 200: + response_data = resp.json() if json else resp.content + + return resp.status_code, response_data + + def get_cookie(self, cookie_name: str) -> str: + """Return the cookie with cookie_name.""" + return self._client.cookies.get(cookie_name) + + async def close(self) -> None: + """Close the protocol.""" + client = self._client + self._client = None + if client: + await client.aclose() diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index 470f40552..a90015259 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -3,9 +3,13 @@ import logging from typing import Dict, Union -import httpx - -from .exceptions import AuthenticationException, SmartDeviceException +from .exceptions import ( + AuthenticationException, + ConnectionException, + RetryableException, + SmartDeviceException, + TimeoutException, +) from .json import dumps as json_dumps from .protocol import BaseTransport, TPLinkProtocol @@ -15,6 +19,8 @@ class IotProtocol(TPLinkProtocol): """Class for the legacy TPLink IOT KASA Protocol.""" + BACKOFF_SECONDS_AFTER_TIMEOUT = 1 + def __init__( self, *, @@ -38,40 +44,39 @@ async def _query(self, request: str, retry_count: int = 3) -> Dict: for retry in range(retry_count + 1): try: return await self._execute_query(request, retry) - except httpx.ConnectError as sdex: + except ConnectionException as sdex: if retry >= retry_count: await self.close() _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise SmartDeviceException( - f"Unable to connect to the device: {self._host}: {sdex}" - ) from sdex + raise sdex continue - except TimeoutError as tex: - await self.close() - raise SmartDeviceException( - f"Unable to connect to the device, timed out: {self._host}: {tex}" - ) from tex except AuthenticationException as auex: await self.close() _LOGGER.debug( "Unable to authenticate with %s, not retrying", self._host ) raise auex + except RetryableException as ex: + if retry >= retry_count: + await self.close() + _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) + raise ex + continue + except TimeoutException as ex: + if retry >= retry_count: + await self.close() + _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() _LOGGER.debug( - "Unable to connect to the device: %s, not retrying: %s", + "Unable to query the device: %s, not retrying: %s", self._host, ex, ) raise ex - except Exception as ex: - if retry >= retry_count: - await self.close() - _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise SmartDeviceException( - f"Unable to connect to the device: {self._host}: {ex}" - ) from ex - continue # make mypy happy, this should never be reached.. raise SmartDeviceException("Query reached somehow to unreachable") diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 220844a29..63a412200 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -48,20 +48,19 @@ import secrets import time from pprint import pformat as pf -from typing import Any, Optional, Tuple +from typing import Any, Dict, Optional, Tuple, cast -import httpx from cryptography.hazmat.primitives import hashes, padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from .credentials import Credentials from .deviceconfig import DeviceConfig from .exceptions import AuthenticationException, SmartDeviceException +from .httpclient import HttpClient from .json import loads as json_loads from .protocol import BaseTransport, md5 _LOGGER = logging.getLogger(__name__) -logging.getLogger("httpx").propagate = False def _sha256(payload: bytes) -> bytes: @@ -98,7 +97,7 @@ def __init__( ) -> None: super().__init__(config=config) - self._default_http_client: Optional[httpx.AsyncClient] = None + self._http_client = HttpClient(config) self._local_seed: Optional[bytes] = None if ( not self._credentials or self._credentials.username is None @@ -118,7 +117,7 @@ def __init__( self._encryption_session: Optional[KlapEncryptionSession] = None self._session_expire_at: Optional[float] = None - self._session_cookie = None + self._session_cookie: Optional[Dict[str, Any]] = None _LOGGER.debug("Created KLAP transport for %s", self._host) @@ -132,34 +131,6 @@ def credentials_hash(self) -> str: """The hashed credentials used by the transport.""" return base64.b64encode(self._local_auth_hash).decode() - @property - def _http_client(self) -> httpx.AsyncClient: - if self._config.http_client: - return self._config.http_client - if not self._default_http_client: - self._default_http_client = httpx.AsyncClient() - return self._default_http_client - - async def client_post(self, url, params=None, data=None): - """Send an http post request to the device.""" - response_data = None - cookies = None - if self._session_cookie: - cookies = httpx.Cookies() - cookies.set(self.SESSION_COOKIE_NAME, self._session_cookie) - self._http_client.cookies.clear() - resp = await self._http_client.post( - url, - params=params, - data=data, - timeout=self._timeout, - cookies=cookies, - ) - if resp.status_code == 200: - response_data = resp.content - - return resp.status_code, response_data - async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: """Perform handshake1.""" local_seed: bytes = secrets.token_bytes(16) @@ -172,7 +143,7 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: url = f"http://{self._host}/app/handshake1" - response_status, response_data = await self.client_post(url, data=payload) + response_status, response_data = await self._http_client.post(url, data=payload) if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( @@ -189,6 +160,7 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: f"Device {self._host} responded with {response_status} to handshake1" ) + response_data = cast(bytes, response_data) remote_seed: bytes = response_data[0:16] server_hash = response_data[16:] @@ -268,7 +240,11 @@ async def perform_handshake2( payload = self.handshake2_seed_auth_hash(local_seed, remote_seed, auth_hash) - response_status, response_data = await self.client_post(url, data=payload) + response_status, _ = await self._http_client.post( + url, + data=payload, + cookies_dict=self._session_cookie, + ) if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( @@ -298,9 +274,10 @@ async def perform_handshake(self) -> Any: self._session_cookie = None local_seed, remote_seed, auth_hash = await self.perform_handshake1() - self._session_cookie = self._http_client.cookies.get( # type: ignore + if cookie := self._http_client.get_cookie( # type: ignore self.SESSION_COOKIE_NAME - ) + ): + 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 @@ -330,10 +307,11 @@ async def send(self, request: str): url = f"http://{self._host}/app/request" - response_status, response_data = await self.client_post( + response_status, response_data = await self._http_client.post( url, params={"seq": seq}, data=payload, + cookies_dict=self._session_cookie, ) msg = ( @@ -374,11 +352,8 @@ async def send(self, request: str): async def close(self) -> None: """Close the transport.""" - client = self._default_http_client - self._default_http_client = None self._handshake_done = False - if client: - await client.aclose() + await self._http_client.close() @staticmethod def generate_auth_hash(creds: Credentials): diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 97573d933..8d74a5020 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -12,13 +12,12 @@ from pprint import pformat as pf from typing import Dict, Union -import httpx - from .exceptions import ( SMART_AUTHENTICATION_ERRORS, SMART_RETRYABLE_ERRORS, SMART_TIMEOUT_ERRORS, AuthenticationException, + ConnectionException, RetryableException, SmartDeviceException, SmartErrorCode, @@ -28,13 +27,12 @@ from .protocol import BaseTransport, TPLinkProtocol, md5 _LOGGER = logging.getLogger(__name__) -logging.getLogger("httpx").propagate = False class SmartProtocol(TPLinkProtocol): """Class for the new TPLink SMART protocol.""" - SLEEP_SECONDS_AFTER_TIMEOUT = 1 + BACKOFF_SECONDS_AFTER_TIMEOUT = 1 def __init__( self, @@ -67,22 +65,11 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: for retry in range(retry_count + 1): try: return await self._execute_query(request, retry) - except httpx.ConnectError as sdex: + except ConnectionException as sdex: if retry >= retry_count: await self.close() _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise SmartDeviceException( - f"Unable to connect to the device: {self._host}: {sdex}" - ) from sdex - continue - except TimeoutError as tex: - if retry >= retry_count: - await self.close() - raise SmartDeviceException( - "Unable to connect to the device, " - + f"timed out: {self._host}: {tex}" - ) from tex - await asyncio.sleep(self.SLEEP_SECONDS_AFTER_TIMEOUT) + raise sdex continue except AuthenticationException as auex: await self.close() @@ -101,24 +88,16 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: await self.close() _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex - await asyncio.sleep(self.SLEEP_SECONDS_AFTER_TIMEOUT) + await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_TIMEOUT) continue except SmartDeviceException as ex: - # Transport would have raised RetryableException if retry makes sense. await self.close() - _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise ex - except Exception as ex: - if retry >= retry_count: - await self.close() - _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise SmartDeviceException( - f"Unable to connect to the device: {self._host}: {ex}" - ) from ex _LOGGER.debug( - "Unable to query the device %s, retrying: %s", self._host, ex + "Unable to query the device: %s, not retrying: %s", + self._host, + ex, ) - continue + raise ex # make mypy happy, this should never be reached.. raise SmartDeviceException("Query reached somehow to unreachable") diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index 82802c40e..75a40af0e 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -145,7 +145,7 @@ async def test_connect_http_client(all_fixture_data, mocker): ) dev = await connect(config=config) if ctype.encryption_type != EncryptType.Xor: - assert dev.protocol._transport._http_client != http_client + assert dev.protocol._transport._http_client.client != http_client config = DeviceConfig( host=host, @@ -155,4 +155,4 @@ async def test_connect_http_client(all_fixture_data, mocker): ) dev = await connect(config=config) if ctype.encryption_type != EncryptType.Xor: - assert dev.protocol._transport._http_client == http_client + assert dev.protocol._transport._http_client.client == http_client diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index f0cce517c..a055bd644 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -321,9 +321,9 @@ async def test_discover_single_http_client(discovery_mock, mocker): assert x.config.uses_http == (discovery_mock.default_port == 80) if discovery_mock.default_port == 80: - assert x.protocol._transport._http_client != http_client + assert x.protocol._transport._http_client.client != http_client x.config.http_client = http_client - assert x.protocol._transport._http_client == http_client + assert x.protocol._transport._http_client.client == http_client async def test_discover_http_client(discovery_mock, mocker): @@ -338,6 +338,6 @@ async def test_discover_http_client(discovery_mock, mocker): assert x.config.uses_http == (discovery_mock.default_port == 80) if discovery_mock.default_port == 80: - assert x.protocol._transport._http_client != http_client + assert x.protocol._transport._http_client.client != http_client x.config.http_client = http_client - assert x.protocol._transport._http_client == http_client + assert x.protocol._transport._http_client.client == http_client diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 5108fef05..e2e4cb61b 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -13,7 +13,12 @@ from ..aestransport import AesTransport from ..credentials import Credentials from ..deviceconfig import DeviceConfig -from ..exceptions import AuthenticationException, SmartDeviceException +from ..exceptions import ( + AuthenticationException, + ConnectionException, + SmartDeviceException, +) +from ..httpclient import HttpClient from ..iotprotocol import IotProtocol from ..klaptransport import ( KlapEncryptionSession, @@ -35,8 +40,8 @@ def __init__(self, status_code, content: bytes): @pytest.mark.parametrize( "error, retry_expectation", [ - (Exception("dummy exception"), True), - (SmartDeviceException("dummy exception"), False), + (Exception("dummy exception"), False), + (httpx.TimeoutException("dummy exception"), True), (httpx.ConnectError("dummy exception"), True), ], ids=("Exception", "SmartDeviceException", "httpx.ConnectError"), @@ -89,7 +94,7 @@ async def test_protocol_retry_recoverable_error( conn = mocker.patch.object( httpx.AsyncClient, "post", - side_effect=httpx.CloseError("foo"), + side_effect=httpx.ConnectError("foo"), ) config = DeviceConfig(host) with pytest.raises(SmartDeviceException): @@ -112,7 +117,7 @@ def _fail_one_less_than_retry_count(*_, **__): nonlocal remaining remaining -= 1 if remaining: - raise Exception("Simulated post failure") + raise ConnectionException("Simulated connection failure") return mock_response @@ -155,7 +160,7 @@ def _return_encrypted(*_, **__): protocol._transport._handshake_done = True protocol._transport._session_expire_at = time.time() + 86400 protocol._transport._encryption_session = encryption_session - mocker.patch.object(KlapTransport, "client_post", side_effect=_return_encrypted) + mocker.patch.object(HttpClient, "post", side_effect=_return_encrypted) response = await protocol.query({}) assert response == {"great": "success"} From 642e9a1f5befce7b7ff227f0da9a889069932c8c Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 18 Jan 2024 17:32:26 +0000 Subject: [PATCH 245/892] Migrate http client to use aiohttp instead of httpx (#643) --- kasa/deviceconfig.py | 4 +- kasa/httpclient.py | 56 ++-- kasa/tests/test_aestransport.py | 30 +- kasa/tests/test_device_factory.py | 4 +- kasa/tests/test_deviceconfig.py | 18 +- kasa/tests/test_discovery.py | 6 +- kasa/tests/test_klapprotocol.py | 39 ++- kasa/tests/test_smartprotocol.py | 1 - poetry.lock | 447 ++++++++++++++++++++++++++---- pyproject.toml | 2 +- 10 files changed, 488 insertions(+), 119 deletions(-) diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 1e1ce7efa..dd3560321 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -8,7 +8,7 @@ from .exceptions import SmartDeviceException if TYPE_CHECKING: - from httpx import AsyncClient + from aiohttp import ClientSession _LOGGER = logging.getLogger(__name__) @@ -151,7 +151,7 @@ class DeviceConfig: # compare=False will be excluded from the serialization and object comparison. #: Set a custom http_client for the device to use. - http_client: Optional["AsyncClient"] = field(default=None, compare=False) + http_client: Optional["ClientSession"] = field(default=None, compare=False) def __post_init__(self): if self.connection_type is None: diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 8d6e0cae6..91c444ae5 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -1,15 +1,16 @@ """Module for HttpClientSession class.""" -import logging -from typing import Any, Dict, Optional, Tuple, Type, Union +from typing import Any, Dict, Optional, Tuple, Union -import httpx +import aiohttp from .deviceconfig import DeviceConfig from .exceptions import ConnectionException, SmartDeviceException, TimeoutException +from .json import loads as json_loads -logging.getLogger("httpx").propagate = False -InnerHttpType = Type[httpx.AsyncClient] +def get_cookie_jar() -> aiohttp.CookieJar: + """Return a new cookie jar with the correct options for device communication.""" + return aiohttp.CookieJar(unsafe=True, quote_cookie=False) class HttpClient: @@ -17,18 +18,20 @@ class HttpClient: def __init__(self, config: DeviceConfig) -> None: self._config = config - self._client: httpx.AsyncClient = None + self._client: aiohttp.ClientSession = None + self._jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False) + self._last_url = f"http://{self._config.host}/" @property - def client(self) -> httpx.AsyncClient: + def client(self) -> aiohttp.ClientSession: """Return the underlying http client.""" if self._config.http_client and issubclass( - self._config.http_client.__class__, httpx.AsyncClient + self._config.http_client.__class__, aiohttp.ClientSession ): return self._config.http_client if not self._client: - self._client = httpx.AsyncClient() + self._client = aiohttp.ClientSession(cookie_jar=get_cookie_jar()) return self._client async def post( @@ -43,12 +46,8 @@ async def post( ) -> Tuple[int, Optional[Union[Dict, bytes]]]: """Send an http post request to the device.""" response_data = None - cookies = None - if cookies_dict: - cookies = httpx.Cookies() - for name, value in cookies_dict.items(): - cookies.set(name, value) - self.client.cookies.clear() + self._last_url = url + self.client.cookie_jar.clear() try: resp = await self.client.post( url, @@ -56,14 +55,14 @@ async def post( data=data, json=json, timeout=self._config.timeout, - cookies=cookies, + cookies=cookies_dict, headers=headers, ) - except httpx.ConnectError as ex: + except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex: raise ConnectionException( f"Unable to connect to the device: {self._config.host}: {ex}" ) from ex - except httpx.TimeoutException as ex: + except aiohttp.ServerTimeoutError as ex: raise TimeoutException( "Unable to query the device, " + f"timed out: {self._config.host}: {ex}" ) from ex @@ -72,18 +71,25 @@ async def post( f"Unable to query the device: {self._config.host}: {ex}" ) from ex - if resp.status_code == 200: - response_data = resp.json() if json else resp.content + async with resp: + if resp.status == 200: + response_data = await resp.read() + if json: + response_data = json_loads(response_data.decode()) - return resp.status_code, response_data + return resp.status, response_data - def get_cookie(self, cookie_name: str) -> str: + def get_cookie(self, cookie_name: str) -> Optional[str]: """Return the cookie with cookie_name.""" - return self._client.cookies.get(cookie_name) + if cookie := self.client.cookie_jar.filter_cookies(self._last_url).get( + cookie_name + ): + return cookie.value + return None async def close(self) -> None: - """Close the protocol.""" + """Close the client.""" client = self._client self._client = None if client: - await client.aclose() + await client.close() diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index faf47a75e..774aaf943 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -5,7 +5,7 @@ from json import dumps as json_dumps from json import loads as json_loads -import httpx +import aiohttp import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding @@ -19,6 +19,7 @@ SmartDeviceException, SmartErrorCode, ) +from ..httpclient import HttpClient DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} @@ -57,7 +58,7 @@ async def test_handshake( ): host = "127.0.0.1" mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code) - mocker.patch.object(httpx.AsyncClient, "post", side_effect=mock_aes_device.post) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) transport = AesTransport( config=DeviceConfig(host, credentials=Credentials("foo", "bar")) @@ -75,7 +76,7 @@ async def test_handshake( async def test_login(mocker, status_code, error_code, inner_error_code, expectation): host = "127.0.0.1" mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code) - mocker.patch.object(httpx.AsyncClient, "post", side_effect=mock_aes_device.post) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) transport = AesTransport( config=DeviceConfig(host, credentials=Credentials("foo", "bar")) @@ -94,7 +95,7 @@ async def test_login(mocker, status_code, error_code, inner_error_code, expectat async def test_send(mocker, status_code, error_code, inner_error_code, expectation): host = "127.0.0.1" mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code) - mocker.patch.object(httpx.AsyncClient, "post", side_effect=mock_aes_device.post) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) transport = AesTransport( config=DeviceConfig(host, credentials=Credentials("foo", "bar")) @@ -123,7 +124,7 @@ async def test_send(mocker, status_code, error_code, inner_error_code, expectati async def test_passthrough_errors(mocker, error_code): host = "127.0.0.1" mock_aes_device = MockAesDevice(host, 200, error_code, 0) - mocker.patch.object(httpx.AsyncClient, "post", side_effect=mock_aes_device.post) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) config = DeviceConfig(host, credentials=Credentials("foo", "bar")) transport = AesTransport(config=config) @@ -145,12 +146,18 @@ async def test_passthrough_errors(mocker, error_code): class MockAesDevice: class _mock_response: - def __init__(self, status_code, json: dict): - self.status_code = status_code + def __init__(self, status, json: dict): + self.status = status self._json = json - def json(self): - return self._json + async def __aenter__(self): + return self + + async def __aexit__(self, exc_t, exc_v, exc_tb): + pass + + async def read(self): + return json_dumps(self._json).encode() encryption_session = AesEncyptionSession(KEY_IV[:16], KEY_IV[16:]) token = "test_token" # noqa @@ -160,6 +167,7 @@ def __init__(self, host, status_code=200, error_code=0, inner_error_code=0): self.status_code = status_code self.error_code = error_code self.inner_error_code = inner_error_code + self.http_client = HttpClient(DeviceConfig(self.host)) async def post(self, url, params=None, json=None, *_, **__): return await self._post(url, json) @@ -193,7 +201,9 @@ async def _return_secure_passthrough_response(self, url, json): decrypted_request = self.encryption_session.decrypt(encrypted_request.encode()) decrypted_request_dict = json_loads(decrypted_request) decrypted_response = await self._post(url, decrypted_request_dict) - decrypted_response_dict = decrypted_response.json() + async with decrypted_response: + response_data = await decrypted_response.read() + decrypted_response_dict = json_loads(response_data.decode()) encrypted_response = self.encryption_session.encrypt( json_dumps(decrypted_response_dict).encode() ) diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index 75a40af0e..25a13aea5 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -2,7 +2,7 @@ import logging from typing import Type -import httpx +import aiohttp import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( @@ -138,7 +138,7 @@ async def test_connect_http_client(all_fixture_data, mocker): mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data) - http_client = httpx.AsyncClient() + http_client = aiohttp.ClientSession() config = DeviceConfig( host=host, credentials=Credentials("foor", "bar"), connection_type=ctype diff --git a/kasa/tests/test_deviceconfig.py b/kasa/tests/test_deviceconfig.py index 432829158..342de919a 100644 --- a/kasa/tests/test_deviceconfig.py +++ b/kasa/tests/test_deviceconfig.py @@ -1,7 +1,7 @@ from json import dumps as json_dumps from json import loads as json_loads -import httpx +import aiohttp from kasa.credentials import Credentials from kasa.deviceconfig import ( @@ -12,8 +12,8 @@ ) -def test_serialization(): - config = DeviceConfig(host="Foo", http_client=httpx.AsyncClient()) +async def test_serialization(): + config = DeviceConfig(host="Foo", http_client=aiohttp.ClientSession()) config_dict = config.to_dict() config_json = json_dumps(config_dict) config2_dict = json_loads(config_json) @@ -21,10 +21,10 @@ def test_serialization(): assert config == config2 -def test_credentials_hash(): +async def test_credentials_hash(): config = DeviceConfig( host="Foo", - http_client=httpx.AsyncClient(), + http_client=aiohttp.ClientSession(), credentials=Credentials("foo", "bar"), ) config_dict = config.to_dict(credentials_hash="credhash") @@ -35,10 +35,10 @@ def test_credentials_hash(): assert config2.credentials is None -def test_blank_credentials_hash(): +async def test_blank_credentials_hash(): config = DeviceConfig( host="Foo", - http_client=httpx.AsyncClient(), + http_client=aiohttp.ClientSession(), credentials=Credentials("foo", "bar"), ) config_dict = config.to_dict(credentials_hash="") @@ -49,10 +49,10 @@ def test_blank_credentials_hash(): assert config2.credentials is None -def test_exclude_credentials(): +async def test_exclude_credentials(): config = DeviceConfig( host="Foo", - http_client=httpx.AsyncClient(), + http_client=aiohttp.ClientSession(), credentials=Credentials("foo", "bar"), ) config_dict = config.to_dict(exclude_credentials=True) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index a055bd644..071a65035 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -3,7 +3,7 @@ import re import socket -import httpx +import aiohttp import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( @@ -314,7 +314,7 @@ async def test_discover_single_http_client(discovery_mock, mocker): host = "127.0.0.1" discovery_mock.ip = host - http_client = httpx.AsyncClient() + http_client = aiohttp.ClientSession() x: SmartDevice = await Discover.discover_single(host) @@ -331,7 +331,7 @@ async def test_discover_http_client(discovery_mock, mocker): host = "127.0.0.1" discovery_mock.ip = host - http_client = httpx.AsyncClient() + http_client = aiohttp.ClientSession() devices = await Discover.discover(discovery_timeout=0) x: SmartDevice = devices[host] diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index e2e4cb61b..a4b12e2cc 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -7,7 +7,7 @@ import time from contextlib import nullcontext as does_not_raise -import httpx +import aiohttp import pytest from ..aestransport import AesTransport @@ -32,19 +32,28 @@ class _mock_response: - def __init__(self, status_code, content: bytes): - self.status_code = status_code + def __init__(self, status, content: bytes): + self.status = status self.content = content + async def __aenter__(self): + return self + + async def __aexit__(self, exc_t, exc_v, exc_tb): + pass + + async def read(self): + return self.content + @pytest.mark.parametrize( "error, retry_expectation", [ (Exception("dummy exception"), False), - (httpx.TimeoutException("dummy exception"), True), - (httpx.ConnectError("dummy exception"), True), + (aiohttp.ServerTimeoutError("dummy exception"), True), + (aiohttp.ClientOSError("dummy exception"), True), ], - ids=("Exception", "SmartDeviceException", "httpx.ConnectError"), + ids=("Exception", "SmartDeviceException", "ConnectError"), ) @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) @@ -53,7 +62,7 @@ async def test_protocol_retries( mocker, retry_count, protocol_class, transport_class, error, retry_expectation ): host = "127.0.0.1" - conn = mocker.patch.object(httpx.AsyncClient, "post", side_effect=error) + conn = mocker.patch.object(aiohttp.ClientSession, "post", side_effect=error) config = DeviceConfig(host) with pytest.raises(SmartDeviceException): @@ -72,7 +81,7 @@ async def test_protocol_no_retry_on_connection_error( ): host = "127.0.0.1" conn = mocker.patch.object( - httpx.AsyncClient, + aiohttp.ClientSession, "post", side_effect=AuthenticationException("foo"), ) @@ -92,9 +101,9 @@ async def test_protocol_retry_recoverable_error( ): host = "127.0.0.1" conn = mocker.patch.object( - httpx.AsyncClient, + aiohttp.ClientSession, "post", - side_effect=httpx.ConnectError("foo"), + side_effect=aiohttp.ClientOSError("foo"), ) config = DeviceConfig(host) with pytest.raises(SmartDeviceException): @@ -240,7 +249,7 @@ async def _return_handshake1_response(url, params=None, data=None, *_, **__): device_auth_hash = transport_class.generate_auth_hash(device_credentials) mocker.patch.object( - httpx.AsyncClient, "post", side_effect=_return_handshake1_response + aiohttp.ClientSession, "post", side_effect=_return_handshake1_response ) config = DeviceConfig("127.0.0.1", credentials=client_credentials) @@ -299,12 +308,12 @@ async def _return_handshake_response(url, params=None, data=None, *_, **__): device_auth_hash = transport_class.generate_auth_hash(client_credentials) mocker.patch.object( - httpx.AsyncClient, "post", side_effect=_return_handshake_response + aiohttp.ClientSession, "post", side_effect=_return_handshake_response ) config = DeviceConfig("127.0.0.1", credentials=client_credentials) protocol = IotProtocol(transport=transport_class(config=config)) - protocol._transport.http_client = httpx.AsyncClient() + protocol._transport.http_client = aiohttp.ClientSession() response_status = 200 await protocol._transport.perform_handshake() @@ -347,7 +356,7 @@ async def _return_response(url, params=None, data=None, *_, **__): client_credentials = Credentials("foo", "bar") device_auth_hash = KlapTransport.generate_auth_hash(client_credentials) - mocker.patch.object(httpx.AsyncClient, "post", side_effect=_return_response) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=_return_response) config = DeviceConfig("127.0.0.1", credentials=client_credentials) protocol = IotProtocol(transport=KlapTransport(config=config)) @@ -392,7 +401,7 @@ async def _return_response(url, params=None, data=None, *_, **__): client_credentials = Credentials("foo", "bar") device_auth_hash = KlapTransport.generate_auth_hash(client_credentials) - mocker.patch.object(httpx.AsyncClient, "post", side_effect=_return_response) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=_return_response) config = DeviceConfig("127.0.0.1", credentials=client_credentials) protocol = IotProtocol(transport=KlapTransport(config=config)) diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 301e367f5..af2fce4c7 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -8,7 +8,6 @@ from contextlib import nullcontext as does_not_raise from itertools import chain -import httpx import pytest from ..aestransport import AesTransport diff --git a/poetry.lock b/poetry.lock index afab01378..82b12f00b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,115 @@ # This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +[[package]] +name = "aiohttp" +version = "3.9.1" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohttp-3.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1f80197f8b0b846a8d5cf7b7ec6084493950d0882cc5537fb7b96a69e3c8590"}, + {file = "aiohttp-3.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c72444d17777865734aa1a4d167794c34b63e5883abb90356a0364a28904e6c0"}, + {file = "aiohttp-3.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b05d5cbe9dafcdc733262c3a99ccf63d2f7ce02543620d2bd8db4d4f7a22f83"}, + {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c4fa235d534b3547184831c624c0b7c1e262cd1de847d95085ec94c16fddcd5"}, + {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:289ba9ae8e88d0ba16062ecf02dd730b34186ea3b1e7489046fc338bdc3361c4"}, + {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bff7e2811814fa2271be95ab6e84c9436d027a0e59665de60edf44e529a42c1f"}, + {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81b77f868814346662c96ab36b875d7814ebf82340d3284a31681085c051320f"}, + {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b9c7426923bb7bd66d409da46c41e3fb40f5caf679da624439b9eba92043fa6"}, + {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8d44e7bf06b0c0a70a20f9100af9fcfd7f6d9d3913e37754c12d424179b4e48f"}, + {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22698f01ff5653fe66d16ffb7658f582a0ac084d7da1323e39fd9eab326a1f26"}, + {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ca7ca5abfbfe8d39e653870fbe8d7710be7a857f8a8386fc9de1aae2e02ce7e4"}, + {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8d7f98fde213f74561be1d6d3fa353656197f75d4edfbb3d94c9eb9b0fc47f5d"}, + {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5216b6082c624b55cfe79af5d538e499cd5f5b976820eac31951fb4325974501"}, + {file = "aiohttp-3.9.1-cp310-cp310-win32.whl", hash = "sha256:0e7ba7ff228c0d9a2cd66194e90f2bca6e0abca810b786901a569c0de082f489"}, + {file = "aiohttp-3.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:c7e939f1ae428a86e4abbb9a7c4732bf4706048818dfd979e5e2839ce0159f23"}, + {file = "aiohttp-3.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:df9cf74b9bc03d586fc53ba470828d7b77ce51b0582d1d0b5b2fb673c0baa32d"}, + {file = "aiohttp-3.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ecca113f19d5e74048c001934045a2b9368d77b0b17691d905af18bd1c21275e"}, + {file = "aiohttp-3.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8cef8710fb849d97c533f259103f09bac167a008d7131d7b2b0e3a33269185c0"}, + {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bea94403a21eb94c93386d559bce297381609153e418a3ffc7d6bf772f59cc35"}, + {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91c742ca59045dce7ba76cab6e223e41d2c70d79e82c284a96411f8645e2afff"}, + {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c93b7c2e52061f0925c3382d5cb8980e40f91c989563d3d32ca280069fd6a87"}, + {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee2527134f95e106cc1653e9ac78846f3a2ec1004cf20ef4e02038035a74544d"}, + {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11ff168d752cb41e8492817e10fb4f85828f6a0142b9726a30c27c35a1835f01"}, + {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b8c3a67eb87394386847d188996920f33b01b32155f0a94f36ca0e0c635bf3e3"}, + {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c7b5d5d64e2a14e35a9240b33b89389e0035e6de8dbb7ffa50d10d8b65c57449"}, + {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:69985d50a2b6f709412d944ffb2e97d0be154ea90600b7a921f95a87d6f108a2"}, + {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:c9110c06eaaac7e1f5562caf481f18ccf8f6fdf4c3323feab28a93d34cc646bd"}, + {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d737e69d193dac7296365a6dcb73bbbf53bb760ab25a3727716bbd42022e8d7a"}, + {file = "aiohttp-3.9.1-cp311-cp311-win32.whl", hash = "sha256:4ee8caa925aebc1e64e98432d78ea8de67b2272252b0a931d2ac3bd876ad5544"}, + {file = "aiohttp-3.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:a34086c5cc285be878622e0a6ab897a986a6e8bf5b67ecb377015f06ed316587"}, + {file = "aiohttp-3.9.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f800164276eec54e0af5c99feb9494c295118fc10a11b997bbb1348ba1a52065"}, + {file = "aiohttp-3.9.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:500f1c59906cd142d452074f3811614be04819a38ae2b3239a48b82649c08821"}, + {file = "aiohttp-3.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0b0a6a36ed7e164c6df1e18ee47afbd1990ce47cb428739d6c99aaabfaf1b3af"}, + {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69da0f3ed3496808e8cbc5123a866c41c12c15baaaead96d256477edf168eb57"}, + {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:176df045597e674fa950bf5ae536be85699e04cea68fa3a616cf75e413737eb5"}, + {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b796b44111f0cab6bbf66214186e44734b5baab949cb5fb56154142a92989aeb"}, + {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f27fdaadce22f2ef950fc10dcdf8048407c3b42b73779e48a4e76b3c35bca26c"}, + {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcb6532b9814ea7c5a6a3299747c49de30e84472fa72821b07f5a9818bce0f66"}, + {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:54631fb69a6e44b2ba522f7c22a6fb2667a02fd97d636048478db2fd8c4e98fe"}, + {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4b4c452d0190c5a820d3f5c0f3cd8a28ace48c54053e24da9d6041bf81113183"}, + {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:cae4c0c2ca800c793cae07ef3d40794625471040a87e1ba392039639ad61ab5b"}, + {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:565760d6812b8d78d416c3c7cfdf5362fbe0d0d25b82fed75d0d29e18d7fc30f"}, + {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:54311eb54f3a0c45efb9ed0d0a8f43d1bc6060d773f6973efd90037a51cd0a3f"}, + {file = "aiohttp-3.9.1-cp312-cp312-win32.whl", hash = "sha256:85c3e3c9cb1d480e0b9a64c658cd66b3cfb8e721636ab8b0e746e2d79a7a9eed"}, + {file = "aiohttp-3.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:11cb254e397a82efb1805d12561e80124928e04e9c4483587ce7390b3866d213"}, + {file = "aiohttp-3.9.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8a22a34bc594d9d24621091d1b91511001a7eea91d6652ea495ce06e27381f70"}, + {file = "aiohttp-3.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:598db66eaf2e04aa0c8900a63b0101fdc5e6b8a7ddd805c56d86efb54eb66672"}, + {file = "aiohttp-3.9.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c9376e2b09895c8ca8b95362283365eb5c03bdc8428ade80a864160605715f1"}, + {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41473de252e1797c2d2293804e389a6d6986ef37cbb4a25208de537ae32141dd"}, + {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c5857612c9813796960c00767645cb5da815af16dafb32d70c72a8390bbf690"}, + {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffcd828e37dc219a72c9012ec44ad2e7e3066bec6ff3aaa19e7d435dbf4032ca"}, + {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:219a16763dc0294842188ac8a12262b5671817042b35d45e44fd0a697d8c8361"}, + {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f694dc8a6a3112059258a725a4ebe9acac5fe62f11c77ac4dcf896edfa78ca28"}, + {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bcc0ea8d5b74a41b621ad4a13d96c36079c81628ccc0b30cfb1603e3dfa3a014"}, + {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:90ec72d231169b4b8d6085be13023ece8fa9b1bb495e4398d847e25218e0f431"}, + {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:cf2a0ac0615842b849f40c4d7f304986a242f1e68286dbf3bd7a835e4f83acfd"}, + {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:0e49b08eafa4f5707ecfb321ab9592717a319e37938e301d462f79b4e860c32a"}, + {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2c59e0076ea31c08553e868cec02d22191c086f00b44610f8ab7363a11a5d9d8"}, + {file = "aiohttp-3.9.1-cp38-cp38-win32.whl", hash = "sha256:4831df72b053b1eed31eb00a2e1aff6896fb4485301d4ccb208cac264b648db4"}, + {file = "aiohttp-3.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:3135713c5562731ee18f58d3ad1bf41e1d8883eb68b363f2ffde5b2ea4b84cc7"}, + {file = "aiohttp-3.9.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cfeadf42840c1e870dc2042a232a8748e75a36b52d78968cda6736de55582766"}, + {file = "aiohttp-3.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:70907533db712f7aa791effb38efa96f044ce3d4e850e2d7691abd759f4f0ae0"}, + {file = "aiohttp-3.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdefe289681507187e375a5064c7599f52c40343a8701761c802c1853a504558"}, + {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7481f581251bb5558ba9f635db70908819caa221fc79ee52a7f58392778c636"}, + {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49f0c1b3c2842556e5de35f122fc0f0b721334ceb6e78c3719693364d4af8499"}, + {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d406b01a9f5a7e232d1b0d161b40c05275ffbcbd772dc18c1d5a570961a1ca4"}, + {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d8e4450e7fe24d86e86b23cc209e0023177b6d59502e33807b732d2deb6975f"}, + {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c0266cd6f005e99f3f51e583012de2778e65af6b73860038b968a0a8888487a"}, + {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab221850108a4a063c5b8a70f00dd7a1975e5a1713f87f4ab26a46e5feac5a0e"}, + {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c88a15f272a0ad3d7773cf3a37cc7b7d077cbfc8e331675cf1346e849d97a4e5"}, + {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:237533179d9747080bcaad4d02083ce295c0d2eab3e9e8ce103411a4312991a0"}, + {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:02ab6006ec3c3463b528374c4cdce86434e7b89ad355e7bf29e2f16b46c7dd6f"}, + {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04fa38875e53eb7e354ece1607b1d2fdee2d175ea4e4d745f6ec9f751fe20c7c"}, + {file = "aiohttp-3.9.1-cp39-cp39-win32.whl", hash = "sha256:82eefaf1a996060602f3cc1112d93ba8b201dbf5d8fd9611227de2003dddb3b7"}, + {file = "aiohttp-3.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:9b05d33ff8e6b269e30a7957bd3244ffbce2a7a35a81b81c382629b80af1a8bf"}, + {file = "aiohttp-3.9.1.tar.gz", hash = "sha256:8fc49a87ac269d4529da45871e2ffb6874e87779c3d0e2ccd813c0899221239d"}, +] + +[package.dependencies] +aiosignal = ">=1.1.2" +async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns", "brotlicffi"] + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + [[package]] name = "alabaster" version = "0.7.13" @@ -71,6 +181,25 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "attrs" +version = "23.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] + [[package]] name = "babel" version = "2.12.1" @@ -479,61 +608,91 @@ docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1 testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] [[package]] -name = "h11" -version = "0.14.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.7" -files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, -] - -[[package]] -name = "httpcore" -version = "1.0.1" -description = "A minimal low-level HTTP client." +name = "frozenlist" +version = "1.4.1" +description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.8" files = [ - {file = "httpcore-1.0.1-py3-none-any.whl", hash = "sha256:c5e97ef177dca2023d0b9aad98e49507ef5423e9f1d94ffe2cfe250aa28e63b0"}, - {file = "httpcore-1.0.1.tar.gz", hash = "sha256:fce1ddf9b606cfb98132ab58865c3728c52c8e4c3c46e2aabb3674464a186e92"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"}, + {file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"}, + {file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"}, + {file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"}, + {file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"}, + {file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"}, + {file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"}, + {file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"}, + {file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"}, + {file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"}, + {file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"}, + {file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"}, + {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, ] -[package.dependencies] -certifi = "*" -h11 = ">=0.13,<0.15" - -[package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<0.23.0)"] - -[[package]] -name = "httpx" -version = "0.25.1" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -files = [ - {file = "httpx-0.25.1-py3-none-any.whl", hash = "sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a"}, - {file = "httpx-0.25.1.tar.gz", hash = "sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0"}, -] - -[package.dependencies] -anyio = "*" -certifi = "*" -httpcore = "*" -idna = "*" -sniffio = "*" - -[package.extras] -brotli = ["brotli", "brotlicffi"] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] - [[package]] name = "identify" version = "2.5.27" @@ -765,6 +924,89 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "multidict" +version = "6.0.4" +description = "multidict implementation" +optional = false +python-versions = ">=3.7" +files = [ + {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8"}, + {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171"}, + {file = "multidict-6.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7"}, + {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b"}, + {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547"}, + {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569"}, + {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93"}, + {file = "multidict-6.0.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98"}, + {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0"}, + {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988"}, + {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc"}, + {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0"}, + {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5"}, + {file = "multidict-6.0.4-cp310-cp310-win32.whl", hash = "sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8"}, + {file = "multidict-6.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc"}, + {file = "multidict-6.0.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03"}, + {file = "multidict-6.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3"}, + {file = "multidict-6.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba"}, + {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9"}, + {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982"}, + {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe"}, + {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710"}, + {file = "multidict-6.0.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c"}, + {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4"}, + {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a"}, + {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c"}, + {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed"}, + {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461"}, + {file = "multidict-6.0.4-cp311-cp311-win32.whl", hash = "sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636"}, + {file = "multidict-6.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0"}, + {file = "multidict-6.0.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78"}, + {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f"}, + {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603"}, + {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac"}, + {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9"}, + {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2"}, + {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde"}, + {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe"}, + {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067"}, + {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87"}, + {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d"}, + {file = "multidict-6.0.4-cp37-cp37m-win32.whl", hash = "sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775"}, + {file = "multidict-6.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e"}, + {file = "multidict-6.0.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c"}, + {file = "multidict-6.0.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161"}, + {file = "multidict-6.0.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11"}, + {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e"}, + {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d"}, + {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2"}, + {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258"}, + {file = "multidict-6.0.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52"}, + {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660"}, + {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951"}, + {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60"}, + {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d"}, + {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1"}, + {file = "multidict-6.0.4-cp38-cp38-win32.whl", hash = "sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779"}, + {file = "multidict-6.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480"}, + {file = "multidict-6.0.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664"}, + {file = "multidict-6.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35"}, + {file = "multidict-6.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60"}, + {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706"}, + {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d"}, + {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca"}, + {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1"}, + {file = "multidict-6.0.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449"}, + {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf"}, + {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063"}, + {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a"}, + {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176"}, + {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95"}, + {file = "multidict-6.0.4-cp39-cp39-win32.whl", hash = "sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313"}, + {file = "multidict-6.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2"}, + {file = "multidict-6.0.4.tar.gz", hash = "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49"}, +] + [[package]] name = "myst-parser" version = "0.18.1" @@ -1652,6 +1894,109 @@ tests-binary = ["cmake", "cmake", "ninja", "ninja", "pybind11", "pybind11", "sci tests-binary-strict = ["cmake (==3.21.2)", "cmake (==3.25.0)", "ninja (==1.10.2)", "ninja (==1.11.1)", "pybind11 (==2.10.3)", "pybind11 (==2.7.1)", "scikit-build (==0.11.1)", "scikit-build (==0.16.1)"] tests-strict = ["codecov (==2.0.15)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)", "typing (==3.7.4)"] +[[package]] +name = "yarl" +version = "1.9.4" +description = "Yet another URL library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"}, + {file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"}, + {file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"}, + {file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"}, + {file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"}, + {file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"}, + {file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"}, + {file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"}, + {file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"}, + {file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"}, + {file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"}, + {file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"}, + {file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"}, + {file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"}, + {file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"}, + {file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" + [[package]] name = "zipp" version = "3.16.2" @@ -1674,4 +2019,4 @@ speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "4c579a1245e1412f1f591c5c1baf41e14c534de6d32450fd631e5885c1afd4d4" +content-hash = "1186d5079b76081e6681e52062828ad697e7d5aba986d4c189158b77c1f702a5" diff --git a/pyproject.toml b/pyproject.toml index 4e44e4546..8db7c56db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ asyncclick = ">=8" pydantic = ">=1" cryptography = ">=1.9" async-timeout = ">=3.0.0" -httpx = ">=0.25.0" +aiohttp = ">=3" # speed ups orjson = { "version" = ">=3.9.1", optional = true } From c3329155c8ae98c274da62568357edb5929c8968 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 18 Jan 2024 17:51:50 +0000 Subject: [PATCH 246/892] Raise SmartDeviceException on invalid config dicts (#640) Co-authored-by: J. Nick Koston --- kasa/deviceconfig.py | 19 +++++++++++++------ kasa/tests/test_deviceconfig.py | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index dd3560321..58d33661b 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -37,11 +37,16 @@ def _dataclass_from_dict(klass, in_val): fieldtypes = {f.name: f.type for f in fields(klass)} val = {} for dict_key in in_val: - if dict_key in fieldtypes and hasattr(fieldtypes[dict_key], "from_dict"): - val[dict_key] = fieldtypes[dict_key].from_dict(in_val[dict_key]) + if dict_key in fieldtypes: + if hasattr(fieldtypes[dict_key], "from_dict"): + val[dict_key] = fieldtypes[dict_key].from_dict(in_val[dict_key]) + else: + val[dict_key] = _dataclass_from_dict( + fieldtypes[dict_key], in_val[dict_key] + ) else: - val[dict_key] = _dataclass_from_dict( - fieldtypes[dict_key], in_val[dict_key] + raise SmartDeviceException( + f"Cannot create dataclass from dict, unknown key: {dict_key}" ) return klass(**val) else: @@ -173,6 +178,8 @@ def to_dict( return _dataclass_to_dict(self) @staticmethod - def from_dict(cparam_dict: Dict[str, Dict[str, str]]) -> "DeviceConfig": + def from_dict(config_dict: Dict[str, Dict[str, str]]) -> "DeviceConfig": """Return device config from dict.""" - return _dataclass_from_dict(DeviceConfig, cparam_dict) + if isinstance(config_dict, dict): + return _dataclass_from_dict(DeviceConfig, config_dict) + raise SmartDeviceException(f"Invalid device config data: {config_dict}") diff --git a/kasa/tests/test_deviceconfig.py b/kasa/tests/test_deviceconfig.py index 342de919a..b802d2aad 100644 --- a/kasa/tests/test_deviceconfig.py +++ b/kasa/tests/test_deviceconfig.py @@ -2,6 +2,7 @@ from json import loads as json_loads import aiohttp +import pytest from kasa.credentials import Credentials from kasa.deviceconfig import ( @@ -10,6 +11,7 @@ DeviceFamilyType, EncryptType, ) +from kasa.exceptions import SmartDeviceException async def test_serialization(): @@ -21,6 +23,19 @@ async def test_serialization(): assert config == config2 +@pytest.mark.parametrize( + ("input_value", "expected_msg"), + [ + ({"Foo": "Bar"}, "Cannot create dataclass from dict, unknown key: Foo"), + ("foobar", "Invalid device config data: foobar"), + ], + ids=["invalid-dict", "not-dict"], +) +def test_deserialization_errors(input_value, expected_msg): + with pytest.raises(SmartDeviceException, match=expected_msg): + DeviceConfig.from_dict(input_value) + + async def test_credentials_hash(): config = DeviceConfig( host="Foo", From bedf05ce3b27e70d188f8ad2c3e78ee828dcdb56 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 18 Jan 2024 18:32:58 +0000 Subject: [PATCH 247/892] Remove time logging in debug message (#645) --- kasa/klaptransport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 63a412200..39dc1f282 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -315,7 +315,7 @@ async def send(self, request: str): ) msg = ( - f"at {datetime.datetime.now()}. Host is {self._host}, " + f"Host is {self._host}, " + f"Sequence is {seq}, " + f"Response status is {response_status}, Request was {request}" ) From 0647adaba0bb336cd6bc33079440ff72a2c9217c Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 19 Jan 2024 01:36:57 +0100 Subject: [PATCH 248/892] Release 0.6.0 (#653) This major brings major changes to the library by adding support for devices that require authentication for communications, all of this being possible thanks to the great work by @sdb9696! This release adds support to a large range of previously unsupported devices, including: * Newer kasa-branded devices, including Matter-enabled devices like KP125M * Newer hardware/firmware versions on some models, like EP25, that suddenly changed the used protocol * Tapo-branded devices like plugs (P110), light bulbs (KL530), LED strips (L900, L920), and wall switches (KS205, KS225) * UK variant of HS110, which was the first device using the new protocol If your device that is not currently listed as supported is working, please consider contributing a test fixture file. Special thanks goes to @SimonWilkinson who created the initial PR for the new communication protocol! --- CHANGELOG.md | 259 +++++++++++++++++-------------------------------- RELEASING.md | 4 +- pyproject.toml | 2 +- 3 files changed, 95 insertions(+), 170 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2100da39..98d53faee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,54 +1,20 @@ # Changelog -## [0.6.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev2) (2024-01-11) +## [0.6.0](https://github.com/python-kasa/python-kasa/tree/0.6.0) (2024-01-19) -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.dev1...0.6.0.dev2) +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.4...0.6.0) -**Documentation updates:** - -- Update docs for newer devices and DeviceConfig [\#614](https://github.com/python-kasa/python-kasa/pull/614) (@sdb9696) - -**Merged pull requests:** - -- Raise TimeoutException on discover\_single timeout [\#632](https://github.com/python-kasa/python-kasa/pull/632) (@sdb9696) -- Add L900-10 fixture and it's additional component requests [\#629](https://github.com/python-kasa/python-kasa/pull/629) (@sdb9696) -- Avoid recreating struct each request in legacy protocol [\#628](https://github.com/python-kasa/python-kasa/pull/628) (@bdraco) -- Return alias as None for new discovery devices before update [\#627](https://github.com/python-kasa/python-kasa/pull/627) (@sdb9696) -- Update config to\_dict to exclude credentials if the hash is empty string [\#626](https://github.com/python-kasa/python-kasa/pull/626) (@sdb9696) -- Improve test coverage [\#625](https://github.com/python-kasa/python-kasa/pull/625) (@sdb9696) - -## [0.6.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev1) (2024-01-05) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.dev0...0.6.0.dev1) - -**Implemented enhancements:** - -- Get child emeters with CLI [\#623](https://github.com/python-kasa/python-kasa/pull/623) (@Obbay2) -- Avoid linear search for emeter realtime and emeter\_today [\#622](https://github.com/python-kasa/python-kasa/pull/622) (@bdraco) -- Add update-credentials command [\#620](https://github.com/python-kasa/python-kasa/pull/620) (@rytilahti) - -**Fixed bugs:** - -- Check the ct range for color temp support [\#619](https://github.com/python-kasa/python-kasa/pull/619) (@rytilahti) -- Fix cli discover bug with None username/password [\#615](https://github.com/python-kasa/python-kasa/pull/615) (@sdb9696) - -**Closed issues:** - -- Need to do error code checking for new protocols [\#612](https://github.com/python-kasa/python-kasa/issues/612) -- Support of last firmware update version 1.3.0 [\#611](https://github.com/python-kasa/python-kasa/issues/611) -- Implement energy and usage for individual plugs in HS300 [\#462](https://github.com/python-kasa/python-kasa/issues/462) - -**Merged pull requests:** - -- Release 0.6.0.dev1 [\#624](https://github.com/python-kasa/python-kasa/pull/624) (@rytilahti) -- Add P125M and update EP25 fixtures [\#621](https://github.com/python-kasa/python-kasa/pull/621) (@bdraco) -- Use consistent envvars for dump\_devinfo credentials [\#618](https://github.com/python-kasa/python-kasa/pull/618) (@rytilahti) -- Mark L900-5 as supported [\#617](https://github.com/python-kasa/python-kasa/pull/617) (@rytilahti) -- Ship CHANGELOG only in sdist [\#610](https://github.com/python-kasa/python-kasa/pull/610) (@rytilahti) - -## [0.6.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev0) (2024-01-03) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.4...0.6.0.dev0) +This major brings major changes to the library by adding support for devices that require authentication for communications, all of this being possible thanks to the great work by @sdb9696! + +This release adds support to a large range of previously unsupported devices, including: +* Newer kasa-branded devices, including Matter-enabled devices like KP125M +* Newer hardware/firmware versions on some models, like EP25, that suddenly changed the used protocol +* Tapo-branded devices like plugs (P110), light bulbs (KL530), LED strips (L900, L920), and wall switches (KS205, KS225) +* UK variant of HS110, which was the first device using the new protocol + +If your device that is not currently listed as supported is working, please consider contributing a test fixture file. + +Special thanks goes to @SimonWilkinson who created the initial PR for the new communication protocol! **Breaking changes:** @@ -60,6 +26,9 @@ - Support for KS225\(US\) Light Dimmer and KS205\(US\) Light Switch [\#589](https://github.com/python-kasa/python-kasa/issues/589) - Set timeout using command line parameters [\#310](https://github.com/python-kasa/python-kasa/issues/310) - Implement the new protocol \(HTTP over 80/tcp, 20002/udp for discovery\) [\#115](https://github.com/python-kasa/python-kasa/issues/115) +- Get child emeters with CLI [\#623](https://github.com/python-kasa/python-kasa/pull/623) (@Obbay2) +- Avoid linear search for emeter realtime and emeter\_today [\#622](https://github.com/python-kasa/python-kasa/pull/622) (@bdraco) +- Add update-credentials command [\#620](https://github.com/python-kasa/python-kasa/pull/620) (@rytilahti) - Allow serializing and passing of credentials\_hashes in DeviceConfig [\#607](https://github.com/python-kasa/python-kasa/pull/607) (@sdb9696) - Implement wifi interface for tapodevice [\#606](https://github.com/python-kasa/python-kasa/pull/606) (@rytilahti) - Add support for KS205 and KS225 wall switches [\#594](https://github.com/python-kasa/python-kasa/pull/594) (@gimpy88) @@ -84,16 +53,26 @@ **Fixed bugs:** - dump\_devinfo crashes when credentials are not given [\#591](https://github.com/python-kasa/python-kasa/issues/591) +- Fix connection indeterminate state on cancellation [\#636](https://github.com/python-kasa/python-kasa/pull/636) (@bdraco) +- Check the ct range for color temp support [\#619](https://github.com/python-kasa/python-kasa/pull/619) (@rytilahti) +- Fix cli discover bug with None username/password [\#615](https://github.com/python-kasa/python-kasa/pull/615) (@sdb9696) - Fix hsv setting for tapobulb [\#573](https://github.com/python-kasa/python-kasa/pull/573) (@rytilahti) - Fix transport retries after close [\#568](https://github.com/python-kasa/python-kasa/pull/568) (@sdb9696) **Documentation updates:** +- Update the documentation for 0.6 release [\#600](https://github.com/python-kasa/python-kasa/issues/600) +- Update docs for newer devices and DeviceConfig [\#614](https://github.com/python-kasa/python-kasa/pull/614) (@sdb9696) - Update readme with clearer instructions, tapo support [\#571](https://github.com/python-kasa/python-kasa/pull/571) (@rytilahti) - Add some more external links to README [\#541](https://github.com/python-kasa/python-kasa/pull/541) (@rytilahti) **Closed issues:** +- Convert to use aiohttp instead of httpx [\#635](https://github.com/python-kasa/python-kasa/issues/635) +- KS225 support [\#631](https://github.com/python-kasa/python-kasa/issues/631) +- Need to do error code checking for new protocols [\#612](https://github.com/python-kasa/python-kasa/issues/612) +- Support of last firmware update version 1.3.0 [\#611](https://github.com/python-kasa/python-kasa/issues/611) +- Improve test coverage for tapodevice class [\#608](https://github.com/python-kasa/python-kasa/issues/608) - Discover returns dictionary with no 'alias' property [\#592](https://github.com/python-kasa/python-kasa/issues/592) - Sending with the legacy protocol is needlessly delayed [\#553](https://github.com/python-kasa/python-kasa/issues/553) - Issues adding a KP405 device [\#549](https://github.com/python-kasa/python-kasa/issues/549) @@ -102,10 +81,29 @@ - Unable to connect to host on different subnet with 0.5.4 [\#545](https://github.com/python-kasa/python-kasa/issues/545) - Discovery/Connect broken when upgrading from 0.5.3 -\> 0.5.4 [\#543](https://github.com/python-kasa/python-kasa/issues/543) - PydanticUserError, If you use `@root_validator` with pre=False \(the default\) you MUST specify `skip_on_failure=True` [\#516](https://github.com/python-kasa/python-kasa/issues/516) +- Implement energy and usage for individual plugs in HS300 [\#462](https://github.com/python-kasa/python-kasa/issues/462) - KP 125M / support for matter devices [\#450](https://github.com/python-kasa/python-kasa/issues/450) **Merged pull requests:** +- Remove time logging in debug message [\#645](https://github.com/python-kasa/python-kasa/pull/645) (@sdb9696) +- Migrate http client to use aiohttp instead of httpx [\#643](https://github.com/python-kasa/python-kasa/pull/643) (@sdb9696) +- Encapsulate http client dependency [\#642](https://github.com/python-kasa/python-kasa/pull/642) (@sdb9696) +- Fix broken docs due to applehelp dependency [\#641](https://github.com/python-kasa/python-kasa/pull/641) (@sdb9696) +- Raise SmartDeviceException on invalid config dicts [\#640](https://github.com/python-kasa/python-kasa/pull/640) (@sdb9696) +- Add fixture for L920 [\#638](https://github.com/python-kasa/python-kasa/pull/638) (@bdraco) +- Release 0.6.0.dev2 [\#633](https://github.com/python-kasa/python-kasa/pull/633) (@rytilahti) +- Raise TimeoutException on discover\_single timeout [\#632](https://github.com/python-kasa/python-kasa/pull/632) (@sdb9696) +- Add L900-10 fixture and it's additional component requests [\#629](https://github.com/python-kasa/python-kasa/pull/629) (@sdb9696) +- Avoid recreating struct each request in legacy protocol [\#628](https://github.com/python-kasa/python-kasa/pull/628) (@bdraco) +- Return alias as None for new discovery devices before update [\#627](https://github.com/python-kasa/python-kasa/pull/627) (@sdb9696) +- Update config to\_dict to exclude credentials if the hash is empty string [\#626](https://github.com/python-kasa/python-kasa/pull/626) (@sdb9696) +- Improve test coverage [\#625](https://github.com/python-kasa/python-kasa/pull/625) (@sdb9696) +- Release 0.6.0.dev1 [\#624](https://github.com/python-kasa/python-kasa/pull/624) (@rytilahti) +- Add P125M and update EP25 fixtures [\#621](https://github.com/python-kasa/python-kasa/pull/621) (@bdraco) +- Use consistent envvars for dump\_devinfo credentials [\#618](https://github.com/python-kasa/python-kasa/pull/618) (@rytilahti) +- Mark L900-5 as supported [\#617](https://github.com/python-kasa/python-kasa/pull/617) (@rytilahti) +- Ship CHANGELOG only in sdist [\#610](https://github.com/python-kasa/python-kasa/pull/610) (@rytilahti) - Release 0.6.0.dev0 [\#609](https://github.com/python-kasa/python-kasa/pull/609) (@rytilahti) - Cleanup credentials handling [\#605](https://github.com/python-kasa/python-kasa/pull/605) (@rytilahti) - Update P110\(EU\) fixture [\#604](https://github.com/python-kasa/python-kasa/pull/604) (@rytilahti) @@ -526,43 +524,15 @@ Pull requests improving the functionality of modules as well as adding better in ## [0.4.0](https://github.com/python-kasa/python-kasa/tree/0.4.0) (2021-09-27) -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev5...0.4.0) +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.pre0...0.4.0) **Implemented enhancements:** +- KL430 support [\#67](https://github.com/python-kasa/python-kasa/issues/67) +- Improve retry logic for discovery, messaging \(was: Handle empty responses\) [\#38](https://github.com/python-kasa/python-kasa/issues/38) - Fix lock being unexpectedly reset on close [\#218](https://github.com/python-kasa/python-kasa/pull/218) (@bdraco) - Avoid calling pformat unless debug logging is enabled [\#217](https://github.com/python-kasa/python-kasa/pull/217) (@bdraco) - -**Closed issues:** - -- Debug logging in protocol.py is the majority of the execution time [\#216](https://github.com/python-kasa/python-kasa/issues/216) - -**Merged pull requests:** - -- Release 0.4.0 [\#221](https://github.com/python-kasa/python-kasa/pull/221) (@rytilahti) -- Add github workflow for pypi publishing [\#220](https://github.com/python-kasa/python-kasa/pull/220) (@rytilahti) -- Add host information to protocol debug logs [\#219](https://github.com/python-kasa/python-kasa/pull/219) (@rytilahti) - -## [0.4.0.dev5](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev5) (2021-09-24) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev4...0.4.0.dev5) - -**Implemented enhancements:** - - Keep connection open and lock to prevent duplicate requests [\#213](https://github.com/python-kasa/python-kasa/pull/213) (@bdraco) - -**Merged pull requests:** - -- Release 0.4.0.dev5 [\#215](https://github.com/python-kasa/python-kasa/pull/215) (@rytilahti) -- Add KL130 fixture, initial lightstrip tests [\#214](https://github.com/python-kasa/python-kasa/pull/214) (@rytilahti) -- Cleanup discovery & add tests [\#212](https://github.com/python-kasa/python-kasa/pull/212) (@rytilahti) - -## [0.4.0.dev4](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev4) (2021-09-23) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev3...0.4.0.dev4) - -**Implemented enhancements:** - - Improve emeterstatus API, move into own module [\#205](https://github.com/python-kasa/python-kasa/pull/205) (@rytilahti) - Avoid temp array during encrypt and decrypt [\#204](https://github.com/python-kasa/python-kasa/pull/204) (@bdraco) - Add emeter support for strip sockets [\#203](https://github.com/python-kasa/python-kasa/pull/203) (@bdraco) @@ -571,59 +541,39 @@ Pull requests improving the functionality of modules as well as adding better in - Improve bulb support \(alias, time settings\) [\#198](https://github.com/python-kasa/python-kasa/pull/198) (@rytilahti) - Improve testing harness to allow tests on real devices [\#197](https://github.com/python-kasa/python-kasa/pull/197) (@rytilahti) - cli: add human-friendly printout when calling temperature on non-supported devices [\#196](https://github.com/python-kasa/python-kasa/pull/196) (@JaydenRA) +- 'Interface' parameter added to discovery process [\#79](https://github.com/python-kasa/python-kasa/pull/79) (@dmitryelj) +- Add support for lightstrips \(KL430\) [\#74](https://github.com/python-kasa/python-kasa/pull/74) (@rytilahti) **Fixed bugs:** - KL430: Throw error for Device specific information [\#189](https://github.com/python-kasa/python-kasa/issues/189) +- `Unable to find a value for 'current'` error when attempting to query KL125 bulb emeter [\#142](https://github.com/python-kasa/python-kasa/issues/142) +- `Unknown color temperature range` error when attempting to query KL125 bulb state [\#141](https://github.com/python-kasa/python-kasa/issues/141) - HS300 Children plugs have emeter [\#64](https://github.com/python-kasa/python-kasa/issues/64) - dump\_devinfo: handle latitude/longitude keys properly [\#175](https://github.com/python-kasa/python-kasa/pull/175) (@rytilahti) +- Simplify discovery query, refactor dump-devinfo [\#147](https://github.com/python-kasa/python-kasa/pull/147) (@rytilahti) +- Return None instead of raising an exception on missing, valid emeter keys [\#146](https://github.com/python-kasa/python-kasa/pull/146) (@rytilahti) +- Simplify device class detection for discovery, fix hardcoded timeout [\#112](https://github.com/python-kasa/python-kasa/pull/112) (@rytilahti) +- Update cli.py to addresss crash on year/month calls and improve output formatting [\#103](https://github.com/python-kasa/python-kasa/pull/103) (@BuongiornoTexas) **Documentation updates:** - Discover does not support specifying network interface [\#167](https://github.com/python-kasa/python-kasa/issues/167) +- Add ability to control individual sockets on KP400 [\#121](https://github.com/python-kasa/python-kasa/issues/121) +- Improve poetry usage documentation [\#60](https://github.com/python-kasa/python-kasa/issues/60) +- Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) (@rytilahti) **Closed issues:** +- Debug logging in protocol.py is the majority of the execution time [\#216](https://github.com/python-kasa/python-kasa/issues/216) - Feature Request - Toggle Command [\#188](https://github.com/python-kasa/python-kasa/issues/188) - Is It Compatible With HS105? [\#186](https://github.com/python-kasa/python-kasa/issues/186) - Cannot use some functions with KP303 [\#181](https://github.com/python-kasa/python-kasa/issues/181) - Help needed - awaiting game [\#179](https://github.com/python-kasa/python-kasa/issues/179) - Version inconsistency between CLI and pip [\#177](https://github.com/python-kasa/python-kasa/issues/177) - Release 0.4.0.dev3? [\#169](https://github.com/python-kasa/python-kasa/issues/169) -- Can't command or query HS200 v5 switch [\#161](https://github.com/python-kasa/python-kasa/issues/161) - -**Merged pull requests:** - -- Release 0.4.0.dev4 [\#210](https://github.com/python-kasa/python-kasa/pull/210) (@rytilahti) -- More CI fixes [\#208](https://github.com/python-kasa/python-kasa/pull/208) (@rytilahti) -- Fix CI dep installation [\#207](https://github.com/python-kasa/python-kasa/pull/207) (@rytilahti) -- Use github actions instead of azure pipelines [\#206](https://github.com/python-kasa/python-kasa/pull/206) (@rytilahti) -- Add KP115 fixture [\#202](https://github.com/python-kasa/python-kasa/pull/202) (@rytilahti) -- Perform initial update only using the sysinfo query [\#199](https://github.com/python-kasa/python-kasa/pull/199) (@rytilahti) -- Add real kasa KL430\(UN\) device dump [\#192](https://github.com/python-kasa/python-kasa/pull/192) (@iprodanovbg) -- Use less strict matcher for kl430 color temperature [\#190](https://github.com/python-kasa/python-kasa/pull/190) (@rytilahti) -- Add EP10\(US\) 1.0 1.0.2 fixture [\#174](https://github.com/python-kasa/python-kasa/pull/174) (@nbrew) -- Add a note about using the discovery target parameter [\#168](https://github.com/python-kasa/python-kasa/pull/168) (@leandroreox) - -## [0.4.0.dev3](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev3) (2021-06-16) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev2...0.4.0.dev3) - -**Fixed bugs:** - -- `Unable to find a value for 'current'` error when attempting to query KL125 bulb emeter [\#142](https://github.com/python-kasa/python-kasa/issues/142) -- `Unknown color temperature range` error when attempting to query KL125 bulb state [\#141](https://github.com/python-kasa/python-kasa/issues/141) -- Simplify discovery query, refactor dump-devinfo [\#147](https://github.com/python-kasa/python-kasa/pull/147) (@rytilahti) -- Return None instead of raising an exception on missing, valid emeter keys [\#146](https://github.com/python-kasa/python-kasa/pull/146) (@rytilahti) - -**Documentation updates:** - -- Add ability to control individual sockets on KP400 [\#121](https://github.com/python-kasa/python-kasa/issues/121) -- Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) (@rytilahti) - -**Closed issues:** - - After installing, command `kasa` not found [\#165](https://github.com/python-kasa/python-kasa/issues/165) +- Can't command or query HS200 v5 switch [\#161](https://github.com/python-kasa/python-kasa/issues/161) - KL430 causing "non-hexadecimal number found in fromhex\(\) arg at position 2" error in smartdevice.py [\#159](https://github.com/python-kasa/python-kasa/issues/159) - Cant get smart strip children to work [\#144](https://github.com/python-kasa/python-kasa/issues/144) - `kasa --host 192.168.1.67 wifi join ` does not change network [\#139](https://github.com/python-kasa/python-kasa/issues/139) @@ -631,40 +581,11 @@ Pull requests improving the functionality of modules as well as adding better in - 'kasa wifi scan' raises RuntimeError [\#127](https://github.com/python-kasa/python-kasa/issues/127) - Runtime Error when I execute Kasa emeter command [\#124](https://github.com/python-kasa/python-kasa/issues/124) - HS105\(US\) HW 5.0/SW 1.0.2 Not Working [\#119](https://github.com/python-kasa/python-kasa/issues/119) +- TPLINK HS100 firmware 4.1 no longer has TCP 9999 available [\#114](https://github.com/python-kasa/python-kasa/issues/114) - HS110\(UK\) not discoverable [\#113](https://github.com/python-kasa/python-kasa/issues/113) - Stopping Kasa SmartDevices from phoning home [\#111](https://github.com/python-kasa/python-kasa/issues/111) -- TP Link Dimmer switch \(HS220\) hardware version 2.0 not being discovered [\#105](https://github.com/python-kasa/python-kasa/issues/105) -- Support for P100 Smart Plug [\#83](https://github.com/python-kasa/python-kasa/issues/83) - -**Merged pull requests:** - -- Prepare 0.4.0.dev3 [\#172](https://github.com/python-kasa/python-kasa/pull/172) (@rytilahti) -- Simplify mac address handling [\#162](https://github.com/python-kasa/python-kasa/pull/162) (@rytilahti) -- Added KL125 and HS200 fixture dumps and updated tests to run on new format [\#160](https://github.com/python-kasa/python-kasa/pull/160) (@brianthedavis) -- Add KL125 bulb definition [\#143](https://github.com/python-kasa/python-kasa/pull/143) (@mdarnol) -- README.md: Add link to MQTT interface for python-kasa [\#140](https://github.com/python-kasa/python-kasa/pull/140) (@flavio-fernandes) -- Fix documentation on Smart strips [\#136](https://github.com/python-kasa/python-kasa/pull/136) (@flavio-fernandes) -- add tapo link, fix tplink-smarthome-simulator link [\#133](https://github.com/python-kasa/python-kasa/pull/133) (@rytilahti) -- Leverage data from UDP discovery to initialize device structure [\#132](https://github.com/python-kasa/python-kasa/pull/132) (@dlee1j1) -- Add HS220 hw 2.0 fixture [\#107](https://github.com/python-kasa/python-kasa/pull/107) (@appleguru) - -## [0.4.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev2) (2020-11-21) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev1...0.4.0.dev2) - -**Implemented enhancements:** - -- 'Interface' parameter added to discovery process [\#79](https://github.com/python-kasa/python-kasa/pull/79) (@dmitryelj) - -**Fixed bugs:** - -- Simplify device class detection for discovery, fix hardcoded timeout [\#112](https://github.com/python-kasa/python-kasa/pull/112) (@rytilahti) -- Update cli.py to addresss crash on year/month calls and improve output formatting [\#103](https://github.com/python-kasa/python-kasa/pull/103) (@BuongiornoTexas) - -**Closed issues:** - -- TPLINK HS100 firmware 4.1 no longer has TCP 9999 available [\#114](https://github.com/python-kasa/python-kasa/issues/114) - 7.1.2 Update to asyncclick breaks github install of python-kasa [\#106](https://github.com/python-kasa/python-kasa/issues/106) +- TP Link Dimmer switch \(HS220\) hardware version 2.0 not being discovered [\#105](https://github.com/python-kasa/python-kasa/issues/105) - cli emeter year and month functions fail [\#102](https://github.com/python-kasa/python-kasa/issues/102) - how to know the duration for which the plug was ON? [\#99](https://github.com/python-kasa/python-kasa/issues/99) - problem controlling the smartplug through a controller [\#98](https://github.com/python-kasa/python-kasa/issues/98) @@ -673,30 +594,9 @@ Pull requests improving the functionality of modules as well as adding better in - issue with installation [\#95](https://github.com/python-kasa/python-kasa/issues/95) - Running via Crontab [\#92](https://github.com/python-kasa/python-kasa/issues/92) - Issues with setup [\#91](https://github.com/python-kasa/python-kasa/issues/91) - -**Merged pull requests:** - -- Release 0.4.0.dev2 [\#118](https://github.com/python-kasa/python-kasa/pull/118) (@rytilahti) -- Pin dependencies on major versions, add poetry.lock [\#94](https://github.com/python-kasa/python-kasa/pull/94) (@rytilahti) - -## [0.4.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev1) (2020-07-28) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev0...0.4.0.dev1) - -**Implemented enhancements:** - -- KL430 support [\#67](https://github.com/python-kasa/python-kasa/issues/67) -- Improve retry logic for discovery, messaging \(was: Handle empty responses\) [\#38](https://github.com/python-kasa/python-kasa/issues/38) -- Add support for lightstrips \(KL430\) [\#74](https://github.com/python-kasa/python-kasa/pull/74) (@rytilahti) - -**Documentation updates:** - -- Improve poetry usage documentation [\#60](https://github.com/python-kasa/python-kasa/issues/60) - -**Closed issues:** - - I don't python... how do I make this executable? [\#88](https://github.com/python-kasa/python-kasa/issues/88) - ImportError: cannot import name 'smartplug' [\#87](https://github.com/python-kasa/python-kasa/issues/87) +- Support for P100 Smart Plug [\#83](https://github.com/python-kasa/python-kasa/issues/83) - not able to pip install the library [\#82](https://github.com/python-kasa/python-kasa/issues/82) - Discover.discover\(\) add selecting network interface \[pull request\] [\#78](https://github.com/python-kasa/python-kasa/issues/78) - LB100 unable to turn on or off the lights [\#68](https://github.com/python-kasa/python-kasa/issues/68) @@ -705,6 +605,33 @@ Pull requests improving the functionality of modules as well as adding better in **Merged pull requests:** +- Release 0.4.0 [\#221](https://github.com/python-kasa/python-kasa/pull/221) (@rytilahti) +- Add github workflow for pypi publishing [\#220](https://github.com/python-kasa/python-kasa/pull/220) (@rytilahti) +- Add host information to protocol debug logs [\#219](https://github.com/python-kasa/python-kasa/pull/219) (@rytilahti) +- Release 0.4.0.dev5 [\#215](https://github.com/python-kasa/python-kasa/pull/215) (@rytilahti) +- Add KL130 fixture, initial lightstrip tests [\#214](https://github.com/python-kasa/python-kasa/pull/214) (@rytilahti) +- Cleanup discovery & add tests [\#212](https://github.com/python-kasa/python-kasa/pull/212) (@rytilahti) +- Release 0.4.0.dev4 [\#210](https://github.com/python-kasa/python-kasa/pull/210) (@rytilahti) +- More CI fixes [\#208](https://github.com/python-kasa/python-kasa/pull/208) (@rytilahti) +- Fix CI dep installation [\#207](https://github.com/python-kasa/python-kasa/pull/207) (@rytilahti) +- Use github actions instead of azure pipelines [\#206](https://github.com/python-kasa/python-kasa/pull/206) (@rytilahti) +- Add KP115 fixture [\#202](https://github.com/python-kasa/python-kasa/pull/202) (@rytilahti) +- Perform initial update only using the sysinfo query [\#199](https://github.com/python-kasa/python-kasa/pull/199) (@rytilahti) +- Add real kasa KL430\(UN\) device dump [\#192](https://github.com/python-kasa/python-kasa/pull/192) (@iprodanovbg) +- Use less strict matcher for kl430 color temperature [\#190](https://github.com/python-kasa/python-kasa/pull/190) (@rytilahti) +- Add EP10\(US\) 1.0 1.0.2 fixture [\#174](https://github.com/python-kasa/python-kasa/pull/174) (@nbrew) +- Prepare 0.4.0.dev3 [\#172](https://github.com/python-kasa/python-kasa/pull/172) (@rytilahti) +- Add a note about using the discovery target parameter [\#168](https://github.com/python-kasa/python-kasa/pull/168) (@leandroreox) +- Simplify mac address handling [\#162](https://github.com/python-kasa/python-kasa/pull/162) (@rytilahti) +- Added KL125 and HS200 fixture dumps and updated tests to run on new format [\#160](https://github.com/python-kasa/python-kasa/pull/160) (@brianthedavis) +- Add KL125 bulb definition [\#143](https://github.com/python-kasa/python-kasa/pull/143) (@mdarnol) +- README.md: Add link to MQTT interface for python-kasa [\#140](https://github.com/python-kasa/python-kasa/pull/140) (@flavio-fernandes) +- Fix documentation on Smart strips [\#136](https://github.com/python-kasa/python-kasa/pull/136) (@flavio-fernandes) +- add tapo link, fix tplink-smarthome-simulator link [\#133](https://github.com/python-kasa/python-kasa/pull/133) (@rytilahti) +- Leverage data from UDP discovery to initialize device structure [\#132](https://github.com/python-kasa/python-kasa/pull/132) (@dlee1j1) +- Release 0.4.0.dev2 [\#118](https://github.com/python-kasa/python-kasa/pull/118) (@rytilahti) +- Add HS220 hw 2.0 fixture [\#107](https://github.com/python-kasa/python-kasa/pull/107) (@appleguru) +- Pin dependencies on major versions, add poetry.lock [\#94](https://github.com/python-kasa/python-kasa/pull/94) (@rytilahti) - Release 0.4.0.dev1 [\#93](https://github.com/python-kasa/python-kasa/pull/93) (@rytilahti) - add a small example script to show library usage [\#90](https://github.com/python-kasa/python-kasa/pull/90) (@rytilahti) - add .readthedocs.yml required for poetry builds [\#89](https://github.com/python-kasa/python-kasa/pull/89) (@rytilahti) @@ -717,10 +644,6 @@ Pull requests improving the functionality of modules as well as adding better in - Bulbs: allow specifying transition for state changes [\#70](https://github.com/python-kasa/python-kasa/pull/70) (@rytilahti) - Add transition support for SmartDimmer [\#69](https://github.com/python-kasa/python-kasa/pull/69) (@connorproctor) -## [0.4.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev0) (2020-05-27) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.pre0...0.4.0.dev0) - ## [0.4.0.pre0](https://github.com/python-kasa/python-kasa/tree/0.4.0.pre0) (2020-05-27) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.3.5...0.4.0.pre0) diff --git a/RELEASING.md b/RELEASING.md index 37d2e4ca6..96212b1e9 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -24,9 +24,11 @@ poetry version $NEW_RELEASE # gem install github_changelog_generator --pre # https://github.com/github-changelog-generator/github-changelog-generator#github-token export CHANGELOG_GITHUB_TOKEN=token -github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md +github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md --exclude-tags-regex 'dev\d$' ``` +Remove '--exclude-tags-regex' for dev releases. + 4. Commit the changed files ```bash diff --git a/pyproject.toml b/pyproject.toml index 8db7c56db..63b77e6f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.6.0.dev2" +version = "0.6.0" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 38159140fb8da85aa9e13d6fc7a0a6830d99a07e Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 19 Jan 2024 20:06:50 +0000 Subject: [PATCH 249/892] Fix httpclient exceptions on read and improve error info (#655) --- kasa/exceptions.py | 9 ++- kasa/httpclient.py | 23 ++++---- kasa/tests/test_httpclient.py | 100 ++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 kasa/tests/test_httpclient.py diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 49e4e2c8c..c0ef23b6a 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -1,4 +1,5 @@ """python-kasa exceptions.""" +from asyncio import TimeoutError from enum import IntEnum from typing import Optional @@ -27,9 +28,15 @@ class RetryableException(SmartDeviceException): """Retryable exception for device errors.""" -class TimeoutException(SmartDeviceException): +class TimeoutException(SmartDeviceException, TimeoutError): """Timeout exception for device errors.""" + def __repr__(self): + return SmartDeviceException.__repr__(self) + + def __str__(self): + return SmartDeviceException.__str__(self) + class ConnectionException(SmartDeviceException): """Connection exception for device errors.""" diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 91c444ae5..26b8d6a79 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -1,4 +1,5 @@ """Module for HttpClientSession class.""" +import asyncio from typing import Any, Dict, Optional, Tuple, Union import aiohttp @@ -58,25 +59,27 @@ async def post( cookies=cookies_dict, headers=headers, ) + async with resp: + if resp.status == 200: + response_data = await resp.read() + if 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}" + f"Unable to connect to the device: {self._config.host}: {ex}", ex ) from ex - except aiohttp.ServerTimeoutError as ex: + except (aiohttp.ServerTimeoutError, asyncio.TimeoutError) as ex: raise TimeoutException( - "Unable to query the device, " + f"timed out: {self._config.host}: {ex}" + "Unable to query the device, " + + f"timed out: {self._config.host}: {ex}", + ex, ) from ex except Exception as ex: raise SmartDeviceException( - f"Unable to query the device: {self._config.host}: {ex}" + f"Unable to query the device: {self._config.host}: {ex}", ex ) from ex - async with resp: - if resp.status == 200: - response_data = await resp.read() - if json: - response_data = json_loads(response_data.decode()) - return resp.status, response_data def get_cookie(self, cookie_name: str) -> Optional[str]: diff --git a/kasa/tests/test_httpclient.py b/kasa/tests/test_httpclient.py new file mode 100644 index 000000000..0a6c2beba --- /dev/null +++ b/kasa/tests/test_httpclient.py @@ -0,0 +1,100 @@ +import asyncio +import re + +import aiohttp +import pytest + +from ..deviceconfig import DeviceConfig +from ..exceptions import ( + ConnectionException, + SmartDeviceException, + TimeoutException, +) +from ..httpclient import HttpClient + + +@pytest.mark.parametrize( + "error, error_raises, error_message", + [ + ( + aiohttp.ServerDisconnectedError(), + ConnectionException, + "Unable to connect to the device: ", + ), + ( + aiohttp.ClientOSError(), + ConnectionException, + "Unable to connect to the device: ", + ), + ( + aiohttp.ServerTimeoutError(), + TimeoutException, + "Unable to query the device, timed out: ", + ), + ( + asyncio.TimeoutError(), + TimeoutException, + "Unable to query the device, timed out: ", + ), + (Exception(), SmartDeviceException, "Unable to query the device: "), + ( + aiohttp.ServerFingerprintMismatch("exp", "got", "host", 1), + SmartDeviceException, + "Unable to query the device: ", + ), + ], + ids=( + "ServerDisconnectedError", + "ClientOSError", + "ServerTimeoutError", + "TimeoutError", + "Exception", + "ServerFingerprintMismatch", + ), +) +@pytest.mark.parametrize("mock_read", (False, True), ids=("post", "read")) +async def test_httpclient_errors(mocker, error, error_raises, error_message, mock_read): + class _mock_response: + def __init__(self, status, error): + self.status = status + self.error = error + self.call_count = 0 + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_t, exc_v, exc_tb): + pass + + async def read(self): + self.call_count += 1 + raise self.error + + mock_response = _mock_response(200, error) + + async def _post(url, *_, **__): + nonlocal mock_response + return mock_response + + host = "127.0.0.1" + + side_effect = _post if mock_read else error + + conn = mocker.patch.object(aiohttp.ClientSession, "post", side_effect=side_effect) + client = HttpClient(DeviceConfig(host)) + # Exceptions with parameters print with double quotes, without use single quotes + full_msg = ( + "\(" + + "['\"]" + + re.escape(f"{error_message}{host}: {error}") + + "['\"]" + + re.escape(f", {repr(error)})") + ) + with pytest.raises(error_raises, match=error_message) as exc_info: + await client.post("http://foobar") + + assert re.match(full_msg, str(exc_info.value)) + if mock_read: + assert mock_response.call_count == 1 + else: + assert conn.call_count == 1 From d62b5a55cccae224c744df80e60cd00a612d2e12 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Jan 2024 10:30:01 -1000 Subject: [PATCH 250/892] Improve and document close behavior (#654) * Close connection on smartprotocol timeout * tweaks --- kasa/httpclient.py | 14 +++++++------- kasa/klaptransport.py | 5 ++--- kasa/smartprotocol.py | 9 +++++++-- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 26b8d6a79..a4bd84a33 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -19,7 +19,7 @@ class HttpClient: def __init__(self, config: DeviceConfig) -> None: self._config = config - self._client: aiohttp.ClientSession = None + self._client_session: aiohttp.ClientSession = None self._jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False) self._last_url = f"http://{self._config.host}/" @@ -31,9 +31,9 @@ def client(self) -> aiohttp.ClientSession: ): return self._config.http_client - if not self._client: - self._client = aiohttp.ClientSession(cookie_jar=get_cookie_jar()) - return self._client + if not self._client_session: + self._client_session = aiohttp.ClientSession(cookie_jar=get_cookie_jar()) + return self._client_session async def post( self, @@ -91,8 +91,8 @@ def get_cookie(self, cookie_name: str) -> Optional[str]: return None async def close(self) -> None: - """Close the client.""" - client = self._client - self._client = None + """Close the ClientSession.""" + client = self._client_session + self._client_session = None if client: await client.close() diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 39dc1f282..8128a20ba 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -320,7 +320,7 @@ async def send(self, request: str): + f"Response status is {response_status}, Request was {request}" ) if response_status != 200: - _LOGGER.error("Query failed after succesful authentication " + msg) + _LOGGER.error("Query failed after successful authentication " + msg) # If we failed with a security error, force a new handshake next time. if response_status == 403: self._handshake_done = False @@ -351,9 +351,8 @@ async def send(self, request: str): return json_payload async def close(self) -> None: - """Close the transport.""" + """Mark the handshake as not done since we likely lost the connection.""" self._handshake_done = False - await self._http_client.close() @staticmethod def generate_auth_hash(creds: Credentials): diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 8d74a5020..e7143d2ea 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -84,8 +84,8 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: raise ex continue except TimeoutException as ex: + await self.close() if retry >= retry_count: - await self.close() _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_TIMEOUT) @@ -167,7 +167,12 @@ def _handle_response_error_code(self, resp_dict: dict): raise SmartDeviceException(msg, error_code=error_code) async def close(self) -> None: - """Close the protocol.""" + """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. + """ await self._transport.close() From 8523800b23dc1c544cc8bf9ca6803a27f9f4f2a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Jan 2024 15:40:21 -1000 Subject: [PATCH 251/892] Fix minor typos in docstrings (#659) --- kasa/klaptransport.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 8128a20ba..92d6fd2b3 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -26,11 +26,11 @@ handshake2: client sends sha25(remote_seed + auth_hash) to the device along with the TP_SESSIONID. Device responds with -200 if succesful. It generally will be because this -implemenation checks the auth_hash it recevied during handshake1 +200 if successful. It generally will be because this +implementation checks the auth_hash it received during handshake1 encryption: local_seed, remote_seed and auth_hash are now used -for encryption. The last 4 bytes of the initialisation vector +for encryption. The last 4 bytes of the initialization vector are used as a sequence number that increments every time the client calls encrypt and this sequence number is sent as a url parameter to the device along with the encrypted payload From e94cd118a4b21fafa00adffb48e73dcfd3a34944 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sat, 20 Jan 2024 12:22:54 +0000 Subject: [PATCH 252/892] Add fixtures with new MAC mask (#661) --- kasa/tests/conftest.py | 2 +- kasa/tests/fixtures/HS100(UK)_1.0_1.2.6.json | 28 ++ kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json | 6 +- kasa/tests/fixtures/KP105(UK)_1.0_1.0.5.json | 36 +++ kasa/tests/fixtures/KP105(UK)_1.0_1.0.7.json | 8 +- .../fixtures/smart/L510B(EU)_3.0_1.0.5.json | 263 ++++++++++++++++++ .../fixtures/smart/L530E(EU)_3.0_1.1.0.json | 24 +- .../smart/L900-10(EU)_1.0_1.0.17.json | 20 +- .../fixtures/smart/P110(UK)_1.0_1.3.0.json | 62 ++--- 9 files changed, 388 insertions(+), 61 deletions(-) create mode 100644 kasa/tests/fixtures/HS100(UK)_1.0_1.2.6.json create mode 100644 kasa/tests/fixtures/KP105(UK)_1.0_1.0.5.json create mode 100644 kasa/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index dc2444022..4aae40356 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -46,7 +46,7 @@ BULBS_SMART_VARIABLE_TEMP = {"L530E"} BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5"} BULBS_SMART_COLOR = {"L530E", *BULBS_SMART_LIGHT_STRIP} -BULBS_SMART_DIMMABLE = {"KS225"} +BULBS_SMART_DIMMABLE = {"KS225", "L510B"} BULBS_SMART = ( BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) .union(BULBS_SMART_DIMMABLE) diff --git a/kasa/tests/fixtures/HS100(UK)_1.0_1.2.6.json b/kasa/tests/fixtures/HS100(UK)_1.0_1.2.6.json new file mode 100644 index 000000000..fd98bd083 --- /dev/null +++ b/kasa/tests/fixtures/HS100(UK)_1.0_1.2.6.json @@ -0,0 +1,28 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "schedule", + "alias": "#MASKED_NAME#", + "dev_name": "Wi-Fi Smart Plug", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "fwId": "00000000000000000000000000000000", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "70:4F:57:00:00:00", + "model": "HS100(UK)", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -59, + "sw_ver": "1.2.6 Build 200727 Rel.120236", + "type": "IOT.SMARTPLUGSWITCH", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json b/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json index 7caf6aebf..6e33fd7dc 100644 --- a/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json +++ b/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json @@ -6,7 +6,7 @@ "factory_default": true, "hw_ver": "4.1", "ip": "127.0.0.123", - "mac": "00-00-00-00-00-00", + "mac": "CC-32-E5-00-00-00", "mgt_encrypt_schm": { "encrypt_type": "KLAP", "http_port": 80, @@ -28,7 +28,7 @@ "latitude_i": 0, "led_off": 0, "longitude_i": 0, - "mac": "00:00:00:00:00:00", + "mac": "CC:32:E5:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "HS100(UK)", "next_action": { @@ -38,7 +38,7 @@ "oemId": "00000000000000000000000000000000", "on_time": 0, "relay_state": 0, - "rssi": -66, + "rssi": -43, "status": "new", "sw_ver": "1.1.0 Build 201016 Rel.175121", "updating": 0 diff --git a/kasa/tests/fixtures/KP105(UK)_1.0_1.0.5.json b/kasa/tests/fixtures/KP105(UK)_1.0_1.0.5.json new file mode 100644 index 000000000..71ec3b7bf --- /dev/null +++ b/kasa/tests/fixtures/KP105(UK)_1.0_1.0.5.json @@ -0,0 +1,36 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "schedule", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "D8:47:32:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP105(UK)", + "next_action": { + "action": 1, + "id": "8AA75A50A8440B17941D192BD9E01FFA", + "schd_sec": 59160, + "type": 1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -66, + "status": "configured", + "sw_ver": "1.0.5 Build 191209 Rel.094735", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/KP105(UK)_1.0_1.0.7.json b/kasa/tests/fixtures/KP105(UK)_1.0_1.0.7.json index b4a12131a..a66dc9030 100644 --- a/kasa/tests/fixtures/KP105(UK)_1.0_1.0.7.json +++ b/kasa/tests/fixtures/KP105(UK)_1.0_1.0.7.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Uk plug", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, @@ -13,7 +13,7 @@ "latitude_i": 0, "led_off": 0, "longitude_i": 0, - "mac": "00:00:00:00:00:00", + "mac": "D8:47:32:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "KP105(UK)", "next_action": { @@ -24,8 +24,8 @@ "oemId": "00000000000000000000000000000000", "on_time": 0, "relay_state": 0, - "rssi": -48, - "status": "new", + "rssi": -67, + "status": "configured", "sw_ver": "1.0.7 Build 210506 Rel.153510", "updating": 0 } diff --git a/kasa/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json b/kasa/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json new file mode 100644 index 000000000..a53e93bb2 --- /dev/null +++ b/kasa/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json @@ -0,0 +1,263 @@ +{ + "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": "L510B(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-E9-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": 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": "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": false, + "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": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "5C-E9-31-00-00-00", + "model": "L510", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/London", + "rssi": -72, + "signal_level": 1, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1705744435 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 1, + "past7": 1, + "today": 1 + }, + "time_usage": { + "past30": 1, + "past7": 1, + "today": 1 + } + }, + "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": 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/L530E(EU)_3.0_1.1.0.json b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json index db3e38494..b5b90d32d 100644 --- a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json +++ b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json @@ -110,7 +110,7 @@ "factory_default": false, "ip": "127.0.0.123", "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", + "mac": "5C-E9-31-00-00-00", "mgt_encrypt_schm": { "encrypt_type": "KLAP", "http_port": 80, @@ -144,7 +144,7 @@ }, "get_device_info": { "avatar": "bulb", - "brightness": 50, + "brightness": 100, "color_temp": 2700, "color_temp_range": [ 2500, @@ -153,7 +153,7 @@ "default_states": { "re_power_type": "last_states", "state": { - "brightness": 50, + "brightness": 100, "color_temp": 2700, "hue": 0, "saturation": 100 @@ -173,13 +173,13 @@ "lang": "en_US", "latitude": 0, "longitude": 0, - "mac": "00-00-00-00-00-00", + "mac": "5C-E9-31-00-00-00", "model": "L530", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Europe/London", - "rssi": -70, + "rssi": -79, "saturation": 100, "signal_level": 1, "specs": "", @@ -190,22 +190,22 @@ "get_device_time": { "region": "Europe/London", "time_diff": 0, - "timestamp": 1703934078 + "timestamp": 1705742727 }, "get_device_usage": { "power_usage": { - "past30": 221, - "past7": 54, + "past30": 140, + "past7": 0, "today": 0 }, "saved_power": { - "past30": 1452, - "past7": 503, + "past30": 1198, + "past7": 0, "today": 0 }, "time_usage": { - "past30": 1673, - "past7": 557, + "past30": 1338, + "past7": 0, "today": 0 } }, diff --git a/kasa/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json b/kasa/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json index 2900accf8..5d05bc94b 100644 --- a/kasa/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json +++ b/kasa/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json @@ -110,7 +110,7 @@ "factory_default": false, "ip": "127.0.0.123", "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", + "mac": "F0-A7-31-00-00-00", "mgt_encrypt_schm": { "encrypt_type": "AES", "http_port": 80, @@ -191,7 +191,7 @@ "name": "Sunset" }, "longitude": 0, - "mac": "00-00-00-00-00-00", + "mac": "F0-A7-31-00-00-00", "model": "L900", "music_rhythm_enable": false, "music_rhythm_mode": "single_lamp", @@ -199,7 +199,7 @@ "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Europe/London", - "rssi": -58, + "rssi": -60, "saturation": 96, "signal_level": 2, "specs": "", @@ -210,22 +210,22 @@ "get_device_time": { "region": "Europe/London", "time_diff": 0, - "timestamp": 1704909087 + "timestamp": 1705742728 }, "get_device_usage": { "power_usage": { - "past30": 15, - "past7": 0, + "past30": 94, + "past7": 79, "today": 0 }, "saved_power": { - "past30": 69, - "past7": 1, + "past30": 434, + "past7": 365, "today": 0 }, "time_usage": { - "past30": 84, - "past7": 1, + "past30": 528, + "past7": 444, "today": 0 } }, diff --git a/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json b/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json index 22deff8ce..339c5fb26 100644 --- a/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json +++ b/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json @@ -94,7 +94,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, @@ -137,7 +137,7 @@ ] }, "get_current_power": { - "current_power": 11 + "current_power": 0 }, "get_device_info": { "auto_off_remain_time": 0, @@ -148,7 +148,7 @@ "type": "last_states" }, "device_id": "0000000000000000000000000000000000000000", - "device_on": true, + "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.3.0 Build 230905 Rel.152200", "has_set_location_info": true, @@ -158,17 +158,17 @@ "lang": "en_US", "latitude": 0, "longitude": 0, - "mac": "00-00-00-00-00-00", + "mac": "48-22-54-00-00-00", "model": "P110", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", - "on_time": 3087, + "on_time": 0, "overcurrent_status": "normal", "overheated": false, "power_protection_status": "normal", "region": "Europe/London", - "rssi": -50, - "signal_level": 2, + "rssi": -45, + "signal_level": 3, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", "time_diff": 0, @@ -177,23 +177,23 @@ "get_device_time": { "region": "Europe/London", "time_diff": 0, - "timestamp": 1703934043 + "timestamp": 1705742730 }, "get_device_usage": { "power_usage": { - "past30": 2090, - "past7": 1171, - "today": 9 + "past30": 3205, + "past7": 0, + "today": 0 }, "saved_power": { - "past30": 31616, - "past7": 5033, - "today": 42 + "past30": 26000, + "past7": 7265, + "today": 3 }, "time_usage": { - "past30": 33706, - "past7": 6204, - "today": 51 + "past30": 29205, + "past7": 7265, + "today": 3 } }, "get_electricity_price_config": { @@ -329,17 +329,17 @@ "type": "constant" }, "get_energy_usage": { - "current_power": 11300, + "current_power": 0, "electricity_charge": [ 0, 0, 0 ], - "local_time": "2023-12-30 11:00:43", - "month_energy": 2090, - "month_runtime": 33706, - "today_energy": 9, - "today_runtime": 51 + "local_time": "2024-01-20 09:25:31", + "month_energy": 1421, + "month_runtime": 19742, + "today_energy": 0, + "today_runtime": 3 }, "get_fw_download_state": { "auto_upgrade": false, @@ -360,17 +360,17 @@ }, "get_led_info": { "led_rule": "night_mode", - "led_status": true, + "led_status": false, "night_mode": { - "end_time": 550, + "end_time": 538, "night_mode_type": "sunrise_sunset", - "start_time": 961, + "start_time": 989, "sunrise_offset": 62, "sunset_offset": 0 } }, "get_max_power": { - "max_power": 3342 + "max_power": 3379 }, "get_next_event": { "desired_states": { @@ -378,7 +378,7 @@ }, "e_time": 0, "id": "S1", - "s_time": 1703937000, + "s_time": 1705751400, "type": 1 }, "get_protection_power": { @@ -389,7 +389,7 @@ "enable": true, "rule_list": [ { - "day": 30, + "day": 20, "desired_states": { "on": true }, @@ -399,12 +399,12 @@ "enable": true, "id": "S1", "mode": "repeat", - "month": 12, + "month": 1, "s_min": 710, "s_type": "normal", "time_offset": 0, "week_day": 127, - "year": 2023 + "year": 2024 } ], "schedule_rule_max_count": 32, From 49cfef087c7cca214a73c52921bdabd899fce927 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sat, 20 Jan 2024 12:35:05 +0000 Subject: [PATCH 253/892] Make close behaviour consistent across new protocols and transports (#660) --- kasa/aestransport.py | 6 ++++-- kasa/iotprotocol.py | 13 +++++++++---- kasa/smartprotocol.py | 4 ++-- kasa/tests/test_klapprotocol.py | 32 +++++++++++++++++++++++++++++++- 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index c19ead5bd..65b0045df 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -265,10 +265,12 @@ async def send(self, request: str): return await self.send_secure_passthrough(request) async def close(self) -> None: - """Close the transport.""" + """Mark the handshake and login as not done. + + Since we likely lost the connection. + """ self._handshake_done = False self._login_token = None - await self._http_client.close() class AesEncyptionSession: diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index a90015259..9f72bbc0a 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -45,8 +45,8 @@ 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: - await self.close() _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise sdex continue @@ -57,14 +57,14 @@ async def _query(self, request: str, retry_count: int = 3) -> Dict: ) raise auex except RetryableException as ex: + await self.close() if retry >= retry_count: - await self.close() _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex continue except TimeoutException as ex: + await self.close() if retry >= retry_count: - await self.close() _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_TIMEOUT) @@ -85,5 +85,10 @@ async def _execute_query(self, request: str, retry_count: int) -> Dict: return await self._transport.send(request) async def close(self) -> None: - """Close the protocol.""" + """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. + """ await self._transport.close() diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index e7143d2ea..c50c511f9 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -66,8 +66,8 @@ 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: - await self.close() _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise sdex continue @@ -78,8 +78,8 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: ) raise auex except RetryableException as ex: + await self.close() if retry >= retry_count: - await self.close() _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex continue diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index a4b12e2cc..8ae32e3f7 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -16,7 +16,9 @@ from ..exceptions import ( AuthenticationException, ConnectionException, + RetryableException, SmartDeviceException, + TimeoutException, ) from ..httpclient import HttpClient from ..iotprotocol import IotProtocol @@ -58,7 +60,7 @@ async def read(self): @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) @pytest.mark.parametrize("retry_count", [1, 3, 5]) -async def test_protocol_retries( +async def test_protocol_retries_via_client_session( mocker, retry_count, protocol_class, transport_class, error, retry_expectation ): host = "127.0.0.1" @@ -74,6 +76,34 @@ async def test_protocol_retries( assert conn.call_count == expected_count +@pytest.mark.parametrize( + "error, retry_expectation", + [ + (SmartDeviceException("dummy exception"), False), + (RetryableException("dummy exception"), True), + (TimeoutException("dummy exception"), True), + ], + ids=("SmartDeviceException", "RetryableException", "TimeoutException"), +) +@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) +@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) +@pytest.mark.parametrize("retry_count", [1, 3, 5]) +async def test_protocol_retries_via_httpclient( + mocker, retry_count, protocol_class, transport_class, error, retry_expectation +): + host = "127.0.0.1" + conn = mocker.patch.object(HttpClient, "post", side_effect=error) + + config = DeviceConfig(host) + with pytest.raises(SmartDeviceException): + await protocol_class(transport=transport_class(config=config)).query( + DUMMY_QUERY, retry_count=retry_count + ) + + expected_count = retry_count + 1 if retry_expectation else 1 + assert conn.call_count == expected_count + + @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) async def test_protocol_no_retry_on_connection_error( From f77e87dc5dbc8283e20a98d92228b98082951fe3 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 20 Jan 2024 14:20:08 +0100 Subject: [PATCH 254/892] dump_devinfo improvements (#657) * dump_devinfo improvements * Scrub only the last three bytes for mac addresses * Add --target to allow creating fixtures based on discovery * Save fixtures directly to correct location, add --basedir to allow defining the location of repository root * Add --autosave to disable prompting for saving * Update fixtures for devices I have * Add fixture for HS110 hw 4.0 fw 1.0.4 * Improve help strings * Fix tests * Update devtools README * Default to discovery if no host/target given --- devtools/README.md | 18 ++- devtools/dump_devinfo.py | 87 +++++++++---- kasa/smartdevice.py | 8 +- kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.json | 34 ++--- kasa/tests/fixtures/HS110(EU)_4.0_1.0.4.json | 40 ++++++ kasa/tests/fixtures/KL130(EU)_1.0_1.8.8.json | 30 ++--- kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json | 18 +-- kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.json | 34 ++--- .../fixtures/smart/L530E(EU)_3.0_1.0.6.json | 38 +++--- .../fixtures/smart/L900-5(EU)_1.0_1.0.17.json | 118 +++++++++++++++--- .../fixtures/smart/P110(EU)_1.0_1.2.3.json | 47 ++++--- 11 files changed, 307 insertions(+), 165 deletions(-) create mode 100644 kasa/tests/fixtures/HS110(EU)_4.0_1.0.4.json diff --git a/devtools/README.md b/devtools/README.md index 50425c254..99d5ec5a0 100644 --- a/devtools/README.md +++ b/devtools/README.md @@ -3,18 +3,24 @@ This directory contains some simple scripts that can be useful for developers. ## dump_devinfo -* Queries the device and returns a fixture that can be added to the test suite +* Queries the device (if --host is given) or discover devices and creates fixture files that can be added to the test suite. ```shell -Usage: dump_devinfo.py [OPTIONS] HOST +Usage: python -m devtools.dump_devinfo [OPTIONS] - Generate devinfo file for given device. + Generate devinfo files for devices. + + Use --host (for a single device) or --target (for a complete network). Options: + --host TEXT Target host. + --target TEXT Target network for discovery. + --username TEXT Username/email address to authenticate to device. + --password TEXT Password to use to authenticate to device. + --basedir TEXT Base directory for the git repository + --autosave Save without prompting -d, --debug - --help Show this message and exit. - --username For authenticating devices. - --password + --help Show this message and exit. ``` ## create_module_fixtures diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 1fb147c4e..85ad01502 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -13,6 +13,7 @@ import logging import re from collections import defaultdict, namedtuple +from pathlib import Path from pprint import pprint from typing import Dict, List @@ -65,7 +66,17 @@ def scrub(res): res[k] = [scrub(vi) for vi in v] else: if k in keys_to_scrub: - if k in ["latitude", "latitude_i", "longitude", "longitude_i"]: + if k in ["mac", "mic_mac"]: + # Some macs have : or - as a separator and others do not + if len(v) == 12: + v = f"{v[:6]}000000" + else: + delim = ":" if ":" in v else "-" + rest = delim.join( + format(s, "02x") for s in bytes.fromhex("000000") + ) + v = f"{v[:8]}{delim}{rest}" + elif k in ["latitude", "latitude_i", "longitude", "longitude_i"]: v = 0 elif k in ["ip"]: v = "127.0.0.123" @@ -95,8 +106,40 @@ def default_to_regular(d): return d +async def handle_device(basedir, autosave, device: SmartDevice): + """Create a fixture for a single device instance.""" + if isinstance(device, TapoDevice): + filename, copy_folder, final = await get_smart_fixture(device) + else: + filename, copy_folder, final = await get_legacy_fixture(device) + + save_filename = Path(basedir) / copy_folder / filename + + pprint(scrub(final)) + if autosave: + save = "y" + else: + save = click.prompt( + f"Do you want to save the above content to {save_filename} (y/n)" + ) + if save == "y": + click.echo(f"Saving info to {save_filename}") + + with open(save_filename, "w") as f: + json.dump(final, f, sort_keys=True, indent=4) + f.write("\n") + else: + click.echo("Not saving.") + + @click.command() -@click.argument("host") +@click.option("--host", required=False, help="Target host.") +@click.option( + "--target", + required=False, + default="255.255.255.255", + help="Target network for discovery.", +) @click.option( "--username", default="", @@ -111,37 +154,31 @@ def default_to_regular(d): envvar="KASA_PASSWORD", help="Password to use to authenticate to device.", ) +@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("-d", "--debug", is_flag=True) -async def cli(host, debug, username, password): - """Generate devinfo file for given device.""" +async def cli(host, target, basedir, autosave, debug, username, password): + """Generate devinfo files for devices. + + Use --host (for a single device) or --target (for a complete network). + """ if debug: logging.basicConfig(level=logging.DEBUG) credentials = Credentials(username=username, password=password) - device = await Discover.discover_single(host, credentials=credentials) - - if isinstance(device, TapoDevice): - save_filename, copy_folder, final = await get_smart_fixture(device) + 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) else: - save_filename, copy_folder, final = await get_legacy_fixture(device) - - pprint(scrub(final)) - save = click.prompt( - f"Do you want to save the above content to {save_filename} (y/n)" - ) - if save == "y": - click.echo(f"Saving info to {save_filename}") - - with open(save_filename, "w") as f: - json.dump(final, f, sort_keys=True, indent=4) - f.write("\n") - click.echo( - f"Saved. Copy/Move {save_filename} to " - + f"{copy_folder} to add it to the test suite" + "No --host given, performing discovery on %s. Use --target to override." + % target ) - else: - click.echo("Not saving.") + 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) async def get_legacy_fixture(device): diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 4249807f1..54e7c4492 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -125,7 +125,7 @@ class SmartDevice: >>> dev.rssi -71 >>> dev.mac - 50:C7:BF:01:F8:CD + 50:C7:BF:00:00:00 Some information can also be changed programmatically: @@ -155,9 +155,9 @@ class SmartDevice: 'hw_ver': '1.0', 'mac': '01:23:45:67:89:ab', 'type': 'IOT.SMARTPLUGSWITCH', - 'hwId': '45E29DA8382494D2E82688B52A0B2EB5', + 'hwId': '00000000000000000000000000000000', 'fwId': '00000000000000000000000000000000', - 'oemId': '3D341ECE302C0642C99E31CE2430544B', + 'oemId': '00000000000000000000000000000000', 'dev_name': 'Wi-Fi Smart Plug With Energy Monitoring'} >>> dev.sys_info @@ -175,7 +175,7 @@ class SmartDevice: >>> dev.has_emeter True >>> dev.emeter_realtime - + >>> dev.emeter_today >>> dev.emeter_this_month diff --git a/kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.json b/kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.json index 1a8ced8f5..4708d5026 100644 --- a/kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.json +++ b/kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.json @@ -1,44 +1,32 @@ { "emeter": { "get_realtime": { - "current": 0.015342, + "current": 0.014937, "err_code": 0, - "power": 0.983971, - "total": 32.448, - "voltage": 235.595234 + "power": 0.928511, + "total": 55.139, + "voltage": 231.067823 } }, - "smartlife.iot.common.emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.smartbulb.lightingservice": { - "err_code": -1, - "err_msg": "module not support" - }, "system": { "get_sysinfo": { "active_mode": "schedule", "alias": "Kitchen", "dev_name": "Wi-Fi Smart Plug With Energy Monitoring", - "deviceId": "8006588E50AD389303FF31AB6302907A17442F16", + "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, "feature": "TIM:ENE", "fwId": "00000000000000000000000000000000", - "hwId": "45E29DA8382494D2E82688B52A0B2EB5", + "hwId": "00000000000000000000000000000000", "hw_ver": "1.0", "icon_hash": "", - "latitude": 51.476938, + "latitude": 0, "led_off": 1, - "longitude": 7.216849, - "mac": "50:C7:BF:01:F8:CD", + "longitude": 0, + "mac": "50:C7:BF:00:00:00", "model": "HS110(EU)", - "oemId": "3D341ECE302C0642C99E31CE2430544B", - "on_time": 512874, + "oemId": "00000000000000000000000000000000", + "on_time": 6023162, "relay_state": 1, "rssi": -71, "sw_ver": "1.2.5 Build 171213 Rel.101523", diff --git a/kasa/tests/fixtures/HS110(EU)_4.0_1.0.4.json b/kasa/tests/fixtures/HS110(EU)_4.0_1.0.4.json new file mode 100644 index 000000000..1d9b3d3ed --- /dev/null +++ b/kasa/tests/fixtures/HS110(EU)_4.0_1.0.4.json @@ -0,0 +1,40 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 451, + "err_code": 0, + "power_mw": 61753, + "total_wh": 16323, + "voltage_mv": 230837 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug With Energy Monitoring", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "4.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 1, + "longitude_i": 0, + "mac": "B0:95:75:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS110(EU)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 1484778, + "relay_state": 1, + "rssi": -60, + "status": "new", + "sw_ver": "1.0.4 Build 191111 Rel.143500", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/KL130(EU)_1.0_1.8.8.json b/kasa/tests/fixtures/KL130(EU)_1.0_1.8.8.json index b57a01d28..98714cfde 100644 --- a/kasa/tests/fixtures/KL130(EU)_1.0_1.8.8.json +++ b/kasa/tests/fixtures/KL130(EU)_1.0_1.8.8.json @@ -2,24 +2,24 @@ "smartlife.iot.common.emeter": { "get_realtime": { "err_code": 0, - "power_mw": 1300 + "power_mw": 2500 } }, "smartlife.iot.smartbulb.lightingservice": { "get_light_state": { - "brightness": 5, - "color_temp": 2700, + "brightness": 17, + "color_temp": 2500, "err_code": 0, - "hue": 1, + "hue": 0, "mode": "normal", "on_off": 1, - "saturation": 1 + "saturation": 0 } }, "system": { "get_sysinfo": { - "active_mode": "schedule", - "alias": "bedroom", + "active_mode": "none", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" @@ -29,7 +29,7 @@ "deviceId": "0000000000000000000000000000000000000000", "disco_ver": "1.0", "err_code": 0, - "heapsize": 332316, + "heapsize": 334708, "hwId": "00000000000000000000000000000000", "hw_ver": "1.0", "is_color": 1, @@ -37,20 +37,20 @@ "is_factory": false, "is_variable_color_temp": 1, "light_state": { - "brightness": 5, - "color_temp": 2700, - "hue": 1, + "brightness": 17, + "color_temp": 2500, + "hue": 0, "mode": "normal", "on_off": 1, - "saturation": 1 + "saturation": 0 }, - "mic_mac": "000000000000", + "mic_mac": "1C3BF3000000", "mic_type": "IOT.SMARTBULB", "model": "KL130(EU)", "oemId": "00000000000000000000000000000000", "preferred_state": [ { - "brightness": 10, + "brightness": 50, "color_temp": 2500, "hue": 0, "index": 0, @@ -78,7 +78,7 @@ "saturation": 75 } ], - "rssi": -62, + "rssi": -60, "sw_ver": "1.8.8 Build 190613 Rel.123436" } } diff --git a/kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json b/kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json index 790597dc0..3b577bac5 100644 --- a/kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json +++ b/kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json @@ -1,17 +1,17 @@ { "emeter": { "get_realtime": { - "current_ma": 0, + "current_ma": 296, "err_code": 0, - "power_mw": 0, - "total_wh": 0, - "voltage_mv": 231561 + "power_mw": 63499, + "total_wh": 12068, + "voltage_mv": 230577 } }, "system": { "get_sysinfo": { "active_mode": "none", - "alias": "TP-LINK_Smart Plug_330B", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, @@ -20,9 +20,9 @@ "hw_ver": "1.0", "icon_hash": "", "latitude_i": 0, - "led_off": 0, + "led_off": 1, "longitude_i": 0, - "mac": "00:00:00:00:00:00", + "mac": "C0:06:C3:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "KP115(EU)", "next_action": { @@ -31,9 +31,9 @@ "ntc_state": 0, "obd_src": "tplink", "oemId": "00000000000000000000000000000000", - "on_time": 197, + "on_time": 2078998, "relay_state": 1, - "rssi": -70, + "rssi": -49, "status": "new", "sw_ver": "1.0.16 Build 210205 Rel.163735", "updating": 0 diff --git a/kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.json b/kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.json index 40a6a2595..c6d632f09 100644 --- a/kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.json +++ b/kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.json @@ -1,20 +1,4 @@ { - "emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.common.emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.smartbulb.lightingservice": { - "err_code": -1, - "err_msg": "module not support" - }, "system": { "get_sysinfo": { "alias": "TP-LINK_Power Strip_CF69", @@ -22,29 +6,29 @@ "children": [ { "alias": "Plug 1", - "id": "00", + "id": "8006E9854025B67C3F9D99BA1E66223D1C9A8A7700", "next_action": { "type": -1 }, - "on_time": 302, + "on_time": 79701, "state": 1 }, { "alias": "Plug 2", - "id": "01", + "id": "8006E9854025B67C3F9D99BA1E66223D1C9A8A7701", "next_action": { "type": -1 }, - "on_time": 0, + "on_time": 79700, "state": 0 }, { "alias": "Plug 3", - "id": "02", + "id": "8006E9854025B67C3F9D99BA1E66223D1C9A8A7702", "next_action": { "type": -1 }, - "on_time": 0, + "on_time": 1484408, "state": 0 } ], @@ -54,14 +38,14 @@ "hwId": "00000000000000000000000000000000", "hw_ver": "1.0", "latitude_i": 0, - "led_off": 0, + "led_off": 1, "longitude_i": 0, - "mac": "00:00:00:00:00:00", + "mac": "1C:3B:F3:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "KP303(UK)", "ntc_state": 0, "oemId": "00000000000000000000000000000000", - "rssi": -63, + "rssi": -68, "status": "new", "sw_ver": "1.0.3 Build 191105 Rel.113122", "updating": 0 diff --git a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json index 09fd8edb9..10b9d3002 100644 --- a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json +++ b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json @@ -106,7 +106,7 @@ "factory_default": false, "ip": "127.0.0.123", "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", + "mac": "5C-E9-31-00-00-00", "mgt_encrypt_schm": { "encrypt_type": "AES", "http_port": 80, @@ -141,7 +141,7 @@ "get_device_info": { "avatar": "bulb", "brightness": 100, - "color_temp": 2500, + "color_temp": 6500, "color_temp_range": [ 2500, 6500 @@ -150,33 +150,33 @@ "re_power_type": "always_on", "state": { "brightness": 100, - "color_temp": 2500, - "hue": 31, - "saturation": 88 + "color_temp": 6500, + "hue": 9, + "saturation": 67 }, "type": "last_states" }, "device_id": "0000000000000000000000000000000000000000", - "device_on": false, + "device_on": true, "dynamic_light_effect_enable": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.6 Build 230509 Rel.195312", "has_set_location_info": true, - "hue": 31, + "hue": 9, "hw_id": "00000000000000000000000000000000", "hw_ver": "3.0", "ip": "127.0.0.123", "lang": "en_US", "latitude": 0, "longitude": 0, - "mac": "00-00-00-00-00-00", + "mac": "5C-E9-31-00-00-00", "model": "L530", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Europe/Berlin", "rssi": -48, - "saturation": 88, + "saturation": 67, "signal_level": 3, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", @@ -186,23 +186,23 @@ "get_device_time": { "region": "Europe/Berlin", "time_diff": 60, - "timestamp": 1704212283 + "timestamp": 1705700000 }, "get_device_usage": { "power_usage": { - "past30": 53, - "past7": 12, - "today": 0 + "past30": 19, + "past7": 3, + "today": 3 }, "saved_power": { - "past30": 494, - "past7": 112, - "today": 0 + "past30": 179, + "past7": 20, + "today": 20 }, "time_usage": { - "past30": 547, - "past7": 124, - "today": 0 + "past30": 198, + "past7": 23, + "today": 23 } }, "get_dynamic_light_effect_rules": { diff --git a/kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json b/kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json index acf96268d..a281f2ec4 100644 --- a/kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json +++ b/kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json @@ -110,7 +110,7 @@ "factory_default": false, "ip": "127.0.0.123", "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", + "mac": "A8-42-A1-00-00-00", "mgt_encrypt_schm": { "encrypt_type": "AES", "http_port": 80, @@ -141,7 +141,7 @@ "get_device_info": { "avatar": "", "brightness": 100, - "color_temp": 9000, + "color_temp": 0, "color_temp_range": [ 9000, 9000 @@ -149,18 +149,18 @@ "default_states": { "state": { "brightness": 100, - "color_temp": 9000, - "hue": 0, - "saturation": 100 + "color_temp": 0, + "hue": 354, + "saturation": 93 }, "type": "last_states" }, "device_id": "0000000000000000000000000000000000000000", - "device_on": true, + "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.17 Build 230426 Rel.153230", "has_set_location_info": false, - "hue": 0, + "hue": 354, "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", "ip": "127.0.0.123", @@ -175,7 +175,7 @@ "name": "station" }, "longitude": 0, - "mac": "00-00-00-00-00-00", + "mac": "A8-42-A1-00-00-00", "model": "L900", "music_rhythm_enable": false, "music_rhythm_mode": "single_lamp", @@ -183,8 +183,8 @@ "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Europe/Berlin", - "rssi": -50, - "saturation": 100, + "rssi": -55, + "saturation": 93, "signal_level": 2, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", @@ -194,23 +194,23 @@ "get_device_time": { "region": "Europe/Berlin", "time_diff": 0, - "timestamp": 1704389578 + "timestamp": 1705699997 }, "get_device_usage": { "power_usage": { - "past30": 5, - "past7": 5, - "today": 5 + "past30": 15, + "past7": 0, + "today": 0 }, "saved_power": { - "past30": 17, - "past7": 17, - "today": 17 + "past30": 81, + "past7": 1, + "today": 0 }, "time_usage": { - "past30": 22, - "past7": 22, - "today": 22 + "past30": 96, + "past7": 1, + "today": 0 } }, "get_fw_download_state": { @@ -221,6 +221,84 @@ "upgrade_time": 5 }, "get_inherit_info": null, + "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 diff --git a/kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json b/kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json index e73742b77..415e8ce67 100644 --- a/kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json +++ b/kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.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": "AES", "http_port": 80, @@ -140,7 +140,7 @@ "lang": "en_US", "latitude": 0, "longitude": 0, - "mac": "00-00-00-00-00-00", + "mac": "48-22-54-00-00-00", "model": "P110", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", @@ -158,23 +158,23 @@ "get_device_time": { "region": "Europe/Berlin", "time_diff": 60, - "timestamp": 1704212614 + "timestamp": 1705699998 }, "get_device_usage": { "power_usage": { - "past30": 896, - "past7": 259, - "today": 0 + "past30": 1010, + "past7": 368, + "today": 46 }, "saved_power": { - "past30": 23138, - "past7": 6684, - "today": 0 + "past30": 26144, + "past7": 9536, + "today": 1218 }, "time_usage": { - "past30": 24034, - "past7": 6943, - "today": 0 + "past30": 27154, + "past7": 9904, + "today": 1264 } }, "get_electricity_price_config": { @@ -316,11 +316,11 @@ 0, 0 ], - "local_time": "2024-01-02 17:23:34", - "month_energy": 0, - "month_runtime": 0, - "today_energy": 0, - "today_runtime": 0 + "local_time": "2024-01-19 22:33:18", + "month_energy": 430, + "month_runtime": 11571, + "today_energy": 46, + "today_runtime": 1264 }, "get_fw_download_state": { "auto_upgrade": false, @@ -341,7 +341,7 @@ } }, "get_max_power": { - "max_power": 3847 + "max_power": 3904 }, "get_next_event": {}, "get_protection_power": { @@ -356,7 +356,16 @@ "sum": 0 }, "get_wireless_scan_info": { - "ap_list": [], + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], "wep_supported": false }, "qs_component_nego": { From df5979182959a2d7a5a5e1b2960d6899c792ba9a Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 21 Jan 2024 01:41:13 +0100 Subject: [PATCH 255/892] Add l900-5 1.1.0 fixture (#664) * Adds new localSmart module * Changed from AES to klap * Doesn't reboot itself when receiving a RST for the cloud connectivity requests --- .../fixtures/smart/L900-5(EU)_1.0_1.1.0.json | 390 ++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json diff --git a/kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json b/kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json new file mode 100644 index 000000000..136d3a0f3 --- /dev/null +++ b/kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json @@ -0,0 +1,390 @@ +{ + "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 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L900-5(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-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": 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": "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.1.0 Build 230905 Rel.184939", + "has_set_location_info": false, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "lighting_effect": { + "brightness": 50, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "longitude": 0, + "mac": "A8-42-A1-00-00-00", + "model": "L900", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "", + "rssi": -50, + "saturation": 100, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "", + "time_diff": 60, + "timestamp": 1705796222 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 2, + "past7": 2, + "today": 2 + }, + "time_usage": { + "past30": 2, + "past7": 2, + "today": 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 230905 Rel.184939", + "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": 16, + "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": "L900", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} From b784d891ff947009180830208b5fb2b001d06ef1 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 21 Jan 2024 01:55:24 +0100 Subject: [PATCH 256/892] Release 0.6.0.1 (#666) A patch release to improve the protocol handling. --- CHANGELOG.md | 43 ++++++++++++++++++++++++++++++++++--------- pyproject.toml | 2 +- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d53faee..de52be989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [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) + +A patch release to improve the protocol handling. + +**Fixed bugs:** + +- Fix httpclient exceptions on read and improve error info [\#655](https://github.com/python-kasa/python-kasa/pull/655) (@sdb9696) +- Improve and document close behavior [\#654](https://github.com/python-kasa/python-kasa/pull/654) (@bdraco) + +**Closed issues:** + +- Do not redact OUI for fixtures [\#652](https://github.com/python-kasa/python-kasa/issues/652) + +**Merged pull requests:** + +- 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) +- Fix minor typos in docstrings [\#659](https://github.com/python-kasa/python-kasa/pull/659) (@bdraco) +- dump\_devinfo improvements [\#657](https://github.com/python-kasa/python-kasa/pull/657) (@rytilahti) + ## [0.6.0](https://github.com/python-kasa/python-kasa/tree/0.6.0) (2024-01-19) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.4...0.6.0) @@ -7,6 +30,7 @@ This major brings major changes to the library by adding support for devices that require authentication for communications, all of this being possible thanks to the great work by @sdb9696! This release adds support to a large range of previously unsupported devices, including: + * Newer kasa-branded devices, including Matter-enabled devices like KP125M * Newer hardware/firmware versions on some models, like EP25, that suddenly changed the used protocol * Tapo-branded devices like plugs (P110), light bulbs (KL530), LED strips (L900, L920), and wall switches (KS205, KS225) @@ -29,9 +53,6 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co - Get child emeters with CLI [\#623](https://github.com/python-kasa/python-kasa/pull/623) (@Obbay2) - Avoid linear search for emeter realtime and emeter\_today [\#622](https://github.com/python-kasa/python-kasa/pull/622) (@bdraco) - Add update-credentials command [\#620](https://github.com/python-kasa/python-kasa/pull/620) (@rytilahti) -- Allow serializing and passing of credentials\_hashes in DeviceConfig [\#607](https://github.com/python-kasa/python-kasa/pull/607) (@sdb9696) -- Implement wifi interface for tapodevice [\#606](https://github.com/python-kasa/python-kasa/pull/606) (@rytilahti) -- Add support for KS205 and KS225 wall switches [\#594](https://github.com/python-kasa/python-kasa/pull/594) (@gimpy88) - Enable multiple requests in smartprotocol [\#584](https://github.com/python-kasa/python-kasa/pull/584) (@sdb9696) - Improve CLI Discovery output [\#583](https://github.com/python-kasa/python-kasa/pull/583) (@sdb9696) - Improve smartprotocol error handling and retries [\#578](https://github.com/python-kasa/python-kasa/pull/578) (@sdb9696) @@ -41,13 +62,16 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co - Make timeout configurable for cli [\#564](https://github.com/python-kasa/python-kasa/pull/564) (@rytilahti) - Update dump\_devinfo to produce new TAPO/SMART fixtures [\#561](https://github.com/python-kasa/python-kasa/pull/561) (@sdb9696) - Kasa KP125M basic emeter support [\#560](https://github.com/python-kasa/python-kasa/pull/560) (@sbytnar) -- Add support for tapo bulbs [\#558](https://github.com/python-kasa/python-kasa/pull/558) (@rytilahti) - Add klap support for TAPO protocol by splitting out Transports and Protocols [\#557](https://github.com/python-kasa/python-kasa/pull/557) (@sdb9696) - Update dump\_devinfo to include 20002 discovery results [\#556](https://github.com/python-kasa/python-kasa/pull/556) (@sdb9696) - Set TCP\_NODELAY to avoid needless buffering [\#554](https://github.com/python-kasa/python-kasa/pull/554) (@bdraco) - Add support for the protocol used by TAPO devices and some newer KASA devices. [\#552](https://github.com/python-kasa/python-kasa/pull/552) (@sdb9696) - Re-add protocol\_class parameter to connect [\#551](https://github.com/python-kasa/python-kasa/pull/551) (@sdb9696) - Update discover single to handle hostnames [\#539](https://github.com/python-kasa/python-kasa/pull/539) (@sdb9696) +- Allow serializing and passing of credentials\_hashes in DeviceConfig [\#607](https://github.com/python-kasa/python-kasa/pull/607) (@sdb9696) +- Implement wifi interface for tapodevice [\#606](https://github.com/python-kasa/python-kasa/pull/606) (@rytilahti) +- Add support for KS205 and KS225 wall switches [\#594](https://github.com/python-kasa/python-kasa/pull/594) (@gimpy88) +- Add support for tapo bulbs [\#558](https://github.com/python-kasa/python-kasa/pull/558) (@rytilahti) - Add klap protocol [\#509](https://github.com/python-kasa/python-kasa/pull/509) (@sdb9696) **Fixed bugs:** @@ -68,11 +92,7 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co **Closed issues:** -- Convert to use aiohttp instead of httpx [\#635](https://github.com/python-kasa/python-kasa/issues/635) - KS225 support [\#631](https://github.com/python-kasa/python-kasa/issues/631) -- Need to do error code checking for new protocols [\#612](https://github.com/python-kasa/python-kasa/issues/612) -- Support of last firmware update version 1.3.0 [\#611](https://github.com/python-kasa/python-kasa/issues/611) -- Improve test coverage for tapodevice class [\#608](https://github.com/python-kasa/python-kasa/issues/608) - Discover returns dictionary with no 'alias' property [\#592](https://github.com/python-kasa/python-kasa/issues/592) - Sending with the legacy protocol is needlessly delayed [\#553](https://github.com/python-kasa/python-kasa/issues/553) - Issues adding a KP405 device [\#549](https://github.com/python-kasa/python-kasa/issues/549) @@ -83,9 +103,14 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co - PydanticUserError, If you use `@root_validator` with pre=False \(the default\) you MUST specify `skip_on_failure=True` [\#516](https://github.com/python-kasa/python-kasa/issues/516) - Implement energy and usage for individual plugs in HS300 [\#462](https://github.com/python-kasa/python-kasa/issues/462) - KP 125M / support for matter devices [\#450](https://github.com/python-kasa/python-kasa/issues/450) +- Convert to use aiohttp instead of httpx [\#635](https://github.com/python-kasa/python-kasa/issues/635) +- Need to do error code checking for new protocols [\#612](https://github.com/python-kasa/python-kasa/issues/612) +- Support of last firmware update version 1.3.0 [\#611](https://github.com/python-kasa/python-kasa/issues/611) +- Improve test coverage for tapodevice class [\#608](https://github.com/python-kasa/python-kasa/issues/608) **Merged pull requests:** +- Release 0.6.0 [\#653](https://github.com/python-kasa/python-kasa/pull/653) (@rytilahti) - Remove time logging in debug message [\#645](https://github.com/python-kasa/python-kasa/pull/645) (@sdb9696) - Migrate http client to use aiohttp instead of httpx [\#643](https://github.com/python-kasa/python-kasa/pull/643) (@sdb9696) - Encapsulate http client dependency [\#642](https://github.com/python-kasa/python-kasa/pull/642) (@sdb9696) @@ -111,7 +136,6 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co - Cleanup custom exception kwarg handling [\#602](https://github.com/python-kasa/python-kasa/pull/602) (@rytilahti) - Pull up emeter handling to tapodevice base class [\#601](https://github.com/python-kasa/python-kasa/pull/601) (@rytilahti) - Add L530\(EU\) klap fixture [\#598](https://github.com/python-kasa/python-kasa/pull/598) (@sdb9696) -- Add known smart requests to dump\_devinfo [\#597](https://github.com/python-kasa/python-kasa/pull/597) (@sdb9696) - Update P110\(UK\) fixture [\#596](https://github.com/python-kasa/python-kasa/pull/596) (@sdb9696) - Fix dump\_devinfo for unauthenticated [\#593](https://github.com/python-kasa/python-kasa/pull/593) (@sdb9696) - Elevate --verbose to top-level option [\#590](https://github.com/python-kasa/python-kasa/pull/590) (@rytilahti) @@ -124,6 +148,7 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co - Re-add regional suffix to TAPO/SMART fixtures [\#566](https://github.com/python-kasa/python-kasa/pull/566) (@sdb9696) - Add P110 fixture [\#562](https://github.com/python-kasa/python-kasa/pull/562) (@rytilahti) - Do not do update\(\) in discover\_single [\#542](https://github.com/python-kasa/python-kasa/pull/542) (@sdb9696) +- Add known smart requests to dump\_devinfo [\#597](https://github.com/python-kasa/python-kasa/pull/597) (@sdb9696) ## [0.5.4](https://github.com/python-kasa/python-kasa/tree/0.5.4) (2023-10-29) diff --git a/pyproject.toml b/pyproject.toml index 63b77e6f1..6bd81a900 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.6.0" +version = "0.6.0.1" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 14acc8550e2ad342602c4423141f5ccd32fc6b4a Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 22 Jan 2024 15:28:30 +0000 Subject: [PATCH 257/892] Rename base TPLinkProtocol to BaseProtocol (#669) --- kasa/__init__.py | 4 ++-- kasa/device_factory.py | 6 +++--- kasa/iotprotocol.py | 4 ++-- kasa/protocol.py | 4 ++-- kasa/smartbulb.py | 4 ++-- kasa/smartdevice.py | 6 +++--- kasa/smartdimmer.py | 4 ++-- kasa/smartlightstrip.py | 4 ++-- kasa/smartplug.py | 4 ++-- kasa/smartprotocol.py | 4 ++-- kasa/smartstrip.py | 4 ++-- kasa/tapo/tapodevice.py | 4 ++-- kasa/tapo/tapoplug.py | 4 ++-- kasa/tests/test_protocol.py | 4 ++-- 14 files changed, 30 insertions(+), 30 deletions(-) diff --git a/kasa/__init__.py b/kasa/__init__.py index 3465147aa..a8101ae3e 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -29,7 +29,7 @@ UnsupportedDeviceException, ) from kasa.iotprotocol import IotProtocol -from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol +from kasa.protocol import BaseProtocol, TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.smartdevice import DeviceType, SmartDevice from kasa.smartdimmer import SmartDimmer @@ -44,7 +44,7 @@ __all__ = [ "Discover", "TPLinkSmartHomeProtocol", - "TPLinkProtocol", + "BaseProtocol", "IotProtocol", "SmartProtocol", "SmartBulb", diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 757f0c337..83db093f4 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -9,8 +9,8 @@ from .iotprotocol import IotProtocol from .klaptransport import KlapTransport, KlapTransportV2 from .protocol import ( + BaseProtocol, BaseTransport, - TPLinkProtocol, TPLinkSmartHomeProtocol, _XorTransport, ) @@ -141,14 +141,14 @@ def get_device_class_from_family(device_type: str) -> Optional[Type[SmartDevice] def get_protocol( config: DeviceConfig, -) -> Optional[TPLinkProtocol]: +) -> Optional[BaseProtocol]: """Return the protocol from the connection name.""" protocol_name = config.connection_type.device_family.value.split(".")[0] protocol_transport_key = ( protocol_name + "." + config.connection_type.encryption_type.value ) supported_device_protocols: Dict[ - str, Tuple[Type[TPLinkProtocol], Type[BaseTransport]] + str, Tuple[Type[BaseProtocol], Type[BaseTransport]] ] = { "IOT.XOR": (TPLinkSmartHomeProtocol, _XorTransport), "IOT.KLAP": (IotProtocol, KlapTransport), diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index 9f72bbc0a..c58cc8802 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -11,12 +11,12 @@ TimeoutException, ) from .json import dumps as json_dumps -from .protocol import BaseTransport, TPLinkProtocol +from .protocol import BaseProtocol, BaseTransport _LOGGER = logging.getLogger(__name__) -class IotProtocol(TPLinkProtocol): +class IotProtocol(BaseProtocol): """Class for the legacy TPLink IOT KASA Protocol.""" BACKOFF_SECONDS_AFTER_TIMEOUT = 1 diff --git a/kasa/protocol.py b/kasa/protocol.py index 74023e017..a63250fac 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -79,7 +79,7 @@ async def close(self) -> None: """Close the transport. Abstract method to be overriden.""" -class TPLinkProtocol(ABC): +class BaseProtocol(ABC): """Base class for all TP-Link Smart Home communication.""" def __init__( @@ -140,7 +140,7 @@ async def close(self) -> None: """Close the transport. Abstract method to be overriden.""" -class TPLinkSmartHomeProtocol(TPLinkProtocol): +class TPLinkSmartHomeProtocol(BaseProtocol): """Implementation of the TP-Link Smart Home protocol.""" INITIALIZATION_VECTOR = 171 diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index 8897ceceb..5b5ae573f 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -11,7 +11,7 @@ from .deviceconfig import DeviceConfig from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage -from .protocol import TPLinkProtocol +from .protocol import BaseProtocol from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update @@ -222,7 +222,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Bulb diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 54e7c4492..08a6bfb65 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -25,7 +25,7 @@ from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException from .modules import Emeter, Module -from .protocol import TPLinkProtocol, TPLinkSmartHomeProtocol, _XorTransport +from .protocol import BaseProtocol, TPLinkSmartHomeProtocol, _XorTransport _LOGGER = logging.getLogger(__name__) @@ -196,7 +196,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: """Create a new SmartDevice instance. @@ -204,7 +204,7 @@ def __init__( """ if config and protocol: protocol._transport._config = config - self.protocol: TPLinkProtocol = protocol or TPLinkSmartHomeProtocol( + self.protocol: BaseProtocol = protocol or TPLinkSmartHomeProtocol( transport=_XorTransport(config=config or DeviceConfig(host=host)), ) _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py index ca0960f11..97738cc43 100644 --- a/kasa/smartdimmer.py +++ b/kasa/smartdimmer.py @@ -4,7 +4,7 @@ from kasa.deviceconfig import DeviceConfig from kasa.modules import AmbientLight, Motion -from kasa.protocol import TPLinkProtocol +from kasa.protocol import BaseProtocol from kasa.smartdevice import DeviceType, SmartDeviceException, requires_update from kasa.smartplug import SmartPlug @@ -70,7 +70,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Dimmer diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py index 27ebf8381..103ecfa88 100644 --- a/kasa/smartlightstrip.py +++ b/kasa/smartlightstrip.py @@ -3,7 +3,7 @@ from .deviceconfig import DeviceConfig from .effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 -from .protocol import TPLinkProtocol +from .protocol import BaseProtocol from .smartbulb import SmartBulb from .smartdevice import DeviceType, SmartDeviceException, requires_update @@ -48,7 +48,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.LightStrip diff --git a/kasa/smartplug.py b/kasa/smartplug.py index d9ac0c863..e8251b689 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -4,7 +4,7 @@ from kasa.deviceconfig import DeviceConfig from kasa.modules import Antitheft, Cloud, Schedule, Time, Usage -from kasa.protocol import TPLinkProtocol +from kasa.protocol import BaseProtocol from kasa.smartdevice import DeviceType, SmartDevice, requires_update _LOGGER = logging.getLogger(__name__) @@ -45,7 +45,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index c50c511f9..c28db948e 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -24,12 +24,12 @@ TimeoutException, ) from .json import dumps as json_dumps -from .protocol import BaseTransport, TPLinkProtocol, md5 +from .protocol import BaseProtocol, BaseTransport, md5 _LOGGER = logging.getLogger(__name__) -class SmartProtocol(TPLinkProtocol): +class SmartProtocol(BaseProtocol): """Class for the new TPLink SMART protocol.""" BACKOFF_SECONDS_AFTER_TIMEOUT = 1 diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 793931325..b1e967c45 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -16,7 +16,7 @@ from .deviceconfig import DeviceConfig from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage -from .protocol import TPLinkProtocol +from .protocol import BaseProtocol _LOGGER = logging.getLogger(__name__) @@ -87,7 +87,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self.emeter_type = "emeter" diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 8edec611c..ff8bdaea8 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -9,7 +9,7 @@ from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationException, SmartDeviceException from ..modules import Emeter -from ..protocol import TPLinkProtocol +from ..protocol import BaseProtocol from ..smartdevice import SmartDevice, WifiNetwork from ..smartprotocol import SmartProtocol @@ -24,7 +24,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: _protocol = protocol or SmartProtocol( transport=AesTransport(config=config or DeviceConfig(host=host)), diff --git a/kasa/tapo/tapoplug.py b/kasa/tapo/tapoplug.py index bb20f5cc5..1bd90fd37 100644 --- a/kasa/tapo/tapoplug.py +++ b/kasa/tapo/tapoplug.py @@ -4,7 +4,7 @@ from typing import Any, Dict, Optional, cast from ..deviceconfig import DeviceConfig -from ..protocol import TPLinkProtocol +from ..protocol import BaseProtocol from ..smartdevice import DeviceType from .tapodevice import TapoDevice @@ -19,7 +19,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 563b8176f..f623b597d 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -16,8 +16,8 @@ from ..exceptions import SmartDeviceException from ..klaptransport import KlapTransport, KlapTransportV2 from ..protocol import ( + BaseProtocol, BaseTransport, - TPLinkProtocol, TPLinkSmartHomeProtocol, _XorTransport, ) @@ -345,7 +345,7 @@ def _get_subclasses(of_class): @pytest.mark.parametrize( - "class_name_obj", _get_subclasses(TPLinkProtocol), ids=lambda t: t[0] + "class_name_obj", _get_subclasses(BaseProtocol), ids=lambda t: t[0] ) def test_protocol_init_signature(class_name_obj): params = list(inspect.signature(class_name_obj[1].__init__).parameters.values()) From 6b0a72d5a73483e9618ad52cc9aafff8bacf1619 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 22 Jan 2024 16:45:19 +0000 Subject: [PATCH 258/892] Add protocol and transport documentation (#663) * Add protocol and transport documentation * Update post review --- docs/source/design.rst | 83 ++++++++++++++++++++++++++++++++++++++++++ kasa/protocol.py | 2 +- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/docs/source/design.rst b/docs/source/design.rst index 6538c8b80..419c60569 100644 --- a/docs/source/design.rst +++ b/docs/source/design.rst @@ -1,5 +1,6 @@ .. py:module:: kasa.modules + .. _library_design: Library Design & Modules @@ -46,6 +47,7 @@ While the properties are designed to provide a nice API to use for common use ca you may sometimes want to access the raw, cached data as returned by the device. This can be done using the :attr:`~kasa.SmartDevice.internal_state` property. + .. _modules: Modules @@ -61,6 +63,42 @@ You can get the list of supported modules for a given device instance using :att If you only need some module-specific information, you can call the wanted method on the module to avoid using :meth:`~kasa.SmartDevice.update`. +Protocols and Transports +************************ + +The library supports two different TP-Link protocols, ``IOT`` and ``SMART``. +``IOT`` is the original Kasa protocol and ``SMART`` is the newer protocol supported by TAPO devices and newer KASA devices. +The original protocol has a ``target``, ``command``, ``args`` interface whereas the new protocol uses a different set of +commands and has a ``method``, ``parameters`` interface. +Confusingly TP-Link originally called the Kasa line "Kasa Smart" and hence this library used "Smart" in a lot of the +module and class names but actually they were built to work with the ``IOT`` protocol. + +In 2021 TP-Link started updating the underlying communication transport used by Kasa devices to make them more secure. +It switched from a TCP connection with static XOR type of encryption to a transport called ``KLAP`` which communicates +over http and uses handshakes to negotiate a dynamic encryption cipher. +This automatic update was put on hold and only seemed to affect UK HS100 models. + +In 2023 TP-Link started updating the underlying communication transport used by Tapo devices to make them more secure. +It switched from AES encryption via public key exchange to use ``KLAP`` encryption and negotiation due to concerns +around impersonation with AES. +The encryption cipher is the same as for Kasa KLAP but the handshake seeds are slightly different. +Also in 2023 TP-Link started releasing newer Kasa branded devices using the ``SMART`` protocol. +This appears to be driven by hardware version rather than firmware. + + +In order to support these different configurations the library migrated from a single :class:`TPLinkSmartHomeProtocol ` +to support pluggable transports and protocols. +The classes providing this functionality are: + +- :class:`BaseProtocol ` +- :class:`IotProtocol ` +- :class:`SmartProtocol ` + +- :class:`BaseTransport ` +- :class:`AesTransport ` +- :class:`KlapTransport ` +- :class:`KlapTransportV2 ` + API documentation for modules ***************************** @@ -70,3 +108,48 @@ API documentation for modules :members: :inherited-members: :undoc-members: + + + +API documentation for protocols and transports +********************************************** + +.. autoclass:: kasa.protocol.BaseProtocol + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.iotprotocol.IotProtocol + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.smartprotocol.SmartProtocol + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.protocol.BaseTransport + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.klaptransport.KlapTransport + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.klaptransport.KlapTransportV2 + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.aestransport.AesTransport + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.protocol.TPLinkSmartHomeProtocol + :members: + :inherited-members: + :undoc-members: diff --git a/kasa/protocol.py b/kasa/protocol.py index a63250fac..bbdd81fdf 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -168,7 +168,7 @@ async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: :param str host: host name or ip address of the device :param request: command to send to the device (can be either dict or - json string) + json string) :param retry_count: how many retries to do in case of failure :return: response dict """ From ee487ad837f51cea7803009dede3e91ffb9cf54f Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 22 Jan 2024 17:25:23 +0000 Subject: [PATCH 259/892] Sleep between discovery packets (#656) * Sleep between discovery packets * Add tests --- kasa/discover.py | 39 +++++++---- kasa/tests/test_discovery.py | 121 ++++++++++++++++++++++++++++++++++- 2 files changed, 147 insertions(+), 13 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index fca578a31..8b58d4bd1 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -49,6 +49,7 @@ def __init__( on_discovered: Optional[OnDiscoveredCallable] = None, target: str = "255.255.255.255", discovery_packets: int = 3, + discovery_timeout: int = 5, interface: Optional[str] = None, on_unsupported: Optional[ Callable[[UnsupportedDeviceException], Awaitable[None]] @@ -65,7 +66,8 @@ def __init__( self.port = port self.discovery_port = port or Discover.DISCOVERY_PORT - self.target = (target, self.discovery_port) + self.target = target + self.target_1 = (target, self.discovery_port) self.target_2 = (target, Discover.DISCOVERY_PORT_2) self.discovered_devices = {} @@ -75,7 +77,9 @@ def __init__( self.discovered_event = discovered_event self.credentials = credentials self.timeout = timeout + self.discovery_timeout = discovery_timeout self.seen_hosts: Set[str] = set() + self.discover_task: Optional[asyncio.Task] = None def connection_made(self, transport) -> None: """Set socket options for broadcasting.""" @@ -93,16 +97,21 @@ def connection_made(self, transport) -> None: socket.SOL_SOCKET, socket.SO_BINDTODEVICE, self.interface.encode() ) - self.do_discover() + self.discover_task = asyncio.create_task(self.do_discover()) - def do_discover(self) -> None: + async def do_discover(self) -> None: """Send number of discovery datagrams.""" req = json_dumps(Discover.DISCOVERY_QUERY) _LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY) encrypted_req = TPLinkSmartHomeProtocol.encrypt(req) - for _i in range(self.discovery_packets): - self.transport.sendto(encrypted_req[4:], self.target) # type: ignore + sleep_between_packets = self.discovery_timeout / self.discovery_packets + for i in range(self.discovery_packets): + if self.target in self.seen_hosts: # Stop sending for discover_single + break + self.transport.sendto(encrypted_req[4:], self.target_1) # type: ignore self.transport.sendto(Discover.DISCOVERY_QUERY_2, self.target_2) # type: ignore + if i < self.discovery_packets - 1: + await asyncio.sleep(sleep_between_packets) def datagram_received(self, data, addr) -> None: """Handle discovery responses.""" @@ -132,14 +141,12 @@ def datagram_received(self, data, addr) -> None: self.unsupported_device_exceptions[ip] = udex if self.on_unsupported is not None: asyncio.ensure_future(self.on_unsupported(udex)) - if self.discovered_event is not None: - self.discovered_event.set() + self._handle_discovered_event() return except SmartDeviceException as ex: _LOGGER.debug(f"[DISCOVERY] Unable to find device type for {ip}: {ex}") self.invalid_device_exceptions[ip] = ex - if self.discovered_event is not None: - self.discovered_event.set() + self._handle_discovered_event() return self.discovered_devices[ip] = device @@ -147,15 +154,23 @@ def datagram_received(self, data, addr) -> None: if self.on_discovered is not None: asyncio.ensure_future(self.on_discovered(device)) + self._handle_discovered_event() + + def _handle_discovered_event(self): + """If discovered_event is available set it and cancel discover_task.""" if self.discovered_event is not None: + if self.discover_task: + self.discover_task.cancel() self.discovered_event.set() def error_received(self, ex): """Handle asyncio.Protocol errors.""" _LOGGER.error("Got error: %s", ex) - def connection_lost(self, ex): - """NOP implementation of connection lost.""" + def connection_lost(self, ex): # pragma: no cover + """Cancel the discover task if running.""" + if self.discover_task: + self.discover_task.cancel() class Discover: @@ -260,6 +275,7 @@ async def discover( on_unsupported=on_unsupported, credentials=credentials, timeout=timeout, + discovery_timeout=discovery_timeout, port=port, ), local_addr=("0.0.0.0", 0), # noqa: S104 @@ -334,6 +350,7 @@ async def discover_single( discovered_event=event, credentials=credentials, timeout=timeout, + discovery_timeout=discovery_timeout, ), local_addr=("0.0.0.0", 0), # noqa: S104 ) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 071a65035..2916e60ad 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -1,10 +1,13 @@ # type: ignore +import asyncio import logging import re import socket +from unittest.mock import MagicMock import aiohttp import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 +from async_timeout import timeout as asyncio_timeout from kasa import ( Credentials, @@ -12,6 +15,7 @@ Discover, SmartDevice, SmartDeviceException, + TPLinkSmartHomeProtocol, protocol, ) from kasa.deviceconfig import ( @@ -198,9 +202,9 @@ async def test_discover_send(mocker): """Test discovery parameters.""" proto = _DiscoverProtocol() assert proto.discovery_packets == 3 - assert proto.target == ("255.255.255.255", 9999) + assert proto.target_1 == ("255.255.255.255", 9999) transport = mocker.patch.object(proto, "transport") - proto.do_discover() + await proto.do_discover() assert transport.sendto.call_count == proto.discovery_packets * 2 @@ -341,3 +345,116 @@ async def test_discover_http_client(discovery_mock, mocker): assert x.protocol._transport._http_client.client != http_client x.config.http_client = http_client assert x.protocol._transport._http_client.client == http_client + + +LEGACY_DISCOVER_DATA = { + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "0.0", + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS100(UK)", + "sw_ver": "1.1.0 Build 201016 Rel.175121", + "updating": 0, + } + } +} + + +class FakeDatagramTransport(asyncio.DatagramTransport): + GHOST_PORT = 8888 + + def __init__(self, dp, port, do_not_reply_count, unsupported=False): + self.dp = dp + self.port = port + self.do_not_reply_count = do_not_reply_count + self.send_count = 0 + if port == 9999: + self.datagram = TPLinkSmartHomeProtocol.encrypt( + json_dumps(LEGACY_DISCOVER_DATA) + )[4:] + elif port == 20002: + discovery_data = UNSUPPORTED if unsupported else AUTHENTICATION_DATA_KLAP + self.datagram = ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(discovery_data).encode() + ) + else: + self.datagram = {"foo": "bar"} + + def get_extra_info(self, name, default=None): + return MagicMock() + + def sendto(self, data, addr=None): + ip, port = addr + if port == self.port or self.port == self.GHOST_PORT: + self.send_count += 1 + if self.send_count > self.do_not_reply_count: + self.dp.datagram_received(self.datagram, (ip, self.port)) + + +@pytest.mark.parametrize("port", [9999, 20002]) +@pytest.mark.parametrize("do_not_reply_count", [0, 1, 2, 3, 4]) +async def test_do_discover_drop_packets(mocker, port, do_not_reply_count): + """Make sure that discover_single handles authenticating devices correctly.""" + host = "127.0.0.1" + discovery_timeout = 1 + + event = asyncio.Event() + dp = _DiscoverProtocol( + target=host, + discovery_timeout=discovery_timeout, + discovery_packets=5, + discovered_event=event, + ) + ft = FakeDatagramTransport(dp, port, do_not_reply_count) + dp.connection_made(ft) + + timed_out = False + try: + async with asyncio_timeout(discovery_timeout): + await event.wait() + except asyncio.TimeoutError: + timed_out = True + + await asyncio.sleep(0) + assert ft.send_count == do_not_reply_count + 1 + assert dp.discover_task.done() + assert timed_out is False + + +@pytest.mark.parametrize( + "port, will_timeout", + [(FakeDatagramTransport.GHOST_PORT, True), (20002, False)], + ids=["unknownport", "unsupporteddevice"], +) +async def test_do_discover_invalid(mocker, port, will_timeout): + """Make sure that discover_single handles authenticating devices correctly.""" + host = "127.0.0.1" + discovery_timeout = 1 + + event = asyncio.Event() + dp = _DiscoverProtocol( + target=host, + discovery_timeout=discovery_timeout, + discovery_packets=5, + discovered_event=event, + ) + ft = FakeDatagramTransport(dp, port, 0, unsupported=True) + dp.connection_made(ft) + + timed_out = False + try: + async with asyncio_timeout(15): + await event.wait() + except asyncio.TimeoutError: + timed_out = True + + await asyncio.sleep(0) + assert dp.discover_task.done() + assert timed_out is will_timeout From 37f522c7630ff3dfd37819970b1612708eedc754 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 19:24:08 -1000 Subject: [PATCH 260/892] Add L530E(US) fixture (#674) --- .../fixtures/smart/L530E(US)_2.0_1.1.0.json | 439 ++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json diff --git a/kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json b/kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json new file mode 100644 index 000000000..59cbf04e4 --- /dev/null +++ b/kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json @@ -0,0 +1,439 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "auto_light", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "light_effect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530E(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-62-8B-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_light_info": { + "enable": false, + "mode": "light_track" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "bulb", + "brightness": 100, + "color_temp": 2700, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 230721 Rel.224802", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "2.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "5C-62-8B-00-00-00", + "model": "L530", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -43, + "saturation": 100, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1705976485 + }, + "get_device_usage": { + "power_usage": { + "past30": 1, + "past7": 1, + "today": 1 + }, + "saved_power": { + "past30": 2, + "past7": 2, + "today": 2 + }, + "time_usage": { + "past30": 3, + "past7": 3, + "today": 3 + } + }, + "get_dynamic_light_effect_rules": { + "enable": false, + "max_count": 2, + "rule_list": [ + { + "change_mode": "direct", + "change_time": 1000, + "color_status_list": [ + [ + 100, + 0, + 0, + 2700 + ], + [ + 100, + 321, + 99, + 0 + ], + [ + 100, + 196, + 99, + 0 + ], + [ + 100, + 6, + 97, + 0 + ], + [ + 100, + 160, + 100, + 0 + ], + [ + 100, + 274, + 95, + 0 + ], + [ + 100, + 48, + 100, + 0 + ], + [ + 100, + 242, + 99, + 0 + ] + ], + "id": "L1", + "scene_name": "" + }, + { + "change_mode": "bln", + "change_time": 2000, + "color_status_list": [ + [ + 100, + 54, + 6, + 0 + ], + [ + 100, + 19, + 39, + 0 + ], + [ + 100, + 194, + 52, + 0 + ], + [ + 100, + 324, + 24, + 0 + ], + [ + 100, + 170, + 34, + 0 + ], + [ + 100, + 276, + 27, + 0 + ], + [ + 100, + 56, + 46, + 0 + ], + [ + 100, + 221, + 36, + 0 + ] + ], + "id": "L2", + "scene_name": "" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.0 Build 230721 Rel.224802", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": false + }, + "get_preset_rules": { + "states": [ + { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 277, + "saturation": 86 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 60, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 300, + "saturation": 100 + } + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L530", + "device_type": "SMART.TAPOBULB" + } + } +} From 65c47a937306538c428bbad60e24fb7c31c808f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 21:12:54 -1000 Subject: [PATCH 261/892] Update fixtures from test devices (#679) * Update fixtures from test devices * move l920 to another pr --- kasa/tests/fixtures/HS300(US)_2.0_1.0.12.json | 90 ++++ kasa/tests/fixtures/KL125(US)_4.0_1.0.5.json | 93 ++++ kasa/tests/fixtures/KL430(US)_2.0_1.0.11.json | 59 +++ kasa/tests/fixtures/KP115(US)_1.0_1.0.21.json | 42 ++ .../fixtures/smart/EP25(US)_2.6_1.0.2.json | 414 ++++++++++++++++++ .../fixtures/smart/L530E(US)_2.0_1.1.0.json | 34 +- .../fixtures/smart/P125M(US)_1.0_1.1.0.json | 22 +- 7 files changed, 726 insertions(+), 28 deletions(-) create mode 100644 kasa/tests/fixtures/HS300(US)_2.0_1.0.12.json create mode 100644 kasa/tests/fixtures/KL125(US)_4.0_1.0.5.json create mode 100644 kasa/tests/fixtures/KL430(US)_2.0_1.0.11.json create mode 100644 kasa/tests/fixtures/KP115(US)_1.0_1.0.21.json create mode 100644 kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json diff --git a/kasa/tests/fixtures/HS300(US)_2.0_1.0.12.json b/kasa/tests/fixtures/HS300(US)_2.0_1.0.12.json new file mode 100644 index 000000000..bdab432e2 --- /dev/null +++ b/kasa/tests/fixtures/HS300(US)_2.0_1.0.12.json @@ -0,0 +1,90 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 6, + "err_code": 0, + "power_mw": 277, + "slot_id": 0, + "total_wh": 62, + "voltage_mv": 120110 + } + }, + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 6, + "children": [ + { + "alias": "#MASKED_NAME#", + "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D00", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + }, + { + "alias": "#MASKED_NAME#", + "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D01", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + }, + { + "alias": "#MASKED_NAME#", + "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D02", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + }, + { + "alias": "#MASKED_NAME#", + "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D03", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + }, + { + "alias": "#MASKED_NAME#", + "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D04", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + }, + { + "alias": "#MASKED_NAME#", + "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D05", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "C0:06:C3:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS300(US)", + "oemId": "00000000000000000000000000000000", + "rssi": -44, + "status": "new", + "sw_ver": "1.0.12 Build 220121 Rel.175814", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/KL125(US)_4.0_1.0.5.json b/kasa/tests/fixtures/KL125(US)_4.0_1.0.5.json new file mode 100644 index 000000000..b098dbda1 --- /dev/null +++ b/kasa/tests/fixtures/KL125(US)_4.0_1.0.5.json @@ -0,0 +1,93 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 0, + "total_wh": 0 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "dft_on_state": { + "brightness": 84, + "color_temp": 0, + "hue": 9, + "mode": "normal", + "saturation": 67 + }, + "err_code": 0, + "on_off": 0 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Smart Wi-Fi LED Bulb with Color Changing", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "4.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "light_state": { + "dft_on_state": { + "brightness": 84, + "color_temp": 0, + "hue": 9, + "mode": "normal", + "saturation": 67 + }, + "on_off": 0 + }, + "longitude_i": 0, + "mic_mac": "5091E3000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL125(US)", + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "index": 1, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "index": 2, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "index": 3, + "saturation": 100 + } + ], + "rssi": -37, + "status": "new", + "sw_ver": "1.0.5 Build 230613 Rel.151643" + } + } +} diff --git a/kasa/tests/fixtures/KL430(US)_2.0_1.0.11.json b/kasa/tests/fixtures/KL430(US)_2.0_1.0.11.json new file mode 100644 index 000000000..cf54d6ebf --- /dev/null +++ b/kasa/tests/fixtures/KL430(US)_2.0_1.0.11.json @@ -0,0 +1,59 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 600, + "total_wh": 0 + } + }, + "system": { + "get_sysinfo": { + "LEF": 1, + "active_mode": "none", + "alias": "#MASKED_NAME#", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Kasa Smart Light Strip, Multicolor", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "length": 16, + "light_state": { + "dft_on_state": { + "brightness": 100, + "color_temp": 9000, + "hue": 9, + "mode": "normal", + "saturation": 67 + }, + "on_off": 0 + }, + "lighting_effect_state": { + "brightness": 70, + "custom": 0, + "enable": 0, + "id": "joqVjlaTsgzmuQQBAlHRkkPAqkBUiqeb", + "name": "Icicle" + }, + "longitude_i": 0, + "mic_mac": "E8:48:B8:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL430(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -43, + "status": "new", + "sw_ver": "1.0.11 Build 220812 Rel.153345" + } + } +} diff --git a/kasa/tests/fixtures/KP115(US)_1.0_1.0.21.json b/kasa/tests/fixtures/KP115(US)_1.0_1.0.21.json new file mode 100644 index 000000000..f073e7923 --- /dev/null +++ b/kasa/tests/fixtures/KP115(US)_1.0_1.0.21.json @@ -0,0 +1,42 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 0, + "total_wh": 0, + "voltage_mv": 120652 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug Mini", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "54:AF:97:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP115(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -60, + "status": "new", + "sw_ver": "1.0.21 Build 231129 Rel.171238", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json b/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json new file mode 100644 index 000000000..2d3e2e5ea --- /dev/null +++ b/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json @@ -0,0 +1,414 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "EP25(US)", + "device_type": "SMART.KASAPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 0 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.2 Build 231108 Rel.163012", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "2.6", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "EP25", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 155838, + "overcurrent_status": "normal", + "overheated": false, + "power_protection_status": "normal", + "region": "America/Chicago", + "rssi": -56, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.KASAPLUG" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1705991903 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 41789, + "past7": 8678, + "today": 38 + }, + "time_usage": { + "past30": 41789, + "past7": 8678, + "today": 38 + } + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "winter": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + "type": "constant" + }, + "get_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-01-23 00:38:23", + "month_energy": 0, + "month_runtime": 31709, + "today_energy": 0, + "today_runtime": 38 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.2 Build 231108 Rel.163012", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 436, + "night_mode_type": "sunrise_sunset", + "start_time": 1072, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_max_power": { + "max_power": 1885 + }, + "get_next_event": {}, + "get_protection_power": { + "enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "EP25", + "device_type": "SMART.KASAPLUG", + "is_klap": false + } + } +} diff --git a/kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json b/kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json index 59cbf04e4..6dac10489 100644 --- a/kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json +++ b/kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json @@ -145,7 +145,7 @@ "get_device_info": { "avatar": "bulb", "brightness": 100, - "color_temp": 2700, + "color_temp": 0, "color_temp_range": [ 2500, 6500 @@ -154,19 +154,19 @@ "re_power_type": "always_on", "state": { "brightness": 100, - "color_temp": 2700, - "hue": 0, - "saturation": 100 + "color_temp": 0, + "hue": 12, + "saturation": 45 }, "type": "last_states" }, "device_id": "0000000000000000000000000000000000000000", - "device_on": true, + "device_on": false, "dynamic_light_effect_enable": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.1.0 Build 230721 Rel.224802", "has_set_location_info": true, - "hue": 0, + "hue": 12, "hw_id": "00000000000000000000000000000000", "hw_ver": "2.0", "ip": "127.0.0.123", @@ -179,8 +179,8 @@ "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Pacific/Honolulu", - "rssi": -43, - "saturation": 100, + "rssi": -41, + "saturation": 45, "signal_level": 3, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", @@ -190,23 +190,23 @@ "get_device_time": { "region": "Pacific/Honolulu", "time_diff": -600, - "timestamp": 1705976485 + "timestamp": 1705991895 }, "get_device_usage": { "power_usage": { - "past30": 1, - "past7": 1, - "today": 1 - }, - "saved_power": { "past30": 2, "past7": 2, "today": 2 }, + "saved_power": { + "past30": 8, + "past7": 8, + "today": 8 + }, "time_usage": { - "past30": 3, - "past7": 3, - "today": 3 + "past30": 10, + "past7": 10, + "today": 10 } }, "get_dynamic_light_effect_rules": { diff --git a/kasa/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json b/kasa/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json index 812cd1ea1..78e876d73 100644 --- a/kasa/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json +++ b/kasa/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json @@ -86,7 +86,7 @@ "factory_default": false, "ip": "127.0.0.123", "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", + "mac": "48-22-54-00-00-00", "mgt_encrypt_schm": { "encrypt_type": "KLAP", "http_port": 80, @@ -130,21 +130,21 @@ "device_on": true, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.1.0 Build 231009 Rel.155831", - "has_set_location_info": false, + "has_set_location_info": true, "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", "ip": "127.0.0.123", "lang": "en_US", "latitude": 0, "longitude": 0, - "mac": "00-00-00-00-00-00", + "mac": "48-22-54-00-00-00", "model": "P125M", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", - "on_time": 76, + "on_time": 189479, "overheated": false, "region": "Pacific/Honolulu", - "rssi": -49, + "rssi": -43, "signal_level": 3, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", @@ -154,13 +154,13 @@ "get_device_time": { "region": "Pacific/Honolulu", "time_diff": -600, - "timestamp": 1704406945 + "timestamp": 1705991899 }, "get_device_usage": { "time_usage": { - "past30": 16892, - "past7": 4, - "today": 4 + "past30": 3163, + "past7": 3163, + "today": 1238 } }, "get_fw_download_state": { @@ -184,9 +184,9 @@ "led_rule": "always", "led_status": true, "night_mode": { - "end_time": 420, + "end_time": 427, "night_mode_type": "sunrise_sunset", - "start_time": 1140, + "start_time": 1092, "sunrise_offset": 0, "sunset_offset": 0 } From abd3ee0768b9fb2ea5057d4681ecbad2d26e00a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 21:31:19 -1000 Subject: [PATCH 262/892] Add P135 fixture (#673) * Add P135 fixture This device is a dimmer but we currently treat it as a on/off * add to conftest --- kasa/tests/conftest.py | 4 +- .../fixtures/smart/P135(US)_1.0_1.0.5.json | 317 ++++++++++++++++++ 2 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/smart/P135(US)_1.0_1.0.5.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 4aae40356..205715499 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -98,7 +98,9 @@ "KP401", "KS200M", } -PLUGS_SMART = {"P110", "KP125M", "EP25", "KS205", "P125M"} +# P135 supports dimming, but its not currently support +# by the library +PLUGS_SMART = {"P110", "KP125M", "EP25", "KS205", "P125M", "P135"} PLUGS = { *PLUGS_IOT, *PLUGS_SMART, diff --git a/kasa/tests/fixtures/smart/P135(US)_1.0_1.0.5.json b/kasa/tests/fixtures/smart/P135(US)_1.0_1.0.5.json new file mode 100644 index 000000000..9f6c3b034 --- /dev/null +++ b/kasa/tests/fixtures/smart/P135(US)_1.0_1.0.5.json @@ -0,0 +1,317 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P135(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "plug", + "brightness": 100, + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 230801 Rel.095702", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "model": "P135", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "Pacific/Honolulu", + "rssi": -43, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1705975451 + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.5 Build 230801 Rel.095702", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "always", + "led_status": false, + "night_mode": { + "end_time": 427, + "night_mode_type": "sunrise_sunset", + "start_time": 1092, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_next_event": {}, + "get_preset_rules": { + "brightness": [ + 100, + 75, + 50, + 25, + 1 + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P135", + "device_type": "SMART.TAPOPLUG" + } + } +} From d5fdf05ed264adf76d51723fe33263a6a9ba8694 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Jan 2024 00:12:24 -1000 Subject: [PATCH 263/892] Add P100 test fixture (#683) --- kasa/tests/conftest.py | 2 +- .../fixtures/smart/P100_1.0.0_1.3.7.json | 197 ++++++++++++++++++ 2 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/smart/P100_1.0.0_1.3.7.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 205715499..12f9c2769 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -100,7 +100,7 @@ } # P135 supports dimming, but its not currently support # by the library -PLUGS_SMART = {"P110", "KP125M", "EP25", "KS205", "P125M", "P135"} +PLUGS_SMART = {"P100", "P110", "KP125M", "EP25", "KS205", "P125M", "P135"} PLUGS = { *PLUGS_IOT, *PLUGS_SMART, diff --git a/kasa/tests/fixtures/smart/P100_1.0.0_1.3.7.json b/kasa/tests/fixtures/smart/P100_1.0.0_1.3.7.json new file mode 100644 index 000000000..cdddc72e0 --- /dev/null +++ b/kasa/tests/fixtures/smart/P100_1.0.0_1.3.7.json @@ -0,0 +1,197 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P100", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "CC-32-E5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "plug", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.3.7 Build 20230711 Rel. 61904", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "location": "bedroom", + "longitude": 0, + "mac": "CC-32-E5-00-00-00", + "model": "P100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -48, + "signal_level": 3, + "specs": "US", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1705995478 + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "get_fw_download_state": { + "download_progress": 0, + "reboot_time": 10, + "status": 0, + "upgrade_time": 0 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.3.7 Build 20230711 Rel. 61904", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "always", + "led_status": false + }, + "get_next_event": { + "action": -1, + "e_time": 0, + "id": "0", + "s_time": 0, + "type": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 20, + "start_index": 0, + "sum": 0 + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "P100", + "device_type": "SMART.TAPOPLUG" + } + } +} From c1f2f8fe670a466bee053fe7967761ecda5aff77 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 23 Jan 2024 11:14:59 +0100 Subject: [PATCH 264/892] Check README for supported models (#684) * Check README for supported models * Use poetry for running due to imports * Update README --- .github/workflows/ci.yml | 4 ++++ README.md | 36 +++++++++++++++++++--------- devtools/check_readme_vs_fixtures.py | 8 +++++++ 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dcd091eb..761ed8baa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,10 @@ jobs: - name: "Run check-ast" run: | poetry run pre-commit run check-ast --all-files + - name: "Check README for supported models" + run: | + poetry run python -m devtools.check_readme_vs_fixtures + tests: name: Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} diff --git a/README.md b/README.md index fdc9a4b83..eeb5e7e2a 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ Note, that this works currently only on kasa-branded devices which use port 9999 In principle, most kasa-branded devices that are locally controllable using the official Kasa mobile app work with this library. The following lists the devices that have been manually verified to work. -**If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `devtools/dump_devinfo.py` to generate one).** +**If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `python -m devtools.dump_devinfo` to generate one).** ### Plugs @@ -228,10 +228,10 @@ The following lists the devices that have been manually verified to work. * KP105 * KP115 * KP125 -* KP125M [See note below](#tapo-and-newer-kasa-branded-devices) +* KP125M [See note below](#newer-kasa-branded-devices) * KP401 * EP10 -* EP25 [See note below](#tapo-and-newer-kasa-branded-devices) +* EP25 [See note below](#newer-kasa-branded-devices) ### Power Strips @@ -273,18 +273,29 @@ The following lists the devices that have been manually verified to work. * KL420L5 * KL430 -### Tapo and newer Kasa branded devices +### Tapo branded devices The library has recently added a limited supported for devices that carry Tapo branding. At the moment, the following devices have been confirmed to work: -* Tapo P110 (plug) -* Tapo L530E (bulb) -* Tapo L900-5 (led strip) -* Tapo L900-10 (led strip) -* Kasa KS205 (Wifi/Matter Wall Switch) -* Kasa KS225 (Wifi/Matter Wall Dimmer Switch) +#### Plugs + +* Tapo P110 +* Tapo P135 (dimming not yet supported) + +#### Bulbs + +* Tapo L510B +* Tapo L530E + +#### Light strips + +* Tapo L900-5 +* Tapo L900-10 +* Tapo L920-5 + +### Newer Kasa branded devices Some newer hardware versions of Kasa branded devices are now using the same protocol as Tapo branded devices. Support for these devices is currently limited as per TAPO branded @@ -292,8 +303,11 @@ devices: * Kasa EP25 (plug) hw_version 2.6 * Kasa KP125M (plug) +* Kasa KS205 (Wifi/Matter Wall Switch) +* Kasa KS225 (Wifi/Matter Wall Dimmer Switch) + -**If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `devtools/dump_devinfo.py` to generate one).** +**If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `python -m devtools.dump_devinfo` to generate one).** ## Resources diff --git a/devtools/check_readme_vs_fixtures.py b/devtools/check_readme_vs_fixtures.py index 1f55eea87..f7a2f2c39 100644 --- a/devtools/check_readme_vs_fixtures.py +++ b/devtools/check_readme_vs_fixtures.py @@ -1,4 +1,6 @@ """Script that checks if README.md is missing devices that have fixtures.""" +import sys + from kasa.tests.conftest import ( ALL_DEVICES, BULBS, @@ -28,6 +30,12 @@ def _get_device_type(dev, typemap): return "Unknown type" +found_unlisted = False for dev in ALL_DEVICES: if dev not in readme: print(f"{dev} not listed in {_get_device_type(dev, typemap)}") + found_unlisted = True + + +if found_unlisted: + sys.exit(-1) From 1db955b05ee10d60b80a65649cdba84f058e04a7 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 23 Jan 2024 10:33:07 +0000 Subject: [PATCH 265/892] Make request batch size configurable and avoid multiRequest if 1 (#681) --- devtools/dump_devinfo.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 85ad01502..6a8240ef6 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -15,7 +15,7 @@ from collections import defaultdict, namedtuple from pathlib import Path from pprint import pprint -from typing import Dict, List +from typing import Dict, List, Union import asyncclick as click @@ -106,10 +106,10 @@ def default_to_regular(d): return d -async def handle_device(basedir, autosave, device: SmartDevice): +async def handle_device(basedir, autosave, device: SmartDevice, batch_size: int): """Create a fixture for a single device instance.""" if isinstance(device, TapoDevice): - filename, copy_folder, final = await get_smart_fixture(device) + filename, copy_folder, final = await get_smart_fixture(device, batch_size) else: filename, copy_folder, final = await get_legacy_fixture(device) @@ -156,8 +156,11 @@ async def handle_device(basedir, autosave, device: SmartDevice): ) @click.option("--basedir", help="Base directory for the git repository", default=".") @click.option("--autosave", is_flag=True, default=False, help="Save without prompting") +@click.option( + "--batch-size", default=5, help="Number of batched requests to send at once" +) @click.option("-d", "--debug", is_flag=True) -async def cli(host, target, basedir, autosave, debug, username, password): +async def cli(host, target, basedir, autosave, debug, username, password, batch_size): """Generate devinfo files for devices. Use --host (for a single device) or --target (for a complete network). @@ -169,7 +172,7 @@ async def cli(host, target, basedir, autosave, debug, username, password): if host is not None: click.echo("Host given, performing discovery on %s." % host) device = await Discover.discover_single(host, credentials=credentials) - await handle_device(basedir, autosave, device) + await handle_device(basedir, autosave, device, batch_size) else: click.echo( "No --host given, performing discovery on %s. Use --target to override." @@ -178,7 +181,7 @@ async def cli(host, target, basedir, autosave, debug, username, password): devices = await Discover.discover(target=target, credentials=credentials) click.echo("Detected %s devices" % len(devices)) for dev in devices.values(): - await handle_device(basedir, autosave, dev) + await handle_device(basedir, autosave, dev, batch_size) async def get_legacy_fixture(device): @@ -252,17 +255,23 @@ async def get_legacy_fixture(device): async def _make_requests_or_exit( - device: SmartDevice, requests: List[SmartRequest], name: str + device: SmartDevice, + requests: List[SmartRequest], + name: str, + batch_size: int, ) -> Dict[str, Dict]: final = {} try: end = len(requests) - step = 5 # Break the requests down as there seems to be a size limit + step = batch_size # Break the requests down as there seems to be a size limit for i in range(0, end, step): x = i requests_step = requests[x : x + step] + request: Union[List[SmartRequest], SmartRequest] = ( + requests_step[0] if len(requests_step) == 1 else requests_step + ) responses = await device.protocol.query( - SmartRequest._create_request_dict(requests_step) + SmartRequest._create_request_dict(request) ) for method, result in responses.items(): final[method] = result @@ -283,7 +292,7 @@ async def _make_requests_or_exit( exit(1) -async def get_smart_fixture(device: TapoDevice): +async def get_smart_fixture(device: TapoDevice, batch_size: int): """Get fixture for new TAPO style protocol.""" extra_test_calls = [ SmartCall( @@ -314,7 +323,7 @@ async def get_smart_fixture(device: TapoDevice): click.echo("Testing component_nego call ..", nl=False) responses = await _make_requests_or_exit( - device, [SmartRequest.component_nego()], "component_nego call" + device, [SmartRequest.component_nego()], "component_nego call", batch_size ) component_info_response = responses["component_nego"] click.echo(click.style("OK", fg="green")) @@ -383,7 +392,9 @@ async def get_smart_fixture(device: TapoDevice): for succ in successes: requests.append(succ.request) - final = await _make_requests_or_exit(device, requests, "all successes at once") + final = await _make_requests_or_exit( + device, requests, "all successes at once", batch_size + ) # Need to recreate a DiscoverResult here because we don't want the aliases # in the fixture, we want the actual field names as returned by the device. From cfbdf7c64adb06ee74eaf14c4f35c4bf1824dfb2 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 23 Jan 2024 13:24:17 +0100 Subject: [PATCH 266/892] Show discovery data for state with verbose (#678) * Show discovery data for state with verbose * Remove duplicate discovery printout on discovery, add a newline for readability --- kasa/cli.py | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 282273162..d1cb72765 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -390,7 +390,6 @@ async def discover(ctx): target = ctx.parent.params["target"] username = ctx.parent.params["username"] password = ctx.parent.params["password"] - verbose = ctx.parent.params["verbose"] discovery_timeout = ctx.parent.params["discovery_timeout"] timeout = ctx.parent.params["timeout"] port = ctx.parent.params["port"] @@ -429,9 +428,6 @@ async def print_discovered(dev: SmartDevice): discovered[dev.host] = dev.internal_state ctx.parent.obj = dev await ctx.parent.invoke(state) - if verbose: - echo() - _echo_discovery_info(dev._discovery_info) echo() await Discover.discover( @@ -473,21 +469,20 @@ def _echo_discovery_info(discovery_info): return echo("\t[bold]== Discovery Result ==[/bold]") - echo(f"\tDevice Type: {dr.device_type}") - echo(f"\tDevice Model: {dr.device_model}") - echo(f"\tIP: {dr.ip}") - echo(f"\tMAC: {dr.mac}") - echo(f"\tDevice Id (hash): {dr.device_id}") - echo(f"\tOwner (hash): {dr.owner}") - echo(f"\tHW Ver: {dr.hw_ver}") - echo(f"\tIs Support IOT Cloud: {dr.is_support_iot_cloud})") - echo(f"\tOBD Src: {dr.obd_src}") - echo(f"\tFactory Default: {dr.factory_default}") - echo("\t\t== Encryption Scheme ==") - echo(f"\t\tEncrypt Type: {dr.mgt_encrypt_schm.encrypt_type}") - echo(f"\t\tIs Support HTTPS: {dr.mgt_encrypt_schm.is_support_https}") - echo(f"\t\tHTTP Port: {dr.mgt_encrypt_schm.http_port}") - echo(f"\t\tLV (Login Level): {dr.mgt_encrypt_schm.lv}") + echo(f"\tDevice Type: {dr.device_type}") + echo(f"\tDevice Model: {dr.device_model}") + echo(f"\tIP: {dr.ip}") + echo(f"\tMAC: {dr.mac}") + echo(f"\tDevice Id (hash): {dr.device_id}") + echo(f"\tOwner (hash): {dr.owner}") + echo(f"\tHW Ver: {dr.hw_ver}") + echo(f"\tSupports IOT Cloud: {dr.is_support_iot_cloud}") + echo(f"\tOBD Src: {dr.obd_src}") + echo(f"\tFactory Default: {dr.factory_default}") + echo(f"\tEncrypt Type: {dr.mgt_encrypt_schm.encrypt_type}") + echo(f"\tSupports HTTPS: {dr.mgt_encrypt_schm.is_support_https}") + echo(f"\tHTTP Port: {dr.mgt_encrypt_schm.http_port}") + echo(f"\tLV (Login Level): {dr.mgt_encrypt_schm.lv}") async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3): @@ -562,6 +557,8 @@ async def state(ctx, dev: SmartDevice): echo(f"\tDevice ID: {dev.device_id}") for feature in dev.features: echo(f"\tFeature: {feature}") + echo() + _echo_discovery_info(dev._discovery_info) return dev.internal_state From c8ac3a29c771546ec38202516ca40902e5f87455 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 23 Jan 2024 14:26:47 +0100 Subject: [PATCH 267/892] Add reboot and factory_reset to tapodevice (#686) * Add reboot and factory_reset to tapodevice * Add test for reboot command * Fix mocking as different protocols use different methods for comms.. --- kasa/tapo/tapodevice.py | 15 +++++++++++++++ kasa/tests/test_cli.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index ff8bdaea8..156a61d1a 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -339,3 +339,18 @@ async def update_credentials(self, username: str, password: str): "time": t, } return await self.protocol.query({"set_qs_info": payload}) + + async def reboot(self, delay: int = 1) -> None: + """Reboot the device. + + Note that giving a delay of zero causes this to block, + as the device reboots immediately without responding to the call. + """ + await self.protocol.query({"device_reboot": {"delay": delay}}) + + async def factory_reset(self) -> None: + """Reset device back to factory settings. + + Note, this does not downgrade the firmware. + """ + await self.protocol.query("device_reset") diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index b1db15e19..3aad37dda 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -21,6 +21,7 @@ cli, emeter, raw_command, + reboot, state, sysinfo, toggle, @@ -103,6 +104,21 @@ async def test_raw_command(dev): assert "Usage" in res.output +@device_smart +async def test_reboot(dev, mocker): + """Test that reboot works on SMART devices.""" + runner = CliRunner() + query_mock = mocker.patch.object(dev.protocol, "query") + + res = await runner.invoke( + reboot, + obj=dev, + ) + + query_mock.assert_called() + assert res.exit_code == 0 + + @device_smart async def test_wifi_scan(dev): runner = CliRunner() From 718983c401ecb180641124bed3a2a78e6caa72dc Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 23 Jan 2024 14:44:32 +0000 Subject: [PATCH 268/892] Try default tapo credentials for klap and aes (#685) * Try default tapo credentials for klap and aes * Add tests --- kasa/aestransport.py | 49 ++++++++++++++++++++------- kasa/klaptransport.py | 45 ++++++++++++------------- kasa/protocol.py | 16 ++++++++- kasa/tests/test_aestransport.py | 60 ++++++++++++++++++++++++++++++++- kasa/tests/test_klapprotocol.py | 6 ++-- 5 files changed, 134 insertions(+), 42 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 65b0045df..cd810b8ff 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -30,7 +30,7 @@ from .httpclient import HttpClient from .json import dumps as json_dumps from .json import loads as json_loads -from .protocol import BaseTransport +from .protocol import DEFAULT_CREDENTIALS, BaseTransport, get_default_credentials _LOGGER = logging.getLogger(__name__) @@ -69,12 +69,12 @@ def __init__( ) and not self._credentials_hash: self._credentials = Credentials() if self._credentials: - self._login_params = self._get_login_params() + self._login_params = self._get_login_params(self._credentials) else: self._login_params = json_loads( base64.b64decode(self._credentials_hash.encode()).decode() # type: ignore[union-attr] ) - + self._default_credentials: Optional[Credentials] = None self._http_client: HttpClient = HttpClient(config) self._handshake_done = False @@ -98,26 +98,27 @@ def credentials_hash(self) -> str: """The hashed credentials used by the transport.""" return base64.b64encode(json_dumps(self._login_params).encode()).decode() - def _get_login_params(self): + def _get_login_params(self, credentials): """Get the login parameters based on the login_version.""" - un, pw = self.hash_credentials(self._login_version == 2) + un, pw = self.hash_credentials(self._login_version == 2, credentials) password_field_name = "password2" if self._login_version == 2 else "password" return {password_field_name: pw, "username": un} - def hash_credentials(self, login_v2): + @staticmethod + def hash_credentials(login_v2, credentials): """Hash the credentials.""" if login_v2: un = base64.b64encode( - _sha1(self._credentials.username.encode()).encode() + _sha1(credentials.username.encode()).encode() ).decode() pw = base64.b64encode( - _sha1(self._credentials.password.encode()).encode() + _sha1(credentials.password.encode()).encode() ).decode() else: un = base64.b64encode( - _sha1(self._credentials.username.encode()).encode() + _sha1(credentials.username.encode()).encode() ).decode() - pw = base64.b64encode(self._credentials.password.encode()).decode() + pw = base64.b64encode(credentials.password.encode()).decode() return un, pw def _handle_response_error_code(self, resp_dict: dict, msg: str): @@ -173,10 +174,28 @@ async def send_secure_passthrough(self, request: str): async def perform_login(self): """Login to the device.""" + try: + await self.try_login(self._login_params) + except AuthenticationException as ex: + if ex.error_code != SmartErrorCode.LOGIN_ERROR: + raise ex + if self._default_credentials is None: + self._default_credentials = get_default_credentials( + DEFAULT_CREDENTIALS["TAPO"] + ) + await self.perform_handshake() + await self.try_login(self._get_login_params(self._default_credentials)) + _LOGGER.debug( + "%s: logged in with default credentials", + self._host, + ) + + async def try_login(self, login_params): + """Try to login with supplied login_params.""" self._login_token = None login_request = { "method": "login_device", - "params": self._login_params, + "params": login_params, "request_time_milis": round(time.time() * 1000), } request = json_dumps(login_request) @@ -260,7 +279,13 @@ async def send(self, request: str): if not self._handshake_done or self._handshake_session_expired(): await self.perform_handshake() if not self._login_token: - await self.perform_login() + try: + await self.perform_login() + # After a login failure handshake needs to + # be redone or a 9999 error is received. + except AuthenticationException as ex: + self._handshake_done = False + raise ex return await self.send_secure_passthrough(request) diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 92d6fd2b3..5411314a3 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -58,7 +58,7 @@ from .exceptions import AuthenticationException, SmartDeviceException from .httpclient import HttpClient from .json import loads as json_loads -from .protocol import BaseTransport, md5 +from .protocol import DEFAULT_CREDENTIALS, BaseTransport, get_default_credentials, md5 _LOGGER = logging.getLogger(__name__) @@ -85,9 +85,6 @@ class KlapTransport(BaseTransport): DEFAULT_PORT: int = 80 DISCOVERY_QUERY = {"system": {"get_sysinfo": None}} - - KASA_SETUP_EMAIL = "kasa@tp-link.net" - KASA_SETUP_PASSWORD = "kasaSetup" # noqa: S105 SESSION_COOKIE_NAME = "TP_SESSIONID" def __init__( @@ -108,7 +105,7 @@ def __init__( self._local_auth_owner = self.generate_owner_hash(self._credentials).hex() else: self._local_auth_hash = base64.b64decode(self._credentials_hash.encode()) # type: ignore[union-attr] - self._kasa_setup_auth_hash = None + self._default_credentials_auth_hash: Dict[str, bytes] = {} self._blank_auth_hash = None self._handshake_lock = asyncio.Lock() self._query_lock = asyncio.Lock() @@ -183,27 +180,27 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: _LOGGER.debug("handshake1 hashes match with expected credentials") return local_seed, remote_seed, self._local_auth_hash # type: ignore - # Now check against the default kasa setup credentials - if not self._kasa_setup_auth_hash: - kasa_setup_creds = Credentials( - username=self.KASA_SETUP_EMAIL, - password=self.KASA_SETUP_PASSWORD, - ) - self._kasa_setup_auth_hash = self.generate_auth_hash(kasa_setup_creds) - - kasa_setup_seed_auth_hash = self.handshake1_seed_auth_hash( - local_seed, - remote_seed, - self._kasa_setup_auth_hash, # type: ignore - ) + # Now check against the default setup credentials + for key, value in DEFAULT_CREDENTIALS.items(): + if key not in self._default_credentials_auth_hash: + default_credentials = get_default_credentials(value) + self._default_credentials_auth_hash[key] = self.generate_auth_hash( + default_credentials + ) - if kasa_setup_seed_auth_hash == server_hash: - _LOGGER.debug( - "Server response doesn't match our expected hash on ip %s" - + " but an authentication with kasa setup credentials matched", - self._host, + default_credentials_seed_auth_hash = self.handshake1_seed_auth_hash( + local_seed, + remote_seed, + self._default_credentials_auth_hash[key], # type: ignore ) - return local_seed, remote_seed, self._kasa_setup_auth_hash # type: ignore + + if default_credentials_seed_auth_hash == server_hash: + _LOGGER.debug( + "Server response doesn't match our expected hash on ip %s" + + f" but an authentication with {key} default credentials matched", + self._host, + ) + return local_seed, remote_seed, self._default_credentials_auth_hash[key] # type: ignore # Finally check against blank credentials if not already blank blank_creds = Credentials() diff --git a/kasa/protocol.py b/kasa/protocol.py index bbdd81fdf..59fea4a84 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -10,6 +10,7 @@ http://www.apache.org/licenses/LICENSE-2.0 """ import asyncio +import base64 import contextlib import errno import logging @@ -17,13 +18,14 @@ import struct from abc import ABC, abstractmethod from pprint import pformat as pf -from typing import Dict, Generator, Optional, Union +from typing import Dict, Generator, Optional, Tuple, Union # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout from async_timeout import timeout as asyncio_timeout from cryptography.hazmat.primitives import hashes +from .credentials import Credentials from .deviceconfig import DeviceConfig from .exceptions import SmartDeviceException from .json import dumps as json_dumps @@ -361,6 +363,18 @@ def decrypt(ciphertext: bytes) -> str: ).decode() +def get_default_credentials(tuple: Tuple[str, str]) -> Credentials: + """Return decoded default credentials.""" + un = base64.b64decode(tuple[0].encode()).decode() + pw = base64.b64decode(tuple[1].encode()).decode() + return Credentials(un, pw) + + +DEFAULT_CREDENTIALS = { + "KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"), + "TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="), +} + # Try to load the kasa_crypt module and if it is available try: from kasa_crypt import decrypt, encrypt diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 774aaf943..748dae9ae 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -16,6 +16,7 @@ from ..exceptions import ( SMART_RETRYABLE_ERRORS, SMART_TIMEOUT_ERRORS, + AuthenticationException, SmartDeviceException, SmartErrorCode, ) @@ -91,6 +92,53 @@ async def test_login(mocker, status_code, error_code, inner_error_code, expectat assert transport._login_token == mock_aes_device.token +@pytest.mark.parametrize( + "inner_error_codes, expectation, call_count", + [ + ([SmartErrorCode.LOGIN_ERROR, 0, 0, 0], does_not_raise(), 4), + ( + [SmartErrorCode.LOGIN_ERROR, SmartErrorCode.LOGIN_ERROR], + pytest.raises(AuthenticationException), + 3, + ), + ( + [SmartErrorCode.LOGIN_FAILED_ERROR], + pytest.raises(AuthenticationException), + 1, + ), + ], + ids=("LOGIN_ERROR-success", "LOGIN_ERROR-LOGIN_ERROR", "LOGIN_FAILED_ERROR"), +) +async def test_login_errors(mocker, inner_error_codes, expectation, call_count): + host = "127.0.0.1" + mock_aes_device = MockAesDevice(host, 200, 0, inner_error_codes) + post_mock = mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_aes_device.post + ) + + transport = AesTransport( + config=DeviceConfig(host, credentials=Credentials("foo", "bar")) + ) + transport._handshake_done = True + transport._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + + assert transport._login_token is None + + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + + with expectation: + await transport.send(json_dumps(request)) + assert transport._login_token == mock_aes_device.token + assert post_mock.call_count == call_count # Login, Handshake, Login + + @status_parameters async def test_send(mocker, status_code, error_code, inner_error_code, expectation): host = "127.0.0.1" @@ -166,8 +214,16 @@ def __init__(self, host, status_code=200, error_code=0, inner_error_code=0): self.host = host self.status_code = status_code self.error_code = error_code - self.inner_error_code = inner_error_code + self._inner_error_code = inner_error_code self.http_client = HttpClient(DeviceConfig(self.host)) + self.inner_call_count = 0 + + @property + def inner_error_code(self): + if isinstance(self._inner_error_code, list): + return self._inner_error_code[self.inner_call_count] + else: + return self._inner_error_code async def post(self, url, params=None, json=None, *_, **__): return await self._post(url, json) @@ -215,8 +271,10 @@ async def _return_secure_passthrough_response(self, url, json): async def _return_login_response(self, url, json): result = {"result": {"token": self.token}, "error_code": self.inner_error_code} + self.inner_call_count += 1 return self._mock_response(self.status_code, result) async def _return_send_response(self, url, json): result = {"result": {"method": None}, "error_code": self.inner_error_code} + self.inner_call_count += 1 return self._mock_response(self.status_code, result) diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 8ae32e3f7..54f4a4bed 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -28,6 +28,7 @@ KlapTransportV2, _sha256, ) +from ..protocol import DEFAULT_CREDENTIALS, get_default_credentials from ..smartprotocol import SmartProtocol DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} @@ -241,10 +242,7 @@ def test_encrypt_unicode(): (Credentials("foo", "bar"), does_not_raise()), (Credentials(), does_not_raise()), ( - Credentials( - KlapTransport.KASA_SETUP_EMAIL, - KlapTransport.KASA_SETUP_PASSWORD, - ), + get_default_credentials(DEFAULT_CREDENTIALS["KASA"]), does_not_raise(), ), ( From e233e377ad8748dc5a4ec8d706ad5f70209825f5 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 23 Jan 2024 15:29:27 +0000 Subject: [PATCH 269/892] Generate AES KeyPair lazily (#687) * Generate AES KeyPair lazily * Fix coverage * Update post-review * Fix pragma * Make json dumps consistent between python and orjson * Add comment * Add comments re json parameter in HttpClient --- kasa/aestransport.py | 52 +++++++++++++++++++++------------ kasa/httpclient.py | 17 +++++++++-- kasa/json.py | 6 +++- kasa/tests/test_aestransport.py | 5 +++- 4 files changed, 57 insertions(+), 23 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index cd810b8ff..14a9ee6a1 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -8,7 +8,7 @@ import hashlib import logging import time -from typing import Dict, Optional, cast +from typing import TYPE_CHECKING, AsyncGenerator, Dict, Optional, cast from cryptography.hazmat.primitives import padding, serialization from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding @@ -55,6 +55,8 @@ class AesTransport(BaseTransport): "requestByApp": "true", "Accept": "application/json", } + CONTENT_LENGTH = "Content-Length" + KEY_PAIR_CONTENT_LENGTH = 314 def __init__( self, @@ -86,6 +88,8 @@ def __init__( self._login_token = None + self._key_pair: Optional[KeyPair] = None + _LOGGER.debug("Created AES transport for %s", self._host) @property @@ -204,34 +208,44 @@ async def try_login(self, login_params): self._handle_response_error_code(resp_dict, "Error logging in") self._login_token = resp_dict["result"]["token"] - async def perform_handshake(self): - """Perform the handshake.""" - _LOGGER.debug("Will perform handshaking...") - _LOGGER.debug("Generating keypair") - - self._handshake_done = False - self._session_expire_at = None - self._session_cookie = None - - url = f"http://{self._host}/app" - key_pair = KeyPair.create_key_pair() + async def _generate_key_pair_payload(self) -> AsyncGenerator: + """Generate the request body and return an ascyn_generator. + This prevents the key pair being generated unless a connection + can be made to the device. + """ + _LOGGER.debug("Generating keypair") + self._key_pair = KeyPair.create_key_pair() pub_key = ( "-----BEGIN PUBLIC KEY-----\n" - + key_pair.get_public_key() + + self._key_pair.get_public_key() # type: ignore[union-attr] + "\n-----END PUBLIC KEY-----\n" ) handshake_params = {"key": pub_key} _LOGGER.debug(f"Handshake params: {handshake_params}") - request_body = {"method": "handshake", "params": handshake_params} - _LOGGER.debug(f"Request {request_body}") + yield json_dumps(request_body).encode() + async def perform_handshake(self): + """Perform the handshake.""" + _LOGGER.debug("Will perform handshaking...") + + self._key_pair = None + self._handshake_done = False + self._session_expire_at = None + self._session_cookie = None + + url = f"http://{self._host}/app" + # Device needs the content length or it will response with 500 + headers = { + **self.COMMON_HEADERS, + self.CONTENT_LENGTH: str(self.KEY_PAIR_CONTENT_LENGTH), + } status_code, resp_dict = await self._http_client.post( url, - json=request_body, - headers=self.COMMON_HEADERS, + json=self._generate_key_pair_payload(), + headers=headers, cookies_dict=self._session_cookie, ) @@ -259,8 +273,10 @@ async def perform_handshake(self): self._session_cookie = {self.SESSION_COOKIE_NAME: cookie} self._session_expire_at = time.time() + 86400 + if TYPE_CHECKING: + assert self._key_pair is not None # pragma: no cover self._encryption_session = AesEncyptionSession.create_from_keypair( - handshake_key, key_pair + handshake_key, self._key_pair ) self._handshake_done = True diff --git a/kasa/httpclient.py b/kasa/httpclient.py index a4bd84a33..28a19e8bd 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -41,14 +41,25 @@ async def post( *, params: Optional[Dict[str, Any]] = None, data: Optional[bytes] = None, - json: Optional[Dict] = None, + json: Optional[Union[Dict, Any]] = None, headers: Optional[Dict[str, str]] = None, cookies_dict: Optional[Dict[str, str]] = None, ) -> Tuple[int, Optional[Union[Dict, bytes]]]: - """Send an http post request to the device.""" + """Send an http post request to the device. + + If the request is provided via the json parameter json will be returned. + """ response_data = None self._last_url = url self.client.cookie_jar.clear() + return_json = bool(json) + # If json is not a dict send as data. + # This allows the json parameter to be used to pass other + # types of data such as async_generator and still have json + # returned. + if json and not isinstance(json, Dict): + data = json + json = None try: resp = await self.client.post( url, @@ -62,7 +73,7 @@ async def post( async with resp: if resp.status == 200: response_data = await resp.read() - if json: + if return_json: response_data = json_loads(response_data.decode()) except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex: diff --git a/kasa/json.py b/kasa/json.py index 4acc865f5..aed8cd56d 100755 --- a/kasa/json.py +++ b/kasa/json.py @@ -11,5 +11,9 @@ def dumps(obj, *, default=None): except ImportError: import json - dumps = json.dumps + def dumps(obj, *, default=None): + """Dump JSON.""" + # Separators specified for consistency with orjson + return json.dumps(obj, separators=(",", ":")) + loads = json.loads diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 748dae9ae..4694e3631 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -225,7 +225,10 @@ def inner_error_code(self): else: return self._inner_error_code - async def post(self, url, params=None, json=None, *_, **__): + async def post(self, url, params=None, json=None, data=None, *_, **__): + if data: + async for item in data: + json = json_loads(item.decode()) return await self._post(url, json) async def _post(self, url, json): From f045696ebe7dbed55ab928559446506b8fe5aad9 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 23 Jan 2024 21:51:07 +0000 Subject: [PATCH 270/892] Fix P100 error getting conn closed when trying default login after login failure (#690) --- kasa/aestransport.py | 33 +++++++++++++++++++++------------ kasa/tests/test_aestransport.py | 12 +++++++++++- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 14a9ee6a1..018176adc 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -180,19 +180,28 @@ async def perform_login(self): """Login to the device.""" try: await self.try_login(self._login_params) - except AuthenticationException as ex: - if ex.error_code != SmartErrorCode.LOGIN_ERROR: - raise ex - if self._default_credentials is None: - self._default_credentials = get_default_credentials( - DEFAULT_CREDENTIALS["TAPO"] + except AuthenticationException as aex: + try: + if aex.error_code != SmartErrorCode.LOGIN_ERROR: + raise aex + if self._default_credentials is None: + self._default_credentials = get_default_credentials( + DEFAULT_CREDENTIALS["TAPO"] + ) + await self.perform_handshake() + await self.try_login(self._get_login_params(self._default_credentials)) + _LOGGER.debug( + "%s: logged in with default credentials", + self._host, ) - await self.perform_handshake() - await self.try_login(self._get_login_params(self._default_credentials)) - _LOGGER.debug( - "%s: logged in with default credentials", - self._host, - ) + except AuthenticationException: + raise + except Exception as ex: + raise AuthenticationException( + "Unable to login and trying default " + + "login raised another exception: %s", + ex, + ) from ex async def try_login(self, login_params): """Try to login with supplied login_params.""" diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 4694e3631..c58aad4eb 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -106,8 +106,18 @@ async def test_login(mocker, status_code, error_code, inner_error_code, expectat pytest.raises(AuthenticationException), 1, ), + ( + [SmartErrorCode.LOGIN_ERROR, SmartErrorCode.SESSION_TIMEOUT_ERROR], + pytest.raises(SmartDeviceException), + 3, + ), ], - ids=("LOGIN_ERROR-success", "LOGIN_ERROR-LOGIN_ERROR", "LOGIN_FAILED_ERROR"), + ids=( + "LOGIN_ERROR-success", + "LOGIN_ERROR-LOGIN_ERROR", + "LOGIN_FAILED_ERROR", + "LOGIN_ERROR-SESSION_TIMEOUT_ERROR", + ), ) async def test_login_errors(mocker, inner_error_codes, expectation, call_count): host = "127.0.0.1" From e576fcdb463b602d4ae9042a02cc36a32902be26 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 23 Jan 2024 22:58:57 +0100 Subject: [PATCH 271/892] Allow raw-command and wifi without update (#688) * Allow raw-command and wifi without update * Call update always but on wifi&raw-command * Add tests * Skip update also if device_family was defined, as device factory performs an update --- kasa/cli.py | 4 +++- kasa/tests/test_cli.py | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index d1cb72765..1b20303d4 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -317,7 +317,6 @@ def _nop_echo(*args, **kwargs): if type is not None: dev = TYPE_TO_CLASS[type](host) - await dev.update() elif device_family and encrypt_type: ctype = ConnectionType( DeviceFamilyType(device_family), @@ -339,6 +338,9 @@ def _nop_echo(*args, **kwargs): port=port, credentials=credentials, ) + + # Skip update for wifi & raw-command, and if factory was used to connect + if ctx.invoked_subcommand not in ["wifi", "raw-command"] and not device_family: await dev.update() ctx.obj = dev diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 3aad37dda..fa2d5c69e 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -34,6 +34,27 @@ from .conftest import device_iot, device_smart, handle_turn_on, new_discovery, turn_on +async def test_update_called_by_cli(dev, mocker): + """Test that device update is called on main.""" + runner = CliRunner() + update = mocker.patch.object(dev, "update") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dev) + + res = await runner.invoke( + cli, + [ + "--host", + "127.0.0.1", + "--username", + "foo", + "--password", + "bar", + ], + ) + assert res.exit_code == 0 + update.assert_called() + + @device_iot async def test_sysinfo(dev): runner = CliRunner() @@ -86,8 +107,9 @@ async def test_alias(dev): await dev.set_alias(old_alias) -async def test_raw_command(dev): +async def test_raw_command(dev, mocker): runner = CliRunner() + update = mocker.patch.object(dev, "update") from kasa.tapo import TapoDevice if isinstance(dev, TapoDevice): @@ -96,6 +118,10 @@ async def test_raw_command(dev): params = ["system", "get_sysinfo"] res = await runner.invoke(raw_command, params, obj=dev) + # Make sure that update was not called for wifi + with pytest.raises(AssertionError): + update.assert_called() + assert res.exit_code == 0 assert dev.model in res.output @@ -129,14 +155,19 @@ async def test_wifi_scan(dev): @device_smart -async def test_wifi_join(dev): +async def test_wifi_join(dev, mocker): runner = CliRunner() + update = mocker.patch.object(dev, "update") res = await runner.invoke( wifi, ["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"], obj=dev, ) + # Make sure that update was not called for wifi + with pytest.raises(AssertionError): + update.assert_called() + assert res.exit_code == 0 assert "Asking the device to connect to FOOBAR" in res.output From 1788c5014637f8bb0d60212111f29e65dfc3267e Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 23 Jan 2024 22:15:18 +0000 Subject: [PATCH 272/892] Update transport close/reset behaviour (#689) Co-authored-by: J. Nick Koston --- kasa/aestransport.py | 8 +++++--- kasa/httpclient.py | 8 ++++++-- kasa/iotprotocol.py | 16 +++++----------- kasa/klaptransport.py | 7 ++++++- kasa/protocol.py | 26 ++++++++++++++++---------- kasa/smartdevice.py | 4 ++++ kasa/smartprotocol.py | 16 +++++----------- kasa/tests/conftest.py | 11 +++++++++-- kasa/tests/newfakes.py | 3 +++ kasa/tests/test_aestransport.py | 1 + kasa/tests/test_device_factory.py | 2 ++ kasa/tests/test_httpclient.py | 4 ++-- kasa/tests/test_klapprotocol.py | 3 ++- 13 files changed, 66 insertions(+), 43 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 018176adc..73d02b0ee 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -315,10 +315,12 @@ async def send(self, request: str): return await self.send_secure_passthrough(request) async def close(self) -> None: - """Mark the handshake and login as not done. + """Close the http client and reset internal state.""" + await self.reset() + await self._http_client.close() - Since we likely lost the connection. - """ + async def reset(self) -> None: + """Reset internal handshake and login state.""" self._handshake_done = False self._login_token = None diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 28a19e8bd..7fe0b2c39 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -5,7 +5,11 @@ import aiohttp from .deviceconfig import DeviceConfig -from .exceptions import ConnectionException, SmartDeviceException, TimeoutException +from .exceptions import ( + ConnectionException, + SmartDeviceException, + TimeoutException, +) from .json import loads as json_loads @@ -78,7 +82,7 @@ async def post( except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex: raise ConnectionException( - f"Unable to connect to the device: {self._config.host}: {ex}", ex + f"Device connection error: {self._config.host}: {ex}", ex ) from ex except (aiohttp.ServerTimeoutError, asyncio.TimeoutError) as ex: raise TimeoutException( diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index c58cc8802..ed926101c 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -45,32 +45,31 @@ async def _query(self, request: str, retry_count: int = 3) -> Dict: try: return await self._execute_query(request, retry) except ConnectionException as sdex: - await self.close() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise sdex continue except AuthenticationException as auex: - await self.close() + await self._transport.reset() _LOGGER.debug( "Unable to authenticate with %s, not retrying", self._host ) raise auex except RetryableException as ex: - await self.close() + await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex continue except TimeoutException as ex: - await self.close() + await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_TIMEOUT) continue except SmartDeviceException as ex: - await self.close() + await self._transport.reset() _LOGGER.debug( "Unable to query the device: %s, not retrying: %s", self._host, @@ -85,10 +84,5 @@ async def _execute_query(self, request: str, retry_count: int) -> Dict: return await self._transport.send(request) async def close(self) -> None: - """Close the underlying transport. - - Some transports may close the connection, and some may - use this as a hint that they need to reconnect, or - reauthenticate. - """ + """Close the underlying transport.""" await self._transport.close() diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 5411314a3..c678e4483 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -348,7 +348,12 @@ async def send(self, request: str): return json_payload async def close(self) -> None: - """Mark the handshake as not done since we likely lost the connection.""" + """Close the http client and reset internal state.""" + await self.reset() + await self._http_client.close() + + async def reset(self) -> None: + """Reset internal handshake state.""" self._handshake_done = False @staticmethod diff --git a/kasa/protocol.py b/kasa/protocol.py index 59fea4a84..ae8eb89b1 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -80,6 +80,10 @@ async def send(self, request: str) -> Dict: async def close(self) -> None: """Close the transport. Abstract method to be overriden.""" + @abstractmethod + async def reset(self) -> None: + """Reset internal state.""" + class BaseProtocol(ABC): """Base class for all TP-Link Smart Home communication.""" @@ -139,7 +143,10 @@ async def send(self, request: str) -> Dict: return {} async def close(self) -> None: - """Close the transport. Abstract method to be overriden.""" + """Close the transport.""" + + async def reset(self) -> None: + """Reset internal state..""" class TPLinkSmartHomeProtocol(BaseProtocol): @@ -233,9 +240,9 @@ def close_without_wait(self) -> None: if writer: writer.close() - def _reset(self) -> None: - """Clear any varibles that should not survive between loops.""" - self.reader = self.writer = None + async def reset(self) -> None: + """Reset the transport.""" + await self.close() async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: """Try to query a device.""" @@ -252,12 +259,12 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: try: await self._connect(timeout) except ConnectionRefusedError as ex: - await self.close() + await self.reset() raise SmartDeviceException( f"Unable to connect to the device: {self._host}:{self._port}: {ex}" ) from ex except OSError as ex: - await self.close() + await self.reset() if ex.errno in _NO_RETRY_ERRORS or retry >= retry_count: raise SmartDeviceException( f"Unable to connect to the device:" @@ -265,7 +272,7 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: ) from ex continue except Exception as ex: - await self.close() + await self.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise SmartDeviceException( @@ -290,7 +297,7 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: async with asyncio_timeout(timeout): return await self._execute_query(request) except Exception as ex: - await self.close() + await self.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise SmartDeviceException( @@ -312,7 +319,7 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: raise # make mypy happy, this should never be reached.. - await self.close() + await self.reset() raise SmartDeviceException("Query reached somehow to unreachable") def __del__(self) -> None: @@ -322,7 +329,6 @@ def __del__(self) -> None: # or in another thread so we need to make sure the call to # close is called safely with call_soon_threadsafe self.loop.call_soon_threadsafe(self.writer.close) - self._reset() @staticmethod def _xor_payload(unencrypted: bytes) -> Generator[int, None, None]: diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 08a6bfb65..31418afcc 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -806,6 +806,10 @@ def config(self) -> DeviceConfig: """Return the device configuration.""" return self.protocol.config + async def disconnect(self): + """Disconnect and close any underlying connection resources.""" + await self.protocol.close() + @staticmethod async def connect( *, diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index c28db948e..6f0648ea0 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -66,32 +66,31 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: try: return await self._execute_query(request, retry) except ConnectionException as sdex: - await self.close() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise sdex continue except AuthenticationException as auex: - await self.close() + await self._transport.reset() _LOGGER.debug( "Unable to authenticate with %s, not retrying", self._host ) raise auex except RetryableException as ex: - await self.close() + await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex continue except TimeoutException as ex: - await self.close() + await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_TIMEOUT) continue except SmartDeviceException as ex: - await self.close() + await self._transport.reset() _LOGGER.debug( "Unable to query the device: %s, not retrying: %s", self._host, @@ -167,12 +166,7 @@ def _handle_response_error_code(self, resp_dict: dict): raise SmartDeviceException(msg, error_code=error_code) async def close(self) -> None: - """Close the underlying transport. - - Some transports may close the connection, and some may - use this as a hint that they need to reconnect, or - reauthenticate. - """ + """Close the underlying transport.""" await self._transport.close() diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 12f9c2769..7addbe72a 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -15,6 +15,7 @@ Credentials, Discover, SmartBulb, + SmartDevice, SmartDimmer, SmartLightStrip, SmartPlug, @@ -416,9 +417,15 @@ async def dev(request): IP_MODEL_CACHE[ip] = model = d.model if model not in file: pytest.skip(f"skipping file {file}") - return d if d else await _discover_update_and_close(ip, username, password) + dev: SmartDevice = ( + d if d else await _discover_update_and_close(ip, username, password) + ) + else: + dev: SmartDevice = await get_device_for_file(file, protocol) + + yield dev - return await get_device_for_file(file, protocol) + await dev.disconnect() @pytest.fixture diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 78bea3340..625a4994c 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -377,6 +377,9 @@ def _send_request(self, request_dict: dict): async def close(self) -> None: pass + async def reset(self) -> None: + pass + class FakeTransportProtocol(TPLinkSmartHomeProtocol): def __init__(self, info): diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index c58aad4eb..cfd292845 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -147,6 +147,7 @@ async def test_login_errors(mocker, inner_error_codes, expectation, call_count): await transport.send(json_dumps(request)) assert transport._login_token == mock_aes_device.token assert post_mock.call_count == call_count # Login, Handshake, Login + await transport.close() @status_parameters diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index 25a13aea5..8e3e2ed60 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -69,6 +69,8 @@ async def test_connect( assert dev.config == config + await dev.disconnect() + @pytest.mark.parametrize("custom_port", [123, None]) async def test_connect_custom_port(all_fixture_data: dict, mocker, custom_port): diff --git a/kasa/tests/test_httpclient.py b/kasa/tests/test_httpclient.py index 0a6c2beba..e178b8189 100644 --- a/kasa/tests/test_httpclient.py +++ b/kasa/tests/test_httpclient.py @@ -19,12 +19,12 @@ ( aiohttp.ServerDisconnectedError(), ConnectionException, - "Unable to connect to the device: ", + "Device connection error: ", ), ( aiohttp.ClientOSError(), ConnectionException, - "Unable to connect to the device: ", + "Device connection error: ", ), ( aiohttp.ServerTimeoutError(), diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 54f4a4bed..09ceccaef 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -54,9 +54,10 @@ async def read(self): [ (Exception("dummy exception"), False), (aiohttp.ServerTimeoutError("dummy exception"), True), + (aiohttp.ServerDisconnectedError("dummy exception"), True), (aiohttp.ClientOSError("dummy exception"), True), ], - ids=("Exception", "SmartDeviceException", "ConnectError"), + ids=("Exception", "ServerTimeoutError", "ServerDisconnectedError", "ClientOSError"), ) @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) From 52c3fb4d524d7e475936bdb62ffa08bc1b845bb3 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 24 Jan 2024 00:12:01 +0100 Subject: [PATCH 273/892] Add 1003 (TRANSPORT_UNKNOWN_CREDENTIALS_ERROR) (#667) --- kasa/exceptions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/kasa/exceptions.py b/kasa/exceptions.py index c0ef23b6a..fb86ef14c 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -53,6 +53,8 @@ class SmartErrorCode(IntEnum): HTTP_TRANSPORT_FAILED_ERROR = 1112 LOGIN_FAILED_ERROR = 1111 HAND_SHAKE_FAILED_ERROR = 1100 + #: Real description unknown, seen after an encryption-changing fw upgrade + TRANSPORT_UNKNOWN_CREDENTIALS_ERROR = 1003 TRANSPORT_NOT_AVAILABLE_ERROR = 1002 CMD_COMMAND_CANCEL_ERROR = 1001 NULL_TRANSPORT_ERROR = 1000 @@ -111,6 +113,7 @@ class SmartErrorCode(IntEnum): SmartErrorCode.LOGIN_FAILED_ERROR, SmartErrorCode.AES_DECODE_FAIL_ERROR, SmartErrorCode.HAND_SHAKE_FAILED_ERROR, + SmartErrorCode.TRANSPORT_UNKNOWN_CREDENTIALS_ERROR, ] SMART_TIMEOUT_ERRORS = [ From 5907dc763a059b4e0a124a81360ebc0283d9c68b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Jan 2024 20:59:39 -1000 Subject: [PATCH 274/892] Add fixtures for L510E (#693) * Add fixtures for L510E * mac --- kasa/tests/conftest.py | 2 +- .../fixtures/smart/L510E(US)_3.0_1.0.5.json | 295 ++++++++++++++++++ .../fixtures/smart/L510E(US)_3.0_1.1.2.json | 267 ++++++++++++++++ 3 files changed, 563 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json create mode 100644 kasa/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 7addbe72a..28f209589 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -47,7 +47,7 @@ BULBS_SMART_VARIABLE_TEMP = {"L530E"} BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5"} BULBS_SMART_COLOR = {"L530E", *BULBS_SMART_LIGHT_STRIP} -BULBS_SMART_DIMMABLE = {"KS225", "L510B"} +BULBS_SMART_DIMMABLE = {"KS225", "L510B", "L510E"} BULBS_SMART = ( BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) .union(BULBS_SMART_DIMMABLE) diff --git a/kasa/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json b/kasa/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json new file mode 100644 index 000000000..15b85d085 --- /dev/null +++ b/kasa/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json @@ -0,0 +1,295 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L510E(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "bulb", + "brightness": 100, + "color_temp_range": [ + 2700, + 2700 + ], + "default_states": { + "brightness": { + "type": "last_states", + "value": 100 + }, + "re_power_type": "always_on" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 230529 Rel.113426", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "00-00-00-00-00-00", + "model": "L510", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -69, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706060153 + }, + "get_device_usage": { + "power_usage": { + "past30": 1, + "past7": 1, + "today": 1 + }, + "saved_power": { + "past30": 4, + "past7": 4, + "today": 4 + }, + "time_usage": { + "past30": 5, + "past7": 5, + "today": 5 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.1.2 Build 231120 Rel.201048", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-12-04", + "release_note": "Modifications and Bug Fixes:\n1. Added the support for setting the Fade In time manually.\n2. Optimized Wi-Fi connection stability\n3. Enhanced local communication security.\n4. Fixed some minor bugs.", + "type": 1 + }, + "get_next_event": {}, + "get_preset_rules": { + "brightness": [ + 1, + 25, + 50, + 75, + 100 + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L510", + "device_type": "SMART.TAPOBULB" + } + } +} diff --git a/kasa/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json b/kasa/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json new file mode 100644 index 000000000..055674d28 --- /dev/null +++ b/kasa/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json @@ -0,0 +1,267 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 3 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L510E(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "bulb", + "brightness": 100, + "color_temp_range": [ + 2700, + 2700 + ], + "default_states": { + "brightness": { + "type": "last_states", + "value": 100 + }, + "re_power_type": "always_on" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 231120 Rel.201048", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "L510", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -69, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706060527 + }, + "get_device_usage": { + "power_usage": { + "past30": 1, + "past7": 1, + "today": 1 + }, + "saved_power": { + "past30": 9, + "past7": 9, + "today": 9 + }, + "time_usage": { + "past30": 10, + "past7": 10, + "today": 10 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.2 Build 231120 Rel.201048", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 2, + "enable": false, + "max_duration": 60 + }, + "on_state": { + "duration": 2, + "enable": false, + "max_duration": 60 + } + }, + "get_preset_rules": { + "brightness": [ + 1, + 25, + 50, + 75, + 100 + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L510", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} From 8b566757c303f62f2dbaa7845765cadfb3ef421f Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 24 Jan 2024 09:10:55 +0100 Subject: [PATCH 275/892] Add new cli command 'command' to execute arbitrary commands (#692) * Add new cli command 'command' to execute arbitrary commands This deprecates 'raw-command', which requires positional argument for module, in favor of new 'command' that accepts '--module' option for IOT devices. * Pull block list to the module level --- kasa/cli.py | 23 +++++++++++++++++++---- kasa/modules/emeter.py | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 1b20303d4..5f726be05 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1,4 +1,5 @@ """python-kasa cli tool.""" +import ast import asyncio import json import logging @@ -69,6 +70,9 @@ def wrapper(message=None, *args, **kwargs): device_family_type.value for device_family_type in DeviceFamilyType ] +# Block list of commands which require no update +SKIP_UPDATE_COMMANDS = ["wifi", "raw-command", "command"] + click.anyio_backend = "asyncio" pass_dev = click.make_pass_decorator(SmartDevice) @@ -339,8 +343,9 @@ def _nop_echo(*args, **kwargs): credentials=credentials, ) - # Skip update for wifi & raw-command, and if factory was used to connect - if ctx.invoked_subcommand not in ["wifi", "raw-command"] and not device_family: + # Skip update on specific commands, or if device factory, + # that performs an update was used for the device. + if ctx.invoked_subcommand not in SKIP_UPDATE_COMMANDS and not device_family: await dev.update() ctx.obj = dev @@ -592,13 +597,23 @@ async def alias(dev, new_alias, index): @cli.command() @pass_dev +@click.pass_context @click.argument("module") @click.argument("command") @click.argument("parameters", default=None, required=False) -async def raw_command(dev: SmartDevice, module, command, parameters): +async def raw_command(ctx, dev: SmartDevice, module, command, parameters): """Run a raw command on the device.""" - import ast + logging.warning("Deprecated, use 'kasa command --module %s %s'", module, command) + return await ctx.forward(cmd_command) + +@cli.command(name="command") +@pass_dev +@click.option("--module", required=False, help="Module for IOT protocol.") +@click.argument("command") +@click.argument("parameters", default=None, required=False) +async def cmd_command(dev: SmartDevice, module, command, parameters): + """Run a raw command on the device.""" if parameters is not None: parameters = ast.literal_eval(parameters) diff --git a/kasa/modules/emeter.py b/kasa/modules/emeter.py index a205396ed..11eed48f8 100644 --- a/kasa/modules/emeter.py +++ b/kasa/modules/emeter.py @@ -63,7 +63,7 @@ def _convert_stat_data( self, data: List[Dict[str, Union[int, float]]], entry_key: str, - kwh: bool=True, + kwh: bool = True, key: Optional[int] = None, ) -> Dict[Union[int, float], Union[int, float]]: """Return emeter information keyed with the day/month. From eb217a748ce38586cd72061ca2e6507f54e784ad Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 24 Jan 2024 08:20:44 +0000 Subject: [PATCH 276/892] Fix test_klapprotocol test duration (#698) --- kasa/tests/test_klapprotocol.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 09ceccaef..4d711f034 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -6,6 +6,7 @@ import sys import time from contextlib import nullcontext as does_not_raise +from unittest.mock import PropertyMock import aiohttp import pytest @@ -67,6 +68,7 @@ async def test_protocol_retries_via_client_session( ): host = "127.0.0.1" conn = mocker.patch.object(aiohttp.ClientSession, "post", side_effect=error) + mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0) config = DeviceConfig(host) with pytest.raises(SmartDeviceException): @@ -95,6 +97,7 @@ async def test_protocol_retries_via_httpclient( ): host = "127.0.0.1" conn = mocker.patch.object(HttpClient, "post", side_effect=error) + mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0) config = DeviceConfig(host) with pytest.raises(SmartDeviceException): @@ -117,6 +120,7 @@ async def test_protocol_no_retry_on_connection_error( "post", side_effect=AuthenticationException("foo"), ) + mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0) config = DeviceConfig(host) with pytest.raises(SmartDeviceException): await protocol_class(transport=transport_class(config=config)).query( From 3f40410db3b2d0069da9d5d4d248299b92f2f205 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 24 Jan 2024 09:36:45 +0100 Subject: [PATCH 277/892] Update readme fixture checker and readme (#699) --- README.md | 4 +++- devtools/check_readme_vs_fixtures.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index eeb5e7e2a..6e99268e0 100644 --- a/README.md +++ b/README.md @@ -282,11 +282,13 @@ At the moment, the following devices have been confirmed to work: #### Plugs * Tapo P110 +* Tapo P125M * Tapo P135 (dimming not yet supported) #### Bulbs * Tapo L510B +* Tapo L510E * Tapo L530E #### Light strips @@ -334,7 +336,7 @@ use it directly you should expect it could break in future releases until this s Other TAPO libraries are: * [PyTapo - Python library for communication with Tapo Cameras](https://github.com/JurajNyiri/pytapo) -* [Tapo P100 (Tapo P105/P100 plugs, Tapo L510E bulbs)](https://github.com/fishbigger/TapoP100) +* [Tapo P100 (Tapo plugs, Tapo bulbs)](https://github.com/fishbigger/TapoP100) * [Home Assistant integration](https://github.com/fishbigger/HomeAssistant-Tapo-P100-Control) * [plugp100, another tapo library](https://github.com/petretiandrea/plugp100) * [Home Assistant integration](https://github.com/petretiandrea/home-assistant-tapo-p100) diff --git a/devtools/check_readme_vs_fixtures.py b/devtools/check_readme_vs_fixtures.py index f7a2f2c39..88663621a 100644 --- a/devtools/check_readme_vs_fixtures.py +++ b/devtools/check_readme_vs_fixtures.py @@ -1,4 +1,5 @@ """Script that checks if README.md is missing devices that have fixtures.""" +import re import sys from kasa.tests.conftest import ( @@ -32,10 +33,11 @@ def _get_device_type(dev, typemap): found_unlisted = False for dev in ALL_DEVICES: - if dev not in readme: + regex = rf"^\*.*\s{dev}" + match = re.search(regex, readme, re.MULTILINE) + if match is None: print(f"{dev} not listed in {_get_device_type(dev, typemap)}") found_unlisted = True - if found_unlisted: sys.exit(-1) From 24c645746e51e58c87ffa6163fc6bcec75c76c2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Jan 2024 22:50:25 -1000 Subject: [PATCH 278/892] Refactor aestransport to use a state enum (#691) --- kasa/aestransport.py | 80 ++++++++++++++++++--------------- kasa/tests/test_aestransport.py | 10 ++--- pyproject.toml | 11 ++++- 3 files changed, 58 insertions(+), 43 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 73d02b0ee..5269d185c 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -8,7 +8,8 @@ import hashlib import logging import time -from typing import TYPE_CHECKING, AsyncGenerator, Dict, Optional, cast +from enum import Enum, auto +from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, Optional, Tuple, cast from cryptography.hazmat.primitives import padding, serialization from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding @@ -41,6 +42,14 @@ def _sha1(payload: bytes) -> str: return sha1_algo.hexdigest() +class TransportState(Enum): + """Enum for AES state.""" + + HANDSHAKE_REQUIRED = auto() # Handshake needed + LOGIN_REQUIRED = auto() # Login needed + ESTABLISHED = auto() # Ready to send requests + + class AesTransport(BaseTransport): """Implementation of the AES encryption protocol. @@ -79,21 +88,21 @@ def __init__( self._default_credentials: Optional[Credentials] = None self._http_client: HttpClient = HttpClient(config) - self._handshake_done = False + self._state = TransportState.HANDSHAKE_REQUIRED self._encryption_session: Optional[AesEncyptionSession] = None self._session_expire_at: Optional[float] = None self._session_cookie: Optional[Dict[str, str]] = None - self._login_token = None + self._login_token: Optional[str] = None self._key_pair: Optional[KeyPair] = None _LOGGER.debug("Created AES transport for %s", self._host) @property - def default_port(self): + def default_port(self) -> int: """Default port for the transport.""" return self.DEFAULT_PORT @@ -102,30 +111,25 @@ def credentials_hash(self) -> str: """The hashed credentials used by the transport.""" return base64.b64encode(json_dumps(self._login_params).encode()).decode() - def _get_login_params(self, credentials): + def _get_login_params(self, credentials: Credentials) -> Dict[str, str]: """Get the login parameters based on the login_version.""" un, pw = self.hash_credentials(self._login_version == 2, credentials) password_field_name = "password2" if self._login_version == 2 else "password" return {password_field_name: pw, "username": un} @staticmethod - def hash_credentials(login_v2, credentials): + def hash_credentials(login_v2: bool, credentials: Credentials) -> Tuple[str, str]: """Hash the credentials.""" + un = base64.b64encode(_sha1(credentials.username.encode()).encode()).decode() if login_v2: - un = base64.b64encode( - _sha1(credentials.username.encode()).encode() - ).decode() pw = base64.b64encode( _sha1(credentials.password.encode()).encode() ).decode() else: - un = base64.b64encode( - _sha1(credentials.username.encode()).encode() - ).decode() pw = base64.b64encode(credentials.password.encode()).decode() return un, pw - def _handle_response_error_code(self, resp_dict: dict, msg: str): + def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] if error_code == SmartErrorCode.SUCCESS: return @@ -135,12 +139,11 @@ def _handle_response_error_code(self, resp_dict: dict, msg: str): if error_code in SMART_RETRYABLE_ERRORS: raise RetryableException(msg, error_code=error_code) if error_code in SMART_AUTHENTICATION_ERRORS: - self._handshake_done = False - self._login_token = None + self._state = TransportState.HANDSHAKE_REQUIRED raise AuthenticationException(msg, error_code=error_code) raise SmartDeviceException(msg, error_code=error_code) - async def send_secure_passthrough(self, request: str): + async def send_secure_passthrough(self, request: str) -> Dict[str, Any]: """Send encrypted message as passthrough.""" url = f"http://{self._host}/app" if self._login_token: @@ -165,16 +168,17 @@ async def send_secure_passthrough(self, request: str): + f"status code {status_code} to passthrough" ) - resp_dict = cast(Dict, resp_dict) self._handle_response_error_code( resp_dict, "Error sending secure_passthrough message" ) - response = self._encryption_session.decrypt( # type: ignore - resp_dict["result"]["response"].encode() - ) - resp_dict = json_loads(response) - return resp_dict + if TYPE_CHECKING: + resp_dict = cast(Dict[str, Any], resp_dict) + assert self._encryption_session is not None + + raw_response: str = resp_dict["result"]["response"] + response = self._encryption_session.decrypt(raw_response.encode()) + return json_loads(response) # type: ignore[return-value] async def perform_login(self): """Login to the device.""" @@ -182,7 +186,7 @@ async def perform_login(self): await self.try_login(self._login_params) except AuthenticationException as aex: try: - if aex.error_code != SmartErrorCode.LOGIN_ERROR: + if aex.error_code is not SmartErrorCode.LOGIN_ERROR: raise aex if self._default_credentials is None: self._default_credentials = get_default_credentials( @@ -203,9 +207,8 @@ async def perform_login(self): ex, ) from ex - async def try_login(self, login_params): + async def try_login(self, login_params: Dict[str, Any]) -> None: """Try to login with supplied login_params.""" - self._login_token = None login_request = { "method": "login_device", "params": login_params, @@ -216,6 +219,7 @@ async def try_login(self, login_params): resp_dict = await self.send_secure_passthrough(request) self._handle_response_error_code(resp_dict, "Error logging in") self._login_token = resp_dict["result"]["token"] + self._state = TransportState.ESTABLISHED async def _generate_key_pair_payload(self) -> AsyncGenerator: """Generate the request body and return an ascyn_generator. @@ -236,12 +240,11 @@ async def _generate_key_pair_payload(self) -> AsyncGenerator: _LOGGER.debug(f"Request {request_body}") yield json_dumps(request_body).encode() - async def perform_handshake(self): + async def perform_handshake(self) -> None: """Perform the handshake.""" _LOGGER.debug("Will perform handshaking...") self._key_pair = None - self._handshake_done = False self._session_expire_at = None self._session_cookie = None @@ -258,7 +261,7 @@ async def perform_handshake(self): cookies_dict=self._session_cookie, ) - _LOGGER.debug(f"Device responded with: {resp_dict}") + _LOGGER.debug("Device responded with: %s", resp_dict) if status_code != 200: raise SmartDeviceException( @@ -268,6 +271,9 @@ async def perform_handshake(self): self._handle_response_error_code(resp_dict, "Unable to complete handshake") + if TYPE_CHECKING: + resp_dict = cast(Dict[str, Any], resp_dict) + handshake_key = resp_dict["result"]["key"] if ( @@ -283,12 +289,12 @@ async def perform_handshake(self): self._session_expire_at = time.time() + 86400 if TYPE_CHECKING: - assert self._key_pair is not None # pragma: no cover + assert self._key_pair is not None self._encryption_session = AesEncyptionSession.create_from_keypair( handshake_key, self._key_pair ) - self._handshake_done = True + self._state = TransportState.LOGIN_REQUIRED _LOGGER.debug("Handshake with %s complete", self._host) @@ -299,17 +305,20 @@ def _handshake_session_expired(self): or self._session_expire_at - time.time() <= 0 ) - async def send(self, request: str): + async def send(self, request: str) -> Dict[str, Any]: """Send the request.""" - if not self._handshake_done or self._handshake_session_expired(): + if ( + self._state is TransportState.HANDSHAKE_REQUIRED + or self._handshake_session_expired() + ): await self.perform_handshake() - if not self._login_token: + if self._state is not TransportState.ESTABLISHED: try: await self.perform_login() # After a login failure handshake needs to # be redone or a 9999 error is received. except AuthenticationException as ex: - self._handshake_done = False + self._state = TransportState.HANDSHAKE_REQUIRED raise ex return await self.send_secure_passthrough(request) @@ -321,8 +330,7 @@ async def close(self) -> None: async def reset(self) -> None: """Reset internal handshake and login state.""" - self._handshake_done = False - self._login_token = None + self._state = TransportState.HANDSHAKE_REQUIRED class AesEncyptionSession: diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index cfd292845..086f6ea60 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -10,7 +10,7 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding -from ..aestransport import AesEncyptionSession, AesTransport +from ..aestransport import AesEncyptionSession, AesTransport, TransportState from ..credentials import Credentials from ..deviceconfig import DeviceConfig from ..exceptions import ( @@ -66,11 +66,11 @@ async def test_handshake( ) assert transport._encryption_session is None - assert transport._handshake_done is False + assert transport._state is TransportState.HANDSHAKE_REQUIRED with expectation: await transport.perform_handshake() assert transport._encryption_session is not None - assert transport._handshake_done is True + assert transport._state is TransportState.LOGIN_REQUIRED @status_parameters @@ -82,7 +82,7 @@ async def test_login(mocker, status_code, error_code, inner_error_code, expectat transport = AesTransport( config=DeviceConfig(host, credentials=Credentials("foo", "bar")) ) - transport._handshake_done = True + transport._state = TransportState.LOGIN_REQUIRED transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session @@ -129,7 +129,7 @@ async def test_login_errors(mocker, inner_error_codes, expectation, call_count): transport = AesTransport( config=DeviceConfig(host, credentials=Credentials("foo", "bar")) ) - transport._handshake_done = True + transport._state = TransportState.LOGIN_REQUIRED transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session diff --git a/pyproject.toml b/pyproject.toml index 6bd81a900..206565559 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,9 +65,16 @@ omit = ["kasa/tests/*"] [tool.coverage.report] exclude_lines = [ - # ignore abstract methods + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", "raise NotImplementedError", - "def __repr__" + # Don't complain about missing debug-only code: + "def __repr__", + # Have to re-enable the standard pragma + "pragma: no cover", + # TYPE_CHECKING and @overload blocks are never executed during pytest run + "if TYPE_CHECKING:", + "@overload" ] [tool.pytest.ini_options] From bab40d43e6973912f97c9ccc1c59ecfbf0eedf6f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Jan 2024 23:11:27 -1000 Subject: [PATCH 279/892] Renew the handshake session 20 minutes before we think it will expire (#697) * Renew the KLAP handshake session 20 minutes before we think it will expire Currently we assumed the clocks were perfectly aligned and the handshake session lasted 20 hours. We now add a 20 minute buffer * use timeout cookie when available --- kasa/aestransport.py | 23 +++++++++++++++++------ kasa/klaptransport.py | 19 ++++++++++++++----- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 5269d185c..412dbbf22 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -36,6 +36,10 @@ _LOGGER = logging.getLogger(__name__) +ONE_DAY_SECONDS = 86400 +SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20 + + def _sha1(payload: bytes) -> str: sha1_algo = hashlib.sha1() # noqa: S324 sha1_algo.update(payload) @@ -59,6 +63,7 @@ class AesTransport(BaseTransport): DEFAULT_PORT: int = 80 SESSION_COOKIE_NAME = "TP_SESSIONID" + TIMEOUT_COOKIE_NAME = "TIMEOUT" COMMON_HEADERS = { "Content-Type": "application/json", "requestByApp": "true", @@ -254,7 +259,9 @@ async def perform_handshake(self) -> None: **self.COMMON_HEADERS, self.CONTENT_LENGTH: str(self.KEY_PAIR_CONTENT_LENGTH), } - status_code, resp_dict = await self._http_client.post( + http_client = self._http_client + + status_code, resp_dict = await http_client.post( url, json=self._generate_key_pair_payload(), headers=headers, @@ -277,17 +284,21 @@ async def perform_handshake(self) -> None: handshake_key = resp_dict["result"]["key"] if ( - cookie := self._http_client.get_cookie( # type: ignore + cookie := http_client.get_cookie( # type: ignore self.SESSION_COOKIE_NAME ) ) or ( - cookie := self._http_client.get_cookie( # type: ignore - "SESSIONID" - ) + cookie := http_client.get_cookie("SESSIONID") # type: ignore ): self._session_cookie = {self.SESSION_COOKIE_NAME: cookie} - self._session_expire_at = time.time() + 86400 + timeout = int( + http_client.get_cookie(self.TIMEOUT_COOKIE_NAME) or ONE_DAY_SECONDS + ) + # There is a 24 hour timeout on the session cookie + # but the clock on the device is not always accurate + # so we set the expiry to 24 hours from now minus a buffer + self._session_expire_at = time.time() + timeout - SESSION_EXPIRE_BUFFER_SECONDS if TYPE_CHECKING: assert self._key_pair is not None self._encryption_session = AesEncyptionSession.create_from_keypair( diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index c678e4483..cd0e3de6b 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -63,6 +63,10 @@ _LOGGER = logging.getLogger(__name__) +ONE_DAY_SECONDS = 86400 +SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20 + + def _sha256(payload: bytes) -> bytes: digest = hashes.Hash(hashes.SHA256()) # noqa: S303 digest.update(payload) @@ -86,6 +90,7 @@ class KlapTransport(BaseTransport): DEFAULT_PORT: int = 80 DISCOVERY_QUERY = {"system": {"get_sysinfo": None}} SESSION_COOKIE_NAME = "TP_SESSIONID" + TIMEOUT_COOKIE_NAME = "TIMEOUT" def __init__( self, @@ -271,14 +276,18 @@ async def perform_handshake(self) -> Any: self._session_cookie = None local_seed, remote_seed, auth_hash = await self.perform_handshake1() - if cookie := self._http_client.get_cookie( # type: ignore - self.SESSION_COOKIE_NAME - ): + http_client = self._http_client + if cookie := http_client.get_cookie(self.SESSION_COOKIE_NAME): # type: ignore self._session_cookie = {self.SESSION_COOKIE_NAME: cookie} # The device returns a TIMEOUT cookie on handshake1 which # it doesn't like to get back so we store the one we want - - self._session_expire_at = time.time() + 86400 + timeout = int( + http_client.get_cookie(self.TIMEOUT_COOKIE_NAME) or ONE_DAY_SECONDS + ) + # There is a 24 hour timeout on the session cookie + # but the clock on the device is not always accurate + # so we set the expiry to 24 hours from now minus a buffer + self._session_expire_at = time.time() + timeout - SESSION_EXPIRE_BUFFER_SECONDS self._encryption_session = await self.perform_handshake2( local_seed, remote_seed, auth_hash ) From f7c04bcef84bb71b55c7018764791fe138104917 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 24 Jan 2024 09:40:36 +0000 Subject: [PATCH 280/892] Add --batch-size hint to timeout errors in dump_devinfo (#696) * Add --batch-size hint to timeout errors in dump_devinfo * Add _echo_error function for displaying critical errors --- devtools/dump_devinfo.py | 57 ++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 6a8240ef6..e9ec56b7b 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -20,7 +20,14 @@ import asyncclick as click from devtools.helpers.smartrequests import COMPONENT_REQUESTS, SmartRequest -from kasa import AuthenticationException, Credentials, Discover, SmartDevice +from kasa import ( + AuthenticationException, + Credentials, + Discover, + SmartDevice, + SmartDeviceException, + TimeoutException, +) from kasa.discover import DiscoveryResult from kasa.exceptions import SmartErrorCode from kasa.tapo.tapodevice import TapoDevice @@ -227,11 +234,7 @@ async def get_legacy_fixture(device): try: final = await device.protocol.query(final_query) except Exception as ex: - click.echo( - click.style( - f"Unable to query all successes at once: {ex}", bold=True, fg="red" - ) - ) + _echo_error(f"Unable to query all successes at once: {ex}", bold=True, fg="red") if device._discovery_info and not device._discovery_info.get("system"): # Need to recreate a DiscoverResult here because we don't want the aliases @@ -254,6 +257,16 @@ async def get_legacy_fixture(device): return save_filename, copy_folder, final +def _echo_error(msg: str): + click.echo( + click.style( + msg, + bold=True, + fg="red", + ) + ) + + async def _make_requests_or_exit( device: SmartDevice, requests: List[SmartRequest], @@ -277,17 +290,25 @@ async def _make_requests_or_exit( final[method] = result return final except AuthenticationException as ex: - click.echo( - click.style( - f"Unable to query the device due to an authentication error: {ex}", - bold=True, - fg="red", - ) + _echo_error( + f"Unable to query the device due to an authentication error: {ex}", + ) + exit(1) + except SmartDeviceException as ex: + _echo_error( + f"Unable to query {name} at once: {ex}", ) + if ( + isinstance(ex, TimeoutException) + or ex.error_code == SmartErrorCode.SESSION_TIMEOUT_ERROR + ): + _echo_error( + "Timeout, try reducing the batch size via --batch-size option.", + ) exit(1) except Exception as ex: - click.echo( - click.style(f"Unable to query {name} at once: {ex}", bold=True, fg="red") + _echo_error( + f"Unexpected exception querying {name} at once: {ex}", ) exit(1) @@ -361,12 +382,8 @@ async def get_smart_fixture(device: TapoDevice, batch_size: int): SmartRequest._create_request_dict(test_call.request) ) except AuthenticationException as ex: - click.echo( - click.style( - f"Unable to query the device due to an authentication error: {ex}", - bold=True, - fg="red", - ) + _echo_error( + f"Unable to query the device due to an authentication error: {ex}", ) exit(1) except Exception as ex: From aecf0ecd8a6aa3d7dde2f3d2a5f20cf0ba7465a4 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 24 Jan 2024 13:21:37 +0100 Subject: [PATCH 281/892] Do not crash on missing geolocation (#701) If 'has_set_location_info' is false, the geolocation is missing. --- kasa/tapo/tapodevice.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 156a61d1a..86967b69d 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -147,8 +147,8 @@ def hw_info(self) -> Dict: def location(self) -> Dict: """Return the device location.""" loc = { - "latitude": cast(float, self._info.get("latitude")) / 10_000, - "longitude": cast(float, self._info.get("longitude")) / 10_000, + "latitude": cast(float, self._info.get("latitude", 0)) / 10_000, + "longitude": cast(float, self._info.get("longitude", 0)) / 10_000, } return loc From 3df837cc825d64038e0fe4699184bd43757fb10f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 09:43:42 -1000 Subject: [PATCH 282/892] Ensure login token is only sent if aes state is ESTABLISHED (#702) --- kasa/aestransport.py | 7 +++---- kasa/tests/test_aestransport.py | 18 ++++++++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 412dbbf22..4e1ccb7d6 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -151,7 +151,7 @@ def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: async def send_secure_passthrough(self, request: str) -> Dict[str, Any]: """Send encrypted message as passthrough.""" url = f"http://{self._host}/app" - if self._login_token: + if self._state is TransportState.ESTABLISHED and self._login_token: url += f"?token={self._login_token}" encrypted_payload = self._encryption_session.encrypt(request.encode()) # type: ignore @@ -250,6 +250,7 @@ async def perform_handshake(self) -> None: _LOGGER.debug("Will perform handshaking...") self._key_pair = None + self._login_token = None self._session_expire_at = None self._session_cookie = None @@ -284,9 +285,7 @@ async def perform_handshake(self) -> None: handshake_key = resp_dict["result"]["key"] if ( - cookie := http_client.get_cookie( # type: ignore - self.SESSION_COOKIE_NAME - ) + cookie := http_client.get_cookie(self.SESSION_COOKIE_NAME) # type: ignore ) or ( cookie := http_client.get_cookie("SESSIONID") # type: ignore ): diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 086f6ea60..9fe5cabd4 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -1,9 +1,12 @@ import base64 import json +import random +import string import time from contextlib import nullcontext as does_not_raise from json import dumps as json_dumps from json import loads as json_loads +from typing import Any, Dict, Optional import aiohttp import pytest @@ -219,7 +222,6 @@ async def read(self): return json_dumps(self._json).encode() encryption_session = AesEncyptionSession(KEY_IV[:16], KEY_IV[16:]) - token = "test_token" # noqa def __init__(self, host, status_code=200, error_code=0, inner_error_code=0): self.host = host @@ -228,6 +230,7 @@ def __init__(self, host, status_code=200, error_code=0, inner_error_code=0): self._inner_error_code = inner_error_code self.http_client = HttpClient(DeviceConfig(self.host)) self.inner_call_count = 0 + self.token = "".join(random.choices(string.ascii_uppercase, k=32)) # noqa: S311 @property def inner_error_code(self): @@ -242,7 +245,7 @@ async def post(self, url, params=None, json=None, data=None, *_, **__): json = json_loads(item.decode()) return await self._post(url, json) - async def _post(self, url, json): + async def _post(self, url: str, json: Dict[str, Any]): if json["method"] == "handshake": return await self._return_handshake_response(url, json) elif json["method"] == "securePassthrough": @@ -253,7 +256,7 @@ async def _post(self, url, json): assert url == f"http://{self.host}/app?token={self.token}" return await self._return_send_response(url, json) - async def _return_handshake_response(self, url, json): + async def _return_handshake_response(self, url: str, json: Dict[str, Any]): start = len("-----BEGIN PUBLIC KEY-----\n") end = len("\n-----END PUBLIC KEY-----\n") client_pub_key = json["params"]["key"][start:-end] @@ -266,7 +269,7 @@ async def _return_handshake_response(self, url, json): self.status_code, {"result": {"key": key_64}, "error_code": self.error_code} ) - async def _return_secure_passthrough_response(self, url, json): + async def _return_secure_passthrough_response(self, url: str, json: Dict[str, Any]): encrypted_request = json["params"]["request"] decrypted_request = self.encryption_session.decrypt(encrypted_request.encode()) decrypted_request_dict = json_loads(decrypted_request) @@ -283,12 +286,15 @@ async def _return_secure_passthrough_response(self, url, json): } return self._mock_response(self.status_code, result) - async def _return_login_response(self, url, json): + async def _return_login_response(self, url: str, json: Dict[str, Any]): + if "token=" in url: + raise Exception("token should not be in url for a login request") + self.token = "".join(random.choices(string.ascii_uppercase, k=32)) # noqa: S311 result = {"result": {"token": self.token}, "error_code": self.inner_error_code} self.inner_call_count += 1 return self._mock_response(self.status_code, result) - async def _return_send_response(self, url, json): + async def _return_send_response(self, url: str, json: Dict[str, Any]): result = {"result": {"method": None}, "error_code": self.inner_error_code} self.inner_call_count += 1 return self._mock_response(self.status_code, result) From ae6a31463ef5840276e4d4e7ce8d3a63060a9e03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 11:53:28 -1000 Subject: [PATCH 283/892] Fix overly greedy _strip_rich_formatting (#703) --- kasa/cli.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/kasa/cli.py b/kasa/cli.py index 5f726be05..86aea4367 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -37,6 +37,9 @@ try: from rich import print as _do_echo except ImportError: + # Remove 7-bit C1 ANSI sequences + # https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python + ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") def _strip_rich_formatting(echo_func): """Strip rich formatting from messages.""" @@ -44,7 +47,7 @@ def _strip_rich_formatting(echo_func): @wraps(echo_func) def wrapper(message=None, *args, **kwargs): if message is not None: - message = re.sub(r"\[/?.+?]", "", message) + message = ansi_escape.sub("", message) echo_func(message, *args, **kwargs) return wrapper From 2d8b966e5bc86d69f157ac1afec0cf43af915c65 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 24 Jan 2024 23:09:27 +0100 Subject: [PATCH 284/892] Document authenticated provisioning (#634) --- docs/source/cli.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index b75cc85b2..c1570bc0c 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -51,6 +51,14 @@ You can provision your device without any extra apps by using the ``kasa wifi`` As with all other commands, you can also pass ``--help`` to both ``join`` and ``scan`` commands to see the available options. +.. note:: + + For devices requiring authentication, the device-stored credentials can be changed using + the ``update-credentials`` commands, for example, to match with other cloud-connected devices. + However, note that communications with devices provisioned using this method will stop working + when connected to the cloud. + + ``kasa --help`` *************** From 3235ba620d68bf10d43f15b03963391a28173b7d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 12:29:55 -1000 Subject: [PATCH 285/892] Add updated L920 fixture (#680) * Add updated L920 fixture * Fix overly greedy _strip_rich_formatting --------- Co-authored-by: Teemu R --- .../fixtures/smart/L920-5(US)_1.0_1.1.3.json | 415 ++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json diff --git a/kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json b/kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json new file mode 100644 index 000000000..5463944dd --- /dev/null +++ b/kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json @@ -0,0 +1,415 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "light_strip", + "ver_code": 1 + }, + { + "id": "light_strip_lighting_effect", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 3 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "music_rhythm", + "ver_code": 3 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "segment", + "ver_code": 1 + }, + { + "id": "segment_effect", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L920-5(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "34-60-F9-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "light_strip", + "brightness": 100, + "color_temp": 0, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 0, + "hue": 250, + "saturation": 85 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.3 Build 231229 Rel.164316", + "has_set_location_info": false, + "hue": 250, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "lighting_effect": { + "brightness": 50, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "mac": "34-60-F9-00-00-00", + "model": "L920", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -32, + "saturation": 85, + "segment_effect": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1705991901 + }, + "get_device_usage": { + "power_usage": { + "past30": 8, + "past7": 7, + "today": 0 + }, + "saved_power": { + "past30": 110, + "past7": 101, + "today": 14 + }, + "time_usage": { + "past30": 118, + "past7": 108, + "today": 14 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.3 Build 231229 Rel.164316", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_lighting_effect": { + "brightness": 50, + "custom": 0, + "direction": 1, + "display_colors": [], + "duration": 0, + "enable": 0, + "expansion_strategy": 2, + "id": "", + "name": "station", + "repeat_times": 1, + "segment_length": 1, + "sequence": [ + [ + 300, + 100, + 50 + ], + [ + 240, + 100, + 50 + ], + [ + 180, + 100, + 50 + ], + [ + 120, + 100, + 50 + ], + [ + 60, + 100, + 50 + ], + [ + 0, + 100, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ] + ], + "spread": 16, + "transition": 400, + "type": "sequence" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": false + }, + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 50, + "color_temp": 9000, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 24, + "start_index": 0, + "sum": 0 + }, + "get_segment_effect_rule": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "get_wireless_scan_info": { + "ap_list": [], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L920", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} From 8947ffbc9439b8973ed763ba97a413edb5f19299 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 12:31:01 -1000 Subject: [PATCH 286/892] Add L930-5 fixture (#694) * Add L930-5 fixture * Mark L930-5 as variable temp * Update readme * Fix overly greedy _strip_rich_formatting --------- Co-authored-by: Teemu Rytilahti --- README.md | 1 + kasa/tests/conftest.py | 4 +- .../fixtures/smart/L930-5(US)_1.0_1.1.2.json | 429 ++++++++++++++++++ 3 files changed, 432 insertions(+), 2 deletions(-) create mode 100644 kasa/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json diff --git a/README.md b/README.md index 6e99268e0..d4cebe488 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,7 @@ At the moment, the following devices have been confirmed to work: * Tapo L900-5 * Tapo L900-10 * Tapo L920-5 +* Tapo L930-5 ### Newer Kasa branded devices diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 28f209589..c043b18c8 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -44,8 +44,8 @@ SUPPORTED_DEVICES = SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES # Tapo bulbs -BULBS_SMART_VARIABLE_TEMP = {"L530E"} -BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5"} +BULBS_SMART_VARIABLE_TEMP = {"L530E", "L930-5"} +BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5", "L930-5"} BULBS_SMART_COLOR = {"L530E", *BULBS_SMART_LIGHT_STRIP} BULBS_SMART_DIMMABLE = {"KS225", "L510B", "L510E"} BULBS_SMART = ( diff --git a/kasa/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json b/kasa/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json new file mode 100644 index 000000000..de7ae2c79 --- /dev/null +++ b/kasa/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json @@ -0,0 +1,429 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "light_strip", + "ver_code": 1 + }, + { + "id": "light_strip_lighting_effect", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 3 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "music_rhythm", + "ver_code": 3 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "segment", + "ver_code": 1 + }, + { + "id": "segment_effect", + "ver_code": 1 + }, + { + "id": "auto_light", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L930-5(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_light_info": { + "enable": false, + "mode": "light_track" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "light_strip", + "brightness": 100, + "color_temp": 4500, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 4500, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 231212 Rel.210005", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "lighting_effect": { + "brightness": 50, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "L930", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -37, + "saturation": 100, + "segment_effect": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706061664 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 6, + "past7": 6, + "today": 6 + }, + "time_usage": { + "past30": 6, + "past7": 6, + "today": 6 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.2 Build 231212 Rel.210005", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_lighting_effect": { + "brightness": 50, + "custom": 0, + "direction": 1, + "display_colors": [], + "duration": 0, + "enable": 0, + "expansion_strategy": 2, + "id": "", + "name": "station", + "repeat_times": 1, + "segment_length": 1, + "sequence": [ + [ + 300, + 100, + 50 + ], + [ + 240, + 100, + 50 + ], + [ + 180, + 100, + 50 + ], + [ + 120, + 100, + 50 + ], + [ + 60, + 100, + 50 + ], + [ + 0, + 100, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ] + ], + "spread": 16, + "transition": 400, + "type": "sequence" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": false + }, + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 50, + "color_temp": 4500, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_segment_effect_rule": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "get_wireless_scan_info": { + "ap_list": [], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L930", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} From fa6bc59b29c148b56499ee4e9afc8459373d6ad0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 21:49:26 -1000 Subject: [PATCH 287/892] Replace rich formatting stripper (#706) * Revert "Fix overly greedy _strip_rich_formatting (#703)" This reverts commit ae6a31463ef5840276e4d4e7ce8d3a63060a9e03. * Improve rich formatter stripper reverts and replaces #703 --- kasa/cli.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 86aea4367..42b13b9bb 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -37,9 +37,11 @@ try: from rich import print as _do_echo except ImportError: - # Remove 7-bit C1 ANSI sequences - # https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python - ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + # Strip out rich formatting if rich is not installed + # but only lower case tags to avoid stripping out + # raw data from the device that is printed from + # the device state. + rich_formatting = re.compile(r"\[/?[a-z]+]") def _strip_rich_formatting(echo_func): """Strip rich formatting from messages.""" @@ -47,7 +49,7 @@ def _strip_rich_formatting(echo_func): @wraps(echo_func) def wrapper(message=None, *args, **kwargs): if message is not None: - message = ansi_escape.sub("", message) + message = rich_formatting.sub("", message) echo_func(message, *args, **kwargs) return wrapper From fa94548723d40bd588c08aedd60d59c274ebaded Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 21:53:43 -1000 Subject: [PATCH 288/892] Add additional L900-10 fixture (#707) --- .../smart/L900-10(US)_1.0_1.0.11.json | 428 ++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 kasa/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json diff --git a/kasa/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json b/kasa/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json new file mode 100644 index 000000000..8665c8f31 --- /dev/null +++ b/kasa/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json @@ -0,0 +1,428 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "light_strip", + "ver_code": 1 + }, + { + "id": "light_strip_lighting_effect", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 3 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "music_rhythm", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L900-10(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "54-AF-97-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "light_strip", + "brightness": 100, + "color_temp": 9000, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 9000, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.11 Build 220119 Rel.221258", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "lighting_effect": { + "brightness": 50, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "longitude": 0, + "mac": "54-AF-97-00-00-00", + "model": "L900", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -42, + "saturation": 100, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706141011 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 3, + "past7": 3, + "today": 3 + }, + "time_usage": { + "past30": 3, + "past7": 3, + "today": 3 + } + }, + "get_fw_download_state": { + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.1.0 Build 230905 Rel.184939", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-10-13", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced stability and performance.\n2. Enhanced the local communication security.", + "type": 2 + }, + "get_lighting_effect": { + "brightness": 50, + "custom": 0, + "direction": 1, + "display_colors": [], + "duration": 0, + "enable": 0, + "expansion_strategy": 2, + "id": "", + "name": "station", + "repeat_times": 1, + "segment_length": 1, + "sequence": [ + [ + 300, + 100, + 50 + ], + [ + 240, + 100, + 50 + ], + [ + 180, + 100, + 50 + ], + [ + 120, + 100, + 50 + ], + [ + 60, + 100, + 50 + ], + [ + 0, + 100, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ] + ], + "spread": 16, + "transition": 400, + "type": "sequence" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": false + }, + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 50, + "color_temp": 9000, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 16, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "L900", + "device_type": "SMART.TAPOBULB" + } + } +} From cba073ebde251d5f3e6eda342e31758f46981cb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 21:54:56 -1000 Subject: [PATCH 289/892] Add support for tapo wall switches (S500D) (#704) * Add support for the S500D * tweak * Update README.md --- README.md | 4 + kasa/device_factory.py | 1 + kasa/deviceconfig.py | 1 + kasa/tests/conftest.py | 9 +- .../fixtures/smart/S500D(US)_1.0_1.0.5.json | 317 ++++++++++++++++++ 5 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json diff --git a/README.md b/README.md index d4cebe488..0300677f9 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,10 @@ At the moment, the following devices have been confirmed to work: * Tapo L920-5 * Tapo L930-5 +#### Wall switches + +* Tapo S500D + ### Newer Kasa branded devices Some newer hardware versions of Kasa branded devices are now using the same protocol as diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 83db093f4..d216e0ef9 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -131,6 +131,7 @@ def get_device_class_from_family(device_type: str) -> Optional[Type[SmartDevice] supported_device_types: Dict[str, Type[SmartDevice]] = { "SMART.TAPOPLUG": TapoPlug, "SMART.TAPOBULB": TapoBulb, + "SMART.TAPOSWITCH": TapoBulb, "SMART.KASAPLUG": TapoPlug, "SMART.KASASWITCH": TapoBulb, "IOT.SMARTPLUGSWITCH": SmartPlug, diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 58d33661b..77ce6df40 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -30,6 +30,7 @@ class DeviceFamilyType(Enum): SmartKasaSwitch = "SMART.KASASWITCH" SmartTapoPlug = "SMART.TAPOPLUG" SmartTapoBulb = "SMART.TAPOBULB" + SmartTapoSwitch = "SMART.TAPOSWITCH" def _dataclass_from_dict(klass, in_val): diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index c043b18c8..eb7b53f3d 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -111,7 +111,7 @@ STRIPS = {*STRIPS_IOT, *STRIPS_SMART} DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} -DIMMERS_SMART: Set[str] = set() +DIMMERS_SMART = {"S500D"} DIMMERS = { *DIMMERS_IOT, *DIMMERS_SMART, @@ -240,6 +240,9 @@ def parametrize(desc, devices, protocol_filter=None, ids=None): plug_smart = parametrize("plug devices smart", PLUGS_SMART, protocol_filter={"SMART"}) bulb_smart = parametrize("bulb devices smart", BULBS_SMART, protocol_filter={"SMART"}) +dimmers_smart = parametrize( + "dimmer devices smart", DIMMERS_SMART, protocol_filter={"SMART"} +) device_smart = parametrize( "devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"} ) @@ -300,6 +303,7 @@ def check_categories(): + lightstrip.args[1] + plug_smart.args[1] + bulb_smart.args[1] + + dimmers_smart.args[1] ) diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures) if diff: @@ -331,6 +335,9 @@ def device_for_file(model, protocol): for d in BULBS_SMART: if d in model: return TapoBulb + for d in DIMMERS_SMART: + if d in model: + return TapoBulb else: for d in STRIPS_IOT: if d in model: diff --git a/kasa/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json b/kasa/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json new file mode 100644 index 000000000..a141e7003 --- /dev/null +++ b/kasa/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json @@ -0,0 +1,317 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "S500D(US)", + "device_type": "SMART.TAPOSWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "48-22-54-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "switch_s500d", + "brightness": 46, + "default_states": { + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 221014 Rel.112003", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "48-22-54-00-00-00", + "model": "S500D", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "Pacific/Honolulu", + "rssi": -31, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOSWITCH" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706136515 + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.1.0 Build 230906 Rel.141935", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-10-07", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced stability and performance.\n2. Enhanced local communication security.", + "type": 2 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 426, + "night_mode_type": "sunrise_sunset", + "start_time": 1093, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 2, + "enable": true + }, + "on_state": { + "duration": 3, + "enable": true + } + }, + "get_preset_rules": { + "brightness": [ + 100, + 75, + 50, + 25, + 1 + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "S500D", + "device_type": "SMART.TAPOSWITCH" + } + } +} From 716b1f82d9ffd6e288d8522c3eaf20eba767983a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 22:07:01 -1000 Subject: [PATCH 290/892] Add support for the S500 (#705) * Add support for the S500D * tweak * Add S505 --- README.md | 1 + kasa/tests/conftest.py | 2 +- .../fixtures/smart/S505(US)_1.0_1.0.2.json | 309 ++++++++++++++++++ 3 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/smart/S505(US)_1.0_1.0.2.json diff --git a/README.md b/README.md index 0300677f9..42b1c99d1 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,7 @@ At the moment, the following devices have been confirmed to work: #### Wall switches * Tapo S500D +* Tapo S505 ### Newer Kasa branded devices diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index eb7b53f3d..9b5731866 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -101,7 +101,7 @@ } # P135 supports dimming, but its not currently support # by the library -PLUGS_SMART = {"P100", "P110", "KP125M", "EP25", "KS205", "P125M", "P135"} +PLUGS_SMART = {"P100", "P110", "KP125M", "EP25", "KS205", "P125M", "P135", "S505"} PLUGS = { *PLUGS_IOT, *PLUGS_SMART, diff --git a/kasa/tests/fixtures/smart/S505(US)_1.0_1.0.2.json b/kasa/tests/fixtures/smart/S505(US)_1.0_1.0.2.json new file mode 100644 index 000000000..c9c63cd7f --- /dev/null +++ b/kasa/tests/fixtures/smart/S505(US)_1.0_1.0.2.json @@ -0,0 +1,309 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 1 + }, + { + "id": "delay_action", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "S505(US)", + "device_type": "SMART.TAPOSWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "switch_s500", + "default_states": { + "state": { + "on": false + }, + "type": "custom" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.2 Build 230313 Rel.101023", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "S505", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -37, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOSWITCH" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706137970 + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.1.0 Build 231024 Rel.201030", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-11-24", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced stability and performance.\n2. Enhanced local communication security.\n3. Fixed some minor bugs.", + "type": 2 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 426, + "night_mode_type": "sunrise_sunset", + "start_time": 1093, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "S505", + "device_type": "SMART.TAPOSWITCH" + } + } +} From c01c3c679c681bafa0152e283ce61459fe1b0a21 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 25 Jan 2024 09:32:45 +0100 Subject: [PATCH 291/892] Prepare 0.6.1 (#709) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.1...0.6.1) Release highlights: * Support for tapo wall switches * Support for unprovisioned devices * Performance and stability improvements **Implemented enhancements:** - Add support for tapo wall switches \(S500D\) [\#704](https://github.com/python-kasa/python-kasa/pull/704) (@bdraco) - Add new cli command 'command' to execute arbitrary commands [\#692](https://github.com/python-kasa/python-kasa/pull/692) (@rytilahti) - Allow raw-command and wifi without update [\#688](https://github.com/python-kasa/python-kasa/pull/688) (@rytilahti) - Generate AES KeyPair lazily [\#687](https://github.com/python-kasa/python-kasa/pull/687) (@sdb9696) - Add reboot and factory\_reset to tapodevice [\#686](https://github.com/python-kasa/python-kasa/pull/686) (@rytilahti) - Try default tapo credentials for klap and aes [\#685](https://github.com/python-kasa/python-kasa/pull/685) (@sdb9696) - Sleep between discovery packets [\#656](https://github.com/python-kasa/python-kasa/pull/656) (@sdb9696) **Fixed bugs:** - Do not crash on missing geolocation [\#701](https://github.com/python-kasa/python-kasa/pull/701) (@rytilahti) - Fix P100 error getting conn closed when trying default login after login failure [\#690](https://github.com/python-kasa/python-kasa/pull/690) (@sdb9696) **Documentation updates:** - Add protocol and transport documentation [\#663](https://github.com/python-kasa/python-kasa/pull/663) (@sdb9696) - Document authenticated provisioning [\#634](https://github.com/python-kasa/python-kasa/pull/634) (@rytilahti) **Closed issues:** - Consider handshake as still valid on ServerDisconnectedError [\#676](https://github.com/python-kasa/python-kasa/issues/676) - AES Transport creates the key even if the device is offline [\#675](https://github.com/python-kasa/python-kasa/issues/675) - how to provision new Tapo plug devices? [\#565](https://github.com/python-kasa/python-kasa/issues/565) - Space out discovery requests [\#229](https://github.com/python-kasa/python-kasa/issues/229) **Merged pull requests:** - Add additional L900-10 fixture [\#707](https://github.com/python-kasa/python-kasa/pull/707) (@bdraco) - Replace rich formatting stripper [\#706](https://github.com/python-kasa/python-kasa/pull/706) (@bdraco) - Add support for the S500 [\#705](https://github.com/python-kasa/python-kasa/pull/705) (@bdraco) - Fix overly greedy \_strip\_rich\_formatting [\#703](https://github.com/python-kasa/python-kasa/pull/703) (@bdraco) - Ensure login token is only sent if aes state is ESTABLISHED [\#702](https://github.com/python-kasa/python-kasa/pull/702) (@bdraco) - Update readme fixture checker and readme [\#699](https://github.com/python-kasa/python-kasa/pull/699) (@rytilahti) - Fix test\_klapprotocol test duration [\#698](https://github.com/python-kasa/python-kasa/pull/698) (@sdb9696) - Renew the handshake session 20 minutes before we think it will expire [\#697](https://github.com/python-kasa/python-kasa/pull/697) (@bdraco) - Add --batch-size hint to timeout errors in dump\_devinfo [\#696](https://github.com/python-kasa/python-kasa/pull/696) (@sdb9696) - Add L930-5 fixture [\#694](https://github.com/python-kasa/python-kasa/pull/694) (@bdraco) - Add fixtures for L510E [\#693](https://github.com/python-kasa/python-kasa/pull/693) (@bdraco) - Refactor aestransport to use a state enum [\#691](https://github.com/python-kasa/python-kasa/pull/691) (@bdraco) - Update transport close/reset behaviour [\#689](https://github.com/python-kasa/python-kasa/pull/689) (@sdb9696) - Check README for supported models [\#684](https://github.com/python-kasa/python-kasa/pull/684) (@rytilahti) - Add P100 test fixture [\#683](https://github.com/python-kasa/python-kasa/pull/683) (@bdraco) - Make dump\_devinfo request batch size configurable [\#681](https://github.com/python-kasa/python-kasa/pull/681) (@sdb9696) - Add updated L920 fixture [\#680](https://github.com/python-kasa/python-kasa/pull/680) (@bdraco) - Update fixtures from test devices [\#679](https://github.com/python-kasa/python-kasa/pull/679) (@bdraco) - Show discovery data for state with verbose [\#678](https://github.com/python-kasa/python-kasa/pull/678) (@rytilahti) - Add L530E\(US\) fixture [\#674](https://github.com/python-kasa/python-kasa/pull/674) (@bdraco) - Add P135 fixture [\#673](https://github.com/python-kasa/python-kasa/pull/673) (@bdraco) - Rename base TPLinkProtocol to BaseProtocol [\#669](https://github.com/python-kasa/python-kasa/pull/669) (@sdb9696) - Add 1003 \(TRANSPORT\_UNKNOWN\_CREDENTIALS\_ERROR\) [\#667](https://github.com/python-kasa/python-kasa/pull/667) (@rytilahti) --- CHANGELOG.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de52be989..2a2fe8d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,67 @@ # Changelog +## [0.6.1](https://github.com/python-kasa/python-kasa/tree/0.6.1) (2024-01-25) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.1...0.6.1) + +Release highlights: +* Support for tapo wall switches +* Support for unprovisioned devices +* Performance and stability improvements + +**Implemented enhancements:** + +- Add support for tapo wall switches \(S500D\) [\#704](https://github.com/python-kasa/python-kasa/pull/704) (@bdraco) +- Add new cli command 'command' to execute arbitrary commands [\#692](https://github.com/python-kasa/python-kasa/pull/692) (@rytilahti) +- Allow raw-command and wifi without update [\#688](https://github.com/python-kasa/python-kasa/pull/688) (@rytilahti) +- Generate AES KeyPair lazily [\#687](https://github.com/python-kasa/python-kasa/pull/687) (@sdb9696) +- Add reboot and factory\_reset to tapodevice [\#686](https://github.com/python-kasa/python-kasa/pull/686) (@rytilahti) +- Try default tapo credentials for klap and aes [\#685](https://github.com/python-kasa/python-kasa/pull/685) (@sdb9696) +- Sleep between discovery packets [\#656](https://github.com/python-kasa/python-kasa/pull/656) (@sdb9696) + +**Fixed bugs:** + +- Do not crash on missing geolocation [\#701](https://github.com/python-kasa/python-kasa/pull/701) (@rytilahti) +- Fix P100 error getting conn closed when trying default login after login failure [\#690](https://github.com/python-kasa/python-kasa/pull/690) (@sdb9696) + +**Documentation updates:** + +- Add protocol and transport documentation [\#663](https://github.com/python-kasa/python-kasa/pull/663) (@sdb9696) +- Document authenticated provisioning [\#634](https://github.com/python-kasa/python-kasa/pull/634) (@rytilahti) + +**Closed issues:** + +- Consider handshake as still valid on ServerDisconnectedError [\#676](https://github.com/python-kasa/python-kasa/issues/676) +- AES Transport creates the key even if the device is offline [\#675](https://github.com/python-kasa/python-kasa/issues/675) +- how to provision new Tapo plug devices? [\#565](https://github.com/python-kasa/python-kasa/issues/565) +- Space out discovery requests [\#229](https://github.com/python-kasa/python-kasa/issues/229) + +**Merged pull requests:** + +- Add additional L900-10 fixture [\#707](https://github.com/python-kasa/python-kasa/pull/707) (@bdraco) +- Replace rich formatting stripper [\#706](https://github.com/python-kasa/python-kasa/pull/706) (@bdraco) +- Add support for the S500 [\#705](https://github.com/python-kasa/python-kasa/pull/705) (@bdraco) +- Fix overly greedy \_strip\_rich\_formatting [\#703](https://github.com/python-kasa/python-kasa/pull/703) (@bdraco) +- Ensure login token is only sent if aes state is ESTABLISHED [\#702](https://github.com/python-kasa/python-kasa/pull/702) (@bdraco) +- Update readme fixture checker and readme [\#699](https://github.com/python-kasa/python-kasa/pull/699) (@rytilahti) +- Fix test\_klapprotocol test duration [\#698](https://github.com/python-kasa/python-kasa/pull/698) (@sdb9696) +- Renew the handshake session 20 minutes before we think it will expire [\#697](https://github.com/python-kasa/python-kasa/pull/697) (@bdraco) +- Add --batch-size hint to timeout errors in dump\_devinfo [\#696](https://github.com/python-kasa/python-kasa/pull/696) (@sdb9696) +- Add L930-5 fixture [\#694](https://github.com/python-kasa/python-kasa/pull/694) (@bdraco) +- Add fixtures for L510E [\#693](https://github.com/python-kasa/python-kasa/pull/693) (@bdraco) +- Refactor aestransport to use a state enum [\#691](https://github.com/python-kasa/python-kasa/pull/691) (@bdraco) +- Update transport close/reset behaviour [\#689](https://github.com/python-kasa/python-kasa/pull/689) (@sdb9696) +- Check README for supported models [\#684](https://github.com/python-kasa/python-kasa/pull/684) (@rytilahti) +- Add P100 test fixture [\#683](https://github.com/python-kasa/python-kasa/pull/683) (@bdraco) +- Make dump\_devinfo request batch size configurable [\#681](https://github.com/python-kasa/python-kasa/pull/681) (@sdb9696) +- Add updated L920 fixture [\#680](https://github.com/python-kasa/python-kasa/pull/680) (@bdraco) +- Update fixtures from test devices [\#679](https://github.com/python-kasa/python-kasa/pull/679) (@bdraco) +- Show discovery data for state with verbose [\#678](https://github.com/python-kasa/python-kasa/pull/678) (@rytilahti) +- Add L530E\(US\) fixture [\#674](https://github.com/python-kasa/python-kasa/pull/674) (@bdraco) +- Add P135 fixture [\#673](https://github.com/python-kasa/python-kasa/pull/673) (@bdraco) +- Rename base TPLinkProtocol to BaseProtocol [\#669](https://github.com/python-kasa/python-kasa/pull/669) (@sdb9696) +- Add 1003 \(TRANSPORT\_UNKNOWN\_CREDENTIALS\_ERROR\) [\#667](https://github.com/python-kasa/python-kasa/pull/667) (@rytilahti) + ## [0.6.0.1](https://github.com/python-kasa/python-kasa/tree/0.6.0.1) (2024-01-21) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0...0.6.0.1) @@ -17,6 +79,7 @@ A patch release to improve the protocol handling. **Merged pull requests:** +- Release 0.6.0.1 [\#666](https://github.com/python-kasa/python-kasa/pull/666) (@rytilahti) - Add l900-5 1.1.0 fixture [\#664](https://github.com/python-kasa/python-kasa/pull/664) (@rytilahti) - Add fixtures with new MAC mask [\#661](https://github.com/python-kasa/python-kasa/pull/661) (@sdb9696) - Make close behaviour consistent across new protocols and transports [\#660](https://github.com/python-kasa/python-kasa/pull/660) (@sdb9696) diff --git a/pyproject.toml b/pyproject.toml index 206565559..f6092024a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.6.0.1" +version = "0.6.1" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From c318303255289f6929565dcde18a6853a50cdec3 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 25 Jan 2024 17:37:19 +0000 Subject: [PATCH 292/892] Add concrete XorTransport class with full implementation (#646) * Add concrete XorTransport class * Update xortransport reset() docstring --- kasa/protocol.py | 3 +- kasa/tests/test_protocol.py | 279 ++++++++++++++++++++++++++++++------ kasa/xortransport.py | 228 +++++++++++++++++++++++++++++ 3 files changed, 464 insertions(+), 46 deletions(-) create mode 100644 kasa/xortransport.py diff --git a/kasa/protocol.py b/kasa/protocol.py index ae8eb89b1..b7ef3dea9 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -60,7 +60,7 @@ def __init__( self._port = config.port_override or self.default_port self._credentials = config.credentials self._credentials_hash = config.credentials_hash - self._timeout = config.timeout + self._timeout = config.timeout or self.DEFAULT_TIMEOUT @property @abstractmethod @@ -124,6 +124,7 @@ class _XorTransport(BaseTransport): """ DEFAULT_PORT: int = 9999 + BLOCK_SIZE = 4 def __init__(self, *, config: DeviceConfig) -> None: super().__init__(config=config) diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index f623b597d..34f2507e1 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -4,6 +4,7 @@ import inspect import json import logging +import os import pkgutil import struct import sys @@ -14,6 +15,7 @@ from ..credentials import Credentials from ..deviceconfig import DeviceConfig from ..exceptions import SmartDeviceException +from ..iotprotocol import IotProtocol from ..klaptransport import KlapTransport, KlapTransportV2 from ..protocol import ( BaseProtocol, @@ -21,10 +23,19 @@ TPLinkSmartHomeProtocol, _XorTransport, ) +from ..xortransport import XorEncryption, XorTransport +@pytest.mark.parametrize( + "protocol_class, transport_class", + [ + (TPLinkSmartHomeProtocol, _XorTransport), + (IotProtocol, XorTransport), + ], + ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) @pytest.mark.parametrize("retry_count", [1, 3, 5]) -async def test_protocol_retries(mocker, retry_count): +async def test_protocol_retries(mocker, retry_count, protocol_class, transport_class): def aio_mock_writer(_, __): reader = mocker.patch("asyncio.StreamReader") writer = mocker.patch("asyncio.StreamWriter") @@ -38,60 +49,100 @@ def aio_mock_writer(_, __): conn = mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) config = DeviceConfig("127.0.0.1") with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)).query( + await protocol_class(transport=transport_class(config=config)).query( {}, retry_count=retry_count ) assert conn.call_count == retry_count + 1 -async def test_protocol_no_retry_on_unreachable(mocker): +@pytest.mark.parametrize( + "protocol_class, transport_class", + [ + (TPLinkSmartHomeProtocol, _XorTransport), + (IotProtocol, XorTransport), + ], + ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_no_retry_on_unreachable( + mocker, protocol_class, transport_class +): conn = mocker.patch( "asyncio.open_connection", side_effect=OSError(errno.EHOSTUNREACH, "No route to host"), ) config = DeviceConfig("127.0.0.1") with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)).query( + await protocol_class(transport=transport_class(config=config)).query( {}, retry_count=5 ) assert conn.call_count == 1 -async def test_protocol_no_retry_connection_refused(mocker): +@pytest.mark.parametrize( + "protocol_class, transport_class", + [ + (TPLinkSmartHomeProtocol, _XorTransport), + (IotProtocol, XorTransport), + ], + ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_no_retry_connection_refused( + mocker, protocol_class, transport_class +): conn = mocker.patch( "asyncio.open_connection", side_effect=ConnectionRefusedError, ) config = DeviceConfig("127.0.0.1") with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)).query( + await protocol_class(transport=transport_class(config=config)).query( {}, retry_count=5 ) assert conn.call_count == 1 -async def test_protocol_retry_recoverable_error(mocker): +@pytest.mark.parametrize( + "protocol_class, transport_class", + [ + (TPLinkSmartHomeProtocol, _XorTransport), + (IotProtocol, XorTransport), + ], + ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_retry_recoverable_error( + mocker, protocol_class, transport_class +): conn = mocker.patch( "asyncio.open_connection", side_effect=OSError(errno.ECONNRESET, "Connection reset by peer"), ) config = DeviceConfig("127.0.0.1") with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)).query( + await protocol_class(transport=transport_class(config=config)).query( {}, retry_count=5 ) assert conn.call_count == 6 +@pytest.mark.parametrize( + "protocol_class, transport_class, encryption_class", + [ + (TPLinkSmartHomeProtocol, _XorTransport, TPLinkSmartHomeProtocol), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) @pytest.mark.parametrize("retry_count", [1, 3, 5]) -async def test_protocol_reconnect(mocker, retry_count): +async def test_protocol_reconnect( + mocker, retry_count, protocol_class, transport_class, encryption_class +): remaining = retry_count - encrypted = TPLinkSmartHomeProtocol.encrypt('{"great":"success"}')[ - TPLinkSmartHomeProtocol.BLOCK_SIZE : + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : ] def _fail_one_less_than_retry_count(*_): @@ -102,7 +153,7 @@ def _fail_one_less_than_retry_count(*_): async def _mock_read(byte_count): nonlocal encrypted - if byte_count == TPLinkSmartHomeProtocol.BLOCK_SIZE: + if byte_count == transport_class.BLOCK_SIZE: return struct.pack(">I", len(encrypted)) if byte_count == len(encrypted): return encrypted @@ -117,16 +168,26 @@ def aio_mock_writer(_, __): return reader, writer config = DeviceConfig("127.0.0.1") - protocol = TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)) + protocol = protocol_class(transport=transport_class(config=config)) mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) response = await protocol.query({}, retry_count=retry_count) assert response == {"great": "success"} -async def test_protocol_handles_cancellation_during_write(mocker): +@pytest.mark.parametrize( + "protocol_class, transport_class, encryption_class", + [ + (TPLinkSmartHomeProtocol, _XorTransport, TPLinkSmartHomeProtocol), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_handles_cancellation_during_write( + mocker, protocol_class, transport_class, encryption_class +): attempts = 0 - encrypted = TPLinkSmartHomeProtocol.encrypt('{"great":"success"}')[ - TPLinkSmartHomeProtocol.BLOCK_SIZE : + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : ] def _cancel_first_attempt(*_): @@ -137,7 +198,7 @@ def _cancel_first_attempt(*_): async def _mock_read(byte_count): nonlocal encrypted - if byte_count == TPLinkSmartHomeProtocol.BLOCK_SIZE: + if byte_count == transport_class.BLOCK_SIZE: return struct.pack(">I", len(encrypted)) if byte_count == len(encrypted): return encrypted @@ -152,24 +213,36 @@ def aio_mock_writer(_, __): return reader, writer config = DeviceConfig("127.0.0.1") - protocol = TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)) - mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + protocol = protocol_class(transport=transport_class(config=config)) + conn_mock = mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) with pytest.raises(asyncio.CancelledError): await protocol.query({}) - assert protocol.writer is None + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + assert writer_obj.writer is None + conn_mock.assert_awaited_once() response = await protocol.query({}) assert response == {"great": "success"} -async def test_protocol_handles_cancellation_during_connection(mocker): +@pytest.mark.parametrize( + "protocol_class, transport_class, encryption_class", + [ + (TPLinkSmartHomeProtocol, _XorTransport, TPLinkSmartHomeProtocol), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_handles_cancellation_during_connection( + mocker, protocol_class, transport_class, encryption_class +): attempts = 0 - encrypted = TPLinkSmartHomeProtocol.encrypt('{"great":"success"}')[ - TPLinkSmartHomeProtocol.BLOCK_SIZE : + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : ] async def _mock_read(byte_count): nonlocal encrypted - if byte_count == TPLinkSmartHomeProtocol.BLOCK_SIZE: + if byte_count == transport_class.BLOCK_SIZE: return struct.pack(">I", len(encrypted)) if byte_count == len(encrypted): return encrypted @@ -187,26 +260,39 @@ def aio_mock_writer(_, __): return reader, writer config = DeviceConfig("127.0.0.1") - protocol = TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)) - mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + protocol = protocol_class(transport=transport_class(config=config)) + conn_mock = mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) with pytest.raises(asyncio.CancelledError): await protocol.query({}) - assert protocol.writer is None + + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + assert writer_obj.writer is None + conn_mock.assert_awaited_once() response = await protocol.query({}) assert response == {"great": "success"} +@pytest.mark.parametrize( + "protocol_class, transport_class, encryption_class", + [ + (TPLinkSmartHomeProtocol, _XorTransport, TPLinkSmartHomeProtocol), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) @pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG]) -async def test_protocol_logging(mocker, caplog, log_level): +async def test_protocol_logging( + mocker, caplog, log_level, protocol_class, transport_class, encryption_class +): caplog.set_level(log_level) logging.getLogger("kasa").setLevel(log_level) - encrypted = TPLinkSmartHomeProtocol.encrypt('{"great":"success"}')[ - TPLinkSmartHomeProtocol.BLOCK_SIZE : + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : ] async def _mock_read(byte_count): nonlocal encrypted - if byte_count == TPLinkSmartHomeProtocol.BLOCK_SIZE: + if byte_count == transport_class.BLOCK_SIZE: return struct.pack(">I", len(encrypted)) if byte_count == len(encrypted): return encrypted @@ -219,7 +305,7 @@ def aio_mock_writer(_, __): return reader, writer config = DeviceConfig("127.0.0.1") - protocol = TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)) + protocol = protocol_class(transport=transport_class(config=config)) mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) response = await protocol.query({}) assert response == {"great": "success"} @@ -229,15 +315,25 @@ def aio_mock_writer(_, __): assert "success" not in caplog.text +@pytest.mark.parametrize( + "protocol_class, transport_class, encryption_class", + [ + (TPLinkSmartHomeProtocol, _XorTransport, TPLinkSmartHomeProtocol), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) @pytest.mark.parametrize("custom_port", [123, None]) -async def test_protocol_custom_port(mocker, custom_port): - encrypted = TPLinkSmartHomeProtocol.encrypt('{"great":"success"}')[ - TPLinkSmartHomeProtocol.BLOCK_SIZE : +async def test_protocol_custom_port( + mocker, custom_port, protocol_class, transport_class, encryption_class +): + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : ] async def _mock_read(byte_count): nonlocal encrypted - if byte_count == TPLinkSmartHomeProtocol.BLOCK_SIZE: + if byte_count == transport_class.BLOCK_SIZE: return struct.pack(">I", len(encrypted)) if byte_count == len(encrypted): return encrypted @@ -254,21 +350,33 @@ def aio_mock_writer(_, port): return reader, writer config = DeviceConfig("127.0.0.1", port_override=custom_port) - protocol = TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)) + protocol = protocol_class(transport=transport_class(config=config)) mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) response = await protocol.query({}) assert response == {"great": "success"} -def test_encrypt(): +@pytest.mark.parametrize( + "encrypt_class", + [TPLinkSmartHomeProtocol, XorEncryption], +) +@pytest.mark.parametrize( + "decrypt_class", + [TPLinkSmartHomeProtocol, XorEncryption], +) +def test_encrypt(encrypt_class, decrypt_class): d = json.dumps({"foo": 1, "bar": 2}) - encrypted = TPLinkSmartHomeProtocol.encrypt(d) + encrypted = encrypt_class.encrypt(d) # encrypt adds a 4 byte header encrypted = encrypted[4:] - assert d == TPLinkSmartHomeProtocol.decrypt(encrypted) + assert d == decrypt_class.decrypt(encrypted) -def test_encrypt_unicode(): +@pytest.mark.parametrize( + "encrypt_class", + [TPLinkSmartHomeProtocol, XorEncryption], +) +def test_encrypt_unicode(encrypt_class): d = "{'snowman': '\u2603'}" e = bytes( @@ -294,14 +402,18 @@ def test_encrypt_unicode(): ] ) - encrypted = TPLinkSmartHomeProtocol.encrypt(d) + encrypted = encrypt_class.encrypt(d) # encrypt adds a 4 byte header encrypted = encrypted[4:] assert e == encrypted -def test_decrypt_unicode(): +@pytest.mark.parametrize( + "decrypt_class", + [TPLinkSmartHomeProtocol, XorEncryption], +) +def test_decrypt_unicode(decrypt_class): e = bytes( [ 208, @@ -327,7 +439,7 @@ def test_decrypt_unicode(): d = "{'snowman': '\u2603'}" - assert d == TPLinkSmartHomeProtocol.decrypt(e) + assert d == decrypt_class.decrypt(e) def _get_subclasses(of_class): @@ -378,7 +490,8 @@ def test_transport_init_signature(class_name_obj): @pytest.mark.parametrize( - "transport_class", [AesTransport, KlapTransport, KlapTransportV2, _XorTransport] + "transport_class", + [AesTransport, KlapTransport, KlapTransportV2, _XorTransport, XorTransport], ) async def test_transport_credentials_hash(mocker, transport_class): host = "127.0.0.1" @@ -391,3 +504,79 @@ async def test_transport_credentials_hash(mocker, transport_class): transport = transport_class(config=config) assert transport.credentials_hash == credentials_hash + + +@pytest.mark.parametrize( + "error, retry_expectation", + [ + (ConnectionRefusedError("dummy exception"), False), + (OSError(errno.EHOSTDOWN, os.strerror(errno.EHOSTDOWN)), False), + (OSError(errno.ECONNRESET, os.strerror(errno.ECONNRESET)), True), + (Exception("dummy exception"), True), + ], + ids=("ConnectionRefusedError", "OSErrorNoRetry", "OSErrorRetry", "Exception"), +) +@pytest.mark.parametrize( + "protocol_class, transport_class", + [ + (TPLinkSmartHomeProtocol, _XorTransport), + (IotProtocol, XorTransport), + ], + ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_will_retry_on_connect( + mocker, protocol_class, transport_class, error, retry_expectation +): + retry_count = 2 + conn = mocker.patch("asyncio.open_connection", side_effect=error) + config = DeviceConfig("127.0.0.1") + with pytest.raises(SmartDeviceException): + await protocol_class(transport=transport_class(config=config)).query( + {}, retry_count=retry_count + ) + + assert conn.call_count == (retry_count + 1 if retry_expectation else 1) + + +@pytest.mark.parametrize( + "error, retry_expectation", + [ + (ConnectionRefusedError("dummy exception"), True), + (OSError(errno.EHOSTDOWN, os.strerror(errno.EHOSTDOWN)), True), + (OSError(errno.ECONNRESET, os.strerror(errno.ECONNRESET)), True), + (Exception("dummy exception"), True), + ], + ids=("ConnectionRefusedError", "OSErrorNoRetry", "OSErrorRetry", "Exception"), +) +@pytest.mark.parametrize( + "protocol_class, transport_class", + [ + (TPLinkSmartHomeProtocol, _XorTransport), + (IotProtocol, XorTransport), + ], + ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_will_retry_on_write( + mocker, protocol_class, transport_class, error, retry_expectation +): + retry_count = 2 + writer = mocker.patch("asyncio.StreamWriter") + write_mock = mocker.patch.object(writer, "write", side_effect=error) + + def aio_mock_writer(_, __): + nonlocal writer + reader = mocker.patch("asyncio.StreamReader") + + return reader, writer + + conn = mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + write_mock = mocker.patch("asyncio.StreamWriter.write", side_effect=error) + config = DeviceConfig("127.0.0.1") + with pytest.raises(SmartDeviceException): + await protocol_class(transport=transport_class(config=config)).query( + {}, retry_count=retry_count + ) + + expected_call_count = retry_count + 1 if retry_expectation else 1 + assert conn.call_count == expected_call_count + assert write_mock.call_count == expected_call_count diff --git a/kasa/xortransport.py b/kasa/xortransport.py new file mode 100644 index 000000000..bed62ea81 --- /dev/null +++ b/kasa/xortransport.py @@ -0,0 +1,228 @@ +"""Module for the XorTransport.""" +import asyncio +import contextlib +import errno +import logging +import socket +import struct +from pprint import pformat as pf +from typing import Dict, Generator, Optional + +# 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 .deviceconfig import DeviceConfig +from .exceptions import RetryableException, SmartDeviceException +from .json import loads as json_loads +from .protocol import BaseTransport + +_LOGGER = logging.getLogger(__name__) +_NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} +_UNSIGNED_INT_NETWORK_ORDER = struct.Struct(">I") + + +class XorTransport(BaseTransport): + """Implementation of the Xor encryption transport. + + WIP, currently only to ensure consistent __init__ method signatures + for protocol classes. Will eventually incorporate the logic from + TPLinkSmartHomeProtocol to simplify the API and re-use the IotProtocol + class. + """ + + DEFAULT_PORT: int = 9999 + BLOCK_SIZE = 4 + + def __init__(self, *, config: DeviceConfig) -> None: + super().__init__(config=config) + self.reader: Optional[asyncio.StreamReader] = None + self.writer: Optional[asyncio.StreamWriter] = None + self.query_lock = asyncio.Lock() + self.loop: Optional[asyncio.AbstractEventLoop] = None + + @property + def default_port(self): + """Default port for the transport.""" + return self.DEFAULT_PORT + + @property + def credentials_hash(self) -> str: + """The hashed credentials used by the transport.""" + return "" + + async def _connect(self, timeout: int) -> None: + """Try to connect or reconnect to the device.""" + if self.writer: + return + self.reader = self.writer = None + + task = asyncio.open_connection(self._host, self._port) + async with asyncio_timeout(timeout): + self.reader, self.writer = await task + sock: socket.socket = self.writer.get_extra_info("socket") + # Ensure our packets get sent without delay as we do all + # our writes in a single go and we do not want any buffering + # which would needlessly delay the request or risk overloading + # the buffer on the device + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + + async def _execute_send(self, request: str) -> Dict: + """Execute a query on the device and wait for the response.""" + assert self.writer is not None # noqa: S101 + assert self.reader is not None # noqa: S101 + debug_log = _LOGGER.isEnabledFor(logging.DEBUG) + if debug_log: + _LOGGER.debug("%s >> %s", self._host, request) + self.writer.write(XorEncryption.encrypt(request)) + await self.writer.drain() + + packed_block_size = await self.reader.readexactly(self.BLOCK_SIZE) + length = _UNSIGNED_INT_NETWORK_ORDER.unpack(packed_block_size)[0] + + buffer = await self.reader.readexactly(length) + response = XorEncryption.decrypt(buffer) + json_payload = json_loads(response) + if debug_log: + _LOGGER.debug("%s << %s", self._host, pf(json_payload)) + + return json_payload + + async def close(self) -> None: + """Close the connection.""" + writer = self.writer + self.close_without_wait() + if writer: + with contextlib.suppress(Exception): + await writer.wait_closed() + + def close_without_wait(self) -> None: + """Close the connection without waiting for the connection to close.""" + writer = self.writer + self.reader = self.writer = None + if writer: + writer.close() + + async def reset(self) -> None: + """Reset the transport. + + The transport cannot be reset so we must close instead. + """ + await self.close() + + async def send(self, request: str) -> Dict: + """Send a message to the device and return a response.""" + # + # Most of the time we will already be connected if the device is online + # and the connect call will do nothing and return right away + # + # However, if we get an unrecoverable error (_NO_RETRY_ERRORS and + # ConnectionRefusedError) we do not want to keep trying since many + # connection open/close operations in the same time frame can block + # the event loop. + # This is especially import when there are multiple tplink devices being polled. + try: + await self._connect(self._timeout) + except ConnectionRefusedError as ex: + 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.reset() + if ex.errno in _NO_RETRY_ERRORS: + raise SmartDeviceException( + f"Unable to connect to the device:" + f" {self._host}:{self._port}: {ex}" + ) from ex + else: + raise RetryableException( + f"Unable to connect to the device:" + f" {self._host}:{self._port}: {ex}" + ) from ex + except Exception as ex: + await self.reset() + raise RetryableException( + f"Unable to connect to the device:" f" {self._host}:{self._port}: {ex}" + ) from ex + except BaseException: + # Likely something cancelled the task so we need to close the connection + # as we are not in an indeterminate state + self.close_without_wait() + raise + + try: + assert self.reader is not None # noqa: S101 + assert self.writer is not None # noqa: S101 + async with asyncio_timeout(self._timeout): + return await self._execute_send(request) + except Exception as ex: + await self.reset() + raise RetryableException( + f"Unable to query the device {self._host}:{self._port}: {ex}" + ) from ex + except BaseException: + # Likely something cancelled the task so we need to close the connection + # as we are not in an indeterminate state + self.close_without_wait() + raise + + def __del__(self) -> None: + if self.writer and self.loop and self.loop.is_running(): + # Since __del__ will be called when python does + # garbage collection is can happen in the event loop thread + # 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) + + +class XorEncryption: + """XorEncryption class.""" + + INITIALIZATION_VECTOR = 171 + + @staticmethod + def _xor_payload(unencrypted: bytes) -> Generator[int, None, None]: + key = XorEncryption.INITIALIZATION_VECTOR + for unencryptedbyte in unencrypted: + key = key ^ unencryptedbyte + yield key + + @staticmethod + def encrypt(request: str) -> bytes: + """Encrypt a request for a TP-Link Smart Home Device. + + :param request: plaintext request data + :return: ciphertext to be send over wire, in bytes + """ + plainbytes = request.encode() + return _UNSIGNED_INT_NETWORK_ORDER.pack(len(plainbytes)) + bytes( + XorEncryption._xor_payload(plainbytes) + ) + + @staticmethod + def _xor_encrypted_payload(ciphertext: bytes) -> Generator[int, None, None]: + key = XorEncryption.INITIALIZATION_VECTOR + for cipherbyte in ciphertext: + plainbyte = key ^ cipherbyte + key = cipherbyte + yield plainbyte + + @staticmethod + def decrypt(ciphertext: bytes) -> str: + """Decrypt a response of a TP-Link Smart Home Device. + + :param ciphertext: encrypted response data + :return: plaintext response + """ + return bytes(XorEncryption._xor_encrypted_payload(ciphertext)).decode() + + +# Try to load the kasa_crypt module and if it is available +try: + from kasa_crypt import decrypt, encrypt + + XorEncryption.decrypt = decrypt # type: ignore[method-assign] + XorEncryption.encrypt = encrypt # type: ignore[method-assign] +except ImportError: + pass From 0d0f56414caf78f623f72eb168deebcc3dddd344 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 26 Jan 2024 09:11:31 +0000 Subject: [PATCH 293/892] Switch from TPLinkSmartHomeProtocol to IotProtocol/XorTransport (#710) * Switch from TPLinkSmartHomeProtocol to IotProtocol/XorTransport * Add test * Update docs * Fix ruff deleting deprecated import --- devtools/parse_pcap.py | 4 +- docs/source/design.rst | 11 +- kasa/__init__.py | 18 +- kasa/device_factory.py | 9 +- kasa/discover.py | 6 +- kasa/iotprotocol.py | 44 ++++- kasa/protocol.py | 275 +----------------------------- kasa/smartdevice.py | 8 +- kasa/tests/conftest.py | 5 +- kasa/tests/newfakes.py | 8 +- kasa/tests/test_cli.py | 1 - kasa/tests/test_device_factory.py | 5 - kasa/tests/test_discovery.py | 14 +- kasa/tests/test_protocol.py | 97 +++++++---- kasa/xortransport.py | 20 ++- 15 files changed, 171 insertions(+), 354 deletions(-) diff --git a/devtools/parse_pcap.py b/devtools/parse_pcap.py index 5e7416238..7a55bf545 100644 --- a/devtools/parse_pcap.py +++ b/devtools/parse_pcap.py @@ -9,7 +9,7 @@ from dpkt.ethernet import ETH_TYPE_IP, Ethernet from kasa.cli import echo -from kasa.protocol import TPLinkSmartHomeProtocol +from kasa.xortransport import XorEncryption def read_payloads_from_file(file): @@ -34,7 +34,7 @@ def read_payloads_from_file(file): data = transport.data try: - decrypted = TPLinkSmartHomeProtocol.decrypt(data[4:]) + decrypted = XorEncryption.decrypt(data[4:]) except Exception as ex: echo(f"[red]Unable to decrypt the data, ignoring: {ex}[/red]") continue diff --git a/docs/source/design.rst b/docs/source/design.rst index 419c60569..4741f5e62 100644 --- a/docs/source/design.rst +++ b/docs/source/design.rst @@ -86,7 +86,7 @@ Also in 2023 TP-Link started releasing newer Kasa branded devices using the ``SM 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 ` +In order to support these different configurations the library migrated from a single protocol class ``TPLinkSmartHomeProtocol`` to support pluggable transports and protocols. The classes providing this functionality are: @@ -95,6 +95,7 @@ The classes providing this functionality are: - :class:`SmartProtocol ` - :class:`BaseTransport ` +- :class:`XorTransport ` - :class:`AesTransport ` - :class:`KlapTransport ` - :class:`KlapTransportV2 ` @@ -134,22 +135,22 @@ API documentation for protocols and transports :inherited-members: :undoc-members: -.. autoclass:: kasa.klaptransport.KlapTransport +.. autoclass:: kasa.xortransport.XorTransport :members: :inherited-members: :undoc-members: -.. autoclass:: kasa.klaptransport.KlapTransportV2 +.. autoclass:: kasa.klaptransport.KlapTransport :members: :inherited-members: :undoc-members: -.. autoclass:: kasa.aestransport.AesTransport +.. autoclass:: kasa.klaptransport.KlapTransportV2 :members: :inherited-members: :undoc-members: -.. autoclass:: kasa.protocol.TPLinkSmartHomeProtocol +.. autoclass:: kasa.aestransport.AesTransport :members: :inherited-members: :undoc-members: diff --git a/kasa/__init__.py b/kasa/__init__.py index a8101ae3e..121413b67 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -12,6 +12,7 @@ to be handled by the user of the library. """ from importlib.metadata import version +from warnings import warn from kasa.credentials import Credentials from kasa.deviceconfig import ( @@ -28,8 +29,11 @@ TimeoutException, UnsupportedDeviceException, ) -from kasa.iotprotocol import IotProtocol -from kasa.protocol import BaseProtocol, TPLinkSmartHomeProtocol +from kasa.iotprotocol import ( + IotProtocol, + _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 +) +from kasa.protocol import BaseProtocol from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.smartdevice import DeviceType, SmartDevice from kasa.smartdimmer import SmartDimmer @@ -43,7 +47,6 @@ __all__ = [ "Discover", - "TPLinkSmartHomeProtocol", "BaseProtocol", "IotProtocol", "SmartProtocol", @@ -68,3 +71,12 @@ "EncryptType", "DeviceFamilyType", ] + +deprecated_names = ["TPLinkSmartHomeProtocol"] + + +def __getattr__(name): + if name in deprecated_names: + warn(f"{name} is deprecated", DeprecationWarning, stacklevel=1) + return globals()[f"_deprecated_{name}"] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/kasa/device_factory.py b/kasa/device_factory.py index d216e0ef9..fdb5b1b49 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -11,8 +11,6 @@ from .protocol import ( BaseProtocol, BaseTransport, - TPLinkSmartHomeProtocol, - _XorTransport, ) from .smartbulb import SmartBulb from .smartdevice import SmartDevice @@ -22,6 +20,7 @@ from .smartprotocol import SmartProtocol from .smartstrip import SmartStrip from .tapo import TapoBulb, TapoPlug +from .xortransport import XorTransport _LOGGER = logging.getLogger(__name__) @@ -76,7 +75,9 @@ def _perf_log(has_params, perf_type): device_class: Optional[Type[SmartDevice]] - if isinstance(protocol, TPLinkSmartHomeProtocol): + if isinstance(protocol, IotProtocol) and isinstance( + protocol._transport, XorTransport + ): info = await protocol.query(GET_SYSINFO_QUERY) _perf_log(True, "get_sysinfo") device_class = get_device_class_from_sys_info(info) @@ -151,7 +152,7 @@ def get_protocol( supported_device_protocols: Dict[ str, Tuple[Type[BaseProtocol], Type[BaseTransport]] ] = { - "IOT.XOR": (TPLinkSmartHomeProtocol, _XorTransport), + "IOT.XOR": (IotProtocol, XorTransport), "IOT.KLAP": (IotProtocol, KlapTransport), "SMART.AES": (SmartProtocol, AesTransport), "SMART.KLAP": (SmartProtocol, KlapTransportV2), diff --git a/kasa/discover.py b/kasa/discover.py index 8b58d4bd1..8286387ae 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -25,8 +25,8 @@ from kasa.exceptions import TimeoutException, UnsupportedDeviceException from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads -from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartdevice import SmartDevice, SmartDeviceException +from kasa.xortransport import XorEncryption _LOGGER = logging.getLogger(__name__) @@ -103,7 +103,7 @@ 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) + encrypted_req = XorEncryption.encrypt(req) 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 @@ -400,7 +400,7 @@ def _get_device_class(info: dict) -> Type[SmartDevice]: def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> SmartDevice: """Get SmartDevice from legacy 9999 response.""" try: - info = json_loads(TPLinkSmartHomeProtocol.decrypt(data)) + info = json_loads(XorEncryption.decrypt(data)) except Exception as ex: raise SmartDeviceException( f"Unable to read response from device: {config.host}: {ex}" diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index ed926101c..f74e56f48 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -1,8 +1,9 @@ """Module for the IOT legacy IOT KASA protocol.""" import asyncio import logging -from typing import Dict, Union +from typing import Dict, Optional, Union +from .deviceconfig import DeviceConfig from .exceptions import ( AuthenticationException, ConnectionException, @@ -12,6 +13,7 @@ ) from .json import dumps as json_dumps from .protocol import BaseProtocol, BaseTransport +from .xortransport import XorEncryption, XorTransport _LOGGER = logging.getLogger(__name__) @@ -86,3 +88,43 @@ async def _execute_query(self, request: str, retry_count: int) -> Dict: async def close(self) -> None: """Close the underlying transport.""" await self._transport.close() + + +class _deprecated_TPLinkSmartHomeProtocol(IotProtocol): + def __init__( + self, + host: Optional[str] = None, + *, + port: Optional[int] = None, + timeout: Optional[int] = None, + transport: Optional[BaseTransport] = None, + ) -> None: + """Create a protocol object.""" + if not host and not transport: + raise SmartDeviceException("host or transport must be supplied") + if not transport: + config = DeviceConfig( + host=host, # type: ignore[arg-type] + port_override=port, + timeout=timeout or XorTransport.DEFAULT_TIMEOUT, + ) + transport = XorTransport(config=config) + super().__init__(transport=transport) + + @staticmethod + def encrypt(request: str) -> bytes: + """Encrypt a request for a TP-Link Smart Home Device. + + :param request: plaintext request data + :return: ciphertext to be send over wire, in bytes + """ + return XorEncryption.encrypt(request) + + @staticmethod + def decrypt(ciphertext: bytes) -> str: + """Decrypt a response of a TP-Link Smart Home Device. + + :param ciphertext: encrypted response data + :return: plaintext response + """ + return XorEncryption.decrypt(ciphertext) diff --git a/kasa/protocol.py b/kasa/protocol.py index b7ef3dea9..60b3d7ca6 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -9,27 +9,19 @@ which are licensed under the Apache License, Version 2.0 http://www.apache.org/licenses/LICENSE-2.0 """ -import asyncio import base64 -import contextlib import errno import logging -import socket import struct from abc import ABC, abstractmethod -from pprint import pformat as pf -from typing import Dict, Generator, Optional, Tuple, Union +from typing import Dict, 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 -from .json import loads as json_loads _LOGGER = logging.getLogger(__name__) _NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} @@ -114,262 +106,6 @@ async def close(self) -> None: """Close the protocol. Abstract method to be overriden.""" -class _XorTransport(BaseTransport): - """Implementation of the Xor encryption transport. - - WIP, currently only to ensure consistent __init__ method signatures - for protocol classes. Will eventually incorporate the logic from - TPLinkSmartHomeProtocol to simplify the API and re-use the IotProtocol - class. - """ - - DEFAULT_PORT: int = 9999 - BLOCK_SIZE = 4 - - def __init__(self, *, config: DeviceConfig) -> None: - super().__init__(config=config) - - @property - def default_port(self): - """Default port for the transport.""" - return self.DEFAULT_PORT - - @property - def credentials_hash(self) -> str: - """The hashed credentials used by the transport.""" - return "" - - async def send(self, request: str) -> Dict: - """Send a message to the device and return a response.""" - return {} - - async def close(self) -> None: - """Close the transport.""" - - async def reset(self) -> None: - """Reset internal state..""" - - -class TPLinkSmartHomeProtocol(BaseProtocol): - """Implementation of the TP-Link Smart Home protocol.""" - - INITIALIZATION_VECTOR = 171 - DEFAULT_PORT = 9999 - BLOCK_SIZE = 4 - - def __init__( - self, - *, - transport: BaseTransport, - ) -> None: - """Create a protocol object.""" - super().__init__(transport=transport) - - self.reader: Optional[asyncio.StreamReader] = None - self.writer: Optional[asyncio.StreamWriter] = None - self.query_lock = asyncio.Lock() - self.loop: Optional[asyncio.AbstractEventLoop] = None - - self._timeout = self._transport._timeout - self._port = self._transport._port - - async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: - """Request information from a TP-Link SmartHome Device. - - :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) - :param retry_count: how many retries to do in case of failure - :return: response dict - """ - if isinstance(request, dict): - request = json_dumps(request) - assert isinstance(request, str) # noqa: S101 - - async with self.query_lock: - return await self._query(request, retry_count, self._timeout) # type: ignore[arg-type] - - async def _connect(self, timeout: int) -> None: - """Try to connect or reconnect to the device.""" - if self.writer: - return - self.reader = self.writer = None - - task = asyncio.open_connection(self._host, self._port) - async with asyncio_timeout(timeout): - self.reader, self.writer = await task - sock: socket.socket = self.writer.get_extra_info("socket") - # Ensure our packets get sent without delay as we do all - # our writes in a single go and we do not want any buffering - # which would needlessly delay the request or risk overloading - # the buffer on the device - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - - async def _execute_query(self, request: str) -> Dict: - """Execute a query on the device and wait for the response.""" - assert self.writer is not None # noqa: S101 - assert self.reader is not None # noqa: S101 - debug_log = _LOGGER.isEnabledFor(logging.DEBUG) - if debug_log: - _LOGGER.debug("%s >> %s", self._host, request) - self.writer.write(TPLinkSmartHomeProtocol.encrypt(request)) - await self.writer.drain() - - packed_block_size = await self.reader.readexactly(self.BLOCK_SIZE) - length = _UNSIGNED_INT_NETWORK_ORDER.unpack(packed_block_size)[0] - - buffer = await self.reader.readexactly(length) - response = TPLinkSmartHomeProtocol.decrypt(buffer) - json_payload = json_loads(response) - if debug_log: - _LOGGER.debug("%s << %s", self._host, pf(json_payload)) - - return json_payload - - async def close(self) -> None: - """Close the connection.""" - writer = self.writer - self.close_without_wait() - if writer: - with contextlib.suppress(Exception): - await writer.wait_closed() - - def close_without_wait(self) -> None: - """Close the connection without waiting for the connection to close.""" - writer = self.writer - self.reader = self.writer = None - if writer: - writer.close() - - 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.""" - # - # Most of the time we will already be connected if the device is online - # and the connect call will do nothing and return right away - # - # However, if we get an unrecoverable error (_NO_RETRY_ERRORS and - # ConnectionRefusedError) we do not want to keep trying since many - # connection open/close operations in the same time frame can block - # the event loop. - # This is especially import when there are multiple tplink devices being polled. - for retry in range(retry_count + 1): - try: - await self._connect(timeout) - except ConnectionRefusedError as ex: - 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.reset() - if ex.errno in _NO_RETRY_ERRORS or retry >= retry_count: - raise SmartDeviceException( - f"Unable to connect to the device:" - f" {self._host}:{self._port}: {ex}" - ) from ex - continue - except Exception as ex: - await self.reset() - if retry >= retry_count: - _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise SmartDeviceException( - f"Unable to connect to the device:" - f" {self._host}:{self._port}: {ex}" - ) from ex - continue - except BaseException as ex: - # Likely something cancelled the task so we need to close the connection - # as we are not in an indeterminate state - self.close_without_wait() - _LOGGER.debug( - "%s: BaseException during connect, closing connection: %s", - self._host, - ex, - ) - raise - - try: - assert self.reader is not None # noqa: S101 - assert self.writer is not None # noqa: S101 - async with asyncio_timeout(timeout): - return await self._execute_query(request) - except Exception as ex: - await self.reset() - if retry >= retry_count: - _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise SmartDeviceException( - f"Unable to query the device {self._host}:{self._port}: {ex}" - ) from ex - - _LOGGER.debug( - "Unable to query the device %s, retrying: %s", self._host, ex - ) - except BaseException as ex: - # Likely something cancelled the task so we need to close the connection - # as we are not in an indeterminate state - self.close_without_wait() - _LOGGER.debug( - "%s: BaseException during query, closing connection: %s", - self._host, - ex, - ) - raise - - # make mypy happy, this should never be reached.. - await self.reset() - raise SmartDeviceException("Query reached somehow to unreachable") - - def __del__(self) -> None: - if self.writer and self.loop and self.loop.is_running(): - # Since __del__ will be called when python does - # garbage collection is can happen in the event loop thread - # 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) - - @staticmethod - def _xor_payload(unencrypted: bytes) -> Generator[int, None, None]: - key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR - for unencryptedbyte in unencrypted: - key = key ^ unencryptedbyte - yield key - - @staticmethod - def encrypt(request: str) -> bytes: - """Encrypt a request for a TP-Link Smart Home Device. - - :param request: plaintext request data - :return: ciphertext to be send over wire, in bytes - """ - plainbytes = request.encode() - return _UNSIGNED_INT_NETWORK_ORDER.pack(len(plainbytes)) + bytes( - TPLinkSmartHomeProtocol._xor_payload(plainbytes) - ) - - @staticmethod - def _xor_encrypted_payload(ciphertext: bytes) -> Generator[int, None, None]: - key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR - for cipherbyte in ciphertext: - plainbyte = key ^ cipherbyte - key = cipherbyte - yield plainbyte - - @staticmethod - def decrypt(ciphertext: bytes) -> str: - """Decrypt a response of a TP-Link Smart Home Device. - - :param ciphertext: encrypted response data - :return: plaintext response - """ - return bytes( - TPLinkSmartHomeProtocol._xor_encrypted_payload(ciphertext) - ).decode() - - def get_default_credentials(tuple: Tuple[str, str]) -> Credentials: """Return decoded default credentials.""" un = base64.b64decode(tuple[0].encode()).decode() @@ -381,12 +117,3 @@ def get_default_credentials(tuple: Tuple[str, str]) -> 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 - - TPLinkSmartHomeProtocol.decrypt = decrypt # type: ignore[method-assign] - TPLinkSmartHomeProtocol.encrypt = encrypt # type: ignore[method-assign] -except ImportError: - pass diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 31418afcc..01ca382dc 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -24,8 +24,10 @@ from .deviceconfig import DeviceConfig from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException +from .iotprotocol import IotProtocol from .modules import Emeter, Module -from .protocol import BaseProtocol, TPLinkSmartHomeProtocol, _XorTransport +from .protocol import BaseProtocol +from .xortransport import XorTransport _LOGGER = logging.getLogger(__name__) @@ -204,8 +206,8 @@ def __init__( """ if config and protocol: protocol._transport._config = config - self.protocol: BaseProtocol = protocol or TPLinkSmartHomeProtocol( - transport=_XorTransport(config=config or DeviceConfig(host=host)), + self.protocol: BaseProtocol = protocol or IotProtocol( + transport=XorTransport(config=config or DeviceConfig(host=host)), ) _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) self._device_type = DeviceType.Unknown diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 9b5731866..24bc3372b 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -20,9 +20,9 @@ SmartLightStrip, SmartPlug, SmartStrip, - TPLinkSmartHomeProtocol, ) from kasa.tapo import TapoBulb, TapoDevice, TapoPlug +from kasa.xortransport import XorEncryption from .newfakes import FakeSmartProtocol, FakeTransportProtocol @@ -478,7 +478,7 @@ class _DiscoveryMock: device_type = sys_info.get("mic_type") or sys_info.get("type") encrypt_type = "XOR" login_version = None - datagram = TPLinkSmartHomeProtocol.encrypt(json_dumps(discovery_data))[4:] + datagram = XorEncryption.encrypt(json_dumps(discovery_data))[4:] dm = _DiscoveryMock( "127.0.0.123", 9999, @@ -517,7 +517,6 @@ async def _query(request, retry_count: int = 3): mocker.patch("kasa.IotProtocol.query", side_effect=_query) mocker.patch("kasa.SmartProtocol.query", side_effect=_query) - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=_query) yield dm diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 625a4994c..aa3d42bef 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -19,8 +19,10 @@ from ..credentials import Credentials from ..deviceconfig import DeviceConfig from ..exceptions import SmartDeviceException -from ..protocol import BaseTransport, TPLinkSmartHomeProtocol, _XorTransport +from ..iotprotocol import IotProtocol +from ..protocol import BaseTransport from ..smartprotocol import SmartProtocol +from ..xortransport import XorTransport _LOGGER = logging.getLogger(__name__) @@ -381,10 +383,10 @@ async def reset(self) -> None: pass -class FakeTransportProtocol(TPLinkSmartHomeProtocol): +class FakeTransportProtocol(IotProtocol): def __init__(self, info): super().__init__( - transport=_XorTransport( + transport=XorTransport( config=DeviceConfig("127.0.0.123"), ) ) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index fa2d5c69e..14dbb4bdb 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -11,7 +11,6 @@ EmeterStatus, SmartDevice, SmartDeviceException, - TPLinkSmartHomeProtocol, UnsupportedDeviceException, ) from kasa.cli import ( diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index 8e3e2ed60..9a068cd99 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -54,7 +54,6 @@ async def test_connect( mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data) config = DeviceConfig( host=host, credentials=Credentials("foor", "bar"), connection_type=ctype @@ -87,7 +86,6 @@ async def test_connect_custom_port(all_fixture_data: dict, mocker, custom_port): default_port = 80 if "discovery_result" in all_fixture_data else 9999 ctype, _ = _get_connection_type_device_class(all_fixture_data) - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data) mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) dev = await connect(config=config) @@ -102,7 +100,6 @@ async def test_connect_logs_connect_time( ctype, _ = _get_connection_type_device_class(all_fixture_data) mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data) host = "127.0.0.1" config = DeviceConfig( @@ -118,7 +115,6 @@ async def test_connect_logs_connect_time( async def test_connect_query_fails(all_fixture_data: dict, mocker): """Make sure that connect fails when query fails.""" host = "127.0.0.1" - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=SmartDeviceException) mocker.patch("kasa.IotProtocol.query", side_effect=SmartDeviceException) mocker.patch("kasa.SmartProtocol.query", side_effect=SmartDeviceException) @@ -138,7 +134,6 @@ async def test_connect_http_client(all_fixture_data, mocker): mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data) http_client = aiohttp.ClientSession() diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 2916e60ad..db4d8fc1c 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -15,7 +15,6 @@ Discover, SmartDevice, SmartDeviceException, - TPLinkSmartHomeProtocol, protocol, ) from kasa.deviceconfig import ( @@ -26,6 +25,7 @@ ) from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps from kasa.exceptions import AuthenticationException, UnsupportedDeviceException +from kasa.xortransport import XorEncryption from .conftest import bulb, bulb_iot, dimmer, lightstrip, new_discovery, plug, strip @@ -189,7 +189,7 @@ async def test_discover_invalid_info(msg, data, mocker): def mock_discover(self): self.datagram_received( - protocol.TPLinkSmartHomeProtocol.encrypt(json_dumps(data))[4:], (host, 9999) + XorEncryption.encrypt(json_dumps(data))[4:], (host, 9999) ) mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) @@ -212,7 +212,7 @@ async def test_discover_datagram_received(mocker, discovery_data): """Verify that datagram received fills discovered_devices.""" proto = _DiscoverProtocol() - mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "decrypt") + mocker.patch.object(XorEncryption, "decrypt") addr = "127.0.0.1" port = 20002 if "result" in discovery_data else 9999 @@ -238,8 +238,8 @@ async def test_discover_invalid_responses(msg, data, mocker): """Verify that we don't crash whole discovery if some devices in the network are sending unexpected data.""" proto = _DiscoverProtocol() mocker.patch("kasa.discover.json_loads", return_value=data) - mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "encrypt") - mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "decrypt") + mocker.patch.object(XorEncryption, "encrypt") + mocker.patch.object(XorEncryption, "decrypt") proto.datagram_received(data, ("127.0.0.1", 9999)) assert len(proto.discovered_devices) == 0 @@ -375,9 +375,7 @@ def __init__(self, dp, port, do_not_reply_count, unsupported=False): 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:] + self.datagram = XorEncryption.encrypt(json_dumps(LEGACY_DISCOVER_DATA))[4:] elif port == 20002: discovery_data = UNSUPPORTED if unsupported else AUTHENTICATION_DATA_KLAP self.datagram = ( diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 34f2507e1..e71f42969 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -15,13 +15,11 @@ from ..credentials import Credentials from ..deviceconfig import DeviceConfig from ..exceptions import SmartDeviceException -from ..iotprotocol import IotProtocol +from ..iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol from ..klaptransport import KlapTransport, KlapTransportV2 from ..protocol import ( BaseProtocol, BaseTransport, - TPLinkSmartHomeProtocol, - _XorTransport, ) from ..xortransport import XorEncryption, XorTransport @@ -29,10 +27,10 @@ @pytest.mark.parametrize( "protocol_class, transport_class", [ - (TPLinkSmartHomeProtocol, _XorTransport), + (_deprecated_TPLinkSmartHomeProtocol, XorTransport), (IotProtocol, XorTransport), ], - ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) @pytest.mark.parametrize("retry_count", [1, 3, 5]) async def test_protocol_retries(mocker, retry_count, protocol_class, transport_class): @@ -59,10 +57,10 @@ def aio_mock_writer(_, __): @pytest.mark.parametrize( "protocol_class, transport_class", [ - (TPLinkSmartHomeProtocol, _XorTransport), + (_deprecated_TPLinkSmartHomeProtocol, XorTransport), (IotProtocol, XorTransport), ], - ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) async def test_protocol_no_retry_on_unreachable( mocker, protocol_class, transport_class @@ -83,10 +81,10 @@ async def test_protocol_no_retry_on_unreachable( @pytest.mark.parametrize( "protocol_class, transport_class", [ - (TPLinkSmartHomeProtocol, _XorTransport), + (_deprecated_TPLinkSmartHomeProtocol, XorTransport), (IotProtocol, XorTransport), ], - ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) async def test_protocol_no_retry_connection_refused( mocker, protocol_class, transport_class @@ -107,10 +105,10 @@ async def test_protocol_no_retry_connection_refused( @pytest.mark.parametrize( "protocol_class, transport_class", [ - (TPLinkSmartHomeProtocol, _XorTransport), + (_deprecated_TPLinkSmartHomeProtocol, XorTransport), (IotProtocol, XorTransport), ], - ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) async def test_protocol_retry_recoverable_error( mocker, protocol_class, transport_class @@ -131,10 +129,14 @@ async def test_protocol_retry_recoverable_error( @pytest.mark.parametrize( "protocol_class, transport_class, encryption_class", [ - (TPLinkSmartHomeProtocol, _XorTransport, TPLinkSmartHomeProtocol), + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), (IotProtocol, XorTransport, XorEncryption), ], - ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) @pytest.mark.parametrize("retry_count", [1, 3, 5]) async def test_protocol_reconnect( @@ -177,10 +179,14 @@ def aio_mock_writer(_, __): @pytest.mark.parametrize( "protocol_class, transport_class, encryption_class", [ - (TPLinkSmartHomeProtocol, _XorTransport, TPLinkSmartHomeProtocol), + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), (IotProtocol, XorTransport, XorEncryption), ], - ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) async def test_protocol_handles_cancellation_during_write( mocker, protocol_class, transport_class, encryption_class @@ -227,10 +233,14 @@ def aio_mock_writer(_, __): @pytest.mark.parametrize( "protocol_class, transport_class, encryption_class", [ - (TPLinkSmartHomeProtocol, _XorTransport, TPLinkSmartHomeProtocol), + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), (IotProtocol, XorTransport, XorEncryption), ], - ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) async def test_protocol_handles_cancellation_during_connection( mocker, protocol_class, transport_class, encryption_class @@ -275,10 +285,14 @@ def aio_mock_writer(_, __): @pytest.mark.parametrize( "protocol_class, transport_class, encryption_class", [ - (TPLinkSmartHomeProtocol, _XorTransport, TPLinkSmartHomeProtocol), + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), (IotProtocol, XorTransport, XorEncryption), ], - ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) @pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG]) async def test_protocol_logging( @@ -318,10 +332,14 @@ def aio_mock_writer(_, __): @pytest.mark.parametrize( "protocol_class, transport_class, encryption_class", [ - (TPLinkSmartHomeProtocol, _XorTransport, TPLinkSmartHomeProtocol), + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), (IotProtocol, XorTransport, XorEncryption), ], - ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) @pytest.mark.parametrize("custom_port", [123, None]) async def test_protocol_custom_port( @@ -358,11 +376,11 @@ def aio_mock_writer(_, port): @pytest.mark.parametrize( "encrypt_class", - [TPLinkSmartHomeProtocol, XorEncryption], + [_deprecated_TPLinkSmartHomeProtocol, XorEncryption], ) @pytest.mark.parametrize( "decrypt_class", - [TPLinkSmartHomeProtocol, XorEncryption], + [_deprecated_TPLinkSmartHomeProtocol, XorEncryption], ) def test_encrypt(encrypt_class, decrypt_class): d = json.dumps({"foo": 1, "bar": 2}) @@ -374,7 +392,7 @@ def test_encrypt(encrypt_class, decrypt_class): @pytest.mark.parametrize( "encrypt_class", - [TPLinkSmartHomeProtocol, XorEncryption], + [_deprecated_TPLinkSmartHomeProtocol, XorEncryption], ) def test_encrypt_unicode(encrypt_class): d = "{'snowman': '\u2603'}" @@ -411,7 +429,7 @@ def test_encrypt_unicode(encrypt_class): @pytest.mark.parametrize( "decrypt_class", - [TPLinkSmartHomeProtocol, XorEncryption], + [_deprecated_TPLinkSmartHomeProtocol, XorEncryption], ) def test_decrypt_unicode(decrypt_class): e = bytes( @@ -451,7 +469,11 @@ def _get_subclasses(of_class): importlib.import_module("." + modname, package="kasa") module = sys.modules["kasa." + modname] for name, obj in inspect.getmembers(module): - if inspect.isclass(obj) and issubclass(obj, of_class): + if ( + inspect.isclass(obj) + and issubclass(obj, of_class) + and name != "_deprecated_TPLinkSmartHomeProtocol" + ): subclasses.add((name, obj)) return subclasses @@ -491,7 +513,7 @@ def test_transport_init_signature(class_name_obj): @pytest.mark.parametrize( "transport_class", - [AesTransport, KlapTransport, KlapTransportV2, _XorTransport, XorTransport], + [AesTransport, KlapTransport, KlapTransportV2, XorTransport, XorTransport], ) async def test_transport_credentials_hash(mocker, transport_class): host = "127.0.0.1" @@ -519,10 +541,10 @@ async def test_transport_credentials_hash(mocker, transport_class): @pytest.mark.parametrize( "protocol_class, transport_class", [ - (TPLinkSmartHomeProtocol, _XorTransport), + (_deprecated_TPLinkSmartHomeProtocol, XorTransport), (IotProtocol, XorTransport), ], - ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) async def test_protocol_will_retry_on_connect( mocker, protocol_class, transport_class, error, retry_expectation @@ -551,10 +573,10 @@ async def test_protocol_will_retry_on_connect( @pytest.mark.parametrize( "protocol_class, transport_class", [ - (TPLinkSmartHomeProtocol, _XorTransport), + (_deprecated_TPLinkSmartHomeProtocol, XorTransport), (IotProtocol, XorTransport), ], - ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) async def test_protocol_will_retry_on_write( mocker, protocol_class, transport_class, error, retry_expectation @@ -580,3 +602,16 @@ def aio_mock_writer(_, __): expected_call_count = retry_count + 1 if retry_expectation else 1 assert conn.call_count == expected_call_count assert write_mock.call_count == expected_call_count + + +def test_deprecated_protocol(): + with pytest.deprecated_call(): + from kasa import TPLinkSmartHomeProtocol + + with pytest.raises( + SmartDeviceException, match="host or transport must be supplied" + ): + proto = TPLinkSmartHomeProtocol() + host = "127.0.0.1" + proto = TPLinkSmartHomeProtocol(host=host) + assert proto.config.host == host diff --git a/kasa/xortransport.py b/kasa/xortransport.py index bed62ea81..95e78c205 100644 --- a/kasa/xortransport.py +++ b/kasa/xortransport.py @@ -1,4 +1,14 @@ -"""Module for the XorTransport.""" +"""Implementation of the legacy TP-Link Smart Home Protocol. + +Encryption/Decryption methods based on the works of +Lubomir Stroetmann and Tobias Esser + +https://www.softscheck.com/en/reverse-engineering-tp-link-hs110/ +https://github.com/softScheck/tplink-smartplug/ + +which are licensed under the Apache License, Version 2.0 +http://www.apache.org/licenses/LICENSE-2.0 +""" import asyncio import contextlib import errno @@ -23,13 +33,7 @@ class XorTransport(BaseTransport): - """Implementation of the Xor encryption transport. - - WIP, currently only to ensure consistent __init__ method signatures - for protocol classes. Will eventually incorporate the logic from - TPLinkSmartHomeProtocol to simplify the API and re-use the IotProtocol - class. - """ + """XorTransport class.""" DEFAULT_PORT: int = 9999 BLOCK_SIZE = 4 From fcd4883645d53f8488d4f92508a74139271008ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Jan 2024 23:33:18 -1000 Subject: [PATCH 294/892] Use hashlib for klap since its faster (#711) --- kasa/klaptransport.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index cd0e3de6b..66052f590 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -467,13 +467,9 @@ def encrypt(self, msg): padder = padding.PKCS7(128).padder() padded_data = padder.update(msg) + padder.finalize() ciphertext = encryptor.update(padded_data) + encryptor.finalize() - - digest = hashes.Hash(hashes.SHA256()) - digest.update( + signature = hashlib.sha256( self._sig + self._seq.to_bytes(4, "big", signed=True) + ciphertext - ) - signature = digest.finalize() - + ).digest() return (signature + ciphertext, self._seq) def decrypt(self, msg): From dd38225f51c70222f3218b15083e410401d01337 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Jan 2024 06:57:56 -1000 Subject: [PATCH 295/892] Use hashlib in place of hashes.Hash (#714) --- kasa/klaptransport.py | 11 +++-------- kasa/protocol.py | 10 +++------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 66052f590..ac9243d1d 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -50,7 +50,7 @@ from pprint import pformat as pf from typing import Any, Dict, Optional, Tuple, cast -from cryptography.hazmat.primitives import hashes, padding +from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from .credentials import Credentials @@ -68,16 +68,11 @@ def _sha256(payload: bytes) -> bytes: - digest = hashes.Hash(hashes.SHA256()) # noqa: S303 - digest.update(payload) - hash = digest.finalize() - return hash + return hashlib.sha256(payload).digest() # noqa: S324 def _sha1(payload: bytes) -> bytes: - digest = hashes.Hash(hashes.SHA1()) # noqa: S303 - digest.update(payload) - return digest.finalize() + return hashlib.sha1(payload).digest() # noqa: S324 class KlapTransport(BaseTransport): diff --git a/kasa/protocol.py b/kasa/protocol.py index 60b3d7ca6..aa9e3cbea 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -11,6 +11,7 @@ """ import base64 import errno +import hashlib import logging import struct from abc import ABC, abstractmethod @@ -18,8 +19,6 @@ # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout -from cryptography.hazmat.primitives import hashes - from .credentials import Credentials from .deviceconfig import DeviceConfig @@ -29,11 +28,8 @@ def md5(payload: bytes) -> bytes: - """Return an md5 hash of the payload.""" - digest = hashes.Hash(hashes.MD5()) # noqa: S303 - digest.update(payload) - hash = digest.finalize() - return hash + """Return the MD5 hash of the payload.""" + return hashlib.md5(payload).digest() # noqa: S324 class BaseTransport(ABC): From 7e2be35e4b4a077fe2a828a507e3fc0a1a351fae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Jan 2024 07:44:41 -1000 Subject: [PATCH 296/892] Reduce the number of times creating the cipher in klap (#712) --- kasa/klaptransport.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index ac9243d1d..898444c2e 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -46,6 +46,7 @@ import hashlib import logging import secrets +import struct import time from pprint import pformat as pf from typing import Any, Dict, Optional, Tuple, cast @@ -66,6 +67,8 @@ ONE_DAY_SECONDS = 86400 SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20 +PACK_SIGNED_LONG = struct.Struct(">l").pack + def _sha256(payload: bytes) -> bytes: return hashlib.sha256(payload).digest() # noqa: S324 @@ -421,12 +424,15 @@ class KlapEncryptionSession: i.e. sequence number which the device expects to increment. """ + _cipher: Cipher + def __init__(self, local_seed, remote_seed, user_hash): self.local_seed = local_seed self.remote_seed = remote_seed self.user_hash = user_hash self._key = self._key_derive(local_seed, remote_seed, user_hash) (self._iv, self._seq) = self._iv_derive(local_seed, remote_seed, user_hash) + self._aes = algorithms.AES(self._key) self._sig = self._sig_derive(local_seed, remote_seed, user_hash) def _key_derive(self, local_seed, remote_seed, user_hash): @@ -446,31 +452,31 @@ def _sig_derive(self, local_seed, remote_seed, user_hash): payload = b"ldk" + local_seed + remote_seed + user_hash return hashlib.sha256(payload).digest()[:28] - def _iv_seq(self): - seq = self._seq.to_bytes(4, "big", signed=True) - iv = self._iv + seq - return iv + def _generate_cipher(self): + iv_seq = self._iv + PACK_SIGNED_LONG(self._seq) + cbc = modes.CBC(iv_seq) + self._cipher = Cipher(self._aes, cbc) def encrypt(self, msg): """Encrypt the data and increment the sequence number.""" - self._seq = self._seq + 1 + self._seq += 1 + self._generate_cipher() + if isinstance(msg, str): msg = msg.encode("utf-8") - cipher = Cipher(algorithms.AES(self._key), modes.CBC(self._iv_seq())) - encryptor = cipher.encryptor() + encryptor = self._cipher.encryptor() padder = padding.PKCS7(128).padder() padded_data = padder.update(msg) + padder.finalize() ciphertext = encryptor.update(padded_data) + encryptor.finalize() signature = hashlib.sha256( - self._sig + self._seq.to_bytes(4, "big", signed=True) + ciphertext + self._sig + PACK_SIGNED_LONG(self._seq) + ciphertext ).digest() return (signature + ciphertext, self._seq) def decrypt(self, msg): """Decrypt the data.""" - cipher = Cipher(algorithms.AES(self._key), modes.CBC(self._iv_seq())) - decryptor = cipher.decryptor() + decryptor = self._cipher.decryptor() dp = decryptor.update(msg[32:]) + decryptor.finalize() unpadder = padding.PKCS7(128).unpadder() plaintextbytes = unpadder.update(dp) + unpadder.finalize() From cedffc5c9ff8072019166f9acf113de05c23e283 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 29 Jan 2024 09:25:36 +0000 Subject: [PATCH 297/892] Update L510E(US) fixture with mac prefix (#722) --- .../fixtures/smart/L510E(US)_3.0_1.0.5.json | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) 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 index 15b85d085..9a51ea45b 100644 --- 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 @@ -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": "3C-52-A1-00-00-00", "mgt_encrypt_schm": { "encrypt_type": "AES", "http_port": 80, @@ -102,7 +102,7 @@ "rule_list": [] }, "get_auto_update_info": { - "enable": true, + "enable": false, "random_range": 120, "time": 180 }, @@ -136,10 +136,10 @@ "hw_id": "00000000000000000000000000000000", "hw_ver": "3.0", "ip": "127.0.0.123", - "lang": "", + "lang": "en_US", "latitude": 0, "longitude": 0, - "mac": "00-00-00-00-00-00", + "mac": "3C-52-A1-00-00-00", "model": "L510", "music_rhythm_enable": false, "music_rhythm_mode": "single_lamp", @@ -147,7 +147,7 @@ "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Pacific/Honolulu", - "rssi": -69, + "rssi": -65, "signal_level": 2, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", @@ -157,23 +157,23 @@ "get_device_time": { "region": "Pacific/Honolulu", "time_diff": -600, - "timestamp": 1706060153 + "timestamp": 1706121405 }, "get_device_usage": { "power_usage": { - "past30": 1, - "past7": 1, - "today": 1 + "past30": 5, + "past7": 5, + "today": 5 }, "saved_power": { - "past30": 4, - "past7": 4, - "today": 4 + "past30": 26, + "past7": 26, + "today": 26 }, "time_usage": { - "past30": 5, - "past7": 5, - "today": 5 + "past30": 31, + "past7": 31, + "today": 31 } }, "get_fw_download_state": { @@ -226,7 +226,7 @@ "channel": 0, "cipher_type": 2, "key_type": "wpa2_psk", - "signal_level": 3, + "signal_level": 2, "ssid": "I01BU0tFRF9TU0lEIw==" }, { @@ -253,6 +253,14 @@ "signal_level": 2, "ssid": "I01BU0tFRF9TU0lEIw==" }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, { "bssid": "000000000000", "channel": 0, From 9c0a83102736f2bd76fb5d1d017a75e0f4343b28 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 29 Jan 2024 10:55:54 +0000 Subject: [PATCH 298/892] Enable batching of multiple requests (#662) * Enable batching of multiple requests * Test for debug enabled outside of loop * tweaks * tweaks * tweaks * Update kasa/smartprotocol.py Co-authored-by: Teemu R. * revert * Update pyproject.toml * Add batch test and make batch_size configurable --------- Co-authored-by: J. Nick Koston Co-authored-by: Teemu R. --- kasa/deviceconfig.py | 2 + kasa/smartprotocol.py | 91 +++++++++++++++++++++----------- kasa/tests/test_smartprotocol.py | 36 ++++++++++++- pyproject.toml | 2 + 4 files changed, 100 insertions(+), 31 deletions(-) diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 77ce6df40..ffb2988e3 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -146,6 +146,8 @@ class DeviceConfig: #: 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. + batch_size: Optional[int] = None + #: The batch size for protoools supporting multiple request batches. connection_type: ConnectionType = field( default_factory=lambda: ConnectionType( DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Xor, 1 diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 6f0648ea0..9ec2547df 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -10,7 +10,7 @@ import time import uuid from pprint import pformat as pf -from typing import Dict, Union +from typing import Any, Dict, Union from .exceptions import ( SMART_AUTHENTICATION_ERRORS, @@ -33,6 +33,7 @@ class SmartProtocol(BaseProtocol): """Class for the new TPLink SMART protocol.""" BACKOFF_SECONDS_AFTER_TIMEOUT = 1 + DEFAULT_MULTI_REQUEST_BATCH_SIZE = 5 def __init__( self, @@ -101,51 +102,81 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: # make mypy happy, this should never be reached.. raise SmartDeviceException("Query reached somehow to unreachable") + async def _execute_multiple_query(self, request: Dict, retry_count: int) -> Dict: + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + multi_result: Dict[str, Any] = {} + smart_method = "multipleRequest" + requests = [ + {"method": method, "params": params} for method, params in request.items() + ] + + end = len(requests) + # Break the requests down as there can be a size limit + step = ( + self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE + ) + for i in range(0, end, step): + requests_step = requests[i : i + step] + + smart_params = {"requests": requests_step} + smart_request = self.get_smart_request(smart_method, smart_params) + if debug_enabled: + _LOGGER.debug( + "%s multi-request-batch-%s >> %s", + self._host, + i + 1, + pf(smart_request), + ) + response_step = await self._transport.send(smart_request) + if debug_enabled: + _LOGGER.debug( + "%s multi-request-batch-%s << %s", + self._host, + i + 1, + pf(response_step), + ) + self._handle_response_error_code(response_step) + responses = response_step["result"]["responses"] + for response in responses: + self._handle_response_error_code(response) + result = response.get("result", None) + multi_result[response["method"]] = result + return multi_result + async def _execute_query(self, request: Union[str, Dict], retry_count: int) -> Dict: + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + if isinstance(request, dict): if len(request) == 1: smart_method = next(iter(request)) smart_params = request[smart_method] else: - requests = [] - for method, params in request.items(): - requests.append({"method": method, "params": params}) - smart_method = "multipleRequest" - smart_params = {"requests": requests} + return await self._execute_multiple_query(request, retry_count) else: smart_method = request smart_params = None smart_request = self.get_smart_request(smart_method, smart_params) - _LOGGER.debug( - "%s >> %s", - self._host, - _LOGGER.isEnabledFor(logging.DEBUG) and pf(smart_request), - ) + if debug_enabled: + _LOGGER.debug( + "%s >> %s", + self._host, + pf(smart_request), + ) response_data = await self._transport.send(smart_request) - _LOGGER.debug( - "%s << %s", - self._host, - _LOGGER.isEnabledFor(logging.DEBUG) and pf(response_data), - ) + if debug_enabled: + _LOGGER.debug( + "%s << %s", + self._host, + pf(response_data), + ) self._handle_response_error_code(response_data) - if (result := response_data.get("result")) is None: - # Single set_ requests do not return a result - return {smart_method: None} - - if (responses := result.get("responses")) is None: - return {smart_method: result} - - # responses is returned for multipleRequest - multi_result = {} - for response in responses: - self._handle_response_error_code(response) - result = response.get("result", None) - multi_result[response["method"]] = result - return multi_result + # Single set_ requests do not return a result + result = response_data.get("result") + return {smart_method: result} def _handle_response_error_code(self, resp_dict: dict): error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index af2fce4c7..9b597b51f 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -24,6 +24,10 @@ from ..smartprotocol import SmartProtocol DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} +DUMMY_MULTIPLE_QUERY = { + "foobar": {"foo": "bar", "bar": "foo"}, + "barfoo": {"foo": "bar", "bar": "foo"}, +} ERRORS = [e for e in SmartErrorCode if e != 0] @@ -74,9 +78,39 @@ async def test_smart_device_errors_in_multiple_request(mocker, error_code): config = DeviceConfig(host, credentials=Credentials("foo", "bar")) protocol = SmartProtocol(transport=AesTransport(config=config)) with pytest.raises(SmartDeviceException): - await protocol.query(DUMMY_QUERY, retry_count=2) + await protocol.query(DUMMY_MULTIPLE_QUERY, retry_count=2) if error_code in chain(SMART_TIMEOUT_ERRORS, SMART_RETRYABLE_ERRORS): expected_calls = 3 else: expected_calls = 1 assert send_mock.call_count == expected_calls + + +@pytest.mark.parametrize("request_size", [1, 3, 5, 10]) +@pytest.mark.parametrize("batch_size", [1, 2, 3, 4, 5]) +async def test_smart_device_multiple_request(mocker, request_size, batch_size): + host = "127.0.0.1" + requests = {} + mock_response = { + "result": {"responses": []}, + "error_code": 0, + } + for i in range(request_size): + method = f"get_method_{i}" + requests[method] = {"foo": "bar", "bar": "foo"} + mock_response["result"]["responses"].append( + {"method": method, "result": {"great": "success"}, "error_code": 0} + ) + + mocker.patch.object(AesTransport, "perform_handshake") + mocker.patch.object(AesTransport, "perform_login") + + send_mock = mocker.patch.object(AesTransport, "send", return_value=mock_response) + config = DeviceConfig( + host, credentials=Credentials("foo", "bar"), batch_size=batch_size + ) + protocol = SmartProtocol(transport=AesTransport(config=config)) + + await protocol.query(requests, retry_count=0) + expected_count = int(request_size / batch_size) + (request_size % batch_size > 0) + assert send_mock.call_count == expected_count diff --git a/pyproject.toml b/pyproject.toml index f6092024a..9db1474a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,8 @@ omit = ["kasa/tests/*"] [tool.coverage.report] exclude_lines = [ + # ignore debug logging + "if debug_enabled:", # Don't complain if tests don't hit defensive assertion code: "raise AssertionError", "raise NotImplementedError", From 69dcc0d8bb216d6d7141e3bf0a961f1959f68f6d Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 29 Jan 2024 11:57:32 +0100 Subject: [PATCH 299/892] Implement alias set for tapodevice (#721) --- kasa/tapo/tapodevice.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 86967b69d..9edcca867 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -340,6 +340,12 @@ async def update_credentials(self, username: str, password: str): } return await self.protocol.query({"set_qs_info": payload}) + async def set_alias(self, alias: str): + """Set the device name (alias).""" + return await self.protocol.query( + {"set_device_info": {"nickname": base64.b64encode(alias.encode()).decode()}} + ) + async def reboot(self, delay: int = 1) -> None: """Reboot the device. From b479b6d84da51c5ab3e0894d53a9e3b60131a3df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Jan 2024 05:26:00 -1000 Subject: [PATCH 300/892] Avoid rebuilding urls for every request (#715) * Avoid rebuilding urls for every request * more fixes * more fixes * make mypy happy * reduce * tweak * fix tests * fix tests * tweak * tweak * lint * fix type --- kasa/aestransport.py | 17 +++++---- kasa/exceptions.py | 6 ++-- kasa/httpclient.py | 5 +-- kasa/klaptransport.py | 11 +++--- kasa/tests/test_aestransport.py | 33 ++++++++++-------- kasa/tests/test_httpclient.py | 2 +- kasa/tests/test_klapprotocol.py | 61 +++++++++++++++++---------------- 7 files changed, 73 insertions(+), 62 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 4e1ccb7d6..f784390bf 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -15,6 +15,7 @@ from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from yarl import URL from .credentials import Credentials from .deviceconfig import DeviceConfig @@ -100,9 +101,9 @@ def __init__( self._session_cookie: Optional[Dict[str, str]] = None - self._login_token: Optional[str] = None - self._key_pair: Optional[KeyPair] = None + self._app_url = URL(f"http://{self._host}/app") + self._token_url: Optional[URL] = None _LOGGER.debug("Created AES transport for %s", self._host) @@ -150,9 +151,10 @@ def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: async def send_secure_passthrough(self, request: str) -> Dict[str, Any]: """Send encrypted message as passthrough.""" - url = f"http://{self._host}/app" - if self._state is TransportState.ESTABLISHED and self._login_token: - url += f"?token={self._login_token}" + if self._state is TransportState.ESTABLISHED and self._token_url: + url = self._token_url + else: + url = self._app_url encrypted_payload = self._encryption_session.encrypt(request.encode()) # type: ignore passthrough_request = { @@ -223,7 +225,8 @@ async def try_login(self, login_params: Dict[str, Any]) -> None: 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"] + login_token = resp_dict["result"]["token"] + self._token_url = self._app_url.with_query(f"token={login_token}") self._state = TransportState.ESTABLISHED async def _generate_key_pair_payload(self) -> AsyncGenerator: @@ -250,7 +253,7 @@ async def perform_handshake(self) -> None: _LOGGER.debug("Will perform handshaking...") self._key_pair = None - self._login_token = None + self._token_url = None self._session_expire_at = None self._session_cookie = None diff --git a/kasa/exceptions.py b/kasa/exceptions.py index fb86ef14c..75f09169f 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -1,13 +1,13 @@ """python-kasa exceptions.""" from asyncio import TimeoutError from enum import IntEnum -from typing import Optional +from typing import Any, Optional class SmartDeviceException(Exception): """Base exception for device errors.""" - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: self.error_code: Optional["SmartErrorCode"] = kwargs.get("error_code", None) super().__init__(*args) @@ -15,7 +15,7 @@ def __init__(self, *args, **kwargs): class UnsupportedDeviceException(SmartDeviceException): """Exception for trying to connect to unsupported devices.""" - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: self.discovery_result = kwargs.get("discovery_result") super().__init__(*args, **kwargs) diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 7fe0b2c39..659ebdcfd 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -3,6 +3,7 @@ from typing import Any, Dict, Optional, Tuple, Union import aiohttp +from yarl import URL from .deviceconfig import DeviceConfig from .exceptions import ( @@ -25,7 +26,7 @@ def __init__(self, config: DeviceConfig) -> None: self._config = config self._client_session: aiohttp.ClientSession = None self._jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False) - self._last_url = f"http://{self._config.host}/" + self._last_url = URL(f"http://{self._config.host}/") @property def client(self) -> aiohttp.ClientSession: @@ -41,7 +42,7 @@ def client(self) -> aiohttp.ClientSession: async def post( self, - url: str, + url: URL, *, params: Optional[Dict[str, Any]] = None, data: Optional[bytes] = None, diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 898444c2e..0e585f2cd 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -53,6 +53,7 @@ from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from yarl import URL from .credentials import Credentials from .deviceconfig import DeviceConfig @@ -120,6 +121,8 @@ def __init__( self._session_cookie: Optional[Dict[str, Any]] = None _LOGGER.debug("Created KLAP transport for %s", self._host) + self._app_url = URL(f"http://{self._host}/app") + self._request_url = self._app_url / "request" @property def default_port(self): @@ -141,7 +144,7 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: payload = local_seed - url = f"http://{self._host}/app/handshake1" + url = self._app_url / "handshake1" response_status, response_data = await self._http_client.post(url, data=payload) @@ -236,7 +239,7 @@ async def perform_handshake2( # Handshake 2 has the following payload: # sha256(serverBytes | authenticator) - url = f"http://{self._host}/app/handshake2" + url = self._app_url / "handshake2" payload = self.handshake2_seed_auth_hash(local_seed, remote_seed, auth_hash) @@ -309,10 +312,8 @@ async def send(self, request: str): if self._encryption_session is not None: payload, seq = self._encryption_session.encrypt(request.encode()) - url = f"http://{self._host}/app/request" - response_status, response_data = await self._http_client.post( - url, + self._request_url, params={"seq": seq}, data=payload, cookies_dict=self._session_cookie, diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 9fe5cabd4..151952bde 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -12,6 +12,7 @@ import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding +from yarl import URL from ..aestransport import AesEncyptionSession, AesTransport, TransportState from ..credentials import Credentials @@ -89,10 +90,10 @@ async def test_login(mocker, status_code, error_code, inner_error_code, expectat transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session - assert transport._login_token is None + assert transport._token_url is None with expectation: await transport.perform_login() - assert transport._login_token == mock_aes_device.token + assert mock_aes_device.token in str(transport._token_url) @pytest.mark.parametrize( @@ -136,7 +137,7 @@ async def test_login_errors(mocker, inner_error_codes, expectation, call_count): transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session - assert transport._login_token is None + assert transport._token_url is None request = { "method": "get_device_info", @@ -148,7 +149,7 @@ async def test_login_errors(mocker, inner_error_codes, expectation, call_count): with expectation: await transport.send(json_dumps(request)) - assert transport._login_token == mock_aes_device.token + assert mock_aes_device.token in str(transport._token_url) assert post_mock.call_count == call_count # Login, Handshake, Login await transport.close() @@ -165,7 +166,9 @@ async def test_send(mocker, status_code, error_code, inner_error_code, expectati transport._handshake_done = True transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session - transport._login_token = mock_aes_device.token + transport._token_url = transport._app_url.with_query( + f"token={mock_aes_device.token}" + ) request = { "method": "get_device_info", @@ -193,7 +196,9 @@ async def test_passthrough_errors(mocker, error_code): transport._handshake_done = True transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session - transport._login_token = mock_aes_device.token + transport._token_url = transport._app_url.with_query( + f"token={mock_aes_device.token}" + ) request = { "method": "get_device_info", @@ -239,13 +244,13 @@ def inner_error_code(self): else: return self._inner_error_code - async def post(self, url, params=None, json=None, data=None, *_, **__): + async def post(self, url: 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: str, json: Dict[str, Any]): + async def _post(self, url: URL, json: Dict[str, Any]): if json["method"] == "handshake": return await self._return_handshake_response(url, json) elif json["method"] == "securePassthrough": @@ -253,10 +258,10 @@ async def _post(self, url: str, json: Dict[str, Any]): elif json["method"] == "login_device": return await self._return_login_response(url, json) else: - assert url == f"http://{self.host}/app?token={self.token}" + assert str(url) == f"http://{self.host}/app?token={self.token}" return await self._return_send_response(url, json) - async def _return_handshake_response(self, url: str, json: Dict[str, Any]): + async def _return_handshake_response(self, url: URL, 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] @@ -269,7 +274,7 @@ async def _return_handshake_response(self, url: str, json: Dict[str, Any]): self.status_code, {"result": {"key": key_64}, "error_code": self.error_code} ) - async def _return_secure_passthrough_response(self, url: str, json: Dict[str, Any]): + async def _return_secure_passthrough_response(self, url: URL, 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) @@ -286,15 +291,15 @@ async def _return_secure_passthrough_response(self, url: str, json: Dict[str, An } return self._mock_response(self.status_code, result) - async def _return_login_response(self, url: str, json: Dict[str, Any]): - if "token=" in url: + async def _return_login_response(self, url: URL, json: Dict[str, Any]): + if "token=" in str(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: str, json: Dict[str, Any]): + async def _return_send_response(self, url: URL, 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_httpclient.py b/kasa/tests/test_httpclient.py index e178b8189..2afabba07 100644 --- a/kasa/tests/test_httpclient.py +++ b/kasa/tests/test_httpclient.py @@ -84,7 +84,7 @@ async def _post(url, *_, **__): client = HttpClient(DeviceConfig(host)) # Exceptions with parameters print with double quotes, without use single quotes full_msg = ( - "\(" + "\(" # type: ignore + "['\"]" + re.escape(f"{error_message}{host}: {error}") + "['\"]" diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 4d711f034..b69d50706 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -10,6 +10,7 @@ import aiohttp import pytest +from yarl import URL from ..aestransport import AesTransport from ..credentials import Credentials @@ -318,28 +319,28 @@ async def _return_handshake1_response(url, params=None, data=None, *_, **__): async def test_handshake( mocker, transport_class, seed_auth_hash_calc1, seed_auth_hash_calc2 ): - async def _return_handshake_response(url, params=None, data=None, *_, **__): + client_seed = None + server_seed = secrets.token_bytes(16) + client_credentials = Credentials("foo", "bar") + device_auth_hash = transport_class.generate_auth_hash(client_credentials) + + async def _return_handshake_response(url: URL, params=None, data=None, *_, **__): nonlocal client_seed, server_seed, device_auth_hash - if url == "http://127.0.0.1/app/handshake1": + if str(url) == "http://127.0.0.1/app/handshake1": client_seed = data seed_auth_hash = _sha256( seed_auth_hash_calc1(client_seed, server_seed, device_auth_hash) ) return _mock_response(200, server_seed + seed_auth_hash) - elif url == "http://127.0.0.1/app/handshake2": + elif str(url) == "http://127.0.0.1/app/handshake2": seed_auth_hash = _sha256( seed_auth_hash_calc2(client_seed, server_seed, device_auth_hash) ) assert data == seed_auth_hash return _mock_response(response_status, b"") - client_seed = None - server_seed = secrets.token_bytes(16) - client_credentials = Credentials("foo", "bar") - device_auth_hash = transport_class.generate_auth_hash(client_credentials) - mocker.patch.object( aiohttp.ClientSession, "post", side_effect=_return_handshake_response ) @@ -360,17 +361,24 @@ async def _return_handshake_response(url, params=None, data=None, *_, **__): async def test_query(mocker): - async def _return_response(url, params=None, data=None, *_, **__): + client_seed = None + last_seq = None + seq = None + server_seed = secrets.token_bytes(16) + client_credentials = Credentials("foo", "bar") + device_auth_hash = KlapTransport.generate_auth_hash(client_credentials) + + async def _return_response(url: URL, params=None, data=None, *_, **__): nonlocal client_seed, server_seed, device_auth_hash, seq - if url == "http://127.0.0.1/app/handshake1": + if str(url) == "http://127.0.0.1/app/handshake1": client_seed = data client_seed_auth_hash = _sha256(data + device_auth_hash) return _mock_response(200, server_seed + client_seed_auth_hash) - elif url == "http://127.0.0.1/app/handshake2": + elif str(url) == "http://127.0.0.1/app/handshake2": return _mock_response(200, b"") - elif url == "http://127.0.0.1/app/request": + elif str(url) == "http://127.0.0.1/app/request": encryption_session = KlapEncryptionSession( protocol._transport._encryption_session.local_seed, protocol._transport._encryption_session.remote_seed, @@ -382,13 +390,6 @@ async def _return_response(url, params=None, data=None, *_, **__): seq = seq return _mock_response(200, encrypted) - client_seed = None - last_seq = None - seq = None - server_seed = secrets.token_bytes(16) - client_credentials = Credentials("foo", "bar") - device_auth_hash = KlapTransport.generate_auth_hash(client_credentials) - mocker.patch.object(aiohttp.ClientSession, "post", side_effect=_return_response) config = DeviceConfig("127.0.0.1", credentials=client_credentials) @@ -413,26 +414,26 @@ async def _return_response(url, params=None, data=None, *_, **__): ids=("handshake1", "handshake2", "request", "non_auth_error"), ) async def test_authentication_failures(mocker, response_status, expectation): - async def _return_response(url, params=None, data=None, *_, **__): + client_seed = None + + server_seed = secrets.token_bytes(16) + client_credentials = Credentials("foo", "bar") + device_auth_hash = KlapTransport.generate_auth_hash(client_credentials) + + async def _return_response(url: URL, params=None, data=None, *_, **__): nonlocal client_seed, server_seed, device_auth_hash, response_status - if url == "http://127.0.0.1/app/handshake1": + if str(url) == "http://127.0.0.1/app/handshake1": client_seed = data client_seed_auth_hash = _sha256(data + device_auth_hash) return _mock_response( response_status[0], server_seed + client_seed_auth_hash ) - elif url == "http://127.0.0.1/app/handshake2": + elif str(url) == "http://127.0.0.1/app/handshake2": return _mock_response(response_status[1], b"") - elif url == "http://127.0.0.1/app/request": - return _mock_response(response_status[2], None) - - client_seed = None - - server_seed = secrets.token_bytes(16) - client_credentials = Credentials("foo", "bar") - device_auth_hash = KlapTransport.generate_auth_hash(client_credentials) + elif str(url) == "http://127.0.0.1/app/request": + return _mock_response(response_status[2], b"") mocker.patch.object(aiohttp.ClientSession, "post", side_effect=_return_response) From 1ad2a05b6578747f0d375372fc90599328b9d6e3 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 29 Jan 2024 17:11:29 +0100 Subject: [PATCH 301/892] Initial support for tapos with child devices (#720) * Add ChildDevice and ChildProtocolWrapper * Initialize & update children * Fix circular imports * Add dummy_protocol fixture and tests for unwrapping responseData * Use dummy_protocol for existing smartprotocol tests * Move _ChildProtocolWrapper to smartprotocol.py * Use dummy_protocol for test multiple requests * Use device_id instead of position for selecting the child * Fix wrapping for regular requests * Remove unused imports * tweak * rename child_device to childdevice * Fix import --- kasa/smartprotocol.py | 67 ++++++++++++ kasa/tapo/childdevice.py | 44 ++++++++ kasa/tapo/tapodevice.py | 29 ++++- kasa/tapo/tapoplug.py | 4 +- kasa/tests/test_protocol.py | 3 + kasa/tests/test_smartprotocol.py | 176 ++++++++++++++++++++++++------- 6 files changed, 280 insertions(+), 43 deletions(-) create mode 100644 kasa/tapo/childdevice.py diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 9ec2547df..74f2275d2 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -279,3 +279,70 @@ def _wait_next_millis(self, last_timestamp): while timestamp <= last_timestamp: timestamp = self._current_millis() return timestamp + + +class _ChildProtocolWrapper(SmartProtocol): + """Protocol wrapper for controlling child devices. + + This is an internal class used to communicate with child devices, + and should not be used directly. + + This class overrides query() method of the protocol to modify all + outgoing queries to use ``control_child`` command, and unwraps the + device responses before returning to the caller. + """ + + def __init__(self, device_id: str, base_protocol: SmartProtocol): + self._device_id = device_id + self._protocol = base_protocol + self._transport = base_protocol._transport + + def _get_method_and_params_for_request(self, request): + """Return payload for wrapping. + + TODO: this does not support batches and requires refactoring in the future. + """ + if isinstance(request, dict): + if len(request) == 1: + smart_method = next(iter(request)) + smart_params = request[smart_method] + else: + smart_method = "multipleRequest" + requests = [ + {"method": method, "params": params} + for method, params in request.items() + ] + smart_params = {"requests": requests} + else: + smart_method = request + smart_params = None + + return smart_method, smart_params + + async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: + """Wrap request inside control_child envelope.""" + method, params = self._get_method_and_params_for_request(request) + request_data = { + "method": method, + "params": params, + } + wrapped_payload = { + "control_child": { + "device_id": self._device_id, + "requestData": request_data, + } + } + + response = await self._protocol.query(wrapped_payload, retry_count) + result = response.get("control_child") + # Unwrap responseData for control_child + if result and (response_data := result.get("responseData")): + self._handle_response_error_code(response_data) + result = response_data.get("result") + + # TODO: handle multipleRequest unwrapping + + return {method: result} + + async def close(self) -> None: + """Do nothing as the parent owns the protocol.""" diff --git a/kasa/tapo/childdevice.py b/kasa/tapo/childdevice.py new file mode 100644 index 000000000..c1b108a39 --- /dev/null +++ b/kasa/tapo/childdevice.py @@ -0,0 +1,44 @@ +"""Child device implementation.""" +from typing import Dict, Optional + +from ..deviceconfig import DeviceConfig +from ..exceptions import SmartDeviceException +from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper +from .tapodevice import TapoDevice + + +class ChildDevice(TapoDevice): + """Presentation of a child device. + + This wraps the protocol communications and sets internal data for the child. + """ + + def __init__( + self, + parent: TapoDevice, + child_id: str, + config: Optional[DeviceConfig] = None, + protocol: Optional[SmartProtocol] = None, + ) -> None: + super().__init__(parent.host, config=parent.config, protocol=parent.protocol) + self._parent = parent + self._id = child_id + self.protocol = _ChildProtocolWrapper(child_id, parent.protocol) + + async def update(self, update_children: bool = True): + """We just set the info here accordingly.""" + + def _get_child_info() -> Dict: + """Return the subdevice information for this device.""" + for child in self._parent._last_update["child_info"]["child_device_list"]: + if child["device_id"] == self._id: + return child + + raise SmartDeviceException( + f"Unable to find child device with position {self._id}" + ) + + self._last_update = self._sys_info = self._info = _get_child_info() + + def __repr__(self): + return f"" diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 9edcca867..a7e57a6d1 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -5,11 +5,11 @@ from typing import Any, Dict, List, Optional, Set, cast from ..aestransport import AesTransport +from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationException, SmartDeviceException from ..modules import Emeter -from ..protocol import BaseProtocol from ..smartdevice import SmartDevice, WifiNetwork from ..smartprotocol import SmartProtocol @@ -24,17 +24,27 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[BaseProtocol] = None, + protocol: Optional[SmartProtocol] = None, ) -> None: _protocol = protocol or SmartProtocol( transport=AesTransport(config=config or DeviceConfig(host=host)), ) super().__init__(host=host, config=config, protocol=_protocol) + self.protocol: SmartProtocol self._components_raw: Optional[Dict[str, Any]] = None self._components: Dict[str, int] self._state_information: Dict[str, Any] = {} - self._discovery_info: Optional[Dict[str, Any]] = None - self.modules: Dict[str, Any] = {} + + async def _initialize_children(self): + children = self._last_update["child_info"]["child_device_list"] + # TODO: Use the type information to construct children, + # as hubs can also have them. + from .childdevice import ChildDevice + + self.children = [ + ChildDevice(parent=self, child_id=child["device_id"]) for child in children + ] + self._device_type = DeviceType.Strip async def update(self, update_children: bool = True): """Update the device.""" @@ -51,6 +61,10 @@ async def update(self, update_children: bool = True): await self._initialize_modules() extra_reqs: Dict[str, Any] = {} + + if "child_device" in self._components: + extra_reqs = {**extra_reqs, "get_child_device_list": None} + if "energy_monitoring" in self._components: extra_reqs = { **extra_reqs, @@ -81,8 +95,15 @@ async def update(self, update_children: bool = True): "time": self._time, "energy": self._energy, "emeter": self._emeter, + "child_info": resp.get("get_child_device_list", {}), } + if self._last_update["child_info"]: + if not self.children: + await self._initialize_children() + for child in self.children: + await child.update() + _LOGGER.debug("Got an update: %s", self._data) async def _initialize_modules(self): diff --git a/kasa/tapo/tapoplug.py b/kasa/tapo/tapoplug.py index 1bd90fd37..e4355e4ba 100644 --- a/kasa/tapo/tapoplug.py +++ b/kasa/tapo/tapoplug.py @@ -4,8 +4,8 @@ from typing import Any, Dict, Optional, cast from ..deviceconfig import DeviceConfig -from ..protocol import BaseProtocol from ..smartdevice import DeviceType +from ..smartprotocol import SmartProtocol from .tapodevice import TapoDevice _LOGGER = logging.getLogger(__name__) @@ -19,7 +19,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[BaseProtocol] = None, + protocol: Optional[SmartProtocol] = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index e71f42969..463597429 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -482,6 +482,9 @@ def _get_subclasses(of_class): "class_name_obj", _get_subclasses(BaseProtocol), ids=lambda t: t[0] ) def test_protocol_init_signature(class_name_obj): + if class_name_obj[0].startswith("_"): + pytest.skip("Skipping internal protocols") + return params = list(inspect.signature(class_name_obj[1].__init__).parameters.values()) assert len(params) == 2 diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 9b597b51f..619caef0a 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -1,16 +1,8 @@ -import errno -import json -import logging -import secrets -import struct -import sys -import time -from contextlib import nullcontext as does_not_raise from itertools import chain +from typing import Dict import pytest -from ..aestransport import AesTransport from ..credentials import Credentials from ..deviceconfig import DeviceConfig from ..exceptions import ( @@ -19,9 +11,8 @@ SmartDeviceException, SmartErrorCode, ) -from ..iotprotocol import IotProtocol -from ..klaptransport import KlapEncryptionSession, KlapTransport, _sha256 -from ..smartprotocol import SmartProtocol +from ..protocol import BaseTransport +from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} DUMMY_MULTIPLE_QUERY = { @@ -31,20 +22,45 @@ ERRORS = [e for e in SmartErrorCode if e != 0] +# TODO: this could be moved to conftest to make it available for other tests? +@pytest.fixture() +def dummy_protocol(): + """Return a smart protocol instance with a mocking-ready dummy transport.""" + + class DummyTransport(BaseTransport): + @property + def default_port(self) -> int: + return -1 + + @property + def credentials_hash(self) -> str: + return "dummy hash" + + async def send(self, request: str) -> Dict: + return {} + + async def close(self) -> None: + pass + + async def reset(self) -> None: + pass + + transport = DummyTransport(config=DeviceConfig(host="127.0.0.123")) + protocol = SmartProtocol(transport=transport) + + return protocol + + @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) -async def test_smart_device_errors(mocker, error_code): - host = "127.0.0.1" +async def test_smart_device_errors(dummy_protocol, mocker, error_code): mock_response = {"result": {"great": "success"}, "error_code": error_code.value} - mocker.patch.object(AesTransport, "perform_handshake") - mocker.patch.object(AesTransport, "perform_login") - - send_mock = mocker.patch.object(AesTransport, "send", return_value=mock_response) + send_mock = mocker.patch.object( + dummy_protocol._transport, "send", return_value=mock_response + ) - config = DeviceConfig(host, credentials=Credentials("foo", "bar")) - protocol = SmartProtocol(transport=AesTransport(config=config)) with pytest.raises(SmartDeviceException): - await protocol.query(DUMMY_QUERY, retry_count=2) + await dummy_protocol.query(DUMMY_QUERY, retry_count=2) if error_code in chain(SMART_TIMEOUT_ERRORS, SMART_RETRYABLE_ERRORS): expected_calls = 3 @@ -54,8 +70,9 @@ async def test_smart_device_errors(mocker, error_code): @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) -async def test_smart_device_errors_in_multiple_request(mocker, error_code): - host = "127.0.0.1" +async def test_smart_device_errors_in_multiple_request( + dummy_protocol, mocker, error_code +): mock_response = { "result": { "responses": [ @@ -71,14 +88,11 @@ async def test_smart_device_errors_in_multiple_request(mocker, error_code): "error_code": 0, } - mocker.patch.object(AesTransport, "perform_handshake") - mocker.patch.object(AesTransport, "perform_login") - - send_mock = mocker.patch.object(AesTransport, "send", return_value=mock_response) - config = DeviceConfig(host, credentials=Credentials("foo", "bar")) - protocol = SmartProtocol(transport=AesTransport(config=config)) + send_mock = mocker.patch.object( + dummy_protocol._transport, "send", return_value=mock_response + ) with pytest.raises(SmartDeviceException): - await protocol.query(DUMMY_MULTIPLE_QUERY, retry_count=2) + await dummy_protocol.query(DUMMY_MULTIPLE_QUERY, retry_count=2) if error_code in chain(SMART_TIMEOUT_ERRORS, SMART_RETRYABLE_ERRORS): expected_calls = 3 else: @@ -88,7 +102,9 @@ async def test_smart_device_errors_in_multiple_request(mocker, error_code): @pytest.mark.parametrize("request_size", [1, 3, 5, 10]) @pytest.mark.parametrize("batch_size", [1, 2, 3, 4, 5]) -async def test_smart_device_multiple_request(mocker, request_size, batch_size): +async def test_smart_device_multiple_request( + dummy_protocol, mocker, request_size, batch_size +): host = "127.0.0.1" requests = {} mock_response = { @@ -102,15 +118,101 @@ async def test_smart_device_multiple_request(mocker, request_size, batch_size): {"method": method, "result": {"great": "success"}, "error_code": 0} ) - mocker.patch.object(AesTransport, "perform_handshake") - mocker.patch.object(AesTransport, "perform_login") - - send_mock = mocker.patch.object(AesTransport, "send", return_value=mock_response) + send_mock = mocker.patch.object( + dummy_protocol._transport, "send", return_value=mock_response + ) config = DeviceConfig( host, credentials=Credentials("foo", "bar"), batch_size=batch_size ) - protocol = SmartProtocol(transport=AesTransport(config=config)) + dummy_protocol._transport._config = config - await protocol.query(requests, retry_count=0) + await dummy_protocol.query(requests, retry_count=0) expected_count = int(request_size / batch_size) + (request_size % batch_size > 0) assert send_mock.call_count == expected_count + + +async def test_childdevicewrapper_unwrapping(dummy_protocol, mocker): + """Test that responseData gets unwrapped correctly.""" + wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol) + mock_response = {"error_code": 0, "result": {"responseData": {"error_code": 0}}} + + mocker.patch.object(wrapped_protocol._transport, "send", return_value=mock_response) + res = await wrapped_protocol.query(DUMMY_QUERY) + assert res == {"foobar": None} + + +async def test_childdevicewrapper_unwrapping_with_payload(dummy_protocol, mocker): + wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol) + mock_response = { + "error_code": 0, + "result": {"responseData": {"error_code": 0, "result": {"bar": "bar"}}}, + } + mocker.patch.object(wrapped_protocol._transport, "send", return_value=mock_response) + res = await wrapped_protocol.query(DUMMY_QUERY) + assert res == {"foobar": {"bar": "bar"}} + + +async def test_childdevicewrapper_error(dummy_protocol, mocker): + """Test that errors inside the responseData payload cause an exception.""" + wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol) + mock_response = {"error_code": 0, "result": {"responseData": {"error_code": -1001}}} + + mocker.patch.object(wrapped_protocol._transport, "send", return_value=mock_response) + with pytest.raises(SmartDeviceException): + await wrapped_protocol.query(DUMMY_QUERY) + + +@pytest.mark.skip("childprotocolwrapper does not yet support multirequests") +async def test_childdevicewrapper_unwrapping_multiplerequest(dummy_protocol, mocker): + """Test that unwrapping multiplerequest works correctly.""" + mock_response = { + "error_code": 0, + "result": { + "responseData": { + "result": { + "responses": [ + { + "error_code": 0, + "method": "get_device_info", + "result": {"foo": "bar"}, + }, + { + "error_code": 0, + "method": "second_command", + "result": {"bar": "foo"}, + }, + ] + } + } + }, + } + + mocker.patch.object(dummy_protocol._transport, "send", return_value=mock_response) + resp = await dummy_protocol.query(DUMMY_QUERY) + assert resp == {"get_device_info": {"foo": "bar"}, "second_command": {"bar": "foo"}} + + +@pytest.mark.skip("childprotocolwrapper does not yet support multirequests") +async def test_childdevicewrapper_multiplerequest_error(dummy_protocol, mocker): + """Test that errors inside multipleRequest response of responseData raise an exception.""" + mock_response = { + "error_code": 0, + "result": { + "responseData": { + "result": { + "responses": [ + { + "error_code": 0, + "method": "get_device_info", + "result": {"foo": "bar"}, + }, + {"error_code": -1001, "method": "invalid_command"}, + ] + } + } + }, + } + + mocker.patch.object(dummy_protocol._transport, "send", return_value=mock_response) + with pytest.raises(SmartDeviceException): + await dummy_protocol.query(DUMMY_QUERY) From f8e273981c304c6aec9fb9b961aa9d40630bd07a Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 29 Jan 2024 18:14:30 +0100 Subject: [PATCH 302/892] Add P300 fixture (#717) * Add P300 fixture * fixture after update * Add tests for p300 --- README.md | 5 + kasa/tapo/__init__.py | 3 +- kasa/tapo/childdevice.py | 2 +- kasa/tests/conftest.py | 45 +- .../fixtures/smart/P300(EU)_1.0_1.0.13.json | 520 ++++++++++++++++++ .../fixtures/smart/P300(EU)_1.0_1.0.7.json | 512 +++++++++++++++++ kasa/tests/newfakes.py | 33 +- kasa/tests/test_discovery.py | 13 +- kasa/tests/test_smartprotocol.py | 29 - 9 files changed, 1123 insertions(+), 39 deletions(-) create mode 100644 kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json create mode 100644 kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json diff --git a/README.md b/README.md index 42b1c99d1..8da1fcdf6 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,11 @@ At the moment, the following devices have been confirmed to work: * Tapo S500D * Tapo S505 +#### Power strips + +* Tapo P300 + + ### Newer Kasa branded devices Some newer hardware versions of Kasa branded devices are now using the same protocol as diff --git a/kasa/tapo/__init__.py b/kasa/tapo/__init__.py index eeb3670cf..0fe4297e2 100644 --- a/kasa/tapo/__init__.py +++ b/kasa/tapo/__init__.py @@ -1,6 +1,7 @@ """Package for supporting tapo-branded and newer kasa devices.""" +from .childdevice import ChildDevice from .tapobulb import TapoBulb from .tapodevice import TapoDevice from .tapoplug import TapoPlug -__all__ = ["TapoDevice", "TapoPlug", "TapoBulb"] +__all__ = ["TapoDevice", "TapoPlug", "TapoBulb", "ChildDevice"] diff --git a/kasa/tapo/childdevice.py b/kasa/tapo/childdevice.py index c1b108a39..7b66a79d3 100644 --- a/kasa/tapo/childdevice.py +++ b/kasa/tapo/childdevice.py @@ -35,7 +35,7 @@ def _get_child_info() -> Dict: return child raise SmartDeviceException( - f"Unable to find child device with position {self._id}" + f"Unable to find child device with id {self._id}" ) self._last_update = self._sys_info = self._info = _get_child_info() diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 24bc3372b..a058c47e0 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -13,15 +13,18 @@ from kasa import ( Credentials, + DeviceConfig, Discover, SmartBulb, SmartDevice, SmartDimmer, SmartLightStrip, SmartPlug, + SmartProtocol, SmartStrip, ) -from kasa.tapo import TapoBulb, TapoDevice, TapoPlug +from kasa.protocol import BaseTransport +from kasa.tapo import TapoBulb, TapoPlug from kasa.xortransport import XorEncryption from .newfakes import FakeSmartProtocol, FakeTransportProtocol @@ -107,7 +110,7 @@ *PLUGS_SMART, } STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} -STRIPS_SMART: Set[str] = set() +STRIPS_SMART = {"P300"} STRIPS = {*STRIPS_IOT, *STRIPS_SMART} DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} @@ -210,7 +213,7 @@ def parametrize(desc, devices, protocol_filter=None, ids=None): bulb = parametrize("bulbs", BULBS, protocol_filter={"SMART", "IOT"}) plug = parametrize("plugs", PLUGS, protocol_filter={"IOT"}) -strip = parametrize("strips", STRIPS, protocol_filter={"IOT"}) +strip = parametrize("strips", STRIPS, protocol_filter={"SMART", "IOT"}) dimmer = parametrize("dimmers", DIMMERS, protocol_filter={"IOT"}) lightstrip = parametrize("lightstrips", LIGHT_STRIPS, protocol_filter={"IOT"}) @@ -238,6 +241,11 @@ def parametrize(desc, devices, protocol_filter=None, ids=None): ) bulb_iot = parametrize("bulb devices iot", BULBS_IOT, protocol_filter={"IOT"}) +strip_iot = parametrize("strip devices iot", STRIPS_IOT, protocol_filter={"IOT"}) +strip_smart = parametrize( + "strip devices smart", STRIPS_SMART, protocol_filter={"SMART"} +) + 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( @@ -338,6 +346,9 @@ def device_for_file(model, protocol): for d in DIMMERS_SMART: if d in model: return TapoBulb + for d in STRIPS_SMART: + if d in model: + return TapoPlug else: for d in STRIPS_IOT: if d in model: @@ -556,6 +567,34 @@ def mock_discover(self): yield discovery_data +@pytest.fixture() +def dummy_protocol(): + """Return a smart protocol instance with a mocking-ready dummy transport.""" + + class DummyTransport(BaseTransport): + @property + def default_port(self) -> int: + return -1 + + @property + def credentials_hash(self) -> str: + return "dummy hash" + + async def send(self, request: str) -> Dict: + return {} + + async def close(self) -> None: + pass + + async def reset(self) -> None: + pass + + transport = DummyTransport(config=DeviceConfig(host="127.0.0.123")) + protocol = SmartProtocol(transport=transport) + + return protocol + + def pytest_addoption(parser): parser.addoption( "--ip", action="store", default=None, help="run against device on given ip" diff --git a/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json b/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json new file mode 100644 index 000000000..4c4402bc3 --- /dev/null +++ b/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json @@ -0,0 +1,520 @@ +{ + "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": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P300(EU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "78-8C-B5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "000000000000000000000000000000000000000000" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "000000000000000000000000000000000000000000" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "000000000000000000000000000000000000000000" + } + ], + "start_index": 0, + "sum": 3 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "state": { + "on": false + }, + "type": "custom" + }, + "device_id": "000000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.13 Build 230925 Rel.150200", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "8022852468EC205A8178C7CBE81FC119213BC020", + "overheat_status": "normal", + "position": 3, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "avatar": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "000000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.13 Build 230925 Rel.150200", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "8022852468EC205A8178C7CBE81FC119213BC020", + "overheat_status": "normal", + "position": 2, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "avatar": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "state": { + "on": true + }, + "type": "custom" + }, + "device_id": "000000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.13 Build 230925 Rel.150200", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "8022852468EC205A8178C7CBE81FC119213BC020", + "overheat_status": "normal", + "position": 1, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + } + ], + "start_index": 0, + "sum": 3 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.13 Build 230925 Rel.150200", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "de_DE", + "latitude": 0, + "longitude": 0, + "mac": "78-8C-B5-00-00-00", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -62, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1706320181 + }, + "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": 0, + "fw_ver": "1.0.13 Build 230925 Rel.150200", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "night_mode", + "led_status": false, + "night_mode": { + "end_time": 496, + "night_mode_type": "sunrise_sunset", + "start_time": 1034, + "sunrise_offset": 0, + "sunset_offset": 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 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P300", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} diff --git a/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json b/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json new file mode 100644 index 000000000..1cd396213 --- /dev/null +++ b/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json @@ -0,0 +1,512 @@ +{ + "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": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "homekit", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P300(EU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "78-8C-B5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "000000000000000000000000000000000000000000" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "000000000000000000000000000000000000000000" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "000000000000000000000000000000000000000000" + } + ], + "start_index": 0, + "sum": 3 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "000000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 220715 Rel.200458", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 366, + "original_device_id": "8022852468EC205A8178C7CBE81FC119213BC020", + "overheat_status": "normal", + "position": 1, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "avatar": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "000000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 220715 Rel.200458", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 366, + "original_device_id": "8022852468EC205A8178C7CBE81FC119213BC020", + "overheat_status": "normal", + "position": 2, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "avatar": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "000000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 220715 Rel.200458", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 366, + "original_device_id": "8022852468EC205A8178C7CBE81FC119213BC020", + "overheat_status": "normal", + "position": 3, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + } + ], + "start_index": 0, + "sum": 3 + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 220715 Rel.200458", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "78-8C-B5-00-00-00", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -58, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1706297406 + }, + "get_device_usage": { + "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_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 497, + "night_mode_type": "sunrise_sunset", + "start_time": 1032, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "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": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P300", + "device_type": "SMART.TAPOPLUG" + } + } +} diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index aa3d42bef..d668f9ee6 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -354,9 +354,36 @@ async def send(self, request: str): def _send_request(self, request_dict: dict): method = request_dict["method"] params = request_dict["params"] + + info = self.info + if method == "control_child": + device_id = params.get("device_id") + request_data = params.get("requestData") + + child_method = request_data.get("method") + child_params = request_data.get("params") + + children = info["get_child_device_list"]["child_device_list"] + + for child in children: + if child["device_id"] == device_id: + info = child + break + + # We only support get & set device info for now. + if child_method == "get_device_info": + return {"result": info, "error_code": 0} + elif child_method == "set_device_info": + info.update(child_params) + return {"error_code": 0} + + raise NotImplementedError( + "Method %s not implemented for children" % child_method + ) + if method == "component_nego" or method[:4] == "get_": - if method in self.info: - return {"result": self.info[method], "error_code": 0} + if method in info: + return {"result": info[method], "error_code": 0} elif ( missing_result := self.FIXTURE_MISSING_MAP.get(method) ) and missing_result[0] in self.components: @@ -373,7 +400,7 @@ def _send_request(self, request_dict: dict): return {"error_code": 0} elif method[:4] == "set_": target_method = f"get_{method[4:]}" - self.info[target_method].update(params) + info[target_method].update(params) return {"error_code": 0} async def close(self) -> None: diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index db4d8fc1c..6ded034d8 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -27,7 +27,16 @@ from kasa.exceptions import AuthenticationException, UnsupportedDeviceException from kasa.xortransport import XorEncryption -from .conftest import bulb, bulb_iot, dimmer, lightstrip, new_discovery, plug, strip +from .conftest import ( + bulb, + bulb_iot, + dimmer, + lightstrip, + new_discovery, + plug, + strip, + strip_iot, +) UNSUPPORTED = { "result": { @@ -67,7 +76,7 @@ async def test_type_detection_bulb(dev: SmartDevice): assert d.device_type == DeviceType.Bulb -@strip +@strip_iot async def test_type_detection_strip(dev: SmartDevice): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_strip diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 619caef0a..5e2120772 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -22,35 +22,6 @@ ERRORS = [e for e in SmartErrorCode if e != 0] -# TODO: this could be moved to conftest to make it available for other tests? -@pytest.fixture() -def dummy_protocol(): - """Return a smart protocol instance with a mocking-ready dummy transport.""" - - class DummyTransport(BaseTransport): - @property - def default_port(self) -> int: - return -1 - - @property - def credentials_hash(self) -> str: - return "dummy hash" - - async def send(self, request: str) -> Dict: - return {} - - async def close(self) -> None: - pass - - async def reset(self) -> None: - pass - - transport = DummyTransport(config=DeviceConfig(host="127.0.0.123")) - protocol = SmartProtocol(transport=transport) - - return protocol - - @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) async def test_smart_device_errors(dummy_protocol, mocker, error_code): mock_response = {"result": {"great": "success"}, "error_code": error_code.value} From bc1503c40e249ad76fed6a5c00f502cf775ec4f9 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 29 Jan 2024 18:52:22 +0000 Subject: [PATCH 303/892] Fix TapoBulb state information for non-dimmable SMARTSWITCH (#726) --- kasa/tapo/tapobulb.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py index bbaf093d6..cfd5768f0 100644 --- a/kasa/tapo/tapobulb.py +++ b/kasa/tapo/tapobulb.py @@ -243,9 +243,10 @@ def state_information(self) -> Dict[str, Any]: info: Dict[str, Any] = { # TODO: re-enable after we don't inherit from smartbulb # **super().state_information - "Brightness": self.brightness, "Is dimmable": self.is_dimmable, } + if self.is_dimmable: + info["Brightness"] = self.brightness if self.is_variable_color_temp: info["Color temperature"] = self.color_temp info["Valid temperature range"] = self.valid_temperature_range From 1e264342058016e9d80a81af971b11892ed1fd7e Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 29 Jan 2024 19:58:30 +0100 Subject: [PATCH 304/892] Prepare 0.6.2 (#728) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.1...0.6.2) Release highlights: * Support for tapo power strips (P300) * Performance improvements and bug fixes **Implemented enhancements:** - Implement alias set for tapodevice [\#721](https://github.com/python-kasa/python-kasa/pull/721) (@rytilahti) - Initial support for tapos with child devices [\#720](https://github.com/python-kasa/python-kasa/pull/720) (@rytilahti) - Avoid rebuilding urls for every request [\#715](https://github.com/python-kasa/python-kasa/pull/715) (@bdraco) - Reduce the number of times creating the cipher in klap [\#712](https://github.com/python-kasa/python-kasa/pull/712) (@bdraco) - Use hashlib for klap [\#711](https://github.com/python-kasa/python-kasa/pull/711) (@bdraco) - Enable batching of multiple requests [\#662](https://github.com/python-kasa/python-kasa/pull/662) (@sdb9696) **Merged pull requests:** - Update L510E\(US\) fixture with mac prefix [\#722](https://github.com/python-kasa/python-kasa/pull/722) (@sdb9696) - Add P300 fixture [\#717](https://github.com/python-kasa/python-kasa/pull/717) (@rytilahti) - Use hashlib in place of hashes.Hash [\#714](https://github.com/python-kasa/python-kasa/pull/714) (@bdraco) - Switch from TPLinkSmartHomeProtocol to IotProtocol/XorTransport [\#710](https://github.com/python-kasa/python-kasa/pull/710) (@sdb9696) - Add concrete XorTransport class with full implementation [\#646](https://github.com/python-kasa/python-kasa/pull/646) (@sdb9696) --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++-------- pyproject.toml | 2 +- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a2fe8d4e..1fd238a1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [0.6.2](https://github.com/python-kasa/python-kasa/tree/0.6.2) (2024-01-29) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.1...0.6.2) + +Release highlights: +* Support for tapo power strips (P300) +* Performance improvements and bug fixes + +**Implemented enhancements:** + +- Implement alias set for tapodevice [\#721](https://github.com/python-kasa/python-kasa/pull/721) (@rytilahti) +- Initial support for tapos with child devices [\#720](https://github.com/python-kasa/python-kasa/pull/720) (@rytilahti) +- Avoid rebuilding urls for every request [\#715](https://github.com/python-kasa/python-kasa/pull/715) (@bdraco) +- Reduce the number of times creating the cipher in klap [\#712](https://github.com/python-kasa/python-kasa/pull/712) (@bdraco) +- Use hashlib for klap [\#711](https://github.com/python-kasa/python-kasa/pull/711) (@bdraco) +- Enable batching of multiple requests [\#662](https://github.com/python-kasa/python-kasa/pull/662) (@sdb9696) + +**Merged pull requests:** + +- Update L510E\(US\) fixture with mac prefix [\#722](https://github.com/python-kasa/python-kasa/pull/722) (@sdb9696) +- Add P300 fixture [\#717](https://github.com/python-kasa/python-kasa/pull/717) (@rytilahti) +- Use hashlib in place of hashes.Hash [\#714](https://github.com/python-kasa/python-kasa/pull/714) (@bdraco) +- Switch from TPLinkSmartHomeProtocol to IotProtocol/XorTransport [\#710](https://github.com/python-kasa/python-kasa/pull/710) (@sdb9696) +- Add concrete XorTransport class with full implementation [\#646](https://github.com/python-kasa/python-kasa/pull/646) (@sdb9696) + ## [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) @@ -16,8 +41,8 @@ Release highlights: - 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) +- Try default tapo credentials for klap and aes [\#685](https://github.com/python-kasa/python-kasa/pull/685) (@sdb9696) **Fixed bugs:** @@ -31,25 +56,21 @@ Release highlights: **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) +- 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) **Merged pull requests:** +- Prepare 0.6.1 [\#709](https://github.com/python-kasa/python-kasa/pull/709) (@rytilahti) - 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) @@ -60,6 +81,11 @@ Release highlights: - 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) +- Ensure login token is only sent if aes state is ESTABLISHED [\#702](https://github.com/python-kasa/python-kasa/pull/702) (@bdraco) +- 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) +- Refactor aestransport to use a state enum [\#691](https://github.com/python-kasa/python-kasa/pull/691) (@bdraco) - 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) diff --git a/pyproject.toml b/pyproject.toml index 9db1474a0..e96f939d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.6.1" +version = "0.6.2" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 9e6896a08f04b380f9fc0536d2077c320ea28f03 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 29 Jan 2024 20:26:39 +0100 Subject: [PATCH 305/892] Various test code cleanups (#725) * Separate fake protocols for iot and smart * Move control_child impl into its own method * Organize schemas into correct places * Add test_childdevice * Add missing return for _handle_control_child --- kasa/tests/conftest.py | 7 +- .../{newfakes.py => fakeprotocol_iot.py} | 294 +----------------- kasa/tests/fakeprotocol_smart.py | 125 ++++++++ kasa/tests/test_bulb.py | 61 +++- kasa/tests/test_childdevice.py | 31 ++ kasa/tests/test_emeter.py | 28 +- kasa/tests/test_plug.py | 8 +- kasa/tests/test_smartdevice.py | 84 ++++- 8 files changed, 333 insertions(+), 305 deletions(-) rename kasa/tests/{newfakes.py => fakeprotocol_iot.py} (56%) create mode 100644 kasa/tests/fakeprotocol_smart.py create mode 100644 kasa/tests/test_childdevice.py diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index a058c47e0..f75e9a7af 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -27,7 +27,8 @@ from kasa.tapo import TapoBulb, TapoPlug from kasa.xortransport import XorEncryption -from .newfakes import FakeSmartProtocol, FakeTransportProtocol +from .fakeprotocol_iot import FakeIotProtocol +from .fakeprotocol_smart import FakeSmartProtocol SUPPORTED_IOT_DEVICES = [ (device, "IOT") @@ -410,7 +411,7 @@ def load_file(): if protocol == "SMART": d.protocol = FakeSmartProtocol(sysinfo) else: - d.protocol = FakeTransportProtocol(sysinfo) + d.protocol = FakeIotProtocol(sysinfo) await _update_and_close(d) return d @@ -521,7 +522,7 @@ def mock_discover(self): if "component_nego" in dm.query_data: proto = FakeSmartProtocol(dm.query_data) else: - proto = FakeTransportProtocol(dm.query_data) + proto = FakeIotProtocol(dm.query_data) async def _query(request, retry_count: int = 3): return await proto.query(request) diff --git a/kasa/tests/newfakes.py b/kasa/tests/fakeprotocol_iot.py similarity index 56% rename from kasa/tests/newfakes.py rename to kasa/tests/fakeprotocol_iot.py index d668f9ee6..fa14d3fc0 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/fakeprotocol_iot.py @@ -1,185 +1,13 @@ -import base64 import copy import logging -import re -import warnings -from json import loads as json_loads - -from voluptuous import ( - REMOVE_EXTRA, - All, - Any, - Coerce, # type: ignore - Invalid, - Optional, - Range, - Schema, -) - -from ..credentials import Credentials + from ..deviceconfig import DeviceConfig -from ..exceptions import SmartDeviceException from ..iotprotocol import IotProtocol -from ..protocol import BaseTransport -from ..smartprotocol import SmartProtocol from ..xortransport import XorTransport _LOGGER = logging.getLogger(__name__) -def check_int_bool(x): - if x != 0 and x != 1: - raise Invalid(x) - return x - - -def check_mac(x): - if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", x.lower()): - return x - raise Invalid(x) - - -def check_mode(x): - if x in ["schedule", "none", "count_down"]: - return x - - raise Invalid(f"invalid mode {x}") - - -def lb_dev_state(x): - if x in ["normal"]: - return x - - raise Invalid(f"Invalid dev_state {x}") - - -TZ_SCHEMA = Schema( - {"zone_str": str, "dst_offset": int, "index": All(int, Range(min=0)), "tz_str": str} -) - -CURRENT_CONSUMPTION_SCHEMA = Schema( - Any( - { - "voltage": Any(All(float, Range(min=0, max=300)), None), - "power": Any(Coerce(float, Range(min=0)), None), - "total": Any(Coerce(float, Range(min=0)), None), - "current": Any(All(float, Range(min=0)), None), - "voltage_mv": Any( - All(float, Range(min=0, max=300000)), int, None - ), # TODO can this be int? - "power_mw": Any(Coerce(float, Range(min=0)), None), - "total_wh": Any(Coerce(float, Range(min=0)), None), - "current_ma": Any( - All(float, Range(min=0)), int, None - ), # TODO can this be int? - "slot_id": Any(Coerce(int, Range(min=0)), None), - }, - None, - ) -) - -# these schemas should go to the mainlib as -# they can be useful when adding support for new features/devices -# as well as to check that faked devices are operating properly. -PLUG_SCHEMA = Schema( - { - "active_mode": check_mode, - "alias": str, - "dev_name": str, - "deviceId": str, - "feature": str, - "fwId": str, - "hwId": str, - "hw_ver": str, - "icon_hash": str, - "led_off": check_int_bool, - "latitude": Any(All(float, Range(min=-90, max=90)), 0, None), - "latitude_i": Any( - All(int, Range(min=-900000, max=900000)), - All(float, Range(min=-900000, max=900000)), - 0, - None, - ), - "longitude": Any(All(float, Range(min=-180, max=180)), 0, None), - "longitude_i": Any( - All(int, Range(min=-18000000, max=18000000)), - All(float, Range(min=-18000000, max=18000000)), - 0, - None, - ), - "mac": check_mac, - "model": str, - "oemId": str, - "on_time": int, - "relay_state": int, - "rssi": Any(int, None), # rssi can also be positive, see #54 - "sw_ver": str, - "type": str, - "mic_type": str, - "updating": check_int_bool, - # these are available on hs220 - "brightness": int, - "preferred_state": [ - {"brightness": All(int, Range(min=0, max=100)), "index": int} - ], - "next_action": {"type": int}, - "child_num": Optional(Any(None, int)), # TODO fix hs300 checks - "children": Optional(list), # TODO fix hs300 - # TODO some tplink simulator entries contain invalid (mic_mac, _i variants for lat/lon) - # Therefore we add REMOVE_EXTRA.. - # "INVALIDmac": Optional, - # "INVALIDlatitude": Optional, - # "INVALIDlongitude": Optional, - }, - extra=REMOVE_EXTRA, -) - -LIGHT_STATE_SCHEMA = Schema( - { - "brightness": All(int, Range(min=0, max=100)), - "color_temp": int, - "hue": All(int, Range(min=0, max=360)), - "mode": str, - "on_off": check_int_bool, - "saturation": All(int, Range(min=0, max=100)), - "dft_on_state": Optional( - { - "brightness": All(int, Range(min=0, max=100)), - "color_temp": All(int, Range(min=0, max=9000)), - "hue": All(int, Range(min=0, max=360)), - "mode": str, - "saturation": All(int, Range(min=0, max=100)), - } - ), - "err_code": int, - } -) - -BULB_SCHEMA = PLUG_SCHEMA.extend( - { - "ctrl_protocols": Optional(dict), - "description": Optional(str), # TODO: LBxxx similar to dev_name - "dev_state": lb_dev_state, - "disco_ver": str, - "heapsize": int, - "is_color": check_int_bool, - "is_dimmable": check_int_bool, - "is_factory": bool, - "is_variable_color_temp": check_int_bool, - "light_state": LIGHT_STATE_SCHEMA, - "preferred_state": [ - { - "brightness": All(int, Range(min=0, max=100)), - "color_temp": int, - "hue": All(int, Range(min=0, max=360)), - "index": int, - "saturation": All(int, Range(min=0, max=100)), - } - ], - } -) - - def get_realtime(obj, x, *args): return { "current": 0.268587, @@ -294,123 +122,7 @@ def success(res): } -class FakeSmartProtocol(SmartProtocol): - def __init__(self, info): - super().__init__( - transport=FakeSmartTransport(info), - ) - - async def query(self, request, retry_count: int = 3): - """Implement query here so can still patch SmartProtocol.query.""" - resp_dict = await self._query(request, retry_count) - return resp_dict - - -class FakeSmartTransport(BaseTransport): - def __init__(self, info): - super().__init__( - config=DeviceConfig( - "127.0.0.123", - credentials=Credentials( - username="dummy_user", - password="dummy_password", # noqa: S106 - ), - ), - ) - self.info = info - self.components = { - comp["id"]: comp["ver_code"] - for comp in self.info["component_nego"]["component_list"] - } - - @property - def default_port(self): - """Default port for the transport.""" - return 80 - - @property - def credentials_hash(self): - """The hashed credentials used by the transport.""" - return self._credentials.username + self._credentials.password + "hash" - - FIXTURE_MISSING_MAP = { - "get_wireless_scan_info": ("wireless", {"ap_list": [], "wep_supported": False}), - } - - async def send(self, request: str): - request_dict = json_loads(request) - method = request_dict["method"] - params = request_dict["params"] - if method == "multipleRequest": - responses = [] - for request in params["requests"]: - response = self._send_request(request) # type: ignore[arg-type] - response["method"] = request["method"] # type: ignore[index] - responses.append(response) - return {"result": {"responses": responses}, "error_code": 0} - else: - return self._send_request(request_dict) - - def _send_request(self, request_dict: dict): - method = request_dict["method"] - params = request_dict["params"] - - info = self.info - if method == "control_child": - device_id = params.get("device_id") - request_data = params.get("requestData") - - child_method = request_data.get("method") - child_params = request_data.get("params") - - children = info["get_child_device_list"]["child_device_list"] - - for child in children: - if child["device_id"] == device_id: - info = child - break - - # We only support get & set device info for now. - if child_method == "get_device_info": - return {"result": info, "error_code": 0} - elif child_method == "set_device_info": - info.update(child_params) - return {"error_code": 0} - - raise NotImplementedError( - "Method %s not implemented for children" % child_method - ) - - if method == "component_nego" or method[:4] == "get_": - if method in info: - return {"result": info[method], "error_code": 0} - elif ( - missing_result := self.FIXTURE_MISSING_MAP.get(method) - ) and missing_result[0] in self.components: - warnings.warn( - UserWarning( - f"Fixture missing expected method {method}, try to regenerate" - ), - stacklevel=1, - ) - return {"result": missing_result[1], "error_code": 0} - else: - raise SmartDeviceException(f"Fixture doesn't support {method}") - elif method == "set_qs_info": - return {"error_code": 0} - elif method[:4] == "set_": - target_method = f"get_{method[4:]}" - info[target_method].update(params) - return {"error_code": 0} - - async def close(self) -> None: - pass - - async def reset(self) -> None: - pass - - -class FakeTransportProtocol(IotProtocol): +class FakeIotProtocol(IotProtocol): def __init__(self, info): super().__init__( transport=XorTransport( @@ -420,7 +132,7 @@ def __init__(self, info): self.discovery_data = info self.writer = None self.reader = None - proto = copy.deepcopy(FakeTransportProtocol.baseproto) + proto = copy.deepcopy(FakeIotProtocol.baseproto) for target in info: # print("target %s" % target) diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py new file mode 100644 index 000000000..bbadec0af --- /dev/null +++ b/kasa/tests/fakeprotocol_smart.py @@ -0,0 +1,125 @@ +import warnings +from json import loads as json_loads + +from kasa import Credentials, DeviceConfig, SmartDeviceException, SmartProtocol +from kasa.protocol import BaseTransport + + +class FakeSmartProtocol(SmartProtocol): + def __init__(self, info): + super().__init__( + transport=FakeSmartTransport(info), + ) + + async def query(self, request, retry_count: int = 3): + """Implement query here so can still patch SmartProtocol.query.""" + resp_dict = await self._query(request, retry_count) + return resp_dict + + +class FakeSmartTransport(BaseTransport): + def __init__(self, info): + super().__init__( + config=DeviceConfig( + "127.0.0.123", + credentials=Credentials( + username="dummy_user", + password="dummy_password", # noqa: S106 + ), + ), + ) + self.info = info + self.components = { + comp["id"]: comp["ver_code"] + for comp in self.info["component_nego"]["component_list"] + } + + @property + def default_port(self): + """Default port for the transport.""" + return 80 + + @property + def credentials_hash(self): + """The hashed credentials used by the transport.""" + return self._credentials.username + self._credentials.password + "hash" + + FIXTURE_MISSING_MAP = { + "get_wireless_scan_info": ("wireless", {"ap_list": [], "wep_supported": False}), + } + + async def send(self, request: str): + request_dict = json_loads(request) + method = request_dict["method"] + params = request_dict["params"] + if method == "multipleRequest": + responses = [] + for request in params["requests"]: + response = self._send_request(request) # type: ignore[arg-type] + response["method"] = request["method"] # type: ignore[index] + responses.append(response) + return {"result": {"responses": responses}, "error_code": 0} + else: + return self._send_request(request_dict) + + def _handle_control_child(self, params: dict): + """Handle control_child command.""" + device_id = params.get("device_id") + request_data = params.get("requestData", {}) + + child_method = request_data.get("method") + child_params = request_data.get("params") + + info = self.info + children = info["get_child_device_list"]["child_device_list"] + + for child in children: + if child["device_id"] == device_id: + info = child + break + + # We only support get & set device info for now. + if child_method == "get_device_info": + return {"result": info, "error_code": 0} + elif child_method == "set_device_info": + info.update(child_params) + return {"error_code": 0} + + raise NotImplementedError( + "Method %s not implemented for children" % child_method + ) + + def _send_request(self, request_dict: dict): + method = request_dict["method"] + params = request_dict["params"] + + info = self.info + if method == "control_child": + return self._handle_control_child(params) + elif method == "component_nego" or method[:4] == "get_": + if method in info: + return {"result": info[method], "error_code": 0} + elif ( + missing_result := self.FIXTURE_MISSING_MAP.get(method) + ) and missing_result[0] in self.components: + warnings.warn( + UserWarning( + f"Fixture missing expected method {method}, try to regenerate" + ), + stacklevel=1, + ) + return {"result": missing_result[1], "error_code": 0} + else: + raise SmartDeviceException(f"Fixture doesn't support {method}") + elif method == "set_qs_info": + return {"error_code": 0} + elif method[:4] == "set_": + target_method = f"get_{method[4:]}" + info[target_method].update(params) + return {"error_code": 0} + + async def close(self) -> None: + pass + + async def reset(self) -> None: + pass diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 0676022ba..5e008da51 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -1,4 +1,15 @@ import pytest +from voluptuous import ( + REMOVE_EXTRA, + All, + Any, + Boolean, + Coerce, # type: ignore + Invalid, + Optional, + Range, + Schema, +) from kasa import DeviceType, SmartBulb, SmartBulbPreset, SmartDeviceException @@ -16,13 +27,13 @@ variable_temp, variable_temp_iot, ) -from .newfakes import BULB_SCHEMA, LIGHT_STATE_SCHEMA +from .test_smartdevice import SYSINFO_SCHEMA @bulb async def test_bulb_sysinfo(dev: SmartBulb): assert dev.sys_info is not None - BULB_SCHEMA(dev.sys_info) + SYSINFO_SCHEMA_BULB(dev.sys_info) assert dev.model is not None @@ -316,3 +327,49 @@ async def test_modify_preset_payloads(dev: SmartBulb, preset, payload, mocker): query_helper = mocker.patch("kasa.SmartBulb._query_helper") await dev.save_preset(preset) query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload) + + +LIGHT_STATE_SCHEMA = Schema( + { + "brightness": All(int, Range(min=0, max=100)), + "color_temp": int, + "hue": All(int, Range(min=0, max=360)), + "mode": str, + "on_off": Boolean, + "saturation": All(int, Range(min=0, max=100)), + "dft_on_state": Optional( + { + "brightness": All(int, Range(min=0, max=100)), + "color_temp": All(int, Range(min=0, max=9000)), + "hue": All(int, Range(min=0, max=360)), + "mode": str, + "saturation": All(int, Range(min=0, max=100)), + } + ), + "err_code": int, + } +) + +SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend( + { + "ctrl_protocols": Optional(dict), + "description": Optional(str), # Seen on LBxxx, similar to dev_name + "dev_state": str, + "disco_ver": str, + "heapsize": int, + "is_color": Boolean, + "is_dimmable": Boolean, + "is_factory": Boolean, + "is_variable_color_temp": Boolean, + "light_state": LIGHT_STATE_SCHEMA, + "preferred_state": [ + { + "brightness": All(int, Range(min=0, max=100)), + "color_temp": int, + "hue": All(int, Range(min=0, max=360)), + "index": int, + "saturation": All(int, Range(min=0, max=100)), + } + ], + } +) diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py new file mode 100644 index 000000000..9acd4b0dc --- /dev/null +++ b/kasa/tests/test_childdevice.py @@ -0,0 +1,31 @@ +from kasa.smartprotocol import _ChildProtocolWrapper +from kasa.tapo import ChildDevice + +from .conftest import strip_smart + + +@strip_smart +def test_childdevice_init(dev, dummy_protocol, mocker): + """Test that child devices get initialized and use protocol wrapper.""" + assert len(dev.children) > 0 + assert dev.is_strip + + first = dev.children[0] + assert isinstance(first.protocol, _ChildProtocolWrapper) + + assert first._info["category"] == "plug.powerstrip.sub-plug" + assert "position" in first._info + + +@strip_smart +async def test_childdevice_update(dev, dummy_protocol, mocker): + """Test that parent update updates children.""" + assert len(dev.children) > 0 + first = dev.children[0] + + child_update = mocker.patch.object(first, "update") + await dev.update() + child_update.assert_called() + + assert dev._last_update != first._last_update + assert dev._last_update["child_info"]["child_device_list"][0] == first._last_update diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index fdb219b5f..cac86f4bc 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -2,12 +2,38 @@ from unittest.mock import Mock import pytest +from voluptuous import ( + REMOVE_EXTRA, + All, + Any, + Coerce, # type: ignore + Invalid, + Optional, + Range, + Schema, +) from kasa import EmeterStatus, SmartDeviceException from kasa.modules.emeter import Emeter from .conftest import has_emeter, has_emeter_iot, no_emeter -from .newfakes import CURRENT_CONSUMPTION_SCHEMA + +CURRENT_CONSUMPTION_SCHEMA = Schema( + Any( + { + "voltage": Any(All(float, Range(min=0, max=300)), None), + "power": Any(Coerce(float, Range(min=0)), None), + "total": Any(Coerce(float, Range(min=0)), None), + "current": Any(All(float, Range(min=0)), None), + "voltage_mv": Any(All(float, Range(min=0, max=300000)), int, None), + "power_mw": Any(Coerce(float, Range(min=0)), None), + "total_wh": Any(Coerce(float, Range(min=0)), None), + "current_ma": Any(All(float, Range(min=0)), int, None), + "slot_id": Any(Coerce(int, Range(min=0)), None), + }, + None, + ) +) @no_emeter diff --git a/kasa/tests/test_plug.py b/kasa/tests/test_plug.py index e9e1592f9..7cde008d6 100644 --- a/kasa/tests/test_plug.py +++ b/kasa/tests/test_plug.py @@ -1,13 +1,17 @@ from kasa import DeviceType from .conftest import plug, plug_smart -from .newfakes import PLUG_SCHEMA +from .test_smartdevice import SYSINFO_SCHEMA + +# these schemas should go to the mainlib as +# they can be useful when adding support for new features/devices +# as well as to check that faked devices are operating properly. @plug async def test_plug_sysinfo(dev): assert dev.sys_info is not None - PLUG_SCHEMA(dev.sys_info) + SYSINFO_SCHEMA(dev.sys_info) assert dev.model is not None diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index b2ae9c33f..c4681ee80 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -1,14 +1,26 @@ import inspect +import re from datetime import datetime from unittest.mock import Mock, patch import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 +from voluptuous import ( + REMOVE_EXTRA, + All, + Any, + Boolean, + In, + Invalid, + Optional, + Range, + Schema, +) import kasa from kasa import Credentials, DeviceConfig, SmartDevice, SmartDeviceException from .conftest import device_iot, handle_turn_on, has_emeter_iot, no_emeter_iot, turn_on -from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol +from .fakeprotocol_iot import FakeIotProtocol # List of all SmartXXX classes including the SmartDevice base class smart_device_classes = [ @@ -30,7 +42,7 @@ async def test_state_info(dev): @device_iot async def test_invalid_connection(dev): with patch.object( - FakeTransportProtocol, "query", side_effect=SmartDeviceException + FakeIotProtocol, "query", side_effect=SmartDeviceException ), pytest.raises(SmartDeviceException): await dev.update() @@ -133,22 +145,22 @@ async def test_timezone(dev): @device_iot async def test_hw_info(dev): - PLUG_SCHEMA(dev.hw_info) + SYSINFO_SCHEMA(dev.hw_info) @device_iot async def test_location(dev): - PLUG_SCHEMA(dev.location) + SYSINFO_SCHEMA(dev.location) @device_iot async def test_rssi(dev): - PLUG_SCHEMA({"rssi": dev.rssi}) # wrapping for vol + SYSINFO_SCHEMA({"rssi": dev.rssi}) # wrapping for vol @device_iot async def test_mac(dev): - PLUG_SCHEMA({"mac": dev.mac}) # wrapping for val + SYSINFO_SCHEMA({"mac": dev.mac}) # wrapping for val @device_iot @@ -263,3 +275,63 @@ async def test_modules_not_supported(dev: SmartDevice): await dev.update() for module in dev.modules.values(): assert module.is_supported is not None + + +def check_mac(x): + if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", x.lower()): + return x + raise Invalid(x) + + +TZ_SCHEMA = Schema( + {"zone_str": str, "dst_offset": int, "index": All(int, Range(min=0)), "tz_str": str} +) + + +SYSINFO_SCHEMA = Schema( + { + "active_mode": In(["schedule", "none", "count_down"]), + "alias": str, + "dev_name": str, + "deviceId": str, + "feature": str, + "fwId": str, + "hwId": str, + "hw_ver": str, + "icon_hash": str, + "led_off": Boolean, + "latitude": Any(All(float, Range(min=-90, max=90)), 0, None), + "latitude_i": Any( + All(int, Range(min=-900000, max=900000)), + All(float, Range(min=-900000, max=900000)), + 0, + None, + ), + "longitude": Any(All(float, Range(min=-180, max=180)), 0, None), + "longitude_i": Any( + All(int, Range(min=-18000000, max=18000000)), + All(float, Range(min=-18000000, max=18000000)), + 0, + None, + ), + "mac": check_mac, + "model": str, + "oemId": str, + "on_time": int, + "relay_state": int, + "rssi": Any(int, None), # rssi can also be positive, see #54 + "sw_ver": str, + "type": str, + "mic_type": str, + "updating": Boolean, + # these are available on hs220 + "brightness": int, + "preferred_state": [ + {"brightness": All(int, Range(min=0, max=100)), "index": int} + ], + "next_action": {"type": int}, + "child_num": Optional(Any(None, int)), + "children": Optional(list), + }, + extra=REMOVE_EXTRA, +) From 55525fc58bcf2a695ca5412c6dfa9d2240dfc77f Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 30 Jan 2024 00:15:58 +0100 Subject: [PATCH 306/892] Unignore F401 for tests (#724) * Unignore F401 for tests * Fix linting --- kasa/tests/conftest.py | 4 ++-- kasa/tests/test_aestransport.py | 4 +--- kasa/tests/test_bulb.py | 4 ---- kasa/tests/test_childdevice.py | 1 - kasa/tests/test_cli.py | 2 -- kasa/tests/test_device_factory.py | 6 ------ kasa/tests/test_deviceconfig.py | 3 --- kasa/tests/test_discovery.py | 6 ------ kasa/tests/test_emeter.py | 3 --- kasa/tests/test_klapprotocol.py | 4 ---- kasa/tests/test_protocol.py | 2 -- kasa/tests/test_readme_examples.py | 2 -- kasa/tests/test_smartprotocol.py | 4 +--- kasa/tests/test_usage.py | 2 -- pyproject.toml | 1 - 15 files changed, 4 insertions(+), 44 deletions(-) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index f75e9a7af..351be5451 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -5,8 +5,8 @@ from dataclasses import dataclass from json import dumps as json_dumps from os.path import basename -from pathlib import Path, PurePath -from typing import Dict, Optional, Set +from pathlib import Path +from typing import Dict, Optional from unittest.mock import MagicMock import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 151952bde..5d590b7fc 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -6,7 +6,7 @@ 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 +from typing import Any, Dict import aiohttp import pytest @@ -18,8 +18,6 @@ from ..credentials import Credentials from ..deviceconfig import DeviceConfig from ..exceptions import ( - SMART_RETRYABLE_ERRORS, - SMART_TIMEOUT_ERRORS, AuthenticationException, SmartDeviceException, SmartErrorCode, diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 5e008da51..a92678b78 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -1,11 +1,7 @@ import pytest from voluptuous import ( - REMOVE_EXTRA, All, - Any, Boolean, - Coerce, # type: ignore - Invalid, Optional, Range, Schema, diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 9acd4b0dc..986f77b65 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -1,5 +1,4 @@ from kasa.smartprotocol import _ChildProtocolWrapper -from kasa.tapo import ChildDevice from .conftest import strip_smart diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 14dbb4bdb..df1f6456c 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -7,7 +7,6 @@ from kasa import ( AuthenticationException, - Credentials, EmeterStatus, SmartDevice, SmartDeviceException, @@ -28,7 +27,6 @@ wifi, ) from kasa.discover import Discover, DiscoveryResult -from kasa.smartprotocol import SmartProtocol from .conftest import device_iot, device_smart, handle_turn_on, new_discovery, turn_on diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index 9a068cd99..f0f73cf27 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -1,20 +1,14 @@ # type: ignore import logging -from typing import Type import aiohttp import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( Credentials, - DeviceType, Discover, - SmartBulb, SmartDevice, SmartDeviceException, - SmartDimmer, - SmartLightStrip, - SmartPlug, ) from kasa.device_factory import connect, get_protocol from kasa.deviceconfig import ( diff --git a/kasa/tests/test_deviceconfig.py b/kasa/tests/test_deviceconfig.py index b802d2aad..fed635f6d 100644 --- a/kasa/tests/test_deviceconfig.py +++ b/kasa/tests/test_deviceconfig.py @@ -6,10 +6,7 @@ from kasa.credentials import Credentials from kasa.deviceconfig import ( - ConnectionType, DeviceConfig, - DeviceFamilyType, - EncryptType, ) from kasa.exceptions import SmartDeviceException diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 6ded034d8..f2344801f 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -1,6 +1,5 @@ # type: ignore import asyncio -import logging import re import socket from unittest.mock import MagicMock @@ -15,26 +14,21 @@ Discover, SmartDevice, SmartDeviceException, - protocol, ) from kasa.deviceconfig import ( ConnectionType, DeviceConfig, - DeviceFamilyType, - EncryptType, ) from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps from kasa.exceptions import AuthenticationException, UnsupportedDeviceException from kasa.xortransport import XorEncryption from .conftest import ( - bulb, bulb_iot, dimmer, lightstrip, new_discovery, plug, - strip, strip_iot, ) diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index cac86f4bc..dbd750247 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -3,12 +3,9 @@ import pytest from voluptuous import ( - REMOVE_EXTRA, All, Any, Coerce, # type: ignore - Invalid, - Optional, Range, Schema, ) diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index b69d50706..1e007b930 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -1,12 +1,8 @@ -import errno import json import logging import secrets -import struct -import sys import time from contextlib import nullcontext as does_not_raise -from unittest.mock import PropertyMock import aiohttp import pytest diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 463597429..69402beec 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -461,8 +461,6 @@ def test_decrypt_unicode(decrypt_class): def _get_subclasses(of_class): - import kasa - package = sys.modules["kasa"] subclasses = set() for _, modname, _ in pkgutil.iter_modules(package.__path__): diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index 5772ba42c..416cbec86 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -1,7 +1,5 @@ import asyncio -import sys -import pytest import xdoctest from kasa.tests.conftest import get_device_for_file diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 5e2120772..86f554b27 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -1,5 +1,4 @@ from itertools import chain -from typing import Dict import pytest @@ -11,8 +10,7 @@ SmartDeviceException, SmartErrorCode, ) -from ..protocol import BaseTransport -from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper +from ..smartprotocol import _ChildProtocolWrapper DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} DUMMY_MULTIPLE_QUERY = { diff --git a/kasa/tests/test_usage.py b/kasa/tests/test_usage.py index 61672ffb7..9f42fca1c 100644 --- a/kasa/tests/test_usage.py +++ b/kasa/tests/test_usage.py @@ -1,8 +1,6 @@ import datetime from unittest.mock import Mock -import pytest - from kasa.modules import Usage diff --git a/pyproject.toml b/pyproject.toml index e96f939d6..7e3a0b93c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,7 +121,6 @@ convention = "pep257" "D102", "D103", "D104", - "F401", "S101", # allow asserts "E501", # ignore line-too-longs ] From 8657959aced93f0784ed5d99634e91bbac5870e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 31 Jan 2024 07:30:19 -1000 Subject: [PATCH 307/892] Add TP15 fixture (#730) --- README.md | 1 + kasa/tests/conftest.py | 12 +- .../fixtures/smart/TP15(US)_1.0_1.0.3.json | 295 ++++++++++++++++++ 3 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/smart/TP15(US)_1.0_1.0.3.json diff --git a/README.md b/README.md index 8da1fcdf6..91e9a90e3 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,7 @@ At the moment, the following devices have been confirmed to work: * Tapo P110 * Tapo P125M * Tapo P135 (dimming not yet supported) +* Tapo TP15 #### Bulbs diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 351be5451..33518e383 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -105,7 +105,17 @@ } # P135 supports dimming, but its not currently support # by the library -PLUGS_SMART = {"P100", "P110", "KP125M", "EP25", "KS205", "P125M", "P135", "S505"} +PLUGS_SMART = { + "P100", + "P110", + "KP125M", + "EP25", + "KS205", + "P125M", + "P135", + "S505", + "TP15", +} PLUGS = { *PLUGS_IOT, *PLUGS_SMART, diff --git a/kasa/tests/fixtures/smart/TP15(US)_1.0_1.0.3.json b/kasa/tests/fixtures/smart/TP15(US)_1.0_1.0.3.json new file mode 100644 index 000000000..404bfe2fc --- /dev/null +++ b/kasa/tests/fixtures/smart/TP15(US)_1.0_1.0.3.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": "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 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "TP15(US)", + "device_type": "SMART.TAPOPLUG", + "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": "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": "plug", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 230112 Rel.124621", + "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": "5C-62-8B-00-00-00", + "model": "TP15", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 83, + "overheated": false, + "region": "America/Chicago", + "rssi": -53, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1706719971 + }, + "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 231009 Rel.155831", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-11-24", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced local communication security.\n2. Improved Matter setup process.\n3. Optimized stability and performance.\n4. Fixed some minor bugs.", + "type": 2 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 433, + "night_mode_type": "sunrise_sunset", + "start_time": 1079, + "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": 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": 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==" + }, + { + "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": "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": "TP15", + "device_type": "SMART.TAPOPLUG" + } + } +} From 1acf4e86da30ca710dbfdc9ae5c8845f724f3359 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 1 Feb 2024 19:27:01 +0100 Subject: [PATCH 308/892] Retain last two chars for children device_id (#733) --- devtools/dump_devinfo.py | 6 ++++ .../fixtures/smart/P300(EU)_1.0_1.0.13.json | 36 +++++++------------ .../fixtures/smart/P300(EU)_1.0_1.0.7.json | 12 +++---- 3 files changed, 25 insertions(+), 29 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index e9ec56b7b..005eb7993 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -60,6 +60,7 @@ def scrub(res): "alias", "bssid", "channel", + "original_device_id", # for child devices ] for k, v in res.items(): @@ -96,6 +97,11 @@ def scrub(res): v = "#MASKED_NAME#" elif isinstance(res[k], int): v = 0 + elif k == "device_id" and len(v) > 40: + # retain the last two chars when scrubbing child ids + end = v[-2:] + v = re.sub(r"\w", "0", v) + v = v[:40] + end else: v = re.sub(r"\w", "0", v) diff --git a/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json b/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json index 4c4402bc3..0d7d4a3bd 100644 --- a/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json +++ b/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json @@ -170,7 +170,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000000" + "device_id": "000000000000000000000000000000000000000002" }, { "component_list": [ @@ -235,7 +235,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000000" + "device_id": "000000000000000000000000000000000000000001" }, { "component_list": [ @@ -318,7 +318,7 @@ }, "type": "custom" }, - "device_id": "000000000000000000000000000000000000000000", + "device_id": "000000000000000000000000000000000000000002", "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.13 Build 230925 Rel.150200", @@ -332,7 +332,7 @@ "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "on_time": 0, - "original_device_id": "8022852468EC205A8178C7CBE81FC119213BC020", + "original_device_id": "0000000000000000000000000000000000000000", "overheat_status": "normal", "position": 3, "region": "Europe/Berlin", @@ -347,7 +347,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000000", + "device_id": "000000000000000000000000000000000000000001", "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.13 Build 230925 Rel.150200", @@ -361,7 +361,7 @@ "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "on_time": 0, - "original_device_id": "8022852468EC205A8178C7CBE81FC119213BC020", + "original_device_id": "0000000000000000000000000000000000000000", "overheat_status": "normal", "position": 2, "region": "Europe/Berlin", @@ -393,7 +393,7 @@ "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "on_time": 0, - "original_device_id": "8022852468EC205A8178C7CBE81FC119213BC020", + "original_device_id": "0000000000000000000000000000000000000000", "overheat_status": "normal", "position": 1, "region": "Europe/Berlin", @@ -406,7 +406,7 @@ "sum": 3 }, "get_connect_cloud_state": { - "status": 0 + "status": 2 }, "get_device_info": { "avatar": "", @@ -435,12 +435,12 @@ "get_device_time": { "region": "Europe/Berlin", "time_diff": 60, - "timestamp": 1706320181 + "timestamp": 1706810351 }, "get_device_usage": { "time_usage": { - "past30": 0, - "past7": 0, + "past30": 134, + "past7": 134, "today": 0 } }, @@ -451,23 +451,13 @@ "status": 0, "upgrade_time": 5 }, - "get_latest_fw": { - "fw_size": 0, - "fw_ver": "1.0.13 Build 230925 Rel.150200", - "hw_id": "", - "need_to_upgrade": false, - "oem_id": "", - "release_date": "", - "release_note": "", - "type": 0 - }, "get_led_info": { "led_rule": "night_mode", "led_status": false, "night_mode": { - "end_time": 496, + "end_time": 489, "night_mode_type": "sunrise_sunset", - "start_time": 1034, + "start_time": 1043, "sunrise_offset": 0, "sunset_offset": 0 } diff --git a/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json b/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json index 1cd396213..17df5ac5e 100644 --- a/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json +++ b/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json @@ -170,7 +170,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000000" + "device_id": "000000000000000000000000000000000000000001" }, { "component_list": [ @@ -235,7 +235,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000000" + "device_id": "000000000000000000000000000000000000000002" }, { "component_list": [ @@ -300,7 +300,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000000" + "device_id": "000000000000000000000000000000000000000003" } ], "start_index": 0, @@ -315,7 +315,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000000", + "device_id": "000000000000000000000000000000000000000001", "device_on": true, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.7 Build 220715 Rel.200458", @@ -344,7 +344,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000000", + "device_id": "000000000000000000000000000000000000000002", "device_on": true, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.7 Build 220715 Rel.200458", @@ -373,7 +373,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000000", + "device_id": "000000000000000000000000000000000000000003", "device_on": true, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.7 Build 220715 Rel.200458", From 1f62aee7b633a1b52fc13d5577d793aec6bb9a6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Feb 2024 12:52:57 -0600 Subject: [PATCH 309/892] Add TP25 fixtures (#729) * Add TP25 fixtures * redump fixture --- README.md | 1 + kasa/tests/conftest.py | 2 +- .../fixtures/smart/TP25(US)_1.0_1.0.2.json | 423 ++++++++++++++++++ 3 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json diff --git a/README.md b/README.md index 91e9a90e3..d5db1cfcc 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,7 @@ At the moment, the following devices have been confirmed to work: #### Power strips * Tapo P300 +* Tapo TP25 ### Newer Kasa branded devices diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 33518e383..6ce491d15 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -121,7 +121,7 @@ *PLUGS_SMART, } STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} -STRIPS_SMART = {"P300"} +STRIPS_SMART = {"P300", "TP25"} STRIPS = {*STRIPS_IOT, *STRIPS_SMART} DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} diff --git a/kasa/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json b/kasa/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json new file mode 100644 index 000000000..1e3321f8f --- /dev/null +++ b/kasa/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json @@ -0,0 +1,423 @@ +{ + "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": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "TP25(US)", + "device_type": "SMART.TAPOPLUG", + "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_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "000000000000000000000000000000000000000000" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "000000000000000000000000000000000000000001" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "000000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.2 Build 230206 Rel.095245", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "3C52A1000000", + "model": "TP25", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "America/Chicago", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "000000000000000000000000000000000000000001", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.2 Build 230206 Rel.095245", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "3C52A1000000", + "model": "TP25", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 63159, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "America/Chicago", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.2 Build 230206 Rel.095245", + "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": "TP25", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "America/Chicago", + "rssi": -39, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1706812337 + }, + "get_device_usage": { + "time_usage": { + "past30": 1, + "past7": 1, + "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 231019 Rel.173739", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-11-24", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced local communication security.\n2. Improved Matter setup process.\n3. Optimized stability and performance.\n4. Fixed some minor bugs.", + "type": 2 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 432, + "night_mode_type": "sunrise_sunset", + "start_time": 1080, + "sunrise_offset": 0, + "sunset_offset": 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": "matter", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "TP25", + "device_type": "SMART.TAPOPLUG" + } + } +} From 1f15bcda7c65367334a150b2c8d1b874aec6c661 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 2 Feb 2024 17:29:14 +0100 Subject: [PATCH 310/892] Avoid crashing on childdevice property accesses (#732) * Avoid crashing on childdevice property accesses * Push updates from parent to child --- kasa/emeterstatus.py | 7 +++-- kasa/tapo/childdevice.py | 25 ++++++++--------- kasa/tapo/tapodevice.py | 50 ++++++++++++++++++++++++++-------- kasa/tests/test_childdevice.py | 50 +++++++++++++++++++++++++++++++--- 4 files changed, 100 insertions(+), 32 deletions(-) diff --git a/kasa/emeterstatus.py b/kasa/emeterstatus.py index 48d6e2410..9d3b3b571 100644 --- a/kasa/emeterstatus.py +++ b/kasa/emeterstatus.py @@ -79,8 +79,11 @@ def __getitem__(self, item): return super().__getitem__(item[: item.find("_")]) * 1000 else: # downscale for i in super().keys(): # noqa: SIM118 - if i.startswith(item): - return self.__getitem__(i) / 1000 + if ( + i.startswith(item) + and (value := self.__getitem__(i)) is not None + ): + return value / 1000 _LOGGER.debug(f"Unable to find value for '{item}'") return None diff --git a/kasa/tapo/childdevice.py b/kasa/tapo/childdevice.py index 7b66a79d3..43b748515 100644 --- a/kasa/tapo/childdevice.py +++ b/kasa/tapo/childdevice.py @@ -1,8 +1,8 @@ """Child device implementation.""" -from typing import Dict, Optional +from typing import Optional +from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..exceptions import SmartDeviceException from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper from .tapodevice import TapoDevice @@ -24,21 +24,18 @@ def __init__( self._parent = parent self._id = child_id self.protocol = _ChildProtocolWrapper(child_id, parent.protocol) + # TODO: remove the assignment after modularization is done, + # currently required to allow accessing time-related properties + self._time = parent._time + self._device_type = DeviceType.StripSocket async def update(self, update_children: bool = True): - """We just set the info here accordingly.""" + """Noop update. The parent updates our internals.""" - def _get_child_info() -> Dict: - """Return the subdevice information for this device.""" - for child in self._parent._last_update["child_info"]["child_device_list"]: - if child["device_id"] == self._id: - return child - - raise SmartDeviceException( - f"Unable to find child device with id {self._id}" - ) - - self._last_update = self._sys_info = self._info = _get_child_info() + def update_internal_state(self, info): + """Set internal state for the child.""" + # TODO: cleanup the _last_update, _sys_info, _info, _data mess. + self._last_update = self._sys_info = self._info = info def __repr__(self): return f"" diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index a7e57a6d1..0ef28d071 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -2,7 +2,7 @@ import base64 import logging from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional, Set, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, cast from ..aestransport import AesTransport from ..device_type import DeviceType @@ -15,6 +15,9 @@ _LOGGER = logging.getLogger(__name__) +if TYPE_CHECKING: + from .childdevice import ChildDevice + class TapoDevice(SmartDevice): """Base class to represent a TAPO device.""" @@ -32,20 +35,40 @@ def __init__( super().__init__(host=host, config=config, protocol=_protocol) self.protocol: SmartProtocol self._components_raw: Optional[Dict[str, Any]] = None - self._components: Dict[str, int] + self._components: Dict[str, int] = {} + self._children: Dict[str, "ChildDevice"] = {} + self._energy: Dict[str, Any] = {} self._state_information: Dict[str, Any] = {} async def _initialize_children(self): + """Initialize children for power strips.""" children = self._last_update["child_info"]["child_device_list"] # TODO: Use the type information to construct children, # as hubs can also have them. from .childdevice import ChildDevice - self.children = [ - ChildDevice(parent=self, child_id=child["device_id"]) for child in children - ] + self._children = { + child["device_id"]: ChildDevice(parent=self, child_id=child["device_id"]) + for child in children + } self._device_type = DeviceType.Strip + @property + def children(self): + """Return list of children. + + This is just to keep the existing SmartDevice API intact. + """ + return list(self._children.values()) + + @children.setter + def children(self, children): + """Initialize from a list of children. + + This is just to keep the existing SmartDevice API intact. + """ + self._children = {child["device_id"]: child for child in children} + async def update(self, update_children: bool = True): """Update the device.""" if self.credentials is None and self.credentials_hash is None: @@ -88,7 +111,7 @@ async def update(self, update_children: bool = True): self._energy = resp.get("get_energy_usage", {}) self._emeter = resp.get("get_current_power", {}) - self._last_update = self._data = { + self._last_update = { "components": self._components_raw, "info": self._info, "usage": self._usage, @@ -98,13 +121,13 @@ async def update(self, update_children: bool = True): "child_info": resp.get("get_child_device_list", {}), } - if self._last_update["child_info"]: + if child_info := self._last_update.get("child_info"): if not self.children: await self._initialize_children() - for child in self.children: - await child.update() + for info in child_info["child_device_list"]: + self._children[info["device_id"]].update_internal_state(info) - _LOGGER.debug("Got an update: %s", self._data) + _LOGGER.debug("Got an update: %s", self._last_update) async def _initialize_modules(self): """Initialize modules based on component negotiation response.""" @@ -192,7 +215,7 @@ def device_id(self) -> str: @property def internal_state(self) -> Any: """Return all the internal state data.""" - return self._data + return self._last_update async def _query_helper( self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None @@ -204,10 +227,13 @@ async def _query_helper( @property def state_information(self) -> Dict[str, Any]: """Return the key state information.""" + ssid = self._info.get("ssid") + ssid = base64.b64decode(ssid).decode() if ssid else "No SSID" + return { "overheated": self._info.get("overheated"), "signal_level": self._info.get("signal_level"), - "SSID": base64.b64decode(str(self._info.get("ssid"))).decode(), + "SSID": ssid, } @property diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 986f77b65..077a1f2dd 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -1,4 +1,10 @@ +import inspect +import sys + +import pytest + from kasa.smartprotocol import _ChildProtocolWrapper +from kasa.tapo.childdevice import ChildDevice from .conftest import strip_smart @@ -19,12 +25,48 @@ def test_childdevice_init(dev, dummy_protocol, mocker): @strip_smart async def test_childdevice_update(dev, dummy_protocol, mocker): """Test that parent update updates children.""" - assert len(dev.children) > 0 + child_info = dev._last_update["child_info"] + child_list = child_info["child_device_list"] + + assert len(dev.children) == child_info["sum"] first = dev.children[0] - child_update = mocker.patch.object(first, "update") await dev.update() - child_update.assert_called() assert dev._last_update != first._last_update - assert dev._last_update["child_info"]["child_device_list"][0] == first._last_update + assert child_list[0] == first._last_update + + +@strip_smart +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="exceptiongroup requires python3.11+", +) +async def test_childdevice_properties(dev: ChildDevice): + """Check that accessing childdevice properties do not raise exceptions.""" + assert len(dev.children) > 0 + + first = dev.children[0] + assert first.is_strip_socket + + # children do not have children + assert not first.children + + def _test_property_getters(): + """Try accessing all properties and return a list of encountered exceptions.""" + exceptions = [] + properties = inspect.getmembers( + first.__class__, lambda o: isinstance(o, property) + ) + for prop in properties: + name, _ = prop + try: + _ = getattr(first, name) + except Exception as ex: + exceptions.append(ex) + + return exceptions + + exceptions = list(_test_property_getters()) + if exceptions: + raise ExceptionGroup("Accessing child properties caused exceptions", exceptions) From 414489ff183efc7901901dea785d7ec92b1eb41a Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 2 Feb 2024 20:18:46 +0100 Subject: [PATCH 311/892] Prepare 0.6.2.1 (#736) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2...0.6.2.1) **Fixed bugs:** - Avoid crashing on childdevice property accesses [\#732](https://github.com/python-kasa/python-kasa/pull/732) (@rytilahti) **Merged pull requests:** - Retain last two chars for children device\_id [\#733](https://github.com/python-kasa/python-kasa/pull/733) (@rytilahti) - Add TP15 fixture [\#730](https://github.com/python-kasa/python-kasa/pull/730) (@bdraco) - Add TP25 fixtures [\#729](https://github.com/python-kasa/python-kasa/pull/729) (@bdraco) - Various test code cleanups [\#725](https://github.com/python-kasa/python-kasa/pull/725) (@rytilahti) - Unignore F401 for tests [\#724](https://github.com/python-kasa/python-kasa/pull/724) (@rytilahti) --- CHANGELOG.md | 39 ++++++++++++++++++++++++++++++++++----- pyproject.toml | 2 +- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd238a1c..b01db8c09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [0.6.2.1](https://github.com/python-kasa/python-kasa/tree/0.6.2.1) (2024-02-02) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2...0.6.2.1) + +**Fixed bugs:** + +- Avoid crashing on childdevice property accesses [\#732](https://github.com/python-kasa/python-kasa/pull/732) (@rytilahti) + +**Merged pull requests:** + +- Retain last two chars for children device\_id [\#733](https://github.com/python-kasa/python-kasa/pull/733) (@rytilahti) +- Add TP15 fixture [\#730](https://github.com/python-kasa/python-kasa/pull/730) (@bdraco) +- Add TP25 fixtures [\#729](https://github.com/python-kasa/python-kasa/pull/729) (@bdraco) +- Various test code cleanups [\#725](https://github.com/python-kasa/python-kasa/pull/725) (@rytilahti) +- Unignore F401 for tests [\#724](https://github.com/python-kasa/python-kasa/pull/724) (@rytilahti) + ## [0.6.2](https://github.com/python-kasa/python-kasa/tree/0.6.2) (2024-01-29) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.1...0.6.2) @@ -11,18 +27,33 @@ Release highlights: **Implemented enhancements:** - Implement alias set for tapodevice [\#721](https://github.com/python-kasa/python-kasa/pull/721) (@rytilahti) -- Initial support for tapos with child devices [\#720](https://github.com/python-kasa/python-kasa/pull/720) (@rytilahti) -- Avoid rebuilding urls for every request [\#715](https://github.com/python-kasa/python-kasa/pull/715) (@bdraco) - Reduce the number of times creating the cipher in klap [\#712](https://github.com/python-kasa/python-kasa/pull/712) (@bdraco) - Use hashlib for klap [\#711](https://github.com/python-kasa/python-kasa/pull/711) (@bdraco) +- Initial support for tapos with child devices [\#720](https://github.com/python-kasa/python-kasa/pull/720) (@rytilahti) +- Avoid rebuilding urls for every request [\#715](https://github.com/python-kasa/python-kasa/pull/715) (@bdraco) - Enable batching of multiple requests [\#662](https://github.com/python-kasa/python-kasa/pull/662) (@sdb9696) +- Sleep between discovery packets [\#656](https://github.com/python-kasa/python-kasa/pull/656) (@sdb9696) + +**Fixed bugs:** + +- Fix TapoBulb state information for non-dimmable SMARTSWITCH [\#726](https://github.com/python-kasa/python-kasa/pull/726) (@sdb9696) + +**Documentation updates:** + +- Add protocol and transport documentation [\#663](https://github.com/python-kasa/python-kasa/pull/663) (@sdb9696) + +**Closed issues:** + +- Need to be able to both close and reset transports [\#671](https://github.com/python-kasa/python-kasa/issues/671) +- Improve re-use of protocol code, particularly around retry logic and the IotProtocol [\#649](https://github.com/python-kasa/python-kasa/issues/649) **Merged pull requests:** +- Prepare 0.6.2 [\#728](https://github.com/python-kasa/python-kasa/pull/728) (@rytilahti) - Update L510E\(US\) fixture with mac prefix [\#722](https://github.com/python-kasa/python-kasa/pull/722) (@sdb9696) -- Add P300 fixture [\#717](https://github.com/python-kasa/python-kasa/pull/717) (@rytilahti) - Use hashlib in place of hashes.Hash [\#714](https://github.com/python-kasa/python-kasa/pull/714) (@bdraco) - Switch from TPLinkSmartHomeProtocol to IotProtocol/XorTransport [\#710](https://github.com/python-kasa/python-kasa/pull/710) (@sdb9696) +- Add P300 fixture [\#717](https://github.com/python-kasa/python-kasa/pull/717) (@rytilahti) - Add concrete XorTransport class with full implementation [\#646](https://github.com/python-kasa/python-kasa/pull/646) (@sdb9696) ## [0.6.1](https://github.com/python-kasa/python-kasa/tree/0.6.1) (2024-01-25) @@ -41,7 +72,6 @@ Release highlights: - 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) -- Sleep between discovery packets [\#656](https://github.com/python-kasa/python-kasa/pull/656) (@sdb9696) - Try default tapo credentials for klap and aes [\#685](https://github.com/python-kasa/python-kasa/pull/685) (@sdb9696) **Fixed bugs:** @@ -51,7 +81,6 @@ Release highlights: **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:** diff --git a/pyproject.toml b/pyproject.toml index 7e3a0b93c..70fbe07a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.6.2" +version = "0.6.2.1" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From fae071f0dfa92fc8758fc34d61d198c8874f64c2 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 3 Feb 2024 15:28:20 +0100 Subject: [PATCH 312/892] Fix port-override for aes&klap transports (#734) * Fix port-override for aes&klap transports * Add tests for port override --- kasa/aestransport.py | 5 ++--- kasa/cli.py | 1 + kasa/httpclient.py | 4 ++++ kasa/klaptransport.py | 2 +- kasa/tests/test_aestransport.py | 13 ++++++++++++- kasa/tests/test_klapprotocol.py | 27 +++++++++++++++++++-------- 6 files changed, 39 insertions(+), 13 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index f784390bf..c4668b0a4 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -102,7 +102,7 @@ def __init__( self._session_cookie: Optional[Dict[str, str]] = None self._key_pair: Optional[KeyPair] = None - self._app_url = URL(f"http://{self._host}/app") + self._app_url = URL(f"http://{self._host}:{self._port}/app") self._token_url: Optional[URL] = None _LOGGER.debug("Created AES transport for %s", self._host) @@ -257,7 +257,6 @@ async def perform_handshake(self) -> 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, @@ -266,7 +265,7 @@ async def perform_handshake(self) -> None: http_client = self._http_client status_code, resp_dict = await http_client.post( - url, + self._app_url, json=self._generate_key_pair_payload(), headers=headers, cookies_dict=self._session_cookie, diff --git a/kasa/cli.py b/kasa/cli.py index 42b13b9bb..3906fdcba 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -334,6 +334,7 @@ def _nop_echo(*args, **kwargs): ) config = DeviceConfig( host=host, + port_override=port, credentials=credentials, credentials_hash=credentials_hash, timeout=timeout, diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 659ebdcfd..607efc7f9 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -1,5 +1,6 @@ """Module for HttpClientSession class.""" import asyncio +import logging from typing import Any, Dict, Optional, Tuple, Union import aiohttp @@ -13,6 +14,8 @@ ) from .json import loads as json_loads +_LOGGER = logging.getLogger(__name__) + def get_cookie_jar() -> aiohttp.CookieJar: """Return a new cookie jar with the correct options for device communication.""" @@ -54,6 +57,7 @@ async def post( If the request is provided via the json parameter json will be returned. """ + _LOGGER.debug("Posting to %s", url) response_data = None self._last_url = url self.client.cookie_jar.clear() diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 0e585f2cd..265650d3c 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -121,7 +121,7 @@ def __init__( self._session_cookie: Optional[Dict[str, Any]] = None _LOGGER.debug("Created KLAP transport for %s", self._host) - self._app_url = URL(f"http://{self._host}/app") + self._app_url = URL(f"http://{self._host}:{self._port}/app") self._request_url = self._app_url / "request" @property diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 5d590b7fc..a692ba9be 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -209,6 +209,17 @@ async def test_passthrough_errors(mocker, error_code): await transport.send(json_dumps(request)) +async def test_port_override(): + """Test that port override sets the app_url.""" + host = "127.0.0.1" + config = DeviceConfig( + host, credentials=Credentials("foo", "bar"), port_override=12345 + ) + transport = AesTransport(config=config) + + assert str(transport._app_url) == "http://127.0.0.1:12345/app" + + class MockAesDevice: class _mock_response: def __init__(self, status, json: dict): @@ -256,7 +267,7 @@ async def _post(self, url: URL, json: Dict[str, Any]): elif json["method"] == "login_device": return await self._return_login_response(url, json) else: - assert str(url) == f"http://{self.host}/app?token={self.token}" + assert str(url) == f"http://{self.host}:80/app?token={self.token}" return await self._return_send_response(url, json) async def _return_handshake_response(self, url: URL, json: Dict[str, Any]): diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 1e007b930..fa25439e6 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -323,14 +323,14 @@ async def test_handshake( async def _return_handshake_response(url: URL, params=None, data=None, *_, **__): nonlocal client_seed, server_seed, device_auth_hash - if str(url) == "http://127.0.0.1/app/handshake1": + if str(url) == "http://127.0.0.1:80/app/handshake1": client_seed = data seed_auth_hash = _sha256( seed_auth_hash_calc1(client_seed, server_seed, device_auth_hash) ) return _mock_response(200, server_seed + seed_auth_hash) - elif str(url) == "http://127.0.0.1/app/handshake2": + elif str(url) == "http://127.0.0.1:80/app/handshake2": seed_auth_hash = _sha256( seed_auth_hash_calc2(client_seed, server_seed, device_auth_hash) ) @@ -367,14 +367,14 @@ async def test_query(mocker): async def _return_response(url: URL, params=None, data=None, *_, **__): nonlocal client_seed, server_seed, device_auth_hash, seq - if str(url) == "http://127.0.0.1/app/handshake1": + if str(url) == "http://127.0.0.1:80/app/handshake1": client_seed = data client_seed_auth_hash = _sha256(data + device_auth_hash) return _mock_response(200, server_seed + client_seed_auth_hash) - elif str(url) == "http://127.0.0.1/app/handshake2": + elif str(url) == "http://127.0.0.1:80/app/handshake2": return _mock_response(200, b"") - elif str(url) == "http://127.0.0.1/app/request": + elif str(url) == "http://127.0.0.1:80/app/request": encryption_session = KlapEncryptionSession( protocol._transport._encryption_session.local_seed, protocol._transport._encryption_session.remote_seed, @@ -419,16 +419,16 @@ async def test_authentication_failures(mocker, response_status, expectation): async def _return_response(url: URL, params=None, data=None, *_, **__): nonlocal client_seed, server_seed, device_auth_hash, response_status - if str(url) == "http://127.0.0.1/app/handshake1": + if str(url) == "http://127.0.0.1:80/app/handshake1": client_seed = data client_seed_auth_hash = _sha256(data + device_auth_hash) return _mock_response( response_status[0], server_seed + client_seed_auth_hash ) - elif str(url) == "http://127.0.0.1/app/handshake2": + elif str(url) == "http://127.0.0.1:80/app/handshake2": return _mock_response(response_status[1], b"") - elif str(url) == "http://127.0.0.1/app/request": + elif str(url) == "http://127.0.0.1:80/app/request": return _mock_response(response_status[2], b"") mocker.patch.object(aiohttp.ClientSession, "post", side_effect=_return_response) @@ -438,3 +438,14 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): with expectation: await protocol.query({}) + + +async def test_port_override(): + """Test that port override sets the app_url.""" + host = "127.0.0.1" + config = DeviceConfig( + host, credentials=Credentials("foo", "bar"), port_override=12345 + ) + transport = KlapTransport(config=config) + + assert str(transport._app_url) == "http://127.0.0.1:12345/app" From 6afd05be5970d90c2093767ebd7567ccc20dc641 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 3 Feb 2024 15:28:51 +0100 Subject: [PATCH 313/892] Do not crash cli on missing discovery info (#735) --- kasa/cli.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/kasa/cli.py b/kasa/cli.py index 3906fdcba..04f16fbdf 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -471,6 +471,10 @@ def _echo_dictionary(discovery_info: dict): def _echo_discovery_info(discovery_info): + # We don't have discovery info when all connection params are passed manually + if discovery_info is None: + return + if "system" in discovery_info and "get_sysinfo" in discovery_info["system"]: _echo_dictionary(discovery_info["system"]["get_sysinfo"]) return From 0d119e63d0b741df65cb29fe5b79f6d5b6c57acb Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sun, 4 Feb 2024 15:20:08 +0000 Subject: [PATCH 314/892] Refactor devices into subpackages and deprecate old names (#716) * Refactor devices into subpackages and deprecate old names * Tweak and add tests * Fix linting * Remove duplicate implementations affecting project coverage * Update post review * Add device base class attributes and rename subclasses * Rename Module to BaseModule * Remove has_emeter_history * Fix missing _time in init * Update post review * Fix test_readmeexamples * Fix erroneously duped files * Clean up iot and smart imports * Update post latest review * Tweak Device docstring --- devtools/create_module_fixtures.py | 9 +- devtools/dump_devinfo.py | 10 +- kasa/__init__.py | 67 +++- kasa/bulb.py | 144 +++++++ kasa/cli.py | 100 ++--- kasa/device.py | 353 ++++++++++++++++++ kasa/device_factory.py | 45 ++- kasa/device_type.py | 2 - kasa/discover.py | 25 +- kasa/iot/__init__.py | 16 + kasa/{smartbulb.py => iot/iotbulb.py} | 58 +-- kasa/{smartdevice.py => iot/iotdevice.py} | 227 ++--------- kasa/{smartdimmer.py => iot/iotdimmer.py} | 15 +- .../iotlightstrip.py} | 15 +- kasa/{smartplug.py => iot/iotplug.py} | 13 +- kasa/{smartstrip.py => iot/iotstrip.py} | 31 +- kasa/{ => iot}/modules/__init__.py | 4 +- kasa/{ => iot}/modules/ambientlight.py | 4 +- kasa/{ => iot}/modules/antitheft.py | 0 kasa/{ => iot}/modules/cloud.py | 4 +- kasa/{ => iot}/modules/countdown.py | 0 kasa/{ => iot}/modules/emeter.py | 2 +- kasa/{ => iot}/modules/module.py | 10 +- kasa/{ => iot}/modules/motion.py | 6 +- kasa/{ => iot}/modules/rulemodule.py | 4 +- kasa/{ => iot}/modules/schedule.py | 0 kasa/{ => iot}/modules/time.py | 6 +- kasa/{ => iot}/modules/usage.py | 4 +- kasa/plug.py | 11 + kasa/smart/__init__.py | 7 + kasa/{tapo/tapobulb.py => smart/smartbulb.py} | 24 +- .../smartchilddevice.py} | 6 +- .../tapodevice.py => smart/smartdevice.py} | 60 +-- kasa/{tapo/tapoplug.py => smart/smartplug.py} | 18 +- kasa/tapo/__init__.py | 7 - kasa/tests/conftest.py | 32 +- kasa/tests/test_bulb.py | 79 ++-- kasa/tests/test_childdevice.py | 4 +- kasa/tests/test_cli.py | 35 +- kasa/tests/test_device_factory.py | 4 +- kasa/tests/test_device_type.py | 2 +- kasa/tests/test_dimmer.py | 12 +- kasa/tests/test_discovery.py | 25 +- kasa/tests/test_emeter.py | 20 +- kasa/tests/test_lightstrip.py | 19 +- kasa/tests/test_readme_examples.py | 36 +- kasa/tests/test_smartdevice.py | 70 +++- kasa/tests/test_strip.py | 7 +- kasa/tests/test_usage.py | 2 +- 49 files changed, 1047 insertions(+), 607 deletions(-) create mode 100644 kasa/bulb.py create mode 100644 kasa/device.py create mode 100644 kasa/iot/__init__.py rename kasa/{smartbulb.py => iot/iotbulb.py} (91%) rename kasa/{smartdevice.py => iot/iotdevice.py} (76%) rename kasa/{smartdimmer.py => iot/iotdimmer.py} (95%) rename kasa/{smartlightstrip.py => iot/iotlightstrip.py} (92%) rename kasa/{smartplug.py => iot/iotplug.py} (90%) rename kasa/{smartstrip.py => iot/iotstrip.py} (95%) rename kasa/{ => iot}/modules/__init__.py (91%) rename kasa/{ => iot}/modules/ambientlight.py (96%) rename kasa/{ => iot}/modules/antitheft.py (100%) rename kasa/{ => iot}/modules/cloud.py (96%) rename kasa/{ => iot}/modules/countdown.py (100%) rename kasa/{ => iot}/modules/emeter.py (98%) rename kasa/{ => iot}/modules/module.py (92%) rename kasa/{ => iot}/modules/motion.py (95%) rename kasa/{ => iot}/modules/rulemodule.py (96%) rename kasa/{ => iot}/modules/schedule.py (100%) rename kasa/{ => iot}/modules/time.py (92%) rename kasa/{ => iot}/modules/usage.py (98%) create mode 100644 kasa/plug.py create mode 100644 kasa/smart/__init__.py rename kasa/{tapo/tapobulb.py => smart/smartbulb.py} (92%) rename kasa/{tapo/childdevice.py => smart/smartchilddevice.py} (93%) rename kasa/{tapo/tapodevice.py => smart/smartdevice.py} (89%) rename kasa/{tapo/tapoplug.py => smart/smartplug.py} (62%) delete mode 100644 kasa/tapo/__init__.py diff --git a/devtools/create_module_fixtures.py b/devtools/create_module_fixtures.py index 1e0f17f72..8372bfff5 100644 --- a/devtools/create_module_fixtures.py +++ b/devtools/create_module_fixtures.py @@ -6,15 +6,17 @@ import asyncio import json from pathlib import Path +from typing import cast import typer -from kasa import Discover, SmartDevice +from kasa import Discover +from kasa.iot import IotDevice app = typer.Typer() -def create_fixtures(dev: SmartDevice, outputdir: Path): +def create_fixtures(dev: IotDevice, outputdir: Path): """Iterate over supported modules and create version-specific fixture files.""" for name, module in dev.modules.items(): module_dir = outputdir / name @@ -43,13 +45,14 @@ def create_module_fixtures( """Create module fixtures for given host/network.""" devs = [] if host is not None: - dev: SmartDevice = asyncio.run(Discover.discover_single(host)) + dev: IotDevice = cast(IotDevice, asyncio.run(Discover.discover_single(host))) devs.append(dev) else: if network is None: network = "255.255.255.255" devs = asyncio.run(Discover.discover(target=network)).values() for dev in devs: + dev = cast(IotDevice, dev) asyncio.run(dev.update()) for dev in devs: diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 005eb7993..c1436aa12 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -23,14 +23,14 @@ from kasa import ( AuthenticationException, Credentials, + Device, Discover, - SmartDevice, SmartDeviceException, TimeoutException, ) from kasa.discover import DiscoveryResult from kasa.exceptions import SmartErrorCode -from kasa.tapo.tapodevice import TapoDevice +from kasa.smart import SmartDevice Call = namedtuple("Call", "module method") SmartCall = namedtuple("SmartCall", "module request should_succeed") @@ -119,9 +119,9 @@ def default_to_regular(d): return d -async def handle_device(basedir, autosave, device: SmartDevice, batch_size: int): +async def handle_device(basedir, autosave, device: Device, batch_size: int): """Create a fixture for a single device instance.""" - if isinstance(device, TapoDevice): + if isinstance(device, SmartDevice): filename, copy_folder, final = await get_smart_fixture(device, batch_size) else: filename, copy_folder, final = await get_legacy_fixture(device) @@ -319,7 +319,7 @@ async def _make_requests_or_exit( exit(1) -async def get_smart_fixture(device: TapoDevice, batch_size: int): +async def get_smart_fixture(device: SmartDevice, batch_size: int): """Get fixture for new TAPO style protocol.""" extra_test_calls = [ SmartCall( diff --git a/kasa/__init__.py b/kasa/__init__.py index 121413b67..0d9e0c3eb 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -12,9 +12,13 @@ to be handled by the user of the library. """ from importlib.metadata import version +from typing import TYPE_CHECKING from warnings import warn +from kasa.bulb import Bulb from kasa.credentials import Credentials +from kasa.device import Device +from kasa.device_type import DeviceType from kasa.deviceconfig import ( ConnectionType, DeviceConfig, @@ -29,18 +33,14 @@ TimeoutException, UnsupportedDeviceException, ) +from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.iotprotocol import ( IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 ) +from kasa.plug import Plug from kasa.protocol import BaseProtocol -from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors -from kasa.smartdevice import DeviceType, SmartDevice -from kasa.smartdimmer import SmartDimmer -from kasa.smartlightstrip import SmartLightStrip -from kasa.smartplug import SmartPlug from kasa.smartprotocol import SmartProtocol -from kasa.smartstrip import SmartStrip __version__ = version("python-kasa") @@ -50,18 +50,15 @@ "BaseProtocol", "IotProtocol", "SmartProtocol", - "SmartBulb", - "SmartBulbPreset", + "BulbPreset", "TurnOnBehaviors", "TurnOnBehavior", "DeviceType", "EmeterStatus", - "SmartDevice", + "Device", + "Bulb", + "Plug", "SmartDeviceException", - "SmartPlug", - "SmartStrip", - "SmartDimmer", - "SmartLightStrip", "AuthenticationException", "UnsupportedDeviceException", "TimeoutException", @@ -72,11 +69,55 @@ "DeviceFamilyType", ] +from . import iot + deprecated_names = ["TPLinkSmartHomeProtocol"] +deprecated_smart_devices = { + "SmartDevice": iot.IotDevice, + "SmartPlug": iot.IotPlug, + "SmartBulb": iot.IotBulb, + "SmartLightStrip": iot.IotLightStrip, + "SmartStrip": iot.IotStrip, + "SmartDimmer": iot.IotDimmer, + "SmartBulbPreset": BulbPreset, +} def __getattr__(name): if name in deprecated_names: warn(f"{name} is deprecated", DeprecationWarning, stacklevel=1) return globals()[f"_deprecated_{name}"] + if name in deprecated_smart_devices: + new_class = deprecated_smart_devices[name] + package_name = ".".join(new_class.__module__.split(".")[:-1]) + warn( + f"{name} is deprecated, use {new_class.__name__} " + + f"from package {package_name} instead or use Discover.discover_single()" + + " and Device.connect() to support new protocols", + DeprecationWarning, + stacklevel=1, + ) + return new_class raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +if TYPE_CHECKING: + SmartDevice = Device + SmartBulb = iot.IotBulb + SmartPlug = iot.IotPlug + SmartLightStrip = iot.IotLightStrip + SmartStrip = iot.IotStrip + SmartDimmer = iot.IotDimmer + SmartBulbPreset = BulbPreset + # Instanstiate all classes so the type checkers catch abstract issues + from . import smart + + smart.SmartDevice("127.0.0.1") + smart.SmartPlug("127.0.0.1") + smart.SmartBulb("127.0.0.1") + iot.IotDevice("127.0.0.1") + iot.IotPlug("127.0.0.1") + iot.IotBulb("127.0.0.1") + iot.IotLightStrip("127.0.0.1") + iot.IotStrip("127.0.0.1") + iot.IotDimmer("127.0.0.1") diff --git a/kasa/bulb.py b/kasa/bulb.py new file mode 100644 index 000000000..5db6e5b75 --- /dev/null +++ b/kasa/bulb.py @@ -0,0 +1,144 @@ +"""Module for Device base class.""" +from abc import ABC, abstractmethod +from typing import Dict, List, NamedTuple, Optional + +from .device import Device + +try: + from pydantic.v1 import BaseModel +except ImportError: + from pydantic import BaseModel + + +class ColorTempRange(NamedTuple): + """Color temperature range.""" + + min: int + max: int + + +class HSV(NamedTuple): + """Hue-saturation-value.""" + + hue: int + saturation: int + value: int + + +class BulbPreset(BaseModel): + """Bulb configuration preset.""" + + index: int + brightness: int + + # These are not available for effect mode presets on light strips + hue: Optional[int] + saturation: Optional[int] + color_temp: Optional[int] + + # Variables for effect mode presets + custom: Optional[int] + id: Optional[str] + mode: Optional[int] + + +class Bulb(Device, ABC): + """Base class for TP-Link Bulb.""" + + def _raise_for_invalid_brightness(self, value): + if not isinstance(value, int) or not (0 <= value <= 100): + raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") + + @property + @abstractmethod + def is_color(self) -> bool: + """Whether the bulb supports color changes.""" + + @property + @abstractmethod + def is_dimmable(self) -> bool: + """Whether the bulb supports brightness changes.""" + + @property + @abstractmethod + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + + @property + @abstractmethod + def valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + + @property + @abstractmethod + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + + @property + @abstractmethod + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + + @property + @abstractmethod + def color_temp(self) -> int: + """Whether the bulb supports color temperature changes.""" + + @property + @abstractmethod + def brightness(self) -> int: + """Return the current brightness in percentage.""" + + @abstractmethod + async def set_hsv( + self, + hue: int, + saturation: int, + value: Optional[int] = None, + *, + transition: Optional[int] = None, + ) -> Dict: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value in percentage [0, 100] + :param int transition: transition in milliseconds. + """ + + @abstractmethod + async def set_color_temp( + self, temp: int, *, brightness=None, transition: Optional[int] = None + ) -> Dict: + """Set the color temperature of the device in kelvin. + + Note, transition is not supported and will be ignored. + + :param int temp: The new color temperature, in Kelvin + :param int transition: transition in milliseconds. + """ + + @abstractmethod + async def set_brightness( + self, brightness: int, *, transition: Optional[int] = None + ) -> Dict: + """Set the brightness in percentage. + + Note, transition is not supported and will be ignored. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + + @property + @abstractmethod + def presets(self) -> List[BulbPreset]: + """Return a list of available bulb setting presets.""" diff --git a/kasa/cli.py b/kasa/cli.py index 04f16fbdf..74c32e4e9 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -13,21 +13,20 @@ from kasa import ( AuthenticationException, + Bulb, ConnectionType, Credentials, + Device, DeviceConfig, DeviceFamilyType, Discover, EncryptType, - SmartBulb, - SmartDevice, - SmartDimmer, - SmartLightStrip, - SmartPlug, - SmartStrip, + SmartDeviceException, UnsupportedDeviceException, ) from kasa.discover import DiscoveryResult +from kasa.iot import IotBulb, IotDevice, IotDimmer, IotLightStrip, IotPlug, IotStrip +from kasa.smart import SmartBulb, SmartDevice, SmartPlug try: from pydantic.v1 import ValidationError @@ -62,11 +61,18 @@ def wrapper(message=None, *args, **kwargs): TYPE_TO_CLASS = { - "plug": SmartPlug, - "bulb": SmartBulb, - "dimmer": SmartDimmer, - "strip": SmartStrip, - "lightstrip": SmartLightStrip, + "plug": IotPlug, + "bulb": IotBulb, + "dimmer": IotDimmer, + "strip": IotStrip, + "lightstrip": IotLightStrip, + "iot.plug": IotPlug, + "iot.bulb": IotBulb, + "iot.dimmer": IotDimmer, + "iot.strip": IotStrip, + "iot.lightstrip": IotLightStrip, + "smart.plug": SmartPlug, + "smart.bulb": SmartBulb, } ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in EncryptType] @@ -80,7 +86,7 @@ def wrapper(message=None, *args, **kwargs): click.anyio_backend = "asyncio" -pass_dev = click.make_pass_decorator(SmartDevice) +pass_dev = click.make_pass_decorator(Device) class ExceptionHandlerGroup(click.Group): @@ -110,8 +116,8 @@ def to_serializable(val): """ return str(val) - @to_serializable.register(SmartDevice) - def _device_to_serializable(val: SmartDevice): + @to_serializable.register(Device) + def _device_to_serializable(val: Device): """Serialize smart device data, just using the last update raw payload.""" return val.internal_state @@ -261,7 +267,7 @@ async def cli( # no need to perform any checks if we are just displaying the help if sys.argv[-1] == "--help": # Context object is required to avoid crashing on sub-groups - ctx.obj = SmartDevice(None) + ctx.obj = Device(None) return # If JSON output is requested, disable echo @@ -340,7 +346,7 @@ def _nop_echo(*args, **kwargs): timeout=timeout, connection_type=ctype, ) - dev = await SmartDevice.connect(config=config) + dev = await Device.connect(config=config) else: echo("No --type or --device-family and --encrypt-type defined, discovering..") dev = await Discover.discover_single( @@ -384,7 +390,7 @@ async def scan(dev): @click.option("--keytype", prompt=True) @click.option("--password", prompt=True, hide_input=True) @pass_dev -async def join(dev: SmartDevice, ssid: str, password: str, keytype: str): +async def join(dev: Device, ssid: str, password: str, keytype: str): """Join the given wifi network.""" echo(f"Asking the device to connect to {ssid}..") res = await dev.wifi_join(ssid, password, keytype=keytype) @@ -428,7 +434,7 @@ async def print_unsupported(unsupported_exception: UnsupportedDeviceException): echo(f"Discovering devices on {target} for {discovery_timeout} seconds") - async def print_discovered(dev: SmartDevice): + async def print_discovered(dev: Device): async with sem: try: await dev.update() @@ -526,7 +532,7 @@ async def sysinfo(dev): @cli.command() @pass_dev @click.pass_context -async def state(ctx, dev: SmartDevice): +async def state(ctx, dev: Device): """Print out device state and versions.""" verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False @@ -589,7 +595,6 @@ async def alias(dev, new_alias, index): if not dev.is_strip: echo("Index can only used for power strips!") return - dev = cast(SmartStrip, dev) dev = dev.get_plug_by_index(index) if new_alias is not None: @@ -611,7 +616,7 @@ async def alias(dev, new_alias, index): @click.argument("module") @click.argument("command") @click.argument("parameters", default=None, required=False) -async def raw_command(ctx, dev: SmartDevice, module, command, parameters): +async def raw_command(ctx, dev: Device, module, command, parameters): """Run a raw command on the device.""" logging.warning("Deprecated, use 'kasa command --module %s %s'", module, command) return await ctx.forward(cmd_command) @@ -622,12 +627,17 @@ async def raw_command(ctx, dev: SmartDevice, module, command, parameters): @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): +async def cmd_command(dev: Device, module, command, parameters): """Run a raw command on the device.""" if parameters is not None: parameters = ast.literal_eval(parameters) - res = await dev._query_helper(module, command, parameters) + if isinstance(dev, IotDevice): + res = await dev._query_helper(module, command, parameters) + elif isinstance(dev, SmartDevice): + res = await dev._query_helper(command, parameters) + else: + raise SmartDeviceException("Unexpected device type %s.", dev) echo(json.dumps(res)) return res @@ -639,7 +649,7 @@ async def cmd_command(dev: SmartDevice, module, command, parameters): @click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) @click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) @click.option("--erase", is_flag=True) -async def emeter(dev: SmartDevice, index: int, name: str, year, month, erase): +async def emeter(dev: Device, index: int, name: str, year, month, erase): """Query emeter for historical consumption. Daily and monthly data provided in CSV format. @@ -649,7 +659,6 @@ async def emeter(dev: SmartDevice, index: int, name: str, year, month, erase): echo("Index and name are only for power strips!") return - dev = cast(SmartStrip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -660,6 +669,12 @@ async def emeter(dev: SmartDevice, index: int, name: str, year, month, erase): echo("Device has no emeter") return + if (year or month or erase) and not isinstance(dev, IotDevice): + echo("Device has no historical statistics") + return + else: + dev = cast(IotDevice, dev) + if erase: echo("Erasing emeter statistics..") return await dev.erase_emeter_stats() @@ -701,7 +716,7 @@ async def emeter(dev: SmartDevice, index: int, name: str, year, month, erase): @click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) @click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) @click.option("--erase", is_flag=True) -async def usage(dev: SmartDevice, year, month, erase): +async def usage(dev: Device, year, month, erase): """Query usage for historical consumption. Daily and monthly data provided in CSV format. @@ -739,7 +754,7 @@ async def usage(dev: SmartDevice, year, month, erase): @click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) @click.option("--transition", type=int, required=False) @pass_dev -async def brightness(dev: SmartBulb, brightness: int, transition: int): +async def brightness(dev: Bulb, brightness: int, transition: int): """Get or set brightness.""" if not dev.is_dimmable: echo("This device does not support brightness.") @@ -759,7 +774,7 @@ async def brightness(dev: SmartBulb, brightness: int, transition: int): ) @click.option("--transition", type=int, required=False) @pass_dev -async def temperature(dev: SmartBulb, temperature: int, transition: int): +async def temperature(dev: Bulb, temperature: int, transition: int): """Get or set color temperature.""" if not dev.is_variable_color_temp: echo("Device does not support color temperature") @@ -852,14 +867,13 @@ async def time(dev): @click.option("--name", type=str, required=False) @click.option("--transition", type=int, required=False) @pass_dev -async def on(dev: SmartDevice, index: int, name: str, transition: int): +async def on(dev: Device, index: int, name: str, transition: int): """Turn the device on.""" if index is not None or name is not None: if not dev.is_strip: echo("Index and name are only for power strips!") return - dev = cast(SmartStrip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -874,14 +888,13 @@ async def on(dev: SmartDevice, index: int, name: str, transition: int): @click.option("--name", type=str, required=False) @click.option("--transition", type=int, required=False) @pass_dev -async def off(dev: SmartDevice, index: int, name: str, transition: int): +async def off(dev: Device, index: int, name: str, transition: int): """Turn the device off.""" if index is not None or name is not None: if not dev.is_strip: echo("Index and name are only for power strips!") return - dev = cast(SmartStrip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -896,14 +909,13 @@ async def off(dev: SmartDevice, index: int, name: str, transition: int): @click.option("--name", type=str, required=False) @click.option("--transition", type=int, required=False) @pass_dev -async def toggle(dev: SmartDevice, index: int, name: str, transition: int): +async def toggle(dev: Device, index: int, name: str, transition: int): """Toggle the device on/off.""" if index is not None or name is not None: if not dev.is_strip: echo("Index and name are only for power strips!") return - dev = cast(SmartStrip, dev) if index is not None: dev = dev.get_plug_by_index(index) elif name: @@ -970,10 +982,10 @@ async def presets(ctx): @presets.command(name="list") @pass_dev -def presets_list(dev: SmartBulb): +def presets_list(dev: IotBulb): """List presets.""" - if not dev.is_bulb: - echo("Presets only supported on bulbs") + if not dev.is_bulb or not isinstance(dev, IotBulb): + echo("Presets only supported on iot bulbs") return for preset in dev.presets: @@ -989,9 +1001,7 @@ def presets_list(dev: SmartBulb): @click.option("--saturation", type=int) @click.option("--temperature", type=int) @pass_dev -async def presets_modify( - dev: SmartBulb, index, brightness, hue, saturation, temperature -): +async def presets_modify(dev: IotBulb, index, brightness, hue, saturation, temperature): """Modify a preset.""" for preset in dev.presets: if preset.index == index: @@ -1019,8 +1029,11 @@ async def presets_modify( @click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False)) @click.option("--last", is_flag=True) @click.option("--preset", type=int) -async def turn_on_behavior(dev: SmartBulb, type, last, preset): +async def turn_on_behavior(dev: IotBulb, type, last, preset): """Modify bulb turn-on behavior.""" + if not dev.is_bulb or not isinstance(dev, IotBulb): + echo("Presets only supported on iot bulbs") + return settings = await dev.get_turn_on_behavior() echo(f"Current turn on behavior: {settings}") @@ -1055,10 +1068,7 @@ async def turn_on_behavior(dev: SmartBulb, type, last, preset): ) async def update_credentials(dev, username, password): """Update device credentials for authenticated devices.""" - # Importing here as this is not really a public interface for now - from kasa.tapo import TapoDevice - - if not isinstance(dev, TapoDevice): + if not isinstance(dev, SmartDevice): raise NotImplementedError( "Credentials can only be updated on authenticated devices." ) diff --git a/kasa/device.py b/kasa/device.py new file mode 100644 index 000000000..48537ff56 --- /dev/null +++ b/kasa/device.py @@ -0,0 +1,353 @@ +"""Module for Device base class.""" +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Dict, List, Optional, Sequence, Set, Union + +from .credentials import Credentials +from .device_type import DeviceType +from .deviceconfig import DeviceConfig +from .emeterstatus import EmeterStatus +from .exceptions import SmartDeviceException +from .iotprotocol import IotProtocol +from .protocol import BaseProtocol +from .xortransport import XorTransport + + +@dataclass +class WifiNetwork: + """Wifi network container.""" + + ssid: str + key_type: int + # These are available only on softaponboarding + cipher_type: Optional[int] = None + bssid: Optional[str] = None + channel: Optional[int] = None + rssi: Optional[int] = None + + # For SMART devices + signal_level: Optional[int] = None + + +_LOGGER = logging.getLogger(__name__) + + +class Device(ABC): + """Common device interface. + + Do not instantiate this class directly, instead get a device instance from + :func:`Device.connect()`, :func:`Discover.discover()` + or :func:`Discover.discover_single()`. + """ + + def __init__( + self, + host: str, + *, + config: Optional[DeviceConfig] = None, + protocol: Optional[BaseProtocol] = None, + ) -> None: + """Create a new Device instance. + + :param str host: host name or IP address of the device + :param DeviceConfig config: device configuration + :param BaseProtocol protocol: protocol for communicating with the device + """ + if config and protocol: + protocol._transport._config = config + self.protocol: BaseProtocol = protocol or IotProtocol( + transport=XorTransport(config=config or DeviceConfig(host=host)), + ) + _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) + self._device_type = DeviceType.Unknown + # TODO: typing Any is just as using Optional[Dict] would require separate + # checks in accessors. the @updated_required decorator does not ensure + # mypy that these are not accessed incorrectly. + self._last_update: Any = None + self._discovery_info: Optional[Dict[str, Any]] = None + + self.modules: Dict[str, Any] = {} + + @staticmethod + async def connect( + *, + host: Optional[str] = None, + config: Optional[DeviceConfig] = None, + ) -> "Device": + """Connect to a single device by the given hostname or device configuration. + + This method avoids the UDP based discovery process and + will connect directly to the device. + + It is generally preferred to avoid :func:`discover_single()` and + use this function 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 config: Connection parameters to ensure the correct protocol + and connection options are used. + :rtype: SmartDevice + :return: Object for querying/controlling found device. + """ + from .device_factory import connect # pylint: disable=import-outside-toplevel + + return await connect(host=host, config=config) # type: ignore[arg-type] + + @abstractmethod + async def update(self, update_children: bool = True): + """Update the device.""" + + async def disconnect(self): + """Disconnect and close any underlying connection resources.""" + await self.protocol.close() + + @property + @abstractmethod + def is_on(self) -> bool: + """Return true if the device is on.""" + + @property + def is_off(self) -> bool: + """Return True if device is off.""" + return not self.is_on + + @abstractmethod + async def turn_on(self, **kwargs) -> Optional[Dict]: + """Turn on the device.""" + + @abstractmethod + async def turn_off(self, **kwargs) -> Optional[Dict]: + """Turn off the device.""" + + @property + def host(self) -> str: + """The device host.""" + return self.protocol._transport._host + + @host.setter + def host(self, value): + """Set the device host. + + Generally used by discovery to set the hostname after ip discovery. + """ + self.protocol._transport._host = value + self.protocol._transport._config.host = value + + @property + def port(self) -> int: + """The device port.""" + return self.protocol._transport._port + + @property + def credentials(self) -> Optional[Credentials]: + """The device credentials.""" + return self.protocol._transport._credentials + + @property + def credentials_hash(self) -> Optional[str]: + """The protocol specific hash of the credentials the device is using.""" + return self.protocol._transport.credentials_hash + + @property + def device_type(self) -> DeviceType: + """Return the device type.""" + return self._device_type + + @abstractmethod + def update_from_discover_info(self, info): + """Update state from info from the discover call.""" + + @property + def config(self) -> DeviceConfig: + """Return the device configuration.""" + return self.protocol.config + + @property + @abstractmethod + def model(self) -> str: + """Returns the device model.""" + + @property + @abstractmethod + def alias(self) -> Optional[str]: + """Returns the device alias or nickname.""" + + async def _raw_query(self, request: Union[str, Dict]) -> Any: + """Send a raw query to the device.""" + return await self.protocol.query(request=request) + + @property + @abstractmethod + def children(self) -> Sequence["Device"]: + """Returns the child devices.""" + + @property + @abstractmethod + def sys_info(self) -> Dict[str, Any]: + """Returns the device info.""" + + @property + def is_bulb(self) -> bool: + """Return True if the device is a bulb.""" + return self._device_type == DeviceType.Bulb + + @property + def is_light_strip(self) -> bool: + """Return True if the device is a led strip.""" + return self._device_type == DeviceType.LightStrip + + @property + def is_plug(self) -> bool: + """Return True if the device is a plug.""" + return self._device_type == DeviceType.Plug + + @property + def is_strip(self) -> bool: + """Return True if the device is a strip.""" + return self._device_type == DeviceType.Strip + + @property + def is_strip_socket(self) -> bool: + """Return True if the device is a strip socket.""" + return self._device_type == DeviceType.StripSocket + + @property + def is_dimmer(self) -> bool: + """Return True if the device is a dimmer.""" + return self._device_type == DeviceType.Dimmer + + @property + def is_dimmable(self) -> bool: + """Return True if the device is dimmable.""" + return False + + @property + def is_variable_color_temp(self) -> bool: + """Return True if the device supports color temperature.""" + return False + + @property + def is_color(self) -> bool: + """Return True if the device supports color changes.""" + return False + + def get_plug_by_name(self, name: str) -> "Device": + """Return child device for the given name.""" + for p in self.children: + if p.alias == name: + return p + + raise SmartDeviceException(f"Device has no child with {name}") + + def get_plug_by_index(self, index: int) -> "Device": + """Return child device for the given index.""" + if index + 1 > len(self.children) or index < 0: + raise SmartDeviceException( + f"Invalid index {index}, device has {len(self.children)} plugs" + ) + return self.children[index] + + @property + @abstractmethod + def time(self) -> datetime: + """Return the time.""" + + @property + @abstractmethod + def timezone(self) -> Dict: + """Return the timezone and time_difference.""" + + @property + @abstractmethod + def hw_info(self) -> Dict: + """Return hardware info for the device.""" + + @property + @abstractmethod + def location(self) -> Dict: + """Return the device location.""" + + @property + @abstractmethod + def rssi(self) -> Optional[int]: + """Return the rssi.""" + + @property + @abstractmethod + def mac(self) -> str: + """Return the mac formatted with colons.""" + + @property + @abstractmethod + def device_id(self) -> str: + """Return the device id.""" + + @property + @abstractmethod + def internal_state(self) -> Any: + """Return all the internal state data.""" + + @property + @abstractmethod + def state_information(self) -> Dict[str, Any]: + """Return the key state information.""" + + @property + @abstractmethod + def features(self) -> Set[str]: + """Return the list of supported features.""" + + @property + @abstractmethod + def has_emeter(self) -> bool: + """Return if the device has emeter.""" + + @property + @abstractmethod + def on_since(self) -> Optional[datetime]: + """Return the time that the device was turned on or None if turned off.""" + + @abstractmethod + async def get_emeter_realtime(self) -> EmeterStatus: + """Retrieve current energy readings.""" + + @property + @abstractmethod + def emeter_realtime(self) -> EmeterStatus: + """Get the emeter status.""" + + @property + @abstractmethod + def emeter_this_month(self) -> Optional[float]: + """Get the emeter value for this month.""" + + @property + @abstractmethod + def emeter_today(self) -> Union[Optional[float], Any]: + """Get the emeter value for today.""" + # Return type of Any ensures consumers being shielded from the return + # type by @update_required are not affected. + + @abstractmethod + async def wifi_scan(self) -> List[WifiNetwork]: + """Scan for available wifi networks.""" + + @abstractmethod + async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): + """Join the given wifi network.""" + + @abstractmethod + async def set_alias(self, alias: str): + """Set the device name (alias).""" + + def __repr__(self): + if self._last_update is None: + return f"<{self._device_type} at {self.host} - update() needed>" + return ( + f"<{self._device_type} model {self.model} at {self.host}" + f" ({self.alias}), is_on: {self.is_on}" + f" - dev specific: {self.state_information}>" + ) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index fdb5b1b49..28a5e3b2b 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -4,22 +4,18 @@ from typing import Any, Dict, Optional, Tuple, Type from .aestransport import AesTransport +from .device import Device from .deviceconfig import DeviceConfig from .exceptions import SmartDeviceException, UnsupportedDeviceException +from .iot import IotBulb, IotDevice, IotDimmer, IotLightStrip, IotPlug, IotStrip from .iotprotocol import IotProtocol from .klaptransport import KlapTransport, KlapTransportV2 from .protocol import ( BaseProtocol, BaseTransport, ) -from .smartbulb import SmartBulb -from .smartdevice import SmartDevice -from .smartdimmer import SmartDimmer -from .smartlightstrip import SmartLightStrip -from .smartplug import SmartPlug +from .smart import SmartBulb, SmartPlug from .smartprotocol import SmartProtocol -from .smartstrip import SmartStrip -from .tapo import TapoBulb, TapoPlug from .xortransport import XorTransport _LOGGER = logging.getLogger(__name__) @@ -29,7 +25,7 @@ } -async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "SmartDevice": +async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Device": """Connect to a single device by the given hostname or device configuration. This method avoids the UDP based discovery process and @@ -73,7 +69,8 @@ def _perf_log(has_params, perf_type): + f"{config.connection_type.device_family.value}" ) - device_class: Optional[Type[SmartDevice]] + device_class: Optional[Type[Device]] + device: Optional[Device] = None if isinstance(protocol, IotProtocol) and isinstance( protocol._transport, XorTransport @@ -100,7 +97,7 @@ def _perf_log(has_params, perf_type): ) -def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[SmartDevice]: +def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[IotDevice]: """Find SmartDevice subclass for device described by passed data.""" if "system" not in info or "get_sysinfo" not in info["system"]: raise SmartDeviceException("No 'system' or 'get_sysinfo' in response") @@ -111,32 +108,32 @@ def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[SmartDevice]: raise SmartDeviceException("Unable to find the device type field!") if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: - return SmartDimmer + return IotDimmer if "smartplug" in type_.lower(): if "children" in sysinfo: - return SmartStrip + return IotStrip - return SmartPlug + return IotPlug if "smartbulb" in type_.lower(): if "length" in sysinfo: # strips have length - return SmartLightStrip + return IotLightStrip - return SmartBulb + return IotBulb raise UnsupportedDeviceException("Unknown device type: %s" % type_) -def get_device_class_from_family(device_type: str) -> Optional[Type[SmartDevice]]: +def get_device_class_from_family(device_type: str) -> Optional[Type[Device]]: """Return the device class from the type name.""" - 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, - "IOT.SMARTBULB": SmartBulb, + supported_device_types: Dict[str, Type[Device]] = { + "SMART.TAPOPLUG": SmartPlug, + "SMART.TAPOBULB": SmartBulb, + "SMART.TAPOSWITCH": SmartBulb, + "SMART.KASAPLUG": SmartPlug, + "SMART.KASASWITCH": SmartBulb, + "IOT.SMARTPLUGSWITCH": IotPlug, + "IOT.SMARTBULB": IotBulb, } return supported_device_types.get(device_type) diff --git a/kasa/device_type.py b/kasa/device_type.py index 8373d730c..162fc4f27 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -14,8 +14,6 @@ class DeviceType(Enum): StripSocket = "stripsocket" Dimmer = "dimmer" LightStrip = "lightstrip" - TapoPlug = "tapoplug" - TapoBulb = "tapobulb" Unknown = "unknown" @staticmethod diff --git a/kasa/discover.py b/kasa/discover.py index 8286387ae..858109e2b 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -15,6 +15,7 @@ except ImportError: from pydantic import BaseModel, ValidationError # pragma: no cover +from kasa import Device from kasa.credentials import Credentials from kasa.device_factory import ( get_device_class_from_family, @@ -22,17 +23,21 @@ get_protocol, ) from kasa.deviceconfig import ConnectionType, DeviceConfig, EncryptType -from kasa.exceptions import TimeoutException, UnsupportedDeviceException +from kasa.exceptions import ( + SmartDeviceException, + TimeoutException, + UnsupportedDeviceException, +) +from kasa.iot.iotdevice import IotDevice from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads -from kasa.smartdevice import SmartDevice, SmartDeviceException from kasa.xortransport import XorEncryption _LOGGER = logging.getLogger(__name__) -OnDiscoveredCallable = Callable[[SmartDevice], Awaitable[None]] -DeviceDict = Dict[str, SmartDevice] +OnDiscoveredCallable = Callable[[Device], Awaitable[None]] +DeviceDict = Dict[str, Device] class _DiscoverProtocol(asyncio.DatagramProtocol): @@ -121,7 +126,7 @@ def datagram_received(self, data, addr) -> None: return self.seen_hosts.add(ip) - device = None + device: Optional[Device] = None config = DeviceConfig(host=ip, port_override=self.port) if self.credentials: @@ -300,7 +305,7 @@ async def discover_single( port: Optional[int] = None, timeout: Optional[int] = None, credentials: Optional[Credentials] = None, - ) -> SmartDevice: + ) -> Device: """Discover a single device by the given IP address. It is generally preferred to avoid :func:`discover_single()` and @@ -382,7 +387,7 @@ async def discover_single( raise SmartDeviceException(f"Unable to get discovery response for {host}") @staticmethod - def _get_device_class(info: dict) -> Type[SmartDevice]: + def _get_device_class(info: dict) -> Type[Device]: """Find SmartDevice subclass for device described by passed data.""" if "result" in info: discovery_result = DiscoveryResult(**info["result"]) @@ -397,7 +402,7 @@ def _get_device_class(info: dict) -> Type[SmartDevice]: return get_device_class_from_sys_info(info) @staticmethod - def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> SmartDevice: + def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice: """Get SmartDevice from legacy 9999 response.""" try: info = json_loads(XorEncryption.decrypt(data)) @@ -408,7 +413,7 @@ def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> SmartDevic _LOGGER.debug("[DISCOVERY] %s << %s", config.host, info) - device_class = Discover._get_device_class(info) + device_class = cast(Type[IotDevice], Discover._get_device_class(info)) device = device_class(config.host, config=config) sys_info = info["system"]["get_sysinfo"] if device_type := sys_info.get("mic_type", sys_info.get("type")): @@ -423,7 +428,7 @@ def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> SmartDevic def _get_device_instance( data: bytes, config: DeviceConfig, - ) -> SmartDevice: + ) -> Device: """Get SmartDevice from the new 20002 response.""" try: info = json_loads(data[16:]) diff --git a/kasa/iot/__init__.py b/kasa/iot/__init__.py new file mode 100644 index 000000000..2ee03d694 --- /dev/null +++ b/kasa/iot/__init__.py @@ -0,0 +1,16 @@ +"""Package for supporting legacy kasa devices.""" +from .iotbulb import IotBulb +from .iotdevice import IotDevice +from .iotdimmer import IotDimmer +from .iotlightstrip import IotLightStrip +from .iotplug import IotPlug +from .iotstrip import IotStrip + +__all__ = [ + "IotDevice", + "IotPlug", + "IotBulb", + "IotStrip", + "IotDimmer", + "IotLightStrip", +] diff --git a/kasa/smartbulb.py b/kasa/iot/iotbulb.py similarity index 91% rename from kasa/smartbulb.py rename to kasa/iot/iotbulb.py index 5b5ae573f..7712f3d7e 100644 --- a/kasa/smartbulb.py +++ b/kasa/iot/iotbulb.py @@ -2,49 +2,19 @@ import logging import re from enum import Enum -from typing import Any, Dict, List, NamedTuple, Optional, cast +from typing import Any, Dict, List, Optional, cast try: from pydantic.v1 import BaseModel, Field, root_validator except ImportError: from pydantic import BaseModel, Field, root_validator -from .deviceconfig import DeviceConfig +from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..protocol import BaseProtocol +from .iotdevice import IotDevice, SmartDeviceException, requires_update from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage -from .protocol import BaseProtocol -from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update - - -class ColorTempRange(NamedTuple): - """Color temperature range.""" - - min: int - max: int - - -class HSV(NamedTuple): - """Hue-saturation-value.""" - - hue: int - saturation: int - value: int - - -class SmartBulbPreset(BaseModel): - """Bulb configuration preset.""" - - index: int - brightness: int - - # These are not available for effect mode presets on light strips - hue: Optional[int] - saturation: Optional[int] - color_temp: Optional[int] - - # Variables for effect mode presets - custom: Optional[int] - id: Optional[str] - mode: Optional[int] class BehaviorMode(str, Enum): @@ -116,7 +86,7 @@ class TurnOnBehaviors(BaseModel): _LOGGER = logging.getLogger(__name__) -class SmartBulb(SmartDevice): +class IotBulb(IotDevice, Bulb): r"""Representation of a TP-Link Smart Bulb. To initialize, you have to await :func:`update()` at least once. @@ -132,7 +102,7 @@ class SmartBulb(SmartDevice): Examples: >>> import asyncio - >>> bulb = SmartBulb("127.0.0.1") + >>> bulb = IotBulb("127.0.0.1") >>> asyncio.run(bulb.update()) >>> print(bulb.alias) Bulb2 @@ -198,7 +168,7 @@ class SmartBulb(SmartDevice): Bulb configuration presets can be accessed using the :func:`presets` property: >>> bulb.presets - [SmartBulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), SmartBulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] + [BulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), BulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), BulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), BulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset` instance to :func:`save_preset` method: @@ -373,10 +343,6 @@ def hsv(self) -> HSV: return HSV(hue, saturation, value) - def _raise_for_invalid_brightness(self, value): - if not isinstance(value, int) or not (0 <= value <= 100): - raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") - @requires_update async def set_hsv( self, @@ -534,11 +500,11 @@ async def set_alias(self, alias: str) -> None: @property # type: ignore @requires_update - def presets(self) -> List[SmartBulbPreset]: + def presets(self) -> List[BulbPreset]: """Return a list of available bulb setting presets.""" - return [SmartBulbPreset(**vals) for vals in self.sys_info["preferred_state"]] + return [BulbPreset(**vals) for vals in self.sys_info["preferred_state"]] - async def save_preset(self, preset: SmartBulbPreset): + async def save_preset(self, preset: BulbPreset): """Save a setting preset. You can either construct a preset object manually, or pass an existing one diff --git a/kasa/smartdevice.py b/kasa/iot/iotdevice.py similarity index 76% rename from kasa/smartdevice.py rename to kasa/iot/iotdevice.py index 01ca382dc..8e51cac65 100755 --- a/kasa/smartdevice.py +++ b/kasa/iot/iotdevice.py @@ -15,37 +15,17 @@ import functools import inspect import logging -from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Set - -from .credentials import Credentials -from .device_type import DeviceType -from .deviceconfig import DeviceConfig -from .emeterstatus import EmeterStatus -from .exceptions import SmartDeviceException -from .iotprotocol import IotProtocol -from .modules import Emeter, Module -from .protocol import BaseProtocol -from .xortransport import XorTransport +from typing import Any, Dict, List, Optional, Sequence, Set -_LOGGER = logging.getLogger(__name__) - - -@dataclass -class WifiNetwork: - """Wifi network container.""" - - ssid: str - key_type: int - # These are available only on softaponboarding - cipher_type: Optional[int] = None - bssid: Optional[str] = None - channel: Optional[int] = None - rssi: Optional[int] = None +from ..device import Device, WifiNetwork +from ..deviceconfig import DeviceConfig +from ..emeterstatus import EmeterStatus +from ..exceptions import SmartDeviceException +from ..protocol import BaseProtocol +from .modules import Emeter, IotModule - # For SMART devices - signal_level: Optional[int] = None +_LOGGER = logging.getLogger(__name__) def merge(d, u): @@ -92,17 +72,17 @@ def _parse_features(features: str) -> Set[str]: return set(features.split(":")) -class SmartDevice: +class IotDevice(Device): """Base class for all supported device types. You don't usually want to initialize this class manually, but either use :class:`Discover` class, or use one of the subclasses: - * :class:`SmartPlug` - * :class:`SmartBulb` - * :class:`SmartStrip` - * :class:`SmartDimmer` - * :class:`SmartLightStrip` + * :class:`IotPlug` + * :class:`IotBulb` + * :class:`IotStrip` + * :class:`IotDimmer` + * :class:`IotLightStrip` To initialize, you have to await :func:`update()` at least once. This will allow accessing the properties using the exposed properties. @@ -115,7 +95,7 @@ class SmartDevice: Examples: >>> import asyncio - >>> dev = SmartDevice("127.0.0.1") + >>> dev = IotDevice("127.0.0.1") >>> asyncio.run(dev.update()) All devices provide several informational properties: @@ -200,59 +180,24 @@ def __init__( config: Optional[DeviceConfig] = None, protocol: Optional[BaseProtocol] = None, ) -> None: - """Create a new SmartDevice instance. - - :param str host: host name or ip address on which the device listens - """ - if config and protocol: - protocol._transport._config = config - self.protocol: BaseProtocol = protocol or IotProtocol( - transport=XorTransport(config=config or DeviceConfig(host=host)), - ) - _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) - self._device_type = DeviceType.Unknown - # TODO: typing Any is just as using Optional[Dict] would require separate - # checks in accessors. the @updated_required decorator does not ensure - # mypy that these are not accessed incorrectly. - self._last_update: Any = None - self._discovery_info: Optional[Dict[str, Any]] = None + """Create a new IotDevice instance.""" + super().__init__(host=host, config=config, protocol=protocol) self._sys_info: Any = None # TODO: this is here to avoid changing tests self._features: Set[str] = set() - self.modules: Dict[str, Any] = {} - - self.children: List["SmartDevice"] = [] - - @property - def host(self) -> str: - """The device host.""" - return self.protocol._transport._host - - @host.setter - def host(self, value): - """Set the device host. - - Generally used by discovery to set the hostname after ip discovery. - """ - self.protocol._transport._host = value - self.protocol._transport._config.host = value + self._children: Sequence["IotDevice"] = [] @property - def port(self) -> int: - """The device port.""" - return self.protocol._transport._port + def children(self) -> Sequence["IotDevice"]: + """Return list of children.""" + return self._children - @property - def credentials(self) -> Optional[Credentials]: - """The device credentials.""" - return self.protocol._transport._credentials - - @property - def credentials_hash(self) -> Optional[str]: - """The protocol specific hash of the credentials the device is using.""" - return self.protocol._transport.credentials_hash + @children.setter + def children(self, children): + """Initialize from a list of children.""" + self._children = children - def add_module(self, name: str, module: Module): + def add_module(self, name: str, module: IotModule): """Register a module.""" if name in self.modules: _LOGGER.debug("Module %s already registered, ignoring..." % name) @@ -291,7 +236,7 @@ async def _query_helper( request = self._create_request(target, cmd, arg, child_ids) try: - response = await self.protocol.query(request=request) + response = await self._raw_query(request=request) except Exception as ex: raise SmartDeviceException(f"Communication error on {target}:{cmd}") from ex @@ -631,13 +576,7 @@ async def turn_off(self, **kwargs) -> Dict: """Turn off the device.""" raise NotImplementedError("Device subclass needs to implement this.") - @property # type: ignore - @requires_update - def is_off(self) -> bool: - """Return True if device is off.""" - return not self.is_on - - async def turn_on(self, **kwargs) -> Dict: + async def turn_on(self, **kwargs) -> Optional[Dict]: """Turn device on.""" raise NotImplementedError("Device subclass needs to implement this.") @@ -714,77 +653,11 @@ async def _join(target, payload): ) return await _join("smartlife.iot.common.softaponboarding", payload) - def get_plug_by_name(self, name: str) -> "SmartDevice": - """Return child device for the given name.""" - for p in self.children: - if p.alias == name: - return p - - raise SmartDeviceException(f"Device has no child with {name}") - - def get_plug_by_index(self, index: int) -> "SmartDevice": - """Return child device for the given index.""" - if index + 1 > len(self.children) or index < 0: - raise SmartDeviceException( - f"Invalid index {index}, device has {len(self.children)} plugs" - ) - return self.children[index] - @property def max_device_response_size(self) -> int: """Returns the maximum response size the device can safely construct.""" return 16 * 1024 - @property - def device_type(self) -> DeviceType: - """Return the device type.""" - return self._device_type - - @property - def is_bulb(self) -> bool: - """Return True if the device is a bulb.""" - return self._device_type == DeviceType.Bulb - - @property - def is_light_strip(self) -> bool: - """Return True if the device is a led strip.""" - return self._device_type == DeviceType.LightStrip - - @property - def is_plug(self) -> bool: - """Return True if the device is a plug.""" - return self._device_type == DeviceType.Plug - - @property - def is_strip(self) -> bool: - """Return True if the device is a strip.""" - return self._device_type == DeviceType.Strip - - @property - def is_strip_socket(self) -> bool: - """Return True if the device is a strip socket.""" - return self._device_type == DeviceType.StripSocket - - @property - def is_dimmer(self) -> bool: - """Return True if the device is a dimmer.""" - return self._device_type == DeviceType.Dimmer - - @property - def is_dimmable(self) -> bool: - """Return True if the device is dimmable.""" - return False - - @property - def is_variable_color_temp(self) -> bool: - """Return True if the device supports color temperature.""" - return False - - @property - def is_color(self) -> bool: - """Return True if the device supports color changes.""" - return False - @property def internal_state(self) -> Any: """Return the internal state of the instance. @@ -793,47 +666,3 @@ def internal_state(self) -> Any: This should only be used for debugging purposes. """ return self._last_update or self._discovery_info - - def __repr__(self): - if self._last_update is None: - return f"<{self._device_type} at {self.host} - update() needed>" - return ( - f"<{self._device_type} model {self.model} at {self.host}" - f" ({self.alias}), is_on: {self.is_on}" - f" - dev specific: {self.state_information}>" - ) - - @property - 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( - *, - host: Optional[str] = None, - config: Optional[DeviceConfig] = None, - ) -> "SmartDevice": - """Connect to a single device by the given hostname or device configuration. - - This method avoids the UDP based discovery process and - will connect directly to the device. - - It is generally preferred to avoid :func:`discover_single()` and - use this function 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 config: Connection parameters to ensure the correct protocol - and connection options are used. - :rtype: SmartDevice - :return: Object for querying/controlling found device. - """ - from .device_factory import connect # pylint: disable=import-outside-toplevel - - return await connect(host=host, config=config) # type: ignore[arg-type] diff --git a/kasa/smartdimmer.py b/kasa/iot/iotdimmer.py similarity index 95% rename from kasa/smartdimmer.py rename to kasa/iot/iotdimmer.py index 97738cc43..b7b727eb1 100644 --- a/kasa/smartdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -2,11 +2,12 @@ from enum import Enum from typing import Any, Dict, Optional -from kasa.deviceconfig import DeviceConfig -from kasa.modules import AmbientLight, Motion -from kasa.protocol import BaseProtocol -from kasa.smartdevice import DeviceType, SmartDeviceException, requires_update -from kasa.smartplug import SmartPlug +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..protocol import BaseProtocol +from .iotdevice import SmartDeviceException, requires_update +from .iotplug import IotPlug +from .modules import AmbientLight, Motion class ButtonAction(Enum): @@ -32,7 +33,7 @@ class FadeType(Enum): FadeOff = "fade_off" -class SmartDimmer(SmartPlug): +class IotDimmer(IotPlug): r"""Representation of a TP-Link Smart Dimmer. Dimmers work similarly to plugs, but provide also support for @@ -50,7 +51,7 @@ class SmartDimmer(SmartPlug): Examples: >>> import asyncio - >>> dimmer = SmartDimmer("192.168.1.105") + >>> dimmer = IotDimmer("192.168.1.105") >>> asyncio.run(dimmer.turn_on()) >>> dimmer.brightness 25 diff --git a/kasa/smartlightstrip.py b/kasa/iot/iotlightstrip.py similarity index 92% rename from kasa/smartlightstrip.py rename to kasa/iot/iotlightstrip.py index 103ecfa88..942b9f785 100644 --- a/kasa/smartlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -1,14 +1,15 @@ """Module for light strips (KL430).""" from typing import Any, Dict, List, Optional -from .deviceconfig import DeviceConfig -from .effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 -from .protocol import BaseProtocol -from .smartbulb import SmartBulb -from .smartdevice import DeviceType, SmartDeviceException, requires_update +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 +from ..protocol import BaseProtocol +from .iotbulb import IotBulb +from .iotdevice import SmartDeviceException, requires_update -class SmartLightStrip(SmartBulb): +class IotLightStrip(IotBulb): """Representation of a TP-Link Smart light strip. Light strips work similarly to bulbs, but use a different service for controlling, @@ -17,7 +18,7 @@ class SmartLightStrip(SmartBulb): Examples: >>> import asyncio - >>> strip = SmartLightStrip("127.0.0.1") + >>> strip = IotLightStrip("127.0.0.1") >>> asyncio.run(strip.update()) >>> print(strip.alias) KL430 pantry lightstrip diff --git a/kasa/smartplug.py b/kasa/iot/iotplug.py similarity index 90% rename from kasa/smartplug.py rename to kasa/iot/iotplug.py index e8251b689..72cba7c31 100644 --- a/kasa/smartplug.py +++ b/kasa/iot/iotplug.py @@ -2,15 +2,16 @@ import logging from typing import Any, Dict, Optional -from kasa.deviceconfig import DeviceConfig -from kasa.modules import Antitheft, Cloud, Schedule, Time, Usage -from kasa.protocol import BaseProtocol -from kasa.smartdevice import DeviceType, SmartDevice, requires_update +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..protocol import BaseProtocol +from .iotdevice import IotDevice, requires_update +from .modules import Antitheft, Cloud, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) -class SmartPlug(SmartDevice): +class IotPlug(IotDevice): r"""Representation of a TP-Link Smart Switch. To initialize, you have to await :func:`update()` at least once. @@ -25,7 +26,7 @@ class SmartPlug(SmartDevice): Examples: >>> import asyncio - >>> plug = SmartPlug("127.0.0.1") + >>> plug = IotPlug("127.0.0.1") >>> asyncio.run(plug.update()) >>> plug.alias Kitchen diff --git a/kasa/smartstrip.py b/kasa/iot/iotstrip.py similarity index 95% rename from kasa/smartstrip.py rename to kasa/iot/iotstrip.py index b1e967c45..7cbb10b03 100755 --- a/kasa/smartstrip.py +++ b/kasa/iot/iotstrip.py @@ -4,19 +4,18 @@ from datetime import datetime, timedelta from typing import Any, DefaultDict, Dict, Optional -from kasa.smartdevice import ( - DeviceType, +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..exceptions import SmartDeviceException +from ..protocol import BaseProtocol +from .iotdevice import ( EmeterStatus, - SmartDevice, - SmartDeviceException, + IotDevice, merge, requires_update, ) -from kasa.smartplug import SmartPlug - -from .deviceconfig import DeviceConfig +from .iotplug import IotPlug from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage -from .protocol import BaseProtocol _LOGGER = logging.getLogger(__name__) @@ -30,7 +29,7 @@ def merge_sums(dicts): return total_dict -class SmartStrip(SmartDevice): +class IotStrip(IotDevice): r"""Representation of a TP-Link Smart Power Strip. A strip consists of the parent device and its children. @@ -49,7 +48,7 @@ class SmartStrip(SmartDevice): Examples: >>> import asyncio - >>> strip = SmartStrip("127.0.0.1") + >>> strip = IotStrip("127.0.0.1") >>> asyncio.run(strip.update()) >>> strip.alias TP-LINK_Power Strip_CF69 @@ -116,10 +115,10 @@ async def update(self, update_children: bool = True): if not self.children: children = self.sys_info["children"] _LOGGER.debug("Initializing %s child sockets", len(children)) - for child in children: - self.children.append( - SmartStripPlug(self.host, parent=self, child_id=child["id"]) - ) + self.children = [ + IotStripPlug(self.host, parent=self, child_id=child["id"]) + for child in children + ] if update_children and self.has_emeter: for plug in self.children: @@ -244,7 +243,7 @@ def emeter_realtime(self) -> EmeterStatus: return EmeterStatus(emeter) -class SmartStripPlug(SmartPlug): +class IotStripPlug(IotPlug): """Representation of a single socket in a power strip. This allows you to use the sockets as they were SmartPlug objects. @@ -254,7 +253,7 @@ class SmartStripPlug(SmartPlug): The plug inherits (most of) the system information from the parent. """ - def __init__(self, host: str, parent: "SmartStrip", child_id: str) -> None: + def __init__(self, host: str, parent: "IotStrip", child_id: str) -> None: super().__init__(host) self.parent = parent diff --git a/kasa/modules/__init__.py b/kasa/iot/modules/__init__.py similarity index 91% rename from kasa/modules/__init__.py rename to kasa/iot/modules/__init__.py index 8ad5088d5..17a34b6e7 100644 --- a/kasa/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -4,7 +4,7 @@ from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter -from .module import Module +from .module import IotModule from .motion import Motion from .rulemodule import Rule, RuleModule from .schedule import Schedule @@ -17,7 +17,7 @@ "Cloud", "Countdown", "Emeter", - "Module", + "IotModule", "Motion", "Rule", "RuleModule", diff --git a/kasa/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py similarity index 96% rename from kasa/modules/ambientlight.py rename to kasa/iot/modules/ambientlight.py index 963c73a3f..0a7663671 100644 --- a/kasa/modules/ambientlight.py +++ b/kasa/iot/modules/ambientlight.py @@ -1,5 +1,5 @@ """Implementation of the ambient light (LAS) module found in some dimmers.""" -from .module import Module +from .module import IotModule # TODO create tests and use the config reply there # [{"hw_id":0,"enable":0,"dark_index":1,"min_adc":0,"max_adc":2450, @@ -11,7 +11,7 @@ # {"name":"custom","adc":2400,"value":97}]}] -class AmbientLight(Module): +class AmbientLight(IotModule): """Implements ambient light controls for the motion sensor.""" def query(self): diff --git a/kasa/modules/antitheft.py b/kasa/iot/modules/antitheft.py similarity index 100% rename from kasa/modules/antitheft.py rename to kasa/iot/modules/antitheft.py diff --git a/kasa/modules/cloud.py b/kasa/iot/modules/cloud.py similarity index 96% rename from kasa/modules/cloud.py rename to kasa/iot/modules/cloud.py index b4eface55..28cf2d1eb 100644 --- a/kasa/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -4,7 +4,7 @@ except ImportError: from pydantic import BaseModel -from .module import Module +from .module import IotModule class CloudInfo(BaseModel): @@ -22,7 +22,7 @@ class CloudInfo(BaseModel): username: str -class Cloud(Module): +class Cloud(IotModule): """Module implementing support for cloud services.""" def query(self): diff --git a/kasa/modules/countdown.py b/kasa/iot/modules/countdown.py similarity index 100% rename from kasa/modules/countdown.py rename to kasa/iot/modules/countdown.py diff --git a/kasa/modules/emeter.py b/kasa/iot/modules/emeter.py similarity index 98% rename from kasa/modules/emeter.py rename to kasa/iot/modules/emeter.py index 11eed48f8..1570519eb 100644 --- a/kasa/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Dict, List, Optional, Union -from ..emeterstatus import EmeterStatus +from ...emeterstatus import EmeterStatus from .usage import Usage diff --git a/kasa/modules/module.py b/kasa/iot/modules/module.py similarity index 92% rename from kasa/modules/module.py rename to kasa/iot/modules/module.py index 40890f297..51d4b350d 100644 --- a/kasa/modules/module.py +++ b/kasa/iot/modules/module.py @@ -4,10 +4,10 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING -from ..exceptions import SmartDeviceException +from ...exceptions import SmartDeviceException if TYPE_CHECKING: - from kasa import SmartDevice + from kasa.iot import IotDevice _LOGGER = logging.getLogger(__name__) @@ -24,15 +24,15 @@ def merge(d, u): return d -class Module(ABC): +class IotModule(ABC): """Base class implemention for all modules. The base classes should implement `query` to return the query they want to be executed during the regular update cycle. """ - def __init__(self, device: "SmartDevice", module: str): - self._device: "SmartDevice" = device + def __init__(self, device: "IotDevice", module: str): + self._device = device self._module = module @abstractmethod diff --git a/kasa/modules/motion.py b/kasa/iot/modules/motion.py similarity index 95% rename from kasa/modules/motion.py rename to kasa/iot/modules/motion.py index 71d1a617b..cd79cba79 100644 --- a/kasa/modules/motion.py +++ b/kasa/iot/modules/motion.py @@ -2,8 +2,8 @@ from enum import Enum from typing import Optional -from ..exceptions import SmartDeviceException -from .module import Module +from ...exceptions import SmartDeviceException +from .module import IotModule class Range(Enum): @@ -20,7 +20,7 @@ class Range(Enum): # "min_adc":0,"max_adc":4095,"array":[80,50,20,0],"err_code":0}}} -class Motion(Module): +class Motion(IotModule): """Implements the motion detection (PIR) module.""" def query(self): diff --git a/kasa/modules/rulemodule.py b/kasa/iot/modules/rulemodule.py similarity index 96% rename from kasa/modules/rulemodule.py rename to kasa/iot/modules/rulemodule.py index 05ef500f0..f840f6725 100644 --- a/kasa/modules/rulemodule.py +++ b/kasa/iot/modules/rulemodule.py @@ -9,7 +9,7 @@ from pydantic import BaseModel -from .module import Module, merge +from .module import IotModule, merge class Action(Enum): @@ -55,7 +55,7 @@ class Rule(BaseModel): _LOGGER = logging.getLogger(__name__) -class RuleModule(Module): +class RuleModule(IotModule): """Base class for rule-based modules, such as countdown and antitheft.""" def query(self): diff --git a/kasa/modules/schedule.py b/kasa/iot/modules/schedule.py similarity index 100% rename from kasa/modules/schedule.py rename to kasa/iot/modules/schedule.py diff --git a/kasa/modules/time.py b/kasa/iot/modules/time.py similarity index 92% rename from kasa/modules/time.py rename to kasa/iot/modules/time.py index d72e2d600..2099e22c4 100644 --- a/kasa/modules/time.py +++ b/kasa/iot/modules/time.py @@ -1,11 +1,11 @@ """Provides the current time and timezone information.""" from datetime import datetime -from ..exceptions import SmartDeviceException -from .module import Module, merge +from ...exceptions import SmartDeviceException +from .module import IotModule, merge -class Time(Module): +class Time(IotModule): """Implements the timezone settings.""" def query(self): diff --git a/kasa/modules/usage.py b/kasa/iot/modules/usage.py similarity index 98% rename from kasa/modules/usage.py rename to kasa/iot/modules/usage.py index 10b9689d3..29dcd1727 100644 --- a/kasa/modules/usage.py +++ b/kasa/iot/modules/usage.py @@ -2,10 +2,10 @@ from datetime import datetime from typing import Dict -from .module import Module, merge +from .module import IotModule, merge -class Usage(Module): +class Usage(IotModule): """Baseclass for emeter/usage interfaces.""" def query(self): diff --git a/kasa/plug.py b/kasa/plug.py new file mode 100644 index 000000000..1271515e5 --- /dev/null +++ b/kasa/plug.py @@ -0,0 +1,11 @@ +"""Module for a TAPO Plug.""" +import logging +from abc import ABC + +from .device import Device + +_LOGGER = logging.getLogger(__name__) + + +class Plug(Device, ABC): + """Base class to represent a Plug.""" diff --git a/kasa/smart/__init__.py b/kasa/smart/__init__.py new file mode 100644 index 000000000..c075ba321 --- /dev/null +++ b/kasa/smart/__init__.py @@ -0,0 +1,7 @@ +"""Package for supporting tapo-branded and newer kasa devices.""" +from .smartbulb import SmartBulb +from .smartchilddevice import SmartChildDevice +from .smartdevice import SmartDevice +from .smartplug import SmartPlug + +__all__ = ["SmartDevice", "SmartPlug", "SmartBulb", "SmartChildDevice"] diff --git a/kasa/tapo/tapobulb.py b/kasa/smart/smartbulb.py similarity index 92% rename from kasa/tapo/tapobulb.py rename to kasa/smart/smartbulb.py index cfd5768f0..3ce4c6eb4 100644 --- a/kasa/tapo/tapobulb.py +++ b/kasa/smart/smartbulb.py @@ -1,9 +1,13 @@ """Module for tapo-branded smart bulbs (L5**).""" from typing import Any, Dict, List, Optional +from ..bulb import Bulb +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig from ..exceptions import SmartDeviceException -from ..smartbulb import HSV, ColorTempRange, SmartBulb, SmartBulbPreset -from .tapodevice import TapoDevice +from ..iot.iotbulb import HSV, BulbPreset, ColorTempRange +from ..smartprotocol import SmartProtocol +from .smartdevice import SmartDevice AVAILABLE_EFFECTS = { "L1": "Party", @@ -11,12 +15,22 @@ } -class TapoBulb(TapoDevice, SmartBulb): +class SmartBulb(SmartDevice, Bulb): """Representation of a TP-Link Tapo Bulb. - Documentation TBD. See :class:`~kasa.smartbulb.SmartBulb` for now. + Documentation TBD. See :class:`~kasa.iot.Bulb` for now. """ + def __init__( + self, + host: str, + *, + config: Optional[DeviceConfig] = None, + protocol: Optional[SmartProtocol] = None, + ) -> None: + super().__init__(host=host, config=config, protocol=protocol) + self._device_type = DeviceType.Bulb + @property def is_color(self) -> bool: """Whether the bulb supports color changes.""" @@ -257,6 +271,6 @@ def state_information(self) -> Dict[str, Any]: return info @property - def presets(self) -> List[SmartBulbPreset]: + def presets(self) -> List[BulbPreset]: """Return a list of available bulb setting presets.""" return [] diff --git a/kasa/tapo/childdevice.py b/kasa/smart/smartchilddevice.py similarity index 93% rename from kasa/tapo/childdevice.py rename to kasa/smart/smartchilddevice.py index 43b748515..69648d5e2 100644 --- a/kasa/tapo/childdevice.py +++ b/kasa/smart/smartchilddevice.py @@ -4,10 +4,10 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper -from .tapodevice import TapoDevice +from .smartdevice import SmartDevice -class ChildDevice(TapoDevice): +class SmartChildDevice(SmartDevice): """Presentation of a child device. This wraps the protocol communications and sets internal data for the child. @@ -15,7 +15,7 @@ class ChildDevice(TapoDevice): def __init__( self, - parent: TapoDevice, + parent: SmartDevice, child_id: str, config: Optional[DeviceConfig] = None, protocol: Optional[SmartProtocol] = None, diff --git a/kasa/tapo/tapodevice.py b/kasa/smart/smartdevice.py similarity index 89% rename from kasa/tapo/tapodevice.py rename to kasa/smart/smartdevice.py index 0ef28d071..ca9ed63be 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/smart/smartdevice.py @@ -1,26 +1,25 @@ -"""Module for a TAPO device.""" +"""Module for a SMART device.""" import base64 import logging from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Set, cast from ..aestransport import AesTransport +from ..device import Device, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationException, SmartDeviceException -from ..modules import Emeter -from ..smartdevice import SmartDevice, WifiNetwork from ..smartprotocol import SmartProtocol _LOGGER = logging.getLogger(__name__) if TYPE_CHECKING: - from .childdevice import ChildDevice + from .smartchilddevice import SmartChildDevice -class TapoDevice(SmartDevice): - """Base class to represent a TAPO device.""" +class SmartDevice(Device): + """Base class to represent a SMART protocol based device.""" def __init__( self, @@ -36,39 +35,31 @@ def __init__( self.protocol: SmartProtocol self._components_raw: Optional[Dict[str, Any]] = None self._components: Dict[str, int] = {} - self._children: Dict[str, "ChildDevice"] = {} + self._children: Dict[str, "SmartChildDevice"] = {} self._energy: Dict[str, Any] = {} self._state_information: Dict[str, Any] = {} + self._time: Dict[str, Any] = {} async def _initialize_children(self): """Initialize children for power strips.""" children = self._last_update["child_info"]["child_device_list"] # TODO: Use the type information to construct children, # as hubs can also have them. - from .childdevice import ChildDevice + from .smartchilddevice import SmartChildDevice self._children = { - child["device_id"]: ChildDevice(parent=self, child_id=child["device_id"]) + child["device_id"]: SmartChildDevice( + parent=self, child_id=child["device_id"] + ) for child in children } self._device_type = DeviceType.Strip @property - def children(self): - """Return list of children. - - This is just to keep the existing SmartDevice API intact. - """ + def children(self) -> Sequence["SmartDevice"]: + """Return list of children.""" return list(self._children.values()) - @children.setter - def children(self, children): - """Initialize from a list of children. - - This is just to keep the existing SmartDevice API intact. - """ - self._children = {child["device_id"]: child for child in children} - async def update(self, update_children: bool = True): """Update the device.""" if self.credentials is None and self.credentials_hash is None: @@ -133,7 +124,6 @@ async def _initialize_modules(self): """Initialize modules based on component negotiation response.""" if "energy_monitoring" in self._components: self.emeter_type = "emeter" - self.modules["emeter"] = Emeter(self, self.emeter_type) @property def sys_info(self) -> Dict[str, Any]: @@ -218,9 +208,9 @@ def internal_state(self) -> Any: return self._last_update async def _query_helper( - self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None + self, method: str, params: Optional[Dict] = None, child_ids=None ) -> Any: - res = await self.protocol.query({cmd: arg}) + res = await self.protocol.query({method: params}) return res @@ -276,6 +266,13 @@ def _convert_energy_data(self, data, scale) -> Optional[float]: """Return adjusted emeter information.""" return data if not data else data * scale + def _verify_emeter(self) -> None: + """Raise an exception if there is no emeter.""" + if not self.has_emeter: + raise SmartDeviceException("Device has no emeter") + if self.emeter_type not in self._last_update: + raise SmartDeviceException("update() required prior accessing emeter") + @property def emeter_realtime(self) -> EmeterStatus: """Get the emeter status.""" @@ -298,6 +295,17 @@ def emeter_today(self) -> Optional[float]: """Get the emeter value for today.""" return self._convert_energy_data(self._energy.get("today_energy"), 1 / 1000) + @property + def on_since(self) -> Optional[datetime]: + """Return the time that the device was turned on or None if turned off.""" + if ( + not self._info.get("device_on") + or (on_time := self._info.get("on_time")) is None + ): + return None + on_time = cast(float, on_time) + return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) + async def wifi_scan(self) -> List[WifiNetwork]: """Scan for available wifi networks.""" diff --git a/kasa/tapo/tapoplug.py b/kasa/smart/smartplug.py similarity index 62% rename from kasa/tapo/tapoplug.py rename to kasa/smart/smartplug.py index e4355e4ba..bd96b4217 100644 --- a/kasa/tapo/tapoplug.py +++ b/kasa/smart/smartplug.py @@ -1,17 +1,17 @@ """Module for a TAPO Plug.""" import logging -from datetime import datetime, timedelta -from typing import Any, Dict, Optional, cast +from typing import Any, Dict, Optional +from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..smartdevice import DeviceType +from ..plug import Plug from ..smartprotocol import SmartProtocol -from .tapodevice import TapoDevice +from .smartdevice import SmartDevice _LOGGER = logging.getLogger(__name__) -class TapoPlug(TapoDevice): +class SmartPlug(SmartDevice, Plug): """Class to represent a TAPO Plug.""" def __init__( @@ -35,11 +35,3 @@ def state_information(self) -> Dict[str, Any]: "auto_off_remain_time": self._info.get("auto_off_remain_time"), }, } - - @property - def on_since(self) -> Optional[datetime]: - """Return the time that the device was turned on or None if turned off.""" - if not self._info.get("device_on"): - return None - on_time = cast(float, self._info.get("on_time")) - return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) diff --git a/kasa/tapo/__init__.py b/kasa/tapo/__init__.py deleted file mode 100644 index 0fe4297e2..000000000 --- a/kasa/tapo/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Package for supporting tapo-branded and newer kasa devices.""" -from .childdevice import ChildDevice -from .tapobulb import TapoBulb -from .tapodevice import TapoDevice -from .tapoplug import TapoPlug - -__all__ = ["TapoDevice", "TapoPlug", "TapoBulb", "ChildDevice"] diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 6ce491d15..b6e9135c8 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -13,18 +13,14 @@ from kasa import ( Credentials, + Device, DeviceConfig, Discover, - SmartBulb, - SmartDevice, - SmartDimmer, - SmartLightStrip, - SmartPlug, SmartProtocol, - SmartStrip, ) +from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip from kasa.protocol import BaseTransport -from kasa.tapo import TapoBulb, TapoPlug +from kasa.smart import SmartBulb, SmartPlug from kasa.xortransport import XorEncryption from .fakeprotocol_iot import FakeIotProtocol @@ -350,37 +346,37 @@ def device_for_file(model, protocol): if protocol == "SMART": for d in PLUGS_SMART: if d in model: - return TapoPlug + return SmartPlug for d in BULBS_SMART: if d in model: - return TapoBulb + return SmartBulb for d in DIMMERS_SMART: if d in model: - return TapoBulb + return SmartBulb for d in STRIPS_SMART: if d in model: - return TapoPlug + return SmartPlug else: for d in STRIPS_IOT: if d in model: - return SmartStrip + return IotStrip for d in PLUGS_IOT: if d in model: - return SmartPlug + return IotPlug # Light strips are recognized also as bulbs, so this has to go first for d in BULBS_IOT_LIGHT_STRIP: if d in model: - return SmartLightStrip + return IotLightStrip for d in BULBS_IOT: if d in model: - return SmartBulb + return IotBulb for d in DIMMERS_IOT: if d in model: - return SmartDimmer + return IotDimmer raise Exception("Unable to find type for %s", model) @@ -446,11 +442,11 @@ async def dev(request): IP_MODEL_CACHE[ip] = model = d.model if model not in file: pytest.skip(f"skipping file {file}") - dev: SmartDevice = ( + dev: Device = ( d if d else await _discover_update_and_close(ip, username, password) ) else: - dev: SmartDevice = await get_device_for_file(file, protocol) + dev: Device = await get_device_for_file(file, protocol) yield dev diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index a92678b78..5cfb9e5e9 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -7,7 +7,8 @@ Schema, ) -from kasa import DeviceType, SmartBulb, SmartBulbPreset, SmartDeviceException +from kasa import Bulb, BulbPreset, DeviceType, SmartDeviceException +from kasa.iot import IotBulb from .conftest import ( bulb, @@ -27,7 +28,7 @@ @bulb -async def test_bulb_sysinfo(dev: SmartBulb): +async def test_bulb_sysinfo(dev: Bulb): assert dev.sys_info is not None SYSINFO_SCHEMA_BULB(dev.sys_info) @@ -40,7 +41,7 @@ async def test_bulb_sysinfo(dev: SmartBulb): @bulb -async def test_state_attributes(dev: SmartBulb): +async def test_state_attributes(dev: Bulb): assert "Brightness" in dev.state_information assert dev.state_information["Brightness"] == dev.brightness @@ -49,7 +50,7 @@ async def test_state_attributes(dev: SmartBulb): @bulb_iot -async def test_light_state_without_update(dev: SmartBulb, monkeypatch): +async def test_light_state_without_update(dev: IotBulb, monkeypatch): with pytest.raises(SmartDeviceException): monkeypatch.setitem( dev._last_update["system"]["get_sysinfo"], "light_state", None @@ -58,13 +59,13 @@ async def test_light_state_without_update(dev: SmartBulb, monkeypatch): @bulb_iot -async def test_get_light_state(dev: SmartBulb): +async def test_get_light_state(dev: IotBulb): LIGHT_STATE_SCHEMA(await dev.get_light_state()) @color_bulb @turn_on -async def test_hsv(dev: SmartBulb, turn_on): +async def test_hsv(dev: Bulb, turn_on): await handle_turn_on(dev, turn_on) assert dev.is_color @@ -83,8 +84,8 @@ async def test_hsv(dev: SmartBulb, turn_on): @color_bulb_iot -async def test_set_hsv_transition(dev: SmartBulb, mocker): - set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") +async def test_set_hsv_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") await dev.set_hsv(10, 10, 100, transition=1000) set_light_state.assert_called_with( @@ -95,31 +96,31 @@ async def test_set_hsv_transition(dev: SmartBulb, mocker): @color_bulb @turn_on -async def test_invalid_hsv(dev: SmartBulb, turn_on): +async def test_invalid_hsv(dev: Bulb, turn_on): await handle_turn_on(dev, turn_on) assert dev.is_color for invalid_hue in [-1, 361, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(invalid_hue, 0, 0) + await dev.set_hsv(invalid_hue, 0, 0) # type: ignore[arg-type] for invalid_saturation in [-1, 101, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(0, invalid_saturation, 0) + await dev.set_hsv(0, invalid_saturation, 0) # type: ignore[arg-type] for invalid_brightness in [-1, 101, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(0, 0, invalid_brightness) + await dev.set_hsv(0, 0, invalid_brightness) # type: ignore[arg-type] @color_bulb -async def test_color_state_information(dev: SmartBulb): +async def test_color_state_information(dev: Bulb): assert "HSV" in dev.state_information assert dev.state_information["HSV"] == dev.hsv @non_color_bulb -async def test_hsv_on_non_color(dev: SmartBulb): +async def test_hsv_on_non_color(dev: Bulb): assert not dev.is_color with pytest.raises(SmartDeviceException): @@ -129,7 +130,7 @@ async def test_hsv_on_non_color(dev: SmartBulb): @variable_temp -async def test_variable_temp_state_information(dev: SmartBulb): +async def test_variable_temp_state_information(dev: Bulb): assert "Color temperature" in dev.state_information assert dev.state_information["Color temperature"] == dev.color_temp @@ -141,7 +142,7 @@ async def test_variable_temp_state_information(dev: SmartBulb): @variable_temp @turn_on -async def test_try_set_colortemp(dev: SmartBulb, turn_on): +async def test_try_set_colortemp(dev: Bulb, turn_on): await handle_turn_on(dev, turn_on) await dev.set_color_temp(2700) await dev.update() @@ -149,15 +150,15 @@ async def test_try_set_colortemp(dev: SmartBulb, turn_on): @variable_temp_iot -async def test_set_color_temp_transition(dev: SmartBulb, mocker): - set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") +async def test_set_color_temp_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") await dev.set_color_temp(2700, transition=100) set_light_state.assert_called_with({"color_temp": 2700}, transition=100) @variable_temp_iot -async def test_unknown_temp_range(dev: SmartBulb, monkeypatch, caplog): +async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") assert dev.valid_temperature_range == (2700, 5000) @@ -165,7 +166,7 @@ async def test_unknown_temp_range(dev: SmartBulb, monkeypatch, caplog): @variable_temp -async def test_out_of_range_temperature(dev: SmartBulb): +async def test_out_of_range_temperature(dev: Bulb): with pytest.raises(ValueError): await dev.set_color_temp(1000) with pytest.raises(ValueError): @@ -173,7 +174,7 @@ async def test_out_of_range_temperature(dev: SmartBulb): @non_variable_temp -async def test_non_variable_temp(dev: SmartBulb): +async def test_non_variable_temp(dev: Bulb): with pytest.raises(SmartDeviceException): await dev.set_color_temp(2700) @@ -186,7 +187,7 @@ async def test_non_variable_temp(dev: SmartBulb): @dimmable @turn_on -async def test_dimmable_brightness(dev: SmartBulb, turn_on): +async def test_dimmable_brightness(dev: Bulb, turn_on): await handle_turn_on(dev, turn_on) assert dev.is_dimmable @@ -199,12 +200,12 @@ async def test_dimmable_brightness(dev: SmartBulb, turn_on): assert dev.brightness == 10 with pytest.raises(ValueError): - await dev.set_brightness("foo") + await dev.set_brightness("foo") # type: ignore[arg-type] @bulb_iot -async def test_turn_on_transition(dev: SmartBulb, mocker): - set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") +async def test_turn_on_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") await dev.turn_on(transition=1000) set_light_state.assert_called_with({"on_off": 1}, transition=1000) @@ -215,15 +216,15 @@ async def test_turn_on_transition(dev: SmartBulb, mocker): @bulb_iot -async def test_dimmable_brightness_transition(dev: SmartBulb, mocker): - set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") +async def test_dimmable_brightness_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") await dev.set_brightness(10, transition=1000) set_light_state.assert_called_with({"brightness": 10}, transition=1000) @dimmable -async def test_invalid_brightness(dev: SmartBulb): +async def test_invalid_brightness(dev: Bulb): assert dev.is_dimmable with pytest.raises(ValueError): @@ -234,7 +235,7 @@ async def test_invalid_brightness(dev: SmartBulb): @non_dimmable -async def test_non_dimmable(dev: SmartBulb): +async def test_non_dimmable(dev: Bulb): assert not dev.is_dimmable with pytest.raises(SmartDeviceException): @@ -245,9 +246,9 @@ async def test_non_dimmable(dev: SmartBulb): @bulb_iot async def test_ignore_default_not_set_without_color_mode_change_turn_on( - dev: SmartBulb, mocker + dev: IotBulb, mocker ): - query_helper = mocker.patch("kasa.SmartBulb._query_helper") + query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") # When turning back without settings, ignore default to restore the state await dev.turn_on() args, kwargs = query_helper.call_args_list[0] @@ -259,7 +260,7 @@ async def test_ignore_default_not_set_without_color_mode_change_turn_on( @bulb_iot -async def test_list_presets(dev: SmartBulb): +async def test_list_presets(dev: IotBulb): presets = dev.presets assert len(presets) == len(dev.sys_info["preferred_state"]) @@ -272,7 +273,7 @@ async def test_list_presets(dev: SmartBulb): @bulb_iot -async def test_modify_preset(dev: SmartBulb, mocker): +async def test_modify_preset(dev: IotBulb, mocker): """Verify that modifying preset calls the and exceptions are raised properly.""" if not dev.presets: pytest.skip("Some strips do not support presets") @@ -284,7 +285,7 @@ async def test_modify_preset(dev: SmartBulb, mocker): "saturation": 0, "color_temp": 0, } - preset = SmartBulbPreset(**data) + preset = BulbPreset(**data) assert preset.index == 0 assert preset.brightness == 10 @@ -297,7 +298,7 @@ async def test_modify_preset(dev: SmartBulb, mocker): with pytest.raises(SmartDeviceException): await dev.save_preset( - SmartBulbPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) + BulbPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) ) @@ -306,21 +307,21 @@ async def test_modify_preset(dev: SmartBulb, mocker): ("preset", "payload"), [ ( - SmartBulbPreset(index=0, hue=0, brightness=1, saturation=0), + BulbPreset(index=0, hue=0, brightness=1, saturation=0), {"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, ), ( - SmartBulbPreset(index=0, brightness=1, id="testid", mode=2, custom=0), + BulbPreset(index=0, brightness=1, id="testid", mode=2, custom=0), {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, ), ], ) -async def test_modify_preset_payloads(dev: SmartBulb, preset, payload, mocker): +async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): """Test that modify preset payloads ignore none values.""" if not dev.presets: pytest.skip("Some strips do not support presets") - query_helper = mocker.patch("kasa.SmartBulb._query_helper") + query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") await dev.save_preset(preset) query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload) diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 077a1f2dd..3247c9173 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -3,8 +3,8 @@ import pytest +from kasa.smart.smartchilddevice import SmartChildDevice from kasa.smartprotocol import _ChildProtocolWrapper -from kasa.tapo.childdevice import ChildDevice from .conftest import strip_smart @@ -42,7 +42,7 @@ async def test_childdevice_update(dev, dummy_protocol, mocker): sys.version_info < (3, 11), reason="exceptiongroup requires python3.11+", ) -async def test_childdevice_properties(dev: ChildDevice): +async def test_childdevice_properties(dev: SmartChildDevice): """Check that accessing childdevice properties do not raise exceptions.""" assert len(dev.children) > 0 diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index df1f6456c..58370d74b 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -7,8 +7,8 @@ from kasa import ( AuthenticationException, + Device, EmeterStatus, - SmartDevice, SmartDeviceException, UnsupportedDeviceException, ) @@ -27,6 +27,7 @@ wifi, ) from kasa.discover import Discover, DiscoveryResult +from kasa.iot import IotDevice from .conftest import device_iot, device_smart, handle_turn_on, new_discovery, turn_on @@ -107,9 +108,9 @@ async def test_alias(dev): async def test_raw_command(dev, mocker): runner = CliRunner() update = mocker.patch.object(dev, "update") - from kasa.tapo import TapoDevice + from kasa.smart import SmartDevice - if isinstance(dev, TapoDevice): + if isinstance(dev, SmartDevice): params = ["na", "get_device_info"] else: params = ["system", "get_sysinfo"] @@ -216,7 +217,7 @@ async def test_update_credentials(dev): ) -async def test_emeter(dev: SmartDevice, mocker): +async def test_emeter(dev: Device, mocker): runner = CliRunner() res = await runner.invoke(emeter, obj=dev) @@ -245,16 +246,24 @@ async def test_emeter(dev: SmartDevice, mocker): assert "Voltage: 122.066 V" in res.output assert realtime_emeter.call_count == 2 - monthly = mocker.patch.object(dev, "get_emeter_monthly") - monthly.return_value = {1: 1234} + if isinstance(dev, IotDevice): + monthly = mocker.patch.object(dev, "get_emeter_monthly") + monthly.return_value = {1: 1234} res = await runner.invoke(emeter, ["--year", "1900"], obj=dev) + if not isinstance(dev, IotDevice): + assert "Device has no historical statistics" in res.output + return assert "For year" in res.output assert "1, 1234" in res.output monthly.assert_called_with(year=1900) - daily = mocker.patch.object(dev, "get_emeter_daily") - daily.return_value = {1: 1234} + if isinstance(dev, IotDevice): + daily = mocker.patch.object(dev, "get_emeter_daily") + daily.return_value = {1: 1234} res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev) + if not isinstance(dev, IotDevice): + assert "Device has no historical statistics" in res.output + return assert "For month" in res.output assert "1, 1234" in res.output daily.assert_called_with(year=1900, month=12) @@ -279,7 +288,7 @@ async def test_brightness(dev): @device_iot -async def test_json_output(dev: SmartDevice, mocker): +async def test_json_output(dev: Device, mocker): """Test that the json output produces correct output.""" mocker.patch("kasa.Discover.discover", return_value=[dev]) runner = CliRunner() @@ -292,10 +301,10 @@ async def test_json_output(dev: SmartDevice, mocker): async def test_credentials(discovery_mock, mocker): """Test credentials are passed correctly from cli to device.""" # Patch state to echo username and password - pass_dev = click.make_pass_decorator(SmartDevice) + pass_dev = click.make_pass_decorator(Device) @pass_dev - async def _state(dev: SmartDevice): + async def _state(dev: Device): if dev.credentials: click.echo( f"Username:{dev.credentials.username} Password:{dev.credentials.password}" @@ -513,10 +522,10 @@ async def test_type_param(device_type, mocker): runner = CliRunner() result_device = FileNotFoundError - pass_dev = click.make_pass_decorator(SmartDevice) + pass_dev = click.make_pass_decorator(Device) @pass_dev - async def _state(dev: SmartDevice): + async def _state(dev: Device): nonlocal result_device result_device = dev diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index f0f73cf27..67ab39d50 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -6,8 +6,8 @@ from kasa import ( Credentials, + Device, Discover, - SmartDevice, SmartDeviceException, ) from kasa.device_factory import connect, get_protocol @@ -83,7 +83,7 @@ async def test_connect_custom_port(all_fixture_data: dict, mocker, custom_port): mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) dev = await connect(config=config) - assert issubclass(dev.__class__, SmartDevice) + assert issubclass(dev.__class__, Device) assert dev.port == custom_port or dev.port == default_port diff --git a/kasa/tests/test_device_type.py b/kasa/tests/test_device_type.py index da1707dc7..099f08626 100644 --- a/kasa/tests/test_device_type.py +++ b/kasa/tests/test_device_type.py @@ -1,4 +1,4 @@ -from kasa.smartdevice import DeviceType +from kasa.device_type import DeviceType async def test_device_type_from_value(): diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index b5e98b787..fafa95441 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -1,6 +1,6 @@ import pytest -from kasa import SmartDimmer +from kasa.iot import IotDimmer from .conftest import dimmer, handle_turn_on, turn_on @@ -23,7 +23,7 @@ async def test_set_brightness(dev, turn_on): @turn_on async def test_set_brightness_transition(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) - query_helper = mocker.spy(SmartDimmer, "_query_helper") + query_helper = mocker.spy(IotDimmer, "_query_helper") await dev.set_brightness(99, transition=1000) @@ -53,7 +53,7 @@ async def test_set_brightness_invalid(dev): @dimmer async def test_turn_on_transition(dev, mocker): - query_helper = mocker.spy(SmartDimmer, "_query_helper") + query_helper = mocker.spy(IotDimmer, "_query_helper") original_brightness = dev.brightness await dev.turn_on(transition=1000) @@ -71,7 +71,7 @@ async def test_turn_on_transition(dev, mocker): @dimmer async def test_turn_off_transition(dev, mocker): await handle_turn_on(dev, True) - query_helper = mocker.spy(SmartDimmer, "_query_helper") + query_helper = mocker.spy(IotDimmer, "_query_helper") original_brightness = dev.brightness await dev.turn_off(transition=1000) @@ -90,7 +90,7 @@ async def test_turn_off_transition(dev, mocker): @turn_on async def test_set_dimmer_transition(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) - query_helper = mocker.spy(SmartDimmer, "_query_helper") + query_helper = mocker.spy(IotDimmer, "_query_helper") await dev.set_dimmer_transition(99, 1000) @@ -109,7 +109,7 @@ async def test_set_dimmer_transition(dev, turn_on, mocker): async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) original_brightness = dev.brightness - query_helper = mocker.spy(SmartDimmer, "_query_helper") + query_helper = mocker.spy(IotDimmer, "_query_helper") await dev.set_dimmer_transition(0, 1000) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index f2344801f..e0a7fdd41 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -10,9 +10,9 @@ from kasa import ( Credentials, + Device, DeviceType, Discover, - SmartDevice, SmartDeviceException, ) from kasa.deviceconfig import ( @@ -21,6 +21,7 @@ ) from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps from kasa.exceptions import AuthenticationException, UnsupportedDeviceException +from kasa.iot import IotDevice from kasa.xortransport import XorEncryption from .conftest import ( @@ -55,14 +56,14 @@ @plug -async def test_type_detection_plug(dev: SmartDevice): +async def test_type_detection_plug(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_plug assert d.device_type == DeviceType.Plug @bulb_iot -async def test_type_detection_bulb(dev: SmartDevice): +async def test_type_detection_bulb(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") # TODO: light_strip is a special case for now to force bulb tests on it if not d.is_light_strip: @@ -71,21 +72,21 @@ async def test_type_detection_bulb(dev: SmartDevice): @strip_iot -async def test_type_detection_strip(dev: SmartDevice): +async def test_type_detection_strip(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_strip assert d.device_type == DeviceType.Strip @dimmer -async def test_type_detection_dimmer(dev: SmartDevice): +async def test_type_detection_dimmer(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_dimmer assert d.device_type == DeviceType.Dimmer @lightstrip -async def test_type_detection_lightstrip(dev: SmartDevice): +async def test_type_detection_lightstrip(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_light_strip assert d.device_type == DeviceType.LightStrip @@ -111,7 +112,7 @@ async def test_discover_single(discovery_mock, custom_port, mocker): x = await Discover.discover_single( host, port=custom_port, credentials=Credentials() ) - assert issubclass(x.__class__, SmartDevice) + assert issubclass(x.__class__, Device) assert x._discovery_info is not None assert x.port == custom_port or x.port == discovery_mock.default_port assert update_mock.call_count == 0 @@ -144,7 +145,7 @@ async def test_discover_single_hostname(discovery_mock, mocker): update_mock = mocker.patch.object(device_class, "update") x = await Discover.discover_single(host, credentials=Credentials()) - assert issubclass(x.__class__, SmartDevice) + assert issubclass(x.__class__, Device) assert x._discovery_info is not None assert x.host == host assert update_mock.call_count == 0 @@ -232,7 +233,7 @@ async def test_discover_datagram_received(mocker, discovery_data): # Check that unsupported device is 1 assert len(proto.unsupported_device_exceptions) == 1 dev = proto.discovered_devices[addr] - assert issubclass(dev.__class__, SmartDevice) + assert issubclass(dev.__class__, Device) assert dev.host == addr @@ -298,7 +299,7 @@ async def test_discover_single_authentication(discovery_mock, mocker): @new_discovery async def test_device_update_from_new_discovery_info(discovery_data): - device = SmartDevice("127.0.0.7") + device = IotDevice("127.0.0.7") discover_info = DiscoveryResult(**discovery_data["result"]) discover_dump = discover_info.get_dict() discover_dump["alias"] = "foobar" @@ -323,7 +324,7 @@ async def test_discover_single_http_client(discovery_mock, mocker): http_client = aiohttp.ClientSession() - x: SmartDevice = await Discover.discover_single(host) + x: Device = await Discover.discover_single(host) assert x.config.uses_http == (discovery_mock.default_port == 80) @@ -341,7 +342,7 @@ async def test_discover_http_client(discovery_mock, mocker): http_client = aiohttp.ClientSession() devices = await Discover.discover(discovery_timeout=0) - x: SmartDevice = devices[host] + x: Device = devices[host] assert x.config.uses_http == (discovery_mock.default_port == 80) if discovery_mock.default_port == 80: diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index dbd750247..809764fad 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -11,7 +11,8 @@ ) from kasa import EmeterStatus, SmartDeviceException -from kasa.modules.emeter import Emeter +from kasa.iot import IotDevice +from kasa.iot.modules.emeter import Emeter from .conftest import has_emeter, has_emeter_iot, no_emeter @@ -39,12 +40,15 @@ async def test_no_emeter(dev): with pytest.raises(SmartDeviceException): await dev.get_emeter_realtime() - with pytest.raises(SmartDeviceException): - await dev.get_emeter_daily() - with pytest.raises(SmartDeviceException): - await dev.get_emeter_monthly() - with pytest.raises(SmartDeviceException): - await dev.erase_emeter_stats() + # Only iot devices support the historical stats so other + # devices will not implement the methods below + if isinstance(dev, IotDevice): + with pytest.raises(SmartDeviceException): + await dev.get_emeter_daily() + with pytest.raises(SmartDeviceException): + await dev.get_emeter_monthly() + with pytest.raises(SmartDeviceException): + await dev.erase_emeter_stats() @has_emeter @@ -121,7 +125,7 @@ async def test_erase_emeter_stats(dev): await dev.erase_emeter() -@has_emeter +@has_emeter_iot async def test_current_consumption(dev): if dev.has_emeter: x = await dev.current_consumption() diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index 109b9d7c3..9ded007ab 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -1,27 +1,28 @@ import pytest -from kasa import DeviceType, SmartLightStrip +from kasa import DeviceType from kasa.exceptions import SmartDeviceException +from kasa.iot import IotLightStrip from .conftest import lightstrip @lightstrip -async def test_lightstrip_length(dev: SmartLightStrip): +async def test_lightstrip_length(dev: IotLightStrip): assert dev.is_light_strip assert dev.device_type == DeviceType.LightStrip assert dev.length == dev.sys_info["length"] @lightstrip -async def test_lightstrip_effect(dev: SmartLightStrip): +async def test_lightstrip_effect(dev: IotLightStrip): assert isinstance(dev.effect, dict) for k in ["brightness", "custom", "enable", "id", "name"]: assert k in dev.effect @lightstrip -async def test_effects_lightstrip_set_effect(dev: SmartLightStrip): +async def test_effects_lightstrip_set_effect(dev: IotLightStrip): with pytest.raises(SmartDeviceException): await dev.set_effect("Not real") @@ -33,9 +34,9 @@ async def test_effects_lightstrip_set_effect(dev: SmartLightStrip): @lightstrip @pytest.mark.parametrize("brightness", [100, 50]) async def test_effects_lightstrip_set_effect_brightness( - dev: SmartLightStrip, brightness, mocker + dev: IotLightStrip, brightness, mocker ): - query_helper = mocker.patch("kasa.SmartLightStrip._query_helper") + query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper") # test that default brightness works (100 for candy cane) if brightness == 100: @@ -51,9 +52,9 @@ async def test_effects_lightstrip_set_effect_brightness( @lightstrip @pytest.mark.parametrize("transition", [500, 1000]) async def test_effects_lightstrip_set_effect_transition( - dev: SmartLightStrip, transition, mocker + dev: IotLightStrip, transition, mocker ): - query_helper = mocker.patch("kasa.SmartLightStrip._query_helper") + query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper") # test that default (500 for candy cane) transition works if transition == 500: @@ -67,6 +68,6 @@ async def test_effects_lightstrip_set_effect_transition( @lightstrip -async def test_effects_lightstrip_has_effects(dev: SmartLightStrip): +async def test_effects_lightstrip_has_effects(dev: IotLightStrip): assert dev.has_effects is True assert dev.effect_list diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index 416cbec86..ec2099c65 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -8,54 +8,54 @@ def test_bulb_examples(mocker): """Use KL130 (bulb with all features) to test the doctests.""" p = asyncio.run(get_device_for_file("KL130(US)_1.0_1.8.11.json", "IOT")) - mocker.patch("kasa.smartbulb.SmartBulb", return_value=p) - mocker.patch("kasa.smartbulb.SmartBulb.update") - res = xdoctest.doctest_module("kasa.smartbulb", "all") + mocker.patch("kasa.iot.iotbulb.IotBulb", return_value=p) + mocker.patch("kasa.iot.iotbulb.IotBulb.update") + res = xdoctest.doctest_module("kasa.iot.iotbulb", "all") assert not res["failed"] def test_smartdevice_examples(mocker): """Use HS110 for emeter examples.""" p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json", "IOT")) - mocker.patch("kasa.smartdevice.SmartDevice", return_value=p) - mocker.patch("kasa.smartdevice.SmartDevice.update") - res = xdoctest.doctest_module("kasa.smartdevice", "all") + mocker.patch("kasa.iot.iotdevice.IotDevice", return_value=p) + mocker.patch("kasa.iot.iotdevice.IotDevice.update") + res = xdoctest.doctest_module("kasa.iot.iotdevice", "all") assert not res["failed"] def test_plug_examples(mocker): """Test plug examples.""" p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json", "IOT")) - mocker.patch("kasa.smartplug.SmartPlug", return_value=p) - mocker.patch("kasa.smartplug.SmartPlug.update") - res = xdoctest.doctest_module("kasa.smartplug", "all") + mocker.patch("kasa.iot.iotplug.IotPlug", return_value=p) + mocker.patch("kasa.iot.iotplug.IotPlug.update") + res = xdoctest.doctest_module("kasa.iot.iotplug", "all") assert not res["failed"] def test_strip_examples(mocker): """Test strip examples.""" p = asyncio.run(get_device_for_file("KP303(UK)_1.0_1.0.3.json", "IOT")) - mocker.patch("kasa.smartstrip.SmartStrip", return_value=p) - mocker.patch("kasa.smartstrip.SmartStrip.update") - res = xdoctest.doctest_module("kasa.smartstrip", "all") + mocker.patch("kasa.iot.iotstrip.IotStrip", return_value=p) + mocker.patch("kasa.iot.iotstrip.IotStrip.update") + res = xdoctest.doctest_module("kasa.iot.iotstrip", "all") assert not res["failed"] def test_dimmer_examples(mocker): """Test dimmer examples.""" p = asyncio.run(get_device_for_file("HS220(US)_1.0_1.5.7.json", "IOT")) - mocker.patch("kasa.smartdimmer.SmartDimmer", return_value=p) - mocker.patch("kasa.smartdimmer.SmartDimmer.update") - res = xdoctest.doctest_module("kasa.smartdimmer", "all") + mocker.patch("kasa.iot.iotdimmer.IotDimmer", return_value=p) + mocker.patch("kasa.iot.iotdimmer.IotDimmer.update") + res = xdoctest.doctest_module("kasa.iot.iotdimmer", "all") assert not res["failed"] def test_lightstrip_examples(mocker): """Test lightstrip examples.""" p = asyncio.run(get_device_for_file("KL430(US)_1.0_1.0.10.json", "IOT")) - mocker.patch("kasa.smartlightstrip.SmartLightStrip", return_value=p) - mocker.patch("kasa.smartlightstrip.SmartLightStrip.update") - res = xdoctest.doctest_module("kasa.smartlightstrip", "all") + mocker.patch("kasa.iot.iotlightstrip.IotLightStrip", return_value=p) + mocker.patch("kasa.iot.iotlightstrip.IotLightStrip.update") + res = xdoctest.doctest_module("kasa.iot.iotlightstrip", "all") assert not res["failed"] diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index c4681ee80..ba5ebc4fe 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -1,5 +1,8 @@ +import importlib import inspect +import pkgutil import re +import sys from datetime import datetime from unittest.mock import Mock, patch @@ -17,20 +20,33 @@ ) import kasa -from kasa import Credentials, DeviceConfig, SmartDevice, SmartDeviceException +from kasa import Credentials, Device, DeviceConfig, SmartDeviceException +from kasa.iot import IotDevice +from kasa.smart import SmartChildDevice, SmartDevice from .conftest import device_iot, handle_turn_on, has_emeter_iot, no_emeter_iot, turn_on from .fakeprotocol_iot import FakeIotProtocol -# List of all SmartXXX classes including the SmartDevice base class -smart_device_classes = [ - dc - for (mn, dc) in inspect.getmembers( - kasa, - lambda member: inspect.isclass(member) - and (member == SmartDevice or issubclass(member, SmartDevice)), - ) -] + +def _get_subclasses(of_class): + package = sys.modules["kasa"] + subclasses = set() + for _, modname, _ in pkgutil.iter_modules(package.__path__): + importlib.import_module("." + modname, package="kasa") + module = sys.modules["kasa." + modname] + for name, obj in inspect.getmembers(module): + if ( + inspect.isclass(obj) + and issubclass(obj, of_class) + and module.__package__ != "kasa" + ): + subclasses.add((module.__package__ + "." + name, obj)) + return subclasses + + +device_classes = pytest.mark.parametrize( + "device_class_name_obj", _get_subclasses(Device), ids=lambda t: t[0] +) @device_iot @@ -220,21 +236,26 @@ async def test_estimated_response_sizes(dev): assert mod.estimated_query_response_size > 0 -@pytest.mark.parametrize("device_class", smart_device_classes) -def test_device_class_ctors(device_class): +@device_classes +async def test_device_class_ctors(device_class_name_obj): """Make sure constructor api not broken for new and existing SmartDevices.""" host = "127.0.0.2" port = 1234 credentials = Credentials("foo", "bar") config = DeviceConfig(host, port_override=port, credentials=credentials) - dev = device_class(host, config=config) + klass = device_class_name_obj[1] + if issubclass(klass, SmartChildDevice): + parent = SmartDevice(host, config=config) + dev = klass(parent, 1) + else: + dev = klass(host, config=config) assert dev.host == host assert dev.port == port assert dev.credentials == credentials @device_iot -async def test_modules_preserved(dev: SmartDevice): +async def test_modules_preserved(dev: IotDevice): """Make modules that are not being updated are preserved between updates.""" dev._last_update["some_module_not_being_updated"] = "should_be_kept" await dev.update() @@ -244,6 +265,8 @@ async def test_modules_preserved(dev: SmartDevice): async def test_create_smart_device_with_timeout(): """Make sure timeout is passed to the protocol.""" host = "127.0.0.1" + dev = IotDevice(host, config=DeviceConfig(host, timeout=100)) + assert dev.protocol._transport._timeout == 100 dev = SmartDevice(host, config=DeviceConfig(host, timeout=100)) assert dev.protocol._transport._timeout == 100 @@ -258,7 +281,7 @@ async def test_create_thin_wrapper(): credentials=Credentials("username", "password"), ) with patch("kasa.device_factory.connect", return_value=mock) as connect: - dev = await SmartDevice.connect(config=config) + dev = await Device.connect(config=config) assert dev is mock connect.assert_called_once_with( @@ -268,7 +291,7 @@ async def test_create_thin_wrapper(): @device_iot -async def test_modules_not_supported(dev: SmartDevice): +async def test_modules_not_supported(dev: IotDevice): """Test that unsupported modules do not break the device.""" for module in dev.modules.values(): assert module.is_supported is not None @@ -277,6 +300,21 @@ async def test_modules_not_supported(dev: SmartDevice): assert module.is_supported is not None +@pytest.mark.parametrize( + "device_class, use_class", kasa.deprecated_smart_devices.items() +) +def test_deprecated_devices(device_class, use_class): + package_name = ".".join(use_class.__module__.split(".")[:-1]) + msg = f"{device_class} is deprecated, use {use_class.__name__} from package {package_name} instead" + with pytest.deprecated_call(match=msg): + getattr(kasa, device_class) + packages = package_name.split(".") + module = __import__(packages[0]) + for _ in packages[1:]: + module = importlib.import_module(package_name, package=module.__name__) + getattr(module, use_class.__name__) + + def check_mac(x): if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", x.lower()): return x diff --git a/kasa/tests/test_strip.py b/kasa/tests/test_strip.py index 451b7e34e..623adde6c 100644 --- a/kasa/tests/test_strip.py +++ b/kasa/tests/test_strip.py @@ -2,7 +2,8 @@ import pytest -from kasa import SmartDeviceException, SmartStrip +from kasa import SmartDeviceException +from kasa.iot import IotStrip from .conftest import handle_turn_on, strip, turn_on @@ -68,7 +69,7 @@ async def test_children_on_since(dev): @strip -async def test_get_plug_by_name(dev: SmartStrip): +async def test_get_plug_by_name(dev: IotStrip): name = dev.children[0].alias assert dev.get_plug_by_name(name) == dev.children[0] # type: ignore[arg-type] @@ -77,7 +78,7 @@ async def test_get_plug_by_name(dev: SmartStrip): @strip -async def test_get_plug_by_index(dev: SmartStrip): +async def test_get_plug_by_index(dev: IotStrip): assert dev.get_plug_by_index(0) == dev.children[0] with pytest.raises(SmartDeviceException): diff --git a/kasa/tests/test_usage.py b/kasa/tests/test_usage.py index 9f42fca1c..3f6c50561 100644 --- a/kasa/tests/test_usage.py +++ b/kasa/tests/test_usage.py @@ -1,7 +1,7 @@ import datetime from unittest.mock import Mock -from kasa.modules import Usage +from kasa.iot.modules import Usage def test_usage_convert_stat_data(): From 215b8d4e4f02a20918a8472c28666a93b4bd9fcd Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 5 Feb 2024 17:53:09 +0000 Subject: [PATCH 315/892] Fix discovery cli to print devices not printed during discovery timeout (#670) * Fix discovery cli to print devices not printed during discovery * Fix tests * Fix print exceptions not being propagated * Fix tests * Reduce test discover_send time * Simplify wait logic * Add tests * Remove sleep loop and make auth failed a list --- kasa/cli.py | 7 ++- kasa/discover.py | 61 ++++++++++++------- kasa/tests/conftest.py | 4 +- kasa/tests/test_cli.py | 22 ++++++- kasa/tests/test_discovery.py | 111 +++++++++++++++++++++++------------ 5 files changed, 142 insertions(+), 63 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 74c32e4e9..53c68adb4 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -444,12 +444,12 @@ async def print_discovered(dev: Device): _echo_discovery_info(dev._discovery_info) echo() else: - discovered[dev.host] = dev.internal_state ctx.parent.obj = dev await ctx.parent.invoke(state) + discovered[dev.host] = dev.internal_state echo() - await Discover.discover( + discovered_devices = await Discover.discover( target=target, discovery_timeout=discovery_timeout, on_discovered=print_discovered, @@ -459,6 +459,9 @@ async def print_discovered(dev: Device): credentials=credentials, ) + for device in discovered_devices.values(): + await device.protocol.close() + echo(f"Found {len(discovered)} devices") if unsupported: echo(f"Found {len(unsupported)} unsupported devices") diff --git a/kasa/discover.py b/kasa/discover.py index 858109e2b..f9ce6e0a5 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -4,7 +4,7 @@ import ipaddress import logging import socket -from typing import Awaitable, Callable, Dict, Optional, Set, Type, cast +from typing import Awaitable, Callable, Dict, List, Optional, Set, Type, cast # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -46,6 +46,8 @@ class _DiscoverProtocol(asyncio.DatagramProtocol): This is internal class, use :func:`Discover.discover`: instead. """ + DISCOVERY_START_TIMEOUT = 1 + discovered_devices: DeviceDict def __init__( @@ -60,7 +62,6 @@ def __init__( Callable[[UnsupportedDeviceException], Awaitable[None]] ] = None, port: Optional[int] = None, - discovered_event: Optional[asyncio.Event] = None, credentials: Optional[Credentials] = None, timeout: Optional[int] = None, ) -> None: @@ -79,12 +80,32 @@ def __init__( self.unsupported_device_exceptions: Dict = {} self.invalid_device_exceptions: Dict = {} self.on_unsupported = on_unsupported - 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 + self.callback_tasks: List[asyncio.Task] = [] + self.target_discovered: bool = False + self._started_event = asyncio.Event() + + def _run_callback_task(self, coro): + task = asyncio.create_task(coro) + self.callback_tasks.append(task) + + async def wait_for_discovery_to_complete(self): + """Wait for the discovery task to complete.""" + # Give some time for connection_made event to be received + async with asyncio_timeout(self.DISCOVERY_START_TIMEOUT): + await self._started_event.wait() + try: + await self.discover_task + except asyncio.CancelledError: + # if target_discovered then cancel was called internally + if not self.target_discovered: + raise + # Wait for any pending callbacks to complete + await asyncio.gather(*self.callback_tasks) def connection_made(self, transport) -> None: """Set socket options for broadcasting.""" @@ -103,6 +124,7 @@ def connection_made(self, transport) -> None: ) self.discover_task = asyncio.create_task(self.do_discover()) + self._started_event.set() async def do_discover(self) -> None: """Send number of discovery datagrams.""" @@ -110,13 +132,12 @@ async def do_discover(self) -> None: _LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY) encrypted_req = XorEncryption.encrypt(req) sleep_between_packets = self.discovery_timeout / self.discovery_packets - for i in range(self.discovery_packets): + for _ 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) + await asyncio.sleep(sleep_between_packets) def datagram_received(self, data, addr) -> None: """Handle discovery responses.""" @@ -145,7 +166,7 @@ def datagram_received(self, data, addr) -> None: _LOGGER.debug("Unsupported device found at %s << %s", ip, udex) self.unsupported_device_exceptions[ip] = udex if self.on_unsupported is not None: - asyncio.ensure_future(self.on_unsupported(udex)) + self._run_callback_task(self.on_unsupported(udex)) self._handle_discovered_event() return except SmartDeviceException as ex: @@ -157,16 +178,16 @@ def datagram_received(self, data, addr) -> None: self.discovered_devices[ip] = device if self.on_discovered is not None: - asyncio.ensure_future(self.on_discovered(device)) + self._run_callback_task(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 target is in seen_hosts cancel discover_task.""" + if self.target in self.seen_hosts: + self.target_discovered = True if self.discover_task: self.discover_task.cancel() - self.discovered_event.set() def error_received(self, ex): """Handle asyncio.Protocol errors.""" @@ -289,7 +310,11 @@ async def discover( try: _LOGGER.debug("Waiting %s seconds for responses...", discovery_timeout) - await asyncio.sleep(discovery_timeout) + await protocol.wait_for_discovery_to_complete() + except SmartDeviceException as ex: + for device in protocol.discovered_devices.values(): + await device.protocol.close() + raise ex finally: transport.close() @@ -322,7 +347,6 @@ async def discover_single( :return: Object for querying/controlling found device. """ loop = asyncio.get_event_loop() - event = asyncio.Event() try: ipaddress.ip_address(host) @@ -352,7 +376,6 @@ async def discover_single( lambda: _DiscoverProtocol( target=ip, port=port, - discovered_event=event, credentials=credentials, timeout=timeout, discovery_timeout=discovery_timeout, @@ -365,13 +388,7 @@ async def discover_single( _LOGGER.debug( "Waiting a total of %s seconds for responses...", discovery_timeout ) - - async with asyncio_timeout(discovery_timeout): - await event.wait() - except asyncio.TimeoutError as ex: - raise TimeoutException( - f"Timed out getting discovery response for {host}" - ) from ex + await protocol.wait_for_discovery_to_complete() finally: transport.close() @@ -384,7 +401,7 @@ async def discover_single( elif ip in protocol.invalid_device_exceptions: raise protocol.invalid_device_exceptions[ip] else: - raise SmartDeviceException(f"Unable to get discovery response for {host}") + raise TimeoutException(f"Timed out getting discovery response for {host}") @staticmethod def _get_device_class(info: dict) -> Type[Device]: diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index b6e9135c8..b5b711d99 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -508,7 +508,7 @@ class _DiscoveryMock: login_version, ) - def mock_discover(self): + async def mock_discover(self): port = ( dm.port_override if dm.port_override and dm.discovery_port != 20002 @@ -561,7 +561,7 @@ def unsupported_device_info(request, mocker): discovery_data = request.param host = "127.0.0.1" - def mock_discover(self): + async def mock_discover(self): if discovery_data: data = ( b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 58370d74b..2aa073825 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -290,7 +290,7 @@ async def test_brightness(dev): @device_iot async def test_json_output(dev: Device, mocker): """Test that the json output produces correct output.""" - mocker.patch("kasa.Discover.discover", return_value=[dev]) + mocker.patch("kasa.Discover.discover", return_value={"127.0.0.1": dev}) runner = CliRunner() res = await runner.invoke(cli, ["--json", "state"], obj=dev) assert res.exit_code == 0 @@ -415,6 +415,26 @@ async def test_discover(discovery_mock, mocker): assert res.exit_code == 0 +async def test_discover_host(discovery_mock, mocker): + """Test discovery output.""" + runner = CliRunner() + res = await runner.invoke( + cli, + [ + "--discovery-timeout", + 0, + "--host", + "127.0.0.123", + "--username", + "foo", + "--password", + "bar", + "--verbose", + ], + ) + assert res.exit_code == 0 + + async def test_discover_unsupported(unsupported_device_info): """Test discovery output.""" runner = CliRunner() diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index e0a7fdd41..8ce5ca6ea 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -191,7 +191,7 @@ async def test_discover_invalid_info(msg, data, mocker): """Make sure that invalid discovery information raises an exception.""" host = "127.0.0.1" - def mock_discover(self): + async def mock_discover(self): self.datagram_received( XorEncryption.encrypt(json_dumps(data))[4:], (host, 9999) ) @@ -204,7 +204,8 @@ def mock_discover(self): async def test_discover_send(mocker): """Test discovery parameters.""" - proto = _DiscoverProtocol() + discovery_timeout = 0 + proto = _DiscoverProtocol(discovery_timeout=discovery_timeout) assert proto.discovery_packets == 3 assert proto.target_1 == ("255.255.255.255", 9999) transport = mocker.patch.object(proto, "transport") @@ -299,22 +300,25 @@ async def test_discover_single_authentication(discovery_mock, mocker): @new_discovery async def test_device_update_from_new_discovery_info(discovery_data): - device = IotDevice("127.0.0.7") + """Make sure that new discovery devices update from discovery info correctly.""" + device_class = Discover._get_device_class(discovery_data) + device = device_class("127.0.0.1") discover_info = DiscoveryResult(**discovery_data["result"]) discover_dump = discover_info.get_dict() - discover_dump["alias"] = "foobar" - discover_dump["model"] = discover_dump["device_model"] + model, _, _ = discover_dump["device_model"].partition("(") + discover_dump["model"] = model device.update_from_discover_info(discover_dump) - assert device.alias == "foobar" assert device.mac == discover_dump["mac"].replace("-", ":") - assert device.model == discover_dump["device_model"] + assert device.model == model - with pytest.raises( - SmartDeviceException, - match=re.escape("You need to await update() to access the data"), - ): - assert device.supported_modules + # TODO implement requires_update for SmartDevice + if isinstance(device, IotDevice): + with pytest.raises( + SmartDeviceException, + match=re.escape("You need to await update() to access the data"), + ): + assert device.supported_modules async def test_discover_single_http_client(discovery_mock, mocker): @@ -335,7 +339,7 @@ async def test_discover_single_http_client(discovery_mock, mocker): async def test_discover_http_client(discovery_mock, mocker): - """Make sure that discover_single returns an initialized SmartDevice instance.""" + """Make sure that discover returns an initialized SmartDevice instance.""" host = "127.0.0.1" discovery_mock.ip = host @@ -403,31 +407,24 @@ def sendto(self, data, addr=None): @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.""" + """Make sure that _DiscoverProtocol handles authenticating devices correctly.""" host = "127.0.0.1" - discovery_timeout = 1 + discovery_timeout = 0 - 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 dp.wait_for_discovery_to_complete() await asyncio.sleep(0) assert ft.send_count == do_not_reply_count + 1 assert dp.discover_task.done() - assert timed_out is False + assert dp.discover_task.cancelled() @pytest.mark.parametrize( @@ -436,27 +433,69 @@ async def test_do_discover_drop_packets(mocker, port, do_not_reply_count): ids=["unknownport", "unsupporteddevice"], ) async def test_do_discover_invalid(mocker, port, will_timeout): - """Make sure that discover_single handles authenticating devices correctly.""" + """Make sure that _DiscoverProtocol handles invalid devices correctly.""" host = "127.0.0.1" - discovery_timeout = 1 + discovery_timeout = 0 - 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 dp.wait_for_discovery_to_complete() await asyncio.sleep(0) assert dp.discover_task.done() - assert timed_out is will_timeout + assert dp.discover_task.cancelled() != will_timeout + + +async def test_discover_propogates_task_exceptions(discovery_mock): + """Make sure that discover propogates callback exceptions.""" + discovery_timeout = 0 + + async def on_discovered(dev): + raise SmartDeviceException("Dummy exception") + + with pytest.raises(SmartDeviceException): + await Discover.discover( + discovery_timeout=discovery_timeout, on_discovered=on_discovered + ) + + +async def test_do_discover_no_connection(mocker): + """Make sure that if the datagram connection doesnt start a TimeoutError is raised.""" + host = "127.0.0.1" + discovery_timeout = 0 + mocker.patch.object(_DiscoverProtocol, "DISCOVERY_START_TIMEOUT", 0) + dp = _DiscoverProtocol( + target=host, + discovery_timeout=discovery_timeout, + discovery_packets=5, + ) + # Normally tests would simulate connection as per below + # ft = FakeDatagramTransport(dp, port, 0, unsupported=True) + # dp.connection_made(ft) + + with pytest.raises(asyncio.TimeoutError): + await dp.wait_for_discovery_to_complete() + + +async def test_do_discover_external_cancel(mocker): + """Make sure that a cancel other than when target is discovered propogates.""" + host = "127.0.0.1" + discovery_timeout = 1 + + dp = _DiscoverProtocol( + target=host, + discovery_timeout=discovery_timeout, + discovery_packets=1, + ) + # Normally tests would simulate connection as per below + ft = FakeDatagramTransport(dp, 9999, 1, unsupported=True) + dp.connection_made(ft) + + with pytest.raises(asyncio.TimeoutError): + async with asyncio_timeout(0): + await dp.wait_for_discovery_to_complete() From 6ab17d823c5797f85ef92eeebbc919a8615720a0 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 5 Feb 2024 20:49:26 +0000 Subject: [PATCH 316/892] Reduce AuthenticationExceptions raising from transports (#740) * Reduce AuthenticationExceptions raising from transports * Make auth failed test ids easier to read * Test invalid klap response length --- kasa/aestransport.py | 4 +- kasa/klaptransport.py | 12 +++++- kasa/tests/test_klapprotocol.py | 70 +++++++++++++++++++++++++++------ 3 files changed, 70 insertions(+), 16 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index c4668b0a4..bc1eacff7 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -208,9 +208,9 @@ async def perform_login(self): except AuthenticationException: raise except Exception as ex: - raise AuthenticationException( + raise SmartDeviceException( "Unable to login and trying default " - + "login raised another exception: %s", + + f"login raised another exception: {ex}", ex, ) from ex diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 265650d3c..0452e7375 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -159,7 +159,7 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: ) if response_status != 200: - raise AuthenticationException( + raise SmartDeviceException( f"Device {self._host} responded with {response_status} to handshake1" ) @@ -167,6 +167,12 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: remote_seed: bytes = response_data[0:16] server_hash = response_data[16:] + if len(server_hash) != 32: + raise SmartDeviceException( + f"Device {self._host} responded with unexpected klap response " + + f"{response_data!r} to handshake1" + ) + if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( "Handshake1 success at %s. Host is %s, " @@ -260,7 +266,9 @@ async def perform_handshake2( ) if response_status != 200: - raise AuthenticationException( + # This shouldn't be caused by incorrect + # credentials so don't raise AuthenticationException + raise SmartDeviceException( f"Device {self._host} responded with {response_status} to handshake2" ) diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index fa25439e6..9dee04fa2 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -350,7 +350,7 @@ async def _return_handshake_response(url: URL, params=None, data=None, *_, **__) assert protocol._transport._handshake_done is True response_status = 403 - with pytest.raises(AuthenticationException): + with pytest.raises(SmartDeviceException): await protocol._transport.perform_handshake() assert protocol._transport._handshake_done is False await protocol.close() @@ -400,34 +400,80 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): @pytest.mark.parametrize( - "response_status, expectation", + "response_status, credentials_match, expectation", [ - ((403, 403, 403), pytest.raises(AuthenticationException)), - ((200, 403, 403), pytest.raises(AuthenticationException)), - ((200, 200, 403), pytest.raises(AuthenticationException)), - ((200, 200, 400), pytest.raises(SmartDeviceException)), + pytest.param( + (403, 403, 403), + True, + pytest.raises(SmartDeviceException), + id="handshake1-403-status", + ), + pytest.param( + (200, 403, 403), + True, + pytest.raises(SmartDeviceException), + id="handshake2-403-status", + ), + pytest.param( + (200, 200, 403), + True, + pytest.raises(AuthenticationException), + id="request-403-status", + ), + pytest.param( + (200, 200, 400), + True, + pytest.raises(SmartDeviceException), + id="request-400-status", + ), + pytest.param( + (200, 200, 200), + False, + pytest.raises(AuthenticationException), + id="handshake1-wrong-auth", + ), + pytest.param( + (200, 200, 200), + secrets.token_bytes(16), + pytest.raises(SmartDeviceException), + id="handshake1-bad-auth-length", + ), ], - ids=("handshake1", "handshake2", "request", "non_auth_error"), ) -async def test_authentication_failures(mocker, response_status, expectation): +async def test_authentication_failures( + mocker, response_status, credentials_match, expectation +): client_seed = None server_seed = secrets.token_bytes(16) client_credentials = Credentials("foo", "bar") - device_auth_hash = KlapTransport.generate_auth_hash(client_credentials) + device_credentials = ( + client_credentials if credentials_match else Credentials("bar", "foo") + ) + device_auth_hash = KlapTransport.generate_auth_hash(device_credentials) async def _return_response(url: URL, params=None, data=None, *_, **__): - nonlocal client_seed, server_seed, device_auth_hash, response_status + nonlocal \ + client_seed, \ + server_seed, \ + device_auth_hash, \ + response_status, \ + credentials_match if str(url) == "http://127.0.0.1:80/app/handshake1": client_seed = data client_seed_auth_hash = _sha256(data + device_auth_hash) - + if credentials_match is not False and credentials_match is not True: + client_seed_auth_hash += credentials_match return _mock_response( response_status[0], server_seed + client_seed_auth_hash ) elif str(url) == "http://127.0.0.1:80/app/handshake2": - return _mock_response(response_status[1], b"") + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) + return _mock_response( + response_status[1], server_seed + client_seed_auth_hash + ) elif str(url) == "http://127.0.0.1:80/app/request": return _mock_response(response_status[2], b"") From 458949157ae74600355c2d14d238e28abc483110 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 6 Feb 2024 14:48:19 +0100 Subject: [PATCH 317/892] Add 'shell' command to cli (#738) * Add 'shell' command to cli * Add test * Add ptpython as optional dep --- kasa/cli.py | 21 +++++++++++++++++++++ kasa/tests/test_cli.py | 18 ++++++++++++++++++ pyproject.toml | 4 ++++ 3 files changed, 43 insertions(+) diff --git a/kasa/cli.py b/kasa/cli.py index 53c68adb4..ab65c448b 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1081,5 +1081,26 @@ async def update_credentials(dev, username, password): return await dev.update_credentials(username, password) +@cli.command() +@pass_dev +async def shell(dev: Device): + """Open interactive shell.""" + echo("Opening shell for %s" % dev) + from ptpython.repl import embed + + logging.getLogger("parso").setLevel(logging.WARNING) # prompt parsing + logging.getLogger("asyncio").setLevel(logging.WARNING) + loop = asyncio.get_event_loop() + try: + await embed( + globals=globals(), + locals=locals(), + return_asyncio_coroutine=True, + patch_stdout=True, + ) + except EOFError: + loop.stop() + + if __name__ == "__main__": cli() diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 2aa073825..84f016c02 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -558,3 +558,21 @@ async def _state(dev: Device): ) assert res.exit_code == 0 assert isinstance(result_device, expected_type) + + +@pytest.mark.skip( + "Skip until pytest-asyncio supports pytest 8.0, https://github.com/pytest-dev/pytest-asyncio/issues/737" +) +async def test_shell(dev: Device, mocker): + """Test that the shell commands tries to embed a shell.""" + mocker.patch("kasa.Discover.discover", return_value=[dev]) + # repl = mocker.patch("ptpython.repl") + mocker.patch.dict( + "sys.modules", + {"ptpython": mocker.MagicMock(), "ptpython.repl": mocker.MagicMock()}, + ) + embed = mocker.patch("ptpython.repl.embed") + runner = CliRunner() + res = await runner.invoke(cli, ["shell"], obj=dev) + assert res.exit_code == 0 + embed.assert_called() diff --git a/pyproject.toml b/pyproject.toml index 70fbe07a4..f4c640d57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,9 @@ sphinxcontrib-programoutput = { version = "^0", optional = true } myst-parser = { version = "*", optional = true } docutils = { version = ">=0.17", optional = true } +# shell support +# ptpython = { version = "*", optional = true } + [tool.poetry.group.dev.dependencies] pytest = "*" pytest-cov = "*" @@ -57,6 +60,7 @@ coverage = {version = "*", extras = ["toml"]} [tool.poetry.extras] docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] speedups = ["orjson", "kasa-crypt"] +# shell = ["ptpython"] [tool.coverage.run] source = ["kasa"] From 5d81e9f94c350c797af6802b8b4e8a3b3355b699 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 8 Feb 2024 19:03:06 +0000 Subject: [PATCH 318/892] Pass timeout parameters to discover_single (#744) * Pass timeout parameters to discover_single * Fix tests --- kasa/cli.py | 9 +++++++-- kasa/tests/test_cli.py | 14 +++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index ab65c448b..0893d5b05 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -216,7 +216,7 @@ def _device_to_serializable(val: Device): @click.option( "--discovery-timeout", envvar="KASA_DISCOVERY_TIMEOUT", - default=3, + default=5, required=False, show_default=True, help="Timeout for discovery.", @@ -348,11 +348,16 @@ def _nop_echo(*args, **kwargs): ) dev = await Device.connect(config=config) else: - echo("No --type or --device-family and --encrypt-type defined, discovering..") + echo( + "No --type or --device-family and --encrypt-type defined, " + + f"discovering for {discovery_timeout} seconds.." + ) dev = await Discover.discover_single( host, port=port, credentials=credentials, + timeout=timeout, + discovery_timeout=discovery_timeout, ) # Skip update on specific commands, or if device factory, diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 84f016c02..362511ceb 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -7,6 +7,7 @@ from kasa import ( AuthenticationException, + Credentials, Device, EmeterStatus, SmartDeviceException, @@ -341,7 +342,9 @@ async def _state(dev: Device): async def test_without_device_type(dev, mocker): """Test connecting without the device type.""" runner = CliRunner() - mocker.patch("kasa.discover.Discover.discover_single", return_value=dev) + discovery_mock = mocker.patch( + "kasa.discover.Discover.discover_single", return_value=dev + ) res = await runner.invoke( cli, [ @@ -351,9 +354,18 @@ async def test_without_device_type(dev, mocker): "foo", "--password", "bar", + "--discovery-timeout", + "7", ], ) assert res.exit_code == 0 + discovery_mock.assert_called_once_with( + "127.0.0.1", + port=None, + credentials=Credentials("foo", "bar"), + timeout=5, + discovery_timeout=7, + ) @pytest.mark.parametrize("auth_param", ["--username", "--password"]) From 45f251e57e033d5342f3a083703ec98dc84479e2 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 14 Feb 2024 17:03:50 +0000 Subject: [PATCH 319/892] Ensure connections are closed when cli is finished (#752) * Ensure connections are closed when cli is finished * Test for close calls on error and success --- kasa/cli.py | 10 +++++++++- kasa/device_factory.py | 20 ++++++++++++++------ kasa/tests/test_device_factory.py | 9 +++++++-- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 0893d5b05..86a0c15a6 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -5,6 +5,7 @@ import logging import re import sys +from contextlib import asynccontextmanager from functools import singledispatch, wraps from pprint import pformat as pf from typing import Any, Dict, cast @@ -365,7 +366,14 @@ def _nop_echo(*args, **kwargs): if ctx.invoked_subcommand not in SKIP_UPDATE_COMMANDS and not device_family: await dev.update() - ctx.obj = dev + @asynccontextmanager + async def async_wrapped_device(device: Device): + try: + yield device + finally: + await device.disconnect() + + ctx.obj = await ctx.with_async_resource(async_wrapped_device(dev)) if ctx.invoked_subcommand is None: return await ctx.invoke(state) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 28a5e3b2b..3550539c7 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -49,6 +49,20 @@ async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Devic if host: config = DeviceConfig(host=host) + if (protocol := get_protocol(config=config)) is None: + raise UnsupportedDeviceException( + f"Unsupported device for {config.host}: " + + f"{config.connection_type.device_family.value}" + ) + + try: + return await _connect(config, protocol) + except: + await protocol.close() + raise + + +async def _connect(config: DeviceConfig, protocol: BaseProtocol) -> "Device": debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) if debug_enabled: start_time = time.perf_counter() @@ -63,12 +77,6 @@ def _perf_log(has_params, perf_type): ) start_time = time.perf_counter() - if (protocol := get_protocol(config=config)) is None: - raise UnsupportedDeviceException( - f"Unsupported device for {config.host}: " - + f"{config.connection_type.device_family.value}" - ) - device_class: Optional[Type[Device]] device: Optional[Device] = None diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index 67ab39d50..7369a9874 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -53,7 +53,7 @@ async def test_connect( host=host, credentials=Credentials("foor", "bar"), connection_type=ctype ) protocol_class = get_protocol(config).__class__ - + close_mock = mocker.patch.object(protocol_class, "close") dev = await connect( config=config, ) @@ -61,8 +61,9 @@ async def test_connect( assert isinstance(dev.protocol, protocol_class) assert dev.config == config - + assert close_mock.call_count == 0 await dev.disconnect() + assert close_mock.call_count == 1 @pytest.mark.parametrize("custom_port", [123, None]) @@ -116,8 +117,12 @@ async def test_connect_query_fails(all_fixture_data: dict, mocker): config = DeviceConfig( host=host, credentials=Credentials("foor", "bar"), connection_type=ctype ) + protocol_class = get_protocol(config).__class__ + close_mock = mocker.patch.object(protocol_class, "close") + assert close_mock.call_count == 0 with pytest.raises(SmartDeviceException): await connect(config=config) + assert close_mock.call_count == 1 async def test_connect_http_client(all_fixture_data, mocker): From 13d8d94bd55a1c895732842deea680a0fc377faa Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 14 Feb 2024 19:13:28 +0000 Subject: [PATCH 320/892] Fix for P100 on fw 1.1.3 login_version none (#751) * Fix for P100 on fw 1.1.3 login_version none * Fix coverage * Add delay before trying default login * Move devtools and fixture out * Change logging string Co-authored-by: Teemu R. * Fix test --------- Co-authored-by: Teemu R. --- kasa/aestransport.py | 24 +++++++-- kasa/smart/smartdevice.py | 12 +++-- kasa/smartprotocol.py | 1 + kasa/tests/test_aestransport.py | 94 ++++++++++++++++++++++++++++++--- 4 files changed, 117 insertions(+), 14 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index bc1eacff7..bbcc511f1 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -3,7 +3,7 @@ Based on the work of https://github.com/petretiandrea/plugp100 under compatible GNU GPL3 license. """ - +import asyncio import base64 import hashlib import logging @@ -39,6 +39,7 @@ ONE_DAY_SECONDS = 86400 SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20 +BACKOFF_SECONDS_AFTER_LOGIN_ERROR = 1 def _sha1(payload: bytes) -> str: @@ -184,8 +185,24 @@ async def send_secure_passthrough(self, request: str) -> Dict[str, Any]: 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] + + try: + response = self._encryption_session.decrypt(raw_response.encode()) + ret_val = json_loads(response) + except Exception as ex: + try: + ret_val = json_loads(raw_response) + _LOGGER.debug( + "Received unencrypted response over secure passthrough from %s", + self._host, + ) + except Exception: + raise SmartDeviceException( + f"Unable to decrypt response from {self._host}, " + + f"error: {ex}, response: {raw_response}", + ex, + ) from ex + return ret_val # type: ignore[return-value] async def perform_login(self): """Login to the device.""" @@ -199,6 +216,7 @@ async def perform_login(self): self._default_credentials = get_default_credentials( DEFAULT_CREDENTIALS["TAPO"] ) + await asyncio.sleep(BACKOFF_SECONDS_AFTER_LOGIN_ERROR) await self.perform_handshake() await self.try_login(self._get_login_params(self._default_credentials)) _LOGGER.debug( diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index ca9ed63be..0929c418d 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -69,7 +69,7 @@ async def update(self, update_children: bool = True): resp = await self.protocol.query("component_nego") self._components_raw = resp["component_nego"] self._components = { - comp["id"]: comp["ver_code"] + comp["id"]: int(comp["ver_code"]) for comp in self._components_raw["component_list"] } await self._initialize_modules() @@ -86,9 +86,14 @@ async def update(self, update_children: bool = True): "get_current_power": None, } + if self._components["device"] >= 2: + extra_reqs = { + **extra_reqs, + "get_device_usage": None, + } + req = { "get_device_info": None, - "get_device_usage": None, "get_device_time": None, **extra_reqs, } @@ -96,8 +101,9 @@ async def update(self, update_children: bool = True): resp = await self.protocol.query(req) self._info = resp["get_device_info"] - self._usage = resp["get_device_usage"] self._time = resp["get_device_time"] + # Device usage is not available on older firmware versions + self._usage = resp.get("get_device_usage", {}) # Emeter is not always available, but we set them still for now. self._energy = resp.get("get_energy_usage", {}) self._emeter = resp.get("get_current_power", {}) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 74f2275d2..f61bac206 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -82,6 +82,7 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: 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 TimeoutException as ex: await self._transport.reset() diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index a692ba9be..51f1e3d90 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -1,5 +1,6 @@ import base64 import json +import logging import random import string import time @@ -180,6 +181,67 @@ async def test_send(mocker, status_code, error_code, inner_error_code, expectati assert "result" in res +async def test_unencrypted_response(mocker, caplog): + host = "127.0.0.1" + mock_aes_device = MockAesDevice(host, 200, 0, 0, do_not_encrypt_response=True) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) + + transport = AesTransport( + config=DeviceConfig(host, credentials=Credentials("foo", "bar")) + ) + transport._state = TransportState.ESTABLISHED + transport._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + transport._token_url = transport._app_url.with_query( + f"token={mock_aes_device.token}" + ) + + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + caplog.set_level(logging.DEBUG) + res = await transport.send(json_dumps(request)) + assert "result" in res + assert ( + "Received unencrypted response over secure passthrough from 127.0.0.1" + in caplog.text + ) + + +async def test_unencrypted_response_invalid_json(mocker, caplog): + host = "127.0.0.1" + mock_aes_device = MockAesDevice( + host, 200, 0, 0, do_not_encrypt_response=True, send_response=b"Foobar" + ) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) + + transport = AesTransport( + config=DeviceConfig(host, credentials=Credentials("foo", "bar")) + ) + transport._state = TransportState.ESTABLISHED + transport._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + transport._token_url = transport._app_url.with_query( + f"token={mock_aes_device.token}" + ) + + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + caplog.set_level(logging.DEBUG) + msg = f"Unable to decrypt response from {host}, error: Incorrect padding, response: Foobar" + with pytest.raises(SmartDeviceException, match=msg): + await transport.send(json_dumps(request)) + + ERRORS = [e for e in SmartErrorCode if e != 0] @@ -233,15 +295,28 @@ async def __aexit__(self, exc_t, exc_v, exc_tb): pass async def read(self): - return json_dumps(self._json).encode() + if isinstance(self._json, dict): + return json_dumps(self._json).encode() + return self._json encryption_session = AesEncyptionSession(KEY_IV[:16], KEY_IV[16:]) - def __init__(self, host, status_code=200, error_code=0, inner_error_code=0): + def __init__( + self, + host, + status_code=200, + error_code=0, + inner_error_code=0, + *, + do_not_encrypt_response=False, + send_response=None, + ): self.host = host self.status_code = status_code self.error_code = error_code self._inner_error_code = inner_error_code + self.do_not_encrypt_response = do_not_encrypt_response + self.send_response = send_response self.http_client = HttpClient(DeviceConfig(self.host)) self.inner_call_count = 0 self.token = "".join(random.choices(string.ascii_uppercase, k=32)) # noqa: S311 @@ -289,13 +364,15 @@ async def _return_secure_passthrough_response(self, url: URL, json: Dict[str, An decrypted_request_dict = json_loads(decrypted_request) decrypted_response = await self._post(url, decrypted_request_dict) async with decrypted_response: - response_data = await decrypted_response.read() - decrypted_response_dict = json_loads(response_data.decode()) - encrypted_response = self.encryption_session.encrypt( - json_dumps(decrypted_response_dict).encode() + decrypted_response_data = await decrypted_response.read() + encrypted_response = self.encryption_session.encrypt(decrypted_response_data) + response = ( + decrypted_response_data + if self.do_not_encrypt_response + else encrypted_response ) result = { - "result": {"response": encrypted_response.decode()}, + "result": {"response": response.decode()}, "error_code": self.error_code, } return self._mock_response(self.status_code, result) @@ -310,5 +387,6 @@ async def _return_login_response(self, url: URL, json: Dict[str, Any]): async def _return_send_response(self, url: URL, json: Dict[str, Any]): result = {"result": {"method": None}, "error_code": self.inner_error_code} + response = self.send_response if self.send_response else result self.inner_call_count += 1 - return self._mock_response(self.status_code, result) + return self._mock_response(self.status_code, response) From 57835276e3ba405f88ef17aff21abedaa1a52724 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 14 Feb 2024 19:43:10 +0000 Subject: [PATCH 321/892] Fix devtools for P100 and add fixture (#753) --- devtools/dump_devinfo.py | 27 ++- devtools/helpers/smartrequests.py | 63 ++++--- .../fixtures/smart/P100_1.0.0_1.1.3.json | 173 ++++++++++++++++++ 3 files changed, 236 insertions(+), 27 deletions(-) create mode 100644 kasa/tests/fixtures/smart/P100_1.0.0_1.1.3.json diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index c1436aa12..a227a50df 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -12,6 +12,7 @@ import json import logging import re +import traceback from collections import defaultdict, namedtuple from pathlib import Path from pprint import pprint @@ -19,7 +20,7 @@ import asyncclick as click -from devtools.helpers.smartrequests import COMPONENT_REQUESTS, SmartRequest +from devtools.helpers.smartrequests import SmartRequest, get_component_requests from kasa import ( AuthenticationException, Credentials, @@ -35,6 +36,8 @@ Call = namedtuple("Call", "module method") SmartCall = namedtuple("SmartCall", "module request should_succeed") +_LOGGER = logging.getLogger(__name__) + def scrub(res): """Remove identifiers from the given dict.""" @@ -228,6 +231,8 @@ async def get_legacy_fixture(device): else: click.echo(click.style("OK", fg="green")) successes.append((test_call, info)) + finally: + await device.protocol.close() final_query = defaultdict(defaultdict) final = defaultdict(defaultdict) @@ -241,7 +246,8 @@ async def get_legacy_fixture(device): final = await device.protocol.query(final_query) except Exception as ex: _echo_error(f"Unable to query all successes at once: {ex}", bold=True, fg="red") - + finally: + await device.protocol.close() if device._discovery_info and not device._discovery_info.get("system"): # 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. @@ -316,7 +322,11 @@ async def _make_requests_or_exit( _echo_error( f"Unexpected exception querying {name} at once: {ex}", ) + if _LOGGER.isEnabledFor(logging.DEBUG): + traceback.print_stack() exit(1) + finally: + await device.protocol.close() async def get_smart_fixture(device: SmartDevice, batch_size: int): @@ -367,14 +377,15 @@ async def get_smart_fixture(device: SmartDevice, batch_size: int): for item in component_info_response["component_list"]: component_id = item["id"] - if requests := COMPONENT_REQUESTS.get(component_id): + ver_code = item["ver_code"] + if (requests := get_component_requests(component_id, ver_code)) is not None: component_test_calls = [ SmartCall(module=component_id, request=request, should_succeed=True) for request in requests ] test_calls.extend(component_test_calls) should_succeed.extend(component_test_calls) - elif component_id not in COMPONENT_REQUESTS: + else: click.echo(f"Skipping {component_id}..", nl=False) click.echo(click.style("UNSUPPORTED", fg="yellow")) @@ -396,7 +407,11 @@ async def get_smart_fixture(device: SmartDevice, batch_size: int): if ( not test_call.should_succeed and hasattr(ex, "error_code") - and ex.error_code == SmartErrorCode.UNKNOWN_METHOD_ERROR + and ex.error_code + in [ + SmartErrorCode.UNKNOWN_METHOD_ERROR, + SmartErrorCode.TRANSPORT_NOT_AVAILABLE_ERROR, + ] ): click.echo(click.style("FAIL - EXPECTED", fg="green")) else: @@ -410,6 +425,8 @@ async def get_smart_fixture(device: SmartDevice, batch_size: int): else: click.echo(click.style("OK", fg="green")) successes.append(test_call) + finally: + await device.protocol.close() requests = [] for succ in successes: diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index e4941713a..de0a53ff4 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -133,11 +133,14 @@ def get_device_usage() -> "SmartRequest": return SmartRequest("get_device_usage") @staticmethod - def device_info_list() -> List["SmartRequest"]: + def device_info_list(ver_code) -> List["SmartRequest"]: """Get device info list.""" + if ver_code == 1: + return [SmartRequest.get_device_info()] return [ SmartRequest.get_device_info(), SmartRequest.get_device_usage(), + SmartRequest.get_auto_update_info(), ] @staticmethod @@ -149,7 +152,6 @@ def get_auto_update_info() -> "SmartRequest": def firmware_info_list() -> List["SmartRequest"]: """Get info list.""" return [ - SmartRequest.get_auto_update_info(), SmartRequest.get_raw_request("get_fw_download_state"), SmartRequest.get_raw_request("get_latest_fw"), ] @@ -165,9 +167,13 @@ def get_device_time() -> "SmartRequest": return SmartRequest("get_device_time") @staticmethod - def get_wireless_scan_info() -> "SmartRequest": + def get_wireless_scan_info( + params: Optional[GetRulesParams] = None + ) -> "SmartRequest": """Get wireless scan info.""" - return SmartRequest("get_wireless_scan_info") + return SmartRequest( + "get_wireless_scan_info", params or SmartRequest.GetRulesParams() + ) @staticmethod def get_schedule_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest": @@ -294,9 +300,13 @@ def set_dynamic_light_effect_rule_enable( @staticmethod def get_component_info_requests(component_nego_response) -> List["SmartRequest"]: """Get a list of requests based on the component info response.""" - request_list = [] + request_list: List["SmartRequest"] = [] for component in component_nego_response["component_list"]: - if requests := COMPONENT_REQUESTS.get(component["id"]): + if ( + requests := get_component_requests( + component["id"], int(component["ver_code"]) + ) + ) is not None: request_list.extend(requests) return request_list @@ -314,8 +324,17 @@ def _create_request_dict( return request +def get_component_requests(component_id, ver_code): + """Get the requests supported by the component and version.""" + if (cr := COMPONENT_REQUESTS.get(component_id)) is None: + return None + if callable(cr): + return cr(ver_code) + return cr + + COMPONENT_REQUESTS = { - "device": SmartRequest.device_info_list(), + "device": SmartRequest.device_info_list, "firmware": SmartRequest.firmware_info_list(), "quick_setup": [SmartRequest.qs_component_nego()], "inherit": [SmartRequest.get_raw_request("get_inherit_info")], @@ -324,33 +343,33 @@ def _create_request_dict( "schedule": SmartRequest.schedule_info_list(), "countdown": [SmartRequest.get_countdown_rules()], "antitheft": [SmartRequest.get_antitheft_rules()], - "account": None, - "synchronize": None, # sync_env - "sunrise_sunset": None, # for schedules + "account": [], + "synchronize": [], # sync_env + "sunrise_sunset": [], # for schedules "led": [SmartRequest.get_led_info()], "cloud_connect": [SmartRequest.get_raw_request("get_connect_cloud_state")], - "iot_cloud": None, - "device_local_time": None, - "default_states": None, # in device_info + "iot_cloud": [], + "device_local_time": [], + "default_states": [], # in device_info "auto_off": [SmartRequest.get_auto_off_config()], - "localSmart": None, + "localSmart": [], "energy_monitoring": SmartRequest.energy_monitoring_list(), "power_protection": SmartRequest.power_protection_list(), - "current_protection": None, # overcurrent in device_info - "matter": None, + "current_protection": [], # overcurrent in device_info + "matter": [], "preset": [SmartRequest.get_preset_rules()], - "brightness": None, # in device_info - "color": None, # in device_info - "color_temperature": None, # in device_info + "brightness": [], # in device_info + "color": [], # in device_info + "color_temperature": [], # in device_info "auto_light": [SmartRequest.get_auto_light_info()], "light_effect": [SmartRequest.get_dynamic_light_effect_rules()], - "bulb_quick_control": None, + "bulb_quick_control": [], "on_off_gradually": [SmartRequest.get_raw_request("get_on_off_gradually_info")], - "light_strip": None, + "light_strip": [], "light_strip_lighting_effect": [ SmartRequest.get_raw_request("get_lighting_effect") ], - "music_rhythm": None, # music_rhythm_enable in device_info + "music_rhythm": [], # music_rhythm_enable in device_info "segment": [SmartRequest.get_raw_request("get_device_segment")], "segment_effect": [SmartRequest.get_raw_request("get_segment_effect_rule")], } diff --git a/kasa/tests/fixtures/smart/P100_1.0.0_1.1.3.json b/kasa/tests/fixtures/smart/P100_1.0.0_1.1.3.json new file mode 100644 index 000000000..337c6f2c9 --- /dev/null +++ b/kasa/tests/fixtures/smart/P100_1.0.0_1.1.3.json @@ -0,0 +1,173 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "quick_setup", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 1 + }, + { + "id": "countdown", + "ver_code": 1 + }, + { + "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 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P100", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "mac": "1C-3B-F3-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false + }, + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_connect_cloud_state": { + "status": -1001 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "plug", + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.3 Build 20191017 Rel. 57937", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0.0", + "ip": "127.0.0.123", + "latitude": 0, + "location": "hallway", + "longitude": 0, + "mac": "1C-3B-F3-00-00-00", + "model": "P100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 6868, + "overheated": false, + "signal_level": 2, + "specs": "US", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_usage_past30": 114, + "time_usage_past7": 114, + "time_usage_today": 114, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1707905077 + }, + "get_fw_download_state": { + "download_progress": 0, + "reboot_time": 10, + "status": 0, + "upgrade_time": 0 + }, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.3.7 Build 20230711 Rel.61904", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-07-26", + "release_note": "Modifications and Bug fixes:\nEnhanced device security.", + "type": 3 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true + }, + "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 + }, + "get_wireless_scan_info": { + "ap_list": [], + "start_index": 0, + "sum": 0, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "P100", + "device_type": "SMART.TAPOPLUG" + } + } +} From 64da736717256de1abecccdaf4da10693b77cc79 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 15 Feb 2024 16:25:08 +0100 Subject: [PATCH 322/892] Add generic interface for accessing device features (#741) This adds a generic interface for all device classes to introspect available device features, that is necessary to make it easier to support a wide variety of supported devices with different set of features. This will allow constructing generic interfaces (e.g., in homeassistant) that fetch and change these features without hard-coding the API calls. `Device.features()` now returns a mapping of `` where the `Feature` contains all necessary information (like the name, the icon, a way to get and change the setting) to present and change the defined feature through its interface. --- kasa/__init__.py | 3 ++ kasa/cli.py | 39 ++++++++++++++++- kasa/device.py | 15 +++++-- kasa/feature.py | 50 +++++++++++++++++++++ kasa/iot/iotdevice.py | 43 +++++++++++++++--- kasa/iot/iotplug.py | 15 ++++++- kasa/iot/modules/cloud.py | 19 ++++++++ kasa/iot/modules/module.py | 11 ++++- kasa/smart/smartdevice.py | 69 ++++++++++++++++++++++++----- kasa/tests/test_cli.py | 22 ++++++++++ kasa/tests/test_feature.py | 79 ++++++++++++++++++++++++++++++++++ kasa/tests/test_smartdevice.py | 8 ++-- 12 files changed, 345 insertions(+), 28 deletions(-) create mode 100644 kasa/feature.py create mode 100644 kasa/tests/test_feature.py diff --git a/kasa/__init__.py b/kasa/__init__.py index 0d9e0c3eb..7dac1170d 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -33,6 +33,7 @@ TimeoutException, UnsupportedDeviceException, ) +from kasa.feature import Feature, FeatureType from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.iotprotocol import ( IotProtocol, @@ -54,6 +55,8 @@ "TurnOnBehaviors", "TurnOnBehavior", "DeviceType", + "Feature", + "FeatureType", "EmeterStatus", "Device", "Bulb", diff --git a/kasa/cli.py b/kasa/cli.py index 86a0c15a6..e922ec81c 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -102,6 +102,7 @@ def __call__(self, *args, **kwargs): asyncio.get_event_loop().run_until_complete(self.main(*args, **kwargs)) except Exception as ex: echo(f"Got error: {ex!r}") + raise def json_formatter_cb(result, **kwargs): @@ -578,6 +579,10 @@ async def state(ctx, dev: Device): else: echo(f"\t{info_name}: {info_data}") + echo("\n\t[bold]== Features == [/bold]") + for id_, feature in dev.features.items(): + echo(f"\t{feature.name} ({id_}): {feature.value}") + if dev.has_emeter: echo("\n\t[bold]== Current State ==[/bold]") emeter_status = dev.emeter_realtime @@ -594,8 +599,6 @@ async def state(ctx, dev: Device): echo("\n\t[bold]== Verbose information ==[/bold]") echo(f"\tCredentials hash: {dev.credentials_hash}") 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 @@ -1115,5 +1118,37 @@ async def shell(dev: Device): loop.stop() +@cli.command(name="feature") +@click.argument("name", required=False) +@click.argument("value", required=False) +@pass_dev +async def feature(dev, name: str, value): + """Access and modify features. + + If no *name* is given, lists available features and their values. + If only *name* is given, the value of named feature is returned. + If both *name* and *value* are set, the described setting is changed. + """ + if not name: + echo("[bold]== Features ==[/bold]") + for name, feat in dev.features.items(): + echo(f"{feat.name} ({name}): {feat.value}") + return + + if name not in dev.features: + echo(f"No feature by name {name}") + return + + feat = dev.features[name] + + if value is None: + echo(f"{feat.name} ({name}): {feat.value}") + return feat.value + + echo(f"Setting {name} to {value}") + value = ast.literal_eval(value) + return await dev.features[name].set_value(value) + + if __name__ == "__main__": cli() diff --git a/kasa/device.py b/kasa/device.py index 48537ff56..3c38b5446 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -3,13 +3,14 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict, List, Optional, Sequence, Set, Union +from typing import Any, Dict, List, Optional, Sequence, Union from .credentials import Credentials from .device_type import DeviceType from .deviceconfig import DeviceConfig from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException +from .feature import Feature from .iotprotocol import IotProtocol from .protocol import BaseProtocol from .xortransport import XorTransport @@ -69,6 +70,7 @@ def __init__( self._discovery_info: Optional[Dict[str, Any]] = None self.modules: Dict[str, Any] = {} + self._features: Dict[str, Feature] = {} @staticmethod async def connect( @@ -296,9 +298,16 @@ def state_information(self) -> Dict[str, Any]: """Return the key state information.""" @property - @abstractmethod - def features(self) -> Set[str]: + def features(self) -> Dict[str, Feature]: """Return the list of supported features.""" + return self._features + + def _add_feature(self, feature: Feature): + """Add a new feature to the device.""" + desc_name = feature.name.lower().replace(" ", "_") + if desc_name in self._features: + raise SmartDeviceException("Duplicate feature name %s" % desc_name) + self._features[desc_name] = feature @property @abstractmethod diff --git a/kasa/feature.py b/kasa/feature.py new file mode 100644 index 000000000..c0c14b06c --- /dev/null +++ b/kasa/feature.py @@ -0,0 +1,50 @@ +"""Generic interface for defining device features.""" +from dataclasses import dataclass +from enum import Enum, auto +from typing import TYPE_CHECKING, Any, Callable, Optional, Union + +if TYPE_CHECKING: + from .device import Device + + +class FeatureType(Enum): + """Type to help decide how to present the feature.""" + + Sensor = auto() + BinarySensor = auto() + Switch = auto() + Button = auto() + + +@dataclass +class Feature: + """Feature defines a generic interface for device features.""" + + #: Device instance required for getting and setting values + device: "Device" + #: User-friendly short description + name: str + #: Name of the property that allows accessing the value + attribute_getter: Union[str, Callable] + #: Name of the method that allows changing the value + attribute_setter: Optional[str] = None + #: Container storing the data, this overrides 'device' for getters + container: Any = None + #: Icon suggestion + icon: Optional[str] = None + #: Type of the feature + type: FeatureType = FeatureType.Sensor + + @property + def value(self): + """Return the current value.""" + container = self.container if self.container is not None else self.device + if isinstance(self.attribute_getter, Callable): + return self.attribute_getter(container) + return getattr(container, self.attribute_getter) + + async def set_value(self, value): + """Set the value.""" + if self.attribute_setter is None: + raise ValueError("Tried to set read-only feature.") + return await getattr(self.device, self.attribute_setter)(value) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 8e51cac65..8ec7cd4bf 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -22,6 +22,7 @@ from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import SmartDeviceException +from ..feature import Feature from ..protocol import BaseProtocol from .modules import Emeter, IotModule @@ -184,8 +185,9 @@ def __init__( super().__init__(host=host, config=config, protocol=protocol) self._sys_info: Any = None # TODO: this is here to avoid changing tests - self._features: Set[str] = set() self._children: Sequence["IotDevice"] = [] + self._supported_modules: Optional[Dict[str, IotModule]] = None + self._legacy_features: Set[str] = set() @property def children(self) -> Sequence["IotDevice"]: @@ -260,7 +262,7 @@ async def _query_helper( @property # type: ignore @requires_update - def features(self) -> Set[str]: + def features(self) -> Dict[str, Feature]: """Return a set of features that the device supports.""" return self._features @@ -276,7 +278,7 @@ def supported_modules(self) -> List[str]: @requires_update def has_emeter(self) -> bool: """Return True if device has an energy meter.""" - return "ENE" in self.features + return "ENE" in self._legacy_features async def get_sys_info(self) -> Dict[str, Any]: """Retrieve system information.""" @@ -299,9 +301,28 @@ async def update(self, update_children: bool = True): self._last_update = response self._set_sys_info(response["system"]["get_sysinfo"]) + if not self._features: + await self._initialize_features() + await self._modular_update(req) self._set_sys_info(self._last_update["system"]["get_sysinfo"]) + async def _initialize_features(self): + self._add_feature( + Feature( + device=self, name="RSSI", attribute_getter="rssi", icon="mdi:signal" + ) + ) + if "on_time" in self._sys_info: + self._add_feature( + Feature( + device=self, + name="On since", + attribute_getter="on_since", + icon="mdi:clock", + ) + ) + async def _modular_update(self, req: dict) -> None: """Execute an update query.""" if self.has_emeter: @@ -310,6 +331,18 @@ async def _modular_update(self, req: dict) -> None: ) self.add_module("emeter", Emeter(self, self.emeter_type)) + # TODO: perhaps modules should not have unsupported modules, + # making separate handling for this unnecessary + if self._supported_modules is None: + supported = {} + for module in self.modules.values(): + if module.is_supported: + supported[module._module] = module + for module_feat in module._module_features.values(): + self._add_feature(module_feat) + + self._supported_modules = supported + request_list = [] est_response_size = 1024 if "system" in req else 0 for module in self.modules.values(): @@ -357,9 +390,7 @@ def _set_sys_info(self, sys_info: Dict[str, Any]) -> None: """Set sys_info.""" self._sys_info = sys_info if features := sys_info.get("feature"): - self._features = _parse_features(features) - else: - self._features = set() + self._legacy_features = _parse_features(features) @property # type: ignore @requires_update diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 72cba7c31..c72489660 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -4,6 +4,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig +from ..feature import Feature, FeatureType from ..protocol import BaseProtocol from .iotdevice import IotDevice, requires_update from .modules import Antitheft, Cloud, Schedule, Time, Usage @@ -56,6 +57,17 @@ def __init__( self.add_module("time", Time(self, "time")) self.add_module("cloud", Cloud(self, "cnCloud")) + self._add_feature( + Feature( + device=self, + name="LED", + icon="mdi:led-{state}", + attribute_getter="led", + attribute_setter="set_led", + type=FeatureType.Switch, + ) + ) + @property # type: ignore @requires_update def is_on(self) -> bool: @@ -88,5 +100,4 @@ async def set_led(self, state: bool): @requires_update def state_information(self) -> Dict[str, Any]: """Return switch-specific state information.""" - info = {"LED state": self.led, "On since": self.on_since} - return info + return {} diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index 28cf2d1eb..76d6fb1eb 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -4,6 +4,7 @@ except ImportError: from pydantic import BaseModel +from ...feature import Feature, FeatureType from .module import IotModule @@ -25,6 +26,24 @@ class CloudInfo(BaseModel): class Cloud(IotModule): """Module implementing support for cloud services.""" + def __init__(self, device, module): + super().__init__(device, module) + self._add_feature( + Feature( + device=device, + container=self, + name="Cloud connection", + icon="mdi:cloud", + attribute_getter="is_connected", + type=FeatureType.BinarySensor, + ) + ) + + @property + def is_connected(self) -> bool: + """Return true if device is connected to the cloud.""" + return self.info.binded + def query(self): """Request cloud connectivity info.""" return self.query_for_command("get_info") diff --git a/kasa/iot/modules/module.py b/kasa/iot/modules/module.py index 51d4b350d..57c245a06 100644 --- a/kasa/iot/modules/module.py +++ b/kasa/iot/modules/module.py @@ -2,9 +2,10 @@ import collections import logging from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict from ...exceptions import SmartDeviceException +from ...feature import Feature if TYPE_CHECKING: from kasa.iot import IotDevice @@ -34,6 +35,14 @@ class IotModule(ABC): def __init__(self, device: "IotDevice", module: str): self._device = device self._module = module + self._module_features: Dict[str, Feature] = {} + + def _add_feature(self, feature: Feature): + """Add module feature.""" + feature_name = f"{self._module}_{feature.name}" + if feature_name in self._module_features: + raise SmartDeviceException("Duplicate name detected %s" % feature_name) + self._module_features[feature_name] = feature @abstractmethod def query(self): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 0929c418d..dde8634f3 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -2,7 +2,7 @@ import base64 import logging from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Set, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, cast from ..aestransport import AesTransport from ..device import Device, WifiNetwork @@ -10,6 +10,7 @@ from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationException, SmartDeviceException +from ..feature import Feature, FeatureType from ..smartprotocol import SmartProtocol _LOGGER = logging.getLogger(__name__) @@ -124,6 +125,11 @@ async def update(self, update_children: bool = True): for info in child_info["child_device_list"]: self._children[info["device_id"]].update_internal_state(info) + # We can first initialize the features after the first update. + # We make here an assumption that every device has at least a single feature. + if not self._features: + await self._initialize_features() + _LOGGER.debug("Got an update: %s", self._last_update) async def _initialize_modules(self): @@ -131,6 +137,51 @@ async def _initialize_modules(self): if "energy_monitoring" in self._components: self.emeter_type = "emeter" + async def _initialize_features(self): + """Initialize device features.""" + self._add_feature( + Feature( + self, + "Signal Level", + attribute_getter=lambda x: x._info["signal_level"], + icon="mdi:signal", + ) + ) + self._add_feature( + Feature( + self, + "RSSI", + attribute_getter=lambda x: x._info["rssi"], + icon="mdi:signal", + ) + ) + self._add_feature( + Feature(device=self, name="SSID", attribute_getter="ssid", icon="mdi:wifi") + ) + + if "overheated" in self._info: + self._add_feature( + Feature( + self, + "Overheated", + attribute_getter=lambda x: x._info["overheated"], + icon="mdi:heat-wave", + type=FeatureType.BinarySensor, + ) + ) + + # We check for the key available, and not for the property truthiness, + # as the value is falsy when the device is off. + if "on_time" in self._info: + self._add_feature( + Feature( + device=self, + name="On since", + attribute_getter="on_since", + icon="mdi:clock", + ) + ) + @property def sys_info(self) -> Dict[str, Any]: """Returns the device info.""" @@ -221,23 +272,21 @@ async def _query_helper( return res @property - def state_information(self) -> Dict[str, Any]: - """Return the key state information.""" + def ssid(self) -> str: + """Return ssid of the connected wifi ap.""" ssid = self._info.get("ssid") ssid = base64.b64decode(ssid).decode() if ssid else "No SSID" + return ssid + @property + def state_information(self) -> Dict[str, Any]: + """Return the key state information.""" return { "overheated": self._info.get("overheated"), "signal_level": self._info.get("signal_level"), - "SSID": ssid, + "SSID": self.ssid, } - @property - def features(self) -> Set[str]: - """Return the list of supported features.""" - # TODO: - return set() - @property def has_emeter(self) -> bool: """Return if the device has emeter.""" diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 362511ceb..51155f407 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -37,6 +37,11 @@ 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") + + # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.device.Device.features", return_value={}) + mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) + mocker.patch("kasa.discover.Discover.discover_single", return_value=dev) res = await runner.invoke( @@ -49,6 +54,7 @@ async def test_update_called_by_cli(dev, mocker): "--password", "bar", ], + catch_exceptions=False, ) assert res.exit_code == 0 update.assert_called() @@ -292,6 +298,10 @@ async def test_brightness(dev): async def test_json_output(dev: Device, mocker): """Test that the json output produces correct output.""" mocker.patch("kasa.Discover.discover", return_value={"127.0.0.1": dev}) + # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.device.Device.features", return_value={}) + mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) + runner = CliRunner() res = await runner.invoke(cli, ["--json", "state"], obj=dev) assert res.exit_code == 0 @@ -345,6 +355,10 @@ async def test_without_device_type(dev, mocker): discovery_mock = mocker.patch( "kasa.discover.Discover.discover_single", return_value=dev ) + # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.device.Device.features", return_value={}) + mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) + res = await runner.invoke( cli, [ @@ -410,6 +424,10 @@ async def test_duplicate_target_device(): async def test_discover(discovery_mock, mocker): """Test discovery output.""" + # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.device.Device.features", return_value={}) + mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) + runner = CliRunner() res = await runner.invoke( cli, @@ -429,6 +447,10 @@ async def test_discover(discovery_mock, mocker): async def test_discover_host(discovery_mock, mocker): """Test discovery output.""" + # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.device.Device.features", return_value={}) + mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) + runner = CliRunner() res = await runner.invoke( cli, diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py new file mode 100644 index 000000000..549f4266e --- /dev/null +++ b/kasa/tests/test_feature.py @@ -0,0 +1,79 @@ +import pytest + +from kasa import Feature, FeatureType + + +@pytest.fixture +def dummy_feature() -> Feature: + # create_autospec for device slows tests way too much, so we use a dummy here + class DummyDevice: + pass + + feat = Feature( + device=DummyDevice(), # type: ignore[arg-type] + name="dummy_feature", + attribute_getter="dummygetter", + attribute_setter="dummysetter", + container=None, + icon="mdi:dummy", + type=FeatureType.BinarySensor, + ) + return feat + + +def test_feature_api(dummy_feature: Feature): + """Test all properties of a dummy feature.""" + assert dummy_feature.device is not None + assert dummy_feature.name == "dummy_feature" + assert dummy_feature.attribute_getter == "dummygetter" + assert dummy_feature.attribute_setter == "dummysetter" + assert dummy_feature.container is None + assert dummy_feature.icon == "mdi:dummy" + assert dummy_feature.type == FeatureType.BinarySensor + + +def test_feature_value(dummy_feature: Feature): + """Verify that property gets accessed on *value* access.""" + dummy_feature.attribute_getter = "test_prop" + dummy_feature.device.test_prop = "dummy" # type: ignore[attr-defined] + assert dummy_feature.value == "dummy" + + +def test_feature_value_container(mocker, dummy_feature: Feature): + """Test that container's attribute is accessed when expected.""" + + class DummyContainer: + @property + def test_prop(self): + return "dummy" + + dummy_feature.container = DummyContainer() + dummy_feature.attribute_getter = "test_prop" + + mock_dev_prop = mocker.patch.object( + dummy_feature, "test_prop", new_callable=mocker.PropertyMock, create=True + ) + + assert dummy_feature.value == "dummy" + mock_dev_prop.assert_not_called() + + +def test_feature_value_callable(dev, dummy_feature: Feature): + """Verify that callables work as *attribute_getter*.""" + dummy_feature.attribute_getter = lambda x: "dummy value" + assert dummy_feature.value == "dummy value" + + +async def test_feature_setter(dev, mocker, dummy_feature: Feature): + """Verify that *set_value* calls the defined method.""" + mock_set_dummy = mocker.patch.object(dummy_feature.device, "set_dummy", create=True) + dummy_feature.attribute_setter = "set_dummy" + await dummy_feature.set_value("dummy value") + mock_set_dummy.assert_called_with("dummy value") + + +async def test_feature_setter_read_only(dummy_feature): + """Verify that read-only feature raises an exception when trying to change it.""" + dummy_feature.attribute_setter = None + with pytest.raises(ValueError): + await dummy_feature.set_value("value for read only feature") diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index ba5ebc4fe..efe6995ba 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -67,7 +67,7 @@ async def test_invalid_connection(dev): async def test_initial_update_emeter(dev, mocker): """Test that the initial update performs second query if emeter is available.""" dev._last_update = None - dev._features = set() + dev._legacy_features = set() spy = mocker.spy(dev.protocol, "query") await dev.update() # Devices with small buffers may require 3 queries @@ -79,7 +79,7 @@ async def test_initial_update_emeter(dev, mocker): async def test_initial_update_no_emeter(dev, mocker): """Test that the initial update performs second query if emeter is available.""" dev._last_update = None - dev._features = set() + dev._legacy_features = set() spy = mocker.spy(dev.protocol, "query") await dev.update() # 2 calls are necessary as some devices crash on unexpected modules @@ -218,9 +218,9 @@ async def test_features(dev): """Make sure features is always accessible.""" sysinfo = dev._last_update["system"]["get_sysinfo"] if "feature" in sysinfo: - assert dev.features == set(sysinfo["feature"].split(":")) + assert dev._legacy_features == set(sysinfo["feature"].split(":")) else: - assert dev.features == set() + assert dev._legacy_features == set() @device_iot From 9ab9420ad6dedba54bd3e8a66358beb5f48ba62a Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 15 Feb 2024 18:10:34 +0000 Subject: [PATCH 323/892] Let caller handle SMART errors on multi-requests (#754) * Fix for missing get_device_usage * Fix coverage and add methods to exceptions * Remove unused caplog fixture --- kasa/exceptions.py | 3 +++ kasa/smart/smartdevice.py | 34 +++++++++++++++++++++------- kasa/smartprotocol.py | 24 +++++++++++--------- kasa/tests/test_smartdevice.py | 38 +++++++++++++++++++++++++++++++- kasa/tests/test_smartprotocol.py | 11 ++++----- 5 files changed, 84 insertions(+), 26 deletions(-) diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 75f09169f..af9aaaa59 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -45,6 +45,9 @@ class ConnectionException(SmartDeviceException): class SmartErrorCode(IntEnum): """Enum for SMART Error Codes.""" + def __str__(self): + return f"{self.name}({self.value})" + SUCCESS = 0 # Transport Errors diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index dde8634f3..d22594347 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -9,7 +9,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus -from ..exceptions import AuthenticationException, SmartDeviceException +from ..exceptions import AuthenticationException, SmartDeviceException, SmartErrorCode from ..feature import Feature, FeatureType from ..smartprotocol import SmartProtocol @@ -61,6 +61,24 @@ def children(self) -> Sequence["SmartDevice"]: """Return list of children.""" return list(self._children.values()) + def _try_get_response(self, responses: dict, request: str, default=None) -> dict: + response = responses.get(request) + if isinstance(response, SmartErrorCode): + _LOGGER.debug( + "Error %s getting request %s for device %s", + response, + request, + self.host, + ) + response = None + if response is not None: + return response + if default is not None: + return default + raise SmartDeviceException( + f"{request} not found in {responses} for device {self.host}" + ) + async def update(self, update_children: bool = True): """Update the device.""" if self.credentials is None and self.credentials_hash is None: @@ -87,7 +105,7 @@ async def update(self, update_children: bool = True): "get_current_power": None, } - if self._components["device"] >= 2: + if self._components.get("device", 0) >= 2: extra_reqs = { **extra_reqs, "get_device_usage": None, @@ -101,13 +119,13 @@ async def update(self, update_children: bool = True): resp = await self.protocol.query(req) - self._info = resp["get_device_info"] - self._time = resp["get_device_time"] + self._info = self._try_get_response(resp, "get_device_info") + self._time = self._try_get_response(resp, "get_device_time", {}) # Device usage is not available on older firmware versions - self._usage = resp.get("get_device_usage", {}) + self._usage = self._try_get_response(resp, "get_device_usage", {}) # Emeter is not always available, but we set them still for now. - self._energy = resp.get("get_energy_usage", {}) - self._emeter = resp.get("get_current_power", {}) + self._energy = self._try_get_response(resp, "get_energy_usage", {}) + self._emeter = self._try_get_response(resp, "get_current_power", {}) self._last_update = { "components": self._components_raw, @@ -116,7 +134,7 @@ async def update(self, update_children: bool = True): "time": self._time, "energy": self._energy, "emeter": self._emeter, - "child_info": resp.get("get_child_device_list", {}), + "child_info": self._try_get_response(resp, "get_child_device_list", {}), } if child_info := self._last_update.get("child_info"): diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index f61bac206..54e2fe1c3 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -129,19 +129,21 @@ async def _execute_multiple_query(self, request: Dict, retry_count: int) -> Dict pf(smart_request), ) response_step = await self._transport.send(smart_request) + batch_name = f"multi-request-batch-{i+1}" if debug_enabled: _LOGGER.debug( - "%s multi-request-batch-%s << %s", + "%s %s << %s", self._host, - i + 1, + batch_name, pf(response_step), ) - self._handle_response_error_code(response_step) + self._handle_response_error_code(response_step, batch_name) responses = response_step["result"]["responses"] for response in responses: - self._handle_response_error_code(response) + method = response["method"] + self._handle_response_error_code(response, method, raise_on_error=False) result = response.get("result", None) - multi_result[response["method"]] = result + multi_result[method] = result return multi_result async def _execute_query(self, request: Union[str, Dict], retry_count: int) -> Dict: @@ -173,22 +175,24 @@ async def _execute_query(self, request: Union[str, Dict], retry_count: int) -> D pf(response_data), ) - self._handle_response_error_code(response_data) + self._handle_response_error_code(response_data, smart_method) # Single set_ requests do not return a result result = response_data.get("result") return {smart_method: result} - def _handle_response_error_code(self, resp_dict: dict): + def _handle_response_error_code(self, resp_dict: dict, method, raise_on_error=True): error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] if error_code == SmartErrorCode.SUCCESS: return + if not raise_on_error: + resp_dict["result"] = error_code + return msg = ( f"Error querying device: {self._host}: " + f"{error_code.name}({error_code.value})" + + f" for method: {method}" ) - if method := resp_dict.get("method"): - msg += f" for method: {method}" if error_code in SMART_TIMEOUT_ERRORS: raise TimeoutException(msg, error_code=error_code) if error_code in SMART_RETRYABLE_ERRORS: @@ -338,7 +342,7 @@ async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: result = response.get("control_child") # Unwrap responseData for control_child if result and (response_data := result.get("responseData")): - self._handle_response_error_code(response_data) + self._handle_response_error_code(response_data, "control_child") result = response_data.get("result") # TODO: handle multipleRequest unwrapping diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index efe6995ba..67f8fa84f 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -1,5 +1,6 @@ import importlib import inspect +import logging import pkgutil import re import sys @@ -21,10 +22,18 @@ import kasa from kasa import Credentials, Device, DeviceConfig, SmartDeviceException +from kasa.exceptions import SmartErrorCode from kasa.iot import IotDevice from kasa.smart import SmartChildDevice, SmartDevice -from .conftest import device_iot, handle_turn_on, has_emeter_iot, no_emeter_iot, turn_on +from .conftest import ( + device_iot, + device_smart, + handle_turn_on, + has_emeter_iot, + no_emeter_iot, + turn_on, +) from .fakeprotocol_iot import FakeIotProtocol @@ -300,6 +309,33 @@ async def test_modules_not_supported(dev: IotDevice): assert module.is_supported is not None +@device_smart +async def test_update_sub_errors(dev: SmartDevice, caplog): + mock_response: dict = { + "get_device_info": {}, + "get_device_usage": SmartErrorCode.PARAMS_ERROR, + "get_device_time": {}, + } + caplog.set_level(logging.DEBUG) + with patch.object(dev.protocol, "query", return_value=mock_response): + await dev.update() + msg = "Error PARAMS_ERROR(-1008) getting request get_device_usage for device 127.0.0.123" + assert msg in caplog.text + + +@device_smart +async def test_update_no_device_info(dev: SmartDevice): + mock_response: dict = { + "get_device_usage": {}, + "get_device_time": {}, + } + msg = f"get_device_info not found in {mock_response} for device 127.0.0.123" + with patch.object(dev.protocol, "query", return_value=mock_response), pytest.raises( + SmartDeviceException, match=msg + ): + await dev.update() + + @pytest.mark.parametrize( "device_class, use_class", kasa.deprecated_smart_devices.items() ) diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 86f554b27..7d677a831 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -60,13 +60,10 @@ async def test_smart_device_errors_in_multiple_request( send_mock = mocker.patch.object( dummy_protocol._transport, "send", return_value=mock_response ) - with pytest.raises(SmartDeviceException): - await dummy_protocol.query(DUMMY_MULTIPLE_QUERY, retry_count=2) - if error_code in chain(SMART_TIMEOUT_ERRORS, SMART_RETRYABLE_ERRORS): - expected_calls = 3 - else: - expected_calls = 1 - assert send_mock.call_count == expected_calls + + resp_dict = await dummy_protocol.query(DUMMY_MULTIPLE_QUERY, retry_count=2) + assert resp_dict["foobar2"] == error_code + assert send_mock.call_count == 1 @pytest.mark.parametrize("request_size", [1, 3, 5, 10]) From e86dcb6bf5540e31dfa19c673f51963f213b0f15 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 19 Feb 2024 00:08:39 +0100 Subject: [PATCH 324/892] Fix dump_devinfo scrubbing for ks240 (#765) --- devtools/dump_devinfo.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index a227a50df..09f102bde 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -52,6 +52,8 @@ def scrub(res): "longitude_i", "latitude", "longitude", + "la", # lat on ks240 + "lo", # lon on ks240 "owner", "device_id", "ip", From 11719991c05bef701e2ff730a0ee1772245b1ad1 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 19 Feb 2024 18:01:31 +0100 Subject: [PATCH 325/892] Initial implementation for modularized smartdevice (#757) The initial steps to modularize the smartdevice. Modules are initialized based on the component negotiation, and each module can indicate which features it supports and which queries should be run during the update cycle. --- kasa/cli.py | 5 +- kasa/iot/iotdevice.py | 3 +- kasa/iot/{modules/module.py => iotmodule.py} | 60 ++----- kasa/iot/modules/__init__.py | 2 - kasa/iot/modules/ambientlight.py | 2 +- kasa/iot/modules/cloud.py | 2 +- kasa/iot/modules/motion.py | 2 +- kasa/iot/modules/rulemodule.py | 2 +- kasa/iot/modules/time.py | 2 +- kasa/iot/modules/usage.py | 2 +- kasa/module.py | 49 ++++++ kasa/smart/modules/__init__.py | 7 + kasa/smart/modules/childdevicemodule.py | 9 + kasa/smart/modules/devicemodule.py | 21 +++ kasa/smart/modules/energymodule.py | 88 ++++++++++ kasa/smart/modules/timemodule.py | 52 ++++++ kasa/smart/smartchilddevice.py | 3 - kasa/smart/smartdevice.py | 164 +++++++++---------- kasa/smart/smartmodule.py | 73 +++++++++ kasa/tests/test_childdevice.py | 5 + kasa/tests/test_smartdevice.py | 11 +- 21 files changed, 408 insertions(+), 156 deletions(-) rename kasa/iot/{modules/module.py => iotmodule.py} (54%) create mode 100644 kasa/module.py create mode 100644 kasa/smart/modules/__init__.py create mode 100644 kasa/smart/modules/childdevicemodule.py create mode 100644 kasa/smart/modules/devicemodule.py create mode 100644 kasa/smart/modules/energymodule.py create mode 100644 kasa/smart/modules/timemodule.py create mode 100644 kasa/smart/smartmodule.py diff --git a/kasa/cli.py b/kasa/cli.py index e922ec81c..4d3590d10 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -590,10 +590,7 @@ async def state(ctx, dev: Device): echo("\n\t[bold]== Modules ==[/bold]") for module in dev.modules.values(): - if module.is_supported: - echo(f"\t[green]+ {module}[/green]") - else: - echo(f"\t[red]- {module}[/red]") + echo(f"\t[green]+ {module}[/green]") if verbose: echo("\n\t[bold]== Verbose information ==[/bold]") diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 8ec7cd4bf..ac902af84 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -24,7 +24,8 @@ from ..exceptions import SmartDeviceException from ..feature import Feature from ..protocol import BaseProtocol -from .modules import Emeter, IotModule +from .iotmodule import IotModule +from .modules import Emeter _LOGGER = logging.getLogger(__name__) diff --git a/kasa/iot/modules/module.py b/kasa/iot/iotmodule.py similarity index 54% rename from kasa/iot/modules/module.py rename to kasa/iot/iotmodule.py index 57c245a06..ddff06b39 100644 --- a/kasa/iot/modules/module.py +++ b/kasa/iot/iotmodule.py @@ -1,20 +1,14 @@ -"""Base class for all module implementations.""" +"""Base class for IOT module implementations.""" import collections import logging -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Dict - -from ...exceptions import SmartDeviceException -from ...feature import Feature - -if TYPE_CHECKING: - from kasa.iot import IotDevice +from ..exceptions import SmartDeviceException +from ..module import Module _LOGGER = logging.getLogger(__name__) -# TODO: This is used for query construcing +# TODO: This is used for query constructing, check for a better place def merge(d, u): """Update dict recursively.""" for k, v in u.items(): @@ -25,32 +19,16 @@ def merge(d, u): return d -class IotModule(ABC): - """Base class implemention for all modules. - - The base classes should implement `query` to return the query they want to be - executed during the regular update cycle. - """ +class IotModule(Module): + """Base class implemention for all IOT modules.""" - def __init__(self, device: "IotDevice", module: str): - self._device = device - self._module = module - self._module_features: Dict[str, Feature] = {} - - def _add_feature(self, feature: Feature): - """Add module feature.""" - feature_name = f"{self._module}_{feature.name}" - if feature_name in self._module_features: - raise SmartDeviceException("Duplicate name detected %s" % feature_name) - self._module_features[feature_name] = feature - - @abstractmethod - def query(self): - """Query to execute during the update cycle. + def call(self, method, params=None): + """Call the given method with the given parameters.""" + return self._device._query_helper(self._module, method, params) - The inheriting modules implement this to include their wanted - queries to the query that gets executed when Device.update() gets called. - """ + def query_for_command(self, query, params=None): + """Create a request object for the given parameters.""" + return self._device._create_request(self._module, query, params) @property def estimated_query_response_size(self): @@ -80,17 +58,3 @@ def is_supported(self) -> bool: return True return "err_code" not in self.data - - def call(self, method, params=None): - """Call the given method with the given parameters.""" - return self._device._query_helper(self._module, method, params) - - def query_for_command(self, query, params=None): - """Create a request object for the given parameters.""" - return self._device._create_request(self._module, query, params) - - def __repr__(self) -> str: - return ( - f"" - ) diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index 17a34b6e7..e4278b26c 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -4,7 +4,6 @@ from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter -from .module import IotModule from .motion import Motion from .rulemodule import Rule, RuleModule from .schedule import Schedule @@ -17,7 +16,6 @@ "Cloud", "Countdown", "Emeter", - "IotModule", "Motion", "Rule", "RuleModule", diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py index 0a7663671..f1069448c 100644 --- a/kasa/iot/modules/ambientlight.py +++ b/kasa/iot/modules/ambientlight.py @@ -1,5 +1,5 @@ """Implementation of the ambient light (LAS) module found in some dimmers.""" -from .module import IotModule +from ..iotmodule import IotModule # TODO create tests and use the config reply there # [{"hw_id":0,"enable":0,"dark_index":1,"min_adc":0,"max_adc":2450, diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index 76d6fb1eb..b5c04d0b0 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -5,7 +5,7 @@ from pydantic import BaseModel from ...feature import Feature, FeatureType -from .module import IotModule +from ..iotmodule import IotModule class CloudInfo(BaseModel): diff --git a/kasa/iot/modules/motion.py b/kasa/iot/modules/motion.py index cd79cba79..05edb2a53 100644 --- a/kasa/iot/modules/motion.py +++ b/kasa/iot/modules/motion.py @@ -3,7 +3,7 @@ from typing import Optional from ...exceptions import SmartDeviceException -from .module import IotModule +from ..iotmodule import IotModule class Range(Enum): diff --git a/kasa/iot/modules/rulemodule.py b/kasa/iot/modules/rulemodule.py index f840f6725..81853793d 100644 --- a/kasa/iot/modules/rulemodule.py +++ b/kasa/iot/modules/rulemodule.py @@ -9,7 +9,7 @@ from pydantic import BaseModel -from .module import IotModule, merge +from ..iotmodule import IotModule, merge class Action(Enum): diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py index 2099e22c4..568df1804 100644 --- a/kasa/iot/modules/time.py +++ b/kasa/iot/modules/time.py @@ -2,7 +2,7 @@ from datetime import datetime from ...exceptions import SmartDeviceException -from .module import IotModule, merge +from ..iotmodule import IotModule, merge class Time(IotModule): diff --git a/kasa/iot/modules/usage.py b/kasa/iot/modules/usage.py index 29dcd1727..f64baf79d 100644 --- a/kasa/iot/modules/usage.py +++ b/kasa/iot/modules/usage.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Dict -from .module import IotModule, merge +from ..iotmodule import IotModule, merge class Usage(IotModule): diff --git a/kasa/module.py b/kasa/module.py new file mode 100644 index 000000000..66a143dc7 --- /dev/null +++ b/kasa/module.py @@ -0,0 +1,49 @@ +"""Base class for all module implementations.""" +import logging +from abc import ABC, abstractmethod +from typing import Dict + +from .device import Device +from .exceptions import SmartDeviceException +from .feature import Feature + +_LOGGER = logging.getLogger(__name__) + + +class Module(ABC): + """Base class implemention for all modules. + + The base classes should implement `query` to return the query they want to be + executed during the regular update cycle. + """ + + def __init__(self, device: "Device", module: str): + self._device = device + self._module = module + self._module_features: Dict[str, Feature] = {} + + @abstractmethod + def query(self): + """Query to execute during the update cycle. + + The inheriting modules implement this to include their wanted + queries to the query that gets executed when Device.update() gets called. + """ + + @property + @abstractmethod + def data(self): + """Return the module specific raw data from the last update.""" + + def _add_feature(self, feature: Feature): + """Add module feature.""" + feat_name = f"{self._module}_{feature.name}" + if feat_name in self._module_features: + raise SmartDeviceException("Duplicate name detected %s" % feat_name) + self._module_features[feat_name] = feature + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py new file mode 100644 index 000000000..564363222 --- /dev/null +++ b/kasa/smart/modules/__init__.py @@ -0,0 +1,7 @@ +"""Modules for SMART devices.""" +from .childdevicemodule import ChildDeviceModule +from .devicemodule import DeviceModule +from .energymodule import EnergyModule +from .timemodule import TimeModule + +__all__ = ["TimeModule", "EnergyModule", "DeviceModule", "ChildDeviceModule"] diff --git a/kasa/smart/modules/childdevicemodule.py b/kasa/smart/modules/childdevicemodule.py new file mode 100644 index 000000000..991acc25b --- /dev/null +++ b/kasa/smart/modules/childdevicemodule.py @@ -0,0 +1,9 @@ +"""Implementation for child devices.""" +from ..smartmodule import SmartModule + + +class ChildDeviceModule(SmartModule): + """Implementation for child devices.""" + + REQUIRED_COMPONENT = "child_device" + QUERY_GETTER_NAME = "get_child_device_list" diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py new file mode 100644 index 000000000..80e7287f0 --- /dev/null +++ b/kasa/smart/modules/devicemodule.py @@ -0,0 +1,21 @@ +"""Implementation of device module.""" +from typing import Dict + +from ..smartmodule import SmartModule + + +class DeviceModule(SmartModule): + """Implementation of device module.""" + + REQUIRED_COMPONENT = "device" + + def query(self) -> Dict: + """Query to execute during the update cycle.""" + query = { + "get_device_info": None, + } + # Device usage is not available on older firmware versions + if self._device._components[self.REQUIRED_COMPONENT] >= 2: + query["get_device_usage"] = None + + return query diff --git a/kasa/smart/modules/energymodule.py b/kasa/smart/modules/energymodule.py new file mode 100644 index 000000000..5782a23fd --- /dev/null +++ b/kasa/smart/modules/energymodule.py @@ -0,0 +1,88 @@ +"""Implementation of energy monitoring module.""" +from typing import TYPE_CHECKING, Dict, Optional + +from ...emeterstatus import EmeterStatus +from ...feature import Feature +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class EnergyModule(SmartModule): + """Implementation of energy monitoring module.""" + + REQUIRED_COMPONENT = "energy_monitoring" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + name="Current consumption", + attribute_getter="current_power", + container=self, + ) + ) # W or mW? + self._add_feature( + Feature( + device, + name="Today's consumption", + attribute_getter="emeter_today", + container=self, + ) + ) # Wh or kWh? + self._add_feature( + Feature( + device, + name="This month's consumption", + attribute_getter="emeter_this_month", + container=self, + ) + ) # Wh or kWH? + + def query(self) -> Dict: + """Query to execute during the update cycle.""" + return { + "get_energy_usage": None, + # The current_power in get_energy_usage is more precise (mw vs. w), + # making this rather useless, but maybe there are version differences? + "get_current_power": None, + } + + @property + def current_power(self): + """Current power.""" + return self.emeter_realtime.power + + @property + def energy(self): + """Return get_energy_usage results.""" + return self.data["get_energy_usage"] + + @property + def emeter_realtime(self): + """Get the emeter status.""" + # TODO: Perhaps we should get rid of emeterstatus altogether for smartdevices + return EmeterStatus( + { + "power_mw": self.energy.get("current_power"), + "total": self._convert_energy_data( + self.energy.get("today_energy"), 1 / 1000 + ), + } + ) + + @property + def emeter_this_month(self) -> Optional[float]: + """Get the emeter value for this month.""" + return self._convert_energy_data(self.energy.get("month_energy"), 1 / 1000) + + @property + def emeter_today(self) -> Optional[float]: + """Get the emeter value for today.""" + return self._convert_energy_data(self.energy.get("today_energy"), 1 / 1000) + + def _convert_energy_data(self, data, scale) -> Optional[float]: + """Return adjusted emeter information.""" + return data if not data else data * scale diff --git a/kasa/smart/modules/timemodule.py b/kasa/smart/modules/timemodule.py new file mode 100644 index 000000000..778da5110 --- /dev/null +++ b/kasa/smart/modules/timemodule.py @@ -0,0 +1,52 @@ +"""Implementation of time module.""" +from datetime import datetime, timedelta, timezone +from time import mktime +from typing import TYPE_CHECKING, cast + +from ...feature import Feature +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class TimeModule(SmartModule): + """Implementation of device_local_time.""" + + REQUIRED_COMPONENT = "time" + QUERY_GETTER_NAME = "get_device_time" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + + self._add_feature( + Feature( + device=device, + name="Time", + attribute_getter="time", + container=self, + ) + ) + + @property + def time(self) -> datetime: + """Return device's current datetime.""" + td = timedelta(minutes=cast(float, self.data.get("time_diff"))) + if self.data.get("region"): + tz = timezone(td, str(self.data.get("region"))) + else: + # in case the device returns a blank region this will result in the + # tzname being a UTC offset + tz = timezone(td) + return datetime.fromtimestamp( + cast(float, self.data.get("timestamp")), + tz=tz, + ) + + async def set_time(self, dt: datetime): + """Set device time.""" + unixtime = mktime(dt.timetuple()) + return await self.call( + "set_device_time", + {"timestamp": unixtime, "time_diff": dt.utcoffset(), "region": dt.tzname()}, + ) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 69648d5e2..698982b67 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -24,9 +24,6 @@ def __init__( self._parent = parent self._id = child_id self.protocol = _ChildProtocolWrapper(child_id, parent.protocol) - # TODO: remove the assignment after modularization is done, - # currently required to allow accessing time-related properties - self._time = parent._time self._device_type = DeviceType.StripSocket async def update(self, update_children: bool = True): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index d22594347..f5e41dc1b 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -1,7 +1,7 @@ """Module for a SMART device.""" import base64 import logging -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, cast from ..aestransport import AesTransport @@ -12,6 +12,13 @@ from ..exceptions import AuthenticationException, SmartDeviceException, SmartErrorCode from ..feature import Feature, FeatureType from ..smartprotocol import SmartProtocol +from .modules import ( # noqa: F401 + ChildDeviceModule, + DeviceModule, + EnergyModule, + TimeModule, +) +from .smartmodule import SmartModule _LOGGER = logging.getLogger(__name__) @@ -37,9 +44,8 @@ def __init__( self._components_raw: Optional[Dict[str, Any]] = None self._components: Dict[str, int] = {} self._children: Dict[str, "SmartChildDevice"] = {} - self._energy: Dict[str, Any] = {} self._state_information: Dict[str, Any] = {} - self._time: Dict[str, Any] = {} + self.modules: Dict[str, SmartModule] = {} async def _initialize_children(self): """Initialize children for power strips.""" @@ -79,67 +85,43 @@ def _try_get_response(self, responses: dict, request: str, default=None) -> dict f"{request} not found in {responses} for device {self.host}" ) + async def _negotiate(self): + resp = await self.protocol.query("component_nego") + self._components_raw = resp["component_nego"] + self._components = { + comp["id"]: int(comp["ver_code"]) + for comp in self._components_raw["component_list"] + } + async def update(self, update_children: bool = True): """Update the device.""" if self.credentials is None and self.credentials_hash is None: raise AuthenticationException("Tapo plug requires authentication.") if self._components_raw is None: - resp = await self.protocol.query("component_nego") - self._components_raw = resp["component_nego"] - self._components = { - comp["id"]: int(comp["ver_code"]) - for comp in self._components_raw["component_list"] - } + await self._negotiate() await self._initialize_modules() - extra_reqs: Dict[str, Any] = {} - - if "child_device" in self._components: - extra_reqs = {**extra_reqs, "get_child_device_list": None} - - if "energy_monitoring" in self._components: - extra_reqs = { - **extra_reqs, - "get_energy_usage": None, - "get_current_power": None, - } + req: Dict[str, Any] = {} - if self._components.get("device", 0) >= 2: - extra_reqs = { - **extra_reqs, - "get_device_usage": None, - } - - req = { - "get_device_info": None, - "get_device_time": None, - **extra_reqs, - } + # TODO: this could be optimized by constructing the query only once + for module in self.modules.values(): + req.update(module.query()) resp = await self.protocol.query(req) self._info = self._try_get_response(resp, "get_device_info") - self._time = self._try_get_response(resp, "get_device_time", {}) - # Device usage is not available on older firmware versions - self._usage = self._try_get_response(resp, "get_device_usage", {}) - # Emeter is not always available, but we set them still for now. - self._energy = self._try_get_response(resp, "get_energy_usage", {}) - self._emeter = self._try_get_response(resp, "get_current_power", {}) self._last_update = { "components": self._components_raw, - "info": self._info, - "usage": self._usage, - "time": self._time, - "energy": self._energy, - "emeter": self._emeter, + **resp, "child_info": self._try_get_response(resp, "get_child_device_list", {}), } if child_info := self._last_update.get("child_info"): if not self.children: await self._initialize_children() + for info in child_info["child_device_list"]: self._children[info["device_id"]].update_internal_state(info) @@ -152,11 +134,32 @@ async def update(self, update_children: bool = True): async def _initialize_modules(self): """Initialize modules based on component negotiation response.""" - if "energy_monitoring" in self._components: - self.emeter_type = "emeter" + from .smartmodule import SmartModule + + for mod in SmartModule.REGISTERED_MODULES.values(): + _LOGGER.debug("%s requires %s", mod, mod.REQUIRED_COMPONENT) + if mod.REQUIRED_COMPONENT in self._components: + _LOGGER.debug( + "Found required %s, adding %s to modules.", + mod.REQUIRED_COMPONENT, + mod.__name__, + ) + module = mod(self, mod.REQUIRED_COMPONENT) + self.modules[module.name] = module async def _initialize_features(self): """Initialize device features.""" + if "device_on" in self._info: + self._add_feature( + Feature( + self, + "State", + attribute_getter="is_on", + attribute_setter="set_state", + type=FeatureType.Switch, + ) + ) + self._add_feature( Feature( self, @@ -200,6 +203,10 @@ async def _initialize_features(self): ) ) + for module in self.modules.values(): + for feat in module._module_features.values(): + self._add_feature(feat) + @property def sys_info(self) -> Dict[str, Any]: """Returns the device info.""" @@ -221,17 +228,8 @@ def alias(self) -> Optional[str]: @property def time(self) -> datetime: """Return the time.""" - td = timedelta(minutes=cast(float, self._time.get("time_diff"))) - if self._time.get("region"): - tz = timezone(td, str(self._time.get("region"))) - else: - # in case the device returns a blank region this will result in the - # tzname being a UTC offset - tz = timezone(td) - return datetime.fromtimestamp( - cast(float, self._time.get("timestamp")), - tz=tz, - ) + _timemod = cast(TimeModule, self.modules["TimeModule"]) + return _timemod.time @property def timezone(self) -> Dict: @@ -308,20 +306,27 @@ def state_information(self) -> Dict[str, Any]: @property def has_emeter(self) -> bool: """Return if the device has emeter.""" - return "energy_monitoring" in self._components + return "EnergyModule" in self.modules @property def is_on(self) -> bool: """Return true if the device is on.""" return bool(self._info.get("device_on")) + async def set_state(self, on: bool): # TODO: better name wanted. + """Set the device state. + + See :meth:`is_on`. + """ + return await self.protocol.query({"set_device_info": {"device_on": on}}) + async def turn_on(self, **kwargs): """Turn on the device.""" - await self.protocol.query({"set_device_info": {"device_on": True}}) + await self.set_state(True) async def turn_off(self, **kwargs): """Turn off the device.""" - await self.protocol.query({"set_device_info": {"device_on": False}}) + await self.set_state(False) def update_from_discover_info(self, info): """Update state from info from the discover call.""" @@ -330,43 +335,28 @@ def update_from_discover_info(self, info): async def get_emeter_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" - self._verify_emeter() - resp = await self.protocol.query("get_energy_usage") - self._energy = resp["get_energy_usage"] - return self.emeter_realtime - - def _convert_energy_data(self, data, scale) -> Optional[float]: - """Return adjusted emeter information.""" - return data if not data else data * scale - - def _verify_emeter(self) -> None: - """Raise an exception if there is no emeter.""" + _LOGGER.warning("Deprecated, use `emeter_realtime`.") if not self.has_emeter: raise SmartDeviceException("Device has no emeter") - if self.emeter_type not in self._last_update: - raise SmartDeviceException("update() required prior accessing emeter") + return self.emeter_realtime @property def emeter_realtime(self) -> EmeterStatus: """Get the emeter status.""" - return EmeterStatus( - { - "power_mw": self._energy.get("current_power"), - "total": self._convert_energy_data( - self._energy.get("today_energy"), 1 / 1000 - ), - } - ) + energy = cast(EnergyModule, self.modules["EnergyModule"]) + return energy.emeter_realtime @property def emeter_this_month(self) -> Optional[float]: """Get the emeter value for this month.""" - return self._convert_energy_data(self._energy.get("month_energy"), 1 / 1000) + energy = cast(EnergyModule, self.modules["EnergyModule"]) + return energy.emeter_this_month @property def emeter_today(self) -> Optional[float]: """Get the emeter value for today.""" - return self._convert_energy_data(self._energy.get("today_energy"), 1 / 1000) + energy = cast(EnergyModule, self.modules["EnergyModule"]) + return energy.emeter_today @property def on_since(self) -> Optional[datetime]: @@ -377,7 +367,11 @@ def on_since(self) -> Optional[datetime]: ): return None on_time = cast(float, on_time) - return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) + if (timemod := self.modules.get("TimeModule")) is not None: + timemod = cast(TimeModule, timemod) + return timemod.time - timedelta(seconds=on_time) + else: # We have no device time, use current local time. + return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) async def wifi_scan(self) -> List[WifiNetwork]: """Scan for available wifi networks.""" @@ -439,7 +433,7 @@ async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): "password": base64.b64encode(password.encode()).decode(), "ssid": base64.b64encode(ssid.encode()).decode(), }, - "time": self.internal_state["time"], + "time": self.internal_state["get_device_time"], } # The device does not respond to the request but changes the settings @@ -458,13 +452,13 @@ async def update_credentials(self, username: str, password: str): This will replace the existing authentication credentials on the device. """ - t = self.internal_state["time"] + time_data = self.internal_state["get_device_time"] payload = { "account": { "username": base64.b64encode(username.encode()).decode(), "password": base64.b64encode(password.encode()).decode(), }, - "time": t, + "time": time_data, } return await self.protocol.query({"set_qs_info": payload}) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py new file mode 100644 index 000000000..6f42f297a --- /dev/null +++ b/kasa/smart/smartmodule.py @@ -0,0 +1,73 @@ +"""Base implementation for SMART modules.""" +import logging +from typing import TYPE_CHECKING, Dict, Type + +from ..exceptions import SmartDeviceException +from ..module import Module + +if TYPE_CHECKING: + from .smartdevice import SmartDevice + +_LOGGER = logging.getLogger(__name__) + + +class SmartModule(Module): + """Base class for SMART modules.""" + + NAME: str + REQUIRED_COMPONENT: str + QUERY_GETTER_NAME: str + REGISTERED_MODULES: Dict[str, Type["SmartModule"]] = {} + + def __init__(self, device: "SmartDevice", module: str): + self._device: SmartDevice + super().__init__(device, module) + + def __init_subclass__(cls, **kwargs): + assert cls.REQUIRED_COMPONENT is not None # noqa: S101 + + name = getattr(cls, "NAME", cls.__name__) + _LOGGER.debug("Registering %s" % cls) + cls.REGISTERED_MODULES[name] = cls + + @property + def name(self) -> str: + """Name of the module.""" + return getattr(self, "NAME", self.__class__.__name__) + + def query(self) -> Dict: + """Query to execute during the update cycle. + + Default implementation uses the raw query getter w/o parameters. + """ + return {self.QUERY_GETTER_NAME: None} + + def call(self, method, params=None): + """Call a method. + + Just a helper method. + """ + return self._device._query_helper(method, params) + + @property + def data(self): + """Return response data for the module. + + If module performs only a single query, the resulting response is unwrapped. + """ + q = self.query() + q_keys = list(q.keys()) + # TODO: hacky way to check if update has been called. + if q_keys[0] not in self._device._last_update: + raise SmartDeviceException( + f"You need to call update() prior accessing module data" + f" for '{self._module}'" + ) + + filtered_data = { + k: v for k, v in self._device._last_update.items() if k in q_keys + } + if len(filtered_data) == 1: + return next(iter(filtered_data.values())) + + return filtered_data diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 3247c9173..78863def3 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -60,6 +60,11 @@ def _test_property_getters(): ) for prop in properties: name, _ = prop + # Skip emeter and time properties + # TODO: needs API cleanup, emeter* should probably be removed in favor + # of access through features/modules, handling of time* needs decision. + if name.startswith("emeter_") or name.startswith("time"): + continue try: _ = getattr(first, name) except Exception as ex: diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 67f8fa84f..487286dbb 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -310,16 +310,13 @@ async def test_modules_not_supported(dev: IotDevice): @device_smart -async def test_update_sub_errors(dev: SmartDevice, caplog): +async def test_try_get_response(dev: SmartDevice, caplog): mock_response: dict = { - "get_device_info": {}, - "get_device_usage": SmartErrorCode.PARAMS_ERROR, - "get_device_time": {}, + "get_device_info": SmartErrorCode.PARAMS_ERROR, } caplog.set_level(logging.DEBUG) - with patch.object(dev.protocol, "query", return_value=mock_response): - await dev.update() - msg = "Error PARAMS_ERROR(-1008) getting request get_device_usage for device 127.0.0.123" + dev._try_get_response(mock_response, "get_device_info", {}) + msg = "Error PARAMS_ERROR(-1008) getting request get_device_info for device 127.0.0.123" assert msg in caplog.text From 520b6bbae36fb2c3269db0395e4952cb62be8687 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 19 Feb 2024 20:39:20 +0100 Subject: [PATCH 326/892] Add smartdevice module for smooth transitions (#759) * Add smart module for smooth transitions * Fix tests * Fix linting --- kasa/smart/modules/__init__.py | 9 ++++- kasa/smart/modules/lighttransitionmodule.py | 41 +++++++++++++++++++++ kasa/smart/smartdevice.py | 1 + kasa/tests/fakeprotocol_smart.py | 1 + 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 kasa/smart/modules/lighttransitionmodule.py diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 564363222..69a3c5727 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -2,6 +2,13 @@ from .childdevicemodule import ChildDeviceModule from .devicemodule import DeviceModule from .energymodule import EnergyModule +from .lighttransitionmodule import LightTransitionModule from .timemodule import TimeModule -__all__ = ["TimeModule", "EnergyModule", "DeviceModule", "ChildDeviceModule"] +__all__ = [ + "TimeModule", + "EnergyModule", + "DeviceModule", + "ChildDeviceModule", + "LightTransitionModule", +] diff --git a/kasa/smart/modules/lighttransitionmodule.py b/kasa/smart/modules/lighttransitionmodule.py new file mode 100644 index 000000000..ef8739bcf --- /dev/null +++ b/kasa/smart/modules/lighttransitionmodule.py @@ -0,0 +1,41 @@ +"""Module for smooth light transitions.""" +from typing import TYPE_CHECKING + +from ...feature import Feature, FeatureType +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class LightTransitionModule(SmartModule): + """Implementation of gradual on/off.""" + + REQUIRED_COMPONENT = "on_off_gradually" + QUERY_GETTER_NAME = "get_on_off_gradually_info" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device=device, + container=self, + name="Smooth transitions", + icon="mdi:transition", + attribute_getter="enabled", + attribute_setter="set_enabled", + type=FeatureType.Switch, + ) + ) + + def set_enabled(self, enable: bool): + """Enable gradual on/off.""" + return self.call("set_on_off_gradually_info", {"enable": enable}) + + @property + def enabled(self) -> bool: + """Return True if gradual on/off is enabled.""" + return bool(self.data["enable"]) + + def __cli_output__(self): + return f"Gradual on/off enabled: {self.enabled}" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index f5e41dc1b..7542078ac 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -16,6 +16,7 @@ ChildDeviceModule, DeviceModule, EnergyModule, + LightTransitionModule, TimeModule, ) from .smartmodule import SmartModule diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index bbadec0af..2945d1677 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -46,6 +46,7 @@ def credentials_hash(self): FIXTURE_MISSING_MAP = { "get_wireless_scan_info": ("wireless", {"ap_list": [], "wep_supported": False}), + "get_on_off_gradually_info": ("on_off_gradually", {"enable": True}), } async def send(self, request: str): From f5175c5632a3354ebe4a12e4a8dba4e5a9c8bb13 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 19 Feb 2024 20:48:46 +0100 Subject: [PATCH 327/892] Add cloud module for smartdevice (#767) Add initial support for the cloud module. Adds a new binary sensor: `Cloud connection (cloud_connection): False` --- kasa/smart/modules/__init__.py | 2 ++ kasa/smart/modules/cloudmodule.py | 34 +++++++++++++++++++++++++++++++ kasa/tests/fakeprotocol_smart.py | 1 + 3 files changed, 37 insertions(+) create mode 100644 kasa/smart/modules/cloudmodule.py diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 69a3c5727..123435be6 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -1,5 +1,6 @@ """Modules for SMART devices.""" from .childdevicemodule import ChildDeviceModule +from .cloudmodule import CloudModule from .devicemodule import DeviceModule from .energymodule import EnergyModule from .lighttransitionmodule import LightTransitionModule @@ -10,5 +11,6 @@ "EnergyModule", "DeviceModule", "ChildDeviceModule", + "CloudModule", "LightTransitionModule", ] diff --git a/kasa/smart/modules/cloudmodule.py b/kasa/smart/modules/cloudmodule.py new file mode 100644 index 000000000..bf4964c32 --- /dev/null +++ b/kasa/smart/modules/cloudmodule.py @@ -0,0 +1,34 @@ +"""Implementation of cloud module.""" +from typing import TYPE_CHECKING + +from ...feature import Feature, FeatureType +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class CloudModule(SmartModule): + """Implementation of cloud module.""" + + QUERY_GETTER_NAME = "get_connect_cloud_state" + REQUIRED_COMPONENT = "cloud_connect" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + + self._add_feature( + Feature( + device, + "Cloud connection", + container=self, + attribute_getter="is_connected", + icon="mdi:cloud", + type=FeatureType.BinarySensor, + ) + ) + + @property + def is_connected(self): + """Return True if device is connected to the cloud.""" + return self.data["status"] == 0 diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 2945d1677..210c63b90 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -46,6 +46,7 @@ def credentials_hash(self): FIXTURE_MISSING_MAP = { "get_wireless_scan_info": ("wireless", {"ap_list": [], "wep_supported": False}), + "get_connect_cloud_state": ("cloud_connect", {"status": 1}), "get_on_off_gradually_info": ("on_off_gradually", {"enable": True}), } From 44b59efbb263210271409b5c3a7d3c7111f3ee8f Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 19 Feb 2024 20:59:09 +0100 Subject: [PATCH 328/892] Add smartdevice module for led controls (#761) Allows controlling LED on devices that support it. --- kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/ledmodule.py | 65 ++++++++++++++++++++++++++++++++ kasa/smart/smartdevice.py | 2 + kasa/tests/fakeprotocol_smart.py | 14 +++++++ 4 files changed, 83 insertions(+) create mode 100644 kasa/smart/modules/ledmodule.py diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 123435be6..5274e7b37 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -3,6 +3,7 @@ from .cloudmodule import CloudModule from .devicemodule import DeviceModule from .energymodule import EnergyModule +from .ledmodule import LedModule from .lighttransitionmodule import LightTransitionModule from .timemodule import TimeModule @@ -11,6 +12,7 @@ "EnergyModule", "DeviceModule", "ChildDeviceModule", + "LedModule", "CloudModule", "LightTransitionModule", ] diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py new file mode 100644 index 000000000..72e3e33a2 --- /dev/null +++ b/kasa/smart/modules/ledmodule.py @@ -0,0 +1,65 @@ +"""Module for led controls.""" +from typing import TYPE_CHECKING, Dict + +from ...feature import Feature, FeatureType +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class LedModule(SmartModule): + """Implementation of led controls.""" + + REQUIRED_COMPONENT = "led" + QUERY_GETTER_NAME = "get_led_info" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device=device, + container=self, + name="LED", + icon="mdi:led-{state}", + attribute_getter="led", + attribute_setter="set_led", + type=FeatureType.Switch, + ) + ) + + def query(self) -> Dict: + """Query to execute during the update cycle.""" + return {self.QUERY_GETTER_NAME: {"led_rule": None}} + + @property + def mode(self): + """LED mode setting. + + "always", "never", "night_mode" + """ + return self.data["led_rule"] + + @property + def led(self): + """Return current led status.""" + return self.data["led_status"] + + async def set_led(self, enable: bool): + """Set led. + + This should probably be a select with always/never/nightmode. + """ + rule = "always" if enable else "never" + return await self.call("set_led_info", self.data | {"led_rule": rule}) + + @property + def night_mode_settings(self): + """Night mode settings.""" + return { + "start": self.data["start_time"], + "end": self.data["end_time"], + "type": self.data["night_mode_type"], + "sunrise_offset": self.data["sunrise_offset"], + "sunset_offset": self.data["sunset_offset"], + } diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 7542078ac..d9e859d44 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -14,8 +14,10 @@ from ..smartprotocol import SmartProtocol from .modules import ( # noqa: F401 ChildDeviceModule, + CloudModule, DeviceModule, EnergyModule, + LedModule, LightTransitionModule, TimeModule, ) diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 210c63b90..0b04282e1 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -46,6 +46,20 @@ def credentials_hash(self): FIXTURE_MISSING_MAP = { "get_wireless_scan_info": ("wireless", {"ap_list": [], "wep_supported": False}), + "get_led_info": ( + "led", + { + "led_rule": "never", + "led_status": False, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 0, + }, + }, + ), "get_connect_cloud_state": ("cloud_connect", {"status": 1}), "get_on_off_gradually_info": ("on_off_gradually", {"enable": True}), } From efb4a0f31f9b5c0ff38f5c1d07d6c8051cf7da8a Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 19 Feb 2024 21:11:11 +0100 Subject: [PATCH 329/892] Auto auto-off module for smartdevice (#760) Adds auto-off implementation. The feature stays enabled after the timer runs out, and it will start the countdown if the device is turned on again without explicitly disabling it. New features: * Switch to select if enabled: `Auto off enabled (auto_off_enabled): False` * Setting to change the delay: `Auto off minutes (auto_off_minutes): 222` * If timer is active, datetime object when the device gets turned off: `Auto off at (auto_off_at): None` --- kasa/feature.py | 3 +- kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/autooffmodule.py | 84 +++++++++++++++++++++++++++++ kasa/smart/smartdevice.py | 1 + kasa/tests/fakeprotocol_smart.py | 1 + 5 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 kasa/smart/modules/autooffmodule.py diff --git a/kasa/feature.py b/kasa/feature.py index c0c14b06c..420fd8485 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -47,4 +47,5 @@ async def set_value(self, value): """Set the value.""" if self.attribute_setter is None: raise ValueError("Tried to set read-only feature.") - return await getattr(self.device, self.attribute_setter)(value) + container = self.container if self.container is not None else self.device + return await getattr(container, self.attribute_setter)(value) diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 5274e7b37..6031ef2ac 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -1,4 +1,5 @@ """Modules for SMART devices.""" +from .autooffmodule import AutoOffModule from .childdevicemodule import ChildDeviceModule from .cloudmodule import CloudModule from .devicemodule import DeviceModule @@ -12,6 +13,7 @@ "EnergyModule", "DeviceModule", "ChildDeviceModule", + "AutoOffModule", "LedModule", "CloudModule", "LightTransitionModule", diff --git a/kasa/smart/modules/autooffmodule.py b/kasa/smart/modules/autooffmodule.py new file mode 100644 index 000000000..b1993deba --- /dev/null +++ b/kasa/smart/modules/autooffmodule.py @@ -0,0 +1,84 @@ +"""Implementation of auto off module.""" +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Dict, Optional + +from ...feature import Feature +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class AutoOffModule(SmartModule): + """Implementation of auto off module.""" + + REQUIRED_COMPONENT = "auto_off" + QUERY_GETTER_NAME = "get_auto_off_config" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Auto off enabled", + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + ) + ) + self._add_feature( + Feature( + device, + "Auto off minutes", + container=self, + attribute_getter="delay", + attribute_setter="set_delay", + ) + ) + self._add_feature( + Feature( + device, "Auto off at", container=self, attribute_getter="auto_off_at" + ) + ) + + def query(self) -> Dict: + """Query to execute during the update cycle.""" + return {self.QUERY_GETTER_NAME: {"start_index": 0}} + + @property + def enabled(self) -> bool: + """Return True if enabled.""" + return self.data["enable"] + + def set_enabled(self, enable: bool): + """Enable/disable auto off.""" + return self.call( + "set_auto_off_config", + {"enable": enable, "delay_min": self.data["delay_min"]}, + ) + + @property + def delay(self) -> int: + """Return time until auto off.""" + return self.data["delay_min"] + + def set_delay(self, delay: int): + """Set time until auto off.""" + return self.call( + "set_auto_off_config", {"delay_min": delay, "enable": self.data["enable"]} + ) + + @property + def is_timer_active(self) -> bool: + """Return True is auto-off timer is active.""" + return self._device.sys_info["auto_off_status"] == "on" + + @property + def auto_off_at(self) -> Optional[datetime]: + """Return when the device will be turned off automatically.""" + if not self.is_timer_active: + return None + + sysinfo = self._device.sys_info + + return self._device.time + timedelta(seconds=sysinfo["auto_off_remain_time"]) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index d9e859d44..62657d816 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -13,6 +13,7 @@ from ..feature import Feature, FeatureType from ..smartprotocol import SmartProtocol from .modules import ( # noqa: F401 + AutoOffModule, ChildDeviceModule, CloudModule, DeviceModule, diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 0b04282e1..4c9b034bf 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -46,6 +46,7 @@ def credentials_hash(self): FIXTURE_MISSING_MAP = { "get_wireless_scan_info": ("wireless", {"ap_list": [], "wep_supported": False}), + "get_auto_off_config": ("auto_off", {'delay_min': 10, 'enable': False}), "get_led_info": ( "led", { From 3de04f320a3e6a8ad2434932fab6fcf2cd266fe5 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 19 Feb 2024 21:29:09 +0100 Subject: [PATCH 330/892] Add firmware module for smartdevice (#766) Initial firmware module implementation. New switch: `Auto update enabled (auto_update_enabled): False` New binary sensor: `Update available (update_available): False` --- kasa/smart/modules/__init__.py | 1 + kasa/smart/modules/firmware.py | 104 +++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 kasa/smart/modules/firmware.py diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 6031ef2ac..9ce94da73 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -15,6 +15,7 @@ "ChildDeviceModule", "AutoOffModule", "LedModule", + "Firmware", "CloudModule", "LightTransitionModule", ] diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py new file mode 100644 index 000000000..541b0b7ab --- /dev/null +++ b/kasa/smart/modules/firmware.py @@ -0,0 +1,104 @@ +"""Implementation of firmware module.""" +from typing import TYPE_CHECKING, Dict, Optional + +from ...exceptions import SmartErrorCode +from ...feature import Feature, FeatureType +from ..smartmodule import SmartModule + +try: + from pydantic.v1 import BaseModel, Field, validator +except ImportError: + from pydantic import BaseModel, Field, validator +from datetime import date + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class UpdateInfo(BaseModel): + """Update info status object.""" + + status: int = Field(alias="type") + fw_ver: Optional[str] = None + release_date: Optional[date] = None + release_notes: Optional[str] = Field(alias="release_note", default=None) + fw_size: Optional[int] = None + oem_id: Optional[str] = None + needs_upgrade: bool = Field(alias="need_to_upgrade") + + @validator("release_date", pre=True) + def _release_date_optional(cls, v): + if not v: + return None + + return v + + @property + def update_available(self): + """Return True if update available.""" + if self.status != 0: + return True + return False + + +class Firmware(SmartModule): + """Implementation of firmware module.""" + + REQUIRED_COMPONENT = "firmware" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Auto update enabled", + container=self, + attribute_getter="auto_update_enabled", + type=FeatureType.Switch, + ) + ) + self._add_feature( + Feature( + device, + "Update available", + container=self, + attribute_getter="update_available", + type=FeatureType.BinarySensor, + ) + ) + + def query(self) -> Dict: + """Query to execute during the update cycle.""" + return {"get_auto_update_info": None, "get_latest_fw": None} + + @property + def latest_firmware(self): + """Return latest firmware information.""" + fw = self.data["get_latest_fw"] + if isinstance(fw, SmartErrorCode): + # Error in response, probably disconnected from the cloud. + return UpdateInfo(type=0, need_to_upgrade=False) + + return UpdateInfo.parse_obj(fw) + + @property + def update_available(self): + """Return True if update is available.""" + return self.latest_firmware.update_available + + async def get_update_state(self): + """Return update state.""" + return await self.call("get_fw_download_state") + + async def update(self): + """Update the device firmware.""" + return await self.call("fw_download") + + @property + def auto_update_enabled(self): + """Return True if autoupdate is enabled.""" + return self.data["get_auto_update_info"]["enable"] + + async def set_auto_update_enabled(self, enabled: bool): + """Change autoupdate setting.""" + await self.call("set_auto_update_info", {"enable": enabled}) From 29e6b92b1eadaa66bddca78d4031390c231cdb84 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 20 Feb 2024 01:00:26 +0100 Subject: [PATCH 331/892] Add missing firmware module import (#774) #766 was merged too hastily to fail the tests as the module was never imported. --- kasa/smart/modules/__init__.py | 1 + kasa/tests/fakeprotocol_smart.py | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 9ce94da73..02c3b86af 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -4,6 +4,7 @@ from .cloudmodule import CloudModule from .devicemodule import DeviceModule from .energymodule import EnergyModule +from .firmware import Firmware from .ledmodule import LedModule from .lighttransitionmodule import LightTransitionModule from .timemodule import TimeModule diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 4c9b034bf..54fc86479 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -46,7 +46,7 @@ def credentials_hash(self): FIXTURE_MISSING_MAP = { "get_wireless_scan_info": ("wireless", {"ap_list": [], "wep_supported": False}), - "get_auto_off_config": ("auto_off", {'delay_min': 10, 'enable': False}), + "get_auto_off_config": ("auto_off", {"delay_min": 10, "enable": False}), "get_led_info": ( "led", { @@ -63,6 +63,23 @@ def credentials_hash(self): ), "get_connect_cloud_state": ("cloud_connect", {"status": 1}), "get_on_off_gradually_info": ("on_off_gradually", {"enable": True}), + "get_latest_fw": ( + "firmware", + { + "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_auto_update_info": ( + "firmware", + {"enable": True, "random_range": 120, "time": 180}, + ), } async def send(self, request: str): From 5ba3676422affd1397a09da689739f21818cbc51 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 20 Feb 2024 11:21:04 +0000 Subject: [PATCH 332/892] Raise CLI errors in debug mode (#771) --- kasa/cli.py | 44 +++++++++++++++++++++++++++++--------- kasa/tests/test_cli.py | 48 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 4d3590d10..b075866b0 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -90,19 +90,43 @@ def wrapper(message=None, *args, **kwargs): pass_dev = click.make_pass_decorator(Device) -class ExceptionHandlerGroup(click.Group): - """Group to capture all exceptions and print them nicely. +def CatchAllExceptions(cls): + """Capture all exceptions and prints them nicely. - Idea from https://stackoverflow.com/a/44347763 + Idea from https://stackoverflow.com/a/44347763 and + https://stackoverflow.com/questions/52213375 """ - def __call__(self, *args, **kwargs): - """Run the coroutine in the event loop and print any exceptions.""" - try: - asyncio.get_event_loop().run_until_complete(self.main(*args, **kwargs)) - except Exception as ex: - echo(f"Got error: {ex!r}") + def _handle_exception(debug, exc): + if isinstance(exc, click.ClickException): + raise + echo(f"Raised error: {exc}") + if debug: raise + echo("Run with --debug enabled to see stacktrace") + sys.exit(1) + + class _CommandCls(cls): + _debug = False + + async def make_context(self, info_name, args, parent=None, **extra): + self._debug = any( + [arg for arg in args if arg in ["--debug", "-d", "--verbose", "-v"]] + ) + try: + return await super().make_context( + info_name, args, parent=parent, **extra + ) + except Exception as exc: + _handle_exception(self._debug, exc) + + async def invoke(self, ctx): + try: + return await super().invoke(ctx) + except Exception as exc: + _handle_exception(self._debug, exc) + + return _CommandCls def json_formatter_cb(result, **kwargs): @@ -129,7 +153,7 @@ def _device_to_serializable(val: Device): @click.group( invoke_without_command=True, - cls=ExceptionHandlerGroup, + cls=CatchAllExceptions(click.Group), result_callback=json_formatter_cb, ) @click.option( diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 51155f407..2e776c1dc 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -504,6 +504,7 @@ async def test_host_unsupported(unsupported_device_info): "foo", "--password", "bar", + "--debug", ], ) @@ -563,6 +564,7 @@ async def test_host_auth_failed(discovery_mock, mocker): "foo", "--password", "bar", + "--debug", ], ) @@ -610,3 +612,49 @@ async def test_shell(dev: Device, mocker): res = await runner.invoke(cli, ["shell"], obj=dev) assert res.exit_code == 0 embed.assert_called() + + +async def test_errors(mocker): + runner = CliRunner() + err = SmartDeviceException("Foobar") + + # Test masking + mocker.patch("kasa.Discover.discover", side_effect=err) + res = await runner.invoke( + cli, + ["--username", "foo", "--password", "bar"], + ) + assert res.exit_code == 1 + assert "Raised error: Foobar" in res.output + assert "Run with --debug enabled to see stacktrace" in res.output + assert isinstance(res.exception, SystemExit) + + # Test --debug + res = await runner.invoke( + cli, + ["--debug"], + ) + assert res.exit_code == 1 + assert "Raised error: Foobar" in res.output + assert res.exception == err + + # Test no device passed to subcommand + mocker.patch("kasa.Discover.discover", return_value={}) + res = await runner.invoke( + cli, + ["sysinfo"], + ) + assert res.exit_code == 1 + assert ( + "Raised error: Managed to invoke callback without a context object of type 'Device' existing." + in res.output + ) + assert isinstance(res.exception, SystemExit) + + # Test click error + res = await runner.invoke( + cli, + ["--foobar"], + ) + assert res.exit_code == 2 + assert "Raised error:" not in res.output From 4beff228c9fe2f212159a103d3d66651b4bb5a15 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 20 Feb 2024 18:40:28 +0000 Subject: [PATCH 333/892] Enable shell extra for installing ptpython and rich (#782) Co-authored-by: Teemu R. --- .github/workflows/ci.yml | 6 +- README.md | 10 +++- kasa/tests/test_cli.py | 2 +- poetry.lock | 115 ++++++++++++++++++++++++++++++++++++++- pyproject.toml | 7 ++- 5 files changed, 130 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 761ed8baa..07fa734b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,16 +88,16 @@ jobs: - uses: "actions/setup-python@v4" with: python-version: "${{ matrix.python-version }}" - - name: "Install dependencies (no speedups)" + - name: "Install dependencies (no extras)" if: matrix.extras == false run: | python -m pip install --upgrade pip poetry poetry install - - name: "Install dependencies (with speedups)" + - name: "Install dependencies (with extras)" if: matrix.extras == true run: | python -m pip install --upgrade pip poetry - poetry install --extras speedups + poetry install --all-extras - name: "Run tests" run: | poetry run pytest --cov kasa --cov-report xml diff --git a/README.md b/README.md index d5db1cfcc..db1bad2d1 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,19 @@ You can install the most recent release using pip: pip install python-kasa ``` +For enhanced cli tool support (coloring, embedded shell) install with `[shell]`: +``` +pip install python-kasa[shell] +``` + If you are using cpython, it is recommended to install with `[speedups]` to enable orjson (faster json support): ``` pip install python-kasa[speedups] ``` - +or for both: +``` +pip install python-kasa[speedups, shell] +``` With `[speedups]`, the protocol overhead is roughly an order of magnitude lower (benchmarks available in devtools). Alternatively, you can clone this repository and use poetry to install the development version: diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 2e776c1dc..636ccd367 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -647,7 +647,7 @@ async def test_errors(mocker): assert res.exit_code == 1 assert ( "Raised error: Managed to invoke callback without a context object of type 'Device' existing." - in res.output + in res.output.replace("\n", "") # Remove newlines from rich formatting ) assert isinstance(res.exception, SystemExit) diff --git a/poetry.lock b/poetry.lock index 82b12f00b..6195a6c52 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "aiohttp" @@ -156,6 +156,17 @@ doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd- test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] trio = ["trio (<0.22)"] +[[package]] +name = "appdirs" +version = "1.4.4" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = true +python-versions = "*" +files = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] + [[package]] name = "async-timeout" version = "4.0.3" @@ -759,6 +770,25 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "jedi" +version = "0.19.1" +description = "An autocompletion tool for Python that can be used for text editors." +optional = true +python-versions = ">=3.6" +files = [ + {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, + {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, +] + +[package.dependencies] +parso = ">=0.8.3,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] + [[package]] name = "jinja2" version = "3.1.2" @@ -1127,6 +1157,21 @@ files = [ {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, ] +[[package]] +name = "parso" +version = "0.8.3" +description = "A Python Parser" +optional = true +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, + {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, +] + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["docopt", "pytest (<6.0.0)"] + [[package]] name = "platformdirs" version = "3.10.0" @@ -1175,6 +1220,41 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "prompt-toolkit" +version = "3.0.43" +description = "Library for building powerful interactive command lines in Python" +optional = true +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.43-py3-none-any.whl", hash = "sha256:a11a29cb3bf0a28a387fe5122cdb649816a957cd9261dcedf8c9f1fef33eacf6"}, + {file = "prompt_toolkit-3.0.43.tar.gz", hash = "sha256:3527b7af26106cbc65a040bcc84839a3566ec1b051bb0bfe953631e704b0ff7d"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "ptpython" +version = "3.0.26" +description = "Python REPL build on top of prompt_toolkit" +optional = true +python-versions = ">=3.7" +files = [ + {file = "ptpython-3.0.26-py2.py3-none-any.whl", hash = "sha256:3dc4c066d049e16d8b181e995a568d36697d04d9acc2724732f3ff6686c5da57"}, + {file = "ptpython-3.0.26.tar.gz", hash = "sha256:c8fb1406502dc349d99c57eaf06e7116f3b2deac94f02f342bae68708909f743"}, +] + +[package.dependencies] +appdirs = "*" +jedi = ">=0.16.0" +prompt-toolkit = ">=3.0.34,<3.1.0" +pygments = "*" + +[package.extras] +all = ["black"] +ptipython = ["ipython"] + [[package]] name = "pycparser" version = "2.21" @@ -1541,6 +1621,25 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rich" +version = "13.7.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = true +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.7.0-py3-none-any.whl", hash = "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235"}, + {file = "rich-13.7.0.tar.gz", hash = "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "setuptools" version = "68.1.2" @@ -1867,6 +1966,17 @@ files = [ {file = "voluptuous-0.13.1.tar.gz", hash = "sha256:e8d31c20601d6773cb14d4c0f42aee29c6821bbd1018039aac7ac5605b489723"}, ] +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = true +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + [[package]] name = "xdoctest" version = "1.1.1" @@ -2014,9 +2124,10 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [extras] docs = ["docutils", "myst-parser", "sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput"] +shell = ["ptpython", "rich"] speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "1186d5079b76081e6681e52062828ad697e7d5aba986d4c189158b77c1f702a5" +content-hash = "aadbdc97219e5282f614f834c1318bbf8430fe769030f0a262e1922c5d7523b8" diff --git a/pyproject.toml b/pyproject.toml index f4c640d57..a35f4b90c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,8 +40,9 @@ sphinxcontrib-programoutput = { version = "^0", optional = true } myst-parser = { version = "*", optional = true } docutils = { version = ">=0.17", optional = true } -# shell support -# ptpython = { version = "*", optional = true } +# enhanced cli support +ptpython = { version = "*", optional = true } +rich = { version = "*", optional = true } [tool.poetry.group.dev.dependencies] pytest = "*" @@ -60,7 +61,7 @@ coverage = {version = "*", extras = ["toml"]} [tool.poetry.extras] docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] speedups = ["orjson", "kasa-crypt"] -# shell = ["ptpython"] +shell = ["ptpython", "rich"] [tool.coverage.run] source = ["kasa"] From 8c39e81a40b3d1fe65bf8f974ea86417f21ef8cd Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 21 Feb 2024 15:52:55 +0000 Subject: [PATCH 334/892] Rename and deprecate exception classes (#739) # Public # SmartDeviceException -> KasaException UnsupportedDeviceException(SmartDeviceException) -> UnsupportedDeviceError(KasaException) TimeoutException(SmartDeviceException, asyncio.TimeoutError) -> TimeoutError(KasaException, asyncio.TimeoutError) Add new exception for error codes -> DeviceError(KasaException) AuthenticationException(SmartDeviceException) -> AuthenticationError(DeviceError) # Internal # RetryableException(SmartDeviceException) -> _RetryableError(DeviceError) ConnectionException(SmartDeviceException) -> _ConnectionError(KasaException) --- devtools/dump_devinfo.py | 17 ++++----- docs/source/design.rst | 34 ++++++++++++++++++ docs/source/smartdevice.rst | 14 +------- kasa/__init__.py | 36 ++++++++++++++----- kasa/aestransport.py | 31 ++++++++-------- kasa/cli.py | 12 +++---- kasa/device.py | 8 ++--- kasa/device_factory.py | 14 ++++---- kasa/deviceconfig.py | 12 +++---- kasa/discover.py | 36 +++++++++---------- kasa/exceptions.py | 59 ++++++++++++++++++------------- kasa/httpclient.py | 12 +++---- kasa/iot/iotbulb.py | 24 ++++++------- kasa/iot/iotdevice.py | 35 +++++++++--------- kasa/iot/iotdimmer.py | 8 ++--- kasa/iot/iotlightstrip.py | 6 ++-- kasa/iot/iotmodule.py | 4 +-- kasa/iot/iotplug.py | 2 +- kasa/iot/iotstrip.py | 6 ++-- kasa/iot/modules/motion.py | 6 ++-- kasa/iot/modules/time.py | 4 +-- kasa/iotprotocol.py | 24 ++++++------- kasa/klaptransport.py | 16 ++++----- kasa/module.py | 4 +-- kasa/smart/smartbulb.py | 16 ++++----- kasa/smart/smartdevice.py | 17 +++++---- kasa/smart/smartmodule.py | 4 +-- kasa/smartprotocol.py | 32 ++++++++--------- kasa/tests/fakeprotocol_smart.py | 4 +-- kasa/tests/test_aestransport.py | 18 +++++----- kasa/tests/test_bulb.py | 20 +++++------ kasa/tests/test_cli.py | 25 +++++++------ kasa/tests/test_device_factory.py | 8 ++--- kasa/tests/test_deviceconfig.py | 4 +-- kasa/tests/test_discovery.py | 24 ++++++------- kasa/tests/test_emeter.py | 10 +++--- kasa/tests/test_httpclient.py | 18 +++++----- kasa/tests/test_klapprotocol.py | 46 ++++++++++++------------ kasa/tests/test_lightstrip.py | 4 +-- kasa/tests/test_protocol.py | 18 +++++----- kasa/tests/test_smartdevice.py | 20 ++++++++--- kasa/tests/test_smartprotocol.py | 16 +++------ kasa/tests/test_strip.py | 8 ++--- kasa/xortransport.py | 12 +++---- 44 files changed, 390 insertions(+), 358 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 09f102bde..5ab736e9c 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -22,12 +22,12 @@ from devtools.helpers.smartrequests import SmartRequest, get_component_requests from kasa import ( - AuthenticationException, + AuthenticationError, Credentials, Device, Discover, - SmartDeviceException, - TimeoutException, + KasaException, + TimeoutError, ) from kasa.discover import DiscoveryResult from kasa.exceptions import SmartErrorCode @@ -303,19 +303,16 @@ async def _make_requests_or_exit( for method, result in responses.items(): final[method] = result return final - except AuthenticationException as ex: + except AuthenticationError as ex: _echo_error( f"Unable to query the device due to an authentication error: {ex}", ) exit(1) - except SmartDeviceException as ex: + except KasaException as ex: _echo_error( f"Unable to query {name} at once: {ex}", ) - if ( - isinstance(ex, TimeoutException) - or ex.error_code == SmartErrorCode.SESSION_TIMEOUT_ERROR - ): + if isinstance(ex, TimeoutError): _echo_error( "Timeout, try reducing the batch size via --batch-size option.", ) @@ -400,7 +397,7 @@ async def get_smart_fixture(device: SmartDevice, batch_size: int): response = await device.protocol.query( SmartRequest._create_request_dict(test_call.request) ) - except AuthenticationException as ex: + except AuthenticationError as ex: _echo_error( f"Unable to query the device due to an authentication error: {ex}", ) diff --git a/docs/source/design.rst b/docs/source/design.rst index 4741f5e62..3b6ae3456 100644 --- a/docs/source/design.rst +++ b/docs/source/design.rst @@ -100,6 +100,17 @@ The classes providing this functionality are: - :class:`KlapTransport ` - :class:`KlapTransportV2 ` +Errors and Exceptions +********************* + +The base exception for all library errors is :class:`KasaException `. + +- If the device returns an error the library raises a :class:`DeviceError ` which will usually contain an ``error_code`` with the detail. +- If the device fails to authenticate the library raises an :class:`AuthenticationError ` which is derived + from :class:`DeviceError ` and could contain an ``error_code`` depending on the type of failure. +- If the library encounters and unsupported deviceit raises an :class:`UnsupportedDeviceError `. +- If the device fails to respond within a timeout the library raises a :class:`TimeoutError `. +- All other failures will raise the base :class:`KasaException ` class. API documentation for modules ***************************** @@ -154,3 +165,26 @@ API documentation for protocols and transports :members: :inherited-members: :undoc-members: + +API documentation for errors and exceptions +******************************************* + +.. autoclass:: kasa.exceptions.KasaException + :members: + :undoc-members: + +.. autoclass:: kasa.exceptions.DeviceError + :members: + :undoc-members: + +.. autoclass:: kasa.exceptions.AuthenticationError + :members: + :undoc-members: + +.. autoclass:: kasa.exceptions.UnsupportedDeviceError + :members: + :undoc-members: + +.. autoclass:: kasa.exceptions.TimeoutError + :members: + :undoc-members: diff --git a/docs/source/smartdevice.rst b/docs/source/smartdevice.rst index 2a29a8d90..5df227781 100644 --- a/docs/source/smartdevice.rst +++ b/docs/source/smartdevice.rst @@ -24,7 +24,7 @@ Methods changing the state of the device do not invalidate the cache (i.e., ther You can assume that the operation has succeeded if no exception is raised. These methods will return the device response, which can be useful for some use cases. -Errors are raised as :class:`SmartDeviceException` instances for the library user to handle. +Errors are raised as :class:`KasaException` instances for the library user to handle. Simple example script showing some functionality for legacy devices: @@ -154,15 +154,3 @@ API documentation .. autoclass:: Credentials :members: :undoc-members: - -.. autoclass:: SmartDeviceException - :members: - :undoc-members: - -.. autoclass:: AuthenticationException - :members: - :undoc-members: - -.. autoclass:: UnsupportedDeviceException - :members: - :undoc-members: diff --git a/kasa/__init__.py b/kasa/__init__.py index 7dac1170d..06bb35149 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -8,7 +8,7 @@ For device type specific actions `SmartBulb`, `SmartPlug`, or `SmartStrip` should be used instead. -Module-specific errors are raised as `SmartDeviceException` and are expected +Module-specific errors are raised as `KasaException` and are expected to be handled by the user of the library. """ from importlib.metadata import version @@ -28,10 +28,11 @@ from kasa.discover import Discover from kasa.emeterstatus import EmeterStatus from kasa.exceptions import ( - AuthenticationException, - SmartDeviceException, - TimeoutException, - UnsupportedDeviceException, + AuthenticationError, + DeviceError, + KasaException, + TimeoutError, + UnsupportedDeviceError, ) from kasa.feature import Feature, FeatureType from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors @@ -61,10 +62,11 @@ "Device", "Bulb", "Plug", - "SmartDeviceException", - "AuthenticationException", - "UnsupportedDeviceException", - "TimeoutException", + "KasaException", + "AuthenticationError", + "DeviceError", + "UnsupportedDeviceError", + "TimeoutError", "Credentials", "DeviceConfig", "ConnectionType", @@ -84,6 +86,12 @@ "SmartDimmer": iot.IotDimmer, "SmartBulbPreset": BulbPreset, } +deprecated_exceptions = { + "SmartDeviceException": KasaException, + "UnsupportedDeviceException": UnsupportedDeviceError, + "AuthenticationException": AuthenticationError, + "TimeoutException": TimeoutError, +} def __getattr__(name): @@ -101,6 +109,11 @@ def __getattr__(name): stacklevel=1, ) return new_class + if name in deprecated_exceptions: + new_class = deprecated_exceptions[name] + msg = f"{name} is deprecated, use {new_class.__name__} instead" + warn(msg, DeprecationWarning, stacklevel=1) + return new_class raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -112,6 +125,11 @@ def __getattr__(name): SmartStrip = iot.IotStrip SmartDimmer = iot.IotDimmer SmartBulbPreset = BulbPreset + + SmartDeviceException = KasaException + UnsupportedDeviceException = UnsupportedDeviceError + AuthenticationException = AuthenticationError + TimeoutException = TimeoutError # Instanstiate all classes so the type checkers catch abstract issues from . import smart diff --git a/kasa/aestransport.py b/kasa/aestransport.py index bbcc511f1..74f59560b 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -22,12 +22,11 @@ from .exceptions import ( SMART_AUTHENTICATION_ERRORS, SMART_RETRYABLE_ERRORS, - SMART_TIMEOUT_ERRORS, - AuthenticationException, - RetryableException, - SmartDeviceException, + AuthenticationError, + DeviceError, + KasaException, SmartErrorCode, - TimeoutException, + _RetryableError, ) from .httpclient import HttpClient from .json import dumps as json_dumps @@ -141,14 +140,12 @@ def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: if error_code == SmartErrorCode.SUCCESS: return msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})" - if error_code in SMART_TIMEOUT_ERRORS: - raise TimeoutException(msg, error_code=error_code) if error_code in SMART_RETRYABLE_ERRORS: - raise RetryableException(msg, error_code=error_code) + raise _RetryableError(msg, error_code=error_code) if error_code in SMART_AUTHENTICATION_ERRORS: self._state = TransportState.HANDSHAKE_REQUIRED - raise AuthenticationException(msg, error_code=error_code) - raise SmartDeviceException(msg, error_code=error_code) + raise AuthenticationError(msg, error_code=error_code) + raise DeviceError(msg, error_code=error_code) async def send_secure_passthrough(self, request: str) -> Dict[str, Any]: """Send encrypted message as passthrough.""" @@ -171,7 +168,7 @@ async def send_secure_passthrough(self, request: str) -> Dict[str, Any]: # _LOGGER.debug(f"secure_passthrough response is {status_code}: {resp_dict}") if status_code != 200: - raise SmartDeviceException( + raise KasaException( f"{self._host} responded with an unexpected " + f"status code {status_code} to passthrough" ) @@ -197,7 +194,7 @@ async def send_secure_passthrough(self, request: str) -> Dict[str, Any]: self._host, ) except Exception: - raise SmartDeviceException( + raise KasaException( f"Unable to decrypt response from {self._host}, " + f"error: {ex}, response: {raw_response}", ex, @@ -208,7 +205,7 @@ async def perform_login(self): """Login to the device.""" try: await self.try_login(self._login_params) - except AuthenticationException as aex: + except AuthenticationError as aex: try: if aex.error_code is not SmartErrorCode.LOGIN_ERROR: raise aex @@ -223,10 +220,10 @@ async def perform_login(self): "%s: logged in with default credentials", self._host, ) - except AuthenticationException: + except AuthenticationError: raise except Exception as ex: - raise SmartDeviceException( + raise KasaException( "Unable to login and trying default " + f"login raised another exception: {ex}", ex, @@ -292,7 +289,7 @@ async def perform_handshake(self) -> None: _LOGGER.debug("Device responded with: %s", resp_dict) if status_code != 200: - raise SmartDeviceException( + raise KasaException( f"{self._host} responded with an unexpected " + f"status code {status_code} to handshake" ) @@ -347,7 +344,7 @@ async def send(self, request: str) -> Dict[str, Any]: await self.perform_login() # After a login failure handshake needs to # be redone or a 9999 error is received. - except AuthenticationException as ex: + except AuthenticationError as ex: self._state = TransportState.HANDSHAKE_REQUIRED raise ex diff --git a/kasa/cli.py b/kasa/cli.py index b075866b0..395022ccd 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -13,7 +13,7 @@ import asyncclick as click from kasa import ( - AuthenticationException, + AuthenticationError, Bulb, ConnectionType, Credentials, @@ -22,8 +22,8 @@ DeviceFamilyType, Discover, EncryptType, - SmartDeviceException, - UnsupportedDeviceException, + KasaException, + UnsupportedDeviceError, ) from kasa.discover import DiscoveryResult from kasa.iot import IotBulb, IotDevice, IotDimmer, IotLightStrip, IotPlug, IotStrip @@ -458,7 +458,7 @@ async def discover(ctx): unsupported = [] auth_failed = [] - async def print_unsupported(unsupported_exception: UnsupportedDeviceException): + async def print_unsupported(unsupported_exception: UnsupportedDeviceError): unsupported.append(unsupported_exception) async with sem: if unsupported_exception.discovery_result: @@ -476,7 +476,7 @@ async def print_discovered(dev: Device): async with sem: try: await dev.update() - except AuthenticationException: + except AuthenticationError: auth_failed.append(dev._discovery_info) echo("== Authentication failed for device ==") _echo_discovery_info(dev._discovery_info) @@ -677,7 +677,7 @@ async def cmd_command(dev: Device, module, command, parameters): elif isinstance(dev, SmartDevice): res = await dev._query_helper(command, parameters) else: - raise SmartDeviceException("Unexpected device type %s.", dev) + raise KasaException("Unexpected device type %s.", dev) echo(json.dumps(res)) return res diff --git a/kasa/device.py b/kasa/device.py index 3c38b5446..d2af7e60b 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -9,7 +9,7 @@ from .device_type import DeviceType from .deviceconfig import DeviceConfig from .emeterstatus import EmeterStatus -from .exceptions import SmartDeviceException +from .exceptions import KasaException from .feature import Feature from .iotprotocol import IotProtocol from .protocol import BaseProtocol @@ -242,12 +242,12 @@ def get_plug_by_name(self, name: str) -> "Device": if p.alias == name: return p - raise SmartDeviceException(f"Device has no child with {name}") + raise KasaException(f"Device has no child with {name}") def get_plug_by_index(self, index: int) -> "Device": """Return child device for the given index.""" if index + 1 > len(self.children) or index < 0: - raise SmartDeviceException( + raise KasaException( f"Invalid index {index}, device has {len(self.children)} plugs" ) return self.children[index] @@ -306,7 +306,7 @@ def _add_feature(self, feature: Feature): """Add a new feature to the device.""" desc_name = feature.name.lower().replace(" ", "_") if desc_name in self._features: - raise SmartDeviceException("Duplicate feature name %s" % desc_name) + raise KasaException("Duplicate feature name %s" % desc_name) self._features[desc_name] = feature @property diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 3550539c7..4fc0996b1 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -6,7 +6,7 @@ from .aestransport import AesTransport from .device import Device from .deviceconfig import DeviceConfig -from .exceptions import SmartDeviceException, UnsupportedDeviceException +from .exceptions import KasaException, UnsupportedDeviceError from .iot import IotBulb, IotDevice, IotDimmer, IotLightStrip, IotPlug, IotStrip from .iotprotocol import IotProtocol from .klaptransport import KlapTransport, KlapTransportV2 @@ -45,12 +45,12 @@ async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Devic :return: Object for querying/controlling found device. """ if host and config or (not host and not config): - raise SmartDeviceException("One of host or config must be provded and not both") + raise KasaException("One of host or config must be provded and not both") if host: config = DeviceConfig(host=host) if (protocol := get_protocol(config=config)) is None: - raise UnsupportedDeviceException( + raise UnsupportedDeviceError( f"Unsupported device for {config.host}: " + f"{config.connection_type.device_family.value}" ) @@ -99,7 +99,7 @@ def _perf_log(has_params, perf_type): _perf_log(True, "update") return device else: - raise UnsupportedDeviceException( + raise UnsupportedDeviceError( f"Unsupported device for {config.host}: " + f"{config.connection_type.device_family.value}" ) @@ -108,12 +108,12 @@ def _perf_log(has_params, perf_type): def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[IotDevice]: """Find SmartDevice subclass for device described by passed data.""" if "system" not in info or "get_sysinfo" not in info["system"]: - raise SmartDeviceException("No 'system' or 'get_sysinfo' in response") + raise KasaException("No 'system' or 'get_sysinfo' in response") sysinfo: Dict[str, Any] = info["system"]["get_sysinfo"] type_: Optional[str] = sysinfo.get("type", sysinfo.get("mic_type")) if type_ is None: - raise SmartDeviceException("Unable to find the device type field!") + raise KasaException("Unable to find the device type field!") if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: return IotDimmer @@ -129,7 +129,7 @@ def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[IotDevice]: return IotLightStrip return IotBulb - raise UnsupportedDeviceException("Unknown device type: %s" % type_) + raise UnsupportedDeviceError("Unknown device type: %s" % type_) def get_device_class_from_family(device_type: str) -> Optional[Type[Device]]: diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index ffb2988e3..af809ac21 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Dict, Optional, Union from .credentials import Credentials -from .exceptions import SmartDeviceException +from .exceptions import KasaException if TYPE_CHECKING: from aiohttp import ClientSession @@ -46,7 +46,7 @@ def _dataclass_from_dict(klass, in_val): fieldtypes[dict_key], in_val[dict_key] ) else: - raise SmartDeviceException( + raise KasaException( f"Cannot create dataclass from dict, unknown key: {dict_key}" ) return klass(**val) @@ -92,7 +92,7 @@ def from_values( login_version, ) except (ValueError, TypeError) as ex: - raise SmartDeviceException( + raise KasaException( f"Invalid connection parameters for {device_family}." + f"{encryption_type}.{login_version}" ) from ex @@ -113,9 +113,7 @@ def from_dict(connection_type_dict: Dict[str, str]) -> "ConnectionType": login_version, # type: ignore[arg-type] ) - raise SmartDeviceException( - f"Invalid connection type data for {connection_type_dict}" - ) + raise KasaException(f"Invalid connection type data for {connection_type_dict}") def to_dict(self) -> Dict[str, Union[str, int]]: """Convert connection params to dict.""" @@ -185,4 +183,4 @@ def from_dict(config_dict: Dict[str, Dict[str, str]]) -> "DeviceConfig": """Return device config from dict.""" if isinstance(config_dict, dict): return _dataclass_from_dict(DeviceConfig, config_dict) - raise SmartDeviceException(f"Invalid device config data: {config_dict}") + raise KasaException(f"Invalid device config data: {config_dict}") diff --git a/kasa/discover.py b/kasa/discover.py index f9ce6e0a5..06e3dc4d6 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -24,9 +24,9 @@ ) from kasa.deviceconfig import ConnectionType, DeviceConfig, EncryptType from kasa.exceptions import ( - SmartDeviceException, - TimeoutException, - UnsupportedDeviceException, + KasaException, + TimeoutError, + UnsupportedDeviceError, ) from kasa.iot.iotdevice import IotDevice from kasa.json import dumps as json_dumps @@ -59,7 +59,7 @@ def __init__( discovery_timeout: int = 5, interface: Optional[str] = None, on_unsupported: Optional[ - Callable[[UnsupportedDeviceException], Awaitable[None]] + Callable[[UnsupportedDeviceError], Awaitable[None]] ] = None, port: Optional[int] = None, credentials: Optional[Credentials] = None, @@ -162,14 +162,14 @@ def datagram_received(self, data, addr) -> None: device = Discover._get_device_instance(data, config) else: return - except UnsupportedDeviceException as udex: + except UnsupportedDeviceError as udex: _LOGGER.debug("Unsupported device found at %s << %s", ip, udex) self.unsupported_device_exceptions[ip] = udex if self.on_unsupported is not None: self._run_callback_task(self.on_unsupported(udex)) self._handle_discovered_event() return - except SmartDeviceException as ex: + except KasaException as ex: _LOGGER.debug(f"[DISCOVERY] Unable to find device type for {ip}: {ex}") self.invalid_device_exceptions[ip] = ex self._handle_discovered_event() @@ -311,7 +311,7 @@ async def discover( try: _LOGGER.debug("Waiting %s seconds for responses...", discovery_timeout) await protocol.wait_for_discovery_to_complete() - except SmartDeviceException as ex: + except KasaException as ex: for device in protocol.discovered_devices.values(): await device.protocol.close() raise ex @@ -368,9 +368,7 @@ async def discover_single( # https://docs.python.org/3/library/socket.html#socket.getaddrinfo ip = adrrinfo[0][4][0] except socket.gaierror as gex: - raise SmartDeviceException( - f"Could not resolve hostname {host}" - ) from gex + raise KasaException(f"Could not resolve hostname {host}") from gex transport, protocol = await loop.create_datagram_endpoint( lambda: _DiscoverProtocol( @@ -401,7 +399,7 @@ async def discover_single( elif ip in protocol.invalid_device_exceptions: raise protocol.invalid_device_exceptions[ip] else: - raise TimeoutException(f"Timed out getting discovery response for {host}") + raise TimeoutError(f"Timed out getting discovery response for {host}") @staticmethod def _get_device_class(info: dict) -> Type[Device]: @@ -410,7 +408,7 @@ def _get_device_class(info: dict) -> Type[Device]: discovery_result = DiscoveryResult(**info["result"]) dev_class = get_device_class_from_family(discovery_result.device_type) if not dev_class: - raise UnsupportedDeviceException( + raise UnsupportedDeviceError( "Unknown device type: %s" % discovery_result.device_type, discovery_result=info, ) @@ -424,7 +422,7 @@ def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice: try: info = json_loads(XorEncryption.decrypt(data)) except Exception as ex: - raise SmartDeviceException( + raise KasaException( f"Unable to read response from device: {config.host}: {ex}" ) from ex @@ -451,7 +449,7 @@ def _get_device_instance( info = json_loads(data[16:]) except Exception as ex: _LOGGER.debug("Got invalid response from device %s: %s", config.host, data) - raise SmartDeviceException( + raise KasaException( f"Unable to read response from device: {config.host}: {ex}" ) from ex try: @@ -460,7 +458,7 @@ def _get_device_instance( _LOGGER.debug( "Unable to parse discovery from device %s: %s", config.host, info ) - raise UnsupportedDeviceException( + raise UnsupportedDeviceError( f"Unable to parse discovery from device: {config.host}: {ex}" ) from ex @@ -472,15 +470,15 @@ def _get_device_instance( discovery_result.mgt_encrypt_schm.encrypt_type, discovery_result.mgt_encrypt_schm.lv, ) - except SmartDeviceException as ex: - raise UnsupportedDeviceException( + except KasaException as ex: + raise UnsupportedDeviceError( f"Unsupported device {config.host} of type {type_} " + f"with encrypt_type {discovery_result.mgt_encrypt_schm.encrypt_type}", discovery_result=discovery_result.get_dict(), ) from ex if (device_class := get_device_class_from_family(type_)) is None: _LOGGER.warning("Got unsupported device type: %s", type_) - raise UnsupportedDeviceException( + raise UnsupportedDeviceError( f"Unsupported device {config.host} of type {type_}: {info}", discovery_result=discovery_result.get_dict(), ) @@ -488,7 +486,7 @@ def _get_device_instance( _LOGGER.warning( "Got unsupported connection type: %s", config.connection_type.to_dict() ) - raise UnsupportedDeviceException( + raise UnsupportedDeviceError( f"Unsupported encryption scheme {config.host} of " + f"type {config.connection_type.to_dict()}: {info}", discovery_result=discovery_result.get_dict(), diff --git a/kasa/exceptions.py b/kasa/exceptions.py index af9aaaa59..d179bf3ae 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -1,45 +1,57 @@ """python-kasa exceptions.""" -from asyncio import TimeoutError +from asyncio import TimeoutError as _asyncioTimeoutError from enum import IntEnum from typing import Any, Optional -class SmartDeviceException(Exception): - """Base exception for device errors.""" +class KasaException(Exception): + """Base exception for library errors.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: - self.error_code: Optional["SmartErrorCode"] = kwargs.get("error_code", None) - super().__init__(*args) +class TimeoutError(KasaException, _asyncioTimeoutError): + """Timeout exception for device errors.""" -class UnsupportedDeviceException(SmartDeviceException): - """Exception for trying to connect to unsupported devices.""" + def __repr__(self): + return KasaException.__repr__(self) - def __init__(self, *args: Any, **kwargs: Any) -> None: - self.discovery_result = kwargs.get("discovery_result") - super().__init__(*args, **kwargs) + def __str__(self): + return KasaException.__str__(self) -class AuthenticationException(SmartDeviceException): - """Base exception for device authentication errors.""" +class _ConnectionError(KasaException): + """Connection exception for device errors.""" -class RetryableException(SmartDeviceException): - """Retryable exception for device errors.""" +class UnsupportedDeviceError(KasaException): + """Exception for trying to connect to unsupported devices.""" + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.discovery_result = kwargs.get("discovery_result") + super().__init__(*args) -class TimeoutException(SmartDeviceException, TimeoutError): - """Timeout exception for device errors.""" + +class DeviceError(KasaException): + """Base exception for device errors.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.error_code: Optional["SmartErrorCode"] = kwargs.get("error_code", None) + super().__init__(*args) def __repr__(self): - return SmartDeviceException.__repr__(self) + err_code = self.error_code.__repr__() if self.error_code else "" + return f"{self.__class__.__name__}({err_code})" def __str__(self): - return SmartDeviceException.__str__(self) + err_code = f" (error_code={self.error_code.name})" if self.error_code else "" + return super().__str__() + err_code -class ConnectionException(SmartDeviceException): - """Connection exception for device errors.""" +class AuthenticationError(DeviceError): + """Base exception for device authentication errors.""" + + +class _RetryableError(DeviceError): + """Retryable exception for device errors.""" class SmartErrorCode(IntEnum): @@ -109,6 +121,7 @@ def __str__(self): SmartErrorCode.TRANSPORT_NOT_AVAILABLE_ERROR, SmartErrorCode.HTTP_TRANSPORT_FAILED_ERROR, SmartErrorCode.UNSPECIFIC_ERROR, + SmartErrorCode.SESSION_TIMEOUT_ERROR, ] SMART_AUTHENTICATION_ERRORS = [ @@ -118,7 +131,3 @@ def __str__(self): SmartErrorCode.HAND_SHAKE_FAILED_ERROR, SmartErrorCode.TRANSPORT_UNKNOWN_CREDENTIALS_ERROR, ] - -SMART_TIMEOUT_ERRORS = [ - SmartErrorCode.SESSION_TIMEOUT_ERROR, -] diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 607efc7f9..b0bbb593a 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -8,9 +8,9 @@ from .deviceconfig import DeviceConfig from .exceptions import ( - ConnectionException, - SmartDeviceException, - TimeoutException, + KasaException, + TimeoutError, + _ConnectionError, ) from .json import loads as json_loads @@ -86,17 +86,17 @@ async def post( response_data = json_loads(response_data.decode()) except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex: - raise ConnectionException( + raise _ConnectionError( f"Device connection error: {self._config.host}: {ex}", ex ) from ex except (aiohttp.ServerTimeoutError, asyncio.TimeoutError) as ex: - raise TimeoutException( + raise TimeoutError( "Unable to query the device, " + f"timed out: {self._config.host}: {ex}", ex, ) from ex except Exception as ex: - raise SmartDeviceException( + raise KasaException( f"Unable to query the device: {self._config.host}: {ex}", ex ) from ex diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 7712f3d7e..6b8d37b06 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -13,7 +13,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..protocol import BaseProtocol -from .iotdevice import IotDevice, SmartDeviceException, requires_update +from .iotdevice import IotDevice, KasaException, requires_update from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage @@ -97,7 +97,7 @@ class IotBulb(IotDevice, Bulb): so you must await :func:`update()` to fetch updates values from the device. Errors reported by the device are raised as - :class:`SmartDeviceExceptions `, + :class:`KasaException `, and should be handled by the user of the library. Examples: @@ -233,7 +233,7 @@ def valid_temperature_range(self) -> ColorTempRange: :return: White temperature range in Kelvin (minimum, maximum) """ if not self.is_variable_color_temp: - raise SmartDeviceException("Color temperature not supported") + raise KasaException("Color temperature not supported") for model, temp_range in TPLINK_KELVIN.items(): sys_info = self.sys_info @@ -249,7 +249,7 @@ def light_state(self) -> Dict[str, str]: """Query the light state.""" light_state = self.sys_info["light_state"] if light_state is None: - raise SmartDeviceException( + raise KasaException( "The device has no light_state or you have not called update()" ) @@ -333,7 +333,7 @@ def hsv(self) -> HSV: :return: hue, saturation and value (degrees, %, %) """ if not self.is_color: - raise SmartDeviceException("Bulb does not support color.") + raise KasaException("Bulb does not support color.") light_state = cast(dict, self.light_state) @@ -360,7 +360,7 @@ async def set_hsv( :param int transition: transition in milliseconds. """ if not self.is_color: - raise SmartDeviceException("Bulb does not support color.") + raise KasaException("Bulb does not support color.") if not isinstance(hue, int) or not (0 <= hue <= 360): raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") @@ -387,7 +387,7 @@ async def set_hsv( def color_temp(self) -> int: """Return color temperature of the device in kelvin.""" if not self.is_variable_color_temp: - raise SmartDeviceException("Bulb does not support colortemp.") + raise KasaException("Bulb does not support colortemp.") light_state = self.light_state return int(light_state["color_temp"]) @@ -402,7 +402,7 @@ async def set_color_temp( :param int transition: transition in milliseconds. """ if not self.is_variable_color_temp: - raise SmartDeviceException("Bulb does not support colortemp.") + raise KasaException("Bulb does not support colortemp.") valid_temperature_range = self.valid_temperature_range if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: @@ -423,7 +423,7 @@ async def set_color_temp( def brightness(self) -> int: """Return the current brightness in percentage.""" if not self.is_dimmable: # pragma: no cover - raise SmartDeviceException("Bulb is not dimmable.") + raise KasaException("Bulb is not dimmable.") light_state = self.light_state return int(light_state["brightness"]) @@ -438,7 +438,7 @@ async def set_brightness( :param int transition: transition in milliseconds. """ if not self.is_dimmable: # pragma: no cover - raise SmartDeviceException("Bulb is not dimmable.") + raise KasaException("Bulb is not dimmable.") self._raise_for_invalid_brightness(brightness) @@ -511,10 +511,10 @@ async def save_preset(self, preset: BulbPreset): obtained using :func:`presets`. """ if len(self.presets) == 0: - raise SmartDeviceException("Device does not supported saving presets") + raise KasaException("Device does not supported saving presets") if preset.index >= len(self.presets): - raise SmartDeviceException("Invalid preset index") + raise KasaException("Invalid preset index") return await self._query_helper( self.LIGHT_SERVICE, "set_preferred_state", preset.dict(exclude_none=True) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index ac902af84..b70fbff00 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -21,7 +21,7 @@ from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus -from ..exceptions import SmartDeviceException +from ..exceptions import KasaException from ..feature import Feature from ..protocol import BaseProtocol from .iotmodule import IotModule @@ -48,9 +48,7 @@ def requires_update(f): async def wrapped(*args, **kwargs): self = args[0] if self._last_update is None and f.__name__ not in self._sys_info: - raise SmartDeviceException( - "You need to await update() to access the data" - ) + raise KasaException("You need to await update() to access the data") return await f(*args, **kwargs) else: @@ -59,9 +57,7 @@ async def wrapped(*args, **kwargs): def wrapped(*args, **kwargs): self = args[0] if self._last_update is None and f.__name__ not in self._sys_info: - raise SmartDeviceException( - "You need to await update() to access the data" - ) + raise KasaException("You need to await update() to access the data") return f(*args, **kwargs) f.requires_update = True @@ -92,7 +88,8 @@ class IotDevice(Device): All changes to the device are done using awaitable methods, which will not change the cached values, but you must await update() separately. - Errors reported by the device are raised as SmartDeviceExceptions, + Errors reported by the device are raised as + :class:`KasaException `, and should be handled by the user of the library. Examples: @@ -221,9 +218,9 @@ def _create_request( def _verify_emeter(self) -> None: """Raise an exception if there is no emeter.""" if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") + raise KasaException("Device has no emeter") if self.emeter_type not in self._last_update: - raise SmartDeviceException("update() required prior accessing emeter") + raise KasaException("update() required prior accessing emeter") async def _query_helper( self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None @@ -241,20 +238,20 @@ async def _query_helper( try: response = await self._raw_query(request=request) except Exception as ex: - raise SmartDeviceException(f"Communication error on {target}:{cmd}") from ex + raise KasaException(f"Communication error on {target}:{cmd}") from ex if target not in response: - raise SmartDeviceException(f"No required {target} in response: {response}") + raise KasaException(f"No required {target} in response: {response}") result = response[target] if "err_code" in result and result["err_code"] != 0: - raise SmartDeviceException(f"Error on {target}.{cmd}: {result}") + raise KasaException(f"Error on {target}.{cmd}: {result}") if cmd not in result: - raise SmartDeviceException(f"No command in response: {response}") + raise KasaException(f"No command in response: {response}") result = result[cmd] if "err_code" in result and result["err_code"] != 0: - raise SmartDeviceException(f"Error on {target} {cmd}: {result}") + raise KasaException(f"Error on {target} {cmd}: {result}") if "err_code" in result: del result["err_code"] @@ -513,7 +510,7 @@ def mac(self) -> str: sys_info = self._sys_info mac = sys_info.get("mac", sys_info.get("mic_mac")) if not mac: - raise SmartDeviceException( + raise KasaException( "Unknown mac, please submit a bug report with sys_info output." ) mac = mac.replace("-", ":") @@ -656,14 +653,14 @@ async def _scan(target): try: info = await _scan("netif") - except SmartDeviceException as ex: + except KasaException as ex: _LOGGER.debug( "Unable to scan using 'netif', retrying with 'softaponboarding': %s", ex ) info = await _scan("smartlife.iot.common.softaponboarding") if "ap_list" not in info: - raise SmartDeviceException("Invalid response for wifi scan: %s" % info) + raise KasaException("Invalid response for wifi scan: %s" % info) return [WifiNetwork(**x) for x in info["ap_list"]] @@ -679,7 +676,7 @@ async def _join(target, payload): payload = {"ssid": ssid, "password": password, "key_type": int(keytype)} try: return await _join("netif", payload) - except SmartDeviceException as ex: + except KasaException as ex: _LOGGER.debug( "Unable to join using 'netif', retrying with 'softaponboarding': %s", ex ) diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index b7b727eb1..721a2c4b3 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -5,7 +5,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..protocol import BaseProtocol -from .iotdevice import SmartDeviceException, requires_update +from .iotdevice import KasaException, requires_update from .iotplug import IotPlug from .modules import AmbientLight, Motion @@ -46,7 +46,7 @@ class IotDimmer(IotPlug): which will not change the cached values, but you must await :func:`update()` separately. - Errors reported by the device are raised as :class:`SmartDeviceException`\s, + Errors reported by the device are raised as :class:`KasaException`\s, and should be handled by the user of the library. Examples: @@ -88,7 +88,7 @@ def brightness(self) -> int: Will return a range between 0 - 100. """ if not self.is_dimmable: - raise SmartDeviceException("Device is not dimmable.") + raise KasaException("Device is not dimmable.") sys_info = self.sys_info return int(sys_info["brightness"]) @@ -103,7 +103,7 @@ async def set_brightness( Using a transition will cause the dimmer to turn on. """ if not self.is_dimmable: - raise SmartDeviceException("Device is not dimmable.") + raise KasaException("Device is not dimmable.") if not isinstance(brightness, int): raise ValueError( diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 942b9f785..fa341a2c5 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -6,7 +6,7 @@ from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from ..protocol import BaseProtocol from .iotbulb import IotBulb -from .iotdevice import SmartDeviceException, requires_update +from .iotdevice import KasaException, requires_update class IotLightStrip(IotBulb): @@ -117,7 +117,7 @@ async def set_effect( :param int transition: The wanted transition time """ if effect not in EFFECT_MAPPING_V1: - raise SmartDeviceException(f"The effect {effect} is not a built in effect.") + raise KasaException(f"The effect {effect} is not a built in effect.") effect_dict = EFFECT_MAPPING_V1[effect] if brightness is not None: effect_dict["brightness"] = brightness @@ -136,7 +136,7 @@ async def set_custom_effect( :param str effect_dict: The custom effect dict to set """ if not self.has_effects: - raise SmartDeviceException("Bulb does not support effects.") + raise KasaException("Bulb does not support effects.") await self._query_helper( "smartlife.iot.lighting_effect", "set_lighting_effect", diff --git a/kasa/iot/iotmodule.py b/kasa/iot/iotmodule.py index ddff06b39..ab29b23cc 100644 --- a/kasa/iot/iotmodule.py +++ b/kasa/iot/iotmodule.py @@ -2,7 +2,7 @@ import collections import logging -from ..exceptions import SmartDeviceException +from ..exceptions import KasaException from ..module import Module _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ def estimated_query_response_size(self): def data(self): """Return the module specific raw data from the last update.""" if self._module not in self._device._last_update: - raise SmartDeviceException( + raise KasaException( f"You need to call update() prior accessing module data" f" for '{self._module}'" ) diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index c72489660..e408bb3ce 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -22,7 +22,7 @@ class IotPlug(IotDevice): which will not change the cached values, but you must await :func:`update()` separately. - Errors reported by the device are raised as :class:`SmartDeviceException`\s, + Errors reported by the device are raised as :class:`KasaException`\s, and should be handled by the user of the library. Examples: diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 7cbb10b03..2c62b754b 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -6,7 +6,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..exceptions import SmartDeviceException +from ..exceptions import KasaException from ..protocol import BaseProtocol from .iotdevice import ( EmeterStatus, @@ -43,7 +43,7 @@ class IotStrip(IotDevice): which will not change the cached values, but you must await :func:`update()` separately. - Errors reported by the device are raised as :class:`SmartDeviceException`\s, + Errors reported by the device are raised as :class:`KasaException`\s, and should be handled by the user of the library. Examples: @@ -375,4 +375,4 @@ def _get_child_info(self) -> Dict: if plug["id"] == self.child_id: return plug - raise SmartDeviceException(f"Unable to find children {self.child_id}") + raise KasaException(f"Unable to find children {self.child_id}") diff --git a/kasa/iot/modules/motion.py b/kasa/iot/modules/motion.py index 05edb2a53..06a729cab 100644 --- a/kasa/iot/modules/motion.py +++ b/kasa/iot/modules/motion.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Optional -from ...exceptions import SmartDeviceException +from ...exceptions import KasaException from ..iotmodule import IotModule @@ -54,9 +54,7 @@ async def set_range( elif range is not None: payload = {"index": range.value} else: - raise SmartDeviceException( - "Either range or custom_range need to be defined" - ) + raise KasaException("Either range or custom_range need to be defined") return await self.call("set_trigger_sens", payload) diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py index 568df1804..15dd55c87 100644 --- a/kasa/iot/modules/time.py +++ b/kasa/iot/modules/time.py @@ -1,7 +1,7 @@ """Provides the current time and timezone information.""" from datetime import datetime -from ...exceptions import SmartDeviceException +from ...exceptions import KasaException from ..iotmodule import IotModule, merge @@ -46,7 +46,7 @@ async def get_time(self): res["min"], res["sec"], ) - except SmartDeviceException: + except KasaException: return None async def get_timezone(self): diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index f74e56f48..6a82a9c1b 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -5,11 +5,11 @@ from .deviceconfig import DeviceConfig from .exceptions import ( - AuthenticationException, - ConnectionException, - RetryableException, - SmartDeviceException, - TimeoutException, + AuthenticationError, + KasaException, + TimeoutError, + _ConnectionError, + _RetryableError, ) from .json import dumps as json_dumps from .protocol import BaseProtocol, BaseTransport @@ -46,31 +46,31 @@ async def _query(self, request: str, retry_count: int = 3) -> Dict: for retry in range(retry_count + 1): try: return await self._execute_query(request, retry) - except ConnectionException as sdex: + except _ConnectionError as sdex: if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise sdex continue - except AuthenticationException as auex: + except AuthenticationError as auex: await self._transport.reset() _LOGGER.debug( "Unable to authenticate with %s, not retrying", self._host ) raise auex - except RetryableException as ex: + except _RetryableError as ex: 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: + except TimeoutError as ex: 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: + except KasaException as ex: await self._transport.reset() _LOGGER.debug( "Unable to query the device: %s, not retrying: %s", @@ -80,7 +80,7 @@ async def _query(self, request: str, retry_count: int = 3) -> Dict: raise ex # make mypy happy, this should never be reached.. - raise SmartDeviceException("Query reached somehow to unreachable") + raise KasaException("Query reached somehow to unreachable") async def _execute_query(self, request: str, retry_count: int) -> Dict: return await self._transport.send(request) @@ -101,7 +101,7 @@ def __init__( ) -> None: """Create a protocol object.""" if not host and not transport: - raise SmartDeviceException("host or transport must be supplied") + raise KasaException("host or transport must be supplied") if not transport: config = DeviceConfig( host=host, # type: ignore[arg-type] diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 0452e7375..ab33ca18e 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -57,7 +57,7 @@ from .credentials import Credentials from .deviceconfig import DeviceConfig -from .exceptions import AuthenticationException, SmartDeviceException +from .exceptions import AuthenticationError, KasaException from .httpclient import HttpClient from .json import loads as json_loads from .protocol import DEFAULT_CREDENTIALS, BaseTransport, get_default_credentials, md5 @@ -159,7 +159,7 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: ) if response_status != 200: - raise SmartDeviceException( + raise KasaException( f"Device {self._host} responded with {response_status} to handshake1" ) @@ -168,7 +168,7 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: server_hash = response_data[16:] if len(server_hash) != 32: - raise SmartDeviceException( + raise KasaException( f"Device {self._host} responded with unexpected klap response " + f"{response_data!r} to handshake1" ) @@ -236,7 +236,7 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: msg = f"Server response doesn't match our challenge on ip {self._host}" _LOGGER.debug(msg) - raise AuthenticationException(msg) + raise AuthenticationError(msg) async def perform_handshake2( self, local_seed, remote_seed, auth_hash @@ -267,8 +267,8 @@ async def perform_handshake2( if response_status != 200: # This shouldn't be caused by incorrect - # credentials so don't raise AuthenticationException - raise SmartDeviceException( + # credentials so don't raise AuthenticationError + raise KasaException( f"Device {self._host} responded with {response_status} to handshake2" ) @@ -337,12 +337,12 @@ async def send(self, request: str): # If we failed with a security error, force a new handshake next time. if response_status == 403: self._handshake_done = False - raise AuthenticationException( + raise AuthenticationError( f"Got a security error from {self._host} after handshake " + "completed" ) else: - raise SmartDeviceException( + raise KasaException( f"Device {self._host} responded with {response_status} to" + f"request with seq {seq}" ) diff --git a/kasa/module.py b/kasa/module.py index 66a143dc7..5066c9535 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -4,7 +4,7 @@ from typing import Dict from .device import Device -from .exceptions import SmartDeviceException +from .exceptions import KasaException from .feature import Feature _LOGGER = logging.getLogger(__name__) @@ -39,7 +39,7 @@ def _add_feature(self, feature: Feature): """Add module feature.""" feat_name = f"{self._module}_{feature.name}" if feat_name in self._module_features: - raise SmartDeviceException("Duplicate name detected %s" % feat_name) + raise KasaException("Duplicate name detected %s" % feat_name) self._module_features[feat_name] = feature def __repr__(self) -> str: diff --git a/kasa/smart/smartbulb.py b/kasa/smart/smartbulb.py index 3ce4c6eb4..c6295eda6 100644 --- a/kasa/smart/smartbulb.py +++ b/kasa/smart/smartbulb.py @@ -4,7 +4,7 @@ from ..bulb import Bulb from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..exceptions import SmartDeviceException +from ..exceptions import KasaException from ..iot.iotbulb import HSV, BulbPreset, ColorTempRange from ..smartprotocol import SmartProtocol from .smartdevice import SmartDevice @@ -57,7 +57,7 @@ def valid_temperature_range(self) -> ColorTempRange: :return: White temperature range in Kelvin (minimum, maximum) """ if not self.is_variable_color_temp: - raise SmartDeviceException("Color temperature not supported") + raise KasaException("Color temperature not supported") ct_range = self._info.get("color_temp_range", [0, 0]) return ColorTempRange(min=ct_range[0], max=ct_range[1]) @@ -107,7 +107,7 @@ def hsv(self) -> HSV: :return: hue, saturation and value (degrees, %, %) """ if not self.is_color: - raise SmartDeviceException("Bulb does not support color.") + raise KasaException("Bulb does not support color.") h, s, v = ( self._info.get("hue", 0), @@ -121,7 +121,7 @@ def hsv(self) -> HSV: def color_temp(self) -> int: """Whether the bulb supports color temperature changes.""" if not self.is_variable_color_temp: - raise SmartDeviceException("Bulb does not support colortemp.") + raise KasaException("Bulb does not support colortemp.") return self._info.get("color_temp", -1) @@ -129,7 +129,7 @@ def color_temp(self) -> int: def brightness(self) -> int: """Return the current brightness in percentage.""" if not self.is_dimmable: # pragma: no cover - raise SmartDeviceException("Bulb is not dimmable.") + raise KasaException("Bulb is not dimmable.") return self._info.get("brightness", -1) @@ -151,7 +151,7 @@ async def set_hsv( :param int transition: transition in milliseconds. """ if not self.is_color: - raise SmartDeviceException("Bulb does not support color.") + raise KasaException("Bulb does not support color.") if not isinstance(hue, int) or not (0 <= hue <= 360): raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") @@ -188,7 +188,7 @@ async def set_color_temp( # TODO: Note, trying to set brightness at the same time # with color_temp causes error -1008 if not self.is_variable_color_temp: - raise SmartDeviceException("Bulb does not support colortemp.") + raise KasaException("Bulb does not support colortemp.") valid_temperature_range = self.valid_temperature_range if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: @@ -211,7 +211,7 @@ async def set_brightness( :param int transition: transition in milliseconds. """ if not self.is_dimmable: # pragma: no cover - raise SmartDeviceException("Bulb is not dimmable.") + raise KasaException("Bulb is not dimmable.") return await self.protocol.query( {"set_device_info": {"brightness": brightness}} diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 62657d816..2a90beeb6 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -9,7 +9,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus -from ..exceptions import AuthenticationException, SmartDeviceException, SmartErrorCode +from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..feature import Feature, FeatureType from ..smartprotocol import SmartProtocol from .modules import ( # noqa: F401 @@ -85,7 +85,7 @@ def _try_get_response(self, responses: dict, request: str, default=None) -> dict return response if default is not None: return default - raise SmartDeviceException( + raise KasaException( f"{request} not found in {responses} for device {self.host}" ) @@ -100,7 +100,7 @@ async def _negotiate(self): async def update(self, update_children: bool = True): """Update the device.""" if self.credentials is None and self.credentials_hash is None: - raise AuthenticationException("Tapo plug requires authentication.") + raise AuthenticationError("Tapo plug requires authentication.") if self._components_raw is None: await self._negotiate() @@ -341,7 +341,7 @@ async def get_emeter_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" _LOGGER.warning("Deprecated, use `emeter_realtime`.") if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") + raise KasaException("Device has no emeter") return self.emeter_realtime @property @@ -421,7 +421,7 @@ async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): after some delay. """ if not self.credentials: - raise AuthenticationException("Device requires authentication.") + raise AuthenticationError("Device requires authentication.") payload = { "account": { @@ -445,10 +445,9 @@ async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): # Thus, We limit retries and suppress the raised exception as useless. try: return await self.protocol.query({"set_qs_info": payload}, retry_count=0) - except SmartDeviceException as ex: - if ex.error_code: # Re-raise on device-reported errors - raise - + except DeviceError: + raise # Re-raise on device-reported errors + except KasaException: _LOGGER.debug("Received an expected for wifi join, but this is expected") async def update_credentials(self, username: str, password: str): diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 6f42f297a..791383e8a 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -2,7 +2,7 @@ import logging from typing import TYPE_CHECKING, Dict, Type -from ..exceptions import SmartDeviceException +from ..exceptions import KasaException from ..module import Module if TYPE_CHECKING: @@ -59,7 +59,7 @@ def data(self): q_keys = list(q.keys()) # TODO: hacky way to check if update has been called. if q_keys[0] not in self._device._last_update: - raise SmartDeviceException( + raise KasaException( f"You need to call update() prior accessing module data" f" for '{self._module}'" ) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 54e2fe1c3..77bf66ab3 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -15,13 +15,13 @@ from .exceptions import ( SMART_AUTHENTICATION_ERRORS, SMART_RETRYABLE_ERRORS, - SMART_TIMEOUT_ERRORS, - AuthenticationException, - ConnectionException, - RetryableException, - SmartDeviceException, + AuthenticationError, + DeviceError, + KasaException, SmartErrorCode, - TimeoutException, + TimeoutError, + _ConnectionError, + _RetryableError, ) from .json import dumps as json_dumps from .protocol import BaseProtocol, BaseTransport, md5 @@ -66,32 +66,32 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: for retry in range(retry_count + 1): try: return await self._execute_query(request, retry) - except ConnectionException as sdex: + except _ConnectionError as sdex: if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise sdex continue - except AuthenticationException as auex: + except AuthenticationError as auex: await self._transport.reset() _LOGGER.debug( "Unable to authenticate with %s, not retrying", self._host ) raise auex - except RetryableException as ex: + except _RetryableError as ex: 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 TimeoutException as ex: + except TimeoutError as ex: 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: + except KasaException as ex: await self._transport.reset() _LOGGER.debug( "Unable to query the device: %s, not retrying: %s", @@ -101,7 +101,7 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: raise ex # make mypy happy, this should never be reached.. - raise SmartDeviceException("Query reached somehow to unreachable") + raise KasaException("Query reached somehow to unreachable") async def _execute_multiple_query(self, request: Dict, retry_count: int) -> Dict: debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) @@ -193,13 +193,11 @@ def _handle_response_error_code(self, resp_dict: dict, method, raise_on_error=Tr + f"{error_code.name}({error_code.value})" + f" for method: {method}" ) - if error_code in SMART_TIMEOUT_ERRORS: - raise TimeoutException(msg, error_code=error_code) if error_code in SMART_RETRYABLE_ERRORS: - raise RetryableException(msg, error_code=error_code) + raise _RetryableError(msg, error_code=error_code) if error_code in SMART_AUTHENTICATION_ERRORS: - raise AuthenticationException(msg, error_code=error_code) - raise SmartDeviceException(msg, error_code=error_code) + raise AuthenticationError(msg, error_code=error_code) + raise DeviceError(msg, error_code=error_code) async def close(self) -> None: """Close the underlying transport.""" diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 54fc86479..6e59ba3d8 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -1,7 +1,7 @@ import warnings from json import loads as json_loads -from kasa import Credentials, DeviceConfig, SmartDeviceException, SmartProtocol +from kasa import Credentials, DeviceConfig, KasaException, SmartProtocol from kasa.protocol import BaseTransport @@ -144,7 +144,7 @@ def _send_request(self, request_dict: dict): ) return {"result": missing_result[1], "error_code": 0} else: - raise SmartDeviceException(f"Fixture doesn't support {method}") + raise KasaException(f"Fixture doesn't support {method}") elif method == "set_qs_info": return {"error_code": 0} elif method[:4] == "set_": diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 51f1e3d90..cc7aeece1 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -19,8 +19,8 @@ from ..credentials import Credentials from ..deviceconfig import DeviceConfig from ..exceptions import ( - AuthenticationException, - SmartDeviceException, + AuthenticationError, + KasaException, SmartErrorCode, ) from ..httpclient import HttpClient @@ -49,8 +49,8 @@ def test_encrypt(): "status_code, error_code, inner_error_code, expectation", [ (200, 0, 0, does_not_raise()), - (400, 0, 0, pytest.raises(SmartDeviceException)), - (200, -1, 0, pytest.raises(SmartDeviceException)), + (400, 0, 0, pytest.raises(KasaException)), + (200, -1, 0, pytest.raises(KasaException)), ], ids=("success", "status_code", "error_code"), ) @@ -101,17 +101,17 @@ async def test_login(mocker, status_code, error_code, inner_error_code, expectat ([SmartErrorCode.LOGIN_ERROR, 0, 0, 0], does_not_raise(), 4), ( [SmartErrorCode.LOGIN_ERROR, SmartErrorCode.LOGIN_ERROR], - pytest.raises(AuthenticationException), + pytest.raises(AuthenticationError), 3, ), ( [SmartErrorCode.LOGIN_FAILED_ERROR], - pytest.raises(AuthenticationException), + pytest.raises(AuthenticationError), 1, ), ( [SmartErrorCode.LOGIN_ERROR, SmartErrorCode.SESSION_TIMEOUT_ERROR], - pytest.raises(SmartDeviceException), + pytest.raises(KasaException), 3, ), ], @@ -238,7 +238,7 @@ async def test_unencrypted_response_invalid_json(mocker, caplog): } caplog.set_level(logging.DEBUG) msg = f"Unable to decrypt response from {host}, error: Incorrect padding, response: Foobar" - with pytest.raises(SmartDeviceException, match=msg): + with pytest.raises(KasaException, match=msg): await transport.send(json_dumps(request)) @@ -267,7 +267,7 @@ async def test_passthrough_errors(mocker, error_code): "requestID": 1, "terminal_uuid": "foobar", } - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await transport.send(json_dumps(request)) diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 5cfb9e5e9..e8c95dbd8 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -7,7 +7,7 @@ Schema, ) -from kasa import Bulb, BulbPreset, DeviceType, SmartDeviceException +from kasa import Bulb, BulbPreset, DeviceType, KasaException from kasa.iot import IotBulb from .conftest import ( @@ -51,7 +51,7 @@ async def test_state_attributes(dev: Bulb): @bulb_iot async def test_light_state_without_update(dev: IotBulb, monkeypatch): - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): monkeypatch.setitem( dev._last_update["system"]["get_sysinfo"], "light_state", None ) @@ -123,9 +123,9 @@ async def test_color_state_information(dev: Bulb): async def test_hsv_on_non_color(dev: Bulb): assert not dev.is_color - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await dev.set_hsv(0, 0, 0) - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): print(dev.hsv) @@ -175,13 +175,13 @@ async def test_out_of_range_temperature(dev: Bulb): @non_variable_temp async def test_non_variable_temp(dev: Bulb): - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await dev.set_color_temp(2700) - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): print(dev.valid_temperature_range) - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): print(dev.color_temp) @@ -238,9 +238,9 @@ async def test_invalid_brightness(dev: Bulb): async def test_non_dimmable(dev: Bulb): assert not dev.is_dimmable - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): assert dev.brightness == 0 - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await dev.set_brightness(100) @@ -296,7 +296,7 @@ async def test_modify_preset(dev: IotBulb, mocker): await dev.save_preset(preset) assert dev.presets[0].brightness == 10 - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await dev.save_preset( BulbPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) ) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 636ccd367..8bffef7d6 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -6,12 +6,13 @@ from asyncclick.testing import CliRunner from kasa import ( - AuthenticationException, + AuthenticationError, Credentials, Device, + DeviceError, EmeterStatus, - SmartDeviceException, - UnsupportedDeviceException, + KasaException, + UnsupportedDeviceError, ) from kasa.cli import ( TYPE_TO_CLASS, @@ -188,15 +189,13 @@ async def test_wifi_join_no_creds(dev): ) assert res.exit_code != 0 - assert isinstance(res.exception, AuthenticationException) + assert isinstance(res.exception, AuthenticationError) @device_smart async def test_wifi_join_exception(dev, mocker): runner = CliRunner() - mocker.patch.object( - dev.protocol, "query", side_effect=SmartDeviceException(error_code=9999) - ) + mocker.patch.object(dev.protocol, "query", side_effect=DeviceError(error_code=9999)) res = await runner.invoke( wifi, ["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"], @@ -204,7 +203,7 @@ async def test_wifi_join_exception(dev, mocker): ) assert res.exit_code != 0 - assert isinstance(res.exception, SmartDeviceException) + assert isinstance(res.exception, KasaException) @device_smart @@ -509,7 +508,7 @@ async def test_host_unsupported(unsupported_device_info): ) assert res.exit_code != 0 - assert isinstance(res.exception, UnsupportedDeviceException) + assert isinstance(res.exception, UnsupportedDeviceError) @new_discovery @@ -522,7 +521,7 @@ async def test_discover_auth_failed(discovery_mock, mocker): mocker.patch.object( device_class, "update", - side_effect=AuthenticationException("Failed to authenticate"), + side_effect=AuthenticationError("Failed to authenticate"), ) res = await runner.invoke( cli, @@ -553,7 +552,7 @@ async def test_host_auth_failed(discovery_mock, mocker): mocker.patch.object( device_class, "update", - side_effect=AuthenticationException("Failed to authenticate"), + side_effect=AuthenticationError("Failed to authenticate"), ) res = await runner.invoke( cli, @@ -569,7 +568,7 @@ async def test_host_auth_failed(discovery_mock, mocker): ) assert res.exit_code != 0 - assert isinstance(res.exception, AuthenticationException) + assert isinstance(res.exception, AuthenticationError) @pytest.mark.parametrize("device_type", list(TYPE_TO_CLASS)) @@ -616,7 +615,7 @@ async def test_shell(dev: Device, mocker): async def test_errors(mocker): runner = CliRunner() - err = SmartDeviceException("Foobar") + err = KasaException("Foobar") # Test masking mocker.patch("kasa.Discover.discover", side_effect=err) diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index 7369a9874..2d6267069 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -8,7 +8,7 @@ Credentials, Device, Discover, - SmartDeviceException, + KasaException, ) from kasa.device_factory import connect, get_protocol from kasa.deviceconfig import ( @@ -110,8 +110,8 @@ async def test_connect_logs_connect_time( async def test_connect_query_fails(all_fixture_data: dict, mocker): """Make sure that connect fails when query fails.""" host = "127.0.0.1" - mocker.patch("kasa.IotProtocol.query", side_effect=SmartDeviceException) - mocker.patch("kasa.SmartProtocol.query", side_effect=SmartDeviceException) + mocker.patch("kasa.IotProtocol.query", side_effect=KasaException) + mocker.patch("kasa.SmartProtocol.query", side_effect=KasaException) ctype, _ = _get_connection_type_device_class(all_fixture_data) config = DeviceConfig( @@ -120,7 +120,7 @@ async def test_connect_query_fails(all_fixture_data: dict, mocker): protocol_class = get_protocol(config).__class__ close_mock = mocker.patch.object(protocol_class, "close") assert close_mock.call_count == 0 - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await connect(config=config) assert close_mock.call_count == 1 diff --git a/kasa/tests/test_deviceconfig.py b/kasa/tests/test_deviceconfig.py index fed635f6d..cefc6179c 100644 --- a/kasa/tests/test_deviceconfig.py +++ b/kasa/tests/test_deviceconfig.py @@ -8,7 +8,7 @@ from kasa.deviceconfig import ( DeviceConfig, ) -from kasa.exceptions import SmartDeviceException +from kasa.exceptions import KasaException async def test_serialization(): @@ -29,7 +29,7 @@ async def test_serialization(): ids=["invalid-dict", "not-dict"], ) def test_deserialization_errors(input_value, expected_msg): - with pytest.raises(SmartDeviceException, match=expected_msg): + with pytest.raises(KasaException, match=expected_msg): DeviceConfig.from_dict(input_value) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 8ce5ca6ea..02cf19bc5 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -13,14 +13,14 @@ Device, DeviceType, Discover, - SmartDeviceException, + KasaException, ) from kasa.deviceconfig import ( ConnectionType, DeviceConfig, ) from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps -from kasa.exceptions import AuthenticationException, UnsupportedDeviceException +from kasa.exceptions import AuthenticationError, UnsupportedDeviceError from kasa.iot import IotDevice from kasa.xortransport import XorEncryption @@ -94,7 +94,7 @@ async def test_type_detection_lightstrip(dev: Device): async def test_type_unknown(): invalid_info = {"system": {"get_sysinfo": {"type": "nosuchtype"}}} - with pytest.raises(UnsupportedDeviceException): + with pytest.raises(UnsupportedDeviceError): Discover._get_device_class(invalid_info) @@ -151,7 +151,7 @@ async def test_discover_single_hostname(discovery_mock, mocker): assert update_mock.call_count == 0 mocker.patch("socket.getaddrinfo", side_effect=socket.gaierror()) - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): x = await Discover.discover_single(host, credentials=Credentials()) @@ -161,7 +161,7 @@ async def test_discover_single_unsupported(unsupported_device_info, mocker): # Test with a valid unsupported response with pytest.raises( - UnsupportedDeviceException, + UnsupportedDeviceError, ): await Discover.discover_single(host) @@ -171,7 +171,7 @@ async def test_discover_single_no_response(mocker): host = "127.0.0.1" mocker.patch.object(_DiscoverProtocol, "do_discover") with pytest.raises( - SmartDeviceException, match=f"Timed out getting discovery response for {host}" + KasaException, match=f"Timed out getting discovery response for {host}" ): await Discover.discover_single(host, discovery_timeout=0) @@ -198,7 +198,7 @@ async def mock_discover(self): mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) - with pytest.raises(SmartDeviceException, match=msg): + with pytest.raises(KasaException, match=msg): await Discover.discover_single(host) @@ -280,11 +280,11 @@ async def test_discover_single_authentication(discovery_mock, mocker): mocker.patch.object( device_class, "update", - side_effect=AuthenticationException("Failed to authenticate"), + side_effect=AuthenticationError("Failed to authenticate"), ) with pytest.raises( - AuthenticationException, + AuthenticationError, match="Failed to authenticate", ): device = await Discover.discover_single( @@ -315,7 +315,7 @@ async def test_device_update_from_new_discovery_info(discovery_data): # TODO implement requires_update for SmartDevice if isinstance(device, IotDevice): with pytest.raises( - SmartDeviceException, + KasaException, match=re.escape("You need to await update() to access the data"), ): assert device.supported_modules @@ -456,9 +456,9 @@ async def test_discover_propogates_task_exceptions(discovery_mock): discovery_timeout = 0 async def on_discovered(dev): - raise SmartDeviceException("Dummy exception") + raise KasaException("Dummy exception") - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await Discover.discover( discovery_timeout=discovery_timeout, on_discovered=on_discovered ) diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 809764fad..a8fe75edd 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -10,7 +10,7 @@ Schema, ) -from kasa import EmeterStatus, SmartDeviceException +from kasa import EmeterStatus, KasaException from kasa.iot import IotDevice from kasa.iot.modules.emeter import Emeter @@ -38,16 +38,16 @@ async def test_no_emeter(dev): assert not dev.has_emeter - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await dev.get_emeter_realtime() # Only iot devices support the historical stats so other # devices will not implement the methods below if isinstance(dev, IotDevice): - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await dev.get_emeter_daily() - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await dev.get_emeter_monthly() - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await dev.erase_emeter_stats() diff --git a/kasa/tests/test_httpclient.py b/kasa/tests/test_httpclient.py index 2afabba07..78aac552f 100644 --- a/kasa/tests/test_httpclient.py +++ b/kasa/tests/test_httpclient.py @@ -6,9 +6,9 @@ from ..deviceconfig import DeviceConfig from ..exceptions import ( - ConnectionException, - SmartDeviceException, - TimeoutException, + KasaException, + TimeoutError, + _ConnectionError, ) from ..httpclient import HttpClient @@ -18,28 +18,28 @@ [ ( aiohttp.ServerDisconnectedError(), - ConnectionException, + _ConnectionError, "Device connection error: ", ), ( aiohttp.ClientOSError(), - ConnectionException, + _ConnectionError, "Device connection error: ", ), ( aiohttp.ServerTimeoutError(), - TimeoutException, + TimeoutError, "Unable to query the device, timed out: ", ), ( asyncio.TimeoutError(), - TimeoutException, + TimeoutError, "Unable to query the device, timed out: ", ), - (Exception(), SmartDeviceException, "Unable to query the device: "), + (Exception(), KasaException, "Unable to query the device: "), ( aiohttp.ServerFingerprintMismatch("exp", "got", "host", 1), - SmartDeviceException, + KasaException, "Unable to query the device: ", ), ], diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 9dee04fa2..7c8054758 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -12,11 +12,11 @@ from ..credentials import Credentials from ..deviceconfig import DeviceConfig from ..exceptions import ( - AuthenticationException, - ConnectionException, - RetryableException, - SmartDeviceException, - TimeoutException, + AuthenticationError, + KasaException, + TimeoutError, + _ConnectionError, + _RetryableError, ) from ..httpclient import HttpClient from ..iotprotocol import IotProtocol @@ -68,7 +68,7 @@ async def test_protocol_retries_via_client_session( mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0) config = DeviceConfig(host) - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( DUMMY_QUERY, retry_count=retry_count ) @@ -80,11 +80,11 @@ async def test_protocol_retries_via_client_session( @pytest.mark.parametrize( "error, retry_expectation", [ - (SmartDeviceException("dummy exception"), False), - (RetryableException("dummy exception"), True), - (TimeoutException("dummy exception"), True), + (KasaException("dummy exception"), False), + (_RetryableError("dummy exception"), True), + (TimeoutError("dummy exception"), True), ], - ids=("SmartDeviceException", "RetryableException", "TimeoutException"), + ids=("KasaException", "_RetryableError", "TimeoutError"), ) @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) @@ -97,7 +97,7 @@ async def test_protocol_retries_via_httpclient( mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0) config = DeviceConfig(host) - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( DUMMY_QUERY, retry_count=retry_count ) @@ -115,11 +115,11 @@ async def test_protocol_no_retry_on_connection_error( conn = mocker.patch.object( aiohttp.ClientSession, "post", - side_effect=AuthenticationException("foo"), + side_effect=AuthenticationError("foo"), ) mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0) config = DeviceConfig(host) - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( DUMMY_QUERY, retry_count=5 ) @@ -139,7 +139,7 @@ async def test_protocol_retry_recoverable_error( side_effect=aiohttp.ClientOSError("foo"), ) config = DeviceConfig(host) - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( DUMMY_QUERY, retry_count=5 ) @@ -159,7 +159,7 @@ def _fail_one_less_than_retry_count(*_, **__): nonlocal remaining remaining -= 1 if remaining: - raise ConnectionException("Simulated connection failure") + raise _ConnectionError("Simulated connection failure") return mock_response @@ -249,7 +249,7 @@ def test_encrypt_unicode(): ), ( Credentials("shouldfail", "shouldfail"), - pytest.raises(AuthenticationException), + pytest.raises(AuthenticationError), ), ], ids=("client", "blank", "kasa_setup", "shouldfail"), @@ -350,7 +350,7 @@ async def _return_handshake_response(url: URL, params=None, data=None, *_, **__) assert protocol._transport._handshake_done is True response_status = 403 - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await protocol._transport.perform_handshake() assert protocol._transport._handshake_done is False await protocol.close() @@ -405,37 +405,37 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): pytest.param( (403, 403, 403), True, - pytest.raises(SmartDeviceException), + pytest.raises(KasaException), id="handshake1-403-status", ), pytest.param( (200, 403, 403), True, - pytest.raises(SmartDeviceException), + pytest.raises(KasaException), id="handshake2-403-status", ), pytest.param( (200, 200, 403), True, - pytest.raises(AuthenticationException), + pytest.raises(AuthenticationError), id="request-403-status", ), pytest.param( (200, 200, 400), True, - pytest.raises(SmartDeviceException), + pytest.raises(KasaException), id="request-400-status", ), pytest.param( (200, 200, 200), False, - pytest.raises(AuthenticationException), + pytest.raises(AuthenticationError), id="handshake1-wrong-auth", ), pytest.param( (200, 200, 200), secrets.token_bytes(16), - pytest.raises(SmartDeviceException), + pytest.raises(KasaException), id="handshake1-bad-auth-length", ), ], diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index 9ded007ab..123360a4e 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -1,7 +1,7 @@ import pytest from kasa import DeviceType -from kasa.exceptions import SmartDeviceException +from kasa.exceptions import KasaException from kasa.iot import IotLightStrip from .conftest import lightstrip @@ -23,7 +23,7 @@ async def test_lightstrip_effect(dev: IotLightStrip): @lightstrip async def test_effects_lightstrip_set_effect(dev: IotLightStrip): - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await dev.set_effect("Not real") await dev.set_effect("Candy Cane") diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 69402beec..e0ddbbb43 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -14,7 +14,7 @@ from ..aestransport import AesTransport from ..credentials import Credentials from ..deviceconfig import DeviceConfig -from ..exceptions import SmartDeviceException +from ..exceptions import KasaException from ..iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol from ..klaptransport import KlapTransport, KlapTransportV2 from ..protocol import ( @@ -46,7 +46,7 @@ def aio_mock_writer(_, __): conn = mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) config = DeviceConfig("127.0.0.1") - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( {}, retry_count=retry_count ) @@ -70,7 +70,7 @@ async def test_protocol_no_retry_on_unreachable( side_effect=OSError(errno.EHOSTUNREACH, "No route to host"), ) config = DeviceConfig("127.0.0.1") - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( {}, retry_count=5 ) @@ -94,7 +94,7 @@ async def test_protocol_no_retry_connection_refused( side_effect=ConnectionRefusedError, ) config = DeviceConfig("127.0.0.1") - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( {}, retry_count=5 ) @@ -118,7 +118,7 @@ async def test_protocol_retry_recoverable_error( side_effect=OSError(errno.ECONNRESET, "Connection reset by peer"), ) config = DeviceConfig("127.0.0.1") - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( {}, retry_count=5 ) @@ -553,7 +553,7 @@ async def test_protocol_will_retry_on_connect( retry_count = 2 conn = mocker.patch("asyncio.open_connection", side_effect=error) config = DeviceConfig("127.0.0.1") - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( {}, retry_count=retry_count ) @@ -595,7 +595,7 @@ def aio_mock_writer(_, __): conn = mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) write_mock = mocker.patch("asyncio.StreamWriter.write", side_effect=error) config = DeviceConfig("127.0.0.1") - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( {}, retry_count=retry_count ) @@ -609,9 +609,7 @@ def test_deprecated_protocol(): with pytest.deprecated_call(): from kasa import TPLinkSmartHomeProtocol - with pytest.raises( - SmartDeviceException, match="host or transport must be supplied" - ): + with pytest.raises(KasaException, match="host or transport must be supplied"): proto = TPLinkSmartHomeProtocol() host = "127.0.0.1" proto = TPLinkSmartHomeProtocol(host=host) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 487286dbb..1a397b892 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -21,7 +21,7 @@ ) import kasa -from kasa import Credentials, Device, DeviceConfig, SmartDeviceException +from kasa import Credentials, Device, DeviceConfig, KasaException from kasa.exceptions import SmartErrorCode from kasa.iot import IotDevice from kasa.smart import SmartChildDevice, SmartDevice @@ -67,8 +67,8 @@ async def test_state_info(dev): @device_iot async def test_invalid_connection(dev): with patch.object( - FakeIotProtocol, "query", side_effect=SmartDeviceException - ), pytest.raises(SmartDeviceException): + FakeIotProtocol, "query", side_effect=KasaException + ), pytest.raises(KasaException): await dev.update() @@ -98,7 +98,7 @@ async def test_initial_update_no_emeter(dev, mocker): @device_iot async def test_query_helper(dev): - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await dev._query_helper("test", "testcmd", {}) # TODO check for unwrapping? @@ -328,7 +328,7 @@ async def test_update_no_device_info(dev: SmartDevice): } msg = f"get_device_info not found in {mock_response} for device 127.0.0.123" with patch.object(dev.protocol, "query", return_value=mock_response), pytest.raises( - SmartDeviceException, match=msg + KasaException, match=msg ): await dev.update() @@ -348,6 +348,16 @@ def test_deprecated_devices(device_class, use_class): getattr(module, use_class.__name__) +@pytest.mark.parametrize( + "exceptions_class, use_class", kasa.deprecated_exceptions.items() +) +def test_deprecated_exceptions(exceptions_class, use_class): + msg = f"{exceptions_class} is deprecated, use {use_class.__name__} instead" + with pytest.deprecated_call(match=msg): + getattr(kasa, exceptions_class) + getattr(kasa, use_class.__name__) + + def check_mac(x): if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", x.lower()): return x diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 7d677a831..aaea519d8 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -1,13 +1,10 @@ -from itertools import chain - import pytest from ..credentials import Credentials from ..deviceconfig import DeviceConfig from ..exceptions import ( SMART_RETRYABLE_ERRORS, - SMART_TIMEOUT_ERRORS, - SmartDeviceException, + KasaException, SmartErrorCode, ) from ..smartprotocol import _ChildProtocolWrapper @@ -28,13 +25,10 @@ async def test_smart_device_errors(dummy_protocol, mocker, error_code): dummy_protocol._transport, "send", return_value=mock_response ) - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await dummy_protocol.query(DUMMY_QUERY, retry_count=2) - if error_code in chain(SMART_TIMEOUT_ERRORS, SMART_RETRYABLE_ERRORS): - expected_calls = 3 - else: - expected_calls = 1 + expected_calls = 3 if error_code in SMART_RETRYABLE_ERRORS else 1 assert send_mock.call_count == expected_calls @@ -124,7 +118,7 @@ async def test_childdevicewrapper_error(dummy_protocol, mocker): mock_response = {"error_code": 0, "result": {"responseData": {"error_code": -1001}}} mocker.patch.object(wrapped_protocol._transport, "send", return_value=mock_response) - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await wrapped_protocol.query(DUMMY_QUERY) @@ -180,5 +174,5 @@ async def test_childdevicewrapper_multiplerequest_error(dummy_protocol, mocker): } mocker.patch.object(dummy_protocol._transport, "send", return_value=mock_response) - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await dummy_protocol.query(DUMMY_QUERY) diff --git a/kasa/tests/test_strip.py b/kasa/tests/test_strip.py index 623adde6c..e7d36f903 100644 --- a/kasa/tests/test_strip.py +++ b/kasa/tests/test_strip.py @@ -2,7 +2,7 @@ import pytest -from kasa import SmartDeviceException +from kasa import KasaException from kasa.iot import IotStrip from .conftest import handle_turn_on, strip, turn_on @@ -73,7 +73,7 @@ async def test_get_plug_by_name(dev: IotStrip): name = dev.children[0].alias assert dev.get_plug_by_name(name) == dev.children[0] # type: ignore[arg-type] - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): dev.get_plug_by_name("NONEXISTING NAME") @@ -81,10 +81,10 @@ async def test_get_plug_by_name(dev: IotStrip): async def test_get_plug_by_index(dev: IotStrip): assert dev.get_plug_by_index(0) == dev.children[0] - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): dev.get_plug_by_index(-1) - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): dev.get_plug_by_index(len(dev.children)) diff --git a/kasa/xortransport.py b/kasa/xortransport.py index 95e78c205..e7b94f8e3 100644 --- a/kasa/xortransport.py +++ b/kasa/xortransport.py @@ -23,7 +23,7 @@ from async_timeout import timeout as asyncio_timeout from .deviceconfig import DeviceConfig -from .exceptions import RetryableException, SmartDeviceException +from .exceptions import KasaException, _RetryableError from .json import loads as json_loads from .protocol import BaseTransport @@ -129,24 +129,24 @@ async def send(self, request: str) -> Dict: await self._connect(self._timeout) except ConnectionRefusedError as ex: await self.reset() - raise SmartDeviceException( + raise KasaException( f"Unable to connect to the device: {self._host}:{self._port}: {ex}" ) from ex except OSError as ex: await self.reset() if ex.errno in _NO_RETRY_ERRORS: - raise SmartDeviceException( + raise KasaException( f"Unable to connect to the device:" f" {self._host}:{self._port}: {ex}" ) from ex else: - raise RetryableException( + raise _RetryableError( f"Unable to connect to the device:" f" {self._host}:{self._port}: {ex}" ) from ex except Exception as ex: await self.reset() - raise RetryableException( + raise _RetryableError( f"Unable to connect to the device:" f" {self._host}:{self._port}: {ex}" ) from ex except BaseException: @@ -162,7 +162,7 @@ async def send(self, request: str) -> Dict: return await self._execute_send(request) except Exception as ex: await self.reset() - raise RetryableException( + raise _RetryableError( f"Unable to query the device {self._host}:{self._port}: {ex}" ) from ex except BaseException: From d9d2f1a43052cfd8568f7d3d34ea424d52e9e112 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 22 Feb 2024 14:34:55 +0100 Subject: [PATCH 335/892] Remove SmartPlug in favor of SmartDevice (#781) With the move towards autodetecting available features, there is no reason to keep SmartPlug around. kasa.smart.SmartPlug is removed in favor of kasa.smart.SmartDevice which offers the same functionality. Information about auto_off can be accessed using Features of the AutoOffModule on supported devices. Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --- kasa/__init__.py | 1 - kasa/cli.py | 4 ++-- kasa/device.py | 16 +++++++-------- kasa/device_factory.py | 6 +++--- kasa/smart/__init__.py | 3 +-- kasa/smart/smartdevice.py | 25 +++++++++++++++++++++++ kasa/smart/smartplug.py | 37 ---------------------------------- kasa/tests/conftest.py | 9 ++++----- kasa/tests/test_plug.py | 3 --- kasa/tests/test_smartdevice.py | 27 +++++++++++++++++++++++++ 10 files changed, 70 insertions(+), 61 deletions(-) delete mode 100644 kasa/smart/smartplug.py diff --git a/kasa/__init__.py b/kasa/__init__.py index 06bb35149..6e937dc30 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -134,7 +134,6 @@ def __getattr__(name): from . import smart smart.SmartDevice("127.0.0.1") - smart.SmartPlug("127.0.0.1") smart.SmartBulb("127.0.0.1") iot.IotDevice("127.0.0.1") iot.IotPlug("127.0.0.1") diff --git a/kasa/cli.py b/kasa/cli.py index 395022ccd..e92c66520 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -27,7 +27,7 @@ ) from kasa.discover import DiscoveryResult from kasa.iot import IotBulb, IotDevice, IotDimmer, IotLightStrip, IotPlug, IotStrip -from kasa.smart import SmartBulb, SmartDevice, SmartPlug +from kasa.smart import SmartBulb, SmartDevice try: from pydantic.v1 import ValidationError @@ -72,7 +72,7 @@ def wrapper(message=None, *args, **kwargs): "iot.dimmer": IotDimmer, "iot.strip": IotStrip, "iot.lightstrip": IotLightStrip, - "smart.plug": SmartPlug, + "smart.plug": SmartDevice, "smart.bulb": SmartBulb, } diff --git a/kasa/device.py b/kasa/device.py index d2af7e60b..6e104f880 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -194,32 +194,32 @@ def sys_info(self) -> Dict[str, Any]: @property def is_bulb(self) -> bool: """Return True if the device is a bulb.""" - return self._device_type == DeviceType.Bulb + return self.device_type == DeviceType.Bulb @property def is_light_strip(self) -> bool: """Return True if the device is a led strip.""" - return self._device_type == DeviceType.LightStrip + return self.device_type == DeviceType.LightStrip @property def is_plug(self) -> bool: """Return True if the device is a plug.""" - return self._device_type == DeviceType.Plug + return self.device_type == DeviceType.Plug @property def is_strip(self) -> bool: """Return True if the device is a strip.""" - return self._device_type == DeviceType.Strip + return self.device_type == DeviceType.Strip @property def is_strip_socket(self) -> bool: """Return True if the device is a strip socket.""" - return self._device_type == DeviceType.StripSocket + return self.device_type == DeviceType.StripSocket @property def is_dimmer(self) -> bool: """Return True if the device is a dimmer.""" - return self._device_type == DeviceType.Dimmer + return self.device_type == DeviceType.Dimmer @property def is_dimmable(self) -> bool: @@ -354,9 +354,9 @@ async def set_alias(self, alias: str): def __repr__(self): if self._last_update is None: - return f"<{self._device_type} at {self.host} - update() needed>" + return f"<{self.device_type} at {self.host} - update() needed>" return ( - f"<{self._device_type} model {self.model} at {self.host}" + f"<{self.device_type} model {self.model} at {self.host}" f" ({self.alias}), is_on: {self.is_on}" f" - dev specific: {self.state_information}>" ) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 4fc0996b1..66903468a 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -14,7 +14,7 @@ BaseProtocol, BaseTransport, ) -from .smart import SmartBulb, SmartPlug +from .smart import SmartBulb, SmartDevice from .smartprotocol import SmartProtocol from .xortransport import XorTransport @@ -135,10 +135,10 @@ def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[IotDevice]: def get_device_class_from_family(device_type: str) -> Optional[Type[Device]]: """Return the device class from the type name.""" supported_device_types: Dict[str, Type[Device]] = { - "SMART.TAPOPLUG": SmartPlug, + "SMART.TAPOPLUG": SmartDevice, "SMART.TAPOBULB": SmartBulb, "SMART.TAPOSWITCH": SmartBulb, - "SMART.KASAPLUG": SmartPlug, + "SMART.KASAPLUG": SmartDevice, "SMART.KASASWITCH": SmartBulb, "IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTBULB": IotBulb, diff --git a/kasa/smart/__init__.py b/kasa/smart/__init__.py index c075ba321..936fa7fde 100644 --- a/kasa/smart/__init__.py +++ b/kasa/smart/__init__.py @@ -2,6 +2,5 @@ from .smartbulb import SmartBulb from .smartchilddevice import SmartChildDevice from .smartdevice import SmartDevice -from .smartplug import SmartPlug -__all__ = ["SmartDevice", "SmartPlug", "SmartBulb", "SmartChildDevice"] +__all__ = ["SmartDevice", "SmartBulb", "SmartChildDevice"] diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 2a90beeb6..ab45eb425 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -485,3 +485,28 @@ async def factory_reset(self) -> None: Note, this does not downgrade the firmware. """ await self.protocol.query("device_reset") + + @property + def device_type(self) -> DeviceType: + """Return the device type.""" + if self._device_type is not DeviceType.Unknown: + return self._device_type + + if self.children: + if "SMART.TAPOHUB" in self.sys_info["type"]: + pass # TODO: placeholder for future hub PR + else: + self._device_type = DeviceType.Strip + elif "light_strip" in self._components: + self._device_type = DeviceType.LightStrip + elif "dimmer_calibration" in self._components: + self._device_type = DeviceType.Dimmer + elif "brightness" in self._components: + self._device_type = DeviceType.Bulb + elif "PLUG" in self.sys_info["type"]: + self._device_type = DeviceType.Plug + else: + _LOGGER.warning("Unknown device type, falling back to plug") + self._device_type = DeviceType.Plug + + return self._device_type diff --git a/kasa/smart/smartplug.py b/kasa/smart/smartplug.py deleted file mode 100644 index bd96b4217..000000000 --- a/kasa/smart/smartplug.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Module for a TAPO Plug.""" -import logging -from typing import Any, Dict, Optional - -from ..device_type import DeviceType -from ..deviceconfig import DeviceConfig -from ..plug import Plug -from ..smartprotocol import SmartProtocol -from .smartdevice import SmartDevice - -_LOGGER = logging.getLogger(__name__) - - -class SmartPlug(SmartDevice, Plug): - """Class to represent a TAPO Plug.""" - - def __init__( - self, - host: str, - *, - config: Optional[DeviceConfig] = None, - protocol: Optional[SmartProtocol] = None, - ) -> None: - super().__init__(host=host, config=config, protocol=protocol) - self._device_type = DeviceType.Plug - - @property - def state_information(self) -> Dict[str, Any]: - """Return the key state information.""" - return { - **super().state_information, - **{ - "On since": self.on_since, - "auto_off_status": self._info.get("auto_off_status"), - "auto_off_remain_time": self._info.get("auto_off_remain_time"), - }, - } diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index b5b711d99..e69b73fa9 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -20,7 +20,7 @@ ) from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip from kasa.protocol import BaseTransport -from kasa.smart import SmartBulb, SmartPlug +from kasa.smart import SmartBulb, SmartDevice from kasa.xortransport import XorEncryption from .fakeprotocol_iot import FakeIotProtocol @@ -108,7 +108,6 @@ "EP25", "KS205", "P125M", - "P135", "S505", "TP15", } @@ -121,7 +120,7 @@ STRIPS = {*STRIPS_IOT, *STRIPS_SMART} DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} -DIMMERS_SMART = {"S500D"} +DIMMERS_SMART = {"S500D", "P135"} DIMMERS = { *DIMMERS_IOT, *DIMMERS_SMART, @@ -346,7 +345,7 @@ def device_for_file(model, protocol): if protocol == "SMART": for d in PLUGS_SMART: if d in model: - return SmartPlug + return SmartDevice for d in BULBS_SMART: if d in model: return SmartBulb @@ -355,7 +354,7 @@ def device_for_file(model, protocol): return SmartBulb for d in STRIPS_SMART: if d in model: - return SmartPlug + return SmartDevice else: for d in STRIPS_IOT: if d in model: diff --git a/kasa/tests/test_plug.py b/kasa/tests/test_plug.py index 7cde008d6..64c420f9d 100644 --- a/kasa/tests/test_plug.py +++ b/kasa/tests/test_plug.py @@ -37,9 +37,6 @@ async def test_led(dev): @plug_smart async def test_plug_device_info(dev): assert dev._info is not None - # PLUG_SCHEMA(dev.sys_info) - assert dev.model is not None assert dev.device_type == DeviceType.Plug or dev.device_type == DeviceType.Strip - # assert dev.is_plug or dev.is_strip diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 1a397b892..a94123809 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -22,16 +22,21 @@ import kasa from kasa import Credentials, Device, DeviceConfig, KasaException +from kasa.device_type import DeviceType from kasa.exceptions import SmartErrorCode from kasa.iot import IotDevice from kasa.smart import SmartChildDevice, SmartDevice from .conftest import ( + bulb, device_iot, device_smart, + dimmer, handle_turn_on, has_emeter_iot, + lightstrip, no_emeter_iot, + plug, turn_on, ) from .fakeprotocol_iot import FakeIotProtocol @@ -416,3 +421,25 @@ def check_mac(x): }, extra=REMOVE_EXTRA, ) + + +@dimmer +def test_device_type_dimmer(dev): + assert dev.device_type == DeviceType.Dimmer + + +@bulb +def test_device_type_bulb(dev): + if dev.is_light_strip: + pytest.skip("bulb has also lightstrips to test the api") + assert dev.device_type == DeviceType.Bulb + + +@plug +def test_device_type_plug(dev): + assert dev.device_type == DeviceType.Plug + + +@lightstrip +def test_device_type_lightstrip(dev): + assert dev.device_type == DeviceType.LightStrip From a87fc3b76600483f09eaf7294dfa09ebac93c70c Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 22 Feb 2024 17:02:03 +0000 Subject: [PATCH 336/892] Retry query on 403 after succesful handshake (#785) If a handshake session becomes invalid the device returns 403 on send and an `AuthenticationError` is raised which prevents a retry, however a retry would be successful. In HA this causes devices to go into reauth flow which is not necessary. --- kasa/klaptransport.py | 4 ++-- kasa/tests/test_klapprotocol.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index ab33ca18e..8feae98c1 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -57,7 +57,7 @@ from .credentials import Credentials from .deviceconfig import DeviceConfig -from .exceptions import AuthenticationError, KasaException +from .exceptions import AuthenticationError, KasaException, _RetryableError from .httpclient import HttpClient from .json import loads as json_loads from .protocol import DEFAULT_CREDENTIALS, BaseTransport, get_default_credentials, md5 @@ -337,7 +337,7 @@ async def send(self, request: str): # If we failed with a security error, force a new handshake next time. if response_status == 403: self._handshake_done = False - raise AuthenticationError( + raise _RetryableError( f"Got a security error from {self._host} after handshake " + "completed" ) diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 7c8054758..b71ea460d 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -417,7 +417,7 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): pytest.param( (200, 200, 403), True, - pytest.raises(AuthenticationError), + pytest.raises(_RetryableError), id="request-403-status", ), pytest.param( From f965b1402127eb9e384c796be66b232bbc2f9388 Mon Sep 17 00:00:00 2001 From: Benjamin Ness Date: Thu, 22 Feb 2024 12:11:30 -0600 Subject: [PATCH 337/892] Add feature for ambient light sensor (#787) --- devtools/helpers/smartrequests.py | 8 ++++---- kasa/iot/modules/ambientlight.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index de0a53ff4..279925190 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -168,7 +168,7 @@ def get_device_time() -> "SmartRequest": @staticmethod def get_wireless_scan_info( - params: Optional[GetRulesParams] = None + params: Optional[GetRulesParams] = None, ) -> "SmartRequest": """Get wireless scan info.""" return SmartRequest( @@ -273,7 +273,7 @@ def get_auto_light_info() -> "SmartRequest": @staticmethod def get_dynamic_light_effect_rules( - params: Optional[GetRulesParams] = None + params: Optional[GetRulesParams] = None, ) -> "SmartRequest": """Get dynamic light effect rules.""" return SmartRequest( @@ -292,7 +292,7 @@ def set_light_info(params: LightInfoParams) -> "SmartRequest": @staticmethod def set_dynamic_light_effect_rule_enable( - params: DynamicLightEffectParams + params: DynamicLightEffectParams, ) -> "SmartRequest": """Enable dynamic light effect rule.""" return SmartRequest("set_dynamic_light_effect_rule_enable", params) @@ -312,7 +312,7 @@ def get_component_info_requests(component_nego_response) -> List["SmartRequest"] @staticmethod def _create_request_dict( - smart_request: Union["SmartRequest", List["SmartRequest"]] + smart_request: Union["SmartRequest", List["SmartRequest"]], ) -> dict: """Create request dict to be passed to SmartProtocol.query().""" if isinstance(smart_request, list): diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py index f1069448c..e14f2991d 100644 --- a/kasa/iot/modules/ambientlight.py +++ b/kasa/iot/modules/ambientlight.py @@ -1,5 +1,6 @@ """Implementation of the ambient light (LAS) module found in some dimmers.""" -from ..iotmodule import IotModule +from ...feature import Feature, FeatureType +from ..iotmodule import IotModule, merge # TODO create tests and use the config reply there # [{"hw_id":0,"enable":0,"dark_index":1,"min_adc":0,"max_adc":2450, @@ -14,9 +15,27 @@ class AmbientLight(IotModule): """Implements ambient light controls for the motion sensor.""" + def __init__(self, device, module): + super().__init__(device, module) + self._add_feature( + Feature( + device=device, + container=self, + name="Ambient Light", + icon="mdi:brightness-percent", + attribute_getter="ambientlight_brightness", + type=FeatureType.Sensor, + ) + ) + def query(self): """Request configuration.""" - return self.query_for_command("get_config") + req = merge( + self.query_for_command("get_config"), + self.query_for_command("get_current_brt"), + ) + + return req @property def presets(self) -> dict: @@ -28,6 +47,11 @@ def enabled(self) -> bool: """Return True if the module is enabled.""" return bool(self.data["enable"]) + @property + def ambientlight_brightness(self) -> int: + """Return True if the module is enabled.""" + return int(self.data["get_current_brt"]["value"]) + async def set_enabled(self, state: bool): """Enable/disable LAS.""" return await self.call("set_enable", {"enable": int(state)}) From 2b0721aea9974bc9f12adcb2e5d4c391c0419650 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 22 Feb 2024 20:46:19 +0100 Subject: [PATCH 338/892] Generalize smartdevice child support (#775) * Initialize children's modules (and features) using the child component negotiation results * Set device_type based on the device response * Print out child features in cli 'state' * Add --child option to cli 'command' to allow targeting child devices * Guard "generic" features like rssi, ssid, etc. only to devices which have this information Note, we do not currently perform queries on child modules so some data may not be available. At the moment, a stop-gap solution to use parent's data is used but this is not always correct; even if the device shares the same clock and cloud connectivity, it may have its own firmware updates. --- kasa/cli.py | 22 ++++- kasa/device.py | 10 ++- kasa/device_type.py | 1 + kasa/iot/iotdevice.py | 20 +---- kasa/iot/iotstrip.py | 8 +- kasa/smart/modules/childdevicemodule.py | 12 ++- kasa/smart/smartchilddevice.py | 37 ++++++-- kasa/smart/smartdevice.py | 112 ++++++++++++++---------- kasa/smart/smartmodule.py | 27 ++++-- kasa/tests/test_childdevice.py | 1 - kasa/tests/test_cli.py | 27 ++++++ kasa/tests/test_smartdevice.py | 20 ++--- 12 files changed, 198 insertions(+), 99 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index e92c66520..16e64a050 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -582,9 +582,14 @@ async def state(ctx, dev: Device): echo(f"\tPort: {dev.port}") echo(f"\tDevice state: {dev.is_on}") if dev.is_strip: - echo("\t[bold]== Plugs ==[/bold]") - for plug in dev.children: # type: ignore - echo(f"\t* Socket '{plug.alias}' state: {plug.is_on} since {plug.on_since}") + echo("\t[bold]== Children ==[/bold]") + for child in dev.children: + echo(f"\t* {child.alias} ({child.model}, {child.device_type})") + for feat in child.features.values(): + try: + echo(f"\t\t{feat.name}: {feat.value}") + except Exception as ex: + echo(f"\t\t{feat.name}: got exception (%s)" % ex) echo() echo("\t[bold]== Generic information ==[/bold]") @@ -665,13 +670,22 @@ async def raw_command(ctx, dev: Device, module, command, parameters): @cli.command(name="command") @pass_dev @click.option("--module", required=False, help="Module for IOT protocol.") +@click.option("--child", required=False, help="Child ID for controlling sub-devices") @click.argument("command") @click.argument("parameters", default=None, required=False) -async def cmd_command(dev: Device, module, command, parameters): +async def cmd_command(dev: Device, module, child, command, parameters): """Run a raw command on the device.""" if parameters is not None: parameters = ast.literal_eval(parameters) + if child: + # The way child devices are accessed requires a ChildDevice to + # wrap the communications. Doing this properly would require creating + # a common interfaces for both IOT and SMART child devices. + # As a stop-gap solution, we perform an update instead. + await dev.update() + dev = dev.get_child_device(child) + if isinstance(dev, IotDevice): res = await dev._query_helper(module, command, parameters) elif isinstance(dev, SmartDevice): diff --git a/kasa/device.py b/kasa/device.py index 6e104f880..72967ee2d 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict, List, Optional, Sequence, Union +from typing import Any, Dict, List, Mapping, Optional, Sequence, Union from .credentials import Credentials from .device_type import DeviceType @@ -71,6 +71,8 @@ def __init__( self.modules: Dict[str, Any] = {} self._features: Dict[str, Feature] = {} + self._parent: Optional["Device"] = None + self._children: Mapping[str, "Device"] = {} @staticmethod async def connect( @@ -182,9 +184,13 @@ async def _raw_query(self, request: Union[str, Dict]) -> Any: return await self.protocol.query(request=request) @property - @abstractmethod def children(self) -> Sequence["Device"]: """Returns the child devices.""" + return list(self._children.values()) + + def get_child_device(self, id_: str) -> "Device": + """Return child device by its ID.""" + return self._children[id_] @property @abstractmethod diff --git a/kasa/device_type.py b/kasa/device_type.py index 162fc4f27..41dd6e363 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -14,6 +14,7 @@ class DeviceType(Enum): StripSocket = "stripsocket" Dimmer = "dimmer" LightStrip = "lightstrip" + Sensor = "sensor" Unknown = "unknown" @staticmethod diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index b70fbff00..5bbb95058 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -16,7 +16,7 @@ import inspect import logging from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Sequence, Set +from typing import Any, Dict, List, Mapping, Optional, Sequence, Set from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig @@ -183,19 +183,14 @@ def __init__( super().__init__(host=host, config=config, protocol=protocol) self._sys_info: Any = None # TODO: this is here to avoid changing tests - self._children: Sequence["IotDevice"] = [] self._supported_modules: Optional[Dict[str, IotModule]] = None self._legacy_features: Set[str] = set() + self._children: Mapping[str, "IotDevice"] = {} @property def children(self) -> Sequence["IotDevice"]: """Return list of children.""" - return self._children - - @children.setter - def children(self, children): - """Initialize from a list of children.""" - self._children = children + return list(self._children.values()) def add_module(self, name: str, module: IotModule): """Register a module.""" @@ -408,15 +403,6 @@ def model(self) -> str: sys_info = self._sys_info return str(sys_info["model"]) - @property - def has_children(self) -> bool: - """Return true if the device has children devices.""" - # Ideally we would check for the 'child_num' key in sys_info, - # but devices that speak klap do not populate this key via - # update_from_discover_info so we check for the devices - # we know have children instead. - return self.is_strip - @property # type: ignore def alias(self) -> Optional[str]: """Return device name (alias).""" diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 2c62b754b..4bf31cc76 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -115,10 +115,12 @@ async def update(self, update_children: bool = True): if not self.children: children = self.sys_info["children"] _LOGGER.debug("Initializing %s child sockets", len(children)) - self.children = [ - IotStripPlug(self.host, parent=self, child_id=child["id"]) + self._children = { + f"{self.mac}_{child['id']}": IotStripPlug( + self.host, parent=self, child_id=child["id"] + ) for child in children - ] + } if update_children and self.has_emeter: for plug in self.children: diff --git a/kasa/smart/modules/childdevicemodule.py b/kasa/smart/modules/childdevicemodule.py index 991acc25b..62e024d0c 100644 --- a/kasa/smart/modules/childdevicemodule.py +++ b/kasa/smart/modules/childdevicemodule.py @@ -1,4 +1,6 @@ """Implementation for child devices.""" +from typing import Dict + from ..smartmodule import SmartModule @@ -6,4 +8,12 @@ class ChildDeviceModule(SmartModule): """Implementation for child devices.""" REQUIRED_COMPONENT = "child_device" - QUERY_GETTER_NAME = "get_child_device_list" + + def query(self) -> Dict: + """Query to execute during the update cycle.""" + # TODO: There is no need to fetch the component list every time, + # so this should be optimized only for the init. + return { + "get_child_device_list": None, + "get_child_device_component_list": None, + } diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 698982b67..6d7bfa587 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -1,4 +1,5 @@ """Child device implementation.""" +import logging from typing import Optional from ..device_type import DeviceType @@ -6,6 +7,8 @@ from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper from .smartdevice import SmartDevice +_LOGGER = logging.getLogger(__name__) + class SmartChildDevice(SmartDevice): """Presentation of a child device. @@ -16,23 +19,41 @@ class SmartChildDevice(SmartDevice): def __init__( self, parent: SmartDevice, - child_id: str, + info, + component_info, config: Optional[DeviceConfig] = None, protocol: Optional[SmartProtocol] = None, ) -> None: super().__init__(parent.host, config=parent.config, protocol=parent.protocol) self._parent = parent - self._id = child_id - self.protocol = _ChildProtocolWrapper(child_id, parent.protocol) - self._device_type = DeviceType.StripSocket + self._update_internal_state(info) + self._components = component_info + self._id = info["device_id"] + self.protocol = _ChildProtocolWrapper(self._id, parent.protocol) async def update(self, update_children: bool = True): """Noop update. The parent updates our internals.""" - def update_internal_state(self, info): - """Set internal state for the child.""" - # TODO: cleanup the _last_update, _sys_info, _info, _data mess. - self._last_update = self._sys_info = self._info = info + @classmethod + async def create(cls, parent: SmartDevice, child_info, child_components): + """Create a child device based on device info and component listing.""" + child: "SmartChildDevice" = cls(parent, child_info, child_components) + await child._initialize_modules() + await child._initialize_features() + return child + + @property + def device_type(self) -> DeviceType: + """Return child device type.""" + child_device_map = { + "plug.powerstrip.sub-plug": DeviceType.Plug, + "subg.trigger.temp-hmdt-sensor": DeviceType.Sensor, + } + dev_type = child_device_map.get(self.sys_info["category"]) + if dev_type is None: + _LOGGER.warning("Unknown child device type, please open issue ") + dev_type = DeviceType.Unknown + return dev_type def __repr__(self): return f"" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index ab45eb425..c5c12fedb 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -2,7 +2,7 @@ import base64 import logging from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, cast +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Sequence, cast from ..aestransport import AesTransport from ..device import Device, WifiNetwork @@ -12,22 +12,12 @@ from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..feature import Feature, FeatureType from ..smartprotocol import SmartProtocol -from .modules import ( # noqa: F401 - AutoOffModule, - ChildDeviceModule, - CloudModule, - DeviceModule, - EnergyModule, - LedModule, - LightTransitionModule, - TimeModule, -) -from .smartmodule import SmartModule +from .modules import * # noqa: F403 _LOGGER = logging.getLogger(__name__) if TYPE_CHECKING: - from .smartchilddevice import SmartChildDevice + from .smartmodule import SmartModule class SmartDevice(Device): @@ -47,23 +37,34 @@ def __init__( self.protocol: SmartProtocol self._components_raw: Optional[Dict[str, Any]] = None self._components: Dict[str, int] = {} - self._children: Dict[str, "SmartChildDevice"] = {} self._state_information: Dict[str, Any] = {} - self.modules: Dict[str, SmartModule] = {} + self.modules: Dict[str, "SmartModule"] = {} + self._parent: Optional["SmartDevice"] = None + self._children: Mapping[str, "SmartDevice"] = {} async def _initialize_children(self): """Initialize children for power strips.""" - children = self._last_update["child_info"]["child_device_list"] - # TODO: Use the type information to construct children, - # as hubs can also have them. + children = self.internal_state["child_info"]["child_device_list"] + children_components = { + child["device_id"]: { + comp["id"]: int(comp["ver_code"]) for comp in child["component_list"] + } + for child in self.internal_state["get_child_device_component_list"][ + "child_component_list" + ] + } from .smartchilddevice import SmartChildDevice self._children = { - child["device_id"]: SmartChildDevice( - parent=self, child_id=child["device_id"] + child_info["device_id"]: await SmartChildDevice.create( + parent=self, + child_info=child_info, + child_components=children_components[child_info["device_id"]], ) - for child in children + for child_info in children } + # TODO: if all are sockets, then we are a strip, and otherwise a hub? + # doesn't work for the walldimmer with fancontrol... self._device_type = DeviceType.Strip @property @@ -126,8 +127,10 @@ async def update(self, update_children: bool = True): if not self.children: await self._initialize_children() + # TODO: we don't currently perform queries on children based on modules, + # but just update the information that is returned in the main query. for info in child_info["child_device_list"]: - self._children[info["device_id"]].update_internal_state(info) + self._children[info["device_id"]]._update_internal_state(info) # We can first initialize the features after the first update. # We make here an assumption that every device has at least a single feature. @@ -153,6 +156,7 @@ async def _initialize_modules(self): async def _initialize_features(self): """Initialize device features.""" + self._add_feature(Feature(self, "Device ID", attribute_getter="device_id")) if "device_on" in self._info: self._add_feature( Feature( @@ -164,25 +168,32 @@ async def _initialize_features(self): ) ) - self._add_feature( - Feature( - self, - "Signal Level", - attribute_getter=lambda x: x._info["signal_level"], - icon="mdi:signal", + if "signal_level" in self._info: + self._add_feature( + Feature( + self, + "Signal Level", + attribute_getter=lambda x: x._info["signal_level"], + icon="mdi:signal", + ) ) - ) - self._add_feature( - Feature( - self, - "RSSI", - attribute_getter=lambda x: x._info["rssi"], - icon="mdi:signal", + + if "rssi" in self._info: + self._add_feature( + Feature( + self, + "RSSI", + attribute_getter=lambda x: x._info["rssi"], + icon="mdi:signal", + ) + ) + + if "ssid" in self._info: + self._add_feature( + Feature( + device=self, name="SSID", attribute_getter="ssid", icon="mdi:wifi" + ) ) - ) - self._add_feature( - Feature(device=self, name="SSID", attribute_getter="ssid", icon="mdi:wifi") - ) if "overheated" in self._info: self._add_feature( @@ -232,7 +243,12 @@ def alias(self) -> Optional[str]: @property def time(self) -> datetime: """Return the time.""" - _timemod = cast(TimeModule, self.modules["TimeModule"]) + # TODO: Default to parent's time module for child devices + if self._parent and "TimeModule" in self.modules: + _timemod = cast(TimeModule, self._parent.modules["TimeModule"]) # noqa: F405 + else: + _timemod = cast(TimeModule, self.modules["TimeModule"]) # noqa: F405 + return _timemod.time @property @@ -284,6 +300,14 @@ def internal_state(self) -> Any: """Return all the internal state data.""" return self._last_update + def _update_internal_state(self, info): + """Update internal state. + + This is used by the parent to push updates to its children + """ + # TODO: cleanup the _last_update, _info mess. + self._last_update = self._info = info + async def _query_helper( self, method: str, params: Optional[Dict] = None, child_ids=None ) -> Any: @@ -347,19 +371,19 @@ async def get_emeter_realtime(self) -> EmeterStatus: @property def emeter_realtime(self) -> EmeterStatus: """Get the emeter status.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) + energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405 return energy.emeter_realtime @property def emeter_this_month(self) -> Optional[float]: """Get the emeter value for this month.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) + energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405 return energy.emeter_this_month @property def emeter_today(self) -> Optional[float]: """Get the emeter value for today.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) + energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405 return energy.emeter_today @property @@ -372,7 +396,7 @@ def on_since(self) -> Optional[datetime]: return None on_time = cast(float, on_time) if (timemod := self.modules.get("TimeModule")) is not None: - timemod = cast(TimeModule, timemod) + timemod = cast(TimeModule, timemod) # noqa: F405 return timemod.time - timedelta(seconds=on_time) else: # We have no device time, use current local time. return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 791383e8a..b557f4934 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -57,16 +57,25 @@ def data(self): """ q = self.query() q_keys = list(q.keys()) + query_key = q_keys[0] + + dev = self._device + # TODO: hacky way to check if update has been called. - if q_keys[0] not in self._device._last_update: - raise KasaException( - f"You need to call update() prior accessing module data" - f" for '{self._module}'" - ) - - filtered_data = { - k: v for k, v in self._device._last_update.items() if k in q_keys - } + # The way this falls back to parent may not always be wanted. + # Especially, devices can have their own firmware updates. + if query_key not in dev._last_update: + if dev._parent and query_key in dev._parent._last_update: + _LOGGER.debug("%s not found child, but found on parent", query_key) + dev = dev._parent + else: + raise KasaException( + f"You need to call update() prior accessing module data" + f" for '{self._module}'" + ) + + filtered_data = {k: v for k, v in dev._last_update.items() if k in q_keys} + if len(filtered_data) == 1: return next(iter(filtered_data.values())) diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 78863def3..6ffd70549 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -47,7 +47,6 @@ async def test_childdevice_properties(dev: SmartChildDevice): assert len(dev.children) > 0 first = dev.children[0] - assert first.is_strip_socket # children do not have children assert not first.children diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 8bffef7d6..4c0d17e13 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -19,6 +19,7 @@ alias, brightness, cli, + cmd_command, emeter, raw_command, reboot, @@ -136,6 +137,32 @@ async def test_raw_command(dev, mocker): assert "Usage" in res.output +async def test_command_with_child(dev, mocker): + """Test 'command' command with --child.""" + runner = CliRunner() + update_mock = mocker.patch.object(dev, "update") + + dummy_child = mocker.create_autospec(IotDevice) + query_mock = mocker.patch.object( + dummy_child, "_query_helper", return_value={"dummy": "response"} + ) + + mocker.patch.object(dev, "_children", {"XYZ": dummy_child}) + mocker.patch.object(dev, "get_child_device", return_value=dummy_child) + + res = await runner.invoke( + cmd_command, + ["--child", "XYZ", "command", "'params'"], + obj=dev, + catch_exceptions=False, + ) + + update_mock.assert_called() + query_mock.assert_called() + assert '{"dummy": "response"}' in res.output + assert res.exit_code == 0 + + @device_smart async def test_reboot(dev, mocker): """Test that reboot works on SMART devices.""" diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index a94123809..92cca5a16 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -37,6 +37,7 @@ lightstrip, no_emeter_iot, plug, + strip, turn_on, ) from .fakeprotocol_iot import FakeIotProtocol @@ -201,13 +202,12 @@ async def test_representation(dev): assert pattern.match(str(dev)) -@device_iot -async def test_childrens(dev): - """Make sure that children property is exposed by every device.""" - if dev.is_strip: - assert len(dev.children) > 0 - else: - assert len(dev.children) == 0 +@strip +def test_children_api(dev): + """Test the child device API.""" + first = dev.children[0] + first_by_get_child_device = dev.get_child_device(first.device_id) + assert first == first_by_get_child_device @device_iot @@ -215,10 +215,8 @@ async def test_children(dev): """Make sure that children property is exposed by every device.""" if dev.is_strip: assert len(dev.children) > 0 - assert dev.has_children is True else: assert len(dev.children) == 0 - assert dev.has_children is False @device_iot @@ -260,7 +258,9 @@ async def test_device_class_ctors(device_class_name_obj): klass = device_class_name_obj[1] if issubclass(klass, SmartChildDevice): parent = SmartDevice(host, config=config) - dev = klass(parent, 1) + dev = klass( + parent, {"dummy": "info", "device_id": "dummy"}, {"dummy": "components"} + ) else: dev = klass(host, config=config) assert dev.host == host From 951d41a6283e8b669bfba890277e627d8c18b139 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 22 Feb 2024 20:57:42 +0100 Subject: [PATCH 339/892] Fix auto update switch (#786) Set the attribute_setter. Also, (at least some) devices expect the full payload data so send it with. --- kasa/smart/modules/firmware.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 541b0b7ab..80eca4df1 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -54,6 +54,7 @@ def __init__(self, device: "SmartDevice", module: str): "Auto update enabled", container=self, attribute_getter="auto_update_enabled", + attribute_setter="set_auto_update_enabled", type=FeatureType.Switch, ) ) @@ -101,4 +102,5 @@ def auto_update_enabled(self): async def set_auto_update_enabled(self, enabled: bool): """Change autoupdate setting.""" - await self.call("set_auto_update_info", {"enable": enabled}) + data = {**self.data["get_auto_update_info"], "enable": enabled} + await self.call("set_auto_update_info", data) #{"enable": enabled}) From bc65f96f85e68065f73f256631989073d6654386 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 22 Feb 2024 23:09:38 +0100 Subject: [PATCH 340/892] Add initial support for H100 and T315 (#776) Adds initial support for H100 and its alarmmodule. Also implements the following modules for T315: * reportmodule (reporting interval) * battery * humidity * temperature --- kasa/cli.py | 2 +- kasa/device_factory.py | 1 + kasa/device_type.py | 1 + kasa/deviceconfig.py | 1 + kasa/smart/modules/__init__.py | 10 ++++ kasa/smart/modules/alarmmodule.py | 87 ++++++++++++++++++++++++++++++ kasa/smart/modules/battery.py | 47 ++++++++++++++++ kasa/smart/modules/humidity.py | 47 ++++++++++++++++ kasa/smart/modules/reportmodule.py | 31 +++++++++++ kasa/smart/modules/temperature.py | 57 ++++++++++++++++++++ kasa/smart/smartbulb.py | 13 ----- kasa/smart/smartdevice.py | 11 ++-- kasa/tests/conftest.py | 4 +- kasa/tests/test_childdevice.py | 1 - 14 files changed, 292 insertions(+), 21 deletions(-) create mode 100644 kasa/smart/modules/alarmmodule.py create mode 100644 kasa/smart/modules/battery.py create mode 100644 kasa/smart/modules/humidity.py create mode 100644 kasa/smart/modules/reportmodule.py create mode 100644 kasa/smart/modules/temperature.py diff --git a/kasa/cli.py b/kasa/cli.py index 16e64a050..c8624966e 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -581,7 +581,7 @@ async def state(ctx, dev: Device): echo(f"\tHost: {dev.host}") echo(f"\tPort: {dev.port}") echo(f"\tDevice state: {dev.is_on}") - if dev.is_strip: + if dev.children: echo("\t[bold]== Children ==[/bold]") for child in dev.children: echo(f"\t* {child.alias} ({child.model}, {child.device_type})") diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 66903468a..2e8ba0c98 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -139,6 +139,7 @@ def get_device_class_from_family(device_type: str) -> Optional[Type[Device]]: "SMART.TAPOBULB": SmartBulb, "SMART.TAPOSWITCH": SmartBulb, "SMART.KASAPLUG": SmartDevice, + "SMART.TAPOHUB": SmartDevice, "SMART.KASASWITCH": SmartBulb, "IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTBULB": IotBulb, diff --git a/kasa/device_type.py b/kasa/device_type.py index 41dd6e363..a44efffa8 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -15,6 +15,7 @@ class DeviceType(Enum): Dimmer = "dimmer" LightStrip = "lightstrip" Sensor = "sensor" + Hub = "hub" Unknown = "unknown" @staticmethod diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index af809ac21..c55265b4c 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -31,6 +31,7 @@ class DeviceFamilyType(Enum): SmartTapoPlug = "SMART.TAPOPLUG" SmartTapoBulb = "SMART.TAPOBULB" SmartTapoSwitch = "SMART.TAPOSWITCH" + SmartTapoHub = "SMART.TAPOHUB" def _dataclass_from_dict(klass, in_val): diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 02c3b86af..3e95dfe78 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -1,19 +1,29 @@ """Modules for SMART devices.""" +from .alarmmodule import AlarmModule from .autooffmodule import AutoOffModule +from .battery import BatterySensor from .childdevicemodule import ChildDeviceModule from .cloudmodule import CloudModule from .devicemodule import DeviceModule from .energymodule import EnergyModule from .firmware import Firmware +from .humidity import HumiditySensor from .ledmodule import LedModule from .lighttransitionmodule import LightTransitionModule +from .reportmodule import ReportModule +from .temperature import TemperatureSensor from .timemodule import TimeModule __all__ = [ + "AlarmModule", "TimeModule", "EnergyModule", "DeviceModule", "ChildDeviceModule", + "BatterySensor", + "HumiditySensor", + "TemperatureSensor", + "ReportModule", "AutoOffModule", "LedModule", "Firmware", diff --git a/kasa/smart/modules/alarmmodule.py b/kasa/smart/modules/alarmmodule.py new file mode 100644 index 000000000..637c44973 --- /dev/null +++ b/kasa/smart/modules/alarmmodule.py @@ -0,0 +1,87 @@ +"""Implementation of alarm module.""" +from typing import TYPE_CHECKING, Dict, List, Optional + +from ...feature import Feature, FeatureType +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class AlarmModule(SmartModule): + """Implementation of alarm module.""" + + REQUIRED_COMPONENT = "alarm" + + def query(self) -> Dict: + """Query to execute during the update cycle.""" + return { + "get_alarm_configure": None, + "get_support_alarm_type_list": None, # This should be needed only once + } + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Alarm", + container=self, + attribute_getter="active", + icon="mdi:bell", + type=FeatureType.BinarySensor, + ) + ) + self._add_feature( + Feature( + device, + "Alarm source", + container=self, + attribute_getter="source", + icon="mdi:bell", + ) + ) + self._add_feature( + Feature( + device, "Alarm sound", container=self, attribute_getter="alarm_sound" + ) + ) + self._add_feature( + Feature( + device, "Alarm volume", container=self, attribute_getter="alarm_volume" + ) + ) + + @property + def alarm_sound(self): + """Return current alarm sound.""" + return self.data["get_alarm_configure"]["type"] + + @property + def alarm_sounds(self) -> List[str]: + """Return list of available alarm sounds.""" + return self.data["get_support_alarm_type_list"]["alarm_type_list"] + + @property + def alarm_volume(self): + """Return alarm volume.""" + return self.data["get_alarm_configure"]["volume"] + + @property + def active(self) -> bool: + """Return true if alarm is active.""" + return self._device.sys_info["in_alarm"] + + @property + def source(self) -> Optional[str]: + """Return the alarm cause.""" + src = self._device.sys_info["in_alarm_source"] + return src if src else None + + async def play(self): + """Play alarm.""" + return self.call("play_alarm") + + async def stop(self): + """Stop alarm.""" + return self.call("stop_alarm") diff --git a/kasa/smart/modules/battery.py b/kasa/smart/modules/battery.py new file mode 100644 index 000000000..accf875b2 --- /dev/null +++ b/kasa/smart/modules/battery.py @@ -0,0 +1,47 @@ +"""Implementation of battery module.""" +from typing import TYPE_CHECKING + +from ...feature import Feature, FeatureType +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class BatterySensor(SmartModule): + """Implementation of battery module.""" + + REQUIRED_COMPONENT = "battery_detect" + QUERY_GETTER_NAME = "get_battery_detect_info" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Battery level", + container=self, + attribute_getter="battery", + icon="mdi:battery", + ) + ) + self._add_feature( + Feature( + device, + "Battery low", + container=self, + attribute_getter="battery_low", + icon="mdi:alert", + type=FeatureType.BinarySensor, + ) + ) + + @property + def battery(self): + """Return battery level.""" + return self._device.sys_info["battery_percentage"] + + @property + def battery_low(self): + """Return True if battery is low.""" + return self._device.sys_info["at_low_battery"] diff --git a/kasa/smart/modules/humidity.py b/kasa/smart/modules/humidity.py new file mode 100644 index 000000000..454bedcda --- /dev/null +++ b/kasa/smart/modules/humidity.py @@ -0,0 +1,47 @@ +"""Implementation of humidity module.""" +from typing import TYPE_CHECKING + +from ...feature import Feature, FeatureType +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class HumiditySensor(SmartModule): + """Implementation of humidity module.""" + + REQUIRED_COMPONENT = "humidity" + QUERY_GETTER_NAME = "get_comfort_humidity_config" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Humidity", + container=self, + attribute_getter="humidity", + icon="mdi:water-percent", + ) + ) + self._add_feature( + Feature( + device, + "Humidity warning", + container=self, + attribute_getter="humidity_warning", + type=FeatureType.BinarySensor, + icon="mdi:alert", + ) + ) + + @property + def humidity(self): + """Return current humidity in percentage.""" + return self._device.sys_info["current_humidity"] + + @property + def humidity_warning(self) -> bool: + """Return true if humidity is outside of the wanted range.""" + return self._device.sys_info["current_humidity_exception"] != 0 diff --git a/kasa/smart/modules/reportmodule.py b/kasa/smart/modules/reportmodule.py new file mode 100644 index 000000000..04301bb4c --- /dev/null +++ b/kasa/smart/modules/reportmodule.py @@ -0,0 +1,31 @@ +"""Implementation of report module.""" +from typing import TYPE_CHECKING + +from ...feature import Feature +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class ReportModule(SmartModule): + """Implementation of report module.""" + + REQUIRED_COMPONENT = "report_mode" + QUERY_GETTER_NAME = "get_report_mode" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Report interval", + container=self, + attribute_getter="report_interval", + ) + ) + + @property + def report_interval(self): + """Reporting interval of a sensor device.""" + return self._device.sys_info["report_interval"] diff --git a/kasa/smart/modules/temperature.py b/kasa/smart/modules/temperature.py new file mode 100644 index 000000000..659fb7dbe --- /dev/null +++ b/kasa/smart/modules/temperature.py @@ -0,0 +1,57 @@ +"""Implementation of temperature module.""" +from typing import TYPE_CHECKING, Literal + +from ...feature import Feature, FeatureType +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class TemperatureSensor(SmartModule): + """Implementation of temperature module.""" + + REQUIRED_COMPONENT = "humidity" + QUERY_GETTER_NAME = "get_comfort_temp_config" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Temperature", + container=self, + attribute_getter="temperature", + icon="mdi:thermometer", + ) + ) + self._add_feature( + Feature( + device, + "Temperature warning", + container=self, + attribute_getter="temperature_warning", + type=FeatureType.BinarySensor, + icon="mdi:alert", + ) + ) + # TODO: use temperature_unit for feature creation + + @property + def temperature(self): + """Return current humidity in percentage.""" + return self._device.sys_info["current_temp"] + + @property + def temperature_warning(self) -> bool: + """Return True if humidity is outside of the wanted range.""" + return self._device.sys_info["current_temp_exception"] != 0 + + @property + def temperature_unit(self): + """Return current temperature unit.""" + return self._device.sys_info["temp_unit"] + + async def set_temperature_unit(self, unit: Literal["celsius", "fahrenheit"]): + """Set the device temperature unit.""" + return await self.call("set_temperature_unit", {"temp_unit": unit}) diff --git a/kasa/smart/smartbulb.py b/kasa/smart/smartbulb.py index c6295eda6..eb3310e81 100644 --- a/kasa/smart/smartbulb.py +++ b/kasa/smart/smartbulb.py @@ -2,11 +2,8 @@ from typing import Any, Dict, List, Optional from ..bulb import Bulb -from ..device_type import DeviceType -from ..deviceconfig import DeviceConfig from ..exceptions import KasaException from ..iot.iotbulb import HSV, BulbPreset, ColorTempRange -from ..smartprotocol import SmartProtocol from .smartdevice import SmartDevice AVAILABLE_EFFECTS = { @@ -21,16 +18,6 @@ class SmartBulb(SmartDevice, Bulb): Documentation TBD. See :class:`~kasa.iot.Bulb` for now. """ - def __init__( - self, - host: str, - *, - config: Optional[DeviceConfig] = None, - protocol: Optional[SmartProtocol] = None, - ) -> None: - super().__init__(host=host, config=config, protocol=protocol) - self._device_type = DeviceType.Bulb - @property def is_color(self) -> bool: """Whether the bulb supports color changes.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index c5c12fedb..66db2c58c 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -63,9 +63,12 @@ async def _initialize_children(self): ) for child_info in children } - # TODO: if all are sockets, then we are a strip, and otherwise a hub? - # doesn't work for the walldimmer with fancontrol... - self._device_type = DeviceType.Strip + # TODO: This may not be the best approach, but it allows distinguishing + # between power strips and hubs for the time being. + if all(child.is_plug for child in self._children.values()): + self._device_type = DeviceType.Strip + else: + self._device_type = DeviceType.Hub @property def children(self) -> Sequence["SmartDevice"]: @@ -518,7 +521,7 @@ def device_type(self) -> DeviceType: if self.children: if "SMART.TAPOHUB" in self.sys_info["type"]: - pass # TODO: placeholder for future hub PR + self._device_type = DeviceType.Hub else: self._device_type = DeviceType.Strip elif "light_strip" in self._components: diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index e69b73fa9..39d5daf5c 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -47,7 +47,7 @@ BULBS_SMART_VARIABLE_TEMP = {"L530E", "L930-5"} BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5", "L930-5"} BULBS_SMART_COLOR = {"L530E", *BULBS_SMART_LIGHT_STRIP} -BULBS_SMART_DIMMABLE = {"KS225", "L510B", "L510E"} +BULBS_SMART_DIMMABLE = {"L510B", "L510E"} BULBS_SMART = ( BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) .union(BULBS_SMART_DIMMABLE) @@ -120,7 +120,7 @@ STRIPS = {*STRIPS_IOT, *STRIPS_SMART} DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} -DIMMERS_SMART = {"S500D", "P135"} +DIMMERS_SMART = {"KS225", "S500D", "P135"} DIMMERS = { *DIMMERS_IOT, *DIMMERS_SMART, diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 6ffd70549..07baf598b 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -13,7 +13,6 @@ def test_childdevice_init(dev, dummy_protocol, mocker): """Test that child devices get initialized and use protocol wrapper.""" assert len(dev.children) > 0 - assert dev.is_strip first = dev.children[0] assert isinstance(first.protocol, _ChildProtocolWrapper) From 7884436679f84093fb980849a5da42335690118a Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 23 Feb 2024 17:13:11 +0100 Subject: [PATCH 341/892] Add updated L530 fixture 1.1.6 (#792) --- .../fixtures/smart/L530E(EU)_3.0_1.1.6.json | 449 ++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json diff --git a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json new file mode 100644 index 000000000..48450fbeb --- /dev/null +++ b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json @@ -0,0 +1,449 @@ +{ + "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": 3 + }, + { + "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(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-E9-31-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": 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": "bulb", + "brightness": 100, + "color_temp": 2700, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.6 Build 240130 Rel.173828", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "de_DE", + "latitude": 0, + "longitude": 0, + "mac": "5C-E9-31-00-00-00", + "model": "L530", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -52, + "saturation": 100, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1708652415 + }, + "get_device_usage": { + "power_usage": { + "past30": 21, + "past7": 21, + "today": 21 + }, + "saved_power": { + "past30": 100, + "past7": 99, + "today": 99 + }, + "time_usage": { + "past30": 121, + "past7": 120, + "today": 120 + } + }, + "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.6 Build 240130 Rel.173828", + "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": true, + "max_duration": 60 + }, + "on_state": { + "duration": 2, + "enable": true, + "max_duration": 60 + } + }, + "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", + "is_klap": true + } + } +} From c61f2e931c4d3c4ed9033c5d166ab71936e8e239 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 23 Feb 2024 23:32:17 +0100 Subject: [PATCH 342/892] Add --child option to feature command (#789) This allows listing and changing child device features that were previously not accessible using the cli tool. --- kasa/cli.py | 25 ++++++++-- kasa/tests/conftest.py | 2 +- kasa/tests/test_cli.py | 105 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 126 insertions(+), 6 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index c8624966e..83980ec20 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1156,22 +1156,39 @@ async def shell(dev: Device): @cli.command(name="feature") @click.argument("name", required=False) @click.argument("value", required=False) +@click.option("--child", required=False) @pass_dev -async def feature(dev, name: str, value): +async def feature(dev: Device, child: str, name: str, value): """Access and modify features. If no *name* is given, lists available features and their values. If only *name* is given, the value of named feature is returned. If both *name* and *value* are set, the described setting is changed. """ + if child is not None: + echo(f"Targeting child device {child}") + dev = dev.get_child_device(child) if not name: + + def _print_features(dev): + for name, feat in dev.features.items(): + try: + echo(f"\t{feat.name} ({name}): {feat.value}") + except Exception as ex: + echo(f"\t{feat.name} ({name}): [red]{ex}[/red]") + echo("[bold]== Features ==[/bold]") - for name, feat in dev.features.items(): - echo(f"{feat.name} ({name}): {feat.value}") + _print_features(dev) + + if dev.children: + for child_dev in dev.children: + echo(f"[bold]== Child {child_dev.alias} ==") + _print_features(child_dev) + return if name not in dev.features: - echo(f"No feature by name {name}") + echo(f"No feature by name '{name}'") return feat = dev.features[name] diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 39d5daf5c..c67641081 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -573,7 +573,7 @@ async def mock_discover(self): yield discovery_data -@pytest.fixture() +@pytest.fixture def dummy_protocol(): """Return a smart protocol instance with a mocking-ready dummy transport.""" diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 4c0d17e13..6d156aec4 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -32,7 +32,14 @@ from kasa.discover import Discover, DiscoveryResult from kasa.iot import IotDevice -from .conftest import device_iot, device_smart, handle_turn_on, new_discovery, turn_on +from .conftest import ( + device_iot, + device_smart, + get_device_for_file, + handle_turn_on, + new_discovery, + turn_on, +) async def test_update_called_by_cli(dev, mocker): @@ -684,3 +691,99 @@ async def test_errors(mocker): ) assert res.exit_code == 2 assert "Raised error:" not in res.output + + +async def test_feature(mocker): + """Test feature command.""" + dummy_device = await get_device_for_file("P300(EU)_1.0_1.0.13.json", "SMART") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + runner = CliRunner() + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature"], + catch_exceptions=False, + ) + assert "LED" in res.output + assert "== Child " in res.output # child listing + + assert res.exit_code == 0 + + +async def test_feature_single(mocker): + """Test feature command returning single value.""" + dummy_device = await get_device_for_file("P300(EU)_1.0_1.0.13.json", "SMART") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + runner = CliRunner() + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature", "led"], + catch_exceptions=False, + ) + assert "LED" in res.output + assert "== Features ==" not in res.output + assert res.exit_code == 0 + +async def test_feature_missing(mocker): + """Test feature command returning single value.""" + dummy_device = await get_device_for_file("P300(EU)_1.0_1.0.13.json", "SMART") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + runner = CliRunner() + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature", "missing"], + catch_exceptions=False, + ) + assert "No feature by name 'missing'" in res.output + assert "== Features ==" not in res.output + assert res.exit_code == 0 + +async def test_feature_set(mocker): + """Test feature command's set value.""" + dummy_device = await get_device_for_file("P300(EU)_1.0_1.0.13.json", "SMART") + led_setter = mocker.patch("kasa.smart.modules.ledmodule.LedModule.set_led") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + + runner = CliRunner() + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature", "led", "True"], + catch_exceptions=False, + ) + + led_setter.assert_called_with(True) + assert "Setting led to True" in res.output + assert res.exit_code == 0 + + +async def test_feature_set_child(mocker): + """Test feature command's set value.""" + dummy_device = await get_device_for_file("P300(EU)_1.0_1.0.13.json", "SMART") + setter = mocker.patch("kasa.smart.smartdevice.SmartDevice.set_state") + + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + get_child_device = mocker.spy(dummy_device, "get_child_device") + + child_id = "000000000000000000000000000000000000000001" + + runner = CliRunner() + res = await runner.invoke( + cli, + [ + "--host", + "127.0.0.123", + "--debug", + "feature", + "--child", + child_id, + "state", + "False", + ], + catch_exceptions=False, + ) + + get_child_device.assert_called() + setter.assert_called_with(False) + + assert f"Targeting child device {child_id}" + assert "Setting state to False" in res.output + assert res.exit_code == 0 From c3aa34425d5d068659381b10ec14cb25f3e43d78 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 24 Feb 2024 00:05:06 +0100 Subject: [PATCH 343/892] Add temperature_unit feature to t315 (#788) --- kasa/smart/modules/temperature.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/kasa/smart/modules/temperature.py b/kasa/smart/modules/temperature.py index 659fb7dbe..c33e565b9 100644 --- a/kasa/smart/modules/temperature.py +++ b/kasa/smart/modules/temperature.py @@ -35,6 +35,15 @@ def __init__(self, device: "SmartDevice", module: str): icon="mdi:alert", ) ) + self._add_feature( + Feature( + device, + "Temperature unit", + container=self, + attribute_getter="temperature_unit", + attribute_setter="set_temperature_unit", + ) + ) # TODO: use temperature_unit for feature creation @property From a73e2a9ede191114b6cd47b761b840b3def1d6ec Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 24 Feb 2024 00:12:19 +0100 Subject: [PATCH 344/892] Add H100 fixtures (#737) One direct out of the box, another with upgraded fw & t315 --- README.md | 3 + devtools/dump_devinfo.py | 5 +- devtools/helpers/smartrequests.py | 11 +- kasa/smart/modules/firmware.py | 2 +- kasa/tests/conftest.py | 12 +- kasa/tests/fakeprotocol_smart.py | 16 + .../fixtures/smart/H100(EU)_1.0_1.2.3.json | 221 ++++++++++ .../fixtures/smart/H100(EU)_1.0_1.5.5.json | 391 ++++++++++++++++++ 8 files changed, 657 insertions(+), 4 deletions(-) create mode 100644 kasa/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json create mode 100644 kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json diff --git a/README.md b/README.md index db1bad2d1..4b45c822d 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,9 @@ At the moment, the following devices have been confirmed to work: * Tapo P300 * Tapo TP25 +#### Hubs + +* Tapo H100 ### Newer Kasa branded devices diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 5ab736e9c..8e0126061 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -65,7 +65,10 @@ def scrub(res): "alias", "bssid", "channel", - "original_device_id", # for child devices + "original_device_id", # for child devices on strips + "parent_device_id", # for hub children + "setup_code", # matter + "setup_payload", # matter ] for k, v in res.items(): diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 279925190..29298e2e0 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -356,7 +356,7 @@ def get_component_requests(component_id, ver_code): "energy_monitoring": SmartRequest.energy_monitoring_list(), "power_protection": SmartRequest.power_protection_list(), "current_protection": [], # overcurrent in device_info - "matter": [], + "matter": [SmartRequest.get_raw_request("get_matter_setup_info")], "preset": [SmartRequest.get_preset_rules()], "brightness": [], # in device_info "color": [], # in device_info @@ -372,4 +372,13 @@ def get_component_requests(component_id, ver_code): "music_rhythm": [], # music_rhythm_enable in device_info "segment": [SmartRequest.get_raw_request("get_device_segment")], "segment_effect": [SmartRequest.get_raw_request("get_segment_effect_rule")], + "device_load": [SmartRequest.get_raw_request("get_device_load_info")], + "child_quick_setup": [ + SmartRequest.get_raw_request("get_support_child_device_category") + ], + "alarm": [ + SmartRequest.get_raw_request("get_support_alarm_type_list"), + SmartRequest.get_raw_request("get_alarm_configure"), + ], + "alarm_logs": [SmartRequest.get_raw_request("get_alarm_triggers")], } diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 80eca4df1..4d1f846cc 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -103,4 +103,4 @@ def auto_update_enabled(self): async def set_auto_update_enabled(self, enabled: bool): """Change autoupdate setting.""" data = {**self.data["get_auto_update_info"], "enable": enabled} - await self.call("set_auto_update_info", data) #{"enable": enabled}) + await self.call("set_auto_update_info", data) # {"enable": enabled}) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index c67641081..431da4631 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -126,6 +126,8 @@ *DIMMERS_SMART, } +HUBS_SMART = {"H100"} + WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} WITH_EMETER_SMART = {"P110", "KP125M", "EP25"} WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} @@ -134,7 +136,10 @@ ALL_DEVICES_IOT = BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT) ALL_DEVICES_SMART = ( - BULBS_SMART.union(PLUGS_SMART).union(STRIPS_SMART).union(DIMMERS_SMART) + BULBS_SMART.union(PLUGS_SMART) + .union(STRIPS_SMART) + .union(DIMMERS_SMART) + .union(HUBS_SMART) ) ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) @@ -257,6 +262,7 @@ def parametrize(desc, devices, protocol_filter=None, ids=None): dimmers_smart = parametrize( "dimmer devices smart", DIMMERS_SMART, protocol_filter={"SMART"} ) +hubs_smart = parametrize("hubs smart", HUBS_SMART, protocol_filter={"SMART"}) device_smart = parametrize( "devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"} ) @@ -318,6 +324,7 @@ def check_categories(): + plug_smart.args[1] + bulb_smart.args[1] + dimmers_smart.args[1] + + hubs_smart.args[1] ) diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures) if diff: @@ -355,6 +362,9 @@ def device_for_file(model, protocol): for d in STRIPS_SMART: if d in model: return SmartDevice + for d in HUBS_SMART: + if d in model: + return SmartDevice else: for d in STRIPS_IOT: if d in model: diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 6e59ba3d8..a164b7355 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -80,6 +80,22 @@ def credentials_hash(self): "firmware", {"enable": True, "random_range": 120, "time": 180}, ), + "get_alarm_configure": ( + "alarm", + { + "get_alarm_configure": { + "duration": 10, + "type": "Doorbell Ring 2", + "volume": "low", + } + }, + ), + "get_support_alarm_type_list": ("alarm", { + "alarm_type_list": [ + "Doorbell Ring 1", + ] + }), + "get_device_usage": ("device", {}), } async def send(self, request: str): diff --git a/kasa/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json b/kasa/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json new file mode 100644 index 000000000..4d4936c6c --- /dev/null +++ b/kasa/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json @@ -0,0 +1,221 @@ +{ + "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": "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": "child_device", + "ver_code": 1 + }, + { + "id": "child_quick_setup", + "ver_code": 1 + }, + { + "id": "child_inherit", + "ver_code": 1 + }, + { + "id": "control_child", + "ver_code": 1 + }, + { + "id": "alarm", + "ver_code": 1 + }, + { + "id": "device_load", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "alarm_logs", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "H100(EU)", + "device_type": "SMART.TAPOHUB", + "factory_default": true, + "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": "" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [], + "start_index": 0, + "sum": 0 + }, + "get_child_device_list": { + "child_device_list": [], + "start_index": 0, + "sum": 0 + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.3 Build 221012 Rel.103821", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "in_alarm_source": "", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "H100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "", + "rssi": -30, + "signal_level": 3, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOHUB" + }, + "get_device_time": { + "region": "", + "time_diff": 0, + "timestamp": 946771480 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "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==" + } + ], + "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": "H100", + "device_type": "SMART.TAPOHUB" + } + } +} diff --git a/kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json b/kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json new file mode 100644 index 000000000..639122bd0 --- /dev/null +++ b/kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json @@ -0,0 +1,391 @@ +{ + "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": "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": "child_device", + "ver_code": 1 + }, + { + "id": "child_quick_setup", + "ver_code": 1 + }, + { + "id": "child_inherit", + "ver_code": 1 + }, + { + "id": "control_child", + "ver_code": 1 + }, + { + "id": "alarm", + "ver_code": 1 + }, + { + "id": "device_load", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "alarm_logs", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 3 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "H100(EU)", + "device_type": "SMART.TAPOHUB", + "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_alarm_configure": { + "duration": 10, + "type": "Doorbell Ring 2", + "volume": "low" + }, + "get_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ], + "device_id": "0000000000000000000000000000000000000000" + } + ], + "start_index": 0, + "sum": 1 + }, + "get_child_device_list": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 56, + "current_humidity_exception": -34, + "current_temp": 22.2, + "current_temp_exception": 0, + "device_id": "0000000000000000000000000000000000000000", + "fw_ver": "1.7.0 Build 230424 Rel.170332", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -118, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1706990901, + "mac": "F0A731000000", + "model": "T315", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 16, + "rssi": -45, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + } + ], + "start_index": 0, + "sum": 1 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "hub", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.5.5 Build 240105 Rel.192438", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "in_alarm_source": "", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "H100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -62, + "signal_level": 2, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOHUB" + }, + "get_device_load_info": { + "cur_load_num": 2, + "load_level": "light", + "max_load_num": 64, + "total_memory": 4352, + "used_memory": 1384 + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1706995844 + }, + "get_device_usage": {}, + "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.5.5 Build 240105 Rel.192438", + "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": 485, + "night_mode_type": "sunrise_sunset", + "start_time": 1046, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_support_alarm_type_list": { + "alarm_type_list": [ + "Doorbell Ring 1", + "Doorbell Ring 2", + "Doorbell Ring 3", + "Doorbell Ring 4", + "Doorbell Ring 5", + "Doorbell Ring 6", + "Doorbell Ring 7", + "Doorbell Ring 8", + "Doorbell Ring 9", + "Doorbell Ring 10", + "Phone Ring", + "Alarm 1", + "Alarm 2", + "Alarm 3", + "Alarm 4", + "Dripping Tap", + "Alarm 5", + "Connection 1", + "Connection 2" + ] + }, + "get_support_child_device_category": { + "device_category_list": [ + { + "category": "subg.trv" + }, + { + "category": "subg.trigger" + }, + { + "category": "subg.plugswitch" + } + ] + }, + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 3 + } + ], + "extra_info": { + "device_model": "H100", + "device_type": "SMART.TAPOHUB", + "is_klap": false + } + } +} From cbf82c94988d115fb81226faa6c9f27948d40730 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 24 Feb 2024 02:16:43 +0100 Subject: [PATCH 345/892] Support for on_off_gradually v2+ (#793) Previously, only v1 of on_off_gradually is supported, and the newer versions are not backwards compatible. This PR adds support for the newer versions of the component, and implements `number` type for `Feature` to expose the transition time selection. This also adds a new `supported_version` property to the main module API. --- kasa/feature.py | 14 ++ kasa/smart/modules/devicemodule.py | 2 +- kasa/smart/modules/lighttransitionmodule.py | 152 ++++++++++++++++++-- kasa/smart/smartmodule.py | 5 + 4 files changed, 158 insertions(+), 15 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index 420fd8485..df28c952c 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -14,6 +14,7 @@ class FeatureType(Enum): BinarySensor = auto() Switch = auto() Button = auto() + Number = auto() @dataclass @@ -35,6 +36,12 @@ class Feature: #: Type of the feature type: FeatureType = FeatureType.Sensor + # Number-specific attributes + #: Minimum value + minimum_value: int = 0 + #: Maximum value + maximum_value: int = 2**16 # Arbitrary max + @property def value(self): """Return the current value.""" @@ -47,5 +54,12 @@ async def set_value(self, value): """Set the value.""" if self.attribute_setter is None: raise ValueError("Tried to set read-only feature.") + if self.type == FeatureType.Number: # noqa: SIM102 + if value < self.minimum_value or value > self.maximum_value: + raise ValueError( + f"Value {value} out of range " + f"[{self.minimum_value}, {self.maximum_value}]" + ) + container = self.container if self.container is not None else self.device return await getattr(container, self.attribute_setter)(value) diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py index 80e7287f0..e36c09fed 100644 --- a/kasa/smart/modules/devicemodule.py +++ b/kasa/smart/modules/devicemodule.py @@ -15,7 +15,7 @@ def query(self) -> Dict: "get_device_info": None, } # Device usage is not available on older firmware versions - if self._device._components[self.REQUIRED_COMPONENT] >= 2: + if self.supported_version >= 2: query["get_device_usage"] = None return query diff --git a/kasa/smart/modules/lighttransitionmodule.py b/kasa/smart/modules/lighttransitionmodule.py index ef8739bcf..f98f21ca8 100644 --- a/kasa/smart/modules/lighttransitionmodule.py +++ b/kasa/smart/modules/lighttransitionmodule.py @@ -1,6 +1,7 @@ """Module for smooth light transitions.""" from typing import TYPE_CHECKING +from ...exceptions import KasaException from ...feature import Feature, FeatureType from ..smartmodule import SmartModule @@ -13,29 +14,152 @@ class LightTransitionModule(SmartModule): REQUIRED_COMPONENT = "on_off_gradually" QUERY_GETTER_NAME = "get_on_off_gradually_info" + MAXIMUM_DURATION = 60 def __init__(self, device: "SmartDevice", module: str): super().__init__(device, module) - self._add_feature( - Feature( - device=device, - container=self, - name="Smooth transitions", - icon="mdi:transition", - attribute_getter="enabled", - attribute_setter="set_enabled", - type=FeatureType.Switch, + self._create_features() + + def _create_features(self): + """Create features based on the available version.""" + icon = "mdi:transition" + if self.supported_version == 1: + self._add_feature( + Feature( + device=self._device, + container=self, + name="Smooth transitions", + icon=icon, + attribute_getter="enabled_v1", + attribute_setter="set_enabled_v1", + type=FeatureType.Switch, + ) + ) + elif self.supported_version >= 2: + # v2 adds separate on & off states + # v3 adds max_duration + # TODO: note, hardcoding the maximums for now as the features get + # initialized before the first update. + self._add_feature( + Feature( + self._device, + "Smooth transition on", + container=self, + attribute_getter="turn_on_transition", + attribute_setter="set_turn_on_transition", + icon=icon, + type=FeatureType.Number, + maximum_value=self.MAXIMUM_DURATION, + ) + ) # self._turn_on_transition_max + self._add_feature( + Feature( + self._device, + "Smooth transition off", + container=self, + attribute_getter="turn_off_transition", + attribute_setter="set_turn_off_transition", + icon=icon, + type=FeatureType.Number, + maximum_value=self.MAXIMUM_DURATION, + ) + ) # self._turn_off_transition_max + + @property + def _turn_on(self): + """Internal getter for turn on settings.""" + if "on_state" not in self.data: + raise KasaException( + f"Unsupported for {self.REQUIRED_COMPONENT} v{self.supported_version}" + ) + + return self.data["on_state"] + + @property + def _turn_off(self): + """Internal getter for turn off settings.""" + if "off_state" not in self.data: + raise KasaException( + f"Unsupported for {self.REQUIRED_COMPONENT} v{self.supported_version}" ) - ) - def set_enabled(self, enable: bool): + return self.data["off_state"] + + def set_enabled_v1(self, enable: bool): """Enable gradual on/off.""" return self.call("set_on_off_gradually_info", {"enable": enable}) @property - def enabled(self) -> bool: + def enabled_v1(self) -> bool: """Return True if gradual on/off is enabled.""" return bool(self.data["enable"]) - def __cli_output__(self): - return f"Gradual on/off enabled: {self.enabled}" + @property + def turn_on_transition(self) -> int: + """Return transition time for turning the light on. + + Available only from v2. + """ + return self._turn_on["duration"] + + @property + def _turn_on_transition_max(self) -> int: + """Maximum turn on duration.""" + # v3 added max_duration, we default to 60 when it's not available + return self._turn_on.get("max_duration", 60) + + async def set_turn_on_transition(self, seconds: int): + """Set turn on transition in seconds. + + Setting to 0 turns the feature off. + """ + if seconds > self._turn_on_transition_max: + raise ValueError( + f"Value {seconds} out of range, max {self._turn_on_transition_max}" + ) + + if seconds <= 0: + return await self.call( + "set_on_off_gradually_info", + {"on_state": {**self._turn_on, "enable": False}}, + ) + + return await self.call( + "set_on_off_gradually_info", + {"on_state": {**self._turn_on, "duration": seconds}}, + ) + + @property + def turn_off_transition(self) -> int: + """Return transition time for turning the light off. + + Available only from v2. + """ + return self._turn_off["duration"] + + @property + def _turn_off_transition_max(self) -> int: + """Maximum turn on duration.""" + # v3 added max_duration, we default to 60 when it's not available + return self._turn_off.get("max_duration", 60) + + async def set_turn_off_transition(self, seconds: int): + """Set turn on transition in seconds. + + Setting to 0 turns the feature off. + """ + if seconds > self._turn_off_transition_max: + raise ValueError( + f"Value {seconds} out of range, max {self._turn_off_transition_max}" + ) + + if seconds <= 0: + return await self.call( + "set_on_off_gradually_info", + {"off_state": {**self._turn_off, "enable": False}}, + ) + + return await self.call( + "set_on_off_gradually_info", + {"off_state": {**self._turn_on, "duration": seconds}}, + ) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index b557f4934..e34f2260a 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -80,3 +80,8 @@ def data(self): return next(iter(filtered_data.values())) return filtered_data + + @property + def supported_version(self) -> int: + """Return version supported by the device.""" + return self._device._components[self.REQUIRED_COMPONENT] From d82747d73faa95268d0d30747f565a6b1fb9dcd4 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 26 Feb 2024 16:13:46 +0000 Subject: [PATCH 346/892] Support multiple child requests (#795) --- kasa/smartprotocol.py | 10 ++++++++-- kasa/tests/test_smartprotocol.py | 14 ++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 77bf66ab3..0b07be5f5 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -340,10 +340,16 @@ async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: result = response.get("control_child") # Unwrap responseData for control_child if result and (response_data := result.get("responseData")): - self._handle_response_error_code(response_data, "control_child") result = response_data.get("result") + if result and (multi_responses := result.get("responses")): + ret_val = {} + for multi_response in multi_responses: + method = multi_response["method"] + self._handle_response_error_code(multi_response, method) + ret_val[method] = multi_response.get("result") + return ret_val - # TODO: handle multipleRequest unwrapping + self._handle_response_error_code(response_data, "control_child") return {method: result} diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index aaea519d8..541d17c99 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -122,7 +122,6 @@ async def test_childdevicewrapper_error(dummy_protocol, mocker): await wrapped_protocol.query(DUMMY_QUERY) -@pytest.mark.skip("childprotocolwrapper does not yet support multirequests") async def test_childdevicewrapper_unwrapping_multiplerequest(dummy_protocol, mocker): """Test that unwrapping multiplerequest works correctly.""" mock_response = { @@ -146,13 +145,12 @@ async def test_childdevicewrapper_unwrapping_multiplerequest(dummy_protocol, moc } }, } - - mocker.patch.object(dummy_protocol._transport, "send", return_value=mock_response) - resp = await dummy_protocol.query(DUMMY_QUERY) + wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol) + mocker.patch.object(wrapped_protocol._transport, "send", return_value=mock_response) + resp = await wrapped_protocol.query(DUMMY_QUERY) assert resp == {"get_device_info": {"foo": "bar"}, "second_command": {"bar": "foo"}} -@pytest.mark.skip("childprotocolwrapper does not yet support multirequests") async def test_childdevicewrapper_multiplerequest_error(dummy_protocol, mocker): """Test that errors inside multipleRequest response of responseData raise an exception.""" mock_response = { @@ -172,7 +170,7 @@ async def test_childdevicewrapper_multiplerequest_error(dummy_protocol, mocker): } }, } - - mocker.patch.object(dummy_protocol._transport, "send", return_value=mock_response) + wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol) + mocker.patch.object(wrapped_protocol._transport, "send", return_value=mock_response) with pytest.raises(KasaException): - await dummy_protocol.query(DUMMY_QUERY) + await wrapped_protocol.query(DUMMY_QUERY) From 996322cea86e3a07ee14278be4a01609ab1512a7 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 27 Feb 2024 14:58:06 +0000 Subject: [PATCH 347/892] Do not fail fast on pypy CI jobs (#799) The pypy jobs are quite error prone, particularly the windows ones. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07fa734b2..779f6b19c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,7 @@ jobs: name: Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} needs: linting runs-on: ${{ matrix.os }} + continue-on-error: ${{ startsWith(matrix.python-version, 'pypy') }} strategy: matrix: From 97680bdceee3ab921e0c2d864d656211e7c72188 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 27 Feb 2024 17:39:04 +0000 Subject: [PATCH 348/892] Refactor test framework (#794) This is in preparation for tests based on supporting features amongst other tweaks: - Consolidates the filtering logic that was split across `filter_model` and `filter_fixture` - Allows filtering `dev` fixture by `component` - Consolidates fixtures missing method warnings into one warning - Does not raise exceptions from `FakeSmartTransport` for missing methods (required for KS240) --- kasa/tests/conftest.py | 583 +------------------------- kasa/tests/device_fixtures.py | 367 ++++++++++++++++ kasa/tests/discovery_fixtures.py | 173 ++++++++ kasa/tests/fakeprotocol_iot.py | 1 + kasa/tests/fakeprotocol_smart.py | 55 ++- kasa/tests/fixtureinfo.py | 118 ++++++ kasa/tests/test_cli.py | 24 +- kasa/tests/test_device_factory.py | 46 +- kasa/tests/test_discovery.py | 3 +- kasa/tests/test_feature_brightness.py | 12 + kasa/tests/test_readme_examples.py | 17 +- 11 files changed, 775 insertions(+), 624 deletions(-) create mode 100644 kasa/tests/device_fixtures.py create mode 100644 kasa/tests/discovery_fixtures.py create mode 100644 kasa/tests/fixtureinfo.py create mode 100644 kasa/tests/test_feature_brightness.py diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 431da4631..0917f081c 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -1,341 +1,17 @@ -import asyncio -import glob -import json -import os -from dataclasses import dataclass -from json import dumps as json_dumps -from os.path import basename -from pathlib import Path -from typing import Dict, Optional +import warnings +from typing import Dict from unittest.mock import MagicMock import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( - Credentials, - Device, DeviceConfig, - Discover, SmartProtocol, ) -from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip from kasa.protocol import BaseTransport -from kasa.smart import SmartBulb, SmartDevice -from kasa.xortransport import XorEncryption -from .fakeprotocol_iot import FakeIotProtocol -from .fakeprotocol_smart import FakeSmartProtocol - -SUPPORTED_IOT_DEVICES = [ - (device, "IOT") - for device in glob.glob( - os.path.dirname(os.path.abspath(__file__)) + "/fixtures/*.json" - ) -] - -SUPPORTED_SMART_DEVICES = [ - (device, "SMART") - for device in glob.glob( - os.path.dirname(os.path.abspath(__file__)) + "/fixtures/smart/*.json" - ) -] - - -SUPPORTED_DEVICES = SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES - -# Tapo bulbs -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 = {"L510B", "L510E"} -BULBS_SMART = ( - BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) - .union(BULBS_SMART_DIMMABLE) - .union(BULBS_SMART_LIGHT_STRIP) -) - -# Kasa (IOT-prefixed) bulbs -BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL430", "KL420L5"} -BULBS_IOT_VARIABLE_TEMP = { - "LB120", - "LB130", - "KL120", - "KL125", - "KL130", - "KL135", - "KL430", -} -BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} -BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"} -BULBS_IOT = ( - BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR) - .union(BULBS_IOT_DIMMABLE) - .union(BULBS_IOT_LIGHT_STRIP) -) - -BULBS_VARIABLE_TEMP = {*BULBS_SMART_VARIABLE_TEMP, *BULBS_IOT_VARIABLE_TEMP} -BULBS_COLOR = {*BULBS_SMART_COLOR, *BULBS_IOT_COLOR} - - -LIGHT_STRIPS = {*BULBS_SMART_LIGHT_STRIP, *BULBS_IOT_LIGHT_STRIP} -BULBS = { - *BULBS_IOT, - *BULBS_SMART, -} - - -PLUGS_IOT = { - "HS100", - "HS103", - "HS105", - "HS110", - "HS200", - "HS210", - "EP10", - "KP100", - "KP105", - "KP115", - "KP125", - "KP401", - "KS200M", -} -# P135 supports dimming, but its not currently support -# by the library -PLUGS_SMART = { - "P100", - "P110", - "KP125M", - "EP25", - "KS205", - "P125M", - "S505", - "TP15", -} -PLUGS = { - *PLUGS_IOT, - *PLUGS_SMART, -} -STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} -STRIPS_SMART = {"P300", "TP25"} -STRIPS = {*STRIPS_IOT, *STRIPS_SMART} - -DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} -DIMMERS_SMART = {"KS225", "S500D", "P135"} -DIMMERS = { - *DIMMERS_IOT, - *DIMMERS_SMART, -} - -HUBS_SMART = {"H100"} - -WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} -WITH_EMETER_SMART = {"P110", "KP125M", "EP25"} -WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} - -DIMMABLE = {*BULBS, *DIMMERS} - -ALL_DEVICES_IOT = BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT) -ALL_DEVICES_SMART = ( - BULBS_SMART.union(PLUGS_SMART) - .union(STRIPS_SMART) - .union(DIMMERS_SMART) - .union(HUBS_SMART) -) -ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) - -IP_MODEL_CACHE: Dict[str, str] = {} - - -def _make_unsupported(device_family, encrypt_type): - return { - "result": { - "device_id": "xx", - "owner": "xx", - "device_type": device_family, - "device_model": "P110(EU)", - "ip": "127.0.0.1", - "mac": "48-22xxx", - "is_support_iot_cloud": True, - "obd_src": "tplink", - "factory_default": False, - "mgt_encrypt_schm": { - "is_support_https": False, - "encrypt_type": encrypt_type, - "http_port": 80, - "lv": 2, - }, - }, - "error_code": 0, - } - - -UNSUPPORTED_DEVICES = { - "unknown_device_family": _make_unsupported("SMART.TAPOXMASTREE", "AES"), - "wrong_encryption_iot": _make_unsupported("IOT.SMARTPLUGSWITCH", "AES"), - "wrong_encryption_smart": _make_unsupported("SMART.TAPOBULB", "IOT"), - "unknown_encryption": _make_unsupported("IOT.SMARTPLUGSWITCH", "FOO"), -} - - -def idgenerator(paramtuple): - try: - return basename(paramtuple[0]) + ( - "" if paramtuple[1] == "IOT" else "-" + paramtuple[1] - ) - except: # TODO: HACK as idgenerator is now used by default # noqa: E722 - return None - - -def filter_model(desc, model_filter, protocol_filter=None): - if protocol_filter is None: - protocol_filter = {"IOT", "SMART"} - filtered = list() - for file, protocol in SUPPORTED_DEVICES: - if protocol in protocol_filter: - file_model_region = basename(file).split("_")[0] - file_model = file_model_region.split("(")[0] - for model in model_filter: - if model == file_model: - filtered.append((file, protocol)) - - filtered_basenames = [basename(f) + "-" + p for f, p in filtered] - print(f"# {desc}") - for file in filtered_basenames: - print(f"\t{file}") - return filtered - - -def parametrize(desc, devices, protocol_filter=None, ids=None): - if ids is None: - ids = idgenerator - return pytest.mark.parametrize( - "dev", filter_model(desc, devices, protocol_filter), indirect=True, ids=ids - ) - - -has_emeter = parametrize("has emeter", WITH_EMETER, protocol_filter={"SMART", "IOT"}) -no_emeter = parametrize( - "no emeter", ALL_DEVICES - WITH_EMETER, protocol_filter={"SMART", "IOT"} -) -has_emeter_iot = parametrize("has emeter iot", WITH_EMETER_IOT, protocol_filter={"IOT"}) -no_emeter_iot = parametrize( - "no emeter iot", ALL_DEVICES_IOT - WITH_EMETER_IOT, protocol_filter={"IOT"} -) - -bulb = parametrize("bulbs", BULBS, protocol_filter={"SMART", "IOT"}) -plug = parametrize("plugs", PLUGS, protocol_filter={"IOT"}) -strip = parametrize("strips", STRIPS, protocol_filter={"SMART", "IOT"}) -dimmer = parametrize("dimmers", DIMMERS, protocol_filter={"IOT"}) -lightstrip = parametrize("lightstrips", LIGHT_STRIPS, protocol_filter={"IOT"}) - -# bulb types -dimmable = parametrize("dimmable", DIMMABLE, protocol_filter={"IOT"}) -non_dimmable = parametrize("non-dimmable", BULBS - DIMMABLE, protocol_filter={"IOT"}) -variable_temp = parametrize( - "variable color temp", BULBS_VARIABLE_TEMP, protocol_filter={"SMART", "IOT"} -) -non_variable_temp = parametrize( - "non-variable color temp", - BULBS - BULBS_VARIABLE_TEMP, - protocol_filter={"SMART", "IOT"}, -) -color_bulb = parametrize("color bulbs", BULBS_COLOR, protocol_filter={"SMART", "IOT"}) -non_color_bulb = parametrize( - "non-color bulbs", BULBS - BULBS_COLOR, protocol_filter={"SMART", "IOT"} -) - -color_bulb_iot = parametrize( - "color bulbs iot", BULBS_IOT_COLOR, protocol_filter={"IOT"} -) -variable_temp_iot = parametrize( - "variable color temp iot", BULBS_IOT_VARIABLE_TEMP, protocol_filter={"IOT"} -) -bulb_iot = parametrize("bulb devices iot", BULBS_IOT, protocol_filter={"IOT"}) - -strip_iot = parametrize("strip devices iot", STRIPS_IOT, protocol_filter={"IOT"}) -strip_smart = parametrize( - "strip devices smart", STRIPS_SMART, protocol_filter={"SMART"} -) - -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"} -) -hubs_smart = parametrize("hubs smart", HUBS_SMART, protocol_filter={"SMART"}) -device_smart = parametrize( - "devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"} -) -device_iot = parametrize("devices iot", ALL_DEVICES_IOT, protocol_filter={"IOT"}) - - -def get_fixture_data(): - """Return raw discovery file contents as JSON. Used for discovery tests.""" - fixture_data = {} - for file, protocol in SUPPORTED_DEVICES: - p = Path(file) - if not p.is_absolute(): - folder = Path(__file__).parent / "fixtures" - if protocol == "SMART": - folder = folder / "smart" - p = folder / file - - with open(p) as f: - fixture_data[basename(p)] = json.load(f) - return fixture_data - - -FIXTURE_DATA = get_fixture_data() - - -def filter_fixtures(desc, root_filter): - filtered = {} - for key, val in FIXTURE_DATA.items(): - if root_filter in val: - filtered[key] = val - - print(f"# {desc}") - for key in filtered: - print(f"\t{key}") - return filtered - - -def parametrize_discovery(desc, root_key): - filtered_fixtures = filter_fixtures(desc, root_key) - return pytest.mark.parametrize( - "all_fixture_data", - filtered_fixtures.values(), - indirect=True, - ids=filtered_fixtures.keys(), - ) - - -new_discovery = parametrize_discovery("new discovery", "discovery_result") - - -def check_categories(): - """Check that every fixture file is categorized.""" - categorized_fixtures = set( - dimmer.args[1] - + strip.args[1] - + plug.args[1] - + bulb.args[1] - + lightstrip.args[1] - + plug_smart.args[1] - + bulb_smart.args[1] - + dimmers_smart.args[1] - + hubs_smart.args[1] - ) - diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures) - if diff: - for file, protocol in diff: - print( - f"No category for file {file} protocol {protocol}, add to the corresponding set (BULBS, PLUGS, ..)" - ) - raise Exception(f"Missing category for {diff}") - - -check_categories() +from .device_fixtures import * # noqa: F403 +from .discovery_fixtures import * # noqa: F403 # Parametrize tests to run with device both on and off turn_on = pytest.mark.parametrize("turn_on", [True, False]) @@ -348,241 +24,6 @@ async def handle_turn_on(dev, turn_on): await dev.turn_off() -def device_for_file(model, protocol): - if protocol == "SMART": - for d in PLUGS_SMART: - if d in model: - return SmartDevice - for d in BULBS_SMART: - if d in model: - return SmartBulb - for d in DIMMERS_SMART: - if d in model: - return SmartBulb - for d in STRIPS_SMART: - if d in model: - return SmartDevice - for d in HUBS_SMART: - if d in model: - return SmartDevice - else: - for d in STRIPS_IOT: - if d in model: - return IotStrip - - for d in PLUGS_IOT: - if d in model: - return IotPlug - - # Light strips are recognized also as bulbs, so this has to go first - for d in BULBS_IOT_LIGHT_STRIP: - if d in model: - return IotLightStrip - - for d in BULBS_IOT: - if d in model: - return IotBulb - - for d in DIMMERS_IOT: - if d in model: - return IotDimmer - - raise Exception("Unable to find type for %s", model) - - -async def _update_and_close(d): - await d.update() - await d.protocol.close() - return d - - -async def _discover_update_and_close(ip, username, password): - if username and password: - credentials = Credentials(username=username, password=password) - else: - credentials = None - d = await Discover.discover_single(ip, timeout=10, credentials=credentials) - return await _update_and_close(d) - - -async def get_device_for_file(file, protocol): - # if the wanted file is not an absolute path, prepend the fixtures directory - p = Path(file) - if not p.is_absolute(): - folder = Path(__file__).parent / "fixtures" - if protocol == "SMART": - folder = folder / "smart" - p = folder / file - - def load_file(): - with open(p) as f: - return json.load(f) - - loop = asyncio.get_running_loop() - sysinfo = await loop.run_in_executor(None, load_file) - - model = basename(file) - d = device_for_file(model, protocol)(host="127.0.0.123") - if protocol == "SMART": - d.protocol = FakeSmartProtocol(sysinfo) - else: - d.protocol = FakeIotProtocol(sysinfo) - await _update_and_close(d) - return d - - -@pytest.fixture(params=SUPPORTED_DEVICES, ids=idgenerator) -async def dev(request): - """Device fixture. - - Provides a device (given --ip) or parametrized fixture for the supported devices. - The initial update is called automatically before returning the device. - """ - file, protocol = request.param - - ip = request.config.getoption("--ip") - username = request.config.getoption("--username") - password = request.config.getoption("--password") - if ip: - model = IP_MODEL_CACHE.get(ip) - d = None - if not model: - d = await _discover_update_and_close(ip, username, password) - IP_MODEL_CACHE[ip] = model = d.model - if model not in file: - pytest.skip(f"skipping file {file}") - dev: Device = ( - d if d else await _discover_update_and_close(ip, username, password) - ) - else: - dev: Device = await get_device_for_file(file, protocol) - - yield dev - - await dev.disconnect() - - -@pytest.fixture -def discovery_mock(all_fixture_data, mocker): - @dataclass - class _DiscoveryMock: - ip: str - default_port: int - discovery_port: int - discovery_data: dict - query_data: dict - device_type: str - encrypt_type: str - login_version: Optional[int] = None - port_override: Optional[int] = None - - if "discovery_result" in all_fixture_data: - discovery_data = {"result": all_fixture_data["discovery_result"]} - device_type = all_fixture_data["discovery_result"]["device_type"] - encrypt_type = all_fixture_data["discovery_result"]["mgt_encrypt_schm"][ - "encrypt_type" - ] - login_version = all_fixture_data["discovery_result"]["mgt_encrypt_schm"].get( - "lv" - ) - datagram = ( - b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" - + json_dumps(discovery_data).encode() - ) - dm = _DiscoveryMock( - "127.0.0.123", - 80, - 20002, - discovery_data, - all_fixture_data, - device_type, - encrypt_type, - login_version, - ) - else: - sys_info = all_fixture_data["system"]["get_sysinfo"] - discovery_data = {"system": {"get_sysinfo": sys_info}} - device_type = sys_info.get("mic_type") or sys_info.get("type") - encrypt_type = "XOR" - login_version = None - datagram = XorEncryption.encrypt(json_dumps(discovery_data))[4:] - dm = _DiscoveryMock( - "127.0.0.123", - 9999, - 9999, - discovery_data, - all_fixture_data, - device_type, - encrypt_type, - login_version, - ) - - async def mock_discover(self): - port = ( - dm.port_override - if dm.port_override and dm.discovery_port != 20002 - else dm.discovery_port - ) - self.datagram_received( - datagram, - (dm.ip, port), - ) - - mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) - mocker.patch( - "socket.getaddrinfo", - side_effect=lambda *_, **__: [(None, None, None, None, (dm.ip, 0))], - ) - - if "component_nego" in dm.query_data: - proto = FakeSmartProtocol(dm.query_data) - else: - proto = FakeIotProtocol(dm.query_data) - - async def _query(request, retry_count: int = 3): - return await proto.query(request) - - mocker.patch("kasa.IotProtocol.query", side_effect=_query) - mocker.patch("kasa.SmartProtocol.query", side_effect=_query) - - yield dm - - -@pytest.fixture -def discovery_data(all_fixture_data): - """Return raw discovery file contents as JSON. Used for discovery tests.""" - if "discovery_result" in all_fixture_data: - return {"result": all_fixture_data["discovery_result"]} - else: - return {"system": {"get_sysinfo": all_fixture_data["system"]["get_sysinfo"]}} - - -@pytest.fixture(params=FIXTURE_DATA.values(), ids=FIXTURE_DATA.keys(), scope="session") -def all_fixture_data(request): - """Return raw fixture file contents as JSON. Used for discovery tests.""" - fixture_data = request.param - return fixture_data - - -@pytest.fixture(params=UNSUPPORTED_DEVICES.values(), ids=UNSUPPORTED_DEVICES.keys()) -def unsupported_device_info(request, mocker): - """Return unsupported devices for cli and discovery tests.""" - discovery_data = request.param - host = "127.0.0.1" - - async def mock_discover(self): - if discovery_data: - data = ( - b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" - + json_dumps(discovery_data).encode() - ) - self.datagram_received(data, (host, 20002)) - - mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) - - yield discovery_data - - @pytest.fixture def dummy_protocol(): """Return a smart protocol instance with a mocking-ready dummy transport.""" @@ -611,6 +52,22 @@ async def reset(self) -> None: return protocol +def pytest_configure(): + pytest.fixtures_missing_methods = {} + + +def pytest_sessionfinish(session, exitstatus): + msg = "\n" + for fixture, methods in sorted(pytest.fixtures_missing_methods.items()): + method_list = ", ".join(methods) + msg += f"Fixture {fixture} missing: {method_list}\n" + + warnings.warn( + UserWarning(msg), + stacklevel=1, + ) + + def pytest_addoption(parser): parser.addoption( "--ip", action="store", default=None, help="run against device on given ip" diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py new file mode 100644 index 000000000..e4f513ffc --- /dev/null +++ b/kasa/tests/device_fixtures.py @@ -0,0 +1,367 @@ +from typing import Dict, Set + +import pytest + +from kasa import ( + Credentials, + Device, + Discover, +) +from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip +from kasa.smart import SmartBulb, SmartDevice + +from .fakeprotocol_iot import FakeIotProtocol +from .fakeprotocol_smart import FakeSmartProtocol +from .fixtureinfo import FIXTURE_DATA, FixtureInfo, filter_fixtures, idgenerator + +# Tapo bulbs +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 = {"L510B", "L510E"} +BULBS_SMART = ( + BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) + .union(BULBS_SMART_DIMMABLE) + .union(BULBS_SMART_LIGHT_STRIP) +) + +# Kasa (IOT-prefixed) bulbs +BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL430", "KL420L5"} +BULBS_IOT_VARIABLE_TEMP = { + "LB120", + "LB130", + "KL120", + "KL125", + "KL130", + "KL135", + "KL430", +} +BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} +BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"} +BULBS_IOT = ( + BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR) + .union(BULBS_IOT_DIMMABLE) + .union(BULBS_IOT_LIGHT_STRIP) +) + +BULBS_VARIABLE_TEMP = {*BULBS_SMART_VARIABLE_TEMP, *BULBS_IOT_VARIABLE_TEMP} +BULBS_COLOR = {*BULBS_SMART_COLOR, *BULBS_IOT_COLOR} + + +LIGHT_STRIPS = {*BULBS_SMART_LIGHT_STRIP, *BULBS_IOT_LIGHT_STRIP} +BULBS = { + *BULBS_IOT, + *BULBS_SMART, +} + + +PLUGS_IOT = { + "HS100", + "HS103", + "HS105", + "HS110", + "HS200", + "HS210", + "EP10", + "KP100", + "KP105", + "KP115", + "KP125", + "KP401", + "KS200M", +} +# P135 supports dimming, but its not currently support +# by the library +PLUGS_SMART = { + "P100", + "P110", + "KP125M", + "EP25", + "KS205", + "P125M", + "S505", + "TP15", +} +PLUGS = { + *PLUGS_IOT, + *PLUGS_SMART, +} +STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} +STRIPS_SMART = {"P300", "TP25"} +STRIPS = {*STRIPS_IOT, *STRIPS_SMART} + +DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} +DIMMERS_SMART = {"KS225", "S500D", "P135"} +DIMMERS = { + *DIMMERS_IOT, + *DIMMERS_SMART, +} + +HUBS_SMART = {"H100"} + +WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} +WITH_EMETER_SMART = {"P110", "KP125M", "EP25"} +WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} + +DIMMABLE = {*BULBS, *DIMMERS} + +ALL_DEVICES_IOT = BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT) +ALL_DEVICES_SMART = ( + BULBS_SMART.union(PLUGS_SMART) + .union(STRIPS_SMART) + .union(DIMMERS_SMART) + .union(HUBS_SMART) +) +ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) + +IP_MODEL_CACHE: Dict[str, str] = {} + + +def parametrize( + desc, + *, + model_filter=None, + protocol_filter=None, + component_filter=None, + data_root_filter=None, + ids=None, +): + if ids is None: + ids = idgenerator + return pytest.mark.parametrize( + "dev", + filter_fixtures( + desc, + model_filter=model_filter, + protocol_filter=protocol_filter, + component_filter=component_filter, + data_root_filter=data_root_filter, + ), + indirect=True, + ids=ids, + ) + + +has_emeter = parametrize( + "has emeter", model_filter=WITH_EMETER, protocol_filter={"SMART", "IOT"} +) +no_emeter = parametrize( + "no emeter", + model_filter=ALL_DEVICES - WITH_EMETER, + protocol_filter={"SMART", "IOT"}, +) +has_emeter_iot = parametrize( + "has emeter iot", model_filter=WITH_EMETER_IOT, protocol_filter={"IOT"} +) +no_emeter_iot = parametrize( + "no emeter iot", + model_filter=ALL_DEVICES_IOT - WITH_EMETER_IOT, + protocol_filter={"IOT"}, +) + +bulb = parametrize("bulbs", model_filter=BULBS, protocol_filter={"SMART", "IOT"}) +plug = parametrize("plugs", model_filter=PLUGS, protocol_filter={"IOT"}) +strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"}) +dimmer = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) +lightstrip = parametrize( + "lightstrips", model_filter=LIGHT_STRIPS, protocol_filter={"IOT"} +) + +# bulb types +dimmable = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) +non_dimmable = parametrize( + "non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"} +) +variable_temp = parametrize( + "variable color temp", + model_filter=BULBS_VARIABLE_TEMP, + protocol_filter={"SMART", "IOT"}, +) +non_variable_temp = parametrize( + "non-variable color temp", + model_filter=BULBS - BULBS_VARIABLE_TEMP, + protocol_filter={"SMART", "IOT"}, +) +color_bulb = parametrize( + "color bulbs", model_filter=BULBS_COLOR, protocol_filter={"SMART", "IOT"} +) +non_color_bulb = parametrize( + "non-color bulbs", + model_filter=BULBS - BULBS_COLOR, + protocol_filter={"SMART", "IOT"}, +) + +color_bulb_iot = parametrize( + "color bulbs iot", model_filter=BULBS_IOT_COLOR, protocol_filter={"IOT"} +) +variable_temp_iot = parametrize( + "variable color temp iot", + model_filter=BULBS_IOT_VARIABLE_TEMP, + protocol_filter={"IOT"}, +) +bulb_iot = parametrize( + "bulb devices iot", model_filter=BULBS_IOT, protocol_filter={"IOT"} +) + +strip_iot = parametrize( + "strip devices iot", model_filter=STRIPS_IOT, protocol_filter={"IOT"} +) +strip_smart = parametrize( + "strip devices smart", model_filter=STRIPS_SMART, protocol_filter={"SMART"} +) + +plug_smart = parametrize( + "plug devices smart", model_filter=PLUGS_SMART, protocol_filter={"SMART"} +) +bulb_smart = parametrize( + "bulb devices smart", model_filter=BULBS_SMART, protocol_filter={"SMART"} +) +dimmers_smart = parametrize( + "dimmer devices smart", model_filter=DIMMERS_SMART, protocol_filter={"SMART"} +) +hubs_smart = parametrize( + "hubs smart", model_filter=HUBS_SMART, protocol_filter={"SMART"} +) +device_smart = parametrize( + "devices smart", model_filter=ALL_DEVICES_SMART, protocol_filter={"SMART"} +) +device_iot = parametrize( + "devices iot", model_filter=ALL_DEVICES_IOT, protocol_filter={"IOT"} +) + +brightness = parametrize("brightness smart", component_filter="brightness") + + +def check_categories(): + """Check that every fixture file is categorized.""" + categorized_fixtures = set( + dimmer.args[1] + + strip.args[1] + + plug.args[1] + + bulb.args[1] + + lightstrip.args[1] + + plug_smart.args[1] + + bulb_smart.args[1] + + dimmers_smart.args[1] + + hubs_smart.args[1] + ) + diffs: Set[FixtureInfo] = set(FIXTURE_DATA) - set(categorized_fixtures) + if diffs: + print(diffs) + for diff in diffs: + print( + f"No category for file {diff.name} protocol {diff.protocol}, add to the corresponding set (BULBS, PLUGS, ..)" + ) + raise Exception(f"Missing category for {diff.name}") + + +check_categories() + + +def device_for_fixture_name(model, protocol): + if protocol == "SMART": + for d in PLUGS_SMART: + if d in model: + return SmartDevice + for d in BULBS_SMART: + if d in model: + return SmartBulb + for d in DIMMERS_SMART: + if d in model: + return SmartBulb + for d in STRIPS_SMART: + if d in model: + return SmartDevice + for d in HUBS_SMART: + if d in model: + return SmartDevice + else: + for d in STRIPS_IOT: + if d in model: + return IotStrip + + for d in PLUGS_IOT: + if d in model: + return IotPlug + + # Light strips are recognized also as bulbs, so this has to go first + for d in BULBS_IOT_LIGHT_STRIP: + if d in model: + return IotLightStrip + + for d in BULBS_IOT: + if d in model: + return IotBulb + + for d in DIMMERS_IOT: + if d in model: + return IotDimmer + + raise Exception("Unable to find type for %s", model) + + +async def _update_and_close(d): + await d.update() + await d.protocol.close() + return d + + +async def _discover_update_and_close(ip, username, password): + if username and password: + credentials = Credentials(username=username, password=password) + else: + credentials = None + d = await Discover.discover_single(ip, timeout=10, credentials=credentials) + return await _update_and_close(d) + + +async def get_device_for_fixture(fixture_data: FixtureInfo): + # if the wanted file is not an absolute path, prepend the fixtures directory + + d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)( + host="127.0.0.123" + ) + if fixture_data.protocol == "SMART": + d.protocol = FakeSmartProtocol(fixture_data.data, fixture_data.name) + else: + d.protocol = FakeIotProtocol(fixture_data.data) + await _update_and_close(d) + return d + + +async def get_device_for_fixture_protocol(fixture, protocol): + finfo = FixtureInfo(name=fixture, protocol=protocol, data={}) + for fixture_info in FIXTURE_DATA: + if finfo == fixture_info: + return await get_device_for_fixture(fixture_info) + + +@pytest.fixture(params=FIXTURE_DATA, ids=idgenerator) +async def dev(request): + """Device fixture. + + Provides a device (given --ip) or parametrized fixture for the supported devices. + The initial update is called automatically before returning the device. + """ + fixture_data: FixtureInfo = request.param + + ip = request.config.getoption("--ip") + username = request.config.getoption("--username") + password = request.config.getoption("--password") + if ip: + model = IP_MODEL_CACHE.get(ip) + d = None + if not model: + d = await _discover_update_and_close(ip, username, password) + IP_MODEL_CACHE[ip] = model = d.model + if model not in fixture_data.name: + pytest.skip(f"skipping file {fixture_data.name}") + dev: Device = ( + d if d else await _discover_update_and_close(ip, username, password) + ) + else: + dev: Device = await get_device_for_fixture(fixture_data) + + yield dev + + await dev.disconnect() diff --git a/kasa/tests/discovery_fixtures.py b/kasa/tests/discovery_fixtures.py new file mode 100644 index 000000000..ce1f7d1c2 --- /dev/null +++ b/kasa/tests/discovery_fixtures.py @@ -0,0 +1,173 @@ +from dataclasses import dataclass +from json import dumps as json_dumps +from typing import Optional + +import pytest + +from kasa.xortransport import XorEncryption + +from .fakeprotocol_iot import FakeIotProtocol +from .fakeprotocol_smart import FakeSmartProtocol +from .fixtureinfo import FIXTURE_DATA, FixtureInfo, filter_fixtures, idgenerator + + +def _make_unsupported(device_family, encrypt_type): + return { + "result": { + "device_id": "xx", + "owner": "xx", + "device_type": device_family, + "device_model": "P110(EU)", + "ip": "127.0.0.1", + "mac": "48-22xxx", + "is_support_iot_cloud": True, + "obd_src": "tplink", + "factory_default": False, + "mgt_encrypt_schm": { + "is_support_https": False, + "encrypt_type": encrypt_type, + "http_port": 80, + "lv": 2, + }, + }, + "error_code": 0, + } + + +UNSUPPORTED_DEVICES = { + "unknown_device_family": _make_unsupported("SMART.TAPOXMASTREE", "AES"), + "wrong_encryption_iot": _make_unsupported("IOT.SMARTPLUGSWITCH", "AES"), + "wrong_encryption_smart": _make_unsupported("SMART.TAPOBULB", "IOT"), + "unknown_encryption": _make_unsupported("IOT.SMARTPLUGSWITCH", "FOO"), +} + + +def parametrize_discovery(desc, root_key): + filtered_fixtures = filter_fixtures(desc, data_root_filter=root_key) + return pytest.mark.parametrize( + "discovery_mock", + filtered_fixtures, + indirect=True, + ids=idgenerator, + ) + + +new_discovery = parametrize_discovery("new discovery", "discovery_result") + + +@pytest.fixture(params=FIXTURE_DATA, ids=idgenerator) +def discovery_mock(request, mocker): + fixture_info: FixtureInfo = request.param + fixture_data = fixture_info.data + + @dataclass + class _DiscoveryMock: + ip: str + default_port: int + discovery_port: int + discovery_data: dict + query_data: dict + device_type: str + encrypt_type: str + login_version: Optional[int] = None + port_override: Optional[int] = None + + if "discovery_result" in fixture_data: + discovery_data = {"result": fixture_data["discovery_result"]} + device_type = fixture_data["discovery_result"]["device_type"] + encrypt_type = fixture_data["discovery_result"]["mgt_encrypt_schm"][ + "encrypt_type" + ] + login_version = fixture_data["discovery_result"]["mgt_encrypt_schm"].get("lv") + datagram = ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(discovery_data).encode() + ) + dm = _DiscoveryMock( + "127.0.0.123", + 80, + 20002, + discovery_data, + fixture_data, + device_type, + encrypt_type, + login_version, + ) + else: + sys_info = fixture_data["system"]["get_sysinfo"] + discovery_data = {"system": {"get_sysinfo": sys_info}} + device_type = sys_info.get("mic_type") or sys_info.get("type") + encrypt_type = "XOR" + login_version = None + datagram = XorEncryption.encrypt(json_dumps(discovery_data))[4:] + dm = _DiscoveryMock( + "127.0.0.123", + 9999, + 9999, + discovery_data, + fixture_data, + device_type, + encrypt_type, + login_version, + ) + + async def mock_discover(self): + port = ( + dm.port_override + if dm.port_override and dm.discovery_port != 20002 + else dm.discovery_port + ) + self.datagram_received( + datagram, + (dm.ip, port), + ) + + mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) + mocker.patch( + "socket.getaddrinfo", + side_effect=lambda *_, **__: [(None, None, None, None, (dm.ip, 0))], + ) + + if fixture_info.protocol == "SMART": + proto = FakeSmartProtocol(fixture_data, fixture_info.name) + else: + proto = FakeIotProtocol(fixture_data) + + async def _query(request, retry_count: int = 3): + return await proto.query(request) + + mocker.patch("kasa.IotProtocol.query", side_effect=_query) + mocker.patch("kasa.SmartProtocol.query", side_effect=_query) + + yield dm + + +@pytest.fixture(params=FIXTURE_DATA, ids=idgenerator) +def discovery_data(request, mocker): + """Return raw discovery file contents as JSON. Used for discovery tests.""" + fixture_info = request.param + mocker.patch("kasa.IotProtocol.query", return_value=fixture_info.data) + mocker.patch("kasa.SmartProtocol.query", return_value=fixture_info.data) + if "discovery_result" in fixture_info.data: + return {"result": fixture_info.data["discovery_result"]} + else: + return {"system": {"get_sysinfo": fixture_info.data["system"]["get_sysinfo"]}} + + +@pytest.fixture(params=UNSUPPORTED_DEVICES.values(), ids=UNSUPPORTED_DEVICES.keys()) +def unsupported_device_info(request, mocker): + """Return unsupported devices for cli and discovery tests.""" + discovery_data = request.param + host = "127.0.0.1" + + async def mock_discover(self): + if discovery_data: + data = ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(discovery_data).encode() + ) + self.datagram_received(data, (host, 20002)) + + mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) + + yield discovery_data diff --git a/kasa/tests/fakeprotocol_iot.py b/kasa/tests/fakeprotocol_iot.py index fa14d3fc0..864576541 100644 --- a/kasa/tests/fakeprotocol_iot.py +++ b/kasa/tests/fakeprotocol_iot.py @@ -129,6 +129,7 @@ def __init__(self, info): config=DeviceConfig("127.0.0.123"), ) ) + info = copy.deepcopy(info) self.discovery_data = info self.writer = None self.reader = None diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index a164b7355..024e76360 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -1,14 +1,17 @@ -import warnings +import copy from json import loads as json_loads -from kasa import Credentials, DeviceConfig, KasaException, SmartProtocol +import pytest + +from kasa import Credentials, DeviceConfig, SmartProtocol +from kasa.exceptions import SmartErrorCode from kasa.protocol import BaseTransport class FakeSmartProtocol(SmartProtocol): - def __init__(self, info): + def __init__(self, info, fixture_name): super().__init__( - transport=FakeSmartTransport(info), + transport=FakeSmartTransport(info, fixture_name), ) async def query(self, request, retry_count: int = 3): @@ -18,7 +21,7 @@ async def query(self, request, retry_count: int = 3): class FakeSmartTransport(BaseTransport): - def __init__(self, info): + def __init__(self, info, fixture_name): super().__init__( config=DeviceConfig( "127.0.0.123", @@ -28,7 +31,8 @@ def __init__(self, info): ), ), ) - self.info = info + self.fixture_name = fixture_name + self.info = copy.deepcopy(info) self.components = { comp["id"]: comp["ver_code"] for comp in self.info["component_nego"]["component_list"] @@ -90,11 +94,14 @@ def credentials_hash(self): } }, ), - "get_support_alarm_type_list": ("alarm", { - "alarm_type_list": [ - "Doorbell Ring 1", - ] - }), + "get_support_alarm_type_list": ( + "alarm", + { + "alarm_type_list": [ + "Doorbell Ring 1", + ] + }, + ), "get_device_usage": ("device", {}), } @@ -149,18 +156,26 @@ def _send_request(self, request_dict: dict): elif method == "component_nego" or method[:4] == "get_": if method in info: return {"result": info[method], "error_code": 0} - elif ( + if ( + # FIXTURE_MISSING is for service calls not in place when + # SMART fixtures started to be generated missing_result := self.FIXTURE_MISSING_MAP.get(method) ) and missing_result[0] in self.components: - warnings.warn( - UserWarning( - f"Fixture missing expected method {method}, try to regenerate" - ), - stacklevel=1, - ) - return {"result": missing_result[1], "error_code": 0} + retval = {"result": missing_result[1], "error_code": 0} else: - raise KasaException(f"Fixture doesn't support {method}") + # PARAMS error returned for KS240 when get_device_usage called + # on parent device. Could be any error code though. + # TODO: Try to figure out if there's a way to prevent the KS240 smartdevice + # calling the unsupported device in the first place. + retval = { + "error_code": SmartErrorCode.PARAMS_ERROR.value, + "method": "get_device_usage", + } + # Reduce warning spam by consolidating and reporting at the end of the run + if self.fixture_name not in pytest.fixtures_missing_methods: + pytest.fixtures_missing_methods[self.fixture_name] = set() + pytest.fixtures_missing_methods[self.fixture_name].add(method) + return retval elif method == "set_qs_info": return {"error_code": 0} elif method[:4] == "set_": diff --git a/kasa/tests/fixtureinfo.py b/kasa/tests/fixtureinfo.py new file mode 100644 index 000000000..52250aab4 --- /dev/null +++ b/kasa/tests/fixtureinfo.py @@ -0,0 +1,118 @@ +import glob +import json +import os +from pathlib import Path +from typing import Dict, List, NamedTuple, Optional, Set + + +class FixtureInfo(NamedTuple): + name: str + protocol: str + data: Dict + + +FixtureInfo.__hash__ = lambda x: hash((x.name, x.protocol)) # type: ignore[attr-defined, method-assign] +FixtureInfo.__eq__ = lambda x, y: hash(x) == hash(y) # type: ignore[method-assign] + + +SUPPORTED_IOT_DEVICES = [ + (device, "IOT") + for device in glob.glob( + os.path.dirname(os.path.abspath(__file__)) + "/fixtures/*.json" + ) +] + +SUPPORTED_SMART_DEVICES = [ + (device, "SMART") + for device in glob.glob( + os.path.dirname(os.path.abspath(__file__)) + "/fixtures/smart/*.json" + ) +] + + +SUPPORTED_DEVICES = SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES + + +def idgenerator(paramtuple: FixtureInfo): + try: + return paramtuple.name + ( + "" if paramtuple.protocol == "IOT" else "-" + paramtuple.protocol + ) + except: # TODO: HACK as idgenerator is now used by default # noqa: E722 + return None + + +def get_fixture_info() -> List[FixtureInfo]: + """Return raw discovery file contents as JSON. Used for discovery tests.""" + fixture_data = [] + for file, protocol in SUPPORTED_DEVICES: + p = Path(file) + folder = Path(__file__).parent / "fixtures" + if protocol == "SMART": + folder = folder / "smart" + p = folder / file + + with open(p) as f: + data = json.load(f) + + fixture_name = p.name + fixture_data.append( + FixtureInfo(data=data, protocol=protocol, name=fixture_name) + ) + return fixture_data + + +FIXTURE_DATA: List[FixtureInfo] = get_fixture_info() + + +def filter_fixtures( + desc, + *, + data_root_filter: Optional[str] = None, + protocol_filter: Optional[Set[str]] = None, + model_filter: Optional[Set[str]] = None, + component_filter: Optional[str] = None, +): + """Filter the fixtures based on supplied parameters. + + data_root_filter: return fixtures containing the supplied top + level key, i.e. discovery_result + protocol_filter: set of protocols to match, IOT or SMART + model_filter: set of device models to match + component_filter: filter SMART fixtures that have the provided + component in component_nego details. + """ + + def _model_match(fixture_data: FixtureInfo, model_filter): + file_model_region = fixture_data.name.split("_")[0] + file_model = file_model_region.split("(")[0] + return file_model in model_filter + + def _component_match(fixture_data: FixtureInfo, component_filter): + if (component_nego := fixture_data.data.get("component_nego")) is None: + return False + components = { + component["id"]: component["ver_code"] + for component in component_nego["component_list"] + } + return component_filter in components + + filtered = [] + if protocol_filter is None: + protocol_filter = {"IOT", "SMART"} + for fixture_data in FIXTURE_DATA: + if data_root_filter and data_root_filter not in fixture_data.data: + continue + if fixture_data.protocol not in protocol_filter: + continue + if model_filter is not None and not _model_match(fixture_data, model_filter): + continue + if component_filter and not _component_match(fixture_data, component_filter): + continue + + filtered.append(fixture_data) + + print(f"# {desc}") + for value in filtered: + print(f"\t{value.name}") + return filtered diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 6d156aec4..01d02273d 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -35,7 +35,7 @@ from .conftest import ( device_iot, device_smart, - get_device_for_file, + get_device_for_fixture_protocol, handle_turn_on, new_discovery, turn_on, @@ -695,7 +695,9 @@ async def test_errors(mocker): async def test_feature(mocker): """Test feature command.""" - dummy_device = await get_device_for_file("P300(EU)_1.0_1.0.13.json", "SMART") + dummy_device = await get_device_for_fixture_protocol( + "P300(EU)_1.0_1.0.13.json", "SMART" + ) mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) runner = CliRunner() res = await runner.invoke( @@ -711,7 +713,9 @@ async def test_feature(mocker): async def test_feature_single(mocker): """Test feature command returning single value.""" - dummy_device = await get_device_for_file("P300(EU)_1.0_1.0.13.json", "SMART") + dummy_device = await get_device_for_fixture_protocol( + "P300(EU)_1.0_1.0.13.json", "SMART" + ) mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) runner = CliRunner() res = await runner.invoke( @@ -723,9 +727,12 @@ async def test_feature_single(mocker): assert "== Features ==" not in res.output assert res.exit_code == 0 + async def test_feature_missing(mocker): """Test feature command returning single value.""" - dummy_device = await get_device_for_file("P300(EU)_1.0_1.0.13.json", "SMART") + dummy_device = await get_device_for_fixture_protocol( + "P300(EU)_1.0_1.0.13.json", "SMART" + ) mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) runner = CliRunner() res = await runner.invoke( @@ -737,9 +744,12 @@ async def test_feature_missing(mocker): assert "== Features ==" not in res.output assert res.exit_code == 0 + async def test_feature_set(mocker): """Test feature command's set value.""" - dummy_device = await get_device_for_file("P300(EU)_1.0_1.0.13.json", "SMART") + dummy_device = await get_device_for_fixture_protocol( + "P300(EU)_1.0_1.0.13.json", "SMART" + ) led_setter = mocker.patch("kasa.smart.modules.ledmodule.LedModule.set_led") mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) @@ -757,7 +767,9 @@ async def test_feature_set(mocker): async def test_feature_set_child(mocker): """Test feature command's set value.""" - dummy_device = await get_device_for_file("P300(EU)_1.0_1.0.13.json", "SMART") + dummy_device = await get_device_for_fixture_protocol( + "P300(EU)_1.0_1.0.13.json", "SMART" + ) setter = mocker.patch("kasa.smart.smartdevice.SmartDevice.set_state") mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index 2d6267069..1519ca5f2 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -20,9 +20,8 @@ from kasa.discover import DiscoveryResult -def _get_connection_type_device_class(the_fixture_data): - if "discovery_result" in the_fixture_data: - discovery_info = {"result": the_fixture_data["discovery_result"]} +def _get_connection_type_device_class(discovery_info): + if "result" in discovery_info: device_class = Discover._get_device_class(discovery_info) dr = DiscoveryResult(**discovery_info["result"]) @@ -33,21 +32,18 @@ def _get_connection_type_device_class(the_fixture_data): connection_type = ConnectionType.from_values( DeviceFamilyType.IotSmartPlugSwitch.value, EncryptType.Xor.value ) - device_class = Discover._get_device_class(the_fixture_data) + device_class = Discover._get_device_class(discovery_info) return connection_type, device_class async def test_connect( - all_fixture_data: dict, + discovery_data, mocker, ): """Test that if the protocol is passed in it gets set correctly.""" host = "127.0.0.1" - ctype, device_class = _get_connection_type_device_class(all_fixture_data) - - mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) + ctype, device_class = _get_connection_type_device_class(discovery_data) config = DeviceConfig( host=host, credentials=Credentials("foor", "bar"), connection_type=ctype @@ -67,34 +63,32 @@ async def test_connect( @pytest.mark.parametrize("custom_port", [123, None]) -async def test_connect_custom_port(all_fixture_data: dict, mocker, custom_port): +async def test_connect_custom_port(discovery_data: dict, mocker, custom_port): """Make sure that connect returns an initialized SmartDevice instance.""" host = "127.0.0.1" - ctype, _ = _get_connection_type_device_class(all_fixture_data) + ctype, _ = _get_connection_type_device_class(discovery_data) config = DeviceConfig( host=host, port_override=custom_port, connection_type=ctype, credentials=Credentials("dummy_user", "dummy_password"), ) - default_port = 80 if "discovery_result" in all_fixture_data else 9999 + default_port = 80 if "result" in discovery_data else 9999 + + ctype, _ = _get_connection_type_device_class(discovery_data) - ctype, _ = _get_connection_type_device_class(all_fixture_data) - mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) dev = await connect(config=config) assert issubclass(dev.__class__, Device) assert dev.port == custom_port or dev.port == default_port async def test_connect_logs_connect_time( - all_fixture_data: dict, caplog: pytest.LogCaptureFixture, mocker + discovery_data: dict, + caplog: pytest.LogCaptureFixture, ): """Test that the connect time is logged when debug logging is enabled.""" - ctype, _ = _get_connection_type_device_class(all_fixture_data) - mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) + ctype, _ = _get_connection_type_device_class(discovery_data) host = "127.0.0.1" config = DeviceConfig( @@ -107,13 +101,13 @@ async def test_connect_logs_connect_time( assert "seconds to update" in caplog.text -async def test_connect_query_fails(all_fixture_data: dict, mocker): +async def test_connect_query_fails(discovery_data, mocker): """Make sure that connect fails when query fails.""" host = "127.0.0.1" mocker.patch("kasa.IotProtocol.query", side_effect=KasaException) mocker.patch("kasa.SmartProtocol.query", side_effect=KasaException) - ctype, _ = _get_connection_type_device_class(all_fixture_data) + ctype, _ = _get_connection_type_device_class(discovery_data) config = DeviceConfig( host=host, credentials=Credentials("foor", "bar"), connection_type=ctype ) @@ -125,14 +119,11 @@ async def test_connect_query_fails(all_fixture_data: dict, mocker): assert close_mock.call_count == 1 -async def test_connect_http_client(all_fixture_data, mocker): +async def test_connect_http_client(discovery_data, mocker): """Make sure that discover_single returns an initialized SmartDevice instance.""" host = "127.0.0.1" - ctype, _ = _get_connection_type_device_class(all_fixture_data) - - mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) + ctype, _ = _get_connection_type_device_class(discovery_data) http_client = aiohttp.ClientSession() @@ -142,6 +133,7 @@ async def test_connect_http_client(all_fixture_data, mocker): dev = await connect(config=config) if ctype.encryption_type != EncryptType.Xor: assert dev.protocol._transport._http_client.client != http_client + await dev.disconnect() config = DeviceConfig( host=host, @@ -152,3 +144,5 @@ async def test_connect_http_client(all_fixture_data, mocker): dev = await connect(config=config) if ctype.encryption_type != EncryptType.Xor: assert dev.protocol._transport._http_client.client == http_client + await dev.disconnect() + await http_client.close() diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 02cf19bc5..897d91d81 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -299,8 +299,9 @@ async def test_discover_single_authentication(discovery_mock, mocker): @new_discovery -async def test_device_update_from_new_discovery_info(discovery_data): +async def test_device_update_from_new_discovery_info(discovery_mock): """Make sure that new discovery devices update from discovery info correctly.""" + discovery_data = discovery_mock.discovery_data device_class = Discover._get_device_class(discovery_data) device = device_class("127.0.0.1") discover_info = DiscoveryResult(**discovery_data["result"]) diff --git a/kasa/tests/test_feature_brightness.py b/kasa/tests/test_feature_brightness.py new file mode 100644 index 000000000..d99b55d1d --- /dev/null +++ b/kasa/tests/test_feature_brightness.py @@ -0,0 +1,12 @@ +from kasa.smart import SmartDevice + +from .conftest import ( + brightness, +) + + +@brightness +async def test_brightness_component(dev: SmartDevice): + """Placeholder to test framwework component filter.""" + assert isinstance(dev, SmartDevice) + assert "brightness" in dev._components diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index ec2099c65..0d43da7be 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -2,12 +2,12 @@ import xdoctest -from kasa.tests.conftest import get_device_for_file +from kasa.tests.conftest import get_device_for_fixture_protocol def test_bulb_examples(mocker): """Use KL130 (bulb with all features) to test the doctests.""" - p = asyncio.run(get_device_for_file("KL130(US)_1.0_1.8.11.json", "IOT")) + p = asyncio.run(get_device_for_fixture_protocol("KL130(US)_1.0_1.8.11.json", "IOT")) mocker.patch("kasa.iot.iotbulb.IotBulb", return_value=p) mocker.patch("kasa.iot.iotbulb.IotBulb.update") res = xdoctest.doctest_module("kasa.iot.iotbulb", "all") @@ -16,7 +16,7 @@ def test_bulb_examples(mocker): def test_smartdevice_examples(mocker): """Use HS110 for emeter examples.""" - p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json", "IOT")) + p = asyncio.run(get_device_for_fixture_protocol("HS110(EU)_1.0_1.2.5.json", "IOT")) mocker.patch("kasa.iot.iotdevice.IotDevice", return_value=p) mocker.patch("kasa.iot.iotdevice.IotDevice.update") res = xdoctest.doctest_module("kasa.iot.iotdevice", "all") @@ -25,7 +25,8 @@ def test_smartdevice_examples(mocker): def test_plug_examples(mocker): """Test plug examples.""" - p = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json", "IOT")) + p = asyncio.run(get_device_for_fixture_protocol("HS110(EU)_1.0_1.2.5.json", "IOT")) + # p = await get_device_for_fixture_protocol("HS110(EU)_1.0_1.2.5.json", "IOT") mocker.patch("kasa.iot.iotplug.IotPlug", return_value=p) mocker.patch("kasa.iot.iotplug.IotPlug.update") res = xdoctest.doctest_module("kasa.iot.iotplug", "all") @@ -34,7 +35,7 @@ def test_plug_examples(mocker): def test_strip_examples(mocker): """Test strip examples.""" - p = asyncio.run(get_device_for_file("KP303(UK)_1.0_1.0.3.json", "IOT")) + p = asyncio.run(get_device_for_fixture_protocol("KP303(UK)_1.0_1.0.3.json", "IOT")) mocker.patch("kasa.iot.iotstrip.IotStrip", return_value=p) mocker.patch("kasa.iot.iotstrip.IotStrip.update") res = xdoctest.doctest_module("kasa.iot.iotstrip", "all") @@ -43,7 +44,7 @@ def test_strip_examples(mocker): def test_dimmer_examples(mocker): """Test dimmer examples.""" - p = asyncio.run(get_device_for_file("HS220(US)_1.0_1.5.7.json", "IOT")) + p = asyncio.run(get_device_for_fixture_protocol("HS220(US)_1.0_1.5.7.json", "IOT")) mocker.patch("kasa.iot.iotdimmer.IotDimmer", return_value=p) mocker.patch("kasa.iot.iotdimmer.IotDimmer.update") res = xdoctest.doctest_module("kasa.iot.iotdimmer", "all") @@ -52,7 +53,7 @@ def test_dimmer_examples(mocker): def test_lightstrip_examples(mocker): """Test lightstrip examples.""" - p = asyncio.run(get_device_for_file("KL430(US)_1.0_1.0.10.json", "IOT")) + p = asyncio.run(get_device_for_fixture_protocol("KL430(US)_1.0_1.0.10.json", "IOT")) mocker.patch("kasa.iot.iotlightstrip.IotLightStrip", return_value=p) mocker.patch("kasa.iot.iotlightstrip.IotLightStrip.update") res = xdoctest.doctest_module("kasa.iot.iotlightstrip", "all") @@ -61,7 +62,7 @@ def test_lightstrip_examples(mocker): def test_discovery_examples(mocker): """Test discovery examples.""" - p = asyncio.run(get_device_for_file("KP303(UK)_1.0_1.0.3.json", "IOT")) + p = asyncio.run(get_device_for_fixture_protocol("KP303(UK)_1.0_1.0.3.json", "IOT")) mocker.patch("kasa.discover.Discover.discover", return_value=[p]) res = xdoctest.doctest_module("kasa.discover", "all") From 75c60eb97c01f8cf6192025583d873fa16cace92 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 27 Feb 2024 20:06:13 +0100 Subject: [PATCH 349/892] Add fixture for P110 sw 1.0.7 (#801) By courtesy of @pplucky (#797) --- .../fixtures/smart/P110(EU)_1.0_1.0.7.json | 566 ++++++++++++++++++ 1 file changed, 566 insertions(+) create mode 100644 kasa/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json diff --git a/kasa/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json b/kasa/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json new file mode 100644 index 000000000..6332f259e --- /dev/null +++ b/kasa/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json @@ -0,0 +1,566 @@ +{ + "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": "energy_monitoring", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110(EU)", + "device_type": "SMART.TAPOPLUG", + "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": "AES", + "http_port": 80, + "is_support_https": false + }, + "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": "plug", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 210629 Rel.174901", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "34-60-F9-00-00-00", + "model": "P110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "Europe/Lisbon", + "rssi": -55, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/Lisbon", + "time_diff": 0, + "timestamp": 1708990159 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "get_energy_usage": { + "current_power": 0, + "local_time": "2024-02-26 23:29:21", + "month_energy": 0, + "month_runtime": 0, + "past1y": [ + 0, + 55, + 416, + 440, + 146, + 204, + 95, + 101, + 0, + 0, + 0, + 0 + ], + "past24h": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "past30d": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "past7d": [ + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "today_energy": 0, + "today_runtime": 0 + }, + "get_fw_download_state": { + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.3.0 Build 230905 Rel.152200", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-10-31", + "release_note": "Modifications and Bug Fixes:\n1. Improved stability and performance.\n2. Enhanced local communication security.", + "type": 2 + }, + "get_led_info": { + "led_rule": "always", + "led_status": false, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "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": "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": 0, + "key_type": "none", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 9, + "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": "P110", + "device_type": "SMART.TAPOPLUG" + } + } +} From 24344b71f511ac049bfd8a17bea50e09b2c98be2 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 28 Feb 2024 16:04:57 +0000 Subject: [PATCH 350/892] Update dump_devinfo to collect child device info (#796) Will create separate fixture files if the model of the child devices differs from the parent (i.e. hubs). Otherwise the child device info will be under `child_devices` --- devtools/dump_devinfo.py | 319 ++++++++++++++++++++++++------ devtools/helpers/smartrequests.py | 28 ++- 2 files changed, 286 insertions(+), 61 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 8e0126061..ebfe3b1bb 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -32,9 +32,14 @@ from kasa.discover import DiscoveryResult from kasa.exceptions import SmartErrorCode from kasa.smart import SmartDevice +from kasa.smartprotocol import _ChildProtocolWrapper Call = namedtuple("Call", "module method") -SmartCall = namedtuple("SmartCall", "module request should_succeed") +SmartCall = namedtuple("SmartCall", "module request should_succeed child_device_id") +FixtureResult = namedtuple("FixtureResult", "filename, folder, data") + +SMART_FOLDER = "kasa/tests/fixtures/smart/" +IOT_FOLDER = "kasa/tests/fixtures/" _LOGGER = logging.getLogger(__name__) @@ -69,6 +74,10 @@ def scrub(res): "parent_device_id", # for hub children "setup_code", # matter "setup_payload", # matter + "mfi_setup_code", # mfi_ for homekit + "mfi_setup_id", + "mfi_token_token", + "mfi_token_uuid", ] for k, v in res.items(): @@ -105,6 +114,8 @@ def scrub(res): v = "#MASKED_NAME#" elif isinstance(res[k], int): v = 0 + elif k == "device_id" and "SCRUBBED" in v: + pass # already scrubbed elif k == "device_id" and len(v) > 40: # retain the last two chars when scrubbing child ids end = v[-2:] @@ -130,27 +141,30 @@ def default_to_regular(d): async def handle_device(basedir, autosave, device: Device, batch_size: int): """Create a fixture for a single device instance.""" if isinstance(device, SmartDevice): - filename, copy_folder, final = await get_smart_fixture(device, batch_size) + fixture_results: List[FixtureResult] = await get_smart_fixtures( + device, batch_size + ) else: - filename, copy_folder, final = await get_legacy_fixture(device) + fixture_results = [await get_legacy_fixture(device)] - save_filename = Path(basedir) / copy_folder / filename + for fixture_result in fixture_results: + save_filename = Path(basedir) / fixture_result.folder / fixture_result.filename - pprint(scrub(final)) - if autosave: - save = "y" - else: - save = click.prompt( - f"Do you want to save the above content to {save_filename} (y/n)" - ) - if save == "y": - click.echo(f"Saving info to {save_filename}") + pprint(scrub(fixture_result.data)) + if autosave: + save = "y" + else: + save = click.prompt( + f"Do you want to save the above content to {save_filename} (y/n)" + ) + if save == "y": + click.echo(f"Saving info to {save_filename}") - with open(save_filename, "w") as f: - json.dump(final, f, sort_keys=True, indent=4) - f.write("\n") - else: - click.echo("Not saving.") + with open(save_filename, "w") as f: + json.dump(fixture_result.data, f, sort_keys=True, indent=4) + f.write("\n") + else: + click.echo("Not saving.") @click.command() @@ -181,7 +195,27 @@ async def handle_device(basedir, autosave, device: Device, batch_size: int): "--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, batch_size): +@click.option( + "-di", + "--discovery-info", + help=( + "Bypass discovery by passing an accurate discovery result json escaped string." + + " Do not use this flag unless you are sure you know what it means." + ), +) +@click.option("--port", help="Port override") +async def cli( + host, + target, + basedir, + autosave, + debug, + username, + password, + batch_size, + discovery_info, + port, +): """Generate devinfo files for devices. Use --host (for a single device) or --target (for a complete network). @@ -191,8 +225,30 @@ async def cli(host, target, basedir, autosave, debug, username, password, batch_ credentials = Credentials(username=username, password=password) if host is not None: - click.echo("Host given, performing discovery on %s." % host) - device = await Discover.discover_single(host, credentials=credentials) + if discovery_info: + click.echo("Host and discovery info given, trying connect on %s." % host) + from kasa import ConnectionType, DeviceConfig + + di = json.loads(discovery_info) + dr = DiscoveryResult(**di) + connection_type = ConnectionType.from_values( + dr.device_type, + dr.mgt_encrypt_schm.encrypt_type, + dr.mgt_encrypt_schm.lv, + ) + dc = DeviceConfig( + host=host, + connection_type=connection_type, + port_override=port, + credentials=credentials, + ) + device = await Device.connect(config=dc) + device.update_from_discover_info(dr.get_dict()) + else: + click.echo("Host given, performing discovery on %s." % host) + device = await Discover.discover_single( + host, credentials=credentials, port=port + ) await handle_device(basedir, autosave, device, batch_size) else: click.echo( @@ -270,8 +326,8 @@ async def get_legacy_fixture(device): sw_version = sysinfo["sw_ver"] sw_version = sw_version.split(" ", maxsplit=1)[0] save_filename = f"{model}_{hw_version}_{sw_version}.json" - copy_folder = "kasa/tests/fixtures/" - return save_filename, copy_folder, final + copy_folder = IOT_FOLDER + return FixtureResult(filename=save_filename, folder=copy_folder, data=final) def _echo_error(msg: str): @@ -289,8 +345,15 @@ async def _make_requests_or_exit( requests: List[SmartRequest], name: str, batch_size: int, + *, + child_device_id: str, ) -> Dict[str, Dict]: final = {} + protocol = ( + device.protocol + if child_device_id == "" + else _ChildProtocolWrapper(child_device_id, device.protocol) + ) try: end = len(requests) step = batch_size # Break the requests down as there seems to be a size limit @@ -300,9 +363,7 @@ async def _make_requests_or_exit( 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(request) - ) + responses = await protocol.query(SmartRequest._create_request_dict(request)) for method, result in responses.items(): final[method] = result return final @@ -331,38 +392,36 @@ async def _make_requests_or_exit( await device.protocol.close() -async def get_smart_fixture(device: SmartDevice, batch_size: int): - """Get fixture for new TAPO style protocol.""" +async def get_smart_test_calls(device: SmartDevice): + """Get the list of test calls to make.""" + test_calls = [] + successes = [] + child_device_components = {} + extra_test_calls = [ SmartCall( module="temp_humidity_records", request=SmartRequest.get_raw_request("get_temp_humidity_records"), should_succeed=False, - ), - SmartCall( - module="child_device_list", - request=SmartRequest.get_raw_request("get_child_device_list"), - should_succeed=False, - ), - SmartCall( - module="child_device_component_list", - request=SmartRequest.get_raw_request("get_child_device_component_list"), - should_succeed=False, + child_device_id="", ), SmartCall( module="trigger_logs", request=SmartRequest.get_raw_request( - "get_trigger_logs", SmartRequest.GetTriggerLogsParams(5, 0) + "get_trigger_logs", SmartRequest.GetTriggerLogsParams() ), should_succeed=False, + child_device_id="", ), ] - successes = [] - click.echo("Testing component_nego call ..", nl=False) responses = await _make_requests_or_exit( - device, [SmartRequest.component_nego()], "component_nego call", batch_size + device, + [SmartRequest.component_nego()], + "component_nego call", + batch_size=1, + child_device_id="", ) component_info_response = responses["component_nego"] click.echo(click.style("OK", fg="green")) @@ -371,35 +430,127 @@ async def get_smart_fixture(device: SmartDevice, batch_size: int): module="component_nego", request=SmartRequest("component_nego"), should_succeed=True, + child_device_id="", ) ) - - test_calls = [] - should_succeed = [] - - for item in component_info_response["component_list"]: - component_id = item["id"] - ver_code = item["ver_code"] + components = { + item["id"]: item["ver_code"] + for item in component_info_response["component_list"] + } + + if "child_device" in components: + child_components = await _make_requests_or_exit( + device, + [SmartRequest.get_child_device_component_list()], + "child device component list", + batch_size=1, + child_device_id="", + ) + successes.append( + SmartCall( + module="child_component_list", + request=SmartRequest.get_child_device_component_list(), + should_succeed=True, + child_device_id="", + ) + ) + test_calls.append( + SmartCall( + module="child_device_list", + request=SmartRequest.get_child_device_list(), + should_succeed=True, + child_device_id="", + ) + ) + # Get list of child components to call + if "control_child" in components: + child_device_components = { + child_component_list["device_id"]: { + item["id"]: item["ver_code"] + for item in child_component_list["component_list"] + } + for child_component_list in child_components[ + "get_child_device_component_list" + ]["child_component_list"] + } + + # Get component calls + for component_id, ver_code in components.items(): + if component_id == "child_device": + continue if (requests := get_component_requests(component_id, ver_code)) is not None: component_test_calls = [ - SmartCall(module=component_id, request=request, should_succeed=True) + SmartCall( + module=component_id, + request=request, + should_succeed=True, + child_device_id="", + ) for request in requests ] test_calls.extend(component_test_calls) - should_succeed.extend(component_test_calls) else: click.echo(f"Skipping {component_id}..", nl=False) click.echo(click.style("UNSUPPORTED", fg="yellow")) test_calls.extend(extra_test_calls) + # Child component calls + for child_device_id, child_components in child_device_components.items(): + for component_id, ver_code in child_components.items(): + if (requests := get_component_requests(component_id, ver_code)) is not None: + component_test_calls = [ + SmartCall( + module=component_id, + request=request, + should_succeed=True, + child_device_id=child_device_id, + ) + for request in requests + ] + test_calls.extend(component_test_calls) + else: + click.echo(f"Skipping {component_id}..", nl=False) + click.echo(click.style("UNSUPPORTED", fg="yellow")) + # Add the extra calls for each child + for extra_call in extra_test_calls: + extra_child_call = extra_call._replace(child_device_id=child_device_id) + test_calls.append(extra_child_call) + + return test_calls, successes + + +def get_smart_child_fixture(response): + """Get a seperate fixture for the child device.""" + info = response["get_device_info"] + hw_version = info["hw_ver"] + sw_version = info["fw_ver"] + sw_version = sw_version.split(" ", maxsplit=1)[0] + model = info["model"] + if region := info.get("specs"): + model += f"({region})" + + save_filename = f"{model}_{hw_version}_{sw_version}.json" + return FixtureResult(filename=save_filename, folder=SMART_FOLDER, data=response) + + +async def get_smart_fixtures(device: SmartDevice, batch_size: int): + """Get fixture for new TAPO style protocol.""" + test_calls, successes = await get_smart_test_calls(device) + for test_call in test_calls: click.echo(f"Testing {test_call.module}..", nl=False) try: click.echo(f"Testing {test_call}..", nl=False) - response = await device.protocol.query( - SmartRequest._create_request_dict(test_call.request) - ) + if test_call.child_device_id == "": + response = await device.protocol.query( + SmartRequest._create_request_dict(test_call.request) + ) + else: + cp = _ChildProtocolWrapper(test_call.child_device_id, device.protocol) + response = await cp.query( + SmartRequest._create_request_dict(test_call.request) + ) except AuthenticationError as ex: _echo_error( f"Unable to query the device due to an authentication error: {ex}", @@ -413,6 +564,7 @@ async def get_smart_fixture(device: SmartDevice, batch_size: int): in [ SmartErrorCode.UNKNOWN_METHOD_ERROR, SmartErrorCode.TRANSPORT_NOT_AVAILABLE_ERROR, + SmartErrorCode.UNSPECIFIC_ERROR, ] ): click.echo(click.style("FAIL - EXPECTED", fg="green")) @@ -430,13 +582,57 @@ async def get_smart_fixture(device: SmartDevice, batch_size: int): finally: await device.protocol.close() - requests = [] - for succ in successes: - requests.append(succ.request) + device_requests: Dict[str, List[SmartRequest]] = {} + for success in successes: + device_request = device_requests.setdefault(success.child_device_id, []) + device_request.append(success.request) + + scrubbed_device_ids = { + device_id: f"SCRUBBED_CHILD_DEVICE_ID_{index}" + for index, device_id in enumerate(device_requests.keys()) + if device_id != "" + } final = await _make_requests_or_exit( - device, requests, "all successes at once", batch_size + device, + device_requests[""], + "all successes at once", + batch_size, + child_device_id="", ) + fixture_results = [] + for child_device_id, requests in device_requests.items(): + if child_device_id == "": + continue + response = await _make_requests_or_exit( + device, + requests, + "all child successes at once", + batch_size, + child_device_id=child_device_id, + ) + scrubbed = scrubbed_device_ids[child_device_id] + if "get_device_info" in response and "device_id" in response["get_device_info"]: + response["get_device_info"]["device_id"] = scrubbed + # If the child is a different model to the parent create a seperate fixture + if ( + "get_device_info" in response + and (child_model := response["get_device_info"].get("model")) + and child_model != final["get_device_info"]["model"] + ): + fixture_results.append(get_smart_child_fixture(response)) + else: + cd = final.setdefault("child_devices", {}) + cd[scrubbed] = response + + # Scrub the device ids in the parent + if gc := final.get("get_child_device_component_list"): + for child in gc["child_component_list"]: + device_id = child["device_id"] + child["device_id"] = scrubbed_device_ids[device_id] + for child in final["get_child_device_list"]["child_device_list"]: + device_id = child["device_id"] + child["device_id"] = scrubbed_device_ids[device_id] # 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. @@ -454,8 +650,11 @@ async def get_smart_fixture(device: SmartDevice, batch_size: int): sw_version = sw_version.split(" ", maxsplit=1)[0] save_filename = f"{model}_{hw_version}_{sw_version}.json" - copy_folder = "kasa/tests/fixtures/smart/" - return save_filename, copy_folder, final + copy_folder = SMART_FOLDER + fixture_results.insert( + 0, FixtureResult(filename=save_filename, folder=copy_folder, data=final) + ) + return fixture_results if __name__ == "__main__": diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 29298e2e0..1ece6c872 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -75,6 +75,13 @@ class GetRulesParams(SmartRequestParams): start_index: int = 0 + @dataclass + class GetScheduleRulesParams(SmartRequestParams): + """Get Rules Params.""" + + start_index: int = 0 + schedule_mode: str = "" + @dataclass class GetTriggerLogsParams(SmartRequestParams): """Trigger Logs params.""" @@ -166,6 +173,16 @@ def get_device_time() -> "SmartRequest": """Get device time.""" return SmartRequest("get_device_time") + @staticmethod + def get_child_device_list() -> "SmartRequest": + """Get child device list.""" + return SmartRequest("get_child_device_list") + + @staticmethod + def get_child_device_component_list() -> "SmartRequest": + """Get child device component list.""" + return SmartRequest("get_child_device_component_list") + @staticmethod def get_wireless_scan_info( params: Optional[GetRulesParams] = None, @@ -179,7 +196,7 @@ def get_wireless_scan_info( def get_schedule_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest": """Get schedule rules.""" return SmartRequest( - "get_schedule_rules", params or SmartRequest.GetRulesParams() + "get_schedule_rules", params or SmartRequest.GetScheduleRulesParams() ) @staticmethod @@ -381,4 +398,13 @@ def get_component_requests(component_id, ver_code): SmartRequest.get_raw_request("get_alarm_configure"), ], "alarm_logs": [SmartRequest.get_raw_request("get_alarm_triggers")], + "child_device": [ + SmartRequest.get_raw_request("get_child_device_list"), + SmartRequest.get_raw_request("get_child_device_component_list"), + ], + "control_child": [], + "homekit": [SmartRequest.get_raw_request("get_homekit_info")], + "dimmer_calibration": [], + "fan_control": [], + "overheat_protection": [], } From 0306e05fb9e035138a72bdc2cb0847077d857646 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 28 Feb 2024 17:57:02 +0000 Subject: [PATCH 351/892] Fix energy module calling get_current_power (#798) --- kasa/smart/modules/energymodule.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/kasa/smart/modules/energymodule.py b/kasa/smart/modules/energymodule.py index 5782a23fd..0479de297 100644 --- a/kasa/smart/modules/energymodule.py +++ b/kasa/smart/modules/energymodule.py @@ -43,12 +43,12 @@ def __init__(self, device: "SmartDevice", module: str): def query(self) -> Dict: """Query to execute during the update cycle.""" - return { + req = { "get_energy_usage": None, - # The current_power in get_energy_usage is more precise (mw vs. w), - # making this rather useless, but maybe there are version differences? - "get_current_power": None, } + if self.supported_version > 1: + req["get_current_power"] = None + return req @property def current_power(self): @@ -58,7 +58,9 @@ def current_power(self): @property def energy(self): """Return get_energy_usage results.""" - return self.data["get_energy_usage"] + if en := self.data.get("get_energy_usage"): + return en + return self.data @property def emeter_realtime(self): From fcad0d2344deab5cd92d80f2d4be49fd7dab873c Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 1 Mar 2024 18:32:45 +0000 Subject: [PATCH 352/892] Add WallSwitch device type and autogenerate supported devices docs (#758) --- .github/workflows/ci.yml | 6 +- .pre-commit-config.yaml | 11 ++ README.md | 128 +++----------- SUPPORTED.md | 210 +++++++++++++++++++++++ devtools/check_readme_vs_fixtures.py | 43 ----- devtools/generate_supported.py | 241 +++++++++++++++++++++++++++ docs/source/SUPPORTED.md | 3 + docs/source/index.rst | 1 + kasa/cli.py | 12 +- kasa/device.py | 5 + kasa/device_factory.py | 39 ++++- kasa/device_type.py | 1 + kasa/iot/__init__.py | 3 +- kasa/iot/iotplug.py | 16 +- kasa/smart/smartdevice.py | 47 +++--- kasa/tests/device_fixtures.py | 51 +++++- kasa/tests/test_device_factory.py | 20 ++- kasa/tests/test_discovery.py | 12 +- kasa/tests/test_plug.py | 44 ++++- poetry.lock | 30 +++- pyproject.toml | 2 +- 21 files changed, 714 insertions(+), 211 deletions(-) create mode 100644 SUPPORTED.md delete mode 100644 devtools/check_readme_vs_fixtures.py create mode 100755 devtools/generate_supported.py create mode 100644 docs/source/SUPPORTED.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 779f6b19c..110d452ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,9 @@ jobs: run: | python -m pip install --upgrade pip poetry poetry install + - name: "Check supported device md files are up to date" + run: | + poetry run pre-commit run generate-supported --all-files - name: "Linting and code formating (ruff)" run: | poetry run pre-commit run ruff --all-files @@ -47,9 +50,6 @@ 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: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4bbfd8c51..4d1f0a4c6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,3 +27,14 @@ repos: hooks: - id: doc8 additional_dependencies: [tomli] + +- repo: local + hooks: + - id: generate-supported + name: Generate supported devices + description: This hook generates the supported device sections of README.md and SUPPORTED.md + entry: devtools/generate_supported.py + language: system # Required or pre-commit creates a new venv + verbose: true # Show output on success + types: [json] + pass_filenames: false # passing filenames causes the hook to run in batches against all-files diff --git a/README.md b/README.md index 4b45c822d..7ffda4c73 100644 --- a/README.md +++ b/README.md @@ -220,120 +220,32 @@ Note, that this works currently only on kasa-branded devices which use port 9999 ## Supported devices -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 `python -m devtools.dump_devinfo` to generate one).** - -### Plugs - -* HS100 -* HS103 -* HS105 -* HS107 -* HS110 -* KP100 -* KP105 -* KP115 -* KP125 -* KP125M [See note below](#newer-kasa-branded-devices) -* KP401 -* EP10 -* EP25 [See note below](#newer-kasa-branded-devices) - -### Power Strips - -* EP40 -* HS300 -* KP303 -* KP200 (in wall) -* KP400 -* KP405 (dimmer) - -### Wall switches - -* ES20M -* HS200 -* HS210 -* HS220 -* KS200M (partial support, no motion, no daylight detection) -* KS220M (partial support, no motion, no daylight detection) -* KS230 +The following devices have been tested and confirmed as working. 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). -### Bulbs - -* LB100 -* LB110 -* LB120 -* LB130 -* LB230 -* KL50 -* KL60 -* KL110 -* KL120 -* KL125 -* KL130 -* KL135 - -### Light strips - -* KL400L5 -* KL420L5 -* KL430 - -### 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: - -#### Plugs - -* Tapo P110 -* Tapo P125M -* Tapo P135 (dimming not yet supported) -* Tapo TP15 - -#### 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 - -#### Power strips - -* Tapo P300 -* Tapo TP25 - -#### Hubs - -* Tapo H100 + + +### Supported Kasa devices -### Newer Kasa branded devices +- **Plugs**: EP10, EP25\*, HS100\*\*, HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M\*, KP401 +- **Power Strips**: EP40, HS107, HS300, KP200, KP303, KP400 +- **Wall Switches**: ES20M, HS200, HS210, HS220, KP405, KS200M, KS205\*, KS220M, KS225\*, KS230 +- **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110, LB120, LB130 +- **Light Strips**: KL400L5, KL420L5, KL430 -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 -devices: +### Supported Tapo\* devices -* Kasa EP25 (plug) hw_version 2.6 -* Kasa KP125M (plug) -* Kasa KS205 (Wifi/Matter Wall Switch) -* Kasa KS225 (Wifi/Matter Wall Dimmer Switch) +- **Plugs**: P100, P110, P125M, P135, TP15 +- **Power Strips**: P300, TP25 +- **Wall Switches**: S500D, S505 +- **Bulbs**: L510B, L510E, L530E +- **Light Strips**: L900-10, L900-5, L920-5, L930-5 +- **Hubs**: H100 + +*  Model requires authentication
+** Newer versions require authentication -**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).** +See [supported devices in our documentation](SUPPORTED.md) for more detailed information about tested hardware and software versions. ## Resources diff --git a/SUPPORTED.md b/SUPPORTED.md new file mode 100644 index 000000000..9a740d6a8 --- /dev/null +++ b/SUPPORTED.md @@ -0,0 +1,210 @@ +# Supported devices + +The following devices have been tested and confirmed as working. 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). + + + +## Kasa devices + +Some newer Kasa devices require authentication. These are marked with * in the list below. + +### Plugs + +- **EP10** + - Hardware: 1.0 (US) / Firmware: 1.0.2 +- **EP25** + - Hardware: 2.6 (US) / Firmware: 1.0.1\* + - Hardware: 2.6 (US) / Firmware: 1.0.2\* +- **HS100** + - Hardware: 1.0 (UK) / Firmware: 1.2.6 + - Hardware: 4.1 (UK) / Firmware: 1.1.0\* + - Hardware: 1.0 (US) / Firmware: 1.2.5 + - Hardware: 2.0 (US) / Firmware: 1.5.6 +- **HS103** + - Hardware: 1.0 (US) / Firmware: 1.5.7 + - Hardware: 2.1 (US) / Firmware: 1.1.2 + - Hardware: 2.1 (US) / Firmware: 1.1.4 +- **HS105** + - Hardware: 1.0 (US) / Firmware: 1.2.9 + - Hardware: 1.0 (US) / Firmware: 1.5.6 +- **HS110** + - Hardware: 1.0 (EU) / Firmware: 1.2.5 + - Hardware: 2.0 (EU) / Firmware: 1.5.2 + - Hardware: 4.0 (EU) / Firmware: 1.0.4 + - Hardware: 1.0 (US) / Firmware: 1.0.8 +- **KP100** + - Hardware: 3.0 (US) / Firmware: 1.0.1 +- **KP105** + - Hardware: 1.0 (UK) / Firmware: 1.0.5 + - Hardware: 1.0 (UK) / Firmware: 1.0.7 +- **KP115** + - Hardware: 1.0 (EU) / Firmware: 1.0.16 + - Hardware: 1.0 (US) / Firmware: 1.0.17 + - Hardware: 1.0 (US) / Firmware: 1.0.21 +- **KP125** + - Hardware: 1.0 (US) / Firmware: 1.0.6 +- **KP125M** + - Hardware: 1.0 (US) / Firmware: 1.1.3\* +- **KP401** + - Hardware: 1.0 (US) / Firmware: 1.0.0 + +### Power Strips + +- **EP40** + - Hardware: 1.0 (US) / Firmware: 1.0.2 +- **HS107** + - Hardware: 1.0 (US) / Firmware: 1.0.8 +- **HS300** + - Hardware: 1.0 (US) / Firmware: 1.0.10 + - Hardware: 2.0 (US) / Firmware: 1.0.12 + - Hardware: 2.0 (US) / Firmware: 1.0.3 +- **KP200** + - Hardware: 3.0 (US) / Firmware: 1.0.3 +- **KP303** + - Hardware: 1.0 (UK) / Firmware: 1.0.3 + - Hardware: 2.0 (US) / Firmware: 1.0.3 +- **KP400** + - Hardware: 1.0 (US) / Firmware: 1.0.10 + - Hardware: 2.0 (US) / Firmware: 1.0.6 + +### Wall Switches + +- **ES20M** + - Hardware: 1.0 (US) / Firmware: 1.0.8 +- **HS200** + - Hardware: 1.0 (US) / Firmware: 1.1.0 + - Hardware: 2.0 (US) / Firmware: 1.5.7 + - Hardware: 5.0 (US) / Firmware: 1.0.2 +- **HS210** + - Hardware: 1.0 (US) / Firmware: 1.5.8 +- **HS220** + - Hardware: 1.0 (US) / Firmware: 1.5.7 + - Hardware: 1.0 (US) / Firmware: 1.5.7 + - Hardware: 2.0 (US) / Firmware: 1.0.3 +- **KP405** + - Hardware: 1.0 (US) / Firmware: 1.0.5 +- **KS200M** + - Hardware: 1.0 (US) / Firmware: 1.0.8 +- **KS205** + - Hardware: 1.0 (US) / Firmware: 1.0.2\* +- **KS220M** + - Hardware: 1.0 (US) / Firmware: 1.0.4 +- **KS225** + - Hardware: 1.0 (US) / Firmware: 1.0.2\* +- **KS230** + - Hardware: 1.0 (US) / Firmware: 1.0.14 + +### Bulbs + +- **KL110** + - Hardware: 1.0 (US) / Firmware: 1.8.11 +- **KL120** + - Hardware: 1.0 (US) / Firmware: 1.8.6 +- **KL125** + - Hardware: 1.20 (US) / Firmware: 1.0.5 + - Hardware: 2.0 (US) / Firmware: 1.0.7 + - Hardware: 4.0 (US) / Firmware: 1.0.5 +- **KL130** + - Hardware: 1.0 (EU) / Firmware: 1.8.8 + - Hardware: 1.0 (US) / Firmware: 1.8.11 +- **KL135** + - Hardware: 1.0 (US) / Firmware: 1.0.6 +- **KL50** + - Hardware: 1.0 (US) / Firmware: 1.1.13 +- **KL60** + - Hardware: 1.0 (UN) / Firmware: 1.1.4 + - Hardware: 1.0 (US) / Firmware: 1.1.13 +- **LB100** + - Hardware: 1.0 (US) / Firmware: 1.4.3 +- **LB110** + - Hardware: 1.0 (US) / Firmware: 1.8.11 +- **LB120** + - Hardware: 1.0 (US) / Firmware: 1.1.0 +- **LB130** + - Hardware: 1.0 (US) / Firmware: 1.6.0 + +### Light Strips + +- **KL400L5** + - Hardware: 1.0 (US) / Firmware: 1.0.5 + - Hardware: 1.0 (US) / Firmware: 1.0.8 +- **KL420L5** + - Hardware: 1.0 (US) / Firmware: 1.0.2 +- **KL430** + - Hardware: 2.0 (UN) / Firmware: 1.0.8 + - Hardware: 1.0 (US) / Firmware: 1.0.10 + - Hardware: 2.0 (US) / Firmware: 1.0.11 + - Hardware: 2.0 (US) / Firmware: 1.0.8 + - Hardware: 2.0 (US) / Firmware: 1.0.9 + + +## Tapo devices + +All Tapo devices require authentication. + +### Plugs + +- **P100** + - Hardware: 1.0.0 / Firmware: 1.1.3 + - Hardware: 1.0.0 / Firmware: 1.3.7 +- **P110** + - Hardware: 1.0 (EU) / Firmware: 1.0.7 + - Hardware: 1.0 (EU) / Firmware: 1.2.3 + - Hardware: 1.0 (UK) / Firmware: 1.3.0 +- **P125M** + - Hardware: 1.0 (US) / Firmware: 1.1.0 +- **P135** + - Hardware: 1.0 (US) / Firmware: 1.0.5 +- **TP15** + - Hardware: 1.0 (US) / Firmware: 1.0.3 + +### Power Strips + +- **P300** + - Hardware: 1.0 (EU) / Firmware: 1.0.13 + - Hardware: 1.0 (EU) / Firmware: 1.0.7 +- **TP25** + - Hardware: 1.0 (US) / Firmware: 1.0.2 + +### Wall Switches + +- **S500D** + - Hardware: 1.0 (US) / Firmware: 1.0.5 +- **S505** + - Hardware: 1.0 (US) / Firmware: 1.0.2 + +### Bulbs + +- **L510B** + - Hardware: 3.0 (EU) / Firmware: 1.0.5 +- **L510E** + - Hardware: 3.0 (US) / Firmware: 1.0.5 + - Hardware: 3.0 (US) / Firmware: 1.1.2 +- **L530E** + - Hardware: 3.0 (EU) / Firmware: 1.0.6 + - Hardware: 3.0 (EU) / Firmware: 1.1.0 + - Hardware: 3.0 (EU) / Firmware: 1.1.6 + - Hardware: 2.0 (US) / Firmware: 1.1.0 + +### Light Strips + +- **L900-10** + - Hardware: 1.0 (EU) / Firmware: 1.0.17 + - Hardware: 1.0 (US) / Firmware: 1.0.11 +- **L900-5** + - Hardware: 1.0 (EU) / Firmware: 1.0.17 + - Hardware: 1.0 (EU) / Firmware: 1.1.0 +- **L920-5** + - Hardware: 1.0 (US) / Firmware: 1.1.0 + - Hardware: 1.0 (US) / Firmware: 1.1.3 +- **L930-5** + - Hardware: 1.0 (US) / Firmware: 1.1.2 + +### Hubs + +- **H100** + - Hardware: 1.0 (EU) / Firmware: 1.2.3 + - Hardware: 1.0 (EU) / Firmware: 1.5.5 + + + diff --git a/devtools/check_readme_vs_fixtures.py b/devtools/check_readme_vs_fixtures.py deleted file mode 100644 index 88663621a..000000000 --- a/devtools/check_readme_vs_fixtures.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Script that checks if README.md is missing devices that have fixtures.""" -import re -import sys - -from kasa.tests.conftest import ( - ALL_DEVICES, - BULBS, - DIMMERS, - LIGHT_STRIPS, - PLUGS, - STRIPS, -) - -with open("README.md") as f: - readme = f.read() - -typemap = { - "light strips": LIGHT_STRIPS, - "bulbs": BULBS, - "plugs": PLUGS, - "strips": STRIPS, - "dimmers": DIMMERS, -} - - -def _get_device_type(dev, typemap): - for typename, devs in typemap.items(): - if dev in devs: - return typename - else: - return "Unknown type" - - -found_unlisted = False -for dev in ALL_DEVICES: - 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/generate_supported.py b/devtools/generate_supported.py new file mode 100755 index 000000000..85dc3992e --- /dev/null +++ b/devtools/generate_supported.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python +"""Script that checks supported devices and updates README.md and SUPPORTED.md.""" +import json +import os +import sys +from pathlib import Path +from string import Template +from typing import NamedTuple + +from kasa.device_factory import _get_device_type_from_sys_info +from kasa.device_type import DeviceType +from kasa.smart.smartdevice import SmartDevice + + +class SupportedVersion(NamedTuple): + """Supported version.""" + + region: str + hw: str + fw: str + auth: bool + + +# The order of devices in this dict drives the display order +DEVICE_TYPE_TO_PRODUCT_GROUP = { + DeviceType.Plug: "Plugs", + DeviceType.Strip: "Power Strips", + DeviceType.StripSocket: "Power Strips", + DeviceType.Dimmer: "Wall Switches", + DeviceType.WallSwitch: "Wall Switches", + DeviceType.Bulb: "Bulbs", + DeviceType.LightStrip: "Light Strips", + DeviceType.Hub: "Hubs", + DeviceType.Sensor: "Sensors", +} + + +SUPPORTED_FILENAME = "SUPPORTED.md" +README_FILENAME = "README.md" + +IOT_FOLDER = "kasa/tests/fixtures/" +SMART_FOLDER = "kasa/tests/fixtures/smart/" + + +def generate_supported(args): + """Generate the SUPPORTED.md from the fixtures.""" + print_diffs = "--print-diffs" in args + running_in_ci = "CI" in os.environ + print("Generating supported devices") + if running_in_ci: + print_diffs = True + print("Detected running in CI") + + supported = {"kasa": {}, "tapo": {}} + + _get_iot_supported(supported) + _get_smart_supported(supported) + + readme_updated = _update_supported_file( + README_FILENAME, _supported_summary(supported), print_diffs + ) + supported_updated = _update_supported_file( + SUPPORTED_FILENAME, _supported_detail(supported), print_diffs + ) + if not readme_updated and not supported_updated: + print("Supported devices unchanged.") + + +def _update_supported_file(filename, supported_text, print_diffs) -> bool: + with open(filename) as f: + contents = f.readlines() + + start_index = end_index = None + for index, line in enumerate(contents): + if line == "\n": + start_index = index + 1 + if line == "\n": + end_index = index + + current_text = "".join(contents[start_index:end_index]) + if current_text != supported_text: + print( + f"{filename} has been modified with updated " + + "supported devices, add file to commit." + ) + if print_diffs: + print("##CURRENT##") + print(current_text) + print("##NEW##") + print(supported_text) + + new_contents = contents[:start_index] + end_contents = contents[end_index:] + new_contents.append(supported_text) + new_contents.extend(end_contents) + + with open(filename, "w") as f: + new_contents_text = "".join(new_contents) + f.write(new_contents_text) + return True + return False + + +def _supported_summary(supported): + return _supported_text( + supported, + "### Supported $brand$auth devices\n\n$types\n", + "- **$type_**: $models\n", + ) + + +def _supported_detail(supported): + return _supported_text( + supported, + "## $brand devices\n\n$preamble\n\n$types\n", + "### $type_\n\n$models\n", + "- **$model**\n$versions", + " - Hardware: $hw$region / Firmware: $fw$auth_flag\n", + ) + + +def _supported_text( + supported, brand_template, types_template, model_template="", version_template="" +): + brandt = Template(brand_template) + typest = Template(types_template) + modelt = Template(model_template) + versst = Template(version_template) + brands = "" + version: SupportedVersion + for brand, types in supported.items(): + preamble_text = ( + "Some newer Kasa devices require authentication. " + + "These are marked with * in the list below." + if brand == "kasa" + else "All Tapo devices require authentication." + ) + brand_text = brand.capitalize() + brand_auth = r"\*" if brand == "tapo" else "" + types_text = "" + for supported_type, models in sorted( + # Sort by device type order in the enum + types.items(), + key=lambda st: list(DEVICE_TYPE_TO_PRODUCT_GROUP.values()).index(st[0]), + ): + models_list = [] + models_text = "" + for model, versions in sorted(models.items()): + auth_count = 0 + versions_text = "" + for version in sorted(versions): + region_text = f" ({version.region})" if version.region else "" + auth_count += 1 if version.auth else 0 + vauth_flag = ( + r"\*" if version.auth and brand == "kasa" else "" + ) + if version_template: + versions_text += versst.substitute( + hw=version.hw, + fw=version.fw, + region=region_text, + auth_flag=vauth_flag, + ) + if brand == "kasa" and auth_count > 0: + auth_flag = ( + r"\*" + if auth_count == len(versions) + else r"\*\*" + ) + else: + auth_flag = "" + if model_template: + models_text += modelt.substitute( + model=model, versions=versions_text, auth_flag=auth_flag + ) + else: + models_list.append(f"{model}{auth_flag}") + models_text = models_text if models_text else ", ".join(models_list) + types_text += typest.substitute(type_=supported_type, models=models_text) + brands += brandt.substitute( + brand=brand_text, types=types_text, auth=brand_auth, preamble=preamble_text + ) + return brands + + +def _get_smart_supported(supported): + for file in Path(SMART_FOLDER).glob("*.json"): + with file.open() as f: + fixture_data = json.load(f) + + model, _, region = fixture_data["discovery_result"]["device_model"].partition( + "(" + ) + # P100 doesn't have region HW + region = region.replace(")", "") if region else "" + device_type = fixture_data["discovery_result"]["device_type"] + _protocol, devicetype = device_type.split(".") + brand = devicetype[:4].lower() + components = [ + component["id"] + for component in fixture_data["component_nego"]["component_list"] + ] + dt = SmartDevice._get_device_type_from_components(components, device_type) + supported_type = DEVICE_TYPE_TO_PRODUCT_GROUP[dt] + + hw_version = fixture_data["get_device_info"]["hw_ver"] + fw_version = fixture_data["get_device_info"]["fw_ver"] + fw_version = fw_version.split(" ", maxsplit=1)[0] + + stype = supported[brand].setdefault(supported_type, {}) + smodel = stype.setdefault(model, []) + smodel.append( + SupportedVersion(region=region, hw=hw_version, fw=fw_version, auth=True) + ) + + +def _get_iot_supported(supported): + for file in Path(IOT_FOLDER).glob("*.json"): + with file.open() as f: + fixture_data = json.load(f) + sysinfo = fixture_data["system"]["get_sysinfo"] + dt = _get_device_type_from_sys_info(fixture_data) + supported_type = DEVICE_TYPE_TO_PRODUCT_GROUP[dt] + + model, _, region = sysinfo["model"][:-1].partition("(") + auth = "discovery_result" in fixture_data + stype = supported["kasa"].setdefault(supported_type, {}) + smodel = stype.setdefault(model, []) + fw = sysinfo["sw_ver"].split(" ", maxsplit=1)[0] + smodel.append( + SupportedVersion(region=region, hw=sysinfo["hw_ver"], fw=fw, auth=auth) + ) + + +def main(): + """Entry point to module.""" + generate_supported(sys.argv[1:]) + + +if __name__ == "__main__": + generate_supported(sys.argv[1:]) diff --git a/docs/source/SUPPORTED.md b/docs/source/SUPPORTED.md new file mode 100644 index 000000000..3ebfbeb29 --- /dev/null +++ b/docs/source/SUPPORTED.md @@ -0,0 +1,3 @@ +```{include} ../../SUPPORTED.md +:relative-docs: doc/source +``` diff --git a/docs/source/index.rst b/docs/source/index.rst index 346c53d08..9dc648a9c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -15,3 +15,4 @@ smartdimmer smartstrip smartlightstrip + SUPPORTED diff --git a/kasa/cli.py b/kasa/cli.py index 83980ec20..78553ebf2 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -26,7 +26,15 @@ UnsupportedDeviceError, ) from kasa.discover import DiscoveryResult -from kasa.iot import IotBulb, IotDevice, IotDimmer, IotLightStrip, IotPlug, IotStrip +from kasa.iot import ( + IotBulb, + IotDevice, + IotDimmer, + IotLightStrip, + IotPlug, + IotStrip, + IotWallSwitch, +) from kasa.smart import SmartBulb, SmartDevice try: @@ -63,11 +71,13 @@ def wrapper(message=None, *args, **kwargs): TYPE_TO_CLASS = { "plug": IotPlug, + "switch": IotWallSwitch, "bulb": IotBulb, "dimmer": IotDimmer, "strip": IotStrip, "lightstrip": IotLightStrip, "iot.plug": IotPlug, + "iot.switch": IotWallSwitch, "iot.bulb": IotBulb, "iot.dimmer": IotDimmer, "iot.strip": IotStrip, diff --git a/kasa/device.py b/kasa/device.py index 72967ee2d..cebec582c 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -212,6 +212,11 @@ def is_plug(self) -> bool: """Return True if the device is a plug.""" return self.device_type == DeviceType.Plug + @property + def is_wallswitch(self) -> bool: + """Return True if the device is a switch.""" + return self.device_type == DeviceType.WallSwitch + @property def is_strip(self) -> bool: """Return True if the device is a strip.""" diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 2e8ba0c98..d35df09c4 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -5,9 +5,18 @@ from .aestransport import AesTransport from .device import Device +from .device_type import DeviceType from .deviceconfig import DeviceConfig from .exceptions import KasaException, UnsupportedDeviceError -from .iot import IotBulb, IotDevice, IotDimmer, IotLightStrip, IotPlug, IotStrip +from .iot import ( + IotBulb, + IotDevice, + IotDimmer, + IotLightStrip, + IotPlug, + IotStrip, + IotWallSwitch, +) from .iotprotocol import IotProtocol from .klaptransport import KlapTransport, KlapTransportV2 from .protocol import ( @@ -105,7 +114,7 @@ def _perf_log(has_params, perf_type): ) -def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[IotDevice]: +def _get_device_type_from_sys_info(info: Dict[str, Any]) -> DeviceType: """Find SmartDevice subclass for device described by passed data.""" if "system" not in info or "get_sysinfo" not in info["system"]: raise KasaException("No 'system' or 'get_sysinfo' in response") @@ -116,22 +125,36 @@ def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[IotDevice]: raise KasaException("Unable to find the device type field!") if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: - return IotDimmer + return DeviceType.Dimmer if "smartplug" in type_.lower(): if "children" in sysinfo: - return IotStrip - - return IotPlug + return DeviceType.Strip + if (dev_name := sysinfo.get("dev_name")) and "light" in dev_name.lower(): + return DeviceType.WallSwitch + return DeviceType.Plug if "smartbulb" in type_.lower(): if "length" in sysinfo: # strips have length - return IotLightStrip + return DeviceType.LightStrip - return IotBulb + return DeviceType.Bulb raise UnsupportedDeviceError("Unknown device type: %s" % type_) +def get_device_class_from_sys_info(sysinfo: Dict[str, Any]) -> Type[IotDevice]: + """Find SmartDevice subclass for device described by passed data.""" + TYPE_TO_CLASS = { + DeviceType.Bulb: IotBulb, + DeviceType.Plug: IotPlug, + DeviceType.Dimmer: IotDimmer, + DeviceType.Strip: IotStrip, + DeviceType.WallSwitch: IotWallSwitch, + DeviceType.LightStrip: IotLightStrip, + } + return TYPE_TO_CLASS[_get_device_type_from_sys_info(sysinfo)] + + def get_device_class_from_family(device_type: str) -> Optional[Type[Device]]: """Return the device class from the type name.""" supported_device_types: Dict[str, Type[Device]] = { diff --git a/kasa/device_type.py b/kasa/device_type.py index a44efffa8..80a816443 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -11,6 +11,7 @@ class DeviceType(Enum): Plug = "plug" Bulb = "bulb" Strip = "strip" + WallSwitch = "wallswitch" StripSocket = "stripsocket" Dimmer = "dimmer" LightStrip = "lightstrip" diff --git a/kasa/iot/__init__.py b/kasa/iot/__init__.py index 2ee03d694..e1e4b5760 100644 --- a/kasa/iot/__init__.py +++ b/kasa/iot/__init__.py @@ -3,7 +3,7 @@ from .iotdevice import IotDevice from .iotdimmer import IotDimmer from .iotlightstrip import IotLightStrip -from .iotplug import IotPlug +from .iotplug import IotPlug, IotWallSwitch from .iotstrip import IotStrip __all__ = [ @@ -13,4 +13,5 @@ "IotStrip", "IotDimmer", "IotLightStrip", + "IotWallSwitch", ] diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index e408bb3ce..3f776b985 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -13,7 +13,7 @@ class IotPlug(IotDevice): - r"""Representation of a TP-Link Smart Switch. + r"""Representation of a TP-Link Smart Plug. To initialize, you have to await :func:`update()` at least once. This will allow accessing the properties using the exposed properties. @@ -101,3 +101,17 @@ async def set_led(self, state: bool): def state_information(self) -> Dict[str, Any]: """Return switch-specific state information.""" return {} + + +class IotWallSwitch(IotPlug): + """Representation of a TP-Link Smart Wall Switch.""" + + def __init__( + self, + host: str, + *, + config: Optional[DeviceConfig] = None, + protocol: Optional[BaseProtocol] = None, + ) -> None: + super().__init__(host=host, config=config, protocol=protocol) + self._device_type = DeviceType.WallSwitch diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 66db2c58c..8b0236c37 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -63,12 +63,6 @@ async def _initialize_children(self): ) for child_info in children } - # TODO: This may not be the best approach, but it allows distinguishing - # between power strips and hubs for the time being. - if all(child.is_plug for child in self._children.values()): - self._device_type = DeviceType.Strip - else: - self._device_type = DeviceType.Hub @property def children(self) -> Sequence["SmartDevice"]: @@ -519,21 +513,30 @@ def device_type(self) -> DeviceType: if self._device_type is not DeviceType.Unknown: return self._device_type - if self.children: - if "SMART.TAPOHUB" in self.sys_info["type"]: - self._device_type = DeviceType.Hub - else: - self._device_type = DeviceType.Strip - elif "light_strip" in self._components: - self._device_type = DeviceType.LightStrip - elif "dimmer_calibration" in self._components: - self._device_type = DeviceType.Dimmer - elif "brightness" in self._components: - self._device_type = DeviceType.Bulb - elif "PLUG" in self.sys_info["type"]: - self._device_type = DeviceType.Plug - else: - _LOGGER.warning("Unknown device type, falling back to plug") - self._device_type = DeviceType.Plug + self._device_type = self._get_device_type_from_components( + list(self._components.keys()), self._info["type"] + ) return self._device_type + + @staticmethod + def _get_device_type_from_components( + components: List[str], device_type: str + ) -> DeviceType: + """Find type to be displayed as a supported device category.""" + if "HUB" in device_type: + return DeviceType.Hub + if "PLUG" in device_type: + if "child_device" in components: + return DeviceType.Strip + return DeviceType.Plug + if "light_strip" in components: + return DeviceType.LightStrip + if "dimmer_calibration" in components: + return DeviceType.Dimmer + if "brightness" in components: + return DeviceType.Bulb + if "SWITCH" in device_type: + return DeviceType.WallSwitch + _LOGGER.warning("Unknown device type, falling back to plug") + return DeviceType.Plug diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index e4f513ffc..73d171d23 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -7,7 +7,7 @@ Device, Discover, ) -from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip +from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch from kasa.smart import SmartBulb, SmartDevice from .fakeprotocol_iot import FakeIotProtocol @@ -60,15 +60,12 @@ "HS103", "HS105", "HS110", - "HS200", - "HS210", "EP10", "KP100", "KP105", "KP115", "KP125", "KP401", - "KS200M", } # P135 supports dimming, but its not currently support # by the library @@ -77,15 +74,25 @@ "P110", "KP125M", "EP25", - "KS205", "P125M", - "S505", "TP15", } PLUGS = { *PLUGS_IOT, *PLUGS_SMART, } +SWITCHES_IOT = { + "HS200", + "HS210", + "KS200M", +} +SWITCHES_SMART = { + "KS205", + "KS225", + "S500D", + "S505", +} +SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} STRIPS_SMART = {"P300", "TP25"} STRIPS = {*STRIPS_IOT, *STRIPS_SMART} @@ -105,12 +112,15 @@ DIMMABLE = {*BULBS, *DIMMERS} -ALL_DEVICES_IOT = BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT) +ALL_DEVICES_IOT = ( + BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT).union(SWITCHES_IOT) +) ALL_DEVICES_SMART = ( BULBS_SMART.union(PLUGS_SMART) .union(STRIPS_SMART) .union(DIMMERS_SMART) .union(HUBS_SMART) + .union(SWITCHES_SMART) ) ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) @@ -160,7 +170,14 @@ def parametrize( ) bulb = parametrize("bulbs", model_filter=BULBS, protocol_filter={"SMART", "IOT"}) -plug = parametrize("plugs", model_filter=PLUGS, protocol_filter={"IOT"}) +plug = parametrize("plugs", model_filter=PLUGS, protocol_filter={"IOT", "SMART"}) +plug_iot = parametrize("plugs iot", model_filter=PLUGS, protocol_filter={"IOT"}) +wallswitch = parametrize( + "wall switches", model_filter=SWITCHES, protocol_filter={"IOT", "SMART"} +) +wallswitch_iot = parametrize( + "wall switches iot", model_filter=SWITCHES, protocol_filter={"IOT"} +) strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"}) dimmer = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) lightstrip = parametrize( @@ -213,6 +230,9 @@ def parametrize( plug_smart = parametrize( "plug devices smart", model_filter=PLUGS_SMART, protocol_filter={"SMART"} ) +switch_smart = parametrize( + "switch devices smart", model_filter=SWITCHES_SMART, protocol_filter={"SMART"} +) bulb_smart = parametrize( "bulb devices smart", model_filter=BULBS_SMART, protocol_filter={"SMART"} ) @@ -239,8 +259,8 @@ def check_categories(): + strip.args[1] + plug.args[1] + bulb.args[1] + + wallswitch.args[1] + lightstrip.args[1] - + plug_smart.args[1] + bulb_smart.args[1] + dimmers_smart.args[1] + hubs_smart.args[1] @@ -263,6 +283,9 @@ def device_for_fixture_name(model, protocol): for d in PLUGS_SMART: if d in model: return SmartDevice + for d in SWITCHES_SMART: + if d in model: + return SmartDevice for d in BULBS_SMART: if d in model: return SmartBulb @@ -283,6 +306,9 @@ def device_for_fixture_name(model, protocol): for d in PLUGS_IOT: if d in model: return IotPlug + for d in SWITCHES_IOT: + if d in model: + return IotWallSwitch # Light strips are recognized also as bulbs, so this has to go first for d in BULBS_IOT_LIGHT_STRIP: @@ -325,6 +351,13 @@ async def get_device_for_fixture(fixture_data: FixtureInfo): d.protocol = FakeSmartProtocol(fixture_data.data, fixture_data.name) else: d.protocol = FakeIotProtocol(fixture_data.data) + if "discovery_result" in fixture_data.data: + discovery_data = {"result": fixture_data.data["discovery_result"]} + else: + discovery_data = { + "system": {"get_sysinfo": fixture_data.data["system"]["get_sysinfo"]} + } + d.update_from_discover_info(discovery_data) await _update_and_close(d) return d diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index 1519ca5f2..dc5144854 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -10,7 +10,11 @@ Discover, KasaException, ) -from kasa.device_factory import connect, get_protocol +from kasa.device_factory import ( + _get_device_type_from_sys_info, + connect, + get_protocol, +) from kasa.deviceconfig import ( ConnectionType, DeviceConfig, @@ -18,6 +22,7 @@ EncryptType, ) from kasa.discover import DiscoveryResult +from kasa.smart.smartdevice import SmartDevice def _get_connection_type_device_class(discovery_info): @@ -146,3 +151,16 @@ async def test_connect_http_client(discovery_data, mocker): assert dev.protocol._transport._http_client.client == http_client await dev.disconnect() await http_client.close() + + +async def test_device_types(dev: Device): + await dev.update() + if isinstance(dev, SmartDevice): + device_type = dev._discovery_info["result"]["device_type"] + res = SmartDevice._get_device_type_from_components( + dev._components.keys(), device_type + ) + else: + res = _get_device_type_from_sys_info(dev._last_update) + + assert dev.device_type == res diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 897d91d81..eb0391444 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -29,8 +29,9 @@ dimmer, lightstrip, new_discovery, - plug, + plug_iot, strip_iot, + wallswitch_iot, ) UNSUPPORTED = { @@ -55,7 +56,14 @@ } -@plug +@wallswitch_iot +async def test_type_detection_switch(dev: Device): + d = Discover._get_device_class(dev._last_update)("localhost") + assert d.is_wallswitch + assert d.device_type == DeviceType.WallSwitch + + +@plug_iot async def test_type_detection_plug(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_plug diff --git a/kasa/tests/test_plug.py b/kasa/tests/test_plug.py index 64c420f9d..9ccf3d043 100644 --- a/kasa/tests/test_plug.py +++ b/kasa/tests/test_plug.py @@ -1,6 +1,6 @@ from kasa import DeviceType -from .conftest import plug, plug_smart +from .conftest import plug_iot, plug_smart, switch_smart, wallswitch_iot from .test_smartdevice import SYSINFO_SCHEMA # these schemas should go to the mainlib as @@ -8,7 +8,7 @@ # as well as to check that faked devices are operating properly. -@plug +@plug_iot async def test_plug_sysinfo(dev): assert dev.sys_info is not None SYSINFO_SCHEMA(dev.sys_info) @@ -19,8 +19,34 @@ async def test_plug_sysinfo(dev): assert dev.is_plug or dev.is_strip -@plug -async def test_led(dev): +@wallswitch_iot +async def test_switch_sysinfo(dev): + assert dev.sys_info is not None + SYSINFO_SCHEMA(dev.sys_info) + + assert dev.model is not None + + assert dev.device_type == DeviceType.WallSwitch + assert dev.is_wallswitch + + +@plug_iot +async def test_plug_led(dev): + original = dev.led + + await dev.set_led(False) + await dev.update() + assert not dev.led + + await dev.set_led(True) + await dev.update() + assert dev.led + + await dev.set_led(original) + + +@wallswitch_iot +async def test_switch_led(dev): original = dev.led await dev.set_led(False) @@ -40,3 +66,13 @@ async def test_plug_device_info(dev): assert dev.model is not None assert dev.device_type == DeviceType.Plug or dev.device_type == DeviceType.Strip + + +@switch_smart +async def test_switch_device_info(dev): + assert dev._info is not None + assert dev.model is not None + + assert ( + dev.device_type == DeviceType.WallSwitch or dev.device_type == DeviceType.Dimmer + ) diff --git a/poetry.lock b/poetry.lock index 6195a6c52..eafa0b29c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1726,20 +1726,22 @@ test = ["cython", "html5lib", "pytest (>=4.6)", "typed_ast"] [[package]] name = "sphinx-rtd-theme" -version = "0.5.1" +version = "2.0.0" description = "Read the Docs theme for Sphinx" optional = true -python-versions = "*" +python-versions = ">=3.6" files = [ - {file = "sphinx_rtd_theme-0.5.1-py2.py3-none-any.whl", hash = "sha256:fa6bebd5ab9a73da8e102509a86f3fcc36dec04a0b52ea80e5a033b2aba00113"}, - {file = "sphinx_rtd_theme-0.5.1.tar.gz", hash = "sha256:eda689eda0c7301a80cf122dad28b1861e5605cbf455558f3775e1e8200e83a5"}, + {file = "sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586"}, + {file = "sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b"}, ] [package.dependencies] -sphinx = "*" +docutils = "<0.21" +sphinx = ">=5,<8" +sphinxcontrib-jquery = ">=4,<5" [package.extras] -dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client"] +dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] [[package]] name = "sphinxcontrib-applehelp" @@ -1786,6 +1788,20 @@ files = [ lint = ["docutils-stubs", "flake8", "mypy"] test = ["html5lib", "pytest"] +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +description = "Extension to include jQuery on newer Sphinx releases" +optional = true +python-versions = ">=2.7" +files = [ + {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, + {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, +] + +[package.dependencies] +Sphinx = ">=1.8" + [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" @@ -2130,4 +2146,4 @@ speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "aadbdc97219e5282f614f834c1318bbf8430fe769030f0a262e1922c5d7523b8" +content-hash = "fecc8870f967cc6da9d6e1fde0e9a9acd261d28c4ba57476250d17234dc2c876" diff --git a/pyproject.toml b/pyproject.toml index a35f4b90c..f3fa470e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ kasa-crypt = { "version" = ">=0.2.0", optional = true } # required only for docs sphinx = { version = "^5", optional = true } -sphinx_rtd_theme = { version = "^0", optional = true } +sphinx_rtd_theme = { version = "^2", optional = true } sphinxcontrib-programoutput = { version = "^0", optional = true } myst-parser = { version = "*", optional = true } docutils = { version = ">=0.17", optional = true } From eb4c048b57976b937ea6031a07a68c7a62c48761 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 5 Mar 2024 13:35:19 +0100 Subject: [PATCH 353/892] Simplify device __repr__ (#805) Previously: ``` >>> dev >>> dev.children[0] > ``` Now: ``` >>> dev Device: >>> dev.children[0] > ``` --- kasa/device.py | 6 +----- kasa/smart/smartchilddevice.py | 2 +- kasa/tests/test_smartdevice.py | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/kasa/device.py b/kasa/device.py index cebec582c..63eafa5b7 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -366,8 +366,4 @@ async def set_alias(self, alias: str): def __repr__(self): if self._last_update is None: return f"<{self.device_type} at {self.host} - update() needed>" - return ( - f"<{self.device_type} model {self.model} at {self.host}" - f" ({self.alias}), is_on: {self.is_on}" - f" - dev specific: {self.state_information}>" - ) + return f"<{self.device_type} at {self.host} - {self.alias} ({self.model})>" diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 6d7bfa587..1ea517aa6 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -56,4 +56,4 @@ def device_type(self) -> DeviceType: return dev_type def __repr__(self): - return f"" + return f"<{self.device_type} {self.alias} ({self.model}) of {self._parent}>" diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 92cca5a16..fdd342ca7 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -198,7 +198,7 @@ async def test_mac(dev): async def test_representation(dev): import re - pattern = re.compile("<.* model .* at .* (.*), is_on: .* - dev specific: .*>") + pattern = re.compile("") assert pattern.match(str(dev)) From 0d5a3c84391f903236b54fe48698076d39e74703 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 5 Mar 2024 15:41:40 +0100 Subject: [PATCH 354/892] Add brightness module (#806) Add module for controlling the brightness. --- kasa/smart/modules/__init__.py | 2 ++ kasa/smart/modules/brightness.py | 43 +++++++++++++++++++++++++++ kasa/smart/smartmodule.py | 11 +++++-- kasa/tests/device_fixtures.py | 2 -- kasa/tests/test_feature_brightness.py | 24 ++++++++++++--- 5 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 kasa/smart/modules/brightness.py diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 3e95dfe78..dc4e0cf5a 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -2,6 +2,7 @@ from .alarmmodule import AlarmModule from .autooffmodule import AutoOffModule from .battery import BatterySensor +from .brightness import Brightness from .childdevicemodule import ChildDeviceModule from .cloudmodule import CloudModule from .devicemodule import DeviceModule @@ -26,6 +27,7 @@ "ReportModule", "AutoOffModule", "LedModule", + "Brightness", "Firmware", "CloudModule", "LightTransitionModule", diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py new file mode 100644 index 000000000..03e9e238c --- /dev/null +++ b/kasa/smart/modules/brightness.py @@ -0,0 +1,43 @@ +"""Implementation of brightness module.""" +from typing import TYPE_CHECKING, Dict + +from ...feature import Feature, FeatureType +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class Brightness(SmartModule): + """Implementation of brightness module.""" + + REQUIRED_COMPONENT = "brightness" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Brightness", + container=self, + attribute_getter="brightness", + attribute_setter="set_brightness", + minimum_value=1, + maximum_value=100, + type=FeatureType.Number, + ) + ) + + def query(self) -> Dict: + """Query to execute during the update cycle.""" + # Brightness is contained in the main device info response. + return {} + + @property + def brightness(self): + """Return current brightness.""" + return self.data["brightness"] + + async def set_brightness(self, brightness: int): + """Set the brightness.""" + return await self.call("set_device_info", {"brightness": brightness}) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index e34f2260a..01a27360f 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -53,14 +53,19 @@ def call(self, method, params=None): def data(self): """Return response data for the module. - If module performs only a single query, the resulting response is unwrapped. + If the module performs only a single query, the resulting response is unwrapped. + If the module does not define a query, this property returns a reference + to the main "get_device_info" response. """ + dev = self._device q = self.query() + + if not q: + return dev.internal_state["get_device_info"] + q_keys = list(q.keys()) query_key = q_keys[0] - dev = self._device - # TODO: hacky way to check if update has been called. # The way this falls back to parent may not always be wanted. # Especially, devices can have their own firmware updates. diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 73d171d23..8a1b643bd 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -249,8 +249,6 @@ def parametrize( "devices iot", model_filter=ALL_DEVICES_IOT, protocol_filter={"IOT"} ) -brightness = parametrize("brightness smart", component_filter="brightness") - def check_categories(): """Check that every fixture file is categorized.""" diff --git a/kasa/tests/test_feature_brightness.py b/kasa/tests/test_feature_brightness.py index d99b55d1d..9d9d3165a 100644 --- a/kasa/tests/test_feature_brightness.py +++ b/kasa/tests/test_feature_brightness.py @@ -1,12 +1,28 @@ +import pytest + from kasa.smart import SmartDevice +from kasa.tests.conftest import parametrize -from .conftest import ( - brightness, -) +brightness = parametrize("brightness smart", component_filter="brightness") @brightness async def test_brightness_component(dev: SmartDevice): - """Placeholder to test framwework component filter.""" + """Test brightness feature.""" assert isinstance(dev, SmartDevice) assert "brightness" in dev._components + + # Test getting the value + feature = dev.features["brightness"] + assert isinstance(feature.value, int) + assert feature.value > 0 and feature.value <= 100 + + # Test setting the value + await feature.set_value(10) + assert feature.value == 10 + + with pytest.raises(ValueError): + await feature.set_value(feature.minimum_value - 10) + + with pytest.raises(ValueError): + await feature.set_value(feature.maximum_value + 10) From ced879498b4a070ccf371d190d46ca5a61bbdbf7 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 6 Mar 2024 14:54:55 +0000 Subject: [PATCH 355/892] Put child fixtures in subfolder (#809) This should prevent child fixtures from hubs breaking tests due to missing discovery info. To get these devices in `filter_fixtures` include protocol string of `SMART.CHILD`. --- devtools/dump_devinfo.py | 5 ++++- kasa/tests/device_fixtures.py | 12 ++++++++---- kasa/tests/discovery_fixtures.py | 24 +++++++++++++++++------- kasa/tests/fixtureinfo.py | 13 ++++++++++++- kasa/tests/fixtures/smart/child/.gitkeep | 1 + 5 files changed, 42 insertions(+), 13 deletions(-) create mode 100644 kasa/tests/fixtures/smart/child/.gitkeep diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index ebfe3b1bb..01921ccf1 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -39,6 +39,7 @@ FixtureResult = namedtuple("FixtureResult", "filename, folder, data") SMART_FOLDER = "kasa/tests/fixtures/smart/" +SMART_CHILD_FOLDER = "kasa/tests/fixtures/smart/child/" IOT_FOLDER = "kasa/tests/fixtures/" _LOGGER = logging.getLogger(__name__) @@ -531,7 +532,9 @@ def get_smart_child_fixture(response): model += f"({region})" save_filename = f"{model}_{hw_version}_{sw_version}.json" - return FixtureResult(filename=save_filename, folder=SMART_FOLDER, data=response) + return FixtureResult( + filename=save_filename, folder=SMART_CHILD_FOLDER, data=response + ) async def get_smart_fixtures(device: SmartDevice, batch_size: int): diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 8a1b643bd..5843639e8 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -277,7 +277,7 @@ def check_categories(): def device_for_fixture_name(model, protocol): - if protocol == "SMART": + if "SMART" in protocol: for d in PLUGS_SMART: if d in model: return SmartDevice @@ -345,17 +345,21 @@ async def get_device_for_fixture(fixture_data: FixtureInfo): d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)( host="127.0.0.123" ) - if fixture_data.protocol == "SMART": + if "SMART" in fixture_data.protocol: d.protocol = FakeSmartProtocol(fixture_data.data, fixture_data.name) else: d.protocol = FakeIotProtocol(fixture_data.data) + + discovery_data = None if "discovery_result" in fixture_data.data: discovery_data = {"result": fixture_data.data["discovery_result"]} - else: + elif "system" in fixture_data.data: discovery_data = { "system": {"get_sysinfo": fixture_data.data["system"]["get_sysinfo"]} } - d.update_from_discover_info(discovery_data) + if discovery_data: # Child devices do not have discovery info + d.update_from_discover_info(discovery_data) + await _update_and_close(d) return d diff --git a/kasa/tests/discovery_fixtures.py b/kasa/tests/discovery_fixtures.py index ce1f7d1c2..653f99709 100644 --- a/kasa/tests/discovery_fixtures.py +++ b/kasa/tests/discovery_fixtures.py @@ -8,7 +8,7 @@ from .fakeprotocol_iot import FakeIotProtocol from .fakeprotocol_smart import FakeSmartProtocol -from .fixtureinfo import FIXTURE_DATA, FixtureInfo, filter_fixtures, idgenerator +from .fixtureinfo import FixtureInfo, filter_fixtures, idgenerator def _make_unsupported(device_family, encrypt_type): @@ -42,8 +42,10 @@ def _make_unsupported(device_family, encrypt_type): } -def parametrize_discovery(desc, root_key): - filtered_fixtures = filter_fixtures(desc, data_root_filter=root_key) +def parametrize_discovery(desc, *, data_root_filter, protocol_filter=None): + filtered_fixtures = filter_fixtures( + desc, data_root_filter=data_root_filter, protocol_filter=protocol_filter + ) return pytest.mark.parametrize( "discovery_mock", filtered_fixtures, @@ -52,10 +54,15 @@ def parametrize_discovery(desc, root_key): ) -new_discovery = parametrize_discovery("new discovery", "discovery_result") +new_discovery = parametrize_discovery( + "new discovery", data_root_filter="discovery_result" +) -@pytest.fixture(params=FIXTURE_DATA, ids=idgenerator) +@pytest.fixture( + params=filter_fixtures("discoverable", protocol_filter={"SMART", "IOT"}), + ids=idgenerator, +) def discovery_mock(request, mocker): fixture_info: FixtureInfo = request.param fixture_data = fixture_info.data @@ -128,7 +135,7 @@ async def mock_discover(self): side_effect=lambda *_, **__: [(None, None, None, None, (dm.ip, 0))], ) - if fixture_info.protocol == "SMART": + if "SMART" in fixture_info.protocol: proto = FakeSmartProtocol(fixture_data, fixture_info.name) else: proto = FakeIotProtocol(fixture_data) @@ -142,7 +149,10 @@ async def _query(request, retry_count: int = 3): yield dm -@pytest.fixture(params=FIXTURE_DATA, ids=idgenerator) +@pytest.fixture( + params=filter_fixtures("discoverable", protocol_filter={"SMART", "IOT"}), + ids=idgenerator, +) def discovery_data(request, mocker): """Return raw discovery file contents as JSON. Used for discovery tests.""" fixture_info = request.param diff --git a/kasa/tests/fixtureinfo.py b/kasa/tests/fixtureinfo.py index 52250aab4..dc6e53075 100644 --- a/kasa/tests/fixtureinfo.py +++ b/kasa/tests/fixtureinfo.py @@ -29,8 +29,17 @@ class FixtureInfo(NamedTuple): ) ] +SUPPORTED_SMART_CHILD_DEVICES = [ + (device, "SMART.CHILD") + for device in glob.glob( + os.path.dirname(os.path.abspath(__file__)) + "/fixtures/smart/child/*.json" + ) +] + -SUPPORTED_DEVICES = SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES +SUPPORTED_DEVICES = ( + SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES + SUPPORTED_SMART_CHILD_DEVICES +) def idgenerator(paramtuple: FixtureInfo): @@ -50,6 +59,8 @@ def get_fixture_info() -> List[FixtureInfo]: folder = Path(__file__).parent / "fixtures" if protocol == "SMART": folder = folder / "smart" + if protocol == "SMART.CHILD": + folder = folder / "smart/child" p = folder / file with open(p) as f: diff --git a/kasa/tests/fixtures/smart/child/.gitkeep b/kasa/tests/fixtures/smart/child/.gitkeep new file mode 100644 index 000000000..74bef8496 --- /dev/null +++ b/kasa/tests/fixtures/smart/child/.gitkeep @@ -0,0 +1 @@ +Can be deleted when first fixture is added From 652696a9a64ee7322d9792553666e4c1f3613f8f Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 6 Mar 2024 15:23:31 +0000 Subject: [PATCH 356/892] Do not run coverage on pypy and cache poetry envs (#812) Currently the CI is very slow for pypy vs cpython, one job is 24m vs 3m on cpython. This PR enables poetry environment caching and bypasses coverage checking for pypy. N.B. The poetry cache is keyed on a hash of the `poetry.lock` file. --- .github/workflows/ci.yml | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 110d452ed..81b08859d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,13 +18,15 @@ jobs: python-version: ["3.12"] steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v2" + - uses: "actions/checkout@v4" + - name: Install poetry + run: pipx install poetry + - uses: "actions/setup-python@v5" with: python-version: "${{ matrix.python-version }}" + cache: 'poetry' - name: "Install dependencies" run: | - python -m pip install --upgrade pip poetry poetry install - name: "Check supported device md files are up to date" run: | @@ -85,21 +87,27 @@ jobs: extras: true steps: - - uses: "actions/checkout@v3" - - uses: "actions/setup-python@v4" + - uses: "actions/checkout@v4" + - name: Install poetry + run: pipx install poetry + - uses: "actions/setup-python@v5" with: python-version: "${{ matrix.python-version }}" + cache: 'poetry' - name: "Install dependencies (no extras)" if: matrix.extras == false run: | - python -m pip install --upgrade pip poetry poetry install - name: "Install dependencies (with extras)" if: matrix.extras == true run: | - python -m pip install --upgrade pip poetry poetry install --all-extras - - name: "Run tests" + - name: "Run tests (no coverage)" + if: ${{ startsWith(matrix.python-version, 'pypy') }} + run: | + poetry run pytest + - name: "Run tests (with coverage)" + if: ${{ !startsWith(matrix.python-version, 'pypy') }} run: | poetry run pytest --cov kasa --cov-report xml - name: "Upload coverage to Codecov" From 42080bd95430bbb6fe19c43a9d18abccbb9268ee Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 6 Mar 2024 16:18:52 +0000 Subject: [PATCH 357/892] Update test framework for dynamic parametrization (#810) --- kasa/tests/device_fixtures.py | 31 ++++++++++++++++++++++++++----- kasa/tests/fixtureinfo.py | 22 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 5843639e8..085bab8e5 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -1,10 +1,11 @@ -from typing import Dict, Set +from typing import Dict, List, Set import pytest from kasa import ( Credentials, Device, + DeviceType, Discover, ) from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch @@ -127,6 +128,21 @@ IP_MODEL_CACHE: Dict[str, str] = {} +def parametrize_combine(parametrized: List[pytest.MarkDecorator]): + """Combine multiple pytest parametrize dev marks into one set of fixtures.""" + fixtures = set() + for param in parametrized: + if param.args[0] != "dev": + raise Exception(f"Supplied mark is not for dev fixture: {param.args[0]}") + fixtures.update(param.args[1]) + return pytest.mark.parametrize( + "dev", + sorted(list(fixtures)), + indirect=True, + ids=idgenerator, + ) + + def parametrize( desc, *, @@ -134,6 +150,7 @@ def parametrize( protocol_filter=None, component_filter=None, data_root_filter=None, + device_type_filter=None, ids=None, ): if ids is None: @@ -146,6 +163,7 @@ def parametrize( protocol_filter=protocol_filter, component_filter=component_filter, data_root_filter=data_root_filter, + device_type_filter=device_type_filter, ), indirect=True, ids=ids, @@ -169,7 +187,6 @@ def parametrize( protocol_filter={"IOT"}, ) -bulb = parametrize("bulbs", model_filter=BULBS, protocol_filter={"SMART", "IOT"}) plug = parametrize("plugs", model_filter=PLUGS, protocol_filter={"IOT", "SMART"}) plug_iot = parametrize("plugs iot", model_filter=PLUGS, protocol_filter={"IOT"}) wallswitch = parametrize( @@ -216,9 +233,16 @@ def parametrize( model_filter=BULBS_IOT_VARIABLE_TEMP, protocol_filter={"IOT"}, ) + +bulb_smart = parametrize( + "bulb devices smart", + device_type_filter=[DeviceType.Bulb, DeviceType.LightStrip], + protocol_filter={"SMART"}, +) bulb_iot = parametrize( "bulb devices iot", model_filter=BULBS_IOT, protocol_filter={"IOT"} ) +bulb = parametrize_combine([bulb_smart, bulb_iot]) strip_iot = parametrize( "strip devices iot", model_filter=STRIPS_IOT, protocol_filter={"IOT"} @@ -233,9 +257,6 @@ def parametrize( switch_smart = parametrize( "switch devices smart", model_filter=SWITCHES_SMART, protocol_filter={"SMART"} ) -bulb_smart = parametrize( - "bulb devices smart", model_filter=BULBS_SMART, protocol_filter={"SMART"} -) dimmers_smart = parametrize( "dimmer devices smart", model_filter=DIMMERS_SMART, protocol_filter={"SMART"} ) diff --git a/kasa/tests/fixtureinfo.py b/kasa/tests/fixtureinfo.py index dc6e53075..70d385f60 100644 --- a/kasa/tests/fixtureinfo.py +++ b/kasa/tests/fixtureinfo.py @@ -4,6 +4,10 @@ from pathlib import Path from typing import Dict, List, NamedTuple, Optional, Set +from kasa.device_factory import _get_device_type_from_sys_info +from kasa.device_type import DeviceType +from kasa.smart.smartdevice import SmartDevice + class FixtureInfo(NamedTuple): name: str @@ -83,6 +87,7 @@ def filter_fixtures( protocol_filter: Optional[Set[str]] = None, model_filter: Optional[Set[str]] = None, component_filter: Optional[str] = None, + device_type_filter: Optional[List[DeviceType]] = None, ): """Filter the fixtures based on supplied parameters. @@ -108,6 +113,19 @@ def _component_match(fixture_data: FixtureInfo, component_filter): } return component_filter in components + def _device_type_match(fixture_data: FixtureInfo, device_type): + if (component_nego := fixture_data.data.get("component_nego")) is None: + return _get_device_type_from_sys_info(fixture_data.data) in device_type + components = [component["id"] for component in component_nego["component_list"]] + if (info := fixture_data.data.get("get_device_info")) and ( + type_ := info.get("type") + ): + return ( + SmartDevice._get_device_type_from_components(components, type_) + in device_type + ) + return False + filtered = [] if protocol_filter is None: protocol_filter = {"IOT", "SMART"} @@ -120,6 +138,10 @@ def _component_match(fixture_data: FixtureInfo, component_filter): continue if component_filter and not _component_match(fixture_data, component_filter): continue + if device_type_filter and not _device_type_match( + fixture_data, device_type_filter + ): + continue filtered.append(fixture_data) From adce92a761e899b7751b8667dfeef9f80561f7c4 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 6 Mar 2024 16:45:08 +0000 Subject: [PATCH 358/892] Add iot brightness feature (#808) --- kasa/iot/iotbulb.py | 17 +++++++++++++++++ kasa/iot/iotdimmer.py | 17 +++++++++++++++++ kasa/iot/iotplug.py | 3 +++ kasa/tests/test_feature_brightness.py | 25 ++++++++++++++++++++++++- 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 6b8d37b06..d80a24ea5 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -12,6 +12,7 @@ from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..device_type import DeviceType from ..deviceconfig import DeviceConfig +from ..feature import Feature, FeatureType from ..protocol import BaseProtocol from .iotdevice import IotDevice, KasaException, requires_update from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage @@ -204,6 +205,22 @@ def __init__( self.add_module("countdown", Countdown(self, "countdown")) self.add_module("cloud", Cloud(self, "smartlife.iot.common.cloud")) + async def _initialize_features(self): + await super()._initialize_features() + + if bool(self.sys_info["is_dimmable"]): # pragma: no branch + self._add_feature( + Feature( + device=self, + name="Brightness", + attribute_getter="brightness", + attribute_setter="set_brightness", + minimum_value=1, + maximum_value=100, + type=FeatureType.Number, + ) + ) + @property # type: ignore @requires_update def is_color(self) -> bool: diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 721a2c4b3..8882ae814 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -4,6 +4,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig +from ..feature import Feature, FeatureType from ..protocol import BaseProtocol from .iotdevice import KasaException, requires_update from .iotplug import IotPlug @@ -80,6 +81,22 @@ def __init__( self.add_module("motion", Motion(self, "smartlife.iot.PIR")) self.add_module("ambient", AmbientLight(self, "smartlife.iot.LAS")) + async def _initialize_features(self): + await super()._initialize_features() + + if "brightness" in self.sys_info: # pragma: no branch + self._add_feature( + Feature( + device=self, + name="Brightness", + attribute_getter="brightness", + attribute_setter="set_brightness", + minimum_value=1, + maximum_value=100, + type=FeatureType.Number, + ) + ) + @property # type: ignore @requires_update def brightness(self) -> int: diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 3f776b985..2d509e05e 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -57,6 +57,9 @@ def __init__( self.add_module("time", Time(self, "time")) self.add_module("cloud", Cloud(self, "cnCloud")) + async def _initialize_features(self): + await super()._initialize_features() + self._add_feature( Feature( device=self, diff --git a/kasa/tests/test_feature_brightness.py b/kasa/tests/test_feature_brightness.py index 9d9d3165a..72bc36373 100644 --- a/kasa/tests/test_feature_brightness.py +++ b/kasa/tests/test_feature_brightness.py @@ -1,7 +1,8 @@ import pytest +from kasa.iot import IotDevice from kasa.smart import SmartDevice -from kasa.tests.conftest import parametrize +from kasa.tests.conftest import dimmable, parametrize brightness = parametrize("brightness smart", component_filter="brightness") @@ -26,3 +27,25 @@ async def test_brightness_component(dev: SmartDevice): with pytest.raises(ValueError): await feature.set_value(feature.maximum_value + 10) + + +@dimmable +async def test_brightness_dimmable(dev: SmartDevice): + """Test brightness feature.""" + assert isinstance(dev, IotDevice) + assert "brightness" in dev.sys_info or bool(dev.sys_info["is_dimmable"]) + + # Test getting the value + feature = dev.features["brightness"] + assert isinstance(feature.value, int) + assert feature.value > 0 and feature.value <= 100 + + # Test setting the value + await feature.set_value(10) + assert feature.value == 10 + + with pytest.raises(ValueError): + await feature.set_value(feature.minimum_value - 10) + + with pytest.raises(ValueError): + await feature.set_value(feature.maximum_value + 10) From 3495bd83df5b9f8755b1a2f461b26c0a846acf97 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 6 Mar 2024 19:04:09 +0100 Subject: [PATCH 359/892] Add T315 fixture, tests for humidity&temperature modules (#802) --- kasa/module.py | 6 +- kasa/smart/modules/temperature.py | 4 +- kasa/tests/conftest.py | 2 +- kasa/tests/device_fixtures.py | 30 +- kasa/tests/fixtureinfo.py | 2 +- kasa/tests/fixtures/smart/child/.gitkeep | 1 - .../smart/child/T315(EU)_1.0_1.7.0.json | 537 ++++++++++++++++++ kasa/tests/smart/__init__.py | 0 kasa/tests/smart/modules/__init__.py | 0 kasa/tests/smart/modules/test_humidity.py | 26 + kasa/tests/smart/modules/test_temperature.py | 27 + 11 files changed, 613 insertions(+), 22 deletions(-) delete mode 100644 kasa/tests/fixtures/smart/child/.gitkeep create mode 100644 kasa/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json create mode 100644 kasa/tests/smart/__init__.py create mode 100644 kasa/tests/smart/modules/__init__.py create mode 100644 kasa/tests/smart/modules/test_humidity.py create mode 100644 kasa/tests/smart/modules/test_temperature.py diff --git a/kasa/module.py b/kasa/module.py index 5066c9535..854ab960e 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -37,7 +37,11 @@ def data(self): def _add_feature(self, feature: Feature): """Add module feature.""" - feat_name = f"{self._module}_{feature.name}" + + def _slugified_name(name): + return name.lower().replace(" ", "_").replace("'", "_") + + feat_name = _slugified_name(feature.name) if feat_name in self._module_features: raise KasaException("Duplicate name detected %s" % feat_name) self._module_features[feat_name] = feature diff --git a/kasa/smart/modules/temperature.py b/kasa/smart/modules/temperature.py index c33e565b9..dbfe7c63c 100644 --- a/kasa/smart/modules/temperature.py +++ b/kasa/smart/modules/temperature.py @@ -11,7 +11,7 @@ class TemperatureSensor(SmartModule): """Implementation of temperature module.""" - REQUIRED_COMPONENT = "humidity" + REQUIRED_COMPONENT = "temperature" QUERY_GETTER_NAME = "get_comfort_temp_config" def __init__(self, device: "SmartDevice", module: str): @@ -53,7 +53,7 @@ def temperature(self): @property def temperature_warning(self) -> bool: - """Return True if humidity is outside of the wanted range.""" + """Return True if temperature is outside of the wanted range.""" return self._device.sys_info["current_temp_exception"] != 0 @property diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 0917f081c..bec48bde2 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -2,7 +2,7 @@ from typing import Dict from unittest.mock import MagicMock -import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 +import pytest from kasa import ( DeviceConfig, diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 085bab8e5..71cc34bd7 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -1,3 +1,4 @@ +from itertools import chain from typing import Dict, List, Set import pytest @@ -106,6 +107,7 @@ } HUBS_SMART = {"H100"} +SENSORS_SMART = {"T315"} WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} WITH_EMETER_SMART = {"P110", "KP125M", "EP25"} @@ -121,6 +123,7 @@ .union(STRIPS_SMART) .union(DIMMERS_SMART) .union(HUBS_SMART) + .union(SENSORS_SMART) .union(SWITCHES_SMART) ) ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) @@ -263,6 +266,9 @@ def parametrize( hubs_smart = parametrize( "hubs smart", model_filter=HUBS_SMART, protocol_filter={"SMART"} ) +sensors_smart = parametrize( + "sensors smart", model_filter=SENSORS_SMART, protocol_filter={"SMART.CHILD"} +) device_smart = parametrize( "devices smart", model_filter=ALL_DEVICES_SMART, protocol_filter={"SMART"} ) @@ -283,6 +289,7 @@ def check_categories(): + bulb_smart.args[1] + dimmers_smart.args[1] + hubs_smart.args[1] + + sensors_smart.args[1] ) diffs: Set[FixtureInfo] = set(FIXTURE_DATA) - set(categorized_fixtures) if diffs: @@ -299,24 +306,14 @@ def check_categories(): def device_for_fixture_name(model, protocol): if "SMART" in protocol: - for d in PLUGS_SMART: - if d in model: - return SmartDevice - for d in SWITCHES_SMART: + for d in chain( + PLUGS_SMART, SWITCHES_SMART, STRIPS_SMART, HUBS_SMART, SENSORS_SMART + ): if d in model: return SmartDevice - for d in BULBS_SMART: - if d in model: - return SmartBulb - for d in DIMMERS_SMART: + for d in chain(BULBS_SMART, DIMMERS_SMART): if d in model: return SmartBulb - for d in STRIPS_SMART: - if d in model: - return SmartDevice - for d in HUBS_SMART: - if d in model: - return SmartDevice else: for d in STRIPS_IOT: if d in model: @@ -378,7 +375,8 @@ async def get_device_for_fixture(fixture_data: FixtureInfo): discovery_data = { "system": {"get_sysinfo": fixture_data.data["system"]["get_sysinfo"]} } - if discovery_data: # Child devices do not have discovery info + + if discovery_data: # Child devices do not have discovery info d.update_from_discover_info(discovery_data) await _update_and_close(d) @@ -392,7 +390,7 @@ async def get_device_for_fixture_protocol(fixture, protocol): return await get_device_for_fixture(fixture_info) -@pytest.fixture(params=FIXTURE_DATA, ids=idgenerator) +@pytest.fixture(params=filter_fixtures("main devices"), ids=idgenerator) async def dev(request): """Device fixture. diff --git a/kasa/tests/fixtureinfo.py b/kasa/tests/fixtureinfo.py index 70d385f60..08414ad4d 100644 --- a/kasa/tests/fixtureinfo.py +++ b/kasa/tests/fixtureinfo.py @@ -93,7 +93,7 @@ def filter_fixtures( data_root_filter: return fixtures containing the supplied top level key, i.e. discovery_result - protocol_filter: set of protocols to match, IOT or SMART + protocol_filter: set of protocols to match, IOT, SMART, SMART.CHILD model_filter: set of device models to match component_filter: filter SMART fixtures that have the provided component in component_nego details. diff --git a/kasa/tests/fixtures/smart/child/.gitkeep b/kasa/tests/fixtures/smart/child/.gitkeep deleted file mode 100644 index 74bef8496..000000000 --- a/kasa/tests/fixtures/smart/child/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Can be deleted when first fixture is added diff --git a/kasa/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json b/kasa/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json new file mode 100644 index 000000000..4fc49b0e8 --- /dev/null +++ b/kasa/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json @@ -0,0 +1,537 @@ +{ + "component_nego" : { + "component_list" : [ + { + "id" : "device", + "ver_code" : 2 + }, + { + "id" : "quick_setup", + "ver_code" : 3 + }, + { + "id" : "trigger_log", + "ver_code" : 1 + }, + { + "id" : "time", + "ver_code" : 1 + }, + { + "id" : "device_local_time", + "ver_code" : 1 + }, + { + "id" : "account", + "ver_code" : 1 + }, + { + "id" : "synchronize", + "ver_code" : 1 + }, + { + "id" : "cloud_connect", + "ver_code" : 1 + }, + { + "id" : "iot_cloud", + "ver_code" : 1 + }, + { + "id" : "firmware", + "ver_code" : 1 + }, + { + "id" : "localSmart", + "ver_code" : 1 + }, + { + "id" : "battery_detect", + "ver_code" : 1 + }, + { + "id" : "temperature", + "ver_code" : 1 + }, + { + "id" : "humidity", + "ver_code" : 1 + }, + { + "id" : "temp_humidity_record", + "ver_code" : 1 + }, + { + "id" : "comfort_temperature", + "ver_code" : 1 + }, + { + "id" : "comfort_humidity", + "ver_code" : 1 + }, + { + "id" : "report_mode", + "ver_code" : 1 + } + ] + }, + "get_connect_cloud_state" : { + "status" : 0 + }, + "get_device_info" : { + "at_low_battery" : false, + "avatar" : "", + "battery_percentage" : 100, + "bind_count" : 1, + "category" : "subg.trigger.temp-hmdt-sensor", + "current_humidity" : 61, + "current_humidity_exception" : 1, + "current_temp" : 21.4, + "current_temp_exception" : 0, + "device_id" : "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver" : "1.7.0 Build 230424 Rel.170332", + "hw_id" : "00000000000000000000000000000000", + "hw_ver" : "1.0", + "jamming_rssi" : -122, + "jamming_signal_level" : 1, + "lastOnboardingTimestamp" : 1706990901, + "mac" : "F0A731000000", + "model" : "T315", + "nickname" : "I01BU0tFRF9OQU1FIw==", + "oem_id" : "00000000000000000000000000000000", + "parent_device_id" : "0000000000000000000000000000000000000000", + "region" : "Europe/Berlin", + "report_interval" : 16, + "rssi" : -56, + "signal_level" : 3, + "specs" : "EU", + "status" : "online", + "status_follow_edge" : false, + "temp_unit" : "celsius", + "type" : "SMART.TAPOSENSOR" + }, + "get_fw_download_state" : { + "cloud_cache_seconds" : 1, + "download_progress" : 0, + "reboot_time" : 5, + "status" : 0, + "upgrade_time" : 5 + }, + "get_latest_fw" : { + "fw_ver" : "1.8.0 Build 230921 Rel.091446", + "hw_id" : "00000000000000000000000000000000", + "need_to_upgrade" : true, + "oem_id" : "00000000000000000000000000000000", + "release_date" : "2023-12-01", + "release_note" : "Modifications and Bug Fixes:\nEnhance the stability of the sensor.", + "type" : 2 + }, + "get_temp_humidity_records" : { + "local_time" : 1709061516, + "past24h_humidity" : [ + 60, + 60, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 58, + 59, + 59, + 58, + 59, + 59, + 59, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 59, + 59, + 59, + 59, + 59, + 59, + 60, + 60, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 64, + 56, + 53, + 55, + 56, + 57, + 57, + 58, + 59, + 63, + 63, + 62, + 62, + 62, + 62, + 61, + 62, + 62, + 61, + 61 + ], + "past24h_humidity_exception" : [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 3, + 2, + 2, + 2, + 2, + 1, + 2, + 2, + 1, + 1 + ], + "past24h_temp" : [ + 217, + 216, + 215, + 214, + 214, + 214, + 214, + 214, + 214, + 213, + 213, + 213, + 213, + 213, + 212, + 212, + 211, + 211, + 211, + 211, + 211, + 211, + 212, + 212, + 212, + 211, + 211, + 211, + 211, + 212, + 212, + 212, + 212, + 212, + 211, + 211, + 211, + 212, + 213, + 214, + 214, + 214, + 213, + 212, + 212, + 212, + 212, + 212, + 212, + 212, + 212, + 212, + 212, + 213, + 213, + 213, + 213, + 213, + 213, + 213, + 213, + 213, + 213, + 214, + 214, + 215, + 215, + 215, + 214, + 215, + 216, + 216, + 216, + 216, + 216, + 216, + 216, + 205, + 196, + 210, + 213, + 213, + 213, + 213, + 213, + 214, + 215, + 214, + 214, + 213, + 213, + 214, + 214, + 214, + 213, + 213 + ], + "past24h_temp_exception" : [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + -4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "temp_unit" : "celsius" + }, + "get_trigger_logs" : { + "logs" : [ + { + "event" : "tooDry", + "eventId" : "118040a8-5422-1100-0804-0a8542211000", + "id" : 1, + "timestamp" : 1706996915 + } + ], + "start_id" : 1, + "sum" : 1 + } +} diff --git a/kasa/tests/smart/__init__.py b/kasa/tests/smart/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/kasa/tests/smart/modules/__init__.py b/kasa/tests/smart/modules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/kasa/tests/smart/modules/test_humidity.py b/kasa/tests/smart/modules/test_humidity.py new file mode 100644 index 000000000..99e4702eb --- /dev/null +++ b/kasa/tests/smart/modules/test_humidity.py @@ -0,0 +1,26 @@ +import pytest + +from kasa.smart.modules import HumiditySensor +from kasa.tests.device_fixtures import parametrize + +humidity = parametrize("has humidity", component_filter="humidity", protocol_filter={"SMART.CHILD"}) + + +@humidity +@pytest.mark.parametrize( + "feature, type", + [ + ("humidity", int), + ("humidity_warning", bool), + ], +) +async def test_humidity_features(dev, feature, type): + """Test that features are registered and work as expected.""" + humidity: HumiditySensor = dev.modules["HumiditySensor"] + + prop = getattr(humidity, feature) + assert isinstance(prop, type) + + feat = humidity._module_features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) diff --git a/kasa/tests/smart/modules/test_temperature.py b/kasa/tests/smart/modules/test_temperature.py new file mode 100644 index 000000000..649b5bc49 --- /dev/null +++ b/kasa/tests/smart/modules/test_temperature.py @@ -0,0 +1,27 @@ +import pytest + +from kasa.smart.modules import TemperatureSensor +from kasa.tests.device_fixtures import parametrize + +temperature = parametrize("has temperature", component_filter="temperature", protocol_filter={"SMART.CHILD"}) + + +@temperature +@pytest.mark.parametrize( + "feature, type", + [ + ("temperature", float), + ("temperature_warning", bool), + ("temperature_unit", str), + ], +) +async def test_temperature_features(dev, feature, type): + """Test that features are registered and work as expected.""" + temp_module: TemperatureSensor = dev.modules["TemperatureSensor"] + + prop = getattr(temp_module, feature) + assert isinstance(prop, type) + + feat = temp_module._module_features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) From 7507837734871562380b2a3271070cef9efcbac5 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 11 Mar 2024 10:17:12 +0000 Subject: [PATCH 360/892] Fix slow aestransport and cli tests (#816) --- kasa/aestransport.py | 4 ++-- kasa/tests/conftest.py | 6 +++--- kasa/tests/fixtureinfo.py | 1 + kasa/tests/test_aestransport.py | 1 + kasa/tests/test_cli.py | 14 +++++++++----- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 74f59560b..e00b1084e 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -38,7 +38,6 @@ ONE_DAY_SECONDS = 86400 SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20 -BACKOFF_SECONDS_AFTER_LOGIN_ERROR = 1 def _sha1(payload: bytes) -> str: @@ -72,6 +71,7 @@ class AesTransport(BaseTransport): } CONTENT_LENGTH = "Content-Length" KEY_PAIR_CONTENT_LENGTH = 314 + BACKOFF_SECONDS_AFTER_LOGIN_ERROR = 1 def __init__( self, @@ -213,7 +213,7 @@ async def perform_login(self): self._default_credentials = get_default_credentials( DEFAULT_CREDENTIALS["TAPO"] ) - await asyncio.sleep(BACKOFF_SECONDS_AFTER_LOGIN_ERROR) + await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_LOGIN_ERROR) await self.perform_handshake() await self.try_login(self._get_login_params(self._default_credentials)) _LOGGER.debug( diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index bec48bde2..a3bd6df22 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -1,6 +1,6 @@ import warnings from typing import Dict -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -48,8 +48,8 @@ async def reset(self) -> None: transport = DummyTransport(config=DeviceConfig(host="127.0.0.123")) protocol = SmartProtocol(transport=transport) - - return protocol + with patch.object(protocol, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0): + yield protocol def pytest_configure(): diff --git a/kasa/tests/fixtureinfo.py b/kasa/tests/fixtureinfo.py index 08414ad4d..bee3e7498 100644 --- a/kasa/tests/fixtureinfo.py +++ b/kasa/tests/fixtureinfo.py @@ -148,4 +148,5 @@ def _device_type_match(fixture_data: FixtureInfo, device_type): print(f"# {desc}") for value in filtered: print(f"\t{value.name}") + filtered.sort() return filtered diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index cc7aeece1..859c35bec 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -135,6 +135,7 @@ async def test_login_errors(mocker, inner_error_codes, expectation, call_count): transport._state = TransportState.LOGIN_REQUIRED transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session + mocker.patch.object(transport, "BACKOFF_SECONDS_AFTER_LOGIN_ERROR", 0) assert transport._token_url is None diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 01d02273d..885fbcd08 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -149,10 +149,15 @@ async def test_command_with_child(dev, mocker): runner = CliRunner() update_mock = mocker.patch.object(dev, "update") - dummy_child = mocker.create_autospec(IotDevice) - query_mock = mocker.patch.object( - dummy_child, "_query_helper", return_value={"dummy": "response"} - ) + # create_autospec for device slows tests way too much, so we use a dummy here + class DummyDevice(dev.__class__): + def __init__(self): + super().__init__("127.0.0.1") + + async def _query_helper(*_, **__): + return {"dummy": "response"} + + dummy_child = DummyDevice() mocker.patch.object(dev, "_children", {"XYZ": dummy_child}) mocker.patch.object(dev, "get_child_device", return_value=dummy_child) @@ -165,7 +170,6 @@ async def test_command_with_child(dev, mocker): ) update_mock.assert_called() - query_mock.assert_called() assert '{"dummy": "response"}' in res.output assert res.exit_code == 0 From 33be568897d10f82244ec7d51a9328f2e7a04192 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 12 Mar 2024 16:28:50 +0000 Subject: [PATCH 361/892] Add P100 fw 1.4.0 fixture (#820) --- SUPPORTED.md | 1 + .../fixtures/smart/P100_1.0.0_1.4.0.json | 204 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 kasa/tests/fixtures/smart/P100_1.0.0_1.4.0.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 9a740d6a8..bf76a8ad6 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -147,6 +147,7 @@ All Tapo devices require authentication. - **P100** - Hardware: 1.0.0 / Firmware: 1.1.3 - Hardware: 1.0.0 / Firmware: 1.3.7 + - Hardware: 1.0.0 / Firmware: 1.4.0 - **P110** - Hardware: 1.0 (EU) / Firmware: 1.0.7 - Hardware: 1.0 (EU) / Firmware: 1.2.3 diff --git a/kasa/tests/fixtures/smart/P100_1.0.0_1.4.0.json b/kasa/tests/fixtures/smart/P100_1.0.0_1.4.0.json new file mode 100644 index 000000000..5ec333435 --- /dev/null +++ b/kasa/tests/fixtures/smart/P100_1.0.0_1.4.0.json @@ -0,0 +1,204 @@ +{ + "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": "74-DA-88-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "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": "", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.4.0 Build 20231017 Rel. 33876", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "location": "", + "longitude": 0, + "mac": "74-DA-88-00-00-00", + "model": "P100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "Europe/London", + "rssi": -57, + "signal_level": 2, + "specs": "US", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1710256253 + }, + "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.4.0 Build 20231017 Rel. 33876", + "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 + }, + "get_wireless_scan_info": { + "ap_list": [], + "start_index": 0, + "sum": 0, + "wep_supported": false + }, + "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", + "is_klap": true + } + } +} From 063518b7dba8e9f58e915607a64ba506d83e8aed Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 12 Mar 2024 17:18:08 +0000 Subject: [PATCH 362/892] Add support for firmware module v1 (#821) The v1 of firmware does not support changing the auto update setting, this makes it so that it isn't requested in that case. --- kasa/smart/modules/firmware.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 4d1f846cc..29cc9185a 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -48,16 +48,17 @@ class Firmware(SmartModule): def __init__(self, device: "SmartDevice", module: str): super().__init__(device, module) - self._add_feature( - Feature( - device, - "Auto update enabled", - container=self, - attribute_getter="auto_update_enabled", - attribute_setter="set_auto_update_enabled", - type=FeatureType.Switch, + if self.supported_version > 1: + self._add_feature( + Feature( + device, + "Auto update enabled", + container=self, + attribute_getter="auto_update_enabled", + attribute_setter="set_auto_update_enabled", + type=FeatureType.Switch, + ) ) - ) self._add_feature( Feature( device, @@ -70,12 +71,17 @@ def __init__(self, device: "SmartDevice", module: str): def query(self) -> Dict: """Query to execute during the update cycle.""" - return {"get_auto_update_info": None, "get_latest_fw": None} + req = { + "get_latest_fw": None, + } + if self.supported_version > 1: + req["get_auto_update_info"] = None + return req @property def latest_firmware(self): """Return latest firmware information.""" - fw = self.data["get_latest_fw"] + fw = self.data.get("get_latest_fw") or self.data if isinstance(fw, SmartErrorCode): # Error in response, probably disconnected from the cloud. return UpdateInfo(type=0, need_to_upgrade=False) @@ -98,7 +104,10 @@ async def update(self): @property def auto_update_enabled(self): """Return True if autoupdate is enabled.""" - return self.data["get_auto_update_info"]["enable"] + return ( + "get_auto_update_info" in self.data + and self.data["get_auto_update_info"]["enable"] + ) async def set_auto_update_enabled(self, enabled: bool): """Change autoupdate setting.""" From 41e58252f742447d75ff9a2714b0074e2968df9d Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 15 Mar 2024 15:42:40 +0000 Subject: [PATCH 363/892] Add pre-commit caching and fix poetry extras cache (#817) Caching pre-commit halves the linting time and the `action/setup-python` cache does not handle `--extras` [properly ](https://github.com/actions/setup-python/issues/505) so switching to action/cache for the poetry cache --- .github/workflows/ci.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81b08859d..827ed947b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,12 +22,21 @@ jobs: - name: Install poetry run: pipx install poetry - uses: "actions/setup-python@v5" + id: setup-python with: python-version: "${{ matrix.python-version }}" cache: 'poetry' - name: "Install dependencies" run: | poetry install + - name: Read pre-commit version + id: pre-commit-version + run: >- + echo "PRE_COMMIT_VERSION=$(poetry run pre-commit -V | awk '{print $2}')" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + with: + path: ~/.cache/pre-commit/ + key: ${{ runner.os }}-pre-commit-${{ steps.pre-commit-version.outputs.PRE_COMMIT_VERSION }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} - name: "Check supported device md files are up to date" run: | poetry run pre-commit run generate-supported --all-files @@ -91,9 +100,19 @@ jobs: - name: Install poetry run: pipx install poetry - uses: "actions/setup-python@v5" + id: setup-python with: python-version: "${{ matrix.python-version }}" - cache: 'poetry' + - name: Read poetry cache location + id: poetry-cache-location + shell: bash + run: | + echo "POETRY_VENV_LOCATION=$(poetry config virtualenvs.path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + with: + path: | + ${{ steps.poetry-cache-location.outputs.POETRY_VENV_LOCATION }} + key: ${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }}-extras-${{ matrix.extras }} - name: "Install dependencies (no extras)" if: matrix.extras == false run: | From 48ac39e6d884fdfc22d69710e6a6d9780ab4ba44 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 15 Mar 2024 16:55:48 +0100 Subject: [PATCH 364/892] Refactor split smartdevice tests to test_{iot,smart}device (#822) --- kasa/tests/device_fixtures.py | 2 +- kasa/tests/smart/modules/test_humidity.py | 4 +- kasa/tests/smart/modules/test_temperature.py | 4 +- kasa/tests/test_bulb.py | 9 +- kasa/tests/test_device.py | 121 ++++++ kasa/tests/test_dimmer.py | 6 + kasa/tests/test_iotdevice.py | 259 ++++++++++++ kasa/tests/test_lightstrip.py | 5 + kasa/tests/test_plug.py | 9 +- kasa/tests/test_smartdevice.py | 416 +------------------ kasa/tests/test_strip.py | 8 + 11 files changed, 425 insertions(+), 418 deletions(-) create mode 100644 kasa/tests/test_device.py create mode 100644 kasa/tests/test_iotdevice.py diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 71cc34bd7..9d01a8305 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -376,7 +376,7 @@ async def get_device_for_fixture(fixture_data: FixtureInfo): "system": {"get_sysinfo": fixture_data.data["system"]["get_sysinfo"]} } - if discovery_data: # Child devices do not have discovery info + if discovery_data: # Child devices do not have discovery info d.update_from_discover_info(discovery_data) await _update_and_close(d) diff --git a/kasa/tests/smart/modules/test_humidity.py b/kasa/tests/smart/modules/test_humidity.py index 99e4702eb..bf746f2b8 100644 --- a/kasa/tests/smart/modules/test_humidity.py +++ b/kasa/tests/smart/modules/test_humidity.py @@ -3,7 +3,9 @@ from kasa.smart.modules import HumiditySensor from kasa.tests.device_fixtures import parametrize -humidity = parametrize("has humidity", component_filter="humidity", protocol_filter={"SMART.CHILD"}) +humidity = parametrize( + "has humidity", component_filter="humidity", protocol_filter={"SMART.CHILD"} +) @humidity diff --git a/kasa/tests/smart/modules/test_temperature.py b/kasa/tests/smart/modules/test_temperature.py index 649b5bc49..3b9ab50e2 100644 --- a/kasa/tests/smart/modules/test_temperature.py +++ b/kasa/tests/smart/modules/test_temperature.py @@ -3,7 +3,9 @@ from kasa.smart.modules import TemperatureSensor from kasa.tests.device_fixtures import parametrize -temperature = parametrize("has temperature", component_filter="temperature", protocol_filter={"SMART.CHILD"}) +temperature = parametrize( + "has temperature", component_filter="temperature", protocol_filter={"SMART.CHILD"} +) @temperature diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index e8c95dbd8..48b5976e4 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -24,7 +24,7 @@ variable_temp, variable_temp_iot, ) -from .test_smartdevice import SYSINFO_SCHEMA +from .test_iotdevice import SYSINFO_SCHEMA @bulb @@ -370,3 +370,10 @@ async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): ], } ) + + +@bulb +def test_device_type_bulb(dev): + if dev.is_light_strip: + pytest.skip("bulb has also lightstrips to test the api") + assert dev.device_type == DeviceType.Bulb diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py new file mode 100644 index 000000000..7ceab8e97 --- /dev/null +++ b/kasa/tests/test_device.py @@ -0,0 +1,121 @@ +"""Tests for all devices.""" +import importlib +import inspect +import pkgutil +import sys +from unittest.mock import Mock, patch + +import pytest + +import kasa +from kasa import Credentials, Device, DeviceConfig +from kasa.iot import IotDevice +from kasa.smart import SmartChildDevice, SmartDevice + + +def _get_subclasses(of_class): + package = sys.modules["kasa"] + subclasses = set() + for _, modname, _ in pkgutil.iter_modules(package.__path__): + importlib.import_module("." + modname, package="kasa") + module = sys.modules["kasa." + modname] + for name, obj in inspect.getmembers(module): + if ( + inspect.isclass(obj) + and issubclass(obj, of_class) + and module.__package__ != "kasa" + ): + subclasses.add((module.__package__ + "." + name, obj)) + return subclasses + + +device_classes = pytest.mark.parametrize( + "device_class_name_obj", _get_subclasses(Device), ids=lambda t: t[0] +) + + +async def test_alias(dev): + test_alias = "TEST1234" + original = dev.alias + + assert isinstance(original, str) + await dev.set_alias(test_alias) + await dev.update() + assert dev.alias == test_alias + + await dev.set_alias(original) + await dev.update() + assert dev.alias == original + + +@device_classes +async def test_device_class_ctors(device_class_name_obj): + """Make sure constructor api not broken for new and existing SmartDevices.""" + host = "127.0.0.2" + port = 1234 + credentials = Credentials("foo", "bar") + config = DeviceConfig(host, port_override=port, credentials=credentials) + klass = device_class_name_obj[1] + if issubclass(klass, SmartChildDevice): + parent = SmartDevice(host, config=config) + dev = klass( + parent, {"dummy": "info", "device_id": "dummy"}, {"dummy": "components"} + ) + else: + dev = klass(host, config=config) + assert dev.host == host + assert dev.port == port + assert dev.credentials == credentials + + +async def test_create_device_with_timeout(): + """Make sure timeout is passed to the protocol.""" + host = "127.0.0.1" + dev = IotDevice(host, config=DeviceConfig(host, timeout=100)) + assert dev.protocol._transport._timeout == 100 + dev = SmartDevice(host, config=DeviceConfig(host, timeout=100)) + assert dev.protocol._transport._timeout == 100 + + +async def test_create_thin_wrapper(): + """Make sure thin wrapper is created with the correct device type.""" + mock = Mock() + config = DeviceConfig( + host="test_host", + port_override=1234, + timeout=100, + credentials=Credentials("username", "password"), + ) + with patch("kasa.device_factory.connect", return_value=mock) as connect: + dev = await Device.connect(config=config) + assert dev is mock + + connect.assert_called_once_with( + host=None, + config=config, + ) + + +@pytest.mark.parametrize( + "device_class, use_class", kasa.deprecated_smart_devices.items() +) +def test_deprecated_devices(device_class, use_class): + package_name = ".".join(use_class.__module__.split(".")[:-1]) + msg = f"{device_class} is deprecated, use {use_class.__name__} from package {package_name} instead" + with pytest.deprecated_call(match=msg): + getattr(kasa, device_class) + packages = package_name.split(".") + module = __import__(packages[0]) + for _ in packages[1:]: + module = importlib.import_module(package_name, package=module.__name__) + getattr(module, use_class.__name__) + + +@pytest.mark.parametrize( + "exceptions_class, use_class", kasa.deprecated_exceptions.items() +) +def test_deprecated_exceptions(exceptions_class, use_class): + msg = f"{exceptions_class} is deprecated, use {use_class.__name__} instead" + with pytest.deprecated_call(match=msg): + getattr(kasa, exceptions_class) + getattr(kasa, use_class.__name__) diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index fafa95441..d63aa4536 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -1,5 +1,6 @@ import pytest +from kasa import DeviceType from kasa.iot import IotDimmer from .conftest import dimmer, handle_turn_on, turn_on @@ -132,3 +133,8 @@ async def test_set_dimmer_transition_invalid(dev): for invalid_transition in [-1, 0, 0.5]: with pytest.raises(ValueError): await dev.set_dimmer_transition(1, invalid_transition) + + +@dimmer +def test_device_type_dimmer(dev): + assert dev.device_type == DeviceType.Dimmer diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py new file mode 100644 index 000000000..b7846e413 --- /dev/null +++ b/kasa/tests/test_iotdevice.py @@ -0,0 +1,259 @@ +"""Module for common iotdevice tests.""" +import re +from datetime import datetime + +import pytest +from voluptuous import ( + REMOVE_EXTRA, + All, + Any, + Boolean, + In, + Invalid, + Optional, + Range, + Schema, +) + +from kasa import KasaException +from kasa.iot import IotDevice + +from .conftest import handle_turn_on, turn_on +from .device_fixtures import device_iot, has_emeter_iot, no_emeter_iot +from .fakeprotocol_iot import FakeIotProtocol + +TZ_SCHEMA = Schema( + {"zone_str": str, "dst_offset": int, "index": All(int, Range(min=0)), "tz_str": str} +) + + +def check_mac(x): + if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", x.lower()): + return x + raise Invalid(x) + + +SYSINFO_SCHEMA = Schema( + { + "active_mode": In(["schedule", "none", "count_down"]), + "alias": str, + "dev_name": str, + "deviceId": str, + "feature": str, + "fwId": str, + "hwId": str, + "hw_ver": str, + "icon_hash": str, + "led_off": Boolean, + "latitude": Any(All(float, Range(min=-90, max=90)), 0, None), + "latitude_i": Any( + All(int, Range(min=-900000, max=900000)), + All(float, Range(min=-900000, max=900000)), + 0, + None, + ), + "longitude": Any(All(float, Range(min=-180, max=180)), 0, None), + "longitude_i": Any( + All(int, Range(min=-18000000, max=18000000)), + All(float, Range(min=-18000000, max=18000000)), + 0, + None, + ), + "mac": check_mac, + "model": str, + "oemId": str, + "on_time": int, + "relay_state": int, + "rssi": Any(int, None), # rssi can also be positive, see #54 + "sw_ver": str, + "type": str, + "mic_type": str, + "updating": Boolean, + # these are available on hs220 + "brightness": int, + "preferred_state": [ + {"brightness": All(int, Range(min=0, max=100)), "index": int} + ], + "next_action": {"type": int}, + "child_num": Optional(Any(None, int)), + "children": Optional(list), + }, + extra=REMOVE_EXTRA, +) + + +@device_iot +async def test_state_info(dev): + assert isinstance(dev.state_information, dict) + + +@pytest.mark.requires_dummy +@device_iot +async def test_invalid_connection(mocker, dev): + with mocker.patch.object( + FakeIotProtocol, "query", side_effect=KasaException + ), pytest.raises(KasaException): + await dev.update() + + +@has_emeter_iot +async def test_initial_update_emeter(dev, mocker): + """Test that the initial update performs second query if emeter is available.""" + dev._last_update = None + dev._legacy_features = set() + spy = mocker.spy(dev.protocol, "query") + await dev.update() + # Devices with small buffers may require 3 queries + expected_queries = 2 if dev.max_device_response_size > 4096 else 3 + assert spy.call_count == expected_queries + len(dev.children) + + +@no_emeter_iot +async def test_initial_update_no_emeter(dev, mocker): + """Test that the initial update performs second query if emeter is available.""" + dev._last_update = None + dev._legacy_features = set() + spy = mocker.spy(dev.protocol, "query") + await dev.update() + # 2 calls are necessary as some devices crash on unexpected modules + # See #105, #120, #161 + assert spy.call_count == 2 + + +@device_iot +async def test_query_helper(dev): + with pytest.raises(KasaException): + await dev._query_helper("test", "testcmd", {}) + # TODO check for unwrapping? + + +@device_iot +@turn_on +async def test_state(dev, turn_on): + await handle_turn_on(dev, turn_on) + orig_state = dev.is_on + if orig_state: + await dev.turn_off() + await dev.update() + assert not dev.is_on + assert dev.is_off + + await dev.turn_on() + await dev.update() + assert dev.is_on + assert not dev.is_off + else: + await dev.turn_on() + await dev.update() + assert dev.is_on + assert not dev.is_off + + await dev.turn_off() + await dev.update() + assert not dev.is_on + assert dev.is_off + + +@device_iot +@turn_on +async def test_on_since(dev, turn_on): + await handle_turn_on(dev, turn_on) + orig_state = dev.is_on + if "on_time" not in dev.sys_info and not dev.is_strip: + assert dev.on_since is None + elif orig_state: + assert isinstance(dev.on_since, datetime) + else: + assert dev.on_since is None + + +@device_iot +async def test_time(dev): + assert isinstance(await dev.get_time(), datetime) + + +@device_iot +async def test_timezone(dev): + TZ_SCHEMA(await dev.get_timezone()) + + +@device_iot +async def test_hw_info(dev): + SYSINFO_SCHEMA(dev.hw_info) + + +@device_iot +async def test_location(dev): + SYSINFO_SCHEMA(dev.location) + + +@device_iot +async def test_rssi(dev): + SYSINFO_SCHEMA({"rssi": dev.rssi}) # wrapping for vol + + +@device_iot +async def test_mac(dev): + SYSINFO_SCHEMA({"mac": dev.mac}) # wrapping for val + + +@device_iot +async def test_representation(dev): + pattern = re.compile("") + assert pattern.match(str(dev)) + + +@device_iot +async def test_children(dev): + """Make sure that children property is exposed by every device.""" + if dev.is_strip: + assert len(dev.children) > 0 + else: + assert len(dev.children) == 0 + + +@device_iot +async def test_modules_preserved(dev: IotDevice): + """Make modules that are not being updated are preserved between updates.""" + dev._last_update["some_module_not_being_updated"] = "should_be_kept" + await dev.update() + assert dev._last_update["some_module_not_being_updated"] == "should_be_kept" + + +@device_iot +async def test_internal_state(dev): + """Make sure the internal state returns the last update results.""" + assert dev.internal_state == dev._last_update + + +@device_iot +async def test_features(dev): + """Make sure features is always accessible.""" + sysinfo = dev._last_update["system"]["get_sysinfo"] + if "feature" in sysinfo: + assert dev._legacy_features == set(sysinfo["feature"].split(":")) + else: + assert dev._legacy_features == set() + + +@device_iot +async def test_max_device_response_size(dev): + """Make sure every device return has a set max response size.""" + assert dev.max_device_response_size > 0 + + +@device_iot +async def test_estimated_response_sizes(dev): + """Make sure every module has an estimated response size set.""" + for mod in dev.modules.values(): + assert mod.estimated_query_response_size > 0 + + +@device_iot +async def test_modules_not_supported(dev: IotDevice): + """Test that unsupported modules do not break the device.""" + for module in dev.modules.values(): + assert module.is_supported is not None + await dev.update() + for module in dev.modules.values(): + assert module.is_supported is not None diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index 123360a4e..fcc48dfaf 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -71,3 +71,8 @@ async def test_effects_lightstrip_set_effect_transition( async def test_effects_lightstrip_has_effects(dev: IotLightStrip): assert dev.has_effects is True assert dev.effect_list + + +@lightstrip +def test_device_type_lightstrip(dev): + assert dev.device_type == DeviceType.LightStrip diff --git a/kasa/tests/test_plug.py b/kasa/tests/test_plug.py index 9ccf3d043..8989c975f 100644 --- a/kasa/tests/test_plug.py +++ b/kasa/tests/test_plug.py @@ -1,7 +1,7 @@ from kasa import DeviceType -from .conftest import plug_iot, plug_smart, switch_smart, wallswitch_iot -from .test_smartdevice import SYSINFO_SCHEMA +from .conftest import plug, plug_iot, plug_smart, switch_smart, wallswitch_iot +from .test_iotdevice import SYSINFO_SCHEMA # these schemas should go to the mainlib as # they can be useful when adding support for new features/devices @@ -76,3 +76,8 @@ async def test_switch_device_info(dev): assert ( dev.device_type == DeviceType.WallSwitch or dev.device_type == DeviceType.Dimmer ) + + +@plug +def test_device_type_plug(dev): + assert dev.device_type == DeviceType.Plug diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index fdd342ca7..a9871fa29 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -1,319 +1,18 @@ -import importlib -import inspect +"""Tests for SMART devices.""" import logging -import pkgutil -import re -import sys -from datetime import datetime -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 -from voluptuous import ( - REMOVE_EXTRA, - All, - Any, - Boolean, - In, - Invalid, - Optional, - Range, - Schema, -) -import kasa -from kasa import Credentials, Device, DeviceConfig, KasaException -from kasa.device_type import DeviceType +from kasa import KasaException from kasa.exceptions import SmartErrorCode -from kasa.iot import IotDevice -from kasa.smart import SmartChildDevice, SmartDevice +from kasa.smart import SmartDevice from .conftest import ( - bulb, - device_iot, device_smart, - dimmer, - handle_turn_on, - has_emeter_iot, - lightstrip, - no_emeter_iot, - plug, - strip, - turn_on, -) -from .fakeprotocol_iot import FakeIotProtocol - - -def _get_subclasses(of_class): - package = sys.modules["kasa"] - subclasses = set() - for _, modname, _ in pkgutil.iter_modules(package.__path__): - importlib.import_module("." + modname, package="kasa") - module = sys.modules["kasa." + modname] - for name, obj in inspect.getmembers(module): - if ( - inspect.isclass(obj) - and issubclass(obj, of_class) - and module.__package__ != "kasa" - ): - subclasses.add((module.__package__ + "." + name, obj)) - return subclasses - - -device_classes = pytest.mark.parametrize( - "device_class_name_obj", _get_subclasses(Device), ids=lambda t: t[0] ) -@device_iot -async def test_state_info(dev): - assert isinstance(dev.state_information, dict) - - -@pytest.mark.requires_dummy -@device_iot -async def test_invalid_connection(dev): - with patch.object( - FakeIotProtocol, "query", side_effect=KasaException - ), pytest.raises(KasaException): - await dev.update() - - -@has_emeter_iot -async def test_initial_update_emeter(dev, mocker): - """Test that the initial update performs second query if emeter is available.""" - dev._last_update = None - dev._legacy_features = set() - spy = mocker.spy(dev.protocol, "query") - await dev.update() - # Devices with small buffers may require 3 queries - expected_queries = 2 if dev.max_device_response_size > 4096 else 3 - assert spy.call_count == expected_queries + len(dev.children) - - -@no_emeter_iot -async def test_initial_update_no_emeter(dev, mocker): - """Test that the initial update performs second query if emeter is available.""" - dev._last_update = None - dev._legacy_features = set() - spy = mocker.spy(dev.protocol, "query") - await dev.update() - # 2 calls are necessary as some devices crash on unexpected modules - # See #105, #120, #161 - assert spy.call_count == 2 - - -@device_iot -async def test_query_helper(dev): - with pytest.raises(KasaException): - await dev._query_helper("test", "testcmd", {}) - # TODO check for unwrapping? - - -@device_iot -@turn_on -async def test_state(dev, turn_on): - await handle_turn_on(dev, turn_on) - orig_state = dev.is_on - if orig_state: - await dev.turn_off() - await dev.update() - assert not dev.is_on - assert dev.is_off - - await dev.turn_on() - await dev.update() - assert dev.is_on - assert not dev.is_off - else: - await dev.turn_on() - await dev.update() - assert dev.is_on - assert not dev.is_off - - await dev.turn_off() - await dev.update() - assert not dev.is_on - assert dev.is_off - - -@device_iot -async def test_alias(dev): - test_alias = "TEST1234" - original = dev.alias - - assert isinstance(original, str) - await dev.set_alias(test_alias) - await dev.update() - assert dev.alias == test_alias - - await dev.set_alias(original) - await dev.update() - assert dev.alias == original - - -@device_iot -@turn_on -async def test_on_since(dev, turn_on): - await handle_turn_on(dev, turn_on) - orig_state = dev.is_on - if "on_time" not in dev.sys_info and not dev.is_strip: - assert dev.on_since is None - elif orig_state: - assert isinstance(dev.on_since, datetime) - else: - assert dev.on_since is None - - -@device_iot -async def test_time(dev): - assert isinstance(await dev.get_time(), datetime) - - -@device_iot -async def test_timezone(dev): - TZ_SCHEMA(await dev.get_timezone()) - - -@device_iot -async def test_hw_info(dev): - SYSINFO_SCHEMA(dev.hw_info) - - -@device_iot -async def test_location(dev): - SYSINFO_SCHEMA(dev.location) - - -@device_iot -async def test_rssi(dev): - SYSINFO_SCHEMA({"rssi": dev.rssi}) # wrapping for vol - - -@device_iot -async def test_mac(dev): - SYSINFO_SCHEMA({"mac": dev.mac}) # wrapping for val - - -@device_iot -async def test_representation(dev): - import re - - pattern = re.compile("") - assert pattern.match(str(dev)) - - -@strip -def test_children_api(dev): - """Test the child device API.""" - first = dev.children[0] - first_by_get_child_device = dev.get_child_device(first.device_id) - assert first == first_by_get_child_device - - -@device_iot -async def test_children(dev): - """Make sure that children property is exposed by every device.""" - if dev.is_strip: - assert len(dev.children) > 0 - else: - assert len(dev.children) == 0 - - -@device_iot -async def test_internal_state(dev): - """Make sure the internal state returns the last update results.""" - assert dev.internal_state == dev._last_update - - -@device_iot -async def test_features(dev): - """Make sure features is always accessible.""" - sysinfo = dev._last_update["system"]["get_sysinfo"] - if "feature" in sysinfo: - assert dev._legacy_features == set(sysinfo["feature"].split(":")) - else: - assert dev._legacy_features == set() - - -@device_iot -async def test_max_device_response_size(dev): - """Make sure every device return has a set max response size.""" - assert dev.max_device_response_size > 0 - - -@device_iot -async def test_estimated_response_sizes(dev): - """Make sure every module has an estimated response size set.""" - for mod in dev.modules.values(): - assert mod.estimated_query_response_size > 0 - - -@device_classes -async def test_device_class_ctors(device_class_name_obj): - """Make sure constructor api not broken for new and existing SmartDevices.""" - host = "127.0.0.2" - port = 1234 - credentials = Credentials("foo", "bar") - config = DeviceConfig(host, port_override=port, credentials=credentials) - klass = device_class_name_obj[1] - if issubclass(klass, SmartChildDevice): - parent = SmartDevice(host, config=config) - dev = klass( - parent, {"dummy": "info", "device_id": "dummy"}, {"dummy": "components"} - ) - else: - dev = klass(host, config=config) - assert dev.host == host - assert dev.port == port - assert dev.credentials == credentials - - -@device_iot -async def test_modules_preserved(dev: IotDevice): - """Make modules that are not being updated are preserved between updates.""" - dev._last_update["some_module_not_being_updated"] = "should_be_kept" - await dev.update() - assert dev._last_update["some_module_not_being_updated"] == "should_be_kept" - - -async def test_create_smart_device_with_timeout(): - """Make sure timeout is passed to the protocol.""" - host = "127.0.0.1" - dev = IotDevice(host, config=DeviceConfig(host, timeout=100)) - assert dev.protocol._transport._timeout == 100 - dev = SmartDevice(host, config=DeviceConfig(host, timeout=100)) - assert dev.protocol._transport._timeout == 100 - - -async def test_create_thin_wrapper(): - """Make sure thin wrapper is created with the correct device type.""" - mock = Mock() - config = DeviceConfig( - host="test_host", - port_override=1234, - timeout=100, - credentials=Credentials("username", "password"), - ) - with patch("kasa.device_factory.connect", return_value=mock) as connect: - dev = await Device.connect(config=config) - assert dev is mock - - connect.assert_called_once_with( - host=None, - config=config, - ) - - -@device_iot -async def test_modules_not_supported(dev: IotDevice): - """Test that unsupported modules do not break the device.""" - for module in dev.modules.values(): - assert module.is_supported is not None - await dev.update() - for module in dev.modules.values(): - assert module.is_supported is not None - - @device_smart async def test_try_get_response(dev: SmartDevice, caplog): mock_response: dict = { @@ -336,110 +35,3 @@ async def test_update_no_device_info(dev: SmartDevice): KasaException, match=msg ): await dev.update() - - -@pytest.mark.parametrize( - "device_class, use_class", kasa.deprecated_smart_devices.items() -) -def test_deprecated_devices(device_class, use_class): - package_name = ".".join(use_class.__module__.split(".")[:-1]) - msg = f"{device_class} is deprecated, use {use_class.__name__} from package {package_name} instead" - with pytest.deprecated_call(match=msg): - getattr(kasa, device_class) - packages = package_name.split(".") - module = __import__(packages[0]) - for _ in packages[1:]: - module = importlib.import_module(package_name, package=module.__name__) - getattr(module, use_class.__name__) - - -@pytest.mark.parametrize( - "exceptions_class, use_class", kasa.deprecated_exceptions.items() -) -def test_deprecated_exceptions(exceptions_class, use_class): - msg = f"{exceptions_class} is deprecated, use {use_class.__name__} instead" - with pytest.deprecated_call(match=msg): - getattr(kasa, exceptions_class) - getattr(kasa, use_class.__name__) - - -def check_mac(x): - if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", x.lower()): - return x - raise Invalid(x) - - -TZ_SCHEMA = Schema( - {"zone_str": str, "dst_offset": int, "index": All(int, Range(min=0)), "tz_str": str} -) - - -SYSINFO_SCHEMA = Schema( - { - "active_mode": In(["schedule", "none", "count_down"]), - "alias": str, - "dev_name": str, - "deviceId": str, - "feature": str, - "fwId": str, - "hwId": str, - "hw_ver": str, - "icon_hash": str, - "led_off": Boolean, - "latitude": Any(All(float, Range(min=-90, max=90)), 0, None), - "latitude_i": Any( - All(int, Range(min=-900000, max=900000)), - All(float, Range(min=-900000, max=900000)), - 0, - None, - ), - "longitude": Any(All(float, Range(min=-180, max=180)), 0, None), - "longitude_i": Any( - All(int, Range(min=-18000000, max=18000000)), - All(float, Range(min=-18000000, max=18000000)), - 0, - None, - ), - "mac": check_mac, - "model": str, - "oemId": str, - "on_time": int, - "relay_state": int, - "rssi": Any(int, None), # rssi can also be positive, see #54 - "sw_ver": str, - "type": str, - "mic_type": str, - "updating": Boolean, - # these are available on hs220 - "brightness": int, - "preferred_state": [ - {"brightness": All(int, Range(min=0, max=100)), "index": int} - ], - "next_action": {"type": int}, - "child_num": Optional(Any(None, int)), - "children": Optional(list), - }, - extra=REMOVE_EXTRA, -) - - -@dimmer -def test_device_type_dimmer(dev): - assert dev.device_type == DeviceType.Dimmer - - -@bulb -def test_device_type_bulb(dev): - if dev.is_light_strip: - pytest.skip("bulb has also lightstrips to test the api") - assert dev.device_type == DeviceType.Bulb - - -@plug -def test_device_type_plug(dev): - assert dev.device_type == DeviceType.Plug - - -@lightstrip -def test_device_type_lightstrip(dev): - assert dev.device_type == DeviceType.LightStrip diff --git a/kasa/tests/test_strip.py b/kasa/tests/test_strip.py index e7d36f903..e5285accb 100644 --- a/kasa/tests/test_strip.py +++ b/kasa/tests/test_strip.py @@ -131,3 +131,11 @@ async def test_all_binary_states(dev): # original state map should be restored for index, state in dev.is_on.items(): assert state == state_map[index] + + +@strip +def test_children_api(dev): + """Test the child device API.""" + first = dev.children[0] + first_by_get_child_device = dev.get_child_device(first.device_id) + assert first == first_by_get_child_device From 270614aa028ed2508bfba83bdb9148c0b0e6e8de Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 15 Mar 2024 17:18:13 +0100 Subject: [PATCH 365/892] Revise device initialization and subsequent updates (#807) This improves the initial update cycle to fetch the information as early as possible and avoid requesting unnecessary information (like the child component listing) in every subsequent call of `update()`. The initial update performs the following steps: 1. `component_nego` (for components) and `get_device_info` (for common device info) are requested as first, and their results are stored in the internal state to allow individual modules (like colortemp) to access the data during the initialization later on. 2. If `child_device` component is available, the child device list and their components is requested separately to initialize the children. 3. The modules are initialized based on component lists, making the queries available for the regular `update()`. 4. Finally, a query requesting all module-defined queries is executed, including also those that we already did above, like the device info. All subsequent updates will only involve queries that are defined by the supported modules. This also means that we do not currently support adding & removing child devices on the fly. The internal state contains now only the responses for the most recent update (i.e., no component information is directly available anymore, but needs to be accessed separately if needed). If component information is wanted from homeassistant users via diagnostics reports, the diagnostic platform needs to be adapted to acquire this separately. --- kasa/smart/modules/childdevicemodule.py | 11 +--- kasa/smart/smartdevice.py | 43 +++++++++----- kasa/tests/test_childdevice.py | 2 +- kasa/tests/test_smartdevice.py | 79 +++++++++++++++++++++++-- 4 files changed, 104 insertions(+), 31 deletions(-) diff --git a/kasa/smart/modules/childdevicemodule.py b/kasa/smart/modules/childdevicemodule.py index 62e024d0c..9f4710b2d 100644 --- a/kasa/smart/modules/childdevicemodule.py +++ b/kasa/smart/modules/childdevicemodule.py @@ -1,5 +1,4 @@ """Implementation for child devices.""" -from typing import Dict from ..smartmodule import SmartModule @@ -8,12 +7,4 @@ class ChildDeviceModule(SmartModule): """Implementation for child devices.""" REQUIRED_COMPONENT = "child_device" - - def query(self) -> Dict: - """Query to execute during the update cycle.""" - # TODO: There is no need to fetch the component list every time, - # so this should be optimized only for the init. - return { - "get_child_device_list": None, - "get_child_device_component_list": None, - } + QUERY_GETTER_NAME = "get_child_device_list" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 8b0236c37..3cbd12f97 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -41,10 +41,18 @@ def __init__( self.modules: Dict[str, "SmartModule"] = {} self._parent: Optional["SmartDevice"] = None self._children: Mapping[str, "SmartDevice"] = {} + self._last_update = {} async def _initialize_children(self): """Initialize children for power strips.""" - children = self.internal_state["child_info"]["child_device_list"] + child_info_query = { + "get_child_device_component_list": None, + "get_child_device_list": None, + } + resp = await self.protocol.query(child_info_query) + self.internal_state.update(resp) + + children = self.internal_state["get_child_device_list"]["child_device_list"] children_components = { child["device_id"]: { comp["id"]: int(comp["ver_code"]) for comp in child["component_list"] @@ -88,13 +96,30 @@ def _try_get_response(self, responses: dict, request: str, default=None) -> dict ) async def _negotiate(self): - resp = await self.protocol.query("component_nego") + """Perform initialization. + + We fetch the device info and the available components as early as possible. + If the device reports supporting child devices, they are also initialized. + """ + initial_query = {"component_nego": None, "get_device_info": None} + resp = await self.protocol.query(initial_query) + + # Save the initial state to allow modules access the device info already + # during the initialization, which is necessary as some information like the + # supported color temperature range is contained within the response. + self._last_update.update(resp) + self._info = self._try_get_response(resp, "get_device_info") + + # Create our internal presentation of available components self._components_raw = resp["component_nego"] self._components = { comp["id"]: int(comp["ver_code"]) for comp in self._components_raw["component_list"] } + if "child_device" in self._components and not self.children: + await self._initialize_children() + async def update(self, update_children: bool = True): """Update the device.""" if self.credentials is None and self.credentials_hash is None: @@ -110,20 +135,10 @@ async def update(self, update_children: bool = True): for module in self.modules.values(): req.update(module.query()) - resp = await self.protocol.query(req) + self._last_update = resp = await self.protocol.query(req) self._info = self._try_get_response(resp, "get_device_info") - - self._last_update = { - "components": self._components_raw, - **resp, - "child_info": self._try_get_response(resp, "get_child_device_list", {}), - } - - if child_info := self._last_update.get("child_info"): - if not self.children: - await self._initialize_children() - + if child_info := self._try_get_response(resp, "get_child_device_list", {}): # TODO: we don't currently perform queries on children based on modules, # but just update the information that is returned in the main query. for info in child_info["child_device_list"]: diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 07baf598b..97d3fd376 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -24,7 +24,7 @@ def test_childdevice_init(dev, dummy_protocol, mocker): @strip_smart async def test_childdevice_update(dev, dummy_protocol, mocker): """Test that parent update updates children.""" - child_info = dev._last_update["child_info"] + child_info = dev.internal_state["get_child_device_list"] child_list = child_info["child_device_list"] assert len(dev.children) == child_info["sum"] diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index a9871fa29..d7b1cca9d 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -1,8 +1,9 @@ """Tests for SMART devices.""" import logging -from unittest.mock import patch +from typing import Any, Dict -import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 +import pytest +from pytest_mock import MockerFixture from kasa import KasaException from kasa.exceptions import SmartErrorCode @@ -25,13 +26,79 @@ async def test_try_get_response(dev: SmartDevice, caplog): @device_smart -async def test_update_no_device_info(dev: SmartDevice): +async def test_update_no_device_info(dev: SmartDevice, mocker: MockerFixture): mock_response: dict = { "get_device_usage": {}, "get_device_time": {}, } msg = f"get_device_info not found in {mock_response} for device 127.0.0.123" - with patch.object(dev.protocol, "query", return_value=mock_response), pytest.raises( - KasaException, match=msg - ): + with mocker.patch.object( + dev.protocol, "query", return_value=mock_response + ), pytest.raises(KasaException, match=msg): await dev.update() + + +@device_smart +async def test_initial_update(dev: SmartDevice, mocker: MockerFixture): + """Test the initial update cycle.""" + # As the fixture data is already initialized, we reset the state for testing + dev._components_raw = None + dev._features = {} + + negotiate = mocker.spy(dev, "_negotiate") + initialize_modules = mocker.spy(dev, "_initialize_modules") + initialize_features = mocker.spy(dev, "_initialize_features") + + # Perform two updates and verify that initialization is only done once + await dev.update() + await dev.update() + + negotiate.assert_called_once() + assert dev._components_raw is not None + initialize_modules.assert_called_once() + assert dev.modules + initialize_features.assert_called_once() + assert dev.features + + +@device_smart +async def test_negotiate(dev: SmartDevice, mocker: MockerFixture): + """Test that the initial negotiation performs expected steps.""" + # As the fixture data is already initialized, we reset the state for testing + dev._components_raw = None + dev._children = {} + + query = mocker.spy(dev.protocol, "query") + initialize_children = mocker.spy(dev, "_initialize_children") + await dev._negotiate() + + # Check that we got the initial negotiation call + query.assert_any_call({"component_nego": None, "get_device_info": None}) + assert dev._components_raw + + # Check the children are created, if device supports them + if "child_device" in dev._components: + initialize_children.assert_called_once() + query.assert_any_call( + { + "get_child_device_component_list": None, + "get_child_device_list": None, + } + ) + assert len(dev.children) == dev.internal_state["get_child_device_list"]["sum"] + + +@device_smart +async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): + """Test that the regular update uses queries from all supported modules.""" + query = mocker.spy(dev.protocol, "query") + + # We need to have some modules initialized by now + assert dev.modules + + await dev.update() + full_query: Dict[str, Any] = {} + for mod in dev.modules.values(): + full_query |= mod.query() + + query.assert_called_with(full_query) From d63f43a23004728e6f746071287cd7de96de5a29 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 15 Mar 2024 17:36:07 +0100 Subject: [PATCH 366/892] Add colortemp module (#814) Allow controlling the color temperature via features interface: ``` $ kasa --host 192.168.xx.xx feature color_temperature Color temperature (color_temperature): 0 $ kasa --host 192.168.xx.xx feature color_temperature 2000 Setting color_temperature to 2000 Raised error: Temperature should be between 2500 and 6500, was 2000 Run with --debug enabled to see stacktrace $ kasa --host 192.168.xx.xx feature color_temperature 3000 Setting color_temperature to 3000 $ kasa --host 192.168.xx.xx feature color_temperature Color temperature (color_temperature): 3000 ``` --- kasa/feature.py | 11 ++++ kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/colortemp.py | 55 +++++++++++++++++++ kasa/tests/smart/features/__init__.py | 0 .../features/test_brightness.py} | 0 kasa/tests/smart/features/test_colortemp.py | 31 +++++++++++ 6 files changed, 99 insertions(+) create mode 100644 kasa/smart/modules/colortemp.py create mode 100644 kasa/tests/smart/features/__init__.py rename kasa/tests/{test_feature_brightness.py => smart/features/test_brightness.py} (100%) create mode 100644 kasa/tests/smart/features/test_colortemp.py diff --git a/kasa/feature.py b/kasa/feature.py index df28c952c..c42debc73 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -41,6 +41,17 @@ class Feature: minimum_value: int = 0 #: Maximum value maximum_value: int = 2**16 # Arbitrary max + #: Attribute containing the name of the range getter property. + #: If set, this property will be used to set *minimum_value* and *maximum_value*. + range_getter: Optional[str] = None + + def __post_init__(self): + """Handle late-binding of members.""" + container = self.container if self.container is not None else self.device + if self.range_getter is not None: + self.minimum_value, self.maximum_value = getattr( + container, self.range_getter + ) @property def value(self): diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index dc4e0cf5a..9d1af1c82 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -5,6 +5,7 @@ from .brightness import Brightness from .childdevicemodule import ChildDeviceModule from .cloudmodule import CloudModule +from .colortemp import ColorTemperatureModule from .devicemodule import DeviceModule from .energymodule import EnergyModule from .firmware import Firmware @@ -31,4 +32,5 @@ "Firmware", "CloudModule", "LightTransitionModule", + "ColorTemperatureModule", ] diff --git a/kasa/smart/modules/colortemp.py b/kasa/smart/modules/colortemp.py new file mode 100644 index 000000000..97388b8d1 --- /dev/null +++ b/kasa/smart/modules/colortemp.py @@ -0,0 +1,55 @@ +"""Implementation of color temp module.""" +from typing import TYPE_CHECKING, Dict + +from ...bulb import ColorTempRange +from ...feature import Feature +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class ColorTemperatureModule(SmartModule): + """Implementation of color temp module.""" + + REQUIRED_COMPONENT = "color_temperature" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Color temperature", + container=self, + attribute_getter="color_temp", + attribute_setter="set_color_temp", + range_getter="valid_temperature_range", + ) + ) + + def query(self) -> Dict: + """Query to execute during the update cycle.""" + # Color temp is contained in the main device info response. + return {} + + @property + def valid_temperature_range(self) -> ColorTempRange: + """Return valid color-temp range.""" + return ColorTempRange(*self.data.get("color_temp_range")) + + @property + def color_temp(self): + """Return current color temperature.""" + return self.data["color_temp"] + + async def set_color_temp(self, temp: int): + """Set the color temperature.""" + valid_temperature_range = self.valid_temperature_range + if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: + raise ValueError( + "Temperature should be between {} and {}, was {}".format( + *valid_temperature_range, temp + ) + ) + + return await self.call("set_device_info", {"color_temp": temp}) diff --git a/kasa/tests/smart/features/__init__.py b/kasa/tests/smart/features/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/kasa/tests/test_feature_brightness.py b/kasa/tests/smart/features/test_brightness.py similarity index 100% rename from kasa/tests/test_feature_brightness.py rename to kasa/tests/smart/features/test_brightness.py diff --git a/kasa/tests/smart/features/test_colortemp.py b/kasa/tests/smart/features/test_colortemp.py new file mode 100644 index 000000000..8c899d6d5 --- /dev/null +++ b/kasa/tests/smart/features/test_colortemp.py @@ -0,0 +1,31 @@ +import pytest + +from kasa.smart import SmartDevice +from kasa.tests.conftest import parametrize + +brightness = parametrize("colortemp smart", component_filter="color_temperature") + + +@brightness +async def test_colortemp_component(dev: SmartDevice): + """Test brightness feature.""" + assert isinstance(dev, SmartDevice) + assert "color_temperature" in dev._components + + # Test getting the value + feature = dev.features["color_temperature"] + assert isinstance(feature.value, int) + assert isinstance(feature.minimum_value, int) + assert isinstance(feature.maximum_value, int) + + # Test setting the value + # We need to take the min here, as L9xx reports a range [9000, 9000]. + new_value = min(feature.minimum_value + 1, feature.maximum_value) + await feature.set_value(new_value) + assert feature.value == new_value + + with pytest.raises(ValueError): + await feature.set_value(feature.minimum_value - 10) + + with pytest.raises(ValueError): + await feature.set_value(feature.maximum_value + 10) From 35dbda704985ce35a3959a81d25d3fddf7360e7b Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 26 Mar 2024 19:28:39 +0100 Subject: [PATCH 367/892] Change state_information to return feature values (#804) This changes `state_information` to return the names and values of all defined features. It was originally a "temporary" hack to show some extra, device-specific information in the cli tool, but now that we have device-defined features we can leverage them. --- kasa/cli.py | 11 +----- kasa/device.py | 4 +-- kasa/iot/iotbulb.py | 19 +---------- kasa/iot/iotdevice.py | 6 ---- kasa/iot/iotdimmer.py | 9 ----- kasa/iot/iotlightstrip.py | 14 +------- kasa/iot/iotplug.py | 8 +---- kasa/iot/iotstrip.py | 13 -------- kasa/smart/smartbulb.py | 21 +----------- kasa/smart/smartdevice.py | 9 ----- kasa/tests/fakeprotocol_iot.py | 61 ++++++++++++++++++++++++++++++++-- kasa/tests/test_bulb.py | 9 +++-- kasa/tests/test_lightstrip.py | 1 - 13 files changed, 70 insertions(+), 115 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 78553ebf2..20372be4e 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -609,16 +609,7 @@ async def state(ctx, dev: Device): echo(f"\tMAC (rssi): {dev.mac} ({dev.rssi})") echo(f"\tLocation: {dev.location}") - echo("\n\t[bold]== Device specific information ==[/bold]") - for info_name, info_data in dev.state_information.items(): - if isinstance(info_data, list): - echo(f"\t{info_name}:") - for item in info_data: - echo(f"\t\t{item}") - else: - echo(f"\t{info_name}: {info_data}") - - echo("\n\t[bold]== Features == [/bold]") + echo("\n\t[bold]== Device-specific information == [/bold]") for id_, feature in dev.features.items(): echo(f"\t{feature.name} ({id_}): {feature.value}") diff --git a/kasa/device.py b/kasa/device.py index 63eafa5b7..fd0fe59c7 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -304,9 +304,9 @@ def internal_state(self) -> Any: """Return all the internal state data.""" @property - @abstractmethod def state_information(self) -> Dict[str, Any]: - """Return the key state information.""" + """Return available features and their values.""" + return {feat.name: feat.value for feat in self._features.values()} @property def features(self) -> Dict[str, Feature]: diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index d80a24ea5..7652a1fba 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -2,7 +2,7 @@ import logging import re from enum import Enum -from typing import Any, Dict, List, Optional, cast +from typing import Dict, List, Optional, cast try: from pydantic.v1 import BaseModel, Field, root_validator @@ -462,23 +462,6 @@ async def set_brightness( light_state = {"brightness": brightness} return await self.set_light_state(light_state, transition=transition) - @property # type: ignore - @requires_update - def state_information(self) -> Dict[str, Any]: - """Return bulb-specific state information.""" - info: Dict[str, Any] = { - "Brightness": self.brightness, - "Is dimmable": self.is_dimmable, - } - if self.is_variable_color_temp: - info["Color temperature"] = self.color_temp - info["Valid temperature range"] = self.valid_temperature_range - if self.is_color: - info["HSV"] = self.hsv - info["Presets"] = self.presets - - return info - @property # type: ignore @requires_update def is_on(self) -> bool: diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 5bbb95058..0f34d8fb9 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -615,12 +615,6 @@ def on_since(self) -> Optional[datetime]: return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) - @property # type: ignore - @requires_update - def state_information(self) -> Dict[str, Any]: - """Return device-type specific, end-user friendly state information.""" - raise NotImplementedError("Device subclass needs to implement this.") - @property # type: ignore @requires_update def device_id(self) -> str: diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 8882ae814..cbcafd12f 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -232,12 +232,3 @@ def is_dimmable(self) -> bool: """Whether the switch supports brightness changes.""" sys_info = self.sys_info return "brightness" in sys_info - - @property # type: ignore - @requires_update - def state_information(self) -> Dict[str, Any]: - """Return switch-specific state information.""" - info = super().state_information - info["Brightness"] = self.brightness - - return info diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index fa341a2c5..1e657a987 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -1,5 +1,5 @@ """Module for light strips (KL430).""" -from typing import Any, Dict, List, Optional +from typing import Dict, List, Optional from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -84,18 +84,6 @@ def effect_list(self) -> Optional[List[str]]: """ return EFFECT_NAMES_V1 if self.has_effects else None - @property # type: ignore - @requires_update - def state_information(self) -> Dict[str, Any]: - """Return strip specific state information.""" - info = super().state_information - - info["Length"] = self.length - if self.has_effects: - info["Effect"] = self.effect["name"] - - return info - @requires_update async def set_effect( self, diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 2d509e05e..2296b1e6d 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -1,6 +1,6 @@ """Module for smart plugs (HS100, HS110, ..).""" import logging -from typing import Any, Dict, Optional +from typing import Optional from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -99,12 +99,6 @@ async def set_led(self, state: bool): "system", "set_led_off", {"off": int(not state)} ) - @property # type: ignore - @requires_update - def state_information(self) -> Dict[str, Any]: - """Return switch-specific state information.""" - return {} - class IotWallSwitch(IotPlug): """Representation of a TP-Link Smart Wall Switch.""" diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 4bf31cc76..2e5af0d08 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -154,19 +154,6 @@ async def set_led(self, state: bool): """Set the state of the led (night mode).""" await self._query_helper("system", "set_led_off", {"off": int(not state)}) - @property # type: ignore - @requires_update - def state_information(self) -> Dict[str, Any]: - """Return strip-specific state information. - - :return: Strip information dict, keys in user-presentable form. - """ - return { - "LED state": self.led, - "Childs count": len(self.children), - "On since": self.on_since, - } - async def current_consumption(self) -> float: """Get the current power consumption in watts.""" return sum([await plug.current_consumption() for plug in self.children]) diff --git a/kasa/smart/smartbulb.py b/kasa/smart/smartbulb.py index eb3310e81..b92edecd2 100644 --- a/kasa/smart/smartbulb.py +++ b/kasa/smart/smartbulb.py @@ -1,5 +1,5 @@ """Module for tapo-branded smart bulbs (L5**).""" -from typing import Any, Dict, List, Optional +from typing import Dict, List, Optional from ..bulb import Bulb from ..exceptions import KasaException @@ -238,25 +238,6 @@ async def set_effect( } ) - @property # type: ignore - def state_information(self) -> Dict[str, Any]: - """Return bulb-specific state information.""" - info: Dict[str, Any] = { - # TODO: re-enable after we don't inherit from smartbulb - # **super().state_information - "Is dimmable": self.is_dimmable, - } - if self.is_dimmable: - info["Brightness"] = self.brightness - if self.is_variable_color_temp: - info["Color temperature"] = self.color_temp - info["Valid temperature range"] = self.valid_temperature_range - if self.is_color: - info["HSV"] = self.hsv - info["Presets"] = self.presets - - return info - @property def presets(self) -> List[BulbPreset]: """Return a list of available bulb setting presets.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 3cbd12f97..f6e7f7347 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -334,15 +334,6 @@ def ssid(self) -> str: ssid = base64.b64decode(ssid).decode() if ssid else "No SSID" return ssid - @property - def state_information(self) -> Dict[str, Any]: - """Return the key state information.""" - return { - "overheated": self._info.get("overheated"), - "signal_level": self._info.get("signal_level"), - "SSID": self.ssid, - } - @property def has_emeter(self) -> bool: """Return if the device has emeter.""" diff --git a/kasa/tests/fakeprotocol_iot.py b/kasa/tests/fakeprotocol_iot.py index 864576541..6b22db0bd 100644 --- a/kasa/tests/fakeprotocol_iot.py +++ b/kasa/tests/fakeprotocol_iot.py @@ -121,6 +121,61 @@ def success(res): "set_timezone": None, } +CLOUD_MODULE = { + "get_info": { + "username": "", + "server": "devs.tplinkcloud.com", + "binded": 0, + "cld_connection": 0, + "illegalType": -1, + "stopConnect": -1, + "tcspStatus": -1, + "fwDlPage": "", + "tcspInfo": "", + "fwNotifyType": 0, + } +} + + +AMBIENT_MODULE = { + "get_current_brt": {"value": 26, "err_code": 0}, + "get_config": { + "devs": [ + { + "hw_id": 0, + "enable": 0, + "dark_index": 1, + "min_adc": 0, + "max_adc": 2450, + "level_array": [ + {"name": "cloudy", "adc": 490, "value": 20}, + {"name": "overcast", "adc": 294, "value": 12}, + {"name": "dawn", "adc": 222, "value": 9}, + {"name": "twilight", "adc": 222, "value": 9}, + {"name": "total darkness", "adc": 111, "value": 4}, + {"name": "custom", "adc": 2400, "value": 97}, + ], + } + ], + "ver": "1.0", + "err_code": 0, + }, +} + + +MOTION_MODULE = { + "get_config": { + "enable": 0, + "version": "1.0", + "trigger_index": 2, + "cold_time": 60000, + "min_adc": 0, + "max_adc": 4095, + "array": [80, 50, 20, 0], + "err_code": 0, + } +} + class FakeIotProtocol(IotProtocol): def __init__(self, info): @@ -306,8 +361,10 @@ def light_state(self, x, *args): "set_brightness": set_hs220_brightness, "set_dimmer_transition": set_hs220_dimmer_transition, }, - "smartlife.iot.LAS": {}, - "smartlife.iot.PIR": {}, + "smartlife.iot.LAS": AMBIENT_MODULE, + "smartlife.iot.PIR": MOTION_MODULE, + "cnCloud": CLOUD_MODULE, + "smartlife.iot.common.cloud": CLOUD_MODULE, } async def query(self, request, port=9999): diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 48b5976e4..2ef52fec7 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -42,11 +42,8 @@ async def test_bulb_sysinfo(dev: Bulb): @bulb async def test_state_attributes(dev: Bulb): - assert "Brightness" in dev.state_information - assert dev.state_information["Brightness"] == dev.brightness - - assert "Is dimmable" in dev.state_information - assert dev.state_information["Is dimmable"] == dev.is_dimmable + assert "Cloud connection" in dev.state_information + assert isinstance(dev.state_information["Cloud connection"], bool) @bulb_iot @@ -114,6 +111,7 @@ async def test_invalid_hsv(dev: Bulb, turn_on): @color_bulb +@pytest.mark.skip("requires color feature") async def test_color_state_information(dev: Bulb): assert "HSV" in dev.state_information assert dev.state_information["HSV"] == dev.hsv @@ -130,6 +128,7 @@ async def test_hsv_on_non_color(dev: Bulb): @variable_temp +@pytest.mark.skip("requires colortemp module") async def test_variable_temp_state_information(dev: Bulb): assert "Color temperature" in dev.state_information assert dev.state_information["Color temperature"] == dev.color_temp diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index fcc48dfaf..ac80c52a0 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -28,7 +28,6 @@ async def test_effects_lightstrip_set_effect(dev: IotLightStrip): await dev.set_effect("Candy Cane") assert dev.effect["name"] == "Candy Cane" - assert dev.state_information["Effect"] == "Candy Cane" @lightstrip From c08b58cd8b2d4d8ce84a5967961c6dae6b88ca9a Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 26 Mar 2024 19:33:10 +0100 Subject: [PATCH 368/892] Add colortemp feature for iot devices (#827) Make color temperature feature available for iot bulbs. --- kasa/iot/iotbulb.py | 13 +++++++++++++ kasa/tests/test_bulb.py | 5 ----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 7652a1fba..1ba943009 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -221,6 +221,19 @@ async def _initialize_features(self): ) ) + if self.is_variable_color_temp: + self._add_feature( + Feature( + device=self, + name="Color temperature", + container=self, + attribute_getter="color_temp", + attribute_setter="set_color_temp", + range_getter="valid_temperature_range", + ) + ) + + @property # type: ignore @requires_update def is_color(self) -> bool: diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 2ef52fec7..be27df1b9 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -133,11 +133,6 @@ async def test_variable_temp_state_information(dev: Bulb): assert "Color temperature" in dev.state_information assert dev.state_information["Color temperature"] == dev.color_temp - assert "Valid temperature range" in dev.state_information - assert ( - dev.state_information["Valid temperature range"] == dev.valid_temperature_range - ) - @variable_temp @turn_on From 0f3b29183dde507d7cab6c271b923d684ecef7e2 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 28 Mar 2024 11:38:58 +0000 Subject: [PATCH 369/892] Fix non python 3.8 compliant test (#832) --- kasa/tests/test_smartdevice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index d7b1cca9d..306a4b3da 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -99,6 +99,6 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): await dev.update() full_query: Dict[str, Any] = {} for mod in dev.modules.values(): - full_query |= mod.query() + full_query = {**full_query, **mod.query()} query.assert_called_with(full_query) From 5d08a4c07404691baac1ac06fa23d647db03bf80 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 28 Mar 2024 11:57:26 +0000 Subject: [PATCH 370/892] Fix CI issue with python version used by pipx to install poetry (#831) --- .github/workflows/ci.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 827ed947b..9d606ab28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,13 +19,23 @@ jobs: steps: - uses: "actions/checkout@v4" - - name: Install poetry - run: pipx install poetry - uses: "actions/setup-python@v5" id: setup-python with: python-version: "${{ matrix.python-version }}" - cache: 'poetry' + - name: Install poetry + run: pipx install poetry --python "${{ steps.setup-python.outputs.python-path }}" + - name: Read poetry cache location + id: poetry-cache-location + shell: bash + run: | + echo "POETRY_VENV_LOCATION=$(poetry config virtualenvs.path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + name: Poetry cache + with: + path: | + ${{ steps.poetry-cache-location.outputs.POETRY_VENV_LOCATION }} + key: ${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }}-extras-false - name: "Install dependencies" run: | poetry install @@ -34,6 +44,7 @@ jobs: run: >- echo "PRE_COMMIT_VERSION=$(poetry run pre-commit -V | awk '{print $2}')" >> $GITHUB_OUTPUT - uses: actions/cache@v3 + name: Pre-commit cache with: path: ~/.cache/pre-commit/ key: ${{ runner.os }}-pre-commit-${{ steps.pre-commit-version.outputs.PRE_COMMIT_VERSION }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} @@ -97,18 +108,19 @@ jobs: steps: - uses: "actions/checkout@v4" - - name: Install poetry - run: pipx install poetry - uses: "actions/setup-python@v5" id: setup-python with: python-version: "${{ matrix.python-version }}" + - name: Install poetry + run: pipx install poetry --python "${{ steps.setup-python.outputs.python-path }}" - name: Read poetry cache location id: poetry-cache-location shell: bash run: | echo "POETRY_VENV_LOCATION=$(poetry config virtualenvs.path)" >> $GITHUB_OUTPUT - uses: actions/cache@v3 + name: Poetry cache with: path: | ${{ steps.poetry-cache-location.outputs.POETRY_VENV_LOCATION }} From 87fa39dd80851fdd12055bb5f4e630cae9aa723c Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sat, 13 Apr 2024 18:56:55 +0100 Subject: [PATCH 371/892] Cache pipx in CI and add custom setup action (#835) --- .github/actions/setup/action.yaml | 83 +++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 64 +++++------------------- 2 files changed, 95 insertions(+), 52 deletions(-) create mode 100644 .github/actions/setup/action.yaml diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml new file mode 100644 index 000000000..be38072e1 --- /dev/null +++ b/.github/actions/setup/action.yaml @@ -0,0 +1,83 @@ +--- +name: Setup Environment +description: Install requested pipx dependencies, configure the system python, and install poetry and the package dependencies + +inputs: + poetry-install-options: + default: "" + poetry-version: + default: 1.8.2 + python-version: + required: true + cache-pre-commit: + default: false + +runs: + using: composite + steps: + - uses: "actions/setup-python@v5" + id: setup-python + with: + python-version: "${{ inputs.python-version }}" + + - name: Setup pipx environment Variables + id: pipx-env-setup + # pipx default home and bin dir are not writable by the cache action + # so override them here and add the bin dir to PATH for later steps. + # This also ensures the pipx cache only contains poetry + run: | + SEP="${{ !startsWith(runner.os, 'windows') && '/' || '\\' }}" + PIPX_CACHE="${{ github.workspace }}${SEP}pipx_cache" + echo "pipx-cache-path=${PIPX_CACHE}" >> $GITHUB_OUTPUT + echo "pipx-version=$(pipx --version)" >> $GITHUB_OUTPUT + echo "PIPX_HOME=${PIPX_CACHE}${SEP}home" >> $GITHUB_ENV + echo "PIPX_BIN_DIR=${PIPX_CACHE}${SEP}bin" >> $GITHUB_ENV + echo "PIPX_MAN_DIR=${PIPX_CACHE}${SEP}man" >> $GITHUB_ENV + echo "${PIPX_CACHE}${SEP}bin" >> $GITHUB_PATH + shell: bash + + - name: Pipx cache + id: pipx-cache + uses: actions/cache@v4 + with: + path: ${{ steps.pipx-env-setup.outputs.pipx-cache-path }} + key: ${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-pipx-${{ steps.pipx-env-setup.outputs.pipx-version }}-poetry-${{ inputs.poetry-version }} + + - name: Install poetry + if: steps.pipx-cache.outputs.cache-hit != 'true' + id: install-poetry + shell: bash + run: |- + pipx install poetry==${{ inputs.poetry-version }} --python "${{ steps.setup-python.outputs.python-path }}" + + - name: Read poetry cache location + id: poetry-cache-location + shell: bash + run: |- + echo "poetry-venv-location=$(poetry config virtualenvs.path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + name: Poetry cache + with: + path: | + ${{ steps.poetry-cache-location.outputs.poetry-venv-location }} + key: ${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }}-options-${{ inputs.poetry-install-options }} + + - name: "Poetry install" + shell: bash + run: | + poetry install ${{ inputs.poetry-install-options }} + + - name: Read pre-commit version + if: inputs.cache-pre-commit == 'true' + id: pre-commit-version + shell: bash + run: >- + echo "pre-commit-version=$(poetry run pre-commit -V | awk '{print $2}')" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + if: inputs.cache-pre-commit == 'true' + name: Pre-commit cache + with: + path: ~/.cache/pre-commit/ + key: ${{ runner.os }}-pre-commit-${{ steps.pre-commit-version.outputs.pre-commit-version }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d606ab28..d2e89db87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,8 @@ on: branches: ["master"] workflow_dispatch: # to allow manual re-runs +env: + POETRY_VERSION: 1.8.2 jobs: linting: @@ -19,35 +21,12 @@ jobs: steps: - uses: "actions/checkout@v4" - - uses: "actions/setup-python@v5" - id: setup-python + - name: Setup environment + uses: ./.github/actions/setup with: - python-version: "${{ matrix.python-version }}" - - name: Install poetry - run: pipx install poetry --python "${{ steps.setup-python.outputs.python-path }}" - - name: Read poetry cache location - id: poetry-cache-location - shell: bash - run: | - echo "POETRY_VENV_LOCATION=$(poetry config virtualenvs.path)" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 - name: Poetry cache - with: - path: | - ${{ steps.poetry-cache-location.outputs.POETRY_VENV_LOCATION }} - key: ${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }}-extras-false - - name: "Install dependencies" - run: | - poetry install - - name: Read pre-commit version - id: pre-commit-version - run: >- - echo "PRE_COMMIT_VERSION=$(poetry run pre-commit -V | awk '{print $2}')" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 - name: Pre-commit cache - with: - path: ~/.cache/pre-commit/ - key: ${{ runner.os }}-pre-commit-${{ steps.pre-commit-version.outputs.PRE_COMMIT_VERSION }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} + python-version: ${{ matrix.python-version }} + cache-pre-commit: true + poetry-version: ${{ env.POETRY_VERSION }} - name: "Check supported device md files are up to date" run: | poetry run pre-commit run generate-supported --all-files @@ -108,31 +87,12 @@ jobs: steps: - uses: "actions/checkout@v4" - - uses: "actions/setup-python@v5" - id: setup-python + - name: Setup environment + uses: ./.github/actions/setup with: - python-version: "${{ matrix.python-version }}" - - name: Install poetry - run: pipx install poetry --python "${{ steps.setup-python.outputs.python-path }}" - - name: Read poetry cache location - id: poetry-cache-location - shell: bash - run: | - echo "POETRY_VENV_LOCATION=$(poetry config virtualenvs.path)" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 - name: Poetry cache - with: - path: | - ${{ steps.poetry-cache-location.outputs.POETRY_VENV_LOCATION }} - key: ${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }}-extras-${{ matrix.extras }} - - name: "Install dependencies (no extras)" - if: matrix.extras == false - run: | - poetry install - - name: "Install dependencies (with extras)" - if: matrix.extras == true - run: | - poetry install --all-extras + python-version: ${{ matrix.python-version }} + poetry-version: ${{ env.POETRY_VERSION }} + poetry-install-options: ${{ matrix.extras == true && '--all-extras' || '' }} - name: "Run tests (no coverage)" if: ${{ startsWith(matrix.python-version, 'pypy') }} run: | From da441bc6970bb3d3eaecaf2a216a96d76dceb649 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 16 Apr 2024 19:21:20 +0100 Subject: [PATCH 372/892] Update poetry locks and pre-commit hooks (#837) Also updates CI pypy versions to be 3.9 and 3.10 which are the currently [supported versions](https://www.pypy.org/posts/2024/01/pypy-v7315-release.html). Otherwise latest cryptography doesn't ship with pypy3.8 wheels and is unable to build on windows. Also updates the `codecov-action` to v4 which fixed some intermittent uploading errors. --- .github/workflows/ci.yml | 6 +- .pre-commit-config.yaml | 6 +- devtools/bench/utils/data.py | 1 - devtools/bench/utils/original.py | 1 + devtools/dump_devinfo.py | 1 + devtools/generate_supported.py | 1 + devtools/perftest.py | 1 + kasa/__init__.py | 1 + kasa/aestransport.py | 1 + kasa/bulb.py | 1 + kasa/cli.py | 1 + kasa/device.py | 1 + kasa/device_factory.py | 1 + kasa/device_type.py | 1 - kasa/deviceconfig.py | 1 + kasa/discover.py | 1 + kasa/emeterstatus.py | 1 + kasa/exceptions.py | 1 + kasa/feature.py | 1 + kasa/httpclient.py | 1 + kasa/iot/__init__.py | 1 + kasa/iot/iotbulb.py | 2 +- kasa/iot/iotdevice.py | 1 + kasa/iot/iotdimmer.py | 1 + kasa/iot/iotlightstrip.py | 1 + kasa/iot/iotmodule.py | 1 + kasa/iot/iotplug.py | 1 + kasa/iot/iotstrip.py | 1 + kasa/iot/modules/__init__.py | 1 + kasa/iot/modules/ambientlight.py | 1 + kasa/iot/modules/antitheft.py | 1 + kasa/iot/modules/cloud.py | 1 + kasa/iot/modules/countdown.py | 1 + kasa/iot/modules/emeter.py | 1 + kasa/iot/modules/motion.py | 1 + kasa/iot/modules/rulemodule.py | 1 + kasa/iot/modules/schedule.py | 1 + kasa/iot/modules/time.py | 1 + kasa/iot/modules/usage.py | 1 + kasa/iotprotocol.py | 1 + kasa/module.py | 1 + kasa/plug.py | 1 + kasa/protocol.py | 1 + kasa/smart/__init__.py | 1 + kasa/smart/modules/__init__.py | 1 + kasa/smart/modules/alarmmodule.py | 1 + kasa/smart/modules/autooffmodule.py | 1 + kasa/smart/modules/battery.py | 1 + kasa/smart/modules/brightness.py | 1 + kasa/smart/modules/cloudmodule.py | 1 + kasa/smart/modules/colortemp.py | 1 + kasa/smart/modules/devicemodule.py | 1 + kasa/smart/modules/energymodule.py | 1 + kasa/smart/modules/firmware.py | 1 + kasa/smart/modules/humidity.py | 1 + kasa/smart/modules/ledmodule.py | 1 + kasa/smart/modules/lighttransitionmodule.py | 1 + kasa/smart/modules/reportmodule.py | 1 + kasa/smart/modules/temperature.py | 1 + kasa/smart/modules/timemodule.py | 1 + kasa/smart/smartbulb.py | 1 + kasa/smart/smartchilddevice.py | 1 + kasa/smart/smartdevice.py | 1 + kasa/smart/smartmodule.py | 1 + kasa/tests/fakeprotocol_iot.py | 6 +- kasa/tests/fixtureinfo.py | 2 +- kasa/tests/test_device.py | 1 + kasa/tests/test_iotdevice.py | 1 + kasa/tests/test_smartdevice.py | 1 + kasa/xortransport.py | 1 + poetry.lock | 1657 +++++++++---------- pyproject.toml | 6 +- 72 files changed, 904 insertions(+), 846 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2e89db87..d4985528b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.8", "pypy-3.10"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.9", "pypy-3.10"] os: [ubuntu-latest, macos-latest, windows-latest] extras: [false, true] exclude: @@ -70,7 +70,7 @@ jobs: - os: windows-latest extras: true - os: ubuntu-latest - python-version: "pypy-3.8" + python-version: "pypy-3.9" extras: true - os: ubuntu-latest python-version: "pypy-3.10" @@ -102,6 +102,6 @@ jobs: run: | poetry run pytest --cov kasa --cov-report xml - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v3" + uses: "codecov/codecov-action@v4" with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4d1f0a4c6..8c0438d9b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -10,14 +10,14 @@ repos: - id: check-ast - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.3 + rev: v0.3.7 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.3.0 + rev: v1.9.0 hooks: - id: mypy additional_dependencies: [types-click] diff --git a/devtools/bench/utils/data.py b/devtools/bench/utils/data.py index 13a49e87a..27adc0ea7 100644 --- a/devtools/bench/utils/data.py +++ b/devtools/bench/utils/data.py @@ -1,6 +1,5 @@ """Test data for benchmarks.""" - import json from .original import OriginalTPLinkSmartHomeProtocol diff --git a/devtools/bench/utils/original.py b/devtools/bench/utils/original.py index 67aeaa33f..d3543afd4 100644 --- a/devtools/bench/utils/original.py +++ b/devtools/bench/utils/original.py @@ -1,4 +1,5 @@ """Original implementation of the TP-Link Smart Home protocol.""" + import struct from typing import Generator diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 01921ccf1..87c703e3f 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -7,6 +7,7 @@ Executing this script will several modules and methods one by one, and finally execute a query to query all of them at once. """ + import base64 import collections.abc import json diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py index 85dc3992e..fb0ac3cdc 100755 --- a/devtools/generate_supported.py +++ b/devtools/generate_supported.py @@ -1,5 +1,6 @@ #!/usr/bin/env python """Script that checks supported devices and updates README.md and SUPPORTED.md.""" + import json import os import sys diff --git a/devtools/perftest.py b/devtools/perftest.py index 55c57f145..24c6b0e88 100644 --- a/devtools/perftest.py +++ b/devtools/perftest.py @@ -1,4 +1,5 @@ """Script for testing update performance on devices.""" + import asyncio import time diff --git a/kasa/__init__.py b/kasa/__init__.py index 6e937dc30..68dbb0c13 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -11,6 +11,7 @@ Module-specific errors are raised as `KasaException` and are expected to be handled by the user of the library. """ + from importlib.metadata import version from typing import TYPE_CHECKING from warnings import warn diff --git a/kasa/aestransport.py b/kasa/aestransport.py index e00b1084e..3b8bfe5d0 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -3,6 +3,7 @@ Based on the work of https://github.com/petretiandrea/plugp100 under compatible GNU GPL3 license. """ + import asyncio import base64 import hashlib diff --git a/kasa/bulb.py b/kasa/bulb.py index 5db6e5b75..5050e593e 100644 --- a/kasa/bulb.py +++ b/kasa/bulb.py @@ -1,4 +1,5 @@ """Module for Device base class.""" + from abc import ABC, abstractmethod from typing import Dict, List, NamedTuple, Optional diff --git a/kasa/cli.py b/kasa/cli.py index 20372be4e..d30c46300 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1,4 +1,5 @@ """python-kasa cli tool.""" + import ast import asyncio import json diff --git a/kasa/device.py b/kasa/device.py index fd0fe59c7..3c5537b1a 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -1,4 +1,5 @@ """Module for Device base class.""" + import logging from abc import ABC, abstractmethod from dataclasses import dataclass diff --git a/kasa/device_factory.py b/kasa/device_factory.py index d35df09c4..a40bc0850 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -1,4 +1,5 @@ """Device creation via DeviceConfig.""" + import logging import time from typing import Any, Dict, Optional, Tuple, Type diff --git a/kasa/device_type.py b/kasa/device_type.py index 80a816443..b6214c17a 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -1,6 +1,5 @@ """TP-Link device types.""" - from enum import Enum diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index c55265b4c..827fd03a8 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -1,4 +1,5 @@ """Module for holding connection parameters.""" + import logging from dataclasses import asdict, dataclass, field, fields, is_dataclass from enum import Enum diff --git a/kasa/discover.py b/kasa/discover.py index 06e3dc4d6..a5d88b99a 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -1,4 +1,5 @@ """Discovery module for TP-Link Smart Home devices.""" + import asyncio import binascii import ipaddress diff --git a/kasa/emeterstatus.py b/kasa/emeterstatus.py index 9d3b3b571..540424997 100644 --- a/kasa/emeterstatus.py +++ b/kasa/emeterstatus.py @@ -1,4 +1,5 @@ """Module for emeter container.""" + import logging from typing import Optional diff --git a/kasa/exceptions.py b/kasa/exceptions.py index d179bf3ae..9b91204a2 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -1,4 +1,5 @@ """python-kasa exceptions.""" + from asyncio import TimeoutError as _asyncioTimeoutError from enum import IntEnum from typing import Any, Optional diff --git a/kasa/feature.py b/kasa/feature.py index c42debc73..60b436700 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -1,4 +1,5 @@ """Generic interface for defining device features.""" + from dataclasses import dataclass from enum import Enum, auto from typing import TYPE_CHECKING, Any, Callable, Optional, Union diff --git a/kasa/httpclient.py b/kasa/httpclient.py index b0bbb593a..3240897cd 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -1,4 +1,5 @@ """Module for HttpClientSession class.""" + import asyncio import logging from typing import Any, Dict, Optional, Tuple, Union diff --git a/kasa/iot/__init__.py b/kasa/iot/__init__.py index e1e4b5760..536679ca3 100644 --- a/kasa/iot/__init__.py +++ b/kasa/iot/__init__.py @@ -1,4 +1,5 @@ """Package for supporting legacy kasa devices.""" + from .iotbulb import IotBulb from .iotdevice import IotDevice from .iotdimmer import IotDimmer diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 1ba943009..1bf198af0 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -1,4 +1,5 @@ """Module for bulbs (LB*, KL*, KB*).""" + import logging import re from enum import Enum @@ -233,7 +234,6 @@ async def _initialize_features(self): ) ) - @property # type: ignore @requires_update def is_color(self) -> bool: diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 0f34d8fb9..8c93f0166 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -11,6 +11,7 @@ You may obtain a copy of the license at http://www.apache.org/licenses/LICENSE-2.0 """ + import collections.abc import functools import inspect diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index cbcafd12f..fd0ff139f 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -1,4 +1,5 @@ """Module for dimmers (currently only HS220).""" + from enum import Enum from typing import Any, Dict, Optional diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 1e657a987..77b948f9a 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -1,4 +1,5 @@ """Module for light strips (KL430).""" + from typing import Dict, List, Optional from ..device_type import DeviceType diff --git a/kasa/iot/iotmodule.py b/kasa/iot/iotmodule.py index ab29b23cc..d8fb4812b 100644 --- a/kasa/iot/iotmodule.py +++ b/kasa/iot/iotmodule.py @@ -1,4 +1,5 @@ """Base class for IOT module implementations.""" + import collections import logging diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 2296b1e6d..0a67debf5 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -1,4 +1,5 @@ """Module for smart plugs (HS100, HS110, ..).""" + import logging from typing import Optional diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 2e5af0d08..1860c8fec 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -1,4 +1,5 @@ """Module for multi-socket devices (HS300, HS107, KP303, ..).""" + import logging from collections import defaultdict from datetime import datetime, timedelta diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index e4278b26c..41e03bbdd 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -1,4 +1,5 @@ """Module for individual feature modules.""" + from .ambientlight import AmbientLight from .antitheft import Antitheft from .cloud import Cloud diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py index e14f2991d..44885b82a 100644 --- a/kasa/iot/modules/ambientlight.py +++ b/kasa/iot/modules/ambientlight.py @@ -1,4 +1,5 @@ """Implementation of the ambient light (LAS) module found in some dimmers.""" + from ...feature import Feature, FeatureType from ..iotmodule import IotModule, merge diff --git a/kasa/iot/modules/antitheft.py b/kasa/iot/modules/antitheft.py index c885a70c2..07d94b9d4 100644 --- a/kasa/iot/modules/antitheft.py +++ b/kasa/iot/modules/antitheft.py @@ -1,4 +1,5 @@ """Implementation of the antitheft module.""" + from .rulemodule import RuleModule diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index b5c04d0b0..316617fd3 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -1,4 +1,5 @@ """Cloud module implementation.""" + try: from pydantic.v1 import BaseModel except ImportError: diff --git a/kasa/iot/modules/countdown.py b/kasa/iot/modules/countdown.py index 9f3e59c16..d1d5c23e5 100644 --- a/kasa/iot/modules/countdown.py +++ b/kasa/iot/modules/countdown.py @@ -1,4 +1,5 @@ """Implementation for the countdown timer.""" + from .rulemodule import RuleModule diff --git a/kasa/iot/modules/emeter.py b/kasa/iot/modules/emeter.py index 1570519eb..178b92e47 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -1,4 +1,5 @@ """Implementation of the emeter module.""" + from datetime import datetime from typing import Dict, List, Optional, Union diff --git a/kasa/iot/modules/motion.py b/kasa/iot/modules/motion.py index 06a729cab..59fe42997 100644 --- a/kasa/iot/modules/motion.py +++ b/kasa/iot/modules/motion.py @@ -1,4 +1,5 @@ """Implementation of the motion detection (PIR) module found in some dimmers.""" + from enum import Enum from typing import Optional diff --git a/kasa/iot/modules/rulemodule.py b/kasa/iot/modules/rulemodule.py index 81853793d..0739058d8 100644 --- a/kasa/iot/modules/rulemodule.py +++ b/kasa/iot/modules/rulemodule.py @@ -1,4 +1,5 @@ """Base implementation for all rule-based modules.""" + import logging from enum import Enum from typing import Dict, List, Optional diff --git a/kasa/iot/modules/schedule.py b/kasa/iot/modules/schedule.py index 62371692b..fe881951c 100644 --- a/kasa/iot/modules/schedule.py +++ b/kasa/iot/modules/schedule.py @@ -1,4 +1,5 @@ """Schedule module implementation.""" + from .rulemodule import RuleModule diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py index 15dd55c87..c280e5d10 100644 --- a/kasa/iot/modules/time.py +++ b/kasa/iot/modules/time.py @@ -1,4 +1,5 @@ """Provides the current time and timezone information.""" + from datetime import datetime from ...exceptions import KasaException diff --git a/kasa/iot/modules/usage.py b/kasa/iot/modules/usage.py index f64baf79d..faffb5d83 100644 --- a/kasa/iot/modules/usage.py +++ b/kasa/iot/modules/usage.py @@ -1,4 +1,5 @@ """Implementation of the usage interface.""" + from datetime import datetime from typing import Dict diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index 6a82a9c1b..a0a286125 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -1,4 +1,5 @@ """Module for the IOT legacy IOT KASA protocol.""" + import asyncio import logging from typing import Dict, Optional, Union diff --git a/kasa/module.py b/kasa/module.py index 854ab960e..3aa973fc3 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -1,4 +1,5 @@ """Base class for all module implementations.""" + import logging from abc import ABC, abstractmethod from typing import Dict diff --git a/kasa/plug.py b/kasa/plug.py index 1271515e5..00796d1c4 100644 --- a/kasa/plug.py +++ b/kasa/plug.py @@ -1,4 +1,5 @@ """Module for a TAPO Plug.""" + import logging from abc import ABC diff --git a/kasa/protocol.py b/kasa/protocol.py index aa9e3cbea..a62bf4def 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -9,6 +9,7 @@ which are licensed under the Apache License, Version 2.0 http://www.apache.org/licenses/LICENSE-2.0 """ + import base64 import errno import hashlib diff --git a/kasa/smart/__init__.py b/kasa/smart/__init__.py index 936fa7fde..721e4eca3 100644 --- a/kasa/smart/__init__.py +++ b/kasa/smart/__init__.py @@ -1,4 +1,5 @@ """Package for supporting tapo-branded and newer kasa devices.""" + from .smartbulb import SmartBulb from .smartchilddevice import SmartChildDevice from .smartdevice import SmartDevice diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 9d1af1c82..1a45bf1f4 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -1,4 +1,5 @@ """Modules for SMART devices.""" + from .alarmmodule import AlarmModule from .autooffmodule import AutoOffModule from .battery import BatterySensor diff --git a/kasa/smart/modules/alarmmodule.py b/kasa/smart/modules/alarmmodule.py index 637c44973..a05fde351 100644 --- a/kasa/smart/modules/alarmmodule.py +++ b/kasa/smart/modules/alarmmodule.py @@ -1,4 +1,5 @@ """Implementation of alarm module.""" + from typing import TYPE_CHECKING, Dict, List, Optional from ...feature import Feature, FeatureType diff --git a/kasa/smart/modules/autooffmodule.py b/kasa/smart/modules/autooffmodule.py index b1993deba..d72b6290a 100644 --- a/kasa/smart/modules/autooffmodule.py +++ b/kasa/smart/modules/autooffmodule.py @@ -1,4 +1,5 @@ """Implementation of auto off module.""" + from datetime import datetime, timedelta from typing import TYPE_CHECKING, Dict, Optional diff --git a/kasa/smart/modules/battery.py b/kasa/smart/modules/battery.py index accf875b2..13d35f6fd 100644 --- a/kasa/smart/modules/battery.py +++ b/kasa/smart/modules/battery.py @@ -1,4 +1,5 @@ """Implementation of battery module.""" + from typing import TYPE_CHECKING from ...feature import Feature, FeatureType diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index 03e9e238c..0d9f035bc 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -1,4 +1,5 @@ """Implementation of brightness module.""" + from typing import TYPE_CHECKING, Dict from ...feature import Feature, FeatureType diff --git a/kasa/smart/modules/cloudmodule.py b/kasa/smart/modules/cloudmodule.py index bf4964c32..4027a25b2 100644 --- a/kasa/smart/modules/cloudmodule.py +++ b/kasa/smart/modules/cloudmodule.py @@ -1,4 +1,5 @@ """Implementation of cloud module.""" + from typing import TYPE_CHECKING from ...feature import Feature, FeatureType diff --git a/kasa/smart/modules/colortemp.py b/kasa/smart/modules/colortemp.py index 97388b8d1..a1338a34d 100644 --- a/kasa/smart/modules/colortemp.py +++ b/kasa/smart/modules/colortemp.py @@ -1,4 +1,5 @@ """Implementation of color temp module.""" + from typing import TYPE_CHECKING, Dict from ...bulb import ColorTempRange diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py index e36c09fed..050a864b0 100644 --- a/kasa/smart/modules/devicemodule.py +++ b/kasa/smart/modules/devicemodule.py @@ -1,4 +1,5 @@ """Implementation of device module.""" + from typing import Dict from ..smartmodule import SmartModule diff --git a/kasa/smart/modules/energymodule.py b/kasa/smart/modules/energymodule.py index 0479de297..7645d1257 100644 --- a/kasa/smart/modules/energymodule.py +++ b/kasa/smart/modules/energymodule.py @@ -1,4 +1,5 @@ """Implementation of energy monitoring module.""" + from typing import TYPE_CHECKING, Dict, Optional from ...emeterstatus import EmeterStatus diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 29cc9185a..abe5dc399 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -1,4 +1,5 @@ """Implementation of firmware module.""" + from typing import TYPE_CHECKING, Dict, Optional from ...exceptions import SmartErrorCode diff --git a/kasa/smart/modules/humidity.py b/kasa/smart/modules/humidity.py index 454bedcda..668bde2d9 100644 --- a/kasa/smart/modules/humidity.py +++ b/kasa/smart/modules/humidity.py @@ -1,4 +1,5 @@ """Implementation of humidity module.""" + from typing import TYPE_CHECKING from ...feature import Feature, FeatureType diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py index 72e3e33a2..34f87710a 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/ledmodule.py @@ -1,4 +1,5 @@ """Module for led controls.""" + from typing import TYPE_CHECKING, Dict from ...feature import Feature, FeatureType diff --git a/kasa/smart/modules/lighttransitionmodule.py b/kasa/smart/modules/lighttransitionmodule.py index f98f21ca8..bf824823b 100644 --- a/kasa/smart/modules/lighttransitionmodule.py +++ b/kasa/smart/modules/lighttransitionmodule.py @@ -1,4 +1,5 @@ """Module for smooth light transitions.""" + from typing import TYPE_CHECKING from ...exceptions import KasaException diff --git a/kasa/smart/modules/reportmodule.py b/kasa/smart/modules/reportmodule.py index 04301bb4c..5bae299c9 100644 --- a/kasa/smart/modules/reportmodule.py +++ b/kasa/smart/modules/reportmodule.py @@ -1,4 +1,5 @@ """Implementation of report module.""" + from typing import TYPE_CHECKING from ...feature import Feature diff --git a/kasa/smart/modules/temperature.py b/kasa/smart/modules/temperature.py index dbfe7c63c..0817e9412 100644 --- a/kasa/smart/modules/temperature.py +++ b/kasa/smart/modules/temperature.py @@ -1,4 +1,5 @@ """Implementation of temperature module.""" + from typing import TYPE_CHECKING, Literal from ...feature import Feature, FeatureType diff --git a/kasa/smart/modules/timemodule.py b/kasa/smart/modules/timemodule.py index 778da5110..fd48f43ba 100644 --- a/kasa/smart/modules/timemodule.py +++ b/kasa/smart/modules/timemodule.py @@ -1,4 +1,5 @@ """Implementation of time module.""" + from datetime import datetime, timedelta, timezone from time import mktime from typing import TYPE_CHECKING, cast diff --git a/kasa/smart/smartbulb.py b/kasa/smart/smartbulb.py index b92edecd2..d7e9372f2 100644 --- a/kasa/smart/smartbulb.py +++ b/kasa/smart/smartbulb.py @@ -1,4 +1,5 @@ """Module for tapo-branded smart bulbs (L5**).""" + from typing import Dict, List, Optional from ..bulb import Bulb diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 1ea517aa6..e8d8c208e 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -1,4 +1,5 @@ """Child device implementation.""" + import logging from typing import Optional diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index f6e7f7347..909341a1c 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -1,4 +1,5 @@ """Module for a SMART device.""" + import base64 import logging from datetime import datetime, timedelta diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 01a27360f..4756a4249 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -1,4 +1,5 @@ """Base implementation for SMART modules.""" + import logging from typing import TYPE_CHECKING, Dict, Type diff --git a/kasa/tests/fakeprotocol_iot.py b/kasa/tests/fakeprotocol_iot.py index 6b22db0bd..c15c63797 100644 --- a/kasa/tests/fakeprotocol_iot.py +++ b/kasa/tests/fakeprotocol_iot.py @@ -302,9 +302,9 @@ def transition_light_state(self, state_changes, *args): def set_preferred_state(self, new_state, *args): """Implement set_preferred_state.""" - self.proto["system"]["get_sysinfo"]["preferred_state"][ - new_state["index"] - ] = new_state + self.proto["system"]["get_sysinfo"]["preferred_state"][new_state["index"]] = ( + new_state + ) def light_state(self, x, *args): light_state = self.proto["system"]["get_sysinfo"]["light_state"] diff --git a/kasa/tests/fixtureinfo.py b/kasa/tests/fixtureinfo.py index bee3e7498..c0b4b506f 100644 --- a/kasa/tests/fixtureinfo.py +++ b/kasa/tests/fixtureinfo.py @@ -15,7 +15,7 @@ class FixtureInfo(NamedTuple): data: Dict -FixtureInfo.__hash__ = lambda x: hash((x.name, x.protocol)) # type: ignore[attr-defined, method-assign] +FixtureInfo.__hash__ = lambda self: hash((self.name, self.protocol)) # type: ignore[attr-defined, method-assign] FixtureInfo.__eq__ = lambda x, y: hash(x) == hash(y) # type: ignore[method-assign] diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index 7ceab8e97..d0ed0c71e 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -1,4 +1,5 @@ """Tests for all devices.""" + import importlib import inspect import pkgutil diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py index b7846e413..4c5d5126a 100644 --- a/kasa/tests/test_iotdevice.py +++ b/kasa/tests/test_iotdevice.py @@ -1,4 +1,5 @@ """Module for common iotdevice tests.""" + import re from datetime import datetime diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 306a4b3da..77ed99787 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -1,4 +1,5 @@ """Tests for SMART devices.""" + import logging from typing import Any, Dict diff --git a/kasa/xortransport.py b/kasa/xortransport.py index e7b94f8e3..085a6d647 100644 --- a/kasa/xortransport.py +++ b/kasa/xortransport.py @@ -9,6 +9,7 @@ which are licensed under the Apache License, Version 2.0 http://www.apache.org/licenses/LICENSE-2.0 """ + import asyncio import contextlib import errno diff --git a/poetry.lock b/poetry.lock index eafa0b29c..f307a4689 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,87 +2,87 @@ [[package]] name = "aiohttp" -version = "3.9.1" +version = "3.9.4" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" files = [ - {file = "aiohttp-3.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1f80197f8b0b846a8d5cf7b7ec6084493950d0882cc5537fb7b96a69e3c8590"}, - {file = "aiohttp-3.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c72444d17777865734aa1a4d167794c34b63e5883abb90356a0364a28904e6c0"}, - {file = "aiohttp-3.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b05d5cbe9dafcdc733262c3a99ccf63d2f7ce02543620d2bd8db4d4f7a22f83"}, - {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c4fa235d534b3547184831c624c0b7c1e262cd1de847d95085ec94c16fddcd5"}, - {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:289ba9ae8e88d0ba16062ecf02dd730b34186ea3b1e7489046fc338bdc3361c4"}, - {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bff7e2811814fa2271be95ab6e84c9436d027a0e59665de60edf44e529a42c1f"}, - {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81b77f868814346662c96ab36b875d7814ebf82340d3284a31681085c051320f"}, - {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b9c7426923bb7bd66d409da46c41e3fb40f5caf679da624439b9eba92043fa6"}, - {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8d44e7bf06b0c0a70a20f9100af9fcfd7f6d9d3913e37754c12d424179b4e48f"}, - {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22698f01ff5653fe66d16ffb7658f582a0ac084d7da1323e39fd9eab326a1f26"}, - {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ca7ca5abfbfe8d39e653870fbe8d7710be7a857f8a8386fc9de1aae2e02ce7e4"}, - {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8d7f98fde213f74561be1d6d3fa353656197f75d4edfbb3d94c9eb9b0fc47f5d"}, - {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5216b6082c624b55cfe79af5d538e499cd5f5b976820eac31951fb4325974501"}, - {file = "aiohttp-3.9.1-cp310-cp310-win32.whl", hash = "sha256:0e7ba7ff228c0d9a2cd66194e90f2bca6e0abca810b786901a569c0de082f489"}, - {file = "aiohttp-3.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:c7e939f1ae428a86e4abbb9a7c4732bf4706048818dfd979e5e2839ce0159f23"}, - {file = "aiohttp-3.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:df9cf74b9bc03d586fc53ba470828d7b77ce51b0582d1d0b5b2fb673c0baa32d"}, - {file = "aiohttp-3.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ecca113f19d5e74048c001934045a2b9368d77b0b17691d905af18bd1c21275e"}, - {file = "aiohttp-3.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8cef8710fb849d97c533f259103f09bac167a008d7131d7b2b0e3a33269185c0"}, - {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bea94403a21eb94c93386d559bce297381609153e418a3ffc7d6bf772f59cc35"}, - {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91c742ca59045dce7ba76cab6e223e41d2c70d79e82c284a96411f8645e2afff"}, - {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c93b7c2e52061f0925c3382d5cb8980e40f91c989563d3d32ca280069fd6a87"}, - {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee2527134f95e106cc1653e9ac78846f3a2ec1004cf20ef4e02038035a74544d"}, - {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11ff168d752cb41e8492817e10fb4f85828f6a0142b9726a30c27c35a1835f01"}, - {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b8c3a67eb87394386847d188996920f33b01b32155f0a94f36ca0e0c635bf3e3"}, - {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c7b5d5d64e2a14e35a9240b33b89389e0035e6de8dbb7ffa50d10d8b65c57449"}, - {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:69985d50a2b6f709412d944ffb2e97d0be154ea90600b7a921f95a87d6f108a2"}, - {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:c9110c06eaaac7e1f5562caf481f18ccf8f6fdf4c3323feab28a93d34cc646bd"}, - {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d737e69d193dac7296365a6dcb73bbbf53bb760ab25a3727716bbd42022e8d7a"}, - {file = "aiohttp-3.9.1-cp311-cp311-win32.whl", hash = "sha256:4ee8caa925aebc1e64e98432d78ea8de67b2272252b0a931d2ac3bd876ad5544"}, - {file = "aiohttp-3.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:a34086c5cc285be878622e0a6ab897a986a6e8bf5b67ecb377015f06ed316587"}, - {file = "aiohttp-3.9.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f800164276eec54e0af5c99feb9494c295118fc10a11b997bbb1348ba1a52065"}, - {file = "aiohttp-3.9.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:500f1c59906cd142d452074f3811614be04819a38ae2b3239a48b82649c08821"}, - {file = "aiohttp-3.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0b0a6a36ed7e164c6df1e18ee47afbd1990ce47cb428739d6c99aaabfaf1b3af"}, - {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69da0f3ed3496808e8cbc5123a866c41c12c15baaaead96d256477edf168eb57"}, - {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:176df045597e674fa950bf5ae536be85699e04cea68fa3a616cf75e413737eb5"}, - {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b796b44111f0cab6bbf66214186e44734b5baab949cb5fb56154142a92989aeb"}, - {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f27fdaadce22f2ef950fc10dcdf8048407c3b42b73779e48a4e76b3c35bca26c"}, - {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcb6532b9814ea7c5a6a3299747c49de30e84472fa72821b07f5a9818bce0f66"}, - {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:54631fb69a6e44b2ba522f7c22a6fb2667a02fd97d636048478db2fd8c4e98fe"}, - {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4b4c452d0190c5a820d3f5c0f3cd8a28ace48c54053e24da9d6041bf81113183"}, - {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:cae4c0c2ca800c793cae07ef3d40794625471040a87e1ba392039639ad61ab5b"}, - {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:565760d6812b8d78d416c3c7cfdf5362fbe0d0d25b82fed75d0d29e18d7fc30f"}, - {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:54311eb54f3a0c45efb9ed0d0a8f43d1bc6060d773f6973efd90037a51cd0a3f"}, - {file = "aiohttp-3.9.1-cp312-cp312-win32.whl", hash = "sha256:85c3e3c9cb1d480e0b9a64c658cd66b3cfb8e721636ab8b0e746e2d79a7a9eed"}, - {file = "aiohttp-3.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:11cb254e397a82efb1805d12561e80124928e04e9c4483587ce7390b3866d213"}, - {file = "aiohttp-3.9.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8a22a34bc594d9d24621091d1b91511001a7eea91d6652ea495ce06e27381f70"}, - {file = "aiohttp-3.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:598db66eaf2e04aa0c8900a63b0101fdc5e6b8a7ddd805c56d86efb54eb66672"}, - {file = "aiohttp-3.9.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c9376e2b09895c8ca8b95362283365eb5c03bdc8428ade80a864160605715f1"}, - {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41473de252e1797c2d2293804e389a6d6986ef37cbb4a25208de537ae32141dd"}, - {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c5857612c9813796960c00767645cb5da815af16dafb32d70c72a8390bbf690"}, - {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffcd828e37dc219a72c9012ec44ad2e7e3066bec6ff3aaa19e7d435dbf4032ca"}, - {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:219a16763dc0294842188ac8a12262b5671817042b35d45e44fd0a697d8c8361"}, - {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f694dc8a6a3112059258a725a4ebe9acac5fe62f11c77ac4dcf896edfa78ca28"}, - {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bcc0ea8d5b74a41b621ad4a13d96c36079c81628ccc0b30cfb1603e3dfa3a014"}, - {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:90ec72d231169b4b8d6085be13023ece8fa9b1bb495e4398d847e25218e0f431"}, - {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:cf2a0ac0615842b849f40c4d7f304986a242f1e68286dbf3bd7a835e4f83acfd"}, - {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:0e49b08eafa4f5707ecfb321ab9592717a319e37938e301d462f79b4e860c32a"}, - {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2c59e0076ea31c08553e868cec02d22191c086f00b44610f8ab7363a11a5d9d8"}, - {file = "aiohttp-3.9.1-cp38-cp38-win32.whl", hash = "sha256:4831df72b053b1eed31eb00a2e1aff6896fb4485301d4ccb208cac264b648db4"}, - {file = "aiohttp-3.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:3135713c5562731ee18f58d3ad1bf41e1d8883eb68b363f2ffde5b2ea4b84cc7"}, - {file = "aiohttp-3.9.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cfeadf42840c1e870dc2042a232a8748e75a36b52d78968cda6736de55582766"}, - {file = "aiohttp-3.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:70907533db712f7aa791effb38efa96f044ce3d4e850e2d7691abd759f4f0ae0"}, - {file = "aiohttp-3.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdefe289681507187e375a5064c7599f52c40343a8701761c802c1853a504558"}, - {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7481f581251bb5558ba9f635db70908819caa221fc79ee52a7f58392778c636"}, - {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49f0c1b3c2842556e5de35f122fc0f0b721334ceb6e78c3719693364d4af8499"}, - {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d406b01a9f5a7e232d1b0d161b40c05275ffbcbd772dc18c1d5a570961a1ca4"}, - {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d8e4450e7fe24d86e86b23cc209e0023177b6d59502e33807b732d2deb6975f"}, - {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c0266cd6f005e99f3f51e583012de2778e65af6b73860038b968a0a8888487a"}, - {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab221850108a4a063c5b8a70f00dd7a1975e5a1713f87f4ab26a46e5feac5a0e"}, - {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c88a15f272a0ad3d7773cf3a37cc7b7d077cbfc8e331675cf1346e849d97a4e5"}, - {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:237533179d9747080bcaad4d02083ce295c0d2eab3e9e8ce103411a4312991a0"}, - {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:02ab6006ec3c3463b528374c4cdce86434e7b89ad355e7bf29e2f16b46c7dd6f"}, - {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04fa38875e53eb7e354ece1607b1d2fdee2d175ea4e4d745f6ec9f751fe20c7c"}, - {file = "aiohttp-3.9.1-cp39-cp39-win32.whl", hash = "sha256:82eefaf1a996060602f3cc1112d93ba8b201dbf5d8fd9611227de2003dddb3b7"}, - {file = "aiohttp-3.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:9b05d33ff8e6b269e30a7957bd3244ffbce2a7a35a81b81c382629b80af1a8bf"}, - {file = "aiohttp-3.9.1.tar.gz", hash = "sha256:8fc49a87ac269d4529da45871e2ffb6874e87779c3d0e2ccd813c0899221239d"}, + {file = "aiohttp-3.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:76d32588ef7e4a3f3adff1956a0ba96faabbdee58f2407c122dd45aa6e34f372"}, + {file = "aiohttp-3.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:56181093c10dbc6ceb8a29dfeea1e815e1dfdc020169203d87fd8d37616f73f9"}, + {file = "aiohttp-3.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7a5b676d3c65e88b3aca41816bf72831898fcd73f0cbb2680e9d88e819d1e4d"}, + {file = "aiohttp-3.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1df528a85fb404899d4207a8d9934cfd6be626e30e5d3a5544a83dbae6d8a7e"}, + {file = "aiohttp-3.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f595db1bceabd71c82e92df212dd9525a8a2c6947d39e3c994c4f27d2fe15b11"}, + {file = "aiohttp-3.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c0b09d76e5a4caac3d27752027fbd43dc987b95f3748fad2b924a03fe8632ad"}, + {file = "aiohttp-3.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:689eb4356649ec9535b3686200b231876fb4cab4aca54e3bece71d37f50c1d13"}, + {file = "aiohttp-3.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3666cf4182efdb44d73602379a66f5fdfd5da0db5e4520f0ac0dcca644a3497"}, + {file = "aiohttp-3.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b65b0f8747b013570eea2f75726046fa54fa8e0c5db60f3b98dd5d161052004a"}, + {file = "aiohttp-3.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1885d2470955f70dfdd33a02e1749613c5a9c5ab855f6db38e0b9389453dce7"}, + {file = "aiohttp-3.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0593822dcdb9483d41f12041ff7c90d4d1033ec0e880bcfaf102919b715f47f1"}, + {file = "aiohttp-3.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:47f6eb74e1ecb5e19a78f4a4228aa24df7fbab3b62d4a625d3f41194a08bd54f"}, + {file = "aiohttp-3.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c8b04a3dbd54de6ccb7604242fe3ad67f2f3ca558f2d33fe19d4b08d90701a89"}, + {file = "aiohttp-3.9.4-cp310-cp310-win32.whl", hash = "sha256:8a78dfb198a328bfb38e4308ca8167028920fb747ddcf086ce706fbdd23b2926"}, + {file = "aiohttp-3.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:e78da6b55275987cbc89141a1d8e75f5070e577c482dd48bd9123a76a96f0bbb"}, + {file = "aiohttp-3.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c111b3c69060d2bafc446917534150fd049e7aedd6cbf21ba526a5a97b4402a5"}, + {file = "aiohttp-3.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:efbdd51872cf170093998c87ccdf3cb5993add3559341a8e5708bcb311934c94"}, + {file = "aiohttp-3.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7bfdb41dc6e85d8535b00d73947548a748e9534e8e4fddd2638109ff3fb081df"}, + {file = "aiohttp-3.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd9d334412961125e9f68d5b73c1d0ab9ea3f74a58a475e6b119f5293eee7ba"}, + {file = "aiohttp-3.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35d78076736f4a668d57ade00c65d30a8ce28719d8a42471b2a06ccd1a2e3063"}, + {file = "aiohttp-3.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:824dff4f9f4d0f59d0fa3577932ee9a20e09edec8a2f813e1d6b9f89ced8293f"}, + {file = "aiohttp-3.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52b8b4e06fc15519019e128abedaeb56412b106ab88b3c452188ca47a25c4093"}, + {file = "aiohttp-3.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eae569fb1e7559d4f3919965617bb39f9e753967fae55ce13454bec2d1c54f09"}, + {file = "aiohttp-3.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:69b97aa5792428f321f72aeb2f118e56893371f27e0b7d05750bcad06fc42ca1"}, + {file = "aiohttp-3.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4d79aad0ad4b980663316f26d9a492e8fab2af77c69c0f33780a56843ad2f89e"}, + {file = "aiohttp-3.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:d6577140cd7db19e430661e4b2653680194ea8c22c994bc65b7a19d8ec834403"}, + {file = "aiohttp-3.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:9860d455847cd98eb67897f5957b7cd69fbcb436dd3f06099230f16a66e66f79"}, + {file = "aiohttp-3.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69ff36d3f8f5652994e08bd22f093e11cfd0444cea310f92e01b45a4e46b624e"}, + {file = "aiohttp-3.9.4-cp311-cp311-win32.whl", hash = "sha256:e27d3b5ed2c2013bce66ad67ee57cbf614288bda8cdf426c8d8fe548316f1b5f"}, + {file = "aiohttp-3.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d6a67e26daa686a6fbdb600a9af8619c80a332556245fa8e86c747d226ab1a1e"}, + {file = "aiohttp-3.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c5ff8ff44825736a4065d8544b43b43ee4c6dd1530f3a08e6c0578a813b0aa35"}, + {file = "aiohttp-3.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d12a244627eba4e9dc52cbf924edef905ddd6cafc6513849b4876076a6f38b0e"}, + {file = "aiohttp-3.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dcad56c8d8348e7e468899d2fb3b309b9bc59d94e6db08710555f7436156097f"}, + {file = "aiohttp-3.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f7e69a7fd4b5ce419238388e55abd220336bd32212c673ceabc57ccf3d05b55"}, + {file = "aiohttp-3.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4870cb049f10d7680c239b55428916d84158798eb8f353e74fa2c98980dcc0b"}, + {file = "aiohttp-3.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2feaf1b7031ede1bc0880cec4b0776fd347259a723d625357bb4b82f62687b"}, + {file = "aiohttp-3.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:939393e8c3f0a5bcd33ef7ace67680c318dc2ae406f15e381c0054dd658397de"}, + {file = "aiohttp-3.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d2334e387b2adcc944680bebcf412743f2caf4eeebd550f67249c1c3696be04"}, + {file = "aiohttp-3.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e0198ea897680e480845ec0ffc5a14e8b694e25b3f104f63676d55bf76a82f1a"}, + {file = "aiohttp-3.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e40d2cd22914d67c84824045861a5bb0fb46586b15dfe4f046c7495bf08306b2"}, + {file = "aiohttp-3.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:aba80e77c227f4234aa34a5ff2b6ff30c5d6a827a91d22ff6b999de9175d71bd"}, + {file = "aiohttp-3.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:fb68dc73bc8ac322d2e392a59a9e396c4f35cb6fdbdd749e139d1d6c985f2527"}, + {file = "aiohttp-3.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f3460a92638dce7e47062cf088d6e7663adb135e936cb117be88d5e6c48c9d53"}, + {file = "aiohttp-3.9.4-cp312-cp312-win32.whl", hash = "sha256:32dc814ddbb254f6170bca198fe307920f6c1308a5492f049f7f63554b88ef36"}, + {file = "aiohttp-3.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:63f41a909d182d2b78fe3abef557fcc14da50c7852f70ae3be60e83ff64edba5"}, + {file = "aiohttp-3.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c3770365675f6be220032f6609a8fbad994d6dcf3ef7dbcf295c7ee70884c9af"}, + {file = "aiohttp-3.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:305edae1dea368ce09bcb858cf5a63a064f3bff4767dec6fa60a0cc0e805a1d3"}, + {file = "aiohttp-3.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6f121900131d116e4a93b55ab0d12ad72573f967b100e49086e496a9b24523ea"}, + {file = "aiohttp-3.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b71e614c1ae35c3d62a293b19eface83d5e4d194e3eb2fabb10059d33e6e8cbf"}, + {file = "aiohttp-3.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:419f009fa4cfde4d16a7fc070d64f36d70a8d35a90d71aa27670bba2be4fd039"}, + {file = "aiohttp-3.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b39476ee69cfe64061fd77a73bf692c40021f8547cda617a3466530ef63f947"}, + {file = "aiohttp-3.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b33f34c9c7decdb2ab99c74be6443942b730b56d9c5ee48fb7df2c86492f293c"}, + {file = "aiohttp-3.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c78700130ce2dcebb1a8103202ae795be2fa8c9351d0dd22338fe3dac74847d9"}, + {file = "aiohttp-3.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:268ba22d917655d1259af2d5659072b7dc11b4e1dc2cb9662fdd867d75afc6a4"}, + {file = "aiohttp-3.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:17e7c051f53a0d2ebf33013a9cbf020bb4e098c4bc5bce6f7b0c962108d97eab"}, + {file = "aiohttp-3.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:7be99f4abb008cb38e144f85f515598f4c2c8932bf11b65add0ff59c9c876d99"}, + {file = "aiohttp-3.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:d58a54d6ff08d2547656356eea8572b224e6f9bbc0cf55fa9966bcaac4ddfb10"}, + {file = "aiohttp-3.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7673a76772bda15d0d10d1aa881b7911d0580c980dbd16e59d7ba1422b2d83cd"}, + {file = "aiohttp-3.9.4-cp38-cp38-win32.whl", hash = "sha256:e4370dda04dc8951012f30e1ce7956a0a226ac0714a7b6c389fb2f43f22a250e"}, + {file = "aiohttp-3.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:eb30c4510a691bb87081192a394fb661860e75ca3896c01c6d186febe7c88530"}, + {file = "aiohttp-3.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:84e90494db7df3be5e056f91412f9fa9e611fbe8ce4aaef70647297f5943b276"}, + {file = "aiohttp-3.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d4845f8501ab28ebfdbeab980a50a273b415cf69e96e4e674d43d86a464df9d"}, + {file = "aiohttp-3.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:69046cd9a2a17245c4ce3c1f1a4ff8c70c7701ef222fce3d1d8435f09042bba1"}, + {file = "aiohttp-3.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b73a06bafc8dcc508420db43b4dd5850e41e69de99009d0351c4f3007960019"}, + {file = "aiohttp-3.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:418bb0038dfafeac923823c2e63226179976c76f981a2aaad0ad5d51f2229bca"}, + {file = "aiohttp-3.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71a8f241456b6c2668374d5d28398f8e8cdae4cce568aaea54e0f39359cd928d"}, + {file = "aiohttp-3.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:935c369bf8acc2dc26f6eeb5222768aa7c62917c3554f7215f2ead7386b33748"}, + {file = "aiohttp-3.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74e4e48c8752d14ecfb36d2ebb3d76d614320570e14de0a3aa7a726ff150a03c"}, + {file = "aiohttp-3.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:916b0417aeddf2c8c61291238ce25286f391a6acb6f28005dd9ce282bd6311b6"}, + {file = "aiohttp-3.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9b6787b6d0b3518b2ee4cbeadd24a507756ee703adbac1ab6dc7c4434b8c572a"}, + {file = "aiohttp-3.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:221204dbda5ef350e8db6287937621cf75e85778b296c9c52260b522231940ed"}, + {file = "aiohttp-3.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:10afd99b8251022ddf81eaed1d90f5a988e349ee7d779eb429fb07b670751e8c"}, + {file = "aiohttp-3.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2506d9f7a9b91033201be9ffe7d89c6a54150b0578803cce5cb84a943d075bc3"}, + {file = "aiohttp-3.9.4-cp39-cp39-win32.whl", hash = "sha256:e571fdd9efd65e86c6af2f332e0e95dad259bfe6beb5d15b3c3eca3a6eb5d87b"}, + {file = "aiohttp-3.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:7d29dd5319d20aa3b7749719ac9685fbd926f71ac8c77b2477272725f882072d"}, + {file = "aiohttp-3.9.4.tar.gz", hash = "sha256:6ff71ede6d9a5a58cfb7b6fffc83ab5d4a63138276c771ac91ceaaddf5459644"}, ] [package.dependencies] @@ -123,13 +123,13 @@ files = [ [[package]] name = "annotated-types" -version = "0.5.0" +version = "0.6.0" description = "Reusable constraint types to use with typing.Annotated" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "annotated_types-0.5.0-py3-none-any.whl", hash = "sha256:58da39888f92c276ad970249761ebea80ba544b77acddaa1a4d6cf78287d45fd"}, - {file = "annotated_types-0.5.0.tar.gz", hash = "sha256:47cdc3490d9ac1506ce92c7aaa76c579dc3509ff11e098fc867e5130ab7be802"}, + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, ] [package.dependencies] @@ -137,24 +137,25 @@ typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} [[package]] name = "anyio" -version = "3.7.1" +version = "4.3.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, - {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, + {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, + {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, ] [package.dependencies] -exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] -doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] -test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] -trio = ["trio (<0.22)"] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] [[package]] name = "appdirs" @@ -180,16 +181,17 @@ files = [ [[package]] name = "asyncclick" -version = "8.1.3.4" +version = "8.1.7.2" description = "Composable command line interface toolkit, async version" optional = false python-versions = ">=3.7" files = [ - {file = "asyncclick-8.1.3.4-py3-none-any.whl", hash = "sha256:f8db604e37dabd43922d58f857817b1dfd8f88695b75c4cc1afe7ff1cc238a7b"}, - {file = "asyncclick-8.1.3.4.tar.gz", hash = "sha256:81d98cbf6c8813f9cd5599f586d56cfc532e9e6441391974d10827abb90fe833"}, + {file = "asyncclick-8.1.7.2-py3-none-any.whl", hash = "sha256:1ab940b04b22cb89b5b400725132b069d01b0c3472a9702c7a2c9d5d007ded02"}, + {file = "asyncclick-8.1.7.2.tar.gz", hash = "sha256:219ea0f29ccdc1bb4ff43bcab7ce0769ac6d48a04f997b43ec6bee99a222daa0"}, ] [package.dependencies] +anyio = "*" colorama = {version = "*", markers = "platform_system == \"Windows\""} [[package]] @@ -213,111 +215,102 @@ tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "p [[package]] name = "babel" -version = "2.12.1" +version = "2.14.0" description = "Internationalization utilities" optional = true python-versions = ">=3.7" files = [ - {file = "Babel-2.12.1-py3-none-any.whl", hash = "sha256:b4246fb7677d3b98f501a39d43396d3cafdc8eadb045f4a31be01863f655c610"}, - {file = "Babel-2.12.1.tar.gz", hash = "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455"}, + {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, + {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, ] [package.dependencies] pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} +[package.extras] +dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] + [[package]] name = "cachetools" -version = "5.3.1" +version = "5.3.3" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" files = [ - {file = "cachetools-5.3.1-py3-none-any.whl", hash = "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590"}, - {file = "cachetools-5.3.1.tar.gz", hash = "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b"}, + {file = "cachetools-5.3.3-py3-none-any.whl", hash = "sha256:0abad1021d3f8325b2fc1d2e9c8b9c9d57b04c3932657a72465447332c24d945"}, + {file = "cachetools-5.3.3.tar.gz", hash = "sha256:ba29e2dfa0b8b556606f097407ed1aa62080ee108ab0dc5ec9d6a723a007d105"}, ] [[package]] name = "certifi" -version = "2023.7.22" +version = "2024.2.2" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, - {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, ] [[package]] name = "cffi" -version = "1.15.1" +version = "1.16.0" description = "Foreign Function Interface for Python calling C code." optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, - {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, - {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, - {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, - {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, - {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, - {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, - {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, - {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, - {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, - {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, - {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, - {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, - {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, - {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, - {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, - {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, - {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, - {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, - {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, - {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, - {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, - {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, - {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, - {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, + {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, + {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, + {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, + {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, + {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, + {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, + {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, + {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, + {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, + {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, + {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, + {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, + {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, + {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, + {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, ] [package.dependencies] @@ -347,86 +340,101 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.2.0" +version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" files = [ - {file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-win32.whl", hash = "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-win32.whl", hash = "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-win32.whl", hash = "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-win32.whl", hash = "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-win32.whl", hash = "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80"}, - {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, ] [[package]] @@ -457,63 +465,63 @@ files = [ [[package]] name = "coverage" -version = "7.3.0" +version = "7.4.4" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:db76a1bcb51f02b2007adacbed4c88b6dee75342c37b05d1822815eed19edee5"}, - {file = "coverage-7.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c02cfa6c36144ab334d556989406837336c1d05215a9bdf44c0bc1d1ac1cb637"}, - {file = "coverage-7.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:477c9430ad5d1b80b07f3c12f7120eef40bfbf849e9e7859e53b9c93b922d2af"}, - {file = "coverage-7.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce2ee86ca75f9f96072295c5ebb4ef2a43cecf2870b0ca5e7a1cbdd929cf67e1"}, - {file = "coverage-7.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68d8a0426b49c053013e631c0cdc09b952d857efa8f68121746b339912d27a12"}, - {file = "coverage-7.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b3eb0c93e2ea6445b2173da48cb548364f8f65bf68f3d090404080d338e3a689"}, - {file = "coverage-7.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:90b6e2f0f66750c5a1178ffa9370dec6c508a8ca5265c42fbad3ccac210a7977"}, - {file = "coverage-7.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:96d7d761aea65b291a98c84e1250cd57b5b51726821a6f2f8df65db89363be51"}, - {file = "coverage-7.3.0-cp310-cp310-win32.whl", hash = "sha256:63c5b8ecbc3b3d5eb3a9d873dec60afc0cd5ff9d9f1c75981d8c31cfe4df8527"}, - {file = "coverage-7.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:97c44f4ee13bce914272589b6b41165bbb650e48fdb7bd5493a38bde8de730a1"}, - {file = "coverage-7.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:74c160285f2dfe0acf0f72d425f3e970b21b6de04157fc65adc9fd07ee44177f"}, - {file = "coverage-7.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b543302a3707245d454fc49b8ecd2c2d5982b50eb63f3535244fd79a4be0c99d"}, - {file = "coverage-7.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad0f87826c4ebd3ef484502e79b39614e9c03a5d1510cfb623f4a4a051edc6fd"}, - {file = "coverage-7.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13c6cbbd5f31211d8fdb477f0f7b03438591bdd077054076eec362cf2207b4a7"}, - {file = "coverage-7.3.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fac440c43e9b479d1241fe9d768645e7ccec3fb65dc3a5f6e90675e75c3f3e3a"}, - {file = "coverage-7.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3c9834d5e3df9d2aba0275c9f67989c590e05732439b3318fa37a725dff51e74"}, - {file = "coverage-7.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4c8e31cf29b60859876474034a83f59a14381af50cbe8a9dbaadbf70adc4b214"}, - {file = "coverage-7.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7a9baf8e230f9621f8e1d00c580394a0aa328fdac0df2b3f8384387c44083c0f"}, - {file = "coverage-7.3.0-cp311-cp311-win32.whl", hash = "sha256:ccc51713b5581e12f93ccb9c5e39e8b5d4b16776d584c0f5e9e4e63381356482"}, - {file = "coverage-7.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:887665f00ea4e488501ba755a0e3c2cfd6278e846ada3185f42d391ef95e7e70"}, - {file = "coverage-7.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d000a739f9feed900381605a12a61f7aaced6beae832719ae0d15058a1e81c1b"}, - {file = "coverage-7.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:59777652e245bb1e300e620ce2bef0d341945842e4eb888c23a7f1d9e143c446"}, - {file = "coverage-7.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9737bc49a9255d78da085fa04f628a310c2332b187cd49b958b0e494c125071"}, - {file = "coverage-7.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5247bab12f84a1d608213b96b8af0cbb30d090d705b6663ad794c2f2a5e5b9fe"}, - {file = "coverage-7.3.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2ac9a1de294773b9fa77447ab7e529cf4fe3910f6a0832816e5f3d538cfea9a"}, - {file = "coverage-7.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:85b7335c22455ec12444cec0d600533a238d6439d8d709d545158c1208483873"}, - {file = "coverage-7.3.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:36ce5d43a072a036f287029a55b5c6a0e9bd73db58961a273b6dc11a2c6eb9c2"}, - {file = "coverage-7.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:211a4576e984f96d9fce61766ffaed0115d5dab1419e4f63d6992b480c2bd60b"}, - {file = "coverage-7.3.0-cp312-cp312-win32.whl", hash = "sha256:56afbf41fa4a7b27f6635bc4289050ac3ab7951b8a821bca46f5b024500e6321"}, - {file = "coverage-7.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f297e0c1ae55300ff688568b04ff26b01c13dfbf4c9d2b7d0cb688ac60df479"}, - {file = "coverage-7.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac0dec90e7de0087d3d95fa0533e1d2d722dcc008bc7b60e1143402a04c117c1"}, - {file = "coverage-7.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:438856d3f8f1e27f8e79b5410ae56650732a0dcfa94e756df88c7e2d24851fcd"}, - {file = "coverage-7.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1084393c6bda8875c05e04fce5cfe1301a425f758eb012f010eab586f1f3905e"}, - {file = "coverage-7.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49ab200acf891e3dde19e5aa4b0f35d12d8b4bd805dc0be8792270c71bd56c54"}, - {file = "coverage-7.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67e6bbe756ed458646e1ef2b0778591ed4d1fcd4b146fc3ba2feb1a7afd4254"}, - {file = "coverage-7.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8f39c49faf5344af36042b293ce05c0d9004270d811c7080610b3e713251c9b0"}, - {file = "coverage-7.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7df91fb24c2edaabec4e0eee512ff3bc6ec20eb8dccac2e77001c1fe516c0c84"}, - {file = "coverage-7.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:34f9f0763d5fa3035a315b69b428fe9c34d4fc2f615262d6be3d3bf3882fb985"}, - {file = "coverage-7.3.0-cp38-cp38-win32.whl", hash = "sha256:bac329371d4c0d456e8d5f38a9b0816b446581b5f278474e416ea0c68c47dcd9"}, - {file = "coverage-7.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:b859128a093f135b556b4765658d5d2e758e1fae3e7cc2f8c10f26fe7005e543"}, - {file = "coverage-7.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed8d310afe013db1eedd37176d0839dc66c96bcfcce8f6607a73ffea2d6ba"}, - {file = "coverage-7.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61260ec93f99f2c2d93d264b564ba912bec502f679793c56f678ba5251f0393"}, - {file = "coverage-7.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97af9554a799bd7c58c0179cc8dbf14aa7ab50e1fd5fa73f90b9b7215874ba28"}, - {file = "coverage-7.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3558e5b574d62f9c46b76120a5c7c16c4612dc2644c3d48a9f4064a705eaee95"}, - {file = "coverage-7.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37d5576d35fcb765fca05654f66aa71e2808d4237d026e64ac8b397ffa66a56a"}, - {file = "coverage-7.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:07ea61bcb179f8f05ffd804d2732b09d23a1238642bf7e51dad62082b5019b34"}, - {file = "coverage-7.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:80501d1b2270d7e8daf1b64b895745c3e234289e00d5f0e30923e706f110334e"}, - {file = "coverage-7.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4eddd3153d02204f22aef0825409091a91bf2a20bce06fe0f638f5c19a85de54"}, - {file = "coverage-7.3.0-cp39-cp39-win32.whl", hash = "sha256:2d22172f938455c156e9af2612650f26cceea47dc86ca048fa4e0b2d21646ad3"}, - {file = "coverage-7.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:60f64e2007c9144375dd0f480a54d6070f00bb1a28f65c408370544091c9bc9e"}, - {file = "coverage-7.3.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:5492a6ce3bdb15c6ad66cb68a0244854d9917478877a25671d70378bdc8562d0"}, - {file = "coverage-7.3.0.tar.gz", hash = "sha256:49dbb19cdcafc130f597d9e04a29d0a032ceedf729e41b181f51cd170e6ee865"}, + {file = "coverage-7.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2"}, + {file = "coverage-7.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c"}, + {file = "coverage-7.4.4-cp310-cp310-win32.whl", hash = "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d"}, + {file = "coverage-7.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f"}, + {file = "coverage-7.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf"}, + {file = "coverage-7.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b"}, + {file = "coverage-7.4.4-cp311-cp311-win32.whl", hash = "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286"}, + {file = "coverage-7.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec"}, + {file = "coverage-7.4.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76"}, + {file = "coverage-7.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9"}, + {file = "coverage-7.4.4-cp312-cp312-win32.whl", hash = "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0"}, + {file = "coverage-7.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e"}, + {file = "coverage-7.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384"}, + {file = "coverage-7.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c"}, + {file = "coverage-7.4.4-cp38-cp38-win32.whl", hash = "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e"}, + {file = "coverage-7.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8"}, + {file = "coverage-7.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d"}, + {file = "coverage-7.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade"}, + {file = "coverage-7.4.4-cp39-cp39-win32.whl", hash = "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57"}, + {file = "coverage-7.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c"}, + {file = "coverage-7.4.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677"}, + {file = "coverage-7.4.4.tar.gz", hash = "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49"}, ] [package.dependencies] @@ -524,80 +532,89 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "41.0.2" +version = "42.0.5" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:01f1d9e537f9a15b037d5d9ee442b8c22e3ae11ce65ea1f3316a41c78756b711"}, - {file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:079347de771f9282fbfe0e0236c716686950c19dee1b76240ab09ce1624d76d7"}, - {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:439c3cc4c0d42fa999b83ded80a9a1fb54d53c58d6e59234cfe97f241e6c781d"}, - {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f14ad275364c8b4e525d018f6716537ae7b6d369c094805cae45300847e0894f"}, - {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:84609ade00a6ec59a89729e87a503c6e36af98ddcd566d5f3be52e29ba993182"}, - {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:49c3222bb8f8e800aead2e376cbef687bc9e3cb9b58b29a261210456a7783d83"}, - {file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d73f419a56d74fef257955f51b18d046f3506270a5fd2ac5febbfa259d6c0fa5"}, - {file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:2a034bf7d9ca894720f2ec1d8b7b5832d7e363571828037f9e0c4f18c1b58a58"}, - {file = "cryptography-41.0.2-cp37-abi3-win32.whl", hash = "sha256:d124682c7a23c9764e54ca9ab5b308b14b18eba02722b8659fb238546de83a76"}, - {file = "cryptography-41.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:9c3fe6534d59d071ee82081ca3d71eed3210f76ebd0361798c74abc2bcf347d4"}, - {file = "cryptography-41.0.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a719399b99377b218dac6cf547b6ec54e6ef20207b6165126a280b0ce97e0d2a"}, - {file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:182be4171f9332b6741ee818ec27daff9fb00349f706629f5cbf417bd50e66fd"}, - {file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7a9a3bced53b7f09da251685224d6a260c3cb291768f54954e28f03ef14e3766"}, - {file = "cryptography-41.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f0dc40e6f7aa37af01aba07277d3d64d5a03dc66d682097541ec4da03cc140ee"}, - {file = "cryptography-41.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:674b669d5daa64206c38e507808aae49904c988fa0a71c935e7006a3e1e83831"}, - {file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7af244b012711a26196450d34f483357e42aeddb04128885d95a69bd8b14b69b"}, - {file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9b6d717393dbae53d4e52684ef4f022444fc1cce3c48c38cb74fca29e1f08eaa"}, - {file = "cryptography-41.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:192255f539d7a89f2102d07d7375b1e0a81f7478925b3bc2e0549ebf739dae0e"}, - {file = "cryptography-41.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f772610fe364372de33d76edcd313636a25684edb94cee53fd790195f5989d14"}, - {file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:b332cba64d99a70c1e0836902720887fb4529ea49ea7f5462cf6640e095e11d2"}, - {file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9a6673c1828db6270b76b22cc696f40cde9043eb90373da5c2f8f2158957f42f"}, - {file = "cryptography-41.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:342f3767e25876751e14f8459ad85e77e660537ca0a066e10e75df9c9e9099f0"}, - {file = "cryptography-41.0.2.tar.gz", hash = "sha256:7d230bf856164de164ecb615ccc14c7fc6de6906ddd5b491f3af90d3514c925c"}, + {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16"}, + {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da"}, + {file = "cryptography-42.0.5-cp37-abi3-win32.whl", hash = "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74"}, + {file = "cryptography-42.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940"}, + {file = "cryptography-42.0.5-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30"}, + {file = "cryptography-42.0.5-cp39-abi3-win32.whl", hash = "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413"}, + {file = "cryptography-42.0.5-cp39-abi3-win_amd64.whl", hash = "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd"}, + {file = "cryptography-42.0.5.tar.gz", hash = "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1"}, ] [package.dependencies] -cffi = ">=1.12" +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} [package.extras] docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] -docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] nox = ["nox"] -pep8test = ["black", "check-sdist", "mypy", "ruff"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] [[package]] name = "distlib" -version = "0.3.7" +version = "0.3.8" description = "Distribution utilities" optional = false python-versions = "*" files = [ - {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, - {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, ] [[package]] name = "docutils" -version = "0.17.1" +version = "0.19" description = "Docutils -- Python Documentation Utilities" optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.7" files = [ - {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, - {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, + {file = "docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc"}, + {file = "docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6"}, ] [[package]] name = "exceptiongroup" -version = "1.1.3" +version = "1.2.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, - {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, + {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, + {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, ] [package.extras] @@ -605,18 +622,19 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.12.2" +version = "3.13.4" description = "A platform independent file lock." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "filelock-3.12.2-py3-none-any.whl", hash = "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec"}, - {file = "filelock-3.12.2.tar.gz", hash = "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81"}, + {file = "filelock-3.13.4-py3-none-any.whl", hash = "sha256:404e5e9253aa60ad457cae1be07c0f0ca90a63931200a47d9b6a6af84fd7b45f"}, + {file = "filelock-3.13.4.tar.gz", hash = "sha256:d13f466618bfde72bd2c18255e269f72542c6e70e7bac83a0232d6b1cc5c8cf4"}, ] [package.extras] -docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] [[package]] name = "frozenlist" @@ -706,13 +724,13 @@ files = [ [[package]] name = "identify" -version = "2.5.27" +version = "2.5.35" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.27-py2.py3-none-any.whl", hash = "sha256:fdb527b2dfe24602809b2201e033c2a113d7bdf716db3ca8e3243f735dcecaba"}, - {file = "identify-2.5.27.tar.gz", hash = "sha256:287b75b04a0e22d727bc9a41f0d4f3c1bcada97490fa6eabb5b28f0e9097e733"}, + {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, + {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, ] [package.extras] @@ -720,13 +738,13 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.4" +version = "3.7" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] [[package]] @@ -742,22 +760,22 @@ files = [ [[package]] name = "importlib-metadata" -version = "6.8.0" +version = "7.1.0" description = "Read metadata from Python packages" optional = true python-versions = ">=3.8" files = [ - {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, - {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, + {file = "importlib_metadata-7.1.0-py3-none-any.whl", hash = "sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570"}, + {file = "importlib_metadata-7.1.0.tar.gz", hash = "sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2"}, ] [package.dependencies] zipp = ">=0.5" [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] [[package]] name = "iniconfig" @@ -791,13 +809,13 @@ testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] [[package]] name = "jinja2" -version = "3.1.2" +version = "3.1.3" description = "A very fast and expressive template engine." optional = true python-versions = ">=3.7" files = [ - {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, - {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, + {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, + {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, ] [package.dependencies] @@ -808,27 +826,38 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "kasa-crypt" -version = "0.3.0" +version = "0.4.1" description = "Fast kasa crypt" optional = true python-versions = ">=3.7,<4.0" files = [ - {file = "kasa_crypt-0.3.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:17f543d6952d3cd8aa094429870f9e3241f6035df2ecfd1b937cd6e7da5902c6"}, - {file = "kasa_crypt-0.3.0-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:4a6b15fd4832051b5f75db1eec8c273ba6e5a3122cd7030e0f92d0a90babc5ed"}, - {file = "kasa_crypt-0.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1558b81cb36be015f211d88c69ead8f8708add1206e89672ffc7f06449c682"}, - {file = "kasa_crypt-0.3.0-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:e7525a9770b0df0cde5f2b764dc5415eb5f136159477ffc85759f9dba21a1aff"}, - {file = "kasa_crypt-0.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:dfa84ee1449939d04e5e4a1c6931a2d429f7c1236a6c99eb3970afdf4723fe76"}, - {file = "kasa_crypt-0.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c7e9f3852f087bc5af2077aa95c31a96a6e2a1f89198a4474dd641e578cd1086"}, - {file = "kasa_crypt-0.3.0-cp310-cp310-win32.whl", hash = "sha256:da1f03dcc12261c10ae8c65bb02d809273ecdf1fc31a9dba58af1ae70cae970e"}, - {file = "kasa_crypt-0.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:ccd10995596e746521a6c7be6ca39a87fae74ddd46558f1d6eea5ab221791107"}, - {file = "kasa_crypt-0.3.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:acc3fbb9adf7b80c310cf4bd7334d8bea8e19478b3a24447064093091acef93f"}, - {file = "kasa_crypt-0.3.0-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:34ea41e7788062fc782bcfbe998f8c8d75308785e50c4e3f338dd4c2e488881f"}, - {file = "kasa_crypt-0.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f5231874d7973036b7afce432bb5b7404cdafbbb4d46363580aafcc5d26fde5"}, - {file = "kasa_crypt-0.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:17c7938af96b30416eac6899689e9126c1d17f8f9a0f9dcf9f5cda86f084c60d"}, - {file = "kasa_crypt-0.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c497dbaf1a76b190d025753f146af3e8c948d037b7ca293f4eeebb9f9721f4f8"}, - {file = "kasa_crypt-0.3.0-cp311-cp311-win32.whl", hash = "sha256:0ede1c2e460e8481705a159e13b6e437fa09ac24993e4a55edc26a962ffa436e"}, - {file = "kasa_crypt-0.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:90f6c66b48db56631e7fe391f4e4934b0ddf6f41d31aa834c1baedc6c94b40d4"}, - {file = "kasa_crypt-0.3.0.tar.gz", hash = "sha256:80c866a1f5d4ad419fcd454b2343a6ecfff8814195ab2caf108941971150ccd8"}, + {file = "kasa_crypt-0.4.1-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:0b806ee24075b88fe9b8e439c89806697fee1276ffa33d5b8c04f0db2a9c85e5"}, + {file = "kasa_crypt-0.4.1-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:6d9e392a57fb73a6a50e3347159288b55e8c37cb553564c3333273eb51a4ac90"}, + {file = "kasa_crypt-0.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb6969da1f2d09e40fc7ba425b16ccfd5dbce084ba3699f566306c56ca90fc3"}, + {file = "kasa_crypt-0.4.1-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:39e1161fd5a3c954ae607a66066b32ef7e9dc20bd388868bdddebee4046a6b1e"}, + {file = "kasa_crypt-0.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c60276e683810aa2669825586249c54a6398f14d3d735c499f7528899c30802a"}, + {file = "kasa_crypt-0.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d417c176ee7ab3e33187e68adce50b0f3d79f92f309bdba0f9d63ae20c8b406e"}, + {file = "kasa_crypt-0.4.1-cp310-cp310-win32.whl", hash = "sha256:f0fcc9c32be0a49d9eca8fd4dbaf46239af3e23074c7bfeebc8739f5221c784e"}, + {file = "kasa_crypt-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:24c34dd3e9f7d4bb2716c295fd7969390fd14de5ac149a7a15e0e6f8ed64434f"}, + {file = "kasa_crypt-0.4.1-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:f940ce8635e349d4d037f8f570f010897062a9495b3c12612962b2fda9f6e6f4"}, + {file = "kasa_crypt-0.4.1-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:30522588d9cae855c0b367922b0ae53a8da2ff36adb5099ba75cd40f1886d229"}, + {file = "kasa_crypt-0.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd038a05925101341358b2d8bd5b01d1b3e811a625da6f2d548238364c0060f2"}, + {file = "kasa_crypt-0.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d7f342314a81f75ab5f32489f33306d2665e9b11ef80b52396a55e1105373536"}, + {file = "kasa_crypt-0.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83e3f552a95f6a090b86f04c68ac9129259f1420280d3c1348eca73d94106fe8"}, + {file = "kasa_crypt-0.4.1-cp311-cp311-win32.whl", hash = "sha256:60034afe4ca341d9dfe3f92d03b7d39aaa1a02f53fda33189a4166ddf6112580"}, + {file = "kasa_crypt-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:6d6fa98b6a38fc71964d1b461f29c083f547d2de3ad97a902584f80a4f2db85a"}, + {file = "kasa_crypt-0.4.1-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:616017fde1460a22f81a78745beb03ae099ef3eccb8184a253ff6ba6cbd424f6"}, + {file = "kasa_crypt-0.4.1-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:50e950458b12b81cb73ba40d7059a4eaaf969edbf76a75a688a195d93e3b47e0"}, + {file = "kasa_crypt-0.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbf069d1cb8425700196103b612cbba006ef0ee8bf0bdc2dc1bf34000054b945"}, + {file = "kasa_crypt-0.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:02186db5d8c520199b9c7531cdd3e385256b50f8c9c560effa8e073701e68b3f"}, + {file = "kasa_crypt-0.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b0e78e69ca7d614edf67da20b77fc4dd49427249c6411d56d5fdab3958966a7a"}, + {file = "kasa_crypt-0.4.1-cp312-cp312-win32.whl", hash = "sha256:e7aff75d81f55f331bca8fa692d8b6bc9d6b2e331ef79effb258f2f3f09bfed3"}, + {file = "kasa_crypt-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:0ae78f7d0b5373bb6d884a26509d018abbe5b10167dc2120d39d0c06237d3f17"}, + {file = "kasa_crypt-0.4.1-pp310-pypy310_pp73-macosx_11_0_x86_64.whl", hash = "sha256:6a6f39a6409de6472ee06f200bee8b7b42bf03dd33968a4e962c9e92b19179e6"}, + {file = "kasa_crypt-0.4.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:d076e14310b50c964a91b4e1322c1700c85062d45d452e29dd52b150058fe75e"}, + {file = "kasa_crypt-0.4.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7093360af43c49e4439c55f208bdf4d76e18104a264a3a5063c58661b449c8f"}, + {file = "kasa_crypt-0.4.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ec769c537d845f8fdaa80d84bc48c213ae6bd896a6c31765fcf111753da729d2"}, + {file = "kasa_crypt-0.4.1.tar.gz", hash = "sha256:32a0ad32fc3df17968f26c83d7a82eb9a91fcb23974b68ed58ec122f9fab82a1"}, ] [[package]] @@ -857,71 +886,71 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "markupsafe" -version = "2.1.3" +version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." optional = true python-versions = ">=3.7" files = [ - {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, - {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] [[package]] @@ -956,112 +985,128 @@ files = [ [[package]] name = "multidict" -version = "6.0.4" +version = "6.0.5" description = "multidict implementation" optional = false python-versions = ">=3.7" files = [ - {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8"}, - {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171"}, - {file = "multidict-6.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7"}, - {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b"}, - {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547"}, - {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569"}, - {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93"}, - {file = "multidict-6.0.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98"}, - {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0"}, - {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988"}, - {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc"}, - {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0"}, - {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5"}, - {file = "multidict-6.0.4-cp310-cp310-win32.whl", hash = "sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8"}, - {file = "multidict-6.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc"}, - {file = "multidict-6.0.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03"}, - {file = "multidict-6.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3"}, - {file = "multidict-6.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba"}, - {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9"}, - {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982"}, - {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe"}, - {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710"}, - {file = "multidict-6.0.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c"}, - {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4"}, - {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a"}, - {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c"}, - {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed"}, - {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461"}, - {file = "multidict-6.0.4-cp311-cp311-win32.whl", hash = "sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636"}, - {file = "multidict-6.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0"}, - {file = "multidict-6.0.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78"}, - {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f"}, - {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603"}, - {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac"}, - {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9"}, - {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2"}, - {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde"}, - {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe"}, - {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067"}, - {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87"}, - {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d"}, - {file = "multidict-6.0.4-cp37-cp37m-win32.whl", hash = "sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775"}, - {file = "multidict-6.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e"}, - {file = "multidict-6.0.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c"}, - {file = "multidict-6.0.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161"}, - {file = "multidict-6.0.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11"}, - {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e"}, - {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d"}, - {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2"}, - {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258"}, - {file = "multidict-6.0.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52"}, - {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660"}, - {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951"}, - {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60"}, - {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d"}, - {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1"}, - {file = "multidict-6.0.4-cp38-cp38-win32.whl", hash = "sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779"}, - {file = "multidict-6.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480"}, - {file = "multidict-6.0.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664"}, - {file = "multidict-6.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35"}, - {file = "multidict-6.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60"}, - {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706"}, - {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d"}, - {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca"}, - {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1"}, - {file = "multidict-6.0.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449"}, - {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf"}, - {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063"}, - {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a"}, - {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176"}, - {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95"}, - {file = "multidict-6.0.4-cp39-cp39-win32.whl", hash = "sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313"}, - {file = "multidict-6.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2"}, - {file = "multidict-6.0.4.tar.gz", hash = "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"}, + {file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"}, + {file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"}, + {file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"}, + {file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"}, + {file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"}, + {file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"}, + {file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"}, + {file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"}, + {file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"}, + {file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"}, + {file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"}, + {file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"}, + {file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"}, + {file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"}, + {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, ] [[package]] name = "myst-parser" -version = "0.18.1" -description = "An extended commonmark compliant parser, with bridges to docutils & sphinx." +version = "1.0.0" +description = "An extended [CommonMark](https://spec.commonmark.org/) compliant parser," optional = true python-versions = ">=3.7" files = [ - {file = "myst-parser-0.18.1.tar.gz", hash = "sha256:79317f4bb2c13053dd6e64f9da1ba1da6cd9c40c8a430c447a7b146a594c246d"}, - {file = "myst_parser-0.18.1-py3-none-any.whl", hash = "sha256:61b275b85d9f58aa327f370913ae1bec26ebad372cc99f3ab85c8ec3ee8d9fb8"}, + {file = "myst-parser-1.0.0.tar.gz", hash = "sha256:502845659313099542bd38a2ae62f01360e7dd4b1310f025dd014dfc0439cdae"}, + {file = "myst_parser-1.0.0-py3-none-any.whl", hash = "sha256:69fb40a586c6fa68995e6521ac0a525793935db7e724ca9bac1d33be51be9a4c"}, ] [package.dependencies] docutils = ">=0.15,<0.20" jinja2 = "*" markdown-it-py = ">=1.0.0,<3.0.0" -mdit-py-plugins = ">=0.3.1,<0.4.0" +mdit-py-plugins = ">=0.3.4,<0.4.0" pyyaml = "*" -sphinx = ">=4,<6" -typing-extensions = "*" +sphinx = ">=5,<7" [package.extras] -code-style = ["pre-commit (>=2.12,<3.0)"] +code-style = ["pre-commit (>=3.0,<4.0)"] linkify = ["linkify-it-py (>=1.0,<2.0)"] -rtd = ["ipython", "sphinx-book-theme", "sphinx-design", "sphinxcontrib.mermaid (>=0.7.1,<0.8.0)", "sphinxext-opengraph (>=0.6.3,<0.7.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] -testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=6,<7)", "pytest-cov", "pytest-param-files (>=0.3.4,<0.4.0)", "pytest-regressions", "sphinx (<5.2)", "sphinx-pytest"] +rtd = ["ipython", "pydata-sphinx-theme (==v0.13.0rc4)", "sphinx-autodoc2 (>=0.4.2,<0.5.0)", "sphinx-book-theme (==1.0.0rc2)", "sphinx-copybutton", "sphinx-design2", "sphinx-pyscript", "sphinx-tippy (>=0.3.1)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.7.5,<0.8.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] +testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=7,<8)", "pytest-cov", "pytest-param-files (>=0.3.4,<0.4.0)", "pytest-regressions", "sphinx-pytest"] +testing-docutils = ["pygments", "pytest (>=7,<8)", "pytest-param-files (>=0.3.4,<0.4.0)"] [[package]] name = "nodeenv" @@ -1079,123 +1124,114 @@ setuptools = "*" [[package]] name = "orjson" -version = "3.9.5" +version = "3.10.1" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = true -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "orjson-3.9.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ad6845912a71adcc65df7c8a7f2155eba2096cf03ad2c061c93857de70d699ad"}, - {file = "orjson-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e298e0aacfcc14ef4476c3f409e85475031de24e5b23605a465e9bf4b2156273"}, - {file = "orjson-3.9.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83c9939073281ef7dd7c5ca7f54cceccb840b440cec4b8a326bda507ff88a0a6"}, - {file = "orjson-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e174cc579904a48ee1ea3acb7045e8a6c5d52c17688dfcb00e0e842ec378cabf"}, - {file = "orjson-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f8d51702f42c785b115401e1d64a27a2ea767ae7cf1fb8edaa09c7cf1571c660"}, - {file = "orjson-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f13d61c0c7414ddee1ef4d0f303e2222f8cced5a2e26d9774751aecd72324c9e"}, - {file = "orjson-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d748cc48caf5a91c883d306ab648df1b29e16b488c9316852844dd0fd000d1c2"}, - {file = "orjson-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bd19bc08fa023e4c2cbf8294ad3f2b8922f4de9ba088dbc71e6b268fdf54591c"}, - {file = "orjson-3.9.5-cp310-none-win32.whl", hash = "sha256:5793a21a21bf34e1767e3d61a778a25feea8476dcc0bdf0ae1bc506dc34561ea"}, - {file = "orjson-3.9.5-cp310-none-win_amd64.whl", hash = "sha256:2bcec0b1024d0031ab3eab7a8cb260c8a4e4a5e35993878a2da639d69cdf6a65"}, - {file = "orjson-3.9.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8547b95ca0e2abd17e1471973e6d676f1d8acedd5f8fb4f739e0612651602d66"}, - {file = "orjson-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87ce174d6a38d12b3327f76145acbd26f7bc808b2b458f61e94d83cd0ebb4d76"}, - {file = "orjson-3.9.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a960bb1bc9a964d16fcc2d4af5a04ce5e4dfddca84e3060c35720d0a062064fe"}, - {file = "orjson-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a7aa5573a949760d6161d826d34dc36db6011926f836851fe9ccb55b5a7d8e8"}, - {file = "orjson-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b2852afca17d7eea85f8e200d324e38c851c96598ac7b227e4f6c4e59fbd3df"}, - {file = "orjson-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa185959c082475288da90f996a82e05e0c437216b96f2a8111caeb1d54ef926"}, - {file = "orjson-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:89c9332695b838438ea4b9a482bce8ffbfddde4df92750522d928fb00b7b8dce"}, - {file = "orjson-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2493f1351a8f0611bc26e2d3d407efb873032b4f6b8926fed8cfed39210ca4ba"}, - {file = "orjson-3.9.5-cp311-none-win32.whl", hash = "sha256:ffc544e0e24e9ae69301b9a79df87a971fa5d1c20a6b18dca885699709d01be0"}, - {file = "orjson-3.9.5-cp311-none-win_amd64.whl", hash = "sha256:89670fe2732e3c0c54406f77cad1765c4c582f67b915c74fda742286809a0cdc"}, - {file = "orjson-3.9.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:15df211469625fa27eced4aa08dc03e35f99c57d45a33855cc35f218ea4071b8"}, - {file = "orjson-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9f17c59fe6c02bc5f89ad29edb0253d3059fe8ba64806d789af89a45c35269a"}, - {file = "orjson-3.9.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca6b96659c7690773d8cebb6115c631f4a259a611788463e9c41e74fa53bf33f"}, - {file = "orjson-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26fafe966e9195b149950334bdbe9026eca17fe8ffe2d8fa87fdc30ca925d30"}, - {file = "orjson-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9006b1eb645ecf460da067e2dd17768ccbb8f39b01815a571bfcfab7e8da5e52"}, - {file = "orjson-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebfdbf695734b1785e792a1315e41835ddf2a3e907ca0e1c87a53f23006ce01d"}, - {file = "orjson-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4a3943234342ab37d9ed78fb0a8f81cd4b9532f67bf2ac0d3aa45fa3f0a339f3"}, - {file = "orjson-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e6762755470b5c82f07b96b934af32e4d77395a11768b964aaa5eb092817bc31"}, - {file = "orjson-3.9.5-cp312-none-win_amd64.whl", hash = "sha256:c74df28749c076fd6e2157190df23d43d42b2c83e09d79b51694ee7315374ad5"}, - {file = "orjson-3.9.5-cp37-cp37m-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:88e18a74d916b74f00d0978d84e365c6bf0e7ab846792efa15756b5fb2f7d49d"}, - {file = "orjson-3.9.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d28514b5b6dfaf69097be70d0cf4f1407ec29d0f93e0b4131bf9cc8fd3f3e374"}, - {file = "orjson-3.9.5-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b81aca8c7be61e2566246b6a0ca49f8aece70dd3f38c7f5c837f398c4cb142"}, - {file = "orjson-3.9.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:385c1c713b1e47fd92e96cf55fd88650ac6dfa0b997e8aa7ecffd8b5865078b1"}, - {file = "orjson-3.9.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9850c03a8e42fba1a508466e6a0f99472fd2b4a5f30235ea49b2a1b32c04c11"}, - {file = "orjson-3.9.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4449f84bbb13bcef493d8aa669feadfced0f7c5eea2d0d88b5cc21f812183af8"}, - {file = "orjson-3.9.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:86127bf194f3b873135e44ce5dc9212cb152b7e06798d5667a898a00f0519be4"}, - {file = "orjson-3.9.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0abcd039f05ae9ab5b0ff11624d0b9e54376253b7d3217a358d09c3edf1d36f7"}, - {file = "orjson-3.9.5-cp37-none-win32.whl", hash = "sha256:10cc8ad5ff7188efcb4bec196009d61ce525a4e09488e6d5db41218c7fe4f001"}, - {file = "orjson-3.9.5-cp37-none-win_amd64.whl", hash = "sha256:ff27e98532cb87379d1a585837d59b187907228268e7b0a87abe122b2be6968e"}, - {file = "orjson-3.9.5-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5bfa79916ef5fef75ad1f377e54a167f0de334c1fa4ebb8d0224075f3ec3d8c0"}, - {file = "orjson-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e87dfa6ac0dae764371ab19b35eaaa46dfcb6ef2545dfca03064f21f5d08239f"}, - {file = "orjson-3.9.5-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50ced24a7b23058b469ecdb96e36607fc611cbaee38b58e62a55c80d1b3ad4e1"}, - {file = "orjson-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b1b74ea2a3064e1375da87788897935832e806cc784de3e789fd3c4ab8eb3fa5"}, - {file = "orjson-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7cb961efe013606913d05609f014ad43edfaced82a576e8b520a5574ce3b2b9"}, - {file = "orjson-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1225d2d5ee76a786bda02f8c5e15017462f8432bb960de13d7c2619dba6f0275"}, - {file = "orjson-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f39f4b99199df05c7ecdd006086259ed25886cdbd7b14c8cdb10c7675cfcca7d"}, - {file = "orjson-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a461dc9fb60cac44f2d3218c36a0c1c01132314839a0e229d7fb1bba69b810d8"}, - {file = "orjson-3.9.5-cp38-none-win32.whl", hash = "sha256:dedf1a6173748202df223aea29de814b5836732a176b33501375c66f6ab7d822"}, - {file = "orjson-3.9.5-cp38-none-win_amd64.whl", hash = "sha256:fa504082f53efcbacb9087cc8676c163237beb6e999d43e72acb4bb6f0db11e6"}, - {file = "orjson-3.9.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6900f0248edc1bec2a2a3095a78a7e3ef4e63f60f8ddc583687eed162eedfd69"}, - {file = "orjson-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17404333c40047888ac40bd8c4d49752a787e0a946e728a4e5723f111b6e55a5"}, - {file = "orjson-3.9.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0eefb7cfdd9c2bc65f19f974a5d1dfecbac711dae91ed635820c6b12da7a3c11"}, - {file = "orjson-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:68c78b2a3718892dc018adbc62e8bab6ef3c0d811816d21e6973dee0ca30c152"}, - {file = "orjson-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:591ad7d9e4a9f9b104486ad5d88658c79ba29b66c5557ef9edf8ca877a3f8d11"}, - {file = "orjson-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cc2cbf302fbb2d0b2c3c142a663d028873232a434d89ce1b2604ebe5cc93ce8"}, - {file = "orjson-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b26b5aa5e9ee1bad2795b925b3adb1b1b34122cb977f30d89e0a1b3f24d18450"}, - {file = "orjson-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ef84724f7d29dcfe3aafb1fc5fc7788dca63e8ae626bb9298022866146091a3e"}, - {file = "orjson-3.9.5-cp39-none-win32.whl", hash = "sha256:664cff27f85939059472afd39acff152fbac9a091b7137092cb651cf5f7747b5"}, - {file = "orjson-3.9.5-cp39-none-win_amd64.whl", hash = "sha256:91dda66755795ac6100e303e206b636568d42ac83c156547634256a2e68de694"}, - {file = "orjson-3.9.5.tar.gz", hash = "sha256:6daf5ee0b3cf530b9978cdbf71024f1c16ed4a67d05f6ec435c6e7fe7a52724c"}, + {file = "orjson-3.10.1-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8ec2fc456d53ea4a47768f622bb709be68acd455b0c6be57e91462259741c4f3"}, + {file = "orjson-3.10.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e900863691d327758be14e2a491931605bd0aded3a21beb6ce133889830b659"}, + {file = "orjson-3.10.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab6ecbd6fe57785ebc86ee49e183f37d45f91b46fc601380c67c5c5e9c0014a2"}, + {file = "orjson-3.10.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8af7c68b01b876335cccfb4eee0beef2b5b6eae1945d46a09a7c24c9faac7a77"}, + {file = "orjson-3.10.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:915abfb2e528677b488a06eba173e9d7706a20fdfe9cdb15890b74ef9791b85e"}, + {file = "orjson-3.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe3fd4a36eff9c63d25503b439531d21828da9def0059c4f472e3845a081aa0b"}, + {file = "orjson-3.10.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d229564e72cfc062e6481a91977a5165c5a0fdce11ddc19ced8471847a67c517"}, + {file = "orjson-3.10.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9e00495b18304173ac843b5c5fbea7b6f7968564d0d49bef06bfaeca4b656f4e"}, + {file = "orjson-3.10.1-cp310-none-win32.whl", hash = "sha256:fd78ec55179545c108174ba19c1795ced548d6cac4d80d014163033c047ca4ea"}, + {file = "orjson-3.10.1-cp310-none-win_amd64.whl", hash = "sha256:50ca42b40d5a442a9e22eece8cf42ba3d7cd4cd0f2f20184b4d7682894f05eec"}, + {file = "orjson-3.10.1-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b345a3d6953628df2f42502297f6c1e1b475cfbf6268013c94c5ac80e8abc04c"}, + {file = "orjson-3.10.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caa7395ef51af4190d2c70a364e2f42138e0e5fcb4bc08bc9b76997659b27dab"}, + {file = "orjson-3.10.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b01d701decd75ae092e5f36f7b88a1e7a1d3bb7c9b9d7694de850fb155578d5a"}, + {file = "orjson-3.10.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5028981ba393f443d8fed9049211b979cadc9d0afecf162832f5a5b152c6297"}, + {file = "orjson-3.10.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31ff6a222ea362b87bf21ff619598a4dc1106aaafaea32b1c4876d692891ec27"}, + {file = "orjson-3.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e852a83d7803d3406135fb7a57cf0c1e4a3e73bac80ec621bd32f01c653849c5"}, + {file = "orjson-3.10.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2567bc928ed3c3fcd90998009e8835de7c7dc59aabcf764b8374d36044864f3b"}, + {file = "orjson-3.10.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4ce98cac60b7bb56457bdd2ed7f0d5d7f242d291fdc0ca566c83fa721b52e92d"}, + {file = "orjson-3.10.1-cp311-none-win32.whl", hash = "sha256:813905e111318acb356bb8029014c77b4c647f8b03f314e7b475bd9ce6d1a8ce"}, + {file = "orjson-3.10.1-cp311-none-win_amd64.whl", hash = "sha256:03a3ca0b3ed52bed1a869163a4284e8a7b0be6a0359d521e467cdef7e8e8a3ee"}, + {file = "orjson-3.10.1-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f02c06cee680b1b3a8727ec26c36f4b3c0c9e2b26339d64471034d16f74f4ef5"}, + {file = "orjson-3.10.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1aa2f127ac546e123283e437cc90b5ecce754a22306c7700b11035dad4ccf85"}, + {file = "orjson-3.10.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2cf29b4b74f585225196944dffdebd549ad2af6da9e80db7115984103fb18a96"}, + {file = "orjson-3.10.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1b130c20b116f413caf6059c651ad32215c28500dce9cd029a334a2d84aa66f"}, + {file = "orjson-3.10.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d31f9a709e6114492136e87c7c6da5e21dfedebefa03af85f3ad72656c493ae9"}, + {file = "orjson-3.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d1d169461726f271ab31633cf0e7e7353417e16fb69256a4f8ecb3246a78d6e"}, + {file = "orjson-3.10.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:57c294d73825c6b7f30d11c9e5900cfec9a814893af7f14efbe06b8d0f25fba9"}, + {file = "orjson-3.10.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d7f11dbacfa9265ec76b4019efffabaabba7a7ebf14078f6b4df9b51c3c9a8ea"}, + {file = "orjson-3.10.1-cp312-none-win32.whl", hash = "sha256:d89e5ed68593226c31c76ab4de3e0d35c760bfd3fbf0a74c4b2be1383a1bf123"}, + {file = "orjson-3.10.1-cp312-none-win_amd64.whl", hash = "sha256:aa76c4fe147fd162107ce1692c39f7189180cfd3a27cfbc2ab5643422812da8e"}, + {file = "orjson-3.10.1-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a2c6a85c92d0e494c1ae117befc93cf8e7bca2075f7fe52e32698da650b2c6d1"}, + {file = "orjson-3.10.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9813f43da955197d36a7365eb99bed42b83680801729ab2487fef305b9ced866"}, + {file = "orjson-3.10.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec917b768e2b34b7084cb6c68941f6de5812cc26c6f1a9fecb728e36a3deb9e8"}, + {file = "orjson-3.10.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5252146b3172d75c8a6d27ebca59c9ee066ffc5a277050ccec24821e68742fdf"}, + {file = "orjson-3.10.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:536429bb02791a199d976118b95014ad66f74c58b7644d21061c54ad284e00f4"}, + {file = "orjson-3.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7dfed3c3e9b9199fb9c3355b9c7e4649b65f639e50ddf50efdf86b45c6de04b5"}, + {file = "orjson-3.10.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2b230ec35f188f003f5b543644ae486b2998f6afa74ee3a98fc8ed2e45960afc"}, + {file = "orjson-3.10.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:01234249ba19c6ab1eb0b8be89f13ea21218b2d72d496ef085cfd37e1bae9dd8"}, + {file = "orjson-3.10.1-cp38-none-win32.whl", hash = "sha256:8a884fbf81a3cc22d264ba780920d4885442144e6acaa1411921260416ac9a54"}, + {file = "orjson-3.10.1-cp38-none-win_amd64.whl", hash = "sha256:dab5f802d52b182163f307d2b1f727d30b1762e1923c64c9c56dd853f9671a49"}, + {file = "orjson-3.10.1-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a51fd55d4486bc5293b7a400f9acd55a2dc3b5fc8420d5ffe9b1d6bb1a056a5e"}, + {file = "orjson-3.10.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53521542a6db1411b3bfa1b24ddce18605a3abdc95a28a67b33f9145f26aa8f2"}, + {file = "orjson-3.10.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:27d610df96ac18ace4931411d489637d20ab3b8f63562b0531bba16011998db0"}, + {file = "orjson-3.10.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79244b1456e5846d44e9846534bd9e3206712936d026ea8e6a55a7374d2c0694"}, + {file = "orjson-3.10.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d751efaa8a49ae15cbebdda747a62a9ae521126e396fda8143858419f3b03610"}, + {file = "orjson-3.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27ff69c620a4fff33267df70cfd21e0097c2a14216e72943bd5414943e376d77"}, + {file = "orjson-3.10.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ebc58693464146506fde0c4eb1216ff6d4e40213e61f7d40e2f0dde9b2f21650"}, + {file = "orjson-3.10.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5be608c3972ed902e0143a5b8776d81ac1059436915d42defe5c6ae97b3137a4"}, + {file = "orjson-3.10.1-cp39-none-win32.whl", hash = "sha256:4ae10753e7511d359405aadcbf96556c86e9dbf3a948d26c2c9f9a150c52b091"}, + {file = "orjson-3.10.1-cp39-none-win_amd64.whl", hash = "sha256:fb5bc4caa2c192077fdb02dce4e5ef8639e7f20bec4e3a834346693907362932"}, + {file = "orjson-3.10.1.tar.gz", hash = "sha256:a883b28d73370df23ed995c466b4f6c708c1f7a9bdc400fe89165c96c7603204"}, ] [[package]] name = "packaging" -version = "23.1" +version = "24.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, - {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] [[package]] name = "parso" -version = "0.8.3" +version = "0.8.4" description = "A Python Parser" optional = true python-versions = ">=3.6" files = [ - {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, - {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, + {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, + {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, ] [package.extras] -qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] -testing = ["docopt", "pytest (<6.0.0)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["docopt", "pytest"] [[package]] name = "platformdirs" -version = "3.10.0" +version = "4.2.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, - {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, ] [package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] [[package]] name = "pluggy" -version = "1.3.0" +version = "1.4.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ - {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, - {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, ] [package.extras] @@ -1204,13 +1240,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "3.3.3" +version = "3.5.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.8" files = [ - {file = "pre_commit-3.3.3-py2.py3-none-any.whl", hash = "sha256:10badb65d6a38caff29703362271d7dca483d01da88f9d7e05d0b97171c136cb"}, - {file = "pre_commit-3.3.3.tar.gz", hash = "sha256:a2256f489cd913d575c145132ae196fe335da32d91a8294b7afe6622335dd023"}, + {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, + {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, ] [package.dependencies] @@ -1257,29 +1293,29 @@ ptipython = ["ipython"] [[package]] name = "pycparser" -version = "2.21" +version = "2.22" description = "C parser in Python" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.8" files = [ - {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, - {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] [[package]] name = "pydantic" -version = "2.3.0" +version = "2.7.0" description = "Data validation using Python type hints" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pydantic-2.3.0-py3-none-any.whl", hash = "sha256:45b5e446c6dfaad9444819a293b921a40e1db1aa61ea08aede0522529ce90e81"}, - {file = "pydantic-2.3.0.tar.gz", hash = "sha256:1607cc106602284cd4a00882986570472f193fde9cb1259bceeaedb26aa79a6d"}, + {file = "pydantic-2.7.0-py3-none-any.whl", hash = "sha256:9dee74a271705f14f9a1567671d144a851c675b072736f0a7b2608fd9e495352"}, + {file = "pydantic-2.7.0.tar.gz", hash = "sha256:b5ecdd42262ca2462e2624793551e80911a1e989f462910bb81aef974b4bb383"}, ] [package.dependencies] annotated-types = ">=0.4.0" -pydantic-core = "2.6.3" +pydantic-core = "2.18.1" typing-extensions = ">=4.6.1" [package.extras] @@ -1287,117 +1323,90 @@ email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-core" -version = "2.6.3" -description = "" +version = "2.18.1" +description = "Core functionality for Pydantic validation and serialization" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.6.3-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:1a0ddaa723c48af27d19f27f1c73bdc615c73686d763388c8683fe34ae777bad"}, - {file = "pydantic_core-2.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5cfde4fab34dd1e3a3f7f3db38182ab6c95e4ea91cf322242ee0be5c2f7e3d2f"}, - {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5493a7027bfc6b108e17c3383959485087d5942e87eb62bbac69829eae9bc1f7"}, - {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84e87c16f582f5c753b7f39a71bd6647255512191be2d2dbf49458c4ef024588"}, - {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:522a9c4a4d1924facce7270c84b5134c5cabcb01513213662a2e89cf28c1d309"}, - {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aaafc776e5edc72b3cad1ccedb5fd869cc5c9a591f1213aa9eba31a781be9ac1"}, - {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a750a83b2728299ca12e003d73d1264ad0440f60f4fc9cee54acc489249b728"}, - {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e8b374ef41ad5c461efb7a140ce4730661aadf85958b5c6a3e9cf4e040ff4bb"}, - {file = "pydantic_core-2.6.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b594b64e8568cf09ee5c9501ede37066b9fc41d83d58f55b9952e32141256acd"}, - {file = "pydantic_core-2.6.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2a20c533cb80466c1d42a43a4521669ccad7cf2967830ac62c2c2f9cece63e7e"}, - {file = "pydantic_core-2.6.3-cp310-none-win32.whl", hash = "sha256:04fe5c0a43dec39aedba0ec9579001061d4653a9b53a1366b113aca4a3c05ca7"}, - {file = "pydantic_core-2.6.3-cp310-none-win_amd64.whl", hash = "sha256:6bf7d610ac8f0065a286002a23bcce241ea8248c71988bda538edcc90e0c39ad"}, - {file = "pydantic_core-2.6.3-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:6bcc1ad776fffe25ea5c187a028991c031a00ff92d012ca1cc4714087e575973"}, - {file = "pydantic_core-2.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:df14f6332834444b4a37685810216cc8fe1fe91f447332cd56294c984ecbff1c"}, - {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0b7486d85293f7f0bbc39b34e1d8aa26210b450bbd3d245ec3d732864009819"}, - {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a892b5b1871b301ce20d40b037ffbe33d1407a39639c2b05356acfef5536d26a"}, - {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:883daa467865e5766931e07eb20f3e8152324f0adf52658f4d302242c12e2c32"}, - {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4eb77df2964b64ba190eee00b2312a1fd7a862af8918ec70fc2d6308f76ac64"}, - {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ce8c84051fa292a5dc54018a40e2a1926fd17980a9422c973e3ebea017aa8da"}, - {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22134a4453bd59b7d1e895c455fe277af9d9d9fbbcb9dc3f4a97b8693e7e2c9b"}, - {file = "pydantic_core-2.6.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:02e1c385095efbd997311d85c6021d32369675c09bcbfff3b69d84e59dc103f6"}, - {file = "pydantic_core-2.6.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d79f1f2f7ebdb9b741296b69049ff44aedd95976bfee38eb4848820628a99b50"}, - {file = "pydantic_core-2.6.3-cp311-none-win32.whl", hash = "sha256:430ddd965ffd068dd70ef4e4d74f2c489c3a313adc28e829dd7262cc0d2dd1e8"}, - {file = "pydantic_core-2.6.3-cp311-none-win_amd64.whl", hash = "sha256:84f8bb34fe76c68c9d96b77c60cef093f5e660ef8e43a6cbfcd991017d375950"}, - {file = "pydantic_core-2.6.3-cp311-none-win_arm64.whl", hash = "sha256:5a2a3c9ef904dcdadb550eedf3291ec3f229431b0084666e2c2aa8ff99a103a2"}, - {file = "pydantic_core-2.6.3-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:8421cf496e746cf8d6b677502ed9a0d1e4e956586cd8b221e1312e0841c002d5"}, - {file = "pydantic_core-2.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bb128c30cf1df0ab78166ded1ecf876620fb9aac84d2413e8ea1594b588c735d"}, - {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37a822f630712817b6ecc09ccc378192ef5ff12e2c9bae97eb5968a6cdf3b862"}, - {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:240a015102a0c0cc8114f1cba6444499a8a4d0333e178bc504a5c2196defd456"}, - {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f90e5e3afb11268628c89f378f7a1ea3f2fe502a28af4192e30a6cdea1e7d5e"}, - {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:340e96c08de1069f3d022a85c2a8c63529fd88709468373b418f4cf2c949fb0e"}, - {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1480fa4682e8202b560dcdc9eeec1005f62a15742b813c88cdc01d44e85308e5"}, - {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f14546403c2a1d11a130b537dda28f07eb6c1805a43dae4617448074fd49c282"}, - {file = "pydantic_core-2.6.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a87c54e72aa2ef30189dc74427421e074ab4561cf2bf314589f6af5b37f45e6d"}, - {file = "pydantic_core-2.6.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f93255b3e4d64785554e544c1c76cd32f4a354fa79e2eeca5d16ac2e7fdd57aa"}, - {file = "pydantic_core-2.6.3-cp312-none-win32.whl", hash = "sha256:f70dc00a91311a1aea124e5f64569ea44c011b58433981313202c46bccbec0e1"}, - {file = "pydantic_core-2.6.3-cp312-none-win_amd64.whl", hash = "sha256:23470a23614c701b37252618e7851e595060a96a23016f9a084f3f92f5ed5881"}, - {file = "pydantic_core-2.6.3-cp312-none-win_arm64.whl", hash = "sha256:1ac1750df1b4339b543531ce793b8fd5c16660a95d13aecaab26b44ce11775e9"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:a53e3195f134bde03620d87a7e2b2f2046e0e5a8195e66d0f244d6d5b2f6d31b"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:f2969e8f72c6236c51f91fbb79c33821d12a811e2a94b7aa59c65f8dbdfad34a"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:672174480a85386dd2e681cadd7d951471ad0bb028ed744c895f11f9d51b9ebe"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:002d0ea50e17ed982c2d65b480bd975fc41086a5a2f9c924ef8fc54419d1dea3"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ccc13afee44b9006a73d2046068d4df96dc5b333bf3509d9a06d1b42db6d8bf"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:439a0de139556745ae53f9cc9668c6c2053444af940d3ef3ecad95b079bc9987"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d63b7545d489422d417a0cae6f9898618669608750fc5e62156957e609e728a5"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b44c42edc07a50a081672e25dfe6022554b47f91e793066a7b601ca290f71e42"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1c721bfc575d57305dd922e6a40a8fe3f762905851d694245807a351ad255c58"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5e4a2cf8c4543f37f5dc881de6c190de08096c53986381daebb56a355be5dfe6"}, - {file = "pydantic_core-2.6.3-cp37-none-win32.whl", hash = "sha256:d9b4916b21931b08096efed090327f8fe78e09ae8f5ad44e07f5c72a7eedb51b"}, - {file = "pydantic_core-2.6.3-cp37-none-win_amd64.whl", hash = "sha256:a8acc9dedd304da161eb071cc7ff1326aa5b66aadec9622b2574ad3ffe225525"}, - {file = "pydantic_core-2.6.3-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:5e9c068f36b9f396399d43bfb6defd4cc99c36215f6ff33ac8b9c14ba15bdf6b"}, - {file = "pydantic_core-2.6.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e61eae9b31799c32c5f9b7be906be3380e699e74b2db26c227c50a5fc7988698"}, - {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85463560c67fc65cd86153a4975d0b720b6d7725cf7ee0b2d291288433fc21b"}, - {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9616567800bdc83ce136e5847d41008a1d602213d024207b0ff6cab6753fe645"}, - {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e9b65a55bbabda7fccd3500192a79f6e474d8d36e78d1685496aad5f9dbd92c"}, - {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f468d520f47807d1eb5d27648393519655eadc578d5dd862d06873cce04c4d1b"}, - {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9680dd23055dd874173a3a63a44e7f5a13885a4cfd7e84814be71be24fba83db"}, - {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a718d56c4d55efcfc63f680f207c9f19c8376e5a8a67773535e6f7e80e93170"}, - {file = "pydantic_core-2.6.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8ecbac050856eb6c3046dea655b39216597e373aa8e50e134c0e202f9c47efec"}, - {file = "pydantic_core-2.6.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:788be9844a6e5c4612b74512a76b2153f1877cd845410d756841f6c3420230eb"}, - {file = "pydantic_core-2.6.3-cp38-none-win32.whl", hash = "sha256:07a1aec07333bf5adebd8264047d3dc518563d92aca6f2f5b36f505132399efc"}, - {file = "pydantic_core-2.6.3-cp38-none-win_amd64.whl", hash = "sha256:621afe25cc2b3c4ba05fff53525156d5100eb35c6e5a7cf31d66cc9e1963e378"}, - {file = "pydantic_core-2.6.3-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:813aab5bfb19c98ae370952b6f7190f1e28e565909bfc219a0909db168783465"}, - {file = "pydantic_core-2.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:50555ba3cb58f9861b7a48c493636b996a617db1a72c18da4d7f16d7b1b9952b"}, - {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19e20f8baedd7d987bd3f8005c146e6bcbda7cdeefc36fad50c66adb2dd2da48"}, - {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b0a5d7edb76c1c57b95df719af703e796fc8e796447a1da939f97bfa8a918d60"}, - {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f06e21ad0b504658a3a9edd3d8530e8cea5723f6ea5d280e8db8efc625b47e49"}, - {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea053cefa008fda40f92aab937fb9f183cf8752e41dbc7bc68917884454c6362"}, - {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:171a4718860790f66d6c2eda1d95dd1edf64f864d2e9f9115840840cf5b5713f"}, - {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ed7ceca6aba5331ece96c0e328cd52f0dcf942b8895a1ed2642de50800b79d3"}, - {file = "pydantic_core-2.6.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:acafc4368b289a9f291e204d2c4c75908557d4f36bd3ae937914d4529bf62a76"}, - {file = "pydantic_core-2.6.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1aa712ba150d5105814e53cb141412217146fedc22621e9acff9236d77d2a5ef"}, - {file = "pydantic_core-2.6.3-cp39-none-win32.whl", hash = "sha256:44b4f937b992394a2e81a5c5ce716f3dcc1237281e81b80c748b2da6dd5cf29a"}, - {file = "pydantic_core-2.6.3-cp39-none-win_amd64.whl", hash = "sha256:9b33bf9658cb29ac1a517c11e865112316d09687d767d7a0e4a63d5c640d1b17"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:d7050899026e708fb185e174c63ebc2c4ee7a0c17b0a96ebc50e1f76a231c057"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:99faba727727b2e59129c59542284efebbddade4f0ae6a29c8b8d3e1f437beb7"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fa159b902d22b283b680ef52b532b29554ea2a7fc39bf354064751369e9dbd7"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:046af9cfb5384f3684eeb3f58a48698ddab8dd870b4b3f67f825353a14441418"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:930bfe73e665ebce3f0da2c6d64455098aaa67e1a00323c74dc752627879fc67"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:85cc4d105747d2aa3c5cf3e37dac50141bff779545ba59a095f4a96b0a460e70"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b25afe9d5c4f60dcbbe2b277a79be114e2e65a16598db8abee2a2dcde24f162b"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e49ce7dc9f925e1fb010fc3d555250139df61fa6e5a0a95ce356329602c11ea9"}, - {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:2dd50d6a1aef0426a1d0199190c6c43ec89812b1f409e7fe44cb0fbf6dfa733c"}, - {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6595b0d8c8711e8e1dc389d52648b923b809f68ac1c6f0baa525c6440aa0daa"}, - {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ef724a059396751aef71e847178d66ad7fc3fc969a1a40c29f5aac1aa5f8784"}, - {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3c8945a105f1589ce8a693753b908815e0748f6279959a4530f6742e1994dcb6"}, - {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c8c6660089a25d45333cb9db56bb9e347241a6d7509838dbbd1931d0e19dbc7f"}, - {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:692b4ff5c4e828a38716cfa92667661a39886e71136c97b7dac26edef18767f7"}, - {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:f1a5d8f18877474c80b7711d870db0eeef9442691fcdb00adabfc97e183ee0b0"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:3796a6152c545339d3b1652183e786df648ecdf7c4f9347e1d30e6750907f5bb"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:b962700962f6e7a6bd77e5f37320cabac24b4c0f76afeac05e9f93cf0c620014"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56ea80269077003eaa59723bac1d8bacd2cd15ae30456f2890811efc1e3d4413"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75c0ebbebae71ed1e385f7dfd9b74c1cff09fed24a6df43d326dd7f12339ec34"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:252851b38bad3bfda47b104ffd077d4f9604a10cb06fe09d020016a25107bf98"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6656a0ae383d8cd7cc94e91de4e526407b3726049ce8d7939049cbfa426518c8"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d9140ded382a5b04a1c030b593ed9bf3088243a0a8b7fa9f071a5736498c5483"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d38bbcef58220f9c81e42c255ef0bf99735d8f11edef69ab0b499da77105158a"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:c9d469204abcca28926cbc28ce98f28e50e488767b084fb3fbdf21af11d3de26"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:48c1ed8b02ffea4d5c9c220eda27af02b8149fe58526359b3c07eb391cb353a2"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b2b1bfed698fa410ab81982f681f5b1996d3d994ae8073286515ac4d165c2e7"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf9d42a71a4d7a7c1f14f629e5c30eac451a6fc81827d2beefd57d014c006c4a"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4292ca56751aebbe63a84bbfc3b5717abb09b14d4b4442cc43fd7c49a1529efd"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:7dc2ce039c7290b4ef64334ec7e6ca6494de6eecc81e21cb4f73b9b39991408c"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:615a31b1629e12445c0e9fc8339b41aaa6cc60bd53bf802d5fe3d2c0cda2ae8d"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1fa1f6312fb84e8c281f32b39affe81984ccd484da6e9d65b3d18c202c666149"}, - {file = "pydantic_core-2.6.3.tar.gz", hash = "sha256:1508f37ba9e3ddc0189e6ff4e2228bd2d3c3a4641cbe8c07177162f76ed696c7"}, + {file = "pydantic_core-2.18.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ee9cf33e7fe14243f5ca6977658eb7d1042caaa66847daacbd2117adb258b226"}, + {file = "pydantic_core-2.18.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b7bbb97d82659ac8b37450c60ff2e9f97e4eb0f8a8a3645a5568b9334b08b50"}, + {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df4249b579e75094f7e9bb4bd28231acf55e308bf686b952f43100a5a0be394c"}, + {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d0491006a6ad20507aec2be72e7831a42efc93193d2402018007ff827dc62926"}, + {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ae80f72bb7a3e397ab37b53a2b49c62cc5496412e71bc4f1277620a7ce3f52b"}, + {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58aca931bef83217fca7a390e0486ae327c4af9c3e941adb75f8772f8eeb03a1"}, + {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1be91ad664fc9245404a789d60cba1e91c26b1454ba136d2a1bf0c2ac0c0505a"}, + {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:667880321e916a8920ef49f5d50e7983792cf59f3b6079f3c9dac2b88a311d17"}, + {file = "pydantic_core-2.18.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f7054fdc556f5421f01e39cbb767d5ec5c1139ea98c3e5b350e02e62201740c7"}, + {file = "pydantic_core-2.18.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:030e4f9516f9947f38179249778709a460a3adb516bf39b5eb9066fcfe43d0e6"}, + {file = "pydantic_core-2.18.1-cp310-none-win32.whl", hash = "sha256:2e91711e36e229978d92642bfc3546333a9127ecebb3f2761372e096395fc649"}, + {file = "pydantic_core-2.18.1-cp310-none-win_amd64.whl", hash = "sha256:9a29726f91c6cb390b3c2338f0df5cd3e216ad7a938762d11c994bb37552edb0"}, + {file = "pydantic_core-2.18.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9ece8a49696669d483d206b4474c367852c44815fca23ac4e48b72b339807f80"}, + {file = "pydantic_core-2.18.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a5d83efc109ceddb99abd2c1316298ced2adb4570410defe766851a804fcd5b"}, + {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7973c381283783cd1043a8c8f61ea5ce7a3a58b0369f0ee0ee975eaf2f2a1b"}, + {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54c7375c62190a7845091f521add19b0f026bcf6ae674bdb89f296972272e86d"}, + {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd63cec4e26e790b70544ae5cc48d11b515b09e05fdd5eff12e3195f54b8a586"}, + {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:561cf62c8a3498406495cfc49eee086ed2bb186d08bcc65812b75fda42c38294"}, + {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68717c38a68e37af87c4da20e08f3e27d7e4212e99e96c3d875fbf3f4812abfc"}, + {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d5728e93d28a3c63ee513d9ffbac9c5989de8c76e049dbcb5bfe4b923a9739d"}, + {file = "pydantic_core-2.18.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f0f17814c505f07806e22b28856c59ac80cee7dd0fbb152aed273e116378f519"}, + {file = "pydantic_core-2.18.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d816f44a51ba5175394bc6c7879ca0bd2be560b2c9e9f3411ef3a4cbe644c2e9"}, + {file = "pydantic_core-2.18.1-cp311-none-win32.whl", hash = "sha256:09f03dfc0ef8c22622eaa8608caa4a1e189cfb83ce847045eca34f690895eccb"}, + {file = "pydantic_core-2.18.1-cp311-none-win_amd64.whl", hash = "sha256:27f1009dc292f3b7ca77feb3571c537276b9aad5dd4efb471ac88a8bd09024e9"}, + {file = "pydantic_core-2.18.1-cp311-none-win_arm64.whl", hash = "sha256:48dd883db92e92519201f2b01cafa881e5f7125666141a49ffba8b9facc072b0"}, + {file = "pydantic_core-2.18.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b6b0e4912030c6f28bcb72b9ebe4989d6dc2eebcd2a9cdc35fefc38052dd4fe8"}, + {file = "pydantic_core-2.18.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3202a429fe825b699c57892d4371c74cc3456d8d71b7f35d6028c96dfecad31"}, + {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3982b0a32d0a88b3907e4b0dc36809fda477f0757c59a505d4e9b455f384b8b"}, + {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25595ac311f20e5324d1941909b0d12933f1fd2171075fcff763e90f43e92a0d"}, + {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14fe73881cf8e4cbdaded8ca0aa671635b597e42447fec7060d0868b52d074e6"}, + {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca976884ce34070799e4dfc6fbd68cb1d181db1eefe4a3a94798ddfb34b8867f"}, + {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684d840d2c9ec5de9cb397fcb3f36d5ebb6fa0d94734f9886032dd796c1ead06"}, + {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:54764c083bbe0264f0f746cefcded6cb08fbbaaf1ad1d78fb8a4c30cff999a90"}, + {file = "pydantic_core-2.18.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:201713f2f462e5c015b343e86e68bd8a530a4f76609b33d8f0ec65d2b921712a"}, + {file = "pydantic_core-2.18.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fd1a9edb9dd9d79fbeac1ea1f9a8dd527a6113b18d2e9bcc0d541d308dae639b"}, + {file = "pydantic_core-2.18.1-cp312-none-win32.whl", hash = "sha256:d5e6b7155b8197b329dc787356cfd2684c9d6a6b1a197f6bbf45f5555a98d411"}, + {file = "pydantic_core-2.18.1-cp312-none-win_amd64.whl", hash = "sha256:9376d83d686ec62e8b19c0ac3bf8d28d8a5981d0df290196fb6ef24d8a26f0d6"}, + {file = "pydantic_core-2.18.1-cp312-none-win_arm64.whl", hash = "sha256:c562b49c96906b4029b5685075fe1ebd3b5cc2601dfa0b9e16c2c09d6cbce048"}, + {file = "pydantic_core-2.18.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:3e352f0191d99fe617371096845070dee295444979efb8f27ad941227de6ad09"}, + {file = "pydantic_core-2.18.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c0295d52b012cbe0d3059b1dba99159c3be55e632aae1999ab74ae2bd86a33d7"}, + {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56823a92075780582d1ffd4489a2e61d56fd3ebb4b40b713d63f96dd92d28144"}, + {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd3f79e17b56741b5177bcc36307750d50ea0698df6aa82f69c7db32d968c1c2"}, + {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38a5024de321d672a132b1834a66eeb7931959c59964b777e8f32dbe9523f6b1"}, + {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2ce426ee691319d4767748c8e0895cfc56593d725594e415f274059bcf3cb76"}, + {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2adaeea59849ec0939af5c5d476935f2bab4b7f0335b0110f0f069a41024278e"}, + {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9b6431559676a1079eac0f52d6d0721fb8e3c5ba43c37bc537c8c83724031feb"}, + {file = "pydantic_core-2.18.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:85233abb44bc18d16e72dc05bf13848a36f363f83757541f1a97db2f8d58cfd9"}, + {file = "pydantic_core-2.18.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:641a018af4fe48be57a2b3d7a1f0f5dbca07c1d00951d3d7463f0ac9dac66622"}, + {file = "pydantic_core-2.18.1-cp38-none-win32.whl", hash = "sha256:63d7523cd95d2fde0d28dc42968ac731b5bb1e516cc56b93a50ab293f4daeaad"}, + {file = "pydantic_core-2.18.1-cp38-none-win_amd64.whl", hash = "sha256:907a4d7720abfcb1c81619863efd47c8a85d26a257a2dbebdb87c3b847df0278"}, + {file = "pydantic_core-2.18.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:aad17e462f42ddbef5984d70c40bfc4146c322a2da79715932cd8976317054de"}, + {file = "pydantic_core-2.18.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:94b9769ba435b598b547c762184bcfc4783d0d4c7771b04a3b45775c3589ca44"}, + {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80e0e57cc704a52fb1b48f16d5b2c8818da087dbee6f98d9bf19546930dc64b5"}, + {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76b86e24039c35280ceee6dce7e62945eb93a5175d43689ba98360ab31eebc4a"}, + {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a05db5013ec0ca4a32cc6433f53faa2a014ec364031408540ba858c2172bb0"}, + {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:250ae39445cb5475e483a36b1061af1bc233de3e9ad0f4f76a71b66231b07f88"}, + {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a32204489259786a923e02990249c65b0f17235073149d0033efcebe80095570"}, + {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6395a4435fa26519fd96fdccb77e9d00ddae9dd6c742309bd0b5610609ad7fb2"}, + {file = "pydantic_core-2.18.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2533ad2883f001efa72f3d0e733fb846710c3af6dcdd544fe5bf14fa5fe2d7db"}, + {file = "pydantic_core-2.18.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b560b72ed4816aee52783c66854d96157fd8175631f01ef58e894cc57c84f0f6"}, + {file = "pydantic_core-2.18.1-cp39-none-win32.whl", hash = "sha256:582cf2cead97c9e382a7f4d3b744cf0ef1a6e815e44d3aa81af3ad98762f5a9b"}, + {file = "pydantic_core-2.18.1-cp39-none-win_amd64.whl", hash = "sha256:ca71d501629d1fa50ea7fa3b08ba884fe10cefc559f5c6c8dfe9036c16e8ae89"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e178e5b66a06ec5bf51668ec0d4ac8cfb2bdcb553b2c207d58148340efd00143"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:72722ce529a76a4637a60be18bd789d8fb871e84472490ed7ddff62d5fed620d"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fe0c1ce5b129455e43f941f7a46f61f3d3861e571f2905d55cdbb8b5c6f5e2c"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4284c621f06a72ce2cb55f74ea3150113d926a6eb78ab38340c08f770eb9b4d"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a0c3e718f4e064efde68092d9d974e39572c14e56726ecfaeebbe6544521f47"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2027493cc44c23b598cfaf200936110433d9caa84e2c6cf487a83999638a96ac"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:76909849d1a6bffa5a07742294f3fa1d357dc917cb1fe7b470afbc3a7579d539"}, + {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ee7ccc7fb7e921d767f853b47814c3048c7de536663e82fbc37f5eb0d532224b"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ee2794111c188548a4547eccc73a6a8527fe2af6cf25e1a4ebda2fd01cdd2e60"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a139fe9f298dc097349fb4f28c8b81cc7a202dbfba66af0e14be5cfca4ef7ce5"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d074b07a10c391fc5bbdcb37b2f16f20fcd9e51e10d01652ab298c0d07908ee2"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c69567ddbac186e8c0aadc1f324a60a564cfe25e43ef2ce81bcc4b8c3abffbae"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf1c7b78cddb5af00971ad5294a4583188bda1495b13760d9f03c9483bb6203"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2684a94fdfd1b146ff10689c6e4e815f6a01141781c493b97342cdc5b06f4d5d"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:73c1bc8a86a5c9e8721a088df234265317692d0b5cd9e86e975ce3bc3db62a59"}, + {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e60defc3c15defb70bb38dd605ff7e0fae5f6c9c7cbfe0ad7868582cb7e844a6"}, + {file = "pydantic_core-2.18.1.tar.gz", hash = "sha256:de9d3e8717560eb05e28739d1b35e4eac2e458553a52a301e51352a7ffc86a35"}, ] [package.dependencies] @@ -1405,27 +1414,28 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pygments" -version = "2.16.1" +version = "2.17.2" description = "Pygments is a syntax highlighting package written in Python." optional = true python-versions = ">=3.7" files = [ - {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, - {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, + {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, + {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, ] [package.extras] plugins = ["importlib-metadata"] +windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyproject-api" -version = "1.5.4" +version = "1.6.1" description = "API to interact with the python pyproject.toml based projects" optional = false python-versions = ">=3.8" files = [ - {file = "pyproject_api-1.5.4-py3-none-any.whl", hash = "sha256:ca462d457880340ceada078678a296ac500061cef77a040e1143004470ab0046"}, - {file = "pyproject_api-1.5.4.tar.gz", hash = "sha256:8d41f3f0c04f0f6a830c27b1c425fa66699715ae06d8a054a1c5eeaaf8bfb145"}, + {file = "pyproject_api-1.6.1-py3-none-any.whl", hash = "sha256:4c0116d60476b0786c88692cf4e325a9814965e2469c5998b830bba16b183675"}, + {file = "pyproject_api-1.6.1.tar.gz", hash = "sha256:1817dc018adc0d1ff9ca1ed8c60e1623d5aaca40814b953af14a9cf9a5cae538"}, ] [package.dependencies] @@ -1433,18 +1443,18 @@ packaging = ">=23.1" tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} [package.extras] -docs = ["furo (>=2023.7.26)", "sphinx (<7.2)", "sphinx-autodoc-typehints (>=1.24)"] -testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "setuptools (>=68)", "wheel (>=0.41.1)"] +docs = ["furo (>=2023.8.19)", "sphinx (<7.2)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "setuptools (>=68.1.2)", "wheel (>=0.41.2)"] [[package]] name = "pytest" -version = "7.4.0" +version = "8.1.1" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, - {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, + {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, + {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, ] [package.dependencies] @@ -1452,39 +1462,39 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +pluggy = ">=1.4,<2.0" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.21.1" +version = "0.23.6" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d"}, - {file = "pytest_asyncio-0.21.1-py3-none-any.whl", hash = "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b"}, + {file = "pytest-asyncio-0.23.6.tar.gz", hash = "sha256:ffe523a89c1c222598c76856e76852b787504ddb72dd5d9b6617ffa8aa2cde5f"}, + {file = "pytest_asyncio-0.23.6-py3-none-any.whl", hash = "sha256:68516fdd1018ac57b846c9846b954f0393b26f094764a28c955eabb0536a4e8a"}, ] [package.dependencies] -pytest = ">=7.0.0" +pytest = ">=7.0.0,<9" [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] -testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" -version = "4.1.0" +version = "5.0.0" description = "Pytest plugin for measuring coverage." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, - {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, ] [package.dependencies] @@ -1492,34 +1502,34 @@ coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" [package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-mock" -version = "3.11.1" +version = "3.14.0" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-mock-3.11.1.tar.gz", hash = "sha256:7f6b125602ac6d743e523ae0bfa71e1a697a2f5534064528c6ff84c2f7c2fc7f"}, - {file = "pytest_mock-3.11.1-py3-none-any.whl", hash = "sha256:21c279fff83d70763b05f8874cc9cfb3fcacd6d354247a976f9529d19f9acf39"}, + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, ] [package.dependencies] -pytest = ">=5.0" +pytest = ">=6.2.5" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] name = "pytest-sugar" -version = "0.9.7" +version = "1.0.0" description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." optional = false python-versions = "*" files = [ - {file = "pytest-sugar-0.9.7.tar.gz", hash = "sha256:f1e74c1abfa55f7241cf7088032b6e378566f16b938f3f08905e2cf4494edd46"}, - {file = "pytest_sugar-0.9.7-py2.py3-none-any.whl", hash = "sha256:8cb5a4e5f8bbcd834622b0235db9e50432f4cbd71fef55b467fe44e43701e062"}, + {file = "pytest-sugar-1.0.0.tar.gz", hash = "sha256:6422e83258f5b0c04ce7c632176c7732cab5fdb909cb39cca5c9139f81276c0a"}, + {file = "pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd"}, ] [package.dependencies] @@ -1532,13 +1542,13 @@ dev = ["black", "flake8", "pre-commit"] [[package]] name = "pytz" -version = "2023.3" +version = "2024.1" description = "World timezone definitions, modern and historical" optional = true python-versions = "*" files = [ - {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"}, - {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, ] [[package]] @@ -1623,13 +1633,13 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" -version = "13.7.0" +version = "13.7.1" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = true python-versions = ">=3.7.0" files = [ - {file = "rich-13.7.0-py3-none-any.whl", hash = "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235"}, - {file = "rich-13.7.0.tar.gz", hash = "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa"}, + {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, + {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, ] [package.dependencies] @@ -1642,40 +1652,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "68.1.2" +version = "69.5.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-68.1.2-py3-none-any.whl", hash = "sha256:3d8083eed2d13afc9426f227b24fd1659489ec107c0e86cec2ffdde5c92e790b"}, - {file = "setuptools-68.1.2.tar.gz", hash = "sha256:3d4dfa6d95f1b101d695a6160a7626e15583af71a5f52176efa5d39a054d475d"}, + {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"}, + {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5,<=7.1.2)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "sniffio" -version = "1.3.0" +version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" files = [ - {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, - {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] [[package]] @@ -1862,13 +1861,13 @@ test = ["pytest"] [[package]] name = "termcolor" -version = "2.3.0" +version = "2.4.0" description = "ANSI color formatting for output in terminal" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "termcolor-2.3.0-py3-none-any.whl", hash = "sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475"}, - {file = "termcolor-2.3.0.tar.gz", hash = "sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a"}, + {file = "termcolor-2.4.0-py3-none-any.whl", hash = "sha256:9297c0df9c99445c2412e832e882a7884038a25617c60cea2ad69488d4040d63"}, + {file = "termcolor-2.4.0.tar.gz", hash = "sha256:aab9e56047c8ac41ed798fa36d892a37aca6b3e9159f3e0c24bc64a9b3ac7b7a"}, ] [package.extras] @@ -1898,88 +1897,88 @@ files = [ [[package]] name = "tox" -version = "4.10.0" +version = "4.14.2" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" files = [ - {file = "tox-4.10.0-py3-none-any.whl", hash = "sha256:e4a1b1438955a6da548d69a52350054350cf6a126658c20943261c48ed6d4c92"}, - {file = "tox-4.10.0.tar.gz", hash = "sha256:e041b2165375be690aca0ec4d96360c6906451380520e4665bf274f66112be35"}, + {file = "tox-4.14.2-py3-none-any.whl", hash = "sha256:2900c4eb7b716af4a928a7fdc2ed248ad6575294ed7cfae2ea41203937422847"}, + {file = "tox-4.14.2.tar.gz", hash = "sha256:0defb44f6dafd911b61788325741cc6b2e12ea71f987ac025ad4d649f1f1a104"}, ] [package.dependencies] -cachetools = ">=5.3.1" +cachetools = ">=5.3.2" chardet = ">=5.2" colorama = ">=0.4.6" -filelock = ">=3.12.2" -packaging = ">=23.1" -platformdirs = ">=3.10" -pluggy = ">=1.2" -pyproject-api = ">=1.5.3" +filelock = ">=3.13.1" +packaging = ">=23.2" +platformdirs = ">=4.1" +pluggy = ">=1.3" +pyproject-api = ">=1.6.1" tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} -virtualenv = ">=20.24.3" +virtualenv = ">=20.25" [package.extras] -docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.24)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.1.1)", "devpi-process (>=0.3.1)", "diff-cover (>=7.7)", "distlib (>=0.3.7)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.18)", "psutil (>=5.9.5)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-xdist (>=3.3.1)", "re-assert (>=1.1)", "time-machine (>=2.12)", "wheel (>=0.41.1)"] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.25.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"] +testing = ["build[virtualenv] (>=1.0.3)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=8.0.2)", "distlib (>=0.3.8)", "flaky (>=3.7)", "hatch-vcs (>=0.4)", "hatchling (>=1.21)", "psutil (>=5.9.7)", "pytest (>=7.4.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-xdist (>=3.5)", "re-assert (>=1.1)", "time-machine (>=2.13)", "wheel (>=0.42)"] [[package]] name = "typing-extensions" -version = "4.7.1" -description = "Backported and Experimental Type Hints for Python 3.7+" +version = "4.11.0" +description = "Backported and Experimental Type Hints for Python 3.8+" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, - {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, + {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, + {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, ] [[package]] name = "urllib3" -version = "2.0.4" +version = "2.2.1" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "urllib3-2.0.4-py3-none-any.whl", hash = "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4"}, - {file = "urllib3-2.0.4.tar.gz", hash = "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11"}, + {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, + {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, ] [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] +h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.24.3" +version = "20.25.1" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.24.3-py3-none-any.whl", hash = "sha256:95a6e9398b4967fbcb5fef2acec5efaf9aa4972049d9ae41f95e0972a683fd02"}, - {file = "virtualenv-20.24.3.tar.gz", hash = "sha256:e5c3b4ce817b0b328af041506a2a299418c98747c4b1e68cb7527e74ced23efc"}, + {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, + {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<4" +platformdirs = ">=3.9.1,<5" [package.extras] -docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [[package]] name = "voluptuous" -version = "0.13.1" -description = "" +version = "0.14.2" +description = "Python data validation library" optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "voluptuous-0.13.1-py3-none-any.whl", hash = "sha256:4b838b185f5951f2d6e8752b68fcf18bd7a9c26ded8f143f92d6d28f3921a3e6"}, - {file = "voluptuous-0.13.1.tar.gz", hash = "sha256:e8d31c20601d6773cb14d4c0f42aee29c6821bbd1018039aac7ac5605b489723"}, + {file = "voluptuous-0.14.2-py3-none-any.whl", hash = "sha256:efc1dadc9ae32a30cc622602c1400a17b7bf8ee2770d64f70418144860739c3b"}, + {file = "voluptuous-0.14.2.tar.gz", hash = "sha256:533e36175967a310f1b73170d091232bf881403e4ebe52a9b4ade8404d151f5d"}, ] [[package]] @@ -1995,30 +1994,26 @@ files = [ [[package]] name = "xdoctest" -version = "1.1.1" +version = "1.1.3" description = "A rewrite of the builtin doctest module" optional = false python-versions = ">=3.6" files = [ - {file = "xdoctest-1.1.1-py3-none-any.whl", hash = "sha256:d59d4ed91cb92e4430ef0ad1b134a2bef02adff7d2fb9c9f057547bee44081a2"}, - {file = "xdoctest-1.1.1.tar.gz", hash = "sha256:2eac8131bdcdf2781b4e5a62d6de87f044b730cc8db8af142a51bb29c245e779"}, + {file = "xdoctest-1.1.3-py3-none-any.whl", hash = "sha256:9360535bd1a971ffc216d9613898cedceb81d0fd024587cc3c03c74d14c00a31"}, + {file = "xdoctest-1.1.3.tar.gz", hash = "sha256:84e76a42a11a5926ff66d9d84c616bc101821099672550481ad96549cbdd02ae"}, ] -[package.dependencies] -six = "*" - [package.extras] -all = ["IPython", "IPython", "Pygments", "Pygments", "attrs", "codecov", "colorama", "debugpy", "debugpy", "debugpy", "debugpy", "debugpy", "ipykernel", "ipykernel", "ipython-genutils", "jedi", "jinja2", "jupyter-client", "jupyter-client", "jupyter-core", "nbconvert", "pyflakes", "pytest", "pytest", "pytest", "pytest-cov", "six", "tomli", "typing"] -all-strict = ["IPython (==7.10.0)", "IPython (==7.23.1)", "Pygments (==2.0.0)", "Pygments (==2.4.1)", "attrs (==19.2.0)", "codecov (==2.0.15)", "colorama (==0.4.1)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==5.2.0)", "ipykernel (==6.0.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==6.1.5)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "pyflakes (==2.2.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)", "six (==1.11.0)", "tomli (==0.2.0)", "typing (==3.7.4)"] +all = ["IPython (>=7.10.0)", "IPython (>=7.23.1)", "Pygments (>=2.0.0)", "Pygments (>=2.4.1)", "attrs (>=19.2.0)", "colorama (>=0.4.1)", "debugpy (>=1.0.0)", "debugpy (>=1.0.0)", "debugpy (>=1.0.0)", "debugpy (>=1.3.0)", "debugpy (>=1.6.0)", "ipykernel (>=5.2.0)", "ipykernel (>=6.0.0)", "ipykernel (>=6.11.0)", "ipython-genutils (>=0.2.0)", "jedi (>=0.16)", "jinja2 (>=3.0.0)", "jupyter-client (>=6.1.5)", "jupyter-client (>=7.0.0)", "jupyter-core (>=4.7.0)", "nbconvert (>=6.0.0)", "nbconvert (>=6.1.0)", "pyflakes (>=2.2.0)", "pytest (>=4.6.0)", "pytest (>=4.6.0)", "pytest (>=6.2.5)", "pytest-cov (>=3.0.0)", "tomli (>=0.2.0)", "typing (>=3.7.4)"] +all-strict = ["IPython (==7.10.0)", "IPython (==7.23.1)", "Pygments (==2.0.0)", "Pygments (==2.4.1)", "attrs (==19.2.0)", "colorama (==0.4.1)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==5.2.0)", "ipykernel (==6.0.0)", "ipykernel (==6.11.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==6.1.5)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "nbconvert (==6.1.0)", "pyflakes (==2.2.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)", "tomli (==0.2.0)", "typing (==3.7.4)"] colors = ["Pygments", "Pygments", "colorama"] -jupyter = ["IPython", "IPython", "attrs", "debugpy", "debugpy", "debugpy", "debugpy", "debugpy", "ipykernel", "ipykernel", "ipython-genutils", "jedi", "jinja2", "jupyter-client", "jupyter-client", "jupyter-core", "nbconvert"] -optional = ["IPython", "IPython", "Pygments", "Pygments", "attrs", "colorama", "debugpy", "debugpy", "debugpy", "debugpy", "debugpy", "ipykernel", "ipykernel", "ipython-genutils", "jedi", "jinja2", "jupyter-client", "jupyter-client", "jupyter-core", "nbconvert", "pyflakes", "tomli"] -optional-strict = ["IPython (==7.10.0)", "IPython (==7.23.1)", "Pygments (==2.0.0)", "Pygments (==2.4.1)", "attrs (==19.2.0)", "colorama (==0.4.1)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==5.2.0)", "ipykernel (==6.0.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==6.1.5)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "pyflakes (==2.2.0)", "tomli (==0.2.0)"] -runtime-strict = ["six (==1.11.0)"] -tests = ["codecov", "pytest", "pytest", "pytest", "pytest-cov", "typing"] +jupyter = ["IPython", "IPython", "attrs", "debugpy", "debugpy", "debugpy", "debugpy", "debugpy", "ipykernel", "ipykernel", "ipykernel", "ipython-genutils", "jedi", "jinja2", "jupyter-client", "jupyter-client", "jupyter-core", "nbconvert", "nbconvert"] +optional = ["IPython (>=7.10.0)", "IPython (>=7.23.1)", "Pygments (>=2.0.0)", "Pygments (>=2.4.1)", "attrs (>=19.2.0)", "colorama (>=0.4.1)", "debugpy (>=1.0.0)", "debugpy (>=1.0.0)", "debugpy (>=1.0.0)", "debugpy (>=1.3.0)", "debugpy (>=1.6.0)", "ipykernel (>=5.2.0)", "ipykernel (>=6.0.0)", "ipykernel (>=6.11.0)", "ipython-genutils (>=0.2.0)", "jedi (>=0.16)", "jinja2 (>=3.0.0)", "jupyter-client (>=6.1.5)", "jupyter-client (>=7.0.0)", "jupyter-core (>=4.7.0)", "nbconvert (>=6.0.0)", "nbconvert (>=6.1.0)", "pyflakes (>=2.2.0)", "tomli (>=0.2.0)"] +optional-strict = ["IPython (==7.10.0)", "IPython (==7.23.1)", "Pygments (==2.0.0)", "Pygments (==2.4.1)", "attrs (==19.2.0)", "colorama (==0.4.1)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==5.2.0)", "ipykernel (==6.0.0)", "ipykernel (==6.11.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==6.1.5)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "nbconvert (==6.1.0)", "pyflakes (==2.2.0)", "tomli (==0.2.0)"] +tests = ["pytest (>=4.6.0)", "pytest (>=4.6.0)", "pytest (>=6.2.5)", "pytest-cov (>=3.0.0)", "typing (>=3.7.4)"] tests-binary = ["cmake", "cmake", "ninja", "ninja", "pybind11", "pybind11", "scikit-build", "scikit-build"] tests-binary-strict = ["cmake (==3.21.2)", "cmake (==3.25.0)", "ninja (==1.10.2)", "ninja (==1.11.1)", "pybind11 (==2.10.3)", "pybind11 (==2.7.1)", "scikit-build (==0.11.1)", "scikit-build (==0.16.1)"] -tests-strict = ["codecov (==2.0.15)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)", "typing (==3.7.4)"] +tests-strict = ["pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)", "typing (==3.7.4)"] [[package]] name = "yarl" @@ -2125,18 +2120,18 @@ multidict = ">=4.0" [[package]] name = "zipp" -version = "3.16.2" +version = "3.18.1" description = "Backport of pathlib-compatible object wrapper for zip files" optional = true python-versions = ">=3.8" files = [ - {file = "zipp-3.16.2-py3-none-any.whl", hash = "sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0"}, - {file = "zipp-3.16.2.tar.gz", hash = "sha256:ebc15946aa78bd63458992fc81ec3b6f7b1e92d51c35e6de1c3804e73b799147"}, + {file = "zipp-3.18.1-py3-none-any.whl", hash = "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b"}, + {file = "zipp-3.18.1.tar.gz", hash = "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715"}, ] [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] +testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [extras] docs = ["docutils", "myst-parser", "sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput"] diff --git a/pyproject.toml b/pyproject.toml index f3fa470e2..533abd2bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,8 @@ build-backend = "poetry.core.masonry.api" [tool.ruff] target-version = "py38" + +[tool.ruff.lint] select = [ "E", # pycodestyle "D", # pydocstyle @@ -116,10 +118,10 @@ ignore = [ "D107", # Missing docstring in `__init__` ] -[tool.ruff.pydocstyle] +[tool.ruff.lint.pydocstyle] convention = "pep257" -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "kasa/tests/*.py" = [ "D100", "D101", From 700643d3cf692d9537bda4cc400c7a400f0f41a2 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 17 Apr 2024 12:07:16 +0200 Subject: [PATCH 373/892] Add fan module (#764) Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- kasa/device_type.py | 1 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/fanmodule.py | 66 ++++++++++++++++++++++++++++ kasa/smart/smartchilddevice.py | 2 + kasa/smart/smartdevice.py | 7 ++- kasa/smart/smartmodule.py | 2 +- kasa/tests/smart/modules/test_fan.py | 43 ++++++++++++++++++ kasa/tests/test_childdevice.py | 4 +- 8 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 kasa/smart/modules/fanmodule.py create mode 100644 kasa/tests/smart/modules/test_fan.py diff --git a/kasa/device_type.py b/kasa/device_type.py index b6214c17a..34f0bd890 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -16,6 +16,7 @@ class DeviceType(Enum): LightStrip = "lightstrip" Sensor = "sensor" Hub = "hub" + Fan = "fan" Unknown = "unknown" @staticmethod diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 1a45bf1f4..e2da5b690 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -9,6 +9,7 @@ from .colortemp import ColorTemperatureModule from .devicemodule import DeviceModule from .energymodule import EnergyModule +from .fanmodule import FanModule from .firmware import Firmware from .humidity import HumiditySensor from .ledmodule import LedModule @@ -30,6 +31,7 @@ "AutoOffModule", "LedModule", "Brightness", + "FanModule", "Firmware", "CloudModule", "LightTransitionModule", diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fanmodule.py new file mode 100644 index 000000000..4734aa91c --- /dev/null +++ b/kasa/smart/modules/fanmodule.py @@ -0,0 +1,66 @@ +"""Implementation of fan_control module.""" +from typing import TYPE_CHECKING, Dict + +from ...feature import Feature, FeatureType +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class FanModule(SmartModule): + """Implementation of fan_control module.""" + + REQUIRED_COMPONENT = "fan_control" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + + self._add_feature( + Feature( + device, + "Fan speed level", + container=self, + attribute_getter="fan_speed_level", + attribute_setter="set_fan_speed_level", + icon="mdi:fan", + type=FeatureType.Number, + minimum_value=1, + maximum_value=4, + ) + ) + self._add_feature( + Feature( + device, + "Fan sleep mode", + container=self, + attribute_getter="sleep_mode", + attribute_setter="set_sleep_mode", + icon="mdi:sleep", + type=FeatureType.Switch + ) + ) + + def query(self) -> Dict: + """Query to execute during the update cycle.""" + return {} + + @property + def fan_speed_level(self) -> int: + """Return fan speed level.""" + return self.data["fan_speed_level"] + + async def set_fan_speed_level(self, level: int): + """Set fan speed level.""" + if level < 1 or level > 4: + raise ValueError("Invalid level, should be in range 1-4.") + return await self.call("set_device_info", {"fan_speed_level": level}) + + @property + def sleep_mode(self) -> bool: + """Return sleep mode status.""" + return self.data["fan_sleep_mode_on"] + + async def set_sleep_mode(self, on: bool): + """Set sleep mode.""" + return await self.call("set_device_info", {"fan_sleep_mode_on": on}) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index e8d8c208e..6289dbc0a 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -49,6 +49,8 @@ def device_type(self) -> DeviceType: child_device_map = { "plug.powerstrip.sub-plug": DeviceType.Plug, "subg.trigger.temp-hmdt-sensor": DeviceType.Sensor, + "kasa.switch.outlet.sub-fan": DeviceType.Fan, + "kasa.switch.outlet.sub-dimmer": DeviceType.Dimmer, } dev_type = child_device_map.get(self.sys_info["category"]) if dev_type is None: diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 909341a1c..331cf66e5 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -314,12 +314,11 @@ def internal_state(self) -> Any: return self._last_update def _update_internal_state(self, info): - """Update internal state. + """Update the internal info state. - This is used by the parent to push updates to its children + This is used by the parent to push updates to its children. """ - # TODO: cleanup the _last_update, _info mess. - self._last_update = self._info = info + self._info = info async def _query_helper( self, method: str, params: Optional[Dict] = None, child_ids=None diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 4756a4249..20580975d 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -62,7 +62,7 @@ def data(self): q = self.query() if not q: - return dev.internal_state["get_device_info"] + return dev.sys_info q_keys = list(q.keys()) query_key = q_keys[0] diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py new file mode 100644 index 000000000..7c7ad9d86 --- /dev/null +++ b/kasa/tests/smart/modules/test_fan.py @@ -0,0 +1,43 @@ +from pytest_mock import MockerFixture + +from kasa import SmartDevice +from kasa.smart.modules import FanModule +from kasa.tests.device_fixtures import parametrize + +fan = parametrize( + "has fan", component_filter="fan_control", protocol_filter={"SMART.CHILD"} +) + + +@fan +async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): + """Test fan speed feature.""" + fan: FanModule = dev.modules["FanModule"] + level_feature = fan._module_features["fan_speed_level"] + assert level_feature.minimum_value <= level_feature.value <= level_feature.maximum_value + + call = mocker.spy(fan, "call") + await fan.set_fan_speed_level(3) + call.assert_called_with("set_device_info", {"fan_sleep_level": 3}) + + await dev.update() + + assert fan.fan_speed_level == 3 + assert level_feature.value == 3 + + +@fan +async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): + """Test sleep mode feature.""" + fan: FanModule = dev.modules["FanModule"] + sleep_feature = fan._module_features["fan_sleep_mode"] + assert isinstance(sleep_feature.value, bool) + + call = mocker.spy(fan, "call") + await fan.set_sleep_mode(True) + call.assert_called_with("set_device_info", {"fan_sleep_mode_on": True}) + + await dev.update() + + assert fan.sleep_mode is True + assert sleep_feature.value is True diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 97d3fd376..64ad70fa1 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -32,8 +32,8 @@ async def test_childdevice_update(dev, dummy_protocol, mocker): await dev.update() - assert dev._last_update != first._last_update - assert child_list[0] == first._last_update + assert dev._info != first._info + assert child_list[0] == first._info @strip_smart From 82d92aeea5f2636bd7122e0eea1f72fda6e59d36 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 17 Apr 2024 13:33:10 +0200 Subject: [PATCH 374/892] smartbulb: Limit brightness range to 1-100 (#829) The allowed brightness for tested light devices (L530, L900) is [1-100] instead of [0-100] like it was for some kasa devices. --- kasa/smart/smartbulb.py | 34 +++++--------------- kasa/tests/smart/features/test_brightness.py | 2 +- kasa/tests/test_smartdevice.py | 25 +++++++++++++- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/kasa/smart/smartbulb.py b/kasa/smart/smartbulb.py index d7e9372f2..365130c72 100644 --- a/kasa/smart/smartbulb.py +++ b/kasa/smart/smartbulb.py @@ -28,8 +28,7 @@ def is_color(self) -> bool: @property def is_dimmable(self) -> bool: """Whether the bulb supports brightness changes.""" - # TODO: this makes an assumption that only dimmables report this - return "brightness" in self._info + return "Brightness" in self.modules @property def is_variable_color_temp(self) -> bool: @@ -188,6 +187,11 @@ async def set_color_temp( return await self.protocol.query({"set_device_info": {"color_temp": temp}}) + def _raise_for_invalid_brightness(self, value: int): + """Raise error on invalid brightness value.""" + if not isinstance(value, int) or not (1 <= value <= 100): + raise ValueError(f"Invalid brightness value: {value} (valid range: 1-100%)") + async def set_brightness( self, brightness: int, *, transition: Optional[int] = None ) -> Dict: @@ -201,25 +205,12 @@ async def set_brightness( if not self.is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") + self._raise_for_invalid_brightness(brightness) + return await self.protocol.query( {"set_device_info": {"brightness": brightness}} ) - # Default state information, should be made to settings - """ - "info": { - "default_states": { - "re_power_type": "always_on", - "type": "last_states", - "state": { - "brightness": 36, - "hue": 0, - "saturation": 0, - "color_temp": 2700, - }, - }, - """ - async def set_effect( self, effect: str, @@ -229,15 +220,6 @@ async def set_effect( ) -> None: """Set an effect on the device.""" raise NotImplementedError() - # TODO: the code below does to activate the effect but gives no error - return await self.protocol.query( - { - "set_device_info": { - "dynamic_light_effect_enable": 1, - "dynamic_light_effect_id": effect, - } - } - ) @property def presets(self) -> List[BulbPreset]: diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index 72bc36373..eb8572691 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -16,7 +16,7 @@ async def test_brightness_component(dev: SmartDevice): # Test getting the value feature = dev.features["brightness"] assert isinstance(feature.value, int) - assert feature.value > 0 and feature.value <= 100 + assert feature.value > 1 and feature.value <= 100 # Test setting the value await feature.set_value(10) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 77ed99787..584897b82 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -8,9 +8,10 @@ from kasa import KasaException from kasa.exceptions import SmartErrorCode -from kasa.smart import SmartDevice +from kasa.smart import SmartBulb, SmartDevice from .conftest import ( + bulb_smart, device_smart, ) @@ -103,3 +104,25 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): full_query = {**full_query, **mod.query()} query.assert_called_with(full_query) + + +@bulb_smart +async def test_smartdevice_brightness(dev: SmartBulb): + """Test brightness setter and getter.""" + assert isinstance(dev, SmartDevice) + assert "brightness" in dev._components + + # Test getting the value + feature = dev.features["brightness"] + assert feature.minimum_value == 1 + assert feature.maximum_value == 100 + + await dev.set_brightness(10) + await dev.update() + assert dev.brightness == 10 + + with pytest.raises(ValueError): + await dev.set_brightness(feature.minimum_value - 10) + + with pytest.raises(ValueError): + await dev.set_brightness(feature.maximum_value + 10) From 203bd79253f948e38de02b541bf8bae531dabd1c Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 17 Apr 2024 14:39:24 +0100 Subject: [PATCH 375/892] Enable and convert to future annotations (#838) --- devtools/dump_devinfo.py | 13 +-- devtools/helpers/smartrequests.py | 91 +++++++++++---------- kasa/aestransport.py | 26 +++--- kasa/bulb.py | 32 ++++---- kasa/cli.py | 6 +- kasa/device.py | 76 ++++++++--------- kasa/device_factory.py | 30 +++---- kasa/device_type.py | 4 +- kasa/deviceconfig.py | 8 +- kasa/discover.py | 66 +++++++-------- kasa/effects.py | 6 +- kasa/emeterstatus.py | 11 +-- kasa/exceptions.py | 6 +- kasa/feature.py | 14 ++-- kasa/httpclient.py | 18 ++-- kasa/iot/iotbulb.py | 40 ++++----- kasa/iot/iotdevice.py | 74 +++++++++-------- kasa/iot/iotdimmer.py | 20 ++--- kasa/iot/iotlightstrip.py | 16 ++-- kasa/iot/iotplug.py | 11 +-- kasa/iot/iotstrip.py | 46 +++++------ kasa/iot/modules/emeter.py | 17 ++-- kasa/iot/modules/motion.py | 5 +- kasa/iot/modules/rulemodule.py | 12 +-- kasa/iot/modules/usage.py | 13 +-- kasa/iotprotocol.py | 17 ++-- kasa/klaptransport.py | 18 ++-- kasa/module.py | 7 +- kasa/protocol.py | 9 +- kasa/smart/modules/alarmmodule.py | 12 +-- kasa/smart/modules/autooffmodule.py | 10 ++- kasa/smart/modules/battery.py | 4 +- kasa/smart/modules/brightness.py | 8 +- kasa/smart/modules/cloudmodule.py | 4 +- kasa/smart/modules/colortemp.py | 8 +- kasa/smart/modules/devicemodule.py | 4 +- kasa/smart/modules/energymodule.py | 14 ++-- kasa/smart/modules/fanmodule.py | 11 ++- kasa/smart/modules/firmware.py | 18 ++-- kasa/smart/modules/humidity.py | 4 +- kasa/smart/modules/ledmodule.py | 8 +- kasa/smart/modules/lighttransitionmodule.py | 4 +- kasa/smart/modules/reportmodule.py | 4 +- kasa/smart/modules/temperature.py | 4 +- kasa/smart/modules/timemodule.py | 4 +- kasa/smart/smartbulb.py | 26 +++--- kasa/smart/smartchilddevice.py | 9 +- kasa/smart/smartdevice.py | 48 +++++------ kasa/smart/smartmodule.py | 10 ++- kasa/smartprotocol.py | 16 ++-- kasa/tests/conftest.py | 5 +- kasa/tests/device_fixtures.py | 9 +- kasa/tests/discovery_fixtures.py | 7 +- kasa/tests/fixtureinfo.py | 20 +++-- kasa/tests/smart/modules/test_fan.py | 6 +- kasa/tests/test_aestransport.py | 14 ++-- kasa/tests/test_smartdevice.py | 6 +- kasa/xortransport.py | 14 ++-- pyproject.toml | 1 + 59 files changed, 562 insertions(+), 462 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 87c703e3f..238522e64 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -8,6 +8,8 @@ and finally execute a query to query all of them at once. """ +from __future__ import annotations + import base64 import collections.abc import json @@ -17,7 +19,6 @@ from collections import defaultdict, namedtuple from pathlib import Path from pprint import pprint -from typing import Dict, List, Union import asyncclick as click @@ -143,7 +144,7 @@ def default_to_regular(d): async def handle_device(basedir, autosave, device: Device, batch_size: int): """Create a fixture for a single device instance.""" if isinstance(device, SmartDevice): - fixture_results: List[FixtureResult] = await get_smart_fixtures( + fixture_results: list[FixtureResult] = await get_smart_fixtures( device, batch_size ) else: @@ -344,12 +345,12 @@ def _echo_error(msg: str): async def _make_requests_or_exit( device: SmartDevice, - requests: List[SmartRequest], + requests: list[SmartRequest], name: str, batch_size: int, *, child_device_id: str, -) -> Dict[str, Dict]: +) -> dict[str, dict]: final = {} protocol = ( device.protocol @@ -362,7 +363,7 @@ async def _make_requests_or_exit( for i in range(0, end, step): x = i requests_step = requests[x : x + step] - request: Union[List[SmartRequest], SmartRequest] = ( + request: list[SmartRequest] | SmartRequest = ( requests_step[0] if len(requests_step) == 1 else requests_step ) responses = await protocol.query(SmartRequest._create_request_dict(request)) @@ -586,7 +587,7 @@ async def get_smart_fixtures(device: SmartDevice, batch_size: int): finally: await device.protocol.close() - device_requests: Dict[str, List[SmartRequest]] = {} + device_requests: dict[str, list[SmartRequest]] = {} for success in successes: device_request = device_requests.setdefault(success.child_device_id, []) device_request.append(success.request) diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 1ece6c872..881488b5e 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -25,9 +25,10 @@ """ +from __future__ import annotations + import logging from dataclasses import asdict, dataclass -from typing import List, Optional, Union _LOGGER = logging.getLogger(__name__) @@ -35,7 +36,7 @@ class SmartRequest: """Class to represent a smart protocol request.""" - def __init__(self, method_name: str, params: Optional["SmartRequestParams"] = None): + def __init__(self, method_name: str, params: SmartRequestParams | None = None): self.method_name = method_name if params: self.params = params.to_dict() @@ -93,7 +94,7 @@ class GetTriggerLogsParams(SmartRequestParams): class LedStatusParams(SmartRequestParams): """LED Status params.""" - led_rule: Optional[str] = None + led_rule: str | None = None @staticmethod def from_bool(state: bool): @@ -105,42 +106,42 @@ def from_bool(state: bool): class LightInfoParams(SmartRequestParams): """LightInfo params.""" - brightness: Optional[int] = None - color_temp: Optional[int] = None - hue: Optional[int] = None - saturation: Optional[int] = None + brightness: int | None = None + color_temp: int | None = None + hue: int | None = None + saturation: int | None = None @dataclass class DynamicLightEffectParams(SmartRequestParams): """LightInfo params.""" enable: bool - id: Optional[str] = None + id: str | None = None @staticmethod def get_raw_request( - method: str, params: Optional[SmartRequestParams] = None - ) -> "SmartRequest": + method: str, params: SmartRequestParams | None = None + ) -> SmartRequest: """Send a raw request to the device.""" return SmartRequest(method, params) @staticmethod - def component_nego() -> "SmartRequest": + def component_nego() -> SmartRequest: """Get quick setup component info.""" return SmartRequest("component_nego") @staticmethod - def get_device_info() -> "SmartRequest": + def get_device_info() -> SmartRequest: """Get device info.""" return SmartRequest("get_device_info") @staticmethod - def get_device_usage() -> "SmartRequest": + def get_device_usage() -> SmartRequest: """Get device usage.""" return SmartRequest("get_device_usage") @staticmethod - def device_info_list(ver_code) -> List["SmartRequest"]: + def device_info_list(ver_code) -> list[SmartRequest]: """Get device info list.""" if ver_code == 1: return [SmartRequest.get_device_info()] @@ -151,12 +152,12 @@ def device_info_list(ver_code) -> List["SmartRequest"]: ] @staticmethod - def get_auto_update_info() -> "SmartRequest": + def get_auto_update_info() -> SmartRequest: """Get auto update info.""" return SmartRequest("get_auto_update_info") @staticmethod - def firmware_info_list() -> List["SmartRequest"]: + def firmware_info_list() -> list[SmartRequest]: """Get info list.""" return [ SmartRequest.get_raw_request("get_fw_download_state"), @@ -164,48 +165,48 @@ def firmware_info_list() -> List["SmartRequest"]: ] @staticmethod - def qs_component_nego() -> "SmartRequest": + def qs_component_nego() -> SmartRequest: """Get quick setup component info.""" return SmartRequest("qs_component_nego") @staticmethod - def get_device_time() -> "SmartRequest": + def get_device_time() -> SmartRequest: """Get device time.""" return SmartRequest("get_device_time") @staticmethod - def get_child_device_list() -> "SmartRequest": + def get_child_device_list() -> SmartRequest: """Get child device list.""" return SmartRequest("get_child_device_list") @staticmethod - def get_child_device_component_list() -> "SmartRequest": + def get_child_device_component_list() -> SmartRequest: """Get child device component list.""" return SmartRequest("get_child_device_component_list") @staticmethod def get_wireless_scan_info( - params: Optional[GetRulesParams] = None, - ) -> "SmartRequest": + params: GetRulesParams | None = None, + ) -> SmartRequest: """Get wireless scan info.""" return SmartRequest( "get_wireless_scan_info", params or SmartRequest.GetRulesParams() ) @staticmethod - def get_schedule_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest": + def get_schedule_rules(params: GetRulesParams | None = None) -> SmartRequest: """Get schedule rules.""" return SmartRequest( "get_schedule_rules", params or SmartRequest.GetScheduleRulesParams() ) @staticmethod - def get_next_event(params: Optional[GetRulesParams] = None) -> "SmartRequest": + def get_next_event(params: GetRulesParams | None = None) -> SmartRequest: """Get next scheduled event.""" return SmartRequest("get_next_event", params or SmartRequest.GetRulesParams()) @staticmethod - def schedule_info_list() -> List["SmartRequest"]: + def schedule_info_list() -> list[SmartRequest]: """Get schedule info list.""" return [ SmartRequest.get_schedule_rules(), @@ -213,38 +214,38 @@ def schedule_info_list() -> List["SmartRequest"]: ] @staticmethod - def get_countdown_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest": + def get_countdown_rules(params: GetRulesParams | None = None) -> SmartRequest: """Get countdown rules.""" return SmartRequest( "get_countdown_rules", params or SmartRequest.GetRulesParams() ) @staticmethod - def get_antitheft_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest": + def get_antitheft_rules(params: GetRulesParams | None = None) -> SmartRequest: """Get antitheft rules.""" return SmartRequest( "get_antitheft_rules", params or SmartRequest.GetRulesParams() ) @staticmethod - def get_led_info(params: Optional[LedStatusParams] = None) -> "SmartRequest": + def get_led_info(params: LedStatusParams | None = None) -> SmartRequest: """Get led info.""" return SmartRequest("get_led_info", params or SmartRequest.LedStatusParams()) @staticmethod - def get_auto_off_config(params: Optional[GetRulesParams] = None) -> "SmartRequest": + def get_auto_off_config(params: GetRulesParams | None = None) -> SmartRequest: """Get auto off config.""" return SmartRequest( "get_auto_off_config", params or SmartRequest.GetRulesParams() ) @staticmethod - def get_delay_action_info() -> "SmartRequest": + def get_delay_action_info() -> SmartRequest: """Get delay action info.""" return SmartRequest("get_delay_action_info") @staticmethod - def auto_off_list() -> List["SmartRequest"]: + def auto_off_list() -> list[SmartRequest]: """Get energy usage.""" return [ SmartRequest.get_auto_off_config(), @@ -252,12 +253,12 @@ def auto_off_list() -> List["SmartRequest"]: ] @staticmethod - def get_energy_usage() -> "SmartRequest": + def get_energy_usage() -> SmartRequest: """Get energy usage.""" return SmartRequest("get_energy_usage") @staticmethod - def energy_monitoring_list() -> List["SmartRequest"]: + def energy_monitoring_list() -> list[SmartRequest]: """Get energy usage.""" return [ SmartRequest("get_energy_usage"), @@ -265,12 +266,12 @@ def energy_monitoring_list() -> List["SmartRequest"]: ] @staticmethod - def get_current_power() -> "SmartRequest": + def get_current_power() -> SmartRequest: """Get current power.""" return SmartRequest("get_current_power") @staticmethod - def power_protection_list() -> List["SmartRequest"]: + def power_protection_list() -> list[SmartRequest]: """Get power protection info list.""" return [ SmartRequest.get_current_power(), @@ -279,45 +280,45 @@ def power_protection_list() -> List["SmartRequest"]: ] @staticmethod - def get_preset_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest": + def get_preset_rules(params: GetRulesParams | None = None) -> SmartRequest: """Get preset rules.""" return SmartRequest("get_preset_rules", params or SmartRequest.GetRulesParams()) @staticmethod - def get_auto_light_info() -> "SmartRequest": + def get_auto_light_info() -> SmartRequest: """Get auto light info.""" return SmartRequest("get_auto_light_info") @staticmethod def get_dynamic_light_effect_rules( - params: Optional[GetRulesParams] = None, - ) -> "SmartRequest": + params: GetRulesParams | None = None, + ) -> SmartRequest: """Get dynamic light effect rules.""" return SmartRequest( "get_dynamic_light_effect_rules", params or SmartRequest.GetRulesParams() ) @staticmethod - def set_device_on(params: DeviceOnParams) -> "SmartRequest": + def set_device_on(params: DeviceOnParams) -> SmartRequest: """Set device on state.""" return SmartRequest("set_device_info", params) @staticmethod - def set_light_info(params: LightInfoParams) -> "SmartRequest": + def set_light_info(params: LightInfoParams) -> SmartRequest: """Set color temperature.""" return SmartRequest("set_device_info", params) @staticmethod def set_dynamic_light_effect_rule_enable( params: DynamicLightEffectParams, - ) -> "SmartRequest": + ) -> SmartRequest: """Enable dynamic light effect rule.""" return SmartRequest("set_dynamic_light_effect_rule_enable", params) @staticmethod - def get_component_info_requests(component_nego_response) -> List["SmartRequest"]: + def get_component_info_requests(component_nego_response) -> list[SmartRequest]: """Get a list of requests based on the component info response.""" - request_list: List["SmartRequest"] = [] + request_list: list[SmartRequest] = [] for component in component_nego_response["component_list"]: if ( requests := get_component_requests( @@ -329,7 +330,7 @@ def get_component_info_requests(component_nego_response) -> List["SmartRequest"] @staticmethod def _create_request_dict( - smart_request: Union["SmartRequest", List["SmartRequest"]], + smart_request: SmartRequest | list[SmartRequest], ) -> dict: """Create request dict to be passed to SmartProtocol.query().""" if isinstance(smart_request, list): diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 3b8bfe5d0..85624abc5 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -4,13 +4,15 @@ under compatible GNU GPL3 license. """ +from __future__ import annotations + import asyncio import base64 import hashlib import logging import time from enum import Enum, auto -from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, Optional, Tuple, cast +from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, cast from cryptography.hazmat.primitives import padding, serialization from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding @@ -92,19 +94,19 @@ def __init__( self._login_params = json_loads( base64.b64decode(self._credentials_hash.encode()).decode() # type: ignore[union-attr] ) - self._default_credentials: Optional[Credentials] = None + self._default_credentials: Credentials | None = None self._http_client: HttpClient = HttpClient(config) self._state = TransportState.HANDSHAKE_REQUIRED - self._encryption_session: Optional[AesEncyptionSession] = None - self._session_expire_at: Optional[float] = None + self._encryption_session: AesEncyptionSession | None = None + self._session_expire_at: float | None = None - self._session_cookie: Optional[Dict[str, str]] = None + self._session_cookie: dict[str, str] | None = None - self._key_pair: Optional[KeyPair] = None + self._key_pair: KeyPair | None = None self._app_url = URL(f"http://{self._host}:{self._port}/app") - self._token_url: Optional[URL] = None + self._token_url: URL | None = None _LOGGER.debug("Created AES transport for %s", self._host) @@ -118,14 +120,14 @@ def credentials_hash(self) -> str: """The hashed credentials used by the transport.""" return base64.b64encode(json_dumps(self._login_params).encode()).decode() - def _get_login_params(self, credentials: Credentials) -> Dict[str, str]: + def _get_login_params(self, credentials: Credentials) -> dict[str, str]: """Get the login parameters based on the login_version.""" un, pw = self.hash_credentials(self._login_version == 2, credentials) password_field_name = "password2" if self._login_version == 2 else "password" return {password_field_name: pw, "username": un} @staticmethod - def hash_credentials(login_v2: bool, credentials: Credentials) -> Tuple[str, str]: + 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: @@ -148,7 +150,7 @@ def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: raise AuthenticationError(msg, error_code=error_code) raise DeviceError(msg, error_code=error_code) - async def send_secure_passthrough(self, request: str) -> Dict[str, Any]: + async def send_secure_passthrough(self, request: str) -> dict[str, Any]: """Send encrypted message as passthrough.""" if self._state is TransportState.ESTABLISHED and self._token_url: url = self._token_url @@ -230,7 +232,7 @@ async def perform_login(self): ex, ) from ex - async def try_login(self, login_params: Dict[str, Any]) -> None: + async def try_login(self, login_params: dict[str, Any]) -> None: """Try to login with supplied login_params.""" login_request = { "method": "login_device", @@ -333,7 +335,7 @@ def _handshake_session_expired(self): or self._session_expire_at - time.time() <= 0 ) - async def send(self, request: str) -> Dict[str, Any]: + async def send(self, request: str) -> dict[str, Any]: """Send the request.""" if ( self._state is TransportState.HANDSHAKE_REQUIRED diff --git a/kasa/bulb.py b/kasa/bulb.py index 5050e593e..50c5d2437 100644 --- a/kasa/bulb.py +++ b/kasa/bulb.py @@ -1,7 +1,9 @@ """Module for Device base class.""" +from __future__ import annotations + from abc import ABC, abstractmethod -from typing import Dict, List, NamedTuple, Optional +from typing import NamedTuple, Optional from .device import Device @@ -33,14 +35,14 @@ class BulbPreset(BaseModel): brightness: int # These are not available for effect mode presets on light strips - hue: Optional[int] - saturation: Optional[int] - color_temp: Optional[int] + hue: Optional[int] # noqa: UP007 + saturation: Optional[int] # noqa: UP007 + color_temp: Optional[int] # noqa: UP007 # Variables for effect mode presets - custom: Optional[int] - id: Optional[str] - mode: Optional[int] + custom: Optional[int] # noqa: UP007 + id: Optional[str] # noqa: UP007 + mode: Optional[int] # noqa: UP007 class Bulb(Device, ABC): @@ -101,10 +103,10 @@ async def set_hsv( self, hue: int, saturation: int, - value: Optional[int] = None, + value: int | None = None, *, - transition: Optional[int] = None, - ) -> Dict: + transition: int | None = None, + ) -> dict: """Set new HSV. Note, transition is not supported and will be ignored. @@ -117,8 +119,8 @@ async def set_hsv( @abstractmethod async def set_color_temp( - self, temp: int, *, brightness=None, transition: Optional[int] = None - ) -> Dict: + self, temp: int, *, brightness=None, transition: int | None = None + ) -> dict: """Set the color temperature of the device in kelvin. Note, transition is not supported and will be ignored. @@ -129,8 +131,8 @@ async def set_color_temp( @abstractmethod async def set_brightness( - self, brightness: int, *, transition: Optional[int] = None - ) -> Dict: + self, brightness: int, *, transition: int | None = None + ) -> dict: """Set the brightness in percentage. Note, transition is not supported and will be ignored. @@ -141,5 +143,5 @@ async def set_brightness( @property @abstractmethod - def presets(self) -> List[BulbPreset]: + def presets(self) -> list[BulbPreset]: """Return a list of available bulb setting presets.""" diff --git a/kasa/cli.py b/kasa/cli.py index d30c46300..41a7759e3 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1,5 +1,7 @@ """python-kasa cli tool.""" +from __future__ import annotations + import ast import asyncio import json @@ -9,7 +11,7 @@ from contextlib import asynccontextmanager from functools import singledispatch, wraps from pprint import pformat as pf -from typing import Any, Dict, cast +from typing import Any, cast import asyncclick as click @@ -320,7 +322,7 @@ def _nop_echo(*args, **kwargs): global _do_echo echo = _do_echo - logging_config: Dict[str, Any] = { + logging_config: dict[str, Any] = { "level": logging.DEBUG if debug > 0 else logging.INFO } try: diff --git a/kasa/device.py b/kasa/device.py index 3c5537b1a..a4c2b5e3a 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -1,10 +1,12 @@ """Module for Device base class.""" +from __future__ import annotations + import logging from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict, List, Mapping, Optional, Sequence, Union +from typing import Any, Mapping, Sequence from .credentials import Credentials from .device_type import DeviceType @@ -24,13 +26,13 @@ class WifiNetwork: ssid: str key_type: int # These are available only on softaponboarding - cipher_type: Optional[int] = None - bssid: Optional[str] = None - channel: Optional[int] = None - rssi: Optional[int] = None + cipher_type: int | None = None + bssid: str | None = None + channel: int | None = None + rssi: int | None = None # For SMART devices - signal_level: Optional[int] = None + signal_level: int | None = None _LOGGER = logging.getLogger(__name__) @@ -48,8 +50,8 @@ def __init__( self, host: str, *, - config: Optional[DeviceConfig] = None, - protocol: Optional[BaseProtocol] = None, + config: DeviceConfig | None = None, + protocol: BaseProtocol | None = None, ) -> None: """Create a new Device instance. @@ -68,19 +70,19 @@ def __init__( # checks in accessors. the @updated_required decorator does not ensure # mypy that these are not accessed incorrectly. self._last_update: Any = None - self._discovery_info: Optional[Dict[str, Any]] = None + self._discovery_info: dict[str, Any] | None = None - self.modules: Dict[str, Any] = {} - self._features: Dict[str, Feature] = {} - self._parent: Optional["Device"] = None - self._children: Mapping[str, "Device"] = {} + self.modules: dict[str, Any] = {} + self._features: dict[str, Feature] = {} + self._parent: Device | None = None + self._children: Mapping[str, Device] = {} @staticmethod async def connect( *, - host: Optional[str] = None, - config: Optional[DeviceConfig] = None, - ) -> "Device": + host: str | None = None, + config: DeviceConfig | None = None, + ) -> Device: """Connect to a single device by the given hostname or device configuration. This method avoids the UDP based discovery process and @@ -120,11 +122,11 @@ def is_off(self) -> bool: return not self.is_on @abstractmethod - async def turn_on(self, **kwargs) -> Optional[Dict]: + async def turn_on(self, **kwargs) -> dict | None: """Turn on the device.""" @abstractmethod - async def turn_off(self, **kwargs) -> Optional[Dict]: + async def turn_off(self, **kwargs) -> dict | None: """Turn off the device.""" @property @@ -147,12 +149,12 @@ def port(self) -> int: return self.protocol._transport._port @property - def credentials(self) -> Optional[Credentials]: + def credentials(self) -> Credentials | None: """The device credentials.""" return self.protocol._transport._credentials @property - def credentials_hash(self) -> Optional[str]: + def credentials_hash(self) -> str | None: """The protocol specific hash of the credentials the device is using.""" return self.protocol._transport.credentials_hash @@ -177,25 +179,25 @@ def model(self) -> str: @property @abstractmethod - def alias(self) -> Optional[str]: + def alias(self) -> str | None: """Returns the device alias or nickname.""" - async def _raw_query(self, request: Union[str, Dict]) -> Any: + async def _raw_query(self, request: str | dict) -> Any: """Send a raw query to the device.""" return await self.protocol.query(request=request) @property - def children(self) -> Sequence["Device"]: + def children(self) -> Sequence[Device]: """Returns the child devices.""" return list(self._children.values()) - def get_child_device(self, id_: str) -> "Device": + def get_child_device(self, id_: str) -> Device: """Return child device by its ID.""" return self._children[id_] @property @abstractmethod - def sys_info(self) -> Dict[str, Any]: + def sys_info(self) -> dict[str, Any]: """Returns the device info.""" @property @@ -248,7 +250,7 @@ def is_color(self) -> bool: """Return True if the device supports color changes.""" return False - def get_plug_by_name(self, name: str) -> "Device": + def get_plug_by_name(self, name: str) -> Device: """Return child device for the given name.""" for p in self.children: if p.alias == name: @@ -256,7 +258,7 @@ def get_plug_by_name(self, name: str) -> "Device": raise KasaException(f"Device has no child with {name}") - def get_plug_by_index(self, index: int) -> "Device": + def get_plug_by_index(self, index: int) -> Device: """Return child device for the given index.""" if index + 1 > len(self.children) or index < 0: raise KasaException( @@ -271,22 +273,22 @@ def time(self) -> datetime: @property @abstractmethod - def timezone(self) -> Dict: + def timezone(self) -> dict: """Return the timezone and time_difference.""" @property @abstractmethod - def hw_info(self) -> Dict: + def hw_info(self) -> dict: """Return hardware info for the device.""" @property @abstractmethod - def location(self) -> Dict: + def location(self) -> dict: """Return the device location.""" @property @abstractmethod - def rssi(self) -> Optional[int]: + def rssi(self) -> int | None: """Return the rssi.""" @property @@ -305,12 +307,12 @@ def internal_state(self) -> Any: """Return all the internal state data.""" @property - def state_information(self) -> Dict[str, Any]: + def state_information(self) -> dict[str, Any]: """Return available features and their values.""" return {feat.name: feat.value for feat in self._features.values()} @property - def features(self) -> Dict[str, Feature]: + def features(self) -> dict[str, Feature]: """Return the list of supported features.""" return self._features @@ -328,7 +330,7 @@ def has_emeter(self) -> bool: @property @abstractmethod - def on_since(self) -> Optional[datetime]: + def on_since(self) -> datetime | None: """Return the time that the device was turned on or None if turned off.""" @abstractmethod @@ -342,18 +344,18 @@ def emeter_realtime(self) -> EmeterStatus: @property @abstractmethod - def emeter_this_month(self) -> Optional[float]: + def emeter_this_month(self) -> float | None: """Get the emeter value for this month.""" @property @abstractmethod - def emeter_today(self) -> Union[Optional[float], Any]: + def emeter_today(self) -> float | None | Any: """Get the emeter value for today.""" # Return type of Any ensures consumers being shielded from the return # type by @update_required are not affected. @abstractmethod - async def wifi_scan(self) -> List[WifiNetwork]: + async def wifi_scan(self) -> list[WifiNetwork]: """Scan for available wifi networks.""" @abstractmethod diff --git a/kasa/device_factory.py b/kasa/device_factory.py index a40bc0850..3c0ae7164 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -1,8 +1,10 @@ """Device creation via DeviceConfig.""" +from __future__ import annotations + import logging import time -from typing import Any, Dict, Optional, Tuple, Type +from typing import Any from .aestransport import AesTransport from .device import Device @@ -35,7 +37,7 @@ } -async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Device": +async def connect(*, host: str | None = None, config: DeviceConfig) -> Device: """Connect to a single device by the given hostname or device configuration. This method avoids the UDP based discovery process and @@ -72,7 +74,7 @@ async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Devic raise -async def _connect(config: DeviceConfig, protocol: BaseProtocol) -> "Device": +async def _connect(config: DeviceConfig, protocol: BaseProtocol) -> Device: debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) if debug_enabled: start_time = time.perf_counter() @@ -87,8 +89,8 @@ def _perf_log(has_params, perf_type): ) start_time = time.perf_counter() - device_class: Optional[Type[Device]] - device: Optional[Device] = None + device_class: type[Device] | None + device: Device | None = None if isinstance(protocol, IotProtocol) and isinstance( protocol._transport, XorTransport @@ -115,13 +117,13 @@ def _perf_log(has_params, perf_type): ) -def _get_device_type_from_sys_info(info: Dict[str, Any]) -> DeviceType: +def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType: """Find SmartDevice subclass for device described by passed data.""" if "system" not in info or "get_sysinfo" not in info["system"]: raise KasaException("No 'system' or 'get_sysinfo' in response") - sysinfo: Dict[str, Any] = info["system"]["get_sysinfo"] - type_: Optional[str] = sysinfo.get("type", sysinfo.get("mic_type")) + sysinfo: dict[str, Any] = info["system"]["get_sysinfo"] + type_: str | None = sysinfo.get("type", sysinfo.get("mic_type")) if type_ is None: raise KasaException("Unable to find the device type field!") @@ -143,7 +145,7 @@ def _get_device_type_from_sys_info(info: Dict[str, Any]) -> DeviceType: raise UnsupportedDeviceError("Unknown device type: %s" % type_) -def get_device_class_from_sys_info(sysinfo: Dict[str, Any]) -> Type[IotDevice]: +def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]: """Find SmartDevice subclass for device described by passed data.""" TYPE_TO_CLASS = { DeviceType.Bulb: IotBulb, @@ -156,9 +158,9 @@ def get_device_class_from_sys_info(sysinfo: Dict[str, Any]) -> Type[IotDevice]: return TYPE_TO_CLASS[_get_device_type_from_sys_info(sysinfo)] -def get_device_class_from_family(device_type: str) -> Optional[Type[Device]]: +def get_device_class_from_family(device_type: str) -> type[Device] | None: """Return the device class from the type name.""" - supported_device_types: Dict[str, Type[Device]] = { + supported_device_types: dict[str, type[Device]] = { "SMART.TAPOPLUG": SmartDevice, "SMART.TAPOBULB": SmartBulb, "SMART.TAPOSWITCH": SmartBulb, @@ -173,14 +175,14 @@ def get_device_class_from_family(device_type: str) -> Optional[Type[Device]]: def get_protocol( config: DeviceConfig, -) -> Optional[BaseProtocol]: +) -> BaseProtocol | None: """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[BaseProtocol], Type[BaseTransport]] + supported_device_protocols: dict[ + str, tuple[type[BaseProtocol], type[BaseTransport]] ] = { "IOT.XOR": (IotProtocol, XorTransport), "IOT.KLAP": (IotProtocol, KlapTransport), diff --git a/kasa/device_type.py b/kasa/device_type.py index 34f0bd890..6a97867cc 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -1,5 +1,7 @@ """TP-Link device types.""" +from __future__ import annotations + from enum import Enum @@ -20,7 +22,7 @@ class DeviceType(Enum): Unknown = "unknown" @staticmethod - def from_value(name: str) -> "DeviceType": + def from_value(name: str) -> DeviceType: """Return device type from string value.""" for device_type in DeviceType: if device_type.value == name: diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 827fd03a8..6ddff6ade 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -1,5 +1,11 @@ -"""Module for holding connection parameters.""" +"""Module for holding connection parameters. +Note that this module does not work with from __future__ import annotations +due to it's use of type returned by fields() which becomes a string with the import. +https://bugs.python.org/issue39442 +""" + +# ruff: noqa: FA100 import logging from dataclasses import asdict, dataclass, field, fields, is_dataclass from enum import Enum diff --git a/kasa/discover.py b/kasa/discover.py index a5d88b99a..d727b2f86 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -1,11 +1,13 @@ """Discovery module for TP-Link Smart Home devices.""" +from __future__ import annotations + import asyncio import binascii import ipaddress import logging import socket -from typing import Awaitable, Callable, Dict, List, Optional, Set, Type, cast +from typing import Awaitable, Callable, Dict, Optional, Type, cast # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -38,6 +40,7 @@ OnDiscoveredCallable = Callable[[Device], Awaitable[None]] +OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Awaitable[None]] DeviceDict = Dict[str, Device] @@ -54,17 +57,15 @@ class _DiscoverProtocol(asyncio.DatagramProtocol): def __init__( self, *, - on_discovered: Optional[OnDiscoveredCallable] = None, + on_discovered: OnDiscoveredCallable | None = None, target: str = "255.255.255.255", discovery_packets: int = 3, discovery_timeout: int = 5, - interface: Optional[str] = None, - on_unsupported: Optional[ - Callable[[UnsupportedDeviceError], Awaitable[None]] - ] = None, - port: Optional[int] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, + interface: str | None = None, + on_unsupported: OnUnsupportedCallable | None = None, + port: int | None = None, + credentials: Credentials | None = None, + timeout: int | None = None, ) -> None: self.transport = None self.discovery_packets = discovery_packets @@ -78,15 +79,15 @@ def __init__( self.target_2 = (target, Discover.DISCOVERY_PORT_2) self.discovered_devices = {} - self.unsupported_device_exceptions: Dict = {} - self.invalid_device_exceptions: Dict = {} + self.unsupported_device_exceptions: dict = {} + self.invalid_device_exceptions: dict = {} self.on_unsupported = on_unsupported self.credentials = credentials self.timeout = timeout self.discovery_timeout = discovery_timeout - self.seen_hosts: Set[str] = set() - self.discover_task: Optional[asyncio.Task] = None - self.callback_tasks: List[asyncio.Task] = [] + self.seen_hosts: set[str] = set() + self.discover_task: asyncio.Task | None = None + self.callback_tasks: list[asyncio.Task] = [] self.target_discovered: bool = False self._started_event = asyncio.Event() @@ -148,7 +149,7 @@ def datagram_received(self, data, addr) -> None: return self.seen_hosts.add(ip) - device: Optional[Device] = None + device: Device | None = None config = DeviceConfig(host=ip, port_override=self.port) if self.credentials: @@ -328,9 +329,9 @@ async def discover_single( host: str, *, discovery_timeout: int = 5, - port: Optional[int] = None, - timeout: Optional[int] = None, - credentials: Optional[Credentials] = None, + port: int | None = None, + timeout: int | None = None, + credentials: Credentials | None = None, ) -> Device: """Discover a single device by the given IP address. @@ -403,7 +404,7 @@ async def discover_single( raise TimeoutError(f"Timed out getting discovery response for {host}") @staticmethod - def _get_device_class(info: dict) -> Type[Device]: + def _get_device_class(info: dict) -> type[Device]: """Find SmartDevice subclass for device described by passed data.""" if "result" in info: discovery_result = DiscoveryResult(**info["result"]) @@ -502,16 +503,17 @@ def _get_device_instance( return device -class DiscoveryResult(BaseModel): - """Base model for discovery result.""" +class EncryptionScheme(BaseModel): + """Base model for encryption scheme of discovery result.""" - class EncryptionScheme(BaseModel): - """Base model for encryption scheme of discovery result.""" + is_support_https: bool + encrypt_type: str + http_port: int + lv: Optional[int] = None # noqa: UP007 - is_support_https: bool - encrypt_type: str - http_port: int - lv: Optional[int] = None + +class DiscoveryResult(BaseModel): + """Base model for discovery result.""" device_type: str device_model: str @@ -520,11 +522,11 @@ class EncryptionScheme(BaseModel): mgt_encrypt_schm: EncryptionScheme device_id: str - hw_ver: Optional[str] = None - owner: Optional[str] = None - is_support_iot_cloud: Optional[bool] = None - obd_src: Optional[str] = None - factory_default: Optional[bool] = None + hw_ver: Optional[str] = None # noqa: UP007 + owner: Optional[str] = None # noqa: UP007 + is_support_iot_cloud: Optional[bool] = None # noqa: UP007 + obd_src: Optional[str] = None # noqa: UP007 + factory_default: Optional[bool] = None # noqa: UP007 def get_dict(self) -> dict: """Return a dict for this discovery result. diff --git a/kasa/effects.py b/kasa/effects.py index cf72bb8d8..8b3e7b329 100644 --- a/kasa/effects.py +++ b/kasa/effects.py @@ -1,6 +1,8 @@ """Module for light strip effects (LB*, KL*, KB*).""" -from typing import List, cast +from __future__ import annotations + +from typing import cast EFFECT_AURORA = { "custom": 0, @@ -292,5 +294,5 @@ EFFECT_VALENTINES, ] -EFFECT_NAMES_V1: List[str] = [cast(str, effect["name"]) for effect in EFFECTS_LIST_V1] +EFFECT_NAMES_V1: list[str] = [cast(str, effect["name"]) for effect in EFFECTS_LIST_V1] EFFECT_MAPPING_V1 = {effect["name"]: effect for effect in EFFECTS_LIST_V1} diff --git a/kasa/emeterstatus.py b/kasa/emeterstatus.py index 540424997..41a43bc76 100644 --- a/kasa/emeterstatus.py +++ b/kasa/emeterstatus.py @@ -1,7 +1,8 @@ """Module for emeter container.""" +from __future__ import annotations + import logging -from typing import Optional _LOGGER = logging.getLogger(__name__) @@ -17,7 +18,7 @@ class EmeterStatus(dict): """ @property - def voltage(self) -> Optional[float]: + def voltage(self) -> float | None: """Return voltage in V.""" try: return self["voltage"] @@ -25,7 +26,7 @@ def voltage(self) -> Optional[float]: return None @property - def power(self) -> Optional[float]: + def power(self) -> float | None: """Return power in W.""" try: return self["power"] @@ -33,7 +34,7 @@ def power(self) -> Optional[float]: return None @property - def current(self) -> Optional[float]: + def current(self) -> float | None: """Return current in A.""" try: return self["current"] @@ -41,7 +42,7 @@ def current(self) -> Optional[float]: return None @property - def total(self) -> Optional[float]: + def total(self) -> float | None: """Return total in kWh.""" try: return self["total"] diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 9b91204a2..567f01b49 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -1,8 +1,10 @@ """python-kasa exceptions.""" +from __future__ import annotations + from asyncio import TimeoutError as _asyncioTimeoutError from enum import IntEnum -from typing import Any, Optional +from typing import Any class KasaException(Exception): @@ -35,7 +37,7 @@ class DeviceError(KasaException): """Base exception for device errors.""" def __init__(self, *args: Any, **kwargs: Any) -> None: - self.error_code: Optional["SmartErrorCode"] = kwargs.get("error_code", None) + self.error_code: SmartErrorCode | None = kwargs.get("error_code", None) super().__init__(*args) def __repr__(self): diff --git a/kasa/feature.py b/kasa/feature.py index 60b436700..a04e1140a 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -1,8 +1,10 @@ """Generic interface for defining device features.""" +from __future__ import annotations + from dataclasses import dataclass from enum import Enum, auto -from typing import TYPE_CHECKING, Any, Callable, Optional, Union +from typing import TYPE_CHECKING, Any, Callable if TYPE_CHECKING: from .device import Device @@ -23,17 +25,17 @@ class Feature: """Feature defines a generic interface for device features.""" #: Device instance required for getting and setting values - device: "Device" + device: Device #: User-friendly short description name: str #: Name of the property that allows accessing the value - attribute_getter: Union[str, Callable] + attribute_getter: str | Callable #: Name of the method that allows changing the value - attribute_setter: Optional[str] = None + attribute_setter: str | None = None #: Container storing the data, this overrides 'device' for getters container: Any = None #: Icon suggestion - icon: Optional[str] = None + icon: str | None = None #: Type of the feature type: FeatureType = FeatureType.Sensor @@ -44,7 +46,7 @@ class Feature: maximum_value: int = 2**16 # Arbitrary max #: Attribute containing the name of the range getter property. #: If set, this property will be used to set *minimum_value* and *maximum_value*. - range_getter: Optional[str] = None + range_getter: str | None = None def __post_init__(self): """Handle late-binding of members.""" diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 3240897cd..55ac5a8ee 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -1,8 +1,10 @@ """Module for HttpClientSession class.""" +from __future__ import annotations + import asyncio import logging -from typing import Any, Dict, Optional, Tuple, Union +from typing import Any, Dict import aiohttp from yarl import URL @@ -48,12 +50,12 @@ async def post( self, url: URL, *, - params: Optional[Dict[str, Any]] = None, - data: Optional[bytes] = 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]]]: + params: dict[str, Any] | None = None, + data: bytes | None = None, + json: dict | Any | None = None, + headers: dict[str, str] | None = None, + cookies_dict: dict[str, str] | None = None, + ) -> tuple[int, dict | bytes | None]: """Send an http post request to the device. If the request is provided via the json parameter json will be returned. @@ -103,7 +105,7 @@ async def post( return resp.status, response_data - def get_cookie(self, cookie_name: str) -> Optional[str]: + def get_cookie(self, cookie_name: str) -> str | None: """Return the cookie with cookie_name.""" if cookie := self.client.cookie_jar.filter_cookies(self._last_url).get( cookie_name diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 1bf198af0..26f40f06c 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -1,9 +1,11 @@ """Module for bulbs (LB*, KL*, KB*).""" +from __future__ import annotations + import logging import re from enum import Enum -from typing import Dict, List, Optional, cast +from typing import Optional, cast try: from pydantic.v1 import BaseModel, Field, root_validator @@ -40,7 +42,7 @@ class TurnOnBehavior(BaseModel): """ #: Index of preset to use, or ``None`` for the last known state. - preset: Optional[int] = Field(alias="index", default=None) + preset: Optional[int] = Field(alias="index", default=None) # noqa: UP007 #: Wanted behavior mode: BehaviorMode @@ -193,8 +195,8 @@ def __init__( self, host: str, *, - config: Optional[DeviceConfig] = None, - protocol: Optional[BaseProtocol] = None, + config: DeviceConfig | None = None, + protocol: BaseProtocol | None = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Bulb @@ -275,7 +277,7 @@ def valid_temperature_range(self) -> ColorTempRange: @property # type: ignore @requires_update - def light_state(self) -> Dict[str, str]: + def light_state(self) -> dict[str, str]: """Query the light state.""" light_state = self.sys_info["light_state"] if light_state is None: @@ -298,7 +300,7 @@ def has_effects(self) -> bool: """Return True if the device supports effects.""" return "lighting_effect_state" in self.sys_info - async def get_light_details(self) -> Dict[str, int]: + async def get_light_details(self) -> dict[str, int]: """Return light details. Example:: @@ -325,14 +327,14 @@ async def set_turn_on_behavior(self, behavior: TurnOnBehaviors): self.LIGHT_SERVICE, "set_default_behavior", behavior.dict(by_alias=True) ) - async def get_light_state(self) -> Dict[str, Dict]: + async def get_light_state(self) -> dict[str, dict]: """Query the light state.""" # TODO: add warning and refer to use light.state? return await self._query_helper(self.LIGHT_SERVICE, "get_light_state") async def set_light_state( - self, state: Dict, *, transition: Optional[int] = None - ) -> Dict: + self, state: dict, *, transition: int | None = None + ) -> dict: """Set the light state.""" if transition is not None: state["transition_period"] = transition @@ -378,10 +380,10 @@ async def set_hsv( self, hue: int, saturation: int, - value: Optional[int] = None, + value: int | None = None, *, - transition: Optional[int] = None, - ) -> Dict: + transition: int | None = None, + ) -> dict: """Set new HSV. :param int hue: hue in degrees @@ -424,8 +426,8 @@ def color_temp(self) -> int: @requires_update async def set_color_temp( - self, temp: int, *, brightness=None, transition: Optional[int] = None - ) -> Dict: + self, temp: int, *, brightness=None, transition: int | None = None + ) -> dict: """Set the color temperature of the device in kelvin. :param int temp: The new color temperature, in Kelvin @@ -460,8 +462,8 @@ def brightness(self) -> int: @requires_update async def set_brightness( - self, brightness: int, *, transition: Optional[int] = None - ) -> Dict: + self, brightness: int, *, transition: int | None = None + ) -> dict: """Set the brightness in percentage. :param int brightness: brightness in percent @@ -482,14 +484,14 @@ def is_on(self) -> bool: light_state = self.light_state return bool(light_state["on_off"]) - async def turn_off(self, *, transition: Optional[int] = None, **kwargs) -> Dict: + async def turn_off(self, *, transition: int | None = None, **kwargs) -> dict: """Turn the bulb off. :param int transition: transition in milliseconds. """ return await self.set_light_state({"on_off": 0}, transition=transition) - async def turn_on(self, *, transition: Optional[int] = None, **kwargs) -> Dict: + async def turn_on(self, *, transition: int | None = None, **kwargs) -> dict: """Turn the bulb on. :param int transition: transition in milliseconds. @@ -513,7 +515,7 @@ async def set_alias(self, alias: str) -> None: @property # type: ignore @requires_update - def presets(self) -> List[BulbPreset]: + def presets(self) -> list[BulbPreset]: """Return a list of available bulb setting presets.""" return [BulbPreset(**vals) for vals in self.sys_info["preferred_state"]] diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 8c93f0166..32781a54c 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -12,12 +12,14 @@ http://www.apache.org/licenses/LICENSE-2.0 """ +from __future__ import annotations + import collections.abc import functools import inspect import logging from datetime import datetime, timedelta -from typing import Any, Dict, List, Mapping, Optional, Sequence, Set +from typing import Any, Mapping, Sequence from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig @@ -66,7 +68,7 @@ def wrapped(*args, **kwargs): @functools.lru_cache -def _parse_features(features: str) -> Set[str]: +def _parse_features(features: str) -> set[str]: """Parse features string.""" return set(features.split(":")) @@ -177,19 +179,19 @@ def __init__( self, host: str, *, - config: Optional[DeviceConfig] = None, - protocol: Optional[BaseProtocol] = None, + config: DeviceConfig | None = None, + protocol: BaseProtocol | None = None, ) -> None: """Create a new IotDevice instance.""" super().__init__(host=host, config=config, protocol=protocol) self._sys_info: Any = None # TODO: this is here to avoid changing tests - self._supported_modules: Optional[Dict[str, IotModule]] = None - self._legacy_features: Set[str] = set() - self._children: Mapping[str, "IotDevice"] = {} + self._supported_modules: dict[str, IotModule] | None = None + self._legacy_features: set[str] = set() + self._children: Mapping[str, IotDevice] = {} @property - def children(self) -> Sequence["IotDevice"]: + def children(self) -> Sequence[IotDevice]: """Return list of children.""" return list(self._children.values()) @@ -203,9 +205,9 @@ def add_module(self, name: str, module: IotModule): self.modules[name] = module def _create_request( - self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None + self, target: str, cmd: str, arg: dict | None = None, child_ids=None ): - request: Dict[str, Any] = {target: {cmd: arg}} + request: dict[str, Any] = {target: {cmd: arg}} if child_ids is not None: request = {"context": {"child_ids": child_ids}, target: {cmd: arg}} @@ -219,7 +221,7 @@ def _verify_emeter(self) -> None: raise KasaException("update() required prior accessing emeter") async def _query_helper( - self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None + self, target: str, cmd: str, arg: dict | None = None, child_ids=None ) -> Any: """Query device, return results or raise an exception. @@ -256,13 +258,13 @@ async def _query_helper( @property # type: ignore @requires_update - def features(self) -> Dict[str, Feature]: + def features(self) -> dict[str, Feature]: """Return a set of features that the device supports.""" return self._features @property # type: ignore @requires_update - def supported_modules(self) -> List[str]: + def supported_modules(self) -> list[str]: """Return a set of modules supported by the device.""" # TODO: this should rather be called `features`, but we don't want to break # the API now. Maybe just deprecate it and point the users to use this? @@ -274,7 +276,7 @@ def has_emeter(self) -> bool: """Return True if device has an energy meter.""" return "ENE" in self._legacy_features - async def get_sys_info(self) -> Dict[str, Any]: + async def get_sys_info(self) -> dict[str, Any]: """Retrieve system information.""" return await self._query_helper("system", "get_sysinfo") @@ -363,12 +365,12 @@ async def _modular_update(self, req: dict) -> None: # responses on top of it so we remember # which modules are not supported, otherwise # every other update will query for them - update: Dict = self._last_update.copy() if self._last_update else {} + update: dict = self._last_update.copy() if self._last_update else {} for response in responses: update = {**update, **response} self._last_update = update - def update_from_discover_info(self, info: Dict[str, Any]) -> None: + def update_from_discover_info(self, info: dict[str, Any]) -> None: """Update state from info from the discover call.""" self._discovery_info = info if "system" in info and (sys_info := info["system"].get("get_sysinfo")): @@ -380,7 +382,7 @@ def update_from_discover_info(self, info: Dict[str, Any]) -> None: # by the requires_update decorator self._set_sys_info(info) - def _set_sys_info(self, sys_info: Dict[str, Any]) -> None: + def _set_sys_info(self, sys_info: dict[str, Any]) -> None: """Set sys_info.""" self._sys_info = sys_info if features := sys_info.get("feature"): @@ -388,7 +390,7 @@ def _set_sys_info(self, sys_info: Dict[str, Any]) -> None: @property # type: ignore @requires_update - def sys_info(self) -> Dict[str, Any]: + def sys_info(self) -> dict[str, Any]: """ Return system information. @@ -405,7 +407,7 @@ def model(self) -> str: return str(sys_info["model"]) @property # type: ignore - def alias(self) -> Optional[str]: + def alias(self) -> str | None: """Return device name (alias).""" sys_info = self._sys_info return sys_info.get("alias") if sys_info else None @@ -422,18 +424,18 @@ def time(self) -> datetime: @property # type: ignore @requires_update - def timezone(self) -> Dict: + def timezone(self) -> dict: """Return the current timezone.""" return self.modules["time"].timezone - async def get_time(self) -> Optional[datetime]: + async def get_time(self) -> datetime | None: """Return current time from the device, if available.""" _LOGGER.warning( "Use `time` property instead, this call will be removed in the future." ) return await self.modules["time"].get_time() - async def get_timezone(self) -> Dict: + async def get_timezone(self) -> dict: """Return timezone information.""" _LOGGER.warning( "Use `timezone` property instead, this call will be removed in the future." @@ -442,7 +444,7 @@ async def get_timezone(self) -> Dict: @property # type: ignore @requires_update - def hw_info(self) -> Dict: + def hw_info(self) -> dict: """Return hardware information. This returns just a selection of sysinfo keys that are related to hardware. @@ -464,7 +466,7 @@ def hw_info(self) -> Dict: @property # type: ignore @requires_update - def location(self) -> Dict: + def location(self) -> dict: """Return geographical location.""" sys_info = self._sys_info loc = {"latitude": None, "longitude": None} @@ -482,7 +484,7 @@ def location(self) -> Dict: @property # type: ignore @requires_update - def rssi(self) -> Optional[int]: + def rssi(self) -> int | None: """Return WiFi signal strength (rssi).""" rssi = self._sys_info.get("rssi") return None if rssi is None else int(rssi) @@ -528,21 +530,21 @@ async def get_emeter_realtime(self) -> EmeterStatus: @property # type: ignore @requires_update - def emeter_today(self) -> Optional[float]: + def emeter_today(self) -> float | None: """Return today's energy consumption in kWh.""" self._verify_emeter() return self.modules["emeter"].emeter_today @property # type: ignore @requires_update - def emeter_this_month(self) -> Optional[float]: + def emeter_this_month(self) -> float | None: """Return this month's energy consumption in kWh.""" self._verify_emeter() return self.modules["emeter"].emeter_this_month async def get_emeter_daily( - self, year: Optional[int] = None, month: Optional[int] = None, kwh: bool = True - ) -> Dict: + self, year: int | None = None, month: int | None = None, kwh: bool = True + ) -> dict: """Retrieve daily statistics for a given month. :param year: year for which to retrieve statistics (default: this year) @@ -556,8 +558,8 @@ async def get_emeter_daily( @requires_update async def get_emeter_monthly( - self, year: Optional[int] = None, kwh: bool = True - ) -> Dict: + self, year: int | None = None, kwh: bool = True + ) -> dict: """Retrieve monthly statistics for a given year. :param year: year for which to retrieve statistics (default: this year) @@ -568,7 +570,7 @@ async def get_emeter_monthly( return await self.modules["emeter"].get_monthstat(year=year, kwh=kwh) @requires_update - async def erase_emeter_stats(self) -> Dict: + async def erase_emeter_stats(self) -> dict: """Erase energy meter statistics.""" self._verify_emeter() return await self.modules["emeter"].erase_stats() @@ -588,11 +590,11 @@ async def reboot(self, delay: int = 1) -> None: """ await self._query_helper("system", "reboot", {"delay": delay}) - async def turn_off(self, **kwargs) -> Dict: + async def turn_off(self, **kwargs) -> dict: """Turn off the device.""" raise NotImplementedError("Device subclass needs to implement this.") - async def turn_on(self, **kwargs) -> Optional[Dict]: + async def turn_on(self, **kwargs) -> dict | None: """Turn device on.""" raise NotImplementedError("Device subclass needs to implement this.") @@ -604,7 +606,7 @@ def is_on(self) -> bool: @property # type: ignore @requires_update - def on_since(self) -> Optional[datetime]: + def on_since(self) -> datetime | None: """Return pretty-printed on-time, or None if not available.""" if "on_time" not in self._sys_info: return None @@ -626,7 +628,7 @@ def device_id(self) -> str: """ return self.mac - async def wifi_scan(self) -> List[WifiNetwork]: # noqa: D202 + async def wifi_scan(self) -> list[WifiNetwork]: # noqa: D202 """Scan for available wifi networks.""" async def _scan(target): diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index fd0ff139f..9c8c8f55a 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -1,7 +1,9 @@ """Module for dimmers (currently only HS220).""" +from __future__ import annotations + from enum import Enum -from typing import Any, Dict, Optional +from typing import Any from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -72,8 +74,8 @@ def __init__( self, host: str, *, - config: Optional[DeviceConfig] = None, - protocol: Optional[BaseProtocol] = None, + config: DeviceConfig | None = None, + protocol: BaseProtocol | None = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Dimmer @@ -112,9 +114,7 @@ def brightness(self) -> int: return int(sys_info["brightness"]) @requires_update - async def set_brightness( - self, brightness: int, *, transition: Optional[int] = None - ): + async def set_brightness(self, brightness: int, *, transition: int | None = None): """Set the new dimmer brightness level in percentage. :param int transition: transition duration in milliseconds. @@ -143,7 +143,7 @@ async def set_brightness( self.DIMMER_SERVICE, "set_brightness", {"brightness": brightness} ) - async def turn_off(self, *, transition: Optional[int] = None, **kwargs): + async def turn_off(self, *, transition: int | None = None, **kwargs): """Turn the bulb off. :param int transition: transition duration in milliseconds. @@ -154,7 +154,7 @@ async def turn_off(self, *, transition: Optional[int] = None, **kwargs): return await super().turn_off() @requires_update - async def turn_on(self, *, transition: Optional[int] = None, **kwargs): + async def turn_on(self, *, transition: int | None = None, **kwargs): """Turn the bulb on. :param int transition: transition duration in milliseconds. @@ -202,7 +202,7 @@ async def get_behaviors(self): @requires_update async def set_button_action( - self, action_type: ActionType, action: ButtonAction, index: Optional[int] = None + self, action_type: ActionType, action: ButtonAction, index: int | None = None ): """Set action to perform on button click/hold. @@ -213,7 +213,7 @@ async def set_button_action( """ action_type_setter = f"set_{action_type}" - payload: Dict[str, Any] = {"mode": str(action)} + payload: dict[str, Any] = {"mode": str(action)} if index is not None: payload["index"] = index diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 77b948f9a..57b3282f7 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -1,6 +1,6 @@ """Module for light strips (KL430).""" -from typing import Dict, List, Optional +from __future__ import annotations from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -49,8 +49,8 @@ def __init__( self, host: str, *, - config: Optional[DeviceConfig] = None, - protocol: Optional[BaseProtocol] = None, + config: DeviceConfig | None = None, + protocol: BaseProtocol | None = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.LightStrip @@ -63,7 +63,7 @@ def length(self) -> int: @property # type: ignore @requires_update - def effect(self) -> Dict: + def effect(self) -> dict: """Return effect state. Example: @@ -77,7 +77,7 @@ def effect(self) -> Dict: @property # type: ignore @requires_update - def effect_list(self) -> Optional[List[str]]: + def effect_list(self) -> list[str] | None: """Return built-in effects list. Example: @@ -90,8 +90,8 @@ async def set_effect( self, effect: str, *, - brightness: Optional[int] = None, - transition: Optional[int] = None, + brightness: int | None = None, + transition: int | None = None, ) -> None: """Set an effect on the device. @@ -118,7 +118,7 @@ async def set_effect( @requires_update async def set_custom_effect( self, - effect_dict: Dict, + effect_dict: dict, ) -> None: """Set a custom effect on the device. diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 0a67debf5..c584131dc 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -1,7 +1,8 @@ """Module for smart plugs (HS100, HS110, ..).""" +from __future__ import annotations + import logging -from typing import Optional from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -47,8 +48,8 @@ def __init__( self, host: str, *, - config: Optional[DeviceConfig] = None, - protocol: Optional[BaseProtocol] = None, + config: DeviceConfig | None = None, + protocol: BaseProtocol | None = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug @@ -108,8 +109,8 @@ def __init__( self, host: str, *, - config: Optional[DeviceConfig] = None, - protocol: Optional[BaseProtocol] = None, + config: DeviceConfig | None = None, + protocol: BaseProtocol | None = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.WallSwitch diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 1860c8fec..e1fdabae3 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -1,9 +1,11 @@ """Module for multi-socket devices (HS300, HS107, KP303, ..).""" +from __future__ import annotations + import logging from collections import defaultdict from datetime import datetime, timedelta -from typing import Any, DefaultDict, Dict, Optional +from typing import Any from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -23,7 +25,7 @@ def merge_sums(dicts): """Merge the sum of dicts.""" - total_dict: DefaultDict[int, float] = defaultdict(lambda: 0.0) + total_dict: defaultdict[int, float] = defaultdict(lambda: 0.0) for sum_dict in dicts: for day, value in sum_dict.items(): total_dict[day] += value @@ -86,8 +88,8 @@ def __init__( self, host: str, *, - config: Optional[DeviceConfig] = None, - protocol: Optional[BaseProtocol] = None, + config: DeviceConfig | None = None, + protocol: BaseProtocol | None = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self.emeter_type = "emeter" @@ -137,7 +139,7 @@ async def turn_off(self, **kwargs): @property # type: ignore @requires_update - def on_since(self) -> Optional[datetime]: + def on_since(self) -> datetime | None: """Return the maximum on-time of all outlets.""" if self.is_off: return None @@ -170,8 +172,8 @@ async def get_emeter_realtime(self) -> EmeterStatus: @requires_update async def get_emeter_daily( - self, year: Optional[int] = None, month: Optional[int] = None, kwh: bool = True - ) -> Dict: + self, year: int | None = None, month: int | None = None, kwh: bool = True + ) -> dict: """Retrieve daily statistics for a given month. :param year: year for which to retrieve statistics (default: this year) @@ -186,8 +188,8 @@ async def get_emeter_daily( @requires_update async def get_emeter_monthly( - self, year: Optional[int] = None, kwh: bool = True - ) -> Dict: + self, year: int | None = None, kwh: bool = True + ) -> dict: """Retrieve monthly statistics for a given year. :param year: year for which to retrieve statistics (default: this year) @@ -197,7 +199,7 @@ async def get_emeter_monthly( "get_emeter_monthly", {"year": year, "kwh": kwh} ) - async def _async_get_emeter_sum(self, func: str, kwargs: Dict[str, Any]) -> Dict: + async def _async_get_emeter_sum(self, func: str, kwargs: dict[str, Any]) -> dict: """Retreive emeter stats for a time period from children.""" self._verify_emeter() return merge_sums( @@ -212,13 +214,13 @@ async def erase_emeter_stats(self): @property # type: ignore @requires_update - def emeter_this_month(self) -> Optional[float]: + def emeter_this_month(self) -> float | None: """Return this month's energy consumption in kWh.""" return sum(plug.emeter_this_month for plug in self.children) @property # type: ignore @requires_update - def emeter_today(self) -> Optional[float]: + def emeter_today(self) -> float | None: """Return this month's energy consumption in kWh.""" return sum(plug.emeter_today for plug in self.children) @@ -243,7 +245,7 @@ class IotStripPlug(IotPlug): The plug inherits (most of) the system information from the parent. """ - def __init__(self, host: str, parent: "IotStrip", child_id: str) -> None: + def __init__(self, host: str, parent: IotStrip, child_id: str) -> None: super().__init__(host) self.parent = parent @@ -262,16 +264,14 @@ async def update(self, update_children: bool = True): """ await self._modular_update({}) - def _create_emeter_request( - self, year: Optional[int] = None, month: Optional[int] = None - ): + def _create_emeter_request(self, year: int | None = None, month: int | None = None): """Create a request for requesting all emeter statistics at once.""" if year is None: year = datetime.now().year if month is None: month = datetime.now().month - req: Dict[str, Any] = {} + req: dict[str, Any] = {} merge(req, self._create_request("emeter", "get_realtime")) merge(req, self._create_request("emeter", "get_monthstat", {"year": year})) @@ -285,16 +285,16 @@ def _create_emeter_request( return req def _create_request( - self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None + self, target: str, cmd: str, arg: dict | None = None, child_ids=None ): - request: Dict[str, Any] = { + request: dict[str, Any] = { "context": {"child_ids": [self.child_id]}, target: {cmd: arg}, } return request async def _query_helper( - self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None + self, target: str, cmd: str, arg: dict | None = None, child_ids=None ) -> Any: """Override query helper to include the child_ids.""" return await self.parent._query_helper( @@ -335,14 +335,14 @@ def alias(self) -> str: @property # type: ignore @requires_update - def next_action(self) -> Dict: + def next_action(self) -> dict: """Return next scheduled(?) action.""" info = self._get_child_info() return info["next_action"] @property # type: ignore @requires_update - def on_since(self) -> Optional[datetime]: + def on_since(self) -> datetime | None: """Return on-time, if available.""" if self.is_off: return None @@ -359,7 +359,7 @@ def model(self) -> str: sys_info = self.parent.sys_info return f"Socket for {sys_info['model']}" - def _get_child_info(self) -> Dict: + def _get_child_info(self) -> dict: """Return the subdevice information for this device.""" for plug in self.parent.sys_info["children"]: if plug["id"] == self.child_id: diff --git a/kasa/iot/modules/emeter.py b/kasa/iot/modules/emeter.py index 178b92e47..52346eccb 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -1,7 +1,8 @@ """Implementation of the emeter module.""" +from __future__ import annotations + from datetime import datetime -from typing import Dict, List, Optional, Union from ...emeterstatus import EmeterStatus from .usage import Usage @@ -16,7 +17,7 @@ def realtime(self) -> EmeterStatus: return EmeterStatus(self.data["get_realtime"]) @property - def emeter_today(self) -> Optional[float]: + def emeter_today(self) -> float | None: """Return today's energy consumption in kWh.""" raw_data = self.daily_data today = datetime.now().day @@ -24,7 +25,7 @@ def emeter_today(self) -> Optional[float]: return data.get(today) @property - def emeter_this_month(self) -> Optional[float]: + def emeter_this_month(self) -> float | None: """Return this month's energy consumption in kWh.""" raw_data = self.monthly_data current_month = datetime.now().month @@ -42,7 +43,7 @@ async def get_realtime(self): """Return real-time statistics.""" return await self.call("get_realtime") - async def get_daystat(self, *, year=None, month=None, kwh=True) -> Dict: + async def get_daystat(self, *, year=None, month=None, kwh=True) -> dict: """Return daily stats for the given year & month. The return value is a dictionary of {day: energy, ...}. @@ -51,7 +52,7 @@ async def get_daystat(self, *, year=None, month=None, kwh=True) -> Dict: data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh) return data - async def get_monthstat(self, *, year=None, kwh=True) -> Dict: + async def get_monthstat(self, *, year=None, kwh=True) -> dict: """Return monthly stats for the given year. The return value is a dictionary of {month: energy, ...}. @@ -62,11 +63,11 @@ async def get_monthstat(self, *, year=None, kwh=True) -> Dict: def _convert_stat_data( self, - data: List[Dict[str, Union[int, float]]], + data: list[dict[str, int | float]], entry_key: str, kwh: bool = True, - key: Optional[int] = None, - ) -> Dict[Union[int, float], Union[int, float]]: + key: int | None = None, + ) -> dict[int | float, int | float]: """Return emeter information keyed with the day/month. The incoming data is a list of dictionaries:: diff --git a/kasa/iot/modules/motion.py b/kasa/iot/modules/motion.py index 59fe42997..fe59748e2 100644 --- a/kasa/iot/modules/motion.py +++ b/kasa/iot/modules/motion.py @@ -1,7 +1,8 @@ """Implementation of the motion detection (PIR) module found in some dimmers.""" +from __future__ import annotations + from enum import Enum -from typing import Optional from ...exceptions import KasaException from ..iotmodule import IotModule @@ -43,7 +44,7 @@ async def set_enabled(self, state: bool): return await self.call("set_enable", {"enable": int(state)}) async def set_range( - self, *, range: Optional[Range] = None, custom_range: Optional[int] = None + self, *, range: Range | None = None, custom_range: int | None = None ): """Set the range for the sensor. diff --git a/kasa/iot/modules/rulemodule.py b/kasa/iot/modules/rulemodule.py index 0739058d8..1feaf456b 100644 --- a/kasa/iot/modules/rulemodule.py +++ b/kasa/iot/modules/rulemodule.py @@ -1,5 +1,7 @@ """Base implementation for all rule-based modules.""" +from __future__ import annotations + import logging from enum import Enum from typing import Dict, List, Optional @@ -37,20 +39,20 @@ class Rule(BaseModel): id: str name: str enable: bool - wday: List[int] + wday: List[int] # noqa: UP006 repeat: bool # start action - sact: Optional[Action] + sact: Optional[Action] # noqa: UP007 stime_opt: TimeOption smin: int - eact: Optional[Action] + eact: Optional[Action] # noqa: UP007 etime_opt: TimeOption emin: int # Only on bulbs - s_light: Optional[Dict] + s_light: Optional[Dict] # noqa: UP006,UP007 _LOGGER = logging.getLogger(__name__) @@ -65,7 +67,7 @@ def query(self): return merge(q, self.query_for_command("get_next_action")) @property - def rules(self) -> List[Rule]: + def rules(self) -> list[Rule]: """Return the list of rules for the service.""" try: return [ diff --git a/kasa/iot/modules/usage.py b/kasa/iot/modules/usage.py index faffb5d83..5acf1dbe0 100644 --- a/kasa/iot/modules/usage.py +++ b/kasa/iot/modules/usage.py @@ -1,7 +1,8 @@ """Implementation of the usage interface.""" +from __future__ import annotations + from datetime import datetime -from typing import Dict from ..iotmodule import IotModule, merge @@ -58,7 +59,7 @@ def usage_this_month(self): return entry["time"] return None - async def get_raw_daystat(self, *, year=None, month=None) -> Dict: + async def get_raw_daystat(self, *, year=None, month=None) -> dict: """Return raw daily stats for the given year & month.""" if year is None: year = datetime.now().year @@ -67,14 +68,14 @@ async def get_raw_daystat(self, *, year=None, month=None) -> Dict: return await self.call("get_daystat", {"year": year, "month": month}) - async def get_raw_monthstat(self, *, year=None) -> Dict: + async def get_raw_monthstat(self, *, year=None) -> dict: """Return raw monthly stats for the given year.""" if year is None: year = datetime.now().year return await self.call("get_monthstat", {"year": year}) - async def get_daystat(self, *, year=None, month=None) -> Dict: + async def get_daystat(self, *, year=None, month=None) -> dict: """Return daily stats for the given year & month. The return value is a dictionary of {day: time, ...}. @@ -83,7 +84,7 @@ async def get_daystat(self, *, year=None, month=None) -> Dict: data = self._convert_stat_data(data["day_list"], entry_key="day") return data - async def get_monthstat(self, *, year=None) -> Dict: + async def get_monthstat(self, *, year=None) -> dict: """Return monthly stats for the given year. The return value is a dictionary of {month: time, ...}. @@ -96,7 +97,7 @@ async def erase_stats(self): """Erase all stats.""" return await self.call("erase_runtime_stat") - def _convert_stat_data(self, data, entry_key) -> Dict: + def _convert_stat_data(self, data, entry_key) -> dict: """Return usage information keyed with the day/month. The incoming data is a list of dictionaries:: diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index a0a286125..1795566e2 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -1,8 +1,9 @@ """Module for the IOT legacy IOT KASA protocol.""" +from __future__ import annotations + import asyncio import logging -from typing import Dict, Optional, Union from .deviceconfig import DeviceConfig from .exceptions import ( @@ -34,7 +35,7 @@ def __init__( self._query_lock = asyncio.Lock() - async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: + async def query(self, request: str | dict, retry_count: int = 3) -> dict: """Query the device retrying for retry_count on failure.""" if isinstance(request, dict): request = json_dumps(request) @@ -43,7 +44,7 @@ async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: async with self._query_lock: return await self._query(request, retry_count) - async def _query(self, request: str, retry_count: int = 3) -> Dict: + async def _query(self, request: str, retry_count: int = 3) -> dict: for retry in range(retry_count + 1): try: return await self._execute_query(request, retry) @@ -83,7 +84,7 @@ async def _query(self, request: str, retry_count: int = 3) -> Dict: # make mypy happy, this should never be reached.. raise KasaException("Query reached somehow to unreachable") - async def _execute_query(self, request: str, retry_count: int) -> Dict: + async def _execute_query(self, request: str, retry_count: int) -> dict: return await self._transport.send(request) async def close(self) -> None: @@ -94,11 +95,11 @@ async def close(self) -> None: class _deprecated_TPLinkSmartHomeProtocol(IotProtocol): def __init__( self, - host: Optional[str] = None, + host: str | None = None, *, - port: Optional[int] = None, - timeout: Optional[int] = None, - transport: Optional[BaseTransport] = None, + port: int | None = None, + timeout: int | None = None, + transport: BaseTransport | None = None, ) -> None: """Create a protocol object.""" if not host and not transport: diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 8feae98c1..3a1eb3367 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -40,6 +40,8 @@ """ +from __future__ import annotations + import asyncio import base64 import datetime @@ -49,7 +51,7 @@ import struct import time from pprint import pformat as pf -from typing import Any, Dict, Optional, Tuple, cast +from typing import Any, cast from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -99,7 +101,7 @@ def __init__( super().__init__(config=config) self._http_client = HttpClient(config) - self._local_seed: Optional[bytes] = None + self._local_seed: bytes | None = None if ( not self._credentials or self._credentials.username is None ) and not self._credentials_hash: @@ -109,16 +111,16 @@ 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._default_credentials_auth_hash: Dict[str, bytes] = {} + self._default_credentials_auth_hash: dict[str, bytes] = {} self._blank_auth_hash = None self._handshake_lock = asyncio.Lock() self._query_lock = asyncio.Lock() self._handshake_done = False - self._encryption_session: Optional[KlapEncryptionSession] = None - self._session_expire_at: Optional[float] = None + self._encryption_session: KlapEncryptionSession | None = None + self._session_expire_at: float | None = None - self._session_cookie: Optional[Dict[str, Any]] = None + self._session_cookie: dict[str, Any] | None = None _LOGGER.debug("Created KLAP transport for %s", self._host) self._app_url = URL(f"http://{self._host}:{self._port}/app") @@ -134,7 +136,7 @@ def credentials_hash(self) -> str: """The hashed credentials used by the transport.""" return base64.b64encode(self._local_auth_hash).decode() - async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: + async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: """Perform handshake1.""" local_seed: bytes = secrets.token_bytes(16) @@ -240,7 +242,7 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: async def perform_handshake2( self, local_seed, remote_seed, auth_hash - ) -> "KlapEncryptionSession": + ) -> KlapEncryptionSession: """Perform handshake2.""" # Handshake 2 has the following payload: # sha256(serverBytes | authenticator) diff --git a/kasa/module.py b/kasa/module.py index 3aa973fc3..ad0b5562a 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -1,8 +1,9 @@ """Base class for all module implementations.""" +from __future__ import annotations + import logging from abc import ABC, abstractmethod -from typing import Dict from .device import Device from .exceptions import KasaException @@ -18,10 +19,10 @@ class Module(ABC): executed during the regular update cycle. """ - def __init__(self, device: "Device", module: str): + def __init__(self, device: Device, module: str): self._device = device self._module = module - self._module_features: Dict[str, Feature] = {} + self._module_features: dict[str, Feature] = {} @abstractmethod def query(self): diff --git a/kasa/protocol.py b/kasa/protocol.py index a62bf4def..c7d505b8a 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -10,13 +10,14 @@ http://www.apache.org/licenses/LICENSE-2.0 """ +from __future__ import annotations + import base64 import errno import hashlib import logging import struct from abc import ABC, abstractmethod -from typing import Dict, Tuple, Union # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -62,7 +63,7 @@ def credentials_hash(self) -> str: """The hashed credentials used by the transport.""" @abstractmethod - async def send(self, request: str) -> Dict: + async def send(self, request: str) -> dict: """Send a message to the device and return a response.""" @abstractmethod @@ -95,7 +96,7 @@ def config(self) -> DeviceConfig: return self._transport._config @abstractmethod - async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: + async def query(self, request: str | dict, retry_count: int = 3) -> dict: """Query the device for the protocol. Abstract method to be overriden.""" @abstractmethod @@ -103,7 +104,7 @@ async def close(self) -> None: """Close the protocol. Abstract method to be overriden.""" -def get_default_credentials(tuple: Tuple[str, str]) -> Credentials: +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() diff --git a/kasa/smart/modules/alarmmodule.py b/kasa/smart/modules/alarmmodule.py index a05fde351..667903262 100644 --- a/kasa/smart/modules/alarmmodule.py +++ b/kasa/smart/modules/alarmmodule.py @@ -1,6 +1,8 @@ """Implementation of alarm module.""" -from typing import TYPE_CHECKING, Dict, List, Optional +from __future__ import annotations + +from typing import TYPE_CHECKING from ...feature import Feature, FeatureType from ..smartmodule import SmartModule @@ -14,14 +16,14 @@ class AlarmModule(SmartModule): REQUIRED_COMPONENT = "alarm" - def query(self) -> Dict: + def query(self) -> dict: """Query to execute during the update cycle.""" return { "get_alarm_configure": None, "get_support_alarm_type_list": None, # This should be needed only once } - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._add_feature( Feature( @@ -59,7 +61,7 @@ def alarm_sound(self): return self.data["get_alarm_configure"]["type"] @property - def alarm_sounds(self) -> List[str]: + def alarm_sounds(self) -> list[str]: """Return list of available alarm sounds.""" return self.data["get_support_alarm_type_list"]["alarm_type_list"] @@ -74,7 +76,7 @@ def active(self) -> bool: return self._device.sys_info["in_alarm"] @property - def source(self) -> Optional[str]: + def source(self) -> str | None: """Return the alarm cause.""" src = self._device.sys_info["in_alarm_source"] return src if src else None diff --git a/kasa/smart/modules/autooffmodule.py b/kasa/smart/modules/autooffmodule.py index d72b6290a..1d31bfb96 100644 --- a/kasa/smart/modules/autooffmodule.py +++ b/kasa/smart/modules/autooffmodule.py @@ -1,7 +1,9 @@ """Implementation of auto off module.""" +from __future__ import annotations + from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Dict, Optional +from typing import TYPE_CHECKING from ...feature import Feature from ..smartmodule import SmartModule @@ -16,7 +18,7 @@ class AutoOffModule(SmartModule): REQUIRED_COMPONENT = "auto_off" QUERY_GETTER_NAME = "get_auto_off_config" - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._add_feature( Feature( @@ -42,7 +44,7 @@ def __init__(self, device: "SmartDevice", module: str): ) ) - def query(self) -> Dict: + def query(self) -> dict: """Query to execute during the update cycle.""" return {self.QUERY_GETTER_NAME: {"start_index": 0}} @@ -75,7 +77,7 @@ def is_timer_active(self) -> bool: return self._device.sys_info["auto_off_status"] == "on" @property - def auto_off_at(self) -> Optional[datetime]: + def auto_off_at(self) -> datetime | None: """Return when the device will be turned off automatically.""" if not self.is_timer_active: return None diff --git a/kasa/smart/modules/battery.py b/kasa/smart/modules/battery.py index 13d35f6fd..982f9c6ab 100644 --- a/kasa/smart/modules/battery.py +++ b/kasa/smart/modules/battery.py @@ -1,5 +1,7 @@ """Implementation of battery module.""" +from __future__ import annotations + from typing import TYPE_CHECKING from ...feature import Feature, FeatureType @@ -15,7 +17,7 @@ class BatterySensor(SmartModule): REQUIRED_COMPONENT = "battery_detect" QUERY_GETTER_NAME = "get_battery_detect_info" - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._add_feature( Feature( diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index 0d9f035bc..a783ec3aa 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -1,6 +1,8 @@ """Implementation of brightness module.""" -from typing import TYPE_CHECKING, Dict +from __future__ import annotations + +from typing import TYPE_CHECKING from ...feature import Feature, FeatureType from ..smartmodule import SmartModule @@ -14,7 +16,7 @@ class Brightness(SmartModule): REQUIRED_COMPONENT = "brightness" - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._add_feature( Feature( @@ -29,7 +31,7 @@ def __init__(self, device: "SmartDevice", module: str): ) ) - def query(self) -> Dict: + def query(self) -> dict: """Query to execute during the update cycle.""" # Brightness is contained in the main device info response. return {} diff --git a/kasa/smart/modules/cloudmodule.py b/kasa/smart/modules/cloudmodule.py index 4027a25b2..d53633f2e 100644 --- a/kasa/smart/modules/cloudmodule.py +++ b/kasa/smart/modules/cloudmodule.py @@ -1,5 +1,7 @@ """Implementation of cloud module.""" +from __future__ import annotations + from typing import TYPE_CHECKING from ...feature import Feature, FeatureType @@ -15,7 +17,7 @@ class CloudModule(SmartModule): QUERY_GETTER_NAME = "get_connect_cloud_state" REQUIRED_COMPONENT = "cloud_connect" - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._add_feature( diff --git a/kasa/smart/modules/colortemp.py b/kasa/smart/modules/colortemp.py index a1338a34d..3fda9c8af 100644 --- a/kasa/smart/modules/colortemp.py +++ b/kasa/smart/modules/colortemp.py @@ -1,6 +1,8 @@ """Implementation of color temp module.""" -from typing import TYPE_CHECKING, Dict +from __future__ import annotations + +from typing import TYPE_CHECKING from ...bulb import ColorTempRange from ...feature import Feature @@ -15,7 +17,7 @@ class ColorTemperatureModule(SmartModule): REQUIRED_COMPONENT = "color_temperature" - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._add_feature( Feature( @@ -28,7 +30,7 @@ def __init__(self, device: "SmartDevice", module: str): ) ) - def query(self) -> Dict: + def query(self) -> dict: """Query to execute during the update cycle.""" # Color temp is contained in the main device info response. return {} diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py index 050a864b0..6a846d542 100644 --- a/kasa/smart/modules/devicemodule.py +++ b/kasa/smart/modules/devicemodule.py @@ -1,6 +1,6 @@ """Implementation of device module.""" -from typing import Dict +from __future__ import annotations from ..smartmodule import SmartModule @@ -10,7 +10,7 @@ class DeviceModule(SmartModule): REQUIRED_COMPONENT = "device" - def query(self) -> Dict: + def query(self) -> dict: """Query to execute during the update cycle.""" query = { "get_device_info": None, diff --git a/kasa/smart/modules/energymodule.py b/kasa/smart/modules/energymodule.py index 7645d1257..a3e0b4a1c 100644 --- a/kasa/smart/modules/energymodule.py +++ b/kasa/smart/modules/energymodule.py @@ -1,6 +1,8 @@ """Implementation of energy monitoring module.""" -from typing import TYPE_CHECKING, Dict, Optional +from __future__ import annotations + +from typing import TYPE_CHECKING from ...emeterstatus import EmeterStatus from ...feature import Feature @@ -15,7 +17,7 @@ class EnergyModule(SmartModule): REQUIRED_COMPONENT = "energy_monitoring" - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._add_feature( Feature( @@ -42,7 +44,7 @@ def __init__(self, device: "SmartDevice", module: str): ) ) # Wh or kWH? - def query(self) -> Dict: + def query(self) -> dict: """Query to execute during the update cycle.""" req = { "get_energy_usage": None, @@ -77,15 +79,15 @@ def emeter_realtime(self): ) @property - def emeter_this_month(self) -> Optional[float]: + def emeter_this_month(self) -> float | None: """Get the emeter value for this month.""" return self._convert_energy_data(self.energy.get("month_energy"), 1 / 1000) @property - def emeter_today(self) -> Optional[float]: + def emeter_today(self) -> float | None: """Get the emeter value for today.""" return self._convert_energy_data(self.energy.get("today_energy"), 1 / 1000) - def _convert_energy_data(self, data, scale) -> Optional[float]: + def _convert_energy_data(self, data, scale) -> float | None: """Return adjusted emeter information.""" return data if not data else data * scale diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fanmodule.py index 4734aa91c..1d79cdead 100644 --- a/kasa/smart/modules/fanmodule.py +++ b/kasa/smart/modules/fanmodule.py @@ -1,5 +1,8 @@ """Implementation of fan_control module.""" -from typing import TYPE_CHECKING, Dict + +from __future__ import annotations + +from typing import TYPE_CHECKING from ...feature import Feature, FeatureType from ..smartmodule import SmartModule @@ -13,7 +16,7 @@ class FanModule(SmartModule): REQUIRED_COMPONENT = "fan_control" - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._add_feature( @@ -37,11 +40,11 @@ def __init__(self, device: "SmartDevice", module: str): attribute_getter="sleep_mode", attribute_setter="set_sleep_mode", icon="mdi:sleep", - type=FeatureType.Switch + type=FeatureType.Switch, ) ) - def query(self) -> Dict: + def query(self) -> dict: """Query to execute during the update cycle.""" return {} diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index abe5dc399..88effe07e 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -1,6 +1,8 @@ """Implementation of firmware module.""" -from typing import TYPE_CHECKING, Dict, Optional +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional from ...exceptions import SmartErrorCode from ...feature import Feature, FeatureType @@ -20,11 +22,11 @@ class UpdateInfo(BaseModel): """Update info status object.""" status: int = Field(alias="type") - fw_ver: Optional[str] = None - release_date: Optional[date] = None - release_notes: Optional[str] = Field(alias="release_note", default=None) - fw_size: Optional[int] = None - oem_id: Optional[str] = None + fw_ver: Optional[str] = None # noqa: UP007 + release_date: Optional[date] = None # noqa: UP007 + release_notes: Optional[str] = Field(alias="release_note", default=None) # noqa: UP007 + fw_size: Optional[int] = None # noqa: UP007 + oem_id: Optional[str] = None # noqa: UP007 needs_upgrade: bool = Field(alias="need_to_upgrade") @validator("release_date", pre=True) @@ -47,7 +49,7 @@ class Firmware(SmartModule): REQUIRED_COMPONENT = "firmware" - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) if self.supported_version > 1: self._add_feature( @@ -70,7 +72,7 @@ def __init__(self, device: "SmartDevice", module: str): ) ) - def query(self) -> Dict: + def query(self) -> dict: """Query to execute during the update cycle.""" req = { "get_latest_fw": None, diff --git a/kasa/smart/modules/humidity.py b/kasa/smart/modules/humidity.py index 668bde2d9..8f829b266 100644 --- a/kasa/smart/modules/humidity.py +++ b/kasa/smart/modules/humidity.py @@ -1,5 +1,7 @@ """Implementation of humidity module.""" +from __future__ import annotations + from typing import TYPE_CHECKING from ...feature import Feature, FeatureType @@ -15,7 +17,7 @@ class HumiditySensor(SmartModule): REQUIRED_COMPONENT = "humidity" QUERY_GETTER_NAME = "get_comfort_humidity_config" - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._add_feature( Feature( diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py index 34f87710a..cac447b5b 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/ledmodule.py @@ -1,6 +1,8 @@ """Module for led controls.""" -from typing import TYPE_CHECKING, Dict +from __future__ import annotations + +from typing import TYPE_CHECKING from ...feature import Feature, FeatureType from ..smartmodule import SmartModule @@ -15,7 +17,7 @@ class LedModule(SmartModule): REQUIRED_COMPONENT = "led" QUERY_GETTER_NAME = "get_led_info" - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._add_feature( Feature( @@ -29,7 +31,7 @@ def __init__(self, device: "SmartDevice", module: str): ) ) - def query(self) -> Dict: + def query(self) -> dict: """Query to execute during the update cycle.""" return {self.QUERY_GETTER_NAME: {"led_rule": None}} diff --git a/kasa/smart/modules/lighttransitionmodule.py b/kasa/smart/modules/lighttransitionmodule.py index bf824823b..229dea578 100644 --- a/kasa/smart/modules/lighttransitionmodule.py +++ b/kasa/smart/modules/lighttransitionmodule.py @@ -1,5 +1,7 @@ """Module for smooth light transitions.""" +from __future__ import annotations + from typing import TYPE_CHECKING from ...exceptions import KasaException @@ -17,7 +19,7 @@ class LightTransitionModule(SmartModule): QUERY_GETTER_NAME = "get_on_off_gradually_info" MAXIMUM_DURATION = 60 - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._create_features() diff --git a/kasa/smart/modules/reportmodule.py b/kasa/smart/modules/reportmodule.py index 5bae299c9..0f3987bd0 100644 --- a/kasa/smart/modules/reportmodule.py +++ b/kasa/smart/modules/reportmodule.py @@ -1,5 +1,7 @@ """Implementation of report module.""" +from __future__ import annotations + from typing import TYPE_CHECKING from ...feature import Feature @@ -15,7 +17,7 @@ class ReportModule(SmartModule): REQUIRED_COMPONENT = "report_mode" QUERY_GETTER_NAME = "get_report_mode" - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._add_feature( Feature( diff --git a/kasa/smart/modules/temperature.py b/kasa/smart/modules/temperature.py index 0817e9412..2a5d73ba7 100644 --- a/kasa/smart/modules/temperature.py +++ b/kasa/smart/modules/temperature.py @@ -1,5 +1,7 @@ """Implementation of temperature module.""" +from __future__ import annotations + from typing import TYPE_CHECKING, Literal from ...feature import Feature, FeatureType @@ -15,7 +17,7 @@ class TemperatureSensor(SmartModule): REQUIRED_COMPONENT = "temperature" QUERY_GETTER_NAME = "get_comfort_temp_config" - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._add_feature( Feature( diff --git a/kasa/smart/modules/timemodule.py b/kasa/smart/modules/timemodule.py index fd48f43ba..7a0eb51b9 100644 --- a/kasa/smart/modules/timemodule.py +++ b/kasa/smart/modules/timemodule.py @@ -1,5 +1,7 @@ """Implementation of time module.""" +from __future__ import annotations + from datetime import datetime, timedelta, timezone from time import mktime from typing import TYPE_CHECKING, cast @@ -17,7 +19,7 @@ class TimeModule(SmartModule): REQUIRED_COMPONENT = "time" QUERY_GETTER_NAME = "get_device_time" - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._add_feature( diff --git a/kasa/smart/smartbulb.py b/kasa/smart/smartbulb.py index 365130c72..082035e74 100644 --- a/kasa/smart/smartbulb.py +++ b/kasa/smart/smartbulb.py @@ -1,6 +1,6 @@ """Module for tapo-branded smart bulbs (L5**).""" -from typing import Dict, List, Optional +from __future__ import annotations from ..bulb import Bulb from ..exceptions import KasaException @@ -55,7 +55,7 @@ def has_effects(self) -> bool: return "dynamic_light_effect_enable" in self._info @property - def effect(self) -> Dict: + def effect(self) -> dict: """Return effect state. This follows the format used by SmartLightStrip. @@ -79,7 +79,7 @@ def effect(self) -> Dict: return data @property - def effect_list(self) -> Optional[List[str]]: + def effect_list(self) -> list[str] | None: """Return built-in effects list. Example: @@ -124,10 +124,10 @@ async def set_hsv( self, hue: int, saturation: int, - value: Optional[int] = None, + value: int | None = None, *, - transition: Optional[int] = None, - ) -> Dict: + transition: int | None = None, + ) -> dict: """Set new HSV. Note, transition is not supported and will be ignored. @@ -163,8 +163,8 @@ async def set_hsv( return await self.protocol.query({"set_device_info": {**request_payload}}) async def set_color_temp( - self, temp: int, *, brightness=None, transition: Optional[int] = None - ) -> Dict: + self, temp: int, *, brightness=None, transition: int | None = None + ) -> dict: """Set the color temperature of the device in kelvin. Note, transition is not supported and will be ignored. @@ -193,8 +193,8 @@ def _raise_for_invalid_brightness(self, value: int): raise ValueError(f"Invalid brightness value: {value} (valid range: 1-100%)") async def set_brightness( - self, brightness: int, *, transition: Optional[int] = None - ) -> Dict: + self, brightness: int, *, transition: int | None = None + ) -> dict: """Set the brightness in percentage. Note, transition is not supported and will be ignored. @@ -215,13 +215,13 @@ async def set_effect( self, effect: str, *, - brightness: Optional[int] = None, - transition: Optional[int] = None, + brightness: int | None = None, + transition: int | None = None, ) -> None: """Set an effect on the device.""" raise NotImplementedError() @property - def presets(self) -> List[BulbPreset]: + def presets(self) -> list[BulbPreset]: """Return a list of available bulb setting presets.""" return [] diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 6289dbc0a..ecff7cfe7 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -1,7 +1,8 @@ """Child device implementation.""" +from __future__ import annotations + import logging -from typing import Optional from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -22,8 +23,8 @@ def __init__( parent: SmartDevice, info, component_info, - config: Optional[DeviceConfig] = None, - protocol: Optional[SmartProtocol] = None, + config: DeviceConfig | None = None, + protocol: SmartProtocol | None = None, ) -> None: super().__init__(parent.host, config=parent.config, protocol=parent.protocol) self._parent = parent @@ -38,7 +39,7 @@ async def update(self, update_children: bool = True): @classmethod async def create(cls, parent: SmartDevice, child_info, child_components): """Create a child device based on device info and component listing.""" - child: "SmartChildDevice" = cls(parent, child_info, child_components) + child: SmartChildDevice = cls(parent, child_info, child_components) await child._initialize_modules() await child._initialize_features() return child diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 331cf66e5..f921fda9c 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -1,9 +1,11 @@ """Module for a SMART device.""" +from __future__ import annotations + import base64 import logging from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Sequence, cast +from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..aestransport import AesTransport from ..device import Device, WifiNetwork @@ -28,20 +30,20 @@ def __init__( self, host: str, *, - config: Optional[DeviceConfig] = None, - protocol: Optional[SmartProtocol] = None, + config: DeviceConfig | None = None, + protocol: SmartProtocol | None = None, ) -> None: _protocol = protocol or SmartProtocol( transport=AesTransport(config=config or DeviceConfig(host=host)), ) super().__init__(host=host, config=config, protocol=_protocol) self.protocol: SmartProtocol - self._components_raw: Optional[Dict[str, Any]] = None - self._components: Dict[str, int] = {} - self._state_information: Dict[str, Any] = {} - self.modules: Dict[str, "SmartModule"] = {} - self._parent: Optional["SmartDevice"] = None - self._children: Mapping[str, "SmartDevice"] = {} + self._components_raw: dict[str, Any] | None = None + self._components: dict[str, int] = {} + self._state_information: dict[str, Any] = {} + self.modules: dict[str, SmartModule] = {} + self._parent: SmartDevice | None = None + self._children: Mapping[str, SmartDevice] = {} self._last_update = {} async def _initialize_children(self): @@ -74,7 +76,7 @@ async def _initialize_children(self): } @property - def children(self) -> Sequence["SmartDevice"]: + def children(self) -> Sequence[SmartDevice]: """Return list of children.""" return list(self._children.values()) @@ -130,7 +132,7 @@ async def update(self, update_children: bool = True): await self._negotiate() await self._initialize_modules() - req: Dict[str, Any] = {} + req: dict[str, Any] = {} # TODO: this could be optimized by constructing the query only once for module in self.modules.values(): @@ -236,7 +238,7 @@ async def _initialize_features(self): self._add_feature(feat) @property - def sys_info(self) -> Dict[str, Any]: + def sys_info(self) -> dict[str, Any]: """Returns the device info.""" return self._info # type: ignore @@ -246,7 +248,7 @@ def model(self) -> str: return str(self._info.get("model")) @property - def alias(self) -> Optional[str]: + def alias(self) -> str | None: """Returns the device alias or nickname.""" if self._info and (nickname := self._info.get("nickname")): return base64.b64decode(nickname).decode() @@ -265,13 +267,13 @@ def time(self) -> datetime: return _timemod.time @property - def timezone(self) -> Dict: + def timezone(self) -> dict: """Return the timezone and time_difference.""" ti = self.time return {"timezone": ti.tzname()} @property - def hw_info(self) -> Dict: + def hw_info(self) -> dict: """Return hardware info for the device.""" return { "sw_ver": self._info.get("fw_ver"), @@ -284,7 +286,7 @@ def hw_info(self) -> Dict: } @property - def location(self) -> Dict: + def location(self) -> dict: """Return the device location.""" loc = { "latitude": cast(float, self._info.get("latitude", 0)) / 10_000, @@ -293,7 +295,7 @@ def location(self) -> Dict: return loc @property - def rssi(self) -> Optional[int]: + def rssi(self) -> int | None: """Return the rssi.""" rssi = self._info.get("rssi") return int(rssi) if rssi else None @@ -321,7 +323,7 @@ def _update_internal_state(self, info): self._info = info async def _query_helper( - self, method: str, params: Optional[Dict] = None, child_ids=None + self, method: str, params: dict | None = None, child_ids=None ) -> Any: res = await self.protocol.query({method: params}) @@ -378,19 +380,19 @@ def emeter_realtime(self) -> EmeterStatus: return energy.emeter_realtime @property - def emeter_this_month(self) -> Optional[float]: + def emeter_this_month(self) -> float | None: """Get the emeter value for this month.""" energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405 return energy.emeter_this_month @property - def emeter_today(self) -> Optional[float]: + def emeter_today(self) -> float | None: """Get the emeter value for today.""" energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405 return energy.emeter_today @property - def on_since(self) -> Optional[datetime]: + def on_since(self) -> datetime | None: """Return the time that the device was turned on or None if turned off.""" if ( not self._info.get("device_on") @@ -404,7 +406,7 @@ def on_since(self) -> Optional[datetime]: else: # We have no device time, use current local time. return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) - async def wifi_scan(self) -> List[WifiNetwork]: + async def wifi_scan(self) -> list[WifiNetwork]: """Scan for available wifi networks.""" def _net_for_scan_info(res): @@ -527,7 +529,7 @@ def device_type(self) -> DeviceType: @staticmethod def _get_device_type_from_components( - components: List[str], device_type: str + components: list[str], device_type: str ) -> DeviceType: """Find type to be displayed as a supported device category.""" if "HUB" in device_type: diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 20580975d..a0f3c1051 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -1,7 +1,9 @@ """Base implementation for SMART modules.""" +from __future__ import annotations + import logging -from typing import TYPE_CHECKING, Dict, Type +from typing import TYPE_CHECKING from ..exceptions import KasaException from ..module import Module @@ -18,9 +20,9 @@ class SmartModule(Module): NAME: str REQUIRED_COMPONENT: str QUERY_GETTER_NAME: str - REGISTERED_MODULES: Dict[str, Type["SmartModule"]] = {} + REGISTERED_MODULES: dict[str, type[SmartModule]] = {} - def __init__(self, device: "SmartDevice", module: str): + def __init__(self, device: SmartDevice, module: str): self._device: SmartDevice super().__init__(device, module) @@ -36,7 +38,7 @@ def name(self) -> str: """Name of the module.""" return getattr(self, "NAME", self.__class__.__name__) - def query(self) -> Dict: + def query(self) -> dict: """Query to execute during the update cycle. Default implementation uses the raw query getter w/o parameters. diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 0b07be5f5..3020a575f 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -4,13 +4,15 @@ under compatible GNU GPL3 license. """ +from __future__ import annotations + import asyncio import base64 import logging import time import uuid from pprint import pformat as pf -from typing import Any, Dict, Union +from typing import Any from .exceptions import ( SMART_AUTHENTICATION_ERRORS, @@ -57,12 +59,12 @@ def get_smart_request(self, method, params=None) -> str: } return json_dumps(request) - async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: + async def query(self, request: str | dict, retry_count: int = 3) -> dict: """Query the device retrying for retry_count on failure.""" async with self._query_lock: return await self._query(request, retry_count) - async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: + async def _query(self, request: str | dict, retry_count: int = 3) -> dict: for retry in range(retry_count + 1): try: return await self._execute_query(request, retry) @@ -103,9 +105,9 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: # make mypy happy, this should never be reached.. raise KasaException("Query reached somehow to unreachable") - async def _execute_multiple_query(self, request: Dict, retry_count: int) -> Dict: + async def _execute_multiple_query(self, request: dict, retry_count: int) -> dict: debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) - multi_result: Dict[str, Any] = {} + multi_result: dict[str, Any] = {} smart_method = "multipleRequest" requests = [ {"method": method, "params": params} for method, params in request.items() @@ -146,7 +148,7 @@ async def _execute_multiple_query(self, request: Dict, retry_count: int) -> Dict multi_result[method] = result return multi_result - async def _execute_query(self, request: Union[str, Dict], retry_count: int) -> Dict: + async def _execute_query(self, request: str | dict, retry_count: int) -> dict: debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) if isinstance(request, dict): @@ -322,7 +324,7 @@ def _get_method_and_params_for_request(self, request): return smart_method, smart_params - async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: + async def query(self, request: str | dict, retry_count: int = 3) -> dict: """Wrap request inside control_child envelope.""" method, params = self._get_method_and_params_for_request(request) request_data = { diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index a3bd6df22..7829eac13 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -1,5 +1,6 @@ +from __future__ import annotations + import warnings -from typing import Dict from unittest.mock import MagicMock, patch import pytest @@ -37,7 +38,7 @@ def default_port(self) -> int: def credentials_hash(self) -> str: return "dummy hash" - async def send(self, request: str) -> Dict: + async def send(self, request: str) -> dict: return {} async def close(self) -> None: diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 9d01a8305..7fe40f486 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -1,5 +1,6 @@ +from __future__ import annotations + from itertools import chain -from typing import Dict, List, Set import pytest @@ -128,10 +129,10 @@ ) ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) -IP_MODEL_CACHE: Dict[str, str] = {} +IP_MODEL_CACHE: dict[str, str] = {} -def parametrize_combine(parametrized: List[pytest.MarkDecorator]): +def parametrize_combine(parametrized: list[pytest.MarkDecorator]): """Combine multiple pytest parametrize dev marks into one set of fixtures.""" fixtures = set() for param in parametrized: @@ -291,7 +292,7 @@ def check_categories(): + hubs_smart.args[1] + sensors_smart.args[1] ) - diffs: Set[FixtureInfo] = set(FIXTURE_DATA) - set(categorized_fixtures) + diffs: set[FixtureInfo] = set(FIXTURE_DATA) - set(categorized_fixtures) if diffs: print(diffs) for diff in diffs: diff --git a/kasa/tests/discovery_fixtures.py b/kasa/tests/discovery_fixtures.py index 653f99709..957dc0074 100644 --- a/kasa/tests/discovery_fixtures.py +++ b/kasa/tests/discovery_fixtures.py @@ -1,6 +1,7 @@ +from __future__ import annotations + from dataclasses import dataclass from json import dumps as json_dumps -from typing import Optional import pytest @@ -76,8 +77,8 @@ class _DiscoveryMock: query_data: dict device_type: str encrypt_type: str - login_version: Optional[int] = None - port_override: Optional[int] = None + login_version: int | None = None + port_override: int | None = None if "discovery_result" in fixture_data: discovery_data = {"result": fixture_data["discovery_result"]} diff --git a/kasa/tests/fixtureinfo.py b/kasa/tests/fixtureinfo.py index c0b4b506f..153d6cc38 100644 --- a/kasa/tests/fixtureinfo.py +++ b/kasa/tests/fixtureinfo.py @@ -1,8 +1,10 @@ +from __future__ import annotations + import glob import json import os from pathlib import Path -from typing import Dict, List, NamedTuple, Optional, Set +from typing import NamedTuple from kasa.device_factory import _get_device_type_from_sys_info from kasa.device_type import DeviceType @@ -12,7 +14,7 @@ class FixtureInfo(NamedTuple): name: str protocol: str - data: Dict + data: dict FixtureInfo.__hash__ = lambda self: hash((self.name, self.protocol)) # type: ignore[attr-defined, method-assign] @@ -55,7 +57,7 @@ def idgenerator(paramtuple: FixtureInfo): return None -def get_fixture_info() -> List[FixtureInfo]: +def get_fixture_info() -> list[FixtureInfo]: """Return raw discovery file contents as JSON. Used for discovery tests.""" fixture_data = [] for file, protocol in SUPPORTED_DEVICES: @@ -77,17 +79,17 @@ def get_fixture_info() -> List[FixtureInfo]: return fixture_data -FIXTURE_DATA: List[FixtureInfo] = get_fixture_info() +FIXTURE_DATA: list[FixtureInfo] = get_fixture_info() def filter_fixtures( desc, *, - data_root_filter: Optional[str] = None, - protocol_filter: Optional[Set[str]] = None, - model_filter: Optional[Set[str]] = None, - component_filter: Optional[str] = None, - device_type_filter: Optional[List[DeviceType]] = None, + data_root_filter: str | None = None, + protocol_filter: set[str] | None = None, + model_filter: set[str] | None = None, + component_filter: str | None = None, + device_type_filter: list[DeviceType] | None = None, ): """Filter the fixtures based on supplied parameters. diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 7c7ad9d86..260fcf1a3 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -14,7 +14,11 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): """Test fan speed feature.""" fan: FanModule = dev.modules["FanModule"] level_feature = fan._module_features["fan_speed_level"] - assert level_feature.minimum_value <= level_feature.value <= level_feature.maximum_value + assert ( + level_feature.minimum_value + <= level_feature.value + <= level_feature.maximum_value + ) call = mocker.spy(fan, "call") await fan.set_fan_speed_level(3) diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 859c35bec..ffd32cb10 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import base64 import json import logging @@ -7,7 +9,7 @@ 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 +from typing import Any import aiohttp import pytest @@ -335,7 +337,7 @@ async def post(self, url: URL, params=None, json=None, data=None, *_, **__): json = json_loads(item.decode()) return await self._post(url, json) - async def _post(self, url: URL, json: Dict[str, Any]): + async def _post(self, url: URL, json: dict[str, Any]): if json["method"] == "handshake": return await self._return_handshake_response(url, json) elif json["method"] == "securePassthrough": @@ -346,7 +348,7 @@ async def _post(self, url: URL, json: Dict[str, Any]): assert str(url) == f"http://{self.host}:80/app?token={self.token}" return await self._return_send_response(url, json) - async def _return_handshake_response(self, url: URL, json: Dict[str, Any]): + async def _return_handshake_response(self, url: URL, 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] @@ -359,7 +361,7 @@ async def _return_handshake_response(self, url: URL, json: Dict[str, Any]): self.status_code, {"result": {"key": key_64}, "error_code": self.error_code} ) - async def _return_secure_passthrough_response(self, url: URL, json: Dict[str, Any]): + async def _return_secure_passthrough_response(self, url: URL, 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) @@ -378,7 +380,7 @@ async def _return_secure_passthrough_response(self, url: URL, json: Dict[str, An } return self._mock_response(self.status_code, result) - async def _return_login_response(self, url: URL, json: Dict[str, Any]): + async def _return_login_response(self, url: URL, json: dict[str, Any]): if "token=" in str(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 @@ -386,7 +388,7 @@ async def _return_login_response(self, url: URL, json: Dict[str, Any]): self.inner_call_count += 1 return self._mock_response(self.status_code, result) - async def _return_send_response(self, url: URL, json: Dict[str, Any]): + async def _return_send_response(self, url: URL, json: dict[str, Any]): result = {"result": {"method": None}, "error_code": self.inner_error_code} response = self.send_response if self.send_response else result self.inner_call_count += 1 diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 584897b82..ffcd57aed 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -1,7 +1,9 @@ """Tests for SMART devices.""" +from __future__ import annotations + import logging -from typing import Any, Dict +from typing import Any import pytest from pytest_mock import MockerFixture @@ -99,7 +101,7 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): assert dev.modules await dev.update() - full_query: Dict[str, Any] = {} + full_query: dict[str, Any] = {} for mod in dev.modules.values(): full_query = {**full_query, **mod.query()} diff --git a/kasa/xortransport.py b/kasa/xortransport.py index 085a6d647..0bca0321c 100644 --- a/kasa/xortransport.py +++ b/kasa/xortransport.py @@ -10,6 +10,8 @@ http://www.apache.org/licenses/LICENSE-2.0 """ +from __future__ import annotations + import asyncio import contextlib import errno @@ -17,7 +19,7 @@ import socket import struct from pprint import pformat as pf -from typing import Dict, Generator, Optional +from typing import Generator # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -41,10 +43,10 @@ class XorTransport(BaseTransport): def __init__(self, *, config: DeviceConfig) -> None: super().__init__(config=config) - self.reader: Optional[asyncio.StreamReader] = None - self.writer: Optional[asyncio.StreamWriter] = None + self.reader: asyncio.StreamReader | None = None + self.writer: asyncio.StreamWriter | None = None self.query_lock = asyncio.Lock() - self.loop: Optional[asyncio.AbstractEventLoop] = None + self.loop: asyncio.AbstractEventLoop | None = None @property def default_port(self): @@ -72,7 +74,7 @@ async def _connect(self, timeout: int) -> None: # the buffer on the device sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - async def _execute_send(self, request: str) -> Dict: + async def _execute_send(self, request: str) -> dict: """Execute a query on the device and wait for the response.""" assert self.writer is not None # noqa: S101 assert self.reader is not None # noqa: S101 @@ -115,7 +117,7 @@ async def reset(self) -> None: """ await self.close() - async def send(self, request: str) -> Dict: + async def send(self, request: str) -> dict: """Send a message to the device and return a response.""" # # Most of the time we will already be connected if the device is online diff --git a/pyproject.toml b/pyproject.toml index 533abd2bf..fa01911af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,6 +110,7 @@ select = [ "UP", # pyupgrade "B", # flake8-bugbear "SIM", # flake8-simplify + "FA", # flake8-future-annotations "I", # isort "S", # bandit ] From 4573260ac8e1a67a617d8cec93b10eb167cbd725 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 20 Apr 2024 17:14:53 +0200 Subject: [PATCH 376/892] Ignore system environment variables for tests (#851) --- kasa/tests/test_cli.py | 117 ++++++++++++++++------------------------- 1 file changed, 44 insertions(+), 73 deletions(-) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 885fbcd08..f190bf46a 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -1,4 +1,5 @@ import json +import os import re import asyncclick as click @@ -42,9 +43,17 @@ ) -async def test_update_called_by_cli(dev, mocker): +@pytest.fixture() +def runner(): + """Runner fixture that unsets the KASA_ environment variables for tests.""" + KASA_VARS = {k: None for k, v in os.environ.items() if k.startswith("KASA_")} + runner = CliRunner(env=KASA_VARS) + + return runner + + +async def test_update_called_by_cli(dev, mocker, runner): """Test that device update is called on main.""" - runner = CliRunner() update = mocker.patch.object(dev, "update") # These will mock the features to avoid accessing non-existing @@ -70,17 +79,15 @@ async def test_update_called_by_cli(dev, mocker): @device_iot -async def test_sysinfo(dev): - runner = CliRunner() +async def test_sysinfo(dev, runner): res = await runner.invoke(sysinfo, obj=dev) assert "System info" in res.output assert dev.alias in res.output @turn_on -async def test_state(dev, turn_on): +async def test_state(dev, turn_on, runner): await handle_turn_on(dev, turn_on) - runner = CliRunner() res = await runner.invoke(state, obj=dev) await dev.update() @@ -91,9 +98,8 @@ async def test_state(dev, turn_on): @turn_on -async def test_toggle(dev, turn_on, mocker): +async def test_toggle(dev, turn_on, runner): await handle_turn_on(dev, turn_on) - runner = CliRunner() await runner.invoke(toggle, obj=dev) if turn_on: @@ -103,9 +109,7 @@ async def test_toggle(dev, turn_on, mocker): @device_iot -async def test_alias(dev): - runner = CliRunner() - +async def test_alias(dev, runner): res = await runner.invoke(alias, obj=dev) assert f"Alias: {dev.alias}" in res.output @@ -121,8 +125,7 @@ async def test_alias(dev): await dev.set_alias(old_alias) -async def test_raw_command(dev, mocker): - runner = CliRunner() +async def test_raw_command(dev, mocker, runner): update = mocker.patch.object(dev, "update") from kasa.smart import SmartDevice @@ -144,9 +147,8 @@ async def test_raw_command(dev, mocker): assert "Usage" in res.output -async def test_command_with_child(dev, mocker): +async def test_command_with_child(dev, mocker, runner): """Test 'command' command with --child.""" - runner = CliRunner() update_mock = mocker.patch.object(dev, "update") # create_autospec for device slows tests way too much, so we use a dummy here @@ -175,9 +177,8 @@ async def _query_helper(*_, **__): @device_smart -async def test_reboot(dev, mocker): +async def test_reboot(dev, mocker, runner): """Test that reboot works on SMART devices.""" - runner = CliRunner() query_mock = mocker.patch.object(dev.protocol, "query") res = await runner.invoke( @@ -190,8 +191,7 @@ async def test_reboot(dev, mocker): @device_smart -async def test_wifi_scan(dev): - runner = CliRunner() +async def test_wifi_scan(dev, runner): res = await runner.invoke(wifi, ["scan"], obj=dev) assert res.exit_code == 0 @@ -199,8 +199,7 @@ async def test_wifi_scan(dev): @device_smart -async def test_wifi_join(dev, mocker): - runner = CliRunner() +async def test_wifi_join(dev, mocker, runner): update = mocker.patch.object(dev, "update") res = await runner.invoke( wifi, @@ -217,8 +216,7 @@ async def test_wifi_join(dev, mocker): @device_smart -async def test_wifi_join_no_creds(dev): - runner = CliRunner() +async def test_wifi_join_no_creds(dev, runner): dev.protocol._transport._credentials = None res = await runner.invoke( wifi, @@ -231,8 +229,7 @@ async def test_wifi_join_no_creds(dev): @device_smart -async def test_wifi_join_exception(dev, mocker): - runner = CliRunner() +async def test_wifi_join_exception(dev, mocker, runner): mocker.patch.object(dev.protocol, "query", side_effect=DeviceError(error_code=9999)) res = await runner.invoke( wifi, @@ -245,8 +242,7 @@ async def test_wifi_join_exception(dev, mocker): @device_smart -async def test_update_credentials(dev): - runner = CliRunner() +async def test_update_credentials(dev, runner): res = await runner.invoke( update_credentials, ["--username", "foo", "--password", "bar"], @@ -261,9 +257,7 @@ async def test_update_credentials(dev): ) -async def test_emeter(dev: Device, mocker): - runner = CliRunner() - +async def test_emeter(dev: Device, mocker, runner): res = await runner.invoke(emeter, obj=dev) if not dev.has_emeter: assert "Device has no emeter" in res.output @@ -314,8 +308,7 @@ async def test_emeter(dev: Device, mocker): @device_iot -async def test_brightness(dev): - runner = CliRunner() +async def test_brightness(dev, runner): res = await runner.invoke(brightness, obj=dev) if not dev.is_dimmable: assert "This device does not support brightness." in res.output @@ -332,21 +325,20 @@ async def test_brightness(dev): @device_iot -async def test_json_output(dev: Device, mocker): +async def test_json_output(dev: Device, mocker, runner): """Test that the json output produces correct output.""" mocker.patch("kasa.Discover.discover", return_value={"127.0.0.1": dev}) # These will mock the features to avoid accessing non-existing mocker.patch("kasa.device.Device.features", return_value={}) mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) - runner = CliRunner() res = await runner.invoke(cli, ["--json", "state"], obj=dev) assert res.exit_code == 0 assert json.loads(res.output) == dev.internal_state @new_discovery -async def test_credentials(discovery_mock, mocker): +async def test_credentials(discovery_mock, mocker, runner): """Test credentials are passed correctly from cli to device.""" # Patch state to echo username and password pass_dev = click.make_pass_decorator(Device) @@ -364,7 +356,6 @@ async def _state(dev: Device): mocker.patch("kasa.SmartProtocol.query", return_value=discovery_mock.query_data) dr = DiscoveryResult(**discovery_mock.discovery_data["result"]) - runner = CliRunner() res = await runner.invoke( cli, [ @@ -386,9 +377,8 @@ async def _state(dev: Device): @device_iot -async def test_without_device_type(dev, mocker): +async def test_without_device_type(dev, mocker, runner): """Test connecting without the device type.""" - runner = CliRunner() discovery_mock = mocker.patch( "kasa.discover.Discover.discover_single", return_value=dev ) @@ -420,10 +410,8 @@ async def test_without_device_type(dev, mocker): @pytest.mark.parametrize("auth_param", ["--username", "--password"]) -async def test_invalid_credential_params(auth_param): +async def test_invalid_credential_params(auth_param, runner): """Test for handling only one of username or password supplied.""" - runner = CliRunner() - res = await runner.invoke( cli, [ @@ -442,10 +430,8 @@ async def test_invalid_credential_params(auth_param): ) -async def test_duplicate_target_device(): +async def test_duplicate_target_device(runner): """Test that defining both --host or --alias gives an error.""" - runner = CliRunner() - res = await runner.invoke( cli, [ @@ -459,13 +445,12 @@ async def test_duplicate_target_device(): assert "Error: Use either --alias or --host, not both." in res.output -async def test_discover(discovery_mock, mocker): +async def test_discover(discovery_mock, mocker, runner): """Test discovery output.""" # These will mock the features to avoid accessing non-existing mocker.patch("kasa.device.Device.features", return_value={}) mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) - runner = CliRunner() res = await runner.invoke( cli, [ @@ -482,13 +467,12 @@ async def test_discover(discovery_mock, mocker): assert res.exit_code == 0 -async def test_discover_host(discovery_mock, mocker): +async def test_discover_host(discovery_mock, mocker, runner): """Test discovery output.""" # These will mock the features to avoid accessing non-existing mocker.patch("kasa.device.Device.features", return_value={}) mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) - runner = CliRunner() res = await runner.invoke( cli, [ @@ -506,9 +490,8 @@ async def test_discover_host(discovery_mock, mocker): assert res.exit_code == 0 -async def test_discover_unsupported(unsupported_device_info): +async def test_discover_unsupported(unsupported_device_info, runner): """Test discovery output.""" - runner = CliRunner() res = await runner.invoke( cli, [ @@ -527,9 +510,8 @@ async def test_discover_unsupported(unsupported_device_info): assert "== Discovery Result ==" in res.output -async def test_host_unsupported(unsupported_device_info): +async def test_host_unsupported(unsupported_device_info, runner): """Test discovery output.""" - runner = CliRunner() host = "127.0.0.1" res = await runner.invoke( @@ -550,9 +532,8 @@ async def test_host_unsupported(unsupported_device_info): @new_discovery -async def test_discover_auth_failed(discovery_mock, mocker): +async def test_discover_auth_failed(discovery_mock, mocker, runner): """Test discovery output.""" - runner = CliRunner() host = "127.0.0.1" discovery_mock.ip = host device_class = Discover._get_device_class(discovery_mock.discovery_data) @@ -581,9 +562,8 @@ async def test_discover_auth_failed(discovery_mock, mocker): @new_discovery -async def test_host_auth_failed(discovery_mock, mocker): +async def test_host_auth_failed(discovery_mock, mocker, runner): """Test discovery output.""" - runner = CliRunner() host = "127.0.0.1" discovery_mock.ip = host device_class = Discover._get_device_class(discovery_mock.discovery_data) @@ -610,10 +590,8 @@ async def test_host_auth_failed(discovery_mock, mocker): @pytest.mark.parametrize("device_type", list(TYPE_TO_CLASS)) -async def test_type_param(device_type, mocker): +async def test_type_param(device_type, mocker, runner): """Test for handling only one of username or password supplied.""" - runner = CliRunner() - result_device = FileNotFoundError pass_dev = click.make_pass_decorator(Device) @@ -636,7 +614,7 @@ async def _state(dev: Device): @pytest.mark.skip( "Skip until pytest-asyncio supports pytest 8.0, https://github.com/pytest-dev/pytest-asyncio/issues/737" ) -async def test_shell(dev: Device, mocker): +async def test_shell(dev: Device, mocker, runner): """Test that the shell commands tries to embed a shell.""" mocker.patch("kasa.Discover.discover", return_value=[dev]) # repl = mocker.patch("ptpython.repl") @@ -645,14 +623,12 @@ async def test_shell(dev: Device, mocker): {"ptpython": mocker.MagicMock(), "ptpython.repl": mocker.MagicMock()}, ) embed = mocker.patch("ptpython.repl.embed") - runner = CliRunner() res = await runner.invoke(cli, ["shell"], obj=dev) assert res.exit_code == 0 embed.assert_called() -async def test_errors(mocker): - runner = CliRunner() +async def test_errors(mocker, runner): err = KasaException("Foobar") # Test masking @@ -697,13 +673,12 @@ async def test_errors(mocker): assert "Raised error:" not in res.output -async def test_feature(mocker): +async def test_feature(mocker, runner): """Test feature command.""" dummy_device = await get_device_for_fixture_protocol( "P300(EU)_1.0_1.0.13.json", "SMART" ) mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) - runner = CliRunner() res = await runner.invoke( cli, ["--host", "127.0.0.123", "--debug", "feature"], @@ -715,13 +690,12 @@ async def test_feature(mocker): assert res.exit_code == 0 -async def test_feature_single(mocker): +async def test_feature_single(mocker, runner): """Test feature command returning single value.""" dummy_device = await get_device_for_fixture_protocol( "P300(EU)_1.0_1.0.13.json", "SMART" ) mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) - runner = CliRunner() res = await runner.invoke( cli, ["--host", "127.0.0.123", "--debug", "feature", "led"], @@ -732,13 +706,12 @@ async def test_feature_single(mocker): assert res.exit_code == 0 -async def test_feature_missing(mocker): +async def test_feature_missing(mocker, runner): """Test feature command returning single value.""" dummy_device = await get_device_for_fixture_protocol( "P300(EU)_1.0_1.0.13.json", "SMART" ) mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) - runner = CliRunner() res = await runner.invoke( cli, ["--host", "127.0.0.123", "--debug", "feature", "missing"], @@ -749,7 +722,7 @@ async def test_feature_missing(mocker): assert res.exit_code == 0 -async def test_feature_set(mocker): +async def test_feature_set(mocker, runner): """Test feature command's set value.""" dummy_device = await get_device_for_fixture_protocol( "P300(EU)_1.0_1.0.13.json", "SMART" @@ -757,7 +730,6 @@ async def test_feature_set(mocker): led_setter = mocker.patch("kasa.smart.modules.ledmodule.LedModule.set_led") mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) - runner = CliRunner() res = await runner.invoke( cli, ["--host", "127.0.0.123", "--debug", "feature", "led", "True"], @@ -769,7 +741,7 @@ async def test_feature_set(mocker): assert res.exit_code == 0 -async def test_feature_set_child(mocker): +async def test_feature_set_child(mocker, runner): """Test feature command's set value.""" dummy_device = await get_device_for_fixture_protocol( "P300(EU)_1.0_1.0.13.json", "SMART" @@ -781,7 +753,6 @@ async def test_feature_set_child(mocker): child_id = "000000000000000000000000000000000000000001" - runner = CliRunner() res = await runner.invoke( cli, [ From aeb2c923c63a5ddab7e41fb7c9782a271f47f5f1 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sat, 20 Apr 2024 16:18:35 +0100 Subject: [PATCH 377/892] Add ColorModule for smart devices (#840) Adds support L530 hw_version 1.0 --- kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/colormodule.py | 94 +++++++++++++++++++++ kasa/smart/modules/colortemp.py | 19 ++++- kasa/smart/smartbulb.py | 72 +++++----------- kasa/smart/smartdevice.py | 3 +- kasa/smart/smartmodule.py | 9 ++ kasa/tests/device_fixtures.py | 5 ++ kasa/tests/smart/features/test_colortemp.py | 6 +- kasa/tests/test_bulb.py | 7 ++ 9 files changed, 160 insertions(+), 57 deletions(-) create mode 100644 kasa/smart/modules/colormodule.py diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index e2da5b690..938bc2b4d 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -6,6 +6,7 @@ from .brightness import Brightness from .childdevicemodule import ChildDeviceModule from .cloudmodule import CloudModule +from .colormodule import ColorModule from .colortemp import ColorTemperatureModule from .devicemodule import DeviceModule from .energymodule import EnergyModule @@ -36,4 +37,5 @@ "CloudModule", "LightTransitionModule", "ColorTemperatureModule", + "ColorModule", ] diff --git a/kasa/smart/modules/colormodule.py b/kasa/smart/modules/colormodule.py new file mode 100644 index 000000000..234acc742 --- /dev/null +++ b/kasa/smart/modules/colormodule.py @@ -0,0 +1,94 @@ +"""Implementation of color module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...bulb import HSV +from ...feature import Feature +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class ColorModule(SmartModule): + """Implementation of color module.""" + + REQUIRED_COMPONENT = "color" + + def __init__(self, device: SmartDevice, module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "HSV", + container=self, + attribute_getter="hsv", + # TODO proper type for setting hsv + attribute_setter="set_hsv", + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # HSV is contained in the main device info response. + return {} + + @property + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, 1-100) + """ + h, s, v = ( + self.data.get("hue", 0), + self.data.get("saturation", 0), + self.data.get("brightness", 0), + ) + + return HSV(hue=h, saturation=s, value=v) + + def _raise_for_invalid_brightness(self, value: int): + """Raise error on invalid brightness value.""" + if not isinstance(value, int) or not (1 <= value <= 100): + raise ValueError(f"Invalid brightness value: {value} (valid range: 1-100%)") + + async def set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = None, + ) -> dict: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value in percentage [0, 100] + :param int transition: transition in milliseconds. + """ + if not isinstance(hue, int) or not (0 <= hue <= 360): + raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") + + if not isinstance(saturation, int) or not (0 <= saturation <= 100): + raise ValueError( + f"Invalid saturation value: {saturation} (valid range: 0-100%)" + ) + + if value is not None: + self._raise_for_invalid_brightness(value) + + request_payload = { + "color_temp": 0, # If set, color_temp takes precedence over hue&sat + "hue": hue, + "saturation": saturation, + } + # The device errors on invalid brightness values. + if value is not None: + request_payload["brightness"] = value + + return await self.call("set_device_info", {**request_payload}) diff --git a/kasa/smart/modules/colortemp.py b/kasa/smart/modules/colortemp.py index 3fda9c8af..2ecb09ddc 100644 --- a/kasa/smart/modules/colortemp.py +++ b/kasa/smart/modules/colortemp.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING from ...bulb import ColorTempRange @@ -12,6 +13,11 @@ from ..smartdevice import SmartDevice +_LOGGER = logging.getLogger(__name__) + +DEFAULT_TEMP_RANGE = [2500, 6500] + + class ColorTemperatureModule(SmartModule): """Implementation of color temp module.""" @@ -38,7 +44,14 @@ def query(self) -> dict: @property def valid_temperature_range(self) -> ColorTempRange: """Return valid color-temp range.""" - return ColorTempRange(*self.data.get("color_temp_range")) + if (ct_range := self.data.get("color_temp_range")) is None: + _LOGGER.debug( + "Device doesn't report color temperature range, " + "falling back to default %s", + DEFAULT_TEMP_RANGE, + ) + ct_range = DEFAULT_TEMP_RANGE + return ColorTempRange(*ct_range) @property def color_temp(self): @@ -56,3 +69,7 @@ async def set_color_temp(self, temp: int): ) return await self.call("set_device_info", {"color_temp": temp}) + + async def _check_supported(self) -> bool: + """Check the color_temp_range has more than one value.""" + return self.valid_temperature_range.min != self.valid_temperature_range.max diff --git a/kasa/smart/smartbulb.py b/kasa/smart/smartbulb.py index 082035e74..0c654e1ff 100644 --- a/kasa/smart/smartbulb.py +++ b/kasa/smart/smartbulb.py @@ -2,9 +2,12 @@ from __future__ import annotations -from ..bulb import Bulb +from typing import cast + +from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..exceptions import KasaException -from ..iot.iotbulb import HSV, BulbPreset, ColorTempRange +from .modules.colormodule import ColorModule +from .modules.colortemp import ColorTemperatureModule from .smartdevice import SmartDevice AVAILABLE_EFFECTS = { @@ -22,8 +25,7 @@ class SmartBulb(SmartDevice, Bulb): @property def is_color(self) -> bool: """Whether the bulb supports color changes.""" - # TODO: this makes an assumption that only color bulbs report this - return "hue" in self._info + return "ColorModule" in self.modules @property def is_dimmable(self) -> bool: @@ -33,9 +35,7 @@ def is_dimmable(self) -> bool: @property def is_variable_color_temp(self) -> bool: """Whether the bulb supports color temperature changes.""" - ct = self._info.get("color_temp_range") - # L900 reports [9000, 9000] even when it doesn't support changing the ct - return ct is not None and ct[0] != ct[1] + return "ColorTemperatureModule" in self.modules @property def valid_temperature_range(self) -> ColorTempRange: @@ -46,8 +46,9 @@ def valid_temperature_range(self) -> ColorTempRange: if not self.is_variable_color_temp: raise KasaException("Color temperature not supported") - ct_range = self._info.get("color_temp_range", [0, 0]) - return ColorTempRange(min=ct_range[0], max=ct_range[1]) + return cast( + ColorTemperatureModule, self.modules["ColorTemperatureModule"] + ).valid_temperature_range @property def has_effects(self) -> bool: @@ -96,13 +97,7 @@ def hsv(self) -> HSV: if not self.is_color: raise KasaException("Bulb does not support color.") - h, s, v = ( - self._info.get("hue", 0), - self._info.get("saturation", 0), - self._info.get("brightness", 0), - ) - - return HSV(hue=h, saturation=s, value=v) + return cast(ColorModule, self.modules["ColorModule"]).hsv @property def color_temp(self) -> int: @@ -110,7 +105,9 @@ def color_temp(self) -> int: if not self.is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") - return self._info.get("color_temp", -1) + return cast( + ColorTemperatureModule, self.modules["ColorTemperatureModule"] + ).color_temp @property def brightness(self) -> int: @@ -134,33 +131,15 @@ async def set_hsv( :param int hue: hue in degrees :param int saturation: saturation in percentage [0,100] - :param int value: value in percentage [0, 100] + :param int value: value between 1 and 100 :param int transition: transition in milliseconds. """ if not self.is_color: raise KasaException("Bulb does not support color.") - if not isinstance(hue, int) or not (0 <= hue <= 360): - raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") - - if not isinstance(saturation, int) or not (0 <= saturation <= 100): - raise ValueError( - f"Invalid saturation value: {saturation} (valid range: 0-100%)" - ) - - if value is not None: - self._raise_for_invalid_brightness(value) - - request_payload = { - "color_temp": 0, # If set, color_temp takes precedence over hue&sat - "hue": hue, - "saturation": saturation, - } - # The device errors on invalid brightness values. - if value is not None: - request_payload["brightness"] = value - - return await self.protocol.query({"set_device_info": {**request_payload}}) + return await cast(ColorModule, self.modules["ColorModule"]).set_hsv( + hue, saturation, value + ) async def set_color_temp( self, temp: int, *, brightness=None, transition: int | None = None @@ -172,20 +151,11 @@ async def set_color_temp( :param int temp: The new color temperature, in Kelvin :param int transition: transition in milliseconds. """ - # TODO: Note, trying to set brightness at the same time - # with color_temp causes error -1008 if not self.is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") - - valid_temperature_range = self.valid_temperature_range - if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: - raise ValueError( - "Temperature should be between {} and {}, was {}".format( - *valid_temperature_range, temp - ) - ) - - return await self.protocol.query({"set_device_info": {"color_temp": temp}}) + return await cast( + ColorTemperatureModule, self.modules["ColorTemperatureModule"] + ).set_color_temp(temp) def _raise_for_invalid_brightness(self, value: int): """Raise error on invalid brightness value.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index f921fda9c..32cf7cfe0 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -167,7 +167,8 @@ async def _initialize_modules(self): mod.__name__, ) module = mod(self, mod.REQUIRED_COMPONENT) - self.modules[module.name] = module + if await module._check_supported(): + self.modules[module.name] = module async def _initialize_features(self): """Initialize device features.""" diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index a0f3c1051..9169b752a 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -93,3 +93,12 @@ def data(self): def supported_version(self) -> int: """Return version supported by the device.""" return self._device._components[self.REQUIRED_COMPONENT] + + async def _check_supported(self) -> bool: + """Additional check to see if the module is supported by the device. + + Used for parents who report components on the parent that are only available + on the child or for modules where the device has a pointless component like + color_temp_range but only supports one value. + """ + return True diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 7fe40f486..362015db9 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -237,6 +237,11 @@ def parametrize( model_filter=BULBS_IOT_VARIABLE_TEMP, protocol_filter={"IOT"}, ) +variable_temp_smart = parametrize( + "variable color temp smart", + model_filter=BULBS_SMART_VARIABLE_TEMP, + protocol_filter={"SMART"}, +) bulb_smart = parametrize( "bulb devices smart", diff --git a/kasa/tests/smart/features/test_colortemp.py b/kasa/tests/smart/features/test_colortemp.py index 8c899d6d5..e7022578d 100644 --- a/kasa/tests/smart/features/test_colortemp.py +++ b/kasa/tests/smart/features/test_colortemp.py @@ -1,12 +1,10 @@ import pytest from kasa.smart import SmartDevice -from kasa.tests.conftest import parametrize +from kasa.tests.conftest import variable_temp_smart -brightness = parametrize("colortemp smart", component_filter="color_temperature") - -@brightness +@variable_temp_smart async def test_colortemp_component(dev: SmartDevice): """Test brightness feature.""" assert isinstance(dev, SmartDevice) diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index be27df1b9..9e7ab5178 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -9,6 +9,7 @@ from kasa import Bulb, BulbPreset, DeviceType, KasaException from kasa.iot import IotBulb +from kasa.smart import SmartBulb from .conftest import ( bulb, @@ -23,6 +24,7 @@ turn_on, variable_temp, variable_temp_iot, + variable_temp_smart, ) from .test_iotdevice import SYSINFO_SCHEMA @@ -159,6 +161,11 @@ async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text +@variable_temp_smart +async def test_smart_temp_range(dev: SmartBulb): + assert dev.valid_temperature_range + + @variable_temp async def test_out_of_range_temperature(dev: Bulb): with pytest.raises(ValueError): From 214b26a1ea99d890d2b5620c26054d1d3a776cee Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sat, 20 Apr 2024 16:24:49 +0100 Subject: [PATCH 378/892] Re-query missing responses after multi request errors (#850) When smart devices encounter an error during a multipleRequest they return the previous successes and the current error and stop processing subsequent requests. This checks the responses returned and re-queries individually for any missing responses so that individual errors do not break other components. --- kasa/smartprotocol.py | 18 +++++++++++++----- kasa/tests/fakeprotocol_smart.py | 3 +++ kasa/tests/test_smartprotocol.py | 8 +++++++- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 3020a575f..9a1482b18 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -105,21 +105,21 @@ async def _query(self, request: str | dict, retry_count: int = 3) -> dict: # make mypy happy, this should never be reached.. raise KasaException("Query reached somehow to unreachable") - async def _execute_multiple_query(self, request: dict, retry_count: int) -> dict: + async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dict: debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) multi_result: dict[str, Any] = {} smart_method = "multipleRequest" - requests = [ - {"method": method, "params": params} for method, params in request.items() + multi_requests = [ + {"method": method, "params": params} for method, params in requests.items() ] - end = len(requests) + end = len(multi_requests) # Break the requests down as there can be a size limit step = ( self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE ) for i in range(0, end, step): - requests_step = requests[i : i + step] + requests_step = multi_requests[i : i + step] smart_params = {"requests": requests_step} smart_request = self.get_smart_request(smart_method, smart_params) @@ -146,6 +146,14 @@ async def _execute_multiple_query(self, request: dict, retry_count: int) -> dict self._handle_response_error_code(response, method, raise_on_error=False) result = response.get("result", None) multi_result[method] = result + # Multi requests don't continue after errors so requery any missing + for method, params in requests.items(): + if method not in multi_result: + resp = await self._transport.send( + self.get_smart_request(method, params) + ) + self._handle_response_error_code(resp, method, raise_on_error=False) + multi_result[method] = resp.get("result") return multi_result async def _execute_query(self, request: str | dict, retry_count: int) -> dict: diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 024e76360..d03d04c42 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -113,6 +113,9 @@ async def send(self, request: str): responses = [] for request in params["requests"]: response = self._send_request(request) # type: ignore[arg-type] + # Devices do not continue after error + if response["error_code"] != 0: + break response["method"] = request["method"] # type: ignore[index] responses.append(response) return {"result": {"responses": responses}, "error_code": 0} diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 541d17c99..b970eaa5a 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -36,6 +36,11 @@ async def test_smart_device_errors(dummy_protocol, mocker, error_code): async def test_smart_device_errors_in_multiple_request( dummy_protocol, mocker, error_code ): + mock_request = { + "foobar1": {"foo": "bar", "bar": "foo"}, + "foobar2": {"foo": "bar", "bar": "foo"}, + "foobar3": {"foo": "bar", "bar": "foo"}, + } mock_response = { "result": { "responses": [ @@ -55,9 +60,10 @@ async def test_smart_device_errors_in_multiple_request( dummy_protocol._transport, "send", return_value=mock_response ) - resp_dict = await dummy_protocol.query(DUMMY_MULTIPLE_QUERY, retry_count=2) + resp_dict = await dummy_protocol.query(mock_request, retry_count=2) assert resp_dict["foobar2"] == error_code assert send_mock.call_count == 1 + assert len(resp_dict) == len(mock_request) @pytest.mark.parametrize("request_size", [1, 3, 5, 10]) From 29b28966e02f0989ce465764ee470a136fb4a386 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 20 Apr 2024 17:37:24 +0200 Subject: [PATCH 379/892] Remove mock fixtures (#845) --- README.md | 2 +- SUPPORTED.md | 11 -- kasa/tests/fixtures/HS105(US)_1.0_mocked.json | 47 -------- kasa/tests/fixtures/HS110(EU)_2.0_mocked.json | 50 --------- kasa/tests/fixtures/HS110(US)_1.0_mocked.json | 50 --------- kasa/tests/fixtures/HS200(US)_1.0_mocked.json | 47 -------- kasa/tests/fixtures/HS220(US)_1.0_mocked.json | 75 ------------- kasa/tests/fixtures/LB100(US)_1.0_mocked.json | 103 ----------------- kasa/tests/fixtures/LB120(US)_1.0_mocked.json | 103 ----------------- kasa/tests/fixtures/LB130(US)_1.0_mocked.json | 104 ------------------ 10 files changed, 1 insertion(+), 591 deletions(-) delete mode 100644 kasa/tests/fixtures/HS105(US)_1.0_mocked.json delete mode 100644 kasa/tests/fixtures/HS110(EU)_2.0_mocked.json delete mode 100644 kasa/tests/fixtures/HS110(US)_1.0_mocked.json delete mode 100644 kasa/tests/fixtures/HS200(US)_1.0_mocked.json delete mode 100644 kasa/tests/fixtures/HS220(US)_1.0_mocked.json delete mode 100644 kasa/tests/fixtures/LB100(US)_1.0_mocked.json delete mode 100644 kasa/tests/fixtures/LB120(US)_1.0_mocked.json delete mode 100644 kasa/tests/fixtures/LB130(US)_1.0_mocked.json diff --git a/README.md b/README.md index 7ffda4c73..91235102f 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: EP10, EP25\*, HS100\*\*, HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M\*, KP401 - **Power Strips**: EP40, HS107, HS300, KP200, KP303, KP400 - **Wall Switches**: ES20M, HS200, HS210, HS220, KP405, KS200M, KS205\*, KS220M, KS225\*, KS230 -- **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110, LB120, LB130 +- **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 - **Light Strips**: KL400L5, KL420L5, KL430 ### Supported Tapo\* devices diff --git a/SUPPORTED.md b/SUPPORTED.md index bf76a8ad6..16fdb0e1d 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -25,13 +25,10 @@ Some newer Kasa devices require authentication. These are marked with *** Date: Sat, 20 Apr 2024 20:29:07 +0200 Subject: [PATCH 380/892] Use brightness module for smartbulb (#853) Moves one more feature out from the smartbulb class --- kasa/smart/modules/brightness.py | 16 ++++++++++++++-- kasa/smart/smartbulb.py | 14 +++----------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index a783ec3aa..1f0b4d995 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -11,6 +11,10 @@ from ..smartdevice import SmartDevice +BRIGHTNESS_MIN = 1 +BRIGHTNESS_MAX = 100 + + class Brightness(SmartModule): """Implementation of brightness module.""" @@ -25,8 +29,8 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="brightness", attribute_setter="set_brightness", - minimum_value=1, - maximum_value=100, + minimum_value=BRIGHTNESS_MIN, + maximum_value=BRIGHTNESS_MAX, type=FeatureType.Number, ) ) @@ -43,4 +47,12 @@ def brightness(self): async def set_brightness(self, brightness: int): """Set the brightness.""" + if not isinstance(brightness, int) or not ( + BRIGHTNESS_MIN <= brightness <= BRIGHTNESS_MAX + ): + raise ValueError( + f"Invalid brightness value: {brightness} " + f"(valid range: {BRIGHTNESS_MIN}-{BRIGHTNESS_MAX}%)" + ) + return await self.call("set_device_info", {"brightness": brightness}) diff --git a/kasa/smart/smartbulb.py b/kasa/smart/smartbulb.py index 0c654e1ff..8da348977 100644 --- a/kasa/smart/smartbulb.py +++ b/kasa/smart/smartbulb.py @@ -6,8 +6,7 @@ from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..exceptions import KasaException -from .modules.colormodule import ColorModule -from .modules.colortemp import ColorTemperatureModule +from .modules import Brightness, ColorModule, ColorTemperatureModule from .smartdevice import SmartDevice AVAILABLE_EFFECTS = { @@ -157,11 +156,6 @@ async def set_color_temp( ColorTemperatureModule, self.modules["ColorTemperatureModule"] ).set_color_temp(temp) - def _raise_for_invalid_brightness(self, value: int): - """Raise error on invalid brightness value.""" - if not isinstance(value, int) or not (1 <= value <= 100): - raise ValueError(f"Invalid brightness value: {value} (valid range: 1-100%)") - async def set_brightness( self, brightness: int, *, transition: int | None = None ) -> dict: @@ -175,10 +169,8 @@ async def set_brightness( if not self.is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") - self._raise_for_invalid_brightness(brightness) - - return await self.protocol.query( - {"set_device_info": {"brightness": brightness}} + return await cast(Brightness, self.modules["Brightness"]).set_brightness( + brightness ) async def set_effect( From 890900daf330424d35d3aed611e962d0aeedda9a Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 22 Apr 2024 11:25:30 +0200 Subject: [PATCH 381/892] Add support for feature units (#843) Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- kasa/cli.py | 19 +++++++++---------- kasa/feature.py | 2 ++ kasa/smart/modules/energymodule.py | 29 ++++++++++++++--------------- kasa/tests/test_feature.py | 2 ++ 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 41a7759e3..b5babdbb7 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -598,9 +598,10 @@ async def state(ctx, dev: Device): echo("\t[bold]== Children ==[/bold]") for child in dev.children: echo(f"\t* {child.alias} ({child.model}, {child.device_type})") - for feat in child.features.values(): + for id_, feat in child.features.items(): try: - echo(f"\t\t{feat.name}: {feat.value}") + unit = f" {feat.unit}" if feat.unit else "" + echo(f"\t\t{feat.name} ({id_}): {feat.value}{unit}") except Exception as ex: echo(f"\t\t{feat.name}: got exception (%s)" % ex) echo() @@ -614,12 +615,8 @@ async def state(ctx, dev: Device): echo("\n\t[bold]== Device-specific information == [/bold]") for id_, feature in dev.features.items(): - echo(f"\t{feature.name} ({id_}): {feature.value}") - - if dev.has_emeter: - echo("\n\t[bold]== Current State ==[/bold]") - emeter_status = dev.emeter_realtime - echo(f"\t{emeter_status}") + unit = f" {feature.unit}" if feature.unit else "" + echo(f"\t{feature.name} ({id_}): {feature.value}{unit}") echo("\n\t[bold]== Modules ==[/bold]") for module in dev.modules.values(): @@ -1177,7 +1174,8 @@ async def feature(dev: Device, child: str, name: str, value): def _print_features(dev): for name, feat in dev.features.items(): try: - echo(f"\t{feat.name} ({name}): {feat.value}") + unit = f" {feat.unit}" if feat.unit else "" + echo(f"\t{feat.name} ({name}): {feat.value}{unit}") except Exception as ex: echo(f"\t{feat.name} ({name}): [red]{ex}[/red]") @@ -1198,7 +1196,8 @@ def _print_features(dev): feat = dev.features[name] if value is None: - echo(f"{feat.name} ({name}): {feat.value}") + unit = f" {feat.unit}" if feat.unit else "" + echo(f"{feat.name} ({name}): {feat.value}{unit}") return feat.value echo(f"Setting {name} to {value}") diff --git a/kasa/feature.py b/kasa/feature.py index a04e1140a..6add0091a 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -36,6 +36,8 @@ class Feature: container: Any = None #: Icon suggestion icon: str | None = None + #: Unit, if applicable + unit: str | None = None #: Type of the feature type: FeatureType = FeatureType.Sensor diff --git a/kasa/smart/modules/energymodule.py b/kasa/smart/modules/energymodule.py index a3e0b4a1c..aedc71aec 100644 --- a/kasa/smart/modules/energymodule.py +++ b/kasa/smart/modules/energymodule.py @@ -25,24 +25,27 @@ def __init__(self, device: SmartDevice, module: str): name="Current consumption", attribute_getter="current_power", container=self, + unit="W", ) - ) # W or mW? + ) self._add_feature( Feature( device, name="Today's consumption", attribute_getter="emeter_today", container=self, + unit="Wh", ) - ) # Wh or kWh? + ) self._add_feature( Feature( device, name="This month's consumption", attribute_getter="emeter_this_month", container=self, + unit="Wh", ) - ) # Wh or kWH? + ) def query(self) -> dict: """Query to execute during the update cycle.""" @@ -54,9 +57,11 @@ def query(self) -> dict: return req @property - def current_power(self): - """Current power.""" - return self.emeter_realtime.power + def current_power(self) -> float | None: + """Current power in watts.""" + if power := self.energy.get("current_power"): + return power / 1_000 + return None @property def energy(self): @@ -72,22 +77,16 @@ def emeter_realtime(self): return EmeterStatus( { "power_mw": self.energy.get("current_power"), - "total": self._convert_energy_data( - self.energy.get("today_energy"), 1 / 1000 - ), + "total": self.energy.get("today_energy") / 1_000, } ) @property def emeter_this_month(self) -> float | None: """Get the emeter value for this month.""" - return self._convert_energy_data(self.energy.get("month_energy"), 1 / 1000) + return self.energy.get("month_energy") @property def emeter_today(self) -> float | None: """Get the emeter value for today.""" - return self._convert_energy_data(self.energy.get("today_energy"), 1 / 1000) - - def _convert_energy_data(self, data, scale) -> float | None: - """Return adjusted emeter information.""" - return data if not data else data * scale + return self.energy.get("today_energy") diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index 549f4266e..b37c38e95 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -17,6 +17,7 @@ class DummyDevice: container=None, icon="mdi:dummy", type=FeatureType.BinarySensor, + unit="dummyunit", ) return feat @@ -30,6 +31,7 @@ def test_feature_api(dummy_feature: Feature): assert dummy_feature.container is None assert dummy_feature.icon == "mdi:dummy" assert dummy_feature.type == FeatureType.BinarySensor + assert dummy_feature.unit == "dummyunit" def test_feature_value(dummy_feature: Feature): From 72db5c6447cb4dab10e547e1eeee2312b5166a25 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 22 Apr 2024 13:39:07 +0200 Subject: [PATCH 382/892] Add temperature control module for smart (#848) --- kasa/device_type.py | 1 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/temperaturecontrol.py | 87 +++++++++++++++++++ kasa/smart/smartchilddevice.py | 1 + .../smart/modules/test_temperaturecontrol.py | 34 ++++++++ 5 files changed, 125 insertions(+) create mode 100644 kasa/smart/modules/temperaturecontrol.py create mode 100644 kasa/tests/smart/modules/test_temperaturecontrol.py diff --git a/kasa/device_type.py b/kasa/device_type.py index 6a97867cc..3d3b828dd 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -19,6 +19,7 @@ class DeviceType(Enum): Sensor = "sensor" Hub = "hub" Fan = "fan" + Thermostat = "thermostat" Unknown = "unknown" @staticmethod diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 938bc2b4d..b3b1d9f47 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -17,6 +17,7 @@ from .lighttransitionmodule import LightTransitionModule from .reportmodule import ReportModule from .temperature import TemperatureSensor +from .temperaturecontrol import TemperatureControl from .timemodule import TimeModule __all__ = [ @@ -28,6 +29,7 @@ "BatterySensor", "HumiditySensor", "TemperatureSensor", + "TemperatureControl", "ReportModule", "AutoOffModule", "LedModule", diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py new file mode 100644 index 000000000..8babf1164 --- /dev/null +++ b/kasa/smart/modules/temperaturecontrol.py @@ -0,0 +1,87 @@ +"""Implementation of temperature control module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...feature import Feature +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class TemperatureControl(SmartModule): + """Implementation of temperature module.""" + + REQUIRED_COMPONENT = "temperature_control" + + def __init__(self, device: SmartDevice, module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Target temperature", + container=self, + attribute_getter="target_temperature", + attribute_setter="set_target_temperature", + icon="mdi:thermometer", + ) + ) + # TODO: this might belong into its own module, temperature_correction? + self._add_feature( + Feature( + device, + "Temperature offset", + container=self, + attribute_getter="temperature_offset", + attribute_setter="set_temperature_offset", + minimum_value=-10, + maximum_value=10, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Target temperature is contained in the main device info response. + return {} + + @property + def minimum_target_temperature(self) -> int: + """Minimum available target temperature.""" + return self._device.sys_info["min_control_temp"] + + @property + def maximum_target_temperature(self) -> int: + """Minimum available target temperature.""" + return self._device.sys_info["max_control_temp"] + + @property + def target_temperature(self) -> int: + """Return target temperature.""" + return self._device.sys_info["target_temperature"] + + async def set_target_temperature(self, target: int): + """Set target temperature.""" + if ( + target < self.minimum_target_temperature + or target > self.maximum_target_temperature + ): + raise ValueError( + f"Invalid target temperature {target}, must be in range " + f"[{self.minimum_target_temperature},{self.maximum_target_temperature}]" + ) + + return await self.call("set_device_info", {"target_temp": target}) + + @property + def temperature_offset(self) -> int: + """Return temperature offset.""" + return self._device.sys_info["temp_offset"] + + async def set_temperature_offset(self, offset: int): + """Set temperature offset.""" + if offset < -10 or offset > 10: + raise ValueError("Temperature offset must be [-10, 10]") + + return await self.call("set_device_info", {"temp_offset": offset}) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index ecff7cfe7..8852262c2 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -52,6 +52,7 @@ def device_type(self) -> DeviceType: "subg.trigger.temp-hmdt-sensor": DeviceType.Sensor, "kasa.switch.outlet.sub-fan": DeviceType.Fan, "kasa.switch.outlet.sub-dimmer": DeviceType.Dimmer, + "subg.trv": DeviceType.Thermostat, } dev_type = child_device_map.get(self.sys_info["category"]) if dev_type is None: diff --git a/kasa/tests/smart/modules/test_temperaturecontrol.py b/kasa/tests/smart/modules/test_temperaturecontrol.py new file mode 100644 index 000000000..5768a4820 --- /dev/null +++ b/kasa/tests/smart/modules/test_temperaturecontrol.py @@ -0,0 +1,34 @@ +import pytest + +from kasa.smart.modules import TemperatureSensor +from kasa.tests.device_fixtures import parametrize + +temperature = parametrize( + "has temperature control", + component_filter="temperature_control", + protocol_filter={"SMART.CHILD"}, +) + + +@temperature +@pytest.mark.parametrize( + "feature, type", + [ + ("target_temperature", int), + ("temperature_offset", int), + ], +) +async def test_temperature_control_features(dev, feature, type): + """Test that features are registered and work as expected.""" + temp_module: TemperatureSensor = dev.modules["TemperatureControl"] + + prop = getattr(temp_module, feature) + assert isinstance(prop, type) + + feat = temp_module._module_features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + await feat.set_value(10) + await dev.update() + assert feat.value == 10 From e7d6758b8db4db3e26469ed830aec4e761767972 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 22 Apr 2024 14:53:49 +0200 Subject: [PATCH 383/892] Add rust tapo link to README (#857) * Added https://github.com/mihai-dinculescu/tapo/ to the list of related projects. * Changed the `kasa.tapo` to `kasa.smart` --------- Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 91235102f..1da3f0c49 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ See [supported devices in our documentation](SUPPORTED.md) for more detailed inf ### TP-Link Tapo support This library has recently added a limited supported for devices that carry Tapo branding. -That support is currently limited to the cli. The package `kasa.tapo` is in flux and if you +That support is currently limited to the cli. The package `kasa.smart` is in flux and if you use it directly you should expect it could break in future releases until this statement is removed. Other TAPO libraries are: @@ -276,3 +276,4 @@ Other TAPO libraries are: * [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) +* [rust and python implementation](https://github.com/mihai-dinculescu/tapo/) From 0ab7436eef18c926a59cc0e97552ded6930a1024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20D=C3=B6rr?= Date: Mon, 22 Apr 2024 15:24:15 +0100 Subject: [PATCH 384/892] Add support for KH100 hub (#847) Add SMART.KASAHUB to the map of supported devices. This also adds fixture files for KH100, KE100, and T310, and adapts affected modules and their tests accordingly. --------- Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- README.md | 1 + SUPPORTED.md | 5 + kasa/device_factory.py | 1 + kasa/deviceconfig.py | 1 + kasa/smart/modules/temperature.py | 21 +- kasa/smart/modules/temperaturecontrol.py | 8 +- kasa/tests/device_fixtures.py | 17 +- .../fixtures/smart/KH100(UK)_1.0_1.5.6.json | 1557 +++++++++++++++++ .../smart/child/KE100(EU)_1.0_2.8.0.json | 171 ++ .../smart/child/KE100(UK)_1.0_2.8.0.json | 171 ++ .../smart/child/T310(EU)_1.0_1.5.0.json | 530 ++++++ kasa/tests/smart/modules/test_temperature.py | 20 +- .../smart/modules/test_temperaturecontrol.py | 6 +- 13 files changed, 2488 insertions(+), 21 deletions(-) create mode 100644 kasa/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json create mode 100644 kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.8.0.json create mode 100644 kasa/tests/fixtures/smart/child/KE100(UK)_1.0_2.8.0.json create mode 100644 kasa/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json diff --git a/README.md b/README.md index 1da3f0c49..6db63734f 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,7 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: ES20M, HS200, HS210, HS220, KP405, KS200M, KS205\*, KS220M, KS225\*, KS230 - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 - **Light Strips**: KL400L5, KL420L5, KL430 +- **Hubs**: KH100\* ### Supported Tapo\* devices diff --git a/SUPPORTED.md b/SUPPORTED.md index 16fdb0e1d..1587e9663 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -126,6 +126,11 @@ Some newer Kasa devices require authentication. These are marked with *\* + ## Tapo devices diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 3c0ae7164..29cc36ffd 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -166,6 +166,7 @@ def get_device_class_from_family(device_type: str) -> type[Device] | None: "SMART.TAPOSWITCH": SmartBulb, "SMART.KASAPLUG": SmartDevice, "SMART.TAPOHUB": SmartDevice, + "SMART.KASAHUB": SmartDevice, "SMART.KASASWITCH": SmartBulb, "IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTBULB": IotBulb, diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 6ddff6ade..4144b784d 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -39,6 +39,7 @@ class DeviceFamilyType(Enum): SmartTapoBulb = "SMART.TAPOBULB" SmartTapoSwitch = "SMART.TAPOSWITCH" SmartTapoHub = "SMART.TAPOHUB" + SmartKasaHub = "SMART.KASAHUB" def _dataclass_from_dict(klass, in_val): diff --git a/kasa/smart/modules/temperature.py b/kasa/smart/modules/temperature.py index 2a5d73ba7..3cec427b5 100644 --- a/kasa/smart/modules/temperature.py +++ b/kasa/smart/modules/temperature.py @@ -28,16 +28,17 @@ def __init__(self, device: SmartDevice, module: str): icon="mdi:thermometer", ) ) - self._add_feature( - Feature( - device, - "Temperature warning", - container=self, - attribute_getter="temperature_warning", - type=FeatureType.BinarySensor, - icon="mdi:alert", + if "current_temp_exception" in device.sys_info: + self._add_feature( + Feature( + device, + "Temperature warning", + container=self, + attribute_getter="temperature_warning", + type=FeatureType.BinarySensor, + icon="mdi:alert", + ) ) - ) self._add_feature( Feature( device, @@ -57,7 +58,7 @@ def temperature(self): @property def temperature_warning(self) -> bool: """Return True if temperature is outside of the wanted range.""" - return self._device.sys_info["current_temp_exception"] != 0 + return self._device.sys_info.get("current_temp_exception", 0) != 0 @property def temperature_unit(self): diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index 8babf1164..9106a56fa 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -14,7 +14,7 @@ class TemperatureControl(SmartModule): """Implementation of temperature module.""" - REQUIRED_COMPONENT = "temperature_control" + REQUIRED_COMPONENT = "temp_control" def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) @@ -57,11 +57,11 @@ def maximum_target_temperature(self) -> int: return self._device.sys_info["max_control_temp"] @property - def target_temperature(self) -> int: + def target_temperature(self) -> float: """Return target temperature.""" - return self._device.sys_info["target_temperature"] + return self._device.sys_info["target_temp"] - async def set_target_temperature(self, target: int): + async def set_target_temperature(self, target: float): """Set target temperature.""" if ( target < self.minimum_target_temperature diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 362015db9..3cad6357e 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -107,8 +107,9 @@ *DIMMERS_SMART, } -HUBS_SMART = {"H100"} -SENSORS_SMART = {"T315"} +HUBS_SMART = {"H100", "KH100"} +SENSORS_SMART = {"T310", "T315"} +THERMOSTATS_SMART = {"KE100"} WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} WITH_EMETER_SMART = {"P110", "KP125M", "EP25"} @@ -126,6 +127,7 @@ .union(HUBS_SMART) .union(SENSORS_SMART) .union(SWITCHES_SMART) + .union(THERMOSTATS_SMART) ) ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) @@ -275,6 +277,9 @@ def parametrize( sensors_smart = parametrize( "sensors smart", model_filter=SENSORS_SMART, protocol_filter={"SMART.CHILD"} ) +thermostats_smart = parametrize( + "thermostats smart", model_filter=THERMOSTATS_SMART, protocol_filter={"SMART.CHILD"} +) device_smart = parametrize( "devices smart", model_filter=ALL_DEVICES_SMART, protocol_filter={"SMART"} ) @@ -296,6 +301,7 @@ def check_categories(): + dimmers_smart.args[1] + hubs_smart.args[1] + sensors_smart.args[1] + + thermostats_smart.args[1] ) diffs: set[FixtureInfo] = set(FIXTURE_DATA) - set(categorized_fixtures) if diffs: @@ -313,7 +319,12 @@ def check_categories(): def device_for_fixture_name(model, protocol): if "SMART" in protocol: for d in chain( - PLUGS_SMART, SWITCHES_SMART, STRIPS_SMART, HUBS_SMART, SENSORS_SMART + PLUGS_SMART, + SWITCHES_SMART, + STRIPS_SMART, + HUBS_SMART, + SENSORS_SMART, + THERMOSTATS_SMART, ): if d in model: return SmartDevice diff --git a/kasa/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json b/kasa/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json new file mode 100644 index 000000000..33e4cec68 --- /dev/null +++ b/kasa/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json @@ -0,0 +1,1557 @@ + { + "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": "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": "child_device", + "ver_code": 1 + }, + { + "id": "child_quick_setup", + "ver_code": 1 + }, + { + "id": "child_inherit", + "ver_code": 1 + }, + { + "id": "control_child", + "ver_code": 1 + }, + { + "id": "alarm", + "ver_code": 1 + }, + { + "id": "device_load", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "alarm_logs", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KH100(UK)", + "device_type": "SMART.KASAHUB", + "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_alarm_configure": { + "duration": 300, + "type": "Doorbell Ring 1", + "volume": "high" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 900 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_6" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_7" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_8" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_9" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_10" + } + ], + "start_index": 0, + "sum": 10 + }, + "get_child_device_list": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "kasa_trv", + "bind_count": 1, + "category": "subg.trv", + "child_protection": false, + "current_temp": 20.1, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "frost_protection_on": false, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -117, + "jamming_signal_level": 1, + "location": "", + "mac": "F0A731000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "rssi": -45, + "signal_level": 3, + "specs": "UK", + "status": "online", + "status_follow_edge": false, + "target_temp": 21.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + { + "at_low_battery": false, + "avatar": "kasa_trv", + "bind_count": 1, + "category": "subg.trv", + "child_protection": false, + "current_temp": 19.5, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "frost_protection_on": false, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -117, + "jamming_signal_level": 1, + "location": "", + "mac": "F0A731000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "rssi": -52, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "target_temp": 21.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + { + "at_low_battery": false, + "avatar": "kasa_trv", + "bind_count": 1, + "category": "subg.trv", + "child_protection": false, + "current_temp": 18.1, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "frost_protection_on": false, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -120, + "jamming_signal_level": 1, + "location": "", + "mac": "F0A731000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "rssi": -46, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "target_temp": 21.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + { + "at_low_battery": false, + "avatar": "kasa_trv", + "bind_count": 1, + "category": "subg.trv", + "child_protection": false, + "current_temp": 20.0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_6", + "frost_protection_on": false, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -114, + "jamming_signal_level": 1, + "location": "", + "mac": "A842A1000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "rssi": -40, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "target_temp": 21.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + { + "at_low_battery": false, + "avatar": "kasa_trv", + "bind_count": 1, + "category": "subg.trv", + "child_protection": false, + "current_temp": 19.2, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_7", + "frost_protection_on": false, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -121, + "jamming_signal_level": 1, + "location": "", + "mac": "F0A731000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "rssi": -73, + "signal_level": 2, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "target_temp": 21.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + { + "at_low_battery": false, + "avatar": "balcony", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 63, + "current_humidity_exception": 3, + "current_temp": 11.9, + "current_temp_exception": -8.1, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.5.0 Build 230105 Rel.150707", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -118, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1713199738, + "mac": "40AE30000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -63, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 54, + "current_humidity_exception": 0, + "current_temp": 19.1, + "current_temp_exception": -0.9, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.5.0 Build 230105 Rel.150707", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -123, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1712755472, + "mac": "40AE30000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -57, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 52, + "current_humidity_exception": 0, + "current_temp": 20.0, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_8", + "fw_ver": "1.5.0 Build 230105 Rel.150707", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -119, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1706550338, + "mac": "E4FAC4000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -68, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 60, + "current_humidity_exception": 0, + "current_temp": 20.1, + "current_temp_exception": 0.1, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_9", + "fw_ver": "1.5.0 Build 230105 Rel.150707", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -116, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1706551426, + "mac": "E4FAC4000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -70, + "signal_level": 2, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 54, + "current_humidity_exception": 0, + "current_temp": 19.3, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_10", + "fw_ver": "1.5.0 Build 230105 Rel.150707", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -120, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1706789728, + "mac": "E4FAC4000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -81, + "signal_level": 1, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + } + ], + "start_index": 0, + "sum": 10 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "kasa_hub", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.5.6 Build 240202 Rel.164142", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "in_alarm_source": "", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "model": "KH100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/London", + "rssi": -37, + "signal_level": 3, + "specs": "UK", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.KASAHUB" + }, + "get_device_load_info": { + "cur_load_num": 24, + "load_level": "light", + "max_load_num": 64, + "total_memory": 4352, + "used_memory": 1581 + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1713550228 + }, + "get_device_usage": {}, + "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.5.6 Build 240202 Rel.164142", + "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": 358, + "night_mode_type": "sunrise_sunset", + "start_time": 1210, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_support_alarm_type_list": { + "alarm_type_list": [ + "Doorbell Ring 1", + "Doorbell Ring 2", + "Doorbell Ring 3", + "Doorbell Ring 4", + "Doorbell Ring 5", + "Doorbell Ring 6", + "Doorbell Ring 7", + "Doorbell Ring 8", + "Doorbell Ring 9", + "Doorbell Ring 10", + "Phone Ring", + "Alarm 1", + "Alarm 2", + "Alarm 3", + "Alarm 4", + "Dripping Tap", + "Alarm 5", + "Connection 1", + "Connection 2" + ] + }, + "get_support_child_device_category": { + "device_category_list": [ + { + "category": "subg.trv" + }, + { + "category": "subg.trigger" + }, + { + "category": "subg.plugswitch" + } + ] + }, + "get_wireless_scan_info": { + "ap_list": [], + "start_index": 0, + "sum": 0, + "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": "KH100", + "device_type": "SMART.KASAHUB", + "is_klap": false + } + } +} diff --git a/kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.8.0.json b/kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.8.0.json new file mode 100644 index 000000000..14bb10c97 --- /dev/null +++ b/kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.8.0.json @@ -0,0 +1,171 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "kasa_trv", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trv", + "child_protection": false, + "current_temp": 19.2, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_7", + "frost_protection_on": false, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -121, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1705684116, + "location": "", + "mac": "F0A731000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "rssi": -73, + "signal_level": 2, + "specs": "EU", + "status": "online", + "target_temp": 21.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + } +} diff --git a/kasa/tests/fixtures/smart/child/KE100(UK)_1.0_2.8.0.json b/kasa/tests/fixtures/smart/child/KE100(UK)_1.0_2.8.0.json new file mode 100644 index 000000000..199d572a6 --- /dev/null +++ b/kasa/tests/fixtures/smart/child/KE100(UK)_1.0_2.8.0.json @@ -0,0 +1,171 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "kasa_trv", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trv", + "child_protection": false, + "current_temp": 20.1, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "frost_protection_on": false, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -117, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1705677078, + "location": "", + "mac": "F0A731000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "rssi": -45, + "signal_level": 3, + "specs": "UK", + "status": "online", + "target_temp": 21.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + } +} diff --git a/kasa/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json b/kasa/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json new file mode 100644 index 000000000..d48875e5f --- /dev/null +++ b/kasa/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json @@ -0,0 +1,530 @@ +{ + "component_nego" : { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 54, + "current_humidity_exception": 0, + "current_temp": 19.3, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_10", + "fw_ver": "1.5.0 Build 230105 Rel.150707", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -120, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1706789728, + "mac": "E4FAC4000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -81, + "signal_level": 1, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.5.0 Build 230105 Rel.150707", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_temp_humidity_records": { + "local_time": 1713550233, + "past24h_humidity": [ + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 62, + 61, + 61, + 62, + 61, + 60, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 63, + 63, + 63, + 64, + 63, + 63, + 63, + 63, + 62, + 63, + 63, + 62, + 62, + 62, + 62, + 62, + 61, + 62, + 61, + 61, + 61, + 61, + 61, + 61, + 60, + 61, + 64, + 64, + 61, + 61, + 63, + 60, + 60, + 60, + 60, + 59, + 59, + 59, + 59, + 59, + 58, + 58, + 58, + 57, + 55 + ], + "past24h_humidity_exception": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 1, + 2, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 3, + 3, + 3, + 4, + 3, + 3, + 3, + 3, + 2, + 3, + 3, + 2, + 2, + 2, + 2, + 2, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 4, + 4, + 1, + 1, + 3, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "past24h_temp": [ + 175, + 175, + 174, + 174, + 173, + 172, + 172, + 171, + 170, + 169, + 169, + 167, + 167, + 166, + 165, + 164, + 163, + 163, + 162, + 162, + 162, + 162, + 163, + 163, + 162, + 162, + 161, + 160, + 159, + 159, + 159, + 159, + 158, + 158, + 159, + 159, + 158, + 159, + 159, + 159, + 159, + 159, + 159, + 159, + 159, + 159, + 158, + 158, + 158, + 158, + 158, + 158, + 159, + 159, + 160, + 161, + 161, + 162, + 162, + 162, + 162, + 162, + 163, + 163, + 166, + 168, + 170, + 172, + 174, + 175, + 176, + 177, + 179, + 181, + 183, + 184, + 185, + 187, + 189, + 190, + 190, + 193, + 194, + 194, + 194, + 194, + 194, + 194, + 195, + 195, + 195, + 196, + 196, + 196, + 195, + 193 + ], + "past24h_temp_exception": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + -1, + -1, + -1, + -1, + -2, + -2, + -1, + -1, + -2, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -2, + -2, + -2, + -2, + -2, + -2, + -1, + -1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [], + "start_id": 0, + "sum": 0 + } +} diff --git a/kasa/tests/smart/modules/test_temperature.py b/kasa/tests/smart/modules/test_temperature.py index 3b9ab50e2..a7d20dac6 100644 --- a/kasa/tests/smart/modules/test_temperature.py +++ b/kasa/tests/smart/modules/test_temperature.py @@ -7,13 +7,18 @@ "has temperature", component_filter="temperature", protocol_filter={"SMART.CHILD"} ) +temperature_warning = parametrize( + "has temperature warning", + component_filter="comfort_temperature", + protocol_filter={"SMART.CHILD"}, +) + @temperature @pytest.mark.parametrize( "feature, type", [ ("temperature", float), - ("temperature_warning", bool), ("temperature_unit", str), ], ) @@ -27,3 +32,16 @@ async def test_temperature_features(dev, feature, type): feat = temp_module._module_features[feature] assert feat.value == prop assert isinstance(feat.value, type) + + +@temperature_warning +async def test_temperature_warning(dev): + """Test that features are registered and work as expected.""" + temp_module: TemperatureSensor = dev.modules["TemperatureSensor"] + + assert hasattr(temp_module, "temperature_warning") + assert isinstance(temp_module.temperature_warning, bool) + + feat = temp_module._module_features["temperature_warning"] + assert feat.value == temp_module.temperature_warning + assert isinstance(feat.value, bool) diff --git a/kasa/tests/smart/modules/test_temperaturecontrol.py b/kasa/tests/smart/modules/test_temperaturecontrol.py index 5768a4820..5f6e3b56e 100644 --- a/kasa/tests/smart/modules/test_temperaturecontrol.py +++ b/kasa/tests/smart/modules/test_temperaturecontrol.py @@ -1,7 +1,7 @@ import pytest from kasa.smart.modules import TemperatureSensor -from kasa.tests.device_fixtures import parametrize +from kasa.tests.device_fixtures import parametrize, thermostats_smart temperature = parametrize( "has temperature control", @@ -10,11 +10,11 @@ ) -@temperature +@thermostats_smart @pytest.mark.parametrize( "feature, type", [ - ("target_temperature", int), + ("target_temperature", float), ("temperature_offset", int), ], ) From 03a0ef3cc3397ef46f3cc98478baae93b8fc5ebe Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 22 Apr 2024 18:17:11 +0100 Subject: [PATCH 385/892] Include component_nego with child fixtures (#858) --- devtools/dump_devinfo.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 238522e64..fe5b8ab37 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -500,6 +500,14 @@ async def get_smart_test_calls(device: SmartDevice): # Child component calls for child_device_id, child_components in child_device_components.items(): + test_calls.append( + SmartCall( + module="component_nego", + request=SmartRequest("component_nego"), + should_succeed=True, + child_device_id=child_device_id, + ) + ) for component_id, ver_code in child_components.items(): if (requests := get_component_requests(component_id, ver_code)) is not None: component_test_calls = [ @@ -621,7 +629,8 @@ async def get_smart_fixtures(device: SmartDevice, batch_size: int): response["get_device_info"]["device_id"] = scrubbed # If the child is a different model to the parent create a seperate fixture if ( - "get_device_info" in response + "component_nego" in response + and "get_device_info" in response and (child_model := response["get_device_info"].get("model")) and child_model != final["get_device_info"]["model"] ): From aa969ef020718ccf3a463f427d74c5012f5eba57 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 23 Apr 2024 12:56:32 +0100 Subject: [PATCH 386/892] Better firmware module support for devices not connected to the internet (#854) Devices not connected to the internet will either error when querying firmware queries (e.g. P300) or return misleading information (e.g. P100). This PR adds the cloud connect query to the initial queries and bypasses the firmware module if not connected. --- kasa/smart/modules/cloudmodule.py | 3 ++ kasa/smart/modules/firmware.py | 12 +++--- kasa/smart/smartdevice.py | 13 +++++- kasa/tests/fakeprotocol_smart.py | 3 +- kasa/tests/test_smartdevice.py | 68 ++++++++++++++++++++++++++++++- 5 files changed, 89 insertions(+), 10 deletions(-) diff --git a/kasa/smart/modules/cloudmodule.py b/kasa/smart/modules/cloudmodule.py index d53633f2e..951ff7894 100644 --- a/kasa/smart/modules/cloudmodule.py +++ b/kasa/smart/modules/cloudmodule.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING +from ...exceptions import SmartErrorCode from ...feature import Feature, FeatureType from ..smartmodule import SmartModule @@ -34,4 +35,6 @@ def __init__(self, device: SmartDevice, module: str): @property def is_connected(self): """Return True if device is connected to the cloud.""" + if isinstance(self.data, SmartErrorCode): + return False return self.data["status"] == 0 diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 88effe07e..eacfd7029 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional from ...exceptions import SmartErrorCode from ...feature import Feature, FeatureType @@ -74,9 +74,7 @@ def __init__(self, device: SmartDevice, module: str): def query(self) -> dict: """Query to execute during the update cycle.""" - req = { - "get_latest_fw": None, - } + req: dict[str, Any] = {"get_latest_fw": None} if self.supported_version > 1: req["get_auto_update_info"] = None return req @@ -85,15 +83,17 @@ def query(self) -> dict: def latest_firmware(self): """Return latest firmware information.""" fw = self.data.get("get_latest_fw") or self.data - if isinstance(fw, SmartErrorCode): + if not self._device.is_cloud_connected or isinstance(fw, SmartErrorCode): # Error in response, probably disconnected from the cloud. return UpdateInfo(type=0, need_to_upgrade=False) return UpdateInfo.parse_obj(fw) @property - def update_available(self): + def update_available(self) -> bool | None: """Return True if update is available.""" + if not self._device.is_cloud_connected: + return None return self.latest_firmware.update_available async def get_update_state(self): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 32cf7cfe0..6bd8774a8 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -104,7 +104,11 @@ async def _negotiate(self): We fetch the device info and the available components as early as possible. If the device reports supporting child devices, they are also initialized. """ - initial_query = {"component_nego": None, "get_device_info": None} + initial_query = { + "component_nego": None, + "get_device_info": None, + "get_connect_cloud_state": None, + } resp = await self.protocol.query(initial_query) # Save the initial state to allow modules access the device info already @@ -238,6 +242,13 @@ async def _initialize_features(self): for feat in module._module_features.values(): self._add_feature(feat) + @property + def is_cloud_connected(self): + """Returns if the device is connected to the cloud.""" + if "CloudModule" not in self.modules: + return False + return self.modules["CloudModule"].is_connected + @property def sys_info(self) -> dict[str, Any]: """Returns the device info.""" diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index d03d04c42..32da9304a 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -65,7 +65,6 @@ def credentials_hash(self): }, }, ), - "get_connect_cloud_state": ("cloud_connect", {"status": 1}), "get_on_off_gradually_info": ("on_off_gradually", {"enable": True}), "get_latest_fw": ( "firmware", @@ -172,7 +171,7 @@ def _send_request(self, request_dict: dict): # calling the unsupported device in the first place. retval = { "error_code": SmartErrorCode.PARAMS_ERROR.value, - "method": "get_device_usage", + "method": method, } # Reduce warning spam by consolidating and reporting at the end of the run if self.fixture_name not in pytest.fixtures_missing_methods: diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index ffcd57aed..32bd32975 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -4,6 +4,7 @@ import logging from typing import Any +from unittest.mock import patch import pytest from pytest_mock import MockerFixture @@ -77,7 +78,13 @@ async def test_negotiate(dev: SmartDevice, mocker: MockerFixture): await dev._negotiate() # Check that we got the initial negotiation call - query.assert_any_call({"component_nego": None, "get_device_info": None}) + query.assert_any_call( + { + "component_nego": None, + "get_device_info": None, + "get_connect_cloud_state": None, + } + ) assert dev._components_raw # Check the children are created, if device supports them @@ -128,3 +135,62 @@ async def test_smartdevice_brightness(dev: SmartBulb): with pytest.raises(ValueError): await dev.set_brightness(feature.maximum_value + 10) + + +@device_smart +async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixture): + """Test is_cloud_connected property.""" + assert isinstance(dev, SmartDevice) + assert "cloud_connect" in dev._components + + is_connected = ( + (cc := dev._last_update.get("get_connect_cloud_state")) + and not isinstance(cc, SmartErrorCode) + and cc["status"] == 0 + ) + + assert dev.is_cloud_connected == is_connected + last_update = dev._last_update + + last_update["get_connect_cloud_state"] = {"status": 0} + with patch.object(dev.protocol, "query", return_value=last_update): + await dev.update() + assert dev.is_cloud_connected is True + + last_update["get_connect_cloud_state"] = {"status": 1} + with patch.object(dev.protocol, "query", return_value=last_update): + await dev.update() + assert dev.is_cloud_connected is False + + last_update["get_connect_cloud_state"] = SmartErrorCode.UNKNOWN_METHOD_ERROR + with patch.object(dev.protocol, "query", return_value=last_update): + await dev.update() + assert dev.is_cloud_connected is False + + # Test for no cloud_connect component during device initialisation + component_list = [ + val + for val in dev._components_raw["component_list"] + if val["id"] not in {"cloud_connect"} + ] + initial_response = { + "component_nego": {"component_list": component_list}, + "get_connect_cloud_state": last_update["get_connect_cloud_state"], + "get_device_info": last_update["get_device_info"], + } + # Child component list is not stored on the device + if "get_child_device_list" in last_update: + child_component_list = await dev.protocol.query( + "get_child_device_component_list" + ) + last_update["get_child_device_component_list"] = child_component_list[ + "get_child_device_component_list" + ] + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + with patch.object( + new_dev.protocol, + "query", + side_effect=[initial_response, last_update, last_update], + ): + await new_dev.update() + assert new_dev.is_cloud_connected is False From b860c32d5f7f828b39cb718394d91530d1a10a61 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 23 Apr 2024 19:20:12 +0200 Subject: [PATCH 387/892] Implement feature categories (#846) Initial implementation for feature categories to help downstreams and our cli tool to categorize the data for more user-friendly manner. As more and more information is being exposed through the generic features interface, it is necessary to give some hints to downstreams about how might want to present the information to users. This is not a 1:1 mapping to the homeassistant's mental model, and it will be necessary to fine-tune homeassistant-specific parameters by other means to polish the presentation. --- kasa/cli.py | 58 ++++++++++++++++++++++-------- kasa/device.py | 8 ++--- kasa/feature.py | 44 +++++++++++++++++++++++ kasa/iot/iotbulb.py | 2 ++ kasa/iot/iotdevice.py | 6 +++- kasa/smart/modules/brightness.py | 1 + kasa/smart/modules/colortemp.py | 1 + kasa/smart/modules/fanmodule.py | 1 + kasa/smart/modules/ledmodule.py | 1 + kasa/smart/modules/reportmodule.py | 1 + kasa/smart/modules/timemodule.py | 1 + kasa/smart/smartdevice.py | 20 +++++++++-- 12 files changed, 123 insertions(+), 21 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index b5babdbb7..b527bef1f 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -25,6 +25,7 @@ DeviceFamilyType, Discover, EncryptType, + Feature, KasaException, UnsupportedDeviceError, ) @@ -583,6 +584,41 @@ async def sysinfo(dev): return dev.sys_info +def _echo_features( + features: dict[str, Feature], title: str, category: Feature.Category | None = None +): + """Print out a listing of features and their values.""" + if category is not None: + features = { + id_: feat for id_, feat in features.items() if feat.category == category + } + + if not features: + return + echo(f"[bold]{title}[/bold]") + for _, feat in features.items(): + try: + echo(f"\t{feat}") + except Exception as ex: + echo(f"\t{feat.name} ({feat.id}): got exception (%s)" % ex) + + +def _echo_all_features(features, title_prefix=None): + """Print out all features by category.""" + if title_prefix is not None: + echo(f"[bold]\n\t == {title_prefix} ==[/bold]") + _echo_features( + features, title="\n\t== Primary features ==", category=Feature.Category.Primary + ) + _echo_features( + features, title="\n\t== Information ==", category=Feature.Category.Info + ) + _echo_features( + features, title="\n\t== Configuration ==", category=Feature.Category.Config + ) + _echo_features(features, title="\n\t== Debug ==", category=Feature.Category.Debug) + + @cli.command() @pass_dev @click.pass_context @@ -595,15 +631,13 @@ async def state(ctx, dev: Device): echo(f"\tPort: {dev.port}") echo(f"\tDevice state: {dev.is_on}") if dev.children: - echo("\t[bold]== Children ==[/bold]") + echo("\t== Children ==") for child in dev.children: - echo(f"\t* {child.alias} ({child.model}, {child.device_type})") - for id_, feat in child.features.items(): - try: - unit = f" {feat.unit}" if feat.unit else "" - echo(f"\t\t{feat.name} ({id_}): {feat.value}{unit}") - except Exception as ex: - echo(f"\t\t{feat.name}: got exception (%s)" % ex) + _echo_all_features( + child.features, + title_prefix=f"{child.alias} ({child.model}, {child.device_type})", + ) + echo() echo("\t[bold]== Generic information ==[/bold]") @@ -613,19 +647,15 @@ async def state(ctx, dev: Device): echo(f"\tMAC (rssi): {dev.mac} ({dev.rssi})") echo(f"\tLocation: {dev.location}") - echo("\n\t[bold]== Device-specific information == [/bold]") - for id_, feature in dev.features.items(): - unit = f" {feature.unit}" if feature.unit else "" - echo(f"\t{feature.name} ({id_}): {feature.value}{unit}") + _echo_all_features(dev.features) echo("\n\t[bold]== Modules ==[/bold]") for module in dev.modules.values(): echo(f"\t[green]+ {module}[/green]") if verbose: - echo("\n\t[bold]== Verbose information ==[/bold]") + echo("\n\t[bold]== Protocol information ==[/bold]") echo(f"\tCredentials hash: {dev.credentials_hash}") - echo(f"\tDevice ID: {dev.device_id}") echo() _echo_discovery_info(dev._discovery_info) return dev.internal_state diff --git a/kasa/device.py b/kasa/device.py index a4c2b5e3a..dda7822f9 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -318,10 +318,10 @@ def features(self) -> dict[str, Feature]: def _add_feature(self, feature: Feature): """Add a new feature to the device.""" - desc_name = feature.name.lower().replace(" ", "_") - if desc_name in self._features: - raise KasaException("Duplicate feature name %s" % desc_name) - self._features[desc_name] = feature + if feature.id in self._features: + raise KasaException("Duplicate feature id %s" % feature.id) + assert feature.id is not None # TODO: hack for typing # noqa: S101 + self._features[feature.id] = feature @property @abstractmethod diff --git a/kasa/feature.py b/kasa/feature.py index 6add0091a..c1bbc97b0 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -10,6 +10,7 @@ from .device import Device +# TODO: This is only useful for Feature, so maybe move to Feature.Type? class FeatureType(Enum): """Type to help decide how to present the feature.""" @@ -24,6 +25,22 @@ class FeatureType(Enum): class Feature: """Feature defines a generic interface for device features.""" + class Category(Enum): + """Category hint for downstreams.""" + + #: Primary features control the device state directly. + #: Examples including turning the device on, or adjust its brightness. + Primary = auto() + #: Config features change device behavior without immediate state changes. + Config = auto() + #: Informative/sensor features deliver some potentially interesting information. + Info = auto() + #: Debug features deliver more verbose information then informative features. + #: You may want to hide these per default to avoid cluttering your UI. + Debug = auto() + #: The default category if none is specified. + Unset = -1 + #: Device instance required for getting and setting values device: Device #: User-friendly short description @@ -38,6 +55,8 @@ class Feature: icon: str | None = None #: Unit, if applicable unit: str | None = None + #: Category hint for downstreams + category: Feature.Category = Category.Unset #: Type of the feature type: FeatureType = FeatureType.Sensor @@ -50,14 +69,29 @@ class Feature: #: If set, this property will be used to set *minimum_value* and *maximum_value*. range_getter: str | None = None + #: Identifier + id: str | None = None + def __post_init__(self): """Handle late-binding of members.""" + # Set id, if unset + if self.id is None: + self.id = self.name.lower().replace(" ", "_") + + # Populate minimum & maximum values, if range_getter is given container = self.container if self.container is not None else self.device if self.range_getter is not None: self.minimum_value, self.maximum_value = getattr( container, self.range_getter ) + # Set the category, if unset + if self.category is Feature.Category.Unset: + if self.attribute_setter: + self.category = Feature.Category.Config + else: + self.category = Feature.Category.Info + @property def value(self): """Return the current value.""" @@ -79,3 +113,13 @@ async def set_value(self, value): container = self.container if self.container is not None else self.device return await getattr(container, self.attribute_setter)(value) + + def __repr__(self): + s = f"{self.name} ({self.id}): {self.value}" + if self.unit is not None: + s += f" {self.unit}" + + if self.type == FeatureType.Number: + s += f" (range: {self.minimum_value}-{self.maximum_value})" + + return s diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 26f40f06c..834c49b11 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -221,6 +221,7 @@ async def _initialize_features(self): minimum_value=1, maximum_value=100, type=FeatureType.Number, + category=Feature.Category.Primary, ) ) @@ -233,6 +234,7 @@ async def _initialize_features(self): attribute_getter="color_temp", attribute_setter="set_color_temp", range_getter="valid_temperature_range", + category=Feature.Category.Primary, ) ) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 32781a54c..d4551d0db 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -306,7 +306,11 @@ async def update(self, update_children: bool = True): async def _initialize_features(self): self._add_feature( Feature( - device=self, name="RSSI", attribute_getter="rssi", icon="mdi:signal" + device=self, + name="RSSI", + attribute_getter="rssi", + icon="mdi:signal", + category=Feature.Category.Debug, ) ) if "on_time" in self._sys_info: diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index 1f0b4d995..eaacf644e 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -32,6 +32,7 @@ def __init__(self, device: SmartDevice, module: str): minimum_value=BRIGHTNESS_MIN, maximum_value=BRIGHTNESS_MAX, type=FeatureType.Number, + category=Feature.Category.Primary, ) ) diff --git a/kasa/smart/modules/colortemp.py b/kasa/smart/modules/colortemp.py index 2ecb09ddc..e0bfec6ac 100644 --- a/kasa/smart/modules/colortemp.py +++ b/kasa/smart/modules/colortemp.py @@ -33,6 +33,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="color_temp", attribute_setter="set_color_temp", range_getter="valid_temperature_range", + category=Feature.Category.Primary, ) ) diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fanmodule.py index 1d79cdead..7c4404346 100644 --- a/kasa/smart/modules/fanmodule.py +++ b/kasa/smart/modules/fanmodule.py @@ -30,6 +30,7 @@ def __init__(self, device: SmartDevice, module: str): type=FeatureType.Number, minimum_value=1, maximum_value=4, + category=Feature.Category.Primary, ) ) self._add_feature( diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py index cac447b5b..75f904258 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/ledmodule.py @@ -28,6 +28,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="led", attribute_setter="set_led", type=FeatureType.Switch, + category=Feature.Category.Config, ) ) diff --git a/kasa/smart/modules/reportmodule.py b/kasa/smart/modules/reportmodule.py index 0f3987bd0..99d95fec1 100644 --- a/kasa/smart/modules/reportmodule.py +++ b/kasa/smart/modules/reportmodule.py @@ -25,6 +25,7 @@ def __init__(self, device: SmartDevice, module: str): "Report interval", container=self, attribute_getter="report_interval", + category=Feature.Category.Debug, ) ) diff --git a/kasa/smart/modules/timemodule.py b/kasa/smart/modules/timemodule.py index 7a0eb51b9..80f1308e5 100644 --- a/kasa/smart/modules/timemodule.py +++ b/kasa/smart/modules/timemodule.py @@ -28,6 +28,7 @@ def __init__(self, device: SmartDevice, module: str): name="Time", attribute_getter="time", container=self, + category=Feature.Category.Debug, ) ) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 6bd8774a8..69e4fe878 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -176,7 +176,14 @@ async def _initialize_modules(self): async def _initialize_features(self): """Initialize device features.""" - self._add_feature(Feature(self, "Device ID", attribute_getter="device_id")) + self._add_feature( + Feature( + self, + "Device ID", + attribute_getter="device_id", + category=Feature.Category.Debug, + ) + ) if "device_on" in self._info: self._add_feature( Feature( @@ -185,6 +192,7 @@ async def _initialize_features(self): attribute_getter="is_on", attribute_setter="set_state", type=FeatureType.Switch, + category=Feature.Category.Primary, ) ) @@ -195,6 +203,7 @@ async def _initialize_features(self): "Signal Level", attribute_getter=lambda x: x._info["signal_level"], icon="mdi:signal", + category=Feature.Category.Info, ) ) @@ -205,13 +214,18 @@ async def _initialize_features(self): "RSSI", attribute_getter=lambda x: x._info["rssi"], icon="mdi:signal", + category=Feature.Category.Debug, ) ) if "ssid" in self._info: self._add_feature( Feature( - device=self, name="SSID", attribute_getter="ssid", icon="mdi:wifi" + device=self, + name="SSID", + attribute_getter="ssid", + icon="mdi:wifi", + category=Feature.Category.Debug, ) ) @@ -223,6 +237,7 @@ async def _initialize_features(self): attribute_getter=lambda x: x._info["overheated"], icon="mdi:heat-wave", type=FeatureType.BinarySensor, + category=Feature.Category.Debug, ) ) @@ -235,6 +250,7 @@ async def _initialize_features(self): name="On since", attribute_getter="on_since", icon="mdi:clock", + category=Feature.Category.Debug, ) ) From 6e5cae1f47cc3eed96d2374e6b260f97cc08cdbe Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 23 Apr 2024 19:49:04 +0200 Subject: [PATCH 388/892] Implement action feature (#849) Adds `FeatureType.Action` making it possible to expose features like "reboot", "test alarm", "pair" etc. The `attribute_getter` is no longer mandatory, but it will raise an exception if not defined for other types than actions. Trying to read returns a static string ``. This overloads the `set_value` to call the given callable on any value. This also fixes the `play` and `stop` coroutines of the alarm module to await the call. --- kasa/feature.py | 12 ++++++++++-- kasa/smart/modules/alarmmodule.py | 22 ++++++++++++++++++++-- kasa/tests/test_feature.py | 22 ++++++++++++++++++++-- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index c1bbc97b0..ffa3df448 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -17,7 +17,7 @@ class FeatureType(Enum): Sensor = auto() BinarySensor = auto() Switch = auto() - Button = auto() + Action = auto() Number = auto() @@ -46,7 +46,7 @@ class Category(Enum): #: User-friendly short description name: str #: Name of the property that allows accessing the value - attribute_getter: str | Callable + attribute_getter: str | Callable | None = None #: Name of the method that allows changing the value attribute_setter: str | None = None #: Container storing the data, this overrides 'device' for getters @@ -95,6 +95,11 @@ def __post_init__(self): @property def value(self): """Return the current value.""" + if self.type == FeatureType.Action: + return "" + if self.attribute_getter is None: + raise ValueError("Not an action and no attribute_getter set") + container = self.container if self.container is not None else self.device if isinstance(self.attribute_getter, Callable): return self.attribute_getter(container) @@ -112,6 +117,9 @@ async def set_value(self, value): ) container = self.container if self.container is not None else self.device + if self.type == FeatureType.Action: + return await getattr(container, self.attribute_setter)() + return await getattr(container, self.attribute_setter)(value) def __repr__(self): diff --git a/kasa/smart/modules/alarmmodule.py b/kasa/smart/modules/alarmmodule.py index 667903262..30e432f47 100644 --- a/kasa/smart/modules/alarmmodule.py +++ b/kasa/smart/modules/alarmmodule.py @@ -54,6 +54,24 @@ def __init__(self, device: SmartDevice, module: str): device, "Alarm volume", container=self, attribute_getter="alarm_volume" ) ) + self._add_feature( + Feature( + device, + "Test alarm", + container=self, + attribute_setter="play", + type=FeatureType.Action, + ) + ) + self._add_feature( + Feature( + device, + "Stop alarm", + container=self, + attribute_setter="stop", + type=FeatureType.Action, + ) + ) @property def alarm_sound(self): @@ -83,8 +101,8 @@ def source(self) -> str | None: async def play(self): """Play alarm.""" - return self.call("play_alarm") + return await self.call("play_alarm") async def stop(self): """Stop alarm.""" - return self.call("stop_alarm") + return await self.call("stop_alarm") diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index b37c38e95..db2d27a8e 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -3,11 +3,13 @@ from kasa import Feature, FeatureType +class DummyDevice: + pass + + @pytest.fixture def dummy_feature() -> Feature: # create_autospec for device slows tests way too much, so we use a dummy here - class DummyDevice: - pass feat = Feature( device=DummyDevice(), # type: ignore[arg-type] @@ -79,3 +81,19 @@ async def test_feature_setter_read_only(dummy_feature): dummy_feature.attribute_setter = None with pytest.raises(ValueError): await dummy_feature.set_value("value for read only feature") + + +async def test_feature_action(mocker): + """Test that setting value on button calls the setter.""" + feat = Feature( + device=DummyDevice(), # type: ignore[arg-type] + name="dummy_feature", + attribute_setter="call_action", + container=None, + icon="mdi:dummy", + type=FeatureType.Action, + ) + mock_call_action = mocker.patch.object(feat.device, "call_action", create=True) + assert feat.value == "" + await feat.set_value(1234) + mock_call_action.assert_called() From e410e4f3f3f494b7ae6d3a46a3955baf542111ef Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 24 Apr 2024 12:25:16 +0100 Subject: [PATCH 389/892] Fix incorrect state updates in FakeTestProtocols (#861) --- kasa/iot/iotbulb.py | 1 + kasa/iot/iotstrip.py | 1 + kasa/tests/fakeprotocol_iot.py | 2 +- kasa/tests/fakeprotocol_smart.py | 6 ++++-- kasa/tests/smart/features/test_brightness.py | 2 ++ kasa/tests/smart/features/test_colortemp.py | 1 + kasa/tests/test_bulb.py | 1 + kasa/tests/test_cli.py | 14 +++++++------ kasa/tests/test_dimmer.py | 21 +++++++++++--------- kasa/tests/test_lightstrip.py | 1 + 10 files changed, 32 insertions(+), 18 deletions(-) diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 834c49b11..f0ecaadad 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -182,6 +182,7 @@ class IotBulb(IotDevice, Bulb): 50 >>> preset.brightness = 100 >>> asyncio.run(bulb.save_preset(preset)) + >>> asyncio.run(bulb.update()) >>> bulb.presets[0].brightness 100 diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index e1fdabae3..17671545a 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -66,6 +66,7 @@ class IotStrip(IotDevice): >>> strip.is_on True >>> asyncio.run(strip.turn_off()) + >>> asyncio.run(strip.update()) Accessing individual plugs can be done using the `children` property: diff --git a/kasa/tests/fakeprotocol_iot.py b/kasa/tests/fakeprotocol_iot.py index c15c63797..ac898c0a1 100644 --- a/kasa/tests/fakeprotocol_iot.py +++ b/kasa/tests/fakeprotocol_iot.py @@ -413,4 +413,4 @@ def get_response_for_command(cmd): for target in request: response.update(get_response_for_module(target)) - return response + return copy.deepcopy(response) diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 32da9304a..dd9b1f169 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -157,13 +157,15 @@ def _send_request(self, request_dict: dict): return self._handle_control_child(params) elif method == "component_nego" or method[:4] == "get_": if method in info: - return {"result": info[method], "error_code": 0} + result = copy.deepcopy(info[method]) + return {"result": result, "error_code": 0} if ( # FIXTURE_MISSING is for service calls not in place when # SMART fixtures started to be generated missing_result := self.FIXTURE_MISSING_MAP.get(method) ) and missing_result[0] in self.components: - retval = {"result": missing_result[1], "error_code": 0} + result = copy.deepcopy(missing_result[1]) + retval = {"result": result, "error_code": 0} else: # PARAMS error returned for KS240 when get_device_usage called # on parent device. Could be any error code though. diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index eb8572691..c18dce97f 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -20,6 +20,7 @@ async def test_brightness_component(dev: SmartDevice): # Test setting the value await feature.set_value(10) + await dev.update() assert feature.value == 10 with pytest.raises(ValueError): @@ -42,6 +43,7 @@ async def test_brightness_dimmable(dev: SmartDevice): # Test setting the value await feature.set_value(10) + await dev.update() assert feature.value == 10 with pytest.raises(ValueError): diff --git a/kasa/tests/smart/features/test_colortemp.py b/kasa/tests/smart/features/test_colortemp.py index e7022578d..54f84b1bf 100644 --- a/kasa/tests/smart/features/test_colortemp.py +++ b/kasa/tests/smart/features/test_colortemp.py @@ -20,6 +20,7 @@ async def test_colortemp_component(dev: SmartDevice): # We need to take the min here, as L9xx reports a range [9000, 9000]. new_value = min(feature.minimum_value + 1, feature.maximum_value) await feature.set_value(new_value) + await dev.update() assert feature.value == new_value with pytest.raises(ValueError): diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 9e7ab5178..668b034bc 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -295,6 +295,7 @@ async def test_modify_preset(dev: IotBulb, mocker): assert preset.color_temp == 0 await dev.save_preset(preset) + await dev.update() assert dev.presets[0].brightness == 10 with pytest.raises(KasaException): diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index f190bf46a..9fb463892 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -88,8 +88,8 @@ async def test_sysinfo(dev, runner): @turn_on async def test_state(dev, turn_on, runner): await handle_turn_on(dev, turn_on) - res = await runner.invoke(state, obj=dev) await dev.update() + res = await runner.invoke(state, obj=dev) if dev.is_on: assert "Device state: True" in res.output @@ -100,12 +100,12 @@ async def test_state(dev, turn_on, runner): @turn_on async def test_toggle(dev, turn_on, runner): await handle_turn_on(dev, turn_on) - await runner.invoke(toggle, obj=dev) + await dev.update() + assert dev.is_on == turn_on - if turn_on: - assert not dev.is_on - else: - assert dev.is_on + await runner.invoke(toggle, obj=dev) + await dev.update() + assert dev.is_on != turn_on @device_iot @@ -118,6 +118,7 @@ async def test_alias(dev, runner): new_alias = "new alias" res = await runner.invoke(alias, [new_alias], obj=dev) assert f"Setting alias to {new_alias}" in res.output + await dev.update() res = await runner.invoke(alias, obj=dev) assert f"Alias: {new_alias}" in res.output @@ -319,6 +320,7 @@ async def test_brightness(dev, runner): res = await runner.invoke(brightness, ["12"], obj=dev) assert "Setting brightness" in res.output + await dev.update() res = await runner.invoke(brightness, obj=dev) assert "Brightness: 12" in res.output diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index d63aa4536..6399ca4f6 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -12,10 +12,12 @@ async def test_set_brightness(dev, turn_on): await handle_turn_on(dev, turn_on) await dev.set_brightness(99) + await dev.update() assert dev.brightness == 99 assert dev.is_on == turn_on await dev.set_brightness(0) + await dev.update() assert dev.brightness == 1 assert dev.is_on == turn_on @@ -27,17 +29,18 @@ async def test_set_brightness_transition(dev, turn_on, mocker): query_helper = mocker.spy(IotDimmer, "_query_helper") await dev.set_brightness(99, transition=1000) - - assert dev.brightness == 99 - assert dev.is_on query_helper.assert_called_with( mocker.ANY, "smartlife.iot.dimmer", "set_dimmer_transition", {"brightness": 99, "duration": 1000}, ) + await dev.update() + assert dev.brightness == 99 + assert dev.is_on await dev.set_brightness(0, transition=1000) + await dev.update() assert dev.brightness == 1 @@ -58,15 +61,15 @@ async def test_turn_on_transition(dev, mocker): original_brightness = dev.brightness await dev.turn_on(transition=1000) - - assert dev.is_on - assert dev.brightness == original_brightness query_helper.assert_called_with( mocker.ANY, "smartlife.iot.dimmer", "set_dimmer_transition", {"brightness": original_brightness, "duration": 1000}, ) + await dev.update() + assert dev.is_on + assert dev.brightness == original_brightness @dimmer @@ -94,15 +97,15 @@ async def test_set_dimmer_transition(dev, turn_on, mocker): query_helper = mocker.spy(IotDimmer, "_query_helper") await dev.set_dimmer_transition(99, 1000) - - assert dev.is_on - assert dev.brightness == 99 query_helper.assert_called_with( mocker.ANY, "smartlife.iot.dimmer", "set_dimmer_transition", {"brightness": 99, "duration": 1000}, ) + await dev.update() + assert dev.is_on + assert dev.brightness == 99 @dimmer diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index ac80c52a0..fc987d2e6 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -27,6 +27,7 @@ async def test_effects_lightstrip_set_effect(dev: IotLightStrip): await dev.set_effect("Not real") await dev.set_effect("Candy Cane") + await dev.update() assert dev.effect["name"] == "Candy Cane" From 65874c0365a485bc189be99f7502dc3b0243f89f Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 24 Apr 2024 18:38:52 +0200 Subject: [PATCH 390/892] Embed FeatureType inside Feature (#860) Moves `FeatureType` into `Feature` to make it easier to use the API. This also enforces that no invalid types are accepted (i.e., `Category.Config` cannot be a `Sensor`) If `--verbose` is used with the cli tool, some extra information is displayed for features when in the state command. --- kasa/__init__.py | 3 +- kasa/cli.py | 36 ++++++++++--- kasa/feature.py | 60 +++++++++++++++------ kasa/iot/iotbulb.py | 4 +- kasa/iot/iotdimmer.py | 4 +- kasa/iot/iotplug.py | 4 +- kasa/iot/modules/ambientlight.py | 4 +- kasa/iot/modules/cloud.py | 4 +- kasa/smart/modules/alarmmodule.py | 8 +-- kasa/smart/modules/autooffmodule.py | 2 + kasa/smart/modules/battery.py | 4 +- kasa/smart/modules/brightness.py | 4 +- kasa/smart/modules/cloudmodule.py | 4 +- kasa/smart/modules/colormodule.py | 3 +- kasa/smart/modules/fanmodule.py | 6 +-- kasa/smart/modules/firmware.py | 6 +-- kasa/smart/modules/humidity.py | 4 +- kasa/smart/modules/ledmodule.py | 4 +- kasa/smart/modules/lighttransitionmodule.py | 8 +-- kasa/smart/modules/temperature.py | 5 +- kasa/smart/modules/temperaturecontrol.py | 2 + kasa/smart/smartdevice.py | 6 +-- kasa/tests/test_feature.py | 19 +++++-- 23 files changed, 135 insertions(+), 69 deletions(-) diff --git a/kasa/__init__.py b/kasa/__init__.py index 68dbb0c13..ceaf7520f 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -35,7 +35,7 @@ TimeoutError, UnsupportedDeviceError, ) -from kasa.feature import Feature, FeatureType +from kasa.feature import Feature from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.iotprotocol import ( IotProtocol, @@ -58,7 +58,6 @@ "TurnOnBehavior", "DeviceType", "Feature", - "FeatureType", "EmeterStatus", "Device", "Bulb", diff --git a/kasa/cli.py b/kasa/cli.py index b527bef1f..66eb89368 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -585,7 +585,10 @@ async def sysinfo(dev): def _echo_features( - features: dict[str, Feature], title: str, category: Feature.Category | None = None + features: dict[str, Feature], + title: str, + category: Feature.Category | None = None, + verbose: bool = False, ): """Print out a listing of features and their values.""" if category is not None: @@ -599,24 +602,42 @@ def _echo_features( for _, feat in features.items(): try: echo(f"\t{feat}") + if verbose: + echo(f"\t\tType: {feat.type}") + echo(f"\t\tCategory: {feat.category}") + echo(f"\t\tIcon: {feat.icon}") except Exception as ex: echo(f"\t{feat.name} ({feat.id}): got exception (%s)" % ex) -def _echo_all_features(features, title_prefix=None): +def _echo_all_features(features, *, verbose=False, title_prefix=None): """Print out all features by category.""" if title_prefix is not None: echo(f"[bold]\n\t == {title_prefix} ==[/bold]") _echo_features( - features, title="\n\t== Primary features ==", category=Feature.Category.Primary + features, + title="\n\t== Primary features ==", + category=Feature.Category.Primary, + verbose=verbose, ) _echo_features( - features, title="\n\t== Information ==", category=Feature.Category.Info + features, + title="\n\t== Information ==", + category=Feature.Category.Info, + verbose=verbose, ) _echo_features( - features, title="\n\t== Configuration ==", category=Feature.Category.Config + features, + title="\n\t== Configuration ==", + category=Feature.Category.Config, + verbose=verbose, + ) + _echo_features( + features, + title="\n\t== Debug ==", + category=Feature.Category.Debug, + verbose=verbose, ) - _echo_features(features, title="\n\t== Debug ==", category=Feature.Category.Debug) @cli.command() @@ -636,6 +657,7 @@ async def state(ctx, dev: Device): _echo_all_features( child.features, title_prefix=f"{child.alias} ({child.model}, {child.device_type})", + verbose=verbose, ) echo() @@ -647,7 +669,7 @@ async def state(ctx, dev: Device): echo(f"\tMAC (rssi): {dev.mac} ({dev.rssi})") echo(f"\tLocation: {dev.location}") - _echo_all_features(dev.features) + _echo_all_features(dev.features, verbose=verbose) echo("\n\t[bold]== Modules ==[/bold]") for module in dev.modules.values(): diff --git a/kasa/feature.py b/kasa/feature.py index ffa3df448..fc6e4c609 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from dataclasses import dataclass from enum import Enum, auto from typing import TYPE_CHECKING, Any, Callable @@ -10,26 +11,44 @@ from .device import Device -# TODO: This is only useful for Feature, so maybe move to Feature.Type? -class FeatureType(Enum): - """Type to help decide how to present the feature.""" - - Sensor = auto() - BinarySensor = auto() - Switch = auto() - Action = auto() - Number = auto() +_LOGGER = logging.getLogger(__name__) @dataclass class Feature: """Feature defines a generic interface for device features.""" + class Type(Enum): + """Type to help decide how to present the feature.""" + + #: Sensor is an informative read-only value + Sensor = auto() + #: BinarySensor is a read-only boolean + BinarySensor = auto() + #: Switch is a boolean setting + Switch = auto() + #: Action triggers some action on device + Action = auto() + #: Number defines a numeric setting + #: See :ref:`range_getter`, :ref:`minimum_value`, and :ref:`maximum_value` + Number = auto() + #: Choice defines a setting with pre-defined values + Choice = auto() + Unknown = -1 + + # TODO: unsure if this is a great idea.. + Sensor = Type.Sensor + BinarySensor = Type.BinarySensor + Switch = Type.Switch + Action = Type.Action + Number = Type.Number + Choice = Type.Choice + class Category(Enum): - """Category hint for downstreams.""" + """Category hint to allow feature grouping.""" #: Primary features control the device state directly. - #: Examples including turning the device on, or adjust its brightness. + #: Examples include turning the device on/off, or adjusting its brightness. Primary = auto() #: Config features change device behavior without immediate state changes. Config = auto() @@ -58,7 +77,7 @@ class Category(Enum): #: Category hint for downstreams category: Feature.Category = Category.Unset #: Type of the feature - type: FeatureType = FeatureType.Sensor + type: Feature.Type = Type.Sensor # Number-specific attributes #: Minimum value @@ -92,10 +111,19 @@ def __post_init__(self): else: self.category = Feature.Category.Info + if self.category == Feature.Category.Config and self.type in [ + Feature.Type.Sensor, + Feature.Type.BinarySensor, + ]: + raise ValueError( + f"Invalid type for configurable feature: {self.name} ({self.id}):" + f" {self.type}" + ) + @property def value(self): """Return the current value.""" - if self.type == FeatureType.Action: + if self.type == Feature.Type.Action: return "" if self.attribute_getter is None: raise ValueError("Not an action and no attribute_getter set") @@ -109,7 +137,7 @@ async def set_value(self, value): """Set the value.""" if self.attribute_setter is None: raise ValueError("Tried to set read-only feature.") - if self.type == FeatureType.Number: # noqa: SIM102 + if self.type == Feature.Type.Number: # noqa: SIM102 if value < self.minimum_value or value > self.maximum_value: raise ValueError( f"Value {value} out of range " @@ -117,7 +145,7 @@ async def set_value(self, value): ) container = self.container if self.container is not None else self.device - if self.type == FeatureType.Action: + if self.type == Feature.Type.Action: return await getattr(container, self.attribute_setter)() return await getattr(container, self.attribute_setter)(value) @@ -127,7 +155,7 @@ def __repr__(self): if self.unit is not None: s += f" {self.unit}" - if self.type == FeatureType.Number: + if self.type == Feature.Type.Number: s += f" (range: {self.minimum_value}-{self.maximum_value})" return s diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index f0ecaadad..4d6e49d2a 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -15,7 +15,7 @@ from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature, FeatureType +from ..feature import Feature from ..protocol import BaseProtocol from .iotdevice import IotDevice, KasaException, requires_update from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage @@ -221,7 +221,7 @@ async def _initialize_features(self): attribute_setter="set_brightness", minimum_value=1, maximum_value=100, - type=FeatureType.Number, + type=Feature.Type.Number, category=Feature.Category.Primary, ) ) diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 9c8c8f55a..672b22656 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -7,7 +7,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature, FeatureType +from ..feature import Feature from ..protocol import BaseProtocol from .iotdevice import KasaException, requires_update from .iotplug import IotPlug @@ -96,7 +96,7 @@ async def _initialize_features(self): attribute_setter="set_brightness", minimum_value=1, maximum_value=100, - type=FeatureType.Number, + type=Feature.Type.Number, ) ) diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index c584131dc..ecf73e035 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -6,7 +6,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature, FeatureType +from ..feature import Feature from ..protocol import BaseProtocol from .iotdevice import IotDevice, requires_update from .modules import Antitheft, Cloud, Schedule, Time, Usage @@ -69,7 +69,7 @@ async def _initialize_features(self): icon="mdi:led-{state}", attribute_getter="led", attribute_setter="set_led", - type=FeatureType.Switch, + type=Feature.Type.Switch, ) ) diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py index 44885b82a..2d7d679ba 100644 --- a/kasa/iot/modules/ambientlight.py +++ b/kasa/iot/modules/ambientlight.py @@ -1,6 +1,6 @@ """Implementation of the ambient light (LAS) module found in some dimmers.""" -from ...feature import Feature, FeatureType +from ...feature import Feature from ..iotmodule import IotModule, merge # TODO create tests and use the config reply there @@ -25,7 +25,7 @@ def __init__(self, device, module): name="Ambient Light", icon="mdi:brightness-percent", attribute_getter="ambientlight_brightness", - type=FeatureType.Sensor, + type=Feature.Type.Sensor, ) ) diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index 316617fd3..5e5521169 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -5,7 +5,7 @@ except ImportError: from pydantic import BaseModel -from ...feature import Feature, FeatureType +from ...feature import Feature from ..iotmodule import IotModule @@ -36,7 +36,7 @@ def __init__(self, device, module): name="Cloud connection", icon="mdi:cloud", attribute_getter="is_connected", - type=FeatureType.BinarySensor, + type=Feature.Type.BinarySensor, ) ) diff --git a/kasa/smart/modules/alarmmodule.py b/kasa/smart/modules/alarmmodule.py index 30e432f47..5f6cd3ee7 100644 --- a/kasa/smart/modules/alarmmodule.py +++ b/kasa/smart/modules/alarmmodule.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from ...feature import Feature, FeatureType +from ...feature import Feature from ..smartmodule import SmartModule if TYPE_CHECKING: @@ -32,7 +32,7 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="active", icon="mdi:bell", - type=FeatureType.BinarySensor, + type=Feature.Type.BinarySensor, ) ) self._add_feature( @@ -60,7 +60,7 @@ def __init__(self, device: SmartDevice, module: str): "Test alarm", container=self, attribute_setter="play", - type=FeatureType.Action, + type=Feature.Type.Action, ) ) self._add_feature( @@ -69,7 +69,7 @@ def __init__(self, device: SmartDevice, module: str): "Stop alarm", container=self, attribute_setter="stop", - type=FeatureType.Action, + type=Feature.Type.Action, ) ) diff --git a/kasa/smart/modules/autooffmodule.py b/kasa/smart/modules/autooffmodule.py index 1d31bfb96..019d42357 100644 --- a/kasa/smart/modules/autooffmodule.py +++ b/kasa/smart/modules/autooffmodule.py @@ -27,6 +27,7 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="enabled", attribute_setter="set_enabled", + type=Feature.Type.Switch, ) ) self._add_feature( @@ -36,6 +37,7 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="delay", attribute_setter="set_delay", + type=Feature.Type.Number, ) ) self._add_feature( diff --git a/kasa/smart/modules/battery.py b/kasa/smart/modules/battery.py index 982f9c6ab..20bca34b2 100644 --- a/kasa/smart/modules/battery.py +++ b/kasa/smart/modules/battery.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from ...feature import Feature, FeatureType +from ...feature import Feature from ..smartmodule import SmartModule if TYPE_CHECKING: @@ -35,7 +35,7 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="battery_low", icon="mdi:alert", - type=FeatureType.BinarySensor, + type=Feature.Type.BinarySensor, ) ) diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index eaacf644e..af3026f62 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from ...feature import Feature, FeatureType +from ...feature import Feature from ..smartmodule import SmartModule if TYPE_CHECKING: @@ -31,7 +31,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_setter="set_brightness", minimum_value=BRIGHTNESS_MIN, maximum_value=BRIGHTNESS_MAX, - type=FeatureType.Number, + type=Feature.Type.Number, category=Feature.Category.Primary, ) ) diff --git a/kasa/smart/modules/cloudmodule.py b/kasa/smart/modules/cloudmodule.py index 951ff7894..55338f269 100644 --- a/kasa/smart/modules/cloudmodule.py +++ b/kasa/smart/modules/cloudmodule.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from ...exceptions import SmartErrorCode -from ...feature import Feature, FeatureType +from ...feature import Feature from ..smartmodule import SmartModule if TYPE_CHECKING: @@ -28,7 +28,7 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="is_connected", icon="mdi:cloud", - type=FeatureType.BinarySensor, + type=Feature.Type.BinarySensor, ) ) diff --git a/kasa/smart/modules/colormodule.py b/kasa/smart/modules/colormodule.py index 234acc742..3adf0b4ef 100644 --- a/kasa/smart/modules/colormodule.py +++ b/kasa/smart/modules/colormodule.py @@ -25,8 +25,9 @@ def __init__(self, device: SmartDevice, module: str): "HSV", container=self, attribute_getter="hsv", - # TODO proper type for setting hsv attribute_setter="set_hsv", + # TODO proper type for setting hsv + type=Feature.Type.Unknown, ) ) diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fanmodule.py index 7c4404346..083f025c6 100644 --- a/kasa/smart/modules/fanmodule.py +++ b/kasa/smart/modules/fanmodule.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from ...feature import Feature, FeatureType +from ...feature import Feature from ..smartmodule import SmartModule if TYPE_CHECKING: @@ -27,7 +27,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="fan_speed_level", attribute_setter="set_fan_speed_level", icon="mdi:fan", - type=FeatureType.Number, + type=Feature.Type.Number, minimum_value=1, maximum_value=4, category=Feature.Category.Primary, @@ -41,7 +41,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="sleep_mode", attribute_setter="set_sleep_mode", icon="mdi:sleep", - type=FeatureType.Switch, + type=Feature.Type.Switch, ) ) diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index eacfd7029..c55400440 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Optional from ...exceptions import SmartErrorCode -from ...feature import Feature, FeatureType +from ...feature import Feature from ..smartmodule import SmartModule try: @@ -59,7 +59,7 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="auto_update_enabled", attribute_setter="set_auto_update_enabled", - type=FeatureType.Switch, + type=Feature.Type.Switch, ) ) self._add_feature( @@ -68,7 +68,7 @@ def __init__(self, device: SmartDevice, module: str): "Update available", container=self, attribute_getter="update_available", - type=FeatureType.BinarySensor, + type=Feature.Type.BinarySensor, ) ) diff --git a/kasa/smart/modules/humidity.py b/kasa/smart/modules/humidity.py index 8f829b266..26fca25a2 100644 --- a/kasa/smart/modules/humidity.py +++ b/kasa/smart/modules/humidity.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from ...feature import Feature, FeatureType +from ...feature import Feature from ..smartmodule import SmartModule if TYPE_CHECKING: @@ -34,7 +34,7 @@ def __init__(self, device: SmartDevice, module: str): "Humidity warning", container=self, attribute_getter="humidity_warning", - type=FeatureType.BinarySensor, + type=Feature.Type.BinarySensor, icon="mdi:alert", ) ) diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py index 75f904258..6fd0d637d 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/ledmodule.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from ...feature import Feature, FeatureType +from ...feature import Feature from ..smartmodule import SmartModule if TYPE_CHECKING: @@ -27,7 +27,7 @@ def __init__(self, device: SmartDevice, module: str): icon="mdi:led-{state}", attribute_getter="led", attribute_setter="set_led", - type=FeatureType.Switch, + type=Feature.Type.Switch, category=Feature.Category.Config, ) ) diff --git a/kasa/smart/modules/lighttransitionmodule.py b/kasa/smart/modules/lighttransitionmodule.py index 229dea578..ebcb093cb 100644 --- a/kasa/smart/modules/lighttransitionmodule.py +++ b/kasa/smart/modules/lighttransitionmodule.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from ...exceptions import KasaException -from ...feature import Feature, FeatureType +from ...feature import Feature from ..smartmodule import SmartModule if TYPE_CHECKING: @@ -35,7 +35,7 @@ def _create_features(self): icon=icon, attribute_getter="enabled_v1", attribute_setter="set_enabled_v1", - type=FeatureType.Switch, + type=Feature.Type.Switch, ) ) elif self.supported_version >= 2: @@ -51,7 +51,7 @@ def _create_features(self): attribute_getter="turn_on_transition", attribute_setter="set_turn_on_transition", icon=icon, - type=FeatureType.Number, + type=Feature.Type.Number, maximum_value=self.MAXIMUM_DURATION, ) ) # self._turn_on_transition_max @@ -63,7 +63,7 @@ def _create_features(self): attribute_getter="turn_off_transition", attribute_setter="set_turn_off_transition", icon=icon, - type=FeatureType.Number, + type=Feature.Type.Number, maximum_value=self.MAXIMUM_DURATION, ) ) # self._turn_off_transition_max diff --git a/kasa/smart/modules/temperature.py b/kasa/smart/modules/temperature.py index 3cec427b5..7b83c42c7 100644 --- a/kasa/smart/modules/temperature.py +++ b/kasa/smart/modules/temperature.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Literal -from ...feature import Feature, FeatureType +from ...feature import Feature from ..smartmodule import SmartModule if TYPE_CHECKING: @@ -35,7 +35,7 @@ def __init__(self, device: SmartDevice, module: str): "Temperature warning", container=self, attribute_getter="temperature_warning", - type=FeatureType.BinarySensor, + type=Feature.Type.BinarySensor, icon="mdi:alert", ) ) @@ -46,6 +46,7 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="temperature_unit", attribute_setter="set_temperature_unit", + type=Feature.Type.Choice, ) ) # TODO: use temperature_unit for feature creation diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index 9106a56fa..1c190f675 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -26,6 +26,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="target_temperature", attribute_setter="set_target_temperature", icon="mdi:thermometer", + type=Feature.Type.Number, ) ) # TODO: this might belong into its own module, temperature_correction? @@ -38,6 +39,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_setter="set_temperature_offset", minimum_value=-10, maximum_value=10, + type=Feature.Type.Number, ) ) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 69e4fe878..6393c61cc 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -13,7 +13,7 @@ from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode -from ..feature import Feature, FeatureType +from ..feature import Feature from ..smartprotocol import SmartProtocol from .modules import * # noqa: F403 @@ -191,7 +191,7 @@ async def _initialize_features(self): "State", attribute_getter="is_on", attribute_setter="set_state", - type=FeatureType.Switch, + type=Feature.Type.Switch, category=Feature.Category.Primary, ) ) @@ -236,7 +236,7 @@ async def _initialize_features(self): "Overheated", attribute_getter=lambda x: x._info["overheated"], icon="mdi:heat-wave", - type=FeatureType.BinarySensor, + type=Feature.Type.BinarySensor, category=Feature.Category.Debug, ) ) diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index db2d27a8e..d100fef01 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -1,6 +1,6 @@ import pytest -from kasa import Feature, FeatureType +from kasa import Feature class DummyDevice: @@ -18,7 +18,7 @@ def dummy_feature() -> Feature: attribute_setter="dummysetter", container=None, icon="mdi:dummy", - type=FeatureType.BinarySensor, + type=Feature.Type.Switch, unit="dummyunit", ) return feat @@ -32,10 +32,21 @@ def test_feature_api(dummy_feature: Feature): assert dummy_feature.attribute_setter == "dummysetter" assert dummy_feature.container is None assert dummy_feature.icon == "mdi:dummy" - assert dummy_feature.type == FeatureType.BinarySensor + assert dummy_feature.type == Feature.Type.Switch assert dummy_feature.unit == "dummyunit" +def test_feature_missing_type(): + """Test that creating a feature with a setter but without type causes an error.""" + with pytest.raises(ValueError): + Feature( + device=DummyDevice(), # type: ignore[arg-type] + name="dummy error", + attribute_getter="dummygetter", + attribute_setter="dummysetter", + ) + + def test_feature_value(dummy_feature: Feature): """Verify that property gets accessed on *value* access.""" dummy_feature.attribute_getter = "test_prop" @@ -91,7 +102,7 @@ async def test_feature_action(mocker): attribute_setter="call_action", container=None, icon="mdi:dummy", - type=FeatureType.Action, + type=Feature.Type.Action, ) mock_call_action = mocker.patch.object(feat.device, "call_action", create=True) assert feat.value == "" From eff8db450def8185b600e53194c0c1801526d233 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 24 Apr 2024 19:17:49 +0100 Subject: [PATCH 391/892] Support for new ks240 fan/light wall switch (#839) In order to support the ks240 which has children for the fan and light components, this PR adds those modules at the parent level and hides the children so it looks like a single device to consumers. It also decides which modules not to take from the child because the child does not support them even though it say it does. It does this for now via a fixed list, e.g. `Time`, `Firmware` etc. Also adds fixtures from two versions and corresponding tests. --- README.md | 2 +- SUPPORTED.md | 3 + kasa/smart/modules/brightness.py | 4 + kasa/smart/modules/fanmodule.py | 4 + kasa/smart/modules/lighttransitionmodule.py | 12 + kasa/smart/smartdevice.py | 30 +- kasa/tests/device_fixtures.py | 1 + kasa/tests/fakeprotocol_smart.py | 21 +- .../fixtures/smart/KS240(US)_1.0_1.0.4.json | 482 ++++++++++++++++++ .../fixtures/smart/KS240(US)_1.0_1.0.5.json | 479 +++++++++++++++++ kasa/tests/smart/features/test_brightness.py | 4 +- kasa/tests/smart/modules/test_fan.py | 14 +- kasa/tests/test_smartdevice.py | 31 +- 13 files changed, 1067 insertions(+), 20 deletions(-) create mode 100644 kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json create mode 100644 kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json diff --git a/README.md b/README.md index 6db63734f..c17d80b56 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: EP10, EP25\*, HS100\*\*, HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M\*, KP401 - **Power Strips**: EP40, HS107, HS300, KP200, KP303, KP400 -- **Wall Switches**: ES20M, HS200, HS210, HS220, KP405, KS200M, KS205\*, KS220M, KS225\*, KS230 +- **Wall Switches**: ES20M, HS200, HS210, HS220, KP405, KS200M, KS205\*, KS220M, KS225\*, KS230, KS240\* - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 - **Light Strips**: KL400L5, KL420L5, KL430 - **Hubs**: KH100\* diff --git a/SUPPORTED.md b/SUPPORTED.md index 1587e9663..c4957c651 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -88,6 +88,9 @@ Some newer Kasa devices require authentication. These are marked with *\* - **KS230** - Hardware: 1.0 (US) / Firmware: 1.0.14 +- **KS240** + - Hardware: 1.0 (US) / Firmware: 1.0.4\* + - Hardware: 1.0 (US) / Firmware: 1.0.5\* ### Bulbs diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index af3026f62..b12098488 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -57,3 +57,7 @@ async def set_brightness(self, brightness: int): ) return await self.call("set_device_info", {"brightness": brightness}) + + async def _check_supported(self): + """Additional check to see if the module is supported by the device.""" + return "brightness" in self.data diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fanmodule.py index 083f025c6..13f35aea8 100644 --- a/kasa/smart/modules/fanmodule.py +++ b/kasa/smart/modules/fanmodule.py @@ -68,3 +68,7 @@ def sleep_mode(self) -> bool: async def set_sleep_mode(self, on: bool): """Set sleep mode.""" return await self.call("set_device_info", {"fan_sleep_mode_on": on}) + + async def _check_supported(self): + """Is the module available on this device.""" + return "fan_speed_level" in self.data diff --git a/kasa/smart/modules/lighttransitionmodule.py b/kasa/smart/modules/lighttransitionmodule.py index ebcb093cb..e7da22ef3 100644 --- a/kasa/smart/modules/lighttransitionmodule.py +++ b/kasa/smart/modules/lighttransitionmodule.py @@ -103,6 +103,8 @@ def turn_on_transition(self) -> int: Available only from v2. """ + if "fade_on_time" in self._device.sys_info: + return self._device.sys_info["fade_on_time"] return self._turn_on["duration"] @property @@ -138,6 +140,8 @@ def turn_off_transition(self) -> int: Available only from v2. """ + if "fade_off_time" in self._device.sys_info: + return self._device.sys_info["fade_off_time"] return self._turn_off["duration"] @property @@ -166,3 +170,11 @@ async def set_turn_off_transition(self, seconds: int): "set_on_off_gradually_info", {"off_state": {**self._turn_on, "duration": seconds}}, ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Some devices have the required info in the device info. + if "gradually_on_mode" in self._device.sys_info: + return {} + else: + return {self.QUERY_GETTER_NAME: None} diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 6393c61cc..b325614be 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -22,6 +22,12 @@ if TYPE_CHECKING: from .smartmodule import SmartModule +# List of modules that wall switches with children, i.e. ks240 report on +# the child but only work on the parent. See longer note below in _initialize_modules. +# This list should be updated when creating new modules that could have the +# same issue, homekit perhaps? +WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule] # noqa: F405 + class SmartDevice(Device): """Base class to represent a SMART protocol based device.""" @@ -78,6 +84,9 @@ async def _initialize_children(self): @property def children(self) -> Sequence[SmartDevice]: """Return list of children.""" + # Wall switches with children report all modules on the parent only + if self.device_type == DeviceType.WallSwitch: + return [] return list(self._children.values()) def _try_get_response(self, responses: dict, request: str, default=None) -> dict: @@ -162,8 +171,23 @@ async def _initialize_modules(self): """Initialize modules based on component negotiation response.""" from .smartmodule import SmartModule + # Some wall switches (like ks240) are internally presented as having child + # devices which report the child's components on the parent's sysinfo, even + # when they need to be accessed through the children. + # The logic below ensures that such devices report all but whitelisted, the + # child modules at the parent level to create an illusion of a single device. + if self._parent and self._parent.device_type == DeviceType.WallSwitch: + modules = self._parent.modules + skip_parent_only_modules = True + else: + modules = self.modules + skip_parent_only_modules = False + for mod in SmartModule.REGISTERED_MODULES.values(): _LOGGER.debug("%s requires %s", mod, mod.REQUIRED_COMPONENT) + + if skip_parent_only_modules and mod in WALL_SWITCH_PARENT_ONLY_MODULES: + continue if mod.REQUIRED_COMPONENT in self._components: _LOGGER.debug( "Found required %s, adding %s to modules.", @@ -171,8 +195,8 @@ async def _initialize_modules(self): mod.__name__, ) module = mod(self, mod.REQUIRED_COMPONENT) - if await module._check_supported(): - self.modules[module.name] = module + if module.name not in modules and await module._check_supported(): + modules[module.name] = module async def _initialize_features(self): """Initialize device features.""" @@ -568,6 +592,8 @@ def _get_device_type_from_components( return DeviceType.Plug if "light_strip" in components: return DeviceType.LightStrip + if "SWITCH" in device_type and "child_device" in components: + return DeviceType.WallSwitch if "dimmer_calibration" in components: return DeviceType.Dimmer if "brightness" in components: diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 3cad6357e..372c74a63 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -92,6 +92,7 @@ SWITCHES_SMART = { "KS205", "KS225", + "KS240", "S500D", "S505", } diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index dd9b1f169..b46f8f3dc 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -139,10 +139,29 @@ def _handle_control_child(self, params: dict): # We only support get & set device info for now. if child_method == "get_device_info": - return {"result": info, "error_code": 0} + result = copy.deepcopy(info) + return {"result": result, "error_code": 0} elif child_method == "set_device_info": info.update(child_params) return {"error_code": 0} + elif ( + # FIXTURE_MISSING is for service calls not in place when + # SMART fixtures started to be generated + missing_result := self.FIXTURE_MISSING_MAP.get(child_method) + ) and missing_result[0] in self.components: + result = copy.deepcopy(missing_result[1]) + retval = {"result": result, "error_code": 0} + return retval + else: + # PARAMS error returned for KS240 when get_device_usage called + # on parent device. Could be any error code though. + # TODO: Try to figure out if there's a way to prevent the KS240 smartdevice + # calling the unsupported device in the first place. + retval = { + "error_code": SmartErrorCode.PARAMS_ERROR.value, + "method": child_method, + } + return retval raise NotImplementedError( "Method %s not implemented for children" % child_method diff --git a/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json b/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json new file mode 100644 index 000000000..2831e5335 --- /dev/null +++ b/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json @@ -0,0 +1,482 @@ +{ + "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": 2 + }, + { + "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": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "localSmart", + "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": "fan_control", + "ver_code": 1 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS240(US)", + "device_type": "SMART.KASASWITCH", + "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_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "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": "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 + } + ], + "device_id": "000000000000000000000000000000000000000001" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "000000000000000000000000000000000000000000" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "switch_ks240", + "bind_count": 1, + "category": "kasa.switch.outlet.sub-fan", + "device_id": "000000000000000000000000000000000000000000", + "device_on": false, + "fan_sleep_mode_on": false, + "fan_speed_level": 1, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.4 Build 230721 Rel.184322", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "F0A731000000", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "region": "America/Chicago", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + { + "avatar": "switch_ks240", + "bind_count": 1, + "brightness": 100, + "category": "kasa.switch.outlet.sub-dimmer", + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "000000000000000000000000000000000000000001", + "device_on": false, + "fade_off_time": 1, + "fade_on_time": 1, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.4 Build 230721 Rel.184322", + "gradually_off_mode": 1, + "gradually_on_mode": 1, + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "lang": "en_US", + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "F0A731000000", + "max_fade_off_time": 60, + "max_fade_on_time": 60, + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 0, + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "preset_state": [ + { + "brightness": 100 + }, + { + "brightness": 75 + }, + { + "brightness": 50 + }, + { + "brightness": 25 + }, + { + "brightness": 1 + } + ], + "region": "America/Chicago", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.4 Build 230721 Rel.184322", + "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": "F0-A7-31-00-00-00", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "America/Chicago", + "rssi": -39, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1708643384 + }, + "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.0.5 Build 231204 Rel.172150", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2024-01-12", + "release_note": "Modifications and Bug Fixes:\n1. Improved time synchronization accuracy.\n2. Enhanced stability and performance.\n3. Fixed some minor bugs.", + "type": 2 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "auto", + "led_status": false, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "get_wireless_scan_info": { + "ap_list": [], + "start_index": 0, + "sum": 0, + "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 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "KS240", + "device_type": "SMART.KASASWITCH" + } + } +} diff --git a/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json b/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json new file mode 100644 index 000000000..6d14f7bfc --- /dev/null +++ b/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json @@ -0,0 +1,479 @@ +{ + "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": 2 + }, + { + "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": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS240(US)", + "device_type": "SMART.KASASWITCH", + "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_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "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": "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 + } + ], + "device_id": "000000000000000000000000000000000000000001" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "000000000000000000000000000000000000000000" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "switch_ks240", + "bind_count": 1, + "category": "kasa.switch.outlet.sub-fan", + "device_id": "000000000000000000000000000000000000000000", + "device_on": true, + "fan_sleep_mode_on": false, + "fan_speed_level": 1, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 231204 Rel.172150", + "gc": 1, + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "la": 0, + "lang": "", + "lo": 0, + "mac": "F0A731000000", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "region": "America/New_York", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + { + "avatar": "switch_ks240", + "bind_count": 1, + "brightness": 100, + "category": "kasa.switch.outlet.sub-dimmer", + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "000000000000000000000000000000000000000001", + "device_on": false, + "fade_off_time": 1, + "fade_on_time": 1, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 231204 Rel.172150", + "gc": 1, + "gradually_off_mode": 1, + "gradually_on_mode": 1, + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "la": 0, + "lang": "", + "led_off": 0, + "lo": 0, + "mac": "F0A731000000", + "max_fade_off_time": 60, + "max_fade_on_time": 60, + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 0, + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "preset_state": [ + { + "brightness": 100 + }, + { + "brightness": 75 + }, + { + "brightness": 50 + }, + { + "brightness": 25 + }, + { + "brightness": 1 + } + ], + "region": "America/New_York", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 231204 Rel.172150", + "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": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "America/New_York", + "rssi": -46, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/New_York", + "time_diff": -300, + "timestamp": 1707863232 + }, + "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.5 Build 231204 Rel.172150", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "auto", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "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 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "KS240", + "device_type": "SMART.KASASWITCH", + "is_klap": false + } + } +} diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index c18dce97f..d677725d8 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -10,11 +10,13 @@ @brightness async def test_brightness_component(dev: SmartDevice): """Test brightness feature.""" + brightness = dev.modules.get("Brightness") + assert brightness assert isinstance(dev, SmartDevice) assert "brightness" in dev._components # Test getting the value - feature = dev.features["brightness"] + feature = brightness._module_features["brightness"] assert isinstance(feature.value, int) assert feature.value > 1 and feature.value <= 100 diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 260fcf1a3..559ffefe0 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -1,18 +1,17 @@ from pytest_mock import MockerFixture from kasa import SmartDevice -from kasa.smart.modules import FanModule from kasa.tests.device_fixtures import parametrize -fan = parametrize( - "has fan", component_filter="fan_control", protocol_filter={"SMART.CHILD"} -) +fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"SMART"}) @fan async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): """Test fan speed feature.""" - fan: FanModule = dev.modules["FanModule"] + fan = dev.modules.get("FanModule") + assert fan + level_feature = fan._module_features["fan_speed_level"] assert ( level_feature.minimum_value @@ -22,7 +21,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): call = mocker.spy(fan, "call") await fan.set_fan_speed_level(3) - call.assert_called_with("set_device_info", {"fan_sleep_level": 3}) + call.assert_called_with("set_device_info", {"fan_speed_level": 3}) await dev.update() @@ -33,7 +32,8 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): @fan async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): """Test sleep mode feature.""" - fan: FanModule = dev.modules["FanModule"] + fan = dev.modules.get("FanModule") + assert fan sleep_feature = fan._module_features["fan_sleep_mode"] assert isinstance(sleep_feature.value, bool) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 32bd32975..037edaf90 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -96,23 +96,29 @@ async def test_negotiate(dev: SmartDevice, mocker: MockerFixture): "get_child_device_list": None, } ) - assert len(dev.children) == dev.internal_state["get_child_device_list"]["sum"] + assert len(dev._children) == dev.internal_state["get_child_device_list"]["sum"] @device_smart async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): """Test that the regular update uses queries from all supported modules.""" - query = mocker.spy(dev.protocol, "query") - # We need to have some modules initialized by now assert dev.modules - await dev.update() - full_query: dict[str, Any] = {} + device_queries: dict[SmartDevice, dict[str, Any]] = {} for mod in dev.modules.values(): - full_query = {**full_query, **mod.query()} + device_queries.setdefault(mod._device, {}).update(mod.query()) + + spies = {} + for dev in device_queries: + spies[dev] = mocker.spy(dev.protocol, "query") - query.assert_called_with(full_query) + await dev.update() + for dev in device_queries: + if device_queries[dev]: + spies[dev].assert_called_with(device_queries[dev]) + else: + spies[dev].assert_not_called() @bulb_smart @@ -187,10 +193,19 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt "get_child_device_component_list" ] new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + + first_call = True + + def side_effect_func(*_, **__): + nonlocal first_call + resp = initial_response if first_call else last_update + first_call = False + return resp + with patch.object( new_dev.protocol, "query", - side_effect=[initial_response, last_update, last_update], + side_effect=side_effect_func, ): await new_dev.update() assert new_dev.is_cloud_connected is False From 53b84b768399cc639e0e5aad9b283674e7199ed0 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 24 Apr 2024 19:32:30 +0100 Subject: [PATCH 392/892] Handle paging of partial responses of lists like child_device_info (#862) When devices have lists greater than 10 for child devices only the first 10 are returned. This retrieves the rest of the items (currently with single requests rather than multiple requests) --- kasa/smartprotocol.py | 43 ++++++++++++++++++++- kasa/tests/fakeprotocol_smart.py | 32 +++++++++++++--- kasa/tests/test_smartprotocol.py | 64 +++++++++++++++++++++++++++++++- 3 files changed, 131 insertions(+), 8 deletions(-) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 9a1482b18..cbfd16b0f 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -67,7 +67,9 @@ async def query(self, request: str | dict, retry_count: int = 3) -> dict: async def _query(self, request: str | dict, retry_count: int = 3) -> dict: for retry in range(retry_count + 1): try: - return await self._execute_query(request, retry) + return await self._execute_query( + request, retry_count=retry, iterate_list_pages=True + ) except _ConnectionError as sdex: if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) @@ -145,6 +147,9 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic method = response["method"] self._handle_response_error_code(response, method, raise_on_error=False) result = response.get("result", None) + await self._handle_response_lists( + result, method, retry_count=retry_count + ) multi_result[method] = result # Multi requests don't continue after errors so requery any missing for method, params in requests.items(): @@ -156,7 +161,9 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic multi_result[method] = resp.get("result") return multi_result - async def _execute_query(self, request: str | dict, retry_count: int) -> dict: + async def _execute_query( + self, request: str | dict, *, retry_count: int, iterate_list_pages: bool = True + ) -> dict: debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) if isinstance(request, dict): @@ -189,8 +196,40 @@ async def _execute_query(self, request: str | dict, retry_count: int) -> dict: # Single set_ requests do not return a result result = response_data.get("result") + if iterate_list_pages and result: + await self._handle_response_lists( + result, smart_method, retry_count=retry_count + ) return {smart_method: result} + async def _handle_response_lists( + self, response_result: dict[str, Any], method, retry_count + ): + if ( + isinstance(response_result, SmartErrorCode) + or "start_index" not in response_result + or (list_sum := response_result.get("sum")) is None + ): + return + + response_list_name = next( + iter( + [ + key + for key in response_result + if isinstance(response_result[key], list) + ] + ) + ) + while (list_length := len(response_result[response_list_name])) < list_sum: + response = await self._execute_query( + {method: {"start_index": list_length}}, + retry_count=retry_count, + iterate_list_pages=False, + ) + next_batch = response[method] + response_result[response_list_name].extend(next_batch[response_list_name]) + def _handle_response_error_code(self, resp_dict: dict, method, raise_on_error=True): error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] if error_code == SmartErrorCode.SUCCESS: diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index b46f8f3dc..7340b5b7d 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -21,7 +21,14 @@ async def query(self, request, retry_count: int = 3): class FakeSmartTransport(BaseTransport): - def __init__(self, info, fixture_name): + def __init__( + self, + info, + fixture_name, + *, + list_return_size=10, + component_nego_not_included=False, + ): super().__init__( config=DeviceConfig( "127.0.0.123", @@ -33,10 +40,12 @@ def __init__(self, info, fixture_name): ) self.fixture_name = fixture_name self.info = copy.deepcopy(info) - self.components = { - comp["id"]: comp["ver_code"] - for comp in self.info["component_nego"]["component_list"] - } + if not component_nego_not_included: + self.components = { + comp["id"]: comp["ver_code"] + for comp in self.info["component_nego"]["component_list"] + } + self.list_return_size = list_return_size @property def default_port(self): @@ -177,7 +186,20 @@ def _send_request(self, request_dict: dict): elif method == "component_nego" or method[:4] == "get_": if method in info: result = copy.deepcopy(info[method]) + if "start_index" in result and "sum" in result: + list_key = next( + iter([key for key in result if isinstance(result[key], list)]) + ) + start_index = ( + start_index + if (params and (start_index := params.get("start_index"))) + else 0 + ) + result[list_key] = result[list_key][ + start_index : start_index + self.list_return_size + ] return {"result": result, "error_code": 0} + if ( # FIXTURE_MISSING is for service calls not in place when # SMART fixtures started to be generated diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index b970eaa5a..ca62ba02d 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -7,7 +7,8 @@ KasaException, SmartErrorCode, ) -from ..smartprotocol import _ChildProtocolWrapper +from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper +from .fakeprotocol_smart import FakeSmartTransport DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} DUMMY_MULTIPLE_QUERY = { @@ -180,3 +181,64 @@ async def test_childdevicewrapper_multiplerequest_error(dummy_protocol, mocker): mocker.patch.object(wrapped_protocol._transport, "send", return_value=mock_response) with pytest.raises(KasaException): await wrapped_protocol.query(DUMMY_QUERY) + + +@pytest.mark.parametrize("list_sum", [5, 10, 30]) +@pytest.mark.parametrize("batch_size", [1, 2, 3, 50]) +async def test_smart_protocol_lists_single_request(mocker, list_sum, batch_size): + child_device_list = [{"foo": i} for i in range(list_sum)] + response = { + "get_child_device_list": { + "child_device_list": child_device_list, + "start_index": 0, + "sum": list_sum, + } + } + request = {"get_child_device_list": None} + + ft = FakeSmartTransport( + response, + "foobar", + list_return_size=batch_size, + component_nego_not_included=True, + ) + protocol = SmartProtocol(transport=ft) + query_spy = mocker.spy(protocol, "_execute_query") + resp = await protocol.query(request) + expected_count = int(list_sum / batch_size) + (1 if list_sum % batch_size else 0) + assert query_spy.call_count == expected_count + assert resp == response + + +@pytest.mark.parametrize("list_sum", [5, 10, 30]) +@pytest.mark.parametrize("batch_size", [1, 2, 3, 50]) +async def test_smart_protocol_lists_multiple_request(mocker, list_sum, batch_size): + child_list = [{"foo": i} for i in range(list_sum)] + response = { + "get_child_device_list": { + "child_device_list": child_list, + "start_index": 0, + "sum": list_sum, + }, + "get_child_device_component_list": { + "child_component_list": child_list, + "start_index": 0, + "sum": list_sum, + }, + } + request = {"get_child_device_list": None, "get_child_device_component_list": None} + + ft = FakeSmartTransport( + response, + "foobar", + list_return_size=batch_size, + component_nego_not_included=True, + ) + protocol = SmartProtocol(transport=ft) + query_spy = mocker.spy(protocol, "_execute_query") + resp = await protocol.query(request) + expected_count = 1 + 2 * ( + int(list_sum / batch_size) + (0 if list_sum % batch_size else -1) + ) + assert query_spy.call_count == expected_count + assert resp == response From 10629f2db931526ee96a007709fcbae3ade3d579 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 25 Apr 2024 08:36:30 +0200 Subject: [PATCH 393/892] Be more lax on unknown SMART devices (#863) --- kasa/device_factory.py | 8 +++++++- kasa/tests/test_device_factory.py | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 29cc36ffd..4450a023f 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -171,7 +171,13 @@ def get_device_class_from_family(device_type: str) -> type[Device] | None: "IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTBULB": IotBulb, } - return supported_device_types.get(device_type) + if ( + cls := supported_device_types.get(device_type) + ) is None and device_type.startswith("SMART."): + _LOGGER.warning("Unknown SMART device with %s, using SmartDevice", device_type) + cls = SmartDevice + + return cls def get_protocol( diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index dc5144854..bcadb7244 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -13,6 +13,7 @@ from kasa.device_factory import ( _get_device_type_from_sys_info, connect, + get_device_class_from_family, get_protocol, ) from kasa.deviceconfig import ( @@ -164,3 +165,11 @@ async def test_device_types(dev: Device): res = _get_device_type_from_sys_info(dev._last_update) assert dev.device_type == res + + +async def test_device_class_from_unknown_family(caplog): + """Verify that unknown SMART devices yield a warning and fallback to SmartDevice.""" + dummy_name = "SMART.foo" + with caplog.at_level(logging.WARNING): + assert get_device_class_from_family(dummy_name) == SmartDevice + assert f"Unknown SMART device with {dummy_name}" in caplog.text From 9efcc0d19f69f5412b087bea789247979cf55b6b Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 25 Apr 2024 08:05:51 +0100 Subject: [PATCH 394/892] Fix broken CI due to missing python version on macos-latest (#864) --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4985528b..e4e2752e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,12 @@ jobs: exclude: - os: macos-latest extras: true + # setup-python not currently working with macos-latest + # https://github.com/actions/setup-python/issues/808 + - os: macos-latest + python-version: "3.8" + - os: macos-latest + python-version: "3.9" - os: windows-latest extras: true - os: ubuntu-latest From 6e55c8d98914b77ca7450b552e7422c94f6ebf6d Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 25 Apr 2024 13:02:00 +0100 Subject: [PATCH 395/892] Add runner.arch to cache-key in CI (#866) --- .github/actions/setup/action.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml index be38072e1..9b5b4503b 100644 --- a/.github/actions/setup/action.yaml +++ b/.github/actions/setup/action.yaml @@ -41,7 +41,7 @@ runs: uses: actions/cache@v4 with: path: ${{ steps.pipx-env-setup.outputs.pipx-cache-path }} - key: ${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-pipx-${{ steps.pipx-env-setup.outputs.pipx-version }}-poetry-${{ inputs.poetry-version }} + key: ${{ runner.os }}-${{ runner.arch }}-python-${{ steps.setup-python.outputs.python-version }}-pipx-${{ steps.pipx-env-setup.outputs.pipx-version }}-poetry-${{ inputs.poetry-version }} - name: Install poetry if: steps.pipx-cache.outputs.cache-hit != 'true' @@ -61,7 +61,7 @@ runs: with: path: | ${{ steps.poetry-cache-location.outputs.poetry-venv-location }} - key: ${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }}-options-${{ inputs.poetry-install-options }} + key: ${{ runner.os }}-${{ runner.arch }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }}-options-${{ inputs.poetry-install-options }} - name: "Poetry install" shell: bash @@ -80,4 +80,4 @@ runs: name: Pre-commit cache with: path: ~/.cache/pre-commit/ - key: ${{ runner.os }}-pre-commit-${{ steps.pre-commit-version.outputs.pre-commit-version }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} + key: ${{ runner.os }}-${{ runner.arch }}-pre-commit-${{ steps.pre-commit-version.outputs.pre-commit-version }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} From 724dad02f79c866af15e84f04f96a6e245e76837 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 25 Apr 2024 13:02:17 +0100 Subject: [PATCH 396/892] Do not try coverage upload for pypy (#867) Do not try to upload coverage for pypy which is run without coverage. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4e2752e7..ca8cfb754 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,6 +108,7 @@ jobs: run: | poetry run pytest --cov kasa --cov-report xml - name: "Upload coverage to Codecov" + if: ${{ !startsWith(matrix.python-version, 'pypy') }} uses: "codecov/codecov-action@v4" with: token: ${{ secrets.CODECOV_TOKEN }} From 1ff316211212dcb2c128ad4a6de7439ae6996c62 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 25 Apr 2024 14:59:17 +0200 Subject: [PATCH 397/892] Expose IOT emeter info as features (#844) Exposes IOT emeter information using features, bases on #843 to allow defining the units. --------- Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- kasa/iot/iotstrip.py | 7 ++-- kasa/iot/modules/emeter.py | 84 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 17671545a..99f5913d6 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -18,7 +18,7 @@ requires_update, ) from .iotplug import IotPlug -from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage +from .modules import Antitheft, Countdown, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) @@ -100,7 +100,6 @@ def __init__( self.add_module("usage", Usage(self, "schedule")) self.add_module("time", Time(self, "time")) self.add_module("countdown", Countdown(self, "countdown")) - self.add_module("emeter", Emeter(self, "emeter")) @property # type: ignore @requires_update @@ -217,13 +216,13 @@ async def erase_emeter_stats(self): @requires_update def emeter_this_month(self) -> float | None: """Return this month's energy consumption in kWh.""" - return sum(plug.emeter_this_month for plug in self.children) + return sum(v if (v := plug.emeter_this_month) else 0 for plug in self.children) @property # type: ignore @requires_update def emeter_today(self) -> float | None: """Return this month's energy consumption in kWh.""" - return sum(plug.emeter_today for plug in self.children) + return sum(v if (v := plug.emeter_today) else 0 for plug in self.children) @property # type: ignore @requires_update diff --git a/kasa/iot/modules/emeter.py b/kasa/iot/modules/emeter.py index 52346eccb..6025eab24 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -4,13 +4,77 @@ from datetime import datetime +from ... import Device from ...emeterstatus import EmeterStatus +from ...feature import Feature from .usage import Usage class Emeter(Usage): """Emeter module.""" + def __init__(self, device: Device, module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + name="Current consumption", + attribute_getter="current_consumption", + container=self, + unit="W", + id="current_power_w", # for homeassistant backwards compat + ) + ) + self._add_feature( + Feature( + device, + name="Today's consumption", + attribute_getter="emeter_today", + container=self, + unit="kWh", + id="today_energy_kwh", # for homeassistant backwards compat + ) + ) + self._add_feature( + Feature( + device, + name="This month's consumption", + attribute_getter="emeter_this_month", + container=self, + unit="kWh", + ) + ) + self._add_feature( + Feature( + device, + name="Total consumption since reboot", + attribute_getter="emeter_total", + container=self, + unit="kWh", + id="total_energy_kwh", # for homeassistant backwards compat + ) + ) + self._add_feature( + Feature( + device, + name="Voltage", + attribute_getter="voltage", + container=self, + unit="V", + id="voltage", # for homeassistant backwards compat + ) + ) + self._add_feature( + Feature( + device, + name="Current", + attribute_getter="current", + container=self, + unit="A", + id="current_a", # for homeassistant backwards compat + ) + ) + @property # type: ignore def realtime(self) -> EmeterStatus: """Return current energy readings.""" @@ -32,6 +96,26 @@ def emeter_this_month(self) -> float | None: data = self._convert_stat_data(raw_data, entry_key="month", key=current_month) return data.get(current_month) + @property + def current_consumption(self) -> float | None: + """Get the current power consumption in Watt.""" + return self.realtime.power + + @property + def emeter_total(self) -> float | None: + """Return total consumption since last reboot in kWh.""" + return self.realtime.total + + @property + def current(self) -> float | None: + """Return the current in A.""" + return self.realtime.current + + @property + def voltage(self) -> float | None: + """Get the current voltage in V.""" + return self.realtime.voltage + async def erase_stats(self): """Erase all stats. From fe6b1892cc1b081d7ead2d65dcc9536551929f44 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 25 Apr 2024 15:57:08 +0100 Subject: [PATCH 398/892] Fix pypy39 CI cache on macos (#868) --- .github/actions/setup/action.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml index 9b5b4503b..8010a4ed2 100644 --- a/.github/actions/setup/action.yaml +++ b/.github/actions/setup/action.yaml @@ -41,7 +41,7 @@ runs: uses: actions/cache@v4 with: path: ${{ steps.pipx-env-setup.outputs.pipx-cache-path }} - key: ${{ runner.os }}-${{ runner.arch }}-python-${{ steps.setup-python.outputs.python-version }}-pipx-${{ steps.pipx-env-setup.outputs.pipx-version }}-poetry-${{ inputs.poetry-version }} + key: ${{ runner.os }}-${{ runner.arch }}-python-${{ inputs.python-version }}-${{ steps.setup-python.outputs.python-version }}-pipx-${{ steps.pipx-env-setup.outputs.pipx-version }}-poetry-${{ inputs.poetry-version }} - name: Install poetry if: steps.pipx-cache.outputs.cache-hit != 'true' @@ -61,7 +61,7 @@ runs: with: path: | ${{ steps.poetry-cache-location.outputs.poetry-venv-location }} - key: ${{ runner.os }}-${{ runner.arch }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }}-options-${{ inputs.poetry-install-options }} + key: ${{ runner.os }}-${{ runner.arch }}-python-${{ inputs.python-version }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }}-options-${{ inputs.poetry-install-options }} - name: "Poetry install" shell: bash @@ -80,4 +80,4 @@ runs: name: Pre-commit cache with: path: ~/.cache/pre-commit/ - key: ${{ runner.os }}-${{ runner.arch }}-pre-commit-${{ steps.pre-commit-version.outputs.pre-commit-version }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} + key: ${{ runner.os }}-${{ runner.arch }}-pre-commit-${{ steps.pre-commit-version.outputs.pre-commit-version }}-python-${{ inputs.python-version }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} From d7a36fe071f068344c96d2c0e7b392807c1880cd Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 29 Apr 2024 13:31:42 +0200 Subject: [PATCH 399/892] Add precision_hint to feature (#871) This can be used to hint how the sensor value should be rounded when displaying it to users. The values are adapted from the values used by homeassistant. --- kasa/feature.py | 9 ++++++++- kasa/iot/modules/emeter.py | 6 ++++++ kasa/smart/modules/energymodule.py | 3 +++ kasa/tests/test_feature.py | 12 ++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/kasa/feature.py b/kasa/feature.py index fc6e4c609..3bd0ccb49 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -79,6 +79,10 @@ class Category(Enum): #: Type of the feature type: Feature.Type = Type.Sensor + # Display hints offer a way suggest how the value should be shown to users + #: Hint to help rounding the sensor values to given after-comma digits + precision_hint: int | None = None + # Number-specific attributes #: Minimum value minimum_value: int = 0 @@ -151,7 +155,10 @@ async def set_value(self, value): return await getattr(container, self.attribute_setter)(value) def __repr__(self): - s = f"{self.name} ({self.id}): {self.value}" + value = self.value + if self.precision_hint is not None and value is not None: + value = round(self.value, self.precision_hint) + s = f"{self.name} ({self.id}): {value}" if self.unit is not None: s += f" {self.unit}" diff --git a/kasa/iot/modules/emeter.py b/kasa/iot/modules/emeter.py index 6025eab24..1542e66ab 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -23,6 +23,7 @@ def __init__(self, device: Device, module: str): container=self, unit="W", id="current_power_w", # for homeassistant backwards compat + precision_hint=1, ) ) self._add_feature( @@ -33,6 +34,7 @@ def __init__(self, device: Device, module: str): container=self, unit="kWh", id="today_energy_kwh", # for homeassistant backwards compat + precision_hint=3, ) ) self._add_feature( @@ -42,6 +44,7 @@ def __init__(self, device: Device, module: str): attribute_getter="emeter_this_month", container=self, unit="kWh", + precision_hint=3, ) ) self._add_feature( @@ -52,6 +55,7 @@ def __init__(self, device: Device, module: str): container=self, unit="kWh", id="total_energy_kwh", # for homeassistant backwards compat + precision_hint=3, ) ) self._add_feature( @@ -62,6 +66,7 @@ def __init__(self, device: Device, module: str): container=self, unit="V", id="voltage", # for homeassistant backwards compat + precision_hint=1, ) ) self._add_feature( @@ -72,6 +77,7 @@ def __init__(self, device: Device, module: str): container=self, unit="A", id="current_a", # for homeassistant backwards compat + precision_hint=2, ) ) diff --git a/kasa/smart/modules/energymodule.py b/kasa/smart/modules/energymodule.py index aedc71aec..6a75299e2 100644 --- a/kasa/smart/modules/energymodule.py +++ b/kasa/smart/modules/energymodule.py @@ -26,6 +26,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="current_power", container=self, unit="W", + precision_hint=1, ) ) self._add_feature( @@ -35,6 +36,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="emeter_today", container=self, unit="Wh", + precision_hint=2, ) ) self._add_feature( @@ -44,6 +46,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="emeter_this_month", container=self, unit="Wh", + precision_hint=2, ) ) diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index d100fef01..85ac42d8f 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -108,3 +108,15 @@ async def test_feature_action(mocker): assert feat.value == "" await feat.set_value(1234) mock_call_action.assert_called() + + +@pytest.mark.parametrize("precision_hint", [1, 2, 3]) +async def test_precision_hint(dummy_feature, precision_hint): + """Test that precision hint works as expected.""" + dummy_value = 3.141593 + dummy_feature.type = Feature.Type.Sensor + dummy_feature.precision_hint = precision_hint + + dummy_feature.attribute_getter = lambda x: dummy_value + assert dummy_feature.value == dummy_value + assert f"{round(dummy_value, precision_hint)} dummyunit" in repr(dummy_feature) From e7553a7af424424fbf3c86f55fa0fa59f3d6ba4d Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 29 Apr 2024 14:24:30 +0100 Subject: [PATCH 400/892] Fix smartprotocol response list handler to handle null reponses (#884) --- kasa/smartprotocol.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index cbfd16b0f..472d93202 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -206,7 +206,8 @@ async def _handle_response_lists( self, response_result: dict[str, Any], method, retry_count ): if ( - isinstance(response_result, SmartErrorCode) + response_result is None + or isinstance(response_result, SmartErrorCode) or "start_index" not in response_result or (list_sum := response_result.get("sum")) is None ): From 6724506fabd53f44e0932a44f72d671381806104 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 29 Apr 2024 14:33:46 +0100 Subject: [PATCH 401/892] Update dump_devinfo to print original exception stack on errors. (#882) --- devtools/dump_devinfo.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index fe5b8ab37..1c7fb42d8 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -15,6 +15,7 @@ import json import logging import re +import sys import traceback from collections import defaultdict, namedtuple from pathlib import Path @@ -343,6 +344,26 @@ def _echo_error(msg: str): ) +def format_exception(e): + """Print full exception stack as if it hadn't been caught. + + https://stackoverflow.com/a/12539332 + """ + exception_list = traceback.format_stack() + exception_list = exception_list[:-2] + exception_list.extend(traceback.format_tb(sys.exc_info()[2])) + exception_list.extend( + traceback.format_exception_only(sys.exc_info()[0], sys.exc_info()[1]) + ) + + exception_str = "Traceback (most recent call last):\n" + exception_str += "".join(exception_list) + # Removing the last \n + exception_str = exception_str[:-1] + + return exception_str + + async def _make_requests_or_exit( device: SmartDevice, requests: list[SmartRequest], @@ -389,7 +410,7 @@ async def _make_requests_or_exit( f"Unexpected exception querying {name} at once: {ex}", ) if _LOGGER.isEnabledFor(logging.DEBUG): - traceback.print_stack() + _echo_error(format_exception(ex)) exit(1) finally: await device.protocol.close() From cb11b36511440d0f6964033eef4ae5b9ff34c7d5 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 29 Apr 2024 17:34:20 +0100 Subject: [PATCH 402/892] Put modules back on children for wall switches (#881) Puts modules back on the children for `WallSwitches` (i.e. ks240) and makes them accessible from the `modules` property on the parent. --- kasa/cli.py | 3 +- kasa/device.py | 7 ++++- kasa/iot/iotdevice.py | 46 +++++++++++++++++----------- kasa/iot/iotstrip.py | 2 +- kasa/module.py | 5 ++- kasa/smart/smartdevice.py | 41 ++++++++++++++++--------- kasa/tests/smart/modules/test_fan.py | 7 +++-- kasa/tests/test_smartdevice.py | 16 +++++----- 8 files changed, 80 insertions(+), 47 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 66eb89368..317bf0383 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -39,6 +39,7 @@ IotStrip, IotWallSwitch, ) +from kasa.iot.modules import Usage from kasa.smart import SmartBulb, SmartDevice try: @@ -829,7 +830,7 @@ async def usage(dev: Device, year, month, erase): Daily and monthly data provided in CSV format. """ echo("[bold]== Usage ==[/bold]") - usage = dev.modules["usage"] + usage = cast(Usage, dev.modules["usage"]) if erase: echo("Erasing usage statistics..") diff --git a/kasa/device.py b/kasa/device.py index dda7822f9..8a81030f8 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -15,6 +15,7 @@ from .exceptions import KasaException from .feature import Feature from .iotprotocol import IotProtocol +from .module import Module from .protocol import BaseProtocol from .xortransport import XorTransport @@ -72,7 +73,6 @@ def __init__( self._last_update: Any = None self._discovery_info: dict[str, Any] | None = None - self.modules: dict[str, Any] = {} self._features: dict[str, Feature] = {} self._parent: Device | None = None self._children: Mapping[str, Device] = {} @@ -111,6 +111,11 @@ async def disconnect(self): """Disconnect and close any underlying connection resources.""" await self.protocol.close() + @property + @abstractmethod + def modules(self) -> Mapping[str, Module]: + """Return the device modules.""" + @property @abstractmethod def is_on(self) -> bool: diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index d4551d0db..81b5eddac 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -19,7 +19,7 @@ import inspect import logging from datetime import datetime, timedelta -from typing import Any, Mapping, Sequence +from typing import Any, Mapping, Sequence, cast from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig @@ -28,7 +28,7 @@ from ..feature import Feature from ..protocol import BaseProtocol from .iotmodule import IotModule -from .modules import Emeter +from .modules import Emeter, Time _LOGGER = logging.getLogger(__name__) @@ -189,12 +189,18 @@ def __init__( self._supported_modules: dict[str, IotModule] | None = None self._legacy_features: set[str] = set() self._children: Mapping[str, IotDevice] = {} + self._modules: dict[str, IotModule] = {} @property def children(self) -> Sequence[IotDevice]: """Return list of children.""" return list(self._children.values()) + @property + def modules(self) -> dict[str, IotModule]: + """Return the device modules.""" + return self._modules + def add_module(self, name: str, module: IotModule): """Register a module.""" if name in self.modules: @@ -420,31 +426,31 @@ async def set_alias(self, alias: str) -> None: """Set the device name (alias).""" return await self._query_helper("system", "set_dev_alias", {"alias": alias}) - @property # type: ignore + @property @requires_update def time(self) -> datetime: """Return current time from the device.""" - return self.modules["time"].time + return cast(Time, self.modules["time"]).time - @property # type: ignore + @property @requires_update def timezone(self) -> dict: """Return the current timezone.""" - return self.modules["time"].timezone + return cast(Time, self.modules["time"]).timezone async def get_time(self) -> datetime | None: """Return current time from the device, if available.""" _LOGGER.warning( "Use `time` property instead, this call will be removed in the future." ) - return await self.modules["time"].get_time() + return await cast(Time, self.modules["time"]).get_time() async def get_timezone(self) -> dict: """Return timezone information.""" _LOGGER.warning( "Use `timezone` property instead, this call will be removed in the future." ) - return await self.modules["time"].get_timezone() + return await cast(Time, self.modules["time"]).get_timezone() @property # type: ignore @requires_update @@ -520,31 +526,31 @@ async def set_mac(self, mac): """ return await self._query_helper("system", "set_mac_addr", {"mac": mac}) - @property # type: ignore + @property @requires_update def emeter_realtime(self) -> EmeterStatus: """Return current energy readings.""" self._verify_emeter() - return EmeterStatus(self.modules["emeter"].realtime) + return EmeterStatus(cast(Emeter, self.modules["emeter"]).realtime) async def get_emeter_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" self._verify_emeter() - return EmeterStatus(await self.modules["emeter"].get_realtime()) + return EmeterStatus(await cast(Emeter, self.modules["emeter"]).get_realtime()) - @property # type: ignore + @property @requires_update def emeter_today(self) -> float | None: """Return today's energy consumption in kWh.""" self._verify_emeter() - return self.modules["emeter"].emeter_today + return cast(Emeter, self.modules["emeter"]).emeter_today - @property # type: ignore + @property @requires_update def emeter_this_month(self) -> float | None: """Return this month's energy consumption in kWh.""" self._verify_emeter() - return self.modules["emeter"].emeter_this_month + return cast(Emeter, self.modules["emeter"]).emeter_this_month async def get_emeter_daily( self, year: int | None = None, month: int | None = None, kwh: bool = True @@ -558,7 +564,9 @@ async def get_emeter_daily( :return: mapping of day of month to value """ self._verify_emeter() - return await self.modules["emeter"].get_daystat(year=year, month=month, kwh=kwh) + return await cast(Emeter, self.modules["emeter"]).get_daystat( + year=year, month=month, kwh=kwh + ) @requires_update async def get_emeter_monthly( @@ -571,13 +579,15 @@ async def get_emeter_monthly( :return: dict: mapping of month to value """ self._verify_emeter() - return await self.modules["emeter"].get_monthstat(year=year, kwh=kwh) + return await cast(Emeter, self.modules["emeter"]).get_monthstat( + year=year, kwh=kwh + ) @requires_update async def erase_emeter_stats(self) -> dict: """Erase energy meter statistics.""" self._verify_emeter() - return await self.modules["emeter"].erase_stats() + return await cast(Emeter, self.modules["emeter"]).erase_stats() @requires_update async def current_consumption(self) -> float: diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 99f5913d6..9e99a0748 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -253,7 +253,7 @@ def __init__(self, host: str, parent: IotStrip, child_id: str) -> None: self._last_update = parent._last_update self._set_sys_info(parent.sys_info) self._device_type = DeviceType.StripSocket - self.modules = {} + self._modules = {} self.protocol = parent.protocol # Must use the same connection as the parent self.add_module("time", Time(self, "time")) diff --git a/kasa/module.py b/kasa/module.py index ad0b5562a..213a2e0ac 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -4,11 +4,14 @@ import logging from abc import ABC, abstractmethod +from typing import TYPE_CHECKING -from .device import Device from .exceptions import KasaException from .feature import Feature +if TYPE_CHECKING: + from .device import Device + _LOGGER = logging.getLogger(__name__) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index b325614be..80528fe44 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -47,7 +47,8 @@ def __init__( self._components_raw: dict[str, Any] | None = None self._components: dict[str, int] = {} self._state_information: dict[str, Any] = {} - self.modules: dict[str, SmartModule] = {} + self._modules: dict[str, SmartModule] = {} + self._exposes_child_modules = False self._parent: SmartDevice | None = None self._children: Mapping[str, SmartDevice] = {} self._last_update = {} @@ -84,11 +85,13 @@ async def _initialize_children(self): @property def children(self) -> Sequence[SmartDevice]: """Return list of children.""" - # Wall switches with children report all modules on the parent only - if self.device_type == DeviceType.WallSwitch: - return [] return list(self._children.values()) + @property + def modules(self) -> dict[str, SmartModule]: + """Return the device modules.""" + return self._modules + def _try_get_response(self, responses: dict, request: str, default=None) -> dict: response = responses.get(request) if isinstance(response, SmartErrorCode): @@ -148,7 +151,7 @@ async def update(self, update_children: bool = True): req: dict[str, Any] = {} # TODO: this could be optimized by constructing the query only once - for module in self.modules.values(): + for module in self._modules.values(): req.update(module.query()) self._last_update = resp = await self.protocol.query(req) @@ -174,19 +177,24 @@ async def _initialize_modules(self): # Some wall switches (like ks240) are internally presented as having child # devices which report the child's components on the parent's sysinfo, even # when they need to be accessed through the children. - # The logic below ensures that such devices report all but whitelisted, the - # child modules at the parent level to create an illusion of a single device. + # The logic below ensures that such devices add all but whitelisted, only on + # the child device. + skip_parent_only_modules = False + child_modules_to_skip = {} if self._parent and self._parent.device_type == DeviceType.WallSwitch: - modules = self._parent.modules skip_parent_only_modules = True - else: - modules = self.modules - skip_parent_only_modules = False + elif self._children and self.device_type == DeviceType.WallSwitch: + # _initialize_modules is called on the parent after the children + self._exposes_child_modules = True + for child in self._children.values(): + child_modules_to_skip.update(**child.modules) for mod in SmartModule.REGISTERED_MODULES.values(): _LOGGER.debug("%s requires %s", mod, mod.REQUIRED_COMPONENT) - if skip_parent_only_modules and mod in WALL_SWITCH_PARENT_ONLY_MODULES: + if ( + skip_parent_only_modules and mod in WALL_SWITCH_PARENT_ONLY_MODULES + ) or mod.__name__ in child_modules_to_skip: continue if mod.REQUIRED_COMPONENT in self._components: _LOGGER.debug( @@ -195,8 +203,11 @@ async def _initialize_modules(self): mod.__name__, ) module = mod(self, mod.REQUIRED_COMPONENT) - if module.name not in modules and await module._check_supported(): - modules[module.name] = module + if await module._check_supported(): + self._modules[module.name] = module + + if self._exposes_child_modules: + self._modules.update(**child_modules_to_skip) async def _initialize_features(self): """Initialize device features.""" @@ -278,7 +289,7 @@ async def _initialize_features(self): ) ) - for module in self.modules.values(): + for module in self._modules.values(): for feat in module._module_features.values(): self._add_feature(feat) diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 559ffefe0..41d5706cc 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -1,6 +1,9 @@ +from typing import cast + from pytest_mock import MockerFixture from kasa import SmartDevice +from kasa.smart.modules import FanModule from kasa.tests.device_fixtures import parametrize fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"SMART"}) @@ -9,7 +12,7 @@ @fan async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): """Test fan speed feature.""" - fan = dev.modules.get("FanModule") + fan = cast(FanModule, dev.modules.get("FanModule")) assert fan level_feature = fan._module_features["fan_speed_level"] @@ -32,7 +35,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): @fan async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): """Test sleep mode feature.""" - fan = dev.modules.get("FanModule") + fan = cast(FanModule, dev.modules.get("FanModule")) assert fan sleep_feature = fan._module_features["fan_sleep_mode"] assert isinstance(sleep_feature.value, bool) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 037edaf90..2b39e105a 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -103,22 +103,22 @@ async def test_negotiate(dev: SmartDevice, mocker: MockerFixture): async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): """Test that the regular update uses queries from all supported modules.""" # We need to have some modules initialized by now - assert dev.modules + assert dev._modules device_queries: dict[SmartDevice, dict[str, Any]] = {} - for mod in dev.modules.values(): + for mod in dev._modules.values(): device_queries.setdefault(mod._device, {}).update(mod.query()) spies = {} - for dev in device_queries: - spies[dev] = mocker.spy(dev.protocol, "query") + for device in device_queries: + spies[device] = mocker.spy(device.protocol, "query") await dev.update() - for dev in device_queries: - if device_queries[dev]: - spies[dev].assert_called_with(device_queries[dev]) + for device in device_queries: + if device_queries[device]: + spies[device].assert_called_with(device_queries[device]) else: - spies[dev].assert_not_called() + spies[device].assert_not_called() @bulb_smart From d3544b4989bca8d97d317615c64339007ad81cbb Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 29 Apr 2024 18:19:44 +0100 Subject: [PATCH 403/892] Move SmartBulb into SmartDevice (#874) --- kasa/__init__.py | 1 - kasa/bulb.py | 4 +- kasa/cli.py | 4 +- kasa/device_factory.py | 8 +- kasa/smart/__init__.py | 3 +- kasa/smart/smartbulb.py | 189 -------------------------------- kasa/smart/smartdevice.py | 193 ++++++++++++++++++++++++++++++++- kasa/tests/device_fixtures.py | 18 +-- kasa/tests/test_bulb.py | 27 +++-- kasa/tests/test_childdevice.py | 11 +- kasa/tests/test_smartdevice.py | 4 +- 11 files changed, 229 insertions(+), 233 deletions(-) delete mode 100644 kasa/smart/smartbulb.py diff --git a/kasa/__init__.py b/kasa/__init__.py index ceaf7520f..62d545025 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -134,7 +134,6 @@ def __getattr__(name): from . import smart smart.SmartDevice("127.0.0.1") - smart.SmartBulb("127.0.0.1") iot.IotDevice("127.0.0.1") iot.IotPlug("127.0.0.1") iot.IotBulb("127.0.0.1") diff --git a/kasa/bulb.py b/kasa/bulb.py index 50c5d2437..890449ca9 100644 --- a/kasa/bulb.py +++ b/kasa/bulb.py @@ -5,8 +5,6 @@ from abc import ABC, abstractmethod from typing import NamedTuple, Optional -from .device import Device - try: from pydantic.v1 import BaseModel except ImportError: @@ -45,7 +43,7 @@ class BulbPreset(BaseModel): mode: Optional[int] # noqa: UP007 -class Bulb(Device, ABC): +class Bulb(ABC): """Base class for TP-Link Bulb.""" def _raise_for_invalid_brightness(self, value): diff --git a/kasa/cli.py b/kasa/cli.py index 317bf0383..d8191a8f0 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -40,7 +40,7 @@ IotWallSwitch, ) from kasa.iot.modules import Usage -from kasa.smart import SmartBulb, SmartDevice +from kasa.smart import SmartDevice try: from pydantic.v1 import ValidationError @@ -88,7 +88,7 @@ def wrapper(message=None, *args, **kwargs): "iot.strip": IotStrip, "iot.lightstrip": IotLightStrip, "smart.plug": SmartDevice, - "smart.bulb": SmartBulb, + "smart.bulb": SmartDevice, } ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in EncryptType] diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 4450a023f..ff2c9fcc8 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -26,7 +26,7 @@ BaseProtocol, BaseTransport, ) -from .smart import SmartBulb, SmartDevice +from .smart import SmartDevice from .smartprotocol import SmartProtocol from .xortransport import XorTransport @@ -162,12 +162,12 @@ def get_device_class_from_family(device_type: str) -> type[Device] | None: """Return the device class from the type name.""" supported_device_types: dict[str, type[Device]] = { "SMART.TAPOPLUG": SmartDevice, - "SMART.TAPOBULB": SmartBulb, - "SMART.TAPOSWITCH": SmartBulb, + "SMART.TAPOBULB": SmartDevice, + "SMART.TAPOSWITCH": SmartDevice, "SMART.KASAPLUG": SmartDevice, "SMART.TAPOHUB": SmartDevice, "SMART.KASAHUB": SmartDevice, - "SMART.KASASWITCH": SmartBulb, + "SMART.KASASWITCH": SmartDevice, "IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTBULB": IotBulb, } diff --git a/kasa/smart/__init__.py b/kasa/smart/__init__.py index 721e4eca3..09e3aba50 100644 --- a/kasa/smart/__init__.py +++ b/kasa/smart/__init__.py @@ -1,7 +1,6 @@ """Package for supporting tapo-branded and newer kasa devices.""" -from .smartbulb import SmartBulb from .smartchilddevice import SmartChildDevice from .smartdevice import SmartDevice -__all__ = ["SmartDevice", "SmartBulb", "SmartChildDevice"] +__all__ = ["SmartDevice", "SmartChildDevice"] diff --git a/kasa/smart/smartbulb.py b/kasa/smart/smartbulb.py deleted file mode 100644 index 8da348977..000000000 --- a/kasa/smart/smartbulb.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Module for tapo-branded smart bulbs (L5**).""" - -from __future__ import annotations - -from typing import cast - -from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange -from ..exceptions import KasaException -from .modules import Brightness, ColorModule, ColorTemperatureModule -from .smartdevice import SmartDevice - -AVAILABLE_EFFECTS = { - "L1": "Party", - "L2": "Relax", -} - - -class SmartBulb(SmartDevice, Bulb): - """Representation of a TP-Link Tapo Bulb. - - Documentation TBD. See :class:`~kasa.iot.Bulb` for now. - """ - - @property - def is_color(self) -> bool: - """Whether the bulb supports color changes.""" - return "ColorModule" in self.modules - - @property - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - return "Brightness" in self.modules - - @property - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - return "ColorTemperatureModule" in self.modules - - @property - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - if not self.is_variable_color_temp: - raise KasaException("Color temperature not supported") - - return cast( - ColorTemperatureModule, self.modules["ColorTemperatureModule"] - ).valid_temperature_range - - @property - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - return "dynamic_light_effect_enable" in self._info - - @property - def effect(self) -> dict: - """Return effect state. - - This follows the format used by SmartLightStrip. - - Example: - {'brightness': 50, - 'custom': 0, - 'enable': 0, - 'id': '', - 'name': ''} - """ - # If no effect is active, dynamic_light_effect_id does not appear in info - current_effect = self._info.get("dynamic_light_effect_id", "") - data = { - "brightness": self.brightness, - "enable": current_effect != "", - "id": current_effect, - "name": AVAILABLE_EFFECTS.get(current_effect, ""), - } - - return data - - @property - def effect_list(self) -> list[str] | None: - """Return built-in effects list. - - Example: - ['Party', 'Relax', ...] - """ - return list(AVAILABLE_EFFECTS.keys()) if self.has_effects else None - - @property - def hsv(self) -> HSV: - """Return the current HSV state of the bulb. - - :return: hue, saturation and value (degrees, %, %) - """ - if not self.is_color: - raise KasaException("Bulb does not support color.") - - return cast(ColorModule, self.modules["ColorModule"]).hsv - - @property - def color_temp(self) -> int: - """Whether the bulb supports color temperature changes.""" - if not self.is_variable_color_temp: - raise KasaException("Bulb does not support colortemp.") - - return cast( - ColorTemperatureModule, self.modules["ColorTemperatureModule"] - ).color_temp - - @property - def brightness(self) -> int: - """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover - raise KasaException("Bulb is not dimmable.") - - return self._info.get("brightness", -1) - - async def set_hsv( - self, - hue: int, - saturation: int, - value: int | None = None, - *, - transition: int | None = None, - ) -> dict: - """Set new HSV. - - Note, transition is not supported and will be ignored. - - :param int hue: hue in degrees - :param int saturation: saturation in percentage [0,100] - :param int value: value between 1 and 100 - :param int transition: transition in milliseconds. - """ - if not self.is_color: - raise KasaException("Bulb does not support color.") - - return await cast(ColorModule, self.modules["ColorModule"]).set_hsv( - hue, saturation, value - ) - - async def set_color_temp( - self, temp: int, *, brightness=None, transition: int | None = None - ) -> dict: - """Set the color temperature of the device in kelvin. - - Note, transition is not supported and will be ignored. - - :param int temp: The new color temperature, in Kelvin - :param int transition: transition in milliseconds. - """ - if not self.is_variable_color_temp: - raise KasaException("Bulb does not support colortemp.") - return await cast( - ColorTemperatureModule, self.modules["ColorTemperatureModule"] - ).set_color_temp(temp) - - async def set_brightness( - self, brightness: int, *, transition: int | None = None - ) -> dict: - """Set the brightness in percentage. - - Note, transition is not supported and will be ignored. - - :param int brightness: brightness in percent - :param int transition: transition in milliseconds. - """ - if not self.is_dimmable: # pragma: no cover - raise KasaException("Bulb is not dimmable.") - - return await cast(Brightness, self.modules["Brightness"]).set_brightness( - brightness - ) - - async def set_effect( - self, - effect: str, - *, - brightness: int | None = None, - transition: int | None = None, - ) -> None: - """Set an effect on the device.""" - raise NotImplementedError() - - @property - def presets(self) -> list[BulbPreset]: - """Return a list of available bulb setting presets.""" - return [] diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 80528fe44..4d9de40ae 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..aestransport import AesTransport +from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..device import Device, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -15,7 +16,16 @@ from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..feature import Feature from ..smartprotocol import SmartProtocol -from .modules import * # noqa: F403 +from .modules import ( + Brightness, + CloudModule, + ColorModule, + ColorTemperatureModule, + DeviceModule, + EnergyModule, + Firmware, + TimeModule, +) _LOGGER = logging.getLogger(__name__) @@ -28,8 +38,13 @@ # same issue, homekit perhaps? WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule] # noqa: F405 +AVAILABLE_BULB_EFFECTS = { + "L1": "Party", + "L2": "Relax", +} -class SmartDevice(Device): + +class SmartDevice(Device, Bulb): """Base class to represent a SMART protocol based device.""" def __init__( @@ -404,6 +419,11 @@ def has_emeter(self) -> bool: """Return if the device has emeter.""" return "EnergyModule" in self.modules + @property + def is_dimmer(self) -> bool: + """Whether the device acts as a dimmer.""" + return self.is_dimmable + @property def is_on(self) -> bool: """Return true if the device is on.""" @@ -613,3 +633,172 @@ def _get_device_type_from_components( return DeviceType.WallSwitch _LOGGER.warning("Unknown device type, falling back to plug") return DeviceType.Plug + + # Bulb interface methods + + @property + def is_color(self) -> bool: + """Whether the bulb supports color changes.""" + return "ColorModule" in self.modules + + @property + def is_dimmable(self) -> bool: + """Whether the bulb supports brightness changes.""" + return "Brightness" in self.modules + + @property + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + return "ColorTemperatureModule" in self.modules + + @property + def valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + if not self.is_variable_color_temp: + raise KasaException("Color temperature not supported") + + return cast( + ColorTemperatureModule, self.modules["ColorTemperatureModule"] + ).valid_temperature_range + + @property + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + return "dynamic_light_effect_enable" in self._info + + @property + def effect(self) -> dict: + """Return effect state. + + This follows the format used by SmartLightStrip. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + # If no effect is active, dynamic_light_effect_id does not appear in info + current_effect = self._info.get("dynamic_light_effect_id", "") + data = { + "brightness": self.brightness, + "enable": current_effect != "", + "id": current_effect, + "name": AVAILABLE_BULB_EFFECTS.get(current_effect, ""), + } + + return data + + @property + def effect_list(self) -> list[str] | None: + """Return built-in effects list. + + Example: + ['Party', 'Relax', ...] + """ + return list(AVAILABLE_BULB_EFFECTS.keys()) if self.has_effects else None + + @property + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if not self.is_color: + raise KasaException("Bulb does not support color.") + + return cast(ColorModule, self.modules["ColorModule"]).hsv + + @property + def color_temp(self) -> int: + """Whether the bulb supports color temperature changes.""" + if not self.is_variable_color_temp: + raise KasaException("Bulb does not support colortemp.") + + return cast( + ColorTemperatureModule, self.modules["ColorTemperatureModule"] + ).color_temp + + @property + def brightness(self) -> int: + """Return the current brightness in percentage.""" + if not self.is_dimmable: # pragma: no cover + raise KasaException("Bulb is not dimmable.") + + return cast(Brightness, self.modules["Brightness"]).brightness + + async def set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = None, + ) -> dict: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value between 1 and 100 + :param int transition: transition in milliseconds. + """ + if not self.is_color: + raise KasaException("Bulb does not support color.") + + return await cast(ColorModule, self.modules["ColorModule"]).set_hsv( + hue, saturation, value + ) + + async def set_color_temp( + self, temp: int, *, brightness=None, transition: int | None = None + ) -> dict: + """Set the color temperature of the device in kelvin. + + Note, transition is not supported and will be ignored. + + :param int temp: The new color temperature, in Kelvin + :param int transition: transition in milliseconds. + """ + if not self.is_variable_color_temp: + raise KasaException("Bulb does not support colortemp.") + return await cast( + ColorTemperatureModule, self.modules["ColorTemperatureModule"] + ).set_color_temp(temp) + + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set the brightness in percentage. + + Note, transition is not supported and will be ignored. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + if not self.is_dimmable: # pragma: no cover + raise KasaException("Bulb is not dimmable.") + + return await cast(Brightness, self.modules["Brightness"]).set_brightness( + brightness + ) + + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> None: + """Set an effect on the device.""" + raise NotImplementedError() + + @property + def presets(self) -> list[BulbPreset]: + """Return a list of available bulb setting presets.""" + return [] diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 372c74a63..50dfbce7f 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -1,7 +1,5 @@ from __future__ import annotations -from itertools import chain - import pytest from kasa import ( @@ -11,7 +9,7 @@ Discover, ) from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch -from kasa.smart import SmartBulb, SmartDevice +from kasa.smart import SmartDevice from .fakeprotocol_iot import FakeIotProtocol from .fakeprotocol_smart import FakeSmartProtocol @@ -319,19 +317,7 @@ def check_categories(): def device_for_fixture_name(model, protocol): if "SMART" in protocol: - for d in chain( - PLUGS_SMART, - SWITCHES_SMART, - STRIPS_SMART, - HUBS_SMART, - SENSORS_SMART, - THERMOSTATS_SMART, - ): - if d in model: - return SmartDevice - for d in chain(BULBS_SMART, DIMMERS_SMART): - if d in model: - return SmartBulb + return SmartDevice else: for d in STRIPS_IOT: if d in model: diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 668b034bc..acee8f74c 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -7,9 +7,9 @@ Schema, ) -from kasa import Bulb, BulbPreset, DeviceType, KasaException -from kasa.iot import IotBulb -from kasa.smart import SmartBulb +from kasa import Bulb, BulbPreset, Device, DeviceType, KasaException +from kasa.iot import IotBulb, IotDimmer +from kasa.smart import SmartDevice from .conftest import ( bulb, @@ -30,7 +30,7 @@ @bulb -async def test_bulb_sysinfo(dev: Bulb): +async def test_bulb_sysinfo(dev: Device): assert dev.sys_info is not None SYSINFO_SCHEMA_BULB(dev.sys_info) @@ -43,7 +43,7 @@ async def test_bulb_sysinfo(dev: Bulb): @bulb -async def test_state_attributes(dev: Bulb): +async def test_state_attributes(dev: Device): assert "Cloud connection" in dev.state_information assert isinstance(dev.state_information["Cloud connection"], bool) @@ -64,7 +64,8 @@ async def test_get_light_state(dev: IotBulb): @color_bulb @turn_on -async def test_hsv(dev: Bulb, turn_on): +async def test_hsv(dev: Device, turn_on): + assert isinstance(dev, Bulb) await handle_turn_on(dev, turn_on) assert dev.is_color @@ -114,7 +115,8 @@ async def test_invalid_hsv(dev: Bulb, turn_on): @color_bulb @pytest.mark.skip("requires color feature") -async def test_color_state_information(dev: Bulb): +async def test_color_state_information(dev: Device): + assert isinstance(dev, Bulb) assert "HSV" in dev.state_information assert dev.state_information["HSV"] == dev.hsv @@ -131,14 +133,16 @@ async def test_hsv_on_non_color(dev: Bulb): @variable_temp @pytest.mark.skip("requires colortemp module") -async def test_variable_temp_state_information(dev: Bulb): +async def test_variable_temp_state_information(dev: Device): + assert isinstance(dev, Bulb) assert "Color temperature" in dev.state_information assert dev.state_information["Color temperature"] == dev.color_temp @variable_temp @turn_on -async def test_try_set_colortemp(dev: Bulb, turn_on): +async def test_try_set_colortemp(dev: Device, turn_on): + assert isinstance(dev, Bulb) await handle_turn_on(dev, turn_on) await dev.set_color_temp(2700) await dev.update() @@ -162,7 +166,7 @@ async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): @variable_temp_smart -async def test_smart_temp_range(dev: SmartBulb): +async def test_smart_temp_range(dev: SmartDevice): assert dev.valid_temperature_range @@ -188,7 +192,8 @@ async def test_non_variable_temp(dev: Bulb): @dimmable @turn_on -async def test_dimmable_brightness(dev: Bulb, turn_on): +async def test_dimmable_brightness(dev: Device, turn_on): + assert isinstance(dev, (Bulb, IotDimmer)) await handle_turn_on(dev, turn_on) assert dev.is_dimmable diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 64ad70fa1..9e4b6fdb6 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -61,7 +61,16 @@ def _test_property_getters(): # Skip emeter and time properties # TODO: needs API cleanup, emeter* should probably be removed in favor # of access through features/modules, handling of time* needs decision. - if name.startswith("emeter_") or name.startswith("time"): + if ( + name.startswith("emeter_") + or name.startswith("time") + or name.startswith("fan") + or name.startswith("color") + or name.startswith("brightness") + or name.startswith("valid_temperature_range") + or name.startswith("hsv") + or name.startswith("effect") + ): continue try: _ = getattr(first, name) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 2b39e105a..2dc27ac46 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -11,7 +11,7 @@ from kasa import KasaException from kasa.exceptions import SmartErrorCode -from kasa.smart import SmartBulb, SmartDevice +from kasa.smart import SmartDevice from .conftest import ( bulb_smart, @@ -122,7 +122,7 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): @bulb_smart -async def test_smartdevice_brightness(dev: SmartBulb): +async def test_smartdevice_brightness(dev: SmartDevice): """Test brightness setter and getter.""" assert isinstance(dev, SmartDevice) assert "brightness" in dev._components From 300d82389512f3978028e71a9ee5f385aff163d3 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 30 Apr 2024 08:56:09 +0200 Subject: [PATCH 404/892] Implement choice feature type (#880) Implement the choice feature type allowing to provide a list of choices that can be set. Co-authored-by: sdb9696 --- kasa/feature.py | 17 ++++++++++++ kasa/module.py | 6 ++++ kasa/smart/modules/alarmmodule.py | 46 +++++++++++++++++++++++++------ kasa/smart/smartdevice.py | 1 + kasa/tests/discovery_fixtures.py | 25 +++++++++++++---- kasa/tests/test_cli.py | 3 -- kasa/tests/test_feature.py | 18 ++++++++++++ 7 files changed, 98 insertions(+), 18 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index 3bd0ccb49..30acf362e 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -92,6 +92,13 @@ class Category(Enum): #: If set, this property will be used to set *minimum_value* and *maximum_value*. range_getter: str | None = None + # Choice-specific attributes + #: List of choices as enum + choices: list[str] | None = None + #: Attribute name of the choices getter property. + #: If set, this property will be used to set *choices*. + choices_getter: str | None = None + #: Identifier id: str | None = None @@ -108,6 +115,10 @@ def __post_init__(self): container, self.range_getter ) + # Populate choices, if choices_getter is given + if self.choices_getter is not None: + self.choices = getattr(container, self.choices_getter) + # Set the category, if unset if self.category is Feature.Category.Unset: if self.attribute_setter: @@ -147,6 +158,12 @@ async def set_value(self, value): f"Value {value} out of range " f"[{self.minimum_value}, {self.maximum_value}]" ) + elif self.type == Feature.Type.Choice: # noqa: SIM102 + if value not in self.choices: + raise ValueError( + f"Unexpected value for {self.name}: {value}" + f" - allowed: {self.choices}" + ) container = self.container if self.container is not None else self.device if self.type == Feature.Type.Action: diff --git a/kasa/module.py b/kasa/module.py index 213a2e0ac..8422eaf94 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -40,6 +40,12 @@ def query(self): def data(self): """Return the module specific raw data from the last update.""" + def _initialize_features(self): # noqa: B027 + """Initialize features after the initial update. + + This can be implemented if features depend on module query responses. + """ + def _add_feature(self, feature: Feature): """Add module feature.""" diff --git a/kasa/smart/modules/alarmmodule.py b/kasa/smart/modules/alarmmodule.py index 5f6cd3ee7..a3c67ef2c 100644 --- a/kasa/smart/modules/alarmmodule.py +++ b/kasa/smart/modules/alarmmodule.py @@ -2,14 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - class AlarmModule(SmartModule): """Implementation of alarm module.""" @@ -23,8 +18,12 @@ def query(self) -> dict: "get_support_alarm_type_list": None, # This should be needed only once } - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features. + + This is implemented as some features depend on device responses. + """ + device = self._device self._add_feature( Feature( device, @@ -46,12 +45,26 @@ def __init__(self, device: SmartDevice, module: str): ) self._add_feature( Feature( - device, "Alarm sound", container=self, attribute_getter="alarm_sound" + device, + "Alarm sound", + container=self, + attribute_getter="alarm_sound", + attribute_setter="set_alarm_sound", + category=Feature.Category.Config, + type=Feature.Type.Choice, + choices_getter="alarm_sounds", ) ) self._add_feature( Feature( - device, "Alarm volume", container=self, attribute_getter="alarm_volume" + device, + "Alarm volume", + container=self, + attribute_getter="alarm_volume", + attribute_setter="set_alarm_volume", + category=Feature.Category.Config, + type=Feature.Type.Choice, + choices=["low", "high"], ) ) self._add_feature( @@ -78,6 +91,15 @@ def alarm_sound(self): """Return current alarm sound.""" return self.data["get_alarm_configure"]["type"] + async def set_alarm_sound(self, sound: str): + """Set alarm sound. + + See *alarm_sounds* for list of available sounds. + """ + payload = self.data["get_alarm_configure"].copy() + payload["type"] = sound + return await self.call("set_alarm_configure", payload) + @property def alarm_sounds(self) -> list[str]: """Return list of available alarm sounds.""" @@ -88,6 +110,12 @@ def alarm_volume(self): """Return alarm volume.""" return self.data["get_alarm_configure"]["volume"] + async def set_alarm_volume(self, volume: str): + """Set alarm volume.""" + payload = self.data["get_alarm_configure"].copy() + payload["volume"] = volume + return await self.call("set_alarm_configure", payload) + @property def active(self) -> bool: """Return true if alarm is active.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 4d9de40ae..577ae0908 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -305,6 +305,7 @@ async def _initialize_features(self): ) for module in self._modules.values(): + module._initialize_features() for feat in module._module_features.values(): self._add_feature(feat) diff --git a/kasa/tests/discovery_fixtures.py b/kasa/tests/discovery_fixtures.py index 957dc0074..175c361a4 100644 --- a/kasa/tests/discovery_fixtures.py +++ b/kasa/tests/discovery_fixtures.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy from dataclasses import dataclass from json import dumps as json_dumps @@ -8,7 +9,7 @@ from kasa.xortransport import XorEncryption from .fakeprotocol_iot import FakeIotProtocol -from .fakeprotocol_smart import FakeSmartProtocol +from .fakeprotocol_smart import FakeSmartProtocol, FakeSmartTransport from .fixtureinfo import FixtureInfo, filter_fixtures, idgenerator @@ -65,6 +66,7 @@ def parametrize_discovery(desc, *, data_root_filter, protocol_filter=None): ids=idgenerator, ) def discovery_mock(request, mocker): + """Mock discovery and patch protocol queries to use Fake protocols.""" fixture_info: FixtureInfo = request.param fixture_data = fixture_info.data @@ -157,12 +159,23 @@ async def _query(request, retry_count: int = 3): def discovery_data(request, mocker): """Return raw discovery file contents as JSON. Used for discovery tests.""" fixture_info = request.param - mocker.patch("kasa.IotProtocol.query", return_value=fixture_info.data) - mocker.patch("kasa.SmartProtocol.query", return_value=fixture_info.data) - if "discovery_result" in fixture_info.data: - return {"result": fixture_info.data["discovery_result"]} + fixture_data = copy.deepcopy(fixture_info.data) + # Add missing queries to fixture data + if "component_nego" in fixture_data: + components = { + comp["id"]: int(comp["ver_code"]) + for comp in fixture_data["component_nego"]["component_list"] + } + for k, v in FakeSmartTransport.FIXTURE_MISSING_MAP.items(): + # Value is a tuple of component,reponse + if k not in fixture_data and v[0] in components: + fixture_data[k] = v[1] + mocker.patch("kasa.IotProtocol.query", return_value=fixture_data) + mocker.patch("kasa.SmartProtocol.query", return_value=fixture_data) + if "discovery_result" in fixture_data: + return {"result": fixture_data["discovery_result"]} else: - return {"system": {"get_sysinfo": fixture_info.data["system"]["get_sysinfo"]}} + return {"system": {"get_sysinfo": fixture_data["system"]["get_sysinfo"]}} @pytest.fixture(params=UNSUPPORTED_DEVICES.values(), ids=UNSUPPORTED_DEVICES.keys()) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 9fb463892..a803fdc26 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -354,9 +354,6 @@ async def _state(dev: Device): mocker.patch("kasa.cli.state", new=_state) - mocker.patch("kasa.IotProtocol.query", return_value=discovery_mock.query_data) - mocker.patch("kasa.SmartProtocol.query", return_value=discovery_mock.query_data) - dr = DiscoveryResult(**discovery_mock.discovery_data["result"]) res = await runner.invoke( cli, diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index 85ac42d8f..f5de47d1f 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -1,4 +1,5 @@ import pytest +from pytest_mock import MockFixture from kasa import Feature @@ -110,6 +111,23 @@ async def test_feature_action(mocker): mock_call_action.assert_called() +async def test_feature_choice_list(dummy_feature, caplog, mocker: MockFixture): + """Test the choice feature type.""" + dummy_feature.type = Feature.Type.Choice + dummy_feature.choices = ["first", "second"] + + mock_setter = mocker.patch.object(dummy_feature.device, "dummysetter", create=True) + await dummy_feature.set_value("first") + mock_setter.assert_called_with("first") + mock_setter.reset_mock() + + with pytest.raises(ValueError): + await dummy_feature.set_value("invalid") + assert "Unexpected value" in caplog.text + + mock_setter.assert_not_called() + + @pytest.mark.parametrize("precision_hint", [1, 2, 3]) async def test_precision_hint(dummy_feature, precision_hint): """Test that precision hint works as expected.""" From 5599756d289f013c7617e046cb493f133cf0fb9c Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 30 Apr 2024 17:31:47 +0200 Subject: [PATCH 405/892] Add support for waterleak sensor (T300) (#876) --- kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/waterleak.py | 62 ++++++++++++++++++++++ kasa/smart/smartchilddevice.py | 1 + kasa/tests/smart/modules/test_waterleak.py | 42 +++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 kasa/smart/modules/waterleak.py create mode 100644 kasa/tests/smart/modules/test_waterleak.py diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index b3b1d9f47..d028b9d77 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -19,6 +19,7 @@ from .temperature import TemperatureSensor from .temperaturecontrol import TemperatureControl from .timemodule import TimeModule +from .waterleak import WaterleakSensor __all__ = [ "AlarmModule", @@ -40,4 +41,5 @@ "LightTransitionModule", "ColorTemperatureModule", "ColorModule", + "WaterleakSensor", ] diff --git a/kasa/smart/modules/waterleak.py b/kasa/smart/modules/waterleak.py new file mode 100644 index 000000000..1809c5560 --- /dev/null +++ b/kasa/smart/modules/waterleak.py @@ -0,0 +1,62 @@ +"""Implementation of waterleak module.""" + +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING + +from ...feature import Feature +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class WaterleakStatus(Enum): + """Waterleawk status.""" + + Normal = "normal" + LeakDetected = "water_leak" + Drying = "water_dry" + + +class WaterleakSensor(SmartModule): + """Implementation of waterleak module.""" + + REQUIRED_COMPONENT = "sensor_alarm" + + def __init__(self, device: SmartDevice, module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Water leak", + container=self, + attribute_getter="status", + icon="mdi:water", + ) + ) + self._add_feature( + Feature( + device, + "Water alert", + container=self, + attribute_getter="alert", + icon="mdi:water-alert", + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Water leak information is contained in the main device info response. + return {} + + @property + def status(self) -> WaterleakStatus: + """Return current humidity in percentage.""" + return WaterleakStatus(self._device.sys_info["water_leak_status"]) + + @property + def alert(self) -> bool: + """Return true if alarm is active.""" + return self._device.sys_info["in_alarm"] diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 8852262c2..7f747b846 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -50,6 +50,7 @@ def device_type(self) -> DeviceType: child_device_map = { "plug.powerstrip.sub-plug": DeviceType.Plug, "subg.trigger.temp-hmdt-sensor": DeviceType.Sensor, + "subg.trigger.water-leak-sensor": DeviceType.Sensor, "kasa.switch.outlet.sub-fan": DeviceType.Fan, "kasa.switch.outlet.sub-dimmer": DeviceType.Dimmer, "subg.trv": DeviceType.Thermostat, diff --git a/kasa/tests/smart/modules/test_waterleak.py b/kasa/tests/smart/modules/test_waterleak.py new file mode 100644 index 000000000..247ffb812 --- /dev/null +++ b/kasa/tests/smart/modules/test_waterleak.py @@ -0,0 +1,42 @@ +from enum import Enum + +import pytest + +from kasa.smart.modules import WaterleakSensor +from kasa.tests.device_fixtures import parametrize + +waterleak = parametrize( + "has waterleak", component_filter="sensor_alarm", protocol_filter={"SMART.CHILD"} +) + + +@waterleak +@pytest.mark.parametrize( + "feature, type", + [ + ("alert", int), + ("status", Enum), + ], +) +async def test_waterleak_properties(dev, feature, type): + """Test that features are registered and work as expected.""" + waterleak: WaterleakSensor = dev.modules["WaterleakSensor"] + + prop = getattr(waterleak, feature) + assert isinstance(prop, type) + + feat = waterleak._module_features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@waterleak +async def test_waterleak_features(dev): + """Test waterleak features.""" + waterleak: WaterleakSensor = dev.modules["WaterleakSensor"] + + assert "water_leak" in dev.features + assert dev.features["water_leak"].value == waterleak.status + + assert "water_alert" in dev.features + assert dev.features["water_alert"].value == waterleak.alert From 7db989e2ecb147b887daddc8f394fd2417f8d770 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 30 Apr 2024 18:30:03 +0200 Subject: [PATCH 406/892] Fix --help on subcommands (#886) Pass a dummy object as context object as it will not be used by --help anyway. Also, allow defining --help anywhere in the argv, not just in the last place. --- kasa/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index d8191a8f0..b55fecebf 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -306,9 +306,9 @@ async def cli( ): """A tool for controlling TP-Link smart home devices.""" # noqa # no need to perform any checks if we are just displaying the help - if sys.argv[-1] == "--help": + if "--help" in sys.argv: # Context object is required to avoid crashing on sub-groups - ctx.obj = Device(None) + ctx.obj = object() return # If JSON output is requested, disable echo From 16f17a77293b37b37c73f91780954464e1cecbe3 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 30 Apr 2024 17:42:53 +0100 Subject: [PATCH 407/892] Add Fan interface for SMART devices (#873) Enables the Fan interface for devices supporting that component. Currently the only device with a fan is the ks240 which implements it as a child device. This PR adds a method `get_module` to search the child device for modules if it is a WallSwitch device type. --- kasa/fan.py | 23 ++++++++++ kasa/smart/modules/fanmodule.py | 14 +++--- kasa/smart/smartdevice.py | 45 ++++++++++++++++---- kasa/tests/smart/features/test_brightness.py | 2 +- kasa/tests/smart/modules/test_fan.py | 39 +++++++++++++++-- kasa/tests/test_smartdevice.py | 19 +++++++++ 6 files changed, 124 insertions(+), 18 deletions(-) create mode 100644 kasa/fan.py diff --git a/kasa/fan.py b/kasa/fan.py new file mode 100644 index 000000000..c9601b1b7 --- /dev/null +++ b/kasa/fan.py @@ -0,0 +1,23 @@ +"""Module for Fan Interface.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class Fan(ABC): + """Interface for a Fan.""" + + @property + @abstractmethod + def is_fan(self) -> bool: + """Return True if the device is a fan.""" + + @property + @abstractmethod + def fan_speed_level(self) -> int: + """Return fan speed level.""" + + @abstractmethod + async def set_fan_speed_level(self, level: int): + """Set fan speed level.""" diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fanmodule.py index 13f35aea8..08a681e7e 100644 --- a/kasa/smart/modules/fanmodule.py +++ b/kasa/smart/modules/fanmodule.py @@ -28,7 +28,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_setter="set_fan_speed_level", icon="mdi:fan", type=Feature.Type.Number, - minimum_value=1, + minimum_value=0, maximum_value=4, category=Feature.Category.Primary, ) @@ -55,10 +55,14 @@ def fan_speed_level(self) -> int: return self.data["fan_speed_level"] async def set_fan_speed_level(self, level: int): - """Set fan speed level.""" - if level < 1 or level > 4: - raise ValueError("Invalid level, should be in range 1-4.") - return await self.call("set_device_info", {"fan_speed_level": level}) + """Set fan speed level, 0 for off, 1-4 for on.""" + if level < 0 or level > 4: + raise ValueError("Invalid level, should be in range 0-4.") + if level == 0: + return await self.call("set_device_info", {"device_on": False}) + return await self.call( + "set_device_info", {"device_on": True, "fan_speed_level": level} + ) @property def sleep_mode(self) -> bool: diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 577ae0908..04c2607be 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -14,6 +14,7 @@ from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode +from ..fan import Fan from ..feature import Feature from ..smartprotocol import SmartProtocol from .modules import ( @@ -23,6 +24,7 @@ ColorTemperatureModule, DeviceModule, EnergyModule, + FanModule, Firmware, TimeModule, ) @@ -36,7 +38,7 @@ # the child but only work on the parent. See longer note below in _initialize_modules. # This list should be updated when creating new modules that could have the # same issue, homekit perhaps? -WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule] # noqa: F405 +WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule] AVAILABLE_BULB_EFFECTS = { "L1": "Party", @@ -44,7 +46,7 @@ } -class SmartDevice(Device, Bulb): +class SmartDevice(Device, Bulb, Fan): """Base class to represent a SMART protocol based device.""" def __init__( @@ -221,9 +223,6 @@ async def _initialize_modules(self): if await module._check_supported(): self._modules[module.name] = module - if self._exposes_child_modules: - self._modules.update(**child_modules_to_skip) - async def _initialize_features(self): """Initialize device features.""" self._add_feature( @@ -309,6 +308,16 @@ async def _initialize_features(self): for feat in module._module_features.values(): self._add_feature(feat) + def get_module(self, module_name) -> SmartModule | None: + """Return the module from the device modules or None if not present.""" + if module_name in self.modules: + return self.modules[module_name] + elif self._exposes_child_modules: + for child in self._children.values(): + if module_name in child.modules: + return child.modules[module_name] + return None + @property def is_cloud_connected(self): """Returns if the device is connected to the cloud.""" @@ -460,19 +469,19 @@ async def get_emeter_realtime(self) -> EmeterStatus: @property def emeter_realtime(self) -> EmeterStatus: """Get the emeter status.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405 + energy = cast(EnergyModule, self.modules["EnergyModule"]) return energy.emeter_realtime @property def emeter_this_month(self) -> float | None: """Get the emeter value for this month.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405 + energy = cast(EnergyModule, self.modules["EnergyModule"]) return energy.emeter_this_month @property def emeter_today(self) -> float | None: """Get the emeter value for today.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) # noqa: F405 + energy = cast(EnergyModule, self.modules["EnergyModule"]) return energy.emeter_today @property @@ -635,6 +644,26 @@ def _get_device_type_from_components( _LOGGER.warning("Unknown device type, falling back to plug") return DeviceType.Plug + # Fan interface methods + + @property + def is_fan(self) -> bool: + """Return True if the device is a fan.""" + return "FanModule" in self.modules + + @property + def fan_speed_level(self) -> int: + """Return fan speed level.""" + if not self.is_fan: + raise KasaException("Device is not a Fan") + return cast(FanModule, self.modules["FanModule"]).fan_speed_level + + async def set_fan_speed_level(self, level: int): + """Set fan speed level.""" + if not self.is_fan: + raise KasaException("Device is not a Fan") + await cast(FanModule, self.modules["FanModule"]).set_fan_speed_level(level) + # Bulb interface methods @property diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index d677725d8..79df0abf9 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -10,7 +10,7 @@ @brightness async def test_brightness_component(dev: SmartDevice): """Test brightness feature.""" - brightness = dev.modules.get("Brightness") + brightness = dev.get_module("Brightness") assert brightness assert isinstance(dev, SmartDevice) assert "brightness" in dev._components diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 41d5706cc..429a5d18f 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -1,8 +1,9 @@ from typing import cast +import pytest from pytest_mock import MockerFixture -from kasa import SmartDevice +from kasa.smart import SmartDevice from kasa.smart.modules import FanModule from kasa.tests.device_fixtures import parametrize @@ -12,7 +13,7 @@ @fan async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): """Test fan speed feature.""" - fan = cast(FanModule, dev.modules.get("FanModule")) + fan = cast(FanModule, dev.get_module("FanModule")) assert fan level_feature = fan._module_features["fan_speed_level"] @@ -24,7 +25,9 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): call = mocker.spy(fan, "call") await fan.set_fan_speed_level(3) - call.assert_called_with("set_device_info", {"fan_speed_level": 3}) + call.assert_called_with( + "set_device_info", {"device_on": True, "fan_speed_level": 3} + ) await dev.update() @@ -35,7 +38,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): @fan async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): """Test sleep mode feature.""" - fan = cast(FanModule, dev.modules.get("FanModule")) + fan = cast(FanModule, dev.get_module("FanModule")) assert fan sleep_feature = fan._module_features["fan_sleep_mode"] assert isinstance(sleep_feature.value, bool) @@ -48,3 +51,31 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): assert fan.sleep_mode is True assert sleep_feature.value is True + + +@fan +async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): + """Test fan speed on device interface.""" + assert isinstance(dev, SmartDevice) + fan = cast(FanModule, dev.get_module("FanModule")) + device = fan._device + assert device.is_fan + + await device.set_fan_speed_level(1) + await dev.update() + assert device.fan_speed_level == 1 + assert device.is_on + + await device.set_fan_speed_level(4) + await dev.update() + assert device.fan_speed_level == 4 + + await device.set_fan_speed_level(0) + await dev.update() + assert not device.is_on + + with pytest.raises(ValueError): + await device.set_fan_speed_level(-1) + + with pytest.raises(ValueError): + await device.set_fan_speed_level(5) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 2dc27ac46..476a37ae5 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -16,6 +16,7 @@ from .conftest import ( bulb_smart, device_smart, + get_device_for_fixture_protocol, ) @@ -121,6 +122,24 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): spies[device].assert_not_called() +async def test_get_modules(mocker): + """Test get_modules for child and parent modules.""" + dummy_device = await get_device_for_fixture_protocol( + "KS240(US)_1.0_1.0.5.json", "SMART" + ) + module = dummy_device.get_module("CloudModule") + assert module + assert module._device == dummy_device + + module = dummy_device.get_module("FanModule") + assert module + assert module._device != dummy_device + assert module._device._parent == dummy_device + + module = dummy_device.get_module("DummyModule") + assert module is None + + @bulb_smart async def test_smartdevice_brightness(dev: SmartDevice): """Test brightness setter and getter.""" From 46338ee21dc9cd9fd931e1419045731e87cd4847 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 1 May 2024 15:59:35 +0200 Subject: [PATCH 408/892] Use pydantic.v1 namespace on all pydantic versions (#883) With https://github.com/pydantic/pydantic/pull/9042 being shipped with [1.10.15](https://docs.pydantic.dev/latest/changelog/#v11015-2024-04-03), we can clean up the imports a bit until we make decisions how to move onward with or without pydantic. --------- Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --- kasa/bulb.py | 5 +- kasa/cli.py | 6 +- kasa/discover.py | 6 +- kasa/iot/iotbulb.py | 5 +- kasa/iot/modules/cloud.py | 5 +- kasa/iot/modules/rulemodule.py | 6 +- kasa/smart/modules/firmware.py | 9 +- poetry.lock | 493 ++++++++++++++++----------------- pyproject.toml | 2 +- 9 files changed, 252 insertions(+), 285 deletions(-) diff --git a/kasa/bulb.py b/kasa/bulb.py index 890449ca9..fd3aab666 100644 --- a/kasa/bulb.py +++ b/kasa/bulb.py @@ -5,10 +5,7 @@ from abc import ABC, abstractmethod from typing import NamedTuple, Optional -try: - from pydantic.v1 import BaseModel -except ImportError: - from pydantic import BaseModel +from pydantic.v1 import BaseModel class ColorTempRange(NamedTuple): diff --git a/kasa/cli.py b/kasa/cli.py index b55fecebf..0ef3eccb7 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -14,6 +14,7 @@ from typing import Any, cast import asyncclick as click +from pydantic.v1 import ValidationError from kasa import ( AuthenticationError, @@ -42,11 +43,6 @@ from kasa.iot.modules import Usage from kasa.smart import SmartDevice -try: - from pydantic.v1 import ValidationError -except ImportError: - from pydantic import ValidationError - try: from rich import print as _do_echo except ImportError: diff --git a/kasa/discover.py b/kasa/discover.py index d727b2f86..833ffb415 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -12,11 +12,7 @@ # 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 - -try: - from pydantic.v1 import BaseModel, ValidationError # pragma: no cover -except ImportError: - from pydantic import BaseModel, ValidationError # pragma: no cover +from pydantic.v1 import BaseModel, ValidationError from kasa import Device from kasa.credentials import Credentials diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 4d6e49d2a..50c31f621 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -7,10 +7,7 @@ from enum import Enum from typing import Optional, cast -try: - from pydantic.v1 import BaseModel, Field, root_validator -except ImportError: - from pydantic import BaseModel, Field, root_validator +from pydantic.v1 import BaseModel, Field, root_validator from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..device_type import DeviceType diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index 5e5521169..41d2cbf54 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -1,9 +1,6 @@ """Cloud module implementation.""" -try: - from pydantic.v1 import BaseModel -except ImportError: - from pydantic import BaseModel +from pydantic.v1 import BaseModel from ...feature import Feature from ..iotmodule import IotModule diff --git a/kasa/iot/modules/rulemodule.py b/kasa/iot/modules/rulemodule.py index 1feaf456b..6e3a2b226 100644 --- a/kasa/iot/modules/rulemodule.py +++ b/kasa/iot/modules/rulemodule.py @@ -6,11 +6,7 @@ from enum import Enum from typing import Dict, List, Optional -try: - from pydantic.v1 import BaseModel -except ImportError: - from pydantic import BaseModel - +from pydantic.v1 import BaseModel from ..iotmodule import IotModule, merge diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index c55400440..5f0c8bb03 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -2,18 +2,15 @@ from __future__ import annotations +from datetime import date from typing import TYPE_CHECKING, Any, Optional +from pydantic.v1 import BaseModel, Field, validator + from ...exceptions import SmartErrorCode from ...feature import Feature from ..smartmodule import SmartModule -try: - from pydantic.v1 import BaseModel, Field, validator -except ImportError: - from pydantic import BaseModel, Field, validator -from datetime import date - if TYPE_CHECKING: from ..smartdevice import SmartDevice diff --git a/poetry.lock b/poetry.lock index f307a4689..6bd770b5e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,88 +1,88 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "aiohttp" -version = "3.9.4" +version = "3.9.5" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" files = [ - {file = "aiohttp-3.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:76d32588ef7e4a3f3adff1956a0ba96faabbdee58f2407c122dd45aa6e34f372"}, - {file = "aiohttp-3.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:56181093c10dbc6ceb8a29dfeea1e815e1dfdc020169203d87fd8d37616f73f9"}, - {file = "aiohttp-3.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7a5b676d3c65e88b3aca41816bf72831898fcd73f0cbb2680e9d88e819d1e4d"}, - {file = "aiohttp-3.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1df528a85fb404899d4207a8d9934cfd6be626e30e5d3a5544a83dbae6d8a7e"}, - {file = "aiohttp-3.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f595db1bceabd71c82e92df212dd9525a8a2c6947d39e3c994c4f27d2fe15b11"}, - {file = "aiohttp-3.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c0b09d76e5a4caac3d27752027fbd43dc987b95f3748fad2b924a03fe8632ad"}, - {file = "aiohttp-3.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:689eb4356649ec9535b3686200b231876fb4cab4aca54e3bece71d37f50c1d13"}, - {file = "aiohttp-3.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3666cf4182efdb44d73602379a66f5fdfd5da0db5e4520f0ac0dcca644a3497"}, - {file = "aiohttp-3.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b65b0f8747b013570eea2f75726046fa54fa8e0c5db60f3b98dd5d161052004a"}, - {file = "aiohttp-3.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1885d2470955f70dfdd33a02e1749613c5a9c5ab855f6db38e0b9389453dce7"}, - {file = "aiohttp-3.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0593822dcdb9483d41f12041ff7c90d4d1033ec0e880bcfaf102919b715f47f1"}, - {file = "aiohttp-3.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:47f6eb74e1ecb5e19a78f4a4228aa24df7fbab3b62d4a625d3f41194a08bd54f"}, - {file = "aiohttp-3.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c8b04a3dbd54de6ccb7604242fe3ad67f2f3ca558f2d33fe19d4b08d90701a89"}, - {file = "aiohttp-3.9.4-cp310-cp310-win32.whl", hash = "sha256:8a78dfb198a328bfb38e4308ca8167028920fb747ddcf086ce706fbdd23b2926"}, - {file = "aiohttp-3.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:e78da6b55275987cbc89141a1d8e75f5070e577c482dd48bd9123a76a96f0bbb"}, - {file = "aiohttp-3.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c111b3c69060d2bafc446917534150fd049e7aedd6cbf21ba526a5a97b4402a5"}, - {file = "aiohttp-3.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:efbdd51872cf170093998c87ccdf3cb5993add3559341a8e5708bcb311934c94"}, - {file = "aiohttp-3.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7bfdb41dc6e85d8535b00d73947548a748e9534e8e4fddd2638109ff3fb081df"}, - {file = "aiohttp-3.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd9d334412961125e9f68d5b73c1d0ab9ea3f74a58a475e6b119f5293eee7ba"}, - {file = "aiohttp-3.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35d78076736f4a668d57ade00c65d30a8ce28719d8a42471b2a06ccd1a2e3063"}, - {file = "aiohttp-3.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:824dff4f9f4d0f59d0fa3577932ee9a20e09edec8a2f813e1d6b9f89ced8293f"}, - {file = "aiohttp-3.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52b8b4e06fc15519019e128abedaeb56412b106ab88b3c452188ca47a25c4093"}, - {file = "aiohttp-3.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eae569fb1e7559d4f3919965617bb39f9e753967fae55ce13454bec2d1c54f09"}, - {file = "aiohttp-3.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:69b97aa5792428f321f72aeb2f118e56893371f27e0b7d05750bcad06fc42ca1"}, - {file = "aiohttp-3.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4d79aad0ad4b980663316f26d9a492e8fab2af77c69c0f33780a56843ad2f89e"}, - {file = "aiohttp-3.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:d6577140cd7db19e430661e4b2653680194ea8c22c994bc65b7a19d8ec834403"}, - {file = "aiohttp-3.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:9860d455847cd98eb67897f5957b7cd69fbcb436dd3f06099230f16a66e66f79"}, - {file = "aiohttp-3.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69ff36d3f8f5652994e08bd22f093e11cfd0444cea310f92e01b45a4e46b624e"}, - {file = "aiohttp-3.9.4-cp311-cp311-win32.whl", hash = "sha256:e27d3b5ed2c2013bce66ad67ee57cbf614288bda8cdf426c8d8fe548316f1b5f"}, - {file = "aiohttp-3.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d6a67e26daa686a6fbdb600a9af8619c80a332556245fa8e86c747d226ab1a1e"}, - {file = "aiohttp-3.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c5ff8ff44825736a4065d8544b43b43ee4c6dd1530f3a08e6c0578a813b0aa35"}, - {file = "aiohttp-3.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d12a244627eba4e9dc52cbf924edef905ddd6cafc6513849b4876076a6f38b0e"}, - {file = "aiohttp-3.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dcad56c8d8348e7e468899d2fb3b309b9bc59d94e6db08710555f7436156097f"}, - {file = "aiohttp-3.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f7e69a7fd4b5ce419238388e55abd220336bd32212c673ceabc57ccf3d05b55"}, - {file = "aiohttp-3.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4870cb049f10d7680c239b55428916d84158798eb8f353e74fa2c98980dcc0b"}, - {file = "aiohttp-3.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2feaf1b7031ede1bc0880cec4b0776fd347259a723d625357bb4b82f62687b"}, - {file = "aiohttp-3.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:939393e8c3f0a5bcd33ef7ace67680c318dc2ae406f15e381c0054dd658397de"}, - {file = "aiohttp-3.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d2334e387b2adcc944680bebcf412743f2caf4eeebd550f67249c1c3696be04"}, - {file = "aiohttp-3.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e0198ea897680e480845ec0ffc5a14e8b694e25b3f104f63676d55bf76a82f1a"}, - {file = "aiohttp-3.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e40d2cd22914d67c84824045861a5bb0fb46586b15dfe4f046c7495bf08306b2"}, - {file = "aiohttp-3.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:aba80e77c227f4234aa34a5ff2b6ff30c5d6a827a91d22ff6b999de9175d71bd"}, - {file = "aiohttp-3.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:fb68dc73bc8ac322d2e392a59a9e396c4f35cb6fdbdd749e139d1d6c985f2527"}, - {file = "aiohttp-3.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f3460a92638dce7e47062cf088d6e7663adb135e936cb117be88d5e6c48c9d53"}, - {file = "aiohttp-3.9.4-cp312-cp312-win32.whl", hash = "sha256:32dc814ddbb254f6170bca198fe307920f6c1308a5492f049f7f63554b88ef36"}, - {file = "aiohttp-3.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:63f41a909d182d2b78fe3abef557fcc14da50c7852f70ae3be60e83ff64edba5"}, - {file = "aiohttp-3.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c3770365675f6be220032f6609a8fbad994d6dcf3ef7dbcf295c7ee70884c9af"}, - {file = "aiohttp-3.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:305edae1dea368ce09bcb858cf5a63a064f3bff4767dec6fa60a0cc0e805a1d3"}, - {file = "aiohttp-3.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6f121900131d116e4a93b55ab0d12ad72573f967b100e49086e496a9b24523ea"}, - {file = "aiohttp-3.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b71e614c1ae35c3d62a293b19eface83d5e4d194e3eb2fabb10059d33e6e8cbf"}, - {file = "aiohttp-3.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:419f009fa4cfde4d16a7fc070d64f36d70a8d35a90d71aa27670bba2be4fd039"}, - {file = "aiohttp-3.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b39476ee69cfe64061fd77a73bf692c40021f8547cda617a3466530ef63f947"}, - {file = "aiohttp-3.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b33f34c9c7decdb2ab99c74be6443942b730b56d9c5ee48fb7df2c86492f293c"}, - {file = "aiohttp-3.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c78700130ce2dcebb1a8103202ae795be2fa8c9351d0dd22338fe3dac74847d9"}, - {file = "aiohttp-3.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:268ba22d917655d1259af2d5659072b7dc11b4e1dc2cb9662fdd867d75afc6a4"}, - {file = "aiohttp-3.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:17e7c051f53a0d2ebf33013a9cbf020bb4e098c4bc5bce6f7b0c962108d97eab"}, - {file = "aiohttp-3.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:7be99f4abb008cb38e144f85f515598f4c2c8932bf11b65add0ff59c9c876d99"}, - {file = "aiohttp-3.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:d58a54d6ff08d2547656356eea8572b224e6f9bbc0cf55fa9966bcaac4ddfb10"}, - {file = "aiohttp-3.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7673a76772bda15d0d10d1aa881b7911d0580c980dbd16e59d7ba1422b2d83cd"}, - {file = "aiohttp-3.9.4-cp38-cp38-win32.whl", hash = "sha256:e4370dda04dc8951012f30e1ce7956a0a226ac0714a7b6c389fb2f43f22a250e"}, - {file = "aiohttp-3.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:eb30c4510a691bb87081192a394fb661860e75ca3896c01c6d186febe7c88530"}, - {file = "aiohttp-3.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:84e90494db7df3be5e056f91412f9fa9e611fbe8ce4aaef70647297f5943b276"}, - {file = "aiohttp-3.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d4845f8501ab28ebfdbeab980a50a273b415cf69e96e4e674d43d86a464df9d"}, - {file = "aiohttp-3.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:69046cd9a2a17245c4ce3c1f1a4ff8c70c7701ef222fce3d1d8435f09042bba1"}, - {file = "aiohttp-3.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b73a06bafc8dcc508420db43b4dd5850e41e69de99009d0351c4f3007960019"}, - {file = "aiohttp-3.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:418bb0038dfafeac923823c2e63226179976c76f981a2aaad0ad5d51f2229bca"}, - {file = "aiohttp-3.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71a8f241456b6c2668374d5d28398f8e8cdae4cce568aaea54e0f39359cd928d"}, - {file = "aiohttp-3.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:935c369bf8acc2dc26f6eeb5222768aa7c62917c3554f7215f2ead7386b33748"}, - {file = "aiohttp-3.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74e4e48c8752d14ecfb36d2ebb3d76d614320570e14de0a3aa7a726ff150a03c"}, - {file = "aiohttp-3.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:916b0417aeddf2c8c61291238ce25286f391a6acb6f28005dd9ce282bd6311b6"}, - {file = "aiohttp-3.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9b6787b6d0b3518b2ee4cbeadd24a507756ee703adbac1ab6dc7c4434b8c572a"}, - {file = "aiohttp-3.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:221204dbda5ef350e8db6287937621cf75e85778b296c9c52260b522231940ed"}, - {file = "aiohttp-3.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:10afd99b8251022ddf81eaed1d90f5a988e349ee7d779eb429fb07b670751e8c"}, - {file = "aiohttp-3.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2506d9f7a9b91033201be9ffe7d89c6a54150b0578803cce5cb84a943d075bc3"}, - {file = "aiohttp-3.9.4-cp39-cp39-win32.whl", hash = "sha256:e571fdd9efd65e86c6af2f332e0e95dad259bfe6beb5d15b3c3eca3a6eb5d87b"}, - {file = "aiohttp-3.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:7d29dd5319d20aa3b7749719ac9685fbd926f71ac8c77b2477272725f882072d"}, - {file = "aiohttp-3.9.4.tar.gz", hash = "sha256:6ff71ede6d9a5a58cfb7b6fffc83ab5d4a63138276c771ac91ceaaddf5459644"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae79c1bc12c34082d92bf9422764f799aee4746fd7a392db46b7fd357d4a17a"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d3ebb9e1316ec74277d19c5f482f98cc65a73ccd5430540d6d11682cd857430"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84dabd95154f43a2ea80deffec9cb44d2e301e38a0c9d331cc4aa0166fe28ae3"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a02fbeca6f63cb1f0475c799679057fc9268b77075ab7cf3f1c600e81dd46b"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:714d4e5231fed4ba2762ed489b4aec07b2b9953cf4ee31e9871caac895a839c0"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7a6a8354f1b62e15d48e04350f13e726fa08b62c3d7b8401c0a1314f02e3558"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c413016880e03e69d166efb5a1a95d40f83d5a3a648d16486592c49ffb76d0db"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ad7f2919d7dac062f24d6f5fe95d401597fbb015a25771f85e692d043c9d7832"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:702e2c7c187c1a498a4e2b03155d52658fdd6fda882d3d7fbb891a5cf108bb10"}, + {file = "aiohttp-3.9.5-cp310-cp310-win32.whl", hash = "sha256:67c3119f5ddc7261d47163ed86d760ddf0e625cd6246b4ed852e82159617b5fb"}, + {file = "aiohttp-3.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:471f0ef53ccedec9995287f02caf0c068732f026455f07db3f01a46e49d76bbb"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ae53e33ee7476dd3d1132f932eeb39bf6125083820049d06edcdca4381f342"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c088c4d70d21f8ca5c0b8b5403fe84a7bc8e024161febdd4ef04575ef35d474d"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:639d0042b7670222f33b0028de6b4e2fad6451462ce7df2af8aee37dcac55424"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26383adb94da5e7fb388d441bf09c61e5e35f455a3217bfd790c6b6bc64b2ee"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66331d00fb28dc90aa606d9a54304af76b335ae204d1836f65797d6fe27f1ca2"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff550491f5492ab5ed3533e76b8567f4b37bd2995e780a1f46bca2024223233"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f22eb3a6c1080d862befa0a89c380b4dafce29dc6cd56083f630073d102eb595"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a81b1143d42b66ffc40a441379387076243ef7b51019204fd3ec36b9f69e77d6"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f64fd07515dad67f24b6ea4a66ae2876c01031de91c93075b8093f07c0a2d93d"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:93e22add827447d2e26d67c9ac0161756007f152fdc5210277d00a85f6c92323"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:55b39c8684a46e56ef8c8d24faf02de4a2b2ac60d26cee93bc595651ff545de9"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4715a9b778f4293b9f8ae7a0a7cef9829f02ff8d6277a39d7f40565c737d3771"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:afc52b8d969eff14e069a710057d15ab9ac17cd4b6753042c407dcea0e40bf75"}, + {file = "aiohttp-3.9.5-cp311-cp311-win32.whl", hash = "sha256:b3df71da99c98534be076196791adca8819761f0bf6e08e07fd7da25127150d6"}, + {file = "aiohttp-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:88e311d98cc0bf45b62fc46c66753a83445f5ab20038bcc1b8a1cc05666f428a"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c7a4b7a6cf5b6eb11e109a9755fd4fda7d57395f8c575e166d363b9fc3ec4678"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0a158704edf0abcac8ac371fbb54044f3270bdbc93e254a82b6c82be1ef08f3c"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d153f652a687a8e95ad367a86a61e8d53d528b0530ef382ec5aaf533140ed00f"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82a6a97d9771cb48ae16979c3a3a9a18b600a8505b1115cfe354dfb2054468b4"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60cdbd56f4cad9f69c35eaac0fbbdf1f77b0ff9456cebd4902f3dd1cf096464c"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8676e8fd73141ded15ea586de0b7cda1542960a7b9ad89b2b06428e97125d4fa"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da00da442a0e31f1c69d26d224e1efd3a1ca5bcbf210978a2ca7426dfcae9f58"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f634d540dd099c262e9f887c8bbacc959847cfe5da7a0e2e1cf3f14dbf2daf"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:320e8618eda64e19d11bdb3bd04ccc0a816c17eaecb7e4945d01deee2a22f95f"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2faa61a904b83142747fc6a6d7ad8fccff898c849123030f8e75d5d967fd4a81"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:8c64a6dc3fe5db7b1b4d2b5cb84c4f677768bdc340611eca673afb7cf416ef5a"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:393c7aba2b55559ef7ab791c94b44f7482a07bf7640d17b341b79081f5e5cd1a"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c671dc117c2c21a1ca10c116cfcd6e3e44da7fcde37bf83b2be485ab377b25da"}, + {file = "aiohttp-3.9.5-cp312-cp312-win32.whl", hash = "sha256:5a7ee16aab26e76add4afc45e8f8206c95d1d75540f1039b84a03c3b3800dd59"}, + {file = "aiohttp-3.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:5ca51eadbd67045396bc92a4345d1790b7301c14d1848feaac1d6a6c9289e888"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:694d828b5c41255e54bc2dddb51a9f5150b4eefa9886e38b52605a05d96566e8"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0605cc2c0088fcaae79f01c913a38611ad09ba68ff482402d3410bf59039bfb8"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4558e5012ee03d2638c681e156461d37b7a113fe13970d438d95d10173d25f78"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbc053ac75ccc63dc3a3cc547b98c7258ec35a215a92bd9f983e0aac95d3d5b"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4109adee842b90671f1b689901b948f347325045c15f46b39797ae1bf17019de"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6ea1a5b409a85477fd8e5ee6ad8f0e40bf2844c270955e09360418cfd09abac"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3c2890ca8c59ee683fd09adf32321a40fe1cf164e3387799efb2acebf090c11"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3916c8692dbd9d55c523374a3b8213e628424d19116ac4308e434dbf6d95bbdd"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8d1964eb7617907c792ca00b341b5ec3e01ae8c280825deadbbd678447b127e1"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5ab8e1f6bee051a4bf6195e38a5c13e5e161cb7bad83d8854524798bd9fcd6e"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52c27110f3862a1afbcb2af4281fc9fdc40327fa286c4625dfee247c3ba90156"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7f64cbd44443e80094309875d4f9c71d0401e966d191c3d469cde4642bc2e031"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b4f72fbb66279624bfe83fd5eb6aea0022dad8eec62b71e7bf63ee1caadeafe"}, + {file = "aiohttp-3.9.5-cp38-cp38-win32.whl", hash = "sha256:6380c039ec52866c06d69b5c7aad5478b24ed11696f0e72f6b807cfb261453da"}, + {file = "aiohttp-3.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:da22dab31d7180f8c3ac7c7635f3bcd53808f374f6aa333fe0b0b9e14b01f91a"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1732102949ff6087589408d76cd6dea656b93c896b011ecafff418c9661dc4ed"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6021d296318cb6f9414b48e6a439a7f5d1f665464da507e8ff640848ee2a58a"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:239f975589a944eeb1bad26b8b140a59a3a320067fb3cd10b75c3092405a1372"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b7b30258348082826d274504fbc7c849959f1989d86c29bc355107accec6cfb"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2adf5c87ff6d8b277814a28a535b59e20bfea40a101db6b3bdca7e9926bc24"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a3d838441bebcf5cf442700e3963f58b5c33f015341f9ea86dcd7d503c07e2"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3a1ae66e3d0c17cf65c08968a5ee3180c5a95920ec2731f53343fac9bad106"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c69e77370cce2d6df5d12b4e12bdcca60c47ba13d1cbbc8645dd005a20b738b"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf56238f4bbf49dab8c2dc2e6b1b68502b1e88d335bea59b3f5b9f4c001475"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d1469f228cd9ffddd396d9948b8c9cd8022b6d1bf1e40c6f25b0fb90b4f893ed"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:45731330e754f5811c314901cebdf19dd776a44b31927fa4b4dbecab9e457b0c"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3fcb4046d2904378e3aeea1df51f697b0467f2aac55d232c87ba162709478c46"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8cf142aa6c1a751fcb364158fd710b8a9be874b81889c2bd13aa8893197455e2"}, + {file = "aiohttp-3.9.5-cp39-cp39-win32.whl", hash = "sha256:7b179eea70833c8dee51ec42f3b4097bd6370892fa93f510f76762105568cf09"}, + {file = "aiohttp-3.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:38d80498e2e169bc61418ff36170e0aad0cd268da8b38a17c4cf29d254a8b3f1"}, + {file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"}, ] [package.dependencies] @@ -465,63 +465,63 @@ files = [ [[package]] name = "coverage" -version = "7.4.4" +version = "7.5.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2"}, - {file = "coverage-7.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf"}, - {file = "coverage-7.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8"}, - {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562"}, - {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2"}, - {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7"}, - {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87"}, - {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c"}, - {file = "coverage-7.4.4-cp310-cp310-win32.whl", hash = "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d"}, - {file = "coverage-7.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f"}, - {file = "coverage-7.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf"}, - {file = "coverage-7.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083"}, - {file = "coverage-7.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63"}, - {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f"}, - {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227"}, - {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd"}, - {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384"}, - {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b"}, - {file = "coverage-7.4.4-cp311-cp311-win32.whl", hash = "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286"}, - {file = "coverage-7.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec"}, - {file = "coverage-7.4.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76"}, - {file = "coverage-7.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818"}, - {file = "coverage-7.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978"}, - {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70"}, - {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51"}, - {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c"}, - {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48"}, - {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9"}, - {file = "coverage-7.4.4-cp312-cp312-win32.whl", hash = "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0"}, - {file = "coverage-7.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e"}, - {file = "coverage-7.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384"}, - {file = "coverage-7.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1"}, - {file = "coverage-7.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a"}, - {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409"}, - {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e"}, - {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd"}, - {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7"}, - {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c"}, - {file = "coverage-7.4.4-cp38-cp38-win32.whl", hash = "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e"}, - {file = "coverage-7.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8"}, - {file = "coverage-7.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d"}, - {file = "coverage-7.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357"}, - {file = "coverage-7.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e"}, - {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e"}, - {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4"}, - {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec"}, - {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd"}, - {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade"}, - {file = "coverage-7.4.4-cp39-cp39-win32.whl", hash = "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57"}, - {file = "coverage-7.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c"}, - {file = "coverage-7.4.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677"}, - {file = "coverage-7.4.4.tar.gz", hash = "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49"}, + {file = "coverage-7.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:432949a32c3e3f820af808db1833d6d1631664d53dd3ce487aa25d574e18ad1c"}, + {file = "coverage-7.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bd7065249703cbeb6d4ce679c734bef0ee69baa7bff9724361ada04a15b7e3b"}, + {file = "coverage-7.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbfe6389c5522b99768a93d89aca52ef92310a96b99782973b9d11e80511f932"}, + {file = "coverage-7.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39793731182c4be939b4be0cdecde074b833f6171313cf53481f869937129ed3"}, + {file = "coverage-7.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a5dbe1ba1bf38d6c63b6d2c42132d45cbee6d9f0c51b52c59aa4afba057517"}, + {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:357754dcdfd811462a725e7501a9b4556388e8ecf66e79df6f4b988fa3d0b39a"}, + {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a81eb64feded34f40c8986869a2f764f0fe2db58c0530d3a4afbcde50f314880"}, + {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:51431d0abbed3a868e967f8257c5faf283d41ec882f58413cf295a389bb22e58"}, + {file = "coverage-7.5.0-cp310-cp310-win32.whl", hash = "sha256:f609ebcb0242d84b7adeee2b06c11a2ddaec5464d21888b2c8255f5fd6a98ae4"}, + {file = "coverage-7.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:6782cd6216fab5a83216cc39f13ebe30adfac2fa72688c5a4d8d180cd52e8f6a"}, + {file = "coverage-7.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e768d870801f68c74c2b669fc909839660180c366501d4cc4b87efd6b0eee375"}, + {file = "coverage-7.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:84921b10aeb2dd453247fd10de22907984eaf80901b578a5cf0bb1e279a587cb"}, + {file = "coverage-7.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:710c62b6e35a9a766b99b15cdc56d5aeda0914edae8bb467e9c355f75d14ee95"}, + {file = "coverage-7.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c379cdd3efc0658e652a14112d51a7668f6bfca7445c5a10dee7eabecabba19d"}, + {file = "coverage-7.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea9d3ca80bcf17edb2c08a4704259dadac196fe5e9274067e7a20511fad1743"}, + {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:41327143c5b1d715f5f98a397608f90ab9ebba606ae4e6f3389c2145410c52b1"}, + {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:565b2e82d0968c977e0b0f7cbf25fd06d78d4856289abc79694c8edcce6eb2de"}, + {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cf3539007202ebfe03923128fedfdd245db5860a36810136ad95a564a2fdffff"}, + {file = "coverage-7.5.0-cp311-cp311-win32.whl", hash = "sha256:bf0b4b8d9caa8d64df838e0f8dcf68fb570c5733b726d1494b87f3da85db3a2d"}, + {file = "coverage-7.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c6384cc90e37cfb60435bbbe0488444e54b98700f727f16f64d8bfda0b84656"}, + {file = "coverage-7.5.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fed7a72d54bd52f4aeb6c6e951f363903bd7d70bc1cad64dd1f087980d309ab9"}, + {file = "coverage-7.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cbe6581fcff7c8e262eb574244f81f5faaea539e712a058e6707a9d272fe5b64"}, + {file = "coverage-7.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad97ec0da94b378e593ef532b980c15e377df9b9608c7c6da3506953182398af"}, + {file = "coverage-7.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd4bacd62aa2f1a1627352fe68885d6ee694bdaebb16038b6e680f2924a9b2cc"}, + {file = "coverage-7.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adf032b6c105881f9d77fa17d9eebe0ad1f9bfb2ad25777811f97c5362aa07f2"}, + {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ba01d9ba112b55bfa4b24808ec431197bb34f09f66f7cb4fd0258ff9d3711b1"}, + {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f0bfe42523893c188e9616d853c47685e1c575fe25f737adf473d0405dcfa7eb"}, + {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a9a7ef30a1b02547c1b23fa9a5564f03c9982fc71eb2ecb7f98c96d7a0db5cf2"}, + {file = "coverage-7.5.0-cp312-cp312-win32.whl", hash = "sha256:3c2b77f295edb9fcdb6a250f83e6481c679335ca7e6e4a955e4290350f2d22a4"}, + {file = "coverage-7.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:427e1e627b0963ac02d7c8730ca6d935df10280d230508c0ba059505e9233475"}, + {file = "coverage-7.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9dd88fce54abbdbf4c42fb1fea0e498973d07816f24c0e27a1ecaf91883ce69e"}, + {file = "coverage-7.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a898c11dca8f8c97b467138004a30133974aacd572818c383596f8d5b2eb04a9"}, + {file = "coverage-7.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07dfdd492d645eea1bd70fb1d6febdcf47db178b0d99161d8e4eed18e7f62fe7"}, + {file = "coverage-7.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3d117890b6eee85887b1eed41eefe2e598ad6e40523d9f94c4c4b213258e4a4"}, + {file = "coverage-7.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6afd2e84e7da40fe23ca588379f815fb6dbbb1b757c883935ed11647205111cb"}, + {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a9960dd1891b2ddf13a7fe45339cd59ecee3abb6b8326d8b932d0c5da208104f"}, + {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ced268e82af993d7801a9db2dbc1d2322e786c5dc76295d8e89473d46c6b84d4"}, + {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e7c211f25777746d468d76f11719e64acb40eed410d81c26cefac641975beb88"}, + {file = "coverage-7.5.0-cp38-cp38-win32.whl", hash = "sha256:262fffc1f6c1a26125d5d573e1ec379285a3723363f3bd9c83923c9593a2ac25"}, + {file = "coverage-7.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:eed462b4541c540d63ab57b3fc69e7d8c84d5957668854ee4e408b50e92ce26a"}, + {file = "coverage-7.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d0194d654e360b3e6cc9b774e83235bae6b9b2cac3be09040880bb0e8a88f4a1"}, + {file = "coverage-7.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:33c020d3322662e74bc507fb11488773a96894aa82a622c35a5a28673c0c26f5"}, + {file = "coverage-7.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbdf2cae14a06827bec50bd58e49249452d211d9caddd8bd80e35b53cb04631"}, + {file = "coverage-7.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3235d7c781232e525b0761730e052388a01548bd7f67d0067a253887c6e8df46"}, + {file = "coverage-7.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2de4e546f0ec4b2787d625e0b16b78e99c3e21bc1722b4977c0dddf11ca84e"}, + {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4d0e206259b73af35c4ec1319fd04003776e11e859936658cb6ceffdeba0f5be"}, + {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2055c4fb9a6ff624253d432aa471a37202cd8f458c033d6d989be4499aed037b"}, + {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:075299460948cd12722a970c7eae43d25d37989da682997687b34ae6b87c0ef0"}, + {file = "coverage-7.5.0-cp39-cp39-win32.whl", hash = "sha256:280132aada3bc2f0fac939a5771db4fbb84f245cb35b94fae4994d4c1f80dae7"}, + {file = "coverage-7.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:c58536f6892559e030e6924896a44098bc1290663ea12532c78cef71d0df8493"}, + {file = "coverage-7.5.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:2b57780b51084d5223eee7b59f0d4911c31c16ee5aa12737c7a02455829ff067"}, + {file = "coverage-7.5.0.tar.gz", hash = "sha256:cf62d17310f34084c59c01e027259076479128d11e4661bb6c9acb38c5e19bb8"}, ] [package.dependencies] @@ -608,13 +608,13 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.2.0" +version = "1.2.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, - {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, + {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, + {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, ] [package.extras] @@ -724,13 +724,13 @@ files = [ [[package]] name = "identify" -version = "2.5.35" +version = "2.5.36" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, - {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, + {file = "identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa"}, + {file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"}, ] [package.extras] @@ -1210,28 +1210,29 @@ testing = ["docopt", "pytest"] [[package]] name = "platformdirs" -version = "4.2.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +version = "4.2.1" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, - {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, + {file = "platformdirs-4.2.1-py3-none-any.whl", hash = "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1"}, + {file = "platformdirs-4.2.1.tar.gz", hash = "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf"}, ] [package.extras] docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] [[package]] name = "pluggy" -version = "1.4.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ - {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, - {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] @@ -1304,18 +1305,18 @@ files = [ [[package]] name = "pydantic" -version = "2.7.0" +version = "2.7.1" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.7.0-py3-none-any.whl", hash = "sha256:9dee74a271705f14f9a1567671d144a851c675b072736f0a7b2608fd9e495352"}, - {file = "pydantic-2.7.0.tar.gz", hash = "sha256:b5ecdd42262ca2462e2624793551e80911a1e989f462910bb81aef974b4bb383"}, + {file = "pydantic-2.7.1-py3-none-any.whl", hash = "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5"}, + {file = "pydantic-2.7.1.tar.gz", hash = "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc"}, ] [package.dependencies] annotated-types = ">=0.4.0" -pydantic-core = "2.18.1" +pydantic-core = "2.18.2" typing-extensions = ">=4.6.1" [package.extras] @@ -1323,90 +1324,90 @@ email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-core" -version = "2.18.1" +version = "2.18.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.18.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ee9cf33e7fe14243f5ca6977658eb7d1042caaa66847daacbd2117adb258b226"}, - {file = "pydantic_core-2.18.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b7bbb97d82659ac8b37450c60ff2e9f97e4eb0f8a8a3645a5568b9334b08b50"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df4249b579e75094f7e9bb4bd28231acf55e308bf686b952f43100a5a0be394c"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d0491006a6ad20507aec2be72e7831a42efc93193d2402018007ff827dc62926"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ae80f72bb7a3e397ab37b53a2b49c62cc5496412e71bc4f1277620a7ce3f52b"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58aca931bef83217fca7a390e0486ae327c4af9c3e941adb75f8772f8eeb03a1"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1be91ad664fc9245404a789d60cba1e91c26b1454ba136d2a1bf0c2ac0c0505a"}, - {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:667880321e916a8920ef49f5d50e7983792cf59f3b6079f3c9dac2b88a311d17"}, - {file = "pydantic_core-2.18.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f7054fdc556f5421f01e39cbb767d5ec5c1139ea98c3e5b350e02e62201740c7"}, - {file = "pydantic_core-2.18.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:030e4f9516f9947f38179249778709a460a3adb516bf39b5eb9066fcfe43d0e6"}, - {file = "pydantic_core-2.18.1-cp310-none-win32.whl", hash = "sha256:2e91711e36e229978d92642bfc3546333a9127ecebb3f2761372e096395fc649"}, - {file = "pydantic_core-2.18.1-cp310-none-win_amd64.whl", hash = "sha256:9a29726f91c6cb390b3c2338f0df5cd3e216ad7a938762d11c994bb37552edb0"}, - {file = "pydantic_core-2.18.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9ece8a49696669d483d206b4474c367852c44815fca23ac4e48b72b339807f80"}, - {file = "pydantic_core-2.18.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a5d83efc109ceddb99abd2c1316298ced2adb4570410defe766851a804fcd5b"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7973c381283783cd1043a8c8f61ea5ce7a3a58b0369f0ee0ee975eaf2f2a1b"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54c7375c62190a7845091f521add19b0f026bcf6ae674bdb89f296972272e86d"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd63cec4e26e790b70544ae5cc48d11b515b09e05fdd5eff12e3195f54b8a586"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:561cf62c8a3498406495cfc49eee086ed2bb186d08bcc65812b75fda42c38294"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68717c38a68e37af87c4da20e08f3e27d7e4212e99e96c3d875fbf3f4812abfc"}, - {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d5728e93d28a3c63ee513d9ffbac9c5989de8c76e049dbcb5bfe4b923a9739d"}, - {file = "pydantic_core-2.18.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f0f17814c505f07806e22b28856c59ac80cee7dd0fbb152aed273e116378f519"}, - {file = "pydantic_core-2.18.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d816f44a51ba5175394bc6c7879ca0bd2be560b2c9e9f3411ef3a4cbe644c2e9"}, - {file = "pydantic_core-2.18.1-cp311-none-win32.whl", hash = "sha256:09f03dfc0ef8c22622eaa8608caa4a1e189cfb83ce847045eca34f690895eccb"}, - {file = "pydantic_core-2.18.1-cp311-none-win_amd64.whl", hash = "sha256:27f1009dc292f3b7ca77feb3571c537276b9aad5dd4efb471ac88a8bd09024e9"}, - {file = "pydantic_core-2.18.1-cp311-none-win_arm64.whl", hash = "sha256:48dd883db92e92519201f2b01cafa881e5f7125666141a49ffba8b9facc072b0"}, - {file = "pydantic_core-2.18.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b6b0e4912030c6f28bcb72b9ebe4989d6dc2eebcd2a9cdc35fefc38052dd4fe8"}, - {file = "pydantic_core-2.18.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3202a429fe825b699c57892d4371c74cc3456d8d71b7f35d6028c96dfecad31"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3982b0a32d0a88b3907e4b0dc36809fda477f0757c59a505d4e9b455f384b8b"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25595ac311f20e5324d1941909b0d12933f1fd2171075fcff763e90f43e92a0d"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14fe73881cf8e4cbdaded8ca0aa671635b597e42447fec7060d0868b52d074e6"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca976884ce34070799e4dfc6fbd68cb1d181db1eefe4a3a94798ddfb34b8867f"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684d840d2c9ec5de9cb397fcb3f36d5ebb6fa0d94734f9886032dd796c1ead06"}, - {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:54764c083bbe0264f0f746cefcded6cb08fbbaaf1ad1d78fb8a4c30cff999a90"}, - {file = "pydantic_core-2.18.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:201713f2f462e5c015b343e86e68bd8a530a4f76609b33d8f0ec65d2b921712a"}, - {file = "pydantic_core-2.18.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fd1a9edb9dd9d79fbeac1ea1f9a8dd527a6113b18d2e9bcc0d541d308dae639b"}, - {file = "pydantic_core-2.18.1-cp312-none-win32.whl", hash = "sha256:d5e6b7155b8197b329dc787356cfd2684c9d6a6b1a197f6bbf45f5555a98d411"}, - {file = "pydantic_core-2.18.1-cp312-none-win_amd64.whl", hash = "sha256:9376d83d686ec62e8b19c0ac3bf8d28d8a5981d0df290196fb6ef24d8a26f0d6"}, - {file = "pydantic_core-2.18.1-cp312-none-win_arm64.whl", hash = "sha256:c562b49c96906b4029b5685075fe1ebd3b5cc2601dfa0b9e16c2c09d6cbce048"}, - {file = "pydantic_core-2.18.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:3e352f0191d99fe617371096845070dee295444979efb8f27ad941227de6ad09"}, - {file = "pydantic_core-2.18.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c0295d52b012cbe0d3059b1dba99159c3be55e632aae1999ab74ae2bd86a33d7"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56823a92075780582d1ffd4489a2e61d56fd3ebb4b40b713d63f96dd92d28144"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd3f79e17b56741b5177bcc36307750d50ea0698df6aa82f69c7db32d968c1c2"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38a5024de321d672a132b1834a66eeb7931959c59964b777e8f32dbe9523f6b1"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2ce426ee691319d4767748c8e0895cfc56593d725594e415f274059bcf3cb76"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2adaeea59849ec0939af5c5d476935f2bab4b7f0335b0110f0f069a41024278e"}, - {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9b6431559676a1079eac0f52d6d0721fb8e3c5ba43c37bc537c8c83724031feb"}, - {file = "pydantic_core-2.18.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:85233abb44bc18d16e72dc05bf13848a36f363f83757541f1a97db2f8d58cfd9"}, - {file = "pydantic_core-2.18.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:641a018af4fe48be57a2b3d7a1f0f5dbca07c1d00951d3d7463f0ac9dac66622"}, - {file = "pydantic_core-2.18.1-cp38-none-win32.whl", hash = "sha256:63d7523cd95d2fde0d28dc42968ac731b5bb1e516cc56b93a50ab293f4daeaad"}, - {file = "pydantic_core-2.18.1-cp38-none-win_amd64.whl", hash = "sha256:907a4d7720abfcb1c81619863efd47c8a85d26a257a2dbebdb87c3b847df0278"}, - {file = "pydantic_core-2.18.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:aad17e462f42ddbef5984d70c40bfc4146c322a2da79715932cd8976317054de"}, - {file = "pydantic_core-2.18.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:94b9769ba435b598b547c762184bcfc4783d0d4c7771b04a3b45775c3589ca44"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80e0e57cc704a52fb1b48f16d5b2c8818da087dbee6f98d9bf19546930dc64b5"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76b86e24039c35280ceee6dce7e62945eb93a5175d43689ba98360ab31eebc4a"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a05db5013ec0ca4a32cc6433f53faa2a014ec364031408540ba858c2172bb0"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:250ae39445cb5475e483a36b1061af1bc233de3e9ad0f4f76a71b66231b07f88"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a32204489259786a923e02990249c65b0f17235073149d0033efcebe80095570"}, - {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6395a4435fa26519fd96fdccb77e9d00ddae9dd6c742309bd0b5610609ad7fb2"}, - {file = "pydantic_core-2.18.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2533ad2883f001efa72f3d0e733fb846710c3af6dcdd544fe5bf14fa5fe2d7db"}, - {file = "pydantic_core-2.18.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b560b72ed4816aee52783c66854d96157fd8175631f01ef58e894cc57c84f0f6"}, - {file = "pydantic_core-2.18.1-cp39-none-win32.whl", hash = "sha256:582cf2cead97c9e382a7f4d3b744cf0ef1a6e815e44d3aa81af3ad98762f5a9b"}, - {file = "pydantic_core-2.18.1-cp39-none-win_amd64.whl", hash = "sha256:ca71d501629d1fa50ea7fa3b08ba884fe10cefc559f5c6c8dfe9036c16e8ae89"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e178e5b66a06ec5bf51668ec0d4ac8cfb2bdcb553b2c207d58148340efd00143"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:72722ce529a76a4637a60be18bd789d8fb871e84472490ed7ddff62d5fed620d"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fe0c1ce5b129455e43f941f7a46f61f3d3861e571f2905d55cdbb8b5c6f5e2c"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4284c621f06a72ce2cb55f74ea3150113d926a6eb78ab38340c08f770eb9b4d"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a0c3e718f4e064efde68092d9d974e39572c14e56726ecfaeebbe6544521f47"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2027493cc44c23b598cfaf200936110433d9caa84e2c6cf487a83999638a96ac"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:76909849d1a6bffa5a07742294f3fa1d357dc917cb1fe7b470afbc3a7579d539"}, - {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ee7ccc7fb7e921d767f853b47814c3048c7de536663e82fbc37f5eb0d532224b"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ee2794111c188548a4547eccc73a6a8527fe2af6cf25e1a4ebda2fd01cdd2e60"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a139fe9f298dc097349fb4f28c8b81cc7a202dbfba66af0e14be5cfca4ef7ce5"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d074b07a10c391fc5bbdcb37b2f16f20fcd9e51e10d01652ab298c0d07908ee2"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c69567ddbac186e8c0aadc1f324a60a564cfe25e43ef2ce81bcc4b8c3abffbae"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf1c7b78cddb5af00971ad5294a4583188bda1495b13760d9f03c9483bb6203"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2684a94fdfd1b146ff10689c6e4e815f6a01141781c493b97342cdc5b06f4d5d"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:73c1bc8a86a5c9e8721a088df234265317692d0b5cd9e86e975ce3bc3db62a59"}, - {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e60defc3c15defb70bb38dd605ff7e0fae5f6c9c7cbfe0ad7868582cb7e844a6"}, - {file = "pydantic_core-2.18.1.tar.gz", hash = "sha256:de9d3e8717560eb05e28739d1b35e4eac2e458553a52a301e51352a7ffc86a35"}, + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81"}, + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38"}, + {file = "pydantic_core-2.18.2-cp310-none-win32.whl", hash = "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027"}, + {file = "pydantic_core-2.18.2-cp310-none-win_amd64.whl", hash = "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664"}, + {file = "pydantic_core-2.18.2-cp311-none-win32.whl", hash = "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e"}, + {file = "pydantic_core-2.18.2-cp311-none-win_amd64.whl", hash = "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3"}, + {file = "pydantic_core-2.18.2-cp311-none-win_arm64.whl", hash = "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3"}, + {file = "pydantic_core-2.18.2-cp312-none-win32.whl", hash = "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038"}, + {file = "pydantic_core-2.18.2-cp312-none-win_amd64.whl", hash = "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438"}, + {file = "pydantic_core-2.18.2-cp312-none-win_arm64.whl", hash = "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788"}, + {file = "pydantic_core-2.18.2-cp38-none-win32.whl", hash = "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350"}, + {file = "pydantic_core-2.18.2-cp38-none-win_amd64.whl", hash = "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b"}, + {file = "pydantic_core-2.18.2-cp39-none-win32.whl", hash = "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e"}, + {file = "pydantic_core-2.18.2-cp39-none-win_amd64.whl", hash = "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374"}, + {file = "pydantic_core-2.18.2.tar.gz", hash = "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e"}, ] [package.dependencies] @@ -1448,13 +1449,13 @@ testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytes [[package]] name = "pytest" -version = "8.1.1" +version = "8.2.0" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, - {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, + {file = "pytest-8.2.0-py3-none-any.whl", hash = "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233"}, + {file = "pytest-8.2.0.tar.gz", hash = "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f"}, ] [package.dependencies] @@ -1462,11 +1463,11 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.4,<2.0" +pluggy = ">=1.5,<2.0" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" @@ -1563,7 +1564,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1571,15 +1571,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1596,7 +1589,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1604,7 +1596,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -1897,13 +1888,13 @@ files = [ [[package]] name = "tox" -version = "4.14.2" +version = "4.15.0" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" files = [ - {file = "tox-4.14.2-py3-none-any.whl", hash = "sha256:2900c4eb7b716af4a928a7fdc2ed248ad6575294ed7cfae2ea41203937422847"}, - {file = "tox-4.14.2.tar.gz", hash = "sha256:0defb44f6dafd911b61788325741cc6b2e12ea71f987ac025ad4d649f1f1a104"}, + {file = "tox-4.15.0-py3-none-any.whl", hash = "sha256:300055f335d855b2ab1b12c5802de7f62a36d4fd53f30bd2835f6a201dda46ea"}, + {file = "tox-4.15.0.tar.gz", hash = "sha256:7a0beeef166fbe566f54f795b4906c31b428eddafc0102ac00d20998dd1933f6"}, ] [package.dependencies] @@ -1952,13 +1943,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.25.1" +version = "20.26.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, - {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, + {file = "virtualenv-20.26.0-py3-none-any.whl", hash = "sha256:0846377ea76e818daaa3e00a4365c018bc3ac9760cbb3544de542885aad61fb3"}, + {file = "virtualenv-20.26.0.tar.gz", hash = "sha256:ec25a9671a5102c8d2657f62792a27b48f016664c6873f6beed3800008577210"}, ] [package.dependencies] @@ -1967,7 +1958,7 @@ filelock = ">=3.12.2,<4" platformdirs = ">=3.9.1,<5" [package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [[package]] @@ -2141,4 +2132,4 @@ speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "fecc8870f967cc6da9d6e1fde0e9a9acd261d28c4ba57476250d17234dc2c876" +content-hash = "d627e4165dade7eaaf21708f00bc919bc3fffb3e8a805e186dfb56e5e1781bbe" diff --git a/pyproject.toml b/pyproject.toml index fa01911af..5b5f4d3e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ kasa = "kasa.cli:cli" python = "^3.8" anyio = "*" # see https://github.com/python-trio/asyncclick/issues/18 asyncclick = ">=8" -pydantic = ">=1" +pydantic = ">=1.10.15" cryptography = ">=1.9" async-timeout = ">=3.0.0" aiohttp = ">=3" From 3fc131dfd2d0d76807de07147c141e407c0cb7cf Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 1 May 2024 15:56:43 +0100 Subject: [PATCH 409/892] Fix wifi scan re-querying error (#891) --- kasa/smart/smartdevice.py | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 04c2607be..7ee5ab0f2 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -512,26 +512,13 @@ def _net_for_scan_info(res): bssid=res["bssid"], ) - async def _query_networks(networks=None, start_index=0): - _LOGGER.debug("Querying networks using start_index=%s", start_index) - if networks is None: - networks = [] + _LOGGER.debug("Querying networks") - resp = await self.protocol.query( - {"get_wireless_scan_info": {"start_index": start_index}} - ) - network_list = [ - _net_for_scan_info(net) - for net in resp["get_wireless_scan_info"]["ap_list"] - ] - networks.extend(network_list) - - if resp["get_wireless_scan_info"].get("sum", 0) > start_index + 10: - return await _query_networks(networks, start_index=start_index + 10) - - return networks - - return await _query_networks() + resp = await self.protocol.query({"get_wireless_scan_info": {"start_index": 0}}) + networks = [ + _net_for_scan_info(net) for net in resp["get_wireless_scan_info"]["ap_list"] + ] + return networks async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): """Join the given wifi network. From b2194a1c621555f126b6fcff334e9ba52ca89308 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 1 May 2024 16:05:37 +0100 Subject: [PATCH 410/892] Update ks240 fixture with child device query info (#890) The fixture now includes the queries returned directly from the child devices which is stored under child_devices along with valid device ids. Also fixes a bug in the test_cli.py::test_wifi_scan which fails with more than 9 networks. --- .../fixtures/smart/KS240(US)_1.0_1.0.4.json | 453 +++++++++++++++++- kasa/tests/test_cli.py | 2 +- 2 files changed, 436 insertions(+), 19 deletions(-) diff --git a/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json b/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json index 2831e5335..2775ee7c2 100644 --- a/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json +++ b/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json @@ -1,4 +1,310 @@ { + "child_devices": { + "SCRUBBED_CHILD_DEVICE_ID_1": { + "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": "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": "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 + } + ] + }, + "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": "switch_ks240", + "bind_count": 1, + "brightness": 44, + "category": "kasa.switch.outlet.sub-dimmer", + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fade_off_time": 5, + "fade_on_time": 5, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.4 Build 230721 Rel.184322", + "gradually_off_mode": 0, + "gradually_on_mode": 0, + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "lang": "en_US", + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "F0A731000000", + "max_fade_off_time": 60, + "max_fade_on_time": 60, + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 1, + "on_time": 67955, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "preset_state": [ + { + "brightness": 100 + }, + { + "brightness": 75 + }, + { + "brightness": 50 + }, + { + "brightness": 25 + }, + { + "brightness": 1 + } + ], + "region": "America/Chicago", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + "get_device_usage": { + "time_usage": { + "past30": 41994, + "past7": 8874, + "today": 236 + } + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 5, + "enable": false, + "max_duration": 60 + }, + "on_state": { + "duration": 5, + "enable": false, + "max_duration": 60 + } + }, + "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 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_2": { + "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": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "switch_ks240", + "bind_count": 1, + "category": "kasa.switch.outlet.sub-fan", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fan_sleep_mode_on": false, + "fan_speed_level": 4, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.4 Build 230721 Rel.184322", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "F0A731000000", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "region": "America/Chicago", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + "get_device_usage": { + "time_usage": { + "past30": 6786, + "past7": 6786, + "today": 236 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + } + }, "component_nego": { "component_list": [ { @@ -210,7 +516,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000001" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" }, { "component_list": [ @@ -271,7 +577,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000000" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" } ], "start_index": 0, @@ -283,10 +589,10 @@ "avatar": "switch_ks240", "bind_count": 1, "category": "kasa.switch.outlet.sub-fan", - "device_id": "000000000000000000000000000000000000000000", - "device_on": false, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, "fan_sleep_mode_on": false, - "fan_speed_level": 1, + "fan_speed_level": 4, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.4 Build 230721 Rel.184322", "has_set_location_info": true, @@ -309,7 +615,7 @@ { "avatar": "switch_ks240", "bind_count": 1, - "brightness": 100, + "brightness": 44, "category": "kasa.switch.outlet.sub-dimmer", "default_states": { "re_power_type": "always_off", @@ -320,14 +626,14 @@ ], "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000001", - "device_on": false, - "fade_off_time": 1, - "fade_on_time": 1, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fade_off_time": 5, + "fade_on_time": 5, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.4 Build 230721 Rel.184322", - "gradually_off_mode": 1, - "gradually_on_mode": 1, + "gradually_off_mode": 0, + "gradually_on_mode": 0, "has_set_location_info": true, "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", @@ -341,8 +647,8 @@ "model": "KS240", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", - "on_off": 0, - "on_time": 0, + "on_off": 1, + "on_time": 67951, "original_device_id": "0000000000000000000000000000000000000000", "overheat_status": "normal", "preset_state": [ @@ -391,7 +697,7 @@ "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "region": "America/Chicago", - "rssi": -39, + "rssi": -37, "signal_level": 3, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", @@ -401,7 +707,7 @@ "get_device_time": { "region": "America/Chicago", "time_diff": -360, - "timestamp": 1708643384 + "timestamp": 1714553757 }, "get_fw_download_state": { "auto_upgrade": false, @@ -410,6 +716,12 @@ "status": 0, "upgrade_time": 5 }, + "get_homekit_info": { + "mfi_setup_code": "000-00-000", + "mfi_setup_id": "0000", + "mfi_token_token": "0000000000000000000000000000000000/00000000000000000000000/00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000+00000000000000000=", + "mfi_token_uuid": "00000000-0000-0000-0000-000000000000" + }, "get_latest_fw": { "fw_size": 786432, "fw_ver": "1.0.5 Build 231204 Rel.172150", @@ -434,9 +746,114 @@ } }, "get_wireless_scan_info": { - "ap_list": [], + "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": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], "start_index": 0, - "sum": 0, + "sum": 13, "wep_supported": false }, "qs_component_nego": { diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index a803fdc26..3d80ee473 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -196,7 +196,7 @@ async def test_wifi_scan(dev, runner): res = await runner.invoke(wifi, ["scan"], obj=dev) assert res.exit_code == 0 - assert re.search(r"Found \d wifi networks!", res.output) + assert re.search(r"Found [\d]+ wifi networks!", res.output) @device_smart From 28d41092e5d43926e5cb89f9480b65aa8504c8cd Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 2 May 2024 13:55:08 +0100 Subject: [PATCH 411/892] Update interfaces so they all inherit from Device (#893) Brings consistency to the api across Smart and Iot so the interfaces can be used for their specialist methods as well as the device methods (e.g. turn_on/off). --- kasa/bulb.py | 4 +++- kasa/device.py | 5 +++++ kasa/fan.py | 9 +++------ kasa/smart/smartdevice.py | 4 +++- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/kasa/bulb.py b/kasa/bulb.py index fd3aab666..01065dc09 100644 --- a/kasa/bulb.py +++ b/kasa/bulb.py @@ -7,6 +7,8 @@ from pydantic.v1 import BaseModel +from .device import Device + class ColorTempRange(NamedTuple): """Color temperature range.""" @@ -40,7 +42,7 @@ class BulbPreset(BaseModel): mode: Optional[int] # noqa: UP007 -class Bulb(ABC): +class Bulb(Device, ABC): """Base class for TP-Link Bulb.""" def _raise_for_invalid_brightness(self, value): diff --git a/kasa/device.py b/kasa/device.py index 8a81030f8..4cb6bd989 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -245,6 +245,11 @@ def is_dimmable(self) -> bool: """Return True if the device is dimmable.""" return False + @property + def is_fan(self) -> bool: + """Return True if the device is a fan.""" + return self.device_type == DeviceType.Fan + @property def is_variable_color_temp(self) -> bool: """Return True if the device supports color temperature.""" diff --git a/kasa/fan.py b/kasa/fan.py index c9601b1b7..e881136e8 100644 --- a/kasa/fan.py +++ b/kasa/fan.py @@ -4,14 +4,11 @@ from abc import ABC, abstractmethod +from .device import Device -class Fan(ABC): - """Interface for a Fan.""" - @property - @abstractmethod - def is_fan(self) -> bool: - """Return True if the device is a fan.""" +class Fan(Device, ABC): + """Interface for a Fan.""" @property @abstractmethod diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 7ee5ab0f2..733f3157d 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -46,7 +46,9 @@ } -class SmartDevice(Device, Bulb, Fan): +# Device must go last as the other interfaces also inherit Device +# and python needs a consistent method resolution order. +class SmartDevice(Bulb, Fan, Device): """Base class to represent a SMART protocol based device.""" def __init__( From 9dcd8ec91b1f7d73461b63f9e2d5b04e1e7d3179 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 2 May 2024 15:05:26 +0200 Subject: [PATCH 412/892] Improve temperature controls (#872) This improves the temperature control features to allow implementing climate platform support for homeassistant. Also adds frostprotection module, which is also used to turn the thermostat on and off. --- kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/frostprotection.py | 58 ++++++++++ kasa/smart/modules/humidity.py | 1 + kasa/smart/modules/temperature.py | 1 + kasa/smart/modules/temperaturecontrol.py | 86 +++++++++++++- .../smart/modules/test_temperaturecontrol.py | 107 +++++++++++++++++- 6 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 kasa/smart/modules/frostprotection.py diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index d028b9d77..ee2b84428 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -12,6 +12,7 @@ from .energymodule import EnergyModule from .fanmodule import FanModule from .firmware import Firmware +from .frostprotection import FrostProtectionModule from .humidity import HumiditySensor from .ledmodule import LedModule from .lighttransitionmodule import LightTransitionModule @@ -42,4 +43,5 @@ "ColorTemperatureModule", "ColorModule", "WaterleakSensor", + "FrostProtectionModule", ] diff --git a/kasa/smart/modules/frostprotection.py b/kasa/smart/modules/frostprotection.py new file mode 100644 index 000000000..07363279c --- /dev/null +++ b/kasa/smart/modules/frostprotection.py @@ -0,0 +1,58 @@ +"""Frost protection module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...feature import Feature +from ..smartmodule import SmartModule + +# TODO: this may not be necessary with __future__.annotations +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class FrostProtectionModule(SmartModule): + """Implementation for frost protection module. + + This basically turns the thermostat on and off. + """ + + REQUIRED_COMPONENT = "frost_protection" + # TODO: the information required for current features do not require this query + QUERY_GETTER_NAME = "get_frost_protection" + + def __init__(self, device: SmartDevice, module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + name="Frost protection enabled", + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + ) + ) + + @property + def enabled(self) -> bool: + """Return True if frost protection is on.""" + return self._device.sys_info["frost_protection_on"] + + async def set_enabled(self, enable: bool): + """Enable/disable frost protection.""" + return await self.call( + "set_device_info", + {"frost_protection_on": enable}, + ) + + @property + def minimum_temperature(self) -> int: + """Return frost protection minimum temperature.""" + return self.data["min_temp"] + + @property + def temperature_unit(self) -> str: + """Return frost protection temperature unit.""" + return self.data["temp_unit"] diff --git a/kasa/smart/modules/humidity.py b/kasa/smart/modules/humidity.py index 26fca25a2..ad2bd8c96 100644 --- a/kasa/smart/modules/humidity.py +++ b/kasa/smart/modules/humidity.py @@ -26,6 +26,7 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="humidity", icon="mdi:water-percent", + unit="%", ) ) self._add_feature( diff --git a/kasa/smart/modules/temperature.py b/kasa/smart/modules/temperature.py index 7b83c42c7..ea9b18e58 100644 --- a/kasa/smart/modules/temperature.py +++ b/kasa/smart/modules/temperature.py @@ -26,6 +26,7 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="temperature", icon="mdi:thermometer", + category=Feature.Category.Primary, ) ) if "current_temp_exception" in device.sys_info: diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index 1c190f675..69847002b 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging +from enum import Enum from typing import TYPE_CHECKING from ...feature import Feature @@ -11,6 +13,19 @@ from ..smartdevice import SmartDevice +_LOGGER = logging.getLogger(__name__) + + +class ThermostatState(Enum): + """Thermostat state.""" + + Heating = "heating" + Calibrating = "progress_calibration" + Idle = "idle" + Off = "off" + Unknown = "unknown" + + class TemperatureControl(SmartModule): """Implementation of temperature module.""" @@ -25,8 +40,10 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="target_temperature", attribute_setter="set_target_temperature", + range_getter="allowed_temperature_range", icon="mdi:thermometer", type=Feature.Type.Number, + category=Feature.Category.Primary, ) ) # TODO: this might belong into its own module, temperature_correction? @@ -40,6 +57,29 @@ def __init__(self, device: SmartDevice, module: str): minimum_value=-10, maximum_value=10, type=Feature.Type.Number, + category=Feature.Category.Config, + ) + ) + + self._add_feature( + Feature( + device, + "State", + container=self, + attribute_getter="state", + attribute_setter="set_state", + category=Feature.Category.Primary, + type=Feature.Type.Switch, + ) + ) + + self._add_feature( + Feature( + device, + "Mode", + container=self, + attribute_getter="mode", + category=Feature.Category.Primary, ) ) @@ -48,6 +88,45 @@ def query(self) -> dict: # Target temperature is contained in the main device info response. return {} + @property + def state(self) -> bool: + """Return thermostat state.""" + return self._device.sys_info["frost_protection_on"] is False + + async def set_state(self, enabled: bool): + """Set thermostat state.""" + return await self.call("set_device_info", {"frost_protection_on": not enabled}) + + @property + def mode(self) -> ThermostatState: + """Return thermostat state.""" + # If frost protection is enabled, the thermostat is off. + if self._device.sys_info.get("frost_protection_on", False): + return ThermostatState.Off + + states = self._device.sys_info["trv_states"] + + # If the states is empty, the device is idling + if not states: + return ThermostatState.Idle + + if len(states) > 1: + _LOGGER.warning( + "Got multiple states (%s), using the first one: %s", states, states[0] + ) + + state = states[0] + try: + return ThermostatState(state) + except: # noqa: E722 + _LOGGER.warning("Got unknown state: %s", state) + return ThermostatState.Unknown + + @property + def allowed_temperature_range(self) -> tuple[int, int]: + """Return allowed temperature range.""" + return self.minimum_target_temperature, self.maximum_target_temperature + @property def minimum_target_temperature(self) -> int: """Minimum available target temperature.""" @@ -74,7 +153,12 @@ async def set_target_temperature(self, target: float): f"[{self.minimum_target_temperature},{self.maximum_target_temperature}]" ) - return await self.call("set_device_info", {"target_temp": target}) + payload = {"target_temp": target} + # If the device has frost protection, we set it off to enable heating + if "frost_protection_on" in self._device.sys_info: + payload["frost_protection_on"] = False + + return await self.call("set_device_info", payload) @property def temperature_offset(self) -> int: diff --git a/kasa/tests/smart/modules/test_temperaturecontrol.py b/kasa/tests/smart/modules/test_temperaturecontrol.py index 5f6e3b56e..4154cbf89 100644 --- a/kasa/tests/smart/modules/test_temperaturecontrol.py +++ b/kasa/tests/smart/modules/test_temperaturecontrol.py @@ -1,6 +1,9 @@ +import logging + import pytest -from kasa.smart.modules import TemperatureSensor +from kasa.smart.modules import TemperatureControl +from kasa.smart.modules.temperaturecontrol import ThermostatState from kasa.tests.device_fixtures import parametrize, thermostats_smart temperature = parametrize( @@ -20,7 +23,7 @@ ) async def test_temperature_control_features(dev, feature, type): """Test that features are registered and work as expected.""" - temp_module: TemperatureSensor = dev.modules["TemperatureControl"] + temp_module: TemperatureControl = dev.modules["TemperatureControl"] prop = getattr(temp_module, feature) assert isinstance(prop, type) @@ -32,3 +35,103 @@ async def test_temperature_control_features(dev, feature, type): await feat.set_value(10) await dev.update() assert feat.value == 10 + + +@thermostats_smart +async def test_set_temperature_turns_heating_on(dev): + """Test that set_temperature turns heating on.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + + await temp_module.set_state(False) + await dev.update() + assert temp_module.state is False + assert temp_module.mode is ThermostatState.Off + + await temp_module.set_target_temperature(10) + await dev.update() + assert temp_module.state is True + assert temp_module.mode is ThermostatState.Heating + assert temp_module.target_temperature == 10 + + +@thermostats_smart +async def test_set_temperature_invalid_values(dev): + """Test that out-of-bounds temperature values raise errors.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + + with pytest.raises(ValueError): + await temp_module.set_target_temperature(-1) + + with pytest.raises(ValueError): + await temp_module.set_target_temperature(100) + + +@thermostats_smart +async def test_temperature_offset(dev): + """Test the temperature offset API.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + with pytest.raises(ValueError): + await temp_module.set_temperature_offset(100) + + with pytest.raises(ValueError): + await temp_module.set_temperature_offset(-100) + + await temp_module.set_temperature_offset(5) + await dev.update() + assert temp_module.temperature_offset == 5 + + +@thermostats_smart +@pytest.mark.parametrize( + "mode, states, frost_protection", + [ + pytest.param(ThermostatState.Idle, [], False, id="idle has empty"), + pytest.param( + ThermostatState.Off, + ["anything"], + True, + id="any state with frost_protection on means off", + ), + pytest.param( + ThermostatState.Heating, + [ThermostatState.Heating], + False, + id="heating is heating", + ), + pytest.param(ThermostatState.Unknown, ["invalid"], False, id="unknown state"), + ], +) +async def test_thermostat_mode(dev, mode, states, frost_protection): + """Test different thermostat modes.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + + temp_module.data["frost_protection_on"] = frost_protection + temp_module.data["trv_states"] = states + + assert temp_module.state is not frost_protection + assert temp_module.mode is mode + + +@thermostats_smart +@pytest.mark.parametrize( + "mode, states, msg", + [ + pytest.param( + ThermostatState.Heating, + ["heating", "something else"], + "Got multiple states", + id="multiple states", + ), + pytest.param( + ThermostatState.Unknown, ["foobar"], "Got unknown state", id="unknown state" + ), + ], +) +async def test_thermostat_mode_warnings(dev, mode, states, msg, caplog): + """Test thermostat modes that should log a warning.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + caplog.set_level(logging.WARNING) + + temp_module.data["trv_states"] = states + assert temp_module.mode is mode + assert msg in caplog.text From 5ef81f46693868b2ef367d6886490e2307e6a462 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 2 May 2024 15:32:06 +0200 Subject: [PATCH 413/892] Improve feature setter robustness (#870) This adds a test to check that all feature.set_value() calls will cause a query, i.e., that there are no self.call()s that are not awaited, and fixes existing code in this context. This also fixes an issue where it was not possible to print out the feature if the value threw an exception. --- kasa/feature.py | 7 ++- kasa/iot/iotbulb.py | 1 + kasa/smart/modules/autooffmodule.py | 8 +-- kasa/smart/modules/colortemp.py | 1 + kasa/smart/modules/lighttransitionmodule.py | 4 +- kasa/smart/modules/temperature.py | 1 + kasa/tests/device_fixtures.py | 18 +++--- kasa/tests/test_feature.py | 68 ++++++++++++++++++++- 8 files changed, 90 insertions(+), 18 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index 30acf362e..b6e933ce8 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -172,9 +172,14 @@ async def set_value(self, value): return await getattr(container, self.attribute_setter)(value) def __repr__(self): - value = self.value + try: + value = self.value + except Exception as ex: + return f"Unable to read value ({self.id}): {ex}" + if self.precision_hint is not None and value is not None: value = round(self.value, self.precision_hint) + s = f"{self.name} ({self.id}): {value}" if self.unit is not None: s += f" {self.unit}" diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 50c31f621..d9456e969 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -233,6 +233,7 @@ async def _initialize_features(self): attribute_setter="set_color_temp", range_getter="valid_temperature_range", category=Feature.Category.Primary, + type=Feature.Type.Number, ) ) diff --git a/kasa/smart/modules/autooffmodule.py b/kasa/smart/modules/autooffmodule.py index 019d42357..8977719c4 100644 --- a/kasa/smart/modules/autooffmodule.py +++ b/kasa/smart/modules/autooffmodule.py @@ -55,9 +55,9 @@ def enabled(self) -> bool: """Return True if enabled.""" return self.data["enable"] - def set_enabled(self, enable: bool): + async def set_enabled(self, enable: bool): """Enable/disable auto off.""" - return self.call( + return await self.call( "set_auto_off_config", {"enable": enable, "delay_min": self.data["delay_min"]}, ) @@ -67,9 +67,9 @@ def delay(self) -> int: """Return time until auto off.""" return self.data["delay_min"] - def set_delay(self, delay: int): + async def set_delay(self, delay: int): """Set time until auto off.""" - return self.call( + return await self.call( "set_auto_off_config", {"delay_min": delay, "enable": self.data["enable"]} ) diff --git a/kasa/smart/modules/colortemp.py b/kasa/smart/modules/colortemp.py index e0bfec6ac..1392775cc 100644 --- a/kasa/smart/modules/colortemp.py +++ b/kasa/smart/modules/colortemp.py @@ -34,6 +34,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_setter="set_color_temp", range_getter="valid_temperature_range", category=Feature.Category.Primary, + type=Feature.Type.Number, ) ) diff --git a/kasa/smart/modules/lighttransitionmodule.py b/kasa/smart/modules/lighttransitionmodule.py index e7da22ef3..1cb7f48a6 100644 --- a/kasa/smart/modules/lighttransitionmodule.py +++ b/kasa/smart/modules/lighttransitionmodule.py @@ -88,9 +88,9 @@ def _turn_off(self): return self.data["off_state"] - def set_enabled_v1(self, enable: bool): + async def set_enabled_v1(self, enable: bool): """Enable gradual on/off.""" - return self.call("set_on_off_gradually_info", {"enable": enable}) + return await self.call("set_on_off_gradually_info", {"enable": enable}) @property def enabled_v1(self) -> bool: diff --git a/kasa/smart/modules/temperature.py b/kasa/smart/modules/temperature.py index ea9b18e58..49ffe046d 100644 --- a/kasa/smart/modules/temperature.py +++ b/kasa/smart/modules/temperature.py @@ -48,6 +48,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="temperature_unit", attribute_setter="set_temperature_unit", type=Feature.Type.Choice, + choices=["celsius", "fahrenheit"], ) ) # TODO: use temperature_unit for feature creation diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 50dfbce7f..83449a53a 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import AsyncGenerator + import pytest from kasa import ( @@ -346,13 +348,13 @@ def device_for_fixture_name(model, protocol): raise Exception("Unable to find type for %s", model) -async def _update_and_close(d): +async def _update_and_close(d) -> Device: await d.update() await d.protocol.close() return d -async def _discover_update_and_close(ip, username, password): +async def _discover_update_and_close(ip, username, password) -> Device: if username and password: credentials = Credentials(username=username, password=password) else: @@ -361,7 +363,7 @@ async def _discover_update_and_close(ip, username, password): return await _update_and_close(d) -async def get_device_for_fixture(fixture_data: FixtureInfo): +async def get_device_for_fixture(fixture_data: FixtureInfo) -> Device: # if the wanted file is not an absolute path, prepend the fixtures directory d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)( @@ -395,13 +397,14 @@ async def get_device_for_fixture_protocol(fixture, protocol): @pytest.fixture(params=filter_fixtures("main devices"), ids=idgenerator) -async def dev(request): +async def dev(request) -> AsyncGenerator[Device, None]: """Device fixture. Provides a device (given --ip) or parametrized fixture for the supported devices. The initial update is called automatically before returning the device. """ fixture_data: FixtureInfo = request.param + dev: Device ip = request.config.getoption("--ip") username = request.config.getoption("--username") @@ -412,13 +415,12 @@ async def dev(request): if not model: d = await _discover_update_and_close(ip, username, password) IP_MODEL_CACHE[ip] = model = d.model + if model not in fixture_data.name: pytest.skip(f"skipping file {fixture_data.name}") - dev: Device = ( - d if d else await _discover_update_and_close(ip, username, password) - ) + dev = d if d else await _discover_update_and_close(ip, username, password) else: - dev: Device = await get_device_for_fixture(fixture_data) + dev = await get_device_for_fixture(fixture_data) yield dev diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index f5de47d1f..fe6ba7f21 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -1,7 +1,12 @@ +import logging +import sys + import pytest -from pytest_mock import MockFixture +from pytest_mock import MockerFixture + +from kasa import Device, Feature, KasaException -from kasa import Feature +_LOGGER = logging.getLogger(__name__) class DummyDevice: @@ -111,7 +116,7 @@ async def test_feature_action(mocker): mock_call_action.assert_called() -async def test_feature_choice_list(dummy_feature, caplog, mocker: MockFixture): +async def test_feature_choice_list(dummy_feature, caplog, mocker: MockerFixture): """Test the choice feature type.""" dummy_feature.type = Feature.Type.Choice dummy_feature.choices = ["first", "second"] @@ -138,3 +143,60 @@ async def test_precision_hint(dummy_feature, precision_hint): dummy_feature.attribute_getter = lambda x: dummy_value assert dummy_feature.value == dummy_value assert f"{round(dummy_value, precision_hint)} dummyunit" in repr(dummy_feature) + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="exceptiongroup requires python3.11+", +) +async def test_feature_setters(dev: Device, mocker: MockerFixture): + """Test that all feature setters query something.""" + + async def _test_feature(feat, query_mock): + if feat.attribute_setter is None: + return + + expecting_call = True + + if feat.type == Feature.Type.Number: + await feat.set_value(feat.minimum_value) + elif feat.type == Feature.Type.Switch: + await feat.set_value(True) + elif feat.type == Feature.Type.Action: + await feat.set_value("dummyvalue") + elif feat.type == Feature.Type.Choice: + await feat.set_value(feat.choices[0]) + elif feat.type == Feature.Type.Unknown: + _LOGGER.warning("Feature '%s' has no type, cannot test the setter", feat) + expecting_call = False + else: + raise NotImplementedError(f"set_value not implemented for {feat.type}") + + if expecting_call: + query_mock.assert_called() + + async def _test_features(dev): + exceptions = [] + query = mocker.patch.object(dev.protocol, "query") + for feat in dev.features.values(): + query.reset_mock() + try: + await _test_feature(feat, query) + # we allow our own exceptions to avoid mocking valid responses + except KasaException: + pass + except Exception as ex: + ex.add_note(f"Exception when trying to set {feat} on {dev}") + exceptions.append(ex) + + return exceptions + + exceptions = await _test_features(dev) + + for child in dev.children: + exceptions.extend(await _test_features(child)) + + if exceptions: + raise ExceptionGroup( + "Got exceptions while testing attribute_setters", exceptions + ) From 5b486074e27ea63a2c371266aa863df41de13149 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 2 May 2024 15:31:12 +0100 Subject: [PATCH 414/892] Add LightEffectModule for dynamic light effects on SMART bulbs (#887) Support the `light_effect` module which allows setting the effect to Off or Party or Relax. Uses the new `Feature.Type.Choice`. Does not currently allow editing of effects. --- kasa/cli.py | 38 +++--- kasa/feature.py | 7 ++ kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/lighteffectmodule.py | 112 ++++++++++++++++++ kasa/smart/smartdevice.py | 58 +-------- kasa/tests/fakeprotocol_smart.py | 16 +++ kasa/tests/smart/modules/test_light_effect.py | 42 +++++++ kasa/tests/test_cli.py | 19 ++- 8 files changed, 217 insertions(+), 77 deletions(-) create mode 100644 kasa/smart/modules/lighteffectmodule.py create mode 100644 kasa/tests/smart/modules/test_light_effect.py diff --git a/kasa/cli.py b/kasa/cli.py index 0ef3eccb7..696dee274 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -586,6 +586,7 @@ def _echo_features( title: str, category: Feature.Category | None = None, verbose: bool = False, + indent: str = "\t", ): """Print out a listing of features and their values.""" if category is not None: @@ -598,13 +599,13 @@ def _echo_features( echo(f"[bold]{title}[/bold]") for _, feat in features.items(): try: - echo(f"\t{feat}") + echo(f"{indent}{feat}") if verbose: - echo(f"\t\tType: {feat.type}") - echo(f"\t\tCategory: {feat.category}") - echo(f"\t\tIcon: {feat.icon}") + echo(f"{indent}\tType: {feat.type}") + echo(f"{indent}\tCategory: {feat.category}") + echo(f"{indent}\tIcon: {feat.icon}") except Exception as ex: - echo(f"\t{feat.name} ({feat.id}): got exception (%s)" % ex) + echo(f"{indent}{feat.name} ({feat.id}): [red]got exception ({ex})[/red]") def _echo_all_features(features, *, verbose=False, title_prefix=None): @@ -1219,22 +1220,15 @@ async def feature(dev: Device, child: str, name: str, value): echo(f"Targeting child device {child}") dev = dev.get_child_device(child) if not name: - - def _print_features(dev): - for name, feat in dev.features.items(): - try: - unit = f" {feat.unit}" if feat.unit else "" - echo(f"\t{feat.name} ({name}): {feat.value}{unit}") - except Exception as ex: - echo(f"\t{feat.name} ({name}): [red]{ex}[/red]") - - echo("[bold]== Features ==[/bold]") - _print_features(dev) + _echo_features(dev.features, "\n[bold]== Features ==[/bold]\n", indent="") if dev.children: for child_dev in dev.children: - echo(f"[bold]== Child {child_dev.alias} ==") - _print_features(child_dev) + _echo_features( + child_dev.features, + f"\n[bold]== Child {child_dev.alias} ==\n", + indent="", + ) return @@ -1249,9 +1243,13 @@ def _print_features(dev): echo(f"{feat.name} ({name}): {feat.value}{unit}") return feat.value - echo(f"Setting {name} to {value}") value = ast.literal_eval(value) - return await dev.features[name].set_value(value) + echo(f"Changing {name} from {feat.value} to {value}") + response = await dev.features[name].set_value(value) + await dev.update() + echo(f"New state: {feat.value}") + + return response if __name__ == "__main__": diff --git a/kasa/feature.py b/kasa/feature.py index b6e933ce8..2000b21af 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -174,9 +174,16 @@ async def set_value(self, value): def __repr__(self): try: value = self.value + choices = self.choices except Exception as ex: return f"Unable to read value ({self.id}): {ex}" + if self.type == Feature.Type.Choice: + if not isinstance(choices, list) or value not in choices: + return f"Value {value} is not a valid choice ({self.id}): {choices}" + value = " ".join( + [f"*{choice}*" if choice == value else choice for choice in choices] + ) if self.precision_hint is not None and value is not None: value = round(self.value, self.precision_hint) diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index ee2b84428..647220791 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -15,6 +15,7 @@ from .frostprotection import FrostProtectionModule from .humidity import HumiditySensor from .ledmodule import LedModule +from .lighteffectmodule import LightEffectModule from .lighttransitionmodule import LightTransitionModule from .reportmodule import ReportModule from .temperature import TemperatureSensor @@ -39,6 +40,7 @@ "FanModule", "Firmware", "CloudModule", + "LightEffectModule", "LightTransitionModule", "ColorTemperatureModule", "ColorModule", diff --git a/kasa/smart/modules/lighteffectmodule.py b/kasa/smart/modules/lighteffectmodule.py new file mode 100644 index 000000000..7f03b8ff6 --- /dev/null +++ b/kasa/smart/modules/lighteffectmodule.py @@ -0,0 +1,112 @@ +"""Module for light effects.""" + +from __future__ import annotations + +import base64 +import copy +from typing import TYPE_CHECKING, Any + +from ...feature import Feature +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class LightEffectModule(SmartModule): + """Implementation of dynamic light effects.""" + + REQUIRED_COMPONENT = "light_effect" + QUERY_GETTER_NAME = "get_dynamic_light_effect_rules" + AVAILABLE_BULB_EFFECTS = { + "L1": "Party", + "L2": "Relax", + } + LIGHT_EFFECTS_OFF = "Off" + + def __init__(self, device: SmartDevice, module: str): + super().__init__(device, module) + self._scenes_names_to_id: dict[str, str] = {} + + def _initialize_features(self): + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device, + "Light effect", + container=self, + attribute_getter="effect", + attribute_setter="set_effect", + category=Feature.Category.Config, + type=Feature.Type.Choice, + choices_getter="effect_list", + ) + ) + + def _initialize_effects(self) -> dict[str, dict[str, Any]]: + """Return built-in effects.""" + # Copy the effects so scene name updates do not update the underlying dict. + effects = copy.deepcopy( + {effect["id"]: effect for effect in self.data["rule_list"]} + ) + for effect in effects.values(): + if not effect["scene_name"]: + # If the name has not been edited scene_name will be an empty string + effect["scene_name"] = self.AVAILABLE_BULB_EFFECTS[effect["id"]] + else: + # Otherwise it will be b64 encoded + effect["scene_name"] = base64.b64decode(effect["scene_name"]).decode() + self._scenes_names_to_id = { + effect["scene_name"]: effect["id"] for effect in effects.values() + } + return effects + + @property + def effect_list(self) -> list[str] | None: + """Return built-in effects list. + + Example: + ['Party', 'Relax', ...] + """ + effects = [self.LIGHT_EFFECTS_OFF] + effects.extend( + [effect["scene_name"] for effect in self._initialize_effects().values()] + ) + return effects + + @property + def effect(self) -> str: + """Return effect name.""" + # get_dynamic_light_effect_rules also has an enable property and current_rule_id + # property that could be used here as an alternative + if self._device._info["dynamic_light_effect_enable"]: + return self._initialize_effects()[ + self._device._info["dynamic_light_effect_id"] + ]["scene_name"] + return self.LIGHT_EFFECTS_OFF + + async def set_effect( + self, + effect: str, + ) -> None: + """Set an effect for the device. + + The device doesn't store an active effect while not enabled so store locally. + """ + if effect != self.LIGHT_EFFECTS_OFF and effect not in self._scenes_names_to_id: + raise ValueError( + f"Cannot set light effect to {effect}, possible values " + f"are: {self.LIGHT_EFFECTS_OFF} " + f"{' '.join(self._scenes_names_to_id.keys())}" + ) + enable = effect != self.LIGHT_EFFECTS_OFF + params: dict[str, bool | str] = {"enable": enable} + if enable: + effect_id = self._scenes_names_to_id[effect] + params["id"] = effect_id + return await self.call("set_dynamic_light_effect_rule_enable", params) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {self.QUERY_GETTER_NAME: {"start_index": 0}} diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 733f3157d..e5df10bee 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -40,11 +40,6 @@ # same issue, homekit perhaps? WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule] -AVAILABLE_BULB_EFFECTS = { - "L1": "Party", - "L2": "Relax", -} - # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. @@ -683,44 +678,6 @@ def valid_temperature_range(self) -> ColorTempRange: ColorTemperatureModule, self.modules["ColorTemperatureModule"] ).valid_temperature_range - @property - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - return "dynamic_light_effect_enable" in self._info - - @property - def effect(self) -> dict: - """Return effect state. - - This follows the format used by SmartLightStrip. - - Example: - {'brightness': 50, - 'custom': 0, - 'enable': 0, - 'id': '', - 'name': ''} - """ - # If no effect is active, dynamic_light_effect_id does not appear in info - current_effect = self._info.get("dynamic_light_effect_id", "") - data = { - "brightness": self.brightness, - "enable": current_effect != "", - "id": current_effect, - "name": AVAILABLE_BULB_EFFECTS.get(current_effect, ""), - } - - return data - - @property - def effect_list(self) -> list[str] | None: - """Return built-in effects list. - - Example: - ['Party', 'Relax', ...] - """ - return list(AVAILABLE_BULB_EFFECTS.keys()) if self.has_effects else None - @property def hsv(self) -> HSV: """Return the current HSV state of the bulb. @@ -807,17 +764,12 @@ async def set_brightness( brightness ) - async def set_effect( - self, - effect: str, - *, - brightness: int | None = None, - transition: int | None = None, - ) -> None: - """Set an effect on the device.""" - raise NotImplementedError() - @property def presets(self) -> list[BulbPreset]: """Return a list of available bulb setting presets.""" return [] + + @property + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + return "LightEffectModule" in self.modules diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 7340b5b7d..ae1a7ad66 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -176,6 +176,19 @@ def _handle_control_child(self, params: dict): "Method %s not implemented for children" % child_method ) + def _set_light_effect(self, info, params): + """Set or remove values as per the device behaviour.""" + info["get_device_info"]["dynamic_light_effect_enable"] = params["enable"] + info["get_dynamic_light_effect_rules"]["enable"] = params["enable"] + if params["enable"]: + info["get_device_info"]["dynamic_light_effect_id"] = params["id"] + info["get_dynamic_light_effect_rules"]["current_rule_id"] = params["enable"] + else: + if "dynamic_light_effect_id" in info["get_device_info"]: + del info["get_device_info"]["dynamic_light_effect_id"] + if "current_rule_id" in info["get_dynamic_light_effect_rules"]: + del info["get_dynamic_light_effect_rules"]["current_rule_id"] + def _send_request(self, request_dict: dict): method = request_dict["method"] params = request_dict["params"] @@ -223,6 +236,9 @@ def _send_request(self, request_dict: dict): return retval elif method == "set_qs_info": return {"error_code": 0} + elif method == "set_dynamic_light_effect_rule_enable": + self._set_light_effect(info, params) + return {"error_code": 0} elif method[:4] == "set_": target_method = f"get_{method[4:]}" info[target_method].update(params) diff --git a/kasa/tests/smart/modules/test_light_effect.py b/kasa/tests/smart/modules/test_light_effect.py new file mode 100644 index 000000000..ba1b22934 --- /dev/null +++ b/kasa/tests/smart/modules/test_light_effect.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from itertools import chain +from typing import cast + +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, Feature +from kasa.smart.modules import LightEffectModule +from kasa.tests.device_fixtures import parametrize + +light_effect = parametrize( + "has light effect", component_filter="light_effect", protocol_filter={"SMART"} +) + + +@light_effect +async def test_light_effect(dev: Device, mocker: MockerFixture): + """Test light effect.""" + light_effect = cast(LightEffectModule, dev.modules.get("LightEffectModule")) + assert light_effect + + feature = light_effect._module_features["light_effect"] + assert feature.type == Feature.Type.Choice + + call = mocker.spy(light_effect, "call") + assert feature.choices == light_effect.effect_list + assert feature.choices + for effect in chain(reversed(feature.choices), feature.choices): + await light_effect.set_effect(effect) + enable = effect != LightEffectModule.LIGHT_EFFECTS_OFF + params: dict[str, bool | str] = {"enable": enable} + if enable: + params["id"] = light_effect._scenes_names_to_id[effect] + call.assert_called_with("set_dynamic_light_effect_rule_enable", params) + await dev.update() + assert light_effect.effect == effect + assert feature.value == effect + + with pytest.raises(ValueError): + await light_effect.set_effect("foobar") diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 3d80ee473..7addd4348 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -689,6 +689,17 @@ async def test_feature(mocker, runner): assert res.exit_code == 0 +async def test_features_all(discovery_mock, mocker, runner): + """Test feature command on all fixtures.""" + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature"], + catch_exceptions=False, + ) + assert "== Features ==" in res.output + assert res.exit_code == 0 + + async def test_feature_single(mocker, runner): """Test feature command returning single value.""" dummy_device = await get_device_for_fixture_protocol( @@ -736,7 +747,7 @@ async def test_feature_set(mocker, runner): ) led_setter.assert_called_with(True) - assert "Setting led to True" in res.output + assert "Changing led from False to True" in res.output assert res.exit_code == 0 @@ -762,14 +773,14 @@ async def test_feature_set_child(mocker, runner): "--child", child_id, "state", - "False", + "True", ], catch_exceptions=False, ) get_child_device.assert_called() - setter.assert_called_with(False) + setter.assert_called_with(True) assert f"Targeting child device {child_id}" - assert "Setting state to False" in res.output + assert "Changing state from False to True" in res.output assert res.exit_code == 0 From 88381f270f4761a5c358afda5298dee5a33192f2 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 3 May 2024 13:57:43 +0200 Subject: [PATCH 415/892] Use Path.save for saving the fixtures (#894) This might fix saving of fixture files on Windows, but it's a good practice to use pathlib where possible. --- devtools/dump_devinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 1c7fb42d8..a6b27e952 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -164,7 +164,7 @@ async def handle_device(basedir, autosave, device: Device, batch_size: int): if save == "y": click.echo(f"Saving info to {save_filename}") - with open(save_filename, "w") as f: + with save_filename.open("w") as f: json.dump(fixture_result.data, f, sort_keys=True, indent=4) f.write("\n") else: From 530fb841b06c6dda35b669b1ad7d1b66432de18e Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 3 May 2024 15:24:34 +0200 Subject: [PATCH 416/892] Add fixture for waterleak sensor T300 (#897) Fixture by courtesy of @ngaertner (https://github.com/python-kasa/python-kasa/issues/875#issuecomment-2091484438) --- kasa/tests/device_fixtures.py | 2 +- .../smart/child/T300(EU)_1.0_1.7.0.json | 533 ++++++++++++++++++ kasa/tests/smart/modules/test_waterleak.py | 10 +- 3 files changed, 539 insertions(+), 6 deletions(-) create mode 100644 kasa/tests/fixtures/smart/child/T300(EU)_1.0_1.7.0.json diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 83449a53a..92a86b6f0 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -109,7 +109,7 @@ } HUBS_SMART = {"H100", "KH100"} -SENSORS_SMART = {"T310", "T315"} +SENSORS_SMART = {"T310", "T315", "T300"} THERMOSTATS_SMART = {"KE100"} WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} diff --git a/kasa/tests/fixtures/smart/child/T300(EU)_1.0_1.7.0.json b/kasa/tests/fixtures/smart/child/T300(EU)_1.0_1.7.0.json new file mode 100644 index 000000000..7a6c8db3c --- /dev/null +++ b/kasa/tests/fixtures/smart/child/T300(EU)_1.0_1.7.0.json @@ -0,0 +1,533 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "sensor_alarm", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor_t300", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trigger.water-leak-sensor", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.7.0 Build 230628 Rel.194748", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "jamming_rssi": -120, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1714661760, + "mac": "98254A000000", + "model": "T300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 16, + "rssi": -49, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR", + "water_leak_status": "normal" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.7.0 Build 230628 Rel.194748", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_temp_humidity_records": { + "local_time": 1714681045, + "past24h_humidity": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_humidity_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "waterDry", + "eventId": "18a67996-611a-a7f9-5689-6699ee55806a", + "id": 8, + "timestamp": 1714680176 + }, + { + "event": "waterLeak", + "eventId": "4b43c78d-a832-7755-cc80-a6357cd88aa3", + "id": 7, + "timestamp": 1714680174 + }, + { + "event": "waterDry", + "eventId": "2a3731ba-7f1d-2c34-38be-f5580e2d3cbc", + "id": 6, + "timestamp": 1714680172 + }, + { + "event": "waterLeak", + "eventId": "eebb19c0-2cda-215c-62f5-be13cda215c6", + "id": 5, + "timestamp": 1714676832 + } + ], + "start_id": 8, + "sum": 4 + } +} diff --git a/kasa/tests/smart/modules/test_waterleak.py b/kasa/tests/smart/modules/test_waterleak.py index 247ffb812..aa589e447 100644 --- a/kasa/tests/smart/modules/test_waterleak.py +++ b/kasa/tests/smart/modules/test_waterleak.py @@ -12,17 +12,17 @@ @waterleak @pytest.mark.parametrize( - "feature, type", + "feature, prop_name, type", [ - ("alert", int), - ("status", Enum), + ("water_alert", "alert", int), + ("water_leak", "status", Enum), ], ) -async def test_waterleak_properties(dev, feature, type): +async def test_waterleak_properties(dev, feature, prop_name, type): """Test that features are registered and work as expected.""" waterleak: WaterleakSensor = dev.modules["WaterleakSensor"] - prop = getattr(waterleak, feature) + prop = getattr(waterleak, prop_name) assert isinstance(prop, type) feat = waterleak._module_features[feature] From c5d65b624b52ffc1d0d1ae1317eee4dd7d50c802 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 3 May 2024 16:01:21 +0100 Subject: [PATCH 417/892] Make get_module return typed module (#892) Passing in a string still works and returns either `IotModule` or `SmartModule` type when called on `IotDevice` or `SmartDevice` respectively. When calling on `Device` will return `Module` type. Passing in a module type is then typed to that module, i.e.: ```py smartdev.get_module(FanModule) # type is FanModule smartdev.get_module("FanModule") # type is SmartModule ``` Only thing this doesn't do is check that you can't pass an `IotModule` to a `SmartDevice.get_module()`. However there is a runtime check which will return null if the passed `ModuleType` is not a subclass of `SmartModule`. Many thanks to @cdce8p for helping with this. --- kasa/device.py | 16 +++++++++-- kasa/iot/iotdevice.py | 23 +++++++++++++++- kasa/module.py | 7 ++++- kasa/smart/smartdevice.py | 22 ++++++++++++--- kasa/tests/smart/features/test_brightness.py | 2 +- kasa/tests/smart/modules/test_fan.py | 9 +++--- kasa/tests/test_iotdevice.py | 29 +++++++++++++++++++- kasa/tests/test_smartdevice.py | 22 ++++++++++++++- 8 files changed, 114 insertions(+), 16 deletions(-) diff --git a/kasa/device.py b/kasa/device.py index 4cb6bd989..ea358a8de 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import Any, Mapping, Sequence +from typing import Any, Mapping, Sequence, overload from .credentials import Credentials from .device_type import DeviceType @@ -15,7 +15,7 @@ from .exceptions import KasaException from .feature import Feature from .iotprotocol import IotProtocol -from .module import Module +from .module import Module, ModuleT from .protocol import BaseProtocol from .xortransport import XorTransport @@ -116,6 +116,18 @@ async def disconnect(self): def modules(self) -> Mapping[str, Module]: """Return the device modules.""" + @overload + @abstractmethod + def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... + + @overload + @abstractmethod + def get_module(self, module_type: str) -> Module | None: ... + + @abstractmethod + def get_module(self, module_type: type[ModuleT] | str) -> ModuleT | Module | None: + """Return the module from the device modules or None if not present.""" + @property @abstractmethod def is_on(self) -> bool: diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 81b5eddac..e69de80cd 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -19,13 +19,14 @@ import inspect import logging from datetime import datetime, timedelta -from typing import Any, Mapping, Sequence, cast +from typing import Any, Mapping, Sequence, cast, overload from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import KasaException from ..feature import Feature +from ..module import ModuleT from ..protocol import BaseProtocol from .iotmodule import IotModule from .modules import Emeter, Time @@ -201,6 +202,26 @@ def modules(self) -> dict[str, IotModule]: """Return the device modules.""" return self._modules + @overload + def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... + + @overload + def get_module(self, module_type: str) -> IotModule | None: ... + + def get_module( + self, module_type: type[ModuleT] | str + ) -> ModuleT | IotModule | None: + """Return the module from the device modules or None if not present.""" + if isinstance(module_type, str): + module_name = module_type.lower() + elif issubclass(module_type, IotModule): + module_name = module_type.__name__.lower() + else: + return None + if module_name in self.modules: + return self.modules[module_name] + return None + def add_module(self, name: str, module: IotModule): """Register a module.""" if name in self.modules: diff --git a/kasa/module.py b/kasa/module.py index 8422eaf94..5b6354a9c 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -4,7 +4,10 @@ import logging from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from typing import ( + TYPE_CHECKING, + TypeVar, +) from .exceptions import KasaException from .feature import Feature @@ -14,6 +17,8 @@ _LOGGER = logging.getLogger(__name__) +ModuleT = TypeVar("ModuleT", bound="Module") + class Module(ABC): """Base class implemention for all modules. diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index e5df10bee..98c5f7efe 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -5,7 +5,7 @@ import base64 import logging from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast +from typing import Any, Mapping, Sequence, cast, overload from ..aestransport import AesTransport from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange @@ -16,6 +16,7 @@ from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..fan import Fan from ..feature import Feature +from ..module import ModuleT from ..smartprotocol import SmartProtocol from .modules import ( Brightness, @@ -28,11 +29,10 @@ Firmware, TimeModule, ) +from .smartmodule import SmartModule _LOGGER = logging.getLogger(__name__) -if TYPE_CHECKING: - from .smartmodule import SmartModule # List of modules that wall switches with children, i.e. ks240 report on # the child but only work on the parent. See longer note below in _initialize_modules. @@ -305,8 +305,22 @@ async def _initialize_features(self): for feat in module._module_features.values(): self._add_feature(feat) - def get_module(self, module_name) -> SmartModule | None: + @overload + def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... + + @overload + def get_module(self, module_type: str) -> SmartModule | None: ... + + def get_module( + self, module_type: type[ModuleT] | str + ) -> ModuleT | SmartModule | None: """Return the module from the device modules or None if not present.""" + if isinstance(module_type, str): + module_name = module_type + elif issubclass(module_type, SmartModule): + module_name = module_type.__name__ + else: + return None if module_name in self.modules: return self.modules[module_name] elif self._exposes_child_modules: diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index 79df0abf9..02a396aae 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -33,7 +33,7 @@ async def test_brightness_component(dev: SmartDevice): @dimmable -async def test_brightness_dimmable(dev: SmartDevice): +async def test_brightness_dimmable(dev: IotDevice): """Test brightness feature.""" assert isinstance(dev, IotDevice) assert "brightness" in dev.sys_info or bool(dev.sys_info["is_dimmable"]) diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 429a5d18f..372459510 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -1,5 +1,3 @@ -from typing import cast - import pytest from pytest_mock import MockerFixture @@ -13,7 +11,7 @@ @fan async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): """Test fan speed feature.""" - fan = cast(FanModule, dev.get_module("FanModule")) + fan = dev.get_module(FanModule) assert fan level_feature = fan._module_features["fan_speed_level"] @@ -38,7 +36,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): @fan async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): """Test sleep mode feature.""" - fan = cast(FanModule, dev.get_module("FanModule")) + fan = dev.get_module(FanModule) assert fan sleep_feature = fan._module_features["fan_sleep_mode"] assert isinstance(sleep_feature.value, bool) @@ -57,7 +55,8 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): """Test fan speed on device interface.""" assert isinstance(dev, SmartDevice) - fan = cast(FanModule, dev.get_module("FanModule")) + fan = dev.get_module(FanModule) + assert fan device = fan._device assert device.is_fan diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py index 4c5d5126a..b4d56291e 100644 --- a/kasa/tests/test_iotdevice.py +++ b/kasa/tests/test_iotdevice.py @@ -19,7 +19,7 @@ from kasa import KasaException from kasa.iot import IotDevice -from .conftest import handle_turn_on, turn_on +from .conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on from .device_fixtures import device_iot, has_emeter_iot, no_emeter_iot from .fakeprotocol_iot import FakeIotProtocol @@ -258,3 +258,30 @@ async def test_modules_not_supported(dev: IotDevice): await dev.update() for module in dev.modules.values(): assert module.is_supported is not None + + +async def test_get_modules(): + """Test get_modules for child and parent modules.""" + dummy_device = await get_device_for_fixture_protocol( + "HS100(US)_2.0_1.5.6.json", "IOT" + ) + from kasa.iot.modules import Cloud + from kasa.smart.modules import CloudModule + + # Modules on device + module = dummy_device.get_module("Cloud") + assert module + assert module._device == dummy_device + assert isinstance(module, Cloud) + + module = dummy_device.get_module(Cloud) + assert module + assert module._device == dummy_device + assert isinstance(module, Cloud) + + # Invalid modules + module = dummy_device.get_module("DummyModule") + assert module is None + + module = dummy_device.get_module(CloudModule) + assert module is None diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 476a37ae5..bb2f81bf0 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -122,23 +122,43 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): spies[device].assert_not_called() -async def test_get_modules(mocker): +async def test_get_modules(): """Test get_modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( "KS240(US)_1.0_1.0.5.json", "SMART" ) + from kasa.iot.modules import AmbientLight + from kasa.smart.modules import CloudModule, FanModule + + # Modules on device module = dummy_device.get_module("CloudModule") assert module assert module._device == dummy_device + assert isinstance(module, CloudModule) + module = dummy_device.get_module(CloudModule) + assert module + assert module._device == dummy_device + assert isinstance(module, CloudModule) + + # Modules on child module = dummy_device.get_module("FanModule") assert module assert module._device != dummy_device assert module._device._parent == dummy_device + module = dummy_device.get_module(FanModule) + assert module + assert module._device != dummy_device + assert module._device._parent == dummy_device + + # Invalid modules module = dummy_device.get_module("DummyModule") assert module is None + module = dummy_device.get_module(AmbientLight) + assert module is None + @bulb_smart async def test_smartdevice_brightness(dev: SmartDevice): From f063c833787a9f2edb0906c448f4a7d5397e233f Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 7 May 2024 07:48:47 +0100 Subject: [PATCH 418/892] Add child devices from hubs to generated list of supported devices (#898) Updates generate_supported hook to include child devices of hubs in the list of supported devices. --- README.md | 7 +++++-- SUPPORTED.md | 19 ++++++++++++++++-- devtools/generate_supported.py | 35 ++++++++++++++++++++++++++-------- kasa/smart/smartdevice.py | 4 ++++ 4 files changed, 53 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c17d80b56..85fc6982b 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,7 @@ The following devices have been tested and confirmed as working. If your device - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 - **Light Strips**: KL400L5, KL420L5, KL430 - **Hubs**: KH100\* +- **Hub-Connected Devices\*\*\***: KE100\* ### Supported Tapo\* devices @@ -241,10 +242,12 @@ The following devices have been tested and confirmed as working. If your device - **Bulbs**: L510B, L510E, L530E - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Hubs**: H100 +- **Hub-Connected Devices\*\*\***: T300, T310, T315 -*  Model requires authentication
-** Newer versions require authentication +\*   Model requires authentication
+\*\*  Newer versions require authentication
+\*\*\* Devices may work across TAPO/KASA branded hubs See [supported devices in our documentation](SUPPORTED.md) for more detailed information about tested hardware and software versions. diff --git a/SUPPORTED.md b/SUPPORTED.md index c4957c651..e52697635 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -6,7 +6,7 @@ The following devices have been tested and confirmed as working. If your device ## Kasa devices -Some newer Kasa devices require authentication. These are marked with * in the list below. +Some newer Kasa devices require authentication. These are marked with * in the list below.
Hub-Connected Devices may work across TAPO/KASA branded hubs even if they don't work across the native apps. ### Plugs @@ -134,10 +134,16 @@ Some newer Kasa devices require authentication. These are marked with *\* +### Hub-Connected Devices + +- **KE100** + - Hardware: 1.0 (EU) / Firmware: 2.8.0\* + - Hardware: 1.0 (UK) / Firmware: 2.8.0\* + ## Tapo devices -All Tapo devices require authentication. +All Tapo devices require authentication.
Hub-Connected Devices may work across TAPO/KASA branded hubs even if they don't work across the native apps. ### Plugs @@ -204,5 +210,14 @@ All Tapo devices require authentication. - Hardware: 1.0 (EU) / Firmware: 1.2.3 - Hardware: 1.0 (EU) / Firmware: 1.5.5 +### Hub-Connected Devices + +- **T300** + - Hardware: 1.0 (EU) / Firmware: 1.7.0 +- **T310** + - Hardware: 1.0 (EU) / Firmware: 1.5.0 +- **T315** + - Hardware: 1.0 (EU) / Firmware: 1.7.0 + diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py index fb0ac3cdc..b2909149c 100755 --- a/devtools/generate_supported.py +++ b/devtools/generate_supported.py @@ -29,10 +29,12 @@ class SupportedVersion(NamedTuple): DeviceType.StripSocket: "Power Strips", DeviceType.Dimmer: "Wall Switches", DeviceType.WallSwitch: "Wall Switches", + DeviceType.Fan: "Wall Switches", DeviceType.Bulb: "Bulbs", DeviceType.LightStrip: "Light Strips", DeviceType.Hub: "Hubs", - DeviceType.Sensor: "Sensors", + DeviceType.Sensor: "Hub-Connected Devices", + DeviceType.Thermostat: "Hub-Connected Devices", } @@ -106,7 +108,7 @@ def _supported_summary(supported): return _supported_text( supported, "### Supported $brand$auth devices\n\n$types\n", - "- **$type_**: $models\n", + "- **$type_$type_asterix**: $models\n", ) @@ -136,6 +138,10 @@ def _supported_text( if brand == "kasa" else "All Tapo devices require authentication." ) + preamble_text += ( + "
Hub-Connected Devices may work across TAPO/KASA branded " + + "hubs even if they don't work across the native apps." + ) brand_text = brand.capitalize() brand_auth = r"\*" if brand == "tapo" else "" types_text = "" @@ -177,7 +183,14 @@ def _supported_text( else: models_list.append(f"{model}{auth_flag}") models_text = models_text if models_text else ", ".join(models_list) - types_text += typest.substitute(type_=supported_type, models=models_text) + type_asterix = ( + r"\*\*\*" + if supported_type == "Hub-Connected Devices" + else "" + ) + types_text += typest.substitute( + type_=supported_type, type_asterix=type_asterix, models=models_text + ) brands += brandt.substitute( brand=brand_text, types=types_text, auth=brand_auth, preamble=preamble_text ) @@ -185,16 +198,22 @@ def _supported_text( def _get_smart_supported(supported): - for file in Path(SMART_FOLDER).glob("*.json"): + for file in Path(SMART_FOLDER).glob("**/*.json"): with file.open() as f: fixture_data = json.load(f) - model, _, region = fixture_data["discovery_result"]["device_model"].partition( - "(" - ) + if "discovery_result" in fixture_data: + model, _, region = fixture_data["discovery_result"][ + "device_model" + ].partition("(") + device_type = fixture_data["discovery_result"]["device_type"] + else: # child devices of hubs do not have discovery result + model = fixture_data["get_device_info"]["model"] + region = fixture_data["get_device_info"].get("specs") + device_type = fixture_data["get_device_info"]["type"] # P100 doesn't have region HW region = region.replace(")", "") if region else "" - device_type = fixture_data["discovery_result"]["device_type"] + _protocol, devicetype = device_type.split(".") brand = devicetype[:4].lower() components = [ diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 98c5f7efe..185352a5a 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -639,6 +639,10 @@ def _get_device_type_from_components( return DeviceType.Bulb if "SWITCH" in device_type: return DeviceType.WallSwitch + if "SENSOR" in device_type: + return DeviceType.Sensor + if "ENERGY" in device_type: + return DeviceType.Thermostat _LOGGER.warning("Unknown device type, falling back to plug") return DeviceType.Plug From 50b5107f758a602541d61a1310fab7a5ecb35937 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 7 May 2024 10:38:09 +0200 Subject: [PATCH 419/892] Add missing alarm volume 'normal' (#899) Also logs a warning in feature repr if value not in choices and fixes the returned string to be consistent with valid values. --- kasa/feature.py | 11 ++++++++++- kasa/smart/modules/alarmmodule.py | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index 2000b21af..02c78b203 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -180,7 +180,16 @@ def __repr__(self): if self.type == Feature.Type.Choice: if not isinstance(choices, list) or value not in choices: - return f"Value {value} is not a valid choice ({self.id}): {choices}" + _LOGGER.warning( + "Invalid value for for choice %s (%s): %s not in %s", + self.name, + self.id, + value, + choices, + ) + return ( + f"{self.name} ({self.id}): invalid value '{value}' not in {choices}" + ) value = " ".join( [f"*{choice}*" if choice == value else choice for choice in choices] ) diff --git a/kasa/smart/modules/alarmmodule.py b/kasa/smart/modules/alarmmodule.py index a3c67ef2c..97bdcf78f 100644 --- a/kasa/smart/modules/alarmmodule.py +++ b/kasa/smart/modules/alarmmodule.py @@ -64,7 +64,7 @@ def _initialize_features(self): attribute_setter="set_alarm_volume", category=Feature.Category.Config, type=Feature.Type.Choice, - choices=["low", "high"], + choices=["low", "normal", "high"], ) ) self._add_feature( From 55653d0346d4dd775e1e88be595006c1346a8b83 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 7 May 2024 11:13:35 +0200 Subject: [PATCH 420/892] Improve categorization of features (#904) This updates the categorization of features and makes the id mandatory for features --- kasa/feature.py | 9 ++------- kasa/iot/iotbulb.py | 2 ++ kasa/iot/iotdevice.py | 2 ++ kasa/iot/iotdimmer.py | 3 +++ kasa/iot/iotplug.py | 1 + kasa/iot/modules/ambientlight.py | 3 +++ kasa/iot/modules/cloud.py | 2 ++ kasa/iot/modules/emeter.py | 7 +++++++ kasa/module.py | 12 ++++-------- kasa/smart/modules/alarmmodule.py | 18 ++++++++++++------ kasa/smart/modules/autooffmodule.py | 13 ++++++++++--- kasa/smart/modules/battery.py | 5 +++++ kasa/smart/modules/brightness.py | 3 ++- kasa/smart/modules/cloudmodule.py | 4 +++- kasa/smart/modules/colormodule.py | 1 + kasa/smart/modules/colortemp.py | 1 + kasa/smart/modules/energymodule.py | 6 ++++++ kasa/smart/modules/fanmodule.py | 6 ++++-- kasa/smart/modules/firmware.py | 7 +++++-- kasa/smart/modules/frostprotection.py | 1 + kasa/smart/modules/humidity.py | 8 ++++++-- kasa/smart/modules/ledmodule.py | 1 + kasa/smart/modules/lighteffectmodule.py | 3 ++- kasa/smart/modules/lighttransitionmodule.py | 7 +++++-- kasa/smart/modules/reportmodule.py | 3 ++- kasa/smart/modules/temperature.py | 10 +++++++--- kasa/smart/modules/temperaturecontrol.py | 12 ++++++++---- kasa/smart/modules/timemodule.py | 1 + kasa/smart/modules/waterleak.py | 8 ++++++-- kasa/smart/smartdevice.py | 21 ++++++++++++++------- kasa/tests/test_feature.py | 3 +++ 31 files changed, 131 insertions(+), 52 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index 02c78b203..1f7d3f3d5 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -62,6 +62,8 @@ class Category(Enum): #: Device instance required for getting and setting values device: Device + #: Identifier + id: str #: User-friendly short description name: str #: Name of the property that allows accessing the value @@ -99,15 +101,8 @@ class Category(Enum): #: If set, this property will be used to set *choices*. choices_getter: str | None = None - #: Identifier - id: str | None = None - def __post_init__(self): """Handle late-binding of members.""" - # Set id, if unset - if self.id is None: - self.id = self.name.lower().replace(" ", "_") - # Populate minimum & maximum values, if range_getter is given container = self.container if self.container is not None else self.device if self.range_getter is not None: diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index d9456e969..6819d94ba 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -213,6 +213,7 @@ async def _initialize_features(self): self._add_feature( Feature( device=self, + id="brightness", name="Brightness", attribute_getter="brightness", attribute_setter="set_brightness", @@ -227,6 +228,7 @@ async def _initialize_features(self): self._add_feature( Feature( device=self, + id="color_temperature", name="Color temperature", container=self, attribute_getter="color_temp", diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index e69de80cd..29ba31554 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -334,6 +334,7 @@ async def _initialize_features(self): self._add_feature( Feature( device=self, + id="rssi", name="RSSI", attribute_getter="rssi", icon="mdi:signal", @@ -344,6 +345,7 @@ async def _initialize_features(self): self._add_feature( Feature( device=self, + id="on_since", name="On since", attribute_getter="on_since", icon="mdi:clock", diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 672b22656..cfe937b8a 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -91,12 +91,15 @@ async def _initialize_features(self): self._add_feature( Feature( device=self, + id="brightness", name="Brightness", attribute_getter="brightness", attribute_setter="set_brightness", minimum_value=1, maximum_value=100, + unit="%", type=Feature.Type.Number, + category=Feature.Category.Primary, ) ) diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index ecf73e035..dadb38f2a 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -65,6 +65,7 @@ async def _initialize_features(self): self._add_feature( Feature( device=self, + id="led", name="LED", icon="mdi:led-{state}", attribute_getter="led", diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py index 2d7d679ba..d49768ef8 100644 --- a/kasa/iot/modules/ambientlight.py +++ b/kasa/iot/modules/ambientlight.py @@ -22,10 +22,13 @@ def __init__(self, device, module): Feature( device=device, container=self, + id="ambient_light", name="Ambient Light", icon="mdi:brightness-percent", attribute_getter="ambientlight_brightness", type=Feature.Type.Sensor, + category=Feature.Category.Primary, + unit="%", ) ) diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index 41d2cbf54..5022a68e7 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -30,10 +30,12 @@ def __init__(self, device, module): Feature( device=device, container=self, + id="cloud_connection", name="Cloud connection", icon="mdi:cloud", attribute_getter="is_connected", type=Feature.Type.BinarySensor, + category=Feature.Category.Info, ) ) diff --git a/kasa/iot/modules/emeter.py b/kasa/iot/modules/emeter.py index 1542e66ab..53fb20da5 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -24,6 +24,7 @@ def __init__(self, device: Device, module: str): unit="W", id="current_power_w", # for homeassistant backwards compat precision_hint=1, + category=Feature.Category.Primary, ) ) self._add_feature( @@ -35,16 +36,19 @@ def __init__(self, device: Device, module: str): unit="kWh", id="today_energy_kwh", # for homeassistant backwards compat precision_hint=3, + category=Feature.Category.Info, ) ) self._add_feature( Feature( device, + id="consumption_this_month", name="This month's consumption", attribute_getter="emeter_this_month", container=self, unit="kWh", precision_hint=3, + category=Feature.Category.Info, ) ) self._add_feature( @@ -56,6 +60,7 @@ def __init__(self, device: Device, module: str): unit="kWh", id="total_energy_kwh", # for homeassistant backwards compat precision_hint=3, + category=Feature.Category.Info, ) ) self._add_feature( @@ -67,6 +72,7 @@ def __init__(self, device: Device, module: str): unit="V", id="voltage", # for homeassistant backwards compat precision_hint=1, + category=Feature.Category.Primary, ) ) self._add_feature( @@ -78,6 +84,7 @@ def __init__(self, device: Device, module: str): unit="A", id="current_a", # for homeassistant backwards compat precision_hint=2, + category=Feature.Category.Primary, ) ) diff --git a/kasa/module.py b/kasa/module.py index 5b6354a9c..3da0c1ad2 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -53,14 +53,10 @@ def _initialize_features(self): # noqa: B027 def _add_feature(self, feature: Feature): """Add module feature.""" - - def _slugified_name(name): - return name.lower().replace(" ", "_").replace("'", "_") - - feat_name = _slugified_name(feature.name) - if feat_name in self._module_features: - raise KasaException("Duplicate name detected %s" % feat_name) - self._module_features[feat_name] = feature + id_ = feature.id + if id_ in self._module_features: + raise KasaException("Duplicate id detected %s" % id_) + self._module_features[id_] = feature def __repr__(self) -> str: return ( diff --git a/kasa/smart/modules/alarmmodule.py b/kasa/smart/modules/alarmmodule.py index 97bdcf78f..845eb65aa 100644 --- a/kasa/smart/modules/alarmmodule.py +++ b/kasa/smart/modules/alarmmodule.py @@ -27,7 +27,8 @@ def _initialize_features(self): self._add_feature( Feature( device, - "Alarm", + id="alarm", + name="Alarm", container=self, attribute_getter="active", icon="mdi:bell", @@ -37,7 +38,8 @@ def _initialize_features(self): self._add_feature( Feature( device, - "Alarm source", + id="alarm_source", + name="Alarm source", container=self, attribute_getter="source", icon="mdi:bell", @@ -46,7 +48,8 @@ def _initialize_features(self): self._add_feature( Feature( device, - "Alarm sound", + id="alarm_sound", + name="Alarm sound", container=self, attribute_getter="alarm_sound", attribute_setter="set_alarm_sound", @@ -58,7 +61,8 @@ def _initialize_features(self): self._add_feature( Feature( device, - "Alarm volume", + id="alarm_volume", + name="Alarm volume", container=self, attribute_getter="alarm_volume", attribute_setter="set_alarm_volume", @@ -70,7 +74,8 @@ def _initialize_features(self): self._add_feature( Feature( device, - "Test alarm", + id="test_alarm", + name="Test alarm", container=self, attribute_setter="play", type=Feature.Type.Action, @@ -79,7 +84,8 @@ def _initialize_features(self): self._add_feature( Feature( device, - "Stop alarm", + id="stop_alarm", + name="Stop alarm", container=self, attribute_setter="stop", type=Feature.Type.Action, diff --git a/kasa/smart/modules/autooffmodule.py b/kasa/smart/modules/autooffmodule.py index 8977719c4..cb8d5e57c 100644 --- a/kasa/smart/modules/autooffmodule.py +++ b/kasa/smart/modules/autooffmodule.py @@ -23,7 +23,8 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Auto off enabled", + id="auto_off_enabled", + name="Auto off enabled", container=self, attribute_getter="enabled", attribute_setter="set_enabled", @@ -33,7 +34,8 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Auto off minutes", + id="auto_off_minutes", + name="Auto off minutes", container=self, attribute_getter="delay", attribute_setter="set_delay", @@ -42,7 +44,12 @@ def __init__(self, device: SmartDevice, module: str): ) self._add_feature( Feature( - device, "Auto off at", container=self, attribute_getter="auto_off_at" + device, + id="auto_off_at", + name="Auto off at", + container=self, + attribute_getter="auto_off_at", + category=Feature.Category.Info, ) ) diff --git a/kasa/smart/modules/battery.py b/kasa/smart/modules/battery.py index 20bca34b2..6f914bdf2 100644 --- a/kasa/smart/modules/battery.py +++ b/kasa/smart/modules/battery.py @@ -22,20 +22,25 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, + "battery_level", "Battery level", container=self, attribute_getter="battery", icon="mdi:battery", + unit="%", + category=Feature.Category.Info, ) ) self._add_feature( Feature( device, + "battery_low", "Battery low", container=self, attribute_getter="battery_low", icon="mdi:alert", type=Feature.Type.BinarySensor, + category=Feature.Category.Debug, ) ) diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index b12098488..b0b58c077 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -25,7 +25,8 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Brightness", + id="brightness", + name="Brightness", container=self, attribute_getter="brightness", attribute_setter="set_brightness", diff --git a/kasa/smart/modules/cloudmodule.py b/kasa/smart/modules/cloudmodule.py index 55338f269..8b9d8f418 100644 --- a/kasa/smart/modules/cloudmodule.py +++ b/kasa/smart/modules/cloudmodule.py @@ -24,11 +24,13 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Cloud connection", + id="cloud_connection", + name="Cloud connection", container=self, attribute_getter="is_connected", icon="mdi:cloud", type=Feature.Type.BinarySensor, + category=Feature.Category.Info, ) ) diff --git a/kasa/smart/modules/colormodule.py b/kasa/smart/modules/colormodule.py index 3adf0b4ef..716d4c444 100644 --- a/kasa/smart/modules/colormodule.py +++ b/kasa/smart/modules/colormodule.py @@ -22,6 +22,7 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, + "hsv", "HSV", container=self, attribute_getter="hsv", diff --git a/kasa/smart/modules/colortemp.py b/kasa/smart/modules/colortemp.py index 1392775cc..d6b43d029 100644 --- a/kasa/smart/modules/colortemp.py +++ b/kasa/smart/modules/colortemp.py @@ -28,6 +28,7 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, + "color_temperature", "Color temperature", container=self, attribute_getter="color_temp", diff --git a/kasa/smart/modules/energymodule.py b/kasa/smart/modules/energymodule.py index 6a75299e2..9cfe8cfb5 100644 --- a/kasa/smart/modules/energymodule.py +++ b/kasa/smart/modules/energymodule.py @@ -22,31 +22,37 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, + "consumption_current", name="Current consumption", attribute_getter="current_power", container=self, unit="W", precision_hint=1, + category=Feature.Category.Primary, ) ) self._add_feature( Feature( device, + "consumption_today", name="Today's consumption", attribute_getter="emeter_today", container=self, unit="Wh", precision_hint=2, + category=Feature.Category.Info, ) ) self._add_feature( Feature( device, + "consumption_this_month", name="This month's consumption", attribute_getter="emeter_this_month", container=self, unit="Wh", precision_hint=2, + category=Feature.Category.Info, ) ) diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fanmodule.py index 08a681e7e..6eeaa4d43 100644 --- a/kasa/smart/modules/fanmodule.py +++ b/kasa/smart/modules/fanmodule.py @@ -22,7 +22,8 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Fan speed level", + id="fan_speed_level", + name="Fan speed level", container=self, attribute_getter="fan_speed_level", attribute_setter="set_fan_speed_level", @@ -36,7 +37,8 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Fan sleep mode", + id="fan_sleep_mode", + name="Fan sleep mode", container=self, attribute_getter="sleep_mode", attribute_setter="set_sleep_mode", diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 5f0c8bb03..626add0f6 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -52,7 +52,8 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Auto update enabled", + id="auto_update_enabled", + name="Auto update enabled", container=self, attribute_getter="auto_update_enabled", attribute_setter="set_auto_update_enabled", @@ -62,10 +63,12 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Update available", + id="update_available", + name="Update available", container=self, attribute_getter="update_available", type=Feature.Type.BinarySensor, + category=Feature.Category.Info, ) ) diff --git a/kasa/smart/modules/frostprotection.py b/kasa/smart/modules/frostprotection.py index 07363279c..cedaf78be 100644 --- a/kasa/smart/modules/frostprotection.py +++ b/kasa/smart/modules/frostprotection.py @@ -27,6 +27,7 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, + "frost_protection_enabled", name="Frost protection enabled", container=self, attribute_getter="enabled", diff --git a/kasa/smart/modules/humidity.py b/kasa/smart/modules/humidity.py index ad2bd8c96..ec7d51a7a 100644 --- a/kasa/smart/modules/humidity.py +++ b/kasa/smart/modules/humidity.py @@ -22,21 +22,25 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Humidity", + id="humidity", + name="Humidity", container=self, attribute_getter="humidity", icon="mdi:water-percent", unit="%", + category=Feature.Category.Primary, ) ) self._add_feature( Feature( device, - "Humidity warning", + id="humidity_warning", + name="Humidity warning", container=self, attribute_getter="humidity_warning", type=Feature.Type.BinarySensor, icon="mdi:alert", + category=Feature.Category.Debug, ) ) diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py index 6fd0d637d..e31131590 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/ledmodule.py @@ -23,6 +23,7 @@ def __init__(self, device: SmartDevice, module: str): Feature( device=device, container=self, + id="led", name="LED", icon="mdi:led-{state}", attribute_getter="led", diff --git a/kasa/smart/modules/lighteffectmodule.py b/kasa/smart/modules/lighteffectmodule.py index 7f03b8ff6..bd0eea0ad 100644 --- a/kasa/smart/modules/lighteffectmodule.py +++ b/kasa/smart/modules/lighteffectmodule.py @@ -34,7 +34,8 @@ def _initialize_features(self): self._add_feature( Feature( device, - "Light effect", + id="light_effect", + name="Light effect", container=self, attribute_getter="effect", attribute_setter="set_effect", diff --git a/kasa/smart/modules/lighttransitionmodule.py b/kasa/smart/modules/lighttransitionmodule.py index 1cb7f48a6..f213d9ac1 100644 --- a/kasa/smart/modules/lighttransitionmodule.py +++ b/kasa/smart/modules/lighttransitionmodule.py @@ -31,6 +31,7 @@ def _create_features(self): Feature( device=self._device, container=self, + id="smooth_transitions", name="Smooth transitions", icon=icon, attribute_getter="enabled_v1", @@ -46,7 +47,8 @@ def _create_features(self): self._add_feature( Feature( self._device, - "Smooth transition on", + id="smooth_transition_on", + name="Smooth transition on", container=self, attribute_getter="turn_on_transition", attribute_setter="set_turn_on_transition", @@ -58,7 +60,8 @@ def _create_features(self): self._add_feature( Feature( self._device, - "Smooth transition off", + id="smooth_transition_off", + name="Smooth transition off", container=self, attribute_getter="turn_off_transition", attribute_setter="set_turn_off_transition", diff --git a/kasa/smart/modules/reportmodule.py b/kasa/smart/modules/reportmodule.py index 99d95fec1..16827a8c5 100644 --- a/kasa/smart/modules/reportmodule.py +++ b/kasa/smart/modules/reportmodule.py @@ -22,7 +22,8 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Report interval", + id="report_interval", + name="Report interval", container=self, attribute_getter="report_interval", category=Feature.Category.Debug, diff --git a/kasa/smart/modules/temperature.py b/kasa/smart/modules/temperature.py index 49ffe046d..4880fc301 100644 --- a/kasa/smart/modules/temperature.py +++ b/kasa/smart/modules/temperature.py @@ -22,7 +22,8 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Temperature", + id="temperature", + name="Temperature", container=self, attribute_getter="temperature", icon="mdi:thermometer", @@ -33,17 +34,20 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Temperature warning", + id="temperature_warning", + name="Temperature warning", container=self, attribute_getter="temperature_warning", type=Feature.Type.BinarySensor, icon="mdi:alert", + category=Feature.Category.Debug, ) ) self._add_feature( Feature( device, - "Temperature unit", + id="temperature_unit", + name="Temperature unit", container=self, attribute_getter="temperature_unit", attribute_setter="set_temperature_unit", diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index 69847002b..ae487bdf2 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -36,7 +36,8 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Target temperature", + id="target_temperature", + name="Target temperature", container=self, attribute_getter="target_temperature", attribute_setter="set_target_temperature", @@ -50,7 +51,8 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Temperature offset", + id="temperature_offset", + name="Temperature offset", container=self, attribute_getter="temperature_offset", attribute_setter="set_temperature_offset", @@ -64,7 +66,8 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "State", + id="state", + name="State", container=self, attribute_getter="state", attribute_setter="set_state", @@ -76,7 +79,8 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Mode", + id="mode", + name="Mode", container=self, attribute_getter="mode", category=Feature.Category.Primary, diff --git a/kasa/smart/modules/timemodule.py b/kasa/smart/modules/timemodule.py index 80f1308e5..23814f571 100644 --- a/kasa/smart/modules/timemodule.py +++ b/kasa/smart/modules/timemodule.py @@ -25,6 +25,7 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device=device, + id="time", name="Time", attribute_getter="time", container=self, diff --git a/kasa/smart/modules/waterleak.py b/kasa/smart/modules/waterleak.py index 1809c5560..6dbc00eb3 100644 --- a/kasa/smart/modules/waterleak.py +++ b/kasa/smart/modules/waterleak.py @@ -30,19 +30,23 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - "Water leak", + id="water_leak", + name="Water leak", container=self, attribute_getter="status", icon="mdi:water", + category=Feature.Category.Debug, ) ) self._add_feature( Feature( device, - "Water alert", + id="water_alert", + name="Water alert", container=self, attribute_getter="alert", icon="mdi:water-alert", + category=Feature.Category.Primary, ) ) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 185352a5a..898133878 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -225,7 +225,8 @@ async def _initialize_features(self): self._add_feature( Feature( self, - "Device ID", + id="device_id", + name="Device ID", attribute_getter="device_id", category=Feature.Category.Debug, ) @@ -234,7 +235,8 @@ async def _initialize_features(self): self._add_feature( Feature( self, - "State", + id="state", + name="State", attribute_getter="is_on", attribute_setter="set_state", type=Feature.Type.Switch, @@ -246,7 +248,8 @@ async def _initialize_features(self): self._add_feature( Feature( self, - "Signal Level", + id="signal_level", + name="Signal Level", attribute_getter=lambda x: x._info["signal_level"], icon="mdi:signal", category=Feature.Category.Info, @@ -257,7 +260,8 @@ async def _initialize_features(self): self._add_feature( Feature( self, - "RSSI", + id="rssi", + name="RSSI", attribute_getter=lambda x: x._info["rssi"], icon="mdi:signal", category=Feature.Category.Debug, @@ -268,6 +272,7 @@ async def _initialize_features(self): self._add_feature( Feature( device=self, + id="ssid", name="SSID", attribute_getter="ssid", icon="mdi:wifi", @@ -279,11 +284,12 @@ async def _initialize_features(self): self._add_feature( Feature( self, - "Overheated", + id="overheated", + name="Overheated", attribute_getter=lambda x: x._info["overheated"], icon="mdi:heat-wave", type=Feature.Type.BinarySensor, - category=Feature.Category.Debug, + category=Feature.Category.Info, ) ) @@ -293,10 +299,11 @@ async def _initialize_features(self): self._add_feature( Feature( device=self, + id="on_since", name="On since", attribute_getter="on_since", icon="mdi:clock", - category=Feature.Category.Debug, + category=Feature.Category.Info, ) ) diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index fe6ba7f21..101a21c0a 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -19,6 +19,7 @@ def dummy_feature() -> Feature: feat = Feature( device=DummyDevice(), # type: ignore[arg-type] + id="dummy_feature", name="dummy_feature", attribute_getter="dummygetter", attribute_setter="dummysetter", @@ -47,6 +48,7 @@ def test_feature_missing_type(): with pytest.raises(ValueError): Feature( device=DummyDevice(), # type: ignore[arg-type] + id="dummy_error", name="dummy error", attribute_getter="dummygetter", attribute_setter="dummysetter", @@ -104,6 +106,7 @@ async def test_feature_action(mocker): """Test that setting value on button calls the setter.""" feat = Feature( device=DummyDevice(), # type: ignore[arg-type] + id="dummy_feature", name="dummy_feature", attribute_setter="call_action", container=None, From 253287c7b71d66d24a3a61bb50139f1199b247fc Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 7 May 2024 14:46:59 +0200 Subject: [PATCH 421/892] Add warning about tapo watchdog (#902) --- docs/source/cli.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index c1570bc0c..dad754d25 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -58,6 +58,13 @@ As with all other commands, you can also pass ``--help`` to both ``join`` and `` However, note that communications with devices provisioned using this method will stop working when connected to the cloud. +.. warning:: + + At least some devices (e.g., Tapo lights L530 and L900) are known to have a watchdog that reboots them every 10 minutes if they are unable to connect to the cloud. + Although the communications are done locally, this will make these devices unavailable for a minute every time the device restarts. + This does not affect other devices to our current knowledge, but you have been warned. + + ``kasa --help`` *************** From b66a337f40a82d0110a3a789cb5d0b9a23e504c8 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 7 May 2024 20:56:03 +0200 Subject: [PATCH 422/892] Add H100 1.5.10 and KE100 2.4.0 fixtures (#905) --- SUPPORTED.md | 2 + .../fixtures/smart/H100(EU)_1.0_1.5.10.json | 547 ++++++++++++++++++ .../smart/child/KE100(EU)_1.0_2.4.0.json | 170 ++++++ 3 files changed, 719 insertions(+) create mode 100644 kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json create mode 100644 kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json diff --git a/SUPPORTED.md b/SUPPORTED.md index e52697635..451efe689 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -137,6 +137,7 @@ Some newer Kasa devices require authentication. These are marked with *\* - Hardware: 1.0 (EU) / Firmware: 2.8.0\* - Hardware: 1.0 (UK) / Firmware: 2.8.0\* @@ -208,6 +209,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **H100** - Hardware: 1.0 (EU) / Firmware: 1.2.3 + - Hardware: 1.0 (EU) / Firmware: 1.5.10 - Hardware: 1.0 (EU) / Firmware: 1.5.5 ### Hub-Connected Devices diff --git a/kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json b/kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json new file mode 100644 index 000000000..021309c78 --- /dev/null +++ b/kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json @@ -0,0 +1,547 @@ +{ + "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": "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": "child_device", + "ver_code": 1 + }, + { + "id": "child_quick_setup", + "ver_code": 1 + }, + { + "id": "child_inherit", + "ver_code": 1 + }, + { + "id": "control_child", + "ver_code": 1 + }, + { + "id": "alarm", + "ver_code": 1 + }, + { + "id": "device_load", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "alarm_logs", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 3 + }, + { + "id": "chime", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "H100(EU)", + "device_type": "SMART.TAPOHUB", + "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_alarm_configure": { + "duration": 10, + "type": "Alarm 1", + "volume": "high" + }, + "get_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 5, + "category": "subg.trv", + "child_protection": false, + "current_temp": 22.9, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "frost_protection_on": false, + "fw_ver": "2.4.0 Build 230804 Rel.193040", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "location": "", + "mac": "A842A1000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -7, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "target_temp": 23.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 62, + "current_humidity_exception": 2, + "current_temp": 24.0, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.7.0 Build 230424 Rel.170332", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -115, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1706990901, + "mac": "F0A731000000", + "model": "T315", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 16, + "rssi": -38, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "hub", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.5.10 Build 240207 Rel.175759", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "in_alarm_source": "", + "ip": "127.0.0.123", + "lang": "de_DE", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "H100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -60, + "signal_level": 2, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOHUB" + }, + "get_device_load_info": { + "cur_load_num": 4, + "load_level": "light", + "max_load_num": 64, + "total_memory": 4352, + "used_memory": 1451 + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1714669215 + }, + "get_device_usage": {}, + "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.5.10 Build 240207 Rel.175759", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "never", + "led_status": false, + "night_mode": { + "end_time": 358, + "night_mode_type": "sunrise_sunset", + "start_time": 1259, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_support_alarm_type_list": { + "alarm_type_list": [ + "Doorbell Ring 1", + "Doorbell Ring 2", + "Doorbell Ring 3", + "Doorbell Ring 4", + "Doorbell Ring 5", + "Doorbell Ring 6", + "Doorbell Ring 7", + "Doorbell Ring 8", + "Doorbell Ring 9", + "Doorbell Ring 10", + "Phone Ring", + "Alarm 1", + "Alarm 2", + "Alarm 3", + "Alarm 4", + "Dripping Tap", + "Alarm 5", + "Connection 1", + "Connection 2" + ] + }, + "get_support_child_device_category": { + "device_category_list": [ + { + "category": "subg.trv" + }, + { + "category": "subg.trigger" + }, + { + "category": "subg.plugswitch" + } + ] + }, + "get_wireless_scan_info": { + "ap_list": [], + "start_index": 0, + "sum": 0, + "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 + }, + { + "id": "matter", + "ver_code": 3 + } + ], + "extra_info": { + "device_model": "H100", + "device_type": "SMART.TAPOHUB", + "is_klap": false + } + } +} diff --git a/kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json b/kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json new file mode 100644 index 000000000..cd3a241ee --- /dev/null +++ b/kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json @@ -0,0 +1,170 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 5, + "category": "subg.trv", + "child_protection": false, + "current_temp": 22.9, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "frost_protection_on": false, + "fw_ver": "2.4.0 Build 230804 Rel.193040", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1713888871, + "location": "", + "mac": "A842A1000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -7, + "signal_level": 3, + "specs": "EU", + "status": "online", + "target_temp": 23.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2024-02-05", + "release_note": "Modifications and Bug Fixes:\n1. Optimized the noise issue in some cases.\n2. Fixed some minor bugs.", + "type": 2 + } +} From 7f98acd477fdfa68ce26eacbc733926797689bc0 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 7 May 2024 20:56:24 +0200 Subject: [PATCH 423/892] Add 'battery_percentage' only when it's available (#906) At least some firmware versions of T110 are known not to report this. --- kasa/smart/modules/battery.py | 39 +++++++++++++++++------------------ 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/kasa/smart/modules/battery.py b/kasa/smart/modules/battery.py index 6f914bdf2..415e47d1e 100644 --- a/kasa/smart/modules/battery.py +++ b/kasa/smart/modules/battery.py @@ -2,14 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - class BatterySensor(SmartModule): """Implementation of battery module.""" @@ -17,23 +12,11 @@ class BatterySensor(SmartModule): REQUIRED_COMPONENT = "battery_detect" QUERY_GETTER_NAME = "get_battery_detect_info" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features.""" self._add_feature( Feature( - device, - "battery_level", - "Battery level", - container=self, - attribute_getter="battery", - icon="mdi:battery", - unit="%", - category=Feature.Category.Info, - ) - ) - self._add_feature( - Feature( - device, + self._device, "battery_low", "Battery low", container=self, @@ -44,6 +27,22 @@ def __init__(self, device: SmartDevice, module: str): ) ) + # Some devices, like T110 contact sensor do not report the battery percentage + if "battery_percentage" in self._device.sys_info: + self._add_feature( + Feature( + self._device, + "battery_level", + "Battery level", + container=self, + attribute_getter="battery", + icon="mdi:battery", + unit="%", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + @property def battery(self): """Return battery level.""" From 353e84438c4e7a323ca948146271f743d9772b7d Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 7 May 2024 20:58:18 +0200 Subject: [PATCH 424/892] Add support for contact sensor (T110) (#877) Initial support for T110 contact sensor & T110 fixture by courtesy of @ngaertner. --- README.md | 2 +- SUPPORTED.md | 2 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/contact.py | 42 ++ kasa/smart/smartchilddevice.py | 1 + kasa/smart/smartdevice.py | 5 +- kasa/smart/smartmodule.py | 18 +- kasa/tests/device_fixtures.py | 2 +- .../smart/child/T110(EU)_1.0_1.8.0.json | 526 ++++++++++++++++++ kasa/tests/smart/modules/test_contact.py | 29 + 10 files changed, 621 insertions(+), 8 deletions(-) create mode 100644 kasa/smart/modules/contact.py create mode 100644 kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json create mode 100644 kasa/tests/smart/modules/test_contact.py diff --git a/README.md b/README.md index 85fc6982b..42ecaaa8a 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ The following devices have been tested and confirmed as working. If your device - **Bulbs**: L510B, L510E, L530E - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Hubs**: H100 -- **Hub-Connected Devices\*\*\***: T300, T310, T315 +- **Hub-Connected Devices\*\*\***: T110, T300, T310, T315 \*   Model requires authentication
diff --git a/SUPPORTED.md b/SUPPORTED.md index 451efe689..f3c505e4c 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -214,6 +214,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros ### Hub-Connected Devices +- **T110** + - Hardware: 1.0 (EU) / Firmware: 1.8.0 - **T300** - Hardware: 1.0 (EU) / Firmware: 1.7.0 - **T310** diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 647220791..b0956b80e 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -8,6 +8,7 @@ from .cloudmodule import CloudModule from .colormodule import ColorModule from .colortemp import ColorTemperatureModule +from .contact import ContactSensor from .devicemodule import DeviceModule from .energymodule import EnergyModule from .fanmodule import FanModule @@ -45,5 +46,6 @@ "ColorTemperatureModule", "ColorModule", "WaterleakSensor", + "ContactSensor", "FrostProtectionModule", ] diff --git a/kasa/smart/modules/contact.py b/kasa/smart/modules/contact.py new file mode 100644 index 000000000..7932a081d --- /dev/null +++ b/kasa/smart/modules/contact.py @@ -0,0 +1,42 @@ +"""Implementation of contact sensor module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...feature import Feature +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class ContactSensor(SmartModule): + """Implementation of contact sensor module.""" + + REQUIRED_COMPONENT = None # we depend on availability of key + REQUIRED_KEY_ON_PARENT = "open" + + def __init__(self, device: SmartDevice, module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + id="is_open", + name="Open", + container=self, + attribute_getter="is_open", + icon="mdi:door", + category=Feature.Category.Primary, + type=Feature.Type.BinarySensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def is_open(self): + """Return True if the contact sensor is open.""" + return self._device.sys_info["open"] diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 7f747b846..d841d2d9d 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -49,6 +49,7 @@ def device_type(self) -> DeviceType: """Return child device type.""" child_device_map = { "plug.powerstrip.sub-plug": DeviceType.Plug, + "subg.trigger.contact-sensor": DeviceType.Sensor, "subg.trigger.temp-hmdt-sensor": DeviceType.Sensor, "subg.trigger.water-leak-sensor": DeviceType.Sensor, "kasa.switch.outlet.sub-fan": DeviceType.Fan, diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 898133878..68b08902e 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -210,7 +210,10 @@ async def _initialize_modules(self): skip_parent_only_modules and mod in WALL_SWITCH_PARENT_ONLY_MODULES ) or mod.__name__ in child_modules_to_skip: continue - if mod.REQUIRED_COMPONENT in self._components: + if ( + mod.REQUIRED_COMPONENT in self._components + or self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None + ): _LOGGER.debug( "Found required %s, adding %s to modules.", mod.REQUIRED_COMPONENT, diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 9169b752a..e78f43933 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -18,8 +18,13 @@ class SmartModule(Module): """Base class for SMART modules.""" NAME: str - REQUIRED_COMPONENT: str + #: Module is initialized, if the given component is available + REQUIRED_COMPONENT: str | None = None + #: Module is initialized, if the given key available in the main sysinfo + REQUIRED_KEY_ON_PARENT: str | None = None + #: Query to execute during the main update cycle QUERY_GETTER_NAME: str + REGISTERED_MODULES: dict[str, type[SmartModule]] = {} def __init__(self, device: SmartDevice, module: str): @@ -27,8 +32,6 @@ def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) def __init_subclass__(cls, **kwargs): - assert cls.REQUIRED_COMPONENT is not None # noqa: S101 - name = getattr(cls, "NAME", cls.__name__) _LOGGER.debug("Registering %s" % cls) cls.REGISTERED_MODULES[name] = cls @@ -91,8 +94,13 @@ def data(self): @property def supported_version(self) -> int: - """Return version supported by the device.""" - return self._device._components[self.REQUIRED_COMPONENT] + """Return version supported by the device. + + If the module has no required component, this will return -1. + """ + if self.REQUIRED_COMPONENT is not None: + return self._device._components[self.REQUIRED_COMPONENT] + return -1 async def _check_supported(self) -> bool: """Additional check to see if the module is supported by the device. diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 92a86b6f0..826465e5e 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -109,7 +109,7 @@ } HUBS_SMART = {"H100", "KH100"} -SENSORS_SMART = {"T310", "T315", "T300"} +SENSORS_SMART = {"T310", "T315", "T300", "T110"} THERMOSTATS_SMART = {"KE100"} WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} diff --git a/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json b/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json new file mode 100644 index 000000000..acf7ae889 --- /dev/null +++ b/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json @@ -0,0 +1,526 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor_t110", + "bind_count": 1, + "category": "subg.trigger.contact-sensor", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.8.0 Build 220728 Rel.160024", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1714661626, + "mac": "E4FAC4000000", + "model": "T110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "open": false, + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 16, + "rssi": -54, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 30, + "reboot_time": 5, + "status": 4, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_ver": "1.9.0 Build 230704 Rel.154531", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-10-30", + "release_note": "Modifications and Bug Fixes:\n1. Reduced power consumption.\n2. Fixed some minor bugs.", + "type": 2 + }, + "get_temp_humidity_records": { + "local_time": 1714681046, + "past24h_humidity": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_humidity_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "close", + "eventId": "8140289c-c66b-bdd6-63b9-542299442299", + "id": 4, + "timestamp": 1714661714 + }, + { + "event": "open", + "eventId": "fb4e1439-2f2c-a5e1-c35a-9e7c0d35a1e3", + "id": 3, + "timestamp": 1714661710 + }, + { + "event": "close", + "eventId": "ddee7733-1180-48ac-56a3-512018048ac5", + "id": 2, + "timestamp": 1714661657 + }, + { + "event": "open", + "eventId": "ab80951f-da38-49f9-21c5-bf025c7b606d", + "id": 1, + "timestamp": 1714661638 + } + ], + "start_id": 4, + "sum": 4 + } +} diff --git a/kasa/tests/smart/modules/test_contact.py b/kasa/tests/smart/modules/test_contact.py new file mode 100644 index 000000000..fc3375450 --- /dev/null +++ b/kasa/tests/smart/modules/test_contact.py @@ -0,0 +1,29 @@ +import pytest + +from kasa import SmartDevice +from kasa.smart.modules import ContactSensor +from kasa.tests.device_fixtures import parametrize + +contact = parametrize( + "is contact sensor", model_filter="T110", protocol_filter={"SMART.CHILD"} +) + + +@contact +@pytest.mark.parametrize( + "feature, type", + [ + ("is_open", bool), + ], +) +async def test_contact_features(dev: SmartDevice, feature, type): + """Test that features are registered and work as expected.""" + contact = dev.get_module(ContactSensor) + assert contact is not None + + prop = getattr(contact, feature) + assert isinstance(prop, type) + + feat = contact._module_features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) From 1e8e289ac7fa49798534bae4da567fb6ecaf74bf Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 8 May 2024 15:25:22 +0200 Subject: [PATCH 425/892] Move contribution instructions into docs (#901) Moves the instructions away from README.md to keep it simpler, and extend the documentation to be up-to-date and easier to approach. --------- Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --- CONTRIBUTING.md | 4 ++ README.md | 36 ++-------------- docs/source/contribute.md | 86 +++++++++++++++++++++++++++++++++++++++ docs/source/index.rst | 1 + 4 files changed, 94 insertions(+), 33 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/source/contribute.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..1f4005438 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,4 @@ +# Contributing to python-kasa + +All types of contributions are very welcome. +To make the process as straight-forward as possible, we have written [some instructions in our docs](https://python-miio.readthedocs.io/en/latest/contribute.html) to get you started. diff --git a/README.md b/README.md index 42ecaaa8a..6c4cfcce1 100644 --- a/README.md +++ b/README.md @@ -185,42 +185,12 @@ The device type specific documentation can be found in their separate pages: ## Contributing -Contributions are very welcome! To simplify the process, we are leveraging automated checks and tests for contributions. - -### Setting up development environment - -To get started, simply clone this repository and initialize the development environment. -We are using [poetry](https://python-poetry.org) for dependency management, so after cloning the repository simply execute -`poetry install` which will install all necessary packages and create a virtual environment for you. - -### Code-style checks - -We use several tools to automatically check all contributions. The simplest way to verify that everything is formatted properly -before creating a pull request, consider activating the pre-commit hooks by executing `pre-commit install`. -This will make sure that the checks are passing when you do a commit. - -You can also execute the checks by running either `tox -e lint` to only do the linting checks, or `tox` to also execute the tests. - -### Running tests - -You can run tests on the library by executing `pytest` in the source directory. -This will run the tests against contributed example responses, but you can also execute the tests against a real device: -``` -$ pytest --ip
-``` -Note that this will perform state changes on the device. - -### Analyzing network captures - -The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. -After capturing the traffic, you can either use the [softScheck's wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) -or the `parse_pcap.py` script contained inside the `devtools` directory. -Note, that this works currently only on kasa-branded devices which use port 9999 for communications. - +Contributions are very welcome! The easiest way to contribute is by [creating a fixture file](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) for the automated test suite if your device hardware and firmware version is not currently listed as supported. +Please refer to [our contributing guidelines](https://python-kasa.readthedocs.io/en/latest/contribute.html). ## Supported devices -The following devices have been tested and confirmed as working. 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). +The following devices have been tested and confirmed as working. If your device is unlisted but working, please consider [contributing a fixture file](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files). diff --git a/docs/source/contribute.md b/docs/source/contribute.md new file mode 100644 index 000000000..67291eba1 --- /dev/null +++ b/docs/source/contribute.md @@ -0,0 +1,86 @@ +# Contributing + +You probably arrived to this page as you are interested in contributing to python-kasa in some form? +All types of contributions are very welcome, so thank you! +This page aims to help you to get started. + +```{contents} Contents + :local: +``` + +## Setting up the development environment + +To get started, simply clone this repository and initialize the development environment. +We are using [poetry](https://python-poetry.org) for dependency management, so after cloning the repository simply execute +`poetry install` which will install all necessary packages and create a virtual environment for you. + +``` +$ git clone https://github.com/python-kasa/python-kasa.git +$ cd python-kasa +$ poetry install +``` + +## Code-style checks + +We use several tools to automatically check all contributions as part of our CI pipeline. +The simplest way to verify that everything is formatted properly +before creating a pull request, consider activating the pre-commit hooks by executing `pre-commit install`. +This will make sure that the checks are passing when you do a commit. + +```{note} +You can also execute the pre-commit hooks on all files by executing `pre-commit run -a` +``` + +## Running tests + +You can run tests on the library by executing `pytest` in the source directory: + +``` +$ poetry run pytest kasa +``` + +This will run the tests against the contributed example responses. + +```{note} +You can also execute the tests against a real device using `pytest --ip
`. +Note that this will perform state changes on the device. +``` + +## Analyzing network captures + +The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. +After capturing the traffic, you can either use the [softScheck's wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) +or the `parse_pcap.py` script contained inside the `devtools` directory. +Note, that this works currently only on kasa-branded devices which use port 9999 for communications. + +## Contributing fixture files + +One of the easiest ways to contribute is by creating a fixture file and uploading it for us. +These files will help us to improve the library and run tests against devices that we have no access to. + +This library is tested against responses from real devices ("fixture files"). +These files contain responses for selected, known device commands and are stored [in our test suite](https://github.com/python-kasa/python-kasa/tree/master/kasa/tests/fixtures). + +You can generate these files by using the `dump_devinfo.py` script. +Note, that this script should be run inside the main source directory so that the generated files are stored in the correct directories. +The easiest way to do that is by doing: + +``` +$ git clone https://github.com/python-kasa/python-kasa.git +$ cd python-kasa +$ poetry install +$ poetry shell +$ python -m devtools.dump_devinfo --username --password --host 192.168.1.123 +``` + +```{note} +You can also execute the script against a network by using `--target`: `python -m devtools.dump_devinfo --target network 192.168.1.255` +``` + +The script will run queries against the device, and prompt at the end if you want to save the results. +If you choose to do so, it will save the fixture files directly in their correct place to make it easy to create a pull request. + +```{note} +When adding new fixture files, you should run `pre-commit run -a` to re-generate the list of supported devices. +You may need to adjust `device_fixtures.py` to add a new model into the correct device categories. Verify that test pass by executing `poetry run pytest kasa`. +``` diff --git a/docs/source/index.rst b/docs/source/index.rst index 9dc648a9c..f5baf3894 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -10,6 +10,7 @@ discover smartdevice design + contribute smartbulb smartplug smartdimmer From 7d4dc4c710d08d415ddb70cae4a7206784a66222 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 9 May 2024 01:43:07 +0200 Subject: [PATCH 426/892] Improve smartdevice update module (#791) * Expose current and latest firmware as features * Provide API to get information about available firmware updates (e.g., changelog, release date etc.) * Implement updating the firmware --- kasa/smart/modules/firmware.py | 116 ++++++++++++++++++++-- kasa/tests/fakeprotocol_smart.py | 2 +- kasa/tests/smart/modules/test_firmware.py | 108 ++++++++++++++++++++ 3 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 kasa/tests/smart/modules/test_firmware.py diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 626add0f6..430515e4b 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -2,9 +2,14 @@ from __future__ import annotations +import asyncio +import logging from datetime import date -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional +# 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 pydantic.v1 import BaseModel, Field, validator from ...exceptions import SmartErrorCode @@ -15,11 +20,27 @@ from ..smartdevice import SmartDevice +_LOGGER = logging.getLogger(__name__) + + +class DownloadState(BaseModel): + """Download state.""" + + # Example: + # {'status': 0, 'download_progress': 0, 'reboot_time': 5, + # 'upgrade_time': 5, 'auto_upgrade': False} + status: int + progress: int = Field(alias="download_progress") + reboot_time: int + upgrade_time: int + auto_upgrade: bool + + class UpdateInfo(BaseModel): """Update info status object.""" status: int = Field(alias="type") - fw_ver: Optional[str] = None # noqa: UP007 + version: Optional[str] = Field(alias="fw_ver", default=None) # noqa: UP007 release_date: Optional[date] = None # noqa: UP007 release_notes: Optional[str] = Field(alias="release_note", default=None) # noqa: UP007 fw_size: Optional[int] = None # noqa: UP007 @@ -71,6 +92,26 @@ def __init__(self, device: SmartDevice, module: str): category=Feature.Category.Info, ) ) + self._add_feature( + Feature( + device, + id="current_firmware_version", + name="Current firmware version", + container=self, + attribute_getter="current_firmware", + category=Feature.Category.Debug, + ) + ) + self._add_feature( + Feature( + device, + id="available_firmware_version", + name="Available firmware version", + container=self, + attribute_getter="latest_firmware", + category=Feature.Category.Debug, + ) + ) def query(self) -> dict: """Query to execute during the update cycle.""" @@ -80,7 +121,17 @@ def query(self) -> dict: return req @property - def latest_firmware(self): + def current_firmware(self) -> str: + """Return the current firmware version.""" + return self._device.hw_info["sw_ver"] + + @property + def latest_firmware(self) -> str: + """Return the latest firmware version.""" + return self.firmware_update_info.version + + @property + def firmware_update_info(self): """Return latest firmware information.""" fw = self.data.get("get_latest_fw") or self.data if not self._device.is_cloud_connected or isinstance(fw, SmartErrorCode): @@ -94,15 +145,62 @@ def update_available(self) -> bool | None: """Return True if update is available.""" if not self._device.is_cloud_connected: return None - return self.latest_firmware.update_available + return self.firmware_update_info.update_available - async def get_update_state(self): + async def get_update_state(self) -> DownloadState: """Return update state.""" - return await self.call("get_fw_download_state") + resp = await self.call("get_fw_download_state") + state = resp["get_fw_download_state"] + return DownloadState(**state) - async def update(self): + async def update( + self, progress_cb: Callable[[DownloadState], Coroutine] | None = None + ): """Update the device firmware.""" - return await self.call("fw_download") + current_fw = self.current_firmware + _LOGGER.info( + "Going to upgrade from %s to %s", + current_fw, + self.firmware_update_info.version, + ) + await self.call("fw_download") + + # TODO: read timeout from get_auto_update_info or from get_fw_download_state? + async with asyncio_timeout(60 * 5): + while True: + await asyncio.sleep(0.5) + try: + state = await self.get_update_state() + except Exception as ex: + _LOGGER.warning( + "Got exception, maybe the device is rebooting? %s", ex + ) + continue + + _LOGGER.debug("Update state: %s" % state) + if progress_cb is not None: + asyncio.create_task(progress_cb(state)) + + if state.status == 0: + _LOGGER.info( + "Update idle, hopefully updated to %s", + self.firmware_update_info.version, + ) + break + elif state.status == 2: + _LOGGER.info("Downloading firmware, progress: %s", state.progress) + elif state.status == 3: + upgrade_sleep = state.upgrade_time + _LOGGER.info( + "Flashing firmware, sleeping for %s before checking status", + upgrade_sleep, + ) + await asyncio.sleep(upgrade_sleep) + elif state.status < 0: + _LOGGER.error("Got error: %s", state.status) + break + else: + _LOGGER.warning("Unhandled state code: %s", state) @property def auto_update_enabled(self): @@ -115,4 +213,4 @@ def auto_update_enabled(self): async def set_auto_update_enabled(self, enabled: bool): """Change autoupdate setting.""" data = {**self.data["get_auto_update_info"], "enable": enabled} - await self.call("set_auto_update_info", data) # {"enable": enabled}) + await self.call("set_auto_update_info", data) diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index ae1a7ad66..5ca4a8ae1 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -234,7 +234,7 @@ def _send_request(self, request_dict: dict): pytest.fixtures_missing_methods[self.fixture_name] = set() pytest.fixtures_missing_methods[self.fixture_name].add(method) return retval - elif method == "set_qs_info": + elif method in ["set_qs_info", "fw_download"]: return {"error_code": 0} elif method == "set_dynamic_light_effect_rule_enable": self._set_light_effect(info, params) diff --git a/kasa/tests/smart/modules/test_firmware.py b/kasa/tests/smart/modules/test_firmware.py new file mode 100644 index 000000000..d0df87ca5 --- /dev/null +++ b/kasa/tests/smart/modules/test_firmware.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import asyncio +import logging + +import pytest +from pytest_mock import MockerFixture + +from kasa.smart import SmartDevice +from kasa.smart.modules import Firmware +from kasa.smart.modules.firmware import DownloadState +from kasa.tests.device_fixtures import parametrize + +firmware = parametrize( + "has firmware", component_filter="firmware", protocol_filter={"SMART"} +) + + +@firmware +@pytest.mark.parametrize( + "feature, prop_name, type, required_version", + [ + ("auto_update_enabled", "auto_update_enabled", bool, 2), + ("update_available", "update_available", bool, 1), + ("update_available", "update_available", bool, 1), + ("current_firmware_version", "current_firmware", str, 1), + ("available_firmware_version", "latest_firmware", str, 1), + ], +) +async def test_firmware_features( + dev: SmartDevice, feature, prop_name, type, required_version, mocker: MockerFixture +): + """Test light effect.""" + fw = dev.get_module(Firmware) + assert fw + + if not dev.is_cloud_connected: + pytest.skip("Device is not cloud connected, skipping test") + + if fw.supported_version < required_version: + pytest.skip("Feature %s requires newer version" % feature) + + prop = getattr(fw, prop_name) + assert isinstance(prop, type) + + feat = fw._module_features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@firmware +async def test_update_available_without_cloud(dev: SmartDevice): + """Test that update_available returns None when disconnected.""" + fw = dev.get_module(Firmware) + assert fw + + if dev.is_cloud_connected: + assert isinstance(fw.update_available, bool) + else: + assert fw.update_available is None + + +@firmware +async def test_firmware_update( + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """Test updating firmware.""" + caplog.set_level(logging.INFO) + + fw = dev.get_module(Firmware) + assert fw + + upgrade_time = 5 + extras = {"reboot_time": 5, "upgrade_time": upgrade_time, "auto_upgrade": False} + update_states = [ + # Unknown 1 + DownloadState(status=1, download_progress=0, **extras), + # Downloading + DownloadState(status=2, download_progress=10, **extras), + DownloadState(status=2, download_progress=100, **extras), + # Flashing + DownloadState(status=3, download_progress=100, **extras), + DownloadState(status=3, download_progress=100, **extras), + # Done + DownloadState(status=0, download_progress=100, **extras), + ] + + asyncio_sleep = asyncio.sleep + sleep = mocker.patch("asyncio.sleep") + mocker.patch.object(fw, "get_update_state", side_effect=update_states) + + cb_mock = mocker.AsyncMock() + + await fw.update(progress_cb=cb_mock) + + # This is necessary to allow the eventloop to process the created tasks + await asyncio_sleep(0) + + assert "Unhandled state code" in caplog.text + assert "Downloading firmware, progress: 10" in caplog.text + assert "Flashing firmware, sleeping" in caplog.text + assert "Update idle" in caplog.text + + for state in update_states: + cb_mock.assert_any_await(state) + + # sleep based on the upgrade_time + sleep.assert_any_call(upgrade_time) From 9473d97ad2b5cb8645df1c06c3dbb477817fee9a Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 10 May 2024 19:29:28 +0100 Subject: [PATCH 427/892] Create common interfaces for remaining device types (#895) Introduce common module interfaces across smart and iot devices and provide better typing implementation for getting modules to support this. --- .pre-commit-config.yaml | 5 + devtools/create_module_fixtures.py | 2 +- kasa/__init__.py | 6 +- kasa/bulb.py | 5 - kasa/device.py | 21 ++-- kasa/interfaces/led.py | 38 ++++++++ kasa/interfaces/lighteffect.py | 80 +++++++++++++++ kasa/{ => iot}/effects.py | 0 kasa/iot/iotdevice.py | 53 ++++------ kasa/iot/iotlightstrip.py | 30 +++--- kasa/iot/iotmodule.py | 10 +- kasa/iot/iotplug.py | 27 +----- kasa/iot/iotstrip.py | 1 - kasa/iot/modules/__init__.py | 2 + kasa/iot/modules/ledmodule.py | 32 ++++++ kasa/iot/modules/lighteffectmodule.py | 97 +++++++++++++++++++ kasa/module.py | 62 +++++++++++- kasa/modulemapping.py | 25 +++++ kasa/modulemapping.pyi | 96 ++++++++++++++++++ kasa/plug.py | 12 --- kasa/smart/modules/ledmodule.py | 27 +----- kasa/smart/modules/lighteffectmodule.py | 45 +++++---- kasa/smart/smartdevice.py | 45 ++++----- kasa/tests/fakeprotocol_smart.py | 12 ++- kasa/tests/smart/features/test_brightness.py | 2 +- kasa/tests/smart/modules/test_contact.py | 5 +- kasa/tests/smart/modules/test_fan.py | 8 +- kasa/tests/smart/modules/test_firmware.py | 8 +- kasa/tests/smart/modules/test_light_effect.py | 7 +- kasa/tests/test_common_modules.py | 95 ++++++++++++++++++ kasa/tests/test_iotdevice.py | 13 ++- kasa/tests/test_lightstrip.py | 3 +- kasa/tests/test_smartdevice.py | 19 ++-- 33 files changed, 673 insertions(+), 220 deletions(-) create mode 100644 kasa/interfaces/led.py create mode 100644 kasa/interfaces/lighteffect.py rename kasa/{ => iot}/effects.py (100%) create mode 100644 kasa/iot/modules/ledmodule.py create mode 100644 kasa/iot/modules/lighteffectmodule.py create mode 100644 kasa/modulemapping.py create mode 100644 kasa/modulemapping.pyi delete mode 100644 kasa/plug.py create mode 100644 kasa/tests/test_common_modules.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8c0438d9b..c274bb979 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,6 +21,11 @@ repos: hooks: - id: mypy additional_dependencies: [types-click] + exclude: | + (?x)^( + kasa/modulemapping\.py| + )$ + - repo: https://github.com/PyCQA/doc8 rev: 'v1.1.1' diff --git a/devtools/create_module_fixtures.py b/devtools/create_module_fixtures.py index 8372bfff5..ed881a88b 100644 --- a/devtools/create_module_fixtures.py +++ b/devtools/create_module_fixtures.py @@ -19,7 +19,7 @@ def create_fixtures(dev: IotDevice, outputdir: Path): """Iterate over supported modules and create version-specific fixture files.""" for name, module in dev.modules.items(): - module_dir = outputdir / name + module_dir = outputdir / str(name) if not module_dir.exists(): module_dir.mkdir(exist_ok=True, parents=True) diff --git a/kasa/__init__.py b/kasa/__init__.py index 62d545025..e9f64c708 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING from warnings import warn -from kasa.bulb import Bulb +from kasa.bulb import Bulb, BulbPreset from kasa.credentials import Credentials from kasa.device import Device from kasa.device_type import DeviceType @@ -36,12 +36,11 @@ UnsupportedDeviceError, ) from kasa.feature import Feature -from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.iotprotocol import ( IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 ) -from kasa.plug import Plug +from kasa.module import Module from kasa.protocol import BaseProtocol from kasa.smartprotocol import SmartProtocol @@ -62,6 +61,7 @@ "Device", "Bulb", "Plug", + "Module", "KasaException", "AuthenticationError", "DeviceError", diff --git a/kasa/bulb.py b/kasa/bulb.py index 01065dc09..52a722d92 100644 --- a/kasa/bulb.py +++ b/kasa/bulb.py @@ -54,11 +54,6 @@ def _raise_for_invalid_brightness(self, value): def is_color(self) -> bool: """Whether the bulb supports color changes.""" - @property - @abstractmethod - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - @property @abstractmethod def is_variable_color_temp(self) -> bool: diff --git a/kasa/device.py b/kasa/device.py index ea358a8de..8150352d9 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import Any, Mapping, Sequence, overload +from typing import TYPE_CHECKING, Any, Mapping, Sequence from .credentials import Credentials from .device_type import DeviceType @@ -15,10 +15,13 @@ from .exceptions import KasaException from .feature import Feature from .iotprotocol import IotProtocol -from .module import Module, ModuleT +from .module import Module from .protocol import BaseProtocol from .xortransport import XorTransport +if TYPE_CHECKING: + from .modulemapping import ModuleMapping + @dataclass class WifiNetwork: @@ -113,21 +116,9 @@ async def disconnect(self): @property @abstractmethod - def modules(self) -> Mapping[str, Module]: + def modules(self) -> ModuleMapping[Module]: """Return the device modules.""" - @overload - @abstractmethod - def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... - - @overload - @abstractmethod - def get_module(self, module_type: str) -> Module | None: ... - - @abstractmethod - def get_module(self, module_type: type[ModuleT] | str) -> ModuleT | Module | None: - """Return the module from the device modules or None if not present.""" - @property @abstractmethod def is_on(self) -> bool: diff --git a/kasa/interfaces/led.py b/kasa/interfaces/led.py new file mode 100644 index 000000000..2ddba00c2 --- /dev/null +++ b/kasa/interfaces/led.py @@ -0,0 +1,38 @@ +"""Module for base light effect module.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from ..feature import Feature +from ..module import Module + + +class Led(Module, ABC): + """Base interface to represent a LED module.""" + + def _initialize_features(self): + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device=device, + container=self, + name="LED", + id="led", + icon="mdi:led", + attribute_getter="led", + attribute_setter="set_led", + type=Feature.Type.Switch, + category=Feature.Category.Config, + ) + ) + + @property + @abstractmethod + def led(self) -> bool: + """Return current led status.""" + + @abstractmethod + async def set_led(self, enable: bool) -> None: + """Set led.""" diff --git a/kasa/interfaces/lighteffect.py b/kasa/interfaces/lighteffect.py new file mode 100644 index 000000000..0eb11b5b4 --- /dev/null +++ b/kasa/interfaces/lighteffect.py @@ -0,0 +1,80 @@ +"""Module for base light effect module.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from ..feature import Feature +from ..module import Module + + +class LightEffect(Module, ABC): + """Interface to represent a light effect module.""" + + LIGHT_EFFECTS_OFF = "Off" + + def _initialize_features(self): + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device, + id="light_effect", + name="Light effect", + container=self, + attribute_getter="effect", + attribute_setter="set_effect", + category=Feature.Category.Primary, + type=Feature.Type.Choice, + choices_getter="effect_list", + ) + ) + + @property + @abstractmethod + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + + @property + @abstractmethod + def effect(self) -> str: + """Return effect state or name.""" + + @property + @abstractmethod + def effect_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + + @abstractmethod + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> None: + """Set an effect on the device. + + If brightness or transition is defined, + its value will be used instead of the effect-specific default. + + See :meth:`effect_list` for available effects, + or use :meth:`set_custom_effect` for custom effects. + + :param str effect: The effect to set + :param int brightness: The wanted brightness + :param int transition: The wanted transition time + """ + + async def set_custom_effect( + self, + effect_dict: dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ diff --git a/kasa/effects.py b/kasa/iot/effects.py similarity index 100% rename from kasa/effects.py rename to kasa/iot/effects.py diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 29ba31554..762fc06cd 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -19,14 +19,15 @@ import inspect import logging from datetime import datetime, timedelta -from typing import Any, Mapping, Sequence, cast, overload +from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import KasaException from ..feature import Feature -from ..module import ModuleT +from ..module import Module +from ..modulemapping import ModuleMapping, ModuleName from ..protocol import BaseProtocol from .iotmodule import IotModule from .modules import Emeter, Time @@ -190,7 +191,7 @@ def __init__( self._supported_modules: dict[str, IotModule] | None = None self._legacy_features: set[str] = set() self._children: Mapping[str, IotDevice] = {} - self._modules: dict[str, IotModule] = {} + self._modules: dict[str | ModuleName[Module], IotModule] = {} @property def children(self) -> Sequence[IotDevice]: @@ -198,38 +199,20 @@ def children(self) -> Sequence[IotDevice]: return list(self._children.values()) @property - def modules(self) -> dict[str, IotModule]: + def modules(self) -> ModuleMapping[IotModule]: """Return the device modules.""" + if TYPE_CHECKING: + return cast(ModuleMapping[IotModule], self._modules) return self._modules - @overload - def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... - - @overload - def get_module(self, module_type: str) -> IotModule | None: ... - - def get_module( - self, module_type: type[ModuleT] | str - ) -> ModuleT | IotModule | None: - """Return the module from the device modules or None if not present.""" - if isinstance(module_type, str): - module_name = module_type.lower() - elif issubclass(module_type, IotModule): - module_name = module_type.__name__.lower() - else: - return None - if module_name in self.modules: - return self.modules[module_name] - return None - - def add_module(self, name: str, module: IotModule): + def add_module(self, name: str | ModuleName[Module], module: IotModule): """Register a module.""" if name in self.modules: _LOGGER.debug("Module %s already registered, ignoring..." % name) return _LOGGER.debug("Adding module %s", module) - self.modules[name] = module + self._modules[name] = module def _create_request( self, target: str, cmd: str, arg: dict | None = None, child_ids=None @@ -291,11 +274,11 @@ def features(self) -> dict[str, Feature]: @property # type: ignore @requires_update - def supported_modules(self) -> list[str]: + def supported_modules(self) -> list[str | ModuleName[Module]]: """Return a set of modules supported by the device.""" # TODO: this should rather be called `features`, but we don't want to break # the API now. Maybe just deprecate it and point the users to use this? - return list(self.modules.keys()) + return list(self._modules.keys()) @property # type: ignore @requires_update @@ -324,10 +307,11 @@ async def update(self, update_children: bool = True): self._last_update = response self._set_sys_info(response["system"]["get_sysinfo"]) + await self._modular_update(req) + if not self._features: await self._initialize_features() - await self._modular_update(req) self._set_sys_info(self._last_update["system"]["get_sysinfo"]) async def _initialize_features(self): @@ -352,6 +336,11 @@ async def _initialize_features(self): ) ) + for module in self._modules.values(): + module._initialize_features() + for module_feat in module._module_features.values(): + self._add_feature(module_feat) + async def _modular_update(self, req: dict) -> None: """Execute an update query.""" if self.has_emeter: @@ -364,17 +353,15 @@ async def _modular_update(self, req: dict) -> None: # making separate handling for this unnecessary if self._supported_modules is None: supported = {} - for module in self.modules.values(): + for module in self._modules.values(): if module.is_supported: supported[module._module] = module - for module_feat in module._module_features.values(): - self._add_feature(module_feat) self._supported_modules = supported request_list = [] est_response_size = 1024 if "system" in req else 0 - for module in self.modules.values(): + for module in self._modules.values(): if not module.is_supported: _LOGGER.debug("Module %s not supported, skipping" % module) continue diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 57b3282f7..a120be7a7 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -4,10 +4,12 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 +from ..module import Module from ..protocol import BaseProtocol +from .effects import EFFECT_NAMES_V1 from .iotbulb import IotBulb from .iotdevice import KasaException, requires_update +from .modules.lighteffectmodule import LightEffectModule class IotLightStrip(IotBulb): @@ -54,6 +56,10 @@ def __init__( ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.LightStrip + self.add_module( + Module.LightEffect, + LightEffectModule(self, "smartlife.iot.lighting_effect"), + ) @property # type: ignore @requires_update @@ -73,6 +79,8 @@ def effect(self) -> dict: 'id': '', 'name': ''} """ + # LightEffectModule returns the current effect name + # so return the dict here for backwards compatibility return self.sys_info["lighting_effect_state"] @property # type: ignore @@ -83,6 +91,8 @@ def effect_list(self) -> list[str] | None: Example: ['Aurora', 'Bubbling Cauldron', ...] """ + # LightEffectModule returns effect names along with a LIGHT_EFFECTS_OFF value + # so return the original effect names here for backwards compatibility return EFFECT_NAMES_V1 if self.has_effects else None @requires_update @@ -105,15 +115,9 @@ async def set_effect( :param int brightness: The wanted brightness :param int transition: The wanted transition time """ - if effect not in EFFECT_MAPPING_V1: - raise KasaException(f"The effect {effect} is not a built in effect.") - effect_dict = EFFECT_MAPPING_V1[effect] - if brightness is not None: - effect_dict["brightness"] = brightness - if transition is not None: - effect_dict["transition"] = transition - - await self.set_custom_effect(effect_dict) + await self.modules[Module.LightEffect].set_effect( + effect, brightness=brightness, transition=transition + ) @requires_update async def set_custom_effect( @@ -126,8 +130,4 @@ async def set_custom_effect( """ if not self.has_effects: raise KasaException("Bulb does not support effects.") - await self._query_helper( - "smartlife.iot.lighting_effect", - "set_lighting_effect", - effect_dict, - ) + await self.modules[Module.LightEffect].set_custom_effect(effect_dict) diff --git a/kasa/iot/iotmodule.py b/kasa/iot/iotmodule.py index d8fb4812b..ca0c3adb7 100644 --- a/kasa/iot/iotmodule.py +++ b/kasa/iot/iotmodule.py @@ -43,13 +43,19 @@ def estimated_query_response_size(self): @property def data(self): """Return the module specific raw data from the last update.""" - if self._module not in self._device._last_update: + dev = self._device + q = self.query() + + if not q: + return dev.sys_info + + if self._module not in dev._last_update: raise KasaException( f"You need to call update() prior accessing module data" f" for '{self._module}'" ) - return self._device._last_update[self._module] + return dev._last_update[self._module] @property def is_supported(self) -> bool: diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index dadb38f2a..22238c7a5 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -6,10 +6,10 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature +from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, requires_update -from .modules import Antitheft, Cloud, Schedule, Time, Usage +from .modules import Antitheft, Cloud, LedModule, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) @@ -58,21 +58,7 @@ def __init__( self.add_module("antitheft", Antitheft(self, "anti_theft")) self.add_module("time", Time(self, "time")) self.add_module("cloud", Cloud(self, "cnCloud")) - - async def _initialize_features(self): - await super()._initialize_features() - - self._add_feature( - Feature( - device=self, - id="led", - name="LED", - icon="mdi:led-{state}", - attribute_getter="led", - attribute_setter="set_led", - type=Feature.Type.Switch, - ) - ) + self.add_module(Module.Led, LedModule(self, "system")) @property # type: ignore @requires_update @@ -93,14 +79,11 @@ async def turn_off(self, **kwargs): @requires_update def led(self) -> bool: """Return the state of the led.""" - sys_info = self.sys_info - return bool(1 - sys_info["led_off"]) + return self.modules[Module.Led].led async def set_led(self, state: bool): """Set the state of the led (night mode).""" - return await self._query_helper( - "system", "set_led_off", {"off": int(not state)} - ) + return await self.modules[Module.Led].set_led(state) class IotWallSwitch(IotPlug): diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 9e99a0748..ab14abb0a 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -253,7 +253,6 @@ def __init__(self, host: str, parent: IotStrip, child_id: str) -> None: self._last_update = parent._last_update self._set_sys_info(parent.sys_info) self._device_type = DeviceType.StripSocket - self._modules = {} self.protocol = parent.protocol # Must use the same connection as the parent self.add_module("time", Time(self, "time")) diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index 41e03bbdd..f061e6070 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -5,6 +5,7 @@ from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter +from .ledmodule import LedModule from .motion import Motion from .rulemodule import Rule, RuleModule from .schedule import Schedule @@ -17,6 +18,7 @@ "Cloud", "Countdown", "Emeter", + "LedModule", "Motion", "Rule", "RuleModule", diff --git a/kasa/iot/modules/ledmodule.py b/kasa/iot/modules/ledmodule.py new file mode 100644 index 000000000..6b3c61948 --- /dev/null +++ b/kasa/iot/modules/ledmodule.py @@ -0,0 +1,32 @@ +"""Module for led controls.""" + +from __future__ import annotations + +from ...interfaces.led import Led +from ..iotmodule import IotModule + + +class LedModule(IotModule, Led): + """Implementation of led controls.""" + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def mode(self): + """LED mode setting. + + "always", "never" + """ + return "always" if self.led else "never" + + @property + def led(self) -> bool: + """Return the state of the led.""" + sys_info = self.data + return bool(1 - sys_info["led_off"]) + + async def set_led(self, state: bool): + """Set the state of the led (night mode).""" + return await self.call("set_led_off", {"off": int(not state)}) diff --git a/kasa/iot/modules/lighteffectmodule.py b/kasa/iot/modules/lighteffectmodule.py new file mode 100644 index 000000000..c53de1920 --- /dev/null +++ b/kasa/iot/modules/lighteffectmodule.py @@ -0,0 +1,97 @@ +"""Module for light effects.""" + +from __future__ import annotations + +from ...interfaces.lighteffect import LightEffect +from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 +from ..iotmodule import IotModule + + +class LightEffectModule(IotModule, LightEffect): + """Implementation of dynamic light effects.""" + + @property + def effect(self) -> str: + """Return effect state. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + if ( + (state := self.data.get("lighting_effect_state")) + and state.get("enable") + and (name := state.get("name")) + and name in EFFECT_NAMES_V1 + ): + return name + return self.LIGHT_EFFECTS_OFF + + @property + def effect_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + effect_list = [self.LIGHT_EFFECTS_OFF] + effect_list.extend(EFFECT_NAMES_V1) + return effect_list + + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> None: + """Set an effect on the device. + + If brightness or transition is defined, + its value will be used instead of the effect-specific default. + + See :meth:`effect_list` for available effects, + or use :meth:`set_custom_effect` for custom effects. + + :param str effect: The effect to set + :param int brightness: The wanted brightness + :param int transition: The wanted transition time + """ + if effect == self.LIGHT_EFFECTS_OFF: + effect_dict = dict(self.data["lighting_effect_state"]) + effect_dict["enable"] = 0 + elif effect not in EFFECT_MAPPING_V1: + raise ValueError(f"The effect {effect} is not a built in effect.") + else: + effect_dict = EFFECT_MAPPING_V1[effect] + if brightness is not None: + effect_dict["brightness"] = brightness + if transition is not None: + effect_dict["transition"] = transition + + await self.set_custom_effect(effect_dict) + + async def set_custom_effect( + self, + effect_dict: dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ + return await self.call( + "set_lighting_effect", + effect_dict, + ) + + @property + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + return True + + def query(self): + """Return the base query.""" + return {} diff --git a/kasa/module.py b/kasa/module.py index 3da0c1ad2..b65f0499a 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -6,14 +6,20 @@ from abc import ABC, abstractmethod from typing import ( TYPE_CHECKING, + Final, TypeVar, ) from .exceptions import KasaException from .feature import Feature +from .modulemapping import ModuleName if TYPE_CHECKING: - from .device import Device + from .device import Device as DeviceType # avoid name clash with Device module + from .interfaces.led import Led + from .interfaces.lighteffect import LightEffect + from .iot import modules as iot + from .smart import modules as smart _LOGGER = logging.getLogger(__name__) @@ -27,7 +33,59 @@ class Module(ABC): executed during the regular update cycle. """ - def __init__(self, device: Device, module: str): + # Common Modules + LightEffect: Final[ModuleName[LightEffect]] = ModuleName("LightEffectModule") + Led: Final[ModuleName[Led]] = ModuleName("LedModule") + + # IOT only Modules + IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") + IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft") + IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown") + IotEmeter: Final[ModuleName[iot.Emeter]] = ModuleName("emeter") + IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion") + IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule") + IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage") + IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud") + IotTime: Final[ModuleName[iot.Time]] = ModuleName("time") + + # SMART only Modules + Alarm: Final[ModuleName[smart.AlarmModule]] = ModuleName("AlarmModule") + AutoOff: Final[ModuleName[smart.AutoOffModule]] = ModuleName("AutoOffModule") + BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor") + Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness") + ChildDevice: Final[ModuleName[smart.ChildDeviceModule]] = ModuleName( + "ChildDeviceModule" + ) + Cloud: Final[ModuleName[smart.CloudModule]] = ModuleName("CloudModule") + Color: Final[ModuleName[smart.ColorModule]] = ModuleName("ColorModule") + ColorTemp: Final[ModuleName[smart.ColorTemperatureModule]] = ModuleName( + "ColorTemperatureModule" + ) + ContactSensor: Final[ModuleName[smart.ContactSensor]] = ModuleName("ContactSensor") + Device: Final[ModuleName[smart.DeviceModule]] = ModuleName("DeviceModule") + Energy: Final[ModuleName[smart.EnergyModule]] = ModuleName("EnergyModule") + Fan: Final[ModuleName[smart.FanModule]] = ModuleName("FanModule") + Firmware: Final[ModuleName[smart.Firmware]] = ModuleName("Firmware") + FrostProtection: Final[ModuleName[smart.FrostProtectionModule]] = ModuleName( + "FrostProtectionModule" + ) + Humidity: Final[ModuleName[smart.HumiditySensor]] = ModuleName("HumiditySensor") + LightTransition: Final[ModuleName[smart.LightTransitionModule]] = ModuleName( + "LightTransitionModule" + ) + Report: Final[ModuleName[smart.ReportModule]] = ModuleName("ReportModule") + Temperature: Final[ModuleName[smart.TemperatureSensor]] = ModuleName( + "TemperatureSensor" + ) + TemperatureSensor: Final[ModuleName[smart.TemperatureControl]] = ModuleName( + "TemperatureControl" + ) + Time: Final[ModuleName[smart.TimeModule]] = ModuleName("TimeModule") + WaterleakSensor: Final[ModuleName[smart.WaterleakSensor]] = ModuleName( + "WaterleakSensor" + ) + + def __init__(self, device: DeviceType, module: str): self._device = device self._module = module self._module_features: dict[str, Feature] = {} diff --git a/kasa/modulemapping.py b/kasa/modulemapping.py new file mode 100644 index 000000000..06ba86190 --- /dev/null +++ b/kasa/modulemapping.py @@ -0,0 +1,25 @@ +"""Module for Implementation for ModuleMapping and ModuleName types. + +Custom dict for getting typed modules from the module dict. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Generic, TypeVar + +if TYPE_CHECKING: + from .module import Module + +_ModuleT = TypeVar("_ModuleT", bound="Module") + + +class ModuleName(str, Generic[_ModuleT]): + """Generic Module name type. + + At runtime this is a generic subclass of str. + """ + + __slots__ = () + + +ModuleMapping = dict diff --git a/kasa/modulemapping.pyi b/kasa/modulemapping.pyi new file mode 100644 index 000000000..8d110d39f --- /dev/null +++ b/kasa/modulemapping.pyi @@ -0,0 +1,96 @@ +"""Typing stub file for ModuleMapping.""" + +from abc import ABCMeta +from collections.abc import Mapping +from typing import Generic, TypeVar, overload + +from .module import Module + +__all__ = [ + "ModuleMapping", + "ModuleName", +] + +_ModuleT = TypeVar("_ModuleT", bound=Module, covariant=True) +_ModuleBaseT = TypeVar("_ModuleBaseT", bound=Module, covariant=True) + +class ModuleName(Generic[_ModuleT]): + """Class for typed Module names. At runtime delegated to str.""" + + def __init__(self, value: str, /) -> None: ... + def __len__(self) -> int: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __getitem__(self, index: int) -> str: ... + +class ModuleMapping( + Mapping[ModuleName[_ModuleBaseT] | str, _ModuleBaseT], metaclass=ABCMeta +): + """Custom dict type to provide better value type hints for Module key types.""" + + @overload + def __getitem__(self, key: ModuleName[_ModuleT], /) -> _ModuleT: ... + @overload + def __getitem__(self, key: str, /) -> _ModuleBaseT: ... + @overload + def __getitem__( + self, key: ModuleName[_ModuleT] | str, / + ) -> _ModuleT | _ModuleBaseT: ... + @overload # type: ignore[override] + def get(self, key: ModuleName[_ModuleT], /) -> _ModuleT | None: ... + @overload + def get(self, key: str, /) -> _ModuleBaseT | None: ... + @overload + def get( + self, key: ModuleName[_ModuleT] | str, / + ) -> _ModuleT | _ModuleBaseT | None: ... + +def _test_module_mapping_typing() -> None: + """Test ModuleMapping overloads work as intended. + + This is tested during the mypy run and needs to be in this file. + """ + from typing import Any, NewType, cast + + from typing_extensions import assert_type + + from .iot.iotmodule import IotModule + from .module import Module + from .smart.smartmodule import SmartModule + + NewCommonModule = NewType("NewCommonModule", Module) + NewIotModule = NewType("NewIotModule", IotModule) + NewSmartModule = NewType("NewSmartModule", SmartModule) + NotModule = NewType("NotModule", list) + + NEW_COMMON_MODULE: ModuleName[NewCommonModule] = ModuleName("NewCommonModule") + NEW_IOT_MODULE: ModuleName[NewIotModule] = ModuleName("NewIotModule") + NEW_SMART_MODULE: ModuleName[NewSmartModule] = ModuleName("NewSmartModule") + + # TODO Enable --warn-unused-ignores + NOT_MODULE: ModuleName[NotModule] = ModuleName("NotModule") # type: ignore[type-var] # noqa: F841 + NOT_MODULE_2 = ModuleName[NotModule]("NotModule2") # type: ignore[type-var] # noqa: F841 + + device_modules: ModuleMapping[Module] = cast(ModuleMapping[Module], {}) + assert_type(device_modules[NEW_COMMON_MODULE], NewCommonModule) + assert_type(device_modules[NEW_IOT_MODULE], NewIotModule) + assert_type(device_modules[NEW_SMART_MODULE], NewSmartModule) + assert_type(device_modules["foobar"], Module) + assert_type(device_modules[3], Any) # type: ignore[call-overload] + + assert_type(device_modules.get(NEW_COMMON_MODULE), NewCommonModule | None) + assert_type(device_modules.get(NEW_IOT_MODULE), NewIotModule | None) + assert_type(device_modules.get(NEW_SMART_MODULE), NewSmartModule | None) + assert_type(device_modules.get(NEW_COMMON_MODULE, default=[1, 2]), Any) # type: ignore[call-overload] + + iot_modules: ModuleMapping[IotModule] = cast(ModuleMapping[IotModule], {}) + smart_modules: ModuleMapping[SmartModule] = cast(ModuleMapping[SmartModule], {}) + + assert_type(smart_modules["foobar"], SmartModule) + assert_type(iot_modules["foobar"], IotModule) + + # Test for covariance + device_modules_2: ModuleMapping[Module] = iot_modules # noqa: F841 + device_modules_3: ModuleMapping[Module] = smart_modules # noqa: F841 + NEW_MODULE: ModuleName[Module] = NEW_SMART_MODULE # noqa: F841 + NEW_MODULE_2: ModuleName[Module] = NEW_IOT_MODULE # noqa: F841 diff --git a/kasa/plug.py b/kasa/plug.py deleted file mode 100644 index 00796d1c4..000000000 --- a/kasa/plug.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Module for a TAPO Plug.""" - -import logging -from abc import ABC - -from .device import Device - -_LOGGER = logging.getLogger(__name__) - - -class Plug(Device, ABC): - """Base class to represent a Plug.""" diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py index e31131590..587be51c4 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/ledmodule.py @@ -2,37 +2,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from ...feature import Feature +from ...interfaces.led import Led from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - -class LedModule(SmartModule): +class LedModule(SmartModule, Led): """Implementation of led controls.""" REQUIRED_COMPONENT = "led" QUERY_GETTER_NAME = "get_led_info" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) - self._add_feature( - Feature( - device=device, - container=self, - id="led", - name="LED", - icon="mdi:led-{state}", - attribute_getter="led", - attribute_setter="set_led", - type=Feature.Type.Switch, - category=Feature.Category.Config, - ) - ) - def query(self) -> dict: """Query to execute during the update cycle.""" return {self.QUERY_GETTER_NAME: {"led_rule": None}} @@ -56,7 +35,7 @@ async def set_led(self, enable: bool): This should probably be a select with always/never/nightmode. """ rule = "always" if enable else "never" - return await self.call("set_led_info", self.data | {"led_rule": rule}) + return await self.call("set_led_info", dict(self.data, **{"led_rule": rule})) @property def night_mode_settings(self): diff --git a/kasa/smart/modules/lighteffectmodule.py b/kasa/smart/modules/lighteffectmodule.py index bd0eea0ad..a06e979a9 100644 --- a/kasa/smart/modules/lighteffectmodule.py +++ b/kasa/smart/modules/lighteffectmodule.py @@ -6,14 +6,14 @@ import copy from typing import TYPE_CHECKING, Any -from ...feature import Feature +from ...interfaces.lighteffect import LightEffect from ..smartmodule import SmartModule if TYPE_CHECKING: from ..smartdevice import SmartDevice -class LightEffectModule(SmartModule): +class LightEffectModule(SmartModule, LightEffect): """Implementation of dynamic light effects.""" REQUIRED_COMPONENT = "light_effect" @@ -22,29 +22,11 @@ class LightEffectModule(SmartModule): "L1": "Party", "L2": "Relax", } - LIGHT_EFFECTS_OFF = "Off" def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._scenes_names_to_id: dict[str, str] = {} - def _initialize_features(self): - """Initialize features.""" - device = self._device - self._add_feature( - Feature( - device, - id="light_effect", - name="Light effect", - container=self, - attribute_getter="effect", - attribute_setter="set_effect", - category=Feature.Category.Config, - type=Feature.Type.Choice, - choices_getter="effect_list", - ) - ) - def _initialize_effects(self) -> dict[str, dict[str, Any]]: """Return built-in effects.""" # Copy the effects so scene name updates do not update the underlying dict. @@ -64,7 +46,7 @@ def _initialize_effects(self) -> dict[str, dict[str, Any]]: return effects @property - def effect_list(self) -> list[str] | None: + def effect_list(self) -> list[str]: """Return built-in effects list. Example: @@ -90,6 +72,9 @@ def effect(self) -> str: async def set_effect( self, effect: str, + *, + brightness: int | None = None, + transition: int | None = None, ) -> None: """Set an effect for the device. @@ -108,6 +93,24 @@ async def set_effect( params["id"] = effect_id return await self.call("set_dynamic_light_effect_rule_enable", params) + async def set_custom_effect( + self, + effect_dict: dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ + raise NotImplementedError( + "Device does not support setting custom effects. " + "Use has_custom_effects to check for support." + ) + + @property + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + return False + def query(self) -> dict: """Query to execute during the update cycle.""" return {self.QUERY_GETTER_NAME: {"start_index": 0}} diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 68b08902e..194e7c17f 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -5,7 +5,7 @@ import base64 import logging from datetime import datetime, timedelta -from typing import Any, Mapping, Sequence, cast, overload +from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..aestransport import AesTransport from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange @@ -16,7 +16,8 @@ from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..fan import Fan from ..feature import Feature -from ..module import ModuleT +from ..module import Module +from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol from .modules import ( Brightness, @@ -61,7 +62,7 @@ def __init__( self._components_raw: dict[str, Any] | None = None self._components: dict[str, int] = {} self._state_information: dict[str, Any] = {} - self._modules: dict[str, SmartModule] = {} + self._modules: dict[str | ModuleName[Module], SmartModule] = {} self._exposes_child_modules = False self._parent: SmartDevice | None = None self._children: Mapping[str, SmartDevice] = {} @@ -102,8 +103,20 @@ def children(self) -> Sequence[SmartDevice]: return list(self._children.values()) @property - def modules(self) -> dict[str, SmartModule]: + def modules(self) -> ModuleMapping[SmartModule]: """Return the device modules.""" + if self._exposes_child_modules: + modules = {k: v for k, v in self._modules.items()} + for child in self._children.values(): + for k, v in child._modules.items(): + if k not in modules: + modules[k] = v + if TYPE_CHECKING: + return cast(ModuleMapping[SmartModule], modules) + return modules + + if TYPE_CHECKING: # Needed for python 3.8 + return cast(ModuleMapping[SmartModule], self._modules) return self._modules def _try_get_response(self, responses: dict, request: str, default=None) -> dict: @@ -315,30 +328,6 @@ async def _initialize_features(self): for feat in module._module_features.values(): self._add_feature(feat) - @overload - def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... - - @overload - def get_module(self, module_type: str) -> SmartModule | None: ... - - def get_module( - self, module_type: type[ModuleT] | str - ) -> ModuleT | SmartModule | None: - """Return the module from the device modules or None if not present.""" - if isinstance(module_type, str): - module_name = module_type - elif issubclass(module_type, SmartModule): - module_name = module_type.__name__ - else: - return None - if module_name in self.modules: - return self.modules[module_name] - elif self._exposes_child_modules: - for child in self._children.values(): - if module_name in child.modules: - return child.modules[module_name] - return None - @property def is_cloud_connected(self): """Returns if the device is connected to the cloud.""" diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 5ca4a8ae1..7c73c71ea 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -189,6 +189,11 @@ def _set_light_effect(self, info, params): if "current_rule_id" in info["get_dynamic_light_effect_rules"]: del info["get_dynamic_light_effect_rules"]["current_rule_id"] + def _set_led_info(self, info, params): + """Set or remove values as per the device behaviour.""" + info["get_led_info"]["led_status"] = params["led_rule"] != "never" + info["get_led_info"]["led_rule"] = params["led_rule"] + def _send_request(self, request_dict: dict): method = request_dict["method"] params = request_dict["params"] @@ -218,7 +223,9 @@ def _send_request(self, request_dict: dict): # SMART fixtures started to be generated missing_result := self.FIXTURE_MISSING_MAP.get(method) ) and missing_result[0] in self.components: - result = copy.deepcopy(missing_result[1]) + # Copy to info so it will work with update methods + info[method] = copy.deepcopy(missing_result[1]) + result = copy.deepcopy(info[method]) retval = {"result": result, "error_code": 0} else: # PARAMS error returned for KS240 when get_device_usage called @@ -239,6 +246,9 @@ def _send_request(self, request_dict: dict): elif method == "set_dynamic_light_effect_rule_enable": self._set_light_effect(info, params) return {"error_code": 0} + elif method == "set_led_info": + self._set_led_info(info, params) + return {"error_code": 0} elif method[:4] == "set_": target_method = f"get_{method[4:]}" info[target_method].update(params) diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index 02a396aae..3c00a4d11 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -10,7 +10,7 @@ @brightness async def test_brightness_component(dev: SmartDevice): """Test brightness feature.""" - brightness = dev.get_module("Brightness") + brightness = dev.modules.get("Brightness") assert brightness assert isinstance(dev, SmartDevice) assert "brightness" in dev._components diff --git a/kasa/tests/smart/modules/test_contact.py b/kasa/tests/smart/modules/test_contact.py index fc3375450..88677c58f 100644 --- a/kasa/tests/smart/modules/test_contact.py +++ b/kasa/tests/smart/modules/test_contact.py @@ -1,7 +1,6 @@ import pytest -from kasa import SmartDevice -from kasa.smart.modules import ContactSensor +from kasa import Module, SmartDevice from kasa.tests.device_fixtures import parametrize contact = parametrize( @@ -18,7 +17,7 @@ ) async def test_contact_features(dev: SmartDevice, feature, type): """Test that features are registered and work as expected.""" - contact = dev.get_module(ContactSensor) + contact = dev.modules.get(Module.ContactSensor) assert contact is not None prop = getattr(contact, feature) diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 372459510..9597471b6 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -1,8 +1,8 @@ import pytest from pytest_mock import MockerFixture +from kasa import Module from kasa.smart import SmartDevice -from kasa.smart.modules import FanModule from kasa.tests.device_fixtures import parametrize fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"SMART"}) @@ -11,7 +11,7 @@ @fan async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): """Test fan speed feature.""" - fan = dev.get_module(FanModule) + fan = dev.modules.get(Module.Fan) assert fan level_feature = fan._module_features["fan_speed_level"] @@ -36,7 +36,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): @fan async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): """Test sleep mode feature.""" - fan = dev.get_module(FanModule) + fan = dev.modules.get(Module.Fan) assert fan sleep_feature = fan._module_features["fan_sleep_mode"] assert isinstance(sleep_feature.value, bool) @@ -55,7 +55,7 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): """Test fan speed on device interface.""" assert isinstance(dev, SmartDevice) - fan = dev.get_module(FanModule) + fan = dev.modules.get(Module.Fan) assert fan device = fan._device assert device.is_fan diff --git a/kasa/tests/smart/modules/test_firmware.py b/kasa/tests/smart/modules/test_firmware.py index d0df87ca5..8f329f708 100644 --- a/kasa/tests/smart/modules/test_firmware.py +++ b/kasa/tests/smart/modules/test_firmware.py @@ -6,8 +6,8 @@ import pytest from pytest_mock import MockerFixture +from kasa import Module from kasa.smart import SmartDevice -from kasa.smart.modules import Firmware from kasa.smart.modules.firmware import DownloadState from kasa.tests.device_fixtures import parametrize @@ -31,7 +31,7 @@ async def test_firmware_features( dev: SmartDevice, feature, prop_name, type, required_version, mocker: MockerFixture ): """Test light effect.""" - fw = dev.get_module(Firmware) + fw = dev.modules.get(Module.Firmware) assert fw if not dev.is_cloud_connected: @@ -51,7 +51,7 @@ async def test_firmware_features( @firmware async def test_update_available_without_cloud(dev: SmartDevice): """Test that update_available returns None when disconnected.""" - fw = dev.get_module(Firmware) + fw = dev.modules.get(Module.Firmware) assert fw if dev.is_cloud_connected: @@ -67,7 +67,7 @@ async def test_firmware_update( """Test updating firmware.""" caplog.set_level(logging.INFO) - fw = dev.get_module(Firmware) + fw = dev.modules.get(Module.Firmware) assert fw upgrade_time = 5 diff --git a/kasa/tests/smart/modules/test_light_effect.py b/kasa/tests/smart/modules/test_light_effect.py index ba1b22934..cc0eee8a9 100644 --- a/kasa/tests/smart/modules/test_light_effect.py +++ b/kasa/tests/smart/modules/test_light_effect.py @@ -1,12 +1,11 @@ from __future__ import annotations from itertools import chain -from typing import cast import pytest from pytest_mock import MockerFixture -from kasa import Device, Feature +from kasa import Device, Feature, Module from kasa.smart.modules import LightEffectModule from kasa.tests.device_fixtures import parametrize @@ -18,8 +17,8 @@ @light_effect async def test_light_effect(dev: Device, mocker: MockerFixture): """Test light effect.""" - light_effect = cast(LightEffectModule, dev.modules.get("LightEffectModule")) - assert light_effect + light_effect = dev.modules.get(Module.LightEffect) + assert isinstance(light_effect, LightEffectModule) feature = light_effect._module_features["light_effect"] assert feature.type == Feature.Type.Choice diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py new file mode 100644 index 000000000..8f7def957 --- /dev/null +++ b/kasa/tests/test_common_modules.py @@ -0,0 +1,95 @@ +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, Module +from kasa.tests.device_fixtures import ( + lightstrip, + parametrize, + parametrize_combine, + plug_iot, +) + +led_smart = parametrize( + "has led smart", component_filter="led", protocol_filter={"SMART"} +) +led = parametrize_combine([led_smart, plug_iot]) + +light_effect_smart = parametrize( + "has light effect smart", component_filter="light_effect", protocol_filter={"SMART"} +) +light_effect = parametrize_combine([light_effect_smart, lightstrip]) + + +@led +async def test_led_module(dev: Device, mocker: MockerFixture): + """Test fan speed feature.""" + led_module = dev.modules.get(Module.Led) + assert led_module + feat = led_module._module_features["led"] + + call = mocker.spy(led_module, "call") + await led_module.set_led(True) + assert call.call_count == 1 + await dev.update() + assert led_module.led is True + assert feat.value is True + + await led_module.set_led(False) + assert call.call_count == 2 + await dev.update() + assert led_module.led is False + assert feat.value is False + + await feat.set_value(True) + assert call.call_count == 3 + await dev.update() + assert feat.value is True + assert led_module.led is True + + +@light_effect +async def test_light_effect_module(dev: Device, mocker: MockerFixture): + """Test fan speed feature.""" + light_effect_module = dev.modules[Module.LightEffect] + assert light_effect_module + feat = light_effect_module._module_features["light_effect"] + + call = mocker.spy(light_effect_module, "call") + effect_list = light_effect_module.effect_list + assert "Off" in effect_list + assert effect_list.index("Off") == 0 + assert len(effect_list) > 1 + assert effect_list == feat.choices + + assert light_effect_module.has_custom_effects is not None + + await light_effect_module.set_effect("Off") + assert call.call_count == 1 + await dev.update() + assert light_effect_module.effect == "Off" + assert feat.value == "Off" + + second_effect = effect_list[1] + await light_effect_module.set_effect(second_effect) + assert call.call_count == 2 + await dev.update() + assert light_effect_module.effect == second_effect + assert feat.value == second_effect + + last_effect = effect_list[len(effect_list) - 1] + await light_effect_module.set_effect(last_effect) + assert call.call_count == 3 + await dev.update() + assert light_effect_module.effect == last_effect + assert feat.value == last_effect + + # Test feature set + await feat.set_value(second_effect) + assert call.call_count == 4 + await dev.update() + assert light_effect_module.effect == second_effect + assert feat.value == second_effect + + with pytest.raises(ValueError): + await light_effect_module.set_effect("foobar") + assert call.call_count == 4 diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py index b4d56291e..d5c76192b 100644 --- a/kasa/tests/test_iotdevice.py +++ b/kasa/tests/test_iotdevice.py @@ -16,7 +16,7 @@ Schema, ) -from kasa import KasaException +from kasa import KasaException, Module from kasa.iot import IotDevice from .conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on @@ -261,27 +261,26 @@ async def test_modules_not_supported(dev: IotDevice): async def test_get_modules(): - """Test get_modules for child and parent modules.""" + """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( "HS100(US)_2.0_1.5.6.json", "IOT" ) from kasa.iot.modules import Cloud - from kasa.smart.modules import CloudModule # Modules on device - module = dummy_device.get_module("Cloud") + module = dummy_device.modules.get("cloud") assert module assert module._device == dummy_device assert isinstance(module, Cloud) - module = dummy_device.get_module(Cloud) + module = dummy_device.modules.get(Module.IotCloud) assert module assert module._device == dummy_device assert isinstance(module, Cloud) # Invalid modules - module = dummy_device.get_module("DummyModule") + module = dummy_device.modules.get("DummyModule") assert module is None - module = dummy_device.get_module(CloudModule) + module = dummy_device.modules.get(Module.Cloud) assert module is None diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index fc987d2e6..f51f1805c 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -1,7 +1,6 @@ import pytest from kasa import DeviceType -from kasa.exceptions import KasaException from kasa.iot import IotLightStrip from .conftest import lightstrip @@ -23,7 +22,7 @@ async def test_lightstrip_effect(dev: IotLightStrip): @lightstrip async def test_effects_lightstrip_set_effect(dev: IotLightStrip): - with pytest.raises(KasaException): + with pytest.raises(ValueError): await dev.set_effect("Not real") await dev.set_effect("Candy Cane") diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index bb2f81bf0..a0af2cb12 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -9,7 +9,7 @@ import pytest from pytest_mock import MockerFixture -from kasa import KasaException +from kasa import KasaException, Module from kasa.exceptions import SmartErrorCode from kasa.smart import SmartDevice @@ -123,40 +123,39 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): async def test_get_modules(): - """Test get_modules for child and parent modules.""" + """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( "KS240(US)_1.0_1.0.5.json", "SMART" ) - from kasa.iot.modules import AmbientLight - from kasa.smart.modules import CloudModule, FanModule + from kasa.smart.modules import CloudModule # Modules on device - module = dummy_device.get_module("CloudModule") + module = dummy_device.modules.get("CloudModule") assert module assert module._device == dummy_device assert isinstance(module, CloudModule) - module = dummy_device.get_module(CloudModule) + module = dummy_device.modules.get(Module.Cloud) assert module assert module._device == dummy_device assert isinstance(module, CloudModule) # Modules on child - module = dummy_device.get_module("FanModule") + module = dummy_device.modules.get("FanModule") assert module assert module._device != dummy_device assert module._device._parent == dummy_device - module = dummy_device.get_module(FanModule) + module = dummy_device.modules.get(Module.Fan) assert module assert module._device != dummy_device assert module._device._parent == dummy_device # Invalid modules - module = dummy_device.get_module("DummyModule") + module = dummy_device.modules.get("DummyModule") assert module is None - module = dummy_device.get_module(AmbientLight) + module = dummy_device.modules.get(Module.IotAmbientLight) assert module is None From f259a8f16218a46785f4d1fa3469826668438ef6 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sat, 11 May 2024 19:28:18 +0100 Subject: [PATCH 428/892] Make module names consistent and remove redundant module casting (#909) Address the inconsistent naming of smart modules by removing all "Module" suffixes and aligning filenames with class names. Removes the casting of modules to the correct module type now that is is redundant. Update the adding of iot modules to use the ModuleName class rather than a free string. --- kasa/iot/iotbulb.py | 19 +++-- kasa/iot/iotdevice.py | 28 ++++---- kasa/iot/iotdimmer.py | 5 +- kasa/iot/iotlightstrip.py | 4 +- kasa/iot/iotplug.py | 14 ++-- kasa/iot/iotstrip.py | 11 +-- kasa/iot/modules/__init__.py | 6 +- kasa/iot/modules/{ledmodule.py => led.py} | 4 +- .../{lighteffectmodule.py => lighteffect.py} | 4 +- kasa/module.py | 50 ++++++------- kasa/smart/modules/__init__.py | 66 ++++++++--------- .../modules/{alarmmodule.py => alarm.py} | 2 +- .../modules/{autooffmodule.py => autooff.py} | 2 +- .../modules/{battery.py => batterysensor.py} | 0 .../{childdevicemodule.py => childdevice.py} | 2 +- .../modules/{cloudmodule.py => cloud.py} | 2 +- .../modules/{colormodule.py => color.py} | 2 +- .../{colortemp.py => colortemperature.py} | 2 +- .../modules/{contact.py => contactsensor.py} | 0 .../modules/{energymodule.py => energy.py} | 2 +- kasa/smart/modules/{fanmodule.py => fan.py} | 2 +- kasa/smart/modules/frostprotection.py | 2 +- .../{humidity.py => humiditysensor.py} | 0 kasa/smart/modules/{ledmodule.py => led.py} | 4 +- .../{lighteffectmodule.py => lighteffect.py} | 4 +- ...transitionmodule.py => lighttransition.py} | 2 +- .../{reportmodule.py => reportmode.py} | 2 +- .../{temperature.py => temperaturesensor.py} | 0 kasa/smart/modules/{timemodule.py => time.py} | 2 +- .../{waterleak.py => waterleaksensor.py} | 0 kasa/smart/smartdevice.py | 72 ++++++++----------- kasa/tests/smart/modules/test_light_effect.py | 6 +- kasa/tests/test_cli.py | 2 +- kasa/tests/test_smartdevice.py | 10 +-- 34 files changed, 162 insertions(+), 171 deletions(-) rename kasa/iot/modules/{ledmodule.py => led.py} (89%) rename kasa/iot/modules/{lighteffectmodule.py => lighteffect.py} (95%) rename kasa/smart/modules/{alarmmodule.py => alarm.py} (99%) rename kasa/smart/modules/{autooffmodule.py => autooff.py} (98%) rename kasa/smart/modules/{battery.py => batterysensor.py} (100%) rename kasa/smart/modules/{childdevicemodule.py => childdevice.py} (84%) rename kasa/smart/modules/{cloudmodule.py => cloud.py} (97%) rename kasa/smart/modules/{colormodule.py => color.py} (98%) rename kasa/smart/modules/{colortemp.py => colortemperature.py} (98%) rename kasa/smart/modules/{contact.py => contactsensor.py} (100%) rename kasa/smart/modules/{energymodule.py => energy.py} (98%) rename kasa/smart/modules/{fanmodule.py => fan.py} (98%) rename kasa/smart/modules/{humidity.py => humiditysensor.py} (100%) rename kasa/smart/modules/{ledmodule.py => led.py} (93%) rename kasa/smart/modules/{lighteffectmodule.py => lighteffect.py} (96%) rename kasa/smart/modules/{lighttransitionmodule.py => lighttransition.py} (99%) rename kasa/smart/modules/{reportmodule.py => reportmode.py} (96%) rename kasa/smart/modules/{temperature.py => temperaturesensor.py} (100%) rename kasa/smart/modules/{timemodule.py => time.py} (98%) rename kasa/smart/modules/{waterleak.py => waterleaksensor.py} (100%) diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 6819d94ba..92bf98147 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -13,6 +13,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..feature import Feature +from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, KasaException, requires_update from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage @@ -198,13 +199,17 @@ def __init__( ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Bulb - self.add_module("schedule", Schedule(self, "smartlife.iot.common.schedule")) - self.add_module("usage", Usage(self, "smartlife.iot.common.schedule")) - self.add_module("antitheft", Antitheft(self, "smartlife.iot.common.anti_theft")) - self.add_module("time", Time(self, "smartlife.iot.common.timesetting")) - self.add_module("emeter", Emeter(self, self.emeter_type)) - self.add_module("countdown", Countdown(self, "countdown")) - self.add_module("cloud", Cloud(self, "smartlife.iot.common.cloud")) + self.add_module( + Module.IotSchedule, Schedule(self, "smartlife.iot.common.schedule") + ) + self.add_module(Module.IotUsage, Usage(self, "smartlife.iot.common.schedule")) + self.add_module( + Module.IotAntitheft, Antitheft(self, "smartlife.iot.common.anti_theft") + ) + self.add_module(Module.IotTime, Time(self, "smartlife.iot.common.timesetting")) + self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) + self.add_module(Module.IotCountdown, Countdown(self, "countdown")) + self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud")) async def _initialize_features(self): await super()._initialize_features() diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 762fc06cd..e4c1bb13a 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -30,7 +30,7 @@ from ..modulemapping import ModuleMapping, ModuleName from ..protocol import BaseProtocol from .iotmodule import IotModule -from .modules import Emeter, Time +from .modules import Emeter _LOGGER = logging.getLogger(__name__) @@ -347,7 +347,7 @@ async def _modular_update(self, req: dict) -> None: _LOGGER.debug( "The device has emeter, querying its information along sysinfo" ) - self.add_module("emeter", Emeter(self, self.emeter_type)) + self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) # TODO: perhaps modules should not have unsupported modules, # making separate handling for this unnecessary @@ -440,27 +440,27 @@ async def set_alias(self, alias: str) -> None: @requires_update def time(self) -> datetime: """Return current time from the device.""" - return cast(Time, self.modules["time"]).time + return self.modules[Module.IotTime].time @property @requires_update def timezone(self) -> dict: """Return the current timezone.""" - return cast(Time, self.modules["time"]).timezone + return self.modules[Module.IotTime].timezone async def get_time(self) -> datetime | None: """Return current time from the device, if available.""" _LOGGER.warning( "Use `time` property instead, this call will be removed in the future." ) - return await cast(Time, self.modules["time"]).get_time() + return await self.modules[Module.IotTime].get_time() async def get_timezone(self) -> dict: """Return timezone information.""" _LOGGER.warning( "Use `timezone` property instead, this call will be removed in the future." ) - return await cast(Time, self.modules["time"]).get_timezone() + return await self.modules[Module.IotTime].get_timezone() @property # type: ignore @requires_update @@ -541,26 +541,26 @@ async def set_mac(self, mac): def emeter_realtime(self) -> EmeterStatus: """Return current energy readings.""" self._verify_emeter() - return EmeterStatus(cast(Emeter, self.modules["emeter"]).realtime) + return EmeterStatus(self.modules[Module.IotEmeter].realtime) async def get_emeter_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" self._verify_emeter() - return EmeterStatus(await cast(Emeter, self.modules["emeter"]).get_realtime()) + return EmeterStatus(await self.modules[Module.IotEmeter].get_realtime()) @property @requires_update def emeter_today(self) -> float | None: """Return today's energy consumption in kWh.""" self._verify_emeter() - return cast(Emeter, self.modules["emeter"]).emeter_today + return self.modules[Module.IotEmeter].emeter_today @property @requires_update def emeter_this_month(self) -> float | None: """Return this month's energy consumption in kWh.""" self._verify_emeter() - return cast(Emeter, self.modules["emeter"]).emeter_this_month + return self.modules[Module.IotEmeter].emeter_this_month async def get_emeter_daily( self, year: int | None = None, month: int | None = None, kwh: bool = True @@ -574,7 +574,7 @@ async def get_emeter_daily( :return: mapping of day of month to value """ self._verify_emeter() - return await cast(Emeter, self.modules["emeter"]).get_daystat( + return await self.modules[Module.IotEmeter].get_daystat( year=year, month=month, kwh=kwh ) @@ -589,15 +589,13 @@ async def get_emeter_monthly( :return: dict: mapping of month to value """ self._verify_emeter() - return await cast(Emeter, self.modules["emeter"]).get_monthstat( - year=year, kwh=kwh - ) + return await self.modules[Module.IotEmeter].get_monthstat(year=year, kwh=kwh) @requires_update async def erase_emeter_stats(self) -> dict: """Erase energy meter statistics.""" self._verify_emeter() - return await cast(Emeter, self.modules["emeter"]).erase_stats() + return await self.modules[Module.IotEmeter].erase_stats() @requires_update async def current_consumption(self) -> float: diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index cfe937b8a..fed9e7e79 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -8,6 +8,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..feature import Feature +from ..module import Module from ..protocol import BaseProtocol from .iotdevice import KasaException, requires_update from .iotplug import IotPlug @@ -81,8 +82,8 @@ def __init__( self._device_type = DeviceType.Dimmer # TODO: need to be verified if it's okay to call these on HS220 w/o these # TODO: need to be figured out what's the best approach to detect support - self.add_module("motion", Motion(self, "smartlife.iot.PIR")) - self.add_module("ambient", AmbientLight(self, "smartlife.iot.LAS")) + self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR")) + self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS")) async def _initialize_features(self): await super()._initialize_features() diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index a120be7a7..7cdbe43ba 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -9,7 +9,7 @@ from .effects import EFFECT_NAMES_V1 from .iotbulb import IotBulb from .iotdevice import KasaException, requires_update -from .modules.lighteffectmodule import LightEffectModule +from .modules.lighteffect import LightEffect class IotLightStrip(IotBulb): @@ -58,7 +58,7 @@ def __init__( self._device_type = DeviceType.LightStrip self.add_module( Module.LightEffect, - LightEffectModule(self, "smartlife.iot.lighting_effect"), + LightEffect(self, "smartlife.iot.lighting_effect"), ) @property # type: ignore diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 22238c7a5..6aace4f8a 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -9,7 +9,7 @@ from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, requires_update -from .modules import Antitheft, Cloud, LedModule, Schedule, Time, Usage +from .modules import Antitheft, Cloud, Led, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) @@ -53,12 +53,12 @@ def __init__( ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug - self.add_module("schedule", Schedule(self, "schedule")) - self.add_module("usage", Usage(self, "schedule")) - self.add_module("antitheft", Antitheft(self, "anti_theft")) - self.add_module("time", Time(self, "time")) - self.add_module("cloud", Cloud(self, "cnCloud")) - self.add_module(Module.Led, LedModule(self, "system")) + self.add_module(Module.IotSchedule, Schedule(self, "schedule")) + self.add_module(Module.IotUsage, Usage(self, "schedule")) + self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) + self.add_module(Module.IotTime, Time(self, "time")) + self.add_module(Module.IotCloud, Cloud(self, "cnCloud")) + self.add_module(Module.Led, Led(self, "system")) @property # type: ignore @requires_update diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index ab14abb0a..4aa966e1f 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -10,6 +10,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..exceptions import KasaException +from ..module import Module from ..protocol import BaseProtocol from .iotdevice import ( EmeterStatus, @@ -95,11 +96,11 @@ def __init__( super().__init__(host=host, config=config, protocol=protocol) self.emeter_type = "emeter" self._device_type = DeviceType.Strip - self.add_module("antitheft", Antitheft(self, "anti_theft")) - self.add_module("schedule", Schedule(self, "schedule")) - self.add_module("usage", Usage(self, "schedule")) - self.add_module("time", Time(self, "time")) - self.add_module("countdown", Countdown(self, "countdown")) + self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) + self.add_module(Module.IotSchedule, Schedule(self, "schedule")) + self.add_module(Module.IotUsage, Usage(self, "schedule")) + self.add_module(Module.IotTime, Time(self, "time")) + self.add_module(Module.IotCountdown, Countdown(self, "countdown")) @property # type: ignore @requires_update diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index f061e6070..e0febfd41 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -5,7 +5,8 @@ from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter -from .ledmodule import LedModule +from .led import Led +from .lighteffect import LightEffect from .motion import Motion from .rulemodule import Rule, RuleModule from .schedule import Schedule @@ -18,7 +19,8 @@ "Cloud", "Countdown", "Emeter", - "LedModule", + "Led", + "LightEffect", "Motion", "Rule", "RuleModule", diff --git a/kasa/iot/modules/ledmodule.py b/kasa/iot/modules/led.py similarity index 89% rename from kasa/iot/modules/ledmodule.py rename to kasa/iot/modules/led.py index 6b3c61948..6c4ca02aa 100644 --- a/kasa/iot/modules/ledmodule.py +++ b/kasa/iot/modules/led.py @@ -2,11 +2,11 @@ from __future__ import annotations -from ...interfaces.led import Led +from ...interfaces.led import Led as LedInterface from ..iotmodule import IotModule -class LedModule(IotModule, Led): +class Led(IotModule, LedInterface): """Implementation of led controls.""" def query(self) -> dict: diff --git a/kasa/iot/modules/lighteffectmodule.py b/kasa/iot/modules/lighteffect.py similarity index 95% rename from kasa/iot/modules/lighteffectmodule.py rename to kasa/iot/modules/lighteffect.py index c53de1920..2d40fb54b 100644 --- a/kasa/iot/modules/lighteffectmodule.py +++ b/kasa/iot/modules/lighteffect.py @@ -2,12 +2,12 @@ from __future__ import annotations -from ...interfaces.lighteffect import LightEffect +from ...interfaces.lighteffect import LightEffect as LightEffectInterface from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from ..iotmodule import IotModule -class LightEffectModule(IotModule, LightEffect): +class LightEffect(IotModule, LightEffectInterface): """Implementation of dynamic light effects.""" @property diff --git a/kasa/module.py b/kasa/module.py index b65f0499a..55eeea185 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -15,7 +15,7 @@ from .modulemapping import ModuleName if TYPE_CHECKING: - from .device import Device as DeviceType # avoid name clash with Device module + from .device import Device from .interfaces.led import Led from .interfaces.lighteffect import LightEffect from .iot import modules as iot @@ -34,8 +34,8 @@ class Module(ABC): """ # Common Modules - LightEffect: Final[ModuleName[LightEffect]] = ModuleName("LightEffectModule") - Led: Final[ModuleName[Led]] = ModuleName("LedModule") + LightEffect: Final[ModuleName[LightEffect]] = ModuleName("LightEffect") + Led: Final[ModuleName[Led]] = ModuleName("Led") # IOT only Modules IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") @@ -49,43 +49,43 @@ class Module(ABC): IotTime: Final[ModuleName[iot.Time]] = ModuleName("time") # SMART only Modules - Alarm: Final[ModuleName[smart.AlarmModule]] = ModuleName("AlarmModule") - AutoOff: Final[ModuleName[smart.AutoOffModule]] = ModuleName("AutoOffModule") + Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm") + AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff") BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor") Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness") - ChildDevice: Final[ModuleName[smart.ChildDeviceModule]] = ModuleName( - "ChildDeviceModule" - ) - Cloud: Final[ModuleName[smart.CloudModule]] = ModuleName("CloudModule") - Color: Final[ModuleName[smart.ColorModule]] = ModuleName("ColorModule") - ColorTemp: Final[ModuleName[smart.ColorTemperatureModule]] = ModuleName( - "ColorTemperatureModule" + ChildDevice: Final[ModuleName[smart.ChildDevice]] = ModuleName("ChildDevice") + Cloud: Final[ModuleName[smart.Cloud]] = ModuleName("Cloud") + Color: Final[ModuleName[smart.Color]] = ModuleName("Color") + ColorTemperature: Final[ModuleName[smart.ColorTemperature]] = ModuleName( + "ColorTemperature" ) ContactSensor: Final[ModuleName[smart.ContactSensor]] = ModuleName("ContactSensor") - Device: Final[ModuleName[smart.DeviceModule]] = ModuleName("DeviceModule") - Energy: Final[ModuleName[smart.EnergyModule]] = ModuleName("EnergyModule") - Fan: Final[ModuleName[smart.FanModule]] = ModuleName("FanModule") + DeviceModule: Final[ModuleName[smart.DeviceModule]] = ModuleName("DeviceModule") + Energy: Final[ModuleName[smart.Energy]] = ModuleName("Energy") + Fan: Final[ModuleName[smart.Fan]] = ModuleName("Fan") Firmware: Final[ModuleName[smart.Firmware]] = ModuleName("Firmware") - FrostProtection: Final[ModuleName[smart.FrostProtectionModule]] = ModuleName( - "FrostProtectionModule" + FrostProtection: Final[ModuleName[smart.FrostProtection]] = ModuleName( + "FrostProtection" + ) + HumiditySensor: Final[ModuleName[smart.HumiditySensor]] = ModuleName( + "HumiditySensor" ) - Humidity: Final[ModuleName[smart.HumiditySensor]] = ModuleName("HumiditySensor") - LightTransition: Final[ModuleName[smart.LightTransitionModule]] = ModuleName( - "LightTransitionModule" + LightTransition: Final[ModuleName[smart.LightTransition]] = ModuleName( + "LightTransition" ) - Report: Final[ModuleName[smart.ReportModule]] = ModuleName("ReportModule") - Temperature: Final[ModuleName[smart.TemperatureSensor]] = ModuleName( + ReportMode: Final[ModuleName[smart.ReportMode]] = ModuleName("ReportMode") + TemperatureSensor: Final[ModuleName[smart.TemperatureSensor]] = ModuleName( "TemperatureSensor" ) - TemperatureSensor: Final[ModuleName[smart.TemperatureControl]] = ModuleName( + TemperatureControl: Final[ModuleName[smart.TemperatureControl]] = ModuleName( "TemperatureControl" ) - Time: Final[ModuleName[smart.TimeModule]] = ModuleName("TimeModule") + Time: Final[ModuleName[smart.Time]] = ModuleName("Time") WaterleakSensor: Final[ModuleName[smart.WaterleakSensor]] = ModuleName( "WaterleakSensor" ) - def __init__(self, device: DeviceType, module: str): + def __init__(self, device: Device, module: str): self._device = device self._module = module self._module_features: dict[str, Feature] = {} diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index b0956b80e..e119e0675 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -1,51 +1,51 @@ """Modules for SMART devices.""" -from .alarmmodule import AlarmModule -from .autooffmodule import AutoOffModule -from .battery import BatterySensor +from .alarm import Alarm +from .autooff import AutoOff +from .batterysensor import BatterySensor from .brightness import Brightness -from .childdevicemodule import ChildDeviceModule -from .cloudmodule import CloudModule -from .colormodule import ColorModule -from .colortemp import ColorTemperatureModule -from .contact import ContactSensor +from .childdevice import ChildDevice +from .cloud import Cloud +from .color import Color +from .colortemperature import ColorTemperature +from .contactsensor import ContactSensor from .devicemodule import DeviceModule -from .energymodule import EnergyModule -from .fanmodule import FanModule +from .energy import Energy +from .fan import Fan from .firmware import Firmware -from .frostprotection import FrostProtectionModule -from .humidity import HumiditySensor -from .ledmodule import LedModule -from .lighteffectmodule import LightEffectModule -from .lighttransitionmodule import LightTransitionModule -from .reportmodule import ReportModule -from .temperature import TemperatureSensor +from .frostprotection import FrostProtection +from .humiditysensor import HumiditySensor +from .led import Led +from .lighteffect import LightEffect +from .lighttransition import LightTransition +from .reportmode import ReportMode from .temperaturecontrol import TemperatureControl -from .timemodule import TimeModule -from .waterleak import WaterleakSensor +from .temperaturesensor import TemperatureSensor +from .time import Time +from .waterleaksensor import WaterleakSensor __all__ = [ - "AlarmModule", - "TimeModule", - "EnergyModule", + "Alarm", + "Time", + "Energy", "DeviceModule", - "ChildDeviceModule", + "ChildDevice", "BatterySensor", "HumiditySensor", "TemperatureSensor", "TemperatureControl", - "ReportModule", - "AutoOffModule", - "LedModule", + "ReportMode", + "AutoOff", + "Led", "Brightness", - "FanModule", + "Fan", "Firmware", - "CloudModule", - "LightEffectModule", - "LightTransitionModule", - "ColorTemperatureModule", - "ColorModule", + "Cloud", + "LightEffect", + "LightTransition", + "ColorTemperature", + "Color", "WaterleakSensor", "ContactSensor", - "FrostProtectionModule", + "FrostProtection", ] diff --git a/kasa/smart/modules/alarmmodule.py b/kasa/smart/modules/alarm.py similarity index 99% rename from kasa/smart/modules/alarmmodule.py rename to kasa/smart/modules/alarm.py index 845eb65aa..f033496a5 100644 --- a/kasa/smart/modules/alarmmodule.py +++ b/kasa/smart/modules/alarm.py @@ -6,7 +6,7 @@ from ..smartmodule import SmartModule -class AlarmModule(SmartModule): +class Alarm(SmartModule): """Implementation of alarm module.""" REQUIRED_COMPONENT = "alarm" diff --git a/kasa/smart/modules/autooffmodule.py b/kasa/smart/modules/autooff.py similarity index 98% rename from kasa/smart/modules/autooffmodule.py rename to kasa/smart/modules/autooff.py index cb8d5e57c..385364fa6 100644 --- a/kasa/smart/modules/autooffmodule.py +++ b/kasa/smart/modules/autooff.py @@ -12,7 +12,7 @@ from ..smartdevice import SmartDevice -class AutoOffModule(SmartModule): +class AutoOff(SmartModule): """Implementation of auto off module.""" REQUIRED_COMPONENT = "auto_off" diff --git a/kasa/smart/modules/battery.py b/kasa/smart/modules/batterysensor.py similarity index 100% rename from kasa/smart/modules/battery.py rename to kasa/smart/modules/batterysensor.py diff --git a/kasa/smart/modules/childdevicemodule.py b/kasa/smart/modules/childdevice.py similarity index 84% rename from kasa/smart/modules/childdevicemodule.py rename to kasa/smart/modules/childdevice.py index 9f4710b2d..5713eff49 100644 --- a/kasa/smart/modules/childdevicemodule.py +++ b/kasa/smart/modules/childdevice.py @@ -3,7 +3,7 @@ from ..smartmodule import SmartModule -class ChildDeviceModule(SmartModule): +class ChildDevice(SmartModule): """Implementation for child devices.""" REQUIRED_COMPONENT = "child_device" diff --git a/kasa/smart/modules/cloudmodule.py b/kasa/smart/modules/cloud.py similarity index 97% rename from kasa/smart/modules/cloudmodule.py rename to kasa/smart/modules/cloud.py index 8b9d8f418..1b64f090a 100644 --- a/kasa/smart/modules/cloudmodule.py +++ b/kasa/smart/modules/cloud.py @@ -12,7 +12,7 @@ from ..smartdevice import SmartDevice -class CloudModule(SmartModule): +class Cloud(SmartModule): """Implementation of cloud module.""" QUERY_GETTER_NAME = "get_connect_cloud_state" diff --git a/kasa/smart/modules/colormodule.py b/kasa/smart/modules/color.py similarity index 98% rename from kasa/smart/modules/colormodule.py rename to kasa/smart/modules/color.py index 716d4c444..979d4fec0 100644 --- a/kasa/smart/modules/colormodule.py +++ b/kasa/smart/modules/color.py @@ -12,7 +12,7 @@ from ..smartdevice import SmartDevice -class ColorModule(SmartModule): +class Color(SmartModule): """Implementation of color module.""" REQUIRED_COMPONENT = "color" diff --git a/kasa/smart/modules/colortemp.py b/kasa/smart/modules/colortemperature.py similarity index 98% rename from kasa/smart/modules/colortemp.py rename to kasa/smart/modules/colortemperature.py index d6b43d029..88d5ea211 100644 --- a/kasa/smart/modules/colortemp.py +++ b/kasa/smart/modules/colortemperature.py @@ -18,7 +18,7 @@ DEFAULT_TEMP_RANGE = [2500, 6500] -class ColorTemperatureModule(SmartModule): +class ColorTemperature(SmartModule): """Implementation of color temp module.""" REQUIRED_COMPONENT = "color_temperature" diff --git a/kasa/smart/modules/contact.py b/kasa/smart/modules/contactsensor.py similarity index 100% rename from kasa/smart/modules/contact.py rename to kasa/smart/modules/contactsensor.py diff --git a/kasa/smart/modules/energymodule.py b/kasa/smart/modules/energy.py similarity index 98% rename from kasa/smart/modules/energymodule.py rename to kasa/smart/modules/energy.py index 9cfe8cfb5..55b5088e7 100644 --- a/kasa/smart/modules/energymodule.py +++ b/kasa/smart/modules/energy.py @@ -12,7 +12,7 @@ from ..smartdevice import SmartDevice -class EnergyModule(SmartModule): +class Energy(SmartModule): """Implementation of energy monitoring module.""" REQUIRED_COMPONENT = "energy_monitoring" diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fan.py similarity index 98% rename from kasa/smart/modules/fanmodule.py rename to kasa/smart/modules/fan.py index 6eeaa4d43..3d8cc7eb6 100644 --- a/kasa/smart/modules/fanmodule.py +++ b/kasa/smart/modules/fan.py @@ -11,7 +11,7 @@ from ..smartdevice import SmartDevice -class FanModule(SmartModule): +class Fan(SmartModule): """Implementation of fan_control module.""" REQUIRED_COMPONENT = "fan_control" diff --git a/kasa/smart/modules/frostprotection.py b/kasa/smart/modules/frostprotection.py index cedaf78be..ee93d2994 100644 --- a/kasa/smart/modules/frostprotection.py +++ b/kasa/smart/modules/frostprotection.py @@ -12,7 +12,7 @@ from ..smartdevice import SmartDevice -class FrostProtectionModule(SmartModule): +class FrostProtection(SmartModule): """Implementation for frost protection module. This basically turns the thermostat on and off. diff --git a/kasa/smart/modules/humidity.py b/kasa/smart/modules/humiditysensor.py similarity index 100% rename from kasa/smart/modules/humidity.py rename to kasa/smart/modules/humiditysensor.py diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/led.py similarity index 93% rename from kasa/smart/modules/ledmodule.py rename to kasa/smart/modules/led.py index 587be51c4..230b83d9f 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/led.py @@ -2,11 +2,11 @@ from __future__ import annotations -from ...interfaces.led import Led +from ...interfaces.led import Led as LedInterface from ..smartmodule import SmartModule -class LedModule(SmartModule, Led): +class Led(SmartModule, LedInterface): """Implementation of led controls.""" REQUIRED_COMPONENT = "led" diff --git a/kasa/smart/modules/lighteffectmodule.py b/kasa/smart/modules/lighteffect.py similarity index 96% rename from kasa/smart/modules/lighteffectmodule.py rename to kasa/smart/modules/lighteffect.py index a06e979a9..4f049576d 100644 --- a/kasa/smart/modules/lighteffectmodule.py +++ b/kasa/smart/modules/lighteffect.py @@ -6,14 +6,14 @@ import copy from typing import TYPE_CHECKING, Any -from ...interfaces.lighteffect import LightEffect +from ...interfaces.lighteffect import LightEffect as LightEffectInterface from ..smartmodule import SmartModule if TYPE_CHECKING: from ..smartdevice import SmartDevice -class LightEffectModule(SmartModule, LightEffect): +class LightEffect(SmartModule, LightEffectInterface): """Implementation of dynamic light effects.""" REQUIRED_COMPONENT = "light_effect" diff --git a/kasa/smart/modules/lighttransitionmodule.py b/kasa/smart/modules/lighttransition.py similarity index 99% rename from kasa/smart/modules/lighttransitionmodule.py rename to kasa/smart/modules/lighttransition.py index f213d9ac1..a11c7d95d 100644 --- a/kasa/smart/modules/lighttransitionmodule.py +++ b/kasa/smart/modules/lighttransition.py @@ -12,7 +12,7 @@ from ..smartdevice import SmartDevice -class LightTransitionModule(SmartModule): +class LightTransition(SmartModule): """Implementation of gradual on/off.""" REQUIRED_COMPONENT = "on_off_gradually" diff --git a/kasa/smart/modules/reportmodule.py b/kasa/smart/modules/reportmode.py similarity index 96% rename from kasa/smart/modules/reportmodule.py rename to kasa/smart/modules/reportmode.py index 16827a8c5..f0af4c1c6 100644 --- a/kasa/smart/modules/reportmodule.py +++ b/kasa/smart/modules/reportmode.py @@ -11,7 +11,7 @@ from ..smartdevice import SmartDevice -class ReportModule(SmartModule): +class ReportMode(SmartModule): """Implementation of report module.""" REQUIRED_COMPONENT = "report_mode" diff --git a/kasa/smart/modules/temperature.py b/kasa/smart/modules/temperaturesensor.py similarity index 100% rename from kasa/smart/modules/temperature.py rename to kasa/smart/modules/temperaturesensor.py diff --git a/kasa/smart/modules/timemodule.py b/kasa/smart/modules/time.py similarity index 98% rename from kasa/smart/modules/timemodule.py rename to kasa/smart/modules/time.py index 23814f571..958cf9e21 100644 --- a/kasa/smart/modules/timemodule.py +++ b/kasa/smart/modules/time.py @@ -13,7 +13,7 @@ from ..smartdevice import SmartDevice -class TimeModule(SmartModule): +class Time(SmartModule): """Implementation of device_local_time.""" REQUIRED_COMPONENT = "time" diff --git a/kasa/smart/modules/waterleak.py b/kasa/smart/modules/waterleaksensor.py similarity index 100% rename from kasa/smart/modules/waterleak.py rename to kasa/smart/modules/waterleaksensor.py diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 194e7c17f..122c943b5 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -20,15 +20,10 @@ from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol from .modules import ( - Brightness, - CloudModule, - ColorModule, - ColorTemperatureModule, + Cloud, DeviceModule, - EnergyModule, - FanModule, Firmware, - TimeModule, + Time, ) from .smartmodule import SmartModule @@ -39,7 +34,7 @@ # the child but only work on the parent. See longer note below in _initialize_modules. # This list should be updated when creating new modules that could have the # same issue, homekit perhaps? -WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule] +WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] # Device must go last as the other interfaces also inherit Device @@ -329,11 +324,11 @@ async def _initialize_features(self): self._add_feature(feat) @property - def is_cloud_connected(self): + def is_cloud_connected(self) -> bool: """Returns if the device is connected to the cloud.""" - if "CloudModule" not in self.modules: + if Module.Cloud not in self.modules: return False - return self.modules["CloudModule"].is_connected + return self.modules[Module.Cloud].is_connected @property def sys_info(self) -> dict[str, Any]: @@ -357,10 +352,10 @@ def alias(self) -> str | None: def time(self) -> datetime: """Return the time.""" # TODO: Default to parent's time module for child devices - if self._parent and "TimeModule" in self.modules: - _timemod = cast(TimeModule, self._parent.modules["TimeModule"]) # noqa: F405 + if self._parent and Module.Time in self.modules: + _timemod = self._parent.modules[Module.Time] else: - _timemod = cast(TimeModule, self.modules["TimeModule"]) # noqa: F405 + _timemod = self.modules[Module.Time] return _timemod.time @@ -437,7 +432,7 @@ def ssid(self) -> str: @property def has_emeter(self) -> bool: """Return if the device has emeter.""" - return "EnergyModule" in self.modules + return Module.Energy in self.modules @property def is_dimmer(self) -> bool: @@ -479,19 +474,19 @@ async def get_emeter_realtime(self) -> EmeterStatus: @property def emeter_realtime(self) -> EmeterStatus: """Get the emeter status.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) + energy = self.modules[Module.Energy] return energy.emeter_realtime @property def emeter_this_month(self) -> float | None: """Get the emeter value for this month.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) + energy = self.modules[Module.Energy] return energy.emeter_this_month @property def emeter_today(self) -> float | None: """Get the emeter value for today.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) + energy = self.modules[Module.Energy] return energy.emeter_today @property @@ -503,8 +498,7 @@ def on_since(self) -> datetime | None: ): return None on_time = cast(float, on_time) - if (timemod := self.modules.get("TimeModule")) is not None: - timemod = cast(TimeModule, timemod) # noqa: F405 + if (timemod := self.modules.get(Module.Time)) is not None: return timemod.time - timedelta(seconds=on_time) else: # We have no device time, use current local time. return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) @@ -650,37 +644,37 @@ def _get_device_type_from_components( @property def is_fan(self) -> bool: """Return True if the device is a fan.""" - return "FanModule" in self.modules + return Module.Fan in self.modules @property def fan_speed_level(self) -> int: """Return fan speed level.""" if not self.is_fan: raise KasaException("Device is not a Fan") - return cast(FanModule, self.modules["FanModule"]).fan_speed_level + return self.modules[Module.Fan].fan_speed_level async def set_fan_speed_level(self, level: int): """Set fan speed level.""" if not self.is_fan: raise KasaException("Device is not a Fan") - await cast(FanModule, self.modules["FanModule"]).set_fan_speed_level(level) + await self.modules[Module.Fan].set_fan_speed_level(level) # Bulb interface methods @property def is_color(self) -> bool: """Whether the bulb supports color changes.""" - return "ColorModule" in self.modules + return Module.Color in self.modules @property def is_dimmable(self) -> bool: """Whether the bulb supports brightness changes.""" - return "Brightness" in self.modules + return Module.Brightness in self.modules @property def is_variable_color_temp(self) -> bool: """Whether the bulb supports color temperature changes.""" - return "ColorTemperatureModule" in self.modules + return Module.ColorTemperature in self.modules @property def valid_temperature_range(self) -> ColorTempRange: @@ -691,9 +685,7 @@ def valid_temperature_range(self) -> ColorTempRange: if not self.is_variable_color_temp: raise KasaException("Color temperature not supported") - return cast( - ColorTemperatureModule, self.modules["ColorTemperatureModule"] - ).valid_temperature_range + return self.modules[Module.ColorTemperature].valid_temperature_range @property def hsv(self) -> HSV: @@ -704,7 +696,7 @@ def hsv(self) -> HSV: if not self.is_color: raise KasaException("Bulb does not support color.") - return cast(ColorModule, self.modules["ColorModule"]).hsv + return self.modules[Module.Color].hsv @property def color_temp(self) -> int: @@ -712,9 +704,7 @@ def color_temp(self) -> int: if not self.is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") - return cast( - ColorTemperatureModule, self.modules["ColorTemperatureModule"] - ).color_temp + return self.modules[Module.ColorTemperature].color_temp @property def brightness(self) -> int: @@ -722,7 +712,7 @@ def brightness(self) -> int: if not self.is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") - return cast(Brightness, self.modules["Brightness"]).brightness + return self.modules[Module.Brightness].brightness async def set_hsv( self, @@ -744,9 +734,7 @@ async def set_hsv( if not self.is_color: raise KasaException("Bulb does not support color.") - return await cast(ColorModule, self.modules["ColorModule"]).set_hsv( - hue, saturation, value - ) + return await self.modules[Module.Color].set_hsv(hue, saturation, value) async def set_color_temp( self, temp: int, *, brightness=None, transition: int | None = None @@ -760,9 +748,7 @@ async def set_color_temp( """ if not self.is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") - return await cast( - ColorTemperatureModule, self.modules["ColorTemperatureModule"] - ).set_color_temp(temp) + return await self.modules[Module.ColorTemperature].set_color_temp(temp) async def set_brightness( self, brightness: int, *, transition: int | None = None @@ -777,9 +763,7 @@ async def set_brightness( if not self.is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") - return await cast(Brightness, self.modules["Brightness"]).set_brightness( - brightness - ) + return await self.modules[Module.Brightness].set_brightness(brightness) @property def presets(self) -> list[BulbPreset]: @@ -789,4 +773,4 @@ def presets(self) -> list[BulbPreset]: @property def has_effects(self) -> bool: """Return True if the device supports effects.""" - return "LightEffectModule" in self.modules + return Module.LightEffect in self.modules diff --git a/kasa/tests/smart/modules/test_light_effect.py b/kasa/tests/smart/modules/test_light_effect.py index cc0eee8a9..56c3f0960 100644 --- a/kasa/tests/smart/modules/test_light_effect.py +++ b/kasa/tests/smart/modules/test_light_effect.py @@ -6,7 +6,7 @@ from pytest_mock import MockerFixture from kasa import Device, Feature, Module -from kasa.smart.modules import LightEffectModule +from kasa.smart.modules import LightEffect from kasa.tests.device_fixtures import parametrize light_effect = parametrize( @@ -18,7 +18,7 @@ async def test_light_effect(dev: Device, mocker: MockerFixture): """Test light effect.""" light_effect = dev.modules.get(Module.LightEffect) - assert isinstance(light_effect, LightEffectModule) + assert isinstance(light_effect, LightEffect) feature = light_effect._module_features["light_effect"] assert feature.type == Feature.Type.Choice @@ -28,7 +28,7 @@ async def test_light_effect(dev: Device, mocker: MockerFixture): assert feature.choices for effect in chain(reversed(feature.choices), feature.choices): await light_effect.set_effect(effect) - enable = effect != LightEffectModule.LIGHT_EFFECTS_OFF + enable = effect != LightEffect.LIGHT_EFFECTS_OFF params: dict[str, bool | str] = {"enable": enable} if enable: params["id"] = light_effect._scenes_names_to_id[effect] diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 7addd4348..a438aa97f 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -737,7 +737,7 @@ async def test_feature_set(mocker, runner): dummy_device = await get_device_for_fixture_protocol( "P300(EU)_1.0_1.0.13.json", "SMART" ) - led_setter = mocker.patch("kasa.smart.modules.ledmodule.LedModule.set_led") + led_setter = mocker.patch("kasa.smart.modules.led.Led.set_led") mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) res = await runner.invoke( diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index a0af2cb12..ed9e57212 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -127,21 +127,21 @@ async def test_get_modules(): dummy_device = await get_device_for_fixture_protocol( "KS240(US)_1.0_1.0.5.json", "SMART" ) - from kasa.smart.modules import CloudModule + from kasa.smart.modules import Cloud # Modules on device - module = dummy_device.modules.get("CloudModule") + module = dummy_device.modules.get("Cloud") assert module assert module._device == dummy_device - assert isinstance(module, CloudModule) + assert isinstance(module, Cloud) module = dummy_device.modules.get(Module.Cloud) assert module assert module._device == dummy_device - assert isinstance(module, CloudModule) + assert isinstance(module, Cloud) # Modules on child - module = dummy_device.modules.get("FanModule") + module = dummy_device.modules.get("Fan") assert module assert module._device != dummy_device assert module._device._parent == dummy_device From d7b00336f4d44e9506ffcb832d5c82cc23db3eb5 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sat, 11 May 2024 19:40:08 +0100 Subject: [PATCH 429/892] Rename bulb interface to light and move fan and light interface to interfaces (#910) Also rename BulbPreset to LightPreset. --- kasa/__init__.py | 10 ++++---- kasa/cli.py | 6 ++--- kasa/interfaces/__init__.py | 14 +++++++++++ kasa/{ => interfaces}/fan.py | 2 +- kasa/{bulb.py => interfaces/light.py} | 12 +++++----- kasa/iot/iotbulb.py | 14 +++++------ kasa/smart/modules/color.py | 2 +- kasa/smart/modules/colortemperature.py | 2 +- kasa/smart/smartdevice.py | 8 +++---- kasa/tests/test_bulb.py | 32 +++++++++++++------------- kasa/tests/test_device.py | 1 + 11 files changed, 59 insertions(+), 44 deletions(-) create mode 100644 kasa/interfaces/__init__.py rename kasa/{ => interfaces}/fan.py (93%) rename kasa/{bulb.py => interfaces/light.py} (94%) diff --git a/kasa/__init__.py b/kasa/__init__.py index e9f64c708..8428154ed 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -16,7 +16,6 @@ from typing import TYPE_CHECKING from warnings import warn -from kasa.bulb import Bulb, BulbPreset from kasa.credentials import Credentials from kasa.device import Device from kasa.device_type import DeviceType @@ -36,6 +35,7 @@ UnsupportedDeviceError, ) from kasa.feature import Feature +from kasa.interfaces.light import Light, LightPreset from kasa.iotprotocol import ( IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 @@ -52,14 +52,14 @@ "BaseProtocol", "IotProtocol", "SmartProtocol", - "BulbPreset", + "LightPreset", "TurnOnBehaviors", "TurnOnBehavior", "DeviceType", "Feature", "EmeterStatus", "Device", - "Bulb", + "Light", "Plug", "Module", "KasaException", @@ -84,7 +84,7 @@ "SmartLightStrip": iot.IotLightStrip, "SmartStrip": iot.IotStrip, "SmartDimmer": iot.IotDimmer, - "SmartBulbPreset": BulbPreset, + "SmartBulbPreset": LightPreset, } deprecated_exceptions = { "SmartDeviceException": KasaException, @@ -124,7 +124,7 @@ def __getattr__(name): SmartLightStrip = iot.IotLightStrip SmartStrip = iot.IotStrip SmartDimmer = iot.IotDimmer - SmartBulbPreset = BulbPreset + SmartBulbPreset = LightPreset SmartDeviceException = KasaException UnsupportedDeviceException = UnsupportedDeviceError diff --git a/kasa/cli.py b/kasa/cli.py index 696dee274..d51679a2f 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -18,7 +18,6 @@ from kasa import ( AuthenticationError, - Bulb, ConnectionType, Credentials, Device, @@ -28,6 +27,7 @@ EncryptType, Feature, KasaException, + Light, UnsupportedDeviceError, ) from kasa.discover import DiscoveryResult @@ -859,7 +859,7 @@ async def usage(dev: Device, year, month, erase): @click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) @click.option("--transition", type=int, required=False) @pass_dev -async def brightness(dev: Bulb, brightness: int, transition: int): +async def brightness(dev: Light, brightness: int, transition: int): """Get or set brightness.""" if not dev.is_dimmable: echo("This device does not support brightness.") @@ -879,7 +879,7 @@ async def brightness(dev: Bulb, brightness: int, transition: int): ) @click.option("--transition", type=int, required=False) @pass_dev -async def temperature(dev: Bulb, temperature: int, transition: int): +async def temperature(dev: Light, temperature: int, transition: int): """Get or set color temperature.""" if not dev.is_variable_color_temp: echo("Device does not support color temperature") diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py new file mode 100644 index 000000000..d8d089c5c --- /dev/null +++ b/kasa/interfaces/__init__.py @@ -0,0 +1,14 @@ +"""Package for interfaces.""" + +from .fan import Fan +from .led import Led +from .light import Light, LightPreset +from .lighteffect import LightEffect + +__all__ = [ + "Fan", + "Led", + "Light", + "LightEffect", + "LightPreset", +] diff --git a/kasa/fan.py b/kasa/interfaces/fan.py similarity index 93% rename from kasa/fan.py rename to kasa/interfaces/fan.py index e881136e8..767fe89f1 100644 --- a/kasa/fan.py +++ b/kasa/interfaces/fan.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod -from .device import Device +from ..device import Device class Fan(Device, ABC): diff --git a/kasa/bulb.py b/kasa/interfaces/light.py similarity index 94% rename from kasa/bulb.py rename to kasa/interfaces/light.py index 52a722d92..141be1fdf 100644 --- a/kasa/bulb.py +++ b/kasa/interfaces/light.py @@ -7,7 +7,7 @@ from pydantic.v1 import BaseModel -from .device import Device +from ..device import Device class ColorTempRange(NamedTuple): @@ -25,8 +25,8 @@ class HSV(NamedTuple): value: int -class BulbPreset(BaseModel): - """Bulb configuration preset.""" +class LightPreset(BaseModel): + """Light configuration preset.""" index: int brightness: int @@ -42,8 +42,8 @@ class BulbPreset(BaseModel): mode: Optional[int] # noqa: UP007 -class Bulb(Device, ABC): - """Base class for TP-Link Bulb.""" +class Light(Device, ABC): + """Base class for TP-Link Light.""" def _raise_for_invalid_brightness(self, value): if not isinstance(value, int) or not (0 <= value <= 100): @@ -135,5 +135,5 @@ async def set_brightness( @property @abstractmethod - def presets(self) -> list[BulbPreset]: + def presets(self) -> list[LightPreset]: """Return a list of available bulb setting presets.""" diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 92bf98147..f6135fd18 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -9,10 +9,10 @@ from pydantic.v1 import BaseModel, Field, root_validator -from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..feature import Feature +from ..interfaces.light import HSV, ColorTempRange, Light, LightPreset from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, KasaException, requires_update @@ -88,7 +88,7 @@ class TurnOnBehaviors(BaseModel): _LOGGER = logging.getLogger(__name__) -class IotBulb(IotDevice, Bulb): +class IotBulb(IotDevice, Light): r"""Representation of a TP-Link Smart Bulb. To initialize, you have to await :func:`update()` at least once. @@ -170,9 +170,9 @@ class IotBulb(IotDevice, Bulb): Bulb configuration presets can be accessed using the :func:`presets` property: >>> bulb.presets - [BulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), BulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), BulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), BulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] + [LightPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), LightPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] - To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset` + To modify an existing preset, pass :class:`~kasa.smartbulb.LightPreset` instance to :func:`save_preset` method: >>> preset = bulb.presets[0] @@ -523,11 +523,11 @@ async def set_alias(self, alias: str) -> None: @property # type: ignore @requires_update - def presets(self) -> list[BulbPreset]: + def presets(self) -> list[LightPreset]: """Return a list of available bulb setting presets.""" - return [BulbPreset(**vals) for vals in self.sys_info["preferred_state"]] + return [LightPreset(**vals) for vals in self.sys_info["preferred_state"]] - async def save_preset(self, preset: BulbPreset): + async def save_preset(self, preset: LightPreset): """Save a setting preset. You can either construct a preset object manually, or pass an existing one diff --git a/kasa/smart/modules/color.py b/kasa/smart/modules/color.py index 979d4fec0..88d029082 100644 --- a/kasa/smart/modules/color.py +++ b/kasa/smart/modules/color.py @@ -4,8 +4,8 @@ from typing import TYPE_CHECKING -from ...bulb import HSV from ...feature import Feature +from ...interfaces.light import HSV from ..smartmodule import SmartModule if TYPE_CHECKING: diff --git a/kasa/smart/modules/colortemperature.py b/kasa/smart/modules/colortemperature.py index 88d5ea211..fa3b74126 100644 --- a/kasa/smart/modules/colortemperature.py +++ b/kasa/smart/modules/colortemperature.py @@ -5,8 +5,8 @@ import logging from typing import TYPE_CHECKING -from ...bulb import ColorTempRange from ...feature import Feature +from ...interfaces.light import ColorTempRange from ..smartmodule import SmartModule if TYPE_CHECKING: diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 122c943b5..e7b45c8e2 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -8,14 +8,14 @@ from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..aestransport import AesTransport -from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..device import Device, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode -from ..fan import Fan from ..feature import Feature +from ..interfaces.fan import Fan +from ..interfaces.light import HSV, ColorTempRange, Light, LightPreset from ..module import Module from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol @@ -39,7 +39,7 @@ # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. -class SmartDevice(Bulb, Fan, Device): +class SmartDevice(Light, Fan, Device): """Base class to represent a SMART protocol based device.""" def __init__( @@ -766,7 +766,7 @@ async def set_brightness( return await self.modules[Module.Brightness].set_brightness(brightness) @property - def presets(self) -> list[BulbPreset]: + def presets(self) -> list[LightPreset]: """Return a list of available bulb setting presets.""" return [] diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index acee8f74c..19400c836 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -7,7 +7,7 @@ Schema, ) -from kasa import Bulb, BulbPreset, Device, DeviceType, KasaException +from kasa import Device, DeviceType, KasaException, Light, LightPreset from kasa.iot import IotBulb, IotDimmer from kasa.smart import SmartDevice @@ -65,7 +65,7 @@ async def test_get_light_state(dev: IotBulb): @color_bulb @turn_on async def test_hsv(dev: Device, turn_on): - assert isinstance(dev, Bulb) + assert isinstance(dev, Light) await handle_turn_on(dev, turn_on) assert dev.is_color @@ -96,7 +96,7 @@ async def test_set_hsv_transition(dev: IotBulb, mocker): @color_bulb @turn_on -async def test_invalid_hsv(dev: Bulb, turn_on): +async def test_invalid_hsv(dev: Light, turn_on): await handle_turn_on(dev, turn_on) assert dev.is_color @@ -116,13 +116,13 @@ async def test_invalid_hsv(dev: Bulb, turn_on): @color_bulb @pytest.mark.skip("requires color feature") async def test_color_state_information(dev: Device): - assert isinstance(dev, Bulb) + assert isinstance(dev, Light) assert "HSV" in dev.state_information assert dev.state_information["HSV"] == dev.hsv @non_color_bulb -async def test_hsv_on_non_color(dev: Bulb): +async def test_hsv_on_non_color(dev: Light): assert not dev.is_color with pytest.raises(KasaException): @@ -134,7 +134,7 @@ async def test_hsv_on_non_color(dev: Bulb): @variable_temp @pytest.mark.skip("requires colortemp module") async def test_variable_temp_state_information(dev: Device): - assert isinstance(dev, Bulb) + assert isinstance(dev, Light) assert "Color temperature" in dev.state_information assert dev.state_information["Color temperature"] == dev.color_temp @@ -142,7 +142,7 @@ async def test_variable_temp_state_information(dev: Device): @variable_temp @turn_on async def test_try_set_colortemp(dev: Device, turn_on): - assert isinstance(dev, Bulb) + assert isinstance(dev, Light) await handle_turn_on(dev, turn_on) await dev.set_color_temp(2700) await dev.update() @@ -171,7 +171,7 @@ async def test_smart_temp_range(dev: SmartDevice): @variable_temp -async def test_out_of_range_temperature(dev: Bulb): +async def test_out_of_range_temperature(dev: Light): with pytest.raises(ValueError): await dev.set_color_temp(1000) with pytest.raises(ValueError): @@ -179,7 +179,7 @@ async def test_out_of_range_temperature(dev: Bulb): @non_variable_temp -async def test_non_variable_temp(dev: Bulb): +async def test_non_variable_temp(dev: Light): with pytest.raises(KasaException): await dev.set_color_temp(2700) @@ -193,7 +193,7 @@ async def test_non_variable_temp(dev: Bulb): @dimmable @turn_on async def test_dimmable_brightness(dev: Device, turn_on): - assert isinstance(dev, (Bulb, IotDimmer)) + assert isinstance(dev, (Light, IotDimmer)) await handle_turn_on(dev, turn_on) assert dev.is_dimmable @@ -230,7 +230,7 @@ async def test_dimmable_brightness_transition(dev: IotBulb, mocker): @dimmable -async def test_invalid_brightness(dev: Bulb): +async def test_invalid_brightness(dev: Light): assert dev.is_dimmable with pytest.raises(ValueError): @@ -241,7 +241,7 @@ async def test_invalid_brightness(dev: Bulb): @non_dimmable -async def test_non_dimmable(dev: Bulb): +async def test_non_dimmable(dev: Light): assert not dev.is_dimmable with pytest.raises(KasaException): @@ -291,7 +291,7 @@ async def test_modify_preset(dev: IotBulb, mocker): "saturation": 0, "color_temp": 0, } - preset = BulbPreset(**data) + preset = LightPreset(**data) assert preset.index == 0 assert preset.brightness == 10 @@ -305,7 +305,7 @@ async def test_modify_preset(dev: IotBulb, mocker): with pytest.raises(KasaException): await dev.save_preset( - BulbPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) + LightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) ) @@ -314,11 +314,11 @@ async def test_modify_preset(dev: IotBulb, mocker): ("preset", "payload"), [ ( - BulbPreset(index=0, hue=0, brightness=1, saturation=0), + LightPreset(index=0, hue=0, brightness=1, saturation=0), {"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, ), ( - BulbPreset(index=0, brightness=1, id="testid", mode=2, custom=0), + LightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, ), ], diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index d0ed0c71e..76ea1acf6 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -25,6 +25,7 @@ def _get_subclasses(of_class): inspect.isclass(obj) and issubclass(obj, of_class) and module.__package__ != "kasa" + and module.__package__ != "kasa.interfaces" ): subclasses.add((module.__package__ + "." + name, obj)) return subclasses From 33d839866ec1d1d83a24399fcc0f723e4deb2c43 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 13 May 2024 17:34:44 +0100 Subject: [PATCH 430/892] Make Light and Fan a common module interface (#911) --- kasa/interfaces/fan.py | 4 +- kasa/interfaces/light.py | 16 +- kasa/iot/iotbulb.py | 58 ++---- kasa/iot/iotdevice.py | 6 + kasa/iot/iotdimmer.py | 27 +-- kasa/iot/iotlightstrip.py | 4 + kasa/iot/iotplug.py | 4 + kasa/iot/iotstrip.py | 4 + kasa/iot/modules/__init__.py | 2 + kasa/iot/modules/light.py | 188 ++++++++++++++++++ kasa/module.py | 8 +- kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/brightness.py | 24 ++- kasa/smart/modules/light.py | 126 ++++++++++++ kasa/smart/smartdevice.py | 151 ++------------ kasa/tests/device_fixtures.py | 12 +- kasa/tests/smart/features/test_brightness.py | 6 +- kasa/tests/smart/modules/test_contact.py | 2 +- kasa/tests/smart/modules/test_fan.py | 20 +- kasa/tests/smart/modules/test_firmware.py | 2 +- kasa/tests/smart/modules/test_humidity.py | 2 +- kasa/tests/smart/modules/test_light_effect.py | 2 +- kasa/tests/smart/modules/test_temperature.py | 4 +- .../smart/modules/test_temperaturecontrol.py | 2 +- kasa/tests/smart/modules/test_waterleak.py | 2 +- kasa/tests/test_bulb.py | 97 +++++---- kasa/tests/test_common_modules.py | 38 +++- kasa/tests/test_dimmer.py | 20 +- kasa/tests/test_discovery.py | 8 +- kasa/tests/test_feature.py | 6 +- kasa/tests/test_lightstrip.py | 16 +- kasa/tests/test_smartdevice.py | 23 --- 32 files changed, 544 insertions(+), 342 deletions(-) create mode 100644 kasa/iot/modules/light.py create mode 100644 kasa/smart/modules/light.py diff --git a/kasa/interfaces/fan.py b/kasa/interfaces/fan.py index 767fe89f1..89d8d82be 100644 --- a/kasa/interfaces/fan.py +++ b/kasa/interfaces/fan.py @@ -4,10 +4,10 @@ from abc import ABC, abstractmethod -from ..device import Device +from ..module import Module -class Fan(Device, ABC): +class Fan(Module, ABC): """Interface for a Fan.""" @property diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py index 141be1fdf..3a8805c10 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -7,7 +7,7 @@ from pydantic.v1 import BaseModel -from ..device import Device +from ..module import Module class ColorTempRange(NamedTuple): @@ -42,12 +42,13 @@ class LightPreset(BaseModel): mode: Optional[int] # noqa: UP007 -class Light(Device, ABC): +class Light(Module, ABC): """Base class for TP-Link Light.""" - def _raise_for_invalid_brightness(self, value): - if not isinstance(value, int) or not (0 <= value <= 100): - raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") + @property + @abstractmethod + def is_dimmable(self) -> bool: + """Whether the light supports brightness changes.""" @property @abstractmethod @@ -132,8 +133,3 @@ async def set_brightness( :param int brightness: brightness in percent :param int transition: transition in milliseconds. """ - - @property - @abstractmethod - def presets(self) -> list[LightPreset]: - """Return a list of available bulb setting presets.""" diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index f6135fd18..e2d860432 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -11,12 +11,20 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature -from ..interfaces.light import HSV, ColorTempRange, Light, LightPreset +from ..interfaces.light import HSV, ColorTempRange, LightPreset from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, KasaException, requires_update -from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage +from .modules import ( + Antitheft, + Cloud, + Countdown, + Emeter, + Light, + Schedule, + Time, + Usage, +) class BehaviorMode(str, Enum): @@ -88,7 +96,7 @@ class TurnOnBehaviors(BaseModel): _LOGGER = logging.getLogger(__name__) -class IotBulb(IotDevice, Light): +class IotBulb(IotDevice): r"""Representation of a TP-Link Smart Bulb. To initialize, you have to await :func:`update()` at least once. @@ -199,6 +207,10 @@ def __init__( ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Bulb + + async def _initialize_modules(self): + """Initialize modules not added in init.""" + await super()._initialize_modules() self.add_module( Module.IotSchedule, Schedule(self, "smartlife.iot.common.schedule") ) @@ -210,39 +222,7 @@ def __init__( self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud")) - - async def _initialize_features(self): - await super()._initialize_features() - - if bool(self.sys_info["is_dimmable"]): # pragma: no branch - self._add_feature( - Feature( - device=self, - id="brightness", - name="Brightness", - attribute_getter="brightness", - attribute_setter="set_brightness", - minimum_value=1, - maximum_value=100, - type=Feature.Type.Number, - category=Feature.Category.Primary, - ) - ) - - if self.is_variable_color_temp: - self._add_feature( - Feature( - device=self, - id="color_temperature", - name="Color temperature", - container=self, - attribute_getter="color_temp", - attribute_setter="set_color_temp", - range_getter="valid_temperature_range", - category=Feature.Category.Primary, - type=Feature.Type.Number, - ) - ) + self.add_module(Module.Light, Light(self, "light")) @property # type: ignore @requires_update @@ -458,6 +438,10 @@ async def set_color_temp( return await self.set_light_state(light_state, transition=transition) + def _raise_for_invalid_brightness(self, value): + if not isinstance(value, int) or not (0 <= value <= 100): + raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") + @property # type: ignore @requires_update def brightness(self) -> int: diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index e4c1bb13a..f3ac5321c 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -307,6 +307,9 @@ async def update(self, update_children: bool = True): self._last_update = response self._set_sys_info(response["system"]["get_sysinfo"]) + if not self._modules: + await self._initialize_modules() + await self._modular_update(req) if not self._features: @@ -314,6 +317,9 @@ async def update(self, update_children: bool = True): self._set_sys_info(self._last_update["system"]["get_sysinfo"]) + async def _initialize_modules(self): + """Initialize modules not added in init.""" + async def _initialize_features(self): self._add_feature( Feature( diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index fed9e7e79..d6f49c246 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -7,12 +7,11 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature from ..module import Module from ..protocol import BaseProtocol from .iotdevice import KasaException, requires_update from .iotplug import IotPlug -from .modules import AmbientLight, Motion +from .modules import AmbientLight, Light, Motion class ButtonAction(Enum): @@ -80,29 +79,15 @@ def __init__( ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Dimmer + + async def _initialize_modules(self): + """Initialize modules.""" + await super()._initialize_modules() # TODO: need to be verified if it's okay to call these on HS220 w/o these # TODO: need to be figured out what's the best approach to detect support self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR")) self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS")) - - async def _initialize_features(self): - await super()._initialize_features() - - if "brightness" in self.sys_info: # pragma: no branch - self._add_feature( - Feature( - device=self, - id="brightness", - name="Brightness", - attribute_getter="brightness", - attribute_setter="set_brightness", - minimum_value=1, - maximum_value=100, - unit="%", - type=Feature.Type.Number, - category=Feature.Category.Primary, - ) - ) + self.add_module(Module.Light, Light(self, "light")) @property # type: ignore @requires_update diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 7cdbe43ba..6bc562583 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -56,6 +56,10 @@ def __init__( ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.LightStrip + + async def _initialize_modules(self): + """Initialize modules not added in init.""" + await super()._initialize_modules() self.add_module( Module.LightEffect, LightEffect(self, "smartlife.iot.lighting_effect"), diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 6aace4f8a..072261783 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -53,6 +53,10 @@ def __init__( ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug + + async def _initialize_modules(self): + """Initialize modules.""" + await super()._initialize_modules() self.add_module(Module.IotSchedule, Schedule(self, "schedule")) self.add_module(Module.IotUsage, Usage(self, "schedule")) self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 4aa966e1f..c4dcc57f5 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -255,6 +255,10 @@ def __init__(self, host: str, parent: IotStrip, child_id: str) -> None: self._set_sys_info(parent.sys_info) self._device_type = DeviceType.StripSocket self.protocol = parent.protocol # Must use the same connection as the parent + + async def _initialize_modules(self): + """Initialize modules not added in init.""" + await super()._initialize_modules() self.add_module("time", Time(self, "time")) async def update(self, update_children: bool = True): diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index e0febfd41..2d6f6a01e 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -6,6 +6,7 @@ from .countdown import Countdown from .emeter import Emeter from .led import Led +from .light import Light from .lighteffect import LightEffect from .motion import Motion from .rulemodule import Rule, RuleModule @@ -20,6 +21,7 @@ "Countdown", "Emeter", "Led", + "Light", "LightEffect", "Motion", "Rule", diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py new file mode 100644 index 000000000..89243a1b5 --- /dev/null +++ b/kasa/iot/modules/light.py @@ -0,0 +1,188 @@ +"""Implementation of brightness module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from ...exceptions import KasaException +from ...feature import Feature +from ...interfaces.light import HSV, ColorTempRange +from ...interfaces.light import Light as LightInterface +from ..iotmodule import IotModule + +if TYPE_CHECKING: + from ..iotbulb import IotBulb + from ..iotdimmer import IotDimmer + + +BRIGHTNESS_MIN = 0 +BRIGHTNESS_MAX = 100 + + +class Light(IotModule, LightInterface): + """Implementation of brightness module.""" + + _device: IotBulb | IotDimmer + + def _initialize_features(self): + """Initialize features.""" + super()._initialize_features() + device = self._device + + if self._device.is_dimmable: + self._add_feature( + Feature( + device, + id="brightness", + name="Brightness", + container=self, + attribute_getter="brightness", + attribute_setter="set_brightness", + minimum_value=BRIGHTNESS_MIN, + maximum_value=BRIGHTNESS_MAX, + type=Feature.Type.Number, + category=Feature.Category.Primary, + ) + ) + if self._device.is_variable_color_temp: + self._add_feature( + Feature( + device=device, + id="color_temperature", + name="Color temperature", + container=self, + attribute_getter="color_temp", + attribute_setter="set_color_temp", + range_getter="valid_temperature_range", + category=Feature.Category.Primary, + type=Feature.Type.Number, + ) + ) + if self._device.is_color: + self._add_feature( + Feature( + device=device, + id="hsv", + name="HSV", + container=self, + attribute_getter="hsv", + attribute_setter="set_hsv", + # TODO proper type for setting hsv + type=Feature.Type.Unknown, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Brightness is contained in the main device info response. + return {} + + def _get_bulb_device(self) -> IotBulb | None: + if self._device.is_bulb or self._device.is_light_strip: + return cast("IotBulb", self._device) + return None + + @property # type: ignore + def is_dimmable(self) -> int: + """Whether the bulb supports brightness changes.""" + return self._device.is_dimmable + + @property # type: ignore + def brightness(self) -> int: + """Return the current brightness in percentage.""" + return self._device.brightness + + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set the brightness in percentage. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + return await self._device.set_brightness(brightness, transition=transition) + + @property + def is_color(self) -> bool: + """Whether the light supports color changes.""" + if (bulb := self._get_bulb_device()) is None: + return False + return bulb.is_color + + @property + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + if (bulb := self._get_bulb_device()) is None: + return False + return bulb.is_variable_color_temp + + @property + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + if (bulb := self._get_bulb_device()) is None: + return False + return bulb.has_effects + + @property + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if (bulb := self._get_bulb_device()) is None or not bulb.is_color: + raise KasaException("Light does not support color.") + return bulb.hsv + + async def set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = None, + ) -> dict: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value in percentage [0, 100] + :param int transition: transition in milliseconds. + """ + if (bulb := self._get_bulb_device()) is None or not bulb.is_color: + raise KasaException("Light does not support color.") + return await bulb.set_hsv(hue, saturation, value, transition=transition) + + @property + def valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + return bulb.valid_temperature_range + + @property + def color_temp(self) -> int: + """Whether the bulb supports color temperature changes.""" + if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + return bulb.color_temp + + async def set_color_temp( + self, temp: int, *, brightness=None, transition: int | None = None + ) -> dict: + """Set the color temperature of the device in kelvin. + + Note, transition is not supported and will be ignored. + + :param int temp: The new color temperature, in Kelvin + :param int transition: transition in milliseconds. + """ + if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + return await bulb.set_color_temp( + temp, brightness=brightness, transition=transition + ) diff --git a/kasa/module.py b/kasa/module.py index 55eeea185..9b541ce04 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -15,9 +15,8 @@ from .modulemapping import ModuleName if TYPE_CHECKING: + from . import interfaces from .device import Device - from .interfaces.led import Led - from .interfaces.lighteffect import LightEffect from .iot import modules as iot from .smart import modules as smart @@ -34,8 +33,9 @@ class Module(ABC): """ # Common Modules - LightEffect: Final[ModuleName[LightEffect]] = ModuleName("LightEffect") - Led: Final[ModuleName[Led]] = ModuleName("Led") + LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect") + Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") + Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") # IOT only Modules IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index e119e0675..b295bcb20 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -16,6 +16,7 @@ from .frostprotection import FrostProtection from .humiditysensor import HumiditySensor from .led import Led +from .light import Light from .lighteffect import LightEffect from .lighttransition import LightTransition from .reportmode import ReportMode @@ -41,6 +42,7 @@ "Fan", "Firmware", "Cloud", + "Light", "LightEffect", "LightTransition", "ColorTemperature", diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index b0b58c077..fbd908083 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -2,16 +2,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - - -BRIGHTNESS_MIN = 1 +BRIGHTNESS_MIN = 0 BRIGHTNESS_MAX = 100 @@ -20,8 +14,11 @@ class Brightness(SmartModule): REQUIRED_COMPONENT = "brightness" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features.""" + super()._initialize_features() + + device = self._device self._add_feature( Feature( device, @@ -47,8 +44,11 @@ def brightness(self): """Return current brightness.""" return self.data["brightness"] - async def set_brightness(self, brightness: int): - """Set the brightness.""" + async def set_brightness(self, brightness: int, *, transition: int | None = None): + """Set the brightness. A brightness value of 0 will turn off the light. + + Note, transition is not supported and will be ignored. + """ if not isinstance(brightness, int) or not ( BRIGHTNESS_MIN <= brightness <= BRIGHTNESS_MAX ): @@ -57,6 +57,8 @@ async def set_brightness(self, brightness: int): f"(valid range: {BRIGHTNESS_MIN}-{BRIGHTNESS_MAX}%)" ) + if brightness == 0: + return await self._device.turn_off() return await self.call("set_device_info", {"brightness": brightness}) async def _check_supported(self): diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py new file mode 100644 index 000000000..88d6486bc --- /dev/null +++ b/kasa/smart/modules/light.py @@ -0,0 +1,126 @@ +"""Module for led controls.""" + +from __future__ import annotations + +from ...exceptions import KasaException +from ...interfaces.light import HSV, ColorTempRange +from ...interfaces.light import Light as LightInterface +from ...module import Module +from ..smartmodule import SmartModule + + +class Light(SmartModule, LightInterface): + """Implementation of a light.""" + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def is_color(self) -> bool: + """Whether the bulb supports color changes.""" + return Module.Color in self._device.modules + + @property + def is_dimmable(self) -> bool: + """Whether the bulb supports brightness changes.""" + return Module.Brightness in self._device.modules + + @property + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + return Module.ColorTemperature in self._device.modules + + @property + def valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + if not self.is_variable_color_temp: + raise KasaException("Color temperature not supported") + + return self._device.modules[Module.ColorTemperature].valid_temperature_range + + @property + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if not self.is_color: + raise KasaException("Bulb does not support color.") + + return self._device.modules[Module.Color].hsv + + @property + def color_temp(self) -> int: + """Whether the bulb supports color temperature changes.""" + if not self.is_variable_color_temp: + raise KasaException("Bulb does not support colortemp.") + + return self._device.modules[Module.ColorTemperature].color_temp + + @property + def brightness(self) -> int: + """Return the current brightness in percentage.""" + if not self.is_dimmable: # pragma: no cover + raise KasaException("Bulb is not dimmable.") + + return self._device.modules[Module.Brightness].brightness + + async def set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = None, + ) -> dict: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value between 1 and 100 + :param int transition: transition in milliseconds. + """ + if not self.is_color: + raise KasaException("Bulb does not support color.") + + return await self._device.modules[Module.Color].set_hsv(hue, saturation, value) + + async def set_color_temp( + self, temp: int, *, brightness=None, transition: int | None = None + ) -> dict: + """Set the color temperature of the device in kelvin. + + Note, transition is not supported and will be ignored. + + :param int temp: The new color temperature, in Kelvin + :param int transition: transition in milliseconds. + """ + if not self.is_variable_color_temp: + raise KasaException("Bulb does not support colortemp.") + return await self._device.modules[Module.ColorTemperature].set_color_temp(temp) + + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set the brightness in percentage. + + Note, transition is not supported and will be ignored. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + if not self.is_dimmable: # pragma: no cover + raise KasaException("Bulb is not dimmable.") + + return await self._device.modules[Module.Brightness].set_brightness(brightness) + + @property + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + return Module.LightEffect in self._device.modules diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index e7b45c8e2..e1939c70b 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -14,8 +14,7 @@ from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..feature import Feature -from ..interfaces.fan import Fan -from ..interfaces.light import HSV, ColorTempRange, Light, LightPreset +from ..interfaces.light import LightPreset from ..module import Module from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol @@ -23,6 +22,7 @@ Cloud, DeviceModule, Firmware, + Light, Time, ) from .smartmodule import SmartModule @@ -39,7 +39,7 @@ # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. -class SmartDevice(Light, Fan, Device): +class SmartDevice(Device): """Base class to represent a SMART protocol based device.""" def __init__( @@ -231,6 +231,13 @@ async def _initialize_modules(self): if await module._check_supported(): self._modules[module.name] = module + if ( + Module.Brightness in self._modules + or Module.Color in self._modules + or Module.ColorTemperature in self._modules + ): + self._modules[Light.__name__] = Light(self, "light") + async def _initialize_features(self): """Initialize device features.""" self._add_feature( @@ -318,8 +325,11 @@ async def _initialize_features(self): ) ) - for module in self._modules.values(): - module._initialize_features() + for module in self.modules.values(): + # Check if module features have already been initialized. + # i.e. when _exposes_child_modules is true + if not module._module_features: + module._initialize_features() for feat in module._module_features.values(): self._add_feature(feat) @@ -639,138 +649,7 @@ def _get_device_type_from_components( _LOGGER.warning("Unknown device type, falling back to plug") return DeviceType.Plug - # Fan interface methods - - @property - def is_fan(self) -> bool: - """Return True if the device is a fan.""" - return Module.Fan in self.modules - - @property - def fan_speed_level(self) -> int: - """Return fan speed level.""" - if not self.is_fan: - raise KasaException("Device is not a Fan") - return self.modules[Module.Fan].fan_speed_level - - async def set_fan_speed_level(self, level: int): - """Set fan speed level.""" - if not self.is_fan: - raise KasaException("Device is not a Fan") - await self.modules[Module.Fan].set_fan_speed_level(level) - - # Bulb interface methods - - @property - def is_color(self) -> bool: - """Whether the bulb supports color changes.""" - return Module.Color in self.modules - - @property - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - return Module.Brightness in self.modules - - @property - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - return Module.ColorTemperature in self.modules - - @property - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - if not self.is_variable_color_temp: - raise KasaException("Color temperature not supported") - - return self.modules[Module.ColorTemperature].valid_temperature_range - - @property - def hsv(self) -> HSV: - """Return the current HSV state of the bulb. - - :return: hue, saturation and value (degrees, %, %) - """ - if not self.is_color: - raise KasaException("Bulb does not support color.") - - return self.modules[Module.Color].hsv - - @property - def color_temp(self) -> int: - """Whether the bulb supports color temperature changes.""" - if not self.is_variable_color_temp: - raise KasaException("Bulb does not support colortemp.") - - return self.modules[Module.ColorTemperature].color_temp - - @property - def brightness(self) -> int: - """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover - raise KasaException("Bulb is not dimmable.") - - return self.modules[Module.Brightness].brightness - - async def set_hsv( - self, - hue: int, - saturation: int, - value: int | None = None, - *, - transition: int | None = None, - ) -> dict: - """Set new HSV. - - Note, transition is not supported and will be ignored. - - :param int hue: hue in degrees - :param int saturation: saturation in percentage [0,100] - :param int value: value between 1 and 100 - :param int transition: transition in milliseconds. - """ - if not self.is_color: - raise KasaException("Bulb does not support color.") - - return await self.modules[Module.Color].set_hsv(hue, saturation, value) - - async def set_color_temp( - self, temp: int, *, brightness=None, transition: int | None = None - ) -> dict: - """Set the color temperature of the device in kelvin. - - Note, transition is not supported and will be ignored. - - :param int temp: The new color temperature, in Kelvin - :param int transition: transition in milliseconds. - """ - if not self.is_variable_color_temp: - raise KasaException("Bulb does not support colortemp.") - return await self.modules[Module.ColorTemperature].set_color_temp(temp) - - async def set_brightness( - self, brightness: int, *, transition: int | None = None - ) -> dict: - """Set the brightness in percentage. - - Note, transition is not supported and will be ignored. - - :param int brightness: brightness in percent - :param int transition: transition in milliseconds. - """ - if not self.is_dimmable: # pragma: no cover - raise KasaException("Bulb is not dimmable.") - - return await self.modules[Module.Brightness].set_brightness(brightness) - @property def presets(self) -> list[LightPreset]: """Return a list of available bulb setting presets.""" return [] - - @property - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - return Module.LightEffect in self.modules diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 826465e5e..e8fbeeece 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -203,14 +203,14 @@ def parametrize( "wall switches iot", model_filter=SWITCHES, protocol_filter={"IOT"} ) strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"}) -dimmer = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) -lightstrip = parametrize( +dimmer_iot = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) +lightstrip_iot = parametrize( "lightstrips", model_filter=LIGHT_STRIPS, protocol_filter={"IOT"} ) # bulb types -dimmable = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) -non_dimmable = parametrize( +dimmable_iot = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) +non_dimmable_iot = parametrize( "non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"} ) variable_temp = parametrize( @@ -292,12 +292,12 @@ def parametrize( def check_categories(): """Check that every fixture file is categorized.""" categorized_fixtures = set( - dimmer.args[1] + dimmer_iot.args[1] + strip.args[1] + plug.args[1] + bulb.args[1] + wallswitch.args[1] - + lightstrip.args[1] + + lightstrip_iot.args[1] + bulb_smart.args[1] + dimmers_smart.args[1] + hubs_smart.args[1] diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index 3c00a4d11..e3c3c5303 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -2,7 +2,7 @@ from kasa.iot import IotDevice from kasa.smart import SmartDevice -from kasa.tests.conftest import dimmable, parametrize +from kasa.tests.conftest import dimmable_iot, parametrize brightness = parametrize("brightness smart", component_filter="brightness") @@ -16,7 +16,7 @@ async def test_brightness_component(dev: SmartDevice): assert "brightness" in dev._components # Test getting the value - feature = brightness._module_features["brightness"] + feature = dev.features["brightness"] assert isinstance(feature.value, int) assert feature.value > 1 and feature.value <= 100 @@ -32,7 +32,7 @@ async def test_brightness_component(dev: SmartDevice): await feature.set_value(feature.maximum_value + 10) -@dimmable +@dimmable_iot async def test_brightness_dimmable(dev: IotDevice): """Test brightness feature.""" assert isinstance(dev, IotDevice) diff --git a/kasa/tests/smart/modules/test_contact.py b/kasa/tests/smart/modules/test_contact.py index 88677c58f..11440871e 100644 --- a/kasa/tests/smart/modules/test_contact.py +++ b/kasa/tests/smart/modules/test_contact.py @@ -23,6 +23,6 @@ async def test_contact_features(dev: SmartDevice, feature, type): prop = getattr(contact, feature) assert isinstance(prop, type) - feat = contact._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 9597471b6..b9627d9fa 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -14,7 +14,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): fan = dev.modules.get(Module.Fan) assert fan - level_feature = fan._module_features["fan_speed_level"] + level_feature = dev.features["fan_speed_level"] assert ( level_feature.minimum_value <= level_feature.value @@ -38,7 +38,7 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): """Test sleep mode feature.""" fan = dev.modules.get(Module.Fan) assert fan - sleep_feature = fan._module_features["fan_sleep_mode"] + sleep_feature = dev.features["fan_sleep_mode"] assert isinstance(sleep_feature.value, bool) call = mocker.spy(fan, "call") @@ -52,7 +52,7 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): @fan -async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): +async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): """Test fan speed on device interface.""" assert isinstance(dev, SmartDevice) fan = dev.modules.get(Module.Fan) @@ -60,21 +60,21 @@ async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): device = fan._device assert device.is_fan - await device.set_fan_speed_level(1) + await fan.set_fan_speed_level(1) await dev.update() - assert device.fan_speed_level == 1 + assert fan.fan_speed_level == 1 assert device.is_on - await device.set_fan_speed_level(4) + await fan.set_fan_speed_level(4) await dev.update() - assert device.fan_speed_level == 4 + assert fan.fan_speed_level == 4 - await device.set_fan_speed_level(0) + await fan.set_fan_speed_level(0) await dev.update() assert not device.is_on with pytest.raises(ValueError): - await device.set_fan_speed_level(-1) + await fan.set_fan_speed_level(-1) with pytest.raises(ValueError): - await device.set_fan_speed_level(5) + await fan.set_fan_speed_level(5) diff --git a/kasa/tests/smart/modules/test_firmware.py b/kasa/tests/smart/modules/test_firmware.py index 8f329f708..b592041f4 100644 --- a/kasa/tests/smart/modules/test_firmware.py +++ b/kasa/tests/smart/modules/test_firmware.py @@ -43,7 +43,7 @@ async def test_firmware_features( prop = getattr(fw, prop_name) assert isinstance(prop, type) - feat = fw._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/smart/modules/test_humidity.py b/kasa/tests/smart/modules/test_humidity.py index bf746f2b8..790393e5d 100644 --- a/kasa/tests/smart/modules/test_humidity.py +++ b/kasa/tests/smart/modules/test_humidity.py @@ -23,6 +23,6 @@ async def test_humidity_features(dev, feature, type): prop = getattr(humidity, feature) assert isinstance(prop, type) - feat = humidity._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/smart/modules/test_light_effect.py b/kasa/tests/smart/modules/test_light_effect.py index 56c3f0960..ed691e664 100644 --- a/kasa/tests/smart/modules/test_light_effect.py +++ b/kasa/tests/smart/modules/test_light_effect.py @@ -20,7 +20,7 @@ async def test_light_effect(dev: Device, mocker: MockerFixture): light_effect = dev.modules.get(Module.LightEffect) assert isinstance(light_effect, LightEffect) - feature = light_effect._module_features["light_effect"] + feature = dev.features["light_effect"] assert feature.type == Feature.Type.Choice call = mocker.spy(light_effect, "call") diff --git a/kasa/tests/smart/modules/test_temperature.py b/kasa/tests/smart/modules/test_temperature.py index a7d20dac6..c9685b9d7 100644 --- a/kasa/tests/smart/modules/test_temperature.py +++ b/kasa/tests/smart/modules/test_temperature.py @@ -29,7 +29,7 @@ async def test_temperature_features(dev, feature, type): prop = getattr(temp_module, feature) assert isinstance(prop, type) - feat = temp_module._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) @@ -42,6 +42,6 @@ async def test_temperature_warning(dev): assert hasattr(temp_module, "temperature_warning") assert isinstance(temp_module.temperature_warning, bool) - feat = temp_module._module_features["temperature_warning"] + feat = dev.features["temperature_warning"] assert feat.value == temp_module.temperature_warning assert isinstance(feat.value, bool) diff --git a/kasa/tests/smart/modules/test_temperaturecontrol.py b/kasa/tests/smart/modules/test_temperaturecontrol.py index 4154cbf89..16e01ed2b 100644 --- a/kasa/tests/smart/modules/test_temperaturecontrol.py +++ b/kasa/tests/smart/modules/test_temperaturecontrol.py @@ -28,7 +28,7 @@ async def test_temperature_control_features(dev, feature, type): prop = getattr(temp_module, feature) assert isinstance(prop, type) - feat = temp_module._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/smart/modules/test_waterleak.py b/kasa/tests/smart/modules/test_waterleak.py index aa589e447..615361934 100644 --- a/kasa/tests/smart/modules/test_waterleak.py +++ b/kasa/tests/smart/modules/test_waterleak.py @@ -25,7 +25,7 @@ async def test_waterleak_properties(dev, feature, prop_name, type): prop = getattr(waterleak, prop_name) assert isinstance(prop, type) - feat = waterleak._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 19400c836..5cfa25daa 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -7,19 +7,18 @@ Schema, ) -from kasa import Device, DeviceType, KasaException, Light, LightPreset +from kasa import Device, DeviceType, KasaException, LightPreset, Module from kasa.iot import IotBulb, IotDimmer -from kasa.smart import SmartDevice from .conftest import ( bulb, bulb_iot, color_bulb, color_bulb_iot, - dimmable, + dimmable_iot, handle_turn_on, non_color_bulb, - non_dimmable, + non_dimmable_iot, non_variable_temp, turn_on, variable_temp, @@ -65,19 +64,20 @@ async def test_get_light_state(dev: IotBulb): @color_bulb @turn_on async def test_hsv(dev: Device, turn_on): - assert isinstance(dev, Light) + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) - assert dev.is_color + assert light.is_color - hue, saturation, brightness = dev.hsv + hue, saturation, brightness = light.hsv assert 0 <= hue <= 360 assert 0 <= saturation <= 100 assert 0 <= brightness <= 100 - await dev.set_hsv(hue=1, saturation=1, value=1) + await light.set_hsv(hue=1, saturation=1, value=1) await dev.update() - hue, saturation, brightness = dev.hsv + hue, saturation, brightness = light.hsv assert hue == 1 assert saturation == 1 assert brightness == 1 @@ -96,57 +96,64 @@ async def test_set_hsv_transition(dev: IotBulb, mocker): @color_bulb @turn_on -async def test_invalid_hsv(dev: Light, turn_on): +async def test_invalid_hsv(dev: Device, turn_on): + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) - assert dev.is_color + assert light.is_color for invalid_hue in [-1, 361, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(invalid_hue, 0, 0) # type: ignore[arg-type] + await light.set_hsv(invalid_hue, 0, 0) # type: ignore[arg-type] for invalid_saturation in [-1, 101, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(0, invalid_saturation, 0) # type: ignore[arg-type] + await light.set_hsv(0, invalid_saturation, 0) # type: ignore[arg-type] for invalid_brightness in [-1, 101, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(0, 0, invalid_brightness) # type: ignore[arg-type] + await light.set_hsv(0, 0, invalid_brightness) # type: ignore[arg-type] @color_bulb @pytest.mark.skip("requires color feature") async def test_color_state_information(dev: Device): - assert isinstance(dev, Light) + light = dev.modules.get(Module.Light) + assert light assert "HSV" in dev.state_information - assert dev.state_information["HSV"] == dev.hsv + assert dev.state_information["HSV"] == light.hsv @non_color_bulb -async def test_hsv_on_non_color(dev: Light): - assert not dev.is_color +async def test_hsv_on_non_color(dev: Device): + light = dev.modules.get(Module.Light) + assert light + assert not light.is_color with pytest.raises(KasaException): - await dev.set_hsv(0, 0, 0) + await light.set_hsv(0, 0, 0) with pytest.raises(KasaException): - print(dev.hsv) + print(light.hsv) @variable_temp @pytest.mark.skip("requires colortemp module") async def test_variable_temp_state_information(dev: Device): - assert isinstance(dev, Light) + light = dev.modules.get(Module.Light) + assert light assert "Color temperature" in dev.state_information - assert dev.state_information["Color temperature"] == dev.color_temp + assert dev.state_information["Color temperature"] == light.color_temp @variable_temp @turn_on async def test_try_set_colortemp(dev: Device, turn_on): - assert isinstance(dev, Light) + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) - await dev.set_color_temp(2700) + await light.set_color_temp(2700) await dev.update() - assert dev.color_temp == 2700 + assert light.color_temp == 2700 @variable_temp_iot @@ -166,34 +173,40 @@ async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): @variable_temp_smart -async def test_smart_temp_range(dev: SmartDevice): - assert dev.valid_temperature_range +async def test_smart_temp_range(dev: Device): + light = dev.modules.get(Module.Light) + assert light + assert light.valid_temperature_range @variable_temp -async def test_out_of_range_temperature(dev: Light): +async def test_out_of_range_temperature(dev: Device): + light = dev.modules.get(Module.Light) + assert light with pytest.raises(ValueError): - await dev.set_color_temp(1000) + await light.set_color_temp(1000) with pytest.raises(ValueError): - await dev.set_color_temp(10000) + await light.set_color_temp(10000) @non_variable_temp -async def test_non_variable_temp(dev: Light): +async def test_non_variable_temp(dev: Device): + light = dev.modules.get(Module.Light) + assert light with pytest.raises(KasaException): - await dev.set_color_temp(2700) + await light.set_color_temp(2700) with pytest.raises(KasaException): - print(dev.valid_temperature_range) + print(light.valid_temperature_range) with pytest.raises(KasaException): - print(dev.color_temp) + print(light.color_temp) -@dimmable +@dimmable_iot @turn_on -async def test_dimmable_brightness(dev: Device, turn_on): - assert isinstance(dev, (Light, IotDimmer)) +async def test_dimmable_brightness(dev: IotBulb, turn_on): + assert isinstance(dev, (IotBulb, IotDimmer)) await handle_turn_on(dev, turn_on) assert dev.is_dimmable @@ -229,8 +242,8 @@ async def test_dimmable_brightness_transition(dev: IotBulb, mocker): set_light_state.assert_called_with({"brightness": 10}, transition=1000) -@dimmable -async def test_invalid_brightness(dev: Light): +@dimmable_iot +async def test_invalid_brightness(dev: IotBulb): assert dev.is_dimmable with pytest.raises(ValueError): @@ -240,8 +253,8 @@ async def test_invalid_brightness(dev: Light): await dev.set_brightness(-100) -@non_dimmable -async def test_non_dimmable(dev: Light): +@non_dimmable_iot +async def test_non_dimmable(dev: IotBulb): assert not dev.is_dimmable with pytest.raises(KasaException): @@ -380,7 +393,7 @@ async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): @bulb -def test_device_type_bulb(dev): +def test_device_type_bulb(dev: Device): if dev.is_light_strip: pytest.skip("bulb has also lightstrips to test the api") assert dev.device_type == DeviceType.Bulb diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index 8f7def957..b07d8d988 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -3,7 +3,9 @@ from kasa import Device, Module from kasa.tests.device_fixtures import ( - lightstrip, + dimmable_iot, + dimmer_iot, + lightstrip_iot, parametrize, parametrize_combine, plug_iot, @@ -17,7 +19,12 @@ light_effect_smart = parametrize( "has light effect smart", component_filter="light_effect", protocol_filter={"SMART"} ) -light_effect = parametrize_combine([light_effect_smart, lightstrip]) +light_effect = parametrize_combine([light_effect_smart, lightstrip_iot]) + +dimmable_smart = parametrize( + "dimmable smart", component_filter="brightness", protocol_filter={"SMART"} +) +dimmable = parametrize_combine([dimmable_smart, dimmer_iot, dimmable_iot]) @led @@ -25,7 +32,7 @@ async def test_led_module(dev: Device, mocker: MockerFixture): """Test fan speed feature.""" led_module = dev.modules.get(Module.Led) assert led_module - feat = led_module._module_features["led"] + feat = dev.features["led"] call = mocker.spy(led_module, "call") await led_module.set_led(True) @@ -52,7 +59,7 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture): """Test fan speed feature.""" light_effect_module = dev.modules[Module.LightEffect] assert light_effect_module - feat = light_effect_module._module_features["light_effect"] + feat = dev.features["light_effect"] call = mocker.spy(light_effect_module, "call") effect_list = light_effect_module.effect_list @@ -93,3 +100,26 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture): with pytest.raises(ValueError): await light_effect_module.set_effect("foobar") assert call.call_count == 4 + + +@dimmable +async def test_light_brightness(dev: Device): + """Test brightness setter and getter.""" + assert isinstance(dev, Device) + light = dev.modules.get(Module.Light) + assert light + + # Test getting the value + feature = dev.features["brightness"] + assert feature.minimum_value == 0 + assert feature.maximum_value == 100 + + await light.set_brightness(10) + await dev.update() + assert light.brightness == 10 + + with pytest.raises(ValueError): + await light.set_brightness(feature.minimum_value - 10) + + with pytest.raises(ValueError): + await light.set_brightness(feature.maximum_value + 10) diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index 6399ca4f6..06150d394 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -3,10 +3,10 @@ from kasa import DeviceType from kasa.iot import IotDimmer -from .conftest import dimmer, handle_turn_on, turn_on +from .conftest import dimmer_iot, handle_turn_on, turn_on -@dimmer +@dimmer_iot @turn_on async def test_set_brightness(dev, turn_on): await handle_turn_on(dev, turn_on) @@ -22,7 +22,7 @@ async def test_set_brightness(dev, turn_on): assert dev.is_on == turn_on -@dimmer +@dimmer_iot @turn_on async def test_set_brightness_transition(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) @@ -44,7 +44,7 @@ async def test_set_brightness_transition(dev, turn_on, mocker): assert dev.brightness == 1 -@dimmer +@dimmer_iot async def test_set_brightness_invalid(dev): for invalid_brightness in [-1, 101, 0.5]: with pytest.raises(ValueError): @@ -55,7 +55,7 @@ async def test_set_brightness_invalid(dev): await dev.set_brightness(1, transition=invalid_transition) -@dimmer +@dimmer_iot async def test_turn_on_transition(dev, mocker): query_helper = mocker.spy(IotDimmer, "_query_helper") original_brightness = dev.brightness @@ -72,7 +72,7 @@ async def test_turn_on_transition(dev, mocker): assert dev.brightness == original_brightness -@dimmer +@dimmer_iot async def test_turn_off_transition(dev, mocker): await handle_turn_on(dev, True) query_helper = mocker.spy(IotDimmer, "_query_helper") @@ -90,7 +90,7 @@ async def test_turn_off_transition(dev, mocker): ) -@dimmer +@dimmer_iot @turn_on async def test_set_dimmer_transition(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) @@ -108,7 +108,7 @@ async def test_set_dimmer_transition(dev, turn_on, mocker): assert dev.brightness == 99 -@dimmer +@dimmer_iot @turn_on async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) @@ -127,7 +127,7 @@ async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): ) -@dimmer +@dimmer_iot async def test_set_dimmer_transition_invalid(dev): for invalid_brightness in [-1, 101, 0.5]: with pytest.raises(ValueError): @@ -138,6 +138,6 @@ async def test_set_dimmer_transition_invalid(dev): await dev.set_dimmer_transition(1, invalid_transition) -@dimmer +@dimmer_iot def test_device_type_dimmer(dev): assert dev.device_type == DeviceType.Dimmer diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index eb0391444..2dea2004d 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -26,8 +26,8 @@ from .conftest import ( bulb_iot, - dimmer, - lightstrip, + dimmer_iot, + lightstrip_iot, new_discovery, plug_iot, strip_iot, @@ -86,14 +86,14 @@ async def test_type_detection_strip(dev: Device): assert d.device_type == DeviceType.Strip -@dimmer +@dimmer_iot async def test_type_detection_dimmer(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_dimmer assert d.device_type == DeviceType.Dimmer -@lightstrip +@lightstrip_iot async def test_type_detection_lightstrip(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_light_strip diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index 101a21c0a..0fb7156d2 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -1,5 +1,6 @@ import logging import sys +from unittest.mock import patch import pytest from pytest_mock import MockerFixture @@ -180,11 +181,10 @@ async def _test_feature(feat, query_mock): async def _test_features(dev): exceptions = [] - query = mocker.patch.object(dev.protocol, "query") for feat in dev.features.values(): - query.reset_mock() try: - await _test_feature(feat, query) + with patch.object(feat.device.protocol, "query") as query: + await _test_feature(feat, query) # we allow our own exceptions to avoid mocking valid responses except KasaException: pass diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index f51f1805c..41fdcde15 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -3,24 +3,24 @@ from kasa import DeviceType from kasa.iot import IotLightStrip -from .conftest import lightstrip +from .conftest import lightstrip_iot -@lightstrip +@lightstrip_iot async def test_lightstrip_length(dev: IotLightStrip): assert dev.is_light_strip assert dev.device_type == DeviceType.LightStrip assert dev.length == dev.sys_info["length"] -@lightstrip +@lightstrip_iot async def test_lightstrip_effect(dev: IotLightStrip): assert isinstance(dev.effect, dict) for k in ["brightness", "custom", "enable", "id", "name"]: assert k in dev.effect -@lightstrip +@lightstrip_iot async def test_effects_lightstrip_set_effect(dev: IotLightStrip): with pytest.raises(ValueError): await dev.set_effect("Not real") @@ -30,7 +30,7 @@ async def test_effects_lightstrip_set_effect(dev: IotLightStrip): assert dev.effect["name"] == "Candy Cane" -@lightstrip +@lightstrip_iot @pytest.mark.parametrize("brightness", [100, 50]) async def test_effects_lightstrip_set_effect_brightness( dev: IotLightStrip, brightness, mocker @@ -48,7 +48,7 @@ async def test_effects_lightstrip_set_effect_brightness( assert payload["brightness"] == brightness -@lightstrip +@lightstrip_iot @pytest.mark.parametrize("transition", [500, 1000]) async def test_effects_lightstrip_set_effect_transition( dev: IotLightStrip, transition, mocker @@ -66,12 +66,12 @@ async def test_effects_lightstrip_set_effect_transition( assert payload["transition"] == transition -@lightstrip +@lightstrip_iot async def test_effects_lightstrip_has_effects(dev: IotLightStrip): assert dev.has_effects is True assert dev.effect_list -@lightstrip +@lightstrip_iot def test_device_type_lightstrip(dev): assert dev.device_type == DeviceType.LightStrip diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index ed9e57212..c4a4685a3 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -14,7 +14,6 @@ from kasa.smart import SmartDevice from .conftest import ( - bulb_smart, device_smart, get_device_for_fixture_protocol, ) @@ -159,28 +158,6 @@ async def test_get_modules(): assert module is None -@bulb_smart -async def test_smartdevice_brightness(dev: SmartDevice): - """Test brightness setter and getter.""" - assert isinstance(dev, SmartDevice) - assert "brightness" in dev._components - - # Test getting the value - feature = dev.features["brightness"] - assert feature.minimum_value == 1 - assert feature.maximum_value == 100 - - await dev.set_brightness(10) - await dev.update() - assert dev.brightness == 10 - - with pytest.raises(ValueError): - await dev.set_brightness(feature.minimum_value - 10) - - with pytest.raises(ValueError): - await dev.set_brightness(feature.maximum_value + 10) - - @device_smart async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixture): """Test is_cloud_connected property.""" From ef49f44eac89515d50b62f5af9fe842ab3210274 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 13 May 2024 18:52:08 +0100 Subject: [PATCH 431/892] Deprecate is_something attributes (#912) Deprecates the is_something attributes like is_bulb and is_dimmable in favour of the modular approach. --- kasa/device.py | 106 +++++++++++++-------------- kasa/iot/iotbulb.py | 20 ++--- kasa/iot/iotdimmer.py | 6 +- kasa/iot/modules/light.py | 30 +++++--- kasa/smart/smartdevice.py | 11 --- kasa/tests/smart/modules/test_fan.py | 1 - kasa/tests/test_bulb.py | 6 +- kasa/tests/test_device.py | 55 +++++++++++++- 8 files changed, 142 insertions(+), 93 deletions(-) diff --git a/kasa/device.py b/kasa/device.py index 8150352d9..0f88f3a13 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING, Any, Mapping, Sequence +from warnings import warn from .credentials import Credentials from .device_type import DeviceType @@ -208,61 +209,6 @@ def get_child_device(self, id_: str) -> Device: def sys_info(self) -> dict[str, Any]: """Returns the device info.""" - @property - def is_bulb(self) -> bool: - """Return True if the device is a bulb.""" - return self.device_type == DeviceType.Bulb - - @property - def is_light_strip(self) -> bool: - """Return True if the device is a led strip.""" - return self.device_type == DeviceType.LightStrip - - @property - def is_plug(self) -> bool: - """Return True if the device is a plug.""" - return self.device_type == DeviceType.Plug - - @property - def is_wallswitch(self) -> bool: - """Return True if the device is a switch.""" - return self.device_type == DeviceType.WallSwitch - - @property - def is_strip(self) -> bool: - """Return True if the device is a strip.""" - return self.device_type == DeviceType.Strip - - @property - def is_strip_socket(self) -> bool: - """Return True if the device is a strip socket.""" - return self.device_type == DeviceType.StripSocket - - @property - def is_dimmer(self) -> bool: - """Return True if the device is a dimmer.""" - return self.device_type == DeviceType.Dimmer - - @property - def is_dimmable(self) -> bool: - """Return True if the device is dimmable.""" - return False - - @property - def is_fan(self) -> bool: - """Return True if the device is a fan.""" - return self.device_type == DeviceType.Fan - - @property - def is_variable_color_temp(self) -> bool: - """Return True if the device supports color temperature.""" - return False - - @property - def is_color(self) -> bool: - """Return True if the device supports color changes.""" - return False - def get_plug_by_name(self, name: str) -> Device: """Return child device for the given name.""" for p in self.children: @@ -383,3 +329,53 @@ def __repr__(self): if self._last_update is None: return f"<{self.device_type} at {self.host} - update() needed>" return f"<{self.device_type} at {self.host} - {self.alias} ({self.model})>" + + _deprecated_attributes = { + # is_type + "is_bulb": (Module.Light, lambda self: self.device_type == DeviceType.Bulb), + "is_dimmer": ( + Module.Light, + lambda self: self.device_type == DeviceType.Dimmer, + ), + "is_light_strip": ( + Module.LightEffect, + lambda self: self.device_type == DeviceType.LightStrip, + ), + "is_plug": (Module.Led, lambda self: self.device_type == DeviceType.Plug), + "is_wallswitch": ( + Module.Led, + lambda self: self.device_type == DeviceType.WallSwitch, + ), + "is_strip": (None, lambda self: self.device_type == DeviceType.Strip), + "is_strip_socket": ( + None, + lambda self: self.device_type == DeviceType.StripSocket, + ), # TODO + # is_light_function + "is_color": ( + Module.Light, + lambda self: Module.Light in self.modules + and self.modules[Module.Light].is_color, + ), + "is_dimmable": ( + Module.Light, + lambda self: Module.Light in self.modules + and self.modules[Module.Light].is_dimmable, + ), + "is_variable_color_temp": ( + Module.Light, + lambda self: Module.Light in self.modules + and self.modules[Module.Light].is_variable_color_temp, + ), + } + + def __getattr__(self, name) -> bool: + if name in self._deprecated_attributes: + module = self._deprecated_attributes[name][0] + func = self._deprecated_attributes[name][1] + msg = f"{name} is deprecated" + if module: + msg += f", use: {module} in device.modules instead" + warn(msg, DeprecationWarning, stacklevel=1) + return func(self) + raise AttributeError(f"Device has no attribute {name!r}") diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index e2d860432..51df94d17 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -226,21 +226,21 @@ async def _initialize_modules(self): @property # type: ignore @requires_update - def is_color(self) -> bool: + def _is_color(self) -> bool: """Whether the bulb supports color changes.""" sys_info = self.sys_info return bool(sys_info["is_color"]) @property # type: ignore @requires_update - def is_dimmable(self) -> bool: + def _is_dimmable(self) -> bool: """Whether the bulb supports brightness changes.""" sys_info = self.sys_info return bool(sys_info["is_dimmable"]) @property # type: ignore @requires_update - def is_variable_color_temp(self) -> bool: + def _is_variable_color_temp(self) -> bool: """Whether the bulb supports color temperature changes.""" sys_info = self.sys_info return bool(sys_info["is_variable_color_temp"]) @@ -252,7 +252,7 @@ def valid_temperature_range(self) -> ColorTempRange: :return: White temperature range in Kelvin (minimum, maximum) """ - if not self.is_variable_color_temp: + if not self._is_variable_color_temp: raise KasaException("Color temperature not supported") for model, temp_range in TPLINK_KELVIN.items(): @@ -352,7 +352,7 @@ def hsv(self) -> HSV: :return: hue, saturation and value (degrees, %, %) """ - if not self.is_color: + if not self._is_color: raise KasaException("Bulb does not support color.") light_state = cast(dict, self.light_state) @@ -379,7 +379,7 @@ async def set_hsv( :param int value: value in percentage [0, 100] :param int transition: transition in milliseconds. """ - if not self.is_color: + if not self._is_color: raise KasaException("Bulb does not support color.") if not isinstance(hue, int) or not (0 <= hue <= 360): @@ -406,7 +406,7 @@ async def set_hsv( @requires_update def color_temp(self) -> int: """Return color temperature of the device in kelvin.""" - if not self.is_variable_color_temp: + if not self._is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") light_state = self.light_state @@ -421,7 +421,7 @@ async def set_color_temp( :param int temp: The new color temperature, in Kelvin :param int transition: transition in milliseconds. """ - if not self.is_variable_color_temp: + if not self._is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") valid_temperature_range = self.valid_temperature_range @@ -446,7 +446,7 @@ def _raise_for_invalid_brightness(self, value): @requires_update def brightness(self) -> int: """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover + if not self._is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") light_state = self.light_state @@ -461,7 +461,7 @@ async def set_brightness( :param int brightness: brightness in percent :param int transition: transition in milliseconds. """ - if not self.is_dimmable: # pragma: no cover + if not self._is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") self._raise_for_invalid_brightness(brightness) diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index d6f49c246..ef99f7496 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -96,7 +96,7 @@ def brightness(self) -> int: Will return a range between 0 - 100. """ - if not self.is_dimmable: + if not self._is_dimmable: raise KasaException("Device is not dimmable.") sys_info = self.sys_info @@ -109,7 +109,7 @@ async def set_brightness(self, brightness: int, *, transition: int | None = None :param int transition: transition duration in milliseconds. Using a transition will cause the dimmer to turn on. """ - if not self.is_dimmable: + if not self._is_dimmable: raise KasaException("Device is not dimmable.") if not isinstance(brightness, int): @@ -218,7 +218,7 @@ async def set_fade_time(self, fade_type: FadeType, time: int): @property # type: ignore @requires_update - def is_dimmable(self) -> bool: + def _is_dimmable(self) -> bool: """Whether the switch supports brightness changes.""" sys_info = self.sys_info return "brightness" in sys_info diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 89243a1b5..1bebf8175 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, cast +from ...device_type import DeviceType from ...exceptions import KasaException from ...feature import Feature from ...interfaces.light import HSV, ColorTempRange @@ -78,14 +79,19 @@ def query(self) -> dict: return {} def _get_bulb_device(self) -> IotBulb | None: - if self._device.is_bulb or self._device.is_light_strip: + """For type checker this gets an IotBulb. + + IotDimmer is not a subclass of IotBulb and using isinstance + here at runtime would create a circular import. + """ + if self._device.device_type in {DeviceType.Bulb, DeviceType.LightStrip}: return cast("IotBulb", self._device) return None @property # type: ignore def is_dimmable(self) -> int: """Whether the bulb supports brightness changes.""" - return self._device.is_dimmable + return self._device._is_dimmable @property # type: ignore def brightness(self) -> int: @@ -107,14 +113,14 @@ def is_color(self) -> bool: """Whether the light supports color changes.""" if (bulb := self._get_bulb_device()) is None: return False - return bulb.is_color + return bulb._is_color @property def is_variable_color_temp(self) -> bool: """Whether the bulb supports color temperature changes.""" if (bulb := self._get_bulb_device()) is None: return False - return bulb.is_variable_color_temp + return bulb._is_variable_color_temp @property def has_effects(self) -> bool: @@ -129,7 +135,7 @@ def hsv(self) -> HSV: :return: hue, saturation and value (degrees, %, %) """ - if (bulb := self._get_bulb_device()) is None or not bulb.is_color: + if (bulb := self._get_bulb_device()) is None or not bulb._is_color: raise KasaException("Light does not support color.") return bulb.hsv @@ -150,7 +156,7 @@ async def set_hsv( :param int value: value in percentage [0, 100] :param int transition: transition in milliseconds. """ - if (bulb := self._get_bulb_device()) is None or not bulb.is_color: + if (bulb := self._get_bulb_device()) is None or not bulb._is_color: raise KasaException("Light does not support color.") return await bulb.set_hsv(hue, saturation, value, transition=transition) @@ -160,14 +166,18 @@ def valid_temperature_range(self) -> ColorTempRange: :return: White temperature range in Kelvin (minimum, maximum) """ - if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + if ( + bulb := self._get_bulb_device() + ) is None or not bulb._is_variable_color_temp: raise KasaException("Light does not support colortemp.") return bulb.valid_temperature_range @property def color_temp(self) -> int: """Whether the bulb supports color temperature changes.""" - if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + if ( + bulb := self._get_bulb_device() + ) is None or not bulb._is_variable_color_temp: raise KasaException("Light does not support colortemp.") return bulb.color_temp @@ -181,7 +191,9 @@ async def set_color_temp( :param int temp: The new color temperature, in Kelvin :param int transition: transition in milliseconds. """ - if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + if ( + bulb := self._get_bulb_device() + ) is None or not bulb._is_variable_color_temp: raise KasaException("Light does not support colortemp.") return await bulb.set_color_temp( temp, brightness=brightness, transition=transition diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index e1939c70b..e42609954 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -14,7 +14,6 @@ from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..feature import Feature -from ..interfaces.light import LightPreset from ..module import Module from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol @@ -444,11 +443,6 @@ def has_emeter(self) -> bool: """Return if the device has emeter.""" return Module.Energy in self.modules - @property - def is_dimmer(self) -> bool: - """Whether the device acts as a dimmer.""" - return self.is_dimmable - @property def is_on(self) -> bool: """Return true if the device is on.""" @@ -648,8 +642,3 @@ def _get_device_type_from_components( return DeviceType.Thermostat _LOGGER.warning("Unknown device type, falling back to plug") return DeviceType.Plug - - @property - def presets(self) -> list[LightPreset]: - """Return a list of available bulb setting presets.""" - return [] diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index b9627d9fa..e5e1ff724 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -58,7 +58,6 @@ async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): fan = dev.modules.get(Module.Fan) assert fan device = fan._device - assert device.is_fan await fan.set_fan_speed_level(1) await dev.update() diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 5cfa25daa..2930db57a 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -208,7 +208,7 @@ async def test_non_variable_temp(dev: Device): async def test_dimmable_brightness(dev: IotBulb, turn_on): assert isinstance(dev, (IotBulb, IotDimmer)) await handle_turn_on(dev, turn_on) - assert dev.is_dimmable + assert dev._is_dimmable await dev.set_brightness(50) await dev.update() @@ -244,7 +244,7 @@ async def test_dimmable_brightness_transition(dev: IotBulb, mocker): @dimmable_iot async def test_invalid_brightness(dev: IotBulb): - assert dev.is_dimmable + assert dev._is_dimmable with pytest.raises(ValueError): await dev.set_brightness(110) @@ -255,7 +255,7 @@ async def test_invalid_brightness(dev: IotBulb): @non_dimmable_iot async def test_non_dimmable(dev: IotBulb): - assert not dev.is_dimmable + assert not dev._is_dimmable with pytest.raises(KasaException): assert dev.brightness == 0 diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index 76ea1acf6..6fd63d15f 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -9,7 +9,7 @@ import pytest import kasa -from kasa import Credentials, Device, DeviceConfig +from kasa import Credentials, Device, DeviceConfig, DeviceType from kasa.iot import IotDevice from kasa.smart import SmartChildDevice, SmartDevice @@ -121,3 +121,56 @@ def test_deprecated_exceptions(exceptions_class, use_class): with pytest.deprecated_call(match=msg): getattr(kasa, exceptions_class) getattr(kasa, use_class.__name__) + + +deprecated_is_device_type = { + "is_bulb": DeviceType.Bulb, + "is_plug": DeviceType.Plug, + "is_dimmer": DeviceType.Dimmer, + "is_light_strip": DeviceType.LightStrip, + "is_wallswitch": DeviceType.WallSwitch, + "is_strip": DeviceType.Strip, + "is_strip_socket": DeviceType.StripSocket, +} +deprecated_is_light_function_smart_module = { + "is_color": "Color", + "is_dimmable": "Brightness", + "is_variable_color_temp": "ColorTemperature", +} + + +def test_deprecated_attributes(dev: SmartDevice): + """Test deprecated attributes on all devices.""" + tested_keys = set() + + def _test_attr(attribute): + tested_keys.add(attribute) + msg = f"{attribute} is deprecated" + if module := Device._deprecated_attributes[attribute][0]: + msg += f", use: {module} in device.modules instead" + with pytest.deprecated_call(match=msg): + val = getattr(dev, attribute) + return val + + for attribute in deprecated_is_device_type: + val = _test_attr(attribute) + expected_val = dev.device_type == deprecated_is_device_type[attribute] + assert val == expected_val + + for attribute in deprecated_is_light_function_smart_module: + val = _test_attr(attribute) + if isinstance(dev, SmartDevice): + expected_val = ( + deprecated_is_light_function_smart_module[attribute] in dev.modules + ) + elif hasattr(dev, f"_{attribute}"): + expected_val = getattr(dev, f"_{attribute}") + else: + expected_val = False + assert val == expected_val + + assert len(tested_keys) == len(Device._deprecated_attributes) + untested_keys = [ + key for key in Device._deprecated_attributes if key not in tested_keys + ] + assert len(untested_keys) == 0 From 67b5d7de83452240a2e277cd8d330e762c69e355 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 14 May 2024 08:38:21 +0100 Subject: [PATCH 432/892] Update cli to use common modules and remove iot specific cli testing (#913) --- kasa/cli.py | 74 +++++++++++++----------- kasa/tests/test_cli.py | 125 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 154 insertions(+), 45 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index d51679a2f..d1b40a9e8 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -27,7 +27,7 @@ EncryptType, Feature, KasaException, - Light, + Module, UnsupportedDeviceError, ) from kasa.discover import DiscoveryResult @@ -859,18 +859,18 @@ async def usage(dev: Device, year, month, erase): @click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) @click.option("--transition", type=int, required=False) @pass_dev -async def brightness(dev: Light, brightness: int, transition: int): +async def brightness(dev: Device, brightness: int, transition: int): """Get or set brightness.""" - if not dev.is_dimmable: + if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: echo("This device does not support brightness.") return if brightness is None: - echo(f"Brightness: {dev.brightness}") - return dev.brightness + echo(f"Brightness: {light.brightness}") + return light.brightness else: echo(f"Setting brightness to {brightness}") - return await dev.set_brightness(brightness, transition=transition) + return await light.set_brightness(brightness, transition=transition) @cli.command() @@ -879,15 +879,15 @@ async def brightness(dev: Light, brightness: int, transition: int): ) @click.option("--transition", type=int, required=False) @pass_dev -async def temperature(dev: Light, temperature: int, transition: int): +async def temperature(dev: Device, temperature: int, transition: int): """Get or set color temperature.""" - if not dev.is_variable_color_temp: + if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: echo("Device does not support color temperature") return if temperature is None: - echo(f"Color temperature: {dev.color_temp}") - valid_temperature_range = dev.valid_temperature_range + echo(f"Color temperature: {light.color_temp}") + valid_temperature_range = light.valid_temperature_range if valid_temperature_range != (0, 0): echo("(min: {}, max: {})".format(*valid_temperature_range)) else: @@ -895,31 +895,34 @@ async def temperature(dev: Light, temperature: int, transition: int): "Temperature range unknown, please open a github issue" f" or a pull request for model '{dev.model}'" ) - return dev.valid_temperature_range + return light.valid_temperature_range else: echo(f"Setting color temperature to {temperature}") - return await dev.set_color_temp(temperature, transition=transition) + return await light.set_color_temp(temperature, transition=transition) @cli.command() @click.argument("effect", type=click.STRING, default=None, required=False) @click.pass_context @pass_dev -async def effect(dev, ctx, effect): +async def effect(dev: Device, ctx, effect): """Set an effect.""" - if not dev.has_effects: + if not (light_effect := dev.modules.get(Module.LightEffect)): echo("Device does not support effects") return if effect is None: raise click.BadArgumentUsage( - f"Setting an effect requires a named built-in effect: {dev.effect_list}", + "Setting an effect requires a named built-in effect: " + + f"{light_effect.effect_list}", ctx, ) - if effect not in dev.effect_list: - raise click.BadArgumentUsage(f"Effect must be one of: {dev.effect_list}", ctx) + if effect not in light_effect.effect_list: + raise click.BadArgumentUsage( + f"Effect must be one of: {light_effect.effect_list}", ctx + ) echo(f"Setting Effect: {effect}") - return await dev.set_effect(effect) + return await light_effect.set_effect(effect) @cli.command() @@ -929,33 +932,36 @@ async def effect(dev, ctx, effect): @click.option("--transition", type=int, required=False) @click.pass_context @pass_dev -async def hsv(dev, ctx, h, s, v, transition): +async def hsv(dev: Device, ctx, h, s, v, transition): """Get or set color in HSV.""" - if not dev.is_color: + if not (light := dev.modules.get(Module.Light)) or not light.is_color: echo("Device does not support colors") return - if h is None or s is None or v is None: - echo(f"Current HSV: {dev.hsv}") - return dev.hsv + if h is None and s is None and v is None: + echo(f"Current HSV: {light.hsv}") + return light.hsv elif s is None or v is None: raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx) else: echo(f"Setting HSV: {h} {s} {v}") - return await dev.set_hsv(h, s, v, transition=transition) + return await light.set_hsv(h, s, v, transition=transition) @cli.command() @click.argument("state", type=bool, required=False) @pass_dev -async def led(dev, state): +async def led(dev: Device, state): """Get or set (Plug's) led state.""" + if not (led := dev.modules.get(Module.Led)): + echo("Device does not support led.") + return if state is not None: echo(f"Turning led to {state}") - return await dev.set_led(state) + return await led.set_led(state) else: - echo(f"LED state: {dev.led}") - return dev.led + echo(f"LED state: {led.led}") + return led.led @cli.command() @@ -975,8 +981,8 @@ async def time(dev): async def on(dev: Device, index: int, name: str, transition: int): """Turn the device on.""" if index is not None or name is not None: - if not dev.is_strip: - echo("Index and name are only for power strips!") + if not dev.children: + echo("Index and name are only for devices with children.") return if index is not None: @@ -996,8 +1002,8 @@ async def on(dev: Device, index: int, name: str, transition: int): async def off(dev: Device, index: int, name: str, transition: int): """Turn the device off.""" if index is not None or name is not None: - if not dev.is_strip: - echo("Index and name are only for power strips!") + if not dev.children: + echo("Index and name are only for devices with children.") return if index is not None: @@ -1017,8 +1023,8 @@ async def off(dev: Device, index: int, name: str, transition: int): async def toggle(dev: Device, index: int, name: str, transition: int): """Toggle the device on/off.""" if index is not None or name is not None: - if not dev.is_strip: - echo("Index and name are only for power strips!") + if not dev.children: + echo("Index and name are only for devices with children.") return if index is not None: diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index a438aa97f..422010ba9 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -13,6 +13,7 @@ DeviceError, EmeterStatus, KasaException, + Module, UnsupportedDeviceError, ) from kasa.cli import ( @@ -21,11 +22,15 @@ brightness, cli, cmd_command, + effect, emeter, + hsv, + led, raw_command, reboot, state, sysinfo, + temperature, toggle, update_credentials, wifi, @@ -34,7 +39,6 @@ from kasa.iot import IotDevice from .conftest import ( - device_iot, device_smart, get_device_for_fixture_protocol, handle_turn_on, @@ -78,11 +82,10 @@ async def test_update_called_by_cli(dev, mocker, runner): update.assert_called() -@device_iot -async def test_sysinfo(dev, runner): +async def test_sysinfo(dev: Device, runner): res = await runner.invoke(sysinfo, obj=dev) assert "System info" in res.output - assert dev.alias in res.output + assert dev.model in res.output @turn_on @@ -108,7 +111,6 @@ async def test_toggle(dev, turn_on, runner): assert dev.is_on != turn_on -@device_iot async def test_alias(dev, runner): res = await runner.invoke(alias, obj=dev) assert f"Alias: {dev.alias}" in res.output @@ -308,15 +310,14 @@ async def test_emeter(dev: Device, mocker, runner): daily.assert_called_with(year=1900, month=12) -@device_iot -async def test_brightness(dev, runner): +async def test_brightness(dev: Device, runner): res = await runner.invoke(brightness, obj=dev) - if not dev.is_dimmable: + if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: assert "This device does not support brightness." in res.output return res = await runner.invoke(brightness, obj=dev) - assert f"Brightness: {dev.brightness}" in res.output + assert f"Brightness: {light.brightness}" in res.output res = await runner.invoke(brightness, ["12"], obj=dev) assert "Setting brightness" in res.output @@ -326,7 +327,110 @@ async def test_brightness(dev, runner): assert "Brightness: 12" in res.output -@device_iot +async def test_color_temperature(dev: Device, runner): + res = await runner.invoke(temperature, obj=dev) + if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: + assert "Device does not support color temperature" in res.output + return + + res = await runner.invoke(temperature, obj=dev) + assert f"Color temperature: {light.color_temp}" in res.output + valid_range = light.valid_temperature_range + assert f"(min: {valid_range.min}, max: {valid_range.max})" in res.output + + val = int((valid_range.min + valid_range.max) / 2) + res = await runner.invoke(temperature, [str(val)], obj=dev) + assert "Setting color temperature to " in res.output + await dev.update() + + res = await runner.invoke(temperature, obj=dev) + assert f"Color temperature: {val}" in res.output + assert res.exit_code == 0 + + invalid_max = valid_range.max + 100 + # Lights that support the maximum range will not get past the click cli range check + # So can't be tested for the internal range check. + if invalid_max < 9000: + res = await runner.invoke(temperature, [str(invalid_max)], obj=dev) + assert res.exit_code == 1 + assert isinstance(res.exception, ValueError) + + res = await runner.invoke(temperature, [str(9100)], obj=dev) + assert res.exit_code == 2 + + +async def test_color_hsv(dev: Device, runner: CliRunner): + res = await runner.invoke(hsv, obj=dev) + if not (light := dev.modules.get(Module.Light)) or not light.is_color: + assert "Device does not support colors" in res.output + return + + res = await runner.invoke(hsv, obj=dev) + assert f"Current HSV: {light.hsv}" in res.output + + res = await runner.invoke(hsv, ["180", "50", "50"], obj=dev) + assert "Setting HSV: 180 50 50" in res.output + assert res.exit_code == 0 + await dev.update() + + res = await runner.invoke(hsv, ["180", "50"], obj=dev) + assert "Setting a color requires 3 values." in res.output + assert res.exit_code == 2 + + +async def test_light_effect(dev: Device, runner: CliRunner): + res = await runner.invoke(effect, obj=dev) + if not (light_effect := dev.modules.get(Module.LightEffect)): + assert "Device does not support effects" in res.output + return + + # Start off with a known state of off + await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF) + await dev.update() + assert light_effect.effect == light_effect.LIGHT_EFFECTS_OFF + + res = await runner.invoke(effect, obj=dev) + msg = ( + "Setting an effect requires a named built-in effect: " + + f"{light_effect.effect_list}" + ) + assert msg in res.output + assert res.exit_code == 2 + + res = await runner.invoke(effect, [light_effect.effect_list[1]], obj=dev) + assert f"Setting Effect: {light_effect.effect_list[1]}" in res.output + assert res.exit_code == 0 + await dev.update() + assert light_effect.effect == light_effect.effect_list[1] + + res = await runner.invoke(effect, ["foobar"], obj=dev) + assert f"Effect must be one of: {light_effect.effect_list}" in res.output + assert res.exit_code == 2 + + +async def test_led(dev: Device, runner: CliRunner): + res = await runner.invoke(led, obj=dev) + if not (led_module := dev.modules.get(Module.Led)): + assert "Device does not support led" in res.output + return + + res = await runner.invoke(led, obj=dev) + assert f"LED state: {led_module.led}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(led, ["on"], obj=dev) + assert "Turning led to True" in res.output + assert res.exit_code == 0 + await dev.update() + assert led_module.led is True + + res = await runner.invoke(led, ["off"], obj=dev) + assert "Turning led to False" in res.output + assert res.exit_code == 0 + await dev.update() + assert led_module.led is False + + async def test_json_output(dev: Device, mocker, runner): """Test that the json output produces correct output.""" mocker.patch("kasa.Discover.discover", return_value={"127.0.0.1": dev}) @@ -375,7 +479,6 @@ async def _state(dev: Device): assert "Username:foo Password:bar\n" in res.output -@device_iot async def test_without_device_type(dev, mocker, runner): """Test connecting without the device type.""" discovery_mock = mocker.patch( From 133a839f222d7025a3d8b395daf9600735c1f882 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 15 May 2024 06:16:57 +0100 Subject: [PATCH 433/892] Add LightEffect module for smart light strips (#918) Implements the `light_strip_lighting_effect` components for `smart` devices. Uses a new list of effects captured from a L900 which are similar to the `iot` effects but include some additional properties and a few extra effects. Assumes that a device only implements `light_strip_lighting_effect` or `light_effect` but not both. --- kasa/cli.py | 9 +- kasa/iot/modules/lighteffect.py | 11 +- kasa/smart/effects.py | 429 +++++++++++++++++++++++++ kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/lightstripeffect.py | 109 +++++++ kasa/tests/fakeprotocol_smart.py | 14 +- kasa/tests/test_cli.py | 8 +- kasa/tests/test_common_modules.py | 9 +- 8 files changed, 572 insertions(+), 19 deletions(-) create mode 100644 kasa/smart/effects.py create mode 100644 kasa/smart/modules/lightstripeffect.py diff --git a/kasa/cli.py b/kasa/cli.py index d1b40a9e8..235387bc1 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -911,11 +911,12 @@ async def effect(dev: Device, ctx, effect): echo("Device does not support effects") return if effect is None: - raise click.BadArgumentUsage( - "Setting an effect requires a named built-in effect: " - + f"{light_effect.effect_list}", - ctx, + echo( + f"Light effect: {light_effect.effect}\n" + + f"Available Effects: {light_effect.effect_list}" ) + return light_effect.effect + if effect not in light_effect.effect_list: raise click.BadArgumentUsage( f"Effect must be one of: {light_effect.effect_list}", ctx diff --git a/kasa/iot/modules/lighteffect.py b/kasa/iot/modules/lighteffect.py index 2d40fb54b..de12fabb6 100644 --- a/kasa/iot/modules/lighteffect.py +++ b/kasa/iot/modules/lighteffect.py @@ -21,13 +21,11 @@ def effect(self) -> str: 'id': '', 'name': ''} """ - if ( - (state := self.data.get("lighting_effect_state")) - and state.get("enable") - and (name := state.get("name")) - and name in EFFECT_NAMES_V1 - ): + eff = self.data["lighting_effect_state"] + name = eff["name"] + if eff["enable"]: return name + return self.LIGHT_EFFECTS_OFF @property @@ -67,6 +65,7 @@ async def set_effect( raise ValueError(f"The effect {effect} is not a built in effect.") else: effect_dict = EFFECT_MAPPING_V1[effect] + if brightness is not None: effect_dict["brightness"] = brightness if transition is not None: diff --git a/kasa/smart/effects.py b/kasa/smart/effects.py new file mode 100644 index 000000000..28e27d3f7 --- /dev/null +++ b/kasa/smart/effects.py @@ -0,0 +1,429 @@ +"""Module for light strip light effects.""" + +from __future__ import annotations + +from typing import cast + +EFFECT_AURORA = { + "custom": 0, + "id": "TapoStrip_1MClvV18i15Jq3bvJVf0eP", + "brightness": 100, + "name": "Aurora", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [ + [120, 100, 100], + [240, 100, 100], + [260, 100, 100], + [280, 100, 100], + ], + "type": "sequence", + "duration": 0, + "transition": 1500, + "direction": 4, + "spread": 7, + "repeat_times": 0, + "sequence": [[120, 100, 100], [240, 100, 100], [260, 100, 100], [280, 100, 100]], +} +EFFECT_BUBBLING_CAULDRON = { + "custom": 0, + "id": "TapoStrip_6DlumDwO2NdfHppy50vJtu", + "brightness": 100, + "name": "Bubbling Cauldron", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[100, 100, 100], [270, 100, 100]], + "type": "random", + "hue_range": [100, 270], + "saturation_range": [80, 100], + "brightness_range": [50, 100], + "duration": 0, + "transition": 200, + "init_states": [[270, 100, 100]], + "fadeoff": 1000, + "random_seed": 24, + "backgrounds": [[270, 40, 50]], +} +EFFECT_CANDY_CANE = { + "custom": 0, + "id": "TapoStrip_6Dy0Nc45vlhFPEzG021Pe9", + "brightness": 100, + "name": "Candy Cane", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[0, 0, 100], [0, 81, 100]], + "type": "sequence", + "duration": 700, + "transition": 500, + "direction": 1, + "spread": 1, + "repeat_times": 0, + "sequence": [ + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [360, 81, 100], + [360, 81, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + ], +} +EFFECT_CHRISTMAS = { + "custom": 0, + "id": "TapoStrip_5zkiG6avJ1IbhjiZbRlWvh", + "brightness": 100, + "name": "Christmas", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[136, 98, 100], [350, 97, 100]], + "type": "random", + "hue_range": [136, 146], + "saturation_range": [90, 100], + "brightness_range": [50, 100], + "duration": 5000, + "transition": 0, + "init_states": [[136, 0, 100]], + "fadeoff": 2000, + "random_seed": 100, + "backgrounds": [[136, 98, 75], [136, 0, 0], [350, 0, 100], [350, 97, 94]], +} +EFFECT_FLICKER = { + "custom": 0, + "id": "TapoStrip_4HVKmMc6vEzjm36jXaGwMs", + "brightness": 100, + "name": "Flicker", + "enable": 1, + "segments": [1], + "expansion_strategy": 1, + "display_colors": [[30, 81, 100], [40, 100, 100]], + "type": "random", + "hue_range": [30, 40], + "saturation_range": [100, 100], + "brightness_range": [50, 100], + "duration": 0, + "transition": 0, + "transition_range": [375, 500], + "init_states": [[30, 81, 80]], +} +EFFECT_GRANDMAS_CHRISTMAS_LIGHTS = { + "custom": 0, + "id": "TapoStrip_3Gk6CmXOXbjCiwz9iD543C", + "brightness": 100, + "name": "Grandma's Christmas Lights", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[30, 100, 100], [240, 100, 100], [130, 100, 100], [0, 100, 100]], + "type": "sequence", + "duration": 5000, + "transition": 100, + "direction": 1, + "spread": 1, + "repeat_times": 0, + "sequence": [ + [30, 100, 100], + [30, 0, 0], + [30, 0, 0], + [240, 100, 100], + [240, 0, 0], + [240, 0, 0], + [240, 0, 100], + [240, 0, 0], + [240, 0, 0], + [130, 100, 100], + [130, 0, 0], + [130, 0, 0], + [0, 100, 100], + [0, 0, 0], + [0, 0, 0], + ], +} +EFFECT_HANUKKAH = { + "custom": 0, + "id": "TapoStrip_2YTk4wramLKv5XZ9KFDVYm", + "brightness": 100, + "name": "Hanukkah", + "enable": 1, + "segments": [1], + "expansion_strategy": 1, + "display_colors": [[200, 100, 100]], + "type": "random", + "hue_range": [200, 210], + "saturation_range": [0, 100], + "brightness_range": [50, 100], + "duration": 1500, + "transition": 0, + "transition_range": [400, 500], + "init_states": [[35, 81, 80]], +} +EFFECT_HAUNTED_MANSION = { + "custom": 0, + "id": "TapoStrip_4rJ6JwC7I9st3tQ8j4lwlI", + "brightness": 100, + "name": "Haunted Mansion", + "enable": 1, + "segments": [80], + "expansion_strategy": 2, + "display_colors": [[44, 9, 100]], + "type": "random", + "hue_range": [45, 45], + "saturation_range": [10, 10], + "brightness_range": [0, 80], + "duration": 0, + "transition": 0, + "transition_range": [50, 1500], + "init_states": [[45, 10, 100]], + "fadeoff": 200, + "random_seed": 1, + "backgrounds": [[45, 10, 100]], +} +EFFECT_ICICLE = { + "custom": 0, + "id": "TapoStrip_7UcYLeJbiaxVIXCxr21tpx", + "brightness": 100, + "name": "Icicle", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[190, 100, 100]], + "type": "sequence", + "duration": 0, + "transition": 400, + "direction": 4, + "spread": 3, + "repeat_times": 0, + "sequence": [ + [190, 100, 70], + [190, 100, 70], + [190, 30, 50], + [190, 100, 70], + [190, 100, 70], + ], +} +EFFECT_LIGHTNING = { + "custom": 0, + "id": "TapoStrip_7OGzfSfnOdhoO2ri4gOHWn", + "brightness": 100, + "name": "Lightning", + "enable": 1, + "segments": [7], + "expansion_strategy": 1, + "display_colors": [[210, 9, 100], [200, 50, 100], [200, 100, 100]], + "type": "random", + "hue_range": [240, 240], + "saturation_range": [10, 11], + "brightness_range": [90, 100], + "duration": 0, + "transition": 50, + "init_states": [[240, 30, 100]], + "fadeoff": 150, + "random_seed": 50, + "backgrounds": [[200, 100, 100], [200, 50, 10], [210, 10, 50], [240, 10, 0]], +} +EFFECT_OCEAN = { + "custom": 0, + "id": "TapoStrip_0fOleCdwSgR0nfjkReeYfw", + "brightness": 100, + "name": "Ocean", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[198, 84, 100]], + "type": "sequence", + "duration": 0, + "transition": 2000, + "direction": 3, + "spread": 16, + "repeat_times": 0, + "sequence": [[198, 84, 30], [198, 70, 30], [198, 10, 30]], +} +EFFECT_RAINBOW = { + "custom": 0, + "id": "TapoStrip_7CC5y4lsL8pETYvmz7UOpQ", + "brightness": 100, + "name": "Rainbow", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [ + [0, 100, 100], + [100, 100, 100], + [200, 100, 100], + [300, 100, 100], + ], + "type": "sequence", + "duration": 0, + "transition": 1500, + "direction": 1, + "spread": 12, + "repeat_times": 0, + "sequence": [[0, 100, 100], [100, 100, 100], [200, 100, 100], [300, 100, 100]], +} +EFFECT_RAINDROP = { + "custom": 0, + "id": "TapoStrip_1t2nWlTBkV8KXBZ0TWvBjs", + "brightness": 100, + "name": "Raindrop", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[200, 9, 100], [200, 19, 100]], + "type": "random", + "hue_range": [200, 200], + "saturation_range": [10, 20], + "brightness_range": [10, 30], + "duration": 0, + "transition": 1000, + "init_states": [[200, 40, 100]], + "fadeoff": 1000, + "random_seed": 24, + "backgrounds": [[200, 40, 0]], +} +EFFECT_SPRING = { + "custom": 0, + "id": "TapoStrip_1nL6GqZ5soOxj71YDJOlZL", + "brightness": 100, + "name": "Spring", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[0, 30, 100], [130, 100, 100]], + "type": "random", + "hue_range": [0, 90], + "saturation_range": [30, 100], + "brightness_range": [90, 100], + "duration": 600, + "transition": 0, + "transition_range": [2000, 6000], + "init_states": [[80, 30, 100]], + "fadeoff": 1000, + "random_seed": 20, + "backgrounds": [[130, 100, 40]], +} +EFFECT_SUNRISE = { + "custom": 0, + "id": "TapoStrip_1OVSyXIsDxrt4j7OxyRvqi", + "brightness": 100, + "name": "Sunrise", + "enable": 1, + "segments": [0], + "expansion_strategy": 2, + "display_colors": [[0, 0, 100], [30, 95, 100], [0, 100, 100]], + "type": "pulse", + "duration": 600, + "transition": 60000, + "direction": 1, + "spread": 1, + "repeat_times": 1, + "run_time": 0, + "sequence": [ + [0, 100, 5], + [0, 100, 5], + [10, 100, 6], + [15, 100, 7], + [20, 100, 8], + [20, 100, 10], + [30, 100, 12], + [30, 95, 15], + [30, 90, 20], + [30, 80, 25], + [30, 75, 30], + [30, 70, 40], + [30, 60, 50], + [30, 50, 60], + [30, 20, 70], + [30, 0, 100], + ], + "trans_sequence": [], +} +EFFECT_SUNSET = { + "custom": 0, + "id": "TapoStrip_5NiN0Y8GAUD78p4neKk9EL", + "brightness": 100, + "name": "Sunset", + "enable": 1, + "segments": [0], + "expansion_strategy": 2, + "display_colors": [[0, 100, 100], [30, 95, 100], [0, 0, 100]], + "type": "pulse", + "duration": 600, + "transition": 60000, + "direction": 1, + "spread": 1, + "repeat_times": 1, + "run_time": 0, + "sequence": [ + [30, 0, 100], + [30, 20, 100], + [30, 50, 99], + [30, 60, 98], + [30, 70, 97], + [30, 75, 95], + [30, 80, 93], + [30, 90, 90], + [30, 95, 85], + [30, 100, 80], + [20, 100, 70], + [20, 100, 60], + [15, 100, 50], + [10, 100, 40], + [0, 100, 30], + [0, 100, 0], + ], + "trans_sequence": [], +} +EFFECT_VALENTINES = { + "custom": 0, + "id": "TapoStrip_2q1Vio9sSjHmaC7JS9d30l", + "brightness": 100, + "name": "Valentines", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[339, 19, 100], [19, 50, 100], [0, 100, 100], [339, 40, 100]], + "type": "random", + "hue_range": [340, 340], + "saturation_range": [30, 40], + "brightness_range": [90, 100], + "duration": 600, + "transition": 2000, + "init_states": [[340, 30, 100]], + "fadeoff": 3000, + "random_seed": 100, + "backgrounds": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], +} +EFFECTS_LIST = [ + EFFECT_AURORA, + EFFECT_BUBBLING_CAULDRON, + EFFECT_CANDY_CANE, + EFFECT_CHRISTMAS, + EFFECT_FLICKER, + EFFECT_GRANDMAS_CHRISTMAS_LIGHTS, + EFFECT_HANUKKAH, + EFFECT_HAUNTED_MANSION, + EFFECT_ICICLE, + EFFECT_LIGHTNING, + EFFECT_OCEAN, + EFFECT_RAINBOW, + EFFECT_RAINDROP, + EFFECT_SPRING, + EFFECT_SUNRISE, + EFFECT_SUNSET, + EFFECT_VALENTINES, +] + +EFFECT_NAMES: list[str] = [cast(str, effect["name"]) for effect in EFFECTS_LIST] +EFFECT_MAPPING = {effect["name"]: effect for effect in EFFECTS_LIST} diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index b295bcb20..688d4a6e5 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -18,6 +18,7 @@ from .led import Led from .light import Light from .lighteffect import LightEffect +from .lightstripeffect import LightStripEffect from .lighttransition import LightTransition from .reportmode import ReportMode from .temperaturecontrol import TemperatureControl @@ -44,6 +45,7 @@ "Cloud", "Light", "LightEffect", + "LightStripEffect", "LightTransition", "ColorTemperature", "Color", diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py new file mode 100644 index 000000000..b47f3fde2 --- /dev/null +++ b/kasa/smart/modules/lightstripeffect.py @@ -0,0 +1,109 @@ +"""Module for light effects.""" + +from __future__ import annotations + +from ...interfaces.lighteffect import LightEffect as LightEffectInterface +from ..effects import EFFECT_MAPPING, EFFECT_NAMES +from ..smartmodule import SmartModule + + +class LightStripEffect(SmartModule, LightEffectInterface): + """Implementation of dynamic light effects.""" + + REQUIRED_COMPONENT = "light_strip_lighting_effect" + + @property + def name(self) -> str: + """Name of the module. + + By default smart modules are keyed in the module mapping by class name. + The name is overriden here as this module implements the same common interface + as the bulb light_effect and the assumption is a device only supports one + or the other. + + """ + return "LightEffect" + + @property + def effect(self) -> str: + """Return effect state. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + eff = self.data["lighting_effect"] + name = eff["name"] + if eff["enable"]: + return name + return self.LIGHT_EFFECTS_OFF + + @property + def effect_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + effect_list = [self.LIGHT_EFFECTS_OFF] + effect_list.extend(EFFECT_NAMES) + return effect_list + + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> None: + """Set an effect on the device. + + If brightness or transition is defined, + its value will be used instead of the effect-specific default. + + See :meth:`effect_list` for available effects, + or use :meth:`set_custom_effect` for custom effects. + + :param str effect: The effect to set + :param int brightness: The wanted brightness + :param int transition: The wanted transition time + """ + if effect == self.LIGHT_EFFECTS_OFF: + effect_dict = dict(self.data["lighting_effect"]) + effect_dict["enable"] = 0 + elif effect not in EFFECT_MAPPING: + raise ValueError(f"The effect {effect} is not a built in effect.") + else: + effect_dict = EFFECT_MAPPING[effect] + + if brightness is not None: + effect_dict["brightness"] = brightness + if transition is not None: + effect_dict["transition"] = transition + + await self.set_custom_effect(effect_dict) + + async def set_custom_effect( + self, + effect_dict: dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ + return await self.call( + "set_lighting_effect", + effect_dict, + ) + + @property + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + return True + + def query(self): + """Return the base query.""" + return {} diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 7c73c71ea..233944509 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -176,7 +176,7 @@ def _handle_control_child(self, params: dict): "Method %s not implemented for children" % child_method ) - def _set_light_effect(self, info, params): + def _set_dynamic_light_effect(self, info, params): """Set or remove values as per the device behaviour.""" info["get_device_info"]["dynamic_light_effect_enable"] = params["enable"] info["get_dynamic_light_effect_rules"]["enable"] = params["enable"] @@ -189,6 +189,13 @@ def _set_light_effect(self, info, params): if "current_rule_id" in info["get_dynamic_light_effect_rules"]: del info["get_dynamic_light_effect_rules"]["current_rule_id"] + def _set_light_strip_effect(self, info, params): + """Set or remove values as per the device behaviour.""" + info["get_device_info"]["lighting_effect"]["enable"] = params["enable"] + info["get_device_info"]["lighting_effect"]["name"] = params["name"] + info["get_device_info"]["lighting_effect"]["id"] = params["id"] + info["get_lighting_effect"] = copy.deepcopy(params) + def _set_led_info(self, info, params): """Set or remove values as per the device behaviour.""" info["get_led_info"]["led_status"] = params["led_rule"] != "never" @@ -244,7 +251,10 @@ def _send_request(self, request_dict: dict): elif method in ["set_qs_info", "fw_download"]: return {"error_code": 0} elif method == "set_dynamic_light_effect_rule_enable": - self._set_light_effect(info, params) + self._set_dynamic_light_effect(info, params) + return {"error_code": 0} + elif method == "set_lighting_effect": + self._set_light_strip_effect(info, params) return {"error_code": 0} elif method == "set_led_info": self._set_led_info(info, params) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 422010ba9..2104de050 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -390,12 +390,8 @@ async def test_light_effect(dev: Device, runner: CliRunner): assert light_effect.effect == light_effect.LIGHT_EFFECTS_OFF res = await runner.invoke(effect, obj=dev) - msg = ( - "Setting an effect requires a named built-in effect: " - + f"{light_effect.effect_list}" - ) - assert msg in res.output - assert res.exit_code == 2 + assert f"Light effect: {light_effect.effect}" in res.output + assert res.exit_code == 0 res = await runner.invoke(effect, [light_effect.effect_list[1]], obj=dev) assert f"Setting Effect: {light_effect.effect_list[1]}" in res.output diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index b07d8d988..ca34d304f 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -19,7 +19,14 @@ light_effect_smart = parametrize( "has light effect smart", component_filter="light_effect", protocol_filter={"SMART"} ) -light_effect = parametrize_combine([light_effect_smart, lightstrip_iot]) +light_strip_effect_smart = parametrize( + "has light strip effect smart", + component_filter="light_strip_lighting_effect", + protocol_filter={"SMART"}, +) +light_effect = parametrize_combine( + [light_effect_smart, light_strip_effect_smart, lightstrip_iot] +) dimmable_smart = parametrize( "dimmable smart", component_filter="brightness", protocol_filter={"SMART"} From a2e8d2c4e88eb104dbd653bffc9843d0c8cfb7b0 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 15 May 2024 18:49:08 +0100 Subject: [PATCH 434/892] Deprecate device level light, effect and led attributes (#916) Deprecates the attributes at device level for light, light effects, and led. i.e. device.led, device.is_color. Will continue to support consumers using these attributes and emit a warning. --- kasa/device.py | 105 +++++++++++++++---------- kasa/iot/iotbulb.py | 16 ++-- kasa/iot/iotdimmer.py | 14 +++- kasa/iot/iotlightstrip.py | 68 +--------------- kasa/iot/iotplug.py | 10 --- kasa/iot/iotstrip.py | 11 --- kasa/iot/modules/light.py | 22 +++--- kasa/iot/modules/lighteffect.py | 26 ++++++ kasa/smart/modules/lightstripeffect.py | 26 ++++++ kasa/tests/test_device.py | 102 +++++++++++++++++++----- 10 files changed, 230 insertions(+), 170 deletions(-) diff --git a/kasa/device.py b/kasa/device.py index 0f88f3a13..052abc4ce 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -21,7 +21,7 @@ from .xortransport import XorTransport if TYPE_CHECKING: - from .modulemapping import ModuleMapping + from .modulemapping import ModuleMapping, ModuleName @dataclass @@ -330,52 +330,73 @@ def __repr__(self): return f"<{self.device_type} at {self.host} - update() needed>" return f"<{self.device_type} at {self.host} - {self.alias} ({self.model})>" - _deprecated_attributes = { + _deprecated_device_type_attributes = { # is_type - "is_bulb": (Module.Light, lambda self: self.device_type == DeviceType.Bulb), - "is_dimmer": ( - Module.Light, - lambda self: self.device_type == DeviceType.Dimmer, - ), - "is_light_strip": ( - Module.LightEffect, - lambda self: self.device_type == DeviceType.LightStrip, - ), - "is_plug": (Module.Led, lambda self: self.device_type == DeviceType.Plug), - "is_wallswitch": ( - Module.Led, - lambda self: self.device_type == DeviceType.WallSwitch, - ), - "is_strip": (None, lambda self: self.device_type == DeviceType.Strip), - "is_strip_socket": ( - None, - lambda self: self.device_type == DeviceType.StripSocket, - ), # TODO - # is_light_function - "is_color": ( - Module.Light, - lambda self: Module.Light in self.modules - and self.modules[Module.Light].is_color, - ), - "is_dimmable": ( - Module.Light, - lambda self: Module.Light in self.modules - and self.modules[Module.Light].is_dimmable, - ), - "is_variable_color_temp": ( - Module.Light, - lambda self: Module.Light in self.modules - and self.modules[Module.Light].is_variable_color_temp, - ), + "is_bulb": (Module.Light, DeviceType.Bulb), + "is_dimmer": (Module.Light, DeviceType.Dimmer), + "is_light_strip": (Module.LightEffect, DeviceType.LightStrip), + "is_plug": (Module.Led, DeviceType.Plug), + "is_wallswitch": (Module.Led, DeviceType.WallSwitch), + "is_strip": (None, DeviceType.Strip), + "is_strip_socket": (None, DeviceType.StripSocket), } - def __getattr__(self, name) -> bool: - if name in self._deprecated_attributes: - module = self._deprecated_attributes[name][0] - func = self._deprecated_attributes[name][1] + def _get_replacing_attr(self, module_name: ModuleName, *attrs): + if module_name not in self.modules: + return None + + for attr in attrs: + if hasattr(self.modules[module_name], attr): + return getattr(self.modules[module_name], attr) + + return None + + _deprecated_other_attributes = { + # light attributes + "is_color": (Module.Light, ["is_color"]), + "is_dimmable": (Module.Light, ["is_dimmable"]), + "is_variable_color_temp": (Module.Light, ["is_variable_color_temp"]), + "brightness": (Module.Light, ["brightness"]), + "set_brightness": (Module.Light, ["set_brightness"]), + "hsv": (Module.Light, ["hsv"]), + "set_hsv": (Module.Light, ["set_hsv"]), + "color_temp": (Module.Light, ["color_temp"]), + "set_color_temp": (Module.Light, ["set_color_temp"]), + "valid_temperature_range": (Module.Light, ["valid_temperature_range"]), + "has_effects": (Module.Light, ["has_effects"]), + # led attributes + "led": (Module.Led, ["led"]), + "set_led": (Module.Led, ["set_led"]), + # light effect attributes + # The return values for effect is a str instead of dict so the lightstrip + # modules have a _deprecated method to return the value as before. + "effect": (Module.LightEffect, ["_deprecated_effect", "effect"]), + # The return values for effect_list includes the Off effect so the lightstrip + # modules have a _deprecated method to return the values as before. + "effect_list": (Module.LightEffect, ["_deprecated_effect_list", "effect_list"]), + "set_effect": (Module.LightEffect, ["set_effect"]), + "set_custom_effect": (Module.LightEffect, ["set_custom_effect"]), + } + + def __getattr__(self, name): + # is_device_type + if dep_device_type_attr := self._deprecated_device_type_attributes.get(name): + module = dep_device_type_attr[0] msg = f"{name} is deprecated" if module: msg += f", use: {module} in device.modules instead" warn(msg, DeprecationWarning, stacklevel=1) - return func(self) + return self.device_type == dep_device_type_attr[1] + # Other deprecated attributes + if (dep_attr := self._deprecated_other_attributes.get(name)) and ( + (replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1])) + is not None + ): + module_name = dep_attr[0] + msg = ( + f"{name} is deprecated, use: " + + f"Module.{module_name} in device.modules instead" + ) + warn(msg, DeprecationWarning, stacklevel=1) + return replacing_attr raise AttributeError(f"Device has no attribute {name!r}") diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 51df94d17..ffeac2801 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -247,7 +247,7 @@ def _is_variable_color_temp(self) -> bool: @property # type: ignore @requires_update - def valid_temperature_range(self) -> ColorTempRange: + def _valid_temperature_range(self) -> ColorTempRange: """Return the device-specific white temperature range (in Kelvin). :return: White temperature range in Kelvin (minimum, maximum) @@ -284,7 +284,7 @@ def light_state(self) -> dict[str, str]: @property # type: ignore @requires_update - def has_effects(self) -> bool: + def _has_effects(self) -> bool: """Return True if the device supports effects.""" return "lighting_effect_state" in self.sys_info @@ -347,7 +347,7 @@ async def set_light_state( @property # type: ignore @requires_update - def hsv(self) -> HSV: + def _hsv(self) -> HSV: """Return the current HSV state of the bulb. :return: hue, saturation and value (degrees, %, %) @@ -364,7 +364,7 @@ def hsv(self) -> HSV: return HSV(hue, saturation, value) @requires_update - async def set_hsv( + async def _set_hsv( self, hue: int, saturation: int, @@ -404,7 +404,7 @@ async def set_hsv( @property # type: ignore @requires_update - def color_temp(self) -> int: + def _color_temp(self) -> int: """Return color temperature of the device in kelvin.""" if not self._is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") @@ -413,7 +413,7 @@ def color_temp(self) -> int: return int(light_state["color_temp"]) @requires_update - async def set_color_temp( + async def _set_color_temp( self, temp: int, *, brightness=None, transition: int | None = None ) -> dict: """Set the color temperature of the device in kelvin. @@ -444,7 +444,7 @@ def _raise_for_invalid_brightness(self, value): @property # type: ignore @requires_update - def brightness(self) -> int: + def _brightness(self) -> int: """Return the current brightness in percentage.""" if not self._is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") @@ -453,7 +453,7 @@ def brightness(self) -> int: return int(light_state["brightness"]) @requires_update - async def set_brightness( + async def _set_brightness( self, brightness: int, *, transition: int | None = None ) -> dict: """Set the brightness in percentage. diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index ef99f7496..740d9bb5a 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -91,7 +91,7 @@ async def _initialize_modules(self): @property # type: ignore @requires_update - def brightness(self) -> int: + def _brightness(self) -> int: """Return current brightness on dimmers. Will return a range between 0 - 100. @@ -103,7 +103,7 @@ def brightness(self) -> int: return int(sys_info["brightness"]) @requires_update - async def set_brightness(self, brightness: int, *, transition: int | None = None): + async def _set_brightness(self, brightness: int, *, transition: int | None = None): """Set the new dimmer brightness level in percentage. :param int transition: transition duration in milliseconds. @@ -222,3 +222,13 @@ def _is_dimmable(self) -> bool: """Whether the switch supports brightness changes.""" sys_info = self.sys_info return "brightness" in sys_info + + @property + def _is_variable_color_temp(self) -> bool: + """Whether the device supports variable color temp.""" + return False + + @property + def _is_color(self) -> bool: + """Whether the device supports color.""" + return False diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 6bc562583..f6a9719db 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -6,9 +6,8 @@ from ..deviceconfig import DeviceConfig from ..module import Module from ..protocol import BaseProtocol -from .effects import EFFECT_NAMES_V1 from .iotbulb import IotBulb -from .iotdevice import KasaException, requires_update +from .iotdevice import requires_update from .modules.lighteffect import LightEffect @@ -70,68 +69,3 @@ async def _initialize_modules(self): def length(self) -> int: """Return length of the strip.""" return self.sys_info["length"] - - @property # type: ignore - @requires_update - def effect(self) -> dict: - """Return effect state. - - Example: - {'brightness': 50, - 'custom': 0, - 'enable': 0, - 'id': '', - 'name': ''} - """ - # LightEffectModule returns the current effect name - # so return the dict here for backwards compatibility - return self.sys_info["lighting_effect_state"] - - @property # type: ignore - @requires_update - def effect_list(self) -> list[str] | None: - """Return built-in effects list. - - Example: - ['Aurora', 'Bubbling Cauldron', ...] - """ - # LightEffectModule returns effect names along with a LIGHT_EFFECTS_OFF value - # so return the original effect names here for backwards compatibility - return EFFECT_NAMES_V1 if self.has_effects else None - - @requires_update - async def set_effect( - self, - effect: str, - *, - brightness: int | None = None, - transition: int | None = None, - ) -> None: - """Set an effect on the device. - - If brightness or transition is defined, - its value will be used instead of the effect-specific default. - - See :meth:`effect_list` for available effects, - or use :meth:`set_custom_effect` for custom effects. - - :param str effect: The effect to set - :param int brightness: The wanted brightness - :param int transition: The wanted transition time - """ - await self.modules[Module.LightEffect].set_effect( - effect, brightness=brightness, transition=transition - ) - - @requires_update - async def set_custom_effect( - self, - effect_dict: dict, - ) -> None: - """Set a custom effect on the device. - - :param str effect_dict: The custom effect dict to set - """ - if not self.has_effects: - raise KasaException("Bulb does not support effects.") - await self.modules[Module.LightEffect].set_custom_effect(effect_dict) diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 072261783..8651bf9a4 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -79,16 +79,6 @@ async def turn_off(self, **kwargs): """Turn the switch off.""" return await self._query_helper("system", "set_relay_state", {"state": 0}) - @property # type: ignore - @requires_update - def led(self) -> bool: - """Return the state of the led.""" - return self.modules[Module.Led].led - - async def set_led(self, state: bool): - """Set the state of the led (night mode).""" - return await self.modules[Module.Led].set_led(state) - class IotWallSwitch(IotPlug): """Representation of a TP-Link Smart Wall Switch.""" diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index c4dcc57f5..619046bd2 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -147,17 +147,6 @@ def on_since(self) -> datetime | None: return max(plug.on_since for plug in self.children if plug.on_since is not None) - @property # type: ignore - @requires_update - def led(self) -> bool: - """Return the state of the led.""" - sys_info = self.sys_info - return bool(1 - sys_info["led_off"]) - - async def set_led(self, state: bool): - """Set the state of the led (night mode).""" - await self._query_helper("system", "set_led_off", {"off": int(not state)}) - async def current_consumption(self) -> float: """Get the current power consumption in watts.""" return sum([await plug.current_consumption() for plug in self.children]) diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 1bebf8175..833709df5 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -30,7 +30,7 @@ def _initialize_features(self): super()._initialize_features() device = self._device - if self._device.is_dimmable: + if self._device._is_dimmable: self._add_feature( Feature( device, @@ -45,7 +45,7 @@ def _initialize_features(self): category=Feature.Category.Primary, ) ) - if self._device.is_variable_color_temp: + if self._device._is_variable_color_temp: self._add_feature( Feature( device=device, @@ -59,7 +59,7 @@ def _initialize_features(self): type=Feature.Type.Number, ) ) - if self._device.is_color: + if self._device._is_color: self._add_feature( Feature( device=device, @@ -96,7 +96,7 @@ def is_dimmable(self) -> int: @property # type: ignore def brightness(self) -> int: """Return the current brightness in percentage.""" - return self._device.brightness + return self._device._brightness async def set_brightness( self, brightness: int, *, transition: int | None = None @@ -106,7 +106,7 @@ async def set_brightness( :param int brightness: brightness in percent :param int transition: transition in milliseconds. """ - return await self._device.set_brightness(brightness, transition=transition) + return await self._device._set_brightness(brightness, transition=transition) @property def is_color(self) -> bool: @@ -127,7 +127,7 @@ def has_effects(self) -> bool: """Return True if the device supports effects.""" if (bulb := self._get_bulb_device()) is None: return False - return bulb.has_effects + return bulb._has_effects @property def hsv(self) -> HSV: @@ -137,7 +137,7 @@ def hsv(self) -> HSV: """ if (bulb := self._get_bulb_device()) is None or not bulb._is_color: raise KasaException("Light does not support color.") - return bulb.hsv + return bulb._hsv async def set_hsv( self, @@ -158,7 +158,7 @@ async def set_hsv( """ if (bulb := self._get_bulb_device()) is None or not bulb._is_color: raise KasaException("Light does not support color.") - return await bulb.set_hsv(hue, saturation, value, transition=transition) + return await bulb._set_hsv(hue, saturation, value, transition=transition) @property def valid_temperature_range(self) -> ColorTempRange: @@ -170,7 +170,7 @@ def valid_temperature_range(self) -> ColorTempRange: bulb := self._get_bulb_device() ) is None or not bulb._is_variable_color_temp: raise KasaException("Light does not support colortemp.") - return bulb.valid_temperature_range + return bulb._valid_temperature_range @property def color_temp(self) -> int: @@ -179,7 +179,7 @@ def color_temp(self) -> int: bulb := self._get_bulb_device() ) is None or not bulb._is_variable_color_temp: raise KasaException("Light does not support colortemp.") - return bulb.color_temp + return bulb._color_temp async def set_color_temp( self, temp: int, *, brightness=None, transition: int | None = None @@ -195,6 +195,6 @@ async def set_color_temp( bulb := self._get_bulb_device() ) is None or not bulb._is_variable_color_temp: raise KasaException("Light does not support colortemp.") - return await bulb.set_color_temp( + return await bulb._set_color_temp( temp, brightness=brightness, transition=transition ) diff --git a/kasa/iot/modules/lighteffect.py b/kasa/iot/modules/lighteffect.py index de12fabb6..54b4725bc 100644 --- a/kasa/iot/modules/lighteffect.py +++ b/kasa/iot/modules/lighteffect.py @@ -94,3 +94,29 @@ def has_custom_effects(self) -> bool: def query(self): """Return the base query.""" return {} + + @property # type: ignore + def _deprecated_effect(self) -> dict: + """Return effect state. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + # LightEffectModule returns the current effect name + # so return the dict here for backwards compatibility + return self.data["lighting_effect_state"] + + @property # type: ignore + def _deprecated_effect_list(self) -> list[str] | None: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + # LightEffectModule returns effect names along with a LIGHT_EFFECTS_OFF value + # so return the original effect names here for backwards compatibility + return EFFECT_NAMES_V1 diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index b47f3fde2..854cf4813 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -107,3 +107,29 @@ def has_custom_effects(self) -> bool: def query(self): """Return the base query.""" return {} + + @property # type: ignore + def _deprecated_effect(self) -> dict: + """Return effect state. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + # LightEffectModule returns the current effect name + # so return the dict here for backwards compatibility + return self.data["lighting_effect"] + + @property # type: ignore + def _deprecated_effect_list(self) -> list[str] | None: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + # LightEffectModule returns effect names along with a LIGHT_EFFECTS_OFF value + # so return the original effect names here for backwards compatibility + return EFFECT_NAMES diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index 6fd63d15f..d8f28d1bc 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -9,7 +9,7 @@ import pytest import kasa -from kasa import Credentials, Device, DeviceConfig, DeviceType +from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module from kasa.iot import IotDevice from kasa.smart import SmartChildDevice, SmartDevice @@ -139,14 +139,12 @@ def test_deprecated_exceptions(exceptions_class, use_class): } -def test_deprecated_attributes(dev: SmartDevice): +def test_deprecated_device_type_attributes(dev: SmartDevice): """Test deprecated attributes on all devices.""" - tested_keys = set() def _test_attr(attribute): - tested_keys.add(attribute) msg = f"{attribute} is deprecated" - if module := Device._deprecated_attributes[attribute][0]: + if module := Device._deprecated_device_type_attributes[attribute][0]: msg += f", use: {module} in device.modules instead" with pytest.deprecated_call(match=msg): val = getattr(dev, attribute) @@ -157,20 +155,86 @@ def _test_attr(attribute): expected_val = dev.device_type == deprecated_is_device_type[attribute] assert val == expected_val - for attribute in deprecated_is_light_function_smart_module: - val = _test_attr(attribute) - if isinstance(dev, SmartDevice): - expected_val = ( - deprecated_is_light_function_smart_module[attribute] in dev.modules + +async def _test_attribute( + dev: Device, attribute_name, is_expected, module_name, *args, will_raise=False +): + if is_expected and will_raise: + ctx = pytest.raises(will_raise) + elif is_expected: + ctx = pytest.deprecated_call( + match=( + f"{attribute_name} is deprecated, use: Module." + + f"{module_name} in device.modules instead" ) - elif hasattr(dev, f"_{attribute}"): - expected_val = getattr(dev, f"_{attribute}") + ) + else: + ctx = pytest.raises( + AttributeError, match=f"Device has no attribute '{attribute_name}'" + ) + + with ctx: + if args: + await getattr(dev, attribute_name)(*args) else: - expected_val = False - assert val == expected_val + attribute_val = getattr(dev, attribute_name) + assert attribute_val is not None + + +async def test_deprecated_light_effect_attributes(dev: Device): + light_effect = dev.modules.get(Module.LightEffect) + + await _test_attribute(dev, "effect", bool(light_effect), "LightEffect") + await _test_attribute(dev, "effect_list", bool(light_effect), "LightEffect") + await _test_attribute(dev, "set_effect", bool(light_effect), "LightEffect", "Off") + exc = ( + NotImplementedError + if light_effect and not light_effect.has_custom_effects + else None + ) + await _test_attribute( + dev, + "set_custom_effect", + bool(light_effect), + "LightEffect", + {"enable": 0, "name": "foo", "id": "bar"}, + will_raise=exc, + ) + + +async def test_deprecated_light_attributes(dev: Device): + light = dev.modules.get(Module.Light) + + await _test_attribute(dev, "is_dimmable", bool(light), "Light") + await _test_attribute(dev, "is_color", bool(light), "Light") + await _test_attribute(dev, "is_variable_color_temp", bool(light), "Light") + + exc = KasaException if light and not light.is_dimmable else None + await _test_attribute(dev, "brightness", bool(light), "Light", will_raise=exc) + await _test_attribute( + dev, "set_brightness", bool(light), "Light", 50, will_raise=exc + ) + + exc = KasaException if light and not light.is_color else None + await _test_attribute(dev, "hsv", bool(light), "Light", will_raise=exc) + await _test_attribute( + dev, "set_hsv", bool(light), "Light", 50, 50, 50, will_raise=exc + ) + + exc = KasaException if light and not light.is_variable_color_temp else None + await _test_attribute(dev, "color_temp", bool(light), "Light", will_raise=exc) + await _test_attribute( + dev, "set_color_temp", bool(light), "Light", 2700, will_raise=exc + ) + await _test_attribute( + dev, "valid_temperature_range", bool(light), "Light", will_raise=exc + ) + + await _test_attribute(dev, "has_effects", bool(light), "Light") + + +async def test_deprecated_other_attributes(dev: Device): + led_module = dev.modules.get(Module.Led) - assert len(tested_keys) == len(Device._deprecated_attributes) - untested_keys = [ - key for key in Device._deprecated_attributes if key not in tested_keys - ] - assert len(untested_keys) == 0 + await _test_attribute(dev, "led", bool(led_module), "Led") + await _test_attribute(dev, "set_led", bool(led_module), "Led", True) From 3490a1ef84d5a2ff5f96958d95fe44db969dbefa Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 16 May 2024 17:13:44 +0100 Subject: [PATCH 435/892] Add tutorial doctest module and enable top level await (#919) Add a tutorial module with examples that can be tested with `doctest`. In order to simplify the examples they can be run with doctest allowing top level await statements by adding a fixture to patch the builtins that xdoctest uses to test code. --------- Co-authored-by: Teemu R. --- README.md | 3 +- docs/source/conf.py | 7 +- docs/source/design.rst | 36 ++++-- docs/source/{smartdevice.rst => device.rst} | 16 +-- docs/source/discover.rst | 2 +- docs/source/index.rst | 3 +- docs/source/smartbulb.rst | 6 +- docs/source/tutorial.md | 8 ++ docs/tutorial.py | 103 ++++++++++++++++++ kasa/__init__.py | 10 +- kasa/deviceconfig.py | 2 +- kasa/discover.py | 6 +- kasa/iot/iotbulb.py | 2 +- kasa/iot/iotplug.py | 2 +- kasa/iot/iotstrip.py | 2 +- .../fixtures/smart/L530E(EU)_3.0_1.1.6.json | 2 +- kasa/tests/test_readme_examples.py | 60 ++++++++++ 17 files changed, 228 insertions(+), 42 deletions(-) rename docs/source/{smartdevice.rst => device.rst} (93%) create mode 100644 docs/source/tutorial.md create mode 100644 docs/tutorial.py diff --git a/README.md b/README.md index 6c4cfcce1..1ed93f752 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,8 @@ Current state: {'total': 133.105, 'power': 108.223577, 'current': 0.54463, 'volt # Library usage -If you want to use this library in your own project, a good starting point is to check [the documentation on discovering devices](https://python-kasa.readthedocs.io/en/latest/discover.html). +If you want to use this library in your own project, a good starting point is [the tutorial in the documentation](https://python-kasa.readthedocs.io/en/latest/tutorial.html). + You can find several code examples in the API documentation of each of the implementation base classes, check out the [documentation for the base class shared by all supported devices](https://python-kasa.readthedocs.io/en/latest/smartdevice.html). [The library design and module structure is described in a separate page](https://python-kasa.readthedocs.io/en/latest/design.html). diff --git a/docs/source/conf.py b/docs/source/conf.py index 017249431..b6064b383 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -10,9 +10,10 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) +import os +import sys + +sys.path.insert(0, os.path.abspath("..")) # Will find modules in the docs parent # -- Project information ----------------------------------------------------- diff --git a/docs/source/design.rst b/docs/source/design.rst index 3b6ae3456..7ed1765d6 100644 --- a/docs/source/design.rst +++ b/docs/source/design.rst @@ -22,13 +22,13 @@ Use :func:`~kasa.Discover.discover` to perform udp-based broadcast discovery on This will return you a list of device instances based on the discovery replies. If the device's host is already known, you can use to construct a device instance with -:meth:`~kasa.SmartDevice.connect()`. +:meth:`~kasa.Device.connect()`. -The :meth:`~kasa.SmartDevice.connect()` also enables support for connecting to new +The :meth:`~kasa.Device.connect()` also enables support for connecting to new KASA SMART protocol and TAPO devices directly using the parameter :class:`~kasa.DeviceConfig`. -Simply serialize the :attr:`~kasa.SmartDevice.config` property via :meth:`~kasa.DeviceConfig.to_dict()` +Simply serialize the :attr:`~kasa.Device.config` property via :meth:`~kasa.DeviceConfig.to_dict()` and then deserialize it later with :func:`~kasa.DeviceConfig.from_dict()` -and then pass it into :meth:`~kasa.SmartDevice.connect()`. +and then pass it into :meth:`~kasa.Device.connect()`. .. _update_cycle: @@ -36,7 +36,7 @@ and then pass it into :meth:`~kasa.SmartDevice.connect()`. Update Cycle ************ -When :meth:`~kasa.SmartDevice.update()` is called, +When :meth:`~kasa.Device.update()` is called, the library constructs a query to send to the device based on :ref:`supported modules `. Internally, each module defines :meth:`~kasa.modules.Module.query()` to describe what they want query during the update. @@ -45,7 +45,7 @@ All properties defined both in the device class and in the module classes follow While the properties are designed to provide a nice API to use for common use cases, 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. +This can be done using the :attr:`~kasa.Device.internal_state` property. .. _modules: @@ -53,15 +53,15 @@ This can be done using the :attr:`~kasa.SmartDevice.internal_state` property. Modules ******* -The functionality provided by all :class:`~kasa.SmartDevice` instances is (mostly) done inside separate modules. +The functionality provided by all :class:`~kasa.Device` instances is (mostly) done inside separate modules. While the individual device-type specific classes provide an easy access for the most import features, you can also access individual modules through :attr:`kasa.SmartDevice.modules`. -You can get the list of supported modules for a given device instance using :attr:`~kasa.SmartDevice.supported_modules`. +You can get the list of supported modules for a given device instance using :attr:`~kasa.Device.supported_modules`. .. note:: If you only need some module-specific information, - you can call the wanted method on the module to avoid using :meth:`~kasa.SmartDevice.update`. + you can call the wanted method on the module to avoid using :meth:`~kasa.Device.update`. Protocols and Transports ************************ @@ -112,10 +112,22 @@ The base exception for all library errors is :class:`KasaException `. - All other failures will raise the base :class:`KasaException ` class. -API documentation for modules -***************************** +API documentation for modules and features +****************************************** -.. automodule:: kasa.modules +.. autoclass:: kasa.Module + :noindex: + :members: + :inherited-members: + :undoc-members: + +.. automodule:: kasa.interfaces + :noindex: + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.Feature :noindex: :members: :inherited-members: diff --git a/docs/source/smartdevice.rst b/docs/source/device.rst similarity index 93% rename from docs/source/smartdevice.rst rename to docs/source/device.rst index 5df227781..328a085d3 100644 --- a/docs/source/smartdevice.rst +++ b/docs/source/device.rst @@ -6,12 +6,12 @@ Common API .. contents:: Contents :local: -SmartDevice class -***************** +Device class +************ -The basic functionalities of all supported devices are accessible using the common :class:`SmartDevice` base class. +The basic functionalities of all supported devices are accessible using the common :class:`Device` base class. -The property accesses use the data obtained before by awaiting :func:`SmartDevice.update()`. +The property accesses use the data obtained before by awaiting :func:`Device.update()`. The values are cached until the next update call. In practice this means that property accesses do no I/O and are dependent, while I/O producing methods need to be awaited. See :ref:`library_design` for more detailed information. @@ -20,7 +20,7 @@ See :ref:`library_design` for more detailed information. This means that you need to use the same event loop for subsequent requests. The library gives a warning ("Detected protocol reuse between different event loop") to hint if you are accessing the device incorrectly. -Methods changing the state of the device do not invalidate the cache (i.e., there is no implicit :func:`SmartDevice.update()` call made by the library). +Methods changing the state of the device do not invalidate the cache (i.e., there is no implicit :func:`Device.update()` call made by the library). You can assume that the operation has succeeded if no exception is raised. These methods will return the device response, which can be useful for some use cases. @@ -103,10 +103,10 @@ Currently there are three known types of encryption for TP-Link devices and two 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()`. +To connect directly pass a :class:`DeviceConfig` object to :meth:`Device.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. +alternatively the config can be retrieved from :attr:`Device.config` post discovery and then re-used. Energy Consumption and Usage Statistics *************************************** @@ -141,7 +141,7 @@ You can access this information using through the usage module (:class:`kasa.mod API documentation ***************** -.. autoclass:: SmartDevice +.. autoclass:: Device :members: :undoc-members: diff --git a/docs/source/discover.rst b/docs/source/discover.rst index b89178a38..29b68196d 100644 --- a/docs/source/discover.rst +++ b/docs/source/discover.rst @@ -13,7 +13,7 @@ Discovery works by sending broadcast UDP packets to two known TP-link discovery 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. +will need to await :func:`Device.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. diff --git a/docs/source/index.rst b/docs/source/index.rst index f5baf3894..5d4a9e559 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,8 +7,9 @@ Home cli + tutorial discover - smartdevice + device design contribute smartbulb diff --git a/docs/source/smartbulb.rst b/docs/source/smartbulb.rst index aa0e27e57..8fae54d17 100644 --- a/docs/source/smartbulb.rst +++ b/docs/source/smartbulb.rst @@ -67,13 +67,13 @@ API documentation :members: :undoc-members: -.. autoclass:: kasa.smartbulb.BehaviorMode +.. autoclass:: kasa.iot.iotbulb.BehaviorMode :members: -.. autoclass:: kasa.TurnOnBehaviors +.. autoclass:: kasa.iot.iotbulb.TurnOnBehaviors :members: -.. autoclass:: kasa.TurnOnBehavior +.. autoclass:: kasa.iot.iotbulb.TurnOnBehavior :undoc-members: :members: diff --git a/docs/source/tutorial.md b/docs/source/tutorial.md new file mode 100644 index 000000000..bd8d251cf --- /dev/null +++ b/docs/source/tutorial.md @@ -0,0 +1,8 @@ +# Tutorial + +```{eval-rst} +.. automodule:: tutorial + :members: + :inherited-members: + :undoc-members: +``` diff --git a/docs/tutorial.py b/docs/tutorial.py new file mode 100644 index 000000000..8757c5e87 --- /dev/null +++ b/docs/tutorial.py @@ -0,0 +1,103 @@ +# ruff: noqa +""" +The kasa library is fully async and methods that perform IO need to be run inside an async couroutine. + +These examples assume you are following the tutorial inside `asyncio REPL` (python -m asyncio) or the code +is running inside an async function (`async def`). + + +The main entry point for the API is :meth:`~kasa.Discover.discover` and +:meth:`~kasa.Discover.discover_single` which return Device objects. + +Most newer devices require your TP-Link cloud username and password, but this can be omitted for older devices. + +>>> from kasa import Device, Discover, Credentials + +:func:`~kasa.Discover.discover` returns a list of devices on your network: + +>>> devices = await Discover.discover(credentials=Credentials("user@example.com", "great_password")) +>>> for dev in devices: +>>> await dev.update() +>>> print(dev.host) +127.0.0.1 +127.0.0.2 + +:meth:`~kasa.Discover.discover_single` returns a single device by hostname: + +>>> dev = await Discover.discover_single("127.0.0.1", credentials=Credentials("user@example.com", "great_password")) +>>> await dev.update() +>>> dev.alias +Living Room +>>> dev.model +L530 +>>> dev.rssi +-52 +>>> dev.mac +5C:E9:31:00:00:00 + +You can update devices by calling different methods (e.g., ``set_``-prefixed ones). +Note, that these do not update the internal state, but you need to call :meth:`~kasa.Device.update()` to query the device again. +back to the device. + +>>> await dev.set_alias("Dining Room") +>>> await dev.update() +>>> dev.alias +Dining Room + +Different groups of functionality are supported by modules which you can access via :attr:`~kasa.Device.modules` with a typed +key from :class:`~kasa.Module`. + +Modules will only be available on the device if they are supported but some individual features of a module may not be available for your device. +You can check the availability using ``is_``-prefixed properties like `is_color`. + +>>> from kasa import Module +>>> Module.Light in dev.modules +True +>>> light = dev.modules[Module.Light] +>>> light.brightness +100 +>>> await light.set_brightness(50) +>>> await dev.update() +>>> light.brightness +50 +>>> light.is_color +True +>>> if light.is_color: +>>> print(light.hsv) +HSV(hue=0, saturation=100, value=50) + +You can test if a module is supported by using `get` to access it. + +>>> if effect := dev.modules.get(Module.LightEffect): +>>> print(effect.effect) +>>> print(effect.effect_list) +>>> if effect := dev.modules.get(Module.LightEffect): +>>> await effect.set_effect("Party") +>>> await dev.update() +>>> print(effect.effect) +Off +['Off', 'Party', 'Relax'] +Party + +Individual pieces of functionality are also exposed via features which you can access via :attr:`~kasa.Device.features` and will only be present if they are supported. + +Features are similar to modules in that they provide functionality that may or may not be present. + +Whereas modules group functionality into a common interface, features expose a single function that may or may not be part of a module. + +The advantage of features is that they have a simple common interface of `id`, `name`, `value` and `set_value` so no need to learn the module API. + +They are useful if you want write code that dynamically adapts as new features are added to the API. + +>>> if auto_update := dev.features.get("auto_update_enabled"): +>>> print(auto_update.value) +False +>>> if auto_update: +>>> await auto_update.set_value(True) +>>> await dev.update() +>>> print(auto_update.value) +True +>>> for feat in dev.features.values(): +>>> print(f"{feat.name}: {feat.value}") +Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: False\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: 1.1.6 Build 240130 Rel.173828\nLight effect: Party\nSmooth transition on: 2\nSmooth transition off: 2\nTime: 2024-02-23 02:40:15+01:00 +""" diff --git a/kasa/__init__.py b/kasa/__init__.py index 8428154ed..3a6f06e8d 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -1,12 +1,12 @@ """Python interface for TP-Link's smart home devices. -All common, shared functionalities are available through `SmartDevice` class:: +All common, shared functionalities are available through `Device` class:: - x = SmartDevice("192.168.1.1") - print(x.sys_info) +>>> from kasa import Discover +>>> x = await Discover.discover_single("192.168.1.1") +>>> print(x.model) -For device type specific actions `SmartBulb`, `SmartPlug`, or `SmartStrip` - should be used instead. +For device type specific actions `modules` and `features` should be used instead. Module-specific errors are raised as `KasaException` and are expected to be handled by the user of the library. diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 4144b784d..806fbaa42 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -150,7 +150,7 @@ class DeviceConfig: 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 can be retrieved from :attr:`Device.credentials_hash` credentials_hash: Optional[str] = None #: The protocol specific type of connection. Defaults to the legacy type. batch_size: Optional[int] = None diff --git a/kasa/discover.py b/kasa/discover.py index 833ffb415..0a3f3c92e 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -270,10 +270,10 @@ async def discover( you can use *target* parameter to specify the network for discovery. If given, `on_discovered` coroutine will get awaited with - a :class:`SmartDevice`-derived object as parameter. + a :class:`Device`-derived object as parameter. The results of the discovery are returned as a dict of - :class:`SmartDevice`-derived objects keyed with IP addresses. + :class:`Device`-derived objects keyed with IP addresses. The devices are already initialized and all but emeter-related properties can be accessed directly. @@ -332,7 +332,7 @@ async def discover_single( """Discover a single device by the given IP address. It is generally preferred to avoid :func:`discover_single()` and - use :meth:`SmartDevice.connect()` instead as it should perform better when + use :meth:`Device.connect()` instead as it should perform better when the WiFi network is congested or the device is not responding to discovery requests. diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index ffeac2801..da95ceb87 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -180,7 +180,7 @@ class IotBulb(IotDevice): >>> bulb.presets [LightPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), LightPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] - To modify an existing preset, pass :class:`~kasa.smartbulb.LightPreset` + To modify an existing preset, pass :class:`~kasa.interfaces.light.LightPreset` instance to :func:`save_preset` method: >>> preset = bulb.presets[0] diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 8651bf9a4..c7e789c67 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -41,7 +41,7 @@ class IotPlug(IotDevice): >>> plug.led True - For more examples, see the :class:`SmartDevice` class. + For more examples, see the :class:`Device` class. """ def __init__( diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 619046bd2..9cc31fae1 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -83,7 +83,7 @@ class IotStrip(IotDevice): >>> strip.is_on True - For more examples, see the :class:`SmartDevice` class. + For more examples, see the :class:`Device` class. """ def __init__( diff --git a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json index 48450fbeb..7e8788dfa 100644 --- a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json +++ b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json @@ -175,7 +175,7 @@ "longitude": 0, "mac": "5C-E9-31-00-00-00", "model": "L530", - "nickname": "I01BU0tFRF9OQU1FIw==", + "nickname": "TGl2aW5nIFJvb20=", "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Europe/Berlin", diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index 0d43da7be..fa1ae2225 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -1,7 +1,9 @@ import asyncio +import pytest import xdoctest +from kasa import Discover from kasa.tests.conftest import get_device_for_fixture_protocol @@ -67,3 +69,61 @@ def test_discovery_examples(mocker): mocker.patch("kasa.discover.Discover.discover", return_value=[p]) res = xdoctest.doctest_module("kasa.discover", "all") assert not res["failed"] + + +def test_tutorial_examples(mocker, top_level_await): + """Test discovery examples.""" + a = asyncio.run( + get_device_for_fixture_protocol("L530E(EU)_3.0_1.1.6.json", "SMART") + ) + b = asyncio.run(get_device_for_fixture_protocol("HS110(EU)_1.0_1.2.5.json", "IOT")) + a.host = "127.0.0.1" + b.host = "127.0.0.2" + + # Note autospec does not work for staticmethods in python < 3.12 + # https://github.com/python/cpython/issues/102978 + mocker.patch( + "kasa.discover.Discover.discover_single", return_value=a, autospec=True + ) + mocker.patch.object(Discover, "discover", return_value=[a, b], autospec=True) + res = xdoctest.doctest_module("docs/tutorial.py", "all") + assert not res["failed"] + + +@pytest.fixture +def top_level_await(mocker): + """Fixture to enable top level awaits in doctests. + + Uses the async exec feature of python to patch the builtins xdoctest uses. + See https://github.com/python/cpython/issues/78797 + """ + import ast + from inspect import CO_COROUTINE + + orig_exec = exec + orig_eval = eval + orig_compile = compile + + def patch_exec(source, globals=None, locals=None, /, **kwargs): + if source.co_flags & CO_COROUTINE == CO_COROUTINE: + asyncio.run(orig_eval(source, globals, locals)) + else: + orig_exec(source, globals, locals, **kwargs) + + def patch_eval(source, globals=None, locals=None, /, **kwargs): + if source.co_flags & CO_COROUTINE == CO_COROUTINE: + return asyncio.run(orig_eval(source, globals, locals, **kwargs)) + else: + return orig_eval(source, globals, locals, **kwargs) + + def patch_compile( + source, filename, mode, flags=0, dont_inherit=False, optimize=-1, **kwargs + ): + flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT + return orig_compile( + source, filename, mode, flags, dont_inherit, optimize, **kwargs + ) + + mocker.patch("builtins.eval", side_effect=patch_eval) + mocker.patch("builtins.exec", side_effect=patch_exec) + mocker.patch("builtins.compile", side_effect=patch_compile) From 9989d0f6ec7ced8d030fec1b814d87615d37861f Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sun, 19 May 2024 10:18:17 +0100 Subject: [PATCH 436/892] Add post update hook to module and use in smart LightEffect (#921) Adds a post update hook to modules so they can calculate values and collections once rather than on each property access --- kasa/module.py | 12 +++++++++ kasa/smart/modules/lighteffect.py | 43 +++++++++++++++---------------- kasa/smart/smartchilddevice.py | 1 - kasa/smart/smartdevice.py | 10 +++++++ kasa/tests/test_smartdevice.py | 3 +++ 5 files changed, 46 insertions(+), 23 deletions(-) diff --git a/kasa/module.py b/kasa/module.py index 9b541ce04..b2be82894 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -107,6 +107,18 @@ def _initialize_features(self): # noqa: B027 """Initialize features after the initial update. This can be implemented if features depend on module query responses. + It will only be called once per module and will always be called + after *_post_update_hook* has been called for every device module and its + children's modules. + """ + + def _post_update_hook(self): # noqa: B027 + """Perform actions after a device update. + + This can be implemented if a module needs to perform actions each time + the device has updated like generating collections for property access. + It will be called after every update and will be called prior to + *_initialize_features* on the first update. """ def _add_feature(self, feature: Feature): diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py index 4f049576d..170cfbb39 100644 --- a/kasa/smart/modules/lighteffect.py +++ b/kasa/smart/modules/lighteffect.py @@ -4,14 +4,11 @@ import base64 import copy -from typing import TYPE_CHECKING, Any +from typing import Any from ...interfaces.lighteffect import LightEffect as LightEffectInterface from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - class LightEffect(SmartModule, LightEffectInterface): """Implementation of dynamic light effects.""" @@ -23,12 +20,13 @@ class LightEffect(SmartModule, LightEffectInterface): "L2": "Relax", } - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) - self._scenes_names_to_id: dict[str, str] = {} + _effect: str + _effect_state_list: dict[str, dict[str, Any]] + _effect_list: list[str] + _scenes_names_to_id: dict[str, str] - def _initialize_effects(self) -> dict[str, dict[str, Any]]: - """Return built-in effects.""" + def _post_update_hook(self) -> None: + """Update internal effect state.""" # Copy the effects so scene name updates do not update the underlying dict. effects = copy.deepcopy( {effect["id"]: effect for effect in self.data["rule_list"]} @@ -40,10 +38,21 @@ def _initialize_effects(self) -> dict[str, dict[str, Any]]: else: # Otherwise it will be b64 encoded effect["scene_name"] = base64.b64decode(effect["scene_name"]).decode() + + self._effect_state_list = effects + self._effect_list = [self.LIGHT_EFFECTS_OFF] + self._effect_list.extend([effect["scene_name"] for effect in effects.values()]) self._scenes_names_to_id = { effect["scene_name"]: effect["id"] for effect in effects.values() } - return effects + # get_dynamic_light_effect_rules also has an enable property and current_rule_id + # property that could be used here as an alternative + if self._device._info["dynamic_light_effect_enable"]: + self._effect = self._effect_state_list[ + self._device._info["dynamic_light_effect_id"] + ]["scene_name"] + else: + self._effect = self.LIGHT_EFFECTS_OFF @property def effect_list(self) -> list[str]: @@ -52,22 +61,12 @@ def effect_list(self) -> list[str]: Example: ['Party', 'Relax', ...] """ - effects = [self.LIGHT_EFFECTS_OFF] - effects.extend( - [effect["scene_name"] for effect in self._initialize_effects().values()] - ) - return effects + return self._effect_list @property def effect(self) -> str: """Return effect name.""" - # get_dynamic_light_effect_rules also has an enable property and current_rule_id - # property that could be used here as an alternative - if self._device._info["dynamic_light_effect_enable"]: - return self._initialize_effects()[ - self._device._info["dynamic_light_effect_id"] - ]["scene_name"] - return self.LIGHT_EFFECTS_OFF + return self._effect async def set_effect( self, diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index d841d2d9d..3c3b0f292 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -41,7 +41,6 @@ async def create(cls, parent: SmartDevice, child_info, child_components): """Create a child device based on device info and component listing.""" child: SmartChildDevice = cls(parent, child_info, child_components) await child._initialize_modules() - await child._initialize_features() return child @property diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index e42609954..55de9c04b 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -184,6 +184,13 @@ async def update(self, update_children: bool = True): for info in child_info["child_device_list"]: self._children[info["device_id"]]._update_internal_state(info) + # Call handle update for modules that want to update internal data + for module in self._modules.values(): + module._post_update_hook() + for child in self._children.values(): + for child_module in child._modules.values(): + child_module._post_update_hook() + # We can first initialize the features after the first update. # We make here an assumption that every device has at least a single feature. if not self._features: @@ -332,6 +339,9 @@ async def _initialize_features(self): for feat in module._module_features.values(): self._add_feature(feat) + for child in self._children.values(): + await child._initialize_features() + @property def is_cloud_connected(self) -> bool: """Returns if the device is connected to the cloud.""" diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index c4a4685a3..88880e103 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -48,7 +48,10 @@ async def test_initial_update(dev: SmartDevice, mocker: MockerFixture): """Test the initial update cycle.""" # As the fixture data is already initialized, we reset the state for testing dev._components_raw = None + dev._components = {} + dev._modules = {} dev._features = {} + dev._children = {} negotiate = mocker.spy(dev, "_negotiate") initialize_modules = mocker.spy(dev, "_initialize_modules") From 1ba5c73279ae21973841e37d41f84cf024e48bc8 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sun, 19 May 2024 10:34:52 +0100 Subject: [PATCH 437/892] Fix potential infinite loop if incomplete lists returned (#920) Fixes the test framework to handle fixtures with incomplete lists better by checking for completeness and overriding the sum. Also adds a pytest-timeout dev dependency with timeout set to 10 seconds. Finally fixes smartprotocol to prevent an infinite loop if incomplete lists ever happens in the real world. Co-authored-by: Teemu R. --- kasa/smartprotocol.py | 7 +++++ kasa/tests/conftest.py | 2 ++ kasa/tests/fakeprotocol_smart.py | 23 +++++++++++++-- kasa/tests/test_smartprotocol.py | 48 ++++++++++++++++++++++++++++++++ poetry.lock | 28 +++++++++++++++++-- pyproject.toml | 2 ++ 6 files changed, 105 insertions(+), 5 deletions(-) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 472d93202..b1cde04df 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -229,6 +229,13 @@ async def _handle_response_lists( iterate_list_pages=False, ) next_batch = response[method] + # In case the device returns empty lists avoid infinite looping + if not next_batch[response_list_name]: + _LOGGER.error( + f"Device {self._host} returned empty " + + f"results list for method {method}" + ) + break response_result[response_list_name].extend(next_batch[response_list_name]) def _handle_response_error_code(self, resp_dict: dict, method, raise_on_error=True): diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 7829eac13..578a82c62 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -58,6 +58,8 @@ def pytest_configure(): def pytest_sessionfinish(session, exitstatus): + if not pytest.fixtures_missing_methods: + return msg = "\n" for fixture, methods in sorted(pytest.fixtures_missing_methods.items()): method_list = ", ".join(methods) diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 233944509..693410b4e 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -28,6 +28,8 @@ def __init__( *, list_return_size=10, component_nego_not_included=False, + warn_fixture_missing_methods=True, + fix_incomplete_fixture_lists=True, ): super().__init__( config=DeviceConfig( @@ -46,6 +48,8 @@ def __init__( for comp in self.info["component_nego"]["component_list"] } self.list_return_size = list_return_size + self.warn_fixture_missing_methods = warn_fixture_missing_methods + self.fix_incomplete_fixture_lists = fix_incomplete_fixture_lists @property def default_port(self): @@ -220,6 +224,18 @@ def _send_request(self, request_dict: dict): if (params and (start_index := params.get("start_index"))) else 0 ) + # Fixtures generated before _handle_response_lists was implemented + # could have incomplete lists. + if ( + len(result[list_key]) < result["sum"] + and self.fix_incomplete_fixture_lists + ): + result["sum"] = len(result[list_key]) + if self.warn_fixture_missing_methods: + pytest.fixtures_missing_methods.setdefault( + self.fixture_name, set() + ).add(f"{method} (incomplete '{list_key}' list)") + result[list_key] = result[list_key][ start_index : start_index + self.list_return_size ] @@ -244,9 +260,10 @@ def _send_request(self, request_dict: dict): "method": method, } # Reduce warning spam by consolidating and reporting at the end of the run - if self.fixture_name not in pytest.fixtures_missing_methods: - pytest.fixtures_missing_methods[self.fixture_name] = set() - pytest.fixtures_missing_methods[self.fixture_name].add(method) + if self.warn_fixture_missing_methods: + pytest.fixtures_missing_methods.setdefault( + self.fixture_name, set() + ).add(method) return retval elif method in ["set_qs_info", "fw_download"]: return {"error_code": 0} diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index ca62ba02d..a2bcacfa4 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -1,3 +1,5 @@ +import logging + import pytest from ..credentials import Credentials @@ -242,3 +244,49 @@ async def test_smart_protocol_lists_multiple_request(mocker, list_sum, batch_siz ) assert query_spy.call_count == expected_count assert resp == response + + +async def test_incomplete_list(mocker, caplog): + """Test for handling incomplete lists returned from queries.""" + info = { + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 50, + }, + { + "brightness": 100, + }, + ], + "sum": 7, + } + } + caplog.set_level(logging.ERROR) + transport = FakeSmartTransport( + info, + "dummy-name", + component_nego_not_included=True, + warn_fixture_missing_methods=False, + ) + protocol = SmartProtocol(transport=transport) + resp = await protocol.query({"get_preset_rules": None}) + assert resp + assert resp["get_preset_rules"]["sum"] == 2 # FakeTransport fixes sum + assert caplog.text == "" + + # Test behaviour without FakeTranport fix + transport = FakeSmartTransport( + info, + "dummy-name", + component_nego_not_included=True, + warn_fixture_missing_methods=False, + fix_incomplete_fixture_lists=False, + ) + protocol = SmartProtocol(transport=transport) + resp = await protocol.query({"get_preset_rules": None}) + assert resp["get_preset_rules"]["sum"] == 7 + assert ( + "Device 127.0.0.123 returned empty results list for method get_preset_rules" + in caplog.text + ) diff --git a/poetry.lock b/poetry.lock index 6bd770b5e..90667c80f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "aiohttp" @@ -1541,6 +1541,20 @@ termcolor = ">=2.1.0" [package.extras] dev = ["black", "flake8", "pre-commit"] +[[package]] +name = "pytest-timeout" +version = "2.3.1" +description = "pytest plugin to abort hanging tests" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9"}, + {file = "pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + [[package]] name = "pytz" version = "2024.1" @@ -1564,6 +1578,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1571,8 +1586,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1589,6 +1611,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1596,6 +1619,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -2132,4 +2156,4 @@ speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "d627e4165dade7eaaf21708f00bc919bc3fffb3e8a805e186dfb56e5e1781bbe" +content-hash = "ba5c0da1e413e466834d0954528c7ace6dd9e01d9fb2e626f4c6b23044803aef" diff --git a/pyproject.toml b/pyproject.toml index 5b5f4d3e8..783477e1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ pytest-mock = "*" codecov = "*" xdoctest = "*" coverage = {version = "*", extras = ["toml"]} +pytest-timeout = "^2" [tool.poetry.extras] docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] @@ -89,6 +90,7 @@ markers = [ "requires_dummy: test requires dummy data to pass, skipped on real devices", ] asyncio_mode = "auto" +timeout = 10 [tool.doc8] paths = ["docs"] From 273c541fcc181cca046ccbe53902fa4ab11d89be Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sun, 19 May 2024 11:20:18 +0100 Subject: [PATCH 438/892] Add light presets common module to devices. (#907) Adds light preset common module for switching to presets and saving presets. Deprecates the `presets` attribute and `save_preset` method from the `bulb` interface in favour of the modular approach. Allows setting preset for `iot` which was not previously supported. --- docs/tutorial.py | 2 +- kasa/__init__.py | 9 +- kasa/device.py | 4 + kasa/interfaces/__init__.py | 4 +- kasa/interfaces/light.py | 38 ++++---- kasa/interfaces/lightpreset.py | 76 +++++++++++++++ kasa/iot/iotbulb.py | 42 +++------ kasa/iot/iotdevice.py | 6 +- kasa/iot/modules/__init__.py | 3 + kasa/iot/modules/light.py | 23 ++++- kasa/iot/modules/lightpreset.py | 151 ++++++++++++++++++++++++++++++ kasa/module.py | 1 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/light.py | 15 ++- kasa/smart/modules/lightpreset.py | 142 ++++++++++++++++++++++++++++ kasa/smart/smartdevice.py | 1 - kasa/tests/fakeprotocol_smart.py | 30 ++++++ kasa/tests/test_bulb.py | 22 +++-- kasa/tests/test_common_modules.py | 86 ++++++++++++++++- kasa/tests/test_device.py | 28 ++++++ 20 files changed, 612 insertions(+), 73 deletions(-) create mode 100644 kasa/interfaces/lightpreset.py create mode 100644 kasa/iot/modules/lightpreset.py create mode 100644 kasa/smart/modules/lightpreset.py diff --git a/docs/tutorial.py b/docs/tutorial.py index 8757c5e87..fb4a62736 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -99,5 +99,5 @@ True >>> for feat in dev.features.values(): >>> print(f"{feat.name}: {feat.value}") -Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: False\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: 1.1.6 Build 240130 Rel.173828\nLight effect: Party\nSmooth transition on: 2\nSmooth transition off: 2\nTime: 2024-02-23 02:40:15+01:00 +Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: False\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: 1.1.6 Build 240130 Rel.173828\nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nTime: 2024-02-23 02:40:15+01:00 """ diff --git a/kasa/__init__.py b/kasa/__init__.py index 3a6f06e8d..ac10c12f8 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -35,7 +35,7 @@ UnsupportedDeviceError, ) from kasa.feature import Feature -from kasa.interfaces.light import Light, LightPreset +from kasa.interfaces.light import Light, LightState from kasa.iotprotocol import ( IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 @@ -52,7 +52,7 @@ "BaseProtocol", "IotProtocol", "SmartProtocol", - "LightPreset", + "LightState", "TurnOnBehaviors", "TurnOnBehavior", "DeviceType", @@ -75,6 +75,7 @@ ] from . import iot +from .iot.modules.lightpreset import IotLightPreset deprecated_names = ["TPLinkSmartHomeProtocol"] deprecated_smart_devices = { @@ -84,7 +85,7 @@ "SmartLightStrip": iot.IotLightStrip, "SmartStrip": iot.IotStrip, "SmartDimmer": iot.IotDimmer, - "SmartBulbPreset": LightPreset, + "SmartBulbPreset": IotLightPreset, } deprecated_exceptions = { "SmartDeviceException": KasaException, @@ -124,7 +125,7 @@ def __getattr__(name): SmartLightStrip = iot.IotLightStrip SmartStrip = iot.IotStrip SmartDimmer = iot.IotDimmer - SmartBulbPreset = LightPreset + SmartBulbPreset = IotLightPreset SmartDeviceException = KasaException UnsupportedDeviceException = UnsupportedDeviceError diff --git a/kasa/device.py b/kasa/device.py index 052abc4ce..7156a2194 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -364,6 +364,7 @@ def _get_replacing_attr(self, module_name: ModuleName, *attrs): "set_color_temp": (Module.Light, ["set_color_temp"]), "valid_temperature_range": (Module.Light, ["valid_temperature_range"]), "has_effects": (Module.Light, ["has_effects"]), + "_deprecated_set_light_state": (Module.Light, ["has_effects"]), # led attributes "led": (Module.Led, ["led"]), "set_led": (Module.Led, ["set_led"]), @@ -376,6 +377,9 @@ def _get_replacing_attr(self, module_name: ModuleName, *attrs): "effect_list": (Module.LightEffect, ["_deprecated_effect_list", "effect_list"]), "set_effect": (Module.LightEffect, ["set_effect"]), "set_custom_effect": (Module.LightEffect, ["set_custom_effect"]), + # light preset attributes + "presets": (Module.LightPreset, ["_deprecated_presets", "preset_states_list"]), + "save_preset": (Module.LightPreset, ["_deprecated_save_preset"]), } def __getattr__(self, name): diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index d8d089c5c..31b9bc33d 100644 --- a/kasa/interfaces/__init__.py +++ b/kasa/interfaces/__init__.py @@ -2,13 +2,15 @@ from .fan import Fan from .led import Led -from .light import Light, LightPreset +from .light import Light, LightState from .lighteffect import LightEffect +from .lightpreset import LightPreset __all__ = [ "Fan", "Led", "Light", "LightEffect", + "LightState", "LightPreset", ] diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py index 3a8805c10..f121d9c69 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -3,13 +3,24 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import NamedTuple, Optional - -from pydantic.v1 import BaseModel +from dataclasses import dataclass +from typing import NamedTuple from ..module import Module +@dataclass +class LightState: + """Class for smart light preset info.""" + + light_on: bool | None = None + brightness: int | None = None + hue: int | None = None + saturation: int | None = None + color_temp: int | None = None + transition: bool | None = None + + class ColorTempRange(NamedTuple): """Color temperature range.""" @@ -25,23 +36,6 @@ class HSV(NamedTuple): value: int -class LightPreset(BaseModel): - """Light configuration preset.""" - - index: int - brightness: int - - # These are not available for effect mode presets on light strips - hue: Optional[int] # noqa: UP007 - saturation: Optional[int] # noqa: UP007 - color_temp: Optional[int] # noqa: UP007 - - # Variables for effect mode presets - custom: Optional[int] # noqa: UP007 - id: Optional[str] # noqa: UP007 - mode: Optional[int] # noqa: UP007 - - class Light(Module, ABC): """Base class for TP-Link Light.""" @@ -133,3 +127,7 @@ async def set_brightness( :param int brightness: brightness in percent :param int transition: transition in milliseconds. """ + + @abstractmethod + async def set_state(self, state: LightState) -> dict: + """Set the light state.""" diff --git a/kasa/interfaces/lightpreset.py b/kasa/interfaces/lightpreset.py new file mode 100644 index 000000000..84a374dbc --- /dev/null +++ b/kasa/interfaces/lightpreset.py @@ -0,0 +1,76 @@ +"""Module for LightPreset base class.""" + +from __future__ import annotations + +from abc import abstractmethod +from typing import Sequence + +from ..feature import Feature +from ..module import Module +from .light import LightState + + +class LightPreset(Module): + """Base interface for light preset module.""" + + PRESET_NOT_SET = "Not set" + + def _initialize_features(self): + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device, + id="light_preset", + name="Light preset", + container=self, + attribute_getter="preset", + attribute_setter="set_preset", + category=Feature.Category.Config, + type=Feature.Type.Choice, + choices_getter="preset_list", + ) + ) + + @property + @abstractmethod + def preset_list(self) -> list[str]: + """Return list of preset names. + + Example: + ['Off', 'Preset 1', 'Preset 2', ...] + """ + + @property + @abstractmethod + def preset_states_list(self) -> Sequence[LightState]: + """Return list of preset states. + + Example: + ['Off', 'Preset 1', 'Preset 2', ...] + """ + + @property + @abstractmethod + def preset(self) -> str: + """Return current preset name.""" + + @abstractmethod + async def set_preset( + self, + preset_name: str, + ) -> None: + """Set a light preset for the device.""" + + @abstractmethod + async def save_preset( + self, + preset_name: str, + preset_info: LightState, + ) -> None: + """Update the preset with *preset_name* with the new *preset_info*.""" + + @property + @abstractmethod + def has_save_preset(self) -> bool: + """Return True if the device supports updating presets.""" diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index da95ceb87..cca1e7922 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -11,7 +11,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..interfaces.light import HSV, ColorTempRange, LightPreset +from ..interfaces.light import HSV, ColorTempRange from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, KasaException, requires_update @@ -21,6 +21,7 @@ Countdown, Emeter, Light, + LightPreset, Schedule, Time, Usage, @@ -178,7 +179,7 @@ class IotBulb(IotDevice): Bulb configuration presets can be accessed using the :func:`presets` property: >>> bulb.presets - [LightPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), LightPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] + [IotLightPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), IotLightPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), IotLightPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), IotLightPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] To modify an existing preset, pass :class:`~kasa.interfaces.light.LightPreset` instance to :func:`save_preset` method: @@ -222,7 +223,8 @@ async def _initialize_modules(self): self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud")) - self.add_module(Module.Light, Light(self, "light")) + self.add_module(Module.Light, Light(self, self.LIGHT_SERVICE)) + self.add_module(Module.LightPreset, LightPreset(self, self.LIGHT_SERVICE)) @property # type: ignore @requires_update @@ -320,7 +322,7 @@ async def get_light_state(self) -> dict[str, dict]: # TODO: add warning and refer to use light.state? return await self._query_helper(self.LIGHT_SERVICE, "get_light_state") - async def set_light_state( + async def _set_light_state( self, state: dict, *, transition: int | None = None ) -> dict: """Set the light state.""" @@ -400,7 +402,7 @@ async def _set_hsv( self._raise_for_invalid_brightness(value) light_state["brightness"] = value - return await self.set_light_state(light_state, transition=transition) + return await self._set_light_state(light_state, transition=transition) @property # type: ignore @requires_update @@ -436,7 +438,7 @@ async def _set_color_temp( if brightness is not None: light_state["brightness"] = brightness - return await self.set_light_state(light_state, transition=transition) + return await self._set_light_state(light_state, transition=transition) def _raise_for_invalid_brightness(self, value): if not isinstance(value, int) or not (0 <= value <= 100): @@ -467,7 +469,7 @@ async def _set_brightness( self._raise_for_invalid_brightness(brightness) light_state = {"brightness": brightness} - return await self.set_light_state(light_state, transition=transition) + return await self._set_light_state(light_state, transition=transition) @property # type: ignore @requires_update @@ -481,14 +483,14 @@ async def turn_off(self, *, transition: int | None = None, **kwargs) -> dict: :param int transition: transition in milliseconds. """ - return await self.set_light_state({"on_off": 0}, transition=transition) + return await self._set_light_state({"on_off": 0}, transition=transition) async def turn_on(self, *, transition: int | None = None, **kwargs) -> dict: """Turn the bulb on. :param int transition: transition in milliseconds. """ - return await self.set_light_state({"on_off": 1}, transition=transition) + return await self._set_light_state({"on_off": 1}, transition=transition) @property # type: ignore @requires_update @@ -505,28 +507,6 @@ async def set_alias(self, alias: str) -> None: "smartlife.iot.common.system", "set_dev_alias", {"alias": alias} ) - @property # type: ignore - @requires_update - def presets(self) -> list[LightPreset]: - """Return a list of available bulb setting presets.""" - return [LightPreset(**vals) for vals in self.sys_info["preferred_state"]] - - async def save_preset(self, preset: LightPreset): - """Save a setting preset. - - You can either construct a preset object manually, or pass an existing one - obtained using :func:`presets`. - """ - if len(self.presets) == 0: - raise KasaException("Device does not supported saving presets") - - if preset.index >= len(self.presets): - raise KasaException("Invalid preset index") - - return await self._query_helper( - self.LIGHT_SERVICE, "set_preferred_state", preset.dict(exclude_none=True) - ) - @property def max_device_response_size(self) -> int: """Returns the maximum response size the device can safely construct.""" diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index f3ac5321c..25e3b44d5 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -312,11 +312,13 @@ async def update(self, update_children: bool = True): await self._modular_update(req) + self._set_sys_info(self._last_update["system"]["get_sysinfo"]) + for module in self._modules.values(): + module._post_update_hook() + if not self._features: await self._initialize_features() - self._set_sys_info(self._last_update["system"]["get_sysinfo"]) - async def _initialize_modules(self): """Initialize modules not added in init.""" diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index 2d6f6a01e..6fd63a706 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -8,6 +8,7 @@ from .led import Led from .light import Light from .lighteffect import LightEffect +from .lightpreset import IotLightPreset, LightPreset from .motion import Motion from .rulemodule import Rule, RuleModule from .schedule import Schedule @@ -23,6 +24,8 @@ "Led", "Light", "LightEffect", + "LightPreset", + "IotLightPreset", "Motion", "Rule", "RuleModule", diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 833709df5..6bbb8894f 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -2,12 +2,13 @@ from __future__ import annotations +from dataclasses import asdict from typing import TYPE_CHECKING, cast from ...device_type import DeviceType from ...exceptions import KasaException from ...feature import Feature -from ...interfaces.light import HSV, ColorTempRange +from ...interfaces.light import HSV, ColorTempRange, LightState from ...interfaces.light import Light as LightInterface from ..iotmodule import IotModule @@ -198,3 +199,23 @@ async def set_color_temp( return await bulb._set_color_temp( temp, brightness=brightness, transition=transition ) + + async def set_state(self, state: LightState) -> dict: + """Set the light state.""" + if (bulb := self._get_bulb_device()) is None: + return await self.set_brightness(state.brightness or 0) + else: + transition = state.transition + state_dict = asdict(state) + state_dict = {k: v for k, v in state_dict.items() if v is not None} + state_dict["on_off"] = 1 if state.light_on is None else int(state.light_on) + return await bulb._set_light_state(state_dict, transition=transition) + + async def _deprecated_set_light_state( + self, state: dict, *, transition: int | None = None + ) -> dict: + """Set the light state.""" + if (bulb := self._get_bulb_device()) is None: + raise KasaException("Device does not support set_light_state") + else: + return await bulb._set_light_state(state, transition=transition) diff --git a/kasa/iot/modules/lightpreset.py b/kasa/iot/modules/lightpreset.py new file mode 100644 index 000000000..49eca3b83 --- /dev/null +++ b/kasa/iot/modules/lightpreset.py @@ -0,0 +1,151 @@ +"""Light preset module.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import TYPE_CHECKING, Optional, Sequence + +from pydantic.v1 import BaseModel, Field + +from ...exceptions import KasaException +from ...interfaces import LightPreset as LightPresetInterface +from ...interfaces import LightState +from ...module import Module +from ..iotmodule import IotModule + +if TYPE_CHECKING: + pass + + +class IotLightPreset(BaseModel, LightState): + """Light configuration preset.""" + + index: int = Field(kw_only=True) + brightness: int = Field(kw_only=True) + + # These are not available for effect mode presets on light strips + hue: Optional[int] = Field(kw_only=True, default=None) # noqa: UP007 + saturation: Optional[int] = Field(kw_only=True, default=None) # noqa: UP007 + color_temp: Optional[int] = Field(kw_only=True, default=None) # noqa: UP007 + + # Variables for effect mode presets + custom: Optional[int] = Field(kw_only=True, default=None) # noqa: UP007 + id: Optional[str] = Field(kw_only=True, default=None) # noqa: UP007 + mode: Optional[int] = Field(kw_only=True, default=None) # noqa: UP007 + + +class LightPreset(IotModule, LightPresetInterface): + """Class for setting light presets.""" + + _presets: dict[str, IotLightPreset] + _preset_list: list[str] + + def _post_update_hook(self): + """Update the internal presets.""" + self._presets = { + f"Light preset {index+1}": IotLightPreset(**vals) + for index, vals in enumerate(self.data["preferred_state"]) + } + self._preset_list = [self.PRESET_NOT_SET] + self._preset_list.extend(self._presets.keys()) + + @property + def preset_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Off', 'Preset 1', 'Preset 2', ...] + """ + return self._preset_list + + @property + def preset_states_list(self) -> Sequence[IotLightPreset]: + """Return built-in effects list. + + Example: + ['Off', 'Preset 1', 'Preset 2', ...] + """ + return list(self._presets.values()) + + @property + def preset(self) -> str: + """Return current preset name.""" + light = self._device.modules[Module.Light] + brightness = light.brightness + color_temp = light.color_temp if light.is_variable_color_temp else None + h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None) + for preset_name, preset in self._presets.items(): + if ( + preset.brightness == brightness + and ( + preset.color_temp == color_temp or not light.is_variable_color_temp + ) + and (preset.hue == h or not light.is_color) + and (preset.saturation == s or not light.is_color) + ): + return preset_name + return self.PRESET_NOT_SET + + async def set_preset( + self, + preset_name: str, + ) -> None: + """Set a light preset for the device.""" + light = self._device.modules[Module.Light] + if preset_name == self.PRESET_NOT_SET: + if light.is_color: + preset = LightState(hue=0, saturation=0, brightness=100) + else: + preset = LightState(brightness=100) + elif (preset := self._presets.get(preset_name)) is None: # type: ignore[assignment] + raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}") + + await light.set_state(preset) + + @property + def has_save_preset(self) -> bool: + """Return True if the device supports updating presets.""" + return True + + async def save_preset( + self, + preset_name: str, + preset_state: LightState, + ) -> None: + """Update the preset with preset_name with the new preset_info.""" + if len(self._presets) == 0: + raise KasaException("Device does not supported saving presets") + if preset_name not in self._presets: + raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}") + + index = list(self._presets.keys()).index(preset_name) + state = asdict(preset_state) + state = {k: v for k, v in state.items() if v is not None} + state["index"] = index + + return await self.call("set_preferred_state", state) + + def query(self): + """Return the base query.""" + return {} + + @property # type: ignore + def _deprecated_presets(self) -> list[IotLightPreset]: + """Return a list of available bulb setting presets.""" + return [ + IotLightPreset(**vals) for vals in self._device.sys_info["preferred_state"] + ] + + async def _deprecated_save_preset(self, preset: IotLightPreset): + """Save a setting preset. + + You can either construct a preset object manually, or pass an existing one + obtained using :func:`presets`. + """ + if len(self._presets) == 0: + raise KasaException("Device does not supported saving presets") + + if preset.index >= len(self._presets): + raise KasaException("Invalid preset index") + + return await self.call("set_preferred_state", preset.dict(exclude_none=True)) diff --git a/kasa/module.py b/kasa/module.py index b2be82894..a2a9c931a 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -36,6 +36,7 @@ class Module(ABC): LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect") Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") + LightPreset: Final[ModuleName[interfaces.LightPreset]] = ModuleName("LightPreset") # IOT only Modules IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 688d4a6e5..ada52f91f 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -18,6 +18,7 @@ from .led import Led from .light import Light from .lighteffect import LightEffect +from .lightpreset import LightPreset from .lightstripeffect import LightStripEffect from .lighttransition import LightTransition from .reportmode import ReportMode @@ -41,6 +42,7 @@ "Led", "Brightness", "Fan", + "LightPreset", "Firmware", "Cloud", "Light", diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py index 88d6486bc..9a07d3e2b 100644 --- a/kasa/smart/modules/light.py +++ b/kasa/smart/modules/light.py @@ -2,8 +2,10 @@ from __future__ import annotations +from dataclasses import asdict + from ...exceptions import KasaException -from ...interfaces.light import HSV, ColorTempRange +from ...interfaces.light import HSV, ColorTempRange, LightState from ...interfaces.light import Light as LightInterface from ...module import Module from ..smartmodule import SmartModule @@ -124,3 +126,14 @@ async def set_brightness( def has_effects(self) -> bool: """Return True if the device supports effects.""" return Module.LightEffect in self._device.modules + + async def set_state(self, state: LightState) -> dict: + """Set the light state.""" + state_dict = asdict(state) + # brightness of 0 turns off the light, it's not a valid brightness + if state.brightness and state.brightness == 0: + state_dict["device_on"] = False + del state_dict["brightness"] + + params = {k: v for k, v in state_dict.items() if v is not None} + return await self.call("set_device_info", params) diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py new file mode 100644 index 000000000..e0a775aff --- /dev/null +++ b/kasa/smart/modules/lightpreset.py @@ -0,0 +1,142 @@ +"""Module for light effects.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import TYPE_CHECKING, Sequence + +from ...interfaces import LightPreset as LightPresetInterface +from ...interfaces import LightState +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class LightPreset(SmartModule, LightPresetInterface): + """Implementation of light presets.""" + + REQUIRED_COMPONENT = "preset" + QUERY_GETTER_NAME = "get_preset_rules" + + SYS_INFO_STATE_KEY = "preset_state" + + _presets: dict[str, LightState] + _preset_list: list[str] + + def __init__(self, device: SmartDevice, module: str): + super().__init__(device, module) + self._state_in_sysinfo = self.SYS_INFO_STATE_KEY in device.sys_info + self._brightness_only: bool = False + + def _post_update_hook(self): + """Update the internal presets.""" + index = 0 + self._presets = {} + + state_key = "states" if not self._state_in_sysinfo else self.SYS_INFO_STATE_KEY + if preset_states := self.data.get(state_key): + for preset_state in preset_states: + color_temp = preset_state.get("color_temp") + hue = preset_state.get("hue") + saturation = preset_state.get("saturation") + self._presets[f"Light preset {index + 1}"] = LightState( + brightness=preset_state["brightness"], + color_temp=color_temp, + hue=hue, + saturation=saturation, + ) + if color_temp is None and hue is None and saturation is None: + self._brightness_only = True + index = index + 1 + elif preset_brightnesses := self.data.get("brightness"): + self._brightness_only = True + for preset_brightness in preset_brightnesses: + self._presets[f"Brightness preset {index + 1}"] = LightState( + brightness=preset_brightness, + ) + index = index + 1 + + self._preset_list = [self.PRESET_NOT_SET] + self._preset_list.extend(self._presets.keys()) + + @property + def preset_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Off', 'Light preset 1', 'Light preset 2', ...] + """ + return self._preset_list + + @property + def preset_states_list(self) -> Sequence[LightState]: + """Return built-in effects list. + + Example: + ['Off', 'Preset 1', 'Preset 2', ...] + """ + return list(self._presets.values()) + + @property + def preset(self) -> str: + """Return current preset name.""" + light = self._device.modules[SmartModule.Light] + brightness = light.brightness + color_temp = light.color_temp if light.is_variable_color_temp else None + h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None) + for preset_name, preset in self._presets.items(): + if ( + preset.brightness == brightness + and ( + preset.color_temp == color_temp or not light.is_variable_color_temp + ) + and preset.hue == h + and preset.saturation == s + ): + return preset_name + return self.PRESET_NOT_SET + + async def set_preset( + self, + preset_name: str, + ) -> None: + """Set a light preset for the device.""" + light = self._device.modules[SmartModule.Light] + if preset_name == self.PRESET_NOT_SET: + if light.is_color: + preset = LightState(hue=0, saturation=0, brightness=100) + else: + preset = LightState(brightness=100) + elif (preset := self._presets.get(preset_name)) is None: # type: ignore[assignment] + raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}") + await self._device.modules[SmartModule.Light].set_state(preset) + + async def save_preset( + self, + preset_name: str, + preset_state: LightState, + ) -> None: + """Update the preset with preset_name with the new preset_info.""" + if preset_name not in self._presets: + raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}") + index = list(self._presets.keys()).index(preset_name) + if self._brightness_only: + bright_list = [state.brightness for state in self._presets.values()] + bright_list[index] = preset_state.brightness + await self.call("set_preset_rules", {"brightness": bright_list}) + else: + state_params = asdict(preset_state) + new_info = {k: v for k, v in state_params.items() if v is not None} + await self.call("edit_preset_rules", {"index": index, "state": new_info}) + + @property + def has_save_preset(self) -> bool: + """Return True if the device supports updating presets.""" + return True + + def query(self) -> dict: + """Query to execute during the update cycle.""" + if self._state_in_sysinfo: # Child lights can have states in the child info + return {} + return {self.QUERY_GETTER_NAME: None} diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 55de9c04b..3250c98e0 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -338,7 +338,6 @@ async def _initialize_features(self): module._initialize_features() for feat in module._module_features.values(): self._add_feature(feat) - for child in self._children.values(): await child._initialize_features() diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 693410b4e..b36c254de 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -157,6 +157,8 @@ def _handle_control_child(self, params: dict): elif child_method == "set_device_info": info.update(child_params) return {"error_code": 0} + elif child_method == "set_preset_rules": + return self._set_child_preset_rules(info, child_params) elif ( # FIXTURE_MISSING is for service calls not in place when # SMART fixtures started to be generated @@ -205,6 +207,30 @@ def _set_led_info(self, info, params): info["get_led_info"]["led_status"] = params["led_rule"] != "never" info["get_led_info"]["led_rule"] = params["led_rule"] + def _set_preset_rules(self, info, params): + """Set or remove values as per the device behaviour.""" + if "brightness" not in info["get_preset_rules"]: + return {"error_code": SmartErrorCode.PARAMS_ERROR} + info["get_preset_rules"]["brightness"] = params["brightness"] + return {"error_code": 0} + + def _set_child_preset_rules(self, info, params): + """Set or remove values as per the device behaviour.""" + # So far the only child device with light preset (KS240) has the + # data available to read in the device_info. If a child device + # appears that doesn't have this this will need to be extended. + if "preset_state" not in info: + return {"error_code": SmartErrorCode.PARAMS_ERROR} + info["preset_state"] = [{"brightness": b} for b in params["brightness"]] + return {"error_code": 0} + + def _edit_preset_rules(self, info, params): + """Set or remove values as per the device behaviour.""" + if "states" not in info["get_preset_rules"] is None: + return {"error_code": SmartErrorCode.PARAMS_ERROR} + info["get_preset_rules"]["states"][params["index"]] = params["state"] + return {"error_code": 0} + def _send_request(self, request_dict: dict): method = request_dict["method"] params = request_dict["params"] @@ -276,6 +302,10 @@ def _send_request(self, request_dict: dict): elif method == "set_led_info": self._set_led_info(info, params) return {"error_code": 0} + elif method == "set_preset_rules": + return self._set_preset_rules(info, params) + elif method == "edit_preset_rules": + return self._edit_preset_rules(info, params) elif method[:4] == "set_": target_method = f"get_{method[4:]}" info[target_method].update(params) diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 2930db57a..97ae85a34 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest from voluptuous import ( All, @@ -7,7 +9,7 @@ Schema, ) -from kasa import Device, DeviceType, KasaException, LightPreset, Module +from kasa import Device, DeviceType, IotLightPreset, KasaException, Module from kasa.iot import IotBulb, IotDimmer from .conftest import ( @@ -85,7 +87,7 @@ async def test_hsv(dev: Device, turn_on): @color_bulb_iot async def test_set_hsv_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") await dev.set_hsv(10, 10, 100, transition=1000) set_light_state.assert_called_with( @@ -158,7 +160,7 @@ async def test_try_set_colortemp(dev: Device, turn_on): @variable_temp_iot async def test_set_color_temp_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") await dev.set_color_temp(2700, transition=100) set_light_state.assert_called_with({"color_temp": 2700}, transition=100) @@ -224,7 +226,7 @@ async def test_dimmable_brightness(dev: IotBulb, turn_on): @bulb_iot async def test_turn_on_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") await dev.turn_on(transition=1000) set_light_state.assert_called_with({"on_off": 1}, transition=1000) @@ -236,7 +238,7 @@ async def test_turn_on_transition(dev: IotBulb, mocker): @bulb_iot async def test_dimmable_brightness_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") await dev.set_brightness(10, transition=1000) set_light_state.assert_called_with({"brightness": 10}, transition=1000) @@ -297,14 +299,14 @@ async def test_modify_preset(dev: IotBulb, mocker): if not dev.presets: pytest.skip("Some strips do not support presets") - data = { + data: dict[str, int | None] = { "index": 0, "brightness": 10, "hue": 0, "saturation": 0, "color_temp": 0, } - preset = LightPreset(**data) + preset = IotLightPreset(**data) # type: ignore[call-arg, arg-type] assert preset.index == 0 assert preset.brightness == 10 @@ -318,7 +320,7 @@ async def test_modify_preset(dev: IotBulb, mocker): with pytest.raises(KasaException): await dev.save_preset( - LightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) + IotLightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) # type: ignore[call-arg] ) @@ -327,11 +329,11 @@ async def test_modify_preset(dev: IotBulb, mocker): ("preset", "payload"), [ ( - LightPreset(index=0, hue=0, brightness=1, saturation=0), + IotLightPreset(index=0, hue=0, brightness=1, saturation=0), # type: ignore[call-arg] {"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, ), ( - LightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), + IotLightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), # type: ignore[call-arg] {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, ), ], diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index ca34d304f..520303079 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -1,8 +1,9 @@ import pytest from pytest_mock import MockerFixture -from kasa import Device, Module +from kasa import Device, LightState, Module from kasa.tests.device_fixtures import ( + bulb_iot, dimmable_iot, dimmer_iot, lightstrip_iot, @@ -33,6 +34,12 @@ ) dimmable = parametrize_combine([dimmable_smart, dimmer_iot, dimmable_iot]) +light_preset_smart = parametrize( + "has light preset smart", component_filter="preset", protocol_filter={"SMART"} +) + +light_preset = parametrize_combine([light_preset_smart, bulb_iot]) + @led async def test_led_module(dev: Device, mocker: MockerFixture): @@ -130,3 +137,80 @@ async def test_light_brightness(dev: Device): with pytest.raises(ValueError): await light.set_brightness(feature.maximum_value + 10) + + +@light_preset +async def test_light_preset_module(dev: Device, mocker: MockerFixture): + """Test light preset module.""" + preset_mod = dev.modules[Module.LightPreset] + assert preset_mod + light_mod = dev.modules[Module.Light] + assert light_mod + feat = dev.features["light_preset"] + + call = mocker.spy(light_mod, "set_state") + preset_list = preset_mod.preset_list + assert "Not set" in preset_list + assert preset_list.index("Not set") == 0 + assert preset_list == feat.choices + + assert preset_mod.has_save_preset is True + + await light_mod.set_brightness(33) # Value that should not be a preset + assert call.call_count == 0 + await dev.update() + assert preset_mod.preset == "Not set" + assert feat.value == "Not set" + + if len(preset_list) == 1: + return + + second_preset = preset_list[1] + await preset_mod.set_preset(second_preset) + assert call.call_count == 1 + await dev.update() + assert preset_mod.preset == second_preset + assert feat.value == second_preset + + last_preset = preset_list[len(preset_list) - 1] + await preset_mod.set_preset(last_preset) + assert call.call_count == 2 + await dev.update() + assert preset_mod.preset == last_preset + assert feat.value == last_preset + + # Test feature set + await feat.set_value(second_preset) + assert call.call_count == 3 + await dev.update() + assert preset_mod.preset == second_preset + assert feat.value == second_preset + + with pytest.raises(ValueError): + await preset_mod.set_preset("foobar") + assert call.call_count == 3 + + +@light_preset +async def test_light_preset_save(dev: Device, mocker: MockerFixture): + """Test saving a new preset value.""" + preset_mod = dev.modules[Module.LightPreset] + assert preset_mod + preset_list = preset_mod.preset_list + if len(preset_list) == 1: + return + + second_preset = preset_list[1] + if preset_mod.preset_states_list[0].hue is None: + new_preset = LightState(brightness=52) + else: + new_preset = LightState(brightness=52, color_temp=3000, hue=20, saturation=30) + await preset_mod.save_preset(second_preset, new_preset) + await dev.update() + new_preset_state = preset_mod.preset_states_list[0] + assert ( + new_preset_state.brightness == new_preset.brightness + and new_preset_state.hue == new_preset.hue + and new_preset_state.saturation == new_preset.saturation + and new_preset_state.color_temp == new_preset.color_temp + ) diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index d8f28d1bc..354507be6 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -1,5 +1,7 @@ """Tests for all devices.""" +from __future__ import annotations + import importlib import inspect import pkgutil @@ -11,6 +13,7 @@ import kasa from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module from kasa.iot import IotDevice +from kasa.iot.modules import IotLightPreset from kasa.smart import SmartChildDevice, SmartDevice @@ -238,3 +241,28 @@ async def test_deprecated_other_attributes(dev: Device): await _test_attribute(dev, "led", bool(led_module), "Led") await _test_attribute(dev, "set_led", bool(led_module), "Led", True) + + +async def test_deprecated_light_preset_attributes(dev: Device): + preset = dev.modules.get(Module.LightPreset) + + exc: type[AttributeError] | type[KasaException] | None = ( + AttributeError if not preset else None + ) + await _test_attribute(dev, "presets", bool(preset), "LightPreset", will_raise=exc) + + exc = None + # deprecated save_preset not implemented for smart devices as it's unlikely anyone + # has an existing reliance on this for the newer devices. + if not preset or isinstance(dev, SmartDevice): + exc = AttributeError + elif len(preset.preset_states_list) == 0: + exc = KasaException + await _test_attribute( + dev, + "save_preset", + bool(preset), + "LightPreset", + IotLightPreset(index=0, hue=100, brightness=100, saturation=0, color_temp=0), # type: ignore[call-arg] + will_raise=exc, + ) From 5e619af29fb4225c80ab3b47c6d12d9161674852 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 19 May 2024 20:00:57 +0200 Subject: [PATCH 439/892] Prepare 0.7.0.dev0 (#922) First dev release for 0.7.0: add module support for SMART devices, support for introspectable device features and refactoring the library --- CHANGELOG.md | 468 ++++++++++++++++++++++++++++++++++++++++--------- pyproject.toml | 2 +- 2 files changed, 383 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b01db8c09..08166a8d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,180 @@ # Changelog +## [0.7.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev0) (2024-05-19) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2.1...0.7.0.dev0) + +**Breaking changes:** + +- Move SmartBulb into SmartDevice [\#874](https://github.com/python-kasa/python-kasa/pull/874) (@sdb9696) +- Change state\_information to return feature values [\#804](https://github.com/python-kasa/python-kasa/pull/804) (@rytilahti) +- Remove SmartPlug in favor of SmartDevice [\#781](https://github.com/python-kasa/python-kasa/pull/781) (@rytilahti) +- Add generic interface for accessing device features [\#741](https://github.com/python-kasa/python-kasa/pull/741) (@rytilahti) +- Rename and deprecate exception classes [\#739](https://github.com/python-kasa/python-kasa/pull/739) (@sdb9696) + +**Implemented enhancements:** + +- Radiator support \(KE100\) [\#422](https://github.com/python-kasa/python-kasa/issues/422) +- Add post update hook to module and use in smart LightEffect [\#921](https://github.com/python-kasa/python-kasa/pull/921) (@sdb9696) +- Add LightEffect module for smart light strips [\#918](https://github.com/python-kasa/python-kasa/pull/918) (@sdb9696) +- Improve categorization of features [\#904](https://github.com/python-kasa/python-kasa/pull/904) (@rytilahti) +- Create common interfaces for remaining device types [\#895](https://github.com/python-kasa/python-kasa/pull/895) (@sdb9696) +- Make get\_module return typed module [\#892](https://github.com/python-kasa/python-kasa/pull/892) (@sdb9696) +- Add LightEffectModule for dynamic light effects on SMART bulbs [\#887](https://github.com/python-kasa/python-kasa/pull/887) (@sdb9696) +- Implement choice feature type [\#880](https://github.com/python-kasa/python-kasa/pull/880) (@rytilahti) +- Add support for contact sensor \(T110\) [\#877](https://github.com/python-kasa/python-kasa/pull/877) (@rytilahti) +- Add support for waterleak sensor \(T300\) [\#876](https://github.com/python-kasa/python-kasa/pull/876) (@rytilahti) +- Add Fan interface for SMART devices [\#873](https://github.com/python-kasa/python-kasa/pull/873) (@sdb9696) +- Improve temperature controls [\#872](https://github.com/python-kasa/python-kasa/pull/872) (@rytilahti) +- Add precision\_hint to feature [\#871](https://github.com/python-kasa/python-kasa/pull/871) (@rytilahti) +- Be more lax on unknown SMART devices [\#863](https://github.com/python-kasa/python-kasa/pull/863) (@rytilahti) +- Handle paging of partial responses of lists like child\_device\_info [\#862](https://github.com/python-kasa/python-kasa/pull/862) (@sdb9696) +- Better firmware module support for devices not connected to the internet [\#854](https://github.com/python-kasa/python-kasa/pull/854) (@sdb9696) +- Re-query missing responses after multi request errors [\#850](https://github.com/python-kasa/python-kasa/pull/850) (@sdb9696) +- Implement action feature [\#849](https://github.com/python-kasa/python-kasa/pull/849) (@rytilahti) +- Add temperature control module for smart [\#848](https://github.com/python-kasa/python-kasa/pull/848) (@rytilahti) +- Add support for KH100 hub [\#847](https://github.com/python-kasa/python-kasa/pull/847) (@Adriandorr) +- Implement feature categories [\#846](https://github.com/python-kasa/python-kasa/pull/846) (@rytilahti) +- Expose IOT emeter info as features [\#844](https://github.com/python-kasa/python-kasa/pull/844) (@rytilahti) +- Add support for feature units [\#843](https://github.com/python-kasa/python-kasa/pull/843) (@rytilahti) +- Add ColorModule for smart devices [\#840](https://github.com/python-kasa/python-kasa/pull/840) (@sdb9696) +- Support for new ks240 fan/light wall switch [\#839](https://github.com/python-kasa/python-kasa/pull/839) (@sdb9696) +- Add colortemp feature for iot devices [\#827](https://github.com/python-kasa/python-kasa/pull/827) (@rytilahti) +- Add support for firmware module v1 [\#821](https://github.com/python-kasa/python-kasa/pull/821) (@sdb9696) +- Add colortemp module [\#814](https://github.com/python-kasa/python-kasa/pull/814) (@rytilahti) +- Revise device initialization and subsequent updates [\#807](https://github.com/python-kasa/python-kasa/pull/807) (@rytilahti) +- Add brightness module [\#806](https://github.com/python-kasa/python-kasa/pull/806) (@rytilahti) +- Support multiple child requests [\#795](https://github.com/python-kasa/python-kasa/pull/795) (@sdb9696) +- Support for on\_off\_gradually v2+ [\#793](https://github.com/python-kasa/python-kasa/pull/793) (@rytilahti) +- Improve smartdevice update module [\#791](https://github.com/python-kasa/python-kasa/pull/791) (@rytilahti) +- Add --child option to feature command [\#789](https://github.com/python-kasa/python-kasa/pull/789) (@rytilahti) +- Add temperature\_unit feature to t315 [\#788](https://github.com/python-kasa/python-kasa/pull/788) (@rytilahti) +- Add feature for ambient light sensor [\#787](https://github.com/python-kasa/python-kasa/pull/787) (@shifty35) +- Add initial support for H100 and T315 [\#776](https://github.com/python-kasa/python-kasa/pull/776) (@rytilahti) +- Generalize smartdevice child support [\#775](https://github.com/python-kasa/python-kasa/pull/775) (@rytilahti) +- Raise CLI errors in debug mode [\#771](https://github.com/python-kasa/python-kasa/pull/771) (@sdb9696) +- Add cloud module for smartdevice [\#767](https://github.com/python-kasa/python-kasa/pull/767) (@rytilahti) +- Add firmware module for smartdevice [\#766](https://github.com/python-kasa/python-kasa/pull/766) (@rytilahti) +- Add fan module [\#764](https://github.com/python-kasa/python-kasa/pull/764) (@rytilahti) +- Add smartdevice module for led controls [\#761](https://github.com/python-kasa/python-kasa/pull/761) (@rytilahti) +- Auto auto-off module for smartdevice [\#760](https://github.com/python-kasa/python-kasa/pull/760) (@rytilahti) +- Add smartdevice module for smooth transitions [\#759](https://github.com/python-kasa/python-kasa/pull/759) (@rytilahti) +- Initial implementation for modularized smartdevice [\#757](https://github.com/python-kasa/python-kasa/pull/757) (@rytilahti) +- Let caller handle SMART errors on multi-requests [\#754](https://github.com/python-kasa/python-kasa/pull/754) (@sdb9696) +- Add 'shell' command to cli [\#738](https://github.com/python-kasa/python-kasa/pull/738) (@rytilahti) + +**Fixed bugs:** + +- Fix --help on subcommands [\#885](https://github.com/python-kasa/python-kasa/issues/885) +- "Unclosed client session" Trying to set brightness on Tapo Bulb [\#828](https://github.com/python-kasa/python-kasa/issues/828) +- TAPO P100 \(hw 1.0.0, sw 1.1.3\) EU plug with 0.6.2.1 Kasa results JSON\_DECODE\_FAIL\_ERROR [\#819](https://github.com/python-kasa/python-kasa/issues/819) +- Cannot add Tapo Plug P110 to Home Assistant 2024.2.3 - Error in debug mode [\#797](https://github.com/python-kasa/python-kasa/issues/797) +- KS240 gets discovered but will not authenticate [\#749](https://github.com/python-kasa/python-kasa/issues/749) +- Individual errors cause failing the whole query [\#616](https://github.com/python-kasa/python-kasa/issues/616) +- Add 'battery\_percentage' only when it's available [\#906](https://github.com/python-kasa/python-kasa/pull/906) (@rytilahti) +- Add missing alarm volume 'normal' [\#899](https://github.com/python-kasa/python-kasa/pull/899) (@rytilahti) +- Use Path.save for saving the fixtures [\#894](https://github.com/python-kasa/python-kasa/pull/894) (@rytilahti) +- Fix --help on subcommands [\#886](https://github.com/python-kasa/python-kasa/pull/886) (@rytilahti) +- Improve feature setter robustness [\#870](https://github.com/python-kasa/python-kasa/pull/870) (@rytilahti) +- smartbulb: Limit brightness range to 1-100 [\#829](https://github.com/python-kasa/python-kasa/pull/829) (@rytilahti) +- Fix energy module calling get\_current\_power [\#798](https://github.com/python-kasa/python-kasa/pull/798) (@sdb9696) +- Fix auto update switch [\#786](https://github.com/python-kasa/python-kasa/pull/786) (@rytilahti) +- Retry query on 403 after successful handshake [\#785](https://github.com/python-kasa/python-kasa/pull/785) (@sdb9696) +- Ensure connections are closed when cli is finished [\#752](https://github.com/python-kasa/python-kasa/pull/752) (@sdb9696) +- Fix for P100 on fw 1.1.3 login\_version none [\#751](https://github.com/python-kasa/python-kasa/pull/751) (@sdb9696) +- Pass timeout parameters to discover\_single [\#744](https://github.com/python-kasa/python-kasa/pull/744) (@sdb9696) +- Reduce AuthenticationExceptions raising from transports [\#740](https://github.com/python-kasa/python-kasa/pull/740) (@sdb9696) +- Do not crash cli on missing discovery info [\#735](https://github.com/python-kasa/python-kasa/pull/735) (@rytilahti) +- Fix port-override for aes&klap transports [\#734](https://github.com/python-kasa/python-kasa/pull/734) (@rytilahti) + +**Documentation updates:** + +- Add tutorial doctest module and enable top level await [\#919](https://github.com/python-kasa/python-kasa/pull/919) (@sdb9696) +- Add warning about tapo watchdog [\#902](https://github.com/python-kasa/python-kasa/pull/902) (@rytilahti) +- Move contribution instructions into docs [\#901](https://github.com/python-kasa/python-kasa/pull/901) (@rytilahti) +- Add rust tapo link to README [\#857](https://github.com/python-kasa/python-kasa/pull/857) (@rytilahti) +- Enable shell extra for installing ptpython and rich [\#782](https://github.com/python-kasa/python-kasa/pull/782) (@sdb9696) +- Add WallSwitch device type and autogenerate supported devices docs [\#758](https://github.com/python-kasa/python-kasa/pull/758) (@sdb9696) + +**Closed issues:** + +- Support for T300 and T110 [\#875](https://github.com/python-kasa/python-kasa/issues/875) +- Allow exposing extra feature metadata [\#842](https://github.com/python-kasa/python-kasa/issues/842) +- Handle modules supported only by children [\#825](https://github.com/python-kasa/python-kasa/issues/825) +- Handle child-embedded module data [\#824](https://github.com/python-kasa/python-kasa/issues/824) +- TP-Kasa Ks240 smart Switch DOES NOT WORK [\#823](https://github.com/python-kasa/python-kasa/issues/823) +- child device component\_nego and module queries for dump\_devinfo [\#813](https://github.com/python-kasa/python-kasa/issues/813) +- Klap protocol needs to retry after 403 error [\#784](https://github.com/python-kasa/python-kasa/issues/784) +- Add units to features and convert emeter to use features [\#772](https://github.com/python-kasa/python-kasa/issues/772) +- \_\_init\_\_\(\) missing 1 required positional argument: 'backend' [\#770](https://github.com/python-kasa/python-kasa/issues/770) +- Be more lax on unknown SMART\* devices [\#768](https://github.com/python-kasa/python-kasa/issues/768) +- Combine smart{plug,light} into smartdevice [\#747](https://github.com/python-kasa/python-kasa/issues/747) +- TP-Link P100 Plug support [\#742](https://github.com/python-kasa/python-kasa/issues/742) +- Clean up newfakes [\#723](https://github.com/python-kasa/python-kasa/issues/723) +- Discovery does not list all discovered\_devices if it times out before it can print them. [\#672](https://github.com/python-kasa/python-kasa/issues/672) +- Modularize tapodevice [\#651](https://github.com/python-kasa/python-kasa/issues/651) +- Add retry logic to legacy protocol for connection and OSErrors. [\#648](https://github.com/python-kasa/python-kasa/issues/648) +- Add timestamp to default logger and remove from log.debug messages [\#647](https://github.com/python-kasa/python-kasa/issues/647) +- Need to create common interfaces for legacy and new devices [\#613](https://github.com/python-kasa/python-kasa/issues/613) +- Kasa discovery crashes on Windows 10 with Python 3.11.2 [\#449](https://github.com/python-kasa/python-kasa/issues/449) + +**Merged pull requests:** + +- Fix potential infinite loop if incomplete lists returned [\#920](https://github.com/python-kasa/python-kasa/pull/920) (@sdb9696) +- Deprecate device level light, effect and led attributes [\#916](https://github.com/python-kasa/python-kasa/pull/916) (@sdb9696) +- Update cli to use common modules and remove iot specific cli testing [\#913](https://github.com/python-kasa/python-kasa/pull/913) (@sdb9696) +- Deprecate is\_something attributes [\#912](https://github.com/python-kasa/python-kasa/pull/912) (@sdb9696) +- Make Light and Fan a common module interface [\#911](https://github.com/python-kasa/python-kasa/pull/911) (@sdb9696) +- Rename bulb interface to light and move fan and light interface to interfaces [\#910](https://github.com/python-kasa/python-kasa/pull/910) (@sdb9696) +- Make module names consistent and remove redundant module casting [\#909](https://github.com/python-kasa/python-kasa/pull/909) (@sdb9696) +- Add light presets common module to devices. [\#907](https://github.com/python-kasa/python-kasa/pull/907) (@sdb9696) +- Add H100 1.5.10 and KE100 2.4.0 fixtures [\#905](https://github.com/python-kasa/python-kasa/pull/905) (@rytilahti) +- Add child devices from hubs to generated list of supported devices [\#898](https://github.com/python-kasa/python-kasa/pull/898) (@sdb9696) +- Add fixture for waterleak sensor T300 [\#897](https://github.com/python-kasa/python-kasa/pull/897) (@rytilahti) +- Update interfaces so they all inherit from Device [\#893](https://github.com/python-kasa/python-kasa/pull/893) (@sdb9696) +- Fix wifi scan re-querying error [\#891](https://github.com/python-kasa/python-kasa/pull/891) (@sdb9696) +- Update ks240 fixture with child device query info [\#890](https://github.com/python-kasa/python-kasa/pull/890) (@sdb9696) +- Fix smartprotocol response list handler to handle null reponses [\#884](https://github.com/python-kasa/python-kasa/pull/884) (@sdb9696) +- Use pydantic.v1 namespace on all pydantic versions [\#883](https://github.com/python-kasa/python-kasa/pull/883) (@rytilahti) +- Update dump\_devinfo to print original exception stack on errors. [\#882](https://github.com/python-kasa/python-kasa/pull/882) (@sdb9696) +- Put modules back on children for wall switches [\#881](https://github.com/python-kasa/python-kasa/pull/881) (@sdb9696) +- Fix pypy39 CI cache on macos [\#868](https://github.com/python-kasa/python-kasa/pull/868) (@sdb9696) +- Do not try coverage upload for pypy [\#867](https://github.com/python-kasa/python-kasa/pull/867) (@sdb9696) +- Add runner.arch to cache-key in CI [\#866](https://github.com/python-kasa/python-kasa/pull/866) (@sdb9696) +- Fix broken CI due to missing python version on macos-latest [\#864](https://github.com/python-kasa/python-kasa/pull/864) (@sdb9696) +- Fix incorrect state updates in FakeTestProtocols [\#861](https://github.com/python-kasa/python-kasa/pull/861) (@sdb9696) +- Embed FeatureType inside Feature [\#860](https://github.com/python-kasa/python-kasa/pull/860) (@rytilahti) +- Include component\_nego with child fixtures [\#858](https://github.com/python-kasa/python-kasa/pull/858) (@sdb9696) +- Use brightness module for smartbulb [\#853](https://github.com/python-kasa/python-kasa/pull/853) (@rytilahti) +- Ignore system environment variables for tests [\#851](https://github.com/python-kasa/python-kasa/pull/851) (@rytilahti) +- Remove mock fixtures [\#845](https://github.com/python-kasa/python-kasa/pull/845) (@rytilahti) +- Enable and convert to future annotations [\#838](https://github.com/python-kasa/python-kasa/pull/838) (@sdb9696) +- Update poetry locks and pre-commit hooks [\#837](https://github.com/python-kasa/python-kasa/pull/837) (@sdb9696) +- Cache pipx in CI and add custom setup action [\#835](https://github.com/python-kasa/python-kasa/pull/835) (@sdb9696) +- Fix non python 3.8 compliant test [\#832](https://github.com/python-kasa/python-kasa/pull/832) (@sdb9696) +- Fix CI issue with python version used by pipx to install poetry [\#831](https://github.com/python-kasa/python-kasa/pull/831) (@sdb9696) +- Refactor split smartdevice tests to test\_{iot,smart}device [\#822](https://github.com/python-kasa/python-kasa/pull/822) (@rytilahti) +- Add P100 fw 1.4.0 fixture [\#820](https://github.com/python-kasa/python-kasa/pull/820) (@sdb9696) +- Add pre-commit caching and fix poetry extras cache [\#817](https://github.com/python-kasa/python-kasa/pull/817) (@sdb9696) +- Fix slow aestransport and cli tests [\#816](https://github.com/python-kasa/python-kasa/pull/816) (@sdb9696) +- Do not run coverage on pypy and cache poetry envs [\#812](https://github.com/python-kasa/python-kasa/pull/812) (@sdb9696) +- Update test framework for dynamic parametrization [\#810](https://github.com/python-kasa/python-kasa/pull/810) (@sdb9696) +- Put child fixtures in subfolder [\#809](https://github.com/python-kasa/python-kasa/pull/809) (@sdb9696) +- Add iot brightness feature [\#808](https://github.com/python-kasa/python-kasa/pull/808) (@sdb9696) +- Simplify device \_\_repr\_\_ [\#805](https://github.com/python-kasa/python-kasa/pull/805) (@rytilahti) +- Add T315 fixture, tests for humidity&temperature modules [\#802](https://github.com/python-kasa/python-kasa/pull/802) (@rytilahti) +- Add fixture for P110 sw 1.0.7 [\#801](https://github.com/python-kasa/python-kasa/pull/801) (@rytilahti) +- Do not fail fast on pypy CI jobs [\#799](https://github.com/python-kasa/python-kasa/pull/799) (@sdb9696) +- Update dump\_devinfo to collect child device info [\#796](https://github.com/python-kasa/python-kasa/pull/796) (@sdb9696) +- Refactor test framework [\#794](https://github.com/python-kasa/python-kasa/pull/794) (@sdb9696) +- Add updated l530 fixture 1.1.6 [\#792](https://github.com/python-kasa/python-kasa/pull/792) (@rytilahti) +- Add missing firmware module import [\#774](https://github.com/python-kasa/python-kasa/pull/774) (@rytilahti) +- Fix dump\_devinfo scrubbing for ks240 [\#765](https://github.com/python-kasa/python-kasa/pull/765) (@rytilahti) +- Fix devtools for P100 and add fixture [\#753](https://github.com/python-kasa/python-kasa/pull/753) (@sdb9696) +- Add H100 fixtures [\#737](https://github.com/python-kasa/python-kasa/pull/737) (@rytilahti) +- Refactor devices into subpackages and deprecate old names [\#716](https://github.com/python-kasa/python-kasa/pull/716) (@sdb9696) +- Fix discovery cli to print devices not printed during discovery timeout [\#670](https://github.com/python-kasa/python-kasa/pull/670) (@sdb9696) + ## [0.6.2.1](https://github.com/python-kasa/python-kasa/tree/0.6.2.1) (2024-02-02) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2...0.6.2.1) @@ -10,6 +185,7 @@ **Merged pull requests:** +- Prepare 0.6.2.1 [\#736](https://github.com/python-kasa/python-kasa/pull/736) (@rytilahti) - Retain last two chars for children device\_id [\#733](https://github.com/python-kasa/python-kasa/pull/733) (@rytilahti) - Add TP15 fixture [\#730](https://github.com/python-kasa/python-kasa/pull/730) (@bdraco) - Add TP25 fixtures [\#729](https://github.com/python-kasa/python-kasa/pull/729) (@bdraco) @@ -143,7 +319,7 @@ A patch release to improve the protocol handling. ## [0.6.0](https://github.com/python-kasa/python-kasa/tree/0.6.0) (2024-01-19) -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.4...0.6.0) +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.dev2...0.6.0) This major brings major changes to the library by adding support for devices that require authentication for communications, all of this being possible thanks to the great work by @sdb9696! @@ -158,6 +334,90 @@ If your device that is not currently listed as supported is working, please cons Special thanks goes to @SimonWilkinson who created the initial PR for the new communication protocol! +**Implemented enhancements:** + +- Allow serializing and passing of credentials\_hashes in DeviceConfig [\#607](https://github.com/python-kasa/python-kasa/pull/607) (@sdb9696) +- Implement wifi interface for tapodevice [\#606](https://github.com/python-kasa/python-kasa/pull/606) (@rytilahti) +- Add support for KS205 and KS225 wall switches [\#594](https://github.com/python-kasa/python-kasa/pull/594) (@gimpy88) +- Add support for tapo bulbs [\#558](https://github.com/python-kasa/python-kasa/pull/558) (@rytilahti) +- Add klap protocol [\#509](https://github.com/python-kasa/python-kasa/pull/509) (@sdb9696) + +**Fixed bugs:** + +- Fix connection indeterminate state on cancellation [\#636](https://github.com/python-kasa/python-kasa/pull/636) (@bdraco) + +**Documentation updates:** + +- Update the documentation for 0.6 release [\#600](https://github.com/python-kasa/python-kasa/issues/600) + +**Closed issues:** + +- KS225 support [\#631](https://github.com/python-kasa/python-kasa/issues/631) +- Convert to use aiohttp instead of httpx [\#635](https://github.com/python-kasa/python-kasa/issues/635) +- Need to do error code checking for new protocols [\#612](https://github.com/python-kasa/python-kasa/issues/612) +- Support of last firmware update version 1.3.0 [\#611](https://github.com/python-kasa/python-kasa/issues/611) +- Improve test coverage for tapodevice class [\#608](https://github.com/python-kasa/python-kasa/issues/608) + +**Merged pull requests:** + +- Release 0.6.0 [\#653](https://github.com/python-kasa/python-kasa/pull/653) (@rytilahti) +- Remove time logging in debug message [\#645](https://github.com/python-kasa/python-kasa/pull/645) (@sdb9696) +- Migrate http client to use aiohttp instead of httpx [\#643](https://github.com/python-kasa/python-kasa/pull/643) (@sdb9696) +- Encapsulate http client dependency [\#642](https://github.com/python-kasa/python-kasa/pull/642) (@sdb9696) +- Fix broken docs due to applehelp dependency [\#641](https://github.com/python-kasa/python-kasa/pull/641) (@sdb9696) +- Raise SmartDeviceException on invalid config dicts [\#640](https://github.com/python-kasa/python-kasa/pull/640) (@sdb9696) +- Add fixture for L920 [\#638](https://github.com/python-kasa/python-kasa/pull/638) (@bdraco) +- Add known smart requests to dump\_devinfo [\#597](https://github.com/python-kasa/python-kasa/pull/597) (@sdb9696) + +## [0.6.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev2) (2024-01-11) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.dev1...0.6.0.dev2) + +**Documentation updates:** + +- Update docs for newer devices and DeviceConfig [\#614](https://github.com/python-kasa/python-kasa/pull/614) (@sdb9696) + +**Merged pull requests:** + +- Release 0.6.0.dev2 [\#633](https://github.com/python-kasa/python-kasa/pull/633) (@rytilahti) +- Raise TimeoutException on discover\_single timeout [\#632](https://github.com/python-kasa/python-kasa/pull/632) (@sdb9696) +- Add L900-10 fixture and it's additional component requests [\#629](https://github.com/python-kasa/python-kasa/pull/629) (@sdb9696) +- Avoid recreating struct each request in legacy protocol [\#628](https://github.com/python-kasa/python-kasa/pull/628) (@bdraco) +- Return alias as None for new discovery devices before update [\#627](https://github.com/python-kasa/python-kasa/pull/627) (@sdb9696) +- Update config to\_dict to exclude credentials if the hash is empty string [\#626](https://github.com/python-kasa/python-kasa/pull/626) (@sdb9696) +- Improve test coverage [\#625](https://github.com/python-kasa/python-kasa/pull/625) (@sdb9696) + +## [0.6.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev1) (2024-01-05) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.dev0...0.6.0.dev1) + +**Implemented enhancements:** + +- Get child emeters with CLI [\#623](https://github.com/python-kasa/python-kasa/pull/623) (@Obbay2) +- Avoid linear search for emeter realtime and emeter\_today [\#622](https://github.com/python-kasa/python-kasa/pull/622) (@bdraco) +- Add update-credentials command [\#620](https://github.com/python-kasa/python-kasa/pull/620) (@rytilahti) + +**Fixed bugs:** + +- Check the ct range for color temp support [\#619](https://github.com/python-kasa/python-kasa/pull/619) (@rytilahti) +- Fix cli discover bug with None username/password [\#615](https://github.com/python-kasa/python-kasa/pull/615) (@sdb9696) + +**Closed issues:** + +- Implement energy and usage for individual plugs in HS300 [\#462](https://github.com/python-kasa/python-kasa/issues/462) + +**Merged pull requests:** + +- Release 0.6.0.dev1 [\#624](https://github.com/python-kasa/python-kasa/pull/624) (@rytilahti) +- Add P125M and update EP25 fixtures [\#621](https://github.com/python-kasa/python-kasa/pull/621) (@bdraco) +- Use consistent envvars for dump\_devinfo credentials [\#618](https://github.com/python-kasa/python-kasa/pull/618) (@rytilahti) +- Mark L900-5 as supported [\#617](https://github.com/python-kasa/python-kasa/pull/617) (@rytilahti) +- Ship CHANGELOG only in sdist [\#610](https://github.com/python-kasa/python-kasa/pull/610) (@rytilahti) + +## [0.6.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev0) (2024-01-03) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.4...0.6.0.dev0) + **Breaking changes:** - Add DeviceConfig to allow specifying configuration parameters [\#569](https://github.com/python-kasa/python-kasa/pull/569) (@sdb9696) @@ -168,9 +428,6 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co - Support for KS225\(US\) Light Dimmer and KS205\(US\) Light Switch [\#589](https://github.com/python-kasa/python-kasa/issues/589) - Set timeout using command line parameters [\#310](https://github.com/python-kasa/python-kasa/issues/310) - Implement the new protocol \(HTTP over 80/tcp, 20002/udp for discovery\) [\#115](https://github.com/python-kasa/python-kasa/issues/115) -- Get child emeters with CLI [\#623](https://github.com/python-kasa/python-kasa/pull/623) (@Obbay2) -- Avoid linear search for emeter realtime and emeter\_today [\#622](https://github.com/python-kasa/python-kasa/pull/622) (@bdraco) -- Add update-credentials command [\#620](https://github.com/python-kasa/python-kasa/pull/620) (@rytilahti) - Enable multiple requests in smartprotocol [\#584](https://github.com/python-kasa/python-kasa/pull/584) (@sdb9696) - Improve CLI Discovery output [\#583](https://github.com/python-kasa/python-kasa/pull/583) (@sdb9696) - Improve smartprotocol error handling and retries [\#578](https://github.com/python-kasa/python-kasa/pull/578) (@sdb9696) @@ -186,31 +443,20 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co - Add support for the protocol used by TAPO devices and some newer KASA devices. [\#552](https://github.com/python-kasa/python-kasa/pull/552) (@sdb9696) - Re-add protocol\_class parameter to connect [\#551](https://github.com/python-kasa/python-kasa/pull/551) (@sdb9696) - Update discover single to handle hostnames [\#539](https://github.com/python-kasa/python-kasa/pull/539) (@sdb9696) -- Allow serializing and passing of credentials\_hashes in DeviceConfig [\#607](https://github.com/python-kasa/python-kasa/pull/607) (@sdb9696) -- Implement wifi interface for tapodevice [\#606](https://github.com/python-kasa/python-kasa/pull/606) (@rytilahti) -- Add support for KS205 and KS225 wall switches [\#594](https://github.com/python-kasa/python-kasa/pull/594) (@gimpy88) -- Add support for tapo bulbs [\#558](https://github.com/python-kasa/python-kasa/pull/558) (@rytilahti) -- Add klap protocol [\#509](https://github.com/python-kasa/python-kasa/pull/509) (@sdb9696) **Fixed bugs:** - dump\_devinfo crashes when credentials are not given [\#591](https://github.com/python-kasa/python-kasa/issues/591) -- Fix connection indeterminate state on cancellation [\#636](https://github.com/python-kasa/python-kasa/pull/636) (@bdraco) -- Check the ct range for color temp support [\#619](https://github.com/python-kasa/python-kasa/pull/619) (@rytilahti) -- Fix cli discover bug with None username/password [\#615](https://github.com/python-kasa/python-kasa/pull/615) (@sdb9696) - Fix hsv setting for tapobulb [\#573](https://github.com/python-kasa/python-kasa/pull/573) (@rytilahti) - Fix transport retries after close [\#568](https://github.com/python-kasa/python-kasa/pull/568) (@sdb9696) **Documentation updates:** -- Update the documentation for 0.6 release [\#600](https://github.com/python-kasa/python-kasa/issues/600) -- Update docs for newer devices and DeviceConfig [\#614](https://github.com/python-kasa/python-kasa/pull/614) (@sdb9696) - Update readme with clearer instructions, tapo support [\#571](https://github.com/python-kasa/python-kasa/pull/571) (@rytilahti) - Add some more external links to README [\#541](https://github.com/python-kasa/python-kasa/pull/541) (@rytilahti) **Closed issues:** -- KS225 support [\#631](https://github.com/python-kasa/python-kasa/issues/631) - Discover returns dictionary with no 'alias' property [\#592](https://github.com/python-kasa/python-kasa/issues/592) - Sending with the legacy protocol is needlessly delayed [\#553](https://github.com/python-kasa/python-kasa/issues/553) - Issues adding a KP405 device [\#549](https://github.com/python-kasa/python-kasa/issues/549) @@ -219,34 +465,10 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co - Unable to connect to host on different subnet with 0.5.4 [\#545](https://github.com/python-kasa/python-kasa/issues/545) - Discovery/Connect broken when upgrading from 0.5.3 -\> 0.5.4 [\#543](https://github.com/python-kasa/python-kasa/issues/543) - PydanticUserError, If you use `@root_validator` with pre=False \(the default\) you MUST specify `skip_on_failure=True` [\#516](https://github.com/python-kasa/python-kasa/issues/516) -- Implement energy and usage for individual plugs in HS300 [\#462](https://github.com/python-kasa/python-kasa/issues/462) - KP 125M / support for matter devices [\#450](https://github.com/python-kasa/python-kasa/issues/450) -- Convert to use aiohttp instead of httpx [\#635](https://github.com/python-kasa/python-kasa/issues/635) -- Need to do error code checking for new protocols [\#612](https://github.com/python-kasa/python-kasa/issues/612) -- Support of last firmware update version 1.3.0 [\#611](https://github.com/python-kasa/python-kasa/issues/611) -- Improve test coverage for tapodevice class [\#608](https://github.com/python-kasa/python-kasa/issues/608) **Merged pull requests:** -- Release 0.6.0 [\#653](https://github.com/python-kasa/python-kasa/pull/653) (@rytilahti) -- Remove time logging in debug message [\#645](https://github.com/python-kasa/python-kasa/pull/645) (@sdb9696) -- Migrate http client to use aiohttp instead of httpx [\#643](https://github.com/python-kasa/python-kasa/pull/643) (@sdb9696) -- Encapsulate http client dependency [\#642](https://github.com/python-kasa/python-kasa/pull/642) (@sdb9696) -- Fix broken docs due to applehelp dependency [\#641](https://github.com/python-kasa/python-kasa/pull/641) (@sdb9696) -- Raise SmartDeviceException on invalid config dicts [\#640](https://github.com/python-kasa/python-kasa/pull/640) (@sdb9696) -- Add fixture for L920 [\#638](https://github.com/python-kasa/python-kasa/pull/638) (@bdraco) -- Release 0.6.0.dev2 [\#633](https://github.com/python-kasa/python-kasa/pull/633) (@rytilahti) -- Raise TimeoutException on discover\_single timeout [\#632](https://github.com/python-kasa/python-kasa/pull/632) (@sdb9696) -- Add L900-10 fixture and it's additional component requests [\#629](https://github.com/python-kasa/python-kasa/pull/629) (@sdb9696) -- Avoid recreating struct each request in legacy protocol [\#628](https://github.com/python-kasa/python-kasa/pull/628) (@bdraco) -- Return alias as None for new discovery devices before update [\#627](https://github.com/python-kasa/python-kasa/pull/627) (@sdb9696) -- Update config to\_dict to exclude credentials if the hash is empty string [\#626](https://github.com/python-kasa/python-kasa/pull/626) (@sdb9696) -- Improve test coverage [\#625](https://github.com/python-kasa/python-kasa/pull/625) (@sdb9696) -- Release 0.6.0.dev1 [\#624](https://github.com/python-kasa/python-kasa/pull/624) (@rytilahti) -- Add P125M and update EP25 fixtures [\#621](https://github.com/python-kasa/python-kasa/pull/621) (@bdraco) -- Use consistent envvars for dump\_devinfo credentials [\#618](https://github.com/python-kasa/python-kasa/pull/618) (@rytilahti) -- Mark L900-5 as supported [\#617](https://github.com/python-kasa/python-kasa/pull/617) (@rytilahti) -- Ship CHANGELOG only in sdist [\#610](https://github.com/python-kasa/python-kasa/pull/610) (@rytilahti) - Release 0.6.0.dev0 [\#609](https://github.com/python-kasa/python-kasa/pull/609) (@rytilahti) - Cleanup credentials handling [\#605](https://github.com/python-kasa/python-kasa/pull/605) (@rytilahti) - Update P110\(EU\) fixture [\#604](https://github.com/python-kasa/python-kasa/pull/604) (@rytilahti) @@ -266,7 +488,6 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co - Re-add regional suffix to TAPO/SMART fixtures [\#566](https://github.com/python-kasa/python-kasa/pull/566) (@sdb9696) - Add P110 fixture [\#562](https://github.com/python-kasa/python-kasa/pull/562) (@rytilahti) - Do not do update\(\) in discover\_single [\#542](https://github.com/python-kasa/python-kasa/pull/542) (@sdb9696) -- Add known smart requests to dump\_devinfo [\#597](https://github.com/python-kasa/python-kasa/pull/597) (@sdb9696) ## [0.5.4](https://github.com/python-kasa/python-kasa/tree/0.5.4) (2023-10-29) @@ -667,15 +888,43 @@ Pull requests improving the functionality of modules as well as adding better in ## [0.4.0](https://github.com/python-kasa/python-kasa/tree/0.4.0) (2021-09-27) -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.pre0...0.4.0) +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev5...0.4.0) **Implemented enhancements:** -- KL430 support [\#67](https://github.com/python-kasa/python-kasa/issues/67) -- Improve retry logic for discovery, messaging \(was: Handle empty responses\) [\#38](https://github.com/python-kasa/python-kasa/issues/38) - Fix lock being unexpectedly reset on close [\#218](https://github.com/python-kasa/python-kasa/pull/218) (@bdraco) - Avoid calling pformat unless debug logging is enabled [\#217](https://github.com/python-kasa/python-kasa/pull/217) (@bdraco) + +**Closed issues:** + +- Debug logging in protocol.py is the majority of the execution time [\#216](https://github.com/python-kasa/python-kasa/issues/216) + +**Merged pull requests:** + +- Release 0.4.0 [\#221](https://github.com/python-kasa/python-kasa/pull/221) (@rytilahti) +- Add github workflow for pypi publishing [\#220](https://github.com/python-kasa/python-kasa/pull/220) (@rytilahti) +- Add host information to protocol debug logs [\#219](https://github.com/python-kasa/python-kasa/pull/219) (@rytilahti) + +## [0.4.0.dev5](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev5) (2021-09-24) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev4...0.4.0.dev5) + +**Implemented enhancements:** + - Keep connection open and lock to prevent duplicate requests [\#213](https://github.com/python-kasa/python-kasa/pull/213) (@bdraco) + +**Merged pull requests:** + +- Release 0.4.0.dev5 [\#215](https://github.com/python-kasa/python-kasa/pull/215) (@rytilahti) +- Add KL130 fixture, initial lightstrip tests [\#214](https://github.com/python-kasa/python-kasa/pull/214) (@rytilahti) +- Cleanup discovery & add tests [\#212](https://github.com/python-kasa/python-kasa/pull/212) (@rytilahti) + +## [0.4.0.dev4](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev4) (2021-09-23) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev3...0.4.0.dev4) + +**Implemented enhancements:** + - Improve emeterstatus API, move into own module [\#205](https://github.com/python-kasa/python-kasa/pull/205) (@rytilahti) - Avoid temp array during encrypt and decrypt [\#204](https://github.com/python-kasa/python-kasa/pull/204) (@bdraco) - Add emeter support for strip sockets [\#203](https://github.com/python-kasa/python-kasa/pull/203) (@bdraco) @@ -684,39 +933,59 @@ Pull requests improving the functionality of modules as well as adding better in - Improve bulb support \(alias, time settings\) [\#198](https://github.com/python-kasa/python-kasa/pull/198) (@rytilahti) - Improve testing harness to allow tests on real devices [\#197](https://github.com/python-kasa/python-kasa/pull/197) (@rytilahti) - cli: add human-friendly printout when calling temperature on non-supported devices [\#196](https://github.com/python-kasa/python-kasa/pull/196) (@JaydenRA) -- 'Interface' parameter added to discovery process [\#79](https://github.com/python-kasa/python-kasa/pull/79) (@dmitryelj) -- Add support for lightstrips \(KL430\) [\#74](https://github.com/python-kasa/python-kasa/pull/74) (@rytilahti) **Fixed bugs:** - KL430: Throw error for Device specific information [\#189](https://github.com/python-kasa/python-kasa/issues/189) -- `Unable to find a value for 'current'` error when attempting to query KL125 bulb emeter [\#142](https://github.com/python-kasa/python-kasa/issues/142) -- `Unknown color temperature range` error when attempting to query KL125 bulb state [\#141](https://github.com/python-kasa/python-kasa/issues/141) - HS300 Children plugs have emeter [\#64](https://github.com/python-kasa/python-kasa/issues/64) - dump\_devinfo: handle latitude/longitude keys properly [\#175](https://github.com/python-kasa/python-kasa/pull/175) (@rytilahti) -- Simplify discovery query, refactor dump-devinfo [\#147](https://github.com/python-kasa/python-kasa/pull/147) (@rytilahti) -- Return None instead of raising an exception on missing, valid emeter keys [\#146](https://github.com/python-kasa/python-kasa/pull/146) (@rytilahti) -- Simplify device class detection for discovery, fix hardcoded timeout [\#112](https://github.com/python-kasa/python-kasa/pull/112) (@rytilahti) -- Update cli.py to addresss crash on year/month calls and improve output formatting [\#103](https://github.com/python-kasa/python-kasa/pull/103) (@BuongiornoTexas) **Documentation updates:** - Discover does not support specifying network interface [\#167](https://github.com/python-kasa/python-kasa/issues/167) -- Add ability to control individual sockets on KP400 [\#121](https://github.com/python-kasa/python-kasa/issues/121) -- Improve poetry usage documentation [\#60](https://github.com/python-kasa/python-kasa/issues/60) -- Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) (@rytilahti) **Closed issues:** -- Debug logging in protocol.py is the majority of the execution time [\#216](https://github.com/python-kasa/python-kasa/issues/216) - Feature Request - Toggle Command [\#188](https://github.com/python-kasa/python-kasa/issues/188) - Is It Compatible With HS105? [\#186](https://github.com/python-kasa/python-kasa/issues/186) - Cannot use some functions with KP303 [\#181](https://github.com/python-kasa/python-kasa/issues/181) - Help needed - awaiting game [\#179](https://github.com/python-kasa/python-kasa/issues/179) - Version inconsistency between CLI and pip [\#177](https://github.com/python-kasa/python-kasa/issues/177) - Release 0.4.0.dev3? [\#169](https://github.com/python-kasa/python-kasa/issues/169) -- After installing, command `kasa` not found [\#165](https://github.com/python-kasa/python-kasa/issues/165) - Can't command or query HS200 v5 switch [\#161](https://github.com/python-kasa/python-kasa/issues/161) + +**Merged pull requests:** + +- Release 0.4.0.dev4 [\#210](https://github.com/python-kasa/python-kasa/pull/210) (@rytilahti) +- More CI fixes [\#208](https://github.com/python-kasa/python-kasa/pull/208) (@rytilahti) +- Fix CI dep installation [\#207](https://github.com/python-kasa/python-kasa/pull/207) (@rytilahti) +- Use github actions instead of azure pipelines [\#206](https://github.com/python-kasa/python-kasa/pull/206) (@rytilahti) +- Add KP115 fixture [\#202](https://github.com/python-kasa/python-kasa/pull/202) (@rytilahti) +- Perform initial update only using the sysinfo query [\#199](https://github.com/python-kasa/python-kasa/pull/199) (@rytilahti) +- Add real kasa KL430\(UN\) device dump [\#192](https://github.com/python-kasa/python-kasa/pull/192) (@iprodanovbg) +- Use less strict matcher for kl430 color temperature [\#190](https://github.com/python-kasa/python-kasa/pull/190) (@rytilahti) +- Add EP10\(US\) 1.0 1.0.2 fixture [\#174](https://github.com/python-kasa/python-kasa/pull/174) (@nbrew) +- Add a note about using the discovery target parameter [\#168](https://github.com/python-kasa/python-kasa/pull/168) (@leandroreox) + +## [0.4.0.dev3](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev3) (2021-06-16) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev2...0.4.0.dev3) + +**Fixed bugs:** + +- `Unable to find a value for 'current'` error when attempting to query KL125 bulb emeter [\#142](https://github.com/python-kasa/python-kasa/issues/142) +- `Unknown color temperature range` error when attempting to query KL125 bulb state [\#141](https://github.com/python-kasa/python-kasa/issues/141) +- Simplify discovery query, refactor dump-devinfo [\#147](https://github.com/python-kasa/python-kasa/pull/147) (@rytilahti) +- Return None instead of raising an exception on missing, valid emeter keys [\#146](https://github.com/python-kasa/python-kasa/pull/146) (@rytilahti) + +**Documentation updates:** + +- Add ability to control individual sockets on KP400 [\#121](https://github.com/python-kasa/python-kasa/issues/121) +- Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) (@rytilahti) + +**Closed issues:** + +- After installing, command `kasa` not found [\#165](https://github.com/python-kasa/python-kasa/issues/165) - KL430 causing "non-hexadecimal number found in fromhex\(\) arg at position 2" error in smartdevice.py [\#159](https://github.com/python-kasa/python-kasa/issues/159) - Cant get smart strip children to work [\#144](https://github.com/python-kasa/python-kasa/issues/144) - `kasa --host 192.168.1.67 wifi join ` does not change network [\#139](https://github.com/python-kasa/python-kasa/issues/139) @@ -724,11 +993,40 @@ Pull requests improving the functionality of modules as well as adding better in - 'kasa wifi scan' raises RuntimeError [\#127](https://github.com/python-kasa/python-kasa/issues/127) - Runtime Error when I execute Kasa emeter command [\#124](https://github.com/python-kasa/python-kasa/issues/124) - HS105\(US\) HW 5.0/SW 1.0.2 Not Working [\#119](https://github.com/python-kasa/python-kasa/issues/119) -- TPLINK HS100 firmware 4.1 no longer has TCP 9999 available [\#114](https://github.com/python-kasa/python-kasa/issues/114) - HS110\(UK\) not discoverable [\#113](https://github.com/python-kasa/python-kasa/issues/113) - Stopping Kasa SmartDevices from phoning home [\#111](https://github.com/python-kasa/python-kasa/issues/111) -- 7.1.2 Update to asyncclick breaks github install of python-kasa [\#106](https://github.com/python-kasa/python-kasa/issues/106) - TP Link Dimmer switch \(HS220\) hardware version 2.0 not being discovered [\#105](https://github.com/python-kasa/python-kasa/issues/105) +- Support for P100 Smart Plug [\#83](https://github.com/python-kasa/python-kasa/issues/83) + +**Merged pull requests:** + +- Prepare 0.4.0.dev3 [\#172](https://github.com/python-kasa/python-kasa/pull/172) (@rytilahti) +- Simplify mac address handling [\#162](https://github.com/python-kasa/python-kasa/pull/162) (@rytilahti) +- Added KL125 and HS200 fixture dumps and updated tests to run on new format [\#160](https://github.com/python-kasa/python-kasa/pull/160) (@brianthedavis) +- Add KL125 bulb definition [\#143](https://github.com/python-kasa/python-kasa/pull/143) (@mdarnol) +- README.md: Add link to MQTT interface for python-kasa [\#140](https://github.com/python-kasa/python-kasa/pull/140) (@flavio-fernandes) +- Fix documentation on Smart strips [\#136](https://github.com/python-kasa/python-kasa/pull/136) (@flavio-fernandes) +- add tapo link, fix tplink-smarthome-simulator link [\#133](https://github.com/python-kasa/python-kasa/pull/133) (@rytilahti) +- Leverage data from UDP discovery to initialize device structure [\#132](https://github.com/python-kasa/python-kasa/pull/132) (@dlee1j1) +- Add HS220 hw 2.0 fixture [\#107](https://github.com/python-kasa/python-kasa/pull/107) (@appleguru) + +## [0.4.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev2) (2020-11-21) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev1...0.4.0.dev2) + +**Implemented enhancements:** + +- 'Interface' parameter added to discovery process [\#79](https://github.com/python-kasa/python-kasa/pull/79) (@dmitryelj) + +**Fixed bugs:** + +- Simplify device class detection for discovery, fix hardcoded timeout [\#112](https://github.com/python-kasa/python-kasa/pull/112) (@rytilahti) +- Update cli.py to addresss crash on year/month calls and improve output formatting [\#103](https://github.com/python-kasa/python-kasa/pull/103) (@BuongiornoTexas) + +**Closed issues:** + +- TPLINK HS100 firmware 4.1 no longer has TCP 9999 available [\#114](https://github.com/python-kasa/python-kasa/issues/114) +- 7.1.2 Update to asyncclick breaks github install of python-kasa [\#106](https://github.com/python-kasa/python-kasa/issues/106) - cli emeter year and month functions fail [\#102](https://github.com/python-kasa/python-kasa/issues/102) - how to know the duration for which the plug was ON? [\#99](https://github.com/python-kasa/python-kasa/issues/99) - problem controlling the smartplug through a controller [\#98](https://github.com/python-kasa/python-kasa/issues/98) @@ -737,9 +1035,30 @@ Pull requests improving the functionality of modules as well as adding better in - issue with installation [\#95](https://github.com/python-kasa/python-kasa/issues/95) - Running via Crontab [\#92](https://github.com/python-kasa/python-kasa/issues/92) - Issues with setup [\#91](https://github.com/python-kasa/python-kasa/issues/91) + +**Merged pull requests:** + +- Release 0.4.0.dev2 [\#118](https://github.com/python-kasa/python-kasa/pull/118) (@rytilahti) +- Pin dependencies on major versions, add poetry.lock [\#94](https://github.com/python-kasa/python-kasa/pull/94) (@rytilahti) + +## [0.4.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev1) (2020-07-28) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev0...0.4.0.dev1) + +**Implemented enhancements:** + +- KL430 support [\#67](https://github.com/python-kasa/python-kasa/issues/67) +- Improve retry logic for discovery, messaging \(was: Handle empty responses\) [\#38](https://github.com/python-kasa/python-kasa/issues/38) +- Add support for lightstrips \(KL430\) [\#74](https://github.com/python-kasa/python-kasa/pull/74) (@rytilahti) + +**Documentation updates:** + +- Improve poetry usage documentation [\#60](https://github.com/python-kasa/python-kasa/issues/60) + +**Closed issues:** + - I don't python... how do I make this executable? [\#88](https://github.com/python-kasa/python-kasa/issues/88) - ImportError: cannot import name 'smartplug' [\#87](https://github.com/python-kasa/python-kasa/issues/87) -- Support for P100 Smart Plug [\#83](https://github.com/python-kasa/python-kasa/issues/83) - not able to pip install the library [\#82](https://github.com/python-kasa/python-kasa/issues/82) - Discover.discover\(\) add selecting network interface \[pull request\] [\#78](https://github.com/python-kasa/python-kasa/issues/78) - LB100 unable to turn on or off the lights [\#68](https://github.com/python-kasa/python-kasa/issues/68) @@ -748,33 +1067,6 @@ Pull requests improving the functionality of modules as well as adding better in **Merged pull requests:** -- Release 0.4.0 [\#221](https://github.com/python-kasa/python-kasa/pull/221) (@rytilahti) -- Add github workflow for pypi publishing [\#220](https://github.com/python-kasa/python-kasa/pull/220) (@rytilahti) -- Add host information to protocol debug logs [\#219](https://github.com/python-kasa/python-kasa/pull/219) (@rytilahti) -- Release 0.4.0.dev5 [\#215](https://github.com/python-kasa/python-kasa/pull/215) (@rytilahti) -- Add KL130 fixture, initial lightstrip tests [\#214](https://github.com/python-kasa/python-kasa/pull/214) (@rytilahti) -- Cleanup discovery & add tests [\#212](https://github.com/python-kasa/python-kasa/pull/212) (@rytilahti) -- Release 0.4.0.dev4 [\#210](https://github.com/python-kasa/python-kasa/pull/210) (@rytilahti) -- More CI fixes [\#208](https://github.com/python-kasa/python-kasa/pull/208) (@rytilahti) -- Fix CI dep installation [\#207](https://github.com/python-kasa/python-kasa/pull/207) (@rytilahti) -- Use github actions instead of azure pipelines [\#206](https://github.com/python-kasa/python-kasa/pull/206) (@rytilahti) -- Add KP115 fixture [\#202](https://github.com/python-kasa/python-kasa/pull/202) (@rytilahti) -- Perform initial update only using the sysinfo query [\#199](https://github.com/python-kasa/python-kasa/pull/199) (@rytilahti) -- Add real kasa KL430\(UN\) device dump [\#192](https://github.com/python-kasa/python-kasa/pull/192) (@iprodanovbg) -- Use less strict matcher for kl430 color temperature [\#190](https://github.com/python-kasa/python-kasa/pull/190) (@rytilahti) -- Add EP10\(US\) 1.0 1.0.2 fixture [\#174](https://github.com/python-kasa/python-kasa/pull/174) (@nbrew) -- Prepare 0.4.0.dev3 [\#172](https://github.com/python-kasa/python-kasa/pull/172) (@rytilahti) -- Add a note about using the discovery target parameter [\#168](https://github.com/python-kasa/python-kasa/pull/168) (@leandroreox) -- Simplify mac address handling [\#162](https://github.com/python-kasa/python-kasa/pull/162) (@rytilahti) -- Added KL125 and HS200 fixture dumps and updated tests to run on new format [\#160](https://github.com/python-kasa/python-kasa/pull/160) (@brianthedavis) -- Add KL125 bulb definition [\#143](https://github.com/python-kasa/python-kasa/pull/143) (@mdarnol) -- README.md: Add link to MQTT interface for python-kasa [\#140](https://github.com/python-kasa/python-kasa/pull/140) (@flavio-fernandes) -- Fix documentation on Smart strips [\#136](https://github.com/python-kasa/python-kasa/pull/136) (@flavio-fernandes) -- add tapo link, fix tplink-smarthome-simulator link [\#133](https://github.com/python-kasa/python-kasa/pull/133) (@rytilahti) -- Leverage data from UDP discovery to initialize device structure [\#132](https://github.com/python-kasa/python-kasa/pull/132) (@dlee1j1) -- Release 0.4.0.dev2 [\#118](https://github.com/python-kasa/python-kasa/pull/118) (@rytilahti) -- Add HS220 hw 2.0 fixture [\#107](https://github.com/python-kasa/python-kasa/pull/107) (@appleguru) -- Pin dependencies on major versions, add poetry.lock [\#94](https://github.com/python-kasa/python-kasa/pull/94) (@rytilahti) - Release 0.4.0.dev1 [\#93](https://github.com/python-kasa/python-kasa/pull/93) (@rytilahti) - add a small example script to show library usage [\#90](https://github.com/python-kasa/python-kasa/pull/90) (@rytilahti) - add .readthedocs.yml required for poetry builds [\#89](https://github.com/python-kasa/python-kasa/pull/89) (@rytilahti) @@ -787,6 +1079,10 @@ Pull requests improving the functionality of modules as well as adding better in - Bulbs: allow specifying transition for state changes [\#70](https://github.com/python-kasa/python-kasa/pull/70) (@rytilahti) - Add transition support for SmartDimmer [\#69](https://github.com/python-kasa/python-kasa/pull/69) (@connorproctor) +## [0.4.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev0) (2020-05-27) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.pre0...0.4.0.dev0) + ## [0.4.0.pre0](https://github.com/python-kasa/python-kasa/tree/0.4.0.pre0) (2020-05-27) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.3.5...0.4.0.pre0) diff --git a/pyproject.toml b/pyproject.toml index 783477e1e..87b43911f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.6.2.1" +version = "0.7.0.dev0" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From db6e3353469fecafed548d98a7cababcca3ed6a6 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 22 May 2024 14:33:55 +0100 Subject: [PATCH 440/892] Fix set_state for common light modules (#929) PR contains a number of fixes from testing with HA devices: - Fixes a bug with turning the light on and off via `set_state` - Aligns `set_brightness` behaviour across `smart` and `iot` devices such that a value of 0 is off. - Aligns `set_brightness` behaviour for `IotDimmer` such that setting the brightness turns on the device with a transition of 1ms. ([HA comment](https://github.com/home-assistant/core/pull/117839#discussion_r1608720006)) - Fixes a typing issue in `LightState`. - Adds `ColorTempRange` and `HSV` to `__init__.py` - Adds a `state` property to the interface returning `LightState` for validating `set_state` changes. - Adds tests for `set_state` --- kasa/__init__.py | 4 ++- kasa/interfaces/light.py | 7 ++++- kasa/iot/iotbulb.py | 3 ++ kasa/iot/iotdimmer.py | 3 ++ kasa/iot/modules/light.py | 50 ++++++++++++++++++++++++++++--- kasa/smart/modules/light.py | 29 +++++++++++++++++- kasa/tests/test_bulb.py | 2 +- kasa/tests/test_common_modules.py | 30 +++++++++++++++++-- kasa/tests/test_dimmer.py | 18 +++++------ 9 files changed, 127 insertions(+), 19 deletions(-) diff --git a/kasa/__init__.py b/kasa/__init__.py index ac10c12f8..d436155eb 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -35,7 +35,7 @@ UnsupportedDeviceError, ) from kasa.feature import Feature -from kasa.interfaces.light import Light, LightState +from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState from kasa.iotprotocol import ( IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 @@ -60,6 +60,8 @@ "EmeterStatus", "Device", "Light", + "ColorTempRange", + "HSV", "Plug", "Module", "KasaException", diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py index f121d9c69..207014cab 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -18,7 +18,7 @@ class LightState: hue: int | None = None saturation: int | None = None color_temp: int | None = None - transition: bool | None = None + transition: int | None = None class ColorTempRange(NamedTuple): @@ -128,6 +128,11 @@ async def set_brightness( :param int transition: transition in milliseconds. """ + @property + @abstractmethod + def state(self) -> LightState: + """Return the current light state.""" + @abstractmethod async def set_state(self, state: LightState) -> dict: """Set the light state.""" diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index cca1e7922..362093609 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -329,6 +329,9 @@ async def _set_light_state( if transition is not None: state["transition_period"] = transition + if "brightness" in state: + self._raise_for_invalid_brightness(state["brightness"]) + # if no on/off is defined, turn on the light if "on_off" not in state: state["on_off"] = 1 diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 740d9bb5a..ca182e49f 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -168,6 +168,9 @@ async def set_dimmer_transition(self, brightness: int, transition: int): if not 0 <= brightness <= 100: raise ValueError("Brightness value %s is not valid." % brightness) + # If zero set to 1 millisecond + if transition == 0: + transition = 1 if not isinstance(transition, int): raise ValueError( "Transition must be integer, " "not of %s.", type(transition) diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 6bbb8894f..8c4e22c90 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -25,6 +25,7 @@ class Light(IotModule, LightInterface): """Implementation of brightness module.""" _device: IotBulb | IotDimmer + _light_state: LightState def _initialize_features(self): """Initialize features.""" @@ -102,12 +103,14 @@ def brightness(self) -> int: async def set_brightness( self, brightness: int, *, transition: int | None = None ) -> dict: - """Set the brightness in percentage. + """Set the brightness in percentage. A value of 0 will turn off the light. :param int brightness: brightness in percent :param int transition: transition in milliseconds. """ - return await self._device._set_brightness(brightness, transition=transition) + return await self.set_state( + LightState(brightness=brightness, transition=transition) + ) @property def is_color(self) -> bool: @@ -202,15 +205,54 @@ async def set_color_temp( async def set_state(self, state: LightState) -> dict: """Set the light state.""" - if (bulb := self._get_bulb_device()) is None: - return await self.set_brightness(state.brightness or 0) + # iot protocol Dimmers and smart protocol devices do not support + # brightness of 0 so 0 will turn off all devices for consistency + if (bulb := self._get_bulb_device()) is None: # Dimmer + if state.brightness == 0 or state.light_on is False: + return await self._device.turn_off(transition=state.transition) + elif state.brightness: + # set_dimmer_transition will turn on the device + return await self._device.set_dimmer_transition( + state.brightness, state.transition or 0 + ) + return await self._device.turn_on(transition=state.transition) else: transition = state.transition state_dict = asdict(state) state_dict = {k: v for k, v in state_dict.items() if v is not None} + if "transition" in state_dict: + del state_dict["transition"] state_dict["on_off"] = 1 if state.light_on is None else int(state.light_on) + if state_dict.get("brightness") == 0: + state_dict["on_off"] = 0 + del state_dict["brightness"] + # If light on state not set default to on. + elif state.light_on is None: + state_dict["on_off"] = 1 + else: + state_dict["on_off"] = int(state.light_on) return await bulb._set_light_state(state_dict, transition=transition) + @property + def state(self) -> LightState: + """Return the current light state.""" + return self._light_state + + def _post_update_hook(self) -> None: + if self._device.is_on is False: + state = LightState(light_on=False) + else: + state = LightState(light_on=True) + if self.is_dimmable: + state.brightness = self.brightness + if self.is_color: + hsv = self.hsv + state.hue = hsv.hue + state.saturation = hsv.saturation + if self.is_variable_color_temp: + state.color_temp = self.color_temp + self._light_state = state + async def _deprecated_set_light_state( self, state: dict, *, transition: int | None = None ) -> dict: diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py index 9a07d3e2b..0a255bb2a 100644 --- a/kasa/smart/modules/light.py +++ b/kasa/smart/modules/light.py @@ -14,6 +14,8 @@ class Light(SmartModule, LightInterface): """Implementation of a light.""" + _light_state: LightState + def query(self) -> dict: """Query to execute during the update cycle.""" return {} @@ -131,9 +133,34 @@ async def set_state(self, state: LightState) -> dict: """Set the light state.""" state_dict = asdict(state) # brightness of 0 turns off the light, it's not a valid brightness - if state.brightness and state.brightness == 0: + if state.brightness == 0: state_dict["device_on"] = False del state_dict["brightness"] + elif state.light_on is not None: + state_dict["device_on"] = state.light_on + del state_dict["light_on"] + else: + state_dict["device_on"] = True params = {k: v for k, v in state_dict.items() if v is not None} return await self.call("set_device_info", params) + + @property + def state(self) -> LightState: + """Return the current light state.""" + return self._light_state + + def _post_update_hook(self) -> None: + if self._device.is_on is False: + state = LightState(light_on=False) + else: + state = LightState(light_on=True) + if self.is_dimmable: + state.brightness = self.brightness + if self.is_color: + hsv = self.hsv + state.hue = hsv.hue + state.saturation = hsv.saturation + if self.is_variable_color_temp: + state.color_temp = self.color_temp + self._light_state = state diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 97ae85a34..b26530154 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -241,7 +241,7 @@ async def test_dimmable_brightness_transition(dev: IotBulb, mocker): set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") await dev.set_brightness(10, transition=1000) - set_light_state.assert_called_with({"brightness": 10}, transition=1000) + set_light_state.assert_called_with({"brightness": 10, "on_off": 1}, transition=1000) @dimmable_iot diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index 520303079..0cdb32ade 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -4,6 +4,7 @@ from kasa import Device, LightState, Module from kasa.tests.device_fixtures import ( bulb_iot, + bulb_smart, dimmable_iot, dimmer_iot, lightstrip_iot, @@ -40,6 +41,8 @@ light_preset = parametrize_combine([light_preset_smart, bulb_iot]) +light = parametrize_combine([bulb_smart, bulb_iot, dimmable]) + @led async def test_led_module(dev: Device, mocker: MockerFixture): @@ -139,6 +142,30 @@ async def test_light_brightness(dev: Device): await light.set_brightness(feature.maximum_value + 10) +@light +async def test_light_set_state(dev: Device): + """Test brightness setter and getter.""" + assert isinstance(dev, Device) + light = dev.modules.get(Module.Light) + assert light + + await light.set_state(LightState(light_on=False)) + await dev.update() + assert light.state.light_on is False + + await light.set_state(LightState(light_on=True)) + await dev.update() + assert light.state.light_on is True + + await light.set_state(LightState(brightness=0)) + await dev.update() + assert light.state.light_on is False + + await light.set_state(LightState(brightness=50)) + await dev.update() + assert light.state.light_on is True + + @light_preset async def test_light_preset_module(dev: Device, mocker: MockerFixture): """Test light preset module.""" @@ -148,7 +175,6 @@ async def test_light_preset_module(dev: Device, mocker: MockerFixture): assert light_mod feat = dev.features["light_preset"] - call = mocker.spy(light_mod, "set_state") preset_list = preset_mod.preset_list assert "Not set" in preset_list assert preset_list.index("Not set") == 0 @@ -157,7 +183,6 @@ async def test_light_preset_module(dev: Device, mocker: MockerFixture): assert preset_mod.has_save_preset is True await light_mod.set_brightness(33) # Value that should not be a preset - assert call.call_count == 0 await dev.update() assert preset_mod.preset == "Not set" assert feat.value == "Not set" @@ -165,6 +190,7 @@ async def test_light_preset_module(dev: Device, mocker: MockerFixture): if len(preset_list) == 1: return + call = mocker.spy(light_mod, "set_state") second_preset = preset_list[1] await preset_mod.set_preset(second_preset) assert call.call_count == 1 diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index 06150d394..5831c0193 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -7,19 +7,19 @@ @dimmer_iot -@turn_on -async def test_set_brightness(dev, turn_on): - await handle_turn_on(dev, turn_on) +async def test_set_brightness(dev): + await handle_turn_on(dev, False) + assert dev.is_on is False await dev.set_brightness(99) await dev.update() assert dev.brightness == 99 - assert dev.is_on == turn_on + assert dev.is_on is True await dev.set_brightness(0) await dev.update() - assert dev.brightness == 1 - assert dev.is_on == turn_on + assert dev.brightness == 99 + assert dev.is_on is False @dimmer_iot @@ -41,7 +41,7 @@ async def test_set_brightness_transition(dev, turn_on, mocker): await dev.set_brightness(0, transition=1000) await dev.update() - assert dev.brightness == 1 + assert dev.is_on is False @dimmer_iot @@ -50,7 +50,7 @@ async def test_set_brightness_invalid(dev): with pytest.raises(ValueError): await dev.set_brightness(invalid_brightness) - for invalid_transition in [-1, 0, 0.5]: + for invalid_transition in [-1, 0.5]: with pytest.raises(ValueError): await dev.set_brightness(1, transition=invalid_transition) @@ -133,7 +133,7 @@ async def test_set_dimmer_transition_invalid(dev): with pytest.raises(ValueError): await dev.set_dimmer_transition(invalid_brightness, 1000) - for invalid_transition in [-1, 0, 0.5]: + for invalid_transition in [-1, 0.5]: with pytest.raises(ValueError): await dev.set_dimmer_transition(1, invalid_transition) From 23c5ee089a09e2fc4a433947ad1265a4e0f1cd9f Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 22 May 2024 16:52:00 +0200 Subject: [PATCH 441/892] Add state feature for iot devices (#924) This is allows a generic implementation for the switch platform in the homeassistant integration. Also elevates set_state(bool) to be part of the standard API. --- kasa/device.py | 8 ++++++++ kasa/iot/iotdevice.py | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/kasa/device.py b/kasa/device.py index 7156a2194..d462239d2 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -138,6 +138,14 @@ async def turn_on(self, **kwargs) -> dict | None: async def turn_off(self, **kwargs) -> dict | None: """Turn off the device.""" + @abstractmethod + async def set_state(self, on: bool): + """Set the device state to *on*. + + This allows turning the device on and off. + See also *turn_off* and *turn_on*. + """ + @property def host(self) -> str: """The device host.""" diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 25e3b44d5..dfe48a12b 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -323,6 +323,18 @@ async def _initialize_modules(self): """Initialize modules not added in init.""" async def _initialize_features(self): + """Initialize common features.""" + self._add_feature( + Feature( + self, + id="state", + name="State", + attribute_getter="is_on", + attribute_setter="set_state", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) + ) self._add_feature( Feature( device=self, @@ -634,6 +646,13 @@ def is_on(self) -> bool: """Return True if the device is on.""" raise NotImplementedError("Device subclass needs to implement this.") + async def set_state(self, on: bool): + """Set the device state.""" + if on: + return await self.turn_on() + else: + return await self.turn_off() + @property # type: ignore @requires_update def on_since(self) -> datetime | None: From c1e14832ef10455d463310717ec7be4d92fdceba Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 22 May 2024 17:37:28 +0200 Subject: [PATCH 442/892] Prepare 0.7.0.dev1 (#931) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev0...0.7.0.dev1) **Implemented enhancements:** - Fix set\_state for common light modules [\#929](https://github.com/python-kasa/python-kasa/pull/929) (@sdb9696) - Add state feature for iot devices [\#924](https://github.com/python-kasa/python-kasa/pull/924) (@rytilahti) --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08166a8d0..820133428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [0.7.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev1) (2024-05-22) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev0...0.7.0.dev1) + +**Implemented enhancements:** + +- Fix set\_state for common light modules [\#929](https://github.com/python-kasa/python-kasa/pull/929) (@sdb9696) +- Add state feature for iot devices [\#924](https://github.com/python-kasa/python-kasa/pull/924) (@rytilahti) + ## [0.7.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev0) (2024-05-19) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2.1...0.7.0.dev0) @@ -120,6 +129,7 @@ **Merged pull requests:** +- Prepare 0.7.0.dev0 [\#922](https://github.com/python-kasa/python-kasa/pull/922) (@rytilahti) - Fix potential infinite loop if incomplete lists returned [\#920](https://github.com/python-kasa/python-kasa/pull/920) (@sdb9696) - Deprecate device level light, effect and led attributes [\#916](https://github.com/python-kasa/python-kasa/pull/916) (@sdb9696) - Update cli to use common modules and remove iot specific cli testing [\#913](https://github.com/python-kasa/python-kasa/pull/913) (@sdb9696) diff --git a/pyproject.toml b/pyproject.toml index 87b43911f..8b583828a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.dev0" +version = "0.7.0.dev1" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From b21781109659d6a9194db478e674977b6a2d80ed Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 23 May 2024 20:35:41 +0200 Subject: [PATCH 443/892] Do not show a zero error code when cli exits from showing help (#935) asyncclick raises a custom runtime exception when exiting help. This suppresses reporting it. --- kasa/cli.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/kasa/cli.py b/kasa/cli.py index 235387bc1..f56aaccd4 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -111,6 +111,10 @@ def CatchAllExceptions(cls): def _handle_exception(debug, exc): if isinstance(exc, click.ClickException): raise + # Handle exit request from click. + if isinstance(exc, click.exceptions.Exit): + sys.exit(exc.exit_code) + echo(f"Raised error: {exc}") if debug: raise From 767156421b119107f567e09c3bf3861e0b95eca0 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 24 May 2024 19:39:10 +0200 Subject: [PATCH 444/892] Initialize autooff features only when data is available (#933) For power strips, the autooff data needs to be requested from the children. Until we do that, we should not create these features to avoid crashing during switch platform initialization. This also ports the module to use `_initialize_features` and add tests. --- kasa/smart/modules/autooff.py | 21 +++-- kasa/tests/smart/modules/test_autooff.py | 103 +++++++++++++++++++++++ 2 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 kasa/tests/smart/modules/test_autooff.py diff --git a/kasa/smart/modules/autooff.py b/kasa/smart/modules/autooff.py index 385364fa6..684a2c510 100644 --- a/kasa/smart/modules/autooff.py +++ b/kasa/smart/modules/autooff.py @@ -2,14 +2,13 @@ from __future__ import annotations +import logging from datetime import datetime, timedelta -from typing import TYPE_CHECKING from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice +_LOGGER = logging.getLogger(__name__) class AutoOff(SmartModule): @@ -18,11 +17,17 @@ class AutoOff(SmartModule): REQUIRED_COMPONENT = "auto_off" QUERY_GETTER_NAME = "get_auto_off_config" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features after the initial update.""" + if not isinstance(self.data, dict): + _LOGGER.warning( + "No data available for module, skipping %s: %s", self, self.data + ) + return + self._add_feature( Feature( - device, + self._device, id="auto_off_enabled", name="Auto off enabled", container=self, @@ -33,7 +38,7 @@ def __init__(self, device: SmartDevice, module: str): ) self._add_feature( Feature( - device, + self._device, id="auto_off_minutes", name="Auto off minutes", container=self, @@ -44,7 +49,7 @@ def __init__(self, device: SmartDevice, module: str): ) self._add_feature( Feature( - device, + self._device, id="auto_off_at", name="Auto off at", container=self, diff --git a/kasa/tests/smart/modules/test_autooff.py b/kasa/tests/smart/modules/test_autooff.py new file mode 100644 index 000000000..c44617a76 --- /dev/null +++ b/kasa/tests/smart/modules/test_autooff.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import sys +from datetime import datetime +from typing import Optional + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice +from kasa.tests.device_fixtures import parametrize + +autooff = parametrize( + "has autooff", component_filter="auto_off", protocol_filter={"SMART"} +) + + +@autooff +@pytest.mark.parametrize( + "feature, prop_name, type", + [ + ("auto_off_enabled", "enabled", bool), + ("auto_off_minutes", "delay", int), + ("auto_off_at", "auto_off_at", Optional[datetime]), + ], +) +@pytest.mark.skipif( + sys.version_info < (3, 10), + reason="Subscripted generics cannot be used with class and instance checks", +) +async def test_autooff_features( + dev: SmartDevice, feature: str, prop_name: str, type: type +): + """Test that features are registered and work as expected.""" + autooff = dev.modules.get(Module.AutoOff) + assert autooff is not None + + prop = getattr(autooff, prop_name) + assert isinstance(prop, type) + + feat = dev.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@autooff +async def test_settings(dev: SmartDevice, mocker: MockerFixture): + """Test autooff settings.""" + autooff = dev.modules.get(Module.AutoOff) + assert autooff + + enabled = dev.features["auto_off_enabled"] + assert autooff.enabled == enabled.value + + delay = dev.features["auto_off_minutes"] + assert autooff.delay == delay.value + + call = mocker.spy(autooff, "call") + new_state = True + + await autooff.set_enabled(new_state) + call.assert_called_with( + "set_auto_off_config", {"enable": new_state, "delay_min": delay.value} + ) + call.reset_mock() + await dev.update() + + new_delay = 123 + + await autooff.set_delay(new_delay) + + call.assert_called_with( + "set_auto_off_config", {"enable": new_state, "delay_min": new_delay} + ) + + await dev.update() + + assert autooff.enabled == new_state + assert autooff.delay == new_delay + + +@autooff +@pytest.mark.parametrize("is_timer_active", [True, False]) +async def test_auto_off_at( + dev: SmartDevice, mocker: MockerFixture, is_timer_active: bool +): + """Test auto-off at sensor.""" + autooff = dev.modules.get(Module.AutoOff) + assert autooff + + autooff_at = dev.features["auto_off_at"] + + mocker.patch.object( + type(autooff), + "is_timer_active", + new_callable=mocker.PropertyMock, + return_value=is_timer_active, + ) + if is_timer_active: + assert isinstance(autooff_at.value, datetime) + else: + assert autooff_at.value is None From 6616d68d42f4c13f32a5bab97b457ad5198633b4 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 3 Jun 2024 12:14:10 +0300 Subject: [PATCH 445/892] Update documentation structure and start migrating to markdown (#934) Starts structuring the documentation library usage into Tutorials, Guides, Explanations and Reference. Continues migrating new docs from rst to markdown. Extends the test framework discovery mocks to allow easy writing and testing of code examples. --- README.md | 2 +- docs/source/conf.py | 4 + docs/source/deprecated.md | 24 +++ docs/source/discover.rst | 62 -------- docs/source/guides.md | 42 ++++++ docs/source/index.md | 12 ++ docs/source/index.rst | 20 --- docs/source/library.md | 15 ++ docs/source/reference.md | 134 +++++++++++++++++ docs/source/{device.rst => smartdevice.rst} | 75 ++------- docs/source/{design.rst => topics.md} | 142 +++++++++++------- docs/source/tutorial.md | 2 +- docs/tutorial.py | 11 +- kasa/deviceconfig.py | 33 +++- kasa/discover.py | 119 ++++++++++----- kasa/feature.py | 3 +- kasa/iot/iotdevice.py | 2 +- kasa/iot/iotlightstrip.py | 2 +- kasa/iot/iotplug.py | 2 +- kasa/iot/iotstrip.py | 2 +- kasa/tests/device_fixtures.py | 7 + kasa/tests/discovery_fixtures.py | 111 ++++++++++---- kasa/tests/fakeprotocol_iot.py | 37 ++++- kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.json | 2 +- kasa/tests/fixtures/HS220(US)_1.0_1.5.7.json | 2 +- kasa/tests/fixtures/HS220(US)_2.0_1.0.3.json | 2 +- kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json | 2 +- kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.json | 2 +- .../fixtures/smart/L530E(EU)_3.0_1.1.6.json | 2 +- kasa/tests/test_discovery.py | 5 +- kasa/tests/test_readme_examples.py | 59 +++++--- 31 files changed, 617 insertions(+), 322 deletions(-) create mode 100644 docs/source/deprecated.md delete mode 100644 docs/source/discover.rst create mode 100644 docs/source/guides.md create mode 100644 docs/source/index.md delete mode 100644 docs/source/index.rst create mode 100644 docs/source/library.md create mode 100644 docs/source/reference.md rename docs/source/{device.rst => smartdevice.rst} (58%) rename docs/source/{design.rst => topics.md} (52%) diff --git a/README.md b/README.md index 1ed93f752..3551a1ee1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

python-kasa

+# python-kasa [![PyPI version](https://badge.fury.io/py/python-kasa.svg)](https://badge.fury.io/py/python-kasa) [![Build Status](https://github.com/python-kasa/python-kasa/actions/workflows/ci.yml/badge.svg)](https://github.com/python-kasa/python-kasa/actions/workflows/ci.yml) diff --git a/docs/source/conf.py b/docs/source/conf.py index b6064b383..5554abf13 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -37,6 +37,10 @@ "myst_parser", ] +myst_enable_extensions = [ + "colon_fence", +] + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/docs/source/deprecated.md b/docs/source/deprecated.md new file mode 100644 index 000000000..d6c22bee5 --- /dev/null +++ b/docs/source/deprecated.md @@ -0,0 +1,24 @@ +# Deprecated API + +```{currentmodule} kasa +``` +The page contains the documentation for the deprecated library API that only works with the older kasa devices. + +If you want to continue to use the old API for older devices, +you can use the classes in the `iot` module to avoid deprecation warnings. + +```py +from kasa.iot import IotDevice, IotBulb, IotPlug, IotDimmer, IotStrip, IotLightStrip +``` + + +```{toctree} +:maxdepth: 2 + +smartdevice +smartbulb +smartplug +smartdimmer +smartstrip +smartlightstrip +``` diff --git a/docs/source/discover.rst b/docs/source/discover.rst deleted file mode 100644 index 29b68196d..000000000 --- a/docs/source/discover.rst +++ /dev/null @@ -1,62 +0,0 @@ -.. 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:`Device.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 -***************** - -.. autoclass:: kasa.Discover - :members: - :undoc-members: diff --git a/docs/source/guides.md b/docs/source/guides.md new file mode 100644 index 000000000..4206c8a92 --- /dev/null +++ b/docs/source/guides.md @@ -0,0 +1,42 @@ +# How-to Guides + +This page contains guides of how to perform common actions using the library. + +## Discover devices + +```{eval-rst} +.. automodule:: kasa.discover +``` + +## Connect without discovery + +```{eval-rst} +.. automodule:: kasa.deviceconfig +``` + +## Get Energy Consumption and Usage Statistics + +:::{note} +In order to use the helper methods to calculate the statistics correctly, your devices need to have correct time set. +The devices use NTP and public servers from [NTP Pool Project](https://www.ntppool.org/) to synchronize their time. +::: + +### Energy Consumption + +The availability of energy consumption sensors depend on the device. +While most of the bulbs support it, only specific switches (e.g., HS110) or strips (e.g., HS300) support it. +You can use {attr}`~Device.has_emeter` to check for the availability. + + +### Usage statistics + +You can use {attr}`~Device.on_since` to query for the time the device has been turned on. +Some devices also support reporting the usage statistics on daily or monthly basis. +You can access this information using through the usage module ({class}`kasa.modules.Usage`): + +```py +dev = SmartPlug("127.0.0.1") +usage = dev.modules["usage"] +print(f"Minutes on this month: {usage.usage_this_month}") +print(f"Minutes on today: {usage.usage_today}") +``` diff --git a/docs/source/index.md b/docs/source/index.md new file mode 100644 index 000000000..e1ba08332 --- /dev/null +++ b/docs/source/index.md @@ -0,0 +1,12 @@ +```{include} ../../README.md +``` + +```{toctree} +:maxdepth: 2 + +Home +cli +library +contribute +SUPPORTED +``` diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index 5d4a9e559..000000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,20 +0,0 @@ -.. include:: ../../README.md - :parser: myst_parser.sphinx_ - -.. toctree:: - :maxdepth: 2 - - - Home - cli - tutorial - discover - device - design - contribute - smartbulb - smartplug - smartdimmer - smartstrip - smartlightstrip - SUPPORTED diff --git a/docs/source/library.md b/docs/source/library.md new file mode 100644 index 000000000..fa276a1b0 --- /dev/null +++ b/docs/source/library.md @@ -0,0 +1,15 @@ +# Library usage + +```{currentmodule} kasa +``` +The page contains all information about the library usage: + +```{toctree} +:maxdepth: 2 + +tutorial +guides +topics +reference +deprecated +``` diff --git a/docs/source/reference.md b/docs/source/reference.md new file mode 100644 index 000000000..9b117298e --- /dev/null +++ b/docs/source/reference.md @@ -0,0 +1,134 @@ +# API Reference + +```{currentmodule} kasa +``` + +## Discover + +```{eval-rst} +.. autoclass:: kasa.Discover + :members: +``` + +## Device + +```{eval-rst} +.. autoclass:: kasa.Device + :members: + :undoc-members: +``` + +## Modules and Features + +```{eval-rst} +.. autoclass:: kasa.Module + :noindex: + :members: + :inherited-members: + :undoc-members: +``` + +```{eval-rst} +.. automodule:: kasa.interfaces + :noindex: + :members: + :inherited-members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.Feature + :noindex: + :members: + :inherited-members: + :undoc-members: +``` + +## Protocols and transports + +```{eval-rst} +.. autoclass:: kasa.protocol.BaseProtocol + :members: + :inherited-members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.iotprotocol.IotProtocol + :members: + :inherited-members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.smartprotocol.SmartProtocol + :members: + :inherited-members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.protocol.BaseTransport + :members: + :inherited-members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.xortransport.XorTransport + :members: + :inherited-members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.klaptransport.KlapTransport + :members: + :inherited-members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.klaptransport.KlapTransportV2 + :members: + :inherited-members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.aestransport.AesTransport + :members: + :inherited-members: + :undoc-members: +``` + +## Errors and exceptions + +```{eval-rst} +.. autoclass:: kasa.exceptions.KasaException + :members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.exceptions.DeviceError + :members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.exceptions.AuthenticationError + :members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.exceptions.UnsupportedDeviceError + :members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.exceptions.TimeoutError + :members: + :undoc-members: diff --git a/docs/source/device.rst b/docs/source/smartdevice.rst similarity index 58% rename from docs/source/device.rst rename to docs/source/smartdevice.rst index 328a085d3..0f91642c5 100644 --- a/docs/source/device.rst +++ b/docs/source/smartdevice.rst @@ -1,32 +1,32 @@ -.. py:module:: kasa +.. py:currentmodule:: kasa -Common API -========== +Base Device +=========== .. contents:: Contents :local: -Device class -************ +SmartDevice class +***************** -The basic functionalities of all supported devices are accessible using the common :class:`Device` base class. +The basic functionalities of all supported devices are accessible using the common :class:`SmartDevice` base class. -The property accesses use the data obtained before by awaiting :func:`Device.update()`. +The property accesses use the data obtained before by awaiting :func:`SmartDevice.update()`. The values are cached until the next update call. In practice this means that property accesses do no I/O and are dependent, while I/O producing methods need to be awaited. -See :ref:`library_design` for more detailed information. +See :ref:`topics-update-cycle` for more detailed information. .. note:: The device instances share the communication socket in background to optimize I/O accesses. This means that you need to use the same event loop for subsequent requests. The library gives a warning ("Detected protocol reuse between different event loop") to hint if you are accessing the device incorrectly. -Methods changing the state of the device do not invalidate the cache (i.e., there is no implicit :func:`Device.update()` call made by the library). +Methods changing the state of the device do not invalidate the cache (i.e., there is no implicit :func:`SmartDevice.update()` call made by the library). You can assume that the operation has succeeded if no exception is raised. These methods will return the device response, which can be useful for some use cases. -Errors are raised as :class:`KasaException` instances for the library user to handle. +Errors are raised as :class:`SmartDeviceException` instances for the library user to handle. -Simple example script showing some functionality for legacy devices: +Simple example script showing some functionality: .. code-block:: python @@ -45,31 +45,6 @@ Simple example script showing some functionality for legacy devices: 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 @@ -92,22 +67,6 @@ 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:`Device.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:`Device.config` post discovery and then re-used. - Energy Consumption and Usage Statistics *************************************** @@ -141,16 +100,6 @@ You can access this information using through the usage module (:class:`kasa.mod API documentation ***************** -.. autoclass:: Device - :members: - :undoc-members: - -.. autoclass:: DeviceConfig - :members: - :inherited-members: - :undoc-members: - :member-order: bysource - -.. autoclass:: Credentials +.. autoclass:: SmartDevice :members: :undoc-members: diff --git a/docs/source/design.rst b/docs/source/topics.md similarity index 52% rename from docs/source/design.rst rename to docs/source/topics.md index 7ed1765d6..0ff66ede8 100644 --- a/docs/source/design.rst +++ b/docs/source/topics.md @@ -1,70 +1,96 @@ -.. py:module:: kasa.modules +# Topics -.. _library_design: - -Library Design & Modules -======================== +```{contents} Contents + :local: +``` -This page aims to provide some details on the design and internals of this library. +These topics aim to provide some details on the design and internals of this library. You might be interested in this if you want to improve this library, or if you are just looking to access some information that is not currently exposed. -.. contents:: Contents - :local: - -.. _initialization: +(topics-initialization)= +## Initialization -Initialization -************** - -Use :func:`~kasa.Discover.discover` to perform udp-based broadcast discovery on the network. +Use {func}`~kasa.Discover.discover` to perform udp-based broadcast discovery on the network. This will return you a list of device instances based on the discovery replies. If the device's host is already known, you can use to construct a device instance with -:meth:`~kasa.Device.connect()`. +{meth}`~kasa.Device.connect()`. + +The {meth}`~kasa.Device.connect()` also enables support for connecting to new +KASA SMART protocol and TAPO devices directly using the parameter {class}`~kasa.DeviceConfig`. +Simply serialize the {attr}`~kasa.Device.config` property via {meth}`~kasa.DeviceConfig.to_dict()` +and then deserialize it later with {func}`~kasa.DeviceConfig.from_dict()` +and then pass it into {meth}`~kasa.Device.connect()`. + + +(topics-discovery)= +## Discovery -The :meth:`~kasa.Device.connect()` also enables support for connecting to new -KASA SMART protocol and TAPO devices directly using the parameter :class:`~kasa.DeviceConfig`. -Simply serialize the :attr:`~kasa.Device.config` property via :meth:`~kasa.DeviceConfig.to_dict()` -and then deserialize it later with :func:`~kasa.DeviceConfig.from_dict()` -and then pass it into :meth:`~kasa.Device.connect()`. +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}`Device.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 `. -.. _update_cycle: +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 {func}`Discover.discover() `, +you can provide a callback to the ``on_unsupported`` parameter +to handle these. -Update Cycle -************ +(topics-deviceconfig)= +## DeviceConfig -When :meth:`~kasa.Device.update()` is called, +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}`Device.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}`Device.config` post discovery and then re-used. + +(topics-update-cycle)= +## Update Cycle + +When {meth}`~kasa.Device.update()` is called, the library constructs a query to send to the device based on :ref:`supported modules `. -Internally, each module defines :meth:`~kasa.modules.Module.query()` to describe what they want query during the update. +Internally, each module defines {meth}`~kasa.modules.Module.query()` to describe what they want query during the update. The returned data is cached internally to avoid I/O on property accesses. All properties defined both in the device class and in the module classes follow this principle. While the properties are designed to provide a nice API to use for common use cases, you may sometimes want to access the raw, cached data as returned by the device. -This can be done using the :attr:`~kasa.Device.internal_state` property. +This can be done using the {attr}`~kasa.Device.internal_state` property. -.. _modules: +(topics-modules-and-features)= +## Modules and Features -Modules -******* - -The functionality provided by all :class:`~kasa.Device` instances is (mostly) done inside separate modules. +The functionality provided by all {class}`~kasa.Device` instances is (mostly) done inside separate modules. While the individual device-type specific classes provide an easy access for the most import features, -you can also access individual modules through :attr:`kasa.SmartDevice.modules`. -You can get the list of supported modules for a given device instance using :attr:`~kasa.Device.supported_modules`. - -.. note:: +you can also access individual modules through {attr}`kasa.Device.modules`. +You can get the list of supported modules for a given device instance using {attr}`~kasa.Device.supported_modules`. - If you only need some module-specific information, - you can call the wanted method on the module to avoid using :meth:`~kasa.Device.update`. +```{note} +If you only need some module-specific information, +you can call the wanted method on the module to avoid using {meth}`~kasa.Device.update`. +``` -Protocols and Transports -************************ +(topics-protocols-and-transports)= +## 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. @@ -90,27 +116,29 @@ In order to support these different configurations the library migrated from a s to support pluggable transports and protocols. The classes providing this functionality are: -- :class:`BaseProtocol ` -- :class:`IotProtocol ` -- :class:`SmartProtocol ` +- {class}`BaseProtocol ` +- {class}`IotProtocol ` +- {class}`SmartProtocol ` -- :class:`BaseTransport ` -- :class:`XorTransport ` -- :class:`AesTransport ` -- :class:`KlapTransport ` -- :class:`KlapTransportV2 ` +- {class}`BaseTransport ` +- {class}`XorTransport ` +- {class}`AesTransport ` +- {class}`KlapTransport ` +- {class}`KlapTransportV2 ` -Errors and Exceptions -********************* +(topics-errors-and-exceptions)= +## Errors and Exceptions -The base exception for all library errors is :class:`KasaException `. +The base exception for all library errors is {class}`KasaException `. -- If the device returns an error the library raises a :class:`DeviceError ` which will usually contain an ``error_code`` with the detail. -- If the device fails to authenticate the library raises an :class:`AuthenticationError ` which is derived - from :class:`DeviceError ` and could contain an ``error_code`` depending on the type of failure. -- If the library encounters and unsupported deviceit raises an :class:`UnsupportedDeviceError `. -- If the device fails to respond within a timeout the library raises a :class:`TimeoutError `. -- All other failures will raise the base :class:`KasaException ` class. +- If the device returns an error the library raises a {class}`DeviceError ` which will usually contain an ``error_code`` with the detail. +- If the device fails to authenticate the library raises an {class}`AuthenticationError ` which is derived + from {class}`DeviceError ` and could contain an ``error_code`` depending on the type of failure. +- If the library encounters and unsupported deviceit raises an {class}`UnsupportedDeviceError `. +- If the device fails to respond within a timeout the library raises a {class}`TimeoutError `. +- All other failures will raise the base {class}`KasaException ` class. + + diff --git a/docs/source/tutorial.md b/docs/source/tutorial.md index bd8d251cf..ee7042896 100644 --- a/docs/source/tutorial.md +++ b/docs/source/tutorial.md @@ -1,4 +1,4 @@ -# Tutorial +# Getting started ```{eval-rst} .. automodule:: tutorial diff --git a/docs/tutorial.py b/docs/tutorial.py index fb4a62736..8984d2cab 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -13,21 +13,24 @@ >>> from kasa import Device, Discover, Credentials -:func:`~kasa.Discover.discover` returns a list of devices on your network: +:func:`~kasa.Discover.discover` returns a dict[str,Device] of devices on your network: >>> devices = await Discover.discover(credentials=Credentials("user@example.com", "great_password")) ->>> for dev in devices: +>>> for dev in devices.values(): >>> await dev.update() >>> print(dev.host) 127.0.0.1 127.0.0.2 +127.0.0.3 +127.0.0.4 +127.0.0.5 :meth:`~kasa.Discover.discover_single` returns a single device by hostname: ->>> dev = await Discover.discover_single("127.0.0.1", credentials=Credentials("user@example.com", "great_password")) +>>> dev = await Discover.discover_single("127.0.0.3", credentials=Credentials("user@example.com", "great_password")) >>> await dev.update() >>> dev.alias -Living Room +Living Room Bulb >>> dev.model L530 >>> dev.rssi diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 806fbaa42..cd1a5f713 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -1,10 +1,35 @@ -"""Module for holding connection parameters. +"""Configuration for connecting directly to a device without discovery. + +If you are connecting to a newer KASA or TAPO device you can get the device +via discovery or connect directly with :class:`DeviceConfig`. + +Discovery returns a list of discovered devices: + +>>> from kasa import Discover, Credentials, Device, DeviceConfig +>>> device = await Discover.discover_single( +>>> "127.0.0.3", +>>> credentials=Credentials("myusername", "mypassword"), +>>> discovery_timeout=10 +>>> ) +>>> print(device.alias) # Alias is None because update() has not been called +None + +>>> config_dict = device.config.to_dict() +>>> # DeviceConfig.to_dict() can be used to store for later +>>> print(config_dict) +{'host': '127.0.0.3', 'timeout': 5, 'credentials': Credentials(), 'connection_type'\ +: {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'login_version': 2},\ + 'uses_http': True} + +>>> later_device = await Device.connect(config=DeviceConfig.from_dict(config_dict)) +>>> print(later_device.alias) # Alias is available as connect() calls update() +Living Room Bulb -Note that this module does not work with from __future__ import annotations -due to it's use of type returned by fields() which becomes a string with the import. -https://bugs.python.org/issue39442 """ +# Note that this module does not work with from __future__ import annotations +# due to it's use of type returned by fields() which becomes a string with the import. +# https://bugs.python.org/issue39442 # ruff: noqa: FA100 import logging from dataclasses import asdict, dataclass, field, fields, is_dataclass diff --git a/kasa/discover.py b/kasa/discover.py index 0a3f3c92e..65c03b987 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -1,4 +1,81 @@ -"""Discovery module for TP-Link Smart Home devices.""" +"""Discover TPLink Smart Home devices. + +The main entry point for this library is :func:`Discover.discover()`, +which returns a dictionary of the found devices. The key is the IP address +of the device and the value contains ready-to-use, SmartDevice-derived +device object. + +:func:`discover_single()` can be used to initialize a single device given its +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 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. + +Discovery returns a dict of {ip: discovered devices}: + +>>> import asyncio +>>> from kasa import Discover, Credentials +>>> +>>> found_devices = await Discover.discover() +>>> [dev.model for dev in found_devices.values()] +['KP303(UK)', 'HS110(EU)', 'L530E', 'KL430(US)', 'HS220(US)'] + +Discovery can also be targeted to a specific broadcast address instead of +the default 255.255.255.255: + +>>> found_devices = await Discover.discover(target="127.0.0.255") +>>> print(len(found_devices)) +5 + +Basic information is available on the device from the discovery broadcast response +but it is important to call device.update() after discovery if you want to access +all the attributes without getting errors or None. + +>>> dev = found_devices["127.0.0.3"] +>>> dev.alias +None +>>> await dev.update() +>>> dev.alias +'Living Room Bulb' + +It is also possible to pass a coroutine to be executed for each found device: + +>>> async def print_dev_info(dev): +>>> await dev.update() +>>> print(f"Discovered {dev.alias} (model: {dev.model})") +>>> +>>> devices = await Discover.discover(on_discovered=print_dev_info) +Discovered Bedroom Power Strip (model: KP303(UK)) +Discovered Bedroom Lamp Plug (model: HS110(EU)) +Discovered Living Room Bulb (model: L530) +Discovered Bedroom Lightstrip (model: KL430(US)) +Discovered Living Room Dimmer Switch (model: HS220(US)) + +You can pass credentials for devices requiring authentication + +>>> devices = await Discover.discover( +>>> credentials=Credentials("myusername", "mypassword"), +>>> discovery_timeout=10 +>>> ) +>>> print(len(devices)) +5 + +Discovering a single device returns a kasa.Device object. + +>>> device = await Discover.discover_single( +>>> "127.0.0.1", +>>> credentials=Credentials("myusername", "mypassword"), +>>> discovery_timeout=10 +>>> ) +>>> device.model +'KP303(UK)' + +""" from __future__ import annotations @@ -198,45 +275,7 @@ def connection_lost(self, ex): # pragma: no cover class Discover: - """Discover TPLink Smart Home devices. - - The main entry point for this library is :func:`Discover.discover()`, - which returns a dictionary of the found devices. The key is the IP address - of the device and the value contains ready-to-use, SmartDevice-derived - device object. - - :func:`discover_single()` can be used to initialize a single device given its - 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 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: - - >>> import asyncio - >>> found_devices = asyncio.run(Discover.discover()) - >>> [dev.alias for dev in found_devices] - ['TP-LINK_Power Strip_CF69'] - - Discovery can also be targeted to a specific broadcast address instead of - the default 255.255.255.255: - - >>> asyncio.run(Discover.discover(target="192.168.8.255")) - - It is also possible to pass a coroutine to be executed for each found device: - - >>> async def print_alias(dev): - >>> print(f"Discovered {dev.alias}") - >>> devices = asyncio.run(Discover.discover(on_discovered=print_alias)) - - - """ + """Class for discovering devices.""" DISCOVERY_PORT = 9999 diff --git a/kasa/feature.py b/kasa/feature.py index 1f7d3f3d5..9863a39b5 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -30,7 +30,8 @@ class Type(Enum): #: Action triggers some action on device Action = auto() #: Number defines a numeric setting - #: See :ref:`range_getter`, :ref:`minimum_value`, and :ref:`maximum_value` + #: See :attr:`range_getter`, :attr:`Feature.minimum_value`, + #: and :attr:`maximum_value` Number = auto() #: Choice defines a setting with pre-defined values Choice = auto() diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index dfe48a12b..c7631763b 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -105,7 +105,7 @@ class IotDevice(Device): All devices provide several informational properties: >>> dev.alias - Kitchen + Bedroom Lamp Plug >>> dev.model HS110(EU) >>> dev.rssi diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index f6a9719db..fcecadd80 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -23,7 +23,7 @@ class IotLightStrip(IotBulb): >>> strip = IotLightStrip("127.0.0.1") >>> asyncio.run(strip.update()) >>> print(strip.alias) - KL430 pantry lightstrip + Bedroom Lightstrip Getting the length of the strip: diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index c7e789c67..a083faac8 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -32,7 +32,7 @@ class IotPlug(IotDevice): >>> plug = IotPlug("127.0.0.1") >>> asyncio.run(plug.update()) >>> plug.alias - Kitchen + Bedroom Lamp Plug Setting the LED state: diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 9cc31fae1..7c6368b02 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -55,7 +55,7 @@ class IotStrip(IotDevice): >>> strip = IotStrip("127.0.0.1") >>> asyncio.run(strip.update()) >>> strip.alias - TP-LINK_Power Strip_CF69 + Bedroom Power Strip All methods act on the whole strip: diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index e8fbeeece..044d60d50 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -396,6 +396,13 @@ async def get_device_for_fixture_protocol(fixture, protocol): return await get_device_for_fixture(fixture_info) +def get_fixture_info(fixture, protocol): + finfo = FixtureInfo(name=fixture, protocol=protocol, data={}) + for fixture_info in FIXTURE_DATA: + if finfo == fixture_info: + return fixture_info + + @pytest.fixture(params=filter_fixtures("main devices"), ids=idgenerator) async def dev(request) -> AsyncGenerator[Device, None]: """Device fixture. diff --git a/kasa/tests/discovery_fixtures.py b/kasa/tests/discovery_fixtures.py index 175c361a4..db9db2e8b 100644 --- a/kasa/tests/discovery_fixtures.py +++ b/kasa/tests/discovery_fixtures.py @@ -44,9 +44,14 @@ def _make_unsupported(device_family, encrypt_type): } -def parametrize_discovery(desc, *, data_root_filter, protocol_filter=None): +def parametrize_discovery( + desc, *, data_root_filter=None, protocol_filter=None, model_filter=None +): filtered_fixtures = filter_fixtures( - desc, data_root_filter=data_root_filter, protocol_filter=protocol_filter + desc, + data_root_filter=data_root_filter, + protocol_filter=protocol_filter, + model_filter=model_filter, ) return pytest.mark.parametrize( "discovery_mock", @@ -65,10 +70,14 @@ def parametrize_discovery(desc, *, data_root_filter, protocol_filter=None): params=filter_fixtures("discoverable", protocol_filter={"SMART", "IOT"}), ids=idgenerator, ) -def discovery_mock(request, mocker): +async def discovery_mock(request, mocker): """Mock discovery and patch protocol queries to use Fake protocols.""" fixture_info: FixtureInfo = request.param - fixture_data = fixture_info.data + yield patch_discovery({"127.0.0.123": fixture_info}, mocker) + + +def create_discovery_mock(ip: str, fixture_data: dict): + """Mock discovery and patch protocol queries to use Fake protocols.""" @dataclass class _DiscoveryMock: @@ -79,6 +88,7 @@ class _DiscoveryMock: query_data: dict device_type: str encrypt_type: str + _datagram: bytes login_version: int | None = None port_override: int | None = None @@ -94,13 +104,14 @@ class _DiscoveryMock: + json_dumps(discovery_data).encode() ) dm = _DiscoveryMock( - "127.0.0.123", + ip, 80, 20002, discovery_data, fixture_data, device_type, encrypt_type, + datagram, login_version, ) else: @@ -111,45 +122,87 @@ class _DiscoveryMock: login_version = None datagram = XorEncryption.encrypt(json_dumps(discovery_data))[4:] dm = _DiscoveryMock( - "127.0.0.123", + ip, 9999, 9999, discovery_data, fixture_data, device_type, encrypt_type, + datagram, login_version, ) + return dm + + +def patch_discovery(fixture_infos: dict[str, FixtureInfo], mocker): + """Mock discovery and patch protocol queries to use Fake protocols.""" + discovery_mocks = { + ip: create_discovery_mock(ip, fixture_info.data) + for ip, fixture_info in fixture_infos.items() + } + protos = { + ip: FakeSmartProtocol(fixture_info.data, fixture_info.name) + if "SMART" in fixture_info.protocol + else FakeIotProtocol(fixture_info.data, fixture_info.name) + for ip, fixture_info in fixture_infos.items() + } + first_ip = list(fixture_infos.keys())[0] + first_host = None + async def mock_discover(self): - port = ( - dm.port_override - if dm.port_override and dm.discovery_port != 20002 - else dm.discovery_port - ) - self.datagram_received( - datagram, - (dm.ip, port), - ) + """Call datagram_received for all mock fixtures. + + Handles test cases modifying the ip and hostname of the first fixture + for discover_single testing. + """ + for ip, dm in discovery_mocks.items(): + first_ip = list(discovery_mocks.values())[0].ip + fixture_info = fixture_infos[ip] + # Ip of first fixture could have been modified by a test + if dm.ip == first_ip: + # hostname could have been used + host = first_host if first_host else first_ip + else: + host = dm.ip + # update the protos for any host testing or the test overriding the first ip + protos[host] = ( + FakeSmartProtocol(fixture_info.data, fixture_info.name) + if "SMART" in fixture_info.protocol + else FakeIotProtocol(fixture_info.data, fixture_info.name) + ) + port = ( + dm.port_override + if dm.port_override and dm.discovery_port != 20002 + else dm.discovery_port + ) + self.datagram_received( + dm._datagram, + (dm.ip, port), + ) + + async def _query(self, request, retry_count: int = 3): + return await protos[self._host].query(request) + def _getaddrinfo(host, *_, **__): + nonlocal first_host, first_ip + first_host = host # Store the hostname used by discover single + first_ip = list(discovery_mocks.values())[ + 0 + ].ip # ip could have been overridden in test + return [(None, None, None, None, (first_ip, 0))] + + mocker.patch("kasa.IotProtocol.query", _query) + mocker.patch("kasa.SmartProtocol.query", _query) mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) mocker.patch( "socket.getaddrinfo", - side_effect=lambda *_, **__: [(None, None, None, None, (dm.ip, 0))], + # side_effect=lambda *_, **__: [(None, None, None, None, (first_ip, 0))], + side_effect=_getaddrinfo, ) - - if "SMART" in fixture_info.protocol: - proto = FakeSmartProtocol(fixture_data, fixture_info.name) - else: - proto = FakeIotProtocol(fixture_data) - - async def _query(request, retry_count: int = 3): - return await proto.query(request) - - mocker.patch("kasa.IotProtocol.query", side_effect=_query) - mocker.patch("kasa.SmartProtocol.query", side_effect=_query) - - yield dm + # Only return the first discovery mock to be used for testing discover single + return discovery_mocks[first_ip] @pytest.fixture( diff --git a/kasa/tests/fakeprotocol_iot.py b/kasa/tests/fakeprotocol_iot.py index ac898c0a1..806e52099 100644 --- a/kasa/tests/fakeprotocol_iot.py +++ b/kasa/tests/fakeprotocol_iot.py @@ -3,7 +3,7 @@ from ..deviceconfig import DeviceConfig from ..iotprotocol import IotProtocol -from ..xortransport import XorTransport +from ..protocol import BaseTransport _LOGGER = logging.getLogger(__name__) @@ -178,17 +178,26 @@ def success(res): class FakeIotProtocol(IotProtocol): - def __init__(self, info): + def __init__(self, info, fixture_name=None): super().__init__( - transport=XorTransport( - config=DeviceConfig("127.0.0.123"), - ) + transport=FakeIotTransport(info, fixture_name), ) + + async def query(self, request, retry_count: int = 3): + """Implement query here so tests can still patch IotProtocol.query.""" + resp_dict = await self._query(request, retry_count) + return resp_dict + + +class FakeIotTransport(BaseTransport): + def __init__(self, info, fixture_name=None): + super().__init__(config=DeviceConfig("127.0.0.123")) info = copy.deepcopy(info) self.discovery_data = info + self.fixture_name = fixture_name self.writer = None self.reader = None - proto = copy.deepcopy(FakeIotProtocol.baseproto) + proto = copy.deepcopy(FakeIotTransport.baseproto) for target in info: # print("target %s" % target) @@ -220,6 +229,14 @@ def __init__(self, info): self.proto = proto + @property + def default_port(self) -> int: + return 9999 + + @property + def credentials_hash(self) -> str: + return "" + def set_alias(self, x, child_ids=None): if child_ids is None: child_ids = [] @@ -367,7 +384,7 @@ def light_state(self, x, *args): "smartlife.iot.common.cloud": CLOUD_MODULE, } - async def query(self, request, port=9999): + async def send(self, request, port=9999): proto = self.proto # collect child ids from context @@ -414,3 +431,9 @@ def get_response_for_command(cmd): response.update(get_response_for_module(target)) return copy.deepcopy(response) + + async def close(self) -> None: + pass + + async def reset(self) -> None: + pass diff --git a/kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.json b/kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.json index 4708d5026..99cba2880 100644 --- a/kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.json +++ b/kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.json @@ -11,7 +11,7 @@ "system": { "get_sysinfo": { "active_mode": "schedule", - "alias": "Kitchen", + "alias": "Bedroom Lamp Plug", "dev_name": "Wi-Fi Smart Plug With Energy Monitoring", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/kasa/tests/fixtures/HS220(US)_1.0_1.5.7.json b/kasa/tests/fixtures/HS220(US)_1.0_1.5.7.json index 7c1662207..eef806fb4 100644 --- a/kasa/tests/fixtures/HS220(US)_1.0_1.5.7.json +++ b/kasa/tests/fixtures/HS220(US)_1.0_1.5.7.json @@ -28,7 +28,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Living room left dimmer", + "alias": "Living Room Dimmer Switch", "brightness": 25, "dev_name": "Smart Wi-Fi Dimmer", "deviceId": "000000000000000000000000000000000000000", diff --git a/kasa/tests/fixtures/HS220(US)_2.0_1.0.3.json b/kasa/tests/fixtures/HS220(US)_2.0_1.0.3.json index d8ca213ef..61e3d84e7 100644 --- a/kasa/tests/fixtures/HS220(US)_2.0_1.0.3.json +++ b/kasa/tests/fixtures/HS220(US)_2.0_1.0.3.json @@ -17,7 +17,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Living Room Lights", + "alias": "Living Room Dimmer Switch", "brightness": 100, "dev_name": "Wi-Fi Smart Dimmer", "deviceId": "0000000000000000000000000000000000000000", diff --git a/kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json b/kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json index f12e7d500..793452ae4 100644 --- a/kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json +++ b/kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json @@ -23,7 +23,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "KL430 pantry lightstrip", + "alias": "Bedroom Lightstrip", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.json b/kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.json index c6d632f09..d02d766b6 100644 --- a/kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.json +++ b/kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.json @@ -1,7 +1,7 @@ { "system": { "get_sysinfo": { - "alias": "TP-LINK_Power Strip_CF69", + "alias": "Bedroom Power Strip", "child_num": 3, "children": [ { diff --git a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json index 7e8788dfa..0e0ad2fa6 100644 --- a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json +++ b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json @@ -175,7 +175,7 @@ "longitude": 0, "mac": "5C-E9-31-00-00-00", "model": "L530", - "nickname": "TGl2aW5nIFJvb20=", + "nickname": "TGl2aW5nIFJvb20gQnVsYg==", "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Europe/Berlin", diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 2dea2004d..4edcf488a 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -107,7 +107,6 @@ async def test_type_unknown(): @pytest.mark.parametrize("custom_port", [123, None]) -# @pytest.mark.parametrize("discovery_mock", [("127.0.0.1",123), ("127.0.0.1",None)], indirect=True) async def test_discover_single(discovery_mock, custom_port, mocker): """Make sure that discover_single returns an initialized SmartDevice instance.""" host = "127.0.0.1" @@ -115,7 +114,8 @@ async def test_discover_single(discovery_mock, custom_port, mocker): discovery_mock.port_override = custom_port device_class = Discover._get_device_class(discovery_mock.discovery_data) - update_mock = mocker.patch.object(device_class, "update") + # discovery_mock patches protocol query methods so use spy here. + update_mock = mocker.spy(device_class, "update") x = await Discover.discover_single( host, port=custom_port, credentials=Credentials() @@ -123,6 +123,7 @@ async def test_discover_single(discovery_mock, custom_port, mocker): assert issubclass(x.__class__, Device) assert x._discovery_info is not None assert x.port == custom_port or x.port == discovery_mock.default_port + # Make sure discovery does not call update() assert update_mock.call_count == 0 if discovery_mock.default_port == 80: assert x.alias is None diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index fa1ae2225..7a5f8e19b 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -3,8 +3,11 @@ import pytest import xdoctest -from kasa import Discover -from kasa.tests.conftest import get_device_for_fixture_protocol +from kasa.tests.conftest import ( + get_device_for_fixture_protocol, + get_fixture_info, + patch_discovery, +) def test_bulb_examples(mocker): @@ -62,34 +65,39 @@ def test_lightstrip_examples(mocker): assert not res["failed"] -def test_discovery_examples(mocker): +def test_discovery_examples(readmes_mock): """Test discovery examples.""" - p = asyncio.run(get_device_for_fixture_protocol("KP303(UK)_1.0_1.0.3.json", "IOT")) - - mocker.patch("kasa.discover.Discover.discover", return_value=[p]) res = xdoctest.doctest_module("kasa.discover", "all") + assert res["n_passed"] > 0 assert not res["failed"] -def test_tutorial_examples(mocker, top_level_await): +def test_deviceconfig_examples(readmes_mock): + """Test discovery examples.""" + res = xdoctest.doctest_module("kasa.deviceconfig", "all") + assert res["n_passed"] > 0 + assert not res["failed"] + + +def test_tutorial_examples(readmes_mock): """Test discovery examples.""" - a = asyncio.run( - get_device_for_fixture_protocol("L530E(EU)_3.0_1.1.6.json", "SMART") - ) - b = asyncio.run(get_device_for_fixture_protocol("HS110(EU)_1.0_1.2.5.json", "IOT")) - a.host = "127.0.0.1" - b.host = "127.0.0.2" - - # Note autospec does not work for staticmethods in python < 3.12 - # https://github.com/python/cpython/issues/102978 - mocker.patch( - "kasa.discover.Discover.discover_single", return_value=a, autospec=True - ) - mocker.patch.object(Discover, "discover", return_value=[a, b], autospec=True) res = xdoctest.doctest_module("docs/tutorial.py", "all") + assert res["n_passed"] > 0 assert not res["failed"] +@pytest.fixture +async def readmes_mock(mocker, top_level_await): + fixture_infos = { + "127.0.0.1": get_fixture_info("KP303(UK)_1.0_1.0.3.json", "IOT"), # Strip + "127.0.0.2": get_fixture_info("HS110(EU)_1.0_1.2.5.json", "IOT"), # Plug + "127.0.0.3": get_fixture_info("L530E(EU)_3.0_1.1.6.json", "SMART"), # Bulb + "127.0.0.4": get_fixture_info("KL430(US)_1.0_1.0.10.json", "IOT"), # Lightstrip + "127.0.0.5": get_fixture_info("HS220(US)_1.0_1.5.7.json", "IOT"), # Dimmer + } + yield patch_discovery(fixture_infos, mocker) + + @pytest.fixture def top_level_await(mocker): """Fixture to enable top level awaits in doctests. @@ -99,19 +107,26 @@ def top_level_await(mocker): """ import ast from inspect import CO_COROUTINE + from types import CodeType orig_exec = exec orig_eval = eval orig_compile = compile def patch_exec(source, globals=None, locals=None, /, **kwargs): - if source.co_flags & CO_COROUTINE == CO_COROUTINE: + if ( + isinstance(source, CodeType) + and source.co_flags & CO_COROUTINE == CO_COROUTINE + ): asyncio.run(orig_eval(source, globals, locals)) else: orig_exec(source, globals, locals, **kwargs) def patch_eval(source, globals=None, locals=None, /, **kwargs): - if source.co_flags & CO_COROUTINE == CO_COROUTINE: + if ( + isinstance(source, CodeType) + and source.co_flags & CO_COROUTINE == CO_COROUTINE + ): return asyncio.run(orig_eval(source, globals, locals, **kwargs)) else: return orig_eval(source, globals, locals, **kwargs) From 30e37038d7aae51e75867d35e8a1677a0241c2bc Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 3 Jun 2024 17:46:38 +0200 Subject: [PATCH 446/892] Fix passing custom port for dump_devinfo (#938) --- devtools/dump_devinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index a6b27e952..c30ee96f8 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -207,7 +207,7 @@ async def handle_device(basedir, autosave, device: Device, batch_size: int): + " Do not use this flag unless you are sure you know what it means." ), ) -@click.option("--port", help="Port override") +@click.option("--port", help="Port override", type=int) async def cli( host, target, From bfba7a347fbdefa3c33bbbf369606195b96b4dd9 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 3 Jun 2024 18:52:15 +0200 Subject: [PATCH 447/892] Add fixture for S505D (#947) By courtesy of @steveredden: https://github.com/python-kasa/python-kasa/issues/888#issuecomment-2145193072 --- README.md | 2 +- SUPPORTED.md | 2 + kasa/tests/device_fixtures.py | 1 + .../fixtures/smart/S505D(US)_1.0_1.1.0.json | 262 ++++++++++++++++++ 4 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json diff --git a/README.md b/README.md index 3551a1ee1..31bd09495 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: P100, P110, P125M, P135, TP15 - **Power Strips**: P300, TP25 -- **Wall Switches**: S500D, S505 +- **Wall Switches**: S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Hubs**: H100 diff --git a/SUPPORTED.md b/SUPPORTED.md index f3c505e4c..e820ae913 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -177,6 +177,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (US) / Firmware: 1.0.5 - **S505** - Hardware: 1.0 (US) / Firmware: 1.0.2 +- **S505D** + - Hardware: 1.0 (US) / Firmware: 1.1.0 ### Bulbs diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 044d60d50..0bfdfda99 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -95,6 +95,7 @@ "KS240", "S500D", "S505", + "S505D", } SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} diff --git a/kasa/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json b/kasa/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json new file mode 100644 index 000000000..97486d456 --- /dev/null +++ b/kasa/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json @@ -0,0 +1,262 @@ +{ + "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": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "S505D(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": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "matter", + "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": 1 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "switch_s500d", + "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.1.0 Build 231024 Rel.201030", + "has_set_location_info": false, + "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": "S505D", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/Chicago", + "rssi": -39, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.TAPOSWITCH" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 952082825 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:-00000000000000.000" + }, + "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": [], + "start_index": 0, + "sum": 0, + "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": "S505D", + "device_type": "SMART.TAPOSWITCH", + "is_klap": true + } + } +} From be5202ccb760490e9a163894ad9d26b73abd2ba3 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 3 Jun 2024 21:06:54 +0300 Subject: [PATCH 448/892] Make device initialisation easier by reducing required imports (#936) Adds username and password arguments to discovery to remove the need to import Credentials. Creates TypeAliases in Device for connection configuration classes and DeviceType. Using the API with these changes will only require importing either Discover or Device depending on whether using Discover.discover() or Device.connect() to initialize and interact with the API. --- devtools/dump_devinfo.py | 4 +- docs/source/guides.md | 2 + docs/source/reference.md | 52 ++++++++++++++++++++++-- docs/tutorial.py | 6 +-- kasa/__init__.py | 25 +++++++----- kasa/cli.py | 18 ++++----- kasa/device.py | 29 +++++++++++-- kasa/deviceconfig.py | 40 +++++++++--------- kasa/discover.py | 67 ++++++++++++++++++++----------- kasa/tests/test_device.py | 34 +++++++++++++--- kasa/tests/test_device_factory.py | 16 ++++---- kasa/tests/test_discovery.py | 60 ++++++++++++++++++++++++++- 12 files changed, 263 insertions(+), 90 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index c30ee96f8..34a067871 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -231,11 +231,11 @@ async def cli( if host is not None: if discovery_info: click.echo("Host and discovery info given, trying connect on %s." % host) - from kasa import ConnectionType, DeviceConfig + from kasa import DeviceConfig, DeviceConnectionParameters di = json.loads(discovery_info) dr = DiscoveryResult(**di) - connection_type = ConnectionType.from_values( + connection_type = DeviceConnectionParameters.from_values( dr.device_type, dr.mgt_encrypt_schm.encrypt_type, dr.mgt_encrypt_schm.lv, diff --git a/docs/source/guides.md b/docs/source/guides.md index 4206c8a92..f45412d19 100644 --- a/docs/source/guides.md +++ b/docs/source/guides.md @@ -6,12 +6,14 @@ This page contains guides of how to perform common actions using the library. ```{eval-rst} .. automodule:: kasa.discover + :noindex: ``` ## Connect without discovery ```{eval-rst} .. automodule:: kasa.deviceconfig + :noindex: ``` ## Get Energy Consumption and Usage Statistics diff --git a/docs/source/reference.md b/docs/source/reference.md index 9b117298e..ffbfab47d 100644 --- a/docs/source/reference.md +++ b/docs/source/reference.md @@ -1,10 +1,11 @@ # API Reference -```{currentmodule} kasa -``` - ## Discover + +```{module} kasa.discover +``` + ```{eval-rst} .. autoclass:: kasa.Discover :members: @@ -12,8 +13,51 @@ ## Device +```{module} kasa.device +``` + +```{eval-rst} +.. autoclass:: Device + :members: + :undoc-members: +``` + + +## Device Config + +```{module} kasa.credentials +``` + +```{eval-rst} +.. autoclass:: Credentials + :members: + :undoc-members: +``` + +```{module} kasa.deviceconfig +``` + +```{eval-rst} +.. autoclass:: DeviceConfig + :members: + :undoc-members: +``` + + +```{eval-rst} +.. autoclass:: kasa.DeviceFamily + :members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.DeviceConnection + :members: + :undoc-members: +``` + ```{eval-rst} -.. autoclass:: kasa.Device +.. autoclass:: kasa.DeviceEncryption :members: :undoc-members: ``` diff --git a/docs/tutorial.py b/docs/tutorial.py index 8984d2cab..f963ac42e 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -11,11 +11,11 @@ Most newer devices require your TP-Link cloud username and password, but this can be omitted for older devices. ->>> from kasa import Device, Discover, Credentials +>>> from kasa import Discover :func:`~kasa.Discover.discover` returns a dict[str,Device] of devices on your network: ->>> devices = await Discover.discover(credentials=Credentials("user@example.com", "great_password")) +>>> devices = await Discover.discover(username="user@example.com", password="great_password") >>> for dev in devices.values(): >>> await dev.update() >>> print(dev.host) @@ -27,7 +27,7 @@ :meth:`~kasa.Discover.discover_single` returns a single device by hostname: ->>> dev = await Discover.discover_single("127.0.0.3", credentials=Credentials("user@example.com", "great_password")) +>>> dev = await Discover.discover_single("127.0.0.3", username="user@example.com", password="great_password") >>> await dev.update() >>> dev.alias Living Room Bulb diff --git a/kasa/__init__.py b/kasa/__init__.py index d436155eb..d383d3a79 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -20,10 +20,10 @@ from kasa.device import Device from kasa.device_type import DeviceType from kasa.deviceconfig import ( - ConnectionType, DeviceConfig, - DeviceFamilyType, - EncryptType, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, ) from kasa.discover import Discover from kasa.emeterstatus import EmeterStatus @@ -71,9 +71,9 @@ "TimeoutError", "Credentials", "DeviceConfig", - "ConnectionType", - "EncryptType", - "DeviceFamilyType", + "DeviceConnectionParameters", + "DeviceEncryptionType", + "DeviceFamily", ] from . import iot @@ -89,11 +89,14 @@ "SmartDimmer": iot.IotDimmer, "SmartBulbPreset": IotLightPreset, } -deprecated_exceptions = { +deprecated_classes = { "SmartDeviceException": KasaException, "UnsupportedDeviceException": UnsupportedDeviceError, "AuthenticationException": AuthenticationError, "TimeoutException": TimeoutError, + "ConnectionType": DeviceConnectionParameters, + "EncryptType": DeviceEncryptionType, + "DeviceFamilyType": DeviceFamily, } @@ -112,8 +115,8 @@ def __getattr__(name): stacklevel=1, ) return new_class - if name in deprecated_exceptions: - new_class = deprecated_exceptions[name] + if name in deprecated_classes: + new_class = deprecated_classes[name] msg = f"{name} is deprecated, use {new_class.__name__} instead" warn(msg, DeprecationWarning, stacklevel=1) return new_class @@ -133,6 +136,10 @@ def __getattr__(name): UnsupportedDeviceException = UnsupportedDeviceError AuthenticationException = AuthenticationError TimeoutException = TimeoutError + ConnectionType = DeviceConnectionParameters + EncryptType = DeviceEncryptionType + DeviceFamilyType = DeviceFamily + # Instanstiate all classes so the type checkers catch abstract issues from . import smart diff --git a/kasa/cli.py b/kasa/cli.py index f56aaccd4..8919f174d 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -18,13 +18,13 @@ from kasa import ( AuthenticationError, - ConnectionType, Credentials, Device, DeviceConfig, - DeviceFamilyType, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, Discover, - EncryptType, Feature, KasaException, Module, @@ -87,11 +87,9 @@ def wrapper(message=None, *args, **kwargs): "smart.bulb": SmartDevice, } -ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in EncryptType] +ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] -DEVICE_FAMILY_TYPES = [ - device_family_type.value for device_family_type in DeviceFamilyType -] +DEVICE_FAMILY_TYPES = [device_family_type.value for device_family_type in DeviceFamily] # Block list of commands which require no update SKIP_UPDATE_COMMANDS = ["wifi", "raw-command", "command"] @@ -374,9 +372,9 @@ def _nop_echo(*args, **kwargs): if type is not None: dev = TYPE_TO_CLASS[type](host) elif device_family and encrypt_type: - ctype = ConnectionType( - DeviceFamilyType(device_family), - EncryptType(encrypt_type), + ctype = DeviceConnectionParameters( + DeviceFamily(device_family), + DeviceEncryptionType(encrypt_type), login_version, ) config = DeviceConfig( diff --git a/kasa/device.py b/kasa/device.py index d462239d2..10722f69b 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -9,9 +9,16 @@ from typing import TYPE_CHECKING, Any, Mapping, Sequence from warnings import warn -from .credentials import Credentials +from typing_extensions import TypeAlias + +from .credentials import Credentials as _Credentials from .device_type import DeviceType -from .deviceconfig import DeviceConfig +from .deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) from .emeterstatus import EmeterStatus from .exceptions import KasaException from .feature import Feature @@ -51,6 +58,22 @@ class Device(ABC): or :func:`Discover.discover_single()`. """ + # All types required to create devices directly via connect are aliased here + # to avoid consumers having to do multiple imports. + + #: The type of device + Type: TypeAlias = DeviceType + #: The credentials for authentication + Credentials: TypeAlias = _Credentials + #: Configuration for connecting to the device + Config: TypeAlias = DeviceConfig + #: The family of the device, e.g. SMART.KASASWITCH. + Family: TypeAlias = DeviceFamily + #: The encryption for the device, e.g. Klap or Aes + EncryptionType: TypeAlias = DeviceEncryptionType + #: The connection type for the device. + ConnectionParameters: TypeAlias = DeviceConnectionParameters + def __init__( self, host: str, @@ -166,7 +189,7 @@ def port(self) -> int: return self.protocol._transport._port @property - def credentials(self) -> Credentials | None: + def credentials(self) -> _Credentials | None: """The device credentials.""" return self.protocol._transport._credentials diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index cd1a5f713..a04a81d09 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -5,11 +5,11 @@ Discovery returns a list of discovered devices: ->>> from kasa import Discover, Credentials, Device, DeviceConfig +>>> from kasa import Discover, Device >>> device = await Discover.discover_single( >>> "127.0.0.3", ->>> credentials=Credentials("myusername", "mypassword"), ->>> discovery_timeout=10 +>>> username="user@example.com", +>>> password="great_password", >>> ) >>> print(device.alias) # Alias is None because update() has not been called None @@ -21,7 +21,7 @@ : {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'login_version': 2},\ 'uses_http': True} ->>> later_device = await Device.connect(config=DeviceConfig.from_dict(config_dict)) +>>> later_device = await Device.connect(config=Device.Config.from_dict(config_dict)) >>> print(later_device.alias) # Alias is available as connect() calls update() Living Room Bulb @@ -45,7 +45,7 @@ _LOGGER = logging.getLogger(__name__) -class EncryptType(Enum): +class DeviceEncryptionType(Enum): """Encrypt type enum.""" Klap = "KLAP" @@ -53,7 +53,7 @@ class EncryptType(Enum): Xor = "XOR" -class DeviceFamilyType(Enum): +class DeviceFamily(Enum): """Encrypt type enum.""" IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH" @@ -105,11 +105,11 @@ def _dataclass_to_dict(in_val): @dataclass -class ConnectionType: +class DeviceConnectionParameters: """Class to hold the the parameters determining connection type.""" - device_family: DeviceFamilyType - encryption_type: EncryptType + device_family: DeviceFamily + encryption_type: DeviceEncryptionType login_version: Optional[int] = None @staticmethod @@ -117,12 +117,12 @@ def from_values( device_family: str, encryption_type: str, login_version: Optional[int] = None, - ) -> "ConnectionType": + ) -> "DeviceConnectionParameters": """Return connection parameters from string values.""" try: - return ConnectionType( - DeviceFamilyType(device_family), - EncryptType(encryption_type), + return DeviceConnectionParameters( + DeviceFamily(device_family), + DeviceEncryptionType(encryption_type), login_version, ) except (ValueError, TypeError) as ex: @@ -132,7 +132,7 @@ def from_values( ) from ex @staticmethod - def from_dict(connection_type_dict: Dict[str, str]) -> "ConnectionType": + def from_dict(connection_type_dict: Dict[str, str]) -> "DeviceConnectionParameters": """Return connection parameters from dict.""" if ( isinstance(connection_type_dict, dict) @@ -141,7 +141,7 @@ def from_dict(connection_type_dict: Dict[str, str]) -> "ConnectionType": ): if login_version := connection_type_dict.get("login_version"): login_version = int(login_version) # type: ignore[assignment] - return ConnectionType.from_values( + return DeviceConnectionParameters.from_values( device_family, encryption_type, login_version, # type: ignore[arg-type] @@ -180,9 +180,9 @@ class DeviceConfig: #: The protocol specific type of connection. Defaults to the legacy type. batch_size: Optional[int] = None #: The batch size for protoools supporting multiple request batches. - connection_type: ConnectionType = field( - default_factory=lambda: ConnectionType( - DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Xor, 1 + connection_type: DeviceConnectionParameters = field( + default_factory=lambda: DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor, 1 ) ) #: True if the device uses http. Consumers should retrieve rather than set this @@ -195,8 +195,8 @@ class DeviceConfig: def __post_init__(self): if self.connection_type is None: - self.connection_type = ConnectionType( - DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Xor + self.connection_type = DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor ) def to_dict( diff --git a/kasa/discover.py b/kasa/discover.py index 65c03b987..4930a68a8 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -18,17 +18,32 @@ Discovery returns a dict of {ip: discovered devices}: ->>> import asyncio >>> from kasa import Discover, Credentials >>> >>> found_devices = await Discover.discover() >>> [dev.model for dev in found_devices.values()] ['KP303(UK)', 'HS110(EU)', 'L530E', 'KL430(US)', 'HS220(US)'] +You can pass username and password for devices requiring authentication + +>>> devices = await Discover.discover( +>>> username="user@example.com", +>>> password="great_password", +>>> ) +>>> print(len(devices)) +5 + +You can also pass a :class:`kasa.Credentials` + +>>> creds = Credentials("user@example.com", "great_password") +>>> devices = await Discover.discover(credentials=creds) +>>> print(len(devices)) +5 + Discovery can also be targeted to a specific broadcast address instead of the default 255.255.255.255: ->>> found_devices = await Discover.discover(target="127.0.0.255") +>>> found_devices = await Discover.discover(target="127.0.0.255", credentials=creds) >>> print(len(found_devices)) 5 @@ -49,29 +64,16 @@ >>> await dev.update() >>> print(f"Discovered {dev.alias} (model: {dev.model})") >>> ->>> devices = await Discover.discover(on_discovered=print_dev_info) +>>> devices = await Discover.discover(on_discovered=print_dev_info, credentials=creds) Discovered Bedroom Power Strip (model: KP303(UK)) Discovered Bedroom Lamp Plug (model: HS110(EU)) Discovered Living Room Bulb (model: L530) Discovered Bedroom Lightstrip (model: KL430(US)) Discovered Living Room Dimmer Switch (model: HS220(US)) -You can pass credentials for devices requiring authentication - ->>> devices = await Discover.discover( ->>> credentials=Credentials("myusername", "mypassword"), ->>> discovery_timeout=10 ->>> ) ->>> print(len(devices)) -5 - Discovering a single device returns a kasa.Device object. ->>> device = await Discover.discover_single( ->>> "127.0.0.1", ->>> credentials=Credentials("myusername", "mypassword"), ->>> discovery_timeout=10 ->>> ) +>>> device = await Discover.discover_single("127.0.0.1", credentials=creds) >>> device.model 'KP303(UK)' @@ -98,7 +100,11 @@ get_device_class_from_sys_info, get_protocol, ) -from kasa.deviceconfig import ConnectionType, DeviceConfig, EncryptType +from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, +) from kasa.exceptions import ( KasaException, TimeoutError, @@ -296,6 +302,8 @@ async def discover( interface=None, on_unsupported=None, credentials=None, + username: str | None = None, + password: str | None = None, port=None, timeout=None, ) -> DeviceDict: @@ -323,11 +331,16 @@ async def discover( :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 credentials: Credentials for devices that require authentication. + username and password are ignored if provided. + :param username: Username for devices that require authentication + :param password: Password for devices that require 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 """ + if not credentials and username and password: + credentials = Credentials(username, password) loop = asyncio.get_event_loop() transport, protocol = await loop.create_datagram_endpoint( lambda: _DiscoverProtocol( @@ -367,6 +380,8 @@ async def discover_single( port: int | None = None, timeout: int | None = None, credentials: Credentials | None = None, + username: str | None = None, + password: str | None = None, ) -> Device: """Discover a single device by the given IP address. @@ -379,10 +394,15 @@ async def discover_single( :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 + :param credentials: Credentials for devices that require authentication. + username and password are ignored if provided. + :param username: Username for devices that require authentication + :param password: Password for devices that require authentication :rtype: SmartDevice :return: Object for querying/controlling found device. """ + if not credentials and username and password: + credentials = Credentials(username, password) loop = asyncio.get_event_loop() try: @@ -469,8 +489,9 @@ def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice: device = device_class(config.host, config=config) sys_info = info["system"]["get_sysinfo"] if device_type := sys_info.get("mic_type", sys_info.get("type")): - config.connection_type = ConnectionType.from_values( - device_family=device_type, encryption_type=EncryptType.Xor.value + config.connection_type = DeviceConnectionParameters.from_values( + device_family=device_type, + encryption_type=DeviceEncryptionType.Xor.value, ) device.protocol = get_protocol(config) # type: ignore[assignment] device.update_from_discover_info(info) @@ -502,7 +523,7 @@ def _get_device_instance( type_ = discovery_result.device_type try: - config.connection_type = ConnectionType.from_values( + config.connection_type = DeviceConnectionParameters.from_values( type_, discovery_result.mgt_encrypt_schm.encrypt_type, discovery_result.mgt_encrypt_schm.lv, diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index 354507be6..c6d412c73 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -116,13 +116,11 @@ def test_deprecated_devices(device_class, use_class): getattr(module, use_class.__name__) -@pytest.mark.parametrize( - "exceptions_class, use_class", kasa.deprecated_exceptions.items() -) -def test_deprecated_exceptions(exceptions_class, use_class): - msg = f"{exceptions_class} is deprecated, use {use_class.__name__} instead" +@pytest.mark.parametrize("deprecated_class, use_class", kasa.deprecated_classes.items()) +def test_deprecated_classes(deprecated_class, use_class): + msg = f"{deprecated_class} is deprecated, use {use_class.__name__} instead" with pytest.deprecated_call(match=msg): - getattr(kasa, exceptions_class) + getattr(kasa, deprecated_class) getattr(kasa, use_class.__name__) @@ -266,3 +264,27 @@ async def test_deprecated_light_preset_attributes(dev: Device): IotLightPreset(index=0, hue=100, brightness=100, saturation=0, color_temp=0), # type: ignore[call-arg] will_raise=exc, ) + + +async def test_device_type_aliases(): + """Test that the device type aliases in Device work.""" + + def _mock_connect(config, *args, **kwargs): + mock = Mock() + mock.config = config + return mock + + with patch("kasa.device_factory.connect", side_effect=_mock_connect): + dev = await Device.connect( + config=Device.Config( + host="127.0.0.1", + credentials=Device.Credentials(username="user", password="foobar"), # noqa: S106 + connection_type=Device.ConnectionParameters( + device_family=Device.Family.SmartKasaPlug, + encryption_type=Device.EncryptionType.Klap, + login_version=2, + ), + ) + ) + assert isinstance(dev.config, DeviceConfig) + assert DeviceType.Dimmer == Device.Type.Dimmer diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index bcadb7244..d5fd27e19 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -17,10 +17,10 @@ get_protocol, ) from kasa.deviceconfig import ( - ConnectionType, DeviceConfig, - DeviceFamilyType, - EncryptType, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, ) from kasa.discover import DiscoveryResult from kasa.smart.smartdevice import SmartDevice @@ -31,12 +31,12 @@ def _get_connection_type_device_class(discovery_info): device_class = Discover._get_device_class(discovery_info) dr = DiscoveryResult(**discovery_info["result"]) - connection_type = ConnectionType.from_values( + connection_type = DeviceConnectionParameters.from_values( dr.device_type, dr.mgt_encrypt_schm.encrypt_type ) else: - connection_type = ConnectionType.from_values( - DeviceFamilyType.IotSmartPlugSwitch.value, EncryptType.Xor.value + connection_type = DeviceConnectionParameters.from_values( + DeviceFamily.IotSmartPlugSwitch.value, DeviceEncryptionType.Xor.value ) device_class = Discover._get_device_class(discovery_info) @@ -137,7 +137,7 @@ async def test_connect_http_client(discovery_data, mocker): host=host, credentials=Credentials("foor", "bar"), connection_type=ctype ) dev = await connect(config=config) - if ctype.encryption_type != EncryptType.Xor: + if ctype.encryption_type != DeviceEncryptionType.Xor: assert dev.protocol._transport._http_client.client != http_client await dev.disconnect() @@ -148,7 +148,7 @@ async def test_connect_http_client(discovery_data, mocker): http_client=http_client, ) dev = await connect(config=config) - if ctype.encryption_type != EncryptType.Xor: + if ctype.encryption_type != DeviceEncryptionType.Xor: assert dev.protocol._transport._http_client.client == http_client await dev.disconnect() await http_client.close() diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 4edcf488a..b657b12ec 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -1,4 +1,6 @@ # type: ignore +# ruff: noqa: S106 + import asyncio import re import socket @@ -16,8 +18,8 @@ KasaException, ) from kasa.deviceconfig import ( - ConnectionType, DeviceConfig, + DeviceConnectionParameters, ) from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps from kasa.exceptions import AuthenticationError, UnsupportedDeviceError @@ -128,7 +130,7 @@ async def test_discover_single(discovery_mock, custom_port, mocker): if discovery_mock.default_port == 80: assert x.alias is None - ct = ConnectionType.from_values( + ct = DeviceConnectionParameters.from_values( discovery_mock.device_type, discovery_mock.encrypt_type, discovery_mock.login_version, @@ -164,6 +166,60 @@ async def test_discover_single_hostname(discovery_mock, mocker): x = await Discover.discover_single(host, credentials=Credentials()) +async def test_discover_credentials(mocker): + """Make sure that discover gives credentials precedence over un and pw.""" + host = "127.0.0.1" + mocker.patch("kasa.discover._DiscoverProtocol.wait_for_discovery_to_complete") + + def mock_discover(self, *_, **__): + self.discovered_devices = {host: MagicMock()} + + mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) + dp = mocker.spy(_DiscoverProtocol, "__init__") + + # Only credentials passed + await Discover.discover(credentials=Credentials(), timeout=0) + assert dp.mock_calls[0].kwargs["credentials"] == Credentials() + # Credentials and un/pw passed + await Discover.discover( + credentials=Credentials(), username="Foo", password="Bar", timeout=0 + ) + assert dp.mock_calls[1].kwargs["credentials"] == Credentials() + # Only un/pw passed + await Discover.discover(username="Foo", password="Bar", timeout=0) + assert dp.mock_calls[2].kwargs["credentials"] == Credentials("Foo", "Bar") + # Only un passed, credentials should be None + await Discover.discover(username="Foo", timeout=0) + assert dp.mock_calls[3].kwargs["credentials"] is None + + +async def test_discover_single_credentials(mocker): + """Make sure that discover_single gives credentials precedence over un and pw.""" + host = "127.0.0.1" + mocker.patch("kasa.discover._DiscoverProtocol.wait_for_discovery_to_complete") + + def mock_discover(self, *_, **__): + self.discovered_devices = {host: MagicMock()} + + mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) + dp = mocker.spy(_DiscoverProtocol, "__init__") + + # Only credentials passed + await Discover.discover_single(host, credentials=Credentials(), timeout=0) + assert dp.mock_calls[0].kwargs["credentials"] == Credentials() + # Credentials and un/pw passed + await Discover.discover_single( + host, credentials=Credentials(), username="Foo", password="Bar", timeout=0 + ) + assert dp.mock_calls[1].kwargs["credentials"] == Credentials() + # Only un/pw passed + await Discover.discover_single(host, username="Foo", password="Bar", timeout=0) + assert dp.mock_calls[2].kwargs["credentials"] == Credentials("Foo", "Bar") + # Only un passed, credentials should be None + await Discover.discover_single(host, username="Foo", timeout=0) + assert dp.mock_calls[3].kwargs["credentials"] is None + + async def test_discover_single_unsupported(unsupported_device_info, mocker): """Make sure that discover_single handles unsupported devices correctly.""" host = "127.0.0.1" From 22347381bca59f7e9547b19fee3943427b2a5d2b Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 3 Jun 2024 20:41:55 +0200 Subject: [PATCH 449/892] Do not raise on multi-request errors on child devices (#949) This will avoid crashing when some commands return an error on multi-requests on child devices. Idea from https://github.com/python-kasa/python-kasa/pull/900/files#r1624803457 --- kasa/smartprotocol.py | 4 +++- kasa/tests/test_smartprotocol.py | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index b1cde04df..545f8147a 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -402,7 +402,9 @@ async def query(self, request: str | dict, retry_count: int = 3) -> dict: ret_val = {} for multi_response in multi_responses: method = multi_response["method"] - self._handle_response_error_code(multi_response, method) + self._handle_response_error_code( + multi_response, method, raise_on_error=False + ) ret_val[method] = multi_response.get("result") return ret_val diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index a2bcacfa4..5a0eb0fa7 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -181,8 +181,9 @@ async def test_childdevicewrapper_multiplerequest_error(dummy_protocol, mocker): } wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol) mocker.patch.object(wrapped_protocol._transport, "send", return_value=mock_response) - with pytest.raises(KasaException): - await wrapped_protocol.query(DUMMY_QUERY) + res = await wrapped_protocol.query(DUMMY_QUERY) + assert res["get_device_info"] == {"foo": "bar"} + assert res["invalid_command"] == SmartErrorCode(-1001) @pytest.mark.parametrize("list_sum", [5, 10, 30]) From f890fcedc7e54a3a58cb717b74c6a611d7002f49 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 4 Jun 2024 19:18:23 +0200 Subject: [PATCH 450/892] Add P115 fixture (#950) --- README.md | 2 +- SUPPORTED.md | 2 + kasa/tests/device_fixtures.py | 3 +- .../fixtures/smart/P115(EU)_1.0_1.2.3.json | 386 ++++++++++++++++++ 4 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 kasa/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json diff --git a/README.md b/README.md index 31bd09495..78cddac7f 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ The following devices have been tested and confirmed as working. If your device ### Supported Tapo\* devices -- **Plugs**: P100, P110, P125M, P135, TP15 +- **Plugs**: P100, P110, P115, P125M, P135, TP15 - **Power Strips**: P300, TP25 - **Wall Switches**: S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E diff --git a/SUPPORTED.md b/SUPPORTED.md index e820ae913..252f075d3 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -156,6 +156,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.0.7 - Hardware: 1.0 (EU) / Firmware: 1.2.3 - Hardware: 1.0 (UK) / Firmware: 1.3.0 +- **P115** + - Hardware: 1.0 (EU) / Firmware: 1.2.3 - **P125M** - Hardware: 1.0 (US) / Firmware: 1.1.0 - **P135** diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 0bfdfda99..04b6d3917 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -75,6 +75,7 @@ PLUGS_SMART = { "P100", "P110", + "P115", "KP125M", "EP25", "P125M", @@ -114,7 +115,7 @@ THERMOSTATS_SMART = {"KE100"} WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} -WITH_EMETER_SMART = {"P110", "KP125M", "EP25"} +WITH_EMETER_SMART = {"P110", "P115", "KP125M", "EP25"} WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} DIMMABLE = {*BULBS, *DIMMERS} diff --git a/kasa/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json b/kasa/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json new file mode 100644 index 000000000..48cd46f2e --- /dev/null +++ b/kasa/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json @@ -0,0 +1,386 @@ +{ + "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 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P115(EU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": true, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "" + }, + "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": 1 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 9 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.3 Build 230425 Rel.142542", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "A8-42-A1-00-00-00", + "model": "P115", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 1621, + "overheated": false, + "power_protection_status": "normal", + "region": "UTC", + "rssi": -45, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "UTC", + "time_diff": 0, + "timestamp": 1717512486 + }, + "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_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": 8962, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-06-04 14:48:06", + "month_energy": 0, + "month_runtime": 6, + "today_energy": 0, + "today_runtime": 6 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_max_power": { + "max_power": 3895 + }, + "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 + }, + "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": "P115", + "device_type": "SMART.TAPOPLUG" + } + } +} From 40f2263770ac917c3a851e9c2e5ad01982ce4921 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 4 Jun 2024 19:24:53 +0200 Subject: [PATCH 451/892] Add some device fixtures (#948) Adds some device fixtures by courtesy of @jimboca, thanks! This is a slightly patched and rebased version of #441. --------- Co-authored-by: JimBo Co-authored-by: sdb9696 --- SUPPORTED.md | 3 + kasa/iot/iotlightstrip.py | 3 +- kasa/iot/modules/lightpreset.py | 7 +- kasa/tests/fixtures/HS110(US)_1.0_1.2.6.json | 37 ++++++++ kasa/tests/fixtures/HS300(US)_1.0_1.0.21.json | 89 +++++++++++++++++++ kasa/tests/fixtures/KL120(US)_1.0_1.8.11.json | 85 ++++++++++++++++++ kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json | 61 ++++++++++--- kasa/tests/test_bulb.py | 13 ++- 8 files changed, 281 insertions(+), 17 deletions(-) create mode 100644 kasa/tests/fixtures/HS110(US)_1.0_1.2.6.json create mode 100644 kasa/tests/fixtures/HS300(US)_1.0_1.0.21.json create mode 100644 kasa/tests/fixtures/KL120(US)_1.0_1.8.11.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 252f075d3..dd63dbc9e 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -29,6 +29,7 @@ Some newer Kasa devices require authentication. These are marked with ***>> strip.effect - {'brightness': 50, 'custom': 0, 'enable': 0, 'id': '', 'name': ''} + {'brightness': 100, 'custom': 0, 'enable': 0, + 'id': 'bCTItKETDFfrKANolgldxfgOakaarARs', 'name': 'Flicker'} .. note:: The device supports some features that are not currently implemented, diff --git a/kasa/iot/modules/lightpreset.py b/kasa/iot/modules/lightpreset.py index 49eca3b83..d9fbb7faf 100644 --- a/kasa/iot/modules/lightpreset.py +++ b/kasa/iot/modules/lightpreset.py @@ -45,6 +45,9 @@ def _post_update_hook(self): self._presets = { f"Light preset {index+1}": IotLightPreset(**vals) for index, vals in enumerate(self.data["preferred_state"]) + # Devices may list some light effects along with normal presets but these + # are handled by the LightEffect module so exclude preferred states with id + if "id" not in vals } self._preset_list = [self.PRESET_NOT_SET] self._preset_list.extend(self._presets.keys()) @@ -133,7 +136,9 @@ def query(self): def _deprecated_presets(self) -> list[IotLightPreset]: """Return a list of available bulb setting presets.""" return [ - IotLightPreset(**vals) for vals in self._device.sys_info["preferred_state"] + IotLightPreset(**vals) + for vals in self._device.sys_info["preferred_state"] + if "id" not in vals ] async def _deprecated_save_preset(self, preset: IotLightPreset): diff --git a/kasa/tests/fixtures/HS110(US)_1.0_1.2.6.json b/kasa/tests/fixtures/HS110(US)_1.0_1.2.6.json new file mode 100644 index 000000000..5e285e729 --- /dev/null +++ b/kasa/tests/fixtures/HS110(US)_1.0_1.2.6.json @@ -0,0 +1,37 @@ +{ + "emeter": { + "get_realtime": { + "current": 0.128037, + "err_code": 0, + "power": 7.677094, + "total": 30.404, + "voltage": 118.917389 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "schedule", + "alias": "Home Google WiFi HS110", + "dev_name": "Wi-Fi Smart Plug With Energy Monitoring", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "fwId": "00000000000000000000000000000000", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "00:00:00:00:00:00", + "model": "HS110(US)", + "oemId": "00000000000000000000000000000000", + "on_time": 14048150, + "relay_state": 1, + "rssi": -38, + "sw_ver": "1.2.6 Build 200727 Rel.121701", + "type": "IOT.SMARTPLUGSWITCH", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/HS300(US)_1.0_1.0.21.json b/kasa/tests/fixtures/HS300(US)_1.0_1.0.21.json new file mode 100644 index 000000000..388fadf35 --- /dev/null +++ b/kasa/tests/fixtures/HS300(US)_1.0_1.0.21.json @@ -0,0 +1,89 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 544, + "err_code": 0, + "power_mw": 62430, + "total_wh": 26889, + "voltage_mv": 118389 + } + }, + "system": { + "get_sysinfo": { + "alias": "TP-LINK_Power Strip_2CA9", + "child_num": 6, + "children": [ + { + "alias": "Home CameraPC", + "id": "800623145DFF1AA096363EFD161C2E661A9D8DED00", + "next_action": { + "type": -1 + }, + "on_time": 1449897, + "state": 1 + }, + { + "alias": "Home Firewalla", + "id": "800623145DFF1AA096363EFD161C2E661A9D8DED01", + "next_action": { + "type": -1 + }, + "on_time": 1449897, + "state": 1 + }, + { + "alias": "Home Cox modem", + "id": "800623145DFF1AA096363EFD161C2E661A9D8DED02", + "next_action": { + "type": -1 + }, + "on_time": 1449897, + "state": 1 + }, + { + "alias": "Home rpi3-2", + "id": "800623145DFF1AA096363EFD161C2E661A9D8DED03", + "next_action": { + "type": -1 + }, + "on_time": 1449897, + "state": 1 + }, + { + "alias": "Home Camera Switch", + "id": "800623145DFF1AA096363EFD161C2E661A9D8DED05", + "next_action": { + "type": -1 + }, + "on_time": 1449897, + "state": 1 + }, + { + "alias": "Home Network Switch", + "id": "800623145DFF1AA096363EFD161C2E661A9D8DED04", + "next_action": { + "type": -1 + }, + "on_time": 1449897, + "state": 1 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS300(US)", + "oemId": "00000000000000000000000000000000", + "rssi": -39, + "status": "new", + "sw_ver": "1.0.21 Build 210524 Rel.161309", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/KL120(US)_1.0_1.8.11.json b/kasa/tests/fixtures/KL120(US)_1.0_1.8.11.json new file mode 100644 index 000000000..1d8e1fce9 --- /dev/null +++ b/kasa/tests/fixtures/KL120(US)_1.0_1.8.11.json @@ -0,0 +1,85 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 7800 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "brightness": 70, + "color_temp": 3001, + "err_code": 0, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "Home Family Room Table", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Smart Wi-Fi LED Bulb with Tunable White Light", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "heapsize": 292140, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 0, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "light_state": { + "brightness": 70, + "color_temp": 3001, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL120(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 3500, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 50, + "color_temp": 5000, + "hue": 0, + "index": 1, + "saturation": 0 + }, + { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "index": 2, + "saturation": 0 + }, + { + "brightness": 1, + "color_temp": 2700, + "hue": 0, + "index": 3, + "saturation": 0 + } + ], + "rssi": -45, + "sw_ver": "1.8.11 Build 191113 Rel.105336" + } + } +} diff --git a/kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json b/kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json index 793452ae4..9b6d84136 100644 --- a/kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json +++ b/kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json @@ -7,8 +7,8 @@ "get_realtime": { "current_ma": 0, "err_code": 0, - "power_mw": 8729, - "total_wh": 21, + "power_mw": 2725, + "total_wh": 1193, "voltage_mv": 0 } }, @@ -22,7 +22,7 @@ }, "system": { "get_sysinfo": { - "active_mode": "none", + "active_mode": "schedule", "alias": "Bedroom Lightstrip", "ctrl_protocols": { "name": "Linkie", @@ -42,27 +42,66 @@ "latitude_i": 0, "length": 16, "light_state": { - "brightness": 50, - "color_temp": 3630, + "brightness": 15, + "color_temp": 2500, "hue": 0, "mode": "normal", "on_off": 1, "saturation": 0 }, "lighting_effect_state": { - "brightness": 50, + "brightness": 100, "custom": 0, "enable": 0, - "id": "", - "name": "" + "id": "bCTItKETDFfrKANolgldxfgOakaarARs", + "name": "Flicker" }, "longitude_i": 0, - "mic_mac": "CC32E5230F55", + "mic_mac": "CC32E5000000", "mic_type": "IOT.SMARTBULB", "model": "KL430(US)", "oemId": "00000000000000000000000000000000", - "preferred_state": [], - "rssi": -56, + "preferred_state": [ + { + "brightness": 100, + "custom": 0, + "id": "QglBhMShPHUAuxLqzNEefFrGiJwahOmz", + "index": 0, + "mode": 2 + }, + { + "brightness": 100, + "custom": 0, + "id": "bCTItKETDFfrKANolgldxfgOakaarARs", + "index": 1, + "mode": 2 + }, + { + "brightness": 34, + "color_temp": 0, + "hue": 7, + "index": 2, + "mode": 1, + "saturation": 49 + }, + { + "brightness": 25, + "color_temp": 0, + "hue": 4, + "index": 3, + "mode": 1, + "saturation": 100 + }, + { + "brightness": 15, + "color_temp": 2500, + "hue": 0, + "index": 4, + "mode": 1, + "saturation": 0 + } + ], + "rssi": -44, "status": "new", "sw_ver": "1.0.10 Build 200522 Rel.104340" } diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index b26530154..c78c539c9 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -283,12 +283,17 @@ async def test_ignore_default_not_set_without_color_mode_change_turn_on( @bulb_iot async def test_list_presets(dev: IotBulb): presets = dev.presets - assert len(presets) == len(dev.sys_info["preferred_state"]) - - for preset, raw in zip(presets, dev.sys_info["preferred_state"]): + # Light strip devices may list some light effects along with normal presets but these + # are handled by the LightEffect module so exclude preferred states with id + raw_presets = [ + pstate for pstate in dev.sys_info["preferred_state"] if "id" not in pstate + ] + assert len(presets) == len(raw_presets) + + for preset, raw in zip(presets, raw_presets): assert preset.index == raw["index"] - assert preset.hue == raw["hue"] assert preset.brightness == raw["brightness"] + assert preset.hue == raw["hue"] assert preset.saturation == raw["saturation"] assert preset.color_temp == raw["color_temp"] From 91de5e20ba3c8bbf9f2ce41d21c15aef3dda22f6 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 4 Jun 2024 20:49:01 +0300 Subject: [PATCH 452/892] Fix P100 errors on multi-requests (#930) Fixes an issue reported by @bdraco with the P100 not working in the latest branch: `[Errno None] Can not write request body for HOST_REDACTED, ClientOSError(None, 'Can not write request body for URL_REDACTED'))` Issue caused by the number of multi requests going above the default batch of 5 and the P100 not being able to handle the second multi request happening immediately as it closes the connection after each query (See https://github.com/python-kasa/python-kasa/pull/690 for similar issue). This introduces a small wait time on concurrent requests once the device has raised a ClientOSError. --- kasa/aestransport.py | 3 -- kasa/httpclient.py | 24 ++++++++++ kasa/tests/test_aestransport.py | 80 ++++++++++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 85624abc5..427801e15 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -6,7 +6,6 @@ from __future__ import annotations -import asyncio import base64 import hashlib import logging @@ -74,7 +73,6 @@ class AesTransport(BaseTransport): } CONTENT_LENGTH = "Content-Length" KEY_PAIR_CONTENT_LENGTH = 314 - BACKOFF_SECONDS_AFTER_LOGIN_ERROR = 1 def __init__( self, @@ -216,7 +214,6 @@ async def perform_login(self): self._default_credentials = get_default_credentials( DEFAULT_CREDENTIALS["TAPO"] ) - await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_LOGIN_ERROR) await self.perform_handshake() await self.try_login(self._get_login_params(self._default_credentials)) _LOGGER.debug( diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 55ac5a8ee..d1f4936e5 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -4,6 +4,7 @@ import asyncio import logging +import time from typing import Any, Dict import aiohttp @@ -28,12 +29,20 @@ def get_cookie_jar() -> aiohttp.CookieJar: class HttpClient: """HttpClient Class.""" + # Some devices (only P100 so far) close the http connection after each request + # and aiohttp doesn't seem to handle it. If a Client OS error is received the + # http client will start ensuring that sequential requests have a wait delay. + WAIT_BETWEEN_REQUESTS_ON_OSERROR = 0.25 + def __init__(self, config: DeviceConfig) -> None: self._config = config self._client_session: aiohttp.ClientSession = None self._jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False) self._last_url = URL(f"http://{self._config.host}/") + self._wait_between_requests = 0.0 + self._last_request_time = 0.0 + @property def client(self) -> aiohttp.ClientSession: """Return the underlying http client.""" @@ -60,6 +69,14 @@ async def post( If the request is provided via the json parameter json will be returned. """ + # Once we know a device needs a wait between sequential queries always wait + # first rather than keep erroring then waiting. + if self._wait_between_requests: + now = time.time() + gap = now - self._last_request_time + if gap < self._wait_between_requests: + await asyncio.sleep(self._wait_between_requests - gap) + _LOGGER.debug("Posting to %s", url) response_data = None self._last_url = url @@ -89,6 +106,9 @@ async def post( response_data = json_loads(response_data.decode()) except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex: + if isinstance(ex, aiohttp.ClientOSError): + self._wait_between_requests = self.WAIT_BETWEEN_REQUESTS_ON_OSERROR + self._last_request_time = time.time() raise _ConnectionError( f"Device connection error: {self._config.host}: {ex}", ex ) from ex @@ -103,6 +123,10 @@ async def post( f"Unable to query the device: {self._config.host}: {ex}", ex ) from ex + # For performance only request system time if waiting is enabled + if self._wait_between_requests: + self._last_request_time = time.time() + return resp.status, response_data def get_cookie(self, cookie_name: str) -> str | None: diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index ffd32cb10..00bcb953d 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -24,6 +24,7 @@ AuthenticationError, KasaException, SmartErrorCode, + _ConnectionError, ) from ..httpclient import HttpClient @@ -137,7 +138,7 @@ async def test_login_errors(mocker, inner_error_codes, expectation, call_count): transport._state = TransportState.LOGIN_REQUIRED transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session - mocker.patch.object(transport, "BACKOFF_SECONDS_AFTER_LOGIN_ERROR", 0) + mocker.patch.object(transport._http_client, "WAIT_BETWEEN_REQUESTS_ON_OSERROR", 0) assert transport._token_url is None @@ -285,6 +286,68 @@ async def test_port_override(): assert str(transport._app_url) == "http://127.0.0.1:12345/app" +@pytest.mark.parametrize( + "request_delay, should_error, should_succeed", + [(0, False, True), (0.125, True, True), (0.3, True, True), (0.7, True, False)], + ids=["No error", "Error then succeed", "Two errors then succeed", "No succeed"], +) +async def test_device_closes_connection( + mocker, request_delay, should_error, should_succeed +): + """Test the delay logic in http client to deal with devices that close connections after each request. + + Currently only the P100 on older firmware. + """ + host = "127.0.0.1" + + # Speed up the test by dividing all times by a factor. Doesn't seem to work on windows + # but leaving here as a TODO to manipulate system time for testing. + speed_up_factor = 1 + default_delay = HttpClient.WAIT_BETWEEN_REQUESTS_ON_OSERROR / speed_up_factor + request_delay = request_delay / speed_up_factor + mock_aes_device = MockAesDevice( + host, 200, 0, 0, sequential_request_delay=request_delay + ) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) + + config = DeviceConfig(host, credentials=Credentials("foo", "bar")) + transport = AesTransport(config=config) + transport._http_client.WAIT_BETWEEN_REQUESTS_ON_OSERROR = default_delay + transport._state = TransportState.LOGIN_REQUIRED + transport._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + transport._token_url = transport._app_url.with_query( + f"token={mock_aes_device.token}" + ) + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + error_count = 0 + success = False + + # If the device errors without a delay then it should error immedately ( + 1) + # and then the number of times the default delay passes within the request delay window + expected_error_count = ( + 0 if not should_error else int(request_delay / default_delay) + 1 + ) + for _ in range(3): + try: + await transport.send(json_dumps(request)) + except _ConnectionError: + error_count += 1 + else: + success = True + + assert bool(transport._http_client._wait_between_requests) == should_error + assert bool(error_count) == should_error + assert error_count == expected_error_count + assert success == should_succeed + + class MockAesDevice: class _mock_response: def __init__(self, status, json: dict): @@ -313,6 +376,7 @@ def __init__( *, do_not_encrypt_response=False, send_response=None, + sequential_request_delay=0, ): self.host = host self.status_code = status_code @@ -323,6 +387,9 @@ def __init__( self.http_client = HttpClient(DeviceConfig(self.host)) self.inner_call_count = 0 self.token = "".join(random.choices(string.ascii_uppercase, k=32)) # noqa: S311 + self.sequential_request_delay = sequential_request_delay + self.last_request_time = None + self.sequential_error_raised = False @property def inner_error_code(self): @@ -332,10 +399,19 @@ def inner_error_code(self): return self._inner_error_code async def post(self, url: URL, params=None, json=None, data=None, *_, **__): + if self.sequential_request_delay and self.last_request_time: + now = time.time() + print(now - self.last_request_time) + if (now - self.last_request_time) < self.sequential_request_delay: + self.sequential_error_raised = True + raise aiohttp.ClientOSError("Test connection closed") if data: async for item in data: json = json_loads(item.decode()) - return await self._post(url, json) + res = await self._post(url, json) + if self.sequential_request_delay: + self.last_request_time = time.time() + return res async def _post(self, url: URL, json: dict[str, Any]): if json["method"] == "handshake": From 9deadaa520bf6f62cc31c33de49964a400f0152d Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 5 Jun 2024 10:59:01 +0300 Subject: [PATCH 453/892] Prepare 0.7.0.dev2 (#952) ## [0.7.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev2) (2024-06-05) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev1...0.7.0.dev2) **Implemented enhancements:** - Make device initialisation easier by reducing required imports [\#936](https://github.com/python-kasa/python-kasa/pull/936) (@sdb9696) **Fixed bugs:** - Do not raise on multi-request errors on child devices [\#949](https://github.com/python-kasa/python-kasa/pull/949) (@rytilahti) - Do not show a zero error code when cli exits from showing help [\#935](https://github.com/python-kasa/python-kasa/pull/935) (@rytilahti) - Initialize autooff features only when data is available [\#933](https://github.com/python-kasa/python-kasa/pull/933) (@rytilahti) - Fix P100 errors on multi-requests [\#930](https://github.com/python-kasa/python-kasa/pull/930) (@sdb9696) **Documentation updates:** - Update documentation structure and start migrating to markdown [\#934](https://github.com/python-kasa/python-kasa/pull/934) (@sdb9696) **Closed issues:** - Simplify instance creation API [\#927](https://github.com/python-kasa/python-kasa/issues/927) **Merged pull requests:** - Add P115 fixture [\#950](https://github.com/python-kasa/python-kasa/pull/950) (@rytilahti) - Add some device fixtures [\#948](https://github.com/python-kasa/python-kasa/pull/948) (@rytilahti) - Add fixture for S505D [\#947](https://github.com/python-kasa/python-kasa/pull/947) (@rytilahti) - Fix passing custom port for dump\_devinfo [\#938](https://github.com/python-kasa/python-kasa/pull/938) (@rytilahti) --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 820133428..e4142d4b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [0.7.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev2) (2024-06-05) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev1...0.7.0.dev2) + +**Implemented enhancements:** + +- Make device initialisation easier by reducing required imports [\#936](https://github.com/python-kasa/python-kasa/pull/936) (@sdb9696) + +**Fixed bugs:** + +- Do not raise on multi-request errors on child devices [\#949](https://github.com/python-kasa/python-kasa/pull/949) (@rytilahti) +- Do not show a zero error code when cli exits from showing help [\#935](https://github.com/python-kasa/python-kasa/pull/935) (@rytilahti) +- Initialize autooff features only when data is available [\#933](https://github.com/python-kasa/python-kasa/pull/933) (@rytilahti) +- Fix P100 errors on multi-requests [\#930](https://github.com/python-kasa/python-kasa/pull/930) (@sdb9696) + +**Documentation updates:** + +- Update documentation structure and start migrating to markdown [\#934](https://github.com/python-kasa/python-kasa/pull/934) (@sdb9696) + +**Closed issues:** + +- Simplify instance creation API [\#927](https://github.com/python-kasa/python-kasa/issues/927) + +**Merged pull requests:** + +- Add P115 fixture [\#950](https://github.com/python-kasa/python-kasa/pull/950) (@rytilahti) +- Add some device fixtures [\#948](https://github.com/python-kasa/python-kasa/pull/948) (@rytilahti) +- Add fixture for S505D [\#947](https://github.com/python-kasa/python-kasa/pull/947) (@rytilahti) +- Fix passing custom port for dump\_devinfo [\#938](https://github.com/python-kasa/python-kasa/pull/938) (@rytilahti) + ## [0.7.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev1) (2024-05-22) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev0...0.7.0.dev1) @@ -9,6 +39,10 @@ - Fix set\_state for common light modules [\#929](https://github.com/python-kasa/python-kasa/pull/929) (@sdb9696) - Add state feature for iot devices [\#924](https://github.com/python-kasa/python-kasa/pull/924) (@rytilahti) +**Merged pull requests:** + +- Prepare 0.7.0.dev1 [\#931](https://github.com/python-kasa/python-kasa/pull/931) (@rytilahti) + ## [0.7.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev0) (2024-05-19) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2.1...0.7.0.dev0) diff --git a/pyproject.toml b/pyproject.toml index 8b583828a..08919e866 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.dev1" +version = "0.7.0.dev2" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 8a0edbe2c58ceb5cf52992e49abb1128f5e1e72b Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 5 Jun 2024 18:48:47 +0200 Subject: [PATCH 454/892] Update release playbook (#932) Updates the RELEASING.md runbook with more steps and scripts. Excludes dev release tags for prior releases. Updates github_changelog_generator config to exclude pull requests with the release-prep label from the changelog. Co-authored-by: sdb9696 --- .github_changelog_generator | 1 + RELEASING.md | 151 ++++++++++++++++++++++++++++++------ 2 files changed, 129 insertions(+), 23 deletions(-) diff --git a/.github_changelog_generator b/.github_changelog_generator index 0341d4088..8a6b1c763 100644 --- a/.github_changelog_generator +++ b/.github_changelog_generator @@ -2,3 +2,4 @@ breaking_labels=breaking change add-sections={"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]}} release_branch=master usernames-as-github-logins=true +exclude-labels=duplicate,question,invalid,wontfix,release-prep diff --git a/RELEASING.md b/RELEASING.md index 96212b1e9..3694cc3b2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,57 +1,162 @@ -1. Set release information +## Requirements +* [github client](https://github.com/cli/cli#installation) +* [gitchub_changelog_generator](https://github.com/github-changelog-generator) +* [github access token](https://github.com/github-changelog-generator/github-changelog-generator#github-token) + +## Export changelog token ```bash -# export PREVIOUS_RELEASE=$(git describe --abbrev=0) -export PREVIOUS_RELEASE=0.3.5 # generate the full changelog since last pyhs100 release -export NEW_RELEASE=0.4.0.dev4 +export CHANGELOG_GITHUB_TOKEN=token ``` -2. Update the version number +## Set release information + +0.3.5 should always be the previous release as it's the last pyhs100 release in HISTORY.md which is the changelog prior to github release notes. + +```bash +export NEW_RELEASE=x.x.x.devx +export PREVIOUS_RELEASE=0.3.5 +``` + +## Create a branch for the release + +```bash +git checkout master +git fetch upstream master +git rebase upstream/master +git checkout -b release/$NEW_RELEASE +``` + +## Update the version number ```bash poetry version $NEW_RELEASE ``` -3. Write a short and understandable summary for the release. +## Update dependencies -* Create a new issue and label it with release-summary -* Create $NEW_RELEASE milestone in github, and assign the issue to that -* Close the issue +```bash +poetry install --all-extras --sync +poetry update +``` -3. Generate changelog +## Run pre-commit and tests + +```bash +pre-commit run --all-files +pytest kasa +``` + +## Create release summary (skip for dev releases) + +Write a short and understandable summary for the release. Can include images. + +### Create $NEW_RELEASE milestone in github + +If not already created + +### Create new issue linked to the milestone + +```bash +gh issue create --label "release-summary" --milestone $NEW_RELEASE --title "$NEW_RELEASE Release Summary" --body "Some summary text" +``` + +You can exclude the --body option to get an interactive editor or leave blank and go into the issue on github and edit there. + +### Close the issue + +Either via github or: + +```bash +gh issue close ISSUE_NUMBER +``` + +## Generate changelog + +### For pre-release + +EXCLUDE_TAGS will exclude all dev tags except for the current release dev tags. + +Regex should be something like this `^((?!0\.7\.0)(.*dev\d))+`. The first match group negative matches on the current release and the second matches on releases ending with dev. + +```bash +EXCLUDE_TAGS=${NEW_RELEASE%.dev*}; EXCLUDE_TAGS=${EXCLUDE_TAGS//"."/"\."}; EXCLUDE_TAGS="^((?!"$EXCLUDE_TAGS")(.*dev\d))+" +github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md --exclude-tags-regex "$EXCLUDE_TAGS" +``` + +### For production ```bash -# gem install github_changelog_generator --pre -# https://github.com/github-changelog-generator/github-changelog-generator#github-token -export CHANGELOG_GITHUB_TOKEN=token github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md --exclude-tags-regex 'dev\d$' ``` -Remove '--exclude-tags-regex' for dev releases. +You can ignore warnings about missing PR commits like below as these relate to PRs to branches other than master: +``` +Warning: PR 908 merge commit was not found in the release branch or tagged git history and no rebased SHA comment was found +``` -4. Commit the changed files + +## Export new release notes to variable + +```bash +export RELEASE_NOTES=$(grep -Poz '(?<=\# Changelog\n\n)(.|\n)+?(?=\#\#)' CHANGELOG.md | tr '\0' '\n' ) +echo "$RELEASE_NOTES" # Check the output and copy paste if neccessary +``` + +## Commit and push the changed files ```bash -git commit -av +git commit --all --verbose -m "Prepare $NEW_RELEASE" +git push upstream release/$NEW_RELEASE -u ``` -5. Create a PR for the release. +## Create a PR for the release, merge it, and re-fetch the master + +### Create the PR +``` +gh pr create --title "Prepare $NEW_RELEASE" --body "$RELEASE_NOTES" --label release-prep --base master +``` -6. Get it merged, fetch the upstream master +### Merge the PR once the CI passes + +Create a squash commit and add the markdown from the PR description to the commit description. + +```bash +gh pr merge --squash --body "$RELEASE_NOTES" +``` + +### Rebase local master ```bash git checkout master -git fetch upstream +git fetch upstream master git rebase upstream/master ``` -7. Tag the release (add short changelog as a tag commit message), push the tag to git +## Create a release tag + +Note, add changelog release notes as the tag commit message so `gh release create --notes-from-tag` can be used to create a release draft. ```bash -git tag -a $NEW_RELEASE +git tag --annotate $NEW_RELEASE -m "$RELEASE_NOTES" git push upstream $NEW_RELEASE ``` -All tags on master branch will trigger a new release on pypi. +## Create release + +### Pre-releases + +```bash +gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=false --prerelease + +``` + +### Production release + +```bash +gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=true +``` + +## Manually publish the release -8. Click the "Draft a new release" button on github, select the new tag and copy & paste the changelog into the description. +Go to the linked URL, verify the contents, and click "release" button to trigger the release CI. From 39fc21a124779a536e5157f32f1a969c7093424c Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 5 Jun 2024 20:13:10 +0300 Subject: [PATCH 455/892] Use freezegun for testing aes http client delays (#954) --- kasa/tests/test_aestransport.py | 34 +++++++++++++------- poetry.lock | 56 ++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 3 files changed, 79 insertions(+), 12 deletions(-) diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 00bcb953d..232546d5a 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -15,6 +15,7 @@ import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding +from freezegun.api import FrozenDateTimeFactory from yarl import URL from ..aestransport import AesEncyptionSession, AesTransport, TransportState @@ -287,12 +288,20 @@ async def test_port_override(): @pytest.mark.parametrize( - "request_delay, should_error, should_succeed", - [(0, False, True), (0.125, True, True), (0.3, True, True), (0.7, True, False)], - ids=["No error", "Error then succeed", "Two errors then succeed", "No succeed"], + "device_delay_required, should_error, should_succeed", + [ + pytest.param(0, False, True, id="No error"), + pytest.param(0.125, True, True, id="Error then succeed"), + pytest.param(0.3, True, True, id="Two errors then succeed"), + pytest.param(0.7, True, False, id="No succeed"), + ], ) async def test_device_closes_connection( - mocker, request_delay, should_error, should_succeed + mocker, + freezer: FrozenDateTimeFactory, + device_delay_required, + should_error, + should_succeed, ): """Test the delay logic in http client to deal with devices that close connections after each request. @@ -300,16 +309,19 @@ async def test_device_closes_connection( """ host = "127.0.0.1" - # Speed up the test by dividing all times by a factor. Doesn't seem to work on windows - # but leaving here as a TODO to manipulate system time for testing. - speed_up_factor = 1 - default_delay = HttpClient.WAIT_BETWEEN_REQUESTS_ON_OSERROR / speed_up_factor - request_delay = request_delay / speed_up_factor + default_delay = HttpClient.WAIT_BETWEEN_REQUESTS_ON_OSERROR + mock_aes_device = MockAesDevice( - host, 200, 0, 0, sequential_request_delay=request_delay + host, 200, 0, 0, sequential_request_delay=device_delay_required ) mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) + async def _asyncio_sleep_mock(delay, result=None): + freezer.tick(delay) + return result + + mocker.patch("asyncio.sleep", side_effect=_asyncio_sleep_mock) + config = DeviceConfig(host, credentials=Credentials("foo", "bar")) transport = AesTransport(config=config) transport._http_client.WAIT_BETWEEN_REQUESTS_ON_OSERROR = default_delay @@ -332,7 +344,7 @@ async def test_device_closes_connection( # If the device errors without a delay then it should error immedately ( + 1) # and then the number of times the default delay passes within the request delay window expected_error_count = ( - 0 if not should_error else int(request_delay / default_delay) + 1 + 0 if not should_error else int(device_delay_required / default_delay) + 1 ) for _ in range(3): try: diff --git a/poetry.lock b/poetry.lock index 90667c80f..3d28de256 100644 --- a/poetry.lock +++ b/poetry.lock @@ -636,6 +636,20 @@ docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1 testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] typing = ["typing-extensions (>=4.8)"] +[[package]] +name = "freezegun" +version = "1.5.1" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.7" +files = [ + {file = "freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1"}, + {file = "freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + [[package]] name = "frozenlist" version = "1.4.1" @@ -1505,6 +1519,21 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] +[[package]] +name = "pytest-freezer" +version = "0.4.8" +description = "Pytest plugin providing a fixture interface for spulec/freezegun" +optional = false +python-versions = ">= 3.6" +files = [ + {file = "pytest_freezer-0.4.8-py3-none-any.whl", hash = "sha256:644ce7ddb8ba52b92a1df0a80a699bad2b93514c55cf92e9f2517b68ebe74814"}, + {file = "pytest_freezer-0.4.8.tar.gz", hash = "sha256:8ee2f724b3ff3540523fa355958a22e6f4c1c819928b78a7a183ae4248ce6ee6"}, +] + +[package.dependencies] +freezegun = ">=1.0" +pytest = ">=3.6" + [[package]] name = "pytest-mock" version = "3.14.0" @@ -1555,6 +1584,20 @@ files = [ [package.dependencies] pytest = ">=7.0.0" +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "pytz" version = "2024.1" @@ -1681,6 +1724,17 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -2156,4 +2210,4 @@ speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "ba5c0da1e413e466834d0954528c7ace6dd9e01d9fb2e626f4c6b23044803aef" +content-hash = "871ef421fe7d48608bcea18b4c41d8bb368e84d74bf7b29db832dc97c5b980ae" diff --git a/pyproject.toml b/pyproject.toml index 08919e866..5f1fc3540 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ codecov = "*" xdoctest = "*" coverage = {version = "*", extras = ["toml"]} pytest-timeout = "^2" +pytest-freezer = "^0.4" [tool.poetry.extras] docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] From 40e40522f9dd03a0c76da0e6bf36f4b4c222ecb2 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 6 Jun 2024 14:18:34 +0100 Subject: [PATCH 456/892] Fix fan speed level when off and derive smart fan module from common fan interface (#957) Picked this up while updating the [Fan platform PR](https://github.com/home-assistant/core/pull/116605) for HA. The smart fan module was not correctly deriving from the common interface and the speed_level is reported as >0 when off. --- kasa/smart/modules/fan.py | 5 +++-- kasa/tests/smart/modules/test_fan.py | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/kasa/smart/modules/fan.py b/kasa/smart/modules/fan.py index 3d8cc7eb6..153f9c8f9 100644 --- a/kasa/smart/modules/fan.py +++ b/kasa/smart/modules/fan.py @@ -5,13 +5,14 @@ from typing import TYPE_CHECKING from ...feature import Feature +from ...interfaces.fan import Fan as FanInterface from ..smartmodule import SmartModule if TYPE_CHECKING: from ..smartdevice import SmartDevice -class Fan(SmartModule): +class Fan(SmartModule, FanInterface): """Implementation of fan_control module.""" REQUIRED_COMPONENT = "fan_control" @@ -54,7 +55,7 @@ def query(self) -> dict: @property def fan_speed_level(self) -> int: """Return fan speed level.""" - return self.data["fan_speed_level"] + return 0 if self.data["device_on"] is False else self.data["fan_speed_level"] async def set_fan_speed_level(self, level: int): """Set fan speed level, 0 for off, 1-4 for on.""" diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index e5e1ff724..6d5a0dd1d 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -64,6 +64,11 @@ async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): assert fan.fan_speed_level == 1 assert device.is_on + # Check that if the device is off the speed level is 0. + await device.set_state(False) + await dev.update() + assert fan.fan_speed_level == 0 + await fan.set_fan_speed_level(4) await dev.update() assert fan.fan_speed_level == 4 From 5befe51c424c1a8ad83cc6a670150a74a6c73ad4 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 6 Jun 2024 17:01:58 +0100 Subject: [PATCH 457/892] Ensure http delay logic works during default login attempt (#959) Ensures retryable exceptions are raised on failure to login with default login credentials. --- kasa/aestransport.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 427801e15..68250b1ad 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -28,6 +28,8 @@ DeviceError, KasaException, SmartErrorCode, + TimeoutError, + _ConnectionError, _RetryableError, ) from .httpclient import HttpClient @@ -220,7 +222,7 @@ async def perform_login(self): "%s: logged in with default credentials", self._host, ) - except AuthenticationError: + except (AuthenticationError, _ConnectionError, TimeoutError): raise except Exception as ex: raise KasaException( From e1e2a396b8e14980f12ae5329403fc4cf9b23db7 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 7 Jun 2024 10:52:11 +0100 Subject: [PATCH 458/892] Add state features to iot strip sockets (#960) Fixes iot strip sockets not creating their own state and on_since features. --- kasa/iot/iotstrip.py | 29 +++++++++++++++++++++++++++++ kasa/tests/test_strip.py | 8 ++++++++ 2 files changed, 37 insertions(+) diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 7c6368b02..dde57faaf 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -10,6 +10,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..exceptions import KasaException +from ..feature import Feature from ..module import Module from ..protocol import BaseProtocol from .iotdevice import ( @@ -125,6 +126,8 @@ async def update(self, update_children: bool = True): ) for child in children } + for child in self._children.values(): + await child._initialize_features() if update_children and self.has_emeter: for plug in self.children: @@ -250,6 +253,32 @@ async def _initialize_modules(self): await super()._initialize_modules() self.add_module("time", Time(self, "time")) + async def _initialize_features(self): + """Initialize common features.""" + self._add_feature( + Feature( + self, + id="state", + name="State", + attribute_getter="is_on", + attribute_setter="set_state", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) + ) + self._add_feature( + Feature( + device=self, + id="on_since", + name="On since", + attribute_getter="on_since", + icon="mdi:clock", + ) + ) + # If the strip plug has it's own modules we should call initialize + # features for the modules here. However the _initialize_modules function + # above does not seem to be called. + async def update(self, update_children: bool = True): """Query the device to update the data. diff --git a/kasa/tests/test_strip.py b/kasa/tests/test_strip.py index e5285accb..4c576d1b2 100644 --- a/kasa/tests/test_strip.py +++ b/kasa/tests/test_strip.py @@ -88,6 +88,14 @@ async def test_get_plug_by_index(dev: IotStrip): dev.get_plug_by_index(len(dev.children)) +@strip +async def test_plug_features(dev: IotStrip): + """Test the child plugs have default features.""" + for child in dev.children: + assert "state" in child.features + assert "on_since" in child.features + + @pytest.mark.skip("this test will wear out your relays") async def test_all_binary_states(dev): # test every binary state From b8c1b39cf0bc1982ed69b94c520c4a8d54b660cc Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 7 Jun 2024 11:29:26 +0100 Subject: [PATCH 459/892] Fix switching off light effects for iot lights strips (#961) Fixes the newly implemented method to turn off active effects on iot devices --- kasa/iot/modules/lighteffect.py | 20 +++++++++++++------- kasa/tests/fakeprotocol_iot.py | 6 ++++++ kasa/tests/test_common_modules.py | 2 +- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/kasa/iot/modules/lighteffect.py b/kasa/iot/modules/lighteffect.py index 54b4725bc..8f855bcf2 100644 --- a/kasa/iot/modules/lighteffect.py +++ b/kasa/iot/modules/lighteffect.py @@ -3,6 +3,7 @@ from __future__ import annotations from ...interfaces.lighteffect import LightEffect as LightEffectInterface +from ...module import Module from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from ..iotmodule import IotModule @@ -59,19 +60,24 @@ async def set_effect( :param int transition: The wanted transition time """ if effect == self.LIGHT_EFFECTS_OFF: - effect_dict = dict(self.data["lighting_effect_state"]) - effect_dict["enable"] = 0 + light_module = self._device.modules[Module.Light] + effect_off_state = light_module.state + if brightness is not None: + effect_off_state.brightness = brightness + if transition is not None: + effect_off_state.transition = transition + await light_module.set_state(effect_off_state) elif effect not in EFFECT_MAPPING_V1: raise ValueError(f"The effect {effect} is not a built in effect.") else: effect_dict = EFFECT_MAPPING_V1[effect] - if brightness is not None: - effect_dict["brightness"] = brightness - if transition is not None: - effect_dict["transition"] = transition + if brightness is not None: + effect_dict["brightness"] = brightness + if transition is not None: + effect_dict["transition"] = transition - await self.set_custom_effect(effect_dict) + await self.set_custom_effect(effect_dict) async def set_custom_effect( self, diff --git a/kasa/tests/fakeprotocol_iot.py b/kasa/tests/fakeprotocol_iot.py index 806e52099..523205989 100644 --- a/kasa/tests/fakeprotocol_iot.py +++ b/kasa/tests/fakeprotocol_iot.py @@ -317,6 +317,12 @@ def transition_light_state(self, state_changes, *args): _LOGGER.debug("New light state: %s", new_state) self.proto["system"]["get_sysinfo"]["light_state"] = new_state + # Setting the light state on a device will turn off any active lighting effects. + if lighting_effect_state := self.proto["system"]["get_sysinfo"].get( + "lighting_effect_state" + ): + lighting_effect_state["enable"] = 0 + def set_preferred_state(self, new_state, *args): """Implement set_preferred_state.""" self.proto["system"]["get_sysinfo"]["preferred_state"][new_state["index"]] = ( diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index 0cdb32ade..eaff5c07c 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -78,7 +78,7 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture): assert light_effect_module feat = dev.features["light_effect"] - call = mocker.spy(light_effect_module, "call") + call = mocker.spy(dev, "_query_helper") effect_list = light_effect_module.effect_list assert "Off" in effect_list assert effect_list.index("Off") == 0 From 9b66ac87657bdceb37a1e5d30fad3800b363010d Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 7 Jun 2024 13:47:51 +0200 Subject: [PATCH 460/892] Require update in cli for wifi commands (#956) Executing wifi join requires passing the current time information for smart devices which we read from the device. This PR removes wifi from the block list to make it work. --- kasa/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/cli.py b/kasa/cli.py index 8919f174d..39f6636fa 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -92,7 +92,7 @@ def wrapper(message=None, *args, **kwargs): DEVICE_FAMILY_TYPES = [device_family_type.value for device_family_type in DeviceFamily] # Block list of commands which require no update -SKIP_UPDATE_COMMANDS = ["wifi", "raw-command", "command"] +SKIP_UPDATE_COMMANDS = ["raw-command", "command"] click.anyio_backend = "asyncio" From b094e334ca27c01dcddc9d8bf41d1136058ede55 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 7 Jun 2024 13:25:17 +0100 Subject: [PATCH 461/892] Prepare 0.7.0.dev3 (#962) ## [0.7.0.dev3](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev3) (2024-06-07) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev2...0.7.0.dev3) **Fixed bugs:** - Fix switching off light effects for iot lights strips [\#961](https://github.com/python-kasa/python-kasa/pull/961) (@sdb9696) - Add state features to iot strip sockets [\#960](https://github.com/python-kasa/python-kasa/pull/960) (@sdb9696) - Ensure http delay logic works during default login attempt [\#959](https://github.com/python-kasa/python-kasa/pull/959) (@sdb9696) - Fix fan speed level when off and derive smart fan module from common fan interface [\#957](https://github.com/python-kasa/python-kasa/pull/957) (@sdb9696) - Require update in cli for wifi commands [\#956](https://github.com/python-kasa/python-kasa/pull/956) (@rytilahti) **Project maintenance:** - Use freezegun for testing aes http client delays [\#954](https://github.com/python-kasa/python-kasa/pull/954) (@sdb9696) - Update release playbook [\#932](https://github.com/python-kasa/python-kasa/pull/932) (@rytilahti) --- .github_changelog_generator | 2 +- CHANGELOG.md | 573 ++++--------------------------- RELEASING.md | 9 +- poetry.lock | 647 +++++++++++++++++------------------- pyproject.toml | 2 +- 5 files changed, 378 insertions(+), 855 deletions(-) diff --git a/.github_changelog_generator b/.github_changelog_generator index 8a6b1c763..c32fe6ead 100644 --- a/.github_changelog_generator +++ b/.github_changelog_generator @@ -1,5 +1,5 @@ breaking_labels=breaking change -add-sections={"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]}} +add-sections={"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}} release_branch=master usernames-as-github-logins=true exclude-labels=duplicate,question,invalid,wontfix,release-prep diff --git a/CHANGELOG.md b/CHANGELOG.md index e4142d4b7..b4febcb7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.7.0.dev3](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev3) (2024-06-07) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev2...0.7.0.dev3) + +**Fixed bugs:** + +- Fix switching off light effects for iot lights strips [\#961](https://github.com/python-kasa/python-kasa/pull/961) (@sdb9696) +- Add state features to iot strip sockets [\#960](https://github.com/python-kasa/python-kasa/pull/960) (@sdb9696) +- Ensure http delay logic works during default login attempt [\#959](https://github.com/python-kasa/python-kasa/pull/959) (@sdb9696) +- Fix fan speed level when off and derive smart fan module from common fan interface [\#957](https://github.com/python-kasa/python-kasa/pull/957) (@sdb9696) +- Require update in cli for wifi commands [\#956](https://github.com/python-kasa/python-kasa/pull/956) (@rytilahti) + +**Project maintenance:** + +- Use freezegun for testing aes http client delays [\#954](https://github.com/python-kasa/python-kasa/pull/954) (@sdb9696) +- Update release playbook [\#932](https://github.com/python-kasa/python-kasa/pull/932) (@rytilahti) + ## [0.7.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev2) (2024-06-05) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev1...0.7.0.dev2) @@ -19,11 +36,7 @@ - Update documentation structure and start migrating to markdown [\#934](https://github.com/python-kasa/python-kasa/pull/934) (@sdb9696) -**Closed issues:** - -- Simplify instance creation API [\#927](https://github.com/python-kasa/python-kasa/issues/927) - -**Merged pull requests:** +**Project maintenance:** - Add P115 fixture [\#950](https://github.com/python-kasa/python-kasa/pull/950) (@rytilahti) - Add some device fixtures [\#948](https://github.com/python-kasa/python-kasa/pull/948) (@rytilahti) @@ -39,10 +52,6 @@ - Fix set\_state for common light modules [\#929](https://github.com/python-kasa/python-kasa/pull/929) (@sdb9696) - Add state feature for iot devices [\#924](https://github.com/python-kasa/python-kasa/pull/924) (@rytilahti) -**Merged pull requests:** - -- Prepare 0.7.0.dev1 [\#931](https://github.com/python-kasa/python-kasa/pull/931) (@rytilahti) - ## [0.7.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev0) (2024-05-19) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2.1...0.7.0.dev0) @@ -57,7 +66,6 @@ **Implemented enhancements:** -- Radiator support \(KE100\) [\#422](https://github.com/python-kasa/python-kasa/issues/422) - Add post update hook to module and use in smart LightEffect [\#921](https://github.com/python-kasa/python-kasa/pull/921) (@sdb9696) - Add LightEffect module for smart light strips [\#918](https://github.com/python-kasa/python-kasa/pull/918) (@sdb9696) - Improve categorization of features [\#904](https://github.com/python-kasa/python-kasa/pull/904) (@rytilahti) @@ -108,12 +116,6 @@ **Fixed bugs:** -- Fix --help on subcommands [\#885](https://github.com/python-kasa/python-kasa/issues/885) -- "Unclosed client session" Trying to set brightness on Tapo Bulb [\#828](https://github.com/python-kasa/python-kasa/issues/828) -- TAPO P100 \(hw 1.0.0, sw 1.1.3\) EU plug with 0.6.2.1 Kasa results JSON\_DECODE\_FAIL\_ERROR [\#819](https://github.com/python-kasa/python-kasa/issues/819) -- Cannot add Tapo Plug P110 to Home Assistant 2024.2.3 - Error in debug mode [\#797](https://github.com/python-kasa/python-kasa/issues/797) -- KS240 gets discovered but will not authenticate [\#749](https://github.com/python-kasa/python-kasa/issues/749) -- Individual errors cause failing the whole query [\#616](https://github.com/python-kasa/python-kasa/issues/616) - Add 'battery\_percentage' only when it's available [\#906](https://github.com/python-kasa/python-kasa/pull/906) (@rytilahti) - Add missing alarm volume 'normal' [\#899](https://github.com/python-kasa/python-kasa/pull/899) (@rytilahti) - Use Path.save for saving the fixtures [\#894](https://github.com/python-kasa/python-kasa/pull/894) (@rytilahti) @@ -139,31 +141,8 @@ - Enable shell extra for installing ptpython and rich [\#782](https://github.com/python-kasa/python-kasa/pull/782) (@sdb9696) - Add WallSwitch device type and autogenerate supported devices docs [\#758](https://github.com/python-kasa/python-kasa/pull/758) (@sdb9696) -**Closed issues:** - -- Support for T300 and T110 [\#875](https://github.com/python-kasa/python-kasa/issues/875) -- Allow exposing extra feature metadata [\#842](https://github.com/python-kasa/python-kasa/issues/842) -- Handle modules supported only by children [\#825](https://github.com/python-kasa/python-kasa/issues/825) -- Handle child-embedded module data [\#824](https://github.com/python-kasa/python-kasa/issues/824) -- TP-Kasa Ks240 smart Switch DOES NOT WORK [\#823](https://github.com/python-kasa/python-kasa/issues/823) -- child device component\_nego and module queries for dump\_devinfo [\#813](https://github.com/python-kasa/python-kasa/issues/813) -- Klap protocol needs to retry after 403 error [\#784](https://github.com/python-kasa/python-kasa/issues/784) -- Add units to features and convert emeter to use features [\#772](https://github.com/python-kasa/python-kasa/issues/772) -- \_\_init\_\_\(\) missing 1 required positional argument: 'backend' [\#770](https://github.com/python-kasa/python-kasa/issues/770) -- Be more lax on unknown SMART\* devices [\#768](https://github.com/python-kasa/python-kasa/issues/768) -- Combine smart{plug,light} into smartdevice [\#747](https://github.com/python-kasa/python-kasa/issues/747) -- TP-Link P100 Plug support [\#742](https://github.com/python-kasa/python-kasa/issues/742) -- Clean up newfakes [\#723](https://github.com/python-kasa/python-kasa/issues/723) -- Discovery does not list all discovered\_devices if it times out before it can print them. [\#672](https://github.com/python-kasa/python-kasa/issues/672) -- Modularize tapodevice [\#651](https://github.com/python-kasa/python-kasa/issues/651) -- Add retry logic to legacy protocol for connection and OSErrors. [\#648](https://github.com/python-kasa/python-kasa/issues/648) -- Add timestamp to default logger and remove from log.debug messages [\#647](https://github.com/python-kasa/python-kasa/issues/647) -- Need to create common interfaces for legacy and new devices [\#613](https://github.com/python-kasa/python-kasa/issues/613) -- Kasa discovery crashes on Windows 10 with Python 3.11.2 [\#449](https://github.com/python-kasa/python-kasa/issues/449) - **Merged pull requests:** -- Prepare 0.7.0.dev0 [\#922](https://github.com/python-kasa/python-kasa/pull/922) (@rytilahti) - Fix potential infinite loop if incomplete lists returned [\#920](https://github.com/python-kasa/python-kasa/pull/920) (@sdb9696) - Deprecate device level light, effect and led attributes [\#916](https://github.com/python-kasa/python-kasa/pull/916) (@sdb9696) - Update cli to use common modules and remove iot specific cli testing [\#913](https://github.com/python-kasa/python-kasa/pull/913) (@sdb9696) @@ -229,7 +208,6 @@ **Merged pull requests:** -- Prepare 0.6.2.1 [\#736](https://github.com/python-kasa/python-kasa/pull/736) (@rytilahti) - Retain last two chars for children device\_id [\#733](https://github.com/python-kasa/python-kasa/pull/733) (@rytilahti) - Add TP15 fixture [\#730](https://github.com/python-kasa/python-kasa/pull/730) (@bdraco) - Add TP25 fixtures [\#729](https://github.com/python-kasa/python-kasa/pull/729) (@bdraco) @@ -240,10 +218,6 @@ [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.1...0.6.2) -Release highlights: -* Support for tapo power strips (P300) -* Performance improvements and bug fixes - **Implemented enhancements:** - Implement alias set for tapodevice [\#721](https://github.com/python-kasa/python-kasa/pull/721) (@rytilahti) @@ -262,14 +236,8 @@ Release highlights: - Add protocol and transport documentation [\#663](https://github.com/python-kasa/python-kasa/pull/663) (@sdb9696) -**Closed issues:** - -- Need to be able to both close and reset transports [\#671](https://github.com/python-kasa/python-kasa/issues/671) -- Improve re-use of protocol code, particularly around retry logic and the IotProtocol [\#649](https://github.com/python-kasa/python-kasa/issues/649) - **Merged pull requests:** -- Prepare 0.6.2 [\#728](https://github.com/python-kasa/python-kasa/pull/728) (@rytilahti) - Update L510E\(US\) fixture with mac prefix [\#722](https://github.com/python-kasa/python-kasa/pull/722) (@sdb9696) - Use hashlib in place of hashes.Hash [\#714](https://github.com/python-kasa/python-kasa/pull/714) (@bdraco) - Switch from TPLinkSmartHomeProtocol to IotProtocol/XorTransport [\#710](https://github.com/python-kasa/python-kasa/pull/710) (@sdb9696) @@ -280,11 +248,6 @@ Release highlights: [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) @@ -303,16 +266,8 @@ Release highlights: - Document authenticated provisioning [\#634](https://github.com/python-kasa/python-kasa/pull/634) (@rytilahti) -**Closed issues:** - -- 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) -- 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) - **Merged pull requests:** -- Prepare 0.6.1 [\#709](https://github.com/python-kasa/python-kasa/pull/709) (@rytilahti) - 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) @@ -341,20 +296,13 @@ Release highlights: [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0...0.6.0.1) -A patch release to improve the protocol handling. - **Fixed bugs:** - Fix httpclient exceptions on read and improve error info [\#655](https://github.com/python-kasa/python-kasa/pull/655) (@sdb9696) - Improve and document close behavior [\#654](https://github.com/python-kasa/python-kasa/pull/654) (@bdraco) -**Closed issues:** - -- Do not redact OUI for fixtures [\#652](https://github.com/python-kasa/python-kasa/issues/652) - **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) @@ -363,104 +311,7 @@ A patch release to improve the protocol handling. ## [0.6.0](https://github.com/python-kasa/python-kasa/tree/0.6.0) (2024-01-19) -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.dev2...0.6.0) - -This major brings major changes to the library by adding support for devices that require authentication for communications, all of this being possible thanks to the great work by @sdb9696! - -This release adds support to a large range of previously unsupported devices, including: - -* Newer kasa-branded devices, including Matter-enabled devices like KP125M -* Newer hardware/firmware versions on some models, like EP25, that suddenly changed the used protocol -* Tapo-branded devices like plugs (P110), light bulbs (KL530), LED strips (L900, L920), and wall switches (KS205, KS225) -* UK variant of HS110, which was the first device using the new protocol - -If your device that is not currently listed as supported is working, please consider contributing a test fixture file. - -Special thanks goes to @SimonWilkinson who created the initial PR for the new communication protocol! - -**Implemented enhancements:** - -- Allow serializing and passing of credentials\_hashes in DeviceConfig [\#607](https://github.com/python-kasa/python-kasa/pull/607) (@sdb9696) -- Implement wifi interface for tapodevice [\#606](https://github.com/python-kasa/python-kasa/pull/606) (@rytilahti) -- Add support for KS205 and KS225 wall switches [\#594](https://github.com/python-kasa/python-kasa/pull/594) (@gimpy88) -- Add support for tapo bulbs [\#558](https://github.com/python-kasa/python-kasa/pull/558) (@rytilahti) -- Add klap protocol [\#509](https://github.com/python-kasa/python-kasa/pull/509) (@sdb9696) - -**Fixed bugs:** - -- Fix connection indeterminate state on cancellation [\#636](https://github.com/python-kasa/python-kasa/pull/636) (@bdraco) - -**Documentation updates:** - -- Update the documentation for 0.6 release [\#600](https://github.com/python-kasa/python-kasa/issues/600) - -**Closed issues:** - -- KS225 support [\#631](https://github.com/python-kasa/python-kasa/issues/631) -- Convert to use aiohttp instead of httpx [\#635](https://github.com/python-kasa/python-kasa/issues/635) -- Need to do error code checking for new protocols [\#612](https://github.com/python-kasa/python-kasa/issues/612) -- Support of last firmware update version 1.3.0 [\#611](https://github.com/python-kasa/python-kasa/issues/611) -- Improve test coverage for tapodevice class [\#608](https://github.com/python-kasa/python-kasa/issues/608) - -**Merged pull requests:** - -- Release 0.6.0 [\#653](https://github.com/python-kasa/python-kasa/pull/653) (@rytilahti) -- Remove time logging in debug message [\#645](https://github.com/python-kasa/python-kasa/pull/645) (@sdb9696) -- Migrate http client to use aiohttp instead of httpx [\#643](https://github.com/python-kasa/python-kasa/pull/643) (@sdb9696) -- Encapsulate http client dependency [\#642](https://github.com/python-kasa/python-kasa/pull/642) (@sdb9696) -- Fix broken docs due to applehelp dependency [\#641](https://github.com/python-kasa/python-kasa/pull/641) (@sdb9696) -- Raise SmartDeviceException on invalid config dicts [\#640](https://github.com/python-kasa/python-kasa/pull/640) (@sdb9696) -- Add fixture for L920 [\#638](https://github.com/python-kasa/python-kasa/pull/638) (@bdraco) -- Add known smart requests to dump\_devinfo [\#597](https://github.com/python-kasa/python-kasa/pull/597) (@sdb9696) - -## [0.6.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev2) (2024-01-11) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.dev1...0.6.0.dev2) - -**Documentation updates:** - -- Update docs for newer devices and DeviceConfig [\#614](https://github.com/python-kasa/python-kasa/pull/614) (@sdb9696) - -**Merged pull requests:** - -- Release 0.6.0.dev2 [\#633](https://github.com/python-kasa/python-kasa/pull/633) (@rytilahti) -- Raise TimeoutException on discover\_single timeout [\#632](https://github.com/python-kasa/python-kasa/pull/632) (@sdb9696) -- Add L900-10 fixture and it's additional component requests [\#629](https://github.com/python-kasa/python-kasa/pull/629) (@sdb9696) -- Avoid recreating struct each request in legacy protocol [\#628](https://github.com/python-kasa/python-kasa/pull/628) (@bdraco) -- Return alias as None for new discovery devices before update [\#627](https://github.com/python-kasa/python-kasa/pull/627) (@sdb9696) -- Update config to\_dict to exclude credentials if the hash is empty string [\#626](https://github.com/python-kasa/python-kasa/pull/626) (@sdb9696) -- Improve test coverage [\#625](https://github.com/python-kasa/python-kasa/pull/625) (@sdb9696) - -## [0.6.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev1) (2024-01-05) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.dev0...0.6.0.dev1) - -**Implemented enhancements:** - -- Get child emeters with CLI [\#623](https://github.com/python-kasa/python-kasa/pull/623) (@Obbay2) -- Avoid linear search for emeter realtime and emeter\_today [\#622](https://github.com/python-kasa/python-kasa/pull/622) (@bdraco) -- Add update-credentials command [\#620](https://github.com/python-kasa/python-kasa/pull/620) (@rytilahti) - -**Fixed bugs:** - -- Check the ct range for color temp support [\#619](https://github.com/python-kasa/python-kasa/pull/619) (@rytilahti) -- Fix cli discover bug with None username/password [\#615](https://github.com/python-kasa/python-kasa/pull/615) (@sdb9696) - -**Closed issues:** - -- Implement energy and usage for individual plugs in HS300 [\#462](https://github.com/python-kasa/python-kasa/issues/462) - -**Merged pull requests:** - -- Release 0.6.0.dev1 [\#624](https://github.com/python-kasa/python-kasa/pull/624) (@rytilahti) -- Add P125M and update EP25 fixtures [\#621](https://github.com/python-kasa/python-kasa/pull/621) (@bdraco) -- Use consistent envvars for dump\_devinfo credentials [\#618](https://github.com/python-kasa/python-kasa/pull/618) (@rytilahti) -- Mark L900-5 as supported [\#617](https://github.com/python-kasa/python-kasa/pull/617) (@rytilahti) -- Ship CHANGELOG only in sdist [\#610](https://github.com/python-kasa/python-kasa/pull/610) (@rytilahti) - -## [0.6.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev0) (2024-01-03) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.4...0.6.0.dev0) +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.4...0.6.0) **Breaking changes:** @@ -469,9 +320,9 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co **Implemented enhancements:** -- Support for KS225\(US\) Light Dimmer and KS205\(US\) Light Switch [\#589](https://github.com/python-kasa/python-kasa/issues/589) -- Set timeout using command line parameters [\#310](https://github.com/python-kasa/python-kasa/issues/310) -- Implement the new protocol \(HTTP over 80/tcp, 20002/udp for discovery\) [\#115](https://github.com/python-kasa/python-kasa/issues/115) +- Get child emeters with CLI [\#623](https://github.com/python-kasa/python-kasa/pull/623) (@Obbay2) +- Avoid linear search for emeter realtime and emeter\_today [\#622](https://github.com/python-kasa/python-kasa/pull/622) (@bdraco) +- Add update-credentials command [\#620](https://github.com/python-kasa/python-kasa/pull/620) (@rytilahti) - Enable multiple requests in smartprotocol [\#584](https://github.com/python-kasa/python-kasa/pull/584) (@sdb9696) - Improve CLI Discovery output [\#583](https://github.com/python-kasa/python-kasa/pull/583) (@sdb9696) - Improve smartprotocol error handling and retries [\#578](https://github.com/python-kasa/python-kasa/pull/578) (@sdb9696) @@ -487,33 +338,44 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co - Add support for the protocol used by TAPO devices and some newer KASA devices. [\#552](https://github.com/python-kasa/python-kasa/pull/552) (@sdb9696) - Re-add protocol\_class parameter to connect [\#551](https://github.com/python-kasa/python-kasa/pull/551) (@sdb9696) - Update discover single to handle hostnames [\#539](https://github.com/python-kasa/python-kasa/pull/539) (@sdb9696) +- Allow serializing and passing of credentials\_hashes in DeviceConfig [\#607](https://github.com/python-kasa/python-kasa/pull/607) (@sdb9696) +- Implement wifi interface for tapodevice [\#606](https://github.com/python-kasa/python-kasa/pull/606) (@rytilahti) +- Add support for KS205 and KS225 wall switches [\#594](https://github.com/python-kasa/python-kasa/pull/594) (@gimpy88) +- Add support for tapo bulbs [\#558](https://github.com/python-kasa/python-kasa/pull/558) (@rytilahti) +- Add klap protocol [\#509](https://github.com/python-kasa/python-kasa/pull/509) (@sdb9696) **Fixed bugs:** -- dump\_devinfo crashes when credentials are not given [\#591](https://github.com/python-kasa/python-kasa/issues/591) +- Fix connection indeterminate state on cancellation [\#636](https://github.com/python-kasa/python-kasa/pull/636) (@bdraco) +- Check the ct range for color temp support [\#619](https://github.com/python-kasa/python-kasa/pull/619) (@rytilahti) +- Fix cli discover bug with None username/password [\#615](https://github.com/python-kasa/python-kasa/pull/615) (@sdb9696) - Fix hsv setting for tapobulb [\#573](https://github.com/python-kasa/python-kasa/pull/573) (@rytilahti) - Fix transport retries after close [\#568](https://github.com/python-kasa/python-kasa/pull/568) (@sdb9696) **Documentation updates:** +- Update docs for newer devices and DeviceConfig [\#614](https://github.com/python-kasa/python-kasa/pull/614) (@sdb9696) - Update readme with clearer instructions, tapo support [\#571](https://github.com/python-kasa/python-kasa/pull/571) (@rytilahti) - Add some more external links to README [\#541](https://github.com/python-kasa/python-kasa/pull/541) (@rytilahti) -**Closed issues:** - -- Discover returns dictionary with no 'alias' property [\#592](https://github.com/python-kasa/python-kasa/issues/592) -- Sending with the legacy protocol is needlessly delayed [\#553](https://github.com/python-kasa/python-kasa/issues/553) -- Issues adding a KP405 device [\#549](https://github.com/python-kasa/python-kasa/issues/549) -- Support for L510E bulb [\#547](https://github.com/python-kasa/python-kasa/issues/547) -- Support for tapo L530E bulbs? [\#546](https://github.com/python-kasa/python-kasa/issues/546) -- Unable to connect to host on different subnet with 0.5.4 [\#545](https://github.com/python-kasa/python-kasa/issues/545) -- Discovery/Connect broken when upgrading from 0.5.3 -\> 0.5.4 [\#543](https://github.com/python-kasa/python-kasa/issues/543) -- PydanticUserError, If you use `@root_validator` with pre=False \(the default\) you MUST specify `skip_on_failure=True` [\#516](https://github.com/python-kasa/python-kasa/issues/516) -- KP 125M / support for matter devices [\#450](https://github.com/python-kasa/python-kasa/issues/450) - **Merged pull requests:** -- Release 0.6.0.dev0 [\#609](https://github.com/python-kasa/python-kasa/pull/609) (@rytilahti) +- Remove time logging in debug message [\#645](https://github.com/python-kasa/python-kasa/pull/645) (@sdb9696) +- Migrate http client to use aiohttp instead of httpx [\#643](https://github.com/python-kasa/python-kasa/pull/643) (@sdb9696) +- Encapsulate http client dependency [\#642](https://github.com/python-kasa/python-kasa/pull/642) (@sdb9696) +- Fix broken docs due to applehelp dependency [\#641](https://github.com/python-kasa/python-kasa/pull/641) (@sdb9696) +- Raise SmartDeviceException on invalid config dicts [\#640](https://github.com/python-kasa/python-kasa/pull/640) (@sdb9696) +- Add fixture for L920 [\#638](https://github.com/python-kasa/python-kasa/pull/638) (@bdraco) +- Raise TimeoutException on discover\_single timeout [\#632](https://github.com/python-kasa/python-kasa/pull/632) (@sdb9696) +- Add L900-10 fixture and it's additional component requests [\#629](https://github.com/python-kasa/python-kasa/pull/629) (@sdb9696) +- Avoid recreating struct each request in legacy protocol [\#628](https://github.com/python-kasa/python-kasa/pull/628) (@bdraco) +- Return alias as None for new discovery devices before update [\#627](https://github.com/python-kasa/python-kasa/pull/627) (@sdb9696) +- Update config to\_dict to exclude credentials if the hash is empty string [\#626](https://github.com/python-kasa/python-kasa/pull/626) (@sdb9696) +- Improve test coverage [\#625](https://github.com/python-kasa/python-kasa/pull/625) (@sdb9696) +- Add P125M and update EP25 fixtures [\#621](https://github.com/python-kasa/python-kasa/pull/621) (@bdraco) +- Use consistent envvars for dump\_devinfo credentials [\#618](https://github.com/python-kasa/python-kasa/pull/618) (@rytilahti) +- Mark L900-5 as supported [\#617](https://github.com/python-kasa/python-kasa/pull/617) (@rytilahti) +- Ship CHANGELOG only in sdist [\#610](https://github.com/python-kasa/python-kasa/pull/610) (@rytilahti) - Cleanup credentials handling [\#605](https://github.com/python-kasa/python-kasa/pull/605) (@rytilahti) - Update P110\(EU\) fixture [\#604](https://github.com/python-kasa/python-kasa/pull/604) (@rytilahti) - Update L530 aes fixture [\#603](https://github.com/python-kasa/python-kasa/pull/603) (@rytilahti) @@ -532,20 +394,12 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co - Re-add regional suffix to TAPO/SMART fixtures [\#566](https://github.com/python-kasa/python-kasa/pull/566) (@sdb9696) - Add P110 fixture [\#562](https://github.com/python-kasa/python-kasa/pull/562) (@rytilahti) - Do not do update\(\) in discover\_single [\#542](https://github.com/python-kasa/python-kasa/pull/542) (@sdb9696) +- Add known smart requests to dump\_devinfo [\#597](https://github.com/python-kasa/python-kasa/pull/597) (@sdb9696) ## [0.5.4](https://github.com/python-kasa/python-kasa/tree/0.5.4) (2023-10-29) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.3...0.5.4) -The highlights of this maintenance release: - -* Support to the alternative discovery protocol and foundational work to support other communication protocols, thanks to @sdb9696. -* Reliability improvements by avoiding overflowing device buffers, thanks to @cobryan05. -* Optimizations for downstream device accesses, thanks to @bdraco. -* Support for both pydantic v1 and v2. - -As always, see the full changelog for details. - **Implemented enhancements:** - Add a connect\_single method to Discover to avoid the need for UDP [\#528](https://github.com/python-kasa/python-kasa/pull/528) (@bdraco) @@ -570,24 +424,8 @@ As always, see the full changelog for details. - Mark KS2{20}M as partially supported [\#508](https://github.com/python-kasa/python-kasa/pull/508) (@lschweiss) - Document cli tool --target for discovery [\#497](https://github.com/python-kasa/python-kasa/pull/497) (@rytilahti) -**Closed issues:** - -- Error running kasa command on the Raspberry PI [\#525](https://github.com/python-kasa/python-kasa/issues/525) -- Installation Problems \(Python Version?\) [\#523](https://github.com/python-kasa/python-kasa/issues/523) -- What are the units in the emeter readings? [\#514](https://github.com/python-kasa/python-kasa/issues/514) -- Set Alias via Command Line [\#511](https://github.com/python-kasa/python-kasa/issues/511) -- How do I know if my device supports emeter? [\#510](https://github.com/python-kasa/python-kasa/issues/510) -- Getting Invalid KeyError when getting sysinfo on an EP40 device [\#500](https://github.com/python-kasa/python-kasa/issues/500) -- Running kasa discover on subnet broadcasts only [\#496](https://github.com/python-kasa/python-kasa/issues/496) -- Failed to discover kasa switchs on the network [\#495](https://github.com/python-kasa/python-kasa/issues/495) -- \[Feature Request\] Add a toggle command [\#492](https://github.com/python-kasa/python-kasa/issues/492) -- \[Feature Request\] Pydantic 2.0+ Support [\#491](https://github.com/python-kasa/python-kasa/issues/491) -- Support for EP10 Plug [\#170](https://github.com/python-kasa/python-kasa/issues/170) -- \[Request\] New release to pip? [\#518](https://github.com/python-kasa/python-kasa/issues/518) - **Merged pull requests:** -- Release 0.5.4 [\#536](https://github.com/python-kasa/python-kasa/pull/536) (@rytilahti) - Use ruff and ruff format [\#534](https://github.com/python-kasa/python-kasa/pull/534) (@rytilahti) - Add python3.12 and pypy-3.10 to CI [\#532](https://github.com/python-kasa/python-kasa/pull/532) (@rytilahti) - Use trusted publisher for publishing to pypi [\#531](https://github.com/python-kasa/python-kasa/pull/531) (@rytilahti) @@ -600,8 +438,6 @@ As always, see the full changelog for details. [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.2...0.5.3) -This release adds support for defining the device port and introduces dependency on async-timeout which improves timeout handling. - **Implemented enhancements:** - Make device port configurable [\#471](https://github.com/python-kasa/python-kasa/pull/471) (@karpach) @@ -612,7 +448,6 @@ This release adds support for defining the device port and introduces dependency **Merged pull requests:** -- Release 0.5.3 [\#485](https://github.com/python-kasa/python-kasa/pull/485) (@rytilahti) - Add tests for KP200 [\#483](https://github.com/python-kasa/python-kasa/pull/483) (@bdraco) - Update pyyaml to fix CI [\#482](https://github.com/python-kasa/python-kasa/pull/482) (@bdraco) @@ -620,10 +455,6 @@ This release adds support for defining the device port and introduces dependency [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.1...0.5.2) -Besides some small improvements, this release: -* Adds optional dependency for for `orjson` and `kasa-crypt` to speed-up protocol handling by an order of magnitude. -* Drops Python 3.7 support as it is no longer maintained. - **Breaking changes:** - Drop python 3.7 support [\#455](https://github.com/python-kasa/python-kasa/pull/455) (@rytilahti) @@ -637,27 +468,12 @@ Besides some small improvements, this release: **Fixed bugs:** -- Request for KP405 Support - Dimmable Plug [\#469](https://github.com/python-kasa/python-kasa/issues/469) -- Issue printing device in on\_discovered: pydantic.error\_wrappers.ValidationError: 3 validation errors for SmartBulbPreset [\#439](https://github.com/python-kasa/python-kasa/issues/439) -- Possible firmware issue with KL125 \(1.0.7 Build 211009 Rel.172044\) [\#345](https://github.com/python-kasa/python-kasa/issues/345) - Exclude querying certain modules for KL125\(US\) which cause crashes [\#451](https://github.com/python-kasa/python-kasa/pull/451) (@brianthedavis) - Return result objects for cli discover and implicit 'state' [\#446](https://github.com/python-kasa/python-kasa/pull/446) (@rytilahti) - Allow effect presets seen on light strips [\#440](https://github.com/python-kasa/python-kasa/pull/440) (@rytilahti) -**Closed issues:** - -- Powershell version? [\#461](https://github.com/python-kasa/python-kasa/issues/461) -- Add `set_cold_time` to Motion module [\#452](https://github.com/python-kasa/python-kasa/issues/452) -- Discover.discover\(\) only returning ip adress on ep10 outlet [\#447](https://github.com/python-kasa/python-kasa/issues/447) -- Query current wifi config? [\#445](https://github.com/python-kasa/python-kasa/issues/445) -- bulb.turn\_off making device undiscoverable [\#444](https://github.com/python-kasa/python-kasa/issues/444) -- best privacy practices for Kasa devices [\#438](https://github.com/python-kasa/python-kasa/issues/438) -- Access device from different network [\#424](https://github.com/python-kasa/python-kasa/issues/424) -- Lots of test failure with 0.5.0 [\#411](https://github.com/python-kasa/python-kasa/issues/411) - **Merged pull requests:** -- Release 0.5.2 [\#475](https://github.com/python-kasa/python-kasa/pull/475) (@rytilahti) - Add benchmarks for speedups [\#473](https://github.com/python-kasa/python-kasa/pull/473) (@bdraco) - Add fixture for KP405 Smart Dimmer Plug [\#470](https://github.com/python-kasa/python-kasa/pull/470) (@xinud190) - Remove importlib-metadata dependency [\#457](https://github.com/python-kasa/python-kasa/pull/457) (@rytilahti) @@ -668,13 +484,6 @@ Besides some small improvements, this release: [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.0...0.5.1) -This minor release contains mostly small UX fine-tuning and documentation improvements alongside with bug fixes: -* Improved console tool (JSON output, colorized output if rich is installed) -* Pretty, colorized console output, if `rich` is installed -* Support for configuring bulb presets -* Usage data is now reported in the expected format -* Dependency pinning is relaxed to give downstreams more control - **Breaking changes:** - Implement changing the bulb turn-on behavior [\#381](https://github.com/python-kasa/python-kasa/pull/381) (@rytilahti) @@ -691,16 +500,11 @@ This minor release contains mostly small UX fine-tuning and documentation improv **Fixed bugs:** -- cli.py usage year and month options do not output data as expected [\#373](https://github.com/python-kasa/python-kasa/issues/373) -- cli.py usage --year command passes year argument incorrectly [\#371](https://github.com/python-kasa/python-kasa/issues/371) -- KP303 reporting as device off [\#319](https://github.com/python-kasa/python-kasa/issues/319) -- HS210 not updating the state correctly [\#193](https://github.com/python-kasa/python-kasa/issues/193) - Fix year emeter for cli by using kwarg for year parameter [\#372](https://github.com/python-kasa/python-kasa/pull/372) (@rytilahti) - Return usage.get\_{monthstat,daystat} in expected format [\#394](https://github.com/python-kasa/python-kasa/pull/394) (@jules43) **Documentation updates:** -- Update misleading docs about supported devices \(was: add support for EP25 plug\) [\#367](https://github.com/python-kasa/python-kasa/issues/367) - Minor fixes to smartbulb docs [\#431](https://github.com/python-kasa/python-kasa/pull/431) (@rytilahti) - Add a note that transition is not supported by all devices [\#398](https://github.com/python-kasa/python-kasa/pull/398) (@rytilahti) - fix more outdated CLI examples, remove EP40 from bulb list [\#383](https://github.com/python-kasa/python-kasa/pull/383) (@HankB) @@ -710,37 +514,8 @@ This minor release contains mostly small UX fine-tuning and documentation improv - Update README to add missing models and fix a link [\#351](https://github.com/python-kasa/python-kasa/pull/351) (@rytilahti) - Add KP125 test fixture and support note. [\#350](https://github.com/python-kasa/python-kasa/pull/350) (@jalseth) -**Closed issues:** - -- detecting when a switch changes state [\#427](https://github.com/python-kasa/python-kasa/issues/427) -- discovery fails for aliases [\#426](https://github.com/python-kasa/python-kasa/issues/426) -- traceback when no devices exist [\#425](https://github.com/python-kasa/python-kasa/issues/425) -- Discover.discover\(\) in a cron that runs every 1 min [\#421](https://github.com/python-kasa/python-kasa/issues/421) -- add Schedule rule? [\#418](https://github.com/python-kasa/python-kasa/issues/418) -- Cannot find EP10 using kasa discover [\#417](https://github.com/python-kasa/python-kasa/issues/417) -- modulenotfound error [\#414](https://github.com/python-kasa/python-kasa/issues/414) -- Issue enabling motion sensor, ES20M\(US\) [\#408](https://github.com/python-kasa/python-kasa/issues/408) -- HS103 not discovered by kasa CLI [\#406](https://github.com/python-kasa/python-kasa/issues/406) -- Multiple warnings from running pytest due to asyncio issues [\#396](https://github.com/python-kasa/python-kasa/issues/396) -- Transition ignored with KL420L5 light strips [\#389](https://github.com/python-kasa/python-kasa/issues/389) -- cli.py passes a dictionary \(TYPE\_TO\_CLASS\) to click.Choice which takes a Sequence\[str\] [\#384](https://github.com/python-kasa/python-kasa/issues/384) -- Error running `kasa wifi scan` [\#376](https://github.com/python-kasa/python-kasa/issues/376) -- Unable to connect to brand new EP40 v1.8 [\#366](https://github.com/python-kasa/python-kasa/issues/366) -- Add support for setting default behaviors for a soft or hard power on of the bulb [\#365](https://github.com/python-kasa/python-kasa/issues/365) -- Set bulb hue using variable [\#361](https://github.com/python-kasa/python-kasa/issues/361) -- Help with SmartLightStrip set\_custom\_effect [\#360](https://github.com/python-kasa/python-kasa/issues/360) -- Import "kasa" could not be resolved [\#357](https://github.com/python-kasa/python-kasa/issues/357) -- Wall switch ES20M \(--type dimmer\) is working [\#353](https://github.com/python-kasa/python-kasa/issues/353) -- HS107 reports `state` not `relay_state` throwing a `KeyError` [\#349](https://github.com/python-kasa/python-kasa/issues/349) -- Error Installing On Windows 10 [\#347](https://github.com/python-kasa/python-kasa/issues/347) -- Error using Kasa [\#346](https://github.com/python-kasa/python-kasa/issues/346) -- KS220M\(US\) support [\#268](https://github.com/python-kasa/python-kasa/issues/268) -- Add machine-readable output [\#209](https://github.com/python-kasa/python-kasa/issues/209) -- Can we donate? [\#77](https://github.com/python-kasa/python-kasa/issues/77) - **Merged pull requests:** -- Prepare 0.5.1 [\#434](https://github.com/python-kasa/python-kasa/pull/434) (@rytilahti) - Some release preparation janitoring [\#432](https://github.com/python-kasa/python-kasa/pull/432) (@rytilahti) - Bump certifi from 2021.10.8 to 2022.12.7 [\#409](https://github.com/python-kasa/python-kasa/pull/409) (@dependabot[bot]) - Add FUNDING.yml [\#402](https://github.com/python-kasa/python-kasa/pull/402) (@rytilahti) @@ -761,59 +536,23 @@ This minor release contains mostly small UX fine-tuning and documentation improv [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.3...0.5.0) -This is the first release of 0.5 series which includes converting the code base towards more modular approach where device-exposed modules (e.g., emeter, antitheft, or schedule) are implemented in their separate python modules to decouple them from the device-specific classes. - -There should be no API breaking changes, but some previous issues hint that there may be as information from all supported modules are now requested during each update cycle (depending on the device type): -* Basic system info -* Emeter -* Time - properties (like `on_since`) use now time from the device for calculation to avoid jitter caused by different time between the host and the device -* Usage statistics - similar interface to emeter, but reports on-time statistics instead of energy consumption (new) -* Countdown (new) -* Antitheft (new) -* Schedule (new) -* Motion - for configuring motion settings on some dimmers (new) -* Ambientlight - for configuring brightness limits when motion sensor actuates on some dimmers (new) -* Cloud - information about cloud connectivity (new) - -For developers, the new functionalities are currently only exposed through the implementation modules accessible through `modules` property. -Pull requests improving the functionality of modules as well as adding better interfaces to device classes are welcome! - **Breaking changes:** - Drop deprecated, type-specific options in favor of --type [\#336](https://github.com/python-kasa/python-kasa/pull/336) (@rytilahti) - Convert the codebase to be more modular [\#299](https://github.com/python-kasa/python-kasa/pull/299) (@rytilahti) -**Implemented enhancements:** - -- Improve HS220 support [\#44](https://github.com/python-kasa/python-kasa/issues/44) - **Fixed bugs:** -- Skip running discovery on --help on subcommands [\#122](https://github.com/python-kasa/python-kasa/issues/122) - Avoid retrying open\_connection on unrecoverable errors [\#340](https://github.com/python-kasa/python-kasa/pull/340) (@bdraco) - Avoid discovery on --help [\#335](https://github.com/python-kasa/python-kasa/pull/335) (@rytilahti) **Documentation updates:** -- Trying to poll device every 5 seconds but getting asyncio errors [\#316](https://github.com/python-kasa/python-kasa/issues/316) -- Docs: Smart Strip - Emeter feature Note [\#257](https://github.com/python-kasa/python-kasa/issues/257) -- Documentation addition: Smartplug access to internet ntp server pool. [\#129](https://github.com/python-kasa/python-kasa/issues/129) - Export modules & make sphinx happy [\#334](https://github.com/python-kasa/python-kasa/pull/334) (@rytilahti) - Various documentation updates [\#333](https://github.com/python-kasa/python-kasa/pull/333) (@rytilahti) -**Closed issues:** - -- "on since" changes [\#295](https://github.com/python-kasa/python-kasa/issues/295) -- How to access KP115 runtime data? [\#244](https://github.com/python-kasa/python-kasa/issues/244) -- How to resolve "Detected protocol reuse between different event loop" warning? [\#238](https://github.com/python-kasa/python-kasa/issues/238) -- Handle discovery where multiple LAN interfaces exist [\#104](https://github.com/python-kasa/python-kasa/issues/104) -- Hyper-V \(and probably virtualbox\) break UDP discovery [\#101](https://github.com/python-kasa/python-kasa/issues/101) -- Trying to get extended lightstrip functionality [\#100](https://github.com/python-kasa/python-kasa/issues/100) -- Can the HS105 be controlled without internet? [\#72](https://github.com/python-kasa/python-kasa/issues/72) - **Merged pull requests:** -- Prepare 0.5.0 [\#342](https://github.com/python-kasa/python-kasa/pull/342) (@rytilahti) - Add fixtures for kl420 [\#339](https://github.com/python-kasa/python-kasa/pull/339) (@bdraco) ## [0.4.3](https://github.com/python-kasa/python-kasa/tree/0.4.3) (2022-04-05) @@ -822,16 +561,10 @@ Pull requests improving the functionality of modules as well as adding better in **Fixed bugs:** -- Divide by zero when HS300 powerstrip is discovered [\#292](https://github.com/python-kasa/python-kasa/issues/292) - Ensure bulb state is restored when turning back on [\#330](https://github.com/python-kasa/python-kasa/pull/330) (@bdraco) -**Closed issues:** - -- KL420L5 controls [\#327](https://github.com/python-kasa/python-kasa/issues/327) - **Merged pull requests:** -- Release 0.4.3 [\#332](https://github.com/python-kasa/python-kasa/pull/332) (@rytilahti) - Update pre-commit hooks to fix black in CI [\#331](https://github.com/python-kasa/python-kasa/pull/331) (@rytilahti) - Fix test\_deprecated\_type stalling [\#325](https://github.com/python-kasa/python-kasa/pull/325) (@bdraco) @@ -848,24 +581,10 @@ Pull requests improving the functionality of modules as well as adding better in **Fixed bugs:** -- TypeError: \_\_init\_\_\(\) got an unexpected keyword argument 'package\_name' [\#311](https://github.com/python-kasa/python-kasa/issues/311) -- RuntimeError: Event loop is closed on WSL [\#294](https://github.com/python-kasa/python-kasa/issues/294) - Don't crash on devices not reporting features [\#317](https://github.com/python-kasa/python-kasa/pull/317) (@rytilahti) -**Closed issues:** - -- SmartDeviceException: Communication error on system:set\_relay\_state [\#309](https://github.com/python-kasa/python-kasa/issues/309) -- Add Support: ES20M and KS200M motion/light switches [\#308](https://github.com/python-kasa/python-kasa/issues/308) -- New problem with installing on Ubuntu 20.04.3 LTS [\#305](https://github.com/python-kasa/python-kasa/issues/305) -- KeyError: 'emeter' when discovering [\#302](https://github.com/python-kasa/python-kasa/issues/302) -- RuntimeError: Event loop is closed [\#291](https://github.com/python-kasa/python-kasa/issues/291) -- provisioning format [\#290](https://github.com/python-kasa/python-kasa/issues/290) -- Fix CI publishing on pypi [\#222](https://github.com/python-kasa/python-kasa/issues/222) -- LED strips effects are not supported \(was LEDs is not turning on after switching on\) [\#191](https://github.com/python-kasa/python-kasa/issues/191) - **Merged pull requests:** -- Release 0.4.2 [\#321](https://github.com/python-kasa/python-kasa/pull/321) (@rytilahti) - Add pyupgrade to CI runs [\#314](https://github.com/python-kasa/python-kasa/pull/314) (@rytilahti) - Depend on asyncclick \>= 8 [\#312](https://github.com/python-kasa/python-kasa/pull/312) (@rytilahti) - Guard emeter accesses to avoid keyerrors [\#304](https://github.com/python-kasa/python-kasa/pull/304) (@rytilahti) @@ -897,31 +616,11 @@ Pull requests improving the functionality of modules as well as adding better in **Fixed bugs:** -- Discovery on WSL results in OSError: \[Errno 22\] Invalid argument [\#246](https://github.com/python-kasa/python-kasa/issues/246) -- New firmware for HS103 blocking local access? [\#42](https://github.com/python-kasa/python-kasa/issues/42) - Pin mistune to \<2.0.0 to fix doc builds [\#270](https://github.com/python-kasa/python-kasa/pull/270) (@rytilahti) - Catch exceptions raised on unknown devices during discovery [\#240](https://github.com/python-kasa/python-kasa/pull/240) (@rytilahti) -**Closed issues:** - -- Control device with alias via python api? [\#285](https://github.com/python-kasa/python-kasa/issues/285) -- Can't install using pip install python-kasa [\#255](https://github.com/python-kasa/python-kasa/issues/255) -- Kasa Smart Bulb KL135 - Unknown color temperature range error [\#252](https://github.com/python-kasa/python-kasa/issues/252) -- KL400 Support [\#247](https://github.com/python-kasa/python-kasa/issues/247) -- Cloud support? [\#245](https://github.com/python-kasa/python-kasa/issues/245) -- Support for kp401 [\#241](https://github.com/python-kasa/python-kasa/issues/241) -- LB130 Bulb stopped working [\#237](https://github.com/python-kasa/python-kasa/issues/237) -- Unable to constantly query bulb in loop [\#225](https://github.com/python-kasa/python-kasa/issues/225) -- HS103: Unable to query the device: unpack requires a buffer of 4 bytes [\#187](https://github.com/python-kasa/python-kasa/issues/187) -- Help request - query value [\#171](https://github.com/python-kasa/python-kasa/issues/171) -- Can't Discover Devices [\#164](https://github.com/python-kasa/python-kasa/issues/164) -- Concurrency performance question [\#110](https://github.com/python-kasa/python-kasa/issues/110) -- Define the port by self? [\#108](https://github.com/python-kasa/python-kasa/issues/108) -- Convert homeassistant integration to use the library [\#9](https://github.com/python-kasa/python-kasa/issues/9) - **Merged pull requests:** -- Prepare 0.4.1 [\#288](https://github.com/python-kasa/python-kasa/pull/288) (@rytilahti) - Publish to pypi on github release published [\#287](https://github.com/python-kasa/python-kasa/pull/287) (@rytilahti) - Relax asyncclick version requirement [\#286](https://github.com/python-kasa/python-kasa/pull/286) (@rytilahti) - Do not crash on discovery on WSL [\#283](https://github.com/python-kasa/python-kasa/pull/283) (@rytilahti) @@ -932,43 +631,13 @@ Pull requests improving the functionality of modules as well as adding better in ## [0.4.0](https://github.com/python-kasa/python-kasa/tree/0.4.0) (2021-09-27) -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev5...0.4.0) +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.pre0...0.4.0) **Implemented enhancements:** - Fix lock being unexpectedly reset on close [\#218](https://github.com/python-kasa/python-kasa/pull/218) (@bdraco) - Avoid calling pformat unless debug logging is enabled [\#217](https://github.com/python-kasa/python-kasa/pull/217) (@bdraco) - -**Closed issues:** - -- Debug logging in protocol.py is the majority of the execution time [\#216](https://github.com/python-kasa/python-kasa/issues/216) - -**Merged pull requests:** - -- Release 0.4.0 [\#221](https://github.com/python-kasa/python-kasa/pull/221) (@rytilahti) -- Add github workflow for pypi publishing [\#220](https://github.com/python-kasa/python-kasa/pull/220) (@rytilahti) -- Add host information to protocol debug logs [\#219](https://github.com/python-kasa/python-kasa/pull/219) (@rytilahti) - -## [0.4.0.dev5](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev5) (2021-09-24) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev4...0.4.0.dev5) - -**Implemented enhancements:** - - Keep connection open and lock to prevent duplicate requests [\#213](https://github.com/python-kasa/python-kasa/pull/213) (@bdraco) - -**Merged pull requests:** - -- Release 0.4.0.dev5 [\#215](https://github.com/python-kasa/python-kasa/pull/215) (@rytilahti) -- Add KL130 fixture, initial lightstrip tests [\#214](https://github.com/python-kasa/python-kasa/pull/214) (@rytilahti) -- Cleanup discovery & add tests [\#212](https://github.com/python-kasa/python-kasa/pull/212) (@rytilahti) - -## [0.4.0.dev4](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev4) (2021-09-23) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev3...0.4.0.dev4) - -**Implemented enhancements:** - - Improve emeterstatus API, move into own module [\#205](https://github.com/python-kasa/python-kasa/pull/205) (@rytilahti) - Avoid temp array during encrypt and decrypt [\#204](https://github.com/python-kasa/python-kasa/pull/204) (@bdraco) - Add emeter support for strip sockets [\#203](https://github.com/python-kasa/python-kasa/pull/203) (@bdraco) @@ -977,30 +646,27 @@ Pull requests improving the functionality of modules as well as adding better in - Improve bulb support \(alias, time settings\) [\#198](https://github.com/python-kasa/python-kasa/pull/198) (@rytilahti) - Improve testing harness to allow tests on real devices [\#197](https://github.com/python-kasa/python-kasa/pull/197) (@rytilahti) - cli: add human-friendly printout when calling temperature on non-supported devices [\#196](https://github.com/python-kasa/python-kasa/pull/196) (@JaydenRA) +- 'Interface' parameter added to discovery process [\#79](https://github.com/python-kasa/python-kasa/pull/79) (@dmitryelj) +- Add support for lightstrips \(KL430\) [\#74](https://github.com/python-kasa/python-kasa/pull/74) (@rytilahti) **Fixed bugs:** -- KL430: Throw error for Device specific information [\#189](https://github.com/python-kasa/python-kasa/issues/189) -- HS300 Children plugs have emeter [\#64](https://github.com/python-kasa/python-kasa/issues/64) - dump\_devinfo: handle latitude/longitude keys properly [\#175](https://github.com/python-kasa/python-kasa/pull/175) (@rytilahti) +- Simplify discovery query, refactor dump-devinfo [\#147](https://github.com/python-kasa/python-kasa/pull/147) (@rytilahti) +- Return None instead of raising an exception on missing, valid emeter keys [\#146](https://github.com/python-kasa/python-kasa/pull/146) (@rytilahti) +- Simplify device class detection for discovery, fix hardcoded timeout [\#112](https://github.com/python-kasa/python-kasa/pull/112) (@rytilahti) +- Update cli.py to addresss crash on year/month calls and improve output formatting [\#103](https://github.com/python-kasa/python-kasa/pull/103) (@BuongiornoTexas) **Documentation updates:** -- Discover does not support specifying network interface [\#167](https://github.com/python-kasa/python-kasa/issues/167) - -**Closed issues:** - -- Feature Request - Toggle Command [\#188](https://github.com/python-kasa/python-kasa/issues/188) -- Is It Compatible With HS105? [\#186](https://github.com/python-kasa/python-kasa/issues/186) -- Cannot use some functions with KP303 [\#181](https://github.com/python-kasa/python-kasa/issues/181) -- Help needed - awaiting game [\#179](https://github.com/python-kasa/python-kasa/issues/179) -- Version inconsistency between CLI and pip [\#177](https://github.com/python-kasa/python-kasa/issues/177) -- Release 0.4.0.dev3? [\#169](https://github.com/python-kasa/python-kasa/issues/169) -- Can't command or query HS200 v5 switch [\#161](https://github.com/python-kasa/python-kasa/issues/161) +- Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) (@rytilahti) **Merged pull requests:** -- Release 0.4.0.dev4 [\#210](https://github.com/python-kasa/python-kasa/pull/210) (@rytilahti) +- Add github workflow for pypi publishing [\#220](https://github.com/python-kasa/python-kasa/pull/220) (@rytilahti) +- Add host information to protocol debug logs [\#219](https://github.com/python-kasa/python-kasa/pull/219) (@rytilahti) +- Add KL130 fixture, initial lightstrip tests [\#214](https://github.com/python-kasa/python-kasa/pull/214) (@rytilahti) +- Cleanup discovery & add tests [\#212](https://github.com/python-kasa/python-kasa/pull/212) (@rytilahti) - More CI fixes [\#208](https://github.com/python-kasa/python-kasa/pull/208) (@rytilahti) - Fix CI dep installation [\#207](https://github.com/python-kasa/python-kasa/pull/207) (@rytilahti) - Use github actions instead of azure pipelines [\#206](https://github.com/python-kasa/python-kasa/pull/206) (@rytilahti) @@ -1010,41 +676,6 @@ Pull requests improving the functionality of modules as well as adding better in - Use less strict matcher for kl430 color temperature [\#190](https://github.com/python-kasa/python-kasa/pull/190) (@rytilahti) - Add EP10\(US\) 1.0 1.0.2 fixture [\#174](https://github.com/python-kasa/python-kasa/pull/174) (@nbrew) - Add a note about using the discovery target parameter [\#168](https://github.com/python-kasa/python-kasa/pull/168) (@leandroreox) - -## [0.4.0.dev3](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev3) (2021-06-16) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev2...0.4.0.dev3) - -**Fixed bugs:** - -- `Unable to find a value for 'current'` error when attempting to query KL125 bulb emeter [\#142](https://github.com/python-kasa/python-kasa/issues/142) -- `Unknown color temperature range` error when attempting to query KL125 bulb state [\#141](https://github.com/python-kasa/python-kasa/issues/141) -- Simplify discovery query, refactor dump-devinfo [\#147](https://github.com/python-kasa/python-kasa/pull/147) (@rytilahti) -- Return None instead of raising an exception on missing, valid emeter keys [\#146](https://github.com/python-kasa/python-kasa/pull/146) (@rytilahti) - -**Documentation updates:** - -- Add ability to control individual sockets on KP400 [\#121](https://github.com/python-kasa/python-kasa/issues/121) -- Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) (@rytilahti) - -**Closed issues:** - -- After installing, command `kasa` not found [\#165](https://github.com/python-kasa/python-kasa/issues/165) -- KL430 causing "non-hexadecimal number found in fromhex\(\) arg at position 2" error in smartdevice.py [\#159](https://github.com/python-kasa/python-kasa/issues/159) -- Cant get smart strip children to work [\#144](https://github.com/python-kasa/python-kasa/issues/144) -- `kasa --host 192.168.1.67 wifi join ` does not change network [\#139](https://github.com/python-kasa/python-kasa/issues/139) -- Poetry returns error when installing dependencies [\#131](https://github.com/python-kasa/python-kasa/issues/131) -- 'kasa wifi scan' raises RuntimeError [\#127](https://github.com/python-kasa/python-kasa/issues/127) -- Runtime Error when I execute Kasa emeter command [\#124](https://github.com/python-kasa/python-kasa/issues/124) -- HS105\(US\) HW 5.0/SW 1.0.2 Not Working [\#119](https://github.com/python-kasa/python-kasa/issues/119) -- HS110\(UK\) not discoverable [\#113](https://github.com/python-kasa/python-kasa/issues/113) -- Stopping Kasa SmartDevices from phoning home [\#111](https://github.com/python-kasa/python-kasa/issues/111) -- TP Link Dimmer switch \(HS220\) hardware version 2.0 not being discovered [\#105](https://github.com/python-kasa/python-kasa/issues/105) -- Support for P100 Smart Plug [\#83](https://github.com/python-kasa/python-kasa/issues/83) - -**Merged pull requests:** - -- Prepare 0.4.0.dev3 [\#172](https://github.com/python-kasa/python-kasa/pull/172) (@rytilahti) - Simplify mac address handling [\#162](https://github.com/python-kasa/python-kasa/pull/162) (@rytilahti) - Added KL125 and HS200 fixture dumps and updated tests to run on new format [\#160](https://github.com/python-kasa/python-kasa/pull/160) (@brianthedavis) - Add KL125 bulb definition [\#143](https://github.com/python-kasa/python-kasa/pull/143) (@mdarnol) @@ -1053,65 +684,7 @@ Pull requests improving the functionality of modules as well as adding better in - add tapo link, fix tplink-smarthome-simulator link [\#133](https://github.com/python-kasa/python-kasa/pull/133) (@rytilahti) - Leverage data from UDP discovery to initialize device structure [\#132](https://github.com/python-kasa/python-kasa/pull/132) (@dlee1j1) - Add HS220 hw 2.0 fixture [\#107](https://github.com/python-kasa/python-kasa/pull/107) (@appleguru) - -## [0.4.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev2) (2020-11-21) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev1...0.4.0.dev2) - -**Implemented enhancements:** - -- 'Interface' parameter added to discovery process [\#79](https://github.com/python-kasa/python-kasa/pull/79) (@dmitryelj) - -**Fixed bugs:** - -- Simplify device class detection for discovery, fix hardcoded timeout [\#112](https://github.com/python-kasa/python-kasa/pull/112) (@rytilahti) -- Update cli.py to addresss crash on year/month calls and improve output formatting [\#103](https://github.com/python-kasa/python-kasa/pull/103) (@BuongiornoTexas) - -**Closed issues:** - -- TPLINK HS100 firmware 4.1 no longer has TCP 9999 available [\#114](https://github.com/python-kasa/python-kasa/issues/114) -- 7.1.2 Update to asyncclick breaks github install of python-kasa [\#106](https://github.com/python-kasa/python-kasa/issues/106) -- cli emeter year and month functions fail [\#102](https://github.com/python-kasa/python-kasa/issues/102) -- how to know the duration for which the plug was ON? [\#99](https://github.com/python-kasa/python-kasa/issues/99) -- problem controlling the smartplug through a controller [\#98](https://github.com/python-kasa/python-kasa/issues/98) -- unable to install [\#97](https://github.com/python-kasa/python-kasa/issues/97) -- Install on Ubuntu 18.04 no luck [\#96](https://github.com/python-kasa/python-kasa/issues/96) -- issue with installation [\#95](https://github.com/python-kasa/python-kasa/issues/95) -- Running via Crontab [\#92](https://github.com/python-kasa/python-kasa/issues/92) -- Issues with setup [\#91](https://github.com/python-kasa/python-kasa/issues/91) - -**Merged pull requests:** - -- Release 0.4.0.dev2 [\#118](https://github.com/python-kasa/python-kasa/pull/118) (@rytilahti) - Pin dependencies on major versions, add poetry.lock [\#94](https://github.com/python-kasa/python-kasa/pull/94) (@rytilahti) - -## [0.4.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev1) (2020-07-28) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev0...0.4.0.dev1) - -**Implemented enhancements:** - -- KL430 support [\#67](https://github.com/python-kasa/python-kasa/issues/67) -- Improve retry logic for discovery, messaging \(was: Handle empty responses\) [\#38](https://github.com/python-kasa/python-kasa/issues/38) -- Add support for lightstrips \(KL430\) [\#74](https://github.com/python-kasa/python-kasa/pull/74) (@rytilahti) - -**Documentation updates:** - -- Improve poetry usage documentation [\#60](https://github.com/python-kasa/python-kasa/issues/60) - -**Closed issues:** - -- I don't python... how do I make this executable? [\#88](https://github.com/python-kasa/python-kasa/issues/88) -- ImportError: cannot import name 'smartplug' [\#87](https://github.com/python-kasa/python-kasa/issues/87) -- not able to pip install the library [\#82](https://github.com/python-kasa/python-kasa/issues/82) -- Discover.discover\(\) add selecting network interface \[pull request\] [\#78](https://github.com/python-kasa/python-kasa/issues/78) -- LB100 unable to turn on or off the lights [\#68](https://github.com/python-kasa/python-kasa/issues/68) -- sys\_info not None fails assertion [\#55](https://github.com/python-kasa/python-kasa/issues/55) -- Upload pre-release to pypi for easier testing [\#17](https://github.com/python-kasa/python-kasa/issues/17) - -**Merged pull requests:** - -- Release 0.4.0.dev1 [\#93](https://github.com/python-kasa/python-kasa/pull/93) (@rytilahti) - add a small example script to show library usage [\#90](https://github.com/python-kasa/python-kasa/pull/90) (@rytilahti) - add .readthedocs.yml required for poetry builds [\#89](https://github.com/python-kasa/python-kasa/pull/89) (@rytilahti) - Improve installation instructions [\#86](https://github.com/python-kasa/python-kasa/pull/86) (@rytilahti) @@ -1123,10 +696,6 @@ Pull requests improving the functionality of modules as well as adding better in - Bulbs: allow specifying transition for state changes [\#70](https://github.com/python-kasa/python-kasa/pull/70) (@rytilahti) - Add transition support for SmartDimmer [\#69](https://github.com/python-kasa/python-kasa/pull/69) (@connorproctor) -## [0.4.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev0) (2020-05-27) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.pre0...0.4.0.dev0) - ## [0.4.0.pre0](https://github.com/python-kasa/python-kasa/tree/0.4.0.pre0) (2020-05-27) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.3.5...0.4.0.pre0) @@ -1135,28 +704,6 @@ Pull requests improving the functionality of modules as well as adding better in - Add commands to control the wifi settings [\#45](https://github.com/python-kasa/python-kasa/pull/45) (@rytilahti) -**Fixed bugs:** - -- HSV cli command not working [\#43](https://github.com/python-kasa/python-kasa/issues/43) - -**Closed issues:** - -- Pull request \#54 broke installer? [\#66](https://github.com/python-kasa/python-kasa/issues/66) -- RFC: remove implicit updates after state changes? [\#61](https://github.com/python-kasa/python-kasa/issues/61) -- How to install? [\#57](https://github.com/python-kasa/python-kasa/issues/57) -- Request all necessary information during update\(\) [\#53](https://github.com/python-kasa/python-kasa/issues/53) -- HS107 Support [\#37](https://github.com/python-kasa/python-kasa/issues/37) -- Separate dimmer-related code from smartplug class [\#33](https://github.com/python-kasa/python-kasa/issues/33) -- Add Mac OSX and Windows for CI [\#30](https://github.com/python-kasa/python-kasa/issues/30) -- KP303\(UK\) does not pass check with pytest [\#27](https://github.com/python-kasa/python-kasa/issues/27) -- Remove sync interface wrapper [\#12](https://github.com/python-kasa/python-kasa/issues/12) -- Mass close pyhs100 issues and PRs [\#11](https://github.com/python-kasa/python-kasa/issues/11) -- Update readme [\#10](https://github.com/python-kasa/python-kasa/issues/10) -- Add contribution guidelines and instructions [\#8](https://github.com/python-kasa/python-kasa/issues/8) -- Convert discovery to use asyncio [\#7](https://github.com/python-kasa/python-kasa/issues/7) -- Python Version? [\#4](https://github.com/python-kasa/python-kasa/issues/4) -- Fix failing tests: KeyError: 'relay\_state' [\#2](https://github.com/python-kasa/python-kasa/issues/2) - **Merged pull requests:** - Add retries to protocol queries [\#65](https://github.com/python-kasa/python-kasa/pull/65) (@rytilahti) diff --git a/RELEASING.md b/RELEASING.md index 3694cc3b2..476e9de59 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -58,10 +58,10 @@ If not already created ### Create new issue linked to the milestone ```bash -gh issue create --label "release-summary" --milestone $NEW_RELEASE --title "$NEW_RELEASE Release Summary" --body "Some summary text" +gh issue create --label "release-summary" --milestone $NEW_RELEASE --title "$NEW_RELEASE Release Summary" --body "## Release Summary" ``` -You can exclude the --body option to get an interactive editor or leave blank and go into the issue on github and edit there. +You can exclude the --body option to get an interactive editor or go into the issue on github and edit there. ### Close the issue @@ -81,13 +81,14 @@ Regex should be something like this `^((?!0\.7\.0)(.*dev\d))+`. The first match ```bash EXCLUDE_TAGS=${NEW_RELEASE%.dev*}; EXCLUDE_TAGS=${EXCLUDE_TAGS//"."/"\."}; EXCLUDE_TAGS="^((?!"$EXCLUDE_TAGS")(.*dev\d))+" -github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md --exclude-tags-regex "$EXCLUDE_TAGS" +echo "$EXCLUDE_TAGS" +github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md --no-issues --exclude-tags-regex "$EXCLUDE_TAGS" ``` ### For production ```bash -github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md --exclude-tags-regex 'dev\d$' +github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md --no-issues --exclude-tags-regex 'dev\d$' ``` You can ignore warnings about missing PR commits like below as these relate to PRs to branches other than master: diff --git a/poetry.lock b/poetry.lock index 3d28de256..71310f732 100644 --- a/poetry.lock +++ b/poetry.lock @@ -123,13 +123,13 @@ files = [ [[package]] name = "annotated-types" -version = "0.6.0" +version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" files = [ - {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, - {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] [package.dependencies] @@ -137,13 +137,13 @@ typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} [[package]] name = "anyio" -version = "4.3.0" +version = "4.4.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" files = [ - {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, - {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, ] [package.dependencies] @@ -215,13 +215,13 @@ tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "p [[package]] name = "babel" -version = "2.14.0" +version = "2.15.0" description = "Internationalization utilities" optional = true -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, - {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, + {file = "Babel-2.15.0-py3-none-any.whl", hash = "sha256:08706bdad8d0a3413266ab61bd6c34d0c28d6e1e7badf40a2cebe67644e2e1fb"}, + {file = "babel-2.15.0.tar.gz", hash = "sha256:8daf0e265d05768bc6c7a314cf1321e9a123afc328cc635c18622a2f30a04413"}, ] [package.dependencies] @@ -243,13 +243,13 @@ files = [ [[package]] name = "certifi" -version = "2024.2.2" +version = "2024.6.2" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, - {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, + {file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"}, + {file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"}, ] [[package]] @@ -465,63 +465,63 @@ files = [ [[package]] name = "coverage" -version = "7.5.0" +version = "7.5.3" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:432949a32c3e3f820af808db1833d6d1631664d53dd3ce487aa25d574e18ad1c"}, - {file = "coverage-7.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bd7065249703cbeb6d4ce679c734bef0ee69baa7bff9724361ada04a15b7e3b"}, - {file = "coverage-7.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbfe6389c5522b99768a93d89aca52ef92310a96b99782973b9d11e80511f932"}, - {file = "coverage-7.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39793731182c4be939b4be0cdecde074b833f6171313cf53481f869937129ed3"}, - {file = "coverage-7.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a5dbe1ba1bf38d6c63b6d2c42132d45cbee6d9f0c51b52c59aa4afba057517"}, - {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:357754dcdfd811462a725e7501a9b4556388e8ecf66e79df6f4b988fa3d0b39a"}, - {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a81eb64feded34f40c8986869a2f764f0fe2db58c0530d3a4afbcde50f314880"}, - {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:51431d0abbed3a868e967f8257c5faf283d41ec882f58413cf295a389bb22e58"}, - {file = "coverage-7.5.0-cp310-cp310-win32.whl", hash = "sha256:f609ebcb0242d84b7adeee2b06c11a2ddaec5464d21888b2c8255f5fd6a98ae4"}, - {file = "coverage-7.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:6782cd6216fab5a83216cc39f13ebe30adfac2fa72688c5a4d8d180cd52e8f6a"}, - {file = "coverage-7.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e768d870801f68c74c2b669fc909839660180c366501d4cc4b87efd6b0eee375"}, - {file = "coverage-7.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:84921b10aeb2dd453247fd10de22907984eaf80901b578a5cf0bb1e279a587cb"}, - {file = "coverage-7.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:710c62b6e35a9a766b99b15cdc56d5aeda0914edae8bb467e9c355f75d14ee95"}, - {file = "coverage-7.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c379cdd3efc0658e652a14112d51a7668f6bfca7445c5a10dee7eabecabba19d"}, - {file = "coverage-7.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea9d3ca80bcf17edb2c08a4704259dadac196fe5e9274067e7a20511fad1743"}, - {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:41327143c5b1d715f5f98a397608f90ab9ebba606ae4e6f3389c2145410c52b1"}, - {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:565b2e82d0968c977e0b0f7cbf25fd06d78d4856289abc79694c8edcce6eb2de"}, - {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cf3539007202ebfe03923128fedfdd245db5860a36810136ad95a564a2fdffff"}, - {file = "coverage-7.5.0-cp311-cp311-win32.whl", hash = "sha256:bf0b4b8d9caa8d64df838e0f8dcf68fb570c5733b726d1494b87f3da85db3a2d"}, - {file = "coverage-7.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c6384cc90e37cfb60435bbbe0488444e54b98700f727f16f64d8bfda0b84656"}, - {file = "coverage-7.5.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fed7a72d54bd52f4aeb6c6e951f363903bd7d70bc1cad64dd1f087980d309ab9"}, - {file = "coverage-7.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cbe6581fcff7c8e262eb574244f81f5faaea539e712a058e6707a9d272fe5b64"}, - {file = "coverage-7.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad97ec0da94b378e593ef532b980c15e377df9b9608c7c6da3506953182398af"}, - {file = "coverage-7.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd4bacd62aa2f1a1627352fe68885d6ee694bdaebb16038b6e680f2924a9b2cc"}, - {file = "coverage-7.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adf032b6c105881f9d77fa17d9eebe0ad1f9bfb2ad25777811f97c5362aa07f2"}, - {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ba01d9ba112b55bfa4b24808ec431197bb34f09f66f7cb4fd0258ff9d3711b1"}, - {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f0bfe42523893c188e9616d853c47685e1c575fe25f737adf473d0405dcfa7eb"}, - {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a9a7ef30a1b02547c1b23fa9a5564f03c9982fc71eb2ecb7f98c96d7a0db5cf2"}, - {file = "coverage-7.5.0-cp312-cp312-win32.whl", hash = "sha256:3c2b77f295edb9fcdb6a250f83e6481c679335ca7e6e4a955e4290350f2d22a4"}, - {file = "coverage-7.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:427e1e627b0963ac02d7c8730ca6d935df10280d230508c0ba059505e9233475"}, - {file = "coverage-7.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9dd88fce54abbdbf4c42fb1fea0e498973d07816f24c0e27a1ecaf91883ce69e"}, - {file = "coverage-7.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a898c11dca8f8c97b467138004a30133974aacd572818c383596f8d5b2eb04a9"}, - {file = "coverage-7.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07dfdd492d645eea1bd70fb1d6febdcf47db178b0d99161d8e4eed18e7f62fe7"}, - {file = "coverage-7.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3d117890b6eee85887b1eed41eefe2e598ad6e40523d9f94c4c4b213258e4a4"}, - {file = "coverage-7.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6afd2e84e7da40fe23ca588379f815fb6dbbb1b757c883935ed11647205111cb"}, - {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a9960dd1891b2ddf13a7fe45339cd59ecee3abb6b8326d8b932d0c5da208104f"}, - {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ced268e82af993d7801a9db2dbc1d2322e786c5dc76295d8e89473d46c6b84d4"}, - {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e7c211f25777746d468d76f11719e64acb40eed410d81c26cefac641975beb88"}, - {file = "coverage-7.5.0-cp38-cp38-win32.whl", hash = "sha256:262fffc1f6c1a26125d5d573e1ec379285a3723363f3bd9c83923c9593a2ac25"}, - {file = "coverage-7.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:eed462b4541c540d63ab57b3fc69e7d8c84d5957668854ee4e408b50e92ce26a"}, - {file = "coverage-7.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d0194d654e360b3e6cc9b774e83235bae6b9b2cac3be09040880bb0e8a88f4a1"}, - {file = "coverage-7.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:33c020d3322662e74bc507fb11488773a96894aa82a622c35a5a28673c0c26f5"}, - {file = "coverage-7.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbdf2cae14a06827bec50bd58e49249452d211d9caddd8bd80e35b53cb04631"}, - {file = "coverage-7.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3235d7c781232e525b0761730e052388a01548bd7f67d0067a253887c6e8df46"}, - {file = "coverage-7.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2de4e546f0ec4b2787d625e0b16b78e99c3e21bc1722b4977c0dddf11ca84e"}, - {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4d0e206259b73af35c4ec1319fd04003776e11e859936658cb6ceffdeba0f5be"}, - {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2055c4fb9a6ff624253d432aa471a37202cd8f458c033d6d989be4499aed037b"}, - {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:075299460948cd12722a970c7eae43d25d37989da682997687b34ae6b87c0ef0"}, - {file = "coverage-7.5.0-cp39-cp39-win32.whl", hash = "sha256:280132aada3bc2f0fac939a5771db4fbb84f245cb35b94fae4994d4c1f80dae7"}, - {file = "coverage-7.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:c58536f6892559e030e6924896a44098bc1290663ea12532c78cef71d0df8493"}, - {file = "coverage-7.5.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:2b57780b51084d5223eee7b59f0d4911c31c16ee5aa12737c7a02455829ff067"}, - {file = "coverage-7.5.0.tar.gz", hash = "sha256:cf62d17310f34084c59c01e027259076479128d11e4661bb6c9acb38c5e19bb8"}, + {file = "coverage-7.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a6519d917abb15e12380406d721e37613e2a67d166f9fb7e5a8ce0375744cd45"}, + {file = "coverage-7.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aea7da970f1feccf48be7335f8b2ca64baf9b589d79e05b9397a06696ce1a1ec"}, + {file = "coverage-7.5.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:923b7b1c717bd0f0f92d862d1ff51d9b2b55dbbd133e05680204465f454bb286"}, + {file = "coverage-7.5.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62bda40da1e68898186f274f832ef3e759ce929da9a9fd9fcf265956de269dbc"}, + {file = "coverage-7.5.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8b7339180d00de83e930358223c617cc343dd08e1aa5ec7b06c3a121aec4e1d"}, + {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:25a5caf742c6195e08002d3b6c2dd6947e50efc5fc2c2205f61ecb47592d2d83"}, + {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:05ac5f60faa0c704c0f7e6a5cbfd6f02101ed05e0aee4d2822637a9e672c998d"}, + {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:239a4e75e09c2b12ea478d28815acf83334d32e722e7433471fbf641c606344c"}, + {file = "coverage-7.5.3-cp310-cp310-win32.whl", hash = "sha256:a5812840d1d00eafae6585aba38021f90a705a25b8216ec7f66aebe5b619fb84"}, + {file = "coverage-7.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:33ca90a0eb29225f195e30684ba4a6db05dbef03c2ccd50b9077714c48153cac"}, + {file = "coverage-7.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f81bc26d609bf0fbc622c7122ba6307993c83c795d2d6f6f6fd8c000a770d974"}, + {file = "coverage-7.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7cec2af81f9e7569280822be68bd57e51b86d42e59ea30d10ebdbb22d2cb7232"}, + {file = "coverage-7.5.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55f689f846661e3f26efa535071775d0483388a1ccfab899df72924805e9e7cd"}, + {file = "coverage-7.5.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50084d3516aa263791198913a17354bd1dc627d3c1639209640b9cac3fef5807"}, + {file = "coverage-7.5.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:341dd8f61c26337c37988345ca5c8ccabeff33093a26953a1ac72e7d0103c4fb"}, + {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ab0b028165eea880af12f66086694768f2c3139b2c31ad5e032c8edbafca6ffc"}, + {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5bc5a8c87714b0c67cfeb4c7caa82b2d71e8864d1a46aa990b5588fa953673b8"}, + {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:38a3b98dae8a7c9057bd91fbf3415c05e700a5114c5f1b5b0ea5f8f429ba6614"}, + {file = "coverage-7.5.3-cp311-cp311-win32.whl", hash = "sha256:fcf7d1d6f5da887ca04302db8e0e0cf56ce9a5e05f202720e49b3e8157ddb9a9"}, + {file = "coverage-7.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:8c836309931839cca658a78a888dab9676b5c988d0dd34ca247f5f3e679f4e7a"}, + {file = "coverage-7.5.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:296a7d9bbc598e8744c00f7a6cecf1da9b30ae9ad51c566291ff1314e6cbbed8"}, + {file = "coverage-7.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:34d6d21d8795a97b14d503dcaf74226ae51eb1f2bd41015d3ef332a24d0a17b3"}, + {file = "coverage-7.5.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e317953bb4c074c06c798a11dbdd2cf9979dbcaa8ccc0fa4701d80042d4ebf1"}, + {file = "coverage-7.5.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:705f3d7c2b098c40f5b81790a5fedb274113373d4d1a69e65f8b68b0cc26f6db"}, + {file = "coverage-7.5.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1196e13c45e327d6cd0b6e471530a1882f1017eb83c6229fc613cd1a11b53cd"}, + {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:015eddc5ccd5364dcb902eaecf9515636806fa1e0d5bef5769d06d0f31b54523"}, + {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fd27d8b49e574e50caa65196d908f80e4dff64d7e592d0c59788b45aad7e8b35"}, + {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:33fc65740267222fc02975c061eb7167185fef4cc8f2770267ee8bf7d6a42f84"}, + {file = "coverage-7.5.3-cp312-cp312-win32.whl", hash = "sha256:7b2a19e13dfb5c8e145c7a6ea959485ee8e2204699903c88c7d25283584bfc08"}, + {file = "coverage-7.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0bbddc54bbacfc09b3edaec644d4ac90c08ee8ed4844b0f86227dcda2d428fcb"}, + {file = "coverage-7.5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f78300789a708ac1f17e134593f577407d52d0417305435b134805c4fb135adb"}, + {file = "coverage-7.5.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b368e1aee1b9b75757942d44d7598dcd22a9dbb126affcbba82d15917f0cc155"}, + {file = "coverage-7.5.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f836c174c3a7f639bded48ec913f348c4761cbf49de4a20a956d3431a7c9cb24"}, + {file = "coverage-7.5.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:244f509f126dc71369393ce5fea17c0592c40ee44e607b6d855e9c4ac57aac98"}, + {file = "coverage-7.5.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4c2872b3c91f9baa836147ca33650dc5c172e9273c808c3c3199c75490e709d"}, + {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dd4b3355b01273a56b20c219e74e7549e14370b31a4ffe42706a8cda91f19f6d"}, + {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f542287b1489c7a860d43a7d8883e27ca62ab84ca53c965d11dac1d3a1fab7ce"}, + {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:75e3f4e86804023e991096b29e147e635f5e2568f77883a1e6eed74512659ab0"}, + {file = "coverage-7.5.3-cp38-cp38-win32.whl", hash = "sha256:c59d2ad092dc0551d9f79d9d44d005c945ba95832a6798f98f9216ede3d5f485"}, + {file = "coverage-7.5.3-cp38-cp38-win_amd64.whl", hash = "sha256:fa21a04112c59ad54f69d80e376f7f9d0f5f9123ab87ecd18fbb9ec3a2beed56"}, + {file = "coverage-7.5.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5102a92855d518b0996eb197772f5ac2a527c0ec617124ad5242a3af5e25f85"}, + {file = "coverage-7.5.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d1da0a2e3b37b745a2b2a678a4c796462cf753aebf94edcc87dcc6b8641eae31"}, + {file = "coverage-7.5.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8383a6c8cefba1b7cecc0149415046b6fc38836295bc4c84e820872eb5478b3d"}, + {file = "coverage-7.5.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9aad68c3f2566dfae84bf46295a79e79d904e1c21ccfc66de88cd446f8686341"}, + {file = "coverage-7.5.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e079c9ec772fedbade9d7ebc36202a1d9ef7291bc9b3a024ca395c4d52853d7"}, + {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bde997cac85fcac227b27d4fb2c7608a2c5f6558469b0eb704c5726ae49e1c52"}, + {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:990fb20b32990b2ce2c5f974c3e738c9358b2735bc05075d50a6f36721b8f303"}, + {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3d5a67f0da401e105753d474369ab034c7bae51a4c31c77d94030d59e41df5bd"}, + {file = "coverage-7.5.3-cp39-cp39-win32.whl", hash = "sha256:e08c470c2eb01977d221fd87495b44867a56d4d594f43739a8028f8646a51e0d"}, + {file = "coverage-7.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:1d2a830ade66d3563bb61d1e3c77c8def97b30ed91e166c67d0632c018f380f0"}, + {file = "coverage-7.5.3-pp38.pp39.pp310-none-any.whl", hash = "sha256:3538d8fb1ee9bdd2e2692b3b18c22bb1c19ffbefd06880f5ac496e42d7bb3884"}, + {file = "coverage-7.5.3.tar.gz", hash = "sha256:04aefca5190d1dc7a53a4c1a5a7f8568811306d7a8ee231c42fb69215571944f"}, ] [package.dependencies] @@ -532,43 +532,43 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "42.0.5" +version = "42.0.8" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16"}, - {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec"}, - {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb"}, - {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4"}, - {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278"}, - {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7"}, - {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee"}, - {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1"}, - {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d"}, - {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da"}, - {file = "cryptography-42.0.5-cp37-abi3-win32.whl", hash = "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74"}, - {file = "cryptography-42.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940"}, - {file = "cryptography-42.0.5-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8"}, - {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1"}, - {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e"}, - {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc"}, - {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a"}, - {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7"}, - {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922"}, - {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc"}, - {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30"}, - {file = "cryptography-42.0.5-cp39-abi3-win32.whl", hash = "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413"}, - {file = "cryptography-42.0.5-cp39-abi3-win_amd64.whl", hash = "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400"}, - {file = "cryptography-42.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8"}, - {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2"}, - {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c"}, - {file = "cryptography-42.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576"}, - {file = "cryptography-42.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6"}, - {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e"}, - {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac"}, - {file = "cryptography-42.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd"}, - {file = "cryptography-42.0.5.tar.gz", hash = "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1"}, + {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e"}, + {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7"}, + {file = "cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2"}, + {file = "cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba"}, + {file = "cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14"}, + {file = "cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c"}, + {file = "cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad"}, + {file = "cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2"}, ] [package.dependencies] @@ -622,13 +622,13 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.13.4" +version = "3.14.0" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.13.4-py3-none-any.whl", hash = "sha256:404e5e9253aa60ad457cae1be07c0f0ca90a63931200a47d9b6a6af84fd7b45f"}, - {file = "filelock-3.13.4.tar.gz", hash = "sha256:d13f466618bfde72bd2c18255e269f72542c6e70e7bac83a0232d6b1cc5c8cf4"}, + {file = "filelock-3.14.0-py3-none-any.whl", hash = "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f"}, + {file = "filelock-3.14.0.tar.gz", hash = "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a"}, ] [package.extras] @@ -823,13 +823,13 @@ testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] [[package]] name = "jinja2" -version = "3.1.3" +version = "3.1.4" description = "A very fast and expressive template engine." optional = true python-versions = ">=3.7" files = [ - {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, - {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, ] [package.dependencies] @@ -840,38 +840,38 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "kasa-crypt" -version = "0.4.1" +version = "0.4.2" description = "Fast kasa crypt" optional = true -python-versions = ">=3.7,<4.0" -files = [ - {file = "kasa_crypt-0.4.1-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:0b806ee24075b88fe9b8e439c89806697fee1276ffa33d5b8c04f0db2a9c85e5"}, - {file = "kasa_crypt-0.4.1-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:6d9e392a57fb73a6a50e3347159288b55e8c37cb553564c3333273eb51a4ac90"}, - {file = "kasa_crypt-0.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb6969da1f2d09e40fc7ba425b16ccfd5dbce084ba3699f566306c56ca90fc3"}, - {file = "kasa_crypt-0.4.1-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:39e1161fd5a3c954ae607a66066b32ef7e9dc20bd388868bdddebee4046a6b1e"}, - {file = "kasa_crypt-0.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c60276e683810aa2669825586249c54a6398f14d3d735c499f7528899c30802a"}, - {file = "kasa_crypt-0.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d417c176ee7ab3e33187e68adce50b0f3d79f92f309bdba0f9d63ae20c8b406e"}, - {file = "kasa_crypt-0.4.1-cp310-cp310-win32.whl", hash = "sha256:f0fcc9c32be0a49d9eca8fd4dbaf46239af3e23074c7bfeebc8739f5221c784e"}, - {file = "kasa_crypt-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:24c34dd3e9f7d4bb2716c295fd7969390fd14de5ac149a7a15e0e6f8ed64434f"}, - {file = "kasa_crypt-0.4.1-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:f940ce8635e349d4d037f8f570f010897062a9495b3c12612962b2fda9f6e6f4"}, - {file = "kasa_crypt-0.4.1-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:30522588d9cae855c0b367922b0ae53a8da2ff36adb5099ba75cd40f1886d229"}, - {file = "kasa_crypt-0.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd038a05925101341358b2d8bd5b01d1b3e811a625da6f2d548238364c0060f2"}, - {file = "kasa_crypt-0.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d7f342314a81f75ab5f32489f33306d2665e9b11ef80b52396a55e1105373536"}, - {file = "kasa_crypt-0.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83e3f552a95f6a090b86f04c68ac9129259f1420280d3c1348eca73d94106fe8"}, - {file = "kasa_crypt-0.4.1-cp311-cp311-win32.whl", hash = "sha256:60034afe4ca341d9dfe3f92d03b7d39aaa1a02f53fda33189a4166ddf6112580"}, - {file = "kasa_crypt-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:6d6fa98b6a38fc71964d1b461f29c083f547d2de3ad97a902584f80a4f2db85a"}, - {file = "kasa_crypt-0.4.1-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:616017fde1460a22f81a78745beb03ae099ef3eccb8184a253ff6ba6cbd424f6"}, - {file = "kasa_crypt-0.4.1-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:50e950458b12b81cb73ba40d7059a4eaaf969edbf76a75a688a195d93e3b47e0"}, - {file = "kasa_crypt-0.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbf069d1cb8425700196103b612cbba006ef0ee8bf0bdc2dc1bf34000054b945"}, - {file = "kasa_crypt-0.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:02186db5d8c520199b9c7531cdd3e385256b50f8c9c560effa8e073701e68b3f"}, - {file = "kasa_crypt-0.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b0e78e69ca7d614edf67da20b77fc4dd49427249c6411d56d5fdab3958966a7a"}, - {file = "kasa_crypt-0.4.1-cp312-cp312-win32.whl", hash = "sha256:e7aff75d81f55f331bca8fa692d8b6bc9d6b2e331ef79effb258f2f3f09bfed3"}, - {file = "kasa_crypt-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:0ae78f7d0b5373bb6d884a26509d018abbe5b10167dc2120d39d0c06237d3f17"}, - {file = "kasa_crypt-0.4.1-pp310-pypy310_pp73-macosx_11_0_x86_64.whl", hash = "sha256:6a6f39a6409de6472ee06f200bee8b7b42bf03dd33968a4e962c9e92b19179e6"}, - {file = "kasa_crypt-0.4.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:d076e14310b50c964a91b4e1322c1700c85062d45d452e29dd52b150058fe75e"}, - {file = "kasa_crypt-0.4.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7093360af43c49e4439c55f208bdf4d76e18104a264a3a5063c58661b449c8f"}, - {file = "kasa_crypt-0.4.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ec769c537d845f8fdaa80d84bc48c213ae6bd896a6c31765fcf111753da729d2"}, - {file = "kasa_crypt-0.4.1.tar.gz", hash = "sha256:32a0ad32fc3df17968f26c83d7a82eb9a91fcb23974b68ed58ec122f9fab82a1"}, +python-versions = "<4.0,>=3.7" +files = [ + {file = "kasa_crypt-0.4.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:38e781ad1ec940ac7551fa3e6e22890e1cf60aa914600d8dc78054e3c431ba68"}, + {file = "kasa_crypt-0.4.2-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:28dbb3dbcbd8c2a17b14248a6c6982740df0f3755a97a9bf4843d52b91612e7a"}, + {file = "kasa_crypt-0.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:584f67653590c0b1dc07214d08553b12bd711109fcbe81eb33437d2e76de3c66"}, + {file = "kasa_crypt-0.4.2-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:cad8534435631d6efe17cd67d3c6d2eba0801d7db0ef3f21a10bfcbb830ac3fb"}, + {file = "kasa_crypt-0.4.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4f7fc204d08a7567c498d4653b8b19bd7931d26bf569991b8087ceba6bb0ed24"}, + {file = "kasa_crypt-0.4.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a3ed8fb6e76d7d1c7a69673d3a351f75da00bf778aa6f7d7621ebbb712a7bd33"}, + {file = "kasa_crypt-0.4.2-cp310-cp310-win32.whl", hash = "sha256:9d72242a9bc86480a3e11557e9b774cdc82baa880444eb4bbb96bf50592f8f7e"}, + {file = "kasa_crypt-0.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:407109b22f18cc998a942a87254e6dad6306a3079f871e74ac50a8db9280b674"}, + {file = "kasa_crypt-0.4.2-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:c34476b8f5a3570b6215e452954ccadcb15a42b5e7efe015453c2c6270a14cad"}, + {file = "kasa_crypt-0.4.2-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:6b4f685baf638289d574ff3516a5f2251ba7ea35fee91ddc32b53a8a6d3fef63"}, + {file = "kasa_crypt-0.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651ad0b0a9a207e0591940a85a4c00e086d25c8a257af3712be4f0ca952f25e2"}, + {file = "kasa_crypt-0.4.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:61a12595cfc7e6a77405fee2f592b6194a8a35e36c7366f662539f9555e881ac"}, + {file = "kasa_crypt-0.4.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:943fe97355635606fbb67aff2de2510e3fe9c7537692798b9692c79bc9ce054e"}, + {file = "kasa_crypt-0.4.2-cp311-cp311-win32.whl", hash = "sha256:37208dc72eac69638b06ddb8c1d3dcabd6a5dac4b98b36378201fb544ed5da0d"}, + {file = "kasa_crypt-0.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:34626074a7a8864044e4cfd131fd04871988b8ada2bc0604248996f42c24965a"}, + {file = "kasa_crypt-0.4.2-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:56193f7954fe5c2895299f36f0b3665a9874152900e4935e48d0d292eef93003"}, + {file = "kasa_crypt-0.4.2-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:1f6080182cbe23732560e73629a763b6b669100da7ff24b245d49f8fab107b62"}, + {file = "kasa_crypt-0.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55326c9f6d5c79a5a15cead3a81c9bb422ce1bc43a2019482753e8cd61df596c"}, + {file = "kasa_crypt-0.4.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4f86433ee2e322847f0539cd84187732a72840560204d5a06561f597214fa4d2"}, + {file = "kasa_crypt-0.4.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bf1a7377c1bcae52aeda4bcb6494a530b58c4a85b42b61e90d4bc4b65348b6b1"}, + {file = "kasa_crypt-0.4.2-cp312-cp312-win32.whl", hash = "sha256:76a5e43c7292acfa2c05628a985f5f9550cba8bfeb62c7d5bbadcea5a53e349f"}, + {file = "kasa_crypt-0.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:7d4ecefb7809084e18292f015e769cf372f1156fa0af143682e0e4eafdce6cb8"}, + {file = "kasa_crypt-0.4.2-pp310-pypy310_pp73-macosx_11_0_x86_64.whl", hash = "sha256:dd6a52f8ae1eee7ca0872636c22515e45494a781265c9c1da6be704478a47d05"}, + {file = "kasa_crypt-0.4.2-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:7b61c8b355925dfce9551be484c632dd7d328da36d2380ed768ff37920a6b031"}, + {file = "kasa_crypt-0.4.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce79a24da498f50ab23609fd01c983b055e3cd6aca61f12f75ac90c024d6984"}, + {file = "kasa_crypt-0.4.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e94fe3090f7f6e77679f665136f41fc574d2fbf34b08e15b8c34d93ed311693"}, + {file = "kasa_crypt-0.4.2.tar.gz", hash = "sha256:fb2af19ff2cdec5c6403ba256d1b9f7e2e57efa676fa09d719f554f6dfb4505c"}, ] [[package]] @@ -1124,76 +1124,68 @@ testing-docutils = ["pygments", "pytest (>=7,<8)", "pytest-param-files (>=0.3.4, [[package]] name = "nodeenv" -version = "1.8.0" +version = "1.9.1" description = "Node.js virtual environment builder" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ - {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, - {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] -[package.dependencies] -setuptools = "*" - [[package]] name = "orjson" -version = "3.10.1" +version = "3.10.3" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = true python-versions = ">=3.8" files = [ - {file = "orjson-3.10.1-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8ec2fc456d53ea4a47768f622bb709be68acd455b0c6be57e91462259741c4f3"}, - {file = "orjson-3.10.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e900863691d327758be14e2a491931605bd0aded3a21beb6ce133889830b659"}, - {file = "orjson-3.10.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab6ecbd6fe57785ebc86ee49e183f37d45f91b46fc601380c67c5c5e9c0014a2"}, - {file = "orjson-3.10.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8af7c68b01b876335cccfb4eee0beef2b5b6eae1945d46a09a7c24c9faac7a77"}, - {file = "orjson-3.10.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:915abfb2e528677b488a06eba173e9d7706a20fdfe9cdb15890b74ef9791b85e"}, - {file = "orjson-3.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe3fd4a36eff9c63d25503b439531d21828da9def0059c4f472e3845a081aa0b"}, - {file = "orjson-3.10.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d229564e72cfc062e6481a91977a5165c5a0fdce11ddc19ced8471847a67c517"}, - {file = "orjson-3.10.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9e00495b18304173ac843b5c5fbea7b6f7968564d0d49bef06bfaeca4b656f4e"}, - {file = "orjson-3.10.1-cp310-none-win32.whl", hash = "sha256:fd78ec55179545c108174ba19c1795ced548d6cac4d80d014163033c047ca4ea"}, - {file = "orjson-3.10.1-cp310-none-win_amd64.whl", hash = "sha256:50ca42b40d5a442a9e22eece8cf42ba3d7cd4cd0f2f20184b4d7682894f05eec"}, - {file = "orjson-3.10.1-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b345a3d6953628df2f42502297f6c1e1b475cfbf6268013c94c5ac80e8abc04c"}, - {file = "orjson-3.10.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caa7395ef51af4190d2c70a364e2f42138e0e5fcb4bc08bc9b76997659b27dab"}, - {file = "orjson-3.10.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b01d701decd75ae092e5f36f7b88a1e7a1d3bb7c9b9d7694de850fb155578d5a"}, - {file = "orjson-3.10.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5028981ba393f443d8fed9049211b979cadc9d0afecf162832f5a5b152c6297"}, - {file = "orjson-3.10.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31ff6a222ea362b87bf21ff619598a4dc1106aaafaea32b1c4876d692891ec27"}, - {file = "orjson-3.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e852a83d7803d3406135fb7a57cf0c1e4a3e73bac80ec621bd32f01c653849c5"}, - {file = "orjson-3.10.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2567bc928ed3c3fcd90998009e8835de7c7dc59aabcf764b8374d36044864f3b"}, - {file = "orjson-3.10.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4ce98cac60b7bb56457bdd2ed7f0d5d7f242d291fdc0ca566c83fa721b52e92d"}, - {file = "orjson-3.10.1-cp311-none-win32.whl", hash = "sha256:813905e111318acb356bb8029014c77b4c647f8b03f314e7b475bd9ce6d1a8ce"}, - {file = "orjson-3.10.1-cp311-none-win_amd64.whl", hash = "sha256:03a3ca0b3ed52bed1a869163a4284e8a7b0be6a0359d521e467cdef7e8e8a3ee"}, - {file = "orjson-3.10.1-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f02c06cee680b1b3a8727ec26c36f4b3c0c9e2b26339d64471034d16f74f4ef5"}, - {file = "orjson-3.10.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1aa2f127ac546e123283e437cc90b5ecce754a22306c7700b11035dad4ccf85"}, - {file = "orjson-3.10.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2cf29b4b74f585225196944dffdebd549ad2af6da9e80db7115984103fb18a96"}, - {file = "orjson-3.10.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1b130c20b116f413caf6059c651ad32215c28500dce9cd029a334a2d84aa66f"}, - {file = "orjson-3.10.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d31f9a709e6114492136e87c7c6da5e21dfedebefa03af85f3ad72656c493ae9"}, - {file = "orjson-3.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d1d169461726f271ab31633cf0e7e7353417e16fb69256a4f8ecb3246a78d6e"}, - {file = "orjson-3.10.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:57c294d73825c6b7f30d11c9e5900cfec9a814893af7f14efbe06b8d0f25fba9"}, - {file = "orjson-3.10.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d7f11dbacfa9265ec76b4019efffabaabba7a7ebf14078f6b4df9b51c3c9a8ea"}, - {file = "orjson-3.10.1-cp312-none-win32.whl", hash = "sha256:d89e5ed68593226c31c76ab4de3e0d35c760bfd3fbf0a74c4b2be1383a1bf123"}, - {file = "orjson-3.10.1-cp312-none-win_amd64.whl", hash = "sha256:aa76c4fe147fd162107ce1692c39f7189180cfd3a27cfbc2ab5643422812da8e"}, - {file = "orjson-3.10.1-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a2c6a85c92d0e494c1ae117befc93cf8e7bca2075f7fe52e32698da650b2c6d1"}, - {file = "orjson-3.10.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9813f43da955197d36a7365eb99bed42b83680801729ab2487fef305b9ced866"}, - {file = "orjson-3.10.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec917b768e2b34b7084cb6c68941f6de5812cc26c6f1a9fecb728e36a3deb9e8"}, - {file = "orjson-3.10.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5252146b3172d75c8a6d27ebca59c9ee066ffc5a277050ccec24821e68742fdf"}, - {file = "orjson-3.10.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:536429bb02791a199d976118b95014ad66f74c58b7644d21061c54ad284e00f4"}, - {file = "orjson-3.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7dfed3c3e9b9199fb9c3355b9c7e4649b65f639e50ddf50efdf86b45c6de04b5"}, - {file = "orjson-3.10.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2b230ec35f188f003f5b543644ae486b2998f6afa74ee3a98fc8ed2e45960afc"}, - {file = "orjson-3.10.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:01234249ba19c6ab1eb0b8be89f13ea21218b2d72d496ef085cfd37e1bae9dd8"}, - {file = "orjson-3.10.1-cp38-none-win32.whl", hash = "sha256:8a884fbf81a3cc22d264ba780920d4885442144e6acaa1411921260416ac9a54"}, - {file = "orjson-3.10.1-cp38-none-win_amd64.whl", hash = "sha256:dab5f802d52b182163f307d2b1f727d30b1762e1923c64c9c56dd853f9671a49"}, - {file = "orjson-3.10.1-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a51fd55d4486bc5293b7a400f9acd55a2dc3b5fc8420d5ffe9b1d6bb1a056a5e"}, - {file = "orjson-3.10.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53521542a6db1411b3bfa1b24ddce18605a3abdc95a28a67b33f9145f26aa8f2"}, - {file = "orjson-3.10.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:27d610df96ac18ace4931411d489637d20ab3b8f63562b0531bba16011998db0"}, - {file = "orjson-3.10.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79244b1456e5846d44e9846534bd9e3206712936d026ea8e6a55a7374d2c0694"}, - {file = "orjson-3.10.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d751efaa8a49ae15cbebdda747a62a9ae521126e396fda8143858419f3b03610"}, - {file = "orjson-3.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27ff69c620a4fff33267df70cfd21e0097c2a14216e72943bd5414943e376d77"}, - {file = "orjson-3.10.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ebc58693464146506fde0c4eb1216ff6d4e40213e61f7d40e2f0dde9b2f21650"}, - {file = "orjson-3.10.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5be608c3972ed902e0143a5b8776d81ac1059436915d42defe5c6ae97b3137a4"}, - {file = "orjson-3.10.1-cp39-none-win32.whl", hash = "sha256:4ae10753e7511d359405aadcbf96556c86e9dbf3a948d26c2c9f9a150c52b091"}, - {file = "orjson-3.10.1-cp39-none-win_amd64.whl", hash = "sha256:fb5bc4caa2c192077fdb02dce4e5ef8639e7f20bec4e3a834346693907362932"}, - {file = "orjson-3.10.1.tar.gz", hash = "sha256:a883b28d73370df23ed995c466b4f6c708c1f7a9bdc400fe89165c96c7603204"}, + {file = "orjson-3.10.3-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9fb6c3f9f5490a3eb4ddd46fc1b6eadb0d6fc16fb3f07320149c3286a1409dd8"}, + {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:252124b198662eee80428f1af8c63f7ff077c88723fe206a25df8dc57a57b1fa"}, + {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9f3e87733823089a338ef9bbf363ef4de45e5c599a9bf50a7a9b82e86d0228da"}, + {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8334c0d87103bb9fbbe59b78129f1f40d1d1e8355bbed2ca71853af15fa4ed3"}, + {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1952c03439e4dce23482ac846e7961f9d4ec62086eb98ae76d97bd41d72644d7"}, + {file = "orjson-3.10.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0403ed9c706dcd2809f1600ed18f4aae50be263bd7112e54b50e2c2bc3ebd6d"}, + {file = "orjson-3.10.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:382e52aa4270a037d41f325e7d1dfa395b7de0c367800b6f337d8157367bf3a7"}, + {file = "orjson-3.10.3-cp310-none-win32.whl", hash = "sha256:be2aab54313752c04f2cbaab4515291ef5af8c2256ce22abc007f89f42f49109"}, + {file = "orjson-3.10.3-cp310-none-win_amd64.whl", hash = "sha256:416b195f78ae461601893f482287cee1e3059ec49b4f99479aedf22a20b1098b"}, + {file = "orjson-3.10.3-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:73100d9abbbe730331f2242c1fc0bcb46a3ea3b4ae3348847e5a141265479700"}, + {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:544a12eee96e3ab828dbfcb4d5a0023aa971b27143a1d35dc214c176fdfb29b3"}, + {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:520de5e2ef0b4ae546bea25129d6c7c74edb43fc6cf5213f511a927f2b28148b"}, + {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ccaa0a401fc02e8828a5bedfd80f8cd389d24f65e5ca3954d72c6582495b4bcf"}, + {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7bc9e8bc11bac40f905640acd41cbeaa87209e7e1f57ade386da658092dc16"}, + {file = "orjson-3.10.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3582b34b70543a1ed6944aca75e219e1192661a63da4d039d088a09c67543b08"}, + {file = "orjson-3.10.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c23dfa91481de880890d17aa7b91d586a4746a4c2aa9a145bebdbaf233768d5"}, + {file = "orjson-3.10.3-cp311-none-win32.whl", hash = "sha256:1770e2a0eae728b050705206d84eda8b074b65ee835e7f85c919f5705b006c9b"}, + {file = "orjson-3.10.3-cp311-none-win_amd64.whl", hash = "sha256:93433b3c1f852660eb5abdc1f4dd0ced2be031ba30900433223b28ee0140cde5"}, + {file = "orjson-3.10.3-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a39aa73e53bec8d410875683bfa3a8edf61e5a1c7bb4014f65f81d36467ea098"}, + {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0943a96b3fa09bee1afdfccc2cb236c9c64715afa375b2af296c73d91c23eab2"}, + {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e852baafceff8da3c9defae29414cc8513a1586ad93e45f27b89a639c68e8176"}, + {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18566beb5acd76f3769c1d1a7ec06cdb81edc4d55d2765fb677e3eaa10fa99e0"}, + {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bd2218d5a3aa43060efe649ec564ebedec8ce6ae0a43654b81376216d5ebd42"}, + {file = "orjson-3.10.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cf20465e74c6e17a104ecf01bf8cd3b7b252565b4ccee4548f18b012ff2f8069"}, + {file = "orjson-3.10.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba7f67aa7f983c4345eeda16054a4677289011a478ca947cd69c0a86ea45e534"}, + {file = "orjson-3.10.3-cp312-none-win32.whl", hash = "sha256:17e0713fc159abc261eea0f4feda611d32eabc35708b74bef6ad44f6c78d5ea0"}, + {file = "orjson-3.10.3-cp312-none-win_amd64.whl", hash = "sha256:4c895383b1ec42b017dd2c75ae8a5b862fc489006afde06f14afbdd0309b2af0"}, + {file = "orjson-3.10.3-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:be2719e5041e9fb76c8c2c06b9600fe8e8584e6980061ff88dcbc2691a16d20d"}, + {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0175a5798bdc878956099f5c54b9837cb62cfbf5d0b86ba6d77e43861bcec2"}, + {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:978be58a68ade24f1af7758626806e13cff7748a677faf95fbb298359aa1e20d"}, + {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16bda83b5c61586f6f788333d3cf3ed19015e3b9019188c56983b5a299210eb5"}, + {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ad1f26bea425041e0a1adad34630c4825a9e3adec49079b1fb6ac8d36f8b754"}, + {file = "orjson-3.10.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9e253498bee561fe85d6325ba55ff2ff08fb5e7184cd6a4d7754133bd19c9195"}, + {file = "orjson-3.10.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0a62f9968bab8a676a164263e485f30a0b748255ee2f4ae49a0224be95f4532b"}, + {file = "orjson-3.10.3-cp38-none-win32.whl", hash = "sha256:8d0b84403d287d4bfa9bf7d1dc298d5c1c5d9f444f3737929a66f2fe4fb8f134"}, + {file = "orjson-3.10.3-cp38-none-win_amd64.whl", hash = "sha256:8bc7a4df90da5d535e18157220d7915780d07198b54f4de0110eca6b6c11e290"}, + {file = "orjson-3.10.3-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9059d15c30e675a58fdcd6f95465c1522b8426e092de9fff20edebfdc15e1cb0"}, + {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d40c7f7938c9c2b934b297412c067936d0b54e4b8ab916fd1a9eb8f54c02294"}, + {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a654ec1de8fdaae1d80d55cee65893cb06494e124681ab335218be6a0691e7"}, + {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:831c6ef73f9aa53c5f40ae8f949ff7681b38eaddb6904aab89dca4d85099cb78"}, + {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99b880d7e34542db89f48d14ddecbd26f06838b12427d5a25d71baceb5ba119d"}, + {file = "orjson-3.10.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2e5e176c994ce4bd434d7aafb9ecc893c15f347d3d2bbd8e7ce0b63071c52e25"}, + {file = "orjson-3.10.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b69a58a37dab856491bf2d3bbf259775fdce262b727f96aafbda359cb1d114d8"}, + {file = "orjson-3.10.3-cp39-none-win32.whl", hash = "sha256:b8d4d1a6868cde356f1402c8faeb50d62cee765a1f7ffcfd6de732ab0581e063"}, + {file = "orjson-3.10.3-cp39-none-win_amd64.whl", hash = "sha256:5102f50c5fc46d94f2033fe00d392588564378260d64377aec702f21a7a22912"}, + {file = "orjson-3.10.3.tar.gz", hash = "sha256:2b166507acae7ba2f7c315dcf185a9111ad5e992ac81f2d507aac39193c2c818"}, ] [[package]] @@ -1224,13 +1216,13 @@ testing = ["docopt", "pytest"] [[package]] name = "platformdirs" -version = "4.2.1" +version = "4.2.2" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.2.1-py3-none-any.whl", hash = "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1"}, - {file = "platformdirs-4.2.1.tar.gz", hash = "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf"}, + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, ] [package.extras] @@ -1273,13 +1265,13 @@ virtualenv = ">=20.10.0" [[package]] name = "prompt-toolkit" -version = "3.0.43" +version = "3.0.46" description = "Library for building powerful interactive command lines in Python" optional = true python-versions = ">=3.7.0" files = [ - {file = "prompt_toolkit-3.0.43-py3-none-any.whl", hash = "sha256:a11a29cb3bf0a28a387fe5122cdb649816a957cd9261dcedf8c9f1fef33eacf6"}, - {file = "prompt_toolkit-3.0.43.tar.gz", hash = "sha256:3527b7af26106cbc65a040bcc84839a3566ec1b051bb0bfe953631e704b0ff7d"}, + {file = "prompt_toolkit-3.0.46-py3-none-any.whl", hash = "sha256:45abe60a8300f3c618b23c16c4bb98c6fc80af8ce8b17c7ae92db48db3ee63c1"}, + {file = "prompt_toolkit-3.0.46.tar.gz", hash = "sha256:869c50d682152336e23c4db7f74667639b5047494202ffe7670817053fd57795"}, ] [package.dependencies] @@ -1287,19 +1279,19 @@ wcwidth = "*" [[package]] name = "ptpython" -version = "3.0.26" +version = "3.0.27" description = "Python REPL build on top of prompt_toolkit" optional = true python-versions = ">=3.7" files = [ - {file = "ptpython-3.0.26-py2.py3-none-any.whl", hash = "sha256:3dc4c066d049e16d8b181e995a568d36697d04d9acc2724732f3ff6686c5da57"}, - {file = "ptpython-3.0.26.tar.gz", hash = "sha256:c8fb1406502dc349d99c57eaf06e7116f3b2deac94f02f342bae68708909f743"}, + {file = "ptpython-3.0.27-py2.py3-none-any.whl", hash = "sha256:549870d537ab3244243cfb92d36347072bb8be823a121fb2fd95297af0fb42bb"}, + {file = "ptpython-3.0.27.tar.gz", hash = "sha256:24b0fda94b73d1c99a27e6fd0d08be6f2e7cda79a2db995c7e3c7b8b1254bad9"}, ] [package.dependencies] appdirs = "*" jedi = ">=0.16.0" -prompt-toolkit = ">=3.0.34,<3.1.0" +prompt-toolkit = ">=3.0.43,<3.1.0" pygments = "*" [package.extras] @@ -1319,18 +1311,18 @@ files = [ [[package]] name = "pydantic" -version = "2.7.1" +version = "2.7.3" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.7.1-py3-none-any.whl", hash = "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5"}, - {file = "pydantic-2.7.1.tar.gz", hash = "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc"}, + {file = "pydantic-2.7.3-py3-none-any.whl", hash = "sha256:ea91b002777bf643bb20dd717c028ec43216b24a6001a280f83877fd2655d0b4"}, + {file = "pydantic-2.7.3.tar.gz", hash = "sha256:c46c76a40bb1296728d7a8b99aa73dd70a48c3510111ff290034f860c99c419e"}, ] [package.dependencies] annotated-types = ">=0.4.0" -pydantic-core = "2.18.2" +pydantic-core = "2.18.4" typing-extensions = ">=4.6.1" [package.extras] @@ -1338,90 +1330,90 @@ email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-core" -version = "2.18.2" +version = "2.18.4" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.18.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81"}, - {file = "pydantic_core-2.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857"}, - {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563"}, - {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38"}, - {file = "pydantic_core-2.18.2-cp310-none-win32.whl", hash = "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027"}, - {file = "pydantic_core-2.18.2-cp310-none-win_amd64.whl", hash = "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543"}, - {file = "pydantic_core-2.18.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3"}, - {file = "pydantic_core-2.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c"}, - {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0"}, - {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664"}, - {file = "pydantic_core-2.18.2-cp311-none-win32.whl", hash = "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e"}, - {file = "pydantic_core-2.18.2-cp311-none-win_amd64.whl", hash = "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3"}, - {file = "pydantic_core-2.18.2-cp311-none-win_arm64.whl", hash = "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d"}, - {file = "pydantic_core-2.18.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242"}, - {file = "pydantic_core-2.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c"}, - {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241"}, - {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3"}, - {file = "pydantic_core-2.18.2-cp312-none-win32.whl", hash = "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038"}, - {file = "pydantic_core-2.18.2-cp312-none-win_amd64.whl", hash = "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438"}, - {file = "pydantic_core-2.18.2-cp312-none-win_arm64.whl", hash = "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec"}, - {file = "pydantic_core-2.18.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439"}, - {file = "pydantic_core-2.18.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b"}, - {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761"}, - {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788"}, - {file = "pydantic_core-2.18.2-cp38-none-win32.whl", hash = "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350"}, - {file = "pydantic_core-2.18.2-cp38-none-win_amd64.whl", hash = "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e"}, - {file = "pydantic_core-2.18.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8"}, - {file = "pydantic_core-2.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4"}, - {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399"}, - {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b"}, - {file = "pydantic_core-2.18.2-cp39-none-win32.whl", hash = "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e"}, - {file = "pydantic_core-2.18.2-cp39-none-win_amd64.whl", hash = "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374"}, - {file = "pydantic_core-2.18.2.tar.gz", hash = "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e"}, + {file = "pydantic_core-2.18.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f76d0ad001edd426b92233d45c746fd08f467d56100fd8f30e9ace4b005266e4"}, + {file = "pydantic_core-2.18.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:59ff3e89f4eaf14050c8022011862df275b552caef8082e37b542b066ce1ff26"}, + {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a55b5b16c839df1070bc113c1f7f94a0af4433fcfa1b41799ce7606e5c79ce0a"}, + {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4d0dcc59664fcb8974b356fe0a18a672d6d7cf9f54746c05f43275fc48636851"}, + {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8951eee36c57cd128f779e641e21eb40bc5073eb28b2d23f33eb0ef14ffb3f5d"}, + {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4701b19f7e3a06ea655513f7938de6f108123bf7c86bbebb1196eb9bd35cf724"}, + {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e00a3f196329e08e43d99b79b286d60ce46bed10f2280d25a1718399457e06be"}, + {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:97736815b9cc893b2b7f663628e63f436018b75f44854c8027040e05230eeddb"}, + {file = "pydantic_core-2.18.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6891a2ae0e8692679c07728819b6e2b822fb30ca7445f67bbf6509b25a96332c"}, + {file = "pydantic_core-2.18.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bc4ff9805858bd54d1a20efff925ccd89c9d2e7cf4986144b30802bf78091c3e"}, + {file = "pydantic_core-2.18.4-cp310-none-win32.whl", hash = "sha256:1b4de2e51bbcb61fdebd0ab86ef28062704f62c82bbf4addc4e37fa4b00b7cbc"}, + {file = "pydantic_core-2.18.4-cp310-none-win_amd64.whl", hash = "sha256:6a750aec7bf431517a9fd78cb93c97b9b0c496090fee84a47a0d23668976b4b0"}, + {file = "pydantic_core-2.18.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:942ba11e7dfb66dc70f9ae66b33452f51ac7bb90676da39a7345e99ffb55402d"}, + {file = "pydantic_core-2.18.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b2ebef0e0b4454320274f5e83a41844c63438fdc874ea40a8b5b4ecb7693f1c4"}, + {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a642295cd0c8df1b86fc3dced1d067874c353a188dc8e0f744626d49e9aa51c4"}, + {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f09baa656c904807e832cf9cce799c6460c450c4ad80803517032da0cd062e2"}, + {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98906207f29bc2c459ff64fa007afd10a8c8ac080f7e4d5beff4c97086a3dabd"}, + {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19894b95aacfa98e7cb093cd7881a0c76f55731efad31073db4521e2b6ff5b7d"}, + {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fbbdc827fe5e42e4d196c746b890b3d72876bdbf160b0eafe9f0334525119c8"}, + {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f85d05aa0918283cf29a30b547b4df2fbb56b45b135f9e35b6807cb28bc47951"}, + {file = "pydantic_core-2.18.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e85637bc8fe81ddb73fda9e56bab24560bdddfa98aa64f87aaa4e4b6730c23d2"}, + {file = "pydantic_core-2.18.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2f5966897e5461f818e136b8451d0551a2e77259eb0f73a837027b47dc95dab9"}, + {file = "pydantic_core-2.18.4-cp311-none-win32.whl", hash = "sha256:44c7486a4228413c317952e9d89598bcdfb06399735e49e0f8df643e1ccd0558"}, + {file = "pydantic_core-2.18.4-cp311-none-win_amd64.whl", hash = "sha256:8a7164fe2005d03c64fd3b85649891cd4953a8de53107940bf272500ba8a788b"}, + {file = "pydantic_core-2.18.4-cp311-none-win_arm64.whl", hash = "sha256:4e99bc050fe65c450344421017f98298a97cefc18c53bb2f7b3531eb39bc7805"}, + {file = "pydantic_core-2.18.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6f5c4d41b2771c730ea1c34e458e781b18cc668d194958e0112455fff4e402b2"}, + {file = "pydantic_core-2.18.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fdf2156aa3d017fddf8aea5adfba9f777db1d6022d392b682d2a8329e087cef"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4748321b5078216070b151d5271ef3e7cc905ab170bbfd27d5c83ee3ec436695"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847a35c4d58721c5dc3dba599878ebbdfd96784f3fb8bb2c356e123bdcd73f34"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c40d4eaad41f78e3bbda31b89edc46a3f3dc6e171bf0ecf097ff7a0ffff7cb1"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:21a5e440dbe315ab9825fcd459b8814bb92b27c974cbc23c3e8baa2b76890077"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01dd777215e2aa86dfd664daed5957704b769e726626393438f9c87690ce78c3"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b06beb3b3f1479d32befd1f3079cc47b34fa2da62457cdf6c963393340b56e9"}, + {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:564d7922e4b13a16b98772441879fcdcbe82ff50daa622d681dd682175ea918c"}, + {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0eb2a4f660fcd8e2b1c90ad566db2b98d7f3f4717c64fe0a83e0adb39766d5b8"}, + {file = "pydantic_core-2.18.4-cp312-none-win32.whl", hash = "sha256:8b8bab4c97248095ae0c4455b5a1cd1cdd96e4e4769306ab19dda135ea4cdb07"}, + {file = "pydantic_core-2.18.4-cp312-none-win_amd64.whl", hash = "sha256:14601cdb733d741b8958224030e2bfe21a4a881fb3dd6fbb21f071cabd48fa0a"}, + {file = "pydantic_core-2.18.4-cp312-none-win_arm64.whl", hash = "sha256:c1322d7dd74713dcc157a2b7898a564ab091ca6c58302d5c7b4c07296e3fd00f"}, + {file = "pydantic_core-2.18.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:823be1deb01793da05ecb0484d6c9e20baebb39bd42b5d72636ae9cf8350dbd2"}, + {file = "pydantic_core-2.18.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ebef0dd9bf9b812bf75bda96743f2a6c5734a02092ae7f721c048d156d5fabae"}, + {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae1d6df168efb88d7d522664693607b80b4080be6750c913eefb77e34c12c71a"}, + {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9899c94762343f2cc2fc64c13e7cae4c3cc65cdfc87dd810a31654c9b7358cc"}, + {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99457f184ad90235cfe8461c4d70ab7dd2680e28821c29eca00252ba90308c78"}, + {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18f469a3d2a2fdafe99296a87e8a4c37748b5080a26b806a707f25a902c040a8"}, + {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7cdf28938ac6b8b49ae5e92f2735056a7ba99c9b110a474473fd71185c1af5d"}, + {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:938cb21650855054dc54dfd9120a851c974f95450f00683399006aa6e8abb057"}, + {file = "pydantic_core-2.18.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:44cd83ab6a51da80fb5adbd9560e26018e2ac7826f9626bc06ca3dc074cd198b"}, + {file = "pydantic_core-2.18.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:972658f4a72d02b8abfa2581d92d59f59897d2e9f7e708fdabe922f9087773af"}, + {file = "pydantic_core-2.18.4-cp38-none-win32.whl", hash = "sha256:1d886dc848e60cb7666f771e406acae54ab279b9f1e4143babc9c2258213daa2"}, + {file = "pydantic_core-2.18.4-cp38-none-win_amd64.whl", hash = "sha256:bb4462bd43c2460774914b8525f79b00f8f407c945d50881568f294c1d9b4443"}, + {file = "pydantic_core-2.18.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:44a688331d4a4e2129140a8118479443bd6f1905231138971372fcde37e43528"}, + {file = "pydantic_core-2.18.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a2fdd81edd64342c85ac7cf2753ccae0b79bf2dfa063785503cb85a7d3593223"}, + {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86110d7e1907ab36691f80b33eb2da87d780f4739ae773e5fc83fb272f88825f"}, + {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46387e38bd641b3ee5ce247563b60c5ca098da9c56c75c157a05eaa0933ed154"}, + {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:123c3cec203e3f5ac7b000bd82235f1a3eced8665b63d18be751f115588fea30"}, + {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dc1803ac5c32ec324c5261c7209e8f8ce88e83254c4e1aebdc8b0a39f9ddb443"}, + {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53db086f9f6ab2b4061958d9c276d1dbe3690e8dd727d6abf2321d6cce37fa94"}, + {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abc267fa9837245cc28ea6929f19fa335f3dc330a35d2e45509b6566dc18be23"}, + {file = "pydantic_core-2.18.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a0d829524aaefdebccb869eed855e2d04c21d2d7479b6cada7ace5448416597b"}, + {file = "pydantic_core-2.18.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:509daade3b8649f80d4e5ff21aa5673e4ebe58590b25fe42fac5f0f52c6f034a"}, + {file = "pydantic_core-2.18.4-cp39-none-win32.whl", hash = "sha256:ca26a1e73c48cfc54c4a76ff78df3727b9d9f4ccc8dbee4ae3f73306a591676d"}, + {file = "pydantic_core-2.18.4-cp39-none-win_amd64.whl", hash = "sha256:c67598100338d5d985db1b3d21f3619ef392e185e71b8d52bceacc4a7771ea7e"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:574d92eac874f7f4db0ca653514d823a0d22e2354359d0759e3f6a406db5d55d"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1f4d26ceb5eb9eed4af91bebeae4b06c3fb28966ca3a8fb765208cf6b51102ab"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77450e6d20016ec41f43ca4a6c63e9fdde03f0ae3fe90e7c27bdbeaece8b1ed4"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d323a01da91851a4f17bf592faf46149c9169d68430b3146dcba2bb5e5719abc"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43d447dd2ae072a0065389092a231283f62d960030ecd27565672bd40746c507"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:578e24f761f3b425834f297b9935e1ce2e30f51400964ce4801002435a1b41ef"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:81b5efb2f126454586d0f40c4d834010979cb80785173d1586df845a632e4e6d"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ab86ce7c8f9bea87b9d12c7f0af71102acbf5ecbc66c17796cff45dae54ef9a5"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:90afc12421df2b1b4dcc975f814e21bc1754640d502a2fbcc6d41e77af5ec312"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:51991a89639a912c17bef4b45c87bd83593aee0437d8102556af4885811d59f5"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:293afe532740370aba8c060882f7d26cfd00c94cae32fd2e212a3a6e3b7bc15e"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48ece5bde2e768197a2d0f6e925f9d7e3e826f0ad2271120f8144a9db18d5c8"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eae237477a873ab46e8dd748e515c72c0c804fb380fbe6c85533c7de51f23a8f"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:834b5230b5dfc0c1ec37b2fda433b271cbbc0e507560b5d1588e2cc1148cf1ce"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e858ac0a25074ba4bce653f9b5d0a85b7456eaddadc0ce82d3878c22489fa4ee"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2fd41f6eff4c20778d717af1cc50eca52f5afe7805ee530a4fbd0bae284f16e9"}, + {file = "pydantic_core-2.18.4.tar.gz", hash = "sha256:ec3beeada09ff865c344ff3bc2f427f5e6c26401cc6113d77e372c3fdac73864"}, ] [package.dependencies] @@ -1429,17 +1421,16 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pygments" -version = "2.17.2" +version = "2.18.0" description = "Pygments is a syntax highlighting package written in Python." optional = true -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, - {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, ] [package.extras] -plugins = ["importlib-metadata"] windows-terminal = ["colorama (>=0.4.6)"] [[package]] @@ -1463,13 +1454,13 @@ testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytes [[package]] name = "pytest" -version = "8.2.0" +version = "8.2.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.2.0-py3-none-any.whl", hash = "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233"}, - {file = "pytest-8.2.0.tar.gz", hash = "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f"}, + {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, + {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, ] [package.dependencies] @@ -1485,13 +1476,13 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.23.6" +version = "0.23.7" description = "Pytest support for asyncio" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-asyncio-0.23.6.tar.gz", hash = "sha256:ffe523a89c1c222598c76856e76852b787504ddb72dd5d9b6617ffa8aa2cde5f"}, - {file = "pytest_asyncio-0.23.6-py3-none-any.whl", hash = "sha256:68516fdd1018ac57b846c9846b954f0393b26f094764a28c955eabb0536a4e8a"}, + {file = "pytest_asyncio-0.23.7-py3-none-any.whl", hash = "sha256:009b48127fbe44518a547bddd25611551b0e43ccdbf1e67d12479f569832c20b"}, + {file = "pytest_asyncio-0.23.7.tar.gz", hash = "sha256:5f5c72948f4c49e7db4f29f2521d4031f1c27f86e57b046126654083d4770268"}, ] [package.dependencies] @@ -1670,13 +1661,13 @@ files = [ [[package]] name = "requests" -version = "2.31.0" +version = "2.32.3" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] @@ -1708,22 +1699,6 @@ typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9 [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] -[[package]] -name = "setuptools" -version = "69.5.1" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"}, - {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - [[package]] name = "six" version = "1.16.0" @@ -1966,13 +1941,13 @@ files = [ [[package]] name = "tox" -version = "4.15.0" +version = "4.15.1" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" files = [ - {file = "tox-4.15.0-py3-none-any.whl", hash = "sha256:300055f335d855b2ab1b12c5802de7f62a36d4fd53f30bd2835f6a201dda46ea"}, - {file = "tox-4.15.0.tar.gz", hash = "sha256:7a0beeef166fbe566f54f795b4906c31b428eddafc0102ac00d20998dd1933f6"}, + {file = "tox-4.15.1-py3-none-any.whl", hash = "sha256:f00a5dc4222b358e69694e47e3da0227ac41253509bca9f45aa8f012053e8d9d"}, + {file = "tox-4.15.1.tar.gz", hash = "sha256:53a092527d65e873e39213ebd4bd027a64623320b6b0326136384213f95b7076"}, ] [package.dependencies] @@ -1993,13 +1968,13 @@ testing = ["build[virtualenv] (>=1.0.3)", "covdefaults (>=2.3)", "detect-test-po [[package]] name = "typing-extensions" -version = "4.11.0" +version = "4.12.1" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, - {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, + {file = "typing_extensions-4.12.1-py3-none-any.whl", hash = "sha256:6024b58b69089e5a89c347397254e35f1bf02a907728ec7fee9bf0fe837d203a"}, + {file = "typing_extensions-4.12.1.tar.gz", hash = "sha256:915f5e35ff76f56588223f15fdd5938f9a1cf9195c0de25130c627e4d597f6d1"}, ] [[package]] @@ -2021,13 +1996,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.26.0" +version = "20.26.2" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.26.0-py3-none-any.whl", hash = "sha256:0846377ea76e818daaa3e00a4365c018bc3ac9760cbb3544de542885aad61fb3"}, - {file = "virtualenv-20.26.0.tar.gz", hash = "sha256:ec25a9671a5102c8d2657f62792a27b48f016664c6873f6beed3800008577210"}, + {file = "virtualenv-20.26.2-py3-none-any.whl", hash = "sha256:a624db5e94f01ad993d476b9ee5346fdf7b9de43ccaee0e0197012dc838a0e9b"}, + {file = "virtualenv-20.26.2.tar.gz", hash = "sha256:82bf0f4eebbb78d36ddaee0283d43fe5736b53880b8a8cdcd37390a07ac3741c"}, ] [package.dependencies] @@ -2063,13 +2038,13 @@ files = [ [[package]] name = "xdoctest" -version = "1.1.3" +version = "1.1.4" description = "A rewrite of the builtin doctest module" optional = false python-versions = ">=3.6" files = [ - {file = "xdoctest-1.1.3-py3-none-any.whl", hash = "sha256:9360535bd1a971ffc216d9613898cedceb81d0fd024587cc3c03c74d14c00a31"}, - {file = "xdoctest-1.1.3.tar.gz", hash = "sha256:84e76a42a11a5926ff66d9d84c616bc101821099672550481ad96549cbdd02ae"}, + {file = "xdoctest-1.1.4-py3-none-any.whl", hash = "sha256:2ee7920603e1a977749cabf611dfde1935165c6ac83dcfb2c9bdf8fc3ac1ec26"}, + {file = "xdoctest-1.1.4.tar.gz", hash = "sha256:eb3fbad5a9ac4d47b2fafa60435ac15f2cbcd33dc860bf1e759a1f63bfeddc10"}, ] [package.extras] @@ -2189,18 +2164,18 @@ multidict = ">=4.0" [[package]] name = "zipp" -version = "3.18.1" +version = "3.19.2" description = "Backport of pathlib-compatible object wrapper for zip files" optional = true python-versions = ">=3.8" files = [ - {file = "zipp-3.18.1-py3-none-any.whl", hash = "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b"}, - {file = "zipp-3.18.1.tar.gz", hash = "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715"}, + {file = "zipp-3.19.2-py3-none-any.whl", hash = "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c"}, + {file = "zipp-3.19.2.tar.gz", hash = "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [extras] docs = ["docutils", "myst-parser", "sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput"] diff --git a/pyproject.toml b/pyproject.toml index 5f1fc3540..feadb1ba8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.dev2" +version = "0.7.0.dev3" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From fe0bbf1b98621f72229277c0ca0c64047a128c92 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 10 Jun 2024 05:59:37 +0100 Subject: [PATCH 462/892] Do not expose child modules on parent devices (#964) Removes the logic to expose child modules on parent devices, which could cause complications with downstream consumers unknowingly duplicating things. --- kasa/smart/smartdevice.py | 17 +---------------- kasa/tests/device_fixtures.py | 13 +++++++++++++ kasa/tests/smart/features/test_brightness.py | 6 +++--- kasa/tests/smart/modules/test_fan.py | 13 ++++++------- kasa/tests/test_common_modules.py | 15 ++++++++------- kasa/tests/test_smartdevice.py | 8 +++----- 6 files changed, 34 insertions(+), 38 deletions(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 3250c98e0..0c56dba80 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -57,7 +57,6 @@ def __init__( self._components: dict[str, int] = {} self._state_information: dict[str, Any] = {} self._modules: dict[str | ModuleName[Module], SmartModule] = {} - self._exposes_child_modules = False self._parent: SmartDevice | None = None self._children: Mapping[str, SmartDevice] = {} self._last_update = {} @@ -99,16 +98,6 @@ def children(self) -> Sequence[SmartDevice]: @property def modules(self) -> ModuleMapping[SmartModule]: """Return the device modules.""" - if self._exposes_child_modules: - modules = {k: v for k, v in self._modules.items()} - for child in self._children.values(): - for k, v in child._modules.items(): - if k not in modules: - modules[k] = v - if TYPE_CHECKING: - return cast(ModuleMapping[SmartModule], modules) - return modules - if TYPE_CHECKING: # Needed for python 3.8 return cast(ModuleMapping[SmartModule], self._modules) return self._modules @@ -213,7 +202,6 @@ async def _initialize_modules(self): skip_parent_only_modules = True elif self._children and self.device_type == DeviceType.WallSwitch: # _initialize_modules is called on the parent after the children - self._exposes_child_modules = True for child in self._children.values(): child_modules_to_skip.update(**child.modules) @@ -332,10 +320,7 @@ async def _initialize_features(self): ) for module in self.modules.values(): - # Check if module features have already been initialized. - # i.e. when _exposes_child_modules is true - if not module._module_features: - module._initialize_features() + module._initialize_features() for feat in module._module_features.values(): self._add_feature(feat) for child in self._children.values(): diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 04b6d3917..184eedaab 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -434,3 +434,16 @@ async def dev(request) -> AsyncGenerator[Device, None]: yield dev await dev.disconnect() + + +def get_parent_and_child_modules(device: Device, module_name): + """Return iterator of module if exists on parent and children. + + Useful for testing devices that have components listed on the parent that are only + supported on the children, i.e. ks240. + """ + if module_name in device.modules: + yield device.modules[module_name] + for child in device.children: + if module_name in child.modules: + yield child.modules[module_name] diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index e3c3c5303..bbf4d6dfa 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -2,7 +2,7 @@ from kasa.iot import IotDevice from kasa.smart import SmartDevice -from kasa.tests.conftest import dimmable_iot, parametrize +from kasa.tests.conftest import dimmable_iot, get_parent_and_child_modules, parametrize brightness = parametrize("brightness smart", component_filter="brightness") @@ -10,13 +10,13 @@ @brightness async def test_brightness_component(dev: SmartDevice): """Test brightness feature.""" - brightness = dev.modules.get("Brightness") + brightness = next(get_parent_and_child_modules(dev, "Brightness")) assert brightness assert isinstance(dev, SmartDevice) assert "brightness" in dev._components # Test getting the value - feature = dev.features["brightness"] + feature = brightness._device.features["brightness"] assert isinstance(feature.value, int) assert feature.value > 1 and feature.value <= 100 diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 6d5a0dd1d..ee04015fa 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -3,7 +3,7 @@ from kasa import Module from kasa.smart import SmartDevice -from kasa.tests.device_fixtures import parametrize +from kasa.tests.device_fixtures import get_parent_and_child_modules, parametrize fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"SMART"}) @@ -11,10 +11,9 @@ @fan async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): """Test fan speed feature.""" - fan = dev.modules.get(Module.Fan) + fan = next(get_parent_and_child_modules(dev, Module.Fan)) assert fan - - level_feature = dev.features["fan_speed_level"] + level_feature = fan._module_features["fan_speed_level"] assert ( level_feature.minimum_value <= level_feature.value @@ -36,9 +35,9 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): @fan async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): """Test sleep mode feature.""" - fan = dev.modules.get(Module.Fan) + fan = next(get_parent_and_child_modules(dev, Module.Fan)) assert fan - sleep_feature = dev.features["fan_sleep_mode"] + sleep_feature = fan._module_features["fan_sleep_mode"] assert isinstance(sleep_feature.value, bool) call = mocker.spy(fan, "call") @@ -55,7 +54,7 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): """Test fan speed on device interface.""" assert isinstance(dev, SmartDevice) - fan = dev.modules.get(Module.Fan) + fan = next(get_parent_and_child_modules(dev, Module.Fan)) assert fan device = fan._device diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index eaff5c07c..c0d905789 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -7,6 +7,7 @@ bulb_smart, dimmable_iot, dimmer_iot, + get_parent_and_child_modules, lightstrip_iot, parametrize, parametrize_combine, @@ -123,11 +124,11 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture): async def test_light_brightness(dev: Device): """Test brightness setter and getter.""" assert isinstance(dev, Device) - light = dev.modules.get(Module.Light) + light = next(get_parent_and_child_modules(dev, Module.Light)) assert light # Test getting the value - feature = dev.features["brightness"] + feature = light._device.features["brightness"] assert feature.minimum_value == 0 assert feature.maximum_value == 100 @@ -146,7 +147,7 @@ async def test_light_brightness(dev: Device): async def test_light_set_state(dev: Device): """Test brightness setter and getter.""" assert isinstance(dev, Device) - light = dev.modules.get(Module.Light) + light = next(get_parent_and_child_modules(dev, Module.Light)) assert light await light.set_state(LightState(light_on=False)) @@ -169,11 +170,11 @@ async def test_light_set_state(dev: Device): @light_preset async def test_light_preset_module(dev: Device, mocker: MockerFixture): """Test light preset module.""" - preset_mod = dev.modules[Module.LightPreset] + preset_mod = next(get_parent_and_child_modules(dev, Module.LightPreset)) assert preset_mod - light_mod = dev.modules[Module.Light] + light_mod = next(get_parent_and_child_modules(dev, Module.Light)) assert light_mod - feat = dev.features["light_preset"] + feat = preset_mod._device.features["light_preset"] preset_list = preset_mod.preset_list assert "Not set" in preset_list @@ -220,7 +221,7 @@ async def test_light_preset_module(dev: Device, mocker: MockerFixture): @light_preset async def test_light_preset_save(dev: Device, mocker: MockerFixture): """Test saving a new preset value.""" - preset_mod = dev.modules[Module.LightPreset] + preset_mod = next(get_parent_and_child_modules(dev, Module.LightPreset)) assert preset_mod preset_list = preset_mod.preset_list if len(preset_list) == 1: diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 88880e103..2ffc40ba1 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -16,6 +16,7 @@ from .conftest import ( device_smart, get_device_for_fixture_protocol, + get_parent_and_child_modules, ) @@ -144,11 +145,8 @@ async def test_get_modules(): # Modules on child module = dummy_device.modules.get("Fan") - assert module - assert module._device != dummy_device - assert module._device._parent == dummy_device - - module = dummy_device.modules.get(Module.Fan) + assert module is None + module = next(get_parent_and_child_modules(dummy_device, "Fan")) assert module assert module._device != dummy_device assert module._device._parent == dummy_device From 9e74e1bd40f53c30f5ff0af3d41d79d6ffabc89c Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 10 Jun 2024 06:21:21 +0100 Subject: [PATCH 463/892] Do not add parent only modules to strip sockets (#963) Excludes modules that child devices report as supported but do not make sense on a child device like firmware, cloud, time etc. --- kasa/smart/smartdevice.py | 10 ++++++---- kasa/tests/device_fixtures.py | 18 ++++++++++++++++++ kasa/tests/test_childdevice.py | 20 +++++++++++++++++++- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 0c56dba80..9013fc934 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -29,11 +29,11 @@ _LOGGER = logging.getLogger(__name__) -# List of modules that wall switches with children, i.e. ks240 report on +# List of modules that non hub devices with children, i.e. ks240/P300, report on # the child but only work on the parent. See longer note below in _initialize_modules. # This list should be updated when creating new modules that could have the # same issue, homekit perhaps? -WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] +NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] # Device must go last as the other interfaces also inherit Device @@ -196,9 +196,11 @@ async def _initialize_modules(self): # when they need to be accessed through the children. # The logic below ensures that such devices add all but whitelisted, only on # the child device. + # It also ensures that devices like power strips do not add modules such as + # firmware to the child devices. skip_parent_only_modules = False child_modules_to_skip = {} - if self._parent and self._parent.device_type == DeviceType.WallSwitch: + if self._parent and self._parent.device_type != DeviceType.Hub: skip_parent_only_modules = True elif self._children and self.device_type == DeviceType.WallSwitch: # _initialize_modules is called on the parent after the children @@ -209,7 +211,7 @@ async def _initialize_modules(self): _LOGGER.debug("%s requires %s", mod, mod.REQUIRED_COMPONENT) if ( - skip_parent_only_modules and mod in WALL_SWITCH_PARENT_ONLY_MODULES + skip_parent_only_modules and mod in NON_HUB_PARENT_ONLY_MODULES ) or mod.__name__ in child_modules_to_skip: continue if ( diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 184eedaab..844314bef 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -152,6 +152,24 @@ def parametrize_combine(parametrized: list[pytest.MarkDecorator]): ) +def parametrize_subtract(params: pytest.MarkDecorator, subtract: pytest.MarkDecorator): + """Combine multiple pytest parametrize dev marks into one set of fixtures.""" + if params.args[0] != "dev" or subtract.args[0] != "dev": + raise Exception( + f"Supplied mark is not for dev fixture: {params.args[0]} {subtract.args[0]}" + ) + fixtures = [] + for param in params.args[1]: + if param not in subtract.args[1]: + fixtures.append(param) + return pytest.mark.parametrize( + "dev", + sorted(fixtures), + indirect=True, + ids=idgenerator, + ) + + def parametrize( desc, *, diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 9e4b6fdb6..26568c24a 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -3,10 +3,20 @@ import pytest +from kasa.device_type import DeviceType from kasa.smart.smartchilddevice import SmartChildDevice +from kasa.smart.smartdevice import NON_HUB_PARENT_ONLY_MODULES from kasa.smartprotocol import _ChildProtocolWrapper -from .conftest import strip_smart +from .conftest import parametrize, parametrize_subtract, strip_smart + +has_children_smart = parametrize( + "has children", component_filter="control_child", protocol_filter={"SMART"} +) +hub_smart = parametrize( + "smart hub", device_type_filter=[DeviceType.Hub], protocol_filter={"SMART"} +) +non_hub_parent_smart = parametrize_subtract(has_children_smart, hub_smart) @strip_smart @@ -82,3 +92,11 @@ def _test_property_getters(): exceptions = list(_test_property_getters()) if exceptions: raise ExceptionGroup("Accessing child properties caused exceptions", exceptions) + + +@non_hub_parent_smart +async def test_parent_only_modules(dev, dummy_protocol, mocker): + """Test that parent only modules are not available on children.""" + for child in dev.children: + for module in NON_HUB_PARENT_ONLY_MODULES: + assert module not in [type(module) for module in child.modules.values()] From 927fe648ac60a354f5f9606defe687c6593917d2 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 10 Jun 2024 14:13:46 +0100 Subject: [PATCH 464/892] Better checking of child modules not supported by parent device (#966) Replaces the logic to skip adding child modules to parent devices based on whether a device is a wall switch and instead relies on the `_check_supported` method. Is more future proof and will fix issue with the P300 with child `auto_off` modules https://github.com/python-kasa/python-kasa/pull/915 not supported on the parent. --- kasa/smart/modules/lightpreset.py | 10 ++++++++++ kasa/smart/smartdevice.py | 4 ---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index e0a775aff..0fb57952f 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -140,3 +140,13 @@ def query(self) -> dict: if self._state_in_sysinfo: # Child lights can have states in the child info return {} return {self.QUERY_GETTER_NAME: None} + + async def _check_supported(self): + """Additional check to see if the module is supported by the device. + + Parent devices that report components of children such as ks240 will not have + the brightness value is sysinfo. + """ + # Look in _device.sys_info here because self.data is either sys_info or + # get_preset_rules depending on whether it's a child device or not. + return "brightness" in self._device.sys_info diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 9013fc934..6f02fad0d 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -202,10 +202,6 @@ async def _initialize_modules(self): child_modules_to_skip = {} if self._parent and self._parent.device_type != DeviceType.Hub: skip_parent_only_modules = True - elif self._children and self.device_type == DeviceType.WallSwitch: - # _initialize_modules is called on the parent after the children - for child in self._children.values(): - child_modules_to_skip.update(**child.modules) for mod in SmartModule.REGISTERED_MODULES.values(): _LOGGER.debug("%s requires %s", mod, mod.REQUIRED_COMPONENT) From db6276d3fd305bc8d70323e175f40d8c16d83696 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 10 Jun 2024 15:47:00 +0100 Subject: [PATCH 465/892] Support smart child modules queries (#967) Required for the P300 firmware update with `auto_off` module on child devices. Will query child modules for parent devices that are not hubs. Coverage will be fixed when the P300 fixture is added https://github.com/python-kasa/python-kasa/pull/915 --- kasa/smart/modules/autooff.py | 8 ++++++++ kasa/smart/smartchilddevice.py | 13 ++++++++++++- kasa/smart/smartdevice.py | 14 +++++++++----- kasa/tests/fakeprotocol_smart.py | 20 +++++++++++++++++++- kasa/tests/smart/modules/test_autooff.py | 16 ++++++++-------- kasa/tests/test_smartdevice.py | 10 ++++++++-- 6 files changed, 64 insertions(+), 17 deletions(-) diff --git a/kasa/smart/modules/autooff.py b/kasa/smart/modules/autooff.py index 684a2c510..afb822c56 100644 --- a/kasa/smart/modules/autooff.py +++ b/kasa/smart/modules/autooff.py @@ -99,3 +99,11 @@ def auto_off_at(self) -> datetime | None: sysinfo = self._device.sys_info return self._device.time + timedelta(seconds=sysinfo["auto_off_remain_time"]) + + async def _check_supported(self): + """Additional check to see if the module is supported by the device. + + Parent devices that report components of children such as P300 will not have + the auto_off_status is sysinfo. + """ + return "auto_off_status" in self._device.sys_info diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 3c3b0f292..c6596b969 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -34,7 +35,17 @@ def __init__( self.protocol = _ChildProtocolWrapper(self._id, parent.protocol) async def update(self, update_children: bool = True): - """Noop update. The parent updates our internals.""" + """Update child module info. + + The parent updates our internal info so just update modules with + their own queries. + """ + req: dict[str, Any] = {} + for module in self.modules.values(): + if mod_query := module.query(): + req.update(mod_query) + if req: + self._last_update = await self.protocol.query(req) @classmethod async def create(cls, parent: SmartDevice, child_info, child_components): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 6f02fad0d..26bf1396d 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -149,7 +149,7 @@ async def _negotiate(self): if "child_device" in self._components and not self.children: await self._initialize_children() - async def update(self, update_children: bool = True): + async def update(self, update_children: bool = False): """Update the device.""" if self.credentials is None and self.credentials_hash is None: raise AuthenticationError("Tapo plug requires authentication.") @@ -167,9 +167,14 @@ async def update(self, update_children: bool = True): self._last_update = resp = await self.protocol.query(req) self._info = self._try_get_response(resp, "get_device_info") + + # Call child update which will only update module calls, info is updated + # from get_child_device_list. update_children only affects hub devices, other + # devices will always update children to prevent errors on module access. + if update_children or self.device_type != DeviceType.Hub: + for child in self._children.values(): + await child.update() if child_info := self._try_get_response(resp, "get_child_device_list", {}): - # TODO: we don't currently perform queries on children based on modules, - # but just update the information that is returned in the main query. for info in child_info["child_device_list"]: self._children[info["device_id"]]._update_internal_state(info) @@ -352,8 +357,7 @@ def alias(self) -> str | None: @property def time(self) -> datetime: """Return the time.""" - # TODO: Default to parent's time module for child devices - if self._parent and Module.Time in self.modules: + if self._parent and Module.Time in self._parent.modules: _timemod = self._parent.modules[Module.Time] else: _timemod = self.modules[Module.Time] diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index b36c254de..533cd6486 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -149,6 +149,11 @@ def _handle_control_child(self, params: dict): if child["device_id"] == device_id: info = child break + # Create the child_devices fixture section for fixtures generated before it was added + if "child_devices" not in self.info: + self.info["child_devices"] = {} + # Get the method calls made directly on the child devices + child_device_calls = self.info["child_devices"].setdefault(device_id, {}) # We only support get & set device info for now. if child_method == "get_device_info": @@ -159,14 +164,27 @@ def _handle_control_child(self, params: dict): return {"error_code": 0} elif child_method == "set_preset_rules": return self._set_child_preset_rules(info, child_params) + elif child_method in child_device_calls: + result = copy.deepcopy(child_device_calls[child_method]) + return {"result": result, "error_code": 0} elif ( # FIXTURE_MISSING is for service calls not in place when # SMART fixtures started to be generated missing_result := self.FIXTURE_MISSING_MAP.get(child_method) ) and missing_result[0] in self.components: - result = copy.deepcopy(missing_result[1]) + # Copy to info so it will work with update methods + child_device_calls[child_method] = copy.deepcopy(missing_result[1]) + result = copy.deepcopy(info[child_method]) retval = {"result": result, "error_code": 0} return retval + elif child_method[:4] == "set_": + target_method = f"get_{child_method[4:]}" + if target_method not in child_device_calls: + raise RuntimeError( + f"No {target_method} in child info, calling set before get not supported." + ) + child_device_calls[target_method].update(child_params) + return {"error_code": 0} else: # PARAMS error returned for KS240 when get_device_usage called # on parent device. Could be any error code though. diff --git a/kasa/tests/smart/modules/test_autooff.py b/kasa/tests/smart/modules/test_autooff.py index c44617a76..50a1c9921 100644 --- a/kasa/tests/smart/modules/test_autooff.py +++ b/kasa/tests/smart/modules/test_autooff.py @@ -9,7 +9,7 @@ from kasa import Module from kasa.smart import SmartDevice -from kasa.tests.device_fixtures import parametrize +from kasa.tests.device_fixtures import get_parent_and_child_modules, parametrize autooff = parametrize( "has autooff", component_filter="auto_off", protocol_filter={"SMART"} @@ -33,13 +33,13 @@ async def test_autooff_features( dev: SmartDevice, feature: str, prop_name: str, type: type ): """Test that features are registered and work as expected.""" - autooff = dev.modules.get(Module.AutoOff) + autooff = next(get_parent_and_child_modules(dev, Module.AutoOff)) assert autooff is not None prop = getattr(autooff, prop_name) assert isinstance(prop, type) - feat = dev.features[feature] + feat = autooff._device.features[feature] assert feat.value == prop assert isinstance(feat.value, type) @@ -47,13 +47,13 @@ async def test_autooff_features( @autooff async def test_settings(dev: SmartDevice, mocker: MockerFixture): """Test autooff settings.""" - autooff = dev.modules.get(Module.AutoOff) + autooff = next(get_parent_and_child_modules(dev, Module.AutoOff)) assert autooff - enabled = dev.features["auto_off_enabled"] + enabled = autooff._device.features["auto_off_enabled"] assert autooff.enabled == enabled.value - delay = dev.features["auto_off_minutes"] + delay = autooff._device.features["auto_off_minutes"] assert autooff.delay == delay.value call = mocker.spy(autooff, "call") @@ -86,10 +86,10 @@ async def test_auto_off_at( dev: SmartDevice, mocker: MockerFixture, is_timer_active: bool ): """Test auto-off at sensor.""" - autooff = dev.modules.get(Module.AutoOff) + autooff = next(get_parent_and_child_modules(dev, Module.AutoOff)) assert autooff - autooff_at = dev.features["auto_off_at"] + autooff_at = autooff._device.features["auto_off_at"] mocker.patch.object( type(autooff), diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 2ffc40ba1..4a260003b 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -9,7 +9,7 @@ import pytest from pytest_mock import MockerFixture -from kasa import KasaException, Module +from kasa import Device, KasaException, Module from kasa.exceptions import SmartErrorCode from kasa.smart import SmartDevice @@ -112,6 +112,11 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): device_queries: dict[SmartDevice, dict[str, Any]] = {} for mod in dev._modules.values(): device_queries.setdefault(mod._device, {}).update(mod.query()) + # Hubs do not query child modules by default. + if dev.device_type != Device.Type.Hub: + for child in dev.children: + for mod in child.modules.values(): + device_queries.setdefault(mod._device, {}).update(mod.query()) spies = {} for device in device_queries: @@ -120,7 +125,8 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): await dev.update() for device in device_queries: if device_queries[device]: - spies[device].assert_called_with(device_queries[device]) + # Need assert any here because the child device updates use the parent's protocol + spies[device].assert_any_call(device_queries[device]) else: spies[device].assert_not_called() From 447d829abe1f7b5bd27d8deed0c276809b0ecdba Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 10 Jun 2024 17:00:31 +0200 Subject: [PATCH 466/892] Add fixture for p300 1.0.15 (#915) This version adds auto-off for individual strip sockets. --- SUPPORTED.md | 1 + .../fixtures/smart/P300(EU)_1.0_1.0.15.json | 966 ++++++++++++++++++ 2 files changed, 967 insertions(+) create mode 100644 kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json diff --git a/SUPPORTED.md b/SUPPORTED.md index dd63dbc9e..9bc5b6b77 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -172,6 +172,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **P300** - Hardware: 1.0 (EU) / Firmware: 1.0.13 + - Hardware: 1.0 (EU) / Firmware: 1.0.15 - Hardware: 1.0 (EU) / Firmware: 1.0.7 - **TP25** - Hardware: 1.0 (US) / Firmware: 1.0.2 diff --git a/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json b/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json new file mode 100644 index 000000000..dd40708e2 --- /dev/null +++ b/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json @@ -0,0 +1,966 @@ +{ + "child_devices": { + "SCRUBBED_CHILD_DEVICE_ID_1": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "state": { + "on": false + }, + "type": "custom" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.15 Build 231130 Rel.122554", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 441974, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 3, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 30367, + "past7": 4909, + "today": 756 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_2": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.15 Build 231130 Rel.122554", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 441975, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 18287, + "past7": 4909, + "today": 756 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_3": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "state": { + "on": true + }, + "type": "custom" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.15 Build 231130 Rel.122554", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 441975, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 30383, + "past7": 4909, + "today": 756 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + } + }, + "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": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P300(EU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "78-8C-B5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" + } + ], + "start_index": 0, + "sum": 3 + }, + "get_child_device_list": { + "child_device_list": [ + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "state": { + "on": false + }, + "type": "custom" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.15 Build 231130 Rel.122554", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 441972, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 3, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.15 Build 231130 Rel.122554", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 441972, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "state": { + "on": true + }, + "type": "custom" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.15 Build 231130 Rel.122554", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 441972, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + } + ], + "start_index": 0, + "sum": 3 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.15 Build 231130 Rel.122554", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "de_DE", + "latitude": 0, + "longitude": 0, + "mac": "78-8C-B5-00-00-00", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -61, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1715622973 + }, + "get_device_usage": { + "time_usage": { + "past30": 30383, + "past7": 4909, + "today": 756 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_homekit_info": { + "mfi_setup_code": "000-00-000", + "mfi_setup_id": "0000", + "mfi_token_token": "00000000000000000000000000000000000000000000000000000000000000000000000000000/00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000/000000000000000000==", + "mfi_token_uuid": "00000000-0000-0000-0000-000000000000" + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.15 Build 231130 Rel.122554", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "never", + "led_status": false, + "night_mode": { + "end_time": 340, + "night_mode_type": "sunrise_sunset", + "start_time": 1277, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 19, + "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 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P300", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} From 57cbd3cb58fc746e0057a9e1a7a6dec6bd7734b1 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 10 Jun 2024 16:59:17 +0100 Subject: [PATCH 467/892] Prepare 0.7.0.dev4 (#969) ## [0.7.0.dev4](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev4) (2024-06-10) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev3...0.7.0.dev4) **Implemented enhancements:** - Support smart child modules queries [\#967](https://github.com/python-kasa/python-kasa/pull/967) (@sdb9696) - Do not expose child modules on parent devices [\#964](https://github.com/python-kasa/python-kasa/pull/964) (@sdb9696) - Do not add parent only modules to strip sockets [\#963](https://github.com/python-kasa/python-kasa/pull/963) (@sdb9696) **Project maintenance:** - Better checking of child modules not supported by parent device [\#966](https://github.com/python-kasa/python-kasa/pull/966) (@sdb9696) - Add fixture for p300 1.0.15 [\#915](https://github.com/python-kasa/python-kasa/pull/915) (@rytilahti) --- CHANGELOG.md | 15 +++++++++++++++ poetry.lock | 26 +++++++++++++------------- pyproject.toml | 2 +- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4febcb7e..1b5f623b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [0.7.0.dev4](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev4) (2024-06-10) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev3...0.7.0.dev4) + +**Implemented enhancements:** + +- Support smart child modules queries [\#967](https://github.com/python-kasa/python-kasa/pull/967) (@sdb9696) +- Do not expose child modules on parent devices [\#964](https://github.com/python-kasa/python-kasa/pull/964) (@sdb9696) +- Do not add parent only modules to strip sockets [\#963](https://github.com/python-kasa/python-kasa/pull/963) (@sdb9696) + +**Project maintenance:** + +- Better checking of child modules not supported by parent device [\#966](https://github.com/python-kasa/python-kasa/pull/966) (@sdb9696) +- Add fixture for p300 1.0.15 [\#915](https://github.com/python-kasa/python-kasa/pull/915) (@rytilahti) + ## [0.7.0.dev3](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev3) (2024-06-07) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev2...0.7.0.dev3) diff --git a/poetry.lock b/poetry.lock index 71310f732..c2f9c7240 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1190,13 +1190,13 @@ files = [ [[package]] name = "packaging" -version = "24.0" +version = "24.1" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, - {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] [[package]] @@ -1265,13 +1265,13 @@ virtualenv = ">=20.10.0" [[package]] name = "prompt-toolkit" -version = "3.0.46" +version = "3.0.47" description = "Library for building powerful interactive command lines in Python" optional = true python-versions = ">=3.7.0" files = [ - {file = "prompt_toolkit-3.0.46-py3-none-any.whl", hash = "sha256:45abe60a8300f3c618b23c16c4bb98c6fc80af8ce8b17c7ae92db48db3ee63c1"}, - {file = "prompt_toolkit-3.0.46.tar.gz", hash = "sha256:869c50d682152336e23c4db7f74667639b5047494202ffe7670817053fd57795"}, + {file = "prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10"}, + {file = "prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360"}, ] [package.dependencies] @@ -1968,13 +1968,13 @@ testing = ["build[virtualenv] (>=1.0.3)", "covdefaults (>=2.3)", "detect-test-po [[package]] name = "typing-extensions" -version = "4.12.1" +version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.12.1-py3-none-any.whl", hash = "sha256:6024b58b69089e5a89c347397254e35f1bf02a907728ec7fee9bf0fe837d203a"}, - {file = "typing_extensions-4.12.1.tar.gz", hash = "sha256:915f5e35ff76f56588223f15fdd5938f9a1cf9195c0de25130c627e4d597f6d1"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] @@ -2038,13 +2038,13 @@ files = [ [[package]] name = "xdoctest" -version = "1.1.4" +version = "1.1.5" description = "A rewrite of the builtin doctest module" optional = false python-versions = ">=3.6" files = [ - {file = "xdoctest-1.1.4-py3-none-any.whl", hash = "sha256:2ee7920603e1a977749cabf611dfde1935165c6ac83dcfb2c9bdf8fc3ac1ec26"}, - {file = "xdoctest-1.1.4.tar.gz", hash = "sha256:eb3fbad5a9ac4d47b2fafa60435ac15f2cbcd33dc860bf1e759a1f63bfeddc10"}, + {file = "xdoctest-1.1.5-py3-none-any.whl", hash = "sha256:f36fe64d7c0ad0553dbff39ff05c43a0aab69d313466f24a38d00e757182ade0"}, + {file = "xdoctest-1.1.5.tar.gz", hash = "sha256:89b0c3ad7fe03a068e22a457ab18c38fc70c62329c2963f43954b83c29374e66"}, ] [package.extras] diff --git a/pyproject.toml b/pyproject.toml index feadb1ba8..d6fdb8cb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.dev3" +version = "0.7.0.dev4" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From f0be672cf51feaaeb97de75271a9c6ec1cb0db48 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 11 Jun 2024 15:46:36 +0100 Subject: [PATCH 468/892] Add supported check to light transition module (#971) Adds an implementation of `_check_supported` to the light transition module so it is not added to a parent device that reports it but doesn't support it, i.e. ks240. --- kasa/smart/modules/lighttransition.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index a11c7d95d..1e5ba0cf1 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -181,3 +181,13 @@ def query(self) -> dict: return {} else: return {self.QUERY_GETTER_NAME: None} + + async def _check_supported(self): + """Additional check to see if the module is supported by the device. + + Parent devices that report components of children such as ks240 will not have + the brightness value is sysinfo. + """ + # Look in _device.sys_info here because self.data is either sys_info or + # get_preset_rules depending on whether it's a child device or not. + return "brightness" in self._device.sys_info From 5d5c353422a77f42ab420574847d19ebeb591586 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 11 Jun 2024 20:22:32 +0200 Subject: [PATCH 469/892] Add fixture for L920-5(EU) 1.0.7 (#972) When not paired, the effect is softAP: `Light effect (light_effect): invalid value 'softAP' not in ['Off', 'Aurora', ...]` --- SUPPORTED.md | 1 + .../fixtures/smart/L920-5(EU)_1.0_1.0.7.json | 350 ++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 9bc5b6b77..a644254a6 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -208,6 +208,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.0.17 - Hardware: 1.0 (EU) / Firmware: 1.1.0 - **L920-5** + - Hardware: 1.0 (EU) / Firmware: 1.0.7 - Hardware: 1.0 (US) / Firmware: 1.1.0 - Hardware: 1.0 (US) / Firmware: 1.1.3 - **L930-5** diff --git a/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json b/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json new file mode 100644 index 000000000..a55707aeb --- /dev/null +++ b/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json @@ -0,0 +1,350 @@ +{ + "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 + }, + { + "id": "segment", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L920-5(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": true, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "1C-61-B4-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false + }, + "obd_src": "tplink", + "owner": "" + }, + "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": 1 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "", + "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.7 Build 220119 Rel.221439", + "has_set_location_info": false, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "lighting_effect": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 1, + "id": "", + "name": "softAP" + }, + "longitude": 0, + "mac": "1C-61-B4-00-00-00", + "model": "L920", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "", + "rssi": -46, + "saturation": 100, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "", + "time_diff": 0, + "timestamp": 946771372 + }, + "get_fw_download_state": { + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_lighting_effect": { + "brightness": 0, + "custom": 0, + "direction": 1, + "display_colors": [], + "duration": 0, + "enable": 1, + "expansion_strategy": 0, + "id": "", + "name": "softAP", + "repeat_times": 0, + "segment_length": 1, + "sequence": [ + [ + 30, + 100, + 0 + ], + [ + 30, + 100, + 50 + ], + [ + 30, + 100, + 0 + ], + [ + 120, + 100, + 0 + ], + [ + 120, + 100, + 50 + ], + [ + 120, + 100, + 0 + ] + ], + "spread": 8, + "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 + }, + { + "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 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 24, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 1, + "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": "L920", + "device_type": "SMART.TAPOBULB" + } + } +} From 7f24408c326db8f1c0753c3874f57e457cad3965 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 11 Jun 2024 19:23:06 +0100 Subject: [PATCH 470/892] Handle unknown light effect names and only calculate effect list once (#973) Fixes issue with unpaired devices reporting light effect as `softAP` reported in https://github.com/python-kasa/python-kasa/pull/972. I don't think we need to handle that effect properly so just reports as off. --- kasa/smart/modules/lightstripeffect.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index 854cf4813..c2f351881 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -2,16 +2,27 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from ...interfaces.lighteffect import LightEffect as LightEffectInterface from ..effects import EFFECT_MAPPING, EFFECT_NAMES from ..smartmodule import SmartModule +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + class LightStripEffect(SmartModule, LightEffectInterface): """Implementation of dynamic light effects.""" REQUIRED_COMPONENT = "light_strip_lighting_effect" + def __init__(self, device: SmartDevice, module: str): + super().__init__(device, module) + effect_list = [self.LIGHT_EFFECTS_OFF] + effect_list.extend(EFFECT_NAMES) + self._effect_list = effect_list + @property def name(self) -> str: """Name of the module. @@ -37,7 +48,8 @@ def effect(self) -> str: """ eff = self.data["lighting_effect"] name = eff["name"] - if eff["enable"]: + # When devices are unpaired effect name is softAP which is not in our list + if eff["enable"] and name in self._effect_list: return name return self.LIGHT_EFFECTS_OFF @@ -48,9 +60,7 @@ def effect_list(self) -> list[str]: Example: ['Aurora', 'Bubbling Cauldron', ...] """ - effect_list = [self.LIGHT_EFFECTS_OFF] - effect_list.extend(EFFECT_NAMES) - return effect_list + return self._effect_list async def set_effect( self, From 4cf395483f7a8a51e47bc7ece150d2da26de57fe Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 12 Jun 2024 20:58:21 +0100 Subject: [PATCH 471/892] Add type hints to feature set_value (#974) To prevent untyped call mypy errors in consumers --- kasa/feature.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index 9863a39b5..b992789a5 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -10,7 +10,6 @@ if TYPE_CHECKING: from .device import Device - _LOGGER = logging.getLogger(__name__) @@ -140,22 +139,24 @@ def value(self): raise ValueError("Not an action and no attribute_getter set") container = self.container if self.container is not None else self.device - if isinstance(self.attribute_getter, Callable): + if callable(self.attribute_getter): return self.attribute_getter(container) return getattr(container, self.attribute_getter) - async def set_value(self, value): + async def set_value(self, value: int | float | bool | str | Enum | None) -> Any: """Set the value.""" if self.attribute_setter is None: raise ValueError("Tried to set read-only feature.") if self.type == Feature.Type.Number: # noqa: SIM102 + if not isinstance(value, (int, float)): + raise ValueError("value must be a number") if value < self.minimum_value or value > self.maximum_value: raise ValueError( f"Value {value} out of range " f"[{self.minimum_value}, {self.maximum_value}]" ) elif self.type == Feature.Type.Choice: # noqa: SIM102 - if value not in self.choices: + if not self.choices or value not in self.choices: raise ValueError( f"Unexpected value for {self.name}: {value}" f" - allowed: {self.choices}" From 6cdbbefb908ff1df1683e1fac4fb3ce3ee77f496 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 14 Jun 2024 22:04:20 +0100 Subject: [PATCH 472/892] Add timezone to on_since attributes (#978) This allows them to displayed in HA without errors. --- kasa/iot/iotdevice.py | 9 ++++++--- kasa/iot/iotstrip.py | 8 +++++--- kasa/smart/smartdevice.py | 39 +++++++++++++++++++-------------------- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index c7631763b..1048034db 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -18,7 +18,7 @@ import functools import inspect import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..device import Device, WifiNetwork @@ -345,7 +345,8 @@ async def _initialize_features(self): category=Feature.Category.Debug, ) ) - if "on_time" in self._sys_info: + # iot strips calculate on_since from the children + if "on_time" in self._sys_info or self.device_type == Device.Type.Strip: self._add_feature( Feature( device=self, @@ -665,7 +666,9 @@ def on_since(self) -> datetime | None: on_time = self._sys_info["on_time"] - return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) + return datetime.now(timezone.utc).astimezone().replace( + microsecond=0 + ) - timedelta(seconds=on_time) @property # type: ignore @requires_update diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index dde57faaf..1ad1bdb86 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -4,7 +4,7 @@ import logging from collections import defaultdict -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any from ..device_type import DeviceType @@ -148,7 +148,7 @@ def on_since(self) -> datetime | None: if self.is_off: return None - return max(plug.on_since for plug in self.children if plug.on_since is not None) + return min(plug.on_since for plug in self.children if plug.on_since is not None) async def current_consumption(self) -> float: """Get the current power consumption in watts.""" @@ -372,7 +372,9 @@ def on_since(self) -> datetime | None: info = self._get_child_info() on_time = info["on_time"] - return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) + return datetime.now(timezone.utc).astimezone().replace( + microsecond=0 + ) - timedelta(seconds=on_time) @property # type: ignore @requires_update diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 26bf1396d..f4e3eb587 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -4,7 +4,7 @@ import base64 import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..aestransport import AesTransport @@ -357,12 +357,25 @@ def alias(self) -> str | None: @property def time(self) -> datetime: """Return the time.""" - if self._parent and Module.Time in self._parent.modules: - _timemod = self._parent.modules[Module.Time] - else: - _timemod = self.modules[Module.Time] + if (self._parent and (time_mod := self._parent.modules.get(Module.Time))) or ( + time_mod := self.modules.get(Module.Time) + ): + return time_mod.time - return _timemod.time + # We have no device time, use current local time. + return datetime.now(timezone.utc).astimezone().replace(microsecond=0) + + @property + def on_since(self) -> datetime | None: + """Return the time that the device was turned on or None if turned off.""" + if ( + not self._info.get("device_on") + or (on_time := self._info.get("on_time")) is None + ): + return None + + on_time = cast(float, on_time) + return self.time - timedelta(seconds=on_time) @property def timezone(self) -> dict: @@ -489,20 +502,6 @@ def emeter_today(self) -> float | None: energy = self.modules[Module.Energy] return energy.emeter_today - @property - def on_since(self) -> datetime | None: - """Return the time that the device was turned on or None if turned off.""" - if ( - not self._info.get("device_on") - or (on_time := self._info.get("on_time")) is None - ): - return None - on_time = cast(float, on_time) - if (timemod := self.modules.get(Module.Time)) is not None: - return timemod.time - timedelta(seconds=on_time) - else: # We have no device time, use current local time. - return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) - async def wifi_scan(self) -> list[WifiNetwork]: """Scan for available wifi networks.""" From 867b7b88309c6ccf39d661aae92afb25c487b24f Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 17 Jun 2024 10:37:08 +0200 Subject: [PATCH 473/892] Add time sync command (#951) Allows setting the device time (on SMART devices) to the current time. Fixes also setting the time which was previously broken. --- docs/source/cli.rst | 5 +++++ kasa/cli.py | 33 +++++++++++++++++++++++++++++++-- kasa/smart/modules/time.py | 8 +++++++- kasa/tests/test_cli.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index dad754d25..7d4eb0806 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -58,6 +58,11 @@ As with all other commands, you can also pass ``--help`` to both ``join`` and `` However, note that communications with devices provisioned using this method will stop working when connected to the cloud. +.. note:: + + Some commands do not work if the device time is out-of-sync. + You can use ``kasa time sync`` command to set the device time from the system where the command is run. + .. warning:: At least some devices (e.g., Tapo lights L530 and L900) are known to have a watchdog that reboots them every 10 minutes if they are unable to connect to the cloud. diff --git a/kasa/cli.py b/kasa/cli.py index 39f6636fa..a8d8b6ece 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -9,6 +9,7 @@ import re import sys from contextlib import asynccontextmanager +from datetime import datetime from functools import singledispatch, wraps from pprint import pformat as pf from typing import Any, cast @@ -967,15 +968,43 @@ async def led(dev: Device, state): return led.led -@cli.command() +@cli.group(invoke_without_command=True) +@click.pass_context +async def time(ctx: click.Context): + """Get and set time.""" + if ctx.invoked_subcommand is None: + await ctx.invoke(time_get) + + +@time.command(name="get") @pass_dev -async def time(dev): +async def time_get(dev: Device): """Get the device time.""" res = dev.time echo(f"Current time: {res}") return res +@time.command(name="sync") +@pass_dev +async def time_sync(dev: SmartDevice): + """Set the device time to current time.""" + if not isinstance(dev, SmartDevice): + raise NotImplementedError("setting time currently only implemented on smart") + + if (time := dev.modules.get(Module.Time)) is None: + echo("Device does not have time module") + return + + echo("Old time: %s" % time.time) + + local_tz = datetime.now().astimezone().tzinfo + await time.set_time(datetime.now(tz=local_tz)) + + await dev.update() + echo("New time: %s" % time.time) + + @cli.command() @click.option("--index", type=int, required=False) @click.option("--name", type=str, required=False) diff --git a/kasa/smart/modules/time.py b/kasa/smart/modules/time.py index 958cf9e21..3c2b96af3 100644 --- a/kasa/smart/modules/time.py +++ b/kasa/smart/modules/time.py @@ -51,7 +51,13 @@ def time(self) -> datetime: async def set_time(self, dt: datetime): """Set device time.""" unixtime = mktime(dt.timetuple()) + offset = cast(timedelta, dt.utcoffset()) + diff = offset / timedelta(minutes=1) return await self.call( "set_device_time", - {"timestamp": unixtime, "time_diff": dt.utcoffset(), "region": dt.tzname()}, + { + "timestamp": int(unixtime), + "time_diff": int(diff), + "region": dt.tzname(), + }, ) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 2104de050..41b1e1ad9 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -31,6 +31,7 @@ state, sysinfo, temperature, + time, toggle, update_credentials, wifi, @@ -260,6 +261,37 @@ async def test_update_credentials(dev, runner): ) +async def test_time_get(dev, runner): + """Test time get command.""" + res = await runner.invoke( + time, + obj=dev, + ) + assert res.exit_code == 0 + assert "Current time: " in res.output + + +@device_smart +async def test_time_sync(dev, mocker, runner): + """Test time sync command. + + Currently implemented only for SMART. + """ + update = mocker.patch.object(dev, "update") + set_time_mock = mocker.spy(dev.modules[Module.Time], "set_time") + res = await runner.invoke( + time, + ["sync"], + obj=dev, + ) + set_time_mock.assert_called() + update.assert_called() + + assert res.exit_code == 0 + assert "Old time: " in res.output + assert "New time: " in res.output + + async def test_emeter(dev: Device, mocker, runner): res = await runner.invoke(emeter, obj=dev) if not dev.has_emeter: From 51a972542f7b2c665dddf7db476c1d613d0bd02e Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 17 Jun 2024 11:04:46 +0200 Subject: [PATCH 474/892] Disallow non-targeted device commands (#982) Prevent the cli from allowing sub commands unless host or alias is specified. It is unwise to allow commands to be run on an arbitrary set of discovered devices so this PR shows an error if attempted. Also consolidates other invalid cli operations to use a single error function to display the error to the user. --- kasa/cli.py | 47 ++++++++++++++++++++++++------------------ kasa/tests/test_cli.py | 10 ++++----- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index a8d8b6ece..f7ff1dd34 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -71,6 +71,12 @@ def wrapper(message=None, *args, **kwargs): echo = _do_echo +def error(msg: str): + """Print an error and exit.""" + echo(f"[bold red]{msg}[/bold red]") + sys.exit(1) + + TYPE_TO_CLASS = { "plug": IotPlug, "switch": IotWallSwitch, @@ -367,6 +373,9 @@ def _nop_echo(*args, **kwargs): credentials = None if host is None: + if ctx.invoked_subcommand and ctx.invoked_subcommand != "discover": + error("Only discover is available without --host or --alias") + echo("No host name given, trying discovery..") return await ctx.invoke(discover) @@ -764,7 +773,7 @@ async def emeter(dev: Device, index: int, name: str, year, month, erase): """ if index is not None or name is not None: if not dev.is_strip: - echo("Index and name are only for power strips!") + error("Index and name are only for power strips!") return if index is not None: @@ -774,11 +783,11 @@ async def emeter(dev: Device, index: int, name: str, year, month, erase): echo("[bold]== Emeter ==[/bold]") if not dev.has_emeter: - echo("Device has no emeter") + error("Device has no emeter") return if (year or month or erase) and not isinstance(dev, IotDevice): - echo("Device has no historical statistics") + error("Device has no historical statistics") return else: dev = cast(IotDevice, dev) @@ -865,7 +874,7 @@ async def usage(dev: Device, year, month, erase): async def brightness(dev: Device, brightness: int, transition: int): """Get or set brightness.""" if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: - echo("This device does not support brightness.") + error("This device does not support brightness.") return if brightness is None: @@ -885,7 +894,7 @@ async def brightness(dev: Device, brightness: int, transition: int): async def temperature(dev: Device, temperature: int, transition: int): """Get or set color temperature.""" if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: - echo("Device does not support color temperature") + error("Device does not support color temperature") return if temperature is None: @@ -911,7 +920,7 @@ async def temperature(dev: Device, temperature: int, transition: int): async def effect(dev: Device, ctx, effect): """Set an effect.""" if not (light_effect := dev.modules.get(Module.LightEffect)): - echo("Device does not support effects") + error("Device does not support effects") return if effect is None: echo( @@ -939,7 +948,7 @@ async def effect(dev: Device, ctx, effect): async def hsv(dev: Device, ctx, h, s, v, transition): """Get or set color in HSV.""" if not (light := dev.modules.get(Module.Light)) or not light.is_color: - echo("Device does not support colors") + error("Device does not support colors") return if h is None and s is None and v is None: @@ -958,7 +967,7 @@ async def hsv(dev: Device, ctx, h, s, v, transition): async def led(dev: Device, state): """Get or set (Plug's) led state.""" if not (led := dev.modules.get(Module.Led)): - echo("Device does not support led.") + error("Device does not support led.") return if state is not None: echo(f"Turning led to {state}") @@ -1014,7 +1023,7 @@ async def on(dev: Device, index: int, name: str, transition: int): """Turn the device on.""" if index is not None or name is not None: if not dev.children: - echo("Index and name are only for devices with children.") + error("Index and name are only for devices with children.") return if index is not None: @@ -1035,7 +1044,7 @@ async def off(dev: Device, index: int, name: str, transition: int): """Turn the device off.""" if index is not None or name is not None: if not dev.children: - echo("Index and name are only for devices with children.") + error("Index and name are only for devices with children.") return if index is not None: @@ -1056,7 +1065,7 @@ async def toggle(dev: Device, index: int, name: str, transition: int): """Toggle the device on/off.""" if index is not None or name is not None: if not dev.children: - echo("Index and name are only for devices with children.") + error("Index and name are only for devices with children.") return if index is not None: @@ -1096,7 +1105,7 @@ def _schedule_list(dev, type): for rule in sched.rules: print(rule) else: - echo(f"No rules of type {type}") + error(f"No rules of type {type}") return sched.rules @@ -1112,7 +1121,7 @@ async def delete_rule(dev, id): echo(f"Deleting rule id {id}") return await schedule.delete_rule(rule_to_delete) else: - echo(f"No rule with id {id} was found") + error(f"No rule with id {id} was found") @cli.group(invoke_without_command=True) @@ -1128,7 +1137,7 @@ async def presets(ctx): def presets_list(dev: IotBulb): """List presets.""" if not dev.is_bulb or not isinstance(dev, IotBulb): - echo("Presets only supported on iot bulbs") + error("Presets only supported on iot bulbs") return for preset in dev.presets: @@ -1150,7 +1159,7 @@ async def presets_modify(dev: IotBulb, index, brightness, hue, saturation, tempe if preset.index == index: break else: - echo(f"No preset found for index {index}") + error(f"No preset found for index {index}") return if brightness is not None: @@ -1175,7 +1184,7 @@ async def presets_modify(dev: IotBulb, index, brightness, hue, saturation, tempe async def turn_on_behavior(dev: IotBulb, type, last, preset): """Modify bulb turn-on behavior.""" if not dev.is_bulb or not isinstance(dev, IotBulb): - echo("Presets only supported on iot bulbs") + error("Presets only supported on iot bulbs") return settings = await dev.get_turn_on_behavior() echo(f"Current turn on behavior: {settings}") @@ -1212,9 +1221,7 @@ async def turn_on_behavior(dev: IotBulb, type, last, preset): async def update_credentials(dev, username, password): """Update device credentials for authenticated devices.""" if not isinstance(dev, SmartDevice): - raise NotImplementedError( - "Credentials can only be updated on authenticated devices." - ) + error("Credentials can only be updated on authenticated devices.") click.confirm("Do you really want to replace the existing credentials?", abort=True) @@ -1271,7 +1278,7 @@ async def feature(dev: Device, child: str, name: str, value): return if name not in dev.features: - echo(f"No feature by name '{name}'") + error(f"No feature by name '{name}'") return feat = dev.features[name] diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 41b1e1ad9..e30685fe4 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -461,12 +461,12 @@ async def test_led(dev: Device, runner: CliRunner): async def test_json_output(dev: Device, mocker, runner): """Test that the json output produces correct output.""" - mocker.patch("kasa.Discover.discover", return_value={"127.0.0.1": dev}) - # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.Discover.discover_single", return_value=dev) + # These will mock the features to avoid accessing non-existing ones mocker.patch("kasa.device.Device.features", return_value={}) mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) - res = await runner.invoke(cli, ["--json", "state"], obj=dev) + res = await runner.invoke(cli, ["--host", "127.0.0.1", "--json", "state"], obj=dev) assert res.exit_code == 0 assert json.loads(res.output) == dev.internal_state @@ -789,7 +789,7 @@ async def test_errors(mocker, runner): ) assert res.exit_code == 1 assert ( - "Raised error: Managed to invoke callback without a context object of type 'Device' existing." + "Only discover is available without --host or --alias" in res.output.replace("\n", "") # Remove newlines from rich formatting ) assert isinstance(res.exception, SystemExit) @@ -860,7 +860,7 @@ async def test_feature_missing(mocker, runner): ) assert "No feature by name 'missing'" in res.output assert "== Features ==" not in res.output - assert res.exit_code == 0 + assert res.exit_code == 1 async def test_feature_set(mocker, runner): From b4a6df2b5cef00066d1d8279be019329b5e680a2 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 17 Jun 2024 11:22:05 +0100 Subject: [PATCH 475/892] Add common energy module and deprecate device emeter attributes (#976) Consolidates logic for energy monitoring across smart and iot devices. Deprecates emeter attributes in favour of common names. --- kasa/device.py | 52 ++++------ kasa/interfaces/__init__.py | 2 + kasa/interfaces/energy.py | 181 +++++++++++++++++++++++++++++++++++ kasa/iot/iotbulb.py | 2 +- kasa/iot/iotdevice.py | 118 ++++------------------- kasa/iot/iotstrip.py | 164 ++++++++++++++++++++----------- kasa/iot/modules/emeter.py | 119 ++++++----------------- kasa/iot/modules/led.py | 5 + kasa/module.py | 5 +- kasa/smart/modules/energy.py | 118 +++++++++++------------ kasa/smart/smartdevice.py | 26 ----- kasa/tests/test_device.py | 20 ++-- kasa/tests/test_emeter.py | 44 +++++++-- kasa/tests/test_iotdevice.py | 11 ++- 14 files changed, 486 insertions(+), 381 deletions(-) create mode 100644 kasa/interfaces/energy.py diff --git a/kasa/device.py b/kasa/device.py index 10722f69b..53b71d859 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -19,7 +19,6 @@ DeviceEncryptionType, DeviceFamily, ) -from .emeterstatus import EmeterStatus from .exceptions import KasaException from .feature import Feature from .iotprotocol import IotProtocol @@ -323,27 +322,6 @@ def has_emeter(self) -> bool: def on_since(self) -> datetime | None: """Return the time that the device was turned on or None if turned off.""" - @abstractmethod - async def get_emeter_realtime(self) -> EmeterStatus: - """Retrieve current energy readings.""" - - @property - @abstractmethod - def emeter_realtime(self) -> EmeterStatus: - """Get the emeter status.""" - - @property - @abstractmethod - def emeter_this_month(self) -> float | None: - """Get the emeter value for this month.""" - - @property - @abstractmethod - def emeter_today(self) -> float | None | Any: - """Get the emeter value for today.""" - # Return type of Any ensures consumers being shielded from the return - # type by @update_required are not affected. - @abstractmethod async def wifi_scan(self) -> list[WifiNetwork]: """Scan for available wifi networks.""" @@ -373,12 +351,15 @@ def __repr__(self): } def _get_replacing_attr(self, module_name: ModuleName, *attrs): - if module_name not in self.modules: + # If module name is None check self + if not module_name: + check = self + elif (check := self.modules.get(module_name)) is None: return None for attr in attrs: - if hasattr(self.modules[module_name], attr): - return getattr(self.modules[module_name], attr) + if hasattr(check, attr): + return attr return None @@ -411,6 +392,16 @@ def _get_replacing_attr(self, module_name: ModuleName, *attrs): # light preset attributes "presets": (Module.LightPreset, ["_deprecated_presets", "preset_states_list"]), "save_preset": (Module.LightPreset, ["_deprecated_save_preset"]), + # Emeter attribues + "get_emeter_realtime": (Module.Energy, ["get_status"]), + "emeter_realtime": (Module.Energy, ["status"]), + "emeter_today": (Module.Energy, ["consumption_today"]), + "emeter_this_month": (Module.Energy, ["consumption_this_month"]), + "current_consumption": (Module.Energy, ["current_consumption"]), + "get_emeter_daily": (Module.Energy, ["get_daily_stats"]), + "get_emeter_monthly": (Module.Energy, ["get_monthly_stats"]), + # Other attributes + "supported_modules": (None, ["modules"]), } def __getattr__(self, name): @@ -427,11 +418,10 @@ def __getattr__(self, name): (replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1])) is not None ): - module_name = dep_attr[0] - msg = ( - f"{name} is deprecated, use: " - + f"Module.{module_name} in device.modules instead" - ) + mod = dep_attr[0] + dev_or_mod = self.modules[mod] if mod else self + replacing = f"Module.{mod} in device.modules" if mod else replacing_attr + msg = f"{name} is deprecated, use: {replacing} instead" warn(msg, DeprecationWarning, stacklevel=1) - return replacing_attr + return getattr(dev_or_mod, replacing_attr) raise AttributeError(f"Device has no attribute {name!r}") diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index 31b9bc33d..6a12bc681 100644 --- a/kasa/interfaces/__init__.py +++ b/kasa/interfaces/__init__.py @@ -1,5 +1,6 @@ """Package for interfaces.""" +from .energy import Energy from .fan import Fan from .led import Led from .light import Light, LightState @@ -8,6 +9,7 @@ __all__ = [ "Fan", + "Energy", "Led", "Light", "LightEffect", diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py new file mode 100644 index 000000000..c1ce3a603 --- /dev/null +++ b/kasa/interfaces/energy.py @@ -0,0 +1,181 @@ +"""Module for base energy module.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from enum import IntFlag, auto +from warnings import warn + +from ..emeterstatus import EmeterStatus +from ..feature import Feature +from ..module import Module + + +class Energy(Module, ABC): + """Base interface to represent an Energy module.""" + + class ModuleFeature(IntFlag): + """Features supported by the device.""" + + #: Device reports :attr:`voltage` and :attr:`current` + VOLTAGE_CURRENT = auto() + #: Device reports :attr:`consumption_total` + CONSUMPTION_TOTAL = auto() + #: Device reports periodic stats via :meth:`get_daily_stats` + #: and :meth:`get_monthly_stats` + PERIODIC_STATS = auto() + + _supported: ModuleFeature = ModuleFeature(0) + + def supports(self, module_feature: ModuleFeature) -> bool: + """Return True if module supports the feature.""" + return module_feature in self._supported + + def _initialize_features(self): + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device, + name="Current consumption", + attribute_getter="current_consumption", + container=self, + unit="W", + id="current_consumption", + precision_hint=1, + category=Feature.Category.Primary, + ) + ) + self._add_feature( + Feature( + device, + name="Today's consumption", + attribute_getter="consumption_today", + container=self, + unit="kWh", + id="consumption_today", + precision_hint=3, + category=Feature.Category.Info, + ) + ) + self._add_feature( + Feature( + device, + id="consumption_this_month", + name="This month's consumption", + attribute_getter="consumption_this_month", + container=self, + unit="kWh", + precision_hint=3, + category=Feature.Category.Info, + ) + ) + if self.supports(self.ModuleFeature.CONSUMPTION_TOTAL): + self._add_feature( + Feature( + device, + name="Total consumption since reboot", + attribute_getter="consumption_total", + container=self, + unit="kWh", + id="consumption_total", + precision_hint=3, + category=Feature.Category.Info, + ) + ) + if self.supports(self.ModuleFeature.VOLTAGE_CURRENT): + self._add_feature( + Feature( + device, + name="Voltage", + attribute_getter="voltage", + container=self, + unit="V", + id="voltage", + precision_hint=1, + category=Feature.Category.Primary, + ) + ) + self._add_feature( + Feature( + device, + name="Current", + attribute_getter="current", + container=self, + unit="A", + id="current", + precision_hint=2, + category=Feature.Category.Primary, + ) + ) + + @property + @abstractmethod + def status(self) -> EmeterStatus: + """Return current energy readings.""" + + @property + @abstractmethod + def current_consumption(self) -> float | None: + """Get the current power consumption in Watt.""" + + @property + @abstractmethod + def consumption_today(self) -> float | None: + """Return today's energy consumption in kWh.""" + + @property + @abstractmethod + def consumption_this_month(self) -> float | None: + """Return this month's energy consumption in kWh.""" + + @property + @abstractmethod + def consumption_total(self) -> float | None: + """Return total consumption since last reboot in kWh.""" + + @property + @abstractmethod + def current(self) -> float | None: + """Return the current in A.""" + + @property + @abstractmethod + def voltage(self) -> float | None: + """Get the current voltage in V.""" + + @abstractmethod + async def get_status(self): + """Return real-time statistics.""" + + @abstractmethod + async def erase_stats(self): + """Erase all stats.""" + + @abstractmethod + async def get_daily_stats(self, *, year=None, month=None, kwh=True) -> dict: + """Return daily stats for the given year & month. + + The return value is a dictionary of {day: energy, ...}. + """ + + @abstractmethod + async def get_monthly_stats(self, *, year=None, kwh=True) -> dict: + """Return monthly stats for the given year.""" + + _deprecated_attributes = { + "emeter_today": "consumption_today", + "emeter_this_month": "consumption_this_month", + "realtime": "status", + "get_realtime": "get_status", + "erase_emeter_stats": "erase_stats", + "get_daystat": "get_daily_stats", + "get_monthstat": "get_monthly_stats", + } + + def __getattr__(self, name): + if attr := self._deprecated_attributes.get(name): + msg = f"{name} is deprecated, use {attr} instead" + warn(msg, DeprecationWarning, stacklevel=1) + return getattr(self, attr) + raise AttributeError(f"Energy module has no attribute {name!r}") diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 362093609..26c73096a 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -220,7 +220,7 @@ async def _initialize_modules(self): Module.IotAntitheft, Antitheft(self, "smartlife.iot.common.anti_theft") ) self.add_module(Module.IotTime, Time(self, "smartlife.iot.common.timesetting")) - self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) + self.add_module(Module.Energy, Emeter(self, self.emeter_type)) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud")) self.add_module(Module.Light, Light(self, self.LIGHT_SERVICE)) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 1048034db..102d6a4dc 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -23,7 +23,6 @@ from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig -from ..emeterstatus import EmeterStatus from ..exceptions import KasaException from ..feature import Feature from ..module import Module @@ -188,7 +187,7 @@ def __init__( super().__init__(host=host, config=config, protocol=protocol) self._sys_info: Any = None # TODO: this is here to avoid changing tests - self._supported_modules: dict[str, IotModule] | None = None + self._supported_modules: dict[str | ModuleName[Module], IotModule] | None = None self._legacy_features: set[str] = set() self._children: Mapping[str, IotDevice] = {} self._modules: dict[str | ModuleName[Module], IotModule] = {} @@ -199,15 +198,16 @@ def children(self) -> Sequence[IotDevice]: return list(self._children.values()) @property + @requires_update def modules(self) -> ModuleMapping[IotModule]: """Return the device modules.""" if TYPE_CHECKING: - return cast(ModuleMapping[IotModule], self._modules) - return self._modules + return cast(ModuleMapping[IotModule], self._supported_modules) + return self._supported_modules def add_module(self, name: str | ModuleName[Module], module: IotModule): """Register a module.""" - if name in self.modules: + if name in self._modules: _LOGGER.debug("Module %s already registered, ignoring..." % name) return @@ -272,14 +272,6 @@ def features(self) -> dict[str, Feature]: """Return a set of features that the device supports.""" return self._features - @property # type: ignore - @requires_update - def supported_modules(self) -> list[str | ModuleName[Module]]: - """Return a set of modules supported by the device.""" - # TODO: this should rather be called `features`, but we don't want to break - # the API now. Maybe just deprecate it and point the users to use this? - return list(self._modules.keys()) - @property # type: ignore @requires_update def has_emeter(self) -> bool: @@ -321,6 +313,11 @@ async def update(self, update_children: bool = True): async def _initialize_modules(self): """Initialize modules not added in init.""" + if self.has_emeter: + _LOGGER.debug( + "The device has emeter, querying its information along sysinfo" + ) + self.add_module(Module.Energy, Emeter(self, self.emeter_type)) async def _initialize_features(self): """Initialize common features.""" @@ -357,29 +354,13 @@ async def _initialize_features(self): ) ) - for module in self._modules.values(): + for module in self._supported_modules.values(): module._initialize_features() for module_feat in module._module_features.values(): self._add_feature(module_feat) async def _modular_update(self, req: dict) -> None: """Execute an update query.""" - if self.has_emeter: - _LOGGER.debug( - "The device has emeter, querying its information along sysinfo" - ) - self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) - - # TODO: perhaps modules should not have unsupported modules, - # making separate handling for this unnecessary - if self._supported_modules is None: - supported = {} - for module in self._modules.values(): - if module.is_supported: - supported[module._module] = module - - self._supported_modules = supported - request_list = [] est_response_size = 1024 if "system" in req else 0 for module in self._modules.values(): @@ -411,6 +392,15 @@ async def _modular_update(self, req: dict) -> None: update = {**update, **response} self._last_update = update + # IOT modules are added as default but could be unsupported post first update + if self._supported_modules is None: + supported = {} + for module_name, module in self._modules.items(): + if module.is_supported: + supported[module_name] = module + + self._supported_modules = supported + def update_from_discover_info(self, info: dict[str, Any]) -> None: """Update state from info from the discover call.""" self._discovery_info = info @@ -557,74 +547,6 @@ async def set_mac(self, mac): """ return await self._query_helper("system", "set_mac_addr", {"mac": mac}) - @property - @requires_update - def emeter_realtime(self) -> EmeterStatus: - """Return current energy readings.""" - self._verify_emeter() - return EmeterStatus(self.modules[Module.IotEmeter].realtime) - - async def get_emeter_realtime(self) -> EmeterStatus: - """Retrieve current energy readings.""" - self._verify_emeter() - return EmeterStatus(await self.modules[Module.IotEmeter].get_realtime()) - - @property - @requires_update - def emeter_today(self) -> float | None: - """Return today's energy consumption in kWh.""" - self._verify_emeter() - return self.modules[Module.IotEmeter].emeter_today - - @property - @requires_update - def emeter_this_month(self) -> float | None: - """Return this month's energy consumption in kWh.""" - self._verify_emeter() - return self.modules[Module.IotEmeter].emeter_this_month - - async def get_emeter_daily( - self, year: int | None = None, month: int | None = None, kwh: bool = True - ) -> dict: - """Retrieve daily statistics for a given month. - - :param year: year for which to retrieve statistics (default: this year) - :param month: month for which to retrieve statistics (default: this - month) - :param kwh: return usage in kWh (default: True) - :return: mapping of day of month to value - """ - self._verify_emeter() - return await self.modules[Module.IotEmeter].get_daystat( - year=year, month=month, kwh=kwh - ) - - @requires_update - async def get_emeter_monthly( - self, year: int | None = None, kwh: bool = True - ) -> dict: - """Retrieve monthly statistics for a given year. - - :param year: year for which to retrieve statistics (default: this year) - :param kwh: return usage in kWh (default: True) - :return: dict: mapping of month to value - """ - self._verify_emeter() - return await self.modules[Module.IotEmeter].get_monthstat(year=year, kwh=kwh) - - @requires_update - async def erase_emeter_stats(self) -> dict: - """Erase energy meter statistics.""" - self._verify_emeter() - return await self.modules[Module.IotEmeter].erase_stats() - - @requires_update - async def current_consumption(self) -> float: - """Get the current power consumption in Watt.""" - self._verify_emeter() - response = self.emeter_realtime - return float(response["power"]) - async def reboot(self, delay: int = 1) -> None: """Reboot the device. diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 1ad1bdb86..c2f2bb860 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -9,16 +9,17 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig +from ..emeterstatus import EmeterStatus from ..exceptions import KasaException from ..feature import Feature +from ..interfaces import Energy from ..module import Module from ..protocol import BaseProtocol from .iotdevice import ( - EmeterStatus, IotDevice, - merge, requires_update, ) +from .iotmodule import IotModule from .iotplug import IotPlug from .modules import Antitheft, Countdown, Schedule, Time, Usage @@ -97,11 +98,20 @@ def __init__( super().__init__(host=host, config=config, protocol=protocol) self.emeter_type = "emeter" self._device_type = DeviceType.Strip + + async def _initialize_modules(self): + """Initialize modules.""" + # Strip has different modules to plug so do not call super self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) self.add_module(Module.IotSchedule, Schedule(self, "schedule")) self.add_module(Module.IotUsage, Usage(self, "schedule")) self.add_module(Module.IotTime, Time(self, "time")) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) + if self.has_emeter: + _LOGGER.debug( + "The device has emeter, querying its information along sysinfo" + ) + self.add_module(Module.Energy, StripEmeter(self, self.emeter_type)) @property # type: ignore @requires_update @@ -114,10 +124,12 @@ async def update(self, update_children: bool = True): Needed for methods that are decorated with `requires_update`. """ + # Super initializes modules and features await super().update(update_children) + initialize_children = not self.children # Initialize the child devices during the first update. - if not self.children: + if initialize_children: children = self.sys_info["children"] _LOGGER.debug("Initializing %s child sockets", len(children)) self._children = { @@ -127,12 +139,22 @@ async def update(self, update_children: bool = True): for child in children } for child in self._children.values(): - await child._initialize_features() + await child._initialize_modules() - if update_children and self.has_emeter: + if update_children: for plug in self.children: await plug.update() + if not self.features: + await self._initialize_features() + + async def _initialize_features(self): + """Initialize common features.""" + # Do not initialize features until children are created + if not self.children: + return + await super()._initialize_features() + async def turn_on(self, **kwargs): """Turn the strip on.""" await self._query_helper("system", "set_relay_state", {"state": 1}) @@ -150,21 +172,43 @@ def on_since(self) -> datetime | None: return min(plug.on_since for plug in self.children if plug.on_since is not None) - async def current_consumption(self) -> float: + +class StripEmeter(IotModule, Energy): + """Energy module implementation to aggregate child modules.""" + + _supported = ( + Energy.ModuleFeature.CONSUMPTION_TOTAL + | Energy.ModuleFeature.PERIODIC_STATS + | Energy.ModuleFeature.VOLTAGE_CURRENT + ) + + def supports(self, module_feature: Energy.ModuleFeature) -> bool: + """Return True if module supports the feature.""" + return module_feature in self._supported + + def query(self): + """Return the base query.""" + return {} + + @property + def current_consumption(self) -> float | None: """Get the current power consumption in watts.""" - return sum([await plug.current_consumption() for plug in self.children]) + return sum( + v if (v := plug.modules[Module.Energy].current_consumption) else 0.0 + for plug in self._device.children + ) - @requires_update - async def get_emeter_realtime(self) -> EmeterStatus: + async def get_status(self) -> EmeterStatus: """Retrieve current energy readings.""" - emeter_rt = await self._async_get_emeter_sum("get_emeter_realtime", {}) + emeter_rt = await self._async_get_emeter_sum("get_status", {}) # Voltage is averaged since each read will result # in a slightly different voltage since they are not atomic - emeter_rt["voltage_mv"] = int(emeter_rt["voltage_mv"] / len(self.children)) + emeter_rt["voltage_mv"] = int( + emeter_rt["voltage_mv"] / len(self._device.children) + ) return EmeterStatus(emeter_rt) - @requires_update - async def get_emeter_daily( + async def get_daily_stats( self, year: int | None = None, month: int | None = None, kwh: bool = True ) -> dict: """Retrieve daily statistics for a given month. @@ -176,11 +220,10 @@ async def get_emeter_daily( :return: mapping of day of month to value """ return await self._async_get_emeter_sum( - "get_emeter_daily", {"year": year, "month": month, "kwh": kwh} + "get_daily_stats", {"year": year, "month": month, "kwh": kwh} ) - @requires_update - async def get_emeter_monthly( + async def get_monthly_stats( self, year: int | None = None, kwh: bool = True ) -> dict: """Retrieve monthly statistics for a given year. @@ -189,44 +232,68 @@ async def get_emeter_monthly( :param kwh: return usage in kWh (default: True) """ return await self._async_get_emeter_sum( - "get_emeter_monthly", {"year": year, "kwh": kwh} + "get_monthly_stats", {"year": year, "kwh": kwh} ) async def _async_get_emeter_sum(self, func: str, kwargs: dict[str, Any]) -> dict: - """Retreive emeter stats for a time period from children.""" - self._verify_emeter() + """Retrieve emeter stats for a time period from children.""" return merge_sums( - [await getattr(plug, func)(**kwargs) for plug in self.children] + [ + await getattr(plug.modules[Module.Energy], func)(**kwargs) + for plug in self._device.children + ] ) - @requires_update - async def erase_emeter_stats(self): + async def erase_stats(self): """Erase energy meter statistics for all plugs.""" - for plug in self.children: - await plug.erase_emeter_stats() + for plug in self._device.children: + await plug.modules[Module.Energy].erase_stats() @property # type: ignore - @requires_update - def emeter_this_month(self) -> float | None: + def consumption_this_month(self) -> float | None: """Return this month's energy consumption in kWh.""" - return sum(v if (v := plug.emeter_this_month) else 0 for plug in self.children) + return sum( + v if (v := plug.modules[Module.Energy].consumption_this_month) else 0.0 + for plug in self._device.children + ) @property # type: ignore - @requires_update - def emeter_today(self) -> float | None: + def consumption_today(self) -> float | None: """Return this month's energy consumption in kWh.""" - return sum(v if (v := plug.emeter_today) else 0 for plug in self.children) + return sum( + v if (v := plug.modules[Module.Energy].consumption_today) else 0.0 + for plug in self._device.children + ) @property # type: ignore - @requires_update - def emeter_realtime(self) -> EmeterStatus: + def consumption_total(self) -> float | None: + """Return total energy consumption since reboot in kWh.""" + return sum( + v if (v := plug.modules[Module.Energy].consumption_total) else 0.0 + for plug in self._device.children + ) + + @property # type: ignore + def status(self) -> EmeterStatus: """Return current energy readings.""" - emeter = merge_sums([plug.emeter_realtime for plug in self.children]) + emeter = merge_sums( + [plug.modules[Module.Energy].status for plug in self._device.children] + ) # Voltage is averaged since each read will result # in a slightly different voltage since they are not atomic - emeter["voltage_mv"] = int(emeter["voltage_mv"] / len(self.children)) + emeter["voltage_mv"] = int(emeter["voltage_mv"] / len(self._device.children)) return EmeterStatus(emeter) + @property + def current(self) -> float | None: + """Return the current in A.""" + return self.status.current + + @property + def voltage(self) -> float | None: + """Get the current voltage in V.""" + return self.status.voltage + class IotStripPlug(IotPlug): """Representation of a single socket in a power strip. @@ -275,9 +342,10 @@ async def _initialize_features(self): icon="mdi:clock", ) ) - # If the strip plug has it's own modules we should call initialize - # features for the modules here. However the _initialize_modules function - # above does not seem to be called. + for module in self._supported_modules.values(): + module._initialize_features() + for module_feat in module._module_features.values(): + self._add_feature(module_feat) async def update(self, update_children: bool = True): """Query the device to update the data. @@ -285,26 +353,8 @@ async def update(self, update_children: bool = True): Needed for properties that are decorated with `requires_update`. """ await self._modular_update({}) - - def _create_emeter_request(self, year: int | None = None, month: int | None = None): - """Create a request for requesting all emeter statistics at once.""" - if year is None: - year = datetime.now().year - if month is None: - month = datetime.now().month - - req: dict[str, Any] = {} - - merge(req, self._create_request("emeter", "get_realtime")) - merge(req, self._create_request("emeter", "get_monthstat", {"year": year})) - merge( - req, - self._create_request( - "emeter", "get_daystat", {"month": month, "year": year} - ), - ) - - return req + if not self._features: + await self._initialize_features() def _create_request( self, target: str, cmd: str, arg: dict | None = None, child_ids=None diff --git a/kasa/iot/modules/emeter.py b/kasa/iot/modules/emeter.py index 53fb20da5..7ae89e5b6 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -4,130 +4,71 @@ from datetime import datetime -from ... import Device from ...emeterstatus import EmeterStatus -from ...feature import Feature +from ...interfaces.energy import Energy as EnergyInterface from .usage import Usage -class Emeter(Usage): +class Emeter(Usage, EnergyInterface): """Emeter module.""" - def __init__(self, device: Device, module: str): - super().__init__(device, module) - self._add_feature( - Feature( - device, - name="Current consumption", - attribute_getter="current_consumption", - container=self, - unit="W", - id="current_power_w", # for homeassistant backwards compat - precision_hint=1, - category=Feature.Category.Primary, + def _post_update_hook(self) -> None: + self._supported = EnergyInterface.ModuleFeature.PERIODIC_STATS + if ( + "voltage_mv" in self.data["get_realtime"] + or "voltage" in self.data["get_realtime"] + ): + self._supported = ( + self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT ) - ) - self._add_feature( - Feature( - device, - name="Today's consumption", - attribute_getter="emeter_today", - container=self, - unit="kWh", - id="today_energy_kwh", # for homeassistant backwards compat - precision_hint=3, - category=Feature.Category.Info, + if ( + "total_wh" in self.data["get_realtime"] + or "total" in self.data["get_realtime"] + ): + self._supported = ( + self._supported | EnergyInterface.ModuleFeature.CONSUMPTION_TOTAL ) - ) - self._add_feature( - Feature( - device, - id="consumption_this_month", - name="This month's consumption", - attribute_getter="emeter_this_month", - container=self, - unit="kWh", - precision_hint=3, - category=Feature.Category.Info, - ) - ) - self._add_feature( - Feature( - device, - name="Total consumption since reboot", - attribute_getter="emeter_total", - container=self, - unit="kWh", - id="total_energy_kwh", # for homeassistant backwards compat - precision_hint=3, - category=Feature.Category.Info, - ) - ) - self._add_feature( - Feature( - device, - name="Voltage", - attribute_getter="voltage", - container=self, - unit="V", - id="voltage", # for homeassistant backwards compat - precision_hint=1, - category=Feature.Category.Primary, - ) - ) - self._add_feature( - Feature( - device, - name="Current", - attribute_getter="current", - container=self, - unit="A", - id="current_a", # for homeassistant backwards compat - precision_hint=2, - category=Feature.Category.Primary, - ) - ) @property # type: ignore - def realtime(self) -> EmeterStatus: + def status(self) -> EmeterStatus: """Return current energy readings.""" return EmeterStatus(self.data["get_realtime"]) @property - def emeter_today(self) -> float | None: + def consumption_today(self) -> float | None: """Return today's energy consumption in kWh.""" raw_data = self.daily_data today = datetime.now().day data = self._convert_stat_data(raw_data, entry_key="day", key=today) - return data.get(today) + return data.get(today, 0.0) @property - def emeter_this_month(self) -> float | None: + def consumption_this_month(self) -> float | None: """Return this month's energy consumption in kWh.""" raw_data = self.monthly_data current_month = datetime.now().month data = self._convert_stat_data(raw_data, entry_key="month", key=current_month) - return data.get(current_month) + return data.get(current_month, 0.0) @property def current_consumption(self) -> float | None: """Get the current power consumption in Watt.""" - return self.realtime.power + return self.status.power @property - def emeter_total(self) -> float | None: + def consumption_total(self) -> float | None: """Return total consumption since last reboot in kWh.""" - return self.realtime.total + return self.status.total @property def current(self) -> float | None: """Return the current in A.""" - return self.realtime.current + return self.status.current @property def voltage(self) -> float | None: """Get the current voltage in V.""" - return self.realtime.voltage + return self.status.voltage async def erase_stats(self): """Erase all stats. @@ -136,11 +77,11 @@ async def erase_stats(self): """ return await self.call("erase_emeter_stat") - async def get_realtime(self): + async def get_status(self) -> EmeterStatus: """Return real-time statistics.""" - return await self.call("get_realtime") + return EmeterStatus(await self.call("get_realtime")) - async def get_daystat(self, *, year=None, month=None, kwh=True) -> dict: + async def get_daily_stats(self, *, year=None, month=None, kwh=True) -> dict: """Return daily stats for the given year & month. The return value is a dictionary of {day: energy, ...}. @@ -149,7 +90,7 @@ async def get_daystat(self, *, year=None, month=None, kwh=True) -> dict: data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh) return data - async def get_monthstat(self, *, year=None, kwh=True) -> dict: + async def get_monthly_stats(self, *, year=None, kwh=True) -> dict: """Return monthly stats for the given year. The return value is a dictionary of {month: energy, ...}. diff --git a/kasa/iot/modules/led.py b/kasa/iot/modules/led.py index 6c4ca02aa..48301f237 100644 --- a/kasa/iot/modules/led.py +++ b/kasa/iot/modules/led.py @@ -30,3 +30,8 @@ def led(self) -> bool: async def set_led(self, state: bool): """Set the state of the led (night mode).""" return await self.call("set_led_off", {"off": int(not state)}) + + @property + def is_supported(self) -> bool: + """Return whether the module is supported by the device.""" + return "led_off" in self.data diff --git a/kasa/module.py b/kasa/module.py index a2a9c931a..177c2baa1 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -33,6 +33,8 @@ class Module(ABC): """ # Common Modules + Energy: Final[ModuleName[interfaces.Energy]] = ModuleName("Energy") + Fan: Final[ModuleName[interfaces.Fan]] = ModuleName("Fan") LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect") Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") @@ -42,7 +44,6 @@ class Module(ABC): IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft") IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown") - IotEmeter: Final[ModuleName[iot.Emeter]] = ModuleName("emeter") IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion") IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule") IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage") @@ -62,8 +63,6 @@ class Module(ABC): ) ContactSensor: Final[ModuleName[smart.ContactSensor]] = ModuleName("ContactSensor") DeviceModule: Final[ModuleName[smart.DeviceModule]] = ModuleName("DeviceModule") - Energy: Final[ModuleName[smart.Energy]] = ModuleName("Energy") - Fan: Final[ModuleName[smart.Fan]] = ModuleName("Fan") Firmware: Final[ModuleName[smart.Firmware]] = ModuleName("Firmware") FrostProtection: Final[ModuleName[smart.FrostProtection]] = ModuleName( "FrostProtection" diff --git a/kasa/smart/modules/energy.py b/kasa/smart/modules/energy.py index 55b5088e7..3edbddb47 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -2,60 +2,17 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...emeterstatus import EmeterStatus -from ...feature import Feature +from ...exceptions import KasaException +from ...interfaces.energy import Energy as EnergyInterface from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - -class Energy(SmartModule): +class Energy(SmartModule, EnergyInterface): """Implementation of energy monitoring module.""" REQUIRED_COMPONENT = "energy_monitoring" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) - self._add_feature( - Feature( - device, - "consumption_current", - name="Current consumption", - attribute_getter="current_power", - container=self, - unit="W", - precision_hint=1, - category=Feature.Category.Primary, - ) - ) - self._add_feature( - Feature( - device, - "consumption_today", - name="Today's consumption", - attribute_getter="emeter_today", - container=self, - unit="Wh", - precision_hint=2, - category=Feature.Category.Info, - ) - ) - self._add_feature( - Feature( - device, - "consumption_this_month", - name="This month's consumption", - attribute_getter="emeter_this_month", - container=self, - unit="Wh", - precision_hint=2, - category=Feature.Category.Info, - ) - ) - def query(self) -> dict: """Query to execute during the update cycle.""" req = { @@ -66,9 +23,9 @@ def query(self) -> dict: return req @property - def current_power(self) -> float | None: + def current_consumption(self) -> float | None: """Current power in watts.""" - if power := self.energy.get("current_power"): + if (power := self.energy.get("current_power")) is not None: return power / 1_000 return None @@ -79,23 +36,64 @@ def energy(self): return en return self.data - @property - def emeter_realtime(self): - """Get the emeter status.""" - # TODO: Perhaps we should get rid of emeterstatus altogether for smartdevices + def _get_status_from_energy(self, energy) -> EmeterStatus: return EmeterStatus( { - "power_mw": self.energy.get("current_power"), - "total": self.energy.get("today_energy") / 1_000, + "power_mw": energy.get("current_power"), + "total": energy.get("today_energy") / 1_000, } ) @property - def emeter_this_month(self) -> float | None: - """Get the emeter value for this month.""" - return self.energy.get("month_energy") + def status(self): + """Get the emeter status.""" + return self._get_status_from_energy(self.energy) + + async def get_status(self): + """Return real-time statistics.""" + res = await self.call("get_energy_usage") + return self._get_status_from_energy(res["get_energy_usage"]) + + @property + def consumption_this_month(self) -> float | None: + """Get the emeter value for this month in kWh.""" + return self.energy.get("month_energy") / 1_000 + + @property + def consumption_today(self) -> float | None: + """Get the emeter value for today in kWh.""" + return self.energy.get("today_energy") / 1_000 + + @property + def consumption_total(self) -> float | None: + """Return total consumption since last reboot in kWh.""" + return None + + @property + def current(self) -> float | None: + """Return the current in A.""" + return None @property - def emeter_today(self) -> float | None: - """Get the emeter value for today.""" - return self.energy.get("today_energy") + def voltage(self) -> float | None: + """Get the current voltage in V.""" + return None + + async def _deprecated_get_realtime(self) -> EmeterStatus: + """Retrieve current energy readings.""" + return self.status + + async def erase_stats(self): + """Erase all stats.""" + raise KasaException("Device does not support periodic statistics") + + async def get_daily_stats(self, *, year=None, month=None, kwh=True) -> dict: + """Return daily stats for the given year & month. + + The return value is a dictionary of {day: energy, ...}. + """ + raise KasaException("Device does not support periodic statistics") + + async def get_monthly_stats(self, *, year=None, kwh=True) -> dict: + """Return monthly stats for the given year.""" + raise KasaException("Device does not support periodic statistics") diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index f4e3eb587..5a2f99e59 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -11,7 +11,6 @@ from ..device import Device, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..feature import Feature from ..module import Module @@ -477,31 +476,6 @@ def update_from_discover_info(self, info): self._discovery_info = info self._info = info - async def get_emeter_realtime(self) -> EmeterStatus: - """Retrieve current energy readings.""" - _LOGGER.warning("Deprecated, use `emeter_realtime`.") - if not self.has_emeter: - raise KasaException("Device has no emeter") - return self.emeter_realtime - - @property - def emeter_realtime(self) -> EmeterStatus: - """Get the emeter status.""" - energy = self.modules[Module.Energy] - return energy.emeter_realtime - - @property - def emeter_this_month(self) -> float | None: - """Get the emeter value for this month.""" - energy = self.modules[Module.Energy] - return energy.emeter_this_month - - @property - def emeter_today(self) -> float | None: - """Get the emeter value for today.""" - energy = self.modules[Module.Energy] - return energy.emeter_today - async def wifi_scan(self) -> list[WifiNetwork]: """Scan for available wifi networks.""" diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index c6d412c73..07e764cbf 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -163,12 +163,7 @@ async def _test_attribute( if is_expected and will_raise: ctx = pytest.raises(will_raise) elif is_expected: - ctx = pytest.deprecated_call( - match=( - f"{attribute_name} is deprecated, use: Module." - + f"{module_name} in device.modules instead" - ) - ) + ctx = pytest.deprecated_call(match=(f"{attribute_name} is deprecated, use:")) else: ctx = pytest.raises( AttributeError, match=f"Device has no attribute '{attribute_name}'" @@ -239,6 +234,19 @@ async def test_deprecated_other_attributes(dev: Device): await _test_attribute(dev, "led", bool(led_module), "Led") await _test_attribute(dev, "set_led", bool(led_module), "Led", True) + await _test_attribute(dev, "supported_modules", True, None) + + +async def test_deprecated_emeter_attributes(dev: Device): + energy_module = dev.modules.get(Module.Energy) + + await _test_attribute(dev, "get_emeter_realtime", bool(energy_module), "Energy") + await _test_attribute(dev, "emeter_realtime", bool(energy_module), "Energy") + await _test_attribute(dev, "emeter_today", bool(energy_module), "Energy") + await _test_attribute(dev, "emeter_this_month", bool(energy_module), "Energy") + await _test_attribute(dev, "current_consumption", bool(energy_module), "Energy") + await _test_attribute(dev, "get_emeter_daily", bool(energy_module), "Energy") + await _test_attribute(dev, "get_emeter_monthly", bool(energy_module), "Energy") async def test_deprecated_light_preset_attributes(dev: Device): diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index a8fe75edd..b710ec73f 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -10,8 +10,9 @@ Schema, ) -from kasa import EmeterStatus, KasaException -from kasa.iot import IotDevice +from kasa import Device, EmeterStatus, Module +from kasa.interfaces.energy import Energy +from kasa.iot import IotDevice, IotStrip from kasa.iot.modules.emeter import Emeter from .conftest import has_emeter, has_emeter_iot, no_emeter @@ -38,16 +39,16 @@ async def test_no_emeter(dev): assert not dev.has_emeter - with pytest.raises(KasaException): + with pytest.raises(AttributeError): await dev.get_emeter_realtime() # Only iot devices support the historical stats so other # devices will not implement the methods below if isinstance(dev, IotDevice): - with pytest.raises(KasaException): + with pytest.raises(AttributeError): await dev.get_emeter_daily() - with pytest.raises(KasaException): + with pytest.raises(AttributeError): await dev.get_emeter_monthly() - with pytest.raises(KasaException): + with pytest.raises(AttributeError): await dev.erase_emeter_stats() @@ -128,11 +129,11 @@ async def test_erase_emeter_stats(dev): @has_emeter_iot async def test_current_consumption(dev): if dev.has_emeter: - x = await dev.current_consumption() + x = dev.current_consumption assert isinstance(x, float) assert x >= 0.0 else: - assert await dev.current_consumption() is None + assert dev.current_consumption is None async def test_emeterstatus_missing_current(): @@ -173,3 +174,30 @@ def data(self): {"day": now.day, "energy_wh": 500, "month": now.month, "year": now.year} ) assert emeter.emeter_today == 0.500 + + +@has_emeter +async def test_supported(dev: Device): + energy_module = dev.modules.get(Module.Energy) + assert energy_module + if isinstance(dev, IotDevice): + info = ( + dev._last_update + if not isinstance(dev, IotStrip) + else dev.children[0].internal_state + ) + emeter = info[energy_module._module]["get_realtime"] + has_total = "total" in emeter or "total_wh" in emeter + has_voltage_current = "voltage" in emeter or "voltage_mv" in emeter + assert ( + energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is has_total + ) + assert ( + energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) + is has_voltage_current + ) + assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is True + else: + assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is False + assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False + assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is False diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py index d5c76192b..f43258e45 100644 --- a/kasa/tests/test_iotdevice.py +++ b/kasa/tests/test_iotdevice.py @@ -116,9 +116,16 @@ async def test_initial_update_no_emeter(dev, mocker): dev._legacy_features = set() spy = mocker.spy(dev.protocol, "query") await dev.update() - # 2 calls are necessary as some devices crash on unexpected modules + # child calls will happen if a child has a module with a query (e.g. schedule) + child_calls = 0 + for child in dev.children: + for module in child.modules.values(): + if module.query(): + child_calls += 1 + break + # 2 parent are necessary as some devices crash on unexpected modules # See #105, #120, #161 - assert spy.call_count == 2 + assert spy.call_count == 2 + child_calls @device_iot From 6b46773609fbdecc026b770439908ae2ecb4f760 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 17 Jun 2024 12:19:04 +0100 Subject: [PATCH 476/892] Prepare 0.7.0.dev5 (#984) ## [0.7.0.dev5](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev5) (2024-06-17) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev4...0.7.0.dev5) **Implemented enhancements:** - Add timezone to on\_since attributes [\#978](https://github.com/python-kasa/python-kasa/pull/978) (@sdb9696) - Add common energy module and deprecate device emeter attributes [\#976](https://github.com/python-kasa/python-kasa/pull/976) (@sdb9696) - Add type hints to feature set\_value [\#974](https://github.com/python-kasa/python-kasa/pull/974) (@sdb9696) - Handle unknown light effect names and only calculate effect list once [\#973](https://github.com/python-kasa/python-kasa/pull/973) (@sdb9696) - Add time sync command [\#951](https://github.com/python-kasa/python-kasa/pull/951) (@rytilahti) **Fixed bugs:** - Disallow non-targeted device commands [\#982](https://github.com/python-kasa/python-kasa/pull/982) (@rytilahti) - Add supported check to light transition module [\#971](https://github.com/python-kasa/python-kasa/pull/971) (@sdb9696) **Project maintenance:** - Add fixture for L920-5\(EU\) 1.0.7 [\#972](https://github.com/python-kasa/python-kasa/pull/972) (@rytilahti) --- CHANGELOG.md | 21 ++++++++++ poetry.lock | 108 ++++++++++++++++++++++++------------------------- pyproject.toml | 2 +- 3 files changed, 76 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b5f623b5..d5fd4de70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [0.7.0.dev5](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev5) (2024-06-17) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev4...0.7.0.dev5) + +**Implemented enhancements:** + +- Add timezone to on\_since attributes [\#978](https://github.com/python-kasa/python-kasa/pull/978) (@sdb9696) +- Add common energy module and deprecate device emeter attributes [\#976](https://github.com/python-kasa/python-kasa/pull/976) (@sdb9696) +- Add type hints to feature set\_value [\#974](https://github.com/python-kasa/python-kasa/pull/974) (@sdb9696) +- Handle unknown light effect names and only calculate effect list once [\#973](https://github.com/python-kasa/python-kasa/pull/973) (@sdb9696) +- Add time sync command [\#951](https://github.com/python-kasa/python-kasa/pull/951) (@rytilahti) + +**Fixed bugs:** + +- Disallow non-targeted device commands [\#982](https://github.com/python-kasa/python-kasa/pull/982) (@rytilahti) +- Add supported check to light transition module [\#971](https://github.com/python-kasa/python-kasa/pull/971) (@sdb9696) + +**Project maintenance:** + +- Add fixture for L920-5\(EU\) 1.0.7 [\#972](https://github.com/python-kasa/python-kasa/pull/972) (@rytilahti) + ## [0.7.0.dev4](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev4) (2024-06-10) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev3...0.7.0.dev4) diff --git a/poetry.lock b/poetry.lock index c2f9c7240..9d5e069fa 100644 --- a/poetry.lock +++ b/poetry.lock @@ -622,18 +622,18 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.14.0" +version = "3.15.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.14.0-py3-none-any.whl", hash = "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f"}, - {file = "filelock-3.14.0.tar.gz", hash = "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a"}, + {file = "filelock-3.15.1-py3-none-any.whl", hash = "sha256:71b3102950e91dfc1bb4209b64be4dc8854f40e5f534428d8684f953ac847fac"}, + {file = "filelock-3.15.1.tar.gz", hash = "sha256:58a2549afdf9e02e10720eaa4d4470f56386d7a6f72edd7d0596337af8ed7ad8"}, ] [package.extras] docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] typing = ["typing-extensions (>=4.8)"] [[package]] @@ -1135,57 +1135,57 @@ files = [ [[package]] name = "orjson" -version = "3.10.3" +version = "3.10.5" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = true python-versions = ">=3.8" files = [ - {file = "orjson-3.10.3-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9fb6c3f9f5490a3eb4ddd46fc1b6eadb0d6fc16fb3f07320149c3286a1409dd8"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:252124b198662eee80428f1af8c63f7ff077c88723fe206a25df8dc57a57b1fa"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9f3e87733823089a338ef9bbf363ef4de45e5c599a9bf50a7a9b82e86d0228da"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8334c0d87103bb9fbbe59b78129f1f40d1d1e8355bbed2ca71853af15fa4ed3"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1952c03439e4dce23482ac846e7961f9d4ec62086eb98ae76d97bd41d72644d7"}, - {file = "orjson-3.10.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0403ed9c706dcd2809f1600ed18f4aae50be263bd7112e54b50e2c2bc3ebd6d"}, - {file = "orjson-3.10.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:382e52aa4270a037d41f325e7d1dfa395b7de0c367800b6f337d8157367bf3a7"}, - {file = "orjson-3.10.3-cp310-none-win32.whl", hash = "sha256:be2aab54313752c04f2cbaab4515291ef5af8c2256ce22abc007f89f42f49109"}, - {file = "orjson-3.10.3-cp310-none-win_amd64.whl", hash = "sha256:416b195f78ae461601893f482287cee1e3059ec49b4f99479aedf22a20b1098b"}, - {file = "orjson-3.10.3-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:73100d9abbbe730331f2242c1fc0bcb46a3ea3b4ae3348847e5a141265479700"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:544a12eee96e3ab828dbfcb4d5a0023aa971b27143a1d35dc214c176fdfb29b3"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:520de5e2ef0b4ae546bea25129d6c7c74edb43fc6cf5213f511a927f2b28148b"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ccaa0a401fc02e8828a5bedfd80f8cd389d24f65e5ca3954d72c6582495b4bcf"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7bc9e8bc11bac40f905640acd41cbeaa87209e7e1f57ade386da658092dc16"}, - {file = "orjson-3.10.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3582b34b70543a1ed6944aca75e219e1192661a63da4d039d088a09c67543b08"}, - {file = "orjson-3.10.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c23dfa91481de880890d17aa7b91d586a4746a4c2aa9a145bebdbaf233768d5"}, - {file = "orjson-3.10.3-cp311-none-win32.whl", hash = "sha256:1770e2a0eae728b050705206d84eda8b074b65ee835e7f85c919f5705b006c9b"}, - {file = "orjson-3.10.3-cp311-none-win_amd64.whl", hash = "sha256:93433b3c1f852660eb5abdc1f4dd0ced2be031ba30900433223b28ee0140cde5"}, - {file = "orjson-3.10.3-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a39aa73e53bec8d410875683bfa3a8edf61e5a1c7bb4014f65f81d36467ea098"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0943a96b3fa09bee1afdfccc2cb236c9c64715afa375b2af296c73d91c23eab2"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e852baafceff8da3c9defae29414cc8513a1586ad93e45f27b89a639c68e8176"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18566beb5acd76f3769c1d1a7ec06cdb81edc4d55d2765fb677e3eaa10fa99e0"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bd2218d5a3aa43060efe649ec564ebedec8ce6ae0a43654b81376216d5ebd42"}, - {file = "orjson-3.10.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cf20465e74c6e17a104ecf01bf8cd3b7b252565b4ccee4548f18b012ff2f8069"}, - {file = "orjson-3.10.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba7f67aa7f983c4345eeda16054a4677289011a478ca947cd69c0a86ea45e534"}, - {file = "orjson-3.10.3-cp312-none-win32.whl", hash = "sha256:17e0713fc159abc261eea0f4feda611d32eabc35708b74bef6ad44f6c78d5ea0"}, - {file = "orjson-3.10.3-cp312-none-win_amd64.whl", hash = "sha256:4c895383b1ec42b017dd2c75ae8a5b862fc489006afde06f14afbdd0309b2af0"}, - {file = "orjson-3.10.3-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:be2719e5041e9fb76c8c2c06b9600fe8e8584e6980061ff88dcbc2691a16d20d"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0175a5798bdc878956099f5c54b9837cb62cfbf5d0b86ba6d77e43861bcec2"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:978be58a68ade24f1af7758626806e13cff7748a677faf95fbb298359aa1e20d"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16bda83b5c61586f6f788333d3cf3ed19015e3b9019188c56983b5a299210eb5"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ad1f26bea425041e0a1adad34630c4825a9e3adec49079b1fb6ac8d36f8b754"}, - {file = "orjson-3.10.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9e253498bee561fe85d6325ba55ff2ff08fb5e7184cd6a4d7754133bd19c9195"}, - {file = "orjson-3.10.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0a62f9968bab8a676a164263e485f30a0b748255ee2f4ae49a0224be95f4532b"}, - {file = "orjson-3.10.3-cp38-none-win32.whl", hash = "sha256:8d0b84403d287d4bfa9bf7d1dc298d5c1c5d9f444f3737929a66f2fe4fb8f134"}, - {file = "orjson-3.10.3-cp38-none-win_amd64.whl", hash = "sha256:8bc7a4df90da5d535e18157220d7915780d07198b54f4de0110eca6b6c11e290"}, - {file = "orjson-3.10.3-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9059d15c30e675a58fdcd6f95465c1522b8426e092de9fff20edebfdc15e1cb0"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d40c7f7938c9c2b934b297412c067936d0b54e4b8ab916fd1a9eb8f54c02294"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a654ec1de8fdaae1d80d55cee65893cb06494e124681ab335218be6a0691e7"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:831c6ef73f9aa53c5f40ae8f949ff7681b38eaddb6904aab89dca4d85099cb78"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99b880d7e34542db89f48d14ddecbd26f06838b12427d5a25d71baceb5ba119d"}, - {file = "orjson-3.10.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2e5e176c994ce4bd434d7aafb9ecc893c15f347d3d2bbd8e7ce0b63071c52e25"}, - {file = "orjson-3.10.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b69a58a37dab856491bf2d3bbf259775fdce262b727f96aafbda359cb1d114d8"}, - {file = "orjson-3.10.3-cp39-none-win32.whl", hash = "sha256:b8d4d1a6868cde356f1402c8faeb50d62cee765a1f7ffcfd6de732ab0581e063"}, - {file = "orjson-3.10.3-cp39-none-win_amd64.whl", hash = "sha256:5102f50c5fc46d94f2033fe00d392588564378260d64377aec702f21a7a22912"}, - {file = "orjson-3.10.3.tar.gz", hash = "sha256:2b166507acae7ba2f7c315dcf185a9111ad5e992ac81f2d507aac39193c2c818"}, + {file = "orjson-3.10.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:545d493c1f560d5ccfc134803ceb8955a14c3fcb47bbb4b2fee0232646d0b932"}, + {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4324929c2dd917598212bfd554757feca3e5e0fa60da08be11b4aa8b90013c1"}, + {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c13ca5e2ddded0ce6a927ea5a9f27cae77eee4c75547b4297252cb20c4d30e6"}, + {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6c8e30adfa52c025f042a87f450a6b9ea29649d828e0fec4858ed5e6caecf63"}, + {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338fd4f071b242f26e9ca802f443edc588fa4ab60bfa81f38beaedf42eda226c"}, + {file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6970ed7a3126cfed873c5d21ece1cd5d6f83ca6c9afb71bbae21a0b034588d96"}, + {file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:235dadefb793ad12f7fa11e98a480db1f7c6469ff9e3da5e73c7809c700d746b"}, + {file = "orjson-3.10.5-cp310-none-win32.whl", hash = "sha256:be79e2393679eda6a590638abda16d167754393f5d0850dcbca2d0c3735cebe2"}, + {file = "orjson-3.10.5-cp310-none-win_amd64.whl", hash = "sha256:c4a65310ccb5c9910c47b078ba78e2787cb3878cdded1702ac3d0da71ddc5228"}, + {file = "orjson-3.10.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cdf7365063e80899ae3a697def1277c17a7df7ccfc979990a403dfe77bb54d40"}, + {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b68742c469745d0e6ca5724506858f75e2f1e5b59a4315861f9e2b1df77775a"}, + {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d10cc1b594951522e35a3463da19e899abe6ca95f3c84c69e9e901e0bd93d38"}, + {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcbe82b35d1ac43b0d84072408330fd3295c2896973112d495e7234f7e3da2e1"}, + {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c0eb7e0c75e1e486c7563fe231b40fdd658a035ae125c6ba651ca3b07936f5"}, + {file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:53ed1c879b10de56f35daf06dbc4a0d9a5db98f6ee853c2dbd3ee9d13e6f302f"}, + {file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:099e81a5975237fda3100f918839af95f42f981447ba8f47adb7b6a3cdb078fa"}, + {file = "orjson-3.10.5-cp311-none-win32.whl", hash = "sha256:1146bf85ea37ac421594107195db8bc77104f74bc83e8ee21a2e58596bfb2f04"}, + {file = "orjson-3.10.5-cp311-none-win_amd64.whl", hash = "sha256:36a10f43c5f3a55c2f680efe07aa93ef4a342d2960dd2b1b7ea2dd764fe4a37c"}, + {file = "orjson-3.10.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:68f85ecae7af14a585a563ac741b0547a3f291de81cd1e20903e79f25170458f"}, + {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28afa96f496474ce60d3340fe8d9a263aa93ea01201cd2bad844c45cd21f5268"}, + {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cd684927af3e11b6e754df80b9ffafd9fb6adcaa9d3e8fdd5891be5a5cad51e"}, + {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d21b9983da032505f7050795e98b5d9eee0df903258951566ecc358f6696969"}, + {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ad1de7fef79736dde8c3554e75361ec351158a906d747bd901a52a5c9c8d24b"}, + {file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d97531cdfe9bdd76d492e69800afd97e5930cb0da6a825646667b2c6c6c0211"}, + {file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d69858c32f09c3e1ce44b617b3ebba1aba030e777000ebdf72b0d8e365d0b2b3"}, + {file = "orjson-3.10.5-cp312-none-win32.whl", hash = "sha256:64c9cc089f127e5875901ac05e5c25aa13cfa5dbbbd9602bda51e5c611d6e3e2"}, + {file = "orjson-3.10.5-cp312-none-win_amd64.whl", hash = "sha256:b2efbd67feff8c1f7728937c0d7f6ca8c25ec81373dc8db4ef394c1d93d13dc5"}, + {file = "orjson-3.10.5-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:03b565c3b93f5d6e001db48b747d31ea3819b89abf041ee10ac6988886d18e01"}, + {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:584c902ec19ab7928fd5add1783c909094cc53f31ac7acfada817b0847975f26"}, + {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a35455cc0b0b3a1eaf67224035f5388591ec72b9b6136d66b49a553ce9eb1e6"}, + {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1670fe88b116c2745a3a30b0f099b699a02bb3482c2591514baf5433819e4f4d"}, + {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:185c394ef45b18b9a7d8e8f333606e2e8194a50c6e3c664215aae8cf42c5385e"}, + {file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ca0b3a94ac8d3886c9581b9f9de3ce858263865fdaa383fbc31c310b9eac07c9"}, + {file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dfc91d4720d48e2a709e9c368d5125b4b5899dced34b5400c3837dadc7d6271b"}, + {file = "orjson-3.10.5-cp38-none-win32.whl", hash = "sha256:c05f16701ab2a4ca146d0bca950af254cb7c02f3c01fca8efbbad82d23b3d9d4"}, + {file = "orjson-3.10.5-cp38-none-win_amd64.whl", hash = "sha256:8a11d459338f96a9aa7f232ba95679fc0c7cedbd1b990d736467894210205c09"}, + {file = "orjson-3.10.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:85c89131d7b3218db1b24c4abecea92fd6c7f9fab87441cfc342d3acc725d807"}, + {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66215277a230c456f9038d5e2d84778141643207f85336ef8d2a9da26bd7ca"}, + {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51bbcdea96cdefa4a9b4461e690c75ad4e33796530d182bdd5c38980202c134a"}, + {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbead71dbe65f959b7bd8cf91e0e11d5338033eba34c114f69078d59827ee139"}, + {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df58d206e78c40da118a8c14fc189207fffdcb1f21b3b4c9c0c18e839b5a214"}, + {file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c4057c3b511bb8aef605616bd3f1f002a697c7e4da6adf095ca5b84c0fd43595"}, + {file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b39e006b00c57125ab974362e740c14a0c6a66ff695bff44615dcf4a70ce2b86"}, + {file = "orjson-3.10.5-cp39-none-win32.whl", hash = "sha256:eded5138cc565a9d618e111c6d5c2547bbdd951114eb822f7f6309e04db0fb47"}, + {file = "orjson-3.10.5-cp39-none-win_amd64.whl", hash = "sha256:cc28e90a7cae7fcba2493953cff61da5a52950e78dc2dacfe931a317ee3d8de7"}, + {file = "orjson-3.10.5.tar.gz", hash = "sha256:7a5baef8a4284405d96c90c7c62b755e9ef1ada84c2406c24a9ebec86b89f46d"}, ] [[package]] @@ -1311,13 +1311,13 @@ files = [ [[package]] name = "pydantic" -version = "2.7.3" +version = "2.7.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.7.3-py3-none-any.whl", hash = "sha256:ea91b002777bf643bb20dd717c028ec43216b24a6001a280f83877fd2655d0b4"}, - {file = "pydantic-2.7.3.tar.gz", hash = "sha256:c46c76a40bb1296728d7a8b99aa73dd70a48c3510111ff290034f860c99c419e"}, + {file = "pydantic-2.7.4-py3-none-any.whl", hash = "sha256:ee8538d41ccb9c0a9ad3e0e5f07bf15ed8015b481ced539a1759d8cc89ae90d0"}, + {file = "pydantic-2.7.4.tar.gz", hash = "sha256:0c84efd9548d545f63ac0060c1e4d39bb9b14db8b3c0652338aecc07b5adec52"}, ] [package.dependencies] diff --git a/pyproject.toml b/pyproject.toml index d6fdb8cb3..d7ac0f632 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.dev4" +version = "0.7.0.dev5" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 0d84d8785e0e4f545dae5bbe8e6cf05c2f767ff2 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 19 Jun 2024 09:53:40 +0100 Subject: [PATCH 477/892] Update docs with more howto examples (#968) Co-authored-by: Teemu R. --- README.md | 11 +-- docs/source/codeinfo.md | 26 ++++++++ docs/source/guides.md | 54 ++++----------- docs/source/guides/connect.md | 10 +++ docs/source/guides/device.md | 10 +++ docs/source/guides/discover.md | 11 +++ docs/source/guides/energy.md | 27 ++++++++ docs/source/guides/feature.md | 10 +++ docs/source/guides/light.md | 26 ++++++++ docs/source/guides/module.md | 10 +++ docs/source/guides/strip.md | 10 +++ docs/source/reference.md | 23 +++---- docs/source/tutorial.md | 3 + docs/tutorial.py | 11 --- kasa/device.py | 104 ++++++++++++++++++++++++++++- kasa/feature.py | 65 +++++++++++++++++- kasa/interfaces/light.py | 62 ++++++++++++++++- kasa/interfaces/lighteffect.py | 42 +++++++++++- kasa/interfaces/lightpreset.py | 70 ++++++++++++++++++- kasa/module.py | 40 ++++++++++- kasa/smart/modules/childdevice.py | 40 ++++++++++- kasa/tests/test_readme_examples.py | 59 ++++++++++++++++ 22 files changed, 642 insertions(+), 82 deletions(-) create mode 100644 docs/source/codeinfo.md create mode 100644 docs/source/guides/connect.md create mode 100644 docs/source/guides/device.md create mode 100644 docs/source/guides/discover.md create mode 100644 docs/source/guides/energy.md create mode 100644 docs/source/guides/feature.md create mode 100644 docs/source/guides/light.md create mode 100644 docs/source/guides/module.md create mode 100644 docs/source/guides/strip.md diff --git a/README.md b/README.md index 78cddac7f..1ef249530 100644 --- a/README.md +++ b/README.md @@ -173,16 +173,9 @@ Current state: {'total': 133.105, 'power': 108.223577, 'current': 0.54463, 'volt If you want to use this library in your own project, a good starting point is [the tutorial in the documentation](https://python-kasa.readthedocs.io/en/latest/tutorial.html). -You can find several code examples in the API documentation of each of the implementation base classes, check out the [documentation for the base class shared by all supported devices](https://python-kasa.readthedocs.io/en/latest/smartdevice.html). +You can find several code examples in the API documentation [How to guides](https://python-kasa.readthedocs.io/en/latest/guides.html). -[The library design and module structure is described in a separate page](https://python-kasa.readthedocs.io/en/latest/design.html). - -The device type specific documentation can be found in their separate pages: -* [Plugs](https://python-kasa.readthedocs.io/en/latest/smartplug.html) -* [Bulbs](https://python-kasa.readthedocs.io/en/latest/smartbulb.html) -* [Dimmers](https://python-kasa.readthedocs.io/en/latest/smartdimmer.html) -* [Power strips](https://python-kasa.readthedocs.io/en/latest/smartstrip.html) -* [Light strips](https://python-kasa.readthedocs.io/en/latest/smartlightstrip.html) +Information about the library design and the way the devices work can be found in the [topics section](https://python-kasa.readthedocs.io/en/latest/topics.html). ## Contributing diff --git a/docs/source/codeinfo.md b/docs/source/codeinfo.md new file mode 100644 index 000000000..3ee91b369 --- /dev/null +++ b/docs/source/codeinfo.md @@ -0,0 +1,26 @@ + +:::{note} +The library is fully async and methods that perform IO need to be run inside an async coroutine. +Code examples assume you are following them inside `asyncio REPL`: +``` + $ python -m asyncio +``` +Or the code is running inside an async function: +```py +import asyncio +from kasa import Discover + +async def main(): + dev = await Discover.discover_single("127.0.0.1",username="un@example.com",password="pw") + await dev.turn_on() + await dev.update() + +if __name__ == "__main__": + asyncio.run(main()) +``` +**All of your code needs to run inside the same event loop so only call `asyncio.run` once.** + +*The main entry point for the API is {meth}`~kasa.Discover.discover` and +{meth}`~kasa.Discover.discover_single` which return Device objects. +Most newer devices require your TP-Link cloud username and password, but this can be omitted for older devices.* +::: diff --git a/docs/source/guides.md b/docs/source/guides.md index f45412d19..75b1424b4 100644 --- a/docs/source/guides.md +++ b/docs/source/guides.md @@ -1,44 +1,16 @@ # How-to Guides -This page contains guides of how to perform common actions using the library. - -## Discover devices - -```{eval-rst} -.. automodule:: kasa.discover - :noindex: -``` - -## Connect without discovery - -```{eval-rst} -.. automodule:: kasa.deviceconfig - :noindex: -``` - -## Get Energy Consumption and Usage Statistics - -:::{note} -In order to use the helper methods to calculate the statistics correctly, your devices need to have correct time set. -The devices use NTP and public servers from [NTP Pool Project](https://www.ntppool.org/) to synchronize their time. -::: - -### Energy Consumption - -The availability of energy consumption sensors depend on the device. -While most of the bulbs support it, only specific switches (e.g., HS110) or strips (e.g., HS300) support it. -You can use {attr}`~Device.has_emeter` to check for the availability. - - -### Usage statistics - -You can use {attr}`~Device.on_since` to query for the time the device has been turned on. -Some devices also support reporting the usage statistics on daily or monthly basis. -You can access this information using through the usage module ({class}`kasa.modules.Usage`): - -```py -dev = SmartPlug("127.0.0.1") -usage = dev.modules["usage"] -print(f"Minutes on this month: {usage.usage_this_month}") -print(f"Minutes on today: {usage.usage_today}") +Guides of how to perform common actions using the library. + +```{toctree} +:maxdepth: 2 + +guides/discover +guides/connect +guides/device +guides/module +guides/feature +guides/light +guides/strip +guides/energy ``` diff --git a/docs/source/guides/connect.md b/docs/source/guides/connect.md new file mode 100644 index 000000000..9336a1c14 --- /dev/null +++ b/docs/source/guides/connect.md @@ -0,0 +1,10 @@ +(connect_target)= +# Connect without discovery + +:::{include} ../codeinfo.md +::: + +```{eval-rst} +.. automodule:: kasa.deviceconfig + :noindex: +``` diff --git a/docs/source/guides/device.md b/docs/source/guides/device.md new file mode 100644 index 000000000..c2fbfb74b --- /dev/null +++ b/docs/source/guides/device.md @@ -0,0 +1,10 @@ +(device_target)= +# Interact with devices + +:::{include} ../codeinfo.md +::: + +```{eval-rst} +.. automodule:: kasa.device + :noindex: +``` diff --git a/docs/source/guides/discover.md b/docs/source/guides/discover.md new file mode 100644 index 000000000..2d50c4c68 --- /dev/null +++ b/docs/source/guides/discover.md @@ -0,0 +1,11 @@ +(discover_target)= +# Discover devices + +:::{include} ../codeinfo.md +::: + + +```{eval-rst} +.. automodule:: kasa.discover + :noindex: +``` diff --git a/docs/source/guides/energy.md b/docs/source/guides/energy.md new file mode 100644 index 000000000..d7b5727c3 --- /dev/null +++ b/docs/source/guides/energy.md @@ -0,0 +1,27 @@ + +# Get Energy Consumption and Usage Statistics + +:::{note} +In order to use the helper methods to calculate the statistics correctly, your devices need to have correct time set. +The devices use NTP (123/UDP) and public servers from [NTP Pool Project](https://www.ntppool.org/) to synchronize their time. +::: + +## Energy Consumption + +The availability of energy consumption sensors depend on the device. +While most of the bulbs support it, only specific switches (e.g., HS110) or strips (e.g., HS300) support it. +You can use {attr}`~Device.has_emeter` to check for the availability. + + +## Usage statistics + +You can use {attr}`~Device.on_since` to query for the time the device has been turned on. +Some devices also support reporting the usage statistics on daily or monthly basis. +You can access this information using through the usage module ({class}`kasa.modules.Usage`): + +```py +dev = SmartPlug("127.0.0.1") +usage = dev.modules["usage"] +print(f"Minutes on this month: {usage.usage_this_month}") +print(f"Minutes on today: {usage.usage_today}") +``` diff --git a/docs/source/guides/feature.md b/docs/source/guides/feature.md new file mode 100644 index 000000000..307f52a6c --- /dev/null +++ b/docs/source/guides/feature.md @@ -0,0 +1,10 @@ +(feature_target)= +# Interact with features + +:::{include} ../codeinfo.md +::: + +```{eval-rst} +.. automodule:: kasa.feature + :noindex: +``` diff --git a/docs/source/guides/light.md b/docs/source/guides/light.md new file mode 100644 index 000000000..c8b72a997 --- /dev/null +++ b/docs/source/guides/light.md @@ -0,0 +1,26 @@ +(light_target)= +# Interact with lights + +:::{include} ../codeinfo.md +::: + +```{eval-rst} +.. automodule:: kasa.interfaces.light + :noindex: +``` + +(lightpreset_target)= +## Presets + +```{eval-rst} +.. automodule:: kasa.interfaces.lightpreset + :noindex: +``` + +(lighteffect_target)= +## Effects + +```{eval-rst} +.. automodule:: kasa.interfaces.lighteffect + :noindex: +``` diff --git a/docs/source/guides/module.md b/docs/source/guides/module.md new file mode 100644 index 000000000..a001cf505 --- /dev/null +++ b/docs/source/guides/module.md @@ -0,0 +1,10 @@ +(module_target)= +# Interact with modules + +:::{include} ../codeinfo.md +::: + +```{eval-rst} +.. automodule:: kasa.module + :noindex: +``` diff --git a/docs/source/guides/strip.md b/docs/source/guides/strip.md new file mode 100644 index 000000000..d1377eab8 --- /dev/null +++ b/docs/source/guides/strip.md @@ -0,0 +1,10 @@ +(child_target)= +# Interact with child devices + +:::{include} ../codeinfo.md +::: + +```{eval-rst} +.. automodule:: kasa.smart.modules.childdevice + :noindex: +``` diff --git a/docs/source/reference.md b/docs/source/reference.md index ffbfab47d..c1bc4662b 100644 --- a/docs/source/reference.md +++ b/docs/source/reference.md @@ -3,18 +3,16 @@ ## Discover -```{module} kasa.discover +```{module} kasa ``` ```{eval-rst} -.. autoclass:: kasa.Discover +.. autoclass:: Discover :members: ``` ## Device -```{module} kasa.device -``` ```{eval-rst} .. autoclass:: Device @@ -25,17 +23,14 @@ ## Device Config -```{module} kasa.credentials -``` ```{eval-rst} .. autoclass:: Credentials :members: :undoc-members: + :noindex: ``` -```{module} kasa.deviceconfig -``` ```{eval-rst} .. autoclass:: DeviceConfig @@ -45,19 +40,19 @@ ```{eval-rst} -.. autoclass:: kasa.DeviceFamily +.. autoclass:: DeviceFamily :members: :undoc-members: ``` ```{eval-rst} -.. autoclass:: kasa.DeviceConnection +.. autoclass:: DeviceConnectionParameters :members: :undoc-members: ``` ```{eval-rst} -.. autoclass:: kasa.DeviceEncryption +.. autoclass:: DeviceEncryptionType :members: :undoc-members: ``` @@ -65,7 +60,7 @@ ## Modules and Features ```{eval-rst} -.. autoclass:: kasa.Module +.. autoclass:: Module :noindex: :members: :inherited-members: @@ -73,7 +68,7 @@ ``` ```{eval-rst} -.. automodule:: kasa.interfaces +.. autoclass:: Feature :noindex: :members: :inherited-members: @@ -81,7 +76,7 @@ ``` ```{eval-rst} -.. autoclass:: kasa.Feature +.. automodule:: kasa.interfaces :noindex: :members: :inherited-members: diff --git a/docs/source/tutorial.md b/docs/source/tutorial.md index ee7042896..30944dd57 100644 --- a/docs/source/tutorial.md +++ b/docs/source/tutorial.md @@ -1,5 +1,8 @@ # Getting started +:::{include} codeinfo.md +::: + ```{eval-rst} .. automodule:: tutorial :members: diff --git a/docs/tutorial.py b/docs/tutorial.py index f963ac42e..5dc768c77 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -1,16 +1,5 @@ # ruff: noqa """ -The kasa library is fully async and methods that perform IO need to be run inside an async couroutine. - -These examples assume you are following the tutorial inside `asyncio REPL` (python -m asyncio) or the code -is running inside an async function (`async def`). - - -The main entry point for the API is :meth:`~kasa.Discover.discover` and -:meth:`~kasa.Discover.discover_single` which return Device objects. - -Most newer devices require your TP-Link cloud username and password, but this can be omitted for older devices. - >>> from kasa import Discover :func:`~kasa.Discover.discover` returns a dict[str,Device] of devices on your network: diff --git a/kasa/device.py b/kasa/device.py index 53b71d859..dde2e97e2 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -1,4 +1,106 @@ -"""Module for Device base class.""" +"""Interact with TPLink Smart Home devices. + +Once you have a device via :ref:`Discovery ` or +:ref:`Connect ` you can start interacting with a device. + +>>> from kasa import Discover +>>> +>>> dev = await Discover.discover_single( +>>> "127.0.0.2", +>>> username="user@example.com", +>>> password="great_password" +>>> ) +>>> + +Most devices can be turned on and off + +>>> await dev.turn_on() +>>> await dev.update() +>>> print(dev.is_on) +True + +>>> await dev.turn_off() +>>> await dev.update() +>>> print(dev.is_on) +False + +All devices provide several informational properties: + +>>> dev.alias +Bedroom Lamp Plug +>>> dev.model +HS110(EU) +>>> dev.rssi +-71 +>>> dev.mac +50:C7:BF:00:00:00 + +Some information can also be changed programmatically: + +>>> await dev.set_alias("new alias") +>>> await dev.update() +>>> dev.alias +new alias + +Devices support different functionality that are exposed via +:ref:`modules ` that you can access via :attr:`~kasa.Device.modules`: + +>>> for module_name in dev.modules: +>>> print(module_name) +Energy +schedule +usage +anti_theft +time +cloud +Led + +>>> led_module = dev.modules["Led"] +>>> print(led_module.led) +False +>>> await led_module.set_led(True) +>>> await dev.update() +>>> print(led_module.led) +True + +Individual pieces of functionality are also exposed via :ref:`features ` +which you can access via :attr:`~kasa.Device.features` and will only be present if +they are supported. + +Features are similar to modules in that they provide functionality that may or may +not be present. + +Whereas modules group functionality into a common interface, features expose a single +function that may or may not be part of a module. + +The advantage of features is that they have a simple common interface of `id`, `name`, +`value` and `set_value` so no need to learn the module API. + +They are useful if you want write code that dynamically adapts as new features are +added to the API. + +>>> for feature_name in dev.features: +>>> print(feature_name) +state +rssi +on_since +current_consumption +consumption_today +consumption_this_month +consumption_total +voltage +current +cloud_connection +led + +>>> led_feature = dev.features["led"] +>>> print(led_feature.value) +True +>>> await led_feature.set_value(False) +>>> await dev.update() +>>> print(led_feature.value) +False +""" from __future__ import annotations diff --git a/kasa/feature.py b/kasa/feature.py index b992789a5..d0c83a3dc 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -1,4 +1,67 @@ -"""Generic interface for defining device features.""" +"""Interact with feature. + +Features are implemented by devices to represent individual pieces of functionality like +state, time, firmware. + +>>> from kasa import Discover, Module +>>> +>>> dev = await Discover.discover_single( +>>> "127.0.0.3", +>>> username="user@example.com", +>>> password="great_password" +>>> ) +>>> await dev.update() +>>> print(dev.alias) +Living Room Bulb + +Features allow for instrospection and can be interacted with as new features are added +to the API: + +>>> for feature_id, feature in dev.features.items(): +>>> print(f"{feature.name} ({feature_id}): {feature.value}") +Device ID (device_id): 0000000000000000000000000000000000000000 +State (state): True +Signal Level (signal_level): 2 +RSSI (rssi): -52 +SSID (ssid): #MASKED_SSID# +Overheated (overheated): False +Brightness (brightness): 100 +Cloud connection (cloud_connection): True +HSV (hsv): HSV(hue=0, saturation=100, value=100) +Color temperature (color_temperature): 2700 +Auto update enabled (auto_update_enabled): False +Update available (update_available): False +Current firmware version (current_firmware_version): 1.1.6 Build 240130 Rel.173828 +Available firmware version (available_firmware_version): 1.1.6 Build 240130 Rel.173828 +Light effect (light_effect): Off +Light preset (light_preset): Not set +Smooth transition on (smooth_transition_on): 2 +Smooth transition off (smooth_transition_off): 2 +Time (time): 2024-02-23 02:40:15+01:00 + +To see whether a device supports a feature, check for the existence of it: + +>>> if feature := dev.features.get("brightness"): +>>> print(feature.value) +100 + +You can update the value of a feature + +>>> await feature.set_value(50) +>>> await dev.update() +>>> print(feature.value) +50 + +Features have types that can be used for introspection: + +>>> feature = dev.features["light_preset"] +>>> print(feature.type) +Type.Choice + +>>> print(feature.choices) +['Not set', 'Light preset 1', 'Light preset 2', 'Light preset 3',\ + 'Light preset 4', 'Light preset 5', 'Light preset 6', 'Light preset 7'] +""" from __future__ import annotations diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py index 207014cab..5d206d1a9 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -1,4 +1,64 @@ -"""Module for Device base class.""" +"""Interact with a TPLink Light. + +>>> from kasa import Discover, Module +>>> +>>> dev = await Discover.discover_single( +>>> "127.0.0.3", +>>> username="user@example.com", +>>> password="great_password" +>>> ) +>>> await dev.update() +>>> print(dev.alias) +Living Room Bulb + +Lights, like any other supported devices, can be turned on and off: + +>>> print(dev.is_on) +>>> await dev.turn_on() +>>> await dev.update() +>>> print(dev.is_on) +True + +Get the light module to interact: + +>>> light = dev.modules[Module.Light] + +You can use the ``is_``-prefixed properties to check for supported features: + +>>> light.is_dimmable +True +>>> light.is_color +True +>>> light.is_variable_color_temp +True + +All known bulbs support changing the brightness: + +>>> light.brightness +100 +>>> await light.set_brightness(50) +>>> await dev.update() +>>> light.brightness +50 + +Bulbs supporting color temperature can be queried for the supported range: + +>>> light.valid_temperature_range +ColorTempRange(min=2500, max=6500) +>>> await light.set_color_temp(3000) +>>> await dev.update() +>>> light.color_temp +3000 + +Color bulbs can be adjusted by passing hue, saturation and value: + +>>> await light.set_hsv(180, 100, 80) +>>> await dev.update() +>>> light.hsv +HSV(hue=180, saturation=100, value=80) + + +""" from __future__ import annotations diff --git a/kasa/interfaces/lighteffect.py b/kasa/interfaces/lighteffect.py index 0eb11b5b4..e4efa2c2b 100644 --- a/kasa/interfaces/lighteffect.py +++ b/kasa/interfaces/lighteffect.py @@ -1,4 +1,44 @@ -"""Module for base light effect module.""" +"""Interact with a TPLink Light Effect. + +>>> from kasa import Discover, Module, LightState +>>> +>>> dev = await Discover.discover_single( +>>> "127.0.0.3", +>>> username="user@example.com", +>>> password="great_password" +>>> ) +>>> await dev.update() +>>> print(dev.alias) +Living Room Bulb + +Light effects are accessed via the LightPreset module. To list available presets + +>>> if dev.modules[Module.Light].has_effects: +>>> light_effect = dev.modules[Module.LightEffect] +>>> light_effect.effect_list +['Off', 'Party', 'Relax'] + +To view the currently selected effect: + +>>> light_effect.effect +Off + +To activate a light effect: + +>>> await light_effect.set_effect("Party") +>>> await dev.update() +>>> light_effect.effect +Party + +If the device supports it you can set custom effects: + +>>> if light_effect.has_custom_effects: +>>> effect_list = { "brightness", 50 } +>>> await light_effect.set_custom_effect(effect_list) +>>> light_effect.has_custom_effects # The device in this examples does not support \ +custom effects +False +""" from __future__ import annotations diff --git a/kasa/interfaces/lightpreset.py b/kasa/interfaces/lightpreset.py index 84a374dbc..95f02946c 100644 --- a/kasa/interfaces/lightpreset.py +++ b/kasa/interfaces/lightpreset.py @@ -1,4 +1,72 @@ -"""Module for LightPreset base class.""" +"""Interact with TPLink Light Presets. + +>>> from kasa import Discover, Module, LightState +>>> +>>> dev = await Discover.discover_single( +>>> "127.0.0.3", +>>> username="user@example.com", +>>> password="great_password" +>>> ) +>>> await dev.update() +>>> print(dev.alias) +Living Room Bulb + +Light presets are accessed via the LightPreset module. To list available presets + +>>> light_preset = dev.modules[Module.LightPreset] +>>> light_preset.preset_list +['Not set', 'Light preset 1', 'Light preset 2', 'Light preset 3',\ + 'Light preset 4', 'Light preset 5', 'Light preset 6', 'Light preset 7'] + +To view the currently selected preset: + +>>> light_preset.preset +Not set + +To view the actual light state for the presets: + +>>> len(light_preset.preset_states_list) +7 + +>>> light_preset.preset_states_list[0] +LightState(light_on=None, brightness=50, hue=0,\ + saturation=100, color_temp=2700, transition=None) + +To set a preset as active: + +>>> dev.modules[Module.Light].state # This is only needed to show the example working +LightState(light_on=True, brightness=100, hue=0,\ + saturation=100, color_temp=2700, transition=None) +>>> await light_preset.set_preset("Light preset 1") +>>> await dev.update() +>>> light_preset.preset +Light preset 1 +>>> dev.modules[Module.Light].state # This is only needed to show the example working +LightState(light_on=True, brightness=50, hue=0,\ + saturation=100, color_temp=2700, transition=None) + +You can save a new preset state if the device supports it: + +>>> if light_preset.has_save_preset: +>>> new_preset_state = LightState(light_on=True, brightness=75, hue=0,\ + saturation=100, color_temp=2700, transition=None) +>>> await light_preset.save_preset("Light preset 1", new_preset_state) +>>> await dev.update() +>>> light_preset.preset # Saving updates the preset state for the preset, it does not \ +set the preset +Not set +>>> light_preset.preset_states_list[0] +LightState(light_on=None, brightness=75, hue=0,\ + saturation=100, color_temp=2700, transition=None) + +If you manually set the light state to a preset state it will show that preset as \ + active: + +>>> await dev.modules[Module.Light].set_brightness(75) +>>> await dev.update() +>>> light_preset.preset +Light preset 1 +""" from __future__ import annotations diff --git a/kasa/module.py b/kasa/module.py index 177c2baa1..3a090782c 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -1,4 +1,42 @@ -"""Base class for all module implementations.""" +"""Interact with modules. + +Modules are implemented by devices to encapsulate sets of functionality like +Light, AutoOff, Firmware etc. + +>>> from kasa import Discover, Module +>>> +>>> dev = await Discover.discover_single( +>>> "127.0.0.3", +>>> username="user@example.com", +>>> password="great_password" +>>> ) +>>> await dev.update() +>>> print(dev.alias) +Living Room Bulb + +To see whether a device supports functionality check for the existence of the module: + +>>> if light := dev.modules.get("Light"): +>>> print(light.hsv) +HSV(hue=0, saturation=100, value=100) + +If you know or expect the module to exist you can access by index: + +>>> light_preset = dev.modules["LightPreset"] +>>> print(light_preset.preset_list) +['Not set', 'Light preset 1', 'Light preset 2', 'Light preset 3',\ + 'Light preset 4', 'Light preset 5', 'Light preset 6', 'Light preset 7'] + +Modules support typing via the Module names in Module: + +>>> from typing_extensions import reveal_type, TYPE_CHECKING +>>> light_effect = dev.modules.get("LightEffect") +>>> light_effect_typed = dev.modules.get(Module.LightEffect) +>>> if TYPE_CHECKING: +>>> reveal_type(light_effect) # Static checker will reveal: str +>>> reveal_type(light_effect_typed) # Static checker will reveal: LightEffect + +""" from __future__ import annotations diff --git a/kasa/smart/modules/childdevice.py b/kasa/smart/modules/childdevice.py index 5713eff49..4c3b99ded 100644 --- a/kasa/smart/modules/childdevice.py +++ b/kasa/smart/modules/childdevice.py @@ -1,4 +1,42 @@ -"""Implementation for child devices.""" +"""Interact with child devices. + +>>> from kasa import Discover +>>> +>>> dev = await Discover.discover_single( +>>> "127.0.0.1", +>>> username="user@example.com", +>>> password="great_password" +>>> ) +>>> await dev.update() +>>> print(dev.alias) +Bedroom Power Strip + +All methods act on the whole strip: + +>>> for plug in dev.children: +>>> print(f"{plug.alias}: {plug.is_on}") +Plug 1: True +Plug 2: False +Plug 3: False +>>> dev.is_on +True +>>> await dev.turn_off() +>>> await dev.update() + +Accessing individual plugs can be done using the `children` property: + +>>> len(dev.children) +3 +>>> for plug in dev.children: +>>> print(f"{plug.alias}: {plug.is_on}") +Plug 1: False +Plug 2: False +Plug 3: False +>>> await dev.children[1].turn_on() +>>> await dev.update() +>>> dev.is_on +True +""" from ..smartmodule import SmartModule diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index 7a5f8e19b..f024c6729 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -69,6 +69,7 @@ def test_discovery_examples(readmes_mock): """Test discovery examples.""" res = xdoctest.doctest_module("kasa.discover", "all") assert res["n_passed"] > 0 + assert res["n_warned"] == 0 assert not res["failed"] @@ -76,6 +77,63 @@ def test_deviceconfig_examples(readmes_mock): """Test discovery examples.""" res = xdoctest.doctest_module("kasa.deviceconfig", "all") assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_device_examples(readmes_mock): + """Test device examples.""" + res = xdoctest.doctest_module("kasa.device", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_light_examples(readmes_mock): + """Test device examples.""" + res = xdoctest.doctest_module("kasa.interfaces.light", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_light_preset_examples(readmes_mock): + """Test device examples.""" + res = xdoctest.doctest_module("kasa.interfaces.lightpreset", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_light_effect_examples(readmes_mock): + """Test device examples.""" + res = xdoctest.doctest_module("kasa.interfaces.lighteffect", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_child_examples(readmes_mock): + """Test device examples.""" + res = xdoctest.doctest_module("kasa.smart.modules.childdevice", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_module_examples(readmes_mock): + """Test device examples.""" + res = xdoctest.doctest_module("kasa.module", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_feature_examples(readmes_mock): + """Test device examples.""" + res = xdoctest.doctest_module("kasa.feature", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 assert not res["failed"] @@ -83,6 +141,7 @@ def test_tutorial_examples(readmes_mock): """Test discovery examples.""" res = xdoctest.doctest_module("docs/tutorial.py", "all") assert res["n_passed"] > 0 + assert res["n_warned"] == 0 assert not res["failed"] From f3fe1bc3f40c90d9db91ba6324d2964aed51491b Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 19 Jun 2024 11:01:35 +0100 Subject: [PATCH 478/892] Fix to call update when only --device-family passed to cli (#987) --- kasa/cli.py | 19 ++++++++++++++----- kasa/tests/test_cli.py | 14 +++++++++++++- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index f7ff1dd34..76dc0ac47 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -379,6 +379,7 @@ def _nop_echo(*args, **kwargs): echo("No host name given, trying discovery..") return await ctx.invoke(discover) + device_updated = False if type is not None: dev = TYPE_TO_CLASS[type](host) elif device_family and encrypt_type: @@ -396,11 +397,19 @@ def _nop_echo(*args, **kwargs): connection_type=ctype, ) dev = await Device.connect(config=config) + device_updated = True else: - echo( - "No --type or --device-family and --encrypt-type defined, " - + f"discovering for {discovery_timeout} seconds.." - ) + if device_family or encrypt_type: + echo( + "--device-family and --encrypt-type options must both be " + "provided or they are ignored\n" + f"discovering for {discovery_timeout} seconds.." + ) + else: + echo( + "No --type or --device-family and --encrypt-type defined, " + + f"discovering for {discovery_timeout} seconds.." + ) dev = await Discover.discover_single( host, port=port, @@ -411,7 +420,7 @@ def _nop_echo(*args, **kwargs): # 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: + if ctx.invoked_subcommand not in SKIP_UPDATE_COMMANDS and not device_updated: await dev.update() @asynccontextmanager diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index e30685fe4..1973c8248 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -57,7 +57,15 @@ def runner(): return runner -async def test_update_called_by_cli(dev, mocker, runner): +@pytest.mark.parametrize( + ("device_family", "encrypt_type"), + [ + pytest.param(None, None, id="No connect params"), + pytest.param("SMART.TAPOPLUG", None, id="Only device_family"), + pytest.param(None, "KLAP", id="Only encrypt_type"), + ], +) +async def test_update_called_by_cli(dev, mocker, runner, device_family, encrypt_type): """Test that device update is called on main.""" update = mocker.patch.object(dev, "update") @@ -76,6 +84,10 @@ async def test_update_called_by_cli(dev, mocker, runner): "foo", "--password", "bar", + "--device-family", + device_family, + "--encrypt-type", + encrypt_type, ], catch_exceptions=False, ) From 5b7e59056c44eb27c5f3097f02c33f4714027ac6 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 19 Jun 2024 12:36:00 +0100 Subject: [PATCH 479/892] Remove anyio dependency from pyproject.toml (#990) This is no longer required as it's correctly configured in [async click release 8.1.7.1](https://pypi.org/project/asyncclick/8.1.7.1/) released in January. Fixed in https://github.com/python-trio/asyncclick/pull/27 --- poetry.lock | 2 +- pyproject.toml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9d5e069fa..ded2154d7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2185,4 +2185,4 @@ speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "871ef421fe7d48608bcea18b4c41d8bb368e84d74bf7b29db832dc97c5b980ae" +content-hash = "f8edc1401028d0654bd4622bf471668dd84b323434f0fa40e783e5c9b45511f6" diff --git a/pyproject.toml b/pyproject.toml index d7ac0f632..946067e73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,7 @@ kasa = "kasa.cli:cli" [tool.poetry.dependencies] python = "^3.8" -anyio = "*" # see https://github.com/python-trio/asyncclick/issues/18 -asyncclick = ">=8" +asyncclick = ">=8.1.7" pydantic = ">=1.10.15" cryptography = ">=1.9" async-timeout = ">=3.0.0" From 416d3118bfc82b1d56f53a289afbb74eb3e9f6fa Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 19 Jun 2024 14:07:59 +0100 Subject: [PATCH 480/892] Configure mypy to run in virtual environment and fix resulting issues (#989) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For some time I've noticed that my IDE is reporting mypy errors that the pre-commit hook is not picking up. This is because [mypy mirror](https://github.com/pre-commit/mirrors-mypy) runs in an isolated pre-commit environment which does not have dependencies installed and it enables `--ignore-missing-imports` to avoid errors. This is [advised against by mypy](https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-library-stubs-or-py-typed-marker) for obvious reasons: > We recommend avoiding --ignore-missing-imports if possible: it’s equivalent to adding a # type: ignore to all unresolved imports in your codebase. This PR configures the mypy pre-commit hook to run in the virtual environment and addresses the additional errors identified as a result. It also introduces a minimal mypy config into the `pyproject.toml` [mypy errors identified without the fixes in this PR](https://github.com/user-attachments/files/15896693/mypyerrors.txt) --- .github/workflows/ci.yml | 1 + .pre-commit-config.yaml | 23 ++++----- devtools/bench/benchmark.py | 5 +- devtools/run-in-env.sh | 3 ++ kasa/aestransport.py | 5 +- kasa/cli.py | 14 +++--- kasa/httpclient.py | 2 +- kasa/tests/discovery_fixtures.py | 4 +- kasa/tests/fakeprotocol_smart.py | 4 +- kasa/tests/smart/modules/test_firmware.py | 13 ++++- kasa/tests/test_device.py | 3 +- kasa/tests/test_emeter.py | 16 +++--- kasa/tests/test_httpclient.py | 4 +- kasa/tests/test_iotdevice.py | 2 +- kasa/xortransport.py | 4 +- poetry.lock | 60 ++++++++++++++++++++++- pyproject.toml | 17 +++++++ 17 files changed, 138 insertions(+), 42 deletions(-) create mode 100755 devtools/run-in-env.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca8cfb754..c139cc695 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: python-version: ${{ matrix.python-version }} cache-pre-commit: true poetry-version: ${{ env.POETRY_VERSION }} + poetry-install-options: "--all-extras" - name: "Check supported device md files are up to date" run: | poetry run pre-commit run generate-supported --all-files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c274bb979..2587eff5c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,17 +16,6 @@ repos: args: [--fix, --exit-non-zero-on-fix] - id: ruff-format -- repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.9.0 - hooks: - - id: mypy - additional_dependencies: [types-click] - exclude: | - (?x)^( - kasa/modulemapping\.py| - )$ - - - repo: https://github.com/PyCQA/doc8 rev: 'v1.1.1' hooks: @@ -35,6 +24,18 @@ repos: - repo: local hooks: + # Run mypy in the virtual environment so it uses the installed dependencies + # for more accurate checking than using the pre-commit mypy mirror + - id: mypy + name: mypy + entry: devtools/run-in-env.sh mypy + language: script + types_or: [python, pyi] + require_serial: true + exclude: | # exclude required because --all-files passes py and pyi + (?x)^( + kasa/modulemapping\.py| + )$ - id: generate-supported name: Generate supported devices description: This hook generates the supported device sections of README.md and SUPPORTED.md diff --git a/devtools/bench/benchmark.py b/devtools/bench/benchmark.py index 2cdbd43e0..91a3a93dc 100644 --- a/devtools/bench/benchmark.py +++ b/devtools/bench/benchmark.py @@ -5,8 +5,9 @@ import orjson from kasa_crypt import decrypt, encrypt -from utils.data import REQUEST, WIRE_RESPONSE -from utils.original import OriginalTPLinkSmartHomeProtocol + +from devtools.bench.utils.data import REQUEST, WIRE_RESPONSE +from devtools.bench.utils.original import OriginalTPLinkSmartHomeProtocol def original_request_response() -> None: diff --git a/devtools/run-in-env.sh b/devtools/run-in-env.sh new file mode 100755 index 000000000..3e67c70eb --- /dev/null +++ b/devtools/run-in-env.sh @@ -0,0 +1,3 @@ +#!/bin/bash +source $(poetry env info --path)/bin/activate +exec "$@" diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 68250b1ad..4ee30c4f0 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -371,7 +371,10 @@ def create_from_keypair(handshake_key: str, keypair): handshake_key_bytes: bytes = base64.b64decode(handshake_key.encode("UTF-8")) private_key_data = base64.b64decode(keypair.get_private_key().encode("UTF-8")) - private_key = serialization.load_der_private_key(private_key_data, None, None) + private_key = cast( + rsa.RSAPrivateKey, + serialization.load_der_private_key(private_key_data, None, None), + ) key_and_iv = private_key.decrypt( handshake_key_bytes, asymmetric_padding.PKCS1v15() ) diff --git a/kasa/cli.py b/kasa/cli.py index 76dc0ac47..616aa4aad 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -101,9 +101,7 @@ def error(msg: str): # Block list of commands which require no update SKIP_UPDATE_COMMANDS = ["raw-command", "command"] -click.anyio_backend = "asyncio" - -pass_dev = click.make_pass_decorator(Device) +pass_dev = click.make_pass_decorator(Device) # type: ignore[type-abstract] def CatchAllExceptions(cls): @@ -1005,7 +1003,7 @@ async def time_get(dev: Device): @time.command(name="sync") @pass_dev -async def time_sync(dev: SmartDevice): +async def time_sync(dev: Device): """Set the device time to current time.""" if not isinstance(dev, SmartDevice): raise NotImplementedError("setting time currently only implemented on smart") @@ -1143,7 +1141,7 @@ async def presets(ctx): @presets.command(name="list") @pass_dev -def presets_list(dev: IotBulb): +def presets_list(dev: Device): """List presets.""" if not dev.is_bulb or not isinstance(dev, IotBulb): error("Presets only supported on iot bulbs") @@ -1162,7 +1160,7 @@ def presets_list(dev: IotBulb): @click.option("--saturation", type=int) @click.option("--temperature", type=int) @pass_dev -async def presets_modify(dev: IotBulb, index, brightness, hue, saturation, temperature): +async def presets_modify(dev: Device, index, brightness, hue, saturation, temperature): """Modify a preset.""" for preset in dev.presets: if preset.index == index: @@ -1190,7 +1188,7 @@ async def presets_modify(dev: IotBulb, index, brightness, hue, saturation, tempe @click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False)) @click.option("--last", is_flag=True) @click.option("--preset", type=int) -async def turn_on_behavior(dev: IotBulb, type, last, preset): +async def turn_on_behavior(dev: Device, type, last, preset): """Modify bulb turn-on behavior.""" if not dev.is_bulb or not isinstance(dev, IotBulb): error("Presets only supported on iot bulbs") @@ -1248,7 +1246,7 @@ async def shell(dev: Device): logging.getLogger("asyncio").setLevel(logging.WARNING) loop = asyncio.get_event_loop() try: - await embed( + await embed( # type: ignore[func-returns-value] globals=globals(), locals=locals(), return_asyncio_coroutine=True, diff --git a/kasa/httpclient.py b/kasa/httpclient.py index d1f4936e5..02e697821 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -36,7 +36,7 @@ class HttpClient: def __init__(self, config: DeviceConfig) -> None: self._config = config - self._client_session: aiohttp.ClientSession = None + self._client_session: aiohttp.ClientSession | None = None self._jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False) self._last_url = URL(f"http://{self._config.host}/") diff --git a/kasa/tests/discovery_fixtures.py b/kasa/tests/discovery_fixtures.py index db9db2e8b..229c6c44a 100644 --- a/kasa/tests/discovery_fixtures.py +++ b/kasa/tests/discovery_fixtures.py @@ -231,7 +231,9 @@ def discovery_data(request, mocker): return {"system": {"get_sysinfo": fixture_data["system"]["get_sysinfo"]}} -@pytest.fixture(params=UNSUPPORTED_DEVICES.values(), ids=UNSUPPORTED_DEVICES.keys()) +@pytest.fixture( + params=UNSUPPORTED_DEVICES.values(), ids=list(UNSUPPORTED_DEVICES.keys()) +) def unsupported_device_info(request, mocker): """Return unsupported devices for cli and discovery tests.""" discovery_data = request.param diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 533cd6486..d601128e0 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -276,7 +276,7 @@ def _send_request(self, request_dict: dict): ): result["sum"] = len(result[list_key]) if self.warn_fixture_missing_methods: - pytest.fixtures_missing_methods.setdefault( + pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] self.fixture_name, set() ).add(f"{method} (incomplete '{list_key}' list)") @@ -305,7 +305,7 @@ def _send_request(self, request_dict: dict): } # Reduce warning spam by consolidating and reporting at the end of the run if self.warn_fixture_missing_methods: - pytest.fixtures_missing_methods.setdefault( + pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] self.fixture_name, set() ).add(method) return retval diff --git a/kasa/tests/smart/modules/test_firmware.py b/kasa/tests/smart/modules/test_firmware.py index b592041f4..8d7b45748 100644 --- a/kasa/tests/smart/modules/test_firmware.py +++ b/kasa/tests/smart/modules/test_firmware.py @@ -2,6 +2,7 @@ import asyncio import logging +from typing import TypedDict import pytest from pytest_mock import MockerFixture @@ -71,7 +72,17 @@ async def test_firmware_update( assert fw upgrade_time = 5 - extras = {"reboot_time": 5, "upgrade_time": upgrade_time, "auto_upgrade": False} + + class Extras(TypedDict): + reboot_time: int + upgrade_time: int + auto_upgrade: bool + + extras: Extras = { + "reboot_time": 5, + "upgrade_time": upgrade_time, + "auto_upgrade": False, + } update_states = [ # Unknown 1 DownloadState(status=1, download_progress=0, **extras), diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index 07e764cbf..bda4514c9 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -6,6 +6,7 @@ import inspect import pkgutil import sys +from contextlib import AbstractContextManager from unittest.mock import Mock, patch import pytest @@ -161,7 +162,7 @@ async def _test_attribute( dev: Device, attribute_name, is_expected, module_name, *args, will_raise=False ): if is_expected and will_raise: - ctx = pytest.raises(will_raise) + ctx: AbstractContextManager = pytest.raises(will_raise) elif is_expected: ctx = pytest.deprecated_call(match=(f"{attribute_name} is deprecated, use:")) else: diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index b710ec73f..220fdbaee 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -5,7 +5,7 @@ from voluptuous import ( All, Any, - Coerce, # type: ignore + Coerce, Range, Schema, ) @@ -21,14 +21,14 @@ Any( { "voltage": Any(All(float, Range(min=0, max=300)), None), - "power": Any(Coerce(float, Range(min=0)), None), - "total": Any(Coerce(float, Range(min=0)), None), - "current": Any(All(float, Range(min=0)), None), + "power": Any(Coerce(float), None), + "total": Any(Coerce(float), None), + "current": Any(All(float), None), "voltage_mv": Any(All(float, Range(min=0, max=300000)), int, None), - "power_mw": Any(Coerce(float, Range(min=0)), None), - "total_wh": Any(Coerce(float, Range(min=0)), None), - "current_ma": Any(All(float, Range(min=0)), int, None), - "slot_id": Any(Coerce(int, Range(min=0)), None), + "power_mw": Any(Coerce(float), None), + "total_wh": Any(Coerce(float), None), + "current_ma": Any(All(float), int, None), + "slot_id": Any(Coerce(int), None), }, None, ) diff --git a/kasa/tests/test_httpclient.py b/kasa/tests/test_httpclient.py index 78aac552f..a4f22c3fe 100644 --- a/kasa/tests/test_httpclient.py +++ b/kasa/tests/test_httpclient.py @@ -38,7 +38,7 @@ ), (Exception(), KasaException, "Unable to query the device: "), ( - aiohttp.ServerFingerprintMismatch("exp", "got", "host", 1), + aiohttp.ServerFingerprintMismatch(b"exp", b"got", "host", 1), KasaException, "Unable to query the device: ", ), @@ -84,7 +84,7 @@ async def _post(url, *_, **__): client = HttpClient(DeviceConfig(host)) # Exceptions with parameters print with double quotes, without use single quotes full_msg = ( - "\(" # type: ignore + re.escape("(") + "['\"]" + re.escape(f"{error_message}{host}: {error}") + "['\"]" diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py index f43258e45..fcf8e94b2 100644 --- a/kasa/tests/test_iotdevice.py +++ b/kasa/tests/test_iotdevice.py @@ -207,7 +207,7 @@ async def test_mac(dev): @device_iot async def test_representation(dev): - pattern = re.compile("") + pattern = re.compile(r"") assert pattern.match(str(dev)) diff --git a/kasa/xortransport.py b/kasa/xortransport.py index 0bca0321c..5319346bf 100644 --- a/kasa/xortransport.py +++ b/kasa/xortransport.py @@ -229,7 +229,7 @@ def decrypt(ciphertext: bytes) -> str: try: from kasa_crypt import decrypt, encrypt - XorEncryption.decrypt = decrypt # type: ignore[method-assign] - XorEncryption.encrypt = encrypt # type: ignore[method-assign] + XorEncryption.decrypt = decrypt # type: ignore[assignment] + XorEncryption.encrypt = encrypt # type: ignore[assignment] except ImportError: pass diff --git a/poetry.lock b/poetry.lock index ded2154d7..dee87eeb4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1096,6 +1096,64 @@ files = [ {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, ] +[[package]] +name = "mypy" +version = "1.9.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"}, + {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"}, + {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"}, + {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"}, + {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"}, + {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"}, + {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"}, + {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"}, + {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"}, + {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"}, + {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"}, + {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"}, + {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"}, + {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"}, + {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"}, + {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"}, + {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"}, + {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"}, + {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + [[package]] name = "myst-parser" version = "1.0.0" @@ -2185,4 +2243,4 @@ speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "f8edc1401028d0654bd4622bf471668dd84b323434f0fa40e783e5c9b45511f6" +content-hash = "3aa0872e4188aad6e75025d47b026fa2a922bf039df38bfaac15f409e38d6889" diff --git a/pyproject.toml b/pyproject.toml index 946067e73..13a5c5730 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ xdoctest = "*" coverage = {version = "*", extras = ["toml"]} pytest-timeout = "^2" pytest-freezer = "^0.4" +mypy = "1.9.0" [tool.poetry.extras] docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] @@ -138,3 +139,19 @@ convention = "pep257" "D100", "D103", ] + +[tool.mypy] +warn_unused_configs = true # warns if overrides sections unused/mis-spelled + +[[tool.mypy.overrides]] +module = [ "kasa.tests.*", "devtools.*" ] +disable_error_code = "annotation-unchecked" + +[[tool.mypy.overrides]] +module = [ + "devtools.bench.benchmark", + "devtools.parse_pcap", + "devtools.perftest", + "devtools.create_module_fixtures" +] +disable_error_code = "import-not-found,import-untyped" From 472008e818a16d5b45d507e90d42fe56a3d4a97e Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 19 Jun 2024 20:24:12 +0200 Subject: [PATCH 481/892] Drop python3.8 support (#992) Drop support for soon-to-be eol'd python 3.8. This will allow some minor cleanups & makes it easier to add support for timezones. Related to https://github.com/python-kasa/python-kasa/issues/980#issuecomment-2170889543 --- .github/workflows/ci.yml | 7 +- kasa/aestransport.py | 3 +- kasa/device.py | 3 +- kasa/discover.py | 3 +- kasa/interfaces/lightpreset.py | 2 +- kasa/iot/iotdevice.py | 3 +- kasa/iot/modules/lightpreset.py | 3 +- kasa/smart/modules/firmware.py | 3 +- kasa/smart/modules/lightpreset.py | 3 +- kasa/smart/smartdevice.py | 7 +- kasa/tests/device_fixtures.py | 2 +- kasa/tests/test_iotdevice.py | 7 +- kasa/tests/test_smartdevice.py | 7 +- kasa/xortransport.py | 2 +- poetry.lock | 165 +++++++++++++----------------- pyproject.toml | 4 +- 16 files changed, 102 insertions(+), 122 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c139cc695..80511bd33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.9", "pypy-3.10"] + python-version: ["3.9", "3.10", "3.11", "3.12", "pypy-3.9", "pypy-3.10"] os: [ubuntu-latest, macos-latest, windows-latest] extras: [false, true] exclude: @@ -70,8 +70,6 @@ jobs: extras: true # setup-python not currently working with macos-latest # https://github.com/actions/setup-python/issues/808 - - os: macos-latest - python-version: "3.8" - os: macos-latest python-version: "3.9" - os: windows-latest @@ -82,9 +80,6 @@ jobs: - os: ubuntu-latest python-version: "pypy-3.10" extras: true - - os: ubuntu-latest - python-version: "3.8" - extras: true - os: ubuntu-latest python-version: "3.9" extras: true diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 4ee30c4f0..f406996f2 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -10,8 +10,9 @@ import hashlib import logging import time +from collections.abc import AsyncGenerator from enum import Enum, auto -from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, cast +from typing import TYPE_CHECKING, Any, Dict, cast from cryptography.hazmat.primitives import padding, serialization from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding diff --git a/kasa/device.py b/kasa/device.py index dde2e97e2..9bf0903ee 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -106,9 +106,10 @@ import logging from abc import ABC, abstractmethod +from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime -from typing import TYPE_CHECKING, Any, Mapping, Sequence +from typing import TYPE_CHECKING, Any from warnings import warn from typing_extensions import TypeAlias diff --git a/kasa/discover.py b/kasa/discover.py index 4930a68a8..b9e34ee2a 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -86,7 +86,8 @@ import ipaddress import logging import socket -from typing import Awaitable, Callable, Dict, Optional, Type, cast +from collections.abc import Awaitable +from typing import Callable, Dict, Optional, Type, cast # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout diff --git a/kasa/interfaces/lightpreset.py b/kasa/interfaces/lightpreset.py index 95f02946c..fc2924196 100644 --- a/kasa/interfaces/lightpreset.py +++ b/kasa/interfaces/lightpreset.py @@ -71,7 +71,7 @@ from __future__ import annotations from abc import abstractmethod -from typing import Sequence +from collections.abc import Sequence from ..feature import Feature from ..module import Module diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 102d6a4dc..4b8325a21 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -18,8 +18,9 @@ import functools import inspect import logging +from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast +from typing import TYPE_CHECKING, Any, cast from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig diff --git a/kasa/iot/modules/lightpreset.py b/kasa/iot/modules/lightpreset.py index d9fbb7faf..d5a603c0b 100644 --- a/kasa/iot/modules/lightpreset.py +++ b/kasa/iot/modules/lightpreset.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import asdict -from typing import TYPE_CHECKING, Optional, Sequence +from typing import TYPE_CHECKING, Optional from pydantic.v1 import BaseModel, Field diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 430515e4b..8cbc7e55a 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -4,8 +4,9 @@ import asyncio import logging +from collections.abc import Coroutine from datetime import date -from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional +from typing import TYPE_CHECKING, Any, Callable, Optional # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 0fb57952f..8e5cae209 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import asdict -from typing import TYPE_CHECKING, Sequence +from typing import TYPE_CHECKING from ...interfaces import LightPreset as LightPresetInterface from ...interfaces import LightState diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 5a2f99e59..bf3eb25e8 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -4,8 +4,9 @@ import base64 import logging +from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast +from typing import Any, cast from ..aestransport import AesTransport from ..device import Device, WifiNetwork @@ -97,9 +98,7 @@ def children(self) -> Sequence[SmartDevice]: @property def modules(self) -> ModuleMapping[SmartModule]: """Return the device modules.""" - if TYPE_CHECKING: # Needed for python 3.8 - return cast(ModuleMapping[SmartModule], self._modules) - return self._modules + return cast(ModuleMapping[SmartModule], self._modules) def _try_get_response(self, responses: dict, request: str, default=None) -> dict: response = responses.get(request) diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 844314bef..718789f6a 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import AsyncGenerator +from collections.abc import AsyncGenerator import pytest diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py index fcf8e94b2..df37f762f 100644 --- a/kasa/tests/test_iotdevice.py +++ b/kasa/tests/test_iotdevice.py @@ -91,9 +91,10 @@ async def test_state_info(dev): @pytest.mark.requires_dummy @device_iot async def test_invalid_connection(mocker, dev): - with mocker.patch.object( - FakeIotProtocol, "query", side_effect=KasaException - ), pytest.raises(KasaException): + with ( + mocker.patch.object(FakeIotProtocol, "query", side_effect=KasaException), + pytest.raises(KasaException), + ): await dev.update() diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 4a260003b..48475a900 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -38,9 +38,10 @@ async def test_update_no_device_info(dev: SmartDevice, mocker: MockerFixture): "get_device_time": {}, } msg = f"get_device_info not found in {mock_response} for device 127.0.0.123" - with mocker.patch.object( - dev.protocol, "query", return_value=mock_response - ), pytest.raises(KasaException, match=msg): + with ( + mocker.patch.object(dev.protocol, "query", return_value=mock_response), + pytest.raises(KasaException, match=msg), + ): await dev.update() diff --git a/kasa/xortransport.py b/kasa/xortransport.py index 5319346bf..e96864533 100644 --- a/kasa/xortransport.py +++ b/kasa/xortransport.py @@ -18,8 +18,8 @@ import logging import socket import struct +from collections.abc import Generator from pprint import pformat as pf -from typing import Generator # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout diff --git a/poetry.lock b/poetry.lock index dee87eeb4..706685c3e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "aiohttp" @@ -112,13 +112,13 @@ frozenlist = ">=1.1.0" [[package]] name = "alabaster" -version = "0.7.13" -description = "A configurable sidebar-enabled Sphinx theme" +version = "0.7.16" +description = "A light, configurable Sphinx theme" optional = true -python-versions = ">=3.6" +python-versions = ">=3.9" files = [ - {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, - {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, + {file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"}, + {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, ] [[package]] @@ -132,9 +132,6 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] -[package.dependencies] -typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} - [[package]] name = "anyio" version = "4.4.0" @@ -224,9 +221,6 @@ files = [ {file = "babel-2.15.0.tar.gz", hash = "sha256:8daf0e265d05768bc6c7a314cf1321e9a123afc328cc635c18622a2f30a04413"}, ] -[package.dependencies] -pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} - [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] @@ -1098,38 +1092,38 @@ files = [ [[package]] name = "mypy" -version = "1.9.0" +version = "1.10.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"}, - {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"}, - {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"}, - {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"}, - {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"}, - {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"}, - {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"}, - {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"}, - {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"}, - {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"}, - {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"}, - {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"}, - {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"}, - {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"}, - {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"}, - {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"}, - {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"}, - {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"}, - {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"}, - {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"}, - {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"}, - {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"}, - {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"}, - {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"}, - {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"}, - {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"}, - {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"}, + {file = "mypy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da1cbf08fb3b851ab3b9523a884c232774008267b1f83371ace57f412fe308c2"}, + {file = "mypy-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12b6bfc1b1a66095ab413160a6e520e1dc076a28f3e22f7fb25ba3b000b4ef99"}, + {file = "mypy-1.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e36fb078cce9904c7989b9693e41cb9711e0600139ce3970c6ef814b6ebc2b2"}, + {file = "mypy-1.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2b0695d605ddcd3eb2f736cd8b4e388288c21e7de85001e9f85df9187f2b50f9"}, + {file = "mypy-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:cd777b780312ddb135bceb9bc8722a73ec95e042f911cc279e2ec3c667076051"}, + {file = "mypy-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3be66771aa5c97602f382230165b856c231d1277c511c9a8dd058be4784472e1"}, + {file = "mypy-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8b2cbaca148d0754a54d44121b5825ae71868c7592a53b7292eeb0f3fdae95ee"}, + {file = "mypy-1.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ec404a7cbe9fc0e92cb0e67f55ce0c025014e26d33e54d9e506a0f2d07fe5de"}, + {file = "mypy-1.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e22e1527dc3d4aa94311d246b59e47f6455b8729f4968765ac1eacf9a4760bc7"}, + {file = "mypy-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:a87dbfa85971e8d59c9cc1fcf534efe664d8949e4c0b6b44e8ca548e746a8d53"}, + {file = "mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b"}, + {file = "mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30"}, + {file = "mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e"}, + {file = "mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5"}, + {file = "mypy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda"}, + {file = "mypy-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9fd50226364cd2737351c79807775136b0abe084433b55b2e29181a4c3c878c0"}, + {file = "mypy-1.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f90cff89eea89273727d8783fef5d4a934be2fdca11b47def50cf5d311aff727"}, + {file = "mypy-1.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcfc70599efde5c67862a07a1aaf50e55bce629ace26bb19dc17cece5dd31ca4"}, + {file = "mypy-1.10.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:075cbf81f3e134eadaf247de187bd604748171d6b79736fa9b6c9685b4083061"}, + {file = "mypy-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:3f298531bca95ff615b6e9f2fc0333aae27fa48052903a0ac90215021cdcfa4f"}, + {file = "mypy-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa7ef5244615a2523b56c034becde4e9e3f9b034854c93639adb667ec9ec2976"}, + {file = "mypy-1.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3236a4c8f535a0631f85f5fcdffba71c7feeef76a6002fcba7c1a8e57c8be1ec"}, + {file = "mypy-1.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a2b5cdbb5dd35aa08ea9114436e0d79aceb2f38e32c21684dcf8e24e1e92821"}, + {file = "mypy-1.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92f93b21c0fe73dc00abf91022234c79d793318b8a96faac147cd579c1671746"}, + {file = "mypy-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:28d0e038361b45f099cc086d9dd99c15ff14d0188f44ac883010e172ce86c38a"}, + {file = "mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee"}, + {file = "mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131"}, ] [package.dependencies] @@ -1305,13 +1299,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "3.5.0" +version = "3.7.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, - {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, + {file = "pre_commit-3.7.1-py2.py3-none-any.whl", hash = "sha256:fae36fd1d7ad7d6a5a1c0b0d5adb2ed1a3bda5a21bf6c3e5372073d7a11cd4c5"}, + {file = "pre_commit-3.7.1.tar.gz", hash = "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a"}, ] [package.dependencies] @@ -1647,17 +1641,6 @@ files = [ [package.dependencies] six = ">=1.5" -[[package]] -name = "pytz" -version = "2024.1" -description = "World timezone definitions, modern and historical" -optional = true -python-versions = "*" -files = [ - {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, - {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, -] - [[package]] name = "pyyaml" version = "6.0.1" @@ -1670,7 +1653,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1678,15 +1660,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1703,7 +1678,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1711,7 +1685,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -1752,7 +1725,6 @@ files = [ [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] @@ -1846,47 +1818,50 @@ dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] [[package]] name = "sphinxcontrib-applehelp" -version = "1.0.4" +version = "1.0.8" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" optional = true -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"}, - {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"}, + {file = "sphinxcontrib_applehelp-1.0.8-py3-none-any.whl", hash = "sha256:cb61eb0ec1b61f349e5cc36b2028e9e7ca765be05e49641c97241274753067b4"}, + {file = "sphinxcontrib_applehelp-1.0.8.tar.gz", hash = "sha256:c40a4f96f3776c4393d933412053962fac2b84f4c99a7982ba42e09576a70619"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-devhelp" -version = "1.0.2" -description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +version = "1.0.6" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" optional = true -python-versions = ">=3.5" +python-versions = ">=3.9" files = [ - {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, - {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, + {file = "sphinxcontrib_devhelp-1.0.6-py3-none-any.whl", hash = "sha256:6485d09629944511c893fa11355bda18b742b83a2b181f9a009f7e500595c90f"}, + {file = "sphinxcontrib_devhelp-1.0.6.tar.gz", hash = "sha256:9893fd3f90506bc4b97bdb977ceb8fbd823989f4316b28c3841ec128544372d3"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-htmlhelp" -version = "2.0.1" +version = "2.0.5" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = true -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, - {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, + {file = "sphinxcontrib_htmlhelp-2.0.5-py3-none-any.whl", hash = "sha256:393f04f112b4d2f53d93448d4bce35842f62b307ccdc549ec1585e950bc35e04"}, + {file = "sphinxcontrib_htmlhelp-2.0.5.tar.gz", hash = "sha256:0dc87637d5de53dd5eec3a6a01753b1ccf99494bd756aafecd74b4fa9e729015"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] test = ["html5lib", "pytest"] [[package]] @@ -1933,32 +1908,34 @@ Sphinx = ">=1.7.0" [[package]] name = "sphinxcontrib-qthelp" -version = "1.0.3" -description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." +version = "1.0.7" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" optional = true -python-versions = ">=3.5" +python-versions = ">=3.9" files = [ - {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, - {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, + {file = "sphinxcontrib_qthelp-1.0.7-py3-none-any.whl", hash = "sha256:e2ae3b5c492d58fcbd73281fbd27e34b8393ec34a073c792642cd8e529288182"}, + {file = "sphinxcontrib_qthelp-1.0.7.tar.gz", hash = "sha256:053dedc38823a80a7209a80860b16b722e9e0209e32fea98c90e4e6624588ed6"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-serializinghtml" -version = "1.1.5" -description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." +version = "1.1.10" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" optional = true -python-versions = ">=3.5" +python-versions = ">=3.9" files = [ - {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, - {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, + {file = "sphinxcontrib_serializinghtml-1.1.10-py3-none-any.whl", hash = "sha256:326369b8df80a7d2d8d7f99aa5ac577f51ea51556ed974e7716cfd4fca3f6cb7"}, + {file = "sphinxcontrib_serializinghtml-1.1.10.tar.gz", hash = "sha256:93f3f5dc458b91b192fe10c397e324f262cf163d79f3282c158e8436a2c4511f"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] @@ -2037,13 +2014,13 @@ files = [ [[package]] name = "urllib3" -version = "2.2.1" +version = "2.2.2" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, - {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, + {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, + {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, ] [package.extras] @@ -2242,5 +2219,5 @@ speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" -python-versions = "^3.8" -content-hash = "3aa0872e4188aad6e75025d47b026fa2a922bf039df38bfaac15f409e38d6889" +python-versions = "^3.9" +content-hash = "dcd115ccc1e4fddc72845600e2a230d9eff978a2092a7eda1822c9a8f1773d2c" diff --git a/pyproject.toml b/pyproject.toml index 13a5c5730..18a5c07b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ include = [ kasa = "kasa.cli:cli" [tool.poetry.dependencies] -python = "^3.8" +python = "^3.9" asyncclick = ">=8.1.7" pydantic = ">=1.10.15" cryptography = ">=1.9" @@ -58,7 +58,7 @@ xdoctest = "*" coverage = {version = "*", extras = ["toml"]} pytest-timeout = "^2" pytest-freezer = "^0.4" -mypy = "1.9.0" +mypy = "^1" [tool.poetry.extras] docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] From ac1e81dc17dd8d91e8cf77948b7a0b8b89f7b0a7 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 21 Jun 2024 14:51:56 +0200 Subject: [PATCH 482/892] Add unit_getter for feature (#993) Allow defining getter for unit, necessary to set the correct unit based on device responses. --- kasa/feature.py | 9 ++++++++- kasa/smart/modules/temperaturesensor.py | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index d0c83a3dc..38c6fca99 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -99,7 +99,7 @@ class Type(Enum): Choice = auto() Unknown = -1 - # TODO: unsure if this is a great idea.. + # Aliases for easy access Sensor = Type.Sensor BinarySensor = Type.BinarySensor Switch = Type.Switch @@ -139,6 +139,9 @@ class Category(Enum): icon: str | None = None #: Unit, if applicable unit: str | None = None + #: Attribute containing the name of the unit getter property. + #: If set, this property will be used to set *unit*. + unit_getter: str | None = None #: Category hint for downstreams category: Feature.Category = Category.Unset #: Type of the feature @@ -177,6 +180,10 @@ def __post_init__(self): if self.choices_getter is not None: self.choices = getattr(container, self.choices_getter) + # Populate unit, if unit_getter is given + if self.unit_getter is not None: + self.unit = getattr(container, self.unit_getter) + # Set the category, if unset if self.category is Feature.Category.Unset: if self.attribute_setter: diff --git a/kasa/smart/modules/temperaturesensor.py b/kasa/smart/modules/temperaturesensor.py index 4880fc301..d58ffd235 100644 --- a/kasa/smart/modules/temperaturesensor.py +++ b/kasa/smart/modules/temperaturesensor.py @@ -28,6 +28,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="temperature", icon="mdi:thermometer", category=Feature.Category.Primary, + unit_getter="temperature_unit", ) ) if "current_temp_exception" in device.sys_info: @@ -55,7 +56,6 @@ def __init__(self, device: SmartDevice, module: str): choices=["celsius", "fahrenheit"], ) ) - # TODO: use temperature_unit for feature creation @property def temperature(self): @@ -68,7 +68,7 @@ def temperature_warning(self) -> bool: return self._device.sys_info.get("current_temp_exception", 0) != 0 @property - def temperature_unit(self): + def temperature_unit(self) -> Literal["celsius", "fahrenheit"]: """Return current temperature unit.""" return self._device.sys_info["temp_unit"] From e083449049e39a87f58db838e550406f20e5598d Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 21 Jun 2024 17:42:43 +0100 Subject: [PATCH 483/892] Update mode, time, rssi and report_interval feature names/units (#995) --- docs/tutorial.py | 2 +- kasa/feature.py | 2 +- kasa/iot/iotdevice.py | 1 + kasa/iot/iotstrip.py | 1 + kasa/smart/modules/reportmode.py | 1 + kasa/smart/modules/temperaturecontrol.py | 4 ++-- kasa/smart/modules/time.py | 6 +++--- kasa/smart/smartdevice.py | 3 ++- 8 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/tutorial.py b/docs/tutorial.py index 5dc768c77..7bb3381a3 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -91,5 +91,5 @@ True >>> for feat in dev.features.values(): >>> print(f"{feat.name}: {feat.value}") -Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: False\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: 1.1.6 Build 240130 Rel.173828\nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nTime: 2024-02-23 02:40:15+01:00 +Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: False\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: 1.1.6 Build 240130 Rel.173828\nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nDevice time: 2024-02-23 02:40:15+01:00 """ diff --git a/kasa/feature.py b/kasa/feature.py index 38c6fca99..53532932b 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -37,7 +37,7 @@ Light preset (light_preset): Not set Smooth transition on (smooth_transition_on): 2 Smooth transition off (smooth_transition_off): 2 -Time (time): 2024-02-23 02:40:15+01:00 +Device time (device_time): 2024-02-23 02:40:15+01:00 To see whether a device supports a feature, check for the existence of it: diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 4b8325a21..e181d7ca9 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -352,6 +352,7 @@ async def _initialize_features(self): name="On since", attribute_getter="on_since", icon="mdi:clock", + category=Feature.Category.Info, ) ) diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index c2f2bb860..eea9f32c3 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -340,6 +340,7 @@ async def _initialize_features(self): name="On since", attribute_getter="on_since", icon="mdi:clock", + category=Feature.Category.Info, ) ) for module in self._supported_modules.values(): diff --git a/kasa/smart/modules/reportmode.py b/kasa/smart/modules/reportmode.py index f0af4c1c6..704476625 100644 --- a/kasa/smart/modules/reportmode.py +++ b/kasa/smart/modules/reportmode.py @@ -26,6 +26,7 @@ def __init__(self, device: SmartDevice, module: str): name="Report interval", container=self, attribute_getter="report_interval", + unit="s", category=Feature.Category.Debug, ) ) diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index ae487bdf2..e582d77a0 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -79,8 +79,8 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device, - id="mode", - name="Mode", + id="thermostat_mode", + name="Thermostat mode", container=self, attribute_getter="mode", category=Feature.Category.Primary, diff --git a/kasa/smart/modules/time.py b/kasa/smart/modules/time.py index 3c2b96af3..c2007ceba 100644 --- a/kasa/smart/modules/time.py +++ b/kasa/smart/modules/time.py @@ -25,11 +25,11 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( device=device, - id="time", - name="Time", + id="device_time", + name="Device time", attribute_getter="time", container=self, - category=Feature.Category.Debug, + category=Feature.Category.Info, ) ) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index bf3eb25e8..ebe73b1c6 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -277,6 +277,7 @@ async def _initialize_features(self): name="RSSI", attribute_getter=lambda x: x._info["rssi"], icon="mdi:signal", + unit="dBm", category=Feature.Category.Debug, ) ) @@ -316,7 +317,7 @@ async def _initialize_features(self): name="On since", attribute_getter="on_since", icon="mdi:clock", - category=Feature.Category.Info, + category=Feature.Category.Debug, ) ) From cee8b0fadcee45a5ebe842377f3ace15e191c31e Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 21 Jun 2024 20:25:55 +0200 Subject: [PATCH 484/892] Improve autooff name and unit (#997) --- kasa/smart/modules/autooff.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kasa/smart/modules/autooff.py b/kasa/smart/modules/autooff.py index afb822c56..47f69d069 100644 --- a/kasa/smart/modules/autooff.py +++ b/kasa/smart/modules/autooff.py @@ -40,11 +40,12 @@ def _initialize_features(self): Feature( self._device, id="auto_off_minutes", - name="Auto off minutes", + name="Auto off in", container=self, attribute_getter="delay", attribute_setter="set_delay", type=Feature.Type.Number, + unit="min", # ha-friendly unit, see UnitOfTime.MINUTES ) ) self._add_feature( From c50ae33346a333331eb74d47af41a6e74f74fed3 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 21 Jun 2024 20:37:46 +0200 Subject: [PATCH 485/892] Update README to be more approachable for new users (#994) UX first approach for both cli & library users, to get you directly started with the basics. Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --- README.md | 204 +++++++++++++++++++++++++++--------------------------- 1 file changed, 103 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 1ef249530..36efa8fc1 100644 --- a/README.md +++ b/README.md @@ -20,21 +20,6 @@ You can install the most recent release using pip: pip install python-kasa ``` -For enhanced cli tool support (coloring, embedded shell) install with `[shell]`: -``` -pip install python-kasa[shell] -``` - -If you are using cpython, it is recommended to install with `[speedups]` to enable orjson (faster json support): -``` -pip install python-kasa[speedups] -``` -or for both: -``` -pip install python-kasa[speedups, shell] -``` -With `[speedups]`, the protocol overhead is roughly an order of magnitude lower (benchmarks available in devtools). - Alternatively, you can clone this repository and use poetry to install the development version: ``` git clone https://github.com/python-kasa/python-kasa.git @@ -47,7 +32,11 @@ If you have not yet provisioned your device, [you can do so using the cli tool]( ## Discovering devices Running `kasa discover` will send discovery packets to the default broadcast address (`255.255.255.255`) to discover supported devices. -If your system has multiple network interfaces, you can specify the broadcast address using the `--target` option. +If your device requires authentication to control it, +you need to pass the credentials using `--username` and `--password` options or define `KASA_USERNAME` and `KASA_PASSWORD` environment variables. + +> [!NOTE] +> If your system has multiple network interfaces, you can specify the broadcast address using the `--target` option. The `discover` command will automatically execute the `state` command on all the discovered devices: @@ -55,122 +44,135 @@ The `discover` command will automatically execute the `state` command on all the $ kasa discover Discovering devices on 255.255.255.255 for 3 seconds -== Bulb McBulby - KL130(EU) == - Host: 192.168.xx.xx - Port: 9999 - Device state: True +== Bulb McBulby - L530 == + Host: 192.0.2.123 + Port: 80 + Device state: False == Generic information == - Time: 2023-12-05 14:33:23 (tz: {'index': 6, 'err_code': 0} - Hardware: 1.0 - Software: 1.8.8 Build 190613 Rel.123436 - MAC (rssi): 1c:3b:f3:xx:xx:xx (-56) - Location: {'latitude': None, 'longitude': None} - - == Device specific information == - Brightness: 16 - Is dimmable: True - Color temperature: 2500 - Valid temperature range: ColorTempRange(min=2500, max=9000) - HSV: HSV(hue=0, saturation=0, value=16) - Presets: - index=0 brightness=50 hue=0 saturation=0 color_temp=2500 custom=None id=None mode=None - index=1 brightness=100 hue=299 saturation=95 color_temp=0 custom=None id=None mode=None - index=2 brightness=100 hue=120 saturation=75 color_temp=0 custom=None id=None mode=None - index=3 brightness=100 hue=240 saturation=75 color_temp=0 custom=None id=None mode=None - - == Current State == - + Time: 2024-06-21 15:09:35+02:00 (tz: {'timezone': 'CEST'} + Hardware: 3.0 + Software: 1.1.6 Build 240130 Rel.173828 + MAC (rssi): 5C:E9:31:aa:bb:cc (-50) + Location: {'latitude': -1, 'longitude': -1} + + == Primary features == + State (state): False + Brightness (brightness): 11 (range: 0-100) + Color temperature (color_temperature): 0 (range: 2500-6500) + Light effect (light_effect): *Off* Party Relax + + == Information == + Signal Level (signal_level): 2 + Overheated (overheated): False + Cloud connection (cloud_connection): False + Update available (update_available): None + + == Configuration == + HSV (hsv): HSV(hue=35, saturation=70, value=11) + Auto update enabled (auto_update_enabled): False + Light preset (light_preset): *Not set* Light preset 1 Light preset 2 Light preset 3 Light preset 4 Light preset 5 Light preset 6 Light preset 7 + Smooth transition on (smooth_transition_on): 2 (range: 0-60) + Smooth transition off (smooth_transition_off): 20 (range: 0-60) + + == Debug == + Device ID (device_id): someuniqueidentifier + RSSI (rssi): -50 + SSID (ssid): SecretNetwork + Current firmware version (current_firmware_version): 1.1.6 Build 240130 Rel.173828 + Available firmware version (available_firmware_version): None + Time (time): 2024-06-21 15:09:35+02:00 == Modules == - + - + - + - + - + - - - + + + + + + + + + + + + + + + + + + + + + + + ``` -If your device requires authentication to control it, -you need to pass the credentials using `--username` and `--password` options. -## Basic functionalities +## Command line usage -All devices support a variety of common commands, including: +All devices support a variety of common commands (like `on`, `off`, and `state`). +The syntax to control device is `kasa --host `: -* `state` which returns state information -* `on` and `off` for turning the device on or off -* `emeter` (where applicable) to return energy consumption information -* `sysinfo` to return raw system information +``` +$ kasa --host 192.0.2.123 on +``` -The syntax to control device is `kasa --host `. Use `kasa --help` ([or consult the documentation](https://python-kasa.readthedocs.io/en/latest/cli.html#kasa-help)) to get a list of all available commands and options. Some examples of available options include JSON output (`--json`), defining timeouts (`--timeout` and `--discovery-timeout`). +Refer [the documentation](https://python-kasa.readthedocs.io/en/latest/cli.html) for more details. -Each individual command may also have additional options, which are shown when called with the `--help` option. -For example, `--transition` on bulbs requests a smooth state change, while `--name` and `--index` are used on power strips to select the socket to act on: +> [!NOTE] +> Each individual command may also have additional options, which are shown when called with the `--help` option. -``` -$ kasa on --help -Usage: kasa on [OPTIONS] +### Feature interface - Turn the device on. +All devices are also controllable through a generic feature-based interface. +The available features differ from device to device -Options: - --index INTEGER - --name TEXT - --transition INTEGER - --help Show this message and exit. ``` +$ kasa --host 192.0.2.123 feature +No --type or --device-family and --encrypt-type defined, discovering for 5 seconds.. +== Features == -### Bulbs - -Common commands for bulbs and light strips include: - -* `brightness` to control the brightness -* `hsv` to control the colors -* `temperature` to control the color temperatures - -When executed without parameters, these commands will report the current state. +Device ID (device_id): someuniqueidentifier +State (state): False +Signal Level (signal_level): 3 +RSSI (rssi): -49 +SSID (ssid): SecretNetwork +Overheated (overheated): False +Brightness (brightness): 11 (range: 0-100) +Cloud connection (cloud_connection): False +HSV (hsv): HSV(hue=35, saturation=70, value=11) +Color temperature (color_temperature): 0 (range: 2500-6500) +Auto update enabled (auto_update_enabled): False +Update available (update_available): None +Current firmware version (current_firmware_version): 1.1.6 Build 240130 Rel.173828 +Available firmware version (available_firmware_version): None +Light effect (light_effect): *Off* Party Relax +Light preset (light_preset): *Not set* Light preset 1 Light preset 2 Light preset 3 Light preset 4 Light preset 5 Light preset 6 Light preset 7 +Smooth transition on (smooth_transition_on): 2 (range: 0-60) +Smooth transition off (smooth_transition_off): 20 (range: 0-60) +Device time (device_time): 2024-06-21 15:36:32+02:00 +``` -Some devices support `--transition` option to perform a smooth state change. -For example, the following turns the light to 30% brightness over a period of five seconds: +Some features are changeable: ``` -$ kasa --host brightness --transition 5000 30 +kasa --host 192.0.2.123 feature color_temperature 2500 +No --type or --device-family and --encrypt-type defined, discovering for 5 seconds.. +Changing color_temperature from 0 to 2500 +New state: 2500 ``` -See `--help` for additional options and [the documentation](https://python-kasa.readthedocs.io/en/latest/smartbulb.html) for more details about supported features and limitations. +> [!NOTE] +> When controlling hub-connected devices, you need to pass the device ID of the connected device as an option: `kasa --host 192.0.2.200 feature --child someuniqueidentifier target_temperature 21` -### Power strips -Each individual socket can be controlled separately by passing `--index` or `--name` to the command. -If neither option is defined, the commands act on the whole power strip. +## Library usage -For example: ``` -$ kasa --host off # turns off all sockets -$ kasa --host off --name 'Socket1' # turns off socket named 'Socket1' -``` - -See `--help` for additional options and [the documentation](https://python-kasa.readthedocs.io/en/latest/smartstrip.html) for more details about supported features and limitations. - +import asyncio +from kasa import Discover -## Energy meter +async def main(): + dev = await Discover.discover_single("192.0.2.123", username="un@example.com", password="pw") + await dev.turn_on() + await dev.update() -Running `kasa emeter` command will return the current consumption. -Possible options include `--year` and `--month` for retrieving historical state, -and reseting the counters can be done with `--erase`. - -``` -$ kasa emeter -== Emeter == -Current state: {'total': 133.105, 'power': 108.223577, 'current': 0.54463, 'voltage': 225.296283} +if __name__ == "__main__": + asyncio.run(main()) ``` -# Library usage - If you want to use this library in your own project, a good starting point is [the tutorial in the documentation](https://python-kasa.readthedocs.io/en/latest/tutorial.html). You can find several code examples in the API documentation [How to guides](https://python-kasa.readthedocs.io/en/latest/guides.html). From fd81d073a51c88c20909ef0aed4a43e4cfbd4526 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 22 Jun 2024 16:29:06 +0200 Subject: [PATCH 486/892] Cleanup README to use the new cli format (#999) * Update cli outputs * Remove tapo support statement * Fix some minor nits --- README.md | 132 +++++++++++++++++++++++------------------------------- 1 file changed, 56 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 36efa8fc1..2dfde360f 100644 --- a/README.md +++ b/README.md @@ -45,55 +45,39 @@ $ kasa discover Discovering devices on 255.255.255.255 for 3 seconds == Bulb McBulby - L530 == - Host: 192.0.2.123 - Port: 80 - Device state: False - == Generic information == - Time: 2024-06-21 15:09:35+02:00 (tz: {'timezone': 'CEST'} - Hardware: 3.0 - Software: 1.1.6 Build 240130 Rel.173828 - MAC (rssi): 5C:E9:31:aa:bb:cc (-50) - Location: {'latitude': -1, 'longitude': -1} - - == Primary features == - State (state): False - Brightness (brightness): 11 (range: 0-100) - Color temperature (color_temperature): 0 (range: 2500-6500) - Light effect (light_effect): *Off* Party Relax - - == Information == - Signal Level (signal_level): 2 - Overheated (overheated): False - Cloud connection (cloud_connection): False - Update available (update_available): None - - == Configuration == - HSV (hsv): HSV(hue=35, saturation=70, value=11) - Auto update enabled (auto_update_enabled): False - Light preset (light_preset): *Not set* Light preset 1 Light preset 2 Light preset 3 Light preset 4 Light preset 5 Light preset 6 Light preset 7 - Smooth transition on (smooth_transition_on): 2 (range: 0-60) - Smooth transition off (smooth_transition_off): 20 (range: 0-60) - - == Debug == - Device ID (device_id): someuniqueidentifier - RSSI (rssi): -50 - SSID (ssid): SecretNetwork - Current firmware version (current_firmware_version): 1.1.6 Build 240130 Rel.173828 - Available firmware version (available_firmware_version): None - Time (time): 2024-06-21 15:09:35+02:00 - - == Modules == - + - + - + - + - + - + - + - + - + - + - + +Host: 192.0.2.123 +Port: 80 +Device state: False +Time: 2024-06-22 15:42:15+02:00 (tz: {'timezone': 'CEST'} +Hardware: 3.0 +Software: 1.1.6 Build 240130 Rel.173828 +MAC (rssi): 5C:E9:31:aa:bb:cc (-50) +== Primary features == +State (state): False +Brightness (brightness): 11 (range: 0-100) +Color temperature (color_temperature): 0 (range: 2500-6500) +Light effect (light_effect): *Off* Party Relax + +== Information == +Signal Level (signal_level): 2 +Overheated (overheated): False +Cloud connection (cloud_connection): False +Update available (update_available): None +Device time (device_time): 2024-06-22 15:42:15+02:00 + +== Configuration == +HSV (hsv): HSV(hue=35, saturation=70, value=11) +Auto update enabled (auto_update_enabled): False +Light preset (light_preset): *Not set* Light preset 1 Light preset 2 Light preset 3 Light preset 4 Light preset 5 Light preset 6 Light preset 7 +Smooth transition on (smooth_transition_on): 2 (range: 0-60) +Smooth transition off (smooth_transition_off): 20 (range: 0-60) + +== Debug == +Device ID (device_id): soneuniqueidentifier +RSSI (rssi): -50 dBm +SSID (ssid): HomeNet +Current firmware version (current_firmware_version): 1.1.6 Build 240130 Rel.173828 +Available firmware version (available_firmware_version): None ``` @@ -107,7 +91,7 @@ $ kasa --host 192.0.2.123 on ``` Use `kasa --help` ([or consult the documentation](https://python-kasa.readthedocs.io/en/latest/cli.html#kasa-help)) to get a list of all available commands and options. -Some examples of available options include JSON output (`--json`), defining timeouts (`--timeout` and `--discovery-timeout`). +Some examples of available options include JSON output (`--json`), more verbose output (`--verbose`), and defining timeouts (`--timeout` and `--discovery-timeout`). Refer [the documentation](https://python-kasa.readthedocs.io/en/latest/cli.html) for more details. > [!NOTE] @@ -117,39 +101,41 @@ Refer [the documentation](https://python-kasa.readthedocs.io/en/latest/cli.html) ### Feature interface All devices are also controllable through a generic feature-based interface. -The available features differ from device to device +The available features differ from device to device and are accessible using `kasa feature` command: ``` $ kasa --host 192.0.2.123 feature -No --type or --device-family and --encrypt-type defined, discovering for 5 seconds.. - -== Features == - -Device ID (device_id): someuniqueidentifier +== Primary features == State (state): False -Signal Level (signal_level): 3 -RSSI (rssi): -49 -SSID (ssid): SecretNetwork -Overheated (overheated): False Brightness (brightness): 11 (range: 0-100) +Color temperature (color_temperature): 0 (range: 2500-6500) +Light effect (light_effect): *Off* Party Relax + +== Information == +Signal Level (signal_level): 2 +Overheated (overheated): False Cloud connection (cloud_connection): False +Update available (update_available): None +Device time (device_time): 2024-06-22 15:39:44+02:00 + +== Configuration == HSV (hsv): HSV(hue=35, saturation=70, value=11) -Color temperature (color_temperature): 0 (range: 2500-6500) Auto update enabled (auto_update_enabled): False -Update available (update_available): None -Current firmware version (current_firmware_version): 1.1.6 Build 240130 Rel.173828 -Available firmware version (available_firmware_version): None -Light effect (light_effect): *Off* Party Relax Light preset (light_preset): *Not set* Light preset 1 Light preset 2 Light preset 3 Light preset 4 Light preset 5 Light preset 6 Light preset 7 Smooth transition on (smooth_transition_on): 2 (range: 0-60) Smooth transition off (smooth_transition_off): 20 (range: 0-60) -Device time (device_time): 2024-06-21 15:36:32+02:00 + +== Debug == +Device ID (device_id): soneuniqueidentifier +RSSI (rssi): -50 dBm +SSID (ssid): HomeNet +Current firmware version (current_firmware_version): 1.1.6 Build 240130 Rel.173828 +Available firmware version (available_firmware_version): None ``` -Some features are changeable: +Some features present configuration that can be changed: ``` kasa --host 192.0.2.123 feature color_temperature 2500 -No --type or --device-family and --encrypt-type defined, discovering for 5 seconds.. Changing color_temperature from 0 to 2500 New state: 2500 ``` @@ -233,17 +219,11 @@ See [supported devices in our documentation](SUPPORTED.md) for more detailed inf * [Home Assistant](https://www.home-assistant.io/integrations/tplink/) * [MQTT access to TP-Link devices, using python-kasa](https://github.com/flavio-fernandes/mqtt2kasa) -### TP-Link Tapo support - -This library has recently added a limited supported for devices that carry Tapo branding. -That support is currently limited to the cli. The package `kasa.smart` is in flux and if you -use it directly you should expect it could break in future releases until this statement is removed. - -Other TAPO libraries are: +### Other related projects * [PyTapo - Python library for communication with Tapo Cameras](https://github.com/JurajNyiri/pytapo) * [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) -* [rust and python implementation](https://github.com/mihai-dinculescu/tapo/) +* [rust and python implementation for tapo devices](https://github.com/mihai-dinculescu/tapo/) From 9f148547479762806bfab51740c6eb0454660ccd Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 23 Jun 2024 08:09:13 +0200 Subject: [PATCH 487/892] Cleanup cli output (#1000) Avoid unnecessary indentation of elements, now only the child device information is indented Use _echo_all_features consistently for both state and feature Avoid discovery log message which brings no extra value Hide location by default --- kasa/cli.py | 82 +++++++++++++++++++++++------------------- kasa/tests/test_cli.py | 5 ++- 2 files changed, 49 insertions(+), 38 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 616aa4aad..4d0a1db5e 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -403,11 +403,6 @@ def _nop_echo(*args, **kwargs): "provided or they are ignored\n" f"discovering for {discovery_timeout} seconds.." ) - else: - echo( - "No --type or --device-family and --encrypt-type defined, " - + f"discovering for {discovery_timeout} seconds.." - ) dev = await Discover.discover_single( host, port=port, @@ -613,9 +608,7 @@ def _echo_features( id_: feat for id_, feat in features.items() if feat.category == category } - if not features: - return - echo(f"[bold]{title}[/bold]") + echo(f"{indent}[bold]{title}[/bold]") for _, feat in features.items(): try: echo(f"{indent}{feat}") @@ -627,33 +620,40 @@ def _echo_features( echo(f"{indent}{feat.name} ({feat.id}): [red]got exception ({ex})[/red]") -def _echo_all_features(features, *, verbose=False, title_prefix=None): +def _echo_all_features(features, *, verbose=False, title_prefix=None, indent=""): """Print out all features by category.""" if title_prefix is not None: - echo(f"[bold]\n\t == {title_prefix} ==[/bold]") + echo(f"[bold]\n{indent}== {title_prefix} ==[/bold]") _echo_features( features, - title="\n\t== Primary features ==", + title="== Primary features ==", category=Feature.Category.Primary, verbose=verbose, + indent=indent, ) + echo() _echo_features( features, - title="\n\t== Information ==", + title="== Information ==", category=Feature.Category.Info, verbose=verbose, + indent=indent, ) + echo() _echo_features( features, - title="\n\t== Configuration ==", + title="== Configuration ==", category=Feature.Category.Config, verbose=verbose, + indent=indent, ) + echo() _echo_features( features, - title="\n\t== Debug ==", + title="== Debug ==", category=Feature.Category.Debug, verbose=verbose, + indent=indent, ) @@ -665,38 +665,42 @@ async def state(ctx, dev: Device): verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False echo(f"[bold]== {dev.alias} - {dev.model} ==[/bold]") - echo(f"\tHost: {dev.host}") - echo(f"\tPort: {dev.port}") - echo(f"\tDevice state: {dev.is_on}") + echo(f"Host: {dev.host}") + echo(f"Port: {dev.port}") + echo(f"Device state: {dev.is_on}") + + echo(f"Time: {dev.time} (tz: {dev.timezone}") + echo(f"Hardware: {dev.hw_info['hw_ver']}") + echo(f"Software: {dev.hw_info['sw_ver']}") + echo(f"MAC (rssi): {dev.mac} ({dev.rssi})") + if verbose: + echo(f"Location: {dev.location}") + + _echo_all_features(dev.features, verbose=verbose) + echo() + if dev.children: - echo("\t== Children ==") + echo("[bold]== Children ==[/bold]") for child in dev.children: _echo_all_features( child.features, - title_prefix=f"{child.alias} ({child.model}, {child.device_type})", + title_prefix=f"{child.alias} ({child.model})", verbose=verbose, + indent="\t", ) echo() - echo("\t[bold]== Generic information ==[/bold]") - echo(f"\tTime: {dev.time} (tz: {dev.timezone}") - echo(f"\tHardware: {dev.hw_info['hw_ver']}") - echo(f"\tSoftware: {dev.hw_info['sw_ver']}") - echo(f"\tMAC (rssi): {dev.mac} ({dev.rssi})") - echo(f"\tLocation: {dev.location}") - - _echo_all_features(dev.features, verbose=verbose) - - echo("\n\t[bold]== Modules ==[/bold]") - for module in dev.modules.values(): - echo(f"\t[green]+ {module}[/green]") - if verbose: + echo("\n\t[bold]== Modules ==[/bold]") + for module in dev.modules.values(): + echo(f"\t[green]+ {module}[/green]") + echo("\n\t[bold]== Protocol information ==[/bold]") echo(f"\tCredentials hash: {dev.credentials_hash}") echo() _echo_discovery_info(dev._discovery_info) + return dev.internal_state @@ -1261,25 +1265,29 @@ async def shell(dev: Device): @click.argument("value", required=False) @click.option("--child", required=False) @pass_dev -async def feature(dev: Device, child: str, name: str, value): +@click.pass_context +async def feature(ctx: click.Context, dev: Device, child: str, name: str, value): """Access and modify features. If no *name* is given, lists available features and their values. If only *name* is given, the value of named feature is returned. If both *name* and *value* are set, the described setting is changed. """ + verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False + if child is not None: echo(f"Targeting child device {child}") dev = dev.get_child_device(child) if not name: - _echo_features(dev.features, "\n[bold]== Features ==[/bold]\n", indent="") + _echo_all_features(dev.features, verbose=verbose, indent="") if dev.children: for child_dev in dev.children: - _echo_features( + _echo_all_features( child_dev.features, - f"\n[bold]== Child {child_dev.alias} ==\n", - indent="", + verbose=verbose, + title_prefix=f"Child {child_dev.alias}", + indent="\t", ) return diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 1973c8248..b163b82fa 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -839,7 +839,10 @@ async def test_features_all(discovery_mock, mocker, runner): ["--host", "127.0.0.123", "--debug", "feature"], catch_exceptions=False, ) - assert "== Features ==" in res.output + assert "== Primary features ==" in res.output + assert "== Information ==" in res.output + assert "== Configuration ==" in res.output + assert "== Debug ==" in res.output assert res.exit_code == 0 From f041f9d7e95a4d34dc9444b58c0e1b3352d3557e Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sun, 23 Jun 2024 07:22:29 +0100 Subject: [PATCH 488/892] Fix smart led status to report rule status (#1002) Change the reporting of the led state for smart devices to match the setter which sets the rule to always or never. --- kasa/smart/modules/led.py | 2 +- kasa/tests/test_cli.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/smart/modules/led.py b/kasa/smart/modules/led.py index 230b83d9f..2d0a354c0 100644 --- a/kasa/smart/modules/led.py +++ b/kasa/smart/modules/led.py @@ -27,7 +27,7 @@ def mode(self): @property def led(self): """Return current led status.""" - return self.data["led_status"] + return self.data["led_rule"] != "never" async def set_led(self, enable: bool): """Set led. diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index b163b82fa..4f8157025 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -893,7 +893,7 @@ async def test_feature_set(mocker, runner): ) led_setter.assert_called_with(True) - assert "Changing led from False to True" in res.output + assert "Changing led from True to True" in res.output assert res.exit_code == 0 From 1b619effe58c0588f9aaee6d66d1c6ceb0646226 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 23 Jun 2024 08:39:34 +0200 Subject: [PATCH 489/892] Demote device_time back to debug (#1001) Reverts unintentional change of feature category for device_time. --- kasa/smart/modules/time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/smart/modules/time.py b/kasa/smart/modules/time.py index c2007ceba..dc4fad3fc 100644 --- a/kasa/smart/modules/time.py +++ b/kasa/smart/modules/time.py @@ -29,7 +29,7 @@ def __init__(self, device: SmartDevice, module: str): name="Device time", attribute_getter="time", container=self, - category=Feature.Category.Info, + category=Feature.Category.Debug, ) ) From 8529d0db9381cac40d047b19a5de028a29745279 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sun, 23 Jun 2024 07:51:46 +0100 Subject: [PATCH 490/892] Add 0.7 api changes section to docs (#996) --- docs/source/deprecated.md | 45 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/docs/source/deprecated.md b/docs/source/deprecated.md index d6c22bee5..f27c09855 100644 --- a/docs/source/deprecated.md +++ b/docs/source/deprecated.md @@ -1,4 +1,47 @@ -# Deprecated API +# 0.7 API changes + +This page contains information about the major API changes in 0.7. + +The previous API reference can be found below. + +## Restructuring the library + +This is the largest refactoring of the library and there are changes in all parts of the library. +Other than the three breaking changes below, all changes are backwards compatible, and you will get a deprecation warning with instructions to help porting your code over. + +* The library has now been restructured into `iot` and `smart` packages to contain the respective protocol (command set) implementations. The old `Smart{Plug,Bulb,Lightstrip}` that do not require authentication are now accessible through `kasa.iot` package. +* Exception classes are renamed +* Using .connect() or discover() is the preferred way to construct device instances rather than initiating constructors on a device. + +### Breaking changes + +* `features()` now returns a dict of `(identifier, feature)` instead of barely used set of strings. +* The `supported_modules` attribute is removed from the device class. +* `state_information` returns information based on features. If you leveraged this property, you may need to adjust your keys. + +## Module support for SMART devices + +This release introduces modules to SMART devices (i.e., devices that require authentication, previously supported using the "tapo" package which has now been renamed to "smart") and uses the device-reported capabilities to initialize the modules supported by the device. +This allows us to support previously unknown devices for known and implemented features, +and makes it easy to add support for new features and device types in the future. + +This inital release adds 26 modules to support a variety of features, including: +* Basic controls for various device (like color temperature, brightness, etc.) +* Light effects & presets +* Control LEDs +* Fan controls +* Thermostat controls +* Handling of firmware updates +* Some hub controls (like playing alarms, ) + +## Introspectable device features + +The library now offers a generic way to access device features ("features"), making it possible to create interfaces without knowledge of the module/feature specific APIs. +We use this information to construct our cli tool status output, and you can use `kasa feature` to read and control them. + +The upcoming homeassistant integration rewrite will also use these interfaces to provide access to features that were not easily available to homeassistant users, and simplifies extending the support for more devices and features in the future. + +## Deprecated API Reference ```{currentmodule} kasa ``` From 4df5fbc0dd8d8434da02431110e45eb5ad30b091 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sun, 23 Jun 2024 08:17:25 +0100 Subject: [PATCH 491/892] Prepare 0.7.0 (#998) ## [0.7.0](https://github.com/python-kasa/python-kasa/tree/0.7.0) (2024-06-23) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2.1...0.7.0) We have been working hard behind the scenes to make this major release possible. This release brings a major refactoring of the library to serve the ever-growing list of supported devices and paves the way for the future, yet unsupported devices. The library now exposes device features through generic module and feature interfaces, that allows easy extension for future improvements. With almost 180 merged pull requests, over 200 changed files and since the last release, this release includes lots of goodies for everyone: * Support for multi-functional devices like the dimmable fan KS240. * Initial support for hubs and hub-connected devices like thermostats and sensors. * Both IOT (legacy kasa) and SMART (tapo and newer kasa) devices now expose features and share common API. * Modules to allow controlling new devices and functions such as light presets, fan controls, thermostats, humidity sensors, firmware updates and alarms. * The common APIs allow dynamic introspection of available device features, making it easy to create dynamic interfaces. * Improved documentation. Hope you enjoy the release, feel free to leave a comment and feedback! If you have a device that works, but is not listed in our supported devices list, feel free to [contribute fixture files](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) to help us to make the library even better! > git diff 0.6.2.1..HEAD|diffstat > 214 files changed, 26960 insertions(+), 6310 deletions(-) For more information on the changes please checkout our [documentation on the API changes](https://python-kasa.readthedocs.io/en/latest/deprecated.html) **Breaking changes:** - Add common energy module and deprecate device emeter attributes [\#976](https://github.com/python-kasa/python-kasa/pull/976) (@sdb9696) - Move SmartBulb into SmartDevice [\#874](https://github.com/python-kasa/python-kasa/pull/874) (@sdb9696) - Change state\_information to return feature values [\#804](https://github.com/python-kasa/python-kasa/pull/804) (@rytilahti) - Remove SmartPlug in favor of SmartDevice [\#781](https://github.com/python-kasa/python-kasa/pull/781) (@rytilahti) - Add generic interface for accessing device features [\#741](https://github.com/python-kasa/python-kasa/pull/741) (@rytilahti) **Implemented enhancements:** - Cleanup cli output [\#1000](https://github.com/python-kasa/python-kasa/pull/1000) (@rytilahti) - Improve autooff name and unit [\#997](https://github.com/python-kasa/python-kasa/pull/997) (@rytilahti) - Update mode, time, rssi and report\_interval feature names/units [\#995](https://github.com/python-kasa/python-kasa/pull/995) (@sdb9696) - Add unit\_getter for feature [\#993](https://github.com/python-kasa/python-kasa/pull/993) (@rytilahti) - Add timezone to on\_since attributes [\#978](https://github.com/python-kasa/python-kasa/pull/978) (@sdb9696) - Add type hints to feature set\_value [\#974](https://github.com/python-kasa/python-kasa/pull/974) (@sdb9696) - Handle unknown light effect names and only calculate effect list once [\#973](https://github.com/python-kasa/python-kasa/pull/973) (@sdb9696) - Support smart child modules queries [\#967](https://github.com/python-kasa/python-kasa/pull/967) (@sdb9696) - Do not expose child modules on parent devices [\#964](https://github.com/python-kasa/python-kasa/pull/964) (@sdb9696) - Do not add parent only modules to strip sockets [\#963](https://github.com/python-kasa/python-kasa/pull/963) (@sdb9696) - Add time sync command [\#951](https://github.com/python-kasa/python-kasa/pull/951) (@rytilahti) - Make device initialisation easier by reducing required imports [\#936](https://github.com/python-kasa/python-kasa/pull/936) (@sdb9696) - Fix set\_state for common light modules [\#929](https://github.com/python-kasa/python-kasa/pull/929) (@sdb9696) - Add state feature for iot devices [\#924](https://github.com/python-kasa/python-kasa/pull/924) (@rytilahti) - Add post update hook to module and use in smart LightEffect [\#921](https://github.com/python-kasa/python-kasa/pull/921) (@sdb9696) - Add LightEffect module for smart light strips [\#918](https://github.com/python-kasa/python-kasa/pull/918) (@sdb9696) - Add light presets common module to devices. [\#907](https://github.com/python-kasa/python-kasa/pull/907) (@sdb9696) - Improve categorization of features [\#904](https://github.com/python-kasa/python-kasa/pull/904) (@rytilahti) - Create common interfaces for remaining device types [\#895](https://github.com/python-kasa/python-kasa/pull/895) (@sdb9696) - Make get\_module return typed module [\#892](https://github.com/python-kasa/python-kasa/pull/892) (@sdb9696) - Add LightEffectModule for dynamic light effects on SMART bulbs [\#887](https://github.com/python-kasa/python-kasa/pull/887) (@sdb9696) - Implement choice feature type [\#880](https://github.com/python-kasa/python-kasa/pull/880) (@rytilahti) - Add Fan interface for SMART devices [\#873](https://github.com/python-kasa/python-kasa/pull/873) (@sdb9696) - Improve temperature controls [\#872](https://github.com/python-kasa/python-kasa/pull/872) (@rytilahti) - Add precision\_hint to feature [\#871](https://github.com/python-kasa/python-kasa/pull/871) (@rytilahti) - Be more lax on unknown SMART devices [\#863](https://github.com/python-kasa/python-kasa/pull/863) (@rytilahti) - Handle paging of partial responses of lists like child\_device\_info [\#862](https://github.com/python-kasa/python-kasa/pull/862) (@sdb9696) - Better firmware module support for devices not connected to the internet [\#854](https://github.com/python-kasa/python-kasa/pull/854) (@sdb9696) - Re-query missing responses after multi request errors [\#850](https://github.com/python-kasa/python-kasa/pull/850) (@sdb9696) - Implement action feature [\#849](https://github.com/python-kasa/python-kasa/pull/849) (@rytilahti) - Add temperature control module for smart [\#848](https://github.com/python-kasa/python-kasa/pull/848) (@rytilahti) - Implement feature categories [\#846](https://github.com/python-kasa/python-kasa/pull/846) (@rytilahti) - Expose IOT emeter info as features [\#844](https://github.com/python-kasa/python-kasa/pull/844) (@rytilahti) - Add support for feature units [\#843](https://github.com/python-kasa/python-kasa/pull/843) (@rytilahti) - Add ColorModule for smart devices [\#840](https://github.com/python-kasa/python-kasa/pull/840) (@sdb9696) - Add colortemp feature for iot devices [\#827](https://github.com/python-kasa/python-kasa/pull/827) (@rytilahti) - Add support for firmware module v1 [\#821](https://github.com/python-kasa/python-kasa/pull/821) (@sdb9696) - Add colortemp module [\#814](https://github.com/python-kasa/python-kasa/pull/814) (@rytilahti) - Add iot brightness feature [\#808](https://github.com/python-kasa/python-kasa/pull/808) (@sdb9696) - Revise device initialization and subsequent updates [\#807](https://github.com/python-kasa/python-kasa/pull/807) (@rytilahti) - Add brightness module [\#806](https://github.com/python-kasa/python-kasa/pull/806) (@rytilahti) - Support multiple child requests [\#795](https://github.com/python-kasa/python-kasa/pull/795) (@sdb9696) - Support for on\_off\_gradually v2+ [\#793](https://github.com/python-kasa/python-kasa/pull/793) (@rytilahti) - Improve smartdevice update module [\#791](https://github.com/python-kasa/python-kasa/pull/791) (@rytilahti) - Add --child option to feature command [\#789](https://github.com/python-kasa/python-kasa/pull/789) (@rytilahti) - Add temperature\_unit feature to t315 [\#788](https://github.com/python-kasa/python-kasa/pull/788) (@rytilahti) - Add feature for ambient light sensor [\#787](https://github.com/python-kasa/python-kasa/pull/787) (@shifty35) - Add initial support for H100 and T315 [\#776](https://github.com/python-kasa/python-kasa/pull/776) (@rytilahti) - Generalize smartdevice child support [\#775](https://github.com/python-kasa/python-kasa/pull/775) (@rytilahti) - Raise CLI errors in debug mode [\#771](https://github.com/python-kasa/python-kasa/pull/771) (@sdb9696) - Add cloud module for smartdevice [\#767](https://github.com/python-kasa/python-kasa/pull/767) (@rytilahti) - Add firmware module for smartdevice [\#766](https://github.com/python-kasa/python-kasa/pull/766) (@rytilahti) - Add fan module [\#764](https://github.com/python-kasa/python-kasa/pull/764) (@rytilahti) - Add smartdevice module for led controls [\#761](https://github.com/python-kasa/python-kasa/pull/761) (@rytilahti) - Auto auto-off module for smartdevice [\#760](https://github.com/python-kasa/python-kasa/pull/760) (@rytilahti) - Add smartdevice module for smooth transitions [\#759](https://github.com/python-kasa/python-kasa/pull/759) (@rytilahti) - Initial implementation for modularized smartdevice [\#757](https://github.com/python-kasa/python-kasa/pull/757) (@rytilahti) - Let caller handle SMART errors on multi-requests [\#754](https://github.com/python-kasa/python-kasa/pull/754) (@sdb9696) - Add 'shell' command to cli [\#738](https://github.com/python-kasa/python-kasa/pull/738) (@rytilahti) **Fixed bugs:** - Fix smart led status to report rule status [\#1002](https://github.com/python-kasa/python-kasa/pull/1002) (@sdb9696) - Demote device\_time back to debug [\#1001](https://github.com/python-kasa/python-kasa/pull/1001) (@rytilahti) - Fix to call update when only --device-family passed to cli [\#987](https://github.com/python-kasa/python-kasa/pull/987) (@sdb9696) - Disallow non-targeted device commands [\#982](https://github.com/python-kasa/python-kasa/pull/982) (@rytilahti) - Add supported check to light transition module [\#971](https://github.com/python-kasa/python-kasa/pull/971) (@sdb9696) - Fix switching off light effects for iot lights strips [\#961](https://github.com/python-kasa/python-kasa/pull/961) (@sdb9696) - Add state features to iot strip sockets [\#960](https://github.com/python-kasa/python-kasa/pull/960) (@sdb9696) - Ensure http delay logic works during default login attempt [\#959](https://github.com/python-kasa/python-kasa/pull/959) (@sdb9696) - Fix fan speed level when off and derive smart fan module from common fan interface [\#957](https://github.com/python-kasa/python-kasa/pull/957) (@sdb9696) - Require update in cli for wifi commands [\#956](https://github.com/python-kasa/python-kasa/pull/956) (@rytilahti) - Do not raise on multi-request errors on child devices [\#949](https://github.com/python-kasa/python-kasa/pull/949) (@rytilahti) - Do not show a zero error code when cli exits from showing help [\#935](https://github.com/python-kasa/python-kasa/pull/935) (@rytilahti) - Initialize autooff features only when data is available [\#933](https://github.com/python-kasa/python-kasa/pull/933) (@rytilahti) - Fix P100 errors on multi-requests [\#930](https://github.com/python-kasa/python-kasa/pull/930) (@sdb9696) - Fix potential infinite loop if incomplete lists returned [\#920](https://github.com/python-kasa/python-kasa/pull/920) (@sdb9696) - Add 'battery\_percentage' only when it's available [\#906](https://github.com/python-kasa/python-kasa/pull/906) (@rytilahti) - Add missing alarm volume 'normal' [\#899](https://github.com/python-kasa/python-kasa/pull/899) (@rytilahti) - Use Path.save for saving the fixtures [\#894](https://github.com/python-kasa/python-kasa/pull/894) (@rytilahti) - Fix wifi scan re-querying error [\#891](https://github.com/python-kasa/python-kasa/pull/891) (@sdb9696) - Fix --help on subcommands [\#886](https://github.com/python-kasa/python-kasa/pull/886) (@rytilahti) - Fix smartprotocol response list handler to handle null reponses [\#884](https://github.com/python-kasa/python-kasa/pull/884) (@sdb9696) - Improve feature setter robustness [\#870](https://github.com/python-kasa/python-kasa/pull/870) (@rytilahti) - smartbulb: Limit brightness range to 1-100 [\#829](https://github.com/python-kasa/python-kasa/pull/829) (@rytilahti) - Fix energy module calling get\_current\_power [\#798](https://github.com/python-kasa/python-kasa/pull/798) (@sdb9696) - Fix auto update switch [\#786](https://github.com/python-kasa/python-kasa/pull/786) (@rytilahti) - Retry query on 403 after successful handshake [\#785](https://github.com/python-kasa/python-kasa/pull/785) (@sdb9696) - Ensure connections are closed when cli is finished [\#752](https://github.com/python-kasa/python-kasa/pull/752) (@sdb9696) - Fix for P100 on fw 1.1.3 login\_version none [\#751](https://github.com/python-kasa/python-kasa/pull/751) (@sdb9696) - Pass timeout parameters to discover\_single [\#744](https://github.com/python-kasa/python-kasa/pull/744) (@sdb9696) - Reduce AuthenticationExceptions raising from transports [\#740](https://github.com/python-kasa/python-kasa/pull/740) (@sdb9696) - Do not crash cli on missing discovery info [\#735](https://github.com/python-kasa/python-kasa/pull/735) (@rytilahti) - Fix port-override for aes&klap transports [\#734](https://github.com/python-kasa/python-kasa/pull/734) (@rytilahti) - Fix discovery cli to print devices not printed during discovery timeout [\#670](https://github.com/python-kasa/python-kasa/pull/670) (@sdb9696) **Added support for devices:** - Add fixture for L920-5\(EU\) 1.0.7 [\#972](https://github.com/python-kasa/python-kasa/pull/972) (@rytilahti) - Add P115 fixture [\#950](https://github.com/python-kasa/python-kasa/pull/950) (@rytilahti) - Add some device fixtures [\#948](https://github.com/python-kasa/python-kasa/pull/948) (@rytilahti) - Add fixture for S505D [\#947](https://github.com/python-kasa/python-kasa/pull/947) (@rytilahti) - Add fixture for p300 1.0.15 [\#915](https://github.com/python-kasa/python-kasa/pull/915) (@rytilahti) - Add H100 1.5.10 and KE100 2.4.0 fixtures [\#905](https://github.com/python-kasa/python-kasa/pull/905) (@rytilahti) - Add fixture for waterleak sensor T300 [\#897](https://github.com/python-kasa/python-kasa/pull/897) (@rytilahti) - Add support for contact sensor \(T110\) [\#877](https://github.com/python-kasa/python-kasa/pull/877) (@rytilahti) - Add support for waterleak sensor \(T300\) [\#876](https://github.com/python-kasa/python-kasa/pull/876) (@rytilahti) - Add support for KH100 hub [\#847](https://github.com/python-kasa/python-kasa/pull/847) (@Adriandorr) - Support for new ks240 fan/light wall switch [\#839](https://github.com/python-kasa/python-kasa/pull/839) (@sdb9696) - Add P100 fw 1.4.0 fixture [\#820](https://github.com/python-kasa/python-kasa/pull/820) (@sdb9696) - Add fixture for P110 sw 1.0.7 [\#801](https://github.com/python-kasa/python-kasa/pull/801) (@rytilahti) - Add updated l530 fixture 1.1.6 [\#792](https://github.com/python-kasa/python-kasa/pull/792) (@rytilahti) - Fix devtools for P100 and add fixture [\#753](https://github.com/python-kasa/python-kasa/pull/753) (@sdb9696) - Add H100 fixtures [\#737](https://github.com/python-kasa/python-kasa/pull/737) (@rytilahti) **Documentation updates:** - Cleanup README to use the new cli format [\#999](https://github.com/python-kasa/python-kasa/pull/999) (@rytilahti) - Add 0.7 api changes section to docs [\#996](https://github.com/python-kasa/python-kasa/pull/996) (@sdb9696) - Update README to be more approachable for new users [\#994](https://github.com/python-kasa/python-kasa/pull/994) (@rytilahti) - Update docs with more howto examples [\#968](https://github.com/python-kasa/python-kasa/pull/968) (@sdb9696) - Update documentation structure and start migrating to markdown [\#934](https://github.com/python-kasa/python-kasa/pull/934) (@sdb9696) - Add tutorial doctest module and enable top level await [\#919](https://github.com/python-kasa/python-kasa/pull/919) (@sdb9696) - Add warning about tapo watchdog [\#902](https://github.com/python-kasa/python-kasa/pull/902) (@rytilahti) - Move contribution instructions into docs [\#901](https://github.com/python-kasa/python-kasa/pull/901) (@rytilahti) - Add rust tapo link to README [\#857](https://github.com/python-kasa/python-kasa/pull/857) (@rytilahti) - Enable shell extra for installing ptpython and rich [\#782](https://github.com/python-kasa/python-kasa/pull/782) (@sdb9696) - Add WallSwitch device type and autogenerate supported devices docs [\#758](https://github.com/python-kasa/python-kasa/pull/758) (@sdb9696) **Project maintenance:** - Drop python3.8 support [\#992](https://github.com/python-kasa/python-kasa/pull/992) (@rytilahti) - Remove anyio dependency from pyproject.toml [\#990](https://github.com/python-kasa/python-kasa/pull/990) (@sdb9696) - Configure mypy to run in virtual environment and fix resulting issues [\#989](https://github.com/python-kasa/python-kasa/pull/989) (@sdb9696) - Better checking of child modules not supported by parent device [\#966](https://github.com/python-kasa/python-kasa/pull/966) (@sdb9696) - Use freezegun for testing aes http client delays [\#954](https://github.com/python-kasa/python-kasa/pull/954) (@sdb9696) - Fix passing custom port for dump\_devinfo [\#938](https://github.com/python-kasa/python-kasa/pull/938) (@rytilahti) - Update release playbook [\#932](https://github.com/python-kasa/python-kasa/pull/932) (@rytilahti) - Deprecate device level light, effect and led attributes [\#916](https://github.com/python-kasa/python-kasa/pull/916) (@sdb9696) - Update cli to use common modules and remove iot specific cli testing [\#913](https://github.com/python-kasa/python-kasa/pull/913) (@sdb9696) - Deprecate is\_something attributes [\#912](https://github.com/python-kasa/python-kasa/pull/912) (@sdb9696) - Make Light and Fan a common module interface [\#911](https://github.com/python-kasa/python-kasa/pull/911) (@sdb9696) - Rename bulb interface to light and move fan and light interface to interfaces [\#910](https://github.com/python-kasa/python-kasa/pull/910) (@sdb9696) - Make module names consistent and remove redundant module casting [\#909](https://github.com/python-kasa/python-kasa/pull/909) (@sdb9696) - Add child devices from hubs to generated list of supported devices [\#898](https://github.com/python-kasa/python-kasa/pull/898) (@sdb9696) - Update interfaces so they all inherit from Device [\#893](https://github.com/python-kasa/python-kasa/pull/893) (@sdb9696) - Update ks240 fixture with child device query info [\#890](https://github.com/python-kasa/python-kasa/pull/890) (@sdb9696) - Use pydantic.v1 namespace on all pydantic versions [\#883](https://github.com/python-kasa/python-kasa/pull/883) (@rytilahti) - Update dump\_devinfo to print original exception stack on errors. [\#882](https://github.com/python-kasa/python-kasa/pull/882) (@sdb9696) - Put modules back on children for wall switches [\#881](https://github.com/python-kasa/python-kasa/pull/881) (@sdb9696) - Fix pypy39 CI cache on macos [\#868](https://github.com/python-kasa/python-kasa/pull/868) (@sdb9696) - Do not try coverage upload for pypy [\#867](https://github.com/python-kasa/python-kasa/pull/867) (@sdb9696) - Add runner.arch to cache-key in CI [\#866](https://github.com/python-kasa/python-kasa/pull/866) (@sdb9696) - Fix broken CI due to missing python version on macos-latest [\#864](https://github.com/python-kasa/python-kasa/pull/864) (@sdb9696) - Fix incorrect state updates in FakeTestProtocols [\#861](https://github.com/python-kasa/python-kasa/pull/861) (@sdb9696) - Embed FeatureType inside Feature [\#860](https://github.com/python-kasa/python-kasa/pull/860) (@rytilahti) - Include component\_nego with child fixtures [\#858](https://github.com/python-kasa/python-kasa/pull/858) (@sdb9696) - Use brightness module for smartbulb [\#853](https://github.com/python-kasa/python-kasa/pull/853) (@rytilahti) - Ignore system environment variables for tests [\#851](https://github.com/python-kasa/python-kasa/pull/851) (@rytilahti) - Remove mock fixtures [\#845](https://github.com/python-kasa/python-kasa/pull/845) (@rytilahti) - Enable and convert to future annotations [\#838](https://github.com/python-kasa/python-kasa/pull/838) (@sdb9696) - Update poetry locks and pre-commit hooks [\#837](https://github.com/python-kasa/python-kasa/pull/837) (@sdb9696) - Cache pipx in CI and add custom setup action [\#835](https://github.com/python-kasa/python-kasa/pull/835) (@sdb9696) - Fix non python 3.8 compliant test [\#832](https://github.com/python-kasa/python-kasa/pull/832) (@sdb9696) - Fix CI issue with python version used by pipx to install poetry [\#831](https://github.com/python-kasa/python-kasa/pull/831) (@sdb9696) - Refactor split smartdevice tests to test\_{iot,smart}device [\#822](https://github.com/python-kasa/python-kasa/pull/822) (@rytilahti) - Add pre-commit caching and fix poetry extras cache [\#817](https://github.com/python-kasa/python-kasa/pull/817) (@sdb9696) - Fix slow aestransport and cli tests [\#816](https://github.com/python-kasa/python-kasa/pull/816) (@sdb9696) - Do not run coverage on pypy and cache poetry envs [\#812](https://github.com/python-kasa/python-kasa/pull/812) (@sdb9696) - Update test framework for dynamic parametrization [\#810](https://github.com/python-kasa/python-kasa/pull/810) (@sdb9696) - Put child fixtures in subfolder [\#809](https://github.com/python-kasa/python-kasa/pull/809) (@sdb9696) - Simplify device \_\_repr\_\_ [\#805](https://github.com/python-kasa/python-kasa/pull/805) (@rytilahti) - Add T315 fixture, tests for humidity&temperature modules [\#802](https://github.com/python-kasa/python-kasa/pull/802) (@rytilahti) - Do not fail fast on pypy CI jobs [\#799](https://github.com/python-kasa/python-kasa/pull/799) (@sdb9696) - Update dump\_devinfo to collect child device info [\#796](https://github.com/python-kasa/python-kasa/pull/796) (@sdb9696) - Refactor test framework [\#794](https://github.com/python-kasa/python-kasa/pull/794) (@sdb9696) - Add missing firmware module import [\#774](https://github.com/python-kasa/python-kasa/pull/774) (@rytilahti) - Fix dump\_devinfo scrubbing for ks240 [\#765](https://github.com/python-kasa/python-kasa/pull/765) (@rytilahti) - Rename and deprecate exception classes [\#739](https://github.com/python-kasa/python-kasa/pull/739) (@sdb9696) - Refactor devices into subpackages and deprecate old names [\#716](https://github.com/python-kasa/python-kasa/pull/716) (@sdb9696) --- .github_changelog_generator | 2 +- CHANGELOG.md | 219 ++++++++++++++++++------------------ poetry.lock | 30 +++-- pyproject.toml | 2 +- 4 files changed, 130 insertions(+), 123 deletions(-) diff --git a/.github_changelog_generator b/.github_changelog_generator index c32fe6ead..9a0c0af9d 100644 --- a/.github_changelog_generator +++ b/.github_changelog_generator @@ -1,5 +1,5 @@ breaking_labels=breaking change -add-sections={"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}} +add-sections={"new-device":{"prefix":"**Added support for devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}} release_branch=master usernames-as-github-logins=true exclude-labels=duplicate,question,invalid,wontfix,release-prep diff --git a/CHANGELOG.md b/CHANGELOG.md index d5fd4de70..b25d5c466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,116 +1,62 @@ # Changelog -## [0.7.0.dev5](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev5) (2024-06-17) +## [0.7.0](https://github.com/python-kasa/python-kasa/tree/0.7.0) (2024-06-23) -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev4...0.7.0.dev5) +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2.1...0.7.0) -**Implemented enhancements:** +We have been working hard behind the scenes to make this major release possible. +This release brings a major refactoring of the library to serve the ever-growing list of supported devices and paves the way for the future, yet unsupported devices. +The library now exposes device features through generic module and feature interfaces, that allows easy extension for future improvements. -- Add timezone to on\_since attributes [\#978](https://github.com/python-kasa/python-kasa/pull/978) (@sdb9696) -- Add common energy module and deprecate device emeter attributes [\#976](https://github.com/python-kasa/python-kasa/pull/976) (@sdb9696) -- Add type hints to feature set\_value [\#974](https://github.com/python-kasa/python-kasa/pull/974) (@sdb9696) -- Handle unknown light effect names and only calculate effect list once [\#973](https://github.com/python-kasa/python-kasa/pull/973) (@sdb9696) -- Add time sync command [\#951](https://github.com/python-kasa/python-kasa/pull/951) (@rytilahti) +With almost 180 merged pull requests, over 200 changed files and since the last release, this release includes lots of goodies for everyone: +* Support for multi-functional devices like the dimmable fan KS240. +* Initial support for hubs and hub-connected devices like thermostats and sensors. +* Both IOT (legacy kasa) and SMART (tapo and newer kasa) devices now expose features and share common API. +* Modules to allow controlling new devices and functions such as light presets, fan controls, thermostats, humidity sensors, firmware updates and alarms. +* The common APIs allow dynamic introspection of available device features, making it easy to create dynamic interfaces. +* Improved documentation. -**Fixed bugs:** +Hope you enjoy the release, feel free to leave a comment and feedback! -- Disallow non-targeted device commands [\#982](https://github.com/python-kasa/python-kasa/pull/982) (@rytilahti) -- Add supported check to light transition module [\#971](https://github.com/python-kasa/python-kasa/pull/971) (@sdb9696) +If you have a device that works, but is not listed in our supported devices list, feel free to [contribute fixture files](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) to help us to make the library even better! -**Project maintenance:** +> git diff 0.6.2.1..HEAD|diffstat +> 214 files changed, 26960 insertions(+), 6310 deletions(-) -- Add fixture for L920-5\(EU\) 1.0.7 [\#972](https://github.com/python-kasa/python-kasa/pull/972) (@rytilahti) +For more information on the changes please checkout our [documentation on the API changes](https://python-kasa.readthedocs.io/en/latest/deprecated.html) -## [0.7.0.dev4](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev4) (2024-06-10) +**Breaking changes:** -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev3...0.7.0.dev4) +- Add common energy module and deprecate device emeter attributes [\#976](https://github.com/python-kasa/python-kasa/pull/976) (@sdb9696) +- Move SmartBulb into SmartDevice [\#874](https://github.com/python-kasa/python-kasa/pull/874) (@sdb9696) +- Change state\_information to return feature values [\#804](https://github.com/python-kasa/python-kasa/pull/804) (@rytilahti) +- Remove SmartPlug in favor of SmartDevice [\#781](https://github.com/python-kasa/python-kasa/pull/781) (@rytilahti) +- Add generic interface for accessing device features [\#741](https://github.com/python-kasa/python-kasa/pull/741) (@rytilahti) **Implemented enhancements:** +- Cleanup cli output [\#1000](https://github.com/python-kasa/python-kasa/pull/1000) (@rytilahti) +- Improve autooff name and unit [\#997](https://github.com/python-kasa/python-kasa/pull/997) (@rytilahti) +- Update mode, time, rssi and report\_interval feature names/units [\#995](https://github.com/python-kasa/python-kasa/pull/995) (@sdb9696) +- Add unit\_getter for feature [\#993](https://github.com/python-kasa/python-kasa/pull/993) (@rytilahti) +- Add timezone to on\_since attributes [\#978](https://github.com/python-kasa/python-kasa/pull/978) (@sdb9696) +- Add type hints to feature set\_value [\#974](https://github.com/python-kasa/python-kasa/pull/974) (@sdb9696) +- Handle unknown light effect names and only calculate effect list once [\#973](https://github.com/python-kasa/python-kasa/pull/973) (@sdb9696) - Support smart child modules queries [\#967](https://github.com/python-kasa/python-kasa/pull/967) (@sdb9696) - Do not expose child modules on parent devices [\#964](https://github.com/python-kasa/python-kasa/pull/964) (@sdb9696) - Do not add parent only modules to strip sockets [\#963](https://github.com/python-kasa/python-kasa/pull/963) (@sdb9696) - -**Project maintenance:** - -- Better checking of child modules not supported by parent device [\#966](https://github.com/python-kasa/python-kasa/pull/966) (@sdb9696) -- Add fixture for p300 1.0.15 [\#915](https://github.com/python-kasa/python-kasa/pull/915) (@rytilahti) - -## [0.7.0.dev3](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev3) (2024-06-07) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev2...0.7.0.dev3) - -**Fixed bugs:** - -- Fix switching off light effects for iot lights strips [\#961](https://github.com/python-kasa/python-kasa/pull/961) (@sdb9696) -- Add state features to iot strip sockets [\#960](https://github.com/python-kasa/python-kasa/pull/960) (@sdb9696) -- Ensure http delay logic works during default login attempt [\#959](https://github.com/python-kasa/python-kasa/pull/959) (@sdb9696) -- Fix fan speed level when off and derive smart fan module from common fan interface [\#957](https://github.com/python-kasa/python-kasa/pull/957) (@sdb9696) -- Require update in cli for wifi commands [\#956](https://github.com/python-kasa/python-kasa/pull/956) (@rytilahti) - -**Project maintenance:** - -- Use freezegun for testing aes http client delays [\#954](https://github.com/python-kasa/python-kasa/pull/954) (@sdb9696) -- Update release playbook [\#932](https://github.com/python-kasa/python-kasa/pull/932) (@rytilahti) - -## [0.7.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev2) (2024-06-05) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev1...0.7.0.dev2) - -**Implemented enhancements:** - +- Add time sync command [\#951](https://github.com/python-kasa/python-kasa/pull/951) (@rytilahti) - Make device initialisation easier by reducing required imports [\#936](https://github.com/python-kasa/python-kasa/pull/936) (@sdb9696) - -**Fixed bugs:** - -- Do not raise on multi-request errors on child devices [\#949](https://github.com/python-kasa/python-kasa/pull/949) (@rytilahti) -- Do not show a zero error code when cli exits from showing help [\#935](https://github.com/python-kasa/python-kasa/pull/935) (@rytilahti) -- Initialize autooff features only when data is available [\#933](https://github.com/python-kasa/python-kasa/pull/933) (@rytilahti) -- Fix P100 errors on multi-requests [\#930](https://github.com/python-kasa/python-kasa/pull/930) (@sdb9696) - -**Documentation updates:** - -- Update documentation structure and start migrating to markdown [\#934](https://github.com/python-kasa/python-kasa/pull/934) (@sdb9696) - -**Project maintenance:** - -- Add P115 fixture [\#950](https://github.com/python-kasa/python-kasa/pull/950) (@rytilahti) -- Add some device fixtures [\#948](https://github.com/python-kasa/python-kasa/pull/948) (@rytilahti) -- Add fixture for S505D [\#947](https://github.com/python-kasa/python-kasa/pull/947) (@rytilahti) -- Fix passing custom port for dump\_devinfo [\#938](https://github.com/python-kasa/python-kasa/pull/938) (@rytilahti) - -## [0.7.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev1) (2024-05-22) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev0...0.7.0.dev1) - -**Implemented enhancements:** - - Fix set\_state for common light modules [\#929](https://github.com/python-kasa/python-kasa/pull/929) (@sdb9696) - Add state feature for iot devices [\#924](https://github.com/python-kasa/python-kasa/pull/924) (@rytilahti) - -## [0.7.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev0) (2024-05-19) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2.1...0.7.0.dev0) - -**Breaking changes:** - -- Move SmartBulb into SmartDevice [\#874](https://github.com/python-kasa/python-kasa/pull/874) (@sdb9696) -- Change state\_information to return feature values [\#804](https://github.com/python-kasa/python-kasa/pull/804) (@rytilahti) -- Remove SmartPlug in favor of SmartDevice [\#781](https://github.com/python-kasa/python-kasa/pull/781) (@rytilahti) -- Add generic interface for accessing device features [\#741](https://github.com/python-kasa/python-kasa/pull/741) (@rytilahti) -- Rename and deprecate exception classes [\#739](https://github.com/python-kasa/python-kasa/pull/739) (@sdb9696) - -**Implemented enhancements:** - - Add post update hook to module and use in smart LightEffect [\#921](https://github.com/python-kasa/python-kasa/pull/921) (@sdb9696) - Add LightEffect module for smart light strips [\#918](https://github.com/python-kasa/python-kasa/pull/918) (@sdb9696) +- Add light presets common module to devices. [\#907](https://github.com/python-kasa/python-kasa/pull/907) (@sdb9696) - Improve categorization of features [\#904](https://github.com/python-kasa/python-kasa/pull/904) (@rytilahti) - Create common interfaces for remaining device types [\#895](https://github.com/python-kasa/python-kasa/pull/895) (@sdb9696) - Make get\_module return typed module [\#892](https://github.com/python-kasa/python-kasa/pull/892) (@sdb9696) - Add LightEffectModule for dynamic light effects on SMART bulbs [\#887](https://github.com/python-kasa/python-kasa/pull/887) (@sdb9696) - Implement choice feature type [\#880](https://github.com/python-kasa/python-kasa/pull/880) (@rytilahti) -- Add support for contact sensor \(T110\) [\#877](https://github.com/python-kasa/python-kasa/pull/877) (@rytilahti) -- Add support for waterleak sensor \(T300\) [\#876](https://github.com/python-kasa/python-kasa/pull/876) (@rytilahti) - Add Fan interface for SMART devices [\#873](https://github.com/python-kasa/python-kasa/pull/873) (@sdb9696) - Improve temperature controls [\#872](https://github.com/python-kasa/python-kasa/pull/872) (@rytilahti) - Add precision\_hint to feature [\#871](https://github.com/python-kasa/python-kasa/pull/871) (@rytilahti) @@ -120,15 +66,14 @@ - Re-query missing responses after multi request errors [\#850](https://github.com/python-kasa/python-kasa/pull/850) (@sdb9696) - Implement action feature [\#849](https://github.com/python-kasa/python-kasa/pull/849) (@rytilahti) - Add temperature control module for smart [\#848](https://github.com/python-kasa/python-kasa/pull/848) (@rytilahti) -- Add support for KH100 hub [\#847](https://github.com/python-kasa/python-kasa/pull/847) (@Adriandorr) - Implement feature categories [\#846](https://github.com/python-kasa/python-kasa/pull/846) (@rytilahti) - Expose IOT emeter info as features [\#844](https://github.com/python-kasa/python-kasa/pull/844) (@rytilahti) - Add support for feature units [\#843](https://github.com/python-kasa/python-kasa/pull/843) (@rytilahti) - Add ColorModule for smart devices [\#840](https://github.com/python-kasa/python-kasa/pull/840) (@sdb9696) -- Support for new ks240 fan/light wall switch [\#839](https://github.com/python-kasa/python-kasa/pull/839) (@sdb9696) - Add colortemp feature for iot devices [\#827](https://github.com/python-kasa/python-kasa/pull/827) (@rytilahti) - Add support for firmware module v1 [\#821](https://github.com/python-kasa/python-kasa/pull/821) (@sdb9696) - Add colortemp module [\#814](https://github.com/python-kasa/python-kasa/pull/814) (@rytilahti) +- Add iot brightness feature [\#808](https://github.com/python-kasa/python-kasa/pull/808) (@sdb9696) - Revise device initialization and subsequent updates [\#807](https://github.com/python-kasa/python-kasa/pull/807) (@rytilahti) - Add brightness module [\#806](https://github.com/python-kasa/python-kasa/pull/806) (@rytilahti) - Support multiple child requests [\#795](https://github.com/python-kasa/python-kasa/pull/795) (@sdb9696) @@ -152,10 +97,27 @@ **Fixed bugs:** +- Fix smart led status to report rule status [\#1002](https://github.com/python-kasa/python-kasa/pull/1002) (@sdb9696) +- Demote device\_time back to debug [\#1001](https://github.com/python-kasa/python-kasa/pull/1001) (@rytilahti) +- Fix to call update when only --device-family passed to cli [\#987](https://github.com/python-kasa/python-kasa/pull/987) (@sdb9696) +- Disallow non-targeted device commands [\#982](https://github.com/python-kasa/python-kasa/pull/982) (@rytilahti) +- Add supported check to light transition module [\#971](https://github.com/python-kasa/python-kasa/pull/971) (@sdb9696) +- Fix switching off light effects for iot lights strips [\#961](https://github.com/python-kasa/python-kasa/pull/961) (@sdb9696) +- Add state features to iot strip sockets [\#960](https://github.com/python-kasa/python-kasa/pull/960) (@sdb9696) +- Ensure http delay logic works during default login attempt [\#959](https://github.com/python-kasa/python-kasa/pull/959) (@sdb9696) +- Fix fan speed level when off and derive smart fan module from common fan interface [\#957](https://github.com/python-kasa/python-kasa/pull/957) (@sdb9696) +- Require update in cli for wifi commands [\#956](https://github.com/python-kasa/python-kasa/pull/956) (@rytilahti) +- Do not raise on multi-request errors on child devices [\#949](https://github.com/python-kasa/python-kasa/pull/949) (@rytilahti) +- Do not show a zero error code when cli exits from showing help [\#935](https://github.com/python-kasa/python-kasa/pull/935) (@rytilahti) +- Initialize autooff features only when data is available [\#933](https://github.com/python-kasa/python-kasa/pull/933) (@rytilahti) +- Fix P100 errors on multi-requests [\#930](https://github.com/python-kasa/python-kasa/pull/930) (@sdb9696) +- Fix potential infinite loop if incomplete lists returned [\#920](https://github.com/python-kasa/python-kasa/pull/920) (@sdb9696) - Add 'battery\_percentage' only when it's available [\#906](https://github.com/python-kasa/python-kasa/pull/906) (@rytilahti) - Add missing alarm volume 'normal' [\#899](https://github.com/python-kasa/python-kasa/pull/899) (@rytilahti) - Use Path.save for saving the fixtures [\#894](https://github.com/python-kasa/python-kasa/pull/894) (@rytilahti) +- Fix wifi scan re-querying error [\#891](https://github.com/python-kasa/python-kasa/pull/891) (@sdb9696) - Fix --help on subcommands [\#886](https://github.com/python-kasa/python-kasa/pull/886) (@rytilahti) +- Fix smartprotocol response list handler to handle null reponses [\#884](https://github.com/python-kasa/python-kasa/pull/884) (@sdb9696) - Improve feature setter robustness [\#870](https://github.com/python-kasa/python-kasa/pull/870) (@rytilahti) - smartbulb: Limit brightness range to 1-100 [\#829](https://github.com/python-kasa/python-kasa/pull/829) (@rytilahti) - Fix energy module calling get\_current\_power [\#798](https://github.com/python-kasa/python-kasa/pull/798) (@sdb9696) @@ -167,9 +129,34 @@ - Reduce AuthenticationExceptions raising from transports [\#740](https://github.com/python-kasa/python-kasa/pull/740) (@sdb9696) - Do not crash cli on missing discovery info [\#735](https://github.com/python-kasa/python-kasa/pull/735) (@rytilahti) - Fix port-override for aes&klap transports [\#734](https://github.com/python-kasa/python-kasa/pull/734) (@rytilahti) +- Fix discovery cli to print devices not printed during discovery timeout [\#670](https://github.com/python-kasa/python-kasa/pull/670) (@sdb9696) + +**Added support for devices:** + +- Add fixture for L920-5\(EU\) 1.0.7 [\#972](https://github.com/python-kasa/python-kasa/pull/972) (@rytilahti) +- Add P115 fixture [\#950](https://github.com/python-kasa/python-kasa/pull/950) (@rytilahti) +- Add some device fixtures [\#948](https://github.com/python-kasa/python-kasa/pull/948) (@rytilahti) +- Add fixture for S505D [\#947](https://github.com/python-kasa/python-kasa/pull/947) (@rytilahti) +- Add fixture for p300 1.0.15 [\#915](https://github.com/python-kasa/python-kasa/pull/915) (@rytilahti) +- Add H100 1.5.10 and KE100 2.4.0 fixtures [\#905](https://github.com/python-kasa/python-kasa/pull/905) (@rytilahti) +- Add fixture for waterleak sensor T300 [\#897](https://github.com/python-kasa/python-kasa/pull/897) (@rytilahti) +- Add support for contact sensor \(T110\) [\#877](https://github.com/python-kasa/python-kasa/pull/877) (@rytilahti) +- Add support for waterleak sensor \(T300\) [\#876](https://github.com/python-kasa/python-kasa/pull/876) (@rytilahti) +- Add support for KH100 hub [\#847](https://github.com/python-kasa/python-kasa/pull/847) (@Adriandorr) +- Support for new ks240 fan/light wall switch [\#839](https://github.com/python-kasa/python-kasa/pull/839) (@sdb9696) +- Add P100 fw 1.4.0 fixture [\#820](https://github.com/python-kasa/python-kasa/pull/820) (@sdb9696) +- Add fixture for P110 sw 1.0.7 [\#801](https://github.com/python-kasa/python-kasa/pull/801) (@rytilahti) +- Add updated l530 fixture 1.1.6 [\#792](https://github.com/python-kasa/python-kasa/pull/792) (@rytilahti) +- Fix devtools for P100 and add fixture [\#753](https://github.com/python-kasa/python-kasa/pull/753) (@sdb9696) +- Add H100 fixtures [\#737](https://github.com/python-kasa/python-kasa/pull/737) (@rytilahti) **Documentation updates:** +- Cleanup README to use the new cli format [\#999](https://github.com/python-kasa/python-kasa/pull/999) (@rytilahti) +- Add 0.7 api changes section to docs [\#996](https://github.com/python-kasa/python-kasa/pull/996) (@sdb9696) +- Update README to be more approachable for new users [\#994](https://github.com/python-kasa/python-kasa/pull/994) (@rytilahti) +- Update docs with more howto examples [\#968](https://github.com/python-kasa/python-kasa/pull/968) (@sdb9696) +- Update documentation structure and start migrating to markdown [\#934](https://github.com/python-kasa/python-kasa/pull/934) (@sdb9696) - Add tutorial doctest module and enable top level await [\#919](https://github.com/python-kasa/python-kasa/pull/919) (@sdb9696) - Add warning about tapo watchdog [\#902](https://github.com/python-kasa/python-kasa/pull/902) (@rytilahti) - Move contribution instructions into docs [\#901](https://github.com/python-kasa/python-kasa/pull/901) (@rytilahti) @@ -177,23 +164,24 @@ - Enable shell extra for installing ptpython and rich [\#782](https://github.com/python-kasa/python-kasa/pull/782) (@sdb9696) - Add WallSwitch device type and autogenerate supported devices docs [\#758](https://github.com/python-kasa/python-kasa/pull/758) (@sdb9696) -**Merged pull requests:** +**Project maintenance:** -- Fix potential infinite loop if incomplete lists returned [\#920](https://github.com/python-kasa/python-kasa/pull/920) (@sdb9696) +- Drop python3.8 support [\#992](https://github.com/python-kasa/python-kasa/pull/992) (@rytilahti) +- Remove anyio dependency from pyproject.toml [\#990](https://github.com/python-kasa/python-kasa/pull/990) (@sdb9696) +- Configure mypy to run in virtual environment and fix resulting issues [\#989](https://github.com/python-kasa/python-kasa/pull/989) (@sdb9696) +- Better checking of child modules not supported by parent device [\#966](https://github.com/python-kasa/python-kasa/pull/966) (@sdb9696) +- Use freezegun for testing aes http client delays [\#954](https://github.com/python-kasa/python-kasa/pull/954) (@sdb9696) +- Fix passing custom port for dump\_devinfo [\#938](https://github.com/python-kasa/python-kasa/pull/938) (@rytilahti) +- Update release playbook [\#932](https://github.com/python-kasa/python-kasa/pull/932) (@rytilahti) - Deprecate device level light, effect and led attributes [\#916](https://github.com/python-kasa/python-kasa/pull/916) (@sdb9696) - Update cli to use common modules and remove iot specific cli testing [\#913](https://github.com/python-kasa/python-kasa/pull/913) (@sdb9696) - Deprecate is\_something attributes [\#912](https://github.com/python-kasa/python-kasa/pull/912) (@sdb9696) - Make Light and Fan a common module interface [\#911](https://github.com/python-kasa/python-kasa/pull/911) (@sdb9696) - Rename bulb interface to light and move fan and light interface to interfaces [\#910](https://github.com/python-kasa/python-kasa/pull/910) (@sdb9696) - Make module names consistent and remove redundant module casting [\#909](https://github.com/python-kasa/python-kasa/pull/909) (@sdb9696) -- Add light presets common module to devices. [\#907](https://github.com/python-kasa/python-kasa/pull/907) (@sdb9696) -- Add H100 1.5.10 and KE100 2.4.0 fixtures [\#905](https://github.com/python-kasa/python-kasa/pull/905) (@rytilahti) - Add child devices from hubs to generated list of supported devices [\#898](https://github.com/python-kasa/python-kasa/pull/898) (@sdb9696) -- Add fixture for waterleak sensor T300 [\#897](https://github.com/python-kasa/python-kasa/pull/897) (@rytilahti) - Update interfaces so they all inherit from Device [\#893](https://github.com/python-kasa/python-kasa/pull/893) (@sdb9696) -- Fix wifi scan re-querying error [\#891](https://github.com/python-kasa/python-kasa/pull/891) (@sdb9696) - Update ks240 fixture with child device query info [\#890](https://github.com/python-kasa/python-kasa/pull/890) (@sdb9696) -- Fix smartprotocol response list handler to handle null reponses [\#884](https://github.com/python-kasa/python-kasa/pull/884) (@sdb9696) - Use pydantic.v1 namespace on all pydantic versions [\#883](https://github.com/python-kasa/python-kasa/pull/883) (@rytilahti) - Update dump\_devinfo to print original exception stack on errors. [\#882](https://github.com/python-kasa/python-kasa/pull/882) (@sdb9696) - Put modules back on children for wall switches [\#881](https://github.com/python-kasa/python-kasa/pull/881) (@sdb9696) @@ -213,26 +201,20 @@ - Fix non python 3.8 compliant test [\#832](https://github.com/python-kasa/python-kasa/pull/832) (@sdb9696) - Fix CI issue with python version used by pipx to install poetry [\#831](https://github.com/python-kasa/python-kasa/pull/831) (@sdb9696) - Refactor split smartdevice tests to test\_{iot,smart}device [\#822](https://github.com/python-kasa/python-kasa/pull/822) (@rytilahti) -- Add P100 fw 1.4.0 fixture [\#820](https://github.com/python-kasa/python-kasa/pull/820) (@sdb9696) - Add pre-commit caching and fix poetry extras cache [\#817](https://github.com/python-kasa/python-kasa/pull/817) (@sdb9696) - Fix slow aestransport and cli tests [\#816](https://github.com/python-kasa/python-kasa/pull/816) (@sdb9696) - Do not run coverage on pypy and cache poetry envs [\#812](https://github.com/python-kasa/python-kasa/pull/812) (@sdb9696) - Update test framework for dynamic parametrization [\#810](https://github.com/python-kasa/python-kasa/pull/810) (@sdb9696) - Put child fixtures in subfolder [\#809](https://github.com/python-kasa/python-kasa/pull/809) (@sdb9696) -- Add iot brightness feature [\#808](https://github.com/python-kasa/python-kasa/pull/808) (@sdb9696) - Simplify device \_\_repr\_\_ [\#805](https://github.com/python-kasa/python-kasa/pull/805) (@rytilahti) - Add T315 fixture, tests for humidity&temperature modules [\#802](https://github.com/python-kasa/python-kasa/pull/802) (@rytilahti) -- Add fixture for P110 sw 1.0.7 [\#801](https://github.com/python-kasa/python-kasa/pull/801) (@rytilahti) - Do not fail fast on pypy CI jobs [\#799](https://github.com/python-kasa/python-kasa/pull/799) (@sdb9696) - Update dump\_devinfo to collect child device info [\#796](https://github.com/python-kasa/python-kasa/pull/796) (@sdb9696) - Refactor test framework [\#794](https://github.com/python-kasa/python-kasa/pull/794) (@sdb9696) -- Add updated l530 fixture 1.1.6 [\#792](https://github.com/python-kasa/python-kasa/pull/792) (@rytilahti) - Add missing firmware module import [\#774](https://github.com/python-kasa/python-kasa/pull/774) (@rytilahti) - Fix dump\_devinfo scrubbing for ks240 [\#765](https://github.com/python-kasa/python-kasa/pull/765) (@rytilahti) -- Fix devtools for P100 and add fixture [\#753](https://github.com/python-kasa/python-kasa/pull/753) (@sdb9696) -- Add H100 fixtures [\#737](https://github.com/python-kasa/python-kasa/pull/737) (@rytilahti) +- Rename and deprecate exception classes [\#739](https://github.com/python-kasa/python-kasa/pull/739) (@sdb9696) - Refactor devices into subpackages and deprecate old names [\#716](https://github.com/python-kasa/python-kasa/pull/716) (@sdb9696) -- Fix discovery cli to print devices not printed during discovery timeout [\#670](https://github.com/python-kasa/python-kasa/pull/670) (@sdb9696) ## [0.6.2.1](https://github.com/python-kasa/python-kasa/tree/0.6.2.1) (2024-02-02) @@ -242,14 +224,20 @@ - Avoid crashing on childdevice property accesses [\#732](https://github.com/python-kasa/python-kasa/pull/732) (@rytilahti) -**Merged pull requests:** +**Added support for devices:** -- Retain last two chars for children device\_id [\#733](https://github.com/python-kasa/python-kasa/pull/733) (@rytilahti) - Add TP15 fixture [\#730](https://github.com/python-kasa/python-kasa/pull/730) (@bdraco) - Add TP25 fixtures [\#729](https://github.com/python-kasa/python-kasa/pull/729) (@bdraco) + +**Project maintenance:** + - Various test code cleanups [\#725](https://github.com/python-kasa/python-kasa/pull/725) (@rytilahti) - Unignore F401 for tests [\#724](https://github.com/python-kasa/python-kasa/pull/724) (@rytilahti) +**Merged pull requests:** + +- Retain last two chars for children device\_id [\#733](https://github.com/python-kasa/python-kasa/pull/733) (@rytilahti) + ## [0.6.2](https://github.com/python-kasa/python-kasa/tree/0.6.2) (2024-01-29) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.1...0.6.2) @@ -268,16 +256,22 @@ - Fix TapoBulb state information for non-dimmable SMARTSWITCH [\#726](https://github.com/python-kasa/python-kasa/pull/726) (@sdb9696) +**Added support for devices:** + +- Update L510E\(US\) fixture with mac prefix [\#722](https://github.com/python-kasa/python-kasa/pull/722) (@sdb9696) +- Add P300 fixture [\#717](https://github.com/python-kasa/python-kasa/pull/717) (@rytilahti) + **Documentation updates:** - Add protocol and transport documentation [\#663](https://github.com/python-kasa/python-kasa/pull/663) (@sdb9696) -**Merged pull requests:** +**Project maintenance:** -- Update L510E\(US\) fixture with mac prefix [\#722](https://github.com/python-kasa/python-kasa/pull/722) (@sdb9696) - Use hashlib in place of hashes.Hash [\#714](https://github.com/python-kasa/python-kasa/pull/714) (@bdraco) - Switch from TPLinkSmartHomeProtocol to IotProtocol/XorTransport [\#710](https://github.com/python-kasa/python-kasa/pull/710) (@sdb9696) -- Add P300 fixture [\#717](https://github.com/python-kasa/python-kasa/pull/717) (@rytilahti) + +**Merged pull requests:** + - Add concrete XorTransport class with full implementation [\#646](https://github.com/python-kasa/python-kasa/pull/646) (@sdb9696) ## [0.6.1](https://github.com/python-kasa/python-kasa/tree/0.6.1) (2024-01-25) @@ -287,6 +281,8 @@ **Implemented enhancements:** - Add support for tapo wall switches \(S500D\) [\#704](https://github.com/python-kasa/python-kasa/pull/704) (@bdraco) +- 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) - 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) @@ -302,15 +298,15 @@ - Document authenticated provisioning [\#634](https://github.com/python-kasa/python-kasa/pull/634) (@rytilahti) -**Merged pull requests:** +**Project maintenance:** - 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) + +**Merged pull requests:** + - Fix overly greedy \_strip\_rich\_formatting [\#703](https://github.com/python-kasa/python-kasa/pull/703) (@bdraco) - Update readme fixture checker and readme [\#699](https://github.com/python-kasa/python-kasa/pull/699) (@rytilahti) -- 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) - 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) @@ -321,6 +317,7 @@ - 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 support for the S500 [\#705](https://github.com/python-kasa/python-kasa/pull/705) (@bdraco) - Ensure login token is only sent if aes state is ESTABLISHED [\#702](https://github.com/python-kasa/python-kasa/pull/702) (@bdraco) - 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) diff --git a/poetry.lock b/poetry.lock index 706685c3e..c59a903aa 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "aiohttp" @@ -616,18 +616,18 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.15.1" +version = "3.15.3" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.15.1-py3-none-any.whl", hash = "sha256:71b3102950e91dfc1bb4209b64be4dc8854f40e5f534428d8684f953ac847fac"}, - {file = "filelock-3.15.1.tar.gz", hash = "sha256:58a2549afdf9e02e10720eaa4d4470f56386d7a6f72edd7d0596337af8ed7ad8"}, + {file = "filelock-3.15.3-py3-none-any.whl", hash = "sha256:0151273e5b5d6cf753a61ec83b3a9b7d8821c39ae9af9d7ecf2f9e2f17404103"}, + {file = "filelock-3.15.3.tar.gz", hash = "sha256:e1199bf5194a2277273dacd50269f0d87d0682088a3c561c15674ea9005d8635"}, ] [package.extras] docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] typing = ["typing-extensions (>=4.8)"] [[package]] @@ -768,22 +768,22 @@ files = [ [[package]] name = "importlib-metadata" -version = "7.1.0" +version = "7.2.0" description = "Read metadata from Python packages" optional = true python-versions = ">=3.8" files = [ - {file = "importlib_metadata-7.1.0-py3-none-any.whl", hash = "sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570"}, - {file = "importlib_metadata-7.1.0.tar.gz", hash = "sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2"}, + {file = "importlib_metadata-7.2.0-py3-none-any.whl", hash = "sha256:04e4aad329b8b948a5711d394fa8759cb80f009225441b4f2a02bd4d8e5f426c"}, + {file = "importlib_metadata-7.2.0.tar.gz", hash = "sha256:3ff4519071ed42740522d494d04819b666541b9752c43012f85afb2cc220fcc6"}, ] [package.dependencies] zipp = ">=0.5" [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] [[package]] name = "iniconfig" @@ -1653,6 +1653,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1660,8 +1661,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1678,6 +1686,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1685,6 +1694,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, diff --git a/pyproject.toml b/pyproject.toml index 18a5c07b8..550f658ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.dev5" +version = "0.7.0" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 5846bbdbbbf0d1666e2337cfce92c2d231b4bba2 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 25 Jun 2024 17:24:05 +0100 Subject: [PATCH 492/892] Fix iot strip so the children do not have led and cloud modules (#1010) Also adds led and cloud connection on the strip parent --- kasa/iot/iotstrip.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index eea9f32c3..9c67002c7 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -21,7 +21,7 @@ ) from .iotmodule import IotModule from .iotplug import IotPlug -from .modules import Antitheft, Countdown, Schedule, Time, Usage +from .modules import Antitheft, Cloud, Countdown, Emeter, Led, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) @@ -107,6 +107,8 @@ async def _initialize_modules(self): self.add_module(Module.IotUsage, Usage(self, "schedule")) self.add_module(Module.IotTime, Time(self, "time")) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) + self.add_module(Module.Led, Led(self, "system")) + self.add_module(Module.IotCloud, Cloud(self, "cnCloud")) if self.has_emeter: _LOGGER.debug( "The device has emeter, querying its information along sysinfo" @@ -317,8 +319,12 @@ def __init__(self, host: str, parent: IotStrip, child_id: str) -> None: async def _initialize_modules(self): """Initialize modules not added in init.""" - await super()._initialize_modules() - self.add_module("time", Time(self, "time")) + if self.has_emeter: + self.add_module(Module.Energy, Emeter(self, self.emeter_type)) + self.add_module(Module.IotUsage, Usage(self, "schedule")) + self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) + self.add_module(Module.IotSchedule, Schedule(self, "schedule")) + self.add_module(Module.IotCountdown, Countdown(self, "countdown")) async def _initialize_features(self): """Initialize common features.""" From 0f5bafaa437b73454a64ca4e242ff6ecbf1696b9 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 25 Jun 2024 18:30:36 +0200 Subject: [PATCH 493/892] Require explicit feature type (#1006) Explicit > implicit. Having this previously would have avoided using a wrong type for water_alert in the first place. --- kasa/feature.py | 21 +++++++++++++-------- kasa/interfaces/energy.py | 6 ++++++ kasa/iot/iotdevice.py | 3 +++ kasa/iot/iotstrip.py | 1 + kasa/smart/modules/alarm.py | 1 + kasa/smart/modules/autooff.py | 1 + kasa/smart/modules/firmware.py | 2 ++ kasa/smart/modules/humiditysensor.py | 1 + kasa/smart/modules/reportmode.py | 1 + kasa/smart/modules/temperaturecontrol.py | 1 + kasa/smart/modules/temperaturesensor.py | 1 + kasa/smart/modules/time.py | 1 + kasa/smart/modules/waterleaksensor.py | 2 ++ kasa/smart/smartdevice.py | 5 +++++ kasa/tests/test_feature.py | 8 ++++++-- 15 files changed, 45 insertions(+), 10 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index 53532932b..e247e6616 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -129,6 +129,8 @@ class Category(Enum): id: str #: User-friendly short description name: str + #: Type of the feature + type: Feature.Type #: Name of the property that allows accessing the value attribute_getter: str | Callable | None = None #: Name of the method that allows changing the value @@ -144,8 +146,6 @@ class Category(Enum): unit_getter: str | None = None #: Category hint for downstreams category: Feature.Category = Category.Unset - #: Type of the feature - type: Feature.Type = Type.Sensor # Display hints offer a way suggest how the value should be shown to users #: Hint to help rounding the sensor values to given after-comma digits @@ -191,14 +191,19 @@ def __post_init__(self): else: self.category = Feature.Category.Info - if self.category == Feature.Category.Config and self.type in [ + if self.type in ( Feature.Type.Sensor, Feature.Type.BinarySensor, - ]: - raise ValueError( - f"Invalid type for configurable feature: {self.name} ({self.id}):" - f" {self.type}" - ) + ): + if self.category == Feature.Category.Config: + raise ValueError( + f"Invalid type for configurable feature: {self.name} ({self.id}):" + f" {self.type}" + ) + elif self.attribute_setter is not None: + raise ValueError( + f"Read-only feat defines attribute_setter: {self.name} ({self.id}):" + ) @property def value(self): diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py index c1ce3a603..76859647d 100644 --- a/kasa/interfaces/energy.py +++ b/kasa/interfaces/energy.py @@ -44,6 +44,7 @@ def _initialize_features(self): id="current_consumption", precision_hint=1, category=Feature.Category.Primary, + type=Feature.Type.Sensor, ) ) self._add_feature( @@ -56,6 +57,7 @@ def _initialize_features(self): id="consumption_today", precision_hint=3, category=Feature.Category.Info, + type=Feature.Type.Sensor, ) ) self._add_feature( @@ -68,6 +70,7 @@ def _initialize_features(self): unit="kWh", precision_hint=3, category=Feature.Category.Info, + type=Feature.Type.Sensor, ) ) if self.supports(self.ModuleFeature.CONSUMPTION_TOTAL): @@ -81,6 +84,7 @@ def _initialize_features(self): id="consumption_total", precision_hint=3, category=Feature.Category.Info, + type=Feature.Type.Sensor, ) ) if self.supports(self.ModuleFeature.VOLTAGE_CURRENT): @@ -94,6 +98,7 @@ def _initialize_features(self): id="voltage", precision_hint=1, category=Feature.Category.Primary, + type=Feature.Type.Sensor, ) ) self._add_feature( @@ -106,6 +111,7 @@ def _initialize_features(self): id="current", precision_hint=2, category=Feature.Category.Primary, + type=Feature.Type.Sensor, ) ) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index e181d7ca9..c637387ae 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -340,7 +340,9 @@ async def _initialize_features(self): name="RSSI", attribute_getter="rssi", icon="mdi:signal", + unit="dBm", category=Feature.Category.Debug, + type=Feature.Type.Sensor, ) ) # iot strips calculate on_since from the children @@ -353,6 +355,7 @@ async def _initialize_features(self): attribute_getter="on_since", icon="mdi:clock", category=Feature.Category.Info, + type=Feature.Type.Sensor, ) ) diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 9c67002c7..9d748a1d2 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -347,6 +347,7 @@ async def _initialize_features(self): attribute_getter="on_since", icon="mdi:clock", category=Feature.Category.Info, + type=Feature.Type.Sensor, ) ) for module in self._supported_modules.values(): diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index f033496a5..89f133f54 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -43,6 +43,7 @@ def _initialize_features(self): container=self, attribute_getter="source", icon="mdi:bell", + type=Feature.Type.Sensor, ) ) self._add_feature( diff --git a/kasa/smart/modules/autooff.py b/kasa/smart/modules/autooff.py index 47f69d069..0004aec43 100644 --- a/kasa/smart/modules/autooff.py +++ b/kasa/smart/modules/autooff.py @@ -56,6 +56,7 @@ def _initialize_features(self): container=self, attribute_getter="auto_off_at", category=Feature.Category.Info, + type=Feature.Type.Sensor, ) ) diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 8cbc7e55a..3dcaddd66 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -101,6 +101,7 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="current_firmware", category=Feature.Category.Debug, + type=Feature.Type.Sensor, ) ) self._add_feature( @@ -111,6 +112,7 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="latest_firmware", category=Feature.Category.Debug, + type=Feature.Type.Sensor, ) ) diff --git a/kasa/smart/modules/humiditysensor.py b/kasa/smart/modules/humiditysensor.py index ec7d51a7a..f0dcc18a4 100644 --- a/kasa/smart/modules/humiditysensor.py +++ b/kasa/smart/modules/humiditysensor.py @@ -29,6 +29,7 @@ def __init__(self, device: SmartDevice, module: str): icon="mdi:water-percent", unit="%", category=Feature.Category.Primary, + type=Feature.Type.Sensor, ) ) self._add_feature( diff --git a/kasa/smart/modules/reportmode.py b/kasa/smart/modules/reportmode.py index 704476625..79c8ae621 100644 --- a/kasa/smart/modules/reportmode.py +++ b/kasa/smart/modules/reportmode.py @@ -28,6 +28,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="report_interval", unit="s", category=Feature.Category.Debug, + type=Feature.Type.Sensor, ) ) diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index e582d77a0..dcd0da725 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -84,6 +84,7 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="mode", category=Feature.Category.Primary, + type=Feature.Type.Sensor, ) ) diff --git a/kasa/smart/modules/temperaturesensor.py b/kasa/smart/modules/temperaturesensor.py index d58ffd235..d98501508 100644 --- a/kasa/smart/modules/temperaturesensor.py +++ b/kasa/smart/modules/temperaturesensor.py @@ -29,6 +29,7 @@ def __init__(self, device: SmartDevice, module: str): icon="mdi:thermometer", category=Feature.Category.Primary, unit_getter="temperature_unit", + type=Feature.Type.Sensor, ) ) if "current_temp_exception" in device.sys_info: diff --git a/kasa/smart/modules/time.py b/kasa/smart/modules/time.py index dc4fad3fc..49a1d940e 100644 --- a/kasa/smart/modules/time.py +++ b/kasa/smart/modules/time.py @@ -30,6 +30,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="time", container=self, category=Feature.Category.Debug, + type=Feature.Type.Sensor, ) ) diff --git a/kasa/smart/modules/waterleaksensor.py b/kasa/smart/modules/waterleaksensor.py index 6dbc00eb3..9f75b9b4a 100644 --- a/kasa/smart/modules/waterleaksensor.py +++ b/kasa/smart/modules/waterleaksensor.py @@ -36,6 +36,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="status", icon="mdi:water", category=Feature.Category.Debug, + type=Feature.Type.Sensor, ) ) self._add_feature( @@ -47,6 +48,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="alert", icon="mdi:water-alert", category=Feature.Category.Primary, + type=Feature.Type.BinarySensor, ) ) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index ebe73b1c6..a5b64e527 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -242,6 +242,7 @@ async def _initialize_features(self): name="Device ID", attribute_getter="device_id", category=Feature.Category.Debug, + type=Feature.Type.Sensor, ) ) if "device_on" in self._info: @@ -266,6 +267,7 @@ async def _initialize_features(self): attribute_getter=lambda x: x._info["signal_level"], icon="mdi:signal", category=Feature.Category.Info, + type=Feature.Type.Sensor, ) ) @@ -279,6 +281,7 @@ async def _initialize_features(self): icon="mdi:signal", unit="dBm", category=Feature.Category.Debug, + type=Feature.Type.Sensor, ) ) @@ -291,6 +294,7 @@ async def _initialize_features(self): attribute_getter="ssid", icon="mdi:wifi", category=Feature.Category.Debug, + type=Feature.Type.Sensor, ) ) @@ -318,6 +322,7 @@ async def _initialize_features(self): attribute_getter="on_since", icon="mdi:clock", category=Feature.Category.Debug, + type=Feature.Type.Sensor, ) ) diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index 0fb7156d2..440c9c1b7 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -44,8 +44,11 @@ def test_feature_api(dummy_feature: Feature): assert dummy_feature.unit == "dummyunit" -def test_feature_missing_type(): - """Test that creating a feature with a setter but without type causes an error.""" +@pytest.mark.parametrize( + "read_only_type", [Feature.Type.Sensor, Feature.Type.BinarySensor] +) +def test_feature_setter_on_sensor(read_only_type): + """Test that creating a sensor feature with a setter causes an error.""" with pytest.raises(ValueError): Feature( device=DummyDevice(), # type: ignore[arg-type] @@ -53,6 +56,7 @@ def test_feature_missing_type(): name="dummy error", attribute_getter="dummygetter", attribute_setter="dummysetter", + type=read_only_type, ) From 07fa0d7a7b028595602df9378d6a6238c4fa656b Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 25 Jun 2024 18:58:07 +0100 Subject: [PATCH 494/892] Fix post update hook for iot child devices (#1011) `_post_update_hook` not being called on child `iot` devices, causing missing emeter features for children --- kasa/iot/iotstrip.py | 3 +++ kasa/tests/test_strip.py | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 9d748a1d2..e64ace051 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -361,6 +361,9 @@ async def update(self, update_children: bool = True): Needed for properties that are decorated with `requires_update`. """ await self._modular_update({}) + for module in self._modules.values(): + module._post_update_hook() + if not self._features: await self._initialize_features() diff --git a/kasa/tests/test_strip.py b/kasa/tests/test_strip.py index 4c576d1b2..73d10bb7d 100644 --- a/kasa/tests/test_strip.py +++ b/kasa/tests/test_strip.py @@ -2,10 +2,10 @@ import pytest -from kasa import KasaException +from kasa import Device, KasaException, Module from kasa.iot import IotStrip -from .conftest import handle_turn_on, strip, turn_on +from .conftest import handle_turn_on, strip, strip_iot, turn_on @strip @@ -147,3 +147,16 @@ def test_children_api(dev): first = dev.children[0] first_by_get_child_device = dev.get_child_device(first.device_id) assert first == first_by_get_child_device + + +@strip_iot +async def test_children_energy(dev: Device): + if Module.Energy not in dev.modules: + pytest.skip(f"skipping device {dev.model} does not support energy") + + for plug in dev.children: + # For now all known strips with energy support these + energy = plug.modules[Module.Energy] + assert "voltage" in energy._module_features + assert "current" in energy._module_features + assert "current_consumption" in energy._module_features From b80e3c916a29acec4f6702f1f8ee46c3d061d799 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 25 Jun 2024 20:00:39 +0200 Subject: [PATCH 495/892] Remove frost_protection feature (#1009) This provides the same functionality as the state in `TemperatureControl`, so we should not expose this separately. --- kasa/smart/modules/frostprotection.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/kasa/smart/modules/frostprotection.py b/kasa/smart/modules/frostprotection.py index ee93d2994..f1811012f 100644 --- a/kasa/smart/modules/frostprotection.py +++ b/kasa/smart/modules/frostprotection.py @@ -2,15 +2,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from ...feature import Feature from ..smartmodule import SmartModule -# TODO: this may not be necessary with __future__.annotations -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - class FrostProtection(SmartModule): """Implementation for frost protection module. @@ -19,23 +12,8 @@ class FrostProtection(SmartModule): """ REQUIRED_COMPONENT = "frost_protection" - # TODO: the information required for current features do not require this query QUERY_GETTER_NAME = "get_frost_protection" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) - self._add_feature( - Feature( - device, - "frost_protection_enabled", - name="Frost protection enabled", - container=self, - attribute_getter="enabled", - attribute_setter="set_enabled", - type=Feature.Type.Switch, - ) - ) - @property def enabled(self) -> bool: """Return True if frost protection is on.""" From f7557daa32be5d36cd52ea65e7040af2b291ec3b Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 25 Jun 2024 19:52:25 +0100 Subject: [PATCH 496/892] Disable lighttransition module on child devices (#1013) Module is not working properly for updates on KS240 so temporarily disable until fixed --- kasa/smart/modules/lighttransition.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index 1e5ba0cf1..fa73cd681 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -183,11 +183,9 @@ def query(self) -> dict: return {self.QUERY_GETTER_NAME: None} async def _check_supported(self): - """Additional check to see if the module is supported by the device. - - Parent devices that report components of children such as ks240 will not have - the brightness value is sysinfo. - """ - # Look in _device.sys_info here because self.data is either sys_info or - # get_preset_rules depending on whether it's a child device or not. + """Additional check to see if the module is supported by the device.""" + # TODO Temporarily disabled on child light devices until module fixed + # to support updates + if self._device._parent is not None: + return False return "brightness" in self._device.sys_info From 0a852431998190dfe0faa105f384bd21be4a0c36 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 25 Jun 2024 21:02:17 +0200 Subject: [PATCH 497/892] Prepare 0.7.0.1 (#1015) ## [0.7.0.1](https://github.com/python-kasa/python-kasa/tree/0.7.0.1) (2024-06-25) This patch release fixes some minor issues found out during testing against all new homeassistant platforms. [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0...0.7.0.1) **Fixed bugs:** - Disable lighttransition module on child devices [\#1013](https://github.com/python-kasa/python-kasa/pull/1013) (@sdb9696) - Fix post update hook for iot child devices [\#1011](https://github.com/python-kasa/python-kasa/pull/1011) (@sdb9696) - Fix iot strip so the children do not have led and cloud modules [\#1010](https://github.com/python-kasa/python-kasa/pull/1010) (@sdb9696) - Require explicit feature type [\#1006](https://github.com/python-kasa/python-kasa/pull/1006) (@rytilahti) **Merged pull requests:** - Remove frost\_protection feature [\#1009](https://github.com/python-kasa/python-kasa/pull/1009) (@rytilahti) --- CHANGELOG.md | 18 ++++ poetry.lock | 268 ++++++++++++++++++++++++------------------------- pyproject.toml | 2 +- 3 files changed, 148 insertions(+), 140 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b25d5c466..ac6746f91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog + +## [0.7.0.1](https://github.com/python-kasa/python-kasa/tree/0.7.0.1) (2024-06-25) + +This patch release fixes some minor issues found out during testing against all new homeassistant platforms. + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0...0.7.0.1) + +**Fixed bugs:** + +- Disable lighttransition module on child devices [\#1013](https://github.com/python-kasa/python-kasa/pull/1013) (@sdb9696) +- Fix post update hook for iot child devices [\#1011](https://github.com/python-kasa/python-kasa/pull/1011) (@sdb9696) +- Fix iot strip so the children do not have led and cloud modules [\#1010](https://github.com/python-kasa/python-kasa/pull/1010) (@sdb9696) +- Require explicit feature type [\#1006](https://github.com/python-kasa/python-kasa/pull/1006) (@rytilahti) + +**Merged pull requests:** + +- Remove frost\_protection feature [\#1009](https://github.com/python-kasa/python-kasa/pull/1009) (@rytilahti) + ## [0.7.0](https://github.com/python-kasa/python-kasa/tree/0.7.0) (2024-06-23) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2.1...0.7.0) diff --git a/poetry.lock b/poetry.lock index c59a903aa..aec24c096 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "aiohttp" @@ -459,63 +459,63 @@ files = [ [[package]] name = "coverage" -version = "7.5.3" +version = "7.5.4" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a6519d917abb15e12380406d721e37613e2a67d166f9fb7e5a8ce0375744cd45"}, - {file = "coverage-7.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aea7da970f1feccf48be7335f8b2ca64baf9b589d79e05b9397a06696ce1a1ec"}, - {file = "coverage-7.5.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:923b7b1c717bd0f0f92d862d1ff51d9b2b55dbbd133e05680204465f454bb286"}, - {file = "coverage-7.5.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62bda40da1e68898186f274f832ef3e759ce929da9a9fd9fcf265956de269dbc"}, - {file = "coverage-7.5.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8b7339180d00de83e930358223c617cc343dd08e1aa5ec7b06c3a121aec4e1d"}, - {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:25a5caf742c6195e08002d3b6c2dd6947e50efc5fc2c2205f61ecb47592d2d83"}, - {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:05ac5f60faa0c704c0f7e6a5cbfd6f02101ed05e0aee4d2822637a9e672c998d"}, - {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:239a4e75e09c2b12ea478d28815acf83334d32e722e7433471fbf641c606344c"}, - {file = "coverage-7.5.3-cp310-cp310-win32.whl", hash = "sha256:a5812840d1d00eafae6585aba38021f90a705a25b8216ec7f66aebe5b619fb84"}, - {file = "coverage-7.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:33ca90a0eb29225f195e30684ba4a6db05dbef03c2ccd50b9077714c48153cac"}, - {file = "coverage-7.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f81bc26d609bf0fbc622c7122ba6307993c83c795d2d6f6f6fd8c000a770d974"}, - {file = "coverage-7.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7cec2af81f9e7569280822be68bd57e51b86d42e59ea30d10ebdbb22d2cb7232"}, - {file = "coverage-7.5.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55f689f846661e3f26efa535071775d0483388a1ccfab899df72924805e9e7cd"}, - {file = "coverage-7.5.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50084d3516aa263791198913a17354bd1dc627d3c1639209640b9cac3fef5807"}, - {file = "coverage-7.5.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:341dd8f61c26337c37988345ca5c8ccabeff33093a26953a1ac72e7d0103c4fb"}, - {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ab0b028165eea880af12f66086694768f2c3139b2c31ad5e032c8edbafca6ffc"}, - {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5bc5a8c87714b0c67cfeb4c7caa82b2d71e8864d1a46aa990b5588fa953673b8"}, - {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:38a3b98dae8a7c9057bd91fbf3415c05e700a5114c5f1b5b0ea5f8f429ba6614"}, - {file = "coverage-7.5.3-cp311-cp311-win32.whl", hash = "sha256:fcf7d1d6f5da887ca04302db8e0e0cf56ce9a5e05f202720e49b3e8157ddb9a9"}, - {file = "coverage-7.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:8c836309931839cca658a78a888dab9676b5c988d0dd34ca247f5f3e679f4e7a"}, - {file = "coverage-7.5.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:296a7d9bbc598e8744c00f7a6cecf1da9b30ae9ad51c566291ff1314e6cbbed8"}, - {file = "coverage-7.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:34d6d21d8795a97b14d503dcaf74226ae51eb1f2bd41015d3ef332a24d0a17b3"}, - {file = "coverage-7.5.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e317953bb4c074c06c798a11dbdd2cf9979dbcaa8ccc0fa4701d80042d4ebf1"}, - {file = "coverage-7.5.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:705f3d7c2b098c40f5b81790a5fedb274113373d4d1a69e65f8b68b0cc26f6db"}, - {file = "coverage-7.5.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1196e13c45e327d6cd0b6e471530a1882f1017eb83c6229fc613cd1a11b53cd"}, - {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:015eddc5ccd5364dcb902eaecf9515636806fa1e0d5bef5769d06d0f31b54523"}, - {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fd27d8b49e574e50caa65196d908f80e4dff64d7e592d0c59788b45aad7e8b35"}, - {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:33fc65740267222fc02975c061eb7167185fef4cc8f2770267ee8bf7d6a42f84"}, - {file = "coverage-7.5.3-cp312-cp312-win32.whl", hash = "sha256:7b2a19e13dfb5c8e145c7a6ea959485ee8e2204699903c88c7d25283584bfc08"}, - {file = "coverage-7.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0bbddc54bbacfc09b3edaec644d4ac90c08ee8ed4844b0f86227dcda2d428fcb"}, - {file = "coverage-7.5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f78300789a708ac1f17e134593f577407d52d0417305435b134805c4fb135adb"}, - {file = "coverage-7.5.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b368e1aee1b9b75757942d44d7598dcd22a9dbb126affcbba82d15917f0cc155"}, - {file = "coverage-7.5.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f836c174c3a7f639bded48ec913f348c4761cbf49de4a20a956d3431a7c9cb24"}, - {file = "coverage-7.5.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:244f509f126dc71369393ce5fea17c0592c40ee44e607b6d855e9c4ac57aac98"}, - {file = "coverage-7.5.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4c2872b3c91f9baa836147ca33650dc5c172e9273c808c3c3199c75490e709d"}, - {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dd4b3355b01273a56b20c219e74e7549e14370b31a4ffe42706a8cda91f19f6d"}, - {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f542287b1489c7a860d43a7d8883e27ca62ab84ca53c965d11dac1d3a1fab7ce"}, - {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:75e3f4e86804023e991096b29e147e635f5e2568f77883a1e6eed74512659ab0"}, - {file = "coverage-7.5.3-cp38-cp38-win32.whl", hash = "sha256:c59d2ad092dc0551d9f79d9d44d005c945ba95832a6798f98f9216ede3d5f485"}, - {file = "coverage-7.5.3-cp38-cp38-win_amd64.whl", hash = "sha256:fa21a04112c59ad54f69d80e376f7f9d0f5f9123ab87ecd18fbb9ec3a2beed56"}, - {file = "coverage-7.5.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5102a92855d518b0996eb197772f5ac2a527c0ec617124ad5242a3af5e25f85"}, - {file = "coverage-7.5.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d1da0a2e3b37b745a2b2a678a4c796462cf753aebf94edcc87dcc6b8641eae31"}, - {file = "coverage-7.5.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8383a6c8cefba1b7cecc0149415046b6fc38836295bc4c84e820872eb5478b3d"}, - {file = "coverage-7.5.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9aad68c3f2566dfae84bf46295a79e79d904e1c21ccfc66de88cd446f8686341"}, - {file = "coverage-7.5.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e079c9ec772fedbade9d7ebc36202a1d9ef7291bc9b3a024ca395c4d52853d7"}, - {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bde997cac85fcac227b27d4fb2c7608a2c5f6558469b0eb704c5726ae49e1c52"}, - {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:990fb20b32990b2ce2c5f974c3e738c9358b2735bc05075d50a6f36721b8f303"}, - {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3d5a67f0da401e105753d474369ab034c7bae51a4c31c77d94030d59e41df5bd"}, - {file = "coverage-7.5.3-cp39-cp39-win32.whl", hash = "sha256:e08c470c2eb01977d221fd87495b44867a56d4d594f43739a8028f8646a51e0d"}, - {file = "coverage-7.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:1d2a830ade66d3563bb61d1e3c77c8def97b30ed91e166c67d0632c018f380f0"}, - {file = "coverage-7.5.3-pp38.pp39.pp310-none-any.whl", hash = "sha256:3538d8fb1ee9bdd2e2692b3b18c22bb1c19ffbefd06880f5ac496e42d7bb3884"}, - {file = "coverage-7.5.3.tar.gz", hash = "sha256:04aefca5190d1dc7a53a4c1a5a7f8568811306d7a8ee231c42fb69215571944f"}, + {file = "coverage-7.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cfb5a4f556bb51aba274588200a46e4dd6b505fb1a5f8c5ae408222eb416f99"}, + {file = "coverage-7.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2174e7c23e0a454ffe12267a10732c273243b4f2d50d07544a91198f05c48f47"}, + {file = "coverage-7.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2214ee920787d85db1b6a0bd9da5f8503ccc8fcd5814d90796c2f2493a2f4d2e"}, + {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1137f46adb28e3813dec8c01fefadcb8c614f33576f672962e323b5128d9a68d"}, + {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b385d49609f8e9efc885790a5a0e89f2e3ae042cdf12958b6034cc442de428d3"}, + {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4a474f799456e0eb46d78ab07303286a84a3140e9700b9e154cfebc8f527016"}, + {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5cd64adedf3be66f8ccee418473c2916492d53cbafbfcff851cbec5a8454b136"}, + {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e564c2cf45d2f44a9da56f4e3a26b2236504a496eb4cb0ca7221cd4cc7a9aca9"}, + {file = "coverage-7.5.4-cp310-cp310-win32.whl", hash = "sha256:7076b4b3a5f6d2b5d7f1185fde25b1e54eb66e647a1dfef0e2c2bfaf9b4c88c8"}, + {file = "coverage-7.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:018a12985185038a5b2bcafab04ab833a9a0f2c59995b3cec07e10074c78635f"}, + {file = "coverage-7.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db14f552ac38f10758ad14dd7b983dbab424e731588d300c7db25b6f89e335b5"}, + {file = "coverage-7.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3257fdd8e574805f27bb5342b77bc65578e98cbc004a92232106344053f319ba"}, + {file = "coverage-7.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a6612c99081d8d6134005b1354191e103ec9705d7ba2754e848211ac8cacc6b"}, + {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45d3cbd94159c468b9b8c5a556e3f6b81a8d1af2a92b77320e887c3e7a5d080"}, + {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed550e7442f278af76d9d65af48069f1fb84c9f745ae249c1a183c1e9d1b025c"}, + {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a892be37ca35eb5019ec85402c3371b0f7cda5ab5056023a7f13da0961e60da"}, + {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8192794d120167e2a64721d88dbd688584675e86e15d0569599257566dec9bf0"}, + {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:820bc841faa502e727a48311948e0461132a9c8baa42f6b2b84a29ced24cc078"}, + {file = "coverage-7.5.4-cp311-cp311-win32.whl", hash = "sha256:6aae5cce399a0f065da65c7bb1e8abd5c7a3043da9dceb429ebe1b289bc07806"}, + {file = "coverage-7.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2e344d6adc8ef81c5a233d3a57b3c7d5181f40e79e05e1c143da143ccb6377d"}, + {file = "coverage-7.5.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:54317c2b806354cbb2dc7ac27e2b93f97096912cc16b18289c5d4e44fc663233"}, + {file = "coverage-7.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:042183de01f8b6d531e10c197f7f0315a61e8d805ab29c5f7b51a01d62782747"}, + {file = "coverage-7.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bb74ed465d5fb204b2ec41d79bcd28afccf817de721e8a807d5141c3426638"}, + {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3d45ff86efb129c599a3b287ae2e44c1e281ae0f9a9bad0edc202179bcc3a2e"}, + {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5013ed890dc917cef2c9f765c4c6a8ae9df983cd60dbb635df8ed9f4ebc9f555"}, + {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1014fbf665fef86cdfd6cb5b7371496ce35e4d2a00cda501cf9f5b9e6fced69f"}, + {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3684bc2ff328f935981847082ba4fdc950d58906a40eafa93510d1b54c08a66c"}, + {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:581ea96f92bf71a5ec0974001f900db495488434a6928a2ca7f01eee20c23805"}, + {file = "coverage-7.5.4-cp312-cp312-win32.whl", hash = "sha256:73ca8fbc5bc622e54627314c1a6f1dfdd8db69788f3443e752c215f29fa87a0b"}, + {file = "coverage-7.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:cef4649ec906ea7ea5e9e796e68b987f83fa9a718514fe147f538cfeda76d7a7"}, + {file = "coverage-7.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdd31315fc20868c194130de9ee6bfd99755cc9565edff98ecc12585b90be882"}, + {file = "coverage-7.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:02ff6e898197cc1e9fa375581382b72498eb2e6d5fc0b53f03e496cfee3fac6d"}, + {file = "coverage-7.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d05c16cf4b4c2fc880cb12ba4c9b526e9e5d5bb1d81313d4d732a5b9fe2b9d53"}, + {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5986ee7ea0795a4095ac4d113cbb3448601efca7f158ec7f7087a6c705304e4"}, + {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df54843b88901fdc2f598ac06737f03d71168fd1175728054c8f5a2739ac3e4"}, + {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ab73b35e8d109bffbda9a3e91c64e29fe26e03e49addf5b43d85fc426dde11f9"}, + {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:aea072a941b033813f5e4814541fc265a5c12ed9720daef11ca516aeacd3bd7f"}, + {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16852febd96acd953b0d55fc842ce2dac1710f26729b31c80b940b9afcd9896f"}, + {file = "coverage-7.5.4-cp38-cp38-win32.whl", hash = "sha256:8f894208794b164e6bd4bba61fc98bf6b06be4d390cf2daacfa6eca0a6d2bb4f"}, + {file = "coverage-7.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:e2afe743289273209c992075a5a4913e8d007d569a406ffed0bd080ea02b0633"}, + {file = "coverage-7.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95c3a8cb0463ba9f77383d0fa8c9194cf91f64445a63fc26fb2327e1e1eb088"}, + {file = "coverage-7.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d7564cc09dd91b5a6001754a5b3c6ecc4aba6323baf33a12bd751036c998be4"}, + {file = "coverage-7.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44da56a2589b684813f86d07597fdf8a9c6ce77f58976727329272f5a01f99f7"}, + {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e16f3d6b491c48c5ae726308e6ab1e18ee830b4cdd6913f2d7f77354b33f91c8"}, + {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbc5958cb471e5a5af41b0ddaea96a37e74ed289535e8deca404811f6cb0bc3d"}, + {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a04e990a2a41740b02d6182b498ee9796cf60eefe40cf859b016650147908029"}, + {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ddbd2f9713a79e8e7242d7c51f1929611e991d855f414ca9996c20e44a895f7c"}, + {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b1ccf5e728ccf83acd313c89f07c22d70d6c375a9c6f339233dcf792094bcbf7"}, + {file = "coverage-7.5.4-cp39-cp39-win32.whl", hash = "sha256:56b4eafa21c6c175b3ede004ca12c653a88b6f922494b023aeb1e836df953ace"}, + {file = "coverage-7.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:65e528e2e921ba8fd67d9055e6b9f9e34b21ebd6768ae1c1723f4ea6ace1234d"}, + {file = "coverage-7.5.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:79b356f3dd5b26f3ad23b35c75dbdaf1f9e2450b6bcefc6d0825ea0aa3f86ca5"}, + {file = "coverage-7.5.4.tar.gz", hash = "sha256:a44963520b069e12789d0faea4e9fdb1e410cdc4aab89d94f7f55cbb7fef0353"}, ] [package.dependencies] @@ -616,13 +616,13 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.15.3" +version = "3.15.4" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.15.3-py3-none-any.whl", hash = "sha256:0151273e5b5d6cf753a61ec83b3a9b7d8821c39ae9af9d7ecf2f9e2f17404103"}, - {file = "filelock-3.15.3.tar.gz", hash = "sha256:e1199bf5194a2277273dacd50269f0d87d0682088a3c561c15674ea9005d8635"}, + {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, + {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, ] [package.extras] @@ -768,13 +768,13 @@ files = [ [[package]] name = "importlib-metadata" -version = "7.2.0" +version = "8.0.0" description = "Read metadata from Python packages" optional = true python-versions = ">=3.8" files = [ - {file = "importlib_metadata-7.2.0-py3-none-any.whl", hash = "sha256:04e4aad329b8b948a5711d394fa8759cb80f009225441b4f2a02bd4d8e5f426c"}, - {file = "importlib_metadata-7.2.0.tar.gz", hash = "sha256:3ff4519071ed42740522d494d04819b666541b9752c43012f85afb2cc220fcc6"}, + {file = "importlib_metadata-8.0.0-py3-none-any.whl", hash = "sha256:15584cf2b1bf449d98ff8a6ff1abef57bf20f3ac6454f431736cd3e660921b2f"}, + {file = "importlib_metadata-8.0.0.tar.gz", hash = "sha256:188bd24e4c346d3f0a933f275c2fec67050326a856b9a359881d7c2a697e8812"}, ] [package.dependencies] @@ -834,38 +834,38 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "kasa-crypt" -version = "0.4.2" +version = "0.4.4" description = "Fast kasa crypt" optional = true python-versions = "<4.0,>=3.7" files = [ - {file = "kasa_crypt-0.4.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:38e781ad1ec940ac7551fa3e6e22890e1cf60aa914600d8dc78054e3c431ba68"}, - {file = "kasa_crypt-0.4.2-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:28dbb3dbcbd8c2a17b14248a6c6982740df0f3755a97a9bf4843d52b91612e7a"}, - {file = "kasa_crypt-0.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:584f67653590c0b1dc07214d08553b12bd711109fcbe81eb33437d2e76de3c66"}, - {file = "kasa_crypt-0.4.2-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:cad8534435631d6efe17cd67d3c6d2eba0801d7db0ef3f21a10bfcbb830ac3fb"}, - {file = "kasa_crypt-0.4.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4f7fc204d08a7567c498d4653b8b19bd7931d26bf569991b8087ceba6bb0ed24"}, - {file = "kasa_crypt-0.4.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a3ed8fb6e76d7d1c7a69673d3a351f75da00bf778aa6f7d7621ebbb712a7bd33"}, - {file = "kasa_crypt-0.4.2-cp310-cp310-win32.whl", hash = "sha256:9d72242a9bc86480a3e11557e9b774cdc82baa880444eb4bbb96bf50592f8f7e"}, - {file = "kasa_crypt-0.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:407109b22f18cc998a942a87254e6dad6306a3079f871e74ac50a8db9280b674"}, - {file = "kasa_crypt-0.4.2-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:c34476b8f5a3570b6215e452954ccadcb15a42b5e7efe015453c2c6270a14cad"}, - {file = "kasa_crypt-0.4.2-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:6b4f685baf638289d574ff3516a5f2251ba7ea35fee91ddc32b53a8a6d3fef63"}, - {file = "kasa_crypt-0.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651ad0b0a9a207e0591940a85a4c00e086d25c8a257af3712be4f0ca952f25e2"}, - {file = "kasa_crypt-0.4.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:61a12595cfc7e6a77405fee2f592b6194a8a35e36c7366f662539f9555e881ac"}, - {file = "kasa_crypt-0.4.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:943fe97355635606fbb67aff2de2510e3fe9c7537692798b9692c79bc9ce054e"}, - {file = "kasa_crypt-0.4.2-cp311-cp311-win32.whl", hash = "sha256:37208dc72eac69638b06ddb8c1d3dcabd6a5dac4b98b36378201fb544ed5da0d"}, - {file = "kasa_crypt-0.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:34626074a7a8864044e4cfd131fd04871988b8ada2bc0604248996f42c24965a"}, - {file = "kasa_crypt-0.4.2-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:56193f7954fe5c2895299f36f0b3665a9874152900e4935e48d0d292eef93003"}, - {file = "kasa_crypt-0.4.2-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:1f6080182cbe23732560e73629a763b6b669100da7ff24b245d49f8fab107b62"}, - {file = "kasa_crypt-0.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55326c9f6d5c79a5a15cead3a81c9bb422ce1bc43a2019482753e8cd61df596c"}, - {file = "kasa_crypt-0.4.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4f86433ee2e322847f0539cd84187732a72840560204d5a06561f597214fa4d2"}, - {file = "kasa_crypt-0.4.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bf1a7377c1bcae52aeda4bcb6494a530b58c4a85b42b61e90d4bc4b65348b6b1"}, - {file = "kasa_crypt-0.4.2-cp312-cp312-win32.whl", hash = "sha256:76a5e43c7292acfa2c05628a985f5f9550cba8bfeb62c7d5bbadcea5a53e349f"}, - {file = "kasa_crypt-0.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:7d4ecefb7809084e18292f015e769cf372f1156fa0af143682e0e4eafdce6cb8"}, - {file = "kasa_crypt-0.4.2-pp310-pypy310_pp73-macosx_11_0_x86_64.whl", hash = "sha256:dd6a52f8ae1eee7ca0872636c22515e45494a781265c9c1da6be704478a47d05"}, - {file = "kasa_crypt-0.4.2-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:7b61c8b355925dfce9551be484c632dd7d328da36d2380ed768ff37920a6b031"}, - {file = "kasa_crypt-0.4.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce79a24da498f50ab23609fd01c983b055e3cd6aca61f12f75ac90c024d6984"}, - {file = "kasa_crypt-0.4.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e94fe3090f7f6e77679f665136f41fc574d2fbf34b08e15b8c34d93ed311693"}, - {file = "kasa_crypt-0.4.2.tar.gz", hash = "sha256:fb2af19ff2cdec5c6403ba256d1b9f7e2e57efa676fa09d719f554f6dfb4505c"}, + {file = "kasa_crypt-0.4.4-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:c2791be3a7ac64d0de0c4d0ecf85d33fd8aa5bcfce3148ce4558703e721ca16b"}, + {file = "kasa_crypt-0.4.4-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:2da1d08151690ab6ade7a80168238964eb7672ddd3defb5188c713411b210a6a"}, + {file = "kasa_crypt-0.4.4-cp310-cp310-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c8db609ec73173c48519f860b2455b311a098b7203573fb8ae0ab52862d603d"}, + {file = "kasa_crypt-0.4.4-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:599b3eed3cadc79dda4e826f96740ddee1f6fcdd4b52a6a922395afad6154fb7"}, + {file = "kasa_crypt-0.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ca1caa741be2e67fd4c84098ecd8d8c2ce1c19330e737435edaef541b867d34a"}, + {file = "kasa_crypt-0.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d027d808e22dc944a23f4f1211fc0fe25e648498ff3817b9d78444bc75cc8d45"}, + {file = "kasa_crypt-0.4.4-cp310-cp310-win32.whl", hash = "sha256:28918bb02bd4a87aab3baefe686cc249c9f97f3408dc8e881d120701851d837c"}, + {file = "kasa_crypt-0.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:c442a7db3fd3ff9ad75e6b25ca9a970af800d7968f7187da67207eab136b7f12"}, + {file = "kasa_crypt-0.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:04fad5f981e734ab1b269922a1175bc506d5498681778b3d61561422619d6e6d"}, + {file = "kasa_crypt-0.4.4-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:a54040539fe8293a7dd20fcf5e613ba4bdcafe15a8d9eeff1cc2805500a0c2d9"}, + {file = "kasa_crypt-0.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a0a0981255225fd5671ffed85f2bfc68b0ac8525b5d424a703aaa1d0f8f4cc2"}, + {file = "kasa_crypt-0.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fa2bcbf7c4bb2af4a86c553fb8df47466c06f5060d5c21253a4ecd9ee2237ef4"}, + {file = "kasa_crypt-0.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:99518489cb93d93c6c2e5ac4e30ad6838bb64c8365e8c3a37204e7f4228805ca"}, + {file = "kasa_crypt-0.4.4-cp311-cp311-win32.whl", hash = "sha256:431223a614f868a253786da7b137a8597c8ce83ed71a8bc10ffe9e56f7a8ba4d"}, + {file = "kasa_crypt-0.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:c3d60a642985c3c7c9b598e19da537566803d2f78a42d0be5a7231d717239f11"}, + {file = "kasa_crypt-0.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:038a16270b15d9a9845ad4ba66f76cbf05109855e40afb6a62d7b99e73ba55a3"}, + {file = "kasa_crypt-0.4.4-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:5cc150ef1bd2a330903557f806e7b671fe59f15fd37337f69ea0d7872cbffdde"}, + {file = "kasa_crypt-0.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c45838d4b361f76615be72ee9b238681c47330f09cc3b0eb830095b063a262c2"}, + {file = "kasa_crypt-0.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:138479985246ebc6be5d9bb896e48860d72a280e068d798af93acd2a210031c1"}, + {file = "kasa_crypt-0.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:806dd2f7a8c6d2242513a78c144a63664817b3f0b6e149166b87db9a6017d742"}, + {file = "kasa_crypt-0.4.4-cp312-cp312-win32.whl", hash = "sha256:791900085be025dbf7052f1e44c176e957556b1d04b6da4a602fc4ddc23f87b0"}, + {file = "kasa_crypt-0.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:9c7d136bfcd74ac30ed5c10cb96c46a4e2eb90bd52974a0dbbc9c6d3e90d7699"}, + {file = "kasa_crypt-0.4.4-pp310-pypy310_pp73-macosx_14_0_arm64.whl", hash = "sha256:b47ecee24bc17bb80ed8c24d8b008d92610a3500c56368b062627ff114688262"}, + {file = "kasa_crypt-0.4.4-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:bd85d206856f866e117186247d161550bf3d5309d1cf07a2e7a3e5785660dd60"}, + {file = "kasa_crypt-0.4.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc37f7302943b5ab0562084df01ec39422e5cd13ba420cbb35895a4bb19ccbb"}, + {file = "kasa_crypt-0.4.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ae739287f220e2e1b3349cf1aacd37a8abf701c97755c9bd53d6168ad41df2f1"}, + {file = "kasa_crypt-0.4.4.tar.gz", hash = "sha256:cc31749e44a309459a71802ae8471a9d5ad6a7656938a44af64b93a8c3873ccd"}, ] [[package]] @@ -1092,38 +1092,38 @@ files = [ [[package]] name = "mypy" -version = "1.10.0" +version = "1.10.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da1cbf08fb3b851ab3b9523a884c232774008267b1f83371ace57f412fe308c2"}, - {file = "mypy-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12b6bfc1b1a66095ab413160a6e520e1dc076a28f3e22f7fb25ba3b000b4ef99"}, - {file = "mypy-1.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e36fb078cce9904c7989b9693e41cb9711e0600139ce3970c6ef814b6ebc2b2"}, - {file = "mypy-1.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2b0695d605ddcd3eb2f736cd8b4e388288c21e7de85001e9f85df9187f2b50f9"}, - {file = "mypy-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:cd777b780312ddb135bceb9bc8722a73ec95e042f911cc279e2ec3c667076051"}, - {file = "mypy-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3be66771aa5c97602f382230165b856c231d1277c511c9a8dd058be4784472e1"}, - {file = "mypy-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8b2cbaca148d0754a54d44121b5825ae71868c7592a53b7292eeb0f3fdae95ee"}, - {file = "mypy-1.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ec404a7cbe9fc0e92cb0e67f55ce0c025014e26d33e54d9e506a0f2d07fe5de"}, - {file = "mypy-1.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e22e1527dc3d4aa94311d246b59e47f6455b8729f4968765ac1eacf9a4760bc7"}, - {file = "mypy-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:a87dbfa85971e8d59c9cc1fcf534efe664d8949e4c0b6b44e8ca548e746a8d53"}, - {file = "mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b"}, - {file = "mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30"}, - {file = "mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e"}, - {file = "mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5"}, - {file = "mypy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda"}, - {file = "mypy-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9fd50226364cd2737351c79807775136b0abe084433b55b2e29181a4c3c878c0"}, - {file = "mypy-1.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f90cff89eea89273727d8783fef5d4a934be2fdca11b47def50cf5d311aff727"}, - {file = "mypy-1.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcfc70599efde5c67862a07a1aaf50e55bce629ace26bb19dc17cece5dd31ca4"}, - {file = "mypy-1.10.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:075cbf81f3e134eadaf247de187bd604748171d6b79736fa9b6c9685b4083061"}, - {file = "mypy-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:3f298531bca95ff615b6e9f2fc0333aae27fa48052903a0ac90215021cdcfa4f"}, - {file = "mypy-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa7ef5244615a2523b56c034becde4e9e3f9b034854c93639adb667ec9ec2976"}, - {file = "mypy-1.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3236a4c8f535a0631f85f5fcdffba71c7feeef76a6002fcba7c1a8e57c8be1ec"}, - {file = "mypy-1.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a2b5cdbb5dd35aa08ea9114436e0d79aceb2f38e32c21684dcf8e24e1e92821"}, - {file = "mypy-1.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92f93b21c0fe73dc00abf91022234c79d793318b8a96faac147cd579c1671746"}, - {file = "mypy-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:28d0e038361b45f099cc086d9dd99c15ff14d0188f44ac883010e172ce86c38a"}, - {file = "mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee"}, - {file = "mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131"}, + {file = "mypy-1.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e36f229acfe250dc660790840916eb49726c928e8ce10fbdf90715090fe4ae02"}, + {file = "mypy-1.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:51a46974340baaa4145363b9e051812a2446cf583dfaeba124af966fa44593f7"}, + {file = "mypy-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:901c89c2d67bba57aaaca91ccdb659aa3a312de67f23b9dfb059727cce2e2e0a"}, + {file = "mypy-1.10.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0cd62192a4a32b77ceb31272d9e74d23cd88c8060c34d1d3622db3267679a5d9"}, + {file = "mypy-1.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:a2cbc68cb9e943ac0814c13e2452d2046c2f2b23ff0278e26599224cf164e78d"}, + {file = "mypy-1.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bd6f629b67bb43dc0d9211ee98b96d8dabc97b1ad38b9b25f5e4c4d7569a0c6a"}, + {file = "mypy-1.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1bbb3a6f5ff319d2b9d40b4080d46cd639abe3516d5a62c070cf0114a457d84"}, + {file = "mypy-1.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8edd4e9bbbc9d7b79502eb9592cab808585516ae1bcc1446eb9122656c6066f"}, + {file = "mypy-1.10.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6166a88b15f1759f94a46fa474c7b1b05d134b1b61fca627dd7335454cc9aa6b"}, + {file = "mypy-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:5bb9cd11c01c8606a9d0b83ffa91d0b236a0e91bc4126d9ba9ce62906ada868e"}, + {file = "mypy-1.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d8681909f7b44d0b7b86e653ca152d6dff0eb5eb41694e163c6092124f8246d7"}, + {file = "mypy-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:378c03f53f10bbdd55ca94e46ec3ba255279706a6aacaecac52ad248f98205d3"}, + {file = "mypy-1.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bacf8f3a3d7d849f40ca6caea5c055122efe70e81480c8328ad29c55c69e93e"}, + {file = "mypy-1.10.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:701b5f71413f1e9855566a34d6e9d12624e9e0a8818a5704d74d6b0402e66c04"}, + {file = "mypy-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c4c2992f6ea46ff7fce0072642cfb62af7a2484efe69017ed8b095f7b39ef31"}, + {file = "mypy-1.10.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:604282c886497645ffb87b8f35a57ec773a4a2721161e709a4422c1636ddde5c"}, + {file = "mypy-1.10.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37fd87cab83f09842653f08de066ee68f1182b9b5282e4634cdb4b407266bade"}, + {file = "mypy-1.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8addf6313777dbb92e9564c5d32ec122bf2c6c39d683ea64de6a1fd98b90fe37"}, + {file = "mypy-1.10.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cc3ca0a244eb9a5249c7c583ad9a7e881aa5d7b73c35652296ddcdb33b2b9c7"}, + {file = "mypy-1.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:1b3a2ffce52cc4dbaeee4df762f20a2905aa171ef157b82192f2e2f368eec05d"}, + {file = "mypy-1.10.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe85ed6836165d52ae8b88f99527d3d1b2362e0cb90b005409b8bed90e9059b3"}, + {file = "mypy-1.10.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2ae450d60d7d020d67ab440c6e3fae375809988119817214440033f26ddf7bf"}, + {file = "mypy-1.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6be84c06e6abd72f960ba9a71561c14137a583093ffcf9bbfaf5e613d63fa531"}, + {file = "mypy-1.10.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2189ff1e39db399f08205e22a797383613ce1cb0cb3b13d8bcf0170e45b96cc3"}, + {file = "mypy-1.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:97a131ee36ac37ce9581f4220311247ab6cba896b4395b9c87af0675a13a755f"}, + {file = "mypy-1.10.1-py3-none-any.whl", hash = "sha256:71d8ac0b906354ebda8ef1673e5fde785936ac1f29ff6987c7483cfbd5a4235a"}, + {file = "mypy-1.10.1.tar.gz", hash = "sha256:1f8f492d7db9e3593ef42d4f115f04e556130f2819ad33ab84551403e97dd4c0"}, ] [package.dependencies] @@ -1487,22 +1487,22 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyproject-api" -version = "1.6.1" +version = "1.7.1" description = "API to interact with the python pyproject.toml based projects" optional = false python-versions = ">=3.8" files = [ - {file = "pyproject_api-1.6.1-py3-none-any.whl", hash = "sha256:4c0116d60476b0786c88692cf4e325a9814965e2469c5998b830bba16b183675"}, - {file = "pyproject_api-1.6.1.tar.gz", hash = "sha256:1817dc018adc0d1ff9ca1ed8c60e1623d5aaca40814b953af14a9cf9a5cae538"}, + {file = "pyproject_api-1.7.1-py3-none-any.whl", hash = "sha256:2dc1654062c2b27733d8fd4cdda672b22fe8741ef1dde8e3a998a9547b071eeb"}, + {file = "pyproject_api-1.7.1.tar.gz", hash = "sha256:7ebc6cd10710f89f4cf2a2731710a98abce37ebff19427116ff2174c9236a827"}, ] [package.dependencies] -packaging = ">=23.1" +packaging = ">=24.1" tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} [package.extras] -docs = ["furo (>=2023.8.19)", "sphinx (<7.2)", "sphinx-autodoc-typehints (>=1.24)"] -testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "setuptools (>=68.1.2)", "wheel (>=0.41.2)"] +docs = ["furo (>=2024.5.6)", "sphinx-autodoc-typehints (>=2.2.1)"] +testing = ["covdefaults (>=2.3)", "pytest (>=8.2.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "setuptools (>=70.1)"] [[package]] name = "pytest" @@ -1653,7 +1653,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1661,15 +1660,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1686,7 +1678,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1694,7 +1685,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -2041,13 +2031,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.26.2" +version = "20.26.3" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.26.2-py3-none-any.whl", hash = "sha256:a624db5e94f01ad993d476b9ee5346fdf7b9de43ccaee0e0197012dc838a0e9b"}, - {file = "virtualenv-20.26.2.tar.gz", hash = "sha256:82bf0f4eebbb78d36ddaee0283d43fe5736b53880b8a8cdcd37390a07ac3741c"}, + {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, + {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, ] [package.dependencies] @@ -2061,13 +2051,13 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [[package]] name = "voluptuous" -version = "0.14.2" +version = "0.15.0" description = "Python data validation library" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "voluptuous-0.14.2-py3-none-any.whl", hash = "sha256:efc1dadc9ae32a30cc622602c1400a17b7bf8ee2770d64f70418144860739c3b"}, - {file = "voluptuous-0.14.2.tar.gz", hash = "sha256:533e36175967a310f1b73170d091232bf881403e4ebe52a9b4ade8404d151f5d"}, + {file = "voluptuous-0.15.0-py3-none-any.whl", hash = "sha256:ab8d0c3b74b83d062b72fde6ed120b9801d7acb7e504666b0f278dd214ae7ce5"}, + {file = "voluptuous-0.15.0.tar.gz", hash = "sha256:90fb449f6088f3985b24c0df79887e3823355639e0a6a220394ceac07258aea0"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 550f658ef..f7bb8acda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0" +version = "0.7.0.1" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From cf24a9452614a7b5210ab4ba28d1d409cbc9403d Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 27 Jun 2024 16:58:45 +0200 Subject: [PATCH 498/892] Handle unknown error codes gracefully (#1016) Makes unknown error codes to be reported through KasaException which may be recoverable in some cases (i.e., a single command failing in the multi request). Related to https://github.com/home-assistant/core/issues/118446 --- kasa/aestransport.py | 8 +++++++- kasa/exceptions.py | 3 +++ kasa/smartprotocol.py | 10 +++++++++- kasa/tests/test_aestransport.py | 27 +++++++++++++++++++++++++++ kasa/tests/test_smartprotocol.py | 19 +++++++++++++++++++ 5 files changed, 65 insertions(+), 2 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index f406996f2..72df4e17a 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -140,7 +140,13 @@ def hash_credentials(login_v2: bool, credentials: Credentials) -> tuple[str, str return un, pw def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: - error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] + error_code_raw = resp_dict.get("error_code") + try: + error_code = SmartErrorCode(error_code_raw) # type: ignore[arg-type] + except ValueError: + _LOGGER.warning("Received unknown error code: %s", error_code_raw) + error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR + if error_code == SmartErrorCode.SUCCESS: return msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})" diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 567f01b49..2d913c2af 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -119,6 +119,9 @@ def __str__(self): DST_ERROR = -2301 DST_SAVE_ERROR = -2302 + # Library internal for unknown error codes + INTERNAL_UNKNOWN_ERROR = -100_000 + SMART_RETRYABLE_ERRORS = [ SmartErrorCode.TRANSPORT_NOT_AVAILABLE_ERROR, diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 545f8147a..a430e3afa 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -239,12 +239,20 @@ async def _handle_response_lists( response_result[response_list_name].extend(next_batch[response_list_name]) def _handle_response_error_code(self, resp_dict: dict, method, raise_on_error=True): - error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] + error_code_raw = resp_dict.get("error_code") + try: + error_code = SmartErrorCode(error_code_raw) # type: ignore[arg-type] + except ValueError: + _LOGGER.warning("Received unknown error code: %s", error_code_raw) + error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR + if error_code == SmartErrorCode.SUCCESS: return + if not raise_on_error: resp_dict["result"] = error_code return + msg = ( f"Error querying device: {self._host}: " + f"{error_code.name}({error_code.value})" diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 232546d5a..940b16b0f 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -276,6 +276,33 @@ async def test_passthrough_errors(mocker, error_code): await transport.send(json_dumps(request)) +@pytest.mark.parametrize("error_code", [-13333, 13333]) +async def test_unknown_errors(mocker, error_code): + host = "127.0.0.1" + mock_aes_device = MockAesDevice(host, 200, error_code, 0) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) + + config = DeviceConfig(host, credentials=Credentials("foo", "bar")) + transport = AesTransport(config=config) + transport._handshake_done = True + transport._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + transport._token_url = transport._app_url.with_query( + f"token={mock_aes_device.token}" + ) + + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + with pytest.raises(KasaException): + res = await transport.send(json_dumps(request)) + assert res is SmartErrorCode.INTERNAL_UNKNOWN_ERROR + + async def test_port_override(): """Test that port override sets the app_url.""" host = "127.0.0.1" diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 5a0eb0fa7..5ead00d61 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -35,6 +35,25 @@ async def test_smart_device_errors(dummy_protocol, mocker, error_code): assert send_mock.call_count == expected_calls +@pytest.mark.parametrize("error_code", [-13333, 13333]) +async def test_smart_device_unknown_errors( + dummy_protocol, mocker, error_code, caplog: pytest.LogCaptureFixture +): + """Test handling of unknown error codes.""" + mock_response = {"result": {"great": "success"}, "error_code": error_code} + + send_mock = mocker.patch.object( + dummy_protocol._transport, "send", return_value=mock_response + ) + + with pytest.raises(KasaException): + res = await dummy_protocol.query(DUMMY_QUERY) + assert res is SmartErrorCode.INTERNAL_UNKNOWN_ERROR + + send_mock.assert_called_once() + assert f"Received unknown error code: {error_code}" in caplog.text + + @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) async def test_smart_device_errors_in_multiple_request( dummy_protocol, mocker, error_code From 2a628499877216c798e394febd02ec1bfcc11df4 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 27 Jun 2024 18:52:54 +0100 Subject: [PATCH 499/892] Update light transition module to work with child devices (#1017) Fixes module to work with child devices, i.e. ks240 Interrogates the data to see whether maximums are available. Fixes a bug whereby setting a duration while the feature is not enabled does not actually enable it. --- kasa/feature.py | 4 +- kasa/smart/modules/lighttransition.py | 152 ++++++++++++------ kasa/tests/device_fixtures.py | 10 +- kasa/tests/discovery_fixtures.py | 4 +- kasa/tests/fakeprotocol_smart.py | 55 ++++++- kasa/tests/fixtureinfo.py | 24 ++- .../smart/modules/test_lighttransition.py | 80 +++++++++ kasa/tests/test_device_factory.py | 52 ++++-- 8 files changed, 304 insertions(+), 77 deletions(-) create mode 100644 kasa/tests/smart/modules/test_lighttransition.py diff --git a/kasa/feature.py b/kasa/feature.py index e247e6616..0ce13d45f 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -107,6 +107,8 @@ class Type(Enum): Number = Type.Number Choice = Type.Choice + DEFAULT_MAX = 2**16 # Arbitrary max + class Category(Enum): """Category hint to allow feature grouping.""" @@ -155,7 +157,7 @@ class Category(Enum): #: Minimum value minimum_value: int = 0 #: Maximum value - maximum_value: int = 2**16 # Arbitrary max + maximum_value: int = DEFAULT_MAX #: Attribute containing the name of the range getter property. #: If set, this property will be used to set *minimum_value* and *maximum_value*. range_getter: str | None = None diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index fa73cd681..29a4bb055 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypedDict from ...exceptions import KasaException from ...feature import Feature @@ -12,6 +12,12 @@ from ..smartdevice import SmartDevice +class _State(TypedDict): + duration: int + enable: bool + max_duration: int + + class LightTransition(SmartModule): """Implementation of gradual on/off.""" @@ -19,14 +25,30 @@ class LightTransition(SmartModule): QUERY_GETTER_NAME = "get_on_off_gradually_info" MAXIMUM_DURATION = 60 + # Key in sysinfo that indicates state can be retrieved from there. + # Usually only for child lights, i.e, ks240. + SYS_INFO_STATE_KEYS = ( + "gradually_on_mode", + "gradually_off_mode", + "fade_on_time", + "fade_off_time", + ) + + _on_state: _State + _off_state: _State + _enabled: bool + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) - self._create_features() + self._state_in_sysinfo = all( + key in device.sys_info for key in self.SYS_INFO_STATE_KEYS + ) + self._supports_on_and_off: bool = self.supported_version > 1 - def _create_features(self): - """Create features based on the available version.""" + def _initialize_features(self): + """Initialize features.""" icon = "mdi:transition" - if self.supported_version == 1: + if not self._supports_on_and_off: self._add_feature( Feature( device=self._device, @@ -34,16 +56,12 @@ def _create_features(self): id="smooth_transitions", name="Smooth transitions", icon=icon, - attribute_getter="enabled_v1", - attribute_setter="set_enabled_v1", + attribute_getter="enabled", + attribute_setter="set_enabled", type=Feature.Type.Switch, ) ) - elif self.supported_version >= 2: - # v2 adds separate on & off states - # v3 adds max_duration - # TODO: note, hardcoding the maximums for now as the features get - # initialized before the first update. + else: self._add_feature( Feature( self._device, @@ -54,9 +72,9 @@ def _create_features(self): attribute_setter="set_turn_on_transition", icon=icon, type=Feature.Type.Number, - maximum_value=self.MAXIMUM_DURATION, + maximum_value=self._turn_on_transition_max, ) - ) # self._turn_on_transition_max + ) self._add_feature( Feature( self._device, @@ -67,38 +85,74 @@ def _create_features(self): attribute_setter="set_turn_off_transition", icon=icon, type=Feature.Type.Number, - maximum_value=self.MAXIMUM_DURATION, + maximum_value=self._turn_off_transition_max, ) - ) # self._turn_off_transition_max - - @property - def _turn_on(self): - """Internal getter for turn on settings.""" - if "on_state" not in self.data: - raise KasaException( - f"Unsupported for {self.REQUIRED_COMPONENT} v{self.supported_version}" ) - return self.data["on_state"] - - @property - def _turn_off(self): - """Internal getter for turn off settings.""" - if "off_state" not in self.data: + def _post_update_hook(self) -> None: + """Update the states.""" + # Assumes any device with state in sysinfo supports on and off and + # has maximum values for both. + # v2 adds separate on & off states + # v3 adds max_duration except for ks240 which is v2 but supports it + if not self._supports_on_and_off: + self._enabled = self.data["enable"] + return + + if self._state_in_sysinfo: + on_max = self._device.sys_info.get( + "max_fade_on_time", self.MAXIMUM_DURATION + ) + off_max = self._device.sys_info.get( + "max_fade_off_time", self.MAXIMUM_DURATION + ) + on_enabled = bool(self._device.sys_info["gradually_on_mode"]) + off_enabled = bool(self._device.sys_info["gradually_off_mode"]) + on_duration = self._device.sys_info["fade_on_time"] + off_duration = self._device.sys_info["fade_off_time"] + elif (on_state := self.data.get("on_state")) and ( + off_state := self.data.get("off_state") + ): + on_max = on_state.get("max_duration", self.MAXIMUM_DURATION) + off_max = off_state.get("max_duration", self.MAXIMUM_DURATION) + on_enabled = on_state["enable"] + off_enabled = off_state["enable"] + on_duration = on_state["duration"] + off_duration = off_state["duration"] + else: raise KasaException( f"Unsupported for {self.REQUIRED_COMPONENT} v{self.supported_version}" ) - return self.data["off_state"] - - async def set_enabled_v1(self, enable: bool): + self._enabled = on_enabled or off_enabled + self._on_state = { + "duration": on_duration, + "enable": on_enabled, + "max_duration": on_max, + } + self._off_state = { + "duration": off_duration, + "enable": off_enabled, + "max_duration": off_max, + } + + async def set_enabled(self, enable: bool): """Enable gradual on/off.""" - return await self.call("set_on_off_gradually_info", {"enable": enable}) + if not self._supports_on_and_off: + return await self.call("set_on_off_gradually_info", {"enable": enable}) + else: + on = await self.call( + "set_on_off_gradually_info", {"on_state": {"enable": enable}} + ) + off = await self.call( + "set_on_off_gradually_info", {"off_state": {"enable": enable}} + ) + return {**on, **off} @property - def enabled_v1(self) -> bool: + def enabled(self) -> bool: """Return True if gradual on/off is enabled.""" - return bool(self.data["enable"]) + return self._enabled @property def turn_on_transition(self) -> int: @@ -106,15 +160,13 @@ def turn_on_transition(self) -> int: Available only from v2. """ - if "fade_on_time" in self._device.sys_info: - return self._device.sys_info["fade_on_time"] - return self._turn_on["duration"] + return self._on_state["duration"] if self._on_state["enable"] else 0 @property def _turn_on_transition_max(self) -> int: """Maximum turn on duration.""" # v3 added max_duration, we default to 60 when it's not available - return self._turn_on.get("max_duration", 60) + return self._on_state["max_duration"] async def set_turn_on_transition(self, seconds: int): """Set turn on transition in seconds. @@ -129,12 +181,12 @@ async def set_turn_on_transition(self, seconds: int): if seconds <= 0: return await self.call( "set_on_off_gradually_info", - {"on_state": {**self._turn_on, "enable": False}}, + {"on_state": {"enable": False}}, ) return await self.call( "set_on_off_gradually_info", - {"on_state": {**self._turn_on, "duration": seconds}}, + {"on_state": {"enable": True, "duration": seconds}}, ) @property @@ -143,15 +195,13 @@ def turn_off_transition(self) -> int: Available only from v2. """ - if "fade_off_time" in self._device.sys_info: - return self._device.sys_info["fade_off_time"] - return self._turn_off["duration"] + return self._off_state["duration"] if self._off_state["enable"] else 0 @property def _turn_off_transition_max(self) -> int: """Maximum turn on duration.""" # v3 added max_duration, we default to 60 when it's not available - return self._turn_off.get("max_duration", 60) + return self._off_state["max_duration"] async def set_turn_off_transition(self, seconds: int): """Set turn on transition in seconds. @@ -166,26 +216,24 @@ async def set_turn_off_transition(self, seconds: int): if seconds <= 0: return await self.call( "set_on_off_gradually_info", - {"off_state": {**self._turn_off, "enable": False}}, + {"off_state": {"enable": False}}, ) return await self.call( "set_on_off_gradually_info", - {"off_state": {**self._turn_on, "duration": seconds}}, + {"off_state": {"enable": True, "duration": seconds}}, ) def query(self) -> dict: """Query to execute during the update cycle.""" # Some devices have the required info in the device info. - if "gradually_on_mode" in self._device.sys_info: + if self._state_in_sysinfo: return {} else: return {self.QUERY_GETTER_NAME: None} async def _check_supported(self): """Additional check to see if the module is supported by the device.""" - # TODO Temporarily disabled on child light devices until module fixed - # to support updates - if self._device._parent is not None: - return False + # For devices that report child components on the parent that are not + # actually supported by the parent. return "brightness" in self._device.sys_info diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 718789f6a..0d6fbd488 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -15,7 +15,13 @@ from .fakeprotocol_iot import FakeIotProtocol from .fakeprotocol_smart import FakeSmartProtocol -from .fixtureinfo import FIXTURE_DATA, FixtureInfo, filter_fixtures, idgenerator +from .fixtureinfo import ( + FIXTURE_DATA, + ComponentFilter, + FixtureInfo, + filter_fixtures, + idgenerator, +) # Tapo bulbs BULBS_SMART_VARIABLE_TEMP = {"L530E", "L930-5"} @@ -175,7 +181,7 @@ def parametrize( *, model_filter=None, protocol_filter=None, - component_filter=None, + component_filter: str | ComponentFilter | None = None, data_root_filter=None, device_type_filter=None, ids=None, diff --git a/kasa/tests/discovery_fixtures.py b/kasa/tests/discovery_fixtures.py index 229c6c44a..1ba24bf1a 100644 --- a/kasa/tests/discovery_fixtures.py +++ b/kasa/tests/discovery_fixtures.py @@ -12,6 +12,8 @@ from .fakeprotocol_smart import FakeSmartProtocol, FakeSmartTransport from .fixtureinfo import FixtureInfo, filter_fixtures, idgenerator +DISCOVERY_MOCK_IP = "127.0.0.123" + def _make_unsupported(device_family, encrypt_type): return { @@ -73,7 +75,7 @@ def parametrize_discovery( async def discovery_mock(request, mocker): """Mock discovery and patch protocol queries to use Fake protocols.""" fixture_info: FixtureInfo = request.param - yield patch_discovery({"127.0.0.123": fixture_info}, mocker) + yield patch_discovery({DISCOVERY_MOCK_IP: fixture_info}, mocker) def create_discovery_mock(ip: str, fixture_data: dict): diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index d601128e0..94c751041 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -78,7 +78,6 @@ def credentials_hash(self): }, }, ), - "get_on_off_gradually_info": ("on_off_gradually", {"enable": True}), "get_latest_fw": ( "firmware", { @@ -164,6 +163,8 @@ def _handle_control_child(self, params: dict): return {"error_code": 0} elif child_method == "set_preset_rules": return self._set_child_preset_rules(info, child_params) + elif child_method == "set_on_off_gradually_info": + return self._set_on_off_gradually_info(info, child_params) elif child_method in child_device_calls: result = copy.deepcopy(child_device_calls[child_method]) return {"result": result, "error_code": 0} @@ -200,6 +201,49 @@ def _handle_control_child(self, params: dict): "Method %s not implemented for children" % child_method ) + def _get_on_off_gradually_info(self, info, params): + if self.components["on_off_gradually"] == 1: + info["get_on_off_gradually_info"] = {"enable": True} + else: + info["get_on_off_gradually_info"] = { + "off_state": {"duration": 5, "enable": False, "max_duration": 60}, + "on_state": {"duration": 5, "enable": False, "max_duration": 60}, + } + return copy.deepcopy(info["get_on_off_gradually_info"]) + + def _set_on_off_gradually_info(self, info, params): + # Child devices can have the required properties directly in info + + if self.components["on_off_gradually"] == 1: + info["get_on_off_gradually_info"] = {"enable": params["enable"]} + elif on_state := params.get("on_state"): + if "fade_on_time" in info and "gradually_on_mode" in info: + info["gradually_on_mode"] = 1 if on_state["enable"] else 0 + if "duration" in on_state: + info["fade_on_time"] = on_state["duration"] + else: + info["get_on_off_gradually_info"]["on_state"]["enable"] = on_state[ + "enable" + ] + if "duration" in on_state: + info["get_on_off_gradually_info"]["on_state"]["duration"] = ( + on_state["duration"] + ) + elif off_state := params.get("off_state"): + if "fade_off_time" in info and "gradually_off_mode" in info: + info["gradually_off_mode"] = 1 if off_state["enable"] else 0 + if "duration" in off_state: + info["fade_off_time"] = off_state["duration"] + else: + info["get_on_off_gradually_info"]["off_state"]["enable"] = off_state[ + "enable" + ] + if "duration" in off_state: + info["get_on_off_gradually_info"]["off_state"]["duration"] = ( + off_state["duration"] + ) + return {"error_code": 0} + def _set_dynamic_light_effect(self, info, params): """Set or remove values as per the device behaviour.""" info["get_device_info"]["dynamic_light_effect_enable"] = params["enable"] @@ -294,6 +338,13 @@ def _send_request(self, request_dict: dict): info[method] = copy.deepcopy(missing_result[1]) result = copy.deepcopy(info[method]) retval = {"result": result, "error_code": 0} + elif ( + method == "get_on_off_gradually_info" + and "on_off_gradually" in self.components + ): + # Need to call a method here to determine which version schema to return + result = self._get_on_off_gradually_info(info, params) + return {"result": result, "error_code": 0} else: # PARAMS error returned for KS240 when get_device_usage called # on parent device. Could be any error code though. @@ -324,6 +375,8 @@ def _send_request(self, request_dict: dict): return self._set_preset_rules(info, params) elif method == "edit_preset_rules": return self._edit_preset_rules(info, params) + elif method == "set_on_off_gradually_info": + return self._set_on_off_gradually_info(info, params) elif method[:4] == "set_": target_method = f"get_{method[4:]}" info[target_method].update(params) diff --git a/kasa/tests/fixtureinfo.py b/kasa/tests/fixtureinfo.py index 153d6cc38..9abf0f065 100644 --- a/kasa/tests/fixtureinfo.py +++ b/kasa/tests/fixtureinfo.py @@ -17,6 +17,12 @@ class FixtureInfo(NamedTuple): data: dict +class ComponentFilter(NamedTuple): + component_name: str + minimum_version: int = 0 + maximum_version: int | None = None + + FixtureInfo.__hash__ = lambda self: hash((self.name, self.protocol)) # type: ignore[attr-defined, method-assign] FixtureInfo.__eq__ = lambda x, y: hash(x) == hash(y) # type: ignore[method-assign] @@ -88,7 +94,7 @@ def filter_fixtures( data_root_filter: str | None = None, protocol_filter: set[str] | None = None, model_filter: set[str] | None = None, - component_filter: str | None = None, + component_filter: str | ComponentFilter | None = None, device_type_filter: list[DeviceType] | None = None, ): """Filter the fixtures based on supplied parameters. @@ -106,14 +112,26 @@ def _model_match(fixture_data: FixtureInfo, model_filter): file_model = file_model_region.split("(")[0] return file_model in model_filter - def _component_match(fixture_data: FixtureInfo, component_filter): + def _component_match( + fixture_data: FixtureInfo, component_filter: str | ComponentFilter + ): if (component_nego := fixture_data.data.get("component_nego")) is None: return False components = { component["id"]: component["ver_code"] for component in component_nego["component_list"] } - return component_filter in components + if isinstance(component_filter, str): + return component_filter in components + else: + return ( + (ver_code := components.get(component_filter.component_name)) + and ver_code >= component_filter.minimum_version + and ( + component_filter.maximum_version is None + or ver_code <= component_filter.maximum_version + ) + ) def _device_type_match(fixture_data: FixtureInfo, device_type): if (component_nego := fixture_data.data.get("component_nego")) is None: diff --git a/kasa/tests/smart/modules/test_lighttransition.py b/kasa/tests/smart/modules/test_lighttransition.py new file mode 100644 index 000000000..beee68b37 --- /dev/null +++ b/kasa/tests/smart/modules/test_lighttransition.py @@ -0,0 +1,80 @@ +from pytest_mock import MockerFixture + +from kasa import Feature, Module +from kasa.smart import SmartDevice +from kasa.tests.device_fixtures import get_parent_and_child_modules, parametrize +from kasa.tests.fixtureinfo import ComponentFilter + +light_transition_v1 = parametrize( + "has light transition", + component_filter=ComponentFilter( + component_name="on_off_gradually", maximum_version=1 + ), + protocol_filter={"SMART"}, +) +light_transition_gt_v1 = parametrize( + "has light transition", + component_filter=ComponentFilter( + component_name="on_off_gradually", minimum_version=2 + ), + protocol_filter={"SMART"}, +) + + +@light_transition_v1 +async def test_module_v1(dev: SmartDevice, mocker: MockerFixture): + """Test light transition module.""" + assert isinstance(dev, SmartDevice) + light_transition = next(get_parent_and_child_modules(dev, Module.LightTransition)) + assert light_transition + assert "smooth_transitions" in light_transition._module_features + assert "smooth_transition_on" not in light_transition._module_features + assert "smooth_transition_off" not in light_transition._module_features + + await light_transition.set_enabled(True) + await dev.update() + assert light_transition.enabled is True + + await light_transition.set_enabled(False) + await dev.update() + assert light_transition.enabled is False + + +@light_transition_gt_v1 +async def test_module_gt_v1(dev: SmartDevice, mocker: MockerFixture): + """Test light transition module.""" + assert isinstance(dev, SmartDevice) + light_transition = next(get_parent_and_child_modules(dev, Module.LightTransition)) + assert light_transition + assert "smooth_transitions" not in light_transition._module_features + assert "smooth_transition_on" in light_transition._module_features + assert "smooth_transition_off" in light_transition._module_features + + await light_transition.set_enabled(True) + await dev.update() + assert light_transition.enabled is True + + await light_transition.set_enabled(False) + await dev.update() + assert light_transition.enabled is False + + await light_transition.set_turn_on_transition(5) + await dev.update() + assert light_transition.turn_on_transition == 5 + # enabled is true if either on or off is enabled + assert light_transition.enabled is True + + await light_transition.set_turn_off_transition(10) + await dev.update() + assert light_transition.turn_off_transition == 10 + assert light_transition.enabled is True + + max_on = light_transition._module_features["smooth_transition_on"].maximum_value + assert max_on < Feature.DEFAULT_MAX + max_off = light_transition._module_features["smooth_transition_off"].maximum_value + assert max_off < Feature.DEFAULT_MAX + + await light_transition.set_turn_on_transition(0) + await light_transition.set_turn_off_transition(0) + await dev.update() + assert light_transition.enabled is False diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index d5fd27e19..7940f1e5d 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -1,16 +1,25 @@ -# type: ignore +"""Module for testing device factory. + +As this module tests the factory with discovery data and expects update to be +called on devices it uses the discovery_mock handles all the patching of the +query methods without actually replacing the device protocol class with one of +the testing fake protocols. +""" + import logging +from typing import cast import aiohttp import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( Credentials, - Device, Discover, KasaException, ) from kasa.device_factory import ( + Device, + SmartDevice, _get_device_type_from_sys_info, connect, get_device_class_from_family, @@ -23,7 +32,8 @@ DeviceFamily, ) from kasa.discover import DiscoveryResult -from kasa.smart.smartdevice import SmartDevice + +from .conftest import DISCOVERY_MOCK_IP def _get_connection_type_device_class(discovery_info): @@ -44,18 +54,22 @@ def _get_connection_type_device_class(discovery_info): async def test_connect( - discovery_data, + discovery_mock, mocker, ): """Test that if the protocol is passed in it gets set correctly.""" - host = "127.0.0.1" - ctype, device_class = _get_connection_type_device_class(discovery_data) + host = DISCOVERY_MOCK_IP + ctype, device_class = _get_connection_type_device_class( + discovery_mock.discovery_data + ) config = DeviceConfig( host=host, credentials=Credentials("foor", "bar"), connection_type=ctype ) protocol_class = get_protocol(config).__class__ close_mock = mocker.patch.object(protocol_class, "close") + # mocker.patch.object(SmartDevice, "update") + # mocker.patch.object(Device, "update") dev = await connect( config=config, ) @@ -69,10 +83,11 @@ async def test_connect( @pytest.mark.parametrize("custom_port", [123, None]) -async def test_connect_custom_port(discovery_data: dict, mocker, custom_port): +async def test_connect_custom_port(discovery_mock, mocker, custom_port): """Make sure that connect returns an initialized SmartDevice instance.""" - host = "127.0.0.1" + host = DISCOVERY_MOCK_IP + discovery_data = discovery_mock.discovery_data ctype, _ = _get_connection_type_device_class(discovery_data) config = DeviceConfig( host=host, @@ -90,13 +105,14 @@ async def test_connect_custom_port(discovery_data: dict, mocker, custom_port): async def test_connect_logs_connect_time( - discovery_data: dict, + discovery_mock, caplog: pytest.LogCaptureFixture, ): """Test that the connect time is logged when debug logging is enabled.""" + discovery_data = discovery_mock.discovery_data ctype, _ = _get_connection_type_device_class(discovery_data) - host = "127.0.0.1" + host = DISCOVERY_MOCK_IP config = DeviceConfig( host=host, credentials=Credentials("foor", "bar"), connection_type=ctype ) @@ -107,9 +123,10 @@ async def test_connect_logs_connect_time( assert "seconds to update" in caplog.text -async def test_connect_query_fails(discovery_data, mocker): +async def test_connect_query_fails(discovery_mock, mocker): """Make sure that connect fails when query fails.""" - host = "127.0.0.1" + host = DISCOVERY_MOCK_IP + discovery_data = discovery_mock.discovery_data mocker.patch("kasa.IotProtocol.query", side_effect=KasaException) mocker.patch("kasa.SmartProtocol.query", side_effect=KasaException) @@ -125,10 +142,10 @@ async def test_connect_query_fails(discovery_data, mocker): assert close_mock.call_count == 1 -async def test_connect_http_client(discovery_data, mocker): +async def test_connect_http_client(discovery_mock, mocker): """Make sure that discover_single returns an initialized SmartDevice instance.""" - host = "127.0.0.1" - + host = DISCOVERY_MOCK_IP + discovery_data = discovery_mock.discovery_data ctype, _ = _get_connection_type_device_class(discovery_data) http_client = aiohttp.ClientSession() @@ -157,9 +174,10 @@ async def test_connect_http_client(discovery_data, mocker): async def test_device_types(dev: Device): await dev.update() if isinstance(dev, SmartDevice): - device_type = dev._discovery_info["result"]["device_type"] + assert dev._discovery_info + device_type = cast(str, dev._discovery_info["result"]["device_type"]) res = SmartDevice._get_device_type_from_components( - dev._components.keys(), device_type + list(dev._components.keys()), device_type ) else: res = _get_device_type_from_sys_info(dev._last_update) From 368590cd36145b0b7786bb248ae7cbfcce30180c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Jun 2024 04:49:59 -0500 Subject: [PATCH 500/892] Cache SmartErrorCode creation (#1022) Uses the python 3.9 cache feature to improve performance of error code creation --- kasa/aestransport.py | 5 ++--- kasa/exceptions.py | 7 +++++++ kasa/smartprotocol.py | 4 ++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 72df4e17a..cc373b190 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -142,12 +142,11 @@ def hash_credentials(login_v2: bool, credentials: Credentials) -> tuple[str, str def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: error_code_raw = resp_dict.get("error_code") try: - error_code = SmartErrorCode(error_code_raw) # type: ignore[arg-type] + error_code = SmartErrorCode.from_int(error_code_raw) except ValueError: _LOGGER.warning("Received unknown error code: %s", error_code_raw) error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR - - if error_code == SmartErrorCode.SUCCESS: + if error_code is SmartErrorCode.SUCCESS: return msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})" if error_code in SMART_RETRYABLE_ERRORS: diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 2d913c2af..f5c26ff04 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -4,6 +4,7 @@ from asyncio import TimeoutError as _asyncioTimeoutError from enum import IntEnum +from functools import cache from typing import Any @@ -63,6 +64,12 @@ class SmartErrorCode(IntEnum): def __str__(self): return f"{self.name}({self.value})" + @staticmethod + @cache + def from_int(value: int) -> SmartErrorCode: + """Convert an integer to a SmartErrorCode.""" + return SmartErrorCode(value) + SUCCESS = 0 # Transport Errors diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index a430e3afa..22fd49dc0 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -241,12 +241,12 @@ async def _handle_response_lists( def _handle_response_error_code(self, resp_dict: dict, method, raise_on_error=True): error_code_raw = resp_dict.get("error_code") try: - error_code = SmartErrorCode(error_code_raw) # type: ignore[arg-type] + error_code = SmartErrorCode.from_int(error_code_raw) except ValueError: _LOGGER.warning("Received unknown error code: %s", error_code_raw) error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR - if error_code == SmartErrorCode.SUCCESS: + if error_code is SmartErrorCode.SUCCESS: return if not raise_on_error: From 2687c71c4b09ab3e9a90ca76568c4618889a1a9f Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 1 Jul 2024 11:51:06 +0100 Subject: [PATCH 501/892] Make parent attribute on device consistent across iot and smart (#1023) Both device types now have an internal `_parent` and a public property getter --- kasa/device.py | 5 +++++ kasa/iot/iotstrip.py | 14 +++++++++----- kasa/tests/test_childdevice.py | 22 +++++++++++++++++++++- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/kasa/device.py b/kasa/device.py index 9bf0903ee..ac23fdb24 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -328,6 +328,11 @@ async def _raw_query(self, request: str | dict) -> Any: """Send a raw query to the device.""" return await self.protocol.query(request=request) + @property + def parent(self) -> Device | None: + """Return the parent on child devices.""" + return self._parent + @property def children(self) -> Sequence[Device]: """Returns the child devices.""" diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index e64ace051..3a1406aa6 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -307,10 +307,12 @@ class IotStripPlug(IotPlug): The plug inherits (most of) the system information from the parent. """ + _parent: IotStrip + def __init__(self, host: str, parent: IotStrip, child_id: str) -> None: super().__init__(host) - self.parent = parent + self._parent = parent self.child_id = child_id self._last_update = parent._last_update self._set_sys_info(parent.sys_info) @@ -380,7 +382,7 @@ async def _query_helper( self, target: str, cmd: str, arg: dict | None = None, child_ids=None ) -> Any: """Override query helper to include the child_ids.""" - return await self.parent._query_helper( + return await self._parent._query_helper( target, cmd, arg, child_ids=[self.child_id] ) @@ -441,13 +443,15 @@ def on_since(self) -> datetime | None: @requires_update def model(self) -> str: """Return device model for a child socket.""" - sys_info = self.parent.sys_info + sys_info = self._parent.sys_info return f"Socket for {sys_info['model']}" def _get_child_info(self) -> dict: """Return the subdevice information for this device.""" - for plug in self.parent.sys_info["children"]: + for plug in self._parent.sys_info["children"]: if plug["id"] == self.child_id: return plug - raise KasaException(f"Unable to find children {self.child_id}") + raise KasaException( + f"Unable to find children {self.child_id}" + ) # pragma: no cover diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 26568c24a..251af8788 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -3,12 +3,19 @@ import pytest +from kasa import Device from kasa.device_type import DeviceType from kasa.smart.smartchilddevice import SmartChildDevice from kasa.smart.smartdevice import NON_HUB_PARENT_ONLY_MODULES from kasa.smartprotocol import _ChildProtocolWrapper -from .conftest import parametrize, parametrize_subtract, strip_smart +from .conftest import ( + parametrize, + parametrize_combine, + parametrize_subtract, + strip_iot, + strip_smart, +) has_children_smart = parametrize( "has children", component_filter="control_child", protocol_filter={"SMART"} @@ -18,6 +25,8 @@ ) non_hub_parent_smart = parametrize_subtract(has_children_smart, hub_smart) +has_children = parametrize_combine([has_children_smart, strip_iot]) + @strip_smart def test_childdevice_init(dev, dummy_protocol, mocker): @@ -100,3 +109,14 @@ async def test_parent_only_modules(dev, dummy_protocol, mocker): for child in dev.children: for module in NON_HUB_PARENT_ONLY_MODULES: assert module not in [type(module) for module in child.modules.values()] + + +@has_children +async def test_parent_property(dev: Device): + """Test a child device exposes it's parent.""" + if not dev.children: + pytest.skip(f"Device {dev} fixture does not have any children") + + assert dev.parent is None + for child in dev.children: + assert child.parent == dev From b31a2ede7ff38b0612fd4883a573a45310341ce4 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 1 Jul 2024 13:59:24 +0200 Subject: [PATCH 502/892] Fix changing brightness when effect is active (#1019) This PR changes the behavior of `brightness` module if an effect is active. Currently, changing the brightness disables the effect when the brightness is changed, this fixes that. This will also improve the `set_effect` interface to use the current brightness when an effect is activated. * light_strip_effect: passing `bAdjusted` with the changed properties changes the brightness. * light_effect: the brightness is stored only in the rule, so we modify it when adjusting the brightness. This is also done during the initial effect activation. --------- Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- kasa/module.py | 3 + kasa/smart/effects.py | 25 +++++ kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/brightness.py | 15 ++- kasa/smart/modules/lighteffect.py | 76 +++++++++++-- kasa/smart/modules/lightstripeffect.py | 46 ++++++-- kasa/tests/fakeprotocol_smart.py | 18 +++- kasa/tests/smart/modules/test_light_effect.py | 42 ++++++++ .../smart/modules/test_light_strip_effect.py | 101 ++++++++++++++++++ kasa/tests/test_common_modules.py | 14 ++- 10 files changed, 321 insertions(+), 21 deletions(-) create mode 100644 kasa/tests/smart/modules/test_light_strip_effect.py diff --git a/kasa/module.py b/kasa/module.py index 3a090782c..69c4e9e21 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -112,6 +112,9 @@ class Module(ABC): "LightTransition" ) ReportMode: Final[ModuleName[smart.ReportMode]] = ModuleName("ReportMode") + SmartLightEffect: Final[ModuleName[smart.SmartLightEffect]] = ModuleName( + "LightEffect" + ) TemperatureSensor: Final[ModuleName[smart.TemperatureSensor]] = ModuleName( "TemperatureSensor" ) diff --git a/kasa/smart/effects.py b/kasa/smart/effects.py index 28e27d3f7..e0ed615c4 100644 --- a/kasa/smart/effects.py +++ b/kasa/smart/effects.py @@ -2,8 +2,33 @@ from __future__ import annotations +from abc import ABC, abstractmethod from typing import cast +from ..interfaces.lighteffect import LightEffect as LightEffectInterface + + +class SmartLightEffect(LightEffectInterface, ABC): + """Abstract interface for smart light effects. + + This interface extends lighteffect interface to add brightness controls. + """ + + @abstractmethod + async def set_brightness(self, brightness: int, *, transition: int | None = None): + """Set effect brightness.""" + + @property + @abstractmethod + def brightness(self) -> int: + """Return effect brightness.""" + + @property + @abstractmethod + def is_active(self) -> bool: + """Return True if effect is active.""" + + EFFECT_AURORA = { "custom": 0, "id": "TapoStrip_1MClvV18i15Jq3bvJVf0eP", diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index ada52f91f..fd9877513 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -1,5 +1,6 @@ """Modules for SMART devices.""" +from ..effects import SmartLightEffect from .alarm import Alarm from .autooff import AutoOff from .batterysensor import BatterySensor @@ -54,4 +55,5 @@ "WaterleakSensor", "ContactSensor", "FrostProtection", + "SmartLightEffect", ] diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index fbd908083..f5e6d6d64 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -3,7 +3,7 @@ from __future__ import annotations from ...feature import Feature -from ..smartmodule import SmartModule +from ..smartmodule import Module, SmartModule BRIGHTNESS_MIN = 0 BRIGHTNESS_MAX = 100 @@ -42,6 +42,12 @@ def query(self) -> dict: @property def brightness(self): """Return current brightness.""" + # If the device supports effects and one is active, use its brightness + if ( + light_effect := self._device.modules.get(Module.SmartLightEffect) + ) is not None and light_effect.is_active: + return light_effect.brightness + return self.data["brightness"] async def set_brightness(self, brightness: int, *, transition: int | None = None): @@ -59,6 +65,13 @@ async def set_brightness(self, brightness: int, *, transition: int | None = None if brightness == 0: return await self._device.turn_off() + + # If the device supports effects and one is active, we adjust its brightness + if ( + light_effect := self._device.modules.get(Module.SmartLightEffect) + ) is not None and light_effect.is_active: + return await light_effect.set_brightness(brightness) + return await self.call("set_device_info", {"brightness": brightness}) async def _check_supported(self): diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py index 170cfbb39..07f6aece9 100644 --- a/kasa/smart/modules/lighteffect.py +++ b/kasa/smart/modules/lighteffect.py @@ -3,14 +3,16 @@ from __future__ import annotations import base64 +import binascii +import contextlib import copy from typing import Any -from ...interfaces.lighteffect import LightEffect as LightEffectInterface -from ..smartmodule import SmartModule +from ..effects import SmartLightEffect +from ..smartmodule import Module, SmartModule -class LightEffect(SmartModule, LightEffectInterface): +class LightEffect(SmartModule, SmartLightEffect): """Implementation of dynamic light effects.""" REQUIRED_COMPONENT = "light_effect" @@ -36,8 +38,11 @@ def _post_update_hook(self) -> None: # If the name has not been edited scene_name will be an empty string effect["scene_name"] = self.AVAILABLE_BULB_EFFECTS[effect["id"]] else: - # Otherwise it will be b64 encoded - effect["scene_name"] = base64.b64decode(effect["scene_name"]).decode() + # Otherwise it might be b64 encoded or raw string + with contextlib.suppress(binascii.Error): + effect["scene_name"] = base64.b64decode( + effect["scene_name"] + ).decode() self._effect_state_list = effects self._effect_list = [self.LIGHT_EFFECTS_OFF] @@ -77,6 +82,8 @@ async def set_effect( ) -> None: """Set an effect for the device. + Calling this will modify the brightness of the effect on the device. + The device doesn't store an active effect while not enabled so store locally. """ if effect != self.LIGHT_EFFECTS_OFF and effect not in self._scenes_names_to_id: @@ -90,7 +97,64 @@ async def set_effect( if enable: effect_id = self._scenes_names_to_id[effect] params["id"] = effect_id - return await self.call("set_dynamic_light_effect_rule_enable", params) + + # We set the wanted brightness before activating the effect + brightness_module = self._device.modules[Module.Brightness] + brightness = ( + brightness if brightness is not None else brightness_module.brightness + ) + await self.set_brightness(brightness, effect_id=effect_id) + + await self.call("set_dynamic_light_effect_rule_enable", params) + + @property + def is_active(self) -> bool: + """Return True if effect is active.""" + return bool(self._device._info["dynamic_light_effect_enable"]) + + def _get_effect_data(self, effect_id: str | None = None) -> dict[str, Any]: + """Return effect data for the *effect_id*. + + If *effect_id* is None, return the data for active effect. + """ + if effect_id is None: + effect_id = self.data["current_rule_id"] + + return self._effect_state_list[effect_id] + + @property + def brightness(self) -> int: + """Return effect brightness.""" + first_color_status = self._get_effect_data()["color_status_list"][0] + brightness = first_color_status[0] + + return brightness + + async def set_brightness( + self, + brightness: int, + *, + transition: int | None = None, + effect_id: str | None = None, + ): + """Set effect brightness.""" + new_effect = self._get_effect_data(effect_id=effect_id).copy() + + def _replace_brightness(data, new_brightness): + """Replace brightness. + + The first element is the brightness, the rest are unknown. + [[33, 0, 0, 2700], [33, 321, 99, 0], [33, 196, 99, 0], .. ] + """ + return [new_brightness, data[1], data[2], data[3]] + + new_color_status_list = [ + _replace_brightness(state, brightness) + for state in new_effect["color_status_list"] + ] + new_effect["color_status_list"] = new_color_status_list + + return await self.call("edit_dynamic_light_effect_rule", new_effect) async def set_custom_effect( self, diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index c2f351881..a80c20f3c 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -4,15 +4,14 @@ from typing import TYPE_CHECKING -from ...interfaces.lighteffect import LightEffect as LightEffectInterface -from ..effects import EFFECT_MAPPING, EFFECT_NAMES -from ..smartmodule import SmartModule +from ..effects import EFFECT_MAPPING, EFFECT_NAMES, SmartLightEffect +from ..smartmodule import Module, SmartModule if TYPE_CHECKING: from ..smartdevice import SmartDevice -class LightStripEffect(SmartModule, LightEffectInterface): +class LightStripEffect(SmartModule, SmartLightEffect): """Implementation of dynamic light effects.""" REQUIRED_COMPONENT = "light_strip_lighting_effect" @@ -22,6 +21,7 @@ def __init__(self, device: SmartDevice, module: str): effect_list = [self.LIGHT_EFFECTS_OFF] effect_list.extend(EFFECT_NAMES) self._effect_list = effect_list + self._effect_mapping = EFFECT_MAPPING @property def name(self) -> str: @@ -53,6 +53,28 @@ def effect(self) -> str: return name return self.LIGHT_EFFECTS_OFF + @property + def is_active(self) -> bool: + """Return if effect is active.""" + eff = self.data["lighting_effect"] + # softAP has enable=1, but brightness 0 which fails on tests + return bool(eff["enable"]) and eff["name"] in self._effect_list + + @property + def brightness(self) -> int: + """Return effect brightness.""" + eff = self.data["lighting_effect"] + return eff["brightness"] + + async def set_brightness(self, brightness: int, *, transition: int | None = None): + """Set effect brightness.""" + if brightness <= 0: + return await self.set_effect(self.LIGHT_EFFECTS_OFF) + + # Need to pass bAdjusted to keep the existing effect running + eff = {"brightness": brightness, "bAdjusted": True} + return await self.set_custom_effect(eff) + @property def effect_list(self) -> list[str]: """Return built-in effects list. @@ -81,16 +103,24 @@ async def set_effect( :param int brightness: The wanted brightness :param int transition: The wanted transition time """ + brightness_module = self._device.modules[Module.Brightness] if effect == self.LIGHT_EFFECTS_OFF: - effect_dict = dict(self.data["lighting_effect"]) - effect_dict["enable"] = 0 - elif effect not in EFFECT_MAPPING: + state = self._device.modules[Module.Light].state + await self._device.modules[Module.Light].set_state(state) + return + + if effect not in self._effect_mapping: raise ValueError(f"The effect {effect} is not a built in effect.") else: - effect_dict = EFFECT_MAPPING[effect] + effect_dict = self._effect_mapping[effect] + # Use explicitly given brightness if brightness is not None: effect_dict["brightness"] = brightness + # Fall back to brightness reported by the brightness module + elif brightness_module.brightness: + effect_dict["brightness"] = brightness_module.brightness + if transition is not None: effect_dict["transition"] = transition diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 94c751041..600cd75d3 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -250,18 +250,31 @@ def _set_dynamic_light_effect(self, info, params): info["get_dynamic_light_effect_rules"]["enable"] = params["enable"] if params["enable"]: info["get_device_info"]["dynamic_light_effect_id"] = params["id"] - info["get_dynamic_light_effect_rules"]["current_rule_id"] = params["enable"] + info["get_dynamic_light_effect_rules"]["current_rule_id"] = params["id"] else: if "dynamic_light_effect_id" in info["get_device_info"]: del info["get_device_info"]["dynamic_light_effect_id"] if "current_rule_id" in info["get_dynamic_light_effect_rules"]: del info["get_dynamic_light_effect_rules"]["current_rule_id"] + def _set_edit_dynamic_light_effect_rule(self, info, params): + """Edit dynamic light effect rule.""" + rules = info["get_dynamic_light_effect_rules"]["rule_list"] + for rule in rules: + if rule["id"] == params["id"]: + rule.update(params) + return + + raise Exception("Unable to find rule with id") + def _set_light_strip_effect(self, info, params): """Set or remove values as per the device behaviour.""" info["get_device_info"]["lighting_effect"]["enable"] = params["enable"] info["get_device_info"]["lighting_effect"]["name"] = params["name"] info["get_device_info"]["lighting_effect"]["id"] = params["id"] + # Brightness is not always available + if (brightness := params.get("brightness")) is not None: + info["get_device_info"]["lighting_effect"]["brightness"] = brightness info["get_lighting_effect"] = copy.deepcopy(params) def _set_led_info(self, info, params): @@ -365,6 +378,9 @@ def _send_request(self, request_dict: dict): elif method == "set_dynamic_light_effect_rule_enable": self._set_dynamic_light_effect(info, params) return {"error_code": 0} + elif method == "edit_dynamic_light_effect_rule": + self._set_edit_dynamic_light_effect_rule(info, params) + return {"error_code": 0} elif method == "set_lighting_effect": self._set_light_strip_effect(info, params) return {"error_code": 0} diff --git a/kasa/tests/smart/modules/test_light_effect.py b/kasa/tests/smart/modules/test_light_effect.py index ed691e664..20435dde5 100644 --- a/kasa/tests/smart/modules/test_light_effect.py +++ b/kasa/tests/smart/modules/test_light_effect.py @@ -39,3 +39,45 @@ async def test_light_effect(dev: Device, mocker: MockerFixture): with pytest.raises(ValueError): await light_effect.set_effect("foobar") + + +@light_effect +@pytest.mark.parametrize("effect_active", [True, False]) +async def test_light_effect_brightness( + dev: Device, effect_active: bool, mocker: MockerFixture +): + """Test that light module uses light_effect for brightness when active.""" + light_module = dev.modules[Module.Light] + + light_effect = dev.modules[Module.SmartLightEffect] + light_effect_set_brightness = mocker.spy(light_effect, "set_brightness") + mock_light_effect_call = mocker.patch.object(light_effect, "call") + + brightness = dev.modules[Module.Brightness] + brightness_set_brightness = mocker.spy(brightness, "set_brightness") + mock_brightness_call = mocker.patch.object(brightness, "call") + + mocker.patch.object( + type(light_effect), + "is_active", + new_callable=mocker.PropertyMock, + return_value=effect_active, + ) + if effect_active: # Set the rule L1 active for testing + light_effect.data["current_rule_id"] = "L1" + + await light_module.set_brightness(10) + + if effect_active: + assert light_effect.is_active + assert light_effect.brightness == dev.brightness + + light_effect_set_brightness.assert_called_with(10) + mock_light_effect_call.assert_called_with( + "edit_dynamic_light_effect_rule", mocker.ANY + ) + else: + assert not light_effect.is_active + + brightness_set_brightness.assert_called_with(10) + mock_brightness_call.assert_called_with("set_device_info", {"brightness": 10}) diff --git a/kasa/tests/smart/modules/test_light_strip_effect.py b/kasa/tests/smart/modules/test_light_strip_effect.py new file mode 100644 index 000000000..92ef2202c --- /dev/null +++ b/kasa/tests/smart/modules/test_light_strip_effect.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from itertools import chain + +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, Feature, Module +from kasa.smart.modules import LightEffect, LightStripEffect +from kasa.tests.device_fixtures import parametrize + +light_strip_effect = parametrize( + "has light strip effect", + component_filter="light_strip_lighting_effect", + protocol_filter={"SMART"}, +) + + +@light_strip_effect +async def test_light_strip_effect(dev: Device, mocker: MockerFixture): + """Test light strip effect.""" + light_effect = dev.modules.get(Module.LightEffect) + + assert isinstance(light_effect, LightStripEffect) + + brightness = dev.modules[Module.Brightness] + + feature = dev.features["light_effect"] + assert feature.type == Feature.Type.Choice + + call = mocker.spy(light_effect, "call") + + light = dev.modules[Module.Light] + light_call = mocker.spy(light, "call") + + assert feature.choices == light_effect.effect_list + assert feature.choices + for effect in chain(reversed(feature.choices), feature.choices): + await light_effect.set_effect(effect) + + if effect == LightEffect.LIGHT_EFFECTS_OFF: + light_call.assert_called() + continue + + # Start with the current effect data + params = light_effect.data["lighting_effect"] + enable = effect != LightEffect.LIGHT_EFFECTS_OFF + params["enable"] = enable + if enable: + params = light_effect._effect_mapping[effect] + params["enable"] = enable + params["brightness"] = brightness.brightness # use the existing brightness + + call.assert_called_with("set_lighting_effect", params) + + await dev.update() + assert light_effect.effect == effect + assert feature.value == effect + + with pytest.raises(ValueError): + await light_effect.set_effect("foobar") + + +@light_strip_effect +@pytest.mark.parametrize("effect_active", [True, False]) +async def test_light_effect_brightness( + dev: Device, effect_active: bool, mocker: MockerFixture +): + """Test that light module uses light_effect for brightness when active.""" + light_module = dev.modules[Module.Light] + + light_effect = dev.modules[Module.SmartLightEffect] + light_effect_set_brightness = mocker.spy(light_effect, "set_brightness") + mock_light_effect_call = mocker.patch.object(light_effect, "call") + + brightness = dev.modules[Module.Brightness] + brightness_set_brightness = mocker.spy(brightness, "set_brightness") + mock_brightness_call = mocker.patch.object(brightness, "call") + + mocker.patch.object( + type(light_effect), + "is_active", + new_callable=mocker.PropertyMock, + return_value=effect_active, + ) + + await light_module.set_brightness(10) + + if effect_active: + assert light_effect.is_active + assert light_effect.brightness == dev.brightness + + light_effect_set_brightness.assert_called_with(10) + mock_light_effect_call.assert_called_with( + "set_lighting_effect", {"brightness": 10, "bAdjusted": True} + ) + else: + assert not light_effect.is_active + + brightness_set_brightness.assert_called_with(10) + mock_brightness_call.assert_called_with("set_device_info", {"brightness": 10}) diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index c0d905789..beed8e8ba 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -89,35 +89,39 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture): assert light_effect_module.has_custom_effects is not None await light_effect_module.set_effect("Off") - assert call.call_count == 1 + call.assert_called() await dev.update() assert light_effect_module.effect == "Off" assert feat.value == "Off" + call.reset_mock() second_effect = effect_list[1] await light_effect_module.set_effect(second_effect) - assert call.call_count == 2 + call.assert_called() await dev.update() assert light_effect_module.effect == second_effect assert feat.value == second_effect + call.reset_mock() last_effect = effect_list[len(effect_list) - 1] await light_effect_module.set_effect(last_effect) - assert call.call_count == 3 + call.assert_called() await dev.update() assert light_effect_module.effect == last_effect assert feat.value == last_effect + call.reset_mock() # Test feature set await feat.set_value(second_effect) - assert call.call_count == 4 + call.assert_called() await dev.update() assert light_effect_module.effect == second_effect assert feat.value == second_effect + call.reset_mock() with pytest.raises(ValueError): await light_effect_module.set_effect("foobar") - assert call.call_count == 4 + call.assert_not_called() @dimmable From 8d1a4a4229ed3fa9646573a788b23a6143ef42e0 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 1 Jul 2024 13:57:13 +0100 Subject: [PATCH 503/892] Disable multi requests on json decode error during multi-request (#1025) Issue affecting some P100 devices --- kasa/smartprotocol.py | 32 +++++++++-- kasa/tests/test_smartprotocol.py | 97 +++++++++++++++++++++++++++++--- 2 files changed, 118 insertions(+), 11 deletions(-) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 22fd49dc0..f7551e33b 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -47,6 +47,9 @@ def __init__( self._terminal_uuid: str = base64.b64encode(md5(uuid.uuid4().bytes)).decode() self._request_id_generator = SnowflakeId(1, 1) self._query_lock = asyncio.Lock() + self._multi_request_batch_size = ( + self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE + ) def get_smart_request(self, method, params=None) -> str: """Get a request message as a string.""" @@ -117,9 +120,16 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic end = len(multi_requests) # Break the requests down as there can be a size limit - step = ( - self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE - ) + step = self._multi_request_batch_size + if step == 1: + # If step is 1 do not send request batches + for request in multi_requests: + method = request["method"] + req = self.get_smart_request(method, request["params"]) + resp = await self._transport.send(req) + self._handle_response_error_code(resp, method, raise_on_error=False) + multi_result[method] = resp["result"] + return multi_result for i in range(0, end, step): requests_step = multi_requests[i : i + step] @@ -141,7 +151,21 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic batch_name, pf(response_step), ) - self._handle_response_error_code(response_step, batch_name) + try: + self._handle_response_error_code(response_step, batch_name) + except DeviceError as ex: + # P100 sometimes raises JSON_DECODE_FAIL_ERROR on batched request so + # disable batching + if ( + ex.error_code is SmartErrorCode.JSON_DECODE_FAIL_ERROR + and self._multi_request_batch_size != 1 + ): + self._multi_request_batch_size = 1 + raise _RetryableError( + "JSON Decode failure, multi requests disabled" + ) from ex + raise ex + responses = response_step["result"]["responses"] for response in responses: method = response["method"] diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 5ead00d61..d362fd00a 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -2,10 +2,9 @@ import pytest -from ..credentials import Credentials -from ..deviceconfig import DeviceConfig from ..exceptions import ( SMART_RETRYABLE_ERRORS, + DeviceError, KasaException, SmartErrorCode, ) @@ -93,7 +92,6 @@ async def test_smart_device_errors_in_multiple_request( async def test_smart_device_multiple_request( dummy_protocol, mocker, request_size, batch_size ): - host = "127.0.0.1" requests = {} mock_response = { "result": {"responses": []}, @@ -109,16 +107,101 @@ async def test_smart_device_multiple_request( send_mock = mocker.patch.object( dummy_protocol._transport, "send", return_value=mock_response ) - config = DeviceConfig( - host, credentials=Credentials("foo", "bar"), batch_size=batch_size - ) - dummy_protocol._transport._config = config + dummy_protocol._multi_request_batch_size = batch_size await dummy_protocol.query(requests, retry_count=0) expected_count = int(request_size / batch_size) + (request_size % batch_size > 0) assert send_mock.call_count == expected_count +async def test_smart_device_multiple_request_json_decode_failure( + dummy_protocol, mocker +): + """Test the logic to disable multiple requests on JSON_DECODE_FAIL_ERROR.""" + requests = {} + mock_responses = [] + + mock_json_error = { + "result": {"responses": []}, + "error_code": SmartErrorCode.JSON_DECODE_FAIL_ERROR.value, + } + for i in range(10): + method = f"get_method_{i}" + requests[method] = {"foo": "bar", "bar": "foo"} + mock_responses.append( + {"method": method, "result": {"great": "success"}, "error_code": 0} + ) + + send_mock = mocker.patch.object( + dummy_protocol._transport, + "send", + side_effect=[mock_json_error, *mock_responses], + ) + dummy_protocol._multi_request_batch_size = 5 + assert dummy_protocol._multi_request_batch_size == 5 + await dummy_protocol.query(requests, retry_count=1) + assert dummy_protocol._multi_request_batch_size == 1 + # Call count should be the first error + number of requests + assert send_mock.call_count == len(requests) + 1 + + +async def test_smart_device_multiple_request_json_decode_failure_twice( + dummy_protocol, mocker +): + """Test the logic to disable multiple requests on JSON_DECODE_FAIL_ERROR.""" + requests = {} + + mock_json_error = { + "result": {"responses": []}, + "error_code": SmartErrorCode.JSON_DECODE_FAIL_ERROR.value, + } + for i in range(10): + method = f"get_method_{i}" + requests[method] = {"foo": "bar", "bar": "foo"} + + send_mock = mocker.patch.object( + dummy_protocol._transport, + "send", + side_effect=[mock_json_error, KasaException], + ) + dummy_protocol._multi_request_batch_size = 5 + with pytest.raises(KasaException): + await dummy_protocol.query(requests, retry_count=1) + assert dummy_protocol._multi_request_batch_size == 1 + + assert send_mock.call_count == 2 + + +async def test_smart_device_multiple_request_non_json_decode_failure( + dummy_protocol, mocker +): + """Test the logic to disable multiple requests on JSON_DECODE_FAIL_ERROR. + + Ensure other exception types behave as expected. + """ + requests = {} + + mock_json_error = { + "result": {"responses": []}, + "error_code": SmartErrorCode.UNKNOWN_METHOD_ERROR.value, + } + for i in range(10): + method = f"get_method_{i}" + requests[method] = {"foo": "bar", "bar": "foo"} + + send_mock = mocker.patch.object( + dummy_protocol._transport, + "send", + side_effect=[mock_json_error, KasaException], + ) + dummy_protocol._multi_request_batch_size = 5 + with pytest.raises(DeviceError): + await dummy_protocol.query(requests, retry_count=1) + assert dummy_protocol._multi_request_batch_size == 5 + + assert send_mock.call_count == 1 + + async def test_childdevicewrapper_unwrapping(dummy_protocol, mocker): """Test that responseData gets unwrapped correctly.""" wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol) From 03f72b8be08f2b4cc008563efbc508a70d983bf9 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 1 Jul 2024 14:33:28 +0100 Subject: [PATCH 504/892] Disable multi-request on unknown errors (#1027) Another P100 fix --- kasa/smartprotocol.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index f7551e33b..e6741bc47 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -154,10 +154,14 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic try: self._handle_response_error_code(response_step, batch_name) except DeviceError as ex: - # P100 sometimes raises JSON_DECODE_FAIL_ERROR on batched request so - # disable batching + # P100 sometimes raises JSON_DECODE_FAIL_ERROR or INTERNAL_UNKNOWN_ERROR + # on batched request so disable batching if ( - ex.error_code is SmartErrorCode.JSON_DECODE_FAIL_ERROR + ex.error_code + in { + SmartErrorCode.JSON_DECODE_FAIL_ERROR, + SmartErrorCode.INTERNAL_UNKNOWN_ERROR, + } and self._multi_request_batch_size != 1 ): self._multi_request_batch_size = 1 From 38a8c964b25e3e6969e1d65e6a87411c5b769bba Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 1 Jul 2024 15:02:21 +0100 Subject: [PATCH 505/892] Prepare 0.7.0.2 (#1028) ## [0.7.0.2](https://github.com/python-kasa/python-kasa/tree/0.7.0.2) (2024-07-01) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.1...0.7.0.2) This patch release fixes some minor issues found out during testing against all new homeassistant platforms. **Fixed bugs:** - Disable multi-request on unknown errors [\#1027](https://github.com/python-kasa/python-kasa/pull/1027) (@sdb9696) - Disable multi requests on json decode error during multi-request [\#1025](https://github.com/python-kasa/python-kasa/pull/1025) (@sdb9696) - Fix changing brightness when effect is active [\#1019](https://github.com/python-kasa/python-kasa/pull/1019) (@rytilahti) - Update light transition module to work with child devices [\#1017](https://github.com/python-kasa/python-kasa/pull/1017) (@sdb9696) - Handle unknown error codes gracefully [\#1016](https://github.com/python-kasa/python-kasa/pull/1016) (@rytilahti) **Project maintenance:** - Make parent attribute on device consistent across iot and smart [\#1023](https://github.com/python-kasa/python-kasa/pull/1023) (@sdb9696) - Cache SmartErrorCode creation [\#1022](https://github.com/python-kasa/python-kasa/pull/1022) (@bdraco) --- CHANGELOG.md | 48 +++++++++++++++++++++++++++++++++--------------- poetry.lock | 17 +++++++++++++---- pyproject.toml | 2 +- 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6746f91..a80adb555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,30 @@ # Changelog +## [0.7.0.2](https://github.com/python-kasa/python-kasa/tree/0.7.0.2) (2024-07-01) -## [0.7.0.1](https://github.com/python-kasa/python-kasa/tree/0.7.0.1) (2024-06-25) +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.1...0.7.0.2) This patch release fixes some minor issues found out during testing against all new homeassistant platforms. +**Fixed bugs:** + +- Disable multi-request on unknown errors [\#1027](https://github.com/python-kasa/python-kasa/pull/1027) (@sdb9696) +- Disable multi requests on json decode error during multi-request [\#1025](https://github.com/python-kasa/python-kasa/pull/1025) (@sdb9696) +- Fix changing brightness when effect is active [\#1019](https://github.com/python-kasa/python-kasa/pull/1019) (@rytilahti) +- Update light transition module to work with child devices [\#1017](https://github.com/python-kasa/python-kasa/pull/1017) (@sdb9696) +- Handle unknown error codes gracefully [\#1016](https://github.com/python-kasa/python-kasa/pull/1016) (@rytilahti) + +**Project maintenance:** + +- Make parent attribute on device consistent across iot and smart [\#1023](https://github.com/python-kasa/python-kasa/pull/1023) (@sdb9696) +- Cache SmartErrorCode creation [\#1022](https://github.com/python-kasa/python-kasa/pull/1022) (@bdraco) + +## [0.7.0.1](https://github.com/python-kasa/python-kasa/tree/0.7.0.1) (2024-06-25) + [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0...0.7.0.1) +This patch release fixes some minor issues found out during testing against all new homeassistant platforms. + **Fixed bugs:** - Disable lighttransition module on child devices [\#1013](https://github.com/python-kasa/python-kasa/pull/1013) (@sdb9696) @@ -54,24 +72,19 @@ For more information on the changes please checkout our [documentation on the AP **Implemented enhancements:** - Cleanup cli output [\#1000](https://github.com/python-kasa/python-kasa/pull/1000) (@rytilahti) -- Improve autooff name and unit [\#997](https://github.com/python-kasa/python-kasa/pull/997) (@rytilahti) - Update mode, time, rssi and report\_interval feature names/units [\#995](https://github.com/python-kasa/python-kasa/pull/995) (@sdb9696) -- Add unit\_getter for feature [\#993](https://github.com/python-kasa/python-kasa/pull/993) (@rytilahti) - Add timezone to on\_since attributes [\#978](https://github.com/python-kasa/python-kasa/pull/978) (@sdb9696) - Add type hints to feature set\_value [\#974](https://github.com/python-kasa/python-kasa/pull/974) (@sdb9696) - Handle unknown light effect names and only calculate effect list once [\#973](https://github.com/python-kasa/python-kasa/pull/973) (@sdb9696) - Support smart child modules queries [\#967](https://github.com/python-kasa/python-kasa/pull/967) (@sdb9696) - Do not expose child modules on parent devices [\#964](https://github.com/python-kasa/python-kasa/pull/964) (@sdb9696) - Do not add parent only modules to strip sockets [\#963](https://github.com/python-kasa/python-kasa/pull/963) (@sdb9696) -- Add time sync command [\#951](https://github.com/python-kasa/python-kasa/pull/951) (@rytilahti) - Make device initialisation easier by reducing required imports [\#936](https://github.com/python-kasa/python-kasa/pull/936) (@sdb9696) - Fix set\_state for common light modules [\#929](https://github.com/python-kasa/python-kasa/pull/929) (@sdb9696) - Add state feature for iot devices [\#924](https://github.com/python-kasa/python-kasa/pull/924) (@rytilahti) - Add post update hook to module and use in smart LightEffect [\#921](https://github.com/python-kasa/python-kasa/pull/921) (@sdb9696) - Add LightEffect module for smart light strips [\#918](https://github.com/python-kasa/python-kasa/pull/918) (@sdb9696) -- Add light presets common module to devices. [\#907](https://github.com/python-kasa/python-kasa/pull/907) (@sdb9696) - Improve categorization of features [\#904](https://github.com/python-kasa/python-kasa/pull/904) (@rytilahti) -- Create common interfaces for remaining device types [\#895](https://github.com/python-kasa/python-kasa/pull/895) (@sdb9696) - Make get\_module return typed module [\#892](https://github.com/python-kasa/python-kasa/pull/892) (@sdb9696) - Add LightEffectModule for dynamic light effects on SMART bulbs [\#887](https://github.com/python-kasa/python-kasa/pull/887) (@sdb9696) - Implement choice feature type [\#880](https://github.com/python-kasa/python-kasa/pull/880) (@rytilahti) @@ -100,6 +113,11 @@ For more information on the changes please checkout our [documentation on the AP - Add --child option to feature command [\#789](https://github.com/python-kasa/python-kasa/pull/789) (@rytilahti) - Add temperature\_unit feature to t315 [\#788](https://github.com/python-kasa/python-kasa/pull/788) (@rytilahti) - Add feature for ambient light sensor [\#787](https://github.com/python-kasa/python-kasa/pull/787) (@shifty35) +- Improve autooff name and unit [\#997](https://github.com/python-kasa/python-kasa/pull/997) (@rytilahti) +- Add unit\_getter for feature [\#993](https://github.com/python-kasa/python-kasa/pull/993) (@rytilahti) +- Add time sync command [\#951](https://github.com/python-kasa/python-kasa/pull/951) (@rytilahti) +- Add light presets common module to devices. [\#907](https://github.com/python-kasa/python-kasa/pull/907) (@sdb9696) +- Create common interfaces for remaining device types [\#895](https://github.com/python-kasa/python-kasa/pull/895) (@sdb9696) - Add initial support for H100 and T315 [\#776](https://github.com/python-kasa/python-kasa/pull/776) (@rytilahti) - Generalize smartdevice child support [\#775](https://github.com/python-kasa/python-kasa/pull/775) (@rytilahti) - Raise CLI errors in debug mode [\#771](https://github.com/python-kasa/python-kasa/pull/771) (@sdb9696) @@ -117,18 +135,13 @@ For more information on the changes please checkout our [documentation on the AP - Fix smart led status to report rule status [\#1002](https://github.com/python-kasa/python-kasa/pull/1002) (@sdb9696) - Demote device\_time back to debug [\#1001](https://github.com/python-kasa/python-kasa/pull/1001) (@rytilahti) -- Fix to call update when only --device-family passed to cli [\#987](https://github.com/python-kasa/python-kasa/pull/987) (@sdb9696) -- Disallow non-targeted device commands [\#982](https://github.com/python-kasa/python-kasa/pull/982) (@rytilahti) - Add supported check to light transition module [\#971](https://github.com/python-kasa/python-kasa/pull/971) (@sdb9696) - Fix switching off light effects for iot lights strips [\#961](https://github.com/python-kasa/python-kasa/pull/961) (@sdb9696) - Add state features to iot strip sockets [\#960](https://github.com/python-kasa/python-kasa/pull/960) (@sdb9696) - Ensure http delay logic works during default login attempt [\#959](https://github.com/python-kasa/python-kasa/pull/959) (@sdb9696) - Fix fan speed level when off and derive smart fan module from common fan interface [\#957](https://github.com/python-kasa/python-kasa/pull/957) (@sdb9696) -- Require update in cli for wifi commands [\#956](https://github.com/python-kasa/python-kasa/pull/956) (@rytilahti) -- Do not raise on multi-request errors on child devices [\#949](https://github.com/python-kasa/python-kasa/pull/949) (@rytilahti) - Do not show a zero error code when cli exits from showing help [\#935](https://github.com/python-kasa/python-kasa/pull/935) (@rytilahti) - Initialize autooff features only when data is available [\#933](https://github.com/python-kasa/python-kasa/pull/933) (@rytilahti) -- Fix P100 errors on multi-requests [\#930](https://github.com/python-kasa/python-kasa/pull/930) (@sdb9696) - Fix potential infinite loop if incomplete lists returned [\#920](https://github.com/python-kasa/python-kasa/pull/920) (@sdb9696) - Add 'battery\_percentage' only when it's available [\#906](https://github.com/python-kasa/python-kasa/pull/906) (@rytilahti) - Add missing alarm volume 'normal' [\#899](https://github.com/python-kasa/python-kasa/pull/899) (@rytilahti) @@ -140,6 +153,11 @@ For more information on the changes please checkout our [documentation on the AP - smartbulb: Limit brightness range to 1-100 [\#829](https://github.com/python-kasa/python-kasa/pull/829) (@rytilahti) - Fix energy module calling get\_current\_power [\#798](https://github.com/python-kasa/python-kasa/pull/798) (@sdb9696) - Fix auto update switch [\#786](https://github.com/python-kasa/python-kasa/pull/786) (@rytilahti) +- Fix to call update when only --device-family passed to cli [\#987](https://github.com/python-kasa/python-kasa/pull/987) (@sdb9696) +- Disallow non-targeted device commands [\#982](https://github.com/python-kasa/python-kasa/pull/982) (@rytilahti) +- Require update in cli for wifi commands [\#956](https://github.com/python-kasa/python-kasa/pull/956) (@rytilahti) +- Do not raise on multi-request errors on child devices [\#949](https://github.com/python-kasa/python-kasa/pull/949) (@rytilahti) +- Fix P100 errors on multi-requests [\#930](https://github.com/python-kasa/python-kasa/pull/930) (@sdb9696) - Retry query on 403 after successful handshake [\#785](https://github.com/python-kasa/python-kasa/pull/785) (@sdb9696) - Ensure connections are closed when cli is finished [\#752](https://github.com/python-kasa/python-kasa/pull/752) (@sdb9696) - Fix for P100 on fw 1.1.3 login\_version none [\#751](https://github.com/python-kasa/python-kasa/pull/751) (@sdb9696) @@ -172,19 +190,18 @@ For more information on the changes please checkout our [documentation on the AP - Cleanup README to use the new cli format [\#999](https://github.com/python-kasa/python-kasa/pull/999) (@rytilahti) - Add 0.7 api changes section to docs [\#996](https://github.com/python-kasa/python-kasa/pull/996) (@sdb9696) -- Update README to be more approachable for new users [\#994](https://github.com/python-kasa/python-kasa/pull/994) (@rytilahti) - Update docs with more howto examples [\#968](https://github.com/python-kasa/python-kasa/pull/968) (@sdb9696) - Update documentation structure and start migrating to markdown [\#934](https://github.com/python-kasa/python-kasa/pull/934) (@sdb9696) - Add tutorial doctest module and enable top level await [\#919](https://github.com/python-kasa/python-kasa/pull/919) (@sdb9696) - Add warning about tapo watchdog [\#902](https://github.com/python-kasa/python-kasa/pull/902) (@rytilahti) - Move contribution instructions into docs [\#901](https://github.com/python-kasa/python-kasa/pull/901) (@rytilahti) - Add rust tapo link to README [\#857](https://github.com/python-kasa/python-kasa/pull/857) (@rytilahti) +- Update README to be more approachable for new users [\#994](https://github.com/python-kasa/python-kasa/pull/994) (@rytilahti) - Enable shell extra for installing ptpython and rich [\#782](https://github.com/python-kasa/python-kasa/pull/782) (@sdb9696) - Add WallSwitch device type and autogenerate supported devices docs [\#758](https://github.com/python-kasa/python-kasa/pull/758) (@sdb9696) **Project maintenance:** -- Drop python3.8 support [\#992](https://github.com/python-kasa/python-kasa/pull/992) (@rytilahti) - Remove anyio dependency from pyproject.toml [\#990](https://github.com/python-kasa/python-kasa/pull/990) (@sdb9696) - Configure mypy to run in virtual environment and fix resulting issues [\#989](https://github.com/python-kasa/python-kasa/pull/989) (@sdb9696) - Better checking of child modules not supported by parent device [\#966](https://github.com/python-kasa/python-kasa/pull/966) (@sdb9696) @@ -194,7 +211,6 @@ For more information on the changes please checkout our [documentation on the AP - Deprecate device level light, effect and led attributes [\#916](https://github.com/python-kasa/python-kasa/pull/916) (@sdb9696) - Update cli to use common modules and remove iot specific cli testing [\#913](https://github.com/python-kasa/python-kasa/pull/913) (@sdb9696) - Deprecate is\_something attributes [\#912](https://github.com/python-kasa/python-kasa/pull/912) (@sdb9696) -- Make Light and Fan a common module interface [\#911](https://github.com/python-kasa/python-kasa/pull/911) (@sdb9696) - Rename bulb interface to light and move fan and light interface to interfaces [\#910](https://github.com/python-kasa/python-kasa/pull/910) (@sdb9696) - Make module names consistent and remove redundant module casting [\#909](https://github.com/python-kasa/python-kasa/pull/909) (@sdb9696) - Add child devices from hubs to generated list of supported devices [\#898](https://github.com/python-kasa/python-kasa/pull/898) (@sdb9696) @@ -229,10 +245,12 @@ For more information on the changes please checkout our [documentation on the AP - Do not fail fast on pypy CI jobs [\#799](https://github.com/python-kasa/python-kasa/pull/799) (@sdb9696) - Update dump\_devinfo to collect child device info [\#796](https://github.com/python-kasa/python-kasa/pull/796) (@sdb9696) - Refactor test framework [\#794](https://github.com/python-kasa/python-kasa/pull/794) (@sdb9696) +- Refactor devices into subpackages and deprecate old names [\#716](https://github.com/python-kasa/python-kasa/pull/716) (@sdb9696) +- Drop python3.8 support [\#992](https://github.com/python-kasa/python-kasa/pull/992) (@rytilahti) +- Make Light and Fan a common module interface [\#911](https://github.com/python-kasa/python-kasa/pull/911) (@sdb9696) - Add missing firmware module import [\#774](https://github.com/python-kasa/python-kasa/pull/774) (@rytilahti) - Fix dump\_devinfo scrubbing for ks240 [\#765](https://github.com/python-kasa/python-kasa/pull/765) (@rytilahti) - Rename and deprecate exception classes [\#739](https://github.com/python-kasa/python-kasa/pull/739) (@sdb9696) -- Refactor devices into subpackages and deprecate old names [\#716](https://github.com/python-kasa/python-kasa/pull/716) (@sdb9696) ## [0.6.2.1](https://github.com/python-kasa/python-kasa/tree/0.6.2.1) (2024-02-02) diff --git a/poetry.lock b/poetry.lock index aec24c096..b6511e147 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "aiohttp" @@ -1653,6 +1653,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1660,8 +1661,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1678,6 +1686,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1685,6 +1694,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -2051,13 +2061,12 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [[package]] name = "voluptuous" -version = "0.15.0" +version = "0.15.1" description = "Python data validation library" optional = false python-versions = ">=3.9" files = [ - {file = "voluptuous-0.15.0-py3-none-any.whl", hash = "sha256:ab8d0c3b74b83d062b72fde6ed120b9801d7acb7e504666b0f278dd214ae7ce5"}, - {file = "voluptuous-0.15.0.tar.gz", hash = "sha256:90fb449f6088f3985b24c0df79887e3823355639e0a6a220394ceac07258aea0"}, + {file = "voluptuous-0.15.1.tar.gz", hash = "sha256:4ba7f38f624379ecd02666e87e99cb24b6f5997a28258d3302c761d1a2c35d00"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index f7bb8acda..45350aefd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.1" +version = "0.7.0.2" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 1bf6d80b2a389ea30dc0a59667f649bc6b605395 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 2 Jul 2024 12:30:43 +0100 Subject: [PATCH 506/892] Update changelog generator config (#1030) Move the static command line options into the config file for consistency and remove `--no-issues` in favour of `issues-wo-labels=false` to fix the problem where `release-summary` issues are being excluded from the changelog. --- .github_changelog_generator | 10 ++++++++-- RELEASING.md | 6 ++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github_changelog_generator b/.github_changelog_generator index 9a0c0af9d..f72e740cd 100644 --- a/.github_changelog_generator +++ b/.github_changelog_generator @@ -1,5 +1,11 @@ -breaking_labels=breaking change -add-sections={"new-device":{"prefix":"**Added support for devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}} +output=CHANGELOG.md +base=HISTORY.md +user=python-kasa +project=python-kasa +since-tag=0.3.5 release_branch=master usernames-as-github-logins=true +breaking_labels=breaking change +add-sections={"new-device":{"prefix":"**Added support for devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}} exclude-labels=duplicate,question,invalid,wontfix,release-prep +issues-wo-labels=false diff --git a/RELEASING.md b/RELEASING.md index 476e9de59..e42e1c871 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -73,6 +73,8 @@ gh issue close ISSUE_NUMBER ## Generate changelog +Configuration settings are in `.github_changelog_generator` + ### For pre-release EXCLUDE_TAGS will exclude all dev tags except for the current release dev tags. @@ -82,13 +84,13 @@ Regex should be something like this `^((?!0\.7\.0)(.*dev\d))+`. The first match ```bash EXCLUDE_TAGS=${NEW_RELEASE%.dev*}; EXCLUDE_TAGS=${EXCLUDE_TAGS//"."/"\."}; EXCLUDE_TAGS="^((?!"$EXCLUDE_TAGS")(.*dev\d))+" echo "$EXCLUDE_TAGS" -github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md --no-issues --exclude-tags-regex "$EXCLUDE_TAGS" +github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex "$EXCLUDE_TAGS" ``` ### For production ```bash -github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md --no-issues --exclude-tags-regex 'dev\d$' +github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex 'dev\d$' ``` You can ignore warnings about missing PR commits like below as these relate to PRs to branches other than master: From e5b959e4a9dc612c419424ed7e4c1d5d25a6eca3 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 2 Jul 2024 14:36:57 +0200 Subject: [PATCH 507/892] Add L920(EU) v1.1.3 fixture (#1031) --- SUPPORTED.md | 1 + .../fixtures/smart/L920-5(EU)_1.0_1.1.3.json | 436 ++++++++++++++++++ 2 files changed, 437 insertions(+) create mode 100644 kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json diff --git a/SUPPORTED.md b/SUPPORTED.md index a644254a6..08ae8ada3 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -209,6 +209,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.1.0 - **L920-5** - Hardware: 1.0 (EU) / Firmware: 1.0.7 + - Hardware: 1.0 (EU) / Firmware: 1.1.3 - Hardware: 1.0 (US) / Firmware: 1.1.0 - Hardware: 1.0 (US) / Firmware: 1.1.3 - **L930-5** diff --git a/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json b/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json new file mode 100644 index 000000000..0e7679e2b --- /dev/null +++ b/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json @@ -0,0 +1,436 @@ +{ + "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(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "1C-61-B4-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": false, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "light_strip", + "brightness": 65, + "color_temp": 0, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 65, + "color_temp": 0, + "hue": 9, + "saturation": 67 + }, + "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": 9, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "de_DE", + "lighting_effect": { + "brightness": 65, + "custom": 0, + "display_colors": [ + [ + 136, + 98, + 100 + ], + [ + 350, + 97, + 100 + ] + ], + "enable": 0, + "id": "TapoStrip_5zkiG6avJ1IbhjiZbRlWvh", + "name": "Christmas" + }, + "mac": "1C-61-B4-00-00-00", + "model": "L920", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -56, + "saturation": 67, + "segment_effect": { + "brightness": 97, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "Lightning" + }, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1719920893 + }, + "get_device_usage": { + "power_usage": { + "past30": 20, + "past7": 20, + "today": 0 + }, + "saved_power": { + "past30": 319, + "past7": 319, + "today": 0 + }, + "time_usage": { + "past30": 339, + "past7": 339, + "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_lighting_effect": { + "backgrounds": [ + [ + 136, + 98, + 75 + ], + [ + 136, + 0, + 0 + ], + [ + 350, + 0, + 100 + ], + [ + 350, + 97, + 94 + ] + ], + "brightness": 65, + "brightness_range": [ + 50, + 100 + ], + "custom": 0, + "display_colors": [ + [ + 136, + 98, + 100 + ], + [ + 350, + 97, + 100 + ] + ], + "duration": 5000, + "enable": 0, + "expansion_strategy": 1, + "fadeoff": 2000, + "hue_range": [ + 136, + 146 + ], + "id": "TapoStrip_5zkiG6avJ1IbhjiZbRlWvh", + "init_states": [ + [ + 136, + 0, + 100 + ] + ], + "name": "Christmas", + "random_seed": 100, + "saturation_range": [ + 90, + 100 + ], + "segments": [ + 0 + ], + "transition": 0, + "type": "random" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": true + }, + "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 + }, + { + "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 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 24, + "start_index": 0, + "sum": 0 + }, + "get_segment_effect_rule": { + "brightness": 97, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "Lightning" + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 1, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L920", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} From b8a87f1c571aee48aa2f168aefdca9509ac53915 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 2 Jul 2024 13:43:37 +0100 Subject: [PATCH 508/892] Fix credential hash to return None on empty credentials (#1029) If discovery is triggered without credentials and discovers devices requiring authentication, blank credentials are used to initialise the protocols and no connection is actually made. In this instance we should not return the credentials_hash for blank credentials as it will be invalid. --- kasa/aestransport.py | 4 ++- kasa/klaptransport.py | 4 ++- kasa/protocol.py | 2 +- kasa/tests/fakeprotocol_iot.py | 4 +-- kasa/tests/test_protocol.py | 64 +++++++++++++++++++++++++++++++++- kasa/xortransport.py | 4 +-- 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index cc373b190..c9cb83bd3 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -117,8 +117,10 @@ def default_port(self) -> int: return self.DEFAULT_PORT @property - def credentials_hash(self) -> str: + def credentials_hash(self) -> str | None: """The hashed credentials used by the transport.""" + if self._credentials == Credentials(): + return None return base64.b64encode(json_dumps(self._login_params).encode()).decode() def _get_login_params(self, credentials: Credentials) -> dict[str, str]: diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 3a1eb3367..dd90ffd28 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -132,8 +132,10 @@ def default_port(self): return self.DEFAULT_PORT @property - def credentials_hash(self) -> str: + def credentials_hash(self) -> str | None: """The hashed credentials used by the transport.""" + if self._credentials == Credentials(): + return None return base64.b64encode(self._local_auth_hash).decode() async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: diff --git a/kasa/protocol.py b/kasa/protocol.py index c7d505b8a..7d717c5ed 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -59,7 +59,7 @@ def default_port(self) -> int: @property @abstractmethod - def credentials_hash(self) -> str: + def credentials_hash(self) -> str | None: """The hashed credentials used by the transport.""" @abstractmethod diff --git a/kasa/tests/fakeprotocol_iot.py b/kasa/tests/fakeprotocol_iot.py index 523205989..9c5f655c4 100644 --- a/kasa/tests/fakeprotocol_iot.py +++ b/kasa/tests/fakeprotocol_iot.py @@ -234,8 +234,8 @@ def default_port(self) -> int: return 9999 @property - def credentials_hash(self) -> str: - return "" + def credentials_hash(self) -> None: + return None def set_alias(self, x, child_ids=None): if child_ids is None: diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index e0ddbbb43..1aeeedb27 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -13,6 +13,7 @@ from ..aestransport import AesTransport from ..credentials import Credentials +from ..device import Device from ..deviceconfig import DeviceConfig from ..exceptions import KasaException from ..iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol @@ -512,11 +513,72 @@ def test_transport_init_signature(class_name_obj): ) +@pytest.mark.parametrize( + ("transport_class", "login_version", "expected_hash"), + [ + pytest.param( + AesTransport, + 1, + "eyJwYXNzd29yZCI6IlFtRnkiLCJ1c2VybmFtZSI6Ik1qQXhZVFppTXpBMU0yTmpNVFF5TW1ReVl6TTJOekJpTmpJMk1UWXlNakZrTWpJNU1Ea3lPUT09In0=", + id="aes-lv-1", + ), + pytest.param( + AesTransport, + 2, + "eyJwYXNzd29yZDIiOiJaVFE1Tm1aa01qQXhNelprTkdKaU56Z3lPR1ZpWWpCaFlqa3lOV0l4WW1RNU56Y3lNRGhsTkE9PSIsInVzZXJuYW1lIjoiTWpBeFlUWmlNekExTTJOak1UUXlNbVF5WXpNMk56QmlOakkyTVRZeU1qRmtNakk1TURreU9RPT0ifQ==", + id="aes-lv-2", + ), + pytest.param(KlapTransport, 1, "xBhMRGYWStVCVk9aSD8/6Q==", id="klap-lv-1"), + pytest.param(KlapTransport, 2, "xBhMRGYWStVCVk9aSD8/6Q==", id="klap-lv-2"), + pytest.param( + KlapTransportV2, + 1, + "tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=", + id="klapv2-lv-1", + ), + pytest.param( + KlapTransportV2, + 2, + "tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=", + id="klapv2-lv-2", + ), + pytest.param(XorTransport, None, None, id="xor"), + ], +) +@pytest.mark.parametrize( + ("credentials", "expected_blank"), + [ + pytest.param(Credentials("Foo", "Bar"), False, id="credentials"), + pytest.param(None, True, id="no-credentials"), + pytest.param(Credentials(None, "Bar"), True, id="no-username"), # type: ignore[arg-type] + ], +) +async def test_transport_credentials_hash( + mocker, transport_class, login_version, expected_hash, credentials, expected_blank +): + """Test that the actual hashing doesn't break and empty credential returns an empty hash.""" + host = "127.0.0.1" + + params = Device.ConnectionParameters( + device_family=Device.Family.SmartTapoPlug, + encryption_type=Device.EncryptionType.Xor, + login_version=login_version, + ) + config = DeviceConfig(host, credentials=credentials, connection_type=params) + transport = transport_class(config=config) + + credentials_hash = transport.credentials_hash + + expected = None if expected_blank else expected_hash + assert credentials_hash == expected + + @pytest.mark.parametrize( "transport_class", [AesTransport, KlapTransport, KlapTransportV2, XorTransport, XorTransport], ) -async def test_transport_credentials_hash(mocker, transport_class): +async def test_transport_credentials_hash_from_config(mocker, transport_class): + """Test that credentials_hash provided via config sets correctly.""" host = "127.0.0.1" credentials = Credentials("Foo", "Bar") diff --git a/kasa/xortransport.py b/kasa/xortransport.py index e96864533..52fba3d3e 100644 --- a/kasa/xortransport.py +++ b/kasa/xortransport.py @@ -54,9 +54,9 @@ def default_port(self): return self.DEFAULT_PORT @property - def credentials_hash(self) -> str: + def credentials_hash(self) -> str | None: """The hashed credentials used by the transport.""" - return "" + return None async def _connect(self, timeout: int) -> None: """Try to connect or reconnect to the device.""" From 9cffbe9e485c004f2b6b4f685d2524f26fc29d0d Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 2 Jul 2024 14:11:19 +0100 Subject: [PATCH 509/892] Support child devices in all applicable cli commands (#1020) Adds a new decorator that adds child options to a command and gets the child device if the options are set. - Single definition of options and error handling - Adds options automatically to command - Backwards compatible with `--index` and `--name` - `--child` allows for id and alias for ease of use - Omitting a value for `--child` gives an interactive prompt Implements private `_update` to allow the CLI to patch a child `update` method to call the parent device `update`. Example help output: ``` $ kasa brightness --help Usage: kasa brightness [OPTIONS] [BRIGHTNESS] Get or set brightness. Options: --transition INTEGER --child, --name TEXT Child ID or alias for controlling sub- devices. If no value provided will show an interactive prompt allowing you to select a child. --child-index, --index INTEGER Child index controlling sub-devices --help Show this message and exit. ``` Fixes #769 --- kasa/cli.py | 316 ++++++++++++++++++++------------- kasa/device.py | 12 +- kasa/iot/iotstrip.py | 10 +- kasa/smart/smartchilddevice.py | 8 + kasa/smart/smartdevice.py | 2 +- kasa/tests/test_cli.py | 119 ++++++++++++- 6 files changed, 333 insertions(+), 134 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 4d0a1db5e..10c422978 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -8,11 +8,11 @@ import logging import re import sys -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, contextmanager from datetime import datetime -from functools import singledispatch, wraps +from functools import singledispatch, update_wrapper, wraps from pprint import pformat as pf -from typing import Any, cast +from typing import Any, Final, cast import asyncclick as click from pydantic.v1 import ValidationError @@ -41,6 +41,7 @@ IotStrip, IotWallSwitch, ) +from kasa.iot.iotstrip import IotStripPlug from kasa.iot.modules import Usage from kasa.smart import SmartDevice @@ -77,6 +78,9 @@ def error(msg: str): sys.exit(1) +# Value for optional options if passed without a value +OPTIONAL_VALUE_FLAG: Final = "_FLAG_" + TYPE_TO_CLASS = { "plug": IotPlug, "switch": IotWallSwitch, @@ -169,6 +173,112 @@ def _device_to_serializable(val: Device): print(json_content) +def pass_dev_or_child(wrapped_function): + """Pass the device or child to the click command based on the child options.""" + child_help = ( + "Child ID or alias for controlling sub-devices. " + "If no value provided will show an interactive prompt allowing you to " + "select a child." + ) + child_index_help = "Child index controlling sub-devices" + + @contextmanager + def patched_device_update(parent: Device, child: Device): + try: + orig_update = child.update + # patch child update method. Can be removed once update can be called + # directly on child devices + child.update = parent.update # type: ignore[method-assign] + yield child + finally: + child.update = orig_update # type: ignore[method-assign] + + @click.pass_obj + @click.pass_context + @click.option( + "--child", + "--name", + is_flag=False, + flag_value=OPTIONAL_VALUE_FLAG, + default=None, + required=False, + type=click.STRING, + help=child_help, + ) + @click.option( + "--child-index", + "--index", + required=False, + default=None, + type=click.INT, + help=child_index_help, + ) + async def wrapper(ctx: click.Context, dev, *args, child, child_index, **kwargs): + if child := await _get_child_device(dev, child, child_index, ctx.info_name): + ctx.obj = ctx.with_resource(patched_device_update(dev, child)) + dev = child + return await ctx.invoke(wrapped_function, dev, *args, **kwargs) + + # Update wrapper function to look like wrapped function + return update_wrapper(wrapper, wrapped_function) + + +async def _get_child_device( + device: Device, child_option, child_index_option, info_command +) -> Device | None: + def _list_children(): + return "\n".join( + [ + f"{idx}: {child.device_id} ({child.alias})" + for idx, child in enumerate(device.children) + ] + ) + + if child_option is None and child_index_option is None: + return None + + if info_command in SKIP_UPDATE_COMMANDS: + # The device hasn't had update called (e.g. for cmd_command) + # The way child devices are accessed requires a ChildDevice to + # wrap the communications. Doing this properly would require creating + # a common interfaces for both IOT and SMART child devices. + # As a stop-gap solution, we perform an update instead. + await device.update() + + if not device.children: + error(f"Device: {device.host} does not have children") + + if child_option is not None and child_index_option is not None: + raise click.BadOptionUsage( + "child", "Use either --child or --child-index, not both." + ) + + if child_option is not None: + if child_option is OPTIONAL_VALUE_FLAG: + msg = _list_children() + child_index_option = click.prompt( + f"\n{msg}\nEnter the index number of the child device", + type=click.IntRange(0, len(device.children) - 1), + ) + elif child := device.get_child_device(child_option): + echo(f"Targeting child device {child.alias}") + return child + else: + error( + "No child device found with device_id or name: " + f"{child_option} children are:\n{_list_children()}" + ) + + if child_index_option + 1 > len(device.children) or child_index_option < 0: + error( + f"Invalid index {child_index_option}, " + f"device has {len(device.children)} children" + ) + child_by_index = device.children[child_index_option] + echo(f"Targeting child device {child_by_index.alias}") + return child_by_index + + @click.group( invoke_without_command=True, cls=CatchAllExceptions(click.Group), @@ -232,6 +342,7 @@ def _device_to_serializable(val: Device): help="Output raw device response as JSON.", ) @click.option( + "-e", "--encrypt-type", envvar="KASA_ENCRYPT_TYPE", default=None, @@ -240,13 +351,14 @@ def _device_to_serializable(val: Device): @click.option( "--device-family", envvar="KASA_DEVICE_FAMILY", - default=None, + default="SMART.TAPOPLUG", type=click.Choice(DEVICE_FAMILY_TYPES, case_sensitive=False), ) @click.option( + "-lv", "--login-version", envvar="KASA_LOGIN_VERSION", - default=None, + default=2, type=int, ) @click.option( @@ -379,7 +491,8 @@ def _nop_echo(*args, **kwargs): device_updated = False if type is not None: - dev = TYPE_TO_CLASS[type](host) + config = DeviceConfig(host=host, port_override=port, timeout=timeout) + dev = TYPE_TO_CLASS[type](host, config=config) elif device_family and encrypt_type: ctype = DeviceConnectionParameters( DeviceFamily(device_family), @@ -397,12 +510,6 @@ def _nop_echo(*args, **kwargs): dev = await Device.connect(config=config) device_updated = True else: - if device_family or encrypt_type: - echo( - "--device-family and --encrypt-type options must both be " - "provided or they are ignored\n" - f"discovering for {discovery_timeout} seconds.." - ) dev = await Discover.discover_single( host, port=port, @@ -587,7 +694,7 @@ async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attem @cli.command() -@pass_dev +@pass_dev_or_child async def sysinfo(dev): """Print out full system information.""" echo("== System info ==") @@ -624,6 +731,7 @@ def _echo_all_features(features, *, verbose=False, title_prefix=None, indent="") """Print out all features by category.""" if title_prefix is not None: echo(f"[bold]\n{indent}== {title_prefix} ==[/bold]") + echo() _echo_features( features, title="== Primary features ==", @@ -658,7 +766,7 @@ def _echo_all_features(features, *, verbose=False, title_prefix=None, indent="") @cli.command() -@pass_dev +@pass_dev_or_child @click.pass_context async def state(ctx, dev: Device): """Print out device state and versions.""" @@ -676,11 +784,16 @@ async def state(ctx, dev: Device): if verbose: echo(f"Location: {dev.location}") - _echo_all_features(dev.features, verbose=verbose) echo() + _echo_all_features(dev.features, verbose=verbose) + + if verbose: + echo("\n[bold]== Modules ==[/bold]") + for module in dev.modules.values(): + echo(f"[green]+ {module}[/green]") if dev.children: - echo("[bold]== Children ==[/bold]") + echo("\n[bold]== Children ==[/bold]") for child in dev.children: _echo_all_features( child.features, @@ -688,14 +801,13 @@ async def state(ctx, dev: Device): verbose=verbose, indent="\t", ) - + if verbose: + echo(f"\n\t[bold]== Child {child.alias} Modules ==[/bold]") + for module in child.modules.values(): + echo(f"\t[green]+ {module}[/green]") echo() if verbose: - echo("\n\t[bold]== Modules ==[/bold]") - for module in dev.modules.values(): - echo(f"\t[green]+ {module}[/green]") - echo("\n\t[bold]== Protocol information ==[/bold]") echo(f"\tCredentials hash: {dev.credentials_hash}") echo() @@ -705,24 +817,19 @@ async def state(ctx, dev: Device): @cli.command() -@pass_dev @click.argument("new_alias", required=False, default=None) -@click.option("--index", type=int) -async def alias(dev, new_alias, index): +@pass_dev_or_child +async def alias(dev, new_alias): """Get or set the device (or plug) alias.""" - if index is not None: - if not dev.is_strip: - echo("Index can only used for power strips!") - return - dev = dev.get_plug_by_index(index) - if new_alias is not None: echo(f"Setting alias to {new_alias}") res = await dev.set_alias(new_alias) + await dev.update() + echo(f"Alias set to: {dev.alias}") return res echo(f"Alias: {dev.alias}") - if dev.is_strip: + if dev.children: for plug in dev.children: echo(f" * {plug.alias}") @@ -730,36 +837,26 @@ 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(ctx, dev: Device, module, command, parameters): +async def raw_command(ctx, module, command, parameters): """Run a raw command on the device.""" 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.option("--child", required=False, help="Child ID for controlling sub-devices") @click.argument("command") @click.argument("parameters", default=None, required=False) -async def cmd_command(dev: Device, module, child, command, parameters): +@pass_dev_or_child +async def cmd_command(dev: Device, module, command, parameters): """Run a raw command on the device.""" if parameters is not None: parameters = ast.literal_eval(parameters) - if child: - # The way child devices are accessed requires a ChildDevice to - # wrap the communications. Doing this properly would require creating - # a common interfaces for both IOT and SMART child devices. - # As a stop-gap solution, we perform an update instead. - await dev.update() - dev = dev.get_child_device(child) - if isinstance(dev, IotDevice): res = await dev._query_helper(module, command, parameters) elif isinstance(dev, SmartDevice): @@ -771,27 +868,30 @@ async def cmd_command(dev: Device, module, child, command, parameters): @cli.command() -@pass_dev @click.option("--index", type=int, required=False) @click.option("--name", type=str, required=False) @click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) @click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) @click.option("--erase", is_flag=True) -async def emeter(dev: Device, index: int, name: str, year, month, erase): - """Query emeter for historical consumption. +@click.pass_context +async def emeter(ctx: click.Context, index, name, year, month, erase): + """Query emeter for historical consumption.""" + logging.warning("Deprecated, use 'kasa energy'") + return await ctx.invoke( + energy, child_index=index, child=name, year=year, month=month, erase=erase + ) - Daily and monthly data provided in CSV format. - """ - if index is not None or name is not None: - if not dev.is_strip: - error("Index and name are only for power strips!") - return - if index is not None: - dev = dev.get_plug_by_index(index) - elif name: - dev = dev.get_plug_by_name(name) +@cli.command() +@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) +@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) +@click.option("--erase", is_flag=True) +@pass_dev_or_child +async def energy(dev: Device, year, month, erase): + """Query energy module for historical consumption. + Daily and monthly data provided in CSV format. + """ echo("[bold]== Emeter ==[/bold]") if not dev.has_emeter: error("Device has no emeter") @@ -817,7 +917,7 @@ async def emeter(dev: Device, index: int, name: str, year, month, erase): usage_data = await dev.get_emeter_daily(year=month.year, month=month.month) else: # Call with no argument outputs summary data and returns - if index is not None or name is not None: + if isinstance(dev, IotStripPlug): emeter_status = await dev.get_emeter_realtime() else: emeter_status = dev.emeter_realtime @@ -840,10 +940,10 @@ async def emeter(dev: Device, index: int, name: str, year, month, erase): @cli.command() -@pass_dev @click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) @click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) @click.option("--erase", is_flag=True) +@pass_dev_or_child async def usage(dev: Device, year, month, erase): """Query usage for historical consumption. @@ -881,7 +981,7 @@ async def usage(dev: Device, year, month, erase): @cli.command() @click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) @click.option("--transition", type=int, required=False) -@pass_dev +@pass_dev_or_child async def brightness(dev: Device, brightness: int, transition: int): """Get or set brightness.""" if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: @@ -901,7 +1001,7 @@ async def brightness(dev: Device, brightness: int, transition: int): "temperature", type=click.IntRange(2500, 9000), default=None, required=False ) @click.option("--transition", type=int, required=False) -@pass_dev +@pass_dev_or_child async def temperature(dev: Device, temperature: int, transition: int): """Get or set color temperature.""" if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: @@ -927,7 +1027,7 @@ async def temperature(dev: Device, temperature: int, transition: int): @cli.command() @click.argument("effect", type=click.STRING, default=None, required=False) @click.pass_context -@pass_dev +@pass_dev_or_child async def effect(dev: Device, ctx, effect): """Set an effect.""" if not (light_effect := dev.modules.get(Module.LightEffect)): @@ -955,7 +1055,7 @@ async def effect(dev: Device, ctx, effect): @click.argument("v", type=click.IntRange(0, 100), default=None, required=False) @click.option("--transition", type=int, required=False) @click.pass_context -@pass_dev +@pass_dev_or_child async def hsv(dev: Device, ctx, h, s, v, transition): """Get or set color in HSV.""" if not (light := dev.modules.get(Module.Light)) or not light.is_color: @@ -974,7 +1074,7 @@ async def hsv(dev: Device, ctx, h, s, v, transition): @cli.command() @click.argument("state", type=bool, required=False) -@pass_dev +@pass_dev_or_child async def led(dev: Device, state): """Get or set (Plug's) led state.""" if not (led := dev.modules.get(Module.Led)): @@ -1026,64 +1126,28 @@ async def time_sync(dev: Device): @cli.command() -@click.option("--index", type=int, required=False) -@click.option("--name", type=str, required=False) @click.option("--transition", type=int, required=False) -@pass_dev -async def on(dev: Device, index: int, name: str, transition: int): +@pass_dev_or_child +async def on(dev: Device, transition: int): """Turn the device on.""" - if index is not None or name is not None: - if not dev.children: - error("Index and name are only for devices with children.") - return - - if index is not None: - dev = dev.get_plug_by_index(index) - elif name: - dev = dev.get_plug_by_name(name) - echo(f"Turning on {dev.alias}") return await dev.turn_on(transition=transition) -@cli.command() -@click.option("--index", type=int, required=False) -@click.option("--name", type=str, required=False) +@cli.command @click.option("--transition", type=int, required=False) -@pass_dev -async def off(dev: Device, index: int, name: str, transition: int): +@pass_dev_or_child +async def off(dev: Device, transition: int): """Turn the device off.""" - if index is not None or name is not None: - if not dev.children: - error("Index and name are only for devices with children.") - return - - if index is not None: - dev = dev.get_plug_by_index(index) - elif name: - dev = dev.get_plug_by_name(name) - echo(f"Turning off {dev.alias}") return await dev.turn_off(transition=transition) @cli.command() -@click.option("--index", type=int, required=False) -@click.option("--name", type=str, required=False) @click.option("--transition", type=int, required=False) -@pass_dev -async def toggle(dev: Device, index: int, name: str, transition: int): +@pass_dev_or_child +async def toggle(dev: Device, transition: int): """Toggle the device on/off.""" - if index is not None or name is not None: - if not dev.children: - error("Index and name are only for devices with children.") - return - - if index is not None: - dev = dev.get_plug_by_index(index) - elif name: - dev = dev.get_plug_by_name(name) - if dev.is_on: echo(f"Turning off {dev.alias}") return await dev.turn_off(transition=transition) @@ -1108,9 +1172,9 @@ async def schedule(dev): @schedule.command(name="list") -@pass_dev +@pass_dev_or_child @click.argument("type", default="schedule") -def _schedule_list(dev, type): +async def _schedule_list(dev, type): """Return the list of schedule actions for the given type.""" sched = dev.modules[type] for rule in sched.rules: @@ -1122,7 +1186,7 @@ def _schedule_list(dev, type): @schedule.command(name="delete") -@pass_dev +@pass_dev_or_child @click.option("--id", type=str, required=True) async def delete_rule(dev, id): """Delete rule from device.""" @@ -1136,25 +1200,26 @@ async def delete_rule(dev, id): @cli.group(invoke_without_command=True) +@pass_dev_or_child @click.pass_context -async def presets(ctx): +async def presets(ctx, dev): """List and modify bulb setting presets.""" if ctx.invoked_subcommand is None: return await ctx.invoke(presets_list) @presets.command(name="list") -@pass_dev +@pass_dev_or_child def presets_list(dev: Device): """List presets.""" - if not dev.is_bulb or not isinstance(dev, IotBulb): - error("Presets only supported on iot bulbs") + if not (light_preset := dev.modules.get(Module.LightPreset)): + error("Presets not supported on device") return - for preset in dev.presets: + for preset in light_preset.preset_states_list: echo(preset) - return dev.presets + return light_preset.preset_states_list @presets.command(name="modify") @@ -1163,7 +1228,7 @@ def presets_list(dev: Device): @click.option("--hue", type=int) @click.option("--saturation", type=int) @click.option("--temperature", type=int) -@pass_dev +@pass_dev_or_child async def presets_modify(dev: Device, index, brightness, hue, saturation, temperature): """Modify a preset.""" for preset in dev.presets: @@ -1188,7 +1253,7 @@ async def presets_modify(dev: Device, index, brightness, hue, saturation, temper @cli.command() -@pass_dev +@pass_dev_or_child @click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False)) @click.option("--last", is_flag=True) @click.option("--preset", type=int) @@ -1240,7 +1305,7 @@ async def update_credentials(dev, username, password): @cli.command() -@pass_dev +@pass_dev_or_child async def shell(dev: Device): """Open interactive shell.""" echo("Opening shell for %s" % dev) @@ -1263,10 +1328,14 @@ async def shell(dev: Device): @cli.command(name="feature") @click.argument("name", required=False) @click.argument("value", required=False) -@click.option("--child", required=False) -@pass_dev +@pass_dev_or_child @click.pass_context -async def feature(ctx: click.Context, dev: Device, child: str, name: str, value): +async def feature( + ctx: click.Context, + dev: Device, + name: str, + value, +): """Access and modify features. If no *name* is given, lists available features and their values. @@ -1275,9 +1344,6 @@ async def feature(ctx: click.Context, dev: Device, child: str, name: str, value) """ verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False - if child is not None: - echo(f"Targeting child device {child}") - dev = dev.get_child_device(child) if not name: _echo_all_features(dev.features, verbose=verbose, indent="") diff --git a/kasa/device.py b/kasa/device.py index ac23fdb24..69b7370b0 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -338,9 +338,15 @@ def children(self) -> Sequence[Device]: """Returns the child devices.""" return list(self._children.values()) - def get_child_device(self, id_: str) -> Device: - """Return child device by its ID.""" - return self._children[id_] + def get_child_device(self, name_or_id: str) -> Device | None: + """Return child device by its device_id or alias.""" + if name_or_id in self._children: + return self._children[name_or_id] + name_lower = name_or_id.lower() + for child in self.children: + if child.alias and child.alias.lower() == name_lower: + return child + return None @property @abstractmethod diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 3a1406aa6..61017228d 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -145,7 +145,7 @@ async def update(self, update_children: bool = True): if update_children: for plug in self.children: - await plug.update() + await plug._update() if not self.features: await self._initialize_features() @@ -362,6 +362,14 @@ async def update(self, update_children: bool = True): Needed for properties that are decorated with `requires_update`. """ + await self._update(update_children) + + async def _update(self, update_children: bool = True): + """Query the device to update the data. + + Internal implementation to allow patching of public update in the cli + or test framework. + """ await self._modular_update({}) for module in self._modules.values(): module._post_update_hook() diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index c6596b969..98145f6c9 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -40,6 +40,14 @@ async def update(self, update_children: bool = True): The parent updates our internal info so just update modules with their own queries. """ + await self._update(update_children) + + async def _update(self, update_children: bool = True): + """Update child module info. + + Internal implementation to allow patching of public update in the cli + or test framework. + """ req: dict[str, Any] = {} for module in self.modules.values(): if mod_query := module.query(): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index a5b64e527..408ba0278 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -171,7 +171,7 @@ async def update(self, update_children: bool = False): # devices will always update children to prevent errors on module access. if update_children or self.device_type != DeviceType.Hub: for child in self._children.values(): - await child.update() + await child._update() if child_info := self._try_get_response(resp, "get_child_device_list", {}): for info in child_info["child_device_list"]: self._children[info["device_id"]]._update_internal_state(info) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 4f8157025..06a7d37ae 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -5,6 +5,7 @@ import asyncclick as click import pytest from asyncclick.testing import CliRunner +from pytest_mock import MockerFixture from kasa import ( AuthenticationError, @@ -24,6 +25,7 @@ cmd_command, effect, emeter, + energy, hsv, led, raw_command, @@ -62,7 +64,6 @@ def runner(): [ pytest.param(None, None, id="No connect params"), pytest.param("SMART.TAPOPLUG", None, id="Only device_family"), - pytest.param(None, "KLAP", id="Only encrypt_type"), ], ) async def test_update_called_by_cli(dev, mocker, runner, device_family, encrypt_type): @@ -171,13 +172,16 @@ async def test_command_with_child(dev, mocker, runner): class DummyDevice(dev.__class__): def __init__(self): super().__init__("127.0.0.1") + # device_type and _info initialised for repr + self._device_type = Device.Type.StripSocket + self._info = {} async def _query_helper(*_, **__): return {"dummy": "response"} dummy_child = DummyDevice() - mocker.patch.object(dev, "_children", {"XYZ": dummy_child}) + mocker.patch.object(dev, "_children", {"XYZ": [dummy_child]}) mocker.patch.object(dev, "get_child_device", return_value=dummy_child) res = await runner.invoke( @@ -314,9 +318,9 @@ async def test_emeter(dev: Device, mocker, runner): if not dev.is_strip: res = await runner.invoke(emeter, ["--index", "0"], obj=dev) - assert "Index and name are only for power strips!" in res.output + assert f"Device: {dev.host} does not have children" in res.output res = await runner.invoke(emeter, ["--name", "mock"], obj=dev) - assert "Index and name are only for power strips!" in res.output + assert f"Device: {dev.host} does not have children" in res.output if dev.is_strip and len(dev.children) > 0: realtime_emeter = mocker.patch.object(dev.children[0], "get_emeter_realtime") @@ -930,3 +934,110 @@ async def test_feature_set_child(mocker, runner): assert f"Targeting child device {child_id}" assert "Changing state from False to True" in res.output assert res.exit_code == 0 + + +async def test_cli_child_commands( + dev: Device, runner: CliRunner, mocker: MockerFixture +): + if not dev.children: + res = await runner.invoke(alias, ["--child-index", "0"], obj=dev) + assert f"Device: {dev.host} does not have children" in res.output + assert res.exit_code == 1 + + res = await runner.invoke(alias, ["--index", "0"], obj=dev) + assert f"Device: {dev.host} does not have children" in res.output + assert res.exit_code == 1 + + res = await runner.invoke(alias, ["--child", "Plug 2"], obj=dev) + assert f"Device: {dev.host} does not have children" in res.output + assert res.exit_code == 1 + + res = await runner.invoke(alias, ["--name", "Plug 2"], obj=dev) + assert f"Device: {dev.host} does not have children" in res.output + assert res.exit_code == 1 + + if dev.children: + child_alias = dev.children[0].alias + assert child_alias + child_device_id = dev.children[0].device_id + child_count = len(dev.children) + child_update_method = dev.children[0].update + + # Test child retrieval + res = await runner.invoke(alias, ["--child-index", "0"], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(alias, ["--index", "0"], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(alias, ["--child", child_alias], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(alias, ["--name", child_alias], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(alias, ["--child", child_device_id], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(alias, ["--name", child_device_id], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + # Test invalid name and index + res = await runner.invoke(alias, ["--child-index", "-1"], obj=dev) + assert f"Invalid index -1, device has {child_count} children" in res.output + assert res.exit_code == 1 + + res = await runner.invoke(alias, ["--child-index", str(child_count)], obj=dev) + assert ( + f"Invalid index {child_count}, device has {child_count} children" + in res.output + ) + assert res.exit_code == 1 + + res = await runner.invoke(alias, ["--child", "foobar"], obj=dev) + assert "No child device found with device_id or name: foobar" in res.output + assert res.exit_code == 1 + + # Test using both options: + + res = await runner.invoke( + alias, ["--child", child_alias, "--child-index", "0"], obj=dev + ) + assert "Use either --child or --child-index, not both." in res.output + assert res.exit_code == 2 + + # Test child with no parameter interactive prompt + + res = await runner.invoke(alias, ["--child"], obj=dev, input="0\n") + assert "Enter the index number of the child device:" in res.output + assert f"Alias: {child_alias}" in res.output + assert res.exit_code == 0 + + # Test values and updates + + res = await runner.invoke(alias, ["foo", "--child", child_device_id], obj=dev) + assert "Alias set to: foo" in res.output + assert res.exit_code == 0 + + # Test help has command options plus child options + + res = await runner.invoke(energy, ["--help"], obj=dev) + assert "--year" in res.output + assert "--child" in res.output + assert "--child-index" in res.output + assert res.exit_code == 0 + + # Test child update patching calls parent and is undone on exit + + parent_update_spy = mocker.spy(dev, "update") + res = await runner.invoke(alias, ["bar", "--child", child_device_id], obj=dev) + assert "Alias set to: bar" in res.output + assert res.exit_code == 0 + parent_update_spy.assert_called_once() + assert dev.children[0].update == child_update_method From 905a14895d6111a7a0b50b773bbbd89ca955e5d4 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 4 Jul 2024 08:02:50 +0100 Subject: [PATCH 510/892] Handle module errors more robustly and add query params to light preset and transition (#1036) Ensures that all modules try to access their data in `_post_update_hook` in a safe manner and disable themselves if there's an error. Also adds parameters to get_preset_rules and get_on_off_gradually_info to fix issues with recent firmware updates. [#1033](https://github.com/python-kasa/python-kasa/issues/1033) --- devtools/helpers/smartrequests.py | 11 ++- kasa/smart/modules/autooff.py | 6 -- kasa/smart/modules/batterysensor.py | 4 ++ kasa/smart/modules/cloud.py | 10 ++- kasa/smart/modules/devicemodule.py | 7 ++ kasa/smart/modules/firmware.py | 12 +++- kasa/smart/modules/frostprotection.py | 4 ++ kasa/smart/modules/humiditysensor.py | 4 ++ kasa/smart/modules/lightpreset.py | 2 +- kasa/smart/modules/lighttransition.py | 2 +- kasa/smart/modules/reportmode.py | 4 ++ kasa/smart/modules/temperaturesensor.py | 4 ++ kasa/smart/smartdevice.py | 30 ++++++-- kasa/smart/smartmodule.py | 22 +++++- kasa/smartprotocol.py | 4 ++ kasa/tests/test_smartdevice.py | 94 ++++++++++++++++++++++--- kasa/tests/test_smartprotocol.py | 16 +++++ 17 files changed, 206 insertions(+), 30 deletions(-) diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 881488b5e..4db1f7a1c 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -284,6 +284,15 @@ def get_preset_rules(params: GetRulesParams | None = None) -> SmartRequest: """Get preset rules.""" return SmartRequest("get_preset_rules", params or SmartRequest.GetRulesParams()) + @staticmethod + def get_on_off_gradually_info( + params: SmartRequestParams | None = None, + ) -> SmartRequest: + """Get preset rules.""" + return SmartRequest( + "get_on_off_gradually_info", params or SmartRequest.SmartRequestParams() + ) + @staticmethod def get_auto_light_info() -> SmartRequest: """Get auto light info.""" @@ -382,7 +391,7 @@ def get_component_requests(component_id, ver_code): "auto_light": [SmartRequest.get_auto_light_info()], "light_effect": [SmartRequest.get_dynamic_light_effect_rules()], "bulb_quick_control": [], - "on_off_gradually": [SmartRequest.get_raw_request("get_on_off_gradually_info")], + "on_off_gradually": [SmartRequest.get_on_off_gradually_info()], "light_strip": [], "light_strip_lighting_effect": [ SmartRequest.get_raw_request("get_lighting_effect") diff --git a/kasa/smart/modules/autooff.py b/kasa/smart/modules/autooff.py index 0004aec43..5e4b100f8 100644 --- a/kasa/smart/modules/autooff.py +++ b/kasa/smart/modules/autooff.py @@ -19,12 +19,6 @@ class AutoOff(SmartModule): def _initialize_features(self): """Initialize features after the initial update.""" - if not isinstance(self.data, dict): - _LOGGER.warning( - "No data available for module, skipping %s: %s", self, self.data - ) - return - self._add_feature( Feature( self._device, diff --git a/kasa/smart/modules/batterysensor.py b/kasa/smart/modules/batterysensor.py index 415e47d1e..7ff7df2d8 100644 --- a/kasa/smart/modules/batterysensor.py +++ b/kasa/smart/modules/batterysensor.py @@ -43,6 +43,10 @@ def _initialize_features(self): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def battery(self): """Return battery level.""" diff --git a/kasa/smart/modules/cloud.py b/kasa/smart/modules/cloud.py index 1b64f090a..8346af57a 100644 --- a/kasa/smart/modules/cloud.py +++ b/kasa/smart/modules/cloud.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING -from ...exceptions import SmartErrorCode from ...feature import Feature from ..smartmodule import SmartModule @@ -18,6 +17,13 @@ class Cloud(SmartModule): QUERY_GETTER_NAME = "get_connect_cloud_state" REQUIRED_COMPONENT = "cloud_connect" + def _post_update_hook(self): + """Perform actions after a device update. + + Overrides the default behaviour to disable a module if the query returns + an error because the logic here is to treat that as not connected. + """ + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) @@ -37,6 +43,6 @@ def __init__(self, device: SmartDevice, module: str): @property def is_connected(self): """Return True if device is connected to the cloud.""" - if isinstance(self.data, SmartErrorCode): + if self._has_data_error(): return False return self.data["status"] == 0 diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py index 6a846d542..3203e82fa 100644 --- a/kasa/smart/modules/devicemodule.py +++ b/kasa/smart/modules/devicemodule.py @@ -10,6 +10,13 @@ class DeviceModule(SmartModule): REQUIRED_COMPONENT = "device" + def _post_update_hook(self): + """Perform actions after a device update. + + Overrides the default behaviour to disable a module if the query returns + an error because this module is critical. + """ + def query(self) -> dict: """Query to execute during the update cycle.""" query = { diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 3dcaddd66..10a6b8245 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -13,7 +13,6 @@ from async_timeout import timeout as asyncio_timeout from pydantic.v1 import BaseModel, Field, validator -from ...exceptions import SmartErrorCode from ...feature import Feature from ..smartmodule import SmartModule @@ -123,6 +122,13 @@ def query(self) -> dict: req["get_auto_update_info"] = None return req + def _post_update_hook(self): + """Perform actions after a device update. + + Overrides the default behaviour to disable a module if the query returns + an error because some of the module still functions. + """ + @property def current_firmware(self) -> str: """Return the current firmware version.""" @@ -136,11 +142,11 @@ def latest_firmware(self) -> str: @property def firmware_update_info(self): """Return latest firmware information.""" - fw = self.data.get("get_latest_fw") or self.data - if not self._device.is_cloud_connected or isinstance(fw, SmartErrorCode): + if not self._device.is_cloud_connected or self._has_data_error(): # Error in response, probably disconnected from the cloud. return UpdateInfo(type=0, need_to_upgrade=False) + fw = self.data.get("get_latest_fw") or self.data return UpdateInfo.parse_obj(fw) @property diff --git a/kasa/smart/modules/frostprotection.py b/kasa/smart/modules/frostprotection.py index f1811012f..440e1ed1b 100644 --- a/kasa/smart/modules/frostprotection.py +++ b/kasa/smart/modules/frostprotection.py @@ -14,6 +14,10 @@ class FrostProtection(SmartModule): REQUIRED_COMPONENT = "frost_protection" QUERY_GETTER_NAME = "get_frost_protection" + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def enabled(self) -> bool: """Return True if frost protection is on.""" diff --git a/kasa/smart/modules/humiditysensor.py b/kasa/smart/modules/humiditysensor.py index f0dcc18a4..b137736ff 100644 --- a/kasa/smart/modules/humiditysensor.py +++ b/kasa/smart/modules/humiditysensor.py @@ -45,6 +45,10 @@ def __init__(self, device: SmartDevice, module: str): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def humidity(self): """Return current humidity in percentage.""" diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 8e5cae209..7635a5f86 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -140,7 +140,7 @@ def query(self) -> dict: """Query to execute during the update cycle.""" if self._state_in_sysinfo: # Child lights can have states in the child info return {} - return {self.QUERY_GETTER_NAME: None} + return {self.QUERY_GETTER_NAME: {"start_index": 0}} async def _check_supported(self): """Additional check to see if the module is supported by the device. diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index 29a4bb055..ca0eca867 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -230,7 +230,7 @@ def query(self) -> dict: if self._state_in_sysinfo: return {} else: - return {self.QUERY_GETTER_NAME: None} + return {self.QUERY_GETTER_NAME: {}} async def _check_supported(self): """Additional check to see if the module is supported by the device.""" diff --git a/kasa/smart/modules/reportmode.py b/kasa/smart/modules/reportmode.py index 79c8ae621..8d210a5b3 100644 --- a/kasa/smart/modules/reportmode.py +++ b/kasa/smart/modules/reportmode.py @@ -32,6 +32,10 @@ def __init__(self, device: SmartDevice, module: str): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def report_interval(self): """Reporting interval of a sensor device.""" diff --git a/kasa/smart/modules/temperaturesensor.py b/kasa/smart/modules/temperaturesensor.py index d98501508..a61859cdc 100644 --- a/kasa/smart/modules/temperaturesensor.py +++ b/kasa/smart/modules/temperaturesensor.py @@ -58,6 +58,10 @@ def __init__(self, device: SmartDevice, module: str): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def temperature(self): """Return current humidity in percentage.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 408ba0278..5bf2400b7 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -177,11 +177,20 @@ async def update(self, update_children: bool = False): self._children[info["device_id"]]._update_internal_state(info) # Call handle update for modules that want to update internal data - for module in self._modules.values(): - module._post_update_hook() + errors = [] + for module_name, module in self._modules.items(): + if not self._handle_module_post_update_hook(module): + errors.append(module_name) + for error in errors: + self._modules.pop(error) + for child in self._children.values(): - for child_module in child._modules.values(): - child_module._post_update_hook() + errors = [] + for child_module_name, child_module in child._modules.items(): + if not self._handle_module_post_update_hook(child_module): + errors.append(child_module_name) + for error in errors: + child._modules.pop(error) # We can first initialize the features after the first update. # We make here an assumption that every device has at least a single feature. @@ -190,6 +199,19 @@ async def update(self, update_children: bool = False): _LOGGER.debug("Got an update: %s", self._last_update) + def _handle_module_post_update_hook(self, module: SmartModule) -> bool: + try: + module._post_update_hook() + return True + except Exception as ex: + _LOGGER.error( + "Error processing %s for device %s, module will be unavailable: %s", + module.name, + self.host, + ex, + ) + return False + async def _initialize_modules(self): """Initialize modules based on component negotiation response.""" from .smartmodule import SmartModule diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index e78f43933..fb946a8b3 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -5,7 +5,7 @@ import logging from typing import TYPE_CHECKING -from ..exceptions import KasaException +from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..module import Module if TYPE_CHECKING: @@ -41,6 +41,14 @@ def name(self) -> str: """Name of the module.""" return getattr(self, "NAME", self.__class__.__name__) + def _post_update_hook(self): # noqa: B027 + """Perform actions after a device update. + + Any modules overriding this should ensure that self.data is + accessed unless the module should remain active despite errors. + """ + assert self.data # noqa: S101 + def query(self) -> dict: """Query to execute during the update cycle. @@ -87,6 +95,11 @@ def data(self): filtered_data = {k: v for k, v in dev._last_update.items() if k in q_keys} + for data_item in filtered_data: + if isinstance(filtered_data[data_item], SmartErrorCode): + raise DeviceError( + f"{data_item} for {self.name}", error_code=filtered_data[data_item] + ) if len(filtered_data) == 1: return next(iter(filtered_data.values())) @@ -110,3 +123,10 @@ async def _check_supported(self) -> bool: color_temp_range but only supports one value. """ return True + + def _has_data_error(self) -> bool: + try: + assert self.data # noqa: S101 + return False + except DeviceError: + return True diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index e6741bc47..3085714c4 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -416,6 +416,10 @@ def _get_method_and_params_for_request(self, request): return smart_method, smart_params async def query(self, request: str | dict, retry_count: int = 3) -> dict: + """Wrap request inside control_child envelope.""" + return await self._query(request, retry_count) + + async def _query(self, request: str | dict, retry_count: int = 3) -> dict: """Wrap request inside control_child envelope.""" method, params = self._get_method_and_params_for_request(request) request_data = { diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 48475a900..44fabc715 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast from unittest.mock import patch import pytest @@ -132,6 +132,78 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): spies[device].assert_not_called() +@device_smart +async def test_update_module_errors(dev: SmartDevice, mocker: MockerFixture): + """Test that modules that error are disabled / removed.""" + # We need to have some modules initialized by now + assert dev._modules + + critical_modules = {Module.DeviceModule, Module.ChildDevice} + not_disabling_modules = {Module.Firmware, Module.Cloud} + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + + module_queries = { + modname: q + for modname, module in dev._modules.items() + if (q := module.query()) and modname not in critical_modules + } + child_module_queries = { + modname: q + for child in dev.children + for modname, module in child._modules.items() + if (q := module.query()) and modname not in critical_modules + } + all_queries_names = { + key for mod_query in module_queries.values() for key in mod_query + } + all_child_queries_names = { + key for mod_query in child_module_queries.values() for key in mod_query + } + + async def _query(request, *args, **kwargs): + responses = await dev.protocol._query(request, *args, **kwargs) + for k in responses: + if k in all_queries_names: + responses[k] = SmartErrorCode.PARAMS_ERROR + return responses + + async def _child_query(self, request, *args, **kwargs): + responses = await child_protocols[self._device_id]._query( + request, *args, **kwargs + ) + for k in responses: + if k in all_child_queries_names: + responses[k] = SmartErrorCode.PARAMS_ERROR + return responses + + mocker.patch.object(new_dev.protocol, "query", side_effect=_query) + + from kasa.smartprotocol import _ChildProtocolWrapper + + child_protocols = { + cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol + for child in dev.children + } + # children not created yet so cannot patch.object + mocker.patch("kasa.smartprotocol._ChildProtocolWrapper.query", new=_child_query) + + await new_dev.update() + for modname in module_queries: + no_disable = modname in not_disabling_modules + mod_present = modname in new_dev._modules + assert ( + mod_present is no_disable + ), f"{modname} present {mod_present} when no_disable {no_disable}" + + for modname in child_module_queries: + no_disable = modname in not_disabling_modules + mod_present = any(modname in child._modules for child in new_dev.children) + assert ( + mod_present is no_disable + ), f"{modname} present {mod_present} when no_disable {no_disable}" + + async def test_get_modules(): """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( @@ -181,6 +253,9 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt assert dev.is_cloud_connected == is_connected last_update = dev._last_update + for child in dev.children: + mocker.patch.object(child.protocol, "query", return_value=child._last_update) + last_update["get_connect_cloud_state"] = {"status": 0} with patch.object(dev.protocol, "query", return_value=last_update): await dev.update() @@ -207,21 +282,18 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt "get_connect_cloud_state": last_update["get_connect_cloud_state"], "get_device_info": last_update["get_device_info"], } - # Child component list is not stored on the device - if "get_child_device_list" in last_update: - child_component_list = await dev.protocol.query( - "get_child_device_component_list" - ) - last_update["get_child_device_component_list"] = child_component_list[ - "get_child_device_component_list" - ] + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) first_call = True - def side_effect_func(*_, **__): + async def side_effect_func(*args, **kwargs): nonlocal first_call - resp = initial_response if first_call else last_update + resp = ( + initial_response + if first_call + else await new_dev.protocol._query(*args, **kwargs) + ) first_call = False return resp diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index d362fd00a..71125ca83 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -1,6 +1,7 @@ import logging import pytest +import pytest_mock from ..exceptions import ( SMART_RETRYABLE_ERRORS, @@ -19,6 +20,21 @@ ERRORS = [e for e in SmartErrorCode if e != 0] +async def test_smart_queries(dummy_protocol, mocker: pytest_mock.MockerFixture): + mock_response = {"result": {"great": "success"}, "error_code": 0} + + mocker.patch.object(dummy_protocol._transport, "send", return_value=mock_response) + # test sending a method name as a string + resp = await dummy_protocol.query("foobar") + assert "foobar" in resp + assert resp["foobar"] == mock_response["result"] + + # test sending a method name as a dict + resp = await dummy_protocol.query(DUMMY_QUERY) + assert "foobar" in resp + assert resp["foobar"] == mock_response["result"] + + @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) async def test_smart_device_errors(dummy_protocol, mocker, error_code): mock_response = {"result": {"great": "success"}, "error_code": error_code.value} From 2f24797033885a08856ae1a7b85340ce30d20b0c Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 4 Jul 2024 08:14:01 +0100 Subject: [PATCH 511/892] Enable CI on the patch branch (#1042) --- .github/workflows/ci.yml | 4 ++-- .github/workflows/codeql-analysis.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80511bd33..c957f8904 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: ["master"] + branches: ["master", "patch"] pull_request: - branches: ["master"] + branches: ["master", "patch"] workflow_dispatch: # to allow manual re-runs env: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b8d5f3968..29d533581 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -2,9 +2,9 @@ name: "CodeQL checks" on: push: - branches: [ master ] + branches: [ "master", "patch" ] pull_request: - branches: [ master ] + branches: [ master, "patch" ] schedule: - cron: '44 17 * * 3' From fe116eaefbe209169ed9df618fd57a5b04665ba6 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 4 Jul 2024 08:29:53 +0100 Subject: [PATCH 512/892] Handle module errors more robustly and add query params to light preset and transition (#1043) Ensures that all modules try to access their data in `_post_update_hook` in a safe manner and disable themselves if there's an error. Also adds parameters to get_preset_rules and get_on_off_gradually_info to fix issues with recent firmware updates. Cherry pick of [#1036](https://github.com/python-kasa/python-kasa/pull/1036) to patch --- devtools/helpers/smartrequests.py | 11 ++- kasa/smart/modules/autooff.py | 6 -- kasa/smart/modules/batterysensor.py | 4 ++ kasa/smart/modules/cloud.py | 10 ++- kasa/smart/modules/devicemodule.py | 7 ++ kasa/smart/modules/firmware.py | 12 +++- kasa/smart/modules/frostprotection.py | 4 ++ kasa/smart/modules/humiditysensor.py | 4 ++ kasa/smart/modules/lightpreset.py | 2 +- kasa/smart/modules/lighttransition.py | 2 +- kasa/smart/modules/reportmode.py | 4 ++ kasa/smart/modules/temperaturesensor.py | 4 ++ kasa/smart/smartdevice.py | 30 ++++++-- kasa/smart/smartmodule.py | 22 +++++- kasa/smartprotocol.py | 4 ++ kasa/tests/test_smartdevice.py | 94 ++++++++++++++++++++++--- kasa/tests/test_smartprotocol.py | 16 +++++ 17 files changed, 206 insertions(+), 30 deletions(-) diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 881488b5e..4db1f7a1c 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -284,6 +284,15 @@ def get_preset_rules(params: GetRulesParams | None = None) -> SmartRequest: """Get preset rules.""" return SmartRequest("get_preset_rules", params or SmartRequest.GetRulesParams()) + @staticmethod + def get_on_off_gradually_info( + params: SmartRequestParams | None = None, + ) -> SmartRequest: + """Get preset rules.""" + return SmartRequest( + "get_on_off_gradually_info", params or SmartRequest.SmartRequestParams() + ) + @staticmethod def get_auto_light_info() -> SmartRequest: """Get auto light info.""" @@ -382,7 +391,7 @@ def get_component_requests(component_id, ver_code): "auto_light": [SmartRequest.get_auto_light_info()], "light_effect": [SmartRequest.get_dynamic_light_effect_rules()], "bulb_quick_control": [], - "on_off_gradually": [SmartRequest.get_raw_request("get_on_off_gradually_info")], + "on_off_gradually": [SmartRequest.get_on_off_gradually_info()], "light_strip": [], "light_strip_lighting_effect": [ SmartRequest.get_raw_request("get_lighting_effect") diff --git a/kasa/smart/modules/autooff.py b/kasa/smart/modules/autooff.py index 0004aec43..5e4b100f8 100644 --- a/kasa/smart/modules/autooff.py +++ b/kasa/smart/modules/autooff.py @@ -19,12 +19,6 @@ class AutoOff(SmartModule): def _initialize_features(self): """Initialize features after the initial update.""" - if not isinstance(self.data, dict): - _LOGGER.warning( - "No data available for module, skipping %s: %s", self, self.data - ) - return - self._add_feature( Feature( self._device, diff --git a/kasa/smart/modules/batterysensor.py b/kasa/smart/modules/batterysensor.py index 415e47d1e..7ff7df2d8 100644 --- a/kasa/smart/modules/batterysensor.py +++ b/kasa/smart/modules/batterysensor.py @@ -43,6 +43,10 @@ def _initialize_features(self): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def battery(self): """Return battery level.""" diff --git a/kasa/smart/modules/cloud.py b/kasa/smart/modules/cloud.py index 1b64f090a..8346af57a 100644 --- a/kasa/smart/modules/cloud.py +++ b/kasa/smart/modules/cloud.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING -from ...exceptions import SmartErrorCode from ...feature import Feature from ..smartmodule import SmartModule @@ -18,6 +17,13 @@ class Cloud(SmartModule): QUERY_GETTER_NAME = "get_connect_cloud_state" REQUIRED_COMPONENT = "cloud_connect" + def _post_update_hook(self): + """Perform actions after a device update. + + Overrides the default behaviour to disable a module if the query returns + an error because the logic here is to treat that as not connected. + """ + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) @@ -37,6 +43,6 @@ def __init__(self, device: SmartDevice, module: str): @property def is_connected(self): """Return True if device is connected to the cloud.""" - if isinstance(self.data, SmartErrorCode): + if self._has_data_error(): return False return self.data["status"] == 0 diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py index 6a846d542..3203e82fa 100644 --- a/kasa/smart/modules/devicemodule.py +++ b/kasa/smart/modules/devicemodule.py @@ -10,6 +10,13 @@ class DeviceModule(SmartModule): REQUIRED_COMPONENT = "device" + def _post_update_hook(self): + """Perform actions after a device update. + + Overrides the default behaviour to disable a module if the query returns + an error because this module is critical. + """ + def query(self) -> dict: """Query to execute during the update cycle.""" query = { diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 3dcaddd66..10a6b8245 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -13,7 +13,6 @@ from async_timeout import timeout as asyncio_timeout from pydantic.v1 import BaseModel, Field, validator -from ...exceptions import SmartErrorCode from ...feature import Feature from ..smartmodule import SmartModule @@ -123,6 +122,13 @@ def query(self) -> dict: req["get_auto_update_info"] = None return req + def _post_update_hook(self): + """Perform actions after a device update. + + Overrides the default behaviour to disable a module if the query returns + an error because some of the module still functions. + """ + @property def current_firmware(self) -> str: """Return the current firmware version.""" @@ -136,11 +142,11 @@ def latest_firmware(self) -> str: @property def firmware_update_info(self): """Return latest firmware information.""" - fw = self.data.get("get_latest_fw") or self.data - if not self._device.is_cloud_connected or isinstance(fw, SmartErrorCode): + if not self._device.is_cloud_connected or self._has_data_error(): # Error in response, probably disconnected from the cloud. return UpdateInfo(type=0, need_to_upgrade=False) + fw = self.data.get("get_latest_fw") or self.data return UpdateInfo.parse_obj(fw) @property diff --git a/kasa/smart/modules/frostprotection.py b/kasa/smart/modules/frostprotection.py index f1811012f..440e1ed1b 100644 --- a/kasa/smart/modules/frostprotection.py +++ b/kasa/smart/modules/frostprotection.py @@ -14,6 +14,10 @@ class FrostProtection(SmartModule): REQUIRED_COMPONENT = "frost_protection" QUERY_GETTER_NAME = "get_frost_protection" + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def enabled(self) -> bool: """Return True if frost protection is on.""" diff --git a/kasa/smart/modules/humiditysensor.py b/kasa/smart/modules/humiditysensor.py index f0dcc18a4..b137736ff 100644 --- a/kasa/smart/modules/humiditysensor.py +++ b/kasa/smart/modules/humiditysensor.py @@ -45,6 +45,10 @@ def __init__(self, device: SmartDevice, module: str): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def humidity(self): """Return current humidity in percentage.""" diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 8e5cae209..7635a5f86 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -140,7 +140,7 @@ def query(self) -> dict: """Query to execute during the update cycle.""" if self._state_in_sysinfo: # Child lights can have states in the child info return {} - return {self.QUERY_GETTER_NAME: None} + return {self.QUERY_GETTER_NAME: {"start_index": 0}} async def _check_supported(self): """Additional check to see if the module is supported by the device. diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index 29a4bb055..ca0eca867 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -230,7 +230,7 @@ def query(self) -> dict: if self._state_in_sysinfo: return {} else: - return {self.QUERY_GETTER_NAME: None} + return {self.QUERY_GETTER_NAME: {}} async def _check_supported(self): """Additional check to see if the module is supported by the device.""" diff --git a/kasa/smart/modules/reportmode.py b/kasa/smart/modules/reportmode.py index 79c8ae621..8d210a5b3 100644 --- a/kasa/smart/modules/reportmode.py +++ b/kasa/smart/modules/reportmode.py @@ -32,6 +32,10 @@ def __init__(self, device: SmartDevice, module: str): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def report_interval(self): """Reporting interval of a sensor device.""" diff --git a/kasa/smart/modules/temperaturesensor.py b/kasa/smart/modules/temperaturesensor.py index d98501508..a61859cdc 100644 --- a/kasa/smart/modules/temperaturesensor.py +++ b/kasa/smart/modules/temperaturesensor.py @@ -58,6 +58,10 @@ def __init__(self, device: SmartDevice, module: str): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def temperature(self): """Return current humidity in percentage.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index a5b64e527..fcbc8a15f 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -177,11 +177,20 @@ async def update(self, update_children: bool = False): self._children[info["device_id"]]._update_internal_state(info) # Call handle update for modules that want to update internal data - for module in self._modules.values(): - module._post_update_hook() + errors = [] + for module_name, module in self._modules.items(): + if not self._handle_module_post_update_hook(module): + errors.append(module_name) + for error in errors: + self._modules.pop(error) + for child in self._children.values(): - for child_module in child._modules.values(): - child_module._post_update_hook() + errors = [] + for child_module_name, child_module in child._modules.items(): + if not self._handle_module_post_update_hook(child_module): + errors.append(child_module_name) + for error in errors: + child._modules.pop(error) # We can first initialize the features after the first update. # We make here an assumption that every device has at least a single feature. @@ -190,6 +199,19 @@ async def update(self, update_children: bool = False): _LOGGER.debug("Got an update: %s", self._last_update) + def _handle_module_post_update_hook(self, module: SmartModule) -> bool: + try: + module._post_update_hook() + return True + except Exception as ex: + _LOGGER.error( + "Error processing %s for device %s, module will be unavailable: %s", + module.name, + self.host, + ex, + ) + return False + async def _initialize_modules(self): """Initialize modules based on component negotiation response.""" from .smartmodule import SmartModule diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index e78f43933..fb946a8b3 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -5,7 +5,7 @@ import logging from typing import TYPE_CHECKING -from ..exceptions import KasaException +from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..module import Module if TYPE_CHECKING: @@ -41,6 +41,14 @@ def name(self) -> str: """Name of the module.""" return getattr(self, "NAME", self.__class__.__name__) + def _post_update_hook(self): # noqa: B027 + """Perform actions after a device update. + + Any modules overriding this should ensure that self.data is + accessed unless the module should remain active despite errors. + """ + assert self.data # noqa: S101 + def query(self) -> dict: """Query to execute during the update cycle. @@ -87,6 +95,11 @@ def data(self): filtered_data = {k: v for k, v in dev._last_update.items() if k in q_keys} + for data_item in filtered_data: + if isinstance(filtered_data[data_item], SmartErrorCode): + raise DeviceError( + f"{data_item} for {self.name}", error_code=filtered_data[data_item] + ) if len(filtered_data) == 1: return next(iter(filtered_data.values())) @@ -110,3 +123,10 @@ async def _check_supported(self) -> bool: color_temp_range but only supports one value. """ return True + + def _has_data_error(self) -> bool: + try: + assert self.data # noqa: S101 + return False + except DeviceError: + return True diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index e6741bc47..3085714c4 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -416,6 +416,10 @@ def _get_method_and_params_for_request(self, request): return smart_method, smart_params async def query(self, request: str | dict, retry_count: int = 3) -> dict: + """Wrap request inside control_child envelope.""" + return await self._query(request, retry_count) + + async def _query(self, request: str | dict, retry_count: int = 3) -> dict: """Wrap request inside control_child envelope.""" method, params = self._get_method_and_params_for_request(request) request_data = { diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 48475a900..44fabc715 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast from unittest.mock import patch import pytest @@ -132,6 +132,78 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): spies[device].assert_not_called() +@device_smart +async def test_update_module_errors(dev: SmartDevice, mocker: MockerFixture): + """Test that modules that error are disabled / removed.""" + # We need to have some modules initialized by now + assert dev._modules + + critical_modules = {Module.DeviceModule, Module.ChildDevice} + not_disabling_modules = {Module.Firmware, Module.Cloud} + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + + module_queries = { + modname: q + for modname, module in dev._modules.items() + if (q := module.query()) and modname not in critical_modules + } + child_module_queries = { + modname: q + for child in dev.children + for modname, module in child._modules.items() + if (q := module.query()) and modname not in critical_modules + } + all_queries_names = { + key for mod_query in module_queries.values() for key in mod_query + } + all_child_queries_names = { + key for mod_query in child_module_queries.values() for key in mod_query + } + + async def _query(request, *args, **kwargs): + responses = await dev.protocol._query(request, *args, **kwargs) + for k in responses: + if k in all_queries_names: + responses[k] = SmartErrorCode.PARAMS_ERROR + return responses + + async def _child_query(self, request, *args, **kwargs): + responses = await child_protocols[self._device_id]._query( + request, *args, **kwargs + ) + for k in responses: + if k in all_child_queries_names: + responses[k] = SmartErrorCode.PARAMS_ERROR + return responses + + mocker.patch.object(new_dev.protocol, "query", side_effect=_query) + + from kasa.smartprotocol import _ChildProtocolWrapper + + child_protocols = { + cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol + for child in dev.children + } + # children not created yet so cannot patch.object + mocker.patch("kasa.smartprotocol._ChildProtocolWrapper.query", new=_child_query) + + await new_dev.update() + for modname in module_queries: + no_disable = modname in not_disabling_modules + mod_present = modname in new_dev._modules + assert ( + mod_present is no_disable + ), f"{modname} present {mod_present} when no_disable {no_disable}" + + for modname in child_module_queries: + no_disable = modname in not_disabling_modules + mod_present = any(modname in child._modules for child in new_dev.children) + assert ( + mod_present is no_disable + ), f"{modname} present {mod_present} when no_disable {no_disable}" + + async def test_get_modules(): """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( @@ -181,6 +253,9 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt assert dev.is_cloud_connected == is_connected last_update = dev._last_update + for child in dev.children: + mocker.patch.object(child.protocol, "query", return_value=child._last_update) + last_update["get_connect_cloud_state"] = {"status": 0} with patch.object(dev.protocol, "query", return_value=last_update): await dev.update() @@ -207,21 +282,18 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt "get_connect_cloud_state": last_update["get_connect_cloud_state"], "get_device_info": last_update["get_device_info"], } - # Child component list is not stored on the device - if "get_child_device_list" in last_update: - child_component_list = await dev.protocol.query( - "get_child_device_component_list" - ) - last_update["get_child_device_component_list"] = child_component_list[ - "get_child_device_component_list" - ] + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) first_call = True - def side_effect_func(*_, **__): + async def side_effect_func(*args, **kwargs): nonlocal first_call - resp = initial_response if first_call else last_update + resp = ( + initial_response + if first_call + else await new_dev.protocol._query(*args, **kwargs) + ) first_call = False return resp diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index d362fd00a..71125ca83 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -1,6 +1,7 @@ import logging import pytest +import pytest_mock from ..exceptions import ( SMART_RETRYABLE_ERRORS, @@ -19,6 +20,21 @@ ERRORS = [e for e in SmartErrorCode if e != 0] +async def test_smart_queries(dummy_protocol, mocker: pytest_mock.MockerFixture): + mock_response = {"result": {"great": "success"}, "error_code": 0} + + mocker.patch.object(dummy_protocol._transport, "send", return_value=mock_response) + # test sending a method name as a string + resp = await dummy_protocol.query("foobar") + assert "foobar" in resp + assert resp["foobar"] == mock_response["result"] + + # test sending a method name as a dict + resp = await dummy_protocol.query(DUMMY_QUERY) + assert "foobar" in resp + assert resp["foobar"] == mock_response["result"] + + @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) async def test_smart_device_errors(dummy_protocol, mocker, error_code): mock_response = {"result": {"great": "success"}, "error_code": error_code.value} From 407cedf781f28edb1a7992212a74904c1f2ccf08 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 4 Jul 2024 09:43:45 +0100 Subject: [PATCH 513/892] Prepare 0.7.0.3 (#1045) ## [0.7.0.3](https://github.com/python-kasa/python-kasa/tree/0.7.0.3) (2024-07-04) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.2...0.7.0.3) Critical bugfix for issue #1033 with ks225 and S505D light preset module errors. Partially fixes light preset module errors with L920 and L930. **Fixed bugs:** Handle module errors more robustly and add query params to light preset and transition [\#1043](https://github.com/python-kasa/python-kasa/pull/1043) --- CHANGELOG.md | 129 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a80adb555..1b2466df9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [0.7.0.3](https://github.com/python-kasa/python-kasa/tree/0.7.0.3) (2024-07-04) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.2...0.7.0.3) + +Critical bugfix for issue #1033 with ks225 and S505D light preset module errors. +Partially fixes light preset module errors with L920 and L930. + +**Fixed bugs:** + +Handle module errors more robustly and add query params to light preset and transition [\#1043](https://github.com/python-kasa/python-kasa/pull/1043) + ## [0.7.0.2](https://github.com/python-kasa/python-kasa/tree/0.7.0.2) (2024-07-01) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.1...0.7.0.2) @@ -71,6 +82,7 @@ For more information on the changes please checkout our [documentation on the AP **Implemented enhancements:** +- Radiator support \(KE100\) [\#422](https://github.com/python-kasa/python-kasa/issues/422) - Cleanup cli output [\#1000](https://github.com/python-kasa/python-kasa/pull/1000) (@rytilahti) - Update mode, time, rssi and report\_interval feature names/units [\#995](https://github.com/python-kasa/python-kasa/pull/995) (@sdb9696) - Add timezone to on\_since attributes [\#978](https://github.com/python-kasa/python-kasa/pull/978) (@sdb9696) @@ -133,6 +145,15 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- TAPO P100 \(hw 1.0.0, sw 1.1.3\) EU plug with 0.6.2.1 Kasa results JSON\_DECODE\_FAIL\_ERROR [\#819](https://github.com/python-kasa/python-kasa/issues/819) +- Cannot add Tapo Plug P110 to Home Assistant 2024.2.3 - Error in debug mode [\#797](https://github.com/python-kasa/python-kasa/issues/797) +- KS240 gets discovered but will not authenticate [\#749](https://github.com/python-kasa/python-kasa/issues/749) +- Individual commands do not work on discovered devices [\#71](https://github.com/python-kasa/python-kasa/issues/71) +- SMART.TAPOHUB does not work with 0.7.0 dev2 [\#958](https://github.com/python-kasa/python-kasa/issues/958) +- Fix --help on subcommands [\#885](https://github.com/python-kasa/python-kasa/issues/885) +- "Unclosed client session" Trying to set brightness on Tapo Bulb [\#828](https://github.com/python-kasa/python-kasa/issues/828) +- Error when trying to discover new Tapo P110 plug [\#818](https://github.com/python-kasa/python-kasa/issues/818) +- Individual errors cause failing the whole query [\#616](https://github.com/python-kasa/python-kasa/issues/616) - Fix smart led status to report rule status [\#1002](https://github.com/python-kasa/python-kasa/pull/1002) (@sdb9696) - Demote device\_time back to debug [\#1001](https://github.com/python-kasa/python-kasa/pull/1001) (@rytilahti) - Add supported check to light transition module [\#971](https://github.com/python-kasa/python-kasa/pull/971) (@sdb9696) @@ -188,6 +209,8 @@ For more information on the changes please checkout our [documentation on the AP **Documentation updates:** +- Document device features [\#755](https://github.com/python-kasa/python-kasa/issues/755) +- Clean up the README [\#979](https://github.com/python-kasa/python-kasa/issues/979) - Cleanup README to use the new cli format [\#999](https://github.com/python-kasa/python-kasa/pull/999) (@rytilahti) - Add 0.7 api changes section to docs [\#996](https://github.com/python-kasa/python-kasa/pull/996) (@sdb9696) - Update docs with more howto examples [\#968](https://github.com/python-kasa/python-kasa/pull/968) (@sdb9696) @@ -278,6 +301,10 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.1...0.6.2) +Release highlights: +* Support for tapo power strips (P300) +* Performance improvements and bug fixes + **Implemented enhancements:** - Implement alias set for tapodevice [\#721](https://github.com/python-kasa/python-kasa/pull/721) (@rytilahti) @@ -314,6 +341,11 @@ For more information on the changes please checkout our [documentation on the AP [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) @@ -365,6 +397,8 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0...0.6.0.1) +A patch release to improve the protocol handling. + **Fixed bugs:** - Fix httpclient exceptions on read and improve error info [\#655](https://github.com/python-kasa/python-kasa/pull/655) (@sdb9696) @@ -382,6 +416,19 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.4...0.6.0) +This major brings major changes to the library by adding support for devices that require authentication for communications, all of this being possible thanks to the great work by @sdb9696! + +This release adds support to a large range of previously unsupported devices, including: + +* Newer kasa-branded devices, including Matter-enabled devices like KP125M +* Newer hardware/firmware versions on some models, like EP25, that suddenly changed the used protocol +* Tapo-branded devices like plugs (P110), light bulbs (KL530), LED strips (L900, L920), and wall switches (KS205, KS225) +* UK variant of HS110, which was the first device using the new protocol + +If your device that is not currently listed as supported is working, please consider contributing a test fixture file. + +Special thanks goes to @SimonWilkinson who created the initial PR for the new communication protocol! + **Breaking changes:** - Add DeviceConfig to allow specifying configuration parameters [\#569](https://github.com/python-kasa/python-kasa/pull/569) (@sdb9696) @@ -389,6 +436,9 @@ For more information on the changes please checkout our [documentation on the AP **Implemented enhancements:** +- Support for KS225\(US\) Light Dimmer and KS205\(US\) Light Switch [\#589](https://github.com/python-kasa/python-kasa/issues/589) +- Set timeout using command line parameters [\#310](https://github.com/python-kasa/python-kasa/issues/310) +- Implement the new protocol \(HTTP over 80/tcp, 20002/udp for discovery\) [\#115](https://github.com/python-kasa/python-kasa/issues/115) - Get child emeters with CLI [\#623](https://github.com/python-kasa/python-kasa/pull/623) (@Obbay2) - Avoid linear search for emeter realtime and emeter\_today [\#622](https://github.com/python-kasa/python-kasa/pull/622) (@bdraco) - Add update-credentials command [\#620](https://github.com/python-kasa/python-kasa/pull/620) (@rytilahti) @@ -415,6 +465,7 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- dump\_devinfo crashes when credentials are not given [\#591](https://github.com/python-kasa/python-kasa/issues/591) - Fix connection indeterminate state on cancellation [\#636](https://github.com/python-kasa/python-kasa/pull/636) (@bdraco) - Check the ct range for color temp support [\#619](https://github.com/python-kasa/python-kasa/pull/619) (@rytilahti) - Fix cli discover bug with None username/password [\#615](https://github.com/python-kasa/python-kasa/pull/615) (@sdb9696) @@ -423,6 +474,7 @@ For more information on the changes please checkout our [documentation on the AP **Documentation updates:** +- Update the documentation for 0.6 release [\#600](https://github.com/python-kasa/python-kasa/issues/600) - Update docs for newer devices and DeviceConfig [\#614](https://github.com/python-kasa/python-kasa/pull/614) (@sdb9696) - Update readme with clearer instructions, tapo support [\#571](https://github.com/python-kasa/python-kasa/pull/571) (@rytilahti) - Add some more external links to README [\#541](https://github.com/python-kasa/python-kasa/pull/541) (@rytilahti) @@ -469,6 +521,15 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.3...0.5.4) +The highlights of this maintenance release: + +* Support to the alternative discovery protocol and foundational work to support other communication protocols, thanks to @sdb9696. +* Reliability improvements by avoiding overflowing device buffers, thanks to @cobryan05. +* Optimizations for downstream device accesses, thanks to @bdraco. +* Support for both pydantic v1 and v2. + +As always, see the full changelog for details. + **Implemented enhancements:** - Add a connect\_single method to Discover to avoid the need for UDP [\#528](https://github.com/python-kasa/python-kasa/pull/528) (@bdraco) @@ -507,6 +568,8 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.2...0.5.3) +This release adds support for defining the device port and introduces dependency on async-timeout which improves timeout handling. + **Implemented enhancements:** - Make device port configurable [\#471](https://github.com/python-kasa/python-kasa/pull/471) (@karpach) @@ -524,6 +587,10 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.1...0.5.2) +Besides some small improvements, this release: +* Adds optional dependency for for `orjson` and `kasa-crypt` to speed-up protocol handling by an order of magnitude. +* Drops Python 3.7 support as it is no longer maintained. + **Breaking changes:** - Drop python 3.7 support [\#455](https://github.com/python-kasa/python-kasa/pull/455) (@rytilahti) @@ -537,6 +604,9 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- Request for KP405 Support - Dimmable Plug [\#469](https://github.com/python-kasa/python-kasa/issues/469) +- Issue printing device in on\_discovered: pydantic.error\_wrappers.ValidationError: 3 validation errors for SmartBulbPreset [\#439](https://github.com/python-kasa/python-kasa/issues/439) +- Possible firmware issue with KL125 \(1.0.7 Build 211009 Rel.172044\) [\#345](https://github.com/python-kasa/python-kasa/issues/345) - Exclude querying certain modules for KL125\(US\) which cause crashes [\#451](https://github.com/python-kasa/python-kasa/pull/451) (@brianthedavis) - Return result objects for cli discover and implicit 'state' [\#446](https://github.com/python-kasa/python-kasa/pull/446) (@rytilahti) - Allow effect presets seen on light strips [\#440](https://github.com/python-kasa/python-kasa/pull/440) (@rytilahti) @@ -553,6 +623,13 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.0...0.5.1) +This minor release contains mostly small UX fine-tuning and documentation improvements alongside with bug fixes: +* Improved console tool (JSON output, colorized output if rich is installed) +* Pretty, colorized console output, if `rich` is installed +* Support for configuring bulb presets +* Usage data is now reported in the expected format +* Dependency pinning is relaxed to give downstreams more control + **Breaking changes:** - Implement changing the bulb turn-on behavior [\#381](https://github.com/python-kasa/python-kasa/pull/381) (@rytilahti) @@ -569,11 +646,16 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- cli.py usage year and month options do not output data as expected [\#373](https://github.com/python-kasa/python-kasa/issues/373) +- cli.py usage --year command passes year argument incorrectly [\#371](https://github.com/python-kasa/python-kasa/issues/371) +- KP303 reporting as device off [\#319](https://github.com/python-kasa/python-kasa/issues/319) +- HS210 not updating the state correctly [\#193](https://github.com/python-kasa/python-kasa/issues/193) - Fix year emeter for cli by using kwarg for year parameter [\#372](https://github.com/python-kasa/python-kasa/pull/372) (@rytilahti) - Return usage.get\_{monthstat,daystat} in expected format [\#394](https://github.com/python-kasa/python-kasa/pull/394) (@jules43) **Documentation updates:** +- Update misleading docs about supported devices \(was: add support for EP25 plug\) [\#367](https://github.com/python-kasa/python-kasa/issues/367) - Minor fixes to smartbulb docs [\#431](https://github.com/python-kasa/python-kasa/pull/431) (@rytilahti) - Add a note that transition is not supported by all devices [\#398](https://github.com/python-kasa/python-kasa/pull/398) (@rytilahti) - fix more outdated CLI examples, remove EP40 from bulb list [\#383](https://github.com/python-kasa/python-kasa/pull/383) (@HankB) @@ -583,6 +665,10 @@ For more information on the changes please checkout our [documentation on the AP - Update README to add missing models and fix a link [\#351](https://github.com/python-kasa/python-kasa/pull/351) (@rytilahti) - Add KP125 test fixture and support note. [\#350](https://github.com/python-kasa/python-kasa/pull/350) (@jalseth) +**Closed issues:** + +- Add support for setting default behaviors for a soft or hard power on of the bulb [\#365](https://github.com/python-kasa/python-kasa/issues/365) + **Merged pull requests:** - Some release preparation janitoring [\#432](https://github.com/python-kasa/python-kasa/pull/432) (@rytilahti) @@ -605,18 +691,43 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.3...0.5.0) +This is the first release of 0.5 series which includes converting the code base towards more modular approach where device-exposed modules (e.g., emeter, antitheft, or schedule) are implemented in their separate python modules to decouple them from the device-specific classes. + +There should be no API breaking changes, but some previous issues hint that there may be as information from all supported modules are now requested during each update cycle (depending on the device type): +* Basic system info +* Emeter +* Time - properties (like `on_since`) use now time from the device for calculation to avoid jitter caused by different time between the host and the device +* Usage statistics - similar interface to emeter, but reports on-time statistics instead of energy consumption (new) +* Countdown (new) +* Antitheft (new) +* Schedule (new) +* Motion - for configuring motion settings on some dimmers (new) +* Ambientlight - for configuring brightness limits when motion sensor actuates on some dimmers (new) +* Cloud - information about cloud connectivity (new) + +For developers, the new functionalities are currently only exposed through the implementation modules accessible through `modules` property. +Pull requests improving the functionality of modules as well as adding better interfaces to device classes are welcome! + **Breaking changes:** - Drop deprecated, type-specific options in favor of --type [\#336](https://github.com/python-kasa/python-kasa/pull/336) (@rytilahti) - Convert the codebase to be more modular [\#299](https://github.com/python-kasa/python-kasa/pull/299) (@rytilahti) +**Implemented enhancements:** + +- Improve HS220 support [\#44](https://github.com/python-kasa/python-kasa/issues/44) + **Fixed bugs:** +- Skip running discovery on --help on subcommands [\#122](https://github.com/python-kasa/python-kasa/issues/122) - Avoid retrying open\_connection on unrecoverable errors [\#340](https://github.com/python-kasa/python-kasa/pull/340) (@bdraco) - Avoid discovery on --help [\#335](https://github.com/python-kasa/python-kasa/pull/335) (@rytilahti) **Documentation updates:** +- Trying to poll device every 5 seconds but getting asyncio errors [\#316](https://github.com/python-kasa/python-kasa/issues/316) +- Docs: Smart Strip - Emeter feature Note [\#257](https://github.com/python-kasa/python-kasa/issues/257) +- Documentation addition: Smartplug access to internet ntp server pool. [\#129](https://github.com/python-kasa/python-kasa/issues/129) - Export modules & make sphinx happy [\#334](https://github.com/python-kasa/python-kasa/pull/334) (@rytilahti) - Various documentation updates [\#333](https://github.com/python-kasa/python-kasa/pull/333) (@rytilahti) @@ -630,6 +741,7 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- Divide by zero when HS300 powerstrip is discovered [\#292](https://github.com/python-kasa/python-kasa/issues/292) - Ensure bulb state is restored when turning back on [\#330](https://github.com/python-kasa/python-kasa/pull/330) (@bdraco) **Merged pull requests:** @@ -650,6 +762,8 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- TypeError: \_\_init\_\_\(\) got an unexpected keyword argument 'package\_name' [\#311](https://github.com/python-kasa/python-kasa/issues/311) +- RuntimeError: Event loop is closed on WSL [\#294](https://github.com/python-kasa/python-kasa/issues/294) - Don't crash on devices not reporting features [\#317](https://github.com/python-kasa/python-kasa/pull/317) (@rytilahti) **Merged pull requests:** @@ -685,6 +799,8 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- Discovery on WSL results in OSError: \[Errno 22\] Invalid argument [\#246](https://github.com/python-kasa/python-kasa/issues/246) +- New firmware for HS103 blocking local access? [\#42](https://github.com/python-kasa/python-kasa/issues/42) - Pin mistune to \<2.0.0 to fix doc builds [\#270](https://github.com/python-kasa/python-kasa/pull/270) (@rytilahti) - Catch exceptions raised on unknown devices during discovery [\#240](https://github.com/python-kasa/python-kasa/pull/240) (@rytilahti) @@ -704,6 +820,8 @@ For more information on the changes please checkout our [documentation on the AP **Implemented enhancements:** +- KL430 support [\#67](https://github.com/python-kasa/python-kasa/issues/67) +- Improve retry logic for discovery, messaging \(was: Handle empty responses\) [\#38](https://github.com/python-kasa/python-kasa/issues/38) - Fix lock being unexpectedly reset on close [\#218](https://github.com/python-kasa/python-kasa/pull/218) (@bdraco) - Avoid calling pformat unless debug logging is enabled [\#217](https://github.com/python-kasa/python-kasa/pull/217) (@bdraco) - Keep connection open and lock to prevent duplicate requests [\#213](https://github.com/python-kasa/python-kasa/pull/213) (@bdraco) @@ -720,6 +838,10 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- KL430: Throw error for Device specific information [\#189](https://github.com/python-kasa/python-kasa/issues/189) +- `Unable to find a value for 'current'` error when attempting to query KL125 bulb emeter [\#142](https://github.com/python-kasa/python-kasa/issues/142) +- `Unknown color temperature range` error when attempting to query KL125 bulb state [\#141](https://github.com/python-kasa/python-kasa/issues/141) +- HS300 Children plugs have emeter [\#64](https://github.com/python-kasa/python-kasa/issues/64) - dump\_devinfo: handle latitude/longitude keys properly [\#175](https://github.com/python-kasa/python-kasa/pull/175) (@rytilahti) - Simplify discovery query, refactor dump-devinfo [\#147](https://github.com/python-kasa/python-kasa/pull/147) (@rytilahti) - Return None instead of raising an exception on missing, valid emeter keys [\#146](https://github.com/python-kasa/python-kasa/pull/146) (@rytilahti) @@ -728,6 +850,9 @@ For more information on the changes please checkout our [documentation on the AP **Documentation updates:** +- Discover does not support specifying network interface [\#167](https://github.com/python-kasa/python-kasa/issues/167) +- Add ability to control individual sockets on KP400 [\#121](https://github.com/python-kasa/python-kasa/issues/121) +- Improve poetry usage documentation [\#60](https://github.com/python-kasa/python-kasa/issues/60) - Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) (@rytilahti) **Merged pull requests:** @@ -773,6 +898,10 @@ For more information on the changes please checkout our [documentation on the AP - Add commands to control the wifi settings [\#45](https://github.com/python-kasa/python-kasa/pull/45) (@rytilahti) +**Fixed bugs:** + +- HSV cli command not working [\#43](https://github.com/python-kasa/python-kasa/issues/43) + **Merged pull requests:** - Add retries to protocol queries [\#65](https://github.com/python-kasa/python-kasa/pull/65) (@rytilahti) diff --git a/pyproject.toml b/pyproject.toml index 45350aefd..fb8df9130 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.2" +version = "0.7.0.3" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From fd4d084839c2b33f4e74a157b5fc32eb7b63af6c Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 4 Jul 2024 11:48:18 +0100 Subject: [PATCH 514/892] Add KS225(US) v1.1.0 fixture (#1046) --- SUPPORTED.md | 1 + .../fixtures/smart/KS225(US)_1.0_1.1.0.json | 332 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 kasa/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 08ae8ada3..7a78c1cc7 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -88,6 +88,7 @@ Some newer Kasa devices require authentication. These are marked with *\* + - Hardware: 1.0 (US) / Firmware: 1.1.0\* - **KS230** - Hardware: 1.0 (US) / Firmware: 1.0.14 - **KS240** diff --git a/kasa/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json b/kasa/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json new file mode 100644 index 000000000..798642d3e --- /dev/null +++ b/kasa/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json @@ -0,0 +1,332 @@ +{ + "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": "KS225(US)", + "device_type": "SMART.KASASWITCH", + "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": "" + }, + "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": "switch_s500d", + "brightness": 5, + "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": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 240411 Rel.150716", + "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": "KS225", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 88, + "overheat_status": "normal", + "region": "America/Toronto", + "rssi": -48, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Toronto", + "time_diff": -300, + "timestamp": 1720036002 + }, + "get_device_usage": { + "time_usage": { + "past30": 1371, + "past7": 659, + "today": 58 + } + }, + "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 240411 Rel.150716", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": false, + "night_mode": { + "end_time": 350, + "night_mode_type": "sunrise_sunset", + "start_time": 1266, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + }, + "on_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + } + }, + "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": "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": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 1, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 5, + "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": "KS225", + "device_type": "SMART.KASASWITCH", + "is_klap": true + } + } +} From 88df7f9ba699ee4c85c5ce428a08260bf5103bd1 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 4 Jul 2024 12:02:47 +0100 Subject: [PATCH 515/892] Add KS200M(US) v1.0.11 fixture (#1047) --- SUPPORTED.md | 1 + .../tests/fixtures/KS200M(US)_1.0_1.0.11.json | 96 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 kasa/tests/fixtures/KS200M(US)_1.0_1.0.11.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 7a78c1cc7..ef57c80b5 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -81,6 +81,7 @@ Some newer Kasa devices require authentication. These are marked with *\* diff --git a/kasa/tests/fixtures/KS200M(US)_1.0_1.0.11.json b/kasa/tests/fixtures/KS200M(US)_1.0_1.0.11.json new file mode 100644 index 000000000..3eb480c3a --- /dev/null +++ b/kasa/tests/fixtures/KS200M(US)_1.0_1.0.11.json @@ -0,0 +1,96 @@ +{ + "smartlife.iot.LAS": { + "get_config": { + "devs": [ + { + "dark_index": 0, + "enable": 0, + "hw_id": 0, + "level_array": [ + { + "adc": 390, + "name": "cloudy", + "value": 15 + }, + { + "adc": 300, + "name": "overcast", + "value": 12 + }, + { + "adc": 222, + "name": "dawn", + "value": 9 + }, + { + "adc": 222, + "name": "twilight", + "value": 9 + }, + { + "adc": 111, + "name": "total darkness", + "value": 4 + }, + { + "adc": 2400, + "name": "custom", + "value": 97 + } + ], + "max_adc": 2450, + "min_adc": 0 + } + ], + "err_code": 0, + "ver": "1.0" + } + }, + "smartlife.iot.PIR": { + "get_config": { + "array": [ + 80, + 50, + 20, + 0 + ], + "cold_time": 60000, + "enable": 0, + "err_code": 0, + "max_adc": 4095, + "min_adc": 0, + "trigger_index": 1, + "version": "1.0" + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Light Switch with PIR", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "3C:52:A1:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS200M(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -40, + "status": "new", + "sw_ver": "1.0.11 Build 230113 Rel.151038", + "updating": 0 + } + } +} From 7427a885700b62ffb935a0f3f84947bfc97d139d Mon Sep 17 00:00:00 2001 From: gimpy88 <64541114+gimpy88@users.noreply.github.com> Date: Thu, 4 Jul 2024 07:21:03 -0400 Subject: [PATCH 516/892] Add KP400 v1.0.3 fixture (#1037) --- SUPPORTED.md | 1 + kasa/tests/fixtures/KP400(US)_3.0_1.0.3.json | 45 ++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 kasa/tests/fixtures/KP400(US)_3.0_1.0.3.json diff --git a/SUPPORTED.md b/SUPPORTED.md index ef57c80b5..e3ffcd1ec 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -65,6 +65,7 @@ Some newer Kasa devices require authentication. These are marked with * Date: Thu, 4 Jul 2024 13:52:01 +0100 Subject: [PATCH 517/892] Structure cli into a package (#1038) PR with just the initial structural changes for the cli to be a package. Subsequent PR will break out `main.py` into modules. Doing it in two stages ensure that the commit history will be continuous for `cli.py` > `cli/main.py` --- devtools/parse_pcap.py | 2 +- kasa/cli/__init__.py | 1 + kasa/cli/__main__.py | 5 +++++ kasa/{cli.py => cli/main.py} | 0 kasa/tests/test_cli.py | 6 +++--- pyproject.toml | 2 +- 6 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 kasa/cli/__init__.py create mode 100644 kasa/cli/__main__.py rename kasa/{cli.py => cli/main.py} (100%) diff --git a/devtools/parse_pcap.py b/devtools/parse_pcap.py index 7a55bf545..02d3911c5 100644 --- a/devtools/parse_pcap.py +++ b/devtools/parse_pcap.py @@ -8,7 +8,7 @@ import dpkt from dpkt.ethernet import ETH_TYPE_IP, Ethernet -from kasa.cli import echo +from kasa.cli.main import echo from kasa.xortransport import XorEncryption diff --git a/kasa/cli/__init__.py b/kasa/cli/__init__.py new file mode 100644 index 000000000..1d4991659 --- /dev/null +++ b/kasa/cli/__init__.py @@ -0,0 +1 @@ +"""Package for the cli.""" diff --git a/kasa/cli/__main__.py b/kasa/cli/__main__.py new file mode 100644 index 000000000..5d4ca6a05 --- /dev/null +++ b/kasa/cli/__main__.py @@ -0,0 +1,5 @@ +"""Main module.""" + +from kasa.cli.main import cli + +cli() diff --git a/kasa/cli.py b/kasa/cli/main.py similarity index 100% rename from kasa/cli.py rename to kasa/cli/main.py diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 06a7d37ae..e6b96cd73 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -17,7 +17,7 @@ Module, UnsupportedDeviceError, ) -from kasa.cli import ( +from kasa.cli.main import ( TYPE_TO_CLASS, alias, brightness, @@ -500,7 +500,7 @@ async def _state(dev: Device): f"Username:{dev.credentials.username} Password:{dev.credentials.password}" ) - mocker.patch("kasa.cli.state", new=_state) + mocker.patch("kasa.cli.main.state", new=_state) dr = DiscoveryResult(**discovery_mock.discovery_data["result"]) res = await runner.invoke( @@ -746,7 +746,7 @@ async def _state(dev: Device): nonlocal result_device result_device = dev - mocker.patch("kasa.cli.state", new=_state) + mocker.patch("kasa.cli.main.state", new=_state) expected_type = TYPE_TO_CLASS[device_type] mocker.patch.object(expected_type, "update") res = await runner.invoke( diff --git a/pyproject.toml b/pyproject.toml index 45350aefd..bfa044774 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ include = [ "Documentation" = "https://python-kasa.readthedocs.io" [tool.poetry.scripts] -kasa = "kasa.cli:cli" +kasa = "kasa.cli:__main__" [tool.poetry.dependencies] python = "^3.9" From 7888f4904a09765ee94f329857da5cd1a3987caa Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 4 Jul 2024 16:22:47 +0100 Subject: [PATCH 518/892] Fix light preset module when list contains lighting effects (#1048) Fixes the residual issues with the light preset module not handling unexpected `lighting_effect` items in the presets list. Completes the fixes started with PR https://github.com/python-kasa/python-kasa/pull/1043 to fix https://github.com/python-kasa/python-kasa/issues/1040, [HA #121115](https://github.com/home-assistant/core/issues/121115) and [HA #121119](https://github.com/home-assistant/core/issues/121119) With this PR affected devices will no longer have the light preset functionality disabled. As this is a new feature this does not warrant a hotfix so will go into the next release. Updated fixture for testing thanks to @szssamuel, many thanks! --- kasa/smart/modules/lightpreset.py | 11 + .../fixtures/smart/L920-5(EU)_1.0_1.1.3.json | 863 +++++++++++++++--- 2 files changed, 767 insertions(+), 107 deletions(-) diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 7635a5f86..589060412 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from collections.abc import Sequence from dataclasses import asdict from typing import TYPE_CHECKING @@ -13,6 +14,8 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice +_LOGGER = logging.getLogger(__name__) + class LightPreset(SmartModule, LightPresetInterface): """Implementation of light presets.""" @@ -38,6 +41,14 @@ def _post_update_hook(self): state_key = "states" if not self._state_in_sysinfo else self.SYS_INFO_STATE_KEY if preset_states := self.data.get(state_key): for preset_state in preset_states: + if "brightness" not in preset_state: + # Some devices can store effects as a preset. These will be ignored + # and handled in the effects module + if "lighting_effect" not in preset_state: + _LOGGER.info( + "Unexpected keys %s in preset", list(preset_state.keys()) + ) + continue color_temp = preset_state.get("color_temp") hue = preset_state.get("hue") saturation = preset_state.get("saturation") diff --git a/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json b/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json index 0e7679e2b..5f03b5b64 100644 --- a/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json +++ b/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json @@ -122,7 +122,7 @@ "factory_default": false, "ip": "127.0.0.123", "is_support_iot_cloud": true, - "mac": "1C-61-B4-00-00-00", + "mac": "B4-B0-24-00-00-00", "mgt_encrypt_schm": { "encrypt_type": "KLAP", "http_port": 80, @@ -138,12 +138,12 @@ "rule_list": [] }, "get_auto_update_info": { - "enable": false, + "enable": true, "random_range": 120, "time": 180 }, "get_connect_cloud_state": { - "status": 1 + "status": 0 }, "get_countdown_rules": { "countdown_rule_max_count": 1, @@ -152,7 +152,7 @@ }, "get_device_info": { "avatar": "light_strip", - "brightness": 65, + "brightness": 100, "color_temp": 0, "color_temp_range": [ 9000, @@ -160,10 +160,10 @@ ], "default_states": { "state": { - "brightness": 65, + "brightness": 100, "color_temp": 0, - "hue": 9, - "saturation": 67 + "hue": 30, + "saturation": 0 }, "type": "last_states" }, @@ -171,78 +171,85 @@ "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.1.3 Build 231229 Rel.164316", - "has_set_location_info": false, - "hue": 9, + "has_set_location_info": true, + "hue": 30, "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", "ip": "127.0.0.123", - "lang": "de_DE", + "lang": "en_US", + "latitude": 0, "lighting_effect": { - "brightness": 65, + "brightness": 100, "custom": 0, "display_colors": [ [ - 136, - 98, + 30, + 0, + 100 + ], + [ + 30, + 95, 100 ], [ - 350, - 97, + 0, + 100, 100 ] ], "enable": 0, - "id": "TapoStrip_5zkiG6avJ1IbhjiZbRlWvh", - "name": "Christmas" + "id": "TapoStrip_1OVSyXIsDxrt4j7OxyRvqi", + "name": "Sunrise" }, - "mac": "1C-61-B4-00-00-00", + "longitude": 0, + "mac": "B4-B0-24-00-00-00", "model": "L920", "music_rhythm_enable": false, "music_rhythm_mode": "single_lamp", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "overheated": false, - "region": "Europe/Berlin", - "rssi": -56, - "saturation": 67, + "region": "Europe/Bucharest", + "rssi": -57, + "saturation": 0, "segment_effect": { "brightness": 97, "custom": 0, "display_colors": [], "enable": 0, "id": "", - "name": "Lightning" + "name": "Warm Aurora" }, "signal_level": 2, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", - "time_diff": 60, + "time_diff": 120, "type": "SMART.TAPOBULB" }, "get_device_segment": { "segment": 50 }, "get_device_time": { - "region": "Europe/Berlin", - "time_diff": 60, - "timestamp": 1719920893 + "region": "Europe/Bucharest", + "time_diff": 120, + "timestamp": 1720089009 }, "get_device_usage": { "power_usage": { - "past30": 20, - "past7": 20, - "today": 0 + "past30": 1211, + "past7": 183, + "today": 7 }, "saved_power": { - "past30": 319, - "past7": 319, - "today": 0 + "past30": 6124, + "past7": 1204, + "today": 30 }, "time_usage": { - "past30": 339, - "past7": 339, - "today": 0 + "past30": 7335, + "past7": 1387, + "today": 37 } }, "get_fw_download_state": { @@ -253,74 +260,133 @@ "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": { - "backgrounds": [ - [ - 136, - 98, - 75 - ], - [ - 136, - 0, - 0 - ], + "brightness": 100, + "custom": 0, + "direction": 1, + "display_colors": [ [ - 350, + 30, 0, 100 ], [ - 350, - 97, - 94 - ] - ], - "brightness": 65, - "brightness_range": [ - 50, - 100 - ], - "custom": 0, - "display_colors": [ - [ - 136, - 98, + 30, + 95, 100 ], [ - 350, - 97, + 0, + 100, 100 ] ], - "duration": 5000, + "duration": 600, "enable": 0, - "expansion_strategy": 1, - "fadeoff": 2000, - "hue_range": [ - 136, - 146 + "expansion_strategy": 2, + "id": "TapoStrip_1OVSyXIsDxrt4j7OxyRvqi", + "name": "Sunrise", + "repeat_times": 1, + "run_time": 0, + "segments": [ + 0 ], - "id": "TapoStrip_5zkiG6avJ1IbhjiZbRlWvh", - "init_states": [ + "sequence": [ [ - 136, + 0, + 100, + 5 + ], + [ + 0, + 100, + 5 + ], + [ + 10, + 100, + 6 + ], + [ + 15, + 100, + 7 + ], + [ + 20, + 100, + 8 + ], + [ + 20, + 100, + 10 + ], + [ + 30, + 100, + 12 + ], + [ + 30, + 95, + 15 + ], + [ + 30, + 90, + 20 + ], + [ + 30, + 80, + 25 + ], + [ + 30, + 75, + 30 + ], + [ + 30, + 70, + 40 + ], + [ + 30, + 60, + 50 + ], + [ + 30, + 50, + 60 + ], + [ + 30, + 20, + 70 + ], + [ + 30, 0, 100 ] ], - "name": "Christmas", - "random_seed": 100, - "saturation_range": [ - 90, - 100 - ], - "segments": [ - 0 - ], - "transition": 0, - "type": "random" + "spread": 1, + "trans_sequence": [], + "transition": 60000, + "type": "pulse" }, "get_next_event": {}, "get_on_off_gradually_info": { @@ -336,39 +402,438 @@ "saturation": 100 }, { - "brightness": 100, - "color_temp": 0, - "hue": 240, - "saturation": 100 + "lighting_effect": { + "brightness": 100, + "custom": 0, + "direction": 1, + "display_colors": [ + [ + 30, + 0, + 100 + ], + [ + 30, + 95, + 100 + ], + [ + 0, + 100, + 100 + ] + ], + "duration": 600, + "enable": 1, + "expansion_strategy": 2, + "id": "TapoStrip_1OVSyXIsDxrt4j7OxyRvqi", + "name": "Sunrise", + "repeat_times": 1, + "run_time": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 0, + 100, + 5 + ], + [ + 0, + 100, + 5 + ], + [ + 10, + 100, + 6 + ], + [ + 15, + 100, + 7 + ], + [ + 20, + 100, + 8 + ], + [ + 20, + 100, + 10 + ], + [ + 30, + 100, + 12 + ], + [ + 30, + 95, + 15 + ], + [ + 30, + 90, + 20 + ], + [ + 30, + 80, + 25 + ], + [ + 30, + 75, + 30 + ], + [ + 30, + 70, + 40 + ], + [ + 30, + 60, + 50 + ], + [ + 30, + 50, + 60 + ], + [ + 30, + 20, + 70 + ], + [ + 30, + 0, + 100 + ] + ], + "spread": 1, + "trans_sequence": [], + "transition": 60000, + "type": "pulse" + } }, { - "brightness": 100, - "color_temp": 0, - "hue": 0, - "saturation": 100 + "lighting_effect": { + "brightness": 100, + "custom": 0, + "direction": 1, + "display_colors": [ + [ + 0, + 100, + 100 + ], + [ + 30, + 95, + 100 + ], + [ + 30, + 0, + 100 + ] + ], + "duration": 600, + "enable": 1, + "expansion_strategy": 2, + "id": "TapoStrip_5NiN0Y8GAUD78p4neKk9EL", + "name": "Sunset", + "repeat_times": 1, + "run_time": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 30, + 0, + 100 + ], + [ + 30, + 20, + 100 + ], + [ + 30, + 50, + 99 + ], + [ + 30, + 60, + 98 + ], + [ + 30, + 70, + 97 + ], + [ + 30, + 75, + 95 + ], + [ + 30, + 80, + 93 + ], + [ + 30, + 90, + 90 + ], + [ + 30, + 95, + 85 + ], + [ + 30, + 100, + 80 + ], + [ + 20, + 100, + 70 + ], + [ + 20, + 100, + 60 + ], + [ + 15, + 100, + 50 + ], + [ + 10, + 100, + 40 + ], + [ + 0, + 100, + 30 + ], + [ + 0, + 100, + 0 + ] + ], + "spread": 1, + "trans_sequence": [], + "transition": 60000, + "type": "pulse" + } }, { - "brightness": 100, - "color_temp": 0, - "hue": 120, - "saturation": 100 + "lighting_effect": { + "brightness": 100, + "custom": 0, + "direction": 1, + "display_colors": [ + [ + 0, + 100, + 100 + ], + [ + 100, + 100, + 100 + ], + [ + 200, + 100, + 100 + ], + [ + 300, + 100, + 100 + ] + ], + "duration": 0, + "enable": 1, + "expansion_strategy": 1, + "id": "TapoStrip_7CC5y4lsL8pETYvmz7UOpQ", + "name": "Rainbow", + "repeat_times": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 0, + 100, + 100 + ], + [ + 100, + 100, + 100 + ], + [ + 200, + 100, + 100 + ], + [ + 300, + 100, + 100 + ] + ], + "spread": 12, + "transition": 1500, + "type": "sequence" + } }, { - "brightness": 100, - "color_temp": 0, - "hue": 277, - "saturation": 86 + "lighting_effect": { + "brightness": 100, + "custom": 0, + "direction": 4, + "display_colors": [ + [ + 120, + 100, + 100 + ], + [ + 240, + 100, + 100 + ], + [ + 260, + 100, + 100 + ], + [ + 280, + 100, + 100 + ] + ], + "duration": 0, + "enable": 1, + "expansion_strategy": 1, + "id": "TapoStrip_1MClvV18i15Jq3bvJVf0eP", + "name": "Aurora", + "repeat_times": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 120, + 100, + 100 + ], + [ + 240, + 100, + 100 + ], + [ + 260, + 100, + 100 + ], + [ + 280, + 100, + 100 + ] + ], + "spread": 7, + "transition": 1500, + "type": "sequence" + } }, { - "brightness": 100, - "color_temp": 0, - "hue": 60, - "saturation": 100 + "lighting_effect": { + "brightness": 100, + "custom": 1, + "direction": 4, + "display_colors": [ + [ + 103, + 100, + 100 + ], + [ + 73, + 100, + 100 + ], + [ + 16, + 100, + 100 + ], + [ + 44, + 100, + 100 + ] + ], + "duration": 0, + "enable": 1, + "expansion_strategy": 1, + "id": "TapoStrip_639hjRuGECd1gsSbFAINNn", + "name": "Warm Aurora", + "repeat_times": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 103, + 100, + 100 + ], + [ + 73, + 100, + 100 + ], + [ + 16, + 100, + 100 + ], + [ + 44, + 100, + 100 + ] + ], + "spread": 7, + "transition": 5000, + "type": "sequence" + } }, { "brightness": 100, "color_temp": 0, - "hue": 300, + "hue": 0, "saturation": 100 } ], @@ -387,7 +852,7 @@ "display_colors": [], "enable": 0, "id": "", - "name": "Lightning" + "name": "Warm Aurora" }, "get_wireless_scan_info": { "ap_list": [ @@ -398,10 +863,194 @@ "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": 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": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" } ], "start_index": 0, - "sum": 1, + "sum": 24, "wep_supported": false }, "qs_component_nego": { From 6e0bbd872011fca2f5802e529a1707426179036c Mon Sep 17 00:00:00 2001 From: gimpy88 <64541114+gimpy88@users.noreply.github.com> Date: Sun, 7 Jul 2024 04:16:07 -0400 Subject: [PATCH 519/892] Add KS205(US) v1.1.0 fixture (#1049) --- SUPPORTED.md | 1 + .../fixtures/smart/KS205(US)_1.0_1.1.0.json | 298 ++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 kasa/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json diff --git a/SUPPORTED.md b/SUPPORTED.md index e3ffcd1ec..e2db7bc7e 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -86,6 +86,7 @@ Some newer Kasa devices require authentication. These are marked with *\* + - Hardware: 1.0 (US) / Firmware: 1.1.0\* - **KS220M** - Hardware: 1.0 (US) / Firmware: 1.0.4 - **KS225** diff --git a/kasa/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json b/kasa/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json new file mode 100644 index 000000000..f9ac5af95 --- /dev/null +++ b/kasa/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json @@ -0,0 +1,298 @@ +{ + "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": 2 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS205(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-ED-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "" + }, + "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_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "switch_s500", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 240411 Rel.144632", + "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": "40-ED-00-00-00-00", + "model": "KS205", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/Toronto", + "rssi": -57, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Toronto", + "time_diff": -300, + "timestamp": 1720146765 + }, + "get_device_usage": { + "time_usage": { + "past30": 10601, + "past7": 966, + "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": 0, + "fw_ver": "1.1.0 Build 240411 Rel.144632", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 351, + "night_mode_type": "sunrise_sunset", + "start_time": 1266, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000-0000.00.000" + }, + "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": "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==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 1, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 6, + "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": "KS205", + "device_type": "SMART.KASASWITCH", + "is_klap": true + } + } +} From 4b77db31d020c71adc6cf566c5745d58a3dd5469 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sun, 7 Jul 2024 13:22:43 +0100 Subject: [PATCH 520/892] Add new HS220 kasa aes fixture (#1050) Many thanks to @pjarbit for making the device available for a fixture! --- README.md | 2 +- SUPPORTED.md | 1 + kasa/tests/device_fixtures.py | 2 +- .../fixtures/smart/HS220(US)_3.26_1.0.1.json | 371 ++++++++++++++++++ 4 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 kasa/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json diff --git a/README.md b/README.md index 2dfde360f..fcc28190d 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: EP10, EP25\*, HS100\*\*, HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M\*, KP401 - **Power Strips**: EP40, HS107, HS300, KP200, KP303, KP400 -- **Wall Switches**: ES20M, HS200, HS210, HS220, KP405, KS200M, KS205\*, KS220M, KS225\*, KS230, KS240\* +- **Wall Switches**: ES20M, HS200, HS210, HS220\*\*, KP405, KS200M, KS205\*, KS220M, KS225\*, KS230, KS240\* - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 - **Light Strips**: KL400L5, KL420L5, KL430 - **Hubs**: KH100\* diff --git a/SUPPORTED.md b/SUPPORTED.md index e2db7bc7e..40330d0a2 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -79,6 +79,7 @@ Some newer Kasa devices require authentication. These are marked with *\* - **KP405** - Hardware: 1.0 (US) / Firmware: 1.0.5 - **KS200M** diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 0d6fbd488..1eb3e829b 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -110,7 +110,7 @@ STRIPS = {*STRIPS_IOT, *STRIPS_SMART} DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} -DIMMERS_SMART = {"KS225", "S500D", "P135"} +DIMMERS_SMART = {"HS220", "KS225", "S500D", "P135"} DIMMERS = { *DIMMERS_IOT, *DIMMERS_SMART, diff --git a/kasa/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json b/kasa/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json new file mode 100644 index 000000000..63ec680b4 --- /dev/null +++ b/kasa/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json @@ -0,0 +1,371 @@ +{ + "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": 3 + }, + { + "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": "smart_switch", + "ver_code": 1 + }, + { + "id": "dimmer_custom_action", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "owner": "00000000000000000000000000000000", + "device_type": "SMART.KASASWITCH", + "device_model": "HS220(US)", + "ip": "127.0.0.123", + "mac": "24-2F-D0-00-00-00", + "is_support_iot_cloud": true, + "obd_src": "tplink", + "factory_default": false, + "mgt_encrypt_schm": { + "is_support_https": false, + "encrypt_type": "AES", + "http_port": 80, + "lv": 2 + } + }, + "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": "table_lamp_5", + "brightness": 51, + "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.1 Build 230829 Rel.160220", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.26", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "24-2F-D0-00-00-00", + "model": "HS220", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/Detroit", + "rssi": -62, + "signal_level": 2, + "smart_switch_state": false, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Detroit", + "time_diff": -300, + "timestamp": 1720201570 + }, + "get_device_usage": { + "time_usage": { + "past30": 30, + "past7": 30, + "today": 11 + } + }, + "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.1 Build 230829 Rel.160220", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "toggle", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + }, + "on_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + } + }, + "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": "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": 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==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 11, + "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": "HS220", + "device_type": "SMART.KASASWITCH", + "is_klap": false + } + } +} From abb5d0d412dc509c4d497b5e0bd6f496078d2f36 Mon Sep 17 00:00:00 2001 From: gimpy88 <64541114+gimpy88@users.noreply.github.com> Date: Sun, 7 Jul 2024 08:23:24 -0400 Subject: [PATCH 521/892] Add KP400(US) v1.0.4 fixture (#1051) --- SUPPORTED.md | 1 + kasa/tests/fixtures/KP400(US)_3.0_1.0.4.json | 46 ++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 kasa/tests/fixtures/KP400(US)_3.0_1.0.4.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 40330d0a2..1781442a0 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -66,6 +66,7 @@ Some newer Kasa devices require authentication. These are marked with * Date: Thu, 11 Jul 2024 13:26:33 +0100 Subject: [PATCH 522/892] Bump project version to 0.7.0.3 (#1053) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bfa044774..ad43f3bd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.2" +version = "0.7.0.3" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From a0440635260abe2d8b6719ff2260510d2fed9b3f Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 11 Jul 2024 15:11:50 +0200 Subject: [PATCH 523/892] Use first known thermostat state as main state (#1054) Instead of trying to use the first state when multiple are reported, iterate over the known states and pick the first matching. This will fix an issue where the device reports extra states (like `low_battery`) while having a known mode active. Related to https://github.com/home-assistant/core/issues/121335 --- kasa/smart/modules/temperaturecontrol.py | 43 ++++++++++--------- .../smart/modules/test_temperaturecontrol.py | 10 ++++- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index dcd0da725..00afe5b53 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -4,15 +4,10 @@ import logging from enum import Enum -from typing import TYPE_CHECKING from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - - _LOGGER = logging.getLogger(__name__) @@ -31,11 +26,11 @@ class TemperatureControl(SmartModule): REQUIRED_COMPONENT = "temp_control" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features after the initial update.""" self._add_feature( Feature( - device, + self._device, id="target_temperature", name="Target temperature", container=self, @@ -50,7 +45,7 @@ def __init__(self, device: SmartDevice, module: str): # TODO: this might belong into its own module, temperature_correction? self._add_feature( Feature( - device, + self._device, id="temperature_offset", name="Temperature offset", container=self, @@ -65,7 +60,7 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( - device, + self._device, id="state", name="State", container=self, @@ -78,7 +73,7 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( - device, + self._device, id="thermostat_mode", name="Thermostat mode", container=self, @@ -109,23 +104,24 @@ def mode(self) -> ThermostatState: if self._device.sys_info.get("frost_protection_on", False): return ThermostatState.Off - states = self._device.sys_info["trv_states"] + states = self.states # If the states is empty, the device is idling if not states: return ThermostatState.Idle + # Discard known extra states, and report on unknown extra states + states.discard("low_battery") if len(states) > 1: - _LOGGER.warning( - "Got multiple states (%s), using the first one: %s", states, states[0] - ) + _LOGGER.warning("Got multiple states: %s", states) - state = states[0] - try: - return ThermostatState(state) - except: # noqa: E722 - _LOGGER.warning("Got unknown state: %s", state) - return ThermostatState.Unknown + # Return the first known state + for state in ThermostatState: + if state.value in states: + return state + + _LOGGER.warning("Got unknown state: %s", states) + return ThermostatState.Unknown @property def allowed_temperature_range(self) -> tuple[int, int]: @@ -147,6 +143,11 @@ def target_temperature(self) -> float: """Return target temperature.""" return self._device.sys_info["target_temp"] + @property + def states(self) -> set: + """Return thermostat states.""" + return set(self._device.sys_info["trv_states"]) + async def set_target_temperature(self, target: float): """Set target temperature.""" if ( diff --git a/kasa/tests/smart/modules/test_temperaturecontrol.py b/kasa/tests/smart/modules/test_temperaturecontrol.py index 16e01ed2b..90f91216f 100644 --- a/kasa/tests/smart/modules/test_temperaturecontrol.py +++ b/kasa/tests/smart/modules/test_temperaturecontrol.py @@ -94,7 +94,7 @@ async def test_temperature_offset(dev): ), pytest.param( ThermostatState.Heating, - [ThermostatState.Heating], + ["heating"], False, id="heating is heating", ), @@ -135,3 +135,11 @@ async def test_thermostat_mode_warnings(dev, mode, states, msg, caplog): temp_module.data["trv_states"] = states assert temp_module.mode is mode assert msg in caplog.text + + +@thermostats_smart +async def test_thermostat_heating_with_low_battery(dev): + """Test that mode is reported correctly with extra states.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + temp_module.data["trv_states"] = ["low_battery", "heating"] + assert temp_module.mode is ThermostatState.Heating From 7fd5c213e6a73e4aca709098211bed347ea42b07 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 11 Jul 2024 16:21:59 +0100 Subject: [PATCH 524/892] Defer module updates for less volatile modules (#1052) Addresses stability issues on older hw device versions - Handles module timeout errors better by querying modules individually on errors and disabling problematic modules like Firmware that go out to the internet to get updates. - Addresses an issue with the Led module on P100 hardware version 1.0 which appears to have a memory leak and will cause the device to crash after approximately 500 calls. - Delays updates of modules that do not have regular changes like LightPreset and LightEffect and enables them to be updated on the next update cycle only if required values have changed. --- kasa/aestransport.py | 14 ++- kasa/exceptions.py | 2 + kasa/httpclient.py | 23 +++- kasa/smart/modules/cloud.py | 1 + kasa/smart/modules/firmware.py | 12 +-- kasa/smart/modules/led.py | 5 +- kasa/smart/modules/lighteffect.py | 5 +- kasa/smart/modules/lightpreset.py | 4 +- kasa/smart/modules/lightstripeffect.py | 4 +- kasa/smart/modules/lighttransition.py | 6 +- kasa/smart/smartchilddevice.py | 2 + kasa/smart/smartdevice.py | 141 ++++++++++++++++++++----- kasa/smart/smartmodule.py | 29 ++++- kasa/smartprotocol.py | 44 ++++++-- kasa/tests/test_smartdevice.py | 126 +++++++++++++++++++++- kasa/tests/test_smartprotocol.py | 2 +- 16 files changed, 364 insertions(+), 56 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index c9cb83bd3..cd0f24b38 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -146,7 +146,9 @@ def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: try: error_code = SmartErrorCode.from_int(error_code_raw) except ValueError: - _LOGGER.warning("Received unknown error code: %s", error_code_raw) + _LOGGER.warning( + "Device %s received unknown error code: %s", self._host, error_code_raw + ) error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR if error_code is SmartErrorCode.SUCCESS: return @@ -216,10 +218,18 @@ async def perform_login(self): """Login to the device.""" try: await self.try_login(self._login_params) + _LOGGER.debug( + "%s: logged in with provided credentials", + self._host, + ) except AuthenticationError as aex: try: if aex.error_code is not SmartErrorCode.LOGIN_ERROR: raise aex + _LOGGER.debug( + "%s: trying login with default TAPO credentials", + self._host, + ) if self._default_credentials is None: self._default_credentials = get_default_credentials( DEFAULT_CREDENTIALS["TAPO"] @@ -227,7 +237,7 @@ async def perform_login(self): await self.perform_handshake() await self.try_login(self._get_login_params(self._default_credentials)) _LOGGER.debug( - "%s: logged in with default credentials", + "%s: logged in with default TAPO credentials", self._host, ) except (AuthenticationError, _ConnectionError, TimeoutError): diff --git a/kasa/exceptions.py b/kasa/exceptions.py index f5c26ff04..3f7f301ba 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -128,6 +128,8 @@ def from_int(value: int) -> SmartErrorCode: # Library internal for unknown error codes INTERNAL_UNKNOWN_ERROR = -100_000 + # Library internal for query errors + INTERNAL_QUERY_ERROR = -100_001 SMART_RETRYABLE_ERRORS = [ diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 02e697821..1c8c46e27 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -75,13 +75,21 @@ async def post( now = time.time() gap = now - self._last_request_time if gap < self._wait_between_requests: - await asyncio.sleep(self._wait_between_requests - gap) + sleep = self._wait_between_requests - gap + _LOGGER.debug( + "Device %s waiting %s seconds to send request", + self._config.host, + sleep, + ) + await asyncio.sleep(sleep) _LOGGER.debug("Posting to %s", url) response_data = None self._last_url = url self.client.cookie_jar.clear() return_json = bool(json) + client_timeout = aiohttp.ClientTimeout(total=self._config.timeout) + # 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 @@ -95,9 +103,10 @@ async def post( params=params, data=data, json=json, - timeout=self._config.timeout, + timeout=client_timeout, cookies=cookies_dict, headers=headers, + ssl=False, ) async with resp: if resp.status == 200: @@ -106,9 +115,15 @@ async def post( response_data = json_loads(response_data.decode()) except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex: - if isinstance(ex, aiohttp.ClientOSError): + if not self._wait_between_requests: + _LOGGER.debug( + "Device %s received an os error, " + "enabling sequential request delay: %s", + self._config.host, + ex, + ) self._wait_between_requests = self.WAIT_BETWEEN_REQUESTS_ON_OSERROR - self._last_request_time = time.time() + self._last_request_time = time.time() raise _ConnectionError( f"Device connection error: {self._config.host}: {ex}", ex ) from ex diff --git a/kasa/smart/modules/cloud.py b/kasa/smart/modules/cloud.py index 8346af57a..e7513a562 100644 --- a/kasa/smart/modules/cloud.py +++ b/kasa/smart/modules/cloud.py @@ -16,6 +16,7 @@ class Cloud(SmartModule): QUERY_GETTER_NAME = "get_connect_cloud_state" REQUIRED_COMPONENT = "cloud_connect" + MINIMUM_UPDATE_INTERVAL_SECS = 60 def _post_update_hook(self): """Perform actions after a device update. diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 10a6b8245..dc0483e71 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -14,7 +14,7 @@ from pydantic.v1 import BaseModel, Field, validator from ...feature import Feature -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -66,6 +66,7 @@ class Firmware(SmartModule): """Implementation of firmware module.""" REQUIRED_COMPONENT = "firmware" + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) @@ -122,13 +123,6 @@ def query(self) -> dict: req["get_auto_update_info"] = None return req - def _post_update_hook(self): - """Perform actions after a device update. - - Overrides the default behaviour to disable a module if the query returns - an error because some of the module still functions. - """ - @property def current_firmware(self) -> str: """Return the current firmware version.""" @@ -162,6 +156,7 @@ async def get_update_state(self) -> DownloadState: state = resp["get_fw_download_state"] return DownloadState(**state) + @allow_update_after async def update( self, progress_cb: Callable[[DownloadState], Coroutine] | None = None ): @@ -219,6 +214,7 @@ def auto_update_enabled(self): and self.data["get_auto_update_info"]["enable"] ) + @allow_update_after async def set_auto_update_enabled(self, enabled: bool): """Change autoupdate setting.""" data = {**self.data["get_auto_update_info"], "enable": enabled} diff --git a/kasa/smart/modules/led.py b/kasa/smart/modules/led.py index 2d0a354c0..bbfe3579b 100644 --- a/kasa/smart/modules/led.py +++ b/kasa/smart/modules/led.py @@ -3,7 +3,7 @@ from __future__ import annotations from ...interfaces.led import Led as LedInterface -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after class Led(SmartModule, LedInterface): @@ -11,6 +11,8 @@ class Led(SmartModule, LedInterface): REQUIRED_COMPONENT = "led" QUERY_GETTER_NAME = "get_led_info" + # Led queries can cause device to crash on P100 + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 def query(self) -> dict: """Query to execute during the update cycle.""" @@ -29,6 +31,7 @@ def led(self): """Return current led status.""" return self.data["led_rule"] != "never" + @allow_update_after async def set_led(self, enable: bool): """Set led. diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py index 07f6aece9..5f589d6dd 100644 --- a/kasa/smart/modules/lighteffect.py +++ b/kasa/smart/modules/lighteffect.py @@ -9,7 +9,7 @@ from typing import Any from ..effects import SmartLightEffect -from ..smartmodule import Module, SmartModule +from ..smartmodule import Module, SmartModule, allow_update_after class LightEffect(SmartModule, SmartLightEffect): @@ -17,6 +17,7 @@ class LightEffect(SmartModule, SmartLightEffect): REQUIRED_COMPONENT = "light_effect" QUERY_GETTER_NAME = "get_dynamic_light_effect_rules" + MINIMUM_UPDATE_INTERVAL_SECS = 60 AVAILABLE_BULB_EFFECTS = { "L1": "Party", "L2": "Relax", @@ -130,6 +131,7 @@ def brightness(self) -> int: return brightness + @allow_update_after async def set_brightness( self, brightness: int, @@ -156,6 +158,7 @@ def _replace_brightness(data, new_brightness): return await self.call("edit_dynamic_light_effect_rule", new_effect) + @allow_update_after async def set_custom_effect( self, effect_dict: dict, diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 589060412..6bb2fb3fa 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -9,7 +9,7 @@ from ...interfaces import LightPreset as LightPresetInterface from ...interfaces import LightState -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -22,6 +22,7 @@ class LightPreset(SmartModule, LightPresetInterface): REQUIRED_COMPONENT = "preset" QUERY_GETTER_NAME = "get_preset_rules" + MINIMUM_UPDATE_INTERVAL_SECS = 60 SYS_INFO_STATE_KEY = "preset_state" @@ -124,6 +125,7 @@ async def set_preset( raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}") await self._device.modules[SmartModule.Light].set_state(preset) + @allow_update_after async def save_preset( self, preset_name: str, diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index a80c20f3c..f75620686 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from ..effects import EFFECT_MAPPING, EFFECT_NAMES, SmartLightEffect -from ..smartmodule import Module, SmartModule +from ..smartmodule import Module, SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -84,6 +84,7 @@ def effect_list(self) -> list[str]: """ return self._effect_list + @allow_update_after async def set_effect( self, effect: str, @@ -126,6 +127,7 @@ async def set_effect( await self.set_custom_effect(effect_dict) + @allow_update_after async def set_custom_effect( self, effect_dict: dict, diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index ca0eca867..3a5897d12 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -6,7 +6,7 @@ from ...exceptions import KasaException from ...feature import Feature -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -23,6 +23,7 @@ class LightTransition(SmartModule): REQUIRED_COMPONENT = "on_off_gradually" QUERY_GETTER_NAME = "get_on_off_gradually_info" + MINIMUM_UPDATE_INTERVAL_SECS = 60 MAXIMUM_DURATION = 60 # Key in sysinfo that indicates state can be retrieved from there. @@ -136,6 +137,7 @@ def _post_update_hook(self) -> None: "max_duration": off_max, } + @allow_update_after async def set_enabled(self, enable: bool): """Enable gradual on/off.""" if not self._supports_on_and_off: @@ -168,6 +170,7 @@ def _turn_on_transition_max(self) -> int: # v3 added max_duration, we default to 60 when it's not available return self._on_state["max_duration"] + @allow_update_after async def set_turn_on_transition(self, seconds: int): """Set turn on transition in seconds. @@ -203,6 +206,7 @@ def _turn_off_transition_max(self) -> int: # v3 added max_duration, we default to 60 when it's not available return self._off_state["max_duration"] + @allow_update_after async def set_turn_off_transition(self, seconds: int): """Set turn on transition in seconds. diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 98145f6c9..679692baf 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import time from typing import Any from ..device_type import DeviceType @@ -54,6 +55,7 @@ async def _update(self, update_children: bool = True): req.update(mod_query) if req: self._last_update = await self.protocol.query(req) + self._last_update_time = time.time() @classmethod async def create(cls, parent: SmartDevice, child_info, child_components): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 5bf2400b7..8cdd2013a 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -4,6 +4,7 @@ import base64 import logging +import time from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone from typing import Any, cast @@ -18,6 +19,7 @@ from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol from .modules import ( + ChildDevice, Cloud, DeviceModule, Firmware, @@ -35,6 +37,9 @@ # same issue, homekit perhaps? NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] +# Modules that are called as part of the init procedure on first update +FIRST_UPDATE_MODULES = {DeviceModule, ChildDevice, Cloud} + # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. @@ -60,6 +65,7 @@ def __init__( self._parent: SmartDevice | None = None self._children: Mapping[str, SmartDevice] = {} self._last_update = {} + self._last_update_time: float | None = None async def _initialize_children(self): """Initialize children for power strips.""" @@ -152,19 +158,15 @@ async def update(self, update_children: bool = False): if self.credentials is None and self.credentials_hash is None: raise AuthenticationError("Tapo plug requires authentication.") - if self._components_raw is None: + first_update = self._last_update_time is None + now = time.time() + self._last_update_time = now + + if first_update: await self._negotiate() await self._initialize_modules() - req: dict[str, Any] = {} - - # TODO: this could be optimized by constructing the query only once - for module in self._modules.values(): - req.update(module.query()) - - self._last_update = resp = await self.protocol.query(req) - - self._info = self._try_get_response(resp, "get_device_info") + resp = await self._modular_update(first_update, now) # Call child update which will only update module calls, info is updated # from get_child_device_list. update_children only affects hub devices, other @@ -172,18 +174,12 @@ async def update(self, update_children: bool = False): if update_children or self.device_type != DeviceType.Hub: for child in self._children.values(): await child._update() - if child_info := self._try_get_response(resp, "get_child_device_list", {}): + if child_info := self._try_get_response( + self._last_update, "get_child_device_list", {} + ): for info in child_info["child_device_list"]: self._children[info["device_id"]]._update_internal_state(info) - # Call handle update for modules that want to update internal data - errors = [] - for module_name, module in self._modules.items(): - if not self._handle_module_post_update_hook(module): - errors.append(module_name) - for error in errors: - self._modules.pop(error) - for child in self._children.values(): errors = [] for child_module_name, child_module in child._modules.items(): @@ -197,14 +193,18 @@ async def update(self, update_children: bool = False): if not self._features: await self._initialize_features() - _LOGGER.debug("Got an update: %s", self._last_update) + _LOGGER.debug( + "Update completed %s: %s", + self.host, + self._last_update if first_update else resp, + ) def _handle_module_post_update_hook(self, module: SmartModule) -> bool: try: module._post_update_hook() return True except Exception as ex: - _LOGGER.error( + _LOGGER.warning( "Error processing %s for device %s, module will be unavailable: %s", module.name, self.host, @@ -212,6 +212,100 @@ def _handle_module_post_update_hook(self, module: SmartModule) -> bool: ) return False + async def _modular_update( + self, first_update: bool, update_time: float + ) -> dict[str, Any]: + """Update the device with via the module queries.""" + req: dict[str, Any] = {} + # Keep a track of actual module queries so we can track the time for + # modules that do not need to be updated frequently + module_queries: list[SmartModule] = [] + mq = { + module: query + for module in self._modules.values() + if (query := module.query()) + } + for module, query in mq.items(): + if first_update and module.__class__ in FIRST_UPDATE_MODULES: + module._last_update_time = update_time + continue + if ( + not module.MINIMUM_UPDATE_INTERVAL_SECS + or not module._last_update_time + or (update_time - module._last_update_time) + >= module.MINIMUM_UPDATE_INTERVAL_SECS + ): + module_queries.append(module) + req.update(query) + + _LOGGER.debug( + "Querying %s for modules: %s", + self.host, + ", ".join(mod.name for mod in module_queries), + ) + + try: + resp = await self.protocol.query(req) + except Exception as ex: + resp = await self._handle_modular_update_error( + ex, first_update, ", ".join(mod.name for mod in module_queries), req + ) + + info_resp = self._last_update if first_update else resp + self._last_update.update(**resp) + self._info = self._try_get_response(info_resp, "get_device_info") + + # Call handle update for modules that want to update internal data + errors = [] + for module_name, module in self._modules.items(): + if not self._handle_module_post_update_hook(module): + errors.append(module_name) + for error in errors: + self._modules.pop(error) + + # Set the last update time for modules that had queries made. + for module in module_queries: + module._last_update_time = update_time + + return resp + + async def _handle_modular_update_error( + self, + ex: Exception, + first_update: bool, + module_names: str, + requests: dict[str, Any], + ) -> dict[str, Any]: + """Handle an error on calling module update. + + Will try to call all modules individually + and any errors such as timeouts will be set as a SmartErrorCode. + """ + msg_part = "on first update" if first_update else "after first update" + + _LOGGER.error( + "Error querying %s for modules '%s' %s: %s", + self.host, + module_names, + msg_part, + ex, + ) + responses = {} + for meth, params in requests.items(): + try: + resp = await self.protocol.query({meth: params}) + responses[meth] = resp[meth] + except Exception as iex: + _LOGGER.error( + "Error querying %s individually for module query '%s' %s: %s", + self.host, + meth, + msg_part, + iex, + ) + responses[meth] = SmartErrorCode.INTERNAL_QUERY_ERROR + return responses + async def _initialize_modules(self): """Initialize modules based on component negotiation response.""" from .smartmodule import SmartModule @@ -229,8 +323,6 @@ async def _initialize_modules(self): skip_parent_only_modules = True for mod in SmartModule.REGISTERED_MODULES.values(): - _LOGGER.debug("%s requires %s", mod, mod.REQUIRED_COMPONENT) - if ( skip_parent_only_modules and mod in NON_HUB_PARENT_ONLY_MODULES ) or mod.__name__ in child_modules_to_skip: @@ -240,7 +332,8 @@ async def _initialize_modules(self): or self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None ): _LOGGER.debug( - "Found required %s, adding %s to modules.", + "Device %s, found required %s, adding %s to modules.", + self.host, mod.REQUIRED_COMPONENT, mod.__name__, ) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index fb946a8b3..f5f2c212a 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -3,7 +3,10 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from collections.abc import Awaitable, Callable, Coroutine +from typing import TYPE_CHECKING, Any + +from typing_extensions import Concatenate, ParamSpec, TypeVar from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..module import Module @@ -13,6 +16,27 @@ _LOGGER = logging.getLogger(__name__) +_T = TypeVar("_T", bound="SmartModule") +_P = ParamSpec("_P") + + +def allow_update_after( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Define a wrapper to set _last_update_time to None. + + This will ensure that a module is updated in the next update cycle after + a value has been changed. + """ + + async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + finally: + self._last_update_time = None + + return _async_wrap + class SmartModule(Module): """Base class for SMART modules.""" @@ -27,9 +51,12 @@ class SmartModule(Module): REGISTERED_MODULES: dict[str, type[SmartModule]] = {} + MINIMUM_UPDATE_INTERVAL_SECS = 0 + def __init__(self, device: SmartDevice, module: str): self._device: SmartDevice super().__init__(device, module) + self._last_update_time: float | None = None def __init_subclass__(cls, **kwargs): name = getattr(cls, "NAME", cls.__name__) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 3085714c4..0c95325a5 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -73,18 +73,32 @@ async def _query(self, request: str | dict, retry_count: int = 3) -> dict: return await self._execute_query( request, retry_count=retry, iterate_list_pages=True ) - except _ConnectionError as sdex: + except _ConnectionError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a connection error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise sdex + raise ex continue - except AuthenticationError as auex: + except AuthenticationError as ex: await self._transport.reset() _LOGGER.debug( - "Unable to authenticate with %s, not retrying", self._host + "Unable to authenticate with %s, not retrying: %s", self._host, ex ) - raise auex + raise ex except _RetryableError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a retryable error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) @@ -92,6 +106,13 @@ async def _query(self, request: str | dict, retry_count: int = 3) -> dict: await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_TIMEOUT) continue except TimeoutError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a timeout error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) @@ -130,20 +151,21 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic self._handle_response_error_code(resp, method, raise_on_error=False) multi_result[method] = resp["result"] return multi_result - for i in range(0, end, step): + + for batch_num, i in enumerate(range(0, end, step)): requests_step = multi_requests[i : i + step] smart_params = {"requests": requests_step} smart_request = self.get_smart_request(smart_method, smart_params) + batch_name = f"multi-request-batch-{batch_num+1}-of-{int(end/step)+1}" if debug_enabled: _LOGGER.debug( - "%s multi-request-batch-%s >> %s", + "%s %s >> %s", self._host, - i + 1, + batch_name, pf(smart_request), ) response_step = await self._transport.send(smart_request) - batch_name = f"multi-request-batch-{i+1}" if debug_enabled: _LOGGER.debug( "%s %s << %s", @@ -271,7 +293,9 @@ def _handle_response_error_code(self, resp_dict: dict, method, raise_on_error=Tr try: error_code = SmartErrorCode.from_int(error_code_raw) except ValueError: - _LOGGER.warning("Received unknown error code: %s", error_code_raw) + _LOGGER.warning( + "Device %s received unknown error code: %s", self._host, error_code_raw + ) error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR if error_code is SmartErrorCode.SUCCESS: diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 44fabc715..99e2ddb9e 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -3,10 +3,12 @@ from __future__ import annotations import logging +import time from typing import Any, cast from unittest.mock import patch import pytest +from freezegun.api import FrozenDateTimeFactory from pytest_mock import MockerFixture from kasa import Device, KasaException, Module @@ -54,6 +56,8 @@ async def test_initial_update(dev: SmartDevice, mocker: MockerFixture): dev._modules = {} dev._features = {} dev._children = {} + dev._last_update = {} + dev._last_update_time = None negotiate = mocker.spy(dev, "_negotiate") initialize_modules = mocker.spy(dev, "_initialize_modules") @@ -109,6 +113,9 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): """Test that the regular update uses queries from all supported modules.""" # We need to have some modules initialized by now assert dev._modules + # Reset last update so all modules will query + for mod in dev._modules.values(): + mod._last_update_time = None device_queries: dict[SmartDevice, dict[str, Any]] = {} for mod in dev._modules.values(): @@ -139,7 +146,7 @@ async def test_update_module_errors(dev: SmartDevice, mocker: MockerFixture): assert dev._modules critical_modules = {Module.DeviceModule, Module.ChildDevice} - not_disabling_modules = {Module.Firmware, Module.Cloud} + not_disabling_modules = {Module.Cloud} new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) @@ -204,6 +211,123 @@ async def _child_query(self, request, *args, **kwargs): ), f"{modname} present {mod_present} when no_disable {no_disable}" +@device_smart +async def test_update_module_update_delays( + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +): + """Test that modules that disabled / removed on query failures.""" + # We need to have some modules initialized by now + assert dev._modules + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + await new_dev.update() + first_update_time = time.time() + assert new_dev._last_update_time == first_update_time + for module in new_dev.modules.values(): + if module.query(): + assert module._last_update_time == first_update_time + + seconds = 0 + tick = 30 + while seconds <= 180: + seconds += tick + freezer.tick(tick) + + now = time.time() + await new_dev.update() + for module in new_dev.modules.values(): + mod_delay = module.MINIMUM_UPDATE_INTERVAL_SECS + if module.query(): + expected_update_time = ( + now if mod_delay == 0 else now - (seconds % mod_delay) + ) + + assert ( + module._last_update_time == expected_update_time + ), f"Expected update time {expected_update_time} after {seconds} seconds for {module.name} with delay {mod_delay} got {module._last_update_time}" + + +@pytest.mark.parametrize( + ("first_update"), + [ + pytest.param(True, id="First update true"), + pytest.param(False, id="First update false"), + ], +) +@device_smart +async def test_update_module_query_errors( + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, + first_update, +): + """Test that modules that disabled / removed on query failures.""" + # We need to have some modules initialized by now + assert dev._modules + + first_update_queries = {"get_device_info", "get_connect_cloud_state"} + + critical_modules = {Module.DeviceModule, Module.ChildDevice} + not_disabling_modules = {Module.Cloud} + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + if not first_update: + await new_dev.update() + freezer.tick( + max(module.MINIMUM_UPDATE_INTERVAL_SECS for module in dev._modules.values()) + ) + + module_queries = { + modname: q + for modname, module in dev._modules.items() + if (q := module.query()) and modname not in critical_modules + } + + async def _query(request, *args, **kwargs): + if ( + "component_nego" in request + or "get_child_device_component_list" in request + or "control_child" in request + ): + return await dev.protocol._query(request, *args, **kwargs) + if len(request) == 1 and "get_device_info" in request: + return await dev.protocol._query(request, *args, **kwargs) + + raise TimeoutError("Dummy timeout") + + from kasa.smartprotocol import _ChildProtocolWrapper + + child_protocols = { + cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol + for child in dev.children + } + + async def _child_query(self, request, *args, **kwargs): + return await child_protocols[self._device_id]._query(request, *args, **kwargs) + + mocker.patch.object(new_dev.protocol, "query", side_effect=_query) + # children not created yet so cannot patch.object + mocker.patch("kasa.smartprotocol._ChildProtocolWrapper.query", new=_child_query) + + await new_dev.update() + msg = f"Error querying {new_dev.host} for modules" + assert msg in caplog.text + for modname in module_queries: + no_disable = modname in not_disabling_modules + mod_present = modname in new_dev._modules + assert ( + mod_present is no_disable + ), f"{modname} present {mod_present} when no_disable {no_disable}" + for mod_query in module_queries[modname]: + if not first_update or mod_query not in first_update_queries: + msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" + assert msg in caplog.text + + async def test_get_modules(): """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 71125ca83..204d0c7f2 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -66,7 +66,7 @@ async def test_smart_device_unknown_errors( assert res is SmartErrorCode.INTERNAL_UNKNOWN_ERROR send_mock.assert_called_once() - assert f"Received unknown error code: {error_code}" in caplog.text + assert f"received unknown error code: {error_code}" in caplog.text @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) From 5dac0922274b6b474072cebc30f96ac50bcd2652 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 11 Jul 2024 16:54:15 +0100 Subject: [PATCH 525/892] Defer module updates for less volatile modules (pick 1052) (#1056) Pick commit 7fd5c213e6a73e4aca709098211bed347ea42b07 from 1052 Addresses stability issues on older hw device versions - Handles module timeout errors better by querying modules individually on errors and disabling problematic modules like Firmware that go out to the internet to get updates. - Addresses an issue with the Led module on P100 hardware version 1.0 which appears to have a memory leak and will cause the device to crash after approximately 500 calls. - Delays updates of modules that do not have regular changes like LightPreset and LightEffect and enables them to be updated on the next update cycle only if required values have changed. --- kasa/aestransport.py | 14 ++- kasa/exceptions.py | 2 + kasa/httpclient.py | 23 +++- kasa/smart/modules/cloud.py | 1 + kasa/smart/modules/firmware.py | 12 +-- kasa/smart/modules/led.py | 5 +- kasa/smart/modules/lighteffect.py | 5 +- kasa/smart/modules/lightpreset.py | 4 +- kasa/smart/modules/lightstripeffect.py | 4 +- kasa/smart/modules/lighttransition.py | 6 +- kasa/smart/smartchilddevice.py | 2 + kasa/smart/smartdevice.py | 141 ++++++++++++++++++++----- kasa/smart/smartmodule.py | 29 ++++- kasa/smartprotocol.py | 44 ++++++-- kasa/tests/test_smartdevice.py | 126 +++++++++++++++++++++- kasa/tests/test_smartprotocol.py | 2 +- 16 files changed, 364 insertions(+), 56 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index cc373b190..abe282c05 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -144,7 +144,9 @@ def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: try: error_code = SmartErrorCode.from_int(error_code_raw) except ValueError: - _LOGGER.warning("Received unknown error code: %s", error_code_raw) + _LOGGER.warning( + "Device %s received unknown error code: %s", self._host, error_code_raw + ) error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR if error_code is SmartErrorCode.SUCCESS: return @@ -214,10 +216,18 @@ async def perform_login(self): """Login to the device.""" try: await self.try_login(self._login_params) + _LOGGER.debug( + "%s: logged in with provided credentials", + self._host, + ) except AuthenticationError as aex: try: if aex.error_code is not SmartErrorCode.LOGIN_ERROR: raise aex + _LOGGER.debug( + "%s: trying login with default TAPO credentials", + self._host, + ) if self._default_credentials is None: self._default_credentials = get_default_credentials( DEFAULT_CREDENTIALS["TAPO"] @@ -225,7 +235,7 @@ async def perform_login(self): await self.perform_handshake() await self.try_login(self._get_login_params(self._default_credentials)) _LOGGER.debug( - "%s: logged in with default credentials", + "%s: logged in with default TAPO credentials", self._host, ) except (AuthenticationError, _ConnectionError, TimeoutError): diff --git a/kasa/exceptions.py b/kasa/exceptions.py index f5c26ff04..3f7f301ba 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -128,6 +128,8 @@ def from_int(value: int) -> SmartErrorCode: # Library internal for unknown error codes INTERNAL_UNKNOWN_ERROR = -100_000 + # Library internal for query errors + INTERNAL_QUERY_ERROR = -100_001 SMART_RETRYABLE_ERRORS = [ diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 02e697821..1c8c46e27 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -75,13 +75,21 @@ async def post( now = time.time() gap = now - self._last_request_time if gap < self._wait_between_requests: - await asyncio.sleep(self._wait_between_requests - gap) + sleep = self._wait_between_requests - gap + _LOGGER.debug( + "Device %s waiting %s seconds to send request", + self._config.host, + sleep, + ) + await asyncio.sleep(sleep) _LOGGER.debug("Posting to %s", url) response_data = None self._last_url = url self.client.cookie_jar.clear() return_json = bool(json) + client_timeout = aiohttp.ClientTimeout(total=self._config.timeout) + # 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 @@ -95,9 +103,10 @@ async def post( params=params, data=data, json=json, - timeout=self._config.timeout, + timeout=client_timeout, cookies=cookies_dict, headers=headers, + ssl=False, ) async with resp: if resp.status == 200: @@ -106,9 +115,15 @@ async def post( response_data = json_loads(response_data.decode()) except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex: - if isinstance(ex, aiohttp.ClientOSError): + if not self._wait_between_requests: + _LOGGER.debug( + "Device %s received an os error, " + "enabling sequential request delay: %s", + self._config.host, + ex, + ) self._wait_between_requests = self.WAIT_BETWEEN_REQUESTS_ON_OSERROR - self._last_request_time = time.time() + self._last_request_time = time.time() raise _ConnectionError( f"Device connection error: {self._config.host}: {ex}", ex ) from ex diff --git a/kasa/smart/modules/cloud.py b/kasa/smart/modules/cloud.py index 8346af57a..e7513a562 100644 --- a/kasa/smart/modules/cloud.py +++ b/kasa/smart/modules/cloud.py @@ -16,6 +16,7 @@ class Cloud(SmartModule): QUERY_GETTER_NAME = "get_connect_cloud_state" REQUIRED_COMPONENT = "cloud_connect" + MINIMUM_UPDATE_INTERVAL_SECS = 60 def _post_update_hook(self): """Perform actions after a device update. diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 10a6b8245..dc0483e71 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -14,7 +14,7 @@ from pydantic.v1 import BaseModel, Field, validator from ...feature import Feature -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -66,6 +66,7 @@ class Firmware(SmartModule): """Implementation of firmware module.""" REQUIRED_COMPONENT = "firmware" + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) @@ -122,13 +123,6 @@ def query(self) -> dict: req["get_auto_update_info"] = None return req - def _post_update_hook(self): - """Perform actions after a device update. - - Overrides the default behaviour to disable a module if the query returns - an error because some of the module still functions. - """ - @property def current_firmware(self) -> str: """Return the current firmware version.""" @@ -162,6 +156,7 @@ async def get_update_state(self) -> DownloadState: state = resp["get_fw_download_state"] return DownloadState(**state) + @allow_update_after async def update( self, progress_cb: Callable[[DownloadState], Coroutine] | None = None ): @@ -219,6 +214,7 @@ def auto_update_enabled(self): and self.data["get_auto_update_info"]["enable"] ) + @allow_update_after async def set_auto_update_enabled(self, enabled: bool): """Change autoupdate setting.""" data = {**self.data["get_auto_update_info"], "enable": enabled} diff --git a/kasa/smart/modules/led.py b/kasa/smart/modules/led.py index 2d0a354c0..bbfe3579b 100644 --- a/kasa/smart/modules/led.py +++ b/kasa/smart/modules/led.py @@ -3,7 +3,7 @@ from __future__ import annotations from ...interfaces.led import Led as LedInterface -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after class Led(SmartModule, LedInterface): @@ -11,6 +11,8 @@ class Led(SmartModule, LedInterface): REQUIRED_COMPONENT = "led" QUERY_GETTER_NAME = "get_led_info" + # Led queries can cause device to crash on P100 + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 def query(self) -> dict: """Query to execute during the update cycle.""" @@ -29,6 +31,7 @@ def led(self): """Return current led status.""" return self.data["led_rule"] != "never" + @allow_update_after async def set_led(self, enable: bool): """Set led. diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py index 07f6aece9..5f589d6dd 100644 --- a/kasa/smart/modules/lighteffect.py +++ b/kasa/smart/modules/lighteffect.py @@ -9,7 +9,7 @@ from typing import Any from ..effects import SmartLightEffect -from ..smartmodule import Module, SmartModule +from ..smartmodule import Module, SmartModule, allow_update_after class LightEffect(SmartModule, SmartLightEffect): @@ -17,6 +17,7 @@ class LightEffect(SmartModule, SmartLightEffect): REQUIRED_COMPONENT = "light_effect" QUERY_GETTER_NAME = "get_dynamic_light_effect_rules" + MINIMUM_UPDATE_INTERVAL_SECS = 60 AVAILABLE_BULB_EFFECTS = { "L1": "Party", "L2": "Relax", @@ -130,6 +131,7 @@ def brightness(self) -> int: return brightness + @allow_update_after async def set_brightness( self, brightness: int, @@ -156,6 +158,7 @@ def _replace_brightness(data, new_brightness): return await self.call("edit_dynamic_light_effect_rule", new_effect) + @allow_update_after async def set_custom_effect( self, effect_dict: dict, diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 7635a5f86..b96924385 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -8,7 +8,7 @@ from ...interfaces import LightPreset as LightPresetInterface from ...interfaces import LightState -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -19,6 +19,7 @@ class LightPreset(SmartModule, LightPresetInterface): REQUIRED_COMPONENT = "preset" QUERY_GETTER_NAME = "get_preset_rules" + MINIMUM_UPDATE_INTERVAL_SECS = 60 SYS_INFO_STATE_KEY = "preset_state" @@ -113,6 +114,7 @@ async def set_preset( raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}") await self._device.modules[SmartModule.Light].set_state(preset) + @allow_update_after async def save_preset( self, preset_name: str, diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index a80c20f3c..f75620686 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from ..effects import EFFECT_MAPPING, EFFECT_NAMES, SmartLightEffect -from ..smartmodule import Module, SmartModule +from ..smartmodule import Module, SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -84,6 +84,7 @@ def effect_list(self) -> list[str]: """ return self._effect_list + @allow_update_after async def set_effect( self, effect: str, @@ -126,6 +127,7 @@ async def set_effect( await self.set_custom_effect(effect_dict) + @allow_update_after async def set_custom_effect( self, effect_dict: dict, diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index ca0eca867..3a5897d12 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -6,7 +6,7 @@ from ...exceptions import KasaException from ...feature import Feature -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -23,6 +23,7 @@ class LightTransition(SmartModule): REQUIRED_COMPONENT = "on_off_gradually" QUERY_GETTER_NAME = "get_on_off_gradually_info" + MINIMUM_UPDATE_INTERVAL_SECS = 60 MAXIMUM_DURATION = 60 # Key in sysinfo that indicates state can be retrieved from there. @@ -136,6 +137,7 @@ def _post_update_hook(self) -> None: "max_duration": off_max, } + @allow_update_after async def set_enabled(self, enable: bool): """Enable gradual on/off.""" if not self._supports_on_and_off: @@ -168,6 +170,7 @@ def _turn_on_transition_max(self) -> int: # v3 added max_duration, we default to 60 when it's not available return self._on_state["max_duration"] + @allow_update_after async def set_turn_on_transition(self, seconds: int): """Set turn on transition in seconds. @@ -203,6 +206,7 @@ def _turn_off_transition_max(self) -> int: # v3 added max_duration, we default to 60 when it's not available return self._off_state["max_duration"] + @allow_update_after async def set_turn_off_transition(self, seconds: int): """Set turn on transition in seconds. diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index c6596b969..3dfbd1468 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import time from typing import Any from ..device_type import DeviceType @@ -46,6 +47,7 @@ async def update(self, update_children: bool = True): req.update(mod_query) if req: self._last_update = await self.protocol.query(req) + self._last_update_time = time.time() @classmethod async def create(cls, parent: SmartDevice, child_info, child_components): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index fcbc8a15f..731789a01 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -4,6 +4,7 @@ import base64 import logging +import time from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone from typing import Any, cast @@ -18,6 +19,7 @@ from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol from .modules import ( + ChildDevice, Cloud, DeviceModule, Firmware, @@ -35,6 +37,9 @@ # same issue, homekit perhaps? NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] +# Modules that are called as part of the init procedure on first update +FIRST_UPDATE_MODULES = {DeviceModule, ChildDevice, Cloud} + # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. @@ -60,6 +65,7 @@ def __init__( self._parent: SmartDevice | None = None self._children: Mapping[str, SmartDevice] = {} self._last_update = {} + self._last_update_time: float | None = None async def _initialize_children(self): """Initialize children for power strips.""" @@ -152,19 +158,15 @@ async def update(self, update_children: bool = False): if self.credentials is None and self.credentials_hash is None: raise AuthenticationError("Tapo plug requires authentication.") - if self._components_raw is None: + first_update = self._last_update_time is None + now = time.time() + self._last_update_time = now + + if first_update: await self._negotiate() await self._initialize_modules() - req: dict[str, Any] = {} - - # TODO: this could be optimized by constructing the query only once - for module in self._modules.values(): - req.update(module.query()) - - self._last_update = resp = await self.protocol.query(req) - - self._info = self._try_get_response(resp, "get_device_info") + resp = await self._modular_update(first_update, now) # Call child update which will only update module calls, info is updated # from get_child_device_list. update_children only affects hub devices, other @@ -172,18 +174,12 @@ async def update(self, update_children: bool = False): if update_children or self.device_type != DeviceType.Hub: for child in self._children.values(): await child.update() - if child_info := self._try_get_response(resp, "get_child_device_list", {}): + if child_info := self._try_get_response( + self._last_update, "get_child_device_list", {} + ): for info in child_info["child_device_list"]: self._children[info["device_id"]]._update_internal_state(info) - # Call handle update for modules that want to update internal data - errors = [] - for module_name, module in self._modules.items(): - if not self._handle_module_post_update_hook(module): - errors.append(module_name) - for error in errors: - self._modules.pop(error) - for child in self._children.values(): errors = [] for child_module_name, child_module in child._modules.items(): @@ -197,14 +193,18 @@ async def update(self, update_children: bool = False): if not self._features: await self._initialize_features() - _LOGGER.debug("Got an update: %s", self._last_update) + _LOGGER.debug( + "Update completed %s: %s", + self.host, + self._last_update if first_update else resp, + ) def _handle_module_post_update_hook(self, module: SmartModule) -> bool: try: module._post_update_hook() return True except Exception as ex: - _LOGGER.error( + _LOGGER.warning( "Error processing %s for device %s, module will be unavailable: %s", module.name, self.host, @@ -212,6 +212,100 @@ def _handle_module_post_update_hook(self, module: SmartModule) -> bool: ) return False + async def _modular_update( + self, first_update: bool, update_time: float + ) -> dict[str, Any]: + """Update the device with via the module queries.""" + req: dict[str, Any] = {} + # Keep a track of actual module queries so we can track the time for + # modules that do not need to be updated frequently + module_queries: list[SmartModule] = [] + mq = { + module: query + for module in self._modules.values() + if (query := module.query()) + } + for module, query in mq.items(): + if first_update and module.__class__ in FIRST_UPDATE_MODULES: + module._last_update_time = update_time + continue + if ( + not module.MINIMUM_UPDATE_INTERVAL_SECS + or not module._last_update_time + or (update_time - module._last_update_time) + >= module.MINIMUM_UPDATE_INTERVAL_SECS + ): + module_queries.append(module) + req.update(query) + + _LOGGER.debug( + "Querying %s for modules: %s", + self.host, + ", ".join(mod.name for mod in module_queries), + ) + + try: + resp = await self.protocol.query(req) + except Exception as ex: + resp = await self._handle_modular_update_error( + ex, first_update, ", ".join(mod.name for mod in module_queries), req + ) + + info_resp = self._last_update if first_update else resp + self._last_update.update(**resp) + self._info = self._try_get_response(info_resp, "get_device_info") + + # Call handle update for modules that want to update internal data + errors = [] + for module_name, module in self._modules.items(): + if not self._handle_module_post_update_hook(module): + errors.append(module_name) + for error in errors: + self._modules.pop(error) + + # Set the last update time for modules that had queries made. + for module in module_queries: + module._last_update_time = update_time + + return resp + + async def _handle_modular_update_error( + self, + ex: Exception, + first_update: bool, + module_names: str, + requests: dict[str, Any], + ) -> dict[str, Any]: + """Handle an error on calling module update. + + Will try to call all modules individually + and any errors such as timeouts will be set as a SmartErrorCode. + """ + msg_part = "on first update" if first_update else "after first update" + + _LOGGER.error( + "Error querying %s for modules '%s' %s: %s", + self.host, + module_names, + msg_part, + ex, + ) + responses = {} + for meth, params in requests.items(): + try: + resp = await self.protocol.query({meth: params}) + responses[meth] = resp[meth] + except Exception as iex: + _LOGGER.error( + "Error querying %s individually for module query '%s' %s: %s", + self.host, + meth, + msg_part, + iex, + ) + responses[meth] = SmartErrorCode.INTERNAL_QUERY_ERROR + return responses + async def _initialize_modules(self): """Initialize modules based on component negotiation response.""" from .smartmodule import SmartModule @@ -229,8 +323,6 @@ async def _initialize_modules(self): skip_parent_only_modules = True for mod in SmartModule.REGISTERED_MODULES.values(): - _LOGGER.debug("%s requires %s", mod, mod.REQUIRED_COMPONENT) - if ( skip_parent_only_modules and mod in NON_HUB_PARENT_ONLY_MODULES ) or mod.__name__ in child_modules_to_skip: @@ -240,7 +332,8 @@ async def _initialize_modules(self): or self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None ): _LOGGER.debug( - "Found required %s, adding %s to modules.", + "Device %s, found required %s, adding %s to modules.", + self.host, mod.REQUIRED_COMPONENT, mod.__name__, ) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index fb946a8b3..f5f2c212a 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -3,7 +3,10 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from collections.abc import Awaitable, Callable, Coroutine +from typing import TYPE_CHECKING, Any + +from typing_extensions import Concatenate, ParamSpec, TypeVar from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..module import Module @@ -13,6 +16,27 @@ _LOGGER = logging.getLogger(__name__) +_T = TypeVar("_T", bound="SmartModule") +_P = ParamSpec("_P") + + +def allow_update_after( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Define a wrapper to set _last_update_time to None. + + This will ensure that a module is updated in the next update cycle after + a value has been changed. + """ + + async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + finally: + self._last_update_time = None + + return _async_wrap + class SmartModule(Module): """Base class for SMART modules.""" @@ -27,9 +51,12 @@ class SmartModule(Module): REGISTERED_MODULES: dict[str, type[SmartModule]] = {} + MINIMUM_UPDATE_INTERVAL_SECS = 0 + def __init__(self, device: SmartDevice, module: str): self._device: SmartDevice super().__init__(device, module) + self._last_update_time: float | None = None def __init_subclass__(cls, **kwargs): name = getattr(cls, "NAME", cls.__name__) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 3085714c4..0c95325a5 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -73,18 +73,32 @@ async def _query(self, request: str | dict, retry_count: int = 3) -> dict: return await self._execute_query( request, retry_count=retry, iterate_list_pages=True ) - except _ConnectionError as sdex: + except _ConnectionError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a connection error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise sdex + raise ex continue - except AuthenticationError as auex: + except AuthenticationError as ex: await self._transport.reset() _LOGGER.debug( - "Unable to authenticate with %s, not retrying", self._host + "Unable to authenticate with %s, not retrying: %s", self._host, ex ) - raise auex + raise ex except _RetryableError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a retryable error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) @@ -92,6 +106,13 @@ async def _query(self, request: str | dict, retry_count: int = 3) -> dict: await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_TIMEOUT) continue except TimeoutError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a timeout error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) @@ -130,20 +151,21 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic self._handle_response_error_code(resp, method, raise_on_error=False) multi_result[method] = resp["result"] return multi_result - for i in range(0, end, step): + + for batch_num, i in enumerate(range(0, end, step)): requests_step = multi_requests[i : i + step] smart_params = {"requests": requests_step} smart_request = self.get_smart_request(smart_method, smart_params) + batch_name = f"multi-request-batch-{batch_num+1}-of-{int(end/step)+1}" if debug_enabled: _LOGGER.debug( - "%s multi-request-batch-%s >> %s", + "%s %s >> %s", self._host, - i + 1, + batch_name, pf(smart_request), ) response_step = await self._transport.send(smart_request) - batch_name = f"multi-request-batch-{i+1}" if debug_enabled: _LOGGER.debug( "%s %s << %s", @@ -271,7 +293,9 @@ def _handle_response_error_code(self, resp_dict: dict, method, raise_on_error=Tr try: error_code = SmartErrorCode.from_int(error_code_raw) except ValueError: - _LOGGER.warning("Received unknown error code: %s", error_code_raw) + _LOGGER.warning( + "Device %s received unknown error code: %s", self._host, error_code_raw + ) error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR if error_code is SmartErrorCode.SUCCESS: diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 44fabc715..99e2ddb9e 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -3,10 +3,12 @@ from __future__ import annotations import logging +import time from typing import Any, cast from unittest.mock import patch import pytest +from freezegun.api import FrozenDateTimeFactory from pytest_mock import MockerFixture from kasa import Device, KasaException, Module @@ -54,6 +56,8 @@ async def test_initial_update(dev: SmartDevice, mocker: MockerFixture): dev._modules = {} dev._features = {} dev._children = {} + dev._last_update = {} + dev._last_update_time = None negotiate = mocker.spy(dev, "_negotiate") initialize_modules = mocker.spy(dev, "_initialize_modules") @@ -109,6 +113,9 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): """Test that the regular update uses queries from all supported modules.""" # We need to have some modules initialized by now assert dev._modules + # Reset last update so all modules will query + for mod in dev._modules.values(): + mod._last_update_time = None device_queries: dict[SmartDevice, dict[str, Any]] = {} for mod in dev._modules.values(): @@ -139,7 +146,7 @@ async def test_update_module_errors(dev: SmartDevice, mocker: MockerFixture): assert dev._modules critical_modules = {Module.DeviceModule, Module.ChildDevice} - not_disabling_modules = {Module.Firmware, Module.Cloud} + not_disabling_modules = {Module.Cloud} new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) @@ -204,6 +211,123 @@ async def _child_query(self, request, *args, **kwargs): ), f"{modname} present {mod_present} when no_disable {no_disable}" +@device_smart +async def test_update_module_update_delays( + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +): + """Test that modules that disabled / removed on query failures.""" + # We need to have some modules initialized by now + assert dev._modules + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + await new_dev.update() + first_update_time = time.time() + assert new_dev._last_update_time == first_update_time + for module in new_dev.modules.values(): + if module.query(): + assert module._last_update_time == first_update_time + + seconds = 0 + tick = 30 + while seconds <= 180: + seconds += tick + freezer.tick(tick) + + now = time.time() + await new_dev.update() + for module in new_dev.modules.values(): + mod_delay = module.MINIMUM_UPDATE_INTERVAL_SECS + if module.query(): + expected_update_time = ( + now if mod_delay == 0 else now - (seconds % mod_delay) + ) + + assert ( + module._last_update_time == expected_update_time + ), f"Expected update time {expected_update_time} after {seconds} seconds for {module.name} with delay {mod_delay} got {module._last_update_time}" + + +@pytest.mark.parametrize( + ("first_update"), + [ + pytest.param(True, id="First update true"), + pytest.param(False, id="First update false"), + ], +) +@device_smart +async def test_update_module_query_errors( + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, + first_update, +): + """Test that modules that disabled / removed on query failures.""" + # We need to have some modules initialized by now + assert dev._modules + + first_update_queries = {"get_device_info", "get_connect_cloud_state"} + + critical_modules = {Module.DeviceModule, Module.ChildDevice} + not_disabling_modules = {Module.Cloud} + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + if not first_update: + await new_dev.update() + freezer.tick( + max(module.MINIMUM_UPDATE_INTERVAL_SECS for module in dev._modules.values()) + ) + + module_queries = { + modname: q + for modname, module in dev._modules.items() + if (q := module.query()) and modname not in critical_modules + } + + async def _query(request, *args, **kwargs): + if ( + "component_nego" in request + or "get_child_device_component_list" in request + or "control_child" in request + ): + return await dev.protocol._query(request, *args, **kwargs) + if len(request) == 1 and "get_device_info" in request: + return await dev.protocol._query(request, *args, **kwargs) + + raise TimeoutError("Dummy timeout") + + from kasa.smartprotocol import _ChildProtocolWrapper + + child_protocols = { + cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol + for child in dev.children + } + + async def _child_query(self, request, *args, **kwargs): + return await child_protocols[self._device_id]._query(request, *args, **kwargs) + + mocker.patch.object(new_dev.protocol, "query", side_effect=_query) + # children not created yet so cannot patch.object + mocker.patch("kasa.smartprotocol._ChildProtocolWrapper.query", new=_child_query) + + await new_dev.update() + msg = f"Error querying {new_dev.host} for modules" + assert msg in caplog.text + for modname in module_queries: + no_disable = modname in not_disabling_modules + mod_present = modname in new_dev._modules + assert ( + mod_present is no_disable + ), f"{modname} present {mod_present} when no_disable {no_disable}" + for mod_query in module_queries[modname]: + if not first_update or mod_query not in first_update_queries: + msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" + assert msg in caplog.text + + async def test_get_modules(): """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 71125ca83..204d0c7f2 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -66,7 +66,7 @@ async def test_smart_device_unknown_errors( assert res is SmartErrorCode.INTERNAL_UNKNOWN_ERROR send_mock.assert_called_once() - assert f"Received unknown error code: {error_code}" in caplog.text + assert f"received unknown error code: {error_code}" in caplog.text @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) From 377fa06d392d08ace2ad3dbf7411c8088f3d4510 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:05:40 +0100 Subject: [PATCH 526/892] Use first known thermostat state as main state (pick #1054) (#1057) Pick commit a0440635260abe2d8b6719ff2260510d2fed9b3f from #1054 Instead of trying to use the first state when multiple are reported, iterate over the known states and pick the first matching. This will fix an issue where the device reports extra states (like `low_battery`) while having a known mode active. Related to home-assistant/core#121335 --- kasa/smart/modules/temperaturecontrol.py | 43 ++++++++++--------- .../smart/modules/test_temperaturecontrol.py | 10 ++++- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index dcd0da725..00afe5b53 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -4,15 +4,10 @@ import logging from enum import Enum -from typing import TYPE_CHECKING from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - - _LOGGER = logging.getLogger(__name__) @@ -31,11 +26,11 @@ class TemperatureControl(SmartModule): REQUIRED_COMPONENT = "temp_control" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features after the initial update.""" self._add_feature( Feature( - device, + self._device, id="target_temperature", name="Target temperature", container=self, @@ -50,7 +45,7 @@ def __init__(self, device: SmartDevice, module: str): # TODO: this might belong into its own module, temperature_correction? self._add_feature( Feature( - device, + self._device, id="temperature_offset", name="Temperature offset", container=self, @@ -65,7 +60,7 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( - device, + self._device, id="state", name="State", container=self, @@ -78,7 +73,7 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( - device, + self._device, id="thermostat_mode", name="Thermostat mode", container=self, @@ -109,23 +104,24 @@ def mode(self) -> ThermostatState: if self._device.sys_info.get("frost_protection_on", False): return ThermostatState.Off - states = self._device.sys_info["trv_states"] + states = self.states # If the states is empty, the device is idling if not states: return ThermostatState.Idle + # Discard known extra states, and report on unknown extra states + states.discard("low_battery") if len(states) > 1: - _LOGGER.warning( - "Got multiple states (%s), using the first one: %s", states, states[0] - ) + _LOGGER.warning("Got multiple states: %s", states) - state = states[0] - try: - return ThermostatState(state) - except: # noqa: E722 - _LOGGER.warning("Got unknown state: %s", state) - return ThermostatState.Unknown + # Return the first known state + for state in ThermostatState: + if state.value in states: + return state + + _LOGGER.warning("Got unknown state: %s", states) + return ThermostatState.Unknown @property def allowed_temperature_range(self) -> tuple[int, int]: @@ -147,6 +143,11 @@ def target_temperature(self) -> float: """Return target temperature.""" return self._device.sys_info["target_temp"] + @property + def states(self) -> set: + """Return thermostat states.""" + return set(self._device.sys_info["trv_states"]) + async def set_target_temperature(self, target: float): """Set target temperature.""" if ( diff --git a/kasa/tests/smart/modules/test_temperaturecontrol.py b/kasa/tests/smart/modules/test_temperaturecontrol.py index 16e01ed2b..90f91216f 100644 --- a/kasa/tests/smart/modules/test_temperaturecontrol.py +++ b/kasa/tests/smart/modules/test_temperaturecontrol.py @@ -94,7 +94,7 @@ async def test_temperature_offset(dev): ), pytest.param( ThermostatState.Heating, - [ThermostatState.Heating], + ["heating"], False, id="heating is heating", ), @@ -135,3 +135,11 @@ async def test_thermostat_mode_warnings(dev, mode, states, msg, caplog): temp_module.data["trv_states"] = states assert temp_module.mode is mode assert msg in caplog.text + + +@thermostats_smart +async def test_thermostat_heating_with_low_battery(dev): + """Test that mode is reported correctly with extra states.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + temp_module.data["trv_states"] = ["low_battery", "heating"] + assert temp_module.mode is ThermostatState.Heating From 448efd7e4ce362f3602745cd24d34ab20f6c02b3 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:30:14 +0100 Subject: [PATCH 527/892] Prepare 0.7.0.4 (#1059) ## [0.7.0.4](https://github.com/python-kasa/python-kasa/tree/0.7.0.4) (2024-07-011) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.3...0.7.0.4) Critical bugfixes for issues with P100s and thermostats. **Fixed bugs:** - Use first known thermostat state as main state (pick #1054) [\#1057](https://github.com/python-kasa/python-kasa/pull/1057) - Defer module updates for less volatile modules (pick 1052) [\#1056](https://github.com/python-kasa/python-kasa/pull/1056) --- CHANGELOG.md | 13 ++++++++++++- pyproject.toml | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b2466df9..77e5c3951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [0.7.0.4](https://github.com/python-kasa/python-kasa/tree/0.7.0.4) (2024-07-011) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.3...0.7.0.4) + +Critical bugfixes for issues with P100s and thermostats. + +**Fixed bugs:** + +- Use first known thermostat state as main state (pick #1054) [\#1057](https://github.com/python-kasa/python-kasa/pull/1057) +- Defer module updates for less volatile modules (pick 1052) [\#1056](https://github.com/python-kasa/python-kasa/pull/1056) + ## [0.7.0.3](https://github.com/python-kasa/python-kasa/tree/0.7.0.3) (2024-07-04) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.2...0.7.0.3) @@ -9,7 +20,7 @@ Partially fixes light preset module errors with L920 and L930. **Fixed bugs:** -Handle module errors more robustly and add query params to light preset and transition [\#1043](https://github.com/python-kasa/python-kasa/pull/1043) +- Handle module errors more robustly and add query params to light preset and transition [\#1043](https://github.com/python-kasa/python-kasa/pull/1043) ## [0.7.0.2](https://github.com/python-kasa/python-kasa/tree/0.7.0.2) (2024-07-01) diff --git a/pyproject.toml b/pyproject.toml index fb8df9130..8b9f73eb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.3" +version = "0.7.0.4" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 84192a0d77ad25d26ff130b264ff1fa439c85108 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 12 Jul 2024 17:45:37 +0100 Subject: [PATCH 528/892] Bump version to 0.7.0.4 (#1060) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ad43f3bd4..b9e8c5784 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.3" +version = "0.7.0.4" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From a2b7daa0692ba43bfec99c308c6f4efc645d76f8 Mon Sep 17 00:00:00 2001 From: daleye Date: Sun, 14 Jul 2024 10:31:31 -0400 Subject: [PATCH 529/892] Add fixture file for KP405 fw 1.0.6 (#1063) --- SUPPORTED.md | 1 + kasa/tests/fixtures/KP405(US)_1.0_1.0.6.json | 65 ++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 kasa/tests/fixtures/KP405(US)_1.0_1.0.6.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 1781442a0..a0d301b32 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -83,6 +83,7 @@ Some newer Kasa devices require authentication. These are marked with *\* - **KP405** - Hardware: 1.0 (US) / Firmware: 1.0.5 + - Hardware: 1.0 (US) / Firmware: 1.0.6 - **KS200M** - Hardware: 1.0 (US) / Firmware: 1.0.11 - Hardware: 1.0 (US) / Firmware: 1.0.8 diff --git a/kasa/tests/fixtures/KP405(US)_1.0_1.0.6.json b/kasa/tests/fixtures/KP405(US)_1.0_1.0.6.json new file mode 100644 index 000000000..d2431bfd5 --- /dev/null +++ b/kasa/tests/fixtures/KP405(US)_1.0_1.0.6.json @@ -0,0 +1,65 @@ +{ + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "bulb_type": 1, + "calibration_type": 0, + "err_code": 0, + "fadeOffTime": 1000, + "fadeOnTime": 1000, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 1, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "brightness": 100, + "dev_name": "Kasa Smart Wi-Fi Outdoor Plug-In Dimmer", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "50:91:E3:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP405(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "preferred_state": [ + { + "brightness": 100, + "index": 0 + }, + { + "brightness": 75, + "index": 1 + }, + { + "brightness": 50, + "index": 2 + }, + { + "brightness": 25, + "index": 3 + } + ], + "relay_state": 0, + "rssi": -66, + "status": "new", + "sw_ver": "1.0.6 Build 240229 Rel.174151", + "updating": 0 + } + } +} From 7e9b1687d0fcc724779d2432d59f09a81537b923 Mon Sep 17 00:00:00 2001 From: Carter Strickland <50763236+clstrickland@users.noreply.github.com> Date: Mon, 15 Jul 2024 07:18:43 -0500 Subject: [PATCH 530/892] Decrypt KLAP data from PCAP files (#1041) Allows for decryption of pcap files capturing klap communication with devices. --- devtools/README.md | 27 ++++ devtools/parse_pcap_klap.py | 307 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 3 files changed, 335 insertions(+) create mode 100755 devtools/parse_pcap_klap.py diff --git a/devtools/README.md b/devtools/README.md index 99d5ec5a0..f59ea374c 100644 --- a/devtools/README.md +++ b/devtools/README.md @@ -99,3 +99,30 @@ id New parser, parsing 100000 messages took 0.6339647499989951 seconds Old parser, parsing 100000 messages took 9.473990250000497 seconds ``` + + +## parse_pcap_klap + +* A tool to allow KLAP data to be exported, in JSON, from a PCAP file of encrypted requests. + +* NOTE: must install pyshark (`pip install pyshark`). +* pyshark requires Wireshark or tshark to be installed on windows and tshark to be installed +on linux (`apt get tshark`) + +```shell +Usage: parse_pcap_klap.py [OPTIONS] + + Export KLAP data in JSON format from a PCAP file. + +Options: + --host TEXT the IP of the smart device as it appears in the pcap + file. [required] + --username TEXT Username/email address to authenticate to device. + [required] + --password TEXT Password to use to authenticate to device. + [required] + --pcap-file-path TEXT The path to the pcap file to parse. [required] + -o, --output TEXT The name of the output file, relative to the current + directory. + --help Show this message and exit. +``` diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py new file mode 100755 index 000000000..d8be6573c --- /dev/null +++ b/devtools/parse_pcap_klap.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python +""" +This code allow for the decryption of KlapV2 data from a pcap file. + +It will output the decrypted data to a file. +This was designed and tested with a Tapo light strip setup using a cloud account. +""" + +import codecs +import json +import re +from threading import Thread + +import asyncclick as click +import pyshark +from cryptography.hazmat.primitives import padding + +from kasa.credentials import Credentials +from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) +from kasa.klaptransport import KlapEncryptionSession, KlapTransportV2 + + +class MyEncryptionSession(KlapEncryptionSession): + """A custom KlapEncryptionSession class that allows for decryption.""" + + def decrypt(self, msg): + """Decrypt the data.""" + decryptor = self._cipher.decryptor() + dp = decryptor.update(msg[32:]) + decryptor.finalize() + unpadder = padding.PKCS7(128).unpadder() + plaintextbytes = unpadder.update(dp) + unpadder.finalize() + + return plaintextbytes.decode("utf-8", "bad_chars_replacement") + + +class Operator: + """A class that handles the data decryption, and the encryption session updating.""" + + def __init__(self, klap, creds): + self._local_seed: bytes = None + self._remote_seed: bytes = None + self._session: MyEncryptionSession = None + self._creds = creds + self._klap: KlapTransportV2 = klap + self._auth_hash = self._klap.generate_auth_hash(self._creds) + self._local_auth_hash = None + self._remote_auth_hash = None + self._seq = 0 + + def update_encryption_session(self): + """Update the encryption session used for decrypting data. + + It is called whenever the local_seed, remote_seed, + or remote_auth_hash is updated. + + It checks if the seeds are set and, if they are, creates a new session. + + Raises: + ValueError: If the auth hashes do not match. + """ + if self._local_seed is None or self._remote_seed is None: + self._session = None + else: + self._local_auth_hash = self._klap.handshake1_seed_auth_hash( + self._local_seed, self._remote_seed, self._auth_hash + ) + if (self._remote_auth_hash is not None) and ( + self._local_auth_hash != self._remote_auth_hash + ): + raise ValueError( + "Local and remote auth hashes do not match.\ +This could mean an incorrect username and/or password." + ) + self._session = MyEncryptionSession( + self._local_seed, self._remote_seed, self._auth_hash + ) + self._session._seq = self._seq + self._session._generate_cipher() + + @property + def seq(self) -> int: + """Get the sequence number.""" + return self._seq + + @seq.setter + def seq(self, value: int): + if not isinstance(value, int): + raise ValueError("seq must be an integer") + self._seq = value + self.update_encryption_session() + + @property + def local_seed(self) -> bytes: + """Get the local seed.""" + return self._local_seed + + @local_seed.setter + def local_seed(self, value: bytes): + if not isinstance(value, bytes): + raise ValueError("local_seed must be bytes") + elif len(value) != 16: + raise ValueError("local_seed must be 16 bytes") + else: + self._local_seed = value + self.update_encryption_session() + + @property + def remote_auth_hash(self) -> bytes: + """Get the remote auth hash.""" + return self._remote_auth_hash + + @remote_auth_hash.setter + def remote_auth_hash(self, value: bytes): + print("setting remote_auth_hash") + if not isinstance(value, bytes): + raise ValueError("remote_auth_hash must be bytes") + elif len(value) != 32: + raise ValueError("remote_auth_hash must be 32 bytes") + else: + self._remote_auth_hash = value + self.update_encryption_session() + + @property + def remote_seed(self) -> bytes: + """Get the remote seed.""" + return self._remote_seed + + @remote_seed.setter + def remote_seed(self, value: bytes): + print("setting remote_seed") + if not isinstance(value, bytes): + raise ValueError("remote_seed must be bytes") + elif len(value) != 16: + raise ValueError("remote_seed must be 16 bytes") + else: + self._remote_seed = value + self.update_encryption_session() + + # This function decrypts the data using the encryption session. + def decrypt(self, *args, **kwargs): + """Decrypt the data using the encryption session.""" + if self._session is None: + raise ValueError("No session available") + return self._session.decrypt(*args, **kwargs) + + +# This is a custom error handler that replaces bad characters with '*', +# in case something goes wrong in decryption. +# Without this, the decryption could yield an error. +def bad_chars_replacement(exception): + """Replace bad characters with '*'.""" + return ("*", exception.start + 1) + + +codecs.register_error("bad_chars_replacement", bad_chars_replacement) + + +def main(username, password, device_ip, pcap_file_path, output_json_name=None): + """Run the main function.""" + capture = pyshark.FileCapture(pcap_file_path, display_filter="http") + + # In an effort to keep this code tied into the original code + # (so that this can hopefully leverage any future codebase updates inheriently), + # some weird initialization is done here + creds = Credentials(username, password) + + fake_connection = DeviceConnectionParameters( + DeviceFamily.SmartTapoBulb, DeviceEncryptionType.Klap + ) + fake_device = DeviceConfig( + device_ip, connection_type=fake_connection, credentials=creds + ) + + operator = Operator(KlapTransportV2(config=fake_device), creds) + + packets = [] + + # pyshark is a little weird in how it handles iteration, + # so this is a workaround to allow for (advanced) iteration over the packets. + while True: + try: + packet = capture.next() + # packet_number = capture._current_packet + # we only care about http packets + if hasattr( + packet, "http" + ): # this is redundant, as pyshark is set to only load http packets + if hasattr(packet.http, "request_uri_path"): + uri = packet.http.get("request_uri_path") + elif hasattr(packet.http, "request_uri"): + uri = packet.http.get("request_uri") + else: + uri = None + if hasattr(packet.http, "request_uri_query"): + query = packet.http.get("request_uri_query") + # use regex to get: seq=(\d+) + seq = re.search(r"seq=(\d+)", query) + if seq is not None: + operator.seq = int( + seq.group(1) + ) # grab the sequence number from the query + data = ( + # Windows and linux file_data attribute returns different + # pretty format so get the raw field value. + packet.http.get_field_value("file_data", raw=True) + if hasattr(packet.http, "file_data") + else None + ) + match uri: + case "/app/request": + if packet.ip.dst != device_ip: + continue + message = bytes.fromhex(data) + try: + plaintext = operator.decrypt(message) + payload = json.loads(plaintext) + print(json.dumps(payload, indent=2)) + packets.append(payload) + except ValueError: + print("Insufficient data to decrypt thus far") + + case "/app/handshake1": + if packet.ip.dst != device_ip: + continue + message = bytes.fromhex(data) + operator.local_seed = message + response = None + while ( + True + ): # we are going to now look for the response to this request + response = capture.next() + if ( + hasattr(response, "http") + and hasattr(response.http, "response_for_uri") + and ( + response.http.response_for_uri + == packet.http.request_full_uri + ) + ): + break + data = response.http.get_field_value("file_data", raw=True) + message = bytes.fromhex(data) + operator.remote_seed = message[0:16] + operator.remote_auth_hash = message[16:] + + case "/app/handshake2": + continue # we don't care about this + case _: + continue + except StopIteration: + break + + # save the final array to a file + if output_json_name is not None: + with open(output_json_name, "w") as f: + f.write(json.dumps(packets, indent=2)) + f.write("\n" * 1) + f.close() + + +@click.command() +@click.option( + "--host", + required=True, + help="the IP of the smart device as it appears in the pcap file.", +) +@click.option( + "--username", + required=True, + envvar="KASA_USERNAME", + help="Username/email address to authenticate to device.", +) +@click.option( + "--password", + required=True, + envvar="KASA_PASSWORD", + help="Password to use to authenticate to device.", +) +@click.option( + "--pcap-file-path", + required=True, + help="The path to the pcap file to parse.", +) +@click.option( + "-o", + "--output", + required=False, + help="The name of the output file, relative to the current directory.", +) +def cli(username, password, host, pcap_file_path, output): + """Export KLAP data in JSON format from a PCAP file.""" + # pyshark does not work within a running event loop and we don't want to + # install click as well as asyncclick so run in a new thread. + thread = Thread( + target=main, args=[username, password, host, pcap_file_path, output] + ) + thread.start() + thread.join() + + +if __name__ == "__main__": + cli() diff --git a/pyproject.toml b/pyproject.toml index b9e8c5784..91317f489 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,6 +151,7 @@ disable_error_code = "annotation-unchecked" module = [ "devtools.bench.benchmark", "devtools.parse_pcap", + "devtools.parse_pcap_klap", "devtools.perftest", "devtools.create_module_fixtures" ] From b220beb8118660387b539db0c432d0fc6f514197 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 16 Jul 2024 13:25:32 +0100 Subject: [PATCH 531/892] Use monotonic time for query timing (#1070) To fix intermittent issues with [windows CI](https://github.com/python-kasa/python-kasa/actions/runs/9952477932/job/27493918272?pr=1068). Probably better to use monotonic here anyway. ``` FAILED kasa/tests/test_smartdevice.py::test_update_module_update_delays[L530E(EU)_3.0_1.1.6.json-SMART] - ValueError: Clock moved backwards. Refusing to generate ID. ``` --- kasa/httpclient.py | 6 +++--- kasa/klaptransport.py | 6 ++++-- kasa/smart/smartdevice.py | 2 +- kasa/smartprotocol.py | 2 +- kasa/tests/test_smartdevice.py | 4 ++-- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 1c8c46e27..ec80ad616 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -72,7 +72,7 @@ async def post( # Once we know a device needs a wait between sequential queries always wait # first rather than keep erroring then waiting. if self._wait_between_requests: - now = time.time() + now = time.monotonic() gap = now - self._last_request_time if gap < self._wait_between_requests: sleep = self._wait_between_requests - gap @@ -123,7 +123,7 @@ async def post( ex, ) self._wait_between_requests = self.WAIT_BETWEEN_REQUESTS_ON_OSERROR - self._last_request_time = time.time() + self._last_request_time = time.monotonic() raise _ConnectionError( f"Device connection error: {self._config.host}: {ex}", ex ) from ex @@ -140,7 +140,7 @@ async def post( # For performance only request system time if waiting is enabled if self._wait_between_requests: - self._last_request_time = time.time() + self._last_request_time = time.monotonic() return resp.status, response_data diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index dd90ffd28..c138ba38e 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -300,7 +300,9 @@ async def perform_handshake(self) -> Any: # 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._session_expire_at = ( + time.monotonic() + timeout - SESSION_EXPIRE_BUFFER_SECONDS + ) self._encryption_session = await self.perform_handshake2( local_seed, remote_seed, auth_hash ) @@ -312,7 +314,7 @@ def _handshake_session_expired(self): """Return true if session has expired.""" return ( self._session_expire_at is None - or self._session_expire_at - time.time() <= 0 + or self._session_expire_at - time.monotonic() <= 0 ) async def send(self, request: str): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 8cdd2013a..21e46cc34 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -159,7 +159,7 @@ async def update(self, update_children: bool = False): raise AuthenticationError("Tapo plug requires authentication.") first_update = self._last_update_time is None - now = time.time() + now = time.monotonic() self._last_update_time = now if first_update: diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 0c95325a5..c0dfb31e4 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -392,7 +392,7 @@ def generate_id(self): ) def _current_millis(self): - return round(time.time() * 1000) + return round(time.monotonic() * 1000) def _wait_next_millis(self, last_timestamp): timestamp = self._current_millis() diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 99e2ddb9e..4e6706444 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -224,7 +224,7 @@ async def test_update_module_update_delays( new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) await new_dev.update() - first_update_time = time.time() + first_update_time = time.monotonic() assert new_dev._last_update_time == first_update_time for module in new_dev.modules.values(): if module.query(): @@ -236,7 +236,7 @@ async def test_update_module_update_delays( seconds += tick freezer.tick(tick) - now = time.time() + now = time.monotonic() await new_dev.update() for module in new_dev.modules.values(): mod_delay = module.MINIMUM_UPDATE_INTERVAL_SECS From e17ca21a8391a037e5d312407a07d1d5d66c329d Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 17 Jul 2024 08:28:11 +0100 Subject: [PATCH 532/892] Only refresh smart LightEffect module daily (#1064) Fixes an issue with L530 bulbs on HW version 1.0 whereby the light effect query causes the device to crash with JSON_ENCODE_FAIL_ERROR after approximately 60 calls. --- kasa/smart/modules/lighteffect.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py index 5f589d6dd..699c679b3 100644 --- a/kasa/smart/modules/lighteffect.py +++ b/kasa/smart/modules/lighteffect.py @@ -17,7 +17,7 @@ class LightEffect(SmartModule, SmartLightEffect): REQUIRED_COMPONENT = "light_effect" QUERY_GETTER_NAME = "get_dynamic_light_effect_rules" - MINIMUM_UPDATE_INTERVAL_SECS = 60 + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 AVAILABLE_BULB_EFFECTS = { "L1": "Party", "L2": "Relax", @@ -74,6 +74,7 @@ def effect(self) -> str: """Return effect name.""" return self._effect + @allow_update_after async def set_effect( self, effect: str, From c19389f23640f891f6941ea057b81b24fb819217 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 17 Jul 2024 08:34:12 +0100 Subject: [PATCH 533/892] Fix parse_pcap_klap on windows and support default credentials (#1068) - Fixes issue running pyshark on new thread in windows - Fixes bug if handshake repeated during capture - Tries the default tplink hardcoded credentials as per the library --- devtools/parse_pcap_klap.py | 95 +++++++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 26 deletions(-) diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index d8be6573c..36384631b 100755 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -6,6 +6,9 @@ This was designed and tested with a Tapo light strip setup using a cloud account. """ +from __future__ import annotations + +import asyncio import codecs import json import re @@ -23,6 +26,7 @@ DeviceFamily, ) from kasa.klaptransport import KlapEncryptionSession, KlapTransportV2 +from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials class MyEncryptionSession(KlapEncryptionSession): @@ -42,16 +46,34 @@ class Operator: """A class that handles the data decryption, and the encryption session updating.""" def __init__(self, klap, creds): - self._local_seed: bytes = None - self._remote_seed: bytes = None - self._session: MyEncryptionSession = None + self._local_seed: bytes | None = None + self._remote_seed: bytes | None = None + self._session: MyEncryptionSession | None = None self._creds = creds self._klap: KlapTransportV2 = klap self._auth_hash = self._klap.generate_auth_hash(self._creds) - self._local_auth_hash = None - self._remote_auth_hash = None + self._local_seed_auth_hash = None + self._remote_seed_auth_hash = None self._seq = 0 + def check_default_credentials(self): + """Check whether default credentials were used. + + Devices sometimes randomly accept the hardcoded default credentials + and the library handles that. + """ + for value in DEFAULT_CREDENTIALS.values(): + default_credentials = get_default_credentials(value) + default_auth_hash = self._klap.generate_auth_hash(default_credentials) + default_credentials_seed_auth_hash = self._klap.handshake1_seed_auth_hash( + self._local_seed, + self._remote_seed, + default_auth_hash, # type: ignore + ) + if self._remote_seed_auth_hash == default_credentials_seed_auth_hash: + return default_auth_hash + return None + def update_encryption_session(self): """Update the encryption session used for decrypting data. @@ -66,21 +88,25 @@ def update_encryption_session(self): if self._local_seed is None or self._remote_seed is None: self._session = None else: - self._local_auth_hash = self._klap.handshake1_seed_auth_hash( + self._local_seed_auth_hash = self._klap.handshake1_seed_auth_hash( self._local_seed, self._remote_seed, self._auth_hash ) - if (self._remote_auth_hash is not None) and ( - self._local_auth_hash != self._remote_auth_hash - ): - raise ValueError( - "Local and remote auth hashes do not match.\ -This could mean an incorrect username and/or password." + auth_hash = None + if self._remote_seed_auth_hash is not None: + if self._local_seed_auth_hash == self._remote_seed_auth_hash: + auth_hash = self._auth_hash + else: + auth_hash = self.check_default_credentials() + if not auth_hash: + raise ValueError( + "Local and remote auth hashes do not match. " + "This could mean an incorrect username and/or password." + ) + self._session = MyEncryptionSession( + self._local_seed, self._remote_seed, auth_hash ) - self._session = MyEncryptionSession( - self._local_seed, self._remote_seed, self._auth_hash - ) - self._session._seq = self._seq - self._session._generate_cipher() + self._session._seq = self._seq + self._session._generate_cipher() @property def seq(self) -> int: @@ -95,24 +121,27 @@ def seq(self, value: int): self.update_encryption_session() @property - def local_seed(self) -> bytes: + def local_seed(self) -> bytes | None: """Get the local seed.""" return self._local_seed @local_seed.setter def local_seed(self, value: bytes): + print("setting local_seed") if not isinstance(value, bytes): raise ValueError("local_seed must be bytes") elif len(value) != 16: raise ValueError("local_seed must be 16 bytes") else: self._local_seed = value + self._remote_seed_auth_hash = None + self._remote_seed = None self.update_encryption_session() @property - def remote_auth_hash(self) -> bytes: + def remote_auth_hash(self) -> bytes | None: """Get the remote auth hash.""" - return self._remote_auth_hash + return self._remote_seed_auth_hash @remote_auth_hash.setter def remote_auth_hash(self, value: bytes): @@ -122,11 +151,11 @@ def remote_auth_hash(self, value: bytes): elif len(value) != 32: raise ValueError("remote_auth_hash must be 32 bytes") else: - self._remote_auth_hash = value + self._remote_seed_auth_hash = value self.update_encryption_session() @property - def remote_seed(self) -> bytes: + def remote_seed(self) -> bytes | None: """Get the remote seed.""" return self._remote_seed @@ -160,9 +189,17 @@ def bad_chars_replacement(exception): codecs.register_error("bad_chars_replacement", bad_chars_replacement) -def main(username, password, device_ip, pcap_file_path, output_json_name=None): +def main( + loop: asyncio.AbstractEventLoop, + username, + password, + device_ip, + pcap_file_path, + output_json_name=None, +): """Run the main function.""" - capture = pyshark.FileCapture(pcap_file_path, display_filter="http") + asyncio.set_event_loop(loop) + capture = pyshark.FileCapture(pcap_file_path, display_filter="http", eventloop=loop) # In an effort to keep this code tied into the original code # (so that this can hopefully leverage any future codebase updates inheriently), @@ -262,6 +299,9 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): f.write("\n" * 1) f.close() + # Call close method which cleans up event loop + capture.close() + @click.command() @click.option( @@ -292,12 +332,15 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): required=False, help="The name of the output file, relative to the current directory.", ) -def cli(username, password, host, pcap_file_path, output): +async def cli(username, password, host, pcap_file_path, output): """Export KLAP data in JSON format from a PCAP file.""" # pyshark does not work within a running event loop and we don't want to # install click as well as asyncclick so run in a new thread. + loop = asyncio.new_event_loop() thread = Thread( - target=main, args=[username, password, host, pcap_file_path, output] + target=main, + args=[loop, username, password, host, pcap_file_path, output], + daemon=True, ) thread.start() thread.join() From c4f015a2fbc5f0f3c2f3e90afda4190319c49338 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 17 Jul 2024 18:57:09 +0100 Subject: [PATCH 534/892] Redact sensitive info from debug logs (#1069) Redacts sensitive data when debug logging device responses such as mac, location and usernames --- kasa/discover.py | 40 ++++++++++++++++--- kasa/iotprotocol.py | 39 ++++++++++++++++++- kasa/klaptransport.py | 9 +---- kasa/protocol.py | 41 +++++++++++++++++++ kasa/smart/smartdevice.py | 8 ++-- kasa/smartprotocol.py | 32 +++++++++++++-- kasa/tests/discovery_fixtures.py | 22 ++++++----- kasa/tests/test_discovery.py | 36 +++++++++++++++++ kasa/tests/test_protocol.py | 67 ++++++++++++++++++++++++++++++++ kasa/tests/test_smartprotocol.py | 35 +++++++++++++++++ kasa/xortransport.py | 10 ++--- 11 files changed, 300 insertions(+), 39 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index b9e34ee2a..c69933a95 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -87,7 +87,8 @@ import logging import socket from collections.abc import Awaitable -from typing import Callable, Dict, Optional, Type, cast +from pprint import pformat as pf +from typing import Any, Callable, Dict, Optional, Type, cast # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -112,8 +113,10 @@ UnsupportedDeviceError, ) from kasa.iot.iotdevice import IotDevice +from kasa.iotprotocol import REDACTORS as IOT_REDACTORS from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads +from kasa.protocol import mask_mac, redact_data from kasa.xortransport import XorEncryption _LOGGER = logging.getLogger(__name__) @@ -123,6 +126,12 @@ OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Awaitable[None]] DeviceDict = Dict[str, Device] +NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = { + "device_id": lambda x: "REDACTED_" + x[9::], + "owner": lambda x: "REDACTED_" + x[9::], + "mac": mask_mac, +} + class _DiscoverProtocol(asyncio.DatagramProtocol): """Implementation of the discovery protocol handler. @@ -293,6 +302,8 @@ class Discover: DISCOVERY_PORT_2 = 20002 DISCOVERY_QUERY_2 = binascii.unhexlify("020000010000000000000000463cb5d3") + _redact_data = True + @staticmethod async def discover( *, @@ -484,7 +495,9 @@ def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice: f"Unable to read response from device: {config.host}: {ex}" ) from ex - _LOGGER.debug("[DISCOVERY] %s << %s", config.host, info) + if _LOGGER.isEnabledFor(logging.DEBUG): + data = redact_data(info, IOT_REDACTORS) if Discover._redact_data else info + _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) device_class = cast(Type[IotDevice], Discover._get_device_class(info)) device = device_class(config.host, config=config) @@ -504,6 +517,7 @@ def _get_device_instance( config: DeviceConfig, ) -> Device: """Get SmartDevice from the new 20002 response.""" + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) try: info = json_loads(data[16:]) except Exception as ex: @@ -514,9 +528,17 @@ def _get_device_instance( try: discovery_result = DiscoveryResult(**info["result"]) except ValidationError as ex: - _LOGGER.debug( - "Unable to parse discovery from device %s: %s", config.host, info - ) + if debug_enabled: + data = ( + redact_data(info, NEW_DISCOVERY_REDACTORS) + if Discover._redact_data + else info + ) + _LOGGER.debug( + "Unable to parse discovery from device %s: %s", + config.host, + pf(data), + ) raise UnsupportedDeviceError( f"Unable to parse discovery from device: {config.host}: {ex}" ) from ex @@ -551,7 +573,13 @@ def _get_device_instance( discovery_result=discovery_result.get_dict(), ) - _LOGGER.debug("[DISCOVERY] %s << %s", config.host, info) + if debug_enabled: + data = ( + redact_data(info, NEW_DISCOVERY_REDACTORS) + if Discover._redact_data + else info + ) + _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) device = device_class(config.host, protocol=protocol) di = discovery_result.get_dict() diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index 1795566e2..91edb0329 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -4,6 +4,8 @@ import asyncio import logging +from pprint import pformat as pf +from typing import Any, Callable from .deviceconfig import DeviceConfig from .exceptions import ( @@ -14,11 +16,26 @@ _RetryableError, ) from .json import dumps as json_dumps -from .protocol import BaseProtocol, BaseTransport +from .protocol import BaseProtocol, BaseTransport, mask_mac, redact_data from .xortransport import XorEncryption, XorTransport _LOGGER = logging.getLogger(__name__) +REDACTORS: dict[str, Callable[[Any], Any] | None] = { + "latitude": lambda x: 0, + "longitude": lambda x: 0, + "latitude_i": lambda x: 0, + "longitude_i": lambda x: 0, + "deviceId": lambda x: "REDACTED_" + x[9::], + "id": lambda x: "REDACTED_" + x[9::], + "alias": lambda x: "#MASKED_NAME#" if x else "", + "mac": mask_mac, + "mic_mac": mask_mac, + "ssid": lambda x: "#MASKED_SSID#" if x else "", + "oemId": lambda x: "REDACTED_" + x[9::], + "username": lambda _: "user@example.com", # cnCloud +} + class IotProtocol(BaseProtocol): """Class for the legacy TPLink IOT KASA Protocol.""" @@ -34,6 +51,7 @@ def __init__( super().__init__(transport=transport) self._query_lock = asyncio.Lock() + self._redact_data = True async def query(self, request: str | dict, retry_count: int = 3) -> dict: """Query the device retrying for retry_count on failure.""" @@ -85,7 +103,24 @@ async def _query(self, request: str, retry_count: int = 3) -> dict: raise KasaException("Query reached somehow to unreachable") async def _execute_query(self, request: str, retry_count: int) -> dict: - return await self._transport.send(request) + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + + if debug_enabled: + _LOGGER.debug( + "%s >> %s", + self._host, + request, + ) + resp = await self._transport.send(request) + + if debug_enabled: + data = redact_data(resp, REDACTORS) if self._redact_data else resp + _LOGGER.debug( + "%s << %s", + self._host, + pf(data), + ) + return resp async def close(self) -> None: """Close the underlying transport.""" diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index c138ba38e..b7976101e 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -50,7 +50,6 @@ import secrets import struct import time -from pprint import pformat as pf from typing import Any, cast from cryptography.hazmat.primitives import padding @@ -353,7 +352,7 @@ async def send(self, request: str): + f"request with seq {seq}" ) else: - _LOGGER.debug("Query posted " + msg) + _LOGGER.debug("Device %s query posted %s", self._host, msg) # Check for mypy if self._encryption_session is not None: @@ -361,11 +360,7 @@ async def send(self, request: str): json_payload = json_loads(decrypted_response) - _LOGGER.debug( - "%s << %s", - self._host, - _LOGGER.isEnabledFor(logging.DEBUG) and pf(json_payload), - ) + _LOGGER.debug("Device %s query response received", self._host) return json_payload diff --git a/kasa/protocol.py b/kasa/protocol.py index 7d717c5ed..9b5ffa3d3 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -18,6 +18,7 @@ import logging import struct from abc import ABC, abstractmethod +from typing import Any, Callable, TypeVar, cast # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -28,6 +29,46 @@ _NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} _UNSIGNED_INT_NETWORK_ORDER = struct.Struct(">I") +_T = TypeVar("_T") + + +def redact_data(data: _T, redactors: dict[str, Callable[[Any], Any] | None]) -> _T: + """Redact sensitive data for logging.""" + if not isinstance(data, (dict, list)): + return data + + if isinstance(data, list): + return cast(_T, [redact_data(val, redactors) for val in data]) + + redacted = {**data} + + for key, value in redacted.items(): + if value is None: + continue + if isinstance(value, str) and not value: + continue + if key in redactors: + if redactor := redactors[key]: + try: + redacted[key] = redactor(value) + except: # noqa: E722 + redacted[key] = "**REDACTEX**" + else: + redacted[key] = "**REDACTED**" + elif isinstance(value, dict): + redacted[key] = redact_data(value, redactors) + elif isinstance(value, list): + redacted[key] = [redact_data(item, redactors) for item in value] + + return cast(_T, redacted) + + +def mask_mac(mac: str) -> str: + """Return mac address with last two octects blanked.""" + delim = ":" if ":" in mac else "-" + rest = delim.join(format(s, "02x") for s in bytes.fromhex("000000")) + return f"{mac[:8]}{delim}{rest}" + def md5(payload: bytes) -> bytes: """Return the MD5 hash of the payload.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 21e46cc34..156db4615 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -193,11 +193,9 @@ async def update(self, update_children: bool = False): if not self._features: await self._initialize_features() - _LOGGER.debug( - "Update completed %s: %s", - self.host, - self._last_update if first_update else resp, - ) + if _LOGGER.isEnabledFor(logging.DEBUG): + updated = self._last_update if first_update else resp + _LOGGER.debug("Update completed %s: %s", self.host, list(updated.keys())) def _handle_module_post_update_hook(self, module: SmartModule) -> bool: try: diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index c0dfb31e4..8b22f0cba 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -12,7 +12,7 @@ import time import uuid from pprint import pformat as pf -from typing import Any +from typing import Any, Callable from .exceptions import ( SMART_AUTHENTICATION_ERRORS, @@ -26,10 +26,31 @@ _RetryableError, ) from .json import dumps as json_dumps -from .protocol import BaseProtocol, BaseTransport, md5 +from .protocol import BaseProtocol, BaseTransport, mask_mac, md5, redact_data _LOGGER = logging.getLogger(__name__) +REDACTORS: dict[str, Callable[[Any], Any] | None] = { + "latitude": lambda x: 0, + "longitude": lambda x: 0, + "la": lambda x: 0, # lat on ks240 + "lo": lambda x: 0, # lon on ks240 + "device_id": lambda x: "REDACTED_" + x[9::], + "parent_device_id": lambda x: "REDACTED_" + x[9::], # Hub attached children + "original_device_id": lambda x: "REDACTED_" + x[9::], # Strip children + "nickname": lambda x: "I01BU0tFRF9OQU1FIw==" if x else "", + "mac": mask_mac, + "ssid": lambda x: "I01BU0tFRF9TU0lEIw=" if x else "", + "bssid": lambda _: "000000000000", + "oem_id": lambda x: "REDACTED_" + x[9::], + "setup_code": None, # matter + "setup_payload": None, # matter + "mfi_setup_code": None, # mfi_ for homekit + "mfi_setup_id": None, + "mfi_token_token": None, + "mfi_token_uuid": None, +} + class SmartProtocol(BaseProtocol): """Class for the new TPLink SMART protocol.""" @@ -50,6 +71,7 @@ def __init__( self._multi_request_batch_size = ( self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE ) + self._redact_data = True def get_smart_request(self, method, params=None) -> str: """Get a request message as a string.""" @@ -167,11 +189,15 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic ) response_step = await self._transport.send(smart_request) if debug_enabled: + if self._redact_data: + data = redact_data(response_step, REDACTORS) + else: + data = response_step _LOGGER.debug( "%s %s << %s", self._host, batch_name, - pf(response_step), + pf(data), ) try: self._handle_response_error_code(response_step, batch_name) diff --git a/kasa/tests/discovery_fixtures.py b/kasa/tests/discovery_fixtures.py index 1ba24bf1a..1451a5cab 100644 --- a/kasa/tests/discovery_fixtures.py +++ b/kasa/tests/discovery_fixtures.py @@ -90,21 +90,26 @@ class _DiscoveryMock: query_data: dict device_type: str encrypt_type: str - _datagram: bytes login_version: int | None = None port_override: int | None = None + @property + def _datagram(self) -> bytes: + if self.default_port == 9999: + return XorEncryption.encrypt(json_dumps(self.discovery_data))[4:] + else: + return ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(self.discovery_data).encode() + ) + if "discovery_result" in fixture_data: - discovery_data = {"result": fixture_data["discovery_result"]} + discovery_data = {"result": fixture_data["discovery_result"].copy()} device_type = fixture_data["discovery_result"]["device_type"] encrypt_type = fixture_data["discovery_result"]["mgt_encrypt_schm"][ "encrypt_type" ] login_version = fixture_data["discovery_result"]["mgt_encrypt_schm"].get("lv") - datagram = ( - b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" - + json_dumps(discovery_data).encode() - ) dm = _DiscoveryMock( ip, 80, @@ -113,16 +118,14 @@ class _DiscoveryMock: fixture_data, device_type, encrypt_type, - datagram, login_version, ) else: sys_info = fixture_data["system"]["get_sysinfo"] - discovery_data = {"system": {"get_sysinfo": sys_info}} + discovery_data = {"system": {"get_sysinfo": sys_info.copy()}} device_type = sys_info.get("mic_type") or sys_info.get("type") encrypt_type = "XOR" login_version = None - datagram = XorEncryption.encrypt(json_dumps(discovery_data))[4:] dm = _DiscoveryMock( ip, 9999, @@ -131,7 +134,6 @@ class _DiscoveryMock: fixture_data, device_type, encrypt_type, - datagram, login_version, ) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index b657b12ec..19eef1f75 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -2,6 +2,7 @@ # ruff: noqa: S106 import asyncio +import logging import re import socket from unittest.mock import MagicMock @@ -565,3 +566,38 @@ async def test_do_discover_external_cancel(mocker): with pytest.raises(asyncio.TimeoutError): async with asyncio_timeout(0): await dp.wait_for_discovery_to_complete() + + +async def test_discovery_redaction(discovery_mock, caplog: pytest.LogCaptureFixture): + """Test query sensitive info redaction.""" + mac = "12:34:56:78:9A:BC" + + if discovery_mock.default_port == 9999: + sysinfo = discovery_mock.discovery_data["system"]["get_sysinfo"] + if "mac" in sysinfo: + sysinfo["mac"] = mac + elif "mic_mac" in sysinfo: + sysinfo["mic_mac"] = mac + else: + discovery_mock.discovery_data["result"]["mac"] = mac + + # Info no message logging + caplog.set_level(logging.INFO) + await Discover.discover() + + assert mac not in caplog.text + + caplog.set_level(logging.DEBUG) + + # Debug no redaction + caplog.clear() + Discover._redact_data = False + await Discover.discover() + assert mac in caplog.text + + # Debug redaction + caplog.clear() + Discover._redact_data = True + await Discover.discover() + assert mac not in caplog.text + assert "12:34:56:00:00:00" in caplog.text diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 1aeeedb27..cb38b6198 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -8,9 +8,12 @@ import pkgutil import struct import sys +from typing import cast import pytest +from kasa.iot import IotDevice + from ..aestransport import AesTransport from ..credentials import Credentials from ..device import Device @@ -21,8 +24,12 @@ from ..protocol import ( BaseProtocol, BaseTransport, + mask_mac, + redact_data, ) from ..xortransport import XorEncryption, XorTransport +from .conftest import device_iot +from .fakeprotocol_iot import FakeIotTransport @pytest.mark.parametrize( @@ -676,3 +683,63 @@ def test_deprecated_protocol(): host = "127.0.0.1" proto = TPLinkSmartHomeProtocol(host=host) assert proto.config.host == host + + +@device_iot +async def test_iot_queries_redaction(dev: IotDevice, caplog: pytest.LogCaptureFixture): + """Test query sensitive info redaction.""" + device_id = "123456789ABCDEF" + cast(FakeIotTransport, dev.protocol._transport).proto["system"]["get_sysinfo"][ + "deviceId" + ] = device_id + + # Info no message logging + caplog.set_level(logging.INFO) + await dev.update() + assert device_id not in caplog.text + + caplog.set_level(logging.DEBUG, logger="kasa") + # The fake iot protocol also logs so disable it + test_logger = logging.getLogger("kasa.tests.fakeprotocol_iot") + test_logger.setLevel(logging.INFO) + + # Debug no redaction + caplog.clear() + cast(IotProtocol, dev.protocol)._redact_data = False + await dev.update() + assert device_id in caplog.text + + # Debug redaction + caplog.clear() + cast(IotProtocol, dev.protocol)._redact_data = True + await dev.update() + assert device_id not in caplog.text + assert "REDACTED_" + device_id[9::] in caplog.text + + +async def test_redact_data(): + """Test redact data function.""" + data = { + "device_id": "123456789ABCDEF", + "owner": "0987654", + "mac": "12:34:56:78:90:AB", + "ip": "192.168.1", + "no_val": None, + } + excpected_data = { + "device_id": "REDACTED_ABCDEF", + "owner": "**REDACTED**", + "mac": "12:34:56:00:00:00", + "ip": "**REDACTEX**", + "no_val": None, + } + REDACTORS = { + "device_id": lambda x: "REDACTED_" + x[9::], + "owner": None, + "mac": mask_mac, + "ip": lambda x: "127.0.0." + x.split(".")[3], + } + + redacted_data = redact_data(data, REDACTORS) + + assert redacted_data == excpected_data diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 204d0c7f2..058bfc3b3 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -1,8 +1,11 @@ import logging +from typing import cast import pytest import pytest_mock +from kasa.smart import SmartDevice + from ..exceptions import ( SMART_RETRYABLE_ERRORS, DeviceError, @@ -10,6 +13,7 @@ SmartErrorCode, ) from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper +from .conftest import device_smart from .fakeprotocol_smart import FakeSmartTransport DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} @@ -409,3 +413,34 @@ async def test_incomplete_list(mocker, caplog): "Device 127.0.0.123 returned empty results list for method get_preset_rules" in caplog.text ) + + +@device_smart +async def test_smart_queries_redaction( + dev: SmartDevice, caplog: pytest.LogCaptureFixture +): + """Test query sensitive info redaction.""" + device_id = "123456789ABCDEF" + cast(FakeSmartTransport, dev.protocol._transport).info["get_device_info"][ + "device_id" + ] = device_id + + # Info no message logging + caplog.set_level(logging.INFO) + await dev.update() + assert device_id not in caplog.text + + caplog.set_level(logging.DEBUG) + + # Debug no redaction + caplog.clear() + dev.protocol._redact_data = False + await dev.update() + assert device_id in caplog.text + + # Debug redaction + caplog.clear() + dev.protocol._redact_data = True + await dev.update() + assert device_id not in caplog.text + assert "REDACTED_" + device_id[9::] in caplog.text diff --git a/kasa/xortransport.py b/kasa/xortransport.py index 52fba3d3e..e8d0303bd 100644 --- a/kasa/xortransport.py +++ b/kasa/xortransport.py @@ -19,7 +19,6 @@ import socket import struct from collections.abc import Generator -from pprint import pformat as pf # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -78,9 +77,8 @@ async def _execute_send(self, request: str) -> dict: """Execute a query on the device and wait for the response.""" assert self.writer is not None # noqa: S101 assert self.reader is not None # noqa: S101 - debug_log = _LOGGER.isEnabledFor(logging.DEBUG) - if debug_log: - _LOGGER.debug("%s >> %s", self._host, request) + _LOGGER.debug("Device %s sending query %s", self._host, request) + self.writer.write(XorEncryption.encrypt(request)) await self.writer.drain() @@ -90,8 +88,8 @@ async def _execute_send(self, request: str) -> dict: buffer = await self.reader.readexactly(length) response = XorEncryption.decrypt(buffer) json_payload = json_loads(response) - if debug_log: - _LOGGER.debug("%s << %s", self._host, pf(json_payload)) + + _LOGGER.debug("Device %s query response received", self._host) return json_payload From a97d2c92bbba415dbd948cfb1ec7869036073848 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 17 Jul 2024 08:28:11 +0100 Subject: [PATCH 535/892] Only refresh smart LightEffect module daily (#1064) Fixes an issue with L530 bulbs on HW version 1.0 whereby the light effect query causes the device to crash with JSON_ENCODE_FAIL_ERROR after approximately 60 calls. --- kasa/smart/modules/lighteffect.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py index 5f589d6dd..699c679b3 100644 --- a/kasa/smart/modules/lighteffect.py +++ b/kasa/smart/modules/lighteffect.py @@ -17,7 +17,7 @@ class LightEffect(SmartModule, SmartLightEffect): REQUIRED_COMPONENT = "light_effect" QUERY_GETTER_NAME = "get_dynamic_light_effect_rules" - MINIMUM_UPDATE_INTERVAL_SECS = 60 + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 AVAILABLE_BULB_EFFECTS = { "L1": "Party", "L2": "Relax", @@ -74,6 +74,7 @@ def effect(self) -> str: """Return effect name.""" return self._effect + @allow_update_after async def set_effect( self, effect: str, From c4a9a19d5b98c05ac4d9019d41641433c959c8a2 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 17 Jul 2024 18:57:09 +0100 Subject: [PATCH 536/892] Redact sensitive info from debug logs (#1069) Redacts sensitive data when debug logging device responses such as mac, location and usernames --- kasa/discover.py | 40 ++++++++++++++++--- kasa/iotprotocol.py | 39 ++++++++++++++++++- kasa/klaptransport.py | 9 +---- kasa/protocol.py | 41 +++++++++++++++++++ kasa/smart/smartdevice.py | 8 ++-- kasa/smartprotocol.py | 32 +++++++++++++-- kasa/tests/discovery_fixtures.py | 22 ++++++----- kasa/tests/test_discovery.py | 36 +++++++++++++++++ kasa/tests/test_protocol.py | 67 ++++++++++++++++++++++++++++++++ kasa/tests/test_smartprotocol.py | 35 +++++++++++++++++ kasa/xortransport.py | 10 ++--- 11 files changed, 300 insertions(+), 39 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index b9e34ee2a..c69933a95 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -87,7 +87,8 @@ import logging import socket from collections.abc import Awaitable -from typing import Callable, Dict, Optional, Type, cast +from pprint import pformat as pf +from typing import Any, Callable, Dict, Optional, Type, cast # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -112,8 +113,10 @@ UnsupportedDeviceError, ) from kasa.iot.iotdevice import IotDevice +from kasa.iotprotocol import REDACTORS as IOT_REDACTORS from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads +from kasa.protocol import mask_mac, redact_data from kasa.xortransport import XorEncryption _LOGGER = logging.getLogger(__name__) @@ -123,6 +126,12 @@ OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Awaitable[None]] DeviceDict = Dict[str, Device] +NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = { + "device_id": lambda x: "REDACTED_" + x[9::], + "owner": lambda x: "REDACTED_" + x[9::], + "mac": mask_mac, +} + class _DiscoverProtocol(asyncio.DatagramProtocol): """Implementation of the discovery protocol handler. @@ -293,6 +302,8 @@ class Discover: DISCOVERY_PORT_2 = 20002 DISCOVERY_QUERY_2 = binascii.unhexlify("020000010000000000000000463cb5d3") + _redact_data = True + @staticmethod async def discover( *, @@ -484,7 +495,9 @@ def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice: f"Unable to read response from device: {config.host}: {ex}" ) from ex - _LOGGER.debug("[DISCOVERY] %s << %s", config.host, info) + if _LOGGER.isEnabledFor(logging.DEBUG): + data = redact_data(info, IOT_REDACTORS) if Discover._redact_data else info + _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) device_class = cast(Type[IotDevice], Discover._get_device_class(info)) device = device_class(config.host, config=config) @@ -504,6 +517,7 @@ def _get_device_instance( config: DeviceConfig, ) -> Device: """Get SmartDevice from the new 20002 response.""" + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) try: info = json_loads(data[16:]) except Exception as ex: @@ -514,9 +528,17 @@ def _get_device_instance( try: discovery_result = DiscoveryResult(**info["result"]) except ValidationError as ex: - _LOGGER.debug( - "Unable to parse discovery from device %s: %s", config.host, info - ) + if debug_enabled: + data = ( + redact_data(info, NEW_DISCOVERY_REDACTORS) + if Discover._redact_data + else info + ) + _LOGGER.debug( + "Unable to parse discovery from device %s: %s", + config.host, + pf(data), + ) raise UnsupportedDeviceError( f"Unable to parse discovery from device: {config.host}: {ex}" ) from ex @@ -551,7 +573,13 @@ def _get_device_instance( discovery_result=discovery_result.get_dict(), ) - _LOGGER.debug("[DISCOVERY] %s << %s", config.host, info) + if debug_enabled: + data = ( + redact_data(info, NEW_DISCOVERY_REDACTORS) + if Discover._redact_data + else info + ) + _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) device = device_class(config.host, protocol=protocol) di = discovery_result.get_dict() diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index 1795566e2..91edb0329 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -4,6 +4,8 @@ import asyncio import logging +from pprint import pformat as pf +from typing import Any, Callable from .deviceconfig import DeviceConfig from .exceptions import ( @@ -14,11 +16,26 @@ _RetryableError, ) from .json import dumps as json_dumps -from .protocol import BaseProtocol, BaseTransport +from .protocol import BaseProtocol, BaseTransport, mask_mac, redact_data from .xortransport import XorEncryption, XorTransport _LOGGER = logging.getLogger(__name__) +REDACTORS: dict[str, Callable[[Any], Any] | None] = { + "latitude": lambda x: 0, + "longitude": lambda x: 0, + "latitude_i": lambda x: 0, + "longitude_i": lambda x: 0, + "deviceId": lambda x: "REDACTED_" + x[9::], + "id": lambda x: "REDACTED_" + x[9::], + "alias": lambda x: "#MASKED_NAME#" if x else "", + "mac": mask_mac, + "mic_mac": mask_mac, + "ssid": lambda x: "#MASKED_SSID#" if x else "", + "oemId": lambda x: "REDACTED_" + x[9::], + "username": lambda _: "user@example.com", # cnCloud +} + class IotProtocol(BaseProtocol): """Class for the legacy TPLink IOT KASA Protocol.""" @@ -34,6 +51,7 @@ def __init__( super().__init__(transport=transport) self._query_lock = asyncio.Lock() + self._redact_data = True async def query(self, request: str | dict, retry_count: int = 3) -> dict: """Query the device retrying for retry_count on failure.""" @@ -85,7 +103,24 @@ async def _query(self, request: str, retry_count: int = 3) -> dict: raise KasaException("Query reached somehow to unreachable") async def _execute_query(self, request: str, retry_count: int) -> dict: - return await self._transport.send(request) + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + + if debug_enabled: + _LOGGER.debug( + "%s >> %s", + self._host, + request, + ) + resp = await self._transport.send(request) + + if debug_enabled: + data = redact_data(resp, REDACTORS) if self._redact_data else resp + _LOGGER.debug( + "%s << %s", + self._host, + pf(data), + ) + return resp async def close(self) -> None: """Close the underlying transport.""" diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 3a1eb3367..a3a20000c 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -50,7 +50,6 @@ import secrets import struct import time -from pprint import pformat as pf from typing import Any, cast from cryptography.hazmat.primitives import padding @@ -349,7 +348,7 @@ async def send(self, request: str): + f"request with seq {seq}" ) else: - _LOGGER.debug("Query posted " + msg) + _LOGGER.debug("Device %s query posted %s", self._host, msg) # Check for mypy if self._encryption_session is not None: @@ -357,11 +356,7 @@ async def send(self, request: str): json_payload = json_loads(decrypted_response) - _LOGGER.debug( - "%s << %s", - self._host, - _LOGGER.isEnabledFor(logging.DEBUG) and pf(json_payload), - ) + _LOGGER.debug("Device %s query response received", self._host) return json_payload diff --git a/kasa/protocol.py b/kasa/protocol.py index c7d505b8a..ad0432dd7 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -18,6 +18,7 @@ import logging import struct from abc import ABC, abstractmethod +from typing import Any, Callable, TypeVar, cast # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -28,6 +29,46 @@ _NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} _UNSIGNED_INT_NETWORK_ORDER = struct.Struct(">I") +_T = TypeVar("_T") + + +def redact_data(data: _T, redactors: dict[str, Callable[[Any], Any] | None]) -> _T: + """Redact sensitive data for logging.""" + if not isinstance(data, (dict, list)): + return data + + if isinstance(data, list): + return cast(_T, [redact_data(val, redactors) for val in data]) + + redacted = {**data} + + for key, value in redacted.items(): + if value is None: + continue + if isinstance(value, str) and not value: + continue + if key in redactors: + if redactor := redactors[key]: + try: + redacted[key] = redactor(value) + except: # noqa: E722 + redacted[key] = "**REDACTEX**" + else: + redacted[key] = "**REDACTED**" + elif isinstance(value, dict): + redacted[key] = redact_data(value, redactors) + elif isinstance(value, list): + redacted[key] = [redact_data(item, redactors) for item in value] + + return cast(_T, redacted) + + +def mask_mac(mac: str) -> str: + """Return mac address with last two octects blanked.""" + delim = ":" if ":" in mac else "-" + rest = delim.join(format(s, "02x") for s in bytes.fromhex("000000")) + return f"{mac[:8]}{delim}{rest}" + def md5(payload: bytes) -> bytes: """Return the MD5 hash of the payload.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 731789a01..b183f8db9 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -193,11 +193,9 @@ async def update(self, update_children: bool = False): if not self._features: await self._initialize_features() - _LOGGER.debug( - "Update completed %s: %s", - self.host, - self._last_update if first_update else resp, - ) + if _LOGGER.isEnabledFor(logging.DEBUG): + updated = self._last_update if first_update else resp + _LOGGER.debug("Update completed %s: %s", self.host, list(updated.keys())) def _handle_module_post_update_hook(self, module: SmartModule) -> bool: try: diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 0c95325a5..24203007c 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -12,7 +12,7 @@ import time import uuid from pprint import pformat as pf -from typing import Any +from typing import Any, Callable from .exceptions import ( SMART_AUTHENTICATION_ERRORS, @@ -26,10 +26,31 @@ _RetryableError, ) from .json import dumps as json_dumps -from .protocol import BaseProtocol, BaseTransport, md5 +from .protocol import BaseProtocol, BaseTransport, mask_mac, md5, redact_data _LOGGER = logging.getLogger(__name__) +REDACTORS: dict[str, Callable[[Any], Any] | None] = { + "latitude": lambda x: 0, + "longitude": lambda x: 0, + "la": lambda x: 0, # lat on ks240 + "lo": lambda x: 0, # lon on ks240 + "device_id": lambda x: "REDACTED_" + x[9::], + "parent_device_id": lambda x: "REDACTED_" + x[9::], # Hub attached children + "original_device_id": lambda x: "REDACTED_" + x[9::], # Strip children + "nickname": lambda x: "I01BU0tFRF9OQU1FIw==" if x else "", + "mac": mask_mac, + "ssid": lambda x: "I01BU0tFRF9TU0lEIw=" if x else "", + "bssid": lambda _: "000000000000", + "oem_id": lambda x: "REDACTED_" + x[9::], + "setup_code": None, # matter + "setup_payload": None, # matter + "mfi_setup_code": None, # mfi_ for homekit + "mfi_setup_id": None, + "mfi_token_token": None, + "mfi_token_uuid": None, +} + class SmartProtocol(BaseProtocol): """Class for the new TPLink SMART protocol.""" @@ -50,6 +71,7 @@ def __init__( self._multi_request_batch_size = ( self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE ) + self._redact_data = True def get_smart_request(self, method, params=None) -> str: """Get a request message as a string.""" @@ -167,11 +189,15 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic ) response_step = await self._transport.send(smart_request) if debug_enabled: + if self._redact_data: + data = redact_data(response_step, REDACTORS) + else: + data = response_step _LOGGER.debug( "%s %s << %s", self._host, batch_name, - pf(response_step), + pf(data), ) try: self._handle_response_error_code(response_step, batch_name) diff --git a/kasa/tests/discovery_fixtures.py b/kasa/tests/discovery_fixtures.py index 1ba24bf1a..1451a5cab 100644 --- a/kasa/tests/discovery_fixtures.py +++ b/kasa/tests/discovery_fixtures.py @@ -90,21 +90,26 @@ class _DiscoveryMock: query_data: dict device_type: str encrypt_type: str - _datagram: bytes login_version: int | None = None port_override: int | None = None + @property + def _datagram(self) -> bytes: + if self.default_port == 9999: + return XorEncryption.encrypt(json_dumps(self.discovery_data))[4:] + else: + return ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(self.discovery_data).encode() + ) + if "discovery_result" in fixture_data: - discovery_data = {"result": fixture_data["discovery_result"]} + discovery_data = {"result": fixture_data["discovery_result"].copy()} device_type = fixture_data["discovery_result"]["device_type"] encrypt_type = fixture_data["discovery_result"]["mgt_encrypt_schm"][ "encrypt_type" ] login_version = fixture_data["discovery_result"]["mgt_encrypt_schm"].get("lv") - datagram = ( - b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" - + json_dumps(discovery_data).encode() - ) dm = _DiscoveryMock( ip, 80, @@ -113,16 +118,14 @@ class _DiscoveryMock: fixture_data, device_type, encrypt_type, - datagram, login_version, ) else: sys_info = fixture_data["system"]["get_sysinfo"] - discovery_data = {"system": {"get_sysinfo": sys_info}} + discovery_data = {"system": {"get_sysinfo": sys_info.copy()}} device_type = sys_info.get("mic_type") or sys_info.get("type") encrypt_type = "XOR" login_version = None - datagram = XorEncryption.encrypt(json_dumps(discovery_data))[4:] dm = _DiscoveryMock( ip, 9999, @@ -131,7 +134,6 @@ class _DiscoveryMock: fixture_data, device_type, encrypt_type, - datagram, login_version, ) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index b657b12ec..19eef1f75 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -2,6 +2,7 @@ # ruff: noqa: S106 import asyncio +import logging import re import socket from unittest.mock import MagicMock @@ -565,3 +566,38 @@ async def test_do_discover_external_cancel(mocker): with pytest.raises(asyncio.TimeoutError): async with asyncio_timeout(0): await dp.wait_for_discovery_to_complete() + + +async def test_discovery_redaction(discovery_mock, caplog: pytest.LogCaptureFixture): + """Test query sensitive info redaction.""" + mac = "12:34:56:78:9A:BC" + + if discovery_mock.default_port == 9999: + sysinfo = discovery_mock.discovery_data["system"]["get_sysinfo"] + if "mac" in sysinfo: + sysinfo["mac"] = mac + elif "mic_mac" in sysinfo: + sysinfo["mic_mac"] = mac + else: + discovery_mock.discovery_data["result"]["mac"] = mac + + # Info no message logging + caplog.set_level(logging.INFO) + await Discover.discover() + + assert mac not in caplog.text + + caplog.set_level(logging.DEBUG) + + # Debug no redaction + caplog.clear() + Discover._redact_data = False + await Discover.discover() + assert mac in caplog.text + + # Debug redaction + caplog.clear() + Discover._redact_data = True + await Discover.discover() + assert mac not in caplog.text + assert "12:34:56:00:00:00" in caplog.text diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index e0ddbbb43..57390b744 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -8,9 +8,12 @@ import pkgutil import struct import sys +from typing import cast import pytest +from kasa.iot import IotDevice + from ..aestransport import AesTransport from ..credentials import Credentials from ..deviceconfig import DeviceConfig @@ -20,8 +23,12 @@ from ..protocol import ( BaseProtocol, BaseTransport, + mask_mac, + redact_data, ) from ..xortransport import XorEncryption, XorTransport +from .conftest import device_iot +from .fakeprotocol_iot import FakeIotTransport @pytest.mark.parametrize( @@ -614,3 +621,63 @@ def test_deprecated_protocol(): host = "127.0.0.1" proto = TPLinkSmartHomeProtocol(host=host) assert proto.config.host == host + + +@device_iot +async def test_iot_queries_redaction(dev: IotDevice, caplog: pytest.LogCaptureFixture): + """Test query sensitive info redaction.""" + device_id = "123456789ABCDEF" + cast(FakeIotTransport, dev.protocol._transport).proto["system"]["get_sysinfo"][ + "deviceId" + ] = device_id + + # Info no message logging + caplog.set_level(logging.INFO) + await dev.update() + assert device_id not in caplog.text + + caplog.set_level(logging.DEBUG, logger="kasa") + # The fake iot protocol also logs so disable it + test_logger = logging.getLogger("kasa.tests.fakeprotocol_iot") + test_logger.setLevel(logging.INFO) + + # Debug no redaction + caplog.clear() + cast(IotProtocol, dev.protocol)._redact_data = False + await dev.update() + assert device_id in caplog.text + + # Debug redaction + caplog.clear() + cast(IotProtocol, dev.protocol)._redact_data = True + await dev.update() + assert device_id not in caplog.text + assert "REDACTED_" + device_id[9::] in caplog.text + + +async def test_redact_data(): + """Test redact data function.""" + data = { + "device_id": "123456789ABCDEF", + "owner": "0987654", + "mac": "12:34:56:78:90:AB", + "ip": "192.168.1", + "no_val": None, + } + excpected_data = { + "device_id": "REDACTED_ABCDEF", + "owner": "**REDACTED**", + "mac": "12:34:56:00:00:00", + "ip": "**REDACTEX**", + "no_val": None, + } + REDACTORS = { + "device_id": lambda x: "REDACTED_" + x[9::], + "owner": None, + "mac": mask_mac, + "ip": lambda x: "127.0.0." + x.split(".")[3], + } + + redacted_data = redact_data(data, REDACTORS) + + assert redacted_data == excpected_data diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 204d0c7f2..058bfc3b3 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -1,8 +1,11 @@ import logging +from typing import cast import pytest import pytest_mock +from kasa.smart import SmartDevice + from ..exceptions import ( SMART_RETRYABLE_ERRORS, DeviceError, @@ -10,6 +13,7 @@ SmartErrorCode, ) from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper +from .conftest import device_smart from .fakeprotocol_smart import FakeSmartTransport DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} @@ -409,3 +413,34 @@ async def test_incomplete_list(mocker, caplog): "Device 127.0.0.123 returned empty results list for method get_preset_rules" in caplog.text ) + + +@device_smart +async def test_smart_queries_redaction( + dev: SmartDevice, caplog: pytest.LogCaptureFixture +): + """Test query sensitive info redaction.""" + device_id = "123456789ABCDEF" + cast(FakeSmartTransport, dev.protocol._transport).info["get_device_info"][ + "device_id" + ] = device_id + + # Info no message logging + caplog.set_level(logging.INFO) + await dev.update() + assert device_id not in caplog.text + + caplog.set_level(logging.DEBUG) + + # Debug no redaction + caplog.clear() + dev.protocol._redact_data = False + await dev.update() + assert device_id in caplog.text + + # Debug redaction + caplog.clear() + dev.protocol._redact_data = True + await dev.update() + assert device_id not in caplog.text + assert "REDACTED_" + device_id[9::] in caplog.text diff --git a/kasa/xortransport.py b/kasa/xortransport.py index e96864533..75572bb09 100644 --- a/kasa/xortransport.py +++ b/kasa/xortransport.py @@ -19,7 +19,6 @@ import socket import struct from collections.abc import Generator -from pprint import pformat as pf # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -78,9 +77,8 @@ async def _execute_send(self, request: str) -> dict: """Execute a query on the device and wait for the response.""" assert self.writer is not None # noqa: S101 assert self.reader is not None # noqa: S101 - debug_log = _LOGGER.isEnabledFor(logging.DEBUG) - if debug_log: - _LOGGER.debug("%s >> %s", self._host, request) + _LOGGER.debug("Device %s sending query %s", self._host, request) + self.writer.write(XorEncryption.encrypt(request)) await self.writer.drain() @@ -90,8 +88,8 @@ async def _execute_send(self, request: str) -> dict: buffer = await self.reader.readexactly(length) response = XorEncryption.decrypt(buffer) json_payload = json_loads(response) - if debug_log: - _LOGGER.debug("%s << %s", self._host, pf(json_payload)) + + _LOGGER.debug("Device %s query response received", self._host) return json_payload From 82cff1346d7846dec6c78628e0bf254e72113ec3 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Thu, 18 Jul 2024 08:40:35 +0100 Subject: [PATCH 537/892] Prepare 0.7.0.5 --- CHANGELOG.md | 16 +++++++++++++++- pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e5c3951..73ab6cd7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ # Changelog -## [0.7.0.4](https://github.com/python-kasa/python-kasa/tree/0.7.0.4) (2024-07-011) +## [0.7.0.5](https://github.com/python-kasa/python-kasa/tree/0.7.0.5) (2024-07-18) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.4...0.7.0.5) + +A critical bugfix for an issue with some L530 Series devices and a redactor for sensitive info from debug logs. + +**Fixed bugs:** + +- Only refresh smart LightEffect module daily [\#1064](https://github.com/python-kasa/python-kasa/pull/1064) + +**Project maintenance:** + +- Redact sensitive info from debug logs [\#1069](https://github.com/python-kasa/python-kasa/pull/1069) + +## [0.7.0.4](https://github.com/python-kasa/python-kasa/tree/0.7.0.4) (2024-07-11) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.3...0.7.0.4) diff --git a/pyproject.toml b/pyproject.toml index 8b9f73eb9..141edf075 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.4" +version = "0.7.0.5" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 06ff598d9c5148191b5711bae8caf2430ad22687 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 22 Jul 2024 19:33:31 +0100 Subject: [PATCH 538/892] Raise KasaException on decryption errors (#1078) Currently if the library encounters an invalid decryption error it raises a value error. This PR wraps it in a KasaException so consumers such as HA can catch an expected library exception. --- kasa/klaptransport.py | 11 ++++-- kasa/tests/test_klapprotocol.py | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index b7976101e..97b231453 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -50,7 +50,7 @@ import secrets import struct import time -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -354,9 +354,14 @@ async def send(self, request: str): else: _LOGGER.debug("Device %s query posted %s", self._host, msg) - # Check for mypy - if self._encryption_session is not None: + if TYPE_CHECKING: + assert self._encryption_session + try: decrypted_response = self._encryption_session.decrypt(response_data) + except Exception as ex: + raise KasaException( + f"Error trying to decrypt device {self._host} response: {ex}" + ) from ex json_payload = json_loads(decrypted_response) diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index b71ea460d..0565683a1 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -238,6 +238,71 @@ def test_encrypt_unicode(): assert d == decrypted +async def test_transport_decrypt(mocker): + """Test transport decryption.""" + d = {"great": "success"} + + seed = secrets.token_bytes(16) + auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + + transport = KlapTransport(config=DeviceConfig(host="127.0.0.1")) + transport._handshake_done = True + transport._session_expire_at = time.monotonic() + 60 + transport._encryption_session = encryption_session + + async def _return_response(url: URL, params=None, data=None, *_, **__): + encryption_session = KlapEncryptionSession( + transport._encryption_session.local_seed, + transport._encryption_session.remote_seed, + transport._encryption_session.user_hash, + ) + seq = params.get("seq") + encryption_session._seq = seq - 1 + encrypted, seq = encryption_session.encrypt(json.dumps(d)) + seq = seq + return 200, encrypted + + mocker.patch.object(HttpClient, "post", side_effect=_return_response) + + resp = await transport.send(json.dumps({})) + assert d == resp + + +async def test_transport_decrypt_error(mocker, caplog): + """Test that a decryption error raises a kasa exception.""" + d = {"great": "success"} + + seed = secrets.token_bytes(16) + auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + + transport = KlapTransport(config=DeviceConfig(host="127.0.0.1")) + transport._handshake_done = True + transport._session_expire_at = time.monotonic() + 60 + transport._encryption_session = encryption_session + + async def _return_response(url: URL, params=None, data=None, *_, **__): + encryption_session = KlapEncryptionSession( + secrets.token_bytes(16), + transport._encryption_session.remote_seed, + transport._encryption_session.user_hash, + ) + seq = params.get("seq") + encryption_session._seq = seq - 1 + encrypted, seq = encryption_session.encrypt(json.dumps(d)) + seq = seq + return 200, encrypted + + mocker.patch.object(HttpClient, "post", side_effect=_return_response) + + with pytest.raises( + KasaException, + match="Error trying to decrypt device 127.0.0.1 response: Invalid padding bytes.", + ): + await transport.send(json.dumps({})) + + @pytest.mark.parametrize( "device_credentials, expectation", [ From 58afeb28a1e48436c0d8ed78f5efaba07284558d Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 23 Jul 2024 19:02:20 +0100 Subject: [PATCH 539/892] Update smart request parameter handling (#1061) Changes to the smart request handling: - Do not send params if null - Drop the requestId parameter - get_preset_rules doesn't send parameters for preset component version less than 3 - get_led_info no longer sends the wrong parameters - get_on_off_gradually_info no longer sends an empty {} parameter --- kasa/smart/modules/led.py | 2 +- kasa/smart/modules/lightpreset.py | 3 + kasa/smart/modules/lighttransition.py | 2 +- kasa/smartprotocol.py | 93 +++------------------------ kasa/tests/fakeprotocol_smart.py | 10 +-- 5 files changed, 19 insertions(+), 91 deletions(-) diff --git a/kasa/smart/modules/led.py b/kasa/smart/modules/led.py index bbfe3579b..9c02be85a 100644 --- a/kasa/smart/modules/led.py +++ b/kasa/smart/modules/led.py @@ -16,7 +16,7 @@ class Led(SmartModule, LedInterface): def query(self) -> dict: """Query to execute during the update cycle.""" - return {self.QUERY_GETTER_NAME: {"led_rule": None}} + return {self.QUERY_GETTER_NAME: None} @property def mode(self): diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 6bb2fb3fa..16cd15ae2 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -153,6 +153,9 @@ def query(self) -> dict: """Query to execute during the update cycle.""" if self._state_in_sysinfo: # Child lights can have states in the child info return {} + if self.supported_version < 3: + return {self.QUERY_GETTER_NAME: None} + return {self.QUERY_GETTER_NAME: {"start_index": 0}} async def _check_supported(self): diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index 3a5897d12..e0aeb4d71 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -234,7 +234,7 @@ def query(self) -> dict: if self._state_in_sysinfo: return {} else: - return {self.QUERY_GETTER_NAME: {}} + return {self.QUERY_GETTER_NAME: None} async def _check_supported(self): """Additional check to see if the module is supported by the device.""" diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 8b22f0cba..8f92b94eb 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -66,7 +66,6 @@ def __init__( """Create a protocol object.""" super().__init__(transport=transport) self._terminal_uuid: str = base64.b64encode(md5(uuid.uuid4().bytes)).decode() - self._request_id_generator = SnowflakeId(1, 1) self._query_lock = asyncio.Lock() self._multi_request_batch_size = ( self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE @@ -77,11 +76,11 @@ def get_smart_request(self, method, params=None) -> str: """Get a request message as a string.""" request = { "method": method, - "params": params, - "requestID": self._request_id_generator.generate_id(), "request_time_milis": round(time.time() * 1000), "terminal_uuid": self._terminal_uuid, } + if params: + request["params"] = params return json_dumps(request) async def query(self, request: str | dict, retry_count: int = 3) -> dict: @@ -157,8 +156,10 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) multi_result: dict[str, Any] = {} smart_method = "multipleRequest" + multi_requests = [ - {"method": method, "params": params} for method, params in requests.items() + {"method": method, "params": params} if params else {"method": method} + for method, params in requests.items() ] end = len(multi_requests) @@ -168,7 +169,7 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic # If step is 1 do not send request batches for request in multi_requests: method = request["method"] - req = self.get_smart_request(method, request["params"]) + req = self.get_smart_request(method, request.get("params")) resp = await self._transport.send(req) self._handle_response_error_code(resp, method, raise_on_error=False) multi_result[method] = resp["result"] @@ -347,86 +348,6 @@ async def close(self) -> None: await self._transport.close() -class SnowflakeId: - """Class for generating snowflake ids.""" - - EPOCH = 1420041600000 # Custom epoch (in milliseconds) - WORKER_ID_BITS = 5 - DATA_CENTER_ID_BITS = 5 - SEQUENCE_BITS = 12 - - MAX_WORKER_ID = (1 << WORKER_ID_BITS) - 1 - MAX_DATA_CENTER_ID = (1 << DATA_CENTER_ID_BITS) - 1 - - SEQUENCE_MASK = (1 << SEQUENCE_BITS) - 1 - - def __init__(self, worker_id, data_center_id): - if worker_id > SnowflakeId.MAX_WORKER_ID or worker_id < 0: - raise ValueError( - "Worker ID can't be greater than " - + str(SnowflakeId.MAX_WORKER_ID) - + " or less than 0" - ) - if data_center_id > SnowflakeId.MAX_DATA_CENTER_ID or data_center_id < 0: - raise ValueError( - "Data center ID can't be greater than " - + str(SnowflakeId.MAX_DATA_CENTER_ID) - + " or less than 0" - ) - - self.worker_id = worker_id - self.data_center_id = data_center_id - self.sequence = 0 - self.last_timestamp = -1 - - def generate_id(self): - """Generate a snowflake id.""" - timestamp = self._current_millis() - - if timestamp < self.last_timestamp: - raise ValueError("Clock moved backwards. Refusing to generate ID.") - - if timestamp == self.last_timestamp: - # Within the same millisecond, increment the sequence number - self.sequence = (self.sequence + 1) & SnowflakeId.SEQUENCE_MASK - if self.sequence == 0: - # Sequence exceeds its bit range, wait until the next millisecond - timestamp = self._wait_next_millis(self.last_timestamp) - else: - # New millisecond, reset the sequence number - self.sequence = 0 - - # Update the last timestamp - self.last_timestamp = timestamp - - # Generate and return the final ID - return ( - ( - (timestamp - SnowflakeId.EPOCH) - << ( - SnowflakeId.WORKER_ID_BITS - + SnowflakeId.SEQUENCE_BITS - + SnowflakeId.DATA_CENTER_ID_BITS - ) - ) - | ( - self.data_center_id - << (SnowflakeId.SEQUENCE_BITS + SnowflakeId.WORKER_ID_BITS) - ) - | (self.worker_id << SnowflakeId.SEQUENCE_BITS) - | self.sequence - ) - - def _current_millis(self): - return round(time.monotonic() * 1000) - - def _wait_next_millis(self, last_timestamp): - timestamp = self._current_millis() - while timestamp <= last_timestamp: - timestamp = self._current_millis() - return timestamp - - class _ChildProtocolWrapper(SmartProtocol): """Protocol wrapper for controlling child devices. @@ -456,6 +377,8 @@ def _get_method_and_params_for_request(self, request): smart_method = "multipleRequest" requests = [ {"method": method, "params": params} + if params + else {"method": method} for method, params in request.items() ] smart_params = {"requests": requests} diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 600cd75d3..7a54be170 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -119,8 +119,9 @@ def credentials_hash(self): async def send(self, request: str): request_dict = json_loads(request) method = request_dict["method"] - params = request_dict["params"] + if method == "multipleRequest": + params = request_dict["params"] responses = [] for request in params["requests"]: response = self._send_request(request) # type: ignore[arg-type] @@ -308,12 +309,13 @@ def _edit_preset_rules(self, info, params): def _send_request(self, request_dict: dict): method = request_dict["method"] - params = request_dict["params"] info = self.info if method == "control_child": - return self._handle_control_child(params) - elif method == "component_nego" or method[:4] == "get_": + return self._handle_control_child(request_dict["params"]) + + params = request_dict.get("params") + if method == "component_nego" or method[:4] == "get_": if method in info: result = copy.deepcopy(info[method]) if "start_index" in result and "sum" in result: From ed033679e5f7e570129c1c6437562c670fa1bc26 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 23 Jul 2024 19:13:52 +0100 Subject: [PATCH 540/892] Split out main cli module into lazily loaded submodules (#1039) --- kasa/cli/__main__.py | 3 +- kasa/cli/common.py | 231 ++++++++ kasa/cli/device.py | 184 ++++++ kasa/cli/discover.py | 142 +++++ kasa/cli/feature.py | 134 +++++ kasa/cli/lazygroup.py | 70 +++ kasa/cli/light.py | 200 +++++++ kasa/cli/main.py | 1208 ++++------------------------------------ kasa/cli/schedule.py | 46 ++ kasa/cli/time.py | 55 ++ kasa/cli/usage.py | 134 +++++ kasa/cli/wifi.py | 50 ++ kasa/tests/test_cli.py | 42 +- pyproject.toml | 2 +- 14 files changed, 1393 insertions(+), 1108 deletions(-) create mode 100644 kasa/cli/common.py create mode 100644 kasa/cli/device.py create mode 100644 kasa/cli/discover.py create mode 100644 kasa/cli/feature.py create mode 100644 kasa/cli/lazygroup.py create mode 100644 kasa/cli/light.py create mode 100644 kasa/cli/schedule.py create mode 100644 kasa/cli/time.py create mode 100644 kasa/cli/usage.py create mode 100644 kasa/cli/wifi.py diff --git a/kasa/cli/__main__.py b/kasa/cli/__main__.py index 5d4ca6a05..1cf92da16 100644 --- a/kasa/cli/__main__.py +++ b/kasa/cli/__main__.py @@ -2,4 +2,5 @@ from kasa.cli.main import cli -cli() +if __name__ == "__main__": + cli() diff --git a/kasa/cli/common.py b/kasa/cli/common.py new file mode 100644 index 000000000..1977d0c83 --- /dev/null +++ b/kasa/cli/common.py @@ -0,0 +1,231 @@ +"""Common cli module.""" + +from __future__ import annotations + +import json +import re +import sys +from contextlib import contextmanager +from functools import singledispatch, update_wrapper, wraps +from typing import Final + +import asyncclick as click + +from kasa import ( + Device, +) + +# Value for optional options if passed without a value +OPTIONAL_VALUE_FLAG: Final = "_FLAG_" + +# Block list of commands which require no update +SKIP_UPDATE_COMMANDS = ["raw-command", "command"] + +pass_dev = click.make_pass_decorator(Device) # type: ignore[type-abstract] + + +try: + from rich import print as _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.""" + + @wraps(echo_func) + def wrapper(message=None, *args, **kwargs): + if message is not None: + message = rich_formatting.sub("", message) + echo_func(message, *args, **kwargs) + + return wrapper + + _echo = _strip_rich_formatting(click.echo) + + +def echo(*args, **kwargs): + """Print a message.""" + ctx = click.get_current_context().find_root() + if "json" not in ctx.params or ctx.params["json"] is False: + _echo(*args, **kwargs) + + +def error(msg: str): + """Print an error and exit.""" + echo(f"[bold red]{msg}[/bold red]") + sys.exit(1) + + +def json_formatter_cb(result, **kwargs): + """Format and output the result as JSON, if requested.""" + if not kwargs.get("json"): + return + + @singledispatch + def to_serializable(val): + """Regular obj-to-string for json serialization. + + The singledispatch trick is from hynek: https://hynek.me/articles/serialization/ + """ + return str(val) + + @to_serializable.register(Device) + def _device_to_serializable(val: Device): + """Serialize smart device data, just using the last update raw payload.""" + return val.internal_state + + json_content = json.dumps(result, indent=4, default=to_serializable) + print(json_content) + + +def pass_dev_or_child(wrapped_function): + """Pass the device or child to the click command based on the child options.""" + child_help = ( + "Child ID or alias for controlling sub-devices. " + "If no value provided will show an interactive prompt allowing you to " + "select a child." + ) + child_index_help = "Child index controlling sub-devices" + + @contextmanager + def patched_device_update(parent: Device, child: Device): + try: + orig_update = child.update + # patch child update method. Can be removed once update can be called + # directly on child devices + child.update = parent.update # type: ignore[method-assign] + yield child + finally: + child.update = orig_update # type: ignore[method-assign] + + @click.pass_obj + @click.pass_context + @click.option( + "--child", + "--name", + is_flag=False, + flag_value=OPTIONAL_VALUE_FLAG, + default=None, + required=False, + type=click.STRING, + help=child_help, + ) + @click.option( + "--child-index", + "--index", + required=False, + default=None, + type=click.INT, + help=child_index_help, + ) + async def wrapper(ctx: click.Context, dev, *args, child, child_index, **kwargs): + if child := await _get_child_device(dev, child, child_index, ctx.info_name): + ctx.obj = ctx.with_resource(patched_device_update(dev, child)) + dev = child + return await ctx.invoke(wrapped_function, dev, *args, **kwargs) + + # Update wrapper function to look like wrapped function + return update_wrapper(wrapper, wrapped_function) + + +async def _get_child_device( + device: Device, child_option, child_index_option, info_command +) -> Device | None: + def _list_children(): + return "\n".join( + [ + f"{idx}: {child.device_id} ({child.alias})" + for idx, child in enumerate(device.children) + ] + ) + + if child_option is None and child_index_option is None: + return None + + if info_command in SKIP_UPDATE_COMMANDS: + # The device hasn't had update called (e.g. for cmd_command) + # The way child devices are accessed requires a ChildDevice to + # wrap the communications. Doing this properly would require creating + # a common interfaces for both IOT and SMART child devices. + # As a stop-gap solution, we perform an update instead. + await device.update() + + if not device.children: + error(f"Device: {device.host} does not have children") + + if child_option is not None and child_index_option is not None: + raise click.BadOptionUsage( + "child", "Use either --child or --child-index, not both." + ) + + if child_option is not None: + if child_option is OPTIONAL_VALUE_FLAG: + msg = _list_children() + child_index_option = click.prompt( + f"\n{msg}\nEnter the index number of the child device", + type=click.IntRange(0, len(device.children) - 1), + ) + elif child := device.get_child_device(child_option): + echo(f"Targeting child device {child.alias}") + return child + else: + error( + "No child device found with device_id or name: " + f"{child_option} children are:\n{_list_children()}" + ) + + if child_index_option + 1 > len(device.children) or child_index_option < 0: + error( + f"Invalid index {child_index_option}, " + f"device has {len(device.children)} children" + ) + child_by_index = device.children[child_index_option] + echo(f"Targeting child device {child_by_index.alias}") + return child_by_index + + +def CatchAllExceptions(cls): + """Capture all exceptions and prints them nicely. + + Idea from https://stackoverflow.com/a/44347763 and + https://stackoverflow.com/questions/52213375 + """ + + def _handle_exception(debug, exc): + if isinstance(exc, click.ClickException): + raise + # Handle exit request from click. + if isinstance(exc, click.exceptions.Exit): + sys.exit(exc.exit_code) + + echo(f"Raised error: {exc}") + if debug: + raise + echo("Run with --debug enabled to see stacktrace") + sys.exit(1) + + class _CommandCls(cls): + _debug = False + + async def make_context(self, info_name, args, parent=None, **extra): + self._debug = any( + [arg for arg in args if arg in ["--debug", "-d", "--verbose", "-v"]] + ) + try: + return await super().make_context( + info_name, args, parent=parent, **extra + ) + except Exception as exc: + _handle_exception(self._debug, exc) + + async def invoke(self, ctx): + try: + return await super().invoke(ctx) + except Exception as exc: + _handle_exception(self._debug, exc) + + return _CommandCls diff --git a/kasa/cli/device.py b/kasa/cli/device.py new file mode 100644 index 000000000..604380354 --- /dev/null +++ b/kasa/cli/device.py @@ -0,0 +1,184 @@ +"""Module for cli device commands.""" + +from __future__ import annotations + +from pprint import pformat as pf + +import asyncclick as click + +from kasa import ( + Device, + Module, +) +from kasa.smart import SmartDevice + +from .common import ( + echo, + error, + pass_dev, + pass_dev_or_child, +) + + +@click.group() +@pass_dev_or_child +def device(dev): + """Commands to control basic device settings.""" + + +@device.command() +@pass_dev_or_child +@click.pass_context +async def state(ctx, dev: Device): + """Print out device state and versions.""" + from .feature import _echo_all_features + + verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False + + echo(f"[bold]== {dev.alias} - {dev.model} ==[/bold]") + echo(f"Host: {dev.host}") + echo(f"Port: {dev.port}") + echo(f"Device state: {dev.is_on}") + + echo(f"Time: {dev.time} (tz: {dev.timezone}") + echo(f"Hardware: {dev.hw_info['hw_ver']}") + echo(f"Software: {dev.hw_info['sw_ver']}") + echo(f"MAC (rssi): {dev.mac} ({dev.rssi})") + if verbose: + echo(f"Location: {dev.location}") + + echo() + _echo_all_features(dev.features, verbose=verbose) + + if verbose: + echo("\n[bold]== Modules ==[/bold]") + for module in dev.modules.values(): + echo(f"[green]+ {module}[/green]") + + if dev.children: + echo("\n[bold]== Children ==[/bold]") + for child in dev.children: + _echo_all_features( + child.features, + title_prefix=f"{child.alias} ({child.model})", + verbose=verbose, + indent="\t", + ) + if verbose: + echo(f"\n\t[bold]== Child {child.alias} Modules ==[/bold]") + for module in child.modules.values(): + echo(f"\t[green]+ {module}[/green]") + echo() + + if verbose: + echo("\n\t[bold]== Protocol information ==[/bold]") + echo(f"\tCredentials hash: {dev.credentials_hash}") + echo() + from .discover import _echo_discovery_info + + _echo_discovery_info(dev._discovery_info) + + return dev.internal_state + + +@device.command() +@pass_dev_or_child +async def sysinfo(dev): + """Print out full system information.""" + echo("== System info ==") + echo(pf(dev.sys_info)) + return dev.sys_info + + +@device.command() +@click.option("--transition", type=int, required=False) +@pass_dev_or_child +async def on(dev: Device, transition: int): + """Turn the device on.""" + echo(f"Turning on {dev.alias}") + return await dev.turn_on(transition=transition) + + +@click.command +@click.option("--transition", type=int, required=False) +@pass_dev_or_child +async def off(dev: Device, transition: int): + """Turn the device off.""" + echo(f"Turning off {dev.alias}") + return await dev.turn_off(transition=transition) + + +@device.command() +@click.option("--transition", type=int, required=False) +@pass_dev_or_child +async def toggle(dev: Device, transition: int): + """Toggle the device on/off.""" + if dev.is_on: + echo(f"Turning off {dev.alias}") + return await dev.turn_off(transition=transition) + + echo(f"Turning on {dev.alias}") + return await dev.turn_on(transition=transition) + + +@device.command() +@click.argument("state", type=bool, required=False) +@pass_dev_or_child +async def led(dev: Device, state): + """Get or set (Plug's) led state.""" + if not (led := dev.modules.get(Module.Led)): + error("Device does not support led.") + return + if state is not None: + echo(f"Turning led to {state}") + return await led.set_led(state) + else: + echo(f"LED state: {led.led}") + return led.led + + +@device.command() +@click.argument("new_alias", required=False, default=None) +@pass_dev_or_child +async def alias(dev, new_alias): + """Get or set the device (or plug) alias.""" + if new_alias is not None: + echo(f"Setting alias to {new_alias}") + res = await dev.set_alias(new_alias) + await dev.update() + echo(f"Alias set to: {dev.alias}") + return res + + echo(f"Alias: {dev.alias}") + if dev.children: + for plug in dev.children: + echo(f" * {plug.alias}") + + return dev.alias + + +@device.command() +@click.option("--delay", default=1) +@pass_dev +async def reboot(plug, delay): + """Reboot the device.""" + echo("Rebooting the device..") + return await plug.reboot(delay) + + +@device.command() +@pass_dev +@click.option( + "--username", required=True, prompt=True, help="New username to set on the device" +) +@click.option( + "--password", required=True, prompt=True, help="New password to set on the device" +) +async def update_credentials(dev, username, password): + """Update device credentials for authenticated devices.""" + if not isinstance(dev, SmartDevice): + error("Credentials can only be updated on authenticated devices.") + + click.confirm("Do you really want to replace the existing credentials?", abort=True) + + return await dev.update_credentials(username, password) diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py new file mode 100644 index 000000000..6bf58e725 --- /dev/null +++ b/kasa/cli/discover.py @@ -0,0 +1,142 @@ +"""Module for cli discovery commands.""" + +from __future__ import annotations + +import asyncio + +import asyncclick as click +from pydantic.v1 import ValidationError + +from kasa import ( + AuthenticationError, + Credentials, + Device, + Discover, + UnsupportedDeviceError, +) +from kasa.discover import DiscoveryResult + +from .common import echo + + +@click.command() +@click.pass_context +async def discover(ctx): + """Discover devices in the network.""" + target = ctx.parent.params["target"] + username = ctx.parent.params["username"] + password = ctx.parent.params["password"] + discovery_timeout = ctx.parent.params["discovery_timeout"] + timeout = ctx.parent.params["timeout"] + port = ctx.parent.params["port"] + + credentials = Credentials(username, password) if username and password else None + + sem = asyncio.Semaphore() + discovered = dict() + unsupported = [] + auth_failed = [] + + async def print_unsupported(unsupported_exception: UnsupportedDeviceError): + unsupported.append(unsupported_exception) + async with sem: + if unsupported_exception.discovery_result: + echo("== Unsupported device ==") + _echo_discovery_info(unsupported_exception.discovery_result) + echo() + else: + echo("== Unsupported device ==") + echo(f"\t{unsupported_exception}") + echo() + + echo(f"Discovering devices on {target} for {discovery_timeout} seconds") + + from .device import state + + async def print_discovered(dev: Device): + async with sem: + try: + await dev.update() + except AuthenticationError: + auth_failed.append(dev._discovery_info) + echo("== Authentication failed for device ==") + _echo_discovery_info(dev._discovery_info) + echo() + else: + ctx.parent.obj = dev + await ctx.parent.invoke(state) + discovered[dev.host] = dev.internal_state + echo() + + discovered_devices = await Discover.discover( + target=target, + discovery_timeout=discovery_timeout, + on_discovered=print_discovered, + on_unsupported=print_unsupported, + port=port, + timeout=timeout, + credentials=credentials, + ) + + for device in discovered_devices.values(): + await device.protocol.close() + + echo(f"Found {len(discovered)} devices") + if unsupported: + echo(f"Found {len(unsupported)} unsupported devices") + if auth_failed: + echo(f"Found {len(auth_failed)} devices that failed to authenticate") + + return discovered + + +def _echo_dictionary(discovery_info: dict): + echo("\t[bold]== Discovery information ==[/bold]") + for key, value in discovery_info.items(): + key_name = " ".join(x.capitalize() or "_" for x in key.split("_")) + key_name_and_spaces = "{:<15}".format(key_name + ":") + echo(f"\t{key_name_and_spaces}{value}") + + +def _echo_discovery_info(discovery_info): + # We don't have discovery info when all connection params are passed manually + if discovery_info is None: + return + + if "system" in discovery_info and "get_sysinfo" in discovery_info["system"]: + _echo_dictionary(discovery_info["system"]["get_sysinfo"]) + return + + try: + dr = DiscoveryResult(**discovery_info) + except ValidationError: + _echo_dictionary(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"\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): + """Discover a device identified by its alias.""" + for _attempt in range(1, attempts): + found_devs = await Discover.discover(target=target, timeout=timeout) + for _ip, dev in found_devs.items(): + if dev.alias.lower() == alias.lower(): + host = dev.host + return host + + return None diff --git a/kasa/cli/feature.py b/kasa/cli/feature.py new file mode 100644 index 000000000..f8cba4e32 --- /dev/null +++ b/kasa/cli/feature.py @@ -0,0 +1,134 @@ +"""Module for cli feature commands.""" + +from __future__ import annotations + +import ast + +import asyncclick as click + +from kasa import ( + Device, + Feature, +) + +from .common import ( + echo, + error, + pass_dev_or_child, +) + + +def _echo_features( + features: dict[str, Feature], + title: str, + category: Feature.Category | None = None, + verbose: bool = False, + indent: str = "\t", +): + """Print out a listing of features and their values.""" + if category is not None: + features = { + id_: feat for id_, feat in features.items() if feat.category == category + } + + echo(f"{indent}[bold]{title}[/bold]") + for _, feat in features.items(): + try: + echo(f"{indent}{feat}") + if verbose: + echo(f"{indent}\tType: {feat.type}") + echo(f"{indent}\tCategory: {feat.category}") + echo(f"{indent}\tIcon: {feat.icon}") + except Exception as ex: + echo(f"{indent}{feat.name} ({feat.id}): [red]got exception ({ex})[/red]") + + +def _echo_all_features(features, *, verbose=False, title_prefix=None, indent=""): + """Print out all features by category.""" + if title_prefix is not None: + echo(f"[bold]\n{indent}== {title_prefix} ==[/bold]") + echo() + _echo_features( + features, + title="== Primary features ==", + category=Feature.Category.Primary, + verbose=verbose, + indent=indent, + ) + echo() + _echo_features( + features, + title="== Information ==", + category=Feature.Category.Info, + verbose=verbose, + indent=indent, + ) + echo() + _echo_features( + features, + title="== Configuration ==", + category=Feature.Category.Config, + verbose=verbose, + indent=indent, + ) + echo() + _echo_features( + features, + title="== Debug ==", + category=Feature.Category.Debug, + verbose=verbose, + indent=indent, + ) + + +@click.command(name="feature") +@click.argument("name", required=False) +@click.argument("value", required=False) +@pass_dev_or_child +@click.pass_context +async def feature( + ctx: click.Context, + dev: Device, + name: str, + value, +): + """Access and modify features. + + If no *name* is given, lists available features and their values. + If only *name* is given, the value of named feature is returned. + If both *name* and *value* are set, the described setting is changed. + """ + verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False + + if not name: + _echo_all_features(dev.features, verbose=verbose, indent="") + + if dev.children: + for child_dev in dev.children: + _echo_all_features( + child_dev.features, + verbose=verbose, + title_prefix=f"Child {child_dev.alias}", + indent="\t", + ) + + return + + if name not in dev.features: + error(f"No feature by name '{name}'") + return + + feat = dev.features[name] + + if value is None: + unit = f" {feat.unit}" if feat.unit else "" + echo(f"{feat.name} ({name}): {feat.value}{unit}") + return feat.value + + value = ast.literal_eval(value) + echo(f"Changing {name} from {feat.value} to {value}") + response = await dev.features[name].set_value(value) + await dev.update() + echo(f"New state: {feat.value}") + + return response diff --git a/kasa/cli/lazygroup.py b/kasa/cli/lazygroup.py new file mode 100644 index 000000000..9e9724aae --- /dev/null +++ b/kasa/cli/lazygroup.py @@ -0,0 +1,70 @@ +"""Module for lazily instantiating sub modules. + +Taken from the click help files. +""" + +import importlib + +import asyncclick as click + + +class LazyGroup(click.Group): + """Lazy group class.""" + + def __init__(self, *args, lazy_subcommands=None, **kwargs): + super().__init__(*args, **kwargs) + # lazy_subcommands is a map of the form: + # + # {command-name} -> {module-name}.{command-object-name} + # + self.lazy_subcommands = lazy_subcommands or {} + + def list_commands(self, ctx): + """List click commands.""" + base = super().list_commands(ctx) + lazy = list(self.lazy_subcommands.keys()) + return lazy + base + + def get_command(self, ctx, cmd_name): + """Get click command.""" + if cmd_name in self.lazy_subcommands: + return self._lazy_load(cmd_name) + return super().get_command(ctx, cmd_name) + + def format_commands(self, ctx, formatter): + """Format the top level help output.""" + sections = {} + for cmd, parent in self.lazy_subcommands.items(): + sections.setdefault(parent, []) + cmd_obj = self.get_command(ctx, cmd) + help = cmd_obj.get_short_help_str() + sections[parent].append((cmd, help)) + for section in sections: + if section: + header = ( + f"Common {section} commands (also available " + f"under the `{section}` subcommand)" + ) + else: + header = "Subcommands" + with formatter.section(header): + formatter.write_dl(sections[section]) + + def _lazy_load(self, cmd_name): + # lazily loading a command, first get the module name and attribute name + if not (import_path := self.lazy_subcommands[cmd_name]): + import_path = f".{cmd_name}.{cmd_name}" + else: + import_path = f".{import_path}.{cmd_name}" + modname, cmd_object_name = import_path.rsplit(".", 1) + # do the import + mod = importlib.import_module(modname, package=__package__) + # get the Command object from that module + cmd_object = getattr(mod, cmd_object_name) + # check the result to make debugging easier + if not isinstance(cmd_object, click.BaseCommand): + raise ValueError( + f"Lazy loading of {cmd_name} failed by returning " + "a non-command object" + ) + return cmd_object diff --git a/kasa/cli/light.py b/kasa/cli/light.py new file mode 100644 index 000000000..06c469077 --- /dev/null +++ b/kasa/cli/light.py @@ -0,0 +1,200 @@ +"""Module for cli light control commands.""" + +import asyncclick as click + +from kasa import ( + Device, + Module, +) +from kasa.iot import ( + IotBulb, +) + +from .common import echo, error, pass_dev_or_child + + +@click.group() +@pass_dev_or_child +def light(dev): + """Commands to control light settings.""" + + +@light.command() +@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) +@click.option("--transition", type=int, required=False) +@pass_dev_or_child +async def brightness(dev: Device, brightness: int, transition: int): + """Get or set brightness.""" + if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: + error("This device does not support brightness.") + return + + if brightness is None: + echo(f"Brightness: {light.brightness}") + return light.brightness + else: + echo(f"Setting brightness to {brightness}") + return await light.set_brightness(brightness, transition=transition) + + +@light.command() +@click.argument( + "temperature", type=click.IntRange(2500, 9000), default=None, required=False +) +@click.option("--transition", type=int, required=False) +@pass_dev_or_child +async def temperature(dev: Device, temperature: int, transition: int): + """Get or set color temperature.""" + if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: + error("Device does not support color temperature") + return + + if temperature is None: + echo(f"Color temperature: {light.color_temp}") + valid_temperature_range = light.valid_temperature_range + if valid_temperature_range != (0, 0): + echo("(min: {}, max: {})".format(*valid_temperature_range)) + else: + echo( + "Temperature range unknown, please open a github issue" + f" or a pull request for model '{dev.model}'" + ) + return light.valid_temperature_range + else: + echo(f"Setting color temperature to {temperature}") + return await light.set_color_temp(temperature, transition=transition) + + +@light.command() +@click.argument("effect", type=click.STRING, default=None, required=False) +@click.pass_context +@pass_dev_or_child +async def effect(dev: Device, ctx, effect): + """Set an effect.""" + if not (light_effect := dev.modules.get(Module.LightEffect)): + error("Device does not support effects") + return + if effect is None: + echo( + f"Light effect: {light_effect.effect}\n" + + f"Available Effects: {light_effect.effect_list}" + ) + return light_effect.effect + + if effect not in light_effect.effect_list: + raise click.BadArgumentUsage( + f"Effect must be one of: {light_effect.effect_list}", ctx + ) + + echo(f"Setting Effect: {effect}") + return await light_effect.set_effect(effect) + + +@light.command() +@click.argument("h", type=click.IntRange(0, 360), default=None, required=False) +@click.argument("s", type=click.IntRange(0, 100), default=None, required=False) +@click.argument("v", type=click.IntRange(0, 100), default=None, required=False) +@click.option("--transition", type=int, required=False) +@click.pass_context +@pass_dev_or_child +async def hsv(dev: Device, ctx, h, s, v, transition): + """Get or set color in HSV.""" + if not (light := dev.modules.get(Module.Light)) or not light.is_color: + error("Device does not support colors") + return + + if h is None and s is None and v is None: + echo(f"Current HSV: {light.hsv}") + return light.hsv + elif s is None or v is None: + raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx) + else: + echo(f"Setting HSV: {h} {s} {v}") + return await light.set_hsv(h, s, v, transition=transition) + + +@light.group(invoke_without_command=True) +@pass_dev_or_child +@click.pass_context +async def presets(ctx, dev): + """List and modify bulb setting presets.""" + if ctx.invoked_subcommand is None: + return await ctx.invoke(presets_list) + + +@presets.command(name="list") +@pass_dev_or_child +def presets_list(dev: Device): + """List presets.""" + if not (light_preset := dev.modules.get(Module.LightPreset)): + error("Presets not supported on device") + return + + for preset in light_preset.preset_states_list: + echo(preset) + + return light_preset.preset_states_list + + +@presets.command(name="modify") +@click.argument("index", type=int) +@click.option("--brightness", type=int) +@click.option("--hue", type=int) +@click.option("--saturation", type=int) +@click.option("--temperature", type=int) +@pass_dev_or_child +async def presets_modify(dev: Device, index, brightness, hue, saturation, temperature): + """Modify a preset.""" + for preset in dev.presets: + if preset.index == index: + break + else: + error(f"No preset found for index {index}") + return + + if brightness is not None: + preset.brightness = brightness + if hue is not None: + preset.hue = hue + if saturation is not None: + preset.saturation = saturation + if temperature is not None: + preset.color_temp = temperature + + echo(f"Going to save preset: {preset}") + + return await dev.save_preset(preset) + + +@light.command() +@pass_dev_or_child +@click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False)) +@click.option("--last", is_flag=True) +@click.option("--preset", type=int) +async def turn_on_behavior(dev: Device, type, last, preset): + """Modify bulb turn-on behavior.""" + if not dev.is_bulb or not isinstance(dev, IotBulb): + error("Presets only supported on iot bulbs") + return + settings = await dev.get_turn_on_behavior() + echo(f"Current turn on behavior: {settings}") + + # Return if we are not setting the value + if not type and not last and not preset: + return settings + + # If we are setting the value, the type has to be specified + if (last or preset) and type is None: + echo("To set the behavior, you need to define --type") + return + + behavior = getattr(settings, type) + + if last: + echo(f"Going to set {type} to last") + behavior.preset = None + elif preset is not None: + echo(f"Going to set {type} to preset {preset}") + behavior.preset = preset + + return await dev.set_turn_on_behavior(settings) diff --git a/kasa/cli/main.py b/kasa/cli/main.py index 10c422978..88b768c41 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -1,4 +1,4 @@ -"""python-kasa cli tool.""" +"""Main module for cli tool.""" from __future__ import annotations @@ -6,282 +6,90 @@ import asyncio import json import logging -import re import sys -from contextlib import asynccontextmanager, contextmanager -from datetime import datetime -from functools import singledispatch, update_wrapper, wraps -from pprint import pformat as pf -from typing import Any, Final, cast +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any import asyncclick as click -from pydantic.v1 import ValidationError - -from kasa import ( - AuthenticationError, - Credentials, - Device, - DeviceConfig, - DeviceConnectionParameters, - DeviceEncryptionType, - DeviceFamily, - Discover, - Feature, - KasaException, - Module, - UnsupportedDeviceError, -) -from kasa.discover import DiscoveryResult -from kasa.iot import ( - IotBulb, - IotDevice, - IotDimmer, - IotLightStrip, - IotPlug, - IotStrip, - IotWallSwitch, -) -from kasa.iot.iotstrip import IotStripPlug -from kasa.iot.modules import Usage -from kasa.smart import SmartDevice - -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.""" - - @wraps(echo_func) - def wrapper(message=None, *args, **kwargs): - if message is not None: - message = rich_formatting.sub("", message) - echo_func(message, *args, **kwargs) - - return wrapper - - _do_echo = _strip_rich_formatting(click.echo) - -# echo is set to _do_echo so that it can be reset to _do_echo later after -# --json has set it to _nop_echo -echo = _do_echo - - -def error(msg: str): - """Print an error and exit.""" - echo(f"[bold red]{msg}[/bold red]") - sys.exit(1) +if TYPE_CHECKING: + from kasa import Device -# Value for optional options if passed without a value -OPTIONAL_VALUE_FLAG: Final = "_FLAG_" +from kasa.deviceconfig import DeviceEncryptionType -TYPE_TO_CLASS = { - "plug": IotPlug, - "switch": IotWallSwitch, - "bulb": IotBulb, - "dimmer": IotDimmer, - "strip": IotStrip, - "lightstrip": IotLightStrip, - "iot.plug": IotPlug, - "iot.switch": IotWallSwitch, - "iot.bulb": IotBulb, - "iot.dimmer": IotDimmer, - "iot.strip": IotStrip, - "iot.lightstrip": IotLightStrip, - "smart.plug": SmartDevice, - "smart.bulb": SmartDevice, -} +from .common import ( + SKIP_UPDATE_COMMANDS, + CatchAllExceptions, + echo, + error, + json_formatter_cb, + pass_dev_or_child, +) +from .lazygroup import LazyGroup + +TYPES = [ + "plug", + "switch", + "bulb", + "dimmer", + "strip", + "lightstrip", + "smart", +] ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] -DEVICE_FAMILY_TYPES = [device_family_type.value for device_family_type in DeviceFamily] - -# Block list of commands which require no update -SKIP_UPDATE_COMMANDS = ["raw-command", "command"] - -pass_dev = click.make_pass_decorator(Device) # type: ignore[type-abstract] - - -def CatchAllExceptions(cls): - """Capture all exceptions and prints them nicely. - - Idea from https://stackoverflow.com/a/44347763 and - https://stackoverflow.com/questions/52213375 - """ - - def _handle_exception(debug, exc): - if isinstance(exc, click.ClickException): - raise - # Handle exit request from click. - if isinstance(exc, click.exceptions.Exit): - sys.exit(exc.exit_code) - - echo(f"Raised error: {exc}") - if debug: - raise - echo("Run with --debug enabled to see stacktrace") - sys.exit(1) - - class _CommandCls(cls): - _debug = False - - async def make_context(self, info_name, args, parent=None, **extra): - self._debug = any( - [arg for arg in args if arg in ["--debug", "-d", "--verbose", "-v"]] - ) - try: - return await super().make_context( - info_name, args, parent=parent, **extra - ) - except Exception as exc: - _handle_exception(self._debug, exc) - - async def invoke(self, ctx): - try: - return await super().invoke(ctx) - except Exception as exc: - _handle_exception(self._debug, exc) - return _CommandCls - - -def json_formatter_cb(result, **kwargs): - """Format and output the result as JSON, if requested.""" - if not kwargs.get("json"): - return - - @singledispatch - def to_serializable(val): - """Regular obj-to-string for json serialization. - - The singledispatch trick is from hynek: https://hynek.me/articles/serialization/ - """ - return str(val) - - @to_serializable.register(Device) - def _device_to_serializable(val: Device): - """Serialize smart device data, just using the last update raw payload.""" - return val.internal_state - - json_content = json.dumps(result, indent=4, default=to_serializable) - print(json_content) - - -def pass_dev_or_child(wrapped_function): - """Pass the device or child to the click command based on the child options.""" - child_help = ( - "Child ID or alias for controlling sub-devices. " - "If no value provided will show an interactive prompt allowing you to " - "select a child." +def _legacy_type_to_class(_type): + from kasa.iot import ( + IotBulb, + IotDimmer, + IotLightStrip, + IotPlug, + IotStrip, + IotWallSwitch, ) - child_index_help = "Child index controlling sub-devices" - - @contextmanager - def patched_device_update(parent: Device, child: Device): - try: - orig_update = child.update - # patch child update method. Can be removed once update can be called - # directly on child devices - child.update = parent.update # type: ignore[method-assign] - yield child - finally: - child.update = orig_update # type: ignore[method-assign] - - @click.pass_obj - @click.pass_context - @click.option( - "--child", - "--name", - is_flag=False, - flag_value=OPTIONAL_VALUE_FLAG, - default=None, - required=False, - type=click.STRING, - help=child_help, - ) - @click.option( - "--child-index", - "--index", - required=False, - default=None, - type=click.INT, - help=child_index_help, - ) - async def wrapper(ctx: click.Context, dev, *args, child, child_index, **kwargs): - if child := await _get_child_device(dev, child, child_index, ctx.info_name): - ctx.obj = ctx.with_resource(patched_device_update(dev, child)) - dev = child - return await ctx.invoke(wrapped_function, dev, *args, **kwargs) - - # Update wrapper function to look like wrapped function - return update_wrapper(wrapper, wrapped_function) - - -async def _get_child_device( - device: Device, child_option, child_index_option, info_command -) -> Device | None: - def _list_children(): - return "\n".join( - [ - f"{idx}: {child.device_id} ({child.alias})" - for idx, child in enumerate(device.children) - ] - ) - - if child_option is None and child_index_option is None: - return None - - if info_command in SKIP_UPDATE_COMMANDS: - # The device hasn't had update called (e.g. for cmd_command) - # The way child devices are accessed requires a ChildDevice to - # wrap the communications. Doing this properly would require creating - # a common interfaces for both IOT and SMART child devices. - # As a stop-gap solution, we perform an update instead. - await device.update() - if not device.children: - error(f"Device: {device.host} does not have children") - - if child_option is not None and child_index_option is not None: - raise click.BadOptionUsage( - "child", "Use either --child or --child-index, not both." - ) - - if child_option is not None: - if child_option is OPTIONAL_VALUE_FLAG: - msg = _list_children() - child_index_option = click.prompt( - f"\n{msg}\nEnter the index number of the child device", - type=click.IntRange(0, len(device.children) - 1), - ) - elif child := device.get_child_device(child_option): - echo(f"Targeting child device {child.alias}") - return child - else: - error( - "No child device found with device_id or name: " - f"{child_option} children are:\n{_list_children()}" - ) - - if child_index_option + 1 > len(device.children) or child_index_option < 0: - error( - f"Invalid index {child_index_option}, " - f"device has {len(device.children)} children" - ) - child_by_index = device.children[child_index_option] - echo(f"Targeting child device {child_by_index.alias}") - return child_by_index + TYPE_TO_CLASS = { + "plug": IotPlug, + "switch": IotWallSwitch, + "bulb": IotBulb, + "dimmer": IotDimmer, + "strip": IotStrip, + "lightstrip": IotLightStrip, + } + return TYPE_TO_CLASS[_type] @click.group( invoke_without_command=True, - cls=CatchAllExceptions(click.Group), + cls=CatchAllExceptions(LazyGroup), + lazy_subcommands={ + "discover": None, + "device": None, + "feature": None, + "light": None, + "wifi": None, + "time": None, + "schedule": None, + "usage": None, + # device commands runnnable at top level + "state": "device", + "on": "device", + "off": "device", + "toggle": "device", + "led": "device", + "alias": "device", + "reboot": "device", + "update_credentials": "device", + "sysinfo": "device", + # light commands runnnable at top level + "presets": "light", + "brightness": "light", + "hsv": "light", + "temperature": "light", + "effect": "light", + }, result_callback=json_formatter_cb, ) @click.option( @@ -332,7 +140,8 @@ def _list_children(): "--type", envvar="KASA_TYPE", default=None, - type=click.Choice(list(TYPE_TO_CLASS), case_sensitive=False), + type=click.Choice(TYPES, case_sensitive=False), + help="The device type in order to bypass discovery. Use `smart` for newer devices", ) @click.option( "--json/--no-json", @@ -352,7 +161,7 @@ def _list_children(): "--device-family", envvar="KASA_DEVICE_FAMILY", default="SMART.TAPOPLUG", - type=click.Choice(DEVICE_FAMILY_TYPES, case_sensitive=False), + help="Device family type, e.g. `SMART.KASASWITCH`. Deprecated use `--type smart`", ) @click.option( "-lv", @@ -360,6 +169,7 @@ def _list_children(): envvar="KASA_LOGIN_VERSION", default=2, type=int, + help="The login version for device authentication. Defaults to 2", ) @click.option( "--timeout", @@ -426,19 +236,6 @@ async def cli( ctx.obj = object() return - # If JSON output is requested, disable echo - global echo - if json: - - def _nop_echo(*args, **kwargs): - pass - - echo = _nop_echo - else: - # Set back to default is required if running tests with CliRunner - global _do_echo - echo = _do_echo - logging_config: dict[str, Any] = { "level": logging.DEBUG if debug > 0 else logging.INFO } @@ -465,6 +262,9 @@ def _nop_echo(*args, **kwargs): if alias is not None and host is None: echo(f"Alias is given, using discovery to find host {alias}") + + from .discover import find_host_from_alias + host = await find_host_from_alias(alias=alias, target=target) if host: echo(f"Found hostname is {host}") @@ -478,6 +278,8 @@ def _nop_echo(*args, **kwargs): ) if username: + from kasa.credentials import Credentials + credentials = Credentials(username=username, password=password) else: credentials = None @@ -487,13 +289,27 @@ def _nop_echo(*args, **kwargs): error("Only discover is available without --host or --alias") echo("No host name given, trying discovery..") + from .discover import discover + return await ctx.invoke(discover) device_updated = False - if type is not None: + if type is not None and type != "smart": + from kasa.deviceconfig import DeviceConfig + config = DeviceConfig(host=host, port_override=port, timeout=timeout) - dev = TYPE_TO_CLASS[type](host, config=config) - elif device_family and encrypt_type: + dev = _legacy_type_to_class(type)(host, config=config) + elif type == "smart" or (device_family and encrypt_type): + from kasa.device import Device + from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, + ) + + if not encrypt_type: + encrypt_type = "KLAP" ctype = DeviceConnectionParameters( DeviceFamily(device_family), DeviceEncryptionType(encrypt_type), @@ -510,6 +326,8 @@ def _nop_echo(*args, **kwargs): dev = await Device.connect(config=config) device_updated = True else: + from kasa.discover import Discover + dev = await Discover.discover_single( host, port=port, @@ -533,307 +351,30 @@ async def async_wrapped_device(device: Device): ctx.obj = await ctx.with_async_resource(async_wrapped_device(dev)) if ctx.invoked_subcommand is None: - return await ctx.invoke(state) - - -@cli.group() -@pass_dev -def wifi(dev): - """Commands to control wifi settings.""" - - -@wifi.command() -@pass_dev -async def scan(dev): - """Scan for available wifi networks.""" - echo("Scanning for wifi networks, wait a second..") - devs = await dev.wifi_scan() - echo(f"Found {len(devs)} wifi networks!") - for dev in devs: - echo(f"\t {dev}") - - return devs - - -@wifi.command() -@click.argument("ssid") -@click.option("--keytype", prompt=True) -@click.option("--password", prompt=True, hide_input=True) -@pass_dev -async def join(dev: Device, ssid: str, password: str, keytype: str): - """Join the given wifi network.""" - echo(f"Asking the device to connect to {ssid}..") - res = await dev.wifi_join(ssid, password, keytype=keytype) - echo( - f"Response: {res} - if the device is not able to join the network, " - f"it will revert back to its previous state." - ) - - return res - - -@cli.command() -@click.pass_context -async def discover(ctx): - """Discover devices in the network.""" - target = ctx.parent.params["target"] - username = ctx.parent.params["username"] - password = ctx.parent.params["password"] - discovery_timeout = ctx.parent.params["discovery_timeout"] - timeout = ctx.parent.params["timeout"] - port = ctx.parent.params["port"] - - credentials = Credentials(username, password) if username and password else None - - sem = asyncio.Semaphore() - discovered = dict() - unsupported = [] - auth_failed = [] - - async def print_unsupported(unsupported_exception: UnsupportedDeviceError): - unsupported.append(unsupported_exception) - async with sem: - if unsupported_exception.discovery_result: - echo("== Unsupported device ==") - _echo_discovery_info(unsupported_exception.discovery_result) - echo() - else: - echo("== Unsupported device ==") - echo(f"\t{unsupported_exception}") - echo() - - echo(f"Discovering devices on {target} for {discovery_timeout} seconds") - - async def print_discovered(dev: Device): - async with sem: - try: - await dev.update() - except AuthenticationError: - auth_failed.append(dev._discovery_info) - echo("== Authentication failed for device ==") - _echo_discovery_info(dev._discovery_info) - echo() - else: - ctx.parent.obj = dev - await ctx.parent.invoke(state) - discovered[dev.host] = dev.internal_state - echo() - - discovered_devices = await Discover.discover( - target=target, - discovery_timeout=discovery_timeout, - on_discovered=print_discovered, - on_unsupported=print_unsupported, - port=port, - timeout=timeout, - credentials=credentials, - ) - - for device in discovered_devices.values(): - await device.protocol.close() + from .device import state - echo(f"Found {len(discovered)} devices") - if unsupported: - echo(f"Found {len(unsupported)} unsupported devices") - if auth_failed: - echo(f"Found {len(auth_failed)} devices that failed to authenticate") - - return discovered - - -def _echo_dictionary(discovery_info: dict): - echo("\t[bold]== Discovery information ==[/bold]") - for key, value in discovery_info.items(): - key_name = " ".join(x.capitalize() or "_" for x in key.split("_")) - key_name_and_spaces = "{:<15}".format(key_name + ":") - echo(f"\t{key_name_and_spaces}{value}") - - -def _echo_discovery_info(discovery_info): - # We don't have discovery info when all connection params are passed manually - if discovery_info is None: - return - - if "system" in discovery_info and "get_sysinfo" in discovery_info["system"]: - _echo_dictionary(discovery_info["system"]["get_sysinfo"]) - return - - try: - dr = DiscoveryResult(**discovery_info) - except ValidationError: - _echo_dictionary(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"\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): - """Discover a device identified by its alias.""" - for _attempt in range(1, attempts): - found_devs = await Discover.discover(target=target, timeout=timeout) - for _ip, dev in found_devs.items(): - if dev.alias.lower() == alias.lower(): - host = dev.host - return host - - return None - - -@cli.command() -@pass_dev_or_child -async def sysinfo(dev): - """Print out full system information.""" - echo("== System info ==") - echo(pf(dev.sys_info)) - return dev.sys_info - - -def _echo_features( - features: dict[str, Feature], - title: str, - category: Feature.Category | None = None, - verbose: bool = False, - indent: str = "\t", -): - """Print out a listing of features and their values.""" - if category is not None: - features = { - id_: feat for id_, feat in features.items() if feat.category == category - } - - echo(f"{indent}[bold]{title}[/bold]") - for _, feat in features.items(): - try: - echo(f"{indent}{feat}") - if verbose: - echo(f"{indent}\tType: {feat.type}") - echo(f"{indent}\tCategory: {feat.category}") - echo(f"{indent}\tIcon: {feat.icon}") - except Exception as ex: - echo(f"{indent}{feat.name} ({feat.id}): [red]got exception ({ex})[/red]") - - -def _echo_all_features(features, *, verbose=False, title_prefix=None, indent=""): - """Print out all features by category.""" - if title_prefix is not None: - echo(f"[bold]\n{indent}== {title_prefix} ==[/bold]") - echo() - _echo_features( - features, - title="== Primary features ==", - category=Feature.Category.Primary, - verbose=verbose, - indent=indent, - ) - echo() - _echo_features( - features, - title="== Information ==", - category=Feature.Category.Info, - verbose=verbose, - indent=indent, - ) - echo() - _echo_features( - features, - title="== Configuration ==", - category=Feature.Category.Config, - verbose=verbose, - indent=indent, - ) - echo() - _echo_features( - features, - title="== Debug ==", - category=Feature.Category.Debug, - verbose=verbose, - indent=indent, - ) - - -@cli.command() -@pass_dev_or_child -@click.pass_context -async def state(ctx, dev: Device): - """Print out device state and versions.""" - verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False - - echo(f"[bold]== {dev.alias} - {dev.model} ==[/bold]") - echo(f"Host: {dev.host}") - echo(f"Port: {dev.port}") - echo(f"Device state: {dev.is_on}") - - echo(f"Time: {dev.time} (tz: {dev.timezone}") - echo(f"Hardware: {dev.hw_info['hw_ver']}") - echo(f"Software: {dev.hw_info['sw_ver']}") - echo(f"MAC (rssi): {dev.mac} ({dev.rssi})") - if verbose: - echo(f"Location: {dev.location}") - - echo() - _echo_all_features(dev.features, verbose=verbose) - - if verbose: - echo("\n[bold]== Modules ==[/bold]") - for module in dev.modules.values(): - echo(f"[green]+ {module}[/green]") - - if dev.children: - echo("\n[bold]== Children ==[/bold]") - for child in dev.children: - _echo_all_features( - child.features, - title_prefix=f"{child.alias} ({child.model})", - verbose=verbose, - indent="\t", - ) - if verbose: - echo(f"\n\t[bold]== Child {child.alias} Modules ==[/bold]") - for module in child.modules.values(): - echo(f"\t[green]+ {module}[/green]") - echo() - - if verbose: - echo("\n\t[bold]== Protocol information ==[/bold]") - echo(f"\tCredentials hash: {dev.credentials_hash}") - echo() - _echo_discovery_info(dev._discovery_info) - - return dev.internal_state + return await ctx.invoke(state) @cli.command() -@click.argument("new_alias", required=False, default=None) @pass_dev_or_child -async def alias(dev, new_alias): - """Get or set the device (or plug) alias.""" - if new_alias is not None: - echo(f"Setting alias to {new_alias}") - res = await dev.set_alias(new_alias) - await dev.update() - echo(f"Alias set to: {dev.alias}") - return res - - echo(f"Alias: {dev.alias}") - if dev.children: - for plug in dev.children: - echo(f" * {plug.alias}") +async def shell(dev: Device): + """Open interactive shell.""" + echo("Opening shell for %s" % dev) + from ptpython.repl import embed - return dev.alias + logging.getLogger("parso").setLevel(logging.WARNING) # prompt parsing + logging.getLogger("asyncio").setLevel(logging.WARNING) + loop = asyncio.get_event_loop() + try: + await embed( # type: ignore[func-returns-value] + globals=globals(), + locals=locals(), + return_asyncio_coroutine=True, + patch_stdout=True, + ) + except EOFError: + loop.stop() @cli.command() @@ -857,6 +398,10 @@ async def cmd_command(dev: Device, module, command, parameters): if parameters is not None: parameters = ast.literal_eval(parameters) + from kasa import KasaException + from kasa.iot import IotDevice + from kasa.smart import SmartDevice + if isinstance(dev, IotDevice): res = await dev._query_helper(module, command, parameters) elif isinstance(dev, SmartDevice): @@ -865,518 +410,3 @@ async def cmd_command(dev: Device, module, command, parameters): raise KasaException("Unexpected device type %s.", dev) echo(json.dumps(res)) return res - - -@cli.command() -@click.option("--index", type=int, required=False) -@click.option("--name", type=str, required=False) -@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) -@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) -@click.option("--erase", is_flag=True) -@click.pass_context -async def emeter(ctx: click.Context, index, name, year, month, erase): - """Query emeter for historical consumption.""" - logging.warning("Deprecated, use 'kasa energy'") - return await ctx.invoke( - energy, child_index=index, child=name, year=year, month=month, erase=erase - ) - - -@cli.command() -@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) -@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) -@click.option("--erase", is_flag=True) -@pass_dev_or_child -async def energy(dev: Device, year, month, erase): - """Query energy module for historical consumption. - - Daily and monthly data provided in CSV format. - """ - echo("[bold]== Emeter ==[/bold]") - if not dev.has_emeter: - error("Device has no emeter") - return - - if (year or month or erase) and not isinstance(dev, IotDevice): - error("Device has no historical statistics") - return - else: - dev = cast(IotDevice, dev) - - if erase: - echo("Erasing emeter statistics..") - return await dev.erase_emeter_stats() - - if year: - echo(f"== For year {year.year} ==") - echo("Month, usage (kWh)") - usage_data = await dev.get_emeter_monthly(year=year.year) - elif month: - echo(f"== For month {month.month} of {month.year} ==") - echo("Day, usage (kWh)") - usage_data = await dev.get_emeter_daily(year=month.year, month=month.month) - else: - # Call with no argument outputs summary data and returns - if isinstance(dev, IotStripPlug): - emeter_status = await dev.get_emeter_realtime() - else: - emeter_status = dev.emeter_realtime - - echo("Current: %s A" % emeter_status["current"]) - echo("Voltage: %s V" % emeter_status["voltage"]) - echo("Power: %s W" % emeter_status["power"]) - echo("Total consumption: %s kWh" % emeter_status["total"]) - - echo("Today: %s kWh" % dev.emeter_today) - echo("This month: %s kWh" % dev.emeter_this_month) - - return emeter_status - - # output any detailed usage data - for index, usage in usage_data.items(): - echo(f"{index}, {usage}") - - return usage_data - - -@cli.command() -@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) -@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) -@click.option("--erase", is_flag=True) -@pass_dev_or_child -async def usage(dev: Device, year, month, erase): - """Query usage for historical consumption. - - Daily and monthly data provided in CSV format. - """ - echo("[bold]== Usage ==[/bold]") - usage = cast(Usage, dev.modules["usage"]) - - if erase: - echo("Erasing usage statistics..") - return await usage.erase_stats() - - if year: - echo(f"== For year {year.year} ==") - echo("Month, usage (minutes)") - usage_data = await usage.get_monthstat(year=year.year) - elif month: - echo(f"== For month {month.month} of {month.year} ==") - echo("Day, usage (minutes)") - usage_data = await usage.get_daystat(year=month.year, month=month.month) - else: - # Call with no argument outputs summary data and returns - echo("Today: %s minutes" % usage.usage_today) - echo("This month: %s minutes" % usage.usage_this_month) - - return usage - - # output any detailed usage data - for index, usage in usage_data.items(): - echo(f"{index}, {usage}") - - return usage_data - - -@cli.command() -@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) -@click.option("--transition", type=int, required=False) -@pass_dev_or_child -async def brightness(dev: Device, brightness: int, transition: int): - """Get or set brightness.""" - if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: - error("This device does not support brightness.") - return - - if brightness is None: - echo(f"Brightness: {light.brightness}") - return light.brightness - else: - echo(f"Setting brightness to {brightness}") - return await light.set_brightness(brightness, transition=transition) - - -@cli.command() -@click.argument( - "temperature", type=click.IntRange(2500, 9000), default=None, required=False -) -@click.option("--transition", type=int, required=False) -@pass_dev_or_child -async def temperature(dev: Device, temperature: int, transition: int): - """Get or set color temperature.""" - if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: - error("Device does not support color temperature") - return - - if temperature is None: - echo(f"Color temperature: {light.color_temp}") - valid_temperature_range = light.valid_temperature_range - if valid_temperature_range != (0, 0): - echo("(min: {}, max: {})".format(*valid_temperature_range)) - else: - echo( - "Temperature range unknown, please open a github issue" - f" or a pull request for model '{dev.model}'" - ) - return light.valid_temperature_range - else: - echo(f"Setting color temperature to {temperature}") - return await light.set_color_temp(temperature, transition=transition) - - -@cli.command() -@click.argument("effect", type=click.STRING, default=None, required=False) -@click.pass_context -@pass_dev_or_child -async def effect(dev: Device, ctx, effect): - """Set an effect.""" - if not (light_effect := dev.modules.get(Module.LightEffect)): - error("Device does not support effects") - return - if effect is None: - echo( - f"Light effect: {light_effect.effect}\n" - + f"Available Effects: {light_effect.effect_list}" - ) - return light_effect.effect - - if effect not in light_effect.effect_list: - raise click.BadArgumentUsage( - f"Effect must be one of: {light_effect.effect_list}", ctx - ) - - echo(f"Setting Effect: {effect}") - return await light_effect.set_effect(effect) - - -@cli.command() -@click.argument("h", type=click.IntRange(0, 360), default=None, required=False) -@click.argument("s", type=click.IntRange(0, 100), default=None, required=False) -@click.argument("v", type=click.IntRange(0, 100), default=None, required=False) -@click.option("--transition", type=int, required=False) -@click.pass_context -@pass_dev_or_child -async def hsv(dev: Device, ctx, h, s, v, transition): - """Get or set color in HSV.""" - if not (light := dev.modules.get(Module.Light)) or not light.is_color: - error("Device does not support colors") - return - - if h is None and s is None and v is None: - echo(f"Current HSV: {light.hsv}") - return light.hsv - elif s is None or v is None: - raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx) - else: - echo(f"Setting HSV: {h} {s} {v}") - return await light.set_hsv(h, s, v, transition=transition) - - -@cli.command() -@click.argument("state", type=bool, required=False) -@pass_dev_or_child -async def led(dev: Device, state): - """Get or set (Plug's) led state.""" - if not (led := dev.modules.get(Module.Led)): - error("Device does not support led.") - return - if state is not None: - echo(f"Turning led to {state}") - return await led.set_led(state) - else: - echo(f"LED state: {led.led}") - return led.led - - -@cli.group(invoke_without_command=True) -@click.pass_context -async def time(ctx: click.Context): - """Get and set time.""" - if ctx.invoked_subcommand is None: - await ctx.invoke(time_get) - - -@time.command(name="get") -@pass_dev -async def time_get(dev: Device): - """Get the device time.""" - res = dev.time - echo(f"Current time: {res}") - return res - - -@time.command(name="sync") -@pass_dev -async def time_sync(dev: Device): - """Set the device time to current time.""" - if not isinstance(dev, SmartDevice): - raise NotImplementedError("setting time currently only implemented on smart") - - if (time := dev.modules.get(Module.Time)) is None: - echo("Device does not have time module") - return - - echo("Old time: %s" % time.time) - - local_tz = datetime.now().astimezone().tzinfo - await time.set_time(datetime.now(tz=local_tz)) - - await dev.update() - echo("New time: %s" % time.time) - - -@cli.command() -@click.option("--transition", type=int, required=False) -@pass_dev_or_child -async def on(dev: Device, transition: int): - """Turn the device on.""" - echo(f"Turning on {dev.alias}") - return await dev.turn_on(transition=transition) - - -@cli.command -@click.option("--transition", type=int, required=False) -@pass_dev_or_child -async def off(dev: Device, transition: int): - """Turn the device off.""" - echo(f"Turning off {dev.alias}") - return await dev.turn_off(transition=transition) - - -@cli.command() -@click.option("--transition", type=int, required=False) -@pass_dev_or_child -async def toggle(dev: Device, transition: int): - """Toggle the device on/off.""" - if dev.is_on: - echo(f"Turning off {dev.alias}") - return await dev.turn_off(transition=transition) - - echo(f"Turning on {dev.alias}") - return await dev.turn_on(transition=transition) - - -@cli.command() -@click.option("--delay", default=1) -@pass_dev -async def reboot(plug, delay): - """Reboot the device.""" - echo("Rebooting the device..") - return await plug.reboot(delay) - - -@cli.group() -@pass_dev -async def schedule(dev): - """Scheduling commands.""" - - -@schedule.command(name="list") -@pass_dev_or_child -@click.argument("type", default="schedule") -async def _schedule_list(dev, type): - """Return the list of schedule actions for the given type.""" - sched = dev.modules[type] - for rule in sched.rules: - print(rule) - else: - error(f"No rules of type {type}") - - return sched.rules - - -@schedule.command(name="delete") -@pass_dev_or_child -@click.option("--id", type=str, required=True) -async def delete_rule(dev, id): - """Delete rule from device.""" - schedule = dev.modules["schedule"] - rule_to_delete = next(filter(lambda rule: (rule.id == id), schedule.rules), None) - if rule_to_delete: - echo(f"Deleting rule id {id}") - return await schedule.delete_rule(rule_to_delete) - else: - error(f"No rule with id {id} was found") - - -@cli.group(invoke_without_command=True) -@pass_dev_or_child -@click.pass_context -async def presets(ctx, dev): - """List and modify bulb setting presets.""" - if ctx.invoked_subcommand is None: - return await ctx.invoke(presets_list) - - -@presets.command(name="list") -@pass_dev_or_child -def presets_list(dev: Device): - """List presets.""" - if not (light_preset := dev.modules.get(Module.LightPreset)): - error("Presets not supported on device") - return - - for preset in light_preset.preset_states_list: - echo(preset) - - return light_preset.preset_states_list - - -@presets.command(name="modify") -@click.argument("index", type=int) -@click.option("--brightness", type=int) -@click.option("--hue", type=int) -@click.option("--saturation", type=int) -@click.option("--temperature", type=int) -@pass_dev_or_child -async def presets_modify(dev: Device, index, brightness, hue, saturation, temperature): - """Modify a preset.""" - for preset in dev.presets: - if preset.index == index: - break - else: - error(f"No preset found for index {index}") - return - - if brightness is not None: - preset.brightness = brightness - if hue is not None: - preset.hue = hue - if saturation is not None: - preset.saturation = saturation - if temperature is not None: - preset.color_temp = temperature - - echo(f"Going to save preset: {preset}") - - return await dev.save_preset(preset) - - -@cli.command() -@pass_dev_or_child -@click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False)) -@click.option("--last", is_flag=True) -@click.option("--preset", type=int) -async def turn_on_behavior(dev: Device, type, last, preset): - """Modify bulb turn-on behavior.""" - if not dev.is_bulb or not isinstance(dev, IotBulb): - error("Presets only supported on iot bulbs") - return - settings = await dev.get_turn_on_behavior() - echo(f"Current turn on behavior: {settings}") - - # Return if we are not setting the value - if not type and not last and not preset: - return settings - - # If we are setting the value, the type has to be specified - if (last or preset) and type is None: - echo("To set the behavior, you need to define --type") - return - - behavior = getattr(settings, type) - - if last: - echo(f"Going to set {type} to last") - behavior.preset = None - elif preset is not None: - echo(f"Going to set {type} to preset {preset}") - behavior.preset = preset - - return await dev.set_turn_on_behavior(settings) - - -@cli.command() -@pass_dev -@click.option( - "--username", required=True, prompt=True, help="New username to set on the device" -) -@click.option( - "--password", required=True, prompt=True, help="New password to set on the device" -) -async def update_credentials(dev, username, password): - """Update device credentials for authenticated devices.""" - if not isinstance(dev, SmartDevice): - error("Credentials can only be updated on authenticated devices.") - - click.confirm("Do you really want to replace the existing credentials?", abort=True) - - return await dev.update_credentials(username, password) - - -@cli.command() -@pass_dev_or_child -async def shell(dev: Device): - """Open interactive shell.""" - echo("Opening shell for %s" % dev) - from ptpython.repl import embed - - logging.getLogger("parso").setLevel(logging.WARNING) # prompt parsing - logging.getLogger("asyncio").setLevel(logging.WARNING) - loop = asyncio.get_event_loop() - try: - await embed( # type: ignore[func-returns-value] - globals=globals(), - locals=locals(), - return_asyncio_coroutine=True, - patch_stdout=True, - ) - except EOFError: - loop.stop() - - -@cli.command(name="feature") -@click.argument("name", required=False) -@click.argument("value", required=False) -@pass_dev_or_child -@click.pass_context -async def feature( - ctx: click.Context, - dev: Device, - name: str, - value, -): - """Access and modify features. - - If no *name* is given, lists available features and their values. - If only *name* is given, the value of named feature is returned. - If both *name* and *value* are set, the described setting is changed. - """ - verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False - - if not name: - _echo_all_features(dev.features, verbose=verbose, indent="") - - if dev.children: - for child_dev in dev.children: - _echo_all_features( - child_dev.features, - verbose=verbose, - title_prefix=f"Child {child_dev.alias}", - indent="\t", - ) - - return - - if name not in dev.features: - error(f"No feature by name '{name}'") - return - - feat = dev.features[name] - - if value is None: - unit = f" {feat.unit}" if feat.unit else "" - echo(f"{feat.name} ({name}): {feat.value}{unit}") - return feat.value - - value = ast.literal_eval(value) - echo(f"Changing {name} from {feat.value} to {value}") - response = await dev.features[name].set_value(value) - await dev.update() - echo(f"New state: {feat.value}") - - return response - - -if __name__ == "__main__": - cli() diff --git a/kasa/cli/schedule.py b/kasa/cli/schedule.py new file mode 100644 index 000000000..8deda3150 --- /dev/null +++ b/kasa/cli/schedule.py @@ -0,0 +1,46 @@ +"""Module for cli schedule commands..""" + +from __future__ import annotations + +import asyncclick as click + +from .common import ( + echo, + error, + pass_dev, + pass_dev_or_child, +) + + +@click.group() +@pass_dev +async def schedule(dev): + """Scheduling commands.""" + + +@schedule.command(name="list") +@pass_dev_or_child +@click.argument("type", default="schedule") +async def _schedule_list(dev, type): + """Return the list of schedule actions for the given type.""" + sched = dev.modules[type] + for rule in sched.rules: + print(rule) + else: + error(f"No rules of type {type}") + + return sched.rules + + +@schedule.command(name="delete") +@pass_dev_or_child +@click.option("--id", type=str, required=True) +async def delete_rule(dev, id): + """Delete rule from device.""" + schedule = dev.modules["schedule"] + rule_to_delete = next(filter(lambda rule: (rule.id == id), schedule.rules), None) + if rule_to_delete: + echo(f"Deleting rule id {id}") + return await schedule.delete_rule(rule_to_delete) + else: + error(f"No rule with id {id} was found") diff --git a/kasa/cli/time.py b/kasa/cli/time.py new file mode 100644 index 000000000..c66812222 --- /dev/null +++ b/kasa/cli/time.py @@ -0,0 +1,55 @@ +"""Module for cli time commands..""" + +from __future__ import annotations + +from datetime import datetime + +import asyncclick as click + +from kasa import ( + Device, + Module, +) +from kasa.smart import SmartDevice + +from .common import ( + echo, + pass_dev, +) + + +@click.group(invoke_without_command=True) +@click.pass_context +async def time(ctx: click.Context): + """Get and set time.""" + if ctx.invoked_subcommand is None: + await ctx.invoke(time_get) + + +@time.command(name="get") +@pass_dev +async def time_get(dev: Device): + """Get the device time.""" + res = dev.time + echo(f"Current time: {res}") + return res + + +@time.command(name="sync") +@pass_dev +async def time_sync(dev: Device): + """Set the device time to current time.""" + if not isinstance(dev, SmartDevice): + raise NotImplementedError("setting time currently only implemented on smart") + + if (time := dev.modules.get(Module.Time)) is None: + echo("Device does not have time module") + return + + echo("Old time: %s" % time.time) + + local_tz = datetime.now().astimezone().tzinfo + await time.set_time(datetime.now(tz=local_tz)) + + await dev.update() + echo("New time: %s" % time.time) diff --git a/kasa/cli/usage.py b/kasa/cli/usage.py new file mode 100644 index 000000000..1a336c743 --- /dev/null +++ b/kasa/cli/usage.py @@ -0,0 +1,134 @@ +"""Module for cli usage commands..""" + +from __future__ import annotations + +import logging +from typing import cast + +import asyncclick as click + +from kasa import ( + Device, +) +from kasa.iot import ( + IotDevice, +) +from kasa.iot.iotstrip import IotStripPlug +from kasa.iot.modules import Usage + +from .common import ( + echo, + error, + pass_dev_or_child, +) + + +@click.command() +@click.option("--index", type=int, required=False) +@click.option("--name", type=str, required=False) +@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) +@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) +@click.option("--erase", is_flag=True) +@click.pass_context +async def emeter(ctx: click.Context, index, name, year, month, erase): + """Query emeter for historical consumption.""" + logging.warning("Deprecated, use 'kasa energy'") + return await ctx.invoke( + energy, child_index=index, child=name, year=year, month=month, erase=erase + ) + + +@click.command() +@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) +@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) +@click.option("--erase", is_flag=True) +@pass_dev_or_child +async def energy(dev: Device, year, month, erase): + """Query energy module for historical consumption. + + Daily and monthly data provided in CSV format. + """ + echo("[bold]== Emeter ==[/bold]") + if not dev.has_emeter: + error("Device has no emeter") + return + + if (year or month or erase) and not isinstance(dev, IotDevice): + error("Device has no historical statistics") + return + else: + dev = cast(IotDevice, dev) + + if erase: + echo("Erasing emeter statistics..") + return await dev.erase_emeter_stats() + + if year: + echo(f"== For year {year.year} ==") + echo("Month, usage (kWh)") + usage_data = await dev.get_emeter_monthly(year=year.year) + elif month: + echo(f"== For month {month.month} of {month.year} ==") + echo("Day, usage (kWh)") + usage_data = await dev.get_emeter_daily(year=month.year, month=month.month) + else: + # Call with no argument outputs summary data and returns + if isinstance(dev, IotStripPlug): + emeter_status = await dev.get_emeter_realtime() + else: + emeter_status = dev.emeter_realtime + + echo("Current: %s A" % emeter_status["current"]) + echo("Voltage: %s V" % emeter_status["voltage"]) + echo("Power: %s W" % emeter_status["power"]) + echo("Total consumption: %s kWh" % emeter_status["total"]) + + echo("Today: %s kWh" % dev.emeter_today) + echo("This month: %s kWh" % dev.emeter_this_month) + + return emeter_status + + # output any detailed usage data + for index, usage in usage_data.items(): + echo(f"{index}, {usage}") + + return usage_data + + +@click.command() +@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) +@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) +@click.option("--erase", is_flag=True) +@pass_dev_or_child +async def usage(dev: Device, year, month, erase): + """Query usage for historical consumption. + + Daily and monthly data provided in CSV format. + """ + echo("[bold]== Usage ==[/bold]") + usage = cast(Usage, dev.modules["usage"]) + + if erase: + echo("Erasing usage statistics..") + return await usage.erase_stats() + + if year: + echo(f"== For year {year.year} ==") + echo("Month, usage (minutes)") + usage_data = await usage.get_monthstat(year=year.year) + elif month: + echo(f"== For month {month.month} of {month.year} ==") + echo("Day, usage (minutes)") + usage_data = await usage.get_daystat(year=month.year, month=month.month) + else: + # Call with no argument outputs summary data and returns + echo("Today: %s minutes" % usage.usage_today) + echo("This month: %s minutes" % usage.usage_this_month) + + return usage + + # output any detailed usage data + for index, usage in usage_data.items(): + echo(f"{index}, {usage}") + + return usage_data diff --git a/kasa/cli/wifi.py b/kasa/cli/wifi.py new file mode 100644 index 000000000..07fb5f207 --- /dev/null +++ b/kasa/cli/wifi.py @@ -0,0 +1,50 @@ +"""Module for cli wifi commands.""" + +from __future__ import annotations + +import asyncclick as click + +from kasa import ( + Device, +) + +from .common import ( + echo, + pass_dev, +) + + +@click.group() +@pass_dev +def wifi(dev): + """Commands to control wifi settings.""" + + +@wifi.command() +@pass_dev +async def scan(dev): + """Scan for available wifi networks.""" + echo("Scanning for wifi networks, wait a second..") + devs = await dev.wifi_scan() + echo(f"Found {len(devs)} wifi networks!") + for dev in devs: + echo(f"\t {dev}") + + return devs + + +@wifi.command() +@click.argument("ssid") +@click.option("--keytype", prompt=True) +@click.option("--password", prompt=True, hide_input=True) +@pass_dev +async def join(dev: Device, ssid: str, password: str, keytype: str): + """Join the given wifi network.""" + echo(f"Asking the device to connect to {ssid}..") + res = await dev.wifi_join(ssid, password, keytype=keytype) + echo( + f"Response: {res} - if the device is not able to join the network, " + f"it will revert back to its previous state." + ) + + return res diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index e6b96cd73..e55f4d016 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -17,29 +17,28 @@ Module, UnsupportedDeviceError, ) -from kasa.cli.main import ( - TYPE_TO_CLASS, +from kasa.cli.device import ( alias, - brightness, - cli, - cmd_command, - effect, - emeter, - energy, - hsv, led, - raw_command, reboot, state, sysinfo, - temperature, - time, toggle, update_credentials, - wifi, ) +from kasa.cli.light import ( + brightness, + effect, + hsv, + temperature, +) +from kasa.cli.main import TYPES, _legacy_type_to_class, cli, cmd_command, raw_command +from kasa.cli.time import time +from kasa.cli.usage import emeter, energy +from kasa.cli.wifi import wifi from kasa.discover import Discover, DiscoveryResult from kasa.iot import IotDevice +from kasa.smart import SmartDevice from .conftest import ( device_smart, @@ -59,6 +58,12 @@ def runner(): return runner +async def test_help(runner): + """Test that all the lazy modules are correctly names.""" + res = await runner.invoke(cli, ["--help"]) + assert res.exit_code == 0, "--help failed, check lazy module names" + + @pytest.mark.parametrize( ("device_family", "encrypt_type"), [ @@ -500,7 +505,7 @@ async def _state(dev: Device): f"Username:{dev.credentials.username} Password:{dev.credentials.password}" ) - mocker.patch("kasa.cli.main.state", new=_state) + mocker.patch("kasa.cli.device.state", new=_state) dr = DiscoveryResult(**discovery_mock.discovery_data["result"]) res = await runner.invoke( @@ -735,7 +740,7 @@ async def test_host_auth_failed(discovery_mock, mocker, runner): assert isinstance(res.exception, AuthenticationError) -@pytest.mark.parametrize("device_type", list(TYPE_TO_CLASS)) +@pytest.mark.parametrize("device_type", TYPES) async def test_type_param(device_type, mocker, runner): """Test for handling only one of username or password supplied.""" result_device = FileNotFoundError @@ -746,8 +751,11 @@ async def _state(dev: Device): nonlocal result_device result_device = dev - mocker.patch("kasa.cli.main.state", new=_state) - expected_type = TYPE_TO_CLASS[device_type] + mocker.patch("kasa.cli.device.state", new=_state) + if device_type == "smart": + expected_type = SmartDevice + else: + expected_type = _legacy_type_to_class(device_type) mocker.patch.object(expected_type, "update") res = await runner.invoke( cli, diff --git a/pyproject.toml b/pyproject.toml index 91317f489..c5c87072c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ include = [ "Documentation" = "https://python-kasa.readthedocs.io" [tool.poetry.scripts] -kasa = "kasa.cli:__main__" +kasa = "kasa.cli.__main__:cli" [tool.poetry.dependencies] python = "^3.9" From dc0aedad20f082aca01cea781fb6533605899ada Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 24 Jul 2024 15:47:38 +0200 Subject: [PATCH 541/892] Expose reboot action (#1073) Expose reboot through the feature interface. This can be useful in situations where one wants to reboot the device, e.g., in recent cases where frequent update calls will render the device unresponsive after a specific amount of time. --- docs/tutorial.py | 2 +- kasa/device.py | 1 + kasa/feature.py | 1 + kasa/iot/iotdevice.py | 12 ++++++++++++ kasa/smart/smartdevice.py | 12 ++++++++++++ 5 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/tutorial.py b/docs/tutorial.py index 7bb3381a3..f2b777b16 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -91,5 +91,5 @@ True >>> for feat in dev.features.values(): >>> print(f"{feat.name}: {feat.value}") -Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: False\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: 1.1.6 Build 240130 Rel.173828\nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nDevice time: 2024-02-23 02:40:15+01:00 +Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nReboot: \nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: False\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: 1.1.6 Build 240130 Rel.173828\nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nDevice time: 2024-02-23 02:40:15+01:00 """ diff --git a/kasa/device.py b/kasa/device.py index 69b7370b0..e07c4853c 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -84,6 +84,7 @@ state rssi on_since +reboot current_consumption consumption_today consumption_this_month diff --git a/kasa/feature.py b/kasa/feature.py index 0ce13d45f..ab73f9913 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -25,6 +25,7 @@ RSSI (rssi): -52 SSID (ssid): #MASKED_SSID# Overheated (overheated): False +Reboot (reboot): Brightness (brightness): 100 Cloud connection (cloud_connection): True HSV (hsv): HSV(hue=0, saturation=100, value=100) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index c637387ae..28ae12281 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -359,6 +359,18 @@ async def _initialize_features(self): ) ) + self._add_feature( + Feature( + device=self, + id="reboot", + name="Reboot", + attribute_setter="reboot", + icon="mdi:restart", + category=Feature.Category.Debug, + type=Feature.Type.Action, + ) + ) + for module in self._supported_modules.values(): module._initialize_features() for module_feat in module._module_features.values(): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 156db4615..fcdbef971 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -439,6 +439,18 @@ async def _initialize_features(self): ) ) + self._add_feature( + Feature( + device=self, + id="reboot", + name="Reboot", + attribute_setter="reboot", + icon="mdi:restart", + category=Feature.Category.Debug, + type=Feature.Type.Action, + ) + ) + for module in self.modules.values(): module._initialize_features() for feat in module._module_features.values(): From 055bbcc0c9b5a87f00e0d3cc68be46fbda18f512 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 24 Jul 2024 15:48:33 +0200 Subject: [PATCH 542/892] Add support for T100 motion sensor (#1079) Add support for T100 motion sensor. Thanks to @DarthSonic for the fixture file! --- README.md | 2 +- SUPPORTED.md | 2 + kasa/module.py | 1 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/motionsensor.py | 36 ++ kasa/tests/device_fixtures.py | 2 +- .../smart/child/T100(EU)_1.0_1.12.0.json | 537 ++++++++++++++++++ kasa/tests/smart/modules/test_motionsensor.py | 28 + 8 files changed, 608 insertions(+), 2 deletions(-) create mode 100644 kasa/smart/modules/motionsensor.py create mode 100644 kasa/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json create mode 100644 kasa/tests/smart/modules/test_motionsensor.py diff --git a/README.md b/README.md index fcc28190d..2533b908e 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ The following devices have been tested and confirmed as working. If your device - **Bulbs**: L510B, L510E, L530E - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Hubs**: H100 -- **Hub-Connected Devices\*\*\***: T110, T300, T310, T315 +- **Hub-Connected Devices\*\*\***: T100, T110, T300, T310, T315 \*   Model requires authentication
diff --git a/SUPPORTED.md b/SUPPORTED.md index a0d301b32..5e6e8553f 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -231,6 +231,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros ### Hub-Connected Devices +- **T100** + - Hardware: 1.0 (EU) / Firmware: 1.12.0 - **T110** - Hardware: 1.0 (EU) / Firmware: 1.8.0 - **T300** diff --git a/kasa/module.py b/kasa/module.py index 69c4e9e21..fe370603c 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -111,6 +111,7 @@ class Module(ABC): LightTransition: Final[ModuleName[smart.LightTransition]] = ModuleName( "LightTransition" ) + MotionSensor: Final[ModuleName[smart.MotionSensor]] = ModuleName("MotionSensor") ReportMode: Final[ModuleName[smart.ReportMode]] = ModuleName("ReportMode") SmartLightEffect: Final[ModuleName[smart.SmartLightEffect]] = ModuleName( "LightEffect" diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index fd9877513..24d5749e6 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -22,6 +22,7 @@ from .lightpreset import LightPreset from .lightstripeffect import LightStripEffect from .lighttransition import LightTransition +from .motionsensor import MotionSensor from .reportmode import ReportMode from .temperaturecontrol import TemperatureControl from .temperaturesensor import TemperatureSensor @@ -54,6 +55,7 @@ "Color", "WaterleakSensor", "ContactSensor", + "MotionSensor", "FrostProtection", "SmartLightEffect", ] diff --git a/kasa/smart/modules/motionsensor.py b/kasa/smart/modules/motionsensor.py new file mode 100644 index 000000000..169b25b61 --- /dev/null +++ b/kasa/smart/modules/motionsensor.py @@ -0,0 +1,36 @@ +"""Implementation of motion sensor module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class MotionSensor(SmartModule): + """Implementation of motion sensor module.""" + + REQUIRED_COMPONENT = "sensitivity" + + def _initialize_features(self): + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="motion_detected", + name="Motion detected", + container=self, + attribute_getter="motion_detected", + icon="mdi:motion-sensor", + category=Feature.Category.Primary, + type=Feature.Type.BinarySensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def motion_detected(self): + """Return True if the motion has been detected.""" + return self._device.sys_info["detected"] diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 1eb3e829b..fca5960aa 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -117,7 +117,7 @@ } HUBS_SMART = {"H100", "KH100"} -SENSORS_SMART = {"T310", "T315", "T300", "T110"} +SENSORS_SMART = {"T310", "T315", "T300", "T100", "T110"} THERMOSTATS_SMART = {"KE100"} WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} diff --git a/kasa/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json b/kasa/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json new file mode 100644 index 000000000..00e46787c --- /dev/null +++ b/kasa/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json @@ -0,0 +1,537 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "sensitivity", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor", + "bind_count": 1, + "category": "subg.trigger.motion-sensor", + "detected": false, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "fw_ver": "1.12.0 Build 230512 Rel.103011", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -118, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1703860126, + "mac": "E4FAC4000000", + "model": "T100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 60, + "rssi": -73, + "signal_level": 2, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.12.0 Build 230512 Rel.103011", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_temp_humidity_records": { + "local_time": 1721645923, + "past24h_humidity": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_humidity_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "motion", + "eventId": "f883b62c-e18f-30ef-883b-62ce18f30ef8", + "id": 28763, + "timestamp": 1721643865 + }, + { + "event": "motion", + "eventId": "c5157545-55d5-157d-4157-54555d5157d4", + "id": 28748, + "timestamp": 1721630821 + }, + { + "event": "motion", + "eventId": "1b587961-edab-08d1-b587-961edab08d1b", + "id": 28746, + "timestamp": 1721629441 + }, + { + "event": "motion", + "eventId": "8ac5e271-3894-c269-bc5e-2713894c269b", + "id": 28738, + "timestamp": 1721622777 + }, + { + "event": "motion", + "eventId": "1ef8037e-c097-bc21-ef80-37ec097bc21e", + "id": 28722, + "timestamp": 1721596432 + } + ], + "start_id": 28763, + "sum": 86 + } +} diff --git a/kasa/tests/smart/modules/test_motionsensor.py b/kasa/tests/smart/modules/test_motionsensor.py new file mode 100644 index 000000000..59fbef68f --- /dev/null +++ b/kasa/tests/smart/modules/test_motionsensor.py @@ -0,0 +1,28 @@ +import pytest + +from kasa import Module, SmartDevice +from kasa.tests.device_fixtures import parametrize + +motion = parametrize( + "is motion sensor", model_filter="T100", protocol_filter={"SMART.CHILD"} +) + + +@motion +@pytest.mark.parametrize( + "feature, type", + [ + ("motion_detected", bool), + ], +) +async def test_motion_features(dev: SmartDevice, feature, type): + """Test that features are registered and work as expected.""" + motion = dev.modules.get(Module.MotionSensor) + assert motion is not None + + prop = getattr(motion, feature) + assert isinstance(prop, type) + + feat = dev.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) From 1c83675e57ce5c3ef9ab610d2aadbfd9bc520dac Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 24 Jul 2024 18:58:37 +0100 Subject: [PATCH 543/892] Fix intermittently failing decryption error test (#1082) --- kasa/tests/test_klapprotocol.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 0565683a1..4a7b3e18f 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -1,5 +1,6 @@ import json import logging +import re import secrets import time from contextlib import nullcontext as does_not_raise @@ -298,7 +299,7 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): with pytest.raises( KasaException, - match="Error trying to decrypt device 127.0.0.1 response: Invalid padding bytes.", + match=re.escape("Error trying to decrypt device 127.0.0.1 response:"), ): await transport.send(json.dumps({})) From 7416e855f1b358aa96e0937d692d861a7d093ed3 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 25 Jul 2024 09:11:48 +0100 Subject: [PATCH 544/892] Fix mypy pre-commit hook on windows (#1081) --- devtools/run-in-env.sh | 18 ++++++++++++++++-- kasa/discover.py | 3 ++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/devtools/run-in-env.sh b/devtools/run-in-env.sh index 3e67c70eb..008d4d289 100755 --- a/devtools/run-in-env.sh +++ b/devtools/run-in-env.sh @@ -1,3 +1,17 @@ -#!/bin/bash -source $(poetry env info --path)/bin/activate +#!/usr/bin/env bash + +OS_KERNEL=$(uname -s) +OS_VER=$(uname -v) +if [[ ( $OS_KERNEL == "Linux" && $OS_VER == *"Microsoft"* ) ]]; then + echo "Pre-commit hook needs git-bash to run. It cannot run in the windows linux subsystem." + echo "Add git bin directory to the front of your path variable, e.g:" + echo "set PATH=C:\Program Files\Git\bin;%PATH%" + exit 1 +fi +if [[ "$(expr substr $OS_KERNEL 1 10)" == "MINGW64_NT" ]]; then + POETRY_PATH=$(poetry.exe env info --path) + source "$POETRY_PATH"\\Scripts\\activate +else + source $(poetry env info --path)/bin/activate +fi exec "$@" diff --git a/kasa/discover.py b/kasa/discover.py index c69933a95..7c1475978 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -209,7 +209,8 @@ def connection_made(self, transport) -> None: except OSError as ex: # WSL does not support SO_REUSEADDR, see #246 _LOGGER.debug("Unable to set SO_REUSEADDR: %s", ex) - if self.interface is not None: + # windows does not support SO_BINDTODEVICE + if self.interface is not None and hasattr(socket, "SO_BINDTODEVICE"): sock.setsockopt( socket.SOL_SOCKET, socket.SO_BINDTODEVICE, self.interface.encode() ) From 91bf9bb73d2e402129b0d8f8c7f384287bc246ff Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sun, 28 Jul 2024 19:41:33 +0100 Subject: [PATCH 545/892] Fix generate_supported pre commit to run in venv (#1085) I noticed after building a new linux instance that running `git commit` when the virtual environment is not active causes the pre-commit to fail, as the `generate_supported` hook is not explicitly configured to run in the virtual env. This PR calls `generate_supported` via the `run-in-env.sh` script. --- .pre-commit-config.yaml | 4 ++-- devtools/run-in-env.sh | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2587eff5c..c3acdb8db 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: - id: mypy name: mypy entry: devtools/run-in-env.sh mypy - language: script + language: system types_or: [python, pyi] require_serial: true exclude: | # exclude required because --all-files passes py and pyi @@ -39,7 +39,7 @@ repos: - id: generate-supported name: Generate supported devices description: This hook generates the supported device sections of README.md and SUPPORTED.md - entry: devtools/generate_supported.py + entry: devtools/run-in-env.sh ./devtools/generate_supported.py language: system # Required or pre-commit creates a new venv verbose: true # Show output on success types: [json] diff --git a/devtools/run-in-env.sh b/devtools/run-in-env.sh index 008d4d289..5efdbc65d 100755 --- a/devtools/run-in-env.sh +++ b/devtools/run-in-env.sh @@ -1,11 +1,15 @@ #!/usr/bin/env bash +# pre-commit by default runs hooks in an isolated environment. +# For some hooks it's needed to run in the virtual environment so this script will activate it. + OS_KERNEL=$(uname -s) OS_VER=$(uname -v) if [[ ( $OS_KERNEL == "Linux" && $OS_VER == *"Microsoft"* ) ]]; then echo "Pre-commit hook needs git-bash to run. It cannot run in the windows linux subsystem." echo "Add git bin directory to the front of your path variable, e.g:" - echo "set PATH=C:\Program Files\Git\bin;%PATH%" + echo "set PATH=C:\Program Files\Git\bin;%PATH% (for CMD prompt)" + echo "\$env:Path = 'C:\Program Files\Git\bin;' + \$env:Path (for Powershell prompt)" exit 1 fi if [[ "$(expr substr $OS_KERNEL 1 10)" == "MINGW64_NT" ]]; then From 60be6e03b7f0840565cf3ae36247f67664a581b1 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 29 Jul 2024 10:51:21 +0100 Subject: [PATCH 546/892] Bump project version to 0.7.0.5 (#1087) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c5c87072c..c7288e101 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.4" +version = "0.7.0.5" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 7bba9926ed89b50cba503e1d50571bf880cc1433 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 30 Jul 2024 19:23:07 +0100 Subject: [PATCH 547/892] Allow erroring modules to recover (#1080) Re-query failed modules after some delay instead of immediately disabling them. Changes to features so they can still be created when modules are erroring. --- kasa/feature.py | 73 ++++++---- kasa/interfaces/energy.py | 12 +- kasa/iot/iotdevice.py | 2 +- kasa/iot/modules/ambientlight.py | 2 +- kasa/iot/modules/light.py | 3 +- kasa/smart/modules/alarm.py | 2 +- kasa/smart/modules/autooff.py | 2 +- kasa/smart/modules/batterysensor.py | 2 +- kasa/smart/modules/brightness.py | 3 +- kasa/smart/modules/cloud.py | 7 - kasa/smart/modules/energy.py | 10 +- kasa/smart/modules/fan.py | 3 +- kasa/smart/modules/humiditysensor.py | 2 +- kasa/smart/modules/lighttransition.py | 4 +- kasa/smart/modules/reportmode.py | 2 +- kasa/smart/modules/temperaturecontrol.py | 3 +- kasa/smart/modules/temperaturesensor.py | 2 +- kasa/smart/smartchilddevice.py | 13 +- kasa/smart/smartdevice.py | 72 +++++----- kasa/smart/smartmodule.py | 53 +++++++ kasa/tests/fakeprotocol_smart.py | 1 + kasa/tests/test_feature.py | 4 +- kasa/tests/test_smartdevice.py | 172 ++++++++++++----------- 23 files changed, 263 insertions(+), 186 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index ab73f9913..18bed554d 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -69,6 +69,7 @@ import logging from dataclasses import dataclass from enum import Enum, auto +from functools import cached_property from typing import TYPE_CHECKING, Any, Callable if TYPE_CHECKING: @@ -142,11 +143,9 @@ class Category(Enum): container: Any = None #: Icon suggestion icon: str | None = None - #: Unit, if applicable - unit: str | None = None #: Attribute containing the name of the unit getter property. - #: If set, this property will be used to set *unit*. - unit_getter: str | None = None + #: If set, this property will be used to get the *unit*. + unit_getter: str | Callable[[], str] | None = None #: Category hint for downstreams category: Feature.Category = Category.Unset @@ -154,38 +153,18 @@ class Category(Enum): #: Hint to help rounding the sensor values to given after-comma digits precision_hint: int | None = None - # Number-specific attributes - #: Minimum value - minimum_value: int = 0 - #: Maximum value - maximum_value: int = DEFAULT_MAX #: Attribute containing the name of the range getter property. #: If set, this property will be used to set *minimum_value* and *maximum_value*. - range_getter: str | None = None + range_getter: str | Callable[[], tuple[int, int]] | None = None - # Choice-specific attributes - #: List of choices as enum - choices: list[str] | None = None #: Attribute name of the choices getter property. - #: If set, this property will be used to set *choices*. - choices_getter: str | None = None + #: If set, this property will be used to get *choices*. + choices_getter: str | Callable[[], list[str]] | None = None def __post_init__(self): """Handle late-binding of members.""" # Populate minimum & maximum values, if range_getter is given - container = self.container if self.container is not None else self.device - if self.range_getter is not None: - self.minimum_value, self.maximum_value = getattr( - container, self.range_getter - ) - - # Populate choices, if choices_getter is given - if self.choices_getter is not None: - self.choices = getattr(container, self.choices_getter) - - # Populate unit, if unit_getter is given - if self.unit_getter is not None: - self.unit = getattr(container, self.unit_getter) + self._container = self.container if self.container is not None else self.device # Set the category, if unset if self.category is Feature.Category.Unset: @@ -208,6 +187,44 @@ def __post_init__(self): f"Read-only feat defines attribute_setter: {self.name} ({self.id}):" ) + def _get_property_value(self, getter): + if getter is None: + return None + if isinstance(getter, str): + return getattr(self._container, getter) + if callable(getter): + return getter() + raise ValueError("Invalid getter: %s", getter) # pragma: no cover + + @property + def choices(self) -> list[str] | None: + """List of choices.""" + return self._get_property_value(self.choices_getter) + + @property + def unit(self) -> str | None: + """Unit if applicable.""" + return self._get_property_value(self.unit_getter) + + @cached_property + def range(self) -> tuple[int, int] | None: + """Range of values if applicable.""" + return self._get_property_value(self.range_getter) + + @cached_property + def maximum_value(self) -> int: + """Maximum value.""" + if range := self.range: + return range[1] + return self.DEFAULT_MAX + + @cached_property + def minimum_value(self) -> int: + """Minimum value.""" + if range := self.range: + return range[0] + return 0 + @property def value(self): """Return the current value.""" diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py index 76859647d..51579322f 100644 --- a/kasa/interfaces/energy.py +++ b/kasa/interfaces/energy.py @@ -40,7 +40,7 @@ def _initialize_features(self): name="Current consumption", attribute_getter="current_consumption", container=self, - unit="W", + unit_getter=lambda: "W", id="current_consumption", precision_hint=1, category=Feature.Category.Primary, @@ -53,7 +53,7 @@ def _initialize_features(self): name="Today's consumption", attribute_getter="consumption_today", container=self, - unit="kWh", + unit_getter=lambda: "kWh", id="consumption_today", precision_hint=3, category=Feature.Category.Info, @@ -67,7 +67,7 @@ def _initialize_features(self): name="This month's consumption", attribute_getter="consumption_this_month", container=self, - unit="kWh", + unit_getter=lambda: "kWh", precision_hint=3, category=Feature.Category.Info, type=Feature.Type.Sensor, @@ -80,7 +80,7 @@ def _initialize_features(self): name="Total consumption since reboot", attribute_getter="consumption_total", container=self, - unit="kWh", + unit_getter=lambda: "kWh", id="consumption_total", precision_hint=3, category=Feature.Category.Info, @@ -94,7 +94,7 @@ def _initialize_features(self): name="Voltage", attribute_getter="voltage", container=self, - unit="V", + unit_getter=lambda: "V", id="voltage", precision_hint=1, category=Feature.Category.Primary, @@ -107,7 +107,7 @@ def _initialize_features(self): name="Current", attribute_getter="current", container=self, - unit="A", + unit_getter=lambda: "A", id="current", precision_hint=2, category=Feature.Category.Primary, diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 28ae12281..234ea9feb 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -340,7 +340,7 @@ async def _initialize_features(self): name="RSSI", attribute_getter="rssi", icon="mdi:signal", - unit="dBm", + unit_getter=lambda: "dBm", category=Feature.Category.Debug, type=Feature.Type.Sensor, ) diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py index d49768ef8..fd693ed52 100644 --- a/kasa/iot/modules/ambientlight.py +++ b/kasa/iot/modules/ambientlight.py @@ -28,7 +28,7 @@ def __init__(self, device, module): attribute_getter="ambientlight_brightness", type=Feature.Type.Sensor, category=Feature.Category.Primary, - unit="%", + unit_getter=lambda: "%", ) ) diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 8c4e22c90..358771a65 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -41,8 +41,7 @@ def _initialize_features(self): container=self, attribute_getter="brightness", attribute_setter="set_brightness", - minimum_value=BRIGHTNESS_MIN, - maximum_value=BRIGHTNESS_MAX, + range_getter=lambda: (BRIGHTNESS_MIN, BRIGHTNESS_MAX), type=Feature.Type.Number, category=Feature.Category.Primary, ) diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index 89f133f54..439bc5716 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -69,7 +69,7 @@ def _initialize_features(self): attribute_setter="set_alarm_volume", category=Feature.Category.Config, type=Feature.Type.Choice, - choices=["low", "normal", "high"], + choices_getter=lambda: ["low", "normal", "high"], ) ) self._add_feature( diff --git a/kasa/smart/modules/autooff.py b/kasa/smart/modules/autooff.py index 5e4b100f8..ae1bb0828 100644 --- a/kasa/smart/modules/autooff.py +++ b/kasa/smart/modules/autooff.py @@ -39,7 +39,7 @@ def _initialize_features(self): attribute_getter="delay", attribute_setter="set_delay", type=Feature.Type.Number, - unit="min", # ha-friendly unit, see UnitOfTime.MINUTES + unit_getter=lambda: "min", # ha-friendly unit, see UnitOfTime.MINUTES ) ) self._add_feature( diff --git a/kasa/smart/modules/batterysensor.py b/kasa/smart/modules/batterysensor.py index 7ff7df2d8..7ecfad20f 100644 --- a/kasa/smart/modules/batterysensor.py +++ b/kasa/smart/modules/batterysensor.py @@ -37,7 +37,7 @@ def _initialize_features(self): container=self, attribute_getter="battery", icon="mdi:battery", - unit="%", + unit_getter=lambda: "%", category=Feature.Category.Info, type=Feature.Type.Sensor, ) diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index f5e6d6d64..f6e5c3229 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -27,8 +27,7 @@ def _initialize_features(self): container=self, attribute_getter="brightness", attribute_setter="set_brightness", - minimum_value=BRIGHTNESS_MIN, - maximum_value=BRIGHTNESS_MAX, + range_getter=lambda: (BRIGHTNESS_MIN, BRIGHTNESS_MAX), type=Feature.Type.Number, category=Feature.Category.Primary, ) diff --git a/kasa/smart/modules/cloud.py b/kasa/smart/modules/cloud.py index e7513a562..e66f18581 100644 --- a/kasa/smart/modules/cloud.py +++ b/kasa/smart/modules/cloud.py @@ -18,13 +18,6 @@ class Cloud(SmartModule): REQUIRED_COMPONENT = "cloud_connect" MINIMUM_UPDATE_INTERVAL_SECS = 60 - def _post_update_hook(self): - """Perform actions after a device update. - - Overrides the default behaviour to disable a module if the query returns - an error because the logic here is to treat that as not connected. - """ - def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) diff --git a/kasa/smart/modules/energy.py b/kasa/smart/modules/energy.py index 3edbddb47..166f688ea 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -5,7 +5,7 @@ from ...emeterstatus import EmeterStatus from ...exceptions import KasaException from ...interfaces.energy import Energy as EnergyInterface -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, raise_if_update_error class Energy(SmartModule, EnergyInterface): @@ -23,6 +23,7 @@ def query(self) -> dict: return req @property + @raise_if_update_error def current_consumption(self) -> float | None: """Current power in watts.""" if (power := self.energy.get("current_power")) is not None: @@ -30,6 +31,7 @@ def current_consumption(self) -> float | None: return None @property + @raise_if_update_error def energy(self): """Return get_energy_usage results.""" if en := self.data.get("get_energy_usage"): @@ -45,6 +47,7 @@ def _get_status_from_energy(self, energy) -> EmeterStatus: ) @property + @raise_if_update_error def status(self): """Get the emeter status.""" return self._get_status_from_energy(self.energy) @@ -55,26 +58,31 @@ async def get_status(self): return self._get_status_from_energy(res["get_energy_usage"]) @property + @raise_if_update_error def consumption_this_month(self) -> float | None: """Get the emeter value for this month in kWh.""" return self.energy.get("month_energy") / 1_000 @property + @raise_if_update_error def consumption_today(self) -> float | None: """Get the emeter value for today in kWh.""" return self.energy.get("today_energy") / 1_000 @property + @raise_if_update_error def consumption_total(self) -> float | None: """Return total consumption since last reboot in kWh.""" return None @property + @raise_if_update_error def current(self) -> float | None: """Return the current in A.""" return None @property + @raise_if_update_error def voltage(self) -> float | None: """Get the current voltage in V.""" return None diff --git a/kasa/smart/modules/fan.py b/kasa/smart/modules/fan.py index 153f9c8f9..245bef2c2 100644 --- a/kasa/smart/modules/fan.py +++ b/kasa/smart/modules/fan.py @@ -30,8 +30,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_setter="set_fan_speed_level", icon="mdi:fan", type=Feature.Type.Number, - minimum_value=0, - maximum_value=4, + range_getter=lambda: (0, 4), category=Feature.Category.Primary, ) ) diff --git a/kasa/smart/modules/humiditysensor.py b/kasa/smart/modules/humiditysensor.py index b137736ff..606b1d548 100644 --- a/kasa/smart/modules/humiditysensor.py +++ b/kasa/smart/modules/humiditysensor.py @@ -27,7 +27,7 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="humidity", icon="mdi:water-percent", - unit="%", + unit_getter=lambda: "%", category=Feature.Category.Primary, type=Feature.Type.Sensor, ) diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index e0aeb4d71..da05995d1 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -73,7 +73,7 @@ def _initialize_features(self): attribute_setter="set_turn_on_transition", icon=icon, type=Feature.Type.Number, - maximum_value=self._turn_on_transition_max, + range_getter=lambda: (0, self._turn_on_transition_max), ) ) self._add_feature( @@ -86,7 +86,7 @@ def _initialize_features(self): attribute_setter="set_turn_off_transition", icon=icon, type=Feature.Type.Number, - maximum_value=self._turn_off_transition_max, + range_getter=lambda: (0, self._turn_off_transition_max), ) ) diff --git a/kasa/smart/modules/reportmode.py b/kasa/smart/modules/reportmode.py index 8d210a5b3..d2c9d929a 100644 --- a/kasa/smart/modules/reportmode.py +++ b/kasa/smart/modules/reportmode.py @@ -26,7 +26,7 @@ def __init__(self, device: SmartDevice, module: str): name="Report interval", container=self, attribute_getter="report_interval", - unit="s", + unit_getter=lambda: "s", category=Feature.Category.Debug, type=Feature.Type.Sensor, ) diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index 00afe5b53..96630ce55 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -51,8 +51,7 @@ def _initialize_features(self): container=self, attribute_getter="temperature_offset", attribute_setter="set_temperature_offset", - minimum_value=-10, - maximum_value=10, + range_getter=lambda: (-10, 10), type=Feature.Type.Number, category=Feature.Category.Config, ) diff --git a/kasa/smart/modules/temperaturesensor.py b/kasa/smart/modules/temperaturesensor.py index a61859cdc..1741b26ba 100644 --- a/kasa/smart/modules/temperaturesensor.py +++ b/kasa/smart/modules/temperaturesensor.py @@ -54,7 +54,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="temperature_unit", attribute_setter="set_temperature_unit", type=Feature.Type.Choice, - choices=["celsius", "fahrenheit"], + choices_getter=lambda: ["celsius", "fahrenheit"], ) ) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 679692baf..8fe3b969c 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -10,6 +10,7 @@ from ..deviceconfig import DeviceConfig from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper from .smartdevice import SmartDevice +from .smartmodule import SmartModule _LOGGER = logging.getLogger(__name__) @@ -49,13 +50,21 @@ async def _update(self, update_children: bool = True): Internal implementation to allow patching of public update in the cli or test framework. """ + now = time.monotonic() + module_queries: list[SmartModule] = [] req: dict[str, Any] = {} for module in self.modules.values(): - if mod_query := module.query(): + if module.disabled is False and (mod_query := module.query()): + module_queries.append(module) req.update(mod_query) if req: self._last_update = await self.protocol.query(req) - self._last_update_time = time.time() + + for module in self.modules.values(): + self._handle_module_post_update( + module, now, had_query=module in module_queries + ) + self._last_update_time = now @classmethod async def create(cls, parent: SmartDevice, child_info, child_components): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index fcdbef971..04a9608a6 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -165,28 +165,25 @@ async def update(self, update_children: bool = False): if first_update: await self._negotiate() await self._initialize_modules() + # Run post update for the cloud module + if cloud_mod := self.modules.get(Module.Cloud): + self._handle_module_post_update(cloud_mod, now, had_query=True) resp = await self._modular_update(first_update, now) + if child_info := self._try_get_response( + self._last_update, "get_child_device_list", {} + ): + for info in child_info["child_device_list"]: + self._children[info["device_id"]]._update_internal_state(info) # Call child update which will only update module calls, info is updated # from get_child_device_list. update_children only affects hub devices, other # devices will always update children to prevent errors on module access. + # This needs to go after updating the internal state of the children so that + # child modules have access to their sysinfo. if update_children or self.device_type != DeviceType.Hub: for child in self._children.values(): await child._update() - if child_info := self._try_get_response( - self._last_update, "get_child_device_list", {} - ): - for info in child_info["child_device_list"]: - self._children[info["device_id"]]._update_internal_state(info) - - for child in self._children.values(): - errors = [] - for child_module_name, child_module in child._modules.items(): - if not self._handle_module_post_update_hook(child_module): - errors.append(child_module_name) - for error in errors: - child._modules.pop(error) # We can first initialize the features after the first update. # We make here an assumption that every device has at least a single feature. @@ -197,18 +194,26 @@ async def update(self, update_children: bool = False): updated = self._last_update if first_update else resp _LOGGER.debug("Update completed %s: %s", self.host, list(updated.keys())) - def _handle_module_post_update_hook(self, module: SmartModule) -> bool: + def _handle_module_post_update( + self, module: SmartModule, update_time: float, had_query: bool + ): + if module.disabled: + return # pragma: no cover + if had_query: + module._last_update_time = update_time try: module._post_update_hook() - return True + module._set_error(None) except Exception as ex: - _LOGGER.warning( - "Error processing %s for device %s, module will be unavailable: %s", - module.name, - self.host, - ex, - ) - return False + # Only set the error if a query happened. + if had_query: + module._set_error(ex) + _LOGGER.warning( + "Error processing %s for device %s, module will be unavailable: %s", + module.name, + self.host, + ex, + ) async def _modular_update( self, first_update: bool, update_time: float @@ -221,17 +226,16 @@ async def _modular_update( mq = { module: query for module in self._modules.values() - if (query := module.query()) + if module.disabled is False and (query := module.query()) } for module, query in mq.items(): if first_update and module.__class__ in FIRST_UPDATE_MODULES: module._last_update_time = update_time continue if ( - not module.MINIMUM_UPDATE_INTERVAL_SECS + not module.update_interval or not module._last_update_time - or (update_time - module._last_update_time) - >= module.MINIMUM_UPDATE_INTERVAL_SECS + or (update_time - module._last_update_time) >= module.update_interval ): module_queries.append(module) req.update(query) @@ -254,16 +258,10 @@ async def _modular_update( self._info = self._try_get_response(info_resp, "get_device_info") # Call handle update for modules that want to update internal data - errors = [] - for module_name, module in self._modules.items(): - if not self._handle_module_post_update_hook(module): - errors.append(module_name) - for error in errors: - self._modules.pop(error) - - # Set the last update time for modules that had queries made. - for module in module_queries: - module._last_update_time = update_time + for module in self._modules.values(): + self._handle_module_post_update( + module, update_time, had_query=module in module_queries + ) return resp @@ -392,7 +390,7 @@ async def _initialize_features(self): name="RSSI", attribute_getter=lambda x: x._info["rssi"], icon="mdi:signal", - unit="dBm", + unit_getter=lambda: "dBm", category=Feature.Category.Debug, type=Feature.Type.Sensor, ) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index f5f2c212a..0e6256a0f 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -18,6 +18,7 @@ _T = TypeVar("_T", bound="SmartModule") _P = ParamSpec("_P") +_R = TypeVar("_R") def allow_update_after( @@ -38,6 +39,17 @@ async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: return _async_wrap +def raise_if_update_error(func: Callable[[_T], _R]) -> Callable[[_T], _R]: + """Define a wrapper to raise an error if the last module update was an error.""" + + def _wrap(self: _T) -> _R: + if err := self._last_update_error: + raise err + return func(self) + + return _wrap + + class SmartModule(Module): """Base class for SMART modules.""" @@ -52,17 +64,58 @@ class SmartModule(Module): REGISTERED_MODULES: dict[str, type[SmartModule]] = {} MINIMUM_UPDATE_INTERVAL_SECS = 0 + UPDATE_INTERVAL_AFTER_ERROR_SECS = 30 + + DISABLE_AFTER_ERROR_COUNT = 10 def __init__(self, device: SmartDevice, module: str): self._device: SmartDevice super().__init__(device, module) self._last_update_time: float | None = None + self._last_update_error: KasaException | None = None + self._error_count = 0 def __init_subclass__(cls, **kwargs): name = getattr(cls, "NAME", cls.__name__) _LOGGER.debug("Registering %s" % cls) cls.REGISTERED_MODULES[name] = cls + def _set_error(self, err: Exception | None): + if err is None: + self._error_count = 0 + self._last_update_error = None + else: + self._last_update_error = KasaException("Module update error", err) + self._error_count += 1 + if self._error_count == self.DISABLE_AFTER_ERROR_COUNT: + _LOGGER.error( + "Error processing %s for device %s, module will be disabled: %s", + self.name, + self._device.host, + err, + ) + if self._error_count > self.DISABLE_AFTER_ERROR_COUNT: + _LOGGER.error( # pragma: no cover + "Unexpected error processing %s for device %s, " + "module should be disabled: %s", + self.name, + self._device.host, + err, + ) + + @property + def update_interval(self) -> int: + """Time to wait between updates.""" + if self._last_update_error is None: + return self.MINIMUM_UPDATE_INTERVAL_SECS + + return self.UPDATE_INTERVAL_AFTER_ERROR_SECS * self._error_count + + @property + def disabled(self) -> bool: + """Return true if the module is disabled due to errors.""" + return self._error_count >= self.DISABLE_AFTER_ERROR_COUNT + @property def name(self) -> str: """Name of the module.""" diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 7a54be170..40465b6f7 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -114,6 +114,7 @@ def credentials_hash(self): }, ), "get_device_usage": ("device", {}), + "get_connect_cloud_state": ("cloud_connect", {"status": 0}), } async def send(self, request: str): diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index 440c9c1b7..fd4008562 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -27,7 +27,7 @@ def dummy_feature() -> Feature: container=None, icon="mdi:dummy", type=Feature.Type.Switch, - unit="dummyunit", + unit_getter=lambda: "dummyunit", ) return feat @@ -127,7 +127,7 @@ async def test_feature_action(mocker): async def test_feature_choice_list(dummy_feature, caplog, mocker: MockerFixture): """Test the choice feature type.""" dummy_feature.type = Feature.Type.Choice - dummy_feature.choices = ["first", "second"] + dummy_feature.choices_getter = lambda: ["first", "second"] mock_setter = mocker.patch.object(dummy_feature.device, "dummysetter", create=True) await dummy_feature.set_value("first") diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 4e6706444..d96542e5e 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -12,8 +12,11 @@ from pytest_mock import MockerFixture from kasa import Device, KasaException, Module -from kasa.exceptions import SmartErrorCode +from kasa.exceptions import DeviceError, SmartErrorCode from kasa.smart import SmartDevice +from kasa.smart.modules.energy import Energy +from kasa.smart.smartmodule import SmartModule +from kasa.smartprotocol import _ChildProtocolWrapper from .conftest import ( device_smart, @@ -139,78 +142,6 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): spies[device].assert_not_called() -@device_smart -async def test_update_module_errors(dev: SmartDevice, mocker: MockerFixture): - """Test that modules that error are disabled / removed.""" - # We need to have some modules initialized by now - assert dev._modules - - critical_modules = {Module.DeviceModule, Module.ChildDevice} - not_disabling_modules = {Module.Cloud} - - new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) - - module_queries = { - modname: q - for modname, module in dev._modules.items() - if (q := module.query()) and modname not in critical_modules - } - child_module_queries = { - modname: q - for child in dev.children - for modname, module in child._modules.items() - if (q := module.query()) and modname not in critical_modules - } - all_queries_names = { - key for mod_query in module_queries.values() for key in mod_query - } - all_child_queries_names = { - key for mod_query in child_module_queries.values() for key in mod_query - } - - async def _query(request, *args, **kwargs): - responses = await dev.protocol._query(request, *args, **kwargs) - for k in responses: - if k in all_queries_names: - responses[k] = SmartErrorCode.PARAMS_ERROR - return responses - - async def _child_query(self, request, *args, **kwargs): - responses = await child_protocols[self._device_id]._query( - request, *args, **kwargs - ) - for k in responses: - if k in all_child_queries_names: - responses[k] = SmartErrorCode.PARAMS_ERROR - return responses - - mocker.patch.object(new_dev.protocol, "query", side_effect=_query) - - from kasa.smartprotocol import _ChildProtocolWrapper - - child_protocols = { - cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol - for child in dev.children - } - # children not created yet so cannot patch.object - mocker.patch("kasa.smartprotocol._ChildProtocolWrapper.query", new=_child_query) - - await new_dev.update() - for modname in module_queries: - no_disable = modname in not_disabling_modules - mod_present = modname in new_dev._modules - assert ( - mod_present is no_disable - ), f"{modname} present {mod_present} when no_disable {no_disable}" - - for modname in child_module_queries: - no_disable = modname in not_disabling_modules - mod_present = any(modname in child._modules for child in new_dev.children) - assert ( - mod_present is no_disable - ), f"{modname} present {mod_present} when no_disable {no_disable}" - - @device_smart async def test_update_module_update_delays( dev: SmartDevice, @@ -218,7 +149,7 @@ async def test_update_module_update_delays( caplog: pytest.LogCaptureFixture, freezer: FrozenDateTimeFactory, ): - """Test that modules that disabled / removed on query failures.""" + """Test that modules with minimum delays delay.""" # We need to have some modules initialized by now assert dev._modules @@ -257,6 +188,20 @@ async def test_update_module_update_delays( pytest.param(False, id="First update false"), ], ) +@pytest.mark.parametrize( + ("error_type"), + [ + pytest.param(SmartErrorCode.PARAMS_ERROR, id="Device error"), + pytest.param(TimeoutError("Dummy timeout"), id="Query error"), + ], +) +@pytest.mark.parametrize( + ("recover"), + [ + pytest.param(True, id="recover"), + pytest.param(False, id="no recover"), + ], +) @device_smart async def test_update_module_query_errors( dev: SmartDevice, @@ -264,15 +209,20 @@ async def test_update_module_query_errors( caplog: pytest.LogCaptureFixture, freezer: FrozenDateTimeFactory, first_update, + error_type, + recover, ): - """Test that modules that disabled / removed on query failures.""" + """Test that modules that disabled / removed on query failures. + + i.e. the whole query times out rather than device returns an error. + """ # We need to have some modules initialized by now assert dev._modules + SmartModule.DISABLE_AFTER_ERROR_COUNT = 2 first_update_queries = {"get_device_info", "get_connect_cloud_state"} critical_modules = {Module.DeviceModule, Module.ChildDevice} - not_disabling_modules = {Module.Cloud} new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) if not first_update: @@ -293,13 +243,18 @@ async def _query(request, *args, **kwargs): or "get_child_device_component_list" in request or "control_child" in request ): - return await dev.protocol._query(request, *args, **kwargs) + resp = await dev.protocol._query(request, *args, **kwargs) + resp["get_connect_cloud_state"] = SmartErrorCode.CLOUD_FAILED_ERROR + return resp + # Don't test for errors on get_device_info as that is likely terminal if len(request) == 1 and "get_device_info" in request: return await dev.protocol._query(request, *args, **kwargs) - raise TimeoutError("Dummy timeout") - - from kasa.smartprotocol import _ChildProtocolWrapper + if isinstance(error_type, SmartErrorCode): + if len(request) == 1: + raise DeviceError("Dummy device error", error_code=error_type) + raise TimeoutError("Dummy timeout") + raise error_type child_protocols = { cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol @@ -314,19 +269,66 @@ async def _child_query(self, request, *args, **kwargs): mocker.patch("kasa.smartprotocol._ChildProtocolWrapper.query", new=_child_query) await new_dev.update() + msg = f"Error querying {new_dev.host} for modules" assert msg in caplog.text for modname in module_queries: - no_disable = modname in not_disabling_modules - mod_present = modname in new_dev._modules - assert ( - mod_present is no_disable - ), f"{modname} present {mod_present} when no_disable {no_disable}" + mod = cast(SmartModule, new_dev.modules[modname]) + assert mod.disabled is False, f"{modname} disabled" + assert mod.update_interval == mod.UPDATE_INTERVAL_AFTER_ERROR_SECS for mod_query in module_queries[modname]: if not first_update or mod_query not in first_update_queries: msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" assert msg in caplog.text + # Query again should not run for the modules + caplog.clear() + await new_dev.update() + for modname in module_queries: + mod = cast(SmartModule, new_dev.modules[modname]) + assert mod.disabled is False, f"{modname} disabled" + + freezer.tick(SmartModule.UPDATE_INTERVAL_AFTER_ERROR_SECS) + + caplog.clear() + + if recover: + mocker.patch.object( + new_dev.protocol, "query", side_effect=new_dev.protocol._query + ) + mocker.patch( + "kasa.smartprotocol._ChildProtocolWrapper.query", + new=_ChildProtocolWrapper._query, + ) + + await new_dev.update() + msg = f"Error querying {new_dev.host} for modules" + if not recover: + assert msg in caplog.text + for modname in module_queries: + mod = cast(SmartModule, new_dev.modules[modname]) + if not recover: + assert mod.disabled is True, f"{modname} not disabled" + assert mod._error_count == 2 + assert mod._last_update_error + for mod_query in module_queries[modname]: + if not first_update or mod_query not in first_update_queries: + msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" + assert msg in caplog.text + # Test one of the raise_if_update_error + if mod.name == "Energy": + emod = cast(Energy, mod) + with pytest.raises(KasaException, match="Module update error"): + assert emod.current_consumption is not None + else: + assert mod.disabled is False + assert mod._error_count == 0 + assert mod._last_update_error is None + # Test one of the raise_if_update_error doesn't raise + if mod.name == "Energy": + emod = cast(Energy, mod) + assert emod.current_consumption is not None + async def test_get_modules(): """Test getting modules for child and parent modules.""" From cb7e904d30c7b3818bb87140f140d78a53be4f08 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 31 Jul 2024 15:52:27 +0100 Subject: [PATCH 548/892] Enable setting brightness with color temp for smart devices (#1091) --- kasa/feature.py | 4 +-- kasa/iot/iotbulb.py | 2 +- kasa/smart/modules/colortemperature.py | 19 +++++----- kasa/smart/modules/light.py | 4 ++- kasa/tests/test_common_modules.py | 48 ++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 15 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index 18bed554d..ad709424d 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -211,14 +211,14 @@ def range(self) -> tuple[int, int] | None: """Range of values if applicable.""" return self._get_property_value(self.range_getter) - @cached_property + @property def maximum_value(self) -> int: """Maximum value.""" if range := self.range: return range[1] return self.DEFAULT_MAX - @cached_property + @property def minimum_value(self) -> int: """Minimum value.""" if range := self.range: diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 26c73096a..81d647e87 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -429,7 +429,7 @@ async def _set_color_temp( if not self._is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") - valid_temperature_range = self.valid_temperature_range + valid_temperature_range = self._valid_temperature_range if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: raise ValueError( "Temperature should be between {} and {}, was {}".format( diff --git a/kasa/smart/modules/colortemperature.py b/kasa/smart/modules/colortemperature.py index fa3b74126..920fa6d2c 100644 --- a/kasa/smart/modules/colortemperature.py +++ b/kasa/smart/modules/colortemperature.py @@ -3,16 +3,11 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING from ...feature import Feature from ...interfaces.light import ColorTempRange from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - - _LOGGER = logging.getLogger(__name__) DEFAULT_TEMP_RANGE = [2500, 6500] @@ -23,11 +18,11 @@ class ColorTemperature(SmartModule): REQUIRED_COMPONENT = "color_temperature" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features.""" self._add_feature( Feature( - device, + self._device, "color_temperature", "Color temperature", container=self, @@ -61,7 +56,7 @@ def color_temp(self): """Return current color temperature.""" return self.data["color_temp"] - async def set_color_temp(self, temp: int): + async def set_color_temp(self, temp: int, *, brightness=None): """Set the color temperature.""" valid_temperature_range = self.valid_temperature_range if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: @@ -70,8 +65,10 @@ async def set_color_temp(self, temp: int): *valid_temperature_range, temp ) ) - - return await self.call("set_device_info", {"color_temp": temp}) + params = {"color_temp": temp} + if brightness: + params["brightness"] = brightness + return await self.call("set_device_info", params) async def _check_supported(self) -> bool: """Check the color_temp_range has more than one value.""" diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py index 0a255bb2a..8e0a37d89 100644 --- a/kasa/smart/modules/light.py +++ b/kasa/smart/modules/light.py @@ -107,7 +107,9 @@ async def set_color_temp( """ if not self.is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") - return await self._device.modules[Module.ColorTemperature].set_color_temp(temp) + return await self._device.modules[Module.ColorTemperature].set_color_temp( + temp, brightness=brightness + ) async def set_brightness( self, brightness: int, *, transition: int | None = None diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index beed8e8ba..114615d4f 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -12,6 +12,7 @@ parametrize, parametrize_combine, plug_iot, + variable_temp_iot, ) led_smart = parametrize( @@ -36,6 +37,14 @@ ) dimmable = parametrize_combine([dimmable_smart, dimmer_iot, dimmable_iot]) +variable_temp_smart = parametrize( + "variable temp smart", + component_filter="color_temperature", + protocol_filter={"SMART"}, +) + +variable_temp = parametrize_combine([variable_temp_iot, variable_temp_smart]) + light_preset_smart = parametrize( "has light preset smart", component_filter="preset", protocol_filter={"SMART"} ) @@ -147,6 +156,45 @@ async def test_light_brightness(dev: Device): await light.set_brightness(feature.maximum_value + 10) +@variable_temp +async def test_light_color_temp(dev: Device): + """Test color temp setter and getter.""" + assert isinstance(dev, Device) + + light = next(get_parent_and_child_modules(dev, Module.Light)) + assert light + if not light.is_variable_color_temp: + pytest.skip( + "Some smart light strips have color_temperature" + " component but min and max are the same" + ) + + # Test getting the value + feature = light._device.features["color_temperature"] + assert isinstance(feature.minimum_value, int) + assert isinstance(feature.maximum_value, int) + + await light.set_color_temp(feature.minimum_value + 10) + await dev.update() + assert light.color_temp == feature.minimum_value + 10 + + # Test setting brightness with color temp + await light.set_brightness(50) + await dev.update() + assert light.brightness == 50 + + await light.set_color_temp(feature.minimum_value + 20, brightness=60) + await dev.update() + assert light.color_temp == feature.minimum_value + 20 + assert light.brightness == 60 + + with pytest.raises(ValueError): + await light.set_color_temp(feature.minimum_value - 10) + + with pytest.raises(ValueError): + await light.set_color_temp(feature.maximum_value + 10) + + @light async def test_light_set_state(dev: Device): """Test brightness setter and getter.""" From cb0077f6349fac6c4429d5c16b9bdb81158e2043 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 31 Jul 2024 15:56:07 +0100 Subject: [PATCH 549/892] Do not send light_on value to iot bulb set_state (#1090) Passing this extra value caused the `ignore_default` check in the `IotBulb._set_light_state` method to fail which causes the device to come back on to the default state. --- kasa/iot/iotbulb.py | 1 + kasa/iot/modules/light.py | 2 ++ kasa/tests/test_bulb.py | 18 +++++++++++++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 81d647e87..97826f2ae 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -326,6 +326,7 @@ async def _set_light_state( self, state: dict, *, transition: int | None = None ) -> dict: """Set the light state.""" + state = {**state} if transition is not None: state["transition_period"] = transition diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 358771a65..c4d6cb09b 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -230,6 +230,8 @@ async def set_state(self, state: LightState) -> dict: state_dict["on_off"] = 1 else: state_dict["on_off"] = int(state.light_on) + # Remove the light_on from the dict + state_dict.pop("light_on", None) return await bulb._set_light_state(state_dict, transition=transition) @property diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index c78c539c9..002cbd419 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -9,7 +9,7 @@ Schema, ) -from kasa import Device, DeviceType, IotLightPreset, KasaException, Module +from kasa import Device, DeviceType, IotLightPreset, KasaException, LightState, Module from kasa.iot import IotBulb, IotDimmer from .conftest import ( @@ -96,6 +96,22 @@ async def test_set_hsv_transition(dev: IotBulb, mocker): ) +@bulb_iot +async def test_light_set_state(dev: IotBulb, mocker): + """Testing setting LightState on the light module.""" + light = dev.modules.get(Module.Light) + assert light + set_light_state = mocker.spy(dev, "_set_light_state") + state = LightState(light_on=True) + await light.set_state(state) + + set_light_state.assert_called_with({"on_off": 1}, transition=None) + state = LightState(light_on=False) + await light.set_state(state) + + set_light_state.assert_called_with({"on_off": 0}, transition=None) + + @color_bulb @turn_on async def test_invalid_hsv(dev: Device, turn_on): From 31ec27c1c875781c5574bc43ccb7aed2338197ec Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 31 Jul 2024 15:58:48 +0100 Subject: [PATCH 550/892] Fix iot light effect brightness (#1092) Fixes issue where the brightness of the `iot` light effect is set properly on the light effect but read back incorrectly from the light. --- kasa/iot/iotbulb.py | 9 +++++- kasa/iot/modules/lighteffect.py | 25 +++++++++++------ kasa/module.py | 1 + kasa/smart/modules/lightstripeffect.py | 13 +++++++-- kasa/tests/fakeprotocol_iot.py | 26 +++++++++++++---- kasa/tests/fakeprotocol_smart.py | 9 +++--- .../smart/modules/test_light_strip_effect.py | 27 ++++++++---------- kasa/tests/test_common_modules.py | 28 +++++++++++++++++++ 8 files changed, 101 insertions(+), 37 deletions(-) diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 97826f2ae..a979e4e62 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -365,7 +365,7 @@ def _hsv(self) -> HSV: hue = light_state["hue"] saturation = light_state["saturation"] - value = light_state["brightness"] + value = self._brightness return HSV(hue, saturation, value) @@ -455,6 +455,13 @@ def _brightness(self) -> int: if not self._is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") + # If the device supports effects and one is active, we get the brightness + # from the effect. This is not required when setting the brightness as + # the device handles it via set_light_state + if ( + light_effect := self.modules.get(Module.IotLightEffect) + ) is not None and light_effect.effect != light_effect.LIGHT_EFFECTS_OFF: + return light_effect.brightness light_state = self.light_state return int(light_state["brightness"]) diff --git a/kasa/iot/modules/lighteffect.py b/kasa/iot/modules/lighteffect.py index 8f855bcf2..3a13f6806 100644 --- a/kasa/iot/modules/lighteffect.py +++ b/kasa/iot/modules/lighteffect.py @@ -3,7 +3,6 @@ from __future__ import annotations from ...interfaces.lighteffect import LightEffect as LightEffectInterface -from ...module import Module from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from ..iotmodule import IotModule @@ -29,6 +28,11 @@ def effect(self) -> str: return self.LIGHT_EFFECTS_OFF + @property + def brightness(self) -> int: + """Return light effect brightness.""" + return self.data["lighting_effect_state"]["brightness"] + @property def effect_list(self) -> list[str]: """Return built-in effects list. @@ -60,18 +64,21 @@ async def set_effect( :param int transition: The wanted transition time """ if effect == self.LIGHT_EFFECTS_OFF: - light_module = self._device.modules[Module.Light] - effect_off_state = light_module.state - if brightness is not None: - effect_off_state.brightness = brightness - if transition is not None: - effect_off_state.transition = transition - await light_module.set_state(effect_off_state) + if self.effect in EFFECT_MAPPING_V1: + # TODO: We could query get_lighting_effect here to + # get the custom effect although not sure how to find + # custom effects + effect_dict = EFFECT_MAPPING_V1[self.effect] + else: + effect_dict = EFFECT_MAPPING_V1["Aurora"] + effect_dict = {**effect_dict} + effect_dict["enable"] = 0 + await self.set_custom_effect(effect_dict) elif effect not in EFFECT_MAPPING_V1: raise ValueError(f"The effect {effect} is not a built in effect.") else: effect_dict = EFFECT_MAPPING_V1[effect] - + effect_dict = {**effect_dict} if brightness is not None: effect_dict["brightness"] = brightness if transition is not None: diff --git a/kasa/module.py b/kasa/module.py index fe370603c..faf17c4d3 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -116,6 +116,7 @@ class Module(ABC): SmartLightEffect: Final[ModuleName[smart.SmartLightEffect]] = ModuleName( "LightEffect" ) + IotLightEffect: Final[ModuleName[iot.LightEffect]] = ModuleName("LightEffect") TemperatureSensor: Final[ModuleName[smart.TemperatureSensor]] = ModuleName( "TemperatureSensor" ) diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index f75620686..3b0ff7da5 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -106,14 +106,23 @@ async def set_effect( """ brightness_module = self._device.modules[Module.Brightness] if effect == self.LIGHT_EFFECTS_OFF: - state = self._device.modules[Module.Light].state - await self._device.modules[Module.Light].set_state(state) + if self.effect in self._effect_mapping: + # TODO: We could query get_lighting_effect here to + # get the custom effect although not sure how to find + # custom effects + effect_dict = self._effect_mapping[self.effect] + else: + effect_dict = self._effect_mapping["Aurora"] + effect_dict = {**effect_dict} + effect_dict["enable"] = 0 + await self.set_custom_effect(effect_dict) return if effect not in self._effect_mapping: raise ValueError(f"The effect {effect} is not a built in effect.") else: effect_dict = self._effect_mapping[effect] + effect_dict = {**effect_dict} # Use explicitly given brightness if brightness is not None: diff --git a/kasa/tests/fakeprotocol_iot.py b/kasa/tests/fakeprotocol_iot.py index 9c5f655c4..0a5433206 100644 --- a/kasa/tests/fakeprotocol_iot.py +++ b/kasa/tests/fakeprotocol_iot.py @@ -292,6 +292,26 @@ def set_lighting_effect(self, effect, *args): self.proto["system"]["get_sysinfo"]["lighting_effect_state"] = dict(effect) def transition_light_state(self, state_changes, *args): + # Setting the light state on a device will turn off any active lighting effects. + # Unless it's just the brightness in which case it will update the brightness for + # the lighting effect + if lighting_effect_state := self.proto["system"]["get_sysinfo"].get( + "lighting_effect_state" + ): + if ( + "hue" in state_changes + or "saturation" in state_changes + or "color_temp" in state_changes + ): + lighting_effect_state["enable"] = 0 + elif ( + lighting_effect_state["enable"] == 1 + and state_changes.get("on_off") != 0 + and (brightness := state_changes.get("brightness")) + ): + lighting_effect_state["brightness"] = brightness + return + _LOGGER.debug("Setting light state to %s", state_changes) light_state = self.proto["system"]["get_sysinfo"]["light_state"] @@ -317,12 +337,6 @@ def transition_light_state(self, state_changes, *args): _LOGGER.debug("New light state: %s", new_state) self.proto["system"]["get_sysinfo"]["light_state"] = new_state - # Setting the light state on a device will turn off any active lighting effects. - if lighting_effect_state := self.proto["system"]["get_sysinfo"].get( - "lighting_effect_state" - ): - lighting_effect_state["enable"] = 0 - def set_preferred_state(self, new_state, *args): """Implement set_preferred_state.""" self.proto["system"]["get_sysinfo"]["preferred_state"][new_state["index"]] = ( diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 40465b6f7..6c9423ecc 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -271,13 +271,14 @@ def _set_edit_dynamic_light_effect_rule(self, info, params): def _set_light_strip_effect(self, info, params): """Set or remove values as per the device behaviour.""" - info["get_device_info"]["lighting_effect"]["enable"] = params["enable"] - info["get_device_info"]["lighting_effect"]["name"] = params["name"] - info["get_device_info"]["lighting_effect"]["id"] = params["id"] # Brightness is not always available if (brightness := params.get("brightness")) is not None: info["get_device_info"]["lighting_effect"]["brightness"] = brightness - info["get_lighting_effect"] = copy.deepcopy(params) + if "enable" in params: + info["get_device_info"]["lighting_effect"]["enable"] = params["enable"] + info["get_device_info"]["lighting_effect"]["name"] = params["name"] + info["get_device_info"]["lighting_effect"]["id"] = params["id"] + info["get_lighting_effect"] = copy.deepcopy(params) def _set_led_info(self, info, params): """Set or remove values as per the device behaviour.""" diff --git a/kasa/tests/smart/modules/test_light_strip_effect.py b/kasa/tests/smart/modules/test_light_strip_effect.py index 92ef2202c..283d294d2 100644 --- a/kasa/tests/smart/modules/test_light_strip_effect.py +++ b/kasa/tests/smart/modules/test_light_strip_effect.py @@ -30,26 +30,23 @@ async def test_light_strip_effect(dev: Device, mocker: MockerFixture): call = mocker.spy(light_effect, "call") - light = dev.modules[Module.Light] - light_call = mocker.spy(light, "call") - assert feature.choices == light_effect.effect_list assert feature.choices for effect in chain(reversed(feature.choices), feature.choices): + if effect == LightEffect.LIGHT_EFFECTS_OFF: + off_effect = ( + light_effect.effect + if light_effect.effect in light_effect._effect_mapping + else "Aurora" + ) await light_effect.set_effect(effect) - if effect == LightEffect.LIGHT_EFFECTS_OFF: - light_call.assert_called() - continue - - # Start with the current effect data - params = light_effect.data["lighting_effect"] - enable = effect != LightEffect.LIGHT_EFFECTS_OFF - params["enable"] = enable - if enable: - params = light_effect._effect_mapping[effect] - params["enable"] = enable - params["brightness"] = brightness.brightness # use the existing brightness + if effect != LightEffect.LIGHT_EFFECTS_OFF: + params = {**light_effect._effect_mapping[effect]} + else: + params = {**light_effect._effect_mapping[off_effect]} + params["enable"] = 0 + params["brightness"] = brightness.brightness # use the existing brightness call.assert_called_with("set_lighting_effect", params) diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index 114615d4f..548e11916 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -133,6 +133,31 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture): call.assert_not_called() +@light_effect +async def test_light_effect_brightness(dev: Device, mocker: MockerFixture): + """Test that light module uses light_effect for brightness when active.""" + light_module = dev.modules[Module.Light] + + light_effect = dev.modules[Module.LightEffect] + + await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF) + await light_module.set_brightness(50) + await dev.update() + assert light_effect.effect == light_effect.LIGHT_EFFECTS_OFF + assert light_module.brightness == 50 + await light_effect.set_effect(light_effect.effect_list[1]) + await dev.update() + # assert light_module.brightness == 100 + + await light_module.set_brightness(75) + await dev.update() + assert light_module.brightness == 75 + + await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF) + await dev.update() + assert light_module.brightness == 50 + + @dimmable async def test_light_brightness(dev: Device): """Test brightness setter and getter.""" @@ -201,6 +226,9 @@ async def test_light_set_state(dev: Device): assert isinstance(dev, Device) light = next(get_parent_and_child_modules(dev, Module.Light)) assert light + # For fixtures that have a light effect active switch off + if light_effect := light._device.modules.get(Module.LightEffect): + await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF) await light.set_state(LightState(light_on=False)) await dev.update() From 6f14330e093775460eed7b700ba86bc9894a6f20 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 31 Jul 2024 17:56:06 +0100 Subject: [PATCH 551/892] Update RELEASING.md for patch releases (#1076) --- RELEASING.md | 187 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 166 insertions(+), 21 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index e42e1c871..a330c002a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,3 +1,5 @@ +# Releasing + ## Requirements * [github client](https://github.com/cli/cli#installation) * [gitchub_changelog_generator](https://github.com/github-changelog-generator) @@ -18,7 +20,9 @@ export NEW_RELEASE=x.x.x.devx export PREVIOUS_RELEASE=0.3.5 ``` -## Create a branch for the release +## Normal releases from master + +### Create a branch for the release ```bash git checkout master @@ -27,35 +31,35 @@ git rebase upstream/master git checkout -b release/$NEW_RELEASE ``` -## Update the version number +### Update the version number ```bash poetry version $NEW_RELEASE ``` -## Update dependencies +### Update dependencies ```bash poetry install --all-extras --sync poetry update ``` -## Run pre-commit and tests +### Run pre-commit and tests ```bash pre-commit run --all-files pytest kasa ``` -## Create release summary (skip for dev releases) +### Create release summary (skip for dev releases) Write a short and understandable summary for the release. Can include images. -### Create $NEW_RELEASE milestone in github +#### Create $NEW_RELEASE milestone in github If not already created -### Create new issue linked to the milestone +#### Create new issue linked to the milestone ```bash gh issue create --label "release-summary" --milestone $NEW_RELEASE --title "$NEW_RELEASE Release Summary" --body "## Release Summary" @@ -63,7 +67,7 @@ gh issue create --label "release-summary" --milestone $NEW_RELEASE --title "$NEW You can exclude the --body option to get an interactive editor or go into the issue on github and edit there. -### Close the issue +#### Close the issue Either via github or: @@ -71,11 +75,11 @@ Either via github or: gh issue close ISSUE_NUMBER ``` -## Generate changelog +### Generate changelog Configuration settings are in `.github_changelog_generator` -### For pre-release +#### For pre-release EXCLUDE_TAGS will exclude all dev tags except for the current release dev tags. @@ -87,7 +91,7 @@ echo "$EXCLUDE_TAGS" github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex "$EXCLUDE_TAGS" ``` -### For production +#### For production ```bash github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex 'dev\d$' @@ -99,28 +103,28 @@ Warning: PR 908 merge commit was not found in the release branch or tagged git h ``` -## Export new release notes to variable +### Export new release notes to variable ```bash export RELEASE_NOTES=$(grep -Poz '(?<=\# Changelog\n\n)(.|\n)+?(?=\#\#)' CHANGELOG.md | tr '\0' '\n' ) echo "$RELEASE_NOTES" # Check the output and copy paste if neccessary ``` -## Commit and push the changed files +### Commit and push the changed files ```bash git commit --all --verbose -m "Prepare $NEW_RELEASE" git push upstream release/$NEW_RELEASE -u ``` -## Create a PR for the release, merge it, and re-fetch the master +### Create a PR for the release, merge it, and re-fetch the master -### Create the PR +#### Create the PR ``` gh pr create --title "Prepare $NEW_RELEASE" --body "$RELEASE_NOTES" --label release-prep --base master ``` -### Merge the PR once the CI passes +#### Merge the PR once the CI passes Create a squash commit and add the markdown from the PR description to the commit description. @@ -136,7 +140,7 @@ git fetch upstream master git rebase upstream/master ``` -## Create a release tag +### Create a release tag Note, add changelog release notes as the tag commit message so `gh release create --notes-from-tag` can be used to create a release draft. @@ -145,21 +149,162 @@ git tag --annotate $NEW_RELEASE -m "$RELEASE_NOTES" git push upstream $NEW_RELEASE ``` -## Create release +### Create release -### Pre-releases +#### Pre-releases ```bash gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=false --prerelease ``` -### Production release +#### Production release ```bash gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=true ``` -## Manually publish the release +### Manually publish the release Go to the linked URL, verify the contents, and click "release" button to trigger the release CI. + +## Patch releases + +This requires git commit signing to be enabled. + +https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification + +### Create release branch + +#### For the first patch release since a new release only + +```bash +export NEW_RELEASE=x.x.x.x +export CURRENT_RELEASE=x.x.x +``` + +```bash +git fetch upstream $CURRENT_RELEASE +git checkout patch +git fetch upstream patch +git rebase upstream/patch +git fetch upstream $CURRENT_RELEASE +git merge $CURRENT_RELEASE --ff-only +git push upstream patch -u +git checkout -b release/$NEW_RELEASE +``` + +#### For subsequent patch releases + +```bash +export NEW_RELEASE=x.x.x.x +``` + +```bash +git checkout patch +git fetch upstream patch +git rebase upstream/patch +git checkout -b release/$NEW_RELEASE +``` +### Cherry pick required commits + +```bash +git cherry-pick commitSHA1 -S +git cherry-pick commitSHA2 -S +``` + +### Update the version number + +```bash +poetry version $NEW_RELEASE +``` + +### Manually edit the changelog + +github_changlog generator_does not work with patch releases so manually add the section for the new release to CHANGELOG.md. + +### Export new release notes to variable + +```bash +export RELEASE_NOTES=$(grep -Poz '(?<=\# Changelog\n\n)(.|\n)+?(?=\#\#)' CHANGELOG.md | tr '\0' '\n' ) +echo "$RELEASE_NOTES" # Check the output and copy paste if neccessary +``` + +### Commit and push the changed files + +```bash +git commit --all --verbose -m "Prepare $NEW_RELEASE" -S +git push upstream release/$NEW_RELEASE -u +``` + +### Create a PR for the release, merge it, and re-fetch patch + +#### Create the PR +``` +gh pr create --title "$NEW_RELEASE" --body "$RELEASE_NOTES" --label release-prep --base patch +``` + +#### Merge the PR once the CI passes + +Create a **merge** commit and add the markdown from the PR description to the commit description. + +```bash +gh pr merge --merge --body "$RELEASE_NOTES" +``` + +### Rebase local patch + +```bash +git checkout patch +git fetch upstream patch +git rebase upstream/patch +``` + +### Create a release tag + +```bash +git tag -s --annotate $NEW_RELEASE -m "$RELEASE_NOTES" +git push upstream $NEW_RELEASE +``` + +### Create release + +```bash +gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=true +``` +Then go into github, review and release + +### Merge patch back to master + +```bash +git checkout master +git fetch upstream master +git rebase upstream/master +git checkout -b janitor/merge_patch +git fetch upstream patch +git merge upstream/patch --no-commit +git diff --name-only --diff-filter=U | xargs git checkout upstream/master +git diff --staged +# The only diff should be the version in pyproject.toml and CHANGELOG.md +# unless a change made on patch that was not part of a cherry-pick commit +# If there are any other unexpected diffs `git checkout upstream/master [thefilename]` +git commit -m "Merge patch into local master" -S +git push upstream janitor/merge_patch -u +gh pr create --title "Merge patch into master" --body '' --label release-prep --base master +``` + +#### Temporarily allow merge commits to master + +1. Open [repository settings](https://github.com/python-kasa/python-kasa/settings) +2. From the left select `Rules` > `Rulesets` +3. Open `master` ruleset, under `Bypass list` select `+ Add bypass` +4. Check `Repository admin` > `Add selected`, select `Save changes` + +#### Merge commit the PR +```bash +gh pr merge --merge --body "" +``` +#### Revert allow merge commits + +1. Under `Bypass list` select `...` next to `Repository admins` +2. `Delete bypass`, select `Save changes` From 145a16db4c0b64d2a02a3b5163789ae3527e7ab4 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 31 Jul 2024 19:02:53 +0100 Subject: [PATCH 552/892] Prepare 0.7.1 (#1094) ## [0.7.1](https://github.com/python-kasa/python-kasa/tree/0.7.1) (2024-07-31) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.5...0.7.1) **Release highlights:** - This release consists mainly of bugfixes and project improvements. - There is also new support for Tapo T100 motion sensors. - The CLI now supports child devices on all applicable commands. **Implemented enhancements:** - Expose reboot action [\#1073](https://github.com/python-kasa/python-kasa/pull/1073) (@rytilahti) - Decrypt KLAP data from PCAP files [\#1041](https://github.com/python-kasa/python-kasa/pull/1041) (@clstrickland) - Support child devices in all applicable cli commands [\#1020](https://github.com/python-kasa/python-kasa/pull/1020) (@sdb9696) **Fixed bugs:** - Fix iot light effect brightness [\#1092](https://github.com/python-kasa/python-kasa/pull/1092) (@sdb9696) - Enable setting brightness with color temp for smart devices [\#1091](https://github.com/python-kasa/python-kasa/pull/1091) (@sdb9696) - Do not send light\_on value to iot bulb set\_state [\#1090](https://github.com/python-kasa/python-kasa/pull/1090) (@sdb9696) - Allow erroring modules to recover [\#1080](https://github.com/python-kasa/python-kasa/pull/1080) (@sdb9696) - Raise KasaException on decryption errors [\#1078](https://github.com/python-kasa/python-kasa/pull/1078) (@sdb9696) - Update smart request parameter handling [\#1061](https://github.com/python-kasa/python-kasa/pull/1061) (@sdb9696) - Fix light preset module when list contains lighting effects [\#1048](https://github.com/python-kasa/python-kasa/pull/1048) (@sdb9696) - Handle module errors more robustly and add query params to light preset and transition [\#1036](https://github.com/python-kasa/python-kasa/pull/1036) (@sdb9696) - Fix credential hash to return None on empty credentials [\#1029](https://github.com/python-kasa/python-kasa/pull/1029) (@sdb9696) **Added support for devices:** - Add support for T100 motion sensor [\#1079](https://github.com/python-kasa/python-kasa/pull/1079) (@rytilahti) **Project maintenance:** - Bump project version to 0.7.0.5 [\#1087](https://github.com/python-kasa/python-kasa/pull/1087) (@sdb9696) - Fix generate\_supported pre commit to run in venv [\#1085](https://github.com/python-kasa/python-kasa/pull/1085) (@sdb9696) - Fix intermittently failing decryption error test [\#1082](https://github.com/python-kasa/python-kasa/pull/1082) (@sdb9696) - Fix mypy pre-commit hook on windows [\#1081](https://github.com/python-kasa/python-kasa/pull/1081) (@sdb9696) - Update RELEASING.md for patch releases [\#1076](https://github.com/python-kasa/python-kasa/pull/1076) (@sdb9696) - Use monotonic time for query timing [\#1070](https://github.com/python-kasa/python-kasa/pull/1070) (@sdb9696) - Fix parse\_pcap\_klap on windows and support default credentials [\#1068](https://github.com/python-kasa/python-kasa/pull/1068) (@sdb9696) - Add fixture file for KP405 fw 1.0.6 [\#1063](https://github.com/python-kasa/python-kasa/pull/1063) (@daleye) - Bump project version to 0.7.0.3 [\#1053](https://github.com/python-kasa/python-kasa/pull/1053) (@sdb9696) - Add KP400\(US\) v1.0.4 fixture [\#1051](https://github.com/python-kasa/python-kasa/pull/1051) (@gimpy88) - Add new HS220 kasa aes fixture [\#1050](https://github.com/python-kasa/python-kasa/pull/1050) (@sdb9696) - Add KS205\(US\) v1.1.0 fixture [\#1049](https://github.com/python-kasa/python-kasa/pull/1049) (@gimpy88) - Add KS200M\(US\) v1.0.11 fixture [\#1047](https://github.com/python-kasa/python-kasa/pull/1047) (@sdb9696) - Add KS225\(US\) v1.1.0 fixture [\#1046](https://github.com/python-kasa/python-kasa/pull/1046) (@sdb9696) - Split out main cli module into lazily loaded submodules [\#1039](https://github.com/python-kasa/python-kasa/pull/1039) (@sdb9696) - Structure cli into a package [\#1038](https://github.com/python-kasa/python-kasa/pull/1038) (@sdb9696) - Add KP400 v1.0.3 fixture [\#1037](https://github.com/python-kasa/python-kasa/pull/1037) (@gimpy88) - Add L920\(EU\) v1.1.3 fixture [\#1031](https://github.com/python-kasa/python-kasa/pull/1031) (@rytilahti) - Update changelog generator config [\#1030](https://github.com/python-kasa/python-kasa/pull/1030) (@sdb9696) --- CHANGELOG.md | 207 ++++++++----- poetry.lock | 807 +++++++++++++++++++++++++------------------------ pyproject.toml | 2 +- 3 files changed, 554 insertions(+), 462 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ab6cd7a..314b2985a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## [0.7.1](https://github.com/python-kasa/python-kasa/tree/0.7.1) (2024-07-31) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.5...0.7.1) + +**Release highlights:** +- This release consists mainly of bugfixes and project improvements. +- There is also new support for Tapo T100 motion sensors. +- The CLI now supports child devices on all applicable commands. + +**Implemented enhancements:** + +- Expose reboot action [\#1073](https://github.com/python-kasa/python-kasa/pull/1073) (@rytilahti) +- Decrypt KLAP data from PCAP files [\#1041](https://github.com/python-kasa/python-kasa/pull/1041) (@clstrickland) +- Support child devices in all applicable cli commands [\#1020](https://github.com/python-kasa/python-kasa/pull/1020) (@sdb9696) + +**Fixed bugs:** + +- Fix iot light effect brightness [\#1092](https://github.com/python-kasa/python-kasa/pull/1092) (@sdb9696) +- Enable setting brightness with color temp for smart devices [\#1091](https://github.com/python-kasa/python-kasa/pull/1091) (@sdb9696) +- Do not send light\_on value to iot bulb set\_state [\#1090](https://github.com/python-kasa/python-kasa/pull/1090) (@sdb9696) +- Allow erroring modules to recover [\#1080](https://github.com/python-kasa/python-kasa/pull/1080) (@sdb9696) +- Raise KasaException on decryption errors [\#1078](https://github.com/python-kasa/python-kasa/pull/1078) (@sdb9696) +- Update smart request parameter handling [\#1061](https://github.com/python-kasa/python-kasa/pull/1061) (@sdb9696) +- Fix light preset module when list contains lighting effects [\#1048](https://github.com/python-kasa/python-kasa/pull/1048) (@sdb9696) +- Handle module errors more robustly and add query params to light preset and transition [\#1036](https://github.com/python-kasa/python-kasa/pull/1036) (@sdb9696) +- Fix credential hash to return None on empty credentials [\#1029](https://github.com/python-kasa/python-kasa/pull/1029) (@sdb9696) + +**Added support for devices:** + +- Add support for T100 motion sensor [\#1079](https://github.com/python-kasa/python-kasa/pull/1079) (@rytilahti) + +**Project maintenance:** + +- Bump project version to 0.7.0.5 [\#1087](https://github.com/python-kasa/python-kasa/pull/1087) (@sdb9696) +- Fix generate\_supported pre commit to run in venv [\#1085](https://github.com/python-kasa/python-kasa/pull/1085) (@sdb9696) +- Fix intermittently failing decryption error test [\#1082](https://github.com/python-kasa/python-kasa/pull/1082) (@sdb9696) +- Fix mypy pre-commit hook on windows [\#1081](https://github.com/python-kasa/python-kasa/pull/1081) (@sdb9696) +- Update RELEASING.md for patch releases [\#1076](https://github.com/python-kasa/python-kasa/pull/1076) (@sdb9696) +- Use monotonic time for query timing [\#1070](https://github.com/python-kasa/python-kasa/pull/1070) (@sdb9696) +- Fix parse\_pcap\_klap on windows and support default credentials [\#1068](https://github.com/python-kasa/python-kasa/pull/1068) (@sdb9696) +- Add fixture file for KP405 fw 1.0.6 [\#1063](https://github.com/python-kasa/python-kasa/pull/1063) (@daleye) +- Bump project version to 0.7.0.3 [\#1053](https://github.com/python-kasa/python-kasa/pull/1053) (@sdb9696) +- Add KP400\(US\) v1.0.4 fixture [\#1051](https://github.com/python-kasa/python-kasa/pull/1051) (@gimpy88) +- Add new HS220 kasa aes fixture [\#1050](https://github.com/python-kasa/python-kasa/pull/1050) (@sdb9696) +- Add KS205\(US\) v1.1.0 fixture [\#1049](https://github.com/python-kasa/python-kasa/pull/1049) (@gimpy88) +- Add KS200M\(US\) v1.0.11 fixture [\#1047](https://github.com/python-kasa/python-kasa/pull/1047) (@sdb9696) +- Add KS225\(US\) v1.1.0 fixture [\#1046](https://github.com/python-kasa/python-kasa/pull/1046) (@sdb9696) +- Split out main cli module into lazily loaded submodules [\#1039](https://github.com/python-kasa/python-kasa/pull/1039) (@sdb9696) +- Structure cli into a package [\#1038](https://github.com/python-kasa/python-kasa/pull/1038) (@sdb9696) +- Add KP400 v1.0.3 fixture [\#1037](https://github.com/python-kasa/python-kasa/pull/1037) (@gimpy88) +- Add L920\(EU\) v1.1.3 fixture [\#1031](https://github.com/python-kasa/python-kasa/pull/1031) (@rytilahti) +- Update changelog generator config [\#1030](https://github.com/python-kasa/python-kasa/pull/1030) (@sdb9696) + ## [0.7.0.5](https://github.com/python-kasa/python-kasa/tree/0.7.0.5) (2024-07-18) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.4...0.7.0.5) @@ -8,33 +61,45 @@ A critical bugfix for an issue with some L530 Series devices and a redactor for **Fixed bugs:** -- Only refresh smart LightEffect module daily [\#1064](https://github.com/python-kasa/python-kasa/pull/1064) +- Only refresh smart LightEffect module daily [\#1064](https://github.com/python-kasa/python-kasa/pull/1064) (@sdb9696) **Project maintenance:** -- Redact sensitive info from debug logs [\#1069](https://github.com/python-kasa/python-kasa/pull/1069) +- Redact sensitive info from debug logs [\#1069](https://github.com/python-kasa/python-kasa/pull/1069) (@sdb9696) ## [0.7.0.4](https://github.com/python-kasa/python-kasa/tree/0.7.0.4) (2024-07-11) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.3...0.7.0.4) -Critical bugfixes for issues with P100s and thermostats. +Critical bugfixes for issues with P100s and thermostats + **Fixed bugs:** -- Use first known thermostat state as main state (pick #1054) [\#1057](https://github.com/python-kasa/python-kasa/pull/1057) -- Defer module updates for less volatile modules (pick 1052) [\#1056](https://github.com/python-kasa/python-kasa/pull/1056) +- Error connecting to L920-5 Smart LED Strip [\#1040](https://github.com/python-kasa/python-kasa/issues/1040) +- Use first known thermostat state as main state \(pick \#1054\) [\#1057](https://github.com/python-kasa/python-kasa/pull/1057) (@sdb9696) +- Defer module updates for less volatile modules \(pick 1052\) [\#1056](https://github.com/python-kasa/python-kasa/pull/1056) (@sdb9696) +- Use first known thermostat state as main state [\#1054](https://github.com/python-kasa/python-kasa/pull/1054) (@rytilahti) +- Defer module updates for less volatile modules [\#1052](https://github.com/python-kasa/python-kasa/pull/1052) (@sdb9696) ## [0.7.0.3](https://github.com/python-kasa/python-kasa/tree/0.7.0.3) (2024-07-04) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.2...0.7.0.3) -Critical bugfix for issue #1033 with ks225 and S505D light preset module errors. +Critical bugfix for issue #1033 with ks225 and S505D light preset module errors. Partially fixes light preset module errors with L920 and L930. **Fixed bugs:** -- Handle module errors more robustly and add query params to light preset and transition [\#1043](https://github.com/python-kasa/python-kasa/pull/1043) +- Handle module errors more robustly and add query params to light preset and transition [\#1043](https://github.com/python-kasa/python-kasa/pull/1043) (@sdb9696) + +**Documentation updates:** + +- Misleading usage of asyncio.run\(\) in code examples [\#348](https://github.com/python-kasa/python-kasa/issues/348) + +**Project maintenance:** + +- Enable CI on the patch branch [\#1042](https://github.com/python-kasa/python-kasa/pull/1042) (@sdb9696) ## [0.7.0.2](https://github.com/python-kasa/python-kasa/tree/0.7.0.2) (2024-07-01) @@ -76,25 +141,25 @@ This patch release fixes some minor issues found out during testing against all [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2.1...0.7.0) -We have been working hard behind the scenes to make this major release possible. -This release brings a major refactoring of the library to serve the ever-growing list of supported devices and paves the way for the future, yet unsupported devices. -The library now exposes device features through generic module and feature interfaces, that allows easy extension for future improvements. - -With almost 180 merged pull requests, over 200 changed files and since the last release, this release includes lots of goodies for everyone: -* Support for multi-functional devices like the dimmable fan KS240. -* Initial support for hubs and hub-connected devices like thermostats and sensors. -* Both IOT (legacy kasa) and SMART (tapo and newer kasa) devices now expose features and share common API. -* Modules to allow controlling new devices and functions such as light presets, fan controls, thermostats, humidity sensors, firmware updates and alarms. -* The common APIs allow dynamic introspection of available device features, making it easy to create dynamic interfaces. -* Improved documentation. - -Hope you enjoy the release, feel free to leave a comment and feedback! - -If you have a device that works, but is not listed in our supported devices list, feel free to [contribute fixture files](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) to help us to make the library even better! - -> git diff 0.6.2.1..HEAD|diffstat -> 214 files changed, 26960 insertions(+), 6310 deletions(-) - +We have been working hard behind the scenes to make this major release possible. +This release brings a major refactoring of the library to serve the ever-growing list of supported devices and paves the way for the future, yet unsupported devices. +The library now exposes device features through generic module and feature interfaces, that allows easy extension for future improvements. + +With almost 180 merged pull requests, over 200 changed files and since the last release, this release includes lots of goodies for everyone: +* Support for multi-functional devices like the dimmable fan KS240. +* Initial support for hubs and hub-connected devices like thermostats and sensors. +* Both IOT (legacy kasa) and SMART (tapo and newer kasa) devices now expose features and share common API. +* Modules to allow controlling new devices and functions such as light presets, fan controls, thermostats, humidity sensors, firmware updates and alarms. +* The common APIs allow dynamic introspection of available device features, making it easy to create dynamic interfaces. +* Improved documentation. + +Hope you enjoy the release, feel free to leave a comment and feedback! + +If you have a device that works, but is not listed in our supported devices list, feel free to [contribute fixture files](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) to help us to make the library even better! + +> git diff 0.6.2.1..HEAD|diffstat +> 214 files changed, 26960 insertions(+), 6310 deletions(-) + For more information on the changes please checkout our [documentation on the API changes](https://python-kasa.readthedocs.io/en/latest/deprecated.html) **Breaking changes:** @@ -326,8 +391,8 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.1...0.6.2) -Release highlights: -* Support for tapo power strips (P300) +Release highlights: +* Support for tapo power strips (P300) * Performance improvements and bug fixes **Implemented enhancements:** @@ -366,9 +431,9 @@ Release highlights: [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 +Release highlights: +* Support for tapo wall switches +* Support for unprovisioned devices * Performance and stability improvements **Implemented enhancements:** @@ -441,17 +506,17 @@ A patch release to improve the protocol handling. [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.4...0.6.0) -This major brings major changes to the library by adding support for devices that require authentication for communications, all of this being possible thanks to the great work by @sdb9696! - -This release adds support to a large range of previously unsupported devices, including: - -* Newer kasa-branded devices, including Matter-enabled devices like KP125M -* Newer hardware/firmware versions on some models, like EP25, that suddenly changed the used protocol -* Tapo-branded devices like plugs (P110), light bulbs (KL530), LED strips (L900, L920), and wall switches (KS205, KS225) -* UK variant of HS110, which was the first device using the new protocol - -If your device that is not currently listed as supported is working, please consider contributing a test fixture file. - +This major brings major changes to the library by adding support for devices that require authentication for communications, all of this being possible thanks to the great work by @sdb9696! + +This release adds support to a large range of previously unsupported devices, including: + +* Newer kasa-branded devices, including Matter-enabled devices like KP125M +* Newer hardware/firmware versions on some models, like EP25, that suddenly changed the used protocol +* Tapo-branded devices like plugs (P110), light bulbs (KL530), LED strips (L900, L920), and wall switches (KS205, KS225) +* UK variant of HS110, which was the first device using the new protocol + +If your device that is not currently listed as supported is working, please consider contributing a test fixture file. + Special thanks goes to @SimonWilkinson who created the initial PR for the new communication protocol! **Breaking changes:** @@ -546,13 +611,13 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.3...0.5.4) -The highlights of this maintenance release: - -* Support to the alternative discovery protocol and foundational work to support other communication protocols, thanks to @sdb9696. -* Reliability improvements by avoiding overflowing device buffers, thanks to @cobryan05. -* Optimizations for downstream device accesses, thanks to @bdraco. -* Support for both pydantic v1 and v2. - +The highlights of this maintenance release: + +* Support to the alternative discovery protocol and foundational work to support other communication protocols, thanks to @sdb9696. +* Reliability improvements by avoiding overflowing device buffers, thanks to @cobryan05. +* Optimizations for downstream device accesses, thanks to @bdraco. +* Support for both pydantic v1 and v2. + As always, see the full changelog for details. **Implemented enhancements:** @@ -612,8 +677,8 @@ This release adds support for defining the device port and introduces dependency [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.1...0.5.2) -Besides some small improvements, this release: -* Adds optional dependency for for `orjson` and `kasa-crypt` to speed-up protocol handling by an order of magnitude. +Besides some small improvements, this release: +* Adds optional dependency for for `orjson` and `kasa-crypt` to speed-up protocol handling by an order of magnitude. * Drops Python 3.7 support as it is no longer maintained. **Breaking changes:** @@ -648,11 +713,11 @@ Besides some small improvements, this release: [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.0...0.5.1) -This minor release contains mostly small UX fine-tuning and documentation improvements alongside with bug fixes: -* Improved console tool (JSON output, colorized output if rich is installed) -* Pretty, colorized console output, if `rich` is installed -* Support for configuring bulb presets -* Usage data is now reported in the expected format +This minor release contains mostly small UX fine-tuning and documentation improvements alongside with bug fixes: +* Improved console tool (JSON output, colorized output if rich is installed) +* Pretty, colorized console output, if `rich` is installed +* Support for configuring bulb presets +* Usage data is now reported in the expected format * Dependency pinning is relaxed to give downstreams more control **Breaking changes:** @@ -716,21 +781,21 @@ This minor release contains mostly small UX fine-tuning and documentation improv [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.3...0.5.0) -This is the first release of 0.5 series which includes converting the code base towards more modular approach where device-exposed modules (e.g., emeter, antitheft, or schedule) are implemented in their separate python modules to decouple them from the device-specific classes. - -There should be no API breaking changes, but some previous issues hint that there may be as information from all supported modules are now requested during each update cycle (depending on the device type): -* Basic system info -* Emeter -* Time - properties (like `on_since`) use now time from the device for calculation to avoid jitter caused by different time between the host and the device -* Usage statistics - similar interface to emeter, but reports on-time statistics instead of energy consumption (new) -* Countdown (new) -* Antitheft (new) -* Schedule (new) -* Motion - for configuring motion settings on some dimmers (new) -* Ambientlight - for configuring brightness limits when motion sensor actuates on some dimmers (new) -* Cloud - information about cloud connectivity (new) - -For developers, the new functionalities are currently only exposed through the implementation modules accessible through `modules` property. +This is the first release of 0.5 series which includes converting the code base towards more modular approach where device-exposed modules (e.g., emeter, antitheft, or schedule) are implemented in their separate python modules to decouple them from the device-specific classes. + +There should be no API breaking changes, but some previous issues hint that there may be as information from all supported modules are now requested during each update cycle (depending on the device type): +* Basic system info +* Emeter +* Time - properties (like `on_since`) use now time from the device for calculation to avoid jitter caused by different time between the host and the device +* Usage statistics - similar interface to emeter, but reports on-time statistics instead of energy consumption (new) +* Countdown (new) +* Antitheft (new) +* Schedule (new) +* Motion - for configuring motion settings on some dimmers (new) +* Ambientlight - for configuring brightness limits when motion sensor actuates on some dimmers (new) +* Cloud - information about cloud connectivity (new) + +For developers, the new functionalities are currently only exposed through the implementation modules accessible through `modules` property. Pull requests improving the functionality of modules as well as adding better interfaces to device classes are welcome! **Breaking changes:** diff --git a/poetry.lock b/poetry.lock index b6511e147..8e9667263 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,91 +1,103 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "aiohappyeyeballs" +version = "2.3.2" +description = "Happy Eyeballs" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "aiohappyeyeballs-2.3.2-py3-none-any.whl", hash = "sha256:903282fb08c8cfb3de356fd546b263248a477c99cb147e20a115e14ab942a4ae"}, + {file = "aiohappyeyeballs-2.3.2.tar.gz", hash = "sha256:77e15a733090547a1f5369a1287ddfc944bd30df0eb8993f585259c34b405f4e"}, +] [[package]] name = "aiohttp" -version = "3.9.5" +version = "3.10.0" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" files = [ - {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"}, - {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"}, - {file = "aiohttp-3.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae79c1bc12c34082d92bf9422764f799aee4746fd7a392db46b7fd357d4a17a"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d3ebb9e1316ec74277d19c5f482f98cc65a73ccd5430540d6d11682cd857430"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84dabd95154f43a2ea80deffec9cb44d2e301e38a0c9d331cc4aa0166fe28ae3"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a02fbeca6f63cb1f0475c799679057fc9268b77075ab7cf3f1c600e81dd46b"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:714d4e5231fed4ba2762ed489b4aec07b2b9953cf4ee31e9871caac895a839c0"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7a6a8354f1b62e15d48e04350f13e726fa08b62c3d7b8401c0a1314f02e3558"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c413016880e03e69d166efb5a1a95d40f83d5a3a648d16486592c49ffb76d0db"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ad7f2919d7dac062f24d6f5fe95d401597fbb015a25771f85e692d043c9d7832"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:702e2c7c187c1a498a4e2b03155d52658fdd6fda882d3d7fbb891a5cf108bb10"}, - {file = "aiohttp-3.9.5-cp310-cp310-win32.whl", hash = "sha256:67c3119f5ddc7261d47163ed86d760ddf0e625cd6246b4ed852e82159617b5fb"}, - {file = "aiohttp-3.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:471f0ef53ccedec9995287f02caf0c068732f026455f07db3f01a46e49d76bbb"}, - {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ae53e33ee7476dd3d1132f932eeb39bf6125083820049d06edcdca4381f342"}, - {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c088c4d70d21f8ca5c0b8b5403fe84a7bc8e024161febdd4ef04575ef35d474d"}, - {file = "aiohttp-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:639d0042b7670222f33b0028de6b4e2fad6451462ce7df2af8aee37dcac55424"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26383adb94da5e7fb388d441bf09c61e5e35f455a3217bfd790c6b6bc64b2ee"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66331d00fb28dc90aa606d9a54304af76b335ae204d1836f65797d6fe27f1ca2"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff550491f5492ab5ed3533e76b8567f4b37bd2995e780a1f46bca2024223233"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f22eb3a6c1080d862befa0a89c380b4dafce29dc6cd56083f630073d102eb595"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a81b1143d42b66ffc40a441379387076243ef7b51019204fd3ec36b9f69e77d6"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f64fd07515dad67f24b6ea4a66ae2876c01031de91c93075b8093f07c0a2d93d"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:93e22add827447d2e26d67c9ac0161756007f152fdc5210277d00a85f6c92323"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:55b39c8684a46e56ef8c8d24faf02de4a2b2ac60d26cee93bc595651ff545de9"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4715a9b778f4293b9f8ae7a0a7cef9829f02ff8d6277a39d7f40565c737d3771"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:afc52b8d969eff14e069a710057d15ab9ac17cd4b6753042c407dcea0e40bf75"}, - {file = "aiohttp-3.9.5-cp311-cp311-win32.whl", hash = "sha256:b3df71da99c98534be076196791adca8819761f0bf6e08e07fd7da25127150d6"}, - {file = "aiohttp-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:88e311d98cc0bf45b62fc46c66753a83445f5ab20038bcc1b8a1cc05666f428a"}, - {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c7a4b7a6cf5b6eb11e109a9755fd4fda7d57395f8c575e166d363b9fc3ec4678"}, - {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0a158704edf0abcac8ac371fbb54044f3270bdbc93e254a82b6c82be1ef08f3c"}, - {file = "aiohttp-3.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d153f652a687a8e95ad367a86a61e8d53d528b0530ef382ec5aaf533140ed00f"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82a6a97d9771cb48ae16979c3a3a9a18b600a8505b1115cfe354dfb2054468b4"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60cdbd56f4cad9f69c35eaac0fbbdf1f77b0ff9456cebd4902f3dd1cf096464c"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8676e8fd73141ded15ea586de0b7cda1542960a7b9ad89b2b06428e97125d4fa"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da00da442a0e31f1c69d26d224e1efd3a1ca5bcbf210978a2ca7426dfcae9f58"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f634d540dd099c262e9f887c8bbacc959847cfe5da7a0e2e1cf3f14dbf2daf"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:320e8618eda64e19d11bdb3bd04ccc0a816c17eaecb7e4945d01deee2a22f95f"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2faa61a904b83142747fc6a6d7ad8fccff898c849123030f8e75d5d967fd4a81"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:8c64a6dc3fe5db7b1b4d2b5cb84c4f677768bdc340611eca673afb7cf416ef5a"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:393c7aba2b55559ef7ab791c94b44f7482a07bf7640d17b341b79081f5e5cd1a"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c671dc117c2c21a1ca10c116cfcd6e3e44da7fcde37bf83b2be485ab377b25da"}, - {file = "aiohttp-3.9.5-cp312-cp312-win32.whl", hash = "sha256:5a7ee16aab26e76add4afc45e8f8206c95d1d75540f1039b84a03c3b3800dd59"}, - {file = "aiohttp-3.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:5ca51eadbd67045396bc92a4345d1790b7301c14d1848feaac1d6a6c9289e888"}, - {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:694d828b5c41255e54bc2dddb51a9f5150b4eefa9886e38b52605a05d96566e8"}, - {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0605cc2c0088fcaae79f01c913a38611ad09ba68ff482402d3410bf59039bfb8"}, - {file = "aiohttp-3.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4558e5012ee03d2638c681e156461d37b7a113fe13970d438d95d10173d25f78"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbc053ac75ccc63dc3a3cc547b98c7258ec35a215a92bd9f983e0aac95d3d5b"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4109adee842b90671f1b689901b948f347325045c15f46b39797ae1bf17019de"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6ea1a5b409a85477fd8e5ee6ad8f0e40bf2844c270955e09360418cfd09abac"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3c2890ca8c59ee683fd09adf32321a40fe1cf164e3387799efb2acebf090c11"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3916c8692dbd9d55c523374a3b8213e628424d19116ac4308e434dbf6d95bbdd"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8d1964eb7617907c792ca00b341b5ec3e01ae8c280825deadbbd678447b127e1"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5ab8e1f6bee051a4bf6195e38a5c13e5e161cb7bad83d8854524798bd9fcd6e"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52c27110f3862a1afbcb2af4281fc9fdc40327fa286c4625dfee247c3ba90156"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7f64cbd44443e80094309875d4f9c71d0401e966d191c3d469cde4642bc2e031"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b4f72fbb66279624bfe83fd5eb6aea0022dad8eec62b71e7bf63ee1caadeafe"}, - {file = "aiohttp-3.9.5-cp38-cp38-win32.whl", hash = "sha256:6380c039ec52866c06d69b5c7aad5478b24ed11696f0e72f6b807cfb261453da"}, - {file = "aiohttp-3.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:da22dab31d7180f8c3ac7c7635f3bcd53808f374f6aa333fe0b0b9e14b01f91a"}, - {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1732102949ff6087589408d76cd6dea656b93c896b011ecafff418c9661dc4ed"}, - {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6021d296318cb6f9414b48e6a439a7f5d1f665464da507e8ff640848ee2a58a"}, - {file = "aiohttp-3.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:239f975589a944eeb1bad26b8b140a59a3a320067fb3cd10b75c3092405a1372"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b7b30258348082826d274504fbc7c849959f1989d86c29bc355107accec6cfb"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2adf5c87ff6d8b277814a28a535b59e20bfea40a101db6b3bdca7e9926bc24"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a3d838441bebcf5cf442700e3963f58b5c33f015341f9ea86dcd7d503c07e2"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3a1ae66e3d0c17cf65c08968a5ee3180c5a95920ec2731f53343fac9bad106"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c69e77370cce2d6df5d12b4e12bdcca60c47ba13d1cbbc8645dd005a20b738b"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf56238f4bbf49dab8c2dc2e6b1b68502b1e88d335bea59b3f5b9f4c001475"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d1469f228cd9ffddd396d9948b8c9cd8022b6d1bf1e40c6f25b0fb90b4f893ed"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:45731330e754f5811c314901cebdf19dd776a44b31927fa4b4dbecab9e457b0c"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3fcb4046d2904378e3aeea1df51f697b0467f2aac55d232c87ba162709478c46"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8cf142aa6c1a751fcb364158fd710b8a9be874b81889c2bd13aa8893197455e2"}, - {file = "aiohttp-3.9.5-cp39-cp39-win32.whl", hash = "sha256:7b179eea70833c8dee51ec42f3b4097bd6370892fa93f510f76762105568cf09"}, - {file = "aiohttp-3.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:38d80498e2e169bc61418ff36170e0aad0cd268da8b38a17c4cf29d254a8b3f1"}, - {file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"}, + {file = "aiohttp-3.10.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:68ab608118e212f56feef44d4785aa90b713042da301f26338f36497b481cd79"}, + {file = "aiohttp-3.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:64a117c16273ca9f18670f33fc7fd9604b9f46ddb453ce948262889a6be72868"}, + {file = "aiohttp-3.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:54076a25f32305e585a3abae1f0ad10646bec539e0e5ebcc62b54ee4982ec29f"}, + {file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71c76685773444d90ae83874433505ed800e1706c391fdf9e57cc7857611e2f4"}, + {file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bdda86ab376f9b3095a1079a16fbe44acb9ddde349634f1c9909d13631ff3bcf"}, + {file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d6dcd1d21da5ae1416f69aa03e883a51e84b6c803b8618cbab341ac89a85b9e"}, + {file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06ef0135d7ab7fb0284342fbbf8e8ddf73b7fee8ecc55f5c3a3d0a6b765e6d8b"}, + {file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccab9381f38c669bb9254d848f3b41a3284193b3e274a34687822f98412097e9"}, + {file = "aiohttp-3.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:947da3aee057010bc750b7b4bb65cbd01b0bdb7c4e1cf278489a1d4a1e9596b3"}, + {file = "aiohttp-3.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5268b35fee7eb754fb5b3d0f16a84a2e9ed21306f5377f3818596214ad2d7714"}, + {file = "aiohttp-3.10.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff25d988fd6ce433b5c393094a5ca50df568bdccf90a8b340900e24e0d5fb45c"}, + {file = "aiohttp-3.10.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:594b4b4f1dfe8378b4a0342576dc87a930c960641159f5ae83843834016dbd59"}, + {file = "aiohttp-3.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c8820dad615cd2f296ed3fdea8402b12663ac9e5ea2aafc90ef5141eb10b50b8"}, + {file = "aiohttp-3.10.0-cp310-cp310-win32.whl", hash = "sha256:ab1d870403817c9a0486ca56ccbc0ebaf85d992277d48777faa5a95e40e5bcca"}, + {file = "aiohttp-3.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:563705a94ea3af43467167f3a21c665f3b847b2a0ae5544fa9e18df686a660da"}, + {file = "aiohttp-3.10.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13679e11937d3f37600860de1f848e2e062e2b396d3aa79b38c89f9c8ab7e791"}, + {file = "aiohttp-3.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8c66a1aadafbc0bd7d648cb7fcb3860ec9beb1b436ce3357036a4d9284fcef9a"}, + {file = "aiohttp-3.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7e3545b06aae925f90f06402e05cfb9c62c6409ce57041932163b09c48daad6"}, + {file = "aiohttp-3.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:effafe5144aa32f0388e8f99b1b2692cf094ea2f6b7ceca384b54338b77b1f50"}, + {file = "aiohttp-3.10.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a04f2c8d41821a2507b49b2694c40495a295b013afb0cc7355b337980b47c546"}, + {file = "aiohttp-3.10.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6dbfac556219d884d50edc6e1952a93545c2786193f00f5521ec0d9d464040ab"}, + {file = "aiohttp-3.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a65472256c5232681968deeea3cd5453aa091c44e8db09f22f1a1491d422c2d9"}, + {file = "aiohttp-3.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:941366a554e566efdd3f042e17a9e461a36202469e5fd2aee66fe3efe6412aef"}, + {file = "aiohttp-3.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:927b4aca6340301e7d8bb05278d0b6585b8633ea852b7022d604a5df920486bf"}, + {file = "aiohttp-3.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:34adb8412e736a5d0df6d1fccdf71599dfb07a63add241a94a189b6364e997f1"}, + {file = "aiohttp-3.10.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:43c60d9b332a01ee985f080f639f3e56abcfb95ec1320013c94083c3b6a2e143"}, + {file = "aiohttp-3.10.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3f49edf7c5cd2987634116e1b6a0ee2438fca17f7c4ee480ff41decb76cf6158"}, + {file = "aiohttp-3.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9784246431eaf9d651b3cc06f9c64f9a9f57299f4971c5ea778fa0b81074ef13"}, + {file = "aiohttp-3.10.0-cp311-cp311-win32.whl", hash = "sha256:bec91402df78b897a47b66b9c071f48051cea68d853d8bc1d4404896c6de41ae"}, + {file = "aiohttp-3.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:25a9924343bf91b0c5082cae32cfc5a1f8787ac0433966319ec07b0ed4570722"}, + {file = "aiohttp-3.10.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:21dab4a704c68dc7bc2a1219a4027158e8968e2079f1444eda2ba88bc9f2895f"}, + {file = "aiohttp-3.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:872c0dcaccebd5733d535868fe2356aa6939f5827dcea7a8b9355bb2eff6f56e"}, + {file = "aiohttp-3.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f381424dbce313bb5a666a215e7a9dcebbc533e9a2c467a1f0c95279d24d1fa7"}, + {file = "aiohttp-3.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ca48e9f092a417c6669ee8d3a19d40b3c66dde1a2ae0d57e66c34812819b671"}, + {file = "aiohttp-3.10.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbe2f6d0466f5c59c7258e0745c20d74806a1385fbb7963e5bbe2309a11cc69b"}, + {file = "aiohttp-3.10.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:03799a95402a7ed62671c4465e1eae51d749d5439dbc49edb6eee52ea165c50b"}, + {file = "aiohttp-3.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5549c71c35b5f057a4eebcc538c41299826f7813f28880722b60e41c861a57ec"}, + {file = "aiohttp-3.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6fa7a42b78d8698491dc4ad388169de54cca551aa9900f750547372de396277"}, + {file = "aiohttp-3.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:77bbf0a2f6fefac6c0db1792c234f577d80299a33ce7125467439097cf869198"}, + {file = "aiohttp-3.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:34eaf5cfcc979846d73571b1a4be22cad5e029d55cdbe77cdc7545caa4dcb925"}, + {file = "aiohttp-3.10.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4f1de31a585344a106db43a9c3af2e15bb82e053618ff759f1fdd31d82da38eb"}, + {file = "aiohttp-3.10.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f3a1ea61d96146e9b9e5597069466e2e4d9e01e09381c5dd51659f890d5e29e7"}, + {file = "aiohttp-3.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:73c01201219eb039a828bb58dcc13112eec2fed6eea718356316cd552df26e04"}, + {file = "aiohttp-3.10.0-cp312-cp312-win32.whl", hash = "sha256:33e915971eee6d2056d15470a1214e4e0f72b6aad10225548a7ab4c4f54e2db7"}, + {file = "aiohttp-3.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2dc75da06c35a7b47a88ceadbf993a53d77d66423c2a78de8c6f9fb41ec35687"}, + {file = "aiohttp-3.10.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f1bc4d68b83966012813598fe39b35b4e6019b69d29385cf7ec1cb08e1ff829b"}, + {file = "aiohttp-3.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d9b8b31c057a0b7bb822a159c490af05cb11b8069097f3236746a78315998afa"}, + {file = "aiohttp-3.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10f0d7894ddc6ff8f369e3fdc082ef1f940dc1f5b9003cd40945d24845477220"}, + {file = "aiohttp-3.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72de8ffba4a27e3c6e83e58a379fc4fe5548f69f9b541fde895afb9be8c31658"}, + {file = "aiohttp-3.10.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd36d0f0afc2bd84f007cedd2d9a449c3cf04af471853a25eb71f28bc2e1a119"}, + {file = "aiohttp-3.10.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f64d503c661864866c09806ac360b95457f872d639ca61719115a9f389b2ec90"}, + {file = "aiohttp-3.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31616121369bc823791056c632f544c6c8f8d1ceecffd8bf3f72ef621eaabf49"}, + {file = "aiohttp-3.10.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f76c12abb88b7ee64b3f9ae72f0644af49ff139067b5add142836dab405d60d4"}, + {file = "aiohttp-3.10.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6c99eef30a7e98144bcf44d615bc0f445b3a3730495fcc16124cb61117e1f81e"}, + {file = "aiohttp-3.10.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:39e7ec718e7a1971a5d98357e3e8c0529477d45c711d32cd91999dc8d8404e1e"}, + {file = "aiohttp-3.10.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f1cef548ee4e84264b78879de0c754bbe223193c6313beb242ce862f82eab184"}, + {file = "aiohttp-3.10.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:f98f036eab11d2f90cdd01b9d1410de9d7eb520d070debeb2edadf158b758431"}, + {file = "aiohttp-3.10.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cc4376ff537f7d2c1e98f97f6d548e99e5d96078b0333c1d3177c11467b972de"}, + {file = "aiohttp-3.10.0-cp38-cp38-win32.whl", hash = "sha256:ebedc51ee6d39f9ea5e26e255fd56a7f4e79a56e77d960f9bae75ef4f95ed57f"}, + {file = "aiohttp-3.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:aad87626f31a85fd4af02ba7fd6cc424b39d4bff5c8677e612882649da572e47"}, + {file = "aiohttp-3.10.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1dc95c5e2a5e60095f1bb51822e3b504e6a7430c9b44bff2120c29bb876c5202"}, + {file = "aiohttp-3.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1c83977f7b6f4f4a96fab500f5a76d355f19f42675224a3002d375b3fb309174"}, + {file = "aiohttp-3.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8cedc48d36652dd3ac40e5c7c139d528202393e341a5e3475acedb5e8d5c4c75"}, + {file = "aiohttp-3.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b099fbb823efed3c1d736f343ac60d66531b13680ee9b2669e368280f41c2b8"}, + {file = "aiohttp-3.10.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d583755ddb9c97a2da1322f17fc7d26792f4e035f472d675e2761c766f94c2ff"}, + {file = "aiohttp-3.10.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a03a4407bdb9ae815f0d5a19df482b17df530cf7bf9c78771aa1c713c37ff1f"}, + {file = "aiohttp-3.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcb6e65f6ea7caa0188e36bebe9e72b259d3d525634758c91209afb5a6cbcba7"}, + {file = "aiohttp-3.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6612c6ed3147a4a2d6463454b94b877566b38215665be4c729cd8b7bdce15b4"}, + {file = "aiohttp-3.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0b0c0148d2a69b82ffe650c2ce235b431d49a90bde7dd2629bcb40314957acf6"}, + {file = "aiohttp-3.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0d85a173b4dbbaaad1900e197181ea0fafa617ca6656663f629a8a372fdc7d06"}, + {file = "aiohttp-3.10.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:12c43dace645023583f3dd2337dfc3aa92c99fb943b64dcf2bc15c7aa0fb4a95"}, + {file = "aiohttp-3.10.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:33acb0d9bf12cdc80ceec6f5fda83ea7990ce0321c54234d629529ca2c54e33d"}, + {file = "aiohttp-3.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:91e0b76502205484a4d1d6f25f461fa60fe81a7987b90e57f7b941b0753c3ec8"}, + {file = "aiohttp-3.10.0-cp39-cp39-win32.whl", hash = "sha256:1ebd8ed91428ffbe8b33a5bd6f50174e11882d5b8e2fe28670406ab5ee045ede"}, + {file = "aiohttp-3.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:0433795c4a8bafc03deb3e662192250ba5db347c41231b0273380d2f53c9ea0b"}, + {file = "aiohttp-3.10.0.tar.gz", hash = "sha256:e8dd7da2609303e3574c95b0ec9f1fd49647ef29b94701a2862cceae76382e1d"}, ] [package.dependencies] +aiohappyeyeballs = ">=2.3.0" aiosignal = ">=1.1.2" async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} attrs = ">=17.3.0" @@ -94,7 +106,7 @@ multidict = ">=4.5,<7.0" yarl = ">=1.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns", "brotlicffi"] +speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] [[package]] name = "aiosignal" @@ -226,24 +238,24 @@ dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] [[package]] name = "cachetools" -version = "5.3.3" +version = "5.4.0" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" files = [ - {file = "cachetools-5.3.3-py3-none-any.whl", hash = "sha256:0abad1021d3f8325b2fc1d2e9c8b9c9d57b04c3932657a72465447332c24d945"}, - {file = "cachetools-5.3.3.tar.gz", hash = "sha256:ba29e2dfa0b8b556606f097407ed1aa62080ee108ab0dc5ec9d6a723a007d105"}, + {file = "cachetools-5.4.0-py3-none-any.whl", hash = "sha256:3ae3b49a3d5e28a77a0be2b37dbcb89005058959cb2323858c2657c4a8cab474"}, + {file = "cachetools-5.4.0.tar.gz", hash = "sha256:b8adc2e7c07f105ced7bc56dbb6dfbe7c4a00acce20e2227b3f355be89bc6827"}, ] [[package]] name = "certifi" -version = "2024.6.2" +version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"}, - {file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"}, + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] [[package]] @@ -459,63 +471,63 @@ files = [ [[package]] name = "coverage" -version = "7.5.4" +version = "7.6.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cfb5a4f556bb51aba274588200a46e4dd6b505fb1a5f8c5ae408222eb416f99"}, - {file = "coverage-7.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2174e7c23e0a454ffe12267a10732c273243b4f2d50d07544a91198f05c48f47"}, - {file = "coverage-7.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2214ee920787d85db1b6a0bd9da5f8503ccc8fcd5814d90796c2f2493a2f4d2e"}, - {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1137f46adb28e3813dec8c01fefadcb8c614f33576f672962e323b5128d9a68d"}, - {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b385d49609f8e9efc885790a5a0e89f2e3ae042cdf12958b6034cc442de428d3"}, - {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4a474f799456e0eb46d78ab07303286a84a3140e9700b9e154cfebc8f527016"}, - {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5cd64adedf3be66f8ccee418473c2916492d53cbafbfcff851cbec5a8454b136"}, - {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e564c2cf45d2f44a9da56f4e3a26b2236504a496eb4cb0ca7221cd4cc7a9aca9"}, - {file = "coverage-7.5.4-cp310-cp310-win32.whl", hash = "sha256:7076b4b3a5f6d2b5d7f1185fde25b1e54eb66e647a1dfef0e2c2bfaf9b4c88c8"}, - {file = "coverage-7.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:018a12985185038a5b2bcafab04ab833a9a0f2c59995b3cec07e10074c78635f"}, - {file = "coverage-7.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db14f552ac38f10758ad14dd7b983dbab424e731588d300c7db25b6f89e335b5"}, - {file = "coverage-7.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3257fdd8e574805f27bb5342b77bc65578e98cbc004a92232106344053f319ba"}, - {file = "coverage-7.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a6612c99081d8d6134005b1354191e103ec9705d7ba2754e848211ac8cacc6b"}, - {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45d3cbd94159c468b9b8c5a556e3f6b81a8d1af2a92b77320e887c3e7a5d080"}, - {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed550e7442f278af76d9d65af48069f1fb84c9f745ae249c1a183c1e9d1b025c"}, - {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a892be37ca35eb5019ec85402c3371b0f7cda5ab5056023a7f13da0961e60da"}, - {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8192794d120167e2a64721d88dbd688584675e86e15d0569599257566dec9bf0"}, - {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:820bc841faa502e727a48311948e0461132a9c8baa42f6b2b84a29ced24cc078"}, - {file = "coverage-7.5.4-cp311-cp311-win32.whl", hash = "sha256:6aae5cce399a0f065da65c7bb1e8abd5c7a3043da9dceb429ebe1b289bc07806"}, - {file = "coverage-7.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2e344d6adc8ef81c5a233d3a57b3c7d5181f40e79e05e1c143da143ccb6377d"}, - {file = "coverage-7.5.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:54317c2b806354cbb2dc7ac27e2b93f97096912cc16b18289c5d4e44fc663233"}, - {file = "coverage-7.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:042183de01f8b6d531e10c197f7f0315a61e8d805ab29c5f7b51a01d62782747"}, - {file = "coverage-7.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bb74ed465d5fb204b2ec41d79bcd28afccf817de721e8a807d5141c3426638"}, - {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3d45ff86efb129c599a3b287ae2e44c1e281ae0f9a9bad0edc202179bcc3a2e"}, - {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5013ed890dc917cef2c9f765c4c6a8ae9df983cd60dbb635df8ed9f4ebc9f555"}, - {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1014fbf665fef86cdfd6cb5b7371496ce35e4d2a00cda501cf9f5b9e6fced69f"}, - {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3684bc2ff328f935981847082ba4fdc950d58906a40eafa93510d1b54c08a66c"}, - {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:581ea96f92bf71a5ec0974001f900db495488434a6928a2ca7f01eee20c23805"}, - {file = "coverage-7.5.4-cp312-cp312-win32.whl", hash = "sha256:73ca8fbc5bc622e54627314c1a6f1dfdd8db69788f3443e752c215f29fa87a0b"}, - {file = "coverage-7.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:cef4649ec906ea7ea5e9e796e68b987f83fa9a718514fe147f538cfeda76d7a7"}, - {file = "coverage-7.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdd31315fc20868c194130de9ee6bfd99755cc9565edff98ecc12585b90be882"}, - {file = "coverage-7.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:02ff6e898197cc1e9fa375581382b72498eb2e6d5fc0b53f03e496cfee3fac6d"}, - {file = "coverage-7.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d05c16cf4b4c2fc880cb12ba4c9b526e9e5d5bb1d81313d4d732a5b9fe2b9d53"}, - {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5986ee7ea0795a4095ac4d113cbb3448601efca7f158ec7f7087a6c705304e4"}, - {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df54843b88901fdc2f598ac06737f03d71168fd1175728054c8f5a2739ac3e4"}, - {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ab73b35e8d109bffbda9a3e91c64e29fe26e03e49addf5b43d85fc426dde11f9"}, - {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:aea072a941b033813f5e4814541fc265a5c12ed9720daef11ca516aeacd3bd7f"}, - {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16852febd96acd953b0d55fc842ce2dac1710f26729b31c80b940b9afcd9896f"}, - {file = "coverage-7.5.4-cp38-cp38-win32.whl", hash = "sha256:8f894208794b164e6bd4bba61fc98bf6b06be4d390cf2daacfa6eca0a6d2bb4f"}, - {file = "coverage-7.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:e2afe743289273209c992075a5a4913e8d007d569a406ffed0bd080ea02b0633"}, - {file = "coverage-7.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95c3a8cb0463ba9f77383d0fa8c9194cf91f64445a63fc26fb2327e1e1eb088"}, - {file = "coverage-7.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d7564cc09dd91b5a6001754a5b3c6ecc4aba6323baf33a12bd751036c998be4"}, - {file = "coverage-7.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44da56a2589b684813f86d07597fdf8a9c6ce77f58976727329272f5a01f99f7"}, - {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e16f3d6b491c48c5ae726308e6ab1e18ee830b4cdd6913f2d7f77354b33f91c8"}, - {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbc5958cb471e5a5af41b0ddaea96a37e74ed289535e8deca404811f6cb0bc3d"}, - {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a04e990a2a41740b02d6182b498ee9796cf60eefe40cf859b016650147908029"}, - {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ddbd2f9713a79e8e7242d7c51f1929611e991d855f414ca9996c20e44a895f7c"}, - {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b1ccf5e728ccf83acd313c89f07c22d70d6c375a9c6f339233dcf792094bcbf7"}, - {file = "coverage-7.5.4-cp39-cp39-win32.whl", hash = "sha256:56b4eafa21c6c175b3ede004ca12c653a88b6f922494b023aeb1e836df953ace"}, - {file = "coverage-7.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:65e528e2e921ba8fd67d9055e6b9f9e34b21ebd6768ae1c1723f4ea6ace1234d"}, - {file = "coverage-7.5.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:79b356f3dd5b26f3ad23b35c75dbdaf1f9e2450b6bcefc6d0825ea0aa3f86ca5"}, - {file = "coverage-7.5.4.tar.gz", hash = "sha256:a44963520b069e12789d0faea4e9fdb1e410cdc4aab89d94f7f55cbb7fef0353"}, + {file = "coverage-7.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd"}, + {file = "coverage-7.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb"}, + {file = "coverage-7.6.0-cp310-cp310-win32.whl", hash = "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c"}, + {file = "coverage-7.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169"}, + {file = "coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933"}, + {file = "coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63"}, + {file = "coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713"}, + {file = "coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1"}, + {file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"}, + {file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"}, + {file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"}, + {file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"}, + {file = "coverage-7.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b"}, + {file = "coverage-7.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b"}, + {file = "coverage-7.6.0-cp38-cp38-win32.whl", hash = "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428"}, + {file = "coverage-7.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8"}, + {file = "coverage-7.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c"}, + {file = "coverage-7.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd"}, + {file = "coverage-7.6.0-cp39-cp39-win32.whl", hash = "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2"}, + {file = "coverage-7.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca"}, + {file = "coverage-7.6.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6"}, + {file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"}, ] [package.dependencies] @@ -526,43 +538,38 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "42.0.8" +version = "43.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e"}, - {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7"}, - {file = "cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2"}, - {file = "cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba"}, - {file = "cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14"}, - {file = "cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c"}, - {file = "cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad"}, - {file = "cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2"}, + {file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"}, + {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"}, + {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"}, + {file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"}, + {file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"}, + {file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"}, + {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"}, + {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"}, + {file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"}, + {file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"}, + {file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"}, ] [package.dependencies] @@ -575,7 +582,7 @@ nox = ["nox"] pep8test = ["check-sdist", "click", "mypy", "ruff"] sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] [[package]] @@ -602,13 +609,13 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.2.1" +version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, - {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] [package.extras] @@ -732,13 +739,13 @@ files = [ [[package]] name = "identify" -version = "2.5.36" +version = "2.6.0" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa"}, - {file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"}, + {file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"}, + {file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"}, ] [package.extras] @@ -768,13 +775,13 @@ files = [ [[package]] name = "importlib-metadata" -version = "8.0.0" +version = "8.2.0" description = "Read metadata from Python packages" optional = true python-versions = ">=3.8" files = [ - {file = "importlib_metadata-8.0.0-py3-none-any.whl", hash = "sha256:15584cf2b1bf449d98ff8a6ff1abef57bf20f3ac6454f431736cd3e660921b2f"}, - {file = "importlib_metadata-8.0.0.tar.gz", hash = "sha256:188bd24e4c346d3f0a933f275c2fec67050326a856b9a359881d7c2a697e8812"}, + {file = "importlib_metadata-8.2.0-py3-none-any.whl", hash = "sha256:11901fa0c2f97919b288679932bb64febaeacf289d18ac84dd68cb2e74213369"}, + {file = "importlib_metadata-8.2.0.tar.gz", hash = "sha256:72e8d4399996132204f9a16dcc751af254a48f8d1b20b9ff0f98d4a8f901e73d"}, ] [package.dependencies] @@ -1092,44 +1099,44 @@ files = [ [[package]] name = "mypy" -version = "1.10.1" +version = "1.11.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e36f229acfe250dc660790840916eb49726c928e8ce10fbdf90715090fe4ae02"}, - {file = "mypy-1.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:51a46974340baaa4145363b9e051812a2446cf583dfaeba124af966fa44593f7"}, - {file = "mypy-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:901c89c2d67bba57aaaca91ccdb659aa3a312de67f23b9dfb059727cce2e2e0a"}, - {file = "mypy-1.10.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0cd62192a4a32b77ceb31272d9e74d23cd88c8060c34d1d3622db3267679a5d9"}, - {file = "mypy-1.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:a2cbc68cb9e943ac0814c13e2452d2046c2f2b23ff0278e26599224cf164e78d"}, - {file = "mypy-1.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bd6f629b67bb43dc0d9211ee98b96d8dabc97b1ad38b9b25f5e4c4d7569a0c6a"}, - {file = "mypy-1.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1bbb3a6f5ff319d2b9d40b4080d46cd639abe3516d5a62c070cf0114a457d84"}, - {file = "mypy-1.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8edd4e9bbbc9d7b79502eb9592cab808585516ae1bcc1446eb9122656c6066f"}, - {file = "mypy-1.10.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6166a88b15f1759f94a46fa474c7b1b05d134b1b61fca627dd7335454cc9aa6b"}, - {file = "mypy-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:5bb9cd11c01c8606a9d0b83ffa91d0b236a0e91bc4126d9ba9ce62906ada868e"}, - {file = "mypy-1.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d8681909f7b44d0b7b86e653ca152d6dff0eb5eb41694e163c6092124f8246d7"}, - {file = "mypy-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:378c03f53f10bbdd55ca94e46ec3ba255279706a6aacaecac52ad248f98205d3"}, - {file = "mypy-1.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bacf8f3a3d7d849f40ca6caea5c055122efe70e81480c8328ad29c55c69e93e"}, - {file = "mypy-1.10.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:701b5f71413f1e9855566a34d6e9d12624e9e0a8818a5704d74d6b0402e66c04"}, - {file = "mypy-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c4c2992f6ea46ff7fce0072642cfb62af7a2484efe69017ed8b095f7b39ef31"}, - {file = "mypy-1.10.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:604282c886497645ffb87b8f35a57ec773a4a2721161e709a4422c1636ddde5c"}, - {file = "mypy-1.10.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37fd87cab83f09842653f08de066ee68f1182b9b5282e4634cdb4b407266bade"}, - {file = "mypy-1.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8addf6313777dbb92e9564c5d32ec122bf2c6c39d683ea64de6a1fd98b90fe37"}, - {file = "mypy-1.10.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cc3ca0a244eb9a5249c7c583ad9a7e881aa5d7b73c35652296ddcdb33b2b9c7"}, - {file = "mypy-1.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:1b3a2ffce52cc4dbaeee4df762f20a2905aa171ef157b82192f2e2f368eec05d"}, - {file = "mypy-1.10.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe85ed6836165d52ae8b88f99527d3d1b2362e0cb90b005409b8bed90e9059b3"}, - {file = "mypy-1.10.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2ae450d60d7d020d67ab440c6e3fae375809988119817214440033f26ddf7bf"}, - {file = "mypy-1.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6be84c06e6abd72f960ba9a71561c14137a583093ffcf9bbfaf5e613d63fa531"}, - {file = "mypy-1.10.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2189ff1e39db399f08205e22a797383613ce1cb0cb3b13d8bcf0170e45b96cc3"}, - {file = "mypy-1.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:97a131ee36ac37ce9581f4220311247ab6cba896b4395b9c87af0675a13a755f"}, - {file = "mypy-1.10.1-py3-none-any.whl", hash = "sha256:71d8ac0b906354ebda8ef1673e5fde785936ac1f29ff6987c7483cfbd5a4235a"}, - {file = "mypy-1.10.1.tar.gz", hash = "sha256:1f8f492d7db9e3593ef42d4f115f04e556130f2819ad33ab84551403e97dd4c0"}, + {file = "mypy-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c"}, + {file = "mypy-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411"}, + {file = "mypy-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03"}, + {file = "mypy-1.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4"}, + {file = "mypy-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58"}, + {file = "mypy-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5"}, + {file = "mypy-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca"}, + {file = "mypy-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de"}, + {file = "mypy-1.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809"}, + {file = "mypy-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72"}, + {file = "mypy-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8"}, + {file = "mypy-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a"}, + {file = "mypy-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417"}, + {file = "mypy-1.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e"}, + {file = "mypy-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525"}, + {file = "mypy-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2"}, + {file = "mypy-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b"}, + {file = "mypy-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0"}, + {file = "mypy-1.11.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd"}, + {file = "mypy-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb"}, + {file = "mypy-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe"}, + {file = "mypy-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c"}, + {file = "mypy-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69"}, + {file = "mypy-1.11.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74"}, + {file = "mypy-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b"}, + {file = "mypy-1.11.1-py3-none-any.whl", hash = "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54"}, + {file = "mypy-1.11.1.tar.gz", hash = "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08"}, ] [package.dependencies] mypy-extensions = ">=1.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = ">=4.1.0" +typing-extensions = ">=4.6.0" [package.extras] dmypy = ["psutil (>=4.0)"] @@ -1187,57 +1194,62 @@ files = [ [[package]] name = "orjson" -version = "3.10.5" +version = "3.10.6" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = true python-versions = ">=3.8" files = [ - {file = "orjson-3.10.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:545d493c1f560d5ccfc134803ceb8955a14c3fcb47bbb4b2fee0232646d0b932"}, - {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4324929c2dd917598212bfd554757feca3e5e0fa60da08be11b4aa8b90013c1"}, - {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c13ca5e2ddded0ce6a927ea5a9f27cae77eee4c75547b4297252cb20c4d30e6"}, - {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6c8e30adfa52c025f042a87f450a6b9ea29649d828e0fec4858ed5e6caecf63"}, - {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338fd4f071b242f26e9ca802f443edc588fa4ab60bfa81f38beaedf42eda226c"}, - {file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6970ed7a3126cfed873c5d21ece1cd5d6f83ca6c9afb71bbae21a0b034588d96"}, - {file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:235dadefb793ad12f7fa11e98a480db1f7c6469ff9e3da5e73c7809c700d746b"}, - {file = "orjson-3.10.5-cp310-none-win32.whl", hash = "sha256:be79e2393679eda6a590638abda16d167754393f5d0850dcbca2d0c3735cebe2"}, - {file = "orjson-3.10.5-cp310-none-win_amd64.whl", hash = "sha256:c4a65310ccb5c9910c47b078ba78e2787cb3878cdded1702ac3d0da71ddc5228"}, - {file = "orjson-3.10.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cdf7365063e80899ae3a697def1277c17a7df7ccfc979990a403dfe77bb54d40"}, - {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b68742c469745d0e6ca5724506858f75e2f1e5b59a4315861f9e2b1df77775a"}, - {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d10cc1b594951522e35a3463da19e899abe6ca95f3c84c69e9e901e0bd93d38"}, - {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcbe82b35d1ac43b0d84072408330fd3295c2896973112d495e7234f7e3da2e1"}, - {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c0eb7e0c75e1e486c7563fe231b40fdd658a035ae125c6ba651ca3b07936f5"}, - {file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:53ed1c879b10de56f35daf06dbc4a0d9a5db98f6ee853c2dbd3ee9d13e6f302f"}, - {file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:099e81a5975237fda3100f918839af95f42f981447ba8f47adb7b6a3cdb078fa"}, - {file = "orjson-3.10.5-cp311-none-win32.whl", hash = "sha256:1146bf85ea37ac421594107195db8bc77104f74bc83e8ee21a2e58596bfb2f04"}, - {file = "orjson-3.10.5-cp311-none-win_amd64.whl", hash = "sha256:36a10f43c5f3a55c2f680efe07aa93ef4a342d2960dd2b1b7ea2dd764fe4a37c"}, - {file = "orjson-3.10.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:68f85ecae7af14a585a563ac741b0547a3f291de81cd1e20903e79f25170458f"}, - {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28afa96f496474ce60d3340fe8d9a263aa93ea01201cd2bad844c45cd21f5268"}, - {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cd684927af3e11b6e754df80b9ffafd9fb6adcaa9d3e8fdd5891be5a5cad51e"}, - {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d21b9983da032505f7050795e98b5d9eee0df903258951566ecc358f6696969"}, - {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ad1de7fef79736dde8c3554e75361ec351158a906d747bd901a52a5c9c8d24b"}, - {file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d97531cdfe9bdd76d492e69800afd97e5930cb0da6a825646667b2c6c6c0211"}, - {file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d69858c32f09c3e1ce44b617b3ebba1aba030e777000ebdf72b0d8e365d0b2b3"}, - {file = "orjson-3.10.5-cp312-none-win32.whl", hash = "sha256:64c9cc089f127e5875901ac05e5c25aa13cfa5dbbbd9602bda51e5c611d6e3e2"}, - {file = "orjson-3.10.5-cp312-none-win_amd64.whl", hash = "sha256:b2efbd67feff8c1f7728937c0d7f6ca8c25ec81373dc8db4ef394c1d93d13dc5"}, - {file = "orjson-3.10.5-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:03b565c3b93f5d6e001db48b747d31ea3819b89abf041ee10ac6988886d18e01"}, - {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:584c902ec19ab7928fd5add1783c909094cc53f31ac7acfada817b0847975f26"}, - {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a35455cc0b0b3a1eaf67224035f5388591ec72b9b6136d66b49a553ce9eb1e6"}, - {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1670fe88b116c2745a3a30b0f099b699a02bb3482c2591514baf5433819e4f4d"}, - {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:185c394ef45b18b9a7d8e8f333606e2e8194a50c6e3c664215aae8cf42c5385e"}, - {file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ca0b3a94ac8d3886c9581b9f9de3ce858263865fdaa383fbc31c310b9eac07c9"}, - {file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dfc91d4720d48e2a709e9c368d5125b4b5899dced34b5400c3837dadc7d6271b"}, - {file = "orjson-3.10.5-cp38-none-win32.whl", hash = "sha256:c05f16701ab2a4ca146d0bca950af254cb7c02f3c01fca8efbbad82d23b3d9d4"}, - {file = "orjson-3.10.5-cp38-none-win_amd64.whl", hash = "sha256:8a11d459338f96a9aa7f232ba95679fc0c7cedbd1b990d736467894210205c09"}, - {file = "orjson-3.10.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:85c89131d7b3218db1b24c4abecea92fd6c7f9fab87441cfc342d3acc725d807"}, - {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66215277a230c456f9038d5e2d84778141643207f85336ef8d2a9da26bd7ca"}, - {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51bbcdea96cdefa4a9b4461e690c75ad4e33796530d182bdd5c38980202c134a"}, - {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbead71dbe65f959b7bd8cf91e0e11d5338033eba34c114f69078d59827ee139"}, - {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df58d206e78c40da118a8c14fc189207fffdcb1f21b3b4c9c0c18e839b5a214"}, - {file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c4057c3b511bb8aef605616bd3f1f002a697c7e4da6adf095ca5b84c0fd43595"}, - {file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b39e006b00c57125ab974362e740c14a0c6a66ff695bff44615dcf4a70ce2b86"}, - {file = "orjson-3.10.5-cp39-none-win32.whl", hash = "sha256:eded5138cc565a9d618e111c6d5c2547bbdd951114eb822f7f6309e04db0fb47"}, - {file = "orjson-3.10.5-cp39-none-win_amd64.whl", hash = "sha256:cc28e90a7cae7fcba2493953cff61da5a52950e78dc2dacfe931a317ee3d8de7"}, - {file = "orjson-3.10.5.tar.gz", hash = "sha256:7a5baef8a4284405d96c90c7c62b755e9ef1ada84c2406c24a9ebec86b89f46d"}, + {file = "orjson-3.10.6-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fb0ee33124db6eaa517d00890fc1a55c3bfe1cf78ba4a8899d71a06f2d6ff5c7"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c1c4b53b24a4c06547ce43e5fee6ec4e0d8fe2d597f4647fc033fd205707365"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eadc8fd310edb4bdbd333374f2c8fec6794bbbae99b592f448d8214a5e4050c0"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61272a5aec2b2661f4fa2b37c907ce9701e821b2c1285d5c3ab0207ebd358d38"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57985ee7e91d6214c837936dc1608f40f330a6b88bb13f5a57ce5257807da143"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:633a3b31d9d7c9f02d49c4ab4d0a86065c4a6f6adc297d63d272e043472acab5"}, + {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1c680b269d33ec444afe2bdc647c9eb73166fa47a16d9a75ee56a374f4a45f43"}, + {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f759503a97a6ace19e55461395ab0d618b5a117e8d0fbb20e70cfd68a47327f2"}, + {file = "orjson-3.10.6-cp310-none-win32.whl", hash = "sha256:95a0cce17f969fb5391762e5719575217bd10ac5a189d1979442ee54456393f3"}, + {file = "orjson-3.10.6-cp310-none-win_amd64.whl", hash = "sha256:df25d9271270ba2133cc88ee83c318372bdc0f2cd6f32e7a450809a111efc45c"}, + {file = "orjson-3.10.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b1ec490e10d2a77c345def52599311849fc063ae0e67cf4f84528073152bb2ba"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d43d3feb8f19d07e9f01e5b9be4f28801cf7c60d0fa0d279951b18fae1932b"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3045267e98fe749408eee1593a142e02357c5c99be0802185ef2170086a863"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c27bc6a28ae95923350ab382c57113abd38f3928af3c80be6f2ba7eb8d8db0b0"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d27456491ca79532d11e507cadca37fb8c9324a3976294f68fb1eff2dc6ced5a"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05ac3d3916023745aa3b3b388e91b9166be1ca02b7c7e41045da6d12985685f0"}, + {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1335d4ef59ab85cab66fe73fd7a4e881c298ee7f63ede918b7faa1b27cbe5212"}, + {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4bbc6d0af24c1575edc79994c20e1b29e6fb3c6a570371306db0993ecf144dc5"}, + {file = "orjson-3.10.6-cp311-none-win32.whl", hash = "sha256:450e39ab1f7694465060a0550b3f6d328d20297bf2e06aa947b97c21e5241fbd"}, + {file = "orjson-3.10.6-cp311-none-win_amd64.whl", hash = "sha256:227df19441372610b20e05bdb906e1742ec2ad7a66ac8350dcfd29a63014a83b"}, + {file = "orjson-3.10.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ea2977b21f8d5d9b758bb3f344a75e55ca78e3ff85595d248eee813ae23ecdfb"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6f3d167d13a16ed263b52dbfedff52c962bfd3d270b46b7518365bcc2121eed"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f710f346e4c44a4e8bdf23daa974faede58f83334289df80bc9cd12fe82573c7"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7275664f84e027dcb1ad5200b8b18373e9c669b2a9ec33d410c40f5ccf4b257e"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0943e4c701196b23c240b3d10ed8ecd674f03089198cf503105b474a4f77f21f"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:446dee5a491b5bc7d8f825d80d9637e7af43f86a331207b9c9610e2f93fee22a"}, + {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:64c81456d2a050d380786413786b057983892db105516639cb5d3ee3c7fd5148"}, + {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:960db0e31c4e52fa0fc3ecbaea5b2d3b58f379e32a95ae6b0ebeaa25b93dfd34"}, + {file = "orjson-3.10.6-cp312-none-win32.whl", hash = "sha256:a6ea7afb5b30b2317e0bee03c8d34c8181bc5a36f2afd4d0952f378972c4efd5"}, + {file = "orjson-3.10.6-cp312-none-win_amd64.whl", hash = "sha256:874ce88264b7e655dde4aeaacdc8fd772a7962faadfb41abe63e2a4861abc3dc"}, + {file = "orjson-3.10.6-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:66680eae4c4e7fc193d91cfc1353ad6d01b4801ae9b5314f17e11ba55e934183"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff75b425db5ef8e8f23af93c80f072f97b4fb3afd4af44482905c9f588da28"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3722fddb821b6036fd2a3c814f6bd9b57a89dc6337b9924ecd614ebce3271394"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2c116072a8533f2fec435fde4d134610f806bdac20188c7bd2081f3e9e0133f"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6eeb13218c8cf34c61912e9df2de2853f1d009de0e46ea09ccdf3d757896af0a"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965a916373382674e323c957d560b953d81d7a8603fbeee26f7b8248638bd48b"}, + {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:03c95484d53ed8e479cade8628c9cea00fd9d67f5554764a1110e0d5aa2de96e"}, + {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e060748a04cccf1e0a6f2358dffea9c080b849a4a68c28b1b907f272b5127e9b"}, + {file = "orjson-3.10.6-cp38-none-win32.whl", hash = "sha256:738dbe3ef909c4b019d69afc19caf6b5ed0e2f1c786b5d6215fbb7539246e4c6"}, + {file = "orjson-3.10.6-cp38-none-win_amd64.whl", hash = "sha256:d40f839dddf6a7d77114fe6b8a70218556408c71d4d6e29413bb5f150a692ff7"}, + {file = "orjson-3.10.6-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:697a35a083c4f834807a6232b3e62c8b280f7a44ad0b759fd4dce748951e70db"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd502f96bf5ea9a61cbc0b2b5900d0dd68aa0da197179042bdd2be67e51a1e4b"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f215789fb1667cdc874c1b8af6a84dc939fd802bf293a8334fce185c79cd359b"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2debd8ddce948a8c0938c8c93ade191d2f4ba4649a54302a7da905a81f00b56"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5410111d7b6681d4b0d65e0f58a13be588d01b473822483f77f513c7f93bd3b2"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb1f28a137337fdc18384079fa5726810681055b32b92253fa15ae5656e1dddb"}, + {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bf2fbbce5fe7cd1aa177ea3eab2b8e6a6bc6e8592e4279ed3db2d62e57c0e1b2"}, + {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:79b9b9e33bd4c517445a62b90ca0cc279b0f1f3970655c3df9e608bc3f91741a"}, + {file = "orjson-3.10.6-cp39-none-win32.whl", hash = "sha256:30b0a09a2014e621b1adf66a4f705f0809358350a757508ee80209b2d8dae219"}, + {file = "orjson-3.10.6-cp39-none-win_amd64.whl", hash = "sha256:49e3bc615652617d463069f91b867a4458114c5b104e13b7ae6872e5f79d0844"}, + {file = "orjson-3.10.6.tar.gz", hash = "sha256:e54b63d0a7c6c54a5f5f726bc93a2078111ef060fec4ecbf34c5db800ca3b3a7"}, ] [[package]] @@ -1299,13 +1311,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "3.7.1" +version = "3.8.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" files = [ - {file = "pre_commit-3.7.1-py2.py3-none-any.whl", hash = "sha256:fae36fd1d7ad7d6a5a1c0b0d5adb2ed1a3bda5a21bf6c3e5372073d7a11cd4c5"}, - {file = "pre_commit-3.7.1.tar.gz", hash = "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a"}, + {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, + {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, ] [package.dependencies] @@ -1331,13 +1343,13 @@ wcwidth = "*" [[package]] name = "ptpython" -version = "3.0.27" +version = "3.0.29" description = "Python REPL build on top of prompt_toolkit" optional = true python-versions = ">=3.7" files = [ - {file = "ptpython-3.0.27-py2.py3-none-any.whl", hash = "sha256:549870d537ab3244243cfb92d36347072bb8be823a121fb2fd95297af0fb42bb"}, - {file = "ptpython-3.0.27.tar.gz", hash = "sha256:24b0fda94b73d1c99a27e6fd0d08be6f2e7cda79a2db995c7e3c7b8b1254bad9"}, + {file = "ptpython-3.0.29-py2.py3-none-any.whl", hash = "sha256:65d75c4871859e4305a020c9b9e204366dceb4d08e0e2bd7b7511bd5e917a402"}, + {file = "ptpython-3.0.29.tar.gz", hash = "sha256:b9d625183aef93a673fc32cbe1c1fcaf51412e7a4f19590521cdaccadf25186e"}, ] [package.dependencies] @@ -1363,109 +1375,122 @@ files = [ [[package]] name = "pydantic" -version = "2.7.4" +version = "2.8.2" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.7.4-py3-none-any.whl", hash = "sha256:ee8538d41ccb9c0a9ad3e0e5f07bf15ed8015b481ced539a1759d8cc89ae90d0"}, - {file = "pydantic-2.7.4.tar.gz", hash = "sha256:0c84efd9548d545f63ac0060c1e4d39bb9b14db8b3c0652338aecc07b5adec52"}, + {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, + {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, ] [package.dependencies] annotated-types = ">=0.4.0" -pydantic-core = "2.18.4" -typing-extensions = ">=4.6.1" +pydantic-core = "2.20.1" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] [package.extras] email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-core" -version = "2.18.4" +version = "2.20.1" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.18.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f76d0ad001edd426b92233d45c746fd08f467d56100fd8f30e9ace4b005266e4"}, - {file = "pydantic_core-2.18.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:59ff3e89f4eaf14050c8022011862df275b552caef8082e37b542b066ce1ff26"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a55b5b16c839df1070bc113c1f7f94a0af4433fcfa1b41799ce7606e5c79ce0a"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4d0dcc59664fcb8974b356fe0a18a672d6d7cf9f54746c05f43275fc48636851"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8951eee36c57cd128f779e641e21eb40bc5073eb28b2d23f33eb0ef14ffb3f5d"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4701b19f7e3a06ea655513f7938de6f108123bf7c86bbebb1196eb9bd35cf724"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e00a3f196329e08e43d99b79b286d60ce46bed10f2280d25a1718399457e06be"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:97736815b9cc893b2b7f663628e63f436018b75f44854c8027040e05230eeddb"}, - {file = "pydantic_core-2.18.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6891a2ae0e8692679c07728819b6e2b822fb30ca7445f67bbf6509b25a96332c"}, - {file = "pydantic_core-2.18.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bc4ff9805858bd54d1a20efff925ccd89c9d2e7cf4986144b30802bf78091c3e"}, - {file = "pydantic_core-2.18.4-cp310-none-win32.whl", hash = "sha256:1b4de2e51bbcb61fdebd0ab86ef28062704f62c82bbf4addc4e37fa4b00b7cbc"}, - {file = "pydantic_core-2.18.4-cp310-none-win_amd64.whl", hash = "sha256:6a750aec7bf431517a9fd78cb93c97b9b0c496090fee84a47a0d23668976b4b0"}, - {file = "pydantic_core-2.18.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:942ba11e7dfb66dc70f9ae66b33452f51ac7bb90676da39a7345e99ffb55402d"}, - {file = "pydantic_core-2.18.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b2ebef0e0b4454320274f5e83a41844c63438fdc874ea40a8b5b4ecb7693f1c4"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a642295cd0c8df1b86fc3dced1d067874c353a188dc8e0f744626d49e9aa51c4"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f09baa656c904807e832cf9cce799c6460c450c4ad80803517032da0cd062e2"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98906207f29bc2c459ff64fa007afd10a8c8ac080f7e4d5beff4c97086a3dabd"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19894b95aacfa98e7cb093cd7881a0c76f55731efad31073db4521e2b6ff5b7d"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fbbdc827fe5e42e4d196c746b890b3d72876bdbf160b0eafe9f0334525119c8"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f85d05aa0918283cf29a30b547b4df2fbb56b45b135f9e35b6807cb28bc47951"}, - {file = "pydantic_core-2.18.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e85637bc8fe81ddb73fda9e56bab24560bdddfa98aa64f87aaa4e4b6730c23d2"}, - {file = "pydantic_core-2.18.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2f5966897e5461f818e136b8451d0551a2e77259eb0f73a837027b47dc95dab9"}, - {file = "pydantic_core-2.18.4-cp311-none-win32.whl", hash = "sha256:44c7486a4228413c317952e9d89598bcdfb06399735e49e0f8df643e1ccd0558"}, - {file = "pydantic_core-2.18.4-cp311-none-win_amd64.whl", hash = "sha256:8a7164fe2005d03c64fd3b85649891cd4953a8de53107940bf272500ba8a788b"}, - {file = "pydantic_core-2.18.4-cp311-none-win_arm64.whl", hash = "sha256:4e99bc050fe65c450344421017f98298a97cefc18c53bb2f7b3531eb39bc7805"}, - {file = "pydantic_core-2.18.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6f5c4d41b2771c730ea1c34e458e781b18cc668d194958e0112455fff4e402b2"}, - {file = "pydantic_core-2.18.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fdf2156aa3d017fddf8aea5adfba9f777db1d6022d392b682d2a8329e087cef"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4748321b5078216070b151d5271ef3e7cc905ab170bbfd27d5c83ee3ec436695"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847a35c4d58721c5dc3dba599878ebbdfd96784f3fb8bb2c356e123bdcd73f34"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c40d4eaad41f78e3bbda31b89edc46a3f3dc6e171bf0ecf097ff7a0ffff7cb1"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:21a5e440dbe315ab9825fcd459b8814bb92b27c974cbc23c3e8baa2b76890077"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01dd777215e2aa86dfd664daed5957704b769e726626393438f9c87690ce78c3"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b06beb3b3f1479d32befd1f3079cc47b34fa2da62457cdf6c963393340b56e9"}, - {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:564d7922e4b13a16b98772441879fcdcbe82ff50daa622d681dd682175ea918c"}, - {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0eb2a4f660fcd8e2b1c90ad566db2b98d7f3f4717c64fe0a83e0adb39766d5b8"}, - {file = "pydantic_core-2.18.4-cp312-none-win32.whl", hash = "sha256:8b8bab4c97248095ae0c4455b5a1cd1cdd96e4e4769306ab19dda135ea4cdb07"}, - {file = "pydantic_core-2.18.4-cp312-none-win_amd64.whl", hash = "sha256:14601cdb733d741b8958224030e2bfe21a4a881fb3dd6fbb21f071cabd48fa0a"}, - {file = "pydantic_core-2.18.4-cp312-none-win_arm64.whl", hash = "sha256:c1322d7dd74713dcc157a2b7898a564ab091ca6c58302d5c7b4c07296e3fd00f"}, - {file = "pydantic_core-2.18.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:823be1deb01793da05ecb0484d6c9e20baebb39bd42b5d72636ae9cf8350dbd2"}, - {file = "pydantic_core-2.18.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ebef0dd9bf9b812bf75bda96743f2a6c5734a02092ae7f721c048d156d5fabae"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae1d6df168efb88d7d522664693607b80b4080be6750c913eefb77e34c12c71a"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9899c94762343f2cc2fc64c13e7cae4c3cc65cdfc87dd810a31654c9b7358cc"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99457f184ad90235cfe8461c4d70ab7dd2680e28821c29eca00252ba90308c78"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18f469a3d2a2fdafe99296a87e8a4c37748b5080a26b806a707f25a902c040a8"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7cdf28938ac6b8b49ae5e92f2735056a7ba99c9b110a474473fd71185c1af5d"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:938cb21650855054dc54dfd9120a851c974f95450f00683399006aa6e8abb057"}, - {file = "pydantic_core-2.18.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:44cd83ab6a51da80fb5adbd9560e26018e2ac7826f9626bc06ca3dc074cd198b"}, - {file = "pydantic_core-2.18.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:972658f4a72d02b8abfa2581d92d59f59897d2e9f7e708fdabe922f9087773af"}, - {file = "pydantic_core-2.18.4-cp38-none-win32.whl", hash = "sha256:1d886dc848e60cb7666f771e406acae54ab279b9f1e4143babc9c2258213daa2"}, - {file = "pydantic_core-2.18.4-cp38-none-win_amd64.whl", hash = "sha256:bb4462bd43c2460774914b8525f79b00f8f407c945d50881568f294c1d9b4443"}, - {file = "pydantic_core-2.18.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:44a688331d4a4e2129140a8118479443bd6f1905231138971372fcde37e43528"}, - {file = "pydantic_core-2.18.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a2fdd81edd64342c85ac7cf2753ccae0b79bf2dfa063785503cb85a7d3593223"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86110d7e1907ab36691f80b33eb2da87d780f4739ae773e5fc83fb272f88825f"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46387e38bd641b3ee5ce247563b60c5ca098da9c56c75c157a05eaa0933ed154"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:123c3cec203e3f5ac7b000bd82235f1a3eced8665b63d18be751f115588fea30"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dc1803ac5c32ec324c5261c7209e8f8ce88e83254c4e1aebdc8b0a39f9ddb443"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53db086f9f6ab2b4061958d9c276d1dbe3690e8dd727d6abf2321d6cce37fa94"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abc267fa9837245cc28ea6929f19fa335f3dc330a35d2e45509b6566dc18be23"}, - {file = "pydantic_core-2.18.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a0d829524aaefdebccb869eed855e2d04c21d2d7479b6cada7ace5448416597b"}, - {file = "pydantic_core-2.18.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:509daade3b8649f80d4e5ff21aa5673e4ebe58590b25fe42fac5f0f52c6f034a"}, - {file = "pydantic_core-2.18.4-cp39-none-win32.whl", hash = "sha256:ca26a1e73c48cfc54c4a76ff78df3727b9d9f4ccc8dbee4ae3f73306a591676d"}, - {file = "pydantic_core-2.18.4-cp39-none-win_amd64.whl", hash = "sha256:c67598100338d5d985db1b3d21f3619ef392e185e71b8d52bceacc4a7771ea7e"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:574d92eac874f7f4db0ca653514d823a0d22e2354359d0759e3f6a406db5d55d"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1f4d26ceb5eb9eed4af91bebeae4b06c3fb28966ca3a8fb765208cf6b51102ab"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77450e6d20016ec41f43ca4a6c63e9fdde03f0ae3fe90e7c27bdbeaece8b1ed4"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d323a01da91851a4f17bf592faf46149c9169d68430b3146dcba2bb5e5719abc"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43d447dd2ae072a0065389092a231283f62d960030ecd27565672bd40746c507"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:578e24f761f3b425834f297b9935e1ce2e30f51400964ce4801002435a1b41ef"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:81b5efb2f126454586d0f40c4d834010979cb80785173d1586df845a632e4e6d"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ab86ce7c8f9bea87b9d12c7f0af71102acbf5ecbc66c17796cff45dae54ef9a5"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:90afc12421df2b1b4dcc975f814e21bc1754640d502a2fbcc6d41e77af5ec312"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:51991a89639a912c17bef4b45c87bd83593aee0437d8102556af4885811d59f5"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:293afe532740370aba8c060882f7d26cfd00c94cae32fd2e212a3a6e3b7bc15e"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48ece5bde2e768197a2d0f6e925f9d7e3e826f0ad2271120f8144a9db18d5c8"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eae237477a873ab46e8dd748e515c72c0c804fb380fbe6c85533c7de51f23a8f"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:834b5230b5dfc0c1ec37b2fda433b271cbbc0e507560b5d1588e2cc1148cf1ce"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e858ac0a25074ba4bce653f9b5d0a85b7456eaddadc0ce82d3878c22489fa4ee"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2fd41f6eff4c20778d717af1cc50eca52f5afe7805ee530a4fbd0bae284f16e9"}, - {file = "pydantic_core-2.18.4.tar.gz", hash = "sha256:ec3beeada09ff865c344ff3bc2f427f5e6c26401cc6113d77e372c3fdac73864"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, + {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, + {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, + {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, + {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, + {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, + {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, + {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, + {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, + {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, + {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, + {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, + {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, + {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, ] [package.dependencies] @@ -1506,13 +1531,13 @@ testing = ["covdefaults (>=2.3)", "pytest (>=8.2.2)", "pytest-cov (>=5)", "pytes [[package]] name = "pytest" -version = "8.2.2" +version = "8.3.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, - {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, + {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, + {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, ] [package.dependencies] @@ -1520,7 +1545,7 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.5,<2.0" +pluggy = ">=1.5,<2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] @@ -1528,13 +1553,13 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.23.7" +version = "0.23.8" description = "Pytest support for asyncio" optional = false python-versions = ">=3.8" files = [ - {file = "pytest_asyncio-0.23.7-py3-none-any.whl", hash = "sha256:009b48127fbe44518a547bddd25611551b0e43ccdbf1e67d12479f569832c20b"}, - {file = "pytest_asyncio-0.23.7.tar.gz", hash = "sha256:5f5c72948f4c49e7db4f29f2521d4031f1c27f86e57b046126654083d4770268"}, + {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, + {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, ] [package.dependencies] @@ -1666,6 +1691,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -1828,49 +1854,49 @@ dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] [[package]] name = "sphinxcontrib-applehelp" -version = "1.0.8" +version = "2.0.0" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" optional = true python-versions = ">=3.9" files = [ - {file = "sphinxcontrib_applehelp-1.0.8-py3-none-any.whl", hash = "sha256:cb61eb0ec1b61f349e5cc36b2028e9e7ca765be05e49641c97241274753067b4"}, - {file = "sphinxcontrib_applehelp-1.0.8.tar.gz", hash = "sha256:c40a4f96f3776c4393d933412053962fac2b84f4c99a7982ba42e09576a70619"}, + {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, + {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, ] [package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-devhelp" -version = "1.0.6" +version = "2.0.0" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" optional = true python-versions = ">=3.9" files = [ - {file = "sphinxcontrib_devhelp-1.0.6-py3-none-any.whl", hash = "sha256:6485d09629944511c893fa11355bda18b742b83a2b181f9a009f7e500595c90f"}, - {file = "sphinxcontrib_devhelp-1.0.6.tar.gz", hash = "sha256:9893fd3f90506bc4b97bdb977ceb8fbd823989f4316b28c3841ec128544372d3"}, + {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, + {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, ] [package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-htmlhelp" -version = "2.0.5" +version = "2.1.0" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = true python-versions = ">=3.9" files = [ - {file = "sphinxcontrib_htmlhelp-2.0.5-py3-none-any.whl", hash = "sha256:393f04f112b4d2f53d93448d4bce35842f62b307ccdc549ec1585e950bc35e04"}, - {file = "sphinxcontrib_htmlhelp-2.0.5.tar.gz", hash = "sha256:0dc87637d5de53dd5eec3a6a01753b1ccf99494bd756aafecd74b4fa9e729015"}, + {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, + {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, ] [package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["html5lib", "pytest"] @@ -1918,33 +1944,33 @@ Sphinx = ">=1.7.0" [[package]] name = "sphinxcontrib-qthelp" -version = "1.0.7" +version = "2.0.0" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" optional = true python-versions = ">=3.9" files = [ - {file = "sphinxcontrib_qthelp-1.0.7-py3-none-any.whl", hash = "sha256:e2ae3b5c492d58fcbd73281fbd27e34b8393ec34a073c792642cd8e529288182"}, - {file = "sphinxcontrib_qthelp-1.0.7.tar.gz", hash = "sha256:053dedc38823a80a7209a80860b16b722e9e0209e32fea98c90e4e6624588ed6"}, + {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, + {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, ] [package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] -test = ["pytest"] +test = ["defusedxml (>=0.7.1)", "pytest"] [[package]] name = "sphinxcontrib-serializinghtml" -version = "1.1.10" +version = "2.0.0" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" optional = true python-versions = ">=3.9" files = [ - {file = "sphinxcontrib_serializinghtml-1.1.10-py3-none-any.whl", hash = "sha256:326369b8df80a7d2d8d7f99aa5ac577f51ea51556ed974e7716cfd4fca3f6cb7"}, - {file = "sphinxcontrib_serializinghtml-1.1.10.tar.gz", hash = "sha256:93f3f5dc458b91b192fe10c397e324f262cf163d79f3282c158e8436a2c4511f"}, + {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, + {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, ] [package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["pytest"] @@ -1986,30 +2012,30 @@ files = [ [[package]] name = "tox" -version = "4.15.1" +version = "4.16.0" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" files = [ - {file = "tox-4.15.1-py3-none-any.whl", hash = "sha256:f00a5dc4222b358e69694e47e3da0227ac41253509bca9f45aa8f012053e8d9d"}, - {file = "tox-4.15.1.tar.gz", hash = "sha256:53a092527d65e873e39213ebd4bd027a64623320b6b0326136384213f95b7076"}, + {file = "tox-4.16.0-py3-none-any.whl", hash = "sha256:61e101061b977b46cf00093d4319438055290ad0009f84497a07bf2d2d7a06d0"}, + {file = "tox-4.16.0.tar.gz", hash = "sha256:43499656f9949edb681c0f907f86fbfee98677af9919d8b11ae5ad77cb800748"}, ] [package.dependencies] -cachetools = ">=5.3.2" +cachetools = ">=5.3.3" chardet = ">=5.2" colorama = ">=0.4.6" -filelock = ">=3.13.1" -packaging = ">=23.2" -platformdirs = ">=4.1" -pluggy = ">=1.3" -pyproject-api = ">=1.6.1" +filelock = ">=3.15.4" +packaging = ">=24.1" +platformdirs = ">=4.2.2" +pluggy = ">=1.5" +pyproject-api = ">=1.7.1" tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} -virtualenv = ">=20.25" +virtualenv = ">=20.26.3" [package.extras] -docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.25.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"] -testing = ["build[virtualenv] (>=1.0.3)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=8.0.2)", "distlib (>=0.3.8)", "flaky (>=3.7)", "hatch-vcs (>=0.4)", "hatchling (>=1.21)", "psutil (>=5.9.7)", "pytest (>=7.4.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-xdist (>=3.5)", "re-assert (>=1.1)", "time-machine (>=2.13)", "wheel (>=0.42)"] +docs = ["furo (>=2024.5.6)", "sphinx (>=7.3.7)", "sphinx-argparse-cli (>=1.16)", "sphinx-autodoc-typehints (>=2.2.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"] +testing = ["build[virtualenv] (>=1.2.1)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=9.1)", "distlib (>=0.3.8)", "flaky (>=3.8.1)", "hatch-vcs (>=0.4)", "hatchling (>=1.25)", "psutil (>=6)", "pytest (>=8.2.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-xdist (>=3.6.1)", "re-assert (>=1.1)", "setuptools (>=70.2)", "time-machine (>=2.14.2)", "wheel (>=0.43)"] [[package]] name = "typing-extensions" @@ -2061,12 +2087,13 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [[package]] name = "voluptuous" -version = "0.15.1" +version = "0.15.2" description = "Python data validation library" optional = false python-versions = ">=3.9" files = [ - {file = "voluptuous-0.15.1.tar.gz", hash = "sha256:4ba7f38f624379ecd02666e87e99cb24b6f5997a28258d3302c761d1a2c35d00"}, + {file = "voluptuous-0.15.2-py3-none-any.whl", hash = "sha256:016348bc7788a9af9520b1764ebd4de0df41fe2138ebe9e06fa036bf86a65566"}, + {file = "voluptuous-0.15.2.tar.gz", hash = "sha256:6ffcab32c4d3230b4d2af3a577c87e1908a714a11f6f95570456b1849b0279aa"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index c7288e101..aa532869f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.5" +version = "0.7.1" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 633f57dcce86b20ea67a34e6488d0cd71abb9df3 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 6 Aug 2024 21:03:35 +0200 Subject: [PATCH 553/892] Enable python 3.13, allow pre-releases for CI (#1086) Adds py3.13 to the CI. Thanks to @hugovk for [pointing out `allow-prereleases` on his blog post](https://dev.to/hugovk/help-test-python-313-14j1)! --------- Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --- .github/actions/setup/action.yaml | 1 + .github/workflows/ci.yml | 6 +- poetry.lock | 149 ++++++++++++++++-------------- 3 files changed, 82 insertions(+), 74 deletions(-) diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml index 8010a4ed2..91f101ab0 100644 --- a/.github/actions/setup/action.yaml +++ b/.github/actions/setup/action.yaml @@ -19,6 +19,7 @@ runs: id: setup-python with: python-version: "${{ inputs.python-version }}" + allow-prereleases: true - name: Setup pipx environment Variables id: pipx-env-setup diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c957f8904..dc1dbf8ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,16 +62,12 @@ jobs: strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "pypy-3.9", "pypy-3.10"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.9", "pypy-3.10"] os: [ubuntu-latest, macos-latest, windows-latest] extras: [false, true] exclude: - os: macos-latest extras: true - # setup-python not currently working with macos-latest - # https://github.com/actions/setup-python/issues/808 - - os: macos-latest - python-version: "3.9" - os: windows-latest extras: true - os: ubuntu-latest diff --git a/poetry.lock b/poetry.lock index 8e9667263..2239fe1c2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -205,22 +205,22 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} [[package]] name = "attrs" -version = "23.2.0" +version = "24.2.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.7" files = [ - {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, - {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, ] [package.extras] -cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] -dev = ["attrs[tests]", "pre-commit"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] -tests = ["attrs[tests-no-zope]", "zope-interface"] -tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] -tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] [[package]] name = "babel" @@ -260,63 +260,78 @@ files = [ [[package]] name = "cffi" -version = "1.16.0" +version = "1.17.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" files = [ - {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, - {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, - {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, - {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, - {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, - {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, - {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, - {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, - {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, - {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, - {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, - {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, - {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, - {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, - {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, - {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, - {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, - {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, - {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, - {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, - {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, - {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, - {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, - {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, - {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, - {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, - {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, + {file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"}, + {file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"}, + {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"}, + {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"}, + {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"}, + {file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"}, + {file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"}, + {file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"}, + {file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"}, + {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"}, + {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"}, + {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"}, + {file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"}, + {file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"}, + {file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"}, + {file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"}, + {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"}, + {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"}, + {file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"}, + {file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"}, + {file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"}, + {file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"}, + {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"}, + {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"}, + {file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"}, + {file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"}, + {file = "cffi-1.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c"}, + {file = "cffi-1.17.0-cp38-cp38-win32.whl", hash = "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499"}, + {file = "cffi-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c"}, + {file = "cffi-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2"}, + {file = "cffi-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932"}, + {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693"}, + {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3"}, + {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4"}, + {file = "cffi-1.17.0-cp39-cp39-win32.whl", hash = "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb"}, + {file = "cffi-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29"}, + {file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"}, ] [package.dependencies] @@ -1678,7 +1693,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1686,7 +1700,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, @@ -1712,7 +1725,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1720,7 +1732,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -2109,13 +2120,13 @@ files = [ [[package]] name = "xdoctest" -version = "1.1.5" +version = "1.1.6" description = "A rewrite of the builtin doctest module" optional = false python-versions = ">=3.6" files = [ - {file = "xdoctest-1.1.5-py3-none-any.whl", hash = "sha256:f36fe64d7c0ad0553dbff39ff05c43a0aab69d313466f24a38d00e757182ade0"}, - {file = "xdoctest-1.1.5.tar.gz", hash = "sha256:89b0c3ad7fe03a068e22a457ab18c38fc70c62329c2963f43954b83c29374e66"}, + {file = "xdoctest-1.1.6-py3-none-any.whl", hash = "sha256:a6f673df8c82b8fe0adc536f14c523464f25c6d2b733ed78888b8f8d6c46012e"}, + {file = "xdoctest-1.1.6.tar.gz", hash = "sha256:00ec7bde36addbedf5d1db0db57b6b669a7a4b29ad2d16480950556644f02109"}, ] [package.extras] From 4669e086053664d43f9bcd29ff45e82df7570a48 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Aug 2024 16:33:54 -0500 Subject: [PATCH 554/892] Improve performance of dict merge code (#1097) Co-authored-by: Teemu R. --- kasa/iot/iotdevice.py | 13 +------------ kasa/iot/iotmodule.py | 17 +++++++++-------- kasa/tests/test_iotdevice.py | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 234ea9feb..1c8b311c9 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -14,7 +14,6 @@ from __future__ import annotations -import collections.abc import functools import inspect import logging @@ -29,22 +28,12 @@ from ..module import Module from ..modulemapping import ModuleMapping, ModuleName from ..protocol import BaseProtocol -from .iotmodule import IotModule +from .iotmodule import IotModule, merge from .modules import Emeter _LOGGER = logging.getLogger(__name__) -def merge(d, u): - """Update dict recursively.""" - for k, v in u.items(): - if isinstance(v, collections.abc.Mapping): - d[k] = merge(d.get(k, {}), v) - else: - d[k] = v - return d - - def requires_update(f): """Indicate that `update` should be called before accessing this method.""" # noqa: D202 if inspect.iscoroutinefunction(f): diff --git a/kasa/iot/iotmodule.py b/kasa/iot/iotmodule.py index ca0c3adb7..7829c8566 100644 --- a/kasa/iot/iotmodule.py +++ b/kasa/iot/iotmodule.py @@ -1,6 +1,5 @@ """Base class for IOT module implementations.""" -import collections import logging from ..exceptions import KasaException @@ -9,15 +8,17 @@ _LOGGER = logging.getLogger(__name__) -# TODO: This is used for query constructing, check for a better place -def merge(d, u): +def _merge_dict(dest: dict, source: dict) -> dict: """Update dict recursively.""" - for k, v in u.items(): - if isinstance(v, collections.abc.Mapping): - d[k] = merge(d.get(k, {}), v) + for k, v in source.items(): + if k in dest and type(v) is dict: # noqa: E721 - only accepts `dict` type + _merge_dict(dest[k], v) else: - d[k] = v - return d + dest[k] = v + return dest + + +merge = _merge_dict class IotModule(Module): diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py index df37f762f..976144fcb 100644 --- a/kasa/tests/test_iotdevice.py +++ b/kasa/tests/test_iotdevice.py @@ -18,6 +18,7 @@ from kasa import KasaException, Module from kasa.iot import IotDevice +from kasa.iot.iotmodule import _merge_dict from .conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on from .device_fixtures import device_iot, has_emeter_iot, no_emeter_iot @@ -292,3 +293,21 @@ async def test_get_modules(): module = dummy_device.modules.get(Module.Cloud) assert module is None + + +def test_merge_dict(): + """Test the recursive dict merge.""" + dest = {"a": 1, "b": {"c": 2, "d": 3}} + source = {"b": {"c": 4, "e": 5}} + assert _merge_dict(dest, source) == {"a": 1, "b": {"c": 4, "d": 3, "e": 5}} + + dest = {"smartlife.iot.common.emeter": {"get_realtime": None}} + source = { + "smartlife.iot.common.emeter": {"get_daystat": {"month": 8, "year": 2024}} + } + assert _merge_dict(dest, source) == { + "smartlife.iot.common.emeter": { + "get_realtime": None, + "get_daystat": {"month": 8, "year": 2024}, + } + } From ae1ee388f636d20559f2739d7eae5042945d01be Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 22 Aug 2024 16:14:47 +0100 Subject: [PATCH 555/892] Remove top level await xdoctest fixture (#1098) This is now natively supported since [xdoctest #158](https://github.com/Erotemic/xdoctest/pull/158) has been released so no need for the monkey patching fixture anymore. --- kasa/tests/test_readme_examples.py | 48 +----------------------------- poetry.lock | 38 ++++++++++++++--------- pyproject.toml | 2 +- 3 files changed, 26 insertions(+), 62 deletions(-) diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index f024c6729..ec312c5a4 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -146,7 +146,7 @@ def test_tutorial_examples(readmes_mock): @pytest.fixture -async def readmes_mock(mocker, top_level_await): +async def readmes_mock(mocker): fixture_infos = { "127.0.0.1": get_fixture_info("KP303(UK)_1.0_1.0.3.json", "IOT"), # Strip "127.0.0.2": get_fixture_info("HS110(EU)_1.0_1.2.5.json", "IOT"), # Plug @@ -155,49 +155,3 @@ async def readmes_mock(mocker, top_level_await): "127.0.0.5": get_fixture_info("HS220(US)_1.0_1.5.7.json", "IOT"), # Dimmer } yield patch_discovery(fixture_infos, mocker) - - -@pytest.fixture -def top_level_await(mocker): - """Fixture to enable top level awaits in doctests. - - Uses the async exec feature of python to patch the builtins xdoctest uses. - See https://github.com/python/cpython/issues/78797 - """ - import ast - from inspect import CO_COROUTINE - from types import CodeType - - orig_exec = exec - orig_eval = eval - orig_compile = compile - - def patch_exec(source, globals=None, locals=None, /, **kwargs): - if ( - isinstance(source, CodeType) - and source.co_flags & CO_COROUTINE == CO_COROUTINE - ): - asyncio.run(orig_eval(source, globals, locals)) - else: - orig_exec(source, globals, locals, **kwargs) - - def patch_eval(source, globals=None, locals=None, /, **kwargs): - if ( - isinstance(source, CodeType) - and source.co_flags & CO_COROUTINE == CO_COROUTINE - ): - return asyncio.run(orig_eval(source, globals, locals, **kwargs)) - else: - return orig_eval(source, globals, locals, **kwargs) - - def patch_compile( - source, filename, mode, flags=0, dont_inherit=False, optimize=-1, **kwargs - ): - flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT - return orig_compile( - source, filename, mode, flags, dont_inherit, optimize, **kwargs - ) - - mocker.patch("builtins.eval", side_effect=patch_eval) - mocker.patch("builtins.exec", side_effect=patch_exec) - mocker.patch("builtins.compile", side_effect=patch_compile) diff --git a/poetry.lock b/poetry.lock index 2239fe1c2..22d1fce35 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1244,6 +1244,8 @@ files = [ {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:960db0e31c4e52fa0fc3ecbaea5b2d3b58f379e32a95ae6b0ebeaa25b93dfd34"}, {file = "orjson-3.10.6-cp312-none-win32.whl", hash = "sha256:a6ea7afb5b30b2317e0bee03c8d34c8181bc5a36f2afd4d0952f378972c4efd5"}, {file = "orjson-3.10.6-cp312-none-win_amd64.whl", hash = "sha256:874ce88264b7e655dde4aeaacdc8fd772a7962faadfb41abe63e2a4861abc3dc"}, + {file = "orjson-3.10.6-cp313-none-win32.whl", hash = "sha256:efdf2c5cde290ae6b83095f03119bdc00303d7a03b42b16c54517baa3c4ca3d0"}, + {file = "orjson-3.10.6-cp313-none-win_amd64.whl", hash = "sha256:8e190fe7888e2e4392f52cafb9626113ba135ef53aacc65cd13109eb9746c43e"}, {file = "orjson-3.10.6-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:66680eae4c4e7fc193d91cfc1353ad6d01b4801ae9b5314f17e11ba55e934183"}, {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff75b425db5ef8e8f23af93c80f072f97b4fb3afd4af44482905c9f588da28"}, {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3722fddb821b6036fd2a3c814f6bd9b57a89dc6337b9924ecd614ebce3271394"}, @@ -1693,6 +1695,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1700,6 +1703,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, @@ -1725,6 +1729,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1732,6 +1737,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -2120,26 +2126,30 @@ files = [ [[package]] name = "xdoctest" -version = "1.1.6" +version = "1.2.0" description = "A rewrite of the builtin doctest module" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "xdoctest-1.1.6-py3-none-any.whl", hash = "sha256:a6f673df8c82b8fe0adc536f14c523464f25c6d2b733ed78888b8f8d6c46012e"}, - {file = "xdoctest-1.1.6.tar.gz", hash = "sha256:00ec7bde36addbedf5d1db0db57b6b669a7a4b29ad2d16480950556644f02109"}, + {file = "xdoctest-1.2.0-py3-none-any.whl", hash = "sha256:0f1ecf5939a687bd1fc8deefbff1743c65419cce26dff908f8b84c93fbe486bc"}, + {file = "xdoctest-1.2.0.tar.gz", hash = "sha256:d8cfca6d8991e488d33f756e600d35b9fdf5efd5c3a249d644efcbbbd2ed5863"}, ] [package.extras] -all = ["IPython (>=7.10.0)", "IPython (>=7.23.1)", "Pygments (>=2.0.0)", "Pygments (>=2.4.1)", "attrs (>=19.2.0)", "colorama (>=0.4.1)", "debugpy (>=1.0.0)", "debugpy (>=1.0.0)", "debugpy (>=1.0.0)", "debugpy (>=1.3.0)", "debugpy (>=1.6.0)", "ipykernel (>=5.2.0)", "ipykernel (>=6.0.0)", "ipykernel (>=6.11.0)", "ipython-genutils (>=0.2.0)", "jedi (>=0.16)", "jinja2 (>=3.0.0)", "jupyter-client (>=6.1.5)", "jupyter-client (>=7.0.0)", "jupyter-core (>=4.7.0)", "nbconvert (>=6.0.0)", "nbconvert (>=6.1.0)", "pyflakes (>=2.2.0)", "pytest (>=4.6.0)", "pytest (>=4.6.0)", "pytest (>=6.2.5)", "pytest-cov (>=3.0.0)", "tomli (>=0.2.0)", "typing (>=3.7.4)"] -all-strict = ["IPython (==7.10.0)", "IPython (==7.23.1)", "Pygments (==2.0.0)", "Pygments (==2.4.1)", "attrs (==19.2.0)", "colorama (==0.4.1)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==5.2.0)", "ipykernel (==6.0.0)", "ipykernel (==6.11.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==6.1.5)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "nbconvert (==6.1.0)", "pyflakes (==2.2.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)", "tomli (==0.2.0)", "typing (==3.7.4)"] -colors = ["Pygments", "Pygments", "colorama"] -jupyter = ["IPython", "IPython", "attrs", "debugpy", "debugpy", "debugpy", "debugpy", "debugpy", "ipykernel", "ipykernel", "ipykernel", "ipython-genutils", "jedi", "jinja2", "jupyter-client", "jupyter-client", "jupyter-core", "nbconvert", "nbconvert"] -optional = ["IPython (>=7.10.0)", "IPython (>=7.23.1)", "Pygments (>=2.0.0)", "Pygments (>=2.4.1)", "attrs (>=19.2.0)", "colorama (>=0.4.1)", "debugpy (>=1.0.0)", "debugpy (>=1.0.0)", "debugpy (>=1.0.0)", "debugpy (>=1.3.0)", "debugpy (>=1.6.0)", "ipykernel (>=5.2.0)", "ipykernel (>=6.0.0)", "ipykernel (>=6.11.0)", "ipython-genutils (>=0.2.0)", "jedi (>=0.16)", "jinja2 (>=3.0.0)", "jupyter-client (>=6.1.5)", "jupyter-client (>=7.0.0)", "jupyter-core (>=4.7.0)", "nbconvert (>=6.0.0)", "nbconvert (>=6.1.0)", "pyflakes (>=2.2.0)", "tomli (>=0.2.0)"] -optional-strict = ["IPython (==7.10.0)", "IPython (==7.23.1)", "Pygments (==2.0.0)", "Pygments (==2.4.1)", "attrs (==19.2.0)", "colorama (==0.4.1)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==5.2.0)", "ipykernel (==6.0.0)", "ipykernel (==6.11.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==6.1.5)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "nbconvert (==6.1.0)", "pyflakes (==2.2.0)", "tomli (==0.2.0)"] -tests = ["pytest (>=4.6.0)", "pytest (>=4.6.0)", "pytest (>=6.2.5)", "pytest-cov (>=3.0.0)", "typing (>=3.7.4)"] -tests-binary = ["cmake", "cmake", "ninja", "ninja", "pybind11", "pybind11", "scikit-build", "scikit-build"] +all = ["IPython (>=7.23.1)", "Pygments (>=2.0.0)", "Pygments (>=2.4.1)", "attrs (>=19.2.0)", "colorama (>=0.4.1)", "debugpy (>=1.0.0)", "debugpy (>=1.3.0)", "debugpy (>=1.6.0)", "ipykernel (>=6.0.0)", "ipykernel (>=6.11.0)", "ipython-genutils (>=0.2.0)", "jedi (>=0.16)", "jinja2 (>=3.0.0)", "jupyter-client (>=7.0.0)", "jupyter-core (>=4.7.0)", "nbconvert (>=6.0.0)", "nbconvert (>=6.1.0)", "pyflakes (>=2.2.0)", "pytest (>=4.6.0)", "pytest (>=6.2.5)", "pytest-cov (>=3.0.0)", "tomli (>=0.2.0)"] +all-strict = ["IPython (==7.23.1)", "Pygments (==2.0.0)", "Pygments (==2.4.1)", "attrs (==19.2.0)", "colorama (==0.4.1)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==6.0.0)", "ipykernel (==6.11.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "nbconvert (==6.1.0)", "pyflakes (==2.2.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)", "tomli (==0.2.0)"] +colors = ["Pygments (>=2.0.0)", "Pygments (>=2.4.1)", "colorama (>=0.4.1)"] +colors-strict = ["Pygments (==2.0.0)", "Pygments (==2.4.1)", "colorama (==0.4.1)"] +docs = ["Pygments (>=2.9.0)", "myst-parser (>=0.18.0)", "sphinx (>=5.0.1)", "sphinx-autoapi (>=1.8.4)", "sphinx-autobuild (>=2021.3.14)", "sphinx-reredirects (>=0.0.1)", "sphinx-rtd-theme (>=1.0.0)", "sphinxcontrib-napoleon (>=0.7)"] +docs-strict = ["Pygments (==2.9.0)", "myst-parser (==0.18.0)", "sphinx (==5.0.1)", "sphinx-autoapi (==1.8.4)", "sphinx-autobuild (==2021.3.14)", "sphinx-reredirects (==0.0.1)", "sphinx-rtd-theme (==1.0.0)", "sphinxcontrib-napoleon (==0.7)"] +jupyter = ["IPython (>=7.23.1)", "attrs (>=19.2.0)", "debugpy (>=1.0.0)", "debugpy (>=1.3.0)", "debugpy (>=1.6.0)", "ipykernel (>=6.0.0)", "ipykernel (>=6.11.0)", "ipython-genutils (>=0.2.0)", "jedi (>=0.16)", "jinja2 (>=3.0.0)", "jupyter-client (>=7.0.0)", "jupyter-core (>=4.7.0)", "nbconvert (>=6.0.0)", "nbconvert (>=6.1.0)"] +jupyter-strict = ["IPython (==7.23.1)", "attrs (==19.2.0)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==6.0.0)", "ipykernel (==6.11.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "nbconvert (==6.1.0)"] +optional = ["IPython (>=7.23.1)", "Pygments (>=2.0.0)", "Pygments (>=2.4.1)", "attrs (>=19.2.0)", "colorama (>=0.4.1)", "debugpy (>=1.0.0)", "debugpy (>=1.3.0)", "debugpy (>=1.6.0)", "ipykernel (>=6.0.0)", "ipykernel (>=6.11.0)", "ipython-genutils (>=0.2.0)", "jedi (>=0.16)", "jinja2 (>=3.0.0)", "jupyter-client (>=7.0.0)", "jupyter-core (>=4.7.0)", "nbconvert (>=6.0.0)", "nbconvert (>=6.1.0)", "pyflakes (>=2.2.0)", "tomli (>=0.2.0)"] +optional-strict = ["IPython (==7.23.1)", "Pygments (==2.0.0)", "Pygments (==2.4.1)", "attrs (==19.2.0)", "colorama (==0.4.1)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==6.0.0)", "ipykernel (==6.11.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "nbconvert (==6.1.0)", "pyflakes (==2.2.0)", "tomli (==0.2.0)"] +tests = ["pytest (>=4.6.0)", "pytest (>=6.2.5)", "pytest-cov (>=3.0.0)"] +tests-binary = ["cmake (>=3.21.2)", "cmake (>=3.25.0)", "ninja (>=1.10.2)", "ninja (>=1.11.1)", "pybind11 (>=2.10.3)", "pybind11 (>=2.7.1)", "scikit-build (>=0.11.1)", "scikit-build (>=0.16.1)"] tests-binary-strict = ["cmake (==3.21.2)", "cmake (==3.25.0)", "ninja (==1.10.2)", "ninja (==1.11.1)", "pybind11 (==2.10.3)", "pybind11 (==2.7.1)", "scikit-build (==0.11.1)", "scikit-build (==0.16.1)"] -tests-strict = ["pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)", "typing (==3.7.4)"] +tests-strict = ["pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)"] [[package]] name = "yarl" @@ -2267,4 +2277,4 @@ speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "dcd115ccc1e4fddc72845600e2a230d9eff978a2092a7eda1822c9a8f1773d2c" +content-hash = "77c2a966172a89c0119dba1fdc03dab67d80fd33939424633cd02731b699d6af" diff --git a/pyproject.toml b/pyproject.toml index aa532869f..6aef8b630 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ toml = "*" tox = "*" pytest-mock = "*" codecov = "*" -xdoctest = "*" +xdoctest = ">=1.2.0" coverage = {version = "*", extras = ["toml"]} pytest-timeout = "^2" pytest-freezer = "^0.4" From beb7ca2242907fd3e95cf394d815c2529414f4a0 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 22 Aug 2024 16:15:04 +0100 Subject: [PATCH 556/892] Fix incorrect docs link in contributing.md (#1099) --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f4005438..b6d851f9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ # Contributing to python-kasa All types of contributions are very welcome. -To make the process as straight-forward as possible, we have written [some instructions in our docs](https://python-miio.readthedocs.io/en/latest/contribute.html) to get you started. +To make the process as straight-forward as possible, we have written [some instructions in our docs](https://python-kasa.readthedocs.io/en/latest/contribute.html) to get you started. From b6339be9ec45886ba479ac72825057dd475a832b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Aug 2024 04:56:33 -0500 Subject: [PATCH 557/892] Fix logging in iotdevice when a module is module not supported (#1100) Debug logger was generating the `repr()` of each module and throwing it away because it had a `%` instead of a `,` --- kasa/iot/iotdevice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 1c8b311c9..0235cc9fe 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -371,7 +371,7 @@ async def _modular_update(self, req: dict) -> None: est_response_size = 1024 if "system" in req else 0 for module in self._modules.values(): if not module.is_supported: - _LOGGER.debug("Module %s not supported, skipping" % module) + _LOGGER.debug("Module %s not supported, skipping", module) continue est_response_size += module.estimated_query_response_size From 2706e9a5be40ed5a5ac691ca72c4c94bd326369b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 23 Aug 2024 19:23:10 +0100 Subject: [PATCH 558/892] Add missing typing_extensions dependency (#1101) --- poetry.lock | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 22d1fce35..12930907b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2277,4 +2277,4 @@ speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "77c2a966172a89c0119dba1fdc03dab67d80fd33939424633cd02731b699d6af" +content-hash = "c29200a35b10776b74812daf37b88bf400f7f496825954f7a9eba718f215ae3b" diff --git a/pyproject.toml b/pyproject.toml index 6aef8b630..0d67a5073 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ docutils = { version = ">=0.17", optional = true } # enhanced cli support ptpython = { version = "*", optional = true } rich = { version = "*", optional = true } +typing-extensions = ">=4.12.2,<5.0" [tool.poetry.group.dev.dependencies] pytest = "*" From 3e43781bb23d2aad1025990fba4a7d4f1714fe5f Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 30 Aug 2024 16:13:14 +0200 Subject: [PATCH 559/892] Add flake8-logging (LOG) and flake8-logging-format (G) for ruff (#1104) Enables rules LOG (flake8-logging) and G (flake8-logging-format) for ruff. This will catch eager log message formatting, among other similar issues. --- kasa/aestransport.py | 3 +-- kasa/device_factory.py | 7 +++++-- kasa/discover.py | 2 +- kasa/emeterstatus.py | 2 +- kasa/iot/iotdevice.py | 2 +- kasa/klaptransport.py | 28 ++++++++++++++-------------- kasa/smart/modules/firmware.py | 2 +- kasa/smart/smartmodule.py | 2 +- kasa/smartprotocol.py | 5 +++-- kasa/tests/fakeprotocol_iot.py | 4 +++- pyproject.toml | 2 ++ 11 files changed, 33 insertions(+), 26 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index cd0f24b38..81dc79a85 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -278,9 +278,8 @@ async def _generate_key_pair_payload(self) -> AsyncGenerator: + "\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}") + _LOGGER.debug("Handshake request: %s", request_body) yield json_dumps(request_body).encode() async def perform_handshake(self) -> None: diff --git a/kasa/device_factory.py b/kasa/device_factory.py index ff2c9fcc8..1bb6fc4ab 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -84,8 +84,11 @@ def _perf_log(has_params, perf_type): if debug_enabled: end_time = time.perf_counter() _LOGGER.debug( - f"Device {config.host} with connection params {has_params} " - + f"took {end_time - start_time:.2f} seconds to {perf_type}", + "Device %s with connection params %s took %.2f seconds to %s", + config.host, + has_params, + end_time - start_time, + perf_type, ) start_time = time.perf_counter() diff --git a/kasa/discover.py b/kasa/discover.py index 7c1475978..b541dd7a4 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -262,7 +262,7 @@ def datagram_received(self, data, addr) -> None: self._handle_discovered_event() return except KasaException as ex: - _LOGGER.debug(f"[DISCOVERY] Unable to find device type for {ip}: {ex}") + _LOGGER.debug("[DISCOVERY] Unable to find device type for %s: %s", ip, ex) self.invalid_device_exceptions[ip] = ex self._handle_discovered_event() return diff --git a/kasa/emeterstatus.py b/kasa/emeterstatus.py index 41a43bc76..0112b33a5 100644 --- a/kasa/emeterstatus.py +++ b/kasa/emeterstatus.py @@ -87,5 +87,5 @@ def __getitem__(self, item): ): return value / 1000 - _LOGGER.debug(f"Unable to find value for '{item}'") + _LOGGER.debug("Unable to find value for '%s'", item) return None diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 0235cc9fe..2dbc5e77f 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -198,7 +198,7 @@ def modules(self) -> ModuleMapping[IotModule]: def add_module(self, name: str | ModuleName[Module], module: IotModule): """Register a module.""" if name in self._modules: - _LOGGER.debug("Module %s already registered, ignoring..." % name) + _LOGGER.debug("Module %s already registered, ignoring...", name) return _LOGGER.debug("Adding module %s", module) diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 97b231453..8e22dec07 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -153,8 +153,8 @@ async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( - "Handshake1 posted at %s. Host is %s, Response" - + "status is %s, Request was %s", + "Handshake1 posted at %s. Host is %s, " + "Response status is %s, Request was %s", datetime.datetime.now(), self._host, response_status, @@ -179,7 +179,7 @@ async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( "Handshake1 success at %s. Host is %s, " - + "Server remote_seed is: %s, server hash is: %s", + "Server remote_seed is: %s, server hash is: %s", datetime.datetime.now(), self._host, remote_seed.hex(), @@ -211,9 +211,10 @@ async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: 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", + "Server response doesn't match our expected hash on ip %s, " + "but an authentication with %s default credentials matched", self._host, + key, ) return local_seed, remote_seed, self._default_credentials_auth_hash[key] # type: ignore @@ -231,8 +232,8 @@ async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: if blank_seed_auth_hash == server_hash: _LOGGER.debug( - "Server response doesn't match our expected hash on ip %s" - + " but an authentication with blank credentials matched", + "Server response doesn't match our expected hash on ip %s, " + "but an authentication with blank credentials matched", self._host, ) return local_seed, remote_seed, self._blank_auth_hash # type: ignore @@ -260,8 +261,8 @@ async def perform_handshake2( if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( - "Handshake2 posted %s. Host is %s, Response status is %s, " - + "Request was %s", + "Handshake2 posted %s. Host is %s, " + "Response status is %s, Request was %s", datetime.datetime.now(), self._host, response_status, @@ -338,18 +339,17 @@ async def send(self, request: str): + f"Response status is {response_status}, Request was {request}" ) if response_status != 200: - _LOGGER.error("Query failed after successful authentication " + msg) + _LOGGER.error("Query failed after successful authentication: %s", msg) # If we failed with a security error, force a new handshake next time. if response_status == 403: self._handshake_done = False raise _RetryableError( - f"Got a security error from {self._host} after handshake " - + "completed" + "Got a security error from %s after handshake completed", self._host ) else: raise KasaException( - f"Device {self._host} responded with {response_status} to" - + f"request with seq {seq}" + f"Device {self._host} responded with {response_status} to " + f"request with seq {seq}" ) else: _LOGGER.debug("Device %s query posted %s", self._host, msg) diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index dc0483e71..21026676c 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -181,7 +181,7 @@ async def update( ) continue - _LOGGER.debug("Update state: %s" % state) + _LOGGER.debug("Update state: %s", state) if progress_cb is not None: asyncio.create_task(progress_cb(state)) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 0e6256a0f..9372b65d0 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -77,7 +77,7 @@ def __init__(self, device: SmartDevice, module: str): def __init_subclass__(cls, **kwargs): name = getattr(cls, "NAME", cls.__name__) - _LOGGER.debug("Registering %s" % cls) + _LOGGER.debug("Registering %s", cls) cls.REGISTERED_MODULES[name] = cls def _set_error(self, err: Exception | None): diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 8f92b94eb..211796949 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -309,8 +309,9 @@ async def _handle_response_lists( # In case the device returns empty lists avoid infinite looping if not next_batch[response_list_name]: _LOGGER.error( - f"Device {self._host} returned empty " - + f"results list for method {method}" + "Device %s returned empty results list for method %s", + self._host, + method, ) break response_result[response_list_name].extend(next_batch[response_list_name]) diff --git a/kasa/tests/fakeprotocol_iot.py b/kasa/tests/fakeprotocol_iot.py index 0a5433206..635f488d7 100644 --- a/kasa/tests/fakeprotocol_iot.py +++ b/kasa/tests/fakeprotocol_iot.py @@ -425,7 +425,9 @@ def get_response_for_command(cmd): return error(msg=f"command {cmd} not found") params = request[target][cmd] - _LOGGER.debug(f"Going to execute {target}.{cmd} (params: {params}).. ") + _LOGGER.debug( + "Going to execute %s.%s (params: %s).. ", target, cmd, params + ) if callable(proto[target][cmd]): res = proto[target][cmd](self, params, child_ids) diff --git a/pyproject.toml b/pyproject.toml index 0d67a5073..4c4bd57e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,6 +117,8 @@ select = [ "FA", # flake8-future-annotations "I", # isort "S", # bandit + "LOG", # flake8-logging + "G", # flake8-logging-format ] ignore = [ "D105", # Missing docstring in magic method From 6a86ffbbbadf4bd40e43cd9b81d8e650043c8f13 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 30 Aug 2024 17:30:07 +0200 Subject: [PATCH 560/892] Add flake8-pytest-style (PT) for ruff (#1105) This will catch common issues with pytest code. * Use `match` when using `pytest.raises()` for base exception types like `TypeError` or `ValueError` * Use tuples for `parametrize()` * Enforces `pytest.raises()` to contain simple statements, using `noqa` to skip this on some cases for now. * Fixes incorrect exception type (valueerror instead of typeerror) for iotdimmer. * Adds check valid types for `iotbulb.set_hsv` and `color` smart module. * Consolidate exception messages for common interface modules. --- kasa/iot/iotbulb.py | 12 ++- kasa/iot/iotdimmer.py | 16 +-- kasa/smart/modules/color.py | 16 ++- kasa/smart/modules/lighteffect.py | 2 +- kasa/tests/conftest.py | 2 +- kasa/tests/discovery_fixtures.py | 4 +- kasa/tests/smart/features/test_brightness.py | 14 +-- kasa/tests/smart/features/test_colortemp.py | 4 +- kasa/tests/smart/modules/test_autooff.py | 2 +- kasa/tests/smart/modules/test_contact.py | 2 +- kasa/tests/smart/modules/test_fan.py | 4 +- kasa/tests/smart/modules/test_firmware.py | 3 +- kasa/tests/smart/modules/test_humidity.py | 2 +- kasa/tests/smart/modules/test_light_effect.py | 2 +- .../smart/modules/test_light_strip_effect.py | 2 +- kasa/tests/smart/modules/test_motionsensor.py | 2 +- kasa/tests/smart/modules/test_temperature.py | 2 +- .../smart/modules/test_temperaturecontrol.py | 23 ++-- kasa/tests/smart/modules/test_waterleak.py | 2 +- kasa/tests/test_aestransport.py | 6 +- kasa/tests/test_bulb.py | 101 ++++++++++++++---- kasa/tests/test_common_modules.py | 26 +++-- kasa/tests/test_device.py | 6 +- kasa/tests/test_dimmer.py | 40 +++++-- kasa/tests/test_discovery.py | 8 +- kasa/tests/test_emeter.py | 4 +- kasa/tests/test_feature.py | 8 +- kasa/tests/test_httpclient.py | 4 +- kasa/tests/test_iotdevice.py | 2 +- kasa/tests/test_klapprotocol.py | 12 +-- kasa/tests/test_lightstrip.py | 4 +- kasa/tests/test_protocol.py | 51 ++++----- kasa/tests/test_readme_examples.py | 4 +- kasa/tests/test_smartprotocol.py | 2 +- kasa/tests/test_usage.py | 3 +- pyproject.toml | 1 + 36 files changed, 248 insertions(+), 150 deletions(-) diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index a979e4e62..8686790fa 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -388,10 +388,14 @@ async def _set_hsv( if not self._is_color: raise KasaException("Bulb does not support color.") - if not isinstance(hue, int) or not (0 <= hue <= 360): + if not isinstance(hue, int): + raise TypeError("Hue must be an integer.") + if not (0 <= hue <= 360): raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") - if not isinstance(saturation, int) or not (0 <= saturation <= 100): + if not isinstance(saturation, int): + raise TypeError("Saturation must be an integer.") + if not (0 <= saturation <= 100): raise ValueError( f"Invalid saturation value: {saturation} (valid range: 0-100%)" ) @@ -445,7 +449,9 @@ async def _set_color_temp( return await self._set_light_state(light_state, transition=transition) def _raise_for_invalid_brightness(self, value): - if not isinstance(value, int) or not (0 <= value <= 100): + if not isinstance(value, int): + raise TypeError("Brightness must be an integer") + if not (0 <= value <= 100): raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") @property # type: ignore diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index ca182e49f..04510fe27 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -118,7 +118,9 @@ async def _set_brightness(self, brightness: int, *, transition: int | None = Non ) if not 0 <= brightness <= 100: - raise ValueError("Brightness value %s is not valid." % brightness) + raise ValueError( + f"Invalid brightness value: {brightness} (valid range: 0-100%)" + ) # Dimmers do not support a brightness of 0, but bulbs do. # Coerce 0 to 1 to maintain the same interface between dimmers and bulbs. @@ -161,20 +163,18 @@ async def set_dimmer_transition(self, brightness: int, transition: int): A brightness value of 0 will turn off the dimmer. """ if not isinstance(brightness, int): - raise ValueError( - "Brightness must be integer, " "not of %s.", type(brightness) - ) + raise TypeError(f"Brightness must be an integer, not {type(brightness)}.") if not 0 <= brightness <= 100: - raise ValueError("Brightness value %s is not valid." % brightness) + raise ValueError( + f"Invalid brightness value: {brightness} (valid range: 0-100%)" + ) # If zero set to 1 millisecond if transition == 0: transition = 1 if not isinstance(transition, int): - raise ValueError( - "Transition must be integer, " "not of %s.", type(transition) - ) + raise TypeError(f"Transition must be integer, not of {type(transition)}.") if transition <= 0: raise ValueError("Transition value %s is not valid." % transition) diff --git a/kasa/smart/modules/color.py b/kasa/smart/modules/color.py index 88d029082..bbabbdef9 100644 --- a/kasa/smart/modules/color.py +++ b/kasa/smart/modules/color.py @@ -51,10 +51,12 @@ def hsv(self) -> HSV: return HSV(hue=h, saturation=s, value=v) - def _raise_for_invalid_brightness(self, value: int): + def _raise_for_invalid_brightness(self, value): """Raise error on invalid brightness value.""" - if not isinstance(value, int) or not (1 <= value <= 100): - raise ValueError(f"Invalid brightness value: {value} (valid range: 1-100%)") + if not isinstance(value, int): + raise TypeError("Brightness must be an integer") + if not (0 <= value <= 100): + raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") async def set_hsv( self, @@ -73,10 +75,14 @@ async def set_hsv( :param int value: value in percentage [0, 100] :param int transition: transition in milliseconds. """ - if not isinstance(hue, int) or not (0 <= hue <= 360): + if not isinstance(hue, int): + raise TypeError("Hue must be an integer") + if not (0 <= hue <= 360): raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") - if not isinstance(saturation, int) or not (0 <= saturation <= 100): + if not isinstance(saturation, int): + raise TypeError("Saturation must be an integer") + if not (0 <= saturation <= 100): raise ValueError( f"Invalid saturation value: {saturation} (valid range: 0-100%)" ) diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py index 699c679b3..7227c442e 100644 --- a/kasa/smart/modules/lighteffect.py +++ b/kasa/smart/modules/lighteffect.py @@ -90,7 +90,7 @@ async def set_effect( """ if effect != self.LIGHT_EFFECTS_OFF and effect not in self._scenes_names_to_id: raise ValueError( - f"Cannot set light effect to {effect}, possible values " + f"The effect {effect} is not a built in effect. Possible values " f"are: {self.LIGHT_EFFECTS_OFF} " f"{' '.join(self._scenes_names_to_id.keys())}" ) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 578a82c62..e709a58f5 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -25,7 +25,7 @@ async def handle_turn_on(dev, turn_on): await dev.turn_off() -@pytest.fixture +@pytest.fixture() def dummy_protocol(): """Return a smart protocol instance with a mocking-ready dummy transport.""" diff --git a/kasa/tests/discovery_fixtures.py b/kasa/tests/discovery_fixtures.py index 1451a5cab..d56f11870 100644 --- a/kasa/tests/discovery_fixtures.py +++ b/kasa/tests/discovery_fixtures.py @@ -75,7 +75,7 @@ def parametrize_discovery( async def discovery_mock(request, mocker): """Mock discovery and patch protocol queries to use Fake protocols.""" fixture_info: FixtureInfo = request.param - yield patch_discovery({DISCOVERY_MOCK_IP: fixture_info}, mocker) + return patch_discovery({DISCOVERY_MOCK_IP: fixture_info}, mocker) def create_discovery_mock(ip: str, fixture_data: dict): @@ -253,4 +253,4 @@ async def mock_discover(self): mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) - yield discovery_data + return discovery_data diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index bbf4d6dfa..4a2569c72 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -18,17 +18,18 @@ async def test_brightness_component(dev: SmartDevice): # Test getting the value feature = brightness._device.features["brightness"] assert isinstance(feature.value, int) - assert feature.value > 1 and feature.value <= 100 + assert feature.value > 1 + assert feature.value <= 100 # Test setting the value await feature.set_value(10) await dev.update() assert feature.value == 10 - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="out of range"): await feature.set_value(feature.minimum_value - 10) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="out of range"): await feature.set_value(feature.maximum_value + 10) @@ -41,15 +42,16 @@ async def test_brightness_dimmable(dev: IotDevice): # Test getting the value feature = dev.features["brightness"] assert isinstance(feature.value, int) - assert feature.value > 0 and feature.value <= 100 + assert feature.value > 0 + assert feature.value <= 100 # Test setting the value await feature.set_value(10) await dev.update() assert feature.value == 10 - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="out of range"): await feature.set_value(feature.minimum_value - 10) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="out of range"): await feature.set_value(feature.maximum_value + 10) diff --git a/kasa/tests/smart/features/test_colortemp.py b/kasa/tests/smart/features/test_colortemp.py index 54f84b1bf..f4b3c0f51 100644 --- a/kasa/tests/smart/features/test_colortemp.py +++ b/kasa/tests/smart/features/test_colortemp.py @@ -23,8 +23,8 @@ async def test_colortemp_component(dev: SmartDevice): await dev.update() assert feature.value == new_value - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="out of range"): await feature.set_value(feature.minimum_value - 10) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="out of range"): await feature.set_value(feature.maximum_value + 10) diff --git a/kasa/tests/smart/modules/test_autooff.py b/kasa/tests/smart/modules/test_autooff.py index 50a1c9921..c8582ec54 100644 --- a/kasa/tests/smart/modules/test_autooff.py +++ b/kasa/tests/smart/modules/test_autooff.py @@ -18,7 +18,7 @@ @autooff @pytest.mark.parametrize( - "feature, prop_name, type", + ("feature", "prop_name", "type"), [ ("auto_off_enabled", "enabled", bool), ("auto_off_minutes", "delay", int), diff --git a/kasa/tests/smart/modules/test_contact.py b/kasa/tests/smart/modules/test_contact.py index 11440871e..732952a4e 100644 --- a/kasa/tests/smart/modules/test_contact.py +++ b/kasa/tests/smart/modules/test_contact.py @@ -10,7 +10,7 @@ @contact @pytest.mark.parametrize( - "feature, type", + ("feature", "type"), [ ("is_open", bool), ], diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index ee04015fa..3781ccd9f 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -76,8 +76,8 @@ async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): await dev.update() assert not device.is_on - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Invalid level"): await fan.set_fan_speed_level(-1) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Invalid level"): await fan.set_fan_speed_level(5) diff --git a/kasa/tests/smart/modules/test_firmware.py b/kasa/tests/smart/modules/test_firmware.py index 8d7b45748..6e7c3314f 100644 --- a/kasa/tests/smart/modules/test_firmware.py +++ b/kasa/tests/smart/modules/test_firmware.py @@ -19,11 +19,10 @@ @firmware @pytest.mark.parametrize( - "feature, prop_name, type, required_version", + ("feature", "prop_name", "type", "required_version"), [ ("auto_update_enabled", "auto_update_enabled", bool, 2), ("update_available", "update_available", bool, 1), - ("update_available", "update_available", bool, 1), ("current_firmware_version", "current_firmware", str, 1), ("available_firmware_version", "latest_firmware", str, 1), ], diff --git a/kasa/tests/smart/modules/test_humidity.py b/kasa/tests/smart/modules/test_humidity.py index 790393e5d..52760b230 100644 --- a/kasa/tests/smart/modules/test_humidity.py +++ b/kasa/tests/smart/modules/test_humidity.py @@ -10,7 +10,7 @@ @humidity @pytest.mark.parametrize( - "feature, type", + ("feature", "type"), [ ("humidity", int), ("humidity_warning", bool), diff --git a/kasa/tests/smart/modules/test_light_effect.py b/kasa/tests/smart/modules/test_light_effect.py index 20435dde5..27869bf25 100644 --- a/kasa/tests/smart/modules/test_light_effect.py +++ b/kasa/tests/smart/modules/test_light_effect.py @@ -37,7 +37,7 @@ async def test_light_effect(dev: Device, mocker: MockerFixture): assert light_effect.effect == effect assert feature.value == effect - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="The effect foobar is not a built in effect"): await light_effect.set_effect("foobar") diff --git a/kasa/tests/smart/modules/test_light_strip_effect.py b/kasa/tests/smart/modules/test_light_strip_effect.py index 283d294d2..f18bf9faf 100644 --- a/kasa/tests/smart/modules/test_light_strip_effect.py +++ b/kasa/tests/smart/modules/test_light_strip_effect.py @@ -54,7 +54,7 @@ async def test_light_strip_effect(dev: Device, mocker: MockerFixture): assert light_effect.effect == effect assert feature.value == effect - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="The effect foobar is not a built in effect"): await light_effect.set_effect("foobar") diff --git a/kasa/tests/smart/modules/test_motionsensor.py b/kasa/tests/smart/modules/test_motionsensor.py index 59fbef68f..06033ea76 100644 --- a/kasa/tests/smart/modules/test_motionsensor.py +++ b/kasa/tests/smart/modules/test_motionsensor.py @@ -10,7 +10,7 @@ @motion @pytest.mark.parametrize( - "feature, type", + ("feature", "type"), [ ("motion_detected", bool), ], diff --git a/kasa/tests/smart/modules/test_temperature.py b/kasa/tests/smart/modules/test_temperature.py index c9685b9d7..3354002db 100644 --- a/kasa/tests/smart/modules/test_temperature.py +++ b/kasa/tests/smart/modules/test_temperature.py @@ -16,7 +16,7 @@ @temperature @pytest.mark.parametrize( - "feature, type", + ("feature", "type"), [ ("temperature", float), ("temperature_unit", str), diff --git a/kasa/tests/smart/modules/test_temperaturecontrol.py b/kasa/tests/smart/modules/test_temperaturecontrol.py index 90f91216f..f186b63f7 100644 --- a/kasa/tests/smart/modules/test_temperaturecontrol.py +++ b/kasa/tests/smart/modules/test_temperaturecontrol.py @@ -1,4 +1,5 @@ import logging +import re import pytest @@ -15,7 +16,7 @@ @thermostats_smart @pytest.mark.parametrize( - "feature, type", + ("feature", "type"), [ ("target_temperature", float), ("temperature_offset", int), @@ -59,10 +60,14 @@ async def test_set_temperature_invalid_values(dev): """Test that out-of-bounds temperature values raise errors.""" temp_module: TemperatureControl = dev.modules["TemperatureControl"] - with pytest.raises(ValueError): + with pytest.raises( + ValueError, match="Invalid target temperature -1, must be in range" + ): await temp_module.set_target_temperature(-1) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, match="Invalid target temperature 100, must be in range" + ): await temp_module.set_target_temperature(100) @@ -70,10 +75,14 @@ async def test_set_temperature_invalid_values(dev): async def test_temperature_offset(dev): """Test the temperature offset API.""" temp_module: TemperatureControl = dev.modules["TemperatureControl"] - with pytest.raises(ValueError): + with pytest.raises( + ValueError, match=re.escape("Temperature offset must be [-10, 10]") + ): await temp_module.set_temperature_offset(100) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, match=re.escape("Temperature offset must be [-10, 10]") + ): await temp_module.set_temperature_offset(-100) await temp_module.set_temperature_offset(5) @@ -83,7 +92,7 @@ async def test_temperature_offset(dev): @thermostats_smart @pytest.mark.parametrize( - "mode, states, frost_protection", + ("mode", "states", "frost_protection"), [ pytest.param(ThermostatState.Idle, [], False, id="idle has empty"), pytest.param( @@ -114,7 +123,7 @@ async def test_thermostat_mode(dev, mode, states, frost_protection): @thermostats_smart @pytest.mark.parametrize( - "mode, states, msg", + ("mode", "states", "msg"), [ pytest.param( ThermostatState.Heating, diff --git a/kasa/tests/smart/modules/test_waterleak.py b/kasa/tests/smart/modules/test_waterleak.py index 615361934..c48d82441 100644 --- a/kasa/tests/smart/modules/test_waterleak.py +++ b/kasa/tests/smart/modules/test_waterleak.py @@ -12,7 +12,7 @@ @waterleak @pytest.mark.parametrize( - "feature, prop_name, type", + ("feature", "prop_name", "type"), [ ("water_alert", "alert", int), ("water_leak", "status", Enum), diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 940b16b0f..16d5718a4 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -100,7 +100,7 @@ async def test_login(mocker, status_code, error_code, inner_error_code, expectat @pytest.mark.parametrize( - "inner_error_codes, expectation, call_count", + ("inner_error_codes", "expectation", "call_count"), [ ([SmartErrorCode.LOGIN_ERROR, 0, 0, 0], does_not_raise(), 4), ( @@ -298,7 +298,7 @@ async def test_unknown_errors(mocker, error_code): "requestID": 1, "terminal_uuid": "foobar", } - with pytest.raises(KasaException): + with pytest.raises(KasaException): # noqa: PT012 res = await transport.send(json_dumps(request)) assert res is SmartErrorCode.INTERNAL_UNKNOWN_ERROR @@ -315,7 +315,7 @@ async def test_port_override(): @pytest.mark.parametrize( - "device_delay_required, should_error, should_succeed", + ("device_delay_required", "should_error", "should_succeed"), [ pytest.param(0, False, True, id="No error"), pytest.param(0.125, True, True, id="Error then succeed"), diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 002cbd419..2b563df89 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -1,5 +1,7 @@ from __future__ import annotations +import re + import pytest from voluptuous import ( All, @@ -51,10 +53,8 @@ async def test_state_attributes(dev: Device): @bulb_iot async def test_light_state_without_update(dev: IotBulb, monkeypatch): + monkeypatch.setitem(dev._last_update["system"]["get_sysinfo"], "light_state", None) with pytest.raises(KasaException): - monkeypatch.setitem( - dev._last_update["system"]["get_sysinfo"], "light_state", None - ) print(dev.light_state) @@ -114,23 +114,72 @@ async def test_light_set_state(dev: IotBulb, mocker): @color_bulb @turn_on -async def test_invalid_hsv(dev: Device, turn_on): +@pytest.mark.parametrize( + ("hue", "sat", "brightness", "exception_cls", "error"), + [ + pytest.param(-1, 0, 0, ValueError, "Invalid hue", id="hue out of range"), + pytest.param(361, 0, 0, ValueError, "Invalid hue", id="hue out of range"), + pytest.param( + 0.5, 0, 0, TypeError, "Hue must be an integer", id="hue invalid type" + ), + pytest.param( + "foo", 0, 0, TypeError, "Hue must be an integer", id="hue invalid type" + ), + pytest.param( + 0, -1, 0, ValueError, "Invalid saturation", id="saturation out of range" + ), + pytest.param( + 0, 101, 0, ValueError, "Invalid saturation", id="saturation out of range" + ), + pytest.param( + 0, + 0.5, + 0, + TypeError, + "Saturation must be an integer", + id="saturation invalid type", + ), + pytest.param( + 0, + "foo", + 0, + TypeError, + "Saturation must be an integer", + id="saturation invalid type", + ), + pytest.param( + 0, 0, -1, ValueError, "Invalid brightness", id="brightness out of range" + ), + pytest.param( + 0, 0, 101, ValueError, "Invalid brightness", id="brightness out of range" + ), + pytest.param( + 0, + 0, + 0.5, + TypeError, + "Brightness must be an integer", + id="brightness invalid type", + ), + pytest.param( + 0, + 0, + "foo", + TypeError, + "Brightness must be an integer", + id="brightness invalid type", + ), + ], +) +async def test_invalid_hsv( + dev: Device, turn_on, hue, sat, brightness, exception_cls, error +): light = dev.modules.get(Module.Light) assert light await handle_turn_on(dev, turn_on) assert light.is_color - - for invalid_hue in [-1, 361, 0.5]: - with pytest.raises(ValueError): - await light.set_hsv(invalid_hue, 0, 0) # type: ignore[arg-type] - - for invalid_saturation in [-1, 101, 0.5]: - with pytest.raises(ValueError): - await light.set_hsv(0, invalid_saturation, 0) # type: ignore[arg-type] - - for invalid_brightness in [-1, 101, 0.5]: - with pytest.raises(ValueError): - await light.set_hsv(0, 0, invalid_brightness) # type: ignore[arg-type] + with pytest.raises(exception_cls, match=error): + await light.set_hsv(hue, sat, brightness) @color_bulb @@ -201,9 +250,13 @@ async def test_smart_temp_range(dev: Device): async def test_out_of_range_temperature(dev: Device): light = dev.modules.get(Module.Light) assert light - with pytest.raises(ValueError): + with pytest.raises( + ValueError, match="Temperature should be between \d+ and \d+, was 1000" + ): await light.set_color_temp(1000) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, match="Temperature should be between \d+ and \d+, was 10000" + ): await light.set_color_temp(10000) @@ -236,7 +289,7 @@ async def test_dimmable_brightness(dev: IotBulb, turn_on): await dev.update() assert dev.brightness == 10 - with pytest.raises(ValueError): + with pytest.raises(TypeError, match="Brightness must be an integer"): await dev.set_brightness("foo") # type: ignore[arg-type] @@ -264,10 +317,16 @@ async def test_dimmable_brightness_transition(dev: IotBulb, mocker): async def test_invalid_brightness(dev: IotBulb): assert dev._is_dimmable - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match=re.escape("Invalid brightness value: 110 (valid range: 0-100%)"), + ): await dev.set_brightness(110) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match=re.escape("Invalid brightness value: -100 (valid range: 0-100%)"), + ): await dev.set_brightness(-100) diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index 548e11916..c254aa8a0 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -128,9 +128,9 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture): assert feat.value == second_effect call.reset_mock() - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="The effect foobar is not a built in effect."): await light_effect_module.set_effect("foobar") - call.assert_not_called() + call.assert_not_called() @light_effect @@ -174,10 +174,10 @@ async def test_light_brightness(dev: Device): await dev.update() assert light.brightness == 10 - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Invalid brightness value: "): await light.set_brightness(feature.minimum_value - 10) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Invalid brightness value: "): await light.set_brightness(feature.maximum_value + 10) @@ -213,10 +213,10 @@ async def test_light_color_temp(dev: Device): assert light.color_temp == feature.minimum_value + 20 assert light.brightness == 60 - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Temperature should be between \d+ and \d+"): await light.set_color_temp(feature.minimum_value - 10) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Temperature should be between \d+ and \d+"): await light.set_color_temp(feature.maximum_value + 10) @@ -293,9 +293,9 @@ async def test_light_preset_module(dev: Device, mocker: MockerFixture): assert preset_mod.preset == second_preset assert feat.value == second_preset - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="foobar is not a valid preset"): await preset_mod.set_preset("foobar") - assert call.call_count == 3 + assert call.call_count == 3 @light_preset @@ -315,9 +315,7 @@ async def test_light_preset_save(dev: Device, mocker: MockerFixture): await preset_mod.save_preset(second_preset, new_preset) await dev.update() new_preset_state = preset_mod.preset_states_list[0] - assert ( - new_preset_state.brightness == new_preset.brightness - and new_preset_state.hue == new_preset.hue - and new_preset_state.saturation == new_preset.saturation - and new_preset_state.color_temp == new_preset.color_temp - ) + assert new_preset_state.brightness == new_preset.brightness + assert new_preset_state.hue == new_preset.hue + assert new_preset_state.saturation == new_preset.saturation + assert new_preset_state.color_temp == new_preset.color_temp diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index bda4514c9..d5d988d78 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -103,7 +103,7 @@ async def test_create_thin_wrapper(): @pytest.mark.parametrize( - "device_class, use_class", kasa.deprecated_smart_devices.items() + ("device_class", "use_class"), kasa.deprecated_smart_devices.items() ) def test_deprecated_devices(device_class, use_class): package_name = ".".join(use_class.__module__.split(".")[:-1]) @@ -117,7 +117,9 @@ def test_deprecated_devices(device_class, use_class): getattr(module, use_class.__name__) -@pytest.mark.parametrize("deprecated_class, use_class", kasa.deprecated_classes.items()) +@pytest.mark.parametrize( + ("deprecated_class", "use_class"), kasa.deprecated_classes.items() +) def test_deprecated_classes(deprecated_class, use_class): msg = f"{deprecated_class} is deprecated, use {use_class.__name__} instead" with pytest.deprecated_call(match=msg): diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index 5831c0193..bf0d0c563 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -46,13 +46,23 @@ async def test_set_brightness_transition(dev, turn_on, mocker): @dimmer_iot async def test_set_brightness_invalid(dev): - for invalid_brightness in [-1, 101, 0.5]: - with pytest.raises(ValueError): + for invalid_brightness in [-1, 101]: + with pytest.raises(ValueError, match="Invalid brightness"): await dev.set_brightness(invalid_brightness) - for invalid_transition in [-1, 0.5]: - with pytest.raises(ValueError): + for invalid_type in [0.5, "foo"]: + with pytest.raises(TypeError, match="Brightness must be an integer"): + await dev.set_brightness(invalid_type) + + +@dimmer_iot +async def test_set_brightness_invalid_transition(dev): + for invalid_transition in [-1]: + with pytest.raises(ValueError, match="Transition value .+? is not valid."): await dev.set_brightness(1, transition=invalid_transition) + for invalid_type in [0.5, "foo"]: + with pytest.raises(TypeError, match="Transition must be integer"): + await dev.set_brightness(1, transition=invalid_type) @dimmer_iot @@ -128,14 +138,24 @@ async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): @dimmer_iot -async def test_set_dimmer_transition_invalid(dev): - for invalid_brightness in [-1, 101, 0.5]: - with pytest.raises(ValueError): +async def test_set_dimmer_transition_invalid_brightness(dev): + for invalid_brightness in [-1, 101]: + with pytest.raises(ValueError, match="Invalid brightness value: "): await dev.set_dimmer_transition(invalid_brightness, 1000) - for invalid_transition in [-1, 0.5]: - with pytest.raises(ValueError): - await dev.set_dimmer_transition(1, invalid_transition) + for invalid_type in [0.5, "foo"]: + with pytest.raises(TypeError, match="Transition must be integer"): + await dev.set_dimmer_transition(1, invalid_type) + + +@dimmer_iot +async def test_set_dimmer_transition_invalid_transition(dev): + for invalid_transition in [-1]: + with pytest.raises(ValueError, match="Transition value .+? is not valid."): + await dev.set_dimmer_transition(1, transition=invalid_transition) + for invalid_type in [0.5, "foo"]: + with pytest.raises(TypeError, match="Transition must be integer"): + await dev.set_dimmer_transition(1, transition=invalid_type) @dimmer_iot diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 19eef1f75..3c388e6ac 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -252,7 +252,7 @@ async def test_discover_single_no_response(mocker): ] -@pytest.mark.parametrize("msg, data", INVALIDS) +@pytest.mark.parametrize(("msg", "data"), INVALIDS) async def test_discover_invalid_info(msg, data, mocker): """Make sure that invalid discovery information raises an exception.""" host = "127.0.0.1" @@ -304,7 +304,7 @@ async def test_discover_datagram_received(mocker, discovery_data): assert dev.host == addr -@pytest.mark.parametrize("msg, data", INVALIDS) +@pytest.mark.parametrize(("msg", "data"), INVALIDS) async def test_discover_invalid_responses(msg, data, mocker): """Verify that we don't crash whole discovery if some devices in the network are sending unexpected data.""" proto = _DiscoverProtocol() @@ -349,7 +349,7 @@ async def test_discover_single_authentication(discovery_mock, mocker): side_effect=AuthenticationError("Failed to authenticate"), ) - with pytest.raises( + with pytest.raises( # noqa: PT012 AuthenticationError, match="Failed to authenticate", ): @@ -495,7 +495,7 @@ async def test_do_discover_drop_packets(mocker, port, do_not_reply_count): @pytest.mark.parametrize( - "port, will_timeout", + ("port", "will_timeout"), [(FakeDatagramTransport.GHOST_PORT, True), (20002, False)], ids=["unknownport", "unsupporteddevice"], ) diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 220fdbaee..3cc69193b 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -61,7 +61,7 @@ async def test_get_emeter_realtime(dev): @has_emeter_iot -@pytest.mark.requires_dummy +@pytest.mark.requires_dummy() async def test_get_emeter_daily(dev): assert dev.has_emeter @@ -81,7 +81,7 @@ async def test_get_emeter_daily(dev): @has_emeter_iot -@pytest.mark.requires_dummy +@pytest.mark.requires_dummy() async def test_get_emeter_monthly(dev): assert dev.has_emeter diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index fd4008562..83b7c24c2 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -14,7 +14,7 @@ class DummyDevice: pass -@pytest.fixture +@pytest.fixture() def dummy_feature() -> Feature: # create_autospec for device slows tests way too much, so we use a dummy here @@ -49,7 +49,7 @@ def test_feature_api(dummy_feature: Feature): ) def test_feature_setter_on_sensor(read_only_type): """Test that creating a sensor feature with a setter causes an error.""" - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Invalid type for configurable feature"): Feature( device=DummyDevice(), # type: ignore[arg-type] id="dummy_error", @@ -103,7 +103,7 @@ async def test_feature_setter(dev, mocker, dummy_feature: Feature): async def test_feature_setter_read_only(dummy_feature): """Verify that read-only feature raises an exception when trying to change it.""" dummy_feature.attribute_setter = None - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Tried to set read-only feature"): await dummy_feature.set_value("value for read only feature") @@ -134,7 +134,7 @@ async def test_feature_choice_list(dummy_feature, caplog, mocker: MockerFixture) mock_setter.assert_called_with("first") mock_setter.reset_mock() - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Unexpected value for dummy_feature: invalid"): # noqa: PT012 await dummy_feature.set_value("invalid") assert "Unexpected value" in caplog.text diff --git a/kasa/tests/test_httpclient.py b/kasa/tests/test_httpclient.py index a4f22c3fe..6200d0fdb 100644 --- a/kasa/tests/test_httpclient.py +++ b/kasa/tests/test_httpclient.py @@ -14,7 +14,7 @@ @pytest.mark.parametrize( - "error, error_raises, error_message", + ("error", "error_raises", "error_message"), [ ( aiohttp.ServerDisconnectedError(), @@ -52,7 +52,7 @@ "ServerFingerprintMismatch", ), ) -@pytest.mark.parametrize("mock_read", (False, True), ids=("post", "read")) +@pytest.mark.parametrize("mock_read", [False, True], ids=("post", "read")) async def test_httpclient_errors(mocker, error, error_raises, error_message, mock_read): class _mock_response: def __init__(self, status, error): diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py index 976144fcb..55565bcc2 100644 --- a/kasa/tests/test_iotdevice.py +++ b/kasa/tests/test_iotdevice.py @@ -89,7 +89,7 @@ async def test_state_info(dev): assert isinstance(dev.state_information, dict) -@pytest.mark.requires_dummy +@pytest.mark.requires_dummy() @device_iot async def test_invalid_connection(mocker, dev): with ( diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 4a7b3e18f..53c295cfb 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -49,7 +49,7 @@ async def read(self): @pytest.mark.parametrize( - "error, retry_expectation", + ("error", "retry_expectation"), [ (Exception("dummy exception"), False), (aiohttp.ServerTimeoutError("dummy exception"), True), @@ -79,7 +79,7 @@ async def test_protocol_retries_via_client_session( @pytest.mark.parametrize( - "error, retry_expectation", + ("error", "retry_expectation"), [ (KasaException("dummy exception"), False), (_RetryableError("dummy exception"), True), @@ -305,7 +305,7 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): @pytest.mark.parametrize( - "device_credentials, expectation", + ("device_credentials", "expectation"), [ (Credentials("foo", "bar"), does_not_raise()), (Credentials(), does_not_raise()), @@ -321,7 +321,7 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): ids=("client", "blank", "kasa_setup", "shouldfail"), ) @pytest.mark.parametrize( - "transport_class, seed_auth_hash_calc", + ("transport_class", "seed_auth_hash_calc"), [ pytest.param(KlapTransport, lambda c, s, a: c + a, id="KLAP"), pytest.param(KlapTransportV2, lambda c, s, a: c + s + a, id="KLAPV2"), @@ -365,7 +365,7 @@ async def _return_handshake1_response(url, params=None, data=None, *_, **__): @pytest.mark.parametrize( - "transport_class, seed_auth_hash_calc1, seed_auth_hash_calc2", + ("transport_class", "seed_auth_hash_calc1", "seed_auth_hash_calc2"), [ pytest.param( KlapTransport, lambda c, s, a: c + a, lambda c, s, a: s + a, id="KLAP" @@ -466,7 +466,7 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): @pytest.mark.parametrize( - "response_status, credentials_match, expectation", + ("response_status", "credentials_match", "expectation"), [ pytest.param( (403, 403, 403), diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index 41fdcde15..c72f10ed0 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -22,7 +22,9 @@ async def test_lightstrip_effect(dev: IotLightStrip): @lightstrip_iot async def test_effects_lightstrip_set_effect(dev: IotLightStrip): - with pytest.raises(ValueError): + with pytest.raises( + ValueError, match="The effect Not real is not a built in effect" + ): await dev.set_effect("Not real") await dev.set_effect("Candy Cane") diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index cb38b6198..f2f73ee5f 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -33,7 +33,7 @@ @pytest.mark.parametrize( - "protocol_class, transport_class", + ("protocol_class", "transport_class"), [ (_deprecated_TPLinkSmartHomeProtocol, XorTransport), (IotProtocol, XorTransport), @@ -63,7 +63,7 @@ def aio_mock_writer(_, __): @pytest.mark.parametrize( - "protocol_class, transport_class", + ("protocol_class", "transport_class"), [ (_deprecated_TPLinkSmartHomeProtocol, XorTransport), (IotProtocol, XorTransport), @@ -87,7 +87,7 @@ async def test_protocol_no_retry_on_unreachable( @pytest.mark.parametrize( - "protocol_class, transport_class", + ("protocol_class", "transport_class"), [ (_deprecated_TPLinkSmartHomeProtocol, XorTransport), (IotProtocol, XorTransport), @@ -111,7 +111,7 @@ async def test_protocol_no_retry_connection_refused( @pytest.mark.parametrize( - "protocol_class, transport_class", + ("protocol_class", "transport_class"), [ (_deprecated_TPLinkSmartHomeProtocol, XorTransport), (IotProtocol, XorTransport), @@ -135,7 +135,7 @@ async def test_protocol_retry_recoverable_error( @pytest.mark.parametrize( - "protocol_class, transport_class, encryption_class", + ("protocol_class", "transport_class", "encryption_class"), [ ( _deprecated_TPLinkSmartHomeProtocol, @@ -185,7 +185,7 @@ def aio_mock_writer(_, __): @pytest.mark.parametrize( - "protocol_class, transport_class, encryption_class", + ("protocol_class", "transport_class", "encryption_class"), [ ( _deprecated_TPLinkSmartHomeProtocol, @@ -239,7 +239,7 @@ def aio_mock_writer(_, __): @pytest.mark.parametrize( - "protocol_class, transport_class, encryption_class", + ("protocol_class", "transport_class", "encryption_class"), [ ( _deprecated_TPLinkSmartHomeProtocol, @@ -291,7 +291,7 @@ def aio_mock_writer(_, __): @pytest.mark.parametrize( - "protocol_class, transport_class, encryption_class", + ("protocol_class", "transport_class", "encryption_class"), [ ( _deprecated_TPLinkSmartHomeProtocol, @@ -338,7 +338,7 @@ def aio_mock_writer(_, __): @pytest.mark.parametrize( - "protocol_class, transport_class, encryption_class", + ("protocol_class", "transport_class", "encryption_class"), [ ( _deprecated_TPLinkSmartHomeProtocol, @@ -494,14 +494,10 @@ def test_protocol_init_signature(class_name_obj): params = list(inspect.signature(class_name_obj[1].__init__).parameters.values()) assert len(params) == 2 - assert ( - params[0].name == "self" - and params[0].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD - ) - assert ( - params[1].name == "transport" - and params[1].kind == inspect.Parameter.KEYWORD_ONLY - ) + assert params[0].name == "self" + assert params[0].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + assert params[1].name == "transport" + assert params[1].kind == inspect.Parameter.KEYWORD_ONLY @pytest.mark.parametrize( @@ -511,13 +507,10 @@ def test_transport_init_signature(class_name_obj): params = list(inspect.signature(class_name_obj[1].__init__).parameters.values()) assert len(params) == 2 - assert ( - params[0].name == "self" - and params[0].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD - ) - assert ( - params[1].name == "config" and params[1].kind == inspect.Parameter.KEYWORD_ONLY - ) + assert params[0].name == "self" + assert params[0].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + assert params[1].name == "config" + assert params[1].kind == inspect.Parameter.KEYWORD_ONLY @pytest.mark.parametrize( @@ -582,7 +575,7 @@ async def test_transport_credentials_hash( @pytest.mark.parametrize( "transport_class", - [AesTransport, KlapTransport, KlapTransportV2, XorTransport, XorTransport], + [AesTransport, KlapTransport, KlapTransportV2, XorTransport], ) async def test_transport_credentials_hash_from_config(mocker, transport_class): """Test that credentials_hash provided via config sets correctly.""" @@ -599,7 +592,7 @@ async def test_transport_credentials_hash_from_config(mocker, transport_class): @pytest.mark.parametrize( - "error, retry_expectation", + ("error", "retry_expectation"), [ (ConnectionRefusedError("dummy exception"), False), (OSError(errno.EHOSTDOWN, os.strerror(errno.EHOSTDOWN)), False), @@ -609,7 +602,7 @@ async def test_transport_credentials_hash_from_config(mocker, transport_class): ids=("ConnectionRefusedError", "OSErrorNoRetry", "OSErrorRetry", "Exception"), ) @pytest.mark.parametrize( - "protocol_class, transport_class", + ("protocol_class", "transport_class"), [ (_deprecated_TPLinkSmartHomeProtocol, XorTransport), (IotProtocol, XorTransport), @@ -631,7 +624,7 @@ async def test_protocol_will_retry_on_connect( @pytest.mark.parametrize( - "error, retry_expectation", + ("error", "retry_expectation"), [ (ConnectionRefusedError("dummy exception"), True), (OSError(errno.EHOSTDOWN, os.strerror(errno.EHOSTDOWN)), True), @@ -641,7 +634,7 @@ async def test_protocol_will_retry_on_connect( ids=("ConnectionRefusedError", "OSErrorNoRetry", "OSErrorRetry", "Exception"), ) @pytest.mark.parametrize( - "protocol_class, transport_class", + ("protocol_class", "transport_class"), [ (_deprecated_TPLinkSmartHomeProtocol, XorTransport), (IotProtocol, XorTransport), diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index ec312c5a4..cbaff9c55 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -145,7 +145,7 @@ def test_tutorial_examples(readmes_mock): assert not res["failed"] -@pytest.fixture +@pytest.fixture() async def readmes_mock(mocker): fixture_infos = { "127.0.0.1": get_fixture_info("KP303(UK)_1.0_1.0.3.json", "IOT"), # Strip @@ -154,4 +154,4 @@ async def readmes_mock(mocker): "127.0.0.4": get_fixture_info("KL430(US)_1.0_1.0.10.json", "IOT"), # Lightstrip "127.0.0.5": get_fixture_info("HS220(US)_1.0_1.5.7.json", "IOT"), # Dimmer } - yield patch_discovery(fixture_infos, mocker) + return patch_discovery(fixture_infos, mocker) diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 058bfc3b3..420c10fc3 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -65,7 +65,7 @@ async def test_smart_device_unknown_errors( dummy_protocol._transport, "send", return_value=mock_response ) - with pytest.raises(KasaException): + with pytest.raises(KasaException): # noqa: PT012 res = await dummy_protocol.query(DUMMY_QUERY) assert res is SmartErrorCode.INTERNAL_UNKNOWN_ERROR diff --git a/kasa/tests/test_usage.py b/kasa/tests/test_usage.py index 3f6c50561..7b2c0eed6 100644 --- a/kasa/tests/test_usage.py +++ b/kasa/tests/test_usage.py @@ -20,7 +20,8 @@ def test_usage_convert_stat_data(): k, v = d.popitem() assert isinstance(k, int) assert isinstance(v, int) - assert k == 4 and v == 30 + assert k == 4 + assert v == 30 def test_usage_today(): diff --git a/pyproject.toml b/pyproject.toml index 4c4bd57e9..a08202e56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,6 +117,7 @@ select = [ "FA", # flake8-future-annotations "I", # isort "S", # bandit + "PT", # flake8-pytest-style "LOG", # flake8-logging "G", # flake8-logging-format ] From 520b9d7a384fda6eb80d22533ce1258bad32dcb1 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 30 Aug 2024 18:01:54 +0100 Subject: [PATCH 561/892] Disable automatic updating of latest firmware (#1103) To resolve issues with the calls to the tplink cloud to get the latest firmware. Disables the automatic calling of `get_latest_fw` and requires firmware update checks to be triggered manually. --- docs/tutorial.py | 2 +- kasa/feature.py | 5 +- kasa/smart/modules/firmware.py | 73 ++++++++++++++++------- kasa/tests/smart/modules/test_firmware.py | 37 +++++++++++- 4 files changed, 88 insertions(+), 29 deletions(-) diff --git a/docs/tutorial.py b/docs/tutorial.py index f2b777b16..8d0a14354 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -91,5 +91,5 @@ True >>> for feat in dev.features.values(): >>> print(f"{feat.name}: {feat.value}") -Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nReboot: \nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: False\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: 1.1.6 Build 240130 Rel.173828\nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nDevice time: 2024-02-23 02:40:15+01:00 +Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nReboot: \nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: \nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nDevice time: 2024-02-23 02:40:15+01:00 """ diff --git a/kasa/feature.py b/kasa/feature.py index ad709424d..e20a926de 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -31,9 +31,10 @@ HSV (hsv): HSV(hue=0, saturation=100, value=100) Color temperature (color_temperature): 2700 Auto update enabled (auto_update_enabled): False -Update available (update_available): False +Update available (update_available): None Current firmware version (current_firmware_version): 1.1.6 Build 240130 Rel.173828 -Available firmware version (available_firmware_version): 1.1.6 Build 240130 Rel.173828 +Available firmware version (available_firmware_version): None +Check latest firmware (check_latest_firmware): Light effect (light_effect): Off Light preset (light_preset): Not set Smooth transition on (smooth_transition_on): 2 diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 21026676c..036c0b6cf 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -6,13 +6,14 @@ import logging from collections.abc import Coroutine from datetime import date -from typing import TYPE_CHECKING, Any, Callable, Optional +from typing import TYPE_CHECKING, Callable, Optional # 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 pydantic.v1 import BaseModel, Field, validator +from ...exceptions import KasaException from ...feature import Feature from ..smartmodule import SmartModule, allow_update_after @@ -70,6 +71,11 @@ class Firmware(SmartModule): def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) + self._firmware_update_info: UpdateInfo | None = None + + def _initialize_features(self): + """Initialize features.""" + device = self._device if self.supported_version > 1: self._add_feature( Feature( @@ -115,13 +121,34 @@ def __init__(self, device: SmartDevice, module: str): type=Feature.Type.Sensor, ) ) + self._add_feature( + Feature( + device, + id="check_latest_firmware", + name="Check latest firmware", + container=self, + attribute_setter="check_latest_firmware", + category=Feature.Category.Info, + type=Feature.Type.Action, + ) + ) def query(self) -> dict: """Query to execute during the update cycle.""" - req: dict[str, Any] = {"get_latest_fw": None} if self.supported_version > 1: - req["get_auto_update_info"] = None - return req + return {"get_auto_update_info": None} + return {} + + async def check_latest_firmware(self) -> UpdateInfo | None: + """Check for the latest firmware for the device.""" + try: + fw = await self.call("get_latest_fw") + self._firmware_update_info = UpdateInfo.parse_obj(fw["get_latest_fw"]) + return self._firmware_update_info + except Exception: + _LOGGER.exception("Error getting latest firmware for %s:", self._device) + self._firmware_update_info = None + return None @property def current_firmware(self) -> str: @@ -129,26 +156,23 @@ def current_firmware(self) -> str: return self._device.hw_info["sw_ver"] @property - def latest_firmware(self) -> str: + def latest_firmware(self) -> str | None: """Return the latest firmware version.""" - return self.firmware_update_info.version + if not self._firmware_update_info: + return None + return self._firmware_update_info.version @property - def firmware_update_info(self): + def firmware_update_info(self) -> UpdateInfo | None: """Return latest firmware information.""" - if not self._device.is_cloud_connected or self._has_data_error(): - # Error in response, probably disconnected from the cloud. - return UpdateInfo(type=0, need_to_upgrade=False) - - fw = self.data.get("get_latest_fw") or self.data - return UpdateInfo.parse_obj(fw) + return self._firmware_update_info @property def update_available(self) -> bool | None: """Return True if update is available.""" - if not self._device.is_cloud_connected: + if not self._device.is_cloud_connected or not self._firmware_update_info: return None - return self.firmware_update_info.update_available + return self._firmware_update_info.update_available async def get_update_state(self) -> DownloadState: """Return update state.""" @@ -161,11 +185,17 @@ async def update( self, progress_cb: Callable[[DownloadState], Coroutine] | None = None ): """Update the device firmware.""" + if not self._firmware_update_info: + raise KasaException( + "You must call check_latest_firmware before calling update" + ) + if not self.update_available: + raise KasaException("A new update must be available to call update") current_fw = self.current_firmware _LOGGER.info( "Going to upgrade from %s to %s", current_fw, - self.firmware_update_info.version, + self._firmware_update_info.version, ) await self.call("fw_download") @@ -188,7 +218,7 @@ async def update( if state.status == 0: _LOGGER.info( "Update idle, hopefully updated to %s", - self.firmware_update_info.version, + self._firmware_update_info.version, ) break elif state.status == 2: @@ -207,15 +237,12 @@ async def update( _LOGGER.warning("Unhandled state code: %s", state) @property - def auto_update_enabled(self): + def auto_update_enabled(self) -> bool: """Return True if autoupdate is enabled.""" - return ( - "get_auto_update_info" in self.data - and self.data["get_auto_update_info"]["enable"] - ) + return "enable" in self.data and self.data["enable"] @allow_update_after async def set_auto_update_enabled(self, enabled: bool): """Change autoupdate setting.""" - data = {**self.data["get_auto_update_info"], "enable": enabled} + data = {**self.data, "enable": enabled} await self.call("set_auto_update_info", data) diff --git a/kasa/tests/smart/modules/test_firmware.py b/kasa/tests/smart/modules/test_firmware.py index 6e7c3314f..c10d90861 100644 --- a/kasa/tests/smart/modules/test_firmware.py +++ b/kasa/tests/smart/modules/test_firmware.py @@ -2,12 +2,13 @@ import asyncio import logging +from contextlib import nullcontext from typing import TypedDict import pytest from pytest_mock import MockerFixture -from kasa import Module +from kasa import KasaException, Module from kasa.smart import SmartDevice from kasa.smart.modules.firmware import DownloadState from kasa.tests.device_fixtures import parametrize @@ -33,10 +34,12 @@ async def test_firmware_features( """Test light effect.""" fw = dev.modules.get(Module.Firmware) assert fw + assert fw.firmware_update_info is None if not dev.is_cloud_connected: pytest.skip("Device is not cloud connected, skipping test") + await fw.check_latest_firmware() if fw.supported_version < required_version: pytest.skip("Feature %s requires newer version" % feature) @@ -53,20 +56,36 @@ async def test_update_available_without_cloud(dev: SmartDevice): """Test that update_available returns None when disconnected.""" fw = dev.modules.get(Module.Firmware) assert fw + assert fw.firmware_update_info is None if dev.is_cloud_connected: + await fw.check_latest_firmware() assert isinstance(fw.update_available, bool) else: assert fw.update_available is None @firmware +@pytest.mark.parametrize( + ("update_available", "expected_result"), + [ + pytest.param(True, nullcontext(), id="available"), + pytest.param(False, pytest.raises(KasaException), id="not-available"), + ], +) async def test_firmware_update( - dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + update_available, + expected_result, ): """Test updating firmware.""" caplog.set_level(logging.INFO) + if not dev.is_cloud_connected: + pytest.skip("Device is not cloud connected, skipping test") + fw = dev.modules.get(Module.Firmware) assert fw @@ -101,7 +120,19 @@ class Extras(TypedDict): cb_mock = mocker.AsyncMock() - await fw.update(progress_cb=cb_mock) + assert fw.firmware_update_info is None + with pytest.raises(KasaException): + await fw.update(progress_cb=cb_mock) + await fw.check_latest_firmware() + assert fw.firmware_update_info is not None + + fw._firmware_update_info.status = 1 if update_available else 0 + + with expected_result: + await fw.update(progress_cb=cb_mock) + + if not update_available: + return # This is necessary to allow the eventloop to process the created tasks await asyncio_sleep(0) From 4ef7306332c900dd281645c59720ebcbd5eb3d1f Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 30 Aug 2024 18:55:36 +0100 Subject: [PATCH 562/892] Prepare 0.7.2 (#1107) ## [0.7.2](https://github.com/python-kasa/python-kasa/tree/0.7.2) (2024-08-30) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.1...0.7.2) **Release summary:** - **Breaking** change to disable including the check for the latest firmware for tapo devices and newer kasa devices in the standard update cycle. To check for the latest firmware call `check_latest_firmware` on the firmware module or run the `check_latest_firmware` feature. - Minor bugfixes and improvements. **Breaking changes:** - Disable automatic updating of latest firmware [\#1103](https://github.com/python-kasa/python-kasa/pull/1103) (@sdb9696) **Implemented enhancements:** - Improve performance of dict merge code [\#1097](https://github.com/python-kasa/python-kasa/pull/1097) (@bdraco) **Fixed bugs:** - Fix logging in iotdevice when a module is module not supported [\#1100](https://github.com/python-kasa/python-kasa/pull/1100) (@bdraco) **Documentation updates:** - Fix incorrect docs link in contributing.md [\#1099](https://github.com/python-kasa/python-kasa/pull/1099) (@sdb9696) **Project maintenance:** - Add flake8-pytest-style \(PT\) for ruff [\#1105](https://github.com/python-kasa/python-kasa/pull/1105) (@rytilahti) - Add flake8-logging \(LOG\) and flake8-logging-format \(G\) for ruff [\#1104](https://github.com/python-kasa/python-kasa/pull/1104) (@rytilahti) - Add missing typing\_extensions dependency [\#1101](https://github.com/python-kasa/python-kasa/pull/1101) (@sdb9696) - Remove top level await xdoctest fixture [\#1098](https://github.com/python-kasa/python-kasa/pull/1098) (@sdb9696) - Enable python 3.13, allow pre-releases for CI [\#1086](https://github.com/python-kasa/python-kasa/pull/1086) (@rytilahti) --- CHANGELOG.md | 48 +++- RELEASING.md | 3 +- poetry.lock | 651 ++++++++++++++++++++++++++----------------------- pyproject.toml | 2 +- 4 files changed, 392 insertions(+), 312 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 314b2985a..e38b57e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## [0.7.2](https://github.com/python-kasa/python-kasa/tree/0.7.2) (2024-08-30) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.1...0.7.2) + +**Release summary:** + +- **Breaking** change to disable including the check for the latest firmware for tapo devices and newer kasa devices in the standard update cycle. To check for the latest firmware call `check_latest_firmware` on the firmware module or run the `check_latest_firmware` feature. +- Minor bugfixes and improvements. + +**Breaking changes:** + +- Disable automatic updating of latest firmware [\#1103](https://github.com/python-kasa/python-kasa/pull/1103) (@sdb9696) + +**Implemented enhancements:** + +- Improve performance of dict merge code [\#1097](https://github.com/python-kasa/python-kasa/pull/1097) (@bdraco) + +**Fixed bugs:** + +- Fix logging in iotdevice when a module is module not supported [\#1100](https://github.com/python-kasa/python-kasa/pull/1100) (@bdraco) + +**Documentation updates:** + +- Fix incorrect docs link in contributing.md [\#1099](https://github.com/python-kasa/python-kasa/pull/1099) (@sdb9696) + +**Project maintenance:** + +- Add flake8-pytest-style \(PT\) for ruff [\#1105](https://github.com/python-kasa/python-kasa/pull/1105) (@rytilahti) +- Add flake8-logging \(LOG\) and flake8-logging-format \(G\) for ruff [\#1104](https://github.com/python-kasa/python-kasa/pull/1104) (@rytilahti) +- Add missing typing\_extensions dependency [\#1101](https://github.com/python-kasa/python-kasa/pull/1101) (@sdb9696) +- Remove top level await xdoctest fixture [\#1098](https://github.com/python-kasa/python-kasa/pull/1098) (@sdb9696) +- Enable python 3.13, allow pre-releases for CI [\#1086](https://github.com/python-kasa/python-kasa/pull/1086) (@rytilahti) + ## [0.7.1](https://github.com/python-kasa/python-kasa/tree/0.7.1) (2024-07-31) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.5...0.7.1) @@ -17,6 +50,8 @@ **Fixed bugs:** +- Error connecting to L920-5 Smart LED Strip [\#1040](https://github.com/python-kasa/python-kasa/issues/1040) +- Handle module errors more robustly and add query params to light preset and transition [\#1036](https://github.com/python-kasa/python-kasa/pull/1036) (@sdb9696) - Fix iot light effect brightness [\#1092](https://github.com/python-kasa/python-kasa/pull/1092) (@sdb9696) - Enable setting brightness with color temp for smart devices [\#1091](https://github.com/python-kasa/python-kasa/pull/1091) (@sdb9696) - Do not send light\_on value to iot bulb set\_state [\#1090](https://github.com/python-kasa/python-kasa/pull/1090) (@sdb9696) @@ -24,7 +59,6 @@ - Raise KasaException on decryption errors [\#1078](https://github.com/python-kasa/python-kasa/pull/1078) (@sdb9696) - Update smart request parameter handling [\#1061](https://github.com/python-kasa/python-kasa/pull/1061) (@sdb9696) - Fix light preset module when list contains lighting effects [\#1048](https://github.com/python-kasa/python-kasa/pull/1048) (@sdb9696) -- Handle module errors more robustly and add query params to light preset and transition [\#1036](https://github.com/python-kasa/python-kasa/pull/1036) (@sdb9696) - Fix credential hash to return None on empty credentials [\#1029](https://github.com/python-kasa/python-kasa/pull/1029) (@sdb9696) **Added support for devices:** @@ -34,6 +68,9 @@ **Project maintenance:** - Bump project version to 0.7.0.5 [\#1087](https://github.com/python-kasa/python-kasa/pull/1087) (@sdb9696) +- Add KP400\(US\) v1.0.4 fixture [\#1051](https://github.com/python-kasa/python-kasa/pull/1051) (@gimpy88) +- Add new HS220 kasa aes fixture [\#1050](https://github.com/python-kasa/python-kasa/pull/1050) (@sdb9696) +- Add KS205\(US\) v1.1.0 fixture [\#1049](https://github.com/python-kasa/python-kasa/pull/1049) (@gimpy88) - Fix generate\_supported pre commit to run in venv [\#1085](https://github.com/python-kasa/python-kasa/pull/1085) (@sdb9696) - Fix intermittently failing decryption error test [\#1082](https://github.com/python-kasa/python-kasa/pull/1082) (@sdb9696) - Fix mypy pre-commit hook on windows [\#1081](https://github.com/python-kasa/python-kasa/pull/1081) (@sdb9696) @@ -42,9 +79,6 @@ - Fix parse\_pcap\_klap on windows and support default credentials [\#1068](https://github.com/python-kasa/python-kasa/pull/1068) (@sdb9696) - Add fixture file for KP405 fw 1.0.6 [\#1063](https://github.com/python-kasa/python-kasa/pull/1063) (@daleye) - Bump project version to 0.7.0.3 [\#1053](https://github.com/python-kasa/python-kasa/pull/1053) (@sdb9696) -- Add KP400\(US\) v1.0.4 fixture [\#1051](https://github.com/python-kasa/python-kasa/pull/1051) (@gimpy88) -- Add new HS220 kasa aes fixture [\#1050](https://github.com/python-kasa/python-kasa/pull/1050) (@sdb9696) -- Add KS205\(US\) v1.1.0 fixture [\#1049](https://github.com/python-kasa/python-kasa/pull/1049) (@gimpy88) - Add KS200M\(US\) v1.0.11 fixture [\#1047](https://github.com/python-kasa/python-kasa/pull/1047) (@sdb9696) - Add KS225\(US\) v1.1.0 fixture [\#1046](https://github.com/python-kasa/python-kasa/pull/1046) (@sdb9696) - Split out main cli module into lazily loaded submodules [\#1039](https://github.com/python-kasa/python-kasa/pull/1039) (@sdb9696) @@ -76,7 +110,6 @@ Critical bugfixes for issues with P100s and thermostats **Fixed bugs:** -- Error connecting to L920-5 Smart LED Strip [\#1040](https://github.com/python-kasa/python-kasa/issues/1040) - Use first known thermostat state as main state \(pick \#1054\) [\#1057](https://github.com/python-kasa/python-kasa/pull/1057) (@sdb9696) - Defer module updates for less volatile modules \(pick 1052\) [\#1056](https://github.com/python-kasa/python-kasa/pull/1056) (@sdb9696) - Use first known thermostat state as main state [\#1054](https://github.com/python-kasa/python-kasa/pull/1054) (@rytilahti) @@ -945,6 +978,10 @@ Pull requests improving the functionality of modules as well as adding better in - Improve poetry usage documentation [\#60](https://github.com/python-kasa/python-kasa/issues/60) - Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) (@rytilahti) +**Project maintenance:** + +- Add HS220 hw 2.0 fixture [\#107](https://github.com/python-kasa/python-kasa/pull/107) (@appleguru) + **Merged pull requests:** - Add github workflow for pypi publishing [\#220](https://github.com/python-kasa/python-kasa/pull/220) (@rytilahti) @@ -967,7 +1004,6 @@ Pull requests improving the functionality of modules as well as adding better in - Fix documentation on Smart strips [\#136](https://github.com/python-kasa/python-kasa/pull/136) (@flavio-fernandes) - add tapo link, fix tplink-smarthome-simulator link [\#133](https://github.com/python-kasa/python-kasa/pull/133) (@rytilahti) - Leverage data from UDP discovery to initialize device structure [\#132](https://github.com/python-kasa/python-kasa/pull/132) (@dlee1j1) -- Add HS220 hw 2.0 fixture [\#107](https://github.com/python-kasa/python-kasa/pull/107) (@appleguru) - Pin dependencies on major versions, add poetry.lock [\#94](https://github.com/python-kasa/python-kasa/pull/94) (@rytilahti) - add a small example script to show library usage [\#90](https://github.com/python-kasa/python-kasa/pull/90) (@rytilahti) - add .readthedocs.yml required for poetry builds [\#89](https://github.com/python-kasa/python-kasa/pull/89) (@rytilahti) diff --git a/RELEASING.md b/RELEASING.md index a330c002a..6c1b5a7ff 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -17,7 +17,6 @@ export CHANGELOG_GITHUB_TOKEN=token ```bash export NEW_RELEASE=x.x.x.devx -export PREVIOUS_RELEASE=0.3.5 ``` ## Normal releases from master @@ -62,7 +61,7 @@ If not already created #### Create new issue linked to the milestone ```bash -gh issue create --label "release-summary" --milestone $NEW_RELEASE --title "$NEW_RELEASE Release Summary" --body "## Release Summary" +gh issue create --label "release-summary" --milestone $NEW_RELEASE --title "$NEW_RELEASE Release Summary" --body "**Release summary:**" ``` You can exclude the --body option to get an interactive editor or go into the issue on github and edit there. diff --git a/poetry.lock b/poetry.lock index 12930907b..035682b2d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,98 +2,113 @@ [[package]] name = "aiohappyeyeballs" -version = "2.3.2" -description = "Happy Eyeballs" +version = "2.4.0" +description = "Happy Eyeballs for asyncio" optional = false -python-versions = ">=3.8,<4.0" +python-versions = ">=3.8" files = [ - {file = "aiohappyeyeballs-2.3.2-py3-none-any.whl", hash = "sha256:903282fb08c8cfb3de356fd546b263248a477c99cb147e20a115e14ab942a4ae"}, - {file = "aiohappyeyeballs-2.3.2.tar.gz", hash = "sha256:77e15a733090547a1f5369a1287ddfc944bd30df0eb8993f585259c34b405f4e"}, + {file = "aiohappyeyeballs-2.4.0-py3-none-any.whl", hash = "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd"}, + {file = "aiohappyeyeballs-2.4.0.tar.gz", hash = "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2"}, ] [[package]] name = "aiohttp" -version = "3.10.0" +version = "3.10.5" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" files = [ - {file = "aiohttp-3.10.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:68ab608118e212f56feef44d4785aa90b713042da301f26338f36497b481cd79"}, - {file = "aiohttp-3.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:64a117c16273ca9f18670f33fc7fd9604b9f46ddb453ce948262889a6be72868"}, - {file = "aiohttp-3.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:54076a25f32305e585a3abae1f0ad10646bec539e0e5ebcc62b54ee4982ec29f"}, - {file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71c76685773444d90ae83874433505ed800e1706c391fdf9e57cc7857611e2f4"}, - {file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bdda86ab376f9b3095a1079a16fbe44acb9ddde349634f1c9909d13631ff3bcf"}, - {file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d6dcd1d21da5ae1416f69aa03e883a51e84b6c803b8618cbab341ac89a85b9e"}, - {file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06ef0135d7ab7fb0284342fbbf8e8ddf73b7fee8ecc55f5c3a3d0a6b765e6d8b"}, - {file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccab9381f38c669bb9254d848f3b41a3284193b3e274a34687822f98412097e9"}, - {file = "aiohttp-3.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:947da3aee057010bc750b7b4bb65cbd01b0bdb7c4e1cf278489a1d4a1e9596b3"}, - {file = "aiohttp-3.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5268b35fee7eb754fb5b3d0f16a84a2e9ed21306f5377f3818596214ad2d7714"}, - {file = "aiohttp-3.10.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff25d988fd6ce433b5c393094a5ca50df568bdccf90a8b340900e24e0d5fb45c"}, - {file = "aiohttp-3.10.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:594b4b4f1dfe8378b4a0342576dc87a930c960641159f5ae83843834016dbd59"}, - {file = "aiohttp-3.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c8820dad615cd2f296ed3fdea8402b12663ac9e5ea2aafc90ef5141eb10b50b8"}, - {file = "aiohttp-3.10.0-cp310-cp310-win32.whl", hash = "sha256:ab1d870403817c9a0486ca56ccbc0ebaf85d992277d48777faa5a95e40e5bcca"}, - {file = "aiohttp-3.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:563705a94ea3af43467167f3a21c665f3b847b2a0ae5544fa9e18df686a660da"}, - {file = "aiohttp-3.10.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13679e11937d3f37600860de1f848e2e062e2b396d3aa79b38c89f9c8ab7e791"}, - {file = "aiohttp-3.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8c66a1aadafbc0bd7d648cb7fcb3860ec9beb1b436ce3357036a4d9284fcef9a"}, - {file = "aiohttp-3.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7e3545b06aae925f90f06402e05cfb9c62c6409ce57041932163b09c48daad6"}, - {file = "aiohttp-3.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:effafe5144aa32f0388e8f99b1b2692cf094ea2f6b7ceca384b54338b77b1f50"}, - {file = "aiohttp-3.10.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a04f2c8d41821a2507b49b2694c40495a295b013afb0cc7355b337980b47c546"}, - {file = "aiohttp-3.10.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6dbfac556219d884d50edc6e1952a93545c2786193f00f5521ec0d9d464040ab"}, - {file = "aiohttp-3.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a65472256c5232681968deeea3cd5453aa091c44e8db09f22f1a1491d422c2d9"}, - {file = "aiohttp-3.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:941366a554e566efdd3f042e17a9e461a36202469e5fd2aee66fe3efe6412aef"}, - {file = "aiohttp-3.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:927b4aca6340301e7d8bb05278d0b6585b8633ea852b7022d604a5df920486bf"}, - {file = "aiohttp-3.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:34adb8412e736a5d0df6d1fccdf71599dfb07a63add241a94a189b6364e997f1"}, - {file = "aiohttp-3.10.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:43c60d9b332a01ee985f080f639f3e56abcfb95ec1320013c94083c3b6a2e143"}, - {file = "aiohttp-3.10.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3f49edf7c5cd2987634116e1b6a0ee2438fca17f7c4ee480ff41decb76cf6158"}, - {file = "aiohttp-3.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9784246431eaf9d651b3cc06f9c64f9a9f57299f4971c5ea778fa0b81074ef13"}, - {file = "aiohttp-3.10.0-cp311-cp311-win32.whl", hash = "sha256:bec91402df78b897a47b66b9c071f48051cea68d853d8bc1d4404896c6de41ae"}, - {file = "aiohttp-3.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:25a9924343bf91b0c5082cae32cfc5a1f8787ac0433966319ec07b0ed4570722"}, - {file = "aiohttp-3.10.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:21dab4a704c68dc7bc2a1219a4027158e8968e2079f1444eda2ba88bc9f2895f"}, - {file = "aiohttp-3.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:872c0dcaccebd5733d535868fe2356aa6939f5827dcea7a8b9355bb2eff6f56e"}, - {file = "aiohttp-3.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f381424dbce313bb5a666a215e7a9dcebbc533e9a2c467a1f0c95279d24d1fa7"}, - {file = "aiohttp-3.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ca48e9f092a417c6669ee8d3a19d40b3c66dde1a2ae0d57e66c34812819b671"}, - {file = "aiohttp-3.10.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbe2f6d0466f5c59c7258e0745c20d74806a1385fbb7963e5bbe2309a11cc69b"}, - {file = "aiohttp-3.10.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:03799a95402a7ed62671c4465e1eae51d749d5439dbc49edb6eee52ea165c50b"}, - {file = "aiohttp-3.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5549c71c35b5f057a4eebcc538c41299826f7813f28880722b60e41c861a57ec"}, - {file = "aiohttp-3.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6fa7a42b78d8698491dc4ad388169de54cca551aa9900f750547372de396277"}, - {file = "aiohttp-3.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:77bbf0a2f6fefac6c0db1792c234f577d80299a33ce7125467439097cf869198"}, - {file = "aiohttp-3.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:34eaf5cfcc979846d73571b1a4be22cad5e029d55cdbe77cdc7545caa4dcb925"}, - {file = "aiohttp-3.10.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4f1de31a585344a106db43a9c3af2e15bb82e053618ff759f1fdd31d82da38eb"}, - {file = "aiohttp-3.10.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f3a1ea61d96146e9b9e5597069466e2e4d9e01e09381c5dd51659f890d5e29e7"}, - {file = "aiohttp-3.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:73c01201219eb039a828bb58dcc13112eec2fed6eea718356316cd552df26e04"}, - {file = "aiohttp-3.10.0-cp312-cp312-win32.whl", hash = "sha256:33e915971eee6d2056d15470a1214e4e0f72b6aad10225548a7ab4c4f54e2db7"}, - {file = "aiohttp-3.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2dc75da06c35a7b47a88ceadbf993a53d77d66423c2a78de8c6f9fb41ec35687"}, - {file = "aiohttp-3.10.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f1bc4d68b83966012813598fe39b35b4e6019b69d29385cf7ec1cb08e1ff829b"}, - {file = "aiohttp-3.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d9b8b31c057a0b7bb822a159c490af05cb11b8069097f3236746a78315998afa"}, - {file = "aiohttp-3.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10f0d7894ddc6ff8f369e3fdc082ef1f940dc1f5b9003cd40945d24845477220"}, - {file = "aiohttp-3.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72de8ffba4a27e3c6e83e58a379fc4fe5548f69f9b541fde895afb9be8c31658"}, - {file = "aiohttp-3.10.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd36d0f0afc2bd84f007cedd2d9a449c3cf04af471853a25eb71f28bc2e1a119"}, - {file = "aiohttp-3.10.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f64d503c661864866c09806ac360b95457f872d639ca61719115a9f389b2ec90"}, - {file = "aiohttp-3.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31616121369bc823791056c632f544c6c8f8d1ceecffd8bf3f72ef621eaabf49"}, - {file = "aiohttp-3.10.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f76c12abb88b7ee64b3f9ae72f0644af49ff139067b5add142836dab405d60d4"}, - {file = "aiohttp-3.10.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6c99eef30a7e98144bcf44d615bc0f445b3a3730495fcc16124cb61117e1f81e"}, - {file = "aiohttp-3.10.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:39e7ec718e7a1971a5d98357e3e8c0529477d45c711d32cd91999dc8d8404e1e"}, - {file = "aiohttp-3.10.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f1cef548ee4e84264b78879de0c754bbe223193c6313beb242ce862f82eab184"}, - {file = "aiohttp-3.10.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:f98f036eab11d2f90cdd01b9d1410de9d7eb520d070debeb2edadf158b758431"}, - {file = "aiohttp-3.10.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cc4376ff537f7d2c1e98f97f6d548e99e5d96078b0333c1d3177c11467b972de"}, - {file = "aiohttp-3.10.0-cp38-cp38-win32.whl", hash = "sha256:ebedc51ee6d39f9ea5e26e255fd56a7f4e79a56e77d960f9bae75ef4f95ed57f"}, - {file = "aiohttp-3.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:aad87626f31a85fd4af02ba7fd6cc424b39d4bff5c8677e612882649da572e47"}, - {file = "aiohttp-3.10.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1dc95c5e2a5e60095f1bb51822e3b504e6a7430c9b44bff2120c29bb876c5202"}, - {file = "aiohttp-3.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1c83977f7b6f4f4a96fab500f5a76d355f19f42675224a3002d375b3fb309174"}, - {file = "aiohttp-3.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8cedc48d36652dd3ac40e5c7c139d528202393e341a5e3475acedb5e8d5c4c75"}, - {file = "aiohttp-3.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b099fbb823efed3c1d736f343ac60d66531b13680ee9b2669e368280f41c2b8"}, - {file = "aiohttp-3.10.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d583755ddb9c97a2da1322f17fc7d26792f4e035f472d675e2761c766f94c2ff"}, - {file = "aiohttp-3.10.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a03a4407bdb9ae815f0d5a19df482b17df530cf7bf9c78771aa1c713c37ff1f"}, - {file = "aiohttp-3.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcb6e65f6ea7caa0188e36bebe9e72b259d3d525634758c91209afb5a6cbcba7"}, - {file = "aiohttp-3.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6612c6ed3147a4a2d6463454b94b877566b38215665be4c729cd8b7bdce15b4"}, - {file = "aiohttp-3.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0b0c0148d2a69b82ffe650c2ce235b431d49a90bde7dd2629bcb40314957acf6"}, - {file = "aiohttp-3.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0d85a173b4dbbaaad1900e197181ea0fafa617ca6656663f629a8a372fdc7d06"}, - {file = "aiohttp-3.10.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:12c43dace645023583f3dd2337dfc3aa92c99fb943b64dcf2bc15c7aa0fb4a95"}, - {file = "aiohttp-3.10.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:33acb0d9bf12cdc80ceec6f5fda83ea7990ce0321c54234d629529ca2c54e33d"}, - {file = "aiohttp-3.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:91e0b76502205484a4d1d6f25f461fa60fe81a7987b90e57f7b941b0753c3ec8"}, - {file = "aiohttp-3.10.0-cp39-cp39-win32.whl", hash = "sha256:1ebd8ed91428ffbe8b33a5bd6f50174e11882d5b8e2fe28670406ab5ee045ede"}, - {file = "aiohttp-3.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:0433795c4a8bafc03deb3e662192250ba5db347c41231b0273380d2f53c9ea0b"}, - {file = "aiohttp-3.10.0.tar.gz", hash = "sha256:e8dd7da2609303e3574c95b0ec9f1fd49647ef29b94701a2862cceae76382e1d"}, + {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:18a01eba2574fb9edd5f6e5fb25f66e6ce061da5dab5db75e13fe1558142e0a3"}, + {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94fac7c6e77ccb1ca91e9eb4cb0ac0270b9fb9b289738654120ba8cebb1189c6"}, + {file = "aiohttp-3.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2f1f1c75c395991ce9c94d3e4aa96e5c59c8356a15b1c9231e783865e2772699"}, + {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f7acae3cf1a2a2361ec4c8e787eaaa86a94171d2417aae53c0cca6ca3118ff6"}, + {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94c4381ffba9cc508b37d2e536b418d5ea9cfdc2848b9a7fea6aebad4ec6aac1"}, + {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c31ad0c0c507894e3eaa843415841995bf8de4d6b2d24c6e33099f4bc9fc0d4f"}, + {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0912b8a8fadeb32ff67a3ed44249448c20148397c1ed905d5dac185b4ca547bb"}, + {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d93400c18596b7dc4794d48a63fb361b01a0d8eb39f28800dc900c8fbdaca91"}, + {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3c5e0d764a5c9aa5a62d99728c56d455310bcc288a79cab10157b3af426f"}, + {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d742c36ed44f2798c8d3f4bc511f479b9ceef2b93f348671184139e7d708042c"}, + {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:814375093edae5f1cb31e3407997cf3eacefb9010f96df10d64829362ae2df69"}, + {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8224f98be68a84b19f48e0bdc14224b5a71339aff3a27df69989fa47d01296f3"}, + {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9a487ef090aea982d748b1b0d74fe7c3950b109df967630a20584f9a99c0683"}, + {file = "aiohttp-3.10.5-cp310-cp310-win32.whl", hash = "sha256:d9ef084e3dc690ad50137cc05831c52b6ca428096e6deb3c43e95827f531d5ef"}, + {file = "aiohttp-3.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:66bf9234e08fe561dccd62083bf67400bdbf1c67ba9efdc3dac03650e97c6088"}, + {file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8c6a4e5e40156d72a40241a25cc226051c0a8d816610097a8e8f517aeacd59a2"}, + {file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c634a3207a5445be65536d38c13791904fda0748b9eabf908d3fe86a52941cf"}, + {file = "aiohttp-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4aff049b5e629ef9b3e9e617fa6e2dfeda1bf87e01bcfecaf3949af9e210105e"}, + {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1942244f00baaacaa8155eca94dbd9e8cc7017deb69b75ef67c78e89fdad3c77"}, + {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04a1f2a65ad2f93aa20f9ff9f1b672bf912413e5547f60749fa2ef8a644e061"}, + {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f2bfc0032a00405d4af2ba27f3c429e851d04fad1e5ceee4080a1c570476697"}, + {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:424ae21498790e12eb759040bbb504e5e280cab64693d14775c54269fd1d2bb7"}, + {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:975218eee0e6d24eb336d0328c768ebc5d617609affaca5dbbd6dd1984f16ed0"}, + {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4120d7fefa1e2d8fb6f650b11489710091788de554e2b6f8347c7a20ceb003f5"}, + {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b90078989ef3fc45cf9221d3859acd1108af7560c52397ff4ace8ad7052a132e"}, + {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ba5a8b74c2a8af7d862399cdedce1533642fa727def0b8c3e3e02fcb52dca1b1"}, + {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:02594361128f780eecc2a29939d9dfc870e17b45178a867bf61a11b2a4367277"}, + {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4fc029e135859f533025bc82047334e24b0d489e75513144f25408ecaf058"}, + {file = "aiohttp-3.10.5-cp311-cp311-win32.whl", hash = "sha256:e1ca1ef5ba129718a8fc827b0867f6aa4e893c56eb00003b7367f8a733a9b072"}, + {file = "aiohttp-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:349ef8a73a7c5665cca65c88ab24abe75447e28aa3bc4c93ea5093474dfdf0ff"}, + {file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:305be5ff2081fa1d283a76113b8df7a14c10d75602a38d9f012935df20731487"}, + {file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3a1c32a19ee6bbde02f1cb189e13a71b321256cc1d431196a9f824050b160d5a"}, + {file = "aiohttp-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61645818edd40cc6f455b851277a21bf420ce347baa0b86eaa41d51ef58ba23d"}, + {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c225286f2b13bab5987425558baa5cbdb2bc925b2998038fa028245ef421e75"}, + {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ba01ebc6175e1e6b7275c907a3a36be48a2d487549b656aa90c8a910d9f3178"}, + {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8eaf44ccbc4e35762683078b72bf293f476561d8b68ec8a64f98cf32811c323e"}, + {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1c43eb1ab7cbf411b8e387dc169acb31f0ca0d8c09ba63f9eac67829585b44f"}, + {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de7a5299827253023c55ea549444e058c0eb496931fa05d693b95140a947cb73"}, + {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4790f0e15f00058f7599dab2b206d3049d7ac464dc2e5eae0e93fa18aee9e7bf"}, + {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:44b324a6b8376a23e6ba25d368726ee3bc281e6ab306db80b5819999c737d820"}, + {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0d277cfb304118079e7044aad0b76685d30ecb86f83a0711fc5fb257ffe832ca"}, + {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:54d9ddea424cd19d3ff6128601a4a4d23d54a421f9b4c0fff740505813739a91"}, + {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f1c9866ccf48a6df2b06823e6ae80573529f2af3a0992ec4fe75b1a510df8a6"}, + {file = "aiohttp-3.10.5-cp312-cp312-win32.whl", hash = "sha256:dc4826823121783dccc0871e3f405417ac116055bf184ac04c36f98b75aacd12"}, + {file = "aiohttp-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:22c0a23a3b3138a6bf76fc553789cb1a703836da86b0f306b6f0dc1617398abc"}, + {file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7f6b639c36734eaa80a6c152a238242bedcee9b953f23bb887e9102976343092"}, + {file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29930bc2921cef955ba39a3ff87d2c4398a0394ae217f41cb02d5c26c8b1b77"}, + {file = "aiohttp-3.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f489a2c9e6455d87eabf907ac0b7d230a9786be43fbe884ad184ddf9e9c1e385"}, + {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:123dd5b16b75b2962d0fff566effb7a065e33cd4538c1692fb31c3bda2bfb972"}, + {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b98e698dc34966e5976e10bbca6d26d6724e6bdea853c7c10162a3235aba6e16"}, + {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3b9162bab7e42f21243effc822652dc5bb5e8ff42a4eb62fe7782bcbcdfacf6"}, + {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1923a5c44061bffd5eebeef58cecf68096e35003907d8201a4d0d6f6e387ccaa"}, + {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d55f011da0a843c3d3df2c2cf4e537b8070a419f891c930245f05d329c4b0689"}, + {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:afe16a84498441d05e9189a15900640a2d2b5e76cf4efe8cbb088ab4f112ee57"}, + {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f8112fb501b1e0567a1251a2fd0747baae60a4ab325a871e975b7bb67e59221f"}, + {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1e72589da4c90337837fdfe2026ae1952c0f4a6e793adbbfbdd40efed7c63599"}, + {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4d46c7b4173415d8e583045fbc4daa48b40e31b19ce595b8d92cf639396c15d5"}, + {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33e6bc4bab477c772a541f76cd91e11ccb6d2efa2b8d7d7883591dfb523e5987"}, + {file = "aiohttp-3.10.5-cp313-cp313-win32.whl", hash = "sha256:c58c6837a2c2a7cf3133983e64173aec11f9c2cd8e87ec2fdc16ce727bcf1a04"}, + {file = "aiohttp-3.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:38172a70005252b6893088c0f5e8a47d173df7cc2b2bd88650957eb84fcf5022"}, + {file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f6f18898ace4bcd2d41a122916475344a87f1dfdec626ecde9ee802a711bc569"}, + {file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5ede29d91a40ba22ac1b922ef510aab871652f6c88ef60b9dcdf773c6d32ad7a"}, + {file = "aiohttp-3.10.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:673f988370f5954df96cc31fd99c7312a3af0a97f09e407399f61583f30da9bc"}, + {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58718e181c56a3c02d25b09d4115eb02aafe1a732ce5714ab70326d9776457c3"}, + {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b38b1570242fbab8d86a84128fb5b5234a2f70c2e32f3070143a6d94bc854cf"}, + {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:074d1bff0163e107e97bd48cad9f928fa5a3eb4b9d33366137ffce08a63e37fe"}, + {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd31f176429cecbc1ba499d4aba31aaccfea488f418d60376b911269d3b883c5"}, + {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7384d0b87d4635ec38db9263e6a3f1eb609e2e06087f0aa7f63b76833737b471"}, + {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8989f46f3d7ef79585e98fa991e6ded55d2f48ae56d2c9fa5e491a6e4effb589"}, + {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:c83f7a107abb89a227d6c454c613e7606c12a42b9a4ca9c5d7dad25d47c776ae"}, + {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cde98f323d6bf161041e7627a5fd763f9fd829bcfcd089804a5fdce7bb6e1b7d"}, + {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:676f94c5480d8eefd97c0c7e3953315e4d8c2b71f3b49539beb2aa676c58272f"}, + {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2d21ac12dc943c68135ff858c3a989f2194a709e6e10b4c8977d7fcd67dfd511"}, + {file = "aiohttp-3.10.5-cp38-cp38-win32.whl", hash = "sha256:17e997105bd1a260850272bfb50e2a328e029c941c2708170d9d978d5a30ad9a"}, + {file = "aiohttp-3.10.5-cp38-cp38-win_amd64.whl", hash = "sha256:1c19de68896747a2aa6257ae4cf6ef59d73917a36a35ee9d0a6f48cff0f94db8"}, + {file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7e2fe37ac654032db1f3499fe56e77190282534810e2a8e833141a021faaab0e"}, + {file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5bf3ead3cb66ab990ee2561373b009db5bc0e857549b6c9ba84b20bc462e172"}, + {file = "aiohttp-3.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b2c16a919d936ca87a3c5f0e43af12a89a3ce7ccbce59a2d6784caba945b68b"}, + {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad146dae5977c4dd435eb31373b3fe9b0b1bf26858c6fc452bf6af394067e10b"}, + {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c5c6fa16412b35999320f5c9690c0f554392dc222c04e559217e0f9ae244b92"}, + {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95c4dc6f61d610bc0ee1edc6f29d993f10febfe5b76bb470b486d90bbece6b22"}, + {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da452c2c322e9ce0cfef392e469a26d63d42860f829026a63374fde6b5c5876f"}, + {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:898715cf566ec2869d5cb4d5fb4be408964704c46c96b4be267442d265390f32"}, + {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:391cc3a9c1527e424c6865e087897e766a917f15dddb360174a70467572ac6ce"}, + {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:380f926b51b92d02a34119d072f178d80bbda334d1a7e10fa22d467a66e494db"}, + {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce91db90dbf37bb6fa0997f26574107e1b9d5ff939315247b7e615baa8ec313b"}, + {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9093a81e18c45227eebe4c16124ebf3e0d893830c6aca7cc310bfca8fe59d857"}, + {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ee40b40aa753d844162dcc80d0fe256b87cba48ca0054f64e68000453caead11"}, + {file = "aiohttp-3.10.5-cp39-cp39-win32.whl", hash = "sha256:03f2645adbe17f274444953bdea69f8327e9d278d961d85657cb0d06864814c1"}, + {file = "aiohttp-3.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:d17920f18e6ee090bdd3d0bfffd769d9f2cb4c8ffde3eb203777a3895c128862"}, + {file = "aiohttp-3.10.5.tar.gz", hash = "sha256:f071854b47d39591ce9a17981c46790acb30518e2f83dfca8db2dfa091178691"}, ] [package.dependencies] @@ -224,13 +239,13 @@ tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] [[package]] name = "babel" -version = "2.15.0" +version = "2.16.0" description = "Internationalization utilities" optional = true python-versions = ">=3.8" files = [ - {file = "Babel-2.15.0-py3-none-any.whl", hash = "sha256:08706bdad8d0a3413266ab61bd6c34d0c28d6e1e7badf40a2cebe67644e2e1fb"}, - {file = "babel-2.15.0.tar.gz", hash = "sha256:8daf0e265d05768bc6c7a314cf1321e9a123afc328cc635c18622a2f30a04413"}, + {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, + {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, ] [package.extras] @@ -238,24 +253,24 @@ dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] [[package]] name = "cachetools" -version = "5.4.0" +version = "5.5.0" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" files = [ - {file = "cachetools-5.4.0-py3-none-any.whl", hash = "sha256:3ae3b49a3d5e28a77a0be2b37dbcb89005058959cb2323858c2657c4a8cab474"}, - {file = "cachetools-5.4.0.tar.gz", hash = "sha256:b8adc2e7c07f105ced7bc56dbb6dfbe7c4a00acce20e2227b3f355be89bc6827"}, + {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, + {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, ] [[package]] name = "certifi" -version = "2024.7.4" +version = "2024.8.30" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, - {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, ] [[package]] @@ -486,63 +501,83 @@ files = [ [[package]] name = "coverage" -version = "7.6.0" +version = "7.6.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd"}, - {file = "coverage-7.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c"}, - {file = "coverage-7.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463"}, - {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791"}, - {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c"}, - {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783"}, - {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6"}, - {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb"}, - {file = "coverage-7.6.0-cp310-cp310-win32.whl", hash = "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c"}, - {file = "coverage-7.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169"}, - {file = "coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933"}, - {file = "coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d"}, - {file = "coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94"}, - {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1"}, - {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac"}, - {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57"}, - {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d"}, - {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63"}, - {file = "coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713"}, - {file = "coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1"}, - {file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"}, - {file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"}, - {file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"}, - {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"}, - {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"}, - {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"}, - {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"}, - {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"}, - {file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"}, - {file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"}, - {file = "coverage-7.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b"}, - {file = "coverage-7.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d"}, - {file = "coverage-7.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca"}, - {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b"}, - {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44"}, - {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03"}, - {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6"}, - {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b"}, - {file = "coverage-7.6.0-cp38-cp38-win32.whl", hash = "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428"}, - {file = "coverage-7.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8"}, - {file = "coverage-7.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c"}, - {file = "coverage-7.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2"}, - {file = "coverage-7.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390"}, - {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b"}, - {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450"}, - {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6"}, - {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166"}, - {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd"}, - {file = "coverage-7.6.0-cp39-cp39-win32.whl", hash = "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2"}, - {file = "coverage-7.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca"}, - {file = "coverage-7.6.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6"}, - {file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, + {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, + {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, + {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, + {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, + {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, + {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, + {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, + {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, + {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, + {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, + {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, + {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, + {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, + {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, + {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, + {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, ] [package.dependencies] @@ -768,13 +803,13 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.7" +version = "3.8" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" files = [ - {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, - {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, + {file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"}, + {file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"}, ] [[package]] @@ -790,13 +825,13 @@ files = [ [[package]] name = "importlib-metadata" -version = "8.2.0" +version = "8.4.0" description = "Read metadata from Python packages" optional = true python-versions = ">=3.8" files = [ - {file = "importlib_metadata-8.2.0-py3-none-any.whl", hash = "sha256:11901fa0c2f97919b288679932bb64febaeacf289d18ac84dd68cb2e74213369"}, - {file = "importlib_metadata-8.2.0.tar.gz", hash = "sha256:72e8d4399996132204f9a16dcc751af254a48f8d1b20b9ff0f98d4a8f901e73d"}, + {file = "importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1"}, + {file = "importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5"}, ] [package.dependencies] @@ -1114,38 +1149,38 @@ files = [ [[package]] name = "mypy" -version = "1.11.1" +version = "1.11.2" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c"}, - {file = "mypy-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411"}, - {file = "mypy-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03"}, - {file = "mypy-1.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4"}, - {file = "mypy-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58"}, - {file = "mypy-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5"}, - {file = "mypy-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca"}, - {file = "mypy-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de"}, - {file = "mypy-1.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809"}, - {file = "mypy-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72"}, - {file = "mypy-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8"}, - {file = "mypy-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a"}, - {file = "mypy-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417"}, - {file = "mypy-1.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e"}, - {file = "mypy-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525"}, - {file = "mypy-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2"}, - {file = "mypy-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b"}, - {file = "mypy-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0"}, - {file = "mypy-1.11.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd"}, - {file = "mypy-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb"}, - {file = "mypy-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe"}, - {file = "mypy-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c"}, - {file = "mypy-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69"}, - {file = "mypy-1.11.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74"}, - {file = "mypy-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b"}, - {file = "mypy-1.11.1-py3-none-any.whl", hash = "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54"}, - {file = "mypy-1.11.1.tar.gz", hash = "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08"}, + {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, + {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, + {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"}, + {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"}, + {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"}, + {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"}, + {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"}, + {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"}, + {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"}, + {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"}, + {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"}, + {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"}, + {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"}, + {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"}, + {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"}, + {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"}, + {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"}, + {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"}, + {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"}, + {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"}, + {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"}, + {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"}, + {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"}, + {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"}, + {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"}, + {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"}, + {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"}, ] [package.dependencies] @@ -1209,64 +1244,68 @@ files = [ [[package]] name = "orjson" -version = "3.10.6" +version = "3.10.7" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = true python-versions = ">=3.8" files = [ - {file = "orjson-3.10.6-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fb0ee33124db6eaa517d00890fc1a55c3bfe1cf78ba4a8899d71a06f2d6ff5c7"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c1c4b53b24a4c06547ce43e5fee6ec4e0d8fe2d597f4647fc033fd205707365"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eadc8fd310edb4bdbd333374f2c8fec6794bbbae99b592f448d8214a5e4050c0"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61272a5aec2b2661f4fa2b37c907ce9701e821b2c1285d5c3ab0207ebd358d38"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57985ee7e91d6214c837936dc1608f40f330a6b88bb13f5a57ce5257807da143"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:633a3b31d9d7c9f02d49c4ab4d0a86065c4a6f6adc297d63d272e043472acab5"}, - {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1c680b269d33ec444afe2bdc647c9eb73166fa47a16d9a75ee56a374f4a45f43"}, - {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f759503a97a6ace19e55461395ab0d618b5a117e8d0fbb20e70cfd68a47327f2"}, - {file = "orjson-3.10.6-cp310-none-win32.whl", hash = "sha256:95a0cce17f969fb5391762e5719575217bd10ac5a189d1979442ee54456393f3"}, - {file = "orjson-3.10.6-cp310-none-win_amd64.whl", hash = "sha256:df25d9271270ba2133cc88ee83c318372bdc0f2cd6f32e7a450809a111efc45c"}, - {file = "orjson-3.10.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b1ec490e10d2a77c345def52599311849fc063ae0e67cf4f84528073152bb2ba"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d43d3feb8f19d07e9f01e5b9be4f28801cf7c60d0fa0d279951b18fae1932b"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3045267e98fe749408eee1593a142e02357c5c99be0802185ef2170086a863"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c27bc6a28ae95923350ab382c57113abd38f3928af3c80be6f2ba7eb8d8db0b0"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d27456491ca79532d11e507cadca37fb8c9324a3976294f68fb1eff2dc6ced5a"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05ac3d3916023745aa3b3b388e91b9166be1ca02b7c7e41045da6d12985685f0"}, - {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1335d4ef59ab85cab66fe73fd7a4e881c298ee7f63ede918b7faa1b27cbe5212"}, - {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4bbc6d0af24c1575edc79994c20e1b29e6fb3c6a570371306db0993ecf144dc5"}, - {file = "orjson-3.10.6-cp311-none-win32.whl", hash = "sha256:450e39ab1f7694465060a0550b3f6d328d20297bf2e06aa947b97c21e5241fbd"}, - {file = "orjson-3.10.6-cp311-none-win_amd64.whl", hash = "sha256:227df19441372610b20e05bdb906e1742ec2ad7a66ac8350dcfd29a63014a83b"}, - {file = "orjson-3.10.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ea2977b21f8d5d9b758bb3f344a75e55ca78e3ff85595d248eee813ae23ecdfb"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6f3d167d13a16ed263b52dbfedff52c962bfd3d270b46b7518365bcc2121eed"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f710f346e4c44a4e8bdf23daa974faede58f83334289df80bc9cd12fe82573c7"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7275664f84e027dcb1ad5200b8b18373e9c669b2a9ec33d410c40f5ccf4b257e"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0943e4c701196b23c240b3d10ed8ecd674f03089198cf503105b474a4f77f21f"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:446dee5a491b5bc7d8f825d80d9637e7af43f86a331207b9c9610e2f93fee22a"}, - {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:64c81456d2a050d380786413786b057983892db105516639cb5d3ee3c7fd5148"}, - {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:960db0e31c4e52fa0fc3ecbaea5b2d3b58f379e32a95ae6b0ebeaa25b93dfd34"}, - {file = "orjson-3.10.6-cp312-none-win32.whl", hash = "sha256:a6ea7afb5b30b2317e0bee03c8d34c8181bc5a36f2afd4d0952f378972c4efd5"}, - {file = "orjson-3.10.6-cp312-none-win_amd64.whl", hash = "sha256:874ce88264b7e655dde4aeaacdc8fd772a7962faadfb41abe63e2a4861abc3dc"}, - {file = "orjson-3.10.6-cp313-none-win32.whl", hash = "sha256:efdf2c5cde290ae6b83095f03119bdc00303d7a03b42b16c54517baa3c4ca3d0"}, - {file = "orjson-3.10.6-cp313-none-win_amd64.whl", hash = "sha256:8e190fe7888e2e4392f52cafb9626113ba135ef53aacc65cd13109eb9746c43e"}, - {file = "orjson-3.10.6-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:66680eae4c4e7fc193d91cfc1353ad6d01b4801ae9b5314f17e11ba55e934183"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff75b425db5ef8e8f23af93c80f072f97b4fb3afd4af44482905c9f588da28"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3722fddb821b6036fd2a3c814f6bd9b57a89dc6337b9924ecd614ebce3271394"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2c116072a8533f2fec435fde4d134610f806bdac20188c7bd2081f3e9e0133f"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6eeb13218c8cf34c61912e9df2de2853f1d009de0e46ea09ccdf3d757896af0a"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965a916373382674e323c957d560b953d81d7a8603fbeee26f7b8248638bd48b"}, - {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:03c95484d53ed8e479cade8628c9cea00fd9d67f5554764a1110e0d5aa2de96e"}, - {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e060748a04cccf1e0a6f2358dffea9c080b849a4a68c28b1b907f272b5127e9b"}, - {file = "orjson-3.10.6-cp38-none-win32.whl", hash = "sha256:738dbe3ef909c4b019d69afc19caf6b5ed0e2f1c786b5d6215fbb7539246e4c6"}, - {file = "orjson-3.10.6-cp38-none-win_amd64.whl", hash = "sha256:d40f839dddf6a7d77114fe6b8a70218556408c71d4d6e29413bb5f150a692ff7"}, - {file = "orjson-3.10.6-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:697a35a083c4f834807a6232b3e62c8b280f7a44ad0b759fd4dce748951e70db"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd502f96bf5ea9a61cbc0b2b5900d0dd68aa0da197179042bdd2be67e51a1e4b"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f215789fb1667cdc874c1b8af6a84dc939fd802bf293a8334fce185c79cd359b"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2debd8ddce948a8c0938c8c93ade191d2f4ba4649a54302a7da905a81f00b56"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5410111d7b6681d4b0d65e0f58a13be588d01b473822483f77f513c7f93bd3b2"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb1f28a137337fdc18384079fa5726810681055b32b92253fa15ae5656e1dddb"}, - {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bf2fbbce5fe7cd1aa177ea3eab2b8e6a6bc6e8592e4279ed3db2d62e57c0e1b2"}, - {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:79b9b9e33bd4c517445a62b90ca0cc279b0f1f3970655c3df9e608bc3f91741a"}, - {file = "orjson-3.10.6-cp39-none-win32.whl", hash = "sha256:30b0a09a2014e621b1adf66a4f705f0809358350a757508ee80209b2d8dae219"}, - {file = "orjson-3.10.6-cp39-none-win_amd64.whl", hash = "sha256:49e3bc615652617d463069f91b867a4458114c5b104e13b7ae6872e5f79d0844"}, - {file = "orjson-3.10.6.tar.gz", hash = "sha256:e54b63d0a7c6c54a5f5f726bc93a2078111ef060fec4ecbf34c5db800ca3b3a7"}, + {file = "orjson-3.10.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:74f4544f5a6405b90da8ea724d15ac9c36da4d72a738c64685003337401f5c12"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34a566f22c28222b08875b18b0dfbf8a947e69df21a9ed5c51a6bf91cfb944ac"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf6ba8ebc8ef5792e2337fb0419f8009729335bb400ece005606336b7fd7bab7"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac7cf6222b29fbda9e3a472b41e6a5538b48f2c8f99261eecd60aafbdb60690c"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de817e2f5fc75a9e7dd350c4b0f54617b280e26d1631811a43e7e968fa71e3e9"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:348bdd16b32556cf8d7257b17cf2bdb7ab7976af4af41ebe79f9796c218f7e91"}, + {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:479fd0844ddc3ca77e0fd99644c7fe2de8e8be1efcd57705b5c92e5186e8a250"}, + {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fdf5197a21dd660cf19dfd2a3ce79574588f8f5e2dbf21bda9ee2d2b46924d84"}, + {file = "orjson-3.10.7-cp310-none-win32.whl", hash = "sha256:d374d36726746c81a49f3ff8daa2898dccab6596864ebe43d50733275c629175"}, + {file = "orjson-3.10.7-cp310-none-win_amd64.whl", hash = "sha256:cb61938aec8b0ffb6eef484d480188a1777e67b05d58e41b435c74b9d84e0b9c"}, + {file = "orjson-3.10.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7db8539039698ddfb9a524b4dd19508256107568cdad24f3682d5773e60504a2"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:480f455222cb7a1dea35c57a67578848537d2602b46c464472c995297117fa09"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a9c9b168b3a19e37fe2778c0003359f07822c90fdff8f98d9d2a91b3144d8e0"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8de062de550f63185e4c1c54151bdddfc5625e37daf0aa1e75d2a1293e3b7d9a"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b0dd04483499d1de9c8f6203f8975caf17a6000b9c0c54630cef02e44ee624e"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b58d3795dafa334fc8fd46f7c5dc013e6ad06fd5b9a4cc98cb1456e7d3558bd6"}, + {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33cfb96c24034a878d83d1a9415799a73dc77480e6c40417e5dda0710d559ee6"}, + {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e724cebe1fadc2b23c6f7415bad5ee6239e00a69f30ee423f319c6af70e2a5c0"}, + {file = "orjson-3.10.7-cp311-none-win32.whl", hash = "sha256:82763b46053727a7168d29c772ed5c870fdae2f61aa8a25994c7984a19b1021f"}, + {file = "orjson-3.10.7-cp311-none-win_amd64.whl", hash = "sha256:eb8d384a24778abf29afb8e41d68fdd9a156cf6e5390c04cc07bbc24b89e98b5"}, + {file = "orjson-3.10.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44a96f2d4c3af51bfac6bc4ef7b182aa33f2f054fd7f34cc0ee9a320d051d41f"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ac14cd57df0572453543f8f2575e2d01ae9e790c21f57627803f5e79b0d3c3"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdbb61dcc365dd9be94e8f7df91975edc9364d6a78c8f7adb69c1cdff318ec93"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b48b3db6bb6e0a08fa8c83b47bc169623f801e5cc4f24442ab2b6617da3b5313"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23820a1563a1d386414fef15c249040042b8e5d07b40ab3fe3efbfbbcbcb8864"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0c6a008e91d10a2564edbb6ee5069a9e66df3fbe11c9a005cb411f441fd2c09"}, + {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d352ee8ac1926d6193f602cbe36b1643bbd1bbcb25e3c1a657a4390f3000c9a5"}, + {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2d9f990623f15c0ae7ac608103c33dfe1486d2ed974ac3f40b693bad1a22a7b"}, + {file = "orjson-3.10.7-cp312-none-win32.whl", hash = "sha256:7c4c17f8157bd520cdb7195f75ddbd31671997cbe10aee559c2d613592e7d7eb"}, + {file = "orjson-3.10.7-cp312-none-win_amd64.whl", hash = "sha256:1d9c0e733e02ada3ed6098a10a8ee0052dd55774de3d9110d29868d24b17faa1"}, + {file = "orjson-3.10.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:77d325ed866876c0fa6492598ec01fe30e803272a6e8b10e992288b009cbe149"}, + {file = "orjson-3.10.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ea2c232deedcb605e853ae1db2cc94f7390ac776743b699b50b071b02bea6fe"}, + {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3dcfbede6737fdbef3ce9c37af3fb6142e8e1ebc10336daa05872bfb1d87839c"}, + {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11748c135f281203f4ee695b7f80bb1358a82a63905f9f0b794769483ea854ad"}, + {file = "orjson-3.10.7-cp313-none-win32.whl", hash = "sha256:a7e19150d215c7a13f39eb787d84db274298d3f83d85463e61d277bbd7f401d2"}, + {file = "orjson-3.10.7-cp313-none-win_amd64.whl", hash = "sha256:eef44224729e9525d5261cc8d28d6b11cafc90e6bd0be2157bde69a52ec83024"}, + {file = "orjson-3.10.7-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6ea2b2258eff652c82652d5e0f02bd5e0463a6a52abb78e49ac288827aaa1469"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:430ee4d85841e1483d487e7b81401785a5dfd69db5de01314538f31f8fbf7ee1"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b6146e439af4c2472c56f8540d799a67a81226e11992008cb47e1267a9b3225"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:084e537806b458911137f76097e53ce7bf5806dda33ddf6aaa66a028f8d43a23"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4829cf2195838e3f93b70fd3b4292156fc5e097aac3739859ac0dcc722b27ac0"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1193b2416cbad1a769f868b1749535d5da47626ac29445803dae7cc64b3f5c98"}, + {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4e6c3da13e5a57e4b3dca2de059f243ebec705857522f188f0180ae88badd354"}, + {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c31008598424dfbe52ce8c5b47e0752dca918a4fdc4a2a32004efd9fab41d866"}, + {file = "orjson-3.10.7-cp38-none-win32.whl", hash = "sha256:7122a99831f9e7fe977dc45784d3b2edc821c172d545e6420c375e5a935f5a1c"}, + {file = "orjson-3.10.7-cp38-none-win_amd64.whl", hash = "sha256:a763bc0e58504cc803739e7df040685816145a6f3c8a589787084b54ebc9f16e"}, + {file = "orjson-3.10.7-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e76be12658a6fa376fcd331b1ea4e58f5a06fd0220653450f0d415b8fd0fbe20"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed350d6978d28b92939bfeb1a0570c523f6170efc3f0a0ef1f1df287cd4f4960"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:144888c76f8520e39bfa121b31fd637e18d4cc2f115727865fdf9fa325b10412"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09b2d92fd95ad2402188cf51573acde57eb269eddabaa60f69ea0d733e789fe9"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b24a579123fa884f3a3caadaed7b75eb5715ee2b17ab5c66ac97d29b18fe57f"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591bcfe7512353bd609875ab38050efe3d55e18934e2f18950c108334b4ff"}, + {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f4db56635b58cd1a200b0a23744ff44206ee6aa428185e2b6c4a65b3197abdcd"}, + {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0fa5886854673222618638c6df7718ea7fe2f3f2384c452c9ccedc70b4a510a5"}, + {file = "orjson-3.10.7-cp39-none-win32.whl", hash = "sha256:8272527d08450ab16eb405f47e0f4ef0e5ff5981c3d82afe0efd25dcbef2bcd2"}, + {file = "orjson-3.10.7-cp39-none-win_amd64.whl", hash = "sha256:974683d4618c0c7dbf4f69c95a979734bf183d0658611760017f6e70a145af58"}, + {file = "orjson-3.10.7.tar.gz", hash = "sha256:75ef0640403f945f3a1f9f6400686560dbfb0fb5b16589ad62cd477043c4eee3"}, ] [[package]] @@ -1570,17 +1609,17 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.23.8" +version = "0.24.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.8" files = [ - {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, - {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, + {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, + {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, ] [package.dependencies] -pytest = ">=7.0.0,<9" +pytest = ">=8.2,<9" [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] @@ -1685,62 +1724,64 @@ six = ">=1.5" [[package]] name = "pyyaml" -version = "6.0.1" +version = "6.0.2" description = "YAML parser and emitter for Python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, - {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, - {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, - {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, - {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, - {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, - {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] [[package]] @@ -1766,13 +1807,13 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" -version = "13.7.1" +version = "13.8.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = true python-versions = ">=3.7.0" files = [ - {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, - {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, + {file = "rich-13.8.0-py3-none-any.whl", hash = "sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc"}, + {file = "rich-13.8.0.tar.gz", hash = "sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4"}, ] [package.dependencies] @@ -2029,17 +2070,17 @@ files = [ [[package]] name = "tox" -version = "4.16.0" +version = "4.18.0" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" files = [ - {file = "tox-4.16.0-py3-none-any.whl", hash = "sha256:61e101061b977b46cf00093d4319438055290ad0009f84497a07bf2d2d7a06d0"}, - {file = "tox-4.16.0.tar.gz", hash = "sha256:43499656f9949edb681c0f907f86fbfee98677af9919d8b11ae5ad77cb800748"}, + {file = "tox-4.18.0-py3-none-any.whl", hash = "sha256:0a457400cf70615dc0627eb70d293e80cd95d8ce174bb40ac011011f0c03a249"}, + {file = "tox-4.18.0.tar.gz", hash = "sha256:5dfa1cab9f146becd6e351333a82f9e0ade374451630ba65ee54584624c27b58"}, ] [package.dependencies] -cachetools = ">=5.3.3" +cachetools = ">=5.4" chardet = ">=5.2" colorama = ">=0.4.6" filelock = ">=3.15.4" @@ -2051,8 +2092,8 @@ tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} virtualenv = ">=20.26.3" [package.extras] -docs = ["furo (>=2024.5.6)", "sphinx (>=7.3.7)", "sphinx-argparse-cli (>=1.16)", "sphinx-autodoc-typehints (>=2.2.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"] -testing = ["build[virtualenv] (>=1.2.1)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=9.1)", "distlib (>=0.3.8)", "flaky (>=3.8.1)", "hatch-vcs (>=0.4)", "hatchling (>=1.25)", "psutil (>=6)", "pytest (>=8.2.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-xdist (>=3.6.1)", "re-assert (>=1.1)", "setuptools (>=70.2)", "time-machine (>=2.14.2)", "wheel (>=0.43)"] +docs = ["furo (>=2024.7.18)", "sphinx (>=7.4.7)", "sphinx-argparse-cli (>=1.16)", "sphinx-autodoc-typehints (>=2.2.3)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"] +testing = ["build[virtualenv] (>=1.2.1)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=9.1.1)", "distlib (>=0.3.8)", "flaky (>=3.8.1)", "hatch-vcs (>=0.4)", "hatchling (>=1.25)", "psutil (>=6)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-xdist (>=3.6.1)", "re-assert (>=1.1)", "setuptools (>=70.3)", "time-machine (>=2.14.2)", "wheel (>=0.43)"] [[package]] name = "typing-extensions" @@ -2256,18 +2297,22 @@ multidict = ">=4.0" [[package]] name = "zipp" -version = "3.19.2" +version = "3.20.1" description = "Backport of pathlib-compatible object wrapper for zip files" optional = true python-versions = ">=3.8" files = [ - {file = "zipp-3.19.2-py3-none-any.whl", hash = "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c"}, - {file = "zipp-3.19.2.tar.gz", hash = "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19"}, + {file = "zipp-3.20.1-py3-none-any.whl", hash = "sha256:9960cd8967c8f85a56f920d5d507274e74f9ff813a0ab8889a5b5be2daf44064"}, + {file = "zipp-3.20.1.tar.gz", hash = "sha256:c22b14cc4763c5a5b04134207736c107db42e9d3ef2d9779d465f5f1bcba572b"}, ] [package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] [extras] docs = ["docutils", "myst-parser", "sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput"] diff --git a/pyproject.toml b/pyproject.toml index a08202e56..09cdfc349 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.1" +version = "0.7.2" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From b0d0c4b70337aa440fd2d29a828664dca277fdea Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 6 Sep 2024 14:46:44 +0200 Subject: [PATCH 563/892] Add KH100 EU fixtures (#1109) --- SUPPORTED.md | 2 + .../fixtures/smart/KH100(EU)_1.0_1.2.3.json | 255 +++++++++++ .../fixtures/smart/KH100(EU)_1.0_1.5.12.json | 429 ++++++++++++++++++ 3 files changed, 686 insertions(+) create mode 100644 kasa/tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json create mode 100644 kasa/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 5e6e8553f..2c4769af0 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -142,6 +142,8 @@ Some newer Kasa devices require authentication. These are marked with *\* + - Hardware: 1.0 (EU) / Firmware: 1.5.12\* - Hardware: 1.0 (UK) / Firmware: 1.5.6\* ### Hub-Connected Devices diff --git a/kasa/tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json b/kasa/tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json new file mode 100644 index 000000000..4ef13a07d --- /dev/null +++ b/kasa/tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json @@ -0,0 +1,255 @@ +{ + "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": "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": "child_device", + "ver_code": 1 + }, + { + "id": "child_quick_setup", + "ver_code": 1 + }, + { + "id": "child_inherit", + "ver_code": 1 + }, + { + "id": "control_child", + "ver_code": 1 + }, + { + "id": "alarm", + "ver_code": 1 + }, + { + "id": "device_load", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "alarm_logs", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KH100(EU)", + "device_type": "SMART.KASAHUB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-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_alarm_configure": { + "duration": 300, + "type": "Doorbell Ring 1", + "volume": "high" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [], + "start_index": 0, + "sum": 0 + }, + "get_child_device_list": { + "child_device_list": [], + "start_index": 0, + "sum": 0 + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.3 Build 221012 Rel.103821", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "in_alarm_source": "", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "A8-42-A1-00-00-00", + "model": "KH100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "", + "rssi": -61, + "signal_level": 2, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.KASAHUB" + }, + "get_device_load_info": { + "cur_load_num": 0, + "load_level": "light", + "max_load_num": 64, + "total_memory": 4352, + "used_memory": 250 + }, + "get_device_time": { + "region": "", + "time_diff": 0, + "timestamp": 1725109066 + }, + "get_device_usage": {}, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_support_alarm_type_list": { + "alarm_type_list": [ + "Doorbell Ring 1", + "Doorbell Ring 2", + "Doorbell Ring 3", + "Doorbell Ring 4", + "Doorbell Ring 5", + "Doorbell Ring 6", + "Doorbell Ring 7", + "Doorbell Ring 8", + "Doorbell Ring 9", + "Doorbell Ring 10", + "Phone Ring", + "Alarm 1", + "Alarm 2", + "Alarm 3", + "Alarm 4", + "Dripping Tap", + "Alarm 5", + "Connection 1", + "Connection 2" + ] + }, + "get_support_child_device_category": { + "device_category_list": [ + { + "category": "subg.trv" + }, + { + "category": "subg.trigger" + }, + { + "category": "subg.plugswitch" + } + ] + }, + "get_wireless_scan_info": { + "ap_list": [], + "start_index": 0, + "sum": 0, + "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": "KH100", + "device_type": "SMART.KASAHUB" + } + } +} diff --git a/kasa/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json b/kasa/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json new file mode 100644 index 000000000..937fe36cc --- /dev/null +++ b/kasa/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json @@ -0,0 +1,429 @@ +{ + "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": "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": "child_device", + "ver_code": 1 + }, + { + "id": "child_quick_setup", + "ver_code": 1 + }, + { + "id": "child_inherit", + "ver_code": 1 + }, + { + "id": "control_child", + "ver_code": 1 + }, + { + "id": "alarm", + "ver_code": 1 + }, + { + "id": "device_load", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "alarm_logs", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KH100(EU)", + "device_type": "SMART.KASAHUB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-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_alarm_configure": { + "duration": 300, + "type": "Doorbell Ring 1", + "volume": "high" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 900 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + } + ], + "start_index": 0, + "sum": 1 + }, + "get_child_device_list": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 75, + "bind_count": 7, + "category": "subg.trv", + "child_protection": false, + "current_temp": 24.0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "frost_protection_on": false, + "fw_ver": "2.4.0 Build 230804 Rel.193040", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -116, + "jamming_signal_level": 1, + "location": "", + "mac": "A842A1000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -13, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "target_temp": 22.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [], + "type": "SMART.KASAENERGY" + } + ], + "start_index": 0, + "sum": 1 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "kasa_hub", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.5.12 Build 240320 Rel.123648", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "in_alarm_source": "", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "A8-42-A1-00-00-00", + "model": "KH100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -60, + "signal_level": 2, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.KASAHUB" + }, + "get_device_load_info": { + "cur_load_num": 2, + "load_level": "light", + "max_load_num": 64, + "total_memory": 4352, + "used_memory": 1439 + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1725111902 + }, + "get_device_usage": {}, + "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.5.12 Build 240320 Rel.123648", + "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": 406, + "night_mode_type": "sunrise_sunset", + "start_time": 1217, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_support_alarm_type_list": { + "alarm_type_list": [ + "Doorbell Ring 1", + "Doorbell Ring 2", + "Doorbell Ring 3", + "Doorbell Ring 4", + "Doorbell Ring 5", + "Doorbell Ring 6", + "Doorbell Ring 7", + "Doorbell Ring 8", + "Doorbell Ring 9", + "Doorbell Ring 10", + "Phone Ring", + "Alarm 1", + "Alarm 2", + "Alarm 3", + "Alarm 4", + "Dripping Tap", + "Alarm 5", + "Connection 1", + "Connection 2" + ] + }, + "get_support_child_device_category": { + "device_category_list": [ + { + "category": "subg.trv" + }, + { + "category": "subg.trigger" + }, + { + "category": "subg.plugswitch" + } + ] + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 1, + "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": "KH100", + "device_type": "SMART.KASAHUB", + "is_klap": true + } + } +} From 1773f98aad7ea6b124771a42ef6a01e842ed4e93 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:27:23 +0100 Subject: [PATCH 564/892] Fix tests due to yarl URL str output change (#1112) Latest versions of yarl>=1.9.5 omit the port 80 when calling str(url) which broke tests. --- kasa/tests/test_aestransport.py | 2 +- kasa/tests/test_klapprotocol.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 16d5718a4..36e8c3d62 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -460,7 +460,7 @@ async def _post(self, url: URL, json: dict[str, Any]): elif json["method"] == "login_device": return await self._return_login_response(url, json) else: - assert str(url) == f"http://{self.host}:80/app?token={self.token}" + assert url == URL(f"http://{self.host}:80/app?token={self.token}") return await self._return_send_response(url, json) async def _return_handshake_response(self, url: URL, json: dict[str, Any]): diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 53c295cfb..24d5df5df 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -389,14 +389,14 @@ async def test_handshake( async def _return_handshake_response(url: URL, params=None, data=None, *_, **__): nonlocal client_seed, server_seed, device_auth_hash - if str(url) == "http://127.0.0.1:80/app/handshake1": + if url == URL("http://127.0.0.1:80/app/handshake1"): client_seed = data seed_auth_hash = _sha256( seed_auth_hash_calc1(client_seed, server_seed, device_auth_hash) ) return _mock_response(200, server_seed + seed_auth_hash) - elif str(url) == "http://127.0.0.1:80/app/handshake2": + elif url == URL("http://127.0.0.1:80/app/handshake2"): seed_auth_hash = _sha256( seed_auth_hash_calc2(client_seed, server_seed, device_auth_hash) ) @@ -433,14 +433,14 @@ async def test_query(mocker): async def _return_response(url: URL, params=None, data=None, *_, **__): nonlocal client_seed, server_seed, device_auth_hash, seq - if str(url) == "http://127.0.0.1:80/app/handshake1": + if url == URL("http://127.0.0.1:80/app/handshake1"): client_seed = data client_seed_auth_hash = _sha256(data + device_auth_hash) return _mock_response(200, server_seed + client_seed_auth_hash) - elif str(url) == "http://127.0.0.1:80/app/handshake2": + elif url == URL("http://127.0.0.1:80/app/handshake2"): return _mock_response(200, b"") - elif str(url) == "http://127.0.0.1:80/app/request": + elif url == URL("http://127.0.0.1:80/app/request"): encryption_session = KlapEncryptionSession( protocol._transport._encryption_session.local_seed, protocol._transport._encryption_session.remote_seed, @@ -526,7 +526,7 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): response_status, \ credentials_match - if str(url) == "http://127.0.0.1:80/app/handshake1": + if url == URL("http://127.0.0.1:80/app/handshake1"): client_seed = data client_seed_auth_hash = _sha256(data + device_auth_hash) if credentials_match is not False and credentials_match is not True: @@ -534,13 +534,13 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): return _mock_response( response_status[0], server_seed + client_seed_auth_hash ) - elif str(url) == "http://127.0.0.1:80/app/handshake2": + elif url == URL("http://127.0.0.1:80/app/handshake2"): client_seed = data client_seed_auth_hash = _sha256(data + device_auth_hash) return _mock_response( response_status[1], server_seed + client_seed_auth_hash ) - elif str(url) == "http://127.0.0.1:80/app/request": + elif url == URL("http://127.0.0.1:80/app/request"): return _mock_response(response_status[2], b"") mocker.patch.object(aiohttp.ClientSession, "post", side_effect=_return_response) From a967d5cd3a761752815bf76f6f5fe9c10d5eb485 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:48:43 +0100 Subject: [PATCH 565/892] Migrate from poetry to uv for dependency and package management (#986) --- .github/actions/setup/action.yaml | 40 +- .github/workflows/ci.yml | 31 +- .github/workflows/publish.yml | 20 +- .pre-commit-config.yaml | 13 +- README.md | 5 +- RELEASING.md | 12 +- devtools/run-in-env.sh | 21 - docs/source/contribute.md | 14 +- poetry.lock | 2325 ----------------------------- pyproject.toml | 128 +- tox.ini | 37 - uv.lock | 1795 ++++++++++++++++++++++ 12 files changed, 1934 insertions(+), 2507 deletions(-) delete mode 100755 devtools/run-in-env.sh delete mode 100644 poetry.lock delete mode 100644 tox.ini create mode 100644 uv.lock diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml index 91f101ab0..b7828ce3f 100644 --- a/.github/actions/setup/action.yaml +++ b/.github/actions/setup/action.yaml @@ -1,16 +1,18 @@ --- name: Setup Environment -description: Install requested pipx dependencies, configure the system python, and install poetry and the package dependencies +description: Install requested pipx dependencies, configure the system python, and install uv and the package dependencies inputs: - poetry-install-options: + uv-install-options: default: "" - poetry-version: - default: 1.8.2 + uv-version: + default: 0.4.5 python-version: required: true cache-pre-commit: default: false + cache-version: + default: "v0.1" runs: using: composite @@ -25,7 +27,7 @@ runs: id: pipx-env-setup # pipx default home and bin dir are not writable by the cache action # so override them here and add the bin dir to PATH for later steps. - # This also ensures the pipx cache only contains poetry + # This also ensures the pipx cache only contains uv run: | SEP="${{ !startsWith(runner.os, 'windows') && '/' || '\\' }}" PIPX_CACHE="${{ github.workspace }}${SEP}pipx_cache" @@ -42,43 +44,43 @@ runs: uses: actions/cache@v4 with: path: ${{ steps.pipx-env-setup.outputs.pipx-cache-path }} - key: ${{ runner.os }}-${{ runner.arch }}-python-${{ inputs.python-version }}-${{ steps.setup-python.outputs.python-version }}-pipx-${{ steps.pipx-env-setup.outputs.pipx-version }}-poetry-${{ inputs.poetry-version }} + key: cache-${{ inputs.cache-version }}-${{ runner.os }}-${{ runner.arch }}-python-${{ inputs.python-version }}-${{ steps.setup-python.outputs.python-version }}-pipx-${{ steps.pipx-env-setup.outputs.pipx-version }}-uv-${{ inputs.uv-version }} - - name: Install poetry + - name: Install uv if: steps.pipx-cache.outputs.cache-hit != 'true' - id: install-poetry + id: install-uv shell: bash run: |- - pipx install poetry==${{ inputs.poetry-version }} --python "${{ steps.setup-python.outputs.python-path }}" + pipx install uv==${{ inputs.uv-version }} --python "${{ steps.setup-python.outputs.python-path }}" - - name: Read poetry cache location - id: poetry-cache-location + - name: Read uv cache location + id: uv-cache-location shell: bash run: |- - echo "poetry-venv-location=$(poetry config virtualenvs.path)" >> $GITHUB_OUTPUT + echo "uv-cache-location=$(uv cache dir)" >> $GITHUB_OUTPUT - uses: actions/cache@v4 - name: Poetry cache + name: uv cache with: path: | - ${{ steps.poetry-cache-location.outputs.poetry-venv-location }} - key: ${{ runner.os }}-${{ runner.arch }}-python-${{ inputs.python-version }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }}-options-${{ inputs.poetry-install-options }} + ${{ steps.uv-cache-location.outputs.uv-cache-location }} + key: cache-${{ inputs.cache-version }}-${{ runner.os }}-${{ runner.arch }}-python-${{ steps.setup-python.outputs.python-version }}-uv-${{ inputs.uv-version }}-${{ hashFiles('uv.lock') }}-options-${{ inputs.uv-install-options }} - - name: "Poetry install" + - name: "uv install" shell: bash run: | - poetry install ${{ inputs.poetry-install-options }} + uv sync --python "${{ steps.setup-python.outputs.python-path }}" ${{ inputs.uv-install-options }} - name: Read pre-commit version if: inputs.cache-pre-commit == 'true' id: pre-commit-version shell: bash run: >- - echo "pre-commit-version=$(poetry run pre-commit -V | awk '{print $2}')" >> $GITHUB_OUTPUT + echo "pre-commit-version=$(uv run pre-commit -- -V | awk '{print $2}')" >> $GITHUB_OUTPUT - uses: actions/cache@v4 if: inputs.cache-pre-commit == 'true' name: Pre-commit cache with: path: ~/.cache/pre-commit/ - key: ${{ runner.os }}-${{ runner.arch }}-pre-commit-${{ steps.pre-commit-version.outputs.pre-commit-version }}-python-${{ inputs.python-version }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} + key: cache-${{ inputs.cache-version }}-${{ runner.os }}-${{ runner.arch }}-pre-commit-${{ steps.pre-commit-version.outputs.pre-commit-version }}-python-${{ inputs.python-version }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc1dbf8ed..84c4905b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: # to allow manual re-runs env: - POETRY_VERSION: 1.8.2 + UV_VERSION: 0.4.5 jobs: linting: @@ -26,32 +26,33 @@ jobs: with: python-version: ${{ matrix.python-version }} cache-pre-commit: true - poetry-version: ${{ env.POETRY_VERSION }} - poetry-install-options: "--all-extras" + uv-version: ${{ env.UV_VERSION }} + uv-install-options: "--all-extras" - name: "Check supported device md files are up to date" run: | - poetry run pre-commit run generate-supported --all-files + uv run pre-commit run generate-supported --all-files - name: "Linting and code formating (ruff)" run: | - poetry run pre-commit run ruff --all-files + uv run pre-commit run ruff --all-files - name: "Typing checks (mypy)" run: | - poetry run pre-commit run mypy --all-files + source .venv/bin/activate + pre-commit run mypy --all-files - name: "Run trailing-whitespace" run: | - poetry run pre-commit run trailing-whitespace --all-files + uv run pre-commit run trailing-whitespace --all-files - name: "Run end-of-file-fixer" run: | - poetry run pre-commit run end-of-file-fixer --all-files + uv run pre-commit run end-of-file-fixer --all-files - name: "Run check-docstring-first" run: | - poetry run pre-commit run check-docstring-first --all-files + uv run pre-commit run check-docstring-first --all-files - name: "Run debug-statements" run: | - poetry run pre-commit run debug-statements --all-files + uv run pre-commit run debug-statements --all-files - name: "Run check-ast" run: | - poetry run pre-commit run check-ast --all-files + uv run pre-commit run check-ast --all-files tests: @@ -89,16 +90,16 @@ jobs: uses: ./.github/actions/setup with: python-version: ${{ matrix.python-version }} - poetry-version: ${{ env.POETRY_VERSION }} - poetry-install-options: ${{ matrix.extras == true && '--all-extras' || '' }} + uv-version: ${{ env.UV_VERSION }} + uv-install-options: ${{ matrix.extras == true && '--all-extras' || '' }} - name: "Run tests (no coverage)" if: ${{ startsWith(matrix.python-version, 'pypy') }} run: | - poetry run pytest + uv run pytest - name: "Run tests (with coverage)" if: ${{ !startsWith(matrix.python-version, 'pypy') }} run: | - poetry run pytest --cov kasa --cov-report xml + uv run pytest --cov kasa --cov-report xml - name: "Upload coverage to Codecov" if: ${{ !startsWith(matrix.python-version, 'pypy') }} uses: "codecov/codecov-action@v4" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e48066bb3..71b5ef0f9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,6 +3,9 @@ on: release: types: [published] +env: + UV_VERSION: 0.4.5 + jobs: build-n-publish: name: Build release packages @@ -17,19 +20,10 @@ jobs: with: python-version: "3.x" - - name: Install pypa/build - run: >- - python -m - pip install - build - --user + - name: Install uv + run: |- + pipx install uv==${{ env.UV_VERSION }} --python "${{ steps.setup-python.outputs.python-path }}" - name: Build a binary wheel and a source tarball - run: >- - python -m - build - --sdist - --wheel - --outdir dist/ - . + run: uv build - name: Publish release on pypi uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c3acdb8db..b6ab6c28f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,12 @@ repos: + +- repo: https://github.com/astral-sh/uv-pre-commit + # uv version. + rev: 0.4.5 + hooks: + # Update the uv lockfile + - id: uv-lock + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: @@ -28,7 +36,7 @@ repos: # for more accurate checking than using the pre-commit mypy mirror - id: mypy name: mypy - entry: devtools/run-in-env.sh mypy + entry: uv run mypy language: system types_or: [python, pyi] require_serial: true @@ -39,8 +47,7 @@ repos: - id: generate-supported name: Generate supported devices description: This hook generates the supported device sections of README.md and SUPPORTED.md - entry: devtools/run-in-env.sh ./devtools/generate_supported.py + entry: uv run ./devtools/generate_supported.py language: system # Required or pre-commit creates a new venv - verbose: true # Show output on success types: [json] pass_filenames: false # passing filenames causes the hook to run in batches against all-files diff --git a/README.md b/README.md index 2533b908e..d9a1ac813 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,12 @@ You can install the most recent release using pip: pip install python-kasa ``` -Alternatively, you can clone this repository and use poetry to install the development version: +Alternatively, you can clone this repository and use `uv` to install the development version: ``` git clone https://github.com/python-kasa/python-kasa.git cd python-kasa/ -poetry install +uv sync --all-extras +uv run kasa ``` If you have not yet provisioned your device, [you can do so using the cli tool](https://python-kasa.readthedocs.io/en/latest/cli.html#provisioning). diff --git a/RELEASING.md b/RELEASING.md index 6c1b5a7ff..315ea5cf9 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -33,21 +33,21 @@ git checkout -b release/$NEW_RELEASE ### Update the version number ```bash -poetry version $NEW_RELEASE +sed -i "0,/version = /{s/version = .*/version = \"${NEW_RELEASE}\"/}" pyproject.toml ``` ### Update dependencies ```bash -poetry install --all-extras --sync -poetry update +uv sync --all-extras +uv lock --upgrade ``` ### Run pre-commit and tests ```bash -pre-commit run --all-files -pytest kasa +uv run pre-commit run --all-files +uv run pytest ``` ### Create release summary (skip for dev releases) @@ -215,7 +215,7 @@ git cherry-pick commitSHA2 -S ### Update the version number ```bash -poetry version $NEW_RELEASE +sed -i "0,/version = /{s/version = .*/version = \"${NEW_RELEASE}\"/}" pyproject.toml ``` ### Manually edit the changelog diff --git a/devtools/run-in-env.sh b/devtools/run-in-env.sh deleted file mode 100755 index 5efdbc65d..000000000 --- a/devtools/run-in-env.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash - -# pre-commit by default runs hooks in an isolated environment. -# For some hooks it's needed to run in the virtual environment so this script will activate it. - -OS_KERNEL=$(uname -s) -OS_VER=$(uname -v) -if [[ ( $OS_KERNEL == "Linux" && $OS_VER == *"Microsoft"* ) ]]; then - echo "Pre-commit hook needs git-bash to run. It cannot run in the windows linux subsystem." - echo "Add git bin directory to the front of your path variable, e.g:" - echo "set PATH=C:\Program Files\Git\bin;%PATH% (for CMD prompt)" - echo "\$env:Path = 'C:\Program Files\Git\bin;' + \$env:Path (for Powershell prompt)" - exit 1 -fi -if [[ "$(expr substr $OS_KERNEL 1 10)" == "MINGW64_NT" ]]; then - POETRY_PATH=$(poetry.exe env info --path) - source "$POETRY_PATH"\\Scripts\\activate -else - source $(poetry env info --path)/bin/activate -fi -exec "$@" diff --git a/docs/source/contribute.md b/docs/source/contribute.md index 67291eba1..4b40c6468 100644 --- a/docs/source/contribute.md +++ b/docs/source/contribute.md @@ -11,13 +11,13 @@ This page aims to help you to get started. ## Setting up the development environment To get started, simply clone this repository and initialize the development environment. -We are using [poetry](https://python-poetry.org) for dependency management, so after cloning the repository simply execute -`poetry install` which will install all necessary packages and create a virtual environment for you. +We are using [uv](https://github.com/astral-sh/uv) for dependency management, so after cloning the repository simply execute +`uv sync` which will install all necessary packages and create a virtual environment for you in `.venv`. ``` $ git clone https://github.com/python-kasa/python-kasa.git $ cd python-kasa -$ poetry install +$ uv sync --all-extras ``` ## Code-style checks @@ -36,7 +36,7 @@ You can also execute the pre-commit hooks on all files by executing `pre-commit You can run tests on the library by executing `pytest` in the source directory: ``` -$ poetry run pytest kasa +$ uv run pytest kasa ``` This will run the tests against the contributed example responses. @@ -68,8 +68,8 @@ The easiest way to do that is by doing: ``` $ git clone https://github.com/python-kasa/python-kasa.git $ cd python-kasa -$ poetry install -$ poetry shell +$ uv sync --all-extras +$ source .venv/bin/activate $ python -m devtools.dump_devinfo --username --password --host 192.168.1.123 ``` @@ -82,5 +82,5 @@ If you choose to do so, it will save the fixture files directly in their correct ```{note} When adding new fixture files, you should run `pre-commit run -a` to re-generate the list of supported devices. -You may need to adjust `device_fixtures.py` to add a new model into the correct device categories. Verify that test pass by executing `poetry run pytest kasa`. +You may need to adjust `device_fixtures.py` to add a new model into the correct device categories. Verify that test pass by executing `uv run pytest kasa`. ``` diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 035682b2d..000000000 --- a/poetry.lock +++ /dev/null @@ -1,2325 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. - -[[package]] -name = "aiohappyeyeballs" -version = "2.4.0" -description = "Happy Eyeballs for asyncio" -optional = false -python-versions = ">=3.8" -files = [ - {file = "aiohappyeyeballs-2.4.0-py3-none-any.whl", hash = "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd"}, - {file = "aiohappyeyeballs-2.4.0.tar.gz", hash = "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2"}, -] - -[[package]] -name = "aiohttp" -version = "3.10.5" -description = "Async http client/server framework (asyncio)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:18a01eba2574fb9edd5f6e5fb25f66e6ce061da5dab5db75e13fe1558142e0a3"}, - {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94fac7c6e77ccb1ca91e9eb4cb0ac0270b9fb9b289738654120ba8cebb1189c6"}, - {file = "aiohttp-3.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2f1f1c75c395991ce9c94d3e4aa96e5c59c8356a15b1c9231e783865e2772699"}, - {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f7acae3cf1a2a2361ec4c8e787eaaa86a94171d2417aae53c0cca6ca3118ff6"}, - {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94c4381ffba9cc508b37d2e536b418d5ea9cfdc2848b9a7fea6aebad4ec6aac1"}, - {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c31ad0c0c507894e3eaa843415841995bf8de4d6b2d24c6e33099f4bc9fc0d4f"}, - {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0912b8a8fadeb32ff67a3ed44249448c20148397c1ed905d5dac185b4ca547bb"}, - {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d93400c18596b7dc4794d48a63fb361b01a0d8eb39f28800dc900c8fbdaca91"}, - {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3c5e0d764a5c9aa5a62d99728c56d455310bcc288a79cab10157b3af426f"}, - {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d742c36ed44f2798c8d3f4bc511f479b9ceef2b93f348671184139e7d708042c"}, - {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:814375093edae5f1cb31e3407997cf3eacefb9010f96df10d64829362ae2df69"}, - {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8224f98be68a84b19f48e0bdc14224b5a71339aff3a27df69989fa47d01296f3"}, - {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9a487ef090aea982d748b1b0d74fe7c3950b109df967630a20584f9a99c0683"}, - {file = "aiohttp-3.10.5-cp310-cp310-win32.whl", hash = "sha256:d9ef084e3dc690ad50137cc05831c52b6ca428096e6deb3c43e95827f531d5ef"}, - {file = "aiohttp-3.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:66bf9234e08fe561dccd62083bf67400bdbf1c67ba9efdc3dac03650e97c6088"}, - {file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8c6a4e5e40156d72a40241a25cc226051c0a8d816610097a8e8f517aeacd59a2"}, - {file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c634a3207a5445be65536d38c13791904fda0748b9eabf908d3fe86a52941cf"}, - {file = "aiohttp-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4aff049b5e629ef9b3e9e617fa6e2dfeda1bf87e01bcfecaf3949af9e210105e"}, - {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1942244f00baaacaa8155eca94dbd9e8cc7017deb69b75ef67c78e89fdad3c77"}, - {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04a1f2a65ad2f93aa20f9ff9f1b672bf912413e5547f60749fa2ef8a644e061"}, - {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f2bfc0032a00405d4af2ba27f3c429e851d04fad1e5ceee4080a1c570476697"}, - {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:424ae21498790e12eb759040bbb504e5e280cab64693d14775c54269fd1d2bb7"}, - {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:975218eee0e6d24eb336d0328c768ebc5d617609affaca5dbbd6dd1984f16ed0"}, - {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4120d7fefa1e2d8fb6f650b11489710091788de554e2b6f8347c7a20ceb003f5"}, - {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b90078989ef3fc45cf9221d3859acd1108af7560c52397ff4ace8ad7052a132e"}, - {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ba5a8b74c2a8af7d862399cdedce1533642fa727def0b8c3e3e02fcb52dca1b1"}, - {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:02594361128f780eecc2a29939d9dfc870e17b45178a867bf61a11b2a4367277"}, - {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4fc029e135859f533025bc82047334e24b0d489e75513144f25408ecaf058"}, - {file = "aiohttp-3.10.5-cp311-cp311-win32.whl", hash = "sha256:e1ca1ef5ba129718a8fc827b0867f6aa4e893c56eb00003b7367f8a733a9b072"}, - {file = "aiohttp-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:349ef8a73a7c5665cca65c88ab24abe75447e28aa3bc4c93ea5093474dfdf0ff"}, - {file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:305be5ff2081fa1d283a76113b8df7a14c10d75602a38d9f012935df20731487"}, - {file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3a1c32a19ee6bbde02f1cb189e13a71b321256cc1d431196a9f824050b160d5a"}, - {file = "aiohttp-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61645818edd40cc6f455b851277a21bf420ce347baa0b86eaa41d51ef58ba23d"}, - {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c225286f2b13bab5987425558baa5cbdb2bc925b2998038fa028245ef421e75"}, - {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ba01ebc6175e1e6b7275c907a3a36be48a2d487549b656aa90c8a910d9f3178"}, - {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8eaf44ccbc4e35762683078b72bf293f476561d8b68ec8a64f98cf32811c323e"}, - {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1c43eb1ab7cbf411b8e387dc169acb31f0ca0d8c09ba63f9eac67829585b44f"}, - {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de7a5299827253023c55ea549444e058c0eb496931fa05d693b95140a947cb73"}, - {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4790f0e15f00058f7599dab2b206d3049d7ac464dc2e5eae0e93fa18aee9e7bf"}, - {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:44b324a6b8376a23e6ba25d368726ee3bc281e6ab306db80b5819999c737d820"}, - {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0d277cfb304118079e7044aad0b76685d30ecb86f83a0711fc5fb257ffe832ca"}, - {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:54d9ddea424cd19d3ff6128601a4a4d23d54a421f9b4c0fff740505813739a91"}, - {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f1c9866ccf48a6df2b06823e6ae80573529f2af3a0992ec4fe75b1a510df8a6"}, - {file = "aiohttp-3.10.5-cp312-cp312-win32.whl", hash = "sha256:dc4826823121783dccc0871e3f405417ac116055bf184ac04c36f98b75aacd12"}, - {file = "aiohttp-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:22c0a23a3b3138a6bf76fc553789cb1a703836da86b0f306b6f0dc1617398abc"}, - {file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7f6b639c36734eaa80a6c152a238242bedcee9b953f23bb887e9102976343092"}, - {file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29930bc2921cef955ba39a3ff87d2c4398a0394ae217f41cb02d5c26c8b1b77"}, - {file = "aiohttp-3.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f489a2c9e6455d87eabf907ac0b7d230a9786be43fbe884ad184ddf9e9c1e385"}, - {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:123dd5b16b75b2962d0fff566effb7a065e33cd4538c1692fb31c3bda2bfb972"}, - {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b98e698dc34966e5976e10bbca6d26d6724e6bdea853c7c10162a3235aba6e16"}, - {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3b9162bab7e42f21243effc822652dc5bb5e8ff42a4eb62fe7782bcbcdfacf6"}, - {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1923a5c44061bffd5eebeef58cecf68096e35003907d8201a4d0d6f6e387ccaa"}, - {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d55f011da0a843c3d3df2c2cf4e537b8070a419f891c930245f05d329c4b0689"}, - {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:afe16a84498441d05e9189a15900640a2d2b5e76cf4efe8cbb088ab4f112ee57"}, - {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f8112fb501b1e0567a1251a2fd0747baae60a4ab325a871e975b7bb67e59221f"}, - {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1e72589da4c90337837fdfe2026ae1952c0f4a6e793adbbfbdd40efed7c63599"}, - {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4d46c7b4173415d8e583045fbc4daa48b40e31b19ce595b8d92cf639396c15d5"}, - {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33e6bc4bab477c772a541f76cd91e11ccb6d2efa2b8d7d7883591dfb523e5987"}, - {file = "aiohttp-3.10.5-cp313-cp313-win32.whl", hash = "sha256:c58c6837a2c2a7cf3133983e64173aec11f9c2cd8e87ec2fdc16ce727bcf1a04"}, - {file = "aiohttp-3.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:38172a70005252b6893088c0f5e8a47d173df7cc2b2bd88650957eb84fcf5022"}, - {file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f6f18898ace4bcd2d41a122916475344a87f1dfdec626ecde9ee802a711bc569"}, - {file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5ede29d91a40ba22ac1b922ef510aab871652f6c88ef60b9dcdf773c6d32ad7a"}, - {file = "aiohttp-3.10.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:673f988370f5954df96cc31fd99c7312a3af0a97f09e407399f61583f30da9bc"}, - {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58718e181c56a3c02d25b09d4115eb02aafe1a732ce5714ab70326d9776457c3"}, - {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b38b1570242fbab8d86a84128fb5b5234a2f70c2e32f3070143a6d94bc854cf"}, - {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:074d1bff0163e107e97bd48cad9f928fa5a3eb4b9d33366137ffce08a63e37fe"}, - {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd31f176429cecbc1ba499d4aba31aaccfea488f418d60376b911269d3b883c5"}, - {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7384d0b87d4635ec38db9263e6a3f1eb609e2e06087f0aa7f63b76833737b471"}, - {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8989f46f3d7ef79585e98fa991e6ded55d2f48ae56d2c9fa5e491a6e4effb589"}, - {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:c83f7a107abb89a227d6c454c613e7606c12a42b9a4ca9c5d7dad25d47c776ae"}, - {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cde98f323d6bf161041e7627a5fd763f9fd829bcfcd089804a5fdce7bb6e1b7d"}, - {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:676f94c5480d8eefd97c0c7e3953315e4d8c2b71f3b49539beb2aa676c58272f"}, - {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2d21ac12dc943c68135ff858c3a989f2194a709e6e10b4c8977d7fcd67dfd511"}, - {file = "aiohttp-3.10.5-cp38-cp38-win32.whl", hash = "sha256:17e997105bd1a260850272bfb50e2a328e029c941c2708170d9d978d5a30ad9a"}, - {file = "aiohttp-3.10.5-cp38-cp38-win_amd64.whl", hash = "sha256:1c19de68896747a2aa6257ae4cf6ef59d73917a36a35ee9d0a6f48cff0f94db8"}, - {file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7e2fe37ac654032db1f3499fe56e77190282534810e2a8e833141a021faaab0e"}, - {file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5bf3ead3cb66ab990ee2561373b009db5bc0e857549b6c9ba84b20bc462e172"}, - {file = "aiohttp-3.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b2c16a919d936ca87a3c5f0e43af12a89a3ce7ccbce59a2d6784caba945b68b"}, - {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad146dae5977c4dd435eb31373b3fe9b0b1bf26858c6fc452bf6af394067e10b"}, - {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c5c6fa16412b35999320f5c9690c0f554392dc222c04e559217e0f9ae244b92"}, - {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95c4dc6f61d610bc0ee1edc6f29d993f10febfe5b76bb470b486d90bbece6b22"}, - {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da452c2c322e9ce0cfef392e469a26d63d42860f829026a63374fde6b5c5876f"}, - {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:898715cf566ec2869d5cb4d5fb4be408964704c46c96b4be267442d265390f32"}, - {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:391cc3a9c1527e424c6865e087897e766a917f15dddb360174a70467572ac6ce"}, - {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:380f926b51b92d02a34119d072f178d80bbda334d1a7e10fa22d467a66e494db"}, - {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce91db90dbf37bb6fa0997f26574107e1b9d5ff939315247b7e615baa8ec313b"}, - {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9093a81e18c45227eebe4c16124ebf3e0d893830c6aca7cc310bfca8fe59d857"}, - {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ee40b40aa753d844162dcc80d0fe256b87cba48ca0054f64e68000453caead11"}, - {file = "aiohttp-3.10.5-cp39-cp39-win32.whl", hash = "sha256:03f2645adbe17f274444953bdea69f8327e9d278d961d85657cb0d06864814c1"}, - {file = "aiohttp-3.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:d17920f18e6ee090bdd3d0bfffd769d9f2cb4c8ffde3eb203777a3895c128862"}, - {file = "aiohttp-3.10.5.tar.gz", hash = "sha256:f071854b47d39591ce9a17981c46790acb30518e2f83dfca8db2dfa091178691"}, -] - -[package.dependencies] -aiohappyeyeballs = ">=2.3.0" -aiosignal = ">=1.1.2" -async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} -attrs = ">=17.3.0" -frozenlist = ">=1.1.1" -multidict = ">=4.5,<7.0" -yarl = ">=1.0,<2.0" - -[package.extras] -speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] - -[[package]] -name = "aiosignal" -version = "1.3.1" -description = "aiosignal: a list of registered asynchronous callbacks" -optional = false -python-versions = ">=3.7" -files = [ - {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, - {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, -] - -[package.dependencies] -frozenlist = ">=1.1.0" - -[[package]] -name = "alabaster" -version = "0.7.16" -description = "A light, configurable Sphinx theme" -optional = true -python-versions = ">=3.9" -files = [ - {file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"}, - {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "anyio" -version = "4.4.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -optional = false -python-versions = ">=3.8" -files = [ - {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, - {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, -] - -[package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} -idna = ">=2.8" -sniffio = ">=1.1" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} - -[package.extras] -doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] -trio = ["trio (>=0.23)"] - -[[package]] -name = "appdirs" -version = "1.4.4" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -optional = true -python-versions = "*" -files = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, -] - -[[package]] -name = "async-timeout" -version = "4.0.3" -description = "Timeout context manager for asyncio programs" -optional = false -python-versions = ">=3.7" -files = [ - {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, - {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, -] - -[[package]] -name = "asyncclick" -version = "8.1.7.2" -description = "Composable command line interface toolkit, async version" -optional = false -python-versions = ">=3.7" -files = [ - {file = "asyncclick-8.1.7.2-py3-none-any.whl", hash = "sha256:1ab940b04b22cb89b5b400725132b069d01b0c3472a9702c7a2c9d5d007ded02"}, - {file = "asyncclick-8.1.7.2.tar.gz", hash = "sha256:219ea0f29ccdc1bb4ff43bcab7ce0769ac6d48a04f997b43ec6bee99a222daa0"}, -] - -[package.dependencies] -anyio = "*" -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "attrs" -version = "24.2.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.7" -files = [ - {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, - {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, -] - -[package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] - -[[package]] -name = "babel" -version = "2.16.0" -description = "Internationalization utilities" -optional = true -python-versions = ">=3.8" -files = [ - {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, - {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, -] - -[package.extras] -dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] - -[[package]] -name = "cachetools" -version = "5.5.0" -description = "Extensible memoizing collections and decorators" -optional = false -python-versions = ">=3.7" -files = [ - {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, - {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, -] - -[[package]] -name = "certifi" -version = "2024.8.30" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -files = [ - {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, - {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, -] - -[[package]] -name = "cffi" -version = "1.17.0" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.8" -files = [ - {file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"}, - {file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"}, - {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"}, - {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"}, - {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"}, - {file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"}, - {file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"}, - {file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"}, - {file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"}, - {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"}, - {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"}, - {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"}, - {file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"}, - {file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"}, - {file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"}, - {file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"}, - {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"}, - {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"}, - {file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"}, - {file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"}, - {file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"}, - {file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"}, - {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"}, - {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"}, - {file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"}, - {file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"}, - {file = "cffi-1.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c"}, - {file = "cffi-1.17.0-cp38-cp38-win32.whl", hash = "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499"}, - {file = "cffi-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c"}, - {file = "cffi-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2"}, - {file = "cffi-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932"}, - {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693"}, - {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3"}, - {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4"}, - {file = "cffi-1.17.0-cp39-cp39-win32.whl", hash = "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb"}, - {file = "cffi-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29"}, - {file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"}, -] - -[package.dependencies] -pycparser = "*" - -[[package]] -name = "cfgv" -version = "3.4.0" -description = "Validate configuration and produce human readable error messages." -optional = false -python-versions = ">=3.8" -files = [ - {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, - {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, -] - -[[package]] -name = "chardet" -version = "5.2.0" -description = "Universal encoding detector for Python 3" -optional = false -python-versions = ">=3.7" -files = [ - {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, - {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.3.2" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, -] - -[[package]] -name = "codecov" -version = "2.1.13" -description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5"}, - {file = "codecov-2.1.13.tar.gz", hash = "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c"}, -] - -[package.dependencies] -coverage = "*" -requests = ">=2.7.9" - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "coverage" -version = "7.6.1" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, - {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, - {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, - {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, - {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, - {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, - {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, - {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, - {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, - {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, - {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, - {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, - {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, - {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, - {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, - {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, - {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, - {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, - {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, - {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, - {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, - {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, - {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, - {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, - {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, - {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, - {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, - {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, - {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, - {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, -] - -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - -[package.extras] -toml = ["tomli"] - -[[package]] -name = "cryptography" -version = "43.0.0" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = false -python-versions = ">=3.7" -files = [ - {file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"}, - {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"}, - {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"}, - {file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"}, - {file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"}, - {file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"}, - {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"}, - {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"}, - {file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"}, - {file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"}, - {file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"}, -] - -[package.dependencies] -cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] -docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] -nox = ["nox"] -pep8test = ["check-sdist", "click", "mypy", "ruff"] -sdist = ["build"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] -test-randomorder = ["pytest-randomly"] - -[[package]] -name = "distlib" -version = "0.3.8" -description = "Distribution utilities" -optional = false -python-versions = "*" -files = [ - {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, - {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, -] - -[[package]] -name = "docutils" -version = "0.19" -description = "Docutils -- Python Documentation Utilities" -optional = true -python-versions = ">=3.7" -files = [ - {file = "docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc"}, - {file = "docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "filelock" -version = "3.15.4" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.8" -files = [ - {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, - {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, -] - -[package.extras] -docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] -typing = ["typing-extensions (>=4.8)"] - -[[package]] -name = "freezegun" -version = "1.5.1" -description = "Let your Python tests travel through time" -optional = false -python-versions = ">=3.7" -files = [ - {file = "freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1"}, - {file = "freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9"}, -] - -[package.dependencies] -python-dateutil = ">=2.7" - -[[package]] -name = "frozenlist" -version = "1.4.1" -description = "A list-like structure which implements collections.abc.MutableSequence" -optional = false -python-versions = ">=3.8" -files = [ - {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, - {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, - {file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"}, - {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"}, - {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"}, - {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"}, - {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"}, - {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"}, - {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"}, - {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"}, - {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"}, - {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"}, - {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"}, - {file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"}, - {file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"}, - {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"}, - {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"}, - {file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"}, - {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"}, - {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"}, - {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"}, - {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"}, - {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"}, - {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"}, - {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"}, - {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"}, - {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"}, - {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"}, - {file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"}, - {file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"}, - {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"}, - {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"}, - {file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"}, - {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"}, - {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"}, - {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"}, - {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"}, - {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"}, - {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"}, - {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"}, - {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"}, - {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"}, - {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"}, - {file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"}, - {file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"}, - {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"}, - {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"}, - {file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"}, - {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"}, - {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"}, - {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"}, - {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"}, - {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"}, - {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"}, - {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"}, - {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"}, - {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"}, - {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"}, - {file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"}, - {file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"}, - {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"}, - {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"}, - {file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"}, - {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"}, - {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"}, - {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"}, - {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"}, - {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"}, - {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"}, - {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"}, - {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"}, - {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"}, - {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"}, - {file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"}, - {file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"}, - {file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"}, - {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, -] - -[[package]] -name = "identify" -version = "2.6.0" -description = "File identification library for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"}, - {file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"}, -] - -[package.extras] -license = ["ukkonen"] - -[[package]] -name = "idna" -version = "3.8" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -files = [ - {file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"}, - {file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"}, -] - -[[package]] -name = "imagesize" -version = "1.4.1" -description = "Getting image size from png/jpeg/jpeg2000/gif file" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, - {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, -] - -[[package]] -name = "importlib-metadata" -version = "8.4.0" -description = "Read metadata from Python packages" -optional = true -python-versions = ">=3.8" -files = [ - {file = "importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1"}, - {file = "importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5"}, -] - -[package.dependencies] -zipp = ">=0.5" - -[package.extras] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "jedi" -version = "0.19.1" -description = "An autocompletion tool for Python that can be used for text editors." -optional = true -python-versions = ">=3.6" -files = [ - {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, - {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, -] - -[package.dependencies] -parso = ">=0.8.3,<0.9.0" - -[package.extras] -docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] -qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] -testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] - -[[package]] -name = "jinja2" -version = "3.1.4" -description = "A very fast and expressive template engine." -optional = true -python-versions = ">=3.7" -files = [ - {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, - {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "kasa-crypt" -version = "0.4.4" -description = "Fast kasa crypt" -optional = true -python-versions = "<4.0,>=3.7" -files = [ - {file = "kasa_crypt-0.4.4-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:c2791be3a7ac64d0de0c4d0ecf85d33fd8aa5bcfce3148ce4558703e721ca16b"}, - {file = "kasa_crypt-0.4.4-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:2da1d08151690ab6ade7a80168238964eb7672ddd3defb5188c713411b210a6a"}, - {file = "kasa_crypt-0.4.4-cp310-cp310-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c8db609ec73173c48519f860b2455b311a098b7203573fb8ae0ab52862d603d"}, - {file = "kasa_crypt-0.4.4-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:599b3eed3cadc79dda4e826f96740ddee1f6fcdd4b52a6a922395afad6154fb7"}, - {file = "kasa_crypt-0.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ca1caa741be2e67fd4c84098ecd8d8c2ce1c19330e737435edaef541b867d34a"}, - {file = "kasa_crypt-0.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d027d808e22dc944a23f4f1211fc0fe25e648498ff3817b9d78444bc75cc8d45"}, - {file = "kasa_crypt-0.4.4-cp310-cp310-win32.whl", hash = "sha256:28918bb02bd4a87aab3baefe686cc249c9f97f3408dc8e881d120701851d837c"}, - {file = "kasa_crypt-0.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:c442a7db3fd3ff9ad75e6b25ca9a970af800d7968f7187da67207eab136b7f12"}, - {file = "kasa_crypt-0.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:04fad5f981e734ab1b269922a1175bc506d5498681778b3d61561422619d6e6d"}, - {file = "kasa_crypt-0.4.4-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:a54040539fe8293a7dd20fcf5e613ba4bdcafe15a8d9eeff1cc2805500a0c2d9"}, - {file = "kasa_crypt-0.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a0a0981255225fd5671ffed85f2bfc68b0ac8525b5d424a703aaa1d0f8f4cc2"}, - {file = "kasa_crypt-0.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fa2bcbf7c4bb2af4a86c553fb8df47466c06f5060d5c21253a4ecd9ee2237ef4"}, - {file = "kasa_crypt-0.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:99518489cb93d93c6c2e5ac4e30ad6838bb64c8365e8c3a37204e7f4228805ca"}, - {file = "kasa_crypt-0.4.4-cp311-cp311-win32.whl", hash = "sha256:431223a614f868a253786da7b137a8597c8ce83ed71a8bc10ffe9e56f7a8ba4d"}, - {file = "kasa_crypt-0.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:c3d60a642985c3c7c9b598e19da537566803d2f78a42d0be5a7231d717239f11"}, - {file = "kasa_crypt-0.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:038a16270b15d9a9845ad4ba66f76cbf05109855e40afb6a62d7b99e73ba55a3"}, - {file = "kasa_crypt-0.4.4-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:5cc150ef1bd2a330903557f806e7b671fe59f15fd37337f69ea0d7872cbffdde"}, - {file = "kasa_crypt-0.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c45838d4b361f76615be72ee9b238681c47330f09cc3b0eb830095b063a262c2"}, - {file = "kasa_crypt-0.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:138479985246ebc6be5d9bb896e48860d72a280e068d798af93acd2a210031c1"}, - {file = "kasa_crypt-0.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:806dd2f7a8c6d2242513a78c144a63664817b3f0b6e149166b87db9a6017d742"}, - {file = "kasa_crypt-0.4.4-cp312-cp312-win32.whl", hash = "sha256:791900085be025dbf7052f1e44c176e957556b1d04b6da4a602fc4ddc23f87b0"}, - {file = "kasa_crypt-0.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:9c7d136bfcd74ac30ed5c10cb96c46a4e2eb90bd52974a0dbbc9c6d3e90d7699"}, - {file = "kasa_crypt-0.4.4-pp310-pypy310_pp73-macosx_14_0_arm64.whl", hash = "sha256:b47ecee24bc17bb80ed8c24d8b008d92610a3500c56368b062627ff114688262"}, - {file = "kasa_crypt-0.4.4-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:bd85d206856f866e117186247d161550bf3d5309d1cf07a2e7a3e5785660dd60"}, - {file = "kasa_crypt-0.4.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc37f7302943b5ab0562084df01ec39422e5cd13ba420cbb35895a4bb19ccbb"}, - {file = "kasa_crypt-0.4.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ae739287f220e2e1b3349cf1aacd37a8abf701c97755c9bd53d6168ad41df2f1"}, - {file = "kasa_crypt-0.4.4.tar.gz", hash = "sha256:cc31749e44a309459a71802ae8471a9d5ad6a7656938a44af64b93a8c3873ccd"}, -] - -[[package]] -name = "markdown-it-py" -version = "2.2.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = true -python-versions = ">=3.7" -files = [ - {file = "markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1"}, - {file = "markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] -profiling = ["gprof2dot"] -rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "markupsafe" -version = "2.1.5" -description = "Safely add untrusted strings to HTML/XML markup." -optional = true -python-versions = ">=3.7" -files = [ - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, - {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, -] - -[[package]] -name = "mdit-py-plugins" -version = "0.3.5" -description = "Collection of plugins for markdown-it-py" -optional = true -python-versions = ">=3.7" -files = [ - {file = "mdit-py-plugins-0.3.5.tar.gz", hash = "sha256:eee0adc7195e5827e17e02d2a258a2ba159944a0748f59c5099a4a27f78fcf6a"}, - {file = "mdit_py_plugins-0.3.5-py3-none-any.whl", hash = "sha256:ca9a0714ea59a24b2b044a1831f48d817dd0c817e84339f20e7889f392d77c4e"}, -] - -[package.dependencies] -markdown-it-py = ">=1.0.0,<3.0.0" - -[package.extras] -code-style = ["pre-commit"] -rtd = ["attrs", "myst-parser (>=0.16.1,<0.17.0)", "sphinx-book-theme (>=0.1.0,<0.2.0)"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = true -python-versions = ">=3.7" -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "multidict" -version = "6.0.5" -description = "multidict implementation" -optional = false -python-versions = ">=3.7" -files = [ - {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, - {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, - {file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"}, - {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"}, - {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"}, - {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"}, - {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"}, - {file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"}, - {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"}, - {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"}, - {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"}, - {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"}, - {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"}, - {file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"}, - {file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"}, - {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"}, - {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"}, - {file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"}, - {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"}, - {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"}, - {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"}, - {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"}, - {file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"}, - {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"}, - {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"}, - {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"}, - {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"}, - {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"}, - {file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"}, - {file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"}, - {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"}, - {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"}, - {file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"}, - {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"}, - {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"}, - {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"}, - {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"}, - {file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"}, - {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"}, - {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"}, - {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"}, - {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"}, - {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"}, - {file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"}, - {file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"}, - {file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"}, - {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"}, - {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"}, - {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"}, - {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"}, - {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"}, - {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"}, - {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"}, - {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"}, - {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"}, - {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"}, - {file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"}, - {file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"}, - {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"}, - {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"}, - {file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"}, - {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"}, - {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"}, - {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"}, - {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"}, - {file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"}, - {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"}, - {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"}, - {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"}, - {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"}, - {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"}, - {file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"}, - {file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"}, - {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"}, - {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"}, - {file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"}, - {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"}, - {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"}, - {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"}, - {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"}, - {file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"}, - {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"}, - {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"}, - {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"}, - {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"}, - {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"}, - {file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"}, - {file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"}, - {file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"}, - {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, -] - -[[package]] -name = "mypy" -version = "1.11.2" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, - {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, - {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"}, - {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"}, - {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"}, - {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"}, - {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"}, - {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"}, - {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"}, - {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"}, - {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"}, - {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"}, - {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"}, - {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"}, - {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"}, - {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"}, - {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"}, - {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"}, - {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"}, - {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"}, - {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"}, - {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"}, - {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"}, - {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"}, - {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"}, - {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"}, - {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"}, -] - -[package.dependencies] -mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = ">=4.6.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.5" -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, -] - -[[package]] -name = "myst-parser" -version = "1.0.0" -description = "An extended [CommonMark](https://spec.commonmark.org/) compliant parser," -optional = true -python-versions = ">=3.7" -files = [ - {file = "myst-parser-1.0.0.tar.gz", hash = "sha256:502845659313099542bd38a2ae62f01360e7dd4b1310f025dd014dfc0439cdae"}, - {file = "myst_parser-1.0.0-py3-none-any.whl", hash = "sha256:69fb40a586c6fa68995e6521ac0a525793935db7e724ca9bac1d33be51be9a4c"}, -] - -[package.dependencies] -docutils = ">=0.15,<0.20" -jinja2 = "*" -markdown-it-py = ">=1.0.0,<3.0.0" -mdit-py-plugins = ">=0.3.4,<0.4.0" -pyyaml = "*" -sphinx = ">=5,<7" - -[package.extras] -code-style = ["pre-commit (>=3.0,<4.0)"] -linkify = ["linkify-it-py (>=1.0,<2.0)"] -rtd = ["ipython", "pydata-sphinx-theme (==v0.13.0rc4)", "sphinx-autodoc2 (>=0.4.2,<0.5.0)", "sphinx-book-theme (==1.0.0rc2)", "sphinx-copybutton", "sphinx-design2", "sphinx-pyscript", "sphinx-tippy (>=0.3.1)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.7.5,<0.8.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] -testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=7,<8)", "pytest-cov", "pytest-param-files (>=0.3.4,<0.4.0)", "pytest-regressions", "sphinx-pytest"] -testing-docutils = ["pygments", "pytest (>=7,<8)", "pytest-param-files (>=0.3.4,<0.4.0)"] - -[[package]] -name = "nodeenv" -version = "1.9.1" -description = "Node.js virtual environment builder" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, - {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, -] - -[[package]] -name = "orjson" -version = "3.10.7" -description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" -optional = true -python-versions = ">=3.8" -files = [ - {file = "orjson-3.10.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:74f4544f5a6405b90da8ea724d15ac9c36da4d72a738c64685003337401f5c12"}, - {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34a566f22c28222b08875b18b0dfbf8a947e69df21a9ed5c51a6bf91cfb944ac"}, - {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf6ba8ebc8ef5792e2337fb0419f8009729335bb400ece005606336b7fd7bab7"}, - {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac7cf6222b29fbda9e3a472b41e6a5538b48f2c8f99261eecd60aafbdb60690c"}, - {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de817e2f5fc75a9e7dd350c4b0f54617b280e26d1631811a43e7e968fa71e3e9"}, - {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:348bdd16b32556cf8d7257b17cf2bdb7ab7976af4af41ebe79f9796c218f7e91"}, - {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:479fd0844ddc3ca77e0fd99644c7fe2de8e8be1efcd57705b5c92e5186e8a250"}, - {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fdf5197a21dd660cf19dfd2a3ce79574588f8f5e2dbf21bda9ee2d2b46924d84"}, - {file = "orjson-3.10.7-cp310-none-win32.whl", hash = "sha256:d374d36726746c81a49f3ff8daa2898dccab6596864ebe43d50733275c629175"}, - {file = "orjson-3.10.7-cp310-none-win_amd64.whl", hash = "sha256:cb61938aec8b0ffb6eef484d480188a1777e67b05d58e41b435c74b9d84e0b9c"}, - {file = "orjson-3.10.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7db8539039698ddfb9a524b4dd19508256107568cdad24f3682d5773e60504a2"}, - {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:480f455222cb7a1dea35c57a67578848537d2602b46c464472c995297117fa09"}, - {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a9c9b168b3a19e37fe2778c0003359f07822c90fdff8f98d9d2a91b3144d8e0"}, - {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8de062de550f63185e4c1c54151bdddfc5625e37daf0aa1e75d2a1293e3b7d9a"}, - {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b0dd04483499d1de9c8f6203f8975caf17a6000b9c0c54630cef02e44ee624e"}, - {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b58d3795dafa334fc8fd46f7c5dc013e6ad06fd5b9a4cc98cb1456e7d3558bd6"}, - {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33cfb96c24034a878d83d1a9415799a73dc77480e6c40417e5dda0710d559ee6"}, - {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e724cebe1fadc2b23c6f7415bad5ee6239e00a69f30ee423f319c6af70e2a5c0"}, - {file = "orjson-3.10.7-cp311-none-win32.whl", hash = "sha256:82763b46053727a7168d29c772ed5c870fdae2f61aa8a25994c7984a19b1021f"}, - {file = "orjson-3.10.7-cp311-none-win_amd64.whl", hash = "sha256:eb8d384a24778abf29afb8e41d68fdd9a156cf6e5390c04cc07bbc24b89e98b5"}, - {file = "orjson-3.10.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44a96f2d4c3af51bfac6bc4ef7b182aa33f2f054fd7f34cc0ee9a320d051d41f"}, - {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ac14cd57df0572453543f8f2575e2d01ae9e790c21f57627803f5e79b0d3c3"}, - {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdbb61dcc365dd9be94e8f7df91975edc9364d6a78c8f7adb69c1cdff318ec93"}, - {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b48b3db6bb6e0a08fa8c83b47bc169623f801e5cc4f24442ab2b6617da3b5313"}, - {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23820a1563a1d386414fef15c249040042b8e5d07b40ab3fe3efbfbbcbcb8864"}, - {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0c6a008e91d10a2564edbb6ee5069a9e66df3fbe11c9a005cb411f441fd2c09"}, - {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d352ee8ac1926d6193f602cbe36b1643bbd1bbcb25e3c1a657a4390f3000c9a5"}, - {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2d9f990623f15c0ae7ac608103c33dfe1486d2ed974ac3f40b693bad1a22a7b"}, - {file = "orjson-3.10.7-cp312-none-win32.whl", hash = "sha256:7c4c17f8157bd520cdb7195f75ddbd31671997cbe10aee559c2d613592e7d7eb"}, - {file = "orjson-3.10.7-cp312-none-win_amd64.whl", hash = "sha256:1d9c0e733e02ada3ed6098a10a8ee0052dd55774de3d9110d29868d24b17faa1"}, - {file = "orjson-3.10.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:77d325ed866876c0fa6492598ec01fe30e803272a6e8b10e992288b009cbe149"}, - {file = "orjson-3.10.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ea2c232deedcb605e853ae1db2cc94f7390ac776743b699b50b071b02bea6fe"}, - {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3dcfbede6737fdbef3ce9c37af3fb6142e8e1ebc10336daa05872bfb1d87839c"}, - {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11748c135f281203f4ee695b7f80bb1358a82a63905f9f0b794769483ea854ad"}, - {file = "orjson-3.10.7-cp313-none-win32.whl", hash = "sha256:a7e19150d215c7a13f39eb787d84db274298d3f83d85463e61d277bbd7f401d2"}, - {file = "orjson-3.10.7-cp313-none-win_amd64.whl", hash = "sha256:eef44224729e9525d5261cc8d28d6b11cafc90e6bd0be2157bde69a52ec83024"}, - {file = "orjson-3.10.7-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6ea2b2258eff652c82652d5e0f02bd5e0463a6a52abb78e49ac288827aaa1469"}, - {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:430ee4d85841e1483d487e7b81401785a5dfd69db5de01314538f31f8fbf7ee1"}, - {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b6146e439af4c2472c56f8540d799a67a81226e11992008cb47e1267a9b3225"}, - {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:084e537806b458911137f76097e53ce7bf5806dda33ddf6aaa66a028f8d43a23"}, - {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4829cf2195838e3f93b70fd3b4292156fc5e097aac3739859ac0dcc722b27ac0"}, - {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1193b2416cbad1a769f868b1749535d5da47626ac29445803dae7cc64b3f5c98"}, - {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4e6c3da13e5a57e4b3dca2de059f243ebec705857522f188f0180ae88badd354"}, - {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c31008598424dfbe52ce8c5b47e0752dca918a4fdc4a2a32004efd9fab41d866"}, - {file = "orjson-3.10.7-cp38-none-win32.whl", hash = "sha256:7122a99831f9e7fe977dc45784d3b2edc821c172d545e6420c375e5a935f5a1c"}, - {file = "orjson-3.10.7-cp38-none-win_amd64.whl", hash = "sha256:a763bc0e58504cc803739e7df040685816145a6f3c8a589787084b54ebc9f16e"}, - {file = "orjson-3.10.7-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e76be12658a6fa376fcd331b1ea4e58f5a06fd0220653450f0d415b8fd0fbe20"}, - {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed350d6978d28b92939bfeb1a0570c523f6170efc3f0a0ef1f1df287cd4f4960"}, - {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:144888c76f8520e39bfa121b31fd637e18d4cc2f115727865fdf9fa325b10412"}, - {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09b2d92fd95ad2402188cf51573acde57eb269eddabaa60f69ea0d733e789fe9"}, - {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b24a579123fa884f3a3caadaed7b75eb5715ee2b17ab5c66ac97d29b18fe57f"}, - {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591bcfe7512353bd609875ab38050efe3d55e18934e2f18950c108334b4ff"}, - {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f4db56635b58cd1a200b0a23744ff44206ee6aa428185e2b6c4a65b3197abdcd"}, - {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0fa5886854673222618638c6df7718ea7fe2f3f2384c452c9ccedc70b4a510a5"}, - {file = "orjson-3.10.7-cp39-none-win32.whl", hash = "sha256:8272527d08450ab16eb405f47e0f4ef0e5ff5981c3d82afe0efd25dcbef2bcd2"}, - {file = "orjson-3.10.7-cp39-none-win_amd64.whl", hash = "sha256:974683d4618c0c7dbf4f69c95a979734bf183d0658611760017f6e70a145af58"}, - {file = "orjson-3.10.7.tar.gz", hash = "sha256:75ef0640403f945f3a1f9f6400686560dbfb0fb5b16589ad62cd477043c4eee3"}, -] - -[[package]] -name = "packaging" -version = "24.1" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, -] - -[[package]] -name = "parso" -version = "0.8.4" -description = "A Python Parser" -optional = true -python-versions = ">=3.6" -files = [ - {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, - {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, -] - -[package.extras] -qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] -testing = ["docopt", "pytest"] - -[[package]] -name = "platformdirs" -version = "4.2.2" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.8" -files = [ - {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, - {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, -] - -[package.extras] -docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] -type = ["mypy (>=1.8)"] - -[[package]] -name = "pluggy" -version = "1.5.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "pre-commit" -version = "3.8.0" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -optional = false -python-versions = ">=3.9" -files = [ - {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, - {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, -] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" - -[[package]] -name = "prompt-toolkit" -version = "3.0.47" -description = "Library for building powerful interactive command lines in Python" -optional = true -python-versions = ">=3.7.0" -files = [ - {file = "prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10"}, - {file = "prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360"}, -] - -[package.dependencies] -wcwidth = "*" - -[[package]] -name = "ptpython" -version = "3.0.29" -description = "Python REPL build on top of prompt_toolkit" -optional = true -python-versions = ">=3.7" -files = [ - {file = "ptpython-3.0.29-py2.py3-none-any.whl", hash = "sha256:65d75c4871859e4305a020c9b9e204366dceb4d08e0e2bd7b7511bd5e917a402"}, - {file = "ptpython-3.0.29.tar.gz", hash = "sha256:b9d625183aef93a673fc32cbe1c1fcaf51412e7a4f19590521cdaccadf25186e"}, -] - -[package.dependencies] -appdirs = "*" -jedi = ">=0.16.0" -prompt-toolkit = ">=3.0.43,<3.1.0" -pygments = "*" - -[package.extras] -all = ["black"] -ptipython = ["ipython"] - -[[package]] -name = "pycparser" -version = "2.22" -description = "C parser in Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, - {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, -] - -[[package]] -name = "pydantic" -version = "2.8.2" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, - {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, -] - -[package.dependencies] -annotated-types = ">=0.4.0" -pydantic-core = "2.20.1" -typing-extensions = [ - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, - {version = ">=4.6.1", markers = "python_version < \"3.13\""}, -] - -[package.extras] -email = ["email-validator (>=2.0.0)"] - -[[package]] -name = "pydantic-core" -version = "2.20.1" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, - {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, - {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, - {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, - {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, - {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, - {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, - {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, - {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, - {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, - {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, - {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, - {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, - {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, - {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, - {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, - {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, - {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, - {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, - {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, - {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, - {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, - {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, - {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, - {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, - {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, - {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, - {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, - {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, - {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, - {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, - {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, - {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, - {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, - {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, - {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, - {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" - -[[package]] -name = "pygments" -version = "2.18.0" -description = "Pygments is a syntax highlighting package written in Python." -optional = true -python-versions = ">=3.8" -files = [ - {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, - {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pyproject-api" -version = "1.7.1" -description = "API to interact with the python pyproject.toml based projects" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pyproject_api-1.7.1-py3-none-any.whl", hash = "sha256:2dc1654062c2b27733d8fd4cdda672b22fe8741ef1dde8e3a998a9547b071eeb"}, - {file = "pyproject_api-1.7.1.tar.gz", hash = "sha256:7ebc6cd10710f89f4cf2a2731710a98abce37ebff19427116ff2174c9236a827"}, -] - -[package.dependencies] -packaging = ">=24.1" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -docs = ["furo (>=2024.5.6)", "sphinx-autodoc-typehints (>=2.2.1)"] -testing = ["covdefaults (>=2.3)", "pytest (>=8.2.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "setuptools (>=70.1)"] - -[[package]] -name = "pytest" -version = "8.3.2" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, - {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=1.5,<2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-asyncio" -version = "0.24.0" -description = "Pytest support for asyncio" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, - {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, -] - -[package.dependencies] -pytest = ">=8.2,<9" - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] - -[[package]] -name = "pytest-cov" -version = "5.0.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, - {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, -] - -[package.dependencies] -coverage = {version = ">=5.2.1", extras = ["toml"]} -pytest = ">=4.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] - -[[package]] -name = "pytest-freezer" -version = "0.4.8" -description = "Pytest plugin providing a fixture interface for spulec/freezegun" -optional = false -python-versions = ">= 3.6" -files = [ - {file = "pytest_freezer-0.4.8-py3-none-any.whl", hash = "sha256:644ce7ddb8ba52b92a1df0a80a699bad2b93514c55cf92e9f2517b68ebe74814"}, - {file = "pytest_freezer-0.4.8.tar.gz", hash = "sha256:8ee2f724b3ff3540523fa355958a22e6f4c1c819928b78a7a183ae4248ce6ee6"}, -] - -[package.dependencies] -freezegun = ">=1.0" -pytest = ">=3.6" - -[[package]] -name = "pytest-mock" -version = "3.14.0" -description = "Thin-wrapper around the mock package for easier use with pytest" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, - {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, -] - -[package.dependencies] -pytest = ">=6.2.5" - -[package.extras] -dev = ["pre-commit", "pytest-asyncio", "tox"] - -[[package]] -name = "pytest-sugar" -version = "1.0.0" -description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." -optional = false -python-versions = "*" -files = [ - {file = "pytest-sugar-1.0.0.tar.gz", hash = "sha256:6422e83258f5b0c04ce7c632176c7732cab5fdb909cb39cca5c9139f81276c0a"}, - {file = "pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd"}, -] - -[package.dependencies] -packaging = ">=21.3" -pytest = ">=6.2.0" -termcolor = ">=2.1.0" - -[package.extras] -dev = ["black", "flake8", "pre-commit"] - -[[package]] -name = "pytest-timeout" -version = "2.3.1" -description = "pytest plugin to abort hanging tests" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9"}, - {file = "pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e"}, -] - -[package.dependencies] -pytest = ">=7.0.0" - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "pyyaml" -version = "6.0.2" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, -] - -[[package]] -name = "requests" -version = "2.32.3" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "rich" -version = "13.8.0" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = true -python-versions = ">=3.7.0" -files = [ - {file = "rich-13.8.0-py3-none-any.whl", hash = "sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc"}, - {file = "rich-13.8.0.tar.gz", hash = "sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "snowballstemmer" -version = "2.2.0" -description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -optional = true -python-versions = "*" -files = [ - {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, - {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, -] - -[[package]] -name = "sphinx" -version = "5.3.0" -description = "Python documentation generator" -optional = true -python-versions = ">=3.6" -files = [ - {file = "Sphinx-5.3.0.tar.gz", hash = "sha256:51026de0a9ff9fc13c05d74913ad66047e104f56a129ff73e174eb5c3ee794b5"}, - {file = "sphinx-5.3.0-py3-none-any.whl", hash = "sha256:060ca5c9f7ba57a08a1219e547b269fadf125ae25b06b9fa7f66768efb652d6d"}, -] - -[package.dependencies] -alabaster = ">=0.7,<0.8" -babel = ">=2.9" -colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.14,<0.20" -imagesize = ">=1.3" -importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""} -Jinja2 = ">=3.0" -packaging = ">=21.0" -Pygments = ">=2.12" -requests = ">=2.5.0" -snowballstemmer = ">=2.0" -sphinxcontrib-applehelp = "*" -sphinxcontrib-devhelp = "*" -sphinxcontrib-htmlhelp = ">=2.0.0" -sphinxcontrib-jsmath = "*" -sphinxcontrib-qthelp = "*" -sphinxcontrib-serializinghtml = ">=1.1.5" - -[package.extras] -docs = ["sphinxcontrib-websupport"] -lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-bugbear", "flake8-comprehensions", "flake8-simplify", "isort", "mypy (>=0.981)", "sphinx-lint", "types-requests", "types-typed-ast"] -test = ["cython", "html5lib", "pytest (>=4.6)", "typed_ast"] - -[[package]] -name = "sphinx-rtd-theme" -version = "2.0.0" -description = "Read the Docs theme for Sphinx" -optional = true -python-versions = ">=3.6" -files = [ - {file = "sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586"}, - {file = "sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b"}, -] - -[package.dependencies] -docutils = "<0.21" -sphinx = ">=5,<8" -sphinxcontrib-jquery = ">=4,<5" - -[package.extras] -dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] - -[[package]] -name = "sphinxcontrib-applehelp" -version = "2.0.0" -description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" -optional = true -python-versions = ">=3.9" -files = [ - {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, - {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, -] - -[package.extras] -lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] -standalone = ["Sphinx (>=5)"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-devhelp" -version = "2.0.0" -description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" -optional = true -python-versions = ">=3.9" -files = [ - {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, - {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, -] - -[package.extras] -lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] -standalone = ["Sphinx (>=5)"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-htmlhelp" -version = "2.1.0" -description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -optional = true -python-versions = ">=3.9" -files = [ - {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, - {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, -] - -[package.extras] -lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] -standalone = ["Sphinx (>=5)"] -test = ["html5lib", "pytest"] - -[[package]] -name = "sphinxcontrib-jquery" -version = "4.1" -description = "Extension to include jQuery on newer Sphinx releases" -optional = true -python-versions = ">=2.7" -files = [ - {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, - {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, -] - -[package.dependencies] -Sphinx = ">=1.8" - -[[package]] -name = "sphinxcontrib-jsmath" -version = "1.0.1" -description = "A sphinx extension which renders display math in HTML via JavaScript" -optional = true -python-versions = ">=3.5" -files = [ - {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, - {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, -] - -[package.extras] -test = ["flake8", "mypy", "pytest"] - -[[package]] -name = "sphinxcontrib-programoutput" -version = "0.17" -description = "Sphinx extension to include program output" -optional = true -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" -files = [ - {file = "sphinxcontrib-programoutput-0.17.tar.gz", hash = "sha256:300ee9b8caee8355d25cc74b4d1c7efd12e608d2ad165e3141d31e6fbc152b7f"}, - {file = "sphinxcontrib_programoutput-0.17-py2.py3-none-any.whl", hash = "sha256:0ef1c1d9159dbe7103077748214305eb4e0138e861feb71c0c346afc5fe97f84"}, -] - -[package.dependencies] -Sphinx = ">=1.7.0" - -[[package]] -name = "sphinxcontrib-qthelp" -version = "2.0.0" -description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" -optional = true -python-versions = ">=3.9" -files = [ - {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, - {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, -] - -[package.extras] -lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] -standalone = ["Sphinx (>=5)"] -test = ["defusedxml (>=0.7.1)", "pytest"] - -[[package]] -name = "sphinxcontrib-serializinghtml" -version = "2.0.0" -description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" -optional = true -python-versions = ">=3.9" -files = [ - {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, - {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, -] - -[package.extras] -lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] -standalone = ["Sphinx (>=5)"] -test = ["pytest"] - -[[package]] -name = "termcolor" -version = "2.4.0" -description = "ANSI color formatting for output in terminal" -optional = false -python-versions = ">=3.8" -files = [ - {file = "termcolor-2.4.0-py3-none-any.whl", hash = "sha256:9297c0df9c99445c2412e832e882a7884038a25617c60cea2ad69488d4040d63"}, - {file = "termcolor-2.4.0.tar.gz", hash = "sha256:aab9e56047c8ac41ed798fa36d892a37aca6b3e9159f3e0c24bc64a9b3ac7b7a"}, -] - -[package.extras] -tests = ["pytest", "pytest-cov"] - -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - -[[package]] -name = "tox" -version = "4.18.0" -description = "tox is a generic virtualenv management and test command line tool" -optional = false -python-versions = ">=3.8" -files = [ - {file = "tox-4.18.0-py3-none-any.whl", hash = "sha256:0a457400cf70615dc0627eb70d293e80cd95d8ce174bb40ac011011f0c03a249"}, - {file = "tox-4.18.0.tar.gz", hash = "sha256:5dfa1cab9f146becd6e351333a82f9e0ade374451630ba65ee54584624c27b58"}, -] - -[package.dependencies] -cachetools = ">=5.4" -chardet = ">=5.2" -colorama = ">=0.4.6" -filelock = ">=3.15.4" -packaging = ">=24.1" -platformdirs = ">=4.2.2" -pluggy = ">=1.5" -pyproject-api = ">=1.7.1" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} -virtualenv = ">=20.26.3" - -[package.extras] -docs = ["furo (>=2024.7.18)", "sphinx (>=7.4.7)", "sphinx-argparse-cli (>=1.16)", "sphinx-autodoc-typehints (>=2.2.3)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"] -testing = ["build[virtualenv] (>=1.2.1)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=9.1.1)", "distlib (>=0.3.8)", "flaky (>=3.8.1)", "hatch-vcs (>=0.4)", "hatchling (>=1.25)", "psutil (>=6)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-xdist (>=3.6.1)", "re-assert (>=1.1)", "setuptools (>=70.3)", "time-machine (>=2.14.2)", "wheel (>=0.43)"] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, -] - -[[package]] -name = "urllib3" -version = "2.2.2" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.8" -files = [ - {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, - {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "virtualenv" -version = "20.26.3" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.7" -files = [ - {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, - {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<5" - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] - -[[package]] -name = "voluptuous" -version = "0.15.2" -description = "Python data validation library" -optional = false -python-versions = ">=3.9" -files = [ - {file = "voluptuous-0.15.2-py3-none-any.whl", hash = "sha256:016348bc7788a9af9520b1764ebd4de0df41fe2138ebe9e06fa036bf86a65566"}, - {file = "voluptuous-0.15.2.tar.gz", hash = "sha256:6ffcab32c4d3230b4d2af3a577c87e1908a714a11f6f95570456b1849b0279aa"}, -] - -[[package]] -name = "wcwidth" -version = "0.2.13" -description = "Measures the displayed width of unicode strings in a terminal" -optional = true -python-versions = "*" -files = [ - {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, - {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, -] - -[[package]] -name = "xdoctest" -version = "1.2.0" -description = "A rewrite of the builtin doctest module" -optional = false -python-versions = ">=3.8" -files = [ - {file = "xdoctest-1.2.0-py3-none-any.whl", hash = "sha256:0f1ecf5939a687bd1fc8deefbff1743c65419cce26dff908f8b84c93fbe486bc"}, - {file = "xdoctest-1.2.0.tar.gz", hash = "sha256:d8cfca6d8991e488d33f756e600d35b9fdf5efd5c3a249d644efcbbbd2ed5863"}, -] - -[package.extras] -all = ["IPython (>=7.23.1)", "Pygments (>=2.0.0)", "Pygments (>=2.4.1)", "attrs (>=19.2.0)", "colorama (>=0.4.1)", "debugpy (>=1.0.0)", "debugpy (>=1.3.0)", "debugpy (>=1.6.0)", "ipykernel (>=6.0.0)", "ipykernel (>=6.11.0)", "ipython-genutils (>=0.2.0)", "jedi (>=0.16)", "jinja2 (>=3.0.0)", "jupyter-client (>=7.0.0)", "jupyter-core (>=4.7.0)", "nbconvert (>=6.0.0)", "nbconvert (>=6.1.0)", "pyflakes (>=2.2.0)", "pytest (>=4.6.0)", "pytest (>=6.2.5)", "pytest-cov (>=3.0.0)", "tomli (>=0.2.0)"] -all-strict = ["IPython (==7.23.1)", "Pygments (==2.0.0)", "Pygments (==2.4.1)", "attrs (==19.2.0)", "colorama (==0.4.1)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==6.0.0)", "ipykernel (==6.11.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "nbconvert (==6.1.0)", "pyflakes (==2.2.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)", "tomli (==0.2.0)"] -colors = ["Pygments (>=2.0.0)", "Pygments (>=2.4.1)", "colorama (>=0.4.1)"] -colors-strict = ["Pygments (==2.0.0)", "Pygments (==2.4.1)", "colorama (==0.4.1)"] -docs = ["Pygments (>=2.9.0)", "myst-parser (>=0.18.0)", "sphinx (>=5.0.1)", "sphinx-autoapi (>=1.8.4)", "sphinx-autobuild (>=2021.3.14)", "sphinx-reredirects (>=0.0.1)", "sphinx-rtd-theme (>=1.0.0)", "sphinxcontrib-napoleon (>=0.7)"] -docs-strict = ["Pygments (==2.9.0)", "myst-parser (==0.18.0)", "sphinx (==5.0.1)", "sphinx-autoapi (==1.8.4)", "sphinx-autobuild (==2021.3.14)", "sphinx-reredirects (==0.0.1)", "sphinx-rtd-theme (==1.0.0)", "sphinxcontrib-napoleon (==0.7)"] -jupyter = ["IPython (>=7.23.1)", "attrs (>=19.2.0)", "debugpy (>=1.0.0)", "debugpy (>=1.3.0)", "debugpy (>=1.6.0)", "ipykernel (>=6.0.0)", "ipykernel (>=6.11.0)", "ipython-genutils (>=0.2.0)", "jedi (>=0.16)", "jinja2 (>=3.0.0)", "jupyter-client (>=7.0.0)", "jupyter-core (>=4.7.0)", "nbconvert (>=6.0.0)", "nbconvert (>=6.1.0)"] -jupyter-strict = ["IPython (==7.23.1)", "attrs (==19.2.0)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==6.0.0)", "ipykernel (==6.11.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "nbconvert (==6.1.0)"] -optional = ["IPython (>=7.23.1)", "Pygments (>=2.0.0)", "Pygments (>=2.4.1)", "attrs (>=19.2.0)", "colorama (>=0.4.1)", "debugpy (>=1.0.0)", "debugpy (>=1.3.0)", "debugpy (>=1.6.0)", "ipykernel (>=6.0.0)", "ipykernel (>=6.11.0)", "ipython-genutils (>=0.2.0)", "jedi (>=0.16)", "jinja2 (>=3.0.0)", "jupyter-client (>=7.0.0)", "jupyter-core (>=4.7.0)", "nbconvert (>=6.0.0)", "nbconvert (>=6.1.0)", "pyflakes (>=2.2.0)", "tomli (>=0.2.0)"] -optional-strict = ["IPython (==7.23.1)", "Pygments (==2.0.0)", "Pygments (==2.4.1)", "attrs (==19.2.0)", "colorama (==0.4.1)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==6.0.0)", "ipykernel (==6.11.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "nbconvert (==6.1.0)", "pyflakes (==2.2.0)", "tomli (==0.2.0)"] -tests = ["pytest (>=4.6.0)", "pytest (>=6.2.5)", "pytest-cov (>=3.0.0)"] -tests-binary = ["cmake (>=3.21.2)", "cmake (>=3.25.0)", "ninja (>=1.10.2)", "ninja (>=1.11.1)", "pybind11 (>=2.10.3)", "pybind11 (>=2.7.1)", "scikit-build (>=0.11.1)", "scikit-build (>=0.16.1)"] -tests-binary-strict = ["cmake (==3.21.2)", "cmake (==3.25.0)", "ninja (==1.10.2)", "ninja (==1.11.1)", "pybind11 (==2.10.3)", "pybind11 (==2.7.1)", "scikit-build (==0.11.1)", "scikit-build (==0.16.1)"] -tests-strict = ["pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)"] - -[[package]] -name = "yarl" -version = "1.9.4" -description = "Yet another URL library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, - {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, - {file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"}, - {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"}, - {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"}, - {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"}, - {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"}, - {file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"}, - {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"}, - {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"}, - {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"}, - {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"}, - {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"}, - {file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"}, - {file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"}, - {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"}, - {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"}, - {file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"}, - {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"}, - {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"}, - {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"}, - {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"}, - {file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"}, - {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"}, - {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"}, - {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"}, - {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"}, - {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"}, - {file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"}, - {file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"}, - {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"}, - {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"}, - {file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"}, - {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"}, - {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"}, - {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"}, - {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"}, - {file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"}, - {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"}, - {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"}, - {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"}, - {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"}, - {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"}, - {file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"}, - {file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"}, - {file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"}, - {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"}, - {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"}, - {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"}, - {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"}, - {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"}, - {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"}, - {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"}, - {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"}, - {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"}, - {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"}, - {file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"}, - {file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"}, - {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"}, - {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"}, - {file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"}, - {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"}, - {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"}, - {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"}, - {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"}, - {file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"}, - {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"}, - {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"}, - {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"}, - {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"}, - {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"}, - {file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"}, - {file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"}, - {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"}, - {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"}, - {file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"}, - {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"}, - {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"}, - {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"}, - {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"}, - {file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"}, - {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"}, - {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"}, - {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"}, - {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"}, - {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"}, - {file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"}, - {file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"}, - {file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"}, - {file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"}, -] - -[package.dependencies] -idna = ">=2.0" -multidict = ">=4.0" - -[[package]] -name = "zipp" -version = "3.20.1" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = true -python-versions = ">=3.8" -files = [ - {file = "zipp-3.20.1-py3-none-any.whl", hash = "sha256:9960cd8967c8f85a56f920d5d507274e74f9ff813a0ab8889a5b5be2daf44064"}, - {file = "zipp-3.20.1.tar.gz", hash = "sha256:c22b14cc4763c5a5b04134207736c107db42e9d3ef2d9779d465f5f1bcba572b"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - -[extras] -docs = ["docutils", "myst-parser", "sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput"] -shell = ["ptpython", "rich"] -speedups = ["kasa-crypt", "orjson"] - -[metadata] -lock-version = "2.0" -python-versions = "^3.9" -content-hash = "c29200a35b10776b74812daf37b88bf400f7f496825954f7a9eba718f215ae3b" diff --git a/pyproject.toml b/pyproject.toml index 09cdfc349..041dd804f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,70 +1,83 @@ -[tool.poetry] +[project] name = "python-kasa" version = "0.7.2" -description = "Python API for TP-Link Kasa Smarthome devices" -license = "GPL-3.0-or-later" -authors = ["python-kasa developers"] -repository = "https://github.com/python-kasa/python-kasa" +description = "Python API for TP-Link Kasa and Tapo devices" +license = {text = "GPL-3.0-or-later"} +authors = [ { name = "python-kasa developers" }] readme = "README.md" -packages = [ - { include = "kasa" } +requires-python = ">=3.9,<4.0" +dependencies = [ + "asyncclick>=8.1.7", + "pydantic>=1.10.15", + "cryptography>=1.9", + "async-timeout>=3.0.0", + "aiohttp>=3", + "typing-extensions>=4.12.2,<5.0", ] -include = [ - { path= "CHANGELOG.md", format = "sdist" } + +classifiers = [ + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] -[tool.poetry.urls] +[project.optional-dependencies] +speedups = ["orjson>=3.9.1", "kasa-crypt>=0.2.0"] +docs = ["sphinx~=5.0", "sphinx_rtd_theme~=2.0", "sphinxcontrib-programoutput~=0.0", "myst-parser", "docutils>=0.17"] +shell = ["ptpython", "rich"] + +[project.urls] +"Homepage" = "https://github.com/python-kasa/python-kasa" "Bug Tracker" = "https://github.com/python-kasa/python-kasa/issues" "Documentation" = "https://python-kasa.readthedocs.io" +"Repository" = "https://github.com/python-kasa/python-kasa" -[tool.poetry.scripts] +[project.scripts] kasa = "kasa.cli.__main__:cli" -[tool.poetry.dependencies] -python = "^3.9" -asyncclick = ">=8.1.7" -pydantic = ">=1.10.15" -cryptography = ">=1.9" -async-timeout = ">=3.0.0" -aiohttp = ">=3" - -# speed ups -orjson = { "version" = ">=3.9.1", optional = true } -kasa-crypt = { "version" = ">=0.2.0", optional = true } - -# required only for docs -sphinx = { version = "^5", optional = true } -sphinx_rtd_theme = { version = "^2", optional = true } -sphinxcontrib-programoutput = { version = "^0", optional = true } -myst-parser = { version = "*", optional = true } -docutils = { version = ">=0.17", optional = true } - -# enhanced cli support -ptpython = { version = "*", optional = true } -rich = { version = "*", optional = true } -typing-extensions = ">=4.12.2,<5.0" - -[tool.poetry.group.dev.dependencies] -pytest = "*" -pytest-cov = "*" -pytest-asyncio = "*" -pytest-sugar = "*" -pre-commit = "*" -voluptuous = "*" -toml = "*" -tox = "*" -pytest-mock = "*" -codecov = "*" -xdoctest = ">=1.2.0" -coverage = {version = "*", extras = ["toml"]} -pytest-timeout = "^2" -pytest-freezer = "^0.4" -mypy = "^1" - -[tool.poetry.extras] -docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] -speedups = ["orjson", "kasa-crypt"] -shell = ["ptpython", "rich"] +[tool.uv] +dev-dependencies = [ + "pytest", + "pytest-cov", + "pytest-asyncio", + "pytest-sugar", + "pre-commit", + "voluptuous", + "toml", + "pytest-mock", + "codecov", + "xdoctest>=1.2.0", + "coverage[toml]", + "pytest-timeout~=2.0", + "pytest-freezer~=0.4", + "mypy~=1.0" +] + + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.sdist] +include = [ + "/kasa", + "/devtools", + "/docs", + "/CHANGELOG.md", +] + +[tool.hatch.build.targets.wheel] +include = [ + "/kasa", +] +exclude = [ + "/kasa/tests", +] [tool.coverage.run] source = ["kasa"] @@ -99,9 +112,6 @@ paths = ["docs"] ignore = ["D001"] ignore-path-errors = ["docs/source/index.rst;D000"] -[build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" [tool.ruff] target-version = "py38" diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 5843142c3..000000000 --- a/tox.ini +++ /dev/null @@ -1,37 +0,0 @@ -[tox] -envlist=py37,py38,lint,coverage -skip_missing_interpreters = True -isolated_build = True - - -[testenv] -whitelist_externals = - poetry - coverage -commands = - poetry install -v - poetry run pytest --cov kasa/tests/ - -[testenv:clean] -deps = coverage -skip_install = true -commands = coverage erase - -[testenv:py37] -commands = coverage run -m pytest {posargs} - -[testenv:py38] -commands = coverage run -m pytest {posargs} - -[testenv:coverage] -basepython = python3.8 -skip_install = true -deps = coverage[toml] -commands = - coverage report - coverage html - -[testenv:lint] -deps = pre-commit -skip_install = true -commands = pre-commit run --all-files diff --git a/uv.lock b/uv.lock new file mode 100644 index 000000000..dfffab36d --- /dev/null +++ b/uv.lock @@ -0,0 +1,1795 @@ +version = 1 +requires-python = ">=3.9, <4.0" +resolution-markers = [ + "python_full_version < '3.13'", + "python_full_version >= '3.13'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f7/22bba300a16fd1cad99da1a23793fe43963ee326d012fdf852d0b4035955/aiohappyeyeballs-2.4.0.tar.gz", hash = "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2", size = 16786 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/b6/58ea188899950d759a837f9a58b2aee1d1a380ea4d6211ce9b1823748851/aiohappyeyeballs-2.4.0-py3-none-any.whl", hash = "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd", size = 12155 }, +] + +[[package]] +name = "aiohttp" +version = "3.10.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/28/ca549838018140b92a19001a8628578b0f2a3b38c16826212cc6f706e6d4/aiohttp-3.10.5.tar.gz", hash = "sha256:f071854b47d39591ce9a17981c46790acb30518e2f83dfca8db2dfa091178691", size = 7524360 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/4a/b27dd9b88fe22dde88742b341fd10251746a6ffcfe1c0b8b15b4a8cbd7c1/aiohttp-3.10.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:18a01eba2574fb9edd5f6e5fb25f66e6ce061da5dab5db75e13fe1558142e0a3", size = 587010 }, + { url = "https://files.pythonhosted.org/packages/de/a9/0f7e2b71549c9d641086c423526ae7a10de3b88d03ba104a3df153574d0d/aiohttp-3.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94fac7c6e77ccb1ca91e9eb4cb0ac0270b9fb9b289738654120ba8cebb1189c6", size = 397698 }, + { url = "https://files.pythonhosted.org/packages/3b/52/26baa486e811c25b0cd16a494038260795459055568713f841e78f016481/aiohttp-3.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2f1f1c75c395991ce9c94d3e4aa96e5c59c8356a15b1c9231e783865e2772699", size = 389052 }, + { url = "https://files.pythonhosted.org/packages/33/df/71ba374a3e925539cb2f6e6d4f5326e7b6b200fabbe1b3cc5e6368f07ce7/aiohttp-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f7acae3cf1a2a2361ec4c8e787eaaa86a94171d2417aae53c0cca6ca3118ff6", size = 1248615 }, + { url = "https://files.pythonhosted.org/packages/67/02/bb89c1eba08a27fc844933bee505d63d480caf8e2816c06961d2941cd128/aiohttp-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94c4381ffba9cc508b37d2e536b418d5ea9cfdc2848b9a7fea6aebad4ec6aac1", size = 1282930 }, + { url = "https://files.pythonhosted.org/packages/db/36/07d8cfcc37f39c039f93a4210cc71dadacca003609946c63af23659ba656/aiohttp-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c31ad0c0c507894e3eaa843415841995bf8de4d6b2d24c6e33099f4bc9fc0d4f", size = 1317250 }, + { url = "https://files.pythonhosted.org/packages/9a/44/cabeac994bef8ba521b552ae996928afc6ee1975a411385a07409811b01f/aiohttp-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0912b8a8fadeb32ff67a3ed44249448c20148397c1ed905d5dac185b4ca547bb", size = 1243212 }, + { url = "https://files.pythonhosted.org/packages/5a/11/23f1e31f5885ac72be52fd205981951dd2e4c87c5b1487cf82fde5bbd46c/aiohttp-3.10.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d93400c18596b7dc4794d48a63fb361b01a0d8eb39f28800dc900c8fbdaca91", size = 1213401 }, + { url = "https://files.pythonhosted.org/packages/3f/e7/6e69a0b0d896fbaf1192d492db4c21688e6c0d327486da610b0e8195bcc9/aiohttp-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3c5e0d764a5c9aa5a62d99728c56d455310bcc288a79cab10157b3af426f", size = 1212450 }, + { url = "https://files.pythonhosted.org/packages/a9/7f/a42f51074c723ea848254946aec118f1e59914a639dc8ba20b0c9247c195/aiohttp-3.10.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d742c36ed44f2798c8d3f4bc511f479b9ceef2b93f348671184139e7d708042c", size = 1211324 }, + { url = "https://files.pythonhosted.org/packages/d5/43/c2f9d2f588ccef8f028f0a0c999b5ceafecbda50b943313faee7e91f3e03/aiohttp-3.10.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:814375093edae5f1cb31e3407997cf3eacefb9010f96df10d64829362ae2df69", size = 1266838 }, + { url = "https://files.pythonhosted.org/packages/c1/a7/ff9f067ecb06896d859e4f2661667aee4bd9c616689599ff034b63cbd9d7/aiohttp-3.10.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8224f98be68a84b19f48e0bdc14224b5a71339aff3a27df69989fa47d01296f3", size = 1285301 }, + { url = "https://files.pythonhosted.org/packages/9a/e3/dd56bb4c67d216046ce61d98dec0f3023043f1de48f561df1bf93dd47aea/aiohttp-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9a487ef090aea982d748b1b0d74fe7c3950b109df967630a20584f9a99c0683", size = 1235806 }, + { url = "https://files.pythonhosted.org/packages/a7/64/90dcd42ac21927a49ba4140b2e4d50e1847379427ef6c43eb338ef9960e3/aiohttp-3.10.5-cp310-cp310-win32.whl", hash = "sha256:d9ef084e3dc690ad50137cc05831c52b6ca428096e6deb3c43e95827f531d5ef", size = 360162 }, + { url = "https://files.pythonhosted.org/packages/f3/45/145d8b4853fc92c0c8509277642767e7726a085e390ce04353dc68b0f5b5/aiohttp-3.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:66bf9234e08fe561dccd62083bf67400bdbf1c67ba9efdc3dac03650e97c6088", size = 379173 }, + { url = "https://files.pythonhosted.org/packages/f1/90/54ccb1e4eadfb6c95deff695582453f6208584431d69bf572782e9ae542b/aiohttp-3.10.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8c6a4e5e40156d72a40241a25cc226051c0a8d816610097a8e8f517aeacd59a2", size = 586455 }, + { url = "https://files.pythonhosted.org/packages/c3/7a/95e88c02756e7e718f054e1bb3ec6ad5d0ee4a2ca2bb1768c5844b3de30a/aiohttp-3.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c634a3207a5445be65536d38c13791904fda0748b9eabf908d3fe86a52941cf", size = 397255 }, + { url = "https://files.pythonhosted.org/packages/07/4f/767387b39990e1ee9aba8ce642abcc286d84d06e068dc167dab983898f18/aiohttp-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4aff049b5e629ef9b3e9e617fa6e2dfeda1bf87e01bcfecaf3949af9e210105e", size = 388973 }, + { url = "https://files.pythonhosted.org/packages/61/46/0df41170a4d228c07b661b1ba9d87101d99a79339dc93b8b1183d8b20545/aiohttp-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1942244f00baaacaa8155eca94dbd9e8cc7017deb69b75ef67c78e89fdad3c77", size = 1326126 }, + { url = "https://files.pythonhosted.org/packages/af/20/da0d65e07ce49d79173fed41598f487a0a722e87cfbaa8bb7e078a7c1d39/aiohttp-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04a1f2a65ad2f93aa20f9ff9f1b672bf912413e5547f60749fa2ef8a644e061", size = 1364538 }, + { url = "https://files.pythonhosted.org/packages/aa/20/b59728405114e57541ba9d5b96033e69d004e811ded299537f74237629ca/aiohttp-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f2bfc0032a00405d4af2ba27f3c429e851d04fad1e5ceee4080a1c570476697", size = 1399896 }, + { url = "https://files.pythonhosted.org/packages/2a/92/006690c31b830acbae09d2618e41308fe4c81c0679b3b33a3af859e0b7bf/aiohttp-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:424ae21498790e12eb759040bbb504e5e280cab64693d14775c54269fd1d2bb7", size = 1312914 }, + { url = "https://files.pythonhosted.org/packages/d4/71/1a253ca215b6c867adbd503f1e142117527ea8775e65962bc09b2fad1d2c/aiohttp-3.10.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:975218eee0e6d24eb336d0328c768ebc5d617609affaca5dbbd6dd1984f16ed0", size = 1271301 }, + { url = "https://files.pythonhosted.org/packages/0a/ab/5d1d9ff9ce6cce8fa54774d0364e64a0f3cd50e512ff09082ced8e5217a1/aiohttp-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4120d7fefa1e2d8fb6f650b11489710091788de554e2b6f8347c7a20ceb003f5", size = 1291652 }, + { url = "https://files.pythonhosted.org/packages/75/5f/f90510ea954b9ae6e7a53d2995b97a3e5c181110fdcf469bc9238445871d/aiohttp-3.10.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b90078989ef3fc45cf9221d3859acd1108af7560c52397ff4ace8ad7052a132e", size = 1286289 }, + { url = "https://files.pythonhosted.org/packages/be/9e/1f523414237798660921817c82b9225a363af436458caf584d2fa6a2eb4a/aiohttp-3.10.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ba5a8b74c2a8af7d862399cdedce1533642fa727def0b8c3e3e02fcb52dca1b1", size = 1341848 }, + { url = "https://files.pythonhosted.org/packages/f6/36/443472ddaa85d7d80321fda541d9535b23ecefe0bf5792cc3955ea635190/aiohttp-3.10.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:02594361128f780eecc2a29939d9dfc870e17b45178a867bf61a11b2a4367277", size = 1361619 }, + { url = "https://files.pythonhosted.org/packages/19/f6/3ecbac0bc4359c7d7ba9e85c6b10f57e20edaf1f97751ad2f892db231ad0/aiohttp-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4fc029e135859f533025bc82047334e24b0d489e75513144f25408ecaf058", size = 1320869 }, + { url = "https://files.pythonhosted.org/packages/34/7e/ed74ffb36e3a0cdec1b05d8fbaa29cb532371d5a20058b3a8052fc90fe7c/aiohttp-3.10.5-cp311-cp311-win32.whl", hash = "sha256:e1ca1ef5ba129718a8fc827b0867f6aa4e893c56eb00003b7367f8a733a9b072", size = 359271 }, + { url = "https://files.pythonhosted.org/packages/98/1b/718901f04bc8c886a742be9e83babb7b93facabf7c475cc95e2b3ab80b4d/aiohttp-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:349ef8a73a7c5665cca65c88ab24abe75447e28aa3bc4c93ea5093474dfdf0ff", size = 379143 }, + { url = "https://files.pythonhosted.org/packages/d9/1c/74f9dad4a2fc4107e73456896283d915937f48177b99867b63381fadac6e/aiohttp-3.10.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:305be5ff2081fa1d283a76113b8df7a14c10d75602a38d9f012935df20731487", size = 583468 }, + { url = "https://files.pythonhosted.org/packages/12/29/68d090551f2b58ce76c2b436ced8dd2dfd32115d41299bf0b0c308a5483c/aiohttp-3.10.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3a1c32a19ee6bbde02f1cb189e13a71b321256cc1d431196a9f824050b160d5a", size = 394066 }, + { url = "https://files.pythonhosted.org/packages/8f/f7/971f88b4cdcaaa4622925ba7d86de47b48ec02a9040a143514b382f78da4/aiohttp-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61645818edd40cc6f455b851277a21bf420ce347baa0b86eaa41d51ef58ba23d", size = 389098 }, + { url = "https://files.pythonhosted.org/packages/f1/5a/fe3742efdce551667b2ddf1158b27c5b8eb1edc13d5e14e996e52e301025/aiohttp-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c225286f2b13bab5987425558baa5cbdb2bc925b2998038fa028245ef421e75", size = 1332742 }, + { url = "https://files.pythonhosted.org/packages/1a/52/a25c0334a1845eb4967dff279151b67ca32a948145a5812ed660ed900868/aiohttp-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ba01ebc6175e1e6b7275c907a3a36be48a2d487549b656aa90c8a910d9f3178", size = 1372134 }, + { url = "https://files.pythonhosted.org/packages/96/3d/33c1d8efc2d8ec36bff9a8eca2df9fdf8a45269c6e24a88e74f2aa4f16bd/aiohttp-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8eaf44ccbc4e35762683078b72bf293f476561d8b68ec8a64f98cf32811c323e", size = 1414413 }, + { url = "https://files.pythonhosted.org/packages/64/74/0f1ddaa5f0caba1d946f0dd0c31f5744116e4a029beec454ec3726d3311f/aiohttp-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1c43eb1ab7cbf411b8e387dc169acb31f0ca0d8c09ba63f9eac67829585b44f", size = 1328107 }, + { url = "https://files.pythonhosted.org/packages/0a/32/c10118f0ad50e4093227234f71fd0abec6982c29367f65f32ee74ed652c4/aiohttp-3.10.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de7a5299827253023c55ea549444e058c0eb496931fa05d693b95140a947cb73", size = 1280126 }, + { url = "https://files.pythonhosted.org/packages/c6/c9/77e3d648d97c03a42acfe843d03e97be3c5ef1b4d9de52e5bd2d28eed8e7/aiohttp-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4790f0e15f00058f7599dab2b206d3049d7ac464dc2e5eae0e93fa18aee9e7bf", size = 1292660 }, + { url = "https://files.pythonhosted.org/packages/7e/5d/99c71f8e5c8b64295be421b4c42d472766b263a1fe32e91b64bf77005bf2/aiohttp-3.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:44b324a6b8376a23e6ba25d368726ee3bc281e6ab306db80b5819999c737d820", size = 1300988 }, + { url = "https://files.pythonhosted.org/packages/8f/2c/76d2377dd947f52fbe8afb19b18a3b816d66c7966755c04030f93b1f7b2d/aiohttp-3.10.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0d277cfb304118079e7044aad0b76685d30ecb86f83a0711fc5fb257ffe832ca", size = 1339268 }, + { url = "https://files.pythonhosted.org/packages/fd/e6/3d9d935cc705d57ed524d82ec5d6b678a53ac1552720ae41282caa273584/aiohttp-3.10.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:54d9ddea424cd19d3ff6128601a4a4d23d54a421f9b4c0fff740505813739a91", size = 1366993 }, + { url = "https://files.pythonhosted.org/packages/fe/c2/f7eed4d602f3f224600d03ab2e1a7734999b0901b1c49b94dc5891340433/aiohttp-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f1c9866ccf48a6df2b06823e6ae80573529f2af3a0992ec4fe75b1a510df8a6", size = 1329459 }, + { url = "https://files.pythonhosted.org/packages/ce/8f/27f205b76531fc592abe29e1ad265a16bf934a9f609509c02d765e6a8055/aiohttp-3.10.5-cp312-cp312-win32.whl", hash = "sha256:dc4826823121783dccc0871e3f405417ac116055bf184ac04c36f98b75aacd12", size = 356968 }, + { url = "https://files.pythonhosted.org/packages/39/8c/4f6c0b2b3629f6be6c81ab84d9d577590f74f01d4412bfc4067958eaa1e1/aiohttp-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:22c0a23a3b3138a6bf76fc553789cb1a703836da86b0f306b6f0dc1617398abc", size = 377650 }, + { url = "https://files.pythonhosted.org/packages/7b/b9/03b4327897a5b5d29338fa9b514f1c2f66a3e4fc88a4e40fad478739314d/aiohttp-3.10.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7f6b639c36734eaa80a6c152a238242bedcee9b953f23bb887e9102976343092", size = 576994 }, + { url = "https://files.pythonhosted.org/packages/67/1b/20c2e159cd07b8ed6dde71c2258233902fdf415b2fe6174bd2364ba63107/aiohttp-3.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29930bc2921cef955ba39a3ff87d2c4398a0394ae217f41cb02d5c26c8b1b77", size = 390684 }, + { url = "https://files.pythonhosted.org/packages/4d/6b/ff83b34f157e370431d8081c5d1741963f4fb12f9aaddb2cacbf50305225/aiohttp-3.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f489a2c9e6455d87eabf907ac0b7d230a9786be43fbe884ad184ddf9e9c1e385", size = 386176 }, + { url = "https://files.pythonhosted.org/packages/4d/a1/6e92817eb657de287560962df4959b7ddd22859c4b23a0309e2d3de12538/aiohttp-3.10.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:123dd5b16b75b2962d0fff566effb7a065e33cd4538c1692fb31c3bda2bfb972", size = 1303310 }, + { url = "https://files.pythonhosted.org/packages/04/29/200518dc7a39c30ae6d5bc232d7207446536e93d3d9299b8e95db6e79c54/aiohttp-3.10.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b98e698dc34966e5976e10bbca6d26d6724e6bdea853c7c10162a3235aba6e16", size = 1340445 }, + { url = "https://files.pythonhosted.org/packages/8e/20/53f7bba841ba7b5bb5dea580fea01c65524879ba39cb917d08c845524717/aiohttp-3.10.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3b9162bab7e42f21243effc822652dc5bb5e8ff42a4eb62fe7782bcbcdfacf6", size = 1385121 }, + { url = "https://files.pythonhosted.org/packages/f1/b4/d99354ad614c48dd38fb1ee880a1a54bd9ab2c3bcad3013048d4a1797d3a/aiohttp-3.10.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1923a5c44061bffd5eebeef58cecf68096e35003907d8201a4d0d6f6e387ccaa", size = 1299669 }, + { url = "https://files.pythonhosted.org/packages/51/39/ca1de675f2a5729c71c327e52ac6344e63f036bd37281686ae5c3fb13bfb/aiohttp-3.10.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d55f011da0a843c3d3df2c2cf4e537b8070a419f891c930245f05d329c4b0689", size = 1252638 }, + { url = "https://files.pythonhosted.org/packages/54/cf/a3ae7ff43138422d477348e309ef8275779701bf305ff6054831ef98b782/aiohttp-3.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:afe16a84498441d05e9189a15900640a2d2b5e76cf4efe8cbb088ab4f112ee57", size = 1266889 }, + { url = "https://files.pythonhosted.org/packages/6e/7a/c6027ad70d9fb23cf254a26144de2723821dade1a624446aa22cd0b6d012/aiohttp-3.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f8112fb501b1e0567a1251a2fd0747baae60a4ab325a871e975b7bb67e59221f", size = 1266249 }, + { url = "https://files.pythonhosted.org/packages/64/fd/ed136d46bc2c7e3342fed24662b4827771d55ceb5a7687847aae977bfc17/aiohttp-3.10.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1e72589da4c90337837fdfe2026ae1952c0f4a6e793adbbfbdd40efed7c63599", size = 1311036 }, + { url = "https://files.pythonhosted.org/packages/76/9a/43eeb0166f1119256d6f43468f900db1aed7fbe32069d2a71c82f987db4d/aiohttp-3.10.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4d46c7b4173415d8e583045fbc4daa48b40e31b19ce595b8d92cf639396c15d5", size = 1338756 }, + { url = "https://files.pythonhosted.org/packages/d5/bc/d01ff0810b3f5e26896f76d44225ed78b088ddd33079b85cd1a23514318b/aiohttp-3.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33e6bc4bab477c772a541f76cd91e11ccb6d2efa2b8d7d7883591dfb523e5987", size = 1299976 }, + { url = "https://files.pythonhosted.org/packages/3e/c9/50a297c4f7ab57a949f4add2d3eafe5f3e68bb42f739e933f8b32a092bda/aiohttp-3.10.5-cp313-cp313-win32.whl", hash = "sha256:c58c6837a2c2a7cf3133983e64173aec11f9c2cd8e87ec2fdc16ce727bcf1a04", size = 355609 }, + { url = "https://files.pythonhosted.org/packages/65/28/aee9d04fb0b3b1f90622c338a08e54af5198e704a910e20947c473298fd0/aiohttp-3.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:38172a70005252b6893088c0f5e8a47d173df7cc2b2bd88650957eb84fcf5022", size = 375697 }, + { url = "https://files.pythonhosted.org/packages/7d/0f/6bcda6528ba2ae65c58e62d63c31ada74b0d761efbb6678d19a447520392/aiohttp-3.10.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7e2fe37ac654032db1f3499fe56e77190282534810e2a8e833141a021faaab0e", size = 588725 }, + { url = "https://files.pythonhosted.org/packages/3b/db/c818fd1c254bcb7af4ca75b69c89ee58807e11d9338348065d1b549a9ee7/aiohttp-3.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5bf3ead3cb66ab990ee2561373b009db5bc0e857549b6c9ba84b20bc462e172", size = 398568 }, + { url = "https://files.pythonhosted.org/packages/6a/56/4a1e41e632c97d2848dfb866a87f6802b9541c0720cc1017a5002cd58873/aiohttp-3.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b2c16a919d936ca87a3c5f0e43af12a89a3ce7ccbce59a2d6784caba945b68b", size = 389860 }, + { url = "https://files.pythonhosted.org/packages/86/d0/c0eb2bbdc2808b80432b6c1a56e2db433fac8c84247f895a30f13be2b68d/aiohttp-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad146dae5977c4dd435eb31373b3fe9b0b1bf26858c6fc452bf6af394067e10b", size = 1253862 }, + { url = "https://files.pythonhosted.org/packages/8e/61/2f46f41bf4491cdfb6d599a486bc426332f103773a4e8003b2b09d2b7b2e/aiohttp-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c5c6fa16412b35999320f5c9690c0f554392dc222c04e559217e0f9ae244b92", size = 1289926 }, + { url = "https://files.pythonhosted.org/packages/88/83/e846ae7546a056e271823c02c3002cc6e722c95bce32582cf3e8578c7b0b/aiohttp-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95c4dc6f61d610bc0ee1edc6f29d993f10febfe5b76bb470b486d90bbece6b22", size = 1325020 }, + { url = "https://files.pythonhosted.org/packages/23/69/200bf165b56c17854d54975f894de10dababc4d0226c07600c9abc679e7e/aiohttp-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da452c2c322e9ce0cfef392e469a26d63d42860f829026a63374fde6b5c5876f", size = 1246350 }, + { url = "https://files.pythonhosted.org/packages/ca/45/d5f6ec14e948d1c72c91f743de5b5f26a476c81151082910002b59c84e0b/aiohttp-3.10.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:898715cf566ec2869d5cb4d5fb4be408964704c46c96b4be267442d265390f32", size = 1216314 }, + { url = "https://files.pythonhosted.org/packages/9b/c7/2078ebb25cfcd0ebadbc451b508f09fe37e3ca3a2fbe3b2791d00912d31c/aiohttp-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:391cc3a9c1527e424c6865e087897e766a917f15dddb360174a70467572ac6ce", size = 1216766 }, + { url = "https://files.pythonhosted.org/packages/d9/59/25f96afdc4f6ba845feb607026b632181b37fc4e3242e4dce3d71a0afaa1/aiohttp-3.10.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:380f926b51b92d02a34119d072f178d80bbda334d1a7e10fa22d467a66e494db", size = 1216190 }, + { url = "https://files.pythonhosted.org/packages/f1/cb/2b6e003cdd3388454b183aabb91b15db9ac5b47eb224d3b6436f938e7380/aiohttp-3.10.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce91db90dbf37bb6fa0997f26574107e1b9d5ff939315247b7e615baa8ec313b", size = 1271316 }, + { url = "https://files.pythonhosted.org/packages/71/1c/1ce6e7a0376ebb861521c96ed47eda1e0c2e9c80c0407e431b46cecc4bfb/aiohttp-3.10.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9093a81e18c45227eebe4c16124ebf3e0d893830c6aca7cc310bfca8fe59d857", size = 1288007 }, + { url = "https://files.pythonhosted.org/packages/01/0c/4e8db6e6d8f3b655d762530a083ea729b5d1ed479ddc55881d845bcd4795/aiohttp-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ee40b40aa753d844162dcc80d0fe256b87cba48ca0054f64e68000453caead11", size = 1238304 }, + { url = "https://files.pythonhosted.org/packages/4c/bd/69a87f5fd0070e339eb4f62d0ca61e87f85bde492746401852cd40f5113c/aiohttp-3.10.5-cp39-cp39-win32.whl", hash = "sha256:03f2645adbe17f274444953bdea69f8327e9d278d961d85657cb0d06864814c1", size = 360755 }, + { url = "https://files.pythonhosted.org/packages/8d/e9/cfdf2e0132860976514439c8a50b57fc8d65715d77eeec0e5b150e9c6a96/aiohttp-3.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:d17920f18e6ee090bdd3d0bfffd769d9f2cb4c8ffde3eb203777a3895c128862", size = 379781 }, +] + +[[package]] +name = "aiosignal" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/67/0952ed97a9793b4958e5736f6d2b346b414a2cd63e82d05940032f45b32f/aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", size = 19422 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/ac/a7305707cb852b7e16ff80eaf5692309bde30e2b1100a1fcacdc8f731d97/aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17", size = 7617 }, +] + +[[package]] +name = "alabaster" +version = "0.7.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511 }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/e3/c4c8d473d6780ef1853d630d581f70d655b4f8d7553c6997958c283039a2/anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94", size = 163930 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/a2/10639a79341f6c019dedc95bd48a4928eed9f1d1197f4c04f546fc7ae0ff/anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7", size = 86780 }, +] + +[[package]] +name = "appdirs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566 }, +] + +[[package]] +name = "async-timeout" +version = "4.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/d6/21b30a550dafea84b1b8eee21b5e23fa16d010ae006011221f33dcd8d7f8/async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", size = 8345 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/fa/e01228c2938de91d47b307831c62ab9e4001e747789d0b05baf779a6488c/async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028", size = 5721 }, +] + +[[package]] +name = "asyncclick" +version = "8.1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/bf/59d836c3433d7aa07f76c2b95c4eb763195ea8a5d7f9ad3311ed30c2af61/asyncclick-8.1.7.2.tar.gz", hash = "sha256:219ea0f29ccdc1bb4ff43bcab7ce0769ac6d48a04f997b43ec6bee99a222daa0", size = 349073 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/6e/9acdbb25733e1de411663b59abe521bec738e72fe4e85843f6ff8b212832/asyncclick-8.1.7.2-py3-none-any.whl", hash = "sha256:1ab940b04b22cb89b5b400725132b069d01b0c3472a9702c7a2c9d5d007ded02", size = 99191 }, +] + +[[package]] +name = "attrs" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/0f/aafca9af9315aee06a89ffde799a10a582fe8de76c563ee80bbcdc08b3fb/attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", size = 792678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 }, +] + +[[package]] +name = "babel" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/74/f1bc80f23eeba13393b7222b11d95ca3af2c1e28edca18af487137eefed9/babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316", size = 9348104 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/20/bc79bc575ba2e2a7f70e8a1155618bb1301eaa5132a8271373a6903f73f8/babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", size = 9587599 }, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, + { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220 }, + { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605 }, + { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910 }, + { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200 }, + { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565 }, + { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635 }, + { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218 }, + { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486 }, + { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911 }, + { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632 }, + { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820 }, + { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290 }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/09/c1bc53dab74b1816a00d8d030de5bf98f724c52c1635e07681d312f20be8/charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", size = 104809 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/61/095a0aa1a84d1481998b534177c8566fdc50bb1233ea9a0478cd3cc075bd/charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", size = 194219 }, + { url = "https://files.pythonhosted.org/packages/cc/94/f7cf5e5134175de79ad2059edf2adce18e0685ebdb9227ff0139975d0e93/charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", size = 122521 }, + { url = "https://files.pythonhosted.org/packages/46/6a/d5c26c41c49b546860cc1acabdddf48b0b3fb2685f4f5617ac59261b44ae/charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", size = 120383 }, + { url = "https://files.pythonhosted.org/packages/b8/60/e2f67915a51be59d4539ed189eb0a2b0d292bf79270410746becb32bc2c3/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", size = 138223 }, + { url = "https://files.pythonhosted.org/packages/05/8c/eb854996d5fef5e4f33ad56927ad053d04dc820e4a3d39023f35cad72617/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", size = 148101 }, + { url = "https://files.pythonhosted.org/packages/f6/93/bb6cbeec3bf9da9b2eba458c15966658d1daa8b982c642f81c93ad9b40e1/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", size = 140699 }, + { url = "https://files.pythonhosted.org/packages/da/f1/3702ba2a7470666a62fd81c58a4c40be00670e5006a67f4d626e57f013ae/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", size = 142065 }, + { url = "https://files.pythonhosted.org/packages/3f/ba/3f5e7be00b215fa10e13d64b1f6237eb6ebea66676a41b2bcdd09fe74323/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", size = 144505 }, + { url = "https://files.pythonhosted.org/packages/33/c3/3b96a435c5109dd5b6adc8a59ba1d678b302a97938f032e3770cc84cd354/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", size = 139425 }, + { url = "https://files.pythonhosted.org/packages/43/05/3bf613e719efe68fb3a77f9c536a389f35b95d75424b96b426a47a45ef1d/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", size = 145287 }, + { url = "https://files.pythonhosted.org/packages/58/78/a0bc646900994df12e07b4ae5c713f2b3e5998f58b9d3720cce2aa45652f/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", size = 149929 }, + { url = "https://files.pythonhosted.org/packages/eb/5c/97d97248af4920bc68687d9c3b3c0f47c910e21a8ff80af4565a576bd2f0/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", size = 141605 }, + { url = "https://files.pythonhosted.org/packages/a8/31/47d018ef89f95b8aded95c589a77c072c55e94b50a41aa99c0a2008a45a4/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", size = 142646 }, + { url = "https://files.pythonhosted.org/packages/ae/d5/4fecf1d58bedb1340a50f165ba1c7ddc0400252d6832ff619c4568b36cc0/charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", size = 92846 }, + { url = "https://files.pythonhosted.org/packages/a2/a0/4af29e22cb5942488cf45630cbdd7cefd908768e69bdd90280842e4e8529/charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", size = 100343 }, + { url = "https://files.pythonhosted.org/packages/68/77/02839016f6fbbf808e8b38601df6e0e66c17bbab76dff4613f7511413597/charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", size = 191647 }, + { url = "https://files.pythonhosted.org/packages/3e/33/21a875a61057165e92227466e54ee076b73af1e21fe1b31f1e292251aa1e/charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", size = 121434 }, + { url = "https://files.pythonhosted.org/packages/dd/51/68b61b90b24ca35495956b718f35a9756ef7d3dd4b3c1508056fa98d1a1b/charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", size = 118979 }, + { url = "https://files.pythonhosted.org/packages/e4/a6/7ee57823d46331ddc37dd00749c95b0edec2c79b15fc0d6e6efb532e89ac/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", size = 136582 }, + { url = "https://files.pythonhosted.org/packages/74/f1/0d9fe69ac441467b737ba7f48c68241487df2f4522dd7246d9426e7c690e/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", size = 146645 }, + { url = "https://files.pythonhosted.org/packages/05/31/e1f51c76db7be1d4aef220d29fbfa5dbb4a99165d9833dcbf166753b6dc0/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", size = 139398 }, + { url = "https://files.pythonhosted.org/packages/40/26/f35951c45070edc957ba40a5b1db3cf60a9dbb1b350c2d5bef03e01e61de/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", size = 140273 }, + { url = "https://files.pythonhosted.org/packages/07/07/7e554f2bbce3295e191f7e653ff15d55309a9ca40d0362fcdab36f01063c/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", size = 142577 }, + { url = "https://files.pythonhosted.org/packages/d8/b5/eb705c313100defa57da79277d9207dc8d8e45931035862fa64b625bfead/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", size = 137747 }, + { url = "https://files.pythonhosted.org/packages/19/28/573147271fd041d351b438a5665be8223f1dd92f273713cb882ddafe214c/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", size = 143375 }, + { url = "https://files.pythonhosted.org/packages/cf/7c/f3b682fa053cc21373c9a839e6beba7705857075686a05c72e0f8c4980ca/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", size = 148474 }, + { url = "https://files.pythonhosted.org/packages/1e/49/7ab74d4ac537ece3bc3334ee08645e231f39f7d6df6347b29a74b0537103/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", size = 140232 }, + { url = "https://files.pythonhosted.org/packages/2d/dc/9dacba68c9ac0ae781d40e1a0c0058e26302ea0660e574ddf6797a0347f7/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", size = 140859 }, + { url = "https://files.pythonhosted.org/packages/6c/c2/4a583f800c0708dd22096298e49f887b49d9746d0e78bfc1d7e29816614c/charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", size = 92509 }, + { url = "https://files.pythonhosted.org/packages/57/ec/80c8d48ac8b1741d5b963797b7c0c869335619e13d4744ca2f67fc11c6fc/charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", size = 99870 }, + { url = "https://files.pythonhosted.org/packages/d1/b2/fcedc8255ec42afee97f9e6f0145c734bbe104aac28300214593eb326f1d/charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", size = 192892 }, + { url = "https://files.pythonhosted.org/packages/2e/7d/2259318c202f3d17f3fe6438149b3b9e706d1070fe3fcbb28049730bb25c/charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", size = 122213 }, + { url = "https://files.pythonhosted.org/packages/3a/52/9f9d17c3b54dc238de384c4cb5a2ef0e27985b42a0e5cc8e8a31d918d48d/charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", size = 119404 }, + { url = "https://files.pythonhosted.org/packages/99/b0/9c365f6d79a9f0f3c379ddb40a256a67aa69c59609608fe7feb6235896e1/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", size = 137275 }, + { url = "https://files.pythonhosted.org/packages/91/33/749df346e93d7a30cdcb90cbfdd41a06026317bfbfb62cd68307c1a3c543/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", size = 147518 }, + { url = "https://files.pythonhosted.org/packages/72/1a/641d5c9f59e6af4c7b53da463d07600a695b9824e20849cb6eea8a627761/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", size = 140182 }, + { url = "https://files.pythonhosted.org/packages/ee/fb/14d30eb4956408ee3ae09ad34299131fb383c47df355ddb428a7331cfa1e/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", size = 141869 }, + { url = "https://files.pythonhosted.org/packages/df/3e/a06b18788ca2eb6695c9b22325b6fde7dde0f1d1838b1792a0076f58fe9d/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", size = 144042 }, + { url = "https://files.pythonhosted.org/packages/45/59/3d27019d3b447a88fe7e7d004a1e04be220227760264cc41b405e863891b/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", size = 138275 }, + { url = "https://files.pythonhosted.org/packages/7b/ef/5eb105530b4da8ae37d506ccfa25057961b7b63d581def6f99165ea89c7e/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", size = 144819 }, + { url = "https://files.pythonhosted.org/packages/a2/51/e5023f937d7f307c948ed3e5c29c4b7a3e42ed2ee0b8cdf8f3a706089bf0/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", size = 149415 }, + { url = "https://files.pythonhosted.org/packages/24/9d/2e3ef673dfd5be0154b20363c5cdcc5606f35666544381bee15af3778239/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", size = 141212 }, + { url = "https://files.pythonhosted.org/packages/5b/ae/ce2c12fcac59cb3860b2e2d76dc405253a4475436b1861d95fe75bdea520/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", size = 142167 }, + { url = "https://files.pythonhosted.org/packages/ed/3a/a448bf035dce5da359daf9ae8a16b8a39623cc395a2ffb1620aa1bce62b0/charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", size = 93041 }, + { url = "https://files.pythonhosted.org/packages/b6/7c/8debebb4f90174074b827c63242c23851bdf00a532489fba57fef3416e40/charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", size = 100397 }, + { url = "https://files.pythonhosted.org/packages/f7/9d/bcf4a449a438ed6f19790eee543a86a740c77508fbc5ddab210ab3ba3a9a/charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", size = 194198 }, + { url = "https://files.pythonhosted.org/packages/66/fe/c7d3da40a66a6bf2920cce0f436fa1f62ee28aaf92f412f0bf3b84c8ad6c/charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", size = 122494 }, + { url = "https://files.pythonhosted.org/packages/2a/9d/a6d15bd1e3e2914af5955c8eb15f4071997e7078419328fee93dfd497eb7/charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", size = 120393 }, + { url = "https://files.pythonhosted.org/packages/3d/85/5b7416b349609d20611a64718bed383b9251b5a601044550f0c8983b8900/charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", size = 138331 }, + { url = "https://files.pythonhosted.org/packages/79/66/8946baa705c588521afe10b2d7967300e49380ded089a62d38537264aece/charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", size = 148097 }, + { url = "https://files.pythonhosted.org/packages/44/80/b339237b4ce635b4af1c73742459eee5f97201bd92b2371c53e11958392e/charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", size = 140711 }, + { url = "https://files.pythonhosted.org/packages/98/69/5d8751b4b670d623aa7a47bef061d69c279e9f922f6705147983aa76c3ce/charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", size = 142251 }, + { url = "https://files.pythonhosted.org/packages/1f/8d/33c860a7032da5b93382cbe2873261f81467e7b37f4ed91e25fed62fd49b/charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", size = 144636 }, + { url = "https://files.pythonhosted.org/packages/c2/65/52aaf47b3dd616c11a19b1052ce7fa6321250a7a0b975f48d8c366733b9f/charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", size = 139514 }, + { url = "https://files.pythonhosted.org/packages/51/fd/0ee5b1c2860bb3c60236d05b6e4ac240cf702b67471138571dad91bcfed8/charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", size = 145528 }, + { url = "https://files.pythonhosted.org/packages/e1/9c/60729bf15dc82e3aaf5f71e81686e42e50715a1399770bcde1a9e43d09db/charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", size = 149804 }, + { url = "https://files.pythonhosted.org/packages/53/cd/aa4b8a4d82eeceb872f83237b2d27e43e637cac9ffaef19a1321c3bafb67/charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", size = 141708 }, + { url = "https://files.pythonhosted.org/packages/54/7f/cad0b328759630814fcf9d804bfabaf47776816ad4ef2e9938b7e1123d04/charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561", size = 142708 }, + { url = "https://files.pythonhosted.org/packages/c1/9d/254a2f1bcb0ce9acad838e94ed05ba71a7cb1e27affaa4d9e1ca3958cdb6/charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", size = 92830 }, + { url = "https://files.pythonhosted.org/packages/2f/0e/d7303ccae9735ff8ff01e36705ad6233ad2002962e8668a970fc000c5e1b/charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", size = 100376 }, + { url = "https://files.pythonhosted.org/packages/28/76/e6222113b83e3622caa4bb41032d0b1bf785250607392e1b778aca0b8a7d/charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", size = 48543 }, +] + +[[package]] +name = "codecov" +version = "2.1.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/bb/594b26d2c85616be6195a64289c578662678afa4910cef2d3ce8417cf73e/codecov-2.1.13.tar.gz", hash = "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c", size = 21416 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/02/18785edcdf6266cdd6c6dc7635f1cbeefd9a5b4c3bb8aff8bd681e9dd095/codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5", size = 16512 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "coverage" +version = "7.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/61/eb7ce5ed62bacf21beca4937a90fe32545c91a3c8a42a30c6616d48fc70d/coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", size = 206690 }, + { url = "https://files.pythonhosted.org/packages/7d/73/041928e434442bd3afde5584bdc3f932fb4562b1597629f537387cec6f3d/coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", size = 207127 }, + { url = "https://files.pythonhosted.org/packages/c7/c8/6ca52b5147828e45ad0242388477fdb90df2c6cbb9a441701a12b3c71bc8/coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", size = 235654 }, + { url = "https://files.pythonhosted.org/packages/d5/da/9ac2b62557f4340270942011d6efeab9833648380109e897d48ab7c1035d/coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc", size = 233598 }, + { url = "https://files.pythonhosted.org/packages/53/23/9e2c114d0178abc42b6d8d5281f651a8e6519abfa0ef460a00a91f80879d/coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", size = 234732 }, + { url = "https://files.pythonhosted.org/packages/0f/7e/a0230756fb133343a52716e8b855045f13342b70e48e8ad41d8a0d60ab98/coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", size = 233816 }, + { url = "https://files.pythonhosted.org/packages/28/7c/3753c8b40d232b1e5eeaed798c875537cf3cb183fb5041017c1fdb7ec14e/coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", size = 232325 }, + { url = "https://files.pythonhosted.org/packages/57/e3/818a2b2af5b7573b4b82cf3e9f137ab158c90ea750a8f053716a32f20f06/coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", size = 233418 }, + { url = "https://files.pythonhosted.org/packages/c8/fb/4532b0b0cefb3f06d201648715e03b0feb822907edab3935112b61b885e2/coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", size = 209343 }, + { url = "https://files.pythonhosted.org/packages/5a/25/af337cc7421eca1c187cc9c315f0a755d48e755d2853715bfe8c418a45fa/coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", size = 210136 }, + { url = "https://files.pythonhosted.org/packages/ad/5f/67af7d60d7e8ce61a4e2ddcd1bd5fb787180c8d0ae0fbd073f903b3dd95d/coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", size = 206796 }, + { url = "https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", size = 207244 }, + { url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279 }, + { url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859 }, + { url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549 }, + { url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477 }, + { url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134 }, + { url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910 }, + { url = "https://files.pythonhosted.org/packages/7d/30/033e663399ff17dca90d793ee8a2ea2890e7fdf085da58d82468b4220bf7/coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", size = 209348 }, + { url = "https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", size = 210230 }, + { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983 }, + { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221 }, + { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342 }, + { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371 }, + { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455 }, + { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924 }, + { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252 }, + { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897 }, + { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606 }, + { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373 }, + { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007 }, + { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269 }, + { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886 }, + { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037 }, + { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038 }, + { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690 }, + { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765 }, + { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611 }, + { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671 }, + { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368 }, + { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758 }, + { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035 }, + { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839 }, + { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569 }, + { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927 }, + { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401 }, + { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301 }, + { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598 }, + { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307 }, + { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453 }, + { url = "https://files.pythonhosted.org/packages/19/d3/d54c5aa83268779d54c86deb39c1c4566e5d45c155369ca152765f8db413/coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", size = 206688 }, + { url = "https://files.pythonhosted.org/packages/a5/fe/137d5dca72e4a258b1bc17bb04f2e0196898fe495843402ce826a7419fe3/coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", size = 207120 }, + { url = "https://files.pythonhosted.org/packages/78/5b/a0a796983f3201ff5485323b225d7c8b74ce30c11f456017e23d8e8d1945/coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", size = 235249 }, + { url = "https://files.pythonhosted.org/packages/4e/e1/76089d6a5ef9d68f018f65411fcdaaeb0141b504587b901d74e8587606ad/coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", size = 233237 }, + { url = "https://files.pythonhosted.org/packages/9a/6f/eef79b779a540326fee9520e5542a8b428cc3bfa8b7c8f1022c1ee4fc66c/coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", size = 234311 }, + { url = "https://files.pythonhosted.org/packages/75/e1/656d65fb126c29a494ef964005702b012f3498db1a30dd562958e85a4049/coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", size = 233453 }, + { url = "https://files.pythonhosted.org/packages/68/6a/45f108f137941a4a1238c85f28fd9d048cc46b5466d6b8dda3aba1bb9d4f/coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", size = 231958 }, + { url = "https://files.pythonhosted.org/packages/9b/e7/47b809099168b8b8c72ae311efc3e88c8d8a1162b3ba4b8da3cfcdb85743/coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", size = 232938 }, + { url = "https://files.pythonhosted.org/packages/52/80/052222ba7058071f905435bad0ba392cc12006380731c37afaf3fe749b88/coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", size = 209352 }, + { url = "https://files.pythonhosted.org/packages/b8/d8/1b92e0b3adcf384e98770a00ca095da1b5f7b483e6563ae4eb5e935d24a1/coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", size = 210153 }, + { url = "https://files.pythonhosted.org/packages/a5/2b/0354ed096bca64dc8e32a7cbcae28b34cb5ad0b1fe2125d6d99583313ac0/coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", size = 198926 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "43.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ba/0664727028b37e249e73879348cc46d45c5c1a2a2e81e8166462953c5755/cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d", size = 686927 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/28/b92c98a04ba762f8cdeb54eba5c4c84e63cac037a7c5e70117d337b15ad6/cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d", size = 6223222 }, + { url = "https://files.pythonhosted.org/packages/33/13/1193774705783ba364121aa2a60132fa31a668b8ababd5edfa1662354ccd/cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062", size = 3794751 }, + { url = "https://files.pythonhosted.org/packages/5e/4b/39bb3c4c8cfb3e94e736b8d8859ce5c81536e91a1033b1d26770c4249000/cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962", size = 3981827 }, + { url = "https://files.pythonhosted.org/packages/ce/dc/1471d4d56608e1013237af334b8a4c35d53895694fbb73882d1c4fd3f55e/cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277", size = 3780034 }, + { url = "https://files.pythonhosted.org/packages/ad/43/7a9920135b0d5437cc2f8f529fa757431eb6a7736ddfadfdee1cc5890800/cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a", size = 3993407 }, + { url = "https://files.pythonhosted.org/packages/cc/42/9ab8467af6c0b76f3d9b8f01d1cf25b9c9f3f2151f4acfab888d21c55a72/cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042", size = 3886457 }, + { url = "https://files.pythonhosted.org/packages/a4/65/430509e31700286ec02868a2457d2111d03ccefc20349d24e58d171ae0a7/cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494", size = 4081499 }, + { url = "https://files.pythonhosted.org/packages/bb/18/a04b6467e6e09df8c73b91dcee8878f4a438a43a3603dc3cd6f8003b92d8/cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2", size = 2616504 }, + { url = "https://files.pythonhosted.org/packages/cc/73/0eacbdc437202edcbdc07f3576ed8fb8b0ab79d27bf2c5d822d758a72faa/cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d", size = 3067456 }, + { url = "https://files.pythonhosted.org/packages/8a/b6/bc54b371f02cffd35ff8dc6baba88304d7cf8e83632566b4b42e00383e03/cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d", size = 6225263 }, + { url = "https://files.pythonhosted.org/packages/00/0e/8217e348a1fa417ec4c78cd3cdf24154f5e76fd7597343a35bd403650dfd/cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806", size = 3794368 }, + { url = "https://files.pythonhosted.org/packages/3d/ed/38b6be7254d8f7251fde8054af597ee8afa14f911da67a9410a45f602fc3/cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85", size = 3981750 }, + { url = "https://files.pythonhosted.org/packages/64/f3/b7946c3887cf7436f002f4cbb1e6aec77b8d299b86be48eeadfefb937c4b/cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c", size = 3778925 }, + { url = "https://files.pythonhosted.org/packages/ac/7e/ebda4dd4ae098a0990753efbb4b50954f1d03003846b943ea85070782da7/cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1", size = 3993152 }, + { url = "https://files.pythonhosted.org/packages/43/f6/feebbd78a3e341e3913846a3bb2c29d0b09b1b3af1573c6baabc2533e147/cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa", size = 3886392 }, + { url = "https://files.pythonhosted.org/packages/bd/4c/ab0b9407d5247576290b4fd8abd06b7f51bd414f04eef0f2800675512d61/cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4", size = 4082606 }, + { url = "https://files.pythonhosted.org/packages/05/36/e532a671998d6fcfdb9122da16434347a58a6bae9465e527e450e0bc60a5/cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47", size = 2617948 }, + { url = "https://files.pythonhosted.org/packages/b3/c6/c09cee6968add5ff868525c3815e5dccc0e3c6e89eec58dc9135d3c40e88/cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb", size = 3070445 }, + { url = "https://files.pythonhosted.org/packages/18/23/4175dcd935e1649865e1af7bd0b827cc9d9769a586dcc84f7cbe96839086/cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034", size = 3152694 }, + { url = "https://files.pythonhosted.org/packages/ea/45/967da50269954b993d4484bf85026c7377bd551651ebdabba94905972556/cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d", size = 3713077 }, + { url = "https://files.pythonhosted.org/packages/df/e6/ccd29a1f9a6b71294e1e9f530c4d779d5dd37c8bb736c05d5fb6d98a971b/cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289", size = 3915597 }, + { url = "https://files.pythonhosted.org/packages/a2/80/fb7d668f1be5e4443b7ac191f68390be24f7c2ebd36011741f62c7645eb2/cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84", size = 2989208 }, + { url = "https://files.pythonhosted.org/packages/b2/aa/782e42ccf854943dfce72fb94a8d62220f22084ff07076a638bc3f34f3cc/cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365", size = 3154685 }, + { url = "https://files.pythonhosted.org/packages/3e/fd/70f3e849ad4d6cca2118ee6938e0b52326d02406f10912356151dd4b6868/cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96", size = 3713909 }, + { url = "https://files.pythonhosted.org/packages/21/b0/4ecefa99519eaa32af49a3ad002bb3e795f9e6eb32221fd87736247fa3cb/cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172", size = 3916544 }, + { url = "https://files.pythonhosted.org/packages/8c/42/2948dd87b237565c77b28b674d972c7f983ffa3977dc8b8ad0736f6a7d97/cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2", size = 2989774 }, +] + +[[package]] +name = "distlib" +version = "0.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/91/e2df406fb4efacdf46871c25cde65d3c6ee5e173b7e5a4547a47bae91920/distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64", size = 609931 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/41/9307e4f5f9976bc8b7fea0b66367734e8faf3ec84bc0d412d8cfabbb66cd/distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", size = 468850 }, +] + +[[package]] +name = "docutils" +version = "0.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/330ea8d383eb2ce973df34d1239b3b21e91cd8c865d21ff82902d952f91f/docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6", size = 2056383 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/69/e391bd51bc08ed9141ecd899a0ddb61ab6465309f1eb470905c0c8868081/docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc", size = 570472 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "filelock" +version = "3.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/dd/49e06f09b6645156550fb9aee9cc1e59aba7efbc972d665a1bd6ae0435d4/filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb", size = 18007 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/f0/48285f0262fe47103a4a45972ed2f9b93e4c80b8fd609fa98da78b2a5706/filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7", size = 16159 }, +] + +[[package]] +name = "freezegun" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/ef/722b8d71ddf4d48f25f6d78aa2533d505bf3eec000a7cacb8ccc8de61f2f/freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9", size = 33697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/0b/0d7fee5919bccc1fdc1c2a7528b98f65c6f69b223a3fd8f809918c142c36/freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1", size = 17569 }, +] + +[[package]] +name = "frozenlist" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/3d/2102257e7acad73efc4a0c306ad3953f68c504c16982bbdfee3ad75d8085/frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b", size = 37820 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/35/1328c7b0f780d34f8afc1d87ebdc2bb065a123b24766a0b475f0d67da637/frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac", size = 94315 }, + { url = "https://files.pythonhosted.org/packages/f4/d6/ca016b0adcf8327714ccef969740688808c86e0287bf3a639ff582f24e82/frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868", size = 53805 }, + { url = "https://files.pythonhosted.org/packages/ae/83/bcdaa437a9bd693ba658a0310f8cdccff26bd78e45fccf8e49897904a5cd/frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776", size = 52163 }, + { url = "https://files.pythonhosted.org/packages/d4/e9/759043ab7d169b74fe05ebfbfa9ee5c881c303ebc838e308346204309cd0/frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a", size = 238595 }, + { url = "https://files.pythonhosted.org/packages/f8/ce/b9de7dc61e753dc318cf0de862181b484178210c5361eae6eaf06792264d/frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad", size = 262428 }, + { url = "https://files.pythonhosted.org/packages/36/ce/dc6f29e0352fa34ebe45421960c8e7352ca63b31630a576e8ffb381e9c08/frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c", size = 258867 }, + { url = "https://files.pythonhosted.org/packages/51/47/159ac53faf8a11ae5ee8bb9db10327575557504e549cfd76f447b969aa91/frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe", size = 229412 }, + { url = "https://files.pythonhosted.org/packages/ec/25/0c87df2e53c0c5d90f7517ca0ff7aca78d050a8ec4d32c4278e8c0e52e51/frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a", size = 239539 }, + { url = "https://files.pythonhosted.org/packages/97/94/a1305fa4716726ae0abf3b1069c2d922fcfd442538cb850f1be543f58766/frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98", size = 253379 }, + { url = "https://files.pythonhosted.org/packages/53/82/274e19f122e124aee6d113188615f63b0736b4242a875f482a81f91e07e2/frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75", size = 245901 }, + { url = "https://files.pythonhosted.org/packages/b8/28/899931015b8cffbe155392fe9ca663f981a17e1adc69589ee0e1e7cdc9a2/frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5", size = 263797 }, + { url = "https://files.pythonhosted.org/packages/6e/4f/b8a5a2f10c4a58c52a52a40cf6cf1ffcdbf3a3b64f276f41dab989bf3ab5/frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950", size = 264415 }, + { url = "https://files.pythonhosted.org/packages/b0/2c/7be3bdc59dbae444864dbd9cde82790314390ec54636baf6b9ce212627ad/frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc", size = 253964 }, + { url = "https://files.pythonhosted.org/packages/2e/ec/4fb5a88f6b9a352aed45ab824dd7ce4801b7bcd379adcb927c17a8f0a1a8/frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1", size = 44559 }, + { url = "https://files.pythonhosted.org/packages/61/15/2b5d644d81282f00b61e54f7b00a96f9c40224107282efe4cd9d2bf1433a/frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439", size = 50434 }, + { url = "https://files.pythonhosted.org/packages/01/bc/8d33f2d84b9368da83e69e42720cff01c5e199b5a868ba4486189a4d8fa9/frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0", size = 97060 }, + { url = "https://files.pythonhosted.org/packages/af/b2/904500d6a162b98a70e510e743e7ea992241b4f9add2c8063bf666ca21df/frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49", size = 55347 }, + { url = "https://files.pythonhosted.org/packages/5b/9c/f12b69997d3891ddc0d7895999a00b0c6a67f66f79498c0e30f27876435d/frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced", size = 53374 }, + { url = "https://files.pythonhosted.org/packages/ac/6e/e0322317b7c600ba21dec224498c0c5959b2bce3865277a7c0badae340a9/frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0", size = 273288 }, + { url = "https://files.pythonhosted.org/packages/a7/76/180ee1b021568dad5b35b7678616c24519af130ed3fa1e0f1ed4014e0f93/frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106", size = 284737 }, + { url = "https://files.pythonhosted.org/packages/05/08/40159d706a6ed983c8aca51922a93fc69f3c27909e82c537dd4054032674/frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068", size = 280267 }, + { url = "https://files.pythonhosted.org/packages/e0/18/9f09f84934c2b2aa37d539a322267939770362d5495f37783440ca9c1b74/frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2", size = 258778 }, + { url = "https://files.pythonhosted.org/packages/b3/c9/0bc5ee7e1f5cc7358ab67da0b7dfe60fbd05c254cea5c6108e7d1ae28c63/frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19", size = 272276 }, + { url = "https://files.pythonhosted.org/packages/12/5d/147556b73a53ad4df6da8bbb50715a66ac75c491fdedac3eca8b0b915345/frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82", size = 272424 }, + { url = "https://files.pythonhosted.org/packages/83/61/2087bbf24070b66090c0af922685f1d0596c24bb3f3b5223625bdeaf03ca/frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec", size = 260881 }, + { url = "https://files.pythonhosted.org/packages/a8/be/a235bc937dd803258a370fe21b5aa2dd3e7bfe0287a186a4bec30c6cccd6/frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a", size = 282327 }, + { url = "https://files.pythonhosted.org/packages/5d/e7/b2469e71f082948066b9382c7b908c22552cc705b960363c390d2e23f587/frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74", size = 281502 }, + { url = "https://files.pythonhosted.org/packages/db/1b/6a5b970e55dffc1a7d0bb54f57b184b2a2a2ad0b7bca16a97ca26d73c5b5/frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2", size = 272292 }, + { url = "https://files.pythonhosted.org/packages/1a/05/ebad68130e6b6eb9b287dacad08ea357c33849c74550c015b355b75cc714/frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17", size = 44446 }, + { url = "https://files.pythonhosted.org/packages/b3/21/c5aaffac47fd305d69df46cfbf118768cdf049a92ee6b0b5cb029d449dcf/frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825", size = 50459 }, + { url = "https://files.pythonhosted.org/packages/b4/db/4cf37556a735bcdb2582f2c3fa286aefde2322f92d3141e087b8aeb27177/frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae", size = 93937 }, + { url = "https://files.pythonhosted.org/packages/46/03/69eb64642ca8c05f30aa5931d6c55e50b43d0cd13256fdd01510a1f85221/frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb", size = 53656 }, + { url = "https://files.pythonhosted.org/packages/3f/ab/c543c13824a615955f57e082c8a5ee122d2d5368e80084f2834e6f4feced/frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b", size = 51868 }, + { url = "https://files.pythonhosted.org/packages/a9/b8/438cfd92be2a124da8259b13409224d9b19ef8f5a5b2507174fc7e7ea18f/frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86", size = 280652 }, + { url = "https://files.pythonhosted.org/packages/54/72/716a955521b97a25d48315c6c3653f981041ce7a17ff79f701298195bca3/frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480", size = 286739 }, + { url = "https://files.pythonhosted.org/packages/65/d8/934c08103637567084568e4d5b4219c1016c60b4d29353b1a5b3587827d6/frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09", size = 289447 }, + { url = "https://files.pythonhosted.org/packages/70/bb/d3b98d83ec6ef88f9bd63d77104a305d68a146fd63a683569ea44c3085f6/frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a", size = 265466 }, + { url = "https://files.pythonhosted.org/packages/0b/f2/b8158a0f06faefec33f4dff6345a575c18095a44e52d4f10c678c137d0e0/frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd", size = 281530 }, + { url = "https://files.pythonhosted.org/packages/ea/a2/20882c251e61be653764038ece62029bfb34bd5b842724fff32a5b7a2894/frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6", size = 281295 }, + { url = "https://files.pythonhosted.org/packages/4c/f9/8894c05dc927af2a09663bdf31914d4fb5501653f240a5bbaf1e88cab1d3/frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1", size = 268054 }, + { url = "https://files.pythonhosted.org/packages/37/ff/a613e58452b60166507d731812f3be253eb1229808e59980f0405d1eafbf/frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b", size = 286904 }, + { url = "https://files.pythonhosted.org/packages/cc/6e/0091d785187f4c2020d5245796d04213f2261ad097e0c1cf35c44317d517/frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e", size = 290754 }, + { url = "https://files.pythonhosted.org/packages/a5/c2/e42ad54bae8bcffee22d1e12a8ee6c7717f7d5b5019261a8c861854f4776/frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8", size = 282602 }, + { url = "https://files.pythonhosted.org/packages/b6/61/56bad8cb94f0357c4bc134acc30822e90e203b5cb8ff82179947de90c17f/frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89", size = 44063 }, + { url = "https://files.pythonhosted.org/packages/3e/dc/96647994a013bc72f3d453abab18340b7f5e222b7b7291e3697ca1fcfbd5/frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5", size = 50452 }, + { url = "https://files.pythonhosted.org/packages/d3/fb/6f2a22086065bc16797f77168728f0e59d5b89be76dd184e06b404f1e43b/frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e", size = 97291 }, + { url = "https://files.pythonhosted.org/packages/4d/23/7f01123d0e5adcc65cbbde5731378237dea7db467abd19e391f1ddd4130d/frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d", size = 55249 }, + { url = "https://files.pythonhosted.org/packages/8b/c9/a81e9af48291954a883d35686f32308238dc968043143133b8ac9e2772af/frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8", size = 53676 }, + { url = "https://files.pythonhosted.org/packages/57/15/172af60c7e150a1d88ecc832f2590721166ae41eab582172fe1e9844eab4/frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0", size = 239365 }, + { url = "https://files.pythonhosted.org/packages/8c/a4/3dc43e259960ad268ef8f2bf92912c2d2cd2e5275a4838804e03fd6f085f/frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b", size = 265592 }, + { url = "https://files.pythonhosted.org/packages/a0/c1/458cf031fc8cd29a751e305b1ec773785ce486106451c93986562c62a21e/frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0", size = 261274 }, + { url = "https://files.pythonhosted.org/packages/4a/32/21329084b61a119ecce0b2942d30312a34a7a0dccd01dcf7b40bda80f22c/frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897", size = 230787 }, + { url = "https://files.pythonhosted.org/packages/70/b0/6f1ebdabfb604e39a0f84428986b89ab55f246b64cddaa495f2c953e1f6b/frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7", size = 240674 }, + { url = "https://files.pythonhosted.org/packages/a3/05/50c53f1cdbfdf3d2cb9582a4ea5e12cd939ce33bd84403e6d07744563486/frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742", size = 255712 }, + { url = "https://files.pythonhosted.org/packages/b8/3d/cbc6f057f7d10efb7f1f410e458ac090f30526fd110ed2b29bb56ec38fe1/frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea", size = 247618 }, + { url = "https://files.pythonhosted.org/packages/96/86/d5e9cd583aed98c9ee35a3aac2ce4d022ce9de93518e963aadf34a18143b/frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5", size = 266868 }, + { url = "https://files.pythonhosted.org/packages/0f/6e/542af762beb9113f13614a590cafe661e0e060cddddee6107c8833605776/frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9", size = 266439 }, + { url = "https://files.pythonhosted.org/packages/ea/db/8b611e23fda75da5311b698730a598df54cfe6236678001f449b1dedb241/frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6", size = 256677 }, + { url = "https://files.pythonhosted.org/packages/eb/06/732cefc0c46c638e4426a859a372a50e4c9d62e65dbfa7ddcf0b13e6a4f2/frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932", size = 44825 }, + { url = "https://files.pythonhosted.org/packages/29/eb/2110c4be2f622e87864e433efd7c4ee6e4f8a59ff2a93c1aa426ee50a8b8/frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0", size = 50652 }, + { url = "https://files.pythonhosted.org/packages/83/10/466fe96dae1bff622021ee687f68e5524d6392b0a2f80d05001cd3a451ba/frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7", size = 11552 }, +] + +[[package]] +name = "identify" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/f4/8e8f7db397a7ce20fbdeac5f25adaf567fc362472432938d25556008e03a/identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf", size = 99116 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/6c/a4f39abe7f19600b74528d0c717b52fff0b300bb0161081510d39c53cb00/identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0", size = 98962 }, +] + +[[package]] +name = "idna" +version = "3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/e349c5e6d4543326c6883ee9491e3921e0d07b55fdf3cce184b40d63e72a/idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603", size = 189467 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/7e/d71db821f177828df9dea8c42ac46473366f191be53080e552e628aad991/idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac", size = 66894 }, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769 }, +] + +[[package]] +name = "importlib-metadata" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/bd/fa8ce65b0a7d4b6d143ec23b0f5fd3f7ab80121078c465bc02baeaab22dc/importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5", size = 54320 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/14/362d31bf1076b21e1bcdcb0dc61944822ff263937b804a79231df2774d28/importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1", size = 26269 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "jedi" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/99b493cec4bf43176b678de30f81ed003fd6a647a301b9c927280c600f0a/jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd", size = 1227821 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/9f/bc63f0f0737ad7a60800bfd472a4836661adae21f9c2535f3957b1e54ceb/jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0", size = 1569361 }, +] + +[[package]] +name = "jinja2" +version = "3.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 }, +] + +[[package]] +name = "kasa-crypt" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ba/f78a63c5b55dc18b39099a1a1bf6569c14ccca47dd342cc4f4d774ec5719/kasa_crypt-0.4.4.tar.gz", hash = "sha256:cc31749e44a309459a71802ae8471a9d5ad6a7656938a44af64b93a8c3873ccd", size = 9306 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a4/6e1405a23097c017651c32c91a7ea97b62f079ae31e370378d4d4e1d9928/kasa_crypt-0.4.4-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:c2791be3a7ac64d0de0c4d0ecf85d33fd8aa5bcfce3148ce4558703e721ca16b", size = 25211 }, + { url = "https://files.pythonhosted.org/packages/c2/da/eb878e182e57a40de2731f0c8b63a0715472c9145f1b3734321f948d6df6/kasa_crypt-0.4.4-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:2da1d08151690ab6ade7a80168238964eb7672ddd3defb5188c713411b210a6a", size = 81852 }, + { url = "https://files.pythonhosted.org/packages/bb/21/905fe8d59d9ba34bf405cb14d17a0d7ba2595de81c81e5f22e226e64c08e/kasa_crypt-0.4.4-cp310-cp310-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c8db609ec73173c48519f860b2455b311a098b7203573fb8ae0ab52862d603d", size = 85252 }, + { url = "https://files.pythonhosted.org/packages/28/c6/1ec6c5854192e5dfe88943b0396a30fc0bd0aa0e1d2a6982ebc41149cd48/kasa_crypt-0.4.4-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:599b3eed3cadc79dda4e826f96740ddee1f6fcdd4b52a6a922395afad6154fb7", size = 84423 }, + { url = "https://files.pythonhosted.org/packages/d7/15/a5a99d7c5a2623406f924a2018610b3382a312c4045a5aa9591345cab7e7/kasa_crypt-0.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ca1caa741be2e67fd4c84098ecd8d8c2ce1c19330e737435edaef541b867d34a", size = 85399 }, + { url = "https://files.pythonhosted.org/packages/13/fc/cedfa52dd8a0e2fc12c408f43041462ff2093133bd47ee8c760f5c003b03/kasa_crypt-0.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d027d808e22dc944a23f4f1211fc0fe25e648498ff3817b9d78444bc75cc8d45", size = 88154 }, + { url = "https://files.pythonhosted.org/packages/b9/11/d09794b62ccf5c9a1ba84fafdadfa04ad1ed8654efc765cdc69c35e90e72/kasa_crypt-0.4.4-cp310-cp310-win32.whl", hash = "sha256:28918bb02bd4a87aab3baefe686cc249c9f97f3408dc8e881d120701851d837c", size = 24057 }, + { url = "https://files.pythonhosted.org/packages/f3/8e/e60b7c03442c306fec2dbaf35a697856ea8a4c9baa3d227e46910e2fb970/kasa_crypt-0.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:c442a7db3fd3ff9ad75e6b25ca9a970af800d7968f7187da67207eab136b7f12", size = 70878 }, + { url = "https://files.pythonhosted.org/packages/71/43/d9e9b54aad36d8aae9f59adc8ddb27bf7a06f505deffe98f28bc865ba494/kasa_crypt-0.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:04fad5f981e734ab1b269922a1175bc506d5498681778b3d61561422619d6e6d", size = 69934 }, + { url = "https://files.pythonhosted.org/packages/15/79/5e94eb76f2935f92de9602b04d0c244653540128eba2be71e6284f9c9997/kasa_crypt-0.4.4-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:a54040539fe8293a7dd20fcf5e613ba4bdcafe15a8d9eeff1cc2805500a0c2d9", size = 133178 }, + { url = "https://files.pythonhosted.org/packages/7a/1e/3836b1e69da964e3c8dbf057d82f8f13d277fe9baa6c327400ea5ebc37e1/kasa_crypt-0.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a0a0981255225fd5671ffed85f2bfc68b0ac8525b5d424a703aaa1d0f8f4cc2", size = 136881 }, + { url = "https://files.pythonhosted.org/packages/aa/24/eeafbbdc5a914abdd8911108eab7fe3ddf5bfdd1e14d3d43f5874a936863/kasa_crypt-0.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fa2bcbf7c4bb2af4a86c553fb8df47466c06f5060d5c21253a4ecd9ee2237ef4", size = 136189 }, + { url = "https://files.pythonhosted.org/packages/69/23/6c0604c093f69f80d00b8953ec7ac0cfc4db2504db7cddf7be26f6ed582d/kasa_crypt-0.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:99518489cb93d93c6c2e5ac4e30ad6838bb64c8365e8c3a37204e7f4228805ca", size = 139644 }, + { url = "https://files.pythonhosted.org/packages/c4/54/13e48c5b280600c966cba23b1940d38ec2847db909f060224c902af33c5c/kasa_crypt-0.4.4-cp311-cp311-win32.whl", hash = "sha256:431223a614f868a253786da7b137a8597c8ce83ed71a8bc10ffe9e56f7a8ba4d", size = 68754 }, + { url = "https://files.pythonhosted.org/packages/02/eb/aa085ddebda8c1d2912e5c6196f3c9106595c6dae2098bcb5df602db978f/kasa_crypt-0.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:c3d60a642985c3c7c9b598e19da537566803d2f78a42d0be5a7231d717239f11", size = 70959 }, + { url = "https://files.pythonhosted.org/packages/aa/f6/de1ecffa3b69200a9ebeb423f8bdb3a46987508865c906c50c09f18e311f/kasa_crypt-0.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:038a16270b15d9a9845ad4ba66f76cbf05109855e40afb6a62d7b99e73ba55a3", size = 70165 }, + { url = "https://files.pythonhosted.org/packages/8a/9a/a43be44b356bb97f7a6213c7a87863c4f7f85c9137e75fb95d66e3f04d9b/kasa_crypt-0.4.4-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:5cc150ef1bd2a330903557f806e7b671fe59f15fd37337f69ea0d7872cbffdde", size = 139126 }, + { url = "https://files.pythonhosted.org/packages/0a/52/b6e8ee4bb8aea9735da157918342baa98bf3cc8e725d74315cd33a62374a/kasa_crypt-0.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c45838d4b361f76615be72ee9b238681c47330f09cc3b0eb830095b063a262c2", size = 143953 }, + { url = "https://files.pythonhosted.org/packages/b0/cb/2c10cb2534a1237c46f4e9d764e74f5f8e3eb84862fa656629e8f1b3ebb9/kasa_crypt-0.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:138479985246ebc6be5d9bb896e48860d72a280e068d798af93acd2a210031c1", size = 141496 }, + { url = "https://files.pythonhosted.org/packages/38/62/9bcf83c27ddfaa50353deb4c9793873356d7c4b99c3b073a1c623eda883c/kasa_crypt-0.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:806dd2f7a8c6d2242513a78c144a63664817b3f0b6e149166b87db9a6017d742", size = 146398 }, + { url = "https://files.pythonhosted.org/packages/d5/63/ad0de4d97f9ec2e290a9ed37756c70ad5c99403f62399a4f9fafeb3d8c81/kasa_crypt-0.4.4-cp312-cp312-win32.whl", hash = "sha256:791900085be025dbf7052f1e44c176e957556b1d04b6da4a602fc4ddc23f87b0", size = 68951 }, + { url = "https://files.pythonhosted.org/packages/44/ce/a843f0a2c3328d792a41ca6261c1564af188a4f15b1af34f83ec8c68c686/kasa_crypt-0.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:9c7d136bfcd74ac30ed5c10cb96c46a4e2eb90bd52974a0dbbc9c6d3e90d7699", size = 71352 }, + { url = "https://files.pythonhosted.org/packages/8f/e5/2d7d825955d4ac0084e195599a42eba5fba6209439a112a49eba8b773aa5/kasa_crypt-0.4.4-pp310-pypy310_pp73-macosx_14_0_arm64.whl", hash = "sha256:b47ecee24bc17bb80ed8c24d8b008d92610a3500c56368b062627ff114688262", size = 66556 }, + { url = "https://files.pythonhosted.org/packages/c1/6e/5dd1081cfaa264cc3ee78ea3771cb9f5b34adb752da586403fab6cb84018/kasa_crypt-0.4.4-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:bd85d206856f866e117186247d161550bf3d5309d1cf07a2e7a3e5785660dd60", size = 70528 }, + { url = "https://files.pythonhosted.org/packages/29/9d/0cb1f3a3f5b764a4f394bf49fd39780aef0284bc9dd63fb3d9fb841d363b/kasa_crypt-0.4.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc37f7302943b5ab0562084df01ec39422e5cd13ba420cbb35895a4bb19ccbb", size = 69994 }, + { url = "https://files.pythonhosted.org/packages/d2/f0/d91e33daa44cf66218e679974900b94d73d840a54e03b81936b9c5b650e0/kasa_crypt-0.4.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ae739287f220e2e1b3349cf1aacd37a8abf701c97755c9bd53d6168ad41df2f1", size = 68343 }, +] + +[[package]] +name = "markdown-it-py" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/c0/59bd6d0571986f72899288a95d9d6178d0eebd70b6650f1bb3f0da90f8f7/markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1", size = 67120 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/25/2d88e8feee8e055d015343f9b86e370a1ccbec546f2865c98397aaef24af/markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30", size = 84466 }, +] + +[[package]] +name = "markupsafe" +version = "2.1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/54/ad5eb37bf9d51800010a74e4665425831a9db4e7c4e0fde4352e391e808e/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", size = 18206 }, + { url = "https://files.pythonhosted.org/packages/6a/4a/a4d49415e600bacae038c67f9fecc1d5433b9d3c71a4de6f33537b89654c/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", size = 14079 }, + { url = "https://files.pythonhosted.org/packages/0a/7b/85681ae3c33c385b10ac0f8dd025c30af83c78cec1c37a6aa3b55e67f5ec/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", size = 26620 }, + { url = "https://files.pythonhosted.org/packages/7c/52/2b1b570f6b8b803cef5ac28fdf78c0da318916c7d2fe9402a84d591b394c/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", size = 25818 }, + { url = "https://files.pythonhosted.org/packages/29/fe/a36ba8c7ca55621620b2d7c585313efd10729e63ef81e4e61f52330da781/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", size = 25493 }, + { url = "https://files.pythonhosted.org/packages/60/ae/9c60231cdfda003434e8bd27282b1f4e197ad5a710c14bee8bea8a9ca4f0/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", size = 30630 }, + { url = "https://files.pythonhosted.org/packages/65/dc/1510be4d179869f5dafe071aecb3f1f41b45d37c02329dfba01ff59e5ac5/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", size = 29745 }, + { url = "https://files.pythonhosted.org/packages/30/39/8d845dd7d0b0613d86e0ef89549bfb5f61ed781f59af45fc96496e897f3a/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", size = 30021 }, + { url = "https://files.pythonhosted.org/packages/c7/5c/356a6f62e4f3c5fbf2602b4771376af22a3b16efa74eb8716fb4e328e01e/MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", size = 16659 }, + { url = "https://files.pythonhosted.org/packages/69/48/acbf292615c65f0604a0c6fc402ce6d8c991276e16c80c46a8f758fbd30c/MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", size = 17213 }, + { url = "https://files.pythonhosted.org/packages/11/e7/291e55127bb2ae67c64d66cef01432b5933859dfb7d6949daa721b89d0b3/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", size = 18219 }, + { url = "https://files.pythonhosted.org/packages/6b/cb/aed7a284c00dfa7c0682d14df85ad4955a350a21d2e3b06d8240497359bf/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", size = 14098 }, + { url = "https://files.pythonhosted.org/packages/1c/cf/35fe557e53709e93feb65575c93927942087e9b97213eabc3fe9d5b25a55/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", size = 29014 }, + { url = "https://files.pythonhosted.org/packages/97/18/c30da5e7a0e7f4603abfc6780574131221d9148f323752c2755d48abad30/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", size = 28220 }, + { url = "https://files.pythonhosted.org/packages/0c/40/2e73e7d532d030b1e41180807a80d564eda53babaf04d65e15c1cf897e40/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", size = 27756 }, + { url = "https://files.pythonhosted.org/packages/18/46/5dca760547e8c59c5311b332f70605d24c99d1303dd9a6e1fc3ed0d73561/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", size = 33988 }, + { url = "https://files.pythonhosted.org/packages/6d/c5/27febe918ac36397919cd4a67d5579cbbfa8da027fa1238af6285bb368ea/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", size = 32718 }, + { url = "https://files.pythonhosted.org/packages/f8/81/56e567126a2c2bc2684d6391332e357589a96a76cb9f8e5052d85cb0ead8/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", size = 33317 }, + { url = "https://files.pythonhosted.org/packages/00/0b/23f4b2470accb53285c613a3ab9ec19dc944eaf53592cb6d9e2af8aa24cc/MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", size = 16670 }, + { url = "https://files.pythonhosted.org/packages/b7/a2/c78a06a9ec6d04b3445a949615c4c7ed86a0b2eb68e44e7541b9d57067cc/MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", size = 17224 }, + { url = "https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", size = 18215 }, + { url = "https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", size = 14069 }, + { url = "https://files.pythonhosted.org/packages/51/b5/5d8ec796e2a08fc814a2c7d2584b55f889a55cf17dd1a90f2beb70744e5c/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", size = 29452 }, + { url = "https://files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", size = 28462 }, + { url = "https://files.pythonhosted.org/packages/2d/75/fd6cb2e68780f72d47e6671840ca517bda5ef663d30ada7616b0462ad1e3/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", size = 27869 }, + { url = "https://files.pythonhosted.org/packages/b0/81/147c477391c2750e8fc7705829f7351cf1cd3be64406edcf900dc633feb2/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", size = 33906 }, + { url = "https://files.pythonhosted.org/packages/8b/ff/9a52b71839d7a256b563e85d11050e307121000dcebc97df120176b3ad93/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", size = 32296 }, + { url = "https://files.pythonhosted.org/packages/88/07/2dc76aa51b481eb96a4c3198894f38b480490e834479611a4053fbf08623/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", size = 33038 }, + { url = "https://files.pythonhosted.org/packages/96/0c/620c1fb3661858c0e37eb3cbffd8c6f732a67cd97296f725789679801b31/MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", size = 16572 }, + { url = "https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127 }, + { url = "https://files.pythonhosted.org/packages/0f/31/780bb297db036ba7b7bbede5e1d7f1e14d704ad4beb3ce53fb495d22bc62/MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", size = 18193 }, + { url = "https://files.pythonhosted.org/packages/6c/77/d77701bbef72892affe060cdacb7a2ed7fd68dae3b477a8642f15ad3b132/MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", size = 14073 }, + { url = "https://files.pythonhosted.org/packages/d9/a7/1e558b4f78454c8a3a0199292d96159eb4d091f983bc35ef258314fe7269/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", size = 26486 }, + { url = "https://files.pythonhosted.org/packages/5f/5a/360da85076688755ea0cceb92472923086993e86b5613bbae9fbc14136b0/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", size = 25685 }, + { url = "https://files.pythonhosted.org/packages/6a/18/ae5a258e3401f9b8312f92b028c54d7026a97ec3ab20bfaddbdfa7d8cce8/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", size = 25338 }, + { url = "https://files.pythonhosted.org/packages/0b/cc/48206bd61c5b9d0129f4d75243b156929b04c94c09041321456fd06a876d/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", size = 30439 }, + { url = "https://files.pythonhosted.org/packages/d1/06/a41c112ab9ffdeeb5f77bc3e331fdadf97fa65e52e44ba31880f4e7f983c/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", size = 29531 }, + { url = "https://files.pythonhosted.org/packages/02/8c/ab9a463301a50dab04d5472e998acbd4080597abc048166ded5c7aa768c8/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", size = 29823 }, + { url = "https://files.pythonhosted.org/packages/bc/29/9bc18da763496b055d8e98ce476c8e718dcfd78157e17f555ce6dd7d0895/MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", size = 16658 }, + { url = "https://files.pythonhosted.org/packages/f6/f8/4da07de16f10551ca1f640c92b5f316f9394088b183c6a57183df6de5ae4/MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", size = 17211 }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/e7/cc2720da8a32724b36d04c6dba5644154cdf883a1482b3bbb81959a642ed/mdit-py-plugins-0.3.5.tar.gz", hash = "sha256:eee0adc7195e5827e17e02d2a258a2ba159944a0748f59c5099a4a27f78fcf6a", size = 39871 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/4c/a9b222f045f98775034d243198212cbea36d3524c3ee1e8ab8c0346d6953/mdit_py_plugins-0.3.5-py3-none-any.whl", hash = "sha256:ca9a0714ea59a24b2b044a1831f48d817dd0c817e84339f20e7889f392d77c4e", size = 52087 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "multidict" +version = "6.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/79/722ca999a3a09a63b35aac12ec27dfa8e5bb3a38b0f857f7a1a209a88836/multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da", size = 59867 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/36/48097b96135017ed1b806c5ea27b6cdc2ed3a6861c5372b793563206c586/multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9", size = 50955 }, + { url = "https://files.pythonhosted.org/packages/d9/48/037440edb5d4a1c65e002925b2f24071d6c27754e6f4734f63037e3169d6/multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604", size = 30361 }, + { url = "https://files.pythonhosted.org/packages/a4/eb/d8e7693c9064554a1585698d1902839440c6c695b0f53c9a8be5d9d4a3b8/multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600", size = 30508 }, + { url = "https://files.pythonhosted.org/packages/f3/7d/fe7648d4b2f200f8854066ce6e56bf51889abfaf859814c62160dd0e32a9/multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c", size = 126318 }, + { url = "https://files.pythonhosted.org/packages/8d/ea/0230b6faa9a5bc10650fd50afcc4a86e6c37af2fe05bc679b74d79253732/multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5", size = 133998 }, + { url = "https://files.pythonhosted.org/packages/36/6d/d2f982fb485175727a193b4900b5f929d461e7aa87d6fb5a91a377fcc9c0/multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f", size = 129150 }, + { url = "https://files.pythonhosted.org/packages/33/62/2c9085e571318d51212a6914566fe41dd0e33d7f268f7e2f23dcd3f06c56/multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae", size = 124266 }, + { url = "https://files.pythonhosted.org/packages/ce/e2/88cdfeaf03eab3498f688a19b62ca704d371cd904cb74b682541ca7b20a7/multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182", size = 116637 }, + { url = "https://files.pythonhosted.org/packages/12/4d/99dfc36872dcc53956879f5da80a6505bbd29214cce90ce792a86e15fddf/multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf", size = 155908 }, + { url = "https://files.pythonhosted.org/packages/c2/5c/1e76b2c742cb9e0248d1e8c4ed420817879230c833fa27d890b5fd22290b/multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442", size = 147111 }, + { url = "https://files.pythonhosted.org/packages/bc/84/9579004267e1cc5968ef2ef8718dab9d8950d99354d85b739dd67b09c273/multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a", size = 160502 }, + { url = "https://files.pythonhosted.org/packages/11/b7/bef33e84e3722bc42531af020d7ae8c31235ce8846bacaa852b6484cf868/multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef", size = 156587 }, + { url = "https://files.pythonhosted.org/packages/26/ce/f745a2d6104e56f7fa0d7d0756bb9ed27b771dd7b8d9d7348cd7f0f7b9de/multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc", size = 151948 }, + { url = "https://files.pythonhosted.org/packages/f1/50/714da64281d2b2b3b4068e84f115e1ef3bd3ed3715b39503ff3c59e8d30d/multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319", size = 25734 }, + { url = "https://files.pythonhosted.org/packages/ef/3d/ba0dc18e96c5d83731c54129819d5892389e180f54ebb045c6124b2e8b87/multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8", size = 28182 }, + { url = "https://files.pythonhosted.org/packages/5f/da/b10ea65b850b54f44a6479177c6987f456bc2d38f8dc73009b78afcf0ede/multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba", size = 50815 }, + { url = "https://files.pythonhosted.org/packages/21/db/3403263f158b0bc7b0d4653766d71cb39498973f2042eead27b2e9758782/multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e", size = 30269 }, + { url = "https://files.pythonhosted.org/packages/02/c1/b15ecceb6ffa5081ed2ed450aea58d65b0e0358001f2b426705f9f41f4c2/multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd", size = 30500 }, + { url = "https://files.pythonhosted.org/packages/3f/e1/7fdd0f39565df3af87d6c2903fb66a7d529fbd0a8a066045d7a5b6ad1145/multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3", size = 130751 }, + { url = "https://files.pythonhosted.org/packages/76/bc/9f593f9e38c6c09bbf0344b56ad67dd53c69167937c2edadee9719a5e17d/multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf", size = 138185 }, + { url = "https://files.pythonhosted.org/packages/28/32/d7799a208701d537b92705f46c777ded812a6dc139c18d8ed599908f6b1c/multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29", size = 133585 }, + { url = "https://files.pythonhosted.org/packages/52/ec/be54a3ad110f386d5bd7a9a42a4ff36b3cd723ebe597f41073a73ffa16b8/multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed", size = 128684 }, + { url = "https://files.pythonhosted.org/packages/36/e1/a680eabeb71e25d4733276d917658dfa1cd3a99b1223625dbc247d266c98/multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733", size = 120994 }, + { url = "https://files.pythonhosted.org/packages/ef/08/08f4f44a8a43ea4cee13aa9cdbbf4a639af8db49310a0637ca389c4cf817/multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f", size = 159689 }, + { url = "https://files.pythonhosted.org/packages/aa/a9/46cdb4cb40bbd4b732169413f56b04a6553460b22bd914f9729c9ba63761/multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4", size = 150611 }, + { url = "https://files.pythonhosted.org/packages/e9/32/35668bb3e6ab2f12f4e4f7f4000f72f714882a94f904d4c3633fbd036753/multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1", size = 164444 }, + { url = "https://files.pythonhosted.org/packages/fa/10/f1388a91552af732d8ec48dab928abc209e732767e9e8f92d24c3544353c/multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc", size = 160158 }, + { url = "https://files.pythonhosted.org/packages/14/c3/f602601f1819983e018156e728e57b3f19726cb424b543667faab82f6939/multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e", size = 156072 }, + { url = "https://files.pythonhosted.org/packages/82/a6/0290af8487326108c0d03d14f8a0b8b1001d71e4494df5f96ab0c88c0b88/multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c", size = 25731 }, + { url = "https://files.pythonhosted.org/packages/88/aa/ea217cb18325aa05cb3e3111c19715f1e97c50a4a900cbc20e54648de5f5/multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea", size = 28176 }, + { url = "https://files.pythonhosted.org/packages/90/9c/7fda9c0defa09538c97b1f195394be82a1f53238536f70b32eb5399dfd4e/multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e", size = 49575 }, + { url = "https://files.pythonhosted.org/packages/be/21/d6ca80dd1b9b2c5605ff7475699a8ff5dc6ea958cd71fb2ff234afc13d79/multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b", size = 29638 }, + { url = "https://files.pythonhosted.org/packages/9c/18/9565f32c19d186168731e859692dfbc0e98f66a1dcf9e14d69c02a78b75a/multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5", size = 29874 }, + { url = "https://files.pythonhosted.org/packages/4e/4e/3815190e73e6ef101b5681c174c541bf972a1b064e926e56eea78d06e858/multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450", size = 129914 }, + { url = "https://files.pythonhosted.org/packages/0c/08/bb47f886457e2259aefc10044e45c8a1b62f0c27228557e17775869d0341/multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496", size = 134589 }, + { url = "https://files.pythonhosted.org/packages/d5/2f/952f79b5f0795cf4e34852fc5cf4dfda6166f63c06c798361215b69c131d/multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a", size = 133259 }, + { url = "https://files.pythonhosted.org/packages/24/1f/af976383b0b772dd351210af5b60ff9927e3abb2f4a103e93da19a957da0/multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226", size = 130779 }, + { url = "https://files.pythonhosted.org/packages/fc/b1/b0a7744be00b0f5045c7ed4e4a6b8ee6bde4672b2c620474712299df5979/multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271", size = 120125 }, + { url = "https://files.pythonhosted.org/packages/d0/bf/2a1d667acf11231cdf0b97a6cd9f30e7a5cf847037b5cf6da44884284bd0/multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb", size = 167095 }, + { url = "https://files.pythonhosted.org/packages/5e/e8/ad6ee74b1a2050d3bc78f566dabcc14c8bf89cbe87eecec866c011479815/multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef", size = 155823 }, + { url = "https://files.pythonhosted.org/packages/45/7c/06926bb91752c52abca3edbfefac1ea90d9d1bc00c84d0658c137589b920/multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24", size = 170233 }, + { url = "https://files.pythonhosted.org/packages/3c/29/3dd36cf6b9c5abba8b97bba84eb499a168ba59c3faec8829327b3887d123/multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6", size = 169035 }, + { url = "https://files.pythonhosted.org/packages/60/47/9a0f43470c70bbf6e148311f78ef5a3d4996b0226b6d295bdd50fdcfe387/multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda", size = 166229 }, + { url = "https://files.pythonhosted.org/packages/1d/23/c1b7ae7a0b8a3e08225284ef3ecbcf014b292a3ee821bc4ed2185fd4ce7d/multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5", size = 25840 }, + { url = "https://files.pythonhosted.org/packages/4a/68/66fceb758ad7a88993940dbdf3ac59911ba9dc46d7798bf6c8652f89f853/multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556", size = 27905 }, + { url = "https://files.pythonhosted.org/packages/c6/7c/c8f4445389c0bbc5ea85d1e737233c257f314d0f836a6644e097a5ef512f/multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929", size = 50828 }, + { url = "https://files.pythonhosted.org/packages/7d/5c/c364a77b37f580cc28da4194b77ed04286c7631933d3e64fdae40f1972e2/multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9", size = 30315 }, + { url = "https://files.pythonhosted.org/packages/1a/25/f4b60a34dde70c475f4dcaeb4796c256db80d2e03198052d0c3cee5d5fbb/multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a", size = 30451 }, + { url = "https://files.pythonhosted.org/packages/d0/10/2ff646c471e84af25fe8111985ffb8ec85a3f6e1ade8643bfcfcc0f4d2b1/multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1", size = 125880 }, + { url = "https://files.pythonhosted.org/packages/c9/ee/a4775297550dfb127641bd335d00d6d896e4ba5cf0216f78654e5ad6ac80/multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e", size = 133606 }, + { url = "https://files.pythonhosted.org/packages/7d/e9/95746d0c7c40bb0f43fc5424b7d7cf783e8638ce67f05fa677fff9ad76bb/multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046", size = 128720 }, + { url = "https://files.pythonhosted.org/packages/39/a9/1f8d42c8103bcb1da6bb719f1bc018594b5acc8eae56b3fec4720ebee225/multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c", size = 123750 }, + { url = "https://files.pythonhosted.org/packages/b5/f8/c8abbe7c425497d8bf997b1fffd9650ca175325ff397fadc9d63ae5dc027/multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40", size = 116213 }, + { url = "https://files.pythonhosted.org/packages/c2/bb/242664de860cd1201f4d207f0bd2011c1a730877e1dbffbe5d6ec4089e2d/multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527", size = 155410 }, + { url = "https://files.pythonhosted.org/packages/f6/5b/35d20c85b8ccd0c9afc47b8dd46e028b6650ad9660a4b6ad191301d220f5/multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9", size = 146668 }, + { url = "https://files.pythonhosted.org/packages/1b/52/6e984685d048f6728807c3fd9b8a6e3e3d51a06a4d6665d6e0102115455d/multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38", size = 160140 }, + { url = "https://files.pythonhosted.org/packages/76/c0/3aa6238557ed1d235be70d9c3f86d63a835c421b76073b8ce06bf32725e8/multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479", size = 156185 }, + { url = "https://files.pythonhosted.org/packages/85/82/02ed81023b5812582bf7c46e8e2868ffd6a29f8c313af1dd76e82e243c39/multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c", size = 151518 }, + { url = "https://files.pythonhosted.org/packages/d8/00/fd6eef9830046c063939cbf119c101898cbb611ea20301ae911b74caca19/multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b", size = 25732 }, + { url = "https://files.pythonhosted.org/packages/58/a3/4d2c1b4d1859c89d9ce48a4ae410ee019485e324e484b0160afdba8cc42b/multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755", size = 28181 }, + { url = "https://files.pythonhosted.org/packages/fa/a2/17e1e23c6be0a916219c5292f509360c345b5fa6beeb50d743203c27532c/multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7", size = 9729 }, +] + +[[package]] +name = "mypy" +version = "1.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/86/5d7cbc4974fd564550b80fbb8103c05501ea11aa7835edf3351d90095896/mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79", size = 3078806 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/cd/815368cd83c3a31873e5e55b317551500b12f2d1d7549720632f32630333/mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a", size = 10939401 }, + { url = "https://files.pythonhosted.org/packages/f1/27/e18c93a195d2fad75eb96e1f1cbc431842c332e8eba2e2b77eaf7313c6b7/mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef", size = 10111697 }, + { url = "https://files.pythonhosted.org/packages/dc/08/cdc1fc6d0d5a67d354741344cc4aa7d53f7128902ebcbe699ddd4f15a61c/mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383", size = 12500508 }, + { url = "https://files.pythonhosted.org/packages/64/12/aad3af008c92c2d5d0720ea3b6674ba94a98cdb86888d389acdb5f218c30/mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8", size = 13020712 }, + { url = "https://files.pythonhosted.org/packages/03/e6/a7d97cc124a565be5e9b7d5c2a6ebf082379ffba99646e4863ed5bbcb3c3/mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7", size = 9567319 }, + { url = "https://files.pythonhosted.org/packages/e2/aa/cc56fb53ebe14c64f1fe91d32d838d6f4db948b9494e200d2f61b820b85d/mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385", size = 10859630 }, + { url = "https://files.pythonhosted.org/packages/04/c8/b19a760fab491c22c51975cf74e3d253b8c8ce2be7afaa2490fbf95a8c59/mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca", size = 10037973 }, + { url = "https://files.pythonhosted.org/packages/88/57/7e7e39f2619c8f74a22efb9a4c4eff32b09d3798335625a124436d121d89/mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104", size = 12416659 }, + { url = "https://files.pythonhosted.org/packages/fc/a6/37f7544666b63a27e46c48f49caeee388bf3ce95f9c570eb5cfba5234405/mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4", size = 12897010 }, + { url = "https://files.pythonhosted.org/packages/84/8b/459a513badc4d34acb31c736a0101c22d2bd0697b969796ad93294165cfb/mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6", size = 9562873 }, + { url = "https://files.pythonhosted.org/packages/35/3a/ed7b12ecc3f6db2f664ccf85cb2e004d3e90bec928e9d7be6aa2f16b7cdf/mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318", size = 10990335 }, + { url = "https://files.pythonhosted.org/packages/04/e4/1a9051e2ef10296d206519f1df13d2cc896aea39e8683302f89bf5792a59/mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36", size = 10007119 }, + { url = "https://files.pythonhosted.org/packages/f3/3c/350a9da895f8a7e87ade0028b962be0252d152e0c2fbaafa6f0658b4d0d4/mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987", size = 12506856 }, + { url = "https://files.pythonhosted.org/packages/b6/49/ee5adf6a49ff13f4202d949544d3d08abb0ea1f3e7f2a6d5b4c10ba0360a/mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca", size = 12952066 }, + { url = "https://files.pythonhosted.org/packages/27/c0/b19d709a42b24004d720db37446a42abadf844d5c46a2c442e2a074d70d9/mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70", size = 9664000 }, + { url = "https://files.pythonhosted.org/packages/16/64/bb5ed751487e2bea0dfaa6f640a7e3bb88083648f522e766d5ef4a76f578/mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6", size = 10937294 }, + { url = "https://files.pythonhosted.org/packages/a9/a3/67a0069abed93c3bf3b0bebb8857e2979a02828a4a3fd82f107f8f1143e8/mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70", size = 10107707 }, + { url = "https://files.pythonhosted.org/packages/2f/4d/0379daf4258b454b1f9ed589a9dabd072c17f97496daea7b72fdacf7c248/mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d", size = 12498367 }, + { url = "https://files.pythonhosted.org/packages/3b/dc/3976a988c280b3571b8eb6928882dc4b723a403b21735a6d8ae6ed20e82b/mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d", size = 13018014 }, + { url = "https://files.pythonhosted.org/packages/83/84/adffc7138fb970e7e2a167bd20b33bb78958370179853a4ebe9008139342/mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24", size = 9568056 }, + { url = "https://files.pythonhosted.org/packages/42/3a/bdf730640ac523229dd6578e8a581795720a9321399de494374afc437ec5/mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12", size = 2619625 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[package]] +name = "myst-parser" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/69/fbddb50198c6b0901a981e72ae30f1b7769d2dfac88071f7df41c946d133/myst-parser-1.0.0.tar.gz", hash = "sha256:502845659313099542bd38a2ae62f01360e7dd4b1310f025dd014dfc0439cdae", size = 84224 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/1f/1621ef434ac5da26c30d31fcca6d588e3383344902941713640ba717fa87/myst_parser-1.0.0-py3-none-any.whl", hash = "sha256:69fb40a586c6fa68995e6521ac0a525793935db7e724ca9bac1d33be51be9a4c", size = 77312 }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "orjson" +version = "3.10.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/03/821c8197d0515e46ea19439f5c5d5fd9a9889f76800613cfac947b5d7845/orjson-3.10.7.tar.gz", hash = "sha256:75ef0640403f945f3a1f9f6400686560dbfb0fb5b16589ad62cd477043c4eee3", size = 5056450 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/12/60931cf808b9334f26210ab496442f4a7a3d66e29d1cf12e0a01857e756f/orjson-3.10.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:74f4544f5a6405b90da8ea724d15ac9c36da4d72a738c64685003337401f5c12", size = 251312 }, + { url = "https://files.pythonhosted.org/packages/fe/0e/efbd0a2d25f8e82b230eb20b6b8424be6dd95b6811b669be9af16234b6db/orjson-3.10.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34a566f22c28222b08875b18b0dfbf8a947e69df21a9ed5c51a6bf91cfb944ac", size = 148124 }, + { url = "https://files.pythonhosted.org/packages/dd/47/1ddff6e23fe5f4aeaaed996a3cde422b3eaac4558c03751723e106184c68/orjson-3.10.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf6ba8ebc8ef5792e2337fb0419f8009729335bb400ece005606336b7fd7bab7", size = 147277 }, + { url = "https://files.pythonhosted.org/packages/04/da/d03d72b54bdd60d05de372114abfbd9f05050946895140c6ff5f27ab8f49/orjson-3.10.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac7cf6222b29fbda9e3a472b41e6a5538b48f2c8f99261eecd60aafbdb60690c", size = 152955 }, + { url = "https://files.pythonhosted.org/packages/7f/7e/ef8522dbba112af6cc52227dcc746dd3447c7d53ea8cea35740239b547ee/orjson-3.10.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de817e2f5fc75a9e7dd350c4b0f54617b280e26d1631811a43e7e968fa71e3e9", size = 163955 }, + { url = "https://files.pythonhosted.org/packages/b6/bc/fbd345d771a73cacc5b0e774d034cd081590b336754c511f4ead9fdc4cf1/orjson-3.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:348bdd16b32556cf8d7257b17cf2bdb7ab7976af4af41ebe79f9796c218f7e91", size = 141896 }, + { url = "https://files.pythonhosted.org/packages/82/0a/1f09c12d15b1e83156b7f3f621561d38650fe5b8f39f38f04a64de1a87fc/orjson-3.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:479fd0844ddc3ca77e0fd99644c7fe2de8e8be1efcd57705b5c92e5186e8a250", size = 170166 }, + { url = "https://files.pythonhosted.org/packages/a6/d8/eee30caba21a8d6a9df06d2519bb0ecd0adbcd57f2e79d360de5570031cf/orjson-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fdf5197a21dd660cf19dfd2a3ce79574588f8f5e2dbf21bda9ee2d2b46924d84", size = 167804 }, + { url = "https://files.pythonhosted.org/packages/44/fe/d1d89d3f15e343511417195f6ccd2bdeb7ebc5a48a882a79ab3bbcdf5fc7/orjson-3.10.7-cp310-none-win32.whl", hash = "sha256:d374d36726746c81a49f3ff8daa2898dccab6596864ebe43d50733275c629175", size = 143010 }, + { url = "https://files.pythonhosted.org/packages/88/8c/0e7b8d5a523927774758ac4ce2de4d8ca5dda569955ba3aeb5e208344eda/orjson-3.10.7-cp310-none-win_amd64.whl", hash = "sha256:cb61938aec8b0ffb6eef484d480188a1777e67b05d58e41b435c74b9d84e0b9c", size = 137306 }, + { url = "https://files.pythonhosted.org/packages/89/c9/dd286c97c2f478d43839bd859ca4d9820e2177d4e07a64c516dc3e018062/orjson-3.10.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7db8539039698ddfb9a524b4dd19508256107568cdad24f3682d5773e60504a2", size = 251312 }, + { url = "https://files.pythonhosted.org/packages/b9/72/d90bd11e83a0e9623b3803b079478a93de8ec4316c98fa66110d594de5fa/orjson-3.10.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:480f455222cb7a1dea35c57a67578848537d2602b46c464472c995297117fa09", size = 148125 }, + { url = "https://files.pythonhosted.org/packages/9d/b6/ed61e87f327a4cbb2075ed0716e32ba68cb029aa654a68c3eb27803050d8/orjson-3.10.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a9c9b168b3a19e37fe2778c0003359f07822c90fdff8f98d9d2a91b3144d8e0", size = 147278 }, + { url = "https://files.pythonhosted.org/packages/66/9f/e6a11b5d1ad11e9dc869d938707ef93ff5ed20b53d6cda8b5e2ac532a9d2/orjson-3.10.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8de062de550f63185e4c1c54151bdddfc5625e37daf0aa1e75d2a1293e3b7d9a", size = 152954 }, + { url = "https://files.pythonhosted.org/packages/92/ee/702d5e8ccd42dc2b9d1043f22daa1ba75165616aa021dc19fb0c5a726ce8/orjson-3.10.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b0dd04483499d1de9c8f6203f8975caf17a6000b9c0c54630cef02e44ee624e", size = 163953 }, + { url = "https://files.pythonhosted.org/packages/d3/cb/55205f3f1ee6ba80c0a9a18ca07423003ca8de99192b18be30f1f31b4cdd/orjson-3.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b58d3795dafa334fc8fd46f7c5dc013e6ad06fd5b9a4cc98cb1456e7d3558bd6", size = 141895 }, + { url = "https://files.pythonhosted.org/packages/bb/ab/1185e472f15c00d37d09c395e478803ed0eae7a3a3d055a5f3885e1ea136/orjson-3.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33cfb96c24034a878d83d1a9415799a73dc77480e6c40417e5dda0710d559ee6", size = 170169 }, + { url = "https://files.pythonhosted.org/packages/53/b9/10abe9089bdb08cd4218cc45eb7abfd787c82cf301cecbfe7f141542d7f4/orjson-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e724cebe1fadc2b23c6f7415bad5ee6239e00a69f30ee423f319c6af70e2a5c0", size = 167808 }, + { url = "https://files.pythonhosted.org/packages/8a/ad/26b40ccef119dcb0f4a39745ffd7d2d319152c1a52859b1ebbd114eca19c/orjson-3.10.7-cp311-none-win32.whl", hash = "sha256:82763b46053727a7168d29c772ed5c870fdae2f61aa8a25994c7984a19b1021f", size = 143010 }, + { url = "https://files.pythonhosted.org/packages/e7/63/5f4101e4895b78ada568f4cf8f870dd594139ca2e75e654e373da78b03b0/orjson-3.10.7-cp311-none-win_amd64.whl", hash = "sha256:eb8d384a24778abf29afb8e41d68fdd9a156cf6e5390c04cc07bbc24b89e98b5", size = 137307 }, + { url = "https://files.pythonhosted.org/packages/14/7c/b4ecc2069210489696a36e42862ccccef7e49e1454a3422030ef52881b01/orjson-3.10.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44a96f2d4c3af51bfac6bc4ef7b182aa33f2f054fd7f34cc0ee9a320d051d41f", size = 251409 }, + { url = "https://files.pythonhosted.org/packages/60/84/e495edb919ef0c98d054a9b6d05f2700fdeba3886edd58f1c4dfb25d514a/orjson-3.10.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ac14cd57df0572453543f8f2575e2d01ae9e790c21f57627803f5e79b0d3c3", size = 147913 }, + { url = "https://files.pythonhosted.org/packages/c5/27/e40bc7d79c4afb7e9264f22320c285d06d2c9574c9c682ba0f1be3012833/orjson-3.10.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdbb61dcc365dd9be94e8f7df91975edc9364d6a78c8f7adb69c1cdff318ec93", size = 147390 }, + { url = "https://files.pythonhosted.org/packages/30/be/fd646fb1a461de4958a6eacf4ecf064b8d5479c023e0e71cc89b28fa91ac/orjson-3.10.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b48b3db6bb6e0a08fa8c83b47bc169623f801e5cc4f24442ab2b6617da3b5313", size = 152973 }, + { url = "https://files.pythonhosted.org/packages/b1/00/414f8d4bc5ec3447e27b5c26b4e996e4ef08594d599e79b3648f64da060c/orjson-3.10.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23820a1563a1d386414fef15c249040042b8e5d07b40ab3fe3efbfbbcbcb8864", size = 164039 }, + { url = "https://files.pythonhosted.org/packages/a0/6b/34e6904ac99df811a06e42d8461d47b6e0c9b86e2fe7ee84934df6e35f0d/orjson-3.10.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0c6a008e91d10a2564edbb6ee5069a9e66df3fbe11c9a005cb411f441fd2c09", size = 142035 }, + { url = "https://files.pythonhosted.org/packages/17/7e/254189d9b6df89660f65aec878d5eeaa5b1ae371bd2c458f85940445d36f/orjson-3.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d352ee8ac1926d6193f602cbe36b1643bbd1bbcb25e3c1a657a4390f3000c9a5", size = 169941 }, + { url = "https://files.pythonhosted.org/packages/02/1a/d11805670c29d3a1b29fc4bd048dc90b094784779690592efe8c9f71249a/orjson-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2d9f990623f15c0ae7ac608103c33dfe1486d2ed974ac3f40b693bad1a22a7b", size = 167994 }, + { url = "https://files.pythonhosted.org/packages/20/5f/03d89b007f9d6733dc11bc35d64812101c85d6c4e9c53af9fa7e7689cb11/orjson-3.10.7-cp312-none-win32.whl", hash = "sha256:7c4c17f8157bd520cdb7195f75ddbd31671997cbe10aee559c2d613592e7d7eb", size = 143130 }, + { url = "https://files.pythonhosted.org/packages/c6/9d/9b9fb6c60b8a0e04031ba85414915e19ecea484ebb625402d968ea45b8d5/orjson-3.10.7-cp312-none-win_amd64.whl", hash = "sha256:1d9c0e733e02ada3ed6098a10a8ee0052dd55774de3d9110d29868d24b17faa1", size = 137326 }, + { url = "https://files.pythonhosted.org/packages/15/05/121af8a87513c56745d01ad7cf215c30d08356da9ad882ebe2ba890824cd/orjson-3.10.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:77d325ed866876c0fa6492598ec01fe30e803272a6e8b10e992288b009cbe149", size = 251331 }, + { url = "https://files.pythonhosted.org/packages/73/7f/8d6ccd64a6f8bdbfe6c9be7c58aeb8094aa52a01fbbb2cda42ff7e312bd7/orjson-3.10.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ea2c232deedcb605e853ae1db2cc94f7390ac776743b699b50b071b02bea6fe", size = 142012 }, + { url = "https://files.pythonhosted.org/packages/04/65/f2a03fd1d4f0308f01d372e004c049f7eb9bc5676763a15f20f383fa9c01/orjson-3.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3dcfbede6737fdbef3ce9c37af3fb6142e8e1ebc10336daa05872bfb1d87839c", size = 169920 }, + { url = "https://files.pythonhosted.org/packages/e2/1c/3ef8d83d7c6a619ad3d69a4d5318591b4ce5862e6eda7c26bbe8208652ca/orjson-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11748c135f281203f4ee695b7f80bb1358a82a63905f9f0b794769483ea854ad", size = 167916 }, + { url = "https://files.pythonhosted.org/packages/f2/0d/820a640e5a7dfbe525e789c70871ebb82aff73b0c7bf80082653f86b9431/orjson-3.10.7-cp313-none-win32.whl", hash = "sha256:a7e19150d215c7a13f39eb787d84db274298d3f83d85463e61d277bbd7f401d2", size = 143089 }, + { url = "https://files.pythonhosted.org/packages/1a/72/a424db9116c7cad2950a8f9e4aeb655a7b57de988eb015acd0fcd1b4609b/orjson-3.10.7-cp313-none-win_amd64.whl", hash = "sha256:eef44224729e9525d5261cc8d28d6b11cafc90e6bd0be2157bde69a52ec83024", size = 137081 }, + { url = "https://files.pythonhosted.org/packages/08/8c/23813894241f920e37ae363aa59a6a0fdb06e90afd60ad89e5a424113d1c/orjson-3.10.7-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e76be12658a6fa376fcd331b1ea4e58f5a06fd0220653450f0d415b8fd0fbe20", size = 251267 }, + { url = "https://files.pythonhosted.org/packages/b8/e5/f3cb8f766e7f5e5197e884d63fba320aa4f32a04a21b68864c71997cb17e/orjson-3.10.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed350d6978d28b92939bfeb1a0570c523f6170efc3f0a0ef1f1df287cd4f4960", size = 147924 }, + { url = "https://files.pythonhosted.org/packages/a3/4a/a041b6c95f623c28ccab87ce0720ac60cd0734f357774fd7212ff1fd9077/orjson-3.10.7-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:144888c76f8520e39bfa121b31fd637e18d4cc2f115727865fdf9fa325b10412", size = 147054 }, + { url = "https://files.pythonhosted.org/packages/ba/5b/89f2d5cda6c7bcad2067a87407aa492392942118969d548bc77ab4e9c818/orjson-3.10.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09b2d92fd95ad2402188cf51573acde57eb269eddabaa60f69ea0d733e789fe9", size = 152676 }, + { url = "https://files.pythonhosted.org/packages/04/02/bcb6ee82ecb5bc8f7487bce2204db9e9d8818f5fe7a3cad1625254f8d3a7/orjson-3.10.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b24a579123fa884f3a3caadaed7b75eb5715ee2b17ab5c66ac97d29b18fe57f", size = 163726 }, + { url = "https://files.pythonhosted.org/packages/6c/c1/97b5bb1869572483b0e060264180fe5417a836ed46c09166f0dc6bb1d42d/orjson-3.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591bcfe7512353bd609875ab38050efe3d55e18934e2f18950c108334b4ff", size = 141681 }, + { url = "https://files.pythonhosted.org/packages/c1/c6/5d5c556720f8a31c5618db7326f6de6c07ddfea72497c1baa69fca24e1ad/orjson-3.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f4db56635b58cd1a200b0a23744ff44206ee6aa428185e2b6c4a65b3197abdcd", size = 169961 }, + { url = "https://files.pythonhosted.org/packages/d7/15/2c1ca80d4e37780514cc369004fce77e2748b54857b62eb217e9a243a669/orjson-3.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0fa5886854673222618638c6df7718ea7fe2f3f2384c452c9ccedc70b4a510a5", size = 167613 }, + { url = "https://files.pythonhosted.org/packages/3b/39/4888bacdd3b82a923ea306369b87ba5bcdafa8951cecc041c1cfef3e7d7f/orjson-3.10.7-cp39-none-win32.whl", hash = "sha256:8272527d08450ab16eb405f47e0f4ef0e5ff5981c3d82afe0efd25dcbef2bcd2", size = 142863 }, + { url = "https://files.pythonhosted.org/packages/0c/c5/c5cbff9dbd45e4f8c4fef4c74ae4819d003b9e97201f3b1066a71368faf3/orjson-3.10.7-cp39-none-win_amd64.whl", hash = "sha256:974683d4618c0c7dbf4f69c95a979734bf183d0658611760017f6e70a145af58", size = 137119 }, +] + +[[package]] +name = "packaging" +version = "24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, +] + +[[package]] +name = "parso" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, +] + +[[package]] +name = "platformdirs" +version = "4.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/52/0763d1d976d5c262df53ddda8d8d4719eedf9594d046f117c25a27261a19/platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3", size = 20916 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/13/2aa1f0e1364feb2c9ef45302f387ac0bd81484e9c9a4c5688a322fbdfd08/platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", size = 18146 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pre-commit" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/10/97ee2fa54dff1e9da9badbc5e35d0bbaef0776271ea5907eccf64140f72f/pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af", size = 177815 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/92/caae8c86e94681b42c246f0bca35c059a2f0529e5b92619f6aba4cf7e7b6/pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f", size = 204643 }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.47" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/6d/0279b119dafc74c1220420028d490c4399b790fc1256998666e3a341879f/prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360", size = 425859 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/23/22750c4b768f09386d1c3cc4337953e8936f48a888fa6dddfb669b2c9088/prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10", size = 386411 }, +] + +[[package]] +name = "ptpython" +version = "3.0.29" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appdirs" }, + { name = "jedi" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/61/352792c9f47de98a910526ff8a684466a6217e53ffa6627b3781960e4f0d/ptpython-3.0.29.tar.gz", hash = "sha256:b9d625183aef93a673fc32cbe1c1fcaf51412e7a4f19590521cdaccadf25186e", size = 72622 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/39/c6fd4dd531e067b6a01624126cff0b3ddc6569e22f83e48d8418ffa9e3be/ptpython-3.0.29-py2.py3-none-any.whl", hash = "sha256:65d75c4871859e4305a020c9b9e204366dceb4d08e0e2bd7b7511bd5e917a402", size = 67057 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pydantic" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/8f/3b9f7a38caa3fa0bcb3cea7ee9958e89a9a6efc0e6f51fd6096f24cac140/pydantic-2.9.0.tar.gz", hash = "sha256:c7a8a9fdf7d100afa49647eae340e2d23efa382466a8d177efcd1381e9be5598", size = 768298 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/38/95bdb5dfcebad2c11c88f7aa2d635fe53a0b7405ef39a6850c8bced455d4/pydantic-2.9.0-py3-none-any.whl", hash = "sha256:f66a7073abd93214a20c5f7b32d56843137a7a2e70d02111f3be287035c45370", size = 434325 }, +] + +[[package]] +name = "pydantic-core" +version = "2.23.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/03/54e4961dfaed4804fea0ad73e94d337f4ef88a635e73990d6e150b469594/pydantic_core-2.23.2.tar.gz", hash = "sha256:95d6bf449a1ac81de562d65d180af5d8c19672793c81877a2eda8fde5d08f2fd", size = 401901 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/3b/cf2605095ebf48dff52463b269dfccdb4a225b430d9b3c0c11a7ca094dab/pydantic_core-2.23.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7d0324a35ab436c9d768753cbc3c47a865a2cbc0757066cb864747baa61f6ece", size = 1846549 }, + { url = "https://files.pythonhosted.org/packages/05/d2/b5c65bcf82537957686959339d1d4440bdf2b71ff0dbee46bf1f6de43841/pydantic_core-2.23.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:276ae78153a94b664e700ac362587c73b84399bd1145e135287513442e7dfbc7", size = 1790171 }, + { url = "https://files.pythonhosted.org/packages/aa/f9/a0b2d02aa618ea55140d77c72d8a69c036d1f14f8d2e95760fd653e1f75a/pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:964c7aa318da542cdcc60d4a648377ffe1a2ef0eb1e996026c7f74507b720a78", size = 1789391 }, + { url = "https://files.pythonhosted.org/packages/c9/6a/f5a8b2a1f869fff2fcd41cf37f0ce611004a1ece335427a31867707bb25e/pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1cf842265a3a820ebc6388b963ead065f5ce8f2068ac4e1c713ef77a67b71f7c", size = 1781839 }, + { url = "https://files.pythonhosted.org/packages/17/d2/76af5e05a9ccad46a139366f53cb2dbaf237b7389bfdb7d7fd0c36eedaf4/pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae90b9e50fe1bd115b24785e962b51130340408156d34d67b5f8f3fa6540938e", size = 1978248 }, + { url = "https://files.pythonhosted.org/packages/57/67/af3fa1cf5ef6288dbac450bfd264372651ae0bf6d683aebf3cb0fafa3a51/pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ae65fdfb8a841556b52935dfd4c3f79132dc5253b12c0061b96415208f4d622", size = 2708429 }, + { url = "https://files.pythonhosted.org/packages/90/60/ce282e9f8cf383c73c1b3234835b1aa6e518aa947b162c4e86c2dcffd281/pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c8aa40f6ca803f95b1c1c5aeaee6237b9e879e4dfb46ad713229a63651a95fb", size = 2069487 }, + { url = "https://files.pythonhosted.org/packages/6f/81/d93a2ca9418f610341339bc2c68d705be21d902f71f8ba2af6801f584751/pydantic_core-2.23.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c53100c8ee5a1e102766abde2158077d8c374bee0639201f11d3032e3555dfbc", size = 1899917 }, + { url = "https://files.pythonhosted.org/packages/20/a5/720d0cebf34da0b26d184c71642b21facb3c18302e9e410fb17f3d66956f/pydantic_core-2.23.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d6b9dd6aa03c812017411734e496c44fef29b43dba1e3dd1fa7361bbacfc1354", size = 1966236 }, + { url = "https://files.pythonhosted.org/packages/0c/5a/91f67eaeccb061b22bd01e65ef8dda9d35953020605a7f07b884c1969ab8/pydantic_core-2.23.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b18cf68255a476b927910c6873d9ed00da692bb293c5b10b282bd48a0afe3ae2", size = 2111250 }, + { url = "https://files.pythonhosted.org/packages/f9/ff/b4bb13a7ec2666a73c936a7675001a3f032f35189711bb51ee38c0756a70/pydantic_core-2.23.2-cp310-none-win32.whl", hash = "sha256:e460475719721d59cd54a350c1f71c797c763212c836bf48585478c5514d2854", size = 1716854 }, + { url = "https://files.pythonhosted.org/packages/02/23/9740be7f352ed3e29e9173758ad9c1471a344b54b8e38b8abae4174219ab/pydantic_core-2.23.2-cp310-none-win_amd64.whl", hash = "sha256:5f3cf3721eaf8741cffaf092487f1ca80831202ce91672776b02b875580e174a", size = 1917044 }, + { url = "https://files.pythonhosted.org/packages/06/22/ab062eefe57579e186c1aad47d1064bf9f710717c8b35817daa94617c1f6/pydantic_core-2.23.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7ce8e26b86a91e305858e018afc7a6e932f17428b1eaa60154bd1f7ee888b5f8", size = 1844143 }, + { url = "https://files.pythonhosted.org/packages/20/0e/f8cee28755de3cd50ba92e1daf06aac32ec949ecb072afcb49d61221d146/pydantic_core-2.23.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e9b24cca4037a561422bf5dc52b38d390fb61f7bfff64053ce1b72f6938e6b2", size = 1787515 }, + { url = "https://files.pythonhosted.org/packages/c0/e2/8b93dffd8ebca299924bfe119896179be965c9dada4fcc81bb63bb49dad0/pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:753294d42fb072aa1775bfe1a2ba1012427376718fa4c72de52005a3d2a22178", size = 1788263 }, + { url = "https://files.pythonhosted.org/packages/38/05/3a7e8682baddd5e06d9f54dadd67e2d66b8f71f79f5b46ec466fd8d110e4/pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:257d6a410a0d8aeb50b4283dea39bb79b14303e0fab0f2b9d617701331ed1515", size = 1780740 }, + { url = "https://files.pythonhosted.org/packages/d2/f0/8dbf5b3a78e2dbe0b4ed8e0ac1e0de53cd8bae82800b634680c8e69a3837/pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8319e0bd6a7b45ad76166cc3d5d6a36c97d0c82a196f478c3ee5346566eebfd", size = 1976823 }, + { url = "https://files.pythonhosted.org/packages/80/d2/5f3a40c0786bcc9b5bd16c2d5157dde2cf4dd7cf1c5827d82c78b935c1d3/pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a05c0240f6c711eb381ac392de987ee974fa9336071fb697768dfdb151345ce", size = 2706812 }, + { url = "https://files.pythonhosted.org/packages/9b/df/d58a06e2dec5abd54167537edee959d2f8f795c44f028a205c7de47c49fe/pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d5b0ff3218858859910295df6953d7bafac3a48d5cd18f4e3ed9999efd2245f", size = 2069873 }, + { url = "https://files.pythonhosted.org/packages/db/32/71f1042a1ebfa5bb74a9d0242ff1c79dba04d0080d69f7d49e96b1a72163/pydantic_core-2.23.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96ef39add33ff58cd4c112cbac076726b96b98bb8f1e7f7595288dcfb2f10b57", size = 1898755 }, + { url = "https://files.pythonhosted.org/packages/61/42/7323144ec46a6141d13ee0536eda7bcde1d7c1ba0d1a551d49967dde278a/pydantic_core-2.23.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0102e49ac7d2df3379ef8d658d3bc59d3d769b0bdb17da189b75efa861fc07b4", size = 1964385 }, + { url = "https://files.pythonhosted.org/packages/ba/a7/8a6be2bc6e01c35a19755b100be8eaa279981e9db3fdd95afe06d4e82b87/pydantic_core-2.23.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a6612c2a844043e4d10a8324c54cdff0042c558eef30bd705770793d70b224aa", size = 2110118 }, + { url = "https://files.pythonhosted.org/packages/eb/6a/2885057cebaf4fe2810bb730734c5b6a4578842b6cec094852fea8513275/pydantic_core-2.23.2-cp311-none-win32.whl", hash = "sha256:caffda619099cfd4f63d48462f6aadbecee3ad9603b4b88b60cb821c1b258576", size = 1715354 }, + { url = "https://files.pythonhosted.org/packages/f3/57/d798c53c9a115fe89f118a6b2b27d21e888eb22d4b53828344ce68035431/pydantic_core-2.23.2-cp311-none-win_amd64.whl", hash = "sha256:6f80fba4af0cb1d2344869d56430e304a51396b70d46b91a55ed4959993c0589", size = 1916403 }, + { url = "https://files.pythonhosted.org/packages/8f/54/9c202171aca4a9f6596e1e2a6572ff7d707e2383a40605533c61487714a2/pydantic_core-2.23.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:4c83c64d05ffbbe12d4e8498ab72bdb05bcc1026340a4a597dc647a13c1605ec", size = 1845364 }, + { url = "https://files.pythonhosted.org/packages/9e/e3/5c29d8fa6dfabd7809fe623fd17959e1b672410681a8c3811eefa42b8051/pydantic_core-2.23.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6294907eaaccf71c076abdd1c7954e272efa39bb043161b4b8aa1cd76a16ce43", size = 1784382 }, + { url = "https://files.pythonhosted.org/packages/79/c3/4b003c9ed0f5f5e559823802ee7a3921de8aa50892bf179535d7695b2e76/pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a801c5e1e13272e0909c520708122496647d1279d252c9e6e07dac216accc41", size = 1791189 }, + { url = "https://files.pythonhosted.org/packages/3f/3b/0c0377c5833093a1758e711387bbe5a5e30511fe9d4afe225be185527aa1/pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc0c316fba3ce72ac3ab7902a888b9dc4979162d320823679da270c2d9ad0cad", size = 1780431 }, + { url = "https://files.pythonhosted.org/packages/4a/88/18a541e2f9cbcef4b44b90a2ba98d85e109d91bc7f3b2210d271f66e6d8f/pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b06c5d4e8701ac2ba99a2ef835e4e1b187d41095a9c619c5b185c9068ed2a49", size = 1976902 }, + { url = "https://files.pythonhosted.org/packages/e3/bd/6c98ca605130ee51438d812133d04ddb403fb1a8a93490f9a1a7e269a4a7/pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82764c0bd697159fe9947ad59b6db6d7329e88505c8f98990eb07e84cc0a5d81", size = 2638882 }, + { url = "https://files.pythonhosted.org/packages/ad/fc/6b4f95c64bbeadaa6f84cffb51f469f6fdd61215d97b4ec8d89d027e574b/pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b1a195efd347ede8bcf723e932300292eb13a9d2a3c1f84eb8f37cbbc905b7f", size = 2111180 }, + { url = "https://files.pythonhosted.org/packages/95/4c/d4a355237ffa3dae28c2224bea9e09d4af69f346bf7611ebb66d8e930574/pydantic_core-2.23.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7efb12e5071ad8d5b547487bdad489fbd4a5a35a0fc36a1941517a6ad7f23e0", size = 1904338 }, + { url = "https://files.pythonhosted.org/packages/83/d6/9f41db9ab2727a050fdac5e56a9e792260aa29e29affa98b4df8a06fd588/pydantic_core-2.23.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5dd0ec5f514ed40e49bf961d49cf1bc2c72e9b50f29a163b2cc9030c6742aa73", size = 1968729 }, + { url = "https://files.pythonhosted.org/packages/f8/bd/0e795ea5eaf26a96d9691faae43603e00de3dbec6339e3710652f5feae12/pydantic_core-2.23.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:820f6ee5c06bc868335e3b6e42d7ef41f50dfb3ea32fbd523ab679d10d8741c0", size = 2120748 }, + { url = "https://files.pythonhosted.org/packages/be/7d/48ddf9f084b5de1d47c6bcccfeafe80900ceac975ebdf1fe374fd18654e5/pydantic_core-2.23.2-cp312-none-win32.whl", hash = "sha256:3713dc093d5048bfaedbba7a8dbc53e74c44a140d45ede020dc347dda18daf3f", size = 1725557 }, + { url = "https://files.pythonhosted.org/packages/e1/d7/ef3558714427b385f84e22a224c4641b4869f9031f733e83cea6f0eb0154/pydantic_core-2.23.2-cp312-none-win_amd64.whl", hash = "sha256:e1895e949f8849bc2757c0dbac28422a04be031204df46a56ab34bcf98507342", size = 1914466 }, + { url = "https://files.pythonhosted.org/packages/91/42/085e27e5c32220531bbd966c2888c74a595ac35cd7e52a71d3302d041b71/pydantic_core-2.23.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:da43cbe593e3c87d07108d0ebd73771dc414488f1f91ed2e204b0370b94b37ac", size = 1845030 }, + { url = "https://files.pythonhosted.org/packages/05/ac/3faf5fd29b221bb7c0aa4bc3fe1a763fb2d38b08d7b5961daeee96f13a8e/pydantic_core-2.23.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:64d094ea1aa97c6ded4748d40886076a931a8bf6f61b6e43e4a1041769c39dd2", size = 1784493 }, + { url = "https://files.pythonhosted.org/packages/83/52/cc692fc65098904a6bdb13cc502b590d0597718069a8ffa8bfdea680657c/pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:084414ffe9a85a52940b49631321d636dadf3576c30259607b75516d131fecd0", size = 1791193 }, + { url = "https://files.pythonhosted.org/packages/53/a9/d7be95020bf6a57ada1d5d18172369ad92e9779dccf9a497b22863d77dd8/pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043ef8469f72609c4c3a5e06a07a1f713d53df4d53112c6d49207c0bd3c3bd9b", size = 1780169 }, + { url = "https://files.pythonhosted.org/packages/10/42/16bee4df87a315a8ae3962e5c2251612bced849e624f850e811a2e6097da/pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3649bd3ae6a8ebea7dc381afb7f3c6db237fc7cebd05c8ac36ca8a4187b03b30", size = 1976946 }, + { url = "https://files.pythonhosted.org/packages/f8/04/8be7280cf309c256bd9fa124fbe15f730b9659ee87d89a40b7da14c6818f/pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6db09153d8438425e98cdc9a289c5fade04a5d2128faff8f227c459da21b9703", size = 2638776 }, + { url = "https://files.pythonhosted.org/packages/24/7b/e102b2073b08b36f78d7336859fe8d09e7483eded849cf12bd3db66f441d/pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5668b3173bb0b2e65020b60d83f5910a7224027232c9f5dc05a71a1deac9f960", size = 2110994 }, + { url = "https://files.pythonhosted.org/packages/90/29/ba00e3e262597203ae95c5eb81d02ce96afcda26b6960484b376a1e5ac59/pydantic_core-2.23.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1c7b81beaf7c7ebde978377dc53679c6cba0e946426fc7ade54251dfe24a7604", size = 1904219 }, + { url = "https://files.pythonhosted.org/packages/4b/ef/3899865e9f2e30b79c1ed9498a71898f33b1ca2a08f3b1619eaee754aa85/pydantic_core-2.23.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ae579143826c6f05a361d9546446c432a165ecf1c0b720bbfd81152645cb897d", size = 1968804 }, + { url = "https://files.pythonhosted.org/packages/19/e9/69742d9c71d08251184584c2b99d436490c78f7487840e588394b1ce97a9/pydantic_core-2.23.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:19f1352fe4b248cae22a89268720fc74e83f008057a652894f08fa931e77dced", size = 2120574 }, + { url = "https://files.pythonhosted.org/packages/5c/fc/7f89094bf3a645fb5b312b6ae907f3b04b6e0d1e37f6eb4c057276d80703/pydantic_core-2.23.2-cp313-none-win32.whl", hash = "sha256:e1a79ad49f346aa1a2921f31e8dbbab4d64484823e813a002679eaa46cba39e1", size = 1725472 }, + { url = "https://files.pythonhosted.org/packages/43/13/39f4b60884174a95c69e3dd196c77a1757c3857841f2567d240bcbd1cf0f/pydantic_core-2.23.2-cp313-none-win_amd64.whl", hash = "sha256:582871902e1902b3c8e9b2c347f32a792a07094110c1bca6c2ea89b90150caac", size = 1914614 }, + { url = "https://files.pythonhosted.org/packages/92/22/ec5bd81d9e526c6cf61f0db36adbec525cf82de502d46c47d1c01e88d1ea/pydantic_core-2.23.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:358331e21a897151e54d58e08d0219acf98ebb14c567267a87e971f3d2a3be59", size = 1846869 }, + { url = "https://files.pythonhosted.org/packages/3e/5b/c95ab67c0f6f483f8620ae6e3c891a4477cd3986ef3a57ce247fee391298/pydantic_core-2.23.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c4d9f15ffe68bcd3898b0ad7233af01b15c57d91cd1667f8d868e0eacbfe3f87", size = 1729814 }, + { url = "https://files.pythonhosted.org/packages/5f/c0/5a73650c8313fac0bf061e92322c16d518b1f40ca4ab4aa7ca3a1c293d27/pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0123655fedacf035ab10c23450163c2f65a4174f2bb034b188240a6cf06bb123", size = 1789905 }, + { url = "https://files.pythonhosted.org/packages/01/a3/0f79cc0b20293f377d9d9e20cb4aac2b0fbae05265c8b5a9efa54ad59470/pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e6e3ccebdbd6e53474b0bb7ab8b88e83c0cfe91484b25e058e581348ee5a01a5", size = 1782019 }, + { url = "https://files.pythonhosted.org/packages/80/92/11e5b7335cae6db8b8821d7bced7285a942c7f993b0cbd0d8f2a0cb87701/pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc535cb898ef88333cf317777ecdfe0faac1c2a3187ef7eb061b6f7ecf7e6bae", size = 1978512 }, + { url = "https://files.pythonhosted.org/packages/20/35/c5f6979920512f95b994a3a7b10487bc4d97f6dc764fd28d22ed3e641f11/pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aab9e522efff3993a9e98ab14263d4e20211e62da088298089a03056980a3e69", size = 2708860 }, + { url = "https://files.pythonhosted.org/packages/96/22/0c704ebeebfe744db499a783642adf00dd11d937828ad8df3c68c169ea45/pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05b366fb8fe3d8683b11ac35fa08947d7b92be78ec64e3277d03bd7f9b7cda79", size = 2070366 }, + { url = "https://files.pythonhosted.org/packages/87/8d/fb14d6b67d36afdf33359e2b8488ac4e3104187da81cbac591dba7952ba5/pydantic_core-2.23.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7568f682c06f10f30ef643a1e8eec4afeecdafde5c4af1b574c6df079e96f96c", size = 1900024 }, + { url = "https://files.pythonhosted.org/packages/1c/5d/96fae9ead7462b535e8bda7431948b360491da684299b7538e00e2a62039/pydantic_core-2.23.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cdd02a08205dc90238669f082747612cb3c82bd2c717adc60f9b9ecadb540f80", size = 1966643 }, + { url = "https://files.pythonhosted.org/packages/3a/39/13ddadba16f285400a779715cdac97dbfdbc488940708f513808c6ac0274/pydantic_core-2.23.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1a2ab4f410f4b886de53b6bddf5dd6f337915a29dd9f22f20f3099659536b2f6", size = 2111822 }, + { url = "https://files.pythonhosted.org/packages/ab/68/708503919bf048d0301a0ea58472ee8c72ce7b5f33e8d80c73d180fa8289/pydantic_core-2.23.2-cp39-none-win32.whl", hash = "sha256:0448b81c3dfcde439551bb04a9f41d7627f676b12701865c8a2574bcea034437", size = 1716955 }, + { url = "https://files.pythonhosted.org/packages/36/a6/da95007bf15e80bc11961c8d14307ae9b3a353a4dd4396da741c76ba4357/pydantic_core-2.23.2-cp39-none-win_amd64.whl", hash = "sha256:4cebb9794f67266d65e7e4cbe5dcf063e29fc7b81c79dc9475bd476d9534150e", size = 1919268 }, + { url = "https://files.pythonhosted.org/packages/33/e8/11e094b026a7c8b9289cd8b7e90eb9d6ce76a774ee67fde7300d1becbe3f/pydantic_core-2.23.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e758d271ed0286d146cf7c04c539a5169a888dd0b57026be621547e756af55bc", size = 1835254 }, + { url = "https://files.pythonhosted.org/packages/82/59/8eef255a6c8df7656a8bf2845d3b59ee3fe118f07db20d9166d4ff27e823/pydantic_core-2.23.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f477d26183e94eaafc60b983ab25af2a809a1b48ce4debb57b343f671b7a90b6", size = 1721793 }, + { url = "https://files.pythonhosted.org/packages/50/8b/83a0234b40d882fdbe3b37f34646f94b748ba05b7c68a189643fd263c5de/pydantic_core-2.23.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da3131ef2b940b99106f29dfbc30d9505643f766704e14c5d5e504e6a480c35e", size = 1782806 }, + { url = "https://files.pythonhosted.org/packages/6f/33/9fc6fabe81331ae7cb049228e8477b039fc0cdabf88ee20925ce9d08f595/pydantic_core-2.23.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329a721253c7e4cbd7aad4a377745fbcc0607f9d72a3cc2102dd40519be75ed2", size = 1930852 }, + { url = "https://files.pythonhosted.org/packages/49/3d/9ee7397d46f9ad27769642fc85ad9027651619932897d68d617911250bab/pydantic_core-2.23.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7706e15cdbf42f8fab1e6425247dfa98f4a6f8c63746c995d6a2017f78e619ae", size = 1894400 }, + { url = "https://files.pythonhosted.org/packages/7e/9b/daebf4e83e465f099d5ab34382b83c2d9a61025ef928d810e9ad651bea69/pydantic_core-2.23.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e64ffaf8f6e17ca15eb48344d86a7a741454526f3a3fa56bc493ad9d7ec63936", size = 1958993 }, + { url = "https://files.pythonhosted.org/packages/3c/dc/27968c6096ece1d8fcf235da6af6d5b4d0c7a1ae3c0aef0aa2b9319e96b5/pydantic_core-2.23.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:dd59638025160056687d598b054b64a79183f8065eae0d3f5ca523cde9943940", size = 2102383 }, + { url = "https://files.pythonhosted.org/packages/1e/7d/6e3a460d47dc95cb9b7337cddf90c3b431f348556711aabe633ba576808d/pydantic_core-2.23.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:12625e69b1199e94b0ae1c9a95d000484ce9f0182f9965a26572f054b1537e44", size = 1917629 }, + { url = "https://files.pythonhosted.org/packages/dd/1d/917f8fbd85712b457ac5ace1092325b95bddf410c579e98bdbfe44548af6/pydantic_core-2.23.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5d813fd871b3d5c3005157622ee102e8908ad6011ec915a18bd8fde673c4360e", size = 1836412 }, + { url = "https://files.pythonhosted.org/packages/0d/6a/505938ce8382f56e53a14fbe09725f336cfdd89d6cfc26dc6bf5f3804b1c/pydantic_core-2.23.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1eb37f7d6a8001c0f86dc8ff2ee8d08291a536d76e49e78cda8587bb54d8b329", size = 1722796 }, + { url = "https://files.pythonhosted.org/packages/5a/1e/60cec75a4a385852c8936c867fb53e59a73bb7aac69e79c82e629c0746ec/pydantic_core-2.23.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ce7eaf9a98680b4312b7cebcdd9352531c43db00fca586115845df388f3c465", size = 1783366 }, + { url = "https://files.pythonhosted.org/packages/96/8f/31979e696fa423ae3f902ac43d46d2fe2b49590b70527f28b2a2627b41f1/pydantic_core-2.23.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f087879f1ffde024dd2788a30d55acd67959dcf6c431e9d3682d1c491a0eb474", size = 1931953 }, + { url = "https://files.pythonhosted.org/packages/b5/ef/96b46c84a7c6cdd6a687eec3a3c7f23548fdfdccaec93a71e15f485dd04f/pydantic_core-2.23.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ce883906810b4c3bd90e0ada1f9e808d9ecf1c5f0b60c6b8831d6100bcc7dd6", size = 1894849 }, + { url = "https://files.pythonhosted.org/packages/ea/b7/4abd0b2152490e15a2b68b4fc31bde3325819ee83be0995fb7fc44ccb109/pydantic_core-2.23.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a8031074a397a5925d06b590121f8339d34a5a74cfe6970f8a1124eb8b83f4ac", size = 1959645 }, + { url = "https://files.pythonhosted.org/packages/4a/c9/6ce837055553ccfc1ccc495fd634518fa0e7d35d49bc00cfabfef83de52c/pydantic_core-2.23.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:23af245b8f2f4ee9e2c99cb3f93d0e22fb5c16df3f2f643f5a8da5caff12a653", size = 2103264 }, + { url = "https://files.pythonhosted.org/packages/66/37/f1dd40032f04606da68278697b3645e0d8a02e566c4f5b3d3baaa36e8ce1/pydantic_core-2.23.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c57e493a0faea1e4c38f860d6862ba6832723396c884fbf938ff5e9b224200e2", size = 1918108 }, +] + +[[package]] +name = "pygments" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, +] + +[[package]] +name = "pytest" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/8c/9862305bdcd6020bc7b45b1b5e7397a6caf1a33d3025b9a003b39075ffb2/pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce", size = 1439314 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/f9/cf155cf32ca7d6fa3601bc4c5dd19086af4b320b706919d48a4c79081cf9/pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", size = 341802 }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 }, +] + +[[package]] +name = "pytest-cov" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, +] + +[[package]] +name = "pytest-freezer" +version = "0.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "freezegun" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/fa/a93d40dd50f712c276a5a15f9c075bee932cc4d28c376e60b4a35904976d/pytest_freezer-0.4.8.tar.gz", hash = "sha256:8ee2f724b3ff3540523fa355958a22e6f4c1c819928b78a7a183ae4248ce6ee6", size = 3212 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/4e/ba488639516a341810aeaeb4b32b70abb0923e53f7c4d14d673dc114d35a/pytest_freezer-0.4.8-py3-none-any.whl", hash = "sha256:644ce7ddb8ba52b92a1df0a80a699bad2b93514c55cf92e9f2517b68ebe74814", size = 3228 }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, +] + +[[package]] +name = "pytest-sugar" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/ac/5754f5edd6d508bc6493bc37d74b928f102a5fff82d9a80347e180998f08/pytest-sugar-1.0.0.tar.gz", hash = "sha256:6422e83258f5b0c04ce7c632176c7732cab5fdb909cb39cca5c9139f81276c0a", size = 14992 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/fb/889f1b69da2f13691de09a111c16c4766a433382d44aa0ecf221deded44a/pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd", size = 10171 }, +] + +[[package]] +name = "pytest-timeout" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/0d/04719abc7a4bdb3a7a1f968f24b0f5253d698c9cc94975330e9d3145befb/pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9", size = 17697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/27/14af9ef8321f5edc7527e47def2a21d8118c6f329a9342cc61387a0c0599/pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e", size = 14148 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-kasa" +version = "0.7.2" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "async-timeout" }, + { name = "asyncclick" }, + { name = "cryptography" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +docs = [ + { name = "docutils" }, + { name = "myst-parser" }, + { name = "sphinx" }, + { name = "sphinx-rtd-theme" }, + { name = "sphinxcontrib-programoutput" }, +] +shell = [ + { name = "ptpython" }, + { name = "rich" }, +] +speedups = [ + { name = "kasa-crypt" }, + { name = "orjson" }, +] + +[package.dev-dependencies] +dev = [ + { name = "codecov" }, + { name = "coverage", extra = ["toml"] }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-freezer" }, + { name = "pytest-mock" }, + { name = "pytest-sugar" }, + { name = "pytest-timeout" }, + { name = "toml" }, + { name = "voluptuous" }, + { name = "xdoctest" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3" }, + { name = "async-timeout", specifier = ">=3.0.0" }, + { name = "asyncclick", specifier = ">=8.1.7" }, + { name = "cryptography", specifier = ">=1.9" }, + { name = "docutils", marker = "extra == 'docs'", specifier = ">=0.17" }, + { name = "kasa-crypt", marker = "extra == 'speedups'", specifier = ">=0.2.0" }, + { name = "myst-parser", marker = "extra == 'docs'" }, + { name = "orjson", marker = "extra == 'speedups'", specifier = ">=3.9.1" }, + { name = "ptpython", marker = "extra == 'shell'" }, + { name = "pydantic", specifier = ">=1.10.15" }, + { name = "rich", marker = "extra == 'shell'" }, + { name = "sphinx", marker = "extra == 'docs'", specifier = "~=5.0" }, + { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0" }, + { name = "sphinxcontrib-programoutput", marker = "extra == 'docs'", specifier = "~=0.0" }, + { name = "typing-extensions", specifier = ">=4.12.2,<5.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codecov" }, + { name = "coverage", extras = ["toml"] }, + { name = "mypy", specifier = "~=1.0" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-freezer", specifier = "~=0.4" }, + { name = "pytest-mock" }, + { name = "pytest-sugar" }, + { name = "pytest-timeout", specifier = "~=2.0" }, + { name = "toml" }, + { name = "voluptuous" }, + { name = "xdoctest", specifier = ">=1.2.0" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, + { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777 }, + { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318 }, + { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891 }, + { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614 }, + { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360 }, + { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006 }, + { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577 }, + { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593 }, + { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "rich" +version = "13.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/60/5959113cae0ce512cf246a6871c623117330105a0d5f59b4e26138f2c9cc/rich-13.8.0.tar.gz", hash = "sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4", size = 222072 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d9/c2a126eeae791e90ea099d05cb0515feea3688474b978343f3cdcfe04523/rich-13.8.0-py3-none-any.whl", hash = "sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc", size = 241597 }, +] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/7b/af302bebf22c749c56c9c3e8ae13190b5b5db37a33d9068652e8f73b7089/snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", size = 86699 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/dc/c02e01294f7265e63a7315fe086dd1df7dacb9f840a804da846b96d01b96/snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a", size = 93002 }, +] + +[[package]] +name = "sphinx" +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/b2/02a43597980903483fe5eb081ee8e0ba2bb62ea43a70499484343795f3bf/Sphinx-5.3.0.tar.gz", hash = "sha256:51026de0a9ff9fc13c05d74913ad66047e104f56a129ff73e174eb5c3ee794b5", size = 6811365 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/a7/01dd6fd9653c056258d65032aa09a615b5d7b07dd840845a9f41a8860fbc/sphinx-5.3.0-py3-none-any.whl", hash = "sha256:060ca5c9f7ba57a08a1219e547b269fadf125ae25b06b9fa7f66768efb652d6d", size = 3183160 }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/33/2a35a9cdbfda9086bda11457bcc872173ab3565b16b6d7f6b3efaa6dc3d6/sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b", size = 2785005 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/46/00fda84467815c29951a9c91e3ae7503c409ddad04373e7cfc78daad4300/sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586", size = 2824721 }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300 }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530 }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705 }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104 }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071 }, +] + +[[package]] +name = "sphinxcontrib-programoutput" +version = "0.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/fe/8a6d8763674b3d3814a6008a83eb8002b6da188710dd7f4654ec77b4a8ac/sphinxcontrib-programoutput-0.17.tar.gz", hash = "sha256:300ee9b8caee8355d25cc74b4d1c7efd12e608d2ad165e3141d31e6fbc152b7f", size = 24067 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/ee/b7be4b3f45f4e36bfa6c444cd234098e0d09880379c67a43e6bb9ab99a86/sphinxcontrib_programoutput-0.17-py2.py3-none-any.whl", hash = "sha256:0ef1c1d9159dbe7103077748214305eb4e0138e861feb71c0c346afc5fe97f84", size = 22131 }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743 }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072 }, +] + +[[package]] +name = "termcolor" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/56/d7d66a84f96d804155f6ff2873d065368b25a07222a6fd51c4f24ef6d764/termcolor-2.4.0.tar.gz", hash = "sha256:aab9e56047c8ac41ed798fa36d892a37aca6b3e9159f3e0c24bc64a9b3ac7b7a", size = 12664 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5f/8c716e47b3a50cbd7c146f45881e11d9414def768b7cd9c5e6650ec2a80a/termcolor-2.4.0-py3-none-any.whl", hash = "sha256:9297c0df9c99445c2412e832e882a7884038a25617c60cea2ad69488d4040d63", size = 7719 }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588 }, +] + +[[package]] +name = "tomli" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "tzdata" +version = "2024.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/5b/e025d02cb3b66b7b76093404392d4b44343c69101cc85f4d180dd5784717/tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd", size = 190559 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/58/f9c9e6be752e9fcb8b6a0ee9fb87e6e7a1f6bcab2cdc73f02bb7ba91ada0/tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252", size = 345370 }, +] + +[[package]] +name = "urllib3" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/6d/fa469ae21497ddc8bc93e5877702dca7cb8f911e337aca7452b5724f1bb6/urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168", size = 292266 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/1c/89ffc63a9605b583d5df2be791a27bc1a42b7c32bab68d3c8f2f73a98cd4/urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", size = 121444 }, +] + +[[package]] +name = "virtualenv" +version = "20.26.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/60/db9f95e6ad456f1872486769c55628c7901fb4de5a72c2f7bdd912abf0c1/virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a", size = 9057588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/4d/410156100224c5e2f0011d435e477b57aed9576fc7fe137abcf14ec16e11/virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589", size = 5684792 }, +] + +[[package]] +name = "voluptuous" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/af/a54ce0fb6f1d867e0b9f0efe5f082a691f51ccf705188fca67a3ecefd7f4/voluptuous-0.15.2.tar.gz", hash = "sha256:6ffcab32c4d3230b4d2af3a577c87e1908a714a11f6f95570456b1849b0279aa", size = 51651 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/a8/8f9cc6749331186e6a513bfe3745454f81d25f6e34c6024f88f80c71ed28/voluptuous-0.15.2-py3-none-any.whl", hash = "sha256:016348bc7788a9af9520b1764ebd4de0df41fe2138ebe9e06fa036bf86a65566", size = 31349 }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +] + +[[package]] +name = "xdoctest" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/a5/7f6dfdaf3a221e16ff79281d2a3c3e4b58989c92de8964a317feb1e6cbb5/xdoctest-1.2.0.tar.gz", hash = "sha256:d8cfca6d8991e488d33f756e600d35b9fdf5efd5c3a249d644efcbbbd2ed5863", size = 204804 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/b8/e4722f5e5f592a665cc8e55a334ea721c359f09574e6b987dc551a1e1f4c/xdoctest-1.2.0-py3-none-any.whl", hash = "sha256:0f1ecf5939a687bd1fc8deefbff1743c65419cce26dff908f8b84c93fbe486bc", size = 151194 }, +] + +[[package]] +name = "yarl" +version = "1.9.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/87/6d71456eabebf614e0cac4387c27116a0bff9decf00a70c362fe7db9394e/yarl-1.9.11.tar.gz", hash = "sha256:c7548a90cb72b67652e2cd6ae80e2683ee08fde663104528ac7df12d8ef271d2", size = 156445 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/83/c58c9d26650a978ca31e50d78c75337f735590456fa526d68e523be745de/yarl-1.9.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:79e08c691deae6fcac2fdde2e0515ac561dd3630d7c8adf7b1e786e22f1e193b", size = 189362 }, + { url = "https://files.pythonhosted.org/packages/41/c4/26fab071e1edb57bf4641b33e9e1244a6be54ed7bf09c1616f6fbea3dd54/yarl-1.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:752f4b5cf93268dc73c2ae994cc6d684b0dad5118bc87fbd965fd5d6dca20f45", size = 113808 }, + { url = "https://files.pythonhosted.org/packages/2a/d7/ef06d41dcf3aa74a4a764fa8fe4a22ac8403939ea52810aef63dd6591570/yarl-1.9.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:441049d3a449fb8756b0535be72c6a1a532938a33e1cf03523076700a5f87a01", size = 111733 }, + { url = "https://files.pythonhosted.org/packages/2f/b6/192f542bed622cb7d2bdee0870f80cf8cc8a5bf50bc097fd6c49dfa4ad6b/yarl-1.9.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3dfe17b4aed832c627319da22a33f27f282bd32633d6b145c726d519c89fbaf", size = 462530 }, + { url = "https://files.pythonhosted.org/packages/cd/c8/669afb2480e9a17a799793bf52ebdb2f98219f20694dc0481bc69fd783a2/yarl-1.9.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:67abcb7df27952864440c9c85f1c549a4ad94afe44e2655f77d74b0d25895454", size = 486919 }, + { url = "https://files.pythonhosted.org/packages/44/16/db843909a991c89d3790f532499da40d5004271eded649075cd50c2d0090/yarl-1.9.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6de3fa29e76fd1518a80e6af4902c44f3b1b4d7fed28eb06913bba4727443de3", size = 481778 }, + { url = "https://files.pythonhosted.org/packages/f9/25/c9a476477264549b578bfaf7f0a5d747735be2b11fdb8886859017dc07cb/yarl-1.9.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fee45b3bd4d8d5786472e056aa1359cc4dc9da68aded95a10cd7929a0ec661fe", size = 468269 }, + { url = "https://files.pythonhosted.org/packages/51/68/595a2c04b88f9e9811a5bcee6cdfb6429c399d82c19ff34b708442a254e6/yarl-1.9.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c59b23886234abeba62087fd97d10fb6b905d9e36e2f3465d1886ce5c0ca30df", size = 451927 }, + { url = "https://files.pythonhosted.org/packages/fa/b1/668c3033059d145b1b0698649ee5f2992cf634c0e82bac5bfad64907fec7/yarl-1.9.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d93c612b2024ac25a3dc01341fd98fdd19c8c5e2011f3dcd084b3743cba8d756", size = 463785 }, + { url = "https://files.pythonhosted.org/packages/b1/a6/a1877f466afef028aaaba53362c529e3f3d35774ebc081a70734281fb6cd/yarl-1.9.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4d368e3b9ecd50fa22017a20c49e356471af6ae91c4d788c6e9297e25ddf5a62", size = 467154 }, + { url = "https://files.pythonhosted.org/packages/b3/86/73a0b99489594338da308b8d40c8f2d052acef597eec6651029910113ac7/yarl-1.9.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5b593acd45cdd4cf6664d342ceacedf25cd95263b83b964fddd6c78930ea5211", size = 492198 }, + { url = "https://files.pythonhosted.org/packages/4c/09/d8adc8b12a5d7b71501a38978cfbaa52d71a0491089d86015ccff456d3aa/yarl-1.9.11-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:224f8186c220ff00079e64bf193909829144d4e5174bb58665ef0da8bf6955c4", size = 492219 }, + { url = "https://files.pythonhosted.org/packages/00/36/a800ec3944cccb7a0036e437bc4a1ed5eedd9c484f568709d81d8426dcf8/yarl-1.9.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:91c478741d7563a12162f7a2db96c0d23d93b0521563f1f1f0ece46ea1702d33", size = 478243 }, + { url = "https://files.pythonhosted.org/packages/47/15/8f92ea31941e4ecfb9bcff0df6eee16328ef0428415e8a3f756120270752/yarl-1.9.11-cp310-cp310-win32.whl", hash = "sha256:1cdb8f5bb0534986776a43df84031da7ff04ac0cf87cb22ae8a6368231949c40", size = 99998 }, + { url = "https://files.pythonhosted.org/packages/09/8b/0ef81fc4d52f9a9a641c8e052781c9879f848d8b53816ff1e9e2cec1881a/yarl-1.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:498439af143b43a2b2314451ffd0295410aa0dcbdac5ee18fc8633da4670b605", size = 109329 }, + { url = "https://files.pythonhosted.org/packages/4c/86/d37652d1e9a8e19db20ce470c1d6c188322b8cc05446dcd0b6642cc90e9d/yarl-1.9.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9e290de5db4fd4859b4ed57cddfe793fcb218504e65781854a8ac283ab8d5518", size = 189496 }, + { url = "https://files.pythonhosted.org/packages/91/b6/38f30c3c3b564a74d6cfe4b43f78305f7aec49ef351b25f7907db70e4c2a/yarl-1.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e5f50a2e26cc2b89186f04c97e0ec0ba107ae41f1262ad16832d46849864f914", size = 113748 }, + { url = "https://files.pythonhosted.org/packages/56/0e/039696b0a464c4f6d4e92c5a2e0c5c13922e42fa2558fa0579c628a6d9ac/yarl-1.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4a0e724a28d7447e4d549c8f40779f90e20147e94bf949d490402eee09845c6", size = 111872 }, + { url = "https://files.pythonhosted.org/packages/f7/0b/71fc2139381c43e48d51ac6cfab7507ef35037d76119916dc4ac36db5985/yarl-1.9.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85333d38a4fa5997fa2ff6fd169be66626d814b34fa35ec669e8c914ca50a097", size = 505183 }, + { url = "https://files.pythonhosted.org/packages/43/93/2066c20798795d91231164235eee7cc736f19422b93fb6a00deada9c7b6d/yarl-1.9.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ff184002ee72e4b247240e35d5dce4c2d9a0e81fdbef715dde79ab4718aa541", size = 526581 }, + { url = "https://files.pythonhosted.org/packages/46/2e/51c9e3b28b53e515cae2bc4c2825dcf75f5e7e76dc7a50a667312673e562/yarl-1.9.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:675004040f847c0284827f44a1fa92d8baf425632cc93e7e0aa38408774b07c1", size = 521188 }, + { url = "https://files.pythonhosted.org/packages/b7/a9/d82324fdad4ee62139c587262acb83f1a1d69ec1b3fc86dd7028200d9428/yarl-1.9.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30703a7ade2b53f02e09a30685b70cd54f65ed314a8d9af08670c9a5391af1b", size = 509063 }, + { url = "https://files.pythonhosted.org/packages/71/af/3b9c8d18be3c8b2fbdf1f4e9a639849ab743025876a252b0ade3463823b1/yarl-1.9.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7230007ab67d43cf19200ec15bc6b654e6b85c402f545a6fc565d254d34ff754", size = 490352 }, + { url = "https://files.pythonhosted.org/packages/1f/a5/46aaeeb9f043d7ed83573210656478fbd47937d861572f6e04b3c60cd135/yarl-1.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8c2cf0c7ad745e1c6530fe6521dfb19ca43338239dfcc7da165d0ef2332c0882", size = 505028 }, + { url = "https://files.pythonhosted.org/packages/65/14/12d3f6dfc0d260085226f432429ae04c0cb80ef34224a0ccab50f4112ef1/yarl-1.9.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4567cc08f479ad80fb07ed0c9e1bcb363a4f6e3483a490a39d57d1419bf1c4c7", size = 503268 }, + { url = "https://files.pythonhosted.org/packages/02/9c/6f901ba254f659f24937dbb975c00ca6163f30a3d181f724138829b7d893/yarl-1.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:95adc179a02949c4560ef40f8f650a008380766eb253d74232eb9c024747c111", size = 534922 }, + { url = "https://files.pythonhosted.org/packages/f3/0d/26312f14700e07a0f707f60a0e6f9382a3f23fa1667f8f601ae96a3a7e77/yarl-1.9.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:755ae9cff06c429632d750aa8206f08df2e3d422ca67be79567aadbe74ae64cc", size = 538629 }, + { url = "https://files.pythonhosted.org/packages/34/94/17e241c753d7ea63baea87895d6b0ec858e6e2344e088e996d10d00786ad/yarl-1.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:94f71d54c5faf715e92c8434b4a0b968c4d1043469954d228fc031d51086f143", size = 520201 }, + { url = "https://files.pythonhosted.org/packages/02/8f/98a5eb47248f233e70b16dece4181f9e0ec66b07c0f19272e26d27579fa2/yarl-1.9.11-cp311-cp311-win32.whl", hash = "sha256:4ae079573efeaa54e5978ce86b77f4175cd32f42afcaf9bfb8a0677e91f84e4e", size = 99927 }, + { url = "https://files.pythonhosted.org/packages/4e/cc/7b21d1466c8c7a7feeb03736176081df9d4813c8900053fe9c5b89419893/yarl-1.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:9fae7ec5c9a4fe22abb995804e6ce87067dfaf7e940272b79328ce37c8f22097", size = 109672 }, + { url = "https://files.pythonhosted.org/packages/ba/ce/cb879750a618b977bc2dd57dae79f7173626a35294a2911c3749f5618f93/yarl-1.9.11-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:614fa50fd0db41b79f426939a413d216cdc7bab8d8c8a25844798d286a999c5a", size = 189965 }, + { url = "https://files.pythonhosted.org/packages/8c/fc/20e7083cc89e5856d59483692e41d623bd9bd1c158d2fb543ee3f6c05535/yarl-1.9.11-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ff64f575d71eacb5a4d6f0696bfe991993d979423ea2241f23ab19ff63f0f9d1", size = 114312 }, + { url = "https://files.pythonhosted.org/packages/7c/80/e027f7c1f51a6eb178657937cceffb175716d18929f054982debb5db9101/yarl-1.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c23f6dc3d7126b4c64b80aa186ac2bb65ab104a8372c4454e462fb074197bc6", size = 111989 }, + { url = "https://files.pythonhosted.org/packages/88/aa/ffea918291c455305475b7850b2cf970f5a80e6dfbcde9b94a5eacdfe8ec/yarl-1.9.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8f847cc092c2b85d22e527f91ea83a6cf51533e727e2461557a47a859f96734", size = 505169 }, + { url = "https://files.pythonhosted.org/packages/67/9e/86f55c8f70868203ab9cdb168e1821a64a13d9a0e35e5e077152c121b534/yarl-1.9.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63a5dc2866791236779d99d7a422611d22bb3a3d50935bafa4e017ea13e51469", size = 522267 }, + { url = "https://files.pythonhosted.org/packages/77/d1/eb10e0ce4f91cc82e19a775341b5d8b06e249f6decbd24267d9009c68a2a/yarl-1.9.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c335342d482e66254ae94b1231b1532790afb754f89e2e0c646f7f19d09740aa", size = 519634 }, + { url = "https://files.pythonhosted.org/packages/2c/b5/a2ca969c82583fc7d2073d3f6461f67c0e7fc8cd4aec5175c8cc7847eab4/yarl-1.9.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4a8c3dedd081cca134a21179aebe58b6e426e8d1e0202da9d1cafa56e01af3c", size = 511879 }, + { url = "https://files.pythonhosted.org/packages/e4/92/724d7eb7b8dca0ae97a9054ba41742e6599d4c94e1e090e29ae3d54aaf7c/yarl-1.9.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:504d19320c92532cabc3495fb7ed6bb599f3c2bfb45fed432049bf4693dbd6d0", size = 488601 }, + { url = "https://files.pythonhosted.org/packages/33/49/0f3d021ca989640d173495846a6832eb124e53a2ce4ed64b9036a4e1683c/yarl-1.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b2a8e5eb18181060197e3d5db7e78f818432725c0759bc1e5a9d603d9246389", size = 507354 }, + { url = "https://files.pythonhosted.org/packages/04/99/161a2365a804dfec9e5ff5c44c6c09c64ba87bcf979b915a54a5a4a37d9e/yarl-1.9.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f568d70b7187f4002b6b500c0996c37674a25ce44b20716faebe5fdb8bd356e7", size = 506537 }, + { url = "https://files.pythonhosted.org/packages/91/dc/f389be705187434423ad70bae78a55243fb16a07e4b80d8d97c5fb821fe0/yarl-1.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:735b285ea46ca7e86ad261a462a071d0968aade44e1a3ea2b7d4f3d63b5aab12", size = 529698 }, + { url = "https://files.pythonhosted.org/packages/39/da/ab9ee8f9de9cae329b4a3f3c115acd18138cca1a2fb78ec10c20f9f114b5/yarl-1.9.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2d1c81c3b92bef0c1c180048e43a5a85754a61b4f69d6f84df8e4bd615bef25d", size = 540822 }, + { url = "https://files.pythonhosted.org/packages/c9/42/cad7ffe2469282b91b84ebc8cfc79bfbd0c7cce182fa0da445d6980c25dc/yarl-1.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8d6e1c1562b53bd26efd38e886fc13863b8d904d559426777990171020c478a9", size = 525804 }, + { url = "https://files.pythonhosted.org/packages/12/e9/bb4db60dc9e63e593b98d59d68006fa3267065eed36a60d1774fb855f46f/yarl-1.9.11-cp312-cp312-win32.whl", hash = "sha256:aeba4aaa59cb709edb824fa88a27cbbff4e0095aaf77212b652989276c493c00", size = 99851 }, + { url = "https://files.pythonhosted.org/packages/58/06/830f22113154c2ed995ed1d62305b619ba15346c0b27f5a1a6224b0f464b/yarl-1.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:569309a3efb8369ff5d32edb2a0520ebaf810c3059f11d34477418c90aa878fd", size = 109687 }, + { url = "https://files.pythonhosted.org/packages/e1/30/8410476ca3d94603d7137cf5f70fb677438aed855dddc0af3262d5799cf7/yarl-1.9.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4915818ac850c3b0413e953af34398775b7a337babe1e4d15f68c8f5c4872553", size = 186811 }, + { url = "https://files.pythonhosted.org/packages/64/b5/2e69a2a42d8c8e5f777ea0fb1e894ccd5436d815fde17ccdaa8907593798/yarl-1.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef9610b2f5a73707d4d8bac040f0115ca848e510e3b1f45ca53e97f609b54130", size = 112675 }, + { url = "https://files.pythonhosted.org/packages/7d/a1/ea9a0b6972e407bb938b5dbaec8dbd075120ff3a8d326b64d5fefd61c5b3/yarl-1.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:47c0a3dc8076a8dd159de10628dea04215bc7ddaa46c5775bf96066a0a18f82b", size = 110581 }, + { url = "https://files.pythonhosted.org/packages/c6/df/67deae2f0967512f8aa7250b995f0fa12e90bc6cde859e764f031ae8b64d/yarl-1.9.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:545f2fbfa0c723b446e9298b5beba0999ff82ce2c126110759e8dac29b5deaf4", size = 487012 }, + { url = "https://files.pythonhosted.org/packages/a4/87/fa5e6b325d90c3688053a12f713fd1cd8427e136c2c5aeebb330522903ee/yarl-1.9.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9137975a4ccc163ad5d7a75aad966e6e4e95dedee08d7995eab896a639a0bce2", size = 502359 }, + { url = "https://files.pythonhosted.org/packages/30/38/c305323bd6b5f79542e1769dd6c54c7f9f1331e2852678ba653db279a50e/yarl-1.9.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b0c70c451d2a86f8408abced5b7498423e2487543acf6fcf618b03f6e669b0a", size = 503314 }, + { url = "https://files.pythonhosted.org/packages/f9/eb/5361af39d4d0b9852df2eee24744796f66c130efbca7de29bdca68b00a04/yarl-1.9.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce2bd986b1e44528677c237b74d59f215c8bfcdf2d69442aa10f62fd6ab2951c", size = 494560 }, + { url = "https://files.pythonhosted.org/packages/b4/c4/c0e80d9727c03b6398bfcf0350f289a9987dbb7df5fd3d4d4fb42f4dd2e4/yarl-1.9.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8d7b717f77846a9631046899c6cc730ea469c0e2fb252ccff1cc119950dbc296", size = 471861 }, + { url = "https://files.pythonhosted.org/packages/ea/f7/e9812de6bab1fd29c51d1ca21490a48e5793769a7c2b888d9702b351e7f8/yarl-1.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3a26a24bbd19241283d601173cea1e5b93dec361a223394e18a1e8e5b0ef20bd", size = 491463 }, + { url = "https://files.pythonhosted.org/packages/6e/72/e68c36c5b41126e74dafd43e82cb735d147c78f13162da817cafbe2a2516/yarl-1.9.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c189bf01af155ac9882e128d9f3b3ad68a1f2c2f51404afad7201305df4e12b1", size = 493787 }, + { url = "https://files.pythonhosted.org/packages/2f/1b/c8501fd2257d86e40f651060f743e80c25cd0d117e56c73f356ff2f8a515/yarl-1.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0cbcc2c54084b2bda4109415631db017cf2960f74f9e8fd1698e1400e4f8aae2", size = 509913 }, + { url = "https://files.pythonhosted.org/packages/c8/c6/ed68fdade11e5135e875294699ffe3ef2932eab2e44b4b86bbc2982d7b51/yarl-1.9.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:30f201bc65941a4aa59c1236783efe89049ec5549dafc8cd2b63cc179d3767b0", size = 520697 }, + { url = "https://files.pythonhosted.org/packages/50/1f/dcb441a46864db3a998a14fa47b42f88fb4857e29691a93c114f5ca5b1d9/yarl-1.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:922ba3b74f0958a0b5b9c14ff1ef12714a381760c08018f2b9827632783a590c", size = 511026 }, + { url = "https://files.pythonhosted.org/packages/4e/c7/2a874b498ab31613a6656a2b5821b268be44dff2d90a2a46125190bc68c1/yarl-1.9.11-cp313-cp313-win32.whl", hash = "sha256:17107b4b8c43e66befdcbe543fff2f9c93f7a3a9f8e3a9c9ac42bffeba0e8828", size = 484296 }, + { url = "https://files.pythonhosted.org/packages/25/ea/a264724a77278e89055ec3e9f75c8870960be799ee7d5f3ba39ac6422aeb/yarl-1.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:0324506afab4f2e176a93cb08b8abcb8b009e1f324e6cbced999a8f5dd9ddb76", size = 492263 }, + { url = "https://files.pythonhosted.org/packages/09/4b/b8752234ba384770cb11a055eba290986b94bdf51a7282f00202006bc9e6/yarl-1.9.11-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d65ad67f981e93ea11f87815f67d086c4f33da4800cf2106d650dd8a0b79dda4", size = 192167 }, + { url = "https://files.pythonhosted.org/packages/ed/b3/3b91a9ad129d85fab03aa84b4b8ed964ba99cd298df696c0be55c6a8f987/yarl-1.9.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:752c0d33b4aacdb147871d0754b88f53922c6dc2aff033096516b3d5f0c02a0f", size = 115382 }, + { url = "https://files.pythonhosted.org/packages/ed/90/831333852eddbb04e426fda3626ccbeba41b3839516d089199f153a4385b/yarl-1.9.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:54cc24be98d7f4ff355ca2e725a577e19909788c0db6beead67a0dda70bd3f82", size = 113174 }, + { url = "https://files.pythonhosted.org/packages/16/10/4e27a96b75073625fd0d2d15065ab36b9d7aa922866ae4061421d35bfd39/yarl-1.9.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c82126817492bb2ebc946e74af1ffa10aacaca81bee360858477f96124be39a", size = 468798 }, + { url = "https://files.pythonhosted.org/packages/8f/8a/1bafeef3310ad40ebf61e56ddb34ca24ea839975869b678a760396567cca/yarl-1.9.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8503989860d7ac10c85cb5b607fec003a45049cf7a5b4b72451e87893c6bb990", size = 496003 }, + { url = "https://files.pythonhosted.org/packages/41/4c/5120253a87fd1d31a275ab60746157315beb11818b29a12f551abad6f0bd/yarl-1.9.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:475e09a67f8b09720192a170ad9021b7abf7827ffd4f3a83826317a705be06b7", size = 489783 }, + { url = "https://files.pythonhosted.org/packages/73/bb/5ca3cd5ac49fe1f0934fd2544e8b4dc25383a6f0dca78c2b8a5084fecf93/yarl-1.9.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afcac5bda602b74ff701e1f683feccd8cce0d5a21dbc68db81bf9bd8fd93ba56", size = 475065 }, + { url = "https://files.pythonhosted.org/packages/55/43/a705d99801883754cbee6c2a94a29f74b1e4977806077f24a21ff26a5881/yarl-1.9.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aaeffcb84faceb2923a94a8a9aaa972745d3c728ab54dd011530cc30a3d5d0c1", size = 458525 }, + { url = "https://files.pythonhosted.org/packages/f7/7a/27f9e55e781c44dee295822431e1167ed54cb5d613ac0fa857c5dc67e172/yarl-1.9.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:51a6f770ac86477cd5c553f88a77a06fe1f6f3b643b053fcc7902ab55d6cbe14", size = 472346 }, + { url = "https://files.pythonhosted.org/packages/70/e1/b4b1c66d5fb8e7bff310739f8c81a31c0cda9d1e0374c6348c5c03c20ed6/yarl-1.9.11-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3fcd056cb7dff3aea5b1ee1b425b0fbaa2fbf6a1c6003e88caf524f01de5f395", size = 474324 }, + { url = "https://files.pythonhosted.org/packages/fb/3a/14e0150de1aaaddbb1715b4fc0a19af3e66c1e4137271ee71c659a473087/yarl-1.9.11-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:21e56c30e39a1833e4e3fd0112dde98c2abcbc4c39b077e6105c76bb63d2aa04", size = 500743 }, + { url = "https://files.pythonhosted.org/packages/be/07/745f898427835a4d044c9e1586e7baeef1adb379f17dd63ad957de3ba92e/yarl-1.9.11-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0a205ec6349879f5e75dddfb63e069a24f726df5330b92ce76c4752a436aac01", size = 500393 }, + { url = "https://files.pythonhosted.org/packages/69/88/8001321678b500df92238b325e67ead9541e592f557c288b623f44ddf155/yarl-1.9.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a5706821e1cf3c70dfea223e4e0958ea354f4e2af9420a1bd45c6b547297fb97", size = 486873 }, + { url = "https://files.pythonhosted.org/packages/d0/c3/41b3bd7a61367ef73b43bc53fa0855dad13eb3e460648d4d913349d24ba7/yarl-1.9.11-cp39-cp39-win32.whl", hash = "sha256:cc295969f8c2172b5d013c0871dccfec7a0e1186cf961e7ea575d47b4d5cbd32", size = 101030 }, + { url = "https://files.pythonhosted.org/packages/f5/01/b6cd8aa377d35c8b7fd152f0e8ae359e0f36982a758e98d484aa313b8093/yarl-1.9.11-cp39-cp39-win_amd64.whl", hash = "sha256:55a67dd29367ce7c08a0541bb602ec0a2c10d46c86b94830a1a665f7fd093dfa", size = 110518 }, + { url = "https://files.pythonhosted.org/packages/a0/20/ae694bcd16b49e83a9f943ebc2f0a90c9ca6bb09846e99a7bda30e1773d9/yarl-1.9.11-py3-none-any.whl", hash = "sha256:c6f6c87665a9e18a635f0545ea541d9640617832af2317d4f5ad389686b4ed3d", size = 36423 }, +] + +[[package]] +name = "zipp" +version = "3.20.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/8b/1239a3ef43a0d0ebdca623fb6413bc7702c321400c5fdd574f0b7aa0fbb4/zipp-3.20.1.tar.gz", hash = "sha256:c22b14cc4763c5a5b04134207736c107db42e9d3ef2d9779d465f5f1bcba572b", size = 23848 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/9e/c96f7a4cd0bf5625bb409b7e61e99b1130dc63a98cb8b24aeabae62d43e8/zipp-3.20.1-py3-none-any.whl", hash = "sha256:9960cd8967c8f85a56f920d5d507274e74f9ff813a0ab8889a5b5be2daf44064", size = 8988 }, +] From 2a89e58ae0299ca7329c989fc94715cbd51c1154 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 10 Sep 2024 18:20:00 +0200 Subject: [PATCH 566/892] Add missing type hints to alarm module (#1111) --- kasa/smart/modules/alarm.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index 439bc5716..1dacf1814 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Literal + from ...feature import Feature from ..smartmodule import SmartModule @@ -94,7 +96,7 @@ def _initialize_features(self): ) @property - def alarm_sound(self): + def alarm_sound(self) -> str: """Return current alarm sound.""" return self.data["get_alarm_configure"]["type"] @@ -113,11 +115,11 @@ def alarm_sounds(self) -> list[str]: return self.data["get_support_alarm_type_list"]["alarm_type_list"] @property - def alarm_volume(self): + def alarm_volume(self) -> Literal["low", "normal", "high"]: """Return alarm volume.""" return self.data["get_alarm_configure"]["volume"] - async def set_alarm_volume(self, volume: str): + async def set_alarm_volume(self, volume: Literal["low", "normal", "high"]): """Set alarm volume.""" payload = self.data["get_alarm_configure"].copy() payload["volume"] = volume @@ -134,10 +136,10 @@ def source(self) -> str | None: src = self._device.sys_info["in_alarm_source"] return src if src else None - async def play(self): + async def play(self) -> dict: """Play alarm.""" return await self.call("play_alarm") - async def stop(self): + async def stop(self) -> dict: """Stop alarm.""" return await self.call("stop_alarm") From fcf8f07232c5abdcb1aa2710af5d7edd6229febe Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 10 Sep 2024 17:24:38 +0100 Subject: [PATCH 567/892] Do not regenerate aes key pair (#1114) And read it from `device_config` if provided. This is required as key generation can eat up cpu when a device is not fully available and the library is retrying. --- kasa/aestransport.py | 13 +++++++++++-- kasa/deviceconfig.py | 13 +++++++++++-- kasa/tests/test_aestransport.py | 24 ++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 81dc79a85..0048bd122 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -106,6 +106,9 @@ def __init__( self._session_cookie: dict[str, str] | None = None self._key_pair: KeyPair | None = None + if config.aes_keys: + aes_keys = config.aes_keys + self._key_pair = KeyPair(aes_keys["private"], aes_keys["public"]) self._app_url = URL(f"http://{self._host}:{self._port}/app") self._token_url: URL | None = None @@ -271,7 +274,14 @@ async def _generate_key_pair_payload(self) -> AsyncGenerator: can be made to the device. """ _LOGGER.debug("Generating keypair") - self._key_pair = KeyPair.create_key_pair() + if not self._key_pair: + kp = KeyPair.create_key_pair() + self._config.aes_keys = { + "private": kp.get_private_key(), + "public": kp.get_public_key(), + } + self._key_pair = kp + pub_key = ( "-----BEGIN PUBLIC KEY-----\n" + self._key_pair.get_public_key() # type: ignore[union-attr] @@ -286,7 +296,6 @@ async def perform_handshake(self) -> None: """Perform the handshake.""" _LOGGER.debug("Will perform handshaking...") - self._key_pair = None self._token_url = None self._session_expire_at = None self._session_cookie = None diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index a04a81d09..0833c0798 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -34,7 +34,7 @@ import logging from dataclasses import asdict, dataclass, field, fields, is_dataclass from enum import Enum -from typing import TYPE_CHECKING, Dict, Optional, Union +from typing import TYPE_CHECKING, Dict, Optional, TypedDict, Union from .credentials import Credentials from .exceptions import KasaException @@ -45,6 +45,13 @@ _LOGGER = logging.getLogger(__name__) +class KeyPairDict(TypedDict): + """Class to represent a public/private key pair.""" + + private: str + public: str + + class DeviceEncryptionType(Enum): """Encrypt type enum.""" @@ -182,7 +189,7 @@ class DeviceConfig: #: The batch size for protoools supporting multiple request batches. connection_type: DeviceConnectionParameters = field( default_factory=lambda: DeviceConnectionParameters( - DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor, 1 + DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor ) ) #: True if the device uses http. Consumers should retrieve rather than set this @@ -193,6 +200,8 @@ class DeviceConfig: #: Set a custom http_client for the device to use. http_client: Optional["ClientSession"] = field(default=None, compare=False) + aes_keys: Optional[KeyPairDict] = None + def __post_init__(self): if self.connection_type is None: self.connection_type = DeviceConnectionParameters( diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 36e8c3d62..53d838581 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -80,6 +80,29 @@ async def test_handshake( assert transport._state is TransportState.LOGIN_REQUIRED +async def test_handshake_with_keys(mocker): + host = "127.0.0.1" + mock_aes_device = MockAesDevice(host) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) + + test_keys = { + "private": "MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAMo/JQpXIbP2M3bLOKyfEVCURFCxHIXv4HDME8J58AL4BwGDXf0oQycgj9nV+T/MzgEd/4iVysYuYfLuIEKXADP7Lby6AfA/dbcinZZ7bLUNMNa7TaylIvVKtSfR0LV8AmG0jdQYkr4cTzLAEd+AEs/wG3nMQNEcoQRVY+svLPDjAgMBAAECgYBCsDOch0KbvrEVmMklUoY5Fcq4+M249HIDf6d8VwznTbWxsAmL8nzCKCCG6eF4QiYjhCrAdPQaCS1PF2oXywbLhngid/9W9gz4CKKDJChs1X8KvLi+TLg1jgJUXvq9yVNh1CB+lS2ho4gdDDCbVmiVOZR5TDfEf0xeJ+Zz3zlUEQJBAPkhuNdc3yRue8huFZbrWwikURQPYBxLOYfVTDsfV9mZGSkGoWS1FPDsxrqSXugTmcTRuw+lrXKDabJ72kqywA8CQQDP0oaGh5r7F12Xzcwb7X9JkTvyr+rO8YgVtKNBaNVOPabAzysNwOlvH/sNCVQcRj8rn5LNXitgLx6T+Q5uqa3tAkA7J0elUzbkhps7ju/vYri9x448zh3K+g2R9BJio2GPmCuCM0HVEK4FOqNBH4oLXsQPGKFq6LLTUuKg74l4XRL/AkBHBO6r8pNn0yhMxCtIL/UbsuIFoVBgv/F9WWmg5K5gOnlN0n4oCRC8xPUKE3IG54qW4cVNIS05hWCxuJ7R+nJRAkByt/+kX1nQxis2wIXj90fztXG3oSmoVaieYxaXPxlWvX3/Q5kslFF5UsGy9gcK0v2PXhqjTbhud3/X0Er6YP4v", + "public": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDKPyUKVyGz9jN2yzisnxFQlERQsRyF7+BwzBPCefAC+AcBg139KEMnII/Z1fk/zM4BHf+IlcrGLmHy7iBClwAz+y28ugHwP3W3Ip2We2y1DTDWu02spSL1SrUn0dC1fAJhtI3UGJK+HE8ywBHfgBLP8Bt5zEDRHKEEVWPrLyzw4wIDAQAB", + } + transport = AesTransport( + config=DeviceConfig( + host, credentials=Credentials("foo", "bar"), aes_keys=test_keys + ) + ) + + assert transport._encryption_session is None + assert transport._state is TransportState.HANDSHAKE_REQUIRED + + await transport.perform_handshake() + assert transport._key_pair.get_private_key() == test_keys["private"] + assert transport._key_pair.get_public_key() == test_keys["public"] + + @status_parameters async def test_login(mocker, status_code, error_code, inner_error_code, expectation): host = "127.0.0.1" @@ -97,6 +120,7 @@ async def test_login(mocker, status_code, error_code, inner_error_code, expectat with expectation: await transport.perform_login() assert mock_aes_device.token in str(transport._token_url) + assert transport._config.aes_keys == transport._key_pair @pytest.mark.parametrize( From 5df6c769b81714493d2f69d75a2b2332a7c058e6 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 10 Sep 2024 17:55:39 +0100 Subject: [PATCH 568/892] Prepare 0.7.3 (#1116) ## [0.7.3](https://github.com/python-kasa/python-kasa/tree/0.7.3) (2024-09-10) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.2...0.7.3) **Release summary:** - Migrate from `poetry` to `uv` for package/project management. - Various minor code improvements **Project maintenance:** - Do not regenerate aes key pair [\#1114](https://github.com/python-kasa/python-kasa/pull/1114) (@sdb9696) - Fix tests due to yarl URL str output change [\#1112](https://github.com/python-kasa/python-kasa/pull/1112) (@sdb9696) - Add missing type hints to alarm module [\#1111](https://github.com/python-kasa/python-kasa/pull/1111) (@rytilahti) - Add KH100 EU fixtures [\#1109](https://github.com/python-kasa/python-kasa/pull/1109) (@rytilahti) - Migrate from poetry to uv for dependency and package management [\#986](https://github.com/python-kasa/python-kasa/pull/986) (@sdb9696) --- CHANGELOG.md | 21 +- pyproject.toml | 2 +- uv.lock | 512 +++++++++++++++++++++++++------------------------ 3 files changed, 280 insertions(+), 255 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e38b57e2f..a5ba8f6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.7.3](https://github.com/python-kasa/python-kasa/tree/0.7.3) (2024-09-10) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.2...0.7.3) + +**Release summary:** + +- Migrate from `poetry` to `uv` for package/project management. +- Various minor code improvements + +**Project maintenance:** + +- Do not regenerate aes key pair [\#1114](https://github.com/python-kasa/python-kasa/pull/1114) (@sdb9696) +- Fix tests due to yarl URL str output change [\#1112](https://github.com/python-kasa/python-kasa/pull/1112) (@sdb9696) +- Add missing type hints to alarm module [\#1111](https://github.com/python-kasa/python-kasa/pull/1111) (@rytilahti) +- Add KH100 EU fixtures [\#1109](https://github.com/python-kasa/python-kasa/pull/1109) (@rytilahti) +- Migrate from poetry to uv for dependency and package management [\#986](https://github.com/python-kasa/python-kasa/pull/986) (@sdb9696) + ## [0.7.2](https://github.com/python-kasa/python-kasa/tree/0.7.2) (2024-08-30) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.1...0.7.2) @@ -27,11 +44,11 @@ **Project maintenance:** +- Remove top level await xdoctest fixture [\#1098](https://github.com/python-kasa/python-kasa/pull/1098) (@sdb9696) +- Enable python 3.13, allow pre-releases for CI [\#1086](https://github.com/python-kasa/python-kasa/pull/1086) (@rytilahti) - Add flake8-pytest-style \(PT\) for ruff [\#1105](https://github.com/python-kasa/python-kasa/pull/1105) (@rytilahti) - Add flake8-logging \(LOG\) and flake8-logging-format \(G\) for ruff [\#1104](https://github.com/python-kasa/python-kasa/pull/1104) (@rytilahti) - Add missing typing\_extensions dependency [\#1101](https://github.com/python-kasa/python-kasa/pull/1101) (@sdb9696) -- Remove top level await xdoctest fixture [\#1098](https://github.com/python-kasa/python-kasa/pull/1098) (@sdb9696) -- Enable python 3.13, allow pre-releases for CI [\#1086](https://github.com/python-kasa/python-kasa/pull/1086) (@rytilahti) ## [0.7.1](https://github.com/python-kasa/python-kasa/tree/0.7.1) (2024-07-31) diff --git a/pyproject.toml b/pyproject.toml index 041dd804f..9f986af08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-kasa" -version = "0.7.2" +version = "0.7.3" description = "Python API for TP-Link Kasa and Tapo devices" license = {text = "GPL-3.0-or-later"} authors = [ { name = "python-kasa developers" }] diff --git a/uv.lock b/uv.lock index dfffab36d..9beac2fa5 100644 --- a/uv.lock +++ b/uv.lock @@ -518,11 +518,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.15.4" +version = "3.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/08/dd/49e06f09b6645156550fb9aee9cc1e59aba7efbc972d665a1bd6ae0435d4/filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb", size = 18007 } +sdist = { url = "https://files.pythonhosted.org/packages/e6/76/3981447fd369539aba35797db99a8e2ff7ed01d9aa63e9344a31658b8d81/filelock-3.16.0.tar.gz", hash = "sha256:81de9eb8453c769b63369f87f11131a7ab04e367f8d97ad39dc230daa07e3bec", size = 18008 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/f0/48285f0262fe47103a4a45972ed2f9b93e4c80b8fd609fa98da78b2a5706/filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7", size = 16159 }, + { url = "https://files.pythonhosted.org/packages/2f/95/f9310f35376024e1086c59cbb438d319fc9a4ef853289ce7c661539edbd4/filelock-3.16.0-py3-none-any.whl", hash = "sha256:f6ed4c963184f4c84dd5557ce8fece759a3724b37b80c6c4f20a2f63a4dc6609", size = 16170 }, ] [[package]] @@ -795,71 +795,89 @@ wheels = [ [[package]] name = "multidict" -version = "6.0.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/79/722ca999a3a09a63b35aac12ec27dfa8e5bb3a38b0f857f7a1a209a88836/multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da", size = 59867 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/36/48097b96135017ed1b806c5ea27b6cdc2ed3a6861c5372b793563206c586/multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9", size = 50955 }, - { url = "https://files.pythonhosted.org/packages/d9/48/037440edb5d4a1c65e002925b2f24071d6c27754e6f4734f63037e3169d6/multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604", size = 30361 }, - { url = "https://files.pythonhosted.org/packages/a4/eb/d8e7693c9064554a1585698d1902839440c6c695b0f53c9a8be5d9d4a3b8/multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600", size = 30508 }, - { url = "https://files.pythonhosted.org/packages/f3/7d/fe7648d4b2f200f8854066ce6e56bf51889abfaf859814c62160dd0e32a9/multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c", size = 126318 }, - { url = "https://files.pythonhosted.org/packages/8d/ea/0230b6faa9a5bc10650fd50afcc4a86e6c37af2fe05bc679b74d79253732/multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5", size = 133998 }, - { url = "https://files.pythonhosted.org/packages/36/6d/d2f982fb485175727a193b4900b5f929d461e7aa87d6fb5a91a377fcc9c0/multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f", size = 129150 }, - { url = "https://files.pythonhosted.org/packages/33/62/2c9085e571318d51212a6914566fe41dd0e33d7f268f7e2f23dcd3f06c56/multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae", size = 124266 }, - { url = "https://files.pythonhosted.org/packages/ce/e2/88cdfeaf03eab3498f688a19b62ca704d371cd904cb74b682541ca7b20a7/multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182", size = 116637 }, - { url = "https://files.pythonhosted.org/packages/12/4d/99dfc36872dcc53956879f5da80a6505bbd29214cce90ce792a86e15fddf/multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf", size = 155908 }, - { url = "https://files.pythonhosted.org/packages/c2/5c/1e76b2c742cb9e0248d1e8c4ed420817879230c833fa27d890b5fd22290b/multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442", size = 147111 }, - { url = "https://files.pythonhosted.org/packages/bc/84/9579004267e1cc5968ef2ef8718dab9d8950d99354d85b739dd67b09c273/multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a", size = 160502 }, - { url = "https://files.pythonhosted.org/packages/11/b7/bef33e84e3722bc42531af020d7ae8c31235ce8846bacaa852b6484cf868/multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef", size = 156587 }, - { url = "https://files.pythonhosted.org/packages/26/ce/f745a2d6104e56f7fa0d7d0756bb9ed27b771dd7b8d9d7348cd7f0f7b9de/multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc", size = 151948 }, - { url = "https://files.pythonhosted.org/packages/f1/50/714da64281d2b2b3b4068e84f115e1ef3bd3ed3715b39503ff3c59e8d30d/multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319", size = 25734 }, - { url = "https://files.pythonhosted.org/packages/ef/3d/ba0dc18e96c5d83731c54129819d5892389e180f54ebb045c6124b2e8b87/multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8", size = 28182 }, - { url = "https://files.pythonhosted.org/packages/5f/da/b10ea65b850b54f44a6479177c6987f456bc2d38f8dc73009b78afcf0ede/multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba", size = 50815 }, - { url = "https://files.pythonhosted.org/packages/21/db/3403263f158b0bc7b0d4653766d71cb39498973f2042eead27b2e9758782/multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e", size = 30269 }, - { url = "https://files.pythonhosted.org/packages/02/c1/b15ecceb6ffa5081ed2ed450aea58d65b0e0358001f2b426705f9f41f4c2/multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd", size = 30500 }, - { url = "https://files.pythonhosted.org/packages/3f/e1/7fdd0f39565df3af87d6c2903fb66a7d529fbd0a8a066045d7a5b6ad1145/multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3", size = 130751 }, - { url = "https://files.pythonhosted.org/packages/76/bc/9f593f9e38c6c09bbf0344b56ad67dd53c69167937c2edadee9719a5e17d/multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf", size = 138185 }, - { url = "https://files.pythonhosted.org/packages/28/32/d7799a208701d537b92705f46c777ded812a6dc139c18d8ed599908f6b1c/multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29", size = 133585 }, - { url = "https://files.pythonhosted.org/packages/52/ec/be54a3ad110f386d5bd7a9a42a4ff36b3cd723ebe597f41073a73ffa16b8/multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed", size = 128684 }, - { url = "https://files.pythonhosted.org/packages/36/e1/a680eabeb71e25d4733276d917658dfa1cd3a99b1223625dbc247d266c98/multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733", size = 120994 }, - { url = "https://files.pythonhosted.org/packages/ef/08/08f4f44a8a43ea4cee13aa9cdbbf4a639af8db49310a0637ca389c4cf817/multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f", size = 159689 }, - { url = "https://files.pythonhosted.org/packages/aa/a9/46cdb4cb40bbd4b732169413f56b04a6553460b22bd914f9729c9ba63761/multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4", size = 150611 }, - { url = "https://files.pythonhosted.org/packages/e9/32/35668bb3e6ab2f12f4e4f7f4000f72f714882a94f904d4c3633fbd036753/multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1", size = 164444 }, - { url = "https://files.pythonhosted.org/packages/fa/10/f1388a91552af732d8ec48dab928abc209e732767e9e8f92d24c3544353c/multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc", size = 160158 }, - { url = "https://files.pythonhosted.org/packages/14/c3/f602601f1819983e018156e728e57b3f19726cb424b543667faab82f6939/multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e", size = 156072 }, - { url = "https://files.pythonhosted.org/packages/82/a6/0290af8487326108c0d03d14f8a0b8b1001d71e4494df5f96ab0c88c0b88/multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c", size = 25731 }, - { url = "https://files.pythonhosted.org/packages/88/aa/ea217cb18325aa05cb3e3111c19715f1e97c50a4a900cbc20e54648de5f5/multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea", size = 28176 }, - { url = "https://files.pythonhosted.org/packages/90/9c/7fda9c0defa09538c97b1f195394be82a1f53238536f70b32eb5399dfd4e/multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e", size = 49575 }, - { url = "https://files.pythonhosted.org/packages/be/21/d6ca80dd1b9b2c5605ff7475699a8ff5dc6ea958cd71fb2ff234afc13d79/multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b", size = 29638 }, - { url = "https://files.pythonhosted.org/packages/9c/18/9565f32c19d186168731e859692dfbc0e98f66a1dcf9e14d69c02a78b75a/multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5", size = 29874 }, - { url = "https://files.pythonhosted.org/packages/4e/4e/3815190e73e6ef101b5681c174c541bf972a1b064e926e56eea78d06e858/multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450", size = 129914 }, - { url = "https://files.pythonhosted.org/packages/0c/08/bb47f886457e2259aefc10044e45c8a1b62f0c27228557e17775869d0341/multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496", size = 134589 }, - { url = "https://files.pythonhosted.org/packages/d5/2f/952f79b5f0795cf4e34852fc5cf4dfda6166f63c06c798361215b69c131d/multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a", size = 133259 }, - { url = "https://files.pythonhosted.org/packages/24/1f/af976383b0b772dd351210af5b60ff9927e3abb2f4a103e93da19a957da0/multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226", size = 130779 }, - { url = "https://files.pythonhosted.org/packages/fc/b1/b0a7744be00b0f5045c7ed4e4a6b8ee6bde4672b2c620474712299df5979/multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271", size = 120125 }, - { url = "https://files.pythonhosted.org/packages/d0/bf/2a1d667acf11231cdf0b97a6cd9f30e7a5cf847037b5cf6da44884284bd0/multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb", size = 167095 }, - { url = "https://files.pythonhosted.org/packages/5e/e8/ad6ee74b1a2050d3bc78f566dabcc14c8bf89cbe87eecec866c011479815/multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef", size = 155823 }, - { url = "https://files.pythonhosted.org/packages/45/7c/06926bb91752c52abca3edbfefac1ea90d9d1bc00c84d0658c137589b920/multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24", size = 170233 }, - { url = "https://files.pythonhosted.org/packages/3c/29/3dd36cf6b9c5abba8b97bba84eb499a168ba59c3faec8829327b3887d123/multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6", size = 169035 }, - { url = "https://files.pythonhosted.org/packages/60/47/9a0f43470c70bbf6e148311f78ef5a3d4996b0226b6d295bdd50fdcfe387/multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda", size = 166229 }, - { url = "https://files.pythonhosted.org/packages/1d/23/c1b7ae7a0b8a3e08225284ef3ecbcf014b292a3ee821bc4ed2185fd4ce7d/multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5", size = 25840 }, - { url = "https://files.pythonhosted.org/packages/4a/68/66fceb758ad7a88993940dbdf3ac59911ba9dc46d7798bf6c8652f89f853/multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556", size = 27905 }, - { url = "https://files.pythonhosted.org/packages/c6/7c/c8f4445389c0bbc5ea85d1e737233c257f314d0f836a6644e097a5ef512f/multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929", size = 50828 }, - { url = "https://files.pythonhosted.org/packages/7d/5c/c364a77b37f580cc28da4194b77ed04286c7631933d3e64fdae40f1972e2/multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9", size = 30315 }, - { url = "https://files.pythonhosted.org/packages/1a/25/f4b60a34dde70c475f4dcaeb4796c256db80d2e03198052d0c3cee5d5fbb/multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a", size = 30451 }, - { url = "https://files.pythonhosted.org/packages/d0/10/2ff646c471e84af25fe8111985ffb8ec85a3f6e1ade8643bfcfcc0f4d2b1/multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1", size = 125880 }, - { url = "https://files.pythonhosted.org/packages/c9/ee/a4775297550dfb127641bd335d00d6d896e4ba5cf0216f78654e5ad6ac80/multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e", size = 133606 }, - { url = "https://files.pythonhosted.org/packages/7d/e9/95746d0c7c40bb0f43fc5424b7d7cf783e8638ce67f05fa677fff9ad76bb/multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046", size = 128720 }, - { url = "https://files.pythonhosted.org/packages/39/a9/1f8d42c8103bcb1da6bb719f1bc018594b5acc8eae56b3fec4720ebee225/multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c", size = 123750 }, - { url = "https://files.pythonhosted.org/packages/b5/f8/c8abbe7c425497d8bf997b1fffd9650ca175325ff397fadc9d63ae5dc027/multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40", size = 116213 }, - { url = "https://files.pythonhosted.org/packages/c2/bb/242664de860cd1201f4d207f0bd2011c1a730877e1dbffbe5d6ec4089e2d/multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527", size = 155410 }, - { url = "https://files.pythonhosted.org/packages/f6/5b/35d20c85b8ccd0c9afc47b8dd46e028b6650ad9660a4b6ad191301d220f5/multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9", size = 146668 }, - { url = "https://files.pythonhosted.org/packages/1b/52/6e984685d048f6728807c3fd9b8a6e3e3d51a06a4d6665d6e0102115455d/multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38", size = 160140 }, - { url = "https://files.pythonhosted.org/packages/76/c0/3aa6238557ed1d235be70d9c3f86d63a835c421b76073b8ce06bf32725e8/multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479", size = 156185 }, - { url = "https://files.pythonhosted.org/packages/85/82/02ed81023b5812582bf7c46e8e2868ffd6a29f8c313af1dd76e82e243c39/multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c", size = 151518 }, - { url = "https://files.pythonhosted.org/packages/d8/00/fd6eef9830046c063939cbf119c101898cbb611ea20301ae911b74caca19/multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b", size = 25732 }, - { url = "https://files.pythonhosted.org/packages/58/a3/4d2c1b4d1859c89d9ce48a4ae410ee019485e324e484b0160afdba8cc42b/multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755", size = 28181 }, - { url = "https://files.pythonhosted.org/packages/fa/a2/17e1e23c6be0a916219c5292f509360c345b5fa6beeb50d743203c27532c/multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7", size = 9729 }, +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/be/504b89a5e9ca731cd47487e91c469064f8ae5af93b7259758dcfc2b9c848/multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a", size = 64002 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/68/259dee7fd14cf56a17c554125e534f6274c2860159692a414d0b402b9a6d/multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60", size = 48628 }, + { url = "https://files.pythonhosted.org/packages/50/79/53ba256069fe5386a4a9e80d4e12857ced9de295baf3e20c68cdda746e04/multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1", size = 29327 }, + { url = "https://files.pythonhosted.org/packages/ff/10/71f1379b05b196dae749b5ac062e87273e3f11634f447ebac12a571d90ae/multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53", size = 29689 }, + { url = "https://files.pythonhosted.org/packages/71/45/70bac4f87438ded36ad4793793c0095de6572d433d98575a5752629ef549/multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5", size = 126639 }, + { url = "https://files.pythonhosted.org/packages/80/cf/17f35b3b9509b4959303c05379c4bfb0d7dd05c3306039fc79cf035bbac0/multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581", size = 134315 }, + { url = "https://files.pythonhosted.org/packages/ef/1f/652d70ab5effb33c031510a3503d4d6efc5ec93153562f1ee0acdc895a57/multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56", size = 129471 }, + { url = "https://files.pythonhosted.org/packages/a6/64/2dd6c4c681688c0165dea3975a6a4eab4944ea30f35000f8b8af1df3148c/multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429", size = 124585 }, + { url = "https://files.pythonhosted.org/packages/87/56/e6ee5459894c7e554b57ba88f7257dc3c3d2d379cb15baaa1e265b8c6165/multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748", size = 116957 }, + { url = "https://files.pythonhosted.org/packages/36/9e/616ce5e8d375c24b84f14fc263c7ef1d8d5e8ef529dbc0f1df8ce71bb5b8/multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db", size = 128609 }, + { url = "https://files.pythonhosted.org/packages/8c/4f/4783e48a38495d000f2124020dc96bacc806a4340345211b1ab6175a6cb4/multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056", size = 123016 }, + { url = "https://files.pythonhosted.org/packages/3e/b3/4950551ab8fc39862ba5e9907dc821f896aa829b4524b4deefd3e12945ab/multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76", size = 133542 }, + { url = "https://files.pythonhosted.org/packages/96/4d/f0ce6ac9914168a2a71df117935bb1f1781916acdecbb43285e225b484b8/multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160", size = 130163 }, + { url = "https://files.pythonhosted.org/packages/be/72/17c9f67e7542a49dd252c5ae50248607dfb780bcc03035907dafefb067e3/multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7", size = 126832 }, + { url = "https://files.pythonhosted.org/packages/71/9f/72d719e248cbd755c8736c6d14780533a1606ffb3fbb0fbd77da9f0372da/multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0", size = 26402 }, + { url = "https://files.pythonhosted.org/packages/04/5a/d88cd5d00a184e1ddffc82aa2e6e915164a6d2641ed3606e766b5d2f275a/multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d", size = 28800 }, + { url = "https://files.pythonhosted.org/packages/93/13/df3505a46d0cd08428e4c8169a196131d1b0c4b515c3649829258843dde6/multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6", size = 48570 }, + { url = "https://files.pythonhosted.org/packages/f0/e1/a215908bfae1343cdb72f805366592bdd60487b4232d039c437fe8f5013d/multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156", size = 29316 }, + { url = "https://files.pythonhosted.org/packages/70/0f/6dc70ddf5d442702ed74f298d69977f904960b82368532c88e854b79f72b/multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb", size = 29640 }, + { url = "https://files.pythonhosted.org/packages/d8/6d/9c87b73a13d1cdea30b321ef4b3824449866bd7f7127eceed066ccb9b9ff/multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b", size = 131067 }, + { url = "https://files.pythonhosted.org/packages/cc/1e/1b34154fef373371fd6c65125b3d42ff5f56c7ccc6bfff91b9b3c60ae9e0/multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72", size = 138507 }, + { url = "https://files.pythonhosted.org/packages/fb/e0/0bc6b2bac6e461822b5f575eae85da6aae76d0e2a79b6665d6206b8e2e48/multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304", size = 133905 }, + { url = "https://files.pythonhosted.org/packages/ba/af/73d13b918071ff9b2205fcf773d316e0f8fefb4ec65354bbcf0b10908cc6/multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351", size = 129004 }, + { url = "https://files.pythonhosted.org/packages/74/21/23960627b00ed39643302d81bcda44c9444ebcdc04ee5bedd0757513f259/multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb", size = 121308 }, + { url = "https://files.pythonhosted.org/packages/8b/5c/cf282263ffce4a596ed0bb2aa1a1dddfe1996d6a62d08842a8d4b33dca13/multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3", size = 132608 }, + { url = "https://files.pythonhosted.org/packages/d7/3e/97e778c041c72063f42b290888daff008d3ab1427f5b09b714f5a8eff294/multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399", size = 127029 }, + { url = "https://files.pythonhosted.org/packages/47/ac/3efb7bfe2f3aefcf8d103e9a7162572f01936155ab2f7ebcc7c255a23212/multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423", size = 137594 }, + { url = "https://files.pythonhosted.org/packages/42/9b/6c6e9e8dc4f915fc90a9b7798c44a30773dea2995fdcb619870e705afe2b/multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3", size = 134556 }, + { url = "https://files.pythonhosted.org/packages/1d/10/8e881743b26aaf718379a14ac58572a240e8293a1c9d68e1418fb11c0f90/multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753", size = 130993 }, + { url = "https://files.pythonhosted.org/packages/45/84/3eb91b4b557442802d058a7579e864b329968c8d0ea57d907e7023c677f2/multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80", size = 26405 }, + { url = "https://files.pythonhosted.org/packages/9f/0b/ad879847ecbf6d27e90a6eabb7eff6b62c129eefe617ea45eae7c1f0aead/multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926", size = 28795 }, + { url = "https://files.pythonhosted.org/packages/fd/16/92057c74ba3b96d5e211b553895cd6dc7cc4d1e43d9ab8fafc727681ef71/multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa", size = 48713 }, + { url = "https://files.pythonhosted.org/packages/94/3d/37d1b8893ae79716179540b89fc6a0ee56b4a65fcc0d63535c6f5d96f217/multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436", size = 29516 }, + { url = "https://files.pythonhosted.org/packages/a2/12/adb6b3200c363062f805275b4c1e656be2b3681aada66c80129932ff0bae/multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761", size = 29557 }, + { url = "https://files.pythonhosted.org/packages/47/e9/604bb05e6e5bce1e6a5cf80a474e0f072e80d8ac105f1b994a53e0b28c42/multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e", size = 130170 }, + { url = "https://files.pythonhosted.org/packages/7e/13/9efa50801785eccbf7086b3c83b71a4fb501a4d43549c2f2f80b8787d69f/multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef", size = 134836 }, + { url = "https://files.pythonhosted.org/packages/bf/0f/93808b765192780d117814a6dfcc2e75de6dcc610009ad408b8814dca3ba/multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95", size = 133475 }, + { url = "https://files.pythonhosted.org/packages/d3/c8/529101d7176fe7dfe1d99604e48d69c5dfdcadb4f06561f465c8ef12b4df/multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925", size = 131049 }, + { url = "https://files.pythonhosted.org/packages/ca/0c/fc85b439014d5a58063e19c3a158a889deec399d47b5269a0f3b6a2e28bc/multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966", size = 120370 }, + { url = "https://files.pythonhosted.org/packages/db/46/d4416eb20176492d2258fbd47b4abe729ff3b6e9c829ea4236f93c865089/multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305", size = 125178 }, + { url = "https://files.pythonhosted.org/packages/5b/46/73697ad7ec521df7de5531a32780bbfd908ded0643cbe457f981a701457c/multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2", size = 119567 }, + { url = "https://files.pythonhosted.org/packages/cd/ed/51f060e2cb0e7635329fa6ff930aa5cffa17f4c7f5c6c3ddc3500708e2f2/multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2", size = 129822 }, + { url = "https://files.pythonhosted.org/packages/df/9e/ee7d1954b1331da3eddea0c4e08d9142da5f14b1321c7301f5014f49d492/multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6", size = 128656 }, + { url = "https://files.pythonhosted.org/packages/77/00/8538f11e3356b5d95fa4b024aa566cde7a38aa7a5f08f4912b32a037c5dc/multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3", size = 125360 }, + { url = "https://files.pythonhosted.org/packages/be/05/5d334c1f2462d43fec2363cd00b1c44c93a78c3925d952e9a71caf662e96/multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133", size = 26382 }, + { url = "https://files.pythonhosted.org/packages/a3/bf/f332a13486b1ed0496d624bcc7e8357bb8053823e8cd4b9a18edc1d97e73/multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1", size = 28529 }, + { url = "https://files.pythonhosted.org/packages/22/67/1c7c0f39fe069aa4e5d794f323be24bf4d33d62d2a348acdb7991f8f30db/multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008", size = 48771 }, + { url = "https://files.pythonhosted.org/packages/3c/25/c186ee7b212bdf0df2519eacfb1981a017bda34392c67542c274651daf23/multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f", size = 29533 }, + { url = "https://files.pythonhosted.org/packages/67/5e/04575fd837e0958e324ca035b339cea174554f6f641d3fb2b4f2e7ff44a2/multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28", size = 29595 }, + { url = "https://files.pythonhosted.org/packages/d3/b2/e56388f86663810c07cfe4a3c3d87227f3811eeb2d08450b9e5d19d78876/multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b", size = 130094 }, + { url = "https://files.pythonhosted.org/packages/6c/ee/30ae9b4186a644d284543d55d491fbd4239b015d36b23fea43b4c94f7052/multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c", size = 134876 }, + { url = "https://files.pythonhosted.org/packages/84/c7/70461c13ba8ce3c779503c70ec9d0345ae84de04521c1f45a04d5f48943d/multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3", size = 133500 }, + { url = "https://files.pythonhosted.org/packages/4a/9f/002af221253f10f99959561123fae676148dd730e2daa2cd053846a58507/multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44", size = 131099 }, + { url = "https://files.pythonhosted.org/packages/82/42/d1c7a7301d52af79d88548a97e297f9d99c961ad76bbe6f67442bb77f097/multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2", size = 120403 }, + { url = "https://files.pythonhosted.org/packages/68/f3/471985c2c7ac707547553e8f37cff5158030d36bdec4414cb825fbaa5327/multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3", size = 125348 }, + { url = "https://files.pythonhosted.org/packages/67/2c/e6df05c77e0e433c214ec1d21ddd203d9a4770a1f2866a8ca40a545869a0/multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa", size = 119673 }, + { url = "https://files.pythonhosted.org/packages/c5/cd/bc8608fff06239c9fb333f9db7743a1b2eafe98c2666c9a196e867a3a0a4/multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa", size = 129927 }, + { url = "https://files.pythonhosted.org/packages/44/8e/281b69b7bc84fc963a44dc6e0bbcc7150e517b91df368a27834299a526ac/multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4", size = 128711 }, + { url = "https://files.pythonhosted.org/packages/12/a4/63e7cd38ed29dd9f1881d5119f272c898ca92536cdb53ffe0843197f6c85/multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6", size = 125519 }, + { url = "https://files.pythonhosted.org/packages/38/e0/4f5855037a72cd8a7a2f60a3952d9aa45feedb37ae7831642102604e8a37/multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81", size = 26426 }, + { url = "https://files.pythonhosted.org/packages/7e/a5/17ee3a4db1e310b7405f5d25834460073a8ccd86198ce044dfaf69eac073/multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774", size = 28531 }, + { url = "https://files.pythonhosted.org/packages/e7/c9/9e153a6572b38ac5ff4434113af38acf8d5e9957897cdb1f513b3d6614ed/multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c", size = 48550 }, + { url = "https://files.pythonhosted.org/packages/76/f5/79565ddb629eba6c7f704f09a09df085c8dc04643b12506f10f718cee37a/multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1", size = 29298 }, + { url = "https://files.pythonhosted.org/packages/60/1b/9851878b704bc98e641a3e0bce49382ae9e05743dac6d97748feb5b7baba/multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c", size = 29641 }, + { url = "https://files.pythonhosted.org/packages/89/87/d451d45aab9e422cb0fb2f7720c31a4c1d3012c740483c37f642eba568fb/multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c", size = 126202 }, + { url = "https://files.pythonhosted.org/packages/fa/b4/27cbe9f3e2e469359887653f2e45470272eef7295139916cc21107c6b48c/multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f", size = 133925 }, + { url = "https://files.pythonhosted.org/packages/4d/a3/afc841899face8adfd004235ce759a37619f6ec99eafd959650c5ce4df57/multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875", size = 129039 }, + { url = "https://files.pythonhosted.org/packages/5e/41/0d0fb18c1ad574f807196f5f3d99164edf9de3e169a58c6dc2d6ed5742b9/multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255", size = 124072 }, + { url = "https://files.pythonhosted.org/packages/00/22/defd7a2e71a44e6e5b9a5428f972e5b572e7fe28e404dfa6519bbf057c93/multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30", size = 116532 }, + { url = "https://files.pythonhosted.org/packages/91/25/f7545102def0b1d456ab6449388eed2dfd822debba1d65af60194904a23a/multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057", size = 128173 }, + { url = "https://files.pythonhosted.org/packages/45/79/3dbe8d35fc99f5ea610813a72ab55f426cb9cf482f860fa8496e5409be11/multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657", size = 122654 }, + { url = "https://files.pythonhosted.org/packages/97/cb/209e735eeab96e1b160825b5d0b36c56d3862abff828fc43999bb957dcad/multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28", size = 133197 }, + { url = "https://files.pythonhosted.org/packages/e4/3a/a13808a7ada62808afccea67837a79d00ad6581440015ef00f726d064c2d/multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972", size = 129754 }, + { url = "https://files.pythonhosted.org/packages/77/dd/8540e139eafb240079242da8f8ffdf9d3f4b4ad1aac5a786cd4050923783/multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43", size = 126402 }, + { url = "https://files.pythonhosted.org/packages/86/99/e82e1a275d8b1ea16d3a251474262258dbbe41c05cce0c01bceda1fc8ea5/multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada", size = 26421 }, + { url = "https://files.pythonhosted.org/packages/86/1c/9fa630272355af7e4446a2c7550c259f11ee422ab2d30ff90a0a71cf3d9e/multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a", size = 28791 }, + { url = "https://files.pythonhosted.org/packages/99/b7/b9e70fde2c0f0c9af4cc5277782a89b66d35948ea3369ec9f598358c3ac5/multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506", size = 10051 }, ] [[package]] @@ -1005,11 +1023,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.2.2" +version = "4.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/52/0763d1d976d5c262df53ddda8d8d4719eedf9594d046f117c25a27261a19/platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3", size = 20916 } +sdist = { url = "https://files.pythonhosted.org/packages/75/a0/d7cab8409cdc7d39b037c85ac46d92434fb6595432e069251b38e5c8dd0e/platformdirs-4.3.2.tar.gz", hash = "sha256:9e5e27a08aa095dd127b9f2e764d74254f482fef22b0970773bfba79d091ab8c", size = 21276 } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/13/2aa1f0e1364feb2c9ef45302f387ac0bd81484e9c9a4c5688a322fbdfd08/platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", size = 18146 }, + { url = "https://files.pythonhosted.org/packages/da/8b/d497999c4017b80678017ddce745cf675489c110681ad3c84a55eddfd3e7/platformdirs-4.3.2-py3-none-any.whl", hash = "sha256:eb1c8582560b34ed4ba105009a4badf7f6f85768b30126f351328507b2beb617", size = 18417 }, ] [[package]] @@ -1075,104 +1093,103 @@ wheels = [ [[package]] name = "pydantic" -version = "2.9.0" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, - { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/8f/3b9f7a38caa3fa0bcb3cea7ee9958e89a9a6efc0e6f51fd6096f24cac140/pydantic-2.9.0.tar.gz", hash = "sha256:c7a8a9fdf7d100afa49647eae340e2d23efa382466a8d177efcd1381e9be5598", size = 768298 } +sdist = { url = "https://files.pythonhosted.org/packages/14/15/3d989541b9c8128b96d532cfd2dd10131ddcc75a807330c00feb3d42a5bd/pydantic-2.9.1.tar.gz", hash = "sha256:1363c7d975c7036df0db2b4a61f2e062fbc0aa5ab5f2772e0ffc7191a4f4bce2", size = 768511 } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/38/95bdb5dfcebad2c11c88f7aa2d635fe53a0b7405ef39a6850c8bced455d4/pydantic-2.9.0-py3-none-any.whl", hash = "sha256:f66a7073abd93214a20c5f7b32d56843137a7a2e70d02111f3be287035c45370", size = 434325 }, + { url = "https://files.pythonhosted.org/packages/e4/28/fff23284071bc1ba419635c7e86561c8b9b8cf62a5bcb459b92d7625fd38/pydantic-2.9.1-py3-none-any.whl", hash = "sha256:7aff4db5fdf3cf573d4b3c30926a510a10e19a0774d38fc4967f78beb6deb612", size = 434363 }, ] [[package]] name = "pydantic-core" -version = "2.23.2" +version = "2.23.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/03/54e4961dfaed4804fea0ad73e94d337f4ef88a635e73990d6e150b469594/pydantic_core-2.23.2.tar.gz", hash = "sha256:95d6bf449a1ac81de562d65d180af5d8c19672793c81877a2eda8fde5d08f2fd", size = 401901 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/3b/cf2605095ebf48dff52463b269dfccdb4a225b430d9b3c0c11a7ca094dab/pydantic_core-2.23.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7d0324a35ab436c9d768753cbc3c47a865a2cbc0757066cb864747baa61f6ece", size = 1846549 }, - { url = "https://files.pythonhosted.org/packages/05/d2/b5c65bcf82537957686959339d1d4440bdf2b71ff0dbee46bf1f6de43841/pydantic_core-2.23.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:276ae78153a94b664e700ac362587c73b84399bd1145e135287513442e7dfbc7", size = 1790171 }, - { url = "https://files.pythonhosted.org/packages/aa/f9/a0b2d02aa618ea55140d77c72d8a69c036d1f14f8d2e95760fd653e1f75a/pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:964c7aa318da542cdcc60d4a648377ffe1a2ef0eb1e996026c7f74507b720a78", size = 1789391 }, - { url = "https://files.pythonhosted.org/packages/c9/6a/f5a8b2a1f869fff2fcd41cf37f0ce611004a1ece335427a31867707bb25e/pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1cf842265a3a820ebc6388b963ead065f5ce8f2068ac4e1c713ef77a67b71f7c", size = 1781839 }, - { url = "https://files.pythonhosted.org/packages/17/d2/76af5e05a9ccad46a139366f53cb2dbaf237b7389bfdb7d7fd0c36eedaf4/pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae90b9e50fe1bd115b24785e962b51130340408156d34d67b5f8f3fa6540938e", size = 1978248 }, - { url = "https://files.pythonhosted.org/packages/57/67/af3fa1cf5ef6288dbac450bfd264372651ae0bf6d683aebf3cb0fafa3a51/pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ae65fdfb8a841556b52935dfd4c3f79132dc5253b12c0061b96415208f4d622", size = 2708429 }, - { url = "https://files.pythonhosted.org/packages/90/60/ce282e9f8cf383c73c1b3234835b1aa6e518aa947b162c4e86c2dcffd281/pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c8aa40f6ca803f95b1c1c5aeaee6237b9e879e4dfb46ad713229a63651a95fb", size = 2069487 }, - { url = "https://files.pythonhosted.org/packages/6f/81/d93a2ca9418f610341339bc2c68d705be21d902f71f8ba2af6801f584751/pydantic_core-2.23.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c53100c8ee5a1e102766abde2158077d8c374bee0639201f11d3032e3555dfbc", size = 1899917 }, - { url = "https://files.pythonhosted.org/packages/20/a5/720d0cebf34da0b26d184c71642b21facb3c18302e9e410fb17f3d66956f/pydantic_core-2.23.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d6b9dd6aa03c812017411734e496c44fef29b43dba1e3dd1fa7361bbacfc1354", size = 1966236 }, - { url = "https://files.pythonhosted.org/packages/0c/5a/91f67eaeccb061b22bd01e65ef8dda9d35953020605a7f07b884c1969ab8/pydantic_core-2.23.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b18cf68255a476b927910c6873d9ed00da692bb293c5b10b282bd48a0afe3ae2", size = 2111250 }, - { url = "https://files.pythonhosted.org/packages/f9/ff/b4bb13a7ec2666a73c936a7675001a3f032f35189711bb51ee38c0756a70/pydantic_core-2.23.2-cp310-none-win32.whl", hash = "sha256:e460475719721d59cd54a350c1f71c797c763212c836bf48585478c5514d2854", size = 1716854 }, - { url = "https://files.pythonhosted.org/packages/02/23/9740be7f352ed3e29e9173758ad9c1471a344b54b8e38b8abae4174219ab/pydantic_core-2.23.2-cp310-none-win_amd64.whl", hash = "sha256:5f3cf3721eaf8741cffaf092487f1ca80831202ce91672776b02b875580e174a", size = 1917044 }, - { url = "https://files.pythonhosted.org/packages/06/22/ab062eefe57579e186c1aad47d1064bf9f710717c8b35817daa94617c1f6/pydantic_core-2.23.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7ce8e26b86a91e305858e018afc7a6e932f17428b1eaa60154bd1f7ee888b5f8", size = 1844143 }, - { url = "https://files.pythonhosted.org/packages/20/0e/f8cee28755de3cd50ba92e1daf06aac32ec949ecb072afcb49d61221d146/pydantic_core-2.23.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e9b24cca4037a561422bf5dc52b38d390fb61f7bfff64053ce1b72f6938e6b2", size = 1787515 }, - { url = "https://files.pythonhosted.org/packages/c0/e2/8b93dffd8ebca299924bfe119896179be965c9dada4fcc81bb63bb49dad0/pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:753294d42fb072aa1775bfe1a2ba1012427376718fa4c72de52005a3d2a22178", size = 1788263 }, - { url = "https://files.pythonhosted.org/packages/38/05/3a7e8682baddd5e06d9f54dadd67e2d66b8f71f79f5b46ec466fd8d110e4/pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:257d6a410a0d8aeb50b4283dea39bb79b14303e0fab0f2b9d617701331ed1515", size = 1780740 }, - { url = "https://files.pythonhosted.org/packages/d2/f0/8dbf5b3a78e2dbe0b4ed8e0ac1e0de53cd8bae82800b634680c8e69a3837/pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8319e0bd6a7b45ad76166cc3d5d6a36c97d0c82a196f478c3ee5346566eebfd", size = 1976823 }, - { url = "https://files.pythonhosted.org/packages/80/d2/5f3a40c0786bcc9b5bd16c2d5157dde2cf4dd7cf1c5827d82c78b935c1d3/pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a05c0240f6c711eb381ac392de987ee974fa9336071fb697768dfdb151345ce", size = 2706812 }, - { url = "https://files.pythonhosted.org/packages/9b/df/d58a06e2dec5abd54167537edee959d2f8f795c44f028a205c7de47c49fe/pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d5b0ff3218858859910295df6953d7bafac3a48d5cd18f4e3ed9999efd2245f", size = 2069873 }, - { url = "https://files.pythonhosted.org/packages/db/32/71f1042a1ebfa5bb74a9d0242ff1c79dba04d0080d69f7d49e96b1a72163/pydantic_core-2.23.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96ef39add33ff58cd4c112cbac076726b96b98bb8f1e7f7595288dcfb2f10b57", size = 1898755 }, - { url = "https://files.pythonhosted.org/packages/61/42/7323144ec46a6141d13ee0536eda7bcde1d7c1ba0d1a551d49967dde278a/pydantic_core-2.23.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0102e49ac7d2df3379ef8d658d3bc59d3d769b0bdb17da189b75efa861fc07b4", size = 1964385 }, - { url = "https://files.pythonhosted.org/packages/ba/a7/8a6be2bc6e01c35a19755b100be8eaa279981e9db3fdd95afe06d4e82b87/pydantic_core-2.23.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a6612c2a844043e4d10a8324c54cdff0042c558eef30bd705770793d70b224aa", size = 2110118 }, - { url = "https://files.pythonhosted.org/packages/eb/6a/2885057cebaf4fe2810bb730734c5b6a4578842b6cec094852fea8513275/pydantic_core-2.23.2-cp311-none-win32.whl", hash = "sha256:caffda619099cfd4f63d48462f6aadbecee3ad9603b4b88b60cb821c1b258576", size = 1715354 }, - { url = "https://files.pythonhosted.org/packages/f3/57/d798c53c9a115fe89f118a6b2b27d21e888eb22d4b53828344ce68035431/pydantic_core-2.23.2-cp311-none-win_amd64.whl", hash = "sha256:6f80fba4af0cb1d2344869d56430e304a51396b70d46b91a55ed4959993c0589", size = 1916403 }, - { url = "https://files.pythonhosted.org/packages/8f/54/9c202171aca4a9f6596e1e2a6572ff7d707e2383a40605533c61487714a2/pydantic_core-2.23.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:4c83c64d05ffbbe12d4e8498ab72bdb05bcc1026340a4a597dc647a13c1605ec", size = 1845364 }, - { url = "https://files.pythonhosted.org/packages/9e/e3/5c29d8fa6dfabd7809fe623fd17959e1b672410681a8c3811eefa42b8051/pydantic_core-2.23.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6294907eaaccf71c076abdd1c7954e272efa39bb043161b4b8aa1cd76a16ce43", size = 1784382 }, - { url = "https://files.pythonhosted.org/packages/79/c3/4b003c9ed0f5f5e559823802ee7a3921de8aa50892bf179535d7695b2e76/pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a801c5e1e13272e0909c520708122496647d1279d252c9e6e07dac216accc41", size = 1791189 }, - { url = "https://files.pythonhosted.org/packages/3f/3b/0c0377c5833093a1758e711387bbe5a5e30511fe9d4afe225be185527aa1/pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc0c316fba3ce72ac3ab7902a888b9dc4979162d320823679da270c2d9ad0cad", size = 1780431 }, - { url = "https://files.pythonhosted.org/packages/4a/88/18a541e2f9cbcef4b44b90a2ba98d85e109d91bc7f3b2210d271f66e6d8f/pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b06c5d4e8701ac2ba99a2ef835e4e1b187d41095a9c619c5b185c9068ed2a49", size = 1976902 }, - { url = "https://files.pythonhosted.org/packages/e3/bd/6c98ca605130ee51438d812133d04ddb403fb1a8a93490f9a1a7e269a4a7/pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82764c0bd697159fe9947ad59b6db6d7329e88505c8f98990eb07e84cc0a5d81", size = 2638882 }, - { url = "https://files.pythonhosted.org/packages/ad/fc/6b4f95c64bbeadaa6f84cffb51f469f6fdd61215d97b4ec8d89d027e574b/pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b1a195efd347ede8bcf723e932300292eb13a9d2a3c1f84eb8f37cbbc905b7f", size = 2111180 }, - { url = "https://files.pythonhosted.org/packages/95/4c/d4a355237ffa3dae28c2224bea9e09d4af69f346bf7611ebb66d8e930574/pydantic_core-2.23.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7efb12e5071ad8d5b547487bdad489fbd4a5a35a0fc36a1941517a6ad7f23e0", size = 1904338 }, - { url = "https://files.pythonhosted.org/packages/83/d6/9f41db9ab2727a050fdac5e56a9e792260aa29e29affa98b4df8a06fd588/pydantic_core-2.23.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5dd0ec5f514ed40e49bf961d49cf1bc2c72e9b50f29a163b2cc9030c6742aa73", size = 1968729 }, - { url = "https://files.pythonhosted.org/packages/f8/bd/0e795ea5eaf26a96d9691faae43603e00de3dbec6339e3710652f5feae12/pydantic_core-2.23.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:820f6ee5c06bc868335e3b6e42d7ef41f50dfb3ea32fbd523ab679d10d8741c0", size = 2120748 }, - { url = "https://files.pythonhosted.org/packages/be/7d/48ddf9f084b5de1d47c6bcccfeafe80900ceac975ebdf1fe374fd18654e5/pydantic_core-2.23.2-cp312-none-win32.whl", hash = "sha256:3713dc093d5048bfaedbba7a8dbc53e74c44a140d45ede020dc347dda18daf3f", size = 1725557 }, - { url = "https://files.pythonhosted.org/packages/e1/d7/ef3558714427b385f84e22a224c4641b4869f9031f733e83cea6f0eb0154/pydantic_core-2.23.2-cp312-none-win_amd64.whl", hash = "sha256:e1895e949f8849bc2757c0dbac28422a04be031204df46a56ab34bcf98507342", size = 1914466 }, - { url = "https://files.pythonhosted.org/packages/91/42/085e27e5c32220531bbd966c2888c74a595ac35cd7e52a71d3302d041b71/pydantic_core-2.23.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:da43cbe593e3c87d07108d0ebd73771dc414488f1f91ed2e204b0370b94b37ac", size = 1845030 }, - { url = "https://files.pythonhosted.org/packages/05/ac/3faf5fd29b221bb7c0aa4bc3fe1a763fb2d38b08d7b5961daeee96f13a8e/pydantic_core-2.23.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:64d094ea1aa97c6ded4748d40886076a931a8bf6f61b6e43e4a1041769c39dd2", size = 1784493 }, - { url = "https://files.pythonhosted.org/packages/83/52/cc692fc65098904a6bdb13cc502b590d0597718069a8ffa8bfdea680657c/pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:084414ffe9a85a52940b49631321d636dadf3576c30259607b75516d131fecd0", size = 1791193 }, - { url = "https://files.pythonhosted.org/packages/53/a9/d7be95020bf6a57ada1d5d18172369ad92e9779dccf9a497b22863d77dd8/pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043ef8469f72609c4c3a5e06a07a1f713d53df4d53112c6d49207c0bd3c3bd9b", size = 1780169 }, - { url = "https://files.pythonhosted.org/packages/10/42/16bee4df87a315a8ae3962e5c2251612bced849e624f850e811a2e6097da/pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3649bd3ae6a8ebea7dc381afb7f3c6db237fc7cebd05c8ac36ca8a4187b03b30", size = 1976946 }, - { url = "https://files.pythonhosted.org/packages/f8/04/8be7280cf309c256bd9fa124fbe15f730b9659ee87d89a40b7da14c6818f/pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6db09153d8438425e98cdc9a289c5fade04a5d2128faff8f227c459da21b9703", size = 2638776 }, - { url = "https://files.pythonhosted.org/packages/24/7b/e102b2073b08b36f78d7336859fe8d09e7483eded849cf12bd3db66f441d/pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5668b3173bb0b2e65020b60d83f5910a7224027232c9f5dc05a71a1deac9f960", size = 2110994 }, - { url = "https://files.pythonhosted.org/packages/90/29/ba00e3e262597203ae95c5eb81d02ce96afcda26b6960484b376a1e5ac59/pydantic_core-2.23.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1c7b81beaf7c7ebde978377dc53679c6cba0e946426fc7ade54251dfe24a7604", size = 1904219 }, - { url = "https://files.pythonhosted.org/packages/4b/ef/3899865e9f2e30b79c1ed9498a71898f33b1ca2a08f3b1619eaee754aa85/pydantic_core-2.23.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ae579143826c6f05a361d9546446c432a165ecf1c0b720bbfd81152645cb897d", size = 1968804 }, - { url = "https://files.pythonhosted.org/packages/19/e9/69742d9c71d08251184584c2b99d436490c78f7487840e588394b1ce97a9/pydantic_core-2.23.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:19f1352fe4b248cae22a89268720fc74e83f008057a652894f08fa931e77dced", size = 2120574 }, - { url = "https://files.pythonhosted.org/packages/5c/fc/7f89094bf3a645fb5b312b6ae907f3b04b6e0d1e37f6eb4c057276d80703/pydantic_core-2.23.2-cp313-none-win32.whl", hash = "sha256:e1a79ad49f346aa1a2921f31e8dbbab4d64484823e813a002679eaa46cba39e1", size = 1725472 }, - { url = "https://files.pythonhosted.org/packages/43/13/39f4b60884174a95c69e3dd196c77a1757c3857841f2567d240bcbd1cf0f/pydantic_core-2.23.2-cp313-none-win_amd64.whl", hash = "sha256:582871902e1902b3c8e9b2c347f32a792a07094110c1bca6c2ea89b90150caac", size = 1914614 }, - { url = "https://files.pythonhosted.org/packages/92/22/ec5bd81d9e526c6cf61f0db36adbec525cf82de502d46c47d1c01e88d1ea/pydantic_core-2.23.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:358331e21a897151e54d58e08d0219acf98ebb14c567267a87e971f3d2a3be59", size = 1846869 }, - { url = "https://files.pythonhosted.org/packages/3e/5b/c95ab67c0f6f483f8620ae6e3c891a4477cd3986ef3a57ce247fee391298/pydantic_core-2.23.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c4d9f15ffe68bcd3898b0ad7233af01b15c57d91cd1667f8d868e0eacbfe3f87", size = 1729814 }, - { url = "https://files.pythonhosted.org/packages/5f/c0/5a73650c8313fac0bf061e92322c16d518b1f40ca4ab4aa7ca3a1c293d27/pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0123655fedacf035ab10c23450163c2f65a4174f2bb034b188240a6cf06bb123", size = 1789905 }, - { url = "https://files.pythonhosted.org/packages/01/a3/0f79cc0b20293f377d9d9e20cb4aac2b0fbae05265c8b5a9efa54ad59470/pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e6e3ccebdbd6e53474b0bb7ab8b88e83c0cfe91484b25e058e581348ee5a01a5", size = 1782019 }, - { url = "https://files.pythonhosted.org/packages/80/92/11e5b7335cae6db8b8821d7bced7285a942c7f993b0cbd0d8f2a0cb87701/pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc535cb898ef88333cf317777ecdfe0faac1c2a3187ef7eb061b6f7ecf7e6bae", size = 1978512 }, - { url = "https://files.pythonhosted.org/packages/20/35/c5f6979920512f95b994a3a7b10487bc4d97f6dc764fd28d22ed3e641f11/pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aab9e522efff3993a9e98ab14263d4e20211e62da088298089a03056980a3e69", size = 2708860 }, - { url = "https://files.pythonhosted.org/packages/96/22/0c704ebeebfe744db499a783642adf00dd11d937828ad8df3c68c169ea45/pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05b366fb8fe3d8683b11ac35fa08947d7b92be78ec64e3277d03bd7f9b7cda79", size = 2070366 }, - { url = "https://files.pythonhosted.org/packages/87/8d/fb14d6b67d36afdf33359e2b8488ac4e3104187da81cbac591dba7952ba5/pydantic_core-2.23.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7568f682c06f10f30ef643a1e8eec4afeecdafde5c4af1b574c6df079e96f96c", size = 1900024 }, - { url = "https://files.pythonhosted.org/packages/1c/5d/96fae9ead7462b535e8bda7431948b360491da684299b7538e00e2a62039/pydantic_core-2.23.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cdd02a08205dc90238669f082747612cb3c82bd2c717adc60f9b9ecadb540f80", size = 1966643 }, - { url = "https://files.pythonhosted.org/packages/3a/39/13ddadba16f285400a779715cdac97dbfdbc488940708f513808c6ac0274/pydantic_core-2.23.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1a2ab4f410f4b886de53b6bddf5dd6f337915a29dd9f22f20f3099659536b2f6", size = 2111822 }, - { url = "https://files.pythonhosted.org/packages/ab/68/708503919bf048d0301a0ea58472ee8c72ce7b5f33e8d80c73d180fa8289/pydantic_core-2.23.2-cp39-none-win32.whl", hash = "sha256:0448b81c3dfcde439551bb04a9f41d7627f676b12701865c8a2574bcea034437", size = 1716955 }, - { url = "https://files.pythonhosted.org/packages/36/a6/da95007bf15e80bc11961c8d14307ae9b3a353a4dd4396da741c76ba4357/pydantic_core-2.23.2-cp39-none-win_amd64.whl", hash = "sha256:4cebb9794f67266d65e7e4cbe5dcf063e29fc7b81c79dc9475bd476d9534150e", size = 1919268 }, - { url = "https://files.pythonhosted.org/packages/33/e8/11e094b026a7c8b9289cd8b7e90eb9d6ce76a774ee67fde7300d1becbe3f/pydantic_core-2.23.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e758d271ed0286d146cf7c04c539a5169a888dd0b57026be621547e756af55bc", size = 1835254 }, - { url = "https://files.pythonhosted.org/packages/82/59/8eef255a6c8df7656a8bf2845d3b59ee3fe118f07db20d9166d4ff27e823/pydantic_core-2.23.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f477d26183e94eaafc60b983ab25af2a809a1b48ce4debb57b343f671b7a90b6", size = 1721793 }, - { url = "https://files.pythonhosted.org/packages/50/8b/83a0234b40d882fdbe3b37f34646f94b748ba05b7c68a189643fd263c5de/pydantic_core-2.23.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da3131ef2b940b99106f29dfbc30d9505643f766704e14c5d5e504e6a480c35e", size = 1782806 }, - { url = "https://files.pythonhosted.org/packages/6f/33/9fc6fabe81331ae7cb049228e8477b039fc0cdabf88ee20925ce9d08f595/pydantic_core-2.23.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329a721253c7e4cbd7aad4a377745fbcc0607f9d72a3cc2102dd40519be75ed2", size = 1930852 }, - { url = "https://files.pythonhosted.org/packages/49/3d/9ee7397d46f9ad27769642fc85ad9027651619932897d68d617911250bab/pydantic_core-2.23.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7706e15cdbf42f8fab1e6425247dfa98f4a6f8c63746c995d6a2017f78e619ae", size = 1894400 }, - { url = "https://files.pythonhosted.org/packages/7e/9b/daebf4e83e465f099d5ab34382b83c2d9a61025ef928d810e9ad651bea69/pydantic_core-2.23.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e64ffaf8f6e17ca15eb48344d86a7a741454526f3a3fa56bc493ad9d7ec63936", size = 1958993 }, - { url = "https://files.pythonhosted.org/packages/3c/dc/27968c6096ece1d8fcf235da6af6d5b4d0c7a1ae3c0aef0aa2b9319e96b5/pydantic_core-2.23.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:dd59638025160056687d598b054b64a79183f8065eae0d3f5ca523cde9943940", size = 2102383 }, - { url = "https://files.pythonhosted.org/packages/1e/7d/6e3a460d47dc95cb9b7337cddf90c3b431f348556711aabe633ba576808d/pydantic_core-2.23.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:12625e69b1199e94b0ae1c9a95d000484ce9f0182f9965a26572f054b1537e44", size = 1917629 }, - { url = "https://files.pythonhosted.org/packages/dd/1d/917f8fbd85712b457ac5ace1092325b95bddf410c579e98bdbfe44548af6/pydantic_core-2.23.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5d813fd871b3d5c3005157622ee102e8908ad6011ec915a18bd8fde673c4360e", size = 1836412 }, - { url = "https://files.pythonhosted.org/packages/0d/6a/505938ce8382f56e53a14fbe09725f336cfdd89d6cfc26dc6bf5f3804b1c/pydantic_core-2.23.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1eb37f7d6a8001c0f86dc8ff2ee8d08291a536d76e49e78cda8587bb54d8b329", size = 1722796 }, - { url = "https://files.pythonhosted.org/packages/5a/1e/60cec75a4a385852c8936c867fb53e59a73bb7aac69e79c82e629c0746ec/pydantic_core-2.23.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ce7eaf9a98680b4312b7cebcdd9352531c43db00fca586115845df388f3c465", size = 1783366 }, - { url = "https://files.pythonhosted.org/packages/96/8f/31979e696fa423ae3f902ac43d46d2fe2b49590b70527f28b2a2627b41f1/pydantic_core-2.23.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f087879f1ffde024dd2788a30d55acd67959dcf6c431e9d3682d1c491a0eb474", size = 1931953 }, - { url = "https://files.pythonhosted.org/packages/b5/ef/96b46c84a7c6cdd6a687eec3a3c7f23548fdfdccaec93a71e15f485dd04f/pydantic_core-2.23.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ce883906810b4c3bd90e0ada1f9e808d9ecf1c5f0b60c6b8831d6100bcc7dd6", size = 1894849 }, - { url = "https://files.pythonhosted.org/packages/ea/b7/4abd0b2152490e15a2b68b4fc31bde3325819ee83be0995fb7fc44ccb109/pydantic_core-2.23.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a8031074a397a5925d06b590121f8339d34a5a74cfe6970f8a1124eb8b83f4ac", size = 1959645 }, - { url = "https://files.pythonhosted.org/packages/4a/c9/6ce837055553ccfc1ccc495fd634518fa0e7d35d49bc00cfabfef83de52c/pydantic_core-2.23.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:23af245b8f2f4ee9e2c99cb3f93d0e22fb5c16df3f2f643f5a8da5caff12a653", size = 2103264 }, - { url = "https://files.pythonhosted.org/packages/66/37/f1dd40032f04606da68278697b3645e0d8a02e566c4f5b3d3baaa36e8ce1/pydantic_core-2.23.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c57e493a0faea1e4c38f860d6862ba6832723396c884fbf938ff5e9b224200e2", size = 1918108 }, +sdist = { url = "https://files.pythonhosted.org/packages/5c/cc/07bec3fb337ff80eacd6028745bd858b9642f61ee58cfdbfb64451c1def0/pydantic_core-2.23.3.tar.gz", hash = "sha256:3cb0f65d8b4121c1b015c60104a685feb929a29d7cf204387c7f2688c7974690", size = 402277 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/fb/fc7077473d843fd70bd1e09177c3225be95621881765d6f7d123036fb9c7/pydantic_core-2.23.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7f10a5d1b9281392f1bf507d16ac720e78285dfd635b05737c3911637601bae6", size = 1845897 }, + { url = "https://files.pythonhosted.org/packages/92/8c/c6f1a0f72328c5687acc0847baf806c4cb31c1a9321de70c3cbcbb37cece/pydantic_core-2.23.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3c09a7885dd33ee8c65266e5aa7fb7e2f23d49d8043f089989726391dd7350c5", size = 1777037 }, + { url = "https://files.pythonhosted.org/packages/bd/fc/89e2a998218230ed8c38f0ba11d8f73947df90ac59a1e9f2fb4e1ba318a5/pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6470b5a1ec4d1c2e9afe928c6cb37eb33381cab99292a708b8cb9aa89e62429b", size = 1801481 }, + { url = "https://files.pythonhosted.org/packages/d7/f3/81a5f69ea1359633876ea2283728d0afe2ed62e028d91d747dcdfabc594e/pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9172d2088e27d9a185ea0a6c8cebe227a9139fd90295221d7d495944d2367700", size = 1807280 }, + { url = "https://files.pythonhosted.org/packages/7a/91/b20f5646d7ef7c2629744b49e6fb86f839aa676b1aa11fb3998371ac5860/pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86fc6c762ca7ac8fbbdff80d61b2c59fb6b7d144aa46e2d54d9e1b7b0e780e01", size = 2003100 }, + { url = "https://files.pythonhosted.org/packages/89/71/59172c61f2ecd4b33276774512ef31912944429fabaa0f4483151f788a35/pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0cb80fd5c2df4898693aa841425ea1727b1b6d2167448253077d2a49003e0ed", size = 2662832 }, + { url = "https://files.pythonhosted.org/packages/80/d1/c6f8e23987dc166976996a910876596635d71e529335b846880d856589fd/pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03667cec5daf43ac4995cefa8aaf58f99de036204a37b889c24a80927b629cec", size = 2057218 }, + { url = "https://files.pythonhosted.org/packages/ae/f3/f4381383b65cf16392aead51643fd5fb3feeb69972226d276ce5c6cfb948/pydantic_core-2.23.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:047531242f8e9c2db733599f1c612925de095e93c9cc0e599e96cf536aaf56ba", size = 1923455 }, + { url = "https://files.pythonhosted.org/packages/a1/8d/d845077d39e55763bdb99d64ef86f8961827f8896b6e58ce08ce6b255bde/pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5499798317fff7f25dbef9347f4451b91ac2a4330c6669821c8202fd354c7bee", size = 1966890 }, + { url = "https://files.pythonhosted.org/packages/53/f8/56355d7b1cf84df63f93b1a455ebb53fd9588edbb63a44fd4d801444a060/pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bbb5e45eab7624440516ee3722a3044b83fff4c0372efe183fd6ba678ff681fe", size = 2112163 }, + { url = "https://files.pythonhosted.org/packages/06/32/a0a7a3a318b4ae98a0e6b9e18db31fadbd3cfc46b31191e4ed4ca658e2d4/pydantic_core-2.23.3-cp310-none-win32.whl", hash = "sha256:8b5b3ed73abb147704a6e9f556d8c5cb078f8c095be4588e669d315e0d11893b", size = 1717086 }, + { url = "https://files.pythonhosted.org/packages/e3/31/38aebe234508fc30c80b4825661d3c1ef0d51b1c40a12e50855b108acd35/pydantic_core-2.23.3-cp310-none-win_amd64.whl", hash = "sha256:2b603cde285322758a0279995b5796d64b63060bfbe214b50a3ca23b5cee3e83", size = 1918933 }, + { url = "https://files.pythonhosted.org/packages/4a/60/ef8eaad365c1d94962d158633f66313e051f7b90cead647e65a96993da22/pydantic_core-2.23.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c889fd87e1f1bbeb877c2ee56b63bb297de4636661cc9bbfcf4b34e5e925bc27", size = 1843251 }, + { url = "https://files.pythonhosted.org/packages/57/f4/20aa352e03379a3b5d6c2fb951a979f70718138ea747e3f756d63dda69da/pydantic_core-2.23.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea85bda3189fb27503af4c45273735bcde3dd31c1ab17d11f37b04877859ef45", size = 1776367 }, + { url = "https://files.pythonhosted.org/packages/f1/b9/e5482ac4ea2d128925759d905fb05a08ca98e67ed1d8ab7401861997c6c8/pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7f7f72f721223f33d3dc98a791666ebc6a91fa023ce63733709f4894a7dc611", size = 1800135 }, + { url = "https://files.pythonhosted.org/packages/78/9f/387353f6b6b2ed023f973cffa4e2384bb2e52d15acf5680bc70c50f6c48f/pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b2b55b0448e9da68f56b696f313949cda1039e8ec7b5d294285335b53104b61", size = 1805896 }, + { url = "https://files.pythonhosted.org/packages/4f/70/9a153f19394e2ef749f586273ebcdb3de97e2fa97e175b957a8e5a2a77f9/pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c24574c7e92e2c56379706b9a3f07c1e0c7f2f87a41b6ee86653100c4ce343e5", size = 2001492 }, + { url = "https://files.pythonhosted.org/packages/a5/1c/79d976846fcdcae0c657922d0f476ca287fa694e69ac1fc9d397b831e1cc/pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2b05e6ccbee333a8f4b8f4d7c244fdb7a979e90977ad9c51ea31261e2085ce0", size = 2659827 }, + { url = "https://files.pythonhosted.org/packages/fd/89/cdd76ae363cabae23a4b70df50d603c81c517415ff9d5d65e72e35251cf6/pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2c409ce1c219c091e47cb03feb3c4ed8c2b8e004efc940da0166aaee8f9d6c8", size = 2055160 }, + { url = "https://files.pythonhosted.org/packages/1a/82/7d62c3dd4e2e101a81ac3fa138d986bfbad9727a6275fc2b4a5efb98bdbd/pydantic_core-2.23.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d965e8b325f443ed3196db890d85dfebbb09f7384486a77461347f4adb1fa7f8", size = 1922282 }, + { url = "https://files.pythonhosted.org/packages/85/e6/ef09f395c974d08674464dd3d49066612fe7cc0466ef8ce9427cadf13e5b/pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f56af3a420fb1ffaf43ece3ea09c2d27c444e7c40dcb7c6e7cf57aae764f2b48", size = 1965827 }, + { url = "https://files.pythonhosted.org/packages/a4/5e/e589474af850c77c3180b101b54bc98bf812ad09728ba2cff4989acc9734/pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5b01a078dd4f9a52494370af21aa52964e0a96d4862ac64ff7cea06e0f12d2c5", size = 2110810 }, + { url = "https://files.pythonhosted.org/packages/e0/ff/626007d5b7ac811f9bcac6d8af3a574ccee4505c1f015d25806101842f0c/pydantic_core-2.23.3-cp311-none-win32.whl", hash = "sha256:560e32f0df04ac69b3dd818f71339983f6d1f70eb99d4d1f8e9705fb6c34a5c1", size = 1715479 }, + { url = "https://files.pythonhosted.org/packages/4f/ff/6dc33f3b71e34ef633e35d6476d245bf303fc3eaf18a00f39bb54f78faf3/pydantic_core-2.23.3-cp311-none-win_amd64.whl", hash = "sha256:c744fa100fdea0d000d8bcddee95213d2de2e95b9c12be083370b2072333a0fa", size = 1918281 }, + { url = "https://files.pythonhosted.org/packages/8f/35/6d81bc4aa7d06e716f39e2bffb0eabcbcebaf7bab94c2f8278e277ded0ea/pydantic_core-2.23.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e0ec50663feedf64d21bad0809f5857bac1ce91deded203efc4a84b31b2e4305", size = 1845250 }, + { url = "https://files.pythonhosted.org/packages/18/42/0821cd46f76406e0fe57df7a89d6af8fddb22cce755bcc2db077773c7d1a/pydantic_core-2.23.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db6e6afcb95edbe6b357786684b71008499836e91f2a4a1e55b840955b341dbb", size = 1769993 }, + { url = "https://files.pythonhosted.org/packages/e5/55/b969088e48bd8ea588548a7194d425de74370b17b385cee4d28f5a79013d/pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98ccd69edcf49f0875d86942f4418a4e83eb3047f20eb897bffa62a5d419c8fa", size = 1791250 }, + { url = "https://files.pythonhosted.org/packages/43/c1/1d460d09c012ac76b68b2a1fd426ad624724f93b40e24a9a993763f12c61/pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a678c1ac5c5ec5685af0133262103defb427114e62eafeda12f1357a12140162", size = 1802530 }, + { url = "https://files.pythonhosted.org/packages/70/8e/fd3c9eda00fbdadca726f17a0f863ecd871a65b3a381b77277ae386d3bcd/pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01491d8b4d8db9f3391d93b0df60701e644ff0894352947f31fff3e52bd5c801", size = 1997848 }, + { url = "https://files.pythonhosted.org/packages/f0/67/13fa22d7b09395e83721edc31bae2bd5c5e2c36a09d470c18f5d1de46958/pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fcf31facf2796a2d3b7fe338fe8640aa0166e4e55b4cb108dbfd1058049bf4cb", size = 2662790 }, + { url = "https://files.pythonhosted.org/packages/fa/1b/1d689c53d15ab67cb0df1c3a2b1df873b50409581e93e4848289dce57e2f/pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7200fd561fb3be06827340da066df4311d0b6b8eb0c2116a110be5245dceb326", size = 2074114 }, + { url = "https://files.pythonhosted.org/packages/3d/d9/b565048609db77760b9a0900f6e0a3b2f33be47cd3c4a433f49653a0d2b5/pydantic_core-2.23.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc1636770a809dee2bd44dd74b89cc80eb41172bcad8af75dd0bc182c2666d4c", size = 1918153 }, + { url = "https://files.pythonhosted.org/packages/41/94/8ee55c51333ed8df3a6f1e73c6530c724a9a37d326e114c9e3b24faacff9/pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:67a5def279309f2e23014b608c4150b0c2d323bd7bccd27ff07b001c12c2415c", size = 1969019 }, + { url = "https://files.pythonhosted.org/packages/f7/49/0233bae5778a5526cef000447a93e8d462f4f13e2214c13c5b23d379cb25/pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:748bdf985014c6dd3e1e4cc3db90f1c3ecc7246ff5a3cd4ddab20c768b2f1dab", size = 2121325 }, + { url = "https://files.pythonhosted.org/packages/42/a1/2f262db2fd6f9c2c9904075a067b1764cc6f71c014be5c6c91d9de52c434/pydantic_core-2.23.3-cp312-none-win32.whl", hash = "sha256:255ec6dcb899c115f1e2a64bc9ebc24cc0e3ab097775755244f77360d1f3c06c", size = 1725252 }, + { url = "https://files.pythonhosted.org/packages/9a/00/a57937080b49500df790c4853d3e7bc605bd0784e4fcaf1a159456f37ef1/pydantic_core-2.23.3-cp312-none-win_amd64.whl", hash = "sha256:40b8441be16c1e940abebed83cd006ddb9e3737a279e339dbd6d31578b802f7b", size = 1920660 }, + { url = "https://files.pythonhosted.org/packages/e1/3c/32958c0a5d1935591b58337037a1695782e61261582d93d5a7f55441f879/pydantic_core-2.23.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6daaf5b1ba1369a22c8b050b643250e3e5efc6a78366d323294aee54953a4d5f", size = 1845068 }, + { url = "https://files.pythonhosted.org/packages/92/a1/7e628e19b78e6ffdb2c92cccbb7eca84bfd3276cee4cafcae8833452f458/pydantic_core-2.23.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d015e63b985a78a3d4ccffd3bdf22b7c20b3bbd4b8227809b3e8e75bc37f9cb2", size = 1770095 }, + { url = "https://files.pythonhosted.org/packages/bb/17/d15fd8ce143cd1abb27be924eeff3c5c0fe3b0582f703c5a5273c11e67ce/pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3fc572d9b5b5cfe13f8e8a6e26271d5d13f80173724b738557a8c7f3a8a3791", size = 1790964 }, + { url = "https://files.pythonhosted.org/packages/24/cc/37feff1792f09dc33207fbad3897373229279d1973c211f9562abfdf137d/pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6bd91345b5163ee7448bee201ed7dd601ca24f43f439109b0212e296eb5b423", size = 1802384 }, + { url = "https://files.pythonhosted.org/packages/44/d8/ca9acd7f5f044d9ff6e43d7f35aab4b1d5982b4773761eabe3317fc68e30/pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc379c73fd66606628b866f661e8785088afe2adaba78e6bbe80796baf708a63", size = 1997824 }, + { url = "https://files.pythonhosted.org/packages/35/0f/146269dba21b10d5bf86f9a7a7bbeab4ce1db06f466a1ab5ec3dec68b409/pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbdce4b47592f9e296e19ac31667daed8753c8367ebb34b9a9bd89dacaa299c9", size = 2662907 }, + { url = "https://files.pythonhosted.org/packages/5a/7d/9573f006e39cd1a7b7716d1a264e3f4f353cf0a6042c04c01c6e31666f62/pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3cf31edf405a161a0adad83246568647c54404739b614b1ff43dad2b02e6d5", size = 2073953 }, + { url = "https://files.pythonhosted.org/packages/7e/a5/25200aaafd1e97e2ec3c1eb4b357669dd93911f2eba252bc60b6ba884fff/pydantic_core-2.23.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e22b477bf90db71c156f89a55bfe4d25177b81fce4aa09294d9e805eec13855", size = 1917822 }, + { url = "https://files.pythonhosted.org/packages/3e/b4/ac069c58e3cee70c69f03693222cc173fdf740d20d53167bceafc1efc7ca/pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0a0137ddf462575d9bce863c4c95bac3493ba8e22f8c28ca94634b4a1d3e2bb4", size = 1968838 }, + { url = "https://files.pythonhosted.org/packages/d1/3d/9f96bbd6212b4b0a6dc6d037e446208d3420baba2b2b81e544094b18a859/pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:203171e48946c3164fe7691fc349c79241ff8f28306abd4cad5f4f75ed80bc8d", size = 2121468 }, + { url = "https://files.pythonhosted.org/packages/ac/50/7399d536d6600d69059a87fff89861332c97a7b3471327a3663c7576e707/pydantic_core-2.23.3-cp313-none-win32.whl", hash = "sha256:76bdab0de4acb3f119c2a4bff740e0c7dc2e6de7692774620f7452ce11ca76c8", size = 1725373 }, + { url = "https://files.pythonhosted.org/packages/24/ba/9ac8744ab636c1161c598cc5e8261379b6b0f1d63c31242bf9d5ed41ed32/pydantic_core-2.23.3-cp313-none-win_amd64.whl", hash = "sha256:37ba321ac2a46100c578a92e9a6aa33afe9ec99ffa084424291d84e456f490c1", size = 1920594 }, + { url = "https://files.pythonhosted.org/packages/b8/9c/cb69375fd9488869c4c29edf6666050ce5c88baf755926f4121aacd9f01f/pydantic_core-2.23.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:82da2f4703894134a9f000e24965df73cc103e31e8c31906cc1ee89fde72cbd8", size = 1846402 }, + { url = "https://files.pythonhosted.org/packages/b5/7d/99d47c7084e39465781552f65889f92b1673a31c179753e476385326a3b6/pydantic_core-2.23.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dd9be0a42de08f4b58a3cc73a123f124f65c24698b95a54c1543065baca8cf0e", size = 1730388 }, + { url = "https://files.pythonhosted.org/packages/80/0d/e6be39d563846de02a1a61fa942758e6d2409f5a87bb5853f65abde2470a/pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89b731f25c80830c76fdb13705c68fef6a2b6dc494402987c7ea9584fe189f5d", size = 1801656 }, + { url = "https://files.pythonhosted.org/packages/3e/4a/6d9e8ad6c95be4af18948d400284382bc7f8b00d795f2222f3f094bc4dcb/pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6de1ec30c4bb94f3a69c9f5f2182baeda5b809f806676675e9ef6b8dc936f28", size = 1807884 }, + { url = "https://files.pythonhosted.org/packages/a9/09/751832a0938384cf78ce0353d38ef350c9ecbf2ebd5dc7ff0b3b3a0f8bfd/pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb68b41c3fa64587412b104294b9cbb027509dc2f6958446c502638d481525ef", size = 2003488 }, + { url = "https://files.pythonhosted.org/packages/4b/1f/77c720b6ca179f59c44a5698163b38be58e735974db28d761b31462da42e/pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c3980f2843de5184656aab58698011b42763ccba11c4a8c35936c8dd6c7068c", size = 2664470 }, + { url = "https://files.pythonhosted.org/packages/47/71/5aa475102a31edc15bb0df9a6627de64f62b11be99be49f2a4a0d2a19eea/pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94f85614f2cba13f62c3c6481716e4adeae48e1eaa7e8bac379b9d177d93947a", size = 2057855 }, + { url = "https://files.pythonhosted.org/packages/d2/66/15d6378783e2ede05416194848030b35cf732d84cf6cb8897aa916f628a6/pydantic_core-2.23.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:510b7fb0a86dc8f10a8bb43bd2f97beb63cffad1203071dc434dac26453955cd", size = 1923691 }, + { url = "https://files.pythonhosted.org/packages/6e/c5/7172805d806012aaff6547d2c819a98bc318313d36a9b10cd48241d85fb1/pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1eba2f7ce3e30ee2170410e2171867ea73dbd692433b81a93758ab2de6c64835", size = 1967678 }, + { url = "https://files.pythonhosted.org/packages/2b/51/6e1f5b06a3e70de9ac4d14d5ddf74564c2831ed403bb86808742c26d4240/pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b259fd8409ab84b4041b7b3f24dcc41e4696f180b775961ca8142b5b21d0e70", size = 2112758 }, + { url = "https://files.pythonhosted.org/packages/3f/e5/1ee8f68f9425728541edb9df26702f95f8243c9e42f405b2a972c64edb1b/pydantic_core-2.23.3-cp39-none-win32.whl", hash = "sha256:40d9bd259538dba2f40963286009bf7caf18b5112b19d2b55b09c14dde6db6a7", size = 1716954 }, + { url = "https://files.pythonhosted.org/packages/96/67/663492ab80a625d07ca4abd3178023fa79a9f6fa1df4acc3213bff371e9d/pydantic_core-2.23.3-cp39-none-win_amd64.whl", hash = "sha256:5a8cd3074a98ee70173a8633ad3c10e00dcb991ecec57263aacb4095c5efb958", size = 1921529 }, + { url = "https://files.pythonhosted.org/packages/c0/2d/1f4ec8614225b516366f6c4c49d55ec42ebb93004c0bc9a3e0d21d0ed3c0/pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f399e8657c67313476a121a6944311fab377085ca7f490648c9af97fc732732d", size = 1834597 }, + { url = "https://files.pythonhosted.org/packages/4d/f0/665d4cd60147992b1da0f5a9d1fd7f309c7f12999e3a494c4898165c64ab/pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b5547d098c76e1694ba85f05b595720d7c60d342f24d5aad32c3049131fa5c4", size = 1721339 }, + { url = "https://files.pythonhosted.org/packages/a7/02/7b85ae2c3452e6b9f43b89482dc2a2ba771c31d86d93c2a5a250870b243b/pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0dda0290a6f608504882d9f7650975b4651ff91c85673341789a476b1159f211", size = 1794316 }, + { url = "https://files.pythonhosted.org/packages/61/09/f0fde8a9d66f37f3e08e03965a9833d71c4b5fb0287d8f625f88d79dfcd6/pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b6e5da855e9c55a0c67f4db8a492bf13d8d3316a59999cfbaf98cc6e401961", size = 1944713 }, + { url = "https://files.pythonhosted.org/packages/61/2b/0bfe144cac991700dbeaff620fed38b0565352acb342f90374ebf1350084/pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:09e926397f392059ce0afdcac920df29d9c833256354d0c55f1584b0b70cf07e", size = 1916385 }, + { url = "https://files.pythonhosted.org/packages/02/4f/7d1b8a28e4a1dd96cdde9e220627abd4d3a7860eb79cc682ccf828cf93e4/pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:87cfa0ed6b8c5bd6ae8b66de941cece179281239d482f363814d2b986b79cedc", size = 1959666 }, + { url = "https://files.pythonhosted.org/packages/5d/9a/b2c520ef627001c68cf23990b2de42ba66eae58a3f56f13375ae9aecb88d/pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e61328920154b6a44d98cabcb709f10e8b74276bc709c9a513a8c37a18786cc4", size = 2103742 }, + { url = "https://files.pythonhosted.org/packages/cd/43/b9a88a4e6454fcad63317e3dade687b68ae7d9f324c868411b1ea70218b3/pydantic_core-2.23.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce3317d155628301d649fe5e16a99528d5680af4ec7aa70b90b8dacd2d725c9b", size = 1916507 }, + { url = "https://files.pythonhosted.org/packages/e7/52/fd89a422e922174728341b594612e9c727f5c07c55e3e436dc3dd626f52d/pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e89513f014c6be0d17b00a9a7c81b1c426f4eb9224b15433f3d98c1a071f8433", size = 1835707 }, + { url = "https://files.pythonhosted.org/packages/be/14/07f8fa279d8c7b414c7e547f868dd1b9f8e76f248f49fb44c2312be62cb0/pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4f62c1c953d7ee375df5eb2e44ad50ce2f5aff931723b398b8bc6f0ac159791a", size = 1722073 }, + { url = "https://files.pythonhosted.org/packages/18/02/09c3ec4f9b270fd5af8f142b5547c396a1cb2aba6721b374f77a60e4bae4/pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2718443bc671c7ac331de4eef9b673063b10af32a0bb385019ad61dcf2cc8f6c", size = 1794805 }, + { url = "https://files.pythonhosted.org/packages/e7/5c/2ab3689816702554ac73ea5c435030be5461180d5b18f252ea7890774227/pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d90e08b2727c5d01af1b5ef4121d2f0c99fbee692c762f4d9d0409c9da6541", size = 1945670 }, + { url = "https://files.pythonhosted.org/packages/12/ef/c16db2dc939e2686b63a1cd19e80fda55fff95b7411cc3a34ca7d7d2463e/pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b676583fc459c64146debea14ba3af54e540b61762dfc0613dc4e98c3f66eeb", size = 1916745 }, + { url = "https://files.pythonhosted.org/packages/00/58/c55081fdfc1a1c26c4d90555c013bbb6193721147154b5ba3dff16c36b96/pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:50e4661f3337977740fdbfbae084ae5693e505ca2b3130a6d4eb0f2281dc43b8", size = 1960193 }, + { url = "https://files.pythonhosted.org/packages/10/0e/664177152393180ca06ed393a3d4b16804d0a98ce9ccb460c1d29950ab77/pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:68f4cf373f0de6abfe599a38307f4417c1c867ca381c03df27c873a9069cda25", size = 2104209 }, + { url = "https://files.pythonhosted.org/packages/88/6a/df8adefd9d1052c72ee98b8c50a5eb042cdb3f2fea1f4f58a16046bdac02/pydantic_core-2.23.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:59d52cf01854cb26c46958552a21acb10dd78a52aa34c86f284e66b209db8cab", size = 1917304 }, ] [[package]] @@ -1186,7 +1203,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.3.2" +version = "8.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1196,9 +1213,9 @@ dependencies = [ { name = "pluggy" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/8c/9862305bdcd6020bc7b45b1b5e7397a6caf1a33d3025b9a003b39075ffb2/pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce", size = 1439314 } +sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/f9/cf155cf32ca7d6fa3601bc4c5dd19086af4b320b706919d48a4c79081cf9/pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", size = 341802 }, + { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, ] [[package]] @@ -1291,7 +1308,7 @@ wheels = [ [[package]] name = "python-kasa" -version = "0.7.2" +version = "0.7.3" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -1444,15 +1461,15 @@ wheels = [ [[package]] name = "rich" -version = "13.8.0" +version = "13.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/60/5959113cae0ce512cf246a6871c623117330105a0d5f59b4e26138f2c9cc/rich-13.8.0.tar.gz", hash = "sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4", size = 222072 } +sdist = { url = "https://files.pythonhosted.org/packages/92/76/40f084cb7db51c9d1fa29a7120717892aeda9a7711f6225692c957a93535/rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a", size = 222080 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/d9/c2a126eeae791e90ea099d05cb0515feea3688474b978343f3cdcfe04523/rich-13.8.0-py3-none-any.whl", hash = "sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc", size = 241597 }, + { url = "https://files.pythonhosted.org/packages/b0/11/dadb85e2bd6b1f1ae56669c3e1f0410797f9605d752d68fb47b77f525b31/rich-13.8.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06", size = 241608 }, ] [[package]] @@ -1638,15 +1655,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] -[[package]] -name = "tzdata" -version = "2024.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/5b/e025d02cb3b66b7b76093404392d4b44343c69101cc85f4d180dd5784717/tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd", size = 190559 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/65/58/f9c9e6be752e9fcb8b6a0ee9fb87e6e7a1f6bcab2cdc73f02bb7ba91ada0/tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252", size = 345370 }, -] - [[package]] name = "urllib3" version = "2.2.2" @@ -1658,16 +1666,16 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.26.3" +version = "20.26.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/60/db9f95e6ad456f1872486769c55628c7901fb4de5a72c2f7bdd912abf0c1/virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a", size = 9057588 } +sdist = { url = "https://files.pythonhosted.org/packages/84/8a/134f65c3d6066153b84fc176c58877acd8165ed0b79a149ff50502597284/virtualenv-20.26.4.tar.gz", hash = "sha256:c17f4e0f3e6036e9f26700446f85c76ab11df65ff6d8a9cbfad9f71aabfcf23c", size = 9385017 } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/4d/410156100224c5e2f0011d435e477b57aed9576fc7fe137abcf14ec16e11/virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589", size = 5684792 }, + { url = "https://files.pythonhosted.org/packages/5d/ea/12f774a18b55754c730c8383dad8f10d7b87397d1cb6b2b944c87381bb3b/virtualenv-20.26.4-py3-none-any.whl", hash = "sha256:48f2695d9809277003f30776d155615ffc11328e6a0a8c1f0ec80188d7874a55", size = 6013327 }, ] [[package]] @@ -1699,90 +1707,90 @@ wheels = [ [[package]] name = "yarl" -version = "1.9.11" +version = "1.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/87/6d71456eabebf614e0cac4387c27116a0bff9decf00a70c362fe7db9394e/yarl-1.9.11.tar.gz", hash = "sha256:c7548a90cb72b67652e2cd6ae80e2683ee08fde663104528ac7df12d8ef271d2", size = 156445 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/83/c58c9d26650a978ca31e50d78c75337f735590456fa526d68e523be745de/yarl-1.9.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:79e08c691deae6fcac2fdde2e0515ac561dd3630d7c8adf7b1e786e22f1e193b", size = 189362 }, - { url = "https://files.pythonhosted.org/packages/41/c4/26fab071e1edb57bf4641b33e9e1244a6be54ed7bf09c1616f6fbea3dd54/yarl-1.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:752f4b5cf93268dc73c2ae994cc6d684b0dad5118bc87fbd965fd5d6dca20f45", size = 113808 }, - { url = "https://files.pythonhosted.org/packages/2a/d7/ef06d41dcf3aa74a4a764fa8fe4a22ac8403939ea52810aef63dd6591570/yarl-1.9.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:441049d3a449fb8756b0535be72c6a1a532938a33e1cf03523076700a5f87a01", size = 111733 }, - { url = "https://files.pythonhosted.org/packages/2f/b6/192f542bed622cb7d2bdee0870f80cf8cc8a5bf50bc097fd6c49dfa4ad6b/yarl-1.9.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3dfe17b4aed832c627319da22a33f27f282bd32633d6b145c726d519c89fbaf", size = 462530 }, - { url = "https://files.pythonhosted.org/packages/cd/c8/669afb2480e9a17a799793bf52ebdb2f98219f20694dc0481bc69fd783a2/yarl-1.9.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:67abcb7df27952864440c9c85f1c549a4ad94afe44e2655f77d74b0d25895454", size = 486919 }, - { url = "https://files.pythonhosted.org/packages/44/16/db843909a991c89d3790f532499da40d5004271eded649075cd50c2d0090/yarl-1.9.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6de3fa29e76fd1518a80e6af4902c44f3b1b4d7fed28eb06913bba4727443de3", size = 481778 }, - { url = "https://files.pythonhosted.org/packages/f9/25/c9a476477264549b578bfaf7f0a5d747735be2b11fdb8886859017dc07cb/yarl-1.9.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fee45b3bd4d8d5786472e056aa1359cc4dc9da68aded95a10cd7929a0ec661fe", size = 468269 }, - { url = "https://files.pythonhosted.org/packages/51/68/595a2c04b88f9e9811a5bcee6cdfb6429c399d82c19ff34b708442a254e6/yarl-1.9.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c59b23886234abeba62087fd97d10fb6b905d9e36e2f3465d1886ce5c0ca30df", size = 451927 }, - { url = "https://files.pythonhosted.org/packages/fa/b1/668c3033059d145b1b0698649ee5f2992cf634c0e82bac5bfad64907fec7/yarl-1.9.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d93c612b2024ac25a3dc01341fd98fdd19c8c5e2011f3dcd084b3743cba8d756", size = 463785 }, - { url = "https://files.pythonhosted.org/packages/b1/a6/a1877f466afef028aaaba53362c529e3f3d35774ebc081a70734281fb6cd/yarl-1.9.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4d368e3b9ecd50fa22017a20c49e356471af6ae91c4d788c6e9297e25ddf5a62", size = 467154 }, - { url = "https://files.pythonhosted.org/packages/b3/86/73a0b99489594338da308b8d40c8f2d052acef597eec6651029910113ac7/yarl-1.9.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5b593acd45cdd4cf6664d342ceacedf25cd95263b83b964fddd6c78930ea5211", size = 492198 }, - { url = "https://files.pythonhosted.org/packages/4c/09/d8adc8b12a5d7b71501a38978cfbaa52d71a0491089d86015ccff456d3aa/yarl-1.9.11-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:224f8186c220ff00079e64bf193909829144d4e5174bb58665ef0da8bf6955c4", size = 492219 }, - { url = "https://files.pythonhosted.org/packages/00/36/a800ec3944cccb7a0036e437bc4a1ed5eedd9c484f568709d81d8426dcf8/yarl-1.9.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:91c478741d7563a12162f7a2db96c0d23d93b0521563f1f1f0ece46ea1702d33", size = 478243 }, - { url = "https://files.pythonhosted.org/packages/47/15/8f92ea31941e4ecfb9bcff0df6eee16328ef0428415e8a3f756120270752/yarl-1.9.11-cp310-cp310-win32.whl", hash = "sha256:1cdb8f5bb0534986776a43df84031da7ff04ac0cf87cb22ae8a6368231949c40", size = 99998 }, - { url = "https://files.pythonhosted.org/packages/09/8b/0ef81fc4d52f9a9a641c8e052781c9879f848d8b53816ff1e9e2cec1881a/yarl-1.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:498439af143b43a2b2314451ffd0295410aa0dcbdac5ee18fc8633da4670b605", size = 109329 }, - { url = "https://files.pythonhosted.org/packages/4c/86/d37652d1e9a8e19db20ce470c1d6c188322b8cc05446dcd0b6642cc90e9d/yarl-1.9.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9e290de5db4fd4859b4ed57cddfe793fcb218504e65781854a8ac283ab8d5518", size = 189496 }, - { url = "https://files.pythonhosted.org/packages/91/b6/38f30c3c3b564a74d6cfe4b43f78305f7aec49ef351b25f7907db70e4c2a/yarl-1.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e5f50a2e26cc2b89186f04c97e0ec0ba107ae41f1262ad16832d46849864f914", size = 113748 }, - { url = "https://files.pythonhosted.org/packages/56/0e/039696b0a464c4f6d4e92c5a2e0c5c13922e42fa2558fa0579c628a6d9ac/yarl-1.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4a0e724a28d7447e4d549c8f40779f90e20147e94bf949d490402eee09845c6", size = 111872 }, - { url = "https://files.pythonhosted.org/packages/f7/0b/71fc2139381c43e48d51ac6cfab7507ef35037d76119916dc4ac36db5985/yarl-1.9.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85333d38a4fa5997fa2ff6fd169be66626d814b34fa35ec669e8c914ca50a097", size = 505183 }, - { url = "https://files.pythonhosted.org/packages/43/93/2066c20798795d91231164235eee7cc736f19422b93fb6a00deada9c7b6d/yarl-1.9.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ff184002ee72e4b247240e35d5dce4c2d9a0e81fdbef715dde79ab4718aa541", size = 526581 }, - { url = "https://files.pythonhosted.org/packages/46/2e/51c9e3b28b53e515cae2bc4c2825dcf75f5e7e76dc7a50a667312673e562/yarl-1.9.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:675004040f847c0284827f44a1fa92d8baf425632cc93e7e0aa38408774b07c1", size = 521188 }, - { url = "https://files.pythonhosted.org/packages/b7/a9/d82324fdad4ee62139c587262acb83f1a1d69ec1b3fc86dd7028200d9428/yarl-1.9.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30703a7ade2b53f02e09a30685b70cd54f65ed314a8d9af08670c9a5391af1b", size = 509063 }, - { url = "https://files.pythonhosted.org/packages/71/af/3b9c8d18be3c8b2fbdf1f4e9a639849ab743025876a252b0ade3463823b1/yarl-1.9.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7230007ab67d43cf19200ec15bc6b654e6b85c402f545a6fc565d254d34ff754", size = 490352 }, - { url = "https://files.pythonhosted.org/packages/1f/a5/46aaeeb9f043d7ed83573210656478fbd47937d861572f6e04b3c60cd135/yarl-1.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8c2cf0c7ad745e1c6530fe6521dfb19ca43338239dfcc7da165d0ef2332c0882", size = 505028 }, - { url = "https://files.pythonhosted.org/packages/65/14/12d3f6dfc0d260085226f432429ae04c0cb80ef34224a0ccab50f4112ef1/yarl-1.9.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4567cc08f479ad80fb07ed0c9e1bcb363a4f6e3483a490a39d57d1419bf1c4c7", size = 503268 }, - { url = "https://files.pythonhosted.org/packages/02/9c/6f901ba254f659f24937dbb975c00ca6163f30a3d181f724138829b7d893/yarl-1.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:95adc179a02949c4560ef40f8f650a008380766eb253d74232eb9c024747c111", size = 534922 }, - { url = "https://files.pythonhosted.org/packages/f3/0d/26312f14700e07a0f707f60a0e6f9382a3f23fa1667f8f601ae96a3a7e77/yarl-1.9.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:755ae9cff06c429632d750aa8206f08df2e3d422ca67be79567aadbe74ae64cc", size = 538629 }, - { url = "https://files.pythonhosted.org/packages/34/94/17e241c753d7ea63baea87895d6b0ec858e6e2344e088e996d10d00786ad/yarl-1.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:94f71d54c5faf715e92c8434b4a0b968c4d1043469954d228fc031d51086f143", size = 520201 }, - { url = "https://files.pythonhosted.org/packages/02/8f/98a5eb47248f233e70b16dece4181f9e0ec66b07c0f19272e26d27579fa2/yarl-1.9.11-cp311-cp311-win32.whl", hash = "sha256:4ae079573efeaa54e5978ce86b77f4175cd32f42afcaf9bfb8a0677e91f84e4e", size = 99927 }, - { url = "https://files.pythonhosted.org/packages/4e/cc/7b21d1466c8c7a7feeb03736176081df9d4813c8900053fe9c5b89419893/yarl-1.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:9fae7ec5c9a4fe22abb995804e6ce87067dfaf7e940272b79328ce37c8f22097", size = 109672 }, - { url = "https://files.pythonhosted.org/packages/ba/ce/cb879750a618b977bc2dd57dae79f7173626a35294a2911c3749f5618f93/yarl-1.9.11-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:614fa50fd0db41b79f426939a413d216cdc7bab8d8c8a25844798d286a999c5a", size = 189965 }, - { url = "https://files.pythonhosted.org/packages/8c/fc/20e7083cc89e5856d59483692e41d623bd9bd1c158d2fb543ee3f6c05535/yarl-1.9.11-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ff64f575d71eacb5a4d6f0696bfe991993d979423ea2241f23ab19ff63f0f9d1", size = 114312 }, - { url = "https://files.pythonhosted.org/packages/7c/80/e027f7c1f51a6eb178657937cceffb175716d18929f054982debb5db9101/yarl-1.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c23f6dc3d7126b4c64b80aa186ac2bb65ab104a8372c4454e462fb074197bc6", size = 111989 }, - { url = "https://files.pythonhosted.org/packages/88/aa/ffea918291c455305475b7850b2cf970f5a80e6dfbcde9b94a5eacdfe8ec/yarl-1.9.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8f847cc092c2b85d22e527f91ea83a6cf51533e727e2461557a47a859f96734", size = 505169 }, - { url = "https://files.pythonhosted.org/packages/67/9e/86f55c8f70868203ab9cdb168e1821a64a13d9a0e35e5e077152c121b534/yarl-1.9.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63a5dc2866791236779d99d7a422611d22bb3a3d50935bafa4e017ea13e51469", size = 522267 }, - { url = "https://files.pythonhosted.org/packages/77/d1/eb10e0ce4f91cc82e19a775341b5d8b06e249f6decbd24267d9009c68a2a/yarl-1.9.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c335342d482e66254ae94b1231b1532790afb754f89e2e0c646f7f19d09740aa", size = 519634 }, - { url = "https://files.pythonhosted.org/packages/2c/b5/a2ca969c82583fc7d2073d3f6461f67c0e7fc8cd4aec5175c8cc7847eab4/yarl-1.9.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4a8c3dedd081cca134a21179aebe58b6e426e8d1e0202da9d1cafa56e01af3c", size = 511879 }, - { url = "https://files.pythonhosted.org/packages/e4/92/724d7eb7b8dca0ae97a9054ba41742e6599d4c94e1e090e29ae3d54aaf7c/yarl-1.9.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:504d19320c92532cabc3495fb7ed6bb599f3c2bfb45fed432049bf4693dbd6d0", size = 488601 }, - { url = "https://files.pythonhosted.org/packages/33/49/0f3d021ca989640d173495846a6832eb124e53a2ce4ed64b9036a4e1683c/yarl-1.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b2a8e5eb18181060197e3d5db7e78f818432725c0759bc1e5a9d603d9246389", size = 507354 }, - { url = "https://files.pythonhosted.org/packages/04/99/161a2365a804dfec9e5ff5c44c6c09c64ba87bcf979b915a54a5a4a37d9e/yarl-1.9.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f568d70b7187f4002b6b500c0996c37674a25ce44b20716faebe5fdb8bd356e7", size = 506537 }, - { url = "https://files.pythonhosted.org/packages/91/dc/f389be705187434423ad70bae78a55243fb16a07e4b80d8d97c5fb821fe0/yarl-1.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:735b285ea46ca7e86ad261a462a071d0968aade44e1a3ea2b7d4f3d63b5aab12", size = 529698 }, - { url = "https://files.pythonhosted.org/packages/39/da/ab9ee8f9de9cae329b4a3f3c115acd18138cca1a2fb78ec10c20f9f114b5/yarl-1.9.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2d1c81c3b92bef0c1c180048e43a5a85754a61b4f69d6f84df8e4bd615bef25d", size = 540822 }, - { url = "https://files.pythonhosted.org/packages/c9/42/cad7ffe2469282b91b84ebc8cfc79bfbd0c7cce182fa0da445d6980c25dc/yarl-1.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8d6e1c1562b53bd26efd38e886fc13863b8d904d559426777990171020c478a9", size = 525804 }, - { url = "https://files.pythonhosted.org/packages/12/e9/bb4db60dc9e63e593b98d59d68006fa3267065eed36a60d1774fb855f46f/yarl-1.9.11-cp312-cp312-win32.whl", hash = "sha256:aeba4aaa59cb709edb824fa88a27cbbff4e0095aaf77212b652989276c493c00", size = 99851 }, - { url = "https://files.pythonhosted.org/packages/58/06/830f22113154c2ed995ed1d62305b619ba15346c0b27f5a1a6224b0f464b/yarl-1.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:569309a3efb8369ff5d32edb2a0520ebaf810c3059f11d34477418c90aa878fd", size = 109687 }, - { url = "https://files.pythonhosted.org/packages/e1/30/8410476ca3d94603d7137cf5f70fb677438aed855dddc0af3262d5799cf7/yarl-1.9.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4915818ac850c3b0413e953af34398775b7a337babe1e4d15f68c8f5c4872553", size = 186811 }, - { url = "https://files.pythonhosted.org/packages/64/b5/2e69a2a42d8c8e5f777ea0fb1e894ccd5436d815fde17ccdaa8907593798/yarl-1.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef9610b2f5a73707d4d8bac040f0115ca848e510e3b1f45ca53e97f609b54130", size = 112675 }, - { url = "https://files.pythonhosted.org/packages/7d/a1/ea9a0b6972e407bb938b5dbaec8dbd075120ff3a8d326b64d5fefd61c5b3/yarl-1.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:47c0a3dc8076a8dd159de10628dea04215bc7ddaa46c5775bf96066a0a18f82b", size = 110581 }, - { url = "https://files.pythonhosted.org/packages/c6/df/67deae2f0967512f8aa7250b995f0fa12e90bc6cde859e764f031ae8b64d/yarl-1.9.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:545f2fbfa0c723b446e9298b5beba0999ff82ce2c126110759e8dac29b5deaf4", size = 487012 }, - { url = "https://files.pythonhosted.org/packages/a4/87/fa5e6b325d90c3688053a12f713fd1cd8427e136c2c5aeebb330522903ee/yarl-1.9.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9137975a4ccc163ad5d7a75aad966e6e4e95dedee08d7995eab896a639a0bce2", size = 502359 }, - { url = "https://files.pythonhosted.org/packages/30/38/c305323bd6b5f79542e1769dd6c54c7f9f1331e2852678ba653db279a50e/yarl-1.9.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b0c70c451d2a86f8408abced5b7498423e2487543acf6fcf618b03f6e669b0a", size = 503314 }, - { url = "https://files.pythonhosted.org/packages/f9/eb/5361af39d4d0b9852df2eee24744796f66c130efbca7de29bdca68b00a04/yarl-1.9.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce2bd986b1e44528677c237b74d59f215c8bfcdf2d69442aa10f62fd6ab2951c", size = 494560 }, - { url = "https://files.pythonhosted.org/packages/b4/c4/c0e80d9727c03b6398bfcf0350f289a9987dbb7df5fd3d4d4fb42f4dd2e4/yarl-1.9.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8d7b717f77846a9631046899c6cc730ea469c0e2fb252ccff1cc119950dbc296", size = 471861 }, - { url = "https://files.pythonhosted.org/packages/ea/f7/e9812de6bab1fd29c51d1ca21490a48e5793769a7c2b888d9702b351e7f8/yarl-1.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3a26a24bbd19241283d601173cea1e5b93dec361a223394e18a1e8e5b0ef20bd", size = 491463 }, - { url = "https://files.pythonhosted.org/packages/6e/72/e68c36c5b41126e74dafd43e82cb735d147c78f13162da817cafbe2a2516/yarl-1.9.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c189bf01af155ac9882e128d9f3b3ad68a1f2c2f51404afad7201305df4e12b1", size = 493787 }, - { url = "https://files.pythonhosted.org/packages/2f/1b/c8501fd2257d86e40f651060f743e80c25cd0d117e56c73f356ff2f8a515/yarl-1.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0cbcc2c54084b2bda4109415631db017cf2960f74f9e8fd1698e1400e4f8aae2", size = 509913 }, - { url = "https://files.pythonhosted.org/packages/c8/c6/ed68fdade11e5135e875294699ffe3ef2932eab2e44b4b86bbc2982d7b51/yarl-1.9.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:30f201bc65941a4aa59c1236783efe89049ec5549dafc8cd2b63cc179d3767b0", size = 520697 }, - { url = "https://files.pythonhosted.org/packages/50/1f/dcb441a46864db3a998a14fa47b42f88fb4857e29691a93c114f5ca5b1d9/yarl-1.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:922ba3b74f0958a0b5b9c14ff1ef12714a381760c08018f2b9827632783a590c", size = 511026 }, - { url = "https://files.pythonhosted.org/packages/4e/c7/2a874b498ab31613a6656a2b5821b268be44dff2d90a2a46125190bc68c1/yarl-1.9.11-cp313-cp313-win32.whl", hash = "sha256:17107b4b8c43e66befdcbe543fff2f9c93f7a3a9f8e3a9c9ac42bffeba0e8828", size = 484296 }, - { url = "https://files.pythonhosted.org/packages/25/ea/a264724a77278e89055ec3e9f75c8870960be799ee7d5f3ba39ac6422aeb/yarl-1.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:0324506afab4f2e176a93cb08b8abcb8b009e1f324e6cbced999a8f5dd9ddb76", size = 492263 }, - { url = "https://files.pythonhosted.org/packages/09/4b/b8752234ba384770cb11a055eba290986b94bdf51a7282f00202006bc9e6/yarl-1.9.11-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d65ad67f981e93ea11f87815f67d086c4f33da4800cf2106d650dd8a0b79dda4", size = 192167 }, - { url = "https://files.pythonhosted.org/packages/ed/b3/3b91a9ad129d85fab03aa84b4b8ed964ba99cd298df696c0be55c6a8f987/yarl-1.9.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:752c0d33b4aacdb147871d0754b88f53922c6dc2aff033096516b3d5f0c02a0f", size = 115382 }, - { url = "https://files.pythonhosted.org/packages/ed/90/831333852eddbb04e426fda3626ccbeba41b3839516d089199f153a4385b/yarl-1.9.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:54cc24be98d7f4ff355ca2e725a577e19909788c0db6beead67a0dda70bd3f82", size = 113174 }, - { url = "https://files.pythonhosted.org/packages/16/10/4e27a96b75073625fd0d2d15065ab36b9d7aa922866ae4061421d35bfd39/yarl-1.9.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c82126817492bb2ebc946e74af1ffa10aacaca81bee360858477f96124be39a", size = 468798 }, - { url = "https://files.pythonhosted.org/packages/8f/8a/1bafeef3310ad40ebf61e56ddb34ca24ea839975869b678a760396567cca/yarl-1.9.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8503989860d7ac10c85cb5b607fec003a45049cf7a5b4b72451e87893c6bb990", size = 496003 }, - { url = "https://files.pythonhosted.org/packages/41/4c/5120253a87fd1d31a275ab60746157315beb11818b29a12f551abad6f0bd/yarl-1.9.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:475e09a67f8b09720192a170ad9021b7abf7827ffd4f3a83826317a705be06b7", size = 489783 }, - { url = "https://files.pythonhosted.org/packages/73/bb/5ca3cd5ac49fe1f0934fd2544e8b4dc25383a6f0dca78c2b8a5084fecf93/yarl-1.9.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afcac5bda602b74ff701e1f683feccd8cce0d5a21dbc68db81bf9bd8fd93ba56", size = 475065 }, - { url = "https://files.pythonhosted.org/packages/55/43/a705d99801883754cbee6c2a94a29f74b1e4977806077f24a21ff26a5881/yarl-1.9.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aaeffcb84faceb2923a94a8a9aaa972745d3c728ab54dd011530cc30a3d5d0c1", size = 458525 }, - { url = "https://files.pythonhosted.org/packages/f7/7a/27f9e55e781c44dee295822431e1167ed54cb5d613ac0fa857c5dc67e172/yarl-1.9.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:51a6f770ac86477cd5c553f88a77a06fe1f6f3b643b053fcc7902ab55d6cbe14", size = 472346 }, - { url = "https://files.pythonhosted.org/packages/70/e1/b4b1c66d5fb8e7bff310739f8c81a31c0cda9d1e0374c6348c5c03c20ed6/yarl-1.9.11-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3fcd056cb7dff3aea5b1ee1b425b0fbaa2fbf6a1c6003e88caf524f01de5f395", size = 474324 }, - { url = "https://files.pythonhosted.org/packages/fb/3a/14e0150de1aaaddbb1715b4fc0a19af3e66c1e4137271ee71c659a473087/yarl-1.9.11-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:21e56c30e39a1833e4e3fd0112dde98c2abcbc4c39b077e6105c76bb63d2aa04", size = 500743 }, - { url = "https://files.pythonhosted.org/packages/be/07/745f898427835a4d044c9e1586e7baeef1adb379f17dd63ad957de3ba92e/yarl-1.9.11-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0a205ec6349879f5e75dddfb63e069a24f726df5330b92ce76c4752a436aac01", size = 500393 }, - { url = "https://files.pythonhosted.org/packages/69/88/8001321678b500df92238b325e67ead9541e592f557c288b623f44ddf155/yarl-1.9.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a5706821e1cf3c70dfea223e4e0958ea354f4e2af9420a1bd45c6b547297fb97", size = 486873 }, - { url = "https://files.pythonhosted.org/packages/d0/c3/41b3bd7a61367ef73b43bc53fa0855dad13eb3e460648d4d913349d24ba7/yarl-1.9.11-cp39-cp39-win32.whl", hash = "sha256:cc295969f8c2172b5d013c0871dccfec7a0e1186cf961e7ea575d47b4d5cbd32", size = 101030 }, - { url = "https://files.pythonhosted.org/packages/f5/01/b6cd8aa377d35c8b7fd152f0e8ae359e0f36982a758e98d484aa313b8093/yarl-1.9.11-cp39-cp39-win_amd64.whl", hash = "sha256:55a67dd29367ce7c08a0541bb602ec0a2c10d46c86b94830a1a665f7fd093dfa", size = 110518 }, - { url = "https://files.pythonhosted.org/packages/a0/20/ae694bcd16b49e83a9f943ebc2f0a90c9ca6bb09846e99a7bda30e1773d9/yarl-1.9.11-py3-none-any.whl", hash = "sha256:c6f6c87665a9e18a635f0545ea541d9640617832af2317d4f5ad389686b4ed3d", size = 36423 }, +sdist = { url = "https://files.pythonhosted.org/packages/e4/3d/4924f9ed49698bac5f112bc9b40aa007bbdcd702462c1df3d2e1383fb158/yarl-1.11.1.tar.gz", hash = "sha256:1bb2d9e212fb7449b8fb73bc461b51eaa17cc8430b4a87d87be7b25052d92f53", size = 162095 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/a3/4e67b1463c12ba178aace33b62468377473c77b33a95bcb12b67b2b93817/yarl-1.11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:400cd42185f92de559d29eeb529e71d80dfbd2f45c36844914a4a34297ca6f00", size = 188473 }, + { url = "https://files.pythonhosted.org/packages/f3/86/c0c76e69a390fb43533783582714e8a58003f443b81cac1605ce71cade00/yarl-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8258c86f47e080a258993eed877d579c71da7bda26af86ce6c2d2d072c11320d", size = 114362 }, + { url = "https://files.pythonhosted.org/packages/07/ef/e6bee78c1bf432de839148fe9fdc1cf5e7fbd6402d8b0b7d7a1522fb9733/yarl-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2164cd9725092761fed26f299e3f276bb4b537ca58e6ff6b252eae9631b5c96e", size = 112537 }, + { url = "https://files.pythonhosted.org/packages/37/f4/3406e76ed71e4d3023dbae4514513a387e2e753cb8a4cadd6ff9ba08a046/yarl-1.11.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08ea567c16f140af8ddc7cb58e27e9138a1386e3e6e53982abaa6f2377b38cc", size = 442573 }, + { url = "https://files.pythonhosted.org/packages/37/15/98b4951271a693142e551fea24bca1e96be71b5256b3091dbe8433532a45/yarl-1.11.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:768ecc550096b028754ea28bf90fde071c379c62c43afa574edc6f33ee5daaec", size = 468046 }, + { url = "https://files.pythonhosted.org/packages/88/1a/f10b88c4d8200708cbc799aad978a37a0ab15a4a72511c60bed11ee585c4/yarl-1.11.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2909fa3a7d249ef64eeb2faa04b7957e34fefb6ec9966506312349ed8a7e77bf", size = 462124 }, + { url = "https://files.pythonhosted.org/packages/02/a3/97b527b5c4551c3b17fd095fe019435664330060b3879c8c1ae80985d4bc/yarl-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01a8697ec24f17c349c4f655763c4db70eebc56a5f82995e5e26e837c6eb0e49", size = 446807 }, + { url = "https://files.pythonhosted.org/packages/40/06/da47aae54f1bb8ac0668d68bbdde40ba761643f253b2c16fdb4362af8ca3/yarl-1.11.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e286580b6511aac7c3268a78cdb861ec739d3e5a2a53b4809faef6b49778eaff", size = 431778 }, + { url = "https://files.pythonhosted.org/packages/ba/a1/54992cd68f61c11d975184f4c8a4c7f43a838e7c6ce183030a3fc0a257a6/yarl-1.11.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4179522dc0305c3fc9782549175c8e8849252fefeb077c92a73889ccbcd508ad", size = 443702 }, + { url = "https://files.pythonhosted.org/packages/5c/8b/adf290dc272a1a30a0e9dc04e2e62486be80f371bd9da2e9899f8e6181f3/yarl-1.11.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:27fcb271a41b746bd0e2a92182df507e1c204759f460ff784ca614e12dd85145", size = 448289 }, + { url = "https://files.pythonhosted.org/packages/fc/98/e6ad935fa009890b9ef2769266dc9dceaeee5a7f9a57bc7daf50b5b6c305/yarl-1.11.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f61db3b7e870914dbd9434b560075e0366771eecbe6d2b5561f5bc7485f39efd", size = 471660 }, + { url = "https://files.pythonhosted.org/packages/91/5d/1ad82849ce3c02661395f5097878c58ecabc4dac5d2d98e4f85949386448/yarl-1.11.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:c92261eb2ad367629dc437536463dc934030c9e7caca861cc51990fe6c565f26", size = 469830 }, + { url = "https://files.pythonhosted.org/packages/e0/70/376046a7f69cfec814b97fb8bf1af6f16dcbe37fd0ef89a9f87b04156923/yarl-1.11.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d95b52fbef190ca87d8c42f49e314eace4fc52070f3dfa5f87a6594b0c1c6e46", size = 457671 }, + { url = "https://files.pythonhosted.org/packages/33/49/825f84f9a5d26d26fbf82531cee3923f356e2d8efc1819b85ada508fa91f/yarl-1.11.1-cp310-cp310-win32.whl", hash = "sha256:489fa8bde4f1244ad6c5f6d11bb33e09cf0d1d0367edb197619c3e3fc06f3d91", size = 101184 }, + { url = "https://files.pythonhosted.org/packages/b0/29/2a08a45b9f2eddd1b840813698ee655256f43b507c12f7f86df947cf5f8f/yarl-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:476e20c433b356e16e9a141449f25161e6b69984fb4cdbd7cd4bd54c17844998", size = 110175 }, + { url = "https://files.pythonhosted.org/packages/af/f1/f3e6be722461cab1e7c6aea657685897956d6e4743940d685d167914e31c/yarl-1.11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:946eedc12895873891aaceb39bceb484b4977f70373e0122da483f6c38faaa68", size = 188410 }, + { url = "https://files.pythonhosted.org/packages/4b/c1/21cc66b263fdc2ec10b6459aed5b239f07eed91a77438d88f0e1bd70e202/yarl-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:21a7c12321436b066c11ec19c7e3cb9aec18884fe0d5b25d03d756a9e654edfe", size = 114293 }, + { url = "https://files.pythonhosted.org/packages/31/7a/0ecab63a166a22357772f4a2852c859e2d5a7b02a5c58803458dd516e6b4/yarl-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c35f493b867912f6fda721a59cc7c4766d382040bdf1ddaeeaa7fa4d072f4675", size = 112548 }, + { url = "https://files.pythonhosted.org/packages/57/5d/78152026864475e841fdae816499345364c8e364b45ea6accd0814a295f0/yarl-1.11.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25861303e0be76b60fddc1250ec5986c42f0a5c0c50ff57cc30b1be199c00e63", size = 485002 }, + { url = "https://files.pythonhosted.org/packages/d3/70/2e880d74aeb4908d45c6403e46bbd4aa866ae31ddb432318d9b8042fe0f6/yarl-1.11.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4b53f73077e839b3f89c992223f15b1d2ab314bdbdf502afdc7bb18e95eae27", size = 504850 }, + { url = "https://files.pythonhosted.org/packages/06/58/5676a47b6d2751853f89d1d68b6a54d725366da6a58482f2410fa7eb38af/yarl-1.11.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:327c724b01b8641a1bf1ab3b232fb638706e50f76c0b5bf16051ab65c868fac5", size = 499291 }, + { url = "https://files.pythonhosted.org/packages/4d/e5/b56d535703a63a8d86ac82059e630e5ba9c0d5626d9c5ac6af53eed815c2/yarl-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4307d9a3417eea87715c9736d050c83e8c1904e9b7aada6ce61b46361b733d92", size = 487818 }, + { url = "https://files.pythonhosted.org/packages/f3/b4/6b95e1e0983593f4145518980b07126a27e2a4938cb6afb8b592ce6fc2c9/yarl-1.11.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48a28bed68ab8fb7e380775f0029a079f08a17799cb3387a65d14ace16c12e2b", size = 470447 }, + { url = "https://files.pythonhosted.org/packages/a8/e5/5d349b7b04ed4247d4f717f271fce601a79d10e2ac81166c13f97c4973a9/yarl-1.11.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:067b961853c8e62725ff2893226fef3d0da060656a9827f3f520fb1d19b2b68a", size = 484544 }, + { url = "https://files.pythonhosted.org/packages/fa/dc/ce90e9d85ef2233e81148a9658e4ea8372c6de070ce96c5c8bd3ff365144/yarl-1.11.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8215f6f21394d1f46e222abeb06316e77ef328d628f593502d8fc2a9117bde83", size = 482409 }, + { url = "https://files.pythonhosted.org/packages/4c/a1/17c0a03615b0cd213aee2e318a0fbd3d07259c37976d85af9eec6184c589/yarl-1.11.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:498442e3af2a860a663baa14fbf23fb04b0dd758039c0e7c8f91cb9279799bff", size = 512970 }, + { url = "https://files.pythonhosted.org/packages/6c/ed/1e317799d54c79a3e4846db597510f5c84fb7643bb8703a3848136d40809/yarl-1.11.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:69721b8effdb588cb055cc22f7c5105ca6fdaa5aeb3ea09021d517882c4a904c", size = 515203 }, + { url = "https://files.pythonhosted.org/packages/7a/37/9a4e2d73953956fa686fa0f0c4a0881245f39423fa75875d981b4f680611/yarl-1.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e969fa4c1e0b1a391f3fcbcb9ec31e84440253325b534519be0d28f4b6b533e", size = 497323 }, + { url = "https://files.pythonhosted.org/packages/a3/c3/a25ae9c85c0e50a8722aecc486ac5ba53b28d1384548df99b2145cb69862/yarl-1.11.1-cp311-cp311-win32.whl", hash = "sha256:7d51324a04fc4b0e097ff8a153e9276c2593106a811704025bbc1d6916f45ca6", size = 101226 }, + { url = "https://files.pythonhosted.org/packages/90/6d/c62ba0ae0232a0b0012706a7735a16b44a03216fedfb6ea0bcda79d1e12c/yarl-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:15061ce6584ece023457fb8b7a7a69ec40bf7114d781a8c4f5dcd68e28b5c53b", size = 110471 }, + { url = "https://files.pythonhosted.org/packages/3b/05/379002019a0c9d5dc0c4cc6f71e324ea43461ae6f58e94ee87e07b8ffa90/yarl-1.11.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a4264515f9117be204935cd230fb2a052dd3792789cc94c101c535d349b3dab0", size = 189044 }, + { url = "https://files.pythonhosted.org/packages/23/d5/e62cfba5ceaaf92ee4f9af6f9c9ab2f2b47d8ad48687fa69570a93b0872c/yarl-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f41fa79114a1d2eddb5eea7b912d6160508f57440bd302ce96eaa384914cd265", size = 114867 }, + { url = "https://files.pythonhosted.org/packages/b1/10/6abc0bd7e7fe7c6b9b9e9ce0ff558912c9ecae65a798f5442020ef9e4177/yarl-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:02da8759b47d964f9173c8675710720b468aa1c1693be0c9c64abb9d8d9a4867", size = 112737 }, + { url = "https://files.pythonhosted.org/packages/37/a5/ad026afde5efe1849f4f55bd9f9a2cb5b006511b324db430ae5336104fb3/yarl-1.11.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9361628f28f48dcf8b2f528420d4d68102f593f9c2e592bfc842f5fb337e44fd", size = 482887 }, + { url = "https://files.pythonhosted.org/packages/f8/82/b8bee972617b800319b4364cfcd69bfaf7326db052e91a56e63986cc3e05/yarl-1.11.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b91044952da03b6f95fdba398d7993dd983b64d3c31c358a4c89e3c19b6f7aef", size = 498635 }, + { url = "https://files.pythonhosted.org/packages/af/ad/ac688503b134e02e8505415f0b8e94dc8e92a97e82abdd9736658389b5ae/yarl-1.11.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74db2ef03b442276d25951749a803ddb6e270d02dda1d1c556f6ae595a0d76a8", size = 496198 }, + { url = "https://files.pythonhosted.org/packages/ce/f2/b6cae0ad1afed6e95f82ab2cb9eb5b63e41f1463ece2a80c39d80cf6167a/yarl-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e975a2211952a8a083d1b9d9ba26472981ae338e720b419eb50535de3c02870", size = 489068 }, + { url = "https://files.pythonhosted.org/packages/c8/f4/355e69b5563154b40550233ffba8f6099eac0c99788600191967763046cf/yarl-1.11.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aef97ba1dd2138112890ef848e17d8526fe80b21f743b4ee65947ea184f07a2", size = 468286 }, + { url = "https://files.pythonhosted.org/packages/26/3d/3c37f3f150faf87b086f7915724f2fcb9ff2f7c9d3f6c0f42b7722bd9b77/yarl-1.11.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a7915ea49b0c113641dc4d9338efa9bd66b6a9a485ffe75b9907e8573ca94b84", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/94/ee/d591abbaea3b14e0f68bdec5cbcb75f27107190c51889d518bafe5d8f120/yarl-1.11.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:504cf0d4c5e4579a51261d6091267f9fd997ef58558c4ffa7a3e1460bd2336fa", size = 484947 }, + { url = "https://files.pythonhosted.org/packages/57/70/ad1c65a13315f03ff0c63fd6359dd40d8198e2a42e61bf86507602a0364f/yarl-1.11.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3de5292f9f0ee285e6bd168b2a77b2a00d74cbcfa420ed078456d3023d2f6dff", size = 505610 }, + { url = "https://files.pythonhosted.org/packages/4c/8c/6086dec0f8d7df16d136b38f373c49cf3d2fb94464e5a10bf788b36f3f54/yarl-1.11.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a34e1e30f1774fa35d37202bbeae62423e9a79d78d0874e5556a593479fdf239", size = 515951 }, + { url = "https://files.pythonhosted.org/packages/49/79/e0479e9a3bbb7bdcb82779d89711b97cea30902a4bfe28d681463b7071ce/yarl-1.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:66b63c504d2ca43bf7221a1f72fbe981ff56ecb39004c70a94485d13e37ebf45", size = 501273 }, + { url = "https://files.pythonhosted.org/packages/8e/85/eab962453e81073276b22f3d1503dffe6bfc3eb9cd0f31899970de05d490/yarl-1.11.1-cp312-cp312-win32.whl", hash = "sha256:a28b70c9e2213de425d9cba5ab2e7f7a1c8ca23a99c4b5159bf77b9c31251447", size = 101139 }, + { url = "https://files.pythonhosted.org/packages/5d/de/618b3e5cab10af8a2ed3eb625dac61c1d16eb155d1f56f9fdb3500786c12/yarl-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:17b5a386d0d36fb828e2fb3ef08c8829c1ebf977eef88e5367d1c8c94b454639", size = 110504 }, + { url = "https://files.pythonhosted.org/packages/07/b7/948e4f427817e0178f3737adf6712fea83f76921e11e2092f403a8a9dc4a/yarl-1.11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1fa2e7a406fbd45b61b4433e3aa254a2c3e14c4b3186f6e952d08a730807fa0c", size = 185061 }, + { url = "https://files.pythonhosted.org/packages/f3/67/8d91ad79a3b907b4fef27fafa912350554443ba53364fff3c347b41105cb/yarl-1.11.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:750f656832d7d3cb0c76be137ee79405cc17e792f31e0a01eee390e383b2936e", size = 113056 }, + { url = "https://files.pythonhosted.org/packages/a1/77/6b2348a753702fa87f435cc33dcec21981aaca8ef98a46566a7b29940b4a/yarl-1.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b8486f322d8f6a38539136a22c55f94d269addb24db5cb6f61adc61eabc9d93", size = 110958 }, + { url = "https://files.pythonhosted.org/packages/8e/3e/6eadf32656741549041f549a392f3b15245d3a0a0b12a9bc22bd6b69621f/yarl-1.11.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fce4da3703ee6048ad4138fe74619c50874afe98b1ad87b2698ef95bf92c96d", size = 470326 }, + { url = "https://files.pythonhosted.org/packages/3d/a4/1b641a8c7899eeaceec45ff105a2e7206ec0eb0fb9d86403963cc8521c5e/yarl-1.11.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ed653638ef669e0efc6fe2acb792275cb419bf9cb5c5049399f3556995f23c7", size = 484778 }, + { url = "https://files.pythonhosted.org/packages/8a/f5/80c142f34779a5c26002b2bf1f73b9a9229aa9e019ee6f9fd9d3e9704e78/yarl-1.11.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18ac56c9dd70941ecad42b5a906820824ca72ff84ad6fa18db33c2537ae2e089", size = 485568 }, + { url = "https://files.pythonhosted.org/packages/f8/f2/6b40ffea2d5d3a11f514ab23c30d14f52600c36a3210786f5974b6701bb8/yarl-1.11.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:688654f8507464745ab563b041d1fb7dab5d9912ca6b06e61d1c4708366832f5", size = 477801 }, + { url = "https://files.pythonhosted.org/packages/4c/1a/e60c116f3241e4842ed43c104eb2751abe02f6bac0301cdae69e4fda9c3a/yarl-1.11.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4973eac1e2ff63cf187073cd4e1f1148dcd119314ab79b88e1b3fad74a18c9d5", size = 455361 }, + { url = "https://files.pythonhosted.org/packages/b9/98/fe0aeee425a4bc5cd3ed86e867661d2bfa782544fa07a8e3dcd97d51ae3d/yarl-1.11.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:964a428132227edff96d6f3cf261573cb0f1a60c9a764ce28cda9525f18f7786", size = 473893 }, + { url = "https://files.pythonhosted.org/packages/6b/9b/677455d146bd3cecd350673f0e4bb28854af66726493ace3b640e9c5552b/yarl-1.11.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6d23754b9939cbab02c63434776df1170e43b09c6a517585c7ce2b3d449b7318", size = 476407 }, + { url = "https://files.pythonhosted.org/packages/33/ca/ce85766247a9a9b56654428fb78a3e14ea6947a580a9c4e891b3aa7da322/yarl-1.11.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c2dc4250fe94d8cd864d66018f8344d4af50e3758e9d725e94fecfa27588ff82", size = 490848 }, + { url = "https://files.pythonhosted.org/packages/6d/d6/717f0f19bcf2c4705ad95550b4b6319a0d8d1d4f137ea5e223207f00df50/yarl-1.11.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09696438cb43ea6f9492ef237761b043f9179f455f405279e609f2bc9100212a", size = 501084 }, + { url = "https://files.pythonhosted.org/packages/14/b5/b93c70d9a462b802c8df65c64b85f49d86b4ba70c393fbad95cf7ec053cb/yarl-1.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:999bfee0a5b7385a0af5ffb606393509cfde70ecca4f01c36985be6d33e336da", size = 491776 }, + { url = "https://files.pythonhosted.org/packages/03/0f/5a52eaa402a6a93265ba82f42c6f6085ccbe483e1b058ad34207e75812b1/yarl-1.11.1-cp313-cp313-win32.whl", hash = "sha256:ce928c9c6409c79e10f39604a7e214b3cb69552952fbda8d836c052832e6a979", size = 485250 }, + { url = "https://files.pythonhosted.org/packages/dd/97/946d26a5d82706a6769399cabd472c59f9a3227ce1432afb4739b9c29572/yarl-1.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:501c503eed2bb306638ccb60c174f856cc3246c861829ff40eaa80e2f0330367", size = 492590 }, + { url = "https://files.pythonhosted.org/packages/66/dd/c65267bc85cb3726d3b0b3d0b50f96d868fe90cc5e5fccd28608d0eea241/yarl-1.11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:884eab2ce97cbaf89f264372eae58388862c33c4f551c15680dd80f53c89a269", size = 191166 }, + { url = "https://files.pythonhosted.org/packages/97/32/a54abf14f380e99b29fc0f03b310934f10b4fcd567264e95dc0134812f5f/yarl-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a336eaa7ee7e87cdece3cedb395c9657d227bfceb6781295cf56abcd3386a26", size = 115912 }, + { url = "https://files.pythonhosted.org/packages/41/e9/8ecf8233b016389b94ab520f6eb178f00586eef5e4b6bd550cef8a227b41/yarl-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87f020d010ba80a247c4abc335fc13421037800ca20b42af5ae40e5fd75e7909", size = 113835 }, + { url = "https://files.pythonhosted.org/packages/e7/68/7409309a0b3aec7ae1a18dd5a09cd9447e018421f5f79d6fd29930f347d5/yarl-1.11.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:637c7ddb585a62d4469f843dac221f23eec3cbad31693b23abbc2c366ad41ff4", size = 449180 }, + { url = "https://files.pythonhosted.org/packages/60/73/9862f1d7c836a6f6681e4b6d5f8037b09bd77e3af347fd5ec88b395f140a/yarl-1.11.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48dfd117ab93f0129084577a07287376cc69c08138694396f305636e229caa1a", size = 476414 }, + { url = "https://files.pythonhosted.org/packages/c8/30/7a76451b7621317c61d7bd1f29ea1936a96558738e20a57beb8ed8e6c13b/yarl-1.11.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e0ae31fb5ccab6eda09ba1494e87eb226dcbd2372dae96b87800e1dcc98804", size = 469102 }, + { url = "https://files.pythonhosted.org/packages/ff/be/78953a3d5154b974af49ce367f1a8d4751ababdf26a66ae607b4ae625d99/yarl-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f46f81501160c28d0c0b7333b4f7be8983dbbc161983b6fb814024d1b4952f79", size = 453981 }, + { url = "https://files.pythonhosted.org/packages/36/10/36ad3a314d7791439bb8e580997a4952f236d4a6fc741128bd040edc91fc/yarl-1.11.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:04293941646647b3bfb1719d1d11ff1028e9c30199509a844da3c0f5919dc520", size = 438686 }, + { url = "https://files.pythonhosted.org/packages/8e/8d/f118c3b744514e71dd93c755c393705a034e1ebec7525c6598c941b8d1ea/yarl-1.11.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:250e888fa62d73e721f3041e3a9abf427788a1934b426b45e1b92f62c1f68366", size = 450933 }, + { url = "https://files.pythonhosted.org/packages/1d/6e/9eb29d7291253d7ae94c292e30fe7bfa4236439a4f97d736e72aee9cca26/yarl-1.11.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e8f63904df26d1a66aabc141bfd258bf738b9bc7bc6bdef22713b4f5ef789a4c", size = 454471 }, + { url = "https://files.pythonhosted.org/packages/16/08/8f450fee2be068e06c683ec1e43b25f335fa4e1f2f468dd0cf23ea2e348c/yarl-1.11.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:aac44097d838dda26526cffb63bdd8737a2dbdf5f2c68efb72ad83aec6673c7e", size = 480037 }, + { url = "https://files.pythonhosted.org/packages/3f/57/37f89174e82534f4c77e301d046481315619cd36cf2866ac993993c10d46/yarl-1.11.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:267b24f891e74eccbdff42241c5fb4f974de2d6271dcc7d7e0c9ae1079a560d9", size = 477252 }, + { url = "https://files.pythonhosted.org/packages/98/76/83d5888796b061519697c96a134e39e403be0da549a23b594814a5107d36/yarl-1.11.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6907daa4b9d7a688063ed098c472f96e8181733c525e03e866fb5db480a424df", size = 465282 }, + { url = "https://files.pythonhosted.org/packages/e4/05/4087620c4a73f4acaccc2a3ec4600760418db4f5e295e380d5b6e83ce684/yarl-1.11.1-cp39-cp39-win32.whl", hash = "sha256:14438dfc5015661f75f85bc5adad0743678eefee266ff0c9a8e32969d5d69f74", size = 102205 }, + { url = "https://files.pythonhosted.org/packages/d8/6a/db7c41f53faa10df3e52bd10be5bab6bf9018f0c90e0e5b06b48fd83e12a/yarl-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:94d0caaa912bfcdc702a4204cd5e2bb01eb917fc4f5ea2315aa23962549561b0", size = 111338 }, + { url = "https://files.pythonhosted.org/packages/5b/b3/841f7d706137bdc8b741c6826106b6f703155076d58f1830f244da857451/yarl-1.11.1-py3-none-any.whl", hash = "sha256:72bf26f66456baa0584eff63e44545c9f0eaed9b73cb6601b647c91f14c11f38", size = 38648 }, ] [[package]] From f07341a5a691dc4c7d51e92ba93914ec4587183a Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sat, 21 Sep 2024 16:37:38 +0200 Subject: [PATCH 569/892] Add reboot() to the device interface (#1124) Both device families have already had a method following this signature, but defining the interface in the base class will make the contract clear. --- kasa/device.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/kasa/device.py b/kasa/device.py index e07c4853c..2e0b3b2b0 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -449,6 +449,14 @@ async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): async def set_alias(self, alias: str): """Set the device name (alias).""" + @abstractmethod + 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. + """ + def __repr__(self): if self._last_update is None: return f"<{self.device_type} at {self.host} - update() needed>" From b7fa0d2040f91d41f04a41e589b0f2fa3e898af7 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sat, 21 Sep 2024 16:52:52 +0200 Subject: [PATCH 570/892] Add factory-reset command to cli (#1108) Allow reseting devices to factory settings using the cli: `kasa device factory-reset`. --- kasa/cli/device.py | 11 +++++++++++ kasa/tests/test_cli.py | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/kasa/cli/device.py b/kasa/cli/device.py index 604380354..400bc4734 100644 --- a/kasa/cli/device.py +++ b/kasa/cli/device.py @@ -166,6 +166,17 @@ async def reboot(plug, delay): return await plug.reboot(delay) +@device.command() +@pass_dev +async def factory_reset(plug): + """Reset device to factory settings.""" + click.confirm( + "Do you really want to reset the device to factory settings?", abort=True + ) + + return await plug.factory_reset() + + @device.command() @pass_dev @click.option( diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index e55f4d016..289dcd232 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -19,6 +19,7 @@ ) from kasa.cli.device import ( alias, + factory_reset, led, reboot, state, @@ -215,6 +216,21 @@ async def test_reboot(dev, mocker, runner): assert res.exit_code == 0 +@device_smart +async def test_factory_reset(dev, mocker, runner): + """Test that factory reset works on SMART devices.""" + query_mock = mocker.patch.object(dev.protocol, "query") + + res = await runner.invoke( + factory_reset, + obj=dev, + input="y\n", + ) + + query_mock.assert_called() + assert res.exit_code == 0 + + @device_smart async def test_wifi_scan(dev, runner): res = await runner.invoke(wifi, ["scan"], obj=dev) From 73b6d160748d5396d43ad3b9a75f98b754db9624 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sat, 21 Sep 2024 17:29:25 +0200 Subject: [PATCH 571/892] Extend KL135 ct range up to 9000K (#1123) --- kasa/iot/iotbulb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 8686790fa..5775b611f 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -85,7 +85,7 @@ class TurnOnBehaviors(BaseModel): "KB130": ColorTempRange(2500, 9000), "KL130": ColorTempRange(2500, 9000), "KL125": ColorTempRange(2500, 6500), - "KL135": ColorTempRange(2500, 6500), + "KL135": ColorTempRange(2500, 9000), r"KL120\(EU\)": ColorTempRange(2700, 6500), r"KL120\(US\)": ColorTempRange(2700, 5000), r"KL430": ColorTempRange(2500, 9000), From 89d611d2cd71f964ff38e024271a28b43f716398 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sat, 21 Sep 2024 20:18:55 +0200 Subject: [PATCH 572/892] Add fixture for KL135(US) fw 1.0.15 (#1122) By courtesy of @jhemak: https://github.com/home-assistant/core/issues/126300#issuecomment-2364640319 --- SUPPORTED.md | 1 + kasa/tests/fixtures/KL135(US)_1.0_1.0.15.json | 93 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 kasa/tests/fixtures/KL135(US)_1.0_1.0.15.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 2c4769af0..f887088e5 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -116,6 +116,7 @@ Some newer Kasa devices require authentication. These are marked with * Date: Sun, 22 Sep 2024 16:29:42 -0400 Subject: [PATCH 573/892] Add KS200M(US) fw 1.0.12 fixture (#1127) --- SUPPORTED.md | 1 + .../tests/fixtures/KS200M(US)_1.0_1.0.12.json | 96 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 kasa/tests/fixtures/KS200M(US)_1.0_1.0.12.json diff --git a/SUPPORTED.md b/SUPPORTED.md index f887088e5..002a9f0fc 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -86,6 +86,7 @@ Some newer Kasa devices require authentication. These are marked with *\* diff --git a/kasa/tests/fixtures/KS200M(US)_1.0_1.0.12.json b/kasa/tests/fixtures/KS200M(US)_1.0_1.0.12.json new file mode 100644 index 000000000..aabbdb06b --- /dev/null +++ b/kasa/tests/fixtures/KS200M(US)_1.0_1.0.12.json @@ -0,0 +1,96 @@ +{ + "smartlife.iot.LAS": { + "get_config": { + "devs": [ + { + "dark_index": 0, + "enable": 1, + "hw_id": 0, + "level_array": [ + { + "adc": 390, + "name": "cloudy", + "value": 15 + }, + { + "adc": 300, + "name": "overcast", + "value": 11 + }, + { + "adc": 222, + "name": "dawn", + "value": 8 + }, + { + "adc": 222, + "name": "twilight", + "value": 8 + }, + { + "adc": 111, + "name": "total darkness", + "value": 4 + }, + { + "adc": 2400, + "name": "custom", + "value": 94 + } + ], + "max_adc": 2550, + "min_adc": 0 + } + ], + "err_code": 0, + "ver": "1.0" + } + }, + "smartlife.iot.PIR": { + "get_config": { + "array": [ + 80, + 50, + 20, + 0 + ], + "cold_time": 600000, + "enable": 1, + "err_code": 0, + "max_adc": 4095, + "min_adc": 0, + "trigger_index": 1, + "version": "1.0" + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Light Switch with PIR", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "98:25:4A:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS200M(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -55, + "status": "new", + "sw_ver": "1.0.12 Build 240507 Rel.143458", + "updating": 0 + } + } +} From 8321fd08aae54a4f7e462ef5e6b3cef7aae0fbd6 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 27 Sep 2024 10:34:30 +0200 Subject: [PATCH 574/892] Mock asyncio.sleep for klapprotocol tests (#1130) Speeds up tests in `test_klapprotocol.py` from 26s to 2s when there's no sleep between the retries. --- kasa/tests/test_klapprotocol.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 24d5df5df..4862235a0 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -67,6 +67,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) + mocker.patch("asyncio.sleep") config = DeviceConfig(host) with pytest.raises(KasaException): @@ -139,6 +140,8 @@ async def test_protocol_retry_recoverable_error( "post", side_effect=aiohttp.ClientOSError("foo"), ) + mocker.patch("asyncio.sleep") + config = DeviceConfig(host) with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( From 1ab08f454f66b35f83bf659319df30698030d53b Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 27 Sep 2024 10:35:17 +0200 Subject: [PATCH 575/892] Add fixture for T110 fw 1.9.0 (#1129) --- SUPPORTED.md | 1 + .../smart/child/T110(EU)_1.0_1.9.0.json | 511 ++++++++++++++++++ 2 files changed, 512 insertions(+) create mode 100644 kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.9.0.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 002a9f0fc..7273735c6 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -239,6 +239,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.12.0 - **T110** - Hardware: 1.0 (EU) / Firmware: 1.8.0 + - Hardware: 1.0 (EU) / Firmware: 1.9.0 - **T300** - Hardware: 1.0 (EU) / Firmware: 1.7.0 - **T310** diff --git a/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.9.0.json b/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.9.0.json new file mode 100644 index 000000000..c11964494 --- /dev/null +++ b/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.9.0.json @@ -0,0 +1,511 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "", + "bind_count": 1, + "category": "subg.trigger.contact-sensor", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.9.0 Build 230704 Rel.154531", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -104, + "jamming_signal_level": 2, + "lastOnboardingTimestamp": 1727367924, + "mac": "74FECE000000", + "model": "T110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "open": false, + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "CEST", + "report_interval": 16, + "rssi": -20, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_temp_humidity_records": { + "local_time": 1727368055, + "past24h_humidity": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_humidity_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "close", + "eventId": "1b85ca6d-3e97-c361-b0d0-683c1683c9e4", + "id": 4, + "timestamp": 1727368053 + }, + { + "event": "keepOpen", + "eventId": "8258b54b-bc4f-3e06-1a14-9b543b0c1f9e", + "id": 3, + "timestamp": 1727367990 + }, + { + "event": "open", + "eventId": "a3620a36-a86f-8cf5-4113-b26a86f8cf54", + "id": 2, + "timestamp": 1727367930 + } + ], + "start_id": 4, + "sum": 3 + } +} From 038b6993ca8f0e9e093a599620716f751bbb835b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 27 Sep 2024 10:27:53 +0100 Subject: [PATCH 576/892] Speed up and simplify github workflows (#1128) - Enable parallel tests in the CI with pytest-xdist - Migrate to the official `astral-sh/setup-uv` github action - Call `pre-commit` run as a single job in CI instead of relisting each check - Use `uv` version 0.4.16 - Fix bug with pre-commit cache - Update `publish.yml` to use `astral-sh/setup-uv` --- .github/actions/setup/action.yaml | 61 ++++++------------------------- .github/workflows/ci.yml | 43 ++++++++-------------- .github/workflows/publish.yml | 16 +++++--- .pre-commit-config.yaml | 2 +- kasa/tests/test_device.py | 2 +- kasa/tests/test_protocol.py | 2 +- pyproject.toml | 4 +- uv.lock | 24 ++++++++++++ 8 files changed, 67 insertions(+), 87 deletions(-) diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml index b7828ce3f..490adaef0 100644 --- a/.github/actions/setup/action.yaml +++ b/.github/actions/setup/action.yaml @@ -1,12 +1,12 @@ --- name: Setup Environment -description: Install requested pipx dependencies, configure the system python, and install uv and the package dependencies +description: Install uv, configure the system python, and the package dependencies inputs: uv-install-options: default: "" uv-version: - default: 0.4.5 + default: 0.4.16 python-version: required: true cache-pre-commit: @@ -17,66 +17,29 @@ inputs: runs: using: composite steps: - - uses: "actions/setup-python@v5" + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: "Setup python" + uses: "actions/setup-python@v5" id: setup-python with: python-version: "${{ inputs.python-version }}" allow-prereleases: true - - name: Setup pipx environment Variables - id: pipx-env-setup - # pipx default home and bin dir are not writable by the cache action - # so override them here and add the bin dir to PATH for later steps. - # This also ensures the pipx cache only contains uv - run: | - SEP="${{ !startsWith(runner.os, 'windows') && '/' || '\\' }}" - PIPX_CACHE="${{ github.workspace }}${SEP}pipx_cache" - echo "pipx-cache-path=${PIPX_CACHE}" >> $GITHUB_OUTPUT - echo "pipx-version=$(pipx --version)" >> $GITHUB_OUTPUT - echo "PIPX_HOME=${PIPX_CACHE}${SEP}home" >> $GITHUB_ENV - echo "PIPX_BIN_DIR=${PIPX_CACHE}${SEP}bin" >> $GITHUB_ENV - echo "PIPX_MAN_DIR=${PIPX_CACHE}${SEP}man" >> $GITHUB_ENV - echo "${PIPX_CACHE}${SEP}bin" >> $GITHUB_PATH - shell: bash - - - name: Pipx cache - id: pipx-cache - uses: actions/cache@v4 - with: - path: ${{ steps.pipx-env-setup.outputs.pipx-cache-path }} - key: cache-${{ inputs.cache-version }}-${{ runner.os }}-${{ runner.arch }}-python-${{ inputs.python-version }}-${{ steps.setup-python.outputs.python-version }}-pipx-${{ steps.pipx-env-setup.outputs.pipx-version }}-uv-${{ inputs.uv-version }} - - - name: Install uv - if: steps.pipx-cache.outputs.cache-hit != 'true' - id: install-uv - shell: bash - run: |- - pipx install uv==${{ inputs.uv-version }} --python "${{ steps.setup-python.outputs.python-path }}" - - - name: Read uv cache location - id: uv-cache-location - shell: bash - run: |- - echo "uv-cache-location=$(uv cache dir)" >> $GITHUB_OUTPUT - - - uses: actions/cache@v4 - name: uv cache - with: - path: | - ${{ steps.uv-cache-location.outputs.uv-cache-location }} - key: cache-${{ inputs.cache-version }}-${{ runner.os }}-${{ runner.arch }}-python-${{ steps.setup-python.outputs.python-version }}-uv-${{ inputs.uv-version }}-${{ hashFiles('uv.lock') }}-options-${{ inputs.uv-install-options }} - - - name: "uv install" + - name: "Install project" shell: bash run: | - uv sync --python "${{ steps.setup-python.outputs.python-path }}" ${{ inputs.uv-install-options }} + uv sync ${{ inputs.uv-install-options }} - name: Read pre-commit version if: inputs.cache-pre-commit == 'true' id: pre-commit-version shell: bash run: >- - echo "pre-commit-version=$(uv run pre-commit -- -V | awk '{print $2}')" >> $GITHUB_OUTPUT + echo "pre-commit-version=$(uv run pre-commit -V | awk '{print $2}')" >> $GITHUB_OUTPUT - uses: actions/cache@v4 if: inputs.cache-pre-commit == 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84c4905b0..d3e81ec1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: # to allow manual re-runs env: - UV_VERSION: 0.4.5 + UV_VERSION: 0.4.16 jobs: linting: @@ -20,7 +20,8 @@ jobs: python-version: ["3.12"] steps: - - uses: "actions/checkout@v4" + - name: "Checkout source files" + uses: "actions/checkout@v4" - name: Setup environment uses: ./.github/actions/setup with: @@ -28,31 +29,10 @@ jobs: cache-pre-commit: true uv-version: ${{ env.UV_VERSION }} uv-install-options: "--all-extras" - - name: "Check supported device md files are up to date" - run: | - uv run pre-commit run generate-supported --all-files - - name: "Linting and code formating (ruff)" - run: | - uv run pre-commit run ruff --all-files - - name: "Typing checks (mypy)" - run: | - source .venv/bin/activate - pre-commit run mypy --all-files - - name: "Run trailing-whitespace" - run: | - uv run pre-commit run trailing-whitespace --all-files - - name: "Run end-of-file-fixer" - run: | - uv run pre-commit run end-of-file-fixer --all-files - - name: "Run check-docstring-first" - run: | - uv run pre-commit run check-docstring-first --all-files - - name: "Run debug-statements" - run: | - uv run pre-commit run debug-statements --all-files - - name: "Run check-ast" + + - name: "Run pre-commit checks" run: | - uv run pre-commit run check-ast --all-files + uv run pre-commit run --all-files --verbose tests: @@ -83,6 +63,13 @@ jobs: - os: ubuntu-latest python-version: "3.10" extras: true + # Exclude pypy on windows due to significant performance issues + # running pytest requires ~12 min instead of 2 min on other platforms + - os: windows-latest + python-version: "pypy-3.9" + - os: windows-latest + python-version: "pypy-3.10" + steps: - uses: "actions/checkout@v4" @@ -95,11 +82,11 @@ jobs: - name: "Run tests (no coverage)" if: ${{ startsWith(matrix.python-version, 'pypy') }} run: | - uv run pytest + uv run pytest -n auto - name: "Run tests (with coverage)" if: ${{ !startsWith(matrix.python-version, 'pypy') }} run: | - uv run pytest --cov kasa --cov-report xml + uv run pytest -n auto --cov kasa --cov-report xml - name: "Upload coverage to Codecov" if: ${{ !startsWith(matrix.python-version, 'pypy') }} uses: "codecov/codecov-action@v4" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 71b5ef0f9..9c1837f0c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,7 +4,8 @@ on: types: [published] env: - UV_VERSION: 0.4.5 + UV_VERSION: 0.4.16 + PYTHON_VERSION: 3.12 jobs: build-n-publish: @@ -14,16 +15,19 @@ jobs: id-token: write steps: - - uses: actions/checkout@master + - name: Checkout source files + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + - name: Setup python uses: actions/setup-python@v4 with: - python-version: "3.x" + python-version: ${{ env.PYTHON_VERSION }} - - name: Install uv - run: |- - pipx install uv==${{ env.UV_VERSION }} --python "${{ steps.setup-python.outputs.python-path }}" - name: Build a binary wheel and a source tarball run: uv build + - name: Publish release on pypi uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b6ab6c28f..4ac50dfa8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.4.5 + rev: 0.4.16 hooks: # Update the uv lockfile - id: uv-lock diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index d5d988d78..0aee5b56d 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -32,7 +32,7 @@ def _get_subclasses(of_class): and module.__package__ != "kasa.interfaces" ): subclasses.add((module.__package__ + "." + name, obj)) - return subclasses + return sorted(subclasses) device_classes = pytest.mark.parametrize( diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index f2f73ee5f..afb953dd7 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -481,7 +481,7 @@ def _get_subclasses(of_class): and name != "_deprecated_TPLinkSmartHomeProtocol" ): subclasses.add((name, obj)) - return subclasses + return sorted(subclasses) @pytest.mark.parametrize( diff --git a/pyproject.toml b/pyproject.toml index 9f986af08..bb1015e4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,8 @@ dev-dependencies = [ "coverage[toml]", "pytest-timeout~=2.0", "pytest-freezer~=0.4", - "mypy~=1.0" + "mypy~=1.0", + "pytest-xdist>=3.6.1", ] @@ -105,6 +106,7 @@ markers = [ "requires_dummy: test requires dummy data to pass, skipped on real devices", ] asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" timeout = 10 [tool.doc8] diff --git a/uv.lock b/uv.lock index 9beac2fa5..5351999cd 100644 --- a/uv.lock +++ b/uv.lock @@ -516,6 +516,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, ] +[[package]] +name = "execnet" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612 }, +] + [[package]] name = "filelock" version = "3.16.0" @@ -1294,6 +1303,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/27/14af9ef8321f5edc7527e47def2a21d8118c6f329a9342cc61387a0c0599/pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e", size = 14148 }, ] +[[package]] +name = "pytest-xdist" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/c4/3c310a19bc1f1e9ef50075582652673ef2bfc8cd62afef9585683821902f/pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d", size = 84060 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7", size = 46108 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1349,6 +1371,7 @@ dev = [ { name = "pytest-mock" }, { name = "pytest-sugar" }, { name = "pytest-timeout" }, + { name = "pytest-xdist" }, { name = "toml" }, { name = "voluptuous" }, { name = "xdoctest" }, @@ -1386,6 +1409,7 @@ dev = [ { name = "pytest-mock" }, { name = "pytest-sugar" }, { name = "pytest-timeout", specifier = "~=2.0" }, + { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "toml" }, { name = "voluptuous" }, { name = "xdoctest", specifier = ">=1.2.0" }, From db686e191a5ea882fa148999c0cb9f9318c06abe Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 27 Sep 2024 10:57:23 +0100 Subject: [PATCH 577/892] Add autouse fixture to patch asyncio.sleep (#1131) --- kasa/tests/conftest.py | 13 +++++++++++++ kasa/tests/test_klapprotocol.py | 3 --- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index e709a58f5..9cefd7ab7 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import warnings from unittest.mock import MagicMock, patch @@ -96,6 +97,18 @@ def pytest_collection_modifyitems(config, items): item.add_marker(requires_dummy) +@pytest.fixture(autouse=True, scope="session") +def asyncio_sleep_fixture(): # noqa: PT004 + """Patch sleep to prevent tests actually waiting.""" + orig_asyncio_sleep = asyncio.sleep + + async def _asyncio_sleep(*_, **__): + await orig_asyncio_sleep(0) + + with patch("asyncio.sleep", side_effect=_asyncio_sleep): + yield + + # allow mocks to be awaited # https://stackoverflow.com/questions/51394411/python-object-magicmock-cant-be-used-in-await-expression/51399767#51399767 diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 4862235a0..ce370b5b6 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -66,8 +66,6 @@ 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) - mocker.patch("asyncio.sleep") config = DeviceConfig(host) with pytest.raises(KasaException): @@ -140,7 +138,6 @@ async def test_protocol_retry_recoverable_error( "post", side_effect=aiohttp.ClientOSError("foo"), ) - mocker.patch("asyncio.sleep") config = DeviceConfig(host) with pytest.raises(KasaException): From 936e45cad7bc8ec3617ba41d3c1c6e6a2b3ab5ee Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 27 Sep 2024 16:36:41 +0100 Subject: [PATCH 578/892] Enable ruff lint pycodestyle warnings (#1132) Addresses repeated SyntaxWarnings when running linters: ``` kasa/tests/test_bulb.py:254: SyntaxWarning: invalid escape sequence '\d' ValueError, match="Temperature should be between \d+ and \d+, was 1000" kasa/tests/test_bulb.py:258: SyntaxWarning: invalid escape sequence '\d' ValueError, match="Temperature should be between \d+ and \d+, was 10000" kasa/tests/test_common_modules.py:216: SyntaxWarning: invalid escape sequence '\d' with pytest.raises(ValueError, match="Temperature should be between \d+ and \d+"): kasa/tests/test_common_modules.py:219: SyntaxWarning: invalid escape sequence '\d' with pytest.raises(ValueError, match="Temperature should be between \d+ and \d+"): ``` --- kasa/tests/test_bulb.py | 4 ++-- kasa/tests/test_common_modules.py | 4 ++-- pyproject.toml | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 2b563df89..9e6dd7c2f 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -251,11 +251,11 @@ async def test_out_of_range_temperature(dev: Device): light = dev.modules.get(Module.Light) assert light with pytest.raises( - ValueError, match="Temperature should be between \d+ and \d+, was 1000" + ValueError, match=r"Temperature should be between \d+ and \d+, was 1000" ): await light.set_color_temp(1000) with pytest.raises( - ValueError, match="Temperature should be between \d+ and \d+, was 10000" + ValueError, match=r"Temperature should be between \d+ and \d+, was 10000" ): await light.set_color_temp(10000) diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index c254aa8a0..6cefa99d2 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -213,10 +213,10 @@ async def test_light_color_temp(dev: Device): assert light.color_temp == feature.minimum_value + 20 assert light.brightness == 60 - with pytest.raises(ValueError, match="Temperature should be between \d+ and \d+"): + with pytest.raises(ValueError, match=r"Temperature should be between \d+ and \d+"): await light.set_color_temp(feature.minimum_value - 10) - with pytest.raises(ValueError, match="Temperature should be between \d+ and \d+"): + with pytest.raises(ValueError, match=r"Temperature should be between \d+ and \d+"): await light.set_color_temp(feature.maximum_value + 10) diff --git a/pyproject.toml b/pyproject.toml index bb1015e4f..4d217a876 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,7 +120,8 @@ target-version = "py38" [tool.ruff.lint] select = [ - "E", # pycodestyle + "E", # pycodestyle errors + "W", # pycodestyle warnings "D", # pydocstyle "F", # pyflakes "UP", # pyupgrade From b4aba36b73cd9610e6c9a1c8da0dc5a491fe0524 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 27 Sep 2024 17:20:25 +0100 Subject: [PATCH 579/892] Use pytest-socket to ensure no tests are performing io (#1133) --- kasa/tests/conftest.py | 37 ++++++++++++++++++++++++++++++++++-- kasa/tests/test_discovery.py | 14 ++++++++------ pyproject.toml | 2 ++ uv.lock | 14 ++++++++++++++ 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 9cefd7ab7..8c6e76345 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import sys import warnings from unittest.mock import MagicMock, patch @@ -87,6 +88,11 @@ def pytest_addoption(parser): def pytest_collection_modifyitems(config, items): if not config.getoption("--ip"): print("Testing against fixtures.") + # pytest_socket doesn't work properly in windows with asyncio + # fine to disable as other platforms will pickup any issues. + if sys.platform == "win32": + for item in items: + item.add_marker(pytest.mark.enable_socket) else: print("Running against ip %s" % config.getoption("--ip")) requires_dummy = pytest.mark.skip( @@ -95,18 +101,45 @@ def pytest_collection_modifyitems(config, items): for item in items: if "requires_dummy" in item.keywords: item.add_marker(requires_dummy) + else: + item.add_marker(pytest.mark.enable_socket) @pytest.fixture(autouse=True, scope="session") -def asyncio_sleep_fixture(): # noqa: PT004 +def asyncio_sleep_fixture(request): # noqa: PT004 """Patch sleep to prevent tests actually waiting.""" orig_asyncio_sleep = asyncio.sleep async def _asyncio_sleep(*_, **__): await orig_asyncio_sleep(0) - with patch("asyncio.sleep", side_effect=_asyncio_sleep): + if request.config.getoption("--ip"): yield + else: + with patch("asyncio.sleep", side_effect=_asyncio_sleep): + yield + + +@pytest.fixture(autouse=True, scope="session") +def mock_datagram_endpoint(request): # noqa: PT004 + """Mock create_datagram_endpoint so it doesn't perform io.""" + + async def _create_datagram_endpoint(protocol_factory, *_, **__): + protocol = protocol_factory() + transport = MagicMock() + try: + return transport, protocol + finally: + protocol.connection_made(transport) + + if request.config.getoption("--ip"): + yield + else: + with patch( + "asyncio.BaseEventLoop.create_datagram_endpoint", + side_effect=_create_datagram_endpoint, + ): + yield # allow mocks to be awaited diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 3c388e6ac..15d4af9c5 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -170,12 +170,13 @@ async def test_discover_single_hostname(discovery_mock, mocker): async def test_discover_credentials(mocker): """Make sure that discover gives credentials precedence over un and pw.""" host = "127.0.0.1" - mocker.patch("kasa.discover._DiscoverProtocol.wait_for_discovery_to_complete") - def mock_discover(self, *_, **__): + async def mock_discover(self, *_, **__): self.discovered_devices = {host: MagicMock()} + self.seen_hosts.add(host) + self._handle_discovered_event() - mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) + mocker.patch.object(_DiscoverProtocol, "do_discover", new=mock_discover) dp = mocker.spy(_DiscoverProtocol, "__init__") # Only credentials passed @@ -197,12 +198,13 @@ def mock_discover(self, *_, **__): async def test_discover_single_credentials(mocker): """Make sure that discover_single gives credentials precedence over un and pw.""" host = "127.0.0.1" - mocker.patch("kasa.discover._DiscoverProtocol.wait_for_discovery_to_complete") - def mock_discover(self, *_, **__): + async def mock_discover(self, *_, **__): self.discovered_devices = {host: MagicMock()} + self.seen_hosts.add(host) + self._handle_discovered_event() - mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) + mocker.patch.object(_DiscoverProtocol, "do_discover", new=mock_discover) dp = mocker.spy(_DiscoverProtocol, "__init__") # Only credentials passed diff --git a/pyproject.toml b/pyproject.toml index 4d217a876..51f05b5c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ dev-dependencies = [ "pytest-freezer~=0.4", "mypy~=1.0", "pytest-xdist>=3.6.1", + "pytest-socket>=0.7.0", ] @@ -108,6 +109,7 @@ markers = [ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" timeout = 10 +addopts = "--disable-socket --allow-unix-socket" [tool.doc8] paths = ["docs"] diff --git a/uv.lock b/uv.lock index 5351999cd..c9d4df2a0 100644 --- a/uv.lock +++ b/uv.lock @@ -1277,6 +1277,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, ] +[[package]] +name = "pytest-socket" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/ff/90c7e1e746baf3d62ce864c479fd53410b534818b9437413903596f81580/pytest_socket-0.7.0.tar.gz", hash = "sha256:71ab048cbbcb085c15a4423b73b619a8b35d6a307f46f78ea46be51b1b7e11b3", size = 12389 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/58/5d14cb5cb59409e491ebe816c47bf81423cd03098ea92281336320ae5681/pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45", size = 6754 }, +] + [[package]] name = "pytest-sugar" version = "1.0.0" @@ -1369,6 +1381,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-freezer" }, { name = "pytest-mock" }, + { name = "pytest-socket" }, { name = "pytest-sugar" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, @@ -1407,6 +1420,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-freezer", specifier = "~=0.4" }, { name = "pytest-mock" }, + { name = "pytest-socket", specifier = ">=0.7.0" }, { name = "pytest-sugar" }, { name = "pytest-timeout", specifier = "~=2.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, From 5d78f000c338a672b8114f27f39fb884d1649392 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 27 Sep 2024 17:34:27 +0100 Subject: [PATCH 580/892] Add stale PR/Issue github workflow (#1126) --- .github/workflows/stale.yml | 69 +++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..294b4e1f8 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,69 @@ +name: Stale + +# yamllint disable-line rule:truthy +on: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + stale: + if: github.repository_owner == 'python-kasa' + runs-on: ubuntu-latest + steps: + - name: Stale issues and prs + uses: actions/stale@v9.0.0 + with: + repo-token: ${{ github.token }} + days-before-stale: 90 + days-before-close: 7 + operations-per-run: 250 + remove-stale-when-updated: true + stale-issue-label: "stale" + exempt-issue-labels: "no-stale,help-wanted,needs-more-information,waiting-for-reporter" + stale-pr-label: "stale" + exempt-pr-labels: "no-stale" + stale-pr-message: > + There hasn't been any activity on this pull request recently. This + pull request has been automatically marked as stale because of that + and will be closed if no further activity occurs within 7 days. + + If you are the author of this PR, please leave a comment if you want + to keep it open. Also, please rebase your PR onto the latest dev + branch to ensure that it's up to date with the latest changes. + + Thank you for your contribution! + stale-issue-message: > + There hasn't been any activity on this issue recently. This issue has + been automatically marked as stale because of that. It will be closed + if no further activity occurs. + + Please make sure to update to the latest python-kasa version and + check if that solves the issue. + + Thank you for your contributions. + + + - name: Needs-more-information and waiting-for-reporter stale issues policy + uses: actions/stale@v9.0.0 + with: + repo-token: ${{ github.token }} + only-labels: "needs-more-information,waiting-for-reporter" + days-before-stale: 21 + days-before-close: 7 + days-before-pr-stale: -1 + days-before-pr-close: -1 + operations-per-run: 250 + remove-stale-when-updated: true + stale-issue-label: "stale" + exempt-issue-labels: "no-stale,help-wanted" + stale-issue-message: > + There hasn't been any activity on this issue recently and it has + been waiting for the reporter to provide information or an update. + This issue has been automatically marked as stale because of that. + It will be closed if no further activity occurs. + + Please make sure to update to the latest python-kasa version and + check if that solves the issue. + + Thank you for your contributions. From d1b43f5408e9d817e63694cf08570967604e3717 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 27 Sep 2024 17:36:45 +0100 Subject: [PATCH 581/892] Fix cli command for device off (#1121) Was previously missed when using the full `kasa device off` command as opposed to the shortcut. --- kasa/cli/device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/cli/device.py b/kasa/cli/device.py index 400bc4734..f513a5e23 100644 --- a/kasa/cli/device.py +++ b/kasa/cli/device.py @@ -99,7 +99,7 @@ async def on(dev: Device, transition: int): return await dev.turn_on(transition=transition) -@click.command +@device.command @click.option("--transition", type=int, required=False) @pass_dev_or_child async def off(dev: Device, transition: int): From 1ce5af24948f51150b43c65d85afbce0dae11077 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 27 Sep 2024 18:42:22 +0200 Subject: [PATCH 582/892] Add factory_reset() to iotdevice (#1125) Also extend the base device class API to make factory_reset() part of the common API. --- kasa/device.py | 7 +++++++ kasa/iot/iotdevice.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/kasa/device.py b/kasa/device.py index 2e0b3b2b0..05a4f7675 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -457,6 +457,13 @@ async def reboot(self, delay: int = 1) -> None: as the device reboots immediately without responding to the call. """ + @abstractmethod + async def factory_reset(self) -> None: + """Reset device back to factory settings. + + Note, this does not downgrade the firmware. + """ + def __repr__(self): if self._last_update is None: return f"<{self.device_type} at {self.host} - update() needed>" diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 2dbc5e77f..3986c001d 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -561,6 +561,13 @@ async def reboot(self, delay: int = 1) -> None: """ await self._query_helper("system", "reboot", {"delay": delay}) + async def factory_reset(self) -> None: + """Reset device back to factory settings. + + Note, this does not downgrade the firmware. + """ + await self._query_helper("system", "reset") + async def turn_off(self, **kwargs) -> dict: """Turn off the device.""" raise NotImplementedError("Device subclass needs to implement this.") From 2922c3f57440f9a4bb20fcdd9c7b0860e261009c Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 27 Sep 2024 18:28:58 +0100 Subject: [PATCH 583/892] Prepare 0.7.4 (#1135) ## [0.7.4](https://github.com/python-kasa/python-kasa/tree/0.7.4) (2024-09-27) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.3...0.7.4) **Release summary:** - KL135 color temp range corrected to 9000k max - Minor enhancements and project maintenance **Implemented enhancements:** - Add factory\_reset\(\) to iotdevice [\#1125](https://github.com/python-kasa/python-kasa/pull/1125) (@rytilahti) - Add reboot\(\) to the device interface [\#1124](https://github.com/python-kasa/python-kasa/pull/1124) (@rytilahti) - Add factory-reset command to cli [\#1108](https://github.com/python-kasa/python-kasa/pull/1108) (@rytilahti) **Fixed bugs:** - Extend KL135 ct range up to 9000K [\#1123](https://github.com/python-kasa/python-kasa/pull/1123) (@rytilahti) - Fix cli command for device off [\#1121](https://github.com/python-kasa/python-kasa/pull/1121) (@sdb9696) **Project maintenance:** - Use pytest-socket to ensure no tests are performing io [\#1133](https://github.com/python-kasa/python-kasa/pull/1133) (@sdb9696) - Enable ruff lint pycodestyle warnings [\#1132](https://github.com/python-kasa/python-kasa/pull/1132) (@sdb9696) - Add autouse fixture to patch asyncio.sleep [\#1131](https://github.com/python-kasa/python-kasa/pull/1131) (@sdb9696) - Mock asyncio.sleep for klapprotocol tests [\#1130](https://github.com/python-kasa/python-kasa/pull/1130) (@rytilahti) - Add fixture for T110 fw 1.9.0 [\#1129](https://github.com/python-kasa/python-kasa/pull/1129) (@rytilahti) - Speed up and simplify github workflows [\#1128](https://github.com/python-kasa/python-kasa/pull/1128) (@sdb9696) - Add KS200M\(US\) fw 1.0.12 fixture [\#1127](https://github.com/python-kasa/python-kasa/pull/1127) (@GatorEG) - Add stale PR/Issue github workflow [\#1126](https://github.com/python-kasa/python-kasa/pull/1126) (@sdb9696) - Add fixture for KL135\(US\) fw 1.0.15 [\#1122](https://github.com/python-kasa/python-kasa/pull/1122) (@rytilahti) --- CHANGELOG.md | 182 ++++++++++------- RELEASING.md | 2 +- pyproject.toml | 2 +- uv.lock | 546 ++++++++++++++++++++++++------------------------- 4 files changed, 382 insertions(+), 350 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5ba8f6a0..d4fe59d41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,44 @@ # Changelog +## [0.7.4](https://github.com/python-kasa/python-kasa/tree/0.7.4) (2024-09-27) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.3...0.7.4) + +**Release summary:** + +- KL135 color temp range corrected to 9000k max +- Minor enhancements and project maintenance + +**Implemented enhancements:** + +- Add factory\_reset\(\) to iotdevice [\#1125](https://github.com/python-kasa/python-kasa/pull/1125) (@rytilahti) +- Add reboot\(\) to the device interface [\#1124](https://github.com/python-kasa/python-kasa/pull/1124) (@rytilahti) +- Add factory-reset command to cli [\#1108](https://github.com/python-kasa/python-kasa/pull/1108) (@rytilahti) + +**Fixed bugs:** + +- Extend KL135 ct range up to 9000K [\#1123](https://github.com/python-kasa/python-kasa/pull/1123) (@rytilahti) +- Fix cli command for device off [\#1121](https://github.com/python-kasa/python-kasa/pull/1121) (@sdb9696) + +**Project maintenance:** + +- Use pytest-socket to ensure no tests are performing io [\#1133](https://github.com/python-kasa/python-kasa/pull/1133) (@sdb9696) +- Enable ruff lint pycodestyle warnings [\#1132](https://github.com/python-kasa/python-kasa/pull/1132) (@sdb9696) +- Add autouse fixture to patch asyncio.sleep [\#1131](https://github.com/python-kasa/python-kasa/pull/1131) (@sdb9696) +- Mock asyncio.sleep for klapprotocol tests [\#1130](https://github.com/python-kasa/python-kasa/pull/1130) (@rytilahti) +- Add fixture for T110 fw 1.9.0 [\#1129](https://github.com/python-kasa/python-kasa/pull/1129) (@rytilahti) +- Speed up and simplify github workflows [\#1128](https://github.com/python-kasa/python-kasa/pull/1128) (@sdb9696) +- Add KS200M\(US\) fw 1.0.12 fixture [\#1127](https://github.com/python-kasa/python-kasa/pull/1127) (@GatorEG) +- Add stale PR/Issue github workflow [\#1126](https://github.com/python-kasa/python-kasa/pull/1126) (@sdb9696) +- Add fixture for KL135\(US\) fw 1.0.15 [\#1122](https://github.com/python-kasa/python-kasa/pull/1122) (@rytilahti) + ## [0.7.3](https://github.com/python-kasa/python-kasa/tree/0.7.3) (2024-09-10) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.2...0.7.3) -**Release summary:** - -- Migrate from `poetry` to `uv` for package/project management. +**Release summary:** + +- Migrate from `poetry` to `uv` for package/project management. - Various minor code improvements **Project maintenance:** @@ -21,9 +53,9 @@ [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.1...0.7.2) -**Release summary:** - -- **Breaking** change to disable including the check for the latest firmware for tapo devices and newer kasa devices in the standard update cycle. To check for the latest firmware call `check_latest_firmware` on the firmware module or run the `check_latest_firmware` feature. +**Release summary:** + +- **Breaking** change to disable including the check for the latest firmware for tapo devices and newer kasa devices in the standard update cycle. To check for the latest firmware call `check_latest_firmware` on the firmware module or run the `check_latest_firmware` feature. - Minor bugfixes and improvements. **Breaking changes:** @@ -54,9 +86,9 @@ [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.5...0.7.1) -**Release highlights:** -- This release consists mainly of bugfixes and project improvements. -- There is also new support for Tapo T100 motion sensors. +**Release highlights:** +- This release consists mainly of bugfixes and project improvements. +- There is also new support for Tapo T100 motion sensors. - The CLI now supports child devices on all applicable commands. **Implemented enhancements:** @@ -122,7 +154,7 @@ A critical bugfix for an issue with some L530 Series devices and a redactor for [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.3...0.7.0.4) -Critical bugfixes for issues with P100s and thermostats +Critical bugfixes for issues with P100s and thermostats **Fixed bugs:** @@ -136,7 +168,7 @@ Critical bugfixes for issues with P100s and thermostats [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.2...0.7.0.3) -Critical bugfix for issue #1033 with ks225 and S505D light preset module errors. +Critical bugfix for issue #1033 with ks225 and S505D light preset module errors. Partially fixes light preset module errors with L920 and L930. **Fixed bugs:** @@ -191,25 +223,25 @@ This patch release fixes some minor issues found out during testing against all [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2.1...0.7.0) -We have been working hard behind the scenes to make this major release possible. -This release brings a major refactoring of the library to serve the ever-growing list of supported devices and paves the way for the future, yet unsupported devices. -The library now exposes device features through generic module and feature interfaces, that allows easy extension for future improvements. - -With almost 180 merged pull requests, over 200 changed files and since the last release, this release includes lots of goodies for everyone: -* Support for multi-functional devices like the dimmable fan KS240. -* Initial support for hubs and hub-connected devices like thermostats and sensors. -* Both IOT (legacy kasa) and SMART (tapo and newer kasa) devices now expose features and share common API. -* Modules to allow controlling new devices and functions such as light presets, fan controls, thermostats, humidity sensors, firmware updates and alarms. -* The common APIs allow dynamic introspection of available device features, making it easy to create dynamic interfaces. -* Improved documentation. - -Hope you enjoy the release, feel free to leave a comment and feedback! - -If you have a device that works, but is not listed in our supported devices list, feel free to [contribute fixture files](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) to help us to make the library even better! - -> git diff 0.6.2.1..HEAD|diffstat -> 214 files changed, 26960 insertions(+), 6310 deletions(-) - +We have been working hard behind the scenes to make this major release possible. +This release brings a major refactoring of the library to serve the ever-growing list of supported devices and paves the way for the future, yet unsupported devices. +The library now exposes device features through generic module and feature interfaces, that allows easy extension for future improvements. + +With almost 180 merged pull requests, over 200 changed files and since the last release, this release includes lots of goodies for everyone: +* Support for multi-functional devices like the dimmable fan KS240. +* Initial support for hubs and hub-connected devices like thermostats and sensors. +* Both IOT (legacy kasa) and SMART (tapo and newer kasa) devices now expose features and share common API. +* Modules to allow controlling new devices and functions such as light presets, fan controls, thermostats, humidity sensors, firmware updates and alarms. +* The common APIs allow dynamic introspection of available device features, making it easy to create dynamic interfaces. +* Improved documentation. + +Hope you enjoy the release, feel free to leave a comment and feedback! + +If you have a device that works, but is not listed in our supported devices list, feel free to [contribute fixture files](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) to help us to make the library even better! + +> git diff 0.6.2.1..HEAD|diffstat +> 214 files changed, 26960 insertions(+), 6310 deletions(-) + For more information on the changes please checkout our [documentation on the API changes](https://python-kasa.readthedocs.io/en/latest/deprecated.html) **Breaking changes:** @@ -441,8 +473,8 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.1...0.6.2) -Release highlights: -* Support for tapo power strips (P300) +Release highlights: +* Support for tapo power strips (P300) * Performance improvements and bug fixes **Implemented enhancements:** @@ -481,9 +513,9 @@ Release highlights: [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 +Release highlights: +* Support for tapo wall switches +* Support for unprovisioned devices * Performance and stability improvements **Implemented enhancements:** @@ -556,17 +588,17 @@ A patch release to improve the protocol handling. [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.4...0.6.0) -This major brings major changes to the library by adding support for devices that require authentication for communications, all of this being possible thanks to the great work by @sdb9696! - -This release adds support to a large range of previously unsupported devices, including: - -* Newer kasa-branded devices, including Matter-enabled devices like KP125M -* Newer hardware/firmware versions on some models, like EP25, that suddenly changed the used protocol -* Tapo-branded devices like plugs (P110), light bulbs (KL530), LED strips (L900, L920), and wall switches (KS205, KS225) -* UK variant of HS110, which was the first device using the new protocol - -If your device that is not currently listed as supported is working, please consider contributing a test fixture file. - +This major brings major changes to the library by adding support for devices that require authentication for communications, all of this being possible thanks to the great work by @sdb9696! + +This release adds support to a large range of previously unsupported devices, including: + +* Newer kasa-branded devices, including Matter-enabled devices like KP125M +* Newer hardware/firmware versions on some models, like EP25, that suddenly changed the used protocol +* Tapo-branded devices like plugs (P110), light bulbs (KL530), LED strips (L900, L920), and wall switches (KS205, KS225) +* UK variant of HS110, which was the first device using the new protocol + +If your device that is not currently listed as supported is working, please consider contributing a test fixture file. + Special thanks goes to @SimonWilkinson who created the initial PR for the new communication protocol! **Breaking changes:** @@ -661,13 +693,13 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.3...0.5.4) -The highlights of this maintenance release: - -* Support to the alternative discovery protocol and foundational work to support other communication protocols, thanks to @sdb9696. -* Reliability improvements by avoiding overflowing device buffers, thanks to @cobryan05. -* Optimizations for downstream device accesses, thanks to @bdraco. -* Support for both pydantic v1 and v2. - +The highlights of this maintenance release: + +* Support to the alternative discovery protocol and foundational work to support other communication protocols, thanks to @sdb9696. +* Reliability improvements by avoiding overflowing device buffers, thanks to @cobryan05. +* Optimizations for downstream device accesses, thanks to @bdraco. +* Support for both pydantic v1 and v2. + As always, see the full changelog for details. **Implemented enhancements:** @@ -727,8 +759,8 @@ This release adds support for defining the device port and introduces dependency [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.1...0.5.2) -Besides some small improvements, this release: -* Adds optional dependency for for `orjson` and `kasa-crypt` to speed-up protocol handling by an order of magnitude. +Besides some small improvements, this release: +* Adds optional dependency for for `orjson` and `kasa-crypt` to speed-up protocol handling by an order of magnitude. * Drops Python 3.7 support as it is no longer maintained. **Breaking changes:** @@ -763,11 +795,11 @@ Besides some small improvements, this release: [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.0...0.5.1) -This minor release contains mostly small UX fine-tuning and documentation improvements alongside with bug fixes: -* Improved console tool (JSON output, colorized output if rich is installed) -* Pretty, colorized console output, if `rich` is installed -* Support for configuring bulb presets -* Usage data is now reported in the expected format +This minor release contains mostly small UX fine-tuning and documentation improvements alongside with bug fixes: +* Improved console tool (JSON output, colorized output if rich is installed) +* Pretty, colorized console output, if `rich` is installed +* Support for configuring bulb presets +* Usage data is now reported in the expected format * Dependency pinning is relaxed to give downstreams more control **Breaking changes:** @@ -831,21 +863,21 @@ This minor release contains mostly small UX fine-tuning and documentation improv [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.3...0.5.0) -This is the first release of 0.5 series which includes converting the code base towards more modular approach where device-exposed modules (e.g., emeter, antitheft, or schedule) are implemented in their separate python modules to decouple them from the device-specific classes. - -There should be no API breaking changes, but some previous issues hint that there may be as information from all supported modules are now requested during each update cycle (depending on the device type): -* Basic system info -* Emeter -* Time - properties (like `on_since`) use now time from the device for calculation to avoid jitter caused by different time between the host and the device -* Usage statistics - similar interface to emeter, but reports on-time statistics instead of energy consumption (new) -* Countdown (new) -* Antitheft (new) -* Schedule (new) -* Motion - for configuring motion settings on some dimmers (new) -* Ambientlight - for configuring brightness limits when motion sensor actuates on some dimmers (new) -* Cloud - information about cloud connectivity (new) - -For developers, the new functionalities are currently only exposed through the implementation modules accessible through `modules` property. +This is the first release of 0.5 series which includes converting the code base towards more modular approach where device-exposed modules (e.g., emeter, antitheft, or schedule) are implemented in their separate python modules to decouple them from the device-specific classes. + +There should be no API breaking changes, but some previous issues hint that there may be as information from all supported modules are now requested during each update cycle (depending on the device type): +* Basic system info +* Emeter +* Time - properties (like `on_since`) use now time from the device for calculation to avoid jitter caused by different time between the host and the device +* Usage statistics - similar interface to emeter, but reports on-time statistics instead of energy consumption (new) +* Countdown (new) +* Antitheft (new) +* Schedule (new) +* Motion - for configuring motion settings on some dimmers (new) +* Ambientlight - for configuring brightness limits when motion sensor actuates on some dimmers (new) +* Cloud - information about cloud connectivity (new) + +For developers, the new functionalities are currently only exposed through the implementation modules accessible through `modules` property. Pull requests improving the functionality of modules as well as adding better interfaces to device classes are welcome! **Breaking changes:** diff --git a/RELEASING.md b/RELEASING.md index 315ea5cf9..62305c755 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -47,7 +47,7 @@ uv lock --upgrade ```bash uv run pre-commit run --all-files -uv run pytest +uv run pytest -n auto ``` ### Create release summary (skip for dev releases) diff --git a/pyproject.toml b/pyproject.toml index 51f05b5c4..dfe7de5bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-kasa" -version = "0.7.3" +version = "0.7.4" description = "Python API for TP-Link Kasa and Tapo devices" license = {text = "GPL-3.0-or-later"} authors = [ { name = "python-kasa developers" }] diff --git a/uv.lock b/uv.lock index c9d4df2a0..509b6536f 100644 --- a/uv.lock +++ b/uv.lock @@ -7,16 +7,16 @@ resolution-markers = [ [[package]] name = "aiohappyeyeballs" -version = "2.4.0" +version = "2.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f7/22bba300a16fd1cad99da1a23793fe43963ee326d012fdf852d0b4035955/aiohappyeyeballs-2.4.0.tar.gz", hash = "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2", size = 16786 } +sdist = { url = "https://files.pythonhosted.org/packages/c7/d9/e710a5c9e51b4d5a977c823ce323a81d344da8c1b6fba16bb270a8be800d/aiohappyeyeballs-2.4.2.tar.gz", hash = "sha256:4ca893e6c5c1f5bf3888b04cb5a3bee24995398efef6e0b9f747b5e89d84fd74", size = 18391 } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/b6/58ea188899950d759a837f9a58b2aee1d1a380ea4d6211ce9b1823748851/aiohappyeyeballs-2.4.0-py3-none-any.whl", hash = "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd", size = 12155 }, + { url = "https://files.pythonhosted.org/packages/13/64/40165ff77ade5203284e3015cf88e11acb07d451f6bf83fff71192912a0d/aiohappyeyeballs-2.4.2-py3-none-any.whl", hash = "sha256:8522691d9a154ba1145b157d6d5c15e5c692527ce6a53c5e5f9876977f6dab2f", size = 14105 }, ] [[package]] name = "aiohttp" -version = "3.10.5" +version = "3.10.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -27,83 +27,83 @@ dependencies = [ { name = "multidict" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/28/ca549838018140b92a19001a8628578b0f2a3b38c16826212cc6f706e6d4/aiohttp-3.10.5.tar.gz", hash = "sha256:f071854b47d39591ce9a17981c46790acb30518e2f83dfca8db2dfa091178691", size = 7524360 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/4a/b27dd9b88fe22dde88742b341fd10251746a6ffcfe1c0b8b15b4a8cbd7c1/aiohttp-3.10.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:18a01eba2574fb9edd5f6e5fb25f66e6ce061da5dab5db75e13fe1558142e0a3", size = 587010 }, - { url = "https://files.pythonhosted.org/packages/de/a9/0f7e2b71549c9d641086c423526ae7a10de3b88d03ba104a3df153574d0d/aiohttp-3.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94fac7c6e77ccb1ca91e9eb4cb0ac0270b9fb9b289738654120ba8cebb1189c6", size = 397698 }, - { url = "https://files.pythonhosted.org/packages/3b/52/26baa486e811c25b0cd16a494038260795459055568713f841e78f016481/aiohttp-3.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2f1f1c75c395991ce9c94d3e4aa96e5c59c8356a15b1c9231e783865e2772699", size = 389052 }, - { url = "https://files.pythonhosted.org/packages/33/df/71ba374a3e925539cb2f6e6d4f5326e7b6b200fabbe1b3cc5e6368f07ce7/aiohttp-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f7acae3cf1a2a2361ec4c8e787eaaa86a94171d2417aae53c0cca6ca3118ff6", size = 1248615 }, - { url = "https://files.pythonhosted.org/packages/67/02/bb89c1eba08a27fc844933bee505d63d480caf8e2816c06961d2941cd128/aiohttp-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94c4381ffba9cc508b37d2e536b418d5ea9cfdc2848b9a7fea6aebad4ec6aac1", size = 1282930 }, - { url = "https://files.pythonhosted.org/packages/db/36/07d8cfcc37f39c039f93a4210cc71dadacca003609946c63af23659ba656/aiohttp-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c31ad0c0c507894e3eaa843415841995bf8de4d6b2d24c6e33099f4bc9fc0d4f", size = 1317250 }, - { url = "https://files.pythonhosted.org/packages/9a/44/cabeac994bef8ba521b552ae996928afc6ee1975a411385a07409811b01f/aiohttp-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0912b8a8fadeb32ff67a3ed44249448c20148397c1ed905d5dac185b4ca547bb", size = 1243212 }, - { url = "https://files.pythonhosted.org/packages/5a/11/23f1e31f5885ac72be52fd205981951dd2e4c87c5b1487cf82fde5bbd46c/aiohttp-3.10.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d93400c18596b7dc4794d48a63fb361b01a0d8eb39f28800dc900c8fbdaca91", size = 1213401 }, - { url = "https://files.pythonhosted.org/packages/3f/e7/6e69a0b0d896fbaf1192d492db4c21688e6c0d327486da610b0e8195bcc9/aiohttp-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3c5e0d764a5c9aa5a62d99728c56d455310bcc288a79cab10157b3af426f", size = 1212450 }, - { url = "https://files.pythonhosted.org/packages/a9/7f/a42f51074c723ea848254946aec118f1e59914a639dc8ba20b0c9247c195/aiohttp-3.10.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d742c36ed44f2798c8d3f4bc511f479b9ceef2b93f348671184139e7d708042c", size = 1211324 }, - { url = "https://files.pythonhosted.org/packages/d5/43/c2f9d2f588ccef8f028f0a0c999b5ceafecbda50b943313faee7e91f3e03/aiohttp-3.10.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:814375093edae5f1cb31e3407997cf3eacefb9010f96df10d64829362ae2df69", size = 1266838 }, - { url = "https://files.pythonhosted.org/packages/c1/a7/ff9f067ecb06896d859e4f2661667aee4bd9c616689599ff034b63cbd9d7/aiohttp-3.10.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8224f98be68a84b19f48e0bdc14224b5a71339aff3a27df69989fa47d01296f3", size = 1285301 }, - { url = "https://files.pythonhosted.org/packages/9a/e3/dd56bb4c67d216046ce61d98dec0f3023043f1de48f561df1bf93dd47aea/aiohttp-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9a487ef090aea982d748b1b0d74fe7c3950b109df967630a20584f9a99c0683", size = 1235806 }, - { url = "https://files.pythonhosted.org/packages/a7/64/90dcd42ac21927a49ba4140b2e4d50e1847379427ef6c43eb338ef9960e3/aiohttp-3.10.5-cp310-cp310-win32.whl", hash = "sha256:d9ef084e3dc690ad50137cc05831c52b6ca428096e6deb3c43e95827f531d5ef", size = 360162 }, - { url = "https://files.pythonhosted.org/packages/f3/45/145d8b4853fc92c0c8509277642767e7726a085e390ce04353dc68b0f5b5/aiohttp-3.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:66bf9234e08fe561dccd62083bf67400bdbf1c67ba9efdc3dac03650e97c6088", size = 379173 }, - { url = "https://files.pythonhosted.org/packages/f1/90/54ccb1e4eadfb6c95deff695582453f6208584431d69bf572782e9ae542b/aiohttp-3.10.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8c6a4e5e40156d72a40241a25cc226051c0a8d816610097a8e8f517aeacd59a2", size = 586455 }, - { url = "https://files.pythonhosted.org/packages/c3/7a/95e88c02756e7e718f054e1bb3ec6ad5d0ee4a2ca2bb1768c5844b3de30a/aiohttp-3.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c634a3207a5445be65536d38c13791904fda0748b9eabf908d3fe86a52941cf", size = 397255 }, - { url = "https://files.pythonhosted.org/packages/07/4f/767387b39990e1ee9aba8ce642abcc286d84d06e068dc167dab983898f18/aiohttp-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4aff049b5e629ef9b3e9e617fa6e2dfeda1bf87e01bcfecaf3949af9e210105e", size = 388973 }, - { url = "https://files.pythonhosted.org/packages/61/46/0df41170a4d228c07b661b1ba9d87101d99a79339dc93b8b1183d8b20545/aiohttp-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1942244f00baaacaa8155eca94dbd9e8cc7017deb69b75ef67c78e89fdad3c77", size = 1326126 }, - { url = "https://files.pythonhosted.org/packages/af/20/da0d65e07ce49d79173fed41598f487a0a722e87cfbaa8bb7e078a7c1d39/aiohttp-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04a1f2a65ad2f93aa20f9ff9f1b672bf912413e5547f60749fa2ef8a644e061", size = 1364538 }, - { url = "https://files.pythonhosted.org/packages/aa/20/b59728405114e57541ba9d5b96033e69d004e811ded299537f74237629ca/aiohttp-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f2bfc0032a00405d4af2ba27f3c429e851d04fad1e5ceee4080a1c570476697", size = 1399896 }, - { url = "https://files.pythonhosted.org/packages/2a/92/006690c31b830acbae09d2618e41308fe4c81c0679b3b33a3af859e0b7bf/aiohttp-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:424ae21498790e12eb759040bbb504e5e280cab64693d14775c54269fd1d2bb7", size = 1312914 }, - { url = "https://files.pythonhosted.org/packages/d4/71/1a253ca215b6c867adbd503f1e142117527ea8775e65962bc09b2fad1d2c/aiohttp-3.10.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:975218eee0e6d24eb336d0328c768ebc5d617609affaca5dbbd6dd1984f16ed0", size = 1271301 }, - { url = "https://files.pythonhosted.org/packages/0a/ab/5d1d9ff9ce6cce8fa54774d0364e64a0f3cd50e512ff09082ced8e5217a1/aiohttp-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4120d7fefa1e2d8fb6f650b11489710091788de554e2b6f8347c7a20ceb003f5", size = 1291652 }, - { url = "https://files.pythonhosted.org/packages/75/5f/f90510ea954b9ae6e7a53d2995b97a3e5c181110fdcf469bc9238445871d/aiohttp-3.10.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b90078989ef3fc45cf9221d3859acd1108af7560c52397ff4ace8ad7052a132e", size = 1286289 }, - { url = "https://files.pythonhosted.org/packages/be/9e/1f523414237798660921817c82b9225a363af436458caf584d2fa6a2eb4a/aiohttp-3.10.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ba5a8b74c2a8af7d862399cdedce1533642fa727def0b8c3e3e02fcb52dca1b1", size = 1341848 }, - { url = "https://files.pythonhosted.org/packages/f6/36/443472ddaa85d7d80321fda541d9535b23ecefe0bf5792cc3955ea635190/aiohttp-3.10.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:02594361128f780eecc2a29939d9dfc870e17b45178a867bf61a11b2a4367277", size = 1361619 }, - { url = "https://files.pythonhosted.org/packages/19/f6/3ecbac0bc4359c7d7ba9e85c6b10f57e20edaf1f97751ad2f892db231ad0/aiohttp-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4fc029e135859f533025bc82047334e24b0d489e75513144f25408ecaf058", size = 1320869 }, - { url = "https://files.pythonhosted.org/packages/34/7e/ed74ffb36e3a0cdec1b05d8fbaa29cb532371d5a20058b3a8052fc90fe7c/aiohttp-3.10.5-cp311-cp311-win32.whl", hash = "sha256:e1ca1ef5ba129718a8fc827b0867f6aa4e893c56eb00003b7367f8a733a9b072", size = 359271 }, - { url = "https://files.pythonhosted.org/packages/98/1b/718901f04bc8c886a742be9e83babb7b93facabf7c475cc95e2b3ab80b4d/aiohttp-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:349ef8a73a7c5665cca65c88ab24abe75447e28aa3bc4c93ea5093474dfdf0ff", size = 379143 }, - { url = "https://files.pythonhosted.org/packages/d9/1c/74f9dad4a2fc4107e73456896283d915937f48177b99867b63381fadac6e/aiohttp-3.10.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:305be5ff2081fa1d283a76113b8df7a14c10d75602a38d9f012935df20731487", size = 583468 }, - { url = "https://files.pythonhosted.org/packages/12/29/68d090551f2b58ce76c2b436ced8dd2dfd32115d41299bf0b0c308a5483c/aiohttp-3.10.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3a1c32a19ee6bbde02f1cb189e13a71b321256cc1d431196a9f824050b160d5a", size = 394066 }, - { url = "https://files.pythonhosted.org/packages/8f/f7/971f88b4cdcaaa4622925ba7d86de47b48ec02a9040a143514b382f78da4/aiohttp-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61645818edd40cc6f455b851277a21bf420ce347baa0b86eaa41d51ef58ba23d", size = 389098 }, - { url = "https://files.pythonhosted.org/packages/f1/5a/fe3742efdce551667b2ddf1158b27c5b8eb1edc13d5e14e996e52e301025/aiohttp-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c225286f2b13bab5987425558baa5cbdb2bc925b2998038fa028245ef421e75", size = 1332742 }, - { url = "https://files.pythonhosted.org/packages/1a/52/a25c0334a1845eb4967dff279151b67ca32a948145a5812ed660ed900868/aiohttp-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ba01ebc6175e1e6b7275c907a3a36be48a2d487549b656aa90c8a910d9f3178", size = 1372134 }, - { url = "https://files.pythonhosted.org/packages/96/3d/33c1d8efc2d8ec36bff9a8eca2df9fdf8a45269c6e24a88e74f2aa4f16bd/aiohttp-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8eaf44ccbc4e35762683078b72bf293f476561d8b68ec8a64f98cf32811c323e", size = 1414413 }, - { url = "https://files.pythonhosted.org/packages/64/74/0f1ddaa5f0caba1d946f0dd0c31f5744116e4a029beec454ec3726d3311f/aiohttp-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1c43eb1ab7cbf411b8e387dc169acb31f0ca0d8c09ba63f9eac67829585b44f", size = 1328107 }, - { url = "https://files.pythonhosted.org/packages/0a/32/c10118f0ad50e4093227234f71fd0abec6982c29367f65f32ee74ed652c4/aiohttp-3.10.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de7a5299827253023c55ea549444e058c0eb496931fa05d693b95140a947cb73", size = 1280126 }, - { url = "https://files.pythonhosted.org/packages/c6/c9/77e3d648d97c03a42acfe843d03e97be3c5ef1b4d9de52e5bd2d28eed8e7/aiohttp-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4790f0e15f00058f7599dab2b206d3049d7ac464dc2e5eae0e93fa18aee9e7bf", size = 1292660 }, - { url = "https://files.pythonhosted.org/packages/7e/5d/99c71f8e5c8b64295be421b4c42d472766b263a1fe32e91b64bf77005bf2/aiohttp-3.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:44b324a6b8376a23e6ba25d368726ee3bc281e6ab306db80b5819999c737d820", size = 1300988 }, - { url = "https://files.pythonhosted.org/packages/8f/2c/76d2377dd947f52fbe8afb19b18a3b816d66c7966755c04030f93b1f7b2d/aiohttp-3.10.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0d277cfb304118079e7044aad0b76685d30ecb86f83a0711fc5fb257ffe832ca", size = 1339268 }, - { url = "https://files.pythonhosted.org/packages/fd/e6/3d9d935cc705d57ed524d82ec5d6b678a53ac1552720ae41282caa273584/aiohttp-3.10.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:54d9ddea424cd19d3ff6128601a4a4d23d54a421f9b4c0fff740505813739a91", size = 1366993 }, - { url = "https://files.pythonhosted.org/packages/fe/c2/f7eed4d602f3f224600d03ab2e1a7734999b0901b1c49b94dc5891340433/aiohttp-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f1c9866ccf48a6df2b06823e6ae80573529f2af3a0992ec4fe75b1a510df8a6", size = 1329459 }, - { url = "https://files.pythonhosted.org/packages/ce/8f/27f205b76531fc592abe29e1ad265a16bf934a9f609509c02d765e6a8055/aiohttp-3.10.5-cp312-cp312-win32.whl", hash = "sha256:dc4826823121783dccc0871e3f405417ac116055bf184ac04c36f98b75aacd12", size = 356968 }, - { url = "https://files.pythonhosted.org/packages/39/8c/4f6c0b2b3629f6be6c81ab84d9d577590f74f01d4412bfc4067958eaa1e1/aiohttp-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:22c0a23a3b3138a6bf76fc553789cb1a703836da86b0f306b6f0dc1617398abc", size = 377650 }, - { url = "https://files.pythonhosted.org/packages/7b/b9/03b4327897a5b5d29338fa9b514f1c2f66a3e4fc88a4e40fad478739314d/aiohttp-3.10.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7f6b639c36734eaa80a6c152a238242bedcee9b953f23bb887e9102976343092", size = 576994 }, - { url = "https://files.pythonhosted.org/packages/67/1b/20c2e159cd07b8ed6dde71c2258233902fdf415b2fe6174bd2364ba63107/aiohttp-3.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29930bc2921cef955ba39a3ff87d2c4398a0394ae217f41cb02d5c26c8b1b77", size = 390684 }, - { url = "https://files.pythonhosted.org/packages/4d/6b/ff83b34f157e370431d8081c5d1741963f4fb12f9aaddb2cacbf50305225/aiohttp-3.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f489a2c9e6455d87eabf907ac0b7d230a9786be43fbe884ad184ddf9e9c1e385", size = 386176 }, - { url = "https://files.pythonhosted.org/packages/4d/a1/6e92817eb657de287560962df4959b7ddd22859c4b23a0309e2d3de12538/aiohttp-3.10.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:123dd5b16b75b2962d0fff566effb7a065e33cd4538c1692fb31c3bda2bfb972", size = 1303310 }, - { url = "https://files.pythonhosted.org/packages/04/29/200518dc7a39c30ae6d5bc232d7207446536e93d3d9299b8e95db6e79c54/aiohttp-3.10.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b98e698dc34966e5976e10bbca6d26d6724e6bdea853c7c10162a3235aba6e16", size = 1340445 }, - { url = "https://files.pythonhosted.org/packages/8e/20/53f7bba841ba7b5bb5dea580fea01c65524879ba39cb917d08c845524717/aiohttp-3.10.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3b9162bab7e42f21243effc822652dc5bb5e8ff42a4eb62fe7782bcbcdfacf6", size = 1385121 }, - { url = "https://files.pythonhosted.org/packages/f1/b4/d99354ad614c48dd38fb1ee880a1a54bd9ab2c3bcad3013048d4a1797d3a/aiohttp-3.10.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1923a5c44061bffd5eebeef58cecf68096e35003907d8201a4d0d6f6e387ccaa", size = 1299669 }, - { url = "https://files.pythonhosted.org/packages/51/39/ca1de675f2a5729c71c327e52ac6344e63f036bd37281686ae5c3fb13bfb/aiohttp-3.10.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d55f011da0a843c3d3df2c2cf4e537b8070a419f891c930245f05d329c4b0689", size = 1252638 }, - { url = "https://files.pythonhosted.org/packages/54/cf/a3ae7ff43138422d477348e309ef8275779701bf305ff6054831ef98b782/aiohttp-3.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:afe16a84498441d05e9189a15900640a2d2b5e76cf4efe8cbb088ab4f112ee57", size = 1266889 }, - { url = "https://files.pythonhosted.org/packages/6e/7a/c6027ad70d9fb23cf254a26144de2723821dade1a624446aa22cd0b6d012/aiohttp-3.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f8112fb501b1e0567a1251a2fd0747baae60a4ab325a871e975b7bb67e59221f", size = 1266249 }, - { url = "https://files.pythonhosted.org/packages/64/fd/ed136d46bc2c7e3342fed24662b4827771d55ceb5a7687847aae977bfc17/aiohttp-3.10.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1e72589da4c90337837fdfe2026ae1952c0f4a6e793adbbfbdd40efed7c63599", size = 1311036 }, - { url = "https://files.pythonhosted.org/packages/76/9a/43eeb0166f1119256d6f43468f900db1aed7fbe32069d2a71c82f987db4d/aiohttp-3.10.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4d46c7b4173415d8e583045fbc4daa48b40e31b19ce595b8d92cf639396c15d5", size = 1338756 }, - { url = "https://files.pythonhosted.org/packages/d5/bc/d01ff0810b3f5e26896f76d44225ed78b088ddd33079b85cd1a23514318b/aiohttp-3.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33e6bc4bab477c772a541f76cd91e11ccb6d2efa2b8d7d7883591dfb523e5987", size = 1299976 }, - { url = "https://files.pythonhosted.org/packages/3e/c9/50a297c4f7ab57a949f4add2d3eafe5f3e68bb42f739e933f8b32a092bda/aiohttp-3.10.5-cp313-cp313-win32.whl", hash = "sha256:c58c6837a2c2a7cf3133983e64173aec11f9c2cd8e87ec2fdc16ce727bcf1a04", size = 355609 }, - { url = "https://files.pythonhosted.org/packages/65/28/aee9d04fb0b3b1f90622c338a08e54af5198e704a910e20947c473298fd0/aiohttp-3.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:38172a70005252b6893088c0f5e8a47d173df7cc2b2bd88650957eb84fcf5022", size = 375697 }, - { url = "https://files.pythonhosted.org/packages/7d/0f/6bcda6528ba2ae65c58e62d63c31ada74b0d761efbb6678d19a447520392/aiohttp-3.10.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7e2fe37ac654032db1f3499fe56e77190282534810e2a8e833141a021faaab0e", size = 588725 }, - { url = "https://files.pythonhosted.org/packages/3b/db/c818fd1c254bcb7af4ca75b69c89ee58807e11d9338348065d1b549a9ee7/aiohttp-3.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5bf3ead3cb66ab990ee2561373b009db5bc0e857549b6c9ba84b20bc462e172", size = 398568 }, - { url = "https://files.pythonhosted.org/packages/6a/56/4a1e41e632c97d2848dfb866a87f6802b9541c0720cc1017a5002cd58873/aiohttp-3.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b2c16a919d936ca87a3c5f0e43af12a89a3ce7ccbce59a2d6784caba945b68b", size = 389860 }, - { url = "https://files.pythonhosted.org/packages/86/d0/c0eb2bbdc2808b80432b6c1a56e2db433fac8c84247f895a30f13be2b68d/aiohttp-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad146dae5977c4dd435eb31373b3fe9b0b1bf26858c6fc452bf6af394067e10b", size = 1253862 }, - { url = "https://files.pythonhosted.org/packages/8e/61/2f46f41bf4491cdfb6d599a486bc426332f103773a4e8003b2b09d2b7b2e/aiohttp-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c5c6fa16412b35999320f5c9690c0f554392dc222c04e559217e0f9ae244b92", size = 1289926 }, - { url = "https://files.pythonhosted.org/packages/88/83/e846ae7546a056e271823c02c3002cc6e722c95bce32582cf3e8578c7b0b/aiohttp-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95c4dc6f61d610bc0ee1edc6f29d993f10febfe5b76bb470b486d90bbece6b22", size = 1325020 }, - { url = "https://files.pythonhosted.org/packages/23/69/200bf165b56c17854d54975f894de10dababc4d0226c07600c9abc679e7e/aiohttp-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da452c2c322e9ce0cfef392e469a26d63d42860f829026a63374fde6b5c5876f", size = 1246350 }, - { url = "https://files.pythonhosted.org/packages/ca/45/d5f6ec14e948d1c72c91f743de5b5f26a476c81151082910002b59c84e0b/aiohttp-3.10.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:898715cf566ec2869d5cb4d5fb4be408964704c46c96b4be267442d265390f32", size = 1216314 }, - { url = "https://files.pythonhosted.org/packages/9b/c7/2078ebb25cfcd0ebadbc451b508f09fe37e3ca3a2fbe3b2791d00912d31c/aiohttp-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:391cc3a9c1527e424c6865e087897e766a917f15dddb360174a70467572ac6ce", size = 1216766 }, - { url = "https://files.pythonhosted.org/packages/d9/59/25f96afdc4f6ba845feb607026b632181b37fc4e3242e4dce3d71a0afaa1/aiohttp-3.10.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:380f926b51b92d02a34119d072f178d80bbda334d1a7e10fa22d467a66e494db", size = 1216190 }, - { url = "https://files.pythonhosted.org/packages/f1/cb/2b6e003cdd3388454b183aabb91b15db9ac5b47eb224d3b6436f938e7380/aiohttp-3.10.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce91db90dbf37bb6fa0997f26574107e1b9d5ff939315247b7e615baa8ec313b", size = 1271316 }, - { url = "https://files.pythonhosted.org/packages/71/1c/1ce6e7a0376ebb861521c96ed47eda1e0c2e9c80c0407e431b46cecc4bfb/aiohttp-3.10.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9093a81e18c45227eebe4c16124ebf3e0d893830c6aca7cc310bfca8fe59d857", size = 1288007 }, - { url = "https://files.pythonhosted.org/packages/01/0c/4e8db6e6d8f3b655d762530a083ea729b5d1ed479ddc55881d845bcd4795/aiohttp-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ee40b40aa753d844162dcc80d0fe256b87cba48ca0054f64e68000453caead11", size = 1238304 }, - { url = "https://files.pythonhosted.org/packages/4c/bd/69a87f5fd0070e339eb4f62d0ca61e87f85bde492746401852cd40f5113c/aiohttp-3.10.5-cp39-cp39-win32.whl", hash = "sha256:03f2645adbe17f274444953bdea69f8327e9d278d961d85657cb0d06864814c1", size = 360755 }, - { url = "https://files.pythonhosted.org/packages/8d/e9/cfdf2e0132860976514439c8a50b57fc8d65715d77eeec0e5b150e9c6a96/aiohttp-3.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:d17920f18e6ee090bdd3d0bfffd769d9f2cb4c8ffde3eb203777a3895c128862", size = 379781 }, +sdist = { url = "https://files.pythonhosted.org/packages/2b/97/15c51bbfcc184bcb4d473b7b02e7b54b6978e0083556a9cd491875cf11f7/aiohttp-3.10.6.tar.gz", hash = "sha256:d2578ef941be0c2ba58f6f421a703527d08427237ed45ecb091fed6f83305336", size = 7538429 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/a5/20d5b4cc1dbf5292434ad968af698c3e25b149394060578c4e83edfe5c56/aiohttp-3.10.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:682836fc672972cc3101cc9e30d49c5f7e8f1d010478d46119fe725a4545acfd", size = 586587 }, + { url = "https://files.pythonhosted.org/packages/d6/77/bcb8f5e382d800673c6457cab3cb24143ae30578682687d334a556fe4021/aiohttp-3.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:289fa8a20018d0d5aa9e4b35d899bd51bcb80f0d5f365d9a23e30dac3b79159b", size = 398987 }, + { url = "https://files.pythonhosted.org/packages/19/b1/874ca8a6fd581303f1f99efc71aff034e1b955702d54b96a61d97f18387f/aiohttp-3.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8617c96a20dd57e7e9d398ff9d04f3d11c4d28b1767273a5b1a018ada5a654d3", size = 390350 }, + { url = "https://files.pythonhosted.org/packages/41/2b/18f60f36d3b0d6b4f9680b00e129058086984af33ab01743d8f8d662ae43/aiohttp-3.10.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bdbeff1b062751c2a2a55b171f7050fb7073633c699299d042e962aacdbe1a07", size = 1228449 }, + { url = "https://files.pythonhosted.org/packages/d5/77/801a07f67a6ccfc2d6e8c372e196b6019f33d637e6a3abf84f876983dbfd/aiohttp-3.10.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ea35d849cdd4a9268f910bff4497baebbc1aa3f2f625fd8ccd9ac99c860c621", size = 1264246 }, + { url = "https://files.pythonhosted.org/packages/bd/ff/984219306cbc1fedd689e9cfe7894d0cb2ae0038f2d7079e2788a79383ee/aiohttp-3.10.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:473961b3252f3b949bb84873d6e268fb6d8aa0ccc6eb7404fa58c76a326bb8e1", size = 1298031 }, + { url = "https://files.pythonhosted.org/packages/25/34/96445dc2db0ff0e7ffe71abb73e298a62c2724b774470d5b232ed8ee89ad/aiohttp-3.10.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d2665c5df629eb2f981dab244c01bfa6cdc185f4ffa026639286c4d56fafb54", size = 1221827 }, + { url = "https://files.pythonhosted.org/packages/6d/59/52170050e3e83e476321cce2232c456d55ecf0b67faf9a31b73328d7b65f/aiohttp-3.10.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25d92f794f1332f656e3765841fc2b7ad5c26c3f3d01e8949eeb3495691cf9f4", size = 1193436 }, + { url = "https://files.pythonhosted.org/packages/4f/ad/cb020bdb02e41ed4cf0f9a1031c67424d9dd1b1dd3e5fd98053ed3b4f72f/aiohttp-3.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9bd6b2033993d5ae80883bb29b83fb2b432270bbe067c2f53cc73bb57c46065f", size = 1193175 }, + { url = "https://files.pythonhosted.org/packages/59/d3/58cf6e9c81064d07173ee0e31743fa18212e3c76d1041a30164e91e91e7d/aiohttp-3.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d7f408c43f5e75ea1edc152fb375e8f46ef916f545fb66d4aebcbcfad05e2796", size = 1192674 }, + { url = "https://files.pythonhosted.org/packages/b2/1f/c203e3ff4636885f8d47228a717f248b7acd5761a9fb57650f8ad393cb1a/aiohttp-3.10.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:cf8b8560aa965f87bf9c13bf9fed7025993a155ca0ce8422da74bf46d18c2f5f", size = 1246831 }, + { url = "https://files.pythonhosted.org/packages/e2/e4/8534f620113c9922d911271678f6f053192627035bfa7b0b62c9baa48908/aiohttp-3.10.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14477c4e52e2f17437b99893fd220ffe7d7ee41df5ebf931a92b8ca82e6fd094", size = 1263732 }, + { url = "https://files.pythonhosted.org/packages/3c/99/d5ada3481146b42312a83b16304b001125df8e8e7751c71a0f26cf4fb38f/aiohttp-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb138fbf9f53928e779650f5ed26d0ea1ed8b2cab67f0ea5d63afa09fdc07593", size = 1215377 }, + { url = "https://files.pythonhosted.org/packages/82/2d/a5afdfb5c37a7dbb4468ff39a4581a6290b2ced18fb5513981a9154c91c1/aiohttp-3.10.6-cp310-cp310-win32.whl", hash = "sha256:9843d683b8756971797be171ead21511d2215a2d6e3c899c6e3107fbbe826791", size = 362309 }, + { url = "https://files.pythonhosted.org/packages/49/2e/d06c4bf365685ba0ea501e5bb5b4a4b0c3f90236f8a38ee0083c56624847/aiohttp-3.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:f8b8e49fe02f744d38352daca1dbef462c3874900bd8166516f6ea8e82b5aacf", size = 380724 }, + { url = "https://files.pythonhosted.org/packages/d5/f0/6c921693dd264db370916dab69cc3267ca4bb14296b4ca88b3855f6152cd/aiohttp-3.10.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52e54fd776ad0da1006708762213b079b154644db54bcfc62f06eaa5b896402", size = 586118 }, + { url = "https://files.pythonhosted.org/packages/df/14/12804459bd128ff3c7d60dadaf49be9e7027df5c8800290518113c411d00/aiohttp-3.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:995ab1a238fd0d19dc65f2d222e5eb064e409665c6426a3e51d5101c1979ee84", size = 398614 }, + { url = "https://files.pythonhosted.org/packages/49/2c/066640d3c3dd52d4c11dfcff64c6eebe4a7607571bc9cb8ae2c2b4013367/aiohttp-3.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0749c4d5a08a802dd66ecdf59b2df4d76b900004017468a7bb736c3b5a3dd902", size = 390262 }, + { url = "https://files.pythonhosted.org/packages/71/50/6db8a9ba23ee4d5621ec2a59427c271cc1ddaf4fc1a9c02c9dcba1ebe671/aiohttp-3.10.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e05b39158f2af0e2438cc2075cfc271f4ace0c3cc4a81ec95b27a0432e161951", size = 1306113 }, + { url = "https://files.pythonhosted.org/packages/51/fd/a923745abb24657264b2122c24a296468cf8c16ba68b7b569060d6c32620/aiohttp-3.10.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f196c970db2dcde4f24317e06615363349dc357cf4d7a3b0716c20ac6d7bcd", size = 1344288 }, + { url = "https://files.pythonhosted.org/packages/f8/7a/5f1397305aa5885a35dce0b10681aa547537348a18d107d96a07e99bebb8/aiohttp-3.10.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:47647c8af04a70e07a2462931b0eba63146a13affa697afb4ecbab9d03a480ce", size = 1378118 }, + { url = "https://files.pythonhosted.org/packages/fd/80/4f1c4b5459a27437a8f18f91d6000fdc45b677aee879129deaadc94c1a23/aiohttp-3.10.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:669c0efe7e99f6d94d63274c06344bd0e9c8daf184ce5602a29bc39e00a18720", size = 1292337 }, + { url = "https://files.pythonhosted.org/packages/e1/57/cef69e70f18271f86080a3d28571598baf0dccb2fc726fbd74b91a56d51a/aiohttp-3.10.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9721cdd83a994225352ca84cd537760d41a9da3c0eacb3ff534747ab8fba6d0", size = 1251407 }, + { url = "https://files.pythonhosted.org/packages/b0/dd/8b718a8ecb271d484c6d43f4ae3d63e684c259367c8c2cda861f1bf12cfd/aiohttp-3.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0b82c8ebed66ce182893e7c0b6b60ba2ace45b1df104feb52380edae266a4850", size = 1271350 }, + { url = "https://files.pythonhosted.org/packages/f8/37/c80d05752ecbe7419ec61d39facff8d77914e295c8d45eb250d1fa03ae78/aiohttp-3.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b169f8e755e541b72e714b89a831b315bbe70db44e33fead28516c9e13d5f931", size = 1265888 }, + { url = "https://files.pythonhosted.org/packages/4f/44/9862295fabcadcf7d79e9a92eb8528866d602042571c43c333d94c7f3025/aiohttp-3.10.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0be3115753baf8b4153e64f9aa7bf6c0c64af57979aa900c31f496301b374570", size = 1321251 }, + { url = "https://files.pythonhosted.org/packages/57/62/5b92e910aa95c2558b418eb68f0d117aab968cdd15019c06ea1c66d0baf2/aiohttp-3.10.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e1f80cd17d81a404b6e70ef22bfe1870bafc511728397634ad5f5efc8698df56", size = 1338856 }, + { url = "https://files.pythonhosted.org/packages/7e/e6/bb013958e9fcfb982d8dba12b0c72621427619cd0a11bb3023601c205988/aiohttp-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6419728b08fb6380c66a470d2319cafcec554c81780e2114b7e150329b9a9a7f", size = 1298691 }, + { url = "https://files.pythonhosted.org/packages/f7/c7/cc2dc01f89d8a0ee2d84d8d0c85b48ec62a427bcd865736f9ceb340c0117/aiohttp-3.10.6-cp311-cp311-win32.whl", hash = "sha256:bd294dcdc1afdc510bb51d35444003f14e327572877d016d576ac3b9a5888a27", size = 361858 }, + { url = "https://files.pythonhosted.org/packages/9f/2a/60284a07a0353250cf64db9728980a3bb9a55eeea334d79c48e65801460a/aiohttp-3.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:bf861da9a43d282d6dd9dcd64c23a0fccf2c5aa5cd7c32024513c8c79fb69de3", size = 381204 }, + { url = "https://files.pythonhosted.org/packages/41/6b/0db03d1105e5e8564fd39a87729fd910300a8021b2c59f6f57ed963fe896/aiohttp-3.10.6-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:2708baccdc62f4b1251e59c2aac725936a900081f079b88843dabcab0feeeb27", size = 583162 }, + { url = "https://files.pythonhosted.org/packages/d0/9e/c44dddee462c38853a0c32b50c4deed09790d27496ab9eb3b481614344a5/aiohttp-3.10.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7475da7a5e2ccf1a1c86c8fee241e277f4874c96564d06f726d8df8e77683ef7", size = 395317 }, + { url = "https://files.pythonhosted.org/packages/25/0e/c0dfb1604645ab64e2b1210e624f951a024a2e9683feb563bbf979874220/aiohttp-3.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:02108326574ff60267b7b35b17ac5c0bbd0008ccb942ce4c48b657bb90f0b8aa", size = 390533 }, + { url = "https://files.pythonhosted.org/packages/5c/50/8c3eba14ce77fd78f1def3788cbc75b54291dd4d8f5647d721316437f5da/aiohttp-3.10.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:029a019627b37fa9eac5c75cc54a6bb722c4ebbf5a54d8c8c0fb4dd8facf2702", size = 1311699 }, + { url = "https://files.pythonhosted.org/packages/bd/02/d0f12cfc7ade482d81c6d2c4c5f2f98964d6305560b7df0b7712212241ca/aiohttp-3.10.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a637d387db6fdad95e293fab5433b775fd104ae6348d2388beaaa60d08b38c4", size = 1350180 }, + { url = "https://files.pythonhosted.org/packages/31/2b/f78ff8d84e700a279434dd371ae6e87e12a13f9ed2a5efe9cd6aacd749d4/aiohttp-3.10.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dc1a16f3fc1944c61290d33c88dc3f09ba62d159b284c38c5331868425aca426", size = 1392281 }, + { url = "https://files.pythonhosted.org/packages/a5/f8/a8722a471cbf19e56763545fd5bc0fdf7b61324535f0b35bd6f0548d4016/aiohttp-3.10.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81b292f37969f9cc54f4643f0be7dacabf3612b3b4a65413661cf6c350226787", size = 1305932 }, + { url = "https://files.pythonhosted.org/packages/38/a5/897caff83bfe41fd749056b11282504772b34c2dfe730aaf8e84bbd3a660/aiohttp-3.10.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0754690a3a26e819173a34093798c155bafb21c3c640bff13be1afa1e9d421f9", size = 1259884 }, + { url = "https://files.pythonhosted.org/packages/e1/75/effbadbf5c9a536f90769544467da311efd6e8c43671bc0729055c59d363/aiohttp-3.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:164ecd32e65467d86843dbb121a6666c3deb23b460e3f8aefdcaacae79eb718a", size = 1270764 }, + { url = "https://files.pythonhosted.org/packages/33/34/33e07d1bc34406bfc0877f22eed071060796431488c8eb6d456c583a74a9/aiohttp-3.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:438c5863feb761f7ca3270d48c292c334814459f61cc12bab5ba5b702d7c9e56", size = 1279882 }, + { url = "https://files.pythonhosted.org/packages/2e/e4/ffed46ce0b45564cbf715b0b97725840468c7c5a9d6e8d560082c29ad4bf/aiohttp-3.10.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ba18573bb1de1063d222f41de64a0d3741223982dcea863b3f74646faf618ec7", size = 1316432 }, + { url = "https://files.pythonhosted.org/packages/ce/00/488d68568f60aa5dbf9d41ef60d276ffbafeab553bf79b00225de7133e0b/aiohttp-3.10.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c82a94ddec996413a905f622f3da02c4359952aab8d817c01cf9915419525e95", size = 1343562 }, + { url = "https://files.pythonhosted.org/packages/14/3e/3679c1438fcb0aadddff32e97b3b88b1c8aea80276d374ec543a5ed70d0d/aiohttp-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:92351aa5363fc3c1f872ca763f86730ced32b01607f0c9662b1fa711087968d0", size = 1305803 }, + { url = "https://files.pythonhosted.org/packages/76/48/fe117dffa13c69d9670e107cbf3dea20be9f7fc5d30d2fd3fd6252f28c58/aiohttp-3.10.6-cp312-cp312-win32.whl", hash = "sha256:3e15e33bfc73fa97c228f72e05e8795e163a693fd5323549f49367c76a6e5883", size = 358921 }, + { url = "https://files.pythonhosted.org/packages/92/9f/7281a6dae91c9cc3f23dfb865f074151810216f31bdb46843bfde8e39f17/aiohttp-3.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:fe517113fe4d35d9072b826c3e147d63c5f808ca8167d450b4f96c520c8a1d8d", size = 378938 }, + { url = "https://files.pythonhosted.org/packages/12/7f/89eb922fda25d5b9c7c08d14d50c788d998f148210478059b7549040424a/aiohttp-3.10.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:482f74057ea13d387a7549d7a7ecb60e45146d15f3e58a2d93a0ad2d5a8457cd", size = 575722 }, + { url = "https://files.pythonhosted.org/packages/84/6d/eb3965c55748f960751b752969983982a995d2aa21f023ed30fe5a471629/aiohttp-3.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:03fa40d1450ee5196e843315ddf74a51afc7e83d489dbfc380eecefea74158b1", size = 391518 }, + { url = "https://files.pythonhosted.org/packages/78/4a/98c9d9cee601477eda8f851376eff88e864e9f3147cbc3a428da47d90ed0/aiohttp-3.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e52e59ed5f4cc3a3acfe2a610f8891f216f486de54d95d6600a2c9ba1581f4d", size = 387037 }, + { url = "https://files.pythonhosted.org/packages/2d/b0/6136aefae0f0d2abe4a435af71a944781e37bbe6fd836a23ff41bbba0682/aiohttp-3.10.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b3935a22c9e41a8000d90588bed96cf395ef572dbb409be44c6219c61d900d", size = 1286703 }, + { url = "https://files.pythonhosted.org/packages/cd/8a/a17ec94a7b6394efeeaca16df8d1e9359f0aa83548e40bf16b5853ed7684/aiohttp-3.10.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bef1480ee50f75abcfcb4b11c12de1005968ca9d0172aec4a5057ba9f2b644f", size = 1323244 }, + { url = "https://files.pythonhosted.org/packages/35/37/4cf6d2a8dce91ea7ff8b8ed8e1ef5c6a5934e07b4da5993ae95660b7cfbc/aiohttp-3.10.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:671745ea7db19693ce867359d503772177f0b20fa8f6ee1e74e00449f4c4151d", size = 1368034 }, + { url = "https://files.pythonhosted.org/packages/0d/d5/e939fcf26bd5c7760a1b71eff7396f6ca0e3c807088086551db28af0c090/aiohttp-3.10.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b50b367308ca8c12e0b50cba5773bc9abe64c428d3fd2bbf5cd25aab37c77bf", size = 1282395 }, + { url = "https://files.pythonhosted.org/packages/46/44/85d5d61b3ac50f30766cd2c1d22e6f937f027922621fc91581ead05749f6/aiohttp-3.10.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a504d7cdb431a777d05a124fd0b21efb94498efa743103ea01b1e3136d2e4fb", size = 1236147 }, + { url = "https://files.pythonhosted.org/packages/61/35/43eee26590f369906151cea78297554304ed2ceda5a5ed69cc2e907e9903/aiohttp-3.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66bc81361131763660b969132a22edce2c4d184978ba39614e8f8f95db5c95f8", size = 1249963 }, + { url = "https://files.pythonhosted.org/packages/44/b5/e099ad2bf7ad6ab5bb685f66a7599dc7f9fb4879eb987a4bf02ca2886974/aiohttp-3.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:27cf19a38506e2e9f12fc17e55f118f04897b0a78537055d93a9de4bf3022e3d", size = 1248579 }, + { url = "https://files.pythonhosted.org/packages/85/81/520348e8ec472679e65deb87c2a2bb2ad2c40e328746245bd35251b7ee4f/aiohttp-3.10.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3468b39f977a11271517c6925b226720e148311039a380cc9117b1e2258a721f", size = 1293005 }, + { url = "https://files.pythonhosted.org/packages/e5/a8/1ddd2af786c3b4f30187bc98464b8e3c54c6bbf18062a20291c6b5b03f27/aiohttp-3.10.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9d26da22a793dfd424be1050712a70c0afd96345245c29aced1e35dbace03413", size = 1319740 }, + { url = "https://files.pythonhosted.org/packages/0a/6f/a757fdf01ce4d20fcfee35af3b63a2393dbd3478873c4ea9aaad24b093f1/aiohttp-3.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:844d48ff9173d0b941abed8b2ea6a412f82b56d9ab1edb918c74000c15839362", size = 1281177 }, + { url = "https://files.pythonhosted.org/packages/9d/d9/e5866f341cfad4de82caf570218a424f96914192a9230dd6f6dfe4653a93/aiohttp-3.10.6-cp313-cp313-win32.whl", hash = "sha256:2dd56e3c43660ed3bea67fd4c5025f1ac1f9ecf6f0b991a6e5efe2e678c490c5", size = 357148 }, + { url = "https://files.pythonhosted.org/packages/57/cc/ba781a170fd4405819cc988026cfa16a9397ffebf5639dc84ad65d518448/aiohttp-3.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:c91781d969fbced1993537f45efe1213bd6fccb4b37bfae2a026e20d6fbed206", size = 376413 }, + { url = "https://files.pythonhosted.org/packages/e3/8c/09c36451df52c753e46be8a1d9533d61d19acdced8424e06575d41285e24/aiohttp-3.10.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5db26bbca8e7968c4c977a0c640e0b9ce7224e1f4dcafa57870dc6ee28e27de6", size = 588214 }, + { url = "https://files.pythonhosted.org/packages/2c/90/2e5f130dbac00f615c99a041fbd0734f40d68f94f32e7e5bc74ac148e228/aiohttp-3.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3fb4216e3ec0dbc01db5ba802f02ed78ad8f07121be54eb9e918448cc3f61b7c", size = 399874 }, + { url = "https://files.pythonhosted.org/packages/d3/de/b60c688d89c357b23084facc1602a23dcfac812d50175bdd6b0d941e8e08/aiohttp-3.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a976ef488f26e224079deb3d424f29144c6d5ba4ded313198169a8af8f47fb82", size = 391201 }, + { url = "https://files.pythonhosted.org/packages/00/b8/a3559410e6fa6e96eec4623a517c84e6774576a276dad5380e1720871760/aiohttp-3.10.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a86610174de8a85a920e956e2d4f9945e7da89f29a00e95ac62a4a414c4ef4e", size = 1233301 }, + { url = "https://files.pythonhosted.org/packages/72/cd/62c6c417bc5e49087ae7ae9f66f64c10df6601ead4d2646f5a4a7630ac30/aiohttp-3.10.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:217791c6a399cc4f2e6577bb44344cba1f5714a2aebf6a0bea04cfa956658284", size = 1270683 }, + { url = "https://files.pythonhosted.org/packages/57/67/e6dc17dbdefb459ec3e5b6e8b3332f0e11683fac6fa7ac4b74335a9edc7a/aiohttp-3.10.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba3662d41abe2eab0eeec7ee56f33ef4e0b34858f38abf24377687f9e1fb00a5", size = 1304500 }, + { url = "https://files.pythonhosted.org/packages/d0/d3/553e55b6adc881e44e9024d92f9dd70c538d59a19d6a58cb715f0838ce24/aiohttp-3.10.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4dfa5ad4bce9ca30a76117fbaa1c1decf41ebb6c18a4e098df44298941566f9", size = 1225049 }, + { url = "https://files.pythonhosted.org/packages/29/a1/f444b1c39039b9f020d02e871c5afd6e0eaeecf34bfcd47ef8f82408c1bf/aiohttp-3.10.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0009258e97502936d3bd5bf2ced15769629097d0abb81e6495fba1047824fe0", size = 1196214 }, + { url = "https://files.pythonhosted.org/packages/3b/50/bab30b4bbe1ef7d66d97358129c34379039c69b2b528ff02804a42b0b4da/aiohttp-3.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0a75d5c9fb4f06c41d029ae70ad943c3a844c40c0a769d12be4b99b04f473d3d", size = 1196350 }, + { url = "https://files.pythonhosted.org/packages/9b/8a/3731748b1080b2195268c85010727406ac3cee2fa878318d46de5614372f/aiohttp-3.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8198b7c002aae2b40b2d16bfe724b9a90bcbc9b78b2566fc96131ef4e382574d", size = 1196837 }, + { url = "https://files.pythonhosted.org/packages/7c/3c/51f9dbdabcdacf54b9e9ec1af210509e1a7e262647a106f720ed683b35ee/aiohttp-3.10.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4611db8c907f90fe86be112efdc2398cd7b4c8eeded5a4f0314b70fdea8feab0", size = 1250939 }, + { url = "https://files.pythonhosted.org/packages/fd/51/e4fde27da37a28c5bdef08d9393115f062c2e198f720172b12bb53b6c4d4/aiohttp-3.10.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ff99ae06eef85c7a565854826114ced72765832ee16c7e3e766c5e4c5b98d20e", size = 1265712 }, + { url = "https://files.pythonhosted.org/packages/1c/0d/b9c0d5ad9dc99cdfba665ba9ac6bd7aa9b97c866aef180477fabc54eaa56/aiohttp-3.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7641920bdcc7cd2d3ddfb8bb9133a6c9536b09dbd49490b79e125180b2d25b93", size = 1216963 }, + { url = "https://files.pythonhosted.org/packages/83/3e/c9ad8da2750ad9014a3d00be4809d8b96991ebdc4903ab78c2793362e192/aiohttp-3.10.6-cp39-cp39-win32.whl", hash = "sha256:e2e7d5591ea868d5ec82b90bbeb366a198715672841d46281b623e23079593db", size = 362896 }, + { url = "https://files.pythonhosted.org/packages/dc/b9/f952f6b156d01a04b6b110ba01f5fed975afdcfaca72ed4d07db964930ce/aiohttp-3.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:b504c08c45623bf5c7ca41be380156d925f00199b3970efd758aef4a77645feb", size = 381395 }, ] [[package]] @@ -138,7 +138,7 @@ wheels = [ [[package]] name = "anyio" -version = "4.4.0" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, @@ -146,9 +146,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/e3/c4c8d473d6780ef1853d630d581f70d655b4f8d7553c6997958c283039a2/anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94", size = 163930 } +sdist = { url = "https://files.pythonhosted.org/packages/78/49/f3f17ec11c4a91fe79275c426658e509b07547f874b14c1a526d86a83fc8/anyio-4.6.0.tar.gz", hash = "sha256:137b4559cbb034c477165047febb6ff83f390fc3b20bf181c1fc0a728cb8beeb", size = 170983 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/a2/10639a79341f6c019dedc95bd48a4928eed9f1d1197f4c04f546fc7ae0ff/anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7", size = 86780 }, + { url = "https://files.pythonhosted.org/packages/9e/ef/7a4f225581a0d7886ea28359179cb861d7fbcdefad29663fc1167b86f69f/anyio-4.6.0-py3-none-any.whl", hash = "sha256:c7d2e9d63e31599eeb636c8c5c03a7e108d73b345f064f1c19fdc87b79036a9a", size = 89631 }, ] [[package]] @@ -527,11 +527,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.16.0" +version = "3.16.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/76/3981447fd369539aba35797db99a8e2ff7ed01d9aa63e9344a31658b8d81/filelock-3.16.0.tar.gz", hash = "sha256:81de9eb8453c769b63369f87f11131a7ab04e367f8d97ad39dc230daa07e3bec", size = 18008 } +sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/95/f9310f35376024e1086c59cbb438d319fc9a4ef853289ce7c661539edbd4/filelock-3.16.0-py3-none-any.whl", hash = "sha256:f6ed4c963184f4c84dd5557ce8fece759a3724b37b80c6c4f20a2f63a4dc6609", size = 16170 }, + { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, ] [[package]] @@ -617,20 +617,20 @@ wheels = [ [[package]] name = "identify" -version = "2.6.0" +version = "2.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/32/f4/8e8f7db397a7ce20fbdeac5f25adaf567fc362472432938d25556008e03a/identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf", size = 99116 } +sdist = { url = "https://files.pythonhosted.org/packages/29/bb/25024dbcc93516c492b75919e76f389bac754a3e4248682fba32b250c880/identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98", size = 99097 } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/6c/a4f39abe7f19600b74528d0c717b52fff0b300bb0161081510d39c53cb00/identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0", size = 98962 }, + { url = "https://files.pythonhosted.org/packages/7d/0c/4ef72754c050979fdcc06c744715ae70ea37e734816bb6514f79df77a42f/identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0", size = 98972 }, ] [[package]] name = "idna" -version = "3.8" +version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/e349c5e6d4543326c6883ee9491e3921e0d07b55fdf3cce184b40d63e72a/idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603", size = 189467 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/7e/d71db821f177828df9dea8c42ac46473366f191be53080e552e628aad991/idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac", size = 66894 }, + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] [[package]] @@ -644,14 +644,14 @@ wheels = [ [[package]] name = "importlib-metadata" -version = "8.4.0" +version = "8.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/bd/fa8ce65b0a7d4b6d143ec23b0f5fd3f7ab80121078c465bc02baeaab22dc/importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5", size = 54320 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/14/362d31bf1076b21e1bcdcb0dc61944822ff263937b804a79231df2774d28/importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1", size = 26269 }, + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514 }, ] [[package]] @@ -1032,11 +1032,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.3.2" +version = "4.3.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/a0/d7cab8409cdc7d39b037c85ac46d92434fb6595432e069251b38e5c8dd0e/platformdirs-4.3.2.tar.gz", hash = "sha256:9e5e27a08aa095dd127b9f2e764d74254f482fef22b0970773bfba79d091ab8c", size = 21276 } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/8b/d497999c4017b80678017ddce745cf675489c110681ad3c84a55eddfd3e7/platformdirs-4.3.2-py3-none-any.whl", hash = "sha256:eb1c8582560b34ed4ba105009a4badf7f6f85768b30126f351328507b2beb617", size = 18417 }, + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, ] [[package]] @@ -1066,14 +1066,14 @@ wheels = [ [[package]] name = "prompt-toolkit" -version = "3.0.47" +version = "3.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/6d/0279b119dafc74c1220420028d490c4399b790fc1256998666e3a341879f/prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360", size = 425859 } +sdist = { url = "https://files.pythonhosted.org/packages/2d/4f/feb5e137aff82f7c7f3248267b97451da3644f6cdc218edfe549fb354127/prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90", size = 424684 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/23/22750c4b768f09386d1c3cc4337953e8936f48a888fa6dddfb669b2c9088/prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10", size = 386411 }, + { url = "https://files.pythonhosted.org/packages/a9/6a/fd08d94654f7e67c52ca30523a178b3f8ccc4237fce4be90d39c938a831a/prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e", size = 386595 }, ] [[package]] @@ -1102,103 +1102,103 @@ wheels = [ [[package]] name = "pydantic" -version = "2.9.1" +version = "2.9.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/15/3d989541b9c8128b96d532cfd2dd10131ddcc75a807330c00feb3d42a5bd/pydantic-2.9.1.tar.gz", hash = "sha256:1363c7d975c7036df0db2b4a61f2e062fbc0aa5ab5f2772e0ffc7191a4f4bce2", size = 768511 } +sdist = { url = "https://files.pythonhosted.org/packages/a9/b7/d9e3f12af310e1120c21603644a1cd86f59060e040ec5c3a80b8f05fae30/pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f", size = 769917 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/28/fff23284071bc1ba419635c7e86561c8b9b8cf62a5bcb459b92d7625fd38/pydantic-2.9.1-py3-none-any.whl", hash = "sha256:7aff4db5fdf3cf573d4b3c30926a510a10e19a0774d38fc4967f78beb6deb612", size = 434363 }, + { url = "https://files.pythonhosted.org/packages/df/e4/ba44652d562cbf0bf320e0f3810206149c8a4e99cdbf66da82e97ab53a15/pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12", size = 434928 }, ] [[package]] name = "pydantic-core" -version = "2.23.3" +version = "2.23.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/cc/07bec3fb337ff80eacd6028745bd858b9642f61ee58cfdbfb64451c1def0/pydantic_core-2.23.3.tar.gz", hash = "sha256:3cb0f65d8b4121c1b015c60104a685feb929a29d7cf204387c7f2688c7974690", size = 402277 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/fb/fc7077473d843fd70bd1e09177c3225be95621881765d6f7d123036fb9c7/pydantic_core-2.23.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7f10a5d1b9281392f1bf507d16ac720e78285dfd635b05737c3911637601bae6", size = 1845897 }, - { url = "https://files.pythonhosted.org/packages/92/8c/c6f1a0f72328c5687acc0847baf806c4cb31c1a9321de70c3cbcbb37cece/pydantic_core-2.23.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3c09a7885dd33ee8c65266e5aa7fb7e2f23d49d8043f089989726391dd7350c5", size = 1777037 }, - { url = "https://files.pythonhosted.org/packages/bd/fc/89e2a998218230ed8c38f0ba11d8f73947df90ac59a1e9f2fb4e1ba318a5/pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6470b5a1ec4d1c2e9afe928c6cb37eb33381cab99292a708b8cb9aa89e62429b", size = 1801481 }, - { url = "https://files.pythonhosted.org/packages/d7/f3/81a5f69ea1359633876ea2283728d0afe2ed62e028d91d747dcdfabc594e/pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9172d2088e27d9a185ea0a6c8cebe227a9139fd90295221d7d495944d2367700", size = 1807280 }, - { url = "https://files.pythonhosted.org/packages/7a/91/b20f5646d7ef7c2629744b49e6fb86f839aa676b1aa11fb3998371ac5860/pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86fc6c762ca7ac8fbbdff80d61b2c59fb6b7d144aa46e2d54d9e1b7b0e780e01", size = 2003100 }, - { url = "https://files.pythonhosted.org/packages/89/71/59172c61f2ecd4b33276774512ef31912944429fabaa0f4483151f788a35/pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0cb80fd5c2df4898693aa841425ea1727b1b6d2167448253077d2a49003e0ed", size = 2662832 }, - { url = "https://files.pythonhosted.org/packages/80/d1/c6f8e23987dc166976996a910876596635d71e529335b846880d856589fd/pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03667cec5daf43ac4995cefa8aaf58f99de036204a37b889c24a80927b629cec", size = 2057218 }, - { url = "https://files.pythonhosted.org/packages/ae/f3/f4381383b65cf16392aead51643fd5fb3feeb69972226d276ce5c6cfb948/pydantic_core-2.23.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:047531242f8e9c2db733599f1c612925de095e93c9cc0e599e96cf536aaf56ba", size = 1923455 }, - { url = "https://files.pythonhosted.org/packages/a1/8d/d845077d39e55763bdb99d64ef86f8961827f8896b6e58ce08ce6b255bde/pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5499798317fff7f25dbef9347f4451b91ac2a4330c6669821c8202fd354c7bee", size = 1966890 }, - { url = "https://files.pythonhosted.org/packages/53/f8/56355d7b1cf84df63f93b1a455ebb53fd9588edbb63a44fd4d801444a060/pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bbb5e45eab7624440516ee3722a3044b83fff4c0372efe183fd6ba678ff681fe", size = 2112163 }, - { url = "https://files.pythonhosted.org/packages/06/32/a0a7a3a318b4ae98a0e6b9e18db31fadbd3cfc46b31191e4ed4ca658e2d4/pydantic_core-2.23.3-cp310-none-win32.whl", hash = "sha256:8b5b3ed73abb147704a6e9f556d8c5cb078f8c095be4588e669d315e0d11893b", size = 1717086 }, - { url = "https://files.pythonhosted.org/packages/e3/31/38aebe234508fc30c80b4825661d3c1ef0d51b1c40a12e50855b108acd35/pydantic_core-2.23.3-cp310-none-win_amd64.whl", hash = "sha256:2b603cde285322758a0279995b5796d64b63060bfbe214b50a3ca23b5cee3e83", size = 1918933 }, - { url = "https://files.pythonhosted.org/packages/4a/60/ef8eaad365c1d94962d158633f66313e051f7b90cead647e65a96993da22/pydantic_core-2.23.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c889fd87e1f1bbeb877c2ee56b63bb297de4636661cc9bbfcf4b34e5e925bc27", size = 1843251 }, - { url = "https://files.pythonhosted.org/packages/57/f4/20aa352e03379a3b5d6c2fb951a979f70718138ea747e3f756d63dda69da/pydantic_core-2.23.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea85bda3189fb27503af4c45273735bcde3dd31c1ab17d11f37b04877859ef45", size = 1776367 }, - { url = "https://files.pythonhosted.org/packages/f1/b9/e5482ac4ea2d128925759d905fb05a08ca98e67ed1d8ab7401861997c6c8/pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7f7f72f721223f33d3dc98a791666ebc6a91fa023ce63733709f4894a7dc611", size = 1800135 }, - { url = "https://files.pythonhosted.org/packages/78/9f/387353f6b6b2ed023f973cffa4e2384bb2e52d15acf5680bc70c50f6c48f/pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b2b55b0448e9da68f56b696f313949cda1039e8ec7b5d294285335b53104b61", size = 1805896 }, - { url = "https://files.pythonhosted.org/packages/4f/70/9a153f19394e2ef749f586273ebcdb3de97e2fa97e175b957a8e5a2a77f9/pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c24574c7e92e2c56379706b9a3f07c1e0c7f2f87a41b6ee86653100c4ce343e5", size = 2001492 }, - { url = "https://files.pythonhosted.org/packages/a5/1c/79d976846fcdcae0c657922d0f476ca287fa694e69ac1fc9d397b831e1cc/pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2b05e6ccbee333a8f4b8f4d7c244fdb7a979e90977ad9c51ea31261e2085ce0", size = 2659827 }, - { url = "https://files.pythonhosted.org/packages/fd/89/cdd76ae363cabae23a4b70df50d603c81c517415ff9d5d65e72e35251cf6/pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2c409ce1c219c091e47cb03feb3c4ed8c2b8e004efc940da0166aaee8f9d6c8", size = 2055160 }, - { url = "https://files.pythonhosted.org/packages/1a/82/7d62c3dd4e2e101a81ac3fa138d986bfbad9727a6275fc2b4a5efb98bdbd/pydantic_core-2.23.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d965e8b325f443ed3196db890d85dfebbb09f7384486a77461347f4adb1fa7f8", size = 1922282 }, - { url = "https://files.pythonhosted.org/packages/85/e6/ef09f395c974d08674464dd3d49066612fe7cc0466ef8ce9427cadf13e5b/pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f56af3a420fb1ffaf43ece3ea09c2d27c444e7c40dcb7c6e7cf57aae764f2b48", size = 1965827 }, - { url = "https://files.pythonhosted.org/packages/a4/5e/e589474af850c77c3180b101b54bc98bf812ad09728ba2cff4989acc9734/pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5b01a078dd4f9a52494370af21aa52964e0a96d4862ac64ff7cea06e0f12d2c5", size = 2110810 }, - { url = "https://files.pythonhosted.org/packages/e0/ff/626007d5b7ac811f9bcac6d8af3a574ccee4505c1f015d25806101842f0c/pydantic_core-2.23.3-cp311-none-win32.whl", hash = "sha256:560e32f0df04ac69b3dd818f71339983f6d1f70eb99d4d1f8e9705fb6c34a5c1", size = 1715479 }, - { url = "https://files.pythonhosted.org/packages/4f/ff/6dc33f3b71e34ef633e35d6476d245bf303fc3eaf18a00f39bb54f78faf3/pydantic_core-2.23.3-cp311-none-win_amd64.whl", hash = "sha256:c744fa100fdea0d000d8bcddee95213d2de2e95b9c12be083370b2072333a0fa", size = 1918281 }, - { url = "https://files.pythonhosted.org/packages/8f/35/6d81bc4aa7d06e716f39e2bffb0eabcbcebaf7bab94c2f8278e277ded0ea/pydantic_core-2.23.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e0ec50663feedf64d21bad0809f5857bac1ce91deded203efc4a84b31b2e4305", size = 1845250 }, - { url = "https://files.pythonhosted.org/packages/18/42/0821cd46f76406e0fe57df7a89d6af8fddb22cce755bcc2db077773c7d1a/pydantic_core-2.23.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db6e6afcb95edbe6b357786684b71008499836e91f2a4a1e55b840955b341dbb", size = 1769993 }, - { url = "https://files.pythonhosted.org/packages/e5/55/b969088e48bd8ea588548a7194d425de74370b17b385cee4d28f5a79013d/pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98ccd69edcf49f0875d86942f4418a4e83eb3047f20eb897bffa62a5d419c8fa", size = 1791250 }, - { url = "https://files.pythonhosted.org/packages/43/c1/1d460d09c012ac76b68b2a1fd426ad624724f93b40e24a9a993763f12c61/pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a678c1ac5c5ec5685af0133262103defb427114e62eafeda12f1357a12140162", size = 1802530 }, - { url = "https://files.pythonhosted.org/packages/70/8e/fd3c9eda00fbdadca726f17a0f863ecd871a65b3a381b77277ae386d3bcd/pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01491d8b4d8db9f3391d93b0df60701e644ff0894352947f31fff3e52bd5c801", size = 1997848 }, - { url = "https://files.pythonhosted.org/packages/f0/67/13fa22d7b09395e83721edc31bae2bd5c5e2c36a09d470c18f5d1de46958/pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fcf31facf2796a2d3b7fe338fe8640aa0166e4e55b4cb108dbfd1058049bf4cb", size = 2662790 }, - { url = "https://files.pythonhosted.org/packages/fa/1b/1d689c53d15ab67cb0df1c3a2b1df873b50409581e93e4848289dce57e2f/pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7200fd561fb3be06827340da066df4311d0b6b8eb0c2116a110be5245dceb326", size = 2074114 }, - { url = "https://files.pythonhosted.org/packages/3d/d9/b565048609db77760b9a0900f6e0a3b2f33be47cd3c4a433f49653a0d2b5/pydantic_core-2.23.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc1636770a809dee2bd44dd74b89cc80eb41172bcad8af75dd0bc182c2666d4c", size = 1918153 }, - { url = "https://files.pythonhosted.org/packages/41/94/8ee55c51333ed8df3a6f1e73c6530c724a9a37d326e114c9e3b24faacff9/pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:67a5def279309f2e23014b608c4150b0c2d323bd7bccd27ff07b001c12c2415c", size = 1969019 }, - { url = "https://files.pythonhosted.org/packages/f7/49/0233bae5778a5526cef000447a93e8d462f4f13e2214c13c5b23d379cb25/pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:748bdf985014c6dd3e1e4cc3db90f1c3ecc7246ff5a3cd4ddab20c768b2f1dab", size = 2121325 }, - { url = "https://files.pythonhosted.org/packages/42/a1/2f262db2fd6f9c2c9904075a067b1764cc6f71c014be5c6c91d9de52c434/pydantic_core-2.23.3-cp312-none-win32.whl", hash = "sha256:255ec6dcb899c115f1e2a64bc9ebc24cc0e3ab097775755244f77360d1f3c06c", size = 1725252 }, - { url = "https://files.pythonhosted.org/packages/9a/00/a57937080b49500df790c4853d3e7bc605bd0784e4fcaf1a159456f37ef1/pydantic_core-2.23.3-cp312-none-win_amd64.whl", hash = "sha256:40b8441be16c1e940abebed83cd006ddb9e3737a279e339dbd6d31578b802f7b", size = 1920660 }, - { url = "https://files.pythonhosted.org/packages/e1/3c/32958c0a5d1935591b58337037a1695782e61261582d93d5a7f55441f879/pydantic_core-2.23.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6daaf5b1ba1369a22c8b050b643250e3e5efc6a78366d323294aee54953a4d5f", size = 1845068 }, - { url = "https://files.pythonhosted.org/packages/92/a1/7e628e19b78e6ffdb2c92cccbb7eca84bfd3276cee4cafcae8833452f458/pydantic_core-2.23.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d015e63b985a78a3d4ccffd3bdf22b7c20b3bbd4b8227809b3e8e75bc37f9cb2", size = 1770095 }, - { url = "https://files.pythonhosted.org/packages/bb/17/d15fd8ce143cd1abb27be924eeff3c5c0fe3b0582f703c5a5273c11e67ce/pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3fc572d9b5b5cfe13f8e8a6e26271d5d13f80173724b738557a8c7f3a8a3791", size = 1790964 }, - { url = "https://files.pythonhosted.org/packages/24/cc/37feff1792f09dc33207fbad3897373229279d1973c211f9562abfdf137d/pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6bd91345b5163ee7448bee201ed7dd601ca24f43f439109b0212e296eb5b423", size = 1802384 }, - { url = "https://files.pythonhosted.org/packages/44/d8/ca9acd7f5f044d9ff6e43d7f35aab4b1d5982b4773761eabe3317fc68e30/pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc379c73fd66606628b866f661e8785088afe2adaba78e6bbe80796baf708a63", size = 1997824 }, - { url = "https://files.pythonhosted.org/packages/35/0f/146269dba21b10d5bf86f9a7a7bbeab4ce1db06f466a1ab5ec3dec68b409/pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbdce4b47592f9e296e19ac31667daed8753c8367ebb34b9a9bd89dacaa299c9", size = 2662907 }, - { url = "https://files.pythonhosted.org/packages/5a/7d/9573f006e39cd1a7b7716d1a264e3f4f353cf0a6042c04c01c6e31666f62/pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3cf31edf405a161a0adad83246568647c54404739b614b1ff43dad2b02e6d5", size = 2073953 }, - { url = "https://files.pythonhosted.org/packages/7e/a5/25200aaafd1e97e2ec3c1eb4b357669dd93911f2eba252bc60b6ba884fff/pydantic_core-2.23.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e22b477bf90db71c156f89a55bfe4d25177b81fce4aa09294d9e805eec13855", size = 1917822 }, - { url = "https://files.pythonhosted.org/packages/3e/b4/ac069c58e3cee70c69f03693222cc173fdf740d20d53167bceafc1efc7ca/pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0a0137ddf462575d9bce863c4c95bac3493ba8e22f8c28ca94634b4a1d3e2bb4", size = 1968838 }, - { url = "https://files.pythonhosted.org/packages/d1/3d/9f96bbd6212b4b0a6dc6d037e446208d3420baba2b2b81e544094b18a859/pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:203171e48946c3164fe7691fc349c79241ff8f28306abd4cad5f4f75ed80bc8d", size = 2121468 }, - { url = "https://files.pythonhosted.org/packages/ac/50/7399d536d6600d69059a87fff89861332c97a7b3471327a3663c7576e707/pydantic_core-2.23.3-cp313-none-win32.whl", hash = "sha256:76bdab0de4acb3f119c2a4bff740e0c7dc2e6de7692774620f7452ce11ca76c8", size = 1725373 }, - { url = "https://files.pythonhosted.org/packages/24/ba/9ac8744ab636c1161c598cc5e8261379b6b0f1d63c31242bf9d5ed41ed32/pydantic_core-2.23.3-cp313-none-win_amd64.whl", hash = "sha256:37ba321ac2a46100c578a92e9a6aa33afe9ec99ffa084424291d84e456f490c1", size = 1920594 }, - { url = "https://files.pythonhosted.org/packages/b8/9c/cb69375fd9488869c4c29edf6666050ce5c88baf755926f4121aacd9f01f/pydantic_core-2.23.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:82da2f4703894134a9f000e24965df73cc103e31e8c31906cc1ee89fde72cbd8", size = 1846402 }, - { url = "https://files.pythonhosted.org/packages/b5/7d/99d47c7084e39465781552f65889f92b1673a31c179753e476385326a3b6/pydantic_core-2.23.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dd9be0a42de08f4b58a3cc73a123f124f65c24698b95a54c1543065baca8cf0e", size = 1730388 }, - { url = "https://files.pythonhosted.org/packages/80/0d/e6be39d563846de02a1a61fa942758e6d2409f5a87bb5853f65abde2470a/pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89b731f25c80830c76fdb13705c68fef6a2b6dc494402987c7ea9584fe189f5d", size = 1801656 }, - { url = "https://files.pythonhosted.org/packages/3e/4a/6d9e8ad6c95be4af18948d400284382bc7f8b00d795f2222f3f094bc4dcb/pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6de1ec30c4bb94f3a69c9f5f2182baeda5b809f806676675e9ef6b8dc936f28", size = 1807884 }, - { url = "https://files.pythonhosted.org/packages/a9/09/751832a0938384cf78ce0353d38ef350c9ecbf2ebd5dc7ff0b3b3a0f8bfd/pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb68b41c3fa64587412b104294b9cbb027509dc2f6958446c502638d481525ef", size = 2003488 }, - { url = "https://files.pythonhosted.org/packages/4b/1f/77c720b6ca179f59c44a5698163b38be58e735974db28d761b31462da42e/pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c3980f2843de5184656aab58698011b42763ccba11c4a8c35936c8dd6c7068c", size = 2664470 }, - { url = "https://files.pythonhosted.org/packages/47/71/5aa475102a31edc15bb0df9a6627de64f62b11be99be49f2a4a0d2a19eea/pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94f85614f2cba13f62c3c6481716e4adeae48e1eaa7e8bac379b9d177d93947a", size = 2057855 }, - { url = "https://files.pythonhosted.org/packages/d2/66/15d6378783e2ede05416194848030b35cf732d84cf6cb8897aa916f628a6/pydantic_core-2.23.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:510b7fb0a86dc8f10a8bb43bd2f97beb63cffad1203071dc434dac26453955cd", size = 1923691 }, - { url = "https://files.pythonhosted.org/packages/6e/c5/7172805d806012aaff6547d2c819a98bc318313d36a9b10cd48241d85fb1/pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1eba2f7ce3e30ee2170410e2171867ea73dbd692433b81a93758ab2de6c64835", size = 1967678 }, - { url = "https://files.pythonhosted.org/packages/2b/51/6e1f5b06a3e70de9ac4d14d5ddf74564c2831ed403bb86808742c26d4240/pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b259fd8409ab84b4041b7b3f24dcc41e4696f180b775961ca8142b5b21d0e70", size = 2112758 }, - { url = "https://files.pythonhosted.org/packages/3f/e5/1ee8f68f9425728541edb9df26702f95f8243c9e42f405b2a972c64edb1b/pydantic_core-2.23.3-cp39-none-win32.whl", hash = "sha256:40d9bd259538dba2f40963286009bf7caf18b5112b19d2b55b09c14dde6db6a7", size = 1716954 }, - { url = "https://files.pythonhosted.org/packages/96/67/663492ab80a625d07ca4abd3178023fa79a9f6fa1df4acc3213bff371e9d/pydantic_core-2.23.3-cp39-none-win_amd64.whl", hash = "sha256:5a8cd3074a98ee70173a8633ad3c10e00dcb991ecec57263aacb4095c5efb958", size = 1921529 }, - { url = "https://files.pythonhosted.org/packages/c0/2d/1f4ec8614225b516366f6c4c49d55ec42ebb93004c0bc9a3e0d21d0ed3c0/pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f399e8657c67313476a121a6944311fab377085ca7f490648c9af97fc732732d", size = 1834597 }, - { url = "https://files.pythonhosted.org/packages/4d/f0/665d4cd60147992b1da0f5a9d1fd7f309c7f12999e3a494c4898165c64ab/pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b5547d098c76e1694ba85f05b595720d7c60d342f24d5aad32c3049131fa5c4", size = 1721339 }, - { url = "https://files.pythonhosted.org/packages/a7/02/7b85ae2c3452e6b9f43b89482dc2a2ba771c31d86d93c2a5a250870b243b/pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0dda0290a6f608504882d9f7650975b4651ff91c85673341789a476b1159f211", size = 1794316 }, - { url = "https://files.pythonhosted.org/packages/61/09/f0fde8a9d66f37f3e08e03965a9833d71c4b5fb0287d8f625f88d79dfcd6/pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b6e5da855e9c55a0c67f4db8a492bf13d8d3316a59999cfbaf98cc6e401961", size = 1944713 }, - { url = "https://files.pythonhosted.org/packages/61/2b/0bfe144cac991700dbeaff620fed38b0565352acb342f90374ebf1350084/pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:09e926397f392059ce0afdcac920df29d9c833256354d0c55f1584b0b70cf07e", size = 1916385 }, - { url = "https://files.pythonhosted.org/packages/02/4f/7d1b8a28e4a1dd96cdde9e220627abd4d3a7860eb79cc682ccf828cf93e4/pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:87cfa0ed6b8c5bd6ae8b66de941cece179281239d482f363814d2b986b79cedc", size = 1959666 }, - { url = "https://files.pythonhosted.org/packages/5d/9a/b2c520ef627001c68cf23990b2de42ba66eae58a3f56f13375ae9aecb88d/pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e61328920154b6a44d98cabcb709f10e8b74276bc709c9a513a8c37a18786cc4", size = 2103742 }, - { url = "https://files.pythonhosted.org/packages/cd/43/b9a88a4e6454fcad63317e3dade687b68ae7d9f324c868411b1ea70218b3/pydantic_core-2.23.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce3317d155628301d649fe5e16a99528d5680af4ec7aa70b90b8dacd2d725c9b", size = 1916507 }, - { url = "https://files.pythonhosted.org/packages/e7/52/fd89a422e922174728341b594612e9c727f5c07c55e3e436dc3dd626f52d/pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e89513f014c6be0d17b00a9a7c81b1c426f4eb9224b15433f3d98c1a071f8433", size = 1835707 }, - { url = "https://files.pythonhosted.org/packages/be/14/07f8fa279d8c7b414c7e547f868dd1b9f8e76f248f49fb44c2312be62cb0/pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4f62c1c953d7ee375df5eb2e44ad50ce2f5aff931723b398b8bc6f0ac159791a", size = 1722073 }, - { url = "https://files.pythonhosted.org/packages/18/02/09c3ec4f9b270fd5af8f142b5547c396a1cb2aba6721b374f77a60e4bae4/pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2718443bc671c7ac331de4eef9b673063b10af32a0bb385019ad61dcf2cc8f6c", size = 1794805 }, - { url = "https://files.pythonhosted.org/packages/e7/5c/2ab3689816702554ac73ea5c435030be5461180d5b18f252ea7890774227/pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d90e08b2727c5d01af1b5ef4121d2f0c99fbee692c762f4d9d0409c9da6541", size = 1945670 }, - { url = "https://files.pythonhosted.org/packages/12/ef/c16db2dc939e2686b63a1cd19e80fda55fff95b7411cc3a34ca7d7d2463e/pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b676583fc459c64146debea14ba3af54e540b61762dfc0613dc4e98c3f66eeb", size = 1916745 }, - { url = "https://files.pythonhosted.org/packages/00/58/c55081fdfc1a1c26c4d90555c013bbb6193721147154b5ba3dff16c36b96/pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:50e4661f3337977740fdbfbae084ae5693e505ca2b3130a6d4eb0f2281dc43b8", size = 1960193 }, - { url = "https://files.pythonhosted.org/packages/10/0e/664177152393180ca06ed393a3d4b16804d0a98ce9ccb460c1d29950ab77/pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:68f4cf373f0de6abfe599a38307f4417c1c867ca381c03df27c873a9069cda25", size = 2104209 }, - { url = "https://files.pythonhosted.org/packages/88/6a/df8adefd9d1052c72ee98b8c50a5eb042cdb3f2fea1f4f58a16046bdac02/pydantic_core-2.23.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:59d52cf01854cb26c46958552a21acb10dd78a52aa34c86f284e66b209db8cab", size = 1917304 }, +sdist = { url = "https://files.pythonhosted.org/packages/e2/aa/6b6a9b9f8537b872f552ddd46dd3da230367754b6f707b8e1e963f515ea3/pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863", size = 402156 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/8b/d3ae387f66277bd8104096d6ec0a145f4baa2966ebb2cad746c0920c9526/pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b", size = 1867835 }, + { url = "https://files.pythonhosted.org/packages/46/76/f68272e4c3a7df8777798282c5e47d508274917f29992d84e1898f8908c7/pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166", size = 1776689 }, + { url = "https://files.pythonhosted.org/packages/cc/69/5f945b4416f42ea3f3bc9d2aaec66c76084a6ff4ff27555bf9415ab43189/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb", size = 1800748 }, + { url = "https://files.pythonhosted.org/packages/50/ab/891a7b0054bcc297fb02d44d05c50e68154e31788f2d9d41d0b72c89fdf7/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916", size = 1806469 }, + { url = "https://files.pythonhosted.org/packages/31/7c/6e3fa122075d78f277a8431c4c608f061881b76c2b7faca01d317ee39b5d/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07", size = 2002246 }, + { url = "https://files.pythonhosted.org/packages/ad/6f/22d5692b7ab63fc4acbc74de6ff61d185804a83160adba5e6cc6068e1128/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232", size = 2659404 }, + { url = "https://files.pythonhosted.org/packages/11/ac/1e647dc1121c028b691028fa61a4e7477e6aeb5132628fde41dd34c1671f/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2", size = 2053940 }, + { url = "https://files.pythonhosted.org/packages/91/75/984740c17f12c3ce18b5a2fcc4bdceb785cce7df1511a4ce89bca17c7e2d/pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f", size = 1921437 }, + { url = "https://files.pythonhosted.org/packages/a0/74/13c5f606b64d93f0721e7768cd3e8b2102164866c207b8cd6f90bb15d24f/pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3", size = 1966129 }, + { url = "https://files.pythonhosted.org/packages/18/03/9c4aa5919457c7b57a016c1ab513b1a926ed9b2bb7915bf8e506bf65c34b/pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071", size = 2110908 }, + { url = "https://files.pythonhosted.org/packages/92/2c/053d33f029c5dc65e5cf44ff03ceeefb7cce908f8f3cca9265e7f9b540c8/pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119", size = 1735278 }, + { url = "https://files.pythonhosted.org/packages/de/81/7dfe464eca78d76d31dd661b04b5f2036ec72ea8848dd87ab7375e185c23/pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f", size = 1917453 }, + { url = "https://files.pythonhosted.org/packages/5d/30/890a583cd3f2be27ecf32b479d5d615710bb926d92da03e3f7838ff3e58b/pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8", size = 1865160 }, + { url = "https://files.pythonhosted.org/packages/1d/9a/b634442e1253bc6889c87afe8bb59447f106ee042140bd57680b3b113ec7/pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d", size = 1776777 }, + { url = "https://files.pythonhosted.org/packages/75/9a/7816295124a6b08c24c96f9ce73085032d8bcbaf7e5a781cd41aa910c891/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e", size = 1799244 }, + { url = "https://files.pythonhosted.org/packages/a9/8f/89c1405176903e567c5f99ec53387449e62f1121894aa9fc2c4fdc51a59b/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607", size = 1805307 }, + { url = "https://files.pythonhosted.org/packages/d5/a5/1a194447d0da1ef492e3470680c66048fef56fc1f1a25cafbea4bc1d1c48/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd", size = 2000663 }, + { url = "https://files.pythonhosted.org/packages/13/a5/1df8541651de4455e7d587cf556201b4f7997191e110bca3b589218745a5/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea", size = 2655941 }, + { url = "https://files.pythonhosted.org/packages/44/31/a3899b5ce02c4316865e390107f145089876dff7e1dfc770a231d836aed8/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e", size = 2052105 }, + { url = "https://files.pythonhosted.org/packages/1b/aa/98e190f8745d5ec831f6d5449344c48c0627ac5fed4e5340a44b74878f8e/pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b", size = 1919967 }, + { url = "https://files.pythonhosted.org/packages/ae/35/b6e00b6abb2acfee3e8f85558c02a0822e9a8b2f2d812ea8b9079b118ba0/pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0", size = 1964291 }, + { url = "https://files.pythonhosted.org/packages/13/46/7bee6d32b69191cd649bbbd2361af79c472d72cb29bb2024f0b6e350ba06/pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64", size = 2109666 }, + { url = "https://files.pythonhosted.org/packages/39/ef/7b34f1b122a81b68ed0a7d0e564da9ccdc9a2924c8d6c6b5b11fa3a56970/pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f", size = 1732940 }, + { url = "https://files.pythonhosted.org/packages/2f/76/37b7e76c645843ff46c1d73e046207311ef298d3f7b2f7d8f6ac60113071/pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3", size = 1916804 }, + { url = "https://files.pythonhosted.org/packages/74/7b/8e315f80666194b354966ec84b7d567da77ad927ed6323db4006cf915f3f/pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231", size = 1856459 }, + { url = "https://files.pythonhosted.org/packages/14/de/866bdce10ed808323d437612aca1ec9971b981e1c52e5e42ad9b8e17a6f6/pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee", size = 1770007 }, + { url = "https://files.pythonhosted.org/packages/dc/69/8edd5c3cd48bb833a3f7ef9b81d7666ccddd3c9a635225214e044b6e8281/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87", size = 1790245 }, + { url = "https://files.pythonhosted.org/packages/80/33/9c24334e3af796ce80d2274940aae38dd4e5676298b4398eff103a79e02d/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8", size = 1801260 }, + { url = "https://files.pythonhosted.org/packages/a5/6f/e9567fd90104b79b101ca9d120219644d3314962caa7948dd8b965e9f83e/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327", size = 1996872 }, + { url = "https://files.pythonhosted.org/packages/2d/ad/b5f0fe9e6cfee915dd144edbd10b6e9c9c9c9d7a56b69256d124b8ac682e/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2", size = 2661617 }, + { url = "https://files.pythonhosted.org/packages/06/c8/7d4b708f8d05a5cbfda3243aad468052c6e99de7d0937c9146c24d9f12e9/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36", size = 2071831 }, + { url = "https://files.pythonhosted.org/packages/89/4d/3079d00c47f22c9a9a8220db088b309ad6e600a73d7a69473e3a8e5e3ea3/pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126", size = 1917453 }, + { url = "https://files.pythonhosted.org/packages/e9/88/9df5b7ce880a4703fcc2d76c8c2d8eb9f861f79d0c56f4b8f5f2607ccec8/pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e", size = 1968793 }, + { url = "https://files.pythonhosted.org/packages/e3/b9/41f7efe80f6ce2ed3ee3c2dcfe10ab7adc1172f778cc9659509a79518c43/pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24", size = 2116872 }, + { url = "https://files.pythonhosted.org/packages/63/08/b59b7a92e03dd25554b0436554bf23e7c29abae7cce4b1c459cd92746811/pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84", size = 1738535 }, + { url = "https://files.pythonhosted.org/packages/88/8d/479293e4d39ab409747926eec4329de5b7129beaedc3786eca070605d07f/pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9", size = 1917992 }, + { url = "https://files.pythonhosted.org/packages/ad/ef/16ee2df472bf0e419b6bc68c05bf0145c49247a1095e85cee1463c6a44a1/pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc", size = 1856143 }, + { url = "https://files.pythonhosted.org/packages/da/fa/bc3dbb83605669a34a93308e297ab22be82dfb9dcf88c6cf4b4f264e0a42/pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd", size = 1770063 }, + { url = "https://files.pythonhosted.org/packages/4e/48/e813f3bbd257a712303ebdf55c8dc46f9589ec74b384c9f652597df3288d/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05", size = 1790013 }, + { url = "https://files.pythonhosted.org/packages/b4/e0/56eda3a37929a1d297fcab1966db8c339023bcca0b64c5a84896db3fcc5c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d", size = 1801077 }, + { url = "https://files.pythonhosted.org/packages/04/be/5e49376769bfbf82486da6c5c1683b891809365c20d7c7e52792ce4c71f3/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510", size = 1996782 }, + { url = "https://files.pythonhosted.org/packages/bc/24/e3ee6c04f1d58cc15f37bcc62f32c7478ff55142b7b3e6d42ea374ea427c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6", size = 2661375 }, + { url = "https://files.pythonhosted.org/packages/c1/f8/11a9006de4e89d016b8de74ebb1db727dc100608bb1e6bbe9d56a3cbbcce/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b", size = 2071635 }, + { url = "https://files.pythonhosted.org/packages/7c/45/bdce5779b59f468bdf262a5bc9eecbae87f271c51aef628d8c073b4b4b4c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327", size = 1916994 }, + { url = "https://files.pythonhosted.org/packages/d8/fa/c648308fe711ee1f88192cad6026ab4f925396d1293e8356de7e55be89b5/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6", size = 1968877 }, + { url = "https://files.pythonhosted.org/packages/16/16/b805c74b35607d24d37103007f899abc4880923b04929547ae68d478b7f4/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f", size = 2116814 }, + { url = "https://files.pythonhosted.org/packages/d1/58/5305e723d9fcdf1c5a655e6a4cc2a07128bf644ff4b1d98daf7a9dbf57da/pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769", size = 1738360 }, + { url = "https://files.pythonhosted.org/packages/a5/ae/e14b0ff8b3f48e02394d8acd911376b7b66e164535687ef7dc24ea03072f/pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", size = 1919411 }, + { url = "https://files.pythonhosted.org/packages/7a/04/2580b2deaae37b3e30fc30c54298be938b973990b23612d6b61c7bdd01c7/pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a", size = 1868200 }, + { url = "https://files.pythonhosted.org/packages/39/6e/e311bd0751505350f0cdcee3077841eb1f9253c5a1ddbad048cd9fbf7c6e/pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36", size = 1749316 }, + { url = "https://files.pythonhosted.org/packages/d0/b4/95b5eb47c6dc8692508c3ca04a1f8d6f0884c9dacb34cf3357595cbe73be/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b", size = 1800880 }, + { url = "https://files.pythonhosted.org/packages/da/79/41c4f817acd7f42d94cd1e16526c062a7b089f66faed4bd30852314d9a66/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323", size = 1807077 }, + { url = "https://files.pythonhosted.org/packages/fb/53/d13d1eb0a97d5c06cf7a225935d471e9c241afd389a333f40c703f214973/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3", size = 2002859 }, + { url = "https://files.pythonhosted.org/packages/53/7d/6b8a1eff453774b46cac8c849e99455b27167971a003212f668e94bc4c9c/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df", size = 2661437 }, + { url = "https://files.pythonhosted.org/packages/6c/ea/8820f57f0b46e6148ee42d8216b15e8fe3b360944284bbc705bf34fac888/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c", size = 2054404 }, + { url = "https://files.pythonhosted.org/packages/0f/36/d4ae869e473c3c7868e1cd1e2a1b9e13bce5cd1a7d287f6ac755a0b1575e/pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55", size = 1921680 }, + { url = "https://files.pythonhosted.org/packages/0d/f8/eed5c65b80c4ac4494117e2101973b45fc655774ef647d17dde40a70f7d2/pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040", size = 1966093 }, + { url = "https://files.pythonhosted.org/packages/e8/c8/1d42ce51d65e571ab53d466cae83434325a126811df7ce4861d9d97bee4b/pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605", size = 2111437 }, + { url = "https://files.pythonhosted.org/packages/aa/c9/7fea9d13383c2ec6865919e09cffe44ab77e911eb281b53a4deaafd4c8e8/pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6", size = 1735049 }, + { url = "https://files.pythonhosted.org/packages/98/95/dd7045c4caa2b73d0bf3b989d66b23cfbb7a0ef14ce99db15677a000a953/pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29", size = 1920180 }, + { url = "https://files.pythonhosted.org/packages/13/a9/5d582eb3204464284611f636b55c0a7410d748ff338756323cb1ce721b96/pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5", size = 1857135 }, + { url = "https://files.pythonhosted.org/packages/2c/57/faf36290933fe16717f97829eabfb1868182ac495f99cf0eda9f59687c9d/pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec", size = 1740583 }, + { url = "https://files.pythonhosted.org/packages/91/7c/d99e3513dc191c4fec363aef1bf4c8af9125d8fa53af7cb97e8babef4e40/pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480", size = 1793637 }, + { url = "https://files.pythonhosted.org/packages/29/18/812222b6d18c2d13eebbb0f7cdc170a408d9ced65794fdb86147c77e1982/pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068", size = 1941963 }, + { url = "https://files.pythonhosted.org/packages/0f/36/c1f3642ac3f05e6bb4aec3ffc399fa3f84895d259cf5f0ce3054b7735c29/pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801", size = 1915332 }, + { url = "https://files.pythonhosted.org/packages/f7/ca/9c0854829311fb446020ebb540ee22509731abad886d2859c855dd29b904/pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728", size = 1957926 }, + { url = "https://files.pythonhosted.org/packages/c0/1c/7836b67c42d0cd4441fcd9fafbf6a027ad4b79b6559f80cf11f89fd83648/pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433", size = 2100342 }, + { url = "https://files.pythonhosted.org/packages/a9/f9/b6bcaf874f410564a78908739c80861a171788ef4d4f76f5009656672dfe/pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753", size = 1920344 }, + { url = "https://files.pythonhosted.org/packages/32/fd/ac9cdfaaa7cf2d32590b807d900612b39acb25e5527c3c7e482f0553025b/pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21", size = 1857850 }, + { url = "https://files.pythonhosted.org/packages/08/fe/038f4b2bcae325ea643c8ad353191187a4c92a9c3b913b139289a6f2ef04/pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb", size = 1740265 }, + { url = "https://files.pythonhosted.org/packages/51/14/b215c9c3cbd1edaaea23014d4b3304260823f712d3fdee52549b19b25d62/pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59", size = 1793912 }, + { url = "https://files.pythonhosted.org/packages/62/de/2c3ad79b63ba564878cbce325be725929ba50089cd5156f89ea5155cb9b3/pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577", size = 1942870 }, + { url = "https://files.pythonhosted.org/packages/cb/55/c222af19e4644c741b3f3fe4fd8bbb6b4cdca87d8a49258b61cf7826b19e/pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744", size = 1915610 }, + { url = "https://files.pythonhosted.org/packages/c4/7a/9a8760692a6f76bb54bcd43f245ff3d8b603db695899bbc624099c00af80/pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef", size = 1958403 }, + { url = "https://files.pythonhosted.org/packages/4c/91/9b03166feb914bb5698e2f6499e07c2617e2eebf69f9374d0358d7eb2009/pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8", size = 2101154 }, + { url = "https://files.pythonhosted.org/packages/1d/d9/1d7ecb98318da4cb96986daaf0e20d66f1651d0aeb9e2d4435b916ce031d/pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e", size = 1920855 }, ] [[package]] @@ -1342,7 +1342,7 @@ wheels = [ [[package]] name = "python-kasa" -version = "0.7.3" +version = "0.7.4" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -1695,25 +1695,25 @@ wheels = [ [[package]] name = "urllib3" -version = "2.2.2" +version = "2.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/6d/fa469ae21497ddc8bc93e5877702dca7cb8f911e337aca7452b5724f1bb6/urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168", size = 292266 } +sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/1c/89ffc63a9605b583d5df2be791a27bc1a42b7c32bab68d3c8f2f73a98cd4/urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", size = 121444 }, + { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, ] [[package]] name = "virtualenv" -version = "20.26.4" +version = "20.26.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/8a/134f65c3d6066153b84fc176c58877acd8165ed0b79a149ff50502597284/virtualenv-20.26.4.tar.gz", hash = "sha256:c17f4e0f3e6036e9f26700446f85c76ab11df65ff6d8a9cbfad9f71aabfcf23c", size = 9385017 } +sdist = { url = "https://files.pythonhosted.org/packages/3f/40/abc5a766da6b0b2457f819feab8e9203cbeae29327bd241359f866a3da9d/virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48", size = 9372482 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/ea/12f774a18b55754c730c8383dad8f10d7b87397d1cb6b2b944c87381bb3b/virtualenv-20.26.4-py3-none-any.whl", hash = "sha256:48f2695d9809277003f30776d155615ffc11328e6a0a8c1f0ec80188d7874a55", size = 6013327 }, + { url = "https://files.pythonhosted.org/packages/59/90/57b8ac0c8a231545adc7698c64c5a36fa7cd8e376c691b9bde877269f2eb/virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2", size = 5999862 }, ] [[package]] @@ -1745,97 +1745,97 @@ wheels = [ [[package]] name = "yarl" -version = "1.11.1" +version = "1.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/3d/4924f9ed49698bac5f112bc9b40aa007bbdcd702462c1df3d2e1383fb158/yarl-1.11.1.tar.gz", hash = "sha256:1bb2d9e212fb7449b8fb73bc461b51eaa17cc8430b4a87d87be7b25052d92f53", size = 162095 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/a3/4e67b1463c12ba178aace33b62468377473c77b33a95bcb12b67b2b93817/yarl-1.11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:400cd42185f92de559d29eeb529e71d80dfbd2f45c36844914a4a34297ca6f00", size = 188473 }, - { url = "https://files.pythonhosted.org/packages/f3/86/c0c76e69a390fb43533783582714e8a58003f443b81cac1605ce71cade00/yarl-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8258c86f47e080a258993eed877d579c71da7bda26af86ce6c2d2d072c11320d", size = 114362 }, - { url = "https://files.pythonhosted.org/packages/07/ef/e6bee78c1bf432de839148fe9fdc1cf5e7fbd6402d8b0b7d7a1522fb9733/yarl-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2164cd9725092761fed26f299e3f276bb4b537ca58e6ff6b252eae9631b5c96e", size = 112537 }, - { url = "https://files.pythonhosted.org/packages/37/f4/3406e76ed71e4d3023dbae4514513a387e2e753cb8a4cadd6ff9ba08a046/yarl-1.11.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08ea567c16f140af8ddc7cb58e27e9138a1386e3e6e53982abaa6f2377b38cc", size = 442573 }, - { url = "https://files.pythonhosted.org/packages/37/15/98b4951271a693142e551fea24bca1e96be71b5256b3091dbe8433532a45/yarl-1.11.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:768ecc550096b028754ea28bf90fde071c379c62c43afa574edc6f33ee5daaec", size = 468046 }, - { url = "https://files.pythonhosted.org/packages/88/1a/f10b88c4d8200708cbc799aad978a37a0ab15a4a72511c60bed11ee585c4/yarl-1.11.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2909fa3a7d249ef64eeb2faa04b7957e34fefb6ec9966506312349ed8a7e77bf", size = 462124 }, - { url = "https://files.pythonhosted.org/packages/02/a3/97b527b5c4551c3b17fd095fe019435664330060b3879c8c1ae80985d4bc/yarl-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01a8697ec24f17c349c4f655763c4db70eebc56a5f82995e5e26e837c6eb0e49", size = 446807 }, - { url = "https://files.pythonhosted.org/packages/40/06/da47aae54f1bb8ac0668d68bbdde40ba761643f253b2c16fdb4362af8ca3/yarl-1.11.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e286580b6511aac7c3268a78cdb861ec739d3e5a2a53b4809faef6b49778eaff", size = 431778 }, - { url = "https://files.pythonhosted.org/packages/ba/a1/54992cd68f61c11d975184f4c8a4c7f43a838e7c6ce183030a3fc0a257a6/yarl-1.11.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4179522dc0305c3fc9782549175c8e8849252fefeb077c92a73889ccbcd508ad", size = 443702 }, - { url = "https://files.pythonhosted.org/packages/5c/8b/adf290dc272a1a30a0e9dc04e2e62486be80f371bd9da2e9899f8e6181f3/yarl-1.11.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:27fcb271a41b746bd0e2a92182df507e1c204759f460ff784ca614e12dd85145", size = 448289 }, - { url = "https://files.pythonhosted.org/packages/fc/98/e6ad935fa009890b9ef2769266dc9dceaeee5a7f9a57bc7daf50b5b6c305/yarl-1.11.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f61db3b7e870914dbd9434b560075e0366771eecbe6d2b5561f5bc7485f39efd", size = 471660 }, - { url = "https://files.pythonhosted.org/packages/91/5d/1ad82849ce3c02661395f5097878c58ecabc4dac5d2d98e4f85949386448/yarl-1.11.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:c92261eb2ad367629dc437536463dc934030c9e7caca861cc51990fe6c565f26", size = 469830 }, - { url = "https://files.pythonhosted.org/packages/e0/70/376046a7f69cfec814b97fb8bf1af6f16dcbe37fd0ef89a9f87b04156923/yarl-1.11.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d95b52fbef190ca87d8c42f49e314eace4fc52070f3dfa5f87a6594b0c1c6e46", size = 457671 }, - { url = "https://files.pythonhosted.org/packages/33/49/825f84f9a5d26d26fbf82531cee3923f356e2d8efc1819b85ada508fa91f/yarl-1.11.1-cp310-cp310-win32.whl", hash = "sha256:489fa8bde4f1244ad6c5f6d11bb33e09cf0d1d0367edb197619c3e3fc06f3d91", size = 101184 }, - { url = "https://files.pythonhosted.org/packages/b0/29/2a08a45b9f2eddd1b840813698ee655256f43b507c12f7f86df947cf5f8f/yarl-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:476e20c433b356e16e9a141449f25161e6b69984fb4cdbd7cd4bd54c17844998", size = 110175 }, - { url = "https://files.pythonhosted.org/packages/af/f1/f3e6be722461cab1e7c6aea657685897956d6e4743940d685d167914e31c/yarl-1.11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:946eedc12895873891aaceb39bceb484b4977f70373e0122da483f6c38faaa68", size = 188410 }, - { url = "https://files.pythonhosted.org/packages/4b/c1/21cc66b263fdc2ec10b6459aed5b239f07eed91a77438d88f0e1bd70e202/yarl-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:21a7c12321436b066c11ec19c7e3cb9aec18884fe0d5b25d03d756a9e654edfe", size = 114293 }, - { url = "https://files.pythonhosted.org/packages/31/7a/0ecab63a166a22357772f4a2852c859e2d5a7b02a5c58803458dd516e6b4/yarl-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c35f493b867912f6fda721a59cc7c4766d382040bdf1ddaeeaa7fa4d072f4675", size = 112548 }, - { url = "https://files.pythonhosted.org/packages/57/5d/78152026864475e841fdae816499345364c8e364b45ea6accd0814a295f0/yarl-1.11.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25861303e0be76b60fddc1250ec5986c42f0a5c0c50ff57cc30b1be199c00e63", size = 485002 }, - { url = "https://files.pythonhosted.org/packages/d3/70/2e880d74aeb4908d45c6403e46bbd4aa866ae31ddb432318d9b8042fe0f6/yarl-1.11.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4b53f73077e839b3f89c992223f15b1d2ab314bdbdf502afdc7bb18e95eae27", size = 504850 }, - { url = "https://files.pythonhosted.org/packages/06/58/5676a47b6d2751853f89d1d68b6a54d725366da6a58482f2410fa7eb38af/yarl-1.11.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:327c724b01b8641a1bf1ab3b232fb638706e50f76c0b5bf16051ab65c868fac5", size = 499291 }, - { url = "https://files.pythonhosted.org/packages/4d/e5/b56d535703a63a8d86ac82059e630e5ba9c0d5626d9c5ac6af53eed815c2/yarl-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4307d9a3417eea87715c9736d050c83e8c1904e9b7aada6ce61b46361b733d92", size = 487818 }, - { url = "https://files.pythonhosted.org/packages/f3/b4/6b95e1e0983593f4145518980b07126a27e2a4938cb6afb8b592ce6fc2c9/yarl-1.11.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48a28bed68ab8fb7e380775f0029a079f08a17799cb3387a65d14ace16c12e2b", size = 470447 }, - { url = "https://files.pythonhosted.org/packages/a8/e5/5d349b7b04ed4247d4f717f271fce601a79d10e2ac81166c13f97c4973a9/yarl-1.11.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:067b961853c8e62725ff2893226fef3d0da060656a9827f3f520fb1d19b2b68a", size = 484544 }, - { url = "https://files.pythonhosted.org/packages/fa/dc/ce90e9d85ef2233e81148a9658e4ea8372c6de070ce96c5c8bd3ff365144/yarl-1.11.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8215f6f21394d1f46e222abeb06316e77ef328d628f593502d8fc2a9117bde83", size = 482409 }, - { url = "https://files.pythonhosted.org/packages/4c/a1/17c0a03615b0cd213aee2e318a0fbd3d07259c37976d85af9eec6184c589/yarl-1.11.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:498442e3af2a860a663baa14fbf23fb04b0dd758039c0e7c8f91cb9279799bff", size = 512970 }, - { url = "https://files.pythonhosted.org/packages/6c/ed/1e317799d54c79a3e4846db597510f5c84fb7643bb8703a3848136d40809/yarl-1.11.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:69721b8effdb588cb055cc22f7c5105ca6fdaa5aeb3ea09021d517882c4a904c", size = 515203 }, - { url = "https://files.pythonhosted.org/packages/7a/37/9a4e2d73953956fa686fa0f0c4a0881245f39423fa75875d981b4f680611/yarl-1.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e969fa4c1e0b1a391f3fcbcb9ec31e84440253325b534519be0d28f4b6b533e", size = 497323 }, - { url = "https://files.pythonhosted.org/packages/a3/c3/a25ae9c85c0e50a8722aecc486ac5ba53b28d1384548df99b2145cb69862/yarl-1.11.1-cp311-cp311-win32.whl", hash = "sha256:7d51324a04fc4b0e097ff8a153e9276c2593106a811704025bbc1d6916f45ca6", size = 101226 }, - { url = "https://files.pythonhosted.org/packages/90/6d/c62ba0ae0232a0b0012706a7735a16b44a03216fedfb6ea0bcda79d1e12c/yarl-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:15061ce6584ece023457fb8b7a7a69ec40bf7114d781a8c4f5dcd68e28b5c53b", size = 110471 }, - { url = "https://files.pythonhosted.org/packages/3b/05/379002019a0c9d5dc0c4cc6f71e324ea43461ae6f58e94ee87e07b8ffa90/yarl-1.11.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a4264515f9117be204935cd230fb2a052dd3792789cc94c101c535d349b3dab0", size = 189044 }, - { url = "https://files.pythonhosted.org/packages/23/d5/e62cfba5ceaaf92ee4f9af6f9c9ab2f2b47d8ad48687fa69570a93b0872c/yarl-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f41fa79114a1d2eddb5eea7b912d6160508f57440bd302ce96eaa384914cd265", size = 114867 }, - { url = "https://files.pythonhosted.org/packages/b1/10/6abc0bd7e7fe7c6b9b9e9ce0ff558912c9ecae65a798f5442020ef9e4177/yarl-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:02da8759b47d964f9173c8675710720b468aa1c1693be0c9c64abb9d8d9a4867", size = 112737 }, - { url = "https://files.pythonhosted.org/packages/37/a5/ad026afde5efe1849f4f55bd9f9a2cb5b006511b324db430ae5336104fb3/yarl-1.11.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9361628f28f48dcf8b2f528420d4d68102f593f9c2e592bfc842f5fb337e44fd", size = 482887 }, - { url = "https://files.pythonhosted.org/packages/f8/82/b8bee972617b800319b4364cfcd69bfaf7326db052e91a56e63986cc3e05/yarl-1.11.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b91044952da03b6f95fdba398d7993dd983b64d3c31c358a4c89e3c19b6f7aef", size = 498635 }, - { url = "https://files.pythonhosted.org/packages/af/ad/ac688503b134e02e8505415f0b8e94dc8e92a97e82abdd9736658389b5ae/yarl-1.11.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74db2ef03b442276d25951749a803ddb6e270d02dda1d1c556f6ae595a0d76a8", size = 496198 }, - { url = "https://files.pythonhosted.org/packages/ce/f2/b6cae0ad1afed6e95f82ab2cb9eb5b63e41f1463ece2a80c39d80cf6167a/yarl-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e975a2211952a8a083d1b9d9ba26472981ae338e720b419eb50535de3c02870", size = 489068 }, - { url = "https://files.pythonhosted.org/packages/c8/f4/355e69b5563154b40550233ffba8f6099eac0c99788600191967763046cf/yarl-1.11.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aef97ba1dd2138112890ef848e17d8526fe80b21f743b4ee65947ea184f07a2", size = 468286 }, - { url = "https://files.pythonhosted.org/packages/26/3d/3c37f3f150faf87b086f7915724f2fcb9ff2f7c9d3f6c0f42b7722bd9b77/yarl-1.11.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a7915ea49b0c113641dc4d9338efa9bd66b6a9a485ffe75b9907e8573ca94b84", size = 484568 }, - { url = "https://files.pythonhosted.org/packages/94/ee/d591abbaea3b14e0f68bdec5cbcb75f27107190c51889d518bafe5d8f120/yarl-1.11.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:504cf0d4c5e4579a51261d6091267f9fd997ef58558c4ffa7a3e1460bd2336fa", size = 484947 }, - { url = "https://files.pythonhosted.org/packages/57/70/ad1c65a13315f03ff0c63fd6359dd40d8198e2a42e61bf86507602a0364f/yarl-1.11.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3de5292f9f0ee285e6bd168b2a77b2a00d74cbcfa420ed078456d3023d2f6dff", size = 505610 }, - { url = "https://files.pythonhosted.org/packages/4c/8c/6086dec0f8d7df16d136b38f373c49cf3d2fb94464e5a10bf788b36f3f54/yarl-1.11.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a34e1e30f1774fa35d37202bbeae62423e9a79d78d0874e5556a593479fdf239", size = 515951 }, - { url = "https://files.pythonhosted.org/packages/49/79/e0479e9a3bbb7bdcb82779d89711b97cea30902a4bfe28d681463b7071ce/yarl-1.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:66b63c504d2ca43bf7221a1f72fbe981ff56ecb39004c70a94485d13e37ebf45", size = 501273 }, - { url = "https://files.pythonhosted.org/packages/8e/85/eab962453e81073276b22f3d1503dffe6bfc3eb9cd0f31899970de05d490/yarl-1.11.1-cp312-cp312-win32.whl", hash = "sha256:a28b70c9e2213de425d9cba5ab2e7f7a1c8ca23a99c4b5159bf77b9c31251447", size = 101139 }, - { url = "https://files.pythonhosted.org/packages/5d/de/618b3e5cab10af8a2ed3eb625dac61c1d16eb155d1f56f9fdb3500786c12/yarl-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:17b5a386d0d36fb828e2fb3ef08c8829c1ebf977eef88e5367d1c8c94b454639", size = 110504 }, - { url = "https://files.pythonhosted.org/packages/07/b7/948e4f427817e0178f3737adf6712fea83f76921e11e2092f403a8a9dc4a/yarl-1.11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1fa2e7a406fbd45b61b4433e3aa254a2c3e14c4b3186f6e952d08a730807fa0c", size = 185061 }, - { url = "https://files.pythonhosted.org/packages/f3/67/8d91ad79a3b907b4fef27fafa912350554443ba53364fff3c347b41105cb/yarl-1.11.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:750f656832d7d3cb0c76be137ee79405cc17e792f31e0a01eee390e383b2936e", size = 113056 }, - { url = "https://files.pythonhosted.org/packages/a1/77/6b2348a753702fa87f435cc33dcec21981aaca8ef98a46566a7b29940b4a/yarl-1.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b8486f322d8f6a38539136a22c55f94d269addb24db5cb6f61adc61eabc9d93", size = 110958 }, - { url = "https://files.pythonhosted.org/packages/8e/3e/6eadf32656741549041f549a392f3b15245d3a0a0b12a9bc22bd6b69621f/yarl-1.11.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fce4da3703ee6048ad4138fe74619c50874afe98b1ad87b2698ef95bf92c96d", size = 470326 }, - { url = "https://files.pythonhosted.org/packages/3d/a4/1b641a8c7899eeaceec45ff105a2e7206ec0eb0fb9d86403963cc8521c5e/yarl-1.11.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ed653638ef669e0efc6fe2acb792275cb419bf9cb5c5049399f3556995f23c7", size = 484778 }, - { url = "https://files.pythonhosted.org/packages/8a/f5/80c142f34779a5c26002b2bf1f73b9a9229aa9e019ee6f9fd9d3e9704e78/yarl-1.11.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18ac56c9dd70941ecad42b5a906820824ca72ff84ad6fa18db33c2537ae2e089", size = 485568 }, - { url = "https://files.pythonhosted.org/packages/f8/f2/6b40ffea2d5d3a11f514ab23c30d14f52600c36a3210786f5974b6701bb8/yarl-1.11.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:688654f8507464745ab563b041d1fb7dab5d9912ca6b06e61d1c4708366832f5", size = 477801 }, - { url = "https://files.pythonhosted.org/packages/4c/1a/e60c116f3241e4842ed43c104eb2751abe02f6bac0301cdae69e4fda9c3a/yarl-1.11.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4973eac1e2ff63cf187073cd4e1f1148dcd119314ab79b88e1b3fad74a18c9d5", size = 455361 }, - { url = "https://files.pythonhosted.org/packages/b9/98/fe0aeee425a4bc5cd3ed86e867661d2bfa782544fa07a8e3dcd97d51ae3d/yarl-1.11.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:964a428132227edff96d6f3cf261573cb0f1a60c9a764ce28cda9525f18f7786", size = 473893 }, - { url = "https://files.pythonhosted.org/packages/6b/9b/677455d146bd3cecd350673f0e4bb28854af66726493ace3b640e9c5552b/yarl-1.11.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6d23754b9939cbab02c63434776df1170e43b09c6a517585c7ce2b3d449b7318", size = 476407 }, - { url = "https://files.pythonhosted.org/packages/33/ca/ce85766247a9a9b56654428fb78a3e14ea6947a580a9c4e891b3aa7da322/yarl-1.11.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c2dc4250fe94d8cd864d66018f8344d4af50e3758e9d725e94fecfa27588ff82", size = 490848 }, - { url = "https://files.pythonhosted.org/packages/6d/d6/717f0f19bcf2c4705ad95550b4b6319a0d8d1d4f137ea5e223207f00df50/yarl-1.11.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09696438cb43ea6f9492ef237761b043f9179f455f405279e609f2bc9100212a", size = 501084 }, - { url = "https://files.pythonhosted.org/packages/14/b5/b93c70d9a462b802c8df65c64b85f49d86b4ba70c393fbad95cf7ec053cb/yarl-1.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:999bfee0a5b7385a0af5ffb606393509cfde70ecca4f01c36985be6d33e336da", size = 491776 }, - { url = "https://files.pythonhosted.org/packages/03/0f/5a52eaa402a6a93265ba82f42c6f6085ccbe483e1b058ad34207e75812b1/yarl-1.11.1-cp313-cp313-win32.whl", hash = "sha256:ce928c9c6409c79e10f39604a7e214b3cb69552952fbda8d836c052832e6a979", size = 485250 }, - { url = "https://files.pythonhosted.org/packages/dd/97/946d26a5d82706a6769399cabd472c59f9a3227ce1432afb4739b9c29572/yarl-1.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:501c503eed2bb306638ccb60c174f856cc3246c861829ff40eaa80e2f0330367", size = 492590 }, - { url = "https://files.pythonhosted.org/packages/66/dd/c65267bc85cb3726d3b0b3d0b50f96d868fe90cc5e5fccd28608d0eea241/yarl-1.11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:884eab2ce97cbaf89f264372eae58388862c33c4f551c15680dd80f53c89a269", size = 191166 }, - { url = "https://files.pythonhosted.org/packages/97/32/a54abf14f380e99b29fc0f03b310934f10b4fcd567264e95dc0134812f5f/yarl-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a336eaa7ee7e87cdece3cedb395c9657d227bfceb6781295cf56abcd3386a26", size = 115912 }, - { url = "https://files.pythonhosted.org/packages/41/e9/8ecf8233b016389b94ab520f6eb178f00586eef5e4b6bd550cef8a227b41/yarl-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87f020d010ba80a247c4abc335fc13421037800ca20b42af5ae40e5fd75e7909", size = 113835 }, - { url = "https://files.pythonhosted.org/packages/e7/68/7409309a0b3aec7ae1a18dd5a09cd9447e018421f5f79d6fd29930f347d5/yarl-1.11.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:637c7ddb585a62d4469f843dac221f23eec3cbad31693b23abbc2c366ad41ff4", size = 449180 }, - { url = "https://files.pythonhosted.org/packages/60/73/9862f1d7c836a6f6681e4b6d5f8037b09bd77e3af347fd5ec88b395f140a/yarl-1.11.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48dfd117ab93f0129084577a07287376cc69c08138694396f305636e229caa1a", size = 476414 }, - { url = "https://files.pythonhosted.org/packages/c8/30/7a76451b7621317c61d7bd1f29ea1936a96558738e20a57beb8ed8e6c13b/yarl-1.11.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e0ae31fb5ccab6eda09ba1494e87eb226dcbd2372dae96b87800e1dcc98804", size = 469102 }, - { url = "https://files.pythonhosted.org/packages/ff/be/78953a3d5154b974af49ce367f1a8d4751ababdf26a66ae607b4ae625d99/yarl-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f46f81501160c28d0c0b7333b4f7be8983dbbc161983b6fb814024d1b4952f79", size = 453981 }, - { url = "https://files.pythonhosted.org/packages/36/10/36ad3a314d7791439bb8e580997a4952f236d4a6fc741128bd040edc91fc/yarl-1.11.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:04293941646647b3bfb1719d1d11ff1028e9c30199509a844da3c0f5919dc520", size = 438686 }, - { url = "https://files.pythonhosted.org/packages/8e/8d/f118c3b744514e71dd93c755c393705a034e1ebec7525c6598c941b8d1ea/yarl-1.11.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:250e888fa62d73e721f3041e3a9abf427788a1934b426b45e1b92f62c1f68366", size = 450933 }, - { url = "https://files.pythonhosted.org/packages/1d/6e/9eb29d7291253d7ae94c292e30fe7bfa4236439a4f97d736e72aee9cca26/yarl-1.11.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e8f63904df26d1a66aabc141bfd258bf738b9bc7bc6bdef22713b4f5ef789a4c", size = 454471 }, - { url = "https://files.pythonhosted.org/packages/16/08/8f450fee2be068e06c683ec1e43b25f335fa4e1f2f468dd0cf23ea2e348c/yarl-1.11.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:aac44097d838dda26526cffb63bdd8737a2dbdf5f2c68efb72ad83aec6673c7e", size = 480037 }, - { url = "https://files.pythonhosted.org/packages/3f/57/37f89174e82534f4c77e301d046481315619cd36cf2866ac993993c10d46/yarl-1.11.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:267b24f891e74eccbdff42241c5fb4f974de2d6271dcc7d7e0c9ae1079a560d9", size = 477252 }, - { url = "https://files.pythonhosted.org/packages/98/76/83d5888796b061519697c96a134e39e403be0da549a23b594814a5107d36/yarl-1.11.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6907daa4b9d7a688063ed098c472f96e8181733c525e03e866fb5db480a424df", size = 465282 }, - { url = "https://files.pythonhosted.org/packages/e4/05/4087620c4a73f4acaccc2a3ec4600760418db4f5e295e380d5b6e83ce684/yarl-1.11.1-cp39-cp39-win32.whl", hash = "sha256:14438dfc5015661f75f85bc5adad0743678eefee266ff0c9a8e32969d5d69f74", size = 102205 }, - { url = "https://files.pythonhosted.org/packages/d8/6a/db7c41f53faa10df3e52bd10be5bab6bf9018f0c90e0e5b06b48fd83e12a/yarl-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:94d0caaa912bfcdc702a4204cd5e2bb01eb917fc4f5ea2315aa23962549561b0", size = 111338 }, - { url = "https://files.pythonhosted.org/packages/5b/b3/841f7d706137bdc8b741c6826106b6f703155076d58f1830f244da857451/yarl-1.11.1-py3-none-any.whl", hash = "sha256:72bf26f66456baa0584eff63e44545c9f0eaed9b73cb6601b647c91f14c11f38", size = 38648 }, +sdist = { url = "https://files.pythonhosted.org/packages/27/6e/b26e831b6abede32fba3763131eb2c52987f574277daa65e10a5fda6021c/yarl-1.13.0.tar.gz", hash = "sha256:02f117a63d11c8c2ada229029f8bb444a811e62e5041da962de548f26ac2c40f", size = 165688 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/e8/5eb11fc80f93aadb4da491bff2f8ad8fced64fd4415dd4ecd32252fe3c12/yarl-1.13.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:66c028066be36d54e7a0a38e832302b23222e75db7e65ed862dc94effc8ef062", size = 189620 }, + { url = "https://files.pythonhosted.org/packages/ee/93/bd1545ff3d1d2087ac769d5b4b03204b03591136409e5188b73a5689f575/yarl-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:517f9d90ca0224bb7002266eba6e70d8fcc8b1d0c9321de2407e41344413ed46", size = 115525 }, + { url = "https://files.pythonhosted.org/packages/50/a1/9cf139b0e89c1a8deed77b5d40b998bee707b0e53629487f2c34348a72f4/yarl-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5378cb60f4209505f6aa60423c174336bd7b22e0d8beb87a2a99ad50787f1341", size = 113695 }, + { url = "https://files.pythonhosted.org/packages/e4/3f/45a2ed60a32db65cf6d3dcdc3b37c235f44728a1d49d4fc14a16ac1e0a0e/yarl-1.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0675a9cf65176e11692b20a516d5f744849251aa24024f422582d2d1bf7c8c82", size = 443623 }, + { url = "https://files.pythonhosted.org/packages/f2/48/1321e9c514e798c89446b1ff6867e8cc6285ce014a4dac6de68de04fd71a/yarl-1.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:419c22b419034b4ee3ba1c27cbbfef01ca8d646f9292f614f008093143334cdc", size = 469118 }, + { url = "https://files.pythonhosted.org/packages/a0/d4/2e401fc232de4dc566b02e6c19cb043b33ecd249663f6ace3654f484dc4e/yarl-1.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aaf10e525e461f43831d82149d904f35929d89f3ccd65beaf7422aecd500dd39", size = 463210 }, + { url = "https://files.pythonhosted.org/packages/e8/98/cb9082e0270f47678ac155f41fa1f0a1b607181bcb923dacd542595c6520/yarl-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d78ebad57152d301284761b03a708aeac99c946a64ba967d47cbcc040e36688b", size = 447903 }, + { url = "https://files.pythonhosted.org/packages/ad/bd/371a1824a185923b3829cb3f50328012269d86b3a17644e9a0b36e3ea0d5/yarl-1.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e480a12cec58009eeaeee7f48728dc8f629f8e0f280d84957d42c361969d84da", size = 432828 }, + { url = "https://files.pythonhosted.org/packages/28/6a/f5b6cbf40012974cf86c1174d23cae0cadc0bf78ead244222cb5f22f3bec/yarl-1.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e5462756fb34c884ca9d4875b6d2ec80957a767123151c467c97a9b423617048", size = 444771 }, + { url = "https://files.pythonhosted.org/packages/3b/34/370e8ce2763c4d2328ee12a0b0ada01d480ad1f9f6019489cf36dfe98595/yarl-1.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:bff0d468664cdf7b2a6bfd5e17d4a7025edb52df12e0e6e17223387b421d425c", size = 449360 }, + { url = "https://files.pythonhosted.org/packages/06/70/5d565edeb49ea5321a5cdf95824ba61b02802e0e082b9e36f10ef849ac5e/yarl-1.13.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ffd8a9758b5df7401a49d50e76491f4c582cf7350365439563062cdff45bf16", size = 472735 }, + { url = "https://files.pythonhosted.org/packages/82/c1/9b65e5771ddff3838241f6c9b1dcd65620d6a218fad9a4aeb62a99867a16/yarl-1.13.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ca71238af0d247d07747cb7202a9359e6e1d6d9e277041e1ad2d9f36b3a111a6", size = 470916 }, + { url = "https://files.pythonhosted.org/packages/e6/2f/d9f7a79cb1d079d947f1202d778f75063102146c7b06597c168d8dcb063a/yarl-1.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fda4404bbb6f91e327827f4483d52fe24f02f92de91217954cf51b1cb9ee9c41", size = 458737 }, + { url = "https://files.pythonhosted.org/packages/59/d6/08ec9b926e6a6254ed1b6178817193c5a93d43ba01888df037c3470b3973/yarl-1.13.0-cp310-cp310-win32.whl", hash = "sha256:e557e2681b47a0ecfdfbea44743b3184d94d31d5ce0e4b13ff64ce227a40f86e", size = 102349 }, + { url = "https://files.pythonhosted.org/packages/6b/9a/2980744994bbbf3a04c3b487044978a9c174367ca9a81c676ced01f5b12f/yarl-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:3590ed9c7477059aea067a58ec87b433bbd47a2ceb67703b1098cca1ba075f0d", size = 111343 }, + { url = "https://files.pythonhosted.org/packages/d5/78/59418fa1cc0ef69cef153b4e6163b1a3850d129a45b92aad8f9d244ac879/yarl-1.13.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8986fa2be78193dc8b8c27bd0d3667fe612f7232844872714c4200499d5225ca", size = 189559 }, + { url = "https://files.pythonhosted.org/packages/9a/41/af4aa6046a4da16b32768bd788ac331c8397ac264b336ed5695c591f198b/yarl-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0db15ce35dfd100bc9ab40173f143fbea26c84d7458d63206934fe5548fae25d", size = 115451 }, + { url = "https://files.pythonhosted.org/packages/8a/50/1496bff64799e82c06852d5b60d29d9d60d4c4fdebf8f5b1fae505d1a10a/yarl-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:49bee8c99586482a238a7b2ec0ef94e5f186bfdbb8204d14a3dd31867b3875ce", size = 113706 }, + { url = "https://files.pythonhosted.org/packages/ff/17/a68f080c08edb27b8b5a62d418ed9ba1d90242af6792c6c2138180923265/yarl-1.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c73e0f8375b75806b8771890580566a2e6135e6785250840c4f6c45b69eb72d", size = 486063 }, + { url = "https://files.pythonhosted.org/packages/ed/2e/fce9be4ff0df5a112e9007e587acee326ec89b98286fba221e8337e969fe/yarl-1.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ab16c9e94726fdfcbf5b37a641c9d9d0b35cc31f286a2c3b9cad6451cb53b2b", size = 505935 }, + { url = "https://files.pythonhosted.org/packages/f0/c9/a797a46a28c2d68a600ec02c601014cd545a2ca33db34d1b8fc0ae854396/yarl-1.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:784d6e50ea96b3bbb078eb7b40d8c0e3674c2f12da4f0061f889b2cfdbab8f37", size = 500357 }, + { url = "https://files.pythonhosted.org/packages/33/64/9ff1dd2c6acffd739adca70d6b16b059aea2a4ba750c4444aa5d59197e26/yarl-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:580fdb2ea48a40bcaa709ee0dc71f64e7a8f23b44356cc18cd9ce55dc3bc3212", size = 488873 }, + { url = "https://files.pythonhosted.org/packages/5a/d1/a9c696e15311f2b32028f8ff3b447c8334316a6029d30d13f73a081517a4/yarl-1.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9d2845f1a37438a8e11e4fcbbf6ffd64bc94dc9cb8c815f72d0eb6f6c622deb0", size = 471532 }, + { url = "https://files.pythonhosted.org/packages/d2/ca/accf33604a178cf785c71c8cce9956f37638e2e72cea23543fe4adaa9595/yarl-1.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bcb374db7a609484941c01380c1450728ec84d9c3e68cd9a5feaecb52626c4be", size = 485599 }, + { url = "https://files.pythonhosted.org/packages/ff/25/d92abf667d068103b912a56b39d889615a8b07fb8d9ae922cf8fdbe52ede/yarl-1.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:561a5f6c054927cf5a993dd7b032aeebc10644419e65db7dd6bdc0b848806e65", size = 483480 }, + { url = "https://files.pythonhosted.org/packages/ef/94/8a058039537682febfda57fb5d333f8bf7237dad5eab9323f5380325308a/yarl-1.13.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b536c2ac042add7f276d4e5857b08364fc32f28e02add153f6f214de50f12d07", size = 514004 }, + { url = "https://files.pythonhosted.org/packages/8c/ed/8253a619335c6a692f909b6406e1764369733eed5af3991bbb91bf4a3b24/yarl-1.13.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:52b7bb09bb48f7855d574480e2efc0c30d31cab4e6ffc6203e2f7ffbf2e4496a", size = 516259 }, + { url = "https://files.pythonhosted.org/packages/e2/17/e8ec3b51d5d02a768a75d6aee5edb8e303483fe3d0bf1f49c1dacf48fe47/yarl-1.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e4dddf99a853b3f60f3ce6363fb1ad94606113743cf653f116a38edd839a4461", size = 498386 }, + { url = "https://files.pythonhosted.org/packages/df/09/2c631d07df653b53c4880416ceb138e1ed7d079329430ef5bc363f02e8c4/yarl-1.13.0-cp311-cp311-win32.whl", hash = "sha256:0b489858642e4e92203941a8fdeeb6373c0535aa986200b22f84d4b39cd602ba", size = 102394 }, + { url = "https://files.pythonhosted.org/packages/df/a0/362619ab4141c2229eb43fa0a62447b14845a4ea50e362a40fd7c934c4aa/yarl-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:31748bee7078db26008bf94d39693c682a26b5c3a80a67194a4c9c8fe3b5cf47", size = 111636 }, + { url = "https://files.pythonhosted.org/packages/11/21/09da58324fca4a9b6ff5710109bd26217b40dee9e6729adb3786e82831c7/yarl-1.13.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3a9b2650425b2ab9cc68865978963601b3c2414e1d94ef04f193dd5865e1bd79", size = 190212 }, + { url = "https://files.pythonhosted.org/packages/83/9d/3a703336f3b8bbb907ad12bc46fe1f4b795e924b7b923dbcf604212ac3e1/yarl-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:73777f145cd591e1377bf8d8a97e5f8e39c9742ad0f100c898bba1f963aef662", size = 116036 }, + { url = "https://files.pythonhosted.org/packages/42/06/bb53625353041364ba2a8a0ca6bbfe2dafcfeec846d038f44d20746ebc70/yarl-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:144b9e9164f21da81731c970dbda52245b343c0f67f3609d71013dd4d0db9ebf", size = 113890 }, + { url = "https://files.pythonhosted.org/packages/4b/1f/e4c00883ea4debfd34b1c812887f897760c5bdb49de7fc4862df12d2599b/yarl-1.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3628e4e572b1db95285a72c4be102356f2dfc6214d9f126de975fd51b517ae55", size = 483935 }, + { url = "https://files.pythonhosted.org/packages/af/93/7ff1c47e5530d79b2e1dc55a38641b079361c7cf5fa754bc50250ca15445/yarl-1.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0bd3caf554a52da78ec08415ebedeb6b9636436ca2afda9b5b9ff4a533478940", size = 499696 }, + { url = "https://files.pythonhosted.org/packages/ee/ad/a2d9a167460b2043123fc1aeef908131f8d47aa939a0563e4ae44015cb89/yarl-1.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d7a44ae252efb0fcd79ac0997416721a44345f53e5aec4a24f489d983aa00e3", size = 497233 }, + { url = "https://files.pythonhosted.org/packages/5f/6f/0fc937394438542790290416a69bd26e4c91d5cb16d2288ef03518f5ec81/yarl-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24b78a1f57780eeeb17f5e1be851ab9fa951b98811e1bb4b5a53f74eec3e2666", size = 490132 }, + { url = "https://files.pythonhosted.org/packages/e5/d0/a7b4e6af60aba824cc8528e870fc41bb84f59fa5e6eecde5fb9908d1793c/yarl-1.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79de5f8432b53d1261d92761f71dfab5fc7e1c75faa12a3535c27e681dacfa9d", size = 469328 }, + { url = "https://files.pythonhosted.org/packages/db/95/3ed41ceaf3bc6ce48eacc0313054223436b4cf66c825f92d5cb806a1b37f/yarl-1.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f603216d62e9680bfac7fb168ef9673fd98abbb50c43e73d97615dfa1afebf57", size = 485630 }, + { url = "https://files.pythonhosted.org/packages/37/9c/b0ae1c3253c9b910abd5c20d4ee4f15b547fea61eaef6ae9f5b9e1b7bf0d/yarl-1.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:acf27399c94270103d68f86118a183008d601e4c2c3a7e98dcde0e3b0163132f", size = 485996 }, + { url = "https://files.pythonhosted.org/packages/7e/23/51879b22108fa24ea5b9f96a225016f73a8b148bdd70adad510a5536abe5/yarl-1.13.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:08037790f973367431b9406a7b9d940e872cca12e081bce3b7cea068daf81f0a", size = 506685 }, + { url = "https://files.pythonhosted.org/packages/f5/ed/2e3034d7adb7fdeb6b64789d3e92408a45db5ca31e707dd2114758e8f7d9/yarl-1.13.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:33e2f5ef965e69a1f2a1b0071a70c4616157da5a5478f3c2f6e185e06c56a322", size = 516992 }, + { url = "https://files.pythonhosted.org/packages/1d/7d/b091b5b444d522f80a5cd54208cf20a99aa7450a3218afc83f6f4a1001ca/yarl-1.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:38a3b742c923fe2cab7d2e2c67220d17da8d0433e8bfe038356167e697ef5524", size = 502349 }, + { url = "https://files.pythonhosted.org/packages/99/ce/e4e14c485086be114ddfdc11b192190a6f0d680d26d66929da42ca51b5bd/yarl-1.13.0-cp312-cp312-win32.whl", hash = "sha256:ab3ee57b25ce15f79ade27b7dfb5e678af26e4b93be5a4e22655acd9d40b81ba", size = 102301 }, + { url = "https://files.pythonhosted.org/packages/72/2f/d5657e841b51e1855a752cb6cea5c7e266e2e61d8784e8bf6d241ae38b39/yarl-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:26214b0a9b8f4b7b04e67eee94a82c9b4e5c721f4d1ce7e8c87c78f0809b7684", size = 111676 }, + { url = "https://files.pythonhosted.org/packages/3c/9d/62e0325479f6e225c006db065b81d92a214e15dbd9d5f08b7f58cbea2a1d/yarl-1.13.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:91251614cca1ba4ab0507f1ba5f5a44e17a5e9a4c7f0308ea441a994bdac3fc7", size = 186212 }, + { url = "https://files.pythonhosted.org/packages/33/e6/216ca46bb456cc6942f0098abb67b192c52733292d37cb4f230889c8c826/yarl-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fe6946c3cbcfbed67c5e50dae49baff82ad054aaa10ff7a4db8dfac646b7b479", size = 114218 }, + { url = "https://files.pythonhosted.org/packages/c4/9d/1e937ba8820129effa4fcb8d7188e990711d73f6eaff0888a9205e33cecd/yarl-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de97ee57e00a82ebb8c378fc73c5d9a773e4c2cec8079ff34ebfef61c8ba5b11", size = 112118 }, + { url = "https://files.pythonhosted.org/packages/62/19/9f60d2c8bfd9820708268c4466e4d52d64b6ecec26557a26d9a7c3d60991/yarl-1.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1129737da2291c9952a93c015e73583dd66054f3ae991c8674f6e39c46d95dd3", size = 471387 }, + { url = "https://files.pythonhosted.org/packages/4c/a9/d6936a780b35a202a9eb93905d283da4243fcfca85f464571d7ce6f5759e/yarl-1.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37049eb26d637a5b2f00562f65aad679f5d231c4c044edcd88320542ad66a2d9", size = 485837 }, + { url = "https://files.pythonhosted.org/packages/f1/62/1903cb89c2b069c985fb0577a152652b80a8700b6f96a72c2b127c00cac3/yarl-1.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08d15aff3477fecb7a469d1fdf5939a686fbc5a16858022897d3e9fc99301f19", size = 486662 }, + { url = "https://files.pythonhosted.org/packages/ea/70/17a1092eec93b9b2ca2fe7e9c854b52f968a5457a16c0192cb1684f666e9/yarl-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa187a8599e0425f26b25987d884a8b67deb5565f1c450c3a6e8d3de2cdc8715", size = 478867 }, + { url = "https://files.pythonhosted.org/packages/3e/ab/20d8b6ff384b126e2aca1546b8ba93e4a4aee35cfa68043b8015cf2fb309/yarl-1.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d95fcc9508390db73a0f1c7e78d9a1b1a3532a3f34ceff97c0b3b04140fbe6e4", size = 456455 }, + { url = "https://files.pythonhosted.org/packages/6a/31/66bebe242af5f0615b2a6f7ae9ac37633983c621eb333367830500f8f954/yarl-1.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d04ea92a3643a9bb28aa6954fff718342caab2cc3d25d0160fe16e26c4a9acb7", size = 474964 }, + { url = "https://files.pythonhosted.org/packages/69/02/67d94189a94d191edf47b8a34721e0e7265556e821e9bb2f856da7f8af39/yarl-1.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2842a89b697d8ca3dda6a25b4e4d835d14afe25a315c8a79dbdf5f70edfd0960", size = 477474 }, + { url = "https://files.pythonhosted.org/packages/60/33/a746b05fedc340e8055d38b3f892418577252b1dbd6be474faebe1ceb9f3/yarl-1.13.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db463fce425f935eee04a9182c74fdf9ed90d3bd2079d4a17f8fb7a2d7c11009", size = 491950 }, + { url = "https://files.pythonhosted.org/packages/5b/75/9759d92dc66108264f305f1ddb3ae02bcc247849a6673ebb678a082d398e/yarl-1.13.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3ff602aa84420b301c083ae7f07df858ae8e371bf3be294397bda3e0b27c6290", size = 502141 }, + { url = "https://files.pythonhosted.org/packages/e8/8d/4d9f6fa810eca7e07ae7bc6eea0136a4268a32439e6ce6e7454470c51dac/yarl-1.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a9a1a600e8449f3a24bc7dca513be8d69db173fe842e8332a7318b5b8757a6af", size = 492846 }, + { url = "https://files.pythonhosted.org/packages/77/b1/da12907ccb4cea4781357ec027e81e141251726aeffa6ea2c8d1f62cc117/yarl-1.13.0-cp313-cp313-win32.whl", hash = "sha256:5540b4896b244a6539f22b613b32b5d1b737e08011aa4ed56644cb0519d687df", size = 486417 }, + { url = "https://files.pythonhosted.org/packages/66/9e/05d133e7523035517e0dc912a59779dcfd5e978aff32c1c2a3cbc1fd4e7c/yarl-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:08a3b0b8d10092dade46424fe775f2c9bc32e5a985fdd6afe410fe28598db6b2", size = 493756 }, + { url = "https://files.pythonhosted.org/packages/47/44/3b6725635d226feb5e0263716a05186657afb548e7ef7ac1e11c0e255b9a/yarl-1.13.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:92abbe37e3fb08935e0e95ac5f83f7b286a6f2575f542225ec7afde405ed1fa1", size = 192323 }, + { url = "https://files.pythonhosted.org/packages/82/d7/d03fef722c92b07f9b91832a55e7fa624ec8dbc1700f146168461d0be425/yarl-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1932c7bfa537f89ad5ca3d1e7e05de3388bb9e893230a384159fb974f6e9f90c", size = 117070 }, + { url = "https://files.pythonhosted.org/packages/4a/52/19eef59dde0dc4e6bc73376dca047757cd6b39872b9fba690fbf33dc1fc2/yarl-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4483680e129b2a4250be20947b554cd5f7140fa9e5a1e4f1f42717cf91f8676a", size = 114997 }, + { url = "https://files.pythonhosted.org/packages/c7/f0/830618cabed258962ca81325f41216cb99ece2297d5c1744b5f3f7acfbea/yarl-1.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f6f4a352d0beea5dd39315ab16fc26f0122d13457a7e65ad4f06c7961dcf87a", size = 450259 }, + { url = "https://files.pythonhosted.org/packages/70/30/18c2a2ec41d2af39e99b7589b019622d2c4abf1f7e44fff5455ebcf6925f/yarl-1.13.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a67f20e97462dee8a89e9b997a78932959d2ed991e8f709514cb4160143e7b1", size = 477472 }, + { url = "https://files.pythonhosted.org/packages/11/cf/ce66ab9a022d824592e387b63d18b3fc19ba31731187201ae4b4ef2e199c/yarl-1.13.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf4f3a87bd52f8f33b0155cd0f6f22bdf2092d88c6c6acbb1aee3bc206ecbe35", size = 470167 }, + { url = "https://files.pythonhosted.org/packages/bd/7c/a7c60205831a8bb3dcafe350650936e69c01b45575c8d971e835a10ae585/yarl-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:deb70c006be548076d628630aad9a3ef3a1b2c28aaa14b395cf0939b9124252e", size = 455050 }, + { url = "https://files.pythonhosted.org/packages/0b/88/b5d1013311f2f0552b7186033fa02eb14501c87f55c042f15b0267382f0c/yarl-1.13.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bf7a9b31729b97985d4a796808859dfd0e37b55f1ca948d46a568e56e51dd8fb", size = 439771 }, + { url = "https://files.pythonhosted.org/packages/c3/f8/83ea7ef4f67ab9f930440bca4f9ea84f3f616876d3589df3e546b3fc2a14/yarl-1.13.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d807417ceebafb7ce18085a1205d28e8fcb1435a43197d7aa3fab98f5bfec5ef", size = 452000 }, + { url = "https://files.pythonhosted.org/packages/23/f2/dc0d49343fa4d0bedd06df4f6665c8438e49108ba3b443d1e245d779750b/yarl-1.13.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9671d0d65f86e0a0eee59c5b05e381c44e3d15c36c2a67da247d5d82875b4e4e", size = 455543 }, + { url = "https://files.pythonhosted.org/packages/06/40/b9dc6e2da5ff8c07470b4f8643589c9d55381900c317f4a1dfc67e269a47/yarl-1.13.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:13a9cd39e47ca4dc25139d3c63fe0dc6acf1b24f9d94d3b5197ac578fbfd84bf", size = 481106 }, + { url = "https://files.pythonhosted.org/packages/90/27/6e6c709ee54374494b8580c26ca3daac6039ba6c7f1b12214d99cf16fabc/yarl-1.13.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:acf8c219a59df22609cfaff4a7158a0946f273e3b03a5385f1fdd502496f0cff", size = 478299 }, + { url = "https://files.pythonhosted.org/packages/da/f3/62bdf7d9fbddbf13cc99c1d941b1687d2890034160cbf76dc6579ab62416/yarl-1.13.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:12c92576633027f297c26e52aba89f6363b460f483d85cf7c14216eb55d06d02", size = 466354 }, + { url = "https://files.pythonhosted.org/packages/42/e2/56a217a01e2ea0f963fe4a4af68c9af745a090d999704a677a5fe9f5403e/yarl-1.13.0-cp39-cp39-win32.whl", hash = "sha256:c2518660bd8166e770b76ce92514b491b8720ae7e7f5f975cd888b1592894d2c", size = 103369 }, + { url = "https://files.pythonhosted.org/packages/46/91/e3ea08a1770f7ba9822e391f1561d6505c4a7e313c3ea8ec65f26182ab93/yarl-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:db90702060b1cdb7c7609d04df5f68a12fd5581d013ad379e58e0c2e651d92b8", size = 112502 }, + { url = "https://files.pythonhosted.org/packages/10/ae/c3c059042053b92ae25363818901d0634708a3a85048e5ac835bd547107e/yarl-1.13.0-py3-none-any.whl", hash = "sha256:c7d35ff2a5a51bc6d40112cdb4ca3fd9636482ce8c6ceeeee2301e34f7ed7556", size = 39813 }, ] [[package]] name = "zipp" -version = "3.20.1" +version = "3.20.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/8b/1239a3ef43a0d0ebdca623fb6413bc7702c321400c5fdd574f0b7aa0fbb4/zipp-3.20.1.tar.gz", hash = "sha256:c22b14cc4763c5a5b04134207736c107db42e9d3ef2d9779d465f5f1bcba572b", size = 23848 } +sdist = { url = "https://files.pythonhosted.org/packages/54/bf/5c0000c44ebc80123ecbdddba1f5dcd94a5ada602a9c225d84b5aaa55e86/zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29", size = 24199 } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/9e/c96f7a4cd0bf5625bb409b7e61e99b1130dc63a98cb8b24aeabae62d43e8/zipp-3.20.1-py3-none-any.whl", hash = "sha256:9960cd8967c8f85a56f920d5d507274e74f9ff813a0ab8889a5b5be2daf44064", size = 8988 }, + { url = "https://files.pythonhosted.org/packages/62/8b/5ba542fa83c90e09eac972fc9baca7a88e7e7ca4b221a89251954019308b/zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350", size = 9200 }, ] From d897503b58ae081ef755aa8278dff47ef335806b Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sat, 28 Sep 2024 20:14:31 +0200 Subject: [PATCH 584/892] Move feature initialization from __init__ to _initialize_features (#1140) --- kasa/iot/modules/ambientlight.py | 6 +++--- kasa/iot/modules/cloud.py | 6 +++--- kasa/smart/modules/cloud.py | 12 +++--------- kasa/smart/modules/color.py | 11 +++-------- kasa/smart/modules/contactsensor.py | 11 +++-------- kasa/smart/modules/fan.py | 14 ++++---------- kasa/smart/modules/humiditysensor.py | 13 ++++--------- kasa/smart/modules/reportmode.py | 11 +++-------- kasa/smart/modules/temperaturesensor.py | 17 +++++++---------- kasa/smart/modules/time.py | 12 ++++-------- kasa/smart/modules/waterleaksensor.py | 12 ++++-------- 11 files changed, 41 insertions(+), 84 deletions(-) diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py index fd693ed52..d6470d264 100644 --- a/kasa/iot/modules/ambientlight.py +++ b/kasa/iot/modules/ambientlight.py @@ -16,11 +16,11 @@ class AmbientLight(IotModule): """Implements ambient light controls for the motion sensor.""" - def __init__(self, device, module): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features after the initial update.""" self._add_feature( Feature( - device=device, + device=self._device, container=self, id="ambient_light", name="Ambient Light", diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index 5022a68e7..8be393d96 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -24,11 +24,11 @@ class CloudInfo(BaseModel): class Cloud(IotModule): """Module implementing support for cloud services.""" - def __init__(self, device, module): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features after the initial update.""" self._add_feature( Feature( - device=device, + device=self._device, container=self, id="cloud_connection", name="Cloud connection", diff --git a/kasa/smart/modules/cloud.py b/kasa/smart/modules/cloud.py index e66f18581..347b9ec8b 100644 --- a/kasa/smart/modules/cloud.py +++ b/kasa/smart/modules/cloud.py @@ -2,14 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - class Cloud(SmartModule): """Implementation of cloud module.""" @@ -18,12 +13,11 @@ class Cloud(SmartModule): REQUIRED_COMPONENT = "cloud_connect" MINIMUM_UPDATE_INTERVAL_SECS = 60 - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) - + def _initialize_features(self): + """Initialize features after the initial update.""" self._add_feature( Feature( - device, + self._device, id="cloud_connection", name="Cloud connection", container=self, diff --git a/kasa/smart/modules/color.py b/kasa/smart/modules/color.py index bbabbdef9..772d9335b 100644 --- a/kasa/smart/modules/color.py +++ b/kasa/smart/modules/color.py @@ -2,26 +2,21 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...feature import Feature from ...interfaces.light import HSV from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - class Color(SmartModule): """Implementation of color module.""" REQUIRED_COMPONENT = "color" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features after the initial update.""" self._add_feature( Feature( - device, + self._device, "hsv", "HSV", container=self, diff --git a/kasa/smart/modules/contactsensor.py b/kasa/smart/modules/contactsensor.py index 7932a081d..0bfa1bded 100644 --- a/kasa/smart/modules/contactsensor.py +++ b/kasa/smart/modules/contactsensor.py @@ -2,14 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - class ContactSensor(SmartModule): """Implementation of contact sensor module.""" @@ -17,11 +12,11 @@ class ContactSensor(SmartModule): REQUIRED_COMPONENT = None # we depend on availability of key REQUIRED_KEY_ON_PARENT = "open" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features after the initial update.""" self._add_feature( Feature( - device, + self._device, id="is_open", name="Open", container=self, diff --git a/kasa/smart/modules/fan.py b/kasa/smart/modules/fan.py index 245bef2c2..9cb1a8dfc 100644 --- a/kasa/smart/modules/fan.py +++ b/kasa/smart/modules/fan.py @@ -2,27 +2,21 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...feature import Feature from ...interfaces.fan import Fan as FanInterface from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - class Fan(SmartModule, FanInterface): """Implementation of fan_control module.""" REQUIRED_COMPONENT = "fan_control" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) - + def _initialize_features(self): + """Initialize features after the initial update.""" self._add_feature( Feature( - device, + self._device, id="fan_speed_level", name="Fan speed level", container=self, @@ -36,7 +30,7 @@ def __init__(self, device: SmartDevice, module: str): ) self._add_feature( Feature( - device, + self._device, id="fan_sleep_mode", name="Fan sleep mode", container=self, diff --git a/kasa/smart/modules/humiditysensor.py b/kasa/smart/modules/humiditysensor.py index 606b1d548..fab30f052 100644 --- a/kasa/smart/modules/humiditysensor.py +++ b/kasa/smart/modules/humiditysensor.py @@ -2,14 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - class HumiditySensor(SmartModule): """Implementation of humidity module.""" @@ -17,11 +12,11 @@ class HumiditySensor(SmartModule): REQUIRED_COMPONENT = "humidity" QUERY_GETTER_NAME = "get_comfort_humidity_config" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features after the initial update.""" self._add_feature( Feature( - device, + self._device, id="humidity", name="Humidity", container=self, @@ -34,7 +29,7 @@ def __init__(self, device: SmartDevice, module: str): ) self._add_feature( Feature( - device, + self._device, id="humidity_warning", name="Humidity warning", container=self, diff --git a/kasa/smart/modules/reportmode.py b/kasa/smart/modules/reportmode.py index d2c9d929a..34559cab2 100644 --- a/kasa/smart/modules/reportmode.py +++ b/kasa/smart/modules/reportmode.py @@ -2,14 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - class ReportMode(SmartModule): """Implementation of report module.""" @@ -17,11 +12,11 @@ class ReportMode(SmartModule): REQUIRED_COMPONENT = "report_mode" QUERY_GETTER_NAME = "get_report_mode" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features after the initial update.""" self._add_feature( Feature( - device, + self._device, id="report_interval", name="Report interval", container=self, diff --git a/kasa/smart/modules/temperaturesensor.py b/kasa/smart/modules/temperaturesensor.py index 1741b26ba..8162ce60d 100644 --- a/kasa/smart/modules/temperaturesensor.py +++ b/kasa/smart/modules/temperaturesensor.py @@ -2,14 +2,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal +from typing import Literal from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - class TemperatureSensor(SmartModule): """Implementation of temperature module.""" @@ -17,11 +14,11 @@ class TemperatureSensor(SmartModule): REQUIRED_COMPONENT = "temperature" QUERY_GETTER_NAME = "get_comfort_temp_config" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features after the initial update.""" self._add_feature( Feature( - device, + self._device, id="temperature", name="Temperature", container=self, @@ -32,10 +29,10 @@ def __init__(self, device: SmartDevice, module: str): type=Feature.Type.Sensor, ) ) - if "current_temp_exception" in device.sys_info: + if "current_temp_exception" in self._device.sys_info: self._add_feature( Feature( - device, + self._device, id="temperature_warning", name="Temperature warning", container=self, @@ -47,7 +44,7 @@ def __init__(self, device: SmartDevice, module: str): ) self._add_feature( Feature( - device, + self._device, id="temperature_unit", name="Temperature unit", container=self, diff --git a/kasa/smart/modules/time.py b/kasa/smart/modules/time.py index 49a1d940e..254fd098b 100644 --- a/kasa/smart/modules/time.py +++ b/kasa/smart/modules/time.py @@ -4,14 +4,11 @@ from datetime import datetime, timedelta, timezone from time import mktime -from typing import TYPE_CHECKING, cast +from typing import cast from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - class Time(SmartModule): """Implementation of device_local_time.""" @@ -19,12 +16,11 @@ class Time(SmartModule): REQUIRED_COMPONENT = "time" QUERY_GETTER_NAME = "get_device_time" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) - + def _initialize_features(self): + """Initialize features after the initial update.""" self._add_feature( Feature( - device=device, + device=self._device, id="device_time", name="Device time", attribute_getter="time", diff --git a/kasa/smart/modules/waterleaksensor.py b/kasa/smart/modules/waterleaksensor.py index 9f75b9b4a..bba4f61dc 100644 --- a/kasa/smart/modules/waterleaksensor.py +++ b/kasa/smart/modules/waterleaksensor.py @@ -3,14 +3,10 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - class WaterleakStatus(Enum): """Waterleawk status.""" @@ -25,11 +21,11 @@ class WaterleakSensor(SmartModule): REQUIRED_COMPONENT = "sensor_alarm" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features after the initial update.""" self._add_feature( Feature( - device, + self._device, id="water_leak", name="Water leak", container=self, @@ -41,7 +37,7 @@ def __init__(self, device: SmartDevice, module: str): ) self._add_feature( Feature( - device, + self._device, id="water_alert", name="Water alert", container=self, From 130e1b6023f5e2ac04ac3171bfa543cdfc9647ee Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sat, 28 Sep 2024 20:20:47 +0200 Subject: [PATCH 585/892] parse_pcap_klap: use request_uri for matching the response (#1136) tshark 4.4.0 does not have response_for_uri, this fixes response detection by using request_uri, too. --- devtools/parse_pcap_klap.py | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index 36384631b..09c5a1e28 100755 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -29,6 +29,24 @@ from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials +def _is_http_response_for_packet(response, packet): + """Return True if the *response* contains a response for request in *packet*. + + Different tshark versions use different field for the information. + """ + if not hasattr(response, "http"): + return False + if hasattr(response.http, "response_for_uri") and ( + response.http.response_for_uri == packet.http.request_full_uri + ): + return True + # tshark 4.4.0 + if response.http.request_uri == packet.http.request_uri: + return True + + return False + + class MyEncryptionSession(KlapEncryptionSession): """A custom KlapEncryptionSession class that allows for decryption.""" @@ -222,7 +240,7 @@ def main( while True: try: packet = capture.next() - # packet_number = capture._current_packet + packet_number = capture._current_packet # we only care about http packets if hasattr( packet, "http" @@ -267,18 +285,16 @@ def main( message = bytes.fromhex(data) operator.local_seed = message response = None + print( + f"got handshake1 in {packet_number}, " + f"looking for the response" + ) while ( True ): # we are going to now look for the response to this request response = capture.next() - if ( - hasattr(response, "http") - and hasattr(response.http, "response_for_uri") - and ( - response.http.response_for_uri - == packet.http.request_full_uri - ) - ): + if _is_http_response_for_packet(response, packet): + print(f"found response in {packet_number}") break data = response.http.get_field_value("file_data", raw=True) message = bytes.fromhex(data) From db80c383a9087bee37c7432d43c0ba643ebd7b4c Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Mon, 30 Sep 2024 10:15:16 +0200 Subject: [PATCH 586/892] parse_pcap_klap: require source host (#1137) Adds a mandatory `--source-host` to make sure the correct handshake is captured when multiple hosts are communicating with the target device. --- devtools/parse_pcap_klap.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index 09c5a1e28..b2cdc938e 100755 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -212,6 +212,7 @@ def main( username, password, device_ip, + source_host, pcap_file_path, output_json_name=None, ): @@ -232,7 +233,6 @@ def main( ) operator = Operator(KlapTransportV2(config=fake_device), creds) - packets = [] # pyshark is a little weird in how it handles iteration, @@ -241,6 +241,8 @@ def main( try: packet = capture.next() packet_number = capture._current_packet + if packet.ip.src != source_host: + continue # we only care about http packets if hasattr( packet, "http" @@ -325,6 +327,11 @@ def main( required=True, help="the IP of the smart device as it appears in the pcap file.", ) +@click.option( + "--source-host", + required=True, + help="the IP of the device communicating with the smart device.", +) @click.option( "--username", required=True, @@ -348,14 +355,14 @@ def main( required=False, help="The name of the output file, relative to the current directory.", ) -async def cli(username, password, host, pcap_file_path, output): +async def cli(username, password, host, source_host, pcap_file_path, output): """Export KLAP data in JSON format from a PCAP file.""" # pyshark does not work within a running event loop and we don't want to # install click as well as asyncclick so run in a new thread. loop = asyncio.new_event_loop() thread = Thread( target=main, - args=[loop, username, password, host, pcap_file_path, output], + args=[loop, username, password, host, source_host, pcap_file_path, output], daemon=True, ) thread.start() From 81e2685605bba07fecb0b8172258dffaea330bed Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 1 Oct 2024 12:47:36 +0100 Subject: [PATCH 587/892] Send empty dictionary instead of null for iot queries (#1145) --- devtools/dump_devinfo.py | 4 ++-- kasa/device_factory.py | 4 ++-- kasa/discover.py | 4 ++-- kasa/iot/iotdevice.py | 2 ++ kasa/klaptransport.py | 1 - 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 34a067871..8ca39d039 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -285,7 +285,7 @@ async def get_legacy_fixture(device): try: click.echo(f"Testing {test_call}..", nl=False) info = await device.protocol.query( - {test_call.module: {test_call.method: None}} + {test_call.module: {test_call.method: {}}} ) resp = info[test_call.module] except Exception as ex: @@ -302,7 +302,7 @@ async def get_legacy_fixture(device): final_query = defaultdict(defaultdict) final = defaultdict(defaultdict) for succ, resp in successes: - final_query[succ.module][succ.method] = None + final_query[succ.module][succ.method] = {} final[succ.module][succ.method] = resp final = default_to_regular(final) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 1bb6fc4ab..a124bb4c4 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -32,8 +32,8 @@ _LOGGER = logging.getLogger(__name__) -GET_SYSINFO_QUERY = { - "system": {"get_sysinfo": None}, +GET_SYSINFO_QUERY: dict[str, dict[str, dict]] = { + "system": {"get_sysinfo": {}}, } diff --git a/kasa/discover.py b/kasa/discover.py index b541dd7a4..a1bc28a31 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -296,8 +296,8 @@ class Discover: DISCOVERY_PORT = 9999 - DISCOVERY_QUERY = { - "system": {"get_sysinfo": None}, + DISCOVERY_QUERY: dict[str, dict[str, dict]] = { + "system": {"get_sysinfo": {}}, } DISCOVERY_PORT_2 = 20002 diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 3986c001d..2959612f9 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -207,6 +207,8 @@ def add_module(self, name: str | ModuleName[Module], module: IotModule): def _create_request( self, target: str, cmd: str, arg: dict | None = None, child_ids=None ): + if arg is None: + arg = {} request: dict[str, Any] = {target: {cmd: arg}} if child_ids is not None: request = {"context": {"child_ids": child_ids}, target: {cmd: arg}} diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 8e22dec07..02e0b2b72 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -88,7 +88,6 @@ class KlapTransport(BaseTransport): """ DEFAULT_PORT: int = 80 - DISCOVERY_QUERY = {"system": {"get_sysinfo": None}} SESSION_COOKIE_NAME = "TP_SESSIONID" TIMEOUT_COOKIE_NAME = "TIMEOUT" From 1fcf3e44c2e90428b58be2bec4e10d29935d6688 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 2 Oct 2024 15:04:16 +0100 Subject: [PATCH 588/892] Stabilise on_since value for smart devices (#1144) Caches the `on_since` value to prevent jitter caused by the device calculations. --- kasa/device.py | 6 +++++- kasa/iot/iotdevice.py | 22 +++++++++++++++------- kasa/iot/iotstrip.py | 13 ++++++++++--- kasa/smart/smartdevice.py | 15 +++++++++++++-- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/kasa/device.py b/kasa/device.py index 05a4f7675..4397e2ffd 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -435,7 +435,11 @@ def has_emeter(self) -> bool: @property @abstractmethod def on_since(self) -> datetime | None: - """Return the time that the device was turned on or None if turned off.""" + """Return the time that the device was turned on or None if turned off. + + This returns a cached value if the device reported value difference is under + five seconds to avoid device-caused jitter. + """ @abstractmethod async def wifi_scan(self) -> list[WifiNetwork]: diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 2959612f9..f0d14e10b 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -181,6 +181,7 @@ def __init__( self._legacy_features: set[str] = set() self._children: Mapping[str, IotDevice] = {} self._modules: dict[str | ModuleName[Module], IotModule] = {} + self._on_since: datetime | None = None @property def children(self) -> Sequence[IotDevice]: @@ -594,18 +595,25 @@ async def set_state(self, on: bool): @property # type: ignore @requires_update def on_since(self) -> datetime | None: - """Return pretty-printed on-time, or None if not available.""" - if "on_time" not in self._sys_info: - return None + """Return the time that the device was turned on or None if turned off. - if self.is_off: + This returns a cached value if the device reported value difference is under + five seconds to avoid device-caused jitter. + """ + if self.is_off or "on_time" not in self._sys_info: + self._on_since = None return None on_time = self._sys_info["on_time"] - return datetime.now(timezone.utc).astimezone().replace( - microsecond=0 - ) - timedelta(seconds=on_time) + time = datetime.now(timezone.utc).astimezone().replace(microsecond=0) + + on_since = time - timedelta(seconds=on_time) + if not self._on_since or timedelta( + seconds=0 + ) < on_since - self._on_since > timedelta(seconds=5): + self._on_since = on_since + return self._on_since @property # type: ignore @requires_update diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 61017228d..0bdfc1cb6 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -318,6 +318,7 @@ def __init__(self, host: str, parent: IotStrip, child_id: str) -> None: self._set_sys_info(parent.sys_info) self._device_type = DeviceType.StripSocket self.protocol = parent.protocol # Must use the same connection as the parent + self._on_since: datetime | None = None async def _initialize_modules(self): """Initialize modules not added in init.""" @@ -438,14 +439,20 @@ def next_action(self) -> dict: def on_since(self) -> datetime | None: """Return on-time, if available.""" if self.is_off: + self._on_since = None return None info = self._get_child_info() on_time = info["on_time"] - return datetime.now(timezone.utc).astimezone().replace( - microsecond=0 - ) - timedelta(seconds=on_time) + time = datetime.now(timezone.utc).astimezone().replace(microsecond=0) + + on_since = time - timedelta(seconds=on_time) + if not self._on_since or timedelta( + seconds=0 + ) < on_since - self._on_since > timedelta(seconds=5): + self._on_since = on_since + return self._on_since @property # type: ignore @requires_update diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 04a9608a6..8d373f580 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -66,6 +66,7 @@ def __init__( self._children: Mapping[str, SmartDevice] = {} self._last_update = {} self._last_update_time: float | None = None + self._on_since: datetime | None = None async def _initialize_children(self): """Initialize children for power strips.""" @@ -494,15 +495,25 @@ def time(self) -> datetime: @property def on_since(self) -> datetime | None: - """Return the time that the device was turned on or None if turned off.""" + """Return the time that the device was turned on or None if turned off. + + This returns a cached value if the device reported value difference is under + five seconds to avoid device-caused jitter. + """ if ( not self._info.get("device_on") or (on_time := self._info.get("on_time")) is None ): + self._on_since = None return None on_time = cast(float, on_time) - return self.time - timedelta(seconds=on_time) + on_since = self.time - timedelta(seconds=on_time) + if not self._on_since or timedelta( + seconds=0 + ) < on_since - self._on_since > timedelta(seconds=5): + self._on_since = on_since + return self._on_since @property def timezone(self) -> dict: From 1026e890a1df071c374be335ad694c5e82408d59 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 2 Oct 2024 16:00:27 +0100 Subject: [PATCH 589/892] Correctly define SmartModule.call as an async function (#1148) --- kasa/smart/smartmodule.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 9372b65d0..381ce2333 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -136,12 +136,12 @@ def query(self) -> dict: """ return {self.QUERY_GETTER_NAME: None} - def call(self, method, params=None): + async def call(self, method, params=None): """Call a method. Just a helper method. """ - return self._device._query_helper(method, params) + return await self._device._query_helper(method, params) @property def data(self): From 8bb2cca7cf412befeb29de5fb3ae1c27d9e5bde6 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 2 Oct 2024 16:12:10 +0100 Subject: [PATCH 590/892] Remove async magic patch from tests (#1146) Not required since AsyncMock available in python 3.8 and probably better to keep magic to a minimum. --- kasa/tests/conftest.py | 11 ----------- kasa/tests/test_device.py | 6 +++--- kasa/tests/test_feature.py | 14 ++++++++++---- kasa/tests/test_protocol.py | 6 ++++++ 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 8c6e76345..0d47080fb 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -140,14 +140,3 @@ async def _create_datagram_endpoint(protocol_factory, *_, **__): side_effect=_create_datagram_endpoint, ): yield - - -# allow mocks to be awaited -# https://stackoverflow.com/questions/51394411/python-object-magicmock-cant-be-used-in-await-expression/51399767#51399767 - - -async def async_magic(): - pass - - -MagicMock.__await__ = lambda x: async_magic().__await__() diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index 0aee5b56d..f67d37c26 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -7,7 +7,7 @@ import pkgutil import sys from contextlib import AbstractContextManager -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, patch import pytest @@ -85,7 +85,7 @@ async def test_create_device_with_timeout(): async def test_create_thin_wrapper(): """Make sure thin wrapper is created with the correct device type.""" - mock = Mock() + mock = AsyncMock() config = DeviceConfig( host="test_host", port_override=1234, @@ -281,7 +281,7 @@ async def test_device_type_aliases(): """Test that the device type aliases in Device work.""" def _mock_connect(config, *args, **kwargs): - mock = Mock() + mock = AsyncMock() mock.config = config return mock diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index 83b7c24c2..938f9547a 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -1,6 +1,6 @@ import logging import sys -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from pytest_mock import MockerFixture @@ -94,7 +94,9 @@ def test_feature_value_callable(dev, dummy_feature: Feature): async def test_feature_setter(dev, mocker, dummy_feature: Feature): """Verify that *set_value* calls the defined method.""" - mock_set_dummy = mocker.patch.object(dummy_feature.device, "set_dummy", create=True) + mock_set_dummy = mocker.patch.object( + dummy_feature.device, "set_dummy", create=True, new_callable=AsyncMock + ) dummy_feature.attribute_setter = "set_dummy" await dummy_feature.set_value("dummy value") mock_set_dummy.assert_called_with("dummy value") @@ -118,7 +120,9 @@ async def test_feature_action(mocker): icon="mdi:dummy", type=Feature.Type.Action, ) - mock_call_action = mocker.patch.object(feat.device, "call_action", create=True) + mock_call_action = mocker.patch.object( + feat.device, "call_action", create=True, new_callable=AsyncMock + ) assert feat.value == "" await feat.set_value(1234) mock_call_action.assert_called() @@ -129,7 +133,9 @@ async def test_feature_choice_list(dummy_feature, caplog, mocker: MockerFixture) dummy_feature.type = Feature.Type.Choice dummy_feature.choices_getter = lambda: ["first", "second"] - mock_setter = mocker.patch.object(dummy_feature.device, "dummysetter", create=True) + mock_setter = mocker.patch.object( + dummy_feature.device, "dummysetter", create=True, new_callable=AsyncMock + ) await dummy_feature.set_value("first") mock_setter.assert_called_with("first") mock_setter.reset_mock() diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index afb953dd7..9c15795f1 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -9,6 +9,7 @@ import struct import sys from typing import cast +from unittest.mock import AsyncMock import pytest @@ -175,6 +176,7 @@ def aio_mock_writer(_, __): writer = mocker.patch("asyncio.StreamWriter") mocker.patch.object(writer, "write", _fail_one_less_than_retry_count) mocker.patch.object(reader, "readexactly", _mock_read) + mocker.patch.object(writer, "drain", new_callable=AsyncMock) return reader, writer config = DeviceConfig("127.0.0.1") @@ -224,6 +226,7 @@ def aio_mock_writer(_, __): writer = mocker.patch("asyncio.StreamWriter") mocker.patch.object(writer, "write", _cancel_first_attempt) mocker.patch.object(reader, "readexactly", _mock_read) + mocker.patch.object(writer, "drain", new_callable=AsyncMock) return reader, writer config = DeviceConfig("127.0.0.1") @@ -275,6 +278,7 @@ def aio_mock_writer(_, __): reader = mocker.patch("asyncio.StreamReader") writer = mocker.patch("asyncio.StreamWriter") mocker.patch.object(reader, "readexactly", _mock_read) + mocker.patch.object(writer, "drain", new_callable=AsyncMock) return reader, writer config = DeviceConfig("127.0.0.1") @@ -324,6 +328,7 @@ def aio_mock_writer(_, __): reader = mocker.patch("asyncio.StreamReader") writer = mocker.patch("asyncio.StreamWriter") mocker.patch.object(reader, "readexactly", _mock_read) + mocker.patch.object(writer, "drain", new_callable=AsyncMock) return reader, writer config = DeviceConfig("127.0.0.1") @@ -373,6 +378,7 @@ def aio_mock_writer(_, port): else: assert port == custom_port mocker.patch.object(reader, "readexactly", _mock_read) + mocker.patch.object(writer, "drain", new_callable=AsyncMock) return reader, writer config = DeviceConfig("127.0.0.1", port_override=custom_port) From 9641edcbc05cde828b58f137e47635665d2f79ae Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 8 Oct 2024 08:16:51 +0100 Subject: [PATCH 591/892] Make iot time timezone aware (#1147) Also makes on_since for iot devices use device time. Changes the return value for device.timezone to be tzinfo instead of a dict. --- kasa/cli/device.py | 2 +- kasa/device.py | 4 +- kasa/iot/iotdevice.py | 10 +- kasa/iot/iotstrip.py | 6 +- kasa/iot/iottimezone.py | 178 ++++++++++++++++++++++++++ kasa/iot/modules/emeter.py | 2 +- kasa/iot/modules/light.py | 2 +- kasa/iot/modules/lightpreset.py | 2 +- kasa/iot/modules/time.py | 20 ++- kasa/module.py | 2 +- kasa/smart/modules/devicemodule.py | 2 +- kasa/smart/modules/light.py | 2 +- kasa/smart/modules/lighteffect.py | 2 +- kasa/smart/modules/lightpreset.py | 2 +- kasa/smart/modules/lighttransition.py | 2 +- kasa/smart/modules/time.py | 23 +++- kasa/smart/smartchilddevice.py | 2 +- kasa/smart/smartdevice.py | 19 +-- kasa/smart/smartmodule.py | 2 +- kasa/tests/test_device.py | 32 +++++ pyproject.toml | 1 + uv.lock | 17 ++- 22 files changed, 289 insertions(+), 45 deletions(-) create mode 100644 kasa/iot/iottimezone.py diff --git a/kasa/cli/device.py b/kasa/cli/device.py index f513a5e23..4a933b874 100644 --- a/kasa/cli/device.py +++ b/kasa/cli/device.py @@ -40,7 +40,7 @@ async def state(ctx, dev: Device): echo(f"Port: {dev.port}") echo(f"Device state: {dev.is_on}") - echo(f"Time: {dev.time} (tz: {dev.timezone}") + echo(f"Time: {dev.time} (tz: {dev.timezone})") echo(f"Hardware: {dev.hw_info['hw_ver']}") echo(f"Software: {dev.hw_info['sw_ver']}") echo(f"MAC (rssi): {dev.mac} ({dev.rssi})") diff --git a/kasa/device.py b/kasa/device.py index 4397e2ffd..d44ca2b8b 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -109,7 +109,7 @@ from abc import ABC, abstractmethod from collections.abc import Mapping, Sequence from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, tzinfo from typing import TYPE_CHECKING, Any from warnings import warn @@ -377,7 +377,7 @@ def time(self) -> datetime: @property @abstractmethod - def timezone(self) -> dict: + def timezone(self) -> tzinfo: """Return the timezone and time_difference.""" @property diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index f0d14e10b..94e72df61 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -18,7 +18,7 @@ import inspect import logging from collections.abc import Mapping, Sequence -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta, tzinfo from typing import TYPE_CHECKING, Any, cast from ..device import Device, WifiNetwork @@ -299,7 +299,7 @@ async def update(self, update_children: bool = True): self._set_sys_info(self._last_update["system"]["get_sysinfo"]) for module in self._modules.values(): - module._post_update_hook() + await module._post_update_hook() if not self._features: await self._initialize_features() @@ -464,7 +464,7 @@ def time(self) -> datetime: @property @requires_update - def timezone(self) -> dict: + def timezone(self) -> tzinfo: """Return the current timezone.""" return self.modules[Module.IotTime].timezone @@ -606,9 +606,7 @@ def on_since(self) -> datetime | None: on_time = self._sys_info["on_time"] - time = datetime.now(timezone.utc).astimezone().replace(microsecond=0) - - on_since = time - timedelta(seconds=on_time) + on_since = self.time - timedelta(seconds=on_time) if not self._on_since or timedelta( seconds=0 ) < on_since - self._on_since > timedelta(seconds=5): diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 0bdfc1cb6..466997049 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -4,7 +4,7 @@ import logging from collections import defaultdict -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from typing import Any from ..device_type import DeviceType @@ -373,7 +373,7 @@ async def _update(self, update_children: bool = True): """ await self._modular_update({}) for module in self._modules.values(): - module._post_update_hook() + await module._post_update_hook() if not self._features: await self._initialize_features() @@ -445,7 +445,7 @@ def on_since(self) -> datetime | None: info = self._get_child_info() on_time = info["on_time"] - time = datetime.now(timezone.utc).astimezone().replace(microsecond=0) + time = self._parent.time on_since = time - timedelta(seconds=on_time) if not self._on_since or timedelta( diff --git a/kasa/iot/iottimezone.py b/kasa/iot/iottimezone.py new file mode 100644 index 000000000..53cb219e0 --- /dev/null +++ b/kasa/iot/iottimezone.py @@ -0,0 +1,178 @@ +"""Module for io device timezone lookups.""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, tzinfo + +from zoneinfo import ZoneInfo + +_LOGGER = logging.getLogger(__name__) + + +async def get_timezone(index: int) -> tzinfo: + """Get the timezone from the index.""" + if index > 109: + _LOGGER.error( + "Unexpected index %s not configured as a timezone, defaulting to UTC", index + ) + return await _CachedZoneInfo.get_cached_zone_info("Etc/UTC") + + name = TIMEZONE_INDEX[index] + return await _CachedZoneInfo.get_cached_zone_info(name) + + +async def get_timezone_index(name: str) -> int: + """Return the iot firmware index for a valid IANA timezone key.""" + rev = {val: key for key, val in TIMEZONE_INDEX.items()} + if name in rev: + return rev[name] + + # Try to find a supported timezone matching dst true/false + zone = await _CachedZoneInfo.get_cached_zone_info(name) + now = datetime.now() + winter = datetime(now.year, 1, 1, 12) + summer = datetime(now.year, 7, 1, 12) + for i in range(110): + configured_zone = await get_timezone(i) + if zone.utcoffset(winter) == configured_zone.utcoffset( + winter + ) and zone.utcoffset(summer) == configured_zone.utcoffset(summer): + return i + raise ValueError("Device does not support timezone %s", name) + + +class _CachedZoneInfo(ZoneInfo): + """Cache zone info objects.""" + + _cache: dict[str, ZoneInfo] = {} + + @classmethod + async def get_cached_zone_info(cls, time_zone_str: str) -> ZoneInfo: + """Get a cached zone info object.""" + if cached := cls._cache.get(time_zone_str): + return cached + loop = asyncio.get_running_loop() + zinfo = await loop.run_in_executor(None, _get_zone_info, time_zone_str) + cls._cache[time_zone_str] = zinfo + return zinfo + + +def _get_zone_info(time_zone_str: str) -> ZoneInfo: + """Get a time zone object for the given time zone string.""" + return ZoneInfo(time_zone_str) + + +TIMEZONE_INDEX = { + 0: "Etc/GMT+12", + 1: "Pacific/Samoa", + 2: "US/Hawaii", + 3: "US/Alaska", + 4: "Mexico/BajaNorte", + 5: "Etc/GMT+8", + 6: "PST8PDT", + 7: "US/Arizona", + 8: "America/Mazatlan", + 9: "MST", + 10: "MST7MDT", + 11: "Mexico/General", + 12: "Etc/GMT+6", + 13: "CST6CDT", + 14: "America/Monterrey", + 15: "Canada/Saskatchewan", + 16: "America/Bogota", + 17: "Etc/GMT+5", + 18: "EST", + 19: "America/Indiana/Indianapolis", + 20: "America/Caracas", + 21: "America/Asuncion", + 22: "Etc/GMT+4", + 23: "Canada/Atlantic", + 24: "America/Cuiaba", + 25: "Brazil/West", + 26: "America/Santiago", + 27: "Canada/Newfoundland", + 28: "America/Sao_Paulo", + 29: "America/Argentina/Buenos_Aires", + 30: "America/Cayenne", + 31: "America/Miquelon", + 32: "America/Montevideo", + 33: "Chile/Continental", + 34: "Etc/GMT+2", + 35: "Atlantic/Azores", + 36: "Atlantic/Cape_Verde", + 37: "Africa/Casablanca", + 38: "UCT", + 39: "GB", + 40: "Africa/Monrovia", + 41: "Europe/Amsterdam", + 42: "Europe/Belgrade", + 43: "Europe/Brussels", + 44: "Europe/Sarajevo", + 45: "Africa/Lagos", + 46: "Africa/Windhoek", + 47: "Asia/Amman", + 48: "Europe/Athens", + 49: "Asia/Beirut", + 50: "Africa/Cairo", + 51: "Asia/Damascus", + 52: "EET", + 53: "Africa/Harare", + 54: "Europe/Helsinki", + 55: "Asia/Istanbul", + 56: "Asia/Jerusalem", + 57: "Europe/Kaliningrad", + 58: "Africa/Tripoli", + 59: "Asia/Baghdad", + 60: "Asia/Kuwait", + 61: "Europe/Minsk", + 62: "Europe/Moscow", + 63: "Africa/Nairobi", + 64: "Asia/Tehran", + 65: "Asia/Muscat", + 66: "Asia/Baku", + 67: "Europe/Samara", + 68: "Indian/Mauritius", + 69: "Asia/Tbilisi", + 70: "Asia/Yerevan", + 71: "Asia/Kabul", + 72: "Asia/Ashgabat", + 73: "Asia/Yekaterinburg", + 74: "Asia/Karachi", + 75: "Asia/Kolkata", + 76: "Asia/Colombo", + 77: "Asia/Kathmandu", + 78: "Asia/Almaty", + 79: "Asia/Dhaka", + 80: "Asia/Novosibirsk", + 81: "Asia/Rangoon", + 82: "Asia/Bangkok", + 83: "Asia/Krasnoyarsk", + 84: "Asia/Chongqing", + 85: "Asia/Irkutsk", + 86: "Asia/Singapore", + 87: "Australia/Perth", + 88: "Asia/Taipei", + 89: "Asia/Ulaanbaatar", + 90: "Asia/Tokyo", + 91: "Asia/Seoul", + 92: "Asia/Yakutsk", + 93: "Australia/Adelaide", + 94: "Australia/Darwin", + 95: "Australia/Brisbane", + 96: "Australia/Canberra", + 97: "Pacific/Guam", + 98: "Australia/Hobart", + 99: "Antarctica/DumontDUrville", + 100: "Asia/Magadan", + 101: "Asia/Srednekolymsk", + 102: "Etc/GMT-11", + 103: "Asia/Anadyr", + 104: "Pacific/Auckland", + 105: "Etc/GMT-12", + 106: "Pacific/Fiji", + 107: "Etc/GMT-13", + 108: "Pacific/Apia", + 109: "Etc/GMT-14", +} diff --git a/kasa/iot/modules/emeter.py b/kasa/iot/modules/emeter.py index 7ae89e5b6..1764af905 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -12,7 +12,7 @@ class Emeter(Usage, EnergyInterface): """Emeter module.""" - def _post_update_hook(self) -> None: + async def _post_update_hook(self) -> None: self._supported = EnergyInterface.ModuleFeature.PERIODIC_STATS if ( "voltage_mv" in self.data["get_realtime"] diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index c4d6cb09b..d83031c8c 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -239,7 +239,7 @@ def state(self) -> LightState: """Return the current light state.""" return self._light_state - def _post_update_hook(self) -> None: + async def _post_update_hook(self) -> None: if self._device.is_on is False: state = LightState(light_on=False) else: diff --git a/kasa/iot/modules/lightpreset.py b/kasa/iot/modules/lightpreset.py index d5a603c0b..bae401efa 100644 --- a/kasa/iot/modules/lightpreset.py +++ b/kasa/iot/modules/lightpreset.py @@ -41,7 +41,7 @@ class LightPreset(IotModule, LightPresetInterface): _presets: dict[str, IotLightPreset] _preset_list: list[str] - def _post_update_hook(self): + async def _post_update_hook(self): """Update the internal presets.""" self._presets = { f"Light preset {index+1}": IotLightPreset(**vals) diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py index c280e5d10..23cc50103 100644 --- a/kasa/iot/modules/time.py +++ b/kasa/iot/modules/time.py @@ -1,14 +1,19 @@ """Provides the current time and timezone information.""" -from datetime import datetime +from __future__ import annotations + +from datetime import datetime, timezone, tzinfo from ...exceptions import KasaException from ..iotmodule import IotModule, merge +from ..iottimezone import get_timezone class Time(IotModule): """Implements the timezone settings.""" + _timezone: tzinfo = timezone.utc + def query(self): """Request time and timezone.""" q = self.query_for_command("get_time") @@ -16,11 +21,16 @@ def query(self): merge(q, self.query_for_command("get_timezone")) return q + async def _post_update_hook(self): + """Perform actions after a device update.""" + if res := self.data.get("get_timezone"): + self._timezone = await get_timezone(res.get("index")) + @property def time(self) -> datetime: """Return current device time.""" res = self.data["get_time"] - return datetime( + time = datetime( res["year"], res["month"], res["mday"], @@ -28,12 +38,12 @@ def time(self) -> datetime: res["min"], res["sec"], ) + return time.astimezone(self.timezone) @property - def timezone(self): + def timezone(self) -> tzinfo: """Return current timezone.""" - res = self.data["get_timezone"] - return res + return self._timezone async def get_time(self): """Return current device time.""" diff --git a/kasa/module.py b/kasa/module.py index faf17c4d3..68f5170d2 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -155,7 +155,7 @@ def _initialize_features(self): # noqa: B027 children's modules. """ - def _post_update_hook(self): # noqa: B027 + async def _post_update_hook(self): # noqa: B027 """Perform actions after a device update. This can be implemented if a module needs to perform actions each time diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py index 3203e82fa..1d2b64f22 100644 --- a/kasa/smart/modules/devicemodule.py +++ b/kasa/smart/modules/devicemodule.py @@ -10,7 +10,7 @@ class DeviceModule(SmartModule): REQUIRED_COMPONENT = "device" - def _post_update_hook(self): + async def _post_update_hook(self): """Perform actions after a device update. Overrides the default behaviour to disable a module if the query returns diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py index 8e0a37d89..487c25f35 100644 --- a/kasa/smart/modules/light.py +++ b/kasa/smart/modules/light.py @@ -152,7 +152,7 @@ def state(self) -> LightState: """Return the current light state.""" return self._light_state - def _post_update_hook(self) -> None: + async def _post_update_hook(self) -> None: if self._device.is_on is False: state = LightState(light_on=False) else: diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py index 7227c442e..55dd3d490 100644 --- a/kasa/smart/modules/lighteffect.py +++ b/kasa/smart/modules/lighteffect.py @@ -28,7 +28,7 @@ class LightEffect(SmartModule, SmartLightEffect): _effect_list: list[str] _scenes_names_to_id: dict[str, str] - def _post_update_hook(self) -> None: + async def _post_update_hook(self) -> None: """Update internal effect state.""" # Copy the effects so scene name updates do not update the underlying dict. effects = copy.deepcopy( diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 16cd15ae2..56ca42c22 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -34,7 +34,7 @@ def __init__(self, device: SmartDevice, module: str): self._state_in_sysinfo = self.SYS_INFO_STATE_KEY in device.sys_info self._brightness_only: bool = False - def _post_update_hook(self): + async def _post_update_hook(self): """Update the internal presets.""" index = 0 self._presets = {} diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index da05995d1..947f8b0e2 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -90,7 +90,7 @@ def _initialize_features(self): ) ) - def _post_update_hook(self) -> None: + async def _post_update_hook(self) -> None: """Update the states.""" # Assumes any device with state in sysinfo supports on and off and # has maximum values for both. diff --git a/kasa/smart/modules/time.py b/kasa/smart/modules/time.py index 254fd098b..13831b2e1 100644 --- a/kasa/smart/modules/time.py +++ b/kasa/smart/modules/time.py @@ -2,10 +2,12 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta, timezone, tzinfo from time import mktime from typing import cast +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + from ...feature import Feature from ..smartmodule import SmartModule @@ -31,18 +33,27 @@ def _initialize_features(self): ) @property - def time(self) -> datetime: - """Return device's current datetime.""" + def timezone(self) -> tzinfo: + """Return current timezone.""" td = timedelta(minutes=cast(float, self.data.get("time_diff"))) - if self.data.get("region"): - tz = timezone(td, str(self.data.get("region"))) + if region := self.data.get("region"): + try: + # Zoneinfo will return a DST aware object + tz: tzinfo = ZoneInfo(region) + except ZoneInfoNotFoundError: + tz = timezone(td, region) else: # in case the device returns a blank region this will result in the # tzname being a UTC offset tz = timezone(td) + return tz + + @property + def time(self) -> datetime: + """Return device's current datetime.""" return datetime.fromtimestamp( cast(float, self.data.get("timestamp")), - tz=tz, + tz=self.timezone, ) async def set_time(self, dt: datetime): diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 8fe3b969c..3b5f53efb 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -61,7 +61,7 @@ async def _update(self, update_children: bool = True): self._last_update = await self.protocol.query(req) for module in self.modules.values(): - self._handle_module_post_update( + await self._handle_module_post_update( module, now, had_query=module in module_queries ) self._last_update_time = now diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 8d373f580..095156e3d 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -6,8 +6,8 @@ import logging import time from collections.abc import Mapping, Sequence -from datetime import datetime, timedelta, timezone -from typing import Any, cast +from datetime import datetime, timedelta, timezone, tzinfo +from typing import TYPE_CHECKING, Any, cast from ..aestransport import AesTransport from ..device import Device, WifiNetwork @@ -168,7 +168,7 @@ async def update(self, update_children: bool = False): await self._initialize_modules() # Run post update for the cloud module if cloud_mod := self.modules.get(Module.Cloud): - self._handle_module_post_update(cloud_mod, now, had_query=True) + await self._handle_module_post_update(cloud_mod, now, had_query=True) resp = await self._modular_update(first_update, now) @@ -195,7 +195,7 @@ async def update(self, update_children: bool = False): updated = self._last_update if first_update else resp _LOGGER.debug("Update completed %s: %s", self.host, list(updated.keys())) - def _handle_module_post_update( + async def _handle_module_post_update( self, module: SmartModule, update_time: float, had_query: bool ): if module.disabled: @@ -203,7 +203,7 @@ def _handle_module_post_update( if had_query: module._last_update_time = update_time try: - module._post_update_hook() + await module._post_update_hook() module._set_error(None) except Exception as ex: # Only set the error if a query happened. @@ -260,7 +260,7 @@ async def _modular_update( # Call handle update for modules that want to update internal data for module in self._modules.values(): - self._handle_module_post_update( + await self._handle_module_post_update( module, update_time, had_query=module in module_queries ) @@ -516,10 +516,11 @@ def on_since(self) -> datetime | None: return self._on_since @property - def timezone(self) -> dict: + def timezone(self) -> tzinfo: """Return the timezone and time_difference.""" - ti = self.time - return {"timezone": ti.tzname()} + if TYPE_CHECKING: + assert self.time.tzinfo + return self.time.tzinfo @property def hw_info(self) -> dict: diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 381ce2333..1f4c4f482 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -121,7 +121,7 @@ def name(self) -> str: """Name of the module.""" return getattr(self, "NAME", self.__class__.__name__) - def _post_update_hook(self): # noqa: B027 + async def _post_update_hook(self): # noqa: B027 """Perform actions after a device update. Any modules overriding this should ensure that self.data is diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index f67d37c26..4b851d260 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -10,10 +10,16 @@ from unittest.mock import AsyncMock, patch import pytest +import zoneinfo import kasa from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module from kasa.iot import IotDevice +from kasa.iot.iottimezone import ( + TIMEZONE_INDEX, + get_timezone, + get_timezone_index, +) from kasa.iot.modules import IotLightPreset from kasa.smart import SmartChildDevice, SmartDevice @@ -299,3 +305,29 @@ def _mock_connect(config, *args, **kwargs): ) assert isinstance(dev.config, DeviceConfig) assert DeviceType.Dimmer == Device.Type.Dimmer + + +async def test_device_timezones(): + """Test the timezone data is good.""" + # Check all indexes return a zoneinfo + for i in range(110): + tz = await get_timezone(i) + assert tz + assert tz != zoneinfo.ZoneInfo("Etc/UTC"), f"{i} is default Etc/UTC" + + # Check an unexpected index returns a UTC default. + tz = await get_timezone(110) + assert tz == zoneinfo.ZoneInfo("Etc/UTC") + + # Get an index from a timezone + for index, zone in TIMEZONE_INDEX.items(): + found_index = await get_timezone_index(zone) + assert found_index == index + + # Try a timezone not hardcoded finds another match + index = await get_timezone_index("Asia/Katmandu") + assert index == 77 + + # Try a timezone not hardcoded no match + with pytest.raises(zoneinfo.ZoneInfoNotFoundError): + await get_timezone_index("Foo/bar") diff --git a/pyproject.toml b/pyproject.toml index dfe7de5bd..7cb875c43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "async-timeout>=3.0.0", "aiohttp>=3", "typing-extensions>=4.12.2,<5.0", + "tzdata>=2024.2 ; platform_system == 'Windows'", ] classifiers = [ diff --git a/uv.lock b/uv.lock index 509b6536f..f47996bb8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,8 +1,10 @@ version = 1 requires-python = ">=3.9, <4.0" resolution-markers = [ - "python_full_version < '3.13'", - "python_full_version >= '3.13'", + "python_full_version < '3.13' and platform_system == 'Windows'", + "python_full_version < '3.13' and platform_system != 'Windows'", + "python_full_version >= '3.13' and platform_system == 'Windows'", + "python_full_version >= '3.13' and platform_system != 'Windows'", ] [[package]] @@ -1351,6 +1353,7 @@ dependencies = [ { name = "cryptography" }, { name = "pydantic" }, { name = "typing-extensions" }, + { name = "tzdata", marker = "platform_system == 'Windows'" }, ] [package.optional-dependencies] @@ -1407,6 +1410,7 @@ requires-dist = [ { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0" }, { name = "sphinxcontrib-programoutput", marker = "extra == 'docs'", specifier = "~=0.0" }, { name = "typing-extensions", specifier = ">=4.12.2,<5.0" }, + { name = "tzdata", marker = "platform_system == 'Windows'", specifier = ">=2024.2" }, ] [package.metadata.requires-dev] @@ -1693,6 +1697,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] +[[package]] +name = "tzdata" +version = "2024.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/34/943888654477a574a86a98e9896bae89c7aa15078ec29f490fef2f1e5384/tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc", size = 193282 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586 }, +] + [[package]] name = "urllib3" version = "2.2.3" From 7c1686d3ae704987ea99916c537a67dbe506447d Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 8 Oct 2024 12:21:01 +0100 Subject: [PATCH 592/892] Cache zoneinfo for smart devices (#1156) --- kasa/cachedzoneinfo.py | 28 ++++++++++++++++++++++++++++ kasa/iot/iottimezone.py | 30 ++++-------------------------- kasa/smart/modules/time.py | 19 +++++++++++++------ 3 files changed, 45 insertions(+), 32 deletions(-) create mode 100644 kasa/cachedzoneinfo.py diff --git a/kasa/cachedzoneinfo.py b/kasa/cachedzoneinfo.py new file mode 100644 index 000000000..c70e83097 --- /dev/null +++ b/kasa/cachedzoneinfo.py @@ -0,0 +1,28 @@ +"""Module for caching ZoneInfos.""" + +from __future__ import annotations + +import asyncio + +from zoneinfo import ZoneInfo + + +class CachedZoneInfo(ZoneInfo): + """Cache ZoneInfo objects.""" + + _cache: dict[str, ZoneInfo] = {} + + @classmethod + async def get_cached_zone_info(cls, time_zone_str: str) -> ZoneInfo: + """Get a cached zone info object.""" + if cached := cls._cache.get(time_zone_str): + return cached + loop = asyncio.get_running_loop() + zinfo = await loop.run_in_executor(None, _get_zone_info, time_zone_str) + cls._cache[time_zone_str] = zinfo + return zinfo + + +def _get_zone_info(time_zone_str: str) -> ZoneInfo: + """Get a time zone object for the given time zone string.""" + return ZoneInfo(time_zone_str) diff --git a/kasa/iot/iottimezone.py b/kasa/iot/iottimezone.py index 53cb219e0..ccbed3e74 100644 --- a/kasa/iot/iottimezone.py +++ b/kasa/iot/iottimezone.py @@ -2,11 +2,10 @@ from __future__ import annotations -import asyncio import logging from datetime import datetime, tzinfo -from zoneinfo import ZoneInfo +from ..cachedzoneinfo import CachedZoneInfo _LOGGER = logging.getLogger(__name__) @@ -17,10 +16,10 @@ async def get_timezone(index: int) -> tzinfo: _LOGGER.error( "Unexpected index %s not configured as a timezone, defaulting to UTC", index ) - return await _CachedZoneInfo.get_cached_zone_info("Etc/UTC") + return await CachedZoneInfo.get_cached_zone_info("Etc/UTC") name = TIMEZONE_INDEX[index] - return await _CachedZoneInfo.get_cached_zone_info(name) + return await CachedZoneInfo.get_cached_zone_info(name) async def get_timezone_index(name: str) -> int: @@ -30,7 +29,7 @@ async def get_timezone_index(name: str) -> int: return rev[name] # Try to find a supported timezone matching dst true/false - zone = await _CachedZoneInfo.get_cached_zone_info(name) + zone = await CachedZoneInfo.get_cached_zone_info(name) now = datetime.now() winter = datetime(now.year, 1, 1, 12) summer = datetime(now.year, 7, 1, 12) @@ -43,27 +42,6 @@ async def get_timezone_index(name: str) -> int: raise ValueError("Device does not support timezone %s", name) -class _CachedZoneInfo(ZoneInfo): - """Cache zone info objects.""" - - _cache: dict[str, ZoneInfo] = {} - - @classmethod - async def get_cached_zone_info(cls, time_zone_str: str) -> ZoneInfo: - """Get a cached zone info object.""" - if cached := cls._cache.get(time_zone_str): - return cached - loop = asyncio.get_running_loop() - zinfo = await loop.run_in_executor(None, _get_zone_info, time_zone_str) - cls._cache[time_zone_str] = zinfo - return zinfo - - -def _get_zone_info(time_zone_str: str) -> ZoneInfo: - """Get a time zone object for the given time zone string.""" - return ZoneInfo(time_zone_str) - - TIMEZONE_INDEX = { 0: "Etc/GMT+12", 1: "Pacific/Samoa", diff --git a/kasa/smart/modules/time.py b/kasa/smart/modules/time.py index 13831b2e1..21dd13a40 100644 --- a/kasa/smart/modules/time.py +++ b/kasa/smart/modules/time.py @@ -6,8 +6,9 @@ from time import mktime from typing import cast -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from zoneinfo import ZoneInfoNotFoundError +from ...cachedzoneinfo import CachedZoneInfo from ...feature import Feature from ..smartmodule import SmartModule @@ -18,6 +19,8 @@ class Time(SmartModule): REQUIRED_COMPONENT = "time" QUERY_GETTER_NAME = "get_device_time" + _timezone: tzinfo = timezone.utc + def _initialize_features(self): """Initialize features after the initial update.""" self._add_feature( @@ -32,21 +35,25 @@ def _initialize_features(self): ) ) - @property - def timezone(self) -> tzinfo: - """Return current timezone.""" + async def _post_update_hook(self): + """Perform actions after a device update.""" td = timedelta(minutes=cast(float, self.data.get("time_diff"))) if region := self.data.get("region"): try: # Zoneinfo will return a DST aware object - tz: tzinfo = ZoneInfo(region) + tz: tzinfo = await CachedZoneInfo.get_cached_zone_info(region) except ZoneInfoNotFoundError: tz = timezone(td, region) else: # in case the device returns a blank region this will result in the # tzname being a UTC offset tz = timezone(td) - return tz + self._timezone = tz + + @property + def timezone(self) -> tzinfo: + """Return current timezone.""" + return self._timezone @property def time(self) -> datetime: From bd5a24b0ed0dbfb45eb68d40504453bab927a01e Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 8 Oct 2024 12:33:19 +0100 Subject: [PATCH 593/892] Use tzinfo in time constructor instead of astime for iot devices (#1158) Fixes using `astime` on a non tzinfo aware object which causes issues with daylight saving. --- kasa/iot/modules/time.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py index 23cc50103..997a5b4d7 100644 --- a/kasa/iot/modules/time.py +++ b/kasa/iot/modules/time.py @@ -37,8 +37,9 @@ def time(self) -> datetime: res["hour"], res["min"], res["sec"], + tzinfo=self.timezone, ) - return time.astimezone(self.timezone) + return time @property def timezone(self) -> tzinfo: From 885a04d24f8cfe9d1238a51a9473322489c26723 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 8 Oct 2024 13:59:01 +0100 Subject: [PATCH 594/892] Prepare 0.7.5 (#1160) ## [0.7.5](https://github.com/python-kasa/python-kasa/tree/0.7.5) (2024-10-08) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.4...0.7.5) **Release summary:** - Fix for KP303 on Firmware 1.0.6 - Fix for `on_since` value jitter - Various maintenance items **Breaking changes:** - Make iot time timezone aware [\#1147](https://github.com/python-kasa/python-kasa/pull/1147) (@sdb9696) **Fixed bugs:** - Use tzinfo in time constructor instead of astime for iot devices [\#1158](https://github.com/python-kasa/python-kasa/pull/1158) (@sdb9696) - Send empty dictionary instead of null for iot queries [\#1145](https://github.com/python-kasa/python-kasa/pull/1145) (@sdb9696) - Stabilise on\_since value for smart devices [\#1144](https://github.com/python-kasa/python-kasa/pull/1144) (@sdb9696) - parse\_pcap\_klap: require source host [\#1137](https://github.com/python-kasa/python-kasa/pull/1137) (@rytilahti) - parse\_pcap\_klap: use request\_uri for matching the response [\#1136](https://github.com/python-kasa/python-kasa/pull/1136) (@rytilahti) **Project maintenance:** - Cache zoneinfo for smart devices [\#1156](https://github.com/python-kasa/python-kasa/pull/1156) (@sdb9696) - Correctly define SmartModule.call as an async function [\#1148](https://github.com/python-kasa/python-kasa/pull/1148) (@sdb9696) - Remove async magic patch from tests [\#1146](https://github.com/python-kasa/python-kasa/pull/1146) (@sdb9696) - Move feature initialization from \_\_init\_\_ to \_initialize\_features [\#1140](https://github.com/python-kasa/python-kasa/pull/1140) (@rytilahti) --- .github_changelog_generator | 4 +- CHANGELOG.md | 34 +++ pyproject.toml | 2 +- uv.lock | 553 +++++++++++++++++++++--------------- 4 files changed, 368 insertions(+), 225 deletions(-) diff --git a/.github_changelog_generator b/.github_changelog_generator index f72e740cd..538e6ec14 100644 --- a/.github_changelog_generator +++ b/.github_changelog_generator @@ -3,9 +3,9 @@ base=HISTORY.md user=python-kasa project=python-kasa since-tag=0.3.5 -release_branch=master +release-branch=master usernames-as-github-logins=true breaking_labels=breaking change add-sections={"new-device":{"prefix":"**Added support for devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}} -exclude-labels=duplicate,question,invalid,wontfix,release-prep +exclude-labels=duplicate,question,invalid,wontfix,release-prep,stale issues-wo-labels=false diff --git a/CHANGELOG.md b/CHANGELOG.md index d4fe59d41..b6b299f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [0.7.5](https://github.com/python-kasa/python-kasa/tree/0.7.5) (2024-10-08) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.4...0.7.5) + +**Release summary:** + +- Fix for KP303 on Firmware 1.0.6 +- Fix for `on_since` value jitter +- Various maintenance items + +**Breaking changes:** + +- Make iot time timezone aware [\#1147](https://github.com/python-kasa/python-kasa/pull/1147) (@sdb9696) + +**Fixed bugs:** + +- Use tzinfo in time constructor instead of astime for iot devices [\#1158](https://github.com/python-kasa/python-kasa/pull/1158) (@sdb9696) +- Send empty dictionary instead of null for iot queries [\#1145](https://github.com/python-kasa/python-kasa/pull/1145) (@sdb9696) +- Stabilise on\_since value for smart devices [\#1144](https://github.com/python-kasa/python-kasa/pull/1144) (@sdb9696) +- parse\_pcap\_klap: require source host [\#1137](https://github.com/python-kasa/python-kasa/pull/1137) (@rytilahti) +- parse\_pcap\_klap: use request\_uri for matching the response [\#1136](https://github.com/python-kasa/python-kasa/pull/1136) (@rytilahti) + + +**Project maintenance:** + +- Cache zoneinfo for smart devices [\#1156](https://github.com/python-kasa/python-kasa/pull/1156) (@sdb9696) +- Correctly define SmartModule.call as an async function [\#1148](https://github.com/python-kasa/python-kasa/pull/1148) (@sdb9696) +- Remove async magic patch from tests [\#1146](https://github.com/python-kasa/python-kasa/pull/1146) (@sdb9696) +- Move feature initialization from \_\_init\_\_ to \_initialize\_features [\#1140](https://github.com/python-kasa/python-kasa/pull/1140) (@rytilahti) + ## [0.7.4](https://github.com/python-kasa/python-kasa/tree/0.7.4) (2024-09-27) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.3...0.7.4) @@ -447,6 +477,10 @@ For more information on the changes please checkout our [documentation on the AP - Fix dump\_devinfo scrubbing for ks240 [\#765](https://github.com/python-kasa/python-kasa/pull/765) (@rytilahti) - Rename and deprecate exception classes [\#739](https://github.com/python-kasa/python-kasa/pull/739) (@sdb9696) +**Closed issues:** + +- Improve timezone support [\#980](https://github.com/python-kasa/python-kasa/issues/980) + ## [0.6.2.1](https://github.com/python-kasa/python-kasa/tree/0.6.2.1) (2024-02-02) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2...0.6.2.1) diff --git a/pyproject.toml b/pyproject.toml index 7cb875c43..8d2d58b9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-kasa" -version = "0.7.4" +version = "0.7.5" description = "Python API for TP-Link Kasa and Tapo devices" license = {text = "GPL-3.0-or-later"} authors = [ { name = "python-kasa developers" }] diff --git a/uv.lock b/uv.lock index f47996bb8..3bfd51844 100644 --- a/uv.lock +++ b/uv.lock @@ -1,24 +1,22 @@ version = 1 requires-python = ">=3.9, <4.0" resolution-markers = [ - "python_full_version < '3.13' and platform_system == 'Windows'", - "python_full_version < '3.13' and platform_system != 'Windows'", - "python_full_version >= '3.13' and platform_system == 'Windows'", - "python_full_version >= '3.13' and platform_system != 'Windows'", + "python_full_version < '3.13'", + "python_full_version >= '3.13'", ] [[package]] name = "aiohappyeyeballs" -version = "2.4.2" +version = "2.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/d9/e710a5c9e51b4d5a977c823ce323a81d344da8c1b6fba16bb270a8be800d/aiohappyeyeballs-2.4.2.tar.gz", hash = "sha256:4ca893e6c5c1f5bf3888b04cb5a3bee24995398efef6e0b9f747b5e89d84fd74", size = 18391 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/69/2f6d5a019bd02e920a3417689a89887b39ad1e350b562f9955693d900c40/aiohappyeyeballs-2.4.3.tar.gz", hash = "sha256:75cf88a15106a5002a8eb1dab212525c00d1f4c0fa96e551c9fbe6f09a621586", size = 21809 } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/64/40165ff77ade5203284e3015cf88e11acb07d451f6bf83fff71192912a0d/aiohappyeyeballs-2.4.2-py3-none-any.whl", hash = "sha256:8522691d9a154ba1145b157d6d5c15e5c692527ce6a53c5e5f9876977f6dab2f", size = 14105 }, + { url = "https://files.pythonhosted.org/packages/f7/d8/120cd0fe3e8530df0539e71ba9683eade12cae103dd7543e50d15f737917/aiohappyeyeballs-2.4.3-py3-none-any.whl", hash = "sha256:8a7a83727b2756f394ab2895ea0765a0a8c475e3c71e98d43d76f22b4b435572", size = 14742 }, ] [[package]] name = "aiohttp" -version = "3.10.6" +version = "3.10.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -29,83 +27,83 @@ dependencies = [ { name = "multidict" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/97/15c51bbfcc184bcb4d473b7b02e7b54b6978e0083556a9cd491875cf11f7/aiohttp-3.10.6.tar.gz", hash = "sha256:d2578ef941be0c2ba58f6f421a703527d08427237ed45ecb091fed6f83305336", size = 7538429 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/a5/20d5b4cc1dbf5292434ad968af698c3e25b149394060578c4e83edfe5c56/aiohttp-3.10.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:682836fc672972cc3101cc9e30d49c5f7e8f1d010478d46119fe725a4545acfd", size = 586587 }, - { url = "https://files.pythonhosted.org/packages/d6/77/bcb8f5e382d800673c6457cab3cb24143ae30578682687d334a556fe4021/aiohttp-3.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:289fa8a20018d0d5aa9e4b35d899bd51bcb80f0d5f365d9a23e30dac3b79159b", size = 398987 }, - { url = "https://files.pythonhosted.org/packages/19/b1/874ca8a6fd581303f1f99efc71aff034e1b955702d54b96a61d97f18387f/aiohttp-3.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8617c96a20dd57e7e9d398ff9d04f3d11c4d28b1767273a5b1a018ada5a654d3", size = 390350 }, - { url = "https://files.pythonhosted.org/packages/41/2b/18f60f36d3b0d6b4f9680b00e129058086984af33ab01743d8f8d662ae43/aiohttp-3.10.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bdbeff1b062751c2a2a55b171f7050fb7073633c699299d042e962aacdbe1a07", size = 1228449 }, - { url = "https://files.pythonhosted.org/packages/d5/77/801a07f67a6ccfc2d6e8c372e196b6019f33d637e6a3abf84f876983dbfd/aiohttp-3.10.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ea35d849cdd4a9268f910bff4497baebbc1aa3f2f625fd8ccd9ac99c860c621", size = 1264246 }, - { url = "https://files.pythonhosted.org/packages/bd/ff/984219306cbc1fedd689e9cfe7894d0cb2ae0038f2d7079e2788a79383ee/aiohttp-3.10.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:473961b3252f3b949bb84873d6e268fb6d8aa0ccc6eb7404fa58c76a326bb8e1", size = 1298031 }, - { url = "https://files.pythonhosted.org/packages/25/34/96445dc2db0ff0e7ffe71abb73e298a62c2724b774470d5b232ed8ee89ad/aiohttp-3.10.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d2665c5df629eb2f981dab244c01bfa6cdc185f4ffa026639286c4d56fafb54", size = 1221827 }, - { url = "https://files.pythonhosted.org/packages/6d/59/52170050e3e83e476321cce2232c456d55ecf0b67faf9a31b73328d7b65f/aiohttp-3.10.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25d92f794f1332f656e3765841fc2b7ad5c26c3f3d01e8949eeb3495691cf9f4", size = 1193436 }, - { url = "https://files.pythonhosted.org/packages/4f/ad/cb020bdb02e41ed4cf0f9a1031c67424d9dd1b1dd3e5fd98053ed3b4f72f/aiohttp-3.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9bd6b2033993d5ae80883bb29b83fb2b432270bbe067c2f53cc73bb57c46065f", size = 1193175 }, - { url = "https://files.pythonhosted.org/packages/59/d3/58cf6e9c81064d07173ee0e31743fa18212e3c76d1041a30164e91e91e7d/aiohttp-3.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d7f408c43f5e75ea1edc152fb375e8f46ef916f545fb66d4aebcbcfad05e2796", size = 1192674 }, - { url = "https://files.pythonhosted.org/packages/b2/1f/c203e3ff4636885f8d47228a717f248b7acd5761a9fb57650f8ad393cb1a/aiohttp-3.10.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:cf8b8560aa965f87bf9c13bf9fed7025993a155ca0ce8422da74bf46d18c2f5f", size = 1246831 }, - { url = "https://files.pythonhosted.org/packages/e2/e4/8534f620113c9922d911271678f6f053192627035bfa7b0b62c9baa48908/aiohttp-3.10.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14477c4e52e2f17437b99893fd220ffe7d7ee41df5ebf931a92b8ca82e6fd094", size = 1263732 }, - { url = "https://files.pythonhosted.org/packages/3c/99/d5ada3481146b42312a83b16304b001125df8e8e7751c71a0f26cf4fb38f/aiohttp-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb138fbf9f53928e779650f5ed26d0ea1ed8b2cab67f0ea5d63afa09fdc07593", size = 1215377 }, - { url = "https://files.pythonhosted.org/packages/82/2d/a5afdfb5c37a7dbb4468ff39a4581a6290b2ced18fb5513981a9154c91c1/aiohttp-3.10.6-cp310-cp310-win32.whl", hash = "sha256:9843d683b8756971797be171ead21511d2215a2d6e3c899c6e3107fbbe826791", size = 362309 }, - { url = "https://files.pythonhosted.org/packages/49/2e/d06c4bf365685ba0ea501e5bb5b4a4b0c3f90236f8a38ee0083c56624847/aiohttp-3.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:f8b8e49fe02f744d38352daca1dbef462c3874900bd8166516f6ea8e82b5aacf", size = 380724 }, - { url = "https://files.pythonhosted.org/packages/d5/f0/6c921693dd264db370916dab69cc3267ca4bb14296b4ca88b3855f6152cd/aiohttp-3.10.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52e54fd776ad0da1006708762213b079b154644db54bcfc62f06eaa5b896402", size = 586118 }, - { url = "https://files.pythonhosted.org/packages/df/14/12804459bd128ff3c7d60dadaf49be9e7027df5c8800290518113c411d00/aiohttp-3.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:995ab1a238fd0d19dc65f2d222e5eb064e409665c6426a3e51d5101c1979ee84", size = 398614 }, - { url = "https://files.pythonhosted.org/packages/49/2c/066640d3c3dd52d4c11dfcff64c6eebe4a7607571bc9cb8ae2c2b4013367/aiohttp-3.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0749c4d5a08a802dd66ecdf59b2df4d76b900004017468a7bb736c3b5a3dd902", size = 390262 }, - { url = "https://files.pythonhosted.org/packages/71/50/6db8a9ba23ee4d5621ec2a59427c271cc1ddaf4fc1a9c02c9dcba1ebe671/aiohttp-3.10.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e05b39158f2af0e2438cc2075cfc271f4ace0c3cc4a81ec95b27a0432e161951", size = 1306113 }, - { url = "https://files.pythonhosted.org/packages/51/fd/a923745abb24657264b2122c24a296468cf8c16ba68b7b569060d6c32620/aiohttp-3.10.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f196c970db2dcde4f24317e06615363349dc357cf4d7a3b0716c20ac6d7bcd", size = 1344288 }, - { url = "https://files.pythonhosted.org/packages/f8/7a/5f1397305aa5885a35dce0b10681aa547537348a18d107d96a07e99bebb8/aiohttp-3.10.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:47647c8af04a70e07a2462931b0eba63146a13affa697afb4ecbab9d03a480ce", size = 1378118 }, - { url = "https://files.pythonhosted.org/packages/fd/80/4f1c4b5459a27437a8f18f91d6000fdc45b677aee879129deaadc94c1a23/aiohttp-3.10.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:669c0efe7e99f6d94d63274c06344bd0e9c8daf184ce5602a29bc39e00a18720", size = 1292337 }, - { url = "https://files.pythonhosted.org/packages/e1/57/cef69e70f18271f86080a3d28571598baf0dccb2fc726fbd74b91a56d51a/aiohttp-3.10.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9721cdd83a994225352ca84cd537760d41a9da3c0eacb3ff534747ab8fba6d0", size = 1251407 }, - { url = "https://files.pythonhosted.org/packages/b0/dd/8b718a8ecb271d484c6d43f4ae3d63e684c259367c8c2cda861f1bf12cfd/aiohttp-3.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0b82c8ebed66ce182893e7c0b6b60ba2ace45b1df104feb52380edae266a4850", size = 1271350 }, - { url = "https://files.pythonhosted.org/packages/f8/37/c80d05752ecbe7419ec61d39facff8d77914e295c8d45eb250d1fa03ae78/aiohttp-3.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b169f8e755e541b72e714b89a831b315bbe70db44e33fead28516c9e13d5f931", size = 1265888 }, - { url = "https://files.pythonhosted.org/packages/4f/44/9862295fabcadcf7d79e9a92eb8528866d602042571c43c333d94c7f3025/aiohttp-3.10.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0be3115753baf8b4153e64f9aa7bf6c0c64af57979aa900c31f496301b374570", size = 1321251 }, - { url = "https://files.pythonhosted.org/packages/57/62/5b92e910aa95c2558b418eb68f0d117aab968cdd15019c06ea1c66d0baf2/aiohttp-3.10.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e1f80cd17d81a404b6e70ef22bfe1870bafc511728397634ad5f5efc8698df56", size = 1338856 }, - { url = "https://files.pythonhosted.org/packages/7e/e6/bb013958e9fcfb982d8dba12b0c72621427619cd0a11bb3023601c205988/aiohttp-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6419728b08fb6380c66a470d2319cafcec554c81780e2114b7e150329b9a9a7f", size = 1298691 }, - { url = "https://files.pythonhosted.org/packages/f7/c7/cc2dc01f89d8a0ee2d84d8d0c85b48ec62a427bcd865736f9ceb340c0117/aiohttp-3.10.6-cp311-cp311-win32.whl", hash = "sha256:bd294dcdc1afdc510bb51d35444003f14e327572877d016d576ac3b9a5888a27", size = 361858 }, - { url = "https://files.pythonhosted.org/packages/9f/2a/60284a07a0353250cf64db9728980a3bb9a55eeea334d79c48e65801460a/aiohttp-3.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:bf861da9a43d282d6dd9dcd64c23a0fccf2c5aa5cd7c32024513c8c79fb69de3", size = 381204 }, - { url = "https://files.pythonhosted.org/packages/41/6b/0db03d1105e5e8564fd39a87729fd910300a8021b2c59f6f57ed963fe896/aiohttp-3.10.6-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:2708baccdc62f4b1251e59c2aac725936a900081f079b88843dabcab0feeeb27", size = 583162 }, - { url = "https://files.pythonhosted.org/packages/d0/9e/c44dddee462c38853a0c32b50c4deed09790d27496ab9eb3b481614344a5/aiohttp-3.10.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7475da7a5e2ccf1a1c86c8fee241e277f4874c96564d06f726d8df8e77683ef7", size = 395317 }, - { url = "https://files.pythonhosted.org/packages/25/0e/c0dfb1604645ab64e2b1210e624f951a024a2e9683feb563bbf979874220/aiohttp-3.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:02108326574ff60267b7b35b17ac5c0bbd0008ccb942ce4c48b657bb90f0b8aa", size = 390533 }, - { url = "https://files.pythonhosted.org/packages/5c/50/8c3eba14ce77fd78f1def3788cbc75b54291dd4d8f5647d721316437f5da/aiohttp-3.10.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:029a019627b37fa9eac5c75cc54a6bb722c4ebbf5a54d8c8c0fb4dd8facf2702", size = 1311699 }, - { url = "https://files.pythonhosted.org/packages/bd/02/d0f12cfc7ade482d81c6d2c4c5f2f98964d6305560b7df0b7712212241ca/aiohttp-3.10.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a637d387db6fdad95e293fab5433b775fd104ae6348d2388beaaa60d08b38c4", size = 1350180 }, - { url = "https://files.pythonhosted.org/packages/31/2b/f78ff8d84e700a279434dd371ae6e87e12a13f9ed2a5efe9cd6aacd749d4/aiohttp-3.10.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dc1a16f3fc1944c61290d33c88dc3f09ba62d159b284c38c5331868425aca426", size = 1392281 }, - { url = "https://files.pythonhosted.org/packages/a5/f8/a8722a471cbf19e56763545fd5bc0fdf7b61324535f0b35bd6f0548d4016/aiohttp-3.10.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81b292f37969f9cc54f4643f0be7dacabf3612b3b4a65413661cf6c350226787", size = 1305932 }, - { url = "https://files.pythonhosted.org/packages/38/a5/897caff83bfe41fd749056b11282504772b34c2dfe730aaf8e84bbd3a660/aiohttp-3.10.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0754690a3a26e819173a34093798c155bafb21c3c640bff13be1afa1e9d421f9", size = 1259884 }, - { url = "https://files.pythonhosted.org/packages/e1/75/effbadbf5c9a536f90769544467da311efd6e8c43671bc0729055c59d363/aiohttp-3.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:164ecd32e65467d86843dbb121a6666c3deb23b460e3f8aefdcaacae79eb718a", size = 1270764 }, - { url = "https://files.pythonhosted.org/packages/33/34/33e07d1bc34406bfc0877f22eed071060796431488c8eb6d456c583a74a9/aiohttp-3.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:438c5863feb761f7ca3270d48c292c334814459f61cc12bab5ba5b702d7c9e56", size = 1279882 }, - { url = "https://files.pythonhosted.org/packages/2e/e4/ffed46ce0b45564cbf715b0b97725840468c7c5a9d6e8d560082c29ad4bf/aiohttp-3.10.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ba18573bb1de1063d222f41de64a0d3741223982dcea863b3f74646faf618ec7", size = 1316432 }, - { url = "https://files.pythonhosted.org/packages/ce/00/488d68568f60aa5dbf9d41ef60d276ffbafeab553bf79b00225de7133e0b/aiohttp-3.10.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c82a94ddec996413a905f622f3da02c4359952aab8d817c01cf9915419525e95", size = 1343562 }, - { url = "https://files.pythonhosted.org/packages/14/3e/3679c1438fcb0aadddff32e97b3b88b1c8aea80276d374ec543a5ed70d0d/aiohttp-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:92351aa5363fc3c1f872ca763f86730ced32b01607f0c9662b1fa711087968d0", size = 1305803 }, - { url = "https://files.pythonhosted.org/packages/76/48/fe117dffa13c69d9670e107cbf3dea20be9f7fc5d30d2fd3fd6252f28c58/aiohttp-3.10.6-cp312-cp312-win32.whl", hash = "sha256:3e15e33bfc73fa97c228f72e05e8795e163a693fd5323549f49367c76a6e5883", size = 358921 }, - { url = "https://files.pythonhosted.org/packages/92/9f/7281a6dae91c9cc3f23dfb865f074151810216f31bdb46843bfde8e39f17/aiohttp-3.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:fe517113fe4d35d9072b826c3e147d63c5f808ca8167d450b4f96c520c8a1d8d", size = 378938 }, - { url = "https://files.pythonhosted.org/packages/12/7f/89eb922fda25d5b9c7c08d14d50c788d998f148210478059b7549040424a/aiohttp-3.10.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:482f74057ea13d387a7549d7a7ecb60e45146d15f3e58a2d93a0ad2d5a8457cd", size = 575722 }, - { url = "https://files.pythonhosted.org/packages/84/6d/eb3965c55748f960751b752969983982a995d2aa21f023ed30fe5a471629/aiohttp-3.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:03fa40d1450ee5196e843315ddf74a51afc7e83d489dbfc380eecefea74158b1", size = 391518 }, - { url = "https://files.pythonhosted.org/packages/78/4a/98c9d9cee601477eda8f851376eff88e864e9f3147cbc3a428da47d90ed0/aiohttp-3.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e52e59ed5f4cc3a3acfe2a610f8891f216f486de54d95d6600a2c9ba1581f4d", size = 387037 }, - { url = "https://files.pythonhosted.org/packages/2d/b0/6136aefae0f0d2abe4a435af71a944781e37bbe6fd836a23ff41bbba0682/aiohttp-3.10.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b3935a22c9e41a8000d90588bed96cf395ef572dbb409be44c6219c61d900d", size = 1286703 }, - { url = "https://files.pythonhosted.org/packages/cd/8a/a17ec94a7b6394efeeaca16df8d1e9359f0aa83548e40bf16b5853ed7684/aiohttp-3.10.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bef1480ee50f75abcfcb4b11c12de1005968ca9d0172aec4a5057ba9f2b644f", size = 1323244 }, - { url = "https://files.pythonhosted.org/packages/35/37/4cf6d2a8dce91ea7ff8b8ed8e1ef5c6a5934e07b4da5993ae95660b7cfbc/aiohttp-3.10.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:671745ea7db19693ce867359d503772177f0b20fa8f6ee1e74e00449f4c4151d", size = 1368034 }, - { url = "https://files.pythonhosted.org/packages/0d/d5/e939fcf26bd5c7760a1b71eff7396f6ca0e3c807088086551db28af0c090/aiohttp-3.10.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b50b367308ca8c12e0b50cba5773bc9abe64c428d3fd2bbf5cd25aab37c77bf", size = 1282395 }, - { url = "https://files.pythonhosted.org/packages/46/44/85d5d61b3ac50f30766cd2c1d22e6f937f027922621fc91581ead05749f6/aiohttp-3.10.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a504d7cdb431a777d05a124fd0b21efb94498efa743103ea01b1e3136d2e4fb", size = 1236147 }, - { url = "https://files.pythonhosted.org/packages/61/35/43eee26590f369906151cea78297554304ed2ceda5a5ed69cc2e907e9903/aiohttp-3.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66bc81361131763660b969132a22edce2c4d184978ba39614e8f8f95db5c95f8", size = 1249963 }, - { url = "https://files.pythonhosted.org/packages/44/b5/e099ad2bf7ad6ab5bb685f66a7599dc7f9fb4879eb987a4bf02ca2886974/aiohttp-3.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:27cf19a38506e2e9f12fc17e55f118f04897b0a78537055d93a9de4bf3022e3d", size = 1248579 }, - { url = "https://files.pythonhosted.org/packages/85/81/520348e8ec472679e65deb87c2a2bb2ad2c40e328746245bd35251b7ee4f/aiohttp-3.10.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3468b39f977a11271517c6925b226720e148311039a380cc9117b1e2258a721f", size = 1293005 }, - { url = "https://files.pythonhosted.org/packages/e5/a8/1ddd2af786c3b4f30187bc98464b8e3c54c6bbf18062a20291c6b5b03f27/aiohttp-3.10.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9d26da22a793dfd424be1050712a70c0afd96345245c29aced1e35dbace03413", size = 1319740 }, - { url = "https://files.pythonhosted.org/packages/0a/6f/a757fdf01ce4d20fcfee35af3b63a2393dbd3478873c4ea9aaad24b093f1/aiohttp-3.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:844d48ff9173d0b941abed8b2ea6a412f82b56d9ab1edb918c74000c15839362", size = 1281177 }, - { url = "https://files.pythonhosted.org/packages/9d/d9/e5866f341cfad4de82caf570218a424f96914192a9230dd6f6dfe4653a93/aiohttp-3.10.6-cp313-cp313-win32.whl", hash = "sha256:2dd56e3c43660ed3bea67fd4c5025f1ac1f9ecf6f0b991a6e5efe2e678c490c5", size = 357148 }, - { url = "https://files.pythonhosted.org/packages/57/cc/ba781a170fd4405819cc988026cfa16a9397ffebf5639dc84ad65d518448/aiohttp-3.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:c91781d969fbced1993537f45efe1213bd6fccb4b37bfae2a026e20d6fbed206", size = 376413 }, - { url = "https://files.pythonhosted.org/packages/e3/8c/09c36451df52c753e46be8a1d9533d61d19acdced8424e06575d41285e24/aiohttp-3.10.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5db26bbca8e7968c4c977a0c640e0b9ce7224e1f4dcafa57870dc6ee28e27de6", size = 588214 }, - { url = "https://files.pythonhosted.org/packages/2c/90/2e5f130dbac00f615c99a041fbd0734f40d68f94f32e7e5bc74ac148e228/aiohttp-3.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3fb4216e3ec0dbc01db5ba802f02ed78ad8f07121be54eb9e918448cc3f61b7c", size = 399874 }, - { url = "https://files.pythonhosted.org/packages/d3/de/b60c688d89c357b23084facc1602a23dcfac812d50175bdd6b0d941e8e08/aiohttp-3.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a976ef488f26e224079deb3d424f29144c6d5ba4ded313198169a8af8f47fb82", size = 391201 }, - { url = "https://files.pythonhosted.org/packages/00/b8/a3559410e6fa6e96eec4623a517c84e6774576a276dad5380e1720871760/aiohttp-3.10.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a86610174de8a85a920e956e2d4f9945e7da89f29a00e95ac62a4a414c4ef4e", size = 1233301 }, - { url = "https://files.pythonhosted.org/packages/72/cd/62c6c417bc5e49087ae7ae9f66f64c10df6601ead4d2646f5a4a7630ac30/aiohttp-3.10.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:217791c6a399cc4f2e6577bb44344cba1f5714a2aebf6a0bea04cfa956658284", size = 1270683 }, - { url = "https://files.pythonhosted.org/packages/57/67/e6dc17dbdefb459ec3e5b6e8b3332f0e11683fac6fa7ac4b74335a9edc7a/aiohttp-3.10.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba3662d41abe2eab0eeec7ee56f33ef4e0b34858f38abf24377687f9e1fb00a5", size = 1304500 }, - { url = "https://files.pythonhosted.org/packages/d0/d3/553e55b6adc881e44e9024d92f9dd70c538d59a19d6a58cb715f0838ce24/aiohttp-3.10.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4dfa5ad4bce9ca30a76117fbaa1c1decf41ebb6c18a4e098df44298941566f9", size = 1225049 }, - { url = "https://files.pythonhosted.org/packages/29/a1/f444b1c39039b9f020d02e871c5afd6e0eaeecf34bfcd47ef8f82408c1bf/aiohttp-3.10.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0009258e97502936d3bd5bf2ced15769629097d0abb81e6495fba1047824fe0", size = 1196214 }, - { url = "https://files.pythonhosted.org/packages/3b/50/bab30b4bbe1ef7d66d97358129c34379039c69b2b528ff02804a42b0b4da/aiohttp-3.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0a75d5c9fb4f06c41d029ae70ad943c3a844c40c0a769d12be4b99b04f473d3d", size = 1196350 }, - { url = "https://files.pythonhosted.org/packages/9b/8a/3731748b1080b2195268c85010727406ac3cee2fa878318d46de5614372f/aiohttp-3.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8198b7c002aae2b40b2d16bfe724b9a90bcbc9b78b2566fc96131ef4e382574d", size = 1196837 }, - { url = "https://files.pythonhosted.org/packages/7c/3c/51f9dbdabcdacf54b9e9ec1af210509e1a7e262647a106f720ed683b35ee/aiohttp-3.10.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4611db8c907f90fe86be112efdc2398cd7b4c8eeded5a4f0314b70fdea8feab0", size = 1250939 }, - { url = "https://files.pythonhosted.org/packages/fd/51/e4fde27da37a28c5bdef08d9393115f062c2e198f720172b12bb53b6c4d4/aiohttp-3.10.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ff99ae06eef85c7a565854826114ced72765832ee16c7e3e766c5e4c5b98d20e", size = 1265712 }, - { url = "https://files.pythonhosted.org/packages/1c/0d/b9c0d5ad9dc99cdfba665ba9ac6bd7aa9b97c866aef180477fabc54eaa56/aiohttp-3.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7641920bdcc7cd2d3ddfb8bb9133a6c9536b09dbd49490b79e125180b2d25b93", size = 1216963 }, - { url = "https://files.pythonhosted.org/packages/83/3e/c9ad8da2750ad9014a3d00be4809d8b96991ebdc4903ab78c2793362e192/aiohttp-3.10.6-cp39-cp39-win32.whl", hash = "sha256:e2e7d5591ea868d5ec82b90bbeb366a198715672841d46281b623e23079593db", size = 362896 }, - { url = "https://files.pythonhosted.org/packages/dc/b9/f952f6b156d01a04b6b110ba01f5fed975afdcfaca72ed4d07db964930ce/aiohttp-3.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:b504c08c45623bf5c7ca41be380156d925f00199b3970efd758aef4a77645feb", size = 381395 }, +sdist = { url = "https://files.pythonhosted.org/packages/14/40/f08c5d26398f987c1a27e1e351a4b461a01ffdbf9dde429c980db5286c92/aiohttp-3.10.9.tar.gz", hash = "sha256:143b0026a9dab07a05ad2dd9e46aa859bffdd6348ddc5967b42161168c24f857", size = 7541983 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/c9/dbbc67dd2474d4df953f05e0a312181e195eb54c46d9baf382b73ba6d566/aiohttp-3.10.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8b3fb28a9ac8f2558760d8e637dbf27aef1e8b7f1d221e8669a1074d1a266bb2", size = 587387 }, + { url = "https://files.pythonhosted.org/packages/88/10/aa4fa5cc30e2116edb02e31e4030d1464ef756a69e48f0c78dec13bbf93a/aiohttp-3.10.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:91aa966858593f64c8a65cdefa3d6dc8fe3c2768b159da84c1ddbbb2c01ab4ef", size = 399780 }, + { url = "https://files.pythonhosted.org/packages/b8/6e/29ff94c6730ebc67bf7746a5c437e676044b60d3e30eac21dcc2372ccafe/aiohttp-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:63649309da83277f06a15bbdc2a54fbe75efb92caa2c25bb57ca37762789c746", size = 391141 }, + { url = "https://files.pythonhosted.org/packages/25/27/a317dbd5a2729d92bab9788b99fdffaa7af09e5a4ff79270748bbfea605c/aiohttp-3.10.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3e7fabedb3fe06933f47f1538df7b3a8d78e13d7167195f51ca47ee12690373", size = 1229237 }, + { url = "https://files.pythonhosted.org/packages/57/c4/4feadf21dc9cf89fab35a3cc71d8884aff5fa7d53fcd70f8f4d7a6ef11b2/aiohttp-3.10.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c070430fda1a550a1c3a4c2d7281d3b8cfc0c6715f616e40e3332201a253067", size = 1265039 }, + { url = "https://files.pythonhosted.org/packages/9c/04/3959f2eacca398b8dfa18cfdadead1cbf2d929ea007d86e6e7ff2b6f4dee/aiohttp-3.10.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:51d0a4901b27272ae54e42067bc4b9a90e619a690b4dc43ea5950eb3070afc32", size = 1298818 }, + { url = "https://files.pythonhosted.org/packages/9a/be/810e82ad2b3e3221530e59177520e0a0a719ef07804a2d8b0d8c73b5f479/aiohttp-3.10.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fec5fac7aea6c060f317f07494961236434928e6f4374e170ef50b3001e14581", size = 1222615 }, + { url = "https://files.pythonhosted.org/packages/92/f5/de2625920d5a3bd99347050ddc94182665e5c4cbd8f1d8fa3f3ebd9e4fad/aiohttp-3.10.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:172ad884bb61ad31ed7beed8be776eb17e7fb423f1c1be836d5cb357a096bf12", size = 1194222 }, + { url = "https://files.pythonhosted.org/packages/6d/b1/9053457d3323301552732a8a45a87e371abbe4f962325822899e7b503ab9/aiohttp-3.10.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d646fdd74c25bbdd4a055414f0fe32896c400f38ffbdfc78c68e62812a9e0257", size = 1193963 }, + { url = "https://files.pythonhosted.org/packages/a1/6c/4396e9dd9371604bd8c5d6faba6775476bc01b9def74d3e46df5b4511c10/aiohttp-3.10.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e86260b76786c28acf0b5fe31c8dca4c2add95098c709b11e8c35b424ebd4f5b", size = 1193461 }, + { url = "https://files.pythonhosted.org/packages/e1/ca/a9b15243a103c2884b5a1e9312b20a8ed44f8c637f0a71fb7509b975769b/aiohttp-3.10.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c7d7cafc11d70fdd8801abfc2ff276744ae4cb39d8060b6b542c7e44e5f2cfc2", size = 1247625 }, + { url = "https://files.pythonhosted.org/packages/61/81/85465f60776e3ece45436b061b91ae3cb2ca10494088480c17093fdf3b03/aiohttp-3.10.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc262c3df78c8ff6020c782d9ce02e4bcffe4900ad71c0ecdad59943cba54442", size = 1264521 }, + { url = "https://files.pythonhosted.org/packages/a4/f5/41712c5d385ffd20d372609aa79de6d37ca8c639b93d4edde86e4e65f255/aiohttp-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:482c85cf3d429844396d939b22bc2a03849cb9ad33344689ad1c85697bcba33a", size = 1216165 }, + { url = "https://files.pythonhosted.org/packages/43/c4/1b06d5a53ac414836bc6ebf8522e3ea70b3db19814736e417b4f669f614f/aiohttp-3.10.9-cp310-cp310-win32.whl", hash = "sha256:aeebd3061f6f1747c011e1d0b0b5f04f9f54ad1a2ca183e687e7277bef2e0da2", size = 363094 }, + { url = "https://files.pythonhosted.org/packages/fd/1c/09b8b3c994cf12db55e8ddf1889567df10e33e8855b948622d9b91288d1a/aiohttp-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:fa430b871220dc62572cef9c69b41e0d70fcb9d486a4a207a5de4c1f25d82593", size = 381512 }, + { url = "https://files.pythonhosted.org/packages/74/25/9cb2c6f7260e26ad67185b5deeb4e9eb002c352add9e7470ecda6174f3a1/aiohttp-3.10.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:16e6a51d8bc96b77f04a6764b4ad03eeef43baa32014fce71e882bd71302c7e4", size = 586917 }, + { url = "https://files.pythonhosted.org/packages/72/6f/cb3943cc0eaa1d7cfc0fbd250652587ffc60dbdb87ef175b5819f7a75920/aiohttp-3.10.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8bd9125dd0cc8ebd84bff2be64b10fdba7dc6fd7be431b5eaf67723557de3a31", size = 399398 }, + { url = "https://files.pythonhosted.org/packages/99/bd/f5b651f9b16b1408e5d15e27076074baf71cf0c7c398b5875ded822284dd/aiohttp-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dcf354661f54e6a49193d0b5653a1b011ba856e0b7a76bda2c33e4c6892f34ea", size = 391048 }, + { url = "https://files.pythonhosted.org/packages/a5/2f/af600aa1e4cad6ee1437ca00696c3a33e4ff318a352e9a2526431e688fdf/aiohttp-3.10.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42775de0ca04f90c10c5c46291535ec08e9bcc4756f1b48f02a0657febe89b10", size = 1306896 }, + { url = "https://files.pythonhosted.org/packages/1c/5e/2744f3085a6c3b8953178480ad596a1742c27c543ccb25e9dfb2f4f80724/aiohttp-3.10.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d1e4185c5d7187684d41ebb50c9aeaaaa06ca1875f4c57593071b0409d2444", size = 1345076 }, + { url = "https://files.pythonhosted.org/packages/be/75/492238db77b095573ed87dd7de9b19a7099310ebfe58a52a1c93abe0fffe/aiohttp-3.10.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2695c61cf53a5d4345a43d689f37fc0f6d3a2dc520660aec27ec0f06288d1f9", size = 1378906 }, + { url = "https://files.pythonhosted.org/packages/b6/64/b434024effa2e8d2e46ab771a4b0b6172016722cd9509de0de64d8ba7934/aiohttp-3.10.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a3f063b41cc06e8d0b3fcbbfc9c05b7420f41287e0cd4f75ce0a1f3d80729e6", size = 1293128 }, + { url = "https://files.pythonhosted.org/packages/7f/67/a069742198d5431c3780cbcf6df6e4e07ea5178632a2ea243bfc439328f4/aiohttp-3.10.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d37f4718002863b82c6f391c8efd4d3a817da37030a29e2682a94d2716209de", size = 1252191 }, + { url = "https://files.pythonhosted.org/packages/d6/ec/15510a7cb66eeba7c09bef3e8ae153f057714017210eecec21be40b47938/aiohttp-3.10.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2746d8994ebca1bdc55a1e998feff4e94222da709623bb18f6e5cfec8ec01baf", size = 1272135 }, + { url = "https://files.pythonhosted.org/packages/d1/6c/91efffd38cfa43f1adecd41ae3b6f38ea5849e230d371247eb6e96cdf594/aiohttp-3.10.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6f3c6648aa123bcd73d6f26607d59967b607b0da8ffcc27d418a4b59f4c98c7c", size = 1266675 }, + { url = "https://files.pythonhosted.org/packages/f0/ff/7a23185fbae0c6b8293a9cda167d747e20243a819fee2a4e2a3d704c53f4/aiohttp-3.10.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:558b3d223fd631ad134d89adea876e7fdb4c93c849ef195049c063ada82b7d08", size = 1322042 }, + { url = "https://files.pythonhosted.org/packages/f9/0f/11f2c383537aa3eba2a0557507c4d00e0d611e134cb5530dd2f43e7f277c/aiohttp-3.10.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4e6cb75f8ddd9c2132d00bc03c9716add57f4beff1263463724f6398b813e7eb", size = 1339642 }, + { url = "https://files.pythonhosted.org/packages/d7/9e/f1f6771bc6e8b2d0cc2c47ef88b781618202d1581a5f1d5c70e5d30fecfb/aiohttp-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:608cecd8d58d285bfd52dbca5b6251ca8d6ea567022c8a0eaae03c2589cd9af9", size = 1299481 }, + { url = "https://files.pythonhosted.org/packages/8a/f5/77e71fb00177c22dcf2319348006817ff8333ad822ba85c5c20141d0e7f7/aiohttp-3.10.9-cp311-cp311-win32.whl", hash = "sha256:36d4fba838be5f083f5490ddd281813b44d69685db910907636bc5dca6322316", size = 362644 }, + { url = "https://files.pythonhosted.org/packages/95/c8/9d1d366dba1641a5fb7642b2193858c54910e614dbe8213ac6e98e759e19/aiohttp-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:8be1a65487bdfc285bd5e9baf3208c2132ca92a9b4020e9f27df1b16fab998a9", size = 381988 }, + { url = "https://files.pythonhosted.org/packages/95/d3/1f1f100e037316a8de685fa52666b6b7b3454fb6029c7e893d17fca84494/aiohttp-3.10.9-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4fd16b30567c5b8e167923be6e027eeae0f20cf2b8a26b98a25115f28ad48ee0", size = 583949 }, + { url = "https://files.pythonhosted.org/packages/10/6d/0e23bf7f73811f32f44d3ea0435e3fbaa406b4f999f6bfe7d07481a7c73a/aiohttp-3.10.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:40ff5b7660f903dc587ed36ef08a88d46840182d9d4b5694e7607877ced698a1", size = 396108 }, + { url = "https://files.pythonhosted.org/packages/fd/af/1114d891e104fe7a2cf4111632fc267fe340133fcc0be82d6b14bbc5f6ba/aiohttp-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4edc3fd701e2b9a0d605a7b23d3de4ad23137d23fc0dbab726aa71d92f11aaaf", size = 391319 }, + { url = "https://files.pythonhosted.org/packages/b3/73/ee8f1819ee70135f019981743cc2b20fbdef184f0300d5bd4464e502ed06/aiohttp-3.10.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e525b69ee8a92c146ae5b4da9ecd15e518df4d40003b01b454ad694a27f498b5", size = 1312486 }, + { url = "https://files.pythonhosted.org/packages/13/22/5399a58e78b7de12949931a1e0b5d4a7304895bf029d59ee5a7c45fb8f66/aiohttp-3.10.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5002a02c17fcfd796d20bac719981d2fca9c006aac0797eb8f430a58e9d12431", size = 1350966 }, + { url = "https://files.pythonhosted.org/packages/6d/13/284b1b3417de5480ca7267614d10752311a73b8269dee8487935ae9aeac3/aiohttp-3.10.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd4ceeae2fb8cabdd1b71c82bfdd39662473d3433ec95b962200e9e752fb70d0", size = 1393071 }, + { url = "https://files.pythonhosted.org/packages/09/bc/a5168e2e46aed7f52c22604b2327aa0c24bcbf5acfb14a2246e0db97ebb8/aiohttp-3.10.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6e395c3d1f773cf0651cd3559e25182eb0c03a2777b53b4575d8adc1149c6e9", size = 1306720 }, + { url = "https://files.pythonhosted.org/packages/7e/0d/9f31ad6abc903abb92f5c03274231cde833be9a81220a79ffa3836d533bd/aiohttp-3.10.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bbdb8def5268f3f9cd753a265756f49228a20ed14a480d151df727808b4531dd", size = 1260673 }, + { url = "https://files.pythonhosted.org/packages/28/c0/cf952fe7aa9680eeb8d5c8285d83f58d48c2005480e47ca94bff38f54794/aiohttp-3.10.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f82ace0ec57c94aaf5b0e118d4366cff5889097412c75aa14b4fd5fc0c44ee3e", size = 1271554 }, + { url = "https://files.pythonhosted.org/packages/92/f6/cd1991bc816f6976e9182a6cde996e16c01ee07a91443eaa76eab57b65d2/aiohttp-3.10.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6ebdc3b3714afe1b134b3bbeb5f745eed3ecbcff92ab25d80e4ef299e83a5465", size = 1280670 }, + { url = "https://files.pythonhosted.org/packages/f1/29/a1f593cae76576cac964aab98242b5fd3f09e3160e31c6a981aeaea318f1/aiohttp-3.10.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f9ca09414003c0e96a735daa1f071f7d7ed06962ef4fa29ceb6c80d06696d900", size = 1317221 }, + { url = "https://files.pythonhosted.org/packages/78/37/9f491dd5c8e29632ad6486022c1baeb3cf6adf16da98d14f61ee5265da11/aiohttp-3.10.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1298b854fd31d0567cbb916091be9d3278168064fca88e70b8468875ef9ff7e7", size = 1344349 }, + { url = "https://files.pythonhosted.org/packages/8e/de/53b365b3cea5bf9b4a31d905c13e1b81a6b1f5379e7513390840fde67e05/aiohttp-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60ad5b8a7452c0f5645c73d4dad7490afd6119d453d302cd5b72b678a85d6044", size = 1306592 }, + { url = "https://files.pythonhosted.org/packages/e9/98/030429cf2d69be27d2ad7c5dbc634d1bd08bddd2343099a81c10dfc105f0/aiohttp-3.10.9-cp312-cp312-win32.whl", hash = "sha256:1a0ee6c0d590c917f1b9629371fce5f3d3f22c317aa96fbdcce3260754d7ea21", size = 359707 }, + { url = "https://files.pythonhosted.org/packages/da/cf/893f385d4ade412a242f61a2669f89afc389380cc9d29edf9335fa9f3d35/aiohttp-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:c46131c6112b534b178d4e002abe450a0a29840b61413ac25243f1291613806a", size = 379726 }, + { url = "https://files.pythonhosted.org/packages/1c/60/36e4b9f165b715b33eb09c199e0b748876bb7ef3480845688e93ff624172/aiohttp-3.10.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2bd9f3eac515c16c4360a6a00c38119333901b8590fe93c3257a9b536026594d", size = 576520 }, + { url = "https://files.pythonhosted.org/packages/24/51/1912195eda818b968f257b9774e2aa48b86d61853cecbbb85c7e85c1ea1a/aiohttp-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8cc0d13b4e3b1362d424ce3f4e8c79e1f7247a00d792823ffd640878abf28e56", size = 392311 }, + { url = "https://files.pythonhosted.org/packages/9f/3a/a5dd75d9fc06fa1791b327a3045c78ae2fa621f066da44db11aebbd8ac4a/aiohttp-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba1a599255ad6a41022e261e31bc2f6f9355a419575b391f9655c4d9e5df5ff5", size = 387829 }, + { url = "https://files.pythonhosted.org/packages/ee/7a/fdf393519f72152b8b6a33dd9c8d4553517358a2df72c78a0c15542df77d/aiohttp-3.10.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:776e9f3c9b377fcf097c4a04b241b15691e6662d850168642ff976780609303c", size = 1287492 }, + { url = "https://files.pythonhosted.org/packages/00/fb/b783999286077dbe41b99cc5ce34f71fb0e3d68621fc8603ad39d518c229/aiohttp-3.10.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8debb45545ad95b58cc16c3c1cc19ad82cffcb106db12b437885dbee265f0ab5", size = 1324034 }, + { url = "https://files.pythonhosted.org/packages/8a/43/bdc6215f327da8236972fd15c31ad349100a2a2b186558ddf76e48b66296/aiohttp-3.10.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2555e4949c8d8782f18ef20e9d39730d2656e218a6f1a21a4c4c0b56546a02e", size = 1368824 }, + { url = "https://files.pythonhosted.org/packages/0c/c9/a366ae87c0a3e9140623a4d84511e65299b35cf8a1dd2880ff245fe480c3/aiohttp-3.10.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c54dc329cd44f7f7883a9f4baaefe686e8b9662e2c6c184ea15cceee587d8d69", size = 1283182 }, + { url = "https://files.pythonhosted.org/packages/34/cd/f7d222dc983c0e2d625a00c449b923fdfa8c40f56154d2da9483ee9d3b92/aiohttp-3.10.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e709d6ac598c5416f879bb1bae3fd751366120ac3fa235a01de763537385d036", size = 1236935 }, + { url = "https://files.pythonhosted.org/packages/c3/a3/379086cd1f193f63f8b5b8cb348df6b5aa43e8eda3dd9b1b5748fa0c0090/aiohttp-3.10.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:17c272cfe7b07a5bb0c6ad3f234e0c336fb53f3bf17840f66bd77b5815ab3d16", size = 1250756 }, + { url = "https://files.pythonhosted.org/packages/44/c2/463d898c6aa0202fc0165aec0bd8d71f1db5876f40d7d297914af7490df4/aiohttp-3.10.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c21c82df33b264216abffff9f8370f303dab65d8eee3767efbbd2734363f677", size = 1249367 }, + { url = "https://files.pythonhosted.org/packages/c0/8f/90c365019d84f90cec9c43d6df8ec97ada513a7610aaa0936bae6cf2bbe0/aiohttp-3.10.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9331dd34145ff105177855017920dde140b447049cd62bb589de320fd6ddd582", size = 1293795 }, + { url = "https://files.pythonhosted.org/packages/8e/62/174aa729cb83d5bbbd13715e463181d3c19c13231304fafba3cc20f7b850/aiohttp-3.10.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ac3196952c673822ebed8871cf8802e17254fff2a2ed4835d9c045d9b88c5ec7", size = 1320527 }, + { url = "https://files.pythonhosted.org/packages/96/f7/102a9a8d3eef0d5d301328feb7ddecac9f78808589c6186497256c80b3d9/aiohttp-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2c33fa6e10bb7ed262e3ff03cc69d52869514f16558db0626a7c5c61dde3c29f", size = 1281964 }, + { url = "https://files.pythonhosted.org/packages/ab/e2/0c9ef8acfdbe6bd417a8989bc95f5e28ce1af475eb941334b2c9a751d01b/aiohttp-3.10.9-cp313-cp313-win32.whl", hash = "sha256:a14e4b672c257a6b94fe934ee62666bacbc8e45b7876f9dd9502d0f0fe69db16", size = 357936 }, + { url = "https://files.pythonhosted.org/packages/71/c0/6d33ac32bfbf9dd91a16c26bc37dd4763084d7f991dc848655d34e31291a/aiohttp-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:a35ed3d03910785f7d9d6f5381f0c24002b2b888b298e6f941b2fc94c5055fcd", size = 377205 }, + { url = "https://files.pythonhosted.org/packages/9b/87/6ff9af3c925dcc1d8e597d83115a919bd56f0b4399e37f4c090dd927c731/aiohttp-3.10.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:fcd546782d03181b0b1d20b43d612429a90a68779659ba8045114b867971ab71", size = 589008 }, + { url = "https://files.pythonhosted.org/packages/40/58/2cfe2759561e64587538a275292b66008e8f5d6d216da4618125a50668c2/aiohttp-3.10.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:85711eec2d875cd88c7eb40e734c4ca6d9ae477d6f26bd2b5bb4f7f60e41b156", size = 400673 }, + { url = "https://files.pythonhosted.org/packages/4b/15/cd02f34d8c84e0519fa4f6fdfa5311126513ad610b626a2d5e656e2ef6ab/aiohttp-3.10.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:02d1d6610588bcd743fae827bd6f2e47e0d09b346f230824b4c6fb85c6065f9c", size = 392003 }, + { url = "https://files.pythonhosted.org/packages/3e/23/d66db0d1bf390aced372e246b0ab3fc2391e7d430f807ffa7940627b4965/aiohttp-3.10.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3668d0c2a4d23fb136a753eba42caa2c0abbd3d9c5c87ee150a716a16c6deec1", size = 1234087 }, + { url = "https://files.pythonhosted.org/packages/03/e5/32f1d4a893fffc7babb79c6c6c360207ddeda972d909e63f09e5ba5881bd/aiohttp-3.10.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7c071235a47d407b0e93aa6262b49422dbe48d7d8566e1158fecc91043dd948", size = 1271471 }, + { url = "https://files.pythonhosted.org/packages/a6/b9/fcc0ccd893c8b46badac5f1a5333cc07af34835821afdf821ba5e631cbb7/aiohttp-3.10.9-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac74e794e3aee92ae8f571bfeaa103a141e409863a100ab63a253b1c53b707eb", size = 1305286 }, + { url = "https://files.pythonhosted.org/packages/fb/ed/039d8a7fd4085635041757328ef4bea2b449afa84ecd09b19b73939a5972/aiohttp-3.10.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bbf94d4a0447705b7775417ca8bb8086cc5482023a6e17cdc8f96d0b1b5aba6", size = 1225844 }, + { url = "https://files.pythonhosted.org/packages/10/0e/90690cbb5df24dbb7a604102433b80c66ede1e208c153d057c0c897c9c0d/aiohttp-3.10.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb0b2d5d51f96b6cc19e6ab46a7b684be23240426ae951dcdac9639ab111b45e", size = 1197001 }, + { url = "https://files.pythonhosted.org/packages/3a/be/b9e01520216ada2fe72f6c8c81f13c932a894e0a07a27533261d504d8bf5/aiohttp-3.10.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e83dfefb4f7d285c2d6a07a22268344a97d61579b3e0dce482a5be0251d672ab", size = 1197137 }, + { url = "https://files.pythonhosted.org/packages/95/38/ddf4c463b1258a4b5df6dccb84201c6a999e53f0b0a98785dffb85d298d1/aiohttp-3.10.9-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f0a44bb40b6aaa4fb9a5c1ee07880570ecda2065433a96ccff409c9c20c1624a", size = 1197624 }, + { url = "https://files.pythonhosted.org/packages/b7/a0/b5fa1c9e280368740d8411518632f973b4cc136e9ef5180cfec085c7f628/aiohttp-3.10.9-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c2b627d3c8982691b06d89d31093cee158c30629fdfebe705a91814d49b554f8", size = 1251727 }, + { url = "https://files.pythonhosted.org/packages/fc/94/348d49e568979593bd1509b99ff224406c4159dd3f6e611873fbe7ad11b6/aiohttp-3.10.9-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:03690541e4cc866eef79626cfa1ef4dd729c5c1408600c8cb9e12e1137eed6ab", size = 1266497 }, + { url = "https://files.pythonhosted.org/packages/52/38/843e288d0d035eb32e8d6ad5ab90d3e6a738d4f4b4f6452174e950892334/aiohttp-3.10.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ad3675c126f2a95bde637d162f8231cff6bc0bc9fbe31bd78075f9ff7921e322", size = 1217751 }, + { url = "https://files.pythonhosted.org/packages/c1/99/e742ba9a6efd885aaaf9a71083dfdb370435fb8e678eed950848efe4202f/aiohttp-3.10.9-cp39-cp39-win32.whl", hash = "sha256:1321658f12b6caffafdc35cfba6c882cb014af86bef4e78c125e7e794dfb927b", size = 363681 }, + { url = "https://files.pythonhosted.org/packages/67/10/4c09a2d732ae5419451ad531afc27df92c74e38f629fdfd42674ff258a79/aiohttp-3.10.9-cp39-cp39-win_amd64.whl", hash = "sha256:9fdf5c839bf95fc67be5794c780419edb0dbef776edcfc6c2e5e2ffd5ee755fa", size = 382182 }, ] [[package]] @@ -737,50 +735,70 @@ wheels = [ [[package]] name = "markupsafe" -version = "2.1.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/54/ad5eb37bf9d51800010a74e4665425831a9db4e7c4e0fde4352e391e808e/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", size = 18206 }, - { url = "https://files.pythonhosted.org/packages/6a/4a/a4d49415e600bacae038c67f9fecc1d5433b9d3c71a4de6f33537b89654c/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", size = 14079 }, - { url = "https://files.pythonhosted.org/packages/0a/7b/85681ae3c33c385b10ac0f8dd025c30af83c78cec1c37a6aa3b55e67f5ec/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", size = 26620 }, - { url = "https://files.pythonhosted.org/packages/7c/52/2b1b570f6b8b803cef5ac28fdf78c0da318916c7d2fe9402a84d591b394c/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", size = 25818 }, - { url = "https://files.pythonhosted.org/packages/29/fe/a36ba8c7ca55621620b2d7c585313efd10729e63ef81e4e61f52330da781/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", size = 25493 }, - { url = "https://files.pythonhosted.org/packages/60/ae/9c60231cdfda003434e8bd27282b1f4e197ad5a710c14bee8bea8a9ca4f0/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", size = 30630 }, - { url = "https://files.pythonhosted.org/packages/65/dc/1510be4d179869f5dafe071aecb3f1f41b45d37c02329dfba01ff59e5ac5/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", size = 29745 }, - { url = "https://files.pythonhosted.org/packages/30/39/8d845dd7d0b0613d86e0ef89549bfb5f61ed781f59af45fc96496e897f3a/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", size = 30021 }, - { url = "https://files.pythonhosted.org/packages/c7/5c/356a6f62e4f3c5fbf2602b4771376af22a3b16efa74eb8716fb4e328e01e/MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", size = 16659 }, - { url = "https://files.pythonhosted.org/packages/69/48/acbf292615c65f0604a0c6fc402ce6d8c991276e16c80c46a8f758fbd30c/MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", size = 17213 }, - { url = "https://files.pythonhosted.org/packages/11/e7/291e55127bb2ae67c64d66cef01432b5933859dfb7d6949daa721b89d0b3/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", size = 18219 }, - { url = "https://files.pythonhosted.org/packages/6b/cb/aed7a284c00dfa7c0682d14df85ad4955a350a21d2e3b06d8240497359bf/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", size = 14098 }, - { url = "https://files.pythonhosted.org/packages/1c/cf/35fe557e53709e93feb65575c93927942087e9b97213eabc3fe9d5b25a55/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", size = 29014 }, - { url = "https://files.pythonhosted.org/packages/97/18/c30da5e7a0e7f4603abfc6780574131221d9148f323752c2755d48abad30/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", size = 28220 }, - { url = "https://files.pythonhosted.org/packages/0c/40/2e73e7d532d030b1e41180807a80d564eda53babaf04d65e15c1cf897e40/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", size = 27756 }, - { url = "https://files.pythonhosted.org/packages/18/46/5dca760547e8c59c5311b332f70605d24c99d1303dd9a6e1fc3ed0d73561/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", size = 33988 }, - { url = "https://files.pythonhosted.org/packages/6d/c5/27febe918ac36397919cd4a67d5579cbbfa8da027fa1238af6285bb368ea/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", size = 32718 }, - { url = "https://files.pythonhosted.org/packages/f8/81/56e567126a2c2bc2684d6391332e357589a96a76cb9f8e5052d85cb0ead8/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", size = 33317 }, - { url = "https://files.pythonhosted.org/packages/00/0b/23f4b2470accb53285c613a3ab9ec19dc944eaf53592cb6d9e2af8aa24cc/MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", size = 16670 }, - { url = "https://files.pythonhosted.org/packages/b7/a2/c78a06a9ec6d04b3445a949615c4c7ed86a0b2eb68e44e7541b9d57067cc/MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", size = 17224 }, - { url = "https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", size = 18215 }, - { url = "https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", size = 14069 }, - { url = "https://files.pythonhosted.org/packages/51/b5/5d8ec796e2a08fc814a2c7d2584b55f889a55cf17dd1a90f2beb70744e5c/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", size = 29452 }, - { url = "https://files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", size = 28462 }, - { url = "https://files.pythonhosted.org/packages/2d/75/fd6cb2e68780f72d47e6671840ca517bda5ef663d30ada7616b0462ad1e3/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", size = 27869 }, - { url = "https://files.pythonhosted.org/packages/b0/81/147c477391c2750e8fc7705829f7351cf1cd3be64406edcf900dc633feb2/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", size = 33906 }, - { url = "https://files.pythonhosted.org/packages/8b/ff/9a52b71839d7a256b563e85d11050e307121000dcebc97df120176b3ad93/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", size = 32296 }, - { url = "https://files.pythonhosted.org/packages/88/07/2dc76aa51b481eb96a4c3198894f38b480490e834479611a4053fbf08623/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", size = 33038 }, - { url = "https://files.pythonhosted.org/packages/96/0c/620c1fb3661858c0e37eb3cbffd8c6f732a67cd97296f725789679801b31/MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", size = 16572 }, - { url = "https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127 }, - { url = "https://files.pythonhosted.org/packages/0f/31/780bb297db036ba7b7bbede5e1d7f1e14d704ad4beb3ce53fb495d22bc62/MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", size = 18193 }, - { url = "https://files.pythonhosted.org/packages/6c/77/d77701bbef72892affe060cdacb7a2ed7fd68dae3b477a8642f15ad3b132/MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", size = 14073 }, - { url = "https://files.pythonhosted.org/packages/d9/a7/1e558b4f78454c8a3a0199292d96159eb4d091f983bc35ef258314fe7269/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", size = 26486 }, - { url = "https://files.pythonhosted.org/packages/5f/5a/360da85076688755ea0cceb92472923086993e86b5613bbae9fbc14136b0/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", size = 25685 }, - { url = "https://files.pythonhosted.org/packages/6a/18/ae5a258e3401f9b8312f92b028c54d7026a97ec3ab20bfaddbdfa7d8cce8/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", size = 25338 }, - { url = "https://files.pythonhosted.org/packages/0b/cc/48206bd61c5b9d0129f4d75243b156929b04c94c09041321456fd06a876d/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", size = 30439 }, - { url = "https://files.pythonhosted.org/packages/d1/06/a41c112ab9ffdeeb5f77bc3e331fdadf97fa65e52e44ba31880f4e7f983c/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", size = 29531 }, - { url = "https://files.pythonhosted.org/packages/02/8c/ab9a463301a50dab04d5472e998acbd4080597abc048166ded5c7aa768c8/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", size = 29823 }, - { url = "https://files.pythonhosted.org/packages/bc/29/9bc18da763496b055d8e98ce476c8e718dcfd78157e17f555ce6dd7d0895/MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", size = 16658 }, - { url = "https://files.pythonhosted.org/packages/f6/f8/4da07de16f10551ca1f640c92b5f316f9394088b183c6a57183df6de5ae4/MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", size = 17211 }, +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/84/3f683b24fcffa08c5b7ef3fb8a845661057dd39c321c1ae16fa37a3eb35b/markupsafe-3.0.0.tar.gz", hash = "sha256:03ff62dea2fef3eadf2f1853bc6332bcb0458d9608b11dfb1cd5aeda1c178ea6", size = 20102 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/a6/f705e503cdcd944f8bb50cf615f2d436f671a60f1d5cb1c5a1a9c7d57028/MarkupSafe-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:380faf314c3c84c1682ca672e6280c6c59e92d0bc13dc71758ffa2de3cd4e252", size = 14337 }, + { url = "https://files.pythonhosted.org/packages/7c/cf/c78c4c5f33492290cddd2469389c86e6e2a7b5ef64dd014b021bf64a5e08/MarkupSafe-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ee9790be6f62121c4c58bbced387b0965ab7bffeecb4e17cc42ef290784e363", size = 12362 }, + { url = "https://files.pythonhosted.org/packages/2a/0f/351109b1403c1061732e2bb76900e15e9387177ba4b8f5d60783c16c8225/MarkupSafe-3.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ddf5cb8e9c00d9bf8b0c75949fb3ff9ea2096ba531693e2e87336d197fdb908", size = 21736 }, + { url = "https://files.pythonhosted.org/packages/10/9f/7984e6dc0f62ff8f18fb129954f393869571cfca95bf0e53030cf4bf6936/MarkupSafe-3.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b36473a2d3e882d1873ea906ce54408b9588dc2c65989664e6e7f5a2de353d7", size = 20905 }, + { url = "https://files.pythonhosted.org/packages/30/3f/be451779aa18f4c5c5e290433fa35aec8474e88099017ece53b304391971/MarkupSafe-3.0.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dba0f83119b9514bc37272ad012f0cc03f0805cc6a2bea7244e19250ac8ff29f", size = 21036 }, + { url = "https://files.pythonhosted.org/packages/b6/42/70e0c73827995ad731812cc018048d9e65bb5fc54c21ee8d693609c4b7fc/MarkupSafe-3.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:409535e0521c4630d5b5a1bf284e9d3c76d2fc2f153ebb12cf3827797798cc99", size = 21636 }, + { url = "https://files.pythonhosted.org/packages/49/b4/667b4f33303b5c085a0cb3dc3764b0240b9a4f79321de1d9fc04301f30a0/MarkupSafe-3.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64a7c7856c3a409011139b17d137c2924df4318dab91ee0530800819617c4381", size = 21298 }, + { url = "https://files.pythonhosted.org/packages/f3/8f/8e3249fdd5bdd9344ace890f0fc7277882d75659449beb28635029cb5684/MarkupSafe-3.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4deea1d9169578917d1f35cdb581bc7bab56a7e8c5be2633bd1b9549c3c22a01", size = 21049 }, + { url = "https://files.pythonhosted.org/packages/c0/c5/dfb13194dcfdcd3e08e4fd29719bfb472d711cf66d86330542daa9e2565f/MarkupSafe-3.0.0-cp310-cp310-win32.whl", hash = "sha256:3cd0bba31d484fe9b9d77698ddb67c978704603dc10cdc905512af308cfcca6b", size = 15025 }, + { url = "https://files.pythonhosted.org/packages/07/8d/d0f52b26efb87733551f78a3a009eaa5fdb529a5af3712947fda1c93b82e/MarkupSafe-3.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:4ca04c60006867610a06575b46941ae616b19da0adc85b9f8f3d9cbd7a3da385", size = 15485 }, + { url = "https://files.pythonhosted.org/packages/d2/af/5d89e9d6fbba5024a047aa004942578fee3396d9991119d4b9f73f027daf/MarkupSafe-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e64b390a306f9e849ee809f92af6a52cda41741c914358e0e9f8499d03741526", size = 14341 }, + { url = "https://files.pythonhosted.org/packages/60/0f/e33b03aeaecd8d90ba869e7c93b9f1aeeb0ab2820e338745200c9a2c8acb/MarkupSafe-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c524203207f5b569df06c96dafdc337228921ee8c3cc5f6e891d024c6595352", size = 12364 }, + { url = "https://files.pythonhosted.org/packages/81/ec/8804186f64b9c15844fa0e5079264e22325ac93573eef9eb4ab41e3929fc/MarkupSafe-3.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409691696bec2b5e5c9efd9593c99025bf2f317380bf0d993ee0213516d908a", size = 23956 }, + { url = "https://files.pythonhosted.org/packages/dd/4f/ddab3f0ab045ae34cf40e8ac1d8bf2933c50cda9c626441353c25d048556/MarkupSafe-3.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64f7d04410be600aa5ec0626d73d43e68a51c86500ce12917e10fd013e258df5", size = 23251 }, + { url = "https://files.pythonhosted.org/packages/59/a2/c68e6167a057d78e19b8e30338c33e3d917c8cd5d6ba574991202291b6b0/MarkupSafe-3.0.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:105ada43a61af22acb8774514c51900dc820c481cc5ba53f17c09d294d9c07ca", size = 23157 }, + { url = "https://files.pythonhosted.org/packages/24/fc/cea6e038c6f911aeeda66a41b96b8885153026867422e1f37f9b018b427f/MarkupSafe-3.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5fd5500d4e4f7cc88d8c0f2e45126c4307ed31e08f8ec521474f2fd99d35ac3", size = 23635 }, + { url = "https://files.pythonhosted.org/packages/36/c7/2fca924654032c27055706ad6647cf5535be8cf641d2148fc693b0e04407/MarkupSafe-3.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:25396abd52b16900932e05b7104bcdc640a4d96c914f39c3b984e5a17b01fba0", size = 23422 }, + { url = "https://files.pythonhosted.org/packages/e7/56/825d2218c93dbf5f0c8b3cb5e86a02a9b1bb95aaa850765026a7fed7aaa1/MarkupSafe-3.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3efde9a8c56c3b6e5f3fa4baea828f8184970c7c78480fedb620d804b1c31e5c", size = 23339 }, + { url = "https://files.pythonhosted.org/packages/0c/70/973f228b3017d9fffb11567a2a02f092be41cae8ca1a9c97ec571801ab50/MarkupSafe-3.0.0-cp311-cp311-win32.whl", hash = "sha256:12ddac720b8965332d36196f6f83477c6351ba6a25d4aff91e30708c729350d7", size = 15056 }, + { url = "https://files.pythonhosted.org/packages/96/4a/6ea3f7265e17226bc9b1896d16ed5b230fe06cf4530a40a4f47e7d311a62/MarkupSafe-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:658fdf6022740896c403d45148bf0c36978c6b48c9ef8b1f8d0c7a11b6cdea86", size = 15493 }, + { url = "https://files.pythonhosted.org/packages/2a/d2/4cda4f2c9a21b426c5f5b80a70991dc26b78bcecd7b03a8e8a22cc1cddc1/MarkupSafe-3.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d261ec38b8a99a39b62e0119ed47fe3b62f7691c500bc1e815265adc016438c1", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6c/46/92fd7ef12daa1b1e5fe4e38cc251e01c51ea288ecda950a30b2e8d66a051/MarkupSafe-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e363440c8534bf2f2ef1b8fdc02037eb5fff8fce2a558519b22d6a3a38b3ec5e", size = 12332 }, + { url = "https://files.pythonhosted.org/packages/61/47/f972faff9134053fc083e591b7415ce7a2f4c51fb1dba17757822d0ebb5d/MarkupSafe-3.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7835de4c56066e096407a1852e5561f6033786dd987fa90dc384e45b9bd21295", size = 24049 }, + { url = "https://files.pythonhosted.org/packages/c0/c9/5c84edd744fe981c1c37e8303799e4d90bc2b146997b60dc158c20791b24/MarkupSafe-3.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6cc46a27d904c9be5732029769acf4b0af69345172ed1ef6d4db0c023ff603b", size = 23199 }, + { url = "https://files.pythonhosted.org/packages/70/6f/70ca971e19d0cd905f58cd53358b0dfe30fa393bd9d5a1f372667f7b97b0/MarkupSafe-3.0.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0411641d31aa6f7f0cc13f0f18b63b8dc08da5f3a7505972a42ab059f479ba3", size = 23099 }, + { url = "https://files.pythonhosted.org/packages/7f/47/c15288e10d0f3c9ac0d997891f581d910a593a74c1e9789046b9cb4e4c53/MarkupSafe-3.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b2a7afd24d408b907672015555bc10be2382e6c5f62a488e2d452da670bbd389", size = 23812 }, + { url = "https://files.pythonhosted.org/packages/dd/f6/518225e5cd027828cb26bbe0b99c9b110512960e60718c66df9823ba5e8f/MarkupSafe-3.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c8ab7efeff1884c5da8e18f743b667215300e09043820d11723718de0b7db934", size = 23392 }, + { url = "https://files.pythonhosted.org/packages/55/a5/94b07a3fe33d52c93476b0970ab9ab011790c04d10d5c110ed3de01863f5/MarkupSafe-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8219e2207f6c188d15614ea043636c2b36d2d79bf853639c124a179412325a13", size = 23559 }, + { url = "https://files.pythonhosted.org/packages/b9/77/1e21ea23aeeaa0760d0ab03976b38f6551ad803cffccdec2db9dcb85ac7c/MarkupSafe-3.0.0-cp312-cp312-win32.whl", hash = "sha256:59420b5a9a5d3fee483a32adb56d7369ae0d630798da056001be1e9f674f3aa6", size = 15064 }, + { url = "https://files.pythonhosted.org/packages/55/e2/4e0c49629d1d8f0642ecc772577cdf870048401280d421321bbb55d8b251/MarkupSafe-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:7ed789d0f7f11fcf118cf0acb378743dfdd4215d7f7d18837c88171405c9a452", size = 15564 }, + { url = "https://files.pythonhosted.org/packages/14/dd/7149242a730e218b6dd7ffa6817c951f51f4204e7afb8e8bbf688d8ae4c3/MarkupSafe-3.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:27d6a73682b99568916c54a4bfced40e7d871ba685b580ea04bbd2e405dfd4c5", size = 14276 }, + { url = "https://files.pythonhosted.org/packages/8a/c5/b6cda6248f83c59148540b6d815b4c59b1222e059fe759eb3c446748b744/MarkupSafe-3.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:494a64efc535e147fcc713dba58eecfce3a79f1e93ebe81995b387f5cd9bc2e1", size = 12325 }, + { url = "https://files.pythonhosted.org/packages/9c/84/9f82de5f77f61c64fec414f4ae7e1e7871b82da0d52414f8810410de752a/MarkupSafe-3.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5243044a927e8a6bb28517838662a019cd7f73d7f106bbb37ab5e7fa8451a92", size = 24010 }, + { url = "https://files.pythonhosted.org/packages/45/14/80f6553deba7a6beeae455f2c1e450f55f0f17241f06ed065571445e2bf0/MarkupSafe-3.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63dae84964a9a3d2610808cee038f435d9a111620c37ccf872c2fcaeca6865b3", size = 23163 }, + { url = "https://files.pythonhosted.org/packages/34/03/e64f36452db4eabf3b89cfbbebf46736afa82eda0c95f3f4bf11c4cf3c85/MarkupSafe-3.0.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcbee57fedc9b2182c54ffc1c5eed316c3da8bbfeda8009e1b5d7220199d15da", size = 23044 }, + { url = "https://files.pythonhosted.org/packages/eb/89/9c47f58e3e75adbaa9387f3db84ca6a7d3a3abd93e7541cfaadad073e5d6/MarkupSafe-3.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f846fd7c241e5bd4161e2a483663eb66e4d8e12130fcdc052f310f388f1d61c6", size = 23849 }, + { url = "https://files.pythonhosted.org/packages/87/ae/fd72c59177ae148aee41eed67f5dcb73e96590f439fd0149c88deab207c0/MarkupSafe-3.0.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:678fbceb202382aae42c1f0cd9f56b776bc20a58ae5b553ee1fe6b802983a1d6", size = 23414 }, + { url = "https://files.pythonhosted.org/packages/7a/8f/2e9a4653c78744b8a65cab56382148073c96893efc4c75eef2fa0a96f608/MarkupSafe-3.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bd9b8e458e2bab52f9ad3ab5dc8b689a3c84b12b2a2f64cd9a0dfe209fb6b42f", size = 23518 }, + { url = "https://files.pythonhosted.org/packages/81/ac/1ab4e1f47f1778bd2c407b7be543b3c08bff555c8444c742e3c53958d114/MarkupSafe-3.0.0-cp313-cp313-win32.whl", hash = "sha256:1fd02f47596e00a372f5b4af2b4c45f528bade65c66dfcbc6e1ea1bfda758e98", size = 15068 }, + { url = "https://files.pythonhosted.org/packages/53/c4/b3d9f84a093244602e6081e35cf1166cd2f6e3d65746da12d4c13511e2cb/MarkupSafe-3.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:b94bec9eda10111ec7102ef909eca4f3c2df979643924bfe58375f560713a7d1", size = 15566 }, + { url = "https://files.pythonhosted.org/packages/47/2d/6ea2c34833582fb04447e2a91ae8f49540a57757add92cb5095e49d12c61/MarkupSafe-3.0.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:509c424069dd037d078925b6815fc56b7271f3aaec471e55e6fa513b0a80d2aa", size = 14513 }, + { url = "https://files.pythonhosted.org/packages/bf/bf/0ee8f270b82fab05b763cfbacc2c33a62f571f59968abc37d4793b3c1623/MarkupSafe-3.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81be2c0084d8c69e97e3c5d73ce9e2a6e523556f2a19c4e195c09d499be2f808", size = 12460 }, + { url = "https://files.pythonhosted.org/packages/e4/63/90a907e327e640462ccc671fd55c140e609d09312fa6db62822b2066bf5b/MarkupSafe-3.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b43ac1eb9f91e0c14aac1d2ef0f76bc7b9ceea51de47536f61268191adf52ad7", size = 25312 }, + { url = "https://files.pythonhosted.org/packages/7a/04/84e439fd573000d85c2394e690dfbf2f322bf09b010689bcac4bafee8834/MarkupSafe-3.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b231255770723f1e125d63c14269bcd8b8136ecfb620b9a18c0297e046d0736", size = 23746 }, + { url = "https://files.pythonhosted.org/packages/5f/7d/2bb2663db79eb702d168ab6728741f64e431cd78f55b22c868e95d9805ef/MarkupSafe-3.0.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c182d45600556917f811aa019d834a89fe4b6f6255da2fd0bdcf80e970f95918", size = 23696 }, + { url = "https://files.pythonhosted.org/packages/5c/66/3227765a7215b205847d71af5def5693027df2538bdd33775eef1ee8151f/MarkupSafe-3.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f91c90f8f3bf436f81c12eeb4d79f9ddd263c71125e6ad71341906832a34386", size = 25026 }, + { url = "https://files.pythonhosted.org/packages/f5/77/f3787b456331c94458aef7629c197a70b1c5279e0d04ad0646a13484a20c/MarkupSafe-3.0.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a7171d2b869e9be238ea318c196baf58fbf272704e9c1cd4be8c380eea963342", size = 23988 }, + { url = "https://files.pythonhosted.org/packages/d8/27/bffd73c503bfe6f00fa3de64703e00768f65f74a37b6fb2342ef771cacfd/MarkupSafe-3.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cb244adf2499aa37d5dc43431990c7f0b632d841af66a51d22bd89c437b60264", size = 23967 }, + { url = "https://files.pythonhosted.org/packages/31/b5/d4a9ecb9785d0d5cad3fac326488dc99eb85270dea989d460cbebd603626/MarkupSafe-3.0.0-cp313-cp313t-win32.whl", hash = "sha256:96e3ed550600185d34429477f1176cedea8293fa40e47fe37a05751bcb64c997", size = 15166 }, + { url = "https://files.pythonhosted.org/packages/8f/86/4b87d92b35f9818d52bfda94abec26ef1b50441982c57d20566ec6b46ada/MarkupSafe-3.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:1d151b9cf3307e259b749125a5a08c030ba15a8f1d567ca5bfb0e92f35e761f5", size = 15694 }, + { url = "https://files.pythonhosted.org/packages/99/51/ef4f8d801aff0e01bd80260dfa85cb64800866927aff6f834c3d6f7ebe7c/MarkupSafe-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:23efb2be7221105c8eb0e905433414d2439cb0a8c5d5ca081c1c72acef0f5613", size = 14328 }, + { url = "https://files.pythonhosted.org/packages/9d/86/afe05136029d09541a7ef6daab922f01739f67e1f086634a1149109a5a78/MarkupSafe-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:81ee9c967956b9ea39b3a5270b7cb1740928d205b0dc72629164ce621b4debf9", size = 12356 }, + { url = "https://files.pythonhosted.org/packages/ff/08/0a5cad23cad2dcd13aa68ad7d8c56b4b10f4c86484e24008aced445ab3e7/MarkupSafe-3.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5509a8373fed30b978557890a226c3d30569746c565b9daba69df80c160365a5", size = 21604 }, + { url = "https://files.pythonhosted.org/packages/6e/ac/a02e6dadef6f778ec98569721e8e71152f9ad1ac7438c99cb70684e0f453/MarkupSafe-3.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1c13c6c908811f867a8e9e66efb2d6c03d1cdd83e92788fe97f693c457dc44f", size = 20769 }, + { url = "https://files.pythonhosted.org/packages/63/63/377ecc7aea0fae9b5aed793cc65b586a4ab4b52bc0f0198622f722f6e4aa/MarkupSafe-3.0.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7e63d1977d3806ce0a1a3e0099b089f61abdede5238ca6a3f3bf8877b46d095", size = 20905 }, + { url = "https://files.pythonhosted.org/packages/8e/13/7819a2261f0ca26474121512def4d8a354869f3f1d28c38fef4226a9936d/MarkupSafe-3.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d2c099be5274847d606574234e494f23a359e829ba337ea9037c3a72b0851942", size = 21498 }, + { url = "https://files.pythonhosted.org/packages/a7/f2/eea3125b43826fe88c9b1cb7d8fa007a283d7c4b79577a3712db6e61e3b1/MarkupSafe-3.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e042ccf8fe5bf8b6a4b38b3f7d618eb10ea20402b0c9f4add9293408de447974", size = 21171 }, + { url = "https://files.pythonhosted.org/packages/20/2d/474d27577ba12d5bb465133096424d037f7f272466f4e81e6c37c9cfe07a/MarkupSafe-3.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:98fb3a2bf525ad66db96745707b93ba0f78928b7a1cb2f1cb4b143bc7e2ba3b3", size = 20905 }, + { url = "https://files.pythonhosted.org/packages/73/8c/7087be0d8e090ee424d59307da837f6401bf6465b03bf6dd0e36bfc40b9a/MarkupSafe-3.0.0-cp39-cp39-win32.whl", hash = "sha256:a80c6740e1bfbe50cea7cbf74f48823bb57bd59d914ee22ff8a81963b08e62d2", size = 15024 }, + { url = "https://files.pythonhosted.org/packages/a1/0d/39a8acf44dd8cfe60c93f589b1c553a4d5865f05e6b752481604147b72e5/MarkupSafe-3.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:5d207ff5cceef77796f8aacd44263266248cf1fbc601441524d7835613f8abec", size = 15477 }, ] [[package]] @@ -1052,7 +1070,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "3.8.0" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -1061,9 +1079,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/10/97ee2fa54dff1e9da9badbc5e35d0bbaef0776271ea5907eccf64140f72f/pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af", size = 177815 } +sdist = { url = "https://files.pythonhosted.org/packages/5c/e8/4aac596478e02f29b3e323db3dfb90a11c1291ef4e5cceca608a57df8975/pre_commit-4.0.0.tar.gz", hash = "sha256:5d9807162cc5537940f94f266cbe2d716a75cfad0d78a317a92cac16287cfed6", size = 191628 } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/92/caae8c86e94681b42c246f0bca35c059a2f0529e5b92619f6aba4cf7e7b6/pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f", size = 204643 }, + { url = "https://files.pythonhosted.org/packages/fd/77/e808ffcf30b842b80a42e466edb7bad9644083d0452f01cce51a1f1921f6/pre_commit-4.0.0-py2.py3-none-any.whl", hash = "sha256:0ca2341cf94ac1865350970951e54b1a50521e57b7b500403307aed4315a1234", size = 218705 }, ] [[package]] @@ -1078,6 +1096,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/6a/fd08d94654f7e67c52ca30523a178b3f8ccc4237fce4be90d39c938a831a/prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e", size = 386595 }, ] +[[package]] +name = "propcache" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/4d/5e5a60b78dbc1d464f8a7bbaeb30957257afdc8512cbb9dfd5659304f5cd/propcache-0.2.0.tar.gz", hash = "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70", size = 40951 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/08/1963dfb932b8d74d5b09098507b37e9b96c835ba89ab8aad35aa330f4ff3/propcache-0.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c5869b8fd70b81835a6f187c5fdbe67917a04d7e52b6e7cc4e5fe39d55c39d58", size = 80712 }, + { url = "https://files.pythonhosted.org/packages/e6/59/49072aba9bf8a8ed958e576182d46f038e595b17ff7408bc7e8807e721e1/propcache-0.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:952e0d9d07609d9c5be361f33b0d6d650cd2bae393aabb11d9b719364521984b", size = 46301 }, + { url = "https://files.pythonhosted.org/packages/33/a2/6b1978c2e0d80a678e2c483f45e5443c15fe5d32c483902e92a073314ef1/propcache-0.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33ac8f098df0585c0b53009f039dfd913b38c1d2edafed0cedcc0c32a05aa110", size = 45581 }, + { url = "https://files.pythonhosted.org/packages/43/95/55acc9adff8f997c7572f23d41993042290dfb29e404cdadb07039a4386f/propcache-0.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e48e8875e6c13909c800fa344cd54cc4b2b0db1d5f911f840458a500fde2c2", size = 208659 }, + { url = "https://files.pythonhosted.org/packages/bd/2c/ef7371ff715e6cd19ea03fdd5637ecefbaa0752fee5b0f2fe8ea8407ee01/propcache-0.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:388f3217649d6d59292b722d940d4d2e1e6a7003259eb835724092a1cca0203a", size = 222613 }, + { url = "https://files.pythonhosted.org/packages/5e/1c/fef251f79fd4971a413fa4b1ae369ee07727b4cc2c71e2d90dfcde664fbb/propcache-0.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f571aea50ba5623c308aa146eb650eebf7dbe0fd8c5d946e28343cb3b5aad577", size = 221067 }, + { url = "https://files.pythonhosted.org/packages/8d/e7/22e76ae6fc5a1708bdce92bdb49de5ebe89a173db87e4ef597d6bbe9145a/propcache-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3dfafb44f7bb35c0c06eda6b2ab4bfd58f02729e7c4045e179f9a861b07c9850", size = 208920 }, + { url = "https://files.pythonhosted.org/packages/04/3e/f10aa562781bcd8a1e0b37683a23bef32bdbe501d9cc7e76969becaac30d/propcache-0.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3ebe9a75be7ab0b7da2464a77bb27febcb4fab46a34f9288f39d74833db7f61", size = 200050 }, + { url = "https://files.pythonhosted.org/packages/d0/98/8ac69f638358c5f2a0043809c917802f96f86026e86726b65006830f3dc6/propcache-0.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2f0d0f976985f85dfb5f3d685697ef769faa6b71993b46b295cdbbd6be8cc37", size = 202346 }, + { url = "https://files.pythonhosted.org/packages/ee/78/4acfc5544a5075d8e660af4d4e468d60c418bba93203d1363848444511ad/propcache-0.2.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a3dc1a4b165283bd865e8f8cb5f0c64c05001e0718ed06250d8cac9bec115b48", size = 199750 }, + { url = "https://files.pythonhosted.org/packages/a2/8f/90ada38448ca2e9cf25adc2fe05d08358bda1b9446f54a606ea38f41798b/propcache-0.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9e0f07b42d2a50c7dd2d8675d50f7343d998c64008f1da5fef888396b7f84630", size = 201279 }, + { url = "https://files.pythonhosted.org/packages/08/31/0e299f650f73903da851f50f576ef09bfffc8e1519e6a2f1e5ed2d19c591/propcache-0.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e63e3e1e0271f374ed489ff5ee73d4b6e7c60710e1f76af5f0e1a6117cd26394", size = 211035 }, + { url = "https://files.pythonhosted.org/packages/85/3e/e356cc6b09064bff1c06d0b2413593e7c925726f0139bc7acef8a21e87a8/propcache-0.2.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:56bb5c98f058a41bb58eead194b4db8c05b088c93d94d5161728515bd52b052b", size = 215565 }, + { url = "https://files.pythonhosted.org/packages/8b/54/4ef7236cd657e53098bd05aa59cbc3cbf7018fba37b40eaed112c3921e51/propcache-0.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7665f04d0c7f26ff8bb534e1c65068409bf4687aa2534faf7104d7182debb336", size = 207604 }, + { url = "https://files.pythonhosted.org/packages/1f/27/d01d7799c068443ee64002f0655d82fb067496897bf74b632e28ee6a32cf/propcache-0.2.0-cp310-cp310-win32.whl", hash = "sha256:7cf18abf9764746b9c8704774d8b06714bcb0a63641518a3a89c7f85cc02c2ad", size = 40526 }, + { url = "https://files.pythonhosted.org/packages/bb/44/6c2add5eeafb7f31ff0d25fbc005d930bea040a1364cf0f5768750ddf4d1/propcache-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:cfac69017ef97db2438efb854edf24f5a29fd09a536ff3a992b75990720cdc99", size = 44958 }, + { url = "https://files.pythonhosted.org/packages/e0/1c/71eec730e12aec6511e702ad0cd73c2872eccb7cad39de8ba3ba9de693ef/propcache-0.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:63f13bf09cc3336eb04a837490b8f332e0db41da66995c9fd1ba04552e516354", size = 80811 }, + { url = "https://files.pythonhosted.org/packages/89/c3/7e94009f9a4934c48a371632197406a8860b9f08e3f7f7d922ab69e57a41/propcache-0.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608cce1da6f2672a56b24a015b42db4ac612ee709f3d29f27a00c943d9e851de", size = 46365 }, + { url = "https://files.pythonhosted.org/packages/c0/1d/c700d16d1d6903aeab28372fe9999762f074b80b96a0ccc953175b858743/propcache-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:466c219deee4536fbc83c08d09115249db301550625c7fef1c5563a584c9bc87", size = 45602 }, + { url = "https://files.pythonhosted.org/packages/2e/5e/4a3e96380805bf742712e39a4534689f4cddf5fa2d3a93f22e9fd8001b23/propcache-0.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2db02409338bf36590aa985a461b2c96fce91f8e7e0f14c50c5fcc4f229016", size = 236161 }, + { url = "https://files.pythonhosted.org/packages/a5/85/90132481183d1436dff6e29f4fa81b891afb6cb89a7306f32ac500a25932/propcache-0.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6ed8db0a556343d566a5c124ee483ae113acc9a557a807d439bcecc44e7dfbb", size = 244938 }, + { url = "https://files.pythonhosted.org/packages/4a/89/c893533cb45c79c970834274e2d0f6d64383ec740be631b6a0a1d2b4ddc0/propcache-0.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91997d9cb4a325b60d4e3f20967f8eb08dfcb32b22554d5ef78e6fd1dda743a2", size = 243576 }, + { url = "https://files.pythonhosted.org/packages/8c/56/98c2054c8526331a05f205bf45cbb2cda4e58e56df70e76d6a509e5d6ec6/propcache-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c7dde9e533c0a49d802b4f3f218fa9ad0a1ce21f2c2eb80d5216565202acab4", size = 236011 }, + { url = "https://files.pythonhosted.org/packages/2d/0c/8b8b9f8a6e1abd869c0fa79b907228e7abb966919047d294ef5df0d136cf/propcache-0.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffcad6c564fe6b9b8916c1aefbb37a362deebf9394bd2974e9d84232e3e08504", size = 224834 }, + { url = "https://files.pythonhosted.org/packages/18/bb/397d05a7298b7711b90e13108db697732325cafdcd8484c894885c1bf109/propcache-0.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:97a58a28bcf63284e8b4d7b460cbee1edaab24634e82059c7b8c09e65284f178", size = 224946 }, + { url = "https://files.pythonhosted.org/packages/25/19/4fc08dac19297ac58135c03770b42377be211622fd0147f015f78d47cd31/propcache-0.2.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:945db8ee295d3af9dbdbb698cce9bbc5c59b5c3fe328bbc4387f59a8a35f998d", size = 217280 }, + { url = "https://files.pythonhosted.org/packages/7e/76/c79276a43df2096ce2aba07ce47576832b1174c0c480fe6b04bd70120e59/propcache-0.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39e104da444a34830751715f45ef9fc537475ba21b7f1f5b0f4d71a3b60d7fe2", size = 220088 }, + { url = "https://files.pythonhosted.org/packages/c3/9a/8a8cf428a91b1336b883f09c8b884e1734c87f724d74b917129a24fe2093/propcache-0.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c5ecca8f9bab618340c8e848d340baf68bcd8ad90a8ecd7a4524a81c1764b3db", size = 233008 }, + { url = "https://files.pythonhosted.org/packages/25/7b/768a8969abd447d5f0f3333df85c6a5d94982a1bc9a89c53c154bf7a8b11/propcache-0.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c436130cc779806bdf5d5fae0d848713105472b8566b75ff70048c47d3961c5b", size = 237719 }, + { url = "https://files.pythonhosted.org/packages/ed/0d/e5d68ccc7976ef8b57d80613ac07bbaf0614d43f4750cf953f0168ef114f/propcache-0.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:191db28dc6dcd29d1a3e063c3be0b40688ed76434622c53a284e5427565bbd9b", size = 227729 }, + { url = "https://files.pythonhosted.org/packages/05/64/17eb2796e2d1c3d0c431dc5f40078d7282f4645af0bb4da9097fbb628c6c/propcache-0.2.0-cp311-cp311-win32.whl", hash = "sha256:5f2564ec89058ee7c7989a7b719115bdfe2a2fb8e7a4543b8d1c0cc4cf6478c1", size = 40473 }, + { url = "https://files.pythonhosted.org/packages/83/c5/e89fc428ccdc897ade08cd7605f174c69390147526627a7650fb883e0cd0/propcache-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e2e54267980349b723cff366d1e29b138b9a60fa376664a157a342689553f71", size = 44921 }, + { url = "https://files.pythonhosted.org/packages/7c/46/a41ca1097769fc548fc9216ec4c1471b772cc39720eb47ed7e38ef0006a9/propcache-0.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ee7606193fb267be4b2e3b32714f2d58cad27217638db98a60f9efb5efeccc2", size = 80800 }, + { url = "https://files.pythonhosted.org/packages/75/4f/93df46aab9cc473498ff56be39b5f6ee1e33529223d7a4d8c0a6101a9ba2/propcache-0.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:91ee8fc02ca52e24bcb77b234f22afc03288e1dafbb1f88fe24db308910c4ac7", size = 46443 }, + { url = "https://files.pythonhosted.org/packages/0b/17/308acc6aee65d0f9a8375e36c4807ac6605d1f38074b1581bd4042b9fb37/propcache-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e900bad2a8456d00a113cad8c13343f3b1f327534e3589acc2219729237a2e8", size = 45676 }, + { url = "https://files.pythonhosted.org/packages/65/44/626599d2854d6c1d4530b9a05e7ff2ee22b790358334b475ed7c89f7d625/propcache-0.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f52a68c21363c45297aca15561812d542f8fc683c85201df0bebe209e349f793", size = 246191 }, + { url = "https://files.pythonhosted.org/packages/f2/df/5d996d7cb18df076debae7d76ac3da085c0575a9f2be6b1f707fe227b54c/propcache-0.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e41d67757ff4fbc8ef2af99b338bfb955010444b92929e9e55a6d4dcc3c4f09", size = 251791 }, + { url = "https://files.pythonhosted.org/packages/2e/6d/9f91e5dde8b1f662f6dd4dff36098ed22a1ef4e08e1316f05f4758f1576c/propcache-0.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a64e32f8bd94c105cc27f42d3b658902b5bcc947ece3c8fe7bc1b05982f60e89", size = 253434 }, + { url = "https://files.pythonhosted.org/packages/3c/e9/1b54b7e26f50b3e0497cd13d3483d781d284452c2c50dd2a615a92a087a3/propcache-0.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55346705687dbd7ef0d77883ab4f6fabc48232f587925bdaf95219bae072491e", size = 248150 }, + { url = "https://files.pythonhosted.org/packages/a7/ef/a35bf191c8038fe3ce9a414b907371c81d102384eda5dbafe6f4dce0cf9b/propcache-0.2.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00181262b17e517df2cd85656fcd6b4e70946fe62cd625b9d74ac9977b64d8d9", size = 233568 }, + { url = "https://files.pythonhosted.org/packages/97/d9/d00bb9277a9165a5e6d60f2142cd1a38a750045c9c12e47ae087f686d781/propcache-0.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6994984550eaf25dd7fc7bd1b700ff45c894149341725bb4edc67f0ffa94efa4", size = 229874 }, + { url = "https://files.pythonhosted.org/packages/8e/78/c123cf22469bdc4b18efb78893e69c70a8b16de88e6160b69ca6bdd88b5d/propcache-0.2.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:56295eb1e5f3aecd516d91b00cfd8bf3a13991de5a479df9e27dd569ea23959c", size = 225857 }, + { url = "https://files.pythonhosted.org/packages/31/1b/fd6b2f1f36d028820d35475be78859d8c89c8f091ad30e377ac49fd66359/propcache-0.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:439e76255daa0f8151d3cb325f6dd4a3e93043e6403e6491813bcaaaa8733887", size = 227604 }, + { url = "https://files.pythonhosted.org/packages/99/36/b07be976edf77a07233ba712e53262937625af02154353171716894a86a6/propcache-0.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f6475a1b2ecb310c98c28d271a30df74f9dd436ee46d09236a6b750a7599ce57", size = 238430 }, + { url = "https://files.pythonhosted.org/packages/0d/64/5822f496c9010e3966e934a011ac08cac8734561842bc7c1f65586e0683c/propcache-0.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3444cdba6628accf384e349014084b1cacd866fbb88433cd9d279d90a54e0b23", size = 244814 }, + { url = "https://files.pythonhosted.org/packages/fd/bd/8657918a35d50b18a9e4d78a5df7b6c82a637a311ab20851eef4326305c1/propcache-0.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a9d9b4d0a9b38d1c391bb4ad24aa65f306c6f01b512e10a8a34a2dc5675d348", size = 235922 }, + { url = "https://files.pythonhosted.org/packages/a8/6f/ec0095e1647b4727db945213a9f395b1103c442ef65e54c62e92a72a3f75/propcache-0.2.0-cp312-cp312-win32.whl", hash = "sha256:69d3a98eebae99a420d4b28756c8ce6ea5a29291baf2dc9ff9414b42676f61d5", size = 40177 }, + { url = "https://files.pythonhosted.org/packages/20/a2/bd0896fdc4f4c1db46d9bc361c8c79a9bf08ccc08ba054a98e38e7ba1557/propcache-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad9c9b99b05f163109466638bd30ada1722abb01bbb85c739c50b6dc11f92dc3", size = 44446 }, + { url = "https://files.pythonhosted.org/packages/a8/a7/5f37b69197d4f558bfef5b4bceaff7c43cc9b51adf5bd75e9081d7ea80e4/propcache-0.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecddc221a077a8132cf7c747d5352a15ed763b674c0448d811f408bf803d9ad7", size = 78120 }, + { url = "https://files.pythonhosted.org/packages/c8/cd/48ab2b30a6b353ecb95a244915f85756d74f815862eb2ecc7a518d565b48/propcache-0.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0e53cb83fdd61cbd67202735e6a6687a7b491c8742dfc39c9e01e80354956763", size = 45127 }, + { url = "https://files.pythonhosted.org/packages/a5/ba/0a1ef94a3412aab057bd996ed5f0ac7458be5bf469e85c70fa9ceb43290b/propcache-0.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92fe151145a990c22cbccf9ae15cae8ae9eddabfc949a219c9f667877e40853d", size = 44419 }, + { url = "https://files.pythonhosted.org/packages/b4/6c/ca70bee4f22fa99eacd04f4d2f1699be9d13538ccf22b3169a61c60a27fa/propcache-0.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a21ef516d36909931a2967621eecb256018aeb11fc48656e3257e73e2e247a", size = 229611 }, + { url = "https://files.pythonhosted.org/packages/19/70/47b872a263e8511ca33718d96a10c17d3c853aefadeb86dc26e8421184b9/propcache-0.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f88a4095e913f98988f5b338c1d4d5d07dbb0b6bad19892fd447484e483ba6b", size = 234005 }, + { url = "https://files.pythonhosted.org/packages/4f/be/3b0ab8c84a22e4a3224719099c1229ddfdd8a6a1558cf75cb55ee1e35c25/propcache-0.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a5b3bb545ead161be780ee85a2b54fdf7092815995661947812dde94a40f6fb", size = 237270 }, + { url = "https://files.pythonhosted.org/packages/04/d8/f071bb000d4b8f851d312c3c75701e586b3f643fe14a2e3409b1b9ab3936/propcache-0.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67aeb72e0f482709991aa91345a831d0b707d16b0257e8ef88a2ad246a7280bf", size = 231877 }, + { url = "https://files.pythonhosted.org/packages/93/e7/57a035a1359e542bbb0a7df95aad6b9871ebee6dce2840cb157a415bd1f3/propcache-0.2.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c997f8c44ec9b9b0bcbf2d422cc00a1d9b9c681f56efa6ca149a941e5560da2", size = 217848 }, + { url = "https://files.pythonhosted.org/packages/f0/93/d1dea40f112ec183398fb6c42fde340edd7bab202411c4aa1a8289f461b6/propcache-0.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a66df3d4992bc1d725b9aa803e8c5a66c010c65c741ad901e260ece77f58d2f", size = 216987 }, + { url = "https://files.pythonhosted.org/packages/62/4c/877340871251145d3522c2b5d25c16a1690ad655fbab7bb9ece6b117e39f/propcache-0.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3ebbcf2a07621f29638799828b8d8668c421bfb94c6cb04269130d8de4fb7136", size = 212451 }, + { url = "https://files.pythonhosted.org/packages/7c/bb/a91b72efeeb42906ef58ccf0cdb87947b54d7475fee3c93425d732f16a61/propcache-0.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1235c01ddaa80da8235741e80815ce381c5267f96cc49b1477fdcf8c047ef325", size = 212879 }, + { url = "https://files.pythonhosted.org/packages/9b/7f/ee7fea8faac57b3ec5d91ff47470c6c5d40d7f15d0b1fccac806348fa59e/propcache-0.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3947483a381259c06921612550867b37d22e1df6d6d7e8361264b6d037595f44", size = 222288 }, + { url = "https://files.pythonhosted.org/packages/ff/d7/acd67901c43d2e6b20a7a973d9d5fd543c6e277af29b1eb0e1f7bd7ca7d2/propcache-0.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d5bed7f9805cc29c780f3aee05de3262ee7ce1f47083cfe9f77471e9d6777e83", size = 228257 }, + { url = "https://files.pythonhosted.org/packages/8d/6f/6272ecc7a8daad1d0754cfc6c8846076a8cb13f810005c79b15ce0ef0cf2/propcache-0.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4a91d44379f45f5e540971d41e4626dacd7f01004826a18cb048e7da7e96544", size = 221075 }, + { url = "https://files.pythonhosted.org/packages/7c/bd/c7a6a719a6b3dd8b3aeadb3675b5783983529e4a3185946aa444d3e078f6/propcache-0.2.0-cp313-cp313-win32.whl", hash = "sha256:f902804113e032e2cdf8c71015651c97af6418363bea8d78dc0911d56c335032", size = 39654 }, + { url = "https://files.pythonhosted.org/packages/88/e7/0eef39eff84fa3e001b44de0bd41c7c0e3432e7648ffd3d64955910f002d/propcache-0.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f188cfcc64fb1266f4684206c9de0e80f54622c3f22a910cbd200478aeae61e", size = 43705 }, + { url = "https://files.pythonhosted.org/packages/38/05/797e6738c9f44ab5039e3ff329540c934eabbe8ad7e63c305c75844bc86f/propcache-0.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:25c8d773a62ce0451b020c7b29a35cfbc05de8b291163a7a0f3b7904f27253e6", size = 81903 }, + { url = "https://files.pythonhosted.org/packages/9f/84/8d5edb9a73e1a56b24dd8f2adb6aac223109ff0e8002313d52e5518258ba/propcache-0.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:375a12d7556d462dc64d70475a9ee5982465fbb3d2b364f16b86ba9135793638", size = 46960 }, + { url = "https://files.pythonhosted.org/packages/e7/77/388697bedda984af0d12d68e536b98129b167282da3401965c8450de510e/propcache-0.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1ec43d76b9677637a89d6ab86e1fef70d739217fefa208c65352ecf0282be957", size = 46133 }, + { url = "https://files.pythonhosted.org/packages/e2/dc/60d444610bc5b1d7a758534f58362b1bcee736a785473f8a39c91f05aad1/propcache-0.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f45eec587dafd4b2d41ac189c2156461ebd0c1082d2fe7013571598abb8505d1", size = 211105 }, + { url = "https://files.pythonhosted.org/packages/bc/c6/40eb0dd1de6f8e84f454615ab61f68eb4a58f9d63d6f6eaf04300ac0cc17/propcache-0.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc092ba439d91df90aea38168e11f75c655880c12782facf5cf9c00f3d42b562", size = 226613 }, + { url = "https://files.pythonhosted.org/packages/de/b6/e078b5e9de58e20db12135eb6a206b4b43cb26c6b62ee0fe36ac40763a64/propcache-0.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa1076244f54bb76e65e22cb6910365779d5c3d71d1f18b275f1dfc7b0d71b4d", size = 225587 }, + { url = "https://files.pythonhosted.org/packages/ce/4e/97059dd24494d1c93d1efb98bb24825e1930265b41858dd59c15cb37a975/propcache-0.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:682a7c79a2fbf40f5dbb1eb6bfe2cd865376deeac65acf9beb607505dced9e12", size = 211826 }, + { url = "https://files.pythonhosted.org/packages/fc/23/4dbf726602a989d2280fe130a9b9dd71faa8d3bb8cd23d3261ff3c23f692/propcache-0.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e40876731f99b6f3c897b66b803c9e1c07a989b366c6b5b475fafd1f7ba3fb8", size = 203140 }, + { url = "https://files.pythonhosted.org/packages/5b/ce/f3bff82c885dbd9ae9e43f134d5b02516c3daa52d46f7a50e4f52ef9121f/propcache-0.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:363ea8cd3c5cb6679f1c2f5f1f9669587361c062e4899fce56758efa928728f8", size = 208841 }, + { url = "https://files.pythonhosted.org/packages/29/d7/19a4d3b4c7e95d08f216da97035d0b103d0c90411c6f739d47088d2da1f0/propcache-0.2.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:140fbf08ab3588b3468932974a9331aff43c0ab8a2ec2c608b6d7d1756dbb6cb", size = 203315 }, + { url = "https://files.pythonhosted.org/packages/db/87/5748212a18beb8d4ab46315c55ade8960d1e2cdc190764985b2d229dd3f4/propcache-0.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e70fac33e8b4ac63dfc4c956fd7d85a0b1139adcfc0d964ce288b7c527537fea", size = 204724 }, + { url = "https://files.pythonhosted.org/packages/84/2a/c3d2f989fc571a5bad0fabcd970669ccb08c8f9b07b037ecddbdab16a040/propcache-0.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b33d7a286c0dc1a15f5fc864cc48ae92a846df287ceac2dd499926c3801054a6", size = 215514 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4c44c133b08bc5f776afcb8f0833889c2636b8a83e07ea1d9096c1e401b0/propcache-0.2.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f6d5749fdd33d90e34c2efb174c7e236829147a2713334d708746e94c4bde40d", size = 220063 }, + { url = "https://files.pythonhosted.org/packages/2e/25/280d0a3bdaee68db74c0acd9a472e59e64b516735b59cffd3a326ff9058a/propcache-0.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22aa8f2272d81d9317ff5756bb108021a056805ce63dd3630e27d042c8092798", size = 211620 }, + { url = "https://files.pythonhosted.org/packages/28/8c/266898981b7883c1563c35954f9ce9ced06019fdcc487a9520150c48dc91/propcache-0.2.0-cp39-cp39-win32.whl", hash = "sha256:73e4b40ea0eda421b115248d7e79b59214411109a5bc47d0d48e4c73e3b8fcf9", size = 41049 }, + { url = "https://files.pythonhosted.org/packages/af/53/a3e5b937f58e757a940716b88105ec4c211c42790c1ea17052b46dc16f16/propcache-0.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:9517d5e9e0731957468c29dbfd0f976736a0e55afaea843726e887f36fe017df", size = 45587 }, + { url = "https://files.pythonhosted.org/packages/3d/b6/e6d98278f2d49b22b4d033c9f792eda783b9ab2094b041f013fc69bcde87/propcache-0.2.0-py3-none-any.whl", hash = "sha256:2ccc28197af5313706511fab3a8b66dcd6da067a1331372c82ea1cb74285e036", size = 11603 }, +] + [[package]] name = "ptpython" version = "3.0.29" @@ -1344,7 +1451,7 @@ wheels = [ [[package]] name = "python-kasa" -version = "0.7.4" +version = "0.7.5" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -1503,15 +1610,16 @@ wheels = [ [[package]] name = "rich" -version = "13.8.1" +version = "13.9.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/76/40f084cb7db51c9d1fa29a7120717892aeda9a7711f6225692c957a93535/rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a", size = 222080 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/9e/1784d15b057b0075e5136445aaea92d23955aad2c93eaede673718a40d95/rich-13.9.2.tar.gz", hash = "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c", size = 222843 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/11/dadb85e2bd6b1f1ae56669c3e1f0410797f9605d752d68fb47b77f525b31/rich-13.8.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06", size = 241608 }, + { url = "https://files.pythonhosted.org/packages/67/91/5474b84e505a6ccc295b2d322d90ff6aa0746745717839ee0c5fb4fdcceb/rich-13.9.2-py3-none-any.whl", hash = "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1", size = 242117 }, ] [[package]] @@ -1663,11 +1771,11 @@ wheels = [ [[package]] name = "termcolor" -version = "2.4.0" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/56/d7d66a84f96d804155f6ff2873d065368b25a07222a6fd51c4f24ef6d764/termcolor-2.4.0.tar.gz", hash = "sha256:aab9e56047c8ac41ed798fa36d892a37aca6b3e9159f3e0c24bc64a9b3ac7b7a", size = 12664 } +sdist = { url = "https://files.pythonhosted.org/packages/37/72/88311445fd44c455c7d553e61f95412cf89054308a1aa2434ab835075fc5/termcolor-2.5.0.tar.gz", hash = "sha256:998d8d27da6d48442e8e1f016119076b690d962507531df4890fcd2db2ef8a6f", size = 13057 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/5f/8c716e47b3a50cbd7c146f45881e11d9414def768b7cd9c5e6650ec2a80a/termcolor-2.4.0-py3-none-any.whl", hash = "sha256:9297c0df9c99445c2412e832e882a7884038a25617c60cea2ad69488d4040d63", size = 7719 }, + { url = "https://files.pythonhosted.org/packages/7f/be/df630c387a0a054815d60be6a97eb4e8f17385d5d6fe660e1c02750062b4/termcolor-2.5.0-py3-none-any.whl", hash = "sha256:37b17b5fc1e604945c2642c872a3764b5d547a48009871aea3edd3afa180afb8", size = 7755 }, ] [[package]] @@ -1681,11 +1789,11 @@ wheels = [ [[package]] name = "tomli" -version = "2.0.1" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164 } +sdist = { url = "https://files.pythonhosted.org/packages/35/b9/de2a5c0144d7d75a57ff355c0c24054f965b2dc3036456ae03a51ea6264b/tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed", size = 16096 } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, + { url = "https://files.pythonhosted.org/packages/cf/db/ce8eda256fa131af12e0a76d481711abe4681b6923c27efb9a255c9e4594/tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", size = 13237 }, ] [[package]] @@ -1758,90 +1866,91 @@ wheels = [ [[package]] name = "yarl" -version = "1.13.0" +version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/27/6e/b26e831b6abede32fba3763131eb2c52987f574277daa65e10a5fda6021c/yarl-1.13.0.tar.gz", hash = "sha256:02f117a63d11c8c2ada229029f8bb444a811e62e5041da962de548f26ac2c40f", size = 165688 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/e8/5eb11fc80f93aadb4da491bff2f8ad8fced64fd4415dd4ecd32252fe3c12/yarl-1.13.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:66c028066be36d54e7a0a38e832302b23222e75db7e65ed862dc94effc8ef062", size = 189620 }, - { url = "https://files.pythonhosted.org/packages/ee/93/bd1545ff3d1d2087ac769d5b4b03204b03591136409e5188b73a5689f575/yarl-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:517f9d90ca0224bb7002266eba6e70d8fcc8b1d0c9321de2407e41344413ed46", size = 115525 }, - { url = "https://files.pythonhosted.org/packages/50/a1/9cf139b0e89c1a8deed77b5d40b998bee707b0e53629487f2c34348a72f4/yarl-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5378cb60f4209505f6aa60423c174336bd7b22e0d8beb87a2a99ad50787f1341", size = 113695 }, - { url = "https://files.pythonhosted.org/packages/e4/3f/45a2ed60a32db65cf6d3dcdc3b37c235f44728a1d49d4fc14a16ac1e0a0e/yarl-1.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0675a9cf65176e11692b20a516d5f744849251aa24024f422582d2d1bf7c8c82", size = 443623 }, - { url = "https://files.pythonhosted.org/packages/f2/48/1321e9c514e798c89446b1ff6867e8cc6285ce014a4dac6de68de04fd71a/yarl-1.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:419c22b419034b4ee3ba1c27cbbfef01ca8d646f9292f614f008093143334cdc", size = 469118 }, - { url = "https://files.pythonhosted.org/packages/a0/d4/2e401fc232de4dc566b02e6c19cb043b33ecd249663f6ace3654f484dc4e/yarl-1.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aaf10e525e461f43831d82149d904f35929d89f3ccd65beaf7422aecd500dd39", size = 463210 }, - { url = "https://files.pythonhosted.org/packages/e8/98/cb9082e0270f47678ac155f41fa1f0a1b607181bcb923dacd542595c6520/yarl-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d78ebad57152d301284761b03a708aeac99c946a64ba967d47cbcc040e36688b", size = 447903 }, - { url = "https://files.pythonhosted.org/packages/ad/bd/371a1824a185923b3829cb3f50328012269d86b3a17644e9a0b36e3ea0d5/yarl-1.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e480a12cec58009eeaeee7f48728dc8f629f8e0f280d84957d42c361969d84da", size = 432828 }, - { url = "https://files.pythonhosted.org/packages/28/6a/f5b6cbf40012974cf86c1174d23cae0cadc0bf78ead244222cb5f22f3bec/yarl-1.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e5462756fb34c884ca9d4875b6d2ec80957a767123151c467c97a9b423617048", size = 444771 }, - { url = "https://files.pythonhosted.org/packages/3b/34/370e8ce2763c4d2328ee12a0b0ada01d480ad1f9f6019489cf36dfe98595/yarl-1.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:bff0d468664cdf7b2a6bfd5e17d4a7025edb52df12e0e6e17223387b421d425c", size = 449360 }, - { url = "https://files.pythonhosted.org/packages/06/70/5d565edeb49ea5321a5cdf95824ba61b02802e0e082b9e36f10ef849ac5e/yarl-1.13.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ffd8a9758b5df7401a49d50e76491f4c582cf7350365439563062cdff45bf16", size = 472735 }, - { url = "https://files.pythonhosted.org/packages/82/c1/9b65e5771ddff3838241f6c9b1dcd65620d6a218fad9a4aeb62a99867a16/yarl-1.13.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ca71238af0d247d07747cb7202a9359e6e1d6d9e277041e1ad2d9f36b3a111a6", size = 470916 }, - { url = "https://files.pythonhosted.org/packages/e6/2f/d9f7a79cb1d079d947f1202d778f75063102146c7b06597c168d8dcb063a/yarl-1.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fda4404bbb6f91e327827f4483d52fe24f02f92de91217954cf51b1cb9ee9c41", size = 458737 }, - { url = "https://files.pythonhosted.org/packages/59/d6/08ec9b926e6a6254ed1b6178817193c5a93d43ba01888df037c3470b3973/yarl-1.13.0-cp310-cp310-win32.whl", hash = "sha256:e557e2681b47a0ecfdfbea44743b3184d94d31d5ce0e4b13ff64ce227a40f86e", size = 102349 }, - { url = "https://files.pythonhosted.org/packages/6b/9a/2980744994bbbf3a04c3b487044978a9c174367ca9a81c676ced01f5b12f/yarl-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:3590ed9c7477059aea067a58ec87b433bbd47a2ceb67703b1098cca1ba075f0d", size = 111343 }, - { url = "https://files.pythonhosted.org/packages/d5/78/59418fa1cc0ef69cef153b4e6163b1a3850d129a45b92aad8f9d244ac879/yarl-1.13.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8986fa2be78193dc8b8c27bd0d3667fe612f7232844872714c4200499d5225ca", size = 189559 }, - { url = "https://files.pythonhosted.org/packages/9a/41/af4aa6046a4da16b32768bd788ac331c8397ac264b336ed5695c591f198b/yarl-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0db15ce35dfd100bc9ab40173f143fbea26c84d7458d63206934fe5548fae25d", size = 115451 }, - { url = "https://files.pythonhosted.org/packages/8a/50/1496bff64799e82c06852d5b60d29d9d60d4c4fdebf8f5b1fae505d1a10a/yarl-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:49bee8c99586482a238a7b2ec0ef94e5f186bfdbb8204d14a3dd31867b3875ce", size = 113706 }, - { url = "https://files.pythonhosted.org/packages/ff/17/a68f080c08edb27b8b5a62d418ed9ba1d90242af6792c6c2138180923265/yarl-1.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c73e0f8375b75806b8771890580566a2e6135e6785250840c4f6c45b69eb72d", size = 486063 }, - { url = "https://files.pythonhosted.org/packages/ed/2e/fce9be4ff0df5a112e9007e587acee326ec89b98286fba221e8337e969fe/yarl-1.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ab16c9e94726fdfcbf5b37a641c9d9d0b35cc31f286a2c3b9cad6451cb53b2b", size = 505935 }, - { url = "https://files.pythonhosted.org/packages/f0/c9/a797a46a28c2d68a600ec02c601014cd545a2ca33db34d1b8fc0ae854396/yarl-1.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:784d6e50ea96b3bbb078eb7b40d8c0e3674c2f12da4f0061f889b2cfdbab8f37", size = 500357 }, - { url = "https://files.pythonhosted.org/packages/33/64/9ff1dd2c6acffd739adca70d6b16b059aea2a4ba750c4444aa5d59197e26/yarl-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:580fdb2ea48a40bcaa709ee0dc71f64e7a8f23b44356cc18cd9ce55dc3bc3212", size = 488873 }, - { url = "https://files.pythonhosted.org/packages/5a/d1/a9c696e15311f2b32028f8ff3b447c8334316a6029d30d13f73a081517a4/yarl-1.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9d2845f1a37438a8e11e4fcbbf6ffd64bc94dc9cb8c815f72d0eb6f6c622deb0", size = 471532 }, - { url = "https://files.pythonhosted.org/packages/d2/ca/accf33604a178cf785c71c8cce9956f37638e2e72cea23543fe4adaa9595/yarl-1.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bcb374db7a609484941c01380c1450728ec84d9c3e68cd9a5feaecb52626c4be", size = 485599 }, - { url = "https://files.pythonhosted.org/packages/ff/25/d92abf667d068103b912a56b39d889615a8b07fb8d9ae922cf8fdbe52ede/yarl-1.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:561a5f6c054927cf5a993dd7b032aeebc10644419e65db7dd6bdc0b848806e65", size = 483480 }, - { url = "https://files.pythonhosted.org/packages/ef/94/8a058039537682febfda57fb5d333f8bf7237dad5eab9323f5380325308a/yarl-1.13.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b536c2ac042add7f276d4e5857b08364fc32f28e02add153f6f214de50f12d07", size = 514004 }, - { url = "https://files.pythonhosted.org/packages/8c/ed/8253a619335c6a692f909b6406e1764369733eed5af3991bbb91bf4a3b24/yarl-1.13.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:52b7bb09bb48f7855d574480e2efc0c30d31cab4e6ffc6203e2f7ffbf2e4496a", size = 516259 }, - { url = "https://files.pythonhosted.org/packages/e2/17/e8ec3b51d5d02a768a75d6aee5edb8e303483fe3d0bf1f49c1dacf48fe47/yarl-1.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e4dddf99a853b3f60f3ce6363fb1ad94606113743cf653f116a38edd839a4461", size = 498386 }, - { url = "https://files.pythonhosted.org/packages/df/09/2c631d07df653b53c4880416ceb138e1ed7d079329430ef5bc363f02e8c4/yarl-1.13.0-cp311-cp311-win32.whl", hash = "sha256:0b489858642e4e92203941a8fdeeb6373c0535aa986200b22f84d4b39cd602ba", size = 102394 }, - { url = "https://files.pythonhosted.org/packages/df/a0/362619ab4141c2229eb43fa0a62447b14845a4ea50e362a40fd7c934c4aa/yarl-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:31748bee7078db26008bf94d39693c682a26b5c3a80a67194a4c9c8fe3b5cf47", size = 111636 }, - { url = "https://files.pythonhosted.org/packages/11/21/09da58324fca4a9b6ff5710109bd26217b40dee9e6729adb3786e82831c7/yarl-1.13.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3a9b2650425b2ab9cc68865978963601b3c2414e1d94ef04f193dd5865e1bd79", size = 190212 }, - { url = "https://files.pythonhosted.org/packages/83/9d/3a703336f3b8bbb907ad12bc46fe1f4b795e924b7b923dbcf604212ac3e1/yarl-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:73777f145cd591e1377bf8d8a97e5f8e39c9742ad0f100c898bba1f963aef662", size = 116036 }, - { url = "https://files.pythonhosted.org/packages/42/06/bb53625353041364ba2a8a0ca6bbfe2dafcfeec846d038f44d20746ebc70/yarl-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:144b9e9164f21da81731c970dbda52245b343c0f67f3609d71013dd4d0db9ebf", size = 113890 }, - { url = "https://files.pythonhosted.org/packages/4b/1f/e4c00883ea4debfd34b1c812887f897760c5bdb49de7fc4862df12d2599b/yarl-1.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3628e4e572b1db95285a72c4be102356f2dfc6214d9f126de975fd51b517ae55", size = 483935 }, - { url = "https://files.pythonhosted.org/packages/af/93/7ff1c47e5530d79b2e1dc55a38641b079361c7cf5fa754bc50250ca15445/yarl-1.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0bd3caf554a52da78ec08415ebedeb6b9636436ca2afda9b5b9ff4a533478940", size = 499696 }, - { url = "https://files.pythonhosted.org/packages/ee/ad/a2d9a167460b2043123fc1aeef908131f8d47aa939a0563e4ae44015cb89/yarl-1.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d7a44ae252efb0fcd79ac0997416721a44345f53e5aec4a24f489d983aa00e3", size = 497233 }, - { url = "https://files.pythonhosted.org/packages/5f/6f/0fc937394438542790290416a69bd26e4c91d5cb16d2288ef03518f5ec81/yarl-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24b78a1f57780eeeb17f5e1be851ab9fa951b98811e1bb4b5a53f74eec3e2666", size = 490132 }, - { url = "https://files.pythonhosted.org/packages/e5/d0/a7b4e6af60aba824cc8528e870fc41bb84f59fa5e6eecde5fb9908d1793c/yarl-1.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79de5f8432b53d1261d92761f71dfab5fc7e1c75faa12a3535c27e681dacfa9d", size = 469328 }, - { url = "https://files.pythonhosted.org/packages/db/95/3ed41ceaf3bc6ce48eacc0313054223436b4cf66c825f92d5cb806a1b37f/yarl-1.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f603216d62e9680bfac7fb168ef9673fd98abbb50c43e73d97615dfa1afebf57", size = 485630 }, - { url = "https://files.pythonhosted.org/packages/37/9c/b0ae1c3253c9b910abd5c20d4ee4f15b547fea61eaef6ae9f5b9e1b7bf0d/yarl-1.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:acf27399c94270103d68f86118a183008d601e4c2c3a7e98dcde0e3b0163132f", size = 485996 }, - { url = "https://files.pythonhosted.org/packages/7e/23/51879b22108fa24ea5b9f96a225016f73a8b148bdd70adad510a5536abe5/yarl-1.13.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:08037790f973367431b9406a7b9d940e872cca12e081bce3b7cea068daf81f0a", size = 506685 }, - { url = "https://files.pythonhosted.org/packages/f5/ed/2e3034d7adb7fdeb6b64789d3e92408a45db5ca31e707dd2114758e8f7d9/yarl-1.13.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:33e2f5ef965e69a1f2a1b0071a70c4616157da5a5478f3c2f6e185e06c56a322", size = 516992 }, - { url = "https://files.pythonhosted.org/packages/1d/7d/b091b5b444d522f80a5cd54208cf20a99aa7450a3218afc83f6f4a1001ca/yarl-1.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:38a3b742c923fe2cab7d2e2c67220d17da8d0433e8bfe038356167e697ef5524", size = 502349 }, - { url = "https://files.pythonhosted.org/packages/99/ce/e4e14c485086be114ddfdc11b192190a6f0d680d26d66929da42ca51b5bd/yarl-1.13.0-cp312-cp312-win32.whl", hash = "sha256:ab3ee57b25ce15f79ade27b7dfb5e678af26e4b93be5a4e22655acd9d40b81ba", size = 102301 }, - { url = "https://files.pythonhosted.org/packages/72/2f/d5657e841b51e1855a752cb6cea5c7e266e2e61d8784e8bf6d241ae38b39/yarl-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:26214b0a9b8f4b7b04e67eee94a82c9b4e5c721f4d1ce7e8c87c78f0809b7684", size = 111676 }, - { url = "https://files.pythonhosted.org/packages/3c/9d/62e0325479f6e225c006db065b81d92a214e15dbd9d5f08b7f58cbea2a1d/yarl-1.13.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:91251614cca1ba4ab0507f1ba5f5a44e17a5e9a4c7f0308ea441a994bdac3fc7", size = 186212 }, - { url = "https://files.pythonhosted.org/packages/33/e6/216ca46bb456cc6942f0098abb67b192c52733292d37cb4f230889c8c826/yarl-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fe6946c3cbcfbed67c5e50dae49baff82ad054aaa10ff7a4db8dfac646b7b479", size = 114218 }, - { url = "https://files.pythonhosted.org/packages/c4/9d/1e937ba8820129effa4fcb8d7188e990711d73f6eaff0888a9205e33cecd/yarl-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de97ee57e00a82ebb8c378fc73c5d9a773e4c2cec8079ff34ebfef61c8ba5b11", size = 112118 }, - { url = "https://files.pythonhosted.org/packages/62/19/9f60d2c8bfd9820708268c4466e4d52d64b6ecec26557a26d9a7c3d60991/yarl-1.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1129737da2291c9952a93c015e73583dd66054f3ae991c8674f6e39c46d95dd3", size = 471387 }, - { url = "https://files.pythonhosted.org/packages/4c/a9/d6936a780b35a202a9eb93905d283da4243fcfca85f464571d7ce6f5759e/yarl-1.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37049eb26d637a5b2f00562f65aad679f5d231c4c044edcd88320542ad66a2d9", size = 485837 }, - { url = "https://files.pythonhosted.org/packages/f1/62/1903cb89c2b069c985fb0577a152652b80a8700b6f96a72c2b127c00cac3/yarl-1.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08d15aff3477fecb7a469d1fdf5939a686fbc5a16858022897d3e9fc99301f19", size = 486662 }, - { url = "https://files.pythonhosted.org/packages/ea/70/17a1092eec93b9b2ca2fe7e9c854b52f968a5457a16c0192cb1684f666e9/yarl-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa187a8599e0425f26b25987d884a8b67deb5565f1c450c3a6e8d3de2cdc8715", size = 478867 }, - { url = "https://files.pythonhosted.org/packages/3e/ab/20d8b6ff384b126e2aca1546b8ba93e4a4aee35cfa68043b8015cf2fb309/yarl-1.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d95fcc9508390db73a0f1c7e78d9a1b1a3532a3f34ceff97c0b3b04140fbe6e4", size = 456455 }, - { url = "https://files.pythonhosted.org/packages/6a/31/66bebe242af5f0615b2a6f7ae9ac37633983c621eb333367830500f8f954/yarl-1.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d04ea92a3643a9bb28aa6954fff718342caab2cc3d25d0160fe16e26c4a9acb7", size = 474964 }, - { url = "https://files.pythonhosted.org/packages/69/02/67d94189a94d191edf47b8a34721e0e7265556e821e9bb2f856da7f8af39/yarl-1.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2842a89b697d8ca3dda6a25b4e4d835d14afe25a315c8a79dbdf5f70edfd0960", size = 477474 }, - { url = "https://files.pythonhosted.org/packages/60/33/a746b05fedc340e8055d38b3f892418577252b1dbd6be474faebe1ceb9f3/yarl-1.13.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db463fce425f935eee04a9182c74fdf9ed90d3bd2079d4a17f8fb7a2d7c11009", size = 491950 }, - { url = "https://files.pythonhosted.org/packages/5b/75/9759d92dc66108264f305f1ddb3ae02bcc247849a6673ebb678a082d398e/yarl-1.13.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3ff602aa84420b301c083ae7f07df858ae8e371bf3be294397bda3e0b27c6290", size = 502141 }, - { url = "https://files.pythonhosted.org/packages/e8/8d/4d9f6fa810eca7e07ae7bc6eea0136a4268a32439e6ce6e7454470c51dac/yarl-1.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a9a1a600e8449f3a24bc7dca513be8d69db173fe842e8332a7318b5b8757a6af", size = 492846 }, - { url = "https://files.pythonhosted.org/packages/77/b1/da12907ccb4cea4781357ec027e81e141251726aeffa6ea2c8d1f62cc117/yarl-1.13.0-cp313-cp313-win32.whl", hash = "sha256:5540b4896b244a6539f22b613b32b5d1b737e08011aa4ed56644cb0519d687df", size = 486417 }, - { url = "https://files.pythonhosted.org/packages/66/9e/05d133e7523035517e0dc912a59779dcfd5e978aff32c1c2a3cbc1fd4e7c/yarl-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:08a3b0b8d10092dade46424fe775f2c9bc32e5a985fdd6afe410fe28598db6b2", size = 493756 }, - { url = "https://files.pythonhosted.org/packages/47/44/3b6725635d226feb5e0263716a05186657afb548e7ef7ac1e11c0e255b9a/yarl-1.13.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:92abbe37e3fb08935e0e95ac5f83f7b286a6f2575f542225ec7afde405ed1fa1", size = 192323 }, - { url = "https://files.pythonhosted.org/packages/82/d7/d03fef722c92b07f9b91832a55e7fa624ec8dbc1700f146168461d0be425/yarl-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1932c7bfa537f89ad5ca3d1e7e05de3388bb9e893230a384159fb974f6e9f90c", size = 117070 }, - { url = "https://files.pythonhosted.org/packages/4a/52/19eef59dde0dc4e6bc73376dca047757cd6b39872b9fba690fbf33dc1fc2/yarl-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4483680e129b2a4250be20947b554cd5f7140fa9e5a1e4f1f42717cf91f8676a", size = 114997 }, - { url = "https://files.pythonhosted.org/packages/c7/f0/830618cabed258962ca81325f41216cb99ece2297d5c1744b5f3f7acfbea/yarl-1.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f6f4a352d0beea5dd39315ab16fc26f0122d13457a7e65ad4f06c7961dcf87a", size = 450259 }, - { url = "https://files.pythonhosted.org/packages/70/30/18c2a2ec41d2af39e99b7589b019622d2c4abf1f7e44fff5455ebcf6925f/yarl-1.13.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a67f20e97462dee8a89e9b997a78932959d2ed991e8f709514cb4160143e7b1", size = 477472 }, - { url = "https://files.pythonhosted.org/packages/11/cf/ce66ab9a022d824592e387b63d18b3fc19ba31731187201ae4b4ef2e199c/yarl-1.13.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf4f3a87bd52f8f33b0155cd0f6f22bdf2092d88c6c6acbb1aee3bc206ecbe35", size = 470167 }, - { url = "https://files.pythonhosted.org/packages/bd/7c/a7c60205831a8bb3dcafe350650936e69c01b45575c8d971e835a10ae585/yarl-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:deb70c006be548076d628630aad9a3ef3a1b2c28aaa14b395cf0939b9124252e", size = 455050 }, - { url = "https://files.pythonhosted.org/packages/0b/88/b5d1013311f2f0552b7186033fa02eb14501c87f55c042f15b0267382f0c/yarl-1.13.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bf7a9b31729b97985d4a796808859dfd0e37b55f1ca948d46a568e56e51dd8fb", size = 439771 }, - { url = "https://files.pythonhosted.org/packages/c3/f8/83ea7ef4f67ab9f930440bca4f9ea84f3f616876d3589df3e546b3fc2a14/yarl-1.13.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d807417ceebafb7ce18085a1205d28e8fcb1435a43197d7aa3fab98f5bfec5ef", size = 452000 }, - { url = "https://files.pythonhosted.org/packages/23/f2/dc0d49343fa4d0bedd06df4f6665c8438e49108ba3b443d1e245d779750b/yarl-1.13.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9671d0d65f86e0a0eee59c5b05e381c44e3d15c36c2a67da247d5d82875b4e4e", size = 455543 }, - { url = "https://files.pythonhosted.org/packages/06/40/b9dc6e2da5ff8c07470b4f8643589c9d55381900c317f4a1dfc67e269a47/yarl-1.13.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:13a9cd39e47ca4dc25139d3c63fe0dc6acf1b24f9d94d3b5197ac578fbfd84bf", size = 481106 }, - { url = "https://files.pythonhosted.org/packages/90/27/6e6c709ee54374494b8580c26ca3daac6039ba6c7f1b12214d99cf16fabc/yarl-1.13.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:acf8c219a59df22609cfaff4a7158a0946f273e3b03a5385f1fdd502496f0cff", size = 478299 }, - { url = "https://files.pythonhosted.org/packages/da/f3/62bdf7d9fbddbf13cc99c1d941b1687d2890034160cbf76dc6579ab62416/yarl-1.13.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:12c92576633027f297c26e52aba89f6363b460f483d85cf7c14216eb55d06d02", size = 466354 }, - { url = "https://files.pythonhosted.org/packages/42/e2/56a217a01e2ea0f963fe4a4af68c9af745a090d999704a677a5fe9f5403e/yarl-1.13.0-cp39-cp39-win32.whl", hash = "sha256:c2518660bd8166e770b76ce92514b491b8720ae7e7f5f975cd888b1592894d2c", size = 103369 }, - { url = "https://files.pythonhosted.org/packages/46/91/e3ea08a1770f7ba9822e391f1561d6505c4a7e313c3ea8ec65f26182ab93/yarl-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:db90702060b1cdb7c7609d04df5f68a12fd5581d013ad379e58e0c2e651d92b8", size = 112502 }, - { url = "https://files.pythonhosted.org/packages/10/ae/c3c059042053b92ae25363818901d0634708a3a85048e5ac835bd547107e/yarl-1.13.0-py3-none-any.whl", hash = "sha256:c7d35ff2a5a51bc6d40112cdb4ca3fd9636482ce8c6ceeeee2301e34f7ed7556", size = 39813 }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/fe/2ca2e5ef45952f3e8adb95659821a4e9169d8bbafab97eb662602ee12834/yarl-1.14.0.tar.gz", hash = "sha256:88c7d9d58aab0724b979ab5617330acb1c7030b79379c8138c1c8c94e121d1b3", size = 166127 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/27/dc4f4eabb51cf82f3ba8f8d977fba0e06006d66cee907ea12982c4c85904/yarl-1.14.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1bfc25aa6a7c99cf86564210f79a0b7d4484159c67e01232b116e445b3036547", size = 135762 }, + { url = "https://files.pythonhosted.org/packages/e7/32/e524d6c4b3acd05c88a5454cb3221b74bf7460b593deccf88f3b27361200/yarl-1.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0cf21f46a15d445417de8fc89f2568852cf57fe8ca1ab3d19ddb24d45c0383ae", size = 87946 }, + { url = "https://files.pythonhosted.org/packages/7f/ae/42c5fe1ae66eade3f17e442e5adce36b0d098867d5bd98e08527ff026d52/yarl-1.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1dda53508df0de87b6e6b0a52d6718ff6c62a5aca8f5552748404963df639269", size = 85854 }, + { url = "https://files.pythonhosted.org/packages/57/21/d653108b654daec3b9359a27f61959cf020839f97248bd345bf1ec7f1490/yarl-1.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:587c3cc59bc148a9b1c07a019346eda2549bc9f468acd2f9824d185749acf0a6", size = 306502 }, + { url = "https://files.pythonhosted.org/packages/8f/0b/996f04d9de5523735661a90ead48ea21d7557e1a71b1f757d1b2e3baae17/yarl-1.14.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3007a5b75cb50140708420fe688c393e71139324df599434633019314ceb8b59", size = 320849 }, + { url = "https://files.pythonhosted.org/packages/7b/10/b720945c7cd294283f8809dd0407e4cd56218949a4cca3ff04995cae6f0a/yarl-1.14.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06ff23462398333c78b6f4f8d3d70410d657a471c2c5bbe6086133be43fc8f1a", size = 318727 }, + { url = "https://files.pythonhosted.org/packages/d3/3a/0c65820d2d73649d99970e1c150e4be6c057a624cb545613ce75c3ebe2a6/yarl-1.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:689a99a42ee4583fcb0d3a67a0204664aa1539684aed72bdafcbd505197a91c4", size = 309599 }, + { url = "https://files.pythonhosted.org/packages/43/01/00f44df69b99e23790096aba5e16651694b8de087af12418578dc00730bd/yarl-1.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0547ab1e9345dc468cac8368d88ea4c5bd473ebc1d8d755347d7401982b5dd8", size = 299716 }, + { url = "https://files.pythonhosted.org/packages/41/1e/9c9e06f53d91f0b5ac6e69162e92d0fdd0851d4cc360f08716e29201802a/yarl-1.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:742aef0a99844faaac200564ea6f5e08facb285d37ea18bd1a5acf2771f3255a", size = 306355 }, + { url = "https://files.pythonhosted.org/packages/65/43/db5da311d287691cc02a4f66be8ac5859bce9627d51f8d553fc4f2beb601/yarl-1.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:176110bff341b6730f64a1eb3a7070e12b373cf1c910a9337e7c3240497db76f", size = 310309 }, + { url = "https://files.pythonhosted.org/packages/47/0c/271fdc45a5c2d13f9d138b039a264e35283a4ead36e7a538aefce4050d5e/yarl-1.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46a9772a1efa93f9cd170ad33101c1817c77e0e9914d4fe33e2da299d7cf0f9b", size = 325571 }, + { url = "https://files.pythonhosted.org/packages/64/7f/bde078ab75deba8387d260f387864b0f549fcdb8d5bee0d9b30406b1b7fe/yarl-1.14.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ee2c68e4f2dd1b1c15b849ba1c96fac105fca6ffdb7c1e8be51da6fabbdeafb9", size = 323477 }, + { url = "https://files.pythonhosted.org/packages/bb/f3/9fcf03b8826893275d2b46360986b2afba131e74eb6d722574b34b479144/yarl-1.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:047b258e00b99091b6f90355521f026238c63bd76dcf996d93527bb13320eefd", size = 316299 }, + { url = "https://files.pythonhosted.org/packages/22/77/b3d0410dfeb0bd779d6013afc1609ba17bff4d15479cab72cc16b11af4fa/yarl-1.14.0-cp310-cp310-win32.whl", hash = "sha256:0aa92e3e30a04f9462a25077db689c4ac5ea9ab6cc68a2e563881b987d42f16d", size = 77408 }, + { url = "https://files.pythonhosted.org/packages/92/69/29f5c9399d705254b2095bf117d7fb758f80057ad359b4e3224aa711b966/yarl-1.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:d9baec588f015d0ee564057aa7574313c53a530662ffad930b7886becc85abdf", size = 83511 }, + { url = "https://files.pythonhosted.org/packages/92/aa/64fcae3d4a081e4ee07902e9e9a3b597c2577283bf6c5b59c06ef0829d90/yarl-1.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:07f9eaf57719d6721ab15805d85f4b01a5b509a0868d7320134371bcb652152d", size = 135761 }, + { url = "https://files.pythonhosted.org/packages/93/a0/5537a1da2c0ec8e11006efa0d133cdaded5ebb94ca71e87e3564b59f6c7f/yarl-1.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c14b504a74e58e2deb0378b3eca10f3d076635c100f45b113c18c770b4a47a50", size = 87888 }, + { url = "https://files.pythonhosted.org/packages/e3/25/1d12bec8ebdc8287a3464f506ded23b30ad75a5fea3ba49526e8b473057f/yarl-1.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a682a127930f3fc4e42583becca6049e1d7214bcad23520c590edd741d2114", size = 85883 }, + { url = "https://files.pythonhosted.org/packages/75/85/01c2eb9a6ed755e073ef7d455151edf0ddd89618fca7d653894f7580b538/yarl-1.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73bedd2be05f48af19f0f2e9e1353921ce0c83f4a1c9e8556ecdcf1f1eae4892", size = 333347 }, + { url = "https://files.pythonhosted.org/packages/38/c7/6c3634ef216f01f928d7eec7b7de5bde56658292c8cbdcd29cc28d830f4d/yarl-1.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3ab950f8814f3b7b5e3eebc117986f817ec933676f68f0a6c5b2137dd7c9c69", size = 346644 }, + { url = "https://files.pythonhosted.org/packages/f4/ce/d1b1c441e41c652ce8081299db4f9b856f25a04b9c1885b3ba2e6edd3102/yarl-1.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b693c63e7e64b524f54aa4888403c680342d1ad0d97be1707c531584d6aeeb4f", size = 344078 }, + { url = "https://files.pythonhosted.org/packages/f0/ec/520686b83b51127792ca507d67ae1090c919c8cb8388c78d1e7c63c98a4a/yarl-1.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85cb3e40eaa98489f1e2e8b29f5ad02ee1ee40d6ce6b88d50cf0f205de1d9d2c", size = 336398 }, + { url = "https://files.pythonhosted.org/packages/30/4d/e842066d3336203299a3dc1730f2d062061e7b8a4497f4b6977d9076d263/yarl-1.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f24f08b6c9b9818fd80612c97857d28f9779f0d1211653ece9844fc7b414df2", size = 325519 }, + { url = "https://files.pythonhosted.org/packages/46/c7/83b9c0e5717ddd99b203dbb61c56450f475ab4a7d4d6b61b4af0a03c54d9/yarl-1.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:29a84a46ec3ebae7a1c024c055612b11e9363a8a23238b3e905552d77a2bc51b", size = 335487 }, + { url = "https://files.pythonhosted.org/packages/5e/58/2c5f0c840ab3bb364ebe5a6233bfe77ed9fcef6b34c19f3809dd15dae972/yarl-1.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5cd5dad8366e0168e0fd23d10705a603790484a6dbb9eb272b33673b8f2cce72", size = 334259 }, + { url = "https://files.pythonhosted.org/packages/6a/6b/95d7a85b5a20d90ffd42a174ff52772f6d046d60b85e4cd506e0baa58341/yarl-1.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a152751af7ef7b5d5fa6d215756e508dd05eb07d0cf2ba51f3e740076aa74373", size = 355310 }, + { url = "https://files.pythonhosted.org/packages/77/14/dd4cc5fe69b8d0708f3c43a2b8c8cca5364f2205e220908ba79be202f61c/yarl-1.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3d569f877ed9a708e4c71a2d13d2940cb0791da309f70bd970ac1a5c088a0a92", size = 356970 }, + { url = "https://files.pythonhosted.org/packages/1a/5e/aa5c615abbc6366c787f7abf5af2ffefd5ebe1ffc381850065624e5072fe/yarl-1.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a615cad11ec3428020fb3c5a88d85ce1b5c69fd66e9fcb91a7daa5e855325dd", size = 344806 }, + { url = "https://files.pythonhosted.org/packages/f3/10/7b9d14b5165d7f3a7b6f474cafab6993fe7a76a908a7f02d34099e915c74/yarl-1.14.0-cp311-cp311-win32.whl", hash = "sha256:bab03192091681d54e8225c53f270b0517637915d9297028409a2a5114ff4634", size = 77527 }, + { url = "https://files.pythonhosted.org/packages/ae/bb/277d3d6d44882614cbbe108474d33c0d0ffe1ea6760e710b4237147840a2/yarl-1.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:985623575e5c4ea763056ffe0e2d63836f771a8c294b3de06d09480538316b13", size = 83765 }, + { url = "https://files.pythonhosted.org/packages/9a/3e/8c8bcb19d6a61a7e91cf9209e2c7349572125496e4d4de205dcad5b11753/yarl-1.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fc2c80bc87fba076e6cbb926216c27fba274dae7100a7b9a0983b53132dd99f2", size = 136002 }, + { url = "https://files.pythonhosted.org/packages/34/07/23fe08dfc56651ec1d77643b4df5ad41d4a1fc4f24fd066b182c660620f9/yarl-1.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:55c144d363ad4626ca744556c049c94e2b95096041ac87098bb363dcc8635e8d", size = 88223 }, + { url = "https://files.pythonhosted.org/packages/f2/dc/daa1b58bb858f3ce32ca9aaeb6011d7535af01d5c0f5e6b52aa698c608e3/yarl-1.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b03384eed107dbeb5f625a99dc3a7de8be04fc8480c9ad42fccbc73434170b20", size = 85967 }, + { url = "https://files.pythonhosted.org/packages/6e/05/7461a7005bd2e969746a3f5218b876a414e4b2d9929b797afd157cd27c29/yarl-1.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f72a0d746d38cb299b79ce3d4d60ba0892c84bbc905d0d49c13df5bace1b65f8", size = 325031 }, + { url = "https://files.pythonhosted.org/packages/15/c2/54a710b97e14f99d36f82e574c8749b93ad881df120ed791fdcd1f2e1989/yarl-1.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8648180b34faaea4aa5b5ca7e871d9eb1277033fa439693855cf0ea9195f85f1", size = 334314 }, + { url = "https://files.pythonhosted.org/packages/60/24/6015e5a365ef6cab2d00058895cea37fe796936f04266de83b434f9a9a2e/yarl-1.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9557c9322aaa33174d285b0c1961fb32499d65ad1866155b7845edc876c3c835", size = 333516 }, + { url = "https://files.pythonhosted.org/packages/3d/4d/9a369945088ac7141dc9ca2fae6a10bd205f0ea8a925996ec465d3afddcd/yarl-1.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f50eb3837012a937a2b649ec872b66ba9541ad9d6f103ddcafb8231cfcafd22", size = 329437 }, + { url = "https://files.pythonhosted.org/packages/b1/38/a71b7a7a8a95d3727075472ab4b88e2d0f3223b649bcb233f6022c42593d/yarl-1.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8892fa575ac9b1b25fae7b221bc4792a273877b9b56a99ee2d8d03eeb3dbb1d2", size = 316742 }, + { url = "https://files.pythonhosted.org/packages/02/e7/b3baf612d964b4abd492594a51e75ba5cd08243a834cbc21e1013c8ac229/yarl-1.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6a2c5c5bb2556dfbfffffc2bcfb9c235fd2b566d5006dfb2a37afc7e3278a07", size = 330168 }, + { url = "https://files.pythonhosted.org/packages/1a/a0/896eb6007cc54347f4097e8c2f31e3907de262ced9c3f56866d8dd79a8ff/yarl-1.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ab3abc0b78a5dfaa4795a6afbe7b282b6aa88d81cf8c1bb5e394993d7cae3457", size = 331898 }, + { url = "https://files.pythonhosted.org/packages/1a/73/94ee96a0e8518c7efee84e745567770371add4af65466c38d3646df86f1f/yarl-1.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:47eede5d11d669ab3759b63afb70d28d5328c14744b8edba3323e27dc52d298d", size = 343316 }, + { url = "https://files.pythonhosted.org/packages/68/6e/4cf1b32b3605fa4ce263ea338852e89e9959affaffb38eb1a7057d0a95f1/yarl-1.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fe4d2536c827f508348d7b40c08767e8c7071614250927233bf0c92170451c0a", size = 351596 }, + { url = "https://files.pythonhosted.org/packages/16/e7/1ec09b0977e3a4a0a80e319aa30359bd4f8beb543527d8ddf9a2e799541e/yarl-1.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0fd7b941dd1b00b5f0acb97455fea2c4b7aac2dd31ea43fb9d155e9bc7b78664", size = 343016 }, + { url = "https://files.pythonhosted.org/packages/de/d0/a2502a37555251c7e10df51eb425f1892f3b2acb6fa598348b96f74f3566/yarl-1.14.0-cp312-cp312-win32.whl", hash = "sha256:99ff3744f5fe48288be6bc402533b38e89749623a43208e1d57091fc96b783b9", size = 77322 }, + { url = "https://files.pythonhosted.org/packages/c0/1f/201f46e02dd074ff36ce7cd764bb8241a19f94ba88adfd6d410cededca13/yarl-1.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:1ca3894e9e9f72da93544f64988d9c052254a338a9f855165f37f51edb6591de", size = 83589 }, + { url = "https://files.pythonhosted.org/packages/f0/cf/ade2a0f0acdbfb7ca1843045a8d1691edcde4caf2dc8995c4b6dd1c6968c/yarl-1.14.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5d02d700705d67e09e1f57681f758f0b9d4412eeb70b2eb8d96ca6200b486db3", size = 134274 }, + { url = "https://files.pythonhosted.org/packages/76/c8/a9e17ac8d81bcd1dc9eca197b25c46b10317092e92ac772094ab3edf57ac/yarl-1.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:30600ba5db60f7c0820ef38a2568bb7379e1418ecc947a0f76fd8b2ff4257a97", size = 87396 }, + { url = "https://files.pythonhosted.org/packages/3f/a8/ab76e6ede9fdb5087df39e7b1c92d08eb6e58e7c4a0a3b2b6112a74cb4af/yarl-1.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e85d86527baebb41a214cc3b45c17177177d900a2ad5783dbe6f291642d4906f", size = 85240 }, + { url = "https://files.pythonhosted.org/packages/f2/1e/809b44e498c67e86c889b919d155ef6978bfabdf7d7e458922ba8f5e67be/yarl-1.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37001e5d4621cef710c8dc1429ca04e189e572f128ab12312eab4e04cf007132", size = 324884 }, + { url = "https://files.pythonhosted.org/packages/b3/88/a4385930e0653ddea4234cbca161882d7de2aa963ca6f3962a1c77dddaad/yarl-1.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f4f4547944d4f5cfcdc03f3f097d6f05bbbc915eaaf80a2ee120d0e756de377d", size = 334245 }, + { url = "https://files.pythonhosted.org/packages/21/fb/6fc8d66bc24f5913427bc8a0a4c2529bc0763ccf855062d70c21e5eb51b6/yarl-1.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75ff4c819757f9bdb35de049a509814d6ce851fe26f06eb95a392a5640052482", size = 335989 }, + { url = "https://files.pythonhosted.org/packages/74/bf/2c493c45589e98833ec8c4e3c5fff8d30f875513bc207361ac822459cb69/yarl-1.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68ac1a09392ed6e3fd14be880d39b951d7b981fd135416db7d18a6208c536561", size = 330270 }, + { url = "https://files.pythonhosted.org/packages/01/ce/1cb0ee93ef3ec827a2d0287936696f68b1743c6f4540251f61cb76d51b63/yarl-1.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96952f642ac69075e44c7d0284528938fdff39422a1d90d3e45ce40b72e5e2d9", size = 316668 }, + { url = "https://files.pythonhosted.org/packages/de/e5/edfdcf4f569eb14cb1e86a451e64ae7052e058147890ab43ecfe06c9272f/yarl-1.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a56fbe3d7f3bce1d060ea18d2413a2ca9ca814eea7cedc4d247b5f338d54844e", size = 331048 }, + { url = "https://files.pythonhosted.org/packages/e6/0a/eeea8057a19f38f07af826954c5199a19ac229823097a0a2f8346c2d9b00/yarl-1.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e2637d75e92763d1322cb5041573279ec43a80c0f7fbbd2d64f5aee98447b17", size = 335671 }, + { url = "https://files.pythonhosted.org/packages/fd/c8/7e727938615a50cf413d00ea4e80872e43778d3cb36b2ff05a55ba43addf/yarl-1.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9abe80ae2c9d37c17599557b712e6515f4100a80efb2cda15f5f070306477cd2", size = 342064 }, + { url = "https://files.pythonhosted.org/packages/38/84/5fdf90939f35bac0e3e34f43dbdb6ff2f2d4bc151885a9a4b50fd4a62d6d/yarl-1.14.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:217a782020b875538eebf3948fac3a7f9bbbd0fd9bf8538f7c2ad7489e80f4e8", size = 350695 }, + { url = "https://files.pythonhosted.org/packages/b3/c1/a27587f7178e41b0f047b83b49104fb6043f4e0a0141d4c156c6cf0a978a/yarl-1.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9cfef3f14f75bf6aba73a76caf61f9d00865912a04a4393c468a7ce0981b519", size = 345151 }, + { url = "https://files.pythonhosted.org/packages/0d/04/394d0d757055b7e8b60d7eb1f9647f200399e6ec57c8a2efc842f49d8487/yarl-1.14.0-cp313-cp313-win32.whl", hash = "sha256:d8361c7d04e6a264481f0b802e395f647cd3f8bbe27acfa7c12049efea675bd1", size = 301897 }, + { url = "https://files.pythonhosted.org/packages/b4/14/63cebb6261f49c9b3db6b20e7c4eb6131524e41f4cd402225e0a3e2bf479/yarl-1.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:bc24f968b82455f336b79bf37dbb243b7d76cd40897489888d663d4e028f5069", size = 307546 }, + { url = "https://files.pythonhosted.org/packages/0c/a3/26de988fdfd23c0cc11db8ef32713a68fc11288faf0c1a7d39d6900837f9/yarl-1.14.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a9f917966d27f7ce30039fe8d900f913c5304134096554fd9bea0774bcda6d1", size = 137284 }, + { url = "https://files.pythonhosted.org/packages/de/6d/3caf3268330f1f3493f72e54c3bd706f457a9f9e19a3a93a253109955ae2/yarl-1.14.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a2f8fb7f944bcdfecd4e8d855f84c703804a594da5123dd206f75036e536d4d", size = 88892 }, + { url = "https://files.pythonhosted.org/packages/6f/08/b076af938b119a8746935ff664f5962886b119b8f24605fb31e034203061/yarl-1.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f4e475f29a9122f908d0f1f706e1f2fc3656536ffd21014ff8a6f2e1b14d1d8", size = 86617 }, + { url = "https://files.pythonhosted.org/packages/f3/1f/bc8895af9eaaa8ec5bb5dd72e1d672d53bdf072f429ca6967a41e612c6ea/yarl-1.14.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8089d4634d8fa2b1806ce44fefa4979b1ab2c12c0bc7ef3dfa45c8a374811348", size = 309736 }, + { url = "https://files.pythonhosted.org/packages/77/57/eef67848041467dfc343c8859251bb14a052eba1be9254faab1a04aea2bf/yarl-1.14.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b16f6c75cffc2dc0616ea295abb0e1967601bd1fb1e0af6a1de1c6c887f3439", size = 324631 }, + { url = "https://files.pythonhosted.org/packages/ed/5d/37fc667ac93d65350a076d96cbe3a80c39d24b4649b5c13d5a7f07c73767/yarl-1.14.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:498b3c55087b9d762636bca9b45f60d37e51d24341786dc01b81253f9552a607", size = 321074 }, + { url = "https://files.pythonhosted.org/packages/8b/ec/55da48680d84f8cfbcccba5e4b5e3e71b888f98d2106ed39fd6918542b30/yarl-1.14.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f8bfc1db82589ef965ed234b87de30d140db8b6dc50ada9e33951ccd8ec07a", size = 313425 }, + { url = "https://files.pythonhosted.org/packages/68/26/f02dd8979668cff2b27a291793d6214c16374fc886a72b7622683b18d921/yarl-1.14.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:625f207b1799e95e7c823f42f473c1e9dbfb6192bd56bba8695656d92be4535f", size = 303490 }, + { url = "https://files.pythonhosted.org/packages/55/ce/c98510780eb6610a9ff97717b06f27a61d6b8a6a0feb56cebb4b160fe06d/yarl-1.14.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:781e2495e408a81e4eaeedeb41ba32b63b1980dddf8b60dbbeff6036bcd35049", size = 309587 }, + { url = "https://files.pythonhosted.org/packages/dd/45/8f22993a7d52488a8bcaeddd21d9dbff3bc6be24aad59e0208873ce524d9/yarl-1.14.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:659603d26d40dd4463200df9bfbc339fbfaed3fe32e5c432fe1dc2b5d4aa94b4", size = 312604 }, + { url = "https://files.pythonhosted.org/packages/59/59/6074e5b66b7b8a8253a6073ffe8ca1bce7a2cd32b4b0698b70ba5251fa41/yarl-1.14.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4e0d45ebf975634468682c8bec021618b3ad52c37619e5c938f8f831fa1ac5c0", size = 329420 }, + { url = "https://files.pythonhosted.org/packages/56/64/76c4a24f4bc8176ac692561b2d435a91784e2b2f728cdd978acf1c604a8d/yarl-1.14.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a2e4725a08cb2b4794db09e350c86dee18202bb8286527210e13a1514dc9a59a", size = 326432 }, + { url = "https://files.pythonhosted.org/packages/61/97/552bf0c24a8bf69743e39bb39b59bef5d40b791affc7cff14e421f765d76/yarl-1.14.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:19268b4fec1d7760134f2de46ef2608c2920134fb1fa61e451f679e41356dc55", size = 320087 }, + { url = "https://files.pythonhosted.org/packages/39/69/2834aaa3b99679d57ff069a5103ebe5bf563f3991da6cb31d1bc224c236e/yarl-1.14.0-cp39-cp39-win32.whl", hash = "sha256:337912bcdcf193ade64b9aae5a4017a0a1950caf8ca140362e361543c6773f21", size = 77960 }, + { url = "https://files.pythonhosted.org/packages/71/9c/da4b110d19b44bc5545b9c76387dd529e27fd9025ff8384ff0261b98bf28/yarl-1.14.0-cp39-cp39-win_amd64.whl", hash = "sha256:b6d0147574ce2e7b812c989e50fa72bbc5338045411a836bd066ce5fc8ac0bce", size = 84091 }, + { url = "https://files.pythonhosted.org/packages/fd/37/6c30afb708ab45f3da32229c77d9a25dfc8ead2ae3ec1f1ea9425172d070/yarl-1.14.0-py3-none-any.whl", hash = "sha256:c8ed4034f0765f8861620c1f2f2364d2e58520ea288497084dae880424fc0d9f", size = 38166 }, ] [[package]] From 7fd8c14c1f373acd5f98a080ece2bf8004edb5d6 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 15 Oct 2024 08:59:25 +0100 Subject: [PATCH 595/892] Create common Time module and add time set cli command (#1157) --- kasa/cli/common.py | 2 + kasa/cli/time.py | 123 +++++++++++++++++++++++++++--- kasa/device.py | 2 +- kasa/interfaces/__init__.py | 2 + kasa/interfaces/time.py | 26 +++++++ kasa/iot/iotbulb.py | 2 +- kasa/iot/iotdevice.py | 21 ++--- kasa/iot/iotplug.py | 2 +- kasa/iot/iotstrip.py | 2 +- kasa/iot/iottimezone.py | 58 ++++++++++---- kasa/iot/modules/time.py | 31 +++++++- kasa/module.py | 3 +- kasa/smart/modules/time.py | 37 +++++---- kasa/tests/fakeprotocol_iot.py | 22 +++++- kasa/tests/test_cli.py | 50 ++++++++++-- kasa/tests/test_common_modules.py | 25 ++++++ kasa/tests/test_device.py | 7 +- kasa/tests/test_iotdevice.py | 2 +- 18 files changed, 349 insertions(+), 68 deletions(-) create mode 100644 kasa/interfaces/time.py diff --git a/kasa/cli/common.py b/kasa/cli/common.py index 1977d0c83..fbd6291bd 100644 --- a/kasa/cli/common.py +++ b/kasa/cli/common.py @@ -201,6 +201,8 @@ def _handle_exception(debug, exc): # Handle exit request from click. if isinstance(exc, click.exceptions.Exit): sys.exit(exc.exit_code) + if isinstance(exc, click.exceptions.Abort): + sys.exit(0) echo(f"Raised error: {exc}") if debug: diff --git a/kasa/cli/time.py b/kasa/cli/time.py index c66812222..904da2cad 100644 --- a/kasa/cli/time.py +++ b/kasa/cli/time.py @@ -5,15 +5,18 @@ from datetime import datetime import asyncclick as click +import zoneinfo from kasa import ( Device, Module, ) -from kasa.smart import SmartDevice +from kasa.iot import IotDevice +from kasa.iot.iottimezone import get_matching_timezones from .common import ( echo, + error, pass_dev, ) @@ -31,25 +34,127 @@ async def time(ctx: click.Context): async def time_get(dev: Device): """Get the device time.""" res = dev.time - echo(f"Current time: {res}") + echo(f"Current time: {dev.time} ({dev.timezone})") return res @time.command(name="sync") +@click.option( + "--timezone", + type=str, + required=False, + default=None, + help="IANA timezone name, will use current device timezone if not provided.", +) +@click.option( + "--skip-confirm", + type=str, + required=False, + default=False, + is_flag=True, + help="Do not ask to confirm the timezone if an exact match is not found.", +) @pass_dev -async def time_sync(dev: Device): +async def time_sync(dev: Device, timezone: str | None, skip_confirm: bool): """Set the device time to current time.""" - if not isinstance(dev, SmartDevice): - raise NotImplementedError("setting time currently only implemented on smart") + if (time := dev.modules.get(Module.Time)) is None: + echo("Device does not have time module") + return + + now = datetime.now() + + tzinfo: zoneinfo.ZoneInfo | None = None + if timezone: + tzinfo = await _get_timezone(dev, timezone, skip_confirm) + if tzinfo.utcoffset(now) != now.astimezone().utcoffset(): + error( + f"{timezone} has a different utc offset to local time," + + "syncing will produce unexpected results." + ) + now = now.replace(tzinfo=tzinfo) + + echo(f"Old time: {time.time} ({time.timezone})") + + await time.set_time(now) + + await dev.update() + echo(f"New time: {time.time} ({time.timezone})") + +@time.command(name="set") +@click.argument("year", type=int) +@click.argument("month", type=int) +@click.argument("day", type=int) +@click.argument("hour", type=int) +@click.argument("minute", type=int) +@click.argument("seconds", type=int, required=False, default=0) +@click.option( + "--timezone", + type=str, + required=False, + default=None, + help="IANA timezone name, will use current device timezone if not provided.", +) +@click.option( + "--skip-confirm", + type=bool, + required=False, + default=False, + is_flag=True, + help="Do not ask to confirm the timezone if an exact match is not found.", +) +@pass_dev +async def time_set( + dev: Device, + year: int, + month: int, + day: int, + hour: int, + minute: int, + seconds: int, + timezone: str | None, + skip_confirm: bool, +): + """Set the device time to the provided time.""" if (time := dev.modules.get(Module.Time)) is None: echo("Device does not have time module") return - echo("Old time: %s" % time.time) + tzinfo: zoneinfo.ZoneInfo | None = None + if timezone: + tzinfo = await _get_timezone(dev, timezone, skip_confirm) - local_tz = datetime.now().astimezone().tzinfo - await time.set_time(datetime.now(tz=local_tz)) + echo(f"Old time: {time.time} ({time.timezone})") + + await time.set_time(datetime(year, month, day, hour, minute, seconds, 0, tzinfo)) await dev.update() - echo("New time: %s" % time.time) + echo(f"New time: {time.time} ({time.timezone})") + + +async def _get_timezone(dev, timezone, skip_confirm) -> zoneinfo.ZoneInfo: + """Get the tzinfo from the timezone or return none.""" + tzinfo: zoneinfo.ZoneInfo | None = None + + if timezone not in zoneinfo.available_timezones(): + error(f"{timezone} is not a valid IANA timezone.") + + tzinfo = zoneinfo.ZoneInfo(timezone) + if skip_confirm is False and isinstance(dev, IotDevice): + matches = await get_matching_timezones(tzinfo) + if not matches: + error(f"Device cannot support {timezone} timezone.") + first = matches[0] + msg = ( + f"An exact match for {timezone} could not be found, " + + f"timezone will be set to {first}" + ) + if len(matches) == 1: + click.confirm(msg, abort=True) + else: + msg = ( + f"Supported timezones matching {timezone} are {', '.join(matches)}\n" + + msg + ) + click.confirm(msg, abort=True) + return tzinfo diff --git a/kasa/device.py b/kasa/device.py index d44ca2b8b..5df1751c5 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -51,7 +51,7 @@ schedule usage anti_theft -time +Time cloud Led diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index 6a12bc681..c83e56c77 100644 --- a/kasa/interfaces/__init__.py +++ b/kasa/interfaces/__init__.py @@ -6,6 +6,7 @@ from .light import Light, LightState from .lighteffect import LightEffect from .lightpreset import LightPreset +from .time import Time __all__ = [ "Fan", @@ -15,4 +16,5 @@ "LightEffect", "LightState", "LightPreset", + "Time", ] diff --git a/kasa/interfaces/time.py b/kasa/interfaces/time.py new file mode 100644 index 000000000..2659b3b3d --- /dev/null +++ b/kasa/interfaces/time.py @@ -0,0 +1,26 @@ +"""Module for time interface.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import datetime, tzinfo + +from ..module import Module + + +class Time(Module, ABC): + """Base class for tplink time module.""" + + @property + @abstractmethod + def time(self) -> datetime: + """Return timezone aware current device time.""" + + @property + @abstractmethod + def timezone(self) -> tzinfo: + """Return current timezone.""" + + @abstractmethod + async def set_time(self, dt: datetime) -> dict: + """Set the device time.""" diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 5775b611f..7e00bebc8 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -219,7 +219,7 @@ async def _initialize_modules(self): self.add_module( Module.IotAntitheft, Antitheft(self, "smartlife.iot.common.anti_theft") ) - self.add_module(Module.IotTime, Time(self, "smartlife.iot.common.timesetting")) + self.add_module(Module.Time, Time(self, "smartlife.iot.common.timesetting")) self.add_module(Module.Energy, Emeter(self, self.emeter_type)) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud")) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 94e72df61..84c4ff818 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -20,6 +20,7 @@ from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, tzinfo from typing import TYPE_CHECKING, Any, cast +from warnings import warn from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig @@ -460,27 +461,27 @@ async def set_alias(self, alias: str) -> None: @requires_update def time(self) -> datetime: """Return current time from the device.""" - return self.modules[Module.IotTime].time + return self.modules[Module.Time].time @property @requires_update def timezone(self) -> tzinfo: """Return the current timezone.""" - return self.modules[Module.IotTime].timezone + return self.modules[Module.Time].timezone - async def get_time(self) -> datetime | None: + async def get_time(self) -> datetime: """Return current time from the device, if available.""" - _LOGGER.warning( - "Use `time` property instead, this call will be removed in the future." - ) - return await self.modules[Module.IotTime].get_time() + msg = "Use `time` property instead, this call will be removed in the future." + warn(msg, DeprecationWarning, stacklevel=1) + return self.time - async def get_timezone(self) -> dict: + async def get_timezone(self) -> tzinfo: """Return timezone information.""" - _LOGGER.warning( + msg = ( "Use `timezone` property instead, this call will be removed in the future." ) - return await self.modules[Module.IotTime].get_timezone() + warn(msg, DeprecationWarning, stacklevel=1) + return self.timezone @property # type: ignore @requires_update diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index a083faac8..89cfef958 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -60,7 +60,7 @@ async def _initialize_modules(self): self.add_module(Module.IotSchedule, Schedule(self, "schedule")) self.add_module(Module.IotUsage, Usage(self, "schedule")) self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) - self.add_module(Module.IotTime, Time(self, "time")) + self.add_module(Module.Time, Time(self, "time")) self.add_module(Module.IotCloud, Cloud(self, "cnCloud")) self.add_module(Module.Led, Led(self, "system")) diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 466997049..a18f27565 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -105,7 +105,7 @@ async def _initialize_modules(self): self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) self.add_module(Module.IotSchedule, Schedule(self, "schedule")) self.add_module(Module.IotUsage, Usage(self, "schedule")) - self.add_module(Module.IotTime, Time(self, "time")) + self.add_module(Module.Time, Time(self, "time")) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) self.add_module(Module.Led, Led(self, "system")) self.add_module(Module.IotCloud, Cloud(self, "cnCloud")) diff --git a/kasa/iot/iottimezone.py b/kasa/iot/iottimezone.py index ccbed3e74..ddeef0753 100644 --- a/kasa/iot/iottimezone.py +++ b/kasa/iot/iottimezone.py @@ -3,7 +3,10 @@ from __future__ import annotations import logging -from datetime import datetime, tzinfo +from datetime import datetime, timedelta, tzinfo +from typing import cast + +from zoneinfo import ZoneInfo from ..cachedzoneinfo import CachedZoneInfo @@ -22,26 +25,53 @@ async def get_timezone(index: int) -> tzinfo: return await CachedZoneInfo.get_cached_zone_info(name) -async def get_timezone_index(name: str) -> int: +async def get_timezone_index(tzone: tzinfo) -> int: """Return the iot firmware index for a valid IANA timezone key.""" - rev = {val: key for key, val in TIMEZONE_INDEX.items()} - if name in rev: - return rev[name] + if isinstance(tzone, ZoneInfo): + name = tzone.key + rev = {val: key for key, val in TIMEZONE_INDEX.items()} + if name in rev: + return rev[name] - # Try to find a supported timezone matching dst true/false - zone = await CachedZoneInfo.get_cached_zone_info(name) - now = datetime.now() - winter = datetime(now.year, 1, 1, 12) - summer = datetime(now.year, 7, 1, 12) for i in range(110): - configured_zone = await get_timezone(i) - if zone.utcoffset(winter) == configured_zone.utcoffset( - winter - ) and zone.utcoffset(summer) == configured_zone.utcoffset(summer): + if _is_same_timezone(tzone, await get_timezone(i)): return i raise ValueError("Device does not support timezone %s", name) +async def get_matching_timezones(tzone: tzinfo) -> list[str]: + """Return the iot firmware index for a valid IANA timezone key.""" + matches = [] + if isinstance(tzone, ZoneInfo): + name = tzone.key + vals = {val for val in TIMEZONE_INDEX.values()} + if name in vals: + matches.append(name) + + for i in range(110): + fw_tz = await get_timezone(i) + if _is_same_timezone(tzone, fw_tz): + match_key = cast(ZoneInfo, fw_tz).key + if match_key not in matches: + matches.append(match_key) + return matches + + +def _is_same_timezone(tzone1: tzinfo, tzone2: tzinfo) -> bool: + """Return true if the timezones have the same utcffset and dst offset. + + Iot devices only support a limited static list of IANA timezones; this is used to + check if a static timezone matches the same utc offset and dst settings. + """ + now = datetime.now() + start_day = datetime(now.year, 1, 1, 12) + for i in range(365): + the_day = start_day + timedelta(days=i) + if tzone1.utcoffset(the_day) != tzone2.utcoffset(the_day): + return False + return True + + TIMEZONE_INDEX = { 0: "Etc/GMT+12", 1: "Pacific/Samoa", diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py index 997a5b4d7..8c672d210 100644 --- a/kasa/iot/modules/time.py +++ b/kasa/iot/modules/time.py @@ -5,11 +5,12 @@ from datetime import datetime, timezone, tzinfo from ...exceptions import KasaException +from ...interfaces import Time as TimeInterface from ..iotmodule import IotModule, merge -from ..iottimezone import get_timezone +from ..iottimezone import get_timezone, get_timezone_index -class Time(IotModule): +class Time(IotModule, TimeInterface): """Implements the timezone settings.""" _timezone: tzinfo = timezone.utc @@ -57,10 +58,36 @@ async def get_time(self): res["hour"], res["min"], res["sec"], + tzinfo=self.timezone, ) except KasaException: return None + async def set_time(self, dt: datetime) -> dict: + """Set the device time.""" + params = { + "year": dt.year, + "month": dt.month, + "mday": dt.day, + "hour": dt.hour, + "min": dt.minute, + "sec": dt.second, + } + if dt.tzinfo: + index = await get_timezone_index(dt.tzinfo) + current_index = self.data.get("get_timezone", {}).get("index", -1) + if current_index != -1 and current_index != index: + params["index"] = index + method = "set_timezone" + else: + method = "set_time" + else: + method = "set_time" + try: + return await self.call(method, params) + except Exception as ex: + raise KasaException(ex) from ex + async def get_timezone(self): """Request timezone information from the device.""" return await self.call("get_timezone") diff --git a/kasa/module.py b/kasa/module.py index 68f5170d2..2c6014e55 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -77,6 +77,7 @@ class Module(ABC): Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") LightPreset: Final[ModuleName[interfaces.LightPreset]] = ModuleName("LightPreset") + Time: Final[ModuleName[interfaces.Time]] = ModuleName("Time") # IOT only Modules IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") @@ -86,7 +87,6 @@ class Module(ABC): IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule") IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage") IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud") - IotTime: Final[ModuleName[iot.Time]] = ModuleName("time") # SMART only Modules Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm") @@ -123,7 +123,6 @@ class Module(ABC): TemperatureControl: Final[ModuleName[smart.TemperatureControl]] = ModuleName( "TemperatureControl" ) - Time: Final[ModuleName[smart.Time]] = ModuleName("Time") WaterleakSensor: Final[ModuleName[smart.WaterleakSensor]] = ModuleName( "WaterleakSensor" ) diff --git a/kasa/smart/modules/time.py b/kasa/smart/modules/time.py index 21dd13a40..c182b8af5 100644 --- a/kasa/smart/modules/time.py +++ b/kasa/smart/modules/time.py @@ -3,17 +3,17 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone, tzinfo -from time import mktime from typing import cast -from zoneinfo import ZoneInfoNotFoundError +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from ...cachedzoneinfo import CachedZoneInfo from ...feature import Feature +from ...interfaces import Time as TimeInterface from ..smartmodule import SmartModule -class Time(SmartModule): +class Time(SmartModule, TimeInterface): """Implementation of device_local_time.""" REQUIRED_COMPONENT = "time" @@ -63,16 +63,23 @@ def time(self) -> datetime: tz=self.timezone, ) - async def set_time(self, dt: datetime): + async def set_time(self, dt: datetime) -> dict: """Set device time.""" - unixtime = mktime(dt.timetuple()) - offset = cast(timedelta, dt.utcoffset()) - diff = offset / timedelta(minutes=1) - return await self.call( - "set_device_time", - { - "timestamp": int(unixtime), - "time_diff": int(diff), - "region": dt.tzname(), - }, - ) + if not dt.tzinfo: + timestamp = dt.replace(tzinfo=self.timezone).timestamp() + utc_offset = cast(timedelta, self.timezone.utcoffset(dt)) + else: + timestamp = dt.timestamp() + utc_offset = cast(timedelta, dt.utcoffset()) + time_diff = utc_offset / timedelta(minutes=1) + + params: dict[str, int | str] = { + "timestamp": int(timestamp), + "time_diff": int(time_diff), + } + if tz := dt.tzinfo: + region = tz.key if isinstance(tz, ZoneInfo) else dt.tzname() + # tzname can return null if a simple timezone object is provided. + if region: + params["region"] = region + return await self.call("set_device_time", params) diff --git a/kasa/tests/fakeprotocol_iot.py b/kasa/tests/fakeprotocol_iot.py index 635f488d7..36f532359 100644 --- a/kasa/tests/fakeprotocol_iot.py +++ b/kasa/tests/fakeprotocol_iot.py @@ -118,7 +118,6 @@ def success(res): "index": 12, "tz_str": "test2", }, - "set_timezone": None, } CLOUD_MODULE = { @@ -353,6 +352,19 @@ def light_state(self, x, *args): else: return light_state + def set_time(self, new_state: dict, *args): + """Implement set_time.""" + mods = [ + v + for k, v in self.proto.items() + if k in {"time", "smartlife.iot.common.timesetting"} + ] + index = new_state.pop("index", None) + for mod in mods: + mod["get_time"] = new_state + if index is not None: + mod["get_timezone"]["index"] = index + baseproto = { "system": { "set_relay_state": set_relay_state, @@ -391,8 +403,12 @@ def light_state(self, x, *args): "smartlife.iot.common.system": { "set_dev_alias": set_alias, }, - "time": TIME_MODULE, - "smartlife.iot.common.timesetting": TIME_MODULE, + "time": {**TIME_MODULE, "set_time": set_time, "set_timezone": set_time}, + "smartlife.iot.common.timesetting": { + **TIME_MODULE, + "set_time": set_time, + "set_timezone": set_time, + }, # HS220 brightness, different setter and getter "smartlife.iot.dimmer": { "set_brightness": set_hs220_brightness, diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 289dcd232..e439644b4 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -1,11 +1,13 @@ import json import os import re +from datetime import datetime import asyncclick as click import pytest from asyncclick.testing import CliRunner from pytest_mock import MockerFixture +from zoneinfo import ZoneInfo from kasa import ( AuthenticationError, @@ -308,12 +310,8 @@ async def test_time_get(dev, runner): assert "Current time: " in res.output -@device_smart async def test_time_sync(dev, mocker, runner): - """Test time sync command. - - Currently implemented only for SMART. - """ + """Test time sync command.""" update = mocker.patch.object(dev, "update") set_time_mock = mocker.spy(dev.modules[Module.Time], "set_time") res = await runner.invoke( @@ -329,6 +327,48 @@ async def test_time_sync(dev, mocker, runner): assert "New time: " in res.output +async def test_time_set(dev: Device, mocker, runner): + """Test time set command.""" + time_mod = dev.modules[Module.Time] + set_time_mock = mocker.spy(time_mod, "set_time") + dt = datetime(2024, 10, 15, 8, 15) + res = await runner.invoke( + time, + ["set", str(dt.year), str(dt.month), str(dt.day), str(dt.hour), str(dt.minute)], + obj=dev, + ) + set_time_mock.assert_called() + assert time_mod.time == dt.replace(tzinfo=time_mod.timezone) + + assert res.exit_code == 0 + assert "Old time: " in res.output + assert "New time: " in res.output + + zone = ZoneInfo("Europe/Berlin") + dt = dt.replace(tzinfo=zone) + res = await runner.invoke( + time, + [ + "set", + str(dt.year), + str(dt.month), + str(dt.day), + str(dt.hour), + str(dt.minute), + "--timezone", + zone.key, + ], + input="y\n", + obj=dev, + ) + + assert time_mod.time == dt + + assert res.exit_code == 0 + assert "Old time: " in res.output + assert "New time: " in res.output + + async def test_emeter(dev: Device, mocker, runner): res = await runner.invoke(emeter, obj=dev) if not dev.has_emeter: diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index 6cefa99d2..1096260e7 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -1,5 +1,9 @@ +from datetime import datetime + import pytest +from freezegun.api import FrozenDateTimeFactory from pytest_mock import MockerFixture +from zoneinfo import ZoneInfo from kasa import Device, LightState, Module from kasa.tests.device_fixtures import ( @@ -319,3 +323,24 @@ async def test_light_preset_save(dev: Device, mocker: MockerFixture): assert new_preset_state.hue == new_preset.hue assert new_preset_state.saturation == new_preset.saturation assert new_preset_state.color_temp == new_preset.color_temp + + +async def test_set_time(dev: Device, freezer: FrozenDateTimeFactory): + """Test setting the device time.""" + freezer.move_to("2021-01-09 12:00:00+00:00") + time_mod = dev.modules[Module.Time] + tz_info = time_mod.timezone + now = datetime.now(tz=tz_info) + now = now.replace(microsecond=0) + assert time_mod.time != now + + await time_mod.set_time(now) + await dev.update() + assert time_mod.time == now + + zone = ZoneInfo("Europe/Berlin") + now = datetime.now(tz=zone) + now = now.replace(microsecond=0) + await time_mod.set_time(now) + await dev.update() + assert time_mod.time == now diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index 4b851d260..2b9d970a4 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -321,13 +321,14 @@ async def test_device_timezones(): # Get an index from a timezone for index, zone in TIMEZONE_INDEX.items(): - found_index = await get_timezone_index(zone) + zone_info = zoneinfo.ZoneInfo(zone) + found_index = await get_timezone_index(zone_info) assert found_index == index # Try a timezone not hardcoded finds another match - index = await get_timezone_index("Asia/Katmandu") + index = await get_timezone_index(zoneinfo.ZoneInfo("Asia/Katmandu")) assert index == 77 # Try a timezone not hardcoded no match with pytest.raises(zoneinfo.ZoneInfoNotFoundError): - await get_timezone_index("Foo/bar") + await get_timezone_index(zoneinfo.ZoneInfo("Foo/bar")) diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py index 55565bcc2..dd401ac99 100644 --- a/kasa/tests/test_iotdevice.py +++ b/kasa/tests/test_iotdevice.py @@ -184,7 +184,7 @@ async def test_time(dev): @device_iot async def test_timezone(dev): - TZ_SCHEMA(await dev.get_timezone()) + TZ_SCHEMA(await dev.modules[Module.Time].get_timezone()) @device_iot From 380fbb93c33274c4fcd28ea61725cdfadefb961b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 16 Oct 2024 15:28:27 +0100 Subject: [PATCH 596/892] Enable newer encrypted discovery protocol (#1168) --- kasa/aestransport.py | 86 +++++++++++++++--------- kasa/cli/discover.py | 54 ++++++++++----- kasa/cli/main.py | 15 ++--- kasa/discover.py | 112 ++++++++++++++++++++++++++++++-- kasa/tests/test_aestransport.py | 4 +- kasa/tests/test_cli.py | 5 +- kasa/tests/test_discovery.py | 52 +++++++++++++-- 7 files changed, 258 insertions(+), 70 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 0048bd122..ae75117c2 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -14,7 +14,7 @@ from enum import Enum, auto from typing import TYPE_CHECKING, Any, Dict, cast -from cryptography.hazmat.primitives import padding, serialization +from cryptography.hazmat.primitives import hashes, padding, serialization from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -108,7 +108,9 @@ def __init__( self._key_pair: KeyPair | None = None if config.aes_keys: aes_keys = config.aes_keys - self._key_pair = KeyPair(aes_keys["private"], aes_keys["public"]) + self._key_pair = KeyPair.create_from_der_keys( + aes_keys["private"], aes_keys["public"] + ) self._app_url = URL(f"http://{self._host}:{self._port}/app") self._token_url: URL | None = None @@ -277,14 +279,14 @@ async def _generate_key_pair_payload(self) -> AsyncGenerator: if not self._key_pair: kp = KeyPair.create_key_pair() self._config.aes_keys = { - "private": kp.get_private_key(), - "public": kp.get_public_key(), + "private": kp.private_key_der_b64, + "public": kp.public_key_der_b64, } self._key_pair = kp pub_key = ( "-----BEGIN PUBLIC KEY-----\n" - + self._key_pair.get_public_key() # type: ignore[union-attr] + + self._key_pair.public_key_der_b64 # type: ignore[union-attr] + "\n-----END PUBLIC KEY-----\n" ) handshake_params = {"key": pub_key} @@ -392,18 +394,11 @@ class AesEncyptionSession: """Class for an AES encryption session.""" @staticmethod - def create_from_keypair(handshake_key: str, keypair): + def create_from_keypair(handshake_key: str, keypair: KeyPair): """Create the encryption session.""" - handshake_key_bytes: bytes = base64.b64decode(handshake_key.encode("UTF-8")) - private_key_data = base64.b64decode(keypair.get_private_key().encode("UTF-8")) + handshake_key_bytes: bytes = base64.b64decode(handshake_key.encode()) - private_key = cast( - rsa.RSAPrivateKey, - serialization.load_der_private_key(private_key_data, None, None), - ) - key_and_iv = private_key.decrypt( - handshake_key_bytes, asymmetric_padding.PKCS1v15() - ) + key_and_iv = keypair.decrypt_handshake_key(handshake_key_bytes) if key_and_iv is None: raise ValueError("Decryption failed!") @@ -438,30 +433,59 @@ def create_key_pair(key_size: int = 1024): """Create a key pair.""" private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size) public_key = private_key.public_key() + return KeyPair(private_key, public_key) + + @staticmethod + def create_from_der_keys(private_key_der_b64: str, public_key_der_b64: str): + """Create a key pair.""" + key_bytes = base64.b64decode(private_key_der_b64.encode()) + private_key = cast( + rsa.RSAPrivateKey, serialization.load_der_private_key(key_bytes, None) + ) + key_bytes = base64.b64decode(public_key_der_b64.encode()) + public_key = cast( + rsa.RSAPublicKey, serialization.load_der_public_key(key_bytes, None) + ) - private_key_bytes = private_key.private_bytes( + return KeyPair(private_key, public_key) + + def __init__(self, private_key: rsa.RSAPrivateKey, public_key: rsa.RSAPublicKey): + self.private_key = private_key + self.public_key = public_key + self.private_key_der_bytes = self.private_key.private_bytes( encoding=serialization.Encoding.DER, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ) - public_key_bytes = public_key.public_bytes( + self.public_key_der_bytes = self.public_key.public_bytes( encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) + self.private_key_der_b64 = base64.b64encode(self.private_key_der_bytes).decode() + self.public_key_der_b64 = base64.b64encode(self.public_key_der_bytes).decode() - return KeyPair( - private_key=base64.b64encode(private_key_bytes).decode("UTF-8"), - public_key=base64.b64encode(public_key_bytes).decode("UTF-8"), + def get_public_pem(self) -> bytes: + """Get public key in PEM encoding.""" + return self.public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, ) - def __init__(self, private_key: str, public_key: str): - self.private_key = private_key - self.public_key = public_key - - def get_private_key(self) -> str: - """Get the private key.""" - return self.private_key - - def get_public_key(self) -> str: - """Get the public key.""" - return self.public_key + def decrypt_handshake_key(self, encrypted_key: bytes) -> bytes: + """Decrypt an aes handshake key.""" + decrypted = self.private_key.decrypt( + encrypted_key, asymmetric_padding.PKCS1v15() + ) + return decrypted + + def decrypt_discovery_key(self, encrypted_key: bytes) -> bytes: + """Decrypt an aes discovery key.""" + decrypted = self.private_key.decrypt( + encrypted_key, + asymmetric_padding.OAEP( + mgf=asymmetric_padding.MGF1(algorithm=hashes.SHA1()), # noqa: S303 + algorithm=hashes.SHA1(), # noqa: S303 + label=None, + ), + ) + return decrypted diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index 6bf58e725..78f426f5d 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from pprint import pformat as pf import asyncclick as click from pydantic.v1 import ValidationError @@ -28,6 +29,7 @@ async def discover(ctx): password = ctx.parent.params["password"] discovery_timeout = ctx.parent.params["discovery_timeout"] timeout = ctx.parent.params["timeout"] + host = ctx.parent.params["host"] port = ctx.parent.params["port"] credentials = Credentials(username, password) if username and password else None @@ -49,8 +51,6 @@ async def print_unsupported(unsupported_exception: UnsupportedDeviceError): echo(f"\t{unsupported_exception}") echo() - echo(f"Discovering devices on {target} for {discovery_timeout} seconds") - from .device import state async def print_discovered(dev: Device): @@ -68,6 +68,18 @@ async def print_discovered(dev: Device): discovered[dev.host] = dev.internal_state echo() + if host: + echo(f"Discovering device {host} for {discovery_timeout} seconds") + return await Discover.discover_single( + host, + port=port, + credentials=credentials, + timeout=timeout, + discovery_timeout=discovery_timeout, + on_unsupported=print_unsupported, + ) + + echo(f"Discovering devices on {target} for {discovery_timeout} seconds") discovered_devices = await Discover.discover( target=target, discovery_timeout=discovery_timeout, @@ -113,21 +125,31 @@ def _echo_discovery_info(discovery_info): _echo_dictionary(discovery_info) return + def _conditional_echo(label, value): + if value: + ws = " " * (19 - len(label)) + echo(f"\t{label}:{ws}{value}") + 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"\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}") + _conditional_echo("Device Type", dr.device_type) + _conditional_echo("Device Model", dr.device_model) + _conditional_echo("Device Name", dr.device_name) + _conditional_echo("IP", dr.ip) + _conditional_echo("MAC", dr.mac) + _conditional_echo("Device Id (hash)", dr.device_id) + _conditional_echo("Owner (hash)", dr.owner) + _conditional_echo("FW Ver", dr.firmware_version) + _conditional_echo("HW Ver", dr.hw_ver) + _conditional_echo("HW Ver", dr.hardware_version) + _conditional_echo("Supports IOT Cloud", dr.is_support_iot_cloud) + _conditional_echo("OBD Src", dr.owner) + _conditional_echo("Factory Default", dr.factory_default) + _conditional_echo("Encrypt Type", dr.mgt_encrypt_schm.encrypt_type) + _conditional_echo("Encrypt Type", dr.encrypt_type) + _conditional_echo("Supports HTTPS", dr.mgt_encrypt_schm.is_support_https) + _conditional_echo("HTTP Port", dr.mgt_encrypt_schm.http_port) + _conditional_echo("Encrypt info", pf(dr.encrypt_info) if dr.encrypt_info else None) + _conditional_echo("Decrypted", pf(dr.decrypted_data) if dr.decrypted_data else None) async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3): diff --git a/kasa/cli/main.py b/kasa/cli/main.py index 88b768c41..1550b7af3 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -158,6 +158,7 @@ def _legacy_type_to_class(_type): type=click.Choice(ENCRYPT_TYPES, case_sensitive=False), ) @click.option( + "-df", "--device-family", envvar="KASA_DEVICE_FAMILY", default="SMART.TAPOPLUG", @@ -182,7 +183,7 @@ def _legacy_type_to_class(_type): @click.option( "--discovery-timeout", envvar="KASA_DISCOVERY_TIMEOUT", - default=5, + default=10, required=False, show_default=True, help="Timeout for discovery.", @@ -326,15 +327,11 @@ async def cli( dev = await Device.connect(config=config) device_updated = True else: - from kasa.discover import Discover + from .discover import discover - dev = await Discover.discover_single( - host, - port=port, - credentials=credentials, - timeout=timeout, - discovery_timeout=discovery_timeout, - ) + dev = await ctx.invoke(discover) + if not dev: + error(f"Unable to create device for {host}") # Skip update on specific commands, or if device factory, # that performs an update was used for the device. diff --git a/kasa/discover.py b/kasa/discover.py index a1bc28a31..9d615398c 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -82,13 +82,16 @@ from __future__ import annotations import asyncio +import base64 import binascii import ipaddress import logging +import secrets import socket +import struct from collections.abc import Awaitable from pprint import pformat as pf -from typing import Any, Callable, Dict, Optional, Type, cast +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, cast # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -96,6 +99,7 @@ from pydantic.v1 import BaseModel, ValidationError from kasa import Device +from kasa.aestransport import AesEncyptionSession, KeyPair from kasa.credentials import Credentials from kasa.device_factory import ( get_device_class_from_family, @@ -133,6 +137,46 @@ } +class _AesDiscoveryQuery: + keypair: KeyPair | None = None + + @classmethod + def generate_query(cls): + if not cls.keypair: + cls.keypair = KeyPair.create_key_pair(key_size=2048) + secret = secrets.token_bytes(4) + + key_payload = {"params": {"rsa_key": cls.keypair.get_public_pem().decode()}} + + key_payload_bytes = json_dumps(key_payload).encode() + # https://labs.withsecure.com/advisories/tp-link-ac1750-pwn2own-2019 + version = 2 # version of tdp + msg_type = 0 + op_code = 1 # probe + msg_size = len(key_payload_bytes) + flags = 17 + padding_byte = 0 # blank byte + device_serial = int.from_bytes(secret, "big") + initial_crc = 0x5A6B7C8D + + disco_header = struct.pack( + ">BBHHBBII", + version, + msg_type, + op_code, + msg_size, + flags, + padding_byte, + device_serial, + initial_crc, + ) + + query = bytearray(disco_header + key_payload_bytes) + crc = binascii.crc32(query).to_bytes(length=4, byteorder="big") + query[12:16] = crc + return query + + class _DiscoverProtocol(asyncio.DatagramProtocol): """Implementation of the discovery protocol handler. @@ -224,15 +268,21 @@ async def do_discover(self) -> None: _LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY) encrypted_req = XorEncryption.encrypt(req) sleep_between_packets = self.discovery_timeout / self.discovery_packets + + aes_discovery_query = _AesDiscoveryQuery.generate_query() for _ 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 + self.transport.sendto(aes_discovery_query, self.target_2) # type: ignore await asyncio.sleep(sleep_between_packets) def datagram_received(self, data, addr) -> None: """Handle discovery responses.""" + if TYPE_CHECKING: + assert _AesDiscoveryQuery.keypair + ip, port = addr # Prevent multiple entries due multiple broadcasts if ip in self.seen_hosts: @@ -395,7 +445,8 @@ async def discover_single( credentials: Credentials | None = None, username: str | None = None, password: str | None = None, - ) -> Device: + on_unsupported: OnUnsupportedCallable | None = None, + ) -> Device | None: """Discover a single device by the given IP address. It is generally preferred to avoid :func:`discover_single()` and @@ -465,7 +516,11 @@ async def discover_single( dev.host = host return dev elif ip in protocol.unsupported_device_exceptions: - raise protocol.unsupported_device_exceptions[ip] + if on_unsupported: + await on_unsupported(protocol.unsupported_device_exceptions[ip]) + return None + else: + raise protocol.unsupported_device_exceptions[ip] elif ip in protocol.invalid_device_exceptions: raise protocol.invalid_device_exceptions[ip] else: @@ -512,6 +567,25 @@ def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice: device.update_from_discover_info(info) return device + @staticmethod + def _decrypt_discovery_data(discovery_result: DiscoveryResult) -> None: + if TYPE_CHECKING: + assert discovery_result.encrypt_info + assert _AesDiscoveryQuery.keypair + encryped_key = discovery_result.encrypt_info.key + encrypted_data = discovery_result.encrypt_info.data + + key_and_iv = _AesDiscoveryQuery.keypair.decrypt_discovery_key( + base64.b64decode(encryped_key.encode()) + ) + + key, iv = key_and_iv[:16], key_and_iv[16:] + + session = AesEncyptionSession(key, iv) + decrypted_data = session.decrypt(encrypted_data) + + discovery_result.decrypted_data = json_loads(decrypted_data) + @staticmethod def _get_device_instance( data: bytes, @@ -528,6 +602,8 @@ def _get_device_instance( ) from ex try: discovery_result = DiscoveryResult(**info["result"]) + if discovery_result.encrypt_info: + Discover._decrypt_discovery_data(discovery_result) except ValidationError as ex: if debug_enabled: data = ( @@ -547,9 +623,19 @@ def _get_device_instance( type_ = discovery_result.device_type try: + if not ( + encrypt_type := discovery_result.mgt_encrypt_schm.encrypt_type + ) and (encrypt_info := discovery_result.encrypt_info): + encrypt_type = encrypt_info.sym_schm + if not encrypt_type: + raise UnsupportedDeviceError( + f"Unsupported device {config.host} of type {type_} " + + "with no encryption type", + discovery_result=discovery_result.get_dict(), + ) config.connection_type = DeviceConnectionParameters.from_values( type_, - discovery_result.mgt_encrypt_schm.encrypt_type, + encrypt_type, discovery_result.mgt_encrypt_schm.lv, ) except KasaException as ex: @@ -593,21 +679,35 @@ class EncryptionScheme(BaseModel): """Base model for encryption scheme of discovery result.""" is_support_https: bool - encrypt_type: str - http_port: int + encrypt_type: Optional[str] # noqa: UP007 + http_port: Optional[int] = None # noqa: UP007 lv: Optional[int] = None # noqa: UP007 +class EncryptionInfo(BaseModel): + """Base model for encryption info of discovery result.""" + + sym_schm: str + key: str + data: str + + class DiscoveryResult(BaseModel): """Base model for discovery result.""" device_type: str device_model: str + device_name: Optional[str] # noqa: UP007 ip: str mac: str mgt_encrypt_schm: EncryptionScheme + encrypt_info: Optional[EncryptionInfo] = None # noqa: UP007 + encrypt_type: Optional[list[str]] = None # noqa: UP007 + decrypted_data: Optional[dict] = None # noqa: UP007 device_id: str + firmware_version: Optional[str] = None # noqa: UP007 + hardware_version: Optional[str] = None # noqa: UP007 hw_ver: Optional[str] = None # noqa: UP007 owner: Optional[str] = None # noqa: UP007 is_support_iot_cloud: Optional[bool] = None # noqa: UP007 diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 53d838581..f1dbfb320 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -99,8 +99,8 @@ async def test_handshake_with_keys(mocker): assert transport._state is TransportState.HANDSHAKE_REQUIRED await transport.perform_handshake() - assert transport._key_pair.get_private_key() == test_keys["private"] - assert transport._key_pair.get_public_key() == test_keys["public"] + assert transport._key_pair.private_key_der_b64 == test_keys["private"] + assert transport._key_pair.public_key_der_b64 == test_keys["public"] @status_parameters diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index e439644b4..553f93d37 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -2,6 +2,7 @@ import os import re from datetime import datetime +from unittest.mock import ANY import asyncclick as click import pytest @@ -17,7 +18,6 @@ EmeterStatus, KasaException, Module, - UnsupportedDeviceError, ) from kasa.cli.device import ( alias, @@ -613,6 +613,7 @@ async def test_without_device_type(dev, mocker, runner): credentials=Credentials("foo", "bar"), timeout=5, discovery_timeout=7, + on_unsupported=ANY, ) @@ -735,7 +736,7 @@ async def test_host_unsupported(unsupported_device_info, runner): ) assert res.exit_code != 0 - assert isinstance(res.exception, UnsupportedDeviceError) + assert "== Unsupported device ==" in res.output @new_discovery diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 15d4af9c5..8163d4c1e 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -2,6 +2,8 @@ # ruff: noqa: S106 import asyncio +import base64 +import json import logging import re import socket @@ -10,6 +12,8 @@ import aiohttp import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from async_timeout import timeout as asyncio_timeout +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding from kasa import ( Credentials, @@ -18,11 +22,17 @@ Discover, KasaException, ) +from kasa.aestransport import AesEncyptionSession from kasa.deviceconfig import ( DeviceConfig, DeviceConnectionParameters, ) -from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps +from kasa.discover import ( + DiscoveryResult, + _AesDiscoveryQuery, + _DiscoverProtocol, + json_dumps, +) from kasa.exceptions import AuthenticationError, UnsupportedDeviceError from kasa.iot import IotDevice from kasa.xortransport import XorEncryption @@ -278,7 +288,7 @@ async def test_discover_send(mocker): assert proto.target_1 == ("255.255.255.255", 9999) transport = mocker.patch.object(proto, "transport") await proto.do_discover() - assert transport.sendto.call_count == proto.discovery_packets * 2 + assert transport.sendto.call_count == proto.discovery_packets * 3 async def test_discover_datagram_received(mocker, discovery_data): @@ -485,13 +495,14 @@ async def test_do_discover_drop_packets(mocker, port, do_not_reply_count): discovery_timeout=discovery_timeout, discovery_packets=5, ) - ft = FakeDatagramTransport(dp, port, do_not_reply_count) + expected_send = 1 if port == 9999 else 2 + ft = FakeDatagramTransport(dp, port, do_not_reply_count * expected_send) dp.connection_made(ft) await dp.wait_for_discovery_to_complete() await asyncio.sleep(0) - assert ft.send_count == do_not_reply_count + 1 + assert ft.send_count == do_not_reply_count * expected_send + expected_send assert dp.discover_task.done() assert dp.discover_task.cancelled() @@ -603,3 +614,36 @@ async def test_discovery_redaction(discovery_mock, caplog: pytest.LogCaptureFixt await Discover.discover() assert mac not in caplog.text assert "12:34:56:00:00:00" in caplog.text + + +async def test_discovery_decryption(): + """Test discovery decryption.""" + key = b"8\x89\x02\xfa\xf5Xs\x1c\xa1 H\x9a\x82\xc7\xd9\t" + iv = b"9=\xf8\x1bS\xcd0\xb5\x89i\xba\xfd^9\x9f\xfa" + key_iv = key + iv + + _AesDiscoveryQuery.generate_query() + keypair = _AesDiscoveryQuery.keypair + + padding = asymmetric_padding.OAEP( + mgf=asymmetric_padding.MGF1(algorithm=hashes.SHA1()), # noqa: S303 + algorithm=hashes.SHA1(), # noqa: S303 + label=None, + ) + encrypted_key_iv = keypair.public_key.encrypt(key_iv, padding) + encrypted_key_iv_b4 = base64.b64encode(encrypted_key_iv) + encryption_session = AesEncyptionSession(key_iv[:16], key_iv[16:]) + + data_dict = {"foo": 1, "bar": 2} + data = json.dumps(data_dict) + encypted_data = encryption_session.encrypt(data.encode()) + + encrypt_info = { + "data": encypted_data.decode(), + "key": encrypted_key_iv_b4.decode(), + "sym_schm": "AES", + } + info = {**UNSUPPORTED["result"], "encrypt_info": encrypt_info} + dr = DiscoveryResult(**info) + Discover._decrypt_discovery_data(dr) + assert dr.decrypted_data == data_dict From dcc36e1dfe3fc9955698cdaf976a68ddfc2e3b6f Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 16 Oct 2024 16:53:52 +0100 Subject: [PATCH 597/892] Initial TapoCamera support (#1165) Adds experimental support for the Tapo Camera protocol also used by the H200 hub. Creates a new SslAesTransport and a derived SmartCamera and SmartCameraProtocol. --- kasa/cli/main.py | 39 +- kasa/device_factory.py | 25 +- kasa/device_type.py | 1 + kasa/deviceconfig.py | 6 + kasa/discover.py | 1 + kasa/experimental/__init__.py | 1 + kasa/experimental/enabled.py | 12 + kasa/experimental/smartcamera.py | 84 ++++ kasa/experimental/smartcameraprotocol.py | 109 +++++ kasa/experimental/sslaestransport.py | 494 +++++++++++++++++++++++ kasa/httpclient.py | 3 +- kasa/tests/test_cli.py | 3 + pyproject.toml | 5 +- 13 files changed, 771 insertions(+), 12 deletions(-) create mode 100644 kasa/experimental/__init__.py create mode 100644 kasa/experimental/enabled.py create mode 100644 kasa/experimental/smartcamera.py create mode 100644 kasa/experimental/smartcameraprotocol.py create mode 100644 kasa/experimental/sslaestransport.py diff --git a/kasa/cli/main.py b/kasa/cli/main.py index 1550b7af3..7ba65155d 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -35,6 +35,7 @@ "strip", "lightstrip", "smart", + "camera", ] ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] @@ -172,6 +173,14 @@ def _legacy_type_to_class(_type): type=int, help="The login version for device authentication. Defaults to 2", ) +@click.option( + "--https/--no-https", + envvar="KASA_HTTPS", + default=False, + is_flag=True, + type=bool, + help="Set flag if the device encryption uses https.", +) @click.option( "--timeout", envvar="KASA_TIMEOUT", @@ -209,6 +218,14 @@ def _legacy_type_to_class(_type): envvar="KASA_CREDENTIALS_HASH", help="Hashed credentials used to authenticate to the device.", ) +@click.option( + "--experimental", + default=False, + is_flag=True, + type=bool, + envvar="KASA_EXPERIMENTAL", + help="Enable experimental mode for devices not yet fully supported.", +) @click.version_option(package_name="python-kasa") @click.pass_context async def cli( @@ -221,6 +238,7 @@ async def cli( debug, type, encrypt_type, + https, device_family, login_version, json, @@ -229,6 +247,7 @@ async def cli( username, password, credentials_hash, + experimental, ): """A tool for controlling TP-Link smart home devices.""" # noqa # no need to perform any checks if we are just displaying the help @@ -237,6 +256,11 @@ async def cli( ctx.obj = object() return + if experimental: + from kasa.experimental.enabled import Enabled + + Enabled.set(True) + logging_config: dict[str, Any] = { "level": logging.DEBUG if debug > 0 else logging.INFO } @@ -295,12 +319,21 @@ async def cli( return await ctx.invoke(discover) device_updated = False - if type is not None and type != "smart": + if type is not None and type not in {"smart", "camera"}: from kasa.deviceconfig import DeviceConfig config = DeviceConfig(host=host, port_override=port, timeout=timeout) dev = _legacy_type_to_class(type)(host, config=config) - elif type == "smart" or (device_family and encrypt_type): + elif type in {"smart", "camera"} or (device_family and encrypt_type): + if type == "camera": + if not experimental: + error( + "Camera is an experimental type, please enable with --experimental" + ) + encrypt_type = "AES" + https = True + device_family = "SMART.IPCAMERA" + from kasa.device import Device from kasa.deviceconfig import ( DeviceConfig, @@ -311,10 +344,12 @@ async def cli( if not encrypt_type: encrypt_type = "KLAP" + ctype = DeviceConnectionParameters( DeviceFamily(device_family), DeviceEncryptionType(encrypt_type), login_version, + https, ) config = DeviceConfig( host=host, diff --git a/kasa/device_factory.py b/kasa/device_factory.py index a124bb4c4..01b2c8e77 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -11,6 +11,9 @@ from .device_type import DeviceType from .deviceconfig import DeviceConfig from .exceptions import KasaException, UnsupportedDeviceError +from .experimental.smartcamera import SmartCamera +from .experimental.smartcameraprotocol import SmartCameraProtocol +from .experimental.sslaestransport import SslAesTransport from .iot import ( IotBulb, IotDevice, @@ -171,6 +174,7 @@ def get_device_class_from_family(device_type: str) -> type[Device] | None: "SMART.TAPOHUB": SmartDevice, "SMART.KASAHUB": SmartDevice, "SMART.KASASWITCH": SmartDevice, + "SMART.IPCAMERA": SmartCamera, "IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTBULB": IotBulb, } @@ -188,8 +192,12 @@ def get_protocol( ) -> BaseProtocol | None: """Return the protocol from the connection name.""" protocol_name = config.connection_type.device_family.value.split(".")[0] + ctype = config.connection_type protocol_transport_key = ( - protocol_name + "." + config.connection_type.encryption_type.value + protocol_name + + "." + + ctype.encryption_type.value + + (".HTTPS" if ctype.https else "") ) supported_device_protocols: dict[ str, tuple[type[BaseProtocol], type[BaseTransport]] @@ -199,10 +207,11 @@ def get_protocol( "SMART.AES": (SmartProtocol, AesTransport), "SMART.KLAP": (SmartProtocol, KlapTransportV2), } - if protocol_transport_key not in supported_device_protocols: - return None - - protocol_class, transport_class = supported_device_protocols.get( - protocol_transport_key - ) # type: ignore - return protocol_class(transport=transport_class(config=config)) + if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)): + from .experimental.enabled import Enabled + + if Enabled.value and protocol_transport_key == "SMART.AES.HTTPS": + prot_tran_cls = (SmartCameraProtocol, SslAesTransport) + else: + return None + return prot_tran_cls[0](transport=prot_tran_cls[1](config=config)) diff --git a/kasa/device_type.py b/kasa/device_type.py index 3d3b828dd..b690f1f10 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -12,6 +12,7 @@ class DeviceType(Enum): Plug = "plug" Bulb = "bulb" Strip = "strip" + Camera = "camera" WallSwitch = "wallswitch" StripSocket = "stripsocket" Dimmer = "dimmer" diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 0833c0798..1bd806f0d 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -72,6 +72,7 @@ class DeviceFamily(Enum): SmartTapoSwitch = "SMART.TAPOSWITCH" SmartTapoHub = "SMART.TAPOHUB" SmartKasaHub = "SMART.KASAHUB" + SmartIpCamera = "SMART.IPCAMERA" def _dataclass_from_dict(klass, in_val): @@ -118,19 +119,24 @@ class DeviceConnectionParameters: device_family: DeviceFamily encryption_type: DeviceEncryptionType login_version: Optional[int] = None + https: bool = False @staticmethod def from_values( device_family: str, encryption_type: str, login_version: Optional[int] = None, + https: Optional[bool] = None, ) -> "DeviceConnectionParameters": """Return connection parameters from string values.""" try: + if https is None: + https = False return DeviceConnectionParameters( DeviceFamily(device_family), DeviceEncryptionType(encryption_type), login_version, + https, ) except (ValueError, TypeError) as ex: raise KasaException( diff --git a/kasa/discover.py b/kasa/discover.py index 9d615398c..79c162161 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -637,6 +637,7 @@ def _get_device_instance( type_, encrypt_type, discovery_result.mgt_encrypt_schm.lv, + discovery_result.mgt_encrypt_schm.is_support_https, ) except KasaException as ex: raise UnsupportedDeviceError( diff --git a/kasa/experimental/__init__.py b/kasa/experimental/__init__.py new file mode 100644 index 000000000..604622464 --- /dev/null +++ b/kasa/experimental/__init__.py @@ -0,0 +1 @@ +"""Package for experimental.""" diff --git a/kasa/experimental/enabled.py b/kasa/experimental/enabled.py new file mode 100644 index 000000000..7679f97c2 --- /dev/null +++ b/kasa/experimental/enabled.py @@ -0,0 +1,12 @@ +"""Package for experimental enabled.""" + + +class Enabled: + """Class for enabling experimental functionality.""" + + value = False + + @classmethod + def set(cls, value): + """Set the enabled value.""" + cls.value = value diff --git a/kasa/experimental/smartcamera.py b/kasa/experimental/smartcamera.py new file mode 100644 index 000000000..809ac74a0 --- /dev/null +++ b/kasa/experimental/smartcamera.py @@ -0,0 +1,84 @@ +"""Module for smartcamera.""" + +from __future__ import annotations + +from ..device_type import DeviceType +from ..smart import SmartDevice +from .sslaestransport import SmartErrorCode + + +class SmartCamera(SmartDevice): + """Class for smart cameras.""" + + async def update(self, update_children: bool = False): + """Update the device.""" + initial_query = { + "getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}}, + "getLensMaskConfig": {"lens_mask": {"name": ["lens_mask_info"]}}, + } + resp = await self.protocol.query(initial_query) + self._last_update.update(resp) + info = self._try_get_response(resp, "getDeviceInfo") + self._info = self._map_info(info["device_info"]) + self._last_update = resp + + def _map_info(self, device_info: dict) -> dict: + basic_info = device_info["basic_info"] + return { + "model": basic_info["device_model"], + "type": basic_info["device_type"], + "alias": basic_info["device_alias"], + "fw_ver": basic_info["sw_version"], + "hw_ver": basic_info["hw_version"], + "mac": basic_info["mac"], + "hwId": basic_info["hw_id"], + "oem_id": basic_info["oem_id"], + } + + @property + def is_on(self) -> bool: + """Return true if the device is on.""" + if isinstance(self._last_update["getLensMaskConfig"], SmartErrorCode): + return True + return ( + self._last_update["getLensMaskConfig"]["lens_mask"]["lens_mask_info"][ + "enabled" + ] + == "on" + ) + + async def set_state(self, on: bool): + """Set the device state.""" + if isinstance(self._last_update["getLensMaskConfig"], SmartErrorCode): + return + query = { + "setLensMaskConfig": { + "lens_mask": {"lens_mask_info": {"enabled": "on" if on else "off"}} + }, + } + return await self.protocol.query(query) + + @property + def device_type(self) -> DeviceType: + """Return the device type.""" + return DeviceType.Camera + + @property + def alias(self) -> str | None: + """Returns the device alias or nickname.""" + if self._info: + return self._info.get("alias") + return None + + @property + def hw_info(self) -> dict: + """Return hardware info for the device.""" + return { + "sw_ver": self._info.get("hw_ver"), + "hw_ver": self._info.get("fw_ver"), + "mac": self._info.get("mac"), + "type": self._info.get("type"), + "hwId": self._info.get("hwId"), + "dev_name": self.alias, + "oemId": self._info.get("oem_id"), + } diff --git a/kasa/experimental/smartcameraprotocol.py b/kasa/experimental/smartcameraprotocol.py new file mode 100644 index 000000000..384b76e90 --- /dev/null +++ b/kasa/experimental/smartcameraprotocol.py @@ -0,0 +1,109 @@ +"""Module for SmartCamera Protocol.""" + +from __future__ import annotations + +import logging +from pprint import pformat as pf +from typing import Any + +from ..exceptions import AuthenticationError, DeviceError, _RetryableError +from ..json import dumps as json_dumps +from ..smartprotocol import SmartProtocol +from .sslaestransport import ( + SMART_AUTHENTICATION_ERRORS, + SMART_RETRYABLE_ERRORS, + SmartErrorCode, +) + +_LOGGER = logging.getLogger(__name__) + + +class SmartCameraProtocol(SmartProtocol): + """Class for SmartCamera Protocol.""" + + async def _handle_response_lists( + self, response_result: dict[str, Any], method, retry_count + ): + pass + + def _handle_response_error_code(self, resp_dict: dict, method, raise_on_error=True): + error_code_raw = resp_dict.get("error_code") + try: + error_code = SmartErrorCode.from_int(error_code_raw) + except ValueError: + _LOGGER.warning( + "Device %s received unknown error code: %s", self._host, error_code_raw + ) + error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR + + if error_code is SmartErrorCode.SUCCESS: + return + + if not raise_on_error: + resp_dict["result"] = error_code + return + + msg = ( + f"Error querying device: {self._host}: " + + f"{error_code.name}({error_code.value})" + + f" for method: {method}" + ) + if error_code in SMART_RETRYABLE_ERRORS: + raise _RetryableError(msg, error_code=error_code) + if error_code in SMART_AUTHENTICATION_ERRORS: + raise AuthenticationError(msg, error_code=error_code) + raise DeviceError(msg, error_code=error_code) + + async def close(self) -> None: + """Close the underlying transport.""" + await self._transport.close() + + async def _execute_query( + self, request: str | dict, *, retry_count: int, iterate_list_pages: bool = True + ) -> dict: + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + + if isinstance(request, dict): + if len(request) == 1: + multi_method = next(iter(request)) + module = next(iter(request[multi_method])) + req = { + "method": multi_method[:3], + module: request[multi_method][module], + } + else: + return await self._execute_multiple_query(request, retry_count) + else: + # If method like getSomeThing then module will be some_thing + multi_method = request + snake_name = "".join( + ["_" + i.lower() if i.isupper() else i for i in multi_method] + ).lstrip("_") + module = snake_name[4:] + req = {"method": snake_name[:3], module: {}} + + smart_request = json_dumps(req) + if debug_enabled: + _LOGGER.debug( + "%s >> %s", + self._host, + pf(smart_request), + ) + response_data = await self._transport.send(smart_request) + + if debug_enabled: + _LOGGER.debug( + "%s << %s", + self._host, + pf(response_data), + ) + + if "error_code" in response_data: + # H200 does not return an error code + self._handle_response_error_code(response_data, multi_method) + + # TODO need to update handle response lists + + if multi_method[:3] == "set": + return {} + return {multi_method: {module: response_data[module]}} diff --git a/kasa/experimental/sslaestransport.py b/kasa/experimental/sslaestransport.py new file mode 100644 index 000000000..8936db8d2 --- /dev/null +++ b/kasa/experimental/sslaestransport.py @@ -0,0 +1,494 @@ +"""Implementation of the TP-Link SSL AES transport.""" + +from __future__ import annotations + +import base64 +import hashlib +import logging +import secrets +import ssl +import time +from enum import Enum, IntEnum, auto +from functools import cache +from typing import TYPE_CHECKING, Any, Dict, cast + +from urllib3.util import create_urllib3_context +from yarl import URL + +from ..aestransport import AesEncyptionSession +from ..credentials import Credentials +from ..deviceconfig import DeviceConfig +from ..exceptions import ( + AuthenticationError, + DeviceError, + KasaException, + _RetryableError, +) +from ..httpclient import HttpClient +from ..json import dumps as json_dumps +from ..json import loads as json_loads +from ..protocol import BaseTransport + +_LOGGER = logging.getLogger(__name__) + + +ONE_DAY_SECONDS = 86400 +SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20 + + +def _sha256(payload: bytes) -> bytes: + return hashlib.sha256(payload).digest() # noqa: S324 + + +def _md5_hash(payload: bytes) -> str: + return hashlib.md5(payload).hexdigest().upper() # noqa: S324 + + +def _sha256_hash(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest().upper() # noqa: S324 + + +class TransportState(Enum): + """Enum for AES state.""" + + HANDSHAKE_REQUIRED = auto() # Handshake needed + ESTABLISHED = auto() # Ready to send requests + + +class SslAesTransport(BaseTransport): + """Implementation of the AES encryption protocol. + + AES is the name used in device discovery for TP-Link's TAPO encryption + protocol, sometimes used by newer firmware versions on kasa devices. + """ + + DEFAULT_PORT: int = 443 + COMMON_HEADERS = { + "Content-Type": "application/json; charset=UTF-8", + "requestByApp": "true", + "Accept": "application/json", + "Accept-Encoding": "gzip, deflate", + "User-Agent": "Tapo CameraClient Android", + "Connection": "close", + } + CIPHERS = ":".join( + [ + "AES256-GCM-SHA384", + "AES256-SHA256", + "AES128-GCM-SHA256", + "AES128-SHA256", + "AES256-SHA", + ] + ) + DEFAULT_TIMEOUT = 10 + + def __init__( + self, + *, + config: DeviceConfig, + ) -> None: + super().__init__(config=config) + + self._login_version = config.connection_type.login_version + if ( + not self._credentials or self._credentials.username is None + ) and not self._credentials_hash: + self._credentials = Credentials() + self._default_credentials: Credentials | None = None + + if not config.timeout: + config.timeout = self.DEFAULT_TIMEOUT + self._http_client: HttpClient = HttpClient(config) + + self._state = TransportState.HANDSHAKE_REQUIRED + + self._encryption_session: AesEncyptionSession | None = None + self._session_expire_at: float | None = None + + self._host_port = f"{self._host}:{self._port}" + self._app_url = URL(f"https://{self._host_port}") + self._token_url: URL | None = None + self._ssl_context = create_urllib3_context( + ciphers=self.CIPHERS, + cert_reqs=ssl.CERT_NONE, + options=0, + ) + ref = str(self._token_url) if self._token_url else str(self._app_url) + self._headers = { + **self.COMMON_HEADERS, + "Host": self._host_port, + "Referer": ref, + } + self._seq: int | None = None + self._pwd_hash: str | None = None + self._username: str | None = None + if self._credentials != Credentials() and self._credentials: + self._username = self._credentials.username + elif self._credentials_hash: + ch = json_loads(base64.b64decode(self._credentials_hash.encode())) + self._pwd_hash = ch["pwd"] + self._username = ch["un"] + self._local_nonce: str | None = None + + _LOGGER.debug("Created AES transport for %s", self._host) + + @property + def default_port(self) -> int: + """Default port for the transport.""" + return self.DEFAULT_PORT + + @property + def credentials_hash(self) -> str | None: + """The hashed credentials used by the transport.""" + if self._credentials == Credentials(): + return None + if self._credentials_hash: + return self._credentials_hash + if self._pwd_hash and self._credentials: + ch = {"un": self._credentials.username, "pwd": self._pwd_hash} + return base64.b64encode(json_dumps(ch).encode()).decode() + return None + + def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: + error_code_raw = resp_dict.get("error_code") + try: + error_code = SmartErrorCode.from_int(error_code_raw) + except ValueError: + _LOGGER.warning( + "Device %s received unknown error code: %s", self._host, error_code_raw + ) + error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR + if error_code is SmartErrorCode.SUCCESS: + return + msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})" + if error_code in SMART_RETRYABLE_ERRORS: + raise _RetryableError(msg, error_code=error_code) + if error_code in SMART_AUTHENTICATION_ERRORS: + self._state = TransportState.HANDSHAKE_REQUIRED + raise AuthenticationError(msg, error_code=error_code) + raise DeviceError(msg, error_code=error_code) + + async def send_secure_passthrough(self, request: str) -> dict[str, Any]: + """Send encrypted message as passthrough.""" + if self._state is TransportState.ESTABLISHED and self._token_url: + url = self._token_url + else: + url = self._app_url + + encrypted_payload = self._encryption_session.encrypt(request.encode()) # type: ignore + passthrough_request = { + "method": "securePassthrough", + "params": {"request": encrypted_payload.decode()}, + } + passthrough_request_str = json_dumps(passthrough_request) + if TYPE_CHECKING: + assert self._pwd_hash + assert self._local_nonce + assert self._seq + tag = self.generate_tag( + passthrough_request_str, self._local_nonce, self._pwd_hash, self._seq + ) + headers = {**self._headers, "Seq": str(self._seq), "Tapo_tag": tag} + self._seq += 1 + status_code, resp_dict = await self._http_client.post( + url, + json=passthrough_request_str, + headers=headers, + ssl=self._ssl_context, + ) + + if status_code != 200: + raise KasaException( + f"{self._host} responded with an unexpected " + + f"status code {status_code} to passthrough" + ) + + self._handle_response_error_code( + resp_dict, "Error sending secure_passthrough message" + ) + + if TYPE_CHECKING: + resp_dict = cast(Dict[str, Any], resp_dict) + assert self._encryption_session is not None + + if "result" in resp_dict and "response" in resp_dict["result"]: + raw_response: str = resp_dict["result"]["response"] + else: + # Tapo Cameras respond unencrypted to single requests. + return resp_dict + + try: + response = self._encryption_session.decrypt(raw_response.encode()) + ret_val = json_loads(response) + except Exception as ex: + try: + ret_val = json_loads(raw_response) + _LOGGER.debug( + "Received unencrypted response over secure passthrough from %s", + self._host, + ) + except Exception: + raise KasaException( + f"Unable to decrypt response from {self._host}, " + + f"error: {ex}, response: {raw_response}", + ex, + ) from ex + return ret_val # type: ignore[return-value] + + @staticmethod + def generate_confirm_hash(local_nonce, server_nonce, pwd_hash): + """Generate an auth hash for the protocol on the supplied credentials.""" + expected_confirm_bytes = _sha256_hash( + local_nonce.encode() + pwd_hash.encode() + server_nonce.encode() + ) + return expected_confirm_bytes + server_nonce + local_nonce + + @staticmethod + def generate_digest_password(local_nonce, server_nonce, pwd_hash): + """Generate an auth hash for the protocol on the supplied credentials.""" + digest_password_hash = _sha256_hash( + pwd_hash.encode() + local_nonce.encode() + server_nonce.encode() + ) + return ( + digest_password_hash.encode() + local_nonce.encode() + server_nonce.encode() + ).decode() + + @staticmethod + def generate_encryption_token( + token_type, local_nonce, server_nonce, pwd_hash + ) -> bytes: + """Generate encryption token.""" + hashedKey = _sha256_hash( + local_nonce.encode() + pwd_hash.encode() + server_nonce.encode() + ) + return _sha256( + token_type.encode() + + local_nonce.encode() + + server_nonce.encode() + + hashedKey.encode() + )[:16] + + @staticmethod + def generate_tag(request: str, local_nonce: str, pwd_hash: str, seq: int) -> str: + """Generate the tag header from the request for the header.""" + pwd_nonce_hash = _sha256_hash(pwd_hash.encode() + local_nonce.encode()) + tag = _sha256_hash( + pwd_nonce_hash.encode() + request.encode() + str(seq).encode() + ) + return tag + + async def perform_handshake(self) -> None: + """Perform the handshake.""" + local_nonce, server_nonce, pwd_hash = await self.perform_handshake1() + await self.perform_handshake2(local_nonce, server_nonce, pwd_hash) + + async def perform_handshake2(self, local_nonce, server_nonce, pwd_hash) -> None: + """Perform the handshake.""" + _LOGGER.debug("Performing handshake2 ...") + digest_password = self.generate_digest_password( + local_nonce, server_nonce, pwd_hash + ) + body = { + "method": "login", + "params": { + "cnonce": local_nonce, + "encrypt_type": "3", + "digest_passwd": digest_password, + "username": self._username, + }, + } + http_client = self._http_client + status_code, resp_dict = await http_client.post( + self._app_url, json=body, headers=self._headers, ssl=self._ssl_context + ) + if status_code != 200: + raise KasaException( + f"{self._host} responded with an unexpected " + + f"status code {status_code} to handshake2" + ) + resp_dict = cast(dict, resp_dict) + self._seq = resp_dict["result"]["start_seq"] + stok = resp_dict["result"]["stok"] + self._token_url = URL(f"{str(self._app_url)}/stok={stok}/ds") + self._pwd_hash = pwd_hash + self._local_nonce = local_nonce + lsk = self.generate_encryption_token("lsk", local_nonce, server_nonce, pwd_hash) + ivb = self.generate_encryption_token("ivb", local_nonce, server_nonce, pwd_hash) + self._encryption_session = AesEncyptionSession(lsk, ivb) + self._state = TransportState.ESTABLISHED + _LOGGER.debug("Handshake2 complete ...") + + async def perform_handshake1(self) -> tuple[str, str, str]: + """Perform the handshake.""" + _LOGGER.debug("Will perform handshaking...") + + if not self._username: + raise KasaException("Cannot connect to device with no credentials") + local_nonce = secrets.token_bytes(8).hex().upper() + # Device needs the content length or it will response with 500 + body = { + "method": "login", + "params": { + "cnonce": local_nonce, + "encrypt_type": "3", + "username": self._username, + }, + } + http_client = self._http_client + + status_code, resp_dict = await http_client.post( + self._app_url, json=body, headers=self._headers, ssl=self._ssl_context + ) + + _LOGGER.debug("Device responded with: %s", resp_dict) + + if status_code != 200: + raise KasaException( + f"{self._host} responded with an unexpected " + + f"status code {status_code} to handshake1" + ) + + resp_dict = cast(dict, resp_dict) + error_code = SmartErrorCode.from_int(resp_dict["error_code"]) + if error_code != SmartErrorCode.INVALID_NONCE: + self._handle_response_error_code(resp_dict, "Unable to complete handshake") + + if TYPE_CHECKING: + resp_dict = cast(Dict[str, Any], resp_dict) + + server_nonce = resp_dict["result"]["data"]["nonce"] + device_confirm = resp_dict["result"]["data"]["device_confirm"] + if self._credentials and self._credentials != Credentials(): + pwd_hash = _sha256_hash(self._credentials.password.encode()) + else: + if TYPE_CHECKING: + assert self._pwd_hash + pwd_hash = self._pwd_hash + + expected_confirm_sha256 = self.generate_confirm_hash( + local_nonce, server_nonce, pwd_hash + ) + if device_confirm == expected_confirm_sha256: + _LOGGER.debug("Credentials match") + return local_nonce, server_nonce, pwd_hash + + if TYPE_CHECKING: + assert self._credentials + assert self._credentials.password + pwd_hash = _md5_hash(self._credentials.password.encode()) + expected_confirm_md5 = self.generate_confirm_hash( + local_nonce, server_nonce, pwd_hash + ) + if device_confirm == expected_confirm_md5: + _LOGGER.debug("Credentials match") + return local_nonce, server_nonce, pwd_hash + + msg = f"Server response doesn't match our challenge on ip {self._host}" + _LOGGER.debug(msg) + raise AuthenticationError(msg) + + def _handshake_session_expired(self): + """Return true if session has expired.""" + return ( + self._session_expire_at is None + or self._session_expire_at - time.time() <= 0 + ) + + async def send(self, request: str) -> dict[str, Any]: + """Send the request.""" + if ( + self._state is TransportState.HANDSHAKE_REQUIRED + or self._handshake_session_expired() + ): + await self.perform_handshake() + + return await self.send_secure_passthrough(request) + + async def close(self) -> None: + """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._state = TransportState.HANDSHAKE_REQUIRED + self._encryption_session = None + self._seq = 0 + self._pwd_hash = None + self._local_nonce = None + + +class SmartErrorCode(IntEnum): + """Smart error codes for this transport.""" + + def __str__(self): + return f"{self.name}({self.value})" + + @staticmethod + @cache + def from_int(value: int) -> SmartErrorCode: + """Convert an integer to a SmartErrorCode.""" + return SmartErrorCode(value) + + SUCCESS = 0 + + SYSTEM_ERROR = -40101 + INVALID_ARGUMENTS = -40209 + + # Camera error codes + SESSION_EXPIRED = -40401 + HOMEKIT_LOGIN_FAIL = -40412 + DEVICE_BLOCKED = -40404 + DEVICE_FACTORY = -40405 + OUT_OF_LIMIT = -40406 + OTHER_ERROR = -40407 + SYSTEM_BLOCKED = -40408 + NONCE_EXPIRED = -40409 + FFS_NONE_PWD = -90000 + TIMEOUT_ERROR = 40108 + UNSUPPORTED_METHOD = -40106 + ONE_SECOND_REPEAT_REQUEST = -40109 + INVALID_NONCE = -40413 + PROTOCOL_FORMAT_ERROR = -40210 + IP_CONFLICT = -40321 + DIAGNOSE_TYPE_NOT_SUPPORT = -69051 + DIAGNOSE_TASK_FULL = -69052 + DIAGNOSE_TASK_BUSY = -69053 + DIAGNOSE_INTERNAL_ERROR = -69055 + DIAGNOSE_ID_NOT_FOUND = -69056 + DIAGNOSE_TASK_NULL = -69057 + CLOUD_LINK_DOWN = -69060 + ONVIF_SET_WRONG_TIME = -69061 + CLOUD_NTP_NO_RESPONSE = -69062 + CLOUD_GET_WRONG_TIME = -69063 + SNTP_SRV_NO_RESPONSE = -69064 + SNTP_GET_WRONG_TIME = -69065 + LINK_UNCONNECTED = -69076 + WIFI_SIGNAL_WEAK = -69077 + LOCAL_NETWORK_POOR = -69078 + CLOUD_NETWORK_POOR = -69079 + INTER_NETWORK_POOR = -69080 + DNS_TIMEOUT = -69081 + DNS_ERROR = -69082 + PING_NO_RESPONSE = -69083 + DHCP_MULTI_SERVER = -69084 + DHCP_ERROR = -69085 + STREAM_SESSION_CLOSE = -69094 + STREAM_BITRATE_EXCEPTION = -69095 + STREAM_FULL = -69096 + STREAM_NO_INTERNET = -69097 + HARDWIRED_NOT_FOUND = -72101 + + # Library internal for unknown error codes + INTERNAL_UNKNOWN_ERROR = -100_000 + # Library internal for query errors + INTERNAL_QUERY_ERROR = -100_001 + + +SMART_RETRYABLE_ERRORS = [ + SmartErrorCode.SESSION_EXPIRED, +] + +SMART_AUTHENTICATION_ERRORS = [ + SmartErrorCode.INVALID_ARGUMENTS, +] diff --git a/kasa/httpclient.py b/kasa/httpclient.py index ec80ad616..9904b17b0 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -64,6 +64,7 @@ async def post( json: dict | Any | None = None, headers: dict[str, str] | None = None, cookies_dict: dict[str, str] | None = None, + ssl=False, ) -> tuple[int, dict | bytes | None]: """Send an http post request to the device. @@ -106,7 +107,7 @@ async def post( timeout=client_timeout, cookies=cookies_dict, headers=headers, - ssl=False, + ssl=ssl, ) async with resp: if resp.status == 200: diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 553f93d37..f22286e58 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -800,6 +800,9 @@ async def test_host_auth_failed(discovery_mock, mocker, runner): @pytest.mark.parametrize("device_type", TYPES) async def test_type_param(device_type, mocker, runner): """Test for handling only one of username or password supplied.""" + if device_type == "camera": + pytest.skip(reason="camera is experimental") + result_device = FileNotFoundError pass_dev = click.make_pass_decorator(Device) diff --git a/pyproject.toml b/pyproject.toml index 8d2d58b9c..f92130efd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,7 +85,10 @@ exclude = [ [tool.coverage.run] source = ["kasa"] branch = true -omit = ["kasa/tests/*"] +omit = [ + "kasa/tests/*", + "kasa/experimental/*" +] [tool.coverage.report] exclude_lines = [ From c6f2d89d44ec1ff89a2e04949a4d452f0c39ce80 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 18 Oct 2024 09:55:07 +0100 Subject: [PATCH 598/892] Expose smart child device map as a class constant (#1173) To facilitate distinguishing between smart and smart camera child devices. --- kasa/smart/smartchilddevice.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 3b5f53efb..16006ea45 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -21,6 +21,17 @@ class SmartChildDevice(SmartDevice): This wraps the protocol communications and sets internal data for the child. """ + CHILD_DEVICE_TYPE_MAP = { + "plug.powerstrip.sub-plug": DeviceType.Plug, + "subg.trigger.contact-sensor": DeviceType.Sensor, + "subg.trigger.temp-hmdt-sensor": DeviceType.Sensor, + "subg.trigger.water-leak-sensor": DeviceType.Sensor, + "kasa.switch.outlet.sub-fan": DeviceType.Fan, + "kasa.switch.outlet.sub-dimmer": DeviceType.Dimmer, + "subg.trv": DeviceType.Thermostat, + "subg.trigger.button": DeviceType.Sensor, + } + def __init__( self, parent: SmartDevice, @@ -76,16 +87,7 @@ async def create(cls, parent: SmartDevice, child_info, child_components): @property def device_type(self) -> DeviceType: """Return child device type.""" - child_device_map = { - "plug.powerstrip.sub-plug": DeviceType.Plug, - "subg.trigger.contact-sensor": DeviceType.Sensor, - "subg.trigger.temp-hmdt-sensor": DeviceType.Sensor, - "subg.trigger.water-leak-sensor": DeviceType.Sensor, - "kasa.switch.outlet.sub-fan": DeviceType.Fan, - "kasa.switch.outlet.sub-dimmer": DeviceType.Dimmer, - "subg.trv": DeviceType.Thermostat, - } - dev_type = child_device_map.get(self.sys_info["category"]) + dev_type = self.CHILD_DEVICE_TYPE_MAP.get(self.sys_info["category"]) if dev_type is None: _LOGGER.warning("Unknown child device type, please open issue ") dev_type = DeviceType.Unknown From 2dd621675a99086e400103a7f78b810f64a0d426 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 18 Oct 2024 10:40:17 +0100 Subject: [PATCH 599/892] Drop urllib3 dependency and create ssl context in executor thread (#1175) --- kasa/experimental/sslaestransport.py | 35 +++++++++++++++++++++------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/kasa/experimental/sslaestransport.py b/kasa/experimental/sslaestransport.py index 8936db8d2..151cd5680 100644 --- a/kasa/experimental/sslaestransport.py +++ b/kasa/experimental/sslaestransport.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import base64 import hashlib import logging @@ -12,7 +13,6 @@ from functools import cache from typing import TYPE_CHECKING, Any, Dict, cast -from urllib3.util import create_urllib3_context from yarl import URL from ..aestransport import AesEncyptionSession @@ -108,11 +108,7 @@ def __init__( self._host_port = f"{self._host}:{self._port}" self._app_url = URL(f"https://{self._host_port}") self._token_url: URL | None = None - self._ssl_context = create_urllib3_context( - ciphers=self.CIPHERS, - cert_reqs=ssl.CERT_NONE, - options=0, - ) + self._ssl_context: ssl.SSLContext | None = None ref = str(self._token_url) if self._token_url else str(self._app_url) self._headers = { **self.COMMON_HEADERS, @@ -168,6 +164,21 @@ def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: raise AuthenticationError(msg, error_code=error_code) raise DeviceError(msg, error_code=error_code) + def _create_ssl_context(self) -> ssl.SSLContext: + context = ssl.SSLContext() + context.set_ciphers(self.CIPHERS) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context + + async def _get_ssl_context(self) -> ssl.SSLContext: + if not self._ssl_context: + loop = asyncio.get_running_loop() + self._ssl_context = await loop.run_in_executor( + None, self._create_ssl_context + ) + return self._ssl_context + async def send_secure_passthrough(self, request: str) -> dict[str, Any]: """Send encrypted message as passthrough.""" if self._state is TransportState.ESTABLISHED and self._token_url: @@ -194,7 +205,7 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]: url, json=passthrough_request_str, headers=headers, - ssl=self._ssl_context, + ssl=await self._get_ssl_context(), ) if status_code != 200: @@ -299,7 +310,10 @@ async def perform_handshake2(self, local_nonce, server_nonce, pwd_hash) -> None: } http_client = self._http_client status_code, resp_dict = await http_client.post( - self._app_url, json=body, headers=self._headers, ssl=self._ssl_context + self._app_url, + json=body, + headers=self._headers, + ssl=await self._get_ssl_context(), ) if status_code != 200: raise KasaException( @@ -337,7 +351,10 @@ async def perform_handshake1(self) -> tuple[str, str, str]: http_client = self._http_client status_code, resp_dict = await http_client.post( - self._app_url, json=body, headers=self._headers, ssl=self._ssl_context + self._app_url, + json=body, + headers=self._headers, + ssl=await self._get_ssl_context(), ) _LOGGER.debug("Device responded with: %s", resp_dict) From 486984fff81df7ec3a032ff8cff6503fd9854326 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 18 Oct 2024 12:31:52 +0200 Subject: [PATCH 600/892] Add motion sensor to known categories (#1176) Also, improve device type warning on unknown devices --- kasa/smart/smartchilddevice.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 16006ea45..1fe0014e7 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -26,6 +26,7 @@ class SmartChildDevice(SmartDevice): "subg.trigger.contact-sensor": DeviceType.Sensor, "subg.trigger.temp-hmdt-sensor": DeviceType.Sensor, "subg.trigger.water-leak-sensor": DeviceType.Sensor, + "subg.trigger.motion-sensor": DeviceType.Sensor, "kasa.switch.outlet.sub-fan": DeviceType.Fan, "kasa.switch.outlet.sub-dimmer": DeviceType.Dimmer, "subg.trv": DeviceType.Thermostat, @@ -87,9 +88,14 @@ async def create(cls, parent: SmartDevice, child_info, child_components): @property def device_type(self) -> DeviceType: """Return child device type.""" - dev_type = self.CHILD_DEVICE_TYPE_MAP.get(self.sys_info["category"]) + category = self.sys_info["category"] + dev_type = self.CHILD_DEVICE_TYPE_MAP.get(category) if dev_type is None: - _LOGGER.warning("Unknown child device type, please open issue ") + _LOGGER.warning( + "Unknown child device type %s for model %s, please open issue", + category, + self.model, + ) dev_type = DeviceType.Unknown return dev_type From acd0202cabe4bbd87d5ec0c4ad19bdc830933b15 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 18 Oct 2024 12:06:22 +0100 Subject: [PATCH 601/892] Update dump_devinfo for smart camera protocol (#1169) Introduces the child camera protocol wrapper, required to get the child device info with the new protocol. --- devtools/dump_devinfo.py | 452 ++++++++++--- devtools/helpers/smartrequests.py | 4 +- kasa/experimental/smartcameraprotocol.py | 100 ++- kasa/experimental/sslaestransport.py | 2 +- kasa/smartprotocol.py | 9 +- .../experimental/C210(EU)_2.0_1.4.2.json | 606 ++++++++++++++++++ 6 files changed, 1076 insertions(+), 97 deletions(-) create mode 100644 kasa/tests/fixtures/experimental/C210(EU)_2.0_1.4.2.json diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 8ca39d039..12e4c3cb8 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -28,14 +28,22 @@ AuthenticationError, Credentials, Device, + DeviceConfig, + DeviceConnectionParameters, Discover, KasaException, TimeoutError, ) +from kasa.device_factory import get_protocol +from kasa.deviceconfig import DeviceEncryptionType, DeviceFamily from kasa.discover import DiscoveryResult from kasa.exceptions import SmartErrorCode -from kasa.smart import SmartDevice -from kasa.smartprotocol import _ChildProtocolWrapper +from kasa.experimental.smartcameraprotocol import ( + SmartCameraProtocol, + _ChildCameraProtocolWrapper, +) +from kasa.smart import SmartChildDevice +from kasa.smartprotocol import SmartProtocol, _ChildProtocolWrapper Call = namedtuple("Call", "module method") SmartCall = namedtuple("SmartCall", "module request should_succeed child_device_id") @@ -45,6 +53,8 @@ SMART_CHILD_FOLDER = "kasa/tests/fixtures/smart/child/" IOT_FOLDER = "kasa/tests/fixtures/" +ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] + _LOGGER = logging.getLogger(__name__) @@ -82,11 +92,23 @@ def scrub(res): "mfi_setup_id", "mfi_token_token", "mfi_token_uuid", + "dev_id", + "device_name", + "device_alias", + "connect_ssid", + "encrypt_info", + "local_ip", ] for k, v in res.items(): if isinstance(v, collections.abc.Mapping): - res[k] = scrub(res.get(k)) + if k == "encrypt_info": + if "data" in v: + v["data"] = "" + if "key" in v: + v["key"] = "" + else: + res[k] = scrub(res.get(k)) elif ( isinstance(v, list) and len(v) > 0 @@ -107,20 +129,20 @@ def scrub(res): v = f"{v[:8]}{delim}{rest}" elif k in ["latitude", "latitude_i", "longitude", "longitude_i"]: v = 0 - elif k in ["ip"]: + elif k in ["ip", "local_ip"]: v = "127.0.0.123" elif k in ["ssid"]: # Need a valid base64 value here v = base64.b64encode(b"#MASKED_SSID#").decode() elif k in ["nickname"]: v = base64.b64encode(b"#MASKED_NAME#").decode() - elif k in ["alias"]: + elif k in ["alias", "device_alias"]: v = "#MASKED_NAME#" elif isinstance(res[k], int): v = 0 - elif k == "device_id" and "SCRUBBED" in v: + elif k in ["device_id", "dev_id"] and "SCRUBBED" in v: pass # already scrubbed - elif k == "device_id" and len(v) > 40: + elif k == ["device_id", "dev_id"] and len(v) > 40: # retain the last two chars when scrubbing child ids end = v[-2:] v = re.sub(r"\w", "0", v) @@ -142,14 +164,18 @@ def default_to_regular(d): return d -async def handle_device(basedir, autosave, device: Device, batch_size: int): +async def handle_device( + basedir, autosave, protocol, *, discovery_info=None, batch_size: int +): """Create a fixture for a single device instance.""" - if isinstance(device, SmartDevice): + if isinstance(protocol, SmartProtocol): fixture_results: list[FixtureResult] = await get_smart_fixtures( - device, batch_size + protocol, discovery_info=discovery_info, batch_size=batch_size ) else: - fixture_results = [await get_legacy_fixture(device)] + fixture_results = [ + await get_legacy_fixture(protocol, discovery_info=discovery_info) + ] for fixture_result in fixture_results: save_filename = Path(basedir) / fixture_result.folder / fixture_result.filename @@ -207,6 +233,44 @@ async def handle_device(basedir, autosave, device: Device, batch_size: int): + " Do not use this flag unless you are sure you know what it means." ), ) +@click.option( + "--discovery-timeout", + envvar="KASA_DISCOVERY_TIMEOUT", + default=10, + required=False, + show_default=True, + help="Timeout for discovery.", +) +@click.option( + "-e", + "--encrypt-type", + envvar="KASA_ENCRYPT_TYPE", + default=None, + type=click.Choice(ENCRYPT_TYPES, case_sensitive=False), +) +@click.option( + "-df", + "--device-family", + envvar="KASA_DEVICE_FAMILY", + default="SMART.TAPOPLUG", + help="Device family type, e.g. `SMART.KASASWITCH`.", +) +@click.option( + "-lv", + "--login-version", + envvar="KASA_LOGIN_VERSION", + default=2, + type=int, + help="The login version for device authentication. Defaults to 2", +) +@click.option( + "--https/--no-https", + envvar="KASA_HTTPS", + default=False, + is_flag=True, + type=bool, + help="Set flag if the device encryption uses https.", +) @click.option("--port", help="Port override", type=int) async def cli( host, @@ -215,9 +279,14 @@ async def cli( autosave, debug, username, + discovery_timeout, password, batch_size, discovery_info, + encrypt_type, + https, + device_family, + login_version, port, ): """Generate devinfo files for devices. @@ -227,11 +296,14 @@ async def cli( if debug: logging.basicConfig(level=logging.DEBUG) + from kasa.experimental.enabled import Enabled + + Enabled.set(True) + credentials = Credentials(username=username, password=password) if host is not None: if discovery_info: click.echo("Host and discovery info given, trying connect on %s." % host) - from kasa import DeviceConfig, DeviceConnectionParameters di = json.loads(discovery_info) dr = DiscoveryResult(**di) @@ -247,25 +319,68 @@ async def cli( credentials=credentials, ) device = await Device.connect(config=dc) - device.update_from_discover_info(dr.get_dict()) + await handle_device( + basedir, + autosave, + device.protocol, + discovery_info=dr.get_dict(), + batch_size=batch_size, + ) + elif device_family and encrypt_type: + ctype = DeviceConnectionParameters( + DeviceFamily(device_family), + DeviceEncryptionType(encrypt_type), + login_version, + https, + ) + config = DeviceConfig( + host=host, + port_override=port, + credentials=credentials, + connection_type=ctype, + ) + if protocol := get_protocol(config): + await handle_device(basedir, autosave, protocol, batch_size=batch_size) + else: + raise KasaException( + "Could not find a protocol for the given parameters. " + + "Maybe you need to enable --experimental." + ) else: click.echo("Host given, performing discovery on %s." % host) device = await Discover.discover_single( - host, credentials=credentials, port=port + host, + credentials=credentials, + port=port, + discovery_timeout=discovery_timeout, + ) + await handle_device( + basedir, + autosave, + device.protocol, + discovery_info=device._discovery_info, + batch_size=batch_size, ) - await handle_device(basedir, autosave, device, batch_size) else: click.echo( "No --host given, performing discovery on %s. Use --target to override." % target ) - devices = await Discover.discover(target=target, credentials=credentials) + devices = await Discover.discover( + target=target, credentials=credentials, discovery_timeout=discovery_timeout + ) click.echo("Detected %s devices" % len(devices)) for dev in devices.values(): - await handle_device(basedir, autosave, dev, batch_size) + await handle_device( + basedir, + autosave, + dev.protocol, + discovery_info=dev._discovery_info, + batch_size=batch_size, + ) -async def get_legacy_fixture(device): +async def get_legacy_fixture(protocol, *, discovery_info): """Get fixture for legacy IOT style protocol.""" items = [ Call(module="system", method="get_sysinfo"), @@ -284,9 +399,7 @@ async def get_legacy_fixture(device): for test_call in items: try: click.echo(f"Testing {test_call}..", nl=False) - info = await device.protocol.query( - {test_call.module: {test_call.method: {}}} - ) + info = await protocol.query({test_call.module: {test_call.method: {}}}) resp = info[test_call.module] except Exception as ex: click.echo(click.style(f"FAIL {ex}", fg="red")) @@ -297,7 +410,7 @@ async def get_legacy_fixture(device): click.echo(click.style("OK", fg="green")) successes.append((test_call, info)) finally: - await device.protocol.close() + await protocol.close() final_query = defaultdict(defaultdict) final = defaultdict(defaultdict) @@ -308,15 +421,15 @@ async def get_legacy_fixture(device): final = default_to_regular(final) try: - final = await device.protocol.query(final_query) + final = await protocol.query(final_query) except Exception as ex: _echo_error(f"Unable to query all successes at once: {ex}", bold=True, fg="red") finally: - await device.protocol.close() - if device._discovery_info and not device._discovery_info.get("system"): + await protocol.close() + if discovery_info and not discovery_info.get("system"): # 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. - dr = DiscoveryResult(**device._discovery_info) + dr = DiscoveryResult(**protocol._discovery_info) final["discovery_result"] = dr.dict( by_alias=False, exclude_unset=True, exclude_none=True, exclude_defaults=True ) @@ -365,29 +478,29 @@ def format_exception(e): async def _make_requests_or_exit( - device: SmartDevice, - requests: list[SmartRequest], + protocol: SmartProtocol, + requests: dict, name: str, batch_size: int, *, child_device_id: str, ) -> dict[str, dict]: final = {} - protocol = ( - device.protocol - if child_device_id == "" - else _ChildProtocolWrapper(child_device_id, device.protocol) - ) + # Calling close on child protocol wrappers is a noop + protocol_to_close = protocol + if child_device_id: + if isinstance(protocol, SmartCameraProtocol): + protocol = _ChildCameraProtocolWrapper(child_device_id, protocol) + else: + protocol = _ChildProtocolWrapper(child_device_id, protocol) try: end = len(requests) step = batch_size # Break the requests down as there seems to be a size limit + keys = [key for key in requests] for i in range(0, end, step): x = i - requests_step = requests[x : x + step] - request: list[SmartRequest] | SmartRequest = ( - requests_step[0] if len(requests_step) == 1 else requests_step - ) - responses = await protocol.query(SmartRequest._create_request_dict(request)) + requests_step = {key: requests[key] for key in keys[x : x + step]} + responses = await protocol.query(requests_step) for method, result in responses.items(): final[method] = result return final @@ -413,10 +526,155 @@ async def _make_requests_or_exit( _echo_error(format_exception(ex)) exit(1) finally: - await device.protocol.close() + await protocol_to_close.close() -async def get_smart_test_calls(device: SmartDevice): +async def get_smart_camera_test_calls(protocol: SmartProtocol): + """Get the list of test calls to make.""" + test_calls: list[SmartCall] = [] + successes: list[SmartCall] = [] + + requests = { + "getAlertTypeList": {"msg_alarm": {"name": "alert_type"}}, + "getNightVisionCapability": {"image_capability": {"name": ["supplement_lamp"]}}, + "getDeviceInfo": {"device_info": {"name": ["basic_info"]}}, + "getDetectionConfig": {"motion_detection": {"name": ["motion_det"]}}, + "getPersonDetectionConfig": {"people_detection": {"name": ["detection"]}}, + "getVehicleDetectionConfig": {"vehicle_detection": {"name": ["detection"]}}, + "getBCDConfig": {"sound_detection": {"name": ["bcd"]}}, + "getPetDetectionConfig": {"pet_detection": {"name": ["detection"]}}, + "getBarkDetectionConfig": {"bark_detection": {"name": ["detection"]}}, + "getMeowDetectionConfig": {"meow_detection": {"name": ["detection"]}}, + "getGlassDetectionConfig": {"glass_detection": {"name": ["detection"]}}, + "getTamperDetectionConfig": {"tamper_detection": {"name": "tamper_det"}}, + "getLensMaskConfig": {"lens_mask": {"name": ["lens_mask_info"]}}, + "getLdc": {"image": {"name": ["switch", "common"]}}, + "getLastAlarmInfo": {"msg_alarm": {"name": ["chn1_msg_alarm_info"]}}, + "getLedStatus": {"led": {"name": ["config"]}}, + "getTargetTrackConfig": {"target_track": {"name": ["target_track_info"]}}, + "getPresetConfig": {"preset": {"name": ["preset"]}}, + "getFirmwareUpdateStatus": {"cloud_config": {"name": "upgrade_status"}}, + "getMediaEncrypt": {"cet": {"name": ["media_encrypt"]}}, + "getConnectionType": {"network": {"get_connection_type": []}}, + "getAlarmConfig": {"msg_alarm": {}}, + "getAlarmPlan": {"msg_alarm_plan": {}}, + "getSirenTypeList": {"siren": {}}, + "getSirenConfig": {"siren": {}}, + "getAlertConfig": { + "msg_alarm": { + "name": ["chn1_msg_alarm_info", "capability"], + "table": ["usr_def_audio"], + } + }, + "getLightTypeList": {"msg_alarm": {}}, + "getSirenStatus": {"siren": {}}, + "getLightFrequencyInfo": {"image": {"name": "common"}}, + "getLightFrequencyCapability": {"image": {"name": "common"}}, + "getRotationStatus": {"image": {"name": ["switch"]}}, + "getNightVisionModeConfig": {"image": {"name": "switch"}}, + "getWhitelampStatus": {"image": {"get_wtl_status": ["null"]}}, + "getWhitelampConfig": {"image": {"name": "switch"}}, + "getMsgPushConfig": {"msg_push": {"name": ["chn1_msg_push_info"]}}, + "getSdCardStatus": {"harddisk_manage": {"table": ["hd_info"]}}, + "getCircularRecordingConfig": {"harddisk_manage": {"name": "harddisk"}}, + "getRecordPlan": {"record_plan": {"name": ["chn1_channel"]}}, + "getAudioConfig": {"audio_config": {"name": ["speaker", "microphone"]}}, + "getFirmwareAutoUpgradeConfig": {"auto_upgrade": {"name": ["common"]}}, + "getVideoQualities": {"video": {"name": ["main"]}}, + "getVideoCapability": {"video_capability": {"name": "main"}}, + } + test_calls = [] + for method, params in requests.items(): + test_calls.append( + SmartCall( + module=method, + request={method: params}, + should_succeed=True, + child_device_id="", + ) + ) + + # Now get the child device requests + try: + child_request = {"getChildDeviceList": {"childControl": {"start_index": 0}}} + child_response = await protocol.query(child_request) + except Exception: + _LOGGER.debug("Device does not have any children.") + else: + successes.append( + SmartCall( + module="getChildDeviceList", + request=child_request, + should_succeed=True, + child_device_id="", + ) + ) + child_list = child_response["getChildDeviceList"]["child_device_list"] + for child in child_list: + child_id = child.get("device_id") or child.get("dev_id") + if not child_id: + _LOGGER.error("Could not find child device id in %s", child) + # If category is in the child device map the protocol is smart. + if ( + category := child.get("category") + ) and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP: + child_protocol = _ChildCameraProtocolWrapper(child_id, protocol) + try: + nego_response = await child_protocol.query({"component_nego": None}) + except Exception as ex: + _LOGGER.error("Error calling component_nego: %s", ex) + continue + if "component_nego" not in nego_response: + _LOGGER.error( + "Could not find component_nego in device response: %s", + nego_response, + ) + continue + successes.append( + SmartCall( + module="component_nego", + request={"component_nego": None}, + should_succeed=True, + child_device_id=child_id, + ) + ) + child_components = { + item["id"]: item["ver_code"] + for item in nego_response["component_nego"]["component_list"] + } + for component_id, ver_code in child_components.items(): + if ( + requests := get_component_requests(component_id, ver_code) + ) is not None: + component_test_calls = [ + SmartCall( + module=component_id, + request={key: val}, + should_succeed=True, + child_device_id=child_id, + ) + for key, val in requests.items() + ] + test_calls.extend(component_test_calls) + else: + click.echo(f"Skipping {component_id}..", nl=False) + click.echo(click.style("UNSUPPORTED", fg="yellow")) + else: # Not a smart protocol device so assume camera protocol + for method, params in requests.items(): + test_calls.append( + SmartCall( + module=method, + request={method: params}, + should_succeed=True, + child_device_id=child_id, + ) + ) + finally: + await protocol.close() + return test_calls, successes + + +async def get_smart_test_calls(protocol: SmartProtocol): """Get the list of test calls to make.""" test_calls = [] successes = [] @@ -425,7 +683,7 @@ async def get_smart_test_calls(device: SmartDevice): extra_test_calls = [ SmartCall( module="temp_humidity_records", - request=SmartRequest.get_raw_request("get_temp_humidity_records"), + request=SmartRequest.get_raw_request("get_temp_humidity_records").to_dict(), should_succeed=False, child_device_id="", ), @@ -433,7 +691,7 @@ async def get_smart_test_calls(device: SmartDevice): module="trigger_logs", request=SmartRequest.get_raw_request( "get_trigger_logs", SmartRequest.GetTriggerLogsParams() - ), + ).to_dict(), should_succeed=False, child_device_id="", ), @@ -441,8 +699,8 @@ async def get_smart_test_calls(device: SmartDevice): click.echo("Testing component_nego call ..", nl=False) responses = await _make_requests_or_exit( - device, - [SmartRequest.component_nego()], + protocol, + SmartRequest.component_nego().to_dict(), "component_nego call", batch_size=1, child_device_id="", @@ -452,7 +710,7 @@ async def get_smart_test_calls(device: SmartDevice): successes.append( SmartCall( module="component_nego", - request=SmartRequest("component_nego"), + request=SmartRequest("component_nego").to_dict(), should_succeed=True, child_device_id="", ) @@ -464,8 +722,8 @@ async def get_smart_test_calls(device: SmartDevice): if "child_device" in components: child_components = await _make_requests_or_exit( - device, - [SmartRequest.get_child_device_component_list()], + protocol, + SmartRequest.get_child_device_component_list().to_dict(), "child device component list", batch_size=1, child_device_id="", @@ -473,7 +731,7 @@ async def get_smart_test_calls(device: SmartDevice): successes.append( SmartCall( module="child_component_list", - request=SmartRequest.get_child_device_component_list(), + request=SmartRequest.get_child_device_component_list().to_dict(), should_succeed=True, child_device_id="", ) @@ -481,7 +739,7 @@ async def get_smart_test_calls(device: SmartDevice): test_calls.append( SmartCall( module="child_device_list", - request=SmartRequest.get_child_device_list(), + request=SmartRequest.get_child_device_list().to_dict(), should_succeed=True, child_device_id="", ) @@ -506,11 +764,11 @@ async def get_smart_test_calls(device: SmartDevice): component_test_calls = [ SmartCall( module=component_id, - request=request, + request={key: val}, should_succeed=True, child_device_id="", ) - for request in requests + for key, val in requests.items() ] test_calls.extend(component_test_calls) else: @@ -524,7 +782,7 @@ async def get_smart_test_calls(device: SmartDevice): test_calls.append( SmartCall( module="component_nego", - request=SmartRequest("component_nego"), + request=SmartRequest("component_nego").to_dict(), should_succeed=True, child_device_id=child_device_id, ) @@ -534,11 +792,11 @@ async def get_smart_test_calls(device: SmartDevice): component_test_calls = [ SmartCall( module=component_id, - request=request, + request={key: val}, should_succeed=True, child_device_id=child_device_id, ) - for request in requests + for key, val in requests.items() ] test_calls.extend(component_test_calls) else: @@ -568,23 +826,28 @@ def get_smart_child_fixture(response): ) -async def get_smart_fixtures(device: SmartDevice, batch_size: int): +async def get_smart_fixtures( + protocol: SmartProtocol, *, discovery_info=None, batch_size: int +): """Get fixture for new TAPO style protocol.""" - test_calls, successes = await get_smart_test_calls(device) + if isinstance(protocol, SmartCameraProtocol): + test_calls, successes = await get_smart_camera_test_calls(protocol) + child_wrapper: type[_ChildProtocolWrapper | _ChildCameraProtocolWrapper] = ( + _ChildCameraProtocolWrapper + ) + else: + test_calls, successes = await get_smart_test_calls(protocol) + child_wrapper = _ChildProtocolWrapper for test_call in test_calls: click.echo(f"Testing {test_call.module}..", nl=False) try: click.echo(f"Testing {test_call}..", nl=False) if test_call.child_device_id == "": - response = await device.protocol.query( - SmartRequest._create_request_dict(test_call.request) - ) + response = await protocol.query(test_call.request) else: - cp = _ChildProtocolWrapper(test_call.child_device_id, device.protocol) - response = await cp.query( - SmartRequest._create_request_dict(test_call.request) - ) + cp = child_wrapper(test_call.child_device_id, protocol) + response = await cp.query(test_call.request) except AuthenticationError as ex: _echo_error( f"Unable to query the device due to an authentication error: {ex}", @@ -614,12 +877,12 @@ async def get_smart_fixtures(device: SmartDevice, batch_size: int): click.echo(click.style("OK", fg="green")) successes.append(test_call) finally: - await device.protocol.close() + await protocol.close() - device_requests: dict[str, list[SmartRequest]] = {} + device_requests: dict[str, dict] = {} for success in successes: - device_request = device_requests.setdefault(success.child_device_id, []) - device_request.append(success.request) + device_request = device_requests.setdefault(success.child_device_id, {}) + device_request.update(success.request) scrubbed_device_ids = { device_id: f"SCRUBBED_CHILD_DEVICE_ID_{index}" @@ -628,7 +891,7 @@ async def get_smart_fixtures(device: SmartDevice, batch_size: int): } final = await _make_requests_or_exit( - device, + protocol, device_requests[""], "all successes at once", batch_size, @@ -639,7 +902,7 @@ async def get_smart_fixtures(device: SmartDevice, batch_size: int): if child_device_id == "": continue response = await _make_requests_or_exit( - device, + protocol, requests, "all child successes at once", batch_size, @@ -649,18 +912,26 @@ async def get_smart_fixtures(device: SmartDevice, batch_size: int): if "get_device_info" in response and "device_id" in response["get_device_info"]: response["get_device_info"]["device_id"] = scrubbed # If the child is a different model to the parent create a seperate fixture + if "get_device_info" in final: + parent_model = final["get_device_info"]["model"] + elif "getDeviceInfo" in final: + parent_model = final["getDeviceInfo"]["device_info"]["basic_info"][ + "device_model" + ] + else: + raise KasaException("Cannot determine parent device model.") if ( "component_nego" in response and "get_device_info" in response and (child_model := response["get_device_info"].get("model")) - and child_model != final["get_device_info"]["model"] + and child_model != parent_model ): fixture_results.append(get_smart_child_fixture(response)) else: cd = final.setdefault("child_devices", {}) cd[scrubbed] = response - # Scrub the device ids in the parent + # Scrub the device ids in the parent for smart protocol if gc := final.get("get_child_device_component_list"): for child in gc["child_component_list"]: device_id = child["device_id"] @@ -669,20 +940,43 @@ async def get_smart_fixtures(device: SmartDevice, batch_size: int): device_id = child["device_id"] child["device_id"] = scrubbed_device_ids[device_id] + # Scrub the device ids in the parent for the smart camera protocol + if gc := final.get("getChildDeviceList"): + for child in gc["child_device_list"]: + if device_id := child.get("device_id"): + child["device_id"] = scrubbed_device_ids[device_id] + continue + if device_id := child.get("dev_id"): + child["dev_id"] = scrubbed_device_ids[device_id] + continue + _LOGGER.error("Could not find a device for the child device: %s", child) + # 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. - dr = DiscoveryResult(**device._discovery_info) # type: ignore - final["discovery_result"] = dr.dict( - by_alias=False, exclude_unset=True, exclude_none=True, exclude_defaults=True - ) + if discovery_info: + dr = DiscoveryResult(**discovery_info) # type: ignore + final["discovery_result"] = dr.dict( + by_alias=False, exclude_unset=True, exclude_none=True, exclude_defaults=True + ) click.echo("Got %s successes" % len(successes)) click.echo(click.style("## device info file ##", bold=True)) - hw_version = final["get_device_info"]["hw_ver"] - sw_version = final["get_device_info"]["fw_ver"] - model = final["discovery_result"]["device_model"] - sw_version = sw_version.split(" ", maxsplit=1)[0] + if "get_device_info" in final: + hw_version = final["get_device_info"]["hw_ver"] + sw_version = final["get_device_info"]["fw_ver"] + if discovery_info: + model = discovery_info["device_model"] + else: + model = final["get_device_info"]["model"] + "(XX)" + sw_version = sw_version.split(" ", maxsplit=1)[0] + else: + hw_version = final["getDeviceInfo"]["device_info"]["basic_info"]["hw_version"] + sw_version = final["getDeviceInfo"]["device_info"]["basic_info"]["sw_version"] + model = final["getDeviceInfo"]["device_info"]["basic_info"]["device_model"] + region = final["getDeviceInfo"]["device_info"]["basic_info"]["region"] + sw_version = sw_version.split(" ", maxsplit=1)[0] + model = f"{model}({region})" save_filename = f"{model}_{hw_version}_{sw_version}.json" copy_folder = SMART_FOLDER diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 4db1f7a1c..104ccb64b 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -356,8 +356,8 @@ def get_component_requests(component_id, ver_code): if (cr := COMPONENT_REQUESTS.get(component_id)) is None: return None if callable(cr): - return cr(ver_code) - return cr + return SmartRequest._create_request_dict(cr(ver_code)) + return SmartRequest._create_request_dict(cr) COMPONENT_REQUESTS = { diff --git a/kasa/experimental/smartcameraprotocol.py b/kasa/experimental/smartcameraprotocol.py index 384b76e90..785796160 100644 --- a/kasa/experimental/smartcameraprotocol.py +++ b/kasa/experimental/smartcameraprotocol.py @@ -6,7 +6,12 @@ from pprint import pformat as pf from typing import Any -from ..exceptions import AuthenticationError, DeviceError, _RetryableError +from ..exceptions import ( + AuthenticationError, + DeviceError, + KasaException, + _RetryableError, +) from ..json import dumps as json_dumps from ..smartprotocol import SmartProtocol from .sslaestransport import ( @@ -65,22 +70,28 @@ async def _execute_query( if isinstance(request, dict): if len(request) == 1: - multi_method = next(iter(request)) - module = next(iter(request[multi_method])) - req = { - "method": multi_method[:3], - module: request[multi_method][module], - } + method = next(iter(request)) + if method == "multipleRequest": + params = request["multipleRequest"] + req = {"method": "multipleRequest", "params": params} + elif method[:3] == "set": + params = next(iter(request[method])) + req = { + "method": method[:3], + params: request[method][params], + } + else: + return await self._execute_multiple_query(request, retry_count) else: return await self._execute_multiple_query(request, retry_count) else: # If method like getSomeThing then module will be some_thing - multi_method = request + method = request snake_name = "".join( - ["_" + i.lower() if i.isupper() else i for i in multi_method] + ["_" + i.lower() if i.isupper() else i for i in method] ).lstrip("_") - module = snake_name[4:] - req = {"method": snake_name[:3], module: {}} + params = snake_name[4:] + req = {"method": snake_name[:3], params: {}} smart_request = json_dumps(req) if debug_enabled: @@ -100,10 +111,71 @@ async def _execute_query( if "error_code" in response_data: # H200 does not return an error code - self._handle_response_error_code(response_data, multi_method) + self._handle_response_error_code(response_data, method) # TODO need to update handle response lists - if multi_method[:3] == "set": + if method[:3] == "set": return {} - return {multi_method: {module: response_data[module]}} + if method == "multipleRequest": + return {method: response_data["result"]} + return {method: {params: response_data[params]}} + + +class _ChildCameraProtocolWrapper(SmartProtocol): + """Protocol wrapper for controlling child devices. + + This is an internal class used to communicate with child devices, + and should not be used directly. + + This class overrides query() method of the protocol to modify all + outgoing queries to use ``controlChild`` command, and unwraps the + device responses before returning to the caller. + """ + + def __init__(self, device_id: str, base_protocol: SmartProtocol): + self._device_id = device_id + self._protocol = base_protocol + self._transport = base_protocol._transport + + async def query(self, request: str | dict, retry_count: int = 3) -> dict: + """Wrap request inside controlChild envelope.""" + return await self._query(request, retry_count) + + async def _query(self, request: str | dict, retry_count: int = 3) -> dict: + """Wrap request inside controlChild envelope.""" + if not isinstance(request, dict): + raise KasaException("Child requests must be dictionaries.") + requests = [] + methods = [] + for key, val in request.items(): + request = { + "method": "controlChild", + "params": { + "childControl": { + "device_id": self._device_id, + "request_data": {"method": key, "params": val}, + } + }, + } + methods.append(key) + requests.append(request) + + multipleRequest = {"multipleRequest": {"requests": requests}} + + response = await self._protocol.query(multipleRequest, retry_count) + + responses = response["multipleRequest"]["responses"] + response_dict = {} + for index_id, response in enumerate(responses): + response_data = response["result"]["response_data"] + method = methods[index_id] + self._handle_response_error_code( + response_data, method, raise_on_error=False + ) + response_dict[method] = response_data.get("result") + + return response_dict + + async def close(self) -> None: + """Do nothing as the parent owns the protocol.""" diff --git a/kasa/experimental/sslaestransport.py b/kasa/experimental/sslaestransport.py index 151cd5680..fa3e69206 100644 --- a/kasa/experimental/sslaestransport.py +++ b/kasa/experimental/sslaestransport.py @@ -507,5 +507,5 @@ def from_int(value: int) -> SmartErrorCode: ] SMART_AUTHENTICATION_ERRORS = [ - SmartErrorCode.INVALID_ARGUMENTS, + SmartErrorCode.HOMEKIT_LOGIN_FAIL, ] diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 211796949..0c2a2bba5 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -163,6 +163,7 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic ] end = len(multi_requests) + # Break the requests down as there can be a size limit step = self._multi_request_batch_size if step == 1: @@ -175,6 +176,10 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic multi_result[method] = resp["result"] return multi_result + # The SmartCameraProtocol sends requests with a length 1 as a + # multipleRequest. The SmartProtocol doesn't so will never + # raise_on_error + raise_on_error = end == 1 for batch_num, i in enumerate(range(0, end, step)): requests_step = multi_requests[i : i + step] @@ -222,7 +227,9 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic responses = response_step["result"]["responses"] for response in responses: method = response["method"] - self._handle_response_error_code(response, method, raise_on_error=False) + self._handle_response_error_code( + response, method, raise_on_error=raise_on_error + ) result = response.get("result", None) await self._handle_response_lists( result, method, retry_count=retry_count diff --git a/kasa/tests/fixtures/experimental/C210(EU)_2.0_1.4.2.json b/kasa/tests/fixtures/experimental/C210(EU)_2.0_1.4.2.json new file mode 100644 index 000000000..304a1e126 --- /dev/null +++ b/kasa/tests/fixtures/experimental/C210(EU)_2.0_1.4.2.json @@ -0,0 +1,606 @@ +{ + "discovery_result": { + "decrypted_data": { + "connect_ssid": "0000000000", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "0", + "last_alarm_type": "", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C210", + "device_name": "00000 000", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.4.2 Build 240829 Rel.54953n", + "hardware_version": "2.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "15", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Tone" + ] + } + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "2", + "rssiValue": -64, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "50", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "Home", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C210 2.0 IPC", + "device_model": "C210", + "device_name": "0000 0.0", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "2.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "40-AE-30-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.4.2 Build 240829 Rel.54953n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "msg_alarm": { + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "50" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [], + "name": [], + "position_pan": [], + "position_tilt": [], + "position_zoom": [], + "read_only": [] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "total_space_accurate": "0B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "0B", + "video_total_space_accurate": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1382", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561" + ], + "minor_stream_support": "0", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2304*1296", + "1920*1080", + "1280*720" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1382", + "bitrate_type": "vbr", + "default_bitrate": "1382", + "encode_type": "H264", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "1920*1080", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + } +} From 8a17752ae234b93ae5ce32a97e78d470e7cca8e2 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 18 Oct 2024 13:18:12 +0200 Subject: [PATCH 602/892] Add waterleak alert timestamp (#1162) The T300 reports the timestamp of the last alarm, this exposes it to consumers. --- kasa/smart/modules/waterleaksensor.py | 26 +- .../smart/child/T300(EU)_1.0_1.7.0.json | 1073 +++++++++-------- kasa/tests/smart/modules/test_waterleak.py | 3 + 3 files changed, 568 insertions(+), 534 deletions(-) diff --git a/kasa/smart/modules/waterleaksensor.py b/kasa/smart/modules/waterleaksensor.py index bba4f61dc..6b8a7ae71 100644 --- a/kasa/smart/modules/waterleaksensor.py +++ b/kasa/smart/modules/waterleaksensor.py @@ -2,10 +2,11 @@ from __future__ import annotations +from datetime import datetime from enum import Enum from ...feature import Feature -from ..smartmodule import SmartModule +from ..smartmodule import Module, SmartModule class WaterleakStatus(Enum): @@ -47,6 +48,18 @@ def _initialize_features(self): type=Feature.Type.BinarySensor, ) ) + self._add_feature( + Feature( + self._device, + id="water_alert_timestamp", + name="Last alert timestamp", + container=self, + attribute_getter="alert_timestamp", + icon="mdi:alert", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) def query(self) -> dict: """Query to execute during the update cycle.""" @@ -62,3 +75,14 @@ def status(self) -> WaterleakStatus: def alert(self) -> bool: """Return true if alarm is active.""" return self._device.sys_info["in_alarm"] + + @property + def alert_timestamp(self) -> datetime | None: + """Return timestamp of the last leak trigger.""" + # The key is not always be there, maybe if it hasn't ever been triggered? + if "trigger_timestamp" not in self._device.sys_info: + return None + + ts = self._device.sys_info["trigger_timestamp"] + tz = self._device.modules[Module.Time].timezone + return datetime.fromtimestamp(ts, tz=tz) diff --git a/kasa/tests/fixtures/smart/child/T300(EU)_1.0_1.7.0.json b/kasa/tests/fixtures/smart/child/T300(EU)_1.0_1.7.0.json index 7a6c8db3c..a08cda11a 100644 --- a/kasa/tests/fixtures/smart/child/T300(EU)_1.0_1.7.0.json +++ b/kasa/tests/fixtures/smart/child/T300(EU)_1.0_1.7.0.json @@ -1,533 +1,540 @@ -{ - "component_nego": { - "component_list": [ - { - "id": "device", - "ver_code": 2 - }, - { - "id": "quick_setup", - "ver_code": 3 - }, - { - "id": "trigger_log", - "ver_code": 1 - }, - { - "id": "time", - "ver_code": 1 - }, - { - "id": "device_local_time", - "ver_code": 1 - }, - { - "id": "account", - "ver_code": 1 - }, - { - "id": "synchronize", - "ver_code": 1 - }, - { - "id": "cloud_connect", - "ver_code": 1 - }, - { - "id": "iot_cloud", - "ver_code": 1 - }, - { - "id": "firmware", - "ver_code": 1 - }, - { - "id": "localSmart", - "ver_code": 1 - }, - { - "id": "battery_detect", - "ver_code": 1 - }, - { - "id": "sensor_alarm", - "ver_code": 1 - } - ] - }, - "get_connect_cloud_state": { - "status": 0 - }, - "get_device_info": { - "at_low_battery": false, - "avatar": "sensor_t300", - "battery_percentage": 100, - "bind_count": 1, - "category": "subg.trigger.water-leak-sensor", - "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", - "fw_ver": "1.7.0 Build 230628 Rel.194748", - "hw_id": "00000000000000000000000000000000", - "hw_ver": "1.0", - "in_alarm": false, - "jamming_rssi": -120, - "jamming_signal_level": 1, - "lastOnboardingTimestamp": 1714661760, - "mac": "98254A000000", - "model": "T300", - "nickname": "I01BU0tFRF9OQU1FIw==", - "oem_id": "00000000000000000000000000000000", - "parent_device_id": "0000000000000000000000000000000000000000", - "region": "Europe/Berlin", - "report_interval": 16, - "rssi": -49, - "signal_level": 3, - "specs": "EU", - "status": "online", - "status_follow_edge": false, - "type": "SMART.TAPOSENSOR", - "water_leak_status": "normal" - }, - "get_fw_download_state": { - "cloud_cache_seconds": 1, - "download_progress": 0, - "reboot_time": 5, - "status": 0, - "upgrade_time": 5 - }, - "get_latest_fw": { - "fw_size": 0, - "fw_ver": "1.7.0 Build 230628 Rel.194748", - "hw_id": "", - "need_to_upgrade": false, - "oem_id": "", - "release_date": "", - "release_note": "", - "type": 0 - }, - "get_temp_humidity_records": { - "local_time": 1714681045, - "past24h_humidity": [ - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000 - ], - "past24h_humidity_exception": [ - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000 - ], - "past24h_temp": [ - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000 - ], - "past24h_temp_exception": [ - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000 - ], - "temp_unit": "celsius" - }, - "get_trigger_logs": { - "logs": [ - { - "event": "waterDry", - "eventId": "18a67996-611a-a7f9-5689-6699ee55806a", - "id": 8, - "timestamp": 1714680176 - }, - { - "event": "waterLeak", - "eventId": "4b43c78d-a832-7755-cc80-a6357cd88aa3", - "id": 7, - "timestamp": 1714680174 - }, - { - "event": "waterDry", - "eventId": "2a3731ba-7f1d-2c34-38be-f5580e2d3cbc", - "id": 6, - "timestamp": 1714680172 - }, - { - "event": "waterLeak", - "eventId": "eebb19c0-2cda-215c-62f5-be13cda215c6", - "id": 5, - "timestamp": 1714676832 - } - ], - "start_id": 8, - "sum": 4 - } -} +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "sensor_alarm", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor_t300", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trigger.water-leak-sensor", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_6", + "fw_ver": "1.7.0 Build 230628 Rel.194748", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "jamming_rssi": -119, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1728470353, + "mac": "A86E84000000", + "model": "T300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "CEST", + "report_interval": 16, + "rssi": -44, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "trigger_timestamp": 1728480717, + "type": "SMART.TAPOSENSOR", + "water_leak_status": "water_dry" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.7.0 Build 230628 Rel.194748", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_temp_humidity_records": { + "local_time": 1729248928, + "past24h_humidity": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_humidity_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "waterDry", + "eventId": "d595356d-4953-5654-d59d-b92b6aca9ab2", + "id": 114, + "timestamp": 1728480717 + }, + { + "event": "waterLeak", + "eventId": "c43fc234-4ff2-ac03-d4bf-0254ff2ac03d", + "id": 113, + "timestamp": 1728480714 + }, + { + "event": "waterDry", + "eventId": "3e68c39e-b027-e405-7d41-d714fd81bfa8", + "id": 112, + "timestamp": 1728471129 + }, + { + "event": "waterLeak", + "eventId": "0e8743a9-d46a-bdde-67bb-d562b9542219", + "id": 111, + "timestamp": 1728471123 + }, + { + "event": "waterDry", + "eventId": "97708bf6-4817-b06b-0ebc-ed45917b06b0", + "id": 110, + "timestamp": 1728471106 + } + ], + "start_id": 114, + "sum": 14 + } +} diff --git a/kasa/tests/smart/modules/test_waterleak.py b/kasa/tests/smart/modules/test_waterleak.py index c48d82441..8704ae81f 100644 --- a/kasa/tests/smart/modules/test_waterleak.py +++ b/kasa/tests/smart/modules/test_waterleak.py @@ -1,3 +1,4 @@ +from datetime import datetime from enum import Enum import pytest @@ -15,6 +16,8 @@ ("feature", "prop_name", "type"), [ ("water_alert", "alert", int), + # Can be converted to 'datetime | None' after py3.9 support is dropped + ("water_alert_timestamp", "alert_timestamp", (datetime, type(None))), ("water_leak", "status", Enum), ], ) From 6ba7c4ac057f73722239bbb9719a5bcf747ea11a Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 18 Oct 2024 14:00:23 +0200 Subject: [PATCH 603/892] Convert fixtures to use unix newlines (#1177) Also, add a .gitattributes entry to let git handle this automatically for json files --- .gitattributes | 1 + kasa/tests/fixtures/KL135(US)_1.0_1.0.15.json | 186 +-- .../fixtures/smart/S505D(US)_1.0_1.1.0.json | 524 ++++---- .../smart/child/T100(EU)_1.0_1.12.0.json | 1074 ++++++++--------- .../smart/child/T110(EU)_1.0_1.8.0.json | 1052 ++++++++-------- 5 files changed, 1419 insertions(+), 1418 deletions(-) diff --git a/.gitattributes b/.gitattributes index f1815500b..01a1c5818 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ *.sh text eol=lf +*.json text eol=lf diff --git a/kasa/tests/fixtures/KL135(US)_1.0_1.0.15.json b/kasa/tests/fixtures/KL135(US)_1.0_1.0.15.json index a9e831946..8d8aa1fe9 100644 --- a/kasa/tests/fixtures/KL135(US)_1.0_1.0.15.json +++ b/kasa/tests/fixtures/KL135(US)_1.0_1.0.15.json @@ -1,93 +1,93 @@ -{ - "smartlife.iot.common.emeter": { - "get_realtime": { - "err_code": 0, - "power_mw": 0, - "total_wh": 25 - } - }, - "smartlife.iot.smartbulb.lightingservice": { - "get_light_state": { - "dft_on_state": { - "brightness": 98, - "color_temp": 6500, - "hue": 28, - "mode": "normal", - "saturation": 72 - }, - "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": "1.0", - "is_color": 1, - "is_dimmable": 1, - "is_factory": false, - "is_variable_color_temp": 1, - "latitude_i": 0, - "light_state": { - "dft_on_state": { - "brightness": 98, - "color_temp": 6500, - "hue": 28, - "mode": "normal", - "saturation": 72 - }, - "on_off": 0 - }, - "longitude_i": 0, - "mic_mac": "000000000000", - "mic_type": "IOT.SMARTBULB", - "model": "KL135(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": -41, - "status": "new", - "sw_ver": "1.0.15 Build 240429 Rel.154143" - } - } -} +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 0, + "total_wh": 25 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "dft_on_state": { + "brightness": 98, + "color_temp": 6500, + "hue": 28, + "mode": "normal", + "saturation": 72 + }, + "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": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "light_state": { + "dft_on_state": { + "brightness": 98, + "color_temp": 6500, + "hue": 28, + "mode": "normal", + "saturation": 72 + }, + "on_off": 0 + }, + "longitude_i": 0, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL135(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": -41, + "status": "new", + "sw_ver": "1.0.15 Build 240429 Rel.154143" + } + } +} diff --git a/kasa/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json b/kasa/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json index 97486d456..6adac9865 100644 --- a/kasa/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json +++ b/kasa/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json @@ -1,262 +1,262 @@ -{ - "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": "localSmart", - "ver_code": 1 - }, - { - "id": "overheat_protection", - "ver_code": 1 - }, - { - "id": "matter", - "ver_code": 2 - } - ] - }, - "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "S505D(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": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "matter", - "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": 1 - }, - "get_countdown_rules": { - "countdown_rule_max_count": 1, - "enable": false, - "rule_list": [] - }, - "get_device_info": { - "avatar": "switch_s500d", - "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.1.0 Build 231024 Rel.201030", - "has_set_location_info": false, - "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": "S505D", - "nickname": "I01BU0tFRF9OQU1FIw==", - "oem_id": "00000000000000000000000000000000", - "on_time": 0, - "overheat_status": "normal", - "region": "America/Chicago", - "rssi": -39, - "signal_level": 3, - "specs": "", - "ssid": "I01BU0tFRF9TU0lEIw==", - "time_diff": -360, - "type": "SMART.TAPOSWITCH" - }, - "get_device_time": { - "region": "America/Chicago", - "time_diff": -360, - "timestamp": 952082825 - }, - "get_fw_download_state": { - "auto_upgrade": false, - "download_progress": 0, - "reboot_time": 5, - "status": 0, - "upgrade_time": 5 - }, - "get_inherit_info": null, - "get_led_info": { - "led_rule": "always", - "led_status": true, - "night_mode": { - "end_time": 420, - "night_mode_type": "sunrise_sunset", - "start_time": 1140, - "sunrise_offset": 0, - "sunset_offset": 0 - } - }, - "get_matter_setup_info": { - "setup_code": "00000000000", - "setup_payload": "00:-00000000000000.000" - }, - "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": [], - "start_index": 0, - "sum": 0, - "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": "S505D", - "device_type": "SMART.TAPOSWITCH", - "is_klap": true - } - } -} +{ + "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": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "S505D(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": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "matter", + "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": 1 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "switch_s500d", + "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.1.0 Build 231024 Rel.201030", + "has_set_location_info": false, + "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": "S505D", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/Chicago", + "rssi": -39, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.TAPOSWITCH" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 952082825 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:-00000000000000.000" + }, + "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": [], + "start_index": 0, + "sum": 0, + "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": "S505D", + "device_type": "SMART.TAPOSWITCH", + "is_klap": true + } + } +} diff --git a/kasa/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json b/kasa/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json index 00e46787c..0103fbdcf 100644 --- a/kasa/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json +++ b/kasa/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json @@ -1,537 +1,537 @@ -{ - "component_nego": { - "component_list": [ - { - "id": "device", - "ver_code": 2 - }, - { - "id": "quick_setup", - "ver_code": 3 - }, - { - "id": "trigger_log", - "ver_code": 1 - }, - { - "id": "time", - "ver_code": 1 - }, - { - "id": "device_local_time", - "ver_code": 1 - }, - { - "id": "account", - "ver_code": 1 - }, - { - "id": "synchronize", - "ver_code": 1 - }, - { - "id": "cloud_connect", - "ver_code": 1 - }, - { - "id": "iot_cloud", - "ver_code": 1 - }, - { - "id": "firmware", - "ver_code": 1 - }, - { - "id": "localSmart", - "ver_code": 1 - }, - { - "id": "battery_detect", - "ver_code": 1 - }, - { - "id": "sensitivity", - "ver_code": 1 - } - ] - }, - "get_connect_cloud_state": { - "status": 0 - }, - "get_device_info": { - "at_low_battery": false, - "avatar": "sensor", - "bind_count": 1, - "category": "subg.trigger.motion-sensor", - "detected": false, - "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", - "fw_ver": "1.12.0 Build 230512 Rel.103011", - "hw_id": "00000000000000000000000000000000", - "hw_ver": "1.0", - "jamming_rssi": -118, - "jamming_signal_level": 1, - "lastOnboardingTimestamp": 1703860126, - "mac": "E4FAC4000000", - "model": "T100", - "nickname": "I01BU0tFRF9OQU1FIw==", - "oem_id": "00000000000000000000000000000000", - "parent_device_id": "0000000000000000000000000000000000000000", - "region": "Europe/Berlin", - "report_interval": 60, - "rssi": -73, - "signal_level": 2, - "specs": "EU", - "status": "online", - "status_follow_edge": false, - "type": "SMART.TAPOSENSOR" - }, - "get_fw_download_state": { - "cloud_cache_seconds": 1, - "download_progress": 0, - "reboot_time": 5, - "status": 0, - "upgrade_time": 5 - }, - "get_latest_fw": { - "fw_size": 0, - "fw_ver": "1.12.0 Build 230512 Rel.103011", - "hw_id": "", - "need_to_upgrade": false, - "oem_id": "", - "release_date": "", - "release_note": "", - "type": 0 - }, - "get_temp_humidity_records": { - "local_time": 1721645923, - "past24h_humidity": [ - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000 - ], - "past24h_humidity_exception": [ - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000 - ], - "past24h_temp": [ - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000 - ], - "past24h_temp_exception": [ - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000 - ], - "temp_unit": "celsius" - }, - "get_trigger_logs": { - "logs": [ - { - "event": "motion", - "eventId": "f883b62c-e18f-30ef-883b-62ce18f30ef8", - "id": 28763, - "timestamp": 1721643865 - }, - { - "event": "motion", - "eventId": "c5157545-55d5-157d-4157-54555d5157d4", - "id": 28748, - "timestamp": 1721630821 - }, - { - "event": "motion", - "eventId": "1b587961-edab-08d1-b587-961edab08d1b", - "id": 28746, - "timestamp": 1721629441 - }, - { - "event": "motion", - "eventId": "8ac5e271-3894-c269-bc5e-2713894c269b", - "id": 28738, - "timestamp": 1721622777 - }, - { - "event": "motion", - "eventId": "1ef8037e-c097-bc21-ef80-37ec097bc21e", - "id": 28722, - "timestamp": 1721596432 - } - ], - "start_id": 28763, - "sum": 86 - } -} +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "sensitivity", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor", + "bind_count": 1, + "category": "subg.trigger.motion-sensor", + "detected": false, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "fw_ver": "1.12.0 Build 230512 Rel.103011", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -118, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1703860126, + "mac": "E4FAC4000000", + "model": "T100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 60, + "rssi": -73, + "signal_level": 2, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.12.0 Build 230512 Rel.103011", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_temp_humidity_records": { + "local_time": 1721645923, + "past24h_humidity": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_humidity_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "motion", + "eventId": "f883b62c-e18f-30ef-883b-62ce18f30ef8", + "id": 28763, + "timestamp": 1721643865 + }, + { + "event": "motion", + "eventId": "c5157545-55d5-157d-4157-54555d5157d4", + "id": 28748, + "timestamp": 1721630821 + }, + { + "event": "motion", + "eventId": "1b587961-edab-08d1-b587-961edab08d1b", + "id": 28746, + "timestamp": 1721629441 + }, + { + "event": "motion", + "eventId": "8ac5e271-3894-c269-bc5e-2713894c269b", + "id": 28738, + "timestamp": 1721622777 + }, + { + "event": "motion", + "eventId": "1ef8037e-c097-bc21-ef80-37ec097bc21e", + "id": 28722, + "timestamp": 1721596432 + } + ], + "start_id": 28763, + "sum": 86 + } +} diff --git a/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json b/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json index acf7ae889..0393e18bf 100644 --- a/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json +++ b/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json @@ -1,526 +1,526 @@ -{ - "component_nego": { - "component_list": [ - { - "id": "device", - "ver_code": 2 - }, - { - "id": "quick_setup", - "ver_code": 3 - }, - { - "id": "trigger_log", - "ver_code": 1 - }, - { - "id": "time", - "ver_code": 1 - }, - { - "id": "device_local_time", - "ver_code": 1 - }, - { - "id": "account", - "ver_code": 1 - }, - { - "id": "synchronize", - "ver_code": 1 - }, - { - "id": "cloud_connect", - "ver_code": 1 - }, - { - "id": "iot_cloud", - "ver_code": 1 - }, - { - "id": "firmware", - "ver_code": 1 - }, - { - "id": "localSmart", - "ver_code": 1 - }, - { - "id": "battery_detect", - "ver_code": 1 - } - ] - }, - "get_connect_cloud_state": { - "status": 0 - }, - "get_device_info": { - "at_low_battery": false, - "avatar": "sensor_t110", - "bind_count": 1, - "category": "subg.trigger.contact-sensor", - "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", - "fw_ver": "1.8.0 Build 220728 Rel.160024", - "hw_id": "00000000000000000000000000000000", - "hw_ver": "1.0", - "jamming_rssi": -113, - "jamming_signal_level": 1, - "lastOnboardingTimestamp": 1714661626, - "mac": "E4FAC4000000", - "model": "T110", - "nickname": "I01BU0tFRF9OQU1FIw==", - "oem_id": "00000000000000000000000000000000", - "open": false, - "parent_device_id": "0000000000000000000000000000000000000000", - "region": "Europe/Berlin", - "report_interval": 16, - "rssi": -54, - "signal_level": 3, - "specs": "EU", - "status": "online", - "status_follow_edge": false, - "type": "SMART.TAPOSENSOR" - }, - "get_fw_download_state": { - "cloud_cache_seconds": 1, - "download_progress": 30, - "reboot_time": 5, - "status": 4, - "upgrade_time": 5 - }, - "get_latest_fw": { - "fw_ver": "1.9.0 Build 230704 Rel.154531", - "hw_id": "00000000000000000000000000000000", - "need_to_upgrade": true, - "oem_id": "00000000000000000000000000000000", - "release_date": "2023-10-30", - "release_note": "Modifications and Bug Fixes:\n1. Reduced power consumption.\n2. Fixed some minor bugs.", - "type": 2 - }, - "get_temp_humidity_records": { - "local_time": 1714681046, - "past24h_humidity": [ - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000 - ], - "past24h_humidity_exception": [ - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000 - ], - "past24h_temp": [ - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000 - ], - "past24h_temp_exception": [ - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000, - -1000 - ], - "temp_unit": "celsius" - }, - "get_trigger_logs": { - "logs": [ - { - "event": "close", - "eventId": "8140289c-c66b-bdd6-63b9-542299442299", - "id": 4, - "timestamp": 1714661714 - }, - { - "event": "open", - "eventId": "fb4e1439-2f2c-a5e1-c35a-9e7c0d35a1e3", - "id": 3, - "timestamp": 1714661710 - }, - { - "event": "close", - "eventId": "ddee7733-1180-48ac-56a3-512018048ac5", - "id": 2, - "timestamp": 1714661657 - }, - { - "event": "open", - "eventId": "ab80951f-da38-49f9-21c5-bf025c7b606d", - "id": 1, - "timestamp": 1714661638 - } - ], - "start_id": 4, - "sum": 4 - } -} +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor_t110", + "bind_count": 1, + "category": "subg.trigger.contact-sensor", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.8.0 Build 220728 Rel.160024", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1714661626, + "mac": "E4FAC4000000", + "model": "T110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "open": false, + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 16, + "rssi": -54, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 30, + "reboot_time": 5, + "status": 4, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_ver": "1.9.0 Build 230704 Rel.154531", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-10-30", + "release_note": "Modifications and Bug Fixes:\n1. Reduced power consumption.\n2. Fixed some minor bugs.", + "type": 2 + }, + "get_temp_humidity_records": { + "local_time": 1714681046, + "past24h_humidity": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_humidity_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "close", + "eventId": "8140289c-c66b-bdd6-63b9-542299442299", + "id": 4, + "timestamp": 1714661714 + }, + { + "event": "open", + "eventId": "fb4e1439-2f2c-a5e1-c35a-9e7c0d35a1e3", + "id": 3, + "timestamp": 1714661710 + }, + { + "event": "close", + "eventId": "ddee7733-1180-48ac-56a3-512018048ac5", + "id": 2, + "timestamp": 1714661657 + }, + { + "event": "open", + "eventId": "ab80951f-da38-49f9-21c5-bf025c7b606d", + "id": 1, + "timestamp": 1714661638 + } + ], + "start_id": 4, + "sum": 4 + } +} From d5450d89ff2dd5e08dc1cf8a70b089a14091dd6a Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 18 Oct 2024 14:02:08 +0100 Subject: [PATCH 604/892] Add H200 experimental fixture (#1180) --- .../experimental/H200(US)_1.0_1.3.6.json | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 kasa/tests/fixtures/experimental/H200(US)_1.0_1.3.6.json diff --git a/kasa/tests/fixtures/experimental/H200(US)_1.0_1.3.6.json b/kasa/tests/fixtures/experimental/H200(US)_1.0_1.3.6.json new file mode 100644 index 000000000..c76662960 --- /dev/null +++ b/kasa/tests/fixtures/experimental/H200(US)_1.0_1.3.6.json @@ -0,0 +1,292 @@ +{ + "getAlertConfig": {}, + "getChildDeviceList": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 51, + "current_humidity_exception": 0, + "current_temp": 19.4, + "current_temp_exception": -0.6, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.5.0 Build 230105 Rel.180832", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1724637745, + "mac": "F0A731000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -36, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "sensor_t315", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 53, + "current_humidity_exception": 0, + "current_temp": 18.3, + "current_temp_exception": -0.7, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.8.0 Build 230921 Rel.091519", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -114, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1724637369, + "mac": "202351000000", + "model": "T315", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -50, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "outdoor", + "bind_count": 1, + "category": "subg.trigger.contact-sensor", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "fw_ver": "1.9.0 Build 230704 Rel.154559", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -116, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1724635267, + "mac": "A86E84000000", + "model": "T110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "open": false, + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -55, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "button", + "bind_count": 1, + "category": "subg.trigger.button", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "fw_ver": "1.12.0 Build 231121 Rel.092508", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -114, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1724636047, + "mac": "3C52A1000000", + "model": "S200B", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -38, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "button", + "bind_count": 1, + "category": "subg.trigger.button", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "fw_ver": "1.12.0 Build 231121 Rel.092508", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -104, + "jamming_signal_level": 2, + "lastOnboardingTimestamp": 1724636886, + "mac": "98254A000000", + "model": "S200B", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -36, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + } + ], + "start_index": 0, + "sum": 5 + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getConnectionType": { + "link_type": "ethernet" + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "hub_h200", + "bind_status": true, + "child_num": 0, + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "H200 1.0", + "device_model": "H200", + "device_name": "0000 0.0", + "device_type": "SMART.TAPOHUB", + "has_set_location_info": 1, + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "latitude": 0, + "local_ip": "127.0.0.123", + "longitude": 0, + "mac": "24-2F-D0-00-00-00", + "need_sync_sha1_password": 0, + "oem_id": "00000000000000000000000000000000", + "product_name": "Tapo Smart Hub", + "region": "US", + "status": "configured", + "sw_version": "1.3.6 Build 20240829 rel.71119" + }, + "info": { + "avatar": "hub_h200", + "bind_status": true, + "child_num": 0, + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "H200 1.0", + "device_model": "H200", + "device_name": "0000 0.0", + "device_type": "SMART.TAPOHUB", + "has_set_location_info": 1, + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "latitude": 0, + "local_ip": "127.0.0.123", + "longitude": 0, + "mac": "24-2F-D0-00-00-00", + "need_sync_sha1_password": 0, + "oem_id": "00000000000000000000000000000000", + "product_name": "Tapo Smart Hub", + "region": "US", + "status": "configured", + "sw_version": "1.3.6 Build 20240829 rel.71119" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": 120, + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLedStatus": { + "led": { + "config": { + ".name": "config", + ".type": "led", + "enabled": "on" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "detect_status": "offline", + "disk_name": "1", + "loop_record_status": "1", + "status": "offline" + } + } + ] + } + }, + "getSirenConfig": { + "duration": 300, + "siren_type": "Doorbell Ring 3", + "volume": "6" + }, + "getSirenStatus": { + "status": "off", + "time_left": 0 + }, + "getSirenTypeList": { + "siren_type_list": [ + "Doorbell Ring 1", + "Doorbell Ring 2", + "Doorbell Ring 3", + "Doorbell Ring 4", + "Doorbell Ring 5", + "Doorbell Ring 6", + "Doorbell Ring 7", + "Doorbell Ring 8", + "Doorbell Ring 9", + "Doorbell Ring 10", + "Phone Ring", + "Alarm 1", + "Alarm 2", + "Alarm 3", + "Alarm 4", + "Dripping Tap", + "Alarm 5", + "Connection 1", + "Connection 2" + ] + } +} From 8d0a5c69ef055f0abf7507b4d5a796e939d946c5 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 18 Oct 2024 16:03:57 +0200 Subject: [PATCH 605/892] Enforce EOLs for *.rst and *.md (#1178) Looks like everything was fine, but let's do this nevertheless. --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitattributes b/.gitattributes index 01a1c5818..581a1cb4e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,4 @@ *.sh text eol=lf *.json text eol=lf +*.md text eol=lf +*.rst text eol=lf From 53fafc3994800602db9db3411adff0bcc1f23060 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 18 Oct 2024 15:05:53 +0100 Subject: [PATCH 606/892] Add T110(US), T310(US) and T315(US) sensor fixtures (#1179) Many thanks to @SirWaddles for the fixtures! --- SUPPORTED.md | 3 + .../smart/child/T110(US)_1.0_1.9.0.json | 105 ++++++++++++++ .../smart/child/T310(US)_1.0_1.5.0.json | 133 +++++++++++++++++ .../smart/child/T315(US)_1.0_1.8.0.json | 134 ++++++++++++++++++ 4 files changed, 375 insertions(+) create mode 100644 kasa/tests/fixtures/smart/child/T110(US)_1.0_1.9.0.json create mode 100644 kasa/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json create mode 100644 kasa/tests/fixtures/smart/child/T315(US)_1.0_1.8.0.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 7273735c6..ce0d5a60a 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -240,12 +240,15 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **T110** - Hardware: 1.0 (EU) / Firmware: 1.8.0 - Hardware: 1.0 (EU) / Firmware: 1.9.0 + - Hardware: 1.0 (US) / Firmware: 1.9.0 - **T300** - Hardware: 1.0 (EU) / Firmware: 1.7.0 - **T310** - Hardware: 1.0 (EU) / Firmware: 1.5.0 + - Hardware: 1.0 (US) / Firmware: 1.5.0 - **T315** - Hardware: 1.0 (EU) / Firmware: 1.7.0 + - Hardware: 1.0 (US) / Firmware: 1.8.0 diff --git a/kasa/tests/fixtures/smart/child/T110(US)_1.0_1.9.0.json b/kasa/tests/fixtures/smart/child/T110(US)_1.0_1.9.0.json new file mode 100644 index 000000000..73aeeb1a2 --- /dev/null +++ b/kasa/tests/fixtures/smart/child/T110(US)_1.0_1.9.0.json @@ -0,0 +1,105 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + } + ] + }, + "get_auto_update_info": -1001, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "outdoor", + "bind_count": 1, + "category": "subg.trigger.contact-sensor", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "fw_ver": "1.9.0 Build 230704 Rel.154559", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -116, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1724635267, + "mac": "A86E84000000", + "model": "T110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "open": false, + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -55, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_device_time": -1001, + "get_device_usage": -1001, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.9.0 Build 230704 Rel.154559", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "qs_component_nego": -1001 +} diff --git a/kasa/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json b/kasa/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json new file mode 100644 index 000000000..518e4eb73 --- /dev/null +++ b/kasa/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json @@ -0,0 +1,133 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ] + }, + "get_auto_update_info": -1001, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 51, + "current_humidity_exception": 0, + "current_temp": 19.4, + "current_temp_exception": -0.6, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.5.0 Build 230105 Rel.180832", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1724637745, + "mac": "F0A731000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -36, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + "get_device_time": -1001, + "get_device_usage": -1001, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.5.0 Build 230105 Rel.180832", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "qs_component_nego": -1001 +} diff --git a/kasa/tests/fixtures/smart/child/T315(US)_1.0_1.8.0.json b/kasa/tests/fixtures/smart/child/T315(US)_1.0_1.8.0.json new file mode 100644 index 000000000..33438bb2d --- /dev/null +++ b/kasa/tests/fixtures/smart/child/T315(US)_1.0_1.8.0.json @@ -0,0 +1,134 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ] + }, + "get_auto_update_info": -1001, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor_t315", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 53, + "current_humidity_exception": 0, + "current_temp": 18.3, + "current_temp_exception": -0.7, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.8.0 Build 230921 Rel.091519", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -114, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1724637369, + "mac": "202351000000", + "model": "T315", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -50, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + "get_device_time": -1001, + "get_device_usage": -1001, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.8.0 Build 230921 Rel.091519", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "qs_component_nego": -1001 +} From 852116795c2aa84da2fb2b3a139f08f72502a332 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 22 Oct 2024 12:15:08 +0100 Subject: [PATCH 607/892] Add discovery list command to cli (#1183) Report discovered devices in a concise table format. --- kasa/cli/discover.py | 85 +++++++++++++++++++++++++++++++----------- kasa/tests/test_cli.py | 49 ++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 21 deletions(-) diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index 78f426f5d..aac2f96d3 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -20,24 +20,21 @@ from .common import echo -@click.command() +@click.group(invoke_without_command=True) @click.pass_context async def discover(ctx): """Discover devices in the network.""" - target = ctx.parent.params["target"] - username = ctx.parent.params["username"] - password = ctx.parent.params["password"] - discovery_timeout = ctx.parent.params["discovery_timeout"] - timeout = ctx.parent.params["timeout"] - host = ctx.parent.params["host"] - port = ctx.parent.params["port"] + if ctx.invoked_subcommand is None: + return await ctx.invoke(detail) - credentials = Credentials(username, password) if username and password else None - sem = asyncio.Semaphore() - discovered = dict() +@discover.command() +@click.pass_context +async def detail(ctx): + """Discover devices in the network using udp broadcasts.""" unsupported = [] auth_failed = [] + sem = asyncio.Semaphore() async def print_unsupported(unsupported_exception: UnsupportedDeviceError): unsupported.append(unsupported_exception) @@ -65,9 +62,61 @@ async def print_discovered(dev: Device): else: ctx.parent.obj = dev await ctx.parent.invoke(state) - discovered[dev.host] = dev.internal_state echo() + discovered = await _discover(ctx, print_discovered, print_unsupported) + if ctx.parent.parent.params["host"]: + return discovered + + echo(f"Found {len(discovered)} devices") + if unsupported: + echo(f"Found {len(unsupported)} unsupported devices") + if auth_failed: + echo(f"Found {len(auth_failed)} devices that failed to authenticate") + + return discovered + + +@discover.command() +@click.pass_context +async def list(ctx): + """List devices in the network in a table using udp broadcasts.""" + sem = asyncio.Semaphore() + + async def print_discovered(dev: Device): + cparams = dev.config.connection_type + infostr = ( + f"{dev.host:<15} {cparams.device_family.value:<20} " + f"{cparams.encryption_type.value:<7}" + ) + async with sem: + try: + await dev.update() + except AuthenticationError: + echo(f"{infostr} - Authentication failed") + else: + echo(f"{infostr} {dev.alias}") + + async def print_unsupported(unsupported_exception: UnsupportedDeviceError): + if res := unsupported_exception.discovery_result: + echo(f"{res.get('ip'):<15} UNSUPPORTED DEVICE") + + echo(f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}") + return await _discover(ctx, print_discovered, print_unsupported, do_echo=False) + + +async def _discover(ctx, print_discovered, print_unsupported, *, do_echo=True): + params = ctx.parent.parent.params + target = params["target"] + username = params["username"] + password = params["password"] + discovery_timeout = params["discovery_timeout"] + timeout = params["timeout"] + host = params["host"] + port = params["port"] + + credentials = Credentials(username, password) if username and password else None + if host: echo(f"Discovering device {host} for {discovery_timeout} seconds") return await Discover.discover_single( @@ -78,8 +127,8 @@ async def print_discovered(dev: Device): discovery_timeout=discovery_timeout, on_unsupported=print_unsupported, ) - - echo(f"Discovering devices on {target} for {discovery_timeout} seconds") + if do_echo: + echo(f"Discovering devices on {target} for {discovery_timeout} seconds") discovered_devices = await Discover.discover( target=target, discovery_timeout=discovery_timeout, @@ -93,13 +142,7 @@ async def print_discovered(dev: Device): for device in discovered_devices.values(): await device.protocol.close() - echo(f"Found {len(discovered)} devices") - if unsupported: - echo(f"Found {len(unsupported)} unsupported devices") - if auth_failed: - echo(f"Found {len(auth_failed)} devices that failed to authenticate") - - return discovered + return discovered_devices def _echo_dictionary(discovery_info: dict): diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index f22286e58..8d830f083 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -104,6 +104,55 @@ async def test_update_called_by_cli(dev, mocker, runner, device_family, encrypt_ update.assert_called() +async def test_list_devices(discovery_mock, runner): + """Test that device update is called on main.""" + res = await runner.invoke( + cli, + ["--username", "foo", "--password", "bar", "discover", "list"], + catch_exceptions=False, + ) + assert res.exit_code == 0 + header = f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}" + row = f"{discovery_mock.ip:<15} {discovery_mock.device_type:<20} {discovery_mock.encrypt_type:<7}" + assert header in res.output + assert row in res.output + + +@new_discovery +async def test_list_auth_failed(discovery_mock, mocker, runner): + """Test that device update is called on main.""" + device_class = Discover._get_device_class(discovery_mock.discovery_data) + mocker.patch.object( + device_class, + "update", + side_effect=AuthenticationError("Failed to authenticate"), + ) + res = await runner.invoke( + cli, + ["--username", "foo", "--password", "bar", "discover", "list"], + catch_exceptions=False, + ) + assert res.exit_code == 0 + header = f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}" + row = f"{discovery_mock.ip:<15} {discovery_mock.device_type:<20} {discovery_mock.encrypt_type:<7} - Authentication failed" + assert header in res.output + assert row in res.output + + +async def test_list_unsupported(unsupported_device_info, runner): + """Test that device update is called on main.""" + res = await runner.invoke( + cli, + ["--username", "foo", "--password", "bar", "discover", "list"], + catch_exceptions=False, + ) + assert res.exit_code == 0 + header = f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}" + row = f"{'127.0.0.1':<15} UNSUPPORTED DEVICE" + assert header in res.output + assert row in res.output + + async def test_sysinfo(dev: Device, runner): res = await runner.invoke(sysinfo, obj=dev) assert "System info" in res.output From 3c865b5fb6acdbbe1c6cebbc8e91e782455b2939 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 22 Oct 2024 14:33:46 +0100 Subject: [PATCH 608/892] Add try_connect_all to allow initialisation without udp broadcast (#1171) - Try all valid combinations of protocol/transport/device class and attempt to connect. - Add cli command `discover config` to return the connection options after connecting via `try_connect_all`. - The cli command does not return the actual device for processing as this is not a recommended way to regularly connect to devices. --- kasa/cli/discover.py | 37 +++++++++++++++++- kasa/cli/main.py | 6 ++- kasa/discover.py | 60 +++++++++++++++++++++++++++++ kasa/tests/test_cli.py | 75 ++++++++++++++++++++++++++++++++++++ kasa/tests/test_discovery.py | 56 ++++++++++++++++++++++++++- 5 files changed, 231 insertions(+), 3 deletions(-) diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index aac2f96d3..deb28b4d9 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -17,7 +17,7 @@ ) from kasa.discover import DiscoveryResult -from .common import echo +from .common import echo, error @click.group(invoke_without_command=True) @@ -145,6 +145,41 @@ async def _discover(ctx, print_discovered, print_unsupported, *, do_echo=True): return discovered_devices +@discover.command() +@click.pass_context +async def config(ctx): + """Bypass udp discovery and try to show connection config for a device. + + Bypasses udp discovery and shows the parameters required to connect + directly to the device. + """ + params = ctx.parent.parent.params + username = params["username"] + password = params["password"] + timeout = params["timeout"] + host = params["host"] + port = params["port"] + + if not host: + error("--host option must be supplied to discover config") + + credentials = Credentials(username, password) if username and password else None + + dev = await Discover.try_connect_all( + host, credentials=credentials, timeout=timeout, port=port + ) + if dev: + cparams = dev.config.connection_type + echo("Managed to connect, cli options to connect are:") + echo( + f"--device-family {cparams.device_family.value} " + f"--encrypt-type {cparams.encryption_type.value} " + f"{'--https' if cparams.https else '--no-https'}" + ) + else: + error(f"Unable to connect to {host}") + + def _echo_dictionary(discovery_info: dict): echo("\t[bold]== Discovery information ==[/bold]") for key, value in discovery_info.items(): diff --git a/kasa/cli/main.py b/kasa/cli/main.py index 7ba65155d..b721e984e 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -39,6 +39,7 @@ ] ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] +DEFAULT_TARGET = "255.255.255.255" def _legacy_type_to_class(_type): @@ -115,7 +116,7 @@ def _legacy_type_to_class(_type): @click.option( "--target", envvar="KASA_TARGET", - default="255.255.255.255", + default=DEFAULT_TARGET, required=False, show_default=True, help="The broadcast address to be used for discovery.", @@ -256,6 +257,9 @@ async def cli( ctx.obj = object() return + if target != DEFAULT_TARGET and host: + error("--target is not a valid option for single host discovery") + if experimental: from kasa.experimental.enabled import Enabled diff --git a/kasa/discover.py b/kasa/discover.py index 79c162161..e7a3946c5 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -526,6 +526,66 @@ async def discover_single( else: raise TimeoutError(f"Timed out getting discovery response for {host}") + @staticmethod + async def try_connect_all( + host: str, + *, + port: int | None = None, + timeout: int | None = None, + credentials: Credentials | None = None, + ) -> Device | None: + """Try to connect directly to a device with all possible parameters. + + This method can be used when udp is not working due to network issues. + After succesfully connecting use the device config and + :meth:`Device.connect()` for future connections. + + :param host: Hostname of device to query + :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. + username and password are ignored if provided. + """ + from .device_factory import _connect + + candidates = { + (type(protocol), type(protocol._transport), device_class): ( + protocol, + config, + ) + for encrypt in Device.EncryptionType + for device_family in Device.Family + for https in (True, False) + if ( + conn_params := DeviceConnectionParameters( + device_family=device_family, + encryption_type=encrypt, + https=https, + ) + ) + and ( + config := DeviceConfig( + host=host, + connection_type=conn_params, + timeout=timeout, + port_override=port, + credentials=credentials, + ) + ) + and (protocol := get_protocol(config)) + and (device_class := get_device_class_from_family(device_family.value)) + } + for protocol, config in candidates.values(): + try: + dev = await _connect(config, protocol) + except Exception: + _LOGGER.debug("Unable to connect with %s", protocol) + else: + return dev + finally: + await protocol.close() + return None + @staticmethod def _get_device_class(info: dict) -> type[Device]: """Find SmartDevice subclass for device described by passed data.""" diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 8d830f083..e1861a29f 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -1158,3 +1158,78 @@ async def test_cli_child_commands( assert res.exit_code == 0 parent_update_spy.assert_called_once() assert dev.children[0].update == child_update_method + + +async def test_discover_config(dev: Device, mocker, runner): + """Test that device config is returned.""" + host = "127.0.0.1" + mocker.patch("kasa.discover.Discover.try_connect_all", return_value=dev) + + res = await runner.invoke( + cli, + [ + "--username", + "foo", + "--password", + "bar", + "--host", + host, + "discover", + "config", + ], + catch_exceptions=False, + ) + assert res.exit_code == 0 + cparam = dev.config.connection_type + expected = f"--device-family {cparam.device_family.value} --encrypt-type {cparam.encryption_type.value} {'--https' if cparam.https else '--no-https'}" + assert expected in res.output + + +async def test_discover_config_invalid(mocker, runner): + """Test the device config command with invalids.""" + host = "127.0.0.1" + mocker.patch("kasa.discover.Discover.try_connect_all", return_value=None) + + res = await runner.invoke( + cli, + [ + "--username", + "foo", + "--password", + "bar", + "--host", + host, + "discover", + "config", + ], + catch_exceptions=False, + ) + assert res.exit_code == 1 + assert f"Unable to connect to {host}" in res.output + + res = await runner.invoke( + cli, + ["--username", "foo", "--password", "bar", "discover", "config"], + catch_exceptions=False, + ) + assert res.exit_code == 1 + assert "--host option must be supplied to discover config" in res.output + + res = await runner.invoke( + cli, + [ + "--username", + "foo", + "--password", + "bar", + "--host", + host, + "--target", + "127.0.0.2", + "discover", + "config", + ], + catch_exceptions=False, + ) + assert res.exit_code == 1 + assert "--target is not a valid option for single host discovery" in res.output diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 8163d4c1e..d6e0a0db9 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -20,9 +20,15 @@ Device, DeviceType, Discover, + IotProtocol, KasaException, ) from kasa.aestransport import AesEncyptionSession +from kasa.device_factory import ( + get_device_class_from_family, + get_device_class_from_sys_info, + get_protocol, +) from kasa.deviceconfig import ( DeviceConfig, DeviceConnectionParameters, @@ -35,7 +41,7 @@ ) from kasa.exceptions import AuthenticationError, UnsupportedDeviceError from kasa.iot import IotDevice -from kasa.xortransport import XorEncryption +from kasa.xortransport import XorEncryption, XorTransport from .conftest import ( bulb_iot, @@ -647,3 +653,51 @@ async def test_discovery_decryption(): dr = DiscoveryResult(**info) Discover._decrypt_discovery_data(dr) assert dr.decrypted_data == data_dict + + +async def test_discover_try_connect_all(discovery_mock, mocker): + """Test that device update is called on main.""" + if "result" in discovery_mock.discovery_data: + dev_class = get_device_class_from_family(discovery_mock.device_type) + cparams = DeviceConnectionParameters.from_values( + discovery_mock.device_type, + discovery_mock.encrypt_type, + discovery_mock.login_version, + False, + ) + protocol = get_protocol( + DeviceConfig(discovery_mock.ip, connection_type=cparams) + ) + protocol_class = protocol.__class__ + transport_class = protocol._transport.__class__ + else: + dev_class = get_device_class_from_sys_info(discovery_mock.discovery_data) + protocol_class = IotProtocol + transport_class = XorTransport + + async def _query(self, *args, **kwargs): + if ( + self.__class__ is protocol_class + and self._transport.__class__ is transport_class + ): + return discovery_mock.query_data + raise KasaException() + + async def _update(self, *args, **kwargs): + if ( + self.protocol.__class__ is protocol_class + and self.protocol._transport.__class__ is transport_class + ): + return + raise KasaException() + + mocker.patch("kasa.IotProtocol.query", new=_query) + mocker.patch("kasa.SmartProtocol.query", new=_query) + mocker.patch.object(dev_class, "update", new=_update) + + dev = await Discover.try_connect_all(discovery_mock.ip) + + assert dev + assert isinstance(dev, dev_class) + assert isinstance(dev.protocol, protocol_class) + assert isinstance(dev.protocol._transport, transport_class) From 048c84d72cc7163c778d080132a3faed6959b7a3 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 22 Oct 2024 18:09:35 +0100 Subject: [PATCH 609/892] Add https parameter to device class factory (#1184) `SMART.TAPOHUB` resolves to different device classes based on the https value --- kasa/cli/discover.py | 4 ++-- kasa/device_factory.py | 18 ++++++++++------ kasa/discover.py | 36 +++++++++++++++++++++++-------- kasa/exceptions.py | 1 + kasa/tests/discovery_fixtures.py | 28 ++++++++++++++++++++++-- kasa/tests/test_cli.py | 1 - kasa/tests/test_device_factory.py | 2 +- kasa/tests/test_discovery.py | 6 ++++-- 8 files changed, 73 insertions(+), 23 deletions(-) diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index deb28b4d9..7989dbb1b 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -98,8 +98,8 @@ async def print_discovered(dev: Device): echo(f"{infostr} {dev.alias}") async def print_unsupported(unsupported_exception: UnsupportedDeviceError): - if res := unsupported_exception.discovery_result: - echo(f"{res.get('ip'):<15} UNSUPPORTED DEVICE") + if host := unsupported_exception.host: + echo(f"{host:<15} UNSUPPORTED DEVICE") echo(f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}") return await _discover(ctx, print_discovered, print_unsupported, do_echo=False) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 01b2c8e77..53ae1efff 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -67,7 +67,8 @@ async def connect(*, host: str | None = None, config: DeviceConfig) -> Device: if (protocol := get_protocol(config=config)) is None: raise UnsupportedDeviceError( f"Unsupported device for {config.host}: " - + f"{config.connection_type.device_family.value}" + + f"{config.connection_type.device_family.value}", + host=config.host, ) try: @@ -110,7 +111,7 @@ def _perf_log(has_params, perf_type): _perf_log(True, "update") return device elif device_class := get_device_class_from_family( - config.connection_type.device_family.value + config.connection_type.device_family.value, https=config.connection_type.https ): device = device_class(host=config.host, protocol=protocol) await device.update() @@ -119,7 +120,8 @@ def _perf_log(has_params, perf_type): else: raise UnsupportedDeviceError( f"Unsupported device for {config.host}: " - + f"{config.connection_type.device_family.value}" + + f"{config.connection_type.device_family.value}", + host=config.host, ) @@ -164,7 +166,9 @@ def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]: return TYPE_TO_CLASS[_get_device_type_from_sys_info(sysinfo)] -def get_device_class_from_family(device_type: str) -> type[Device] | None: +def get_device_class_from_family( + device_type: str, *, https: bool +) -> type[Device] | None: """Return the device class from the type name.""" supported_device_types: dict[str, type[Device]] = { "SMART.TAPOPLUG": SmartDevice, @@ -172,14 +176,16 @@ def get_device_class_from_family(device_type: str) -> type[Device] | None: "SMART.TAPOSWITCH": SmartDevice, "SMART.KASAPLUG": SmartDevice, "SMART.TAPOHUB": SmartDevice, + "SMART.TAPOHUB.HTTPS": SmartCamera, "SMART.KASAHUB": SmartDevice, "SMART.KASASWITCH": SmartDevice, - "SMART.IPCAMERA": SmartCamera, + "SMART.IPCAMERA.HTTPS": SmartCamera, "IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTBULB": IotBulb, } + lookup_key = f"{device_type}{'.HTTPS' if https else ''}" if ( - cls := supported_device_types.get(device_type) + cls := supported_device_types.get(lookup_key) ) is None and device_type.startswith("SMART."): _LOGGER.warning("Unknown SMART device with %s, using SmartDevice", device_type) cls = SmartDevice diff --git a/kasa/discover.py b/kasa/discover.py index e7a3946c5..5df094bb5 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -573,7 +573,11 @@ async def try_connect_all( ) ) and (protocol := get_protocol(config)) - and (device_class := get_device_class_from_family(device_family.value)) + and ( + device_class := get_device_class_from_family( + device_family.value, https=https + ) + ) } for protocol, config in candidates.values(): try: @@ -591,7 +595,10 @@ def _get_device_class(info: dict) -> type[Device]: """Find SmartDevice subclass for device described by passed data.""" if "result" in info: discovery_result = DiscoveryResult(**info["result"]) - dev_class = get_device_class_from_family(discovery_result.device_type) + https = discovery_result.mgt_encrypt_schm.is_support_https + dev_class = get_device_class_from_family( + discovery_result.device_type, https=https + ) if not dev_class: raise UnsupportedDeviceError( "Unknown device type: %s" % discovery_result.device_type, @@ -662,7 +669,9 @@ def _get_device_instance( ) from ex try: discovery_result = DiscoveryResult(**info["result"]) - if discovery_result.encrypt_info: + if ( + encrypt_info := discovery_result.encrypt_info + ) and encrypt_info.sym_schm == "AES": Discover._decrypt_discovery_data(discovery_result) except ValidationError as ex: if debug_enabled: @@ -677,21 +686,23 @@ def _get_device_instance( pf(data), ) raise UnsupportedDeviceError( - f"Unable to parse discovery from device: {config.host}: {ex}" + f"Unable to parse discovery from device: {config.host}: {ex}", + host=config.host, ) from ex type_ = discovery_result.device_type - + encrypt_schm = discovery_result.mgt_encrypt_schm try: - if not ( - encrypt_type := discovery_result.mgt_encrypt_schm.encrypt_type - ) and (encrypt_info := discovery_result.encrypt_info): + if not (encrypt_type := encrypt_schm.encrypt_type) and ( + encrypt_info := discovery_result.encrypt_info + ): encrypt_type = encrypt_info.sym_schm if not encrypt_type: raise UnsupportedDeviceError( f"Unsupported device {config.host} of type {type_} " + "with no encryption type", discovery_result=discovery_result.get_dict(), + host=config.host, ) config.connection_type = DeviceConnectionParameters.from_values( type_, @@ -704,12 +715,18 @@ def _get_device_instance( f"Unsupported device {config.host} of type {type_} " + f"with encrypt_type {discovery_result.mgt_encrypt_schm.encrypt_type}", discovery_result=discovery_result.get_dict(), + host=config.host, ) from ex - if (device_class := get_device_class_from_family(type_)) is None: + if ( + device_class := get_device_class_from_family( + type_, https=encrypt_schm.is_support_https + ) + ) is None: _LOGGER.warning("Got unsupported device type: %s", type_) raise UnsupportedDeviceError( f"Unsupported device {config.host} of type {type_}: {info}", discovery_result=discovery_result.get_dict(), + host=config.host, ) if (protocol := get_protocol(config)) is None: _LOGGER.warning( @@ -719,6 +736,7 @@ def _get_device_instance( f"Unsupported encryption scheme {config.host} of " + f"type {config.connection_type.to_dict()}: {info}", discovery_result=discovery_result.get_dict(), + host=config.host, ) if debug_enabled: diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 3f7f301ba..e32e9fd1e 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -31,6 +31,7 @@ class UnsupportedDeviceError(KasaException): def __init__(self, *args: Any, **kwargs: Any) -> None: self.discovery_result = kwargs.get("discovery_result") + self.host = kwargs.get("host") super().__init__(*args) diff --git a/kasa/tests/discovery_fixtures.py b/kasa/tests/discovery_fixtures.py index d56f11870..ccad1510b 100644 --- a/kasa/tests/discovery_fixtures.py +++ b/kasa/tests/discovery_fixtures.py @@ -15,8 +15,10 @@ DISCOVERY_MOCK_IP = "127.0.0.123" -def _make_unsupported(device_family, encrypt_type): - return { +def _make_unsupported(device_family, encrypt_type, *, omit_keys=None): + if omit_keys is None: + omit_keys = {"encrypt_info": None} + result = { "result": { "device_id": "xx", "owner": "xx", @@ -33,9 +35,17 @@ def _make_unsupported(device_family, encrypt_type): "http_port": 80, "lv": 2, }, + "encrypt_info": {"data": "", "key": "", "sym_schm": encrypt_type}, }, "error_code": 0, } + for key, val in omit_keys.items(): + if val is None: + result["result"].pop(key) + else: + result["result"][key].pop(val) + + return result UNSUPPORTED_DEVICES = { @@ -43,6 +53,16 @@ def _make_unsupported(device_family, encrypt_type): "wrong_encryption_iot": _make_unsupported("IOT.SMARTPLUGSWITCH", "AES"), "wrong_encryption_smart": _make_unsupported("SMART.TAPOBULB", "IOT"), "unknown_encryption": _make_unsupported("IOT.SMARTPLUGSWITCH", "FOO"), + "missing_encrypt_type": _make_unsupported( + "SMART.TAPOBULB", + "FOO", + omit_keys={"mgt_encrypt_schm": "encrypt_type", "encrypt_info": None}, + ), + "unable_to_parse": _make_unsupported( + "SMART.TAPOBULB", + "FOO", + omit_keys={"mgt_encrypt_schm": None}, + ), } @@ -90,6 +110,7 @@ class _DiscoveryMock: query_data: dict device_type: str encrypt_type: str + https: bool login_version: int | None = None port_override: int | None = None @@ -110,6 +131,7 @@ def _datagram(self) -> bytes: "encrypt_type" ] login_version = fixture_data["discovery_result"]["mgt_encrypt_schm"].get("lv") + https = fixture_data["discovery_result"]["mgt_encrypt_schm"]["is_support_https"] dm = _DiscoveryMock( ip, 80, @@ -118,6 +140,7 @@ def _datagram(self) -> bytes: fixture_data, device_type, encrypt_type, + https, login_version, ) else: @@ -134,6 +157,7 @@ def _datagram(self) -> bytes: fixture_data, device_type, encrypt_type, + False, login_version, ) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index e1861a29f..bd93d4301 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -764,7 +764,6 @@ async def test_discover_unsupported(unsupported_device_info, runner): ) assert res.exit_code == 0 assert "== Unsupported device ==" in res.output - assert "== Discovery Result ==" in res.output async def test_host_unsupported(unsupported_device_info, runner): diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index 7940f1e5d..35031cd0e 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -189,5 +189,5 @@ async def test_device_class_from_unknown_family(caplog): """Verify that unknown SMART devices yield a warning and fallback to SmartDevice.""" dummy_name = "SMART.foo" with caplog.at_level(logging.WARNING): - assert get_device_class_from_family(dummy_name) == SmartDevice + assert get_device_class_from_family(dummy_name, https=False) == SmartDevice assert f"Unknown SMART device with {dummy_name}" in caplog.text diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index d6e0a0db9..ff21b610a 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -658,12 +658,14 @@ async def test_discovery_decryption(): async def test_discover_try_connect_all(discovery_mock, mocker): """Test that device update is called on main.""" if "result" in discovery_mock.discovery_data: - dev_class = get_device_class_from_family(discovery_mock.device_type) + dev_class = get_device_class_from_family( + discovery_mock.device_type, https=discovery_mock.https + ) cparams = DeviceConnectionParameters.from_values( discovery_mock.device_type, discovery_mock.encrypt_type, discovery_mock.login_version, - False, + discovery_mock.https, ) protocol = get_protocol( DeviceConfig(discovery_mock.ip, connection_type=cparams) From cd0a74ca962443d740cca8542f78a69f47e1f6a6 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 23 Oct 2024 16:17:27 +0100 Subject: [PATCH 610/892] Improve supported module checks for hub children (#1188) No devices in `fixtures/smart/child` support the `get_device_time` or `get_device_usage` methods so this PR tests for whether the device is a hub child and marks those modules/methods as not supported. This prevents features being erroneously created on child devices. It also moves the logic for getting the time from the parent module behind getting it from the child module which was masking the creation of these unsupported modules. --- kasa/smart/modules/devicemodule.py | 3 ++- kasa/smart/modules/time.py | 9 +++++++++ kasa/smart/smartdevice.py | 9 +++++++-- kasa/tests/test_childdevice.py | 14 ++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py index 1d2b64f22..89c87c208 100644 --- a/kasa/smart/modules/devicemodule.py +++ b/kasa/smart/modules/devicemodule.py @@ -23,7 +23,8 @@ def query(self) -> dict: "get_device_info": None, } # Device usage is not available on older firmware versions - if self.supported_version >= 2: + # or child devices of hubs + if self.supported_version >= 2 and not self._device._is_hub_child: query["get_device_usage"] = None return query diff --git a/kasa/smart/modules/time.py b/kasa/smart/modules/time.py index c182b8af5..cac01d732 100644 --- a/kasa/smart/modules/time.py +++ b/kasa/smart/modules/time.py @@ -83,3 +83,12 @@ async def set_time(self, dt: datetime) -> dict: if region: params["region"] = region return await self.call("set_device_time", params) + + async def _check_supported(self): + """Additional check to see if the module is supported by the device. + + Hub attached sensors report the time module but do return device time. + """ + if self._device._is_hub_child: + return False + return await super()._check_supported() diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 095156e3d..0a8c136c0 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -457,6 +457,11 @@ async def _initialize_features(self): for child in self._children.values(): await child._initialize_features() + @property + def _is_hub_child(self) -> bool: + """Returns true if the device is a child of a hub.""" + return self.parent is not None and self.parent.device_type is DeviceType.Hub + @property def is_cloud_connected(self) -> bool: """Returns if the device is connected to the cloud.""" @@ -485,8 +490,8 @@ def alias(self) -> str | None: @property def time(self) -> datetime: """Return the time.""" - if (self._parent and (time_mod := self._parent.modules.get(Module.Time))) or ( - time_mod := self.modules.get(Module.Time) + if (time_mod := self.modules.get(Module.Time)) or ( + self._parent and (time_mod := self._parent.modules.get(Module.Time)) ): return time_mod.time diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 251af8788..797e8dff5 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -1,7 +1,9 @@ import inspect import sys +from datetime import datetime, timezone import pytest +from freezegun.api import FrozenDateTimeFactory from kasa import Device from kasa.device_type import DeviceType @@ -120,3 +122,15 @@ async def test_parent_property(dev: Device): assert dev.parent is None for child in dev.children: assert child.parent == dev + + +@has_children_smart +async def test_child_time(dev: Device, freezer: FrozenDateTimeFactory): + """Test a child device gets the time from it's parent module.""" + if not dev.children: + pytest.skip(f"Device {dev} fixture does not have any children") + + fallback_time = datetime.now(timezone.utc).astimezone().replace(microsecond=0) + assert dev.parent is None + for child in dev.children: + assert child.time != fallback_time From a0f3f016a29ec4c986bd23ed390fda74a9ab0453 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 23 Oct 2024 19:26:11 +0100 Subject: [PATCH 611/892] Rename experimental fixtures folder to smartcamera (#1191) --- .../{experimental => smartcamera}/C210(EU)_2.0_1.4.2.json | 0 .../{experimental => smartcamera}/H200(US)_1.0_1.3.6.json | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename kasa/tests/fixtures/{experimental => smartcamera}/C210(EU)_2.0_1.4.2.json (100%) rename kasa/tests/fixtures/{experimental => smartcamera}/H200(US)_1.0_1.3.6.json (100%) diff --git a/kasa/tests/fixtures/experimental/C210(EU)_2.0_1.4.2.json b/kasa/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json similarity index 100% rename from kasa/tests/fixtures/experimental/C210(EU)_2.0_1.4.2.json rename to kasa/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json diff --git a/kasa/tests/fixtures/experimental/H200(US)_1.0_1.3.6.json b/kasa/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json similarity index 100% rename from kasa/tests/fixtures/experimental/H200(US)_1.0_1.3.6.json rename to kasa/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json From a88b677776b5e207003a69b2d64bd880b0df2a84 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 23 Oct 2024 20:07:32 +0100 Subject: [PATCH 612/892] Combine smartcamera error codes into SmartErrorCode (#1190) Having these in a seperate place complicates the code unnecessarily. --- kasa/exceptions.py | 49 +++++++++++++++++ kasa/experimental/smartcamera.py | 2 +- kasa/experimental/sslaestransport.py | 82 ++-------------------------- 3 files changed, 54 insertions(+), 79 deletions(-) diff --git a/kasa/exceptions.py b/kasa/exceptions.py index e32e9fd1e..9172cfc32 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -127,6 +127,53 @@ def from_int(value: int) -> SmartErrorCode: DST_ERROR = -2301 DST_SAVE_ERROR = -2302 + SYSTEM_ERROR = -40101 + INVALID_ARGUMENTS = -40209 + + # Camera error codes + SESSION_EXPIRED = -40401 + HOMEKIT_LOGIN_FAIL = -40412 + DEVICE_BLOCKED = -40404 + DEVICE_FACTORY = -40405 + OUT_OF_LIMIT = -40406 + OTHER_ERROR = -40407 + SYSTEM_BLOCKED = -40408 + NONCE_EXPIRED = -40409 + FFS_NONE_PWD = -90000 + TIMEOUT_ERROR = 40108 + UNSUPPORTED_METHOD = -40106 + ONE_SECOND_REPEAT_REQUEST = -40109 + INVALID_NONCE = -40413 + PROTOCOL_FORMAT_ERROR = -40210 + IP_CONFLICT = -40321 + DIAGNOSE_TYPE_NOT_SUPPORT = -69051 + DIAGNOSE_TASK_FULL = -69052 + DIAGNOSE_TASK_BUSY = -69053 + DIAGNOSE_INTERNAL_ERROR = -69055 + DIAGNOSE_ID_NOT_FOUND = -69056 + DIAGNOSE_TASK_NULL = -69057 + CLOUD_LINK_DOWN = -69060 + ONVIF_SET_WRONG_TIME = -69061 + CLOUD_NTP_NO_RESPONSE = -69062 + CLOUD_GET_WRONG_TIME = -69063 + SNTP_SRV_NO_RESPONSE = -69064 + SNTP_GET_WRONG_TIME = -69065 + LINK_UNCONNECTED = -69076 + WIFI_SIGNAL_WEAK = -69077 + LOCAL_NETWORK_POOR = -69078 + CLOUD_NETWORK_POOR = -69079 + INTER_NETWORK_POOR = -69080 + DNS_TIMEOUT = -69081 + DNS_ERROR = -69082 + PING_NO_RESPONSE = -69083 + DHCP_MULTI_SERVER = -69084 + DHCP_ERROR = -69085 + STREAM_SESSION_CLOSE = -69094 + STREAM_BITRATE_EXCEPTION = -69095 + STREAM_FULL = -69096 + STREAM_NO_INTERNET = -69097 + HARDWIRED_NOT_FOUND = -72101 + # Library internal for unknown error codes INTERNAL_UNKNOWN_ERROR = -100_000 # Library internal for query errors @@ -138,6 +185,7 @@ def from_int(value: int) -> SmartErrorCode: SmartErrorCode.HTTP_TRANSPORT_FAILED_ERROR, SmartErrorCode.UNSPECIFIC_ERROR, SmartErrorCode.SESSION_TIMEOUT_ERROR, + SmartErrorCode.SESSION_EXPIRED, ] SMART_AUTHENTICATION_ERRORS = [ @@ -146,4 +194,5 @@ def from_int(value: int) -> SmartErrorCode: SmartErrorCode.AES_DECODE_FAIL_ERROR, SmartErrorCode.HAND_SHAKE_FAILED_ERROR, SmartErrorCode.TRANSPORT_UNKNOWN_CREDENTIALS_ERROR, + SmartErrorCode.HOMEKIT_LOGIN_FAIL, ] diff --git a/kasa/experimental/smartcamera.py b/kasa/experimental/smartcamera.py index 809ac74a0..b70ef5df7 100644 --- a/kasa/experimental/smartcamera.py +++ b/kasa/experimental/smartcamera.py @@ -3,8 +3,8 @@ from __future__ import annotations from ..device_type import DeviceType +from ..exceptions import SmartErrorCode from ..smart import SmartDevice -from .sslaestransport import SmartErrorCode class SmartCamera(SmartDevice): diff --git a/kasa/experimental/sslaestransport.py b/kasa/experimental/sslaestransport.py index fa3e69206..f095a11ec 100644 --- a/kasa/experimental/sslaestransport.py +++ b/kasa/experimental/sslaestransport.py @@ -9,8 +9,7 @@ import secrets import ssl import time -from enum import Enum, IntEnum, auto -from functools import cache +from enum import Enum, auto from typing import TYPE_CHECKING, Any, Dict, cast from yarl import URL @@ -19,9 +18,12 @@ from ..credentials import Credentials from ..deviceconfig import DeviceConfig from ..exceptions import ( + SMART_AUTHENTICATION_ERRORS, + SMART_RETRYABLE_ERRORS, AuthenticationError, DeviceError, KasaException, + SmartErrorCode, _RetryableError, ) from ..httpclient import HttpClient @@ -433,79 +435,3 @@ async def reset(self) -> None: self._seq = 0 self._pwd_hash = None self._local_nonce = None - - -class SmartErrorCode(IntEnum): - """Smart error codes for this transport.""" - - def __str__(self): - return f"{self.name}({self.value})" - - @staticmethod - @cache - def from_int(value: int) -> SmartErrorCode: - """Convert an integer to a SmartErrorCode.""" - return SmartErrorCode(value) - - SUCCESS = 0 - - SYSTEM_ERROR = -40101 - INVALID_ARGUMENTS = -40209 - - # Camera error codes - SESSION_EXPIRED = -40401 - HOMEKIT_LOGIN_FAIL = -40412 - DEVICE_BLOCKED = -40404 - DEVICE_FACTORY = -40405 - OUT_OF_LIMIT = -40406 - OTHER_ERROR = -40407 - SYSTEM_BLOCKED = -40408 - NONCE_EXPIRED = -40409 - FFS_NONE_PWD = -90000 - TIMEOUT_ERROR = 40108 - UNSUPPORTED_METHOD = -40106 - ONE_SECOND_REPEAT_REQUEST = -40109 - INVALID_NONCE = -40413 - PROTOCOL_FORMAT_ERROR = -40210 - IP_CONFLICT = -40321 - DIAGNOSE_TYPE_NOT_SUPPORT = -69051 - DIAGNOSE_TASK_FULL = -69052 - DIAGNOSE_TASK_BUSY = -69053 - DIAGNOSE_INTERNAL_ERROR = -69055 - DIAGNOSE_ID_NOT_FOUND = -69056 - DIAGNOSE_TASK_NULL = -69057 - CLOUD_LINK_DOWN = -69060 - ONVIF_SET_WRONG_TIME = -69061 - CLOUD_NTP_NO_RESPONSE = -69062 - CLOUD_GET_WRONG_TIME = -69063 - SNTP_SRV_NO_RESPONSE = -69064 - SNTP_GET_WRONG_TIME = -69065 - LINK_UNCONNECTED = -69076 - WIFI_SIGNAL_WEAK = -69077 - LOCAL_NETWORK_POOR = -69078 - CLOUD_NETWORK_POOR = -69079 - INTER_NETWORK_POOR = -69080 - DNS_TIMEOUT = -69081 - DNS_ERROR = -69082 - PING_NO_RESPONSE = -69083 - DHCP_MULTI_SERVER = -69084 - DHCP_ERROR = -69085 - STREAM_SESSION_CLOSE = -69094 - STREAM_BITRATE_EXCEPTION = -69095 - STREAM_FULL = -69096 - STREAM_NO_INTERNET = -69097 - HARDWIRED_NOT_FOUND = -72101 - - # Library internal for unknown error codes - INTERNAL_UNKNOWN_ERROR = -100_000 - # Library internal for query errors - INTERNAL_QUERY_ERROR = -100_001 - - -SMART_RETRYABLE_ERRORS = [ - SmartErrorCode.SESSION_EXPIRED, -] - -SMART_AUTHENTICATION_ERRORS = [ - SmartErrorCode.HOMEKIT_LOGIN_FAIL, -] From 51958d8078c70ba85a86aee2193cd1d02eddefb5 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 23 Oct 2024 21:42:01 +0100 Subject: [PATCH 613/892] Allow deriving from SmartModule without being registered (#1189) --- kasa/smart/smartmodule.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 1f4c4f482..8fea1d9fb 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -76,9 +76,11 @@ def __init__(self, device: SmartDevice, module: str): self._error_count = 0 def __init_subclass__(cls, **kwargs): - name = getattr(cls, "NAME", cls.__name__) - _LOGGER.debug("Registering %s", cls) - cls.REGISTERED_MODULES[name] = cls + # We only want to register submodules in a modules package so that + # other classes can inherit from smartmodule and not be registered + if cls.__module__.split(".")[-2] == "modules": + _LOGGER.debug("Registering %s", cls) + cls.REGISTERED_MODULES[cls.__name__] = cls def _set_error(self, err: Exception | None): if err is None: From c839aaa1dd2637ab946023e75fc735ec59f98789 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 24 Oct 2024 09:36:18 +0100 Subject: [PATCH 614/892] Add test framework for smartcamera (#1192) --- kasa/experimental/smartcamera.py | 16 +- kasa/tests/device_fixtures.py | 23 ++- kasa/tests/fakeprotocol_smartcamera.py | 217 +++++++++++++++++++++ kasa/tests/fixtureinfo.py | 41 ++-- kasa/tests/smartcamera/__init__.py | 0 kasa/tests/smartcamera/test_smartcamera.py | 20 ++ 6 files changed, 302 insertions(+), 15 deletions(-) create mode 100644 kasa/tests/fakeprotocol_smartcamera.py create mode 100644 kasa/tests/smartcamera/__init__.py create mode 100644 kasa/tests/smartcamera/test_smartcamera.py diff --git a/kasa/experimental/smartcamera.py b/kasa/experimental/smartcamera.py index b70ef5df7..3224c0034 100644 --- a/kasa/experimental/smartcamera.py +++ b/kasa/experimental/smartcamera.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from ..device_type import DeviceType from ..exceptions import SmartErrorCode from ..smart import SmartDevice @@ -10,6 +12,14 @@ class SmartCamera(SmartDevice): """Class for smart cameras.""" + @staticmethod + def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType: + """Find type to be displayed as a supported device category.""" + device_type = sysinfo["device_type"] + if device_type.endswith("HUB"): + return DeviceType.Hub + return DeviceType.Camera + async def update(self, update_children: bool = False): """Update the device.""" initial_query = { @@ -26,7 +36,7 @@ def _map_info(self, device_info: dict) -> dict: basic_info = device_info["basic_info"] return { "model": basic_info["device_model"], - "type": basic_info["device_type"], + "device_type": basic_info["device_type"], "alias": basic_info["device_alias"], "fw_ver": basic_info["sw_version"], "hw_ver": basic_info["hw_version"], @@ -61,7 +71,9 @@ async def set_state(self, on: bool): @property def device_type(self) -> DeviceType: """Return the device type.""" - return DeviceType.Camera + if self._device_type == DeviceType.Unknown: + self._device_type = self._get_device_type_from_sysinfo(self._info) + return self._device_type @property def alias(self) -> str | None: diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index fca5960aa..1608be94b 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -10,11 +10,13 @@ DeviceType, Discover, ) +from kasa.experimental.smartcamera import SmartCamera from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch from kasa.smart import SmartDevice from .fakeprotocol_iot import FakeIotProtocol from .fakeprotocol_smart import FakeSmartProtocol +from .fakeprotocol_smartcamera import FakeSmartCameraProtocol from .fixtureinfo import ( FIXTURE_DATA, ComponentFilter, @@ -313,6 +315,17 @@ def parametrize( device_iot = parametrize( "devices iot", model_filter=ALL_DEVICES_IOT, protocol_filter={"IOT"} ) +device_smartcamera = parametrize("devices smartcamera", protocol_filter={"SMARTCAMERA"}) +camera_smartcamera = parametrize( + "camera smartcamera", + device_type_filter=[DeviceType.Camera], + protocol_filter={"SMARTCAMERA"}, +) +hub_smartcamera = parametrize( + "hub smartcamera", + device_type_filter=[DeviceType.Hub], + protocol_filter={"SMARTCAMERA"}, +) def check_categories(): @@ -329,6 +342,8 @@ def check_categories(): + hubs_smart.args[1] + sensors_smart.args[1] + thermostats_smart.args[1] + + camera_smartcamera.args[1] + + hub_smartcamera.args[1] ) diffs: set[FixtureInfo] = set(FIXTURE_DATA) - set(categorized_fixtures) if diffs: @@ -344,8 +359,10 @@ def check_categories(): def device_for_fixture_name(model, protocol): - if "SMART" in protocol: + if protocol in {"SMART", "SMART.CHILD"}: return SmartDevice + elif protocol == "SMARTCAMERA": + return SmartCamera else: for d in STRIPS_IOT: if d in model: @@ -395,8 +412,10 @@ async def get_device_for_fixture(fixture_data: FixtureInfo) -> Device: d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)( host="127.0.0.123" ) - if "SMART" in fixture_data.protocol: + if fixture_data.protocol in {"SMART", "SMART.CHILD"}: d.protocol = FakeSmartProtocol(fixture_data.data, fixture_data.name) + elif fixture_data.protocol == "SMARTCAMERA": + d.protocol = FakeSmartCameraProtocol(fixture_data.data, fixture_data.name) else: d.protocol = FakeIotProtocol(fixture_data.data) diff --git a/kasa/tests/fakeprotocol_smartcamera.py b/kasa/tests/fakeprotocol_smartcamera.py new file mode 100644 index 000000000..e2a849dba --- /dev/null +++ b/kasa/tests/fakeprotocol_smartcamera.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import copy +from json import loads as json_loads +from warnings import warn + +from kasa import Credentials, DeviceConfig, SmartProtocol +from kasa.experimental.smartcameraprotocol import SmartCameraProtocol +from kasa.protocol import BaseTransport +from kasa.smart import SmartChildDevice + +from .fakeprotocol_smart import FakeSmartProtocol + + +class FakeSmartCameraProtocol(SmartCameraProtocol): + def __init__(self, info, fixture_name): + super().__init__( + transport=FakeSmartCameraTransport(info, fixture_name), + ) + + async def query(self, request, retry_count: int = 3): + """Implement query here so can still patch SmartProtocol.query.""" + resp_dict = await self._query(request, retry_count) + return resp_dict + + +class FakeSmartCameraTransport(BaseTransport): + def __init__( + self, + info, + fixture_name, + *, + list_return_size=10, + ): + super().__init__( + config=DeviceConfig( + "127.0.0.123", + credentials=Credentials( + username="dummy_user", + password="dummy_password", # noqa: S106 + ), + ), + ) + self.fixture_name = fixture_name + self.info = copy.deepcopy(info) + self.child_protocols = self._get_child_protocols() + self.list_return_size = list_return_size + + @property + def default_port(self): + """Default port for the transport.""" + return 443 + + @property + def credentials_hash(self): + """The hashed credentials used by the transport.""" + return self._credentials.username + self._credentials.password + "camerahash" + + async def send(self, request: str): + request_dict = json_loads(request) + method = request_dict["method"] + + if method == "multipleRequest": + params = request_dict["params"] + responses = [] + for request in params["requests"]: + response = await self._send_request(request) # type: ignore[arg-type] + # Devices do not continue after error + if response["error_code"] != 0: + break + response["method"] = request["method"] # type: ignore[index] + responses.append(response) + return {"result": {"responses": responses}, "error_code": 0} + else: + return await self._send_request(request_dict) + + def _get_child_protocols(self): + child_infos = self.info.get("getChildDeviceList", {}).get( + "child_device_list", [] + ) + found_child_fixture_infos = [] + child_protocols = {} + # imported here to avoid circular import + from .conftest import filter_fixtures + + for child_info in child_infos: + if ( + (device_id := child_info.get("device_id")) + and (category := child_info.get("category")) + and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP + ): + hw_version = child_info["hw_ver"] + sw_version = child_info["fw_ver"] + sw_version = sw_version.split(" ")[0] + model = child_info["model"] + region = child_info["specs"] + child_fixture_name = f"{model}({region})_{hw_version}_{sw_version}" + child_fixtures = filter_fixtures( + "Child fixture", + protocol_filter={"SMART.CHILD"}, + model_filter=child_fixture_name, + ) + if child_fixtures: + fixture_info = next(iter(child_fixtures)) + found_child_fixture_infos.append(child_info) + child_protocols[device_id] = FakeSmartProtocol( + fixture_info.data, fixture_info.name + ) + else: + warn( + f"Could not find child fixture {child_fixture_name}", + stacklevel=1, + ) + else: + warn( + f"Child is a cameraprotocol which needs to be implemented {child_info}", + stacklevel=1, + ) + # Replace child infos with the infos that found child fixtures + if child_infos: + self.info["getChildDeviceList"]["child_device_list"] = ( + found_child_fixture_infos + ) + return child_protocols + + async def _handle_control_child(self, params: dict): + """Handle control_child command.""" + device_id = params.get("device_id") + assert device_id in self.child_protocols, "Fixture does not have child info" + + child_protocol: SmartProtocol = self.child_protocols[device_id] + + request_data = params.get("request_data", {}) + + child_method = request_data.get("method") + child_params = request_data.get("params") # noqa: F841 + + resp = await child_protocol.query({child_method: child_params}) + resp["error_code"] = 0 + for val in resp.values(): + return { + "result": {"response_data": {"result": val, "error_code": 0}}, + "error_code": 0, + } + + @staticmethod + def _get_param_set_value(info: dict, set_keys: list[str], value): + for key in set_keys[:-1]: + info = info[key] + info[set_keys[-1]] = value + + SETTERS = { + ("system", "sys", "dev_alias"): [ + "getDeviceInfo", + "device_info", + "basic_info", + "device_alias", + ], + ("lens_mask", "lens_mask_info", "enabled"): [ + "getLensMaskConfig", + "lens_mask", + "lens_mask_info", + "enabled", + ], + } + + async def _send_request(self, request_dict: dict): + method = request_dict["method"] + + info = self.info + if method == "controlChild": + return await self._handle_control_child( + request_dict["params"]["childControl"] + ) + + if method == "set": + for key, val in request_dict.items(): + if key != "method": + module = key + section = next(iter(val)) + skey_val = val[section] + for skey, sval in skey_val.items(): + section_key = skey + section_value = sval + break + if setter_keys := self.SETTERS.get((module, section, section_key)): + self._get_param_set_value(info, setter_keys, section_value) + return {"error_code": 0} + else: + return {"error_code": -1} + elif method[:3] == "get": + params = request_dict.get("params") + if method in info: + result = copy.deepcopy(info[method]) + if "start_index" in result and "sum" in result: + list_key = next( + iter([key for key in result if isinstance(result[key], list)]) + ) + start_index = ( + start_index + if (params and (start_index := params.get("start_index"))) + else 0 + ) + + result[list_key] = result[list_key][ + start_index : start_index + self.list_return_size + ] + return {"result": result, "error_code": 0} + else: + return {"error_code": -1} + return {"error_code": -1} + + async def close(self) -> None: + pass + + async def reset(self) -> None: + pass diff --git a/kasa/tests/fixtureinfo.py b/kasa/tests/fixtureinfo.py index 9abf0f065..8db960240 100644 --- a/kasa/tests/fixtureinfo.py +++ b/kasa/tests/fixtureinfo.py @@ -4,10 +4,11 @@ import json import os from pathlib import Path -from typing import NamedTuple +from typing import Iterable, NamedTuple from kasa.device_factory import _get_device_type_from_sys_info from kasa.device_type import DeviceType +from kasa.experimental.smartcamera import SmartCamera from kasa.smart.smartdevice import SmartDevice @@ -48,9 +49,18 @@ class ComponentFilter(NamedTuple): ) ] +SUPPORTED_SMARTCAMERA_DEVICES = [ + (device, "SMARTCAMERA") + for device in glob.glob( + os.path.dirname(os.path.abspath(__file__)) + "/fixtures/smartcamera/*.json" + ) +] SUPPORTED_DEVICES = ( - SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES + SUPPORTED_SMART_CHILD_DEVICES + SUPPORTED_IOT_DEVICES + + SUPPORTED_SMART_DEVICES + + SUPPORTED_SMART_CHILD_DEVICES + + SUPPORTED_SMARTCAMERA_DEVICES ) @@ -95,7 +105,7 @@ def filter_fixtures( protocol_filter: set[str] | None = None, model_filter: set[str] | None = None, component_filter: str | ComponentFilter | None = None, - device_type_filter: list[DeviceType] | None = None, + device_type_filter: Iterable[DeviceType] | None = None, ): """Filter the fixtures based on supplied parameters. @@ -107,7 +117,11 @@ def filter_fixtures( component in component_nego details. """ - def _model_match(fixture_data: FixtureInfo, model_filter): + def _model_match(fixture_data: FixtureInfo, model_filter: set[str]): + model_filter_list = [mf for mf in model_filter] + if len(model_filter_list) == 1 and model_filter_list[0].split("_") == 3: + # return exact match + return fixture_data.name == model_filter_list[0] file_model_region = fixture_data.name.split("_")[0] file_model = file_model_region.split("(")[0] return file_model in model_filter @@ -134,16 +148,21 @@ def _component_match( ) def _device_type_match(fixture_data: FixtureInfo, device_type): - if (component_nego := fixture_data.data.get("component_nego")) is None: - return _get_device_type_from_sys_info(fixture_data.data) in device_type - components = [component["id"] for component in component_nego["component_list"]] - if (info := fixture_data.data.get("get_device_info")) and ( - type_ := info.get("type") - ): + if fixture_data.protocol in {"SMART", "SMART.CHILD"}: + info = fixture_data.data["get_device_info"] + component_nego = fixture_data.data["component_nego"] + components = [ + component["id"] for component in component_nego["component_list"] + ] return ( - SmartDevice._get_device_type_from_components(components, type_) + SmartDevice._get_device_type_from_components(components, info["type"]) in device_type ) + elif fixture_data.protocol == "IOT": + return _get_device_type_from_sys_info(fixture_data.data) in device_type + elif fixture_data.protocol == "SMARTCAMERA": + info = fixture_data.data["getDeviceInfo"]["device_info"]["basic_info"] + return SmartCamera._get_device_type_from_sysinfo(info) in device_type return False filtered = [] diff --git a/kasa/tests/smartcamera/__init__.py b/kasa/tests/smartcamera/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/kasa/tests/smartcamera/test_smartcamera.py b/kasa/tests/smartcamera/test_smartcamera.py new file mode 100644 index 000000000..9c8893c02 --- /dev/null +++ b/kasa/tests/smartcamera/test_smartcamera.py @@ -0,0 +1,20 @@ +"""Tests for smart camera devices.""" + +from __future__ import annotations + +import pytest + +from kasa import Device, DeviceType + +from ..conftest import device_smartcamera + + +@device_smartcamera +async def test_state(dev: Device): + if dev.device_type is DeviceType.Hub: + pytest.skip("Hubs cannot be switched on and off") + + state = dev.is_on + await dev.set_state(not state) + await dev.update() + assert dev.is_on is not state From 8ee8c17bdc747dc30c8ae90c3b28e130a451a2a0 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 24 Oct 2024 13:11:28 +0100 Subject: [PATCH 615/892] Update smartcamera to support single get/set/do requests (#1187) Not supported by H200 hub --- devtools/dump_devinfo.py | 157 ++++++----- devtools/helpers/smartcamerarequests.py | 61 +++++ kasa/experimental/smartcameraprotocol.py | 126 +++++++-- kasa/smartprotocol.py | 12 +- kasa/tests/fakeprotocol_smartcamera.py | 10 +- .../smartcamera/C210(EU)_2.0_1.4.2.json | 248 ++++++++++++++++-- .../smartcamera/H200(US)_1.0_1.3.6.json | 16 ++ 7 files changed, 506 insertions(+), 124 deletions(-) create mode 100644 devtools/helpers/smartcamerarequests.py diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 12e4c3cb8..da46f10a3 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -12,6 +12,7 @@ import base64 import collections.abc +import dataclasses import json import logging import re @@ -23,6 +24,7 @@ import asyncclick as click +from devtools.helpers.smartcamerarequests import SMARTCAMERA_REQUESTS from devtools.helpers.smartrequests import SmartRequest, get_component_requests from kasa import ( AuthenticationError, @@ -46,10 +48,10 @@ from kasa.smartprotocol import SmartProtocol, _ChildProtocolWrapper Call = namedtuple("Call", "module method") -SmartCall = namedtuple("SmartCall", "module request should_succeed child_device_id") FixtureResult = namedtuple("FixtureResult", "filename, folder, data") SMART_FOLDER = "kasa/tests/fixtures/smart/" +SMARTCAMERA_FOLDER = "kasa/tests/fixtures/smartcamera/" SMART_CHILD_FOLDER = "kasa/tests/fixtures/smart/child/" IOT_FOLDER = "kasa/tests/fixtures/" @@ -58,6 +60,17 @@ _LOGGER = logging.getLogger(__name__) +@dataclasses.dataclass +class SmartCall: + """Class for smart and smartcamera calls.""" + + module: str + request: dict + should_succeed: bool + child_device_id: str + supports_multiple: bool = True + + def scrub(res): """Remove identifiers from the given dict.""" keys_to_scrub = [ @@ -136,7 +149,7 @@ def scrub(res): v = base64.b64encode(b"#MASKED_SSID#").decode() elif k in ["nickname"]: v = base64.b64encode(b"#MASKED_NAME#").decode() - elif k in ["alias", "device_alias"]: + elif k in ["alias", "device_alias", "device_name"]: v = "#MASKED_NAME#" elif isinstance(res[k], int): v = 0 @@ -477,6 +490,44 @@ def format_exception(e): return exception_str +async def _make_final_calls( + protocol: SmartProtocol, + calls: list[SmartCall], + name: str, + batch_size: int, + *, + child_device_id: str, +) -> dict[str, dict]: + """Call all successes again. + + After trying each call individually make the calls again either as a + multiple request or as single requests for those that don't support + multiple queries. + """ + multiple_requests = { + key: smartcall.request[key] + for smartcall in calls + if smartcall.supports_multiple and (key := next(iter(smartcall.request))) + } + final = await _make_requests_or_exit( + protocol, + multiple_requests, + name + " - multiple", + batch_size, + child_device_id=child_device_id, + ) + single_calls = [smartcall for smartcall in calls if not smartcall.supports_multiple] + for smartcall in single_calls: + final[smartcall.module] = await _make_requests_or_exit( + protocol, + smartcall.request, + f"{name} + {smartcall.module}", + batch_size, + child_device_id=child_device_id, + ) + return final + + async def _make_requests_or_exit( protocol: SmartProtocol, requests: dict, @@ -534,69 +585,28 @@ async def get_smart_camera_test_calls(protocol: SmartProtocol): test_calls: list[SmartCall] = [] successes: list[SmartCall] = [] - requests = { - "getAlertTypeList": {"msg_alarm": {"name": "alert_type"}}, - "getNightVisionCapability": {"image_capability": {"name": ["supplement_lamp"]}}, - "getDeviceInfo": {"device_info": {"name": ["basic_info"]}}, - "getDetectionConfig": {"motion_detection": {"name": ["motion_det"]}}, - "getPersonDetectionConfig": {"people_detection": {"name": ["detection"]}}, - "getVehicleDetectionConfig": {"vehicle_detection": {"name": ["detection"]}}, - "getBCDConfig": {"sound_detection": {"name": ["bcd"]}}, - "getPetDetectionConfig": {"pet_detection": {"name": ["detection"]}}, - "getBarkDetectionConfig": {"bark_detection": {"name": ["detection"]}}, - "getMeowDetectionConfig": {"meow_detection": {"name": ["detection"]}}, - "getGlassDetectionConfig": {"glass_detection": {"name": ["detection"]}}, - "getTamperDetectionConfig": {"tamper_detection": {"name": "tamper_det"}}, - "getLensMaskConfig": {"lens_mask": {"name": ["lens_mask_info"]}}, - "getLdc": {"image": {"name": ["switch", "common"]}}, - "getLastAlarmInfo": {"msg_alarm": {"name": ["chn1_msg_alarm_info"]}}, - "getLedStatus": {"led": {"name": ["config"]}}, - "getTargetTrackConfig": {"target_track": {"name": ["target_track_info"]}}, - "getPresetConfig": {"preset": {"name": ["preset"]}}, - "getFirmwareUpdateStatus": {"cloud_config": {"name": "upgrade_status"}}, - "getMediaEncrypt": {"cet": {"name": ["media_encrypt"]}}, - "getConnectionType": {"network": {"get_connection_type": []}}, - "getAlarmConfig": {"msg_alarm": {}}, - "getAlarmPlan": {"msg_alarm_plan": {}}, - "getSirenTypeList": {"siren": {}}, - "getSirenConfig": {"siren": {}}, - "getAlertConfig": { - "msg_alarm": { - "name": ["chn1_msg_alarm_info", "capability"], - "table": ["usr_def_audio"], - } - }, - "getLightTypeList": {"msg_alarm": {}}, - "getSirenStatus": {"siren": {}}, - "getLightFrequencyInfo": {"image": {"name": "common"}}, - "getLightFrequencyCapability": {"image": {"name": "common"}}, - "getRotationStatus": {"image": {"name": ["switch"]}}, - "getNightVisionModeConfig": {"image": {"name": "switch"}}, - "getWhitelampStatus": {"image": {"get_wtl_status": ["null"]}}, - "getWhitelampConfig": {"image": {"name": "switch"}}, - "getMsgPushConfig": {"msg_push": {"name": ["chn1_msg_push_info"]}}, - "getSdCardStatus": {"harddisk_manage": {"table": ["hd_info"]}}, - "getCircularRecordingConfig": {"harddisk_manage": {"name": "harddisk"}}, - "getRecordPlan": {"record_plan": {"name": ["chn1_channel"]}}, - "getAudioConfig": {"audio_config": {"name": ["speaker", "microphone"]}}, - "getFirmwareAutoUpgradeConfig": {"auto_upgrade": {"name": ["common"]}}, - "getVideoQualities": {"video": {"name": ["main"]}}, - "getVideoCapability": {"video_capability": {"name": "main"}}, - } test_calls = [] - for method, params in requests.items(): + for request in SMARTCAMERA_REQUESTS: + method = next(iter(request)) + if method == "get": + module = method + "_" + next(iter(request[method])) + else: + module = method test_calls.append( SmartCall( - module=method, - request={method: params}, + module=module, + request=request, should_succeed=True, child_device_id="", + supports_multiple=(method != "get"), ) ) # Now get the child device requests + child_request = { + "getChildDeviceList": {"childControl": {"start_index": 0}}, + } try: - child_request = {"getChildDeviceList": {"childControl": {"start_index": 0}}} child_response = await protocol.query(child_request) except Exception: _LOGGER.debug("Device does not have any children.") @@ -607,6 +617,7 @@ async def get_smart_camera_test_calls(protocol: SmartProtocol): request=child_request, should_succeed=True, child_device_id="", + supports_multiple=True, ) ) child_list = child_response["getChildDeviceList"]["child_device_list"] @@ -660,11 +671,14 @@ async def get_smart_camera_test_calls(protocol: SmartProtocol): click.echo(f"Skipping {component_id}..", nl=False) click.echo(click.style("UNSUPPORTED", fg="yellow")) else: # Not a smart protocol device so assume camera protocol - for method, params in requests.items(): + for request in SMARTCAMERA_REQUESTS: + method = next(iter(request)) + if method == "get": + method = method + "_" + next(iter(request[method])) test_calls.append( SmartCall( module=method, - request={method: params}, + request=request, should_succeed=True, child_device_id=child_id, ) @@ -804,7 +818,9 @@ async def get_smart_test_calls(protocol: SmartProtocol): click.echo(click.style("UNSUPPORTED", fg="yellow")) # Add the extra calls for each child for extra_call in extra_test_calls: - extra_child_call = extra_call._replace(child_device_id=child_device_id) + extra_child_call = dataclasses.replace( + extra_call, child_device_id=child_device_id + ) test_calls.append(extra_child_call) return test_calls, successes @@ -879,10 +895,10 @@ async def get_smart_fixtures( finally: await protocol.close() - device_requests: dict[str, dict] = {} + device_requests: dict[str, list[SmartCall]] = {} for success in successes: - device_request = device_requests.setdefault(success.child_device_id, {}) - device_request.update(success.request) + device_request = device_requests.setdefault(success.child_device_id, []) + device_request.append(success) scrubbed_device_ids = { device_id: f"SCRUBBED_CHILD_DEVICE_ID_{index}" @@ -890,24 +906,21 @@ async def get_smart_fixtures( if device_id != "" } - final = await _make_requests_or_exit( - protocol, - device_requests[""], - "all successes at once", - batch_size, - child_device_id="", + final = await _make_final_calls( + protocol, device_requests[""], "All successes", batch_size, child_device_id="" ) fixture_results = [] for child_device_id, requests in device_requests.items(): if child_device_id == "": continue - response = await _make_requests_or_exit( + response = await _make_final_calls( protocol, requests, - "all child successes at once", + "All child successes", batch_size, child_device_id=child_device_id, ) + scrubbed = scrubbed_device_ids[child_device_id] if "get_device_info" in response and "device_id" in response["get_device_info"]: response["get_device_info"]["device_id"] = scrubbed @@ -963,6 +976,7 @@ async def get_smart_fixtures( click.echo(click.style("## device info file ##", bold=True)) if "get_device_info" in final: + # smart protocol hw_version = final["get_device_info"]["hw_ver"] sw_version = final["get_device_info"]["fw_ver"] if discovery_info: @@ -970,16 +984,19 @@ async def get_smart_fixtures( else: model = final["get_device_info"]["model"] + "(XX)" sw_version = sw_version.split(" ", maxsplit=1)[0] + copy_folder = SMART_FOLDER else: + # smart camera protocol hw_version = final["getDeviceInfo"]["device_info"]["basic_info"]["hw_version"] sw_version = final["getDeviceInfo"]["device_info"]["basic_info"]["sw_version"] model = final["getDeviceInfo"]["device_info"]["basic_info"]["device_model"] region = final["getDeviceInfo"]["device_info"]["basic_info"]["region"] sw_version = sw_version.split(" ", maxsplit=1)[0] model = f"{model}({region})" + copy_folder = SMARTCAMERA_FOLDER save_filename = f"{model}_{hw_version}_{sw_version}.json" - copy_folder = SMART_FOLDER + fixture_results.insert( 0, FixtureResult(filename=save_filename, folder=copy_folder, data=final) ) diff --git a/devtools/helpers/smartcamerarequests.py b/devtools/helpers/smartcamerarequests.py new file mode 100644 index 000000000..3f5596f76 --- /dev/null +++ b/devtools/helpers/smartcamerarequests.py @@ -0,0 +1,61 @@ +"""Module for smart camera requests.""" + +from __future__ import annotations + +SMARTCAMERA_REQUESTS: list[dict] = [ + {"getAlertTypeList": {"msg_alarm": {"name": "alert_type"}}}, + {"getNightVisionCapability": {"image_capability": {"name": ["supplement_lamp"]}}}, + {"getDeviceInfo": {"device_info": {"name": ["basic_info"]}}}, + {"getDetectionConfig": {"motion_detection": {"name": ["motion_det"]}}}, + {"getPersonDetectionConfig": {"people_detection": {"name": ["detection"]}}}, + {"getVehicleDetectionConfig": {"vehicle_detection": {"name": ["detection"]}}}, + {"getBCDConfig": {"sound_detection": {"name": ["bcd"]}}}, + {"getPetDetectionConfig": {"pet_detection": {"name": ["detection"]}}}, + {"getBarkDetectionConfig": {"bark_detection": {"name": ["detection"]}}}, + {"getMeowDetectionConfig": {"meow_detection": {"name": ["detection"]}}}, + {"getGlassDetectionConfig": {"glass_detection": {"name": ["detection"]}}}, + {"getTamperDetectionConfig": {"tamper_detection": {"name": "tamper_det"}}}, + {"getLensMaskConfig": {"lens_mask": {"name": ["lens_mask_info"]}}}, + {"getLdc": {"image": {"name": ["switch", "common"]}}}, + {"getLastAlarmInfo": {"system": {"name": ["last_alarm_info"]}}}, + {"getLedStatus": {"led": {"name": ["config"]}}}, + {"getTargetTrackConfig": {"target_track": {"name": ["target_track_info"]}}}, + {"getPresetConfig": {"preset": {"name": ["preset"]}}}, + {"getFirmwareUpdateStatus": {"cloud_config": {"name": "upgrade_status"}}}, + {"getMediaEncrypt": {"cet": {"name": ["media_encrypt"]}}}, + {"getConnectionType": {"network": {"get_connection_type": []}}}, + { + "getAlertConfig": { + "msg_alarm": { + "name": ["chn1_msg_alarm_info", "capability"], + "table": ["usr_def_audio"], + } + } + }, + {"getAlertPlan": {"msg_alarm_plan": {"name": "chn1_msg_alarm_plan"}}}, + {"getSirenTypeList": {"siren": {}}}, + {"getSirenConfig": {"siren": {}}}, + {"getLightTypeList": {"msg_alarm": {}}}, + {"getSirenStatus": {"siren": {}}}, + {"getLightFrequencyInfo": {"image": {"name": "common"}}}, + {"getRotationStatus": {"image": {"name": ["switch"]}}}, + {"getNightVisionModeConfig": {"image": {"name": "switch"}}}, + {"getWhitelampStatus": {"image": {"get_wtl_status": ["null"]}}}, + {"getWhitelampConfig": {"image": {"name": "switch"}}}, + {"getMsgPushConfig": {"msg_push": {"name": ["chn1_msg_push_info"]}}}, + {"getSdCardStatus": {"harddisk_manage": {"table": ["hd_info"]}}}, + {"getCircularRecordingConfig": {"harddisk_manage": {"name": "harddisk"}}}, + {"getRecordPlan": {"record_plan": {"name": ["chn1_channel"]}}}, + {"getAudioConfig": {"audio_config": {"name": ["speaker", "microphone"]}}}, + {"getFirmwareAutoUpgradeConfig": {"auto_upgrade": {"name": ["common"]}}}, + {"getVideoQualities": {"video": {"name": ["main"]}}}, + {"getVideoCapability": {"video_capability": {"name": "main"}}}, + {"getTimezone": {"system": {"name": "basic"}}}, + {"getClockStatus": {"system": {"name": "clock_status"}}}, + # single request only methods + {"get": {"function": {"name": ["module_spec"]}}}, + {"get": {"cet": {"name": ["vhttpd"]}}}, + {"get": {"motor": {"name": ["capability"]}}}, + {"get": {"audio_capability": {"name": ["device_speaker", "device_microphone"]}}}, + {"get": {"audio_config": {"name": ["speaker", "microphone"]}}}, +] diff --git a/kasa/experimental/smartcameraprotocol.py b/kasa/experimental/smartcameraprotocol.py index 785796160..b298fbd2e 100644 --- a/kasa/experimental/smartcameraprotocol.py +++ b/kasa/experimental/smartcameraprotocol.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from dataclasses import dataclass from pprint import pformat as pf from typing import Any @@ -22,6 +23,28 @@ _LOGGER = logging.getLogger(__name__) +# List of getMethodNames that should be sent as {"method":"do"} +# https://md.depau.eu/s/r1Ys_oWoP#Modules +GET_METHODS_AS_DO = { + "getSdCardFormatStatus", + "getConnectionType", + "getUserID", + "getP2PSharePassword", + "getAESEncryptKey", + "getFirmwareAFResult", + "getWhitelampStatus", +} + + +@dataclass +class SingleRequest: + """Class for returning single request details from helper functions.""" + + method_type: str + method_name: str + param_name: str + request: dict[str, Any] + class SmartCameraProtocol(SmartProtocol): """Class for SmartCamera Protocol.""" @@ -63,37 +86,70 @@ async def close(self) -> None: """Close the underlying transport.""" await self._transport.close() + @staticmethod + def _get_smart_camera_single_request( + request: dict[str, dict[str, Any]], + ) -> SingleRequest: + method = next(iter(request)) + if method == "multipleRequest": + method_type = "multi" + params = request["multipleRequest"] + req = {"method": "multipleRequest", "params": params} + return SingleRequest("multi", "multipleRequest", "", req) + + param = next(iter(request[method])) + method_type = method + req = { + "method": method, + param: request[method][param], + } + return SingleRequest(method_type, method, param, req) + + @staticmethod + def _make_snake_name(name: str) -> str: + """Convert camel or pascal case to snake name.""" + sn = "".join(["_" + i.lower() if i.isupper() else i for i in name]).lstrip("_") + return sn + + @staticmethod + def _make_smart_camera_single_request( + request: str, + ) -> SingleRequest: + """Make a single request given a method name and no params. + + If method like getSomeThing then module will be some_thing. + """ + method = request + method_type = request[:3] + snake_name = SmartCameraProtocol._make_snake_name(request) + param = snake_name[4:] + if ( + (short_method := method[:3]) + and short_method in {"get", "set"} + and method not in GET_METHODS_AS_DO + ): + method_type = short_method + param = snake_name[4:] + else: + method_type = "do" + param = snake_name + req = {"method": method_type, param: {}} + return SingleRequest(method_type, method, param, req) + async def _execute_query( self, request: str | dict, *, retry_count: int, iterate_list_pages: bool = True ) -> dict: debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) - if isinstance(request, dict): - if len(request) == 1: - method = next(iter(request)) - if method == "multipleRequest": - params = request["multipleRequest"] - req = {"method": "multipleRequest", "params": params} - elif method[:3] == "set": - params = next(iter(request[method])) - req = { - "method": method[:3], - params: request[method][params], - } - else: - return await self._execute_multiple_query(request, retry_count) + method = next(iter(request)) + if len(request) == 1 and method in {"get", "set", "do", "multipleRequest"}: + single_request = self._get_smart_camera_single_request(request) else: return await self._execute_multiple_query(request, retry_count) else: - # If method like getSomeThing then module will be some_thing - method = request - snake_name = "".join( - ["_" + i.lower() if i.isupper() else i for i in method] - ).lstrip("_") - params = snake_name[4:] - req = {"method": snake_name[:3], params: {}} - - smart_request = json_dumps(req) + single_request = self._make_smart_camera_single_request(request) + + smart_request = json_dumps(single_request.request) if debug_enabled: _LOGGER.debug( "%s >> %s", @@ -111,15 +167,29 @@ async def _execute_query( if "error_code" in response_data: # H200 does not return an error code - self._handle_response_error_code(response_data, method) + self._handle_response_error_code(response_data, single_request.method_name) + # Requests that are invalid and raise PROTOCOL_FORMAT_ERROR when sent + # as a multipleRequest will return {} when sent as a single request. + if single_request.method_type == "get" and ( + not (section := next(iter(response_data))) or response_data[section] == {} + ): + raise DeviceError( + f"No results for get request {single_request.method_name}" + ) # TODO need to update handle response lists - if method[:3] == "set": + if single_request.method_type == "do": + return {single_request.method_name: response_data} + if single_request.method_type == "set": return {} - if method == "multipleRequest": - return {method: response_data["result"]} - return {method: {params: response_data[params]}} + if single_request.method_type == "multi": + return {single_request.method_name: response_data["result"]} + return { + single_request.method_name: { + single_request.param_name: response_data[single_request.param_name] + } + } class _ChildCameraProtocolWrapper(SmartProtocol): diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 0c2a2bba5..71be7dee1 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -163,6 +163,10 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic ] end = len(multi_requests) + # The SmartCameraProtocol sends requests with a length 1 as a + # multipleRequest. The SmartProtocol doesn't so will never + # raise_on_error + raise_on_error = end == 1 # Break the requests down as there can be a size limit step = self._multi_request_batch_size @@ -172,14 +176,12 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic method = request["method"] req = self.get_smart_request(method, request.get("params")) resp = await self._transport.send(req) - self._handle_response_error_code(resp, method, raise_on_error=False) + self._handle_response_error_code( + resp, method, raise_on_error=raise_on_error + ) multi_result[method] = resp["result"] return multi_result - # The SmartCameraProtocol sends requests with a length 1 as a - # multipleRequest. The SmartProtocol doesn't so will never - # raise_on_error - raise_on_error = end == 1 for batch_num, i in enumerate(range(0, end, step)): requests_step = multi_requests[i : i + step] diff --git a/kasa/tests/fakeprotocol_smartcamera.py b/kasa/tests/fakeprotocol_smartcamera.py index e2a849dba..50d34e938 100644 --- a/kasa/tests/fakeprotocol_smartcamera.py +++ b/kasa/tests/fakeprotocol_smartcamera.py @@ -173,10 +173,16 @@ async def _send_request(self, request_dict: dict): request_dict["params"]["childControl"] ) - if method == "set": + if method[:3] == "set": for key, val in request_dict.items(): if key != "method": - module = key + # key is params for multi request and the actual params + # for single requests + if key == "params": + module = next(iter(val)) + val = val[module] + else: + module = key section = next(iter(val)) skey_val = val[section] for skey, sval in skey_val.items(): diff --git a/kasa/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json b/kasa/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json index 304a1e126..a4c529a53 100644 --- a/kasa/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json +++ b/kasa/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json @@ -5,14 +5,14 @@ "connect_type": "wireless", "device_id": "0000000000000000000000000000000000000000", "http_port": 443, - "last_alarm_time": "0", - "last_alarm_type": "", + "last_alarm_time": "1729264456", + "last_alarm_type": "motion", "owner": "00000000000000000000000000000000", "sd_status": "offline" }, "device_id": "00000000000000000000000000000000", "device_model": "C210", - "device_name": "00000 000", + "device_name": "#MASKED_NAME#", "device_type": "SMART.IPCAMERA", "encrypt_info": { "data": "", @@ -60,6 +60,14 @@ "usr_def_audio": [] } }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, "getAlertTypeList": { "msg_alarm": { "alert_type": { @@ -106,10 +114,18 @@ } } }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-10-24 12:49:09", + "seconds_from_1970": 1729770549 + } + } + }, "getConnectionType": { "link_type": "wifi", - "rssi": "2", - "rssiValue": -64, + "rssi": "3", + "rssiValue": -62, "ssid": "I01BU0tFRF9TU0lEIw==" }, "getDetectionConfig": { @@ -133,7 +149,7 @@ "device_alias": "#MASKED_NAME#", "device_info": "C210 2.0 IPC", "device_model": "C210", - "device_name": "0000 0.0", + "device_name": "#MASKED_NAME#", "device_type": "SMART.IPCAMERA", "features": 3, "ffs": false, @@ -171,19 +187,10 @@ } }, "getLastAlarmInfo": { - "msg_alarm": { - "chn1_msg_alarm_info": { - "alarm_duration": "0", - "alarm_mode": [ - "sound", - "light" - ], - "alarm_type": "0", - "alarm_volume": "high", - "enabled": "off", - "light_alarm_enabled": "on", - "light_type": "1", - "sound_alarm_enabled": "on" + "system": { + "last_alarm_info": { + "last_alarm_time": "1729264456", + "last_alarm_type": "motion" } } }, @@ -519,6 +526,15 @@ } } }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-00:00", + "timing_mode": "ntp", + "zone_id": "Europe/Berlin" + } + } + }, "getVideoCapability": { "video_capability": { "main": { @@ -602,5 +618,199 @@ "getWhitelampStatus": { "rest_time": 0, "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8", + "16" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "1.1" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "1.3" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "1.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "1" + } + } + } } } diff --git a/kasa/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json b/kasa/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json index c76662960..544ab267f 100644 --- a/kasa/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json +++ b/kasa/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json @@ -210,6 +210,22 @@ } } }, + "getTimezone": { + "system": { + "basic": { + "zone_id": "Australia/Canberra", + "timezone": "UTC+10:00" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "seconds_from_1970": 1729509322, + "local_time": "2024-10-21 22:15:22" + } + } + }, "getFirmwareAutoUpgradeConfig": { "auto_upgrade": { "common": { From 28361c17279e37a0acd8232241a92cacbf170fef Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 24 Oct 2024 17:22:45 +0100 Subject: [PATCH 616/892] Add core device, child and camera modules to smartcamera (#1193) Co-authored-by: Teemu R. --- kasa/experimental/modules/__init__.py | 11 ++ kasa/experimental/modules/camera.py | 45 ++++++ kasa/experimental/modules/childdevice.py | 23 ++++ kasa/experimental/modules/device.py | 40 ++++++ kasa/experimental/smartcamera.py | 151 +++++++++++++++++---- kasa/experimental/smartcameramodule.py | 96 +++++++++++++ kasa/module.py | 4 + kasa/smart/smartchilddevice.py | 34 ++++- kasa/smart/smartdevice.py | 33 +++-- kasa/smart/smartmodule.py | 8 +- kasa/tests/smartcamera/test_smartcamera.py | 29 +++- 11 files changed, 427 insertions(+), 47 deletions(-) create mode 100644 kasa/experimental/modules/__init__.py create mode 100644 kasa/experimental/modules/camera.py create mode 100644 kasa/experimental/modules/childdevice.py create mode 100644 kasa/experimental/modules/device.py create mode 100644 kasa/experimental/smartcameramodule.py diff --git a/kasa/experimental/modules/__init__.py b/kasa/experimental/modules/__init__.py new file mode 100644 index 000000000..9f1683845 --- /dev/null +++ b/kasa/experimental/modules/__init__.py @@ -0,0 +1,11 @@ +"""Modules for SMARTCAMERA devices.""" + +from .camera import Camera +from .childdevice import ChildDevice +from .device import DeviceModule + +__all__ = [ + "Camera", + "ChildDevice", + "DeviceModule", +] diff --git a/kasa/experimental/modules/camera.py b/kasa/experimental/modules/camera.py new file mode 100644 index 000000000..76701b52a --- /dev/null +++ b/kasa/experimental/modules/camera.py @@ -0,0 +1,45 @@ +"""Implementation of device module.""" + +from __future__ import annotations + +from ...device_type import DeviceType +from ...feature import Feature +from ..smartcameramodule import SmartCameraModule + + +class Camera(SmartCameraModule): + """Implementation of device module.""" + + QUERY_GETTER_NAME = "getLensMaskConfig" + QUERY_MODULE_NAME = "lens_mask" + QUERY_SECTION_NAMES = "lens_mask_info" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="state", + name="State", + attribute_getter="is_on", + attribute_setter="set_state", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) + ) + + @property + def is_on(self) -> bool: + """Return the device id.""" + return self.data["lens_mask_info"]["enabled"] == "on" + + async def set_state(self, on: bool) -> dict: + """Set the device state.""" + params = {"enabled": "on" if on else "off"} + return await self._device._query_setter_helper( + "setLensMaskConfig", self.QUERY_MODULE_NAME, "lens_mask_info", params + ) + + async def _check_supported(self) -> bool: + """Additional check to see if the module is supported by the device.""" + return self._device.device_type is DeviceType.Camera diff --git a/kasa/experimental/modules/childdevice.py b/kasa/experimental/modules/childdevice.py new file mode 100644 index 000000000..837793f1c --- /dev/null +++ b/kasa/experimental/modules/childdevice.py @@ -0,0 +1,23 @@ +"""Module for child devices.""" + +from ...device_type import DeviceType +from ..smartcameramodule import SmartCameraModule + + +class ChildDevice(SmartCameraModule): + """Implementation for child devices.""" + + NAME = "childdevice" + QUERY_GETTER_NAME = "getChildDeviceList" + QUERY_MODULE_NAME = "childControl" + + def query(self) -> dict: + """Query to execute during the update cycle. + + Default implementation uses the raw query getter w/o parameters. + """ + return {self.QUERY_GETTER_NAME: {self.QUERY_MODULE_NAME: {"start_index": 0}}} + + async def _check_supported(self) -> bool: + """Additional check to see if the module is supported by the device.""" + return self._device.device_type is DeviceType.Hub diff --git a/kasa/experimental/modules/device.py b/kasa/experimental/modules/device.py new file mode 100644 index 000000000..34474ef2b --- /dev/null +++ b/kasa/experimental/modules/device.py @@ -0,0 +1,40 @@ +"""Implementation of device module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartcameramodule import SmartCameraModule + + +class DeviceModule(SmartCameraModule): + """Implementation of device module.""" + + NAME = "devicemodule" + QUERY_GETTER_NAME = "getDeviceInfo" + QUERY_MODULE_NAME = "device_info" + QUERY_SECTION_NAMES = ["basic_info", "info"] + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="device_id", + name="Device ID", + attribute_getter="device_id", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + async def _post_update_hook(self) -> None: + """Overriden to prevent module disabling. + + Overrides the default behaviour to disable a module if the query returns + an error because this module is critical. + """ + + @property + def device_id(self) -> str: + """Return the device id.""" + return self.data["basic_info"]["dev_id"] diff --git a/kasa/experimental/smartcamera.py b/kasa/experimental/smartcamera.py index 3224c0034..52a6acdfa 100644 --- a/kasa/experimental/smartcamera.py +++ b/kasa/experimental/smartcamera.py @@ -2,16 +2,26 @@ from __future__ import annotations +import logging from typing import Any from ..device_type import DeviceType -from ..exceptions import SmartErrorCode -from ..smart import SmartDevice +from ..module import Module +from ..smart import SmartChildDevice, SmartDevice +from .modules.childdevice import ChildDevice +from .modules.device import DeviceModule +from .smartcameramodule import SmartCameraModule +from .smartcameraprotocol import _ChildCameraProtocolWrapper + +_LOGGER = logging.getLogger(__name__) class SmartCamera(SmartDevice): """Class for smart cameras.""" + # Modules that are called as part of the init procedure on first update + FIRST_UPDATE_MODULES = {DeviceModule, ChildDevice} + @staticmethod def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType: """Find type to be displayed as a supported device category.""" @@ -20,17 +30,108 @@ def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType: return DeviceType.Hub return DeviceType.Camera - async def update(self, update_children: bool = False): - """Update the device.""" + def _update_internal_info(self, info_resp: dict) -> None: + """Update the internal device info.""" + info = self._try_get_response(info_resp, "getDeviceInfo") + self._info = self._map_info(info["device_info"]) + + def _update_children_info(self) -> None: + """Update the internal child device info from the parent info.""" + if child_info := self._try_get_response( + self._last_update, "getChildDeviceList", {} + ): + for info in child_info["child_device_list"]: + self._children[info["device_id"]]._update_internal_state(info) + + async def _initialize_smart_child(self, info: dict) -> SmartDevice: + """Initialize a smart child device attached to a smartcamera.""" + child_id = info["device_id"] + child_protocol = _ChildCameraProtocolWrapper(child_id, self.protocol) + try: + initial_response = await child_protocol.query( + {"component_nego": None, "get_connect_cloud_state": None} + ) + child_components = { + item["id"]: item["ver_code"] + for item in initial_response["component_nego"]["component_list"] + } + except Exception as ex: + _LOGGER.exception("Error initialising child %s: %s", child_id, ex) + + return await SmartChildDevice.create( + parent=self, + child_info=info, + child_components=child_components, + protocol=child_protocol, + last_update=initial_response, + ) + + async def _initialize_children(self) -> None: + """Initialize children for hubs.""" + if not ( + child_info := self._try_get_response( + self._last_update, "getChildDeviceList", {} + ) + ): + return + + children = {} + for info in child_info["child_device_list"]: + if ( + category := info.get("category") + ) and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP: + child_id = info["device_id"] + children[child_id] = await self._initialize_smart_child(info) + else: + _LOGGER.debug("Child device type not supported: %s", info) + + self._children = children + + async def _initialize_modules(self) -> None: + """Initialize modules based on component negotiation response.""" + for mod in SmartCameraModule.REGISTERED_MODULES.values(): + module = mod(self, mod._module_name()) + if await module._check_supported(): + self._modules[module.name] = module + + async def _initialize_features(self) -> None: + """Initialize device features.""" + for module in self.modules.values(): + module._initialize_features() + for feat in module._module_features.values(): + self._add_feature(feat) + + for child in self._children.values(): + await child._initialize_features() + + async def _query_setter_helper( + self, method: str, module: str, section: str, params: dict | None = None + ) -> dict: + res = await self.protocol.query({method: {module: {section: params}}}) + + return res + + async def _query_getter_helper( + self, method: str, module: str, sections: str | list[str] + ) -> Any: + res = await self.protocol.query({method: {module: {"name": sections}}}) + + return res + + async def _negotiate(self) -> None: + """Perform initialization. + + We fetch the device info and the available components as early as possible. + If the device reports supporting child devices, they are also initialized. + """ initial_query = { "getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}}, - "getLensMaskConfig": {"lens_mask": {"name": ["lens_mask_info"]}}, + "getChildDeviceList": {"childControl": {"start_index": 0}}, } resp = await self.protocol.query(initial_query) self._last_update.update(resp) - info = self._try_get_response(resp, "getDeviceInfo") - self._info = self._map_info(info["device_info"]) - self._last_update = resp + self._update_internal_info(resp) + await self._initialize_children() def _map_info(self, device_info: dict) -> dict: basic_info = device_info["basic_info"] @@ -48,25 +149,17 @@ def _map_info(self, device_info: dict) -> dict: @property def is_on(self) -> bool: """Return true if the device is on.""" - if isinstance(self._last_update["getLensMaskConfig"], SmartErrorCode): - return True - return ( - self._last_update["getLensMaskConfig"]["lens_mask"]["lens_mask_info"][ - "enabled" - ] - == "on" - ) + if (camera := self.modules.get(Module.Camera)) and not camera.disabled: + return camera.is_on + + return True - async def set_state(self, on: bool): + async def set_state(self, on: bool) -> dict: """Set the device state.""" - if isinstance(self._last_update["getLensMaskConfig"], SmartErrorCode): - return - query = { - "setLensMaskConfig": { - "lens_mask": {"lens_mask_info": {"enabled": "on" if on else "off"}} - }, - } - return await self.protocol.query(query) + if (camera := self.modules.get(Module.Camera)) and not camera.disabled: + return await camera.set_state(on) + + return {} @property def device_type(self) -> DeviceType: @@ -82,6 +175,14 @@ def alias(self) -> str | None: return self._info.get("alias") return None + async def set_alias(self, alias: str) -> dict: + """Set the device name (alias).""" + return await self.protocol.query( + { + "setDeviceAlias": {"system": {"sys": {"dev_alias": alias}}}, + } + ) + @property def hw_info(self) -> dict: """Return hardware info for the device.""" diff --git a/kasa/experimental/smartcameramodule.py b/kasa/experimental/smartcameramodule.py new file mode 100644 index 000000000..fed97cb35 --- /dev/null +++ b/kasa/experimental/smartcameramodule.py @@ -0,0 +1,96 @@ +"""Base implementation for SMART modules.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from ..exceptions import DeviceError, KasaException, SmartErrorCode +from ..smart.smartmodule import SmartModule + +if TYPE_CHECKING: + from .smartcamera import SmartCamera + +_LOGGER = logging.getLogger(__name__) + + +class SmartCameraModule(SmartModule): + """Base class for SMARTCAMERA modules.""" + + #: Query to execute during the main update cycle + QUERY_GETTER_NAME: str + #: Module name to be queried + QUERY_MODULE_NAME: str + #: Section name or names to be queried + QUERY_SECTION_NAMES: str | list[str] + + REGISTERED_MODULES = {} + + _device: SmartCamera + + def query(self) -> dict: + """Query to execute during the update cycle. + + Default implementation uses the raw query getter w/o parameters. + """ + return { + self.QUERY_GETTER_NAME: { + self.QUERY_MODULE_NAME: {"name": self.QUERY_SECTION_NAMES} + } + } + + async def call(self, method: str, params: dict | None = None) -> dict: + """Call a method. + + Just a helper method. + """ + if params: + module = next(iter(params)) + section = next(iter(params[module])) + else: + module = "system" + section = "null" + + if method[:3] == "get": + return await self._device._query_getter_helper(method, module, section) + + return await self._device._query_setter_helper(method, module, section, params) + + @property + def data(self) -> dict: + """Return response data for the module.""" + dev = self._device + q = self.query() + + if not q: + return dev.sys_info + + if len(q) == 1: + query_resp = dev._last_update.get(self.QUERY_GETTER_NAME, {}) + if isinstance(query_resp, SmartErrorCode): + raise DeviceError( + f"Error accessing module data in {self._module}", + error_code=SmartErrorCode, + ) + + if not query_resp: + raise KasaException( + f"You need to call update() prior accessing module data" + f" for '{self._module}'" + ) + + return query_resp.get(self.QUERY_MODULE_NAME) + else: + found = {key: val for key, val in dev._last_update.items() if key in q} + for key in q: + if key not in found: + raise KasaException( + f"{key} not found, you need to call update() prior accessing" + f" module data for '{self._module}'" + ) + if isinstance(found[key], SmartErrorCode): + raise DeviceError( + f"Error accessing module data {key} in {self._module}", + error_code=SmartErrorCode, + ) + return found diff --git a/kasa/module.py b/kasa/module.py index 2c6014e55..e10b2d632 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -55,6 +55,7 @@ if TYPE_CHECKING: from . import interfaces from .device import Device + from .experimental import modules as experimental from .iot import modules as iot from .smart import modules as smart @@ -127,6 +128,9 @@ class Module(ABC): "WaterleakSensor" ) + # SMARTCAMERA only modules + Camera: Final[ModuleName[experimental.Camera]] = ModuleName("Camera") + def __init__(self, device: Device, module: str): self._device = device self._module = module diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 1fe0014e7..f3e39ce9d 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -36,17 +36,19 @@ class SmartChildDevice(SmartDevice): def __init__( self, parent: SmartDevice, - info, - component_info, + info: dict, + component_info: dict, + *, config: DeviceConfig | None = None, protocol: SmartProtocol | None = None, ) -> None: - super().__init__(parent.host, config=parent.config, protocol=parent.protocol) + super().__init__(parent.host, config=parent.config, protocol=protocol) self._parent = parent self._update_internal_state(info) self._components = component_info self._id = info["device_id"] - self.protocol = _ChildProtocolWrapper(self._id, parent.protocol) + # wrap device protocol if no protocol is given + self.protocol = protocol or _ChildProtocolWrapper(self._id, parent.protocol) async def update(self, update_children: bool = True): """Update child module info. @@ -79,9 +81,27 @@ async def _update(self, update_children: bool = True): self._last_update_time = now @classmethod - async def create(cls, parent: SmartDevice, child_info, child_components): - """Create a child device based on device info and component listing.""" - child: SmartChildDevice = cls(parent, child_info, child_components) + async def create( + cls, + parent: SmartDevice, + child_info: dict, + child_components: dict, + protocol: SmartProtocol | None = None, + *, + last_update: dict | None = None, + ) -> SmartDevice: + """Create a child device based on device info and component listing. + + If creating a smart child from a different protocol, i.e. a camera hub, + protocol: SmartProtocol and last_update should be provided as per the + FIRST_UPDATE_MODULES expected by the update cycle as these cannot be + derived from the parent. + """ + child: SmartChildDevice = cls( + parent, child_info, child_components, protocol=protocol + ) + if last_update: + child._last_update = last_update await child._initialize_modules() return child diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 0a8c136c0..f4012b68f 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -37,15 +37,15 @@ # same issue, homekit perhaps? NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] -# Modules that are called as part of the init procedure on first update -FIRST_UPDATE_MODULES = {DeviceModule, ChildDevice, Cloud} - # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. class SmartDevice(Device): """Base class to represent a SMART protocol based device.""" + # Modules that are called as part of the init procedure on first update + FIRST_UPDATE_MODULES = {DeviceModule, ChildDevice, Cloud} + def __init__( self, host: str, @@ -67,6 +67,7 @@ def __init__( self._last_update = {} self._last_update_time: float | None = None self._on_since: datetime | None = None + self._info: dict[str, Any] = {} async def _initialize_children(self): """Initialize children for power strips.""" @@ -154,6 +155,18 @@ async def _negotiate(self): if "child_device" in self._components and not self.children: await self._initialize_children() + def _update_children_info(self) -> None: + """Update the internal child device info from the parent info.""" + if child_info := self._try_get_response( + self._last_update, "get_child_device_list", {} + ): + for info in child_info["child_device_list"]: + self._children[info["device_id"]]._update_internal_state(info) + + def _update_internal_info(self, info_resp: dict) -> None: + """Update the internal device info.""" + self._info = self._try_get_response(info_resp, "get_device_info") + async def update(self, update_children: bool = False): """Update the device.""" if self.credentials is None and self.credentials_hash is None: @@ -172,11 +185,7 @@ async def update(self, update_children: bool = False): resp = await self._modular_update(first_update, now) - if child_info := self._try_get_response( - self._last_update, "get_child_device_list", {} - ): - for info in child_info["child_device_list"]: - self._children[info["device_id"]]._update_internal_state(info) + self._update_children_info() # Call child update which will only update module calls, info is updated # from get_child_device_list. update_children only affects hub devices, other # devices will always update children to prevent errors on module access. @@ -227,10 +236,10 @@ async def _modular_update( mq = { module: query for module in self._modules.values() - if module.disabled is False and (query := module.query()) + if (first_update or module.disabled is False) and (query := module.query()) } for module, query in mq.items(): - if first_update and module.__class__ in FIRST_UPDATE_MODULES: + if first_update and module.__class__ in self.FIRST_UPDATE_MODULES: module._last_update_time = update_time continue if ( @@ -256,7 +265,7 @@ async def _modular_update( info_resp = self._last_update if first_update else resp self._last_update.update(**resp) - self._info = self._try_get_response(info_resp, "get_device_info") + self._update_internal_info(info_resp) # Call handle update for modules that want to update internal data for module in self._modules.values(): @@ -570,7 +579,7 @@ def internal_state(self) -> Any: """Return all the internal state data.""" return self._last_update - def _update_internal_state(self, info): + def _update_internal_state(self, info: dict) -> None: """Update the internal info state. This is used by the parent to push updates to its children. diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 8fea1d9fb..f20186ec6 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -80,7 +80,7 @@ def __init_subclass__(cls, **kwargs): # other classes can inherit from smartmodule and not be registered if cls.__module__.split(".")[-2] == "modules": _LOGGER.debug("Registering %s", cls) - cls.REGISTERED_MODULES[cls.__name__] = cls + cls.REGISTERED_MODULES[cls._module_name()] = cls def _set_error(self, err: Exception | None): if err is None: @@ -118,10 +118,14 @@ def disabled(self) -> bool: """Return true if the module is disabled due to errors.""" return self._error_count >= self.DISABLE_AFTER_ERROR_COUNT + @classmethod + def _module_name(cls): + return getattr(cls, "NAME", cls.__name__) + @property def name(self) -> str: """Name of the module.""" - return getattr(self, "NAME", self.__class__.__name__) + return self._module_name() async def _post_update_hook(self): # noqa: B027 """Perform actions after a device update. diff --git a/kasa/tests/smartcamera/test_smartcamera.py b/kasa/tests/smartcamera/test_smartcamera.py index 9c8893c02..50a1a1366 100644 --- a/kasa/tests/smartcamera/test_smartcamera.py +++ b/kasa/tests/smartcamera/test_smartcamera.py @@ -6,7 +6,7 @@ from kasa import Device, DeviceType -from ..conftest import device_smartcamera +from ..conftest import device_smartcamera, hub_smartcamera @device_smartcamera @@ -18,3 +18,30 @@ async def test_state(dev: Device): await dev.set_state(not state) await dev.update() assert dev.is_on is not state + + +@device_smartcamera +async def test_alias(dev): + test_alias = "TEST1234" + original = dev.alias + + assert isinstance(original, str) + await dev.set_alias(test_alias) + await dev.update() + assert dev.alias == test_alias + + await dev.set_alias(original) + await dev.update() + assert dev.alias == original + + +@hub_smartcamera +async def test_hub(dev): + assert dev.children + for child in dev.children: + assert "Cloud" in child.modules + assert child.modules["Cloud"].data + assert child.alias + await child.update() + assert "Time" not in child.modules + assert child.time From e3610cf37e7afd539b891332bc2a790f5a9e7702 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 24 Oct 2024 19:11:21 +0100 Subject: [PATCH 617/892] Add Time module to SmartCamera devices (#1182) --- kasa/experimental/modules/__init__.py | 2 + kasa/experimental/modules/time.py | 91 ++++++++++++++++++++++ kasa/experimental/smartcameramodule.py | 8 +- kasa/tests/fakeprotocol_smartcamera.py | 30 +++++-- kasa/tests/smartcamera/test_smartcamera.py | 16 +++- 5 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 kasa/experimental/modules/time.py diff --git a/kasa/experimental/modules/__init__.py b/kasa/experimental/modules/__init__.py index 9f1683845..48c4c2acd 100644 --- a/kasa/experimental/modules/__init__.py +++ b/kasa/experimental/modules/__init__.py @@ -3,9 +3,11 @@ from .camera import Camera from .childdevice import ChildDevice from .device import DeviceModule +from .time import Time __all__ = [ "Camera", "ChildDevice", "DeviceModule", + "Time", ] diff --git a/kasa/experimental/modules/time.py b/kasa/experimental/modules/time.py new file mode 100644 index 000000000..33070892d --- /dev/null +++ b/kasa/experimental/modules/time.py @@ -0,0 +1,91 @@ +"""Implementation of time module.""" + +from __future__ import annotations + +from datetime import datetime, timezone, tzinfo +from typing import cast + +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from ...cachedzoneinfo import CachedZoneInfo +from ...feature import Feature +from ...interfaces import Time as TimeInterface +from ..smartcameramodule import SmartCameraModule + + +class Time(SmartCameraModule, TimeInterface): + """Implementation of device_local_time.""" + + QUERY_GETTER_NAME = "getTimezone" + QUERY_MODULE_NAME = "system" + QUERY_SECTION_NAMES = "basic" + + _timezone: tzinfo = timezone.utc + _time: datetime + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + device=self._device, + id="device_time", + name="Device time", + attribute_getter="time", + container=self, + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + q = super().query() + q["getClockStatus"] = {self.QUERY_MODULE_NAME: {"name": "clock_status"}} + + return q + + async def _post_update_hook(self) -> None: + """Perform actions after a device update.""" + time_data = self.data["getClockStatus"]["system"]["clock_status"] + timezone_data = self.data["getTimezone"]["system"]["basic"] + zone_id = timezone_data["zone_id"] + timestamp = time_data["seconds_from_1970"] + try: + # Zoneinfo will return a DST aware object + tz: tzinfo = await CachedZoneInfo.get_cached_zone_info(zone_id) + except ZoneInfoNotFoundError: + # timezone string like: UTC+10:00 + timezone_str = timezone_data["timezone"] + tz = cast(tzinfo, datetime.strptime(timezone_str[-6:], "%z").tzinfo) + + self._timezone = tz + self._time = datetime.fromtimestamp( + cast(float, timestamp), + tz=tz, + ) + + @property + def timezone(self) -> tzinfo: + """Return current timezone.""" + return self._timezone + + @property + def time(self) -> datetime: + """Return device's current datetime.""" + return self._time + + async def set_time(self, dt: datetime) -> dict: + """Set device time.""" + if not dt.tzinfo: + timestamp = dt.replace(tzinfo=self.timezone).timestamp() + else: + timestamp = dt.timestamp() + + lt = datetime.fromtimestamp(timestamp).isoformat().replace("T", " ") + params = {"seconds_from_1970": int(timestamp), "local_time": lt} + # Doesn't seem to update the time, perhaps because timing_mode is ntp + res = await self.call("setTimezone", {"system": {"clock_status": params}}) + if (zinfo := dt.tzinfo) and isinstance(zinfo, ZoneInfo): + tz_params = {"zone_id": zinfo.key} + res = await self.call("setTimezone", {"system": {"basic": tz_params}}) + return res diff --git a/kasa/experimental/smartcameramodule.py b/kasa/experimental/smartcameramodule.py index fed97cb35..bfb42fc05 100644 --- a/kasa/experimental/smartcameramodule.py +++ b/kasa/experimental/smartcameramodule.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, cast from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..smart.smartmodule import SmartModule @@ -54,7 +54,11 @@ async def call(self, method: str, params: dict | None = None) -> dict: if method[:3] == "get": return await self._device._query_getter_helper(method, module, section) - return await self._device._query_setter_helper(method, module, section, params) + if TYPE_CHECKING: + params = cast(dict[str, dict[str, Any]], params) + return await self._device._query_setter_helper( + method, module, section, params[module][section] + ) @property def data(self) -> dict: diff --git a/kasa/tests/fakeprotocol_smartcamera.py b/kasa/tests/fakeprotocol_smartcamera.py index 50d34e938..a8c49bd4a 100644 --- a/kasa/tests/fakeprotocol_smartcamera.py +++ b/kasa/tests/fakeprotocol_smartcamera.py @@ -162,6 +162,24 @@ def _get_param_set_value(info: dict, set_keys: list[str], value): "lens_mask_info", "enabled", ], + ("system", "clock_status", "seconds_from_1970"): [ + "getClockStatus", + "system", + "clock_status", + "seconds_from_1970", + ], + ("system", "clock_status", "local_time"): [ + "getClockStatus", + "system", + "clock_status", + "local_time", + ], + ("system", "basic", "zone_id"): [ + "getTimezone", + "system", + "basic", + "zone_id", + ], } async def _send_request(self, request_dict: dict): @@ -188,12 +206,14 @@ async def _send_request(self, request_dict: dict): for skey, sval in skey_val.items(): section_key = skey section_value = sval + if setter_keys := self.SETTERS.get( + (module, section, section_key) + ): + self._get_param_set_value(info, setter_keys, section_value) + else: + return {"error_code": -1} break - if setter_keys := self.SETTERS.get((module, section, section_key)): - self._get_param_set_value(info, setter_keys, section_value) - return {"error_code": 0} - else: - return {"error_code": -1} + return {"error_code": 0} elif method[:3] == "get": params = request_dict.get("params") if method in info: diff --git a/kasa/tests/smartcamera/test_smartcamera.py b/kasa/tests/smartcamera/test_smartcamera.py index 50a1a1366..3e12dcfb8 100644 --- a/kasa/tests/smartcamera/test_smartcamera.py +++ b/kasa/tests/smartcamera/test_smartcamera.py @@ -2,9 +2,12 @@ from __future__ import annotations +from datetime import datetime, timezone + import pytest +from freezegun.api import FrozenDateTimeFactory -from kasa import Device, DeviceType +from kasa import Device, DeviceType, Module from ..conftest import device_smartcamera, hub_smartcamera @@ -45,3 +48,14 @@ async def test_hub(dev): await child.update() assert "Time" not in child.modules assert child.time + + +@device_smartcamera +async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory): + """Test a child device gets the time from it's parent module.""" + fallback_time = datetime.now(timezone.utc).astimezone().replace(microsecond=0) + assert dev.time != fallback_time + module = dev.modules[Module.Time] + await module.set_time(fallback_time) + await dev.update() + assert dev.time == fallback_time From 91e219f4671cd3cc452e4e2399a1acfbbddf9cca Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 25 Oct 2024 18:04:43 +0100 Subject: [PATCH 618/892] Fix device_config serialisation of https value (#1196) --- kasa/deviceconfig.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 1bd806f0d..e0fd1725c 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -18,8 +18,8 @@ >>> # DeviceConfig.to_dict() can be used to store for later >>> print(config_dict) {'host': '127.0.0.3', 'timeout': 5, 'credentials': Credentials(), 'connection_type'\ -: {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'login_version': 2},\ - 'uses_http': True} +: {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'https': False, \ +'login_version': 2}, 'uses_http': True} >>> later_device = await Device.connect(config=Device.Config.from_dict(config_dict)) >>> print(later_device.alias) # Alias is available as connect() calls update() @@ -34,7 +34,7 @@ import logging from dataclasses import asdict, dataclass, field, fields, is_dataclass from enum import Enum -from typing import TYPE_CHECKING, Dict, Optional, TypedDict, Union +from typing import TYPE_CHECKING, Any, Dict, Optional, TypedDict, Union from .credentials import Credentials from .exceptions import KasaException @@ -145,7 +145,7 @@ def from_values( ) from ex @staticmethod - def from_dict(connection_type_dict: Dict[str, str]) -> "DeviceConnectionParameters": + def from_dict(connection_type_dict: Dict[str, Any]) -> "DeviceConnectionParameters": """Return connection parameters from dict.""" if ( isinstance(connection_type_dict, dict) @@ -158,15 +158,17 @@ def from_dict(connection_type_dict: Dict[str, str]) -> "DeviceConnectionParamete device_family, encryption_type, login_version, # type: ignore[arg-type] + connection_type_dict.get("https", False), ) raise KasaException(f"Invalid connection type data for {connection_type_dict}") - def to_dict(self) -> Dict[str, Union[str, int]]: + def to_dict(self) -> Dict[str, Union[str, int, bool]]: """Convert connection params to dict.""" result: Dict[str, Union[str, int]] = { "device_family": self.device_family.value, "encryption_type": self.encryption_type.value, + "https": self.https, } if self.login_version: result["login_version"] = self.login_version From 1e0ca799bc516503918b6957eb95dbf069f3644c Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 25 Oct 2024 18:30:21 +0100 Subject: [PATCH 619/892] Add stream_rtsp_url to camera module (#1197) --- kasa/experimental/modules/camera.py | 30 ++++++++++++++-- kasa/experimental/sslaestransport.py | 10 +++--- kasa/tests/smartcamera/test_smartcamera.py | 42 ++++++++++++++++++++-- 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/kasa/experimental/modules/camera.py b/kasa/experimental/modules/camera.py index 76701b52a..ecd7fff70 100644 --- a/kasa/experimental/modules/camera.py +++ b/kasa/experimental/modules/camera.py @@ -2,10 +2,15 @@ from __future__ import annotations +from urllib.parse import quote_plus + +from ...credentials import Credentials from ...device_type import DeviceType from ...feature import Feature from ..smartcameramodule import SmartCameraModule +LOCAL_STREAMING_PORT = 554 + class Camera(SmartCameraModule): """Implementation of device module.""" @@ -31,11 +36,32 @@ def _initialize_features(self) -> None: @property def is_on(self) -> bool: """Return the device id.""" - return self.data["lens_mask_info"]["enabled"] == "on" + return self.data["lens_mask_info"]["enabled"] == "off" + + def stream_rtsp_url(self, credentials: Credentials | None = None) -> str | None: + """Return the local rtsp streaming url. + + :param credentials: Credentials for camera account. + These could be different credentials to tplink cloud credentials. + If not provided will use tplink credentials if available + :return: rtsp url with escaped credentials or None if no credentials or + camera is off. + """ + if not self.is_on: + return None + dev = self._device + if not credentials: + credentials = dev.credentials + if not credentials or not credentials.username or not credentials.password: + return None + username = quote_plus(credentials.username) + password = quote_plus(credentials.password) + return f"rtsp://{username}:{password}@{dev.host}:{LOCAL_STREAMING_PORT}/stream1" async def set_state(self, on: bool) -> dict: """Set the device state.""" - params = {"enabled": "on" if on else "off"} + # Turning off enables the privacy mask which is why value is reversed. + params = {"enabled": "off" if on else "on"} return await self._device._query_setter_helper( "setLensMaskConfig", self.QUERY_MODULE_NAME, "lens_mask_info", params ) diff --git a/kasa/experimental/sslaestransport.py b/kasa/experimental/sslaestransport.py index f095a11ec..2a5d12e2d 100644 --- a/kasa/experimental/sslaestransport.py +++ b/kasa/experimental/sslaestransport.py @@ -120,11 +120,13 @@ def __init__( self._seq: int | None = None self._pwd_hash: str | None = None self._username: str | None = None + self._password: str | None = None if self._credentials != Credentials() and self._credentials: self._username = self._credentials.username + self._password = self._credentials.password elif self._credentials_hash: ch = json_loads(base64.b64decode(self._credentials_hash.encode())) - self._pwd_hash = ch["pwd"] + self._password = ch["pwd"] self._username = ch["un"] self._local_nonce: str | None = None @@ -140,10 +142,10 @@ def credentials_hash(self) -> str | None: """The hashed credentials used by the transport.""" if self._credentials == Credentials(): return None - if self._credentials_hash: + if not self._credentials and self._credentials_hash: return self._credentials_hash - if self._pwd_hash and self._credentials: - ch = {"un": self._credentials.username, "pwd": self._pwd_hash} + if (cred := self._credentials) and cred.password and cred.username: + ch = {"un": cred.username, "pwd": cred.password} return base64.b64encode(json_dumps(ch).encode()).decode() return None diff --git a/kasa/tests/smartcamera/test_smartcamera.py b/kasa/tests/smartcamera/test_smartcamera.py index 3e12dcfb8..1185943ac 100644 --- a/kasa/tests/smartcamera/test_smartcamera.py +++ b/kasa/tests/smartcamera/test_smartcamera.py @@ -3,13 +3,14 @@ from __future__ import annotations from datetime import datetime, timezone +from unittest.mock import patch import pytest from freezegun.api import FrozenDateTimeFactory -from kasa import Device, DeviceType, Module +from kasa import Credentials, Device, DeviceType, Module -from ..conftest import device_smartcamera, hub_smartcamera +from ..conftest import camera_smartcamera, device_smartcamera, hub_smartcamera @device_smartcamera @@ -23,6 +24,43 @@ async def test_state(dev: Device): assert dev.is_on is not state +@camera_smartcamera +async def test_stream_rtsp_url(dev: Device): + camera_module = dev.modules.get(Module.Camera) + assert camera_module + + await camera_module.set_state(True) + await dev.update() + assert camera_module.is_on + url = camera_module.stream_rtsp_url(Credentials("foo", "bar")) + assert url == "rtsp://foo:bar@127.0.0.123:554/stream1" + + with patch.object( + dev.protocol._transport, "_credentials", Credentials("bar", "foo") + ): + url = camera_module.stream_rtsp_url() + assert url == "rtsp://bar:foo@127.0.0.123:554/stream1" + + with patch.object(dev.protocol._transport, "_credentials", Credentials("bar", "")): + url = camera_module.stream_rtsp_url() + assert url is None + + with patch.object(dev.protocol._transport, "_credentials", Credentials("", "Foo")): + url = camera_module.stream_rtsp_url() + assert url is None + + # Test with camera off + await camera_module.set_state(False) + await dev.update() + url = camera_module.stream_rtsp_url(Credentials("foo", "bar")) + assert url is None + with patch.object( + dev.protocol._transport, "_credentials", Credentials("bar", "foo") + ): + url = camera_module.stream_rtsp_url() + assert url is None + + @device_smartcamera async def test_alias(dev): test_alias = "TEST1234" From 8b95b7d5573f972562620ae4759823baf6a7a402 Mon Sep 17 00:00:00 2001 From: Fulch36 <9916786+Fulch36@users.noreply.github.com> Date: Fri, 25 Oct 2024 19:24:43 +0100 Subject: [PATCH 620/892] Fallback to get_current_power if get_energy_usage does not provide current_power (#1186) --- kasa/smart/modules/energy.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/kasa/smart/modules/energy.py b/kasa/smart/modules/energy.py index 166f688ea..ab89c3193 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -28,6 +28,12 @@ def current_consumption(self) -> float | None: """Current power in watts.""" if (power := self.energy.get("current_power")) is not None: return power / 1_000 + # Fallback if get_energy_usage does not provide current_power, + # which can happen on some newer devices (e.g. P304M). + elif ( + power := self.data.get("get_current_power").get("current_power") + ) is not None: + return power return None @property @@ -105,3 +111,8 @@ async def get_daily_stats(self, *, year=None, month=None, kwh=True) -> dict: async def get_monthly_stats(self, *, year=None, kwh=True) -> dict: """Return monthly stats for the given year.""" raise KasaException("Device does not support periodic statistics") + + async def _check_supported(self): + """Additional check to see if the module is supported by the device.""" + # Energy module is not supported on P304M parent device + return "device_on" in self._device.sys_info From 7eb8d45b6eec3fad65f7f13c34d1613afee01257 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 25 Oct 2024 19:27:40 +0100 Subject: [PATCH 621/892] Try default logon credentials in SslAesTransport (#1195) Also ensure `AuthenticationErrors` are raised during handshake1. --- kasa/exceptions.py | 1 + kasa/experimental/sslaestransport.py | 126 ++++++++++++++++----------- kasa/protocol.py | 1 + 3 files changed, 78 insertions(+), 50 deletions(-) diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 9172cfc32..b646e514c 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -186,6 +186,7 @@ def from_int(value: int) -> SmartErrorCode: SmartErrorCode.UNSPECIFIC_ERROR, SmartErrorCode.SESSION_TIMEOUT_ERROR, SmartErrorCode.SESSION_EXPIRED, + SmartErrorCode.INVALID_NONCE, ] SMART_AUTHENTICATION_ERRORS = [ diff --git a/kasa/experimental/sslaestransport.py b/kasa/experimental/sslaestransport.py index 2a5d12e2d..9f8912636 100644 --- a/kasa/experimental/sslaestransport.py +++ b/kasa/experimental/sslaestransport.py @@ -8,7 +8,6 @@ import logging import secrets import ssl -import time from enum import Enum, auto from typing import TYPE_CHECKING, Any, Dict, cast @@ -29,7 +28,7 @@ from ..httpclient import HttpClient from ..json import dumps as json_dumps from ..json import loads as json_loads -from ..protocol import BaseTransport +from ..protocol import DEFAULT_CREDENTIALS, BaseTransport, get_default_credentials _LOGGER = logging.getLogger(__name__) @@ -71,7 +70,6 @@ class SslAesTransport(BaseTransport): "Accept": "application/json", "Accept-Encoding": "gzip, deflate", "User-Agent": "Tapo CameraClient Android", - "Connection": "close", } CIPHERS = ":".join( [ @@ -96,7 +94,9 @@ def __init__( not self._credentials or self._credentials.username is None ) and not self._credentials_hash: self._credentials = Credentials() - self._default_credentials: Credentials | None = None + self._default_credentials: Credentials = get_default_credentials( + DEFAULT_CREDENTIALS["TAPOCAMERA"] + ) if not config.timeout: config.timeout = self.DEFAULT_TIMEOUT @@ -149,7 +149,7 @@ def credentials_hash(self) -> str | None: return base64.b64encode(json_dumps(ch).encode()).decode() return None - def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: + def _get_response_error(self, resp_dict: Any) -> SmartErrorCode: error_code_raw = resp_dict.get("error_code") try: error_code = SmartErrorCode.from_int(error_code_raw) @@ -158,6 +158,10 @@ def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: "Device %s received unknown error code: %s", self._host, error_code_raw ) error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR + return error_code + + def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: + error_code = self._get_response_error(resp_dict) if error_code is SmartErrorCode.SUCCESS: return msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})" @@ -325,6 +329,8 @@ async def perform_handshake2(self, local_nonce, server_nonce, pwd_hash) -> None: + f"status code {status_code} to handshake2" ) resp_dict = cast(dict, resp_dict) + self._handle_response_error_code(resp_dict, "Error in handshake2") + self._seq = resp_dict["result"]["start_seq"] stok = resp_dict["result"]["stok"] self._token_url = URL(f"{str(self._app_url)}/stok={stok}/ds") @@ -337,42 +343,41 @@ async def perform_handshake2(self, local_nonce, server_nonce, pwd_hash) -> None: _LOGGER.debug("Handshake2 complete ...") async def perform_handshake1(self) -> tuple[str, str, str]: - """Perform the handshake.""" - _LOGGER.debug("Will perform handshaking...") - - if not self._username: - raise KasaException("Cannot connect to device with no credentials") - local_nonce = secrets.token_bytes(8).hex().upper() - # Device needs the content length or it will response with 500 - body = { - "method": "login", - "params": { - "cnonce": local_nonce, - "encrypt_type": "3", - "username": self._username, - }, - } - http_client = self._http_client + """Perform the handshake1.""" + resp_dict = None + if self._username: + local_nonce = secrets.token_bytes(8).hex().upper() + resp_dict = await self.try_send_handshake1(self._username, local_nonce) - status_code, resp_dict = await http_client.post( - self._app_url, - json=body, - headers=self._headers, - ssl=await self._get_ssl_context(), - ) - - _LOGGER.debug("Device responded with: %s", resp_dict) - - if status_code != 200: - raise KasaException( - f"{self._host} responded with an unexpected " - + f"status code {status_code} to handshake1" + # Try the default username. If it fails raise the original error_code + if ( + not resp_dict + or (error_code := self._get_response_error(resp_dict)) + is not SmartErrorCode.INVALID_NONCE + or "nonce" not in resp_dict["result"].get("data", {}) + ): + local_nonce = secrets.token_bytes(8).hex().upper() + default_resp_dict = await self.try_send_handshake1( + self._default_credentials.username, local_nonce ) + if ( + default_error_code := self._get_response_error(default_resp_dict) + ) is SmartErrorCode.INVALID_NONCE and "nonce" in default_resp_dict[ + "result" + ].get("data", {}): + _LOGGER.debug("Connected to {self._host} with default username") + self._username = self._default_credentials.username + error_code = default_error_code + resp_dict = default_resp_dict - resp_dict = cast(dict, resp_dict) - error_code = SmartErrorCode.from_int(resp_dict["error_code"]) - if error_code != SmartErrorCode.INVALID_NONCE: - self._handle_response_error_code(resp_dict, "Unable to complete handshake") + if not self._username: + raise AuthenticationError( + "Credentials must be supplied to connect to {self._host}" + ) + if error_code is not SmartErrorCode.INVALID_NONCE or ( + resp_dict and "nonce" not in resp_dict["result"].get("data", {}) + ): + raise AuthenticationError("Error trying handshake1: {resp_dict}") if TYPE_CHECKING: resp_dict = cast(Dict[str, Any], resp_dict) @@ -381,10 +386,10 @@ async def perform_handshake1(self) -> tuple[str, str, str]: device_confirm = resp_dict["result"]["data"]["device_confirm"] if self._credentials and self._credentials != Credentials(): pwd_hash = _sha256_hash(self._credentials.password.encode()) + elif self._username and self._password: + pwd_hash = _sha256_hash(self._password.encode()) else: - if TYPE_CHECKING: - assert self._pwd_hash - pwd_hash = self._pwd_hash + pwd_hash = _sha256_hash(self._default_credentials.password.encode()) expected_confirm_sha256 = self.generate_confirm_hash( local_nonce, server_nonce, pwd_hash @@ -408,19 +413,40 @@ async def perform_handshake1(self) -> tuple[str, str, str]: _LOGGER.debug(msg) raise AuthenticationError(msg) - def _handshake_session_expired(self): - """Return true if session has expired.""" - return ( - self._session_expire_at is None - or self._session_expire_at - time.time() <= 0 + async def try_send_handshake1(self, username: str, local_nonce: str) -> dict: + """Perform the handshake.""" + _LOGGER.debug("Will to send handshake1...") + + body = { + "method": "login", + "params": { + "cnonce": local_nonce, + "encrypt_type": "3", + "username": self._username, + }, + } + http_client = self._http_client + + status_code, resp_dict = await http_client.post( + self._app_url, + json=body, + headers=self._headers, + ssl=await self._get_ssl_context(), ) + _LOGGER.debug("Device responded with: %s", resp_dict) + + if status_code != 200: + raise KasaException( + f"{self._host} responded with an unexpected " + + f"status code {status_code} to handshake1" + ) + + return cast(dict, resp_dict) + async def send(self, request: str) -> dict[str, Any]: """Send the request.""" - if ( - self._state is TransportState.HANDSHAKE_REQUIRED - or self._handshake_session_expired() - ): + if self._state is TransportState.HANDSHAKE_REQUIRED: await self.perform_handshake() return await self.send_secure_passthrough(request) diff --git a/kasa/protocol.py b/kasa/protocol.py index 9b5ffa3d3..1107fa1d7 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -155,4 +155,5 @@ def get_default_credentials(tuple: tuple[str, str]) -> Credentials: DEFAULT_CREDENTIALS = { "KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"), "TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="), + "TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="), } From 88b7951feeb80f40cded04c3c0d3c42b35a121d1 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 25 Oct 2024 19:43:37 +0100 Subject: [PATCH 622/892] Allow passing an aiohttp client session during discover try_connect_all (#1198) --- kasa/discover.py | 6 ++++++ kasa/tests/test_discovery.py | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/kasa/discover.py b/kasa/discover.py index 5df094bb5..ade6a54a6 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -93,6 +93,8 @@ from pprint import pformat as pf from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, cast +from aiohttp import ClientSession + # 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 @@ -533,6 +535,7 @@ async def try_connect_all( port: int | None = None, timeout: int | None = None, credentials: Credentials | None = None, + http_client: ClientSession | None = None, ) -> Device | None: """Try to connect directly to a device with all possible parameters. @@ -544,6 +547,7 @@ async def try_connect_all( :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. + :param http_client: Optional client session for devices that use http. username and password are ignored if provided. """ from .device_factory import _connect @@ -570,6 +574,8 @@ async def try_connect_all( timeout=timeout, port_override=port, credentials=credentials, + http_client=http_client, + uses_http=encrypt is not Device.EncryptionType.Xor, ) ) and (protocol := get_protocol(config)) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index ff21b610a..a31ef8363 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -697,9 +697,13 @@ async def _update(self, *args, **kwargs): mocker.patch("kasa.SmartProtocol.query", new=_query) mocker.patch.object(dev_class, "update", new=_update) - dev = await Discover.try_connect_all(discovery_mock.ip) + session = aiohttp.ClientSession() + dev = await Discover.try_connect_all(discovery_mock.ip, http_client=session) assert dev assert isinstance(dev, dev_class) assert isinstance(dev.protocol, protocol_class) assert isinstance(dev.protocol._transport, transport_class) + assert dev.config.uses_http is (transport_class != XorTransport) + if transport_class != XorTransport: + assert dev.protocol._transport._http_client.client == session From 51611156217b2d1cb12e17455193adf0dd066e13 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sun, 27 Oct 2024 12:08:02 +0000 Subject: [PATCH 623/892] Update SMART test framework to use fake child protocols (#1199) --- kasa/tests/fakeprotocol_smart.py | 155 ++++++++++++++++++++++--- kasa/tests/fakeprotocol_smartcamera.py | 68 ++--------- kasa/tests/fixtureinfo.py | 11 +- kasa/tests/test_emeter.py | 11 ++ 4 files changed, 170 insertions(+), 75 deletions(-) diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 6c9423ecc..c3d8104e9 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -1,17 +1,19 @@ import copy from json import loads as json_loads +from warnings import warn import pytest from kasa import Credentials, DeviceConfig, SmartProtocol from kasa.exceptions import SmartErrorCode from kasa.protocol import BaseTransport +from kasa.smart import SmartChildDevice class FakeSmartProtocol(SmartProtocol): - def __init__(self, info, fixture_name): + def __init__(self, info, fixture_name, *, is_child=False): super().__init__( - transport=FakeSmartTransport(info, fixture_name), + transport=FakeSmartTransport(info, fixture_name, is_child=is_child), ) async def query(self, request, retry_count: int = 3): @@ -30,6 +32,7 @@ def __init__( component_nego_not_included=False, warn_fixture_missing_methods=True, fix_incomplete_fixture_lists=True, + is_child=False, ): super().__init__( config=DeviceConfig( @@ -41,7 +44,15 @@ def __init__( ), ) self.fixture_name = fixture_name - self.info = copy.deepcopy(info) + # Don't copy the dict if the device is a child so that updates on the + # child are then still reflected on the parent's lis of child device in + if not is_child: + self.info = copy.deepcopy(info) + self.child_protocols = self._get_child_protocols( + self.info, self.fixture_name, "get_child_device_list" + ) + else: + self.info = info if not component_nego_not_included: self.components = { comp["id"]: comp["ver_code"] @@ -125,7 +136,7 @@ async def send(self, request: str): params = request_dict["params"] responses = [] for request in params["requests"]: - response = self._send_request(request) # type: ignore[arg-type] + response = await self._send_request(request) # type: ignore[arg-type] # Devices do not continue after error if response["error_code"] != 0: break @@ -133,11 +144,111 @@ async def send(self, request: str): responses.append(response) return {"result": {"responses": responses}, "error_code": 0} else: - return self._send_request(request_dict) + return await self._send_request(request_dict) - def _handle_control_child(self, params: dict): + @staticmethod + def _get_child_protocols( + parent_fixture_info, parent_fixture_name, child_devices_key + ): + child_infos = parent_fixture_info.get(child_devices_key, {}).get( + "child_device_list", [] + ) + if not child_infos: + return + found_child_fixture_infos = [] + child_protocols = {} + # imported here to avoid circular import + from .conftest import filter_fixtures + + def try_get_child_fixture_info(child_dev_info): + hw_version = child_dev_info["hw_ver"] + sw_version = child_dev_info["fw_ver"] + sw_version = sw_version.split(" ")[0] + model = child_dev_info["model"] + region = child_dev_info.get("specs", "XX") + child_fixture_name = f"{model}({region})_{hw_version}_{sw_version}" + child_fixtures = filter_fixtures( + "Child fixture", + protocol_filter={"SMART.CHILD"}, + model_filter={child_fixture_name}, + ) + if child_fixtures: + return next(iter(child_fixtures)) + return None + + for child_info in child_infos: + if ( # Is SMART protocol + (device_id := child_info.get("device_id")) + and (category := child_info.get("category")) + and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP + ): + if fixture_info_tuple := try_get_child_fixture_info(child_info): + child_fixture = copy.deepcopy(fixture_info_tuple.data) + child_fixture["get_device_info"]["device_id"] = device_id + found_child_fixture_infos.append(child_fixture["get_device_info"]) + child_protocols[device_id] = FakeSmartProtocol( + child_fixture, fixture_info_tuple.name, is_child=True + ) + # Look for fixture inline + elif (child_fixtures := parent_fixture_info.get("child_devices")) and ( + child_fixture := child_fixtures.get(device_id) + ): + found_child_fixture_infos.append(child_fixture["get_device_info"]) + child_protocols[device_id] = FakeSmartProtocol( + child_fixture, + f"{parent_fixture_name}-{device_id}", + is_child=True, + ) + else: + warn( + f"Could not find child SMART fixture for {child_info}", + stacklevel=1, + ) + else: + warn( + f"Child is a cameraprotocol which needs to be implemented {child_info}", + stacklevel=1, + ) + # Replace parent child infos with the infos from the child fixtures so + # that updates update both + if child_infos and found_child_fixture_infos: + parent_fixture_info[child_devices_key]["child_device_list"] = ( + found_child_fixture_infos + ) + return child_protocols + + async def _handle_control_child(self, params: dict): """Handle control_child command.""" device_id = params.get("device_id") + if device_id not in self.child_protocols: + warn( + f"Could not find child fixture {device_id} in {self.fixture_name}", + stacklevel=1, + ) + return self._handle_control_child_missing(params) + + child_protocol: SmartProtocol = self.child_protocols[device_id] + + request_data = params.get("requestData", {}) + + child_method = request_data.get("method") + child_params = request_data.get("params") # noqa: F841 + + resp = await child_protocol.query({child_method: child_params}) + resp["error_code"] = 0 + for val in resp.values(): + return { + "result": {"responseData": {"result": val, "error_code": 0}}, + "error_code": 0, + } + + def _handle_control_child_missing(self, params: dict): + """Handle control_child command. + + Used for older fixtures where child info wasn't stored in the fixture. + TODO: Should be removed somehow for future maintanability. + """ + device_id = params.get("device_id") request_data = params.get("requestData", {}) child_method = request_data.get("method") @@ -156,7 +267,7 @@ def _handle_control_child(self, params: dict): # Get the method calls made directly on the child devices child_device_calls = self.info["child_devices"].setdefault(device_id, {}) - # We only support get & set device info for now. + # We only support get & set device info in this method for missing. if child_method == "get_device_info": result = copy.deepcopy(info) return {"result": result, "error_code": 0} @@ -216,14 +327,17 @@ def _get_on_off_gradually_info(self, info, params): def _set_on_off_gradually_info(self, info, params): # Child devices can have the required properties directly in info + # the _handle_control_child_missing directly passes in get_device_info + sys_info = info.get("get_device_info", info) + if self.components["on_off_gradually"] == 1: info["get_on_off_gradually_info"] = {"enable": params["enable"]} elif on_state := params.get("on_state"): - if "fade_on_time" in info and "gradually_on_mode" in info: - info["gradually_on_mode"] = 1 if on_state["enable"] else 0 + if "fade_on_time" in sys_info and "gradually_on_mode" in sys_info: + sys_info["gradually_on_mode"] = 1 if on_state["enable"] else 0 if "duration" in on_state: - info["fade_on_time"] = on_state["duration"] - else: + sys_info["fade_on_time"] = on_state["duration"] + if "get_on_off_gradually_info" in info: info["get_on_off_gradually_info"]["on_state"]["enable"] = on_state[ "enable" ] @@ -232,11 +346,11 @@ def _set_on_off_gradually_info(self, info, params): on_state["duration"] ) elif off_state := params.get("off_state"): - if "fade_off_time" in info and "gradually_off_mode" in info: - info["gradually_off_mode"] = 1 if off_state["enable"] else 0 + if "fade_off_time" in sys_info and "gradually_off_mode" in sys_info: + sys_info["gradually_off_mode"] = 1 if off_state["enable"] else 0 if "duration" in off_state: - info["fade_off_time"] = off_state["duration"] - else: + sys_info["fade_off_time"] = off_state["duration"] + if "get_on_off_gradually_info" in info: info["get_on_off_gradually_info"]["off_state"]["enable"] = off_state[ "enable" ] @@ -290,6 +404,13 @@ def _set_preset_rules(self, info, params): if "brightness" not in info["get_preset_rules"]: return {"error_code": SmartErrorCode.PARAMS_ERROR} info["get_preset_rules"]["brightness"] = params["brightness"] + # So far the only child device with light preset (KS240) also has the + # data available to read in the device_info. + device_info = info["get_device_info"] + if "preset_state" in device_info: + device_info["preset_state"] = [ + {"brightness": b} for b in params["brightness"] + ] return {"error_code": 0} def _set_child_preset_rules(self, info, params): @@ -309,12 +430,12 @@ def _edit_preset_rules(self, info, params): info["get_preset_rules"]["states"][params["index"]] = params["state"] return {"error_code": 0} - def _send_request(self, request_dict: dict): + async def _send_request(self, request_dict: dict): method = request_dict["method"] info = self.info if method == "control_child": - return self._handle_control_child(request_dict["params"]) + return await self._handle_control_child(request_dict["params"]) params = request_dict.get("params") if method == "component_nego" or method[:4] == "get_": diff --git a/kasa/tests/fakeprotocol_smartcamera.py b/kasa/tests/fakeprotocol_smartcamera.py index a8c49bd4a..d7465489c 100644 --- a/kasa/tests/fakeprotocol_smartcamera.py +++ b/kasa/tests/fakeprotocol_smartcamera.py @@ -2,20 +2,18 @@ import copy from json import loads as json_loads -from warnings import warn from kasa import Credentials, DeviceConfig, SmartProtocol from kasa.experimental.smartcameraprotocol import SmartCameraProtocol from kasa.protocol import BaseTransport -from kasa.smart import SmartChildDevice -from .fakeprotocol_smart import FakeSmartProtocol +from .fakeprotocol_smart import FakeSmartTransport class FakeSmartCameraProtocol(SmartCameraProtocol): - def __init__(self, info, fixture_name): + def __init__(self, info, fixture_name, *, is_child=False): super().__init__( - transport=FakeSmartCameraTransport(info, fixture_name), + transport=FakeSmartCameraTransport(info, fixture_name, is_child=is_child), ) async def query(self, request, retry_count: int = 3): @@ -31,6 +29,7 @@ def __init__( fixture_name, *, list_return_size=10, + is_child=False, ): super().__init__( config=DeviceConfig( @@ -42,8 +41,14 @@ def __init__( ), ) self.fixture_name = fixture_name - self.info = copy.deepcopy(info) - self.child_protocols = self._get_child_protocols() + if not is_child: + self.info = copy.deepcopy(info) + self.child_protocols = FakeSmartTransport._get_child_protocols( + self.info, self.fixture_name, "getChildDeviceList" + ) + else: + self.info = info + # self.child_protocols = self._get_child_protocols() self.list_return_size = list_return_size @property @@ -74,55 +79,6 @@ async def send(self, request: str): else: return await self._send_request(request_dict) - def _get_child_protocols(self): - child_infos = self.info.get("getChildDeviceList", {}).get( - "child_device_list", [] - ) - found_child_fixture_infos = [] - child_protocols = {} - # imported here to avoid circular import - from .conftest import filter_fixtures - - for child_info in child_infos: - if ( - (device_id := child_info.get("device_id")) - and (category := child_info.get("category")) - and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP - ): - hw_version = child_info["hw_ver"] - sw_version = child_info["fw_ver"] - sw_version = sw_version.split(" ")[0] - model = child_info["model"] - region = child_info["specs"] - child_fixture_name = f"{model}({region})_{hw_version}_{sw_version}" - child_fixtures = filter_fixtures( - "Child fixture", - protocol_filter={"SMART.CHILD"}, - model_filter=child_fixture_name, - ) - if child_fixtures: - fixture_info = next(iter(child_fixtures)) - found_child_fixture_infos.append(child_info) - child_protocols[device_id] = FakeSmartProtocol( - fixture_info.data, fixture_info.name - ) - else: - warn( - f"Could not find child fixture {child_fixture_name}", - stacklevel=1, - ) - else: - warn( - f"Child is a cameraprotocol which needs to be implemented {child_info}", - stacklevel=1, - ) - # Replace child infos with the infos that found child fixtures - if child_infos: - self.info["getChildDeviceList"]["child_device_list"] = ( - found_child_fixture_infos - ) - return child_protocols - async def _handle_control_child(self, params: dict): """Handle control_child command.""" device_id = params.get("device_id") diff --git a/kasa/tests/fixtureinfo.py b/kasa/tests/fixtureinfo.py index 8db960240..9f4d39529 100644 --- a/kasa/tests/fixtureinfo.py +++ b/kasa/tests/fixtureinfo.py @@ -118,10 +118,17 @@ def filter_fixtures( """ def _model_match(fixture_data: FixtureInfo, model_filter: set[str]): + if isinstance(model_filter, str): + model_filter = {model_filter} + assert isinstance(model_filter, set), "model filter must be a set" model_filter_list = [mf for mf in model_filter] - if len(model_filter_list) == 1 and model_filter_list[0].split("_") == 3: + if ( + len(model_filter_list) == 1 + and (model := model_filter_list[0]) + and len(model.split("_")) == 3 + ): # return exact match - return fixture_data.name == model_filter_list[0] + return fixture_data.name == f"{model}.json" file_model_region = fixture_data.name.split("_")[0] file_model = file_model_region.split("(")[0] return file_model in model_filter diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 3cc69193b..d5a35758d 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -14,6 +14,8 @@ from kasa.interfaces.energy import Energy from kasa.iot import IotDevice, IotStrip from kasa.iot.modules.emeter import Emeter +from kasa.smart import SmartDevice +from kasa.smart.modules import Energy as SmartEnergyModule from .conftest import has_emeter, has_emeter_iot, no_emeter @@ -54,6 +56,11 @@ async def test_no_emeter(dev): @has_emeter async def test_get_emeter_realtime(dev): + if isinstance(dev, SmartDevice): + mod = SmartEnergyModule(dev, str(Module.Energy)) + if not await mod._check_supported(): + pytest.skip(f"Energy module not supported for {dev}.") + assert dev.has_emeter current_emeter = await dev.get_emeter_realtime() @@ -178,6 +185,10 @@ def data(self): @has_emeter async def test_supported(dev: Device): + if isinstance(dev, SmartDevice): + mod = SmartEnergyModule(dev, str(Module.Energy)) + if not await mod._check_supported(): + pytest.skip(f"Energy module not supported for {dev}.") energy_module = dev.modules.get(Module.Energy) assert energy_module if isinstance(dev, IotDevice): From c051e75d1d288fc12a887b30e437c7bb9a33d909 Mon Sep 17 00:00:00 2001 From: Fulch36 <9916786+Fulch36@users.noreply.github.com> Date: Sun, 27 Oct 2024 12:15:13 +0000 Subject: [PATCH 624/892] Add P304M(UK) test fixture (#1185) P304M supports energy monitoring on child SMART devices. --- README.md | 2 +- SUPPORTED.md | 2 + kasa/tests/device_fixtures.py | 4 +- .../fixtures/smart/P304M(UK)_1.0_1.0.3.json | 2262 +++++++++++++++++ 4 files changed, 2267 insertions(+), 3 deletions(-) create mode 100644 kasa/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json diff --git a/README.md b/README.md index d9a1ac813..6e883dbcd 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ The following devices have been tested and confirmed as working. If your device ### Supported Tapo\* devices - **Plugs**: P100, P110, P115, P125M, P135, TP15 -- **Power Strips**: P300, TP25 +- **Power Strips**: P300, P304M, TP25 - **Wall Switches**: S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E - **Light Strips**: L900-10, L900-5, L920-5, L930-5 diff --git a/SUPPORTED.md b/SUPPORTED.md index ce0d5a60a..f80362fb2 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -185,6 +185,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.0.13 - Hardware: 1.0 (EU) / Firmware: 1.0.15 - Hardware: 1.0 (EU) / Firmware: 1.0.7 +- **P304M** + - Hardware: 1.0 (UK) / Firmware: 1.0.3 - **TP25** - Hardware: 1.0 (US) / Firmware: 1.0.2 diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 1608be94b..a2ef92c50 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -108,7 +108,7 @@ } SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} -STRIPS_SMART = {"P300", "TP25"} +STRIPS_SMART = {"P300", "P304M", "TP25"} STRIPS = {*STRIPS_IOT, *STRIPS_SMART} DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} @@ -123,7 +123,7 @@ THERMOSTATS_SMART = {"KE100"} WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} -WITH_EMETER_SMART = {"P110", "P115", "KP125M", "EP25"} +WITH_EMETER_SMART = {"P110", "P115", "KP125M", "EP25", "P304M"} WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} DIMMABLE = {*BULBS, *DIMMERS} diff --git a/kasa/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json b/kasa/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json new file mode 100644 index 000000000..4e67f482c --- /dev/null +++ b/kasa/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json @@ -0,0 +1,2262 @@ +{ + "child_devices": { + "SCRUBBED_CHILD_DEVICE_ID_1": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 1, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 14, + "past7": 14, + "today": 0 + }, + "saved_power": { + "past30": 206, + "past7": 206, + "today": 0 + }, + "time_usage": { + "past30": 220, + "past7": 220, + "today": 0 + } + }, + "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": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-10-22 13:30:15", + "month_energy": 14, + "month_runtime": 220, + "today_energy": 0, + "today_runtime": 0 + }, + "get_max_power": { + "max_power": 3252 + }, + "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 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_2": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 2, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 20, + "past7": 20, + "today": 0 + }, + "time_usage": { + "past30": 20, + "past7": 20, + "today": 0 + } + }, + "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": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-10-22 13:30:15", + "month_energy": 0, + "month_runtime": 20, + "today_energy": 0, + "today_runtime": 0 + }, + "get_max_power": { + "max_power": 3244 + }, + "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 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_3": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 3, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 18, + "past7": 18, + "today": 0 + }, + "time_usage": { + "past30": 18, + "past7": 18, + "today": 0 + } + }, + "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": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-10-22 13:30:15", + "month_energy": 0, + "month_runtime": 18, + "today_energy": 0, + "today_runtime": 0 + }, + "get_max_power": { + "max_power": 3262 + }, + "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 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_4": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 4, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "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": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-10-22 13:30:15", + "month_energy": 24, + "month_runtime": 432, + "today_energy": 0, + "today_runtime": 0 + }, + "get_max_power": { + "max_power": 3262 + }, + "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 + } + } + }, + "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": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P304M(UK)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-6E-84-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4" + } + ], + "start_index": 0, + "sum": 4 + }, + "get_child_device_list": { + "child_device_list": [ + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 1, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 2, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 3, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 4, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + } + ], + "start_index": 0, + "sum": 4 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "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": "A8-6E-84-00-00-00", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "Europe/London", + "rssi": -44, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1729600212 + }, + "get_device_usage": { + "power_usage": { + "past30": 14, + "past7": 14, + "today": 0 + }, + "saved_power": { + "past30": 206, + "past7": 206, + "today": 0 + }, + "time_usage": { + "past30": 220, + "past7": 220, + "today": 0 + } + }, + "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_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.3 Build 240605 Rel.091502", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "night_mode", + "led_status": true, + "night_mode": { + "end_time": 461, + "night_mode_type": "sunrise_sunset", + "start_time": 1077, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0-00000000000000000" + }, + "get_protection_power": { + "enabled": false, + "protection_power": 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": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 8, + "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 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P304M", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} From 02876062355db622fea4f8cbd9cb6cdcefbe1f03 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Mon, 28 Oct 2024 13:47:24 +0100 Subject: [PATCH 625/892] Add TC65 fixture (#1200) --- devtools/dump_devinfo.py | 12 +- kasa/experimental/smartcamera.py | 2 +- .../fixtures/smartcamera/TC65_1.0_1.3.9.json | 638 ++++++++++++++++++ 3 files changed, 646 insertions(+), 6 deletions(-) create mode 100644 kasa/tests/fixtures/smartcamera/TC65_1.0_1.3.9.json diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index da46f10a3..91a4505bd 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -987,12 +987,14 @@ async def get_smart_fixtures( copy_folder = SMART_FOLDER else: # smart camera protocol - hw_version = final["getDeviceInfo"]["device_info"]["basic_info"]["hw_version"] - sw_version = final["getDeviceInfo"]["device_info"]["basic_info"]["sw_version"] - model = final["getDeviceInfo"]["device_info"]["basic_info"]["device_model"] - region = final["getDeviceInfo"]["device_info"]["basic_info"]["region"] + basic_info = final["getDeviceInfo"]["device_info"]["basic_info"] + hw_version = basic_info["hw_version"] + sw_version = basic_info["sw_version"] + model = basic_info["device_model"] + region = basic_info.get("region") sw_version = sw_version.split(" ", maxsplit=1)[0] - model = f"{model}({region})" + if region is not None: + model = f"{model}({region})" copy_folder = SMARTCAMERA_FOLDER save_filename = f"{model}_{hw_version}_{sw_version}.json" diff --git a/kasa/experimental/smartcamera.py b/kasa/experimental/smartcamera.py index 52a6acdfa..059bac8e0 100644 --- a/kasa/experimental/smartcamera.py +++ b/kasa/experimental/smartcamera.py @@ -142,7 +142,7 @@ def _map_info(self, device_info: dict) -> dict: "fw_ver": basic_info["sw_version"], "hw_ver": basic_info["hw_version"], "mac": basic_info["mac"], - "hwId": basic_info["hw_id"], + "hwId": basic_info.get("hw_id"), "oem_id": basic_info["oem_id"], } diff --git a/kasa/tests/fixtures/smartcamera/TC65_1.0_1.3.9.json b/kasa/tests/fixtures/smartcamera/TC65_1.0_1.3.9.json new file mode 100644 index 000000000..04f5354d0 --- /dev/null +++ b/kasa/tests/fixtures/smartcamera/TC65_1.0_1.3.9.json @@ -0,0 +1,638 @@ +{ + "discovery_result": { + "decrypted_data": { + "connect_ssid": "0000000", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "TC65", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.9 Build 231024 Rel.72919n(4555)", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-6E-84-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + ".name": "chn1_msg_alarm_plan", + ".type": "plan", + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + ".name": "microphone", + ".type": "audio_config", + "channels": "1", + "encode_type": "G711alaw", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + ".name": "speaker", + ".type": "audio_config", + "volume": "100" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + ".name": "harddisk", + ".type": "storage", + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-10-27 16:56:20", + "seconds_from_1970": 1730044580 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "3", + "rssiValue": -57, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + ".name": "motion_det", + ".type": "on_off", + "digital_sensitivity": "60", + "enabled": "on", + "sensitivity": "medium" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "Baby room", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "TC65 1.0 IPC", + "device_model": "TC65", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": "3", + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "A8-6E-84-00-00-00", + "oem_id": "00000000000000000000000000000000", + "sw_version": "1.3.9 Build 231024 Rel.72919n(4555)" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + ".name": "common", + ".type": "on_off", + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": false, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + ".name": "common", + ".type": "para", + "area_compensation": "default", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "exp_gain": "0", + "exp_type": "auto", + "focus_limited": "600", + "focus_type": "semi_auto", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_start_time": "64800", + "inf_type": "auto", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off" + }, + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getLedStatus": { + "led": { + "config": { + ".name": "config", + ".type": "led", + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + ".name": "lens_mask_info", + ".type": "lens_mask_info", + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + ".name": "common", + ".type": "para", + "area_compensation": "default", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "exp_gain": "0", + "exp_type": "auto", + "focus_limited": "600", + "focus_type": "semi_auto", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_start_time": "64800", + "inf_type": "auto", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + ".name": "media_encrypt", + ".type": "on_off", + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + ".name": "chn1_msg_push_info", + ".type": "on_off", + "notification_enabled": "off", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + ".name": "detection", + ".type": "on_off", + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + ".name": "chn1_channel", + ".type": "plan", + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_total_space": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_total_space": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "type": "local", + "video_free_space": "0B", + "video_total_space": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + ".name": "tamper_det", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTimezone": { + "system": { + "basic": { + ".name": "basic", + ".type": "setting", + "timezone": "UTC+01:00", + "timing_mode": "ntp", + "zone_id": "Europe/Amsterdam" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + ".name": "main", + ".type": "capability", + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "2048" + ], + "encode_types": [ + "H264" + ], + "frame_rates": [ + "65537", + "65546", + "65551" + ], + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2304*1296", + "1920*1080", + "1280*720" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + ".name": "main", + ".type": "stream", + "bitrate": "2048", + "bitrate_type": "vbr", + "encode_type": "H264", + "frame_rate": "65551", + "gop_factor": "2", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "1920*1080", + "stream_type": "general" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + ".name": "device_microphone", + ".type": "capability", + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + ".name": "device_speaker", + ".type": "capability", + "channels": "1", + "decode_type": [ + "G711" + ], + "mute": "0", + "sampling_rate": [ + "8" + ], + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + ".name": "microphone", + ".type": "audio_config", + "channels": "1", + "encode_type": "G711alaw", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + ".name": "speaker", + ".type": "audio_config", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + ".name": "vhttpd", + ".type": "server", + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + ".name": "module_spec", + ".type": "module-spec", + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "audioexception_detection": "0", + "auth_encrypt": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "0", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "greeter": "1.0", + "http_system_state_audio_support": "1", + "intrusion_detection": "1", + "led": "1", + "lens_mask": "1", + "linecrossing_detection": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_alarm_separate_list": [ + "light", + "sound" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi" + ], + "ota_upgrade": "1", + "p2p_support_versions": [ + "1.1" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "1.3" + ], + "reonboarding": "1", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "1.0" + ], + "target_track": "0", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264" + ], + "video_detection_digital_sensitivity": "1", + "wifi_cascade_connection": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "1" + } + } + } + } +} From 440b2d153bbc94db7c70f1ab8645e1280926d4e7 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 28 Oct 2024 16:36:34 +0000 Subject: [PATCH 626/892] Fix SslAesTransport default login and add tests (#1202) Co-authored-by: Teemu R. --- kasa/experimental/sslaestransport.py | 21 +- kasa/tests/test_sslaestransport.py | 374 +++++++++++++++++++++++++++ 2 files changed, 390 insertions(+), 5 deletions(-) create mode 100644 kasa/tests/test_sslaestransport.py diff --git a/kasa/experimental/sslaestransport.py b/kasa/experimental/sslaestransport.py index 9f8912636..eddc6698d 100644 --- a/kasa/experimental/sslaestransport.py +++ b/kasa/experimental/sslaestransport.py @@ -137,6 +137,11 @@ def default_port(self) -> int: """Default port for the transport.""" return self.DEFAULT_PORT + @staticmethod + def _create_b64_credentials(credentials: Credentials) -> str: + ch = {"un": credentials.username, "pwd": credentials.password} + return base64.b64encode(json_dumps(ch).encode()).decode() + @property def credentials_hash(self) -> str | None: """The hashed credentials used by the transport.""" @@ -145,8 +150,7 @@ def credentials_hash(self) -> str | None: if not self._credentials and self._credentials_hash: return self._credentials_hash if (cred := self._credentials) and cred.password and cred.username: - ch = {"un": cred.username, "pwd": cred.password} - return base64.b64encode(json_dumps(ch).encode()).decode() + return self._create_b64_credentials(cred) return None def _get_response_error(self, resp_dict: Any) -> SmartErrorCode: @@ -329,6 +333,13 @@ async def perform_handshake2(self, local_nonce, server_nonce, pwd_hash) -> None: + f"status code {status_code} to handshake2" ) resp_dict = cast(dict, resp_dict) + if ( + error_code := self._get_response_error(resp_dict) + ) and error_code is SmartErrorCode.INVALID_NONCE: + raise AuthenticationError( + f"Invalid password hash in handshake2 for {self._host}" + ) + self._handle_response_error_code(resp_dict, "Error in handshake2") self._seq = resp_dict["result"]["start_seq"] @@ -372,12 +383,12 @@ async def perform_handshake1(self) -> tuple[str, str, str]: if not self._username: raise AuthenticationError( - "Credentials must be supplied to connect to {self._host}" + f"Credentials must be supplied to connect to {self._host}" ) if error_code is not SmartErrorCode.INVALID_NONCE or ( resp_dict and "nonce" not in resp_dict["result"].get("data", {}) ): - raise AuthenticationError("Error trying handshake1: {resp_dict}") + raise AuthenticationError(f"Error trying handshake1: {resp_dict}") if TYPE_CHECKING: resp_dict = cast(Dict[str, Any], resp_dict) @@ -422,7 +433,7 @@ async def try_send_handshake1(self, username: str, local_nonce: str) -> dict: "params": { "cnonce": local_nonce, "encrypt_type": "3", - "username": self._username, + "username": username, }, } http_client = self._http_client diff --git a/kasa/tests/test_sslaestransport.py b/kasa/tests/test_sslaestransport.py new file mode 100644 index 000000000..bea10528b --- /dev/null +++ b/kasa/tests/test_sslaestransport.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +import logging +import secrets +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 + +import aiohttp +import pytest +from yarl import URL + +from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials + +from ..aestransport import AesEncyptionSession +from ..credentials import Credentials +from ..deviceconfig import DeviceConfig +from ..exceptions import ( + AuthenticationError, + KasaException, + SmartErrorCode, +) +from ..experimental.sslaestransport import SslAesTransport, TransportState, _sha256_hash +from ..httpclient import HttpClient + +MOCK_ADMIN_USER = get_default_credentials(DEFAULT_CREDENTIALS["TAPOCAMERA"]).username +MOCK_PWD = "correct_pwd" # noqa: S105 +MOCK_USER = "mock@example.com" +MOCK_STOCK = "abcdefghijklmnopqrstuvwxyz1234)(" + + +@pytest.mark.parametrize( + ( + "status_code", + "username", + "password", + "wants_default_user", + "digest_password_fail", + "expectation", + ), + [ + pytest.param( + 200, MOCK_USER, MOCK_PWD, False, False, does_not_raise(), id="success" + ), + pytest.param( + 200, + MOCK_USER, + MOCK_PWD, + True, + False, + does_not_raise(), + id="success-default", + ), + pytest.param( + 400, + MOCK_USER, + MOCK_PWD, + False, + False, + pytest.raises(KasaException), + id="400 error", + ), + pytest.param( + 200, + "foobar", + MOCK_PWD, + False, + False, + pytest.raises(AuthenticationError), + id="bad-username", + ), + pytest.param( + 200, + MOCK_USER, + "barfoo", + False, + False, + pytest.raises(AuthenticationError), + id="bad-password", + ), + pytest.param( + 200, + MOCK_USER, + MOCK_PWD, + False, + True, + pytest.raises(AuthenticationError), + id="bad-password-digest", + ), + ], +) +async def test_handshake( + mocker, + status_code, + username, + password, + wants_default_user, + digest_password_fail, + expectation, +): + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice( + host, + status_code=status_code, + want_default_username=wants_default_user, + digest_password_fail=digest_password_fail, + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(username, password)) + ) + + assert transport._encryption_session is None + assert transport._state is TransportState.HANDSHAKE_REQUIRED + with expectation: + await transport.perform_handshake() + assert transport._encryption_session is not None + assert transport._state is TransportState.ESTABLISHED + + +@pytest.mark.parametrize( + ("wants_default_user"), + [pytest.param(False, id="username"), pytest.param(True, id="default")], +) +async def test_credentials_hash(mocker, wants_default_user): + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice( + host, want_default_username=wants_default_user + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + creds = Credentials(MOCK_USER, MOCK_PWD) + creds_hash = SslAesTransport._create_b64_credentials(creds) + + # Test with credentials input + transport = SslAesTransport(config=DeviceConfig(host, credentials=creds)) + assert transport.credentials_hash == creds_hash + await transport.perform_handshake() + assert transport.credentials_hash == creds_hash + + # Test with credentials_hash input + transport = SslAesTransport(config=DeviceConfig(host, credentials_hash=creds_hash)) + mock_ssl_aes_device.handshake1_complete = False + assert transport.credentials_hash == creds_hash + await transport.perform_handshake() + assert transport.credentials_hash == creds_hash + + +async def test_send(mocker): + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice(host, want_default_username=False) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + request = { + "method": "getDeviceInfo", + "params": None, + } + + res = await transport.send(json_dumps(request)) + assert "result" in res + + +async def test_unencrypted_response(mocker, caplog): + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice(host, do_not_encrypt_response=True) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + + request = { + "method": "getDeviceInfo", + "params": None, + } + caplog.set_level(logging.DEBUG) + res = await transport.send(json_dumps(request)) + assert "result" in res + assert ( + "Received unencrypted response over secure passthrough from 127.0.0.1" + in caplog.text + ) + + +async def test_port_override(): + """Test that port override sets the app_url.""" + host = "127.0.0.1" + port_override = 12345 + config = DeviceConfig( + host, credentials=Credentials("foo", "bar"), port_override=port_override + ) + transport = SslAesTransport(config=config) + + assert str(transport._app_url) == f"https://127.0.0.1:{port_override}" + + +class MockSslAesDevice: + BAD_USER_RESP = { + "error_code": SmartErrorCode.SESSION_EXPIRED.value, + "result": { + "data": { + "code": -60502, + } + }, + } + + BAD_PWD_RESP = { + "error_code": SmartErrorCode.INVALID_NONCE.value, + "result": { + "data": { + "code": SmartErrorCode.SESSION_EXPIRED.value, + "encrypt_type": ["3"], + "key": "Someb64keyWithUnknownPurpose", + "nonce": "1234567890ABCDEF", # Whatever the original nonce was + "device_confirm": "", + } + }, + } + + class _mock_response: + def __init__(self, status, request: dict): + self.status = status + self._json = request + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_t, exc_v, exc_tb): + pass + + async def read(self): + if isinstance(self._json, dict): + return json_dumps(self._json).encode() + return self._json + + def __init__( + self, + host, + *, + status_code=200, + want_default_username: bool = False, + do_not_encrypt_response=False, + send_response=None, + sequential_request_delay=0, + send_error_code=0, + secure_passthrough_error_code=0, + digest_password_fail=False, + ): + self.host = host + self.http_client = HttpClient(DeviceConfig(self.host)) + self.encryption_session: AesEncyptionSession | None = None + self.server_nonce = secrets.token_bytes(8).hex().upper() + self.handshake1_complete = False + + # test behaviour attributes + self.status_code = status_code + self.send_error_code = send_error_code + self.secure_passthrough_error_code = secure_passthrough_error_code + self.do_not_encrypt_response = do_not_encrypt_response + self.want_default_username = want_default_username + self.digest_password_fail = digest_password_fail + + async def post(self, url: URL, params=None, json=None, data=None, *_, **__): + if data: + json = json_loads(data) + res = await self._post(url, json) + return res + + async def _post(self, url: URL, json: dict[str, Any]): + method = json["method"] + + if method == "login" and not self.handshake1_complete: + return await self._return_handshake1_response(url, json) + + if method == "login" and self.handshake1_complete: + return await self._return_handshake2_response(url, json) + elif method == "securePassthrough": + assert url == URL(f"https://{self.host}/stok={MOCK_STOCK}/ds") + return await self._return_secure_passthrough_response(url, json) + else: + assert url == URL(f"https://{self.host}/stok={MOCK_STOCK}/ds") + return await self._return_send_response(url, json) + + async def _return_handshake1_response(self, url: URL, request: dict[str, Any]): + request_nonce = request["params"].get("cnonce") + request_username = request["params"].get("username") + + if (self.want_default_username and request_username != MOCK_ADMIN_USER) or ( + not self.want_default_username and request_username != MOCK_USER + ): + return self._mock_response(self.status_code, self.BAD_USER_RESP) + + device_confirm = SslAesTransport.generate_confirm_hash( + request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) + ) + self.handshake1_complete = True + resp = { + "error_code": SmartErrorCode.INVALID_NONCE.value, + "result": { + "data": { + "code": SmartErrorCode.INVALID_NONCE.value, + "encrypt_type": ["3"], + "key": "Someb64keyWithUnknownPurpose", + "nonce": self.server_nonce, + "device_confirm": device_confirm, + } + }, + } + return self._mock_response(self.status_code, resp) + + async def _return_handshake2_response(self, url: URL, request: dict[str, Any]): + request_nonce = request["params"].get("cnonce") + request_username = request["params"].get("username") + if (self.want_default_username and request_username != MOCK_ADMIN_USER) or ( + not self.want_default_username and request_username != MOCK_USER + ): + return self._mock_response(self.status_code, self.BAD_USER_RESP) + + request_password = request["params"].get("digest_passwd") + expected_pwd = SslAesTransport.generate_digest_password( + request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) + ) + if request_password != expected_pwd or self.digest_password_fail: + return self._mock_response(self.status_code, self.BAD_PWD_RESP) + + lsk = SslAesTransport.generate_encryption_token( + "lsk", request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) + ) + ivb = SslAesTransport.generate_encryption_token( + "ivb", request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) + ) + self.encryption_session = AesEncyptionSession(lsk, ivb) + resp = { + "error_code": 0, + "result": {"stok": MOCK_STOCK, "user_group": "root", "start_seq": 100}, + } + return self._mock_response(self.status_code, resp) + + async def _return_secure_passthrough_response(self, url: URL, json: dict[str, Any]): + encrypted_request = json["params"]["request"] + assert self.encryption_session + decrypted_request = self.encryption_session.decrypt(encrypted_request.encode()) + decrypted_request_dict = json_loads(decrypted_request) + decrypted_response = await self._post(url, decrypted_request_dict) + async with decrypted_response: + decrypted_response_data = await decrypted_response.read() + + encrypted_response = self.encryption_session.encrypt(decrypted_response_data) + response = ( + decrypted_response_data + if self.do_not_encrypt_response + else encrypted_response + ) + result = { + "result": {"response": response.decode()}, + "error_code": self.secure_passthrough_error_code, + } + return self._mock_response(self.status_code, result) + + async def _return_send_response(self, url: URL, json: dict[str, Any]): + result = {"result": {"method": None}, "error_code": self.send_error_code} + return self._mock_response(self.status_code, result) From e7f921299aa8220c10a3a192db006d7b109f5daf Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 29 Oct 2024 07:11:31 +0000 Subject: [PATCH 627/892] Fix smartcamera childdevice module (#1206) Unlike most `smartcamera` queries, the child info query request and response have different section names, i.e. `controlChild` and `child_device_list` respectively. --- kasa/experimental/modules/childdevice.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/kasa/experimental/modules/childdevice.py b/kasa/experimental/modules/childdevice.py index 837793f1c..0168011dd 100644 --- a/kasa/experimental/modules/childdevice.py +++ b/kasa/experimental/modules/childdevice.py @@ -9,14 +9,16 @@ class ChildDevice(SmartCameraModule): NAME = "childdevice" QUERY_GETTER_NAME = "getChildDeviceList" - QUERY_MODULE_NAME = "childControl" + # This module is unusual in that QUERY_MODULE_NAME in the response is not + # the same one used in the request. + QUERY_MODULE_NAME = "child_device_list" def query(self) -> dict: """Query to execute during the update cycle. Default implementation uses the raw query getter w/o parameters. """ - return {self.QUERY_GETTER_NAME: {self.QUERY_MODULE_NAME: {"start_index": 0}}} + return {self.QUERY_GETTER_NAME: {"childControl": {"start_index": 0}}} async def _check_supported(self) -> bool: """Additional check to see if the module is supported by the device.""" From fdadeebaa9aa5fdc512108b6fd9a246407f7dc90 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 29 Oct 2024 08:58:47 +0000 Subject: [PATCH 628/892] Add S200B(EU) fw 1.11.0 fixture (#1205) Adds a note about button presses not being supported. --- README.md | 5 +- SUPPORTED.md | 6 + kasa/tests/device_fixtures.py | 2 +- .../smart/child/S200B(EU)_1.0_1.11.0.json | 115 ++++++++++++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 kasa/tests/fixtures/smart/child/S200B(EU)_1.0_1.11.0.json diff --git a/README.md b/README.md index 6e883dbcd..70c3127e0 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,9 @@ Please refer to [our contributing guidelines](https://python-kasa.readthedocs.io The following devices have been tested and confirmed as working. If your device is unlisted but working, please consider [contributing a fixture file](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files). +> [!NOTE] +> The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed. + ### Supported Kasa devices @@ -195,7 +198,7 @@ The following devices have been tested and confirmed as working. If your device - **Bulbs**: L510B, L510E, L530E - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Hubs**: H100 -- **Hub-Connected Devices\*\*\***: T100, T110, T300, T310, T315 +- **Hub-Connected Devices\*\*\***: S200B, T100, T110, T300, T310, T315 \*   Model requires authentication
diff --git a/SUPPORTED.md b/SUPPORTED.md index f80362fb2..cb995eca6 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -2,6 +2,10 @@ The following devices have been tested and confirmed as working. 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). +> [!NOTE] +> The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed. + + ## Kasa devices @@ -237,6 +241,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros ### Hub-Connected Devices +- **S200B** + - Hardware: 1.0 (EU) / Firmware: 1.11.0 - **T100** - Hardware: 1.0 (EU) / Firmware: 1.12.0 - **T110** diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index a2ef92c50..fec386d60 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -119,7 +119,7 @@ } HUBS_SMART = {"H100", "KH100"} -SENSORS_SMART = {"T310", "T315", "T300", "T100", "T110"} +SENSORS_SMART = {"T310", "T315", "T300", "T100", "T110", "S200B"} THERMOSTATS_SMART = {"KE100"} WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} diff --git a/kasa/tests/fixtures/smart/child/S200B(EU)_1.0_1.11.0.json b/kasa/tests/fixtures/smart/child/S200B(EU)_1.0_1.11.0.json new file mode 100644 index 000000000..9df75fd76 --- /dev/null +++ b/kasa/tests/fixtures/smart/child/S200B(EU)_1.0_1.11.0.json @@ -0,0 +1,115 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "double_click", + "ver_code": 1 + } + ] + }, + "get_auto_update_info": -1001, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "button", + "bind_count": 2, + "category": "subg.trigger.button", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.11.0 Build 230821 Rel.113553", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -120, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1714016798, + "mac": "202351000000", + "model": "S200B", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -55, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_device_time": -1001, + "get_device_usage": -1001, + "get_double_click_info": { + "enable": false + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_ver": "1.12.0 Build 231121 Rel.092444", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2024-04-02", + "release_note": "Modifications and Bug Fixes:\n1. Optimized low battery notification.\n2. Fixed some minor bugs.", + "type": 2 + }, + "get_trigger_logs": { + "logs": [], + "start_id": 0, + "sum": 0 + }, + "qs_component_nego": -1001 +} From ad6472c05d3c31e6212a2ae0be55164d078a4cda Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 29 Oct 2024 09:18:17 +0000 Subject: [PATCH 629/892] Add H200(EU) fw 1.3.2 fixture (#1204) --- .../smartcamera/H200(EU)_1.0_1.3.2.json | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 kasa/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json diff --git a/kasa/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json b/kasa/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json new file mode 100644 index 000000000..05d302fc4 --- /dev/null +++ b/kasa/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json @@ -0,0 +1,221 @@ +{ + "discovery_result": { + "decrypted_data": { + "connect_ssid": "", + "connect_type": "wired", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "H200", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.2 Build 20240424 rel.75425", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-6E-84-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + }, + "getAlertConfig": {}, + "getChildDeviceList": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "button", + "bind_count": 1, + "category": "subg.trigger.button", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.11.0 Build 230821 Rel.113553", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -116, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1713970593, + "mac": "202351000000", + "model": "S200B", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -68, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + } + ], + "start_index": 0, + "sum": 1 + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-04-25 16:15:39", + "seconds_from_1970": 1714061739 + } + } + }, + "getConnectionType": { + "link_type": "ethernet" + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "gateway", + "bind_status": true, + "child_num": 0, + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "H200 1.0", + "device_model": "H200", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "has_set_location_info": 1, + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "A8-6E-84-00-00-00", + "need_sync_sha1_password": 0, + "oem_id": "00000000000000000000000000000000", + "product_name": "Tapo Smart Hub", + "region": "EU", + "status": "configured", + "sw_version": "1.3.2 Build 20240424 rel.75425" + }, + "info": { + "avatar": "gateway", + "bind_status": true, + "child_num": 0, + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "H200 1.0", + "device_model": "H200", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "has_set_location_info": 1, + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "A8-6E-84-00-00-00", + "need_sync_sha1_password": 0, + "oem_id": "00000000000000000000000000000000", + "product_name": "Tapo Smart Hub", + "region": "EU", + "status": "configured", + "sw_version": "1.3.2 Build 20240424 rel.75425" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": 120, + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLedStatus": { + "led": { + "config": { + ".name": "config", + ".type": "led", + "enabled": "on" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "detect_status": "offline", + "disk_name": "1", + "loop_record_status": "1", + "status": "offline" + } + } + ] + } + }, + "getSirenConfig": { + "duration": 300, + "siren_type": "Doorbell Ring 1", + "volume": "6" + }, + "getSirenStatus": { + "status": "off", + "time_left": 0 + }, + "getSirenTypeList": { + "siren_type_list": [ + "Doorbell Ring 1", + "Doorbell Ring 2", + "Doorbell Ring 3", + "Doorbell Ring 4", + "Doorbell Ring 5", + "Doorbell Ring 6", + "Doorbell Ring 7", + "Doorbell Ring 8", + "Doorbell Ring 9", + "Doorbell Ring 10", + "Phone Ring", + "Alarm 1", + "Alarm 2", + "Alarm 3", + "Alarm 4", + "Dripping Tap", + "Alarm 5", + "Connection 1", + "Connection 2" + ] + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+00:00", + "zone_id": "Europe/London" + } + } + } +} From d30d116f37d00cab924cf01dca0151445b1c497f Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 29 Oct 2024 10:30:13 +0100 Subject: [PATCH 630/892] dump_devinfo: query get_current_brt for iot dimmers (#1209) --- devtools/dump_devinfo.py | 1 + 1 file changed, 1 insertion(+) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 91a4505bd..f3b7810e9 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -404,6 +404,7 @@ async def get_legacy_fixture(protocol, *, discovery_info): module="smartlife.iot.smartbulb.lightingservice", method="get_light_state" ), Call(module="smartlife.iot.LAS", method="get_config"), + Call(module="smartlife.iot.LAS", method="get_current_brt"), Call(module="smartlife.iot.PIR", method="get_config"), ] From 4aec9d302fc86ac53433cf7564cc7447e49aadc6 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 29 Oct 2024 09:30:30 +0000 Subject: [PATCH 631/892] Allow enabling experimental devices from environment variable (#1194) --- devtools/dump_devinfo.py | 4 ++-- kasa/cli/main.py | 14 ++++++++------ kasa/device_factory.py | 4 ++-- kasa/experimental/__init__.py | 27 ++++++++++++++++++++++++++ kasa/experimental/enabled.py | 12 ------------ kasa/tests/test_cli.py | 36 +++++++++++++++++++++++++++++++++++ 6 files changed, 75 insertions(+), 22 deletions(-) delete mode 100644 kasa/experimental/enabled.py diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index f3b7810e9..47d48454c 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -309,9 +309,9 @@ async def cli( if debug: logging.basicConfig(level=logging.DEBUG) - from kasa.experimental.enabled import Enabled + from kasa.experimental import Experimental - Enabled.set(True) + Experimental.set_enabled(True) credentials = Credentials(username=username, password=password) if host is not None: diff --git a/kasa/cli/main.py b/kasa/cli/main.py index b721e984e..a386fe4b1 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -16,6 +16,7 @@ from kasa import Device from kasa.deviceconfig import DeviceEncryptionType +from kasa.experimental import Experimental from .common import ( SKIP_UPDATE_COMMANDS, @@ -220,11 +221,11 @@ def _legacy_type_to_class(_type): help="Hashed credentials used to authenticate to the device.", ) @click.option( - "--experimental", - default=False, + "--experimental/--no-experimental", + default=None, is_flag=True, type=bool, - envvar="KASA_EXPERIMENTAL", + envvar=Experimental.ENV_VAR, help="Enable experimental mode for devices not yet fully supported.", ) @click.version_option(package_name="python-kasa") @@ -260,10 +261,11 @@ async def cli( if target != DEFAULT_TARGET and host: error("--target is not a valid option for single host discovery") - if experimental: - from kasa.experimental.enabled import Enabled + if experimental is not None: + Experimental.set_enabled(experimental) - Enabled.set(True) + if Experimental.enabled(): + echo("Experimental support is enabled") logging_config: dict[str, Any] = { "level": logging.DEBUG if debug > 0 else logging.INFO diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 53ae1efff..d7b778437 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -214,9 +214,9 @@ def get_protocol( "SMART.KLAP": (SmartProtocol, KlapTransportV2), } if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)): - from .experimental.enabled import Enabled + from .experimental import Experimental - if Enabled.value and protocol_transport_key == "SMART.AES.HTTPS": + if Experimental.enabled() and protocol_transport_key == "SMART.AES.HTTPS": prot_tran_cls = (SmartCameraProtocol, SslAesTransport) else: return None diff --git a/kasa/experimental/__init__.py b/kasa/experimental/__init__.py index 604622464..388c57360 100644 --- a/kasa/experimental/__init__.py +++ b/kasa/experimental/__init__.py @@ -1 +1,28 @@ """Package for experimental.""" + +from __future__ import annotations + +import os + + +class Experimental: + """Class for enabling experimental functionality.""" + + _enabled: bool | None = None + ENV_VAR = "KASA_EXPERIMENTAL" + + @classmethod + def set_enabled(cls, enabled): + """Set the enabled value.""" + cls._enabled = enabled + + @classmethod + def enabled(cls): + """Get the enabled value.""" + if cls._enabled is not None: + return cls._enabled + + if env_var := os.getenv(cls.ENV_VAR): + return env_var.lower() in {"true", "1", "t", "on"} + + return False diff --git a/kasa/experimental/enabled.py b/kasa/experimental/enabled.py deleted file mode 100644 index 7679f97c2..000000000 --- a/kasa/experimental/enabled.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Package for experimental enabled.""" - - -class Enabled: - """Class for enabling experimental functionality.""" - - value = False - - @classmethod - def set(cls, value): - """Set the enabled value.""" - cls.value = value diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index bd93d4301..80b5daaf7 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -1232,3 +1232,39 @@ async def test_discover_config_invalid(mocker, runner): ) assert res.exit_code == 1 assert "--target is not a valid option for single host discovery" in res.output + + +@pytest.mark.parametrize( + ("option", "env_var_value", "expectation"), + [ + pytest.param("--experimental", None, True), + pytest.param("--experimental", "false", True), + pytest.param(None, None, False), + pytest.param(None, "true", True), + pytest.param(None, "false", False), + pytest.param("--no-experimental", "true", False), + ], +) +async def test_experimental_flags(mocker, option, env_var_value, expectation): + """Test the experimental flag is set correctly.""" + mocker.patch("kasa.discover.Discover.try_connect_all", return_value=None) + + # reset the class internal variable + from kasa.experimental import Experimental + + Experimental._enabled = None + + KASA_VARS = {k: None for k, v in os.environ.items() if k.startswith("KASA_")} + if env_var_value: + KASA_VARS["KASA_EXPERIMENTAL"] = env_var_value + args = [ + "--host", + "127.0.0.2", + "discover", + "config", + ] + if option: + args.insert(0, option) + runner = CliRunner(env=KASA_VARS) + res = await runner.invoke(cli, args) + assert ("Experimental support is enabled" in res.output) is expectation From 5cde7cba27c743dc5ca630737ff5e8cb84adae3f Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 29 Oct 2024 10:37:34 +0100 Subject: [PATCH 632/892] Add S200D button fixtures (#1161) Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- README.md | 2 +- SUPPORTED.md | 3 + kasa/tests/device_fixtures.py | 2 +- .../smart/child/S200D(EU)_1.0_1.11.0.json | 504 ++++++++++++++++ .../smart/child/S200D(EU)_1.0_1.12.0.json | 536 ++++++++++++++++++ 5 files changed, 1045 insertions(+), 2 deletions(-) create mode 100644 kasa/tests/fixtures/smart/child/S200D(EU)_1.0_1.11.0.json create mode 100644 kasa/tests/fixtures/smart/child/S200D(EU)_1.0_1.12.0.json diff --git a/README.md b/README.md index 70c3127e0..4eff5338a 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ The following devices have been tested and confirmed as working. If your device - **Bulbs**: L510B, L510E, L530E - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Hubs**: H100 -- **Hub-Connected Devices\*\*\***: S200B, T100, T110, T300, T310, T315 +- **Hub-Connected Devices\*\*\***: S200B, S200D, T100, T110, T300, T310, T315 \*   Model requires authentication
diff --git a/SUPPORTED.md b/SUPPORTED.md index cb995eca6..e81e58310 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -243,6 +243,9 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **S200B** - Hardware: 1.0 (EU) / Firmware: 1.11.0 +- **S200D** + - Hardware: 1.0 (EU) / Firmware: 1.11.0 + - Hardware: 1.0 (EU) / Firmware: 1.12.0 - **T100** - Hardware: 1.0 (EU) / Firmware: 1.12.0 - **T110** diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index fec386d60..e05be7b69 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -119,7 +119,7 @@ } HUBS_SMART = {"H100", "KH100"} -SENSORS_SMART = {"T310", "T315", "T300", "T100", "T110", "S200B"} +SENSORS_SMART = {"T310", "T315", "T300", "T100", "T110", "S200B", "S200D"} THERMOSTATS_SMART = {"KE100"} WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} diff --git a/kasa/tests/fixtures/smart/child/S200D(EU)_1.0_1.11.0.json b/kasa/tests/fixtures/smart/child/S200D(EU)_1.0_1.11.0.json new file mode 100644 index 000000000..3ee20e537 --- /dev/null +++ b/kasa/tests/fixtures/smart/child/S200D(EU)_1.0_1.11.0.json @@ -0,0 +1,504 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "double_click", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "button", + "bind_count": 1, + "category": "subg.trigger.button", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.11.0 Build 230821 Rel.113553", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -117, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1728469002, + "mac": "6083E7000000", + "model": "S200D", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "CEST", + "report_interval": 16, + "rssi": -42, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_ver": "1.12.0 Build 231121 Rel.092444", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2024-06-06", + "release_note": "Modifications and Bug Fixes:\n1. Optimized low battery notification.\n2. Fixed some minor bugs.", + "type": 1 + }, + "get_temp_humidity_records": { + "local_time": 1728469073, + "past24h_humidity": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_humidity_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [], + "start_id": 0, + "sum": 0 + } +} diff --git a/kasa/tests/fixtures/smart/child/S200D(EU)_1.0_1.12.0.json b/kasa/tests/fixtures/smart/child/S200D(EU)_1.0_1.12.0.json new file mode 100644 index 000000000..0ba6e17b0 --- /dev/null +++ b/kasa/tests/fixtures/smart/child/S200D(EU)_1.0_1.12.0.json @@ -0,0 +1,536 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "double_click", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "button", + "bind_count": 1, + "category": "subg.trigger.button", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.12.0 Build 231121 Rel.092444", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -120, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1728469002, + "mac": "6083E7000000", + "model": "S200D", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "CEST", + "report_interval": 16, + "rssi": -42, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.12.0 Build 231121 Rel.092444", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_temp_humidity_records": { + "local_time": 1728470630, + "past24h_humidity": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_humidity_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "singleClick", + "eventId": "601a2fbd-f4d0-ca4f-85e0-dacf4d0ca4f8", + "id": 99, + "timestamp": 1728469787 + }, + { + "event": "singleClick", + "eventId": "d0b50fda-30c5-37c6-3646-fea20c537c63", + "id": 98, + "timestamp": 1728469781 + }, + { + "event": "singleClick", + "eventId": "f830dc2a-d920-5466-f7b7-1f436dfab990", + "id": 97, + "timestamp": 1728469780 + }, + { + "event": "doubleClick", + "eventId": "8b6719ae-7d1c-acf4-d846-89e6d1cacf4d", + "id": 96, + "timestamp": 1728469776 + }, + { + "event": "singleClick", + "eventId": "913fe08f-b823-66c4-9db9-2bea82366c49", + "id": 95, + "timestamp": 1728469774 + } + ], + "start_id": 99, + "sum": 51 + } +} From 450bcf0bde337b644ec53b243b5240a4bc5f962a Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 29 Oct 2024 09:49:49 +0000 Subject: [PATCH 633/892] Add S200B(US) fw 1.12.0 fixture (#1181) --- SUPPORTED.md | 1 + .../smart/child/S200B(US)_1.0_1.12.0.json | 108 ++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 kasa/tests/fixtures/smart/child/S200B(US)_1.0_1.12.0.json diff --git a/SUPPORTED.md b/SUPPORTED.md index e81e58310..fa5cd0f98 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -243,6 +243,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **S200B** - Hardware: 1.0 (EU) / Firmware: 1.11.0 + - Hardware: 1.0 (US) / Firmware: 1.12.0 - **S200D** - Hardware: 1.0 (EU) / Firmware: 1.11.0 - Hardware: 1.0 (EU) / Firmware: 1.12.0 diff --git a/kasa/tests/fixtures/smart/child/S200B(US)_1.0_1.12.0.json b/kasa/tests/fixtures/smart/child/S200B(US)_1.0_1.12.0.json new file mode 100644 index 000000000..1efd77421 --- /dev/null +++ b/kasa/tests/fixtures/smart/child/S200B(US)_1.0_1.12.0.json @@ -0,0 +1,108 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "double_click", + "ver_code": 1 + } + ] + }, + "get_auto_update_info": -1001, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "button", + "bind_count": 1, + "category": "subg.trigger.button", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "fw_ver": "1.12.0 Build 231121 Rel.092508", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -104, + "jamming_signal_level": 2, + "lastOnboardingTimestamp": 1724636886, + "mac": "98254A000000", + "model": "S200B", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -36, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_device_time": -1001, + "get_device_usage": -1001, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.12.0 Build 231121 Rel.092508", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "qs_component_nego": -1001 +} From 7483411ca2bf45de9c3b4444ce03440a81d9200a Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 29 Oct 2024 09:50:27 +0000 Subject: [PATCH 634/892] Add trigger_logs and double_click to dump_devinfo helper (#1208) --- devtools/dump_devinfo.py | 8 -------- devtools/helpers/smartrequests.py | 6 ++++++ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 47d48454c..6d03472ea 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -702,14 +702,6 @@ async def get_smart_test_calls(protocol: SmartProtocol): should_succeed=False, child_device_id="", ), - SmartCall( - module="trigger_logs", - request=SmartRequest.get_raw_request( - "get_trigger_logs", SmartRequest.GetTriggerLogsParams() - ).to_dict(), - should_succeed=False, - child_device_id="", - ), ] click.echo("Testing component_nego call ..", nl=False) diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 104ccb64b..4ad7407d2 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -408,6 +408,12 @@ def get_component_requests(component_id, ver_code): SmartRequest.get_raw_request("get_alarm_configure"), ], "alarm_logs": [SmartRequest.get_raw_request("get_alarm_triggers")], + "trigger_log": [ + SmartRequest.get_raw_request( + "get_trigger_logs", SmartRequest.GetTriggerLogsParams() + ) + ], + "double_click": [SmartRequest.get_raw_request("get_double_click_info")], "child_device": [ SmartRequest.get_raw_request("get_child_device_list"), SmartRequest.get_raw_request("get_child_device_component_list"), From b82743a5debc6ec1958afaccdeb388093a6012ed Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 29 Oct 2024 11:52:53 +0000 Subject: [PATCH 635/892] Do not pass None as timeout to http requests (#1203) --- kasa/experimental/sslaestransport.py | 3 --- kasa/httpclient.py | 2 ++ kasa/protocol.py | 4 +++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/kasa/experimental/sslaestransport.py b/kasa/experimental/sslaestransport.py index eddc6698d..68420f89a 100644 --- a/kasa/experimental/sslaestransport.py +++ b/kasa/experimental/sslaestransport.py @@ -97,9 +97,6 @@ def __init__( self._default_credentials: Credentials = get_default_credentials( DEFAULT_CREDENTIALS["TAPOCAMERA"] ) - - if not config.timeout: - config.timeout = self.DEFAULT_TIMEOUT self._http_client: HttpClient = HttpClient(config) self._state = TransportState.HANDSHAKE_REQUIRED diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 9904b17b0..6b8e234c0 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -89,6 +89,8 @@ async def post( self._last_url = url self.client.cookie_jar.clear() return_json = bool(json) + if self._config.timeout is None: + _LOGGER.warning("Request timeout is set to None.") client_timeout = aiohttp.ClientTimeout(total=self._config.timeout) # If json is not a dict send as data. diff --git a/kasa/protocol.py b/kasa/protocol.py index 1107fa1d7..140e9c415 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -91,7 +91,9 @@ def __init__( self._port = config.port_override or self.default_port self._credentials = config.credentials self._credentials_hash = config.credentials_hash - self._timeout = config.timeout or self.DEFAULT_TIMEOUT + if not config.timeout: + config.timeout = self.DEFAULT_TIMEOUT + self._timeout = config.timeout @property @abstractmethod From 6d8dc1cc5fd79e6cb873814b789d88e81f9111fb Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 29 Oct 2024 16:21:24 +0000 Subject: [PATCH 636/892] Only send 20002 discovery request with key included (#1207) --- kasa/discover.py | 1 - kasa/tests/test_discovery.py | 7 +++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index ade6a54a6..3b8f7c448 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -276,7 +276,6 @@ async def do_discover(self) -> None: 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 self.transport.sendto(aes_discovery_query, self.target_2) # type: ignore await asyncio.sleep(sleep_between_packets) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index a31ef8363..0dc4e0d7c 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -294,7 +294,7 @@ async def test_discover_send(mocker): assert proto.target_1 == ("255.255.255.255", 9999) transport = mocker.patch.object(proto, "transport") await proto.do_discover() - assert transport.sendto.call_count == proto.discovery_packets * 3 + assert transport.sendto.call_count == proto.discovery_packets * 2 async def test_discover_datagram_received(mocker, discovery_data): @@ -501,14 +501,13 @@ async def test_do_discover_drop_packets(mocker, port, do_not_reply_count): discovery_timeout=discovery_timeout, discovery_packets=5, ) - expected_send = 1 if port == 9999 else 2 - ft = FakeDatagramTransport(dp, port, do_not_reply_count * expected_send) + ft = FakeDatagramTransport(dp, port, do_not_reply_count) dp.connection_made(ft) await dp.wait_for_discovery_to_complete() await asyncio.sleep(0) - assert ft.send_count == do_not_reply_count * expected_send + expected_send + assert ft.send_count == do_not_reply_count + 1 assert dp.discover_task.done() assert dp.discover_task.cancelled() From 673a32258fb098804befe822bbad9e881a00e69b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 29 Oct 2024 17:14:52 +0000 Subject: [PATCH 637/892] Make HSV NamedTuple creation more efficient (#1211) --- kasa/iot/iotbulb.py | 4 +++- kasa/smart/modules/color.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 7e00bebc8..3302e80db 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -367,7 +367,9 @@ def _hsv(self) -> HSV: saturation = light_state["saturation"] value = self._brightness - return HSV(hue, saturation, value) + # Simple HSV(hue, saturation, value) is less efficent than below + # due to the cpython implementation. + return tuple.__new__(HSV, (hue, saturation, value)) @requires_update async def _set_hsv( diff --git a/kasa/smart/modules/color.py b/kasa/smart/modules/color.py index 772d9335b..3faa1a82e 100644 --- a/kasa/smart/modules/color.py +++ b/kasa/smart/modules/color.py @@ -44,7 +44,9 @@ def hsv(self) -> HSV: self.data.get("brightness", 0), ) - return HSV(hue=h, saturation=s, value=v) + # Simple HSV(h, s, v) is less efficent than below + # due to the cpython implementation. + return tuple.__new__(HSV, (h, s, v)) def _raise_for_invalid_brightness(self, value): """Raise error on invalid brightness value.""" From 1f1d50dd5cc04194a833a0e9ec160211157e1a5a Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 29 Oct 2024 17:57:40 +0000 Subject: [PATCH 638/892] Fix mypy errors in parse_pcap_klap (#1214) --- devtools/parse_pcap_klap.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index b2cdc938e..b291b0d43 100755 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -272,6 +272,7 @@ def main( case "/app/request": if packet.ip.dst != device_ip: continue + assert isinstance(data, str) # noqa: S101 message = bytes.fromhex(data) try: plaintext = operator.decrypt(message) @@ -284,6 +285,7 @@ def main( case "/app/handshake1": if packet.ip.dst != device_ip: continue + assert isinstance(data, str) # noqa: S101 message = bytes.fromhex(data) operator.local_seed = message response = None From 530cf4b52374d69544689ed512f3116d49889f98 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 29 Oct 2024 18:05:22 +0000 Subject: [PATCH 639/892] Prepare 0.7.6 (#1213) ## [0.7.6](https://github.com/python-kasa/python-kasa/tree/0.7.6) (2024-10-29) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.5...0.7.6) **Release summary:** - Experimental support for Tapo cameras and the Tapo H200 hub which uses the same protocol. - Better timestamp support across all devices. - Support for new devices P304M, S200D and S200B (see README.md for note on the S200 support). - Various other fixes and minor features. **Implemented enhancements:** - Add support for setting the timezone [\#436](https://github.com/python-kasa/python-kasa/issues/436) - Add stream\_rtsp\_url to camera module [\#1197](https://github.com/python-kasa/python-kasa/pull/1197) (@sdb9696) - Try default logon credentials in SslAesTransport [\#1195](https://github.com/python-kasa/python-kasa/pull/1195) (@sdb9696) - Allow enabling experimental devices from environment variable [\#1194](https://github.com/python-kasa/python-kasa/pull/1194) (@sdb9696) - Add core device, child and camera modules to smartcamera [\#1193](https://github.com/python-kasa/python-kasa/pull/1193) (@sdb9696) - Fallback to get\_current\_power if get\_energy\_usage does not provide current\_power [\#1186](https://github.com/python-kasa/python-kasa/pull/1186) (@Fulch36) - Add https parameter to device class factory [\#1184](https://github.com/python-kasa/python-kasa/pull/1184) (@sdb9696) - Add discovery list command to cli [\#1183](https://github.com/python-kasa/python-kasa/pull/1183) (@sdb9696) - Add Time module to SmartCamera devices [\#1182](https://github.com/python-kasa/python-kasa/pull/1182) (@sdb9696) - Add try\_connect\_all to allow initialisation without udp broadcast [\#1171](https://github.com/python-kasa/python-kasa/pull/1171) (@sdb9696) - Update dump\_devinfo for smart camera protocol [\#1169](https://github.com/python-kasa/python-kasa/pull/1169) (@sdb9696) - Enable newer encrypted discovery protocol [\#1168](https://github.com/python-kasa/python-kasa/pull/1168) (@sdb9696) - Initial TapoCamera support [\#1165](https://github.com/python-kasa/python-kasa/pull/1165) (@sdb9696) - Add waterleak alert timestamp [\#1162](https://github.com/python-kasa/python-kasa/pull/1162) (@rytilahti) - Create common Time module and add time set cli command [\#1157](https://github.com/python-kasa/python-kasa/pull/1157) (@sdb9696) **Fixed bugs:** - Only send 20002 discovery request with key included [\#1207](https://github.com/python-kasa/python-kasa/pull/1207) (@sdb9696) - Fix SslAesTransport default login and add tests [\#1202](https://github.com/python-kasa/python-kasa/pull/1202) (@sdb9696) - Fix device\_config serialisation of https value [\#1196](https://github.com/python-kasa/python-kasa/pull/1196) (@sdb9696) **Added support for devices:** - Add S200B\(EU\) fw 1.11.0 fixture [\#1205](https://github.com/python-kasa/python-kasa/pull/1205) (@sdb9696) - Add TC65 fixture [\#1200](https://github.com/python-kasa/python-kasa/pull/1200) (@rytilahti) - Add P304M\(UK\) test fixture [\#1185](https://github.com/python-kasa/python-kasa/pull/1185) (@Fulch36) - Add H200 experimental fixture [\#1180](https://github.com/python-kasa/python-kasa/pull/1180) (@sdb9696) - Add S200D button fixtures [\#1161](https://github.com/python-kasa/python-kasa/pull/1161) (@rytilahti) **Project maintenance:** - Fix mypy errors in parse_pcap_klap [\#1214](https://github.com/python-kasa/python-kasa/pull/1214) (@sdb9696) - Make HSV NamedTuple creation more efficient [\#1211](https://github.com/python-kasa/python-kasa/pull/1211) (@sdb9696) - dump\_devinfo: query get\_current\_brt for iot dimmers [\#1209](https://github.com/python-kasa/python-kasa/pull/1209) (@rytilahti) - Add trigger\_logs and double\_click to dump\_devinfo helper [\#1208](https://github.com/python-kasa/python-kasa/pull/1208) (@sdb9696) - Fix smartcamera childdevice module [\#1206](https://github.com/python-kasa/python-kasa/pull/1206) (@sdb9696) - Add H200\(EU\) fw 1.3.2 fixture [\#1204](https://github.com/python-kasa/python-kasa/pull/1204) (@sdb9696) - Do not pass None as timeout to http requests [\#1203](https://github.com/python-kasa/python-kasa/pull/1203) (@sdb9696) - Update SMART test framework to use fake child protocols [\#1199](https://github.com/python-kasa/python-kasa/pull/1199) (@sdb9696) - Allow passing an aiohttp client session during discover try\_connect\_all [\#1198](https://github.com/python-kasa/python-kasa/pull/1198) (@sdb9696) - Add test framework for smartcamera [\#1192](https://github.com/python-kasa/python-kasa/pull/1192) (@sdb9696) - Rename experimental fixtures folder to smartcamera [\#1191](https://github.com/python-kasa/python-kasa/pull/1191) (@sdb9696) - Combine smartcamera error codes into SmartErrorCode [\#1190](https://github.com/python-kasa/python-kasa/pull/1190) (@sdb9696) - Allow deriving from SmartModule without being registered [\#1189](https://github.com/python-kasa/python-kasa/pull/1189) (@sdb9696) - Improve supported module checks for hub children [\#1188](https://github.com/python-kasa/python-kasa/pull/1188) (@sdb9696) - Update smartcamera to support single get/set/do requests [\#1187](https://github.com/python-kasa/python-kasa/pull/1187) (@sdb9696) - Add S200B\(US\) fw 1.12.0 fixture [\#1181](https://github.com/python-kasa/python-kasa/pull/1181) (@sdb9696) - Add T110\(US\), T310\(US\) and T315\(US\) sensor fixtures [\#1179](https://github.com/python-kasa/python-kasa/pull/1179) (@sdb9696) - Enforce EOLs for \*.rst and \*.md [\#1178](https://github.com/python-kasa/python-kasa/pull/1178) (@rytilahti) - Convert fixtures to use unix newlines [\#1177](https://github.com/python-kasa/python-kasa/pull/1177) (@rytilahti) - Add motion sensor to known categories [\#1176](https://github.com/python-kasa/python-kasa/pull/1176) (@rytilahti) - Drop urllib3 dependency and create ssl context in executor thread [\#1175](https://github.com/python-kasa/python-kasa/pull/1175) (@sdb9696) - Expose smart child device map as a class constant [\#1173](https://github.com/python-kasa/python-kasa/pull/1173) (@sdb9696) --- CHANGELOG.md | 79 +++- pyproject.toml | 2 +- uv.lock | 1109 +++++++++++++++++++++++++----------------------- 3 files changed, 651 insertions(+), 539 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6b299f62..a3d2120d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,73 @@ # Changelog +## [0.7.6](https://github.com/python-kasa/python-kasa/tree/0.7.6) (2024-10-29) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.5...0.7.6) + +**Release summary:** + +- Experimental support for Tapo cameras and the Tapo H200 hub which uses the same protocol. +- Better timestamp support across all devices. +- Support for new devices P304M, S200D and S200B (see README.md for note on the S200 support). +- Various other fixes and minor features. + +**Implemented enhancements:** + +- Add support for setting the timezone [\#436](https://github.com/python-kasa/python-kasa/issues/436) +- Add stream\_rtsp\_url to camera module [\#1197](https://github.com/python-kasa/python-kasa/pull/1197) (@sdb9696) +- Try default logon credentials in SslAesTransport [\#1195](https://github.com/python-kasa/python-kasa/pull/1195) (@sdb9696) +- Allow enabling experimental devices from environment variable [\#1194](https://github.com/python-kasa/python-kasa/pull/1194) (@sdb9696) +- Add core device, child and camera modules to smartcamera [\#1193](https://github.com/python-kasa/python-kasa/pull/1193) (@sdb9696) +- Fallback to get\_current\_power if get\_energy\_usage does not provide current\_power [\#1186](https://github.com/python-kasa/python-kasa/pull/1186) (@Fulch36) +- Add https parameter to device class factory [\#1184](https://github.com/python-kasa/python-kasa/pull/1184) (@sdb9696) +- Add discovery list command to cli [\#1183](https://github.com/python-kasa/python-kasa/pull/1183) (@sdb9696) +- Add Time module to SmartCamera devices [\#1182](https://github.com/python-kasa/python-kasa/pull/1182) (@sdb9696) +- Add try\_connect\_all to allow initialisation without udp broadcast [\#1171](https://github.com/python-kasa/python-kasa/pull/1171) (@sdb9696) +- Update dump\_devinfo for smart camera protocol [\#1169](https://github.com/python-kasa/python-kasa/pull/1169) (@sdb9696) +- Enable newer encrypted discovery protocol [\#1168](https://github.com/python-kasa/python-kasa/pull/1168) (@sdb9696) +- Initial TapoCamera support [\#1165](https://github.com/python-kasa/python-kasa/pull/1165) (@sdb9696) +- Add waterleak alert timestamp [\#1162](https://github.com/python-kasa/python-kasa/pull/1162) (@rytilahti) +- Create common Time module and add time set cli command [\#1157](https://github.com/python-kasa/python-kasa/pull/1157) (@sdb9696) + +**Fixed bugs:** + +- Only send 20002 discovery request with key included [\#1207](https://github.com/python-kasa/python-kasa/pull/1207) (@sdb9696) +- Fix SslAesTransport default login and add tests [\#1202](https://github.com/python-kasa/python-kasa/pull/1202) (@sdb9696) +- Fix device\_config serialisation of https value [\#1196](https://github.com/python-kasa/python-kasa/pull/1196) (@sdb9696) + +**Added support for devices:** + +- Add S200B\(EU\) fw 1.11.0 fixture [\#1205](https://github.com/python-kasa/python-kasa/pull/1205) (@sdb9696) +- Add TC65 fixture [\#1200](https://github.com/python-kasa/python-kasa/pull/1200) (@rytilahti) +- Add P304M\(UK\) test fixture [\#1185](https://github.com/python-kasa/python-kasa/pull/1185) (@Fulch36) +- Add H200 experimental fixture [\#1180](https://github.com/python-kasa/python-kasa/pull/1180) (@sdb9696) +- Add S200D button fixtures [\#1161](https://github.com/python-kasa/python-kasa/pull/1161) (@rytilahti) + +**Project maintenance:** + +- Fix mypy errors in parse_pcap_klap [\#1214](https://github.com/python-kasa/python-kasa/pull/1214) (@sdb9696) +- Make HSV NamedTuple creation more efficient [\#1211](https://github.com/python-kasa/python-kasa/pull/1211) (@sdb9696) +- dump\_devinfo: query get\_current\_brt for iot dimmers [\#1209](https://github.com/python-kasa/python-kasa/pull/1209) (@rytilahti) +- Add trigger\_logs and double\_click to dump\_devinfo helper [\#1208](https://github.com/python-kasa/python-kasa/pull/1208) (@sdb9696) +- Fix smartcamera childdevice module [\#1206](https://github.com/python-kasa/python-kasa/pull/1206) (@sdb9696) +- Add H200\(EU\) fw 1.3.2 fixture [\#1204](https://github.com/python-kasa/python-kasa/pull/1204) (@sdb9696) +- Do not pass None as timeout to http requests [\#1203](https://github.com/python-kasa/python-kasa/pull/1203) (@sdb9696) +- Update SMART test framework to use fake child protocols [\#1199](https://github.com/python-kasa/python-kasa/pull/1199) (@sdb9696) +- Allow passing an aiohttp client session during discover try\_connect\_all [\#1198](https://github.com/python-kasa/python-kasa/pull/1198) (@sdb9696) +- Add test framework for smartcamera [\#1192](https://github.com/python-kasa/python-kasa/pull/1192) (@sdb9696) +- Rename experimental fixtures folder to smartcamera [\#1191](https://github.com/python-kasa/python-kasa/pull/1191) (@sdb9696) +- Combine smartcamera error codes into SmartErrorCode [\#1190](https://github.com/python-kasa/python-kasa/pull/1190) (@sdb9696) +- Allow deriving from SmartModule without being registered [\#1189](https://github.com/python-kasa/python-kasa/pull/1189) (@sdb9696) +- Improve supported module checks for hub children [\#1188](https://github.com/python-kasa/python-kasa/pull/1188) (@sdb9696) +- Update smartcamera to support single get/set/do requests [\#1187](https://github.com/python-kasa/python-kasa/pull/1187) (@sdb9696) +- Add S200B\(US\) fw 1.12.0 fixture [\#1181](https://github.com/python-kasa/python-kasa/pull/1181) (@sdb9696) +- Add T110\(US\), T310\(US\) and T315\(US\) sensor fixtures [\#1179](https://github.com/python-kasa/python-kasa/pull/1179) (@sdb9696) +- Enforce EOLs for \*.rst and \*.md [\#1178](https://github.com/python-kasa/python-kasa/pull/1178) (@rytilahti) +- Convert fixtures to use unix newlines [\#1177](https://github.com/python-kasa/python-kasa/pull/1177) (@rytilahti) +- Add motion sensor to known categories [\#1176](https://github.com/python-kasa/python-kasa/pull/1176) (@rytilahti) +- Drop urllib3 dependency and create ssl context in executor thread [\#1175](https://github.com/python-kasa/python-kasa/pull/1175) (@sdb9696) +- Expose smart child device map as a class constant [\#1173](https://github.com/python-kasa/python-kasa/pull/1173) (@sdb9696) + ## [0.7.5](https://github.com/python-kasa/python-kasa/tree/0.7.5) (2024-10-08) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.4...0.7.5) @@ -16,19 +84,22 @@ **Fixed bugs:** -- Use tzinfo in time constructor instead of astime for iot devices [\#1158](https://github.com/python-kasa/python-kasa/pull/1158) (@sdb9696) - Send empty dictionary instead of null for iot queries [\#1145](https://github.com/python-kasa/python-kasa/pull/1145) (@sdb9696) -- Stabilise on\_since value for smart devices [\#1144](https://github.com/python-kasa/python-kasa/pull/1144) (@sdb9696) - parse\_pcap\_klap: require source host [\#1137](https://github.com/python-kasa/python-kasa/pull/1137) (@rytilahti) - parse\_pcap\_klap: use request\_uri for matching the response [\#1136](https://github.com/python-kasa/python-kasa/pull/1136) (@rytilahti) - +- Use tzinfo in time constructor instead of astime for iot devices [\#1158](https://github.com/python-kasa/python-kasa/pull/1158) (@sdb9696) +- Stabilise on\_since value for smart devices [\#1144](https://github.com/python-kasa/python-kasa/pull/1144) (@sdb9696) **Project maintenance:** +- Move feature initialization from \_\_init\_\_ to \_initialize\_features [\#1140](https://github.com/python-kasa/python-kasa/pull/1140) (@rytilahti) - Cache zoneinfo for smart devices [\#1156](https://github.com/python-kasa/python-kasa/pull/1156) (@sdb9696) - Correctly define SmartModule.call as an async function [\#1148](https://github.com/python-kasa/python-kasa/pull/1148) (@sdb9696) - Remove async magic patch from tests [\#1146](https://github.com/python-kasa/python-kasa/pull/1146) (@sdb9696) -- Move feature initialization from \_\_init\_\_ to \_initialize\_features [\#1140](https://github.com/python-kasa/python-kasa/pull/1140) (@rytilahti) + +**Closed issues:** + +- Move code examples out from docs [\#630](https://github.com/python-kasa/python-kasa/issues/630) ## [0.7.4](https://github.com/python-kasa/python-kasa/tree/0.7.4) (2024-09-27) diff --git a/pyproject.toml b/pyproject.toml index f92130efd..33b441f2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-kasa" -version = "0.7.5" +version = "0.7.6" description = "Python API for TP-Link Kasa and Tapo devices" license = {text = "GPL-3.0-or-later"} authors = [ { name = "python-kasa developers" }] diff --git a/uv.lock b/uv.lock index 3bfd51844..39eeb63c2 100644 --- a/uv.lock +++ b/uv.lock @@ -16,7 +16,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.10.9" +version = "3.10.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -27,83 +27,83 @@ dependencies = [ { name = "multidict" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/40/f08c5d26398f987c1a27e1e351a4b461a01ffdbf9dde429c980db5286c92/aiohttp-3.10.9.tar.gz", hash = "sha256:143b0026a9dab07a05ad2dd9e46aa859bffdd6348ddc5967b42161168c24f857", size = 7541983 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/c9/dbbc67dd2474d4df953f05e0a312181e195eb54c46d9baf382b73ba6d566/aiohttp-3.10.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8b3fb28a9ac8f2558760d8e637dbf27aef1e8b7f1d221e8669a1074d1a266bb2", size = 587387 }, - { url = "https://files.pythonhosted.org/packages/88/10/aa4fa5cc30e2116edb02e31e4030d1464ef756a69e48f0c78dec13bbf93a/aiohttp-3.10.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:91aa966858593f64c8a65cdefa3d6dc8fe3c2768b159da84c1ddbbb2c01ab4ef", size = 399780 }, - { url = "https://files.pythonhosted.org/packages/b8/6e/29ff94c6730ebc67bf7746a5c437e676044b60d3e30eac21dcc2372ccafe/aiohttp-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:63649309da83277f06a15bbdc2a54fbe75efb92caa2c25bb57ca37762789c746", size = 391141 }, - { url = "https://files.pythonhosted.org/packages/25/27/a317dbd5a2729d92bab9788b99fdffaa7af09e5a4ff79270748bbfea605c/aiohttp-3.10.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3e7fabedb3fe06933f47f1538df7b3a8d78e13d7167195f51ca47ee12690373", size = 1229237 }, - { url = "https://files.pythonhosted.org/packages/57/c4/4feadf21dc9cf89fab35a3cc71d8884aff5fa7d53fcd70f8f4d7a6ef11b2/aiohttp-3.10.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c070430fda1a550a1c3a4c2d7281d3b8cfc0c6715f616e40e3332201a253067", size = 1265039 }, - { url = "https://files.pythonhosted.org/packages/9c/04/3959f2eacca398b8dfa18cfdadead1cbf2d929ea007d86e6e7ff2b6f4dee/aiohttp-3.10.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:51d0a4901b27272ae54e42067bc4b9a90e619a690b4dc43ea5950eb3070afc32", size = 1298818 }, - { url = "https://files.pythonhosted.org/packages/9a/be/810e82ad2b3e3221530e59177520e0a0a719ef07804a2d8b0d8c73b5f479/aiohttp-3.10.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fec5fac7aea6c060f317f07494961236434928e6f4374e170ef50b3001e14581", size = 1222615 }, - { url = "https://files.pythonhosted.org/packages/92/f5/de2625920d5a3bd99347050ddc94182665e5c4cbd8f1d8fa3f3ebd9e4fad/aiohttp-3.10.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:172ad884bb61ad31ed7beed8be776eb17e7fb423f1c1be836d5cb357a096bf12", size = 1194222 }, - { url = "https://files.pythonhosted.org/packages/6d/b1/9053457d3323301552732a8a45a87e371abbe4f962325822899e7b503ab9/aiohttp-3.10.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d646fdd74c25bbdd4a055414f0fe32896c400f38ffbdfc78c68e62812a9e0257", size = 1193963 }, - { url = "https://files.pythonhosted.org/packages/a1/6c/4396e9dd9371604bd8c5d6faba6775476bc01b9def74d3e46df5b4511c10/aiohttp-3.10.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e86260b76786c28acf0b5fe31c8dca4c2add95098c709b11e8c35b424ebd4f5b", size = 1193461 }, - { url = "https://files.pythonhosted.org/packages/e1/ca/a9b15243a103c2884b5a1e9312b20a8ed44f8c637f0a71fb7509b975769b/aiohttp-3.10.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c7d7cafc11d70fdd8801abfc2ff276744ae4cb39d8060b6b542c7e44e5f2cfc2", size = 1247625 }, - { url = "https://files.pythonhosted.org/packages/61/81/85465f60776e3ece45436b061b91ae3cb2ca10494088480c17093fdf3b03/aiohttp-3.10.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc262c3df78c8ff6020c782d9ce02e4bcffe4900ad71c0ecdad59943cba54442", size = 1264521 }, - { url = "https://files.pythonhosted.org/packages/a4/f5/41712c5d385ffd20d372609aa79de6d37ca8c639b93d4edde86e4e65f255/aiohttp-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:482c85cf3d429844396d939b22bc2a03849cb9ad33344689ad1c85697bcba33a", size = 1216165 }, - { url = "https://files.pythonhosted.org/packages/43/c4/1b06d5a53ac414836bc6ebf8522e3ea70b3db19814736e417b4f669f614f/aiohttp-3.10.9-cp310-cp310-win32.whl", hash = "sha256:aeebd3061f6f1747c011e1d0b0b5f04f9f54ad1a2ca183e687e7277bef2e0da2", size = 363094 }, - { url = "https://files.pythonhosted.org/packages/fd/1c/09b8b3c994cf12db55e8ddf1889567df10e33e8855b948622d9b91288d1a/aiohttp-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:fa430b871220dc62572cef9c69b41e0d70fcb9d486a4a207a5de4c1f25d82593", size = 381512 }, - { url = "https://files.pythonhosted.org/packages/74/25/9cb2c6f7260e26ad67185b5deeb4e9eb002c352add9e7470ecda6174f3a1/aiohttp-3.10.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:16e6a51d8bc96b77f04a6764b4ad03eeef43baa32014fce71e882bd71302c7e4", size = 586917 }, - { url = "https://files.pythonhosted.org/packages/72/6f/cb3943cc0eaa1d7cfc0fbd250652587ffc60dbdb87ef175b5819f7a75920/aiohttp-3.10.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8bd9125dd0cc8ebd84bff2be64b10fdba7dc6fd7be431b5eaf67723557de3a31", size = 399398 }, - { url = "https://files.pythonhosted.org/packages/99/bd/f5b651f9b16b1408e5d15e27076074baf71cf0c7c398b5875ded822284dd/aiohttp-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dcf354661f54e6a49193d0b5653a1b011ba856e0b7a76bda2c33e4c6892f34ea", size = 391048 }, - { url = "https://files.pythonhosted.org/packages/a5/2f/af600aa1e4cad6ee1437ca00696c3a33e4ff318a352e9a2526431e688fdf/aiohttp-3.10.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42775de0ca04f90c10c5c46291535ec08e9bcc4756f1b48f02a0657febe89b10", size = 1306896 }, - { url = "https://files.pythonhosted.org/packages/1c/5e/2744f3085a6c3b8953178480ad596a1742c27c543ccb25e9dfb2f4f80724/aiohttp-3.10.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d1e4185c5d7187684d41ebb50c9aeaaaa06ca1875f4c57593071b0409d2444", size = 1345076 }, - { url = "https://files.pythonhosted.org/packages/be/75/492238db77b095573ed87dd7de9b19a7099310ebfe58a52a1c93abe0fffe/aiohttp-3.10.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2695c61cf53a5d4345a43d689f37fc0f6d3a2dc520660aec27ec0f06288d1f9", size = 1378906 }, - { url = "https://files.pythonhosted.org/packages/b6/64/b434024effa2e8d2e46ab771a4b0b6172016722cd9509de0de64d8ba7934/aiohttp-3.10.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a3f063b41cc06e8d0b3fcbbfc9c05b7420f41287e0cd4f75ce0a1f3d80729e6", size = 1293128 }, - { url = "https://files.pythonhosted.org/packages/7f/67/a069742198d5431c3780cbcf6df6e4e07ea5178632a2ea243bfc439328f4/aiohttp-3.10.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d37f4718002863b82c6f391c8efd4d3a817da37030a29e2682a94d2716209de", size = 1252191 }, - { url = "https://files.pythonhosted.org/packages/d6/ec/15510a7cb66eeba7c09bef3e8ae153f057714017210eecec21be40b47938/aiohttp-3.10.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2746d8994ebca1bdc55a1e998feff4e94222da709623bb18f6e5cfec8ec01baf", size = 1272135 }, - { url = "https://files.pythonhosted.org/packages/d1/6c/91efffd38cfa43f1adecd41ae3b6f38ea5849e230d371247eb6e96cdf594/aiohttp-3.10.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6f3c6648aa123bcd73d6f26607d59967b607b0da8ffcc27d418a4b59f4c98c7c", size = 1266675 }, - { url = "https://files.pythonhosted.org/packages/f0/ff/7a23185fbae0c6b8293a9cda167d747e20243a819fee2a4e2a3d704c53f4/aiohttp-3.10.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:558b3d223fd631ad134d89adea876e7fdb4c93c849ef195049c063ada82b7d08", size = 1322042 }, - { url = "https://files.pythonhosted.org/packages/f9/0f/11f2c383537aa3eba2a0557507c4d00e0d611e134cb5530dd2f43e7f277c/aiohttp-3.10.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4e6cb75f8ddd9c2132d00bc03c9716add57f4beff1263463724f6398b813e7eb", size = 1339642 }, - { url = "https://files.pythonhosted.org/packages/d7/9e/f1f6771bc6e8b2d0cc2c47ef88b781618202d1581a5f1d5c70e5d30fecfb/aiohttp-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:608cecd8d58d285bfd52dbca5b6251ca8d6ea567022c8a0eaae03c2589cd9af9", size = 1299481 }, - { url = "https://files.pythonhosted.org/packages/8a/f5/77e71fb00177c22dcf2319348006817ff8333ad822ba85c5c20141d0e7f7/aiohttp-3.10.9-cp311-cp311-win32.whl", hash = "sha256:36d4fba838be5f083f5490ddd281813b44d69685db910907636bc5dca6322316", size = 362644 }, - { url = "https://files.pythonhosted.org/packages/95/c8/9d1d366dba1641a5fb7642b2193858c54910e614dbe8213ac6e98e759e19/aiohttp-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:8be1a65487bdfc285bd5e9baf3208c2132ca92a9b4020e9f27df1b16fab998a9", size = 381988 }, - { url = "https://files.pythonhosted.org/packages/95/d3/1f1f100e037316a8de685fa52666b6b7b3454fb6029c7e893d17fca84494/aiohttp-3.10.9-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4fd16b30567c5b8e167923be6e027eeae0f20cf2b8a26b98a25115f28ad48ee0", size = 583949 }, - { url = "https://files.pythonhosted.org/packages/10/6d/0e23bf7f73811f32f44d3ea0435e3fbaa406b4f999f6bfe7d07481a7c73a/aiohttp-3.10.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:40ff5b7660f903dc587ed36ef08a88d46840182d9d4b5694e7607877ced698a1", size = 396108 }, - { url = "https://files.pythonhosted.org/packages/fd/af/1114d891e104fe7a2cf4111632fc267fe340133fcc0be82d6b14bbc5f6ba/aiohttp-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4edc3fd701e2b9a0d605a7b23d3de4ad23137d23fc0dbab726aa71d92f11aaaf", size = 391319 }, - { url = "https://files.pythonhosted.org/packages/b3/73/ee8f1819ee70135f019981743cc2b20fbdef184f0300d5bd4464e502ed06/aiohttp-3.10.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e525b69ee8a92c146ae5b4da9ecd15e518df4d40003b01b454ad694a27f498b5", size = 1312486 }, - { url = "https://files.pythonhosted.org/packages/13/22/5399a58e78b7de12949931a1e0b5d4a7304895bf029d59ee5a7c45fb8f66/aiohttp-3.10.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5002a02c17fcfd796d20bac719981d2fca9c006aac0797eb8f430a58e9d12431", size = 1350966 }, - { url = "https://files.pythonhosted.org/packages/6d/13/284b1b3417de5480ca7267614d10752311a73b8269dee8487935ae9aeac3/aiohttp-3.10.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd4ceeae2fb8cabdd1b71c82bfdd39662473d3433ec95b962200e9e752fb70d0", size = 1393071 }, - { url = "https://files.pythonhosted.org/packages/09/bc/a5168e2e46aed7f52c22604b2327aa0c24bcbf5acfb14a2246e0db97ebb8/aiohttp-3.10.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6e395c3d1f773cf0651cd3559e25182eb0c03a2777b53b4575d8adc1149c6e9", size = 1306720 }, - { url = "https://files.pythonhosted.org/packages/7e/0d/9f31ad6abc903abb92f5c03274231cde833be9a81220a79ffa3836d533bd/aiohttp-3.10.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bbdb8def5268f3f9cd753a265756f49228a20ed14a480d151df727808b4531dd", size = 1260673 }, - { url = "https://files.pythonhosted.org/packages/28/c0/cf952fe7aa9680eeb8d5c8285d83f58d48c2005480e47ca94bff38f54794/aiohttp-3.10.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f82ace0ec57c94aaf5b0e118d4366cff5889097412c75aa14b4fd5fc0c44ee3e", size = 1271554 }, - { url = "https://files.pythonhosted.org/packages/92/f6/cd1991bc816f6976e9182a6cde996e16c01ee07a91443eaa76eab57b65d2/aiohttp-3.10.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6ebdc3b3714afe1b134b3bbeb5f745eed3ecbcff92ab25d80e4ef299e83a5465", size = 1280670 }, - { url = "https://files.pythonhosted.org/packages/f1/29/a1f593cae76576cac964aab98242b5fd3f09e3160e31c6a981aeaea318f1/aiohttp-3.10.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f9ca09414003c0e96a735daa1f071f7d7ed06962ef4fa29ceb6c80d06696d900", size = 1317221 }, - { url = "https://files.pythonhosted.org/packages/78/37/9f491dd5c8e29632ad6486022c1baeb3cf6adf16da98d14f61ee5265da11/aiohttp-3.10.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1298b854fd31d0567cbb916091be9d3278168064fca88e70b8468875ef9ff7e7", size = 1344349 }, - { url = "https://files.pythonhosted.org/packages/8e/de/53b365b3cea5bf9b4a31d905c13e1b81a6b1f5379e7513390840fde67e05/aiohttp-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60ad5b8a7452c0f5645c73d4dad7490afd6119d453d302cd5b72b678a85d6044", size = 1306592 }, - { url = "https://files.pythonhosted.org/packages/e9/98/030429cf2d69be27d2ad7c5dbc634d1bd08bddd2343099a81c10dfc105f0/aiohttp-3.10.9-cp312-cp312-win32.whl", hash = "sha256:1a0ee6c0d590c917f1b9629371fce5f3d3f22c317aa96fbdcce3260754d7ea21", size = 359707 }, - { url = "https://files.pythonhosted.org/packages/da/cf/893f385d4ade412a242f61a2669f89afc389380cc9d29edf9335fa9f3d35/aiohttp-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:c46131c6112b534b178d4e002abe450a0a29840b61413ac25243f1291613806a", size = 379726 }, - { url = "https://files.pythonhosted.org/packages/1c/60/36e4b9f165b715b33eb09c199e0b748876bb7ef3480845688e93ff624172/aiohttp-3.10.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2bd9f3eac515c16c4360a6a00c38119333901b8590fe93c3257a9b536026594d", size = 576520 }, - { url = "https://files.pythonhosted.org/packages/24/51/1912195eda818b968f257b9774e2aa48b86d61853cecbbb85c7e85c1ea1a/aiohttp-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8cc0d13b4e3b1362d424ce3f4e8c79e1f7247a00d792823ffd640878abf28e56", size = 392311 }, - { url = "https://files.pythonhosted.org/packages/9f/3a/a5dd75d9fc06fa1791b327a3045c78ae2fa621f066da44db11aebbd8ac4a/aiohttp-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba1a599255ad6a41022e261e31bc2f6f9355a419575b391f9655c4d9e5df5ff5", size = 387829 }, - { url = "https://files.pythonhosted.org/packages/ee/7a/fdf393519f72152b8b6a33dd9c8d4553517358a2df72c78a0c15542df77d/aiohttp-3.10.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:776e9f3c9b377fcf097c4a04b241b15691e6662d850168642ff976780609303c", size = 1287492 }, - { url = "https://files.pythonhosted.org/packages/00/fb/b783999286077dbe41b99cc5ce34f71fb0e3d68621fc8603ad39d518c229/aiohttp-3.10.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8debb45545ad95b58cc16c3c1cc19ad82cffcb106db12b437885dbee265f0ab5", size = 1324034 }, - { url = "https://files.pythonhosted.org/packages/8a/43/bdc6215f327da8236972fd15c31ad349100a2a2b186558ddf76e48b66296/aiohttp-3.10.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2555e4949c8d8782f18ef20e9d39730d2656e218a6f1a21a4c4c0b56546a02e", size = 1368824 }, - { url = "https://files.pythonhosted.org/packages/0c/c9/a366ae87c0a3e9140623a4d84511e65299b35cf8a1dd2880ff245fe480c3/aiohttp-3.10.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c54dc329cd44f7f7883a9f4baaefe686e8b9662e2c6c184ea15cceee587d8d69", size = 1283182 }, - { url = "https://files.pythonhosted.org/packages/34/cd/f7d222dc983c0e2d625a00c449b923fdfa8c40f56154d2da9483ee9d3b92/aiohttp-3.10.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e709d6ac598c5416f879bb1bae3fd751366120ac3fa235a01de763537385d036", size = 1236935 }, - { url = "https://files.pythonhosted.org/packages/c3/a3/379086cd1f193f63f8b5b8cb348df6b5aa43e8eda3dd9b1b5748fa0c0090/aiohttp-3.10.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:17c272cfe7b07a5bb0c6ad3f234e0c336fb53f3bf17840f66bd77b5815ab3d16", size = 1250756 }, - { url = "https://files.pythonhosted.org/packages/44/c2/463d898c6aa0202fc0165aec0bd8d71f1db5876f40d7d297914af7490df4/aiohttp-3.10.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c21c82df33b264216abffff9f8370f303dab65d8eee3767efbbd2734363f677", size = 1249367 }, - { url = "https://files.pythonhosted.org/packages/c0/8f/90c365019d84f90cec9c43d6df8ec97ada513a7610aaa0936bae6cf2bbe0/aiohttp-3.10.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9331dd34145ff105177855017920dde140b447049cd62bb589de320fd6ddd582", size = 1293795 }, - { url = "https://files.pythonhosted.org/packages/8e/62/174aa729cb83d5bbbd13715e463181d3c19c13231304fafba3cc20f7b850/aiohttp-3.10.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ac3196952c673822ebed8871cf8802e17254fff2a2ed4835d9c045d9b88c5ec7", size = 1320527 }, - { url = "https://files.pythonhosted.org/packages/96/f7/102a9a8d3eef0d5d301328feb7ddecac9f78808589c6186497256c80b3d9/aiohttp-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2c33fa6e10bb7ed262e3ff03cc69d52869514f16558db0626a7c5c61dde3c29f", size = 1281964 }, - { url = "https://files.pythonhosted.org/packages/ab/e2/0c9ef8acfdbe6bd417a8989bc95f5e28ce1af475eb941334b2c9a751d01b/aiohttp-3.10.9-cp313-cp313-win32.whl", hash = "sha256:a14e4b672c257a6b94fe934ee62666bacbc8e45b7876f9dd9502d0f0fe69db16", size = 357936 }, - { url = "https://files.pythonhosted.org/packages/71/c0/6d33ac32bfbf9dd91a16c26bc37dd4763084d7f991dc848655d34e31291a/aiohttp-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:a35ed3d03910785f7d9d6f5381f0c24002b2b888b298e6f941b2fc94c5055fcd", size = 377205 }, - { url = "https://files.pythonhosted.org/packages/9b/87/6ff9af3c925dcc1d8e597d83115a919bd56f0b4399e37f4c090dd927c731/aiohttp-3.10.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:fcd546782d03181b0b1d20b43d612429a90a68779659ba8045114b867971ab71", size = 589008 }, - { url = "https://files.pythonhosted.org/packages/40/58/2cfe2759561e64587538a275292b66008e8f5d6d216da4618125a50668c2/aiohttp-3.10.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:85711eec2d875cd88c7eb40e734c4ca6d9ae477d6f26bd2b5bb4f7f60e41b156", size = 400673 }, - { url = "https://files.pythonhosted.org/packages/4b/15/cd02f34d8c84e0519fa4f6fdfa5311126513ad610b626a2d5e656e2ef6ab/aiohttp-3.10.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:02d1d6610588bcd743fae827bd6f2e47e0d09b346f230824b4c6fb85c6065f9c", size = 392003 }, - { url = "https://files.pythonhosted.org/packages/3e/23/d66db0d1bf390aced372e246b0ab3fc2391e7d430f807ffa7940627b4965/aiohttp-3.10.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3668d0c2a4d23fb136a753eba42caa2c0abbd3d9c5c87ee150a716a16c6deec1", size = 1234087 }, - { url = "https://files.pythonhosted.org/packages/03/e5/32f1d4a893fffc7babb79c6c6c360207ddeda972d909e63f09e5ba5881bd/aiohttp-3.10.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7c071235a47d407b0e93aa6262b49422dbe48d7d8566e1158fecc91043dd948", size = 1271471 }, - { url = "https://files.pythonhosted.org/packages/a6/b9/fcc0ccd893c8b46badac5f1a5333cc07af34835821afdf821ba5e631cbb7/aiohttp-3.10.9-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac74e794e3aee92ae8f571bfeaa103a141e409863a100ab63a253b1c53b707eb", size = 1305286 }, - { url = "https://files.pythonhosted.org/packages/fb/ed/039d8a7fd4085635041757328ef4bea2b449afa84ecd09b19b73939a5972/aiohttp-3.10.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bbf94d4a0447705b7775417ca8bb8086cc5482023a6e17cdc8f96d0b1b5aba6", size = 1225844 }, - { url = "https://files.pythonhosted.org/packages/10/0e/90690cbb5df24dbb7a604102433b80c66ede1e208c153d057c0c897c9c0d/aiohttp-3.10.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb0b2d5d51f96b6cc19e6ab46a7b684be23240426ae951dcdac9639ab111b45e", size = 1197001 }, - { url = "https://files.pythonhosted.org/packages/3a/be/b9e01520216ada2fe72f6c8c81f13c932a894e0a07a27533261d504d8bf5/aiohttp-3.10.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e83dfefb4f7d285c2d6a07a22268344a97d61579b3e0dce482a5be0251d672ab", size = 1197137 }, - { url = "https://files.pythonhosted.org/packages/95/38/ddf4c463b1258a4b5df6dccb84201c6a999e53f0b0a98785dffb85d298d1/aiohttp-3.10.9-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f0a44bb40b6aaa4fb9a5c1ee07880570ecda2065433a96ccff409c9c20c1624a", size = 1197624 }, - { url = "https://files.pythonhosted.org/packages/b7/a0/b5fa1c9e280368740d8411518632f973b4cc136e9ef5180cfec085c7f628/aiohttp-3.10.9-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c2b627d3c8982691b06d89d31093cee158c30629fdfebe705a91814d49b554f8", size = 1251727 }, - { url = "https://files.pythonhosted.org/packages/fc/94/348d49e568979593bd1509b99ff224406c4159dd3f6e611873fbe7ad11b6/aiohttp-3.10.9-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:03690541e4cc866eef79626cfa1ef4dd729c5c1408600c8cb9e12e1137eed6ab", size = 1266497 }, - { url = "https://files.pythonhosted.org/packages/52/38/843e288d0d035eb32e8d6ad5ab90d3e6a738d4f4b4f6452174e950892334/aiohttp-3.10.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ad3675c126f2a95bde637d162f8231cff6bc0bc9fbe31bd78075f9ff7921e322", size = 1217751 }, - { url = "https://files.pythonhosted.org/packages/c1/99/e742ba9a6efd885aaaf9a71083dfdb370435fb8e678eed950848efe4202f/aiohttp-3.10.9-cp39-cp39-win32.whl", hash = "sha256:1321658f12b6caffafdc35cfba6c882cb014af86bef4e78c125e7e794dfb927b", size = 363681 }, - { url = "https://files.pythonhosted.org/packages/67/10/4c09a2d732ae5419451ad531afc27df92c74e38f629fdfd42674ff258a79/aiohttp-3.10.9-cp39-cp39-win_amd64.whl", hash = "sha256:9fdf5c839bf95fc67be5794c780419edb0dbef776edcfc6c2e5e2ffd5ee755fa", size = 382182 }, +sdist = { url = "https://files.pythonhosted.org/packages/17/7e/16e57e6cf20eb62481a2f9ce8674328407187950ccc602ad07c685279141/aiohttp-3.10.10.tar.gz", hash = "sha256:0631dd7c9f0822cc61c88586ca76d5b5ada26538097d0f1df510b082bad3411a", size = 7542993 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/dd/3d40c0e67e79c5c42671e3e268742f1ff96c6573ca43823563d01abd9475/aiohttp-3.10.10-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:be7443669ae9c016b71f402e43208e13ddf00912f47f623ee5994e12fc7d4b3f", size = 586969 }, + { url = "https://files.pythonhosted.org/packages/75/64/8de41b5555e5b43ef6d4ed1261891d33fe45ecc6cb62875bfafb90b9ab93/aiohttp-3.10.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b06b7843929e41a94ea09eb1ce3927865387e3e23ebe108e0d0d09b08d25be9", size = 399367 }, + { url = "https://files.pythonhosted.org/packages/96/36/27bd62ea7ce43906d1443a73691823fc82ffb8fa03276b0e2f7e1037c286/aiohttp-3.10.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:333cf6cf8e65f6a1e06e9eb3e643a0c515bb850d470902274239fea02033e9a8", size = 390720 }, + { url = "https://files.pythonhosted.org/packages/e8/4d/d516b050d811ce0dd26325c383013c104ffa8b58bd361b82e52833f68e78/aiohttp-3.10.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:274cfa632350225ce3fdeb318c23b4a10ec25c0e2c880eff951a3842cf358ac1", size = 1228820 }, + { url = "https://files.pythonhosted.org/packages/53/94/964d9327a3e336d89aad52260836e4ec87fdfa1207176550fdf384eaffe7/aiohttp-3.10.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9e5e4a85bdb56d224f412d9c98ae4cbd032cc4f3161818f692cd81766eee65a", size = 1264616 }, + { url = "https://files.pythonhosted.org/packages/0c/20/70ce17764b685ca8f5bf4d568881b4e1f1f4ea5e8170f512fdb1a33859d2/aiohttp-3.10.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b606353da03edcc71130b52388d25f9a30a126e04caef1fd637e31683033abd", size = 1298402 }, + { url = "https://files.pythonhosted.org/packages/d1/d1/5248225ccc687f498d06c3bca5af2647a361c3687a85eb3aedcc247ee1aa/aiohttp-3.10.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab5a5a0c7a7991d90446a198689c0535be89bbd6b410a1f9a66688f0880ec026", size = 1222205 }, + { url = "https://files.pythonhosted.org/packages/f2/a3/9296b27cc5d4feadf970a14d0694902a49a985f3fae71b8322a5f77b0baa/aiohttp-3.10.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:578a4b875af3e0daaf1ac6fa983d93e0bbfec3ead753b6d6f33d467100cdc67b", size = 1193804 }, + { url = "https://files.pythonhosted.org/packages/d9/07/f3760160feb12ac51a6168a6da251a4a8f2a70733d49e6ceb9b3e6ee2f03/aiohttp-3.10.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8105fd8a890df77b76dd3054cddf01a879fc13e8af576805d667e0fa0224c35d", size = 1193544 }, + { url = "https://files.pythonhosted.org/packages/7e/4c/93a70f9a4ba1c30183a6dd68bfa79cddbf9a674f162f9c62e823a74a5515/aiohttp-3.10.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3bcd391d083f636c06a68715e69467963d1f9600f85ef556ea82e9ef25f043f7", size = 1193047 }, + { url = "https://files.pythonhosted.org/packages/ff/a3/36a1e23ff00c7a0cd696c5a28db05db25dc42bfc78c508bd78623ff62a4a/aiohttp-3.10.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fbc6264158392bad9df19537e872d476f7c57adf718944cc1e4495cbabf38e2a", size = 1247201 }, + { url = "https://files.pythonhosted.org/packages/55/ae/95399848557b98bb2c402d640b2276ce3a542b94dba202de5a5a1fe29abe/aiohttp-3.10.10-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e48d5021a84d341bcaf95c8460b152cfbad770d28e5fe14a768988c461b821bc", size = 1264102 }, + { url = "https://files.pythonhosted.org/packages/38/f5/02e5c72c1b60d7cceb30b982679a26167e84ac029fd35a93dd4da52c50a3/aiohttp-3.10.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2609e9ab08474702cc67b7702dbb8a80e392c54613ebe80db7e8dbdb79837c68", size = 1215760 }, + { url = "https://files.pythonhosted.org/packages/30/17/1463840bad10d02d0439068f37ce5af0b383884b0d5838f46fb027e233bf/aiohttp-3.10.10-cp310-cp310-win32.whl", hash = "sha256:84afcdea18eda514c25bc68b9af2a2b1adea7c08899175a51fe7c4fb6d551257", size = 362678 }, + { url = "https://files.pythonhosted.org/packages/dd/01/a0ef707d93e867a43abbffee3a2cdf30559910750b9176b891628c7ad074/aiohttp-3.10.10-cp310-cp310-win_amd64.whl", hash = "sha256:9c72109213eb9d3874f7ac8c0c5fa90e072d678e117d9061c06e30c85b4cf0e6", size = 381097 }, + { url = "https://files.pythonhosted.org/packages/72/31/3c351d17596194e5a38ef169a4da76458952b2497b4b54645b9d483cbbb0/aiohttp-3.10.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c30a0eafc89d28e7f959281b58198a9fa5e99405f716c0289b7892ca345fe45f", size = 586501 }, + { url = "https://files.pythonhosted.org/packages/a4/a8/a559d09eb08478cdead6b7ce05b0c4a133ba27fcdfa91e05d2e62867300d/aiohttp-3.10.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:258c5dd01afc10015866114e210fb7365f0d02d9d059c3c3415382ab633fcbcb", size = 398993 }, + { url = "https://files.pythonhosted.org/packages/c5/47/7736d4174613feef61d25332c3bd1a4f8ff5591fbd7331988238a7299485/aiohttp-3.10.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:15ecd889a709b0080f02721255b3f80bb261c2293d3c748151274dfea93ac871", size = 390647 }, + { url = "https://files.pythonhosted.org/packages/27/21/e9ba192a04b7160f5a8952c98a1de7cf8072ad150fa3abd454ead1ab1d7f/aiohttp-3.10.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3935f82f6f4a3820270842e90456ebad3af15810cf65932bd24da4463bc0a4c", size = 1306481 }, + { url = "https://files.pythonhosted.org/packages/cf/50/f364c01c8d0def1dc34747b2470969e216f5a37c7ece00fe558810f37013/aiohttp-3.10.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:413251f6fcf552a33c981c4709a6bba37b12710982fec8e558ae944bfb2abd38", size = 1344652 }, + { url = "https://files.pythonhosted.org/packages/1d/c2/74f608e984e9b585649e2e83883facad6fa3fc1d021de87b20cc67e8e5ae/aiohttp-3.10.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1720b4f14c78a3089562b8875b53e36b51c97c51adc53325a69b79b4b48ebcb", size = 1378498 }, + { url = "https://files.pythonhosted.org/packages/9f/a7/05a48c7c0a7a80a5591b1203bf1b64ca2ed6a2050af918d09c05852dc42b/aiohttp-3.10.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:679abe5d3858b33c2cf74faec299fda60ea9de62916e8b67e625d65bf069a3b7", size = 1292718 }, + { url = "https://files.pythonhosted.org/packages/7d/78/a925655018747e9790350180330032e27d6e0d7ed30bde545fae42f8c49c/aiohttp-3.10.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79019094f87c9fb44f8d769e41dbb664d6e8fcfd62f665ccce36762deaa0e911", size = 1251776 }, + { url = "https://files.pythonhosted.org/packages/47/9d/85c6b69f702351d1236594745a4fdc042fc43f494c247a98dac17e004026/aiohttp-3.10.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe2fb38c2ed905a2582948e2de560675e9dfbee94c6d5ccdb1301c6d0a5bf092", size = 1271716 }, + { url = "https://files.pythonhosted.org/packages/7f/a7/55fc805ff9b14af818903882ece08e2235b12b73b867b521b92994c52b14/aiohttp-3.10.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a3f00003de6eba42d6e94fabb4125600d6e484846dbf90ea8e48a800430cc142", size = 1266263 }, + { url = "https://files.pythonhosted.org/packages/1f/ec/d2be2ca7b063e4f91519d550dbc9c1cb43040174a322470deed90b3d3333/aiohttp-3.10.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1bbb122c557a16fafc10354b9d99ebf2f2808a660d78202f10ba9d50786384b9", size = 1321617 }, + { url = "https://files.pythonhosted.org/packages/c9/a3/b29f7920e1cd0a9a68a45dd3eb16140074d2efb1518d2e1f3e140357dc37/aiohttp-3.10.10-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:30ca7c3b94708a9d7ae76ff281b2f47d8eaf2579cd05971b5dc681db8caac6e1", size = 1339227 }, + { url = "https://files.pythonhosted.org/packages/8a/81/34b67235c47e232d807b4bbc42ba9b927c7ce9476872372fddcfd1e41b3d/aiohttp-3.10.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df9270660711670e68803107d55c2b5949c2e0f2e4896da176e1ecfc068b974a", size = 1299068 }, + { url = "https://files.pythonhosted.org/packages/04/1f/26a7fe11b6ad3184f214733428353c89ae9fe3e4f605a657f5245c5e720c/aiohttp-3.10.10-cp311-cp311-win32.whl", hash = "sha256:aafc8ee9b742ce75044ae9a4d3e60e3d918d15a4c2e08a6c3c3e38fa59b92d94", size = 362223 }, + { url = "https://files.pythonhosted.org/packages/10/91/85dcd93f64011434359ce2666bece981f08d31bc49df33261e625b28595d/aiohttp-3.10.10-cp311-cp311-win_amd64.whl", hash = "sha256:362f641f9071e5f3ee6f8e7d37d5ed0d95aae656adf4ef578313ee585b585959", size = 381576 }, + { url = "https://files.pythonhosted.org/packages/ae/99/4c5aefe5ad06a1baf206aed6598c7cdcbc7c044c46801cd0d1ecb758cae3/aiohttp-3.10.10-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9294bbb581f92770e6ed5c19559e1e99255e4ca604a22c5c6397b2f9dd3ee42c", size = 583536 }, + { url = "https://files.pythonhosted.org/packages/a9/36/8b3bc49b49cb6d2da40ee61ff15dbcc44fd345a3e6ab5bb20844df929821/aiohttp-3.10.10-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a8fa23fe62c436ccf23ff930149c047f060c7126eae3ccea005f0483f27b2e28", size = 395693 }, + { url = "https://files.pythonhosted.org/packages/e1/77/0aa8660dcf11fa65d61712dbb458c4989de220a844bd69778dff25f2d50b/aiohttp-3.10.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c6a5b8c7926ba5d8545c7dd22961a107526562da31a7a32fa2456baf040939f", size = 390898 }, + { url = "https://files.pythonhosted.org/packages/38/d2/b833d95deb48c75db85bf6646de0a697e7fb5d87bd27cbade4f9746b48b1/aiohttp-3.10.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:007ec22fbc573e5eb2fb7dec4198ef8f6bf2fe4ce20020798b2eb5d0abda6138", size = 1312060 }, + { url = "https://files.pythonhosted.org/packages/aa/5f/29fd5113165a0893de8efedf9b4737e0ba92dfcd791415a528f947d10299/aiohttp-3.10.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9627cc1a10c8c409b5822a92d57a77f383b554463d1884008e051c32ab1b3742", size = 1350553 }, + { url = "https://files.pythonhosted.org/packages/ad/cc/f835f74b7d344428469200105236d44606cfa448be1e7c95ca52880d9bac/aiohttp-3.10.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50edbcad60d8f0e3eccc68da67f37268b5144ecc34d59f27a02f9611c1d4eec7", size = 1392646 }, + { url = "https://files.pythonhosted.org/packages/bf/fe/1332409d845ca601893bbf2d76935e0b93d41686e5f333841c7d7a4a770d/aiohttp-3.10.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a45d85cf20b5e0d0aa5a8dca27cce8eddef3292bc29d72dcad1641f4ed50aa16", size = 1306310 }, + { url = "https://files.pythonhosted.org/packages/e4/a1/25a7633a5a513278a9892e333501e2e69c83e50be4b57a62285fb7a008c3/aiohttp-3.10.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b00807e2605f16e1e198f33a53ce3c4523114059b0c09c337209ae55e3823a8", size = 1260255 }, + { url = "https://files.pythonhosted.org/packages/f2/39/30eafe89e0e2a06c25e4762844c8214c0c0cd0fd9ffc3471694a7986f421/aiohttp-3.10.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f2d4324a98062be0525d16f768a03e0bbb3b9fe301ceee99611dc9a7953124e6", size = 1271141 }, + { url = "https://files.pythonhosted.org/packages/5b/fc/33125df728b48391ef1fcb512dfb02072158cc10d041414fb79803463020/aiohttp-3.10.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:438cd072f75bb6612f2aca29f8bd7cdf6e35e8f160bc312e49fbecab77c99e3a", size = 1280244 }, + { url = "https://files.pythonhosted.org/packages/3b/61/e42bf2c2934b5caa4e2ec0b5e5fd86989adb022b5ee60c2572a9d77cf6fe/aiohttp-3.10.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:baa42524a82f75303f714108fea528ccacf0386af429b69fff141ffef1c534f9", size = 1316805 }, + { url = "https://files.pythonhosted.org/packages/18/32/f52a5e2ae9ad3bba10e026a63a7a23abfa37c7d97aeeb9004eaa98df3ce3/aiohttp-3.10.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a7d8d14fe962153fc681f6366bdec33d4356f98a3e3567782aac1b6e0e40109a", size = 1343930 }, + { url = "https://files.pythonhosted.org/packages/05/be/6a403b464dcab3631fe8e27b0f1d906d9e45c5e92aca97ee007e5a895560/aiohttp-3.10.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1277cd707c465cd09572a774559a3cc7c7a28802eb3a2a9472588f062097205", size = 1306186 }, + { url = "https://files.pythonhosted.org/packages/8e/fd/bb50fe781068a736a02bf5c7ad5f3ab53e39f1d1e63110da6d30f7605edc/aiohttp-3.10.10-cp312-cp312-win32.whl", hash = "sha256:59bb3c54aa420521dc4ce3cc2c3fe2ad82adf7b09403fa1f48ae45c0cbde6628", size = 359289 }, + { url = "https://files.pythonhosted.org/packages/70/9e/5add7e240f77ef67c275c82cc1d08afbca57b77593118c1f6e920ae8ad3f/aiohttp-3.10.10-cp312-cp312-win_amd64.whl", hash = "sha256:0e1b370d8007c4ae31ee6db7f9a2fe801a42b146cec80a86766e7ad5c4a259cf", size = 379313 }, + { url = "https://files.pythonhosted.org/packages/b1/eb/618b1b76c7fe8082a71c9d62e3fe84c5b9af6703078caa9ec57850a12080/aiohttp-3.10.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ad7593bb24b2ab09e65e8a1d385606f0f47c65b5a2ae6c551db67d6653e78c28", size = 576114 }, + { url = "https://files.pythonhosted.org/packages/aa/37/3126995d7869f8b30d05381b81a2d4fb4ec6ad313db788e009bc6d39c211/aiohttp-3.10.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1eb89d3d29adaf533588f209768a9c02e44e4baf832b08118749c5fad191781d", size = 391901 }, + { url = "https://files.pythonhosted.org/packages/3e/f2/8fdfc845be1f811c31ceb797968523813f8e1263ee3e9120d61253f6848f/aiohttp-3.10.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3fe407bf93533a6fa82dece0e74dbcaaf5d684e5a51862887f9eaebe6372cd79", size = 387418 }, + { url = "https://files.pythonhosted.org/packages/60/d5/33d2061d36bf07e80286e04b7e0a4de37ce04b5ebfed72dba67659a05250/aiohttp-3.10.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50aed5155f819873d23520919e16703fc8925e509abbb1a1491b0087d1cd969e", size = 1287073 }, + { url = "https://files.pythonhosted.org/packages/00/52/affb55be16a4747740bd630b4c002dac6c5eac42f9bb64202fc3cf3f1930/aiohttp-3.10.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f05e9727ce409358baa615dbeb9b969db94324a79b5a5cea45d39bdb01d82e6", size = 1323612 }, + { url = "https://files.pythonhosted.org/packages/94/f2/cddb69b975387daa2182a8442566971d6410b8a0179bb4540d81c97b1611/aiohttp-3.10.10-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dffb610a30d643983aeb185ce134f97f290f8935f0abccdd32c77bed9388b42", size = 1368406 }, + { url = "https://files.pythonhosted.org/packages/c1/e4/afba7327da4d932da8c6e29aecaf855f9d52dace53ac15bfc8030a246f1b/aiohttp-3.10.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa6658732517ddabe22c9036479eabce6036655ba87a0224c612e1ae6af2087e", size = 1282761 }, + { url = "https://files.pythonhosted.org/packages/9f/6b/364856faa0c9031ea76e24ef0f7fef79cddd9fa8e7dba9a1771c6acc56b5/aiohttp-3.10.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:741a46d58677d8c733175d7e5aa618d277cd9d880301a380fd296975a9cdd7bc", size = 1236518 }, + { url = "https://files.pythonhosted.org/packages/46/af/c382846f8356fe64a7b5908bb9b477457aa23b71be7ed551013b7b7d4d87/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e00e3505cd80440f6c98c6d69269dcc2a119f86ad0a9fd70bccc59504bebd68a", size = 1250344 }, + { url = "https://files.pythonhosted.org/packages/87/53/294f87fc086fd0772d0ab82497beb9df67f0f27a8b3dd5742a2656db2bc6/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ffe595f10566f8276b76dc3a11ae4bb7eba1aac8ddd75811736a15b0d5311414", size = 1248956 }, + { url = "https://files.pythonhosted.org/packages/86/30/7d746717fe11bdfefb88bb6c09c5fc985d85c4632da8bb6018e273899254/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdfcf6443637c148c4e1a20c48c566aa694fa5e288d34b20fcdc58507882fed3", size = 1293379 }, + { url = "https://files.pythonhosted.org/packages/48/b9/45d670a834458db67a24258e9139ba61fa3bd7d69b98ecf3650c22806f8f/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d183cf9c797a5291e8301790ed6d053480ed94070637bfaad914dd38b0981f67", size = 1320108 }, + { url = "https://files.pythonhosted.org/packages/72/8c/804bb2e837a175635d2000a0659eafc15b2e9d92d3d81c8f69e141ecd0b0/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:77abf6665ae54000b98b3c742bc6ea1d1fb31c394bcabf8b5d2c1ac3ebfe7f3b", size = 1281546 }, + { url = "https://files.pythonhosted.org/packages/89/c0/862e6a9de3d6eeb126cd9d9ea388243b70df9b871ce1a42b193b7a4a77fc/aiohttp-3.10.10-cp313-cp313-win32.whl", hash = "sha256:4470c73c12cd9109db8277287d11f9dd98f77fc54155fc71a7738a83ffcc8ea8", size = 357516 }, + { url = "https://files.pythonhosted.org/packages/ae/63/3e1aee3e554263f3f1011cca50d78a4894ae16ce99bf78101ac3a2f0ef74/aiohttp-3.10.10-cp313-cp313-win_amd64.whl", hash = "sha256:486f7aabfa292719a2753c016cc3a8f8172965cabb3ea2e7f7436c7f5a22a151", size = 376785 }, + { url = "https://files.pythonhosted.org/packages/3b/8e/0946283d36f156b0fda6564a97a91f42881d3efcdf236223989a93e7caa0/aiohttp-3.10.10-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:01948b1d570f83ee7bbf5a60ea2375a89dfb09fd419170e7f5af029510033d24", size = 588595 }, + { url = "https://files.pythonhosted.org/packages/05/84/acf2e75f652c02c304d547507597f0e322e43e8531adaba5798b3b90f29e/aiohttp-3.10.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9fc1500fd2a952c5c8e3b29aaf7e3cc6e27e9cfc0a8819b3bce48cc1b849e4cc", size = 400259 }, + { url = "https://files.pythonhosted.org/packages/54/0a/2395fb583fdf490240f6990a3196e8a56d91081ac1dcdca4ca542a013d9b/aiohttp-3.10.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f614ab0c76397661b90b6851a030004dac502e48260ea10f2441abd2207fbcc7", size = 391585 }, + { url = "https://files.pythonhosted.org/packages/4f/1d/d2ecab9d1f71adf073a01233a94500e6416d760ba4b04049d432c8b22589/aiohttp-3.10.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00819de9e45d42584bed046314c40ea7e9aea95411b38971082cad449392b08c", size = 1233673 }, + { url = "https://files.pythonhosted.org/packages/e8/0d/0e198499fdc48b75cca3e32f60a87e1ed9919c51647f1ca87160e27477ac/aiohttp-3.10.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05646ebe6b94cc93407b3bf34b9eb26c20722384d068eb7339de802154d61bc5", size = 1271052 }, + { url = "https://files.pythonhosted.org/packages/df/a3/e5e2061cfeb2e37bc7eeaa1320858194dad3e01127a844036dc1f8af5953/aiohttp-3.10.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:998f3bd3cfc95e9424a6acd7840cbdd39e45bc09ef87533c006f94ac47296090", size = 1304875 }, + { url = "https://files.pythonhosted.org/packages/31/40/ba9e90b88b5e227954858184be687019ba662f072b27ae3b7cba3ae64661/aiohttp-3.10.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9010c31cd6fa59438da4e58a7f19e4753f7f264300cd152e7f90d4602449762", size = 1225430 }, + { url = "https://files.pythonhosted.org/packages/86/5f/8e17c6ba352e654a12d9fc67fadeb89f3f92675aea43e68a0119cd66b3d0/aiohttp-3.10.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ea7ffc6d6d6f8a11e6f40091a1040995cdff02cfc9ba4c2f30a516cb2633554", size = 1196582 }, + { url = "https://files.pythonhosted.org/packages/00/41/ba0f75f356febbe320abc725f1ad2fccb276d38d998f6cd1630de84c963e/aiohttp-3.10.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ef9c33cc5cbca35808f6c74be11eb7f5f6b14d2311be84a15b594bd3e58b5527", size = 1196719 }, + { url = "https://files.pythonhosted.org/packages/5e/d9/f5e686c9891d70190e8162893b97cc7e47b2d2a516da8fb5dadb30995625/aiohttp-3.10.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ce0cdc074d540265bfeb31336e678b4e37316849d13b308607efa527e981f5c2", size = 1197209 }, + { url = "https://files.pythonhosted.org/packages/25/12/c4b1ea70135afe8a03c0519c29421e8b97fc4afeb5c7fc4b583ffb6c620e/aiohttp-3.10.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:597a079284b7ee65ee102bc3a6ea226a37d2b96d0418cc9047490f231dc09fe8", size = 1251306 }, + { url = "https://files.pythonhosted.org/packages/f8/17/4041d26c5d5bddd928a7f3f2972679de59d65044a208bcd026f43c3675f4/aiohttp-3.10.10-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:7789050d9e5d0c309c706953e5e8876e38662d57d45f936902e176d19f1c58ab", size = 1266087 }, + { url = "https://files.pythonhosted.org/packages/16/41/1b0c191c3477e1d6e5313d4a9fefeb436ab649c498622d4c14a9cc9eee6b/aiohttp-3.10.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e7f8b04d83483577fd9200461b057c9f14ced334dcb053090cea1da9c8321a91", size = 1217338 }, + { url = "https://files.pythonhosted.org/packages/4a/4b/4be4ab18675255178acaf18edda4fb19f15debefc873dfcc9ad6b73d3b2c/aiohttp-3.10.10-cp39-cp39-win32.whl", hash = "sha256:c02a30b904282777d872266b87b20ed8cc0d1501855e27f831320f471d54d983", size = 363262 }, + { url = "https://files.pythonhosted.org/packages/f7/54/e1f69b580e11127deb4c18e765bcc4730cd133ab3c75806c62f985af3e1c/aiohttp-3.10.10-cp39-cp39-win_amd64.whl", hash = "sha256:edfe3341033a6b53a5c522c802deb2079eee5cbfbb0af032a55064bd65c73a23", size = 381766 }, ] [[package]] @@ -138,7 +138,7 @@ wheels = [ [[package]] name = "anyio" -version = "4.6.0" +version = "4.6.2.post1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, @@ -146,9 +146,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/49/f3f17ec11c4a91fe79275c426658e509b07547f874b14c1a526d86a83fc8/anyio-4.6.0.tar.gz", hash = "sha256:137b4559cbb034c477165047febb6ff83f390fc3b20bf181c1fc0a728cb8beeb", size = 170983 } +sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/ef/7a4f225581a0d7886ea28359179cb861d7fbcdefad29663fc1167b86f69f/anyio-4.6.0-py3-none-any.whl", hash = "sha256:c7d2e9d63e31599eeb636c8c5c03a7e108d73b345f064f1c19fdc87b79036a9a", size = 89631 }, + { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377 }, ] [[package]] @@ -289,71 +289,86 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/09/c1bc53dab74b1816a00d8d030de5bf98f724c52c1635e07681d312f20be8/charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", size = 104809 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/61/095a0aa1a84d1481998b534177c8566fdc50bb1233ea9a0478cd3cc075bd/charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", size = 194219 }, - { url = "https://files.pythonhosted.org/packages/cc/94/f7cf5e5134175de79ad2059edf2adce18e0685ebdb9227ff0139975d0e93/charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", size = 122521 }, - { url = "https://files.pythonhosted.org/packages/46/6a/d5c26c41c49b546860cc1acabdddf48b0b3fb2685f4f5617ac59261b44ae/charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", size = 120383 }, - { url = "https://files.pythonhosted.org/packages/b8/60/e2f67915a51be59d4539ed189eb0a2b0d292bf79270410746becb32bc2c3/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", size = 138223 }, - { url = "https://files.pythonhosted.org/packages/05/8c/eb854996d5fef5e4f33ad56927ad053d04dc820e4a3d39023f35cad72617/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", size = 148101 }, - { url = "https://files.pythonhosted.org/packages/f6/93/bb6cbeec3bf9da9b2eba458c15966658d1daa8b982c642f81c93ad9b40e1/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", size = 140699 }, - { url = "https://files.pythonhosted.org/packages/da/f1/3702ba2a7470666a62fd81c58a4c40be00670e5006a67f4d626e57f013ae/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", size = 142065 }, - { url = "https://files.pythonhosted.org/packages/3f/ba/3f5e7be00b215fa10e13d64b1f6237eb6ebea66676a41b2bcdd09fe74323/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", size = 144505 }, - { url = "https://files.pythonhosted.org/packages/33/c3/3b96a435c5109dd5b6adc8a59ba1d678b302a97938f032e3770cc84cd354/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", size = 139425 }, - { url = "https://files.pythonhosted.org/packages/43/05/3bf613e719efe68fb3a77f9c536a389f35b95d75424b96b426a47a45ef1d/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", size = 145287 }, - { url = "https://files.pythonhosted.org/packages/58/78/a0bc646900994df12e07b4ae5c713f2b3e5998f58b9d3720cce2aa45652f/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", size = 149929 }, - { url = "https://files.pythonhosted.org/packages/eb/5c/97d97248af4920bc68687d9c3b3c0f47c910e21a8ff80af4565a576bd2f0/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", size = 141605 }, - { url = "https://files.pythonhosted.org/packages/a8/31/47d018ef89f95b8aded95c589a77c072c55e94b50a41aa99c0a2008a45a4/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", size = 142646 }, - { url = "https://files.pythonhosted.org/packages/ae/d5/4fecf1d58bedb1340a50f165ba1c7ddc0400252d6832ff619c4568b36cc0/charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", size = 92846 }, - { url = "https://files.pythonhosted.org/packages/a2/a0/4af29e22cb5942488cf45630cbdd7cefd908768e69bdd90280842e4e8529/charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", size = 100343 }, - { url = "https://files.pythonhosted.org/packages/68/77/02839016f6fbbf808e8b38601df6e0e66c17bbab76dff4613f7511413597/charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", size = 191647 }, - { url = "https://files.pythonhosted.org/packages/3e/33/21a875a61057165e92227466e54ee076b73af1e21fe1b31f1e292251aa1e/charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", size = 121434 }, - { url = "https://files.pythonhosted.org/packages/dd/51/68b61b90b24ca35495956b718f35a9756ef7d3dd4b3c1508056fa98d1a1b/charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", size = 118979 }, - { url = "https://files.pythonhosted.org/packages/e4/a6/7ee57823d46331ddc37dd00749c95b0edec2c79b15fc0d6e6efb532e89ac/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", size = 136582 }, - { url = "https://files.pythonhosted.org/packages/74/f1/0d9fe69ac441467b737ba7f48c68241487df2f4522dd7246d9426e7c690e/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", size = 146645 }, - { url = "https://files.pythonhosted.org/packages/05/31/e1f51c76db7be1d4aef220d29fbfa5dbb4a99165d9833dcbf166753b6dc0/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", size = 139398 }, - { url = "https://files.pythonhosted.org/packages/40/26/f35951c45070edc957ba40a5b1db3cf60a9dbb1b350c2d5bef03e01e61de/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", size = 140273 }, - { url = "https://files.pythonhosted.org/packages/07/07/7e554f2bbce3295e191f7e653ff15d55309a9ca40d0362fcdab36f01063c/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", size = 142577 }, - { url = "https://files.pythonhosted.org/packages/d8/b5/eb705c313100defa57da79277d9207dc8d8e45931035862fa64b625bfead/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", size = 137747 }, - { url = "https://files.pythonhosted.org/packages/19/28/573147271fd041d351b438a5665be8223f1dd92f273713cb882ddafe214c/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", size = 143375 }, - { url = "https://files.pythonhosted.org/packages/cf/7c/f3b682fa053cc21373c9a839e6beba7705857075686a05c72e0f8c4980ca/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", size = 148474 }, - { url = "https://files.pythonhosted.org/packages/1e/49/7ab74d4ac537ece3bc3334ee08645e231f39f7d6df6347b29a74b0537103/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", size = 140232 }, - { url = "https://files.pythonhosted.org/packages/2d/dc/9dacba68c9ac0ae781d40e1a0c0058e26302ea0660e574ddf6797a0347f7/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", size = 140859 }, - { url = "https://files.pythonhosted.org/packages/6c/c2/4a583f800c0708dd22096298e49f887b49d9746d0e78bfc1d7e29816614c/charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", size = 92509 }, - { url = "https://files.pythonhosted.org/packages/57/ec/80c8d48ac8b1741d5b963797b7c0c869335619e13d4744ca2f67fc11c6fc/charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", size = 99870 }, - { url = "https://files.pythonhosted.org/packages/d1/b2/fcedc8255ec42afee97f9e6f0145c734bbe104aac28300214593eb326f1d/charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", size = 192892 }, - { url = "https://files.pythonhosted.org/packages/2e/7d/2259318c202f3d17f3fe6438149b3b9e706d1070fe3fcbb28049730bb25c/charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", size = 122213 }, - { url = "https://files.pythonhosted.org/packages/3a/52/9f9d17c3b54dc238de384c4cb5a2ef0e27985b42a0e5cc8e8a31d918d48d/charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", size = 119404 }, - { url = "https://files.pythonhosted.org/packages/99/b0/9c365f6d79a9f0f3c379ddb40a256a67aa69c59609608fe7feb6235896e1/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", size = 137275 }, - { url = "https://files.pythonhosted.org/packages/91/33/749df346e93d7a30cdcb90cbfdd41a06026317bfbfb62cd68307c1a3c543/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", size = 147518 }, - { url = "https://files.pythonhosted.org/packages/72/1a/641d5c9f59e6af4c7b53da463d07600a695b9824e20849cb6eea8a627761/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", size = 140182 }, - { url = "https://files.pythonhosted.org/packages/ee/fb/14d30eb4956408ee3ae09ad34299131fb383c47df355ddb428a7331cfa1e/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", size = 141869 }, - { url = "https://files.pythonhosted.org/packages/df/3e/a06b18788ca2eb6695c9b22325b6fde7dde0f1d1838b1792a0076f58fe9d/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", size = 144042 }, - { url = "https://files.pythonhosted.org/packages/45/59/3d27019d3b447a88fe7e7d004a1e04be220227760264cc41b405e863891b/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", size = 138275 }, - { url = "https://files.pythonhosted.org/packages/7b/ef/5eb105530b4da8ae37d506ccfa25057961b7b63d581def6f99165ea89c7e/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", size = 144819 }, - { url = "https://files.pythonhosted.org/packages/a2/51/e5023f937d7f307c948ed3e5c29c4b7a3e42ed2ee0b8cdf8f3a706089bf0/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", size = 149415 }, - { url = "https://files.pythonhosted.org/packages/24/9d/2e3ef673dfd5be0154b20363c5cdcc5606f35666544381bee15af3778239/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", size = 141212 }, - { url = "https://files.pythonhosted.org/packages/5b/ae/ce2c12fcac59cb3860b2e2d76dc405253a4475436b1861d95fe75bdea520/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", size = 142167 }, - { url = "https://files.pythonhosted.org/packages/ed/3a/a448bf035dce5da359daf9ae8a16b8a39623cc395a2ffb1620aa1bce62b0/charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", size = 93041 }, - { url = "https://files.pythonhosted.org/packages/b6/7c/8debebb4f90174074b827c63242c23851bdf00a532489fba57fef3416e40/charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", size = 100397 }, - { url = "https://files.pythonhosted.org/packages/f7/9d/bcf4a449a438ed6f19790eee543a86a740c77508fbc5ddab210ab3ba3a9a/charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", size = 194198 }, - { url = "https://files.pythonhosted.org/packages/66/fe/c7d3da40a66a6bf2920cce0f436fa1f62ee28aaf92f412f0bf3b84c8ad6c/charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", size = 122494 }, - { url = "https://files.pythonhosted.org/packages/2a/9d/a6d15bd1e3e2914af5955c8eb15f4071997e7078419328fee93dfd497eb7/charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", size = 120393 }, - { url = "https://files.pythonhosted.org/packages/3d/85/5b7416b349609d20611a64718bed383b9251b5a601044550f0c8983b8900/charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", size = 138331 }, - { url = "https://files.pythonhosted.org/packages/79/66/8946baa705c588521afe10b2d7967300e49380ded089a62d38537264aece/charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", size = 148097 }, - { url = "https://files.pythonhosted.org/packages/44/80/b339237b4ce635b4af1c73742459eee5f97201bd92b2371c53e11958392e/charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", size = 140711 }, - { url = "https://files.pythonhosted.org/packages/98/69/5d8751b4b670d623aa7a47bef061d69c279e9f922f6705147983aa76c3ce/charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", size = 142251 }, - { url = "https://files.pythonhosted.org/packages/1f/8d/33c860a7032da5b93382cbe2873261f81467e7b37f4ed91e25fed62fd49b/charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", size = 144636 }, - { url = "https://files.pythonhosted.org/packages/c2/65/52aaf47b3dd616c11a19b1052ce7fa6321250a7a0b975f48d8c366733b9f/charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", size = 139514 }, - { url = "https://files.pythonhosted.org/packages/51/fd/0ee5b1c2860bb3c60236d05b6e4ac240cf702b67471138571dad91bcfed8/charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", size = 145528 }, - { url = "https://files.pythonhosted.org/packages/e1/9c/60729bf15dc82e3aaf5f71e81686e42e50715a1399770bcde1a9e43d09db/charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", size = 149804 }, - { url = "https://files.pythonhosted.org/packages/53/cd/aa4b8a4d82eeceb872f83237b2d27e43e637cac9ffaef19a1321c3bafb67/charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", size = 141708 }, - { url = "https://files.pythonhosted.org/packages/54/7f/cad0b328759630814fcf9d804bfabaf47776816ad4ef2e9938b7e1123d04/charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561", size = 142708 }, - { url = "https://files.pythonhosted.org/packages/c1/9d/254a2f1bcb0ce9acad838e94ed05ba71a7cb1e27affaa4d9e1ca3958cdb6/charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", size = 92830 }, - { url = "https://files.pythonhosted.org/packages/2f/0e/d7303ccae9735ff8ff01e36705ad6233ad2002962e8668a970fc000c5e1b/charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", size = 100376 }, - { url = "https://files.pythonhosted.org/packages/28/76/e6222113b83e3622caa4bb41032d0b1bf785250607392e1b778aca0b8a7d/charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", size = 48543 }, +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/8b/825cc84cf13a28bfbcba7c416ec22bf85a9584971be15b21dd8300c65b7f/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", size = 196363 }, + { url = "https://files.pythonhosted.org/packages/23/81/d7eef6a99e42c77f444fdd7bc894b0ceca6c3a95c51239e74a722039521c/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", size = 125639 }, + { url = "https://files.pythonhosted.org/packages/21/67/b4564d81f48042f520c948abac7079356e94b30cb8ffb22e747532cf469d/charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", size = 120451 }, + { url = "https://files.pythonhosted.org/packages/c2/72/12a7f0943dd71fb5b4e7b55c41327ac0a1663046a868ee4d0d8e9c369b85/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", size = 140041 }, + { url = "https://files.pythonhosted.org/packages/67/56/fa28c2c3e31217c4c52158537a2cf5d98a6c1e89d31faf476c89391cd16b/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", size = 150333 }, + { url = "https://files.pythonhosted.org/packages/f9/d2/466a9be1f32d89eb1554cf84073a5ed9262047acee1ab39cbaefc19635d2/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", size = 142921 }, + { url = "https://files.pythonhosted.org/packages/f8/01/344ec40cf5d85c1da3c1f57566c59e0c9b56bcc5566c08804a95a6cc8257/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", size = 144785 }, + { url = "https://files.pythonhosted.org/packages/73/8b/2102692cb6d7e9f03b9a33a710e0164cadfce312872e3efc7cfe22ed26b4/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", size = 146631 }, + { url = "https://files.pythonhosted.org/packages/d8/96/cc2c1b5d994119ce9f088a9a0c3ebd489d360a2eb058e2c8049f27092847/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", size = 140867 }, + { url = "https://files.pythonhosted.org/packages/c9/27/cde291783715b8ec30a61c810d0120411844bc4c23b50189b81188b273db/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", size = 149273 }, + { url = "https://files.pythonhosted.org/packages/3a/a4/8633b0fc1a2d1834d5393dafecce4a1cc56727bfd82b4dc18fc92f0d3cc3/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", size = 152437 }, + { url = "https://files.pythonhosted.org/packages/64/ea/69af161062166b5975ccbb0961fd2384853190c70786f288684490913bf5/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", size = 150087 }, + { url = "https://files.pythonhosted.org/packages/3b/fd/e60a9d9fd967f4ad5a92810138192f825d77b4fa2a557990fd575a47695b/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", size = 145142 }, + { url = "https://files.pythonhosted.org/packages/6d/02/8cb0988a1e49ac9ce2eed1e07b77ff118f2923e9ebd0ede41ba85f2dcb04/charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", size = 94701 }, + { url = "https://files.pythonhosted.org/packages/d6/20/f1d4670a8a723c46be695dff449d86d6092916f9e99c53051954ee33a1bc/charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", size = 102191 }, + { url = "https://files.pythonhosted.org/packages/9c/61/73589dcc7a719582bf56aae309b6103d2762b526bffe189d635a7fcfd998/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", size = 193339 }, + { url = "https://files.pythonhosted.org/packages/77/d5/8c982d58144de49f59571f940e329ad6e8615e1e82ef84584c5eeb5e1d72/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", size = 124366 }, + { url = "https://files.pythonhosted.org/packages/bf/19/411a64f01ee971bed3231111b69eb56f9331a769072de479eae7de52296d/charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", size = 118874 }, + { url = "https://files.pythonhosted.org/packages/4c/92/97509850f0d00e9f14a46bc751daabd0ad7765cff29cdfb66c68b6dad57f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", size = 138243 }, + { url = "https://files.pythonhosted.org/packages/e2/29/d227805bff72ed6d6cb1ce08eec707f7cfbd9868044893617eb331f16295/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", size = 148676 }, + { url = "https://files.pythonhosted.org/packages/13/bc/87c2c9f2c144bedfa62f894c3007cd4530ba4b5351acb10dc786428a50f0/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", size = 141289 }, + { url = "https://files.pythonhosted.org/packages/eb/5b/6f10bad0f6461fa272bfbbdf5d0023b5fb9bc6217c92bf068fa5a99820f5/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", size = 142585 }, + { url = "https://files.pythonhosted.org/packages/3b/a0/a68980ab8a1f45a36d9745d35049c1af57d27255eff8c907e3add84cf68f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", size = 144408 }, + { url = "https://files.pythonhosted.org/packages/d7/a1/493919799446464ed0299c8eef3c3fad0daf1c3cd48bff9263c731b0d9e2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", size = 139076 }, + { url = "https://files.pythonhosted.org/packages/fb/9d/9c13753a5a6e0db4a0a6edb1cef7aee39859177b64e1a1e748a6e3ba62c2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", size = 146874 }, + { url = "https://files.pythonhosted.org/packages/75/d2/0ab54463d3410709c09266dfb416d032a08f97fd7d60e94b8c6ef54ae14b/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", size = 150871 }, + { url = "https://files.pythonhosted.org/packages/8d/c9/27e41d481557be53d51e60750b85aa40eaf52b841946b3cdeff363105737/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", size = 148546 }, + { url = "https://files.pythonhosted.org/packages/ee/44/4f62042ca8cdc0cabf87c0fc00ae27cd8b53ab68be3605ba6d071f742ad3/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", size = 143048 }, + { url = "https://files.pythonhosted.org/packages/01/f8/38842422988b795220eb8038745d27a675ce066e2ada79516c118f291f07/charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", size = 94389 }, + { url = "https://files.pythonhosted.org/packages/0b/6e/b13bd47fa9023b3699e94abf565b5a2f0b0be6e9ddac9812182596ee62e4/charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", size = 101752 }, + { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 }, + { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 }, + { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 }, + { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 }, + { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 }, + { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 }, + { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 }, + { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 }, + { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 }, + { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 }, + { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 }, + { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 }, + { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 }, + { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 }, + { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 }, + { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, + { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, + { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, + { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, + { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, + { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, + { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, + { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, + { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, + { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, + { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, + { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, + { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, + { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, + { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, + { url = "https://files.pythonhosted.org/packages/54/2f/28659eee7f5d003e0f5a3b572765bf76d6e0fe6601ab1f1b1dd4cba7e4f1/charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", size = 196326 }, + { url = "https://files.pythonhosted.org/packages/d1/18/92869d5c0057baa973a3ee2af71573be7b084b3c3d428fe6463ce71167f8/charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", size = 125614 }, + { url = "https://files.pythonhosted.org/packages/d6/27/327904c5a54a7796bb9f36810ec4173d2df5d88b401d2b95ef53111d214e/charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", size = 120450 }, + { url = "https://files.pythonhosted.org/packages/a4/23/65af317914a0308495133b2d654cf67b11bbd6ca16637c4e8a38f80a5a69/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", size = 140135 }, + { url = "https://files.pythonhosted.org/packages/f2/41/6190102ad521a8aa888519bb014a74251ac4586cde9b38e790901684f9ab/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", size = 150413 }, + { url = "https://files.pythonhosted.org/packages/7b/ab/f47b0159a69eab9bd915591106859f49670c75f9a19082505ff16f50efc0/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", size = 142992 }, + { url = "https://files.pythonhosted.org/packages/28/89/60f51ad71f63aaaa7e51a2a2ad37919985a341a1d267070f212cdf6c2d22/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", size = 144871 }, + { url = "https://files.pythonhosted.org/packages/0c/48/0050550275fea585a6e24460b42465020b53375017d8596c96be57bfabca/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", size = 146756 }, + { url = "https://files.pythonhosted.org/packages/dc/b5/47f8ee91455946f745e6c9ddbb0f8f50314d2416dd922b213e7d5551ad09/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", size = 141034 }, + { url = "https://files.pythonhosted.org/packages/84/79/5c731059ebab43e80bf61fa51666b9b18167974b82004f18c76378ed31a3/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", size = 149434 }, + { url = "https://files.pythonhosted.org/packages/ca/f3/0719cd09fc4dc42066f239cb3c48ced17fc3316afca3e2a30a4756fe49ab/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", size = 152443 }, + { url = "https://files.pythonhosted.org/packages/f7/0e/c6357297f1157c8e8227ff337e93fd0a90e498e3d6ab96b2782204ecae48/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", size = 150294 }, + { url = "https://files.pythonhosted.org/packages/54/9a/acfa96dc4ea8c928040b15822b59d0863d6e1757fba8bd7de3dc4f761c13/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", size = 145314 }, + { url = "https://files.pythonhosted.org/packages/73/1c/b10a63032eaebb8d7bcb8544f12f063f41f5f463778ac61da15d9985e8b6/charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", size = 94724 }, + { url = "https://files.pythonhosted.org/packages/c5/77/3a78bf28bfaa0863f9cfef278dbeadf55efe064eafff8c7c424ae3c4c1bf/charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", size = 102159 }, + { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, ] [[package]] @@ -380,71 +395,71 @@ wheels = [ [[package]] name = "coverage" -version = "7.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/61/eb7ce5ed62bacf21beca4937a90fe32545c91a3c8a42a30c6616d48fc70d/coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", size = 206690 }, - { url = "https://files.pythonhosted.org/packages/7d/73/041928e434442bd3afde5584bdc3f932fb4562b1597629f537387cec6f3d/coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", size = 207127 }, - { url = "https://files.pythonhosted.org/packages/c7/c8/6ca52b5147828e45ad0242388477fdb90df2c6cbb9a441701a12b3c71bc8/coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", size = 235654 }, - { url = "https://files.pythonhosted.org/packages/d5/da/9ac2b62557f4340270942011d6efeab9833648380109e897d48ab7c1035d/coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc", size = 233598 }, - { url = "https://files.pythonhosted.org/packages/53/23/9e2c114d0178abc42b6d8d5281f651a8e6519abfa0ef460a00a91f80879d/coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", size = 234732 }, - { url = "https://files.pythonhosted.org/packages/0f/7e/a0230756fb133343a52716e8b855045f13342b70e48e8ad41d8a0d60ab98/coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", size = 233816 }, - { url = "https://files.pythonhosted.org/packages/28/7c/3753c8b40d232b1e5eeaed798c875537cf3cb183fb5041017c1fdb7ec14e/coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", size = 232325 }, - { url = "https://files.pythonhosted.org/packages/57/e3/818a2b2af5b7573b4b82cf3e9f137ab158c90ea750a8f053716a32f20f06/coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", size = 233418 }, - { url = "https://files.pythonhosted.org/packages/c8/fb/4532b0b0cefb3f06d201648715e03b0feb822907edab3935112b61b885e2/coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", size = 209343 }, - { url = "https://files.pythonhosted.org/packages/5a/25/af337cc7421eca1c187cc9c315f0a755d48e755d2853715bfe8c418a45fa/coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", size = 210136 }, - { url = "https://files.pythonhosted.org/packages/ad/5f/67af7d60d7e8ce61a4e2ddcd1bd5fb787180c8d0ae0fbd073f903b3dd95d/coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", size = 206796 }, - { url = "https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", size = 207244 }, - { url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279 }, - { url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859 }, - { url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549 }, - { url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477 }, - { url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134 }, - { url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910 }, - { url = "https://files.pythonhosted.org/packages/7d/30/033e663399ff17dca90d793ee8a2ea2890e7fdf085da58d82468b4220bf7/coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", size = 209348 }, - { url = "https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", size = 210230 }, - { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983 }, - { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221 }, - { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342 }, - { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371 }, - { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455 }, - { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924 }, - { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252 }, - { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897 }, - { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606 }, - { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373 }, - { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007 }, - { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269 }, - { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886 }, - { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037 }, - { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038 }, - { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690 }, - { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765 }, - { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611 }, - { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671 }, - { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368 }, - { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758 }, - { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035 }, - { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839 }, - { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569 }, - { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927 }, - { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401 }, - { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301 }, - { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598 }, - { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307 }, - { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453 }, - { url = "https://files.pythonhosted.org/packages/19/d3/d54c5aa83268779d54c86deb39c1c4566e5d45c155369ca152765f8db413/coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", size = 206688 }, - { url = "https://files.pythonhosted.org/packages/a5/fe/137d5dca72e4a258b1bc17bb04f2e0196898fe495843402ce826a7419fe3/coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", size = 207120 }, - { url = "https://files.pythonhosted.org/packages/78/5b/a0a796983f3201ff5485323b225d7c8b74ce30c11f456017e23d8e8d1945/coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", size = 235249 }, - { url = "https://files.pythonhosted.org/packages/4e/e1/76089d6a5ef9d68f018f65411fcdaaeb0141b504587b901d74e8587606ad/coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", size = 233237 }, - { url = "https://files.pythonhosted.org/packages/9a/6f/eef79b779a540326fee9520e5542a8b428cc3bfa8b7c8f1022c1ee4fc66c/coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", size = 234311 }, - { url = "https://files.pythonhosted.org/packages/75/e1/656d65fb126c29a494ef964005702b012f3498db1a30dd562958e85a4049/coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", size = 233453 }, - { url = "https://files.pythonhosted.org/packages/68/6a/45f108f137941a4a1238c85f28fd9d048cc46b5466d6b8dda3aba1bb9d4f/coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", size = 231958 }, - { url = "https://files.pythonhosted.org/packages/9b/e7/47b809099168b8b8c72ae311efc3e88c8d8a1162b3ba4b8da3cfcdb85743/coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", size = 232938 }, - { url = "https://files.pythonhosted.org/packages/52/80/052222ba7058071f905435bad0ba392cc12006380731c37afaf3fe749b88/coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", size = 209352 }, - { url = "https://files.pythonhosted.org/packages/b8/d8/1b92e0b3adcf384e98770a00ca095da1b5f7b483e6563ae4eb5e935d24a1/coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", size = 210153 }, - { url = "https://files.pythonhosted.org/packages/a5/2b/0354ed096bca64dc8e32a7cbcae28b34cb5ad0b1fe2125d6d99583313ac0/coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", size = 198926 }, +version = "7.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/12/3669b6382792783e92046730ad3327f53b2726f0603f4c311c4da4824222/coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73", size = 798716 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/93/4ad92f71e28ece5c0326e5f4a6630aa4928a8846654a65cfff69b49b95b9/coverage-7.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07", size = 206713 }, + { url = "https://files.pythonhosted.org/packages/01/ae/747a580b1eda3f2e431d87de48f0604bd7bc92e52a1a95185a4aa585bc47/coverage-7.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0", size = 207149 }, + { url = "https://files.pythonhosted.org/packages/07/1a/1f573f8a6145f6d4c9130bbc120e0024daf1b24cf2a78d7393fa6eb6aba7/coverage-7.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72", size = 235584 }, + { url = "https://files.pythonhosted.org/packages/40/42/c8523f2e4db34aa9389caee0d3688b6ada7a84fcc782e943a868a7f302bd/coverage-7.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/8d/95/565c310fffa16ede1a042e9ea1ca3962af0d8eb5543bc72df6b91dc0c3d5/coverage-7.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491", size = 234649 }, + { url = "https://files.pythonhosted.org/packages/d5/81/3b550674d98968ec29c92e3e8650682be6c8b1fa7581a059e7e12e74c431/coverage-7.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b", size = 233744 }, + { url = "https://files.pythonhosted.org/packages/0d/70/d66c7f51b3e33aabc5ea9f9624c1c9d9655472962270eb5e7b0d32707224/coverage-7.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea", size = 232204 }, + { url = "https://files.pythonhosted.org/packages/23/2d/2b3a2dbed7a5f40693404c8a09e779d7c1a5fbed089d3e7224c002129ec8/coverage-7.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a", size = 233335 }, + { url = "https://files.pythonhosted.org/packages/5a/4f/92d1d2ad720d698a4e71c176eacf531bfb8e0721d5ad560556f2c484a513/coverage-7.6.4-cp310-cp310-win32.whl", hash = "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa", size = 209435 }, + { url = "https://files.pythonhosted.org/packages/c7/b9/cdf158e7991e2287bcf9082670928badb73d310047facac203ff8dcd5ff3/coverage-7.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172", size = 210243 }, + { url = "https://files.pythonhosted.org/packages/87/31/9c0cf84f0dfcbe4215b7eb95c31777cdc0483c13390e69584c8150c85175/coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b", size = 206819 }, + { url = "https://files.pythonhosted.org/packages/53/ed/a38401079ad320ad6e054a01ec2b61d270511aeb3c201c80e99c841229d5/coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25", size = 207263 }, + { url = "https://files.pythonhosted.org/packages/20/e7/c3ad33b179ab4213f0d70da25a9c214d52464efa11caeab438592eb1d837/coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546", size = 239205 }, + { url = "https://files.pythonhosted.org/packages/36/91/fc02e8d8e694f557752120487fd982f654ba1421bbaa5560debf96ddceda/coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b", size = 236612 }, + { url = "https://files.pythonhosted.org/packages/cc/57/cb08f0eda0389a9a8aaa4fc1f9fec7ac361c3e2d68efd5890d7042c18aa3/coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e", size = 238479 }, + { url = "https://files.pythonhosted.org/packages/d5/c9/2c7681a9b3ca6e6f43d489c2e6653a53278ed857fd6e7010490c307b0a47/coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718", size = 237405 }, + { url = "https://files.pythonhosted.org/packages/b5/4e/ebfc6944b96317df8b537ae875d2e57c27b84eb98820bc0a1055f358f056/coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db", size = 236038 }, + { url = "https://files.pythonhosted.org/packages/13/f2/3a0bf1841a97c0654905e2ef531170f02c89fad2555879db8fe41a097871/coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522", size = 236812 }, + { url = "https://files.pythonhosted.org/packages/b9/9c/66bf59226b52ce6ed9541b02d33e80a6e816a832558fbdc1111a7bd3abd4/coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf", size = 209400 }, + { url = "https://files.pythonhosted.org/packages/2a/a0/b0790934c04dfc8d658d4a62acb8f7ca0efdf3818456fcad757b11c6479d/coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19", size = 210243 }, + { url = "https://files.pythonhosted.org/packages/7d/e7/9291de916d084f41adddfd4b82246e68d61d6a75747f075f7e64628998d2/coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2", size = 207013 }, + { url = "https://files.pythonhosted.org/packages/27/03/932c2c5717a7fa80cd43c6a07d3177076d97b79f12f40f882f9916db0063/coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117", size = 207251 }, + { url = "https://files.pythonhosted.org/packages/d5/3f/0af47dcb9327f65a45455fbca846fe96eb57c153af46c4754a3ba678938a/coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613", size = 240268 }, + { url = "https://files.pythonhosted.org/packages/8a/3c/37a9d81bbd4b23bc7d46ca820e16174c613579c66342faa390a271d2e18b/coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27", size = 237298 }, + { url = "https://files.pythonhosted.org/packages/c0/70/6b0627e5bd68204ee580126ed3513140b2298995c1233bd67404b4e44d0e/coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52", size = 239367 }, + { url = "https://files.pythonhosted.org/packages/3c/eb/634d7dfab24ac3b790bebaf9da0f4a5352cbc125ce6a9d5c6cf4c6cae3c7/coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2", size = 238853 }, + { url = "https://files.pythonhosted.org/packages/d9/0d/8e3ed00f1266ef7472a4e33458f42e39492e01a64281084fb3043553d3f1/coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1", size = 237160 }, + { url = "https://files.pythonhosted.org/packages/ce/9c/4337f468ef0ab7a2e0887a9c9da0e58e2eada6fc6cbee637a4acd5dfd8a9/coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5", size = 238824 }, + { url = "https://files.pythonhosted.org/packages/5e/09/3e94912b8dd37251377bb02727a33a67ee96b84bbbe092f132b401ca5dd9/coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17", size = 209639 }, + { url = "https://files.pythonhosted.org/packages/01/69/d4f3a4101171f32bc5b3caec8ff94c2c60f700107a6aaef7244b2c166793/coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08", size = 210428 }, + { url = "https://files.pythonhosted.org/packages/c2/4d/2dede4f7cb5a70fb0bb40a57627fddf1dbdc6b9c1db81f7c4dcdcb19e2f4/coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9", size = 207039 }, + { url = "https://files.pythonhosted.org/packages/3f/f9/d86368ae8c79e28f1fb458ebc76ae9ff3e8bd8069adc24e8f2fed03c58b7/coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba", size = 207298 }, + { url = "https://files.pythonhosted.org/packages/64/c5/b4cc3c3f64622c58fbfd4d8b9a7a8ce9d355f172f91fcabbba1f026852f6/coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c", size = 239813 }, + { url = "https://files.pythonhosted.org/packages/8a/86/14c42e60b70a79b26099e4d289ccdfefbc68624d096f4481163085aa614c/coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06", size = 236959 }, + { url = "https://files.pythonhosted.org/packages/7f/f8/4436a643631a2fbab4b44d54f515028f6099bfb1cd95b13cfbf701e7f2f2/coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f", size = 238950 }, + { url = "https://files.pythonhosted.org/packages/49/50/1571810ddd01f99a0a8be464a4ac8b147f322cd1e8e296a1528984fc560b/coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b", size = 238610 }, + { url = "https://files.pythonhosted.org/packages/f3/8c/6312d241fe7cbd1f0cade34a62fea6f333d1a261255d76b9a87074d8703c/coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21", size = 236697 }, + { url = "https://files.pythonhosted.org/packages/ce/5f/fef33dfd05d87ee9030f614c857deb6df6556b8f6a1c51bbbb41e24ee5ac/coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a", size = 238541 }, + { url = "https://files.pythonhosted.org/packages/a9/64/6a984b6e92e1ea1353b7ffa08e27f707a5e29b044622445859200f541e8c/coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e", size = 209707 }, + { url = "https://files.pythonhosted.org/packages/5c/60/ce5a9e942e9543783b3db5d942e0578b391c25cdd5e7f342d854ea83d6b7/coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963", size = 210439 }, + { url = "https://files.pythonhosted.org/packages/78/53/6719677e92c308207e7f10561a1b16ab8b5c00e9328efc9af7cfd6fb703e/coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f", size = 207784 }, + { url = "https://files.pythonhosted.org/packages/fa/dd/7054928930671fcb39ae6a83bb71d9ab5f0afb733172543ced4b09a115ca/coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806", size = 208058 }, + { url = "https://files.pythonhosted.org/packages/b5/7d/fd656ddc2b38301927b9eb3aae3fe827e7aa82e691923ed43721fd9423c9/coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11", size = 250772 }, + { url = "https://files.pythonhosted.org/packages/90/d0/eb9a3cc2100b83064bb086f18aedde3afffd7de6ead28f69736c00b7f302/coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3", size = 246490 }, + { url = "https://files.pythonhosted.org/packages/45/44/3f64f38f6faab8a0cfd2c6bc6eb4c6daead246b97cf5f8fc23bf3788f841/coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a", size = 248848 }, + { url = "https://files.pythonhosted.org/packages/5d/11/4c465a5f98656821e499f4b4619929bd5a34639c466021740ecdca42aa30/coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc", size = 248340 }, + { url = "https://files.pythonhosted.org/packages/f1/96/ebecda2d016cce9da812f404f720ca5df83c6b29f65dc80d2000d0078741/coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70", size = 246229 }, + { url = "https://files.pythonhosted.org/packages/16/d9/3d820c00066ae55d69e6d0eae11d6149a5ca7546de469ba9d597f01bf2d7/coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef", size = 247510 }, + { url = "https://files.pythonhosted.org/packages/8f/c3/4fa1eb412bb288ff6bfcc163c11700ff06e02c5fad8513817186e460ed43/coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e", size = 210353 }, + { url = "https://files.pythonhosted.org/packages/7e/77/03fc2979d1538884d921c2013075917fc927f41cd8526909852fe4494112/coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1", size = 211502 }, + { url = "https://files.pythonhosted.org/packages/fb/27/7efede2355bd1417137246246ab0980751b3ba6065102518a2d1eba6a278/coverage-7.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3", size = 206714 }, + { url = "https://files.pythonhosted.org/packages/f3/94/594af55226676d078af72b329372e2d036f9ba1eb6bcf1f81debea2453c7/coverage-7.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c", size = 207146 }, + { url = "https://files.pythonhosted.org/packages/d5/13/19de1c5315b22795dd67dbd9168281632424a344b648d23d146572e42c2b/coverage-7.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076", size = 235180 }, + { url = "https://files.pythonhosted.org/packages/db/26/8fba01ce9f376708c7efed2761cea740f50a1b4138551886213797a4cecd/coverage-7.6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376", size = 233100 }, + { url = "https://files.pythonhosted.org/packages/74/66/4db60266551b89e820b457bc3811a3c5eaad3c1324cef7730c468633387a/coverage-7.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0", size = 234231 }, + { url = "https://files.pythonhosted.org/packages/2a/9b/7b33f0892fccce50fc82ad8da76c7af1731aea48ec71279eef63a9522db7/coverage-7.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858", size = 233383 }, + { url = "https://files.pythonhosted.org/packages/91/49/6ff9c4e8a67d9014e1c434566e9169965f970350f4792a0246cd0d839442/coverage-7.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111", size = 231863 }, + { url = "https://files.pythonhosted.org/packages/81/f9/c9d330dec440676b91504fcceebca0814718fa71c8498cf29d4e21e9dbfc/coverage-7.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901", size = 232854 }, + { url = "https://files.pythonhosted.org/packages/ee/d9/605517a023a0ba8eb1f30d958f0a7ff3a21867b07dcb42618f862695ca0e/coverage-7.6.4-cp39-cp39-win32.whl", hash = "sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09", size = 209437 }, + { url = "https://files.pythonhosted.org/packages/aa/79/2626903efa84e9f5b9c8ee6972de8338673fdb5bb8d8d2797740bf911027/coverage-7.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f", size = 210209 }, + { url = "https://files.pythonhosted.org/packages/cc/56/e1d75e8981a2a92c2a777e67c26efa96c66da59d645423146eb9ff3a851b/coverage-7.6.4-pp39.pp310-none-any.whl", hash = "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e", size = 198954 }, ] [package.optional-dependencies] @@ -454,48 +469,48 @@ toml = [ [[package]] name = "cryptography" -version = "43.0.1" +version = "43.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/ba/0664727028b37e249e73879348cc46d45c5c1a2a2e81e8166462953c5755/cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d", size = 686927 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/28/b92c98a04ba762f8cdeb54eba5c4c84e63cac037a7c5e70117d337b15ad6/cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d", size = 6223222 }, - { url = "https://files.pythonhosted.org/packages/33/13/1193774705783ba364121aa2a60132fa31a668b8ababd5edfa1662354ccd/cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062", size = 3794751 }, - { url = "https://files.pythonhosted.org/packages/5e/4b/39bb3c4c8cfb3e94e736b8d8859ce5c81536e91a1033b1d26770c4249000/cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962", size = 3981827 }, - { url = "https://files.pythonhosted.org/packages/ce/dc/1471d4d56608e1013237af334b8a4c35d53895694fbb73882d1c4fd3f55e/cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277", size = 3780034 }, - { url = "https://files.pythonhosted.org/packages/ad/43/7a9920135b0d5437cc2f8f529fa757431eb6a7736ddfadfdee1cc5890800/cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a", size = 3993407 }, - { url = "https://files.pythonhosted.org/packages/cc/42/9ab8467af6c0b76f3d9b8f01d1cf25b9c9f3f2151f4acfab888d21c55a72/cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042", size = 3886457 }, - { url = "https://files.pythonhosted.org/packages/a4/65/430509e31700286ec02868a2457d2111d03ccefc20349d24e58d171ae0a7/cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494", size = 4081499 }, - { url = "https://files.pythonhosted.org/packages/bb/18/a04b6467e6e09df8c73b91dcee8878f4a438a43a3603dc3cd6f8003b92d8/cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2", size = 2616504 }, - { url = "https://files.pythonhosted.org/packages/cc/73/0eacbdc437202edcbdc07f3576ed8fb8b0ab79d27bf2c5d822d758a72faa/cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d", size = 3067456 }, - { url = "https://files.pythonhosted.org/packages/8a/b6/bc54b371f02cffd35ff8dc6baba88304d7cf8e83632566b4b42e00383e03/cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d", size = 6225263 }, - { url = "https://files.pythonhosted.org/packages/00/0e/8217e348a1fa417ec4c78cd3cdf24154f5e76fd7597343a35bd403650dfd/cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806", size = 3794368 }, - { url = "https://files.pythonhosted.org/packages/3d/ed/38b6be7254d8f7251fde8054af597ee8afa14f911da67a9410a45f602fc3/cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85", size = 3981750 }, - { url = "https://files.pythonhosted.org/packages/64/f3/b7946c3887cf7436f002f4cbb1e6aec77b8d299b86be48eeadfefb937c4b/cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c", size = 3778925 }, - { url = "https://files.pythonhosted.org/packages/ac/7e/ebda4dd4ae098a0990753efbb4b50954f1d03003846b943ea85070782da7/cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1", size = 3993152 }, - { url = "https://files.pythonhosted.org/packages/43/f6/feebbd78a3e341e3913846a3bb2c29d0b09b1b3af1573c6baabc2533e147/cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa", size = 3886392 }, - { url = "https://files.pythonhosted.org/packages/bd/4c/ab0b9407d5247576290b4fd8abd06b7f51bd414f04eef0f2800675512d61/cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4", size = 4082606 }, - { url = "https://files.pythonhosted.org/packages/05/36/e532a671998d6fcfdb9122da16434347a58a6bae9465e527e450e0bc60a5/cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47", size = 2617948 }, - { url = "https://files.pythonhosted.org/packages/b3/c6/c09cee6968add5ff868525c3815e5dccc0e3c6e89eec58dc9135d3c40e88/cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb", size = 3070445 }, - { url = "https://files.pythonhosted.org/packages/18/23/4175dcd935e1649865e1af7bd0b827cc9d9769a586dcc84f7cbe96839086/cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034", size = 3152694 }, - { url = "https://files.pythonhosted.org/packages/ea/45/967da50269954b993d4484bf85026c7377bd551651ebdabba94905972556/cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d", size = 3713077 }, - { url = "https://files.pythonhosted.org/packages/df/e6/ccd29a1f9a6b71294e1e9f530c4d779d5dd37c8bb736c05d5fb6d98a971b/cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289", size = 3915597 }, - { url = "https://files.pythonhosted.org/packages/a2/80/fb7d668f1be5e4443b7ac191f68390be24f7c2ebd36011741f62c7645eb2/cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84", size = 2989208 }, - { url = "https://files.pythonhosted.org/packages/b2/aa/782e42ccf854943dfce72fb94a8d62220f22084ff07076a638bc3f34f3cc/cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365", size = 3154685 }, - { url = "https://files.pythonhosted.org/packages/3e/fd/70f3e849ad4d6cca2118ee6938e0b52326d02406f10912356151dd4b6868/cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96", size = 3713909 }, - { url = "https://files.pythonhosted.org/packages/21/b0/4ecefa99519eaa32af49a3ad002bb3e795f9e6eb32221fd87736247fa3cb/cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172", size = 3916544 }, - { url = "https://files.pythonhosted.org/packages/8c/42/2948dd87b237565c77b28b674d972c7f983ffa3977dc8b8ad0736f6a7d97/cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2", size = 2989774 }, +sdist = { url = "https://files.pythonhosted.org/packages/0d/05/07b55d1fa21ac18c3a8c79f764e2514e6f6a9698f1be44994f5adf0d29db/cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805", size = 686989 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f3/01fdf26701a26f4b4dbc337a26883ad5bccaa6f1bbbdd29cd89e22f18a1c/cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e", size = 6225303 }, + { url = "https://files.pythonhosted.org/packages/a3/01/4896f3d1b392025d4fcbecf40fdea92d3df8662123f6835d0af828d148fd/cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e", size = 3760905 }, + { url = "https://files.pythonhosted.org/packages/0a/be/f9a1f673f0ed4b7f6c643164e513dbad28dd4f2dcdf5715004f172ef24b6/cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f", size = 3977271 }, + { url = "https://files.pythonhosted.org/packages/4e/49/80c3a7b5514d1b416d7350830e8c422a4d667b6d9b16a9392ebfd4a5388a/cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6", size = 3746606 }, + { url = "https://files.pythonhosted.org/packages/0e/16/a28ddf78ac6e7e3f25ebcef69ab15c2c6be5ff9743dd0709a69a4f968472/cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18", size = 3986484 }, + { url = "https://files.pythonhosted.org/packages/01/f5/69ae8da70c19864a32b0315049866c4d411cce423ec169993d0434218762/cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd", size = 3852131 }, + { url = "https://files.pythonhosted.org/packages/fd/db/e74911d95c040f9afd3612b1f732e52b3e517cb80de8bf183be0b7d413c6/cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73", size = 4075647 }, + { url = "https://files.pythonhosted.org/packages/56/48/7b6b190f1462818b324e674fa20d1d5ef3e24f2328675b9b16189cbf0b3c/cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2", size = 2623873 }, + { url = "https://files.pythonhosted.org/packages/eb/b1/0ebff61a004f7f89e7b65ca95f2f2375679d43d0290672f7713ee3162aff/cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd", size = 3068039 }, + { url = "https://files.pythonhosted.org/packages/30/d5/c8b32c047e2e81dd172138f772e81d852c51f0f2ad2ae8a24f1122e9e9a7/cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984", size = 6222984 }, + { url = "https://files.pythonhosted.org/packages/2f/78/55356eb9075d0be6e81b59f45c7b48df87f76a20e73893872170471f3ee8/cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5", size = 3762968 }, + { url = "https://files.pythonhosted.org/packages/2a/2c/488776a3dc843f95f86d2f957ca0fc3407d0242b50bede7fad1e339be03f/cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4", size = 3977754 }, + { url = "https://files.pythonhosted.org/packages/7c/04/2345ca92f7a22f601a9c62961741ef7dd0127c39f7310dffa0041c80f16f/cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7", size = 3749458 }, + { url = "https://files.pythonhosted.org/packages/ac/25/e715fa0bc24ac2114ed69da33adf451a38abb6f3f24ec207908112e9ba53/cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405", size = 3988220 }, + { url = "https://files.pythonhosted.org/packages/21/ce/b9c9ff56c7164d8e2edfb6c9305045fbc0df4508ccfdb13ee66eb8c95b0e/cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16", size = 3853898 }, + { url = "https://files.pythonhosted.org/packages/2a/33/b3682992ab2e9476b9c81fff22f02c8b0a1e6e1d49ee1750a67d85fd7ed2/cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", size = 4076592 }, + { url = "https://files.pythonhosted.org/packages/81/1e/ffcc41b3cebd64ca90b28fd58141c5f68c83d48563c88333ab660e002cd3/cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995", size = 2623145 }, + { url = "https://files.pythonhosted.org/packages/87/5c/3dab83cc4aba1f4b0e733e3f0c3e7d4386440d660ba5b1e3ff995feb734d/cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362", size = 3068026 }, + { url = "https://files.pythonhosted.org/packages/6f/db/d8b8a039483f25fc3b70c90bc8f3e1d4497a99358d610c5067bf3bd4f0af/cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c", size = 3144545 }, + { url = "https://files.pythonhosted.org/packages/93/90/116edd5f8ec23b2dc879f7a42443e073cdad22950d3c8ee834e3b8124543/cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3", size = 3679828 }, + { url = "https://files.pythonhosted.org/packages/d8/32/1e1d78b316aa22c0ba6493cc271c1c309969e5aa5c22c830a1d7ce3471e6/cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83", size = 3908132 }, + { url = "https://files.pythonhosted.org/packages/91/bb/cd2c13be3332e7af3cdf16154147952d39075b9f61ea5e6b5241bf4bf436/cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7", size = 2988811 }, + { url = "https://files.pythonhosted.org/packages/cc/fc/ff7c76afdc4f5933b5e99092528d4783d3d1b131960fc8b31eb38e076ca8/cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664", size = 3146844 }, + { url = "https://files.pythonhosted.org/packages/d7/29/a233efb3e98b13d9175dcb3c3146988ec990896c8fa07e8467cce27d5a80/cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08", size = 3681997 }, + { url = "https://files.pythonhosted.org/packages/c0/cf/c9eea7791b961f279fb6db86c3355cfad29a73141f46427af71852b23b95/cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa", size = 3905208 }, + { url = "https://files.pythonhosted.org/packages/21/ea/6c38ca546d5b6dab3874c2b8fc6b1739baac29bacdea31a8c6c0513b3cfa/cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff", size = 2989787 }, ] [[package]] name = "distlib" -version = "0.3.8" +version = "0.3.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/91/e2df406fb4efacdf46871c25cde65d3c6ee5e173b7e5a4547a47bae91920/distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64", size = 609931 } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/41/9307e4f5f9976bc8b7fea0b66367734e8faf3ec84bc0d412d8cfabbb66cd/distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", size = 468850 }, + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, ] [[package]] @@ -548,71 +563,86 @@ wheels = [ [[package]] name = "frozenlist" -version = "1.4.1" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/3d/2102257e7acad73efc4a0c306ad3953f68c504c16982bbdfee3ad75d8085/frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b", size = 37820 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/35/1328c7b0f780d34f8afc1d87ebdc2bb065a123b24766a0b475f0d67da637/frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac", size = 94315 }, - { url = "https://files.pythonhosted.org/packages/f4/d6/ca016b0adcf8327714ccef969740688808c86e0287bf3a639ff582f24e82/frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868", size = 53805 }, - { url = "https://files.pythonhosted.org/packages/ae/83/bcdaa437a9bd693ba658a0310f8cdccff26bd78e45fccf8e49897904a5cd/frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776", size = 52163 }, - { url = "https://files.pythonhosted.org/packages/d4/e9/759043ab7d169b74fe05ebfbfa9ee5c881c303ebc838e308346204309cd0/frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a", size = 238595 }, - { url = "https://files.pythonhosted.org/packages/f8/ce/b9de7dc61e753dc318cf0de862181b484178210c5361eae6eaf06792264d/frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad", size = 262428 }, - { url = "https://files.pythonhosted.org/packages/36/ce/dc6f29e0352fa34ebe45421960c8e7352ca63b31630a576e8ffb381e9c08/frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c", size = 258867 }, - { url = "https://files.pythonhosted.org/packages/51/47/159ac53faf8a11ae5ee8bb9db10327575557504e549cfd76f447b969aa91/frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe", size = 229412 }, - { url = "https://files.pythonhosted.org/packages/ec/25/0c87df2e53c0c5d90f7517ca0ff7aca78d050a8ec4d32c4278e8c0e52e51/frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a", size = 239539 }, - { url = "https://files.pythonhosted.org/packages/97/94/a1305fa4716726ae0abf3b1069c2d922fcfd442538cb850f1be543f58766/frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98", size = 253379 }, - { url = "https://files.pythonhosted.org/packages/53/82/274e19f122e124aee6d113188615f63b0736b4242a875f482a81f91e07e2/frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75", size = 245901 }, - { url = "https://files.pythonhosted.org/packages/b8/28/899931015b8cffbe155392fe9ca663f981a17e1adc69589ee0e1e7cdc9a2/frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5", size = 263797 }, - { url = "https://files.pythonhosted.org/packages/6e/4f/b8a5a2f10c4a58c52a52a40cf6cf1ffcdbf3a3b64f276f41dab989bf3ab5/frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950", size = 264415 }, - { url = "https://files.pythonhosted.org/packages/b0/2c/7be3bdc59dbae444864dbd9cde82790314390ec54636baf6b9ce212627ad/frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc", size = 253964 }, - { url = "https://files.pythonhosted.org/packages/2e/ec/4fb5a88f6b9a352aed45ab824dd7ce4801b7bcd379adcb927c17a8f0a1a8/frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1", size = 44559 }, - { url = "https://files.pythonhosted.org/packages/61/15/2b5d644d81282f00b61e54f7b00a96f9c40224107282efe4cd9d2bf1433a/frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439", size = 50434 }, - { url = "https://files.pythonhosted.org/packages/01/bc/8d33f2d84b9368da83e69e42720cff01c5e199b5a868ba4486189a4d8fa9/frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0", size = 97060 }, - { url = "https://files.pythonhosted.org/packages/af/b2/904500d6a162b98a70e510e743e7ea992241b4f9add2c8063bf666ca21df/frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49", size = 55347 }, - { url = "https://files.pythonhosted.org/packages/5b/9c/f12b69997d3891ddc0d7895999a00b0c6a67f66f79498c0e30f27876435d/frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced", size = 53374 }, - { url = "https://files.pythonhosted.org/packages/ac/6e/e0322317b7c600ba21dec224498c0c5959b2bce3865277a7c0badae340a9/frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0", size = 273288 }, - { url = "https://files.pythonhosted.org/packages/a7/76/180ee1b021568dad5b35b7678616c24519af130ed3fa1e0f1ed4014e0f93/frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106", size = 284737 }, - { url = "https://files.pythonhosted.org/packages/05/08/40159d706a6ed983c8aca51922a93fc69f3c27909e82c537dd4054032674/frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068", size = 280267 }, - { url = "https://files.pythonhosted.org/packages/e0/18/9f09f84934c2b2aa37d539a322267939770362d5495f37783440ca9c1b74/frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2", size = 258778 }, - { url = "https://files.pythonhosted.org/packages/b3/c9/0bc5ee7e1f5cc7358ab67da0b7dfe60fbd05c254cea5c6108e7d1ae28c63/frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19", size = 272276 }, - { url = "https://files.pythonhosted.org/packages/12/5d/147556b73a53ad4df6da8bbb50715a66ac75c491fdedac3eca8b0b915345/frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82", size = 272424 }, - { url = "https://files.pythonhosted.org/packages/83/61/2087bbf24070b66090c0af922685f1d0596c24bb3f3b5223625bdeaf03ca/frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec", size = 260881 }, - { url = "https://files.pythonhosted.org/packages/a8/be/a235bc937dd803258a370fe21b5aa2dd3e7bfe0287a186a4bec30c6cccd6/frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a", size = 282327 }, - { url = "https://files.pythonhosted.org/packages/5d/e7/b2469e71f082948066b9382c7b908c22552cc705b960363c390d2e23f587/frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74", size = 281502 }, - { url = "https://files.pythonhosted.org/packages/db/1b/6a5b970e55dffc1a7d0bb54f57b184b2a2a2ad0b7bca16a97ca26d73c5b5/frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2", size = 272292 }, - { url = "https://files.pythonhosted.org/packages/1a/05/ebad68130e6b6eb9b287dacad08ea357c33849c74550c015b355b75cc714/frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17", size = 44446 }, - { url = "https://files.pythonhosted.org/packages/b3/21/c5aaffac47fd305d69df46cfbf118768cdf049a92ee6b0b5cb029d449dcf/frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825", size = 50459 }, - { url = "https://files.pythonhosted.org/packages/b4/db/4cf37556a735bcdb2582f2c3fa286aefde2322f92d3141e087b8aeb27177/frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae", size = 93937 }, - { url = "https://files.pythonhosted.org/packages/46/03/69eb64642ca8c05f30aa5931d6c55e50b43d0cd13256fdd01510a1f85221/frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb", size = 53656 }, - { url = "https://files.pythonhosted.org/packages/3f/ab/c543c13824a615955f57e082c8a5ee122d2d5368e80084f2834e6f4feced/frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b", size = 51868 }, - { url = "https://files.pythonhosted.org/packages/a9/b8/438cfd92be2a124da8259b13409224d9b19ef8f5a5b2507174fc7e7ea18f/frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86", size = 280652 }, - { url = "https://files.pythonhosted.org/packages/54/72/716a955521b97a25d48315c6c3653f981041ce7a17ff79f701298195bca3/frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480", size = 286739 }, - { url = "https://files.pythonhosted.org/packages/65/d8/934c08103637567084568e4d5b4219c1016c60b4d29353b1a5b3587827d6/frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09", size = 289447 }, - { url = "https://files.pythonhosted.org/packages/70/bb/d3b98d83ec6ef88f9bd63d77104a305d68a146fd63a683569ea44c3085f6/frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a", size = 265466 }, - { url = "https://files.pythonhosted.org/packages/0b/f2/b8158a0f06faefec33f4dff6345a575c18095a44e52d4f10c678c137d0e0/frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd", size = 281530 }, - { url = "https://files.pythonhosted.org/packages/ea/a2/20882c251e61be653764038ece62029bfb34bd5b842724fff32a5b7a2894/frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6", size = 281295 }, - { url = "https://files.pythonhosted.org/packages/4c/f9/8894c05dc927af2a09663bdf31914d4fb5501653f240a5bbaf1e88cab1d3/frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1", size = 268054 }, - { url = "https://files.pythonhosted.org/packages/37/ff/a613e58452b60166507d731812f3be253eb1229808e59980f0405d1eafbf/frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b", size = 286904 }, - { url = "https://files.pythonhosted.org/packages/cc/6e/0091d785187f4c2020d5245796d04213f2261ad097e0c1cf35c44317d517/frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e", size = 290754 }, - { url = "https://files.pythonhosted.org/packages/a5/c2/e42ad54bae8bcffee22d1e12a8ee6c7717f7d5b5019261a8c861854f4776/frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8", size = 282602 }, - { url = "https://files.pythonhosted.org/packages/b6/61/56bad8cb94f0357c4bc134acc30822e90e203b5cb8ff82179947de90c17f/frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89", size = 44063 }, - { url = "https://files.pythonhosted.org/packages/3e/dc/96647994a013bc72f3d453abab18340b7f5e222b7b7291e3697ca1fcfbd5/frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5", size = 50452 }, - { url = "https://files.pythonhosted.org/packages/d3/fb/6f2a22086065bc16797f77168728f0e59d5b89be76dd184e06b404f1e43b/frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e", size = 97291 }, - { url = "https://files.pythonhosted.org/packages/4d/23/7f01123d0e5adcc65cbbde5731378237dea7db467abd19e391f1ddd4130d/frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d", size = 55249 }, - { url = "https://files.pythonhosted.org/packages/8b/c9/a81e9af48291954a883d35686f32308238dc968043143133b8ac9e2772af/frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8", size = 53676 }, - { url = "https://files.pythonhosted.org/packages/57/15/172af60c7e150a1d88ecc832f2590721166ae41eab582172fe1e9844eab4/frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0", size = 239365 }, - { url = "https://files.pythonhosted.org/packages/8c/a4/3dc43e259960ad268ef8f2bf92912c2d2cd2e5275a4838804e03fd6f085f/frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b", size = 265592 }, - { url = "https://files.pythonhosted.org/packages/a0/c1/458cf031fc8cd29a751e305b1ec773785ce486106451c93986562c62a21e/frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0", size = 261274 }, - { url = "https://files.pythonhosted.org/packages/4a/32/21329084b61a119ecce0b2942d30312a34a7a0dccd01dcf7b40bda80f22c/frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897", size = 230787 }, - { url = "https://files.pythonhosted.org/packages/70/b0/6f1ebdabfb604e39a0f84428986b89ab55f246b64cddaa495f2c953e1f6b/frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7", size = 240674 }, - { url = "https://files.pythonhosted.org/packages/a3/05/50c53f1cdbfdf3d2cb9582a4ea5e12cd939ce33bd84403e6d07744563486/frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742", size = 255712 }, - { url = "https://files.pythonhosted.org/packages/b8/3d/cbc6f057f7d10efb7f1f410e458ac090f30526fd110ed2b29bb56ec38fe1/frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea", size = 247618 }, - { url = "https://files.pythonhosted.org/packages/96/86/d5e9cd583aed98c9ee35a3aac2ce4d022ce9de93518e963aadf34a18143b/frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5", size = 266868 }, - { url = "https://files.pythonhosted.org/packages/0f/6e/542af762beb9113f13614a590cafe661e0e060cddddee6107c8833605776/frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9", size = 266439 }, - { url = "https://files.pythonhosted.org/packages/ea/db/8b611e23fda75da5311b698730a598df54cfe6236678001f449b1dedb241/frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6", size = 256677 }, - { url = "https://files.pythonhosted.org/packages/eb/06/732cefc0c46c638e4426a859a372a50e4c9d62e65dbfa7ddcf0b13e6a4f2/frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932", size = 44825 }, - { url = "https://files.pythonhosted.org/packages/29/eb/2110c4be2f622e87864e433efd7c4ee6e4f8a59ff2a93c1aa426ee50a8b8/frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0", size = 50652 }, - { url = "https://files.pythonhosted.org/packages/83/10/466fe96dae1bff622021ee687f68e5524d6392b0a2f80d05001cd3a451ba/frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7", size = 11552 }, +sdist = { url = "https://files.pythonhosted.org/packages/8f/ed/0f4cec13a93c02c47ec32d81d11c0c1efbadf4a471e3f3ce7cad366cbbd3/frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817", size = 39930 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/79/29d44c4af36b2b240725dce566b20f63f9b36ef267aaaa64ee7466f4f2f8/frozenlist-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a", size = 94451 }, + { url = "https://files.pythonhosted.org/packages/47/47/0c999aeace6ead8a44441b4f4173e2261b18219e4ad1fe9a479871ca02fc/frozenlist-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb", size = 54301 }, + { url = "https://files.pythonhosted.org/packages/8d/60/107a38c1e54176d12e06e9d4b5d755b677d71d1219217cee063911b1384f/frozenlist-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15538c0cbf0e4fa11d1e3a71f823524b0c46299aed6e10ebb4c2089abd8c3bec", size = 52213 }, + { url = "https://files.pythonhosted.org/packages/17/62/594a6829ac5679c25755362a9dc93486a8a45241394564309641425d3ff6/frozenlist-1.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e79225373c317ff1e35f210dd5f1344ff31066ba8067c307ab60254cd3a78ad5", size = 240946 }, + { url = "https://files.pythonhosted.org/packages/7e/75/6c8419d8f92c80dd0ee3f63bdde2702ce6398b0ac8410ff459f9b6f2f9cb/frozenlist-1.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9272fa73ca71266702c4c3e2d4a28553ea03418e591e377a03b8e3659d94fa76", size = 264608 }, + { url = "https://files.pythonhosted.org/packages/88/3e/82a6f0b84bc6fb7e0be240e52863c6d4ab6098cd62e4f5b972cd31e002e8/frozenlist-1.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:498524025a5b8ba81695761d78c8dd7382ac0b052f34e66939c42df860b8ff17", size = 261361 }, + { url = "https://files.pythonhosted.org/packages/fd/85/14e5f9ccac1b64ff2f10c927b3ffdf88772aea875882406f9ba0cec8ad84/frozenlist-1.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92b5278ed9d50fe610185ecd23c55d8b307d75ca18e94c0e7de328089ac5dcba", size = 231649 }, + { url = "https://files.pythonhosted.org/packages/ee/59/928322800306f6529d1852323014ee9008551e9bb027cc38d276cbc0b0e7/frozenlist-1.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3c8c1dacd037df16e85227bac13cca58c30da836c6f936ba1df0c05d046d8d", size = 241853 }, + { url = "https://files.pythonhosted.org/packages/7d/bd/e01fa4f146a6f6c18c5d34cab8abdc4013774a26c4ff851128cd1bd3008e/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2ac49a9bedb996086057b75bf93538240538c6d9b38e57c82d51f75a73409d2", size = 243652 }, + { url = "https://files.pythonhosted.org/packages/a5/bd/e4771fd18a8ec6757033f0fa903e447aecc3fbba54e3630397b61596acf0/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e66cc454f97053b79c2ab09c17fbe3c825ea6b4de20baf1be28919460dd7877f", size = 241734 }, + { url = "https://files.pythonhosted.org/packages/21/13/c83821fa5544af4f60c5d3a65d054af3213c26b14d3f5f48e43e5fb48556/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3ba5f9a0dfed20337d3e966dc359784c9f96503674c2faf015f7fe8e96798c", size = 260959 }, + { url = "https://files.pythonhosted.org/packages/71/f3/1f91c9a9bf7ed0e8edcf52698d23f3c211d8d00291a53c9f115ceb977ab1/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6321899477db90bdeb9299ac3627a6a53c7399c8cd58d25da094007402b039ab", size = 262706 }, + { url = "https://files.pythonhosted.org/packages/4c/22/4a256fdf5d9bcb3ae32622c796ee5ff9451b3a13a68cfe3f68e2c95588ce/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76e4753701248476e6286f2ef492af900ea67d9706a0155335a40ea21bf3b2f5", size = 250401 }, + { url = "https://files.pythonhosted.org/packages/af/89/c48ebe1f7991bd2be6d5f4ed202d94960c01b3017a03d6954dd5fa9ea1e8/frozenlist-1.5.0-cp310-cp310-win32.whl", hash = "sha256:977701c081c0241d0955c9586ffdd9ce44f7a7795df39b9151cd9a6fd0ce4cfb", size = 45498 }, + { url = "https://files.pythonhosted.org/packages/28/2f/cc27d5f43e023d21fe5c19538e08894db3d7e081cbf582ad5ed366c24446/frozenlist-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:189f03b53e64144f90990d29a27ec4f7997d91ed3d01b51fa39d2dbe77540fd4", size = 51622 }, + { url = "https://files.pythonhosted.org/packages/79/43/0bed28bf5eb1c9e4301003b74453b8e7aa85fb293b31dde352aac528dafc/frozenlist-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30", size = 94987 }, + { url = "https://files.pythonhosted.org/packages/bb/bf/b74e38f09a246e8abbe1e90eb65787ed745ccab6eaa58b9c9308e052323d/frozenlist-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5", size = 54584 }, + { url = "https://files.pythonhosted.org/packages/2c/31/ab01375682f14f7613a1ade30149f684c84f9b8823a4391ed950c8285656/frozenlist-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778", size = 52499 }, + { url = "https://files.pythonhosted.org/packages/98/a8/d0ac0b9276e1404f58fec3ab6e90a4f76b778a49373ccaf6a563f100dfbc/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a", size = 276357 }, + { url = "https://files.pythonhosted.org/packages/ad/c9/c7761084fa822f07dac38ac29f841d4587570dd211e2262544aa0b791d21/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869", size = 287516 }, + { url = "https://files.pythonhosted.org/packages/a1/ff/cd7479e703c39df7bdab431798cef89dc75010d8aa0ca2514c5b9321db27/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d", size = 283131 }, + { url = "https://files.pythonhosted.org/packages/59/a0/370941beb47d237eca4fbf27e4e91389fd68699e6f4b0ebcc95da463835b/frozenlist-1.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45", size = 261320 }, + { url = "https://files.pythonhosted.org/packages/b8/5f/c10123e8d64867bc9b4f2f510a32042a306ff5fcd7e2e09e5ae5100ee333/frozenlist-1.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d", size = 274877 }, + { url = "https://files.pythonhosted.org/packages/fa/79/38c505601ae29d4348f21706c5d89755ceded02a745016ba2f58bd5f1ea6/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3", size = 269592 }, + { url = "https://files.pythonhosted.org/packages/19/e2/39f3a53191b8204ba9f0bb574b926b73dd2efba2a2b9d2d730517e8f7622/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a", size = 265934 }, + { url = "https://files.pythonhosted.org/packages/d5/c9/3075eb7f7f3a91f1a6b00284af4de0a65a9ae47084930916f5528144c9dd/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9", size = 283859 }, + { url = "https://files.pythonhosted.org/packages/05/f5/549f44d314c29408b962fa2b0e69a1a67c59379fb143b92a0a065ffd1f0f/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2", size = 287560 }, + { url = "https://files.pythonhosted.org/packages/9d/f8/cb09b3c24a3eac02c4c07a9558e11e9e244fb02bf62c85ac2106d1eb0c0b/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf", size = 277150 }, + { url = "https://files.pythonhosted.org/packages/37/48/38c2db3f54d1501e692d6fe058f45b6ad1b358d82cd19436efab80cfc965/frozenlist-1.5.0-cp311-cp311-win32.whl", hash = "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942", size = 45244 }, + { url = "https://files.pythonhosted.org/packages/ca/8c/2ddffeb8b60a4bce3b196c32fcc30d8830d4615e7b492ec2071da801b8ad/frozenlist-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d", size = 51634 }, + { url = "https://files.pythonhosted.org/packages/79/73/fa6d1a96ab7fd6e6d1c3500700963eab46813847f01ef0ccbaa726181dd5/frozenlist-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21", size = 94026 }, + { url = "https://files.pythonhosted.org/packages/ab/04/ea8bf62c8868b8eada363f20ff1b647cf2e93377a7b284d36062d21d81d1/frozenlist-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d", size = 54150 }, + { url = "https://files.pythonhosted.org/packages/d0/9a/8e479b482a6f2070b26bda572c5e6889bb3ba48977e81beea35b5ae13ece/frozenlist-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e", size = 51927 }, + { url = "https://files.pythonhosted.org/packages/e3/12/2aad87deb08a4e7ccfb33600871bbe8f0e08cb6d8224371387f3303654d7/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a", size = 282647 }, + { url = "https://files.pythonhosted.org/packages/77/f2/07f06b05d8a427ea0060a9cef6e63405ea9e0d761846b95ef3fb3be57111/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a", size = 289052 }, + { url = "https://files.pythonhosted.org/packages/bd/9f/8bf45a2f1cd4aa401acd271b077989c9267ae8463e7c8b1eb0d3f561b65e/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee", size = 291719 }, + { url = "https://files.pythonhosted.org/packages/41/d1/1f20fd05a6c42d3868709b7604c9f15538a29e4f734c694c6bcfc3d3b935/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6", size = 267433 }, + { url = "https://files.pythonhosted.org/packages/af/f2/64b73a9bb86f5a89fb55450e97cd5c1f84a862d4ff90d9fd1a73ab0f64a5/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e", size = 283591 }, + { url = "https://files.pythonhosted.org/packages/29/e2/ffbb1fae55a791fd6c2938dd9ea779509c977435ba3940b9f2e8dc9d5316/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9", size = 273249 }, + { url = "https://files.pythonhosted.org/packages/2e/6e/008136a30798bb63618a114b9321b5971172a5abddff44a100c7edc5ad4f/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039", size = 271075 }, + { url = "https://files.pythonhosted.org/packages/ae/f0/4e71e54a026b06724cec9b6c54f0b13a4e9e298cc8db0f82ec70e151f5ce/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784", size = 285398 }, + { url = "https://files.pythonhosted.org/packages/4d/36/70ec246851478b1c0b59f11ef8ade9c482ff447c1363c2bd5fad45098b12/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631", size = 294445 }, + { url = "https://files.pythonhosted.org/packages/37/e0/47f87544055b3349b633a03c4d94b405956cf2437f4ab46d0928b74b7526/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f", size = 280569 }, + { url = "https://files.pythonhosted.org/packages/f9/7c/490133c160fb6b84ed374c266f42800e33b50c3bbab1652764e6e1fc498a/frozenlist-1.5.0-cp312-cp312-win32.whl", hash = "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8", size = 44721 }, + { url = "https://files.pythonhosted.org/packages/b1/56/4e45136ffc6bdbfa68c29ca56ef53783ef4c2fd395f7cbf99a2624aa9aaa/frozenlist-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f", size = 51329 }, + { url = "https://files.pythonhosted.org/packages/da/3b/915f0bca8a7ea04483622e84a9bd90033bab54bdf485479556c74fd5eaf5/frozenlist-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953", size = 91538 }, + { url = "https://files.pythonhosted.org/packages/c7/d1/a7c98aad7e44afe5306a2b068434a5830f1470675f0e715abb86eb15f15b/frozenlist-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0", size = 52849 }, + { url = "https://files.pythonhosted.org/packages/3a/c8/76f23bf9ab15d5f760eb48701909645f686f9c64fbb8982674c241fbef14/frozenlist-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2", size = 50583 }, + { url = "https://files.pythonhosted.org/packages/1f/22/462a3dd093d11df623179d7754a3b3269de3b42de2808cddef50ee0f4f48/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f", size = 265636 }, + { url = "https://files.pythonhosted.org/packages/80/cf/e075e407fc2ae7328155a1cd7e22f932773c8073c1fc78016607d19cc3e5/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608", size = 270214 }, + { url = "https://files.pythonhosted.org/packages/a1/58/0642d061d5de779f39c50cbb00df49682832923f3d2ebfb0fedf02d05f7f/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b", size = 273905 }, + { url = "https://files.pythonhosted.org/packages/ab/66/3fe0f5f8f2add5b4ab7aa4e199f767fd3b55da26e3ca4ce2cc36698e50c4/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840", size = 250542 }, + { url = "https://files.pythonhosted.org/packages/f6/b8/260791bde9198c87a465224e0e2bb62c4e716f5d198fc3a1dacc4895dbd1/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439", size = 267026 }, + { url = "https://files.pythonhosted.org/packages/2e/a4/3d24f88c527f08f8d44ade24eaee83b2627793fa62fa07cbb7ff7a2f7d42/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de", size = 257690 }, + { url = "https://files.pythonhosted.org/packages/de/9a/d311d660420b2beeff3459b6626f2ab4fb236d07afbdac034a4371fe696e/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641", size = 253893 }, + { url = "https://files.pythonhosted.org/packages/c6/23/e491aadc25b56eabd0f18c53bb19f3cdc6de30b2129ee0bc39cd387cd560/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e", size = 267006 }, + { url = "https://files.pythonhosted.org/packages/08/c4/ab918ce636a35fb974d13d666dcbe03969592aeca6c3ab3835acff01f79c/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9", size = 276157 }, + { url = "https://files.pythonhosted.org/packages/c0/29/3b7a0bbbbe5a34833ba26f686aabfe982924adbdcafdc294a7a129c31688/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03", size = 264642 }, + { url = "https://files.pythonhosted.org/packages/ab/42/0595b3dbffc2e82d7fe658c12d5a5bafcd7516c6bf2d1d1feb5387caa9c1/frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c", size = 44914 }, + { url = "https://files.pythonhosted.org/packages/17/c4/b7db1206a3fea44bf3b838ca61deb6f74424a8a5db1dd53ecb21da669be6/frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28", size = 51167 }, + { url = "https://files.pythonhosted.org/packages/da/4d/d94ff0fb0f5313902c132817c62d19cdc5bdcd0c195d392006ef4b779fc6/frozenlist-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9bbcdfaf4af7ce002694a4e10a0159d5a8d20056a12b05b45cea944a4953f972", size = 95319 }, + { url = "https://files.pythonhosted.org/packages/8c/1b/d90e554ca2b483d31cb2296e393f72c25bdc38d64526579e95576bfda587/frozenlist-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1893f948bf6681733aaccf36c5232c231e3b5166d607c5fa77773611df6dc336", size = 54749 }, + { url = "https://files.pythonhosted.org/packages/f8/66/7fdecc9ef49f8db2aa4d9da916e4ecf357d867d87aea292efc11e1b2e932/frozenlist-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2b5e23253bb709ef57a8e95e6ae48daa9ac5f265637529e4ce6b003a37b2621f", size = 52718 }, + { url = "https://files.pythonhosted.org/packages/08/04/e2fddc92135276e07addbc1cf413acffa0c2d848b3e54cacf684e146df49/frozenlist-1.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f253985bb515ecd89629db13cb58d702035ecd8cfbca7d7a7e29a0e6d39af5f", size = 241756 }, + { url = "https://files.pythonhosted.org/packages/c6/52/be5ff200815d8a341aee5b16b6b707355e0ca3652953852238eb92b120c2/frozenlist-1.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04a5c6babd5e8fb7d3c871dc8b321166b80e41b637c31a995ed844a6139942b6", size = 267718 }, + { url = "https://files.pythonhosted.org/packages/88/be/4bd93a58be57a3722fc544c36debdf9dcc6758f761092e894d78f18b8f20/frozenlist-1.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9fe0f1c29ba24ba6ff6abf688cb0b7cf1efab6b6aa6adc55441773c252f7411", size = 263494 }, + { url = "https://files.pythonhosted.org/packages/32/ba/58348b90193caa096ce9e9befea6ae67f38dabfd3aacb47e46137a6250a8/frozenlist-1.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:226d72559fa19babe2ccd920273e767c96a49b9d3d38badd7c91a0fdeda8ea08", size = 232838 }, + { url = "https://files.pythonhosted.org/packages/f6/33/9f152105227630246135188901373c4f322cc026565ca6215b063f4c82f4/frozenlist-1.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15b731db116ab3aedec558573c1a5eec78822b32292fe4f2f0345b7f697745c2", size = 242912 }, + { url = "https://files.pythonhosted.org/packages/a0/10/3db38fb3ccbafadd80a1b0d6800c987b0e3fe3ef2d117c6ced0246eea17a/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:366d8f93e3edfe5a918c874702f78faac300209a4d5bf38352b2c1bdc07a766d", size = 244763 }, + { url = "https://files.pythonhosted.org/packages/e2/cd/1df468fdce2f66a4608dffe44c40cdc35eeaa67ef7fd1d813f99a9a37842/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1b96af8c582b94d381a1c1f51ffaedeb77c821c690ea5f01da3d70a487dd0a9b", size = 242841 }, + { url = "https://files.pythonhosted.org/packages/ee/5f/16097a5ca0bb6b6779c02cc9379c72fe98d56115d4c54d059fb233168fb6/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c03eff4a41bd4e38415cbed054bbaff4a075b093e2394b6915dca34a40d1e38b", size = 263407 }, + { url = "https://files.pythonhosted.org/packages/0f/f7/58cd220ee1c2248ee65a32f5b4b93689e3fe1764d85537eee9fc392543bc/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:50cf5e7ee9b98f22bdecbabf3800ae78ddcc26e4a435515fc72d97903e8488e0", size = 265083 }, + { url = "https://files.pythonhosted.org/packages/62/b8/49768980caabf81ac4a2d156008f7cbd0107e6b36d08a313bb31035d9201/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e76bfbc72353269c44e0bc2cfe171900fbf7f722ad74c9a7b638052afe6a00c", size = 251564 }, + { url = "https://files.pythonhosted.org/packages/cb/83/619327da3b86ef957ee7a0cbf3c166a09ed1e87a3f7f1ff487d7d0284683/frozenlist-1.5.0-cp39-cp39-win32.whl", hash = "sha256:666534d15ba8f0fda3f53969117383d5dc021266b3c1a42c9ec4855e4b58b9d3", size = 45691 }, + { url = "https://files.pythonhosted.org/packages/8b/28/407bc34a745151ed2322c690b6e7d83d7101472e81ed76e1ebdac0b70a78/frozenlist-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:5c28f4b5dbef8a0d8aad0d4de24d1e9e981728628afaf4ea0792f5d0939372f0", size = 51767 }, + { url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901 }, ] [[package]] @@ -735,70 +765,70 @@ wheels = [ [[package]] name = "markupsafe" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/84/3f683b24fcffa08c5b7ef3fb8a845661057dd39c321c1ae16fa37a3eb35b/markupsafe-3.0.0.tar.gz", hash = "sha256:03ff62dea2fef3eadf2f1853bc6332bcb0458d9608b11dfb1cd5aeda1c178ea6", size = 20102 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/a6/f705e503cdcd944f8bb50cf615f2d436f671a60f1d5cb1c5a1a9c7d57028/MarkupSafe-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:380faf314c3c84c1682ca672e6280c6c59e92d0bc13dc71758ffa2de3cd4e252", size = 14337 }, - { url = "https://files.pythonhosted.org/packages/7c/cf/c78c4c5f33492290cddd2469389c86e6e2a7b5ef64dd014b021bf64a5e08/MarkupSafe-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ee9790be6f62121c4c58bbced387b0965ab7bffeecb4e17cc42ef290784e363", size = 12362 }, - { url = "https://files.pythonhosted.org/packages/2a/0f/351109b1403c1061732e2bb76900e15e9387177ba4b8f5d60783c16c8225/MarkupSafe-3.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ddf5cb8e9c00d9bf8b0c75949fb3ff9ea2096ba531693e2e87336d197fdb908", size = 21736 }, - { url = "https://files.pythonhosted.org/packages/10/9f/7984e6dc0f62ff8f18fb129954f393869571cfca95bf0e53030cf4bf6936/MarkupSafe-3.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b36473a2d3e882d1873ea906ce54408b9588dc2c65989664e6e7f5a2de353d7", size = 20905 }, - { url = "https://files.pythonhosted.org/packages/30/3f/be451779aa18f4c5c5e290433fa35aec8474e88099017ece53b304391971/MarkupSafe-3.0.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dba0f83119b9514bc37272ad012f0cc03f0805cc6a2bea7244e19250ac8ff29f", size = 21036 }, - { url = "https://files.pythonhosted.org/packages/b6/42/70e0c73827995ad731812cc018048d9e65bb5fc54c21ee8d693609c4b7fc/MarkupSafe-3.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:409535e0521c4630d5b5a1bf284e9d3c76d2fc2f153ebb12cf3827797798cc99", size = 21636 }, - { url = "https://files.pythonhosted.org/packages/49/b4/667b4f33303b5c085a0cb3dc3764b0240b9a4f79321de1d9fc04301f30a0/MarkupSafe-3.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64a7c7856c3a409011139b17d137c2924df4318dab91ee0530800819617c4381", size = 21298 }, - { url = "https://files.pythonhosted.org/packages/f3/8f/8e3249fdd5bdd9344ace890f0fc7277882d75659449beb28635029cb5684/MarkupSafe-3.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4deea1d9169578917d1f35cdb581bc7bab56a7e8c5be2633bd1b9549c3c22a01", size = 21049 }, - { url = "https://files.pythonhosted.org/packages/c0/c5/dfb13194dcfdcd3e08e4fd29719bfb472d711cf66d86330542daa9e2565f/MarkupSafe-3.0.0-cp310-cp310-win32.whl", hash = "sha256:3cd0bba31d484fe9b9d77698ddb67c978704603dc10cdc905512af308cfcca6b", size = 15025 }, - { url = "https://files.pythonhosted.org/packages/07/8d/d0f52b26efb87733551f78a3a009eaa5fdb529a5af3712947fda1c93b82e/MarkupSafe-3.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:4ca04c60006867610a06575b46941ae616b19da0adc85b9f8f3d9cbd7a3da385", size = 15485 }, - { url = "https://files.pythonhosted.org/packages/d2/af/5d89e9d6fbba5024a047aa004942578fee3396d9991119d4b9f73f027daf/MarkupSafe-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e64b390a306f9e849ee809f92af6a52cda41741c914358e0e9f8499d03741526", size = 14341 }, - { url = "https://files.pythonhosted.org/packages/60/0f/e33b03aeaecd8d90ba869e7c93b9f1aeeb0ab2820e338745200c9a2c8acb/MarkupSafe-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c524203207f5b569df06c96dafdc337228921ee8c3cc5f6e891d024c6595352", size = 12364 }, - { url = "https://files.pythonhosted.org/packages/81/ec/8804186f64b9c15844fa0e5079264e22325ac93573eef9eb4ab41e3929fc/MarkupSafe-3.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409691696bec2b5e5c9efd9593c99025bf2f317380bf0d993ee0213516d908a", size = 23956 }, - { url = "https://files.pythonhosted.org/packages/dd/4f/ddab3f0ab045ae34cf40e8ac1d8bf2933c50cda9c626441353c25d048556/MarkupSafe-3.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64f7d04410be600aa5ec0626d73d43e68a51c86500ce12917e10fd013e258df5", size = 23251 }, - { url = "https://files.pythonhosted.org/packages/59/a2/c68e6167a057d78e19b8e30338c33e3d917c8cd5d6ba574991202291b6b0/MarkupSafe-3.0.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:105ada43a61af22acb8774514c51900dc820c481cc5ba53f17c09d294d9c07ca", size = 23157 }, - { url = "https://files.pythonhosted.org/packages/24/fc/cea6e038c6f911aeeda66a41b96b8885153026867422e1f37f9b018b427f/MarkupSafe-3.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5fd5500d4e4f7cc88d8c0f2e45126c4307ed31e08f8ec521474f2fd99d35ac3", size = 23635 }, - { url = "https://files.pythonhosted.org/packages/36/c7/2fca924654032c27055706ad6647cf5535be8cf641d2148fc693b0e04407/MarkupSafe-3.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:25396abd52b16900932e05b7104bcdc640a4d96c914f39c3b984e5a17b01fba0", size = 23422 }, - { url = "https://files.pythonhosted.org/packages/e7/56/825d2218c93dbf5f0c8b3cb5e86a02a9b1bb95aaa850765026a7fed7aaa1/MarkupSafe-3.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3efde9a8c56c3b6e5f3fa4baea828f8184970c7c78480fedb620d804b1c31e5c", size = 23339 }, - { url = "https://files.pythonhosted.org/packages/0c/70/973f228b3017d9fffb11567a2a02f092be41cae8ca1a9c97ec571801ab50/MarkupSafe-3.0.0-cp311-cp311-win32.whl", hash = "sha256:12ddac720b8965332d36196f6f83477c6351ba6a25d4aff91e30708c729350d7", size = 15056 }, - { url = "https://files.pythonhosted.org/packages/96/4a/6ea3f7265e17226bc9b1896d16ed5b230fe06cf4530a40a4f47e7d311a62/MarkupSafe-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:658fdf6022740896c403d45148bf0c36978c6b48c9ef8b1f8d0c7a11b6cdea86", size = 15493 }, - { url = "https://files.pythonhosted.org/packages/2a/d2/4cda4f2c9a21b426c5f5b80a70991dc26b78bcecd7b03a8e8a22cc1cddc1/MarkupSafe-3.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d261ec38b8a99a39b62e0119ed47fe3b62f7691c500bc1e815265adc016438c1", size = 14274 }, - { url = "https://files.pythonhosted.org/packages/6c/46/92fd7ef12daa1b1e5fe4e38cc251e01c51ea288ecda950a30b2e8d66a051/MarkupSafe-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e363440c8534bf2f2ef1b8fdc02037eb5fff8fce2a558519b22d6a3a38b3ec5e", size = 12332 }, - { url = "https://files.pythonhosted.org/packages/61/47/f972faff9134053fc083e591b7415ce7a2f4c51fb1dba17757822d0ebb5d/MarkupSafe-3.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7835de4c56066e096407a1852e5561f6033786dd987fa90dc384e45b9bd21295", size = 24049 }, - { url = "https://files.pythonhosted.org/packages/c0/c9/5c84edd744fe981c1c37e8303799e4d90bc2b146997b60dc158c20791b24/MarkupSafe-3.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6cc46a27d904c9be5732029769acf4b0af69345172ed1ef6d4db0c023ff603b", size = 23199 }, - { url = "https://files.pythonhosted.org/packages/70/6f/70ca971e19d0cd905f58cd53358b0dfe30fa393bd9d5a1f372667f7b97b0/MarkupSafe-3.0.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0411641d31aa6f7f0cc13f0f18b63b8dc08da5f3a7505972a42ab059f479ba3", size = 23099 }, - { url = "https://files.pythonhosted.org/packages/7f/47/c15288e10d0f3c9ac0d997891f581d910a593a74c1e9789046b9cb4e4c53/MarkupSafe-3.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b2a7afd24d408b907672015555bc10be2382e6c5f62a488e2d452da670bbd389", size = 23812 }, - { url = "https://files.pythonhosted.org/packages/dd/f6/518225e5cd027828cb26bbe0b99c9b110512960e60718c66df9823ba5e8f/MarkupSafe-3.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c8ab7efeff1884c5da8e18f743b667215300e09043820d11723718de0b7db934", size = 23392 }, - { url = "https://files.pythonhosted.org/packages/55/a5/94b07a3fe33d52c93476b0970ab9ab011790c04d10d5c110ed3de01863f5/MarkupSafe-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8219e2207f6c188d15614ea043636c2b36d2d79bf853639c124a179412325a13", size = 23559 }, - { url = "https://files.pythonhosted.org/packages/b9/77/1e21ea23aeeaa0760d0ab03976b38f6551ad803cffccdec2db9dcb85ac7c/MarkupSafe-3.0.0-cp312-cp312-win32.whl", hash = "sha256:59420b5a9a5d3fee483a32adb56d7369ae0d630798da056001be1e9f674f3aa6", size = 15064 }, - { url = "https://files.pythonhosted.org/packages/55/e2/4e0c49629d1d8f0642ecc772577cdf870048401280d421321bbb55d8b251/MarkupSafe-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:7ed789d0f7f11fcf118cf0acb378743dfdd4215d7f7d18837c88171405c9a452", size = 15564 }, - { url = "https://files.pythonhosted.org/packages/14/dd/7149242a730e218b6dd7ffa6817c951f51f4204e7afb8e8bbf688d8ae4c3/MarkupSafe-3.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:27d6a73682b99568916c54a4bfced40e7d871ba685b580ea04bbd2e405dfd4c5", size = 14276 }, - { url = "https://files.pythonhosted.org/packages/8a/c5/b6cda6248f83c59148540b6d815b4c59b1222e059fe759eb3c446748b744/MarkupSafe-3.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:494a64efc535e147fcc713dba58eecfce3a79f1e93ebe81995b387f5cd9bc2e1", size = 12325 }, - { url = "https://files.pythonhosted.org/packages/9c/84/9f82de5f77f61c64fec414f4ae7e1e7871b82da0d52414f8810410de752a/MarkupSafe-3.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5243044a927e8a6bb28517838662a019cd7f73d7f106bbb37ab5e7fa8451a92", size = 24010 }, - { url = "https://files.pythonhosted.org/packages/45/14/80f6553deba7a6beeae455f2c1e450f55f0f17241f06ed065571445e2bf0/MarkupSafe-3.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63dae84964a9a3d2610808cee038f435d9a111620c37ccf872c2fcaeca6865b3", size = 23163 }, - { url = "https://files.pythonhosted.org/packages/34/03/e64f36452db4eabf3b89cfbbebf46736afa82eda0c95f3f4bf11c4cf3c85/MarkupSafe-3.0.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcbee57fedc9b2182c54ffc1c5eed316c3da8bbfeda8009e1b5d7220199d15da", size = 23044 }, - { url = "https://files.pythonhosted.org/packages/eb/89/9c47f58e3e75adbaa9387f3db84ca6a7d3a3abd93e7541cfaadad073e5d6/MarkupSafe-3.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f846fd7c241e5bd4161e2a483663eb66e4d8e12130fcdc052f310f388f1d61c6", size = 23849 }, - { url = "https://files.pythonhosted.org/packages/87/ae/fd72c59177ae148aee41eed67f5dcb73e96590f439fd0149c88deab207c0/MarkupSafe-3.0.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:678fbceb202382aae42c1f0cd9f56b776bc20a58ae5b553ee1fe6b802983a1d6", size = 23414 }, - { url = "https://files.pythonhosted.org/packages/7a/8f/2e9a4653c78744b8a65cab56382148073c96893efc4c75eef2fa0a96f608/MarkupSafe-3.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bd9b8e458e2bab52f9ad3ab5dc8b689a3c84b12b2a2f64cd9a0dfe209fb6b42f", size = 23518 }, - { url = "https://files.pythonhosted.org/packages/81/ac/1ab4e1f47f1778bd2c407b7be543b3c08bff555c8444c742e3c53958d114/MarkupSafe-3.0.0-cp313-cp313-win32.whl", hash = "sha256:1fd02f47596e00a372f5b4af2b4c45f528bade65c66dfcbc6e1ea1bfda758e98", size = 15068 }, - { url = "https://files.pythonhosted.org/packages/53/c4/b3d9f84a093244602e6081e35cf1166cd2f6e3d65746da12d4c13511e2cb/MarkupSafe-3.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:b94bec9eda10111ec7102ef909eca4f3c2df979643924bfe58375f560713a7d1", size = 15566 }, - { url = "https://files.pythonhosted.org/packages/47/2d/6ea2c34833582fb04447e2a91ae8f49540a57757add92cb5095e49d12c61/MarkupSafe-3.0.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:509c424069dd037d078925b6815fc56b7271f3aaec471e55e6fa513b0a80d2aa", size = 14513 }, - { url = "https://files.pythonhosted.org/packages/bf/bf/0ee8f270b82fab05b763cfbacc2c33a62f571f59968abc37d4793b3c1623/MarkupSafe-3.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81be2c0084d8c69e97e3c5d73ce9e2a6e523556f2a19c4e195c09d499be2f808", size = 12460 }, - { url = "https://files.pythonhosted.org/packages/e4/63/90a907e327e640462ccc671fd55c140e609d09312fa6db62822b2066bf5b/MarkupSafe-3.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b43ac1eb9f91e0c14aac1d2ef0f76bc7b9ceea51de47536f61268191adf52ad7", size = 25312 }, - { url = "https://files.pythonhosted.org/packages/7a/04/84e439fd573000d85c2394e690dfbf2f322bf09b010689bcac4bafee8834/MarkupSafe-3.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b231255770723f1e125d63c14269bcd8b8136ecfb620b9a18c0297e046d0736", size = 23746 }, - { url = "https://files.pythonhosted.org/packages/5f/7d/2bb2663db79eb702d168ab6728741f64e431cd78f55b22c868e95d9805ef/MarkupSafe-3.0.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c182d45600556917f811aa019d834a89fe4b6f6255da2fd0bdcf80e970f95918", size = 23696 }, - { url = "https://files.pythonhosted.org/packages/5c/66/3227765a7215b205847d71af5def5693027df2538bdd33775eef1ee8151f/MarkupSafe-3.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f91c90f8f3bf436f81c12eeb4d79f9ddd263c71125e6ad71341906832a34386", size = 25026 }, - { url = "https://files.pythonhosted.org/packages/f5/77/f3787b456331c94458aef7629c197a70b1c5279e0d04ad0646a13484a20c/MarkupSafe-3.0.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a7171d2b869e9be238ea318c196baf58fbf272704e9c1cd4be8c380eea963342", size = 23988 }, - { url = "https://files.pythonhosted.org/packages/d8/27/bffd73c503bfe6f00fa3de64703e00768f65f74a37b6fb2342ef771cacfd/MarkupSafe-3.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cb244adf2499aa37d5dc43431990c7f0b632d841af66a51d22bd89c437b60264", size = 23967 }, - { url = "https://files.pythonhosted.org/packages/31/b5/d4a9ecb9785d0d5cad3fac326488dc99eb85270dea989d460cbebd603626/MarkupSafe-3.0.0-cp313-cp313t-win32.whl", hash = "sha256:96e3ed550600185d34429477f1176cedea8293fa40e47fe37a05751bcb64c997", size = 15166 }, - { url = "https://files.pythonhosted.org/packages/8f/86/4b87d92b35f9818d52bfda94abec26ef1b50441982c57d20566ec6b46ada/MarkupSafe-3.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:1d151b9cf3307e259b749125a5a08c030ba15a8f1d567ca5bfb0e92f35e761f5", size = 15694 }, - { url = "https://files.pythonhosted.org/packages/99/51/ef4f8d801aff0e01bd80260dfa85cb64800866927aff6f834c3d6f7ebe7c/MarkupSafe-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:23efb2be7221105c8eb0e905433414d2439cb0a8c5d5ca081c1c72acef0f5613", size = 14328 }, - { url = "https://files.pythonhosted.org/packages/9d/86/afe05136029d09541a7ef6daab922f01739f67e1f086634a1149109a5a78/MarkupSafe-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:81ee9c967956b9ea39b3a5270b7cb1740928d205b0dc72629164ce621b4debf9", size = 12356 }, - { url = "https://files.pythonhosted.org/packages/ff/08/0a5cad23cad2dcd13aa68ad7d8c56b4b10f4c86484e24008aced445ab3e7/MarkupSafe-3.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5509a8373fed30b978557890a226c3d30569746c565b9daba69df80c160365a5", size = 21604 }, - { url = "https://files.pythonhosted.org/packages/6e/ac/a02e6dadef6f778ec98569721e8e71152f9ad1ac7438c99cb70684e0f453/MarkupSafe-3.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1c13c6c908811f867a8e9e66efb2d6c03d1cdd83e92788fe97f693c457dc44f", size = 20769 }, - { url = "https://files.pythonhosted.org/packages/63/63/377ecc7aea0fae9b5aed793cc65b586a4ab4b52bc0f0198622f722f6e4aa/MarkupSafe-3.0.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7e63d1977d3806ce0a1a3e0099b089f61abdede5238ca6a3f3bf8877b46d095", size = 20905 }, - { url = "https://files.pythonhosted.org/packages/8e/13/7819a2261f0ca26474121512def4d8a354869f3f1d28c38fef4226a9936d/MarkupSafe-3.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d2c099be5274847d606574234e494f23a359e829ba337ea9037c3a72b0851942", size = 21498 }, - { url = "https://files.pythonhosted.org/packages/a7/f2/eea3125b43826fe88c9b1cb7d8fa007a283d7c4b79577a3712db6e61e3b1/MarkupSafe-3.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e042ccf8fe5bf8b6a4b38b3f7d618eb10ea20402b0c9f4add9293408de447974", size = 21171 }, - { url = "https://files.pythonhosted.org/packages/20/2d/474d27577ba12d5bb465133096424d037f7f272466f4e81e6c37c9cfe07a/MarkupSafe-3.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:98fb3a2bf525ad66db96745707b93ba0f78928b7a1cb2f1cb4b143bc7e2ba3b3", size = 20905 }, - { url = "https://files.pythonhosted.org/packages/73/8c/7087be0d8e090ee424d59307da837f6401bf6465b03bf6dd0e36bfc40b9a/MarkupSafe-3.0.0-cp39-cp39-win32.whl", hash = "sha256:a80c6740e1bfbe50cea7cbf74f48823bb57bd59d914ee22ff8a81963b08e62d2", size = 15024 }, - { url = "https://files.pythonhosted.org/packages/a1/0d/39a8acf44dd8cfe60c93f589b1c553a4d5865f05e6b752481604147b72e5/MarkupSafe-3.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:5d207ff5cceef77796f8aacd44263266248cf1fbc601441524d7835613f8abec", size = 15477 }, +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 }, + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, + { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344 }, + { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389 }, + { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607 }, + { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728 }, + { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826 }, + { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843 }, + { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219 }, + { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946 }, + { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063 }, + { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 }, ] [[package]] @@ -911,36 +941,41 @@ wheels = [ [[package]] name = "mypy" -version = "1.11.2" +version = "1.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/86/5d7cbc4974fd564550b80fbb8103c05501ea11aa7835edf3351d90095896/mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79", size = 3078806 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/cd/815368cd83c3a31873e5e55b317551500b12f2d1d7549720632f32630333/mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a", size = 10939401 }, - { url = "https://files.pythonhosted.org/packages/f1/27/e18c93a195d2fad75eb96e1f1cbc431842c332e8eba2e2b77eaf7313c6b7/mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef", size = 10111697 }, - { url = "https://files.pythonhosted.org/packages/dc/08/cdc1fc6d0d5a67d354741344cc4aa7d53f7128902ebcbe699ddd4f15a61c/mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383", size = 12500508 }, - { url = "https://files.pythonhosted.org/packages/64/12/aad3af008c92c2d5d0720ea3b6674ba94a98cdb86888d389acdb5f218c30/mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8", size = 13020712 }, - { url = "https://files.pythonhosted.org/packages/03/e6/a7d97cc124a565be5e9b7d5c2a6ebf082379ffba99646e4863ed5bbcb3c3/mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7", size = 9567319 }, - { url = "https://files.pythonhosted.org/packages/e2/aa/cc56fb53ebe14c64f1fe91d32d838d6f4db948b9494e200d2f61b820b85d/mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385", size = 10859630 }, - { url = "https://files.pythonhosted.org/packages/04/c8/b19a760fab491c22c51975cf74e3d253b8c8ce2be7afaa2490fbf95a8c59/mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca", size = 10037973 }, - { url = "https://files.pythonhosted.org/packages/88/57/7e7e39f2619c8f74a22efb9a4c4eff32b09d3798335625a124436d121d89/mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104", size = 12416659 }, - { url = "https://files.pythonhosted.org/packages/fc/a6/37f7544666b63a27e46c48f49caeee388bf3ce95f9c570eb5cfba5234405/mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4", size = 12897010 }, - { url = "https://files.pythonhosted.org/packages/84/8b/459a513badc4d34acb31c736a0101c22d2bd0697b969796ad93294165cfb/mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6", size = 9562873 }, - { url = "https://files.pythonhosted.org/packages/35/3a/ed7b12ecc3f6db2f664ccf85cb2e004d3e90bec928e9d7be6aa2f16b7cdf/mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318", size = 10990335 }, - { url = "https://files.pythonhosted.org/packages/04/e4/1a9051e2ef10296d206519f1df13d2cc896aea39e8683302f89bf5792a59/mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36", size = 10007119 }, - { url = "https://files.pythonhosted.org/packages/f3/3c/350a9da895f8a7e87ade0028b962be0252d152e0c2fbaafa6f0658b4d0d4/mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987", size = 12506856 }, - { url = "https://files.pythonhosted.org/packages/b6/49/ee5adf6a49ff13f4202d949544d3d08abb0ea1f3e7f2a6d5b4c10ba0360a/mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca", size = 12952066 }, - { url = "https://files.pythonhosted.org/packages/27/c0/b19d709a42b24004d720db37446a42abadf844d5c46a2c442e2a074d70d9/mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70", size = 9664000 }, - { url = "https://files.pythonhosted.org/packages/16/64/bb5ed751487e2bea0dfaa6f640a7e3bb88083648f522e766d5ef4a76f578/mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6", size = 10937294 }, - { url = "https://files.pythonhosted.org/packages/a9/a3/67a0069abed93c3bf3b0bebb8857e2979a02828a4a3fd82f107f8f1143e8/mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70", size = 10107707 }, - { url = "https://files.pythonhosted.org/packages/2f/4d/0379daf4258b454b1f9ed589a9dabd072c17f97496daea7b72fdacf7c248/mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d", size = 12498367 }, - { url = "https://files.pythonhosted.org/packages/3b/dc/3976a988c280b3571b8eb6928882dc4b723a403b21735a6d8ae6ed20e82b/mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d", size = 13018014 }, - { url = "https://files.pythonhosted.org/packages/83/84/adffc7138fb970e7e2a167bd20b33bb78958370179853a4ebe9008139342/mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24", size = 9568056 }, - { url = "https://files.pythonhosted.org/packages/42/3a/bdf730640ac523229dd6578e8a581795720a9321399de494374afc437ec5/mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12", size = 2619625 }, +sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/8c/206de95a27722b5b5a8c85ba3100467bd86299d92a4f71c6b9aa448bfa2f/mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a", size = 11020731 }, + { url = "https://files.pythonhosted.org/packages/ab/bb/b31695a29eea76b1569fd28b4ab141a1adc9842edde080d1e8e1776862c7/mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80", size = 10184276 }, + { url = "https://files.pythonhosted.org/packages/a5/2d/4a23849729bb27934a0e079c9c1aad912167d875c7b070382a408d459651/mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7", size = 12587706 }, + { url = "https://files.pythonhosted.org/packages/5c/c3/d318e38ada50255e22e23353a469c791379825240e71b0ad03e76ca07ae6/mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f", size = 13105586 }, + { url = "https://files.pythonhosted.org/packages/4a/25/3918bc64952370c3dbdbd8c82c363804678127815febd2925b7273d9482c/mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372", size = 9632318 }, + { url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027 }, + { url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699 }, + { url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263 }, + { url = "https://files.pythonhosted.org/packages/3f/1d/676e76f07f7d5ddcd4227af3938a9c9640f293b7d8a44dd4ff41d4db25c1/mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", size = 12984688 }, + { url = "https://files.pythonhosted.org/packages/9c/03/5a85a30ae5407b1d28fab51bd3e2103e52ad0918d1e68f02a7778669a307/mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", size = 9626811 }, + { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, + { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, + { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, + { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, + { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, + { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, + { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, + { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, + { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, + { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, + { url = "https://files.pythonhosted.org/packages/5f/d4/b33ddd40dad230efb317898a2d1c267c04edba73bc5086bf77edeb410fb2/mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc", size = 11013906 }, + { url = "https://files.pythonhosted.org/packages/f4/e6/f414bca465b44d01cd5f4a82761e15044bedd1bf8025c5af3cc64518fac5/mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732", size = 10180657 }, + { url = "https://files.pythonhosted.org/packages/38/e9/fc3865e417722f98d58409770be01afb961e2c1f99930659ff4ae7ca8b7e/mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc", size = 12586394 }, + { url = "https://files.pythonhosted.org/packages/2e/35/f4d8b6d2cb0b3dad63e96caf159419dda023f45a358c6c9ac582ccaee354/mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d", size = 13103591 }, + { url = "https://files.pythonhosted.org/packages/22/1d/80594aef135f921dd52e142fa0acd19df197690bd0cde42cea7b88cf5aa2/mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24", size = 9634690 }, + { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, ] [[package]] @@ -980,56 +1015,57 @@ wheels = [ [[package]] name = "orjson" -version = "3.10.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/03/821c8197d0515e46ea19439f5c5d5fd9a9889f76800613cfac947b5d7845/orjson-3.10.7.tar.gz", hash = "sha256:75ef0640403f945f3a1f9f6400686560dbfb0fb5b16589ad62cd477043c4eee3", size = 5056450 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/12/60931cf808b9334f26210ab496442f4a7a3d66e29d1cf12e0a01857e756f/orjson-3.10.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:74f4544f5a6405b90da8ea724d15ac9c36da4d72a738c64685003337401f5c12", size = 251312 }, - { url = "https://files.pythonhosted.org/packages/fe/0e/efbd0a2d25f8e82b230eb20b6b8424be6dd95b6811b669be9af16234b6db/orjson-3.10.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34a566f22c28222b08875b18b0dfbf8a947e69df21a9ed5c51a6bf91cfb944ac", size = 148124 }, - { url = "https://files.pythonhosted.org/packages/dd/47/1ddff6e23fe5f4aeaaed996a3cde422b3eaac4558c03751723e106184c68/orjson-3.10.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf6ba8ebc8ef5792e2337fb0419f8009729335bb400ece005606336b7fd7bab7", size = 147277 }, - { url = "https://files.pythonhosted.org/packages/04/da/d03d72b54bdd60d05de372114abfbd9f05050946895140c6ff5f27ab8f49/orjson-3.10.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac7cf6222b29fbda9e3a472b41e6a5538b48f2c8f99261eecd60aafbdb60690c", size = 152955 }, - { url = "https://files.pythonhosted.org/packages/7f/7e/ef8522dbba112af6cc52227dcc746dd3447c7d53ea8cea35740239b547ee/orjson-3.10.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de817e2f5fc75a9e7dd350c4b0f54617b280e26d1631811a43e7e968fa71e3e9", size = 163955 }, - { url = "https://files.pythonhosted.org/packages/b6/bc/fbd345d771a73cacc5b0e774d034cd081590b336754c511f4ead9fdc4cf1/orjson-3.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:348bdd16b32556cf8d7257b17cf2bdb7ab7976af4af41ebe79f9796c218f7e91", size = 141896 }, - { url = "https://files.pythonhosted.org/packages/82/0a/1f09c12d15b1e83156b7f3f621561d38650fe5b8f39f38f04a64de1a87fc/orjson-3.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:479fd0844ddc3ca77e0fd99644c7fe2de8e8be1efcd57705b5c92e5186e8a250", size = 170166 }, - { url = "https://files.pythonhosted.org/packages/a6/d8/eee30caba21a8d6a9df06d2519bb0ecd0adbcd57f2e79d360de5570031cf/orjson-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fdf5197a21dd660cf19dfd2a3ce79574588f8f5e2dbf21bda9ee2d2b46924d84", size = 167804 }, - { url = "https://files.pythonhosted.org/packages/44/fe/d1d89d3f15e343511417195f6ccd2bdeb7ebc5a48a882a79ab3bbcdf5fc7/orjson-3.10.7-cp310-none-win32.whl", hash = "sha256:d374d36726746c81a49f3ff8daa2898dccab6596864ebe43d50733275c629175", size = 143010 }, - { url = "https://files.pythonhosted.org/packages/88/8c/0e7b8d5a523927774758ac4ce2de4d8ca5dda569955ba3aeb5e208344eda/orjson-3.10.7-cp310-none-win_amd64.whl", hash = "sha256:cb61938aec8b0ffb6eef484d480188a1777e67b05d58e41b435c74b9d84e0b9c", size = 137306 }, - { url = "https://files.pythonhosted.org/packages/89/c9/dd286c97c2f478d43839bd859ca4d9820e2177d4e07a64c516dc3e018062/orjson-3.10.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7db8539039698ddfb9a524b4dd19508256107568cdad24f3682d5773e60504a2", size = 251312 }, - { url = "https://files.pythonhosted.org/packages/b9/72/d90bd11e83a0e9623b3803b079478a93de8ec4316c98fa66110d594de5fa/orjson-3.10.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:480f455222cb7a1dea35c57a67578848537d2602b46c464472c995297117fa09", size = 148125 }, - { url = "https://files.pythonhosted.org/packages/9d/b6/ed61e87f327a4cbb2075ed0716e32ba68cb029aa654a68c3eb27803050d8/orjson-3.10.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a9c9b168b3a19e37fe2778c0003359f07822c90fdff8f98d9d2a91b3144d8e0", size = 147278 }, - { url = "https://files.pythonhosted.org/packages/66/9f/e6a11b5d1ad11e9dc869d938707ef93ff5ed20b53d6cda8b5e2ac532a9d2/orjson-3.10.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8de062de550f63185e4c1c54151bdddfc5625e37daf0aa1e75d2a1293e3b7d9a", size = 152954 }, - { url = "https://files.pythonhosted.org/packages/92/ee/702d5e8ccd42dc2b9d1043f22daa1ba75165616aa021dc19fb0c5a726ce8/orjson-3.10.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b0dd04483499d1de9c8f6203f8975caf17a6000b9c0c54630cef02e44ee624e", size = 163953 }, - { url = "https://files.pythonhosted.org/packages/d3/cb/55205f3f1ee6ba80c0a9a18ca07423003ca8de99192b18be30f1f31b4cdd/orjson-3.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b58d3795dafa334fc8fd46f7c5dc013e6ad06fd5b9a4cc98cb1456e7d3558bd6", size = 141895 }, - { url = "https://files.pythonhosted.org/packages/bb/ab/1185e472f15c00d37d09c395e478803ed0eae7a3a3d055a5f3885e1ea136/orjson-3.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33cfb96c24034a878d83d1a9415799a73dc77480e6c40417e5dda0710d559ee6", size = 170169 }, - { url = "https://files.pythonhosted.org/packages/53/b9/10abe9089bdb08cd4218cc45eb7abfd787c82cf301cecbfe7f141542d7f4/orjson-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e724cebe1fadc2b23c6f7415bad5ee6239e00a69f30ee423f319c6af70e2a5c0", size = 167808 }, - { url = "https://files.pythonhosted.org/packages/8a/ad/26b40ccef119dcb0f4a39745ffd7d2d319152c1a52859b1ebbd114eca19c/orjson-3.10.7-cp311-none-win32.whl", hash = "sha256:82763b46053727a7168d29c772ed5c870fdae2f61aa8a25994c7984a19b1021f", size = 143010 }, - { url = "https://files.pythonhosted.org/packages/e7/63/5f4101e4895b78ada568f4cf8f870dd594139ca2e75e654e373da78b03b0/orjson-3.10.7-cp311-none-win_amd64.whl", hash = "sha256:eb8d384a24778abf29afb8e41d68fdd9a156cf6e5390c04cc07bbc24b89e98b5", size = 137307 }, - { url = "https://files.pythonhosted.org/packages/14/7c/b4ecc2069210489696a36e42862ccccef7e49e1454a3422030ef52881b01/orjson-3.10.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44a96f2d4c3af51bfac6bc4ef7b182aa33f2f054fd7f34cc0ee9a320d051d41f", size = 251409 }, - { url = "https://files.pythonhosted.org/packages/60/84/e495edb919ef0c98d054a9b6d05f2700fdeba3886edd58f1c4dfb25d514a/orjson-3.10.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ac14cd57df0572453543f8f2575e2d01ae9e790c21f57627803f5e79b0d3c3", size = 147913 }, - { url = "https://files.pythonhosted.org/packages/c5/27/e40bc7d79c4afb7e9264f22320c285d06d2c9574c9c682ba0f1be3012833/orjson-3.10.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdbb61dcc365dd9be94e8f7df91975edc9364d6a78c8f7adb69c1cdff318ec93", size = 147390 }, - { url = "https://files.pythonhosted.org/packages/30/be/fd646fb1a461de4958a6eacf4ecf064b8d5479c023e0e71cc89b28fa91ac/orjson-3.10.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b48b3db6bb6e0a08fa8c83b47bc169623f801e5cc4f24442ab2b6617da3b5313", size = 152973 }, - { url = "https://files.pythonhosted.org/packages/b1/00/414f8d4bc5ec3447e27b5c26b4e996e4ef08594d599e79b3648f64da060c/orjson-3.10.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23820a1563a1d386414fef15c249040042b8e5d07b40ab3fe3efbfbbcbcb8864", size = 164039 }, - { url = "https://files.pythonhosted.org/packages/a0/6b/34e6904ac99df811a06e42d8461d47b6e0c9b86e2fe7ee84934df6e35f0d/orjson-3.10.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0c6a008e91d10a2564edbb6ee5069a9e66df3fbe11c9a005cb411f441fd2c09", size = 142035 }, - { url = "https://files.pythonhosted.org/packages/17/7e/254189d9b6df89660f65aec878d5eeaa5b1ae371bd2c458f85940445d36f/orjson-3.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d352ee8ac1926d6193f602cbe36b1643bbd1bbcb25e3c1a657a4390f3000c9a5", size = 169941 }, - { url = "https://files.pythonhosted.org/packages/02/1a/d11805670c29d3a1b29fc4bd048dc90b094784779690592efe8c9f71249a/orjson-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2d9f990623f15c0ae7ac608103c33dfe1486d2ed974ac3f40b693bad1a22a7b", size = 167994 }, - { url = "https://files.pythonhosted.org/packages/20/5f/03d89b007f9d6733dc11bc35d64812101c85d6c4e9c53af9fa7e7689cb11/orjson-3.10.7-cp312-none-win32.whl", hash = "sha256:7c4c17f8157bd520cdb7195f75ddbd31671997cbe10aee559c2d613592e7d7eb", size = 143130 }, - { url = "https://files.pythonhosted.org/packages/c6/9d/9b9fb6c60b8a0e04031ba85414915e19ecea484ebb625402d968ea45b8d5/orjson-3.10.7-cp312-none-win_amd64.whl", hash = "sha256:1d9c0e733e02ada3ed6098a10a8ee0052dd55774de3d9110d29868d24b17faa1", size = 137326 }, - { url = "https://files.pythonhosted.org/packages/15/05/121af8a87513c56745d01ad7cf215c30d08356da9ad882ebe2ba890824cd/orjson-3.10.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:77d325ed866876c0fa6492598ec01fe30e803272a6e8b10e992288b009cbe149", size = 251331 }, - { url = "https://files.pythonhosted.org/packages/73/7f/8d6ccd64a6f8bdbfe6c9be7c58aeb8094aa52a01fbbb2cda42ff7e312bd7/orjson-3.10.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ea2c232deedcb605e853ae1db2cc94f7390ac776743b699b50b071b02bea6fe", size = 142012 }, - { url = "https://files.pythonhosted.org/packages/04/65/f2a03fd1d4f0308f01d372e004c049f7eb9bc5676763a15f20f383fa9c01/orjson-3.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3dcfbede6737fdbef3ce9c37af3fb6142e8e1ebc10336daa05872bfb1d87839c", size = 169920 }, - { url = "https://files.pythonhosted.org/packages/e2/1c/3ef8d83d7c6a619ad3d69a4d5318591b4ce5862e6eda7c26bbe8208652ca/orjson-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11748c135f281203f4ee695b7f80bb1358a82a63905f9f0b794769483ea854ad", size = 167916 }, - { url = "https://files.pythonhosted.org/packages/f2/0d/820a640e5a7dfbe525e789c70871ebb82aff73b0c7bf80082653f86b9431/orjson-3.10.7-cp313-none-win32.whl", hash = "sha256:a7e19150d215c7a13f39eb787d84db274298d3f83d85463e61d277bbd7f401d2", size = 143089 }, - { url = "https://files.pythonhosted.org/packages/1a/72/a424db9116c7cad2950a8f9e4aeb655a7b57de988eb015acd0fcd1b4609b/orjson-3.10.7-cp313-none-win_amd64.whl", hash = "sha256:eef44224729e9525d5261cc8d28d6b11cafc90e6bd0be2157bde69a52ec83024", size = 137081 }, - { url = "https://files.pythonhosted.org/packages/08/8c/23813894241f920e37ae363aa59a6a0fdb06e90afd60ad89e5a424113d1c/orjson-3.10.7-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e76be12658a6fa376fcd331b1ea4e58f5a06fd0220653450f0d415b8fd0fbe20", size = 251267 }, - { url = "https://files.pythonhosted.org/packages/b8/e5/f3cb8f766e7f5e5197e884d63fba320aa4f32a04a21b68864c71997cb17e/orjson-3.10.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed350d6978d28b92939bfeb1a0570c523f6170efc3f0a0ef1f1df287cd4f4960", size = 147924 }, - { url = "https://files.pythonhosted.org/packages/a3/4a/a041b6c95f623c28ccab87ce0720ac60cd0734f357774fd7212ff1fd9077/orjson-3.10.7-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:144888c76f8520e39bfa121b31fd637e18d4cc2f115727865fdf9fa325b10412", size = 147054 }, - { url = "https://files.pythonhosted.org/packages/ba/5b/89f2d5cda6c7bcad2067a87407aa492392942118969d548bc77ab4e9c818/orjson-3.10.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09b2d92fd95ad2402188cf51573acde57eb269eddabaa60f69ea0d733e789fe9", size = 152676 }, - { url = "https://files.pythonhosted.org/packages/04/02/bcb6ee82ecb5bc8f7487bce2204db9e9d8818f5fe7a3cad1625254f8d3a7/orjson-3.10.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b24a579123fa884f3a3caadaed7b75eb5715ee2b17ab5c66ac97d29b18fe57f", size = 163726 }, - { url = "https://files.pythonhosted.org/packages/6c/c1/97b5bb1869572483b0e060264180fe5417a836ed46c09166f0dc6bb1d42d/orjson-3.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591bcfe7512353bd609875ab38050efe3d55e18934e2f18950c108334b4ff", size = 141681 }, - { url = "https://files.pythonhosted.org/packages/c1/c6/5d5c556720f8a31c5618db7326f6de6c07ddfea72497c1baa69fca24e1ad/orjson-3.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f4db56635b58cd1a200b0a23744ff44206ee6aa428185e2b6c4a65b3197abdcd", size = 169961 }, - { url = "https://files.pythonhosted.org/packages/d7/15/2c1ca80d4e37780514cc369004fce77e2748b54857b62eb217e9a243a669/orjson-3.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0fa5886854673222618638c6df7718ea7fe2f3f2384c452c9ccedc70b4a510a5", size = 167613 }, - { url = "https://files.pythonhosted.org/packages/3b/39/4888bacdd3b82a923ea306369b87ba5bcdafa8951cecc041c1cfef3e7d7f/orjson-3.10.7-cp39-none-win32.whl", hash = "sha256:8272527d08450ab16eb405f47e0f4ef0e5ff5981c3d82afe0efd25dcbef2bcd2", size = 142863 }, - { url = "https://files.pythonhosted.org/packages/0c/c5/c5cbff9dbd45e4f8c4fef4c74ae4819d003b9e97201f3b1066a71368faf3/orjson-3.10.7-cp39-none-win_amd64.whl", hash = "sha256:974683d4618c0c7dbf4f69c95a979734bf183d0658611760017f6e70a145af58", size = 137119 }, +version = "3.10.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/44/d36e86b33fc84f224b5f2cdf525adf3b8f9f475753e721c402b1ddef731e/orjson-3.10.10.tar.gz", hash = "sha256:37949383c4df7b4337ce82ee35b6d7471e55195efa7dcb45ab8226ceadb0fe3b", size = 5404170 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/c7/07ca73c32d49550490572235e5000aa0d75e333997cbb3a221890ef0fa04/orjson-3.10.10-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b788a579b113acf1c57e0a68e558be71d5d09aa67f62ca1f68e01117e550a998", size = 270718 }, + { url = "https://files.pythonhosted.org/packages/4d/6e/eaefdfe4b11fd64b38f6663c71a3c9063054c8c643a52555c5b6d4350446/orjson-3.10.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:804b18e2b88022c8905bb79bd2cbe59c0cd014b9328f43da8d3b28441995cda4", size = 153292 }, + { url = "https://files.pythonhosted.org/packages/cf/87/94474cbf63306f196a0a85a2f3ea6cea261328b4141a260b7ec5e7145bc5/orjson-3.10.10-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9972572a1d042ec9ee421b6da69f7cc823da5962237563fa548ab17f152f0b9b", size = 168625 }, + { url = "https://files.pythonhosted.org/packages/0a/67/1a6bd763282bc89fcc0269e3a44a8ecbb236a1e4b6f5a6320301726b36a1/orjson-3.10.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc6993ab1c2ae7dd0711161e303f1db69062955ac2668181bfdf2dd410e65258", size = 155845 }, + { url = "https://files.pythonhosted.org/packages/ae/28/bb2dd7a988159896be9fa59ef4c991dca8cca9af85ebdc27164234929008/orjson-3.10.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d78e4cacced5781b01d9bc0f0cd8b70b906a0e109825cb41c1b03f9c41e4ce86", size = 166406 }, + { url = "https://files.pythonhosted.org/packages/e3/88/42199849c791b4b5b92fcace0e8ef178d5ae1ea9865dfd4d5809e650d652/orjson-3.10.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6eb2598df518281ba0cbc30d24c5b06124ccf7e19169e883c14e0831217a0bc", size = 144518 }, + { url = "https://files.pythonhosted.org/packages/c7/77/e684fe4ed34e73149bc0e7320b91a459386693279cd62efab6e82da072a3/orjson-3.10.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23776265c5215ec532de6238a52707048401a568f0fa0d938008e92a147fe2c7", size = 172184 }, + { url = "https://files.pythonhosted.org/packages/fa/b2/9dc2ed13121b27b9f99acba077c821ad2c0deff9feeb617efef4699fad35/orjson-3.10.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cc2a654c08755cef90b468ff17c102e2def0edd62898b2486767204a7f5cc9c", size = 170148 }, + { url = "https://files.pythonhosted.org/packages/86/0a/b06967f9374856f491f297a914c588eae97ef9490a77ec0e146a2e4bfe7f/orjson-3.10.10-cp310-none-win32.whl", hash = "sha256:081b3fc6a86d72efeb67c13d0ea7c030017bd95f9868b1e329a376edc456153b", size = 145116 }, + { url = "https://files.pythonhosted.org/packages/1f/c7/1aecf5e320828261ece0683e472ee77c520f4e6c52c468486862e2257962/orjson-3.10.10-cp310-none-win_amd64.whl", hash = "sha256:ff38c5fb749347768a603be1fb8a31856458af839f31f064c5aa74aca5be9efe", size = 139307 }, + { url = "https://files.pythonhosted.org/packages/79/bc/2a0eb0029729f1e466d5a595261446e5c5b6ed9213759ee56b6202f99417/orjson-3.10.10-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:879e99486c0fbb256266c7c6a67ff84f46035e4f8749ac6317cc83dacd7f993a", size = 270717 }, + { url = "https://files.pythonhosted.org/packages/3d/2b/5af226f183ce264bf64f15afe58647b09263dc1bde06aaadae6bbeca17f1/orjson-3.10.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:019481fa9ea5ff13b5d5d95e6fd5ab25ded0810c80b150c2c7b1cc8660b662a7", size = 153294 }, + { url = "https://files.pythonhosted.org/packages/1d/95/d6a68ab51ed76e3794669dabb51bf7fa6ec2f4745f66e4af4518aeab4b73/orjson-3.10.10-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0dd57eff09894938b4c86d4b871a479260f9e156fa7f12f8cad4b39ea8028bb5", size = 168628 }, + { url = "https://files.pythonhosted.org/packages/c0/c9/1bbe5262f5e9df3e1aeec44ca8cc86846c7afb2746fa76bf668a7d0979e9/orjson-3.10.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbde6d70cd95ab4d11ea8ac5e738e30764e510fc54d777336eec09bb93b8576c", size = 155845 }, + { url = "https://files.pythonhosted.org/packages/bf/22/e17b14ff74646e6c080dccb2859686a820bc6468f6b62ea3fe29a8bd3b05/orjson-3.10.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2625cb37b8fb42e2147404e5ff7ef08712099197a9cd38895006d7053e69d6", size = 166406 }, + { url = "https://files.pythonhosted.org/packages/8a/1e/b3abbe352f648f96a418acd1e602b1c77ffcc60cf801a57033da990b2c49/orjson-3.10.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbf3c20c6a7db69df58672a0d5815647ecf78c8e62a4d9bd284e8621c1fe5ccb", size = 144518 }, + { url = "https://files.pythonhosted.org/packages/0e/5e/28f521ee0950d279489db1522e7a2460d0596df7c5ca452e242ff1509cfe/orjson-3.10.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:75c38f5647e02d423807d252ce4528bf6a95bd776af999cb1fb48867ed01d1f6", size = 172187 }, + { url = "https://files.pythonhosted.org/packages/04/b4/538bf6f42eb0fd5a485abbe61e488d401a23fd6d6a758daefcf7811b6807/orjson-3.10.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23458d31fa50ec18e0ec4b0b4343730928296b11111df5f547c75913714116b2", size = 170152 }, + { url = "https://files.pythonhosted.org/packages/94/5c/a1a326a58452f9261972ad326ae3bb46d7945681239b7062a1b85d8811e2/orjson-3.10.10-cp311-none-win32.whl", hash = "sha256:2787cd9dedc591c989f3facd7e3e86508eafdc9536a26ec277699c0aa63c685b", size = 145116 }, + { url = "https://files.pythonhosted.org/packages/df/12/a02965df75f5a247091306d6cf40a77d20bf6c0490d0a5cb8719551ee815/orjson-3.10.10-cp311-none-win_amd64.whl", hash = "sha256:6514449d2c202a75183f807bc755167713297c69f1db57a89a1ef4a0170ee269", size = 139307 }, + { url = "https://files.pythonhosted.org/packages/21/c6/f1d2ec3ffe9d6a23a62af0477cd11dd2926762e0186a1fad8658a4f48117/orjson-3.10.10-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8564f48f3620861f5ef1e080ce7cd122ee89d7d6dacf25fcae675ff63b4d6e05", size = 270801 }, + { url = "https://files.pythonhosted.org/packages/52/01/eba0226efaa4d4be8e44d9685750428503a3803648878fa5607100a74f81/orjson-3.10.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5bf161a32b479034098c5b81f2608f09167ad2fa1c06abd4e527ea6bf4837a9", size = 153221 }, + { url = "https://files.pythonhosted.org/packages/da/4b/a705f9d3ae4786955ee0ac840b20960add357e612f1b0a54883d1811fe1a/orjson-3.10.10-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68b65c93617bcafa7f04b74ae8bc2cc214bd5cb45168a953256ff83015c6747d", size = 168590 }, + { url = "https://files.pythonhosted.org/packages/de/6c/eb405252e7d9ae9905a12bad582cfe37ef8ef18fdfee941549cb5834c7b2/orjson-3.10.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8e28406f97fc2ea0c6150f4c1b6e8261453318930b334abc419214c82314f85", size = 156052 }, + { url = "https://files.pythonhosted.org/packages/9f/e7/65a0461574078a38f204575153524876350f0865162faa6e6e300ecaa199/orjson-3.10.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4d0d9fe174cc7a5bdce2e6c378bcdb4c49b2bf522a8f996aa586020e1b96cee", size = 166562 }, + { url = "https://files.pythonhosted.org/packages/dd/99/85780be173e7014428859ba0211e6f2a8f8038ea6ebabe344b42d5daa277/orjson-3.10.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3be81c42f1242cbed03cbb3973501fcaa2675a0af638f8be494eaf37143d999", size = 144892 }, + { url = "https://files.pythonhosted.org/packages/ed/c0/c7c42a2daeb262da417f70064746b700786ee0811b9a5821d9d37543b29d/orjson-3.10.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65f9886d3bae65be026219c0a5f32dbbe91a9e6272f56d092ab22561ad0ea33b", size = 172093 }, + { url = "https://files.pythonhosted.org/packages/ad/9b/be8b3d3aec42aa47f6058482ace0d2ca3023477a46643d766e96281d5d31/orjson-3.10.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:730ed5350147db7beb23ddaf072f490329e90a1d059711d364b49fe352ec987b", size = 170424 }, + { url = "https://files.pythonhosted.org/packages/1b/15/a4cc61e23c39b9dec4620cb95817c83c84078be1771d602f6d03f0e5c696/orjson-3.10.10-cp312-none-win32.whl", hash = "sha256:a8f4bf5f1c85bea2170800020d53a8877812892697f9c2de73d576c9307a8a5f", size = 145132 }, + { url = "https://files.pythonhosted.org/packages/9f/8a/ce7c28e4ea337f6d95261345d7c61322f8561c52f57b263a3ad7025984f4/orjson-3.10.10-cp312-none-win_amd64.whl", hash = "sha256:384cd13579a1b4cd689d218e329f459eb9ddc504fa48c5a83ef4889db7fd7a4f", size = 139389 }, + { url = "https://files.pythonhosted.org/packages/0c/69/f1c4382cd44bdaf10006c4e82cb85d2bcae735369f84031e203c4e5d87de/orjson-3.10.10-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44bffae68c291f94ff5a9b4149fe9d1bdd4cd0ff0fb575bcea8351d48db629a1", size = 270695 }, + { url = "https://files.pythonhosted.org/packages/61/29/aeb5153271d4953872b06ed239eb54993a5f344353727c42d3aabb2046f6/orjson-3.10.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e27b4c6437315df3024f0835887127dac2a0a3ff643500ec27088d2588fa5ae1", size = 141632 }, + { url = "https://files.pythonhosted.org/packages/bc/a2/c8ac38d8fb461a9b717c766fbe1f7d3acf9bde2f12488eb13194960782e4/orjson-3.10.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca84df16d6b49325a4084fd8b2fe2229cb415e15c46c529f868c3387bb1339d", size = 144854 }, + { url = "https://files.pythonhosted.org/packages/79/51/e7698fdb28bdec633888cc667edc29fd5376fce9ade0a5b3e22f5ebe0343/orjson-3.10.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c14ce70e8f39bd71f9f80423801b5d10bf93d1dceffdecd04df0f64d2c69bc01", size = 172023 }, + { url = "https://files.pythonhosted.org/packages/02/2d/0d99c20878658c7e33b90e6a4bb75cf2924d6ff29c2365262cff3c26589a/orjson-3.10.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:24ac62336da9bda1bd93c0491eff0613003b48d3cb5d01470842e7b52a40d5b4", size = 170429 }, + { url = "https://files.pythonhosted.org/packages/cd/45/6a4a446f4fb29bb4703c3537d5c6a2bf7fed768cb4d7b7dce9d71b72fc93/orjson-3.10.10-cp313-none-win32.whl", hash = "sha256:eb0a42831372ec2b05acc9ee45af77bcaccbd91257345f93780a8e654efc75db", size = 145099 }, + { url = "https://files.pythonhosted.org/packages/72/6e/4631fe219a4203aa111e9bb763ad2e2e0cdd1a03805029e4da124d96863f/orjson-3.10.10-cp313-none-win_amd64.whl", hash = "sha256:f0c4f37f8bf3f1075c6cc8dd8a9f843689a4b618628f8812d0a71e6968b95ffd", size = 139176 }, + { url = "https://files.pythonhosted.org/packages/7b/3c/04294098b67d1cd93d56e23cee874fac4a8379943c5e556b7a922775e672/orjson-3.10.10-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5a059afddbaa6dd733b5a2d76a90dbc8af790b993b1b5cb97a1176ca713b5df8", size = 270518 }, + { url = "https://files.pythonhosted.org/packages/da/91/f021aa2eed9919f89ae2e4507e851fbbc8c5faef3fa79984549f415c7fa9/orjson-3.10.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f9b5c59f7e2a1a410f971c5ebc68f1995822837cd10905ee255f96074537ee6", size = 153116 }, + { url = "https://files.pythonhosted.org/packages/95/52/d4fc57145446c7d0cbf5cfdaceb0ea4d5f0636e7398de02e3abc3bf91341/orjson-3.10.10-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d5ef198bafdef4aa9d49a4165ba53ffdc0a9e1c7b6f76178572ab33118afea25", size = 168400 }, + { url = "https://files.pythonhosted.org/packages/cf/75/9b081915f083a10832f276d24babee910029ea42368486db9a81741b8dba/orjson-3.10.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf29ce0bb5d3320824ec3d1508652421000ba466abd63bdd52c64bcce9eb1fa", size = 155586 }, + { url = "https://files.pythonhosted.org/packages/90/c6/52ce917ea468ef564ec100e3f2164e548e61b4c71140c3e058a913bfea9b/orjson-3.10.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dddd5516bcc93e723d029c1633ae79c4417477b4f57dad9bfeeb6bc0315e654a", size = 166167 }, + { url = "https://files.pythonhosted.org/packages/dc/40/139fc90e69a8200e8d971c4dd0495ed2c7de6d8d9f70254d3324cb9be026/orjson-3.10.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12f2003695b10817f0fa8b8fca982ed7f5761dcb0d93cff4f2f9f6709903fd7", size = 144285 }, + { url = "https://files.pythonhosted.org/packages/54/d0/ff81ce26587459368a58ed772ce131938458c421b77fd0e74b1b11988f1e/orjson-3.10.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:672f9874a8a8fb9bb1b771331d31ba27f57702c8106cdbadad8bda5d10bc1019", size = 171917 }, + { url = "https://files.pythonhosted.org/packages/5e/5a/8c4b509288240f72f8a4a28bf0cc3f9df780c749a4ec57a588769bd0e8b9/orjson-3.10.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1dcbb0ca5fafb2b378b2c74419480ab2486326974826bbf6588f4dc62137570a", size = 169900 }, + { url = "https://files.pythonhosted.org/packages/15/7e/f593101ea030bb452a9c35e9098a3aabf18ce2c62165b2a098c6d7af802f/orjson-3.10.10-cp39-none-win32.whl", hash = "sha256:d9bbd3a4b92256875cb058c3381b782649b9a3c68a4aa9a2fff020c2f9cfc1be", size = 144977 }, + { url = "https://files.pythonhosted.org/packages/72/86/59b7ca088109e3403d493d4becb5430de3683fc2c6a5134e6d942e541dc8/orjson-3.10.10-cp39-none-win_amd64.whl", hash = "sha256:766f21487a53aee8524b97ca9582d5c6541b03ab6210fbaf10142ae2f3ced2aa", size = 139123 }, ] [[package]] @@ -1070,7 +1106,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.0.0" +version = "4.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -1079,9 +1115,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/e8/4aac596478e02f29b3e323db3dfb90a11c1291ef4e5cceca608a57df8975/pre_commit-4.0.0.tar.gz", hash = "sha256:5d9807162cc5537940f94f266cbe2d716a75cfad0d78a317a92cac16287cfed6", size = 191628 } +sdist = { url = "https://files.pythonhosted.org/packages/2e/c8/e22c292035f1bac8b9f5237a2622305bc0304e776080b246f3df57c4ff9f/pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", size = 191678 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/77/e808ffcf30b842b80a42e466edb7bad9644083d0452f01cce51a1f1921f6/pre_commit-4.0.0-py2.py3-none-any.whl", hash = "sha256:0ca2341cf94ac1865350970951e54b1a50521e57b7b500403307aed4315a1234", size = 218705 }, + { url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713 }, ] [[package]] @@ -1451,7 +1487,7 @@ wheels = [ [[package]] name = "python-kasa" -version = "0.7.5" +version = "0.7.6" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -1610,16 +1646,16 @@ wheels = [ [[package]] name = "rich" -version = "13.9.2" +version = "13.9.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/9e/1784d15b057b0075e5136445aaea92d23955aad2c93eaede673718a40d95/rich-13.9.2.tar.gz", hash = "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c", size = 222843 } +sdist = { url = "https://files.pythonhosted.org/packages/d9/e9/cf9ef5245d835065e6673781dbd4b8911d352fb770d56cf0879cf11b7ee1/rich-13.9.3.tar.gz", hash = "sha256:bc1e01b899537598cf02579d2b9f4a415104d3fc439313a7a2c165d76557a08e", size = 222889 } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/91/5474b84e505a6ccc295b2d322d90ff6aa0746745717839ee0c5fb4fdcceb/rich-13.9.2-py3-none-any.whl", hash = "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1", size = 242117 }, + { url = "https://files.pythonhosted.org/packages/9a/e2/10e9819cf4a20bd8ea2f5dabafc2e6bf4a78d6a0965daeb60a4b34d1c11f/rich-13.9.3-py3-none-any.whl", hash = "sha256:9836f5096eb2172c9e77df411c1b009bace4193d6a481d534fea75ebba758283", size = 242157 }, ] [[package]] @@ -1825,16 +1861,16 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.26.6" +version = "20.27.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/40/abc5a766da6b0b2457f819feab8e9203cbeae29327bd241359f866a3da9d/virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48", size = 9372482 } +sdist = { url = "https://files.pythonhosted.org/packages/8c/b3/7b6a79c5c8cf6d90ea681310e169cf2db2884f4d583d16c6e1d5a75a4e04/virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba", size = 6491145 } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/90/57b8ac0c8a231545adc7698c64c5a36fa7cd8e376c691b9bde877269f2eb/virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2", size = 5999862 }, + { url = "https://files.pythonhosted.org/packages/ae/92/78324ff89391e00c8f4cf6b8526c41c6ef36b4ea2d2c132250b1a6fc2b8d/virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4", size = 3117838 }, ] [[package]] @@ -1866,91 +1902,96 @@ wheels = [ [[package]] name = "yarl" -version = "1.14.0" +version = "1.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/fe/2ca2e5ef45952f3e8adb95659821a4e9169d8bbafab97eb662602ee12834/yarl-1.14.0.tar.gz", hash = "sha256:88c7d9d58aab0724b979ab5617330acb1c7030b79379c8138c1c8c94e121d1b3", size = 166127 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/27/dc4f4eabb51cf82f3ba8f8d977fba0e06006d66cee907ea12982c4c85904/yarl-1.14.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1bfc25aa6a7c99cf86564210f79a0b7d4484159c67e01232b116e445b3036547", size = 135762 }, - { url = "https://files.pythonhosted.org/packages/e7/32/e524d6c4b3acd05c88a5454cb3221b74bf7460b593deccf88f3b27361200/yarl-1.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0cf21f46a15d445417de8fc89f2568852cf57fe8ca1ab3d19ddb24d45c0383ae", size = 87946 }, - { url = "https://files.pythonhosted.org/packages/7f/ae/42c5fe1ae66eade3f17e442e5adce36b0d098867d5bd98e08527ff026d52/yarl-1.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1dda53508df0de87b6e6b0a52d6718ff6c62a5aca8f5552748404963df639269", size = 85854 }, - { url = "https://files.pythonhosted.org/packages/57/21/d653108b654daec3b9359a27f61959cf020839f97248bd345bf1ec7f1490/yarl-1.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:587c3cc59bc148a9b1c07a019346eda2549bc9f468acd2f9824d185749acf0a6", size = 306502 }, - { url = "https://files.pythonhosted.org/packages/8f/0b/996f04d9de5523735661a90ead48ea21d7557e1a71b1f757d1b2e3baae17/yarl-1.14.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3007a5b75cb50140708420fe688c393e71139324df599434633019314ceb8b59", size = 320849 }, - { url = "https://files.pythonhosted.org/packages/7b/10/b720945c7cd294283f8809dd0407e4cd56218949a4cca3ff04995cae6f0a/yarl-1.14.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06ff23462398333c78b6f4f8d3d70410d657a471c2c5bbe6086133be43fc8f1a", size = 318727 }, - { url = "https://files.pythonhosted.org/packages/d3/3a/0c65820d2d73649d99970e1c150e4be6c057a624cb545613ce75c3ebe2a6/yarl-1.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:689a99a42ee4583fcb0d3a67a0204664aa1539684aed72bdafcbd505197a91c4", size = 309599 }, - { url = "https://files.pythonhosted.org/packages/43/01/00f44df69b99e23790096aba5e16651694b8de087af12418578dc00730bd/yarl-1.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0547ab1e9345dc468cac8368d88ea4c5bd473ebc1d8d755347d7401982b5dd8", size = 299716 }, - { url = "https://files.pythonhosted.org/packages/41/1e/9c9e06f53d91f0b5ac6e69162e92d0fdd0851d4cc360f08716e29201802a/yarl-1.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:742aef0a99844faaac200564ea6f5e08facb285d37ea18bd1a5acf2771f3255a", size = 306355 }, - { url = "https://files.pythonhosted.org/packages/65/43/db5da311d287691cc02a4f66be8ac5859bce9627d51f8d553fc4f2beb601/yarl-1.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:176110bff341b6730f64a1eb3a7070e12b373cf1c910a9337e7c3240497db76f", size = 310309 }, - { url = "https://files.pythonhosted.org/packages/47/0c/271fdc45a5c2d13f9d138b039a264e35283a4ead36e7a538aefce4050d5e/yarl-1.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46a9772a1efa93f9cd170ad33101c1817c77e0e9914d4fe33e2da299d7cf0f9b", size = 325571 }, - { url = "https://files.pythonhosted.org/packages/64/7f/bde078ab75deba8387d260f387864b0f549fcdb8d5bee0d9b30406b1b7fe/yarl-1.14.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ee2c68e4f2dd1b1c15b849ba1c96fac105fca6ffdb7c1e8be51da6fabbdeafb9", size = 323477 }, - { url = "https://files.pythonhosted.org/packages/bb/f3/9fcf03b8826893275d2b46360986b2afba131e74eb6d722574b34b479144/yarl-1.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:047b258e00b99091b6f90355521f026238c63bd76dcf996d93527bb13320eefd", size = 316299 }, - { url = "https://files.pythonhosted.org/packages/22/77/b3d0410dfeb0bd779d6013afc1609ba17bff4d15479cab72cc16b11af4fa/yarl-1.14.0-cp310-cp310-win32.whl", hash = "sha256:0aa92e3e30a04f9462a25077db689c4ac5ea9ab6cc68a2e563881b987d42f16d", size = 77408 }, - { url = "https://files.pythonhosted.org/packages/92/69/29f5c9399d705254b2095bf117d7fb758f80057ad359b4e3224aa711b966/yarl-1.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:d9baec588f015d0ee564057aa7574313c53a530662ffad930b7886becc85abdf", size = 83511 }, - { url = "https://files.pythonhosted.org/packages/92/aa/64fcae3d4a081e4ee07902e9e9a3b597c2577283bf6c5b59c06ef0829d90/yarl-1.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:07f9eaf57719d6721ab15805d85f4b01a5b509a0868d7320134371bcb652152d", size = 135761 }, - { url = "https://files.pythonhosted.org/packages/93/a0/5537a1da2c0ec8e11006efa0d133cdaded5ebb94ca71e87e3564b59f6c7f/yarl-1.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c14b504a74e58e2deb0378b3eca10f3d076635c100f45b113c18c770b4a47a50", size = 87888 }, - { url = "https://files.pythonhosted.org/packages/e3/25/1d12bec8ebdc8287a3464f506ded23b30ad75a5fea3ba49526e8b473057f/yarl-1.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a682a127930f3fc4e42583becca6049e1d7214bcad23520c590edd741d2114", size = 85883 }, - { url = "https://files.pythonhosted.org/packages/75/85/01c2eb9a6ed755e073ef7d455151edf0ddd89618fca7d653894f7580b538/yarl-1.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73bedd2be05f48af19f0f2e9e1353921ce0c83f4a1c9e8556ecdcf1f1eae4892", size = 333347 }, - { url = "https://files.pythonhosted.org/packages/38/c7/6c3634ef216f01f928d7eec7b7de5bde56658292c8cbdcd29cc28d830f4d/yarl-1.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3ab950f8814f3b7b5e3eebc117986f817ec933676f68f0a6c5b2137dd7c9c69", size = 346644 }, - { url = "https://files.pythonhosted.org/packages/f4/ce/d1b1c441e41c652ce8081299db4f9b856f25a04b9c1885b3ba2e6edd3102/yarl-1.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b693c63e7e64b524f54aa4888403c680342d1ad0d97be1707c531584d6aeeb4f", size = 344078 }, - { url = "https://files.pythonhosted.org/packages/f0/ec/520686b83b51127792ca507d67ae1090c919c8cb8388c78d1e7c63c98a4a/yarl-1.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85cb3e40eaa98489f1e2e8b29f5ad02ee1ee40d6ce6b88d50cf0f205de1d9d2c", size = 336398 }, - { url = "https://files.pythonhosted.org/packages/30/4d/e842066d3336203299a3dc1730f2d062061e7b8a4497f4b6977d9076d263/yarl-1.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f24f08b6c9b9818fd80612c97857d28f9779f0d1211653ece9844fc7b414df2", size = 325519 }, - { url = "https://files.pythonhosted.org/packages/46/c7/83b9c0e5717ddd99b203dbb61c56450f475ab4a7d4d6b61b4af0a03c54d9/yarl-1.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:29a84a46ec3ebae7a1c024c055612b11e9363a8a23238b3e905552d77a2bc51b", size = 335487 }, - { url = "https://files.pythonhosted.org/packages/5e/58/2c5f0c840ab3bb364ebe5a6233bfe77ed9fcef6b34c19f3809dd15dae972/yarl-1.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5cd5dad8366e0168e0fd23d10705a603790484a6dbb9eb272b33673b8f2cce72", size = 334259 }, - { url = "https://files.pythonhosted.org/packages/6a/6b/95d7a85b5a20d90ffd42a174ff52772f6d046d60b85e4cd506e0baa58341/yarl-1.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a152751af7ef7b5d5fa6d215756e508dd05eb07d0cf2ba51f3e740076aa74373", size = 355310 }, - { url = "https://files.pythonhosted.org/packages/77/14/dd4cc5fe69b8d0708f3c43a2b8c8cca5364f2205e220908ba79be202f61c/yarl-1.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3d569f877ed9a708e4c71a2d13d2940cb0791da309f70bd970ac1a5c088a0a92", size = 356970 }, - { url = "https://files.pythonhosted.org/packages/1a/5e/aa5c615abbc6366c787f7abf5af2ffefd5ebe1ffc381850065624e5072fe/yarl-1.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a615cad11ec3428020fb3c5a88d85ce1b5c69fd66e9fcb91a7daa5e855325dd", size = 344806 }, - { url = "https://files.pythonhosted.org/packages/f3/10/7b9d14b5165d7f3a7b6f474cafab6993fe7a76a908a7f02d34099e915c74/yarl-1.14.0-cp311-cp311-win32.whl", hash = "sha256:bab03192091681d54e8225c53f270b0517637915d9297028409a2a5114ff4634", size = 77527 }, - { url = "https://files.pythonhosted.org/packages/ae/bb/277d3d6d44882614cbbe108474d33c0d0ffe1ea6760e710b4237147840a2/yarl-1.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:985623575e5c4ea763056ffe0e2d63836f771a8c294b3de06d09480538316b13", size = 83765 }, - { url = "https://files.pythonhosted.org/packages/9a/3e/8c8bcb19d6a61a7e91cf9209e2c7349572125496e4d4de205dcad5b11753/yarl-1.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fc2c80bc87fba076e6cbb926216c27fba274dae7100a7b9a0983b53132dd99f2", size = 136002 }, - { url = "https://files.pythonhosted.org/packages/34/07/23fe08dfc56651ec1d77643b4df5ad41d4a1fc4f24fd066b182c660620f9/yarl-1.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:55c144d363ad4626ca744556c049c94e2b95096041ac87098bb363dcc8635e8d", size = 88223 }, - { url = "https://files.pythonhosted.org/packages/f2/dc/daa1b58bb858f3ce32ca9aaeb6011d7535af01d5c0f5e6b52aa698c608e3/yarl-1.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b03384eed107dbeb5f625a99dc3a7de8be04fc8480c9ad42fccbc73434170b20", size = 85967 }, - { url = "https://files.pythonhosted.org/packages/6e/05/7461a7005bd2e969746a3f5218b876a414e4b2d9929b797afd157cd27c29/yarl-1.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f72a0d746d38cb299b79ce3d4d60ba0892c84bbc905d0d49c13df5bace1b65f8", size = 325031 }, - { url = "https://files.pythonhosted.org/packages/15/c2/54a710b97e14f99d36f82e574c8749b93ad881df120ed791fdcd1f2e1989/yarl-1.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8648180b34faaea4aa5b5ca7e871d9eb1277033fa439693855cf0ea9195f85f1", size = 334314 }, - { url = "https://files.pythonhosted.org/packages/60/24/6015e5a365ef6cab2d00058895cea37fe796936f04266de83b434f9a9a2e/yarl-1.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9557c9322aaa33174d285b0c1961fb32499d65ad1866155b7845edc876c3c835", size = 333516 }, - { url = "https://files.pythonhosted.org/packages/3d/4d/9a369945088ac7141dc9ca2fae6a10bd205f0ea8a925996ec465d3afddcd/yarl-1.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f50eb3837012a937a2b649ec872b66ba9541ad9d6f103ddcafb8231cfcafd22", size = 329437 }, - { url = "https://files.pythonhosted.org/packages/b1/38/a71b7a7a8a95d3727075472ab4b88e2d0f3223b649bcb233f6022c42593d/yarl-1.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8892fa575ac9b1b25fae7b221bc4792a273877b9b56a99ee2d8d03eeb3dbb1d2", size = 316742 }, - { url = "https://files.pythonhosted.org/packages/02/e7/b3baf612d964b4abd492594a51e75ba5cd08243a834cbc21e1013c8ac229/yarl-1.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6a2c5c5bb2556dfbfffffc2bcfb9c235fd2b566d5006dfb2a37afc7e3278a07", size = 330168 }, - { url = "https://files.pythonhosted.org/packages/1a/a0/896eb6007cc54347f4097e8c2f31e3907de262ced9c3f56866d8dd79a8ff/yarl-1.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ab3abc0b78a5dfaa4795a6afbe7b282b6aa88d81cf8c1bb5e394993d7cae3457", size = 331898 }, - { url = "https://files.pythonhosted.org/packages/1a/73/94ee96a0e8518c7efee84e745567770371add4af65466c38d3646df86f1f/yarl-1.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:47eede5d11d669ab3759b63afb70d28d5328c14744b8edba3323e27dc52d298d", size = 343316 }, - { url = "https://files.pythonhosted.org/packages/68/6e/4cf1b32b3605fa4ce263ea338852e89e9959affaffb38eb1a7057d0a95f1/yarl-1.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fe4d2536c827f508348d7b40c08767e8c7071614250927233bf0c92170451c0a", size = 351596 }, - { url = "https://files.pythonhosted.org/packages/16/e7/1ec09b0977e3a4a0a80e319aa30359bd4f8beb543527d8ddf9a2e799541e/yarl-1.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0fd7b941dd1b00b5f0acb97455fea2c4b7aac2dd31ea43fb9d155e9bc7b78664", size = 343016 }, - { url = "https://files.pythonhosted.org/packages/de/d0/a2502a37555251c7e10df51eb425f1892f3b2acb6fa598348b96f74f3566/yarl-1.14.0-cp312-cp312-win32.whl", hash = "sha256:99ff3744f5fe48288be6bc402533b38e89749623a43208e1d57091fc96b783b9", size = 77322 }, - { url = "https://files.pythonhosted.org/packages/c0/1f/201f46e02dd074ff36ce7cd764bb8241a19f94ba88adfd6d410cededca13/yarl-1.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:1ca3894e9e9f72da93544f64988d9c052254a338a9f855165f37f51edb6591de", size = 83589 }, - { url = "https://files.pythonhosted.org/packages/f0/cf/ade2a0f0acdbfb7ca1843045a8d1691edcde4caf2dc8995c4b6dd1c6968c/yarl-1.14.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5d02d700705d67e09e1f57681f758f0b9d4412eeb70b2eb8d96ca6200b486db3", size = 134274 }, - { url = "https://files.pythonhosted.org/packages/76/c8/a9e17ac8d81bcd1dc9eca197b25c46b10317092e92ac772094ab3edf57ac/yarl-1.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:30600ba5db60f7c0820ef38a2568bb7379e1418ecc947a0f76fd8b2ff4257a97", size = 87396 }, - { url = "https://files.pythonhosted.org/packages/3f/a8/ab76e6ede9fdb5087df39e7b1c92d08eb6e58e7c4a0a3b2b6112a74cb4af/yarl-1.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e85d86527baebb41a214cc3b45c17177177d900a2ad5783dbe6f291642d4906f", size = 85240 }, - { url = "https://files.pythonhosted.org/packages/f2/1e/809b44e498c67e86c889b919d155ef6978bfabdf7d7e458922ba8f5e67be/yarl-1.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37001e5d4621cef710c8dc1429ca04e189e572f128ab12312eab4e04cf007132", size = 324884 }, - { url = "https://files.pythonhosted.org/packages/b3/88/a4385930e0653ddea4234cbca161882d7de2aa963ca6f3962a1c77dddaad/yarl-1.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f4f4547944d4f5cfcdc03f3f097d6f05bbbc915eaaf80a2ee120d0e756de377d", size = 334245 }, - { url = "https://files.pythonhosted.org/packages/21/fb/6fc8d66bc24f5913427bc8a0a4c2529bc0763ccf855062d70c21e5eb51b6/yarl-1.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75ff4c819757f9bdb35de049a509814d6ce851fe26f06eb95a392a5640052482", size = 335989 }, - { url = "https://files.pythonhosted.org/packages/74/bf/2c493c45589e98833ec8c4e3c5fff8d30f875513bc207361ac822459cb69/yarl-1.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68ac1a09392ed6e3fd14be880d39b951d7b981fd135416db7d18a6208c536561", size = 330270 }, - { url = "https://files.pythonhosted.org/packages/01/ce/1cb0ee93ef3ec827a2d0287936696f68b1743c6f4540251f61cb76d51b63/yarl-1.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96952f642ac69075e44c7d0284528938fdff39422a1d90d3e45ce40b72e5e2d9", size = 316668 }, - { url = "https://files.pythonhosted.org/packages/de/e5/edfdcf4f569eb14cb1e86a451e64ae7052e058147890ab43ecfe06c9272f/yarl-1.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a56fbe3d7f3bce1d060ea18d2413a2ca9ca814eea7cedc4d247b5f338d54844e", size = 331048 }, - { url = "https://files.pythonhosted.org/packages/e6/0a/eeea8057a19f38f07af826954c5199a19ac229823097a0a2f8346c2d9b00/yarl-1.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e2637d75e92763d1322cb5041573279ec43a80c0f7fbbd2d64f5aee98447b17", size = 335671 }, - { url = "https://files.pythonhosted.org/packages/fd/c8/7e727938615a50cf413d00ea4e80872e43778d3cb36b2ff05a55ba43addf/yarl-1.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9abe80ae2c9d37c17599557b712e6515f4100a80efb2cda15f5f070306477cd2", size = 342064 }, - { url = "https://files.pythonhosted.org/packages/38/84/5fdf90939f35bac0e3e34f43dbdb6ff2f2d4bc151885a9a4b50fd4a62d6d/yarl-1.14.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:217a782020b875538eebf3948fac3a7f9bbbd0fd9bf8538f7c2ad7489e80f4e8", size = 350695 }, - { url = "https://files.pythonhosted.org/packages/b3/c1/a27587f7178e41b0f047b83b49104fb6043f4e0a0141d4c156c6cf0a978a/yarl-1.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9cfef3f14f75bf6aba73a76caf61f9d00865912a04a4393c468a7ce0981b519", size = 345151 }, - { url = "https://files.pythonhosted.org/packages/0d/04/394d0d757055b7e8b60d7eb1f9647f200399e6ec57c8a2efc842f49d8487/yarl-1.14.0-cp313-cp313-win32.whl", hash = "sha256:d8361c7d04e6a264481f0b802e395f647cd3f8bbe27acfa7c12049efea675bd1", size = 301897 }, - { url = "https://files.pythonhosted.org/packages/b4/14/63cebb6261f49c9b3db6b20e7c4eb6131524e41f4cd402225e0a3e2bf479/yarl-1.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:bc24f968b82455f336b79bf37dbb243b7d76cd40897489888d663d4e028f5069", size = 307546 }, - { url = "https://files.pythonhosted.org/packages/0c/a3/26de988fdfd23c0cc11db8ef32713a68fc11288faf0c1a7d39d6900837f9/yarl-1.14.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a9f917966d27f7ce30039fe8d900f913c5304134096554fd9bea0774bcda6d1", size = 137284 }, - { url = "https://files.pythonhosted.org/packages/de/6d/3caf3268330f1f3493f72e54c3bd706f457a9f9e19a3a93a253109955ae2/yarl-1.14.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a2f8fb7f944bcdfecd4e8d855f84c703804a594da5123dd206f75036e536d4d", size = 88892 }, - { url = "https://files.pythonhosted.org/packages/6f/08/b076af938b119a8746935ff664f5962886b119b8f24605fb31e034203061/yarl-1.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f4e475f29a9122f908d0f1f706e1f2fc3656536ffd21014ff8a6f2e1b14d1d8", size = 86617 }, - { url = "https://files.pythonhosted.org/packages/f3/1f/bc8895af9eaaa8ec5bb5dd72e1d672d53bdf072f429ca6967a41e612c6ea/yarl-1.14.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8089d4634d8fa2b1806ce44fefa4979b1ab2c12c0bc7ef3dfa45c8a374811348", size = 309736 }, - { url = "https://files.pythonhosted.org/packages/77/57/eef67848041467dfc343c8859251bb14a052eba1be9254faab1a04aea2bf/yarl-1.14.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b16f6c75cffc2dc0616ea295abb0e1967601bd1fb1e0af6a1de1c6c887f3439", size = 324631 }, - { url = "https://files.pythonhosted.org/packages/ed/5d/37fc667ac93d65350a076d96cbe3a80c39d24b4649b5c13d5a7f07c73767/yarl-1.14.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:498b3c55087b9d762636bca9b45f60d37e51d24341786dc01b81253f9552a607", size = 321074 }, - { url = "https://files.pythonhosted.org/packages/8b/ec/55da48680d84f8cfbcccba5e4b5e3e71b888f98d2106ed39fd6918542b30/yarl-1.14.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f8bfc1db82589ef965ed234b87de30d140db8b6dc50ada9e33951ccd8ec07a", size = 313425 }, - { url = "https://files.pythonhosted.org/packages/68/26/f02dd8979668cff2b27a291793d6214c16374fc886a72b7622683b18d921/yarl-1.14.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:625f207b1799e95e7c823f42f473c1e9dbfb6192bd56bba8695656d92be4535f", size = 303490 }, - { url = "https://files.pythonhosted.org/packages/55/ce/c98510780eb6610a9ff97717b06f27a61d6b8a6a0feb56cebb4b160fe06d/yarl-1.14.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:781e2495e408a81e4eaeedeb41ba32b63b1980dddf8b60dbbeff6036bcd35049", size = 309587 }, - { url = "https://files.pythonhosted.org/packages/dd/45/8f22993a7d52488a8bcaeddd21d9dbff3bc6be24aad59e0208873ce524d9/yarl-1.14.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:659603d26d40dd4463200df9bfbc339fbfaed3fe32e5c432fe1dc2b5d4aa94b4", size = 312604 }, - { url = "https://files.pythonhosted.org/packages/59/59/6074e5b66b7b8a8253a6073ffe8ca1bce7a2cd32b4b0698b70ba5251fa41/yarl-1.14.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4e0d45ebf975634468682c8bec021618b3ad52c37619e5c938f8f831fa1ac5c0", size = 329420 }, - { url = "https://files.pythonhosted.org/packages/56/64/76c4a24f4bc8176ac692561b2d435a91784e2b2f728cdd978acf1c604a8d/yarl-1.14.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a2e4725a08cb2b4794db09e350c86dee18202bb8286527210e13a1514dc9a59a", size = 326432 }, - { url = "https://files.pythonhosted.org/packages/61/97/552bf0c24a8bf69743e39bb39b59bef5d40b791affc7cff14e421f765d76/yarl-1.14.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:19268b4fec1d7760134f2de46ef2608c2920134fb1fa61e451f679e41356dc55", size = 320087 }, - { url = "https://files.pythonhosted.org/packages/39/69/2834aaa3b99679d57ff069a5103ebe5bf563f3991da6cb31d1bc224c236e/yarl-1.14.0-cp39-cp39-win32.whl", hash = "sha256:337912bcdcf193ade64b9aae5a4017a0a1950caf8ca140362e361543c6773f21", size = 77960 }, - { url = "https://files.pythonhosted.org/packages/71/9c/da4b110d19b44bc5545b9c76387dd529e27fd9025ff8384ff0261b98bf28/yarl-1.14.0-cp39-cp39-win_amd64.whl", hash = "sha256:b6d0147574ce2e7b812c989e50fa72bbc5338045411a836bd066ce5fc8ac0bce", size = 84091 }, - { url = "https://files.pythonhosted.org/packages/fd/37/6c30afb708ab45f3da32229c77d9a25dfc8ead2ae3ec1f1ea9425172d070/yarl-1.14.0-py3-none-any.whl", hash = "sha256:c8ed4034f0765f8861620c1f2f2364d2e58520ea288497084dae880424fc0d9f", size = 38166 }, +sdist = { url = "https://files.pythonhosted.org/packages/55/8f/d2d546f8b674335fa7ef83cc5c1892294f3f516c570893e65a7ea8ed49c9/yarl-1.17.0.tar.gz", hash = "sha256:d3f13583f378930377e02002b4085a3d025b00402d5a80911726d43a67911cd9", size = 177249 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/f0/8a0fc780d5d3528c4bc85d1429c7f935e107564374f0b397961edf4c60ad/yarl-1.17.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2d8715edfe12eee6f27f32a3655f38d6c7410deb482158c0b7d4b7fad5d07628", size = 140320 }, + { url = "https://files.pythonhosted.org/packages/68/61/7c2a92f62bd90949844bce495cef522b2e4701b456f08f3616864f40ff58/yarl-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1803bf2a7a782e02db746d8bd18f2384801bc1d108723840b25e065b116ad726", size = 93260 }, + { url = "https://files.pythonhosted.org/packages/93/45/421044f7d1e1e2bedf2195b2e700c5450e47931097e55610c450941bfd6f/yarl-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e66589110e20c2951221a938fa200c7aa134a8bdf4e4dc97e6b21539ff026d4", size = 91098 }, + { url = "https://files.pythonhosted.org/packages/ef/8a/375218414390674a24a7aebcae643128f0b3109b1a96dbfe666ea62a1ba9/yarl-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7069d411cfccf868e812497e0ec4acb7c7bf8d684e93caa6c872f1e6f5d1664d", size = 313457 }, + { url = "https://files.pythonhosted.org/packages/b4/a9/4e25863684ab883070c362f39ef84de5952f082a07a366fb8f7c322966da/yarl-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cbf70ba16118db3e4b0da69dcde9d4d4095d383c32a15530564c283fa38a7c52", size = 328921 }, + { url = "https://files.pythonhosted.org/packages/ae/c4/f10bc70a4d883f3a15c9f344e8853c1b6ce34f67e8237334abba2a15ee56/yarl-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0bc53cc349675b32ead83339a8de79eaf13b88f2669c09d4962322bb0f064cbc", size = 325480 }, + { url = "https://files.pythonhosted.org/packages/00/91/0e638513d91cb9f064a437eb5b3bf86011f3ee84fea63db491a8acd232af/yarl-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6aa18a402d1c80193ce97c8729871f17fd3e822037fbd7d9b719864018df746", size = 318359 }, + { url = "https://files.pythonhosted.org/packages/af/68/f039ad42145d74532e803f9f815a002a4581ca76cc0577444884af0e759b/yarl-1.17.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d89c5bc701861cfab357aa0cd039bc905fe919997b8c312b4b0c358619c38d4d", size = 309846 }, + { url = "https://files.pythonhosted.org/packages/0f/27/fdc5ee8664aeba5750ba90ab3ca62e0c2925829371c1fc8607cde894a074/yarl-1.17.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b728bdf38ca58f2da1d583e4af4ba7d4cd1a58b31a363a3137a8159395e7ecc7", size = 317981 }, + { url = "https://files.pythonhosted.org/packages/c3/2f/8bc603b1e19412b4516b04444b9e66f6e5a11d3909688909d55622b43241/yarl-1.17.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:5542e57dc15d5473da5a39fbde14684b0cc4301412ee53cbab677925e8497c11", size = 317293 }, + { url = "https://files.pythonhosted.org/packages/38/11/6ec6d03e8cfbc4a2fefd62351bd4974ae418cb1d86ebc6cd87ad395b0c7b/yarl-1.17.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e564b57e5009fb150cb513804d7e9e9912fee2e48835638f4f47977f88b4a39c", size = 323101 }, + { url = "https://files.pythonhosted.org/packages/ab/d9/e9d372361eef9a57e3fd3a04a1338642212a43c736a10b5bea0883ecf7e4/yarl-1.17.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:eb3c4cff524b4c1c1dba3a6da905edb1dfd2baf6f55f18a58914bbb2d26b59e1", size = 337331 }, + { url = "https://files.pythonhosted.org/packages/fb/32/027ca7d682bca0f094ec87a1889276590e2a5c8cc937bb30955f89700e00/yarl-1.17.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:05e13f389038842da930d439fbed63bdce3f7644902714cb68cf527c971af804", size = 338658 }, + { url = "https://files.pythonhosted.org/packages/39/59/7e2f9b24a7f96a73860096c6ee5baa7bfef96de31f827e7beeec9b7637d5/yarl-1.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:153c38ee2b4abba136385af4467459c62d50f2a3f4bde38c7b99d43a20c143ef", size = 330774 }, + { url = "https://files.pythonhosted.org/packages/89/79/153d35d1d8addaee756e43319c41a8ba0e5bcbc472b79cf18a8002bd85f5/yarl-1.17.0-cp310-cp310-win32.whl", hash = "sha256:4065b4259d1ae6f70fd9708ffd61e1c9c27516f5b4fae273c41028afcbe3a094", size = 83275 }, + { url = "https://files.pythonhosted.org/packages/65/e7/e9d99d9e1a2a334d416d796751581ed78035731126352c285679d7760b23/yarl-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:abf366391a02a8335c5c26163b5fe6f514cc1d79e74d8bf3ffab13572282368e", size = 89465 }, + { url = "https://files.pythonhosted.org/packages/ad/72/a455fd01d4d33c10d683c209f87af5962bae54b13f435a69747354b169b1/yarl-1.17.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19a4fe0279626c6295c5b0c8c2bb7228319d2e985883621a6e87b344062d8135", size = 140427 }, + { url = "https://files.pythonhosted.org/packages/ca/f6/8f2af9ad1ceab385660f90930433d41191b8647ad3946a67ea573333317f/yarl-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cadd0113f4db3c6b56868d6a19ca6286f5ccfa7bc08c27982cf92e5ed31b489a", size = 93259 }, + { url = "https://files.pythonhosted.org/packages/5d/c5/61036a97e6686de3a3b47ffd51f2db10f4eff895dfdc287f27f9acdc4097/yarl-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:60d6693eef43215b1ccfb1df3f6eae8db30a9ff1e7989fb6b2a6f0b468930ee8", size = 91194 }, + { url = "https://files.pythonhosted.org/packages/0c/a0/fe9db41a1807da0f6f9cbc78243da3267258734c383ff911696f506cae49/yarl-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb8bf3843e1fa8cf3fe77813c512818e57368afab7ebe9ef02446fe1a10b492", size = 339165 }, + { url = "https://files.pythonhosted.org/packages/27/d5/d99e6e25e77ea26ac1d73630ad26ba29ec01ec7594c530cf045b150f7e1f/yarl-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2a5b35fd1d8d90443e061d0c8669ac7600eec5c14c4a51f619e9e105b136715", size = 354290 }, + { url = "https://files.pythonhosted.org/packages/5f/98/0c475389a172e096467ef44cb59d649fc4f44ac186689a70299cd2e03dea/yarl-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c5bf17b32f392df20ab5c3a69d37b26d10efaa018b4f4e5643c7520d8eee7ac7", size = 351486 }, + { url = "https://files.pythonhosted.org/packages/b2/0d/8ecf4604cf62abd8d4aa30dd927466b095f263ee708aed2e576f9f6c6ac8/yarl-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48f51b529b958cd06e78158ff297a8bf57b4021243c179ee03695b5dbf9cb6e1", size = 343091 }, + { url = "https://files.pythonhosted.org/packages/c8/11/e0978e6e2f312c4ac5d441634df8374d25afa17164a6a5caed65f2071ce1/yarl-1.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fcaa06bf788e19f913d315d9c99a69e196a40277dc2c23741a1d08c93f4d430", size = 336785 }, + { url = "https://files.pythonhosted.org/packages/35/26/ecfebb253652b2446082e5072bf347dc2663a176f1a7f96830fb3f2ddb37/yarl-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:32f3ee19ff0f18a7a522d44e869e1ebc8218ad3ae4ebb7020445f59b4bbe5897", size = 346317 }, + { url = "https://files.pythonhosted.org/packages/4f/d7/bec0e8ab6788824a21b4d2a467ebd491c034bf5a61aae9f91bac3225cd0f/yarl-1.17.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a4fb69a81ae2ec2b609574ae35420cf5647d227e4d0475c16aa861dd24e840b0", size = 344050 }, + { url = "https://files.pythonhosted.org/packages/5d/cd/a3d7496963fa6fda90987efc6c6d63e17035a15607a7ba432b3658ee7c4a/yarl-1.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7bacc8b77670322132a1b2522c50a1f62991e2f95591977455fd9a398b4e678d", size = 350009 }, + { url = "https://files.pythonhosted.org/packages/4c/11/e32119eba4f1b2a888d653348571ec835fda93da45255d0d4e0fd557ae75/yarl-1.17.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:437bf6eb47a2d20baaf7f6739895cb049e56896a5ffdea61a4b25da781966e8b", size = 361038 }, + { url = "https://files.pythonhosted.org/packages/b2/3f/868044fff54c060cade272a54baaf57a155537ac79424312c6c0a3c0ff17/yarl-1.17.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:30534a03c87484092080e3b6e789140bd277e40f453358900ad1f0f2e61fc8ec", size = 365043 }, + { url = "https://files.pythonhosted.org/packages/6f/63/99b77939e7a6b8dbb638fb7b6c6ecea4a730ccd7bdda5b621df9ff5bbbc6/yarl-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b30df4ff98703649915144be6f0df3b16fd4870ac38a09c56d5d9e54ff2d5f96", size = 357382 }, + { url = "https://files.pythonhosted.org/packages/b8/cc/48b49f45e4fc5fbb7538a6b513f0a4ae7377c44568e375fca65f270f03d7/yarl-1.17.0-cp311-cp311-win32.whl", hash = "sha256:263b487246858e874ab53e148e2a9a0de8465341b607678106829a81d81418c6", size = 83336 }, + { url = "https://files.pythonhosted.org/packages/ae/60/2ac590d83bb8aa5b8cc3d7f9c47d532d89fb06c3ffa2c4d4fc8d6935aded/yarl-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:07055a9e8b647a362e7d4810fe99d8f98421575e7d2eede32e008c89a65a17bd", size = 89919 }, + { url = "https://files.pythonhosted.org/packages/58/30/3d1b3eea23b9d1764c3d6a6bc22a12336bc91c748475dd1ea79f63a72bf1/yarl-1.17.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:84095ab25ba69a8fa3fb4936e14df631b8a71193fe18bd38be7ecbe34d0f5512", size = 141535 }, + { url = "https://files.pythonhosted.org/packages/aa/0d/178955afc7b6b17f7a693878da366ad4dbf2adfee84cbb76640755115191/yarl-1.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02608fb3f6df87039212fc746017455ccc2a5fc96555ee247c45d1e9f21f1d7b", size = 93821 }, + { url = "https://files.pythonhosted.org/packages/d1/b3/808461c3c3d4c32ff8783364a8673bd785ce887b7421e0ea8d758357d874/yarl-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13468d291fe8c12162b7cf2cdb406fe85881c53c9e03053ecb8c5d3523822cd9", size = 91750 }, + { url = "https://files.pythonhosted.org/packages/95/8b/572f96dd61de8f8b82caf18254573707d526715ad38fd83c47663f2b3c28/yarl-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8da3f8f368fb7e2f052fded06d5672260c50b5472c956a5f1bd7bf474ae504ab", size = 331165 }, + { url = "https://files.pythonhosted.org/packages/4d/f6/8870c4beb0a120d381e7a62f6c1e6a590d929e94de135802ecdb042caffa/yarl-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec0507ab6523980bed050137007c76883d941b519aca0e26d4c1ec1f297dd646", size = 340972 }, + { url = "https://files.pythonhosted.org/packages/cb/08/97a6ccb59df29bbedb560491bc74f9f946dbf074bec1b61f942c29d2bc32/yarl-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08fc76df7fd8360e9ff30e6ccc3ee85b8dbd6ed5d3a295e6ec62bcae7601b932", size = 340557 }, + { url = "https://files.pythonhosted.org/packages/5a/f4/52be40fc0a8811a18a2b2ae99c6233e769fe391b52fae95a23a4db45e82c/yarl-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d522f390686acb6bab2b917dd9ca06740c5080cd2eaa5aef8827b97e967319d", size = 336362 }, + { url = "https://files.pythonhosted.org/packages/0a/25/b95d3c0130c65d2118b3b58d644261a3cd4571a317e5b46dcb2a44d096e2/yarl-1.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:147c527a80bb45b3dcd6e63401af8ac574125d8d120e6afe9901049286ff64ef", size = 324716 }, + { url = "https://files.pythonhosted.org/packages/ab/8a/b4d020a2b83bcab78d9cf094ed30cd08f966a7ce900abdbc3d57e34d1a4b/yarl-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:24cf43bcd17a0a1f72284e47774f9c60e0bf0d2484d5851f4ddf24ded49f33c6", size = 342539 }, + { url = "https://files.pythonhosted.org/packages/e9/e5/29959b19f9267dde6d80d9576bd95d9ed9463693a7c7e5408cd33bf66b18/yarl-1.17.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c28a44b9e0fba49c3857360e7ad1473fc18bc7f6659ca08ed4f4f2b9a52c75fa", size = 341129 }, + { url = "https://files.pythonhosted.org/packages/0a/b2/e5bb6f8909f96179b2982b6d4f44e3700b319eebbacf3f88adc75b2ae4e9/yarl-1.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:350cacb2d589bc07d230eb995d88fcc646caad50a71ed2d86df533a465a4e6e1", size = 344626 }, + { url = "https://files.pythonhosted.org/packages/86/6a/324d0b022032380ea8c378282d5e84e3d1535565489472518e80b8734f1f/yarl-1.17.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fd1ab1373274dea1c6448aee420d7b38af163b5c4732057cd7ee9f5454efc8b1", size = 355409 }, + { url = "https://files.pythonhosted.org/packages/20/f7/e2440d94826723f8bfd194a62ee014974ec416c16f953aa27c23e3ed3128/yarl-1.17.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4934e0f96dadc567edc76d9c08181633c89c908ab5a3b8f698560124167d9488", size = 361845 }, + { url = "https://files.pythonhosted.org/packages/d7/69/757dc8bb7a9e543b319e200c8c6ed30fbf7e7155736c609e2c140d0bb719/yarl-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8d0a278170d75c88e435a1ce76557af6758bfebc338435b2eba959df2552163e", size = 356050 }, + { url = "https://files.pythonhosted.org/packages/2c/3a/c563287d638200be202d46c03698079d85993b7c68f1488451546e60999b/yarl-1.17.0-cp312-cp312-win32.whl", hash = "sha256:61584f33196575a08785bb56db6b453682c88f009cd9c6f338a10f6737ce419f", size = 82982 }, + { url = "https://files.pythonhosted.org/packages/9a/cb/07a4084b90e7761749c56a5338c34366765051e9838eb669e449f012fdb2/yarl-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9987a439ad33a7712bd5bbd073f09ad10d38640425fa498ecc99d8aa064f8fc4", size = 89294 }, + { url = "https://files.pythonhosted.org/packages/6c/4d/9285cd4d13a1bb521350656f89a09b6d44e4e167d4329246a01dc76a2128/yarl-1.17.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8deda7b8eb15a52db94c2014acdc7bdd14cb59ec4b82ac65d2ad16dc234a109e", size = 139677 }, + { url = "https://files.pythonhosted.org/packages/25/c9/eec62c4b4bb1151be548c378c06d3c7282aa70b027f0b26d24c6dde55106/yarl-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56294218b348dcbd3d7fce0ffd79dd0b6c356cb2a813a1181af730b7c40de9e7", size = 93066 }, + { url = "https://files.pythonhosted.org/packages/03/b0/ae2fc93595bf076bf568ed795a3f91ecf596975d9286aab62635340de1d7/yarl-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1fab91292f51c884b290ebec0b309a64a5318860ccda0c4940e740425a67b6b7", size = 90877 }, + { url = "https://files.pythonhosted.org/packages/3e/c2/8dd9c26534eaac304088674582e94d06d874e0b9c43ecf17d93d735eaf8a/yarl-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cf93fa61ff4d9c7d40482ce1a2c9916ca435e34a1b8451e17f295781ccc034f", size = 332747 }, + { url = "https://files.pythonhosted.org/packages/43/95/130310a39e90d99cf5894a4ea6bee147f133db3423e4d88bf6f2baba4ee4/yarl-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:261be774a0d71908c8830c33bacc89eef15c198433a8cc73767c10eeeb35a7d0", size = 343341 }, + { url = "https://files.pythonhosted.org/packages/e1/59/995a99e510f74d39c849157407d8d3e683b5b3d3d830f28de6dfca2c7f60/yarl-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deec9693b67f6af856a733b8a3e465553ef09e5e8ead792f52c25b699b8f9e6e", size = 344880 }, + { url = "https://files.pythonhosted.org/packages/78/41/520458d62a79b6115f035d63f6dec7c70ebfc19c50875cd0b9c3d63bd66f/yarl-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c804b07622ba50a765ca7fb8145512836ab65956de01307541def869e4a456c9", size = 338438 }, + { url = "https://files.pythonhosted.org/packages/b1/90/878e20cc8f54206407d035f17ccd567c75ed2bf77fb9c137c2977e58baf4/yarl-1.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d013a7c9574e98c14831a8f22d27277688ec3b2741d0188ac01a910b009987a", size = 326415 }, + { url = "https://files.pythonhosted.org/packages/0a/2e/709c8339cd5a0b8fb3e7474428165293feec85d77c642b95b0d7be7bda9c/yarl-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e2cfcba719bd494c7413dcf0caafb51772dec168c7c946e094f710d6aa70494e", size = 345526 }, + { url = "https://files.pythonhosted.org/packages/62/5e/90c60a9ac1b3f254b52e542674024160b90e0e547014f0d2a3025c789796/yarl-1.17.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c068aba9fc5b94dfae8ea1cedcbf3041cd4c64644021362ffb750f79837e881f", size = 340048 }, + { url = "https://files.pythonhosted.org/packages/ae/1f/2d086911313e4db00b28f5d105d64823dbcd4a78efcbba70bd58ffc72e20/yarl-1.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3616df510ffac0df3c9fa851a40b76087c6c89cbcea2de33a835fc80f9faac24", size = 344999 }, + { url = "https://files.pythonhosted.org/packages/da/f7/8670ff0427f82db0ec25f4f7e62f5111cc76d79b05a2fe9631155cd0f742/yarl-1.17.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:755d6176b442fba9928a4df787591a6a3d62d4969f05c406cad83d296c5d4e05", size = 353920 }, + { url = "https://files.pythonhosted.org/packages/68/b8/1f5a2fdecee03c23b4b5c9d394342709ed04e15bead1d3c7bee53854a61b/yarl-1.17.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c18f6e708d1cf9ff5b1af026e697ac73bea9cb70ee26a2b045b112548579bed2", size = 360209 }, + { url = "https://files.pythonhosted.org/packages/2b/95/d2e538a544c75131836b5e93975fa677932f0cbacbe4d7a4adb80caba967/yarl-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b937c216b6dee8b858c6afea958de03c5ff28406257d22b55c24962a2baf6fd", size = 359149 }, + { url = "https://files.pythonhosted.org/packages/93/c7/c7f954200ebae213f0b76b072dcd3c37b39a42f4cf3d80a30d580bcedef7/yarl-1.17.0-cp313-cp313-win32.whl", hash = "sha256:d0131b14cb545c1a7bd98f4565a3e9bdf25a1bd65c83fc156ee5d8a8499ec4a3", size = 308608 }, + { url = "https://files.pythonhosted.org/packages/c7/cc/57117f63f27668e87e3ea9ce9fecab7331f0a30b72690211a2857b5db9f5/yarl-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:01c96efa4313c01329e88b7e9e9e1b2fc671580270ddefdd41129fa8d0db7696", size = 314345 }, + { url = "https://files.pythonhosted.org/packages/63/d5/64258ee2af4ad1a25606f5740c282160eae199e02e1b88e70ee3b7de2061/yarl-1.17.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0d44f67e193f0a7acdf552ecb4d1956a3a276c68e7952471add9f93093d1c30d", size = 141626 }, + { url = "https://files.pythonhosted.org/packages/e6/1b/da620f07d73f9525c2f2b0df2c9c15f3b6cdc360f1e77dde7af6ea0c9a05/yarl-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:16ea0aa5f890cdcb7ae700dffa0397ed6c280840f637cd07bffcbe4b8d68b985", size = 93855 }, + { url = "https://files.pythonhosted.org/packages/1b/77/43caa9029936b43c500b6cfbb35c5883431596f156a384767afa2bf40a2d/yarl-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cf5469dc7dcfa65edf5cc3a6add9f84c5529c6b556729b098e81a09a92e60e51", size = 91690 }, + { url = "https://files.pythonhosted.org/packages/18/50/a2ce9c595161ddd146610376388382c786d3763645c536a347e2b0cdce76/yarl-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e662bf2f6e90b73cf2095f844e2bc1fda39826472a2aa1959258c3f2a8500a2f", size = 315804 }, + { url = "https://files.pythonhosted.org/packages/bf/32/a18b8b9dbe7aa2110967d73e0ee8d17c6a33a714494a790bad80b68a6f0d/yarl-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8260e88f1446904ba20b558fa8ce5d0ab9102747238e82343e46d056d7304d7e", size = 332868 }, + { url = "https://files.pythonhosted.org/packages/e1/c5/ac6ff7a774001433da7c687e51372bb5c3989b47fde33da559fe0a2afdfc/yarl-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5dc16477a4a2c71e64c5d3d15d7ae3d3a6bb1e8b955288a9f73c60d2a391282f", size = 328682 }, + { url = "https://files.pythonhosted.org/packages/40/5b/95a2675ce4ac31e5cfb1b3cf86186e509b887078f9946e38b8d343264405/yarl-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46027e326cecd55e5950184ec9d86c803f4f6fe4ba6af9944a0e537d643cdbe0", size = 320438 }, + { url = "https://files.pythonhosted.org/packages/ee/69/55af26629312ac686848b402d7dc48194dd14e509a3da6d31e71734ce43a/yarl-1.17.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc95e46c92a2b6f22e70afe07e34dbc03a4acd07d820204a6938798b16f4014f", size = 313099 }, + { url = "https://files.pythonhosted.org/packages/52/dc/882b922b37868efa29c07baa509e6a1fe69762b733b5cd12ca4cb3a34992/yarl-1.17.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:16ca76c7ac9515320cd09d6cc083d8d13d1803f6ebe212b06ea2505fd66ecff8", size = 321353 }, + { url = "https://files.pythonhosted.org/packages/80/06/9feb083092fb5556f8fa78c15c58aacfc7dacc0d28524b571ad83c679630/yarl-1.17.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:eb1a5b97388f2613f9305d78a3473cdf8d80c7034e554d8199d96dcf80c62ac4", size = 322983 }, + { url = "https://files.pythonhosted.org/packages/4f/71/a0edd86e473589e885350aef584359dcd5a6117154fd3192869799e48dbd/yarl-1.17.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:41fd5498975418cdc34944060b8fbeec0d48b2741068077222564bea68daf5a6", size = 326432 }, + { url = "https://files.pythonhosted.org/packages/c6/11/b74a0b7ac4294ecc5225391af0eeccb580b3c6e63d8bbfed9992a8884445/yarl-1.17.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:146ca582ed04a5664ad04b0e0603934281eaab5c0115a5a46cce0b3c061a56a1", size = 338673 }, + { url = "https://files.pythonhosted.org/packages/4f/8c/09abe2f91571c54deae92c8167c80c37a8788f723bfa9a25576d1858cbba/yarl-1.17.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:6abb8c06107dbec97481b2392dafc41aac091a5d162edf6ed7d624fe7da0587a", size = 339042 }, + { url = "https://files.pythonhosted.org/packages/7b/ff/2572507b577c9039248da6eb97b52b6fbf7f5f9fc81398bd5b1f4e2ed61b/yarl-1.17.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4d14be4613dd4f96c25feb4bd8c0d8ce0f529ab0ae555a17df5789e69d8ec0c5", size = 333817 }, + { url = "https://files.pythonhosted.org/packages/a3/0f/dae6b48f8e0f8af054a47c9933167c74e138b89a07971d69a33104863697/yarl-1.17.0-cp39-cp39-win32.whl", hash = "sha256:174d6a6cad1068f7850702aad0c7b1bca03bcac199ca6026f84531335dfc2646", size = 83814 }, + { url = "https://files.pythonhosted.org/packages/75/87/35e0d82d908c879510f92dde7ac225d4055d06211d8f3d6d9591bc93702b/yarl-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:6af417ca2c7349b101d3fd557ad96b4cd439fdb6ab0d288e3f64a068eea394d0", size = 89937 }, + { url = "https://files.pythonhosted.org/packages/93/86/f1305e1ab1d6dc27d245ffc83d18d88f2bebf6c6488725ee82dffb3eda7a/yarl-1.17.0-py3-none-any.whl", hash = "sha256:62dd42bb0e49423f4dd58836a04fcf09c80237836796025211bbe913f1524993", size = 44053 }, ] [[package]] From 9975bbf26a10ef865f7cbf50f064944f3c5fe5cf Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Thu, 31 Oct 2024 11:41:11 +0100 Subject: [PATCH 640/892] Expose PIR enabled setting for iot dimmers (#1174) This adds PIR enabled feature to iot dimmers, making it possible to enable and disable the motion detection. --- kasa/iot/modules/motion.py | 44 +++++++++++++++++---- kasa/tests/iot/modules/__init__.py | 0 kasa/tests/iot/modules/test_motion.py | 57 +++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 kasa/tests/iot/modules/__init__.py create mode 100644 kasa/tests/iot/modules/test_motion.py diff --git a/kasa/iot/modules/motion.py b/kasa/iot/modules/motion.py index fe59748e2..db272e2f2 100644 --- a/kasa/iot/modules/motion.py +++ b/kasa/iot/modules/motion.py @@ -2,11 +2,15 @@ from __future__ import annotations +import logging from enum import Enum from ...exceptions import KasaException +from ...feature import Feature from ..iotmodule import IotModule +_LOGGER = logging.getLogger(__name__) + class Range(Enum): """Range for motion detection.""" @@ -17,27 +21,51 @@ class Range(Enum): Custom = 3 -# TODO: use the config reply in tests -# {"enable":0,"version":"1.0","trigger_index":2,"cold_time":60000, -# "min_adc":0,"max_adc":4095,"array":[80,50,20,0],"err_code":0}}} - - class Motion(IotModule): """Implements the motion detection (PIR) module.""" + def _initialize_features(self): + """Initialize features after the initial update.""" + # Only add features if the device supports the module + if "get_config" not in self.data: + return + + if "enable" not in self.config: + _LOGGER.warning("%r initialized, but no enable in response") + return + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_enabled", + name="PIR enabled", + icon="mdi:motion-sensor", + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Config, + ) + ) + def query(self): """Request PIR configuration.""" return self.query_for_command("get_config") + @property + def config(self) -> dict: + """Return current configuration.""" + return self.data["get_config"] + @property def range(self) -> Range: """Return motion detection range.""" - return Range(self.data["trigger_index"]) + return Range(self.config["trigger_index"]) @property def enabled(self) -> bool: """Return True if module is enabled.""" - return bool(self.data["enable"]) + return bool(self.config["enable"]) async def set_enabled(self, state: bool): """Enable/disable PIR.""" @@ -63,7 +91,7 @@ async def set_range( @property def inactivity_timeout(self) -> int: """Return inactivity timeout in milliseconds.""" - return self.data["cold_time"] + return self.config["cold_time"] async def set_inactivity_timeout(self, timeout: int): """Set inactivity timeout in milliseconds. diff --git a/kasa/tests/iot/modules/__init__.py b/kasa/tests/iot/modules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/kasa/tests/iot/modules/test_motion.py b/kasa/tests/iot/modules/test_motion.py new file mode 100644 index 000000000..932361641 --- /dev/null +++ b/kasa/tests/iot/modules/test_motion.py @@ -0,0 +1,57 @@ +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.iot import IotDimmer +from kasa.iot.modules.motion import Motion, Range +from kasa.tests.device_fixtures import dimmer_iot + + +@dimmer_iot +def test_motion_getters(dev: IotDimmer): + assert Module.IotMotion in dev.modules + motion: Motion = dev.modules[Module.IotMotion] + + assert motion.enabled == motion.config["enable"] + assert motion.inactivity_timeout == motion.config["cold_time"] + assert motion.range.value == motion.config["trigger_index"] + + +@dimmer_iot +async def test_motion_setters(dev: IotDimmer, mocker: MockerFixture): + motion: Motion = dev.modules[Module.IotMotion] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + await motion.set_enabled(True) + query_helper.assert_called_with("smartlife.iot.PIR", "set_enable", {"enable": True}) + + await motion.set_inactivity_timeout(10) + query_helper.assert_called_with( + "smartlife.iot.PIR", "set_cold_time", {"cold_time": 10} + ) + + +@dimmer_iot +async def test_motion_range(dev: IotDimmer, mocker: MockerFixture): + motion: Motion = dev.modules[Module.IotMotion] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + await motion.set_range(custom_range=123) + query_helper.assert_called_with( + "smartlife.iot.PIR", + "set_trigger_sens", + {"index": Range.Custom.value, "value": 123}, + ) + + await motion.set_range(range=Range.Far) + query_helper.assert_called_with( + "smartlife.iot.PIR", "set_trigger_sens", {"index": Range.Far.value} + ) + + +@dimmer_iot +def test_motion_feature(dev: IotDimmer): + assert Module.IotMotion in dev.modules + motion: Motion = dev.modules[Module.IotMotion] + + pir_enabled = dev.features["pir_enabled"] + assert motion.enabled == pir_enabled.value From 6c141c3b6536332b6422960a17bae54f08a06464 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Thu, 31 Oct 2024 12:17:18 +0100 Subject: [PATCH 641/892] Expose ambient light setting for iot dimmers (#1210) This PR adds a setting to control the ambient light enabled/disabled. Also fixes the getters. --- kasa/iot/modules/ambientlight.py | 38 ++++++++++++----- kasa/tests/iot/modules/test_ambientlight.py | 46 +++++++++++++++++++++ 2 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 kasa/tests/iot/modules/test_ambientlight.py diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py index d6470d264..691f88f16 100644 --- a/kasa/iot/modules/ambientlight.py +++ b/kasa/iot/modules/ambientlight.py @@ -1,16 +1,11 @@ """Implementation of the ambient light (LAS) module found in some dimmers.""" +import logging + from ...feature import Feature from ..iotmodule import IotModule, merge -# TODO create tests and use the config reply there -# [{"hw_id":0,"enable":0,"dark_index":1,"min_adc":0,"max_adc":2450, -# "level_array":[{"name":"cloudy","adc":490,"value":20}, -# {"name":"overcast","adc":294,"value":12}, -# {"name":"dawn","adc":222,"value":9}, -# {"name":"twilight","adc":222,"value":9}, -# {"name":"total darkness","adc":111,"value":4}, -# {"name":"custom","adc":2400,"value":97}]}] +_LOGGER = logging.getLogger(__name__) class AmbientLight(IotModule): @@ -18,6 +13,19 @@ class AmbientLight(IotModule): def _initialize_features(self): """Initialize features after the initial update.""" + self._add_feature( + Feature( + device=self._device, + container=self, + id="ambient_light_enabled", + name="Ambient light enabled", + icon="mdi:brightness-percent", + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Config, + ) + ) self._add_feature( Feature( device=self._device, @@ -41,15 +49,25 @@ def query(self): return req + @property + def config(self) -> dict: + """Return current ambient light config.""" + config = self.data["get_config"] + devs = config["devs"] + if len(devs) != 1: + _LOGGER.error("Unexpected number of devs in config: %s", config) + + return devs[0] + @property def presets(self) -> dict: """Return device-defined presets for brightness setting.""" - return self.data["level_array"] + return self.config["level_array"] @property def enabled(self) -> bool: """Return True if the module is enabled.""" - return bool(self.data["enable"]) + return bool(self.config["enable"]) @property def ambientlight_brightness(self) -> int: diff --git a/kasa/tests/iot/modules/test_ambientlight.py b/kasa/tests/iot/modules/test_ambientlight.py new file mode 100644 index 000000000..d7c584750 --- /dev/null +++ b/kasa/tests/iot/modules/test_ambientlight.py @@ -0,0 +1,46 @@ +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.iot import IotDimmer +from kasa.iot.modules.ambientlight import AmbientLight +from kasa.tests.device_fixtures import dimmer_iot + + +@dimmer_iot +def test_ambientlight_getters(dev: IotDimmer): + assert Module.IotAmbientLight in dev.modules + ambientlight: AmbientLight = dev.modules[Module.IotAmbientLight] + + assert ambientlight.enabled == ambientlight.config["enable"] + assert ambientlight.presets == ambientlight.config["level_array"] + + assert ( + ambientlight.ambientlight_brightness + == ambientlight.data["get_current_brt"]["value"] + ) + + +@dimmer_iot +async def test_ambientlight_setters(dev: IotDimmer, mocker: MockerFixture): + ambientlight: AmbientLight = dev.modules[Module.IotAmbientLight] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + await ambientlight.set_enabled(True) + query_helper.assert_called_with("smartlife.iot.LAS", "set_enable", {"enable": True}) + + await ambientlight.set_brightness_limit(10) + query_helper.assert_called_with( + "smartlife.iot.LAS", "set_brt_level", {"index": 0, "value": 10} + ) + + +@dimmer_iot +def test_ambientlight_feature(dev: IotDimmer): + assert Module.IotAmbientLight in dev.modules + ambientlight: AmbientLight = dev.modules[Module.IotAmbientLight] + + enabled = dev.features["ambient_light_enabled"] + assert ambientlight.enabled == enabled.value + + brightness = dev.features["ambient_light"] + assert ambientlight.ambientlight_brightness == brightness.value From 5da41fcc11d7621633424b18b2804803319044a4 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Thu, 31 Oct 2024 14:12:17 +0100 Subject: [PATCH 642/892] Use stacklevel=2 for warnings to report on callsites (#1219) Use stacklevel=2 for warnings, as this will correctly show the callsite instead of the line where the warning is reported. Currently: ``` kasa/__init__.py:110 /home/tpr/code/python-kasa/kasa/__init__.py:110: DeprecationWarning: SmartDevice is deprecated, use IotDevice from package kasa.iot instead or use Discover.discover_single() and Device.connect() to support new protocols warn( ``` After: ``` kasa/tests/smart/modules/test_contact.py:3 /home/tpr/code/python-kasa/kasa/tests/smart/modules/test_contact.py:3: DeprecationWarning: SmartDevice is deprecated, use IotDevice from package kasa.iot instead or use Discover.discover_single() and Device.connect() to support new protocols from kasa import Module, SmartDevice ``` Currently: ``` kasa/tests/test_lightstrip.py: 56 warnings /home/tpr/code/python-kasa/kasa/device.py:559: DeprecationWarning: effect is deprecated, use: Module.LightEffect in device.modules instead warn(msg, DeprecationWarning, stacklevel=1) ``` After: ``` kasa/tests/test_lightstrip.py::test_effects_lightstrip_set_effect_transition[500-KL430(US)_2.0_1.0.9.json] /home/tpr/code/python-kasa/kasa/tests/test_lightstrip.py:62: DeprecationWarning: set_effect is deprecated, use: Module.LightEffect in device.modules instead await dev.set_effect("Candy Cane") ``` --- kasa/__init__.py | 6 +++--- kasa/device.py | 4 ++-- kasa/interfaces/energy.py | 2 +- kasa/iot/iotdevice.py | 4 ++-- kasa/tests/fakeprotocol_smart.py | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/kasa/__init__.py b/kasa/__init__.py index d383d3a79..11000419c 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -102,7 +102,7 @@ def __getattr__(name): if name in deprecated_names: - warn(f"{name} is deprecated", DeprecationWarning, stacklevel=1) + warn(f"{name} is deprecated", DeprecationWarning, stacklevel=2) return globals()[f"_deprecated_{name}"] if name in deprecated_smart_devices: new_class = deprecated_smart_devices[name] @@ -112,13 +112,13 @@ def __getattr__(name): + f"from package {package_name} instead or use Discover.discover_single()" + " and Device.connect() to support new protocols", DeprecationWarning, - stacklevel=1, + stacklevel=2, ) return new_class if name in deprecated_classes: new_class = deprecated_classes[name] msg = f"{name} is deprecated, use {new_class.__name__} instead" - warn(msg, DeprecationWarning, stacklevel=1) + warn(msg, DeprecationWarning, stacklevel=2) return new_class raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/kasa/device.py b/kasa/device.py index 5df1751c5..08dcf2a19 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -545,7 +545,7 @@ def __getattr__(self, name): msg = f"{name} is deprecated" if module: msg += f", use: {module} in device.modules instead" - warn(msg, DeprecationWarning, stacklevel=1) + warn(msg, DeprecationWarning, stacklevel=2) return self.device_type == dep_device_type_attr[1] # Other deprecated attributes if (dep_attr := self._deprecated_other_attributes.get(name)) and ( @@ -556,6 +556,6 @@ def __getattr__(self, name): dev_or_mod = self.modules[mod] if mod else self replacing = f"Module.{mod} in device.modules" if mod else replacing_attr msg = f"{name} is deprecated, use: {replacing} instead" - warn(msg, DeprecationWarning, stacklevel=1) + warn(msg, DeprecationWarning, stacklevel=2) return getattr(dev_or_mod, replacing_attr) raise AttributeError(f"Device has no attribute {name!r}") diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py index 51579322f..4e040e6fd 100644 --- a/kasa/interfaces/energy.py +++ b/kasa/interfaces/energy.py @@ -182,6 +182,6 @@ async def get_monthly_stats(self, *, year=None, kwh=True) -> dict: def __getattr__(self, name): if attr := self._deprecated_attributes.get(name): msg = f"{name} is deprecated, use {attr} instead" - warn(msg, DeprecationWarning, stacklevel=1) + warn(msg, DeprecationWarning, stacklevel=2) return getattr(self, attr) raise AttributeError(f"Energy module has no attribute {name!r}") diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 84c4ff818..692968235 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -472,7 +472,7 @@ def timezone(self) -> tzinfo: async def get_time(self) -> datetime: """Return current time from the device, if available.""" msg = "Use `time` property instead, this call will be removed in the future." - warn(msg, DeprecationWarning, stacklevel=1) + warn(msg, DeprecationWarning, stacklevel=2) return self.time async def get_timezone(self) -> tzinfo: @@ -480,7 +480,7 @@ async def get_timezone(self) -> tzinfo: msg = ( "Use `timezone` property instead, this call will be removed in the future." ) - warn(msg, DeprecationWarning, stacklevel=1) + warn(msg, DeprecationWarning, stacklevel=2) return self.timezone @property # type: ignore diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index c3d8104e9..c5a7c11e0 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -202,12 +202,12 @@ def try_get_child_fixture_info(child_dev_info): else: warn( f"Could not find child SMART fixture for {child_info}", - stacklevel=1, + stacklevel=2, ) else: warn( f"Child is a cameraprotocol which needs to be implemented {child_info}", - stacklevel=1, + stacklevel=2, ) # Replace parent child infos with the infos from the child fixtures so # that updates update both @@ -223,7 +223,7 @@ async def _handle_control_child(self, params: dict): if device_id not in self.child_protocols: warn( f"Could not find child fixture {device_id} in {self.fixture_name}", - stacklevel=1, + stacklevel=2, ) return self._handle_control_child_missing(params) From e73da5b677582e8c243f78b5344844f845821f14 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 31 Oct 2024 15:21:54 +0000 Subject: [PATCH 643/892] Fix AES child device creation error (#1220) Bug exposed when passing credentials_hash and creating child devices for klap devices as the default is to try to create an AES transport and the credentials hashes are incompatible. --- kasa/smart/smartchilddevice.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index f3e39ce9d..a5b24fd56 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -42,13 +42,12 @@ def __init__( config: DeviceConfig | None = None, protocol: SmartProtocol | None = None, ) -> None: - super().__init__(parent.host, config=parent.config, protocol=protocol) + self._id = info["device_id"] + _protocol = protocol or _ChildProtocolWrapper(self._id, parent.protocol) + super().__init__(parent.host, config=parent.config, protocol=_protocol) self._parent = parent self._update_internal_state(info) self._components = component_info - self._id = info["device_id"] - # wrap device protocol if no protocol is given - self.protocol = protocol or _ChildProtocolWrapper(self._id, parent.protocol) async def update(self, update_children: bool = True): """Update child module info. From 54f0e91c0455d0532b174ee1d2d64bd24519f0e6 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 1 Nov 2024 14:52:39 +0000 Subject: [PATCH 644/892] Add component queries to smartcamera devices (#1223) --- devtools/helpers/smartcamerarequests.py | 2 + .../smartcamera/C210(EU)_2.0_1.4.3.json | 962 ++++++++++++++++++ .../smartcamera/H200(EU)_1.0_1.3.2.json | 185 +++- 3 files changed, 1141 insertions(+), 8 deletions(-) create mode 100644 kasa/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.3.json diff --git a/devtools/helpers/smartcamerarequests.py b/devtools/helpers/smartcamerarequests.py index 3f5596f76..2779ac0e5 100644 --- a/devtools/helpers/smartcamerarequests.py +++ b/devtools/helpers/smartcamerarequests.py @@ -52,6 +52,8 @@ {"getVideoCapability": {"video_capability": {"name": "main"}}}, {"getTimezone": {"system": {"name": "basic"}}}, {"getClockStatus": {"system": {"name": "clock_status"}}}, + {"getAppComponentList": {"app_component": {"name": "app_component_list"}}}, + {"getChildDeviceComponentList": {"childControl": {"start_index": 0}}}, # single request only methods {"get": {"function": {"name": ["module_spec"]}}}, {"get": {"cet": {"name": ["vhttpd"]}}}, diff --git a/kasa/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.3.json b/kasa/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.3.json new file mode 100644 index 000000000..a2f7666ed --- /dev/null +++ b/kasa/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.3.json @@ -0,0 +1,962 @@ +{ + "discovery_result": { + "decrypted_data": { + "connect_ssid": "0000000000", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "0", + "last_alarm_type": "", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.4.3 Build 241010 Rel.33858n", + "hardware_version": "2.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "15", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Tone" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 2 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "panoramicView", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "staticIp", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-11-01 13:58:50", + "seconds_from_1970": 1730469530 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "3", + "rssiValue": -57, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "50", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "Home", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C210 2.0 IPC", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "2.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "40-AE-30-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.4.3 Build 241010 Rel.33858n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "0", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "on" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "50" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [], + "name": [], + "position_pan": [], + "position_tilt": [], + "position_zoom": [], + "read_only": [] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "total_space_accurate": "0B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "0B", + "video_total_space_accurate": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-00:00", + "timing_mode": "ntp", + "zone_id": "Europe/London" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1382", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561" + ], + "minor_stream_support": "0", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2304*1296", + "1920*1080", + "1280*720" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1382", + "bitrate_type": "vbr", + "default_bitrate": "1382", + "encode_type": "H264", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "1920*1080", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8", + "16" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "1.1" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "1.3" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "1.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "1" + } + } + } + } +} diff --git a/kasa/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json b/kasa/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json index 05d302fc4..22b6c9d91 100644 --- a/kasa/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json +++ b/kasa/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json @@ -31,20 +31,189 @@ } }, "getAlertConfig": {}, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 2 + }, + { + "name": "dateTime", + "version": 1 + }, + { + "name": "system", + "version": 4 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 1 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "hubRecord", + "version": 1 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "siren", + "version": 2 + }, + { + "name": "childControl", + "version": 1 + }, + { + "name": "childQuickSetup", + "version": 1 + }, + { + "name": "childInherit", + "version": 1 + }, + { + "name": "deviceLoad", + "version": 1 + }, + { + "name": "subg", + "version": 2 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "preWakeUp", + "version": 1 + }, + { + "name": "supportRE", + "version": 1 + }, + { + "name": "testSignal", + "version": 1 + }, + { + "name": "dataDownload", + "version": 1 + }, + { + "name": "testChildSignal", + "version": 1 + }, + { + "name": "ringLog", + "version": 1 + }, + { + "name": "matter", + "version": 1 + }, + { + "name": "localSmart", + "version": 1 + } + ] + } + }, + "getChildDeviceComponentList": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "double_click", + "ver_code": 1 + } + ], + "device_id": "0000000000000000000000000000000000000000" + } + ], + "start_index": 0, + "sum": 1 + }, "getChildDeviceList": { "child_device_list": [ { "at_low_battery": false, "avatar": "button", - "bind_count": 1, + "bind_count": 2, "category": "subg.trigger.button", "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", "fw_ver": "1.11.0 Build 230821 Rel.113553", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -116, - "jamming_signal_level": 1, - "lastOnboardingTimestamp": 1713970593, + "jamming_rssi": -108, + "jamming_signal_level": 2, + "lastOnboardingTimestamp": 1714016798, "mac": "202351000000", "model": "S200B", "nickname": "I01BU0tFRF9OQU1FIw==", @@ -52,7 +221,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Europe/London", "report_interval": 16, - "rssi": -68, + "rssi": -66, "signal_level": 3, "specs": "EU", "status": "online", @@ -73,8 +242,8 @@ "getClockStatus": { "system": { "clock_status": { - "local_time": "2024-04-25 16:15:39", - "seconds_from_1970": 1714061739 + "local_time": "2024-11-01 13:56:27", + "seconds_from_1970": 1730469387 } } }, @@ -134,7 +303,7 @@ "getFirmwareAutoUpgradeConfig": { "auto_upgrade": { "common": { - "enabled": "on", + "enabled": "off", "random_range": 120, "time": "03:00" } From 7335a7d33f3f756f9669147dbc5f24062d1a7e00 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 1 Nov 2024 15:15:13 +0000 Subject: [PATCH 645/892] Update smartcamera fixtures from latest dump_devinfo (#1224) --- .../smart/child/S200B(US)_1.0_1.12.0.json | 14 ++- .../smart/child/T110(US)_1.0_1.9.0.json | 9 +- .../smart/child/T310(US)_1.0_1.5.0.json | 15 ++- .../smart/child/T315(US)_1.0_1.8.0.json | 15 ++- .../smartcamera/H200(US)_1.0_1.3.6.json | 101 ++++++++++++------ 5 files changed, 104 insertions(+), 50 deletions(-) diff --git a/kasa/tests/fixtures/smart/child/S200B(US)_1.0_1.12.0.json b/kasa/tests/fixtures/smart/child/S200B(US)_1.0_1.12.0.json index 1efd77421..fa3b7c136 100644 --- a/kasa/tests/fixtures/smart/child/S200B(US)_1.0_1.12.0.json +++ b/kasa/tests/fixtures/smart/child/S200B(US)_1.0_1.12.0.json @@ -68,8 +68,8 @@ "fw_ver": "1.12.0 Build 231121 Rel.092508", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -104, - "jamming_signal_level": 2, + "jamming_rssi": -113, + "jamming_signal_level": 1, "lastOnboardingTimestamp": 1724636886, "mac": "98254A000000", "model": "S200B", @@ -78,7 +78,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Australia/Canberra", "report_interval": 16, - "rssi": -36, + "rssi": -56, "signal_level": 3, "specs": "US", "status": "online", @@ -87,6 +87,9 @@ }, "get_device_time": -1001, "get_device_usage": -1001, + "get_double_click_info": { + "enable": false + }, "get_fw_download_state": { "cloud_cache_seconds": 1, "download_progress": 0, @@ -104,5 +107,10 @@ "release_note": "", "type": 0 }, + "get_trigger_logs": { + "logs": [], + "start_id": 0, + "sum": 0 + }, "qs_component_nego": -1001 } diff --git a/kasa/tests/fixtures/smart/child/T110(US)_1.0_1.9.0.json b/kasa/tests/fixtures/smart/child/T110(US)_1.0_1.9.0.json index 73aeeb1a2..43dbf731e 100644 --- a/kasa/tests/fixtures/smart/child/T110(US)_1.0_1.9.0.json +++ b/kasa/tests/fixtures/smart/child/T110(US)_1.0_1.9.0.json @@ -64,7 +64,7 @@ "fw_ver": "1.9.0 Build 230704 Rel.154559", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -116, + "jamming_rssi": -113, "jamming_signal_level": 1, "lastOnboardingTimestamp": 1724635267, "mac": "A86E84000000", @@ -75,7 +75,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Australia/Canberra", "report_interval": 16, - "rssi": -55, + "rssi": -56, "signal_level": 3, "specs": "US", "status": "online", @@ -101,5 +101,10 @@ "release_note": "", "type": 0 }, + "get_trigger_logs": { + "logs": [], + "start_id": 0, + "sum": 0 + }, "qs_component_nego": -1001 } diff --git a/kasa/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json b/kasa/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json index 518e4eb73..bdc4eef69 100644 --- a/kasa/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json +++ b/kasa/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json @@ -84,15 +84,15 @@ "avatar": "sensor_t310", "bind_count": 1, "category": "subg.trigger.temp-hmdt-sensor", - "current_humidity": 51, + "current_humidity": 49, "current_humidity_exception": 0, - "current_temp": 19.4, - "current_temp_exception": -0.6, + "current_temp": 21.7, + "current_temp_exception": 0, "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", "fw_ver": "1.5.0 Build 230105 Rel.180832", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -113, + "jamming_rssi": -111, "jamming_signal_level": 1, "lastOnboardingTimestamp": 1724637745, "mac": "F0A731000000", @@ -102,7 +102,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Australia/Canberra", "report_interval": 16, - "rssi": -36, + "rssi": -46, "signal_level": 3, "specs": "US", "status": "online", @@ -129,5 +129,10 @@ "release_note": "", "type": 0 }, + "get_trigger_logs": { + "logs": [], + "start_id": 0, + "sum": 0 + }, "qs_component_nego": -1001 } diff --git a/kasa/tests/fixtures/smart/child/T315(US)_1.0_1.8.0.json b/kasa/tests/fixtures/smart/child/T315(US)_1.0_1.8.0.json index 33438bb2d..7a557b8c7 100644 --- a/kasa/tests/fixtures/smart/child/T315(US)_1.0_1.8.0.json +++ b/kasa/tests/fixtures/smart/child/T315(US)_1.0_1.8.0.json @@ -85,15 +85,15 @@ "battery_percentage": 100, "bind_count": 1, "category": "subg.trigger.temp-hmdt-sensor", - "current_humidity": 53, + "current_humidity": 51, "current_humidity_exception": 0, - "current_temp": 18.3, - "current_temp_exception": -0.7, + "current_temp": 21.5, + "current_temp_exception": 0, "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", "fw_ver": "1.8.0 Build 230921 Rel.091519", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -114, + "jamming_rssi": -113, "jamming_signal_level": 1, "lastOnboardingTimestamp": 1724637369, "mac": "202351000000", @@ -103,7 +103,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Australia/Canberra", "report_interval": 16, - "rssi": -50, + "rssi": -44, "signal_level": 3, "specs": "US", "status": "online", @@ -130,5 +130,10 @@ "release_note": "", "type": 0 }, + "get_trigger_logs": { + "logs": [], + "start_id": 0, + "sum": 0 + }, "qs_component_nego": -1001 } diff --git a/kasa/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json b/kasa/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json index 544ab267f..dda7ade8f 100644 --- a/kasa/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json +++ b/kasa/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json @@ -1,4 +1,35 @@ { + "discovery_result": { + "decrypted_data": { + "connect_ssid": "", + "connect_type": "wired", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "H200", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.6 Build 20240829 rel.71119", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "24-2F-D0-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + }, "getAlertConfig": {}, "getChildDeviceList": { "child_device_list": [ @@ -7,15 +38,15 @@ "avatar": "sensor_t310", "bind_count": 1, "category": "subg.trigger.temp-hmdt-sensor", - "current_humidity": 51, + "current_humidity": 49, "current_humidity_exception": 0, - "current_temp": 19.4, - "current_temp_exception": -0.6, + "current_temp": 21.7, + "current_temp_exception": 0, "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", "fw_ver": "1.5.0 Build 230105 Rel.180832", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -113, + "jamming_rssi": -111, "jamming_signal_level": 1, "lastOnboardingTimestamp": 1724637745, "mac": "F0A731000000", @@ -25,7 +56,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Australia/Canberra", "report_interval": 16, - "rssi": -36, + "rssi": -46, "signal_level": 3, "specs": "US", "status": "online", @@ -39,15 +70,15 @@ "battery_percentage": 100, "bind_count": 1, "category": "subg.trigger.temp-hmdt-sensor", - "current_humidity": 53, + "current_humidity": 51, "current_humidity_exception": 0, - "current_temp": 18.3, - "current_temp_exception": -0.7, + "current_temp": 21.5, + "current_temp_exception": 0, "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", "fw_ver": "1.8.0 Build 230921 Rel.091519", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -114, + "jamming_rssi": -113, "jamming_signal_level": 1, "lastOnboardingTimestamp": 1724637369, "mac": "202351000000", @@ -57,7 +88,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Australia/Canberra", "report_interval": 16, - "rssi": -50, + "rssi": -44, "signal_level": 3, "specs": "US", "status": "online", @@ -74,7 +105,7 @@ "fw_ver": "1.9.0 Build 230704 Rel.154559", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -116, + "jamming_rssi": -113, "jamming_signal_level": 1, "lastOnboardingTimestamp": 1724635267, "mac": "A86E84000000", @@ -85,7 +116,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Australia/Canberra", "report_interval": 16, - "rssi": -55, + "rssi": -56, "signal_level": 3, "specs": "US", "status": "online", @@ -101,7 +132,7 @@ "fw_ver": "1.12.0 Build 231121 Rel.092508", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -114, + "jamming_rssi": -112, "jamming_signal_level": 1, "lastOnboardingTimestamp": 1724636047, "mac": "3C52A1000000", @@ -111,7 +142,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Australia/Canberra", "report_interval": 16, - "rssi": -38, + "rssi": -36, "signal_level": 3, "specs": "US", "status": "online", @@ -127,8 +158,8 @@ "fw_ver": "1.12.0 Build 231121 Rel.092508", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -104, - "jamming_signal_level": 2, + "jamming_rssi": -113, + "jamming_signal_level": 1, "lastOnboardingTimestamp": 1724636886, "mac": "98254A000000", "model": "S200B", @@ -137,7 +168,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Australia/Canberra", "report_interval": 16, - "rssi": -36, + "rssi": -56, "signal_level": 3, "specs": "US", "status": "online", @@ -155,6 +186,14 @@ } } }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-11-01 22:16:12", + "seconds_from_1970": 1730459772 + } + } + }, "getConnectionType": { "link_type": "ethernet" }, @@ -168,7 +207,7 @@ "device_alias": "#MASKED_NAME#", "device_info": "H200 1.0", "device_model": "H200", - "device_name": "0000 0.0", + "device_name": "#MASKED_NAME#", "device_type": "SMART.TAPOHUB", "has_set_location_info": 1, "hw_id": "00000000000000000000000000000000", @@ -192,7 +231,7 @@ "device_alias": "#MASKED_NAME#", "device_info": "H200 1.0", "device_model": "H200", - "device_name": "0000 0.0", + "device_name": "#MASKED_NAME#", "device_type": "SMART.TAPOHUB", "has_set_location_info": 1, "hw_id": "00000000000000000000000000000000", @@ -210,22 +249,6 @@ } } }, - "getTimezone": { - "system": { - "basic": { - "zone_id": "Australia/Canberra", - "timezone": "UTC+10:00" - } - } - }, - "getClockStatus": { - "system": { - "clock_status": { - "seconds_from_1970": 1729509322, - "local_time": "2024-10-21 22:15:22" - } - } - }, "getFirmwareAutoUpgradeConfig": { "auto_upgrade": { "common": { @@ -304,5 +327,13 @@ "Connection 1", "Connection 2" ] + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+10:00", + "zone_id": "Australia/Canberra" + } + } } } From 8969b54b87bed8c7d3823b23528e9531133a4439 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 1 Nov 2024 16:17:52 +0100 Subject: [PATCH 646/892] Update TC65 fixture (#1225) --- .../fixtures/smartcamera/TC65_1.0_1.3.9.json | 142 +++++++++++++++++- 1 file changed, 135 insertions(+), 7 deletions(-) diff --git a/kasa/tests/fixtures/smartcamera/TC65_1.0_1.3.9.json b/kasa/tests/fixtures/smartcamera/TC65_1.0_1.3.9.json index 04f5354d0..5b05a1b3d 100644 --- a/kasa/tests/fixtures/smartcamera/TC65_1.0_1.3.9.json +++ b/kasa/tests/fixtures/smartcamera/TC65_1.0_1.3.9.json @@ -5,6 +5,8 @@ "connect_type": "wireless", "device_id": "0000000000000000000000000000000000000000", "http_port": 443, + "last_alarm_time": "1698149810", + "last_alarm_type": "motion", "owner": "00000000000000000000000000000000", "sd_status": "offline" }, @@ -40,6 +42,132 @@ } } }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 4 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 4 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 1 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 2 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "intrusionDetection", + "version": 2 + }, + { + "name": "linecrossingDetection", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + } + ] + } + }, "getAudioConfig": { "audio_config": { "microphone": { @@ -71,15 +199,15 @@ "getClockStatus": { "system": { "clock_status": { - "local_time": "2024-10-27 16:56:20", - "seconds_from_1970": 1730044580 + "local_time": "2024-11-01 16:10:28", + "seconds_from_1970": 1730473828 } } }, "getConnectionType": { "link_type": "wifi", "rssi": "3", - "rssiValue": -57, + "rssiValue": -58, "ssid": "I01BU0tFRF9TU0lEIw==" }, "getDetectionConfig": { @@ -96,7 +224,7 @@ "getDeviceInfo": { "device_info": { "basic_info": { - "avatar": "Baby room", + "avatar": "room", "barcode": "", "dev_id": "0000000000000000000000000000000000000000", "device_alias": "#MASKED_NAME#", @@ -140,8 +268,8 @@ "getLastAlarmInfo": { "system": { "last_alarm_info": { - "last_alarm_time": "", - "last_alarm_type": "" + "last_alarm_time": "1698149810", + "last_alarm_type": "motion" } } }, @@ -275,7 +403,7 @@ "chn1_msg_push_info": { ".name": "chn1_msg_push_info", ".type": "on_off", - "notification_enabled": "off", + "notification_enabled": "on", "rich_notification_enabled": "off" } } From 70c96b5a5d7bcced7e2be9f916842478154e63ad Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 1 Nov 2024 16:36:09 +0100 Subject: [PATCH 647/892] Initial trigger logs implementation (#900) Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --- kasa/cli/device.py | 10 +++++++++ kasa/module.py | 1 + kasa/smart/modules/__init__.py | 2 ++ kasa/smart/modules/triggerlogs.py | 34 +++++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+) create mode 100644 kasa/smart/modules/triggerlogs.py diff --git a/kasa/cli/device.py b/kasa/cli/device.py index 4a933b874..9814108c6 100644 --- a/kasa/cli/device.py +++ b/kasa/cli/device.py @@ -193,3 +193,13 @@ async def update_credentials(dev, username, password): click.confirm("Do you really want to replace the existing credentials?", abort=True) return await dev.update_credentials(username, password) + + +@device.command(name="logs") +@pass_dev_or_child +async def child_logs(dev): + """Print child device trigger logs.""" + if logs := dev.modules.get(Module.TriggerLogs): + await dev.update(update_children=True) + for entry in logs.logs: + print(entry) diff --git a/kasa/module.py b/kasa/module.py index e10b2d632..646755e59 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -127,6 +127,7 @@ class Module(ABC): WaterleakSensor: Final[ModuleName[smart.WaterleakSensor]] = ModuleName( "WaterleakSensor" ) + TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs") # SMARTCAMERA only modules Camera: Final[ModuleName[experimental.Camera]] = ModuleName("Camera") diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 24d5749e6..d1b8dc0bf 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -27,6 +27,7 @@ from .temperaturecontrol import TemperatureControl from .temperaturesensor import TemperatureSensor from .time import Time +from .triggerlogs import TriggerLogs from .waterleaksensor import WaterleakSensor __all__ = [ @@ -56,6 +57,7 @@ "WaterleakSensor", "ContactSensor", "MotionSensor", + "TriggerLogs", "FrostProtection", "SmartLightEffect", ] diff --git a/kasa/smart/modules/triggerlogs.py b/kasa/smart/modules/triggerlogs.py new file mode 100644 index 000000000..480c72f5e --- /dev/null +++ b/kasa/smart/modules/triggerlogs.py @@ -0,0 +1,34 @@ +"""Implementation of trigger logs module.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic.v1 import BaseModel, Field, parse_obj_as + +from ..smartmodule import SmartModule + + +class LogEntry(BaseModel): + """Presentation of a single log entry.""" + + id: int + event_id: str = Field(alias="eventId") + timestamp: datetime + event: str + + +class TriggerLogs(SmartModule): + """Implementation of trigger logs.""" + + REQUIRED_COMPONENT = "trigger_log" + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {"get_trigger_logs": {"start_id": 0}} + + @property + def logs(self) -> list[LogEntry]: + """Return logs.""" + return parse_obj_as(list[LogEntry], self.data["logs"]) From 77b654a9aa00ef8f5030fb86ebe23ef5bad53420 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 1 Nov 2024 18:17:18 +0000 Subject: [PATCH 648/892] Update try_connect_all to be more efficient and report attempts (#1222) --- kasa/__init__.py | 3 ++- kasa/cli/discover.py | 13 ++++++++++-- kasa/device_factory.py | 8 +++++--- kasa/discover.py | 46 ++++++++++++++++++++++++++++++++++-------- kasa/tests/test_cli.py | 10 ++++++++- 5 files changed, 65 insertions(+), 15 deletions(-) diff --git a/kasa/__init__.py b/kasa/__init__.py index 11000419c..a74cb4c41 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -41,7 +41,7 @@ _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 ) from kasa.module import Module -from kasa.protocol import BaseProtocol +from kasa.protocol import BaseProtocol, BaseTransport from kasa.smartprotocol import SmartProtocol __version__ = version("python-kasa") @@ -50,6 +50,7 @@ __all__ = [ "Discover", "BaseProtocol", + "BaseTransport", "IotProtocol", "SmartProtocol", "LightState", diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index 7989dbb1b..6a55cb432 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -15,7 +15,7 @@ Discover, UnsupportedDeviceError, ) -from kasa.discover import DiscoveryResult +from kasa.discover import ConnectAttempt, DiscoveryResult from .common import echo, error @@ -165,8 +165,17 @@ async def config(ctx): credentials = Credentials(username, password) if username and password else None + host_port = host + (f":{port}" if port else "") + + def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None: + prot, tran, dev = connect_attempt + key_str = f"{prot.__name__} + {tran.__name__} + {dev.__name__}" + result = "succeeded" if success else "failed" + msg = f"Attempt to connect to {host_port} with {key_str} {result}" + echo(msg) + dev = await Discover.try_connect_all( - host, credentials=credentials, timeout=timeout, port=port + host, credentials=credentials, timeout=timeout, port=port, on_attempt=on_attempt ) if dev: cparams = dev.config.connection_type diff --git a/kasa/device_factory.py b/kasa/device_factory.py index d7b778437..7f2150d7c 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -167,7 +167,7 @@ def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]: def get_device_class_from_family( - device_type: str, *, https: bool + device_type: str, *, https: bool, require_exact: bool = False ) -> type[Device] | None: """Return the device class from the type name.""" supported_device_types: dict[str, type[Device]] = { @@ -185,8 +185,10 @@ def get_device_class_from_family( } lookup_key = f"{device_type}{'.HTTPS' if https else ''}" if ( - cls := supported_device_types.get(lookup_key) - ) is None and device_type.startswith("SMART."): + (cls := supported_device_types.get(lookup_key)) is None + and device_type.startswith("SMART.") + and not require_exact + ): _LOGGER.warning("Unknown SMART device with %s, using SmartDevice", device_type) cls = SmartDevice diff --git a/kasa/discover.py b/kasa/discover.py index 3b8f7c448..a774ebdea 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -91,7 +91,7 @@ import struct from collections.abc import Awaitable from pprint import pformat as pf -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, cast +from typing import TYPE_CHECKING, Any, Callable, Dict, NamedTuple, Optional, Type, cast from aiohttp import ClientSession @@ -118,6 +118,7 @@ TimeoutError, UnsupportedDeviceError, ) +from kasa.experimental import Experimental from kasa.iot.iotdevice import IotDevice from kasa.iotprotocol import REDACTORS as IOT_REDACTORS from kasa.json import dumps as json_dumps @@ -127,9 +128,21 @@ _LOGGER = logging.getLogger(__name__) +if TYPE_CHECKING: + from kasa import BaseProtocol, BaseTransport + + +class ConnectAttempt(NamedTuple): + """Try to connect attempt.""" + + protocol: type + transport: type + device: type + OnDiscoveredCallable = Callable[[Device], Awaitable[None]] OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Awaitable[None]] +OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None] DeviceDict = Dict[str, Device] NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = { @@ -535,6 +548,7 @@ async def try_connect_all( timeout: int | None = None, credentials: Credentials | None = None, http_client: ClientSession | None = None, + on_attempt: OnConnectAttemptCallable | None = None, ) -> Device | None: """Try to connect directly to a device with all possible parameters. @@ -551,13 +565,22 @@ async def try_connect_all( """ from .device_factory import _connect - candidates = { + main_device_families = { + Device.Family.SmartTapoPlug, + Device.Family.IotSmartPlugSwitch, + } + if Experimental.enabled(): + main_device_families.add(Device.Family.SmartIpCamera) + candidates: dict[ + tuple[type[BaseProtocol], type[BaseTransport], type[Device]], + tuple[BaseProtocol, DeviceConfig], + ] = { (type(protocol), type(protocol._transport), device_class): ( protocol, config, ) for encrypt in Device.EncryptionType - for device_family in Device.Family + for device_family in main_device_families for https in (True, False) if ( conn_params := DeviceConnectionParameters( @@ -580,19 +603,26 @@ async def try_connect_all( and (protocol := get_protocol(config)) and ( device_class := get_device_class_from_family( - device_family.value, https=https + device_family.value, https=https, require_exact=True ) ) } - for protocol, config in candidates.values(): + for key, val in candidates.items(): try: - dev = await _connect(config, protocol) + prot, config = val + dev = await _connect(config, prot) except Exception: - _LOGGER.debug("Unable to connect with %s", protocol) + _LOGGER.debug("Unable to connect with %s", prot) + if on_attempt: + ca = tuple.__new__(ConnectAttempt, key) + on_attempt(ca, False) else: + if on_attempt: + ca = tuple.__new__(ConnectAttempt, key) + on_attempt(ca, True) return dev finally: - await protocol.close() + await prot.close() return None @staticmethod diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 80b5daaf7..7a0b0ddee 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -1162,7 +1162,7 @@ async def test_cli_child_commands( async def test_discover_config(dev: Device, mocker, runner): """Test that device config is returned.""" host = "127.0.0.1" - mocker.patch("kasa.discover.Discover.try_connect_all", return_value=dev) + mocker.patch("kasa.device_factory._connect", side_effect=[Exception, dev]) res = await runner.invoke( cli, @@ -1182,6 +1182,14 @@ async def test_discover_config(dev: Device, mocker, runner): cparam = dev.config.connection_type expected = f"--device-family {cparam.device_family.value} --encrypt-type {cparam.encryption_type.value} {'--https' if cparam.https else '--no-https'}" assert expected in res.output + assert re.search( + r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ failed", + res.output.replace("\n", ""), + ) + assert re.search( + r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ succeeded", + res.output.replace("\n", ""), + ) async def test_discover_config_invalid(mocker, runner): From 0360107e3ffa649083073444327ac451d1a3e8f7 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 1 Nov 2024 20:46:36 +0100 Subject: [PATCH 649/892] Add childprotection module (#1141) When turned on, rotating the thermostat will not change the target temperature. --- kasa/module.py | 3 ++ kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/childprotection.py | 41 ++++++++++++++++++ kasa/tests/fakeprotocol_smart.py | 14 +++++- .../smart/modules/test_childprotection.py | 43 +++++++++++++++++++ 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 kasa/smart/modules/childprotection.py create mode 100644 kasa/tests/smart/modules/test_childprotection.py diff --git a/kasa/module.py b/kasa/module.py index 646755e59..8b68881ea 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -127,6 +127,9 @@ class Module(ABC): WaterleakSensor: Final[ModuleName[smart.WaterleakSensor]] = ModuleName( "WaterleakSensor" ) + ChildProtection: Final[ModuleName[smart.ChildProtection]] = ModuleName( + "ChildProtection" + ) TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs") # SMARTCAMERA only modules diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index d1b8dc0bf..efe17aa4c 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -6,6 +6,7 @@ from .batterysensor import BatterySensor from .brightness import Brightness from .childdevice import ChildDevice +from .childprotection import ChildProtection from .cloud import Cloud from .color import Color from .colortemperature import ColorTemperature @@ -40,6 +41,7 @@ "HumiditySensor", "TemperatureSensor", "TemperatureControl", + "ChildProtection", "ReportMode", "AutoOff", "Led", diff --git a/kasa/smart/modules/childprotection.py b/kasa/smart/modules/childprotection.py new file mode 100644 index 000000000..d9670a234 --- /dev/null +++ b/kasa/smart/modules/childprotection.py @@ -0,0 +1,41 @@ +"""Child lock module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class ChildProtection(SmartModule): + """Implementation for child_protection.""" + + REQUIRED_COMPONENT = "child_protection" + QUERY_GETTER_NAME = "get_child_protection" + + def _initialize_features(self): + """Initialize features after the initial update.""" + self._add_feature( + Feature( + device=self._device, + id="child_lock", + name="Child lock", + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Config, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def enabled(self) -> bool: + """Return True if child protection is enabled.""" + return self.data["child_protection"] + + async def set_enabled(self, enabled: bool) -> dict: + """Set child protection.""" + return await self.call("set_child_protection", {"enable": enabled}) diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index c5a7c11e0..2deebf90b 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -430,6 +430,16 @@ def _edit_preset_rules(self, info, params): info["get_preset_rules"]["states"][params["index"]] = params["state"] return {"error_code": 0} + def _update_sysinfo_key(self, info: dict, key: str, value: str) -> dict: + """Update a single key in the main system info. + + This is used to implement child device setters that change the main sysinfo state. + """ + sys_info = info.get("get_device_info", info) + sys_info[key] = value + + return {"error_code": 0} + async def _send_request(self, request_dict: dict): method = request_dict["method"] @@ -437,7 +447,7 @@ async def _send_request(self, request_dict: dict): if method == "control_child": return await self._handle_control_child(request_dict["params"]) - params = request_dict.get("params") + params = request_dict.get("params", {}) if method == "component_nego" or method[:4] == "get_": if method in info: result = copy.deepcopy(info[method]) @@ -518,6 +528,8 @@ async def _send_request(self, request_dict: dict): return self._edit_preset_rules(info, params) elif method == "set_on_off_gradually_info": return self._set_on_off_gradually_info(info, params) + elif method == "set_child_protection": + return self._update_sysinfo_key(info, "child_protection", params["enable"]) elif method[:4] == "set_": target_method = f"get_{method[4:]}" info[target_method].update(params) diff --git a/kasa/tests/smart/modules/test_childprotection.py b/kasa/tests/smart/modules/test_childprotection.py new file mode 100644 index 000000000..c8fce03ec --- /dev/null +++ b/kasa/tests/smart/modules/test_childprotection.py @@ -0,0 +1,43 @@ +import pytest + +from kasa import Module +from kasa.smart.modules import ChildProtection +from kasa.tests.device_fixtures import parametrize + +child_protection = parametrize( + "has child protection", + component_filter="child_protection", + protocol_filter={"SMART.CHILD"}, +) + + +@child_protection +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("child_lock", "enabled", bool), + ], +) +async def test_features(dev, feature, prop_name, type): + """Test that features are registered and work as expected.""" + protect: ChildProtection = dev.modules[Module.ChildProtection] + assert protect is not None + + prop = getattr(protect, prop_name) + assert isinstance(prop, type) + + feat = protect._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@child_protection +async def test_enabled(dev): + """Test the API.""" + protect: ChildProtection = dev.modules[Module.ChildProtection] + assert protect is not None + + assert isinstance(protect.enabled, bool) + await protect.set_enabled(False) + await dev.update() + assert protect.enabled is False From b2f3971a4cbbb9bfeec87b0e2468f50999d16365 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sun, 3 Nov 2024 16:45:48 +0100 Subject: [PATCH 650/892] Add PIR&LAS for wall switches mentioning PIR support (#1227) Some devices (like KS200M) support ambient and motion, but as they are detected as wall switches, they don't get the modules added. This PR enables the respective modules for wall switches when the `dev_name` contains `PIR`. --- kasa/iot/iotplug.py | 11 ++++++++++- kasa/tests/iot/test_wallswitch.py | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/iot/test_wallswitch.py diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 89cfef958..3a1193181 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -9,7 +9,7 @@ from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, requires_update -from .modules import Antitheft, Cloud, Led, Schedule, Time, Usage +from .modules import AmbientLight, Antitheft, Cloud, Led, Motion, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) @@ -92,3 +92,12 @@ def __init__( ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.WallSwitch + + async def _initialize_modules(self) -> None: + """Initialize modules.""" + await super()._initialize_modules() + if (dev_name := self.sys_info["dev_name"]) and "PIR" in dev_name: + self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR")) + self.add_module( + Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS") + ) diff --git a/kasa/tests/iot/test_wallswitch.py b/kasa/tests/iot/test_wallswitch.py new file mode 100644 index 000000000..07f5976d9 --- /dev/null +++ b/kasa/tests/iot/test_wallswitch.py @@ -0,0 +1,9 @@ +from kasa.tests.device_fixtures import wallswitch_iot + + +@wallswitch_iot +def test_wallswitch_motion(dev): + """Check that wallswitches with motion sensor get modules enabled.""" + has_motion = "PIR" in dev.sys_info["dev_name"] + assert "motion" in dev.modules if has_motion else True + assert "ambient" in dev.modules if has_motion else True From 4640dfaedc464b44772d6497de4091b94cdf0a1e Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Mon, 4 Nov 2024 11:24:58 +0100 Subject: [PATCH 651/892] parse_pcap_klap: various code cleanups (#1138) Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- devtools/parse_pcap_klap.py | 133 ++++++++++++++++++------------------ 1 file changed, 65 insertions(+), 68 deletions(-) diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index b291b0d43..640c7aef0 100755 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -29,6 +29,18 @@ from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials +def _get_seq_from_query(packet): + """Return sequence number for the query.""" + query = packet.http.get("request_uri_query") + if query is None: + raise Exception("No request_uri_query found") + # use regex to get: seq=(\d+) + seq = re.search(r"seq=(\d+)", query) + if seq is not None: + return int(seq.group(1)) + raise Exception("Unable to find sequence number") + + def _is_http_response_for_packet(response, packet): """Return True if the *response* contains a response for request in *packet*. @@ -41,10 +53,7 @@ def _is_http_response_for_packet(response, packet): ): return True # tshark 4.4.0 - if response.http.request_uri == packet.http.request_uri: - return True - - return False + return response.http.request_uri == packet.http.request_uri class MyEncryptionSession(KlapEncryptionSession): @@ -244,71 +253,59 @@ def main( if packet.ip.src != source_host: continue # we only care about http packets - if hasattr( - packet, "http" - ): # this is redundant, as pyshark is set to only load http packets - if hasattr(packet.http, "request_uri_path"): - uri = packet.http.get("request_uri_path") - elif hasattr(packet.http, "request_uri"): - uri = packet.http.get("request_uri") - else: - uri = None - if hasattr(packet.http, "request_uri_query"): - query = packet.http.get("request_uri_query") - # use regex to get: seq=(\d+) - seq = re.search(r"seq=(\d+)", query) - if seq is not None: - operator.seq = int( - seq.group(1) - ) # grab the sequence number from the query - data = ( - # Windows and linux file_data attribute returns different - # pretty format so get the raw field value. - packet.http.get_field_value("file_data", raw=True) - if hasattr(packet.http, "file_data") - else None - ) - match uri: - case "/app/request": - if packet.ip.dst != device_ip: - continue - assert isinstance(data, str) # noqa: S101 - message = bytes.fromhex(data) - try: - plaintext = operator.decrypt(message) - payload = json.loads(plaintext) - print(json.dumps(payload, indent=2)) - packets.append(payload) - except ValueError: - print("Insufficient data to decrypt thus far") - - case "/app/handshake1": - if packet.ip.dst != device_ip: - continue - assert isinstance(data, str) # noqa: S101 - message = bytes.fromhex(data) - operator.local_seed = message - response = None - print( - f"got handshake1 in {packet_number}, " - f"looking for the response" - ) - while ( - True - ): # we are going to now look for the response to this request - response = capture.next() - if _is_http_response_for_packet(response, packet): - print(f"found response in {packet_number}") - break - data = response.http.get_field_value("file_data", raw=True) - message = bytes.fromhex(data) - operator.remote_seed = message[0:16] - operator.remote_auth_hash = message[16:] - - case "/app/handshake2": - continue # we don't care about this - case _: + # this is redundant, as pyshark is set to only load http packets + if not hasattr(packet, "http"): + continue + + uri = packet.http.get("request_uri_path", packet.http.get("request_uri")) + if uri is None: + continue + + operator.seq = _get_seq_from_query(packet) + + # Windows and linux file_data attribute returns different + # pretty format so get the raw field value. + data = packet.http.get_field_value("file_data", raw=True) + + match uri: + case "/app/request": + if packet.ip.dst != device_ip: + continue + message = bytes.fromhex(data) + try: + plaintext = operator.decrypt(message) + payload = json.loads(plaintext) + print(json.dumps(payload, indent=2)) + packets.append(payload) + except ValueError: + print("Insufficient data to decrypt thus far") + + case "/app/handshake1": + if packet.ip.dst != device_ip: continue + message = bytes.fromhex(data) + operator.local_seed = message + response = None + print( + f"got handshake1 in {packet_number}, " + f"looking for the response" + ) + while ( + True + ): # we are going to now look for the response to this request + response = capture.next() + if _is_http_response_for_packet(response, packet): + print(f"found response in {packet_number}") + break + data = response.http.get_field_value("file_data", raw=True) + message = bytes.fromhex(data) + operator.remote_seed = message[0:16] + operator.remote_auth_hash = message[16:] + + case "/app/handshake2": + continue # we don't care about this + case _: + continue except StopIteration: break From 331baf6bc47808548a5743f49d6b2d2b84128d77 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 4 Nov 2024 15:57:43 +0000 Subject: [PATCH 652/892] Prepare 0.7.7 (#1229) ## [0.7.7](https://github.com/python-kasa/python-kasa/tree/0.7.7) (2024-11-04) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.6...0.7.7) **Release summary:** - Bugfix for child device device creation error with credentials_hash - PIR support for iot dimmers and wall switches. - Various small enhancements and project improvements. **Implemented enhancements:** - Add PIR&LAS for wall switches mentioning PIR support [\#1227](https://github.com/python-kasa/python-kasa/pull/1227) (@rytilahti) - Expose ambient light setting for iot dimmers [\#1210](https://github.com/python-kasa/python-kasa/pull/1210) (@rytilahti) - Expose PIR enabled setting for iot dimmers [\#1174](https://github.com/python-kasa/python-kasa/pull/1174) (@rytilahti) - Add childprotection module [\#1141](https://github.com/python-kasa/python-kasa/pull/1141) (@rytilahti) - Initial trigger logs implementation [\#900](https://github.com/python-kasa/python-kasa/pull/900) (@rytilahti) **Fixed bugs:** - Fix AES child device creation error [\#1220](https://github.com/python-kasa/python-kasa/pull/1220) (@sdb9696) **Project maintenance:** - Update TC65 fixture [\#1225](https://github.com/python-kasa/python-kasa/pull/1225) (@rytilahti) - Update smartcamera fixtures from latest dump\_devinfo [\#1224](https://github.com/python-kasa/python-kasa/pull/1224) (@sdb9696) - Add component queries to smartcamera devices [\#1223](https://github.com/python-kasa/python-kasa/pull/1223) (@sdb9696) - Update try\_connect\_all to be more efficient and report attempts [\#1222](https://github.com/python-kasa/python-kasa/pull/1222) (@sdb9696) - Use stacklevel=2 for warnings to report on callsites [\#1219](https://github.com/python-kasa/python-kasa/pull/1219) (@rytilahti) - parse\_pcap\_klap: various code cleanups [\#1138](https://github.com/python-kasa/python-kasa/pull/1138) (@rytilahti) --- CHANGELOG.md | 33 +++++- RELEASING.md | 1 + pyproject.toml | 2 +- uv.lock | 284 ++++++++++++++++++++++++------------------------- 4 files changed, 176 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d2120d6..86c3aa84c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## [0.7.7](https://github.com/python-kasa/python-kasa/tree/0.7.7) (2024-11-04) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.6...0.7.7) + +**Release summary:** + +- Bugfix for child device device creation error with credentials_hash +- PIR support for iot dimmers and wall switches. +- Various small enhancements and project improvements. + +**Implemented enhancements:** + +- Add PIR&LAS for wall switches mentioning PIR support [\#1227](https://github.com/python-kasa/python-kasa/pull/1227) (@rytilahti) +- Expose ambient light setting for iot dimmers [\#1210](https://github.com/python-kasa/python-kasa/pull/1210) (@rytilahti) +- Expose PIR enabled setting for iot dimmers [\#1174](https://github.com/python-kasa/python-kasa/pull/1174) (@rytilahti) +- Add childprotection module [\#1141](https://github.com/python-kasa/python-kasa/pull/1141) (@rytilahti) +- Initial trigger logs implementation [\#900](https://github.com/python-kasa/python-kasa/pull/900) (@rytilahti) + +**Fixed bugs:** + +- Fix AES child device creation error [\#1220](https://github.com/python-kasa/python-kasa/pull/1220) (@sdb9696) + +**Project maintenance:** + +- Update TC65 fixture [\#1225](https://github.com/python-kasa/python-kasa/pull/1225) (@rytilahti) +- Update smartcamera fixtures from latest dump\_devinfo [\#1224](https://github.com/python-kasa/python-kasa/pull/1224) (@sdb9696) +- Add component queries to smartcamera devices [\#1223](https://github.com/python-kasa/python-kasa/pull/1223) (@sdb9696) +- Update try\_connect\_all to be more efficient and report attempts [\#1222](https://github.com/python-kasa/python-kasa/pull/1222) (@sdb9696) +- Use stacklevel=2 for warnings to report on callsites [\#1219](https://github.com/python-kasa/python-kasa/pull/1219) (@rytilahti) +- parse\_pcap\_klap: various code cleanups [\#1138](https://github.com/python-kasa/python-kasa/pull/1138) (@rytilahti) + ## [0.7.6](https://github.com/python-kasa/python-kasa/tree/0.7.6) (2024-10-29) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.5...0.7.6) @@ -45,7 +76,7 @@ **Project maintenance:** -- Fix mypy errors in parse_pcap_klap [\#1214](https://github.com/python-kasa/python-kasa/pull/1214) (@sdb9696) +- Fix mypy errors in parse\_pcap\_klap [\#1214](https://github.com/python-kasa/python-kasa/pull/1214) (@sdb9696) - Make HSV NamedTuple creation more efficient [\#1211](https://github.com/python-kasa/python-kasa/pull/1211) (@sdb9696) - dump\_devinfo: query get\_current\_brt for iot dimmers [\#1209](https://github.com/python-kasa/python-kasa/pull/1209) (@rytilahti) - Add trigger\_logs and double\_click to dump\_devinfo helper [\#1208](https://github.com/python-kasa/python-kasa/pull/1208) (@sdb9696) diff --git a/RELEASING.md b/RELEASING.md index 62305c755..032aeb0c5 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -41,6 +41,7 @@ sed -i "0,/version = /{s/version = .*/version = \"${NEW_RELEASE}\"/}" pyproject. ```bash uv sync --all-extras uv lock --upgrade +uv sync --all-extras ``` ### Run pre-commit and tests diff --git a/pyproject.toml b/pyproject.toml index 33b441f2e..c2ad3a365 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-kasa" -version = "0.7.6" +version = "0.7.7" description = "Python API for TP-Link Kasa and Tapo devices" license = {text = "GPL-3.0-or-later"} authors = [ { name = "python-kasa developers" }] diff --git a/uv.lock b/uv.lock index 39eeb63c2..27a1100a5 100644 --- a/uv.lock +++ b/uv.lock @@ -1015,57 +1015,57 @@ wheels = [ [[package]] name = "orjson" -version = "3.10.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/44/d36e86b33fc84f224b5f2cdf525adf3b8f9f475753e721c402b1ddef731e/orjson-3.10.10.tar.gz", hash = "sha256:37949383c4df7b4337ce82ee35b6d7471e55195efa7dcb45ab8226ceadb0fe3b", size = 5404170 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/c7/07ca73c32d49550490572235e5000aa0d75e333997cbb3a221890ef0fa04/orjson-3.10.10-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b788a579b113acf1c57e0a68e558be71d5d09aa67f62ca1f68e01117e550a998", size = 270718 }, - { url = "https://files.pythonhosted.org/packages/4d/6e/eaefdfe4b11fd64b38f6663c71a3c9063054c8c643a52555c5b6d4350446/orjson-3.10.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:804b18e2b88022c8905bb79bd2cbe59c0cd014b9328f43da8d3b28441995cda4", size = 153292 }, - { url = "https://files.pythonhosted.org/packages/cf/87/94474cbf63306f196a0a85a2f3ea6cea261328b4141a260b7ec5e7145bc5/orjson-3.10.10-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9972572a1d042ec9ee421b6da69f7cc823da5962237563fa548ab17f152f0b9b", size = 168625 }, - { url = "https://files.pythonhosted.org/packages/0a/67/1a6bd763282bc89fcc0269e3a44a8ecbb236a1e4b6f5a6320301726b36a1/orjson-3.10.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc6993ab1c2ae7dd0711161e303f1db69062955ac2668181bfdf2dd410e65258", size = 155845 }, - { url = "https://files.pythonhosted.org/packages/ae/28/bb2dd7a988159896be9fa59ef4c991dca8cca9af85ebdc27164234929008/orjson-3.10.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d78e4cacced5781b01d9bc0f0cd8b70b906a0e109825cb41c1b03f9c41e4ce86", size = 166406 }, - { url = "https://files.pythonhosted.org/packages/e3/88/42199849c791b4b5b92fcace0e8ef178d5ae1ea9865dfd4d5809e650d652/orjson-3.10.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6eb2598df518281ba0cbc30d24c5b06124ccf7e19169e883c14e0831217a0bc", size = 144518 }, - { url = "https://files.pythonhosted.org/packages/c7/77/e684fe4ed34e73149bc0e7320b91a459386693279cd62efab6e82da072a3/orjson-3.10.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23776265c5215ec532de6238a52707048401a568f0fa0d938008e92a147fe2c7", size = 172184 }, - { url = "https://files.pythonhosted.org/packages/fa/b2/9dc2ed13121b27b9f99acba077c821ad2c0deff9feeb617efef4699fad35/orjson-3.10.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cc2a654c08755cef90b468ff17c102e2def0edd62898b2486767204a7f5cc9c", size = 170148 }, - { url = "https://files.pythonhosted.org/packages/86/0a/b06967f9374856f491f297a914c588eae97ef9490a77ec0e146a2e4bfe7f/orjson-3.10.10-cp310-none-win32.whl", hash = "sha256:081b3fc6a86d72efeb67c13d0ea7c030017bd95f9868b1e329a376edc456153b", size = 145116 }, - { url = "https://files.pythonhosted.org/packages/1f/c7/1aecf5e320828261ece0683e472ee77c520f4e6c52c468486862e2257962/orjson-3.10.10-cp310-none-win_amd64.whl", hash = "sha256:ff38c5fb749347768a603be1fb8a31856458af839f31f064c5aa74aca5be9efe", size = 139307 }, - { url = "https://files.pythonhosted.org/packages/79/bc/2a0eb0029729f1e466d5a595261446e5c5b6ed9213759ee56b6202f99417/orjson-3.10.10-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:879e99486c0fbb256266c7c6a67ff84f46035e4f8749ac6317cc83dacd7f993a", size = 270717 }, - { url = "https://files.pythonhosted.org/packages/3d/2b/5af226f183ce264bf64f15afe58647b09263dc1bde06aaadae6bbeca17f1/orjson-3.10.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:019481fa9ea5ff13b5d5d95e6fd5ab25ded0810c80b150c2c7b1cc8660b662a7", size = 153294 }, - { url = "https://files.pythonhosted.org/packages/1d/95/d6a68ab51ed76e3794669dabb51bf7fa6ec2f4745f66e4af4518aeab4b73/orjson-3.10.10-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0dd57eff09894938b4c86d4b871a479260f9e156fa7f12f8cad4b39ea8028bb5", size = 168628 }, - { url = "https://files.pythonhosted.org/packages/c0/c9/1bbe5262f5e9df3e1aeec44ca8cc86846c7afb2746fa76bf668a7d0979e9/orjson-3.10.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbde6d70cd95ab4d11ea8ac5e738e30764e510fc54d777336eec09bb93b8576c", size = 155845 }, - { url = "https://files.pythonhosted.org/packages/bf/22/e17b14ff74646e6c080dccb2859686a820bc6468f6b62ea3fe29a8bd3b05/orjson-3.10.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2625cb37b8fb42e2147404e5ff7ef08712099197a9cd38895006d7053e69d6", size = 166406 }, - { url = "https://files.pythonhosted.org/packages/8a/1e/b3abbe352f648f96a418acd1e602b1c77ffcc60cf801a57033da990b2c49/orjson-3.10.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbf3c20c6a7db69df58672a0d5815647ecf78c8e62a4d9bd284e8621c1fe5ccb", size = 144518 }, - { url = "https://files.pythonhosted.org/packages/0e/5e/28f521ee0950d279489db1522e7a2460d0596df7c5ca452e242ff1509cfe/orjson-3.10.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:75c38f5647e02d423807d252ce4528bf6a95bd776af999cb1fb48867ed01d1f6", size = 172187 }, - { url = "https://files.pythonhosted.org/packages/04/b4/538bf6f42eb0fd5a485abbe61e488d401a23fd6d6a758daefcf7811b6807/orjson-3.10.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23458d31fa50ec18e0ec4b0b4343730928296b11111df5f547c75913714116b2", size = 170152 }, - { url = "https://files.pythonhosted.org/packages/94/5c/a1a326a58452f9261972ad326ae3bb46d7945681239b7062a1b85d8811e2/orjson-3.10.10-cp311-none-win32.whl", hash = "sha256:2787cd9dedc591c989f3facd7e3e86508eafdc9536a26ec277699c0aa63c685b", size = 145116 }, - { url = "https://files.pythonhosted.org/packages/df/12/a02965df75f5a247091306d6cf40a77d20bf6c0490d0a5cb8719551ee815/orjson-3.10.10-cp311-none-win_amd64.whl", hash = "sha256:6514449d2c202a75183f807bc755167713297c69f1db57a89a1ef4a0170ee269", size = 139307 }, - { url = "https://files.pythonhosted.org/packages/21/c6/f1d2ec3ffe9d6a23a62af0477cd11dd2926762e0186a1fad8658a4f48117/orjson-3.10.10-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8564f48f3620861f5ef1e080ce7cd122ee89d7d6dacf25fcae675ff63b4d6e05", size = 270801 }, - { url = "https://files.pythonhosted.org/packages/52/01/eba0226efaa4d4be8e44d9685750428503a3803648878fa5607100a74f81/orjson-3.10.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5bf161a32b479034098c5b81f2608f09167ad2fa1c06abd4e527ea6bf4837a9", size = 153221 }, - { url = "https://files.pythonhosted.org/packages/da/4b/a705f9d3ae4786955ee0ac840b20960add357e612f1b0a54883d1811fe1a/orjson-3.10.10-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68b65c93617bcafa7f04b74ae8bc2cc214bd5cb45168a953256ff83015c6747d", size = 168590 }, - { url = "https://files.pythonhosted.org/packages/de/6c/eb405252e7d9ae9905a12bad582cfe37ef8ef18fdfee941549cb5834c7b2/orjson-3.10.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8e28406f97fc2ea0c6150f4c1b6e8261453318930b334abc419214c82314f85", size = 156052 }, - { url = "https://files.pythonhosted.org/packages/9f/e7/65a0461574078a38f204575153524876350f0865162faa6e6e300ecaa199/orjson-3.10.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4d0d9fe174cc7a5bdce2e6c378bcdb4c49b2bf522a8f996aa586020e1b96cee", size = 166562 }, - { url = "https://files.pythonhosted.org/packages/dd/99/85780be173e7014428859ba0211e6f2a8f8038ea6ebabe344b42d5daa277/orjson-3.10.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3be81c42f1242cbed03cbb3973501fcaa2675a0af638f8be494eaf37143d999", size = 144892 }, - { url = "https://files.pythonhosted.org/packages/ed/c0/c7c42a2daeb262da417f70064746b700786ee0811b9a5821d9d37543b29d/orjson-3.10.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65f9886d3bae65be026219c0a5f32dbbe91a9e6272f56d092ab22561ad0ea33b", size = 172093 }, - { url = "https://files.pythonhosted.org/packages/ad/9b/be8b3d3aec42aa47f6058482ace0d2ca3023477a46643d766e96281d5d31/orjson-3.10.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:730ed5350147db7beb23ddaf072f490329e90a1d059711d364b49fe352ec987b", size = 170424 }, - { url = "https://files.pythonhosted.org/packages/1b/15/a4cc61e23c39b9dec4620cb95817c83c84078be1771d602f6d03f0e5c696/orjson-3.10.10-cp312-none-win32.whl", hash = "sha256:a8f4bf5f1c85bea2170800020d53a8877812892697f9c2de73d576c9307a8a5f", size = 145132 }, - { url = "https://files.pythonhosted.org/packages/9f/8a/ce7c28e4ea337f6d95261345d7c61322f8561c52f57b263a3ad7025984f4/orjson-3.10.10-cp312-none-win_amd64.whl", hash = "sha256:384cd13579a1b4cd689d218e329f459eb9ddc504fa48c5a83ef4889db7fd7a4f", size = 139389 }, - { url = "https://files.pythonhosted.org/packages/0c/69/f1c4382cd44bdaf10006c4e82cb85d2bcae735369f84031e203c4e5d87de/orjson-3.10.10-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44bffae68c291f94ff5a9b4149fe9d1bdd4cd0ff0fb575bcea8351d48db629a1", size = 270695 }, - { url = "https://files.pythonhosted.org/packages/61/29/aeb5153271d4953872b06ed239eb54993a5f344353727c42d3aabb2046f6/orjson-3.10.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e27b4c6437315df3024f0835887127dac2a0a3ff643500ec27088d2588fa5ae1", size = 141632 }, - { url = "https://files.pythonhosted.org/packages/bc/a2/c8ac38d8fb461a9b717c766fbe1f7d3acf9bde2f12488eb13194960782e4/orjson-3.10.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca84df16d6b49325a4084fd8b2fe2229cb415e15c46c529f868c3387bb1339d", size = 144854 }, - { url = "https://files.pythonhosted.org/packages/79/51/e7698fdb28bdec633888cc667edc29fd5376fce9ade0a5b3e22f5ebe0343/orjson-3.10.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c14ce70e8f39bd71f9f80423801b5d10bf93d1dceffdecd04df0f64d2c69bc01", size = 172023 }, - { url = "https://files.pythonhosted.org/packages/02/2d/0d99c20878658c7e33b90e6a4bb75cf2924d6ff29c2365262cff3c26589a/orjson-3.10.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:24ac62336da9bda1bd93c0491eff0613003b48d3cb5d01470842e7b52a40d5b4", size = 170429 }, - { url = "https://files.pythonhosted.org/packages/cd/45/6a4a446f4fb29bb4703c3537d5c6a2bf7fed768cb4d7b7dce9d71b72fc93/orjson-3.10.10-cp313-none-win32.whl", hash = "sha256:eb0a42831372ec2b05acc9ee45af77bcaccbd91257345f93780a8e654efc75db", size = 145099 }, - { url = "https://files.pythonhosted.org/packages/72/6e/4631fe219a4203aa111e9bb763ad2e2e0cdd1a03805029e4da124d96863f/orjson-3.10.10-cp313-none-win_amd64.whl", hash = "sha256:f0c4f37f8bf3f1075c6cc8dd8a9f843689a4b618628f8812d0a71e6968b95ffd", size = 139176 }, - { url = "https://files.pythonhosted.org/packages/7b/3c/04294098b67d1cd93d56e23cee874fac4a8379943c5e556b7a922775e672/orjson-3.10.10-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5a059afddbaa6dd733b5a2d76a90dbc8af790b993b1b5cb97a1176ca713b5df8", size = 270518 }, - { url = "https://files.pythonhosted.org/packages/da/91/f021aa2eed9919f89ae2e4507e851fbbc8c5faef3fa79984549f415c7fa9/orjson-3.10.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f9b5c59f7e2a1a410f971c5ebc68f1995822837cd10905ee255f96074537ee6", size = 153116 }, - { url = "https://files.pythonhosted.org/packages/95/52/d4fc57145446c7d0cbf5cfdaceb0ea4d5f0636e7398de02e3abc3bf91341/orjson-3.10.10-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d5ef198bafdef4aa9d49a4165ba53ffdc0a9e1c7b6f76178572ab33118afea25", size = 168400 }, - { url = "https://files.pythonhosted.org/packages/cf/75/9b081915f083a10832f276d24babee910029ea42368486db9a81741b8dba/orjson-3.10.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf29ce0bb5d3320824ec3d1508652421000ba466abd63bdd52c64bcce9eb1fa", size = 155586 }, - { url = "https://files.pythonhosted.org/packages/90/c6/52ce917ea468ef564ec100e3f2164e548e61b4c71140c3e058a913bfea9b/orjson-3.10.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dddd5516bcc93e723d029c1633ae79c4417477b4f57dad9bfeeb6bc0315e654a", size = 166167 }, - { url = "https://files.pythonhosted.org/packages/dc/40/139fc90e69a8200e8d971c4dd0495ed2c7de6d8d9f70254d3324cb9be026/orjson-3.10.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12f2003695b10817f0fa8b8fca982ed7f5761dcb0d93cff4f2f9f6709903fd7", size = 144285 }, - { url = "https://files.pythonhosted.org/packages/54/d0/ff81ce26587459368a58ed772ce131938458c421b77fd0e74b1b11988f1e/orjson-3.10.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:672f9874a8a8fb9bb1b771331d31ba27f57702c8106cdbadad8bda5d10bc1019", size = 171917 }, - { url = "https://files.pythonhosted.org/packages/5e/5a/8c4b509288240f72f8a4a28bf0cc3f9df780c749a4ec57a588769bd0e8b9/orjson-3.10.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1dcbb0ca5fafb2b378b2c74419480ab2486326974826bbf6588f4dc62137570a", size = 169900 }, - { url = "https://files.pythonhosted.org/packages/15/7e/f593101ea030bb452a9c35e9098a3aabf18ce2c62165b2a098c6d7af802f/orjson-3.10.10-cp39-none-win32.whl", hash = "sha256:d9bbd3a4b92256875cb058c3381b782649b9a3c68a4aa9a2fff020c2f9cfc1be", size = 144977 }, - { url = "https://files.pythonhosted.org/packages/72/86/59b7ca088109e3403d493d4becb5430de3683fc2c6a5134e6d942e541dc8/orjson-3.10.10-cp39-none-win_amd64.whl", hash = "sha256:766f21487a53aee8524b97ca9582d5c6541b03ab6210fbaf10142ae2f3ced2aa", size = 139123 }, +version = "3.10.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/3a/10320029954badc7eaa338a15ee279043436f396e965dafc169610e4933f/orjson-3.10.11.tar.gz", hash = "sha256:e35b6d730de6384d5b2dab5fd23f0d76fae8bbc8c353c2f78210aa5fa4beb3ef", size = 5444879 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/63/f7d412e09f6e2c4e2562ddc44e86f2316a7ce9d7f353afa7cbce4f6a78d5/orjson-3.10.11-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6dade64687f2bd7c090281652fe18f1151292d567a9302b34c2dbb92a3872f1f", size = 266434 }, + { url = "https://files.pythonhosted.org/packages/a2/6a/3dfcd3a8c0e588581c8d1f3d9002cca970432da8a8096c1a42b99914a34d/orjson-3.10.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82f07c550a6ccd2b9290849b22316a609023ed851a87ea888c0456485a7d196a", size = 151884 }, + { url = "https://files.pythonhosted.org/packages/41/02/8981bc5ccbc04a2bd49cd86224d5b1e2c7417fb33e83590c66c3a028ede5/orjson-3.10.11-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd9a187742d3ead9df2e49240234d728c67c356516cf4db018833a86f20ec18c", size = 167371 }, + { url = "https://files.pythonhosted.org/packages/df/3f/772a12a417444eccc54fa597955b689848eb121d5e43dd7da9f6658c314d/orjson-3.10.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77b0fed6f209d76c1c39f032a70df2d7acf24b1812ca3e6078fd04e8972685a3", size = 154367 }, + { url = "https://files.pythonhosted.org/packages/8a/63/d0d6ba28410ec603fc31726a49dc782c72c0a64f4cd0a6734a6d8bc07a4a/orjson-3.10.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63fc9d5fe1d4e8868f6aae547a7b8ba0a2e592929245fff61d633f4caccdcdd6", size = 165726 }, + { url = "https://files.pythonhosted.org/packages/97/6e/d291bf382173af7788b368e4c22d02c7bdb9b7ac29b83e92930841321c16/orjson-3.10.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65cd3e3bb4fbb4eddc3c1e8dce10dc0b73e808fcb875f9fab40c81903dd9323e", size = 142522 }, + { url = "https://files.pythonhosted.org/packages/6d/3b/7364c10fcadf7c08e3658fe7103bf3b0408783f91022be4691fbe0b5ba1d/orjson-3.10.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6f67c570602300c4befbda12d153113b8974a3340fdcf3d6de095ede86c06d92", size = 146931 }, + { url = "https://files.pythonhosted.org/packages/95/8c/43f454e642cc85ef845cda6efcfddc6b5fe46b897b692412022012e1272c/orjson-3.10.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1f39728c7f7d766f1f5a769ce4d54b5aaa4c3f92d5b84817053cc9995b977acc", size = 142900 }, + { url = "https://files.pythonhosted.org/packages/bb/29/ca24efe043501b4a4584d728fdc65af5cfc070ab9021a07fb195bce98919/orjson-3.10.11-cp310-none-win32.whl", hash = "sha256:1789d9db7968d805f3d94aae2c25d04014aae3a2fa65b1443117cd462c6da647", size = 144456 }, + { url = "https://files.pythonhosted.org/packages/b7/ec/f15dc012928459cfb96ed86178d92fddb5c01847f2c53fd8be2fa62dee6c/orjson-3.10.11-cp310-none-win_amd64.whl", hash = "sha256:5576b1e5a53a5ba8f8df81872bb0878a112b3ebb1d392155f00f54dd86c83ff6", size = 136442 }, + { url = "https://files.pythonhosted.org/packages/1e/25/c869a1fbd481dcb02c70032fd6a7243de7582bc48c7cae03d6f0985a11c0/orjson-3.10.11-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1444f9cb7c14055d595de1036f74ecd6ce15f04a715e73f33bb6326c9cef01b6", size = 266432 }, + { url = "https://files.pythonhosted.org/packages/6a/a4/2307155ee92457d28345308f7d8c0e712348404723025613adeffcb531d0/orjson-3.10.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdec57fe3b4bdebcc08a946db3365630332dbe575125ff3d80a3272ebd0ddafe", size = 151884 }, + { url = "https://files.pythonhosted.org/packages/aa/82/daf1b2596dd49fe44a1bd92367568faf6966dcb5d7f99fd437c3d0dc2de6/orjson-3.10.11-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4eed32f33a0ea6ef36ccc1d37f8d17f28a1d6e8eefae5928f76aff8f1df85e67", size = 167371 }, + { url = "https://files.pythonhosted.org/packages/63/a8/680578e4589be5fdcfe0186bdd7dc6fe4a39d30e293a9da833cbedd5a56e/orjson-3.10.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80df27dd8697242b904f4ea54820e2d98d3f51f91e97e358fc13359721233e4b", size = 154368 }, + { url = "https://files.pythonhosted.org/packages/6e/ce/9cb394b5b01ef34579eeca6d704b21f97248f607067ce95a24ba9ea2698e/orjson-3.10.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:705f03cee0cb797256d54de6695ef219e5bc8c8120b6654dd460848d57a9af3d", size = 165725 }, + { url = "https://files.pythonhosted.org/packages/49/24/55eeb05cfb36b9e950d05743e6f6fdb7d5f33ca951a27b06ea6d03371aed/orjson-3.10.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03246774131701de8e7059b2e382597da43144a9a7400f178b2a32feafc54bd5", size = 142522 }, + { url = "https://files.pythonhosted.org/packages/94/0c/3a6a289e56dcc9fe67dc6b6d33c91dc5491f9ec4a03745efd739d2acf0ff/orjson-3.10.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8b5759063a6c940a69c728ea70d7c33583991c6982915a839c8da5f957e0103a", size = 146934 }, + { url = "https://files.pythonhosted.org/packages/1d/5c/a08c0e90a91e2526029a4681ff8c6fc4495b8bab77d48801144e378c7da9/orjson-3.10.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:677f23e32491520eebb19c99bb34675daf5410c449c13416f7f0d93e2cf5f981", size = 142904 }, + { url = "https://files.pythonhosted.org/packages/2c/c9/710286a60b14e88288ca014d43befb08bb0a4a6a0f51b875f8c2f05e8205/orjson-3.10.11-cp311-none-win32.whl", hash = "sha256:a11225d7b30468dcb099498296ffac36b4673a8398ca30fdaec1e6c20df6aa55", size = 144459 }, + { url = "https://files.pythonhosted.org/packages/7d/68/ef7b920e0a09e02b1a30daca1b4864938463797995c2fabe457c1500220a/orjson-3.10.11-cp311-none-win_amd64.whl", hash = "sha256:df8c677df2f9f385fcc85ab859704045fa88d4668bc9991a527c86e710392bec", size = 136444 }, + { url = "https://files.pythonhosted.org/packages/78/f2/a712dbcef6d84ff53e13056e7dc69d9d4844bd1e35e51b7431679ddd154d/orjson-3.10.11-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:360a4e2c0943da7c21505e47cf6bd725588962ff1d739b99b14e2f7f3545ba51", size = 266505 }, + { url = "https://files.pythonhosted.org/packages/94/54/53970831786d71f98fdc13c0f80451324c9b5c20fbf42f42ef6147607ee7/orjson-3.10.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:496e2cb45de21c369079ef2d662670a4892c81573bcc143c4205cae98282ba97", size = 151745 }, + { url = "https://files.pythonhosted.org/packages/35/38/482667da1ca7ef95d44d4d2328257a144fd2752383e688637c53ed474d2a/orjson-3.10.11-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7dfa8db55c9792d53c5952900c6a919cfa377b4f4534c7a786484a6a4a350c19", size = 167274 }, + { url = "https://files.pythonhosted.org/packages/23/2f/5bb0a03e819781d82dadb733fde8ebbe20d1777d1a33715d45ada4d82ce8/orjson-3.10.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51f3382415747e0dbda9dade6f1e1a01a9d37f630d8c9049a8ed0e385b7a90c0", size = 154605 }, + { url = "https://files.pythonhosted.org/packages/49/e9/14cc34d45c7bd51665aff9b1bb6b83475a61c52edb0d753fffe1adc97764/orjson-3.10.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f35a1b9f50a219f470e0e497ca30b285c9f34948d3c8160d5ad3a755d9299433", size = 165874 }, + { url = "https://files.pythonhosted.org/packages/7b/61/c2781ecf90f99623e97c67a31e8553f38a1ecebaf3189485726ac8641576/orjson-3.10.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f3b7c5803138e67028dde33450e054c87e0703afbe730c105f1fcd873496d5", size = 142813 }, + { url = "https://files.pythonhosted.org/packages/4d/4f/18c83f78b501b6608569b1610fcb5a25c9bb9ab6a7eb4b3a55131e0fba37/orjson-3.10.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f91d9eb554310472bd09f5347950b24442600594c2edc1421403d7610a0998fd", size = 146762 }, + { url = "https://files.pythonhosted.org/packages/ba/19/ea80d5b575abd3f76a790409c2b7b8a60f3fc9447965c27d09613b8bddf4/orjson-3.10.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dfbb2d460a855c9744bbc8e36f9c3a997c4b27d842f3d5559ed54326e6911f9b", size = 143186 }, + { url = "https://files.pythonhosted.org/packages/2c/f5/d835fee01a0284d4b78effc24d16e7609daac2ff6b6851ca1bdd3b6194fc/orjson-3.10.11-cp312-none-win32.whl", hash = "sha256:d4a62c49c506d4d73f59514986cadebb7e8d186ad510c518f439176cf8d5359d", size = 144489 }, + { url = "https://files.pythonhosted.org/packages/03/60/748e0e205060dec74328dfd835e47902eb5522ae011766da76bfff64e2f4/orjson-3.10.11-cp312-none-win_amd64.whl", hash = "sha256:f1eec3421a558ff7a9b010a6c7effcfa0ade65327a71bb9b02a1c3b77a247284", size = 136614 }, + { url = "https://files.pythonhosted.org/packages/13/92/400970baf46b987c058469e9e779fb7a40d54a5754914d3634cca417e054/orjson-3.10.11-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c46294faa4e4d0eb73ab68f1a794d2cbf7bab33b1dda2ac2959ffb7c61591899", size = 266402 }, + { url = "https://files.pythonhosted.org/packages/3c/fa/f126fc2d817552bd1f67466205abdcbff64eab16f6844fe6df2853528675/orjson-3.10.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52e5834d7d6e58a36846e059d00559cb9ed20410664f3ad156cd2cc239a11230", size = 140826 }, + { url = "https://files.pythonhosted.org/packages/ad/18/9b9664d7d4af5b4fe9fe6600b7654afc0684bba528260afdde10c4a530aa/orjson-3.10.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2fc947e5350fdce548bfc94f434e8760d5cafa97fb9c495d2fef6757aa02ec0", size = 142593 }, + { url = "https://files.pythonhosted.org/packages/20/f9/a30c68f12778d5e58e6b5cdd26f86ee2d0babce1a475073043f46fdd8402/orjson-3.10.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0efabbf839388a1dab5b72b5d3baedbd6039ac83f3b55736eb9934ea5494d258", size = 146777 }, + { url = "https://files.pythonhosted.org/packages/f2/97/12047b0c0e9b391d589fb76eb40538f522edc664f650f8e352fdaaf77ff5/orjson-3.10.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3f29634260708c200c4fe148e42b4aae97d7b9fee417fbdd74f8cfc265f15b0", size = 142961 }, + { url = "https://files.pythonhosted.org/packages/a4/97/d904e26c1cabf2dd6ab1b0909e9b790af28a7f0fcb9d8378d7320d4869eb/orjson-3.10.11-cp313-none-win32.whl", hash = "sha256:1a1222ffcee8a09476bbdd5d4f6f33d06d0d6642df2a3d78b7a195ca880d669b", size = 144486 }, + { url = "https://files.pythonhosted.org/packages/42/62/3760bd1e6e949321d99bab238d08db2b1266564d2f708af668f57109bb36/orjson-3.10.11-cp313-none-win_amd64.whl", hash = "sha256:bc274ac261cc69260913b2d1610760e55d3c0801bb3457ba7b9004420b6b4270", size = 136361 }, + { url = "https://files.pythonhosted.org/packages/29/72/e44004a65831ed8c0d0303623744f01abdb41811a483584edad69ca5358d/orjson-3.10.11-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c95f2ecafe709b4e5c733b5e2768ac569bed308623c85806c395d9cca00e08af", size = 266080 }, + { url = "https://files.pythonhosted.org/packages/f9/84/36b6153ec6be55c9068e3df5e76d38712049052f85e4a4ee4eedba9f36c9/orjson-3.10.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80c00d4acded0c51c98754fe8218cb49cb854f0f7eb39ea4641b7f71732d2cb7", size = 151671 }, + { url = "https://files.pythonhosted.org/packages/59/1d/ca3e7e3c166587dfffc5c2c4ce06219f180ef338699d61e5e301dff8cc71/orjson-3.10.11-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:461311b693d3d0a060439aa669c74f3603264d4e7a08faa68c47ae5a863f352d", size = 167130 }, + { url = "https://files.pythonhosted.org/packages/87/22/46fb6668601c86af701ca32ec181f97f8ad5d246bd9713fce34798e2a1d3/orjson-3.10.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52ca832f17d86a78cbab86cdc25f8c13756ebe182b6fc1a97d534051c18a08de", size = 154079 }, + { url = "https://files.pythonhosted.org/packages/35/6b/98d96dd8576cc14779822d03f465acc42ae47a0acb9c7b79555e691d427b/orjson-3.10.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c57ea78a753812f528178aa2f1c57da633754c91d2124cb28991dab4c79a54", size = 165449 }, + { url = "https://files.pythonhosted.org/packages/88/40/ff08c642eb0e226d2bb8e7095c21262802e7f4cf2a492f2635b4bed935bb/orjson-3.10.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7fcfc6f7ca046383fb954ba528587e0f9336828b568282b27579c49f8e16aad", size = 142283 }, + { url = "https://files.pythonhosted.org/packages/86/37/05e39dde53aa53d1172fe6585dde3bc2a4a327cf9a6ba2bc6ac99ed46cf0/orjson-3.10.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:86b9dd983857970c29e4c71bb3e95ff085c07d3e83e7c46ebe959bac07ebd80b", size = 146711 }, + { url = "https://files.pythonhosted.org/packages/36/ac/5c749779eacf60eb02ef5396821dec2c688f9df1bc2c3224e35b67d02335/orjson-3.10.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4d83f87582d223e54efb2242a79547611ba4ebae3af8bae1e80fa9a0af83bb7f", size = 142701 }, + { url = "https://files.pythonhosted.org/packages/db/66/a61cb47eaf4b8afe10907e465d4e38f61f6e694fc982f01261b0020be8ed/orjson-3.10.11-cp39-none-win32.whl", hash = "sha256:9fd0ad1c129bc9beb1154c2655f177620b5beaf9a11e0d10bac63ef3fce96950", size = 144301 }, + { url = "https://files.pythonhosted.org/packages/cb/08/69b1ce42bb7ee604e23270cf46514ea775265960f3fa4b246e1f8bfde081/orjson-3.10.11-cp39-none-win_amd64.whl", hash = "sha256:10f416b2a017c8bd17f325fb9dee1fb5cdd7a54e814284896b7c3f2763faa017", size = 136263 }, ] [[package]] @@ -1386,15 +1386,15 @@ wheels = [ [[package]] name = "pytest-cov" -version = "5.0.0" +version = "6.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042 } +sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, + { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, ] [[package]] @@ -1487,7 +1487,7 @@ wheels = [ [[package]] name = "python-kasa" -version = "0.7.6" +version = "0.7.7" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -1646,16 +1646,16 @@ wheels = [ [[package]] name = "rich" -version = "13.9.3" +version = "13.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/e9/cf9ef5245d835065e6673781dbd4b8911d352fb770d56cf0879cf11b7ee1/rich-13.9.3.tar.gz", hash = "sha256:bc1e01b899537598cf02579d2b9f4a415104d3fc439313a7a2c165d76557a08e", size = 222889 } +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/e2/10e9819cf4a20bd8ea2f5dabafc2e6bf4a78d6a0965daeb60a4b34d1c11f/rich-13.9.3-py3-none-any.whl", hash = "sha256:9836f5096eb2172c9e77df411c1b009bace4193d6a481d534fea75ebba758283", size = 242157 }, + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, ] [[package]] @@ -1902,96 +1902,96 @@ wheels = [ [[package]] name = "yarl" -version = "1.17.0" +version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/8f/d2d546f8b674335fa7ef83cc5c1892294f3f516c570893e65a7ea8ed49c9/yarl-1.17.0.tar.gz", hash = "sha256:d3f13583f378930377e02002b4085a3d025b00402d5a80911726d43a67911cd9", size = 177249 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/f0/8a0fc780d5d3528c4bc85d1429c7f935e107564374f0b397961edf4c60ad/yarl-1.17.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2d8715edfe12eee6f27f32a3655f38d6c7410deb482158c0b7d4b7fad5d07628", size = 140320 }, - { url = "https://files.pythonhosted.org/packages/68/61/7c2a92f62bd90949844bce495cef522b2e4701b456f08f3616864f40ff58/yarl-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1803bf2a7a782e02db746d8bd18f2384801bc1d108723840b25e065b116ad726", size = 93260 }, - { url = "https://files.pythonhosted.org/packages/93/45/421044f7d1e1e2bedf2195b2e700c5450e47931097e55610c450941bfd6f/yarl-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e66589110e20c2951221a938fa200c7aa134a8bdf4e4dc97e6b21539ff026d4", size = 91098 }, - { url = "https://files.pythonhosted.org/packages/ef/8a/375218414390674a24a7aebcae643128f0b3109b1a96dbfe666ea62a1ba9/yarl-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7069d411cfccf868e812497e0ec4acb7c7bf8d684e93caa6c872f1e6f5d1664d", size = 313457 }, - { url = "https://files.pythonhosted.org/packages/b4/a9/4e25863684ab883070c362f39ef84de5952f082a07a366fb8f7c322966da/yarl-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cbf70ba16118db3e4b0da69dcde9d4d4095d383c32a15530564c283fa38a7c52", size = 328921 }, - { url = "https://files.pythonhosted.org/packages/ae/c4/f10bc70a4d883f3a15c9f344e8853c1b6ce34f67e8237334abba2a15ee56/yarl-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0bc53cc349675b32ead83339a8de79eaf13b88f2669c09d4962322bb0f064cbc", size = 325480 }, - { url = "https://files.pythonhosted.org/packages/00/91/0e638513d91cb9f064a437eb5b3bf86011f3ee84fea63db491a8acd232af/yarl-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6aa18a402d1c80193ce97c8729871f17fd3e822037fbd7d9b719864018df746", size = 318359 }, - { url = "https://files.pythonhosted.org/packages/af/68/f039ad42145d74532e803f9f815a002a4581ca76cc0577444884af0e759b/yarl-1.17.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d89c5bc701861cfab357aa0cd039bc905fe919997b8c312b4b0c358619c38d4d", size = 309846 }, - { url = "https://files.pythonhosted.org/packages/0f/27/fdc5ee8664aeba5750ba90ab3ca62e0c2925829371c1fc8607cde894a074/yarl-1.17.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b728bdf38ca58f2da1d583e4af4ba7d4cd1a58b31a363a3137a8159395e7ecc7", size = 317981 }, - { url = "https://files.pythonhosted.org/packages/c3/2f/8bc603b1e19412b4516b04444b9e66f6e5a11d3909688909d55622b43241/yarl-1.17.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:5542e57dc15d5473da5a39fbde14684b0cc4301412ee53cbab677925e8497c11", size = 317293 }, - { url = "https://files.pythonhosted.org/packages/38/11/6ec6d03e8cfbc4a2fefd62351bd4974ae418cb1d86ebc6cd87ad395b0c7b/yarl-1.17.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e564b57e5009fb150cb513804d7e9e9912fee2e48835638f4f47977f88b4a39c", size = 323101 }, - { url = "https://files.pythonhosted.org/packages/ab/d9/e9d372361eef9a57e3fd3a04a1338642212a43c736a10b5bea0883ecf7e4/yarl-1.17.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:eb3c4cff524b4c1c1dba3a6da905edb1dfd2baf6f55f18a58914bbb2d26b59e1", size = 337331 }, - { url = "https://files.pythonhosted.org/packages/fb/32/027ca7d682bca0f094ec87a1889276590e2a5c8cc937bb30955f89700e00/yarl-1.17.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:05e13f389038842da930d439fbed63bdce3f7644902714cb68cf527c971af804", size = 338658 }, - { url = "https://files.pythonhosted.org/packages/39/59/7e2f9b24a7f96a73860096c6ee5baa7bfef96de31f827e7beeec9b7637d5/yarl-1.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:153c38ee2b4abba136385af4467459c62d50f2a3f4bde38c7b99d43a20c143ef", size = 330774 }, - { url = "https://files.pythonhosted.org/packages/89/79/153d35d1d8addaee756e43319c41a8ba0e5bcbc472b79cf18a8002bd85f5/yarl-1.17.0-cp310-cp310-win32.whl", hash = "sha256:4065b4259d1ae6f70fd9708ffd61e1c9c27516f5b4fae273c41028afcbe3a094", size = 83275 }, - { url = "https://files.pythonhosted.org/packages/65/e7/e9d99d9e1a2a334d416d796751581ed78035731126352c285679d7760b23/yarl-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:abf366391a02a8335c5c26163b5fe6f514cc1d79e74d8bf3ffab13572282368e", size = 89465 }, - { url = "https://files.pythonhosted.org/packages/ad/72/a455fd01d4d33c10d683c209f87af5962bae54b13f435a69747354b169b1/yarl-1.17.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19a4fe0279626c6295c5b0c8c2bb7228319d2e985883621a6e87b344062d8135", size = 140427 }, - { url = "https://files.pythonhosted.org/packages/ca/f6/8f2af9ad1ceab385660f90930433d41191b8647ad3946a67ea573333317f/yarl-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cadd0113f4db3c6b56868d6a19ca6286f5ccfa7bc08c27982cf92e5ed31b489a", size = 93259 }, - { url = "https://files.pythonhosted.org/packages/5d/c5/61036a97e6686de3a3b47ffd51f2db10f4eff895dfdc287f27f9acdc4097/yarl-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:60d6693eef43215b1ccfb1df3f6eae8db30a9ff1e7989fb6b2a6f0b468930ee8", size = 91194 }, - { url = "https://files.pythonhosted.org/packages/0c/a0/fe9db41a1807da0f6f9cbc78243da3267258734c383ff911696f506cae49/yarl-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb8bf3843e1fa8cf3fe77813c512818e57368afab7ebe9ef02446fe1a10b492", size = 339165 }, - { url = "https://files.pythonhosted.org/packages/27/d5/d99e6e25e77ea26ac1d73630ad26ba29ec01ec7594c530cf045b150f7e1f/yarl-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2a5b35fd1d8d90443e061d0c8669ac7600eec5c14c4a51f619e9e105b136715", size = 354290 }, - { url = "https://files.pythonhosted.org/packages/5f/98/0c475389a172e096467ef44cb59d649fc4f44ac186689a70299cd2e03dea/yarl-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c5bf17b32f392df20ab5c3a69d37b26d10efaa018b4f4e5643c7520d8eee7ac7", size = 351486 }, - { url = "https://files.pythonhosted.org/packages/b2/0d/8ecf4604cf62abd8d4aa30dd927466b095f263ee708aed2e576f9f6c6ac8/yarl-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48f51b529b958cd06e78158ff297a8bf57b4021243c179ee03695b5dbf9cb6e1", size = 343091 }, - { url = "https://files.pythonhosted.org/packages/c8/11/e0978e6e2f312c4ac5d441634df8374d25afa17164a6a5caed65f2071ce1/yarl-1.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fcaa06bf788e19f913d315d9c99a69e196a40277dc2c23741a1d08c93f4d430", size = 336785 }, - { url = "https://files.pythonhosted.org/packages/35/26/ecfebb253652b2446082e5072bf347dc2663a176f1a7f96830fb3f2ddb37/yarl-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:32f3ee19ff0f18a7a522d44e869e1ebc8218ad3ae4ebb7020445f59b4bbe5897", size = 346317 }, - { url = "https://files.pythonhosted.org/packages/4f/d7/bec0e8ab6788824a21b4d2a467ebd491c034bf5a61aae9f91bac3225cd0f/yarl-1.17.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a4fb69a81ae2ec2b609574ae35420cf5647d227e4d0475c16aa861dd24e840b0", size = 344050 }, - { url = "https://files.pythonhosted.org/packages/5d/cd/a3d7496963fa6fda90987efc6c6d63e17035a15607a7ba432b3658ee7c4a/yarl-1.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7bacc8b77670322132a1b2522c50a1f62991e2f95591977455fd9a398b4e678d", size = 350009 }, - { url = "https://files.pythonhosted.org/packages/4c/11/e32119eba4f1b2a888d653348571ec835fda93da45255d0d4e0fd557ae75/yarl-1.17.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:437bf6eb47a2d20baaf7f6739895cb049e56896a5ffdea61a4b25da781966e8b", size = 361038 }, - { url = "https://files.pythonhosted.org/packages/b2/3f/868044fff54c060cade272a54baaf57a155537ac79424312c6c0a3c0ff17/yarl-1.17.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:30534a03c87484092080e3b6e789140bd277e40f453358900ad1f0f2e61fc8ec", size = 365043 }, - { url = "https://files.pythonhosted.org/packages/6f/63/99b77939e7a6b8dbb638fb7b6c6ecea4a730ccd7bdda5b621df9ff5bbbc6/yarl-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b30df4ff98703649915144be6f0df3b16fd4870ac38a09c56d5d9e54ff2d5f96", size = 357382 }, - { url = "https://files.pythonhosted.org/packages/b8/cc/48b49f45e4fc5fbb7538a6b513f0a4ae7377c44568e375fca65f270f03d7/yarl-1.17.0-cp311-cp311-win32.whl", hash = "sha256:263b487246858e874ab53e148e2a9a0de8465341b607678106829a81d81418c6", size = 83336 }, - { url = "https://files.pythonhosted.org/packages/ae/60/2ac590d83bb8aa5b8cc3d7f9c47d532d89fb06c3ffa2c4d4fc8d6935aded/yarl-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:07055a9e8b647a362e7d4810fe99d8f98421575e7d2eede32e008c89a65a17bd", size = 89919 }, - { url = "https://files.pythonhosted.org/packages/58/30/3d1b3eea23b9d1764c3d6a6bc22a12336bc91c748475dd1ea79f63a72bf1/yarl-1.17.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:84095ab25ba69a8fa3fb4936e14df631b8a71193fe18bd38be7ecbe34d0f5512", size = 141535 }, - { url = "https://files.pythonhosted.org/packages/aa/0d/178955afc7b6b17f7a693878da366ad4dbf2adfee84cbb76640755115191/yarl-1.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02608fb3f6df87039212fc746017455ccc2a5fc96555ee247c45d1e9f21f1d7b", size = 93821 }, - { url = "https://files.pythonhosted.org/packages/d1/b3/808461c3c3d4c32ff8783364a8673bd785ce887b7421e0ea8d758357d874/yarl-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13468d291fe8c12162b7cf2cdb406fe85881c53c9e03053ecb8c5d3523822cd9", size = 91750 }, - { url = "https://files.pythonhosted.org/packages/95/8b/572f96dd61de8f8b82caf18254573707d526715ad38fd83c47663f2b3c28/yarl-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8da3f8f368fb7e2f052fded06d5672260c50b5472c956a5f1bd7bf474ae504ab", size = 331165 }, - { url = "https://files.pythonhosted.org/packages/4d/f6/8870c4beb0a120d381e7a62f6c1e6a590d929e94de135802ecdb042caffa/yarl-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec0507ab6523980bed050137007c76883d941b519aca0e26d4c1ec1f297dd646", size = 340972 }, - { url = "https://files.pythonhosted.org/packages/cb/08/97a6ccb59df29bbedb560491bc74f9f946dbf074bec1b61f942c29d2bc32/yarl-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08fc76df7fd8360e9ff30e6ccc3ee85b8dbd6ed5d3a295e6ec62bcae7601b932", size = 340557 }, - { url = "https://files.pythonhosted.org/packages/5a/f4/52be40fc0a8811a18a2b2ae99c6233e769fe391b52fae95a23a4db45e82c/yarl-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d522f390686acb6bab2b917dd9ca06740c5080cd2eaa5aef8827b97e967319d", size = 336362 }, - { url = "https://files.pythonhosted.org/packages/0a/25/b95d3c0130c65d2118b3b58d644261a3cd4571a317e5b46dcb2a44d096e2/yarl-1.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:147c527a80bb45b3dcd6e63401af8ac574125d8d120e6afe9901049286ff64ef", size = 324716 }, - { url = "https://files.pythonhosted.org/packages/ab/8a/b4d020a2b83bcab78d9cf094ed30cd08f966a7ce900abdbc3d57e34d1a4b/yarl-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:24cf43bcd17a0a1f72284e47774f9c60e0bf0d2484d5851f4ddf24ded49f33c6", size = 342539 }, - { url = "https://files.pythonhosted.org/packages/e9/e5/29959b19f9267dde6d80d9576bd95d9ed9463693a7c7e5408cd33bf66b18/yarl-1.17.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c28a44b9e0fba49c3857360e7ad1473fc18bc7f6659ca08ed4f4f2b9a52c75fa", size = 341129 }, - { url = "https://files.pythonhosted.org/packages/0a/b2/e5bb6f8909f96179b2982b6d4f44e3700b319eebbacf3f88adc75b2ae4e9/yarl-1.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:350cacb2d589bc07d230eb995d88fcc646caad50a71ed2d86df533a465a4e6e1", size = 344626 }, - { url = "https://files.pythonhosted.org/packages/86/6a/324d0b022032380ea8c378282d5e84e3d1535565489472518e80b8734f1f/yarl-1.17.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fd1ab1373274dea1c6448aee420d7b38af163b5c4732057cd7ee9f5454efc8b1", size = 355409 }, - { url = "https://files.pythonhosted.org/packages/20/f7/e2440d94826723f8bfd194a62ee014974ec416c16f953aa27c23e3ed3128/yarl-1.17.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4934e0f96dadc567edc76d9c08181633c89c908ab5a3b8f698560124167d9488", size = 361845 }, - { url = "https://files.pythonhosted.org/packages/d7/69/757dc8bb7a9e543b319e200c8c6ed30fbf7e7155736c609e2c140d0bb719/yarl-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8d0a278170d75c88e435a1ce76557af6758bfebc338435b2eba959df2552163e", size = 356050 }, - { url = "https://files.pythonhosted.org/packages/2c/3a/c563287d638200be202d46c03698079d85993b7c68f1488451546e60999b/yarl-1.17.0-cp312-cp312-win32.whl", hash = "sha256:61584f33196575a08785bb56db6b453682c88f009cd9c6f338a10f6737ce419f", size = 82982 }, - { url = "https://files.pythonhosted.org/packages/9a/cb/07a4084b90e7761749c56a5338c34366765051e9838eb669e449f012fdb2/yarl-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9987a439ad33a7712bd5bbd073f09ad10d38640425fa498ecc99d8aa064f8fc4", size = 89294 }, - { url = "https://files.pythonhosted.org/packages/6c/4d/9285cd4d13a1bb521350656f89a09b6d44e4e167d4329246a01dc76a2128/yarl-1.17.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8deda7b8eb15a52db94c2014acdc7bdd14cb59ec4b82ac65d2ad16dc234a109e", size = 139677 }, - { url = "https://files.pythonhosted.org/packages/25/c9/eec62c4b4bb1151be548c378c06d3c7282aa70b027f0b26d24c6dde55106/yarl-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56294218b348dcbd3d7fce0ffd79dd0b6c356cb2a813a1181af730b7c40de9e7", size = 93066 }, - { url = "https://files.pythonhosted.org/packages/03/b0/ae2fc93595bf076bf568ed795a3f91ecf596975d9286aab62635340de1d7/yarl-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1fab91292f51c884b290ebec0b309a64a5318860ccda0c4940e740425a67b6b7", size = 90877 }, - { url = "https://files.pythonhosted.org/packages/3e/c2/8dd9c26534eaac304088674582e94d06d874e0b9c43ecf17d93d735eaf8a/yarl-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cf93fa61ff4d9c7d40482ce1a2c9916ca435e34a1b8451e17f295781ccc034f", size = 332747 }, - { url = "https://files.pythonhosted.org/packages/43/95/130310a39e90d99cf5894a4ea6bee147f133db3423e4d88bf6f2baba4ee4/yarl-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:261be774a0d71908c8830c33bacc89eef15c198433a8cc73767c10eeeb35a7d0", size = 343341 }, - { url = "https://files.pythonhosted.org/packages/e1/59/995a99e510f74d39c849157407d8d3e683b5b3d3d830f28de6dfca2c7f60/yarl-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deec9693b67f6af856a733b8a3e465553ef09e5e8ead792f52c25b699b8f9e6e", size = 344880 }, - { url = "https://files.pythonhosted.org/packages/78/41/520458d62a79b6115f035d63f6dec7c70ebfc19c50875cd0b9c3d63bd66f/yarl-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c804b07622ba50a765ca7fb8145512836ab65956de01307541def869e4a456c9", size = 338438 }, - { url = "https://files.pythonhosted.org/packages/b1/90/878e20cc8f54206407d035f17ccd567c75ed2bf77fb9c137c2977e58baf4/yarl-1.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d013a7c9574e98c14831a8f22d27277688ec3b2741d0188ac01a910b009987a", size = 326415 }, - { url = "https://files.pythonhosted.org/packages/0a/2e/709c8339cd5a0b8fb3e7474428165293feec85d77c642b95b0d7be7bda9c/yarl-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e2cfcba719bd494c7413dcf0caafb51772dec168c7c946e094f710d6aa70494e", size = 345526 }, - { url = "https://files.pythonhosted.org/packages/62/5e/90c60a9ac1b3f254b52e542674024160b90e0e547014f0d2a3025c789796/yarl-1.17.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c068aba9fc5b94dfae8ea1cedcbf3041cd4c64644021362ffb750f79837e881f", size = 340048 }, - { url = "https://files.pythonhosted.org/packages/ae/1f/2d086911313e4db00b28f5d105d64823dbcd4a78efcbba70bd58ffc72e20/yarl-1.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3616df510ffac0df3c9fa851a40b76087c6c89cbcea2de33a835fc80f9faac24", size = 344999 }, - { url = "https://files.pythonhosted.org/packages/da/f7/8670ff0427f82db0ec25f4f7e62f5111cc76d79b05a2fe9631155cd0f742/yarl-1.17.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:755d6176b442fba9928a4df787591a6a3d62d4969f05c406cad83d296c5d4e05", size = 353920 }, - { url = "https://files.pythonhosted.org/packages/68/b8/1f5a2fdecee03c23b4b5c9d394342709ed04e15bead1d3c7bee53854a61b/yarl-1.17.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c18f6e708d1cf9ff5b1af026e697ac73bea9cb70ee26a2b045b112548579bed2", size = 360209 }, - { url = "https://files.pythonhosted.org/packages/2b/95/d2e538a544c75131836b5e93975fa677932f0cbacbe4d7a4adb80caba967/yarl-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b937c216b6dee8b858c6afea958de03c5ff28406257d22b55c24962a2baf6fd", size = 359149 }, - { url = "https://files.pythonhosted.org/packages/93/c7/c7f954200ebae213f0b76b072dcd3c37b39a42f4cf3d80a30d580bcedef7/yarl-1.17.0-cp313-cp313-win32.whl", hash = "sha256:d0131b14cb545c1a7bd98f4565a3e9bdf25a1bd65c83fc156ee5d8a8499ec4a3", size = 308608 }, - { url = "https://files.pythonhosted.org/packages/c7/cc/57117f63f27668e87e3ea9ce9fecab7331f0a30b72690211a2857b5db9f5/yarl-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:01c96efa4313c01329e88b7e9e9e1b2fc671580270ddefdd41129fa8d0db7696", size = 314345 }, - { url = "https://files.pythonhosted.org/packages/63/d5/64258ee2af4ad1a25606f5740c282160eae199e02e1b88e70ee3b7de2061/yarl-1.17.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0d44f67e193f0a7acdf552ecb4d1956a3a276c68e7952471add9f93093d1c30d", size = 141626 }, - { url = "https://files.pythonhosted.org/packages/e6/1b/da620f07d73f9525c2f2b0df2c9c15f3b6cdc360f1e77dde7af6ea0c9a05/yarl-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:16ea0aa5f890cdcb7ae700dffa0397ed6c280840f637cd07bffcbe4b8d68b985", size = 93855 }, - { url = "https://files.pythonhosted.org/packages/1b/77/43caa9029936b43c500b6cfbb35c5883431596f156a384767afa2bf40a2d/yarl-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cf5469dc7dcfa65edf5cc3a6add9f84c5529c6b556729b098e81a09a92e60e51", size = 91690 }, - { url = "https://files.pythonhosted.org/packages/18/50/a2ce9c595161ddd146610376388382c786d3763645c536a347e2b0cdce76/yarl-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e662bf2f6e90b73cf2095f844e2bc1fda39826472a2aa1959258c3f2a8500a2f", size = 315804 }, - { url = "https://files.pythonhosted.org/packages/bf/32/a18b8b9dbe7aa2110967d73e0ee8d17c6a33a714494a790bad80b68a6f0d/yarl-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8260e88f1446904ba20b558fa8ce5d0ab9102747238e82343e46d056d7304d7e", size = 332868 }, - { url = "https://files.pythonhosted.org/packages/e1/c5/ac6ff7a774001433da7c687e51372bb5c3989b47fde33da559fe0a2afdfc/yarl-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5dc16477a4a2c71e64c5d3d15d7ae3d3a6bb1e8b955288a9f73c60d2a391282f", size = 328682 }, - { url = "https://files.pythonhosted.org/packages/40/5b/95a2675ce4ac31e5cfb1b3cf86186e509b887078f9946e38b8d343264405/yarl-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46027e326cecd55e5950184ec9d86c803f4f6fe4ba6af9944a0e537d643cdbe0", size = 320438 }, - { url = "https://files.pythonhosted.org/packages/ee/69/55af26629312ac686848b402d7dc48194dd14e509a3da6d31e71734ce43a/yarl-1.17.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc95e46c92a2b6f22e70afe07e34dbc03a4acd07d820204a6938798b16f4014f", size = 313099 }, - { url = "https://files.pythonhosted.org/packages/52/dc/882b922b37868efa29c07baa509e6a1fe69762b733b5cd12ca4cb3a34992/yarl-1.17.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:16ca76c7ac9515320cd09d6cc083d8d13d1803f6ebe212b06ea2505fd66ecff8", size = 321353 }, - { url = "https://files.pythonhosted.org/packages/80/06/9feb083092fb5556f8fa78c15c58aacfc7dacc0d28524b571ad83c679630/yarl-1.17.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:eb1a5b97388f2613f9305d78a3473cdf8d80c7034e554d8199d96dcf80c62ac4", size = 322983 }, - { url = "https://files.pythonhosted.org/packages/4f/71/a0edd86e473589e885350aef584359dcd5a6117154fd3192869799e48dbd/yarl-1.17.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:41fd5498975418cdc34944060b8fbeec0d48b2741068077222564bea68daf5a6", size = 326432 }, - { url = "https://files.pythonhosted.org/packages/c6/11/b74a0b7ac4294ecc5225391af0eeccb580b3c6e63d8bbfed9992a8884445/yarl-1.17.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:146ca582ed04a5664ad04b0e0603934281eaab5c0115a5a46cce0b3c061a56a1", size = 338673 }, - { url = "https://files.pythonhosted.org/packages/4f/8c/09abe2f91571c54deae92c8167c80c37a8788f723bfa9a25576d1858cbba/yarl-1.17.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:6abb8c06107dbec97481b2392dafc41aac091a5d162edf6ed7d624fe7da0587a", size = 339042 }, - { url = "https://files.pythonhosted.org/packages/7b/ff/2572507b577c9039248da6eb97b52b6fbf7f5f9fc81398bd5b1f4e2ed61b/yarl-1.17.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4d14be4613dd4f96c25feb4bd8c0d8ce0f529ab0ae555a17df5789e69d8ec0c5", size = 333817 }, - { url = "https://files.pythonhosted.org/packages/a3/0f/dae6b48f8e0f8af054a47c9933167c74e138b89a07971d69a33104863697/yarl-1.17.0-cp39-cp39-win32.whl", hash = "sha256:174d6a6cad1068f7850702aad0c7b1bca03bcac199ca6026f84531335dfc2646", size = 83814 }, - { url = "https://files.pythonhosted.org/packages/75/87/35e0d82d908c879510f92dde7ac225d4055d06211d8f3d6d9591bc93702b/yarl-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:6af417ca2c7349b101d3fd557ad96b4cd439fdb6ab0d288e3f64a068eea394d0", size = 89937 }, - { url = "https://files.pythonhosted.org/packages/93/86/f1305e1ab1d6dc27d245ffc83d18d88f2bebf6c6488725ee82dffb3eda7a/yarl-1.17.0-py3-none-any.whl", hash = "sha256:62dd42bb0e49423f4dd58836a04fcf09c80237836796025211bbe913f1524993", size = 44053 }, +sdist = { url = "https://files.pythonhosted.org/packages/54/9c/9c0a9bfa683fc1be7fdcd9687635151544d992cccd48892dc5e0a5885a29/yarl-1.17.1.tar.gz", hash = "sha256:067a63fcfda82da6b198fa73079b1ca40b7c9b7994995b6ee38acda728b64d47", size = 178163 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/63/0e1e3626a323f366a8ff8eeb4d2835d403cb505393c2fce00c68c2be9d1a/yarl-1.17.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1794853124e2f663f0ea54efb0340b457f08d40a1cef78edfa086576179c91", size = 140627 }, + { url = "https://files.pythonhosted.org/packages/ff/ef/80c92e43f5ca5dfe964f42080252b669097fdd37d40e8c174e5a10d67d2c/yarl-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fbea1751729afe607d84acfd01efd95e3b31db148a181a441984ce9b3d3469da", size = 93563 }, + { url = "https://files.pythonhosted.org/packages/05/43/add866f8c7e99af126a3ff4a673165537617995a5ae90e86cb95f9a1d4ad/yarl-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ee427208c675f1b6e344a1f89376a9613fc30b52646a04ac0c1f6587c7e46ec", size = 91400 }, + { url = "https://files.pythonhosted.org/packages/b9/44/464aba5761fb7ab448d8854520d98355217481746d2421231b8d07d2de8c/yarl-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b74ff4767d3ef47ffe0cd1d89379dc4d828d4873e5528976ced3b44fe5b0a21", size = 313746 }, + { url = "https://files.pythonhosted.org/packages/c1/0f/3a08d81f1e4ff88b07d62f3bb271603c0e2d063cea12239e500defa800d3/yarl-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62a91aefff3d11bf60e5956d340eb507a983a7ec802b19072bb989ce120cd948", size = 329234 }, + { url = "https://files.pythonhosted.org/packages/7d/0f/98f29b8637cf13d7589bb7a1fdc4357bcfc0cfc3f20bc65a6970b71a22ec/yarl-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:846dd2e1243407133d3195d2d7e4ceefcaa5f5bf7278f0a9bda00967e6326b04", size = 325776 }, + { url = "https://files.pythonhosted.org/packages/3c/8c/f383fc542a3d2a1837fb0543ce698653f1760cc18954c29e6d6d49713376/yarl-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e844be8d536afa129366d9af76ed7cb8dfefec99f5f1c9e4f8ae542279a6dc3", size = 318659 }, + { url = "https://files.pythonhosted.org/packages/2b/35/742b4a03ca90e116f70a44b24a36d2138f1b1d776a532ddfece4d60cd93d/yarl-1.17.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc7c92c1baa629cb03ecb0c3d12564f172218fb1739f54bf5f3881844daadc6d", size = 310172 }, + { url = "https://files.pythonhosted.org/packages/9b/fc/f1aba4194861f44673d9b432310cbee2e7c3ffa8ff9bdf165c7eaa9c6e38/yarl-1.17.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ae3476e934b9d714aa8000d2e4c01eb2590eee10b9d8cd03e7983ad65dfbfcba", size = 318283 }, + { url = "https://files.pythonhosted.org/packages/27/0f/2b20100839064d1c75fb85fa6b5cbd68249d96a4b06a5cf25f9eaaf9b32a/yarl-1.17.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c7e177c619342e407415d4f35dec63d2d134d951e24b5166afcdfd1362828e17", size = 317599 }, + { url = "https://files.pythonhosted.org/packages/7b/da/3f2d6643d8cf3003c72587f28a9d9c76829a5b45186cae8f978bac113fc5/yarl-1.17.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64cc6e97f14cf8a275d79c5002281f3040c12e2e4220623b5759ea7f9868d6a5", size = 323398 }, + { url = "https://files.pythonhosted.org/packages/9e/f8/881c97cc35603ec63b48875d47e36e1b984648826b36ce7affac16e08261/yarl-1.17.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:84c063af19ef5130084db70ada40ce63a84f6c1ef4d3dbc34e5e8c4febb20822", size = 337601 }, + { url = "https://files.pythonhosted.org/packages/81/da/049b354e00b33019c32126f2a40ecbcc320859f619c4304c556cf23a5dc3/yarl-1.17.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:482c122b72e3c5ec98f11457aeb436ae4aecca75de19b3d1de7cf88bc40db82f", size = 338975 }, + { url = "https://files.pythonhosted.org/packages/26/64/e36e808b249d64cfc33caca7e9ef2d7e636e4f9e8529e4fe5ed4813ac5b0/yarl-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:380e6c38ef692b8fd5a0f6d1fa8774d81ebc08cfbd624b1bca62a4d4af2f9931", size = 331078 }, + { url = "https://files.pythonhosted.org/packages/82/cb/6fe205b528cc889f8e13d6d180adbc8721a21a6aac67fc3158294575add3/yarl-1.17.1-cp310-cp310-win32.whl", hash = "sha256:16bca6678a83657dd48df84b51bd56a6c6bd401853aef6d09dc2506a78484c7b", size = 83573 }, + { url = "https://files.pythonhosted.org/packages/55/96/4dcb7110ae4cd53768254fb50ace7bca00e110459e6eff1d16983c513219/yarl-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:561c87fea99545ef7d692403c110b2f99dced6dff93056d6e04384ad3bc46243", size = 89761 }, + { url = "https://files.pythonhosted.org/packages/ec/0f/ce6a2c8aab9946446fb27f1e28f0fd89ce84ae913ab18a92d18078a1c7ed/yarl-1.17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cbad927ea8ed814622305d842c93412cb47bd39a496ed0f96bfd42b922b4a217", size = 140727 }, + { url = "https://files.pythonhosted.org/packages/9d/df/204f7a502bdc3973cd9fc29e7dfad18ae48b3acafdaaf1ae07c0f41025aa/yarl-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fca4b4307ebe9c3ec77a084da3a9d1999d164693d16492ca2b64594340999988", size = 93560 }, + { url = "https://files.pythonhosted.org/packages/a2/e1/f4d522ae0560c91a4ea31113a50f00f85083be885e1092fc6e74eb43cb1d/yarl-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff5c6771c7e3511a06555afa317879b7db8d640137ba55d6ab0d0c50425cab75", size = 91497 }, + { url = "https://files.pythonhosted.org/packages/f1/82/783d97bf4a226f1a2e59b1966f2752244c2bf4dc89bc36f61d597b8e34e5/yarl-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b29beab10211a746f9846baa39275e80034e065460d99eb51e45c9a9495bcca", size = 339446 }, + { url = "https://files.pythonhosted.org/packages/e5/ff/615600647048d81289c80907165de713fbc566d1e024789863a2f6563ba3/yarl-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a52a1ffdd824fb1835272e125385c32fd8b17fbdefeedcb4d543cc23b332d74", size = 354616 }, + { url = "https://files.pythonhosted.org/packages/a5/04/bfb7adb452bd19dfe0c35354ffce8ebc3086e028e5f8270e409d17da5466/yarl-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58c8e9620eb82a189c6c40cb6b59b4e35b2ee68b1f2afa6597732a2b467d7e8f", size = 351801 }, + { url = "https://files.pythonhosted.org/packages/10/e0/efe21edacdc4a638ce911f8cabf1c77cac3f60e9819ba7d891b9ceb6e1d4/yarl-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d216e5d9b8749563c7f2c6f7a0831057ec844c68b4c11cb10fc62d4fd373c26d", size = 343381 }, + { url = "https://files.pythonhosted.org/packages/63/f9/7bc7e69857d6fc3920ecd173592f921d5701f4a0dd3f2ae293b386cfa3bf/yarl-1.17.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:881764d610e3269964fc4bb3c19bb6fce55422828e152b885609ec176b41cf11", size = 337093 }, + { url = "https://files.pythonhosted.org/packages/93/52/99da61947466275ff17d7bc04b0ac31dfb7ec699bd8d8985dffc34c3a913/yarl-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8c79e9d7e3d8a32d4824250a9c6401194fb4c2ad9a0cec8f6a96e09a582c2cc0", size = 346619 }, + { url = "https://files.pythonhosted.org/packages/91/8a/8aaad86a35a16e485ba0e5de0d2ae55bf8dd0c9f1cccac12be4c91366b1d/yarl-1.17.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:299f11b44d8d3a588234adbe01112126010bd96d9139c3ba7b3badd9829261c3", size = 344347 }, + { url = "https://files.pythonhosted.org/packages/af/b6/97f29f626b4a1768ffc4b9b489533612cfcb8905c90f745aade7b2eaf75e/yarl-1.17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cc7d768260f4ba4ea01741c1b5fe3d3a6c70eb91c87f4c8761bbcce5181beafe", size = 350316 }, + { url = "https://files.pythonhosted.org/packages/d7/98/8e0e8b812479569bdc34d66dd3e2471176ca33be4ff5c272a01333c4b269/yarl-1.17.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:de599af166970d6a61accde358ec9ded821234cbbc8c6413acfec06056b8e860", size = 361336 }, + { url = "https://files.pythonhosted.org/packages/9e/d3/d1507efa0a85c25285f8eb51df9afa1ba1b6e446dda781d074d775b6a9af/yarl-1.17.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2b24ec55fad43e476905eceaf14f41f6478780b870eda5d08b4d6de9a60b65b4", size = 365350 }, + { url = "https://files.pythonhosted.org/packages/22/ba/ee7f1830449c96bae6f33210b7d89e8aaf3079fbdaf78ac398e50a9da404/yarl-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9fb815155aac6bfa8d86184079652c9715c812d506b22cfa369196ef4e99d1b4", size = 357689 }, + { url = "https://files.pythonhosted.org/packages/a0/85/321c563dc5afe1661108831b965c512d185c61785400f5606006507d2e18/yarl-1.17.1-cp311-cp311-win32.whl", hash = "sha256:7615058aabad54416ddac99ade09a5510cf77039a3b903e94e8922f25ed203d7", size = 83635 }, + { url = "https://files.pythonhosted.org/packages/bc/da/543a32c00860588ff1235315b68f858cea30769099c32cd22b7bb266411b/yarl-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:14bc88baa44e1f84164a392827b5defb4fa8e56b93fecac3d15315e7c8e5d8b3", size = 90218 }, + { url = "https://files.pythonhosted.org/packages/5d/af/e25615c7920396219b943b9ff8b34636ae3e1ad30777649371317d7f05f8/yarl-1.17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:327828786da2006085a4d1feb2594de6f6d26f8af48b81eb1ae950c788d97f61", size = 141839 }, + { url = "https://files.pythonhosted.org/packages/83/5e/363d9de3495c7c66592523f05d21576a811015579e0c87dd38c7b5788afd/yarl-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cc353841428d56b683a123a813e6a686e07026d6b1c5757970a877195f880c2d", size = 94125 }, + { url = "https://files.pythonhosted.org/packages/e3/a2/b65447626227ebe36f18f63ac551790068bf42c69bb22dfa3ae986170728/yarl-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73df5b6e8fabe2ddb74876fb82d9dd44cbace0ca12e8861ce9155ad3c886139", size = 92048 }, + { url = "https://files.pythonhosted.org/packages/a1/f5/2ef86458446f85cde10582054fd5113495ef8ce8477da35aaaf26d2970ef/yarl-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bdff5e0995522706c53078f531fb586f56de9c4c81c243865dd5c66c132c3b5", size = 331472 }, + { url = "https://files.pythonhosted.org/packages/f3/6b/1ba79758ba352cdf2ad4c20cab1b982dd369aa595bb0d7601fc89bf82bee/yarl-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06157fb3c58f2736a5e47c8fcbe1afc8b5de6fb28b14d25574af9e62150fcaac", size = 341260 }, + { url = "https://files.pythonhosted.org/packages/2d/41/4e07c2afca3f9ed3da5b0e38d43d0280d9b624a3d5c478c425e5ce17775c/yarl-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1654ec814b18be1af2c857aa9000de7a601400bd4c9ca24629b18486c2e35463", size = 340882 }, + { url = "https://files.pythonhosted.org/packages/c3/c0/cd8e94618983c1b811af082e1a7ad7764edb3a6af2bc6b468e0e686238ba/yarl-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f6595c852ca544aaeeb32d357e62c9c780eac69dcd34e40cae7b55bc4fb1147", size = 336648 }, + { url = "https://files.pythonhosted.org/packages/ac/fc/73ec4340d391ffbb8f34eb4c55429784ec9f5bd37973ce86d52d67135418/yarl-1.17.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:459e81c2fb920b5f5df744262d1498ec2c8081acdcfe18181da44c50f51312f7", size = 325019 }, + { url = "https://files.pythonhosted.org/packages/57/48/da3ebf418fc239d0a156b3bdec6b17a5446f8d2dea752299c6e47b143a85/yarl-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e48cdb8226644e2fbd0bdb0a0f87906a3db07087f4de77a1b1b1ccfd9e93685", size = 342841 }, + { url = "https://files.pythonhosted.org/packages/5d/79/107272745a470a8167924e353a5312eb52b5a9bb58e22686adc46c94f7ec/yarl-1.17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d9b6b28a57feb51605d6ae5e61a9044a31742db557a3b851a74c13bc61de5172", size = 341433 }, + { url = "https://files.pythonhosted.org/packages/30/9c/6459668b3b8dcc11cd061fc53e12737e740fb6b1575b49c84cbffb387b3a/yarl-1.17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e594b22688d5747b06e957f1ef822060cb5cb35b493066e33ceac0cf882188b7", size = 344927 }, + { url = "https://files.pythonhosted.org/packages/c5/0b/93a17ed733aca8164fc3a01cb7d47b3f08854ce4f957cce67a6afdb388a0/yarl-1.17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5f236cb5999ccd23a0ab1bd219cfe0ee3e1c1b65aaf6dd3320e972f7ec3a39da", size = 355732 }, + { url = "https://files.pythonhosted.org/packages/9a/63/ead2ed6aec3c59397e135cadc66572330325a0c24cd353cd5c94f5e63463/yarl-1.17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a2a64e62c7a0edd07c1c917b0586655f3362d2c2d37d474db1a509efb96fea1c", size = 362123 }, + { url = "https://files.pythonhosted.org/packages/89/bf/f6b75b4c2fcf0e7bb56edc0ed74e33f37fac45dc40e5a52a3be66b02587a/yarl-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d0eea830b591dbc68e030c86a9569826145df485b2b4554874b07fea1275a199", size = 356355 }, + { url = "https://files.pythonhosted.org/packages/45/1f/50a0257cd07eef65c8c65ad6a21f5fb230012d659e021aeb6ac8a7897bf6/yarl-1.17.1-cp312-cp312-win32.whl", hash = "sha256:46ddf6e0b975cd680eb83318aa1d321cb2bf8d288d50f1754526230fcf59ba96", size = 83279 }, + { url = "https://files.pythonhosted.org/packages/bc/82/fafb2c1268d63d54ec08b3a254fbe51f4ef098211501df646026717abee3/yarl-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:117ed8b3732528a1e41af3aa6d4e08483c2f0f2e3d3d7dca7cf538b3516d93df", size = 89590 }, + { url = "https://files.pythonhosted.org/packages/06/1e/5a93e3743c20eefbc68bd89334d9c9f04f3f2334380f7bbf5e950f29511b/yarl-1.17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5d1d42556b063d579cae59e37a38c61f4402b47d70c29f0ef15cee1acaa64488", size = 139974 }, + { url = "https://files.pythonhosted.org/packages/a1/be/4e0f6919013c7c5eaea5c31811c551ccd599d2fc80aa3dd6962f1bbdcddd/yarl-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0167540094838ee9093ef6cc2c69d0074bbf84a432b4995835e8e5a0d984374", size = 93364 }, + { url = "https://files.pythonhosted.org/packages/73/f0/650f994bc491d0cb85df8bb45392780b90eab1e175f103a5edc61445ff67/yarl-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2f0a6423295a0d282d00e8701fe763eeefba8037e984ad5de44aa349002562ac", size = 91177 }, + { url = "https://files.pythonhosted.org/packages/f3/e8/9945ed555d14b43ede3ae8b1bd73e31068a694cad2b9d3cad0a28486c2eb/yarl-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5b078134f48552c4d9527db2f7da0b5359abd49393cdf9794017baec7506170", size = 333086 }, + { url = "https://files.pythonhosted.org/packages/a6/c0/7d167e48e14d26639ca066825af8da7df1d2fcdba827e3fd6341aaf22a3b/yarl-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d401f07261dc5aa36c2e4efc308548f6ae943bfff20fcadb0a07517a26b196d8", size = 343661 }, + { url = "https://files.pythonhosted.org/packages/fa/81/80a266517531d4e3553aecd141800dbf48d02e23ebd52909e63598a80134/yarl-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5f1ac7359e17efe0b6e5fec21de34145caef22b260e978336f325d5c84e6938", size = 345196 }, + { url = "https://files.pythonhosted.org/packages/b0/77/6adc482ba7f2dc6c0d9b3b492e7cd100edfac4cfc3849c7ffa26fd7beb1a/yarl-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f63d176a81555984e91f2c84c2a574a61cab7111cc907e176f0f01538e9ff6e", size = 338743 }, + { url = "https://files.pythonhosted.org/packages/6d/cc/f0c4c0b92ff3ada517ffde2b127406c001504b225692216d969879ada89a/yarl-1.17.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e275792097c9f7e80741c36de3b61917aebecc08a67ae62899b074566ff8556", size = 326719 }, + { url = "https://files.pythonhosted.org/packages/18/3b/7bfc80d3376b5fa162189993a87a5a6a58057f88315bd0ea00610055b57a/yarl-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81713b70bea5c1386dc2f32a8f0dab4148a2928c7495c808c541ee0aae614d67", size = 345826 }, + { url = "https://files.pythonhosted.org/packages/2e/66/cf0b0338107a5c370205c1a572432af08f36ca12ecce127f5b558398b4fd/yarl-1.17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:aa46dce75078fceaf7cecac5817422febb4355fbdda440db55206e3bd288cfb8", size = 340335 }, + { url = "https://files.pythonhosted.org/packages/2f/52/b084b0eec0fd4d2490e1d33ace3320fad704c5f1f3deaa709f929d2d87fc/yarl-1.17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1ce36ded585f45b1e9bb36d0ae94765c6608b43bd2e7f5f88079f7a85c61a4d3", size = 345301 }, + { url = "https://files.pythonhosted.org/packages/ef/38/9e2036d948efd3bafcdb4976cb212166fded76615f0dfc6c1492c4ce4784/yarl-1.17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2d374d70fdc36f5863b84e54775452f68639bc862918602d028f89310a034ab0", size = 354205 }, + { url = "https://files.pythonhosted.org/packages/81/c1/13dfe1e70b86811733316221c696580725ceb1c46d4e4db852807e134310/yarl-1.17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2d9f0606baaec5dd54cb99667fcf85183a7477f3766fbddbe3f385e7fc253299", size = 360501 }, + { url = "https://files.pythonhosted.org/packages/91/87/756e05c74cd8bf9e71537df4a2cae7e8211a9ebe0d2350a3e26949e1e41c/yarl-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0341e6d9a0c0e3cdc65857ef518bb05b410dbd70d749a0d33ac0f39e81a4258", size = 359452 }, + { url = "https://files.pythonhosted.org/packages/06/b2/b2bb09c1e6d59e1c9b1b36a86caa473e22c3dbf26d1032c030e9bfb554dc/yarl-1.17.1-cp313-cp313-win32.whl", hash = "sha256:2e7ba4c9377e48fb7b20dedbd473cbcbc13e72e1826917c185157a137dac9df2", size = 308904 }, + { url = "https://files.pythonhosted.org/packages/f3/27/f084d9a5668853c1f3b246620269b14ee871ef3c3cc4f3a1dd53645b68ec/yarl-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:949681f68e0e3c25377462be4b658500e85ca24323d9619fdc41f68d46a1ffda", size = 314637 }, + { url = "https://files.pythonhosted.org/packages/8b/1d/715a116e42ecd31f515b268c1a0237a9d8771622cdfc1b4a4216f7854d16/yarl-1.17.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8994b29c462de9a8fce2d591028b986dbbe1b32f3ad600b2d3e1c482c93abad6", size = 141924 }, + { url = "https://files.pythonhosted.org/packages/f4/fa/50c9ac90ce17b6161bd815967f3d40304945353da831c9746bbac3bb0369/yarl-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f9cbfbc5faca235fbdf531b93aa0f9f005ec7d267d9d738761a4d42b744ea159", size = 94156 }, + { url = "https://files.pythonhosted.org/packages/ff/a6/3f7c41d7c63d1e7819871ac1c6c3b94af27b359e162f4769ffe613e3c43c/yarl-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b40d1bf6e6f74f7c0a567a9e5e778bbd4699d1d3d2c0fe46f4b717eef9e96b95", size = 91989 }, + { url = "https://files.pythonhosted.org/packages/12/5d/8bd30a5d2269b0f4062ce10804c79c2bdffde6be4c0501d1761ee99e9bc7/yarl-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5efe0661b9fcd6246f27957f6ae1c0eb29bc60552820f01e970b4996e016004", size = 316098 }, + { url = "https://files.pythonhosted.org/packages/95/70/2bca909b53502ffa2b46695ece4e893eb2a7d6e6628e82741c3b518fb5d0/yarl-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5c4804e4039f487e942c13381e6c27b4b4e66066d94ef1fae3f6ba8b953f383", size = 333170 }, + { url = "https://files.pythonhosted.org/packages/d9/1b/ef6d740e96f555a9c96572367f53b8e853e511d6dbfc228d4e09b7217b8d/yarl-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5d6a6c9602fd4598fa07e0389e19fe199ae96449008d8304bf5d47cb745462e", size = 328992 }, + { url = "https://files.pythonhosted.org/packages/02/d7/4b7877b277ba46dc571de11f0e9df9a9f3ea1548d6125b66541277b68e15/yarl-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4c9156c4d1eb490fe374fb294deeb7bc7eaccda50e23775b2354b6a6739934", size = 320752 }, + { url = "https://files.pythonhosted.org/packages/ae/da/d6ba097b6c78dadf3b9b40f13f0bf19fd9084b95c42611e90b6938d132a3/yarl-1.17.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6324274b4e0e2fa1b3eccb25997b1c9ed134ff61d296448ab8269f5ac068c4c", size = 313372 }, + { url = "https://files.pythonhosted.org/packages/0b/18/39e7c0d57d2d132e1e5d2dd3e11cb5acf6cc87fa7b9a58b947c005c7d858/yarl-1.17.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d8a8b74d843c2638f3864a17d97a4acda58e40d3e44b6303b8cc3d3c44ae2d29", size = 321654 }, + { url = "https://files.pythonhosted.org/packages/fd/ac/3e8e22eaec701ca15a5f236c62c6fc5303aff78beb9c49d15307843abdcc/yarl-1.17.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:7fac95714b09da9278a0b52e492466f773cfe37651cf467a83a1b659be24bf71", size = 323298 }, + { url = "https://files.pythonhosted.org/packages/5d/44/f4aa2bbf3d62b8de8a9e9987256ba1be9e05c6fc4b34ef5d286a8364ad38/yarl-1.17.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c180ac742a083e109c1a18151f4dd8675f32679985a1c750d2ff806796165b55", size = 326736 }, + { url = "https://files.pythonhosted.org/packages/36/65/0c0245b826ca27c6a9ab7887749de10560a75734d124515f7992a311c0c7/yarl-1.17.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:578d00c9b7fccfa1745a44f4eddfdc99d723d157dad26764538fbdda37209857", size = 338987 }, + { url = "https://files.pythonhosted.org/packages/75/65/32115ff01b61f6f492b0e588c7b698be1f58941a7ad52789886f7713d732/yarl-1.17.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1a3b91c44efa29e6c8ef8a9a2b583347998e2ba52c5d8280dbd5919c02dfc3b5", size = 339352 }, + { url = "https://files.pythonhosted.org/packages/f0/04/f7c2d9cb220e4d179f1d7be2319d55bacf3ab088e66d3cbf7f0c258f97df/yarl-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a7ac5b4984c468ce4f4a553df281450df0a34aefae02e58d77a0847be8d1e11f", size = 334126 }, + { url = "https://files.pythonhosted.org/packages/69/6d/838a7b90f441d5111374ded683ba64f93fbac591a799c12cc0e722be61bf/yarl-1.17.1-cp39-cp39-win32.whl", hash = "sha256:7294e38f9aa2e9f05f765b28ffdc5d81378508ce6dadbe93f6d464a8c9594473", size = 84113 }, + { url = "https://files.pythonhosted.org/packages/5b/60/f93718008e232747ceed89f2cd7b7d67b180478020c3d18a795d36291bae/yarl-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:eb6dce402734575e1a8cc0bb1509afca508a400a57ce13d306ea2c663bad1138", size = 90234 }, + { url = "https://files.pythonhosted.org/packages/52/ad/1fe7ff5f3e8869d4c5070f47b96bac2b4d15e67c100a8278d8e7876329fc/yarl-1.17.1-py3-none-any.whl", hash = "sha256:f1790a4b1e8e8e028c391175433b9c8122c39b46e1663228158e61e6f915bf06", size = 44352 }, ] [[package]] From 4026e8a80c90daa99800e837c0b05bbc7b429811 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Thu, 7 Nov 2024 20:09:51 +0100 Subject: [PATCH 653/892] Make __repr__ work on discovery info (#1233) This PR will make `__repr__` also work on smartdevices where only discovery data is available by modifying the `model` property to fallback to the data found in the discovery payloads. --- kasa/device.py | 8 +++++--- kasa/smart/smartdevice.py | 4 +++- kasa/tests/test_discovery.py | 24 ++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/kasa/device.py b/kasa/device.py index 08dcf2a19..fb9b9f0c5 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -469,9 +469,11 @@ async def factory_reset(self) -> None: """ def __repr__(self): - if self._last_update is None: - return f"<{self.device_type} at {self.host} - update() needed>" - return f"<{self.device_type} at {self.host} - {self.alias} ({self.model})>" + update_needed = " - update() needed" if not self._last_update else "" + return ( + f"<{self.device_type} at {self.host} -" + f" {self.alias} ({self.model}){update_needed}>" + ) _deprecated_device_type_attributes = { # is_type diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index f4012b68f..17386e07f 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -732,8 +732,10 @@ def device_type(self) -> DeviceType: if self._device_type is not DeviceType.Unknown: return self._device_type + # Fallback to device_type (from disco info) + type_str = self._info.get("type", self._info.get("device_type")) self._device_type = self._get_device_type_from_components( - list(self._components.keys()), self._info["type"] + list(self._components.keys()), type_str ) return self._device_type diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 0dc4e0d7c..0318de35c 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -706,3 +706,27 @@ async def _update(self, *args, **kwargs): assert dev.config.uses_http is (transport_class != XorTransport) if transport_class != XorTransport: assert dev.protocol._transport._http_client.client == session + + +async def test_discovery_device_repr(discovery_mock, mocker): + """Test that repr works when only discovery data is available.""" + host = "foobar" + ip = "127.0.0.1" + + discovery_mock.ip = ip + device_class = Discover._get_device_class(discovery_mock.discovery_data) + update_mock = mocker.patch.object(device_class, "update") + + dev = await Discover.discover_single(host, credentials=Credentials()) + assert update_mock.call_count == 0 + + repr_ = repr(dev) + assert dev.host in repr_ + assert str(dev.device_type) in repr_ + assert dev.model in repr_ + + # For IOT devices, _last_update is filled from the discovery data + if dev._last_update: + assert "update() needed" not in repr_ + else: + assert "update() needed" in repr_ From 6039760186ec90527d1bee237a302e1878e4fad1 Mon Sep 17 00:00:00 2001 From: Ryan Nitcher Date: Fri, 8 Nov 2024 17:47:56 -0700 Subject: [PATCH 654/892] Add EP40M Fixture (#1238) Add fixture for [EP40M](https://www.kasasmart.com/us/products/smart-plugs/smart-wifi-outdoor-plug-ep40m), Smart Wi-Fi Outdoor Plug (Matter). --- README.md | 2 +- SUPPORTED.md | 2 + kasa/tests/device_fixtures.py | 2 +- .../fixtures/smart/EP40M(US)_1.0_1.1.0.json | 876 ++++++++++++++++++ 4 files changed, 880 insertions(+), 2 deletions(-) create mode 100644 kasa/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json diff --git a/README.md b/README.md index 4eff5338a..cab2e38eb 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ The following devices have been tested and confirmed as working. If your device ### Supported Kasa devices - **Plugs**: EP10, EP25\*, HS100\*\*, HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M\*, KP401 -- **Power Strips**: EP40, HS107, HS300, KP200, KP303, KP400 +- **Power Strips**: EP40, EP40M\*, HS107, HS300, KP200, KP303, KP400 - **Wall Switches**: ES20M, HS200, HS210, HS220\*\*, KP405, KS200M, KS205\*, KS220M, KS225\*, KS230, KS240\* - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 - **Light Strips**: KL400L5, KL420L5, KL430 diff --git a/SUPPORTED.md b/SUPPORTED.md index fa5cd0f98..50e1d3cb8 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -54,6 +54,8 @@ Some newer Kasa devices require authentication. These are marked with *\* - **HS107** - Hardware: 1.0 (US) / Firmware: 1.0.8 - **HS300** diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index e05be7b69..d38581a99 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -108,7 +108,7 @@ } SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} -STRIPS_SMART = {"P300", "P304M", "TP25"} +STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M"} STRIPS = {*STRIPS_IOT, *STRIPS_SMART} DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} diff --git a/kasa/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json b/kasa/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json new file mode 100644 index 000000000..1126fad50 --- /dev/null +++ b/kasa/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json @@ -0,0 +1,876 @@ +{ + "child_devices": { + "SCRUBBED_CHILD_DEVICE_ID_1": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "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": "tape_lights", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 240415 Rel.171219", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "F0090D000000", + "model": "EP40M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "America/Denver", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.KASAPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 2, + "past7": 2, + "today": 0 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_2": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "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": "house", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 240415 Rel.171219", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "F0090D000000", + "model": "EP40M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "America/Denver", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.KASAPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 842, + "past7": 842, + "today": 249 + } + }, + "get_next_event": { + "desired_states": { + "on": true + }, + "e_time": 0, + "id": "S1", + "s_time": 1729382340, + "type": 1 + }, + "get_schedule_rules": { + "enable": true, + "rule_list": [ + { + "day": 19, + "desired_states": { + "on": true + }, + "e_action": "none", + "e_min": 0, + "e_type": "normal", + "enable": true, + "id": "S1", + "mode": "repeat", + "month": 10, + "s_min": 1081, + "s_type": "sunset", + "time_offset": -15, + "week_day": 127, + "year": 2024 + }, + { + "day": 19, + "desired_states": { + "on": false + }, + "e_action": "none", + "e_min": 0, + "e_type": "normal", + "enable": true, + "id": "S2", + "mode": "repeat", + "month": 10, + "s_min": 1330, + "s_type": "normal", + "time_offset": 0, + "week_day": 127, + "year": 2024 + } + ], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 2 + } + } + }, + "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": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "EP40M(US)", + "device_type": "SMART.KASAPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-09-0D-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "matter", + "owner": "00000000000000000000000000000000" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "tape_lights", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 240415 Rel.171219", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "F0090D000000", + "model": "EP40M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "America/Denver", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.KASAPLUG" + }, + { + "avatar": "house", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 240415 Rel.171219", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "F0090D000000", + "model": "EP40M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "America/Denver", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.KASAPLUG" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 240415 Rel.171219", + "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": "F0-09-0D-00-00-00", + "model": "EP40M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "America/Denver", + "rssi": -66, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -420, + "type": "SMART.KASAPLUG" + }, + "get_device_time": { + "region": "America/Denver", + "time_diff": -420, + "timestamp": 1729316541 + }, + "get_device_usage": { + "time_usage": { + "past30": 842, + "past7": 842, + "today": 249 + } + }, + "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.1.0 Build 240415 Rel.171219", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "never", + "led_status": false, + "night_mode": { + "end_time": 435, + "night_mode_type": "sunrise_sunset", + "start_time": 1096, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:000000-000000000000" + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "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==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 18, + "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 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "EP40M", + "device_type": "SMART.KASAPLUG", + "is_klap": true + } + } +} From a4df0143283121060a3eb89e6b0089293b4201f3 Mon Sep 17 00:00:00 2001 From: Ryan Nitcher Date: Fri, 8 Nov 2024 18:50:21 -0700 Subject: [PATCH 655/892] Add KS220 Fixture (#1237) Add Fixture for [KS220](https://www.kasasmart.com/us/products/smart-switches/kasa-smart-wifi-light-switch-dimmer-ks220), Smart Wi-Fi Light Switch, Dimmer (HomeKit). --- README.md | 2 +- SUPPORTED.md | 2 + kasa/tests/device_fixtures.py | 2 +- kasa/tests/fixtures/KS220(US)_1.0_1.0.13.json | 65 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 kasa/tests/fixtures/KS220(US)_1.0_1.0.13.json diff --git a/README.md b/README.md index cab2e38eb..bb42baf2f 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: EP10, EP25\*, HS100\*\*, HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M\*, KP401 - **Power Strips**: EP40, EP40M\*, HS107, HS300, KP200, KP303, KP400 -- **Wall Switches**: ES20M, HS200, HS210, HS220\*\*, KP405, KS200M, KS205\*, KS220M, KS225\*, KS230, KS240\* +- **Wall Switches**: ES20M, HS200, HS210, HS220\*\*, KP405, KS200M, KS205\*, KS220, KS220M, KS225\*, KS230, KS240\* - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 - **Light Strips**: KL400L5, KL420L5, KL430 - **Hubs**: KH100\* diff --git a/SUPPORTED.md b/SUPPORTED.md index 50e1d3cb8..2c8544644 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -97,6 +97,8 @@ Some newer Kasa devices require authentication. These are marked with *\* - Hardware: 1.0 (US) / Firmware: 1.1.0\* +- **KS220** + - Hardware: 1.0 (US) / Firmware: 1.0.13 - **KS220M** - Hardware: 1.0 (US) / Firmware: 1.0.4 - **KS225** diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index d38581a99..1726ee8cb 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -111,7 +111,7 @@ STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M"} STRIPS = {*STRIPS_IOT, *STRIPS_SMART} -DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} +DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"} DIMMERS_SMART = {"HS220", "KS225", "S500D", "P135"} DIMMERS = { *DIMMERS_IOT, diff --git a/kasa/tests/fixtures/KS220(US)_1.0_1.0.13.json b/kasa/tests/fixtures/KS220(US)_1.0_1.0.13.json new file mode 100644 index 000000000..86ee9d3e7 --- /dev/null +++ b/kasa/tests/fixtures/KS220(US)_1.0_1.0.13.json @@ -0,0 +1,65 @@ +{ + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "bulb_type": 1, + "calibration_type": 1, + "err_code": 0, + "fadeOffTime": 1000, + "fadeOnTime": 1000, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 1, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "brightness": 100, + "dev_name": "Smart Wi-Fi Dimmer Switch", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "30:DE:4B:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS220(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "apple", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "preferred_state": [ + { + "brightness": 100, + "index": 0 + }, + { + "brightness": 75, + "index": 1 + }, + { + "brightness": 50, + "index": 2 + }, + { + "brightness": 25, + "index": 3 + } + ], + "relay_state": 0, + "rssi": -47, + "status": "configured", + "sw_ver": "1.0.13 Build 240424 Rel.102214", + "updating": 0 + } + } +} From 857a70664983aa240449aca26cfc93b469054182 Mon Sep 17 00:00:00 2001 From: Ryan Nitcher Date: Sat, 9 Nov 2024 00:16:41 -0700 Subject: [PATCH 656/892] Add Additional Firmware Test Fixures (#1234) Fixtures for the following new firmware versions on existing devices: - ES20M(US)_1.0_1.0.11 - HS200(US)_3.0_1.1.5 - HS200(US)_5.0_1.0.11 - HS210(US)_2.0_1.1.5 - KP303(US)_2.0_1.0.9 - KS200M(US)_1.0_1.0.10 - KP125M(US)_1.0_1.2.3 - KS240(US)_1.0_1.0.7 --- SUPPORTED.md | 8 + kasa/tests/fixtures/ES20M(US)_1.0_1.0.11.json | 127 +++ kasa/tests/fixtures/HS200(US)_3.0_1.1.5.json | 31 + kasa/tests/fixtures/HS200(US)_5.0_1.0.11.json | 32 + kasa/tests/fixtures/HS210(US)_2.0_1.1.5.json | 31 + kasa/tests/fixtures/KP303(US)_2.0_1.0.9.json | 55 ++ .../tests/fixtures/KS200M(US)_1.0_1.0.10.json | 96 ++ .../fixtures/smart/KP125M(US)_1.0_1.2.3.json | 487 +++++++++ .../fixtures/smart/KS240(US)_1.0_1.0.7.json | 926 ++++++++++++++++++ 9 files changed, 1793 insertions(+) create mode 100644 kasa/tests/fixtures/ES20M(US)_1.0_1.0.11.json create mode 100644 kasa/tests/fixtures/HS200(US)_3.0_1.1.5.json create mode 100644 kasa/tests/fixtures/HS200(US)_5.0_1.0.11.json create mode 100644 kasa/tests/fixtures/HS210(US)_2.0_1.1.5.json create mode 100644 kasa/tests/fixtures/KP303(US)_2.0_1.0.9.json create mode 100644 kasa/tests/fixtures/KS200M(US)_1.0_1.0.10.json create mode 100644 kasa/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json create mode 100644 kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 2c8544644..801586819 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -47,6 +47,7 @@ Some newer Kasa devices require authentication. These are marked with *\* + - Hardware: 1.0 (US) / Firmware: 1.2.3\* - **KP401** - Hardware: 1.0 (US) / Firmware: 1.0.0 @@ -68,6 +69,7 @@ Some newer Kasa devices require authentication. These are marked with ****\* - Hardware: 1.0 (US) / Firmware: 1.0.5\* + - Hardware: 1.0 (US) / Firmware: 1.0.7\* ### Bulbs diff --git a/kasa/tests/fixtures/ES20M(US)_1.0_1.0.11.json b/kasa/tests/fixtures/ES20M(US)_1.0_1.0.11.json new file mode 100644 index 000000000..dd7272360 --- /dev/null +++ b/kasa/tests/fixtures/ES20M(US)_1.0_1.0.11.json @@ -0,0 +1,127 @@ +{ + "smartlife.iot.LAS": { + "get_config": { + "devs": [ + { + "dark_index": 0, + "enable": 1, + "hw_id": 0, + "level_array": [ + { + "adc": 390, + "name": "cloudy", + "value": 15 + }, + { + "adc": 300, + "name": "overcast", + "value": 11 + }, + { + "adc": 222, + "name": "dawn", + "value": 8 + }, + { + "adc": 222, + "name": "twilight", + "value": 8 + }, + { + "adc": 111, + "name": "total darkness", + "value": 4 + }, + { + "adc": 2400, + "name": "custom", + "value": 94 + } + ], + "max_adc": 2550, + "min_adc": 0 + } + ], + "err_code": 0, + "ver": "1.0" + } + }, + "smartlife.iot.PIR": { + "get_config": { + "array": [ + 80, + 50, + 20, + 0 + ], + "cold_time": 120000, + "enable": 0, + "err_code": 0, + "max_adc": 4095, + "min_adc": 0, + "trigger_index": 1, + "version": "1.0" + } + }, + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "bulb_type": 1, + "err_code": 0, + "fadeOffTime": 0, + "fadeOnTime": 0, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 5, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "brightness": 100, + "dev_name": "Wi-Fi Smart Dimmer with sensor", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "28:87:BA:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "ES20M(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "preferred_state": [ + { + "brightness": 100, + "index": 0 + }, + { + "brightness": 75, + "index": 1 + }, + { + "brightness": 50, + "index": 2 + }, + { + "brightness": 25, + "index": 3 + } + ], + "relay_state": 0, + "rssi": -54, + "status": "new", + "sw_ver": "1.0.11 Build 240514 Rel.110351", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/HS200(US)_3.0_1.1.5.json b/kasa/tests/fixtures/HS200(US)_3.0_1.1.5.json new file mode 100644 index 000000000..f953e7a12 --- /dev/null +++ b/kasa/tests/fixtures/HS200(US)_3.0_1.1.5.json @@ -0,0 +1,31 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Light Switch", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "3.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "D8:07:B6:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS200(US)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -44, + "status": "new", + "sw_ver": "1.1.5 Build 210422 Rel.082129", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/HS200(US)_5.0_1.0.11.json b/kasa/tests/fixtures/HS200(US)_5.0_1.0.11.json new file mode 100644 index 000000000..19780635d --- /dev/null +++ b/kasa/tests/fixtures/HS200(US)_5.0_1.0.11.json @@ -0,0 +1,32 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Light Switch", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "5.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "28:87:BA:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS200(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -41, + "status": "new", + "sw_ver": "1.0.11 Build 230908 Rel.160526", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/HS210(US)_2.0_1.1.5.json b/kasa/tests/fixtures/HS210(US)_2.0_1.1.5.json new file mode 100644 index 000000000..3478b2b51 --- /dev/null +++ b/kasa/tests/fixtures/HS210(US)_2.0_1.1.5.json @@ -0,0 +1,31 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi 3-Way Light Switch", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "D8:07:B6:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS210(US)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -43, + "status": "new", + "sw_ver": "1.1.5 Build 210422 Rel.113212", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/KP303(US)_2.0_1.0.9.json b/kasa/tests/fixtures/KP303(US)_2.0_1.0.9.json new file mode 100644 index 000000000..d500ebb8f --- /dev/null +++ b/kasa/tests/fixtures/KP303(US)_2.0_1.0.9.json @@ -0,0 +1,55 @@ +{ + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 3, + "children": [ + { + "alias": "#MASKED_NAME#", + "id": "800639AA097730E58235162FCDA301CE1F038F9101", + "next_action": { + "type": -1 + }, + "on_time": 1461030, + "state": 1 + }, + { + "alias": "#MASKED_NAME#", + "id": "800639AA097730E58235162FCDA301CE1F038F9102", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "#MASKED_NAME#", + "id": "800639AA097730E58235162FCDA301CE1F038F9100", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "latitude_i": 0, + "led_off": 1, + "longitude_i": 0, + "mac": "B0:A7:B9:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP303(US)", + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "rssi": -58, + "status": "new", + "sw_ver": "1.0.9 Build 240131 Rel.141407", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/KS200M(US)_1.0_1.0.10.json b/kasa/tests/fixtures/KS200M(US)_1.0_1.0.10.json new file mode 100644 index 000000000..87c64f4bb --- /dev/null +++ b/kasa/tests/fixtures/KS200M(US)_1.0_1.0.10.json @@ -0,0 +1,96 @@ +{ + "smartlife.iot.LAS": { + "get_config": { + "devs": [ + { + "dark_index": 0, + "enable": 1, + "hw_id": 0, + "level_array": [ + { + "adc": 390, + "name": "cloudy", + "value": 15 + }, + { + "adc": 300, + "name": "overcast", + "value": 12 + }, + { + "adc": 222, + "name": "dawn", + "value": 9 + }, + { + "adc": 222, + "name": "twilight", + "value": 9 + }, + { + "adc": 111, + "name": "total darkness", + "value": 4 + }, + { + "adc": 2400, + "name": "custom", + "value": 97 + } + ], + "max_adc": 2450, + "min_adc": 0 + } + ], + "err_code": 0, + "ver": "1.0" + } + }, + "smartlife.iot.PIR": { + "get_config": { + "array": [ + 80, + 50, + 20, + 0 + ], + "cold_time": 15000, + "enable": 1, + "err_code": 0, + "max_adc": 4095, + "min_adc": 0, + "trigger_index": 0, + "version": "1.0" + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Light Switch with PIR", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "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": "KS200M(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -39, + "status": "new", + "sw_ver": "1.0.10 Build 221019 Rel.194527", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json b/kasa/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json new file mode 100644 index 000000000..7605af0f2 --- /dev/null +++ b/kasa/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json @@ -0,0 +1,487 @@ +{ + "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": "matter", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KP125M(US)", + "device_type": "SMART.KASAPLUG", + "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": "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_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": 69 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "coffee_maker", + "default_states": { + "state": { + "on": true + }, + "type": "custom" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.3 Build 240624 Rel.154806", + "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": "KP125M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 8818511, + "overcurrent_status": "normal", + "overheat_status": "normal", + "power_protection_status": "normal", + "region": "America/Denver", + "rssi": -40, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -420, + "type": "SMART.KASAPLUG" + }, + "get_device_time": { + "region": "America/Denver", + "time_diff": -420, + "timestamp": 1730957712 + }, + "get_device_usage": { + "power_usage": { + "past30": 46786, + "past7": 10896, + "today": 1507 + }, + "saved_power": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "time_usage": { + "past30": 43175, + "past7": 10055, + "today": 1355 + } + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 120, + "offpeak": 60, + "onpeak": 170, + "period": [ + 5, + 1, + 10, + 31 + ], + "weekday_config": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 2, + 2, + 2, + 2, + 0, + 0, + 0, + 0, + 0 + ], + "weekend_config": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "winter": { + "midpeak": 90, + "offpeak": 60, + "onpeak": 110, + "period": [ + 11, + 1, + 4, + 30 + ], + "weekday_config": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 2, + 2, + 2, + 2, + 0, + 0, + 0, + 0, + 0 + ], + "weekend_config": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "type": "time_of_use" + }, + "get_energy_usage": { + "current_power": 69737, + "electricity_charge": [ + 2901, + 3351, + 536 + ], + "local_time": "2024-11-06 22:35:14", + "month_energy": 9332, + "month_runtime": 8615, + "today_energy": 1507, + "today_runtime": 1355 + }, + "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.2.3 Build 240624 Rel.154806", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 396, + "night_mode_type": "sunrise_sunset", + "start_time": 1013, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:000000-000000000000" + }, + "get_max_power": { + "max_power": 1537 + }, + "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": [ + { + "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": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 7, + "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": "KP125M", + "device_type": "SMART.KASAPLUG", + "is_klap": true + } + } +} diff --git a/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json b/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json new file mode 100644 index 000000000..a3f28309f --- /dev/null +++ b/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json @@ -0,0 +1,926 @@ +{ + "child_devices": { + "SCRUBBED_CHILD_DEVICE_ID_1": { + "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": "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": "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 + } + ] + }, + "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": "switch_ks240", + "bind_count": 1, + "brightness": 100, + "category": "kasa.switch.outlet.sub-dimmer", + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": false, + "fade_off_time": 1, + "fade_on_time": 1, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 240607 Rel.205559", + "gc": 1, + "gradually_off_mode": 1, + "gradually_on_mode": 1, + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "la": 0, + "lang": "en_US", + "led_off": 0, + "lo": 0, + "mac": "F0A731000000", + "max_fade_off_time": 60, + "max_fade_on_time": 60, + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 0, + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "preset_state": [ + { + "brightness": 100 + }, + { + "brightness": 75 + }, + { + "brightness": 50 + }, + { + "brightness": 25 + }, + { + "brightness": 1 + } + ], + "region": "America/Denver", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + "get_device_usage": { + "time_usage": { + "past30": 113, + "past7": 113, + "today": 0 + } + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + }, + "on_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + } + }, + "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 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_2": { + "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": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "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": "fan_control", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "ceiling_fan", + "bind_count": 1, + "category": "kasa.switch.outlet.sub-fan", + "default_states": { + "re_power_fan_speed": 1, + "re_power_type": "last_states", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ] + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": false, + "fan_sleep_mode_on": false, + "fan_speed_level": 3, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 240607 Rel.205559", + "gc": 1, + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "la": 0, + "lang": "en_US", + "lo": 0, + "mac": "F0A731000000", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "region": "America/Denver", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + } + }, + "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": 2 + }, + { + "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": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS240(US)", + "device_type": "SMART.KASASWITCH", + "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": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "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": "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 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "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": "fan_control", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "ceiling_fan", + "bind_count": 1, + "category": "kasa.switch.outlet.sub-fan", + "default_states": { + "re_power_fan_speed": 1, + "re_power_type": "last_states", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ] + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": false, + "fan_sleep_mode_on": false, + "fan_speed_level": 3, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 240607 Rel.205559", + "gc": 1, + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "la": 0, + "lang": "en_US", + "lo": 0, + "mac": "F0A731000000", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "region": "America/Denver", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + { + "avatar": "switch_ks240", + "bind_count": 1, + "brightness": 100, + "category": "kasa.switch.outlet.sub-dimmer", + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": false, + "fade_off_time": 1, + "fade_on_time": 1, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 240607 Rel.205559", + "gc": 1, + "gradually_off_mode": 1, + "gradually_on_mode": 1, + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "la": 0, + "lang": "en_US", + "led_off": 0, + "lo": 0, + "mac": "F0A731000000", + "max_fade_off_time": 60, + "max_fade_on_time": 60, + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 0, + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "preset_state": [ + { + "brightness": 100 + }, + { + "brightness": 75 + }, + { + "brightness": 50 + }, + { + "brightness": 25 + }, + { + "brightness": 1 + } + ], + "region": "America/Denver", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 240607 Rel.205559", + "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": "F0-A7-31-00-00-00", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "America/Denver", + "rssi": -50, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -420, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Denver", + "time_diff": -420, + "timestamp": 1730957728 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_homekit_info": { + "mfi_setup_code": "000-00-000", + "mfi_setup_id": "0000", + "mfi_token_token": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000+00000000000000000000000000000000000000/00000000000000+0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000/00000000000", + "mfi_token_uuid": "00000000-0000-0000-0000-000000000000" + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.7 Build 240607 Rel.205559", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "auto", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1200 + } + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "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==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 13, + "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 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "KS240", + "device_type": "SMART.KASASWITCH", + "is_klap": true + } + } +} From 4e9a3e6b023dc21308e4d002e35a776d2c9674c9 Mon Sep 17 00:00:00 2001 From: Puxtril Date: Sat, 9 Nov 2024 12:03:06 -0500 Subject: [PATCH 657/892] Print formatting for IotLightPreset (#1216) Now prints presets as such: ``` [0] Hue: 0 Saturation: 0 Brightness/Value: 100 Temp: 6000 Custom: None Mode: None Id: None [1] Hue: 0 Saturation: 0 Brightness/Value: 100 Temp: 2500 Custom: None Mode: None Id: None [2] Hue: 0 Saturation: 0 Brightness/Value: 60 Temp: 2500 Custom: None Mode: None Id: None [3] Hue: 240 Saturation: 100 Brightness/Value: 100 Temp: 0 Custom: None Mode: None Id: None ``` --- kasa/cli/light.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/kasa/cli/light.py b/kasa/cli/light.py index 06c469077..d9feee783 100644 --- a/kasa/cli/light.py +++ b/kasa/cli/light.py @@ -130,8 +130,11 @@ def presets_list(dev: Device): error("Presets not supported on device") return - for preset in light_preset.preset_states_list: - echo(preset) + for idx, preset in enumerate(light_preset.preset_states_list): + echo( + f"[{idx}] Hue: {preset.hue:3} Saturation: {preset.saturation:3} " + f"Brightness/Value: {preset.brightness:3} Temp: {preset.color_temp:4}" + ) return light_preset.preset_states_list From 24d7b8612e0e4de4dea4e2aab565f31e99441b87 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sun, 10 Nov 2024 14:56:14 +0100 Subject: [PATCH 658/892] Add L630 fixture (#1240) --- README.md | 2 +- SUPPORTED.md | 2 + .../fixtures/smart/L630(EU)_1.0_1.1.2.json | 452 ++++++++++++++++++ 3 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json diff --git a/README.md b/README.md index bb42baf2f..0da595a84 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: P100, P110, P115, P125M, P135, TP15 - **Power Strips**: P300, P304M, TP25 - **Wall Switches**: S500D, S505, S505D -- **Bulbs**: L510B, L510E, L530E +- **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Hubs**: H100 - **Hub-Connected Devices\*\*\***: S200B, S200D, T100, T110, T300, T310, T315 diff --git a/SUPPORTED.md b/SUPPORTED.md index 801586819..7452b69a4 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -227,6 +227,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 3.0 (EU) / Firmware: 1.1.0 - Hardware: 3.0 (EU) / Firmware: 1.1.6 - Hardware: 2.0 (US) / Firmware: 1.1.0 +- **L630** + - Hardware: 1.0 (EU) / Firmware: 1.1.2 ### Light Strips diff --git a/kasa/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json b/kasa/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json new file mode 100644 index 000000000..4ca91c9b4 --- /dev/null +++ b/kasa/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json @@ -0,0 +1,452 @@ +{ + "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": 3 + }, + { + "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": "L630(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-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": false, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [ + { + "delay": 120, + "desired_states": { + "on": false + }, + "enable": false, + "id": "C1", + "remain": 0 + } + ] + }, + "get_device_info": { + "avatar": "", + "brightness": 35, + "color_temp": 3200, + "color_temp_range": [ + 2200, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 100, + "color_temp": 3100, + "hue": 0, + "saturation": 100 + }, + "type": "custom" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240926 Rel.164744", + "has_set_location_info": false, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "40-AE-30-00-00-00", + "model": "L630", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Vienna", + "rssi": -71, + "saturation": 100, + "signal_level": 1, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/Vienna", + "time_diff": 60, + "timestamp": 1731224827 + }, + "get_device_usage": { + "power_usage": { + "past30": 206, + "past7": 11, + "today": 0 + }, + "saved_power": { + "past30": 6610, + "past7": 424, + "today": 0 + }, + "time_usage": { + "past30": 6816, + "past7": 435, + "today": 0 + } + }, + "get_dynamic_light_effect_rules": { + "enable": false, + "max_count": 2, + "rule_list": [ + { + "change_mode": "direct", + "change_time": 1000, + "color_status_list": [ + [ + 10, + 0, + 0, + 2700 + ], + [ + 10, + 321, + 99, + 0 + ], + [ + 10, + 196, + 99, + 0 + ], + [ + 10, + 6, + 97, + 0 + ], + [ + 10, + 160, + 100, + 0 + ], + [ + 10, + 274, + 95, + 0 + ], + [ + 10, + 48, + 100, + 0 + ], + [ + 10, + 242, + 99, + 0 + ] + ], + "id": "L1", + "scene_name": "Party" + }, + { + "change_mode": "bln", + "change_time": 3000, + "color_status_list": [ + [ + 100, + 240, + 100, + 0 + ], + [ + 100, + 195, + 100, + 0 + ], + [ + 100, + 195, + 100, + 0 + ], + [ + 100, + 240, + 100, + 0 + ] + ], + "id": "L2", + "scene_name": "Relax" + } + ], + "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_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 4, + "enable": false, + "max_duration": 60 + }, + "on_state": { + "duration": 8, + "enable": true, + "max_duration": 60 + } + }, + "get_preset_rules": { + "states": [ + { + "brightness": 100, + "color_temp": 3100, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 4500, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 50, + "color_temp": 3200, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 1, + "color_temp": 3200, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 1, + "color_temp": 0, + "hue": 300, + "saturation": 100 + }, + { + "brightness": 1, + "color_temp": 0, + "hue": 195, + "saturation": 100 + }, + { + "brightness": 1, + "color_temp": 0, + "hue": 240, + "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": [ + { + "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==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 3, + "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": "L630", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} From 6b44fe6242c59dc4c27480aad73785a1c589f8d4 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sun, 10 Nov 2024 14:03:08 +0000 Subject: [PATCH 659/892] Fixup contributing.md for running test against a real device (#1236) --- docs/source/contribute.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/contribute.md b/docs/source/contribute.md index 4b40c6468..e2aae43f8 100644 --- a/docs/source/contribute.md +++ b/docs/source/contribute.md @@ -42,7 +42,7 @@ $ uv run pytest kasa This will run the tests against the contributed example responses. ```{note} -You can also execute the tests against a real device using `pytest --ip
`. +You can also execute the tests against a real device using `uv run pytest --ip=
--username= --password=`. Note that this will perform state changes on the device. ``` @@ -74,7 +74,7 @@ $ python -m devtools.dump_devinfo --username --password -- ``` ```{note} -You can also execute the script against a network by using `--target`: `python -m devtools.dump_devinfo --target network 192.168.1.255` +You can also execute the script against a network by using `--target`: `python -m devtools.dump_devinfo --target 192.168.1.255` ``` The script will run queries against the device, and prompt at the end if you want to save the results. From 66eb17057ef223be185e381896e377740e7fbf54 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sun, 10 Nov 2024 19:55:13 +0100 Subject: [PATCH 660/892] Enable ruff check for ANN (#1139) --- docs/source/conf.py | 2 +- kasa/__init__.py | 6 +- kasa/aestransport.py | 38 ++++++------ kasa/cli/common.py | 23 +++++--- kasa/cli/device.py | 2 +- kasa/cli/discover.py | 8 +-- kasa/cli/feature.py | 6 +- kasa/cli/lazygroup.py | 8 ++- kasa/cli/light.py | 2 +- kasa/cli/main.py | 6 +- kasa/cli/schedule.py | 2 +- kasa/cli/time.py | 2 +- kasa/cli/usage.py | 16 +++--- kasa/cli/wifi.py | 2 +- kasa/device.py | 36 ++++++------ kasa/device_factory.py | 4 +- kasa/deviceconfig.py | 10 ++-- kasa/discover.py | 69 +++++++++++++--------- kasa/emeterstatus.py | 4 +- kasa/exceptions.py | 10 ++-- kasa/experimental/__init__.py | 4 +- kasa/experimental/smartcameraprotocol.py | 10 ++-- kasa/experimental/sslaestransport.py | 14 +++-- kasa/feature.py | 12 ++-- kasa/httpclient.py | 3 +- kasa/interfaces/energy.py | 17 ++++-- kasa/interfaces/fan.py | 2 +- kasa/interfaces/led.py | 4 +- kasa/interfaces/light.py | 2 +- kasa/interfaces/lighteffect.py | 7 ++- kasa/interfaces/lightpreset.py | 6 +- kasa/iot/iotbulb.py | 12 ++-- kasa/iot/iotdevice.py | 52 ++++++++++------- kasa/iot/iotdimmer.py | 26 +++++---- kasa/iot/iotlightstrip.py | 2 +- kasa/iot/iotmodule.py | 13 +++-- kasa/iot/iotplug.py | 7 ++- kasa/iot/iotstrip.py | 49 ++++++++++------ kasa/iot/modules/ambientlight.py | 10 ++-- kasa/iot/modules/cloud.py | 12 ++-- kasa/iot/modules/emeter.py | 10 +++- kasa/iot/modules/led.py | 4 +- kasa/iot/modules/light.py | 4 +- kasa/iot/modules/lighteffect.py | 10 ++-- kasa/iot/modules/lightpreset.py | 12 ++-- kasa/iot/modules/motion.py | 10 ++-- kasa/iot/modules/rulemodule.py | 10 ++-- kasa/iot/modules/time.py | 8 +-- kasa/iot/modules/usage.py | 32 ++++++----- kasa/json.py | 8 ++- kasa/klaptransport.py | 52 ++++++++++------- kasa/module.py | 14 ++--- kasa/protocol.py | 2 +- kasa/smart/effects.py | 4 +- kasa/smart/modules/alarm.py | 6 +- kasa/smart/modules/autooff.py | 8 +-- kasa/smart/modules/batterysensor.py | 6 +- kasa/smart/modules/brightness.py | 10 ++-- kasa/smart/modules/childprotection.py | 2 +- kasa/smart/modules/cloud.py | 4 +- kasa/smart/modules/color.py | 4 +- kasa/smart/modules/colortemperature.py | 6 +- kasa/smart/modules/contactsensor.py | 4 +- kasa/smart/modules/devicemodule.py | 2 +- kasa/smart/modules/energy.py | 32 ++++++----- kasa/smart/modules/fan.py | 8 +-- kasa/smart/modules/firmware.py | 16 +++--- kasa/smart/modules/frostprotection.py | 2 +- kasa/smart/modules/humiditysensor.py | 4 +- kasa/smart/modules/led.py | 8 +-- kasa/smart/modules/light.py | 2 +- kasa/smart/modules/lighteffect.py | 10 ++-- kasa/smart/modules/lightpreset.py | 18 +++--- kasa/smart/modules/lightstripeffect.py | 17 +++--- kasa/smart/modules/lighttransition.py | 12 ++-- kasa/smart/modules/motionsensor.py | 4 +- kasa/smart/modules/reportmode.py | 4 +- kasa/smart/modules/temperaturecontrol.py | 8 +-- kasa/smart/modules/temperaturesensor.py | 8 ++- kasa/smart/modules/time.py | 6 +- kasa/smart/modules/waterleaksensor.py | 2 +- kasa/smart/smartchilddevice.py | 6 +- kasa/smart/smartdevice.py | 70 +++++++++++++---------- kasa/smart/smartmodule.py | 22 +++---- kasa/smartprotocol.py | 14 +++-- kasa/tests/fakeprotocol_smart.py | 4 +- kasa/tests/smart/modules/test_firmware.py | 2 +- kasa/xortransport.py | 2 +- pyproject.toml | 15 +++++ 89 files changed, 596 insertions(+), 452 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 5554abf13..03e44d95a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -66,6 +66,6 @@ myst_heading_anchors = 3 -def setup(app): +def setup(app): # noqa: ANN201,ANN001 # add copybutton to hide the >>> prompts, see https://github.com/readthedocs/sphinx_rtd_theme/issues/167 app.add_js_file("copybutton.js") diff --git a/kasa/__init__.py b/kasa/__init__.py index a74cb4c41..ffeaa5038 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -13,7 +13,7 @@ """ from importlib.metadata import version -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from warnings import warn from kasa.credentials import Credentials @@ -101,7 +101,7 @@ } -def __getattr__(name): +def __getattr__(name: str) -> Any: if name in deprecated_names: warn(f"{name} is deprecated", DeprecationWarning, stacklevel=2) return globals()[f"_deprecated_{name}"] @@ -117,7 +117,7 @@ def __getattr__(name): ) return new_class if name in deprecated_classes: - new_class = deprecated_classes[name] + new_class = deprecated_classes[name] # type: ignore[assignment] msg = f"{name} is deprecated, use {new_class.__name__} instead" warn(msg, DeprecationWarning, stacklevel=2) return new_class diff --git a/kasa/aestransport.py b/kasa/aestransport.py index ae75117c2..fc807fb3a 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -146,7 +146,7 @@ def hash_credentials(login_v2: bool, credentials: Credentials) -> tuple[str, str pw = base64.b64encode(credentials.password.encode()).decode() return un, pw - def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: + def _handle_response_error_code(self, resp_dict: dict, msg: str) -> None: error_code_raw = resp_dict.get("error_code") try: error_code = SmartErrorCode.from_int(error_code_raw) @@ -191,14 +191,14 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]: + f"status code {status_code} to passthrough" ) - self._handle_response_error_code( - resp_dict, "Error sending secure_passthrough message" - ) - if TYPE_CHECKING: resp_dict = cast(Dict[str, Any], resp_dict) assert self._encryption_session is not None + self._handle_response_error_code( + resp_dict, "Error sending secure_passthrough message" + ) + raw_response: str = resp_dict["result"]["response"] try: @@ -219,7 +219,7 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]: ) from ex return ret_val # type: ignore[return-value] - async def perform_login(self): + async def perform_login(self) -> None: """Login to the device.""" try: await self.try_login(self._login_params) @@ -324,11 +324,11 @@ async def perform_handshake(self) -> None: + f"status code {status_code} to handshake" ) - self._handle_response_error_code(resp_dict, "Unable to complete handshake") - if TYPE_CHECKING: resp_dict = cast(Dict[str, Any], resp_dict) + self._handle_response_error_code(resp_dict, "Unable to complete handshake") + handshake_key = resp_dict["result"]["key"] if ( @@ -355,7 +355,7 @@ async def perform_handshake(self) -> None: _LOGGER.debug("Handshake with %s complete", self._host) - def _handshake_session_expired(self): + def _handshake_session_expired(self) -> bool: """Return true if session has expired.""" return ( self._session_expire_at is None @@ -394,7 +394,9 @@ class AesEncyptionSession: """Class for an AES encryption session.""" @staticmethod - def create_from_keypair(handshake_key: str, keypair: KeyPair): + def create_from_keypair( + handshake_key: str, keypair: KeyPair + ) -> AesEncyptionSession: """Create the encryption session.""" handshake_key_bytes: bytes = base64.b64decode(handshake_key.encode()) @@ -404,11 +406,11 @@ def create_from_keypair(handshake_key: str, keypair: KeyPair): return AesEncyptionSession(key_and_iv[:16], key_and_iv[16:]) - def __init__(self, key, iv): + def __init__(self, key: bytes, iv: bytes) -> None: self.cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) self.padding_strategy = padding.PKCS7(algorithms.AES.block_size) - def encrypt(self, data) -> bytes: + def encrypt(self, data: bytes) -> bytes: """Encrypt the message.""" encryptor = self.cipher.encryptor() padder = self.padding_strategy.padder() @@ -416,7 +418,7 @@ def encrypt(self, data) -> bytes: encrypted = encryptor.update(padded_data) + encryptor.finalize() return base64.b64encode(encrypted) - def decrypt(self, data) -> str: + def decrypt(self, data: str | bytes) -> str: """Decrypt the message.""" decryptor = self.cipher.decryptor() unpadder = self.padding_strategy.unpadder() @@ -429,14 +431,16 @@ class KeyPair: """Class for generating key pairs.""" @staticmethod - def create_key_pair(key_size: int = 1024): + def create_key_pair(key_size: int = 1024) -> KeyPair: """Create a key pair.""" private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size) public_key = private_key.public_key() return KeyPair(private_key, public_key) @staticmethod - def create_from_der_keys(private_key_der_b64: str, public_key_der_b64: str): + def create_from_der_keys( + private_key_der_b64: str, public_key_der_b64: str + ) -> KeyPair: """Create a key pair.""" key_bytes = base64.b64decode(private_key_der_b64.encode()) private_key = cast( @@ -449,7 +453,9 @@ def create_from_der_keys(private_key_der_b64: str, public_key_der_b64: str): return KeyPair(private_key, public_key) - def __init__(self, private_key: rsa.RSAPrivateKey, public_key: rsa.RSAPublicKey): + def __init__( + self, private_key: rsa.RSAPrivateKey, public_key: rsa.RSAPublicKey + ) -> None: self.private_key = private_key self.public_key = public_key self.private_key_der_bytes = self.private_key.private_bytes( diff --git a/kasa/cli/common.py b/kasa/cli/common.py index fbd6291bd..fe7be761a 100644 --- a/kasa/cli/common.py +++ b/kasa/cli/common.py @@ -7,7 +7,7 @@ import sys from contextlib import contextmanager from functools import singledispatch, update_wrapper, wraps -from typing import Final +from typing import TYPE_CHECKING, Any, Callable, Final import asyncclick as click @@ -37,7 +37,7 @@ def _strip_rich_formatting(echo_func): """Strip rich formatting from messages.""" @wraps(echo_func) - def wrapper(message=None, *args, **kwargs): + def wrapper(message=None, *args, **kwargs) -> None: if message is not None: message = rich_formatting.sub("", message) echo_func(message, *args, **kwargs) @@ -47,20 +47,20 @@ def wrapper(message=None, *args, **kwargs): _echo = _strip_rich_formatting(click.echo) -def echo(*args, **kwargs): +def echo(*args, **kwargs) -> None: """Print a message.""" ctx = click.get_current_context().find_root() if "json" not in ctx.params or ctx.params["json"] is False: _echo(*args, **kwargs) -def error(msg: str): +def error(msg: str) -> None: """Print an error and exit.""" echo(f"[bold red]{msg}[/bold red]") sys.exit(1) -def json_formatter_cb(result, **kwargs): +def json_formatter_cb(result: Any, **kwargs) -> None: """Format and output the result as JSON, if requested.""" if not kwargs.get("json"): return @@ -82,7 +82,7 @@ def _device_to_serializable(val: Device): print(json_content) -def pass_dev_or_child(wrapped_function): +def pass_dev_or_child(wrapped_function: Callable) -> Callable: """Pass the device or child to the click command based on the child options.""" child_help = ( "Child ID or alias for controlling sub-devices. " @@ -133,7 +133,10 @@ async def wrapper(ctx: click.Context, dev, *args, child, child_index, **kwargs): async def _get_child_device( - device: Device, child_option, child_index_option, info_command + device: Device, + child_option: str | None, + child_index_option: int | None, + info_command: str | None, ) -> Device | None: def _list_children(): return "\n".join( @@ -178,11 +181,15 @@ def _list_children(): f"{child_option} children are:\n{_list_children()}" ) + if TYPE_CHECKING: + assert isinstance(child_index_option, int) + if child_index_option + 1 > len(device.children) or child_index_option < 0: error( f"Invalid index {child_index_option}, " f"device has {len(device.children)} children" ) + child_by_index = device.children[child_index_option] echo(f"Targeting child device {child_by_index.alias}") return child_by_index @@ -195,7 +202,7 @@ def CatchAllExceptions(cls): https://stackoverflow.com/questions/52213375 """ - def _handle_exception(debug, exc): + def _handle_exception(debug, exc) -> None: if isinstance(exc, click.ClickException): raise # Handle exit request from click. diff --git a/kasa/cli/device.py b/kasa/cli/device.py index 9814108c6..2e621368e 100644 --- a/kasa/cli/device.py +++ b/kasa/cli/device.py @@ -22,7 +22,7 @@ @click.group() @pass_dev_or_child -def device(dev): +def device(dev) -> None: """Commands to control basic device settings.""" diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index 6a55cb432..8df59de84 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -36,7 +36,7 @@ async def detail(ctx): auth_failed = [] sem = asyncio.Semaphore() - async def print_unsupported(unsupported_exception: UnsupportedDeviceError): + async def print_unsupported(unsupported_exception: UnsupportedDeviceError) -> None: unsupported.append(unsupported_exception) async with sem: if unsupported_exception.discovery_result: @@ -50,7 +50,7 @@ async def print_unsupported(unsupported_exception: UnsupportedDeviceError): from .device import state - async def print_discovered(dev: Device): + async def print_discovered(dev: Device) -> None: async with sem: try: await dev.update() @@ -189,7 +189,7 @@ def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None: error(f"Unable to connect to {host}") -def _echo_dictionary(discovery_info: dict): +def _echo_dictionary(discovery_info: dict) -> None: echo("\t[bold]== Discovery information ==[/bold]") for key, value in discovery_info.items(): key_name = " ".join(x.capitalize() or "_" for x in key.split("_")) @@ -197,7 +197,7 @@ def _echo_dictionary(discovery_info: dict): echo(f"\t{key_name_and_spaces}{value}") -def _echo_discovery_info(discovery_info): +def _echo_discovery_info(discovery_info) -> None: # We don't have discovery info when all connection params are passed manually if discovery_info is None: return diff --git a/kasa/cli/feature.py b/kasa/cli/feature.py index f8cba4e32..2c5fa0456 100644 --- a/kasa/cli/feature.py +++ b/kasa/cli/feature.py @@ -24,7 +24,7 @@ def _echo_features( category: Feature.Category | None = None, verbose: bool = False, indent: str = "\t", -): +) -> None: """Print out a listing of features and their values.""" if category is not None: features = { @@ -43,7 +43,9 @@ def _echo_features( echo(f"{indent}{feat.name} ({feat.id}): [red]got exception ({ex})[/red]") -def _echo_all_features(features, *, verbose=False, title_prefix=None, indent=""): +def _echo_all_features( + features, *, verbose=False, title_prefix=None, indent="" +) -> None: """Print out all features by category.""" if title_prefix is not None: echo(f"[bold]\n{indent}== {title_prefix} ==[/bold]") diff --git a/kasa/cli/lazygroup.py b/kasa/cli/lazygroup.py index 9e9724aae..a28586346 100644 --- a/kasa/cli/lazygroup.py +++ b/kasa/cli/lazygroup.py @@ -3,6 +3,8 @@ Taken from the click help files. """ +from __future__ import annotations + import importlib import asyncclick as click @@ -11,7 +13,7 @@ class LazyGroup(click.Group): """Lazy group class.""" - def __init__(self, *args, lazy_subcommands=None, **kwargs): + def __init__(self, *args, lazy_subcommands=None, **kwargs) -> None: super().__init__(*args, **kwargs) # lazy_subcommands is a map of the form: # @@ -31,9 +33,9 @@ def get_command(self, ctx, cmd_name): return self._lazy_load(cmd_name) return super().get_command(ctx, cmd_name) - def format_commands(self, ctx, formatter): + def format_commands(self, ctx, formatter) -> None: """Format the top level help output.""" - sections = {} + sections: dict[str, list] = {} for cmd, parent in self.lazy_subcommands.items(): sections.setdefault(parent, []) cmd_obj = self.get_command(ctx, cmd) diff --git a/kasa/cli/light.py b/kasa/cli/light.py index d9feee783..6b342c3da 100644 --- a/kasa/cli/light.py +++ b/kasa/cli/light.py @@ -15,7 +15,7 @@ @click.group() @pass_dev_or_child -def light(dev): +def light(dev) -> None: """Commands to control light settings.""" diff --git a/kasa/cli/main.py b/kasa/cli/main.py index a386fe4b1..d6b9fa9d7 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -43,7 +43,7 @@ DEFAULT_TARGET = "255.255.255.255" -def _legacy_type_to_class(_type): +def _legacy_type_to_class(_type: str) -> Any: from kasa.iot import ( IotBulb, IotDimmer, @@ -396,9 +396,9 @@ async def async_wrapped_device(device: Device): @cli.command() @pass_dev_or_child -async def shell(dev: Device): +async def shell(dev: Device) -> None: """Open interactive shell.""" - echo("Opening shell for %s" % dev) + echo(f"Opening shell for {dev}") from ptpython.repl import embed logging.getLogger("parso").setLevel(logging.WARNING) # prompt parsing diff --git a/kasa/cli/schedule.py b/kasa/cli/schedule.py index 8deda3150..7c9c73817 100644 --- a/kasa/cli/schedule.py +++ b/kasa/cli/schedule.py @@ -14,7 +14,7 @@ @click.group() @pass_dev -async def schedule(dev): +async def schedule(dev) -> None: """Scheduling commands.""" diff --git a/kasa/cli/time.py b/kasa/cli/time.py index 904da2cad..9e9301087 100644 --- a/kasa/cli/time.py +++ b/kasa/cli/time.py @@ -23,7 +23,7 @@ @click.group(invoke_without_command=True) @click.pass_context -async def time(ctx: click.Context): +async def time(ctx: click.Context) -> None: """Get and set time.""" if ctx.invoked_subcommand is None: await ctx.invoke(time_get) diff --git a/kasa/cli/usage.py b/kasa/cli/usage.py index 1a336c743..314182fdd 100644 --- a/kasa/cli/usage.py +++ b/kasa/cli/usage.py @@ -78,13 +78,13 @@ async def energy(dev: Device, year, month, erase): else: emeter_status = dev.emeter_realtime - echo("Current: %s A" % emeter_status["current"]) - echo("Voltage: %s V" % emeter_status["voltage"]) - echo("Power: %s W" % emeter_status["power"]) - echo("Total consumption: %s kWh" % emeter_status["total"]) + echo("Current: {} A".format(emeter_status["current"])) + echo("Voltage: {} V".format(emeter_status["voltage"])) + echo("Power: {} W".format(emeter_status["power"])) + echo("Total consumption: {} kWh".format(emeter_status["total"])) - echo("Today: %s kWh" % dev.emeter_today) - echo("This month: %s kWh" % dev.emeter_this_month) + echo(f"Today: {dev.emeter_today} kWh") + echo(f"This month: {dev.emeter_this_month} kWh") return emeter_status @@ -122,8 +122,8 @@ async def usage(dev: Device, year, month, erase): usage_data = await usage.get_daystat(year=month.year, month=month.month) else: # Call with no argument outputs summary data and returns - echo("Today: %s minutes" % usage.usage_today) - echo("This month: %s minutes" % usage.usage_this_month) + echo(f"Today: {usage.usage_today} minutes") + echo(f"This month: {usage.usage_this_month} minutes") return usage diff --git a/kasa/cli/wifi.py b/kasa/cli/wifi.py index 07fb5f207..924e83f1f 100644 --- a/kasa/cli/wifi.py +++ b/kasa/cli/wifi.py @@ -16,7 +16,7 @@ @click.group() @pass_dev -def wifi(dev): +def wifi(dev) -> None: """Commands to control wifi settings.""" diff --git a/kasa/device.py b/kasa/device.py index fb9b9f0c5..72c567175 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -234,10 +234,10 @@ async def connect( return await connect(host=host, config=config) # type: ignore[arg-type] @abstractmethod - async def update(self, update_children: bool = True): + async def update(self, update_children: bool = True) -> None: """Update the device.""" - async def disconnect(self): + async def disconnect(self) -> None: """Disconnect and close any underlying connection resources.""" await self.protocol.close() @@ -257,15 +257,15 @@ def is_off(self) -> bool: return not self.is_on @abstractmethod - async def turn_on(self, **kwargs) -> dict | None: + async def turn_on(self, **kwargs) -> dict: """Turn on the device.""" @abstractmethod - async def turn_off(self, **kwargs) -> dict | None: + async def turn_off(self, **kwargs) -> dict: """Turn off the device.""" @abstractmethod - async def set_state(self, on: bool): + async def set_state(self, on: bool) -> dict: """Set the device state to *on*. This allows turning the device on and off. @@ -278,7 +278,7 @@ def host(self) -> str: return self.protocol._transport._host @host.setter - def host(self, value): + def host(self, value: str) -> None: """Set the device host. Generally used by discovery to set the hostname after ip discovery. @@ -307,7 +307,7 @@ def device_type(self) -> DeviceType: return self._device_type @abstractmethod - def update_from_discover_info(self, info): + def update_from_discover_info(self, info: dict) -> None: """Update state from info from the discover call.""" @property @@ -325,7 +325,7 @@ def model(self) -> str: def alias(self) -> str | None: """Returns the device alias or nickname.""" - async def _raw_query(self, request: str | dict) -> Any: + async def _raw_query(self, request: str | dict) -> dict: """Send a raw query to the device.""" return await self.protocol.query(request=request) @@ -407,7 +407,7 @@ def device_id(self) -> str: @property @abstractmethod - def internal_state(self) -> Any: + def internal_state(self) -> dict: """Return all the internal state data.""" @property @@ -420,10 +420,10 @@ def features(self) -> dict[str, Feature]: """Return the list of supported features.""" return self._features - def _add_feature(self, feature: Feature): + def _add_feature(self, feature: Feature) -> None: """Add a new feature to the device.""" if feature.id in self._features: - raise KasaException("Duplicate feature id %s" % feature.id) + raise KasaException(f"Duplicate feature id {feature.id}") assert feature.id is not None # TODO: hack for typing # noqa: S101 self._features[feature.id] = feature @@ -446,11 +446,13 @@ async def wifi_scan(self) -> list[WifiNetwork]: """Scan for available wifi networks.""" @abstractmethod - async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): + async def wifi_join( + self, ssid: str, password: str, keytype: str = "wpa2_psk" + ) -> dict: """Join the given wifi network.""" @abstractmethod - async def set_alias(self, alias: str): + async def set_alias(self, alias: str) -> dict: """Set the device name (alias).""" @abstractmethod @@ -468,7 +470,7 @@ async def factory_reset(self) -> None: Note, this does not downgrade the firmware. """ - def __repr__(self): + def __repr__(self) -> str: update_needed = " - update() needed" if not self._last_update else "" return ( f"<{self.device_type} at {self.host} -" @@ -486,7 +488,9 @@ def __repr__(self): "is_strip_socket": (None, DeviceType.StripSocket), } - def _get_replacing_attr(self, module_name: ModuleName, *attrs): + def _get_replacing_attr( + self, module_name: ModuleName | None, *attrs: Any + ) -> str | None: # If module name is None check self if not module_name: check = self @@ -540,7 +544,7 @@ def _get_replacing_attr(self, module_name: ModuleName, *attrs): "supported_modules": (None, ["modules"]), } - def __getattr__(self, name): + def __getattr__(self, name: str) -> Any: # is_device_type if dep_device_type_attr := self._deprecated_device_type_attributes.get(name): module = dep_device_type_attr[0] diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 7f2150d7c..0c1ed427c 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -83,7 +83,7 @@ async def _connect(config: DeviceConfig, protocol: BaseProtocol) -> Device: if debug_enabled: start_time = time.perf_counter() - def _perf_log(has_params, perf_type): + def _perf_log(has_params: bool, perf_type: str) -> None: nonlocal start_time if debug_enabled: end_time = time.perf_counter() @@ -150,7 +150,7 @@ def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType: return DeviceType.LightStrip return DeviceType.Bulb - raise UnsupportedDeviceError("Unknown device type: %s" % type_) + raise UnsupportedDeviceError(f"Unknown device type: {type_}") def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]: diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index e0fd1725c..f4a5f2a30 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -75,14 +75,14 @@ class DeviceFamily(Enum): SmartIpCamera = "SMART.IPCAMERA" -def _dataclass_from_dict(klass, in_val): +def _dataclass_from_dict(klass: Any, in_val: dict) -> Any: if is_dataclass(klass): fieldtypes = {f.name: f.type for f in fields(klass)} val = {} for dict_key in in_val: if dict_key in fieldtypes: if hasattr(fieldtypes[dict_key], "from_dict"): - val[dict_key] = fieldtypes[dict_key].from_dict(in_val[dict_key]) + val[dict_key] = fieldtypes[dict_key].from_dict(in_val[dict_key]) # type: ignore[union-attr] else: val[dict_key] = _dataclass_from_dict( fieldtypes[dict_key], in_val[dict_key] @@ -91,12 +91,12 @@ def _dataclass_from_dict(klass, in_val): raise KasaException( f"Cannot create dataclass from dict, unknown key: {dict_key}" ) - return klass(**val) + return klass(**val) # type: ignore[operator] else: return in_val -def _dataclass_to_dict(in_val): +def _dataclass_to_dict(in_val: Any) -> dict: fieldtypes = {f.name: f.type for f in fields(in_val) if f.compare} out_val = {} for field_name in fieldtypes: @@ -210,7 +210,7 @@ class DeviceConfig: aes_keys: Optional[KeyPairDict] = None - def __post_init__(self): + def __post_init__(self) -> None: if self.connection_type is None: self.connection_type = DeviceConnectionParameters( DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor diff --git a/kasa/discover.py b/kasa/discover.py index a774ebdea..efb1e5e41 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -89,9 +89,19 @@ import secrets import socket import struct -from collections.abc import Awaitable +from asyncio.transports import DatagramTransport from pprint import pformat as pf -from typing import TYPE_CHECKING, Any, Callable, Dict, NamedTuple, Optional, Type, cast +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Coroutine, + Dict, + NamedTuple, + Optional, + Type, + cast, +) from aiohttp import ClientSession @@ -140,8 +150,8 @@ class ConnectAttempt(NamedTuple): device: type -OnDiscoveredCallable = Callable[[Device], Awaitable[None]] -OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Awaitable[None]] +OnDiscoveredCallable = Callable[[Device], Coroutine] +OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Coroutine] OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None] DeviceDict = Dict[str, Device] @@ -156,7 +166,7 @@ class _AesDiscoveryQuery: keypair: KeyPair | None = None @classmethod - def generate_query(cls): + def generate_query(cls) -> bytearray: if not cls.keypair: cls.keypair = KeyPair.create_key_pair(key_size=2048) secret = secrets.token_bytes(4) @@ -215,7 +225,7 @@ def __init__( credentials: Credentials | None = None, timeout: int | None = None, ) -> None: - self.transport = None + self.transport: DatagramTransport | None = None self.discovery_packets = discovery_packets self.interface = interface self.on_discovered = on_discovered @@ -239,16 +249,19 @@ def __init__( self.target_discovered: bool = False self._started_event = asyncio.Event() - def _run_callback_task(self, coro): - task = asyncio.create_task(coro) + def _run_callback_task(self, coro: Coroutine) -> None: + task: asyncio.Task = asyncio.create_task(coro) self.callback_tasks.append(task) - async def wait_for_discovery_to_complete(self): + async def wait_for_discovery_to_complete(self) -> None: """Wait for the discovery task to complete.""" # Give some time for connection_made event to be received async with asyncio_timeout(self.DISCOVERY_START_TIMEOUT): await self._started_event.wait() try: + if TYPE_CHECKING: + assert isinstance(self.discover_task, asyncio.Task) + await self.discover_task except asyncio.CancelledError: # if target_discovered then cancel was called internally @@ -257,11 +270,11 @@ async def wait_for_discovery_to_complete(self): # Wait for any pending callbacks to complete await asyncio.gather(*self.callback_tasks) - def connection_made(self, transport) -> None: + def connection_made(self, transport: DatagramTransport) -> None: # type: ignore[override] """Set socket options for broadcasting.""" - self.transport = transport + self.transport = cast(DatagramTransport, transport) - sock = transport.get_extra_info("socket") + sock = self.transport.get_extra_info("socket") sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) try: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -292,7 +305,11 @@ async def do_discover(self) -> None: self.transport.sendto(aes_discovery_query, self.target_2) # type: ignore await asyncio.sleep(sleep_between_packets) - def datagram_received(self, data, addr) -> None: + def datagram_received( + self, + data: bytes, + addr: tuple[str, int], + ) -> None: """Handle discovery responses.""" if TYPE_CHECKING: assert _AesDiscoveryQuery.keypair @@ -338,18 +355,18 @@ def datagram_received(self, data, addr) -> None: self._handle_discovered_event() - def _handle_discovered_event(self): + def _handle_discovered_event(self) -> None: """If target is in seen_hosts cancel discover_task.""" if self.target in self.seen_hosts: self.target_discovered = True if self.discover_task: self.discover_task.cancel() - def error_received(self, ex): + def error_received(self, ex: Exception) -> None: """Handle asyncio.Protocol errors.""" _LOGGER.error("Got error: %s", ex) - def connection_lost(self, ex): # pragma: no cover + def connection_lost(self, ex: Exception | None) -> None: # pragma: no cover """Cancel the discover task if running.""" if self.discover_task: self.discover_task.cancel() @@ -372,17 +389,17 @@ class Discover: @staticmethod async def discover( *, - target="255.255.255.255", - on_discovered=None, - discovery_timeout=5, - discovery_packets=3, - interface=None, - on_unsupported=None, - credentials=None, + target: str = "255.255.255.255", + on_discovered: OnDiscoveredCallable | None = None, + discovery_timeout: int = 5, + discovery_packets: int = 3, + interface: str | None = None, + on_unsupported: OnUnsupportedCallable | None = None, + credentials: Credentials | None = None, username: str | None = None, password: str | None = None, - port=None, - timeout=None, + port: int | None = None, + timeout: int | None = None, ) -> DeviceDict: """Discover supported devices. @@ -636,7 +653,7 @@ def _get_device_class(info: dict) -> type[Device]: ) if not dev_class: raise UnsupportedDeviceError( - "Unknown device type: %s" % discovery_result.device_type, + f"Unknown device type: {discovery_result.device_type}", discovery_result=info, ) return dev_class diff --git a/kasa/emeterstatus.py b/kasa/emeterstatus.py index 0112b33a5..acb877894 100644 --- a/kasa/emeterstatus.py +++ b/kasa/emeterstatus.py @@ -49,13 +49,13 @@ def total(self) -> float | None: except ValueError: return None - def __repr__(self): + def __repr__(self) -> str: return ( f"" ) - def __getitem__(self, item): + def __getitem__(self, item: str) -> float | None: """Return value in wanted units.""" valid_keys = [ "voltage_mv", diff --git a/kasa/exceptions.py b/kasa/exceptions.py index b646e514c..7bc796535 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -15,10 +15,10 @@ class KasaException(Exception): class TimeoutError(KasaException, _asyncioTimeoutError): """Timeout exception for device errors.""" - def __repr__(self): + def __repr__(self) -> str: return KasaException.__repr__(self) - def __str__(self): + def __str__(self) -> str: return KasaException.__str__(self) @@ -42,11 +42,11 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.error_code: SmartErrorCode | None = kwargs.get("error_code", None) super().__init__(*args) - def __repr__(self): + def __repr__(self) -> str: err_code = self.error_code.__repr__() if self.error_code else "" return f"{self.__class__.__name__}({err_code})" - def __str__(self): + def __str__(self) -> str: err_code = f" (error_code={self.error_code.name})" if self.error_code else "" return super().__str__() + err_code @@ -62,7 +62,7 @@ class _RetryableError(DeviceError): class SmartErrorCode(IntEnum): """Enum for SMART Error Codes.""" - def __str__(self): + def __str__(self) -> str: return f"{self.name}({self.value})" @staticmethod diff --git a/kasa/experimental/__init__.py b/kasa/experimental/__init__.py index 388c57360..a866787e2 100644 --- a/kasa/experimental/__init__.py +++ b/kasa/experimental/__init__.py @@ -12,12 +12,12 @@ class Experimental: ENV_VAR = "KASA_EXPERIMENTAL" @classmethod - def set_enabled(cls, enabled): + def set_enabled(cls, enabled: bool) -> None: """Set the enabled value.""" cls._enabled = enabled @classmethod - def enabled(cls): + def enabled(cls) -> bool: """Get the enabled value.""" if cls._enabled is not None: return cls._enabled diff --git a/kasa/experimental/smartcameraprotocol.py b/kasa/experimental/smartcameraprotocol.py index b298fbd2e..38530b161 100644 --- a/kasa/experimental/smartcameraprotocol.py +++ b/kasa/experimental/smartcameraprotocol.py @@ -50,11 +50,13 @@ class SmartCameraProtocol(SmartProtocol): """Class for SmartCamera Protocol.""" async def _handle_response_lists( - self, response_result: dict[str, Any], method, retry_count - ): + self, response_result: dict[str, Any], method: str, retry_count: int + ) -> None: pass - def _handle_response_error_code(self, resp_dict: dict, method, raise_on_error=True): + def _handle_response_error_code( + self, resp_dict: dict, method: str, raise_on_error: bool = True + ) -> None: error_code_raw = resp_dict.get("error_code") try: error_code = SmartErrorCode.from_int(error_code_raw) @@ -203,7 +205,7 @@ class _ChildCameraProtocolWrapper(SmartProtocol): device responses before returning to the caller. """ - def __init__(self, device_id: str, base_protocol: SmartProtocol): + def __init__(self, device_id: str, base_protocol: SmartProtocol) -> None: self._device_id = device_id self._protocol = base_protocol self._transport = base_protocol._transport diff --git a/kasa/experimental/sslaestransport.py b/kasa/experimental/sslaestransport.py index 68420f89a..f188f1441 100644 --- a/kasa/experimental/sslaestransport.py +++ b/kasa/experimental/sslaestransport.py @@ -256,7 +256,9 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]: return ret_val # type: ignore[return-value] @staticmethod - def generate_confirm_hash(local_nonce, server_nonce, pwd_hash): + def generate_confirm_hash( + local_nonce: str, server_nonce: str, pwd_hash: str + ) -> str: """Generate an auth hash for the protocol on the supplied credentials.""" expected_confirm_bytes = _sha256_hash( local_nonce.encode() + pwd_hash.encode() + server_nonce.encode() @@ -264,7 +266,9 @@ def generate_confirm_hash(local_nonce, server_nonce, pwd_hash): return expected_confirm_bytes + server_nonce + local_nonce @staticmethod - def generate_digest_password(local_nonce, server_nonce, pwd_hash): + def generate_digest_password( + local_nonce: str, server_nonce: str, pwd_hash: str + ) -> str: """Generate an auth hash for the protocol on the supplied credentials.""" digest_password_hash = _sha256_hash( pwd_hash.encode() + local_nonce.encode() + server_nonce.encode() @@ -275,7 +279,7 @@ def generate_digest_password(local_nonce, server_nonce, pwd_hash): @staticmethod def generate_encryption_token( - token_type, local_nonce, server_nonce, pwd_hash + token_type: str, local_nonce: str, server_nonce: str, pwd_hash: str ) -> bytes: """Generate encryption token.""" hashedKey = _sha256_hash( @@ -302,7 +306,9 @@ async def perform_handshake(self) -> None: local_nonce, server_nonce, pwd_hash = await self.perform_handshake1() await self.perform_handshake2(local_nonce, server_nonce, pwd_hash) - async def perform_handshake2(self, local_nonce, server_nonce, pwd_hash) -> None: + async def perform_handshake2( + self, local_nonce: str, server_nonce: str, pwd_hash: str + ) -> None: """Perform the handshake.""" _LOGGER.debug("Performing handshake2 ...") digest_password = self.generate_digest_password( diff --git a/kasa/feature.py b/kasa/feature.py index e20a926de..e61cba07c 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -162,7 +162,7 @@ class Category(Enum): #: If set, this property will be used to get *choices*. choices_getter: str | Callable[[], list[str]] | None = None - def __post_init__(self): + def __post_init__(self) -> None: """Handle late-binding of members.""" # Populate minimum & maximum values, if range_getter is given self._container = self.container if self.container is not None else self.device @@ -188,7 +188,7 @@ def __post_init__(self): f"Read-only feat defines attribute_setter: {self.name} ({self.id}):" ) - def _get_property_value(self, getter): + def _get_property_value(self, getter: str | Callable | None) -> Any: if getter is None: return None if isinstance(getter, str): @@ -227,7 +227,7 @@ def minimum_value(self) -> int: return 0 @property - def value(self): + def value(self) -> int | float | bool | str | Enum | None: """Return the current value.""" if self.type == Feature.Type.Action: return "" @@ -264,7 +264,7 @@ async def set_value(self, value: int | float | bool | str | Enum | None) -> Any: return await getattr(container, self.attribute_setter)(value) - def __repr__(self): + def __repr__(self) -> str: try: value = self.value choices = self.choices @@ -286,8 +286,8 @@ def __repr__(self): value = " ".join( [f"*{choice}*" if choice == value else choice for choice in choices] ) - if self.precision_hint is not None and value is not None: - value = round(self.value, self.precision_hint) + if self.precision_hint is not None and isinstance(value, float): + value = round(value, self.precision_hint) s = f"{self.name} ({self.id}): {value}" if self.unit is not None: diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 6b8e234c0..8b69df52d 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -4,6 +4,7 @@ import asyncio import logging +import ssl import time from typing import Any, Dict @@ -64,7 +65,7 @@ async def post( json: dict | Any | None = None, headers: dict[str, str] | None = None, cookies_dict: dict[str, str] | None = None, - ssl=False, + ssl: ssl.SSLContext | bool = False, ) -> tuple[int, dict | bytes | None]: """Send an http post request to the device. diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py index 4e040e6fd..7092788ec 100644 --- a/kasa/interfaces/energy.py +++ b/kasa/interfaces/energy.py @@ -4,6 +4,7 @@ from abc import ABC, abstractmethod from enum import IntFlag, auto +from typing import Any from warnings import warn from ..emeterstatus import EmeterStatus @@ -31,7 +32,7 @@ def supports(self, module_feature: ModuleFeature) -> bool: """Return True if module supports the feature.""" return module_feature in self._supported - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features.""" device = self._device self._add_feature( @@ -151,22 +152,26 @@ def voltage(self) -> float | None: """Get the current voltage in V.""" @abstractmethod - async def get_status(self): + async def get_status(self) -> EmeterStatus: """Return real-time statistics.""" @abstractmethod - async def erase_stats(self): + async def erase_stats(self) -> dict: """Erase all stats.""" @abstractmethod - async def get_daily_stats(self, *, year=None, month=None, kwh=True) -> dict: + async def get_daily_stats( + self, *, year: int | None = None, month: int | None = None, kwh: bool = True + ) -> dict: """Return daily stats for the given year & month. The return value is a dictionary of {day: energy, ...}. """ @abstractmethod - async def get_monthly_stats(self, *, year=None, kwh=True) -> dict: + async def get_monthly_stats( + self, *, year: int | None = None, kwh: bool = True + ) -> dict: """Return monthly stats for the given year.""" _deprecated_attributes = { @@ -179,7 +184,7 @@ async def get_monthly_stats(self, *, year=None, kwh=True) -> dict: "get_monthstat": "get_monthly_stats", } - def __getattr__(self, name): + def __getattr__(self, name: str) -> Any: if attr := self._deprecated_attributes.get(name): msg = f"{name} is deprecated, use {attr} instead" warn(msg, DeprecationWarning, stacklevel=2) diff --git a/kasa/interfaces/fan.py b/kasa/interfaces/fan.py index 89d8d82be..ade009286 100644 --- a/kasa/interfaces/fan.py +++ b/kasa/interfaces/fan.py @@ -16,5 +16,5 @@ def fan_speed_level(self) -> int: """Return fan speed level.""" @abstractmethod - async def set_fan_speed_level(self, level: int): + async def set_fan_speed_level(self, level: int) -> dict: """Set fan speed level.""" diff --git a/kasa/interfaces/led.py b/kasa/interfaces/led.py index 2ddba00c2..2d34597bb 100644 --- a/kasa/interfaces/led.py +++ b/kasa/interfaces/led.py @@ -11,7 +11,7 @@ class Led(Module, ABC): """Base interface to represent a LED module.""" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features.""" device = self._device self._add_feature( @@ -34,5 +34,5 @@ def led(self) -> bool: """Return current led status.""" @abstractmethod - async def set_led(self, enable: bool) -> None: + async def set_led(self, enable: bool) -> dict: """Set led.""" diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py index 5d206d1a9..298ad1f8e 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -166,7 +166,7 @@ async def set_hsv( @abstractmethod async def set_color_temp( - self, temp: int, *, brightness=None, transition: int | None = None + self, temp: int, *, brightness: int | None = None, transition: int | None = None ) -> dict: """Set the color temperature of the device in kelvin. diff --git a/kasa/interfaces/lighteffect.py b/kasa/interfaces/lighteffect.py index e4efa2c2b..9a69f2d09 100644 --- a/kasa/interfaces/lighteffect.py +++ b/kasa/interfaces/lighteffect.py @@ -53,7 +53,7 @@ class LightEffect(Module, ABC): LIGHT_EFFECTS_OFF = "Off" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features.""" device = self._device self._add_feature( @@ -96,7 +96,7 @@ async def set_effect( *, brightness: int | None = None, transition: int | None = None, - ) -> None: + ) -> dict: """Set an effect on the device. If brightness or transition is defined, @@ -110,10 +110,11 @@ async def set_effect( :param int transition: The wanted transition time """ + @abstractmethod async def set_custom_effect( self, effect_dict: dict, - ) -> None: + ) -> dict: """Set a custom effect on the device. :param str effect_dict: The custom effect dict to set diff --git a/kasa/interfaces/lightpreset.py b/kasa/interfaces/lightpreset.py index fc2924196..586671e70 100644 --- a/kasa/interfaces/lightpreset.py +++ b/kasa/interfaces/lightpreset.py @@ -83,7 +83,7 @@ class LightPreset(Module): PRESET_NOT_SET = "Not set" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features.""" device = self._device self._add_feature( @@ -127,7 +127,7 @@ def preset(self) -> str: async def set_preset( self, preset_name: str, - ) -> None: + ) -> dict: """Set a light preset for the device.""" @abstractmethod @@ -135,7 +135,7 @@ async def save_preset( self, preset_name: str, preset_info: LightState, - ) -> None: + ) -> dict: """Update the preset with *preset_name* with the new *preset_info*.""" @property diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 3302e80db..481a9da8f 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -54,7 +54,7 @@ class TurnOnBehavior(BaseModel): mode: BehaviorMode @root_validator - def _mode_based_on_preset(cls, values): + def _mode_based_on_preset(cls, values: dict) -> dict: """Set the mode based on the preset value.""" if values["preset"] is not None: values["mode"] = BehaviorMode.Preset @@ -209,7 +209,7 @@ def __init__( super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Bulb - async def _initialize_modules(self): + async def _initialize_modules(self) -> None: """Initialize modules not added in init.""" await super()._initialize_modules() self.add_module( @@ -307,7 +307,7 @@ async def get_turn_on_behavior(self) -> TurnOnBehaviors: await self._query_helper(self.LIGHT_SERVICE, "get_default_behavior") ) - async def set_turn_on_behavior(self, behavior: TurnOnBehaviors): + async def set_turn_on_behavior(self, behavior: TurnOnBehaviors) -> dict: """Set the behavior for turning the bulb on. If you do not want to manually construct the behavior object, @@ -426,7 +426,7 @@ def _color_temp(self) -> int: @requires_update async def _set_color_temp( - self, temp: int, *, brightness=None, transition: int | None = None + self, temp: int, *, brightness: int | None = None, transition: int | None = None ) -> dict: """Set the color temperature of the device in kelvin. @@ -450,7 +450,7 @@ async def _set_color_temp( return await self._set_light_state(light_state, transition=transition) - def _raise_for_invalid_brightness(self, value): + def _raise_for_invalid_brightness(self, value: int) -> None: if not isinstance(value, int): raise TypeError("Brightness must be an integer") if not (0 <= value <= 100): @@ -517,7 +517,7 @@ def has_emeter(self) -> bool: """Return that the bulb has an emeter.""" return True - async def set_alias(self, alias: str) -> None: + async def set_alias(self, alias: str) -> dict: """Set the device name (alias). Overridden to use a different module name. diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 692968235..4ee403dba 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -19,7 +19,7 @@ import logging from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, tzinfo -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Callable, cast from warnings import warn from ..device import Device, WifiNetwork @@ -35,12 +35,12 @@ _LOGGER = logging.getLogger(__name__) -def requires_update(f): +def requires_update(f: Callable) -> Any: """Indicate that `update` should be called before accessing this method.""" # noqa: D202 if inspect.iscoroutinefunction(f): @functools.wraps(f) - async def wrapped(*args, **kwargs): + async def wrapped(*args: Any, **kwargs: Any) -> Any: self = args[0] if self._last_update is None and f.__name__ not in self._sys_info: raise KasaException("You need to await update() to access the data") @@ -49,13 +49,13 @@ async def wrapped(*args, **kwargs): else: @functools.wraps(f) - def wrapped(*args, **kwargs): + def wrapped(*args: Any, **kwargs: Any) -> Any: self = args[0] if self._last_update is None and f.__name__ not in self._sys_info: raise KasaException("You need to await update() to access the data") return f(*args, **kwargs) - f.requires_update = True + f.requires_update = True # type: ignore[attr-defined] return wrapped @@ -197,7 +197,7 @@ def modules(self) -> ModuleMapping[IotModule]: return cast(ModuleMapping[IotModule], self._supported_modules) return self._supported_modules - def add_module(self, name: str | ModuleName[Module], module: IotModule): + def add_module(self, name: str | ModuleName[Module], module: IotModule) -> None: """Register a module.""" if name in self._modules: _LOGGER.debug("Module %s already registered, ignoring...", name) @@ -207,8 +207,12 @@ def add_module(self, name: str | ModuleName[Module], module: IotModule): self._modules[name] = module def _create_request( - self, target: str, cmd: str, arg: dict | None = None, child_ids=None - ): + self, + target: str, + cmd: str, + arg: dict | None = None, + child_ids: list | None = None, + ) -> dict: if arg is None: arg = {} request: dict[str, Any] = {target: {cmd: arg}} @@ -225,8 +229,12 @@ def _verify_emeter(self) -> None: raise KasaException("update() required prior accessing emeter") async def _query_helper( - self, target: str, cmd: str, arg: dict | None = None, child_ids=None - ) -> Any: + self, + target: str, + cmd: str, + arg: dict | None = None, + child_ids: list | None = None, + ) -> dict: """Query device, return results or raise an exception. :param target: Target system {system, time, emeter, ..} @@ -276,7 +284,7 @@ async def get_sys_info(self) -> dict[str, Any]: """Retrieve system information.""" return await self._query_helper("system", "get_sysinfo") - async def update(self, update_children: bool = True): + async def update(self, update_children: bool = True) -> None: """Query the device to update the data. Needed for properties that are decorated with `requires_update`. @@ -305,7 +313,7 @@ async def update(self, update_children: bool = True): if not self._features: await self._initialize_features() - async def _initialize_modules(self): + async def _initialize_modules(self) -> None: """Initialize modules not added in init.""" if self.has_emeter: _LOGGER.debug( @@ -313,7 +321,7 @@ async def _initialize_modules(self): ) self.add_module(Module.Energy, Emeter(self, self.emeter_type)) - async def _initialize_features(self): + async def _initialize_features(self) -> None: """Initialize common features.""" self._add_feature( Feature( @@ -364,7 +372,7 @@ async def _initialize_features(self): ) ) - for module in self._supported_modules.values(): + for module in self.modules.values(): module._initialize_features() for module_feat in module._module_features.values(): self._add_feature(module_feat) @@ -453,7 +461,7 @@ def alias(self) -> str | None: sys_info = self._sys_info return sys_info.get("alias") if sys_info else None - async def set_alias(self, alias: str) -> None: + async def set_alias(self, alias: str) -> dict: """Set the device name (alias).""" return await self._query_helper("system", "set_dev_alias", {"alias": alias}) @@ -550,7 +558,7 @@ def mac(self) -> str: return mac - async def set_mac(self, mac): + async def set_mac(self, mac: str) -> dict: """Set the mac address. :param str mac: mac in hexadecimal with colons, e.g. 01:23:45:67:89:ab @@ -576,7 +584,7 @@ async def turn_off(self, **kwargs) -> dict: """Turn off the device.""" raise NotImplementedError("Device subclass needs to implement this.") - async def turn_on(self, **kwargs) -> dict | None: + async def turn_on(self, **kwargs) -> dict: """Turn device on.""" raise NotImplementedError("Device subclass needs to implement this.") @@ -586,7 +594,7 @@ def is_on(self) -> bool: """Return True if the device is on.""" raise NotImplementedError("Device subclass needs to implement this.") - async def set_state(self, on: bool): + async def set_state(self, on: bool) -> dict: """Set the device state.""" if on: return await self.turn_on() @@ -627,7 +635,7 @@ def device_id(self) -> str: async def wifi_scan(self) -> list[WifiNetwork]: # noqa: D202 """Scan for available wifi networks.""" - async def _scan(target): + async def _scan(target: str) -> dict: return await self._query_helper(target, "get_scaninfo", {"refresh": 1}) try: @@ -639,17 +647,17 @@ async def _scan(target): info = await _scan("smartlife.iot.common.softaponboarding") if "ap_list" not in info: - raise KasaException("Invalid response for wifi scan: %s" % info) + raise KasaException(f"Invalid response for wifi scan: {info}") return [WifiNetwork(**x) for x in info["ap_list"]] - async def wifi_join(self, ssid: str, password: str, keytype: str = "3"): # noqa: D202 + async def wifi_join(self, ssid: str, password: str, keytype: str = "3") -> dict: # noqa: D202 """Join the given wifi network. If joining the network fails, the device will return to AP mode after a while. """ - async def _join(target, payload): + async def _join(target: str, payload: dict) -> dict: return await self._query_helper(target, "set_stainfo", payload) payload = {"ssid": ssid, "password": password, "key_type": int(keytype)} diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 04510fe27..2cd8de44c 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -80,7 +80,7 @@ def __init__( super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Dimmer - async def _initialize_modules(self): + async def _initialize_modules(self) -> None: """Initialize modules.""" await super()._initialize_modules() # TODO: need to be verified if it's okay to call these on HS220 w/o these @@ -103,7 +103,9 @@ def _brightness(self) -> int: return int(sys_info["brightness"]) @requires_update - async def _set_brightness(self, brightness: int, *, transition: int | None = None): + async def _set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: """Set the new dimmer brightness level in percentage. :param int transition: transition duration in milliseconds. @@ -134,7 +136,7 @@ async def _set_brightness(self, brightness: int, *, transition: int | None = Non self.DIMMER_SERVICE, "set_brightness", {"brightness": brightness} ) - async def turn_off(self, *, transition: int | None = None, **kwargs): + async def turn_off(self, *, transition: int | None = None, **kwargs) -> dict: """Turn the bulb off. :param int transition: transition duration in milliseconds. @@ -145,7 +147,7 @@ async def turn_off(self, *, transition: int | None = None, **kwargs): return await super().turn_off() @requires_update - async def turn_on(self, *, transition: int | None = None, **kwargs): + async def turn_on(self, *, transition: int | None = None, **kwargs) -> dict: """Turn the bulb on. :param int transition: transition duration in milliseconds. @@ -157,7 +159,7 @@ async def turn_on(self, *, transition: int | None = None, **kwargs): return await super().turn_on() - async def set_dimmer_transition(self, brightness: int, transition: int): + async def set_dimmer_transition(self, brightness: int, transition: int) -> dict: """Turn the bulb on to brightness percentage over transition milliseconds. A brightness value of 0 will turn off the dimmer. @@ -176,7 +178,7 @@ async def set_dimmer_transition(self, brightness: int, transition: int): if not isinstance(transition, int): raise TypeError(f"Transition must be integer, not of {type(transition)}.") if transition <= 0: - raise ValueError("Transition value %s is not valid." % transition) + raise ValueError(f"Transition value {transition} is not valid.") return await self._query_helper( self.DIMMER_SERVICE, @@ -185,7 +187,7 @@ async def set_dimmer_transition(self, brightness: int, transition: int): ) @requires_update - async def get_behaviors(self): + async def get_behaviors(self) -> dict: """Return button behavior settings.""" behaviors = await self._query_helper( self.DIMMER_SERVICE, "get_default_behavior", {} @@ -195,7 +197,7 @@ async def get_behaviors(self): @requires_update async def set_button_action( self, action_type: ActionType, action: ButtonAction, index: int | None = None - ): + ) -> dict: """Set action to perform on button click/hold. :param action_type ActionType: whether to control double click or hold action. @@ -209,15 +211,17 @@ async def set_button_action( if index is not None: payload["index"] = index - await self._query_helper(self.DIMMER_SERVICE, action_type_setter, payload) + return await self._query_helper( + self.DIMMER_SERVICE, action_type_setter, payload + ) @requires_update - async def set_fade_time(self, fade_type: FadeType, time: int): + async def set_fade_time(self, fade_type: FadeType, time: int) -> dict: """Set time for fade in / fade out.""" fade_type_setter = f"set_{fade_type}_time" payload = {"fadeTime": time} - await self._query_helper(self.DIMMER_SERVICE, fade_type_setter, payload) + return await self._query_helper(self.DIMMER_SERVICE, fade_type_setter, payload) @property # type: ignore @requires_update diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index abe532f72..14e98684b 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -57,7 +57,7 @@ def __init__( super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.LightStrip - async def _initialize_modules(self): + async def _initialize_modules(self) -> None: """Initialize modules not added in init.""" await super()._initialize_modules() self.add_module( diff --git a/kasa/iot/iotmodule.py b/kasa/iot/iotmodule.py index 7829c8566..ddb0da2c1 100644 --- a/kasa/iot/iotmodule.py +++ b/kasa/iot/iotmodule.py @@ -1,6 +1,9 @@ """Base class for IOT module implementations.""" +from __future__ import annotations + import logging +from typing import Any from ..exceptions import KasaException from ..module import Module @@ -24,16 +27,16 @@ def _merge_dict(dest: dict, source: dict) -> dict: class IotModule(Module): """Base class implemention for all IOT modules.""" - def call(self, method, params=None): + async def call(self, method: str, params: dict | None = None) -> dict: """Call the given method with the given parameters.""" - return self._device._query_helper(self._module, method, params) + return await self._device._query_helper(self._module, method, params) - def query_for_command(self, query, params=None): + def query_for_command(self, query: str, params: dict | None = None) -> dict: """Create a request object for the given parameters.""" return self._device._create_request(self._module, query, params) @property - def estimated_query_response_size(self): + def estimated_query_response_size(self) -> int: """Estimated maximum size of query response. The inheriting modules implement this to estimate how large a query response @@ -42,7 +45,7 @@ def estimated_query_response_size(self): return 256 # Estimate for modules that don't specify @property - def data(self): + def data(self) -> dict[str, Any]: """Return the module specific raw data from the last update.""" dev = self._device q = self.query() diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 3a1193181..ab10e9326 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -54,7 +55,7 @@ def __init__( super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug - async def _initialize_modules(self): + async def _initialize_modules(self) -> None: """Initialize modules.""" await super()._initialize_modules() self.add_module(Module.IotSchedule, Schedule(self, "schedule")) @@ -71,11 +72,11 @@ def is_on(self) -> bool: sys_info = self.sys_info return bool(sys_info["relay_state"]) - async def turn_on(self, **kwargs): + async def turn_on(self, **kwargs: Any) -> dict: """Turn the switch on.""" return await self._query_helper("system", "set_relay_state", {"state": 1}) - async def turn_off(self, **kwargs): + async def turn_off(self, **kwargs: Any) -> dict: """Turn the switch off.""" return await self._query_helper("system", "set_relay_state", {"state": 0}) diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index a18f27565..a212dd61c 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) -def merge_sums(dicts): +def merge_sums(dicts: list[dict]) -> dict: """Merge the sum of dicts.""" total_dict: defaultdict[int, float] = defaultdict(lambda: 0.0) for sum_dict in dicts: @@ -99,7 +99,7 @@ def __init__( self.emeter_type = "emeter" self._device_type = DeviceType.Strip - async def _initialize_modules(self): + async def _initialize_modules(self) -> None: """Initialize modules.""" # Strip has different modules to plug so do not call super self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) @@ -121,7 +121,7 @@ def is_on(self) -> bool: """Return if any of the outlets are on.""" return any(plug.is_on for plug in self.children) - async def update(self, update_children: bool = True): + async def update(self, update_children: bool = True) -> None: """Update some of the attributes. Needed for methods that are decorated with `requires_update`. @@ -150,20 +150,20 @@ async def update(self, update_children: bool = True): if not self.features: await self._initialize_features() - async def _initialize_features(self): + async def _initialize_features(self) -> None: """Initialize common features.""" # Do not initialize features until children are created if not self.children: return await super()._initialize_features() - async def turn_on(self, **kwargs): + async def turn_on(self, **kwargs) -> dict: """Turn the strip on.""" - await self._query_helper("system", "set_relay_state", {"state": 1}) + return await self._query_helper("system", "set_relay_state", {"state": 1}) - async def turn_off(self, **kwargs): + async def turn_off(self, **kwargs) -> dict: """Turn the strip off.""" - await self._query_helper("system", "set_relay_state", {"state": 0}) + return await self._query_helper("system", "set_relay_state", {"state": 0}) @property # type: ignore @requires_update @@ -188,7 +188,7 @@ def supports(self, module_feature: Energy.ModuleFeature) -> bool: """Return True if module supports the feature.""" return module_feature in self._supported - def query(self): + def query(self) -> dict: """Return the base query.""" return {} @@ -246,11 +246,13 @@ async def _async_get_emeter_sum(self, func: str, kwargs: dict[str, Any]) -> dict ] ) - async def erase_stats(self): + async def erase_stats(self) -> dict: """Erase energy meter statistics for all plugs.""" for plug in self._device.children: await plug.modules[Module.Energy].erase_stats() + return {} + @property # type: ignore def consumption_this_month(self) -> float | None: """Return this month's energy consumption in kWh.""" @@ -320,7 +322,7 @@ def __init__(self, host: str, parent: IotStrip, child_id: str) -> None: self.protocol = parent.protocol # Must use the same connection as the parent self._on_since: datetime | None = None - async def _initialize_modules(self): + async def _initialize_modules(self) -> None: """Initialize modules not added in init.""" if self.has_emeter: self.add_module(Module.Energy, Emeter(self, self.emeter_type)) @@ -329,7 +331,7 @@ async def _initialize_modules(self): self.add_module(Module.IotSchedule, Schedule(self, "schedule")) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) - async def _initialize_features(self): + async def _initialize_features(self) -> None: """Initialize common features.""" self._add_feature( Feature( @@ -353,19 +355,20 @@ async def _initialize_features(self): type=Feature.Type.Sensor, ) ) - for module in self._supported_modules.values(): + + for module in self.modules.values(): module._initialize_features() for module_feat in module._module_features.values(): self._add_feature(module_feat) - async def update(self, update_children: bool = True): + async def update(self, update_children: bool = True) -> None: """Query the device to update the data. Needed for properties that are decorated with `requires_update`. """ await self._update(update_children) - async def _update(self, update_children: bool = True): + async def _update(self, update_children: bool = True) -> None: """Query the device to update the data. Internal implementation to allow patching of public update in the cli @@ -379,8 +382,12 @@ async def _update(self, update_children: bool = True): await self._initialize_features() def _create_request( - self, target: str, cmd: str, arg: dict | None = None, child_ids=None - ): + self, + target: str, + cmd: str, + arg: dict | None = None, + child_ids: list | None = None, + ) -> dict: request: dict[str, Any] = { "context": {"child_ids": [self.child_id]}, target: {cmd: arg}, @@ -388,8 +395,12 @@ def _create_request( return request async def _query_helper( - self, target: str, cmd: str, arg: dict | None = None, child_ids=None - ) -> Any: + self, + target: str, + cmd: str, + arg: dict | None = None, + child_ids: list | None = None, + ) -> dict: """Override query helper to include the child_ids.""" return await self._parent._query_helper( target, cmd, arg, child_ids=[self.child_id] diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py index 691f88f16..ac5c3488c 100644 --- a/kasa/iot/modules/ambientlight.py +++ b/kasa/iot/modules/ambientlight.py @@ -11,7 +11,7 @@ class AmbientLight(IotModule): """Implements ambient light controls for the motion sensor.""" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( Feature( @@ -40,7 +40,7 @@ def _initialize_features(self): ) ) - def query(self): + def query(self) -> dict: """Request configuration.""" req = merge( self.query_for_command("get_config"), @@ -74,18 +74,18 @@ def ambientlight_brightness(self) -> int: """Return True if the module is enabled.""" return int(self.data["get_current_brt"]["value"]) - async def set_enabled(self, state: bool): + async def set_enabled(self, state: bool) -> dict: """Enable/disable LAS.""" return await self.call("set_enable", {"enable": int(state)}) - async def current_brightness(self) -> int: + async def current_brightness(self) -> dict: """Return current brightness. Return value units. """ return await self.call("get_current_brt") - async def set_brightness_limit(self, value: int): + async def set_brightness_limit(self, value: int) -> dict: """Set the limit when the motion sensor is inactive. See `presets` for preset values. Custom values are also likely allowed. diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index 8be393d96..10097e646 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -24,7 +24,7 @@ class CloudInfo(BaseModel): class Cloud(IotModule): """Module implementing support for cloud services.""" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( Feature( @@ -44,7 +44,7 @@ def is_connected(self) -> bool: """Return true if device is connected to the cloud.""" return self.info.binded - def query(self): + def query(self) -> dict: """Request cloud connectivity info.""" return self.query_for_command("get_info") @@ -53,20 +53,20 @@ def info(self) -> CloudInfo: """Return information about the cloud connectivity.""" return CloudInfo.parse_obj(self.data["get_info"]) - def get_available_firmwares(self): + def get_available_firmwares(self) -> dict: """Return list of available firmwares.""" return self.query_for_command("get_intl_fw_list") - def set_server(self, url: str): + def set_server(self, url: str) -> dict: """Set the update server URL.""" return self.query_for_command("set_server_url", {"server": url}) - def connect(self, username: str, password: str): + def connect(self, username: str, password: str) -> dict: """Login to the cloud using given information.""" return self.query_for_command( "bind", {"username": username, "password": password} ) - def disconnect(self): + def disconnect(self) -> dict: """Disconnect from the cloud.""" return self.query_for_command("unbind") diff --git a/kasa/iot/modules/emeter.py b/kasa/iot/modules/emeter.py index 1764af905..012bda04c 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -70,7 +70,7 @@ def voltage(self) -> float | None: """Get the current voltage in V.""" return self.status.voltage - async def erase_stats(self): + async def erase_stats(self) -> dict: """Erase all stats. Uses different query than usage meter. @@ -81,7 +81,9 @@ async def get_status(self) -> EmeterStatus: """Return real-time statistics.""" return EmeterStatus(await self.call("get_realtime")) - async def get_daily_stats(self, *, year=None, month=None, kwh=True) -> dict: + async def get_daily_stats( + self, *, year: int | None = None, month: int | None = None, kwh: bool = True + ) -> dict: """Return daily stats for the given year & month. The return value is a dictionary of {day: energy, ...}. @@ -90,7 +92,9 @@ async def get_daily_stats(self, *, year=None, month=None, kwh=True) -> dict: data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh) return data - async def get_monthly_stats(self, *, year=None, kwh=True) -> dict: + async def get_monthly_stats( + self, *, year: int | None = None, kwh: bool = True + ) -> dict: """Return monthly stats for the given year. The return value is a dictionary of {month: energy, ...}. diff --git a/kasa/iot/modules/led.py b/kasa/iot/modules/led.py index 48301f237..8a5727b05 100644 --- a/kasa/iot/modules/led.py +++ b/kasa/iot/modules/led.py @@ -14,7 +14,7 @@ def query(self) -> dict: return {} @property - def mode(self): + def mode(self) -> str: """LED mode setting. "always", "never" @@ -27,7 +27,7 @@ def led(self) -> bool: sys_info = self.data return bool(1 - sys_info["led_off"]) - async def set_led(self, state: bool): + async def set_led(self, state: bool) -> dict: """Set the state of the led (night mode).""" return await self.call("set_led_off", {"off": int(not state)}) diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index d83031c8c..7c9342c9d 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -27,7 +27,7 @@ class Light(IotModule, LightInterface): _device: IotBulb | IotDimmer _light_state: LightState - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features.""" super()._initialize_features() device = self._device @@ -185,7 +185,7 @@ def color_temp(self) -> int: return bulb._color_temp async def set_color_temp( - self, temp: int, *, brightness=None, transition: int | None = None + self, temp: int, *, brightness: int | None = None, transition: int | None = None ) -> dict: """Set the color temperature of the device in kelvin. diff --git a/kasa/iot/modules/lighteffect.py b/kasa/iot/modules/lighteffect.py index 3a13f6806..cdfaaae16 100644 --- a/kasa/iot/modules/lighteffect.py +++ b/kasa/iot/modules/lighteffect.py @@ -50,7 +50,7 @@ async def set_effect( *, brightness: int | None = None, transition: int | None = None, - ) -> None: + ) -> dict: """Set an effect on the device. If brightness or transition is defined, @@ -73,7 +73,7 @@ async def set_effect( effect_dict = EFFECT_MAPPING_V1["Aurora"] effect_dict = {**effect_dict} effect_dict["enable"] = 0 - await self.set_custom_effect(effect_dict) + return await self.set_custom_effect(effect_dict) elif effect not in EFFECT_MAPPING_V1: raise ValueError(f"The effect {effect} is not a built in effect.") else: @@ -84,12 +84,12 @@ async def set_effect( if transition is not None: effect_dict["transition"] = transition - await self.set_custom_effect(effect_dict) + return await self.set_custom_effect(effect_dict) async def set_custom_effect( self, effect_dict: dict, - ) -> None: + ) -> dict: """Set a custom effect on the device. :param str effect_dict: The custom effect dict to set @@ -104,7 +104,7 @@ def has_custom_effects(self) -> bool: """Return True if the device supports setting custom effects.""" return True - def query(self): + def query(self) -> dict: """Return the base query.""" return {} diff --git a/kasa/iot/modules/lightpreset.py b/kasa/iot/modules/lightpreset.py index bae401efa..13fee33ee 100644 --- a/kasa/iot/modules/lightpreset.py +++ b/kasa/iot/modules/lightpreset.py @@ -41,7 +41,7 @@ class LightPreset(IotModule, LightPresetInterface): _presets: dict[str, IotLightPreset] _preset_list: list[str] - async def _post_update_hook(self): + async def _post_update_hook(self) -> None: """Update the internal presets.""" self._presets = { f"Light preset {index+1}": IotLightPreset(**vals) @@ -93,7 +93,7 @@ def preset(self) -> str: async def set_preset( self, preset_name: str, - ) -> None: + ) -> dict: """Set a light preset for the device.""" light = self._device.modules[Module.Light] if preset_name == self.PRESET_NOT_SET: @@ -104,7 +104,7 @@ async def set_preset( elif (preset := self._presets.get(preset_name)) is None: # type: ignore[assignment] raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}") - await light.set_state(preset) + return await light.set_state(preset) @property def has_save_preset(self) -> bool: @@ -115,7 +115,7 @@ async def save_preset( self, preset_name: str, preset_state: LightState, - ) -> None: + ) -> dict: """Update the preset with preset_name with the new preset_info.""" if len(self._presets) == 0: raise KasaException("Device does not supported saving presets") @@ -129,7 +129,7 @@ async def save_preset( return await self.call("set_preferred_state", state) - def query(self): + def query(self) -> dict: """Return the base query.""" return {} @@ -142,7 +142,7 @@ def _deprecated_presets(self) -> list[IotLightPreset]: if "id" not in vals ] - async def _deprecated_save_preset(self, preset: IotLightPreset): + async def _deprecated_save_preset(self, preset: IotLightPreset) -> dict: """Save a setting preset. You can either construct a preset object manually, or pass an existing one diff --git a/kasa/iot/modules/motion.py b/kasa/iot/modules/motion.py index db272e2f2..e65cbd93b 100644 --- a/kasa/iot/modules/motion.py +++ b/kasa/iot/modules/motion.py @@ -24,7 +24,7 @@ class Range(Enum): class Motion(IotModule): """Implements the motion detection (PIR) module.""" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" # Only add features if the device supports the module if "get_config" not in self.data: @@ -48,7 +48,7 @@ def _initialize_features(self): ) ) - def query(self): + def query(self) -> dict: """Request PIR configuration.""" return self.query_for_command("get_config") @@ -67,13 +67,13 @@ def enabled(self) -> bool: """Return True if module is enabled.""" return bool(self.config["enable"]) - async def set_enabled(self, state: bool): + async def set_enabled(self, state: bool) -> dict: """Enable/disable PIR.""" return await self.call("set_enable", {"enable": int(state)}) async def set_range( self, *, range: Range | None = None, custom_range: int | None = None - ): + ) -> dict: """Set the range for the sensor. :param range: for using standard ranges @@ -93,7 +93,7 @@ def inactivity_timeout(self) -> int: """Return inactivity timeout in milliseconds.""" return self.config["cold_time"] - async def set_inactivity_timeout(self, timeout: int): + async def set_inactivity_timeout(self, timeout: int) -> dict: """Set inactivity timeout in milliseconds. Note, that you need to delete the default "Smart Control" rule in the app diff --git a/kasa/iot/modules/rulemodule.py b/kasa/iot/modules/rulemodule.py index 6e3a2b226..2515b71bd 100644 --- a/kasa/iot/modules/rulemodule.py +++ b/kasa/iot/modules/rulemodule.py @@ -57,7 +57,7 @@ class Rule(BaseModel): class RuleModule(IotModule): """Base class for rule-based modules, such as countdown and antitheft.""" - def query(self): + def query(self) -> dict: """Prepare the query for rules.""" q = self.query_for_command("get_rules") return merge(q, self.query_for_command("get_next_action")) @@ -73,14 +73,14 @@ def rules(self) -> list[Rule]: _LOGGER.error("Unable to read rule list: %s (data: %s)", ex, self.data) return [] - async def set_enabled(self, state: bool): + async def set_enabled(self, state: bool) -> dict: """Enable or disable the service.""" - return await self.call("set_overall_enable", state) + return await self.call("set_overall_enable", {"enable": state}) - async def delete_rule(self, rule: Rule): + async def delete_rule(self, rule: Rule) -> dict: """Delete the given rule.""" return await self.call("delete_rule", {"id": rule.id}) - async def delete_all_rules(self): + async def delete_all_rules(self) -> dict: """Delete all rules.""" return await self.call("delete_all_rules") diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py index 8c672d210..f65dd9107 100644 --- a/kasa/iot/modules/time.py +++ b/kasa/iot/modules/time.py @@ -15,14 +15,14 @@ class Time(IotModule, TimeInterface): _timezone: tzinfo = timezone.utc - def query(self): + def query(self) -> dict: """Request time and timezone.""" q = self.query_for_command("get_time") merge(q, self.query_for_command("get_timezone")) return q - async def _post_update_hook(self): + async def _post_update_hook(self) -> None: """Perform actions after a device update.""" if res := self.data.get("get_timezone"): self._timezone = await get_timezone(res.get("index")) @@ -47,7 +47,7 @@ def timezone(self) -> tzinfo: """Return current timezone.""" return self._timezone - async def get_time(self): + async def get_time(self) -> datetime | None: """Return current device time.""" try: res = await self.call("get_time") @@ -88,6 +88,6 @@ async def set_time(self, dt: datetime) -> dict: except Exception as ex: raise KasaException(ex) from ex - async def get_timezone(self): + async def get_timezone(self) -> dict: """Request timezone information from the device.""" return await self.call("get_timezone") diff --git a/kasa/iot/modules/usage.py b/kasa/iot/modules/usage.py index 5acf1dbe0..89d8cca2b 100644 --- a/kasa/iot/modules/usage.py +++ b/kasa/iot/modules/usage.py @@ -10,7 +10,7 @@ class Usage(IotModule): """Baseclass for emeter/usage interfaces.""" - def query(self): + def query(self) -> dict: """Return the base query.""" now = datetime.now() year = now.year @@ -25,22 +25,22 @@ def query(self): return req @property - def estimated_query_response_size(self): + def estimated_query_response_size(self) -> int: """Estimated maximum query response size.""" return 2048 @property - def daily_data(self): + def daily_data(self) -> list[dict]: """Return statistics on daily basis.""" return self.data["get_daystat"]["day_list"] @property - def monthly_data(self): + def monthly_data(self) -> list[dict]: """Return statistics on monthly basis.""" return self.data["get_monthstat"]["month_list"] @property - def usage_today(self): + def usage_today(self) -> int | None: """Return today's usage in minutes.""" today = datetime.now().day # Traverse the list in reverse order to find the latest entry. @@ -50,7 +50,7 @@ def usage_today(self): return None @property - def usage_this_month(self): + def usage_this_month(self) -> int | None: """Return usage in this month in minutes.""" this_month = datetime.now().month # Traverse the list in reverse order to find the latest entry. @@ -59,7 +59,9 @@ def usage_this_month(self): return entry["time"] return None - async def get_raw_daystat(self, *, year=None, month=None) -> dict: + async def get_raw_daystat( + self, *, year: int | None = None, month: int | None = None + ) -> dict: """Return raw daily stats for the given year & month.""" if year is None: year = datetime.now().year @@ -68,14 +70,16 @@ async def get_raw_daystat(self, *, year=None, month=None) -> dict: return await self.call("get_daystat", {"year": year, "month": month}) - async def get_raw_monthstat(self, *, year=None) -> dict: + async def get_raw_monthstat(self, *, year: int | None = None) -> dict: """Return raw monthly stats for the given year.""" if year is None: year = datetime.now().year return await self.call("get_monthstat", {"year": year}) - async def get_daystat(self, *, year=None, month=None) -> dict: + async def get_daystat( + self, *, year: int | None = None, month: int | None = None + ) -> dict: """Return daily stats for the given year & month. The return value is a dictionary of {day: time, ...}. @@ -84,7 +88,7 @@ async def get_daystat(self, *, year=None, month=None) -> dict: data = self._convert_stat_data(data["day_list"], entry_key="day") return data - async def get_monthstat(self, *, year=None) -> dict: + async def get_monthstat(self, *, year: int | None = None) -> dict: """Return monthly stats for the given year. The return value is a dictionary of {month: time, ...}. @@ -93,11 +97,11 @@ async def get_monthstat(self, *, year=None) -> dict: data = self._convert_stat_data(data["month_list"], entry_key="month") return data - async def erase_stats(self): + async def erase_stats(self) -> dict: """Erase all stats.""" return await self.call("erase_runtime_stat") - def _convert_stat_data(self, data, entry_key) -> dict: + def _convert_stat_data(self, data: list[dict], entry_key: str) -> dict: """Return usage information keyed with the day/month. The incoming data is a list of dictionaries:: @@ -113,6 +117,6 @@ def _convert_stat_data(self, data, entry_key) -> dict: if not data: return {} - data = {entry[entry_key]: entry["time"] for entry in data} + res = {entry[entry_key]: entry["time"] for entry in data} - return data + return res diff --git a/kasa/json.py b/kasa/json.py index aed8cd56d..10edc690e 100755 --- a/kasa/json.py +++ b/kasa/json.py @@ -1,9 +1,13 @@ """JSON abstraction.""" +from __future__ import annotations + +from typing import Any, Callable + try: import orjson - def dumps(obj, *, default=None): + def dumps(obj: Any, *, default: Callable | None = None) -> str: """Dump JSON.""" return orjson.dumps(obj).decode() @@ -11,7 +15,7 @@ def dumps(obj, *, default=None): except ImportError: import json - def dumps(obj, *, default=None): + def dumps(obj: Any, *, default: Callable | None = None) -> str: """Dump JSON.""" # Separators specified for consistency with orjson return json.dumps(obj, separators=(",", ":")) diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 02e0b2b72..870304d16 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -50,7 +50,8 @@ import secrets import struct import time -from typing import TYPE_CHECKING, Any, cast +from asyncio import Future +from typing import TYPE_CHECKING, Any, Generator, cast from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -110,10 +111,10 @@ def __init__( else: self._local_auth_hash = base64.b64decode(self._credentials_hash.encode()) # type: ignore[union-attr] self._default_credentials_auth_hash: dict[str, bytes] = {} - self._blank_auth_hash = None + self._blank_auth_hash: bytes | None = None self._handshake_lock = asyncio.Lock() self._query_lock = asyncio.Lock() - self._handshake_done = False + self._handshake_done: bool = False self._encryption_session: KlapEncryptionSession | None = None self._session_expire_at: float | None = None @@ -125,7 +126,7 @@ def __init__( self._request_url = self._app_url / "request" @property - def default_port(self): + def default_port(self) -> int: """Default port for the transport.""" return self.DEFAULT_PORT @@ -242,7 +243,7 @@ async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: raise AuthenticationError(msg) async def perform_handshake2( - self, local_seed, remote_seed, auth_hash + self, local_seed: bytes, remote_seed: bytes, auth_hash: bytes ) -> KlapEncryptionSession: """Perform handshake2.""" # Handshake 2 has the following payload: @@ -277,7 +278,7 @@ async def perform_handshake2( return KlapEncryptionSession(local_seed, remote_seed, auth_hash) - async def perform_handshake(self) -> Any: + async def perform_handshake(self) -> None: """Perform handshake1 and handshake2. Sets the encryption_session if successful. @@ -309,14 +310,14 @@ async def perform_handshake(self) -> Any: _LOGGER.debug("Handshake with %s complete", self._host) - def _handshake_session_expired(self): + def _handshake_session_expired(self) -> bool: """Return true if session has expired.""" return ( self._session_expire_at is None or self._session_expire_at - time.monotonic() <= 0 ) - async def send(self, request: str): + async def send(self, request: str) -> Generator[Future, None, dict[str, str]]: # type: ignore[override] """Send the request.""" if not self._handshake_done or self._handshake_session_expired(): await self.perform_handshake() @@ -355,6 +356,7 @@ async def send(self, request: str): if TYPE_CHECKING: assert self._encryption_session + assert isinstance(response_data, bytes) try: decrypted_response = self._encryption_session.decrypt(response_data) except Exception as ex: @@ -378,7 +380,7 @@ async def reset(self) -> None: self._handshake_done = False @staticmethod - def generate_auth_hash(creds: Credentials): + def generate_auth_hash(creds: Credentials) -> bytes: """Generate an md5 auth hash for the protocol on the supplied credentials.""" un = creds.username pw = creds.password @@ -388,19 +390,19 @@ def generate_auth_hash(creds: Credentials): @staticmethod def handshake1_seed_auth_hash( local_seed: bytes, remote_seed: bytes, auth_hash: bytes - ): + ) -> bytes: """Generate an md5 auth hash for the protocol on the supplied credentials.""" return _sha256(local_seed + auth_hash) @staticmethod def handshake2_seed_auth_hash( local_seed: bytes, remote_seed: bytes, auth_hash: bytes - ): + ) -> bytes: """Generate an md5 auth hash for the protocol on the supplied credentials.""" return _sha256(remote_seed + auth_hash) @staticmethod - def generate_owner_hash(creds: Credentials): + def generate_owner_hash(creds: Credentials) -> bytes: """Return the MD5 hash of the username in this object.""" un = creds.username return md5(un.encode()) @@ -410,7 +412,7 @@ class KlapTransportV2(KlapTransport): """Implementation of the KLAP encryption protocol with v2 hanshake hashes.""" @staticmethod - def generate_auth_hash(creds: Credentials): + def generate_auth_hash(creds: Credentials) -> bytes: """Generate an md5 auth hash for the protocol on the supplied credentials.""" un = creds.username pw = creds.password @@ -420,14 +422,14 @@ def generate_auth_hash(creds: Credentials): @staticmethod def handshake1_seed_auth_hash( local_seed: bytes, remote_seed: bytes, auth_hash: bytes - ): + ) -> bytes: """Generate an md5 auth hash for the protocol on the supplied credentials.""" return _sha256(local_seed + remote_seed + auth_hash) @staticmethod def handshake2_seed_auth_hash( local_seed: bytes, remote_seed: bytes, auth_hash: bytes - ): + ) -> bytes: """Generate an md5 auth hash for the protocol on the supplied credentials.""" return _sha256(remote_seed + local_seed + auth_hash) @@ -440,7 +442,7 @@ class KlapEncryptionSession: _cipher: Cipher - def __init__(self, local_seed, remote_seed, user_hash): + def __init__(self, local_seed: bytes, remote_seed: bytes, user_hash: bytes) -> None: self.local_seed = local_seed self.remote_seed = remote_seed self.user_hash = user_hash @@ -449,11 +451,15 @@ def __init__(self, local_seed, remote_seed, user_hash): self._aes = algorithms.AES(self._key) self._sig = self._sig_derive(local_seed, remote_seed, user_hash) - def _key_derive(self, local_seed, remote_seed, user_hash): + def _key_derive( + self, local_seed: bytes, remote_seed: bytes, user_hash: bytes + ) -> bytes: payload = b"lsk" + local_seed + remote_seed + user_hash return hashlib.sha256(payload).digest()[:16] - def _iv_derive(self, local_seed, remote_seed, user_hash): + def _iv_derive( + self, local_seed: bytes, remote_seed: bytes, user_hash: bytes + ) -> tuple[bytes, int]: # iv is first 16 bytes of sha256, where the last 4 bytes forms the # sequence number used in requests and is incremented on each request payload = b"iv" + local_seed + remote_seed + user_hash @@ -461,17 +467,19 @@ def _iv_derive(self, local_seed, remote_seed, user_hash): seq = int.from_bytes(fulliv[-4:], "big", signed=True) return (fulliv[:12], seq) - def _sig_derive(self, local_seed, remote_seed, user_hash): + def _sig_derive( + self, local_seed: bytes, remote_seed: bytes, user_hash: bytes + ) -> bytes: # used to create a hash with which to prefix each request payload = b"ldk" + local_seed + remote_seed + user_hash return hashlib.sha256(payload).digest()[:28] - def _generate_cipher(self): + def _generate_cipher(self) -> None: iv_seq = self._iv + PACK_SIGNED_LONG(self._seq) cbc = modes.CBC(iv_seq) self._cipher = Cipher(self._aes, cbc) - def encrypt(self, msg): + def encrypt(self, msg: bytes | str) -> tuple[bytes, int]: """Encrypt the data and increment the sequence number.""" self._seq += 1 self._generate_cipher() @@ -488,7 +496,7 @@ def encrypt(self, msg): ).digest() return (signature + ciphertext, self._seq) - def decrypt(self, msg): + def decrypt(self, msg: bytes) -> str: """Decrypt the data.""" decryptor = self._cipher.decryptor() dp = decryptor.update(msg[32:]) + decryptor.finalize() diff --git a/kasa/module.py b/kasa/module.py index 8b68881ea..c4e9f9a11 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -135,13 +135,13 @@ class Module(ABC): # SMARTCAMERA only modules Camera: Final[ModuleName[experimental.Camera]] = ModuleName("Camera") - def __init__(self, device: Device, module: str): + def __init__(self, device: Device, module: str) -> None: self._device = device self._module = module self._module_features: dict[str, Feature] = {} @abstractmethod - def query(self): + def query(self) -> dict: """Query to execute during the update cycle. The inheriting modules implement this to include their wanted @@ -150,10 +150,10 @@ def query(self): @property @abstractmethod - def data(self): + def data(self) -> dict: """Return the module specific raw data from the last update.""" - def _initialize_features(self): # noqa: B027 + def _initialize_features(self) -> None: # noqa: B027 """Initialize features after the initial update. This can be implemented if features depend on module query responses. @@ -162,7 +162,7 @@ def _initialize_features(self): # noqa: B027 children's modules. """ - async def _post_update_hook(self): # noqa: B027 + async def _post_update_hook(self) -> None: # noqa: B027 """Perform actions after a device update. This can be implemented if a module needs to perform actions each time @@ -171,11 +171,11 @@ async def _post_update_hook(self): # noqa: B027 *_initialize_features* on the first update. """ - def _add_feature(self, feature: Feature): + def _add_feature(self, feature: Feature) -> None: """Add module feature.""" id_ = feature.id if id_ in self._module_features: - raise KasaException("Duplicate id detected %s" % id_) + raise KasaException(f"Duplicate id detected {id_}") self._module_features[id_] = feature def __repr__(self) -> str: diff --git a/kasa/protocol.py b/kasa/protocol.py index 140e9c415..f2560987d 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -130,7 +130,7 @@ def __init__( self._transport = transport @property - def _host(self): + def _host(self) -> str: return self._transport._host @property diff --git a/kasa/smart/effects.py b/kasa/smart/effects.py index e0ed615c4..815f777b7 100644 --- a/kasa/smart/effects.py +++ b/kasa/smart/effects.py @@ -15,7 +15,9 @@ class SmartLightEffect(LightEffectInterface, ABC): """ @abstractmethod - async def set_brightness(self, brightness: int, *, transition: int | None = None): + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: """Set effect brightness.""" @property diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index 1dacf1814..f1bf72363 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -20,7 +20,7 @@ def query(self) -> dict: "get_support_alarm_type_list": None, # This should be needed only once } - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features. This is implemented as some features depend on device responses. @@ -100,7 +100,7 @@ def alarm_sound(self) -> str: """Return current alarm sound.""" return self.data["get_alarm_configure"]["type"] - async def set_alarm_sound(self, sound: str): + async def set_alarm_sound(self, sound: str) -> dict: """Set alarm sound. See *alarm_sounds* for list of available sounds. @@ -119,7 +119,7 @@ def alarm_volume(self) -> Literal["low", "normal", "high"]: """Return alarm volume.""" return self.data["get_alarm_configure"]["volume"] - async def set_alarm_volume(self, volume: Literal["low", "normal", "high"]): + async def set_alarm_volume(self, volume: Literal["low", "normal", "high"]) -> dict: """Set alarm volume.""" payload = self.data["get_alarm_configure"].copy() payload["volume"] = volume diff --git a/kasa/smart/modules/autooff.py b/kasa/smart/modules/autooff.py index ae1bb0828..4fefb0007 100644 --- a/kasa/smart/modules/autooff.py +++ b/kasa/smart/modules/autooff.py @@ -17,7 +17,7 @@ class AutoOff(SmartModule): REQUIRED_COMPONENT = "auto_off" QUERY_GETTER_NAME = "get_auto_off_config" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( Feature( @@ -63,7 +63,7 @@ def enabled(self) -> bool: """Return True if enabled.""" return self.data["enable"] - async def set_enabled(self, enable: bool): + async def set_enabled(self, enable: bool) -> dict: """Enable/disable auto off.""" return await self.call( "set_auto_off_config", @@ -75,7 +75,7 @@ def delay(self) -> int: """Return time until auto off.""" return self.data["delay_min"] - async def set_delay(self, delay: int): + async def set_delay(self, delay: int) -> dict: """Set time until auto off.""" return await self.call( "set_auto_off_config", {"delay_min": delay, "enable": self.data["enable"]} @@ -96,7 +96,7 @@ def auto_off_at(self) -> datetime | None: return self._device.time + timedelta(seconds=sysinfo["auto_off_remain_time"]) - async def _check_supported(self): + async def _check_supported(self) -> bool: """Additional check to see if the module is supported by the device. Parent devices that report components of children such as P300 will not have diff --git a/kasa/smart/modules/batterysensor.py b/kasa/smart/modules/batterysensor.py index 7ecfad20f..87072b104 100644 --- a/kasa/smart/modules/batterysensor.py +++ b/kasa/smart/modules/batterysensor.py @@ -12,7 +12,7 @@ class BatterySensor(SmartModule): REQUIRED_COMPONENT = "battery_detect" QUERY_GETTER_NAME = "get_battery_detect_info" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features.""" self._add_feature( Feature( @@ -48,11 +48,11 @@ def query(self) -> dict: return {} @property - def battery(self): + def battery(self) -> int: """Return battery level.""" return self._device.sys_info["battery_percentage"] @property - def battery_low(self): + def battery_low(self) -> bool: """Return True if battery is low.""" return self._device.sys_info["at_low_battery"] diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index f6e5c3229..b5b8d3541 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -14,7 +14,7 @@ class Brightness(SmartModule): REQUIRED_COMPONENT = "brightness" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features.""" super()._initialize_features() @@ -39,7 +39,7 @@ def query(self) -> dict: return {} @property - def brightness(self): + def brightness(self) -> int: """Return current brightness.""" # If the device supports effects and one is active, use its brightness if ( @@ -49,7 +49,9 @@ def brightness(self): return self.data["brightness"] - async def set_brightness(self, brightness: int, *, transition: int | None = None): + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: """Set the brightness. A brightness value of 0 will turn off the light. Note, transition is not supported and will be ignored. @@ -73,6 +75,6 @@ async def set_brightness(self, brightness: int, *, transition: int | None = None return await self.call("set_device_info", {"brightness": brightness}) - async def _check_supported(self): + async def _check_supported(self) -> bool: """Additional check to see if the module is supported by the device.""" return "brightness" in self.data diff --git a/kasa/smart/modules/childprotection.py b/kasa/smart/modules/childprotection.py index d9670a234..fba89cc09 100644 --- a/kasa/smart/modules/childprotection.py +++ b/kasa/smart/modules/childprotection.py @@ -12,7 +12,7 @@ class ChildProtection(SmartModule): REQUIRED_COMPONENT = "child_protection" QUERY_GETTER_NAME = "get_child_protection" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( Feature( diff --git a/kasa/smart/modules/cloud.py b/kasa/smart/modules/cloud.py index 347b9ec8b..fd6d0a0f0 100644 --- a/kasa/smart/modules/cloud.py +++ b/kasa/smart/modules/cloud.py @@ -13,7 +13,7 @@ class Cloud(SmartModule): REQUIRED_COMPONENT = "cloud_connect" MINIMUM_UPDATE_INTERVAL_SECS = 60 - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( Feature( @@ -29,7 +29,7 @@ def _initialize_features(self): ) @property - def is_connected(self): + def is_connected(self) -> bool: """Return True if device is connected to the cloud.""" if self._has_data_error(): return False diff --git a/kasa/smart/modules/color.py b/kasa/smart/modules/color.py index 3faa1a82e..de0c3f747 100644 --- a/kasa/smart/modules/color.py +++ b/kasa/smart/modules/color.py @@ -12,7 +12,7 @@ class Color(SmartModule): REQUIRED_COMPONENT = "color" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( Feature( @@ -48,7 +48,7 @@ def hsv(self) -> HSV: # due to the cpython implementation. return tuple.__new__(HSV, (h, s, v)) - def _raise_for_invalid_brightness(self, value): + def _raise_for_invalid_brightness(self, value: int) -> None: """Raise error on invalid brightness value.""" if not isinstance(value, int): raise TypeError("Brightness must be an integer") diff --git a/kasa/smart/modules/colortemperature.py b/kasa/smart/modules/colortemperature.py index 920fa6d2c..32d6e67da 100644 --- a/kasa/smart/modules/colortemperature.py +++ b/kasa/smart/modules/colortemperature.py @@ -18,7 +18,7 @@ class ColorTemperature(SmartModule): REQUIRED_COMPONENT = "color_temperature" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features.""" self._add_feature( Feature( @@ -52,11 +52,11 @@ def valid_temperature_range(self) -> ColorTempRange: return ColorTempRange(*ct_range) @property - def color_temp(self): + def color_temp(self) -> int: """Return current color temperature.""" return self.data["color_temp"] - async def set_color_temp(self, temp: int, *, brightness=None): + async def set_color_temp(self, temp: int, *, brightness: int | None = None) -> dict: """Set the color temperature.""" valid_temperature_range = self.valid_temperature_range if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: diff --git a/kasa/smart/modules/contactsensor.py b/kasa/smart/modules/contactsensor.py index 0bfa1bded..f388b781d 100644 --- a/kasa/smart/modules/contactsensor.py +++ b/kasa/smart/modules/contactsensor.py @@ -12,7 +12,7 @@ class ContactSensor(SmartModule): REQUIRED_COMPONENT = None # we depend on availability of key REQUIRED_KEY_ON_PARENT = "open" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( Feature( @@ -32,6 +32,6 @@ def query(self) -> dict: return {} @property - def is_open(self): + def is_open(self) -> bool: """Return True if the contact sensor is open.""" return self._device.sys_info["open"] diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py index 89c87c208..bf112e2dd 100644 --- a/kasa/smart/modules/devicemodule.py +++ b/kasa/smart/modules/devicemodule.py @@ -10,7 +10,7 @@ class DeviceModule(SmartModule): REQUIRED_COMPONENT = "device" - async def _post_update_hook(self): + async def _post_update_hook(self) -> None: """Perform actions after a device update. Overrides the default behaviour to disable a module if the query returns diff --git a/kasa/smart/modules/energy.py b/kasa/smart/modules/energy.py index ab89c3193..16a4890ec 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import NoReturn + from ...emeterstatus import EmeterStatus from ...exceptions import KasaException from ...interfaces.energy import Energy as EnergyInterface @@ -31,34 +33,34 @@ def current_consumption(self) -> float | None: # Fallback if get_energy_usage does not provide current_power, # which can happen on some newer devices (e.g. P304M). elif ( - power := self.data.get("get_current_power").get("current_power") + power := self.data.get("get_current_power", {}).get("current_power") ) is not None: return power return None @property @raise_if_update_error - def energy(self): + def energy(self) -> dict: """Return get_energy_usage results.""" if en := self.data.get("get_energy_usage"): return en return self.data - def _get_status_from_energy(self, energy) -> EmeterStatus: + def _get_status_from_energy(self, energy: dict) -> EmeterStatus: return EmeterStatus( { - "power_mw": energy.get("current_power"), - "total": energy.get("today_energy") / 1_000, + "power_mw": energy.get("current_power", 0), + "total": energy.get("today_energy", 0) / 1_000, } ) @property @raise_if_update_error - def status(self): + def status(self) -> EmeterStatus: """Get the emeter status.""" return self._get_status_from_energy(self.energy) - async def get_status(self): + async def get_status(self) -> EmeterStatus: """Return real-time statistics.""" res = await self.call("get_energy_usage") return self._get_status_from_energy(res["get_energy_usage"]) @@ -67,13 +69,13 @@ async def get_status(self): @raise_if_update_error def consumption_this_month(self) -> float | None: """Get the emeter value for this month in kWh.""" - return self.energy.get("month_energy") / 1_000 + return self.energy.get("month_energy", 0) / 1_000 @property @raise_if_update_error def consumption_today(self) -> float | None: """Get the emeter value for today in kWh.""" - return self.energy.get("today_energy") / 1_000 + return self.energy.get("today_energy", 0) / 1_000 @property @raise_if_update_error @@ -97,22 +99,26 @@ async def _deprecated_get_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" return self.status - async def erase_stats(self): + async def erase_stats(self) -> NoReturn: """Erase all stats.""" raise KasaException("Device does not support periodic statistics") - async def get_daily_stats(self, *, year=None, month=None, kwh=True) -> dict: + async def get_daily_stats( + self, *, year: int | None = None, month: int | None = None, kwh: bool = True + ) -> dict: """Return daily stats for the given year & month. The return value is a dictionary of {day: energy, ...}. """ raise KasaException("Device does not support periodic statistics") - async def get_monthly_stats(self, *, year=None, kwh=True) -> dict: + async def get_monthly_stats( + self, *, year: int | None = None, kwh: bool = True + ) -> dict: """Return monthly stats for the given year.""" raise KasaException("Device does not support periodic statistics") - async def _check_supported(self): + async def _check_supported(self) -> bool: """Additional check to see if the module is supported by the device.""" # Energy module is not supported on P304M parent device return "device_on" in self._device.sys_info diff --git a/kasa/smart/modules/fan.py b/kasa/smart/modules/fan.py index 9cb1a8dfc..36b3aadfa 100644 --- a/kasa/smart/modules/fan.py +++ b/kasa/smart/modules/fan.py @@ -12,7 +12,7 @@ class Fan(SmartModule, FanInterface): REQUIRED_COMPONENT = "fan_control" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( Feature( @@ -50,7 +50,7 @@ def fan_speed_level(self) -> int: """Return fan speed level.""" return 0 if self.data["device_on"] is False else self.data["fan_speed_level"] - async def set_fan_speed_level(self, level: int): + async def set_fan_speed_level(self, level: int) -> dict: """Set fan speed level, 0 for off, 1-4 for on.""" if level < 0 or level > 4: raise ValueError("Invalid level, should be in range 0-4.") @@ -65,10 +65,10 @@ def sleep_mode(self) -> bool: """Return sleep mode status.""" return self.data["fan_sleep_mode_on"] - async def set_sleep_mode(self, on: bool): + async def set_sleep_mode(self, on: bool) -> dict: """Set sleep mode.""" return await self.call("set_device_info", {"fan_sleep_mode_on": on}) - async def _check_supported(self): + async def _check_supported(self) -> bool: """Is the module available on this device.""" return "fan_speed_level" in self.data diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 036c0b6cf..f9e6b0341 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -49,14 +49,14 @@ class UpdateInfo(BaseModel): needs_upgrade: bool = Field(alias="need_to_upgrade") @validator("release_date", pre=True) - def _release_date_optional(cls, v): + def _release_date_optional(cls, v: str) -> str | None: if not v: return None return v @property - def update_available(self): + def update_available(self) -> bool: """Return True if update available.""" if self.status != 0: return True @@ -69,11 +69,11 @@ class Firmware(SmartModule): REQUIRED_COMPONENT = "firmware" MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 - def __init__(self, device: SmartDevice, module: str): + def __init__(self, device: SmartDevice, module: str) -> None: super().__init__(device, module) self._firmware_update_info: UpdateInfo | None = None - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features.""" device = self._device if self.supported_version > 1: @@ -183,7 +183,7 @@ async def get_update_state(self) -> DownloadState: @allow_update_after async def update( self, progress_cb: Callable[[DownloadState], Coroutine] | None = None - ): + ) -> dict: """Update the device firmware.""" if not self._firmware_update_info: raise KasaException( @@ -236,13 +236,15 @@ async def update( else: _LOGGER.warning("Unhandled state code: %s", state) + return state.dict() + @property def auto_update_enabled(self) -> bool: """Return True if autoupdate is enabled.""" return "enable" in self.data and self.data["enable"] @allow_update_after - async def set_auto_update_enabled(self, enabled: bool): + async def set_auto_update_enabled(self, enabled: bool) -> dict: """Change autoupdate setting.""" data = {**self.data, "enable": enabled} - await self.call("set_auto_update_info", data) + return await self.call("set_auto_update_info", data) diff --git a/kasa/smart/modules/frostprotection.py b/kasa/smart/modules/frostprotection.py index 440e1ed1b..dd3671a05 100644 --- a/kasa/smart/modules/frostprotection.py +++ b/kasa/smart/modules/frostprotection.py @@ -23,7 +23,7 @@ def enabled(self) -> bool: """Return True if frost protection is on.""" return self._device.sys_info["frost_protection_on"] - async def set_enabled(self, enable: bool): + async def set_enabled(self, enable: bool) -> dict: """Enable/disable frost protection.""" return await self.call( "set_device_info", diff --git a/kasa/smart/modules/humiditysensor.py b/kasa/smart/modules/humiditysensor.py index fab30f052..8ce9e576f 100644 --- a/kasa/smart/modules/humiditysensor.py +++ b/kasa/smart/modules/humiditysensor.py @@ -12,7 +12,7 @@ class HumiditySensor(SmartModule): REQUIRED_COMPONENT = "humidity" QUERY_GETTER_NAME = "get_comfort_humidity_config" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( Feature( @@ -45,7 +45,7 @@ def query(self) -> dict: return {} @property - def humidity(self): + def humidity(self) -> int: """Return current humidity in percentage.""" return self._device.sys_info["current_humidity"] diff --git a/kasa/smart/modules/led.py b/kasa/smart/modules/led.py index 9c02be85a..1733c3ce4 100644 --- a/kasa/smart/modules/led.py +++ b/kasa/smart/modules/led.py @@ -19,7 +19,7 @@ def query(self) -> dict: return {self.QUERY_GETTER_NAME: None} @property - def mode(self): + def mode(self) -> str: """LED mode setting. "always", "never", "night_mode" @@ -27,12 +27,12 @@ def mode(self): return self.data["led_rule"] @property - def led(self): + def led(self) -> bool: """Return current led status.""" return self.data["led_rule"] != "never" @allow_update_after - async def set_led(self, enable: bool): + async def set_led(self, enable: bool) -> dict: """Set led. This should probably be a select with always/never/nightmode. @@ -41,7 +41,7 @@ async def set_led(self, enable: bool): return await self.call("set_led_info", dict(self.data, **{"led_rule": rule})) @property - def night_mode_settings(self): + def night_mode_settings(self) -> dict: """Night mode settings.""" return { "start": self.data["start_time"], diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py index 487c25f35..e637b6075 100644 --- a/kasa/smart/modules/light.py +++ b/kasa/smart/modules/light.py @@ -96,7 +96,7 @@ async def set_hsv( return await self._device.modules[Module.Color].set_hsv(hue, saturation, value) async def set_color_temp( - self, temp: int, *, brightness=None, transition: int | None = None + self, temp: int, *, brightness: int | None = None, transition: int | None = None ) -> dict: """Set the color temperature of the device in kelvin. diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py index 55dd3d490..96135de47 100644 --- a/kasa/smart/modules/lighteffect.py +++ b/kasa/smart/modules/lighteffect.py @@ -81,7 +81,7 @@ async def set_effect( *, brightness: int | None = None, transition: int | None = None, - ) -> None: + ) -> dict: """Set an effect for the device. Calling this will modify the brightness of the effect on the device. @@ -107,7 +107,7 @@ async def set_effect( ) await self.set_brightness(brightness, effect_id=effect_id) - await self.call("set_dynamic_light_effect_rule_enable", params) + return await self.call("set_dynamic_light_effect_rule_enable", params) @property def is_active(self) -> bool: @@ -139,11 +139,11 @@ async def set_brightness( *, transition: int | None = None, effect_id: str | None = None, - ): + ) -> dict: """Set effect brightness.""" new_effect = self._get_effect_data(effect_id=effect_id).copy() - def _replace_brightness(data, new_brightness): + def _replace_brightness(data: list[int], new_brightness: int) -> list[int]: """Replace brightness. The first element is the brightness, the rest are unknown. @@ -163,7 +163,7 @@ def _replace_brightness(data, new_brightness): async def set_custom_effect( self, effect_dict: dict, - ) -> None: + ) -> dict: """Set a custom effect on the device. :param str effect_dict: The custom effect dict to set diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 56ca42c22..2eba75725 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -29,12 +29,12 @@ class LightPreset(SmartModule, LightPresetInterface): _presets: dict[str, LightState] _preset_list: list[str] - def __init__(self, device: SmartDevice, module: str): + def __init__(self, device: SmartDevice, module: str) -> None: super().__init__(device, module) self._state_in_sysinfo = self.SYS_INFO_STATE_KEY in device.sys_info self._brightness_only: bool = False - async def _post_update_hook(self): + async def _post_update_hook(self) -> None: """Update the internal presets.""" index = 0 self._presets = {} @@ -113,7 +113,7 @@ def preset(self) -> str: async def set_preset( self, preset_name: str, - ) -> None: + ) -> dict: """Set a light preset for the device.""" light = self._device.modules[SmartModule.Light] if preset_name == self.PRESET_NOT_SET: @@ -123,14 +123,14 @@ async def set_preset( preset = LightState(brightness=100) elif (preset := self._presets.get(preset_name)) is None: # type: ignore[assignment] raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}") - await self._device.modules[SmartModule.Light].set_state(preset) + return await self._device.modules[SmartModule.Light].set_state(preset) @allow_update_after async def save_preset( self, preset_name: str, preset_state: LightState, - ) -> None: + ) -> dict: """Update the preset with preset_name with the new preset_info.""" if preset_name not in self._presets: raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}") @@ -138,11 +138,13 @@ async def save_preset( if self._brightness_only: bright_list = [state.brightness for state in self._presets.values()] bright_list[index] = preset_state.brightness - await self.call("set_preset_rules", {"brightness": bright_list}) + return await self.call("set_preset_rules", {"brightness": bright_list}) else: state_params = asdict(preset_state) new_info = {k: v for k, v in state_params.items() if v is not None} - await self.call("edit_preset_rules", {"index": index, "state": new_info}) + return await self.call( + "edit_preset_rules", {"index": index, "state": new_info} + ) @property def has_save_preset(self) -> bool: @@ -158,7 +160,7 @@ def query(self) -> dict: return {self.QUERY_GETTER_NAME: {"start_index": 0}} - async def _check_supported(self): + async def _check_supported(self) -> bool: """Additional check to see if the module is supported by the device. Parent devices that report components of children such as ks240 will not have diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index 3b0ff7da5..91d891887 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -16,7 +16,7 @@ class LightStripEffect(SmartModule, SmartLightEffect): REQUIRED_COMPONENT = "light_strip_lighting_effect" - def __init__(self, device: SmartDevice, module: str): + def __init__(self, device: SmartDevice, module: str) -> None: super().__init__(device, module) effect_list = [self.LIGHT_EFFECTS_OFF] effect_list.extend(EFFECT_NAMES) @@ -66,7 +66,9 @@ def brightness(self) -> int: eff = self.data["lighting_effect"] return eff["brightness"] - async def set_brightness(self, brightness: int, *, transition: int | None = None): + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: """Set effect brightness.""" if brightness <= 0: return await self.set_effect(self.LIGHT_EFFECTS_OFF) @@ -91,7 +93,7 @@ async def set_effect( *, brightness: int | None = None, transition: int | None = None, - ) -> None: + ) -> dict: """Set an effect on the device. If brightness or transition is defined, @@ -115,8 +117,7 @@ async def set_effect( effect_dict = self._effect_mapping["Aurora"] effect_dict = {**effect_dict} effect_dict["enable"] = 0 - await self.set_custom_effect(effect_dict) - return + return await self.set_custom_effect(effect_dict) if effect not in self._effect_mapping: raise ValueError(f"The effect {effect} is not a built in effect.") @@ -134,13 +135,13 @@ async def set_effect( if transition is not None: effect_dict["transition"] = transition - await self.set_custom_effect(effect_dict) + return await self.set_custom_effect(effect_dict) @allow_update_after async def set_custom_effect( self, effect_dict: dict, - ) -> None: + ) -> dict: """Set a custom effect on the device. :param str effect_dict: The custom effect dict to set @@ -155,7 +156,7 @@ def has_custom_effects(self) -> bool: """Return True if the device supports setting custom effects.""" return True - def query(self): + def query(self) -> dict: """Return the base query.""" return {} diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index 947f8b0e2..68c4af233 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -39,14 +39,14 @@ class LightTransition(SmartModule): _off_state: _State _enabled: bool - def __init__(self, device: SmartDevice, module: str): + def __init__(self, device: SmartDevice, module: str) -> None: super().__init__(device, module) self._state_in_sysinfo = all( key in device.sys_info for key in self.SYS_INFO_STATE_KEYS ) self._supports_on_and_off: bool = self.supported_version > 1 - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features.""" icon = "mdi:transition" if not self._supports_on_and_off: @@ -138,7 +138,7 @@ async def _post_update_hook(self) -> None: } @allow_update_after - async def set_enabled(self, enable: bool): + async def set_enabled(self, enable: bool) -> dict: """Enable gradual on/off.""" if not self._supports_on_and_off: return await self.call("set_on_off_gradually_info", {"enable": enable}) @@ -171,7 +171,7 @@ def _turn_on_transition_max(self) -> int: return self._on_state["max_duration"] @allow_update_after - async def set_turn_on_transition(self, seconds: int): + async def set_turn_on_transition(self, seconds: int) -> dict: """Set turn on transition in seconds. Setting to 0 turns the feature off. @@ -207,7 +207,7 @@ def _turn_off_transition_max(self) -> int: return self._off_state["max_duration"] @allow_update_after - async def set_turn_off_transition(self, seconds: int): + async def set_turn_off_transition(self, seconds: int) -> dict: """Set turn on transition in seconds. Setting to 0 turns the feature off. @@ -236,7 +236,7 @@ def query(self) -> dict: else: return {self.QUERY_GETTER_NAME: None} - async def _check_supported(self): + async def _check_supported(self) -> bool: """Additional check to see if the module is supported by the device.""" # For devices that report child components on the parent that are not # actually supported by the parent. diff --git a/kasa/smart/modules/motionsensor.py b/kasa/smart/modules/motionsensor.py index 169b25b61..fe9ac5c00 100644 --- a/kasa/smart/modules/motionsensor.py +++ b/kasa/smart/modules/motionsensor.py @@ -11,7 +11,7 @@ class MotionSensor(SmartModule): REQUIRED_COMPONENT = "sensitivity" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features.""" self._add_feature( Feature( @@ -31,6 +31,6 @@ def query(self) -> dict: return {} @property - def motion_detected(self): + def motion_detected(self) -> bool: """Return True if the motion has been detected.""" return self._device.sys_info["detected"] diff --git a/kasa/smart/modules/reportmode.py b/kasa/smart/modules/reportmode.py index 34559cab2..4765b4e13 100644 --- a/kasa/smart/modules/reportmode.py +++ b/kasa/smart/modules/reportmode.py @@ -12,7 +12,7 @@ class ReportMode(SmartModule): REQUIRED_COMPONENT = "report_mode" QUERY_GETTER_NAME = "get_report_mode" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( Feature( @@ -32,6 +32,6 @@ def query(self) -> dict: return {} @property - def report_interval(self): + def report_interval(self) -> int: """Reporting interval of a sensor device.""" return self._device.sys_info["report_interval"] diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index 96630ce55..138c3d2e3 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -26,7 +26,7 @@ class TemperatureControl(SmartModule): REQUIRED_COMPONENT = "temp_control" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( Feature( @@ -92,7 +92,7 @@ def state(self) -> bool: """Return thermostat state.""" return self._device.sys_info["frost_protection_on"] is False - async def set_state(self, enabled: bool): + async def set_state(self, enabled: bool) -> dict: """Set thermostat state.""" return await self.call("set_device_info", {"frost_protection_on": not enabled}) @@ -147,7 +147,7 @@ def states(self) -> set: """Return thermostat states.""" return set(self._device.sys_info["trv_states"]) - async def set_target_temperature(self, target: float): + async def set_target_temperature(self, target: float) -> dict: """Set target temperature.""" if ( target < self.minimum_target_temperature @@ -170,7 +170,7 @@ def temperature_offset(self) -> int: """Return temperature offset.""" return self._device.sys_info["temp_offset"] - async def set_temperature_offset(self, offset: int): + async def set_temperature_offset(self, offset: int) -> dict: """Set temperature offset.""" if offset < -10 or offset > 10: raise ValueError("Temperature offset must be [-10, 10]") diff --git a/kasa/smart/modules/temperaturesensor.py b/kasa/smart/modules/temperaturesensor.py index 8162ce60d..0a591a3d4 100644 --- a/kasa/smart/modules/temperaturesensor.py +++ b/kasa/smart/modules/temperaturesensor.py @@ -14,7 +14,7 @@ class TemperatureSensor(SmartModule): REQUIRED_COMPONENT = "temperature" QUERY_GETTER_NAME = "get_comfort_temp_config" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( Feature( @@ -60,7 +60,7 @@ def query(self) -> dict: return {} @property - def temperature(self): + def temperature(self) -> float: """Return current humidity in percentage.""" return self._device.sys_info["current_temp"] @@ -74,6 +74,8 @@ def temperature_unit(self) -> Literal["celsius", "fahrenheit"]: """Return current temperature unit.""" return self._device.sys_info["temp_unit"] - async def set_temperature_unit(self, unit: Literal["celsius", "fahrenheit"]): + async def set_temperature_unit( + self, unit: Literal["celsius", "fahrenheit"] + ) -> dict: """Set the device temperature unit.""" return await self.call("set_temperature_unit", {"temp_unit": unit}) diff --git a/kasa/smart/modules/time.py b/kasa/smart/modules/time.py index cac01d732..d82991c19 100644 --- a/kasa/smart/modules/time.py +++ b/kasa/smart/modules/time.py @@ -21,7 +21,7 @@ class Time(SmartModule, TimeInterface): _timezone: tzinfo = timezone.utc - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( Feature( @@ -35,7 +35,7 @@ def _initialize_features(self): ) ) - async def _post_update_hook(self): + async def _post_update_hook(self) -> None: """Perform actions after a device update.""" td = timedelta(minutes=cast(float, self.data.get("time_diff"))) if region := self.data.get("region"): @@ -84,7 +84,7 @@ async def set_time(self, dt: datetime) -> dict: params["region"] = region return await self.call("set_device_time", params) - async def _check_supported(self): + async def _check_supported(self) -> bool: """Additional check to see if the module is supported by the device. Hub attached sensors report the time module but do return device time. diff --git a/kasa/smart/modules/waterleaksensor.py b/kasa/smart/modules/waterleaksensor.py index 6b8a7ae71..b6f010174 100644 --- a/kasa/smart/modules/waterleaksensor.py +++ b/kasa/smart/modules/waterleaksensor.py @@ -22,7 +22,7 @@ class WaterleakSensor(SmartModule): REQUIRED_COMPONENT = "sensor_alarm" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( Feature( diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index a5b24fd56..49c922294 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -49,7 +49,7 @@ def __init__( self._update_internal_state(info) self._components = component_info - async def update(self, update_children: bool = True): + async def update(self, update_children: bool = True) -> None: """Update child module info. The parent updates our internal info so just update modules with @@ -57,7 +57,7 @@ async def update(self, update_children: bool = True): """ await self._update(update_children) - async def _update(self, update_children: bool = True): + async def _update(self, update_children: bool = True) -> None: """Update child module info. Internal implementation to allow patching of public update in the cli @@ -118,5 +118,5 @@ def device_type(self) -> DeviceType: dev_type = DeviceType.Unknown return dev_type - def __repr__(self): + def __repr__(self) -> str: return f"<{self.device_type} {self.alias} ({self.model}) of {self._parent}>" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 17386e07f..35524ee8c 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -69,7 +69,7 @@ def __init__( self._on_since: datetime | None = None self._info: dict[str, Any] = {} - async def _initialize_children(self): + async def _initialize_children(self) -> None: """Initialize children for power strips.""" child_info_query = { "get_child_device_component_list": None, @@ -108,7 +108,9 @@ def modules(self) -> ModuleMapping[SmartModule]: """Return the device modules.""" return cast(ModuleMapping[SmartModule], self._modules) - def _try_get_response(self, responses: dict, request: str, default=None) -> dict: + def _try_get_response( + self, responses: dict, request: str, default: Any | None = None + ) -> dict: response = responses.get(request) if isinstance(response, SmartErrorCode): _LOGGER.debug( @@ -126,7 +128,7 @@ def _try_get_response(self, responses: dict, request: str, default=None) -> dict f"{request} not found in {responses} for device {self.host}" ) - async def _negotiate(self): + async def _negotiate(self) -> None: """Perform initialization. We fetch the device info and the available components as early as possible. @@ -146,7 +148,8 @@ async def _negotiate(self): self._info = self._try_get_response(resp, "get_device_info") # Create our internal presentation of available components - self._components_raw = resp["component_nego"] + self._components_raw = cast(dict, resp["component_nego"]) + self._components = { comp["id"]: int(comp["ver_code"]) for comp in self._components_raw["component_list"] @@ -167,7 +170,7 @@ def _update_internal_info(self, info_resp: dict) -> None: """Update the internal device info.""" self._info = self._try_get_response(info_resp, "get_device_info") - async def update(self, update_children: bool = False): + async def update(self, update_children: bool = False) -> None: """Update the device.""" if self.credentials is None and self.credentials_hash is None: raise AuthenticationError("Tapo plug requires authentication.") @@ -206,7 +209,7 @@ async def update(self, update_children: bool = False): async def _handle_module_post_update( self, module: SmartModule, update_time: float, had_query: bool - ): + ) -> None: if module.disabled: return # pragma: no cover if had_query: @@ -312,7 +315,7 @@ async def _handle_modular_update_error( responses[meth] = SmartErrorCode.INTERNAL_QUERY_ERROR return responses - async def _initialize_modules(self): + async def _initialize_modules(self) -> None: """Initialize modules based on component negotiation response.""" from .smartmodule import SmartModule @@ -324,7 +327,7 @@ async def _initialize_modules(self): # It also ensures that devices like power strips do not add modules such as # firmware to the child devices. skip_parent_only_modules = False - child_modules_to_skip = {} + child_modules_to_skip: dict = {} # TODO: this is never non-empty if self._parent and self._parent.device_type != DeviceType.Hub: skip_parent_only_modules = True @@ -333,17 +336,18 @@ async def _initialize_modules(self): skip_parent_only_modules and mod in NON_HUB_PARENT_ONLY_MODULES ) or mod.__name__ in child_modules_to_skip: continue - if ( - mod.REQUIRED_COMPONENT in self._components - or self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None + required_component = cast(str, mod.REQUIRED_COMPONENT) + if required_component in self._components or ( + mod.REQUIRED_KEY_ON_PARENT + and self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None ): _LOGGER.debug( "Device %s, found required %s, adding %s to modules.", self.host, - mod.REQUIRED_COMPONENT, + required_component, mod.__name__, ) - module = mod(self, mod.REQUIRED_COMPONENT) + module = mod(self, required_component) if await module._check_supported(): self._modules[module.name] = module @@ -354,7 +358,7 @@ async def _initialize_modules(self): ): self._modules[Light.__name__] = Light(self, "light") - async def _initialize_features(self): + async def _initialize_features(self) -> None: """Initialize device features.""" self._add_feature( Feature( @@ -575,11 +579,11 @@ def device_id(self) -> str: return str(self._info.get("device_id")) @property - def internal_state(self) -> Any: + def internal_state(self) -> dict: """Return all the internal state data.""" return self._last_update - def _update_internal_state(self, info: dict) -> None: + def _update_internal_state(self, info: dict[str, Any]) -> None: """Update the internal info state. This is used by the parent to push updates to its children. @@ -587,8 +591,8 @@ def _update_internal_state(self, info: dict) -> None: self._info = info async def _query_helper( - self, method: str, params: dict | None = None, child_ids=None - ) -> Any: + self, method: str, params: dict | None = None, child_ids: None = None + ) -> dict: res = await self.protocol.query({method: params}) return res @@ -610,22 +614,25 @@ def is_on(self) -> bool: """Return true if the device is on.""" return bool(self._info.get("device_on")) - async def set_state(self, on: bool): # TODO: better name wanted. + async def set_state(self, on: bool) -> dict: """Set the device state. See :meth:`is_on`. """ return await self.protocol.query({"set_device_info": {"device_on": on}}) - async def turn_on(self, **kwargs): + async def turn_on(self, **kwargs: Any) -> dict: """Turn on the device.""" - await self.set_state(True) + return await self.set_state(True) - async def turn_off(self, **kwargs): + async def turn_off(self, **kwargs: Any) -> dict: """Turn off the device.""" - await self.set_state(False) + return await self.set_state(False) - def update_from_discover_info(self, info): + def update_from_discover_info( + self, + info: dict, + ) -> None: """Update state from info from the discover call.""" self._discovery_info = info self._info = info @@ -633,7 +640,7 @@ def update_from_discover_info(self, info): async def wifi_scan(self) -> list[WifiNetwork]: """Scan for available wifi networks.""" - def _net_for_scan_info(res): + def _net_for_scan_info(res: dict) -> WifiNetwork: return WifiNetwork( ssid=base64.b64decode(res["ssid"]).decode(), cipher_type=res["cipher_type"], @@ -651,7 +658,9 @@ def _net_for_scan_info(res): ] return networks - async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): + async def wifi_join( + self, ssid: str, password: str, keytype: str = "wpa2_psk" + ) -> dict: """Join the given wifi network. This method returns nothing as the device tries to activate the new @@ -688,9 +697,12 @@ async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): except DeviceError: raise # Re-raise on device-reported errors except KasaException: - _LOGGER.debug("Received an expected for wifi join, but this is expected") + _LOGGER.debug( + "Received a kasa exception for wifi join, but this is expected" + ) + return {} - async def update_credentials(self, username: str, password: str): + async def update_credentials(self, username: str, password: str) -> dict: """Update device credentials. This will replace the existing authentication credentials on the device. @@ -705,7 +717,7 @@ async def update_credentials(self, username: str, password: str): } return await self.protocol.query({"set_qs_info": payload}) - async def set_alias(self, alias: str): + async def set_alias(self, alias: str) -> dict: """Set the device name (alias).""" return await self.protocol.query( {"set_device_info": {"nickname": base64.b64encode(alias.encode()).decode()}} diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index f20186ec6..f0b95ecba 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -22,17 +22,17 @@ def allow_update_after( - func: Callable[Concatenate[_T, _P], Awaitable[None]], -) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + func: Callable[Concatenate[_T, _P], Awaitable[dict]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, dict]]: """Define a wrapper to set _last_update_time to None. This will ensure that a module is updated in the next update cycle after a value has been changed. """ - async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> dict: try: - await func(self, *args, **kwargs) + return await func(self, *args, **kwargs) finally: self._last_update_time = None @@ -68,21 +68,21 @@ class SmartModule(Module): DISABLE_AFTER_ERROR_COUNT = 10 - def __init__(self, device: SmartDevice, module: str): + def __init__(self, device: SmartDevice, module: str) -> None: self._device: SmartDevice super().__init__(device, module) self._last_update_time: float | None = None self._last_update_error: KasaException | None = None self._error_count = 0 - def __init_subclass__(cls, **kwargs): + def __init_subclass__(cls, **kwargs) -> None: # We only want to register submodules in a modules package so that # other classes can inherit from smartmodule and not be registered if cls.__module__.split(".")[-2] == "modules": _LOGGER.debug("Registering %s", cls) cls.REGISTERED_MODULES[cls._module_name()] = cls - def _set_error(self, err: Exception | None): + def _set_error(self, err: Exception | None) -> None: if err is None: self._error_count = 0 self._last_update_error = None @@ -119,7 +119,7 @@ def disabled(self) -> bool: return self._error_count >= self.DISABLE_AFTER_ERROR_COUNT @classmethod - def _module_name(cls): + def _module_name(cls) -> str: return getattr(cls, "NAME", cls.__name__) @property @@ -127,7 +127,7 @@ def name(self) -> str: """Name of the module.""" return self._module_name() - async def _post_update_hook(self): # noqa: B027 + async def _post_update_hook(self) -> None: # noqa: B027 """Perform actions after a device update. Any modules overriding this should ensure that self.data is @@ -142,7 +142,7 @@ def query(self) -> dict: """ return {self.QUERY_GETTER_NAME: None} - async def call(self, method, params=None): + async def call(self, method: str, params: dict | None = None) -> dict: """Call a method. Just a helper method. @@ -150,7 +150,7 @@ async def call(self, method, params=None): return await self._device._query_helper(method, params) @property - def data(self): + def data(self) -> dict[str, Any]: """Return response data for the module. If the module performs only a single query, the resulting response is unwrapped. diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 71be7dee1..e2ff6af73 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -72,7 +72,7 @@ def __init__( ) self._redact_data = True - def get_smart_request(self, method, params=None) -> str: + def get_smart_request(self, method: str, params: dict | None = None) -> str: """Get a request message as a string.""" request = { "method": method, @@ -289,8 +289,8 @@ async def _execute_query( return {smart_method: result} async def _handle_response_lists( - self, response_result: dict[str, Any], method, retry_count - ): + self, response_result: dict[str, Any], method: str, retry_count: int + ) -> None: if ( response_result is None or isinstance(response_result, SmartErrorCode) @@ -325,7 +325,9 @@ async def _handle_response_lists( break response_result[response_list_name].extend(next_batch[response_list_name]) - def _handle_response_error_code(self, resp_dict: dict, method, raise_on_error=True): + def _handle_response_error_code( + self, resp_dict: dict, method: str, raise_on_error: bool = True + ) -> None: error_code_raw = resp_dict.get("error_code") try: error_code = SmartErrorCode.from_int(error_code_raw) @@ -369,12 +371,12 @@ class _ChildProtocolWrapper(SmartProtocol): device responses before returning to the caller. """ - def __init__(self, device_id: str, base_protocol: SmartProtocol): + def __init__(self, device_id: str, base_protocol: SmartProtocol) -> None: self._device_id = device_id self._protocol = base_protocol self._transport = base_protocol._transport - def _get_method_and_params_for_request(self, request): + def _get_method_and_params_for_request(self, request: dict[str, Any] | str) -> Any: """Return payload for wrapping. TODO: this does not support batches and requires refactoring in the future. diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 2deebf90b..842147f35 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -310,9 +310,7 @@ def _handle_control_child_missing(self, params: dict): } return retval - raise NotImplementedError( - "Method %s not implemented for children" % child_method - ) + raise NotImplementedError(f"Method {child_method} not implemented for children") def _get_on_off_gradually_info(self, info, params): if self.components["on_off_gradually"] == 1: diff --git a/kasa/tests/smart/modules/test_firmware.py b/kasa/tests/smart/modules/test_firmware.py index c10d90861..013533d0e 100644 --- a/kasa/tests/smart/modules/test_firmware.py +++ b/kasa/tests/smart/modules/test_firmware.py @@ -41,7 +41,7 @@ async def test_firmware_features( await fw.check_latest_firmware() if fw.supported_version < required_version: - pytest.skip("Feature %s requires newer version" % feature) + pytest.skip(f"Feature {feature} requires newer version") prop = getattr(fw, prop_name) assert isinstance(prop, type) diff --git a/kasa/xortransport.py b/kasa/xortransport.py index e8d0303bd..7abc2a3b8 100644 --- a/kasa/xortransport.py +++ b/kasa/xortransport.py @@ -48,7 +48,7 @@ def __init__(self, *, config: DeviceConfig) -> None: self.loop: asyncio.AbstractEventLoop | None = None @property - def default_port(self): + def default_port(self) -> int: """Default port for the transport.""" return self.DEFAULT_PORT diff --git a/pyproject.toml b/pyproject.toml index c2ad3a365..8374a7117 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,10 +139,15 @@ select = [ "PT", # flake8-pytest-style "LOG", # flake8-logging "G", # flake8-logging-format + "ANN", # annotations ] ignore = [ "D105", # Missing docstring in magic method "D107", # Missing docstring in `__init__` + "ANN101", # Missing type annotation for `self` + "ANN102", # Missing type annotation for `cls` in classmethod + "ANN003", # Missing type annotation for `**kwargs` + "ANN401", # allow any ] [tool.ruff.lint.pydocstyle] @@ -157,11 +162,21 @@ convention = "pep257" "D104", "S101", # allow asserts "E501", # ignore line-too-longs + "ANN", # skip for now ] "docs/source/conf.py" = [ "D100", "D103", ] +# Temporary ANN disable +"kasa/cli/*.py" = [ + "ANN", +] +# Temporary ANN disable +"devtools/*.py" = [ + "ANN", +] + [tool.mypy] warn_unused_configs = true # warns if overrides sections unused/mis-spelled From e5dd874333a17a41fa4833f593f0fe4c27e38c87 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Mon, 11 Nov 2024 10:31:13 +0100 Subject: [PATCH 661/892] Update fixture for ES20M 1.0.11 (#1215) --- kasa/tests/fixtures/ES20M(US)_1.0_1.0.11.json | 20 +++++++++++-------- kasa/tests/test_dimmer.py | 3 +++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/kasa/tests/fixtures/ES20M(US)_1.0_1.0.11.json b/kasa/tests/fixtures/ES20M(US)_1.0_1.0.11.json index dd7272360..f87a0a2b1 100644 --- a/kasa/tests/fixtures/ES20M(US)_1.0_1.0.11.json +++ b/kasa/tests/fixtures/ES20M(US)_1.0_1.0.11.json @@ -44,6 +44,10 @@ ], "err_code": 0, "ver": "1.0" + }, + "get_current_brt": { + "err_code": 0, + "value": 16 } }, "smartlife.iot.PIR": { @@ -55,11 +59,11 @@ 0 ], "cold_time": 120000, - "enable": 0, + "enable": 1, "err_code": 0, "max_adc": 4095, "min_adc": 0, - "trigger_index": 1, + "trigger_index": 0, "version": "1.0" } }, @@ -71,7 +75,7 @@ "fadeOnTime": 0, "gentleOffTime": 10000, "gentleOnTime": 3000, - "minThreshold": 5, + "minThreshold": 17, "rampRate": 30 } }, @@ -88,9 +92,9 @@ "hw_ver": "1.0", "icon_hash": "", "latitude_i": 0, - "led_off": 0, + "led_off": 1, "longitude_i": 0, - "mac": "28:87:BA:00:00:00", + "mac": "B0:A7:B9:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "ES20M(US)", "next_action": { @@ -98,7 +102,7 @@ }, "obd_src": "tplink", "oemId": "00000000000000000000000000000000", - "on_time": 0, + "on_time": 6, "preferred_state": [ { "brightness": 100, @@ -117,8 +121,8 @@ "index": 3 } ], - "relay_state": 0, - "rssi": -54, + "relay_state": 1, + "rssi": -40, "status": "new", "sw_ver": "1.0.11 Build 240514 Rel.110351", "updating": 0 diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index bf0d0c563..5d1d10e53 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -9,6 +9,7 @@ @dimmer_iot async def test_set_brightness(dev): await handle_turn_on(dev, False) + await dev.update() assert dev.is_on is False await dev.set_brightness(99) @@ -89,6 +90,7 @@ async def test_turn_off_transition(dev, mocker): original_brightness = dev.brightness await dev.turn_off(transition=1000) + await dev.update() assert dev.is_off assert dev.brightness == original_brightness @@ -126,6 +128,7 @@ async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): query_helper = mocker.spy(IotDimmer, "_query_helper") await dev.set_dimmer_transition(0, 1000) + await dev.update() assert dev.is_off assert dev.brightness == original_brightness From 32671da9e97275048702baf886c6a0711fdae0bf Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 11 Nov 2024 10:11:31 +0000 Subject: [PATCH 662/892] Move tests folder to top level of project (#1242) --- devtools/dump_devinfo.py | 10 +++++----- devtools/generate_supported.py | 4 ++-- docs/source/contribute.md | 2 +- pyproject.toml | 10 +++++----- {kasa/tests => tests}/__init__.py | 0 {kasa/tests => tests}/conftest.py | 0 {kasa/tests => tests}/device_fixtures.py | 0 {kasa/tests => tests}/discovery_fixtures.py | 0 {kasa/tests => tests}/fakeprotocol_iot.py | 6 +++--- {kasa/tests => tests}/fakeprotocol_smart.py | 0 .../fakeprotocol_smartcamera.py | 0 {kasa/tests => tests}/fixtureinfo.py | 0 .../fixtures/EP10(US)_1.0_1.0.2.json | 0 .../fixtures/EP40(US)_1.0_1.0.2.json | 0 .../fixtures/ES20M(US)_1.0_1.0.11.json | 0 .../fixtures/ES20M(US)_1.0_1.0.8.json | 0 .../fixtures/HS100(UK)_1.0_1.2.6.json | 0 .../fixtures/HS100(UK)_4.1_1.1.0.json | 0 .../fixtures/HS100(US)_1.0_1.2.5.json | 0 .../fixtures/HS100(US)_2.0_1.5.6.json | 0 .../fixtures/HS103(US)_1.0_1.5.7.json | 0 .../fixtures/HS103(US)_2.1_1.1.2.json | 0 .../fixtures/HS103(US)_2.1_1.1.4.json | 0 .../fixtures/HS105(US)_1.0_1.5.6.json | 0 .../fixtures/HS107(US)_1.0_1.0.8.json | 0 .../fixtures/HS110(EU)_1.0_1.2.5.json | 0 .../fixtures/HS110(EU)_4.0_1.0.4.json | 0 .../fixtures/HS110(US)_1.0_1.2.6.json | 0 .../fixtures/HS200(US)_2.0_1.5.7.json | 0 .../fixtures/HS200(US)_3.0_1.1.5.json | 0 .../fixtures/HS200(US)_5.0_1.0.11.json | 0 .../fixtures/HS200(US)_5.0_1.0.2.json | 0 .../fixtures/HS210(US)_1.0_1.5.8.json | 0 .../fixtures/HS210(US)_2.0_1.1.5.json | 0 .../fixtures/HS220(US)_1.0_1.5.7.json | 0 .../fixtures/HS220(US)_2.0_1.0.3.json | 0 .../fixtures/HS300(US)_1.0_1.0.10.json | 0 .../fixtures/HS300(US)_1.0_1.0.21.json | 0 .../fixtures/HS300(US)_2.0_1.0.12.json | 0 .../fixtures/HS300(US)_2.0_1.0.3.json | 0 .../fixtures/KL110(US)_1.0_1.8.11.json | 0 .../fixtures/KL120(US)_1.0_1.8.11.json | 0 .../fixtures/KL120(US)_1.0_1.8.6.json | 0 .../fixtures/KL125(US)_1.20_1.0.5.json | 0 .../fixtures/KL125(US)_2.0_1.0.7.json | 0 .../fixtures/KL125(US)_4.0_1.0.5.json | 0 .../fixtures/KL130(EU)_1.0_1.8.8.json | 0 .../fixtures/KL130(US)_1.0_1.8.11.json | 0 .../fixtures/KL135(US)_1.0_1.0.15.json | 0 .../fixtures/KL135(US)_1.0_1.0.6.json | 0 .../fixtures/KL400L5(US)_1.0_1.0.5.json | 0 .../fixtures/KL400L5(US)_1.0_1.0.8.json | 0 .../fixtures/KL420L5(US)_1.0_1.0.2.json | 0 .../fixtures/KL430(UN)_2.0_1.0.8.json | 0 .../fixtures/KL430(US)_1.0_1.0.10.json | 0 .../fixtures/KL430(US)_2.0_1.0.11.json | 0 .../fixtures/KL430(US)_2.0_1.0.8.json | 0 .../fixtures/KL430(US)_2.0_1.0.9.json | 0 .../fixtures/KL50(US)_1.0_1.1.13.json | 0 .../fixtures/KL60(UN)_1.0_1.1.4.json | 0 .../fixtures/KL60(US)_1.0_1.1.13.json | 0 .../fixtures/KP100(US)_3.0_1.0.1.json | 0 .../fixtures/KP105(UK)_1.0_1.0.5.json | 0 .../fixtures/KP105(UK)_1.0_1.0.7.json | 0 .../fixtures/KP115(EU)_1.0_1.0.16.json | 0 .../fixtures/KP115(US)_1.0_1.0.17.json | 0 .../fixtures/KP115(US)_1.0_1.0.21.json | 0 .../fixtures/KP125(US)_1.0_1.0.6.json | 0 .../fixtures/KP200(US)_3.0_1.0.3.json | 0 .../fixtures/KP303(UK)_1.0_1.0.3.json | 0 .../fixtures/KP303(US)_2.0_1.0.3.json | 0 .../fixtures/KP303(US)_2.0_1.0.9.json | 0 .../fixtures/KP400(US)_1.0_1.0.10.json | 0 .../fixtures/KP400(US)_2.0_1.0.6.json | 0 .../fixtures/KP400(US)_3.0_1.0.3.json | 0 .../fixtures/KP400(US)_3.0_1.0.4.json | 0 .../fixtures/KP401(US)_1.0_1.0.0.json | 0 .../fixtures/KP405(US)_1.0_1.0.5.json | 0 .../fixtures/KP405(US)_1.0_1.0.6.json | 0 .../fixtures/KS200M(US)_1.0_1.0.10.json | 0 .../fixtures/KS200M(US)_1.0_1.0.11.json | 0 .../fixtures/KS200M(US)_1.0_1.0.12.json | 0 .../fixtures/KS200M(US)_1.0_1.0.8.json | 0 .../fixtures/KS220(US)_1.0_1.0.13.json | 0 .../fixtures/KS220M(US)_1.0_1.0.4.json | 0 .../fixtures/KS230(US)_1.0_1.0.14.json | 0 .../fixtures/LB110(US)_1.0_1.8.11.json | 0 .../fixtures/smart/EP25(US)_2.6_1.0.1.json | 0 .../fixtures/smart/EP25(US)_2.6_1.0.2.json | 0 .../fixtures/smart/EP40M(US)_1.0_1.1.0.json | 0 .../fixtures/smart/H100(EU)_1.0_1.2.3.json | 0 .../fixtures/smart/H100(EU)_1.0_1.5.10.json | 0 .../fixtures/smart/H100(EU)_1.0_1.5.5.json | 0 .../fixtures/smart/HS220(US)_3.26_1.0.1.json | 0 .../fixtures/smart/KH100(EU)_1.0_1.2.3.json | 0 .../fixtures/smart/KH100(EU)_1.0_1.5.12.json | 0 .../fixtures/smart/KH100(UK)_1.0_1.5.6.json | 0 .../fixtures/smart/KP125M(US)_1.0_1.1.3.json | 0 .../fixtures/smart/KP125M(US)_1.0_1.2.3.json | 0 .../fixtures/smart/KS205(US)_1.0_1.0.2.json | 0 .../fixtures/smart/KS205(US)_1.0_1.1.0.json | 0 .../fixtures/smart/KS225(US)_1.0_1.0.2.json | 0 .../fixtures/smart/KS225(US)_1.0_1.1.0.json | 0 .../fixtures/smart/KS240(US)_1.0_1.0.4.json | 0 .../fixtures/smart/KS240(US)_1.0_1.0.5.json | 0 .../fixtures/smart/KS240(US)_1.0_1.0.7.json | 0 .../fixtures/smart/L510B(EU)_3.0_1.0.5.json | 0 .../fixtures/smart/L510E(US)_3.0_1.0.5.json | 0 .../fixtures/smart/L510E(US)_3.0_1.1.2.json | 0 .../fixtures/smart/L530E(EU)_3.0_1.0.6.json | 0 .../fixtures/smart/L530E(EU)_3.0_1.1.0.json | 0 .../fixtures/smart/L530E(EU)_3.0_1.1.6.json | 0 .../fixtures/smart/L530E(US)_2.0_1.1.0.json | 0 .../fixtures/smart/L630(EU)_1.0_1.1.2.json | 0 .../smart/L900-10(EU)_1.0_1.0.17.json | 0 .../smart/L900-10(US)_1.0_1.0.11.json | 0 .../fixtures/smart/L900-5(EU)_1.0_1.0.17.json | 0 .../fixtures/smart/L900-5(EU)_1.0_1.1.0.json | 0 .../fixtures/smart/L920-5(EU)_1.0_1.0.7.json | 0 .../fixtures/smart/L920-5(EU)_1.0_1.1.3.json | 0 .../fixtures/smart/L920-5(US)_1.0_1.1.0.json | 0 .../fixtures/smart/L920-5(US)_1.0_1.1.3.json | 0 .../fixtures/smart/L930-5(US)_1.0_1.1.2.json | 0 .../fixtures/smart/P100_1.0.0_1.1.3.json | 0 .../fixtures/smart/P100_1.0.0_1.3.7.json | 0 .../fixtures/smart/P100_1.0.0_1.4.0.json | 0 .../fixtures/smart/P110(EU)_1.0_1.0.7.json | 0 .../fixtures/smart/P110(EU)_1.0_1.2.3.json | 0 .../fixtures/smart/P110(UK)_1.0_1.3.0.json | 0 .../fixtures/smart/P115(EU)_1.0_1.2.3.json | 0 .../fixtures/smart/P125M(US)_1.0_1.1.0.json | 0 .../fixtures/smart/P135(US)_1.0_1.0.5.json | 0 .../fixtures/smart/P300(EU)_1.0_1.0.13.json | 0 .../fixtures/smart/P300(EU)_1.0_1.0.15.json | 0 .../fixtures/smart/P300(EU)_1.0_1.0.7.json | 0 .../fixtures/smart/P304M(UK)_1.0_1.0.3.json | 0 .../fixtures/smart/S500D(US)_1.0_1.0.5.json | 0 .../fixtures/smart/S505(US)_1.0_1.0.2.json | 0 .../fixtures/smart/S505D(US)_1.0_1.1.0.json | 0 .../fixtures/smart/TP15(US)_1.0_1.0.3.json | 0 .../fixtures/smart/TP25(US)_1.0_1.0.2.json | 0 .../smart/child/KE100(EU)_1.0_2.4.0.json | 0 .../smart/child/KE100(EU)_1.0_2.8.0.json | 0 .../smart/child/KE100(UK)_1.0_2.8.0.json | 0 .../smart/child/S200B(EU)_1.0_1.11.0.json | 0 .../smart/child/S200B(US)_1.0_1.12.0.json | 0 .../smart/child/S200D(EU)_1.0_1.11.0.json | 0 .../smart/child/S200D(EU)_1.0_1.12.0.json | 0 .../smart/child/T100(EU)_1.0_1.12.0.json | 0 .../smart/child/T110(EU)_1.0_1.8.0.json | 0 .../smart/child/T110(EU)_1.0_1.9.0.json | 0 .../smart/child/T110(US)_1.0_1.9.0.json | 0 .../smart/child/T300(EU)_1.0_1.7.0.json | 0 .../smart/child/T310(EU)_1.0_1.5.0.json | 0 .../smart/child/T310(US)_1.0_1.5.0.json | 0 .../smart/child/T315(EU)_1.0_1.7.0.json | 0 .../smart/child/T315(US)_1.0_1.8.0.json | 0 .../smartcamera/C210(EU)_2.0_1.4.2.json | 0 .../smartcamera/C210(EU)_2.0_1.4.3.json | 0 .../smartcamera/H200(EU)_1.0_1.3.2.json | 0 .../smartcamera/H200(US)_1.0_1.3.6.json | 0 .../fixtures/smartcamera/TC65_1.0_1.3.9.json | 0 .../iot/modules => tests/iot}/__init__.py | 0 .../smart => tests/iot/modules}/__init__.py | 0 .../iot/modules/test_ambientlight.py | 3 ++- .../iot/modules/test_motion.py | 3 ++- {kasa/tests => tests}/iot/test_wallswitch.py | 2 +- .../features => tests/smart}/__init__.py | 0 .../smart/features}/__init__.py | 0 .../smart/features/test_brightness.py | 3 ++- .../smart/features/test_colortemp.py | 3 ++- .../smart/modules}/__init__.py | 0 .../smart/modules/test_autooff.py | 3 ++- .../smart/modules/test_childprotection.py | 3 ++- .../smart/modules/test_contact.py | 3 ++- .../tests => tests}/smart/modules/test_fan.py | 3 ++- .../smart/modules/test_firmware.py | 3 ++- .../smart/modules/test_humidity.py | 3 ++- .../smart/modules/test_light_effect.py | 3 ++- .../smart/modules/test_light_strip_effect.py | 3 ++- .../smart/modules/test_lighttransition.py | 5 +++-- .../smart/modules/test_motionsensor.py | 3 ++- .../smart/modules/test_temperature.py | 3 ++- .../smart/modules/test_temperaturecontrol.py | 3 ++- .../smart/modules/test_waterleak.py | 3 ++- tests/smartcamera/__init__.py | 0 .../smartcamera/test_smartcamera.py | 0 {kasa/tests => tests}/test_aestransport.py | 10 +++++----- {kasa/tests => tests}/test_bulb.py | 0 {kasa/tests => tests}/test_childdevice.py | 0 {kasa/tests => tests}/test_cli.py | 0 {kasa/tests => tests}/test_common_modules.py | 3 ++- {kasa/tests => tests}/test_device.py | 0 {kasa/tests => tests}/test_device_factory.py | 0 {kasa/tests => tests}/test_device_type.py | 0 {kasa/tests => tests}/test_deviceconfig.py | 0 {kasa/tests => tests}/test_dimmer.py | 0 {kasa/tests => tests}/test_discovery.py | 0 {kasa/tests => tests}/test_emeter.py | 0 {kasa/tests => tests}/test_feature.py | 0 {kasa/tests => tests}/test_httpclient.py | 6 +++--- {kasa/tests => tests}/test_iotdevice.py | 0 {kasa/tests => tests}/test_klapprotocol.py | 18 ++++++++--------- {kasa/tests => tests}/test_lightstrip.py | 0 {kasa/tests => tests}/test_plug.py | 0 {kasa/tests => tests}/test_protocol.py | 20 +++++++++---------- {kasa/tests => tests}/test_readme_examples.py | 2 +- {kasa/tests => tests}/test_smartdevice.py | 0 {kasa/tests => tests}/test_smartprotocol.py | 8 ++++---- {kasa/tests => tests}/test_sslaestransport.py | 19 ++++++++++-------- {kasa/tests => tests}/test_strip.py | 0 {kasa/tests => tests}/test_usage.py | 0 212 files changed, 97 insertions(+), 76 deletions(-) rename {kasa/tests => tests}/__init__.py (100%) rename {kasa/tests => tests}/conftest.py (100%) rename {kasa/tests => tests}/device_fixtures.py (100%) rename {kasa/tests => tests}/discovery_fixtures.py (100%) rename {kasa/tests => tests}/fakeprotocol_iot.py (99%) rename {kasa/tests => tests}/fakeprotocol_smart.py (100%) rename {kasa/tests => tests}/fakeprotocol_smartcamera.py (100%) rename {kasa/tests => tests}/fixtureinfo.py (100%) rename {kasa/tests => tests}/fixtures/EP10(US)_1.0_1.0.2.json (100%) rename {kasa/tests => tests}/fixtures/EP40(US)_1.0_1.0.2.json (100%) rename {kasa/tests => tests}/fixtures/ES20M(US)_1.0_1.0.11.json (100%) rename {kasa/tests => tests}/fixtures/ES20M(US)_1.0_1.0.8.json (100%) rename {kasa/tests => tests}/fixtures/HS100(UK)_1.0_1.2.6.json (100%) rename {kasa/tests => tests}/fixtures/HS100(UK)_4.1_1.1.0.json (100%) rename {kasa/tests => tests}/fixtures/HS100(US)_1.0_1.2.5.json (100%) rename {kasa/tests => tests}/fixtures/HS100(US)_2.0_1.5.6.json (100%) rename {kasa/tests => tests}/fixtures/HS103(US)_1.0_1.5.7.json (100%) rename {kasa/tests => tests}/fixtures/HS103(US)_2.1_1.1.2.json (100%) rename {kasa/tests => tests}/fixtures/HS103(US)_2.1_1.1.4.json (100%) rename {kasa/tests => tests}/fixtures/HS105(US)_1.0_1.5.6.json (100%) rename {kasa/tests => tests}/fixtures/HS107(US)_1.0_1.0.8.json (100%) rename {kasa/tests => tests}/fixtures/HS110(EU)_1.0_1.2.5.json (100%) rename {kasa/tests => tests}/fixtures/HS110(EU)_4.0_1.0.4.json (100%) rename {kasa/tests => tests}/fixtures/HS110(US)_1.0_1.2.6.json (100%) rename {kasa/tests => tests}/fixtures/HS200(US)_2.0_1.5.7.json (100%) rename {kasa/tests => tests}/fixtures/HS200(US)_3.0_1.1.5.json (100%) rename {kasa/tests => tests}/fixtures/HS200(US)_5.0_1.0.11.json (100%) rename {kasa/tests => tests}/fixtures/HS200(US)_5.0_1.0.2.json (100%) rename {kasa/tests => tests}/fixtures/HS210(US)_1.0_1.5.8.json (100%) rename {kasa/tests => tests}/fixtures/HS210(US)_2.0_1.1.5.json (100%) rename {kasa/tests => tests}/fixtures/HS220(US)_1.0_1.5.7.json (100%) rename {kasa/tests => tests}/fixtures/HS220(US)_2.0_1.0.3.json (100%) rename {kasa/tests => tests}/fixtures/HS300(US)_1.0_1.0.10.json (100%) rename {kasa/tests => tests}/fixtures/HS300(US)_1.0_1.0.21.json (100%) rename {kasa/tests => tests}/fixtures/HS300(US)_2.0_1.0.12.json (100%) rename {kasa/tests => tests}/fixtures/HS300(US)_2.0_1.0.3.json (100%) rename {kasa/tests => tests}/fixtures/KL110(US)_1.0_1.8.11.json (100%) rename {kasa/tests => tests}/fixtures/KL120(US)_1.0_1.8.11.json (100%) rename {kasa/tests => tests}/fixtures/KL120(US)_1.0_1.8.6.json (100%) rename {kasa/tests => tests}/fixtures/KL125(US)_1.20_1.0.5.json (100%) rename {kasa/tests => tests}/fixtures/KL125(US)_2.0_1.0.7.json (100%) rename {kasa/tests => tests}/fixtures/KL125(US)_4.0_1.0.5.json (100%) rename {kasa/tests => tests}/fixtures/KL130(EU)_1.0_1.8.8.json (100%) rename {kasa/tests => tests}/fixtures/KL130(US)_1.0_1.8.11.json (100%) rename {kasa/tests => tests}/fixtures/KL135(US)_1.0_1.0.15.json (100%) rename {kasa/tests => tests}/fixtures/KL135(US)_1.0_1.0.6.json (100%) rename {kasa/tests => tests}/fixtures/KL400L5(US)_1.0_1.0.5.json (100%) rename {kasa/tests => tests}/fixtures/KL400L5(US)_1.0_1.0.8.json (100%) rename {kasa/tests => tests}/fixtures/KL420L5(US)_1.0_1.0.2.json (100%) rename {kasa/tests => tests}/fixtures/KL430(UN)_2.0_1.0.8.json (100%) rename {kasa/tests => tests}/fixtures/KL430(US)_1.0_1.0.10.json (100%) rename {kasa/tests => tests}/fixtures/KL430(US)_2.0_1.0.11.json (100%) rename {kasa/tests => tests}/fixtures/KL430(US)_2.0_1.0.8.json (100%) rename {kasa/tests => tests}/fixtures/KL430(US)_2.0_1.0.9.json (100%) rename {kasa/tests => tests}/fixtures/KL50(US)_1.0_1.1.13.json (100%) rename {kasa/tests => tests}/fixtures/KL60(UN)_1.0_1.1.4.json (100%) rename {kasa/tests => tests}/fixtures/KL60(US)_1.0_1.1.13.json (100%) rename {kasa/tests => tests}/fixtures/KP100(US)_3.0_1.0.1.json (100%) rename {kasa/tests => tests}/fixtures/KP105(UK)_1.0_1.0.5.json (100%) rename {kasa/tests => tests}/fixtures/KP105(UK)_1.0_1.0.7.json (100%) rename {kasa/tests => tests}/fixtures/KP115(EU)_1.0_1.0.16.json (100%) rename {kasa/tests => tests}/fixtures/KP115(US)_1.0_1.0.17.json (100%) rename {kasa/tests => tests}/fixtures/KP115(US)_1.0_1.0.21.json (100%) rename {kasa/tests => tests}/fixtures/KP125(US)_1.0_1.0.6.json (100%) rename {kasa/tests => tests}/fixtures/KP200(US)_3.0_1.0.3.json (100%) rename {kasa/tests => tests}/fixtures/KP303(UK)_1.0_1.0.3.json (100%) rename {kasa/tests => tests}/fixtures/KP303(US)_2.0_1.0.3.json (100%) rename {kasa/tests => tests}/fixtures/KP303(US)_2.0_1.0.9.json (100%) rename {kasa/tests => tests}/fixtures/KP400(US)_1.0_1.0.10.json (100%) rename {kasa/tests => tests}/fixtures/KP400(US)_2.0_1.0.6.json (100%) rename {kasa/tests => tests}/fixtures/KP400(US)_3.0_1.0.3.json (100%) rename {kasa/tests => tests}/fixtures/KP400(US)_3.0_1.0.4.json (100%) rename {kasa/tests => tests}/fixtures/KP401(US)_1.0_1.0.0.json (100%) rename {kasa/tests => tests}/fixtures/KP405(US)_1.0_1.0.5.json (100%) rename {kasa/tests => tests}/fixtures/KP405(US)_1.0_1.0.6.json (100%) rename {kasa/tests => tests}/fixtures/KS200M(US)_1.0_1.0.10.json (100%) rename {kasa/tests => tests}/fixtures/KS200M(US)_1.0_1.0.11.json (100%) rename {kasa/tests => tests}/fixtures/KS200M(US)_1.0_1.0.12.json (100%) rename {kasa/tests => tests}/fixtures/KS200M(US)_1.0_1.0.8.json (100%) rename {kasa/tests => tests}/fixtures/KS220(US)_1.0_1.0.13.json (100%) rename {kasa/tests => tests}/fixtures/KS220M(US)_1.0_1.0.4.json (100%) rename {kasa/tests => tests}/fixtures/KS230(US)_1.0_1.0.14.json (100%) rename {kasa/tests => tests}/fixtures/LB110(US)_1.0_1.8.11.json (100%) rename {kasa/tests => tests}/fixtures/smart/EP25(US)_2.6_1.0.1.json (100%) rename {kasa/tests => tests}/fixtures/smart/EP25(US)_2.6_1.0.2.json (100%) rename {kasa/tests => tests}/fixtures/smart/EP40M(US)_1.0_1.1.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/H100(EU)_1.0_1.2.3.json (100%) rename {kasa/tests => tests}/fixtures/smart/H100(EU)_1.0_1.5.10.json (100%) rename {kasa/tests => tests}/fixtures/smart/H100(EU)_1.0_1.5.5.json (100%) rename {kasa/tests => tests}/fixtures/smart/HS220(US)_3.26_1.0.1.json (100%) rename {kasa/tests => tests}/fixtures/smart/KH100(EU)_1.0_1.2.3.json (100%) rename {kasa/tests => tests}/fixtures/smart/KH100(EU)_1.0_1.5.12.json (100%) rename {kasa/tests => tests}/fixtures/smart/KH100(UK)_1.0_1.5.6.json (100%) rename {kasa/tests => tests}/fixtures/smart/KP125M(US)_1.0_1.1.3.json (100%) rename {kasa/tests => tests}/fixtures/smart/KP125M(US)_1.0_1.2.3.json (100%) rename {kasa/tests => tests}/fixtures/smart/KS205(US)_1.0_1.0.2.json (100%) rename {kasa/tests => tests}/fixtures/smart/KS205(US)_1.0_1.1.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/KS225(US)_1.0_1.0.2.json (100%) rename {kasa/tests => tests}/fixtures/smart/KS225(US)_1.0_1.1.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/KS240(US)_1.0_1.0.4.json (100%) rename {kasa/tests => tests}/fixtures/smart/KS240(US)_1.0_1.0.5.json (100%) rename {kasa/tests => tests}/fixtures/smart/KS240(US)_1.0_1.0.7.json (100%) rename {kasa/tests => tests}/fixtures/smart/L510B(EU)_3.0_1.0.5.json (100%) rename {kasa/tests => tests}/fixtures/smart/L510E(US)_3.0_1.0.5.json (100%) rename {kasa/tests => tests}/fixtures/smart/L510E(US)_3.0_1.1.2.json (100%) rename {kasa/tests => tests}/fixtures/smart/L530E(EU)_3.0_1.0.6.json (100%) rename {kasa/tests => tests}/fixtures/smart/L530E(EU)_3.0_1.1.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/L530E(EU)_3.0_1.1.6.json (100%) rename {kasa/tests => tests}/fixtures/smart/L530E(US)_2.0_1.1.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/L630(EU)_1.0_1.1.2.json (100%) rename {kasa/tests => tests}/fixtures/smart/L900-10(EU)_1.0_1.0.17.json (100%) rename {kasa/tests => tests}/fixtures/smart/L900-10(US)_1.0_1.0.11.json (100%) rename {kasa/tests => tests}/fixtures/smart/L900-5(EU)_1.0_1.0.17.json (100%) rename {kasa/tests => tests}/fixtures/smart/L900-5(EU)_1.0_1.1.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/L920-5(EU)_1.0_1.0.7.json (100%) rename {kasa/tests => tests}/fixtures/smart/L920-5(EU)_1.0_1.1.3.json (100%) rename {kasa/tests => tests}/fixtures/smart/L920-5(US)_1.0_1.1.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/L920-5(US)_1.0_1.1.3.json (100%) rename {kasa/tests => tests}/fixtures/smart/L930-5(US)_1.0_1.1.2.json (100%) rename {kasa/tests => tests}/fixtures/smart/P100_1.0.0_1.1.3.json (100%) rename {kasa/tests => tests}/fixtures/smart/P100_1.0.0_1.3.7.json (100%) rename {kasa/tests => tests}/fixtures/smart/P100_1.0.0_1.4.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/P110(EU)_1.0_1.0.7.json (100%) rename {kasa/tests => tests}/fixtures/smart/P110(EU)_1.0_1.2.3.json (100%) rename {kasa/tests => tests}/fixtures/smart/P110(UK)_1.0_1.3.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/P115(EU)_1.0_1.2.3.json (100%) rename {kasa/tests => tests}/fixtures/smart/P125M(US)_1.0_1.1.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/P135(US)_1.0_1.0.5.json (100%) rename {kasa/tests => tests}/fixtures/smart/P300(EU)_1.0_1.0.13.json (100%) rename {kasa/tests => tests}/fixtures/smart/P300(EU)_1.0_1.0.15.json (100%) rename {kasa/tests => tests}/fixtures/smart/P300(EU)_1.0_1.0.7.json (100%) rename {kasa/tests => tests}/fixtures/smart/P304M(UK)_1.0_1.0.3.json (100%) rename {kasa/tests => tests}/fixtures/smart/S500D(US)_1.0_1.0.5.json (100%) rename {kasa/tests => tests}/fixtures/smart/S505(US)_1.0_1.0.2.json (100%) rename {kasa/tests => tests}/fixtures/smart/S505D(US)_1.0_1.1.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/TP15(US)_1.0_1.0.3.json (100%) rename {kasa/tests => tests}/fixtures/smart/TP25(US)_1.0_1.0.2.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/KE100(EU)_1.0_2.8.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/KE100(UK)_1.0_2.8.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/S200B(EU)_1.0_1.11.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/S200B(US)_1.0_1.12.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/S200D(EU)_1.0_1.11.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/S200D(EU)_1.0_1.12.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/T100(EU)_1.0_1.12.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/T110(EU)_1.0_1.8.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/T110(EU)_1.0_1.9.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/T110(US)_1.0_1.9.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/T300(EU)_1.0_1.7.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/T310(EU)_1.0_1.5.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/T310(US)_1.0_1.5.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/T315(EU)_1.0_1.7.0.json (100%) rename {kasa/tests => tests}/fixtures/smart/child/T315(US)_1.0_1.8.0.json (100%) rename {kasa/tests => tests}/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json (100%) rename {kasa/tests => tests}/fixtures/smartcamera/C210(EU)_2.0_1.4.3.json (100%) rename {kasa/tests => tests}/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json (100%) rename {kasa/tests => tests}/fixtures/smartcamera/H200(US)_1.0_1.3.6.json (100%) rename {kasa/tests => tests}/fixtures/smartcamera/TC65_1.0_1.3.9.json (100%) rename {kasa/tests/iot/modules => tests/iot}/__init__.py (100%) rename {kasa/tests/smart => tests/iot/modules}/__init__.py (100%) rename {kasa/tests => tests}/iot/modules/test_ambientlight.py (96%) rename {kasa/tests => tests}/iot/modules/test_motion.py (97%) rename {kasa/tests => tests}/iot/test_wallswitch.py (84%) rename {kasa/tests/smart/features => tests/smart}/__init__.py (100%) rename {kasa/tests/smart/modules => tests/smart/features}/__init__.py (100%) rename {kasa/tests => tests}/smart/features/test_brightness.py (95%) rename {kasa/tests => tests}/smart/features/test_colortemp.py (94%) rename {kasa/tests/smartcamera => tests/smart/modules}/__init__.py (100%) rename {kasa/tests => tests}/smart/modules/test_autooff.py (97%) rename {kasa/tests => tests}/smart/modules/test_childprotection.py (95%) rename {kasa/tests => tests}/smart/modules/test_contact.py (92%) rename {kasa/tests => tests}/smart/modules/test_fan.py (96%) rename {kasa/tests => tests}/smart/modules/test_firmware.py (98%) rename {kasa/tests => tests}/smart/modules/test_humidity.py (92%) rename {kasa/tests => tests}/smart/modules/test_light_effect.py (98%) rename {kasa/tests => tests}/smart/modules/test_light_strip_effect.py (98%) rename {kasa/tests => tests}/smart/modules/test_lighttransition.py (95%) rename {kasa/tests => tests}/smart/modules/test_motionsensor.py (92%) rename {kasa/tests => tests}/smart/modules/test_temperature.py (96%) rename {kasa/tests => tests}/smart/modules/test_temperaturecontrol.py (98%) rename {kasa/tests => tests}/smart/modules/test_waterleak.py (96%) create mode 100644 tests/smartcamera/__init__.py rename {kasa/tests => tests}/smartcamera/test_smartcamera.py (100%) rename {kasa/tests => tests}/test_aestransport.py (98%) rename {kasa/tests => tests}/test_bulb.py (100%) rename {kasa/tests => tests}/test_childdevice.py (100%) rename {kasa/tests => tests}/test_cli.py (100%) rename {kasa/tests => tests}/test_common_modules.py (99%) rename {kasa/tests => tests}/test_device.py (100%) rename {kasa/tests => tests}/test_device_factory.py (100%) rename {kasa/tests => tests}/test_device_type.py (100%) rename {kasa/tests => tests}/test_deviceconfig.py (100%) rename {kasa/tests => tests}/test_dimmer.py (100%) rename {kasa/tests => tests}/test_discovery.py (100%) rename {kasa/tests => tests}/test_emeter.py (100%) rename {kasa/tests => tests}/test_feature.py (100%) rename {kasa/tests => tests}/test_httpclient.py (95%) rename {kasa/tests => tests}/test_iotdevice.py (100%) rename {kasa/tests => tests}/test_klapprotocol.py (98%) rename {kasa/tests => tests}/test_lightstrip.py (100%) rename {kasa/tests => tests}/test_plug.py (100%) rename {kasa/tests => tests}/test_protocol.py (98%) rename {kasa/tests => tests}/test_readme_examples.py (99%) rename {kasa/tests => tests}/test_smartdevice.py (100%) rename {kasa/tests => tests}/test_smartprotocol.py (99%) rename {kasa/tests => tests}/test_sslaestransport.py (97%) rename {kasa/tests => tests}/test_strip.py (100%) rename {kasa/tests => tests}/test_usage.py (100%) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 6d03472ea..5cbfff76f 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -1,7 +1,7 @@ """This script generates devinfo files for the test suite. If you have new, yet unsupported device or a device with no devinfo file under - kasa/tests/fixtures, feel free to run this script and create a PR to add the file + tests/fixtures, feel free to run this script and create a PR to add the file to the repository. Executing this script will several modules and methods one by one, @@ -50,10 +50,10 @@ Call = namedtuple("Call", "module method") FixtureResult = namedtuple("FixtureResult", "filename, folder, data") -SMART_FOLDER = "kasa/tests/fixtures/smart/" -SMARTCAMERA_FOLDER = "kasa/tests/fixtures/smartcamera/" -SMART_CHILD_FOLDER = "kasa/tests/fixtures/smart/child/" -IOT_FOLDER = "kasa/tests/fixtures/" +SMART_FOLDER = "tests/fixtures/smart/" +SMARTCAMERA_FOLDER = "tests/fixtures/smartcamera/" +SMART_CHILD_FOLDER = "tests/fixtures/smart/child/" +IOT_FOLDER = "tests/fixtures/" ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py index b2909149c..43a69455b 100755 --- a/devtools/generate_supported.py +++ b/devtools/generate_supported.py @@ -41,8 +41,8 @@ class SupportedVersion(NamedTuple): SUPPORTED_FILENAME = "SUPPORTED.md" README_FILENAME = "README.md" -IOT_FOLDER = "kasa/tests/fixtures/" -SMART_FOLDER = "kasa/tests/fixtures/smart/" +IOT_FOLDER = "tests/fixtures/" +SMART_FOLDER = "tests/fixtures/smart/" def generate_supported(args): diff --git a/docs/source/contribute.md b/docs/source/contribute.md index e2aae43f8..2f735ce18 100644 --- a/docs/source/contribute.md +++ b/docs/source/contribute.md @@ -59,7 +59,7 @@ One of the easiest ways to contribute is by creating a fixture file and uploadin These files will help us to improve the library and run tests against devices that we have no access to. This library is tested against responses from real devices ("fixture files"). -These files contain responses for selected, known device commands and are stored [in our test suite](https://github.com/python-kasa/python-kasa/tree/master/kasa/tests/fixtures). +These files contain responses for selected, known device commands and are stored [in our test suite](https://github.com/python-kasa/python-kasa/tree/master/tests/fixtures). You can generate these files by using the `dump_devinfo.py` script. Note, that this script should be run inside the main source directory so that the generated files are stored in the correct directories. diff --git a/pyproject.toml b/pyproject.toml index 8374a7117..92ef7bbe3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ include = [ "/kasa", "/devtools", "/docs", + "/tests", "/CHANGELOG.md", ] @@ -78,15 +79,11 @@ include = [ include = [ "/kasa", ] -exclude = [ - "/kasa/tests", -] [tool.coverage.run] source = ["kasa"] branch = true omit = [ - "kasa/tests/*", "kasa/experimental/*" ] @@ -107,6 +104,9 @@ exclude_lines = [ ] [tool.pytest.ini_options] +testpaths = [ + "tests", +] markers = [ "requires_dummy: test requires dummy data to pass, skipped on real devices", ] @@ -154,7 +154,7 @@ ignore = [ convention = "pep257" [tool.ruff.lint.per-file-ignores] -"kasa/tests/*.py" = [ +"tests/*.py" = [ "D100", "D101", "D102", diff --git a/kasa/tests/__init__.py b/tests/__init__.py similarity index 100% rename from kasa/tests/__init__.py rename to tests/__init__.py diff --git a/kasa/tests/conftest.py b/tests/conftest.py similarity index 100% rename from kasa/tests/conftest.py rename to tests/conftest.py diff --git a/kasa/tests/device_fixtures.py b/tests/device_fixtures.py similarity index 100% rename from kasa/tests/device_fixtures.py rename to tests/device_fixtures.py diff --git a/kasa/tests/discovery_fixtures.py b/tests/discovery_fixtures.py similarity index 100% rename from kasa/tests/discovery_fixtures.py rename to tests/discovery_fixtures.py diff --git a/kasa/tests/fakeprotocol_iot.py b/tests/fakeprotocol_iot.py similarity index 99% rename from kasa/tests/fakeprotocol_iot.py rename to tests/fakeprotocol_iot.py index 36f532359..c8897d9b9 100644 --- a/kasa/tests/fakeprotocol_iot.py +++ b/tests/fakeprotocol_iot.py @@ -1,9 +1,9 @@ import copy import logging -from ..deviceconfig import DeviceConfig -from ..iotprotocol import IotProtocol -from ..protocol import BaseTransport +from kasa.deviceconfig import DeviceConfig +from kasa.iotprotocol import IotProtocol +from kasa.protocol import BaseTransport _LOGGER = logging.getLogger(__name__) diff --git a/kasa/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py similarity index 100% rename from kasa/tests/fakeprotocol_smart.py rename to tests/fakeprotocol_smart.py diff --git a/kasa/tests/fakeprotocol_smartcamera.py b/tests/fakeprotocol_smartcamera.py similarity index 100% rename from kasa/tests/fakeprotocol_smartcamera.py rename to tests/fakeprotocol_smartcamera.py diff --git a/kasa/tests/fixtureinfo.py b/tests/fixtureinfo.py similarity index 100% rename from kasa/tests/fixtureinfo.py rename to tests/fixtureinfo.py diff --git a/kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json b/tests/fixtures/EP10(US)_1.0_1.0.2.json similarity index 100% rename from kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json rename to tests/fixtures/EP10(US)_1.0_1.0.2.json diff --git a/kasa/tests/fixtures/EP40(US)_1.0_1.0.2.json b/tests/fixtures/EP40(US)_1.0_1.0.2.json similarity index 100% rename from kasa/tests/fixtures/EP40(US)_1.0_1.0.2.json rename to tests/fixtures/EP40(US)_1.0_1.0.2.json diff --git a/kasa/tests/fixtures/ES20M(US)_1.0_1.0.11.json b/tests/fixtures/ES20M(US)_1.0_1.0.11.json similarity index 100% rename from kasa/tests/fixtures/ES20M(US)_1.0_1.0.11.json rename to tests/fixtures/ES20M(US)_1.0_1.0.11.json diff --git a/kasa/tests/fixtures/ES20M(US)_1.0_1.0.8.json b/tests/fixtures/ES20M(US)_1.0_1.0.8.json similarity index 100% rename from kasa/tests/fixtures/ES20M(US)_1.0_1.0.8.json rename to tests/fixtures/ES20M(US)_1.0_1.0.8.json diff --git a/kasa/tests/fixtures/HS100(UK)_1.0_1.2.6.json b/tests/fixtures/HS100(UK)_1.0_1.2.6.json similarity index 100% rename from kasa/tests/fixtures/HS100(UK)_1.0_1.2.6.json rename to tests/fixtures/HS100(UK)_1.0_1.2.6.json diff --git a/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json b/tests/fixtures/HS100(UK)_4.1_1.1.0.json similarity index 100% rename from kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json rename to tests/fixtures/HS100(UK)_4.1_1.1.0.json diff --git a/kasa/tests/fixtures/HS100(US)_1.0_1.2.5.json b/tests/fixtures/HS100(US)_1.0_1.2.5.json similarity index 100% rename from kasa/tests/fixtures/HS100(US)_1.0_1.2.5.json rename to tests/fixtures/HS100(US)_1.0_1.2.5.json diff --git a/kasa/tests/fixtures/HS100(US)_2.0_1.5.6.json b/tests/fixtures/HS100(US)_2.0_1.5.6.json similarity index 100% rename from kasa/tests/fixtures/HS100(US)_2.0_1.5.6.json rename to tests/fixtures/HS100(US)_2.0_1.5.6.json diff --git a/kasa/tests/fixtures/HS103(US)_1.0_1.5.7.json b/tests/fixtures/HS103(US)_1.0_1.5.7.json similarity index 100% rename from kasa/tests/fixtures/HS103(US)_1.0_1.5.7.json rename to tests/fixtures/HS103(US)_1.0_1.5.7.json diff --git a/kasa/tests/fixtures/HS103(US)_2.1_1.1.2.json b/tests/fixtures/HS103(US)_2.1_1.1.2.json similarity index 100% rename from kasa/tests/fixtures/HS103(US)_2.1_1.1.2.json rename to tests/fixtures/HS103(US)_2.1_1.1.2.json diff --git a/kasa/tests/fixtures/HS103(US)_2.1_1.1.4.json b/tests/fixtures/HS103(US)_2.1_1.1.4.json similarity index 100% rename from kasa/tests/fixtures/HS103(US)_2.1_1.1.4.json rename to tests/fixtures/HS103(US)_2.1_1.1.4.json diff --git a/kasa/tests/fixtures/HS105(US)_1.0_1.5.6.json b/tests/fixtures/HS105(US)_1.0_1.5.6.json similarity index 100% rename from kasa/tests/fixtures/HS105(US)_1.0_1.5.6.json rename to tests/fixtures/HS105(US)_1.0_1.5.6.json diff --git a/kasa/tests/fixtures/HS107(US)_1.0_1.0.8.json b/tests/fixtures/HS107(US)_1.0_1.0.8.json similarity index 100% rename from kasa/tests/fixtures/HS107(US)_1.0_1.0.8.json rename to tests/fixtures/HS107(US)_1.0_1.0.8.json diff --git a/kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.json b/tests/fixtures/HS110(EU)_1.0_1.2.5.json similarity index 100% rename from kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.json rename to tests/fixtures/HS110(EU)_1.0_1.2.5.json diff --git a/kasa/tests/fixtures/HS110(EU)_4.0_1.0.4.json b/tests/fixtures/HS110(EU)_4.0_1.0.4.json similarity index 100% rename from kasa/tests/fixtures/HS110(EU)_4.0_1.0.4.json rename to tests/fixtures/HS110(EU)_4.0_1.0.4.json diff --git a/kasa/tests/fixtures/HS110(US)_1.0_1.2.6.json b/tests/fixtures/HS110(US)_1.0_1.2.6.json similarity index 100% rename from kasa/tests/fixtures/HS110(US)_1.0_1.2.6.json rename to tests/fixtures/HS110(US)_1.0_1.2.6.json diff --git a/kasa/tests/fixtures/HS200(US)_2.0_1.5.7.json b/tests/fixtures/HS200(US)_2.0_1.5.7.json similarity index 100% rename from kasa/tests/fixtures/HS200(US)_2.0_1.5.7.json rename to tests/fixtures/HS200(US)_2.0_1.5.7.json diff --git a/kasa/tests/fixtures/HS200(US)_3.0_1.1.5.json b/tests/fixtures/HS200(US)_3.0_1.1.5.json similarity index 100% rename from kasa/tests/fixtures/HS200(US)_3.0_1.1.5.json rename to tests/fixtures/HS200(US)_3.0_1.1.5.json diff --git a/kasa/tests/fixtures/HS200(US)_5.0_1.0.11.json b/tests/fixtures/HS200(US)_5.0_1.0.11.json similarity index 100% rename from kasa/tests/fixtures/HS200(US)_5.0_1.0.11.json rename to tests/fixtures/HS200(US)_5.0_1.0.11.json diff --git a/kasa/tests/fixtures/HS200(US)_5.0_1.0.2.json b/tests/fixtures/HS200(US)_5.0_1.0.2.json similarity index 100% rename from kasa/tests/fixtures/HS200(US)_5.0_1.0.2.json rename to tests/fixtures/HS200(US)_5.0_1.0.2.json diff --git a/kasa/tests/fixtures/HS210(US)_1.0_1.5.8.json b/tests/fixtures/HS210(US)_1.0_1.5.8.json similarity index 100% rename from kasa/tests/fixtures/HS210(US)_1.0_1.5.8.json rename to tests/fixtures/HS210(US)_1.0_1.5.8.json diff --git a/kasa/tests/fixtures/HS210(US)_2.0_1.1.5.json b/tests/fixtures/HS210(US)_2.0_1.1.5.json similarity index 100% rename from kasa/tests/fixtures/HS210(US)_2.0_1.1.5.json rename to tests/fixtures/HS210(US)_2.0_1.1.5.json diff --git a/kasa/tests/fixtures/HS220(US)_1.0_1.5.7.json b/tests/fixtures/HS220(US)_1.0_1.5.7.json similarity index 100% rename from kasa/tests/fixtures/HS220(US)_1.0_1.5.7.json rename to tests/fixtures/HS220(US)_1.0_1.5.7.json diff --git a/kasa/tests/fixtures/HS220(US)_2.0_1.0.3.json b/tests/fixtures/HS220(US)_2.0_1.0.3.json similarity index 100% rename from kasa/tests/fixtures/HS220(US)_2.0_1.0.3.json rename to tests/fixtures/HS220(US)_2.0_1.0.3.json diff --git a/kasa/tests/fixtures/HS300(US)_1.0_1.0.10.json b/tests/fixtures/HS300(US)_1.0_1.0.10.json similarity index 100% rename from kasa/tests/fixtures/HS300(US)_1.0_1.0.10.json rename to tests/fixtures/HS300(US)_1.0_1.0.10.json diff --git a/kasa/tests/fixtures/HS300(US)_1.0_1.0.21.json b/tests/fixtures/HS300(US)_1.0_1.0.21.json similarity index 100% rename from kasa/tests/fixtures/HS300(US)_1.0_1.0.21.json rename to tests/fixtures/HS300(US)_1.0_1.0.21.json diff --git a/kasa/tests/fixtures/HS300(US)_2.0_1.0.12.json b/tests/fixtures/HS300(US)_2.0_1.0.12.json similarity index 100% rename from kasa/tests/fixtures/HS300(US)_2.0_1.0.12.json rename to tests/fixtures/HS300(US)_2.0_1.0.12.json diff --git a/kasa/tests/fixtures/HS300(US)_2.0_1.0.3.json b/tests/fixtures/HS300(US)_2.0_1.0.3.json similarity index 100% rename from kasa/tests/fixtures/HS300(US)_2.0_1.0.3.json rename to tests/fixtures/HS300(US)_2.0_1.0.3.json diff --git a/kasa/tests/fixtures/KL110(US)_1.0_1.8.11.json b/tests/fixtures/KL110(US)_1.0_1.8.11.json similarity index 100% rename from kasa/tests/fixtures/KL110(US)_1.0_1.8.11.json rename to tests/fixtures/KL110(US)_1.0_1.8.11.json diff --git a/kasa/tests/fixtures/KL120(US)_1.0_1.8.11.json b/tests/fixtures/KL120(US)_1.0_1.8.11.json similarity index 100% rename from kasa/tests/fixtures/KL120(US)_1.0_1.8.11.json rename to tests/fixtures/KL120(US)_1.0_1.8.11.json diff --git a/kasa/tests/fixtures/KL120(US)_1.0_1.8.6.json b/tests/fixtures/KL120(US)_1.0_1.8.6.json similarity index 100% rename from kasa/tests/fixtures/KL120(US)_1.0_1.8.6.json rename to tests/fixtures/KL120(US)_1.0_1.8.6.json diff --git a/kasa/tests/fixtures/KL125(US)_1.20_1.0.5.json b/tests/fixtures/KL125(US)_1.20_1.0.5.json similarity index 100% rename from kasa/tests/fixtures/KL125(US)_1.20_1.0.5.json rename to tests/fixtures/KL125(US)_1.20_1.0.5.json diff --git a/kasa/tests/fixtures/KL125(US)_2.0_1.0.7.json b/tests/fixtures/KL125(US)_2.0_1.0.7.json similarity index 100% rename from kasa/tests/fixtures/KL125(US)_2.0_1.0.7.json rename to tests/fixtures/KL125(US)_2.0_1.0.7.json diff --git a/kasa/tests/fixtures/KL125(US)_4.0_1.0.5.json b/tests/fixtures/KL125(US)_4.0_1.0.5.json similarity index 100% rename from kasa/tests/fixtures/KL125(US)_4.0_1.0.5.json rename to tests/fixtures/KL125(US)_4.0_1.0.5.json diff --git a/kasa/tests/fixtures/KL130(EU)_1.0_1.8.8.json b/tests/fixtures/KL130(EU)_1.0_1.8.8.json similarity index 100% rename from kasa/tests/fixtures/KL130(EU)_1.0_1.8.8.json rename to tests/fixtures/KL130(EU)_1.0_1.8.8.json diff --git a/kasa/tests/fixtures/KL130(US)_1.0_1.8.11.json b/tests/fixtures/KL130(US)_1.0_1.8.11.json similarity index 100% rename from kasa/tests/fixtures/KL130(US)_1.0_1.8.11.json rename to tests/fixtures/KL130(US)_1.0_1.8.11.json diff --git a/kasa/tests/fixtures/KL135(US)_1.0_1.0.15.json b/tests/fixtures/KL135(US)_1.0_1.0.15.json similarity index 100% rename from kasa/tests/fixtures/KL135(US)_1.0_1.0.15.json rename to tests/fixtures/KL135(US)_1.0_1.0.15.json diff --git a/kasa/tests/fixtures/KL135(US)_1.0_1.0.6.json b/tests/fixtures/KL135(US)_1.0_1.0.6.json similarity index 100% rename from kasa/tests/fixtures/KL135(US)_1.0_1.0.6.json rename to tests/fixtures/KL135(US)_1.0_1.0.6.json diff --git a/kasa/tests/fixtures/KL400L5(US)_1.0_1.0.5.json b/tests/fixtures/KL400L5(US)_1.0_1.0.5.json similarity index 100% rename from kasa/tests/fixtures/KL400L5(US)_1.0_1.0.5.json rename to tests/fixtures/KL400L5(US)_1.0_1.0.5.json diff --git a/kasa/tests/fixtures/KL400L5(US)_1.0_1.0.8.json b/tests/fixtures/KL400L5(US)_1.0_1.0.8.json similarity index 100% rename from kasa/tests/fixtures/KL400L5(US)_1.0_1.0.8.json rename to tests/fixtures/KL400L5(US)_1.0_1.0.8.json diff --git a/kasa/tests/fixtures/KL420L5(US)_1.0_1.0.2.json b/tests/fixtures/KL420L5(US)_1.0_1.0.2.json similarity index 100% rename from kasa/tests/fixtures/KL420L5(US)_1.0_1.0.2.json rename to tests/fixtures/KL420L5(US)_1.0_1.0.2.json diff --git a/kasa/tests/fixtures/KL430(UN)_2.0_1.0.8.json b/tests/fixtures/KL430(UN)_2.0_1.0.8.json similarity index 100% rename from kasa/tests/fixtures/KL430(UN)_2.0_1.0.8.json rename to tests/fixtures/KL430(UN)_2.0_1.0.8.json diff --git a/kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json b/tests/fixtures/KL430(US)_1.0_1.0.10.json similarity index 100% rename from kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json rename to tests/fixtures/KL430(US)_1.0_1.0.10.json diff --git a/kasa/tests/fixtures/KL430(US)_2.0_1.0.11.json b/tests/fixtures/KL430(US)_2.0_1.0.11.json similarity index 100% rename from kasa/tests/fixtures/KL430(US)_2.0_1.0.11.json rename to tests/fixtures/KL430(US)_2.0_1.0.11.json diff --git a/kasa/tests/fixtures/KL430(US)_2.0_1.0.8.json b/tests/fixtures/KL430(US)_2.0_1.0.8.json similarity index 100% rename from kasa/tests/fixtures/KL430(US)_2.0_1.0.8.json rename to tests/fixtures/KL430(US)_2.0_1.0.8.json diff --git a/kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json b/tests/fixtures/KL430(US)_2.0_1.0.9.json similarity index 100% rename from kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json rename to tests/fixtures/KL430(US)_2.0_1.0.9.json diff --git a/kasa/tests/fixtures/KL50(US)_1.0_1.1.13.json b/tests/fixtures/KL50(US)_1.0_1.1.13.json similarity index 100% rename from kasa/tests/fixtures/KL50(US)_1.0_1.1.13.json rename to tests/fixtures/KL50(US)_1.0_1.1.13.json diff --git a/kasa/tests/fixtures/KL60(UN)_1.0_1.1.4.json b/tests/fixtures/KL60(UN)_1.0_1.1.4.json similarity index 100% rename from kasa/tests/fixtures/KL60(UN)_1.0_1.1.4.json rename to tests/fixtures/KL60(UN)_1.0_1.1.4.json diff --git a/kasa/tests/fixtures/KL60(US)_1.0_1.1.13.json b/tests/fixtures/KL60(US)_1.0_1.1.13.json similarity index 100% rename from kasa/tests/fixtures/KL60(US)_1.0_1.1.13.json rename to tests/fixtures/KL60(US)_1.0_1.1.13.json diff --git a/kasa/tests/fixtures/KP100(US)_3.0_1.0.1.json b/tests/fixtures/KP100(US)_3.0_1.0.1.json similarity index 100% rename from kasa/tests/fixtures/KP100(US)_3.0_1.0.1.json rename to tests/fixtures/KP100(US)_3.0_1.0.1.json diff --git a/kasa/tests/fixtures/KP105(UK)_1.0_1.0.5.json b/tests/fixtures/KP105(UK)_1.0_1.0.5.json similarity index 100% rename from kasa/tests/fixtures/KP105(UK)_1.0_1.0.5.json rename to tests/fixtures/KP105(UK)_1.0_1.0.5.json diff --git a/kasa/tests/fixtures/KP105(UK)_1.0_1.0.7.json b/tests/fixtures/KP105(UK)_1.0_1.0.7.json similarity index 100% rename from kasa/tests/fixtures/KP105(UK)_1.0_1.0.7.json rename to tests/fixtures/KP105(UK)_1.0_1.0.7.json diff --git a/kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json b/tests/fixtures/KP115(EU)_1.0_1.0.16.json similarity index 100% rename from kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json rename to tests/fixtures/KP115(EU)_1.0_1.0.16.json diff --git a/kasa/tests/fixtures/KP115(US)_1.0_1.0.17.json b/tests/fixtures/KP115(US)_1.0_1.0.17.json similarity index 100% rename from kasa/tests/fixtures/KP115(US)_1.0_1.0.17.json rename to tests/fixtures/KP115(US)_1.0_1.0.17.json diff --git a/kasa/tests/fixtures/KP115(US)_1.0_1.0.21.json b/tests/fixtures/KP115(US)_1.0_1.0.21.json similarity index 100% rename from kasa/tests/fixtures/KP115(US)_1.0_1.0.21.json rename to tests/fixtures/KP115(US)_1.0_1.0.21.json diff --git a/kasa/tests/fixtures/KP125(US)_1.0_1.0.6.json b/tests/fixtures/KP125(US)_1.0_1.0.6.json similarity index 100% rename from kasa/tests/fixtures/KP125(US)_1.0_1.0.6.json rename to tests/fixtures/KP125(US)_1.0_1.0.6.json diff --git a/kasa/tests/fixtures/KP200(US)_3.0_1.0.3.json b/tests/fixtures/KP200(US)_3.0_1.0.3.json similarity index 100% rename from kasa/tests/fixtures/KP200(US)_3.0_1.0.3.json rename to tests/fixtures/KP200(US)_3.0_1.0.3.json diff --git a/kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.json b/tests/fixtures/KP303(UK)_1.0_1.0.3.json similarity index 100% rename from kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.json rename to tests/fixtures/KP303(UK)_1.0_1.0.3.json diff --git a/kasa/tests/fixtures/KP303(US)_2.0_1.0.3.json b/tests/fixtures/KP303(US)_2.0_1.0.3.json similarity index 100% rename from kasa/tests/fixtures/KP303(US)_2.0_1.0.3.json rename to tests/fixtures/KP303(US)_2.0_1.0.3.json diff --git a/kasa/tests/fixtures/KP303(US)_2.0_1.0.9.json b/tests/fixtures/KP303(US)_2.0_1.0.9.json similarity index 100% rename from kasa/tests/fixtures/KP303(US)_2.0_1.0.9.json rename to tests/fixtures/KP303(US)_2.0_1.0.9.json diff --git a/kasa/tests/fixtures/KP400(US)_1.0_1.0.10.json b/tests/fixtures/KP400(US)_1.0_1.0.10.json similarity index 100% rename from kasa/tests/fixtures/KP400(US)_1.0_1.0.10.json rename to tests/fixtures/KP400(US)_1.0_1.0.10.json diff --git a/kasa/tests/fixtures/KP400(US)_2.0_1.0.6.json b/tests/fixtures/KP400(US)_2.0_1.0.6.json similarity index 100% rename from kasa/tests/fixtures/KP400(US)_2.0_1.0.6.json rename to tests/fixtures/KP400(US)_2.0_1.0.6.json diff --git a/kasa/tests/fixtures/KP400(US)_3.0_1.0.3.json b/tests/fixtures/KP400(US)_3.0_1.0.3.json similarity index 100% rename from kasa/tests/fixtures/KP400(US)_3.0_1.0.3.json rename to tests/fixtures/KP400(US)_3.0_1.0.3.json diff --git a/kasa/tests/fixtures/KP400(US)_3.0_1.0.4.json b/tests/fixtures/KP400(US)_3.0_1.0.4.json similarity index 100% rename from kasa/tests/fixtures/KP400(US)_3.0_1.0.4.json rename to tests/fixtures/KP400(US)_3.0_1.0.4.json diff --git a/kasa/tests/fixtures/KP401(US)_1.0_1.0.0.json b/tests/fixtures/KP401(US)_1.0_1.0.0.json similarity index 100% rename from kasa/tests/fixtures/KP401(US)_1.0_1.0.0.json rename to tests/fixtures/KP401(US)_1.0_1.0.0.json diff --git a/kasa/tests/fixtures/KP405(US)_1.0_1.0.5.json b/tests/fixtures/KP405(US)_1.0_1.0.5.json similarity index 100% rename from kasa/tests/fixtures/KP405(US)_1.0_1.0.5.json rename to tests/fixtures/KP405(US)_1.0_1.0.5.json diff --git a/kasa/tests/fixtures/KP405(US)_1.0_1.0.6.json b/tests/fixtures/KP405(US)_1.0_1.0.6.json similarity index 100% rename from kasa/tests/fixtures/KP405(US)_1.0_1.0.6.json rename to tests/fixtures/KP405(US)_1.0_1.0.6.json diff --git a/kasa/tests/fixtures/KS200M(US)_1.0_1.0.10.json b/tests/fixtures/KS200M(US)_1.0_1.0.10.json similarity index 100% rename from kasa/tests/fixtures/KS200M(US)_1.0_1.0.10.json rename to tests/fixtures/KS200M(US)_1.0_1.0.10.json diff --git a/kasa/tests/fixtures/KS200M(US)_1.0_1.0.11.json b/tests/fixtures/KS200M(US)_1.0_1.0.11.json similarity index 100% rename from kasa/tests/fixtures/KS200M(US)_1.0_1.0.11.json rename to tests/fixtures/KS200M(US)_1.0_1.0.11.json diff --git a/kasa/tests/fixtures/KS200M(US)_1.0_1.0.12.json b/tests/fixtures/KS200M(US)_1.0_1.0.12.json similarity index 100% rename from kasa/tests/fixtures/KS200M(US)_1.0_1.0.12.json rename to tests/fixtures/KS200M(US)_1.0_1.0.12.json diff --git a/kasa/tests/fixtures/KS200M(US)_1.0_1.0.8.json b/tests/fixtures/KS200M(US)_1.0_1.0.8.json similarity index 100% rename from kasa/tests/fixtures/KS200M(US)_1.0_1.0.8.json rename to tests/fixtures/KS200M(US)_1.0_1.0.8.json diff --git a/kasa/tests/fixtures/KS220(US)_1.0_1.0.13.json b/tests/fixtures/KS220(US)_1.0_1.0.13.json similarity index 100% rename from kasa/tests/fixtures/KS220(US)_1.0_1.0.13.json rename to tests/fixtures/KS220(US)_1.0_1.0.13.json diff --git a/kasa/tests/fixtures/KS220M(US)_1.0_1.0.4.json b/tests/fixtures/KS220M(US)_1.0_1.0.4.json similarity index 100% rename from kasa/tests/fixtures/KS220M(US)_1.0_1.0.4.json rename to tests/fixtures/KS220M(US)_1.0_1.0.4.json diff --git a/kasa/tests/fixtures/KS230(US)_1.0_1.0.14.json b/tests/fixtures/KS230(US)_1.0_1.0.14.json similarity index 100% rename from kasa/tests/fixtures/KS230(US)_1.0_1.0.14.json rename to tests/fixtures/KS230(US)_1.0_1.0.14.json diff --git a/kasa/tests/fixtures/LB110(US)_1.0_1.8.11.json b/tests/fixtures/LB110(US)_1.0_1.8.11.json similarity index 100% rename from kasa/tests/fixtures/LB110(US)_1.0_1.8.11.json rename to tests/fixtures/LB110(US)_1.0_1.8.11.json diff --git a/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json b/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json similarity index 100% rename from kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json rename to tests/fixtures/smart/EP25(US)_2.6_1.0.1.json diff --git a/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json b/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json similarity index 100% rename from kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json rename to tests/fixtures/smart/EP25(US)_2.6_1.0.2.json diff --git a/kasa/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json b/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json similarity index 100% rename from kasa/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json rename to tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json diff --git a/kasa/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json b/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json similarity index 100% rename from kasa/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json rename to tests/fixtures/smart/H100(EU)_1.0_1.2.3.json diff --git a/kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json b/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json similarity index 100% rename from kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json rename to tests/fixtures/smart/H100(EU)_1.0_1.5.10.json diff --git a/kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json b/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json similarity index 100% rename from kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json rename to tests/fixtures/smart/H100(EU)_1.0_1.5.5.json diff --git a/kasa/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json b/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json similarity index 100% rename from kasa/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json rename to tests/fixtures/smart/HS220(US)_3.26_1.0.1.json diff --git a/kasa/tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json b/tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json similarity index 100% rename from kasa/tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json rename to tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json diff --git a/kasa/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json b/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json similarity index 100% rename from kasa/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json rename to tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json diff --git a/kasa/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json b/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json similarity index 100% rename from kasa/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json rename to tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json diff --git a/kasa/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json b/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json similarity index 100% rename from kasa/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json rename to tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json diff --git a/kasa/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json b/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json similarity index 100% rename from kasa/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json rename to tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json diff --git a/kasa/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json b/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json similarity index 100% rename from kasa/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json rename to tests/fixtures/smart/KS205(US)_1.0_1.0.2.json diff --git a/kasa/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json b/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json similarity index 100% rename from kasa/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json rename to tests/fixtures/smart/KS205(US)_1.0_1.1.0.json diff --git a/kasa/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json b/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json similarity index 100% rename from kasa/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json rename to tests/fixtures/smart/KS225(US)_1.0_1.0.2.json diff --git a/kasa/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json b/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json similarity index 100% rename from kasa/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json rename to tests/fixtures/smart/KS225(US)_1.0_1.1.0.json diff --git a/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json b/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json similarity index 100% rename from kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json rename to tests/fixtures/smart/KS240(US)_1.0_1.0.4.json diff --git a/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json b/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json similarity index 100% rename from kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json rename to tests/fixtures/smart/KS240(US)_1.0_1.0.5.json diff --git a/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json b/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json similarity index 100% rename from kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json rename to tests/fixtures/smart/KS240(US)_1.0_1.0.7.json diff --git a/kasa/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json b/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json similarity index 100% rename from kasa/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json rename to tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json diff --git a/kasa/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json b/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json similarity index 100% rename from kasa/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json rename to tests/fixtures/smart/L510E(US)_3.0_1.0.5.json diff --git a/kasa/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json b/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json similarity index 100% rename from kasa/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json rename to tests/fixtures/smart/L510E(US)_3.0_1.1.2.json diff --git a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json b/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json similarity index 100% rename from kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json rename to tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json diff --git a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json b/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json similarity index 100% rename from kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json rename to tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json diff --git a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json b/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json similarity index 100% rename from kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json rename to tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json diff --git a/kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json b/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json similarity index 100% rename from kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json rename to tests/fixtures/smart/L530E(US)_2.0_1.1.0.json diff --git a/kasa/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json b/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json similarity index 100% rename from kasa/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json rename to tests/fixtures/smart/L630(EU)_1.0_1.1.2.json diff --git a/kasa/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json b/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json similarity index 100% rename from kasa/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json rename to tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json diff --git a/kasa/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json b/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json similarity index 100% rename from kasa/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json rename to tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json diff --git a/kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json b/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json similarity index 100% rename from kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json rename to tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json diff --git a/kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json b/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json similarity index 100% rename from kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json rename to tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json diff --git a/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json b/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json similarity index 100% rename from kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json rename to tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json diff --git a/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json b/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json similarity index 100% rename from kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json rename to tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json diff --git a/kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json b/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json similarity index 100% rename from kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json rename to tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json diff --git a/kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json b/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json similarity index 100% rename from kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json rename to tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json diff --git a/kasa/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json b/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json similarity index 100% rename from kasa/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json rename to tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json diff --git a/kasa/tests/fixtures/smart/P100_1.0.0_1.1.3.json b/tests/fixtures/smart/P100_1.0.0_1.1.3.json similarity index 100% rename from kasa/tests/fixtures/smart/P100_1.0.0_1.1.3.json rename to tests/fixtures/smart/P100_1.0.0_1.1.3.json diff --git a/kasa/tests/fixtures/smart/P100_1.0.0_1.3.7.json b/tests/fixtures/smart/P100_1.0.0_1.3.7.json similarity index 100% rename from kasa/tests/fixtures/smart/P100_1.0.0_1.3.7.json rename to tests/fixtures/smart/P100_1.0.0_1.3.7.json diff --git a/kasa/tests/fixtures/smart/P100_1.0.0_1.4.0.json b/tests/fixtures/smart/P100_1.0.0_1.4.0.json similarity index 100% rename from kasa/tests/fixtures/smart/P100_1.0.0_1.4.0.json rename to tests/fixtures/smart/P100_1.0.0_1.4.0.json diff --git a/kasa/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json b/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json similarity index 100% rename from kasa/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json rename to tests/fixtures/smart/P110(EU)_1.0_1.0.7.json diff --git a/kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json b/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json similarity index 100% rename from kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json rename to tests/fixtures/smart/P110(EU)_1.0_1.2.3.json diff --git a/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json b/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json similarity index 100% rename from kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json rename to tests/fixtures/smart/P110(UK)_1.0_1.3.0.json diff --git a/kasa/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json b/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json similarity index 100% rename from kasa/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json rename to tests/fixtures/smart/P115(EU)_1.0_1.2.3.json diff --git a/kasa/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json b/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json similarity index 100% rename from kasa/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json rename to tests/fixtures/smart/P125M(US)_1.0_1.1.0.json diff --git a/kasa/tests/fixtures/smart/P135(US)_1.0_1.0.5.json b/tests/fixtures/smart/P135(US)_1.0_1.0.5.json similarity index 100% rename from kasa/tests/fixtures/smart/P135(US)_1.0_1.0.5.json rename to tests/fixtures/smart/P135(US)_1.0_1.0.5.json diff --git a/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json b/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json similarity index 100% rename from kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json rename to tests/fixtures/smart/P300(EU)_1.0_1.0.13.json diff --git a/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json b/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json similarity index 100% rename from kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json rename to tests/fixtures/smart/P300(EU)_1.0_1.0.15.json diff --git a/kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json b/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json similarity index 100% rename from kasa/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json rename to tests/fixtures/smart/P300(EU)_1.0_1.0.7.json diff --git a/kasa/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json b/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json similarity index 100% rename from kasa/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json rename to tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json diff --git a/kasa/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json b/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json similarity index 100% rename from kasa/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json rename to tests/fixtures/smart/S500D(US)_1.0_1.0.5.json diff --git a/kasa/tests/fixtures/smart/S505(US)_1.0_1.0.2.json b/tests/fixtures/smart/S505(US)_1.0_1.0.2.json similarity index 100% rename from kasa/tests/fixtures/smart/S505(US)_1.0_1.0.2.json rename to tests/fixtures/smart/S505(US)_1.0_1.0.2.json diff --git a/kasa/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json b/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json similarity index 100% rename from kasa/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json rename to tests/fixtures/smart/S505D(US)_1.0_1.1.0.json diff --git a/kasa/tests/fixtures/smart/TP15(US)_1.0_1.0.3.json b/tests/fixtures/smart/TP15(US)_1.0_1.0.3.json similarity index 100% rename from kasa/tests/fixtures/smart/TP15(US)_1.0_1.0.3.json rename to tests/fixtures/smart/TP15(US)_1.0_1.0.3.json diff --git a/kasa/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json b/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json similarity index 100% rename from kasa/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json rename to tests/fixtures/smart/TP25(US)_1.0_1.0.2.json diff --git a/kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json b/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json rename to tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json diff --git a/kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.8.0.json b/tests/fixtures/smart/child/KE100(EU)_1.0_2.8.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.8.0.json rename to tests/fixtures/smart/child/KE100(EU)_1.0_2.8.0.json diff --git a/kasa/tests/fixtures/smart/child/KE100(UK)_1.0_2.8.0.json b/tests/fixtures/smart/child/KE100(UK)_1.0_2.8.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/KE100(UK)_1.0_2.8.0.json rename to tests/fixtures/smart/child/KE100(UK)_1.0_2.8.0.json diff --git a/kasa/tests/fixtures/smart/child/S200B(EU)_1.0_1.11.0.json b/tests/fixtures/smart/child/S200B(EU)_1.0_1.11.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/S200B(EU)_1.0_1.11.0.json rename to tests/fixtures/smart/child/S200B(EU)_1.0_1.11.0.json diff --git a/kasa/tests/fixtures/smart/child/S200B(US)_1.0_1.12.0.json b/tests/fixtures/smart/child/S200B(US)_1.0_1.12.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/S200B(US)_1.0_1.12.0.json rename to tests/fixtures/smart/child/S200B(US)_1.0_1.12.0.json diff --git a/kasa/tests/fixtures/smart/child/S200D(EU)_1.0_1.11.0.json b/tests/fixtures/smart/child/S200D(EU)_1.0_1.11.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/S200D(EU)_1.0_1.11.0.json rename to tests/fixtures/smart/child/S200D(EU)_1.0_1.11.0.json diff --git a/kasa/tests/fixtures/smart/child/S200D(EU)_1.0_1.12.0.json b/tests/fixtures/smart/child/S200D(EU)_1.0_1.12.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/S200D(EU)_1.0_1.12.0.json rename to tests/fixtures/smart/child/S200D(EU)_1.0_1.12.0.json diff --git a/kasa/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json b/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json rename to tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json diff --git a/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json b/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json rename to tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json diff --git a/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.9.0.json b/tests/fixtures/smart/child/T110(EU)_1.0_1.9.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.9.0.json rename to tests/fixtures/smart/child/T110(EU)_1.0_1.9.0.json diff --git a/kasa/tests/fixtures/smart/child/T110(US)_1.0_1.9.0.json b/tests/fixtures/smart/child/T110(US)_1.0_1.9.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/T110(US)_1.0_1.9.0.json rename to tests/fixtures/smart/child/T110(US)_1.0_1.9.0.json diff --git a/kasa/tests/fixtures/smart/child/T300(EU)_1.0_1.7.0.json b/tests/fixtures/smart/child/T300(EU)_1.0_1.7.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/T300(EU)_1.0_1.7.0.json rename to tests/fixtures/smart/child/T300(EU)_1.0_1.7.0.json diff --git a/kasa/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json b/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json rename to tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json diff --git a/kasa/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json b/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json rename to tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json diff --git a/kasa/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json b/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json rename to tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json diff --git a/kasa/tests/fixtures/smart/child/T315(US)_1.0_1.8.0.json b/tests/fixtures/smart/child/T315(US)_1.0_1.8.0.json similarity index 100% rename from kasa/tests/fixtures/smart/child/T315(US)_1.0_1.8.0.json rename to tests/fixtures/smart/child/T315(US)_1.0_1.8.0.json diff --git a/kasa/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json b/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json similarity index 100% rename from kasa/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json rename to tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json diff --git a/kasa/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.3.json b/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.3.json similarity index 100% rename from kasa/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.3.json rename to tests/fixtures/smartcamera/C210(EU)_2.0_1.4.3.json diff --git a/kasa/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json b/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json similarity index 100% rename from kasa/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json rename to tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json diff --git a/kasa/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json b/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json similarity index 100% rename from kasa/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json rename to tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json diff --git a/kasa/tests/fixtures/smartcamera/TC65_1.0_1.3.9.json b/tests/fixtures/smartcamera/TC65_1.0_1.3.9.json similarity index 100% rename from kasa/tests/fixtures/smartcamera/TC65_1.0_1.3.9.json rename to tests/fixtures/smartcamera/TC65_1.0_1.3.9.json diff --git a/kasa/tests/iot/modules/__init__.py b/tests/iot/__init__.py similarity index 100% rename from kasa/tests/iot/modules/__init__.py rename to tests/iot/__init__.py diff --git a/kasa/tests/smart/__init__.py b/tests/iot/modules/__init__.py similarity index 100% rename from kasa/tests/smart/__init__.py rename to tests/iot/modules/__init__.py diff --git a/kasa/tests/iot/modules/test_ambientlight.py b/tests/iot/modules/test_ambientlight.py similarity index 96% rename from kasa/tests/iot/modules/test_ambientlight.py rename to tests/iot/modules/test_ambientlight.py index d7c584750..ff2bd92c2 100644 --- a/kasa/tests/iot/modules/test_ambientlight.py +++ b/tests/iot/modules/test_ambientlight.py @@ -3,7 +3,8 @@ from kasa import Module from kasa.iot import IotDimmer from kasa.iot.modules.ambientlight import AmbientLight -from kasa.tests.device_fixtures import dimmer_iot + +from ...device_fixtures import dimmer_iot @dimmer_iot diff --git a/kasa/tests/iot/modules/test_motion.py b/tests/iot/modules/test_motion.py similarity index 97% rename from kasa/tests/iot/modules/test_motion.py rename to tests/iot/modules/test_motion.py index 932361641..a2b32a877 100644 --- a/kasa/tests/iot/modules/test_motion.py +++ b/tests/iot/modules/test_motion.py @@ -3,7 +3,8 @@ from kasa import Module from kasa.iot import IotDimmer from kasa.iot.modules.motion import Motion, Range -from kasa.tests.device_fixtures import dimmer_iot + +from ...device_fixtures import dimmer_iot @dimmer_iot diff --git a/kasa/tests/iot/test_wallswitch.py b/tests/iot/test_wallswitch.py similarity index 84% rename from kasa/tests/iot/test_wallswitch.py rename to tests/iot/test_wallswitch.py index 07f5976d9..b6fd2a673 100644 --- a/kasa/tests/iot/test_wallswitch.py +++ b/tests/iot/test_wallswitch.py @@ -1,4 +1,4 @@ -from kasa.tests.device_fixtures import wallswitch_iot +from ..device_fixtures import wallswitch_iot @wallswitch_iot diff --git a/kasa/tests/smart/features/__init__.py b/tests/smart/__init__.py similarity index 100% rename from kasa/tests/smart/features/__init__.py rename to tests/smart/__init__.py diff --git a/kasa/tests/smart/modules/__init__.py b/tests/smart/features/__init__.py similarity index 100% rename from kasa/tests/smart/modules/__init__.py rename to tests/smart/features/__init__.py diff --git a/kasa/tests/smart/features/test_brightness.py b/tests/smart/features/test_brightness.py similarity index 95% rename from kasa/tests/smart/features/test_brightness.py rename to tests/smart/features/test_brightness.py index 4a2569c72..ff38854a8 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/tests/smart/features/test_brightness.py @@ -2,7 +2,8 @@ from kasa.iot import IotDevice from kasa.smart import SmartDevice -from kasa.tests.conftest import dimmable_iot, get_parent_and_child_modules, parametrize + +from ...conftest import dimmable_iot, get_parent_and_child_modules, parametrize brightness = parametrize("brightness smart", component_filter="brightness") diff --git a/kasa/tests/smart/features/test_colortemp.py b/tests/smart/features/test_colortemp.py similarity index 94% rename from kasa/tests/smart/features/test_colortemp.py rename to tests/smart/features/test_colortemp.py index f4b3c0f51..055c5b299 100644 --- a/kasa/tests/smart/features/test_colortemp.py +++ b/tests/smart/features/test_colortemp.py @@ -1,7 +1,8 @@ import pytest from kasa.smart import SmartDevice -from kasa.tests.conftest import variable_temp_smart + +from ...conftest import variable_temp_smart @variable_temp_smart diff --git a/kasa/tests/smartcamera/__init__.py b/tests/smart/modules/__init__.py similarity index 100% rename from kasa/tests/smartcamera/__init__.py rename to tests/smart/modules/__init__.py diff --git a/kasa/tests/smart/modules/test_autooff.py b/tests/smart/modules/test_autooff.py similarity index 97% rename from kasa/tests/smart/modules/test_autooff.py rename to tests/smart/modules/test_autooff.py index c8582ec54..412bd68c2 100644 --- a/kasa/tests/smart/modules/test_autooff.py +++ b/tests/smart/modules/test_autooff.py @@ -9,7 +9,8 @@ from kasa import Module from kasa.smart import SmartDevice -from kasa.tests.device_fixtures import get_parent_and_child_modules, parametrize + +from ...device_fixtures import get_parent_and_child_modules, parametrize autooff = parametrize( "has autooff", component_filter="auto_off", protocol_filter={"SMART"} diff --git a/kasa/tests/smart/modules/test_childprotection.py b/tests/smart/modules/test_childprotection.py similarity index 95% rename from kasa/tests/smart/modules/test_childprotection.py rename to tests/smart/modules/test_childprotection.py index c8fce03ec..ad2878e57 100644 --- a/kasa/tests/smart/modules/test_childprotection.py +++ b/tests/smart/modules/test_childprotection.py @@ -2,7 +2,8 @@ from kasa import Module from kasa.smart.modules import ChildProtection -from kasa.tests.device_fixtures import parametrize + +from ...device_fixtures import parametrize child_protection = parametrize( "has child protection", diff --git a/kasa/tests/smart/modules/test_contact.py b/tests/smart/modules/test_contact.py similarity index 92% rename from kasa/tests/smart/modules/test_contact.py rename to tests/smart/modules/test_contact.py index 732952a4e..56287e2a3 100644 --- a/kasa/tests/smart/modules/test_contact.py +++ b/tests/smart/modules/test_contact.py @@ -1,7 +1,8 @@ import pytest from kasa import Module, SmartDevice -from kasa.tests.device_fixtures import parametrize + +from ...device_fixtures import parametrize contact = parametrize( "is contact sensor", model_filter="T110", protocol_filter={"SMART.CHILD"} diff --git a/kasa/tests/smart/modules/test_fan.py b/tests/smart/modules/test_fan.py similarity index 96% rename from kasa/tests/smart/modules/test_fan.py rename to tests/smart/modules/test_fan.py index 3781ccd9f..a032794cb 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/tests/smart/modules/test_fan.py @@ -3,7 +3,8 @@ from kasa import Module from kasa.smart import SmartDevice -from kasa.tests.device_fixtures import get_parent_and_child_modules, parametrize + +from ...device_fixtures import get_parent_and_child_modules, parametrize fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"SMART"}) diff --git a/kasa/tests/smart/modules/test_firmware.py b/tests/smart/modules/test_firmware.py similarity index 98% rename from kasa/tests/smart/modules/test_firmware.py rename to tests/smart/modules/test_firmware.py index 013533d0e..c1961b415 100644 --- a/kasa/tests/smart/modules/test_firmware.py +++ b/tests/smart/modules/test_firmware.py @@ -11,7 +11,8 @@ from kasa import KasaException, Module from kasa.smart import SmartDevice from kasa.smart.modules.firmware import DownloadState -from kasa.tests.device_fixtures import parametrize + +from ...device_fixtures import parametrize firmware = parametrize( "has firmware", component_filter="firmware", protocol_filter={"SMART"} diff --git a/kasa/tests/smart/modules/test_humidity.py b/tests/smart/modules/test_humidity.py similarity index 92% rename from kasa/tests/smart/modules/test_humidity.py rename to tests/smart/modules/test_humidity.py index 52760b230..5e14a05b4 100644 --- a/kasa/tests/smart/modules/test_humidity.py +++ b/tests/smart/modules/test_humidity.py @@ -1,7 +1,8 @@ import pytest from kasa.smart.modules import HumiditySensor -from kasa.tests.device_fixtures import parametrize + +from ...device_fixtures import parametrize humidity = parametrize( "has humidity", component_filter="humidity", protocol_filter={"SMART.CHILD"} diff --git a/kasa/tests/smart/modules/test_light_effect.py b/tests/smart/modules/test_light_effect.py similarity index 98% rename from kasa/tests/smart/modules/test_light_effect.py rename to tests/smart/modules/test_light_effect.py index 27869bf25..a48b29add 100644 --- a/kasa/tests/smart/modules/test_light_effect.py +++ b/tests/smart/modules/test_light_effect.py @@ -7,7 +7,8 @@ from kasa import Device, Feature, Module from kasa.smart.modules import LightEffect -from kasa.tests.device_fixtures import parametrize + +from ...device_fixtures import parametrize light_effect = parametrize( "has light effect", component_filter="light_effect", protocol_filter={"SMART"} diff --git a/kasa/tests/smart/modules/test_light_strip_effect.py b/tests/smart/modules/test_light_strip_effect.py similarity index 98% rename from kasa/tests/smart/modules/test_light_strip_effect.py rename to tests/smart/modules/test_light_strip_effect.py index f18bf9faf..a3db847e3 100644 --- a/kasa/tests/smart/modules/test_light_strip_effect.py +++ b/tests/smart/modules/test_light_strip_effect.py @@ -7,7 +7,8 @@ from kasa import Device, Feature, Module from kasa.smart.modules import LightEffect, LightStripEffect -from kasa.tests.device_fixtures import parametrize + +from ...device_fixtures import parametrize light_strip_effect = parametrize( "has light strip effect", diff --git a/kasa/tests/smart/modules/test_lighttransition.py b/tests/smart/modules/test_lighttransition.py similarity index 95% rename from kasa/tests/smart/modules/test_lighttransition.py rename to tests/smart/modules/test_lighttransition.py index beee68b37..c1b805e48 100644 --- a/kasa/tests/smart/modules/test_lighttransition.py +++ b/tests/smart/modules/test_lighttransition.py @@ -2,8 +2,9 @@ from kasa import Feature, Module from kasa.smart import SmartDevice -from kasa.tests.device_fixtures import get_parent_and_child_modules, parametrize -from kasa.tests.fixtureinfo import ComponentFilter + +from ...device_fixtures import get_parent_and_child_modules, parametrize +from ...fixtureinfo import ComponentFilter light_transition_v1 = parametrize( "has light transition", diff --git a/kasa/tests/smart/modules/test_motionsensor.py b/tests/smart/modules/test_motionsensor.py similarity index 92% rename from kasa/tests/smart/modules/test_motionsensor.py rename to tests/smart/modules/test_motionsensor.py index 06033ea76..91119a759 100644 --- a/kasa/tests/smart/modules/test_motionsensor.py +++ b/tests/smart/modules/test_motionsensor.py @@ -1,7 +1,8 @@ import pytest from kasa import Module, SmartDevice -from kasa.tests.device_fixtures import parametrize + +from ...device_fixtures import parametrize motion = parametrize( "is motion sensor", model_filter="T100", protocol_filter={"SMART.CHILD"} diff --git a/kasa/tests/smart/modules/test_temperature.py b/tests/smart/modules/test_temperature.py similarity index 96% rename from kasa/tests/smart/modules/test_temperature.py rename to tests/smart/modules/test_temperature.py index 3354002db..c2f91ae1d 100644 --- a/kasa/tests/smart/modules/test_temperature.py +++ b/tests/smart/modules/test_temperature.py @@ -1,7 +1,8 @@ import pytest from kasa.smart.modules import TemperatureSensor -from kasa.tests.device_fixtures import parametrize + +from ...device_fixtures import parametrize temperature = parametrize( "has temperature", component_filter="temperature", protocol_filter={"SMART.CHILD"} diff --git a/kasa/tests/smart/modules/test_temperaturecontrol.py b/tests/smart/modules/test_temperaturecontrol.py similarity index 98% rename from kasa/tests/smart/modules/test_temperaturecontrol.py rename to tests/smart/modules/test_temperaturecontrol.py index f186b63f7..2653c53e1 100644 --- a/kasa/tests/smart/modules/test_temperaturecontrol.py +++ b/tests/smart/modules/test_temperaturecontrol.py @@ -5,7 +5,8 @@ from kasa.smart.modules import TemperatureControl from kasa.smart.modules.temperaturecontrol import ThermostatState -from kasa.tests.device_fixtures import parametrize, thermostats_smart + +from ...device_fixtures import parametrize, thermostats_smart temperature = parametrize( "has temperature control", diff --git a/kasa/tests/smart/modules/test_waterleak.py b/tests/smart/modules/test_waterleak.py similarity index 96% rename from kasa/tests/smart/modules/test_waterleak.py rename to tests/smart/modules/test_waterleak.py index 8704ae81f..318973922 100644 --- a/kasa/tests/smart/modules/test_waterleak.py +++ b/tests/smart/modules/test_waterleak.py @@ -4,7 +4,8 @@ import pytest from kasa.smart.modules import WaterleakSensor -from kasa.tests.device_fixtures import parametrize + +from ...device_fixtures import parametrize waterleak = parametrize( "has waterleak", component_filter="sensor_alarm", protocol_filter={"SMART.CHILD"} diff --git a/tests/smartcamera/__init__.py b/tests/smartcamera/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/kasa/tests/smartcamera/test_smartcamera.py b/tests/smartcamera/test_smartcamera.py similarity index 100% rename from kasa/tests/smartcamera/test_smartcamera.py rename to tests/smartcamera/test_smartcamera.py diff --git a/kasa/tests/test_aestransport.py b/tests/test_aestransport.py similarity index 98% rename from kasa/tests/test_aestransport.py rename to tests/test_aestransport.py index f1dbfb320..1bc6d8dd2 100644 --- a/kasa/tests/test_aestransport.py +++ b/tests/test_aestransport.py @@ -18,16 +18,16 @@ from freezegun.api import FrozenDateTimeFactory from yarl import URL -from ..aestransport import AesEncyptionSession, AesTransport, TransportState -from ..credentials import Credentials -from ..deviceconfig import DeviceConfig -from ..exceptions import ( +from kasa.aestransport import AesEncyptionSession, AesTransport, TransportState +from kasa.credentials import Credentials +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import ( AuthenticationError, KasaException, SmartErrorCode, _ConnectionError, ) -from ..httpclient import HttpClient +from kasa.httpclient import HttpClient DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} diff --git a/kasa/tests/test_bulb.py b/tests/test_bulb.py similarity index 100% rename from kasa/tests/test_bulb.py rename to tests/test_bulb.py diff --git a/kasa/tests/test_childdevice.py b/tests/test_childdevice.py similarity index 100% rename from kasa/tests/test_childdevice.py rename to tests/test_childdevice.py diff --git a/kasa/tests/test_cli.py b/tests/test_cli.py similarity index 100% rename from kasa/tests/test_cli.py rename to tests/test_cli.py diff --git a/kasa/tests/test_common_modules.py b/tests/test_common_modules.py similarity index 99% rename from kasa/tests/test_common_modules.py rename to tests/test_common_modules.py index 1096260e7..5e2622b9b 100644 --- a/kasa/tests/test_common_modules.py +++ b/tests/test_common_modules.py @@ -6,7 +6,8 @@ from zoneinfo import ZoneInfo from kasa import Device, LightState, Module -from kasa.tests.device_fixtures import ( + +from .device_fixtures import ( bulb_iot, bulb_smart, dimmable_iot, diff --git a/kasa/tests/test_device.py b/tests/test_device.py similarity index 100% rename from kasa/tests/test_device.py rename to tests/test_device.py diff --git a/kasa/tests/test_device_factory.py b/tests/test_device_factory.py similarity index 100% rename from kasa/tests/test_device_factory.py rename to tests/test_device_factory.py diff --git a/kasa/tests/test_device_type.py b/tests/test_device_type.py similarity index 100% rename from kasa/tests/test_device_type.py rename to tests/test_device_type.py diff --git a/kasa/tests/test_deviceconfig.py b/tests/test_deviceconfig.py similarity index 100% rename from kasa/tests/test_deviceconfig.py rename to tests/test_deviceconfig.py diff --git a/kasa/tests/test_dimmer.py b/tests/test_dimmer.py similarity index 100% rename from kasa/tests/test_dimmer.py rename to tests/test_dimmer.py diff --git a/kasa/tests/test_discovery.py b/tests/test_discovery.py similarity index 100% rename from kasa/tests/test_discovery.py rename to tests/test_discovery.py diff --git a/kasa/tests/test_emeter.py b/tests/test_emeter.py similarity index 100% rename from kasa/tests/test_emeter.py rename to tests/test_emeter.py diff --git a/kasa/tests/test_feature.py b/tests/test_feature.py similarity index 100% rename from kasa/tests/test_feature.py rename to tests/test_feature.py diff --git a/kasa/tests/test_httpclient.py b/tests/test_httpclient.py similarity index 95% rename from kasa/tests/test_httpclient.py rename to tests/test_httpclient.py index 6200d0fdb..ed7ce5383 100644 --- a/kasa/tests/test_httpclient.py +++ b/tests/test_httpclient.py @@ -4,13 +4,13 @@ import aiohttp import pytest -from ..deviceconfig import DeviceConfig -from ..exceptions import ( +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import ( KasaException, TimeoutError, _ConnectionError, ) -from ..httpclient import HttpClient +from kasa.httpclient import HttpClient @pytest.mark.parametrize( diff --git a/kasa/tests/test_iotdevice.py b/tests/test_iotdevice.py similarity index 100% rename from kasa/tests/test_iotdevice.py rename to tests/test_iotdevice.py diff --git a/kasa/tests/test_klapprotocol.py b/tests/test_klapprotocol.py similarity index 98% rename from kasa/tests/test_klapprotocol.py rename to tests/test_klapprotocol.py index ce370b5b6..55df0b34e 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/tests/test_klapprotocol.py @@ -9,26 +9,26 @@ import pytest from yarl import URL -from ..aestransport import AesTransport -from ..credentials import Credentials -from ..deviceconfig import DeviceConfig -from ..exceptions import ( +from kasa.aestransport import AesTransport +from kasa.credentials import Credentials +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import ( AuthenticationError, KasaException, TimeoutError, _ConnectionError, _RetryableError, ) -from ..httpclient import HttpClient -from ..iotprotocol import IotProtocol -from ..klaptransport import ( +from kasa.httpclient import HttpClient +from kasa.iotprotocol import IotProtocol +from kasa.klaptransport import ( KlapEncryptionSession, KlapTransport, KlapTransportV2, _sha256, ) -from ..protocol import DEFAULT_CREDENTIALS, get_default_credentials -from ..smartprotocol import SmartProtocol +from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials +from kasa.smartprotocol import SmartProtocol DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} diff --git a/kasa/tests/test_lightstrip.py b/tests/test_lightstrip.py similarity index 100% rename from kasa/tests/test_lightstrip.py rename to tests/test_lightstrip.py diff --git a/kasa/tests/test_plug.py b/tests/test_plug.py similarity index 100% rename from kasa/tests/test_plug.py rename to tests/test_plug.py diff --git a/kasa/tests/test_protocol.py b/tests/test_protocol.py similarity index 98% rename from kasa/tests/test_protocol.py rename to tests/test_protocol.py index 9c15795f1..6c79885db 100644 --- a/kasa/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -13,22 +13,22 @@ import pytest +from kasa.aestransport import AesTransport +from kasa.credentials import Credentials +from kasa.device import Device +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import KasaException from kasa.iot import IotDevice - -from ..aestransport import AesTransport -from ..credentials import Credentials -from ..device import Device -from ..deviceconfig import DeviceConfig -from ..exceptions import KasaException -from ..iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol -from ..klaptransport import KlapTransport, KlapTransportV2 -from ..protocol import ( +from kasa.iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol +from kasa.klaptransport import KlapTransport, KlapTransportV2 +from kasa.protocol import ( BaseProtocol, BaseTransport, mask_mac, redact_data, ) -from ..xortransport import XorEncryption, XorTransport +from kasa.xortransport import XorEncryption, XorTransport + from .conftest import device_iot from .fakeprotocol_iot import FakeIotTransport diff --git a/kasa/tests/test_readme_examples.py b/tests/test_readme_examples.py similarity index 99% rename from kasa/tests/test_readme_examples.py rename to tests/test_readme_examples.py index cbaff9c55..f8f433d47 100644 --- a/kasa/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -3,7 +3,7 @@ import pytest import xdoctest -from kasa.tests.conftest import ( +from .conftest import ( get_device_for_fixture_protocol, get_fixture_info, patch_discovery, diff --git a/kasa/tests/test_smartdevice.py b/tests/test_smartdevice.py similarity index 100% rename from kasa/tests/test_smartdevice.py rename to tests/test_smartdevice.py diff --git a/kasa/tests/test_smartprotocol.py b/tests/test_smartprotocol.py similarity index 99% rename from kasa/tests/test_smartprotocol.py rename to tests/test_smartprotocol.py index 420c10fc3..19e62a3dd 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/tests/test_smartprotocol.py @@ -4,15 +4,15 @@ import pytest import pytest_mock -from kasa.smart import SmartDevice - -from ..exceptions import ( +from kasa.exceptions import ( SMART_RETRYABLE_ERRORS, DeviceError, KasaException, SmartErrorCode, ) -from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper +from kasa.smart import SmartDevice +from kasa.smartprotocol import SmartProtocol, _ChildProtocolWrapper + from .conftest import device_smart from .fakeprotocol_smart import FakeSmartTransport diff --git a/kasa/tests/test_sslaestransport.py b/tests/test_sslaestransport.py similarity index 97% rename from kasa/tests/test_sslaestransport.py rename to tests/test_sslaestransport.py index bea10528b..30c74234c 100644 --- a/kasa/tests/test_sslaestransport.py +++ b/tests/test_sslaestransport.py @@ -11,18 +11,21 @@ import pytest from yarl import URL -from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials - -from ..aestransport import AesEncyptionSession -from ..credentials import Credentials -from ..deviceconfig import DeviceConfig -from ..exceptions import ( +from kasa.aestransport import AesEncyptionSession +from kasa.credentials import Credentials +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import ( AuthenticationError, KasaException, SmartErrorCode, ) -from ..experimental.sslaestransport import SslAesTransport, TransportState, _sha256_hash -from ..httpclient import HttpClient +from kasa.experimental.sslaestransport import ( + SslAesTransport, + TransportState, + _sha256_hash, +) +from kasa.httpclient import HttpClient +from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials MOCK_ADMIN_USER = get_default_credentials(DEFAULT_CREDENTIALS["TAPOCAMERA"]).username MOCK_PWD = "correct_pwd" # noqa: S105 diff --git a/kasa/tests/test_strip.py b/tests/test_strip.py similarity index 100% rename from kasa/tests/test_strip.py rename to tests/test_strip.py diff --git a/kasa/tests/test_usage.py b/tests/test_usage.py similarity index 100% rename from kasa/tests/test_usage.py rename to tests/test_usage.py From 71ae06fa83e82a77c0da42a65cce6ded20cbf1b6 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:41:31 +0000 Subject: [PATCH 663/892] Fix test framework running against real devices (#1235) --- kasa/device.py | 5 +++ kasa/iot/iotdevice.py | 6 +++ kasa/smart/smartdevice.py | 11 ++++++ tests/device_fixtures.py | 58 +++++++++++++++++++++++----- tests/fixtureinfo.py | 20 +++++++--- tests/smart/modules/test_firmware.py | 1 + tests/test_aestransport.py | 2 + tests/test_bulb.py | 2 +- tests/test_childdevice.py | 7 +++- tests/test_cli.py | 4 ++ tests/test_common_modules.py | 49 +++++++++++++++-------- tests/test_device_factory.py | 4 ++ tests/test_discovery.py | 3 ++ tests/test_klapprotocol.py | 3 ++ tests/test_protocol.py | 11 ++++-- tests/test_smartdevice.py | 2 + tests/test_smartprotocol.py | 10 ++--- tests/test_sslaestransport.py | 3 ++ 18 files changed, 158 insertions(+), 43 deletions(-) diff --git a/kasa/device.py b/kasa/device.py index 72c567175..ca16bb6bf 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -320,6 +320,11 @@ def config(self) -> DeviceConfig: def model(self) -> str: """Returns the device model.""" + @property + @abstractmethod + def _model_region(self) -> str: + """Return device full model name and region.""" + @property @abstractmethod def alias(self) -> str | None: diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 4ee403dba..20284c1d8 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -455,6 +455,12 @@ def model(self) -> str: sys_info = self._sys_info return str(sys_info["model"]) + @property + @requires_update + def _model_region(self) -> str: + """Return device full model name and region.""" + return self.model + @property # type: ignore def alias(self) -> str | None: """Return device name (alias).""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 35524ee8c..e497b8e8c 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -492,6 +492,17 @@ def model(self) -> str: """Returns the device model.""" return str(self._info.get("model")) + @property + def _model_region(self) -> str: + """Return device full model name and region.""" + if (disco := self._discovery_info) and ( + disco_model := disco.get("device_model") + ): + return disco_model + # Some devices have the region in the specs element. + region = f"({specs})" if (specs := self._info.get("specs")) else "" + return f"{self.model}{region}" + @property def alias(self) -> str | None: """Returns the device alias or nickname.""" diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 1726ee8cb..4d335d5c5 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from collections.abc import AsyncGenerator import pytest @@ -142,7 +143,7 @@ ) ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) -IP_MODEL_CACHE: dict[str, str] = {} +IP_FIXTURE_CACHE: dict[str, FixtureInfo] = {} def parametrize_combine(parametrized: list[pytest.MarkDecorator]): @@ -448,6 +449,39 @@ def get_fixture_info(fixture, protocol): return fixture_info +def get_nearest_fixture_to_ip(dev): + if isinstance(dev, SmartDevice): + protocol_fixtures = filter_fixtures("", protocol_filter={"SMART"}) + elif isinstance(dev, SmartCamera): + protocol_fixtures = filter_fixtures("", protocol_filter={"SMARTCAMERA"}) + else: + protocol_fixtures = filter_fixtures("", protocol_filter={"IOT"}) + assert protocol_fixtures, "Unknown device type" + + # This will get the best fixture with a match on model region + if model_region_fixtures := filter_fixtures( + "", model_filter={dev._model_region}, fixture_list=protocol_fixtures + ): + return next(iter(model_region_fixtures)) + + # This will get the best fixture based on model starting with the name. + if "(" in dev.model: + model, _, _ = dev.model.partition("(") + else: + model = dev.model + if model_fixtures := filter_fixtures( + "", model_startswith_filter=model, fixture_list=protocol_fixtures + ): + return next(iter(model_fixtures)) + + if device_type_fixtures := filter_fixtures( + "", device_type_filter={dev.device_type}, fixture_list=protocol_fixtures + ): + return next(iter(device_type_fixtures)) + + return next(iter(protocol_fixtures)) + + @pytest.fixture(params=filter_fixtures("main devices"), ids=idgenerator) async def dev(request) -> AsyncGenerator[Device, None]: """Device fixture. @@ -459,24 +493,28 @@ async def dev(request) -> AsyncGenerator[Device, None]: dev: Device ip = request.config.getoption("--ip") - username = request.config.getoption("--username") - password = request.config.getoption("--password") + username = request.config.getoption("--username") or os.environ.get("KASA_USERNAME") + password = request.config.getoption("--password") or os.environ.get("KASA_PASSWORD") if ip: - model = IP_MODEL_CACHE.get(ip) + fixture = IP_FIXTURE_CACHE.get(ip) + d = None - if not model: + if not fixture: d = await _discover_update_and_close(ip, username, password) - IP_MODEL_CACHE[ip] = model = d.model - - if model not in fixture_data.name: + IP_FIXTURE_CACHE[ip] = fixture = get_nearest_fixture_to_ip(d) + assert fixture + if fixture.name != fixture_data.name: pytest.skip(f"skipping file {fixture_data.name}") - dev = d if d else await _discover_update_and_close(ip, username, password) + dev = None + else: + dev = d if d else await _discover_update_and_close(ip, username, password) else: dev = await get_device_for_fixture(fixture_data) yield dev - await dev.disconnect() + if dev: + await dev.disconnect() def get_parent_and_child_modules(device: Device, module_name): diff --git a/tests/fixtureinfo.py b/tests/fixtureinfo.py index 9f4d39529..cb75b4232 100644 --- a/tests/fixtureinfo.py +++ b/tests/fixtureinfo.py @@ -104,8 +104,10 @@ def filter_fixtures( data_root_filter: str | None = None, protocol_filter: set[str] | None = None, model_filter: set[str] | None = None, + model_startswith_filter: str | None = None, component_filter: str | ComponentFilter | None = None, device_type_filter: Iterable[DeviceType] | None = None, + fixture_list: list[FixtureInfo] = FIXTURE_DATA, ): """Filter the fixtures based on supplied parameters. @@ -127,12 +129,15 @@ def _model_match(fixture_data: FixtureInfo, model_filter: set[str]): and (model := model_filter_list[0]) and len(model.split("_")) == 3 ): - # return exact match + # filter string includes hw and fw, return exact match return fixture_data.name == f"{model}.json" file_model_region = fixture_data.name.split("_")[0] file_model = file_model_region.split("(")[0] return file_model in model_filter + def _model_startswith_match(fixture_data: FixtureInfo, starts_with: str): + return fixture_data.name.startswith(starts_with) + def _component_match( fixture_data: FixtureInfo, component_filter: str | ComponentFilter ): @@ -175,13 +180,17 @@ def _device_type_match(fixture_data: FixtureInfo, device_type): filtered = [] if protocol_filter is None: protocol_filter = {"IOT", "SMART"} - for fixture_data in FIXTURE_DATA: + for fixture_data in fixture_list: if data_root_filter and data_root_filter not in fixture_data.data: continue if fixture_data.protocol not in protocol_filter: continue if model_filter is not None and not _model_match(fixture_data, model_filter): continue + if model_startswith_filter is not None and not _model_startswith_match( + fixture_data, model_startswith_filter + ): + continue if component_filter and not _component_match(fixture_data, component_filter): continue if device_type_filter and not _device_type_match( @@ -191,8 +200,9 @@ def _device_type_match(fixture_data: FixtureInfo, device_type): filtered.append(fixture_data) - print(f"# {desc}") - for value in filtered: - print(f"\t{value.name}") + if desc: + print(f"# {desc}") + for value in filtered: + print(f"\t{value.name}") filtered.sort() return filtered diff --git a/tests/smart/modules/test_firmware.py b/tests/smart/modules/test_firmware.py index c1961b415..8f6fe6ebf 100644 --- a/tests/smart/modules/test_firmware.py +++ b/tests/smart/modules/test_firmware.py @@ -74,6 +74,7 @@ async def test_update_available_without_cloud(dev: SmartDevice): pytest.param(False, pytest.raises(KasaException), id="not-available"), ], ) +@pytest.mark.requires_dummy() async def test_firmware_update( dev: SmartDevice, mocker: MockerFixture, diff --git a/tests/test_aestransport.py b/tests/test_aestransport.py index 1bc6d8dd2..302f195a2 100644 --- a/tests/test_aestransport.py +++ b/tests/test_aestransport.py @@ -29,6 +29,8 @@ ) from kasa.httpclient import HttpClient +pytestmark = [pytest.mark.requires_dummy] + DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} key = b"8\x89\x02\xfa\xf5Xs\x1c\xa1 H\x9a\x82\xc7\xd9\t" diff --git a/tests/test_bulb.py b/tests/test_bulb.py index 9e6dd7c2f..64c012fd7 100644 --- a/tests/test_bulb.py +++ b/tests/test_bulb.py @@ -32,7 +32,7 @@ from .test_iotdevice import SYSINFO_SCHEMA -@bulb +@bulb_iot async def test_bulb_sysinfo(dev: Device): assert dev.sys_info is not None SYSINFO_SCHEMA_BULB(dev.sys_info) diff --git a/tests/test_childdevice.py b/tests/test_childdevice.py index 797e8dff5..05743abb5 100644 --- a/tests/test_childdevice.py +++ b/tests/test_childdevice.py @@ -125,8 +125,13 @@ async def test_parent_property(dev: Device): @has_children_smart +@pytest.mark.requires_dummy() async def test_child_time(dev: Device, freezer: FrozenDateTimeFactory): - """Test a child device gets the time from it's parent module.""" + """Test a child device gets the time from it's parent module. + + This is excluded from real device testing as the test often fail if the + device time is not in the past. + """ if not dev.children: pytest.skip(f"Device {dev} fixture does not have any children") diff --git a/tests/test_cli.py b/tests/test_cli.py index 7a0b0ddee..d22bb1129 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -51,6 +51,10 @@ turn_on, ) +# The cli tests should be testing the cli logic rather than a physical device +# so mark the whole file for skipping with real devices. +pytestmark = [pytest.mark.requires_dummy] + @pytest.fixture() def runner(): diff --git a/tests/test_common_modules.py b/tests/test_common_modules.py index 5e2622b9b..168b4090f 100644 --- a/tests/test_common_modules.py +++ b/tests/test_common_modules.py @@ -1,7 +1,6 @@ from datetime import datetime import pytest -from freezegun.api import FrozenDateTimeFactory from pytest_mock import MockerFixture from zoneinfo import ZoneInfo @@ -326,22 +325,38 @@ async def test_light_preset_save(dev: Device, mocker: MockerFixture): assert new_preset_state.color_temp == new_preset.color_temp -async def test_set_time(dev: Device, freezer: FrozenDateTimeFactory): +async def test_set_time(dev: Device): """Test setting the device time.""" - freezer.move_to("2021-01-09 12:00:00+00:00") time_mod = dev.modules[Module.Time] - tz_info = time_mod.timezone - now = datetime.now(tz=tz_info) - now = now.replace(microsecond=0) - assert time_mod.time != now - await time_mod.set_time(now) - await dev.update() - assert time_mod.time == now - - zone = ZoneInfo("Europe/Berlin") - now = datetime.now(tz=zone) - now = now.replace(microsecond=0) - await time_mod.set_time(now) - await dev.update() - assert time_mod.time == now + original_time = time_mod.time + original_timezone = time_mod.timezone + + test_time = datetime.fromisoformat("2021-01-09 12:00:00+00:00") + test_time = test_time.astimezone(original_timezone) + + try: + assert time_mod.time != test_time + + await time_mod.set_time(test_time) + await dev.update() + assert time_mod.time == test_time + + if ( + isinstance(original_timezone, ZoneInfo) + and original_timezone.key != "Europe/Berlin" + ): + test_zonezone = ZoneInfo("Europe/Berlin") + else: + test_zonezone = ZoneInfo("Europe/London") + + # Just update the timezone + new_time = time_mod.time.astimezone(test_zonezone) + await time_mod.set_time(new_time) + await dev.update() + assert time_mod.time == new_time + finally: + # Reset back to the original + await time_mod.set_time(original_time) + await dev.update() + assert time_mod.time == original_time diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 35031cd0e..8690e5802 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -35,6 +35,10 @@ from .conftest import DISCOVERY_MOCK_IP +# Device Factory tests are not relevant for real devices which run against +# a single device that has already been created via the factory. +pytestmark = [pytest.mark.requires_dummy] + def _get_connection_type_device_class(discovery_info): if "result" in discovery_info: diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 0318de35c..7f69977ed 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -53,6 +53,9 @@ wallswitch_iot, ) +# A physical device has to respond to discovery for the tests to work. +pytestmark = [pytest.mark.requires_dummy] + UNSUPPORTED = { "result": { "device_id": "xx", diff --git a/tests/test_klapprotocol.py b/tests/test_klapprotocol.py index 55df0b34e..524a6be3e 100644 --- a/tests/test_klapprotocol.py +++ b/tests/test_klapprotocol.py @@ -32,6 +32,9 @@ DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} +# Transport tests are not designed for real devices +pytestmark = [pytest.mark.requires_dummy] + class _mock_response: def __init__(self, status, content: bytes): diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 6c79885db..7638a4bfa 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -687,10 +687,13 @@ def test_deprecated_protocol(): @device_iot async def test_iot_queries_redaction(dev: IotDevice, caplog: pytest.LogCaptureFixture): """Test query sensitive info redaction.""" - device_id = "123456789ABCDEF" - cast(FakeIotTransport, dev.protocol._transport).proto["system"]["get_sysinfo"][ - "deviceId" - ] = device_id + if isinstance(dev.protocol._transport, FakeIotTransport): + device_id = "123456789ABCDEF" + cast(FakeIotTransport, dev.protocol._transport).proto["system"]["get_sysinfo"][ + "deviceId" + ] = device_id + else: # real device with --ip + device_id = dev.sys_info["deviceId"] # Info no message logging caplog.set_level(logging.INFO) diff --git a/tests/test_smartdevice.py b/tests/test_smartdevice.py index d96542e5e..616db77e7 100644 --- a/tests/test_smartdevice.py +++ b/tests/test_smartdevice.py @@ -26,6 +26,7 @@ @device_smart +@pytest.mark.requires_dummy() async def test_try_get_response(dev: SmartDevice, caplog): mock_response: dict = { "get_device_info": SmartErrorCode.PARAMS_ERROR, @@ -37,6 +38,7 @@ async def test_try_get_response(dev: SmartDevice, caplog): @device_smart +@pytest.mark.requires_dummy() async def test_update_no_device_info(dev: SmartDevice, mocker: MockerFixture): mock_response: dict = { "get_device_usage": {}, diff --git a/tests/test_smartprotocol.py b/tests/test_smartprotocol.py index 19e62a3dd..ab68b34b5 100644 --- a/tests/test_smartprotocol.py +++ b/tests/test_smartprotocol.py @@ -1,5 +1,4 @@ import logging -from typing import cast import pytest import pytest_mock @@ -420,10 +419,11 @@ async def test_smart_queries_redaction( dev: SmartDevice, caplog: pytest.LogCaptureFixture ): """Test query sensitive info redaction.""" - device_id = "123456789ABCDEF" - cast(FakeSmartTransport, dev.protocol._transport).info["get_device_info"][ - "device_id" - ] = device_id + if isinstance(dev.protocol._transport, FakeSmartTransport): + device_id = "123456789ABCDEF" + dev.protocol._transport.info["get_device_info"]["device_id"] = device_id + else: # real device + device_id = dev.device_id # Info no message logging caplog.set_level(logging.INFO) diff --git a/tests/test_sslaestransport.py b/tests/test_sslaestransport.py index 30c74234c..49605d372 100644 --- a/tests/test_sslaestransport.py +++ b/tests/test_sslaestransport.py @@ -27,6 +27,9 @@ from kasa.httpclient import HttpClient from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials +# Transport tests are not designed for real devices +pytestmark = [pytest.mark.requires_dummy] + MOCK_ADMIN_USER = get_default_credentials(DEFAULT_CREDENTIALS["TAPOCAMERA"]).username MOCK_PWD = "correct_pwd" # noqa: S105 MOCK_USER = "mock@example.com" From 668ba748c5763b2eff6aeeb98dde8c8e599953a2 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 12 Nov 2024 14:40:44 +0100 Subject: [PATCH 664/892] Move transports into their own package (#1247) This moves all transport implementations into a new `transports` package for cleaner main package & easier to understand project structure. --- devtools/parse_pcap.py | 2 +- devtools/parse_pcap_klap.py | 2 +- docs/source/reference.md | 10 ++--- kasa/__init__.py | 3 +- kasa/device.py | 2 +- kasa/device_factory.py | 11 ++++-- kasa/discover.py | 7 ++-- kasa/experimental/sslaestransport.py | 4 +- kasa/iotprotocol.py | 9 +++-- kasa/protocol.py | 49 +++-------------------- kasa/smart/smartdevice.py | 2 +- kasa/smartprotocol.py | 8 +++- kasa/transports/__init__.py | 16 ++++++++ kasa/{ => transports}/aestransport.py | 16 ++++---- kasa/transports/basetransport.py | 55 ++++++++++++++++++++++++++ kasa/{ => transports}/klaptransport.py | 18 ++++++--- kasa/{ => transports}/xortransport.py | 9 +++-- tests/conftest.py | 2 +- tests/discovery_fixtures.py | 2 +- tests/fakeprotocol_iot.py | 2 +- tests/fakeprotocol_smart.py | 2 +- tests/fakeprotocol_smartcamera.py | 2 +- tests/test_aestransport.py | 6 ++- tests/test_discovery.py | 4 +- tests/test_klapprotocol.py | 8 ++-- tests/test_protocol.py | 8 ++-- tests/test_sslaestransport.py | 2 +- 27 files changed, 159 insertions(+), 102 deletions(-) create mode 100644 kasa/transports/__init__.py rename kasa/{ => transports}/aestransport.py (98%) create mode 100644 kasa/transports/basetransport.py rename kasa/{ => transports}/klaptransport.py (98%) rename kasa/{ => transports}/xortransport.py (97%) diff --git a/devtools/parse_pcap.py b/devtools/parse_pcap.py index 02d3911c5..f08e4dd3a 100644 --- a/devtools/parse_pcap.py +++ b/devtools/parse_pcap.py @@ -9,7 +9,7 @@ from dpkt.ethernet import ETH_TYPE_IP, Ethernet from kasa.cli.main import echo -from kasa.xortransport import XorEncryption +from kasa.transports.xortransport import XorEncryption def read_payloads_from_file(file): diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index 640c7aef0..9af590233 100755 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -25,8 +25,8 @@ DeviceEncryptionType, DeviceFamily, ) -from kasa.klaptransport import KlapEncryptionSession, KlapTransportV2 from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials +from kasa.transports.klaptransport import KlapEncryptionSession, KlapTransportV2 def _get_seq_from_query(packet): diff --git a/docs/source/reference.md b/docs/source/reference.md index c1bc4662b..b8ebee9f3 100644 --- a/docs/source/reference.md +++ b/docs/source/reference.md @@ -107,35 +107,35 @@ ``` ```{eval-rst} -.. autoclass:: kasa.protocol.BaseTransport +.. autoclass:: kasa.transports.BaseTransport :members: :inherited-members: :undoc-members: ``` ```{eval-rst} -.. autoclass:: kasa.xortransport.XorTransport +.. autoclass:: kasa.transports.XorTransport :members: :inherited-members: :undoc-members: ``` ```{eval-rst} -.. autoclass:: kasa.klaptransport.KlapTransport +.. autoclass:: kasa.transports.KlapTransport :members: :inherited-members: :undoc-members: ``` ```{eval-rst} -.. autoclass:: kasa.klaptransport.KlapTransportV2 +.. autoclass:: kasa.transports.KlapTransportV2 :members: :inherited-members: :undoc-members: ``` ```{eval-rst} -.. autoclass:: kasa.aestransport.AesTransport +.. autoclass:: kasa.transports.AesTransport :members: :inherited-members: :undoc-members: diff --git a/kasa/__init__.py b/kasa/__init__.py index ffeaa5038..49e77966e 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -41,8 +41,9 @@ _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 ) from kasa.module import Module -from kasa.protocol import BaseProtocol, BaseTransport +from kasa.protocol import BaseProtocol from kasa.smartprotocol import SmartProtocol +from kasa.transports import BaseTransport __version__ = version("python-kasa") diff --git a/kasa/device.py b/kasa/device.py index ca16bb6bf..acb3af8c1 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -128,7 +128,7 @@ from .iotprotocol import IotProtocol from .module import Module from .protocol import BaseProtocol -from .xortransport import XorTransport +from .transports import XorTransport if TYPE_CHECKING: from .modulemapping import ModuleMapping, ModuleName diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 0c1ed427c..9cdef53e2 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -6,7 +6,6 @@ import time from typing import Any -from .aestransport import AesTransport from .device import Device from .device_type import DeviceType from .deviceconfig import DeviceConfig @@ -24,14 +23,18 @@ IotWallSwitch, ) from .iotprotocol import IotProtocol -from .klaptransport import KlapTransport, KlapTransportV2 from .protocol import ( BaseProtocol, - BaseTransport, ) from .smart import SmartDevice from .smartprotocol import SmartProtocol -from .xortransport import XorTransport +from .transports import ( + AesTransport, + BaseTransport, + KlapTransport, + KlapTransportV2, + XorTransport, +) _LOGGER = logging.getLogger(__name__) diff --git a/kasa/discover.py b/kasa/discover.py index efb1e5e41..bed43e85c 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -111,7 +111,6 @@ from pydantic.v1 import BaseModel, ValidationError from kasa import Device -from kasa.aestransport import AesEncyptionSession, KeyPair from kasa.credentials import Credentials from kasa.device_factory import ( get_device_class_from_family, @@ -134,12 +133,14 @@ from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads from kasa.protocol import mask_mac, redact_data -from kasa.xortransport import XorEncryption +from kasa.transports.aestransport import AesEncyptionSession, KeyPair +from kasa.transports.xortransport import XorEncryption _LOGGER = logging.getLogger(__name__) if TYPE_CHECKING: - from kasa import BaseProtocol, BaseTransport + from kasa import BaseProtocol + from kasa.transports import BaseTransport class ConnectAttempt(NamedTuple): diff --git a/kasa/experimental/sslaestransport.py b/kasa/experimental/sslaestransport.py index f188f1441..6b5144b15 100644 --- a/kasa/experimental/sslaestransport.py +++ b/kasa/experimental/sslaestransport.py @@ -13,7 +13,6 @@ from yarl import URL -from ..aestransport import AesEncyptionSession from ..credentials import Credentials from ..deviceconfig import DeviceConfig from ..exceptions import ( @@ -28,7 +27,8 @@ from ..httpclient import HttpClient from ..json import dumps as json_dumps from ..json import loads as json_loads -from ..protocol import DEFAULT_CREDENTIALS, BaseTransport, get_default_credentials +from ..protocol import DEFAULT_CREDENTIALS, get_default_credentials +from ..transports import AesEncyptionSession, BaseTransport _LOGGER = logging.getLogger(__name__) diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index 91edb0329..bb5704989 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -5,7 +5,7 @@ import asyncio import logging from pprint import pformat as pf -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Callable from .deviceconfig import DeviceConfig from .exceptions import ( @@ -16,8 +16,11 @@ _RetryableError, ) from .json import dumps as json_dumps -from .protocol import BaseProtocol, BaseTransport, mask_mac, redact_data -from .xortransport import XorEncryption, XorTransport +from .protocol import BaseProtocol, mask_mac, redact_data +from .transports import XorEncryption, XorTransport + +if TYPE_CHECKING: + from .transports import BaseTransport _LOGGER = logging.getLogger(__name__) diff --git a/kasa/protocol.py b/kasa/protocol.py index f2560987d..8e8a2352a 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -18,7 +18,7 @@ import logging import struct from abc import ABC, abstractmethod -from typing import Any, Callable, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -32,6 +32,10 @@ _T = TypeVar("_T") +if TYPE_CHECKING: + from .transports import BaseTransport + + def redact_data(data: _T, redactors: dict[str, Callable[[Any], Any] | None]) -> _T: """Redact sensitive data for logging.""" if not isinstance(data, (dict, list)): @@ -75,49 +79,6 @@ def md5(payload: bytes) -> bytes: return hashlib.md5(payload).digest() # noqa: S324 -class BaseTransport(ABC): - """Base class for all TP-Link protocol transports.""" - - DEFAULT_TIMEOUT = 5 - - def __init__( - self, - *, - config: DeviceConfig, - ) -> None: - """Create a protocol object.""" - self._config = config - self._host = config.host - self._port = config.port_override or self.default_port - self._credentials = config.credentials - self._credentials_hash = config.credentials_hash - if not config.timeout: - config.timeout = self.DEFAULT_TIMEOUT - self._timeout = config.timeout - - @property - @abstractmethod - def default_port(self) -> int: - """The default port for the transport.""" - - @property - @abstractmethod - def credentials_hash(self) -> str | None: - """The hashed credentials used by the transport.""" - - @abstractmethod - async def send(self, request: str) -> dict: - """Send a message to the device and return a response.""" - - @abstractmethod - async def close(self) -> None: - """Close the transport. Abstract method to be overriden.""" - - @abstractmethod - async def reset(self) -> None: - """Reset internal state.""" - - class BaseProtocol(ABC): """Base class for all TP-Link Smart Home communication.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index e497b8e8c..e8e2186c4 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -9,7 +9,6 @@ from datetime import datetime, timedelta, timezone, tzinfo from typing import TYPE_CHECKING, Any, cast -from ..aestransport import AesTransport from ..device import Device, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -18,6 +17,7 @@ from ..module import Module from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol +from ..transports import AesTransport from .modules import ( ChildDevice, Cloud, diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index e2ff6af73..7d43bdb45 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -12,7 +12,7 @@ import time import uuid from pprint import pformat as pf -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Callable from .exceptions import ( SMART_AUTHENTICATION_ERRORS, @@ -26,7 +26,11 @@ _RetryableError, ) from .json import dumps as json_dumps -from .protocol import BaseProtocol, BaseTransport, mask_mac, md5, redact_data +from .protocol import BaseProtocol, mask_mac, md5, redact_data + +if TYPE_CHECKING: + from .transports import BaseTransport + _LOGGER = logging.getLogger(__name__) diff --git a/kasa/transports/__init__.py b/kasa/transports/__init__.py new file mode 100644 index 000000000..8ccdae65d --- /dev/null +++ b/kasa/transports/__init__.py @@ -0,0 +1,16 @@ +"""Package containing all supported transports.""" + +from .aestransport import AesEncyptionSession, AesTransport +from .basetransport import BaseTransport +from .klaptransport import KlapTransport, KlapTransportV2 +from .xortransport import XorEncryption, XorTransport + +__all__ = [ + "AesTransport", + "AesEncyptionSession", + "BaseTransport", + "KlapTransport", + "KlapTransportV2", + "XorTransport", + "XorEncryption", +] diff --git a/kasa/aestransport.py b/kasa/transports/aestransport.py similarity index 98% rename from kasa/aestransport.py rename to kasa/transports/aestransport.py index fc807fb3a..61b7c27bd 100644 --- a/kasa/aestransport.py +++ b/kasa/transports/aestransport.py @@ -20,9 +20,9 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from yarl import URL -from .credentials import Credentials -from .deviceconfig import DeviceConfig -from .exceptions import ( +from kasa.credentials import Credentials +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import ( SMART_AUTHENTICATION_ERRORS, SMART_RETRYABLE_ERRORS, AuthenticationError, @@ -33,10 +33,12 @@ _ConnectionError, _RetryableError, ) -from .httpclient import HttpClient -from .json import dumps as json_dumps -from .json import loads as json_loads -from .protocol import DEFAULT_CREDENTIALS, BaseTransport, get_default_credentials +from kasa.httpclient import HttpClient +from kasa.json import dumps as json_dumps +from kasa.json import loads as json_loads +from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials + +from .basetransport import BaseTransport _LOGGER = logging.getLogger(__name__) diff --git a/kasa/transports/basetransport.py b/kasa/transports/basetransport.py new file mode 100644 index 000000000..1f1ed7d95 --- /dev/null +++ b/kasa/transports/basetransport.py @@ -0,0 +1,55 @@ +"""Base class for all transport implementations. + +All transport classes must derive from this to implement the common interface. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from kasa import DeviceConfig + + +class BaseTransport(ABC): + """Base class for all TP-Link protocol transports.""" + + DEFAULT_TIMEOUT = 5 + + def __init__( + self, + *, + config: DeviceConfig, + ) -> None: + """Create a protocol object.""" + self._config = config + self._host = config.host + self._port = config.port_override or self.default_port + self._credentials = config.credentials + self._credentials_hash = config.credentials_hash + if not config.timeout: + config.timeout = self.DEFAULT_TIMEOUT + self._timeout = config.timeout + + @property + @abstractmethod + def default_port(self) -> int: + """The default port for the transport.""" + + @property + @abstractmethod + def credentials_hash(self) -> str | None: + """The hashed credentials used by the transport.""" + + @abstractmethod + async def send(self, request: str) -> dict: + """Send a message to the device and return a response.""" + + @abstractmethod + async def close(self) -> None: + """Close the transport. Abstract method to be overriden.""" + + @abstractmethod + async def reset(self) -> None: + """Reset internal state.""" diff --git a/kasa/klaptransport.py b/kasa/transports/klaptransport.py similarity index 98% rename from kasa/klaptransport.py rename to kasa/transports/klaptransport.py index 870304d16..d9d5e952b 100644 --- a/kasa/klaptransport.py +++ b/kasa/transports/klaptransport.py @@ -57,12 +57,18 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from yarl import URL -from .credentials import Credentials -from .deviceconfig import DeviceConfig -from .exceptions import AuthenticationError, KasaException, _RetryableError -from .httpclient import HttpClient -from .json import loads as json_loads -from .protocol import DEFAULT_CREDENTIALS, BaseTransport, get_default_credentials, md5 +from kasa.credentials import Credentials +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import AuthenticationError, KasaException, _RetryableError +from kasa.httpclient import HttpClient +from kasa.json import loads as json_loads +from kasa.protocol import ( + DEFAULT_CREDENTIALS, + get_default_credentials, + md5, +) + +from .basetransport import BaseTransport _LOGGER = logging.getLogger(__name__) diff --git a/kasa/xortransport.py b/kasa/transports/xortransport.py similarity index 97% rename from kasa/xortransport.py rename to kasa/transports/xortransport.py index 7abc2a3b8..932a9415b 100644 --- a/kasa/xortransport.py +++ b/kasa/transports/xortransport.py @@ -24,10 +24,11 @@ # async_timeout can be replaced with asyncio.timeout from async_timeout import timeout as asyncio_timeout -from .deviceconfig import DeviceConfig -from .exceptions import KasaException, _RetryableError -from .json import loads as json_loads -from .protocol import BaseTransport +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import KasaException, _RetryableError +from kasa.json import loads as json_loads + +from .basetransport import BaseTransport _LOGGER = logging.getLogger(__name__) _NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} diff --git a/tests/conftest.py b/tests/conftest.py index 0d47080fb..c56cba0fc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ DeviceConfig, SmartProtocol, ) -from kasa.protocol import BaseTransport +from kasa.transports.basetransport import BaseTransport from .device_fixtures import * # noqa: F403 from .discovery_fixtures import * # noqa: F403 diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index ccad1510b..e69a8b73c 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -6,7 +6,7 @@ import pytest -from kasa.xortransport import XorEncryption +from kasa.transports.xortransport import XorEncryption from .fakeprotocol_iot import FakeIotProtocol from .fakeprotocol_smart import FakeSmartProtocol, FakeSmartTransport diff --git a/tests/fakeprotocol_iot.py b/tests/fakeprotocol_iot.py index c8897d9b9..1249ec216 100644 --- a/tests/fakeprotocol_iot.py +++ b/tests/fakeprotocol_iot.py @@ -3,7 +3,7 @@ from kasa.deviceconfig import DeviceConfig from kasa.iotprotocol import IotProtocol -from kasa.protocol import BaseTransport +from kasa.transports.basetransport import BaseTransport _LOGGER = logging.getLogger(__name__) diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index 842147f35..ce60a61ba 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -6,8 +6,8 @@ from kasa import Credentials, DeviceConfig, SmartProtocol from kasa.exceptions import SmartErrorCode -from kasa.protocol import BaseTransport from kasa.smart import SmartChildDevice +from kasa.transports.basetransport import BaseTransport class FakeSmartProtocol(SmartProtocol): diff --git a/tests/fakeprotocol_smartcamera.py b/tests/fakeprotocol_smartcamera.py index d7465489c..7ff0bab22 100644 --- a/tests/fakeprotocol_smartcamera.py +++ b/tests/fakeprotocol_smartcamera.py @@ -5,7 +5,7 @@ from kasa import Credentials, DeviceConfig, SmartProtocol from kasa.experimental.smartcameraprotocol import SmartCameraProtocol -from kasa.protocol import BaseTransport +from kasa.transports.basetransport import BaseTransport from .fakeprotocol_smart import FakeSmartTransport diff --git a/tests/test_aestransport.py b/tests/test_aestransport.py index 302f195a2..4c95289a3 100644 --- a/tests/test_aestransport.py +++ b/tests/test_aestransport.py @@ -18,7 +18,6 @@ from freezegun.api import FrozenDateTimeFactory from yarl import URL -from kasa.aestransport import AesEncyptionSession, AesTransport, TransportState from kasa.credentials import Credentials from kasa.deviceconfig import DeviceConfig from kasa.exceptions import ( @@ -28,6 +27,11 @@ _ConnectionError, ) from kasa.httpclient import HttpClient +from kasa.transports.aestransport import ( + AesEncyptionSession, + AesTransport, + TransportState, +) pytestmark = [pytest.mark.requires_dummy] diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 7f69977ed..32330dcad 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -23,7 +23,6 @@ IotProtocol, KasaException, ) -from kasa.aestransport import AesEncyptionSession from kasa.device_factory import ( get_device_class_from_family, get_device_class_from_sys_info, @@ -41,7 +40,8 @@ ) from kasa.exceptions import AuthenticationError, UnsupportedDeviceError from kasa.iot import IotDevice -from kasa.xortransport import XorEncryption, XorTransport +from kasa.transports.aestransport import AesEncyptionSession +from kasa.transports.xortransport import XorEncryption, XorTransport from .conftest import ( bulb_iot, diff --git a/tests/test_klapprotocol.py b/tests/test_klapprotocol.py index 524a6be3e..bdb054906 100644 --- a/tests/test_klapprotocol.py +++ b/tests/test_klapprotocol.py @@ -9,7 +9,6 @@ import pytest from yarl import URL -from kasa.aestransport import AesTransport from kasa.credentials import Credentials from kasa.deviceconfig import DeviceConfig from kasa.exceptions import ( @@ -21,14 +20,15 @@ ) from kasa.httpclient import HttpClient from kasa.iotprotocol import IotProtocol -from kasa.klaptransport import ( +from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials +from kasa.smartprotocol import SmartProtocol +from kasa.transports.aestransport import AesTransport +from kasa.transports.klaptransport import ( KlapEncryptionSession, KlapTransport, KlapTransportV2, _sha256, ) -from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials -from kasa.smartprotocol import SmartProtocol DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 7638a4bfa..11e2afcf8 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -13,21 +13,21 @@ import pytest -from kasa.aestransport import AesTransport from kasa.credentials import Credentials from kasa.device import Device from kasa.deviceconfig import DeviceConfig from kasa.exceptions import KasaException from kasa.iot import IotDevice from kasa.iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol -from kasa.klaptransport import KlapTransport, KlapTransportV2 from kasa.protocol import ( BaseProtocol, - BaseTransport, mask_mac, redact_data, ) -from kasa.xortransport import XorEncryption, XorTransport +from kasa.transports.aestransport import AesTransport +from kasa.transports.basetransport import BaseTransport +from kasa.transports.klaptransport import KlapTransport, KlapTransportV2 +from kasa.transports.xortransport import XorEncryption, XorTransport from .conftest import device_iot from .fakeprotocol_iot import FakeIotTransport diff --git a/tests/test_sslaestransport.py b/tests/test_sslaestransport.py index 49605d372..52507892e 100644 --- a/tests/test_sslaestransport.py +++ b/tests/test_sslaestransport.py @@ -11,7 +11,6 @@ import pytest from yarl import URL -from kasa.aestransport import AesEncyptionSession from kasa.credentials import Credentials from kasa.deviceconfig import DeviceConfig from kasa.exceptions import ( @@ -26,6 +25,7 @@ ) from kasa.httpclient import HttpClient from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials +from kasa.transports.aestransport import AesEncyptionSession # Transport tests are not designed for real devices pytestmark = [pytest.mark.requires_dummy] From 9d5e07b969313c6b23d25f4f5cf06a56be33b44b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 12 Nov 2024 19:34:02 +0000 Subject: [PATCH 665/892] Add SmartCamera Led Module (#1249) --- kasa/experimental/modules/__init__.py | 2 ++ kasa/experimental/modules/led.py | 28 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 kasa/experimental/modules/led.py diff --git a/kasa/experimental/modules/__init__.py b/kasa/experimental/modules/__init__.py index 48c4c2acd..cf2b43777 100644 --- a/kasa/experimental/modules/__init__.py +++ b/kasa/experimental/modules/__init__.py @@ -3,11 +3,13 @@ from .camera import Camera from .childdevice import ChildDevice from .device import DeviceModule +from .led import Led from .time import Time __all__ = [ "Camera", "ChildDevice", "DeviceModule", + "Led", "Time", ] diff --git a/kasa/experimental/modules/led.py b/kasa/experimental/modules/led.py new file mode 100644 index 000000000..0443d320a --- /dev/null +++ b/kasa/experimental/modules/led.py @@ -0,0 +1,28 @@ +"""Module for led controls.""" + +from __future__ import annotations + +from ...interfaces.led import Led as LedInterface +from ..smartcameramodule import SmartCameraModule + + +class Led(SmartCameraModule, LedInterface): + """Implementation of led controls.""" + + REQUIRED_COMPONENT = "led" + QUERY_GETTER_NAME = "getLedStatus" + QUERY_MODULE_NAME = "led" + QUERY_SECTION_NAMES = "config" + + @property + def led(self) -> bool: + """Return current led status.""" + return self.data["config"]["enabled"] == "on" + + async def set_led(self, enable: bool) -> dict: + """Set led. + + This should probably be a select with always/never/nightmode. + """ + params = {"enabled": "on"} if enable else {"enabled": "off"} + return await self.call("setLedStatus", {"led": {"config": params}}) From 254a9af5c1f057a6bbbf5a87363bbc5422a3889b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 12 Nov 2024 21:00:04 +0000 Subject: [PATCH 666/892] Update DiscoveryResult to use Mashumaro instead of pydantic (#1231) Mashumaro is faster and doesn't come with all versioning problems that pydantic does. A basic perf test deserializing all of our discovery results fixtures shows mashumaro as being about 6 times faster deserializing dicts than pydantic. It's much faster parsing from a json string but that's likely because it uses orjson under the hood although that's not really our use case at the moment. ``` PYDANTIC - ms ================= json dict ----------------- 4.7665 1.3268 3.1548 1.5922 3.1130 1.8039 4.2834 2.7606 2.0669 1.3757 2.0163 1.6377 3.1667 1.3561 4.1296 2.7297 2.0132 1.3471 4.0648 1.4105 MASHUMARO - ms ================= json dict ----------------- 0.5977 0.5543 0.5336 0.2983 0.3955 0.2549 0.6516 0.2742 0.5386 0.2706 0.6678 0.2580 0.4120 0.2511 0.3836 0.2472 0.4020 0.2465 0.4268 0.2487 ``` --- devtools/dump_devinfo.py | 12 +++--- kasa/cli/discover.py | 2 +- kasa/discover.py | 73 ++++++++++++++++++++++-------------- kasa/json.py | 10 +++++ pyproject.toml | 1 + tests/test_cli.py | 2 +- tests/test_device_factory.py | 2 +- tests/test_discovery.py | 6 +-- uv.lock | 14 +++++++ 9 files changed, 81 insertions(+), 41 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 5cbfff76f..83df9dcd3 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -319,7 +319,7 @@ async def cli( click.echo("Host and discovery info given, trying connect on %s." % host) di = json.loads(discovery_info) - dr = DiscoveryResult(**di) + dr = DiscoveryResult.from_dict(di) connection_type = DeviceConnectionParameters.from_values( dr.device_type, dr.mgt_encrypt_schm.encrypt_type, @@ -336,7 +336,7 @@ async def cli( basedir, autosave, device.protocol, - discovery_info=dr.get_dict(), + discovery_info=dr.to_dict(), batch_size=batch_size, ) elif device_family and encrypt_type: @@ -443,7 +443,7 @@ async def get_legacy_fixture(protocol, *, discovery_info): if discovery_info and not discovery_info.get("system"): # 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. - dr = DiscoveryResult(**protocol._discovery_info) + dr = DiscoveryResult.from_dict(protocol._discovery_info) final["discovery_result"] = dr.dict( by_alias=False, exclude_unset=True, exclude_none=True, exclude_defaults=True ) @@ -960,10 +960,8 @@ async def get_smart_fixtures( # 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. if discovery_info: - dr = DiscoveryResult(**discovery_info) # type: ignore - final["discovery_result"] = dr.dict( - by_alias=False, exclude_unset=True, exclude_none=True, exclude_defaults=True - ) + dr = DiscoveryResult.from_dict(discovery_info) # type: ignore + final["discovery_result"] = dr.to_dict() click.echo("Got %s successes" % len(successes)) click.echo(click.style("## device info file ##", bold=True)) diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index 8df59de84..3ebb4a9f6 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -207,7 +207,7 @@ def _echo_discovery_info(discovery_info) -> None: return try: - dr = DiscoveryResult(**discovery_info) + dr = DiscoveryResult.from_dict(discovery_info) except ValidationError: _echo_dictionary(discovery_info) return diff --git a/kasa/discover.py b/kasa/discover.py index bed43e85c..d1240aa81 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -90,6 +90,7 @@ import socket import struct from asyncio.transports import DatagramTransport +from dataclasses import dataclass, field from pprint import pformat as pf from typing import ( TYPE_CHECKING, @@ -108,7 +109,8 @@ # 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 pydantic.v1 import BaseModel, ValidationError +from mashumaro import field_options +from mashumaro.config import BaseConfig from kasa import Device from kasa.credentials import Credentials @@ -130,6 +132,7 @@ from kasa.experimental import Experimental from kasa.iot.iotdevice import IotDevice from kasa.iotprotocol import REDACTORS as IOT_REDACTORS +from kasa.json import DataClassJSONMixin from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads from kasa.protocol import mask_mac, redact_data @@ -647,7 +650,7 @@ async def try_connect_all( def _get_device_class(info: dict) -> type[Device]: """Find SmartDevice subclass for device described by passed data.""" if "result" in info: - discovery_result = DiscoveryResult(**info["result"]) + discovery_result = DiscoveryResult.from_dict(info["result"]) https = discovery_result.mgt_encrypt_schm.is_support_https dev_class = get_device_class_from_family( discovery_result.device_type, https=https @@ -721,12 +724,8 @@ def _get_device_instance( f"Unable to read response from device: {config.host}: {ex}" ) from ex try: - discovery_result = DiscoveryResult(**info["result"]) - if ( - encrypt_info := discovery_result.encrypt_info - ) and encrypt_info.sym_schm == "AES": - Discover._decrypt_discovery_data(discovery_result) - except ValidationError as ex: + discovery_result = DiscoveryResult.from_dict(info["result"]) + except Exception as ex: if debug_enabled: data = ( redact_data(info, NEW_DISCOVERY_REDACTORS) @@ -742,6 +741,16 @@ def _get_device_instance( f"Unable to parse discovery from device: {config.host}: {ex}", host=config.host, ) from ex + # Decrypt the data + if ( + encrypt_info := discovery_result.encrypt_info + ) and encrypt_info.sym_schm == "AES": + try: + Discover._decrypt_discovery_data(discovery_result) + except Exception: + _LOGGER.exception( + "Unable to decrypt discovery data %s: %s", config.host, data + ) type_ = discovery_result.device_type encrypt_schm = discovery_result.mgt_encrypt_schm @@ -754,7 +763,7 @@ def _get_device_instance( raise UnsupportedDeviceError( f"Unsupported device {config.host} of type {type_} " + "with no encryption type", - discovery_result=discovery_result.get_dict(), + discovery_result=discovery_result.to_dict(), host=config.host, ) config.connection_type = DeviceConnectionParameters.from_values( @@ -767,7 +776,7 @@ def _get_device_instance( raise UnsupportedDeviceError( f"Unsupported device {config.host} of type {type_} " + f"with encrypt_type {discovery_result.mgt_encrypt_schm.encrypt_type}", - discovery_result=discovery_result.get_dict(), + discovery_result=discovery_result.to_dict(), host=config.host, ) from ex if ( @@ -778,7 +787,7 @@ def _get_device_instance( _LOGGER.warning("Got unsupported device type: %s", type_) raise UnsupportedDeviceError( f"Unsupported device {config.host} of type {type_}: {info}", - discovery_result=discovery_result.get_dict(), + discovery_result=discovery_result.to_dict(), host=config.host, ) if (protocol := get_protocol(config)) is None: @@ -788,7 +797,7 @@ def _get_device_instance( raise UnsupportedDeviceError( f"Unsupported encryption scheme {config.host} of " + f"type {config.connection_type.to_dict()}: {info}", - discovery_result=discovery_result.get_dict(), + discovery_result=discovery_result.to_dict(), host=config.host, ) @@ -801,22 +810,35 @@ def _get_device_instance( _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) device = device_class(config.host, protocol=protocol) - di = discovery_result.get_dict() + di = discovery_result.to_dict() di["model"], _, _ = discovery_result.device_model.partition("(") device.update_from_discover_info(di) return device -class EncryptionScheme(BaseModel): +class _DiscoveryBaseMixin(DataClassJSONMixin): + """Base class for serialization mixin.""" + + class Config(BaseConfig): + """Serialization config.""" + + omit_none = True + omit_default = True + serialize_by_alias = True + + +@dataclass +class EncryptionScheme(_DiscoveryBaseMixin): """Base model for encryption scheme of discovery result.""" is_support_https: bool - encrypt_type: Optional[str] # noqa: UP007 + encrypt_type: Optional[str] = None # noqa: UP007 http_port: Optional[int] = None # noqa: UP007 lv: Optional[int] = None # noqa: UP007 -class EncryptionInfo(BaseModel): +@dataclass +class EncryptionInfo(_DiscoveryBaseMixin): """Base model for encryption info of discovery result.""" sym_schm: str @@ -824,19 +846,23 @@ class EncryptionInfo(BaseModel): data: str -class DiscoveryResult(BaseModel): +@dataclass +class DiscoveryResult(_DiscoveryBaseMixin): """Base model for discovery result.""" device_type: str device_model: str - device_name: Optional[str] # noqa: UP007 + device_id: str ip: str mac: str mgt_encrypt_schm: EncryptionScheme + device_name: Optional[str] = None # noqa: UP007 encrypt_info: Optional[EncryptionInfo] = None # noqa: UP007 encrypt_type: Optional[list[str]] = None # noqa: UP007 decrypted_data: Optional[dict] = None # noqa: UP007 - device_id: str + is_reset_wifi: Optional[bool] = field( # noqa: UP007 + metadata=field_options(alias="isResetWiFi"), default=None + ) firmware_version: Optional[str] = None # noqa: UP007 hardware_version: Optional[str] = None # noqa: UP007 @@ -845,12 +871,3 @@ class DiscoveryResult(BaseModel): is_support_iot_cloud: Optional[bool] = None # noqa: UP007 obd_src: Optional[str] = None # noqa: UP007 factory_default: Optional[bool] = None # noqa: UP007 - - def get_dict(self) -> dict: - """Return a dict for this discovery result. - - containing only the values actually set and with aliases as field names. - """ - return self.dict( - by_alias=False, exclude_unset=True, exclude_none=True, exclude_defaults=True - ) diff --git a/kasa/json.py b/kasa/json.py index 10edc690e..6f1149fa5 100755 --- a/kasa/json.py +++ b/kasa/json.py @@ -21,3 +21,13 @@ def dumps(obj: Any, *, default: Callable | None = None) -> str: return json.dumps(obj, separators=(",", ":")) loads = json.loads + + +try: + from mashumaro.mixins.orjson import DataClassORJSONMixin + + DataClassJSONMixin = DataClassORJSONMixin +except ImportError: + from mashumaro.mixins.json import DataClassJSONMixin as JSONMixin + + DataClassJSONMixin = JSONMixin # type: ignore[assignment, misc] diff --git a/pyproject.toml b/pyproject.toml index 92ef7bbe3..44959c6fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "aiohttp>=3", "typing-extensions>=4.12.2,<5.0", "tzdata>=2024.2 ; platform_system == 'Windows'", + "mashumaro>=3.14", ] classifiers = [ diff --git a/tests/test_cli.py b/tests/test_cli.py index d22bb1129..b6bcdfd4a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -616,7 +616,7 @@ async def _state(dev: Device): mocker.patch("kasa.cli.device.state", new=_state) - dr = DiscoveryResult(**discovery_mock.discovery_data["result"]) + dr = DiscoveryResult.from_dict(discovery_mock.discovery_data["result"]) res = await runner.invoke( cli, [ diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 8690e5802..0042d6e28 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -43,7 +43,7 @@ def _get_connection_type_device_class(discovery_info): if "result" in discovery_info: device_class = Discover._get_device_class(discovery_info) - dr = DiscoveryResult(**discovery_info["result"]) + dr = DiscoveryResult.from_dict(discovery_info["result"]) connection_type = DeviceConnectionParameters.from_values( dr.device_type, dr.mgt_encrypt_schm.encrypt_type diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 32330dcad..aeda423ea 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -391,8 +391,8 @@ async def test_device_update_from_new_discovery_info(discovery_mock): discovery_data = discovery_mock.discovery_data device_class = Discover._get_device_class(discovery_data) device = device_class("127.0.0.1") - discover_info = DiscoveryResult(**discovery_data["result"]) - discover_dump = discover_info.get_dict() + discover_info = DiscoveryResult.from_dict(discovery_data["result"]) + discover_dump = discover_info.to_dict() model, _, _ = discover_dump["device_model"].partition("(") discover_dump["model"] = model device.update_from_discover_info(discover_dump) @@ -652,7 +652,7 @@ async def test_discovery_decryption(): "sym_schm": "AES", } info = {**UNSUPPORTED["result"], "encrypt_info": encrypt_info} - dr = DiscoveryResult(**info) + dr = DiscoveryResult.from_dict(info) Discover._decrypt_discovery_data(dr) assert dr.decrypted_data == data_dict diff --git a/uv.lock b/uv.lock index 27a1100a5..79a6f9898 100644 --- a/uv.lock +++ b/uv.lock @@ -831,6 +831,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 }, ] +[[package]] +name = "mashumaro" +version = "3.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/47/0a450b281bef2d7e97ec02c8e1168d821e283f58e02e6c403b2bb4d73c1c/mashumaro-3.14.tar.gz", hash = "sha256:5ef6f2b963892cbe9a4ceb3441dfbea37f8c3412523f25d42e9b3a7186555f1d", size = 166160 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/35/8d63733a2c12149d0c7663c29bf626bdbeea5f0ff963afe58a42b4810981/mashumaro-3.14-py3-none-any.whl", hash = "sha256:c12a649599a8f7b1a0b35d18f12e678423c3066189f7bc7bd8dd431c5c8132c3", size = 92183 }, +] + [[package]] name = "mdit-py-plugins" version = "0.3.5" @@ -1494,6 +1506,7 @@ dependencies = [ { name = "async-timeout" }, { name = "asyncclick" }, { name = "cryptography" }, + { name = "mashumaro" }, { name = "pydantic" }, { name = "typing-extensions" }, { name = "tzdata", marker = "platform_system == 'Windows'" }, @@ -1544,6 +1557,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=1.9" }, { name = "docutils", marker = "extra == 'docs'", specifier = ">=0.17" }, { name = "kasa-crypt", marker = "extra == 'speedups'", specifier = ">=0.2.0" }, + { name = "mashumaro", specifier = ">=3.14" }, { name = "myst-parser", marker = "extra == 'docs'" }, { name = "orjson", marker = "extra == 'speedups'", specifier = ">=3.9.1" }, { name = "ptpython", marker = "extra == 'shell'" }, From 9294845384f12d2e1c4d085beee82e86acf720df Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 13 Nov 2024 10:14:07 +0000 Subject: [PATCH 667/892] Update smartcamera fixtures with components (#1250) --- .../smartcamera/C210(EU)_2.0_1.4.2.json | 146 ++++++ .../smartcamera/H200(EU)_1.0_1.3.2.json | 2 +- .../smartcamera/H200(US)_1.0_1.3.6.json | 476 +++++++++++++++++- 3 files changed, 608 insertions(+), 16 deletions(-) diff --git a/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json b/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json index a4c529a53..ba2e00108 100644 --- a/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json +++ b/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json @@ -78,6 +78,152 @@ } } }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 2 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "panoramicView", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "staticIp", + "version": 2 + } + ] + } + }, "getAudioConfig": { "audio_config": { "microphone": { diff --git a/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json b/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json index 22b6c9d91..04bcc262c 100644 --- a/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json +++ b/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json @@ -194,7 +194,7 @@ "ver_code": 1 } ], - "device_id": "0000000000000000000000000000000000000000" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" } ], "start_index": 0, diff --git a/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json b/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json index dda7ade8f..f1a6ae157 100644 --- a/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json +++ b/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json @@ -24,6 +24,7 @@ "firmware_version": "1.3.6 Build 20240829 rel.71119", "hardware_version": "1.0", "ip": "127.0.0.123", + "isResetWiFi": false, "is_support_iot_cloud": true, "mac": "24-2F-D0-00-00-00", "mgt_encrypt_schm": { @@ -31,6 +32,451 @@ } }, "getAlertConfig": {}, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 2 + }, + { + "name": "dateTime", + "version": 1 + }, + { + "name": "system", + "version": 4 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 1 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "hubRecord", + "version": 1 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "siren", + "version": 2 + }, + { + "name": "childControl", + "version": 1 + }, + { + "name": "childQuickSetup", + "version": 1 + }, + { + "name": "childInherit", + "version": 1 + }, + { + "name": "deviceLoad", + "version": 1 + }, + { + "name": "subg", + "version": 2 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "preWakeUp", + "version": 1 + }, + { + "name": "supportRE", + "version": 1 + }, + { + "name": "testSignal", + "version": 1 + }, + { + "name": "dataDownload", + "version": 1 + }, + { + "name": "testChildSignal", + "version": 1 + }, + { + "name": "ringLog", + "version": 1 + }, + { + "name": "matter", + "version": 1 + }, + { + "name": "localSmart", + "version": 1 + }, + { + "name": "generalCameraManage", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "hubPlayback", + "version": 1 + } + ] + } + }, + "getChildDeviceComponentList": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "double_click", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "double_click", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5" + } + ], + "start_index": 0, + "sum": 5 + }, "getChildDeviceList": { "child_device_list": [ { @@ -38,7 +484,7 @@ "avatar": "sensor_t310", "bind_count": 1, "category": "subg.trigger.temp-hmdt-sensor", - "current_humidity": 49, + "current_humidity": 56, "current_humidity_exception": 0, "current_temp": 21.7, "current_temp_exception": 0, @@ -56,7 +502,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Australia/Canberra", "report_interval": 16, - "rssi": -46, + "rssi": -43, "signal_level": 3, "specs": "US", "status": "online", @@ -70,16 +516,16 @@ "battery_percentage": 100, "bind_count": 1, "category": "subg.trigger.temp-hmdt-sensor", - "current_humidity": 51, + "current_humidity": 58, "current_humidity_exception": 0, - "current_temp": 21.5, + "current_temp": 21.6, "current_temp_exception": 0, "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", "fw_ver": "1.8.0 Build 230921 Rel.091519", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -113, - "jamming_signal_level": 1, + "jamming_rssi": -107, + "jamming_signal_level": 2, "lastOnboardingTimestamp": 1724637369, "mac": "202351000000", "model": "T315", @@ -88,7 +534,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Australia/Canberra", "report_interval": 16, - "rssi": -44, + "rssi": -42, "signal_level": 3, "specs": "US", "status": "online", @@ -105,7 +551,7 @@ "fw_ver": "1.9.0 Build 230704 Rel.154559", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -113, + "jamming_rssi": -112, "jamming_signal_level": 1, "lastOnboardingTimestamp": 1724635267, "mac": "A86E84000000", @@ -116,7 +562,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Australia/Canberra", "report_interval": 16, - "rssi": -56, + "rssi": -47, "signal_level": 3, "specs": "US", "status": "online", @@ -132,7 +578,7 @@ "fw_ver": "1.12.0 Build 231121 Rel.092508", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -112, + "jamming_rssi": -115, "jamming_signal_level": 1, "lastOnboardingTimestamp": 1724636047, "mac": "3C52A1000000", @@ -142,7 +588,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Australia/Canberra", "report_interval": 16, - "rssi": -36, + "rssi": -45, "signal_level": 3, "specs": "US", "status": "online", @@ -158,7 +604,7 @@ "fw_ver": "1.12.0 Build 231121 Rel.092508", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -113, + "jamming_rssi": -114, "jamming_signal_level": 1, "lastOnboardingTimestamp": 1724636886, "mac": "98254A000000", @@ -168,7 +614,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Australia/Canberra", "report_interval": 16, - "rssi": -56, + "rssi": -57, "signal_level": 3, "specs": "US", "status": "online", @@ -189,8 +635,8 @@ "getClockStatus": { "system": { "clock_status": { - "local_time": "2024-11-01 22:16:12", - "seconds_from_1970": 1730459772 + "local_time": "2024-11-13 09:26:28", + "seconds_from_1970": 1731450388 } } }, From 3086aa8a20ad8ff26e127549c23cd19f9bea99e3 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 13 Nov 2024 10:21:12 +0000 Subject: [PATCH 668/892] Use component queries to select smartcamera modules (#1248) --- kasa/experimental/modules/childdevice.py | 1 + kasa/experimental/smartcamera.py | 52 ++++++++++++++++-------- kasa/experimental/smartcameramodule.py | 4 +- 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/kasa/experimental/modules/childdevice.py b/kasa/experimental/modules/childdevice.py index 0168011dd..81905fbfc 100644 --- a/kasa/experimental/modules/childdevice.py +++ b/kasa/experimental/modules/childdevice.py @@ -7,6 +7,7 @@ class ChildDevice(SmartCameraModule): """Implementation for child devices.""" + REQUIRED_COMPONENT = "childControl" NAME = "childdevice" QUERY_GETTER_NAME = "getChildDeviceList" # This module is unusual in that QUERY_MODULE_NAME in the response is not diff --git a/kasa/experimental/smartcamera.py b/kasa/experimental/smartcamera.py index 059bac8e0..feadf87bb 100644 --- a/kasa/experimental/smartcamera.py +++ b/kasa/experimental/smartcamera.py @@ -43,18 +43,16 @@ def _update_children_info(self) -> None: for info in child_info["child_device_list"]: self._children[info["device_id"]]._update_internal_state(info) - async def _initialize_smart_child(self, info: dict) -> SmartDevice: + async def _initialize_smart_child( + self, info: dict, child_components: dict + ) -> SmartDevice: """Initialize a smart child device attached to a smartcamera.""" child_id = info["device_id"] child_protocol = _ChildCameraProtocolWrapper(child_id, self.protocol) try: initial_response = await child_protocol.query( - {"component_nego": None, "get_connect_cloud_state": None} + {"get_connect_cloud_state": None} ) - child_components = { - item["id"]: item["ver_code"] - for item in initial_response["component_nego"]["component_list"] - } except Exception as ex: _LOGGER.exception("Error initialising child %s: %s", child_id, ex) @@ -68,20 +66,28 @@ async def _initialize_smart_child(self, info: dict) -> SmartDevice: async def _initialize_children(self) -> None: """Initialize children for hubs.""" - if not ( - child_info := self._try_get_response( - self._last_update, "getChildDeviceList", {} - ) - ): - return + child_info_query = { + "getChildDeviceList": {"childControl": {"start_index": 0}}, + "getChildDeviceComponentList": {"childControl": {"start_index": 0}}, + } + resp = await self.protocol.query(child_info_query) + self.internal_state.update(resp) + children_components = { + child["device_id"]: { + comp["id"]: int(comp["ver_code"]) for comp in child["component_list"] + } + for child in resp["getChildDeviceComponentList"]["child_component_list"] + } children = {} - for info in child_info["child_device_list"]: + for info in resp["getChildDeviceList"]["child_device_list"]: if ( category := info.get("category") ) and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP: child_id = info["device_id"] - children[child_id] = await self._initialize_smart_child(info) + children[child_id] = await self._initialize_smart_child( + info, children_components[child_id] + ) else: _LOGGER.debug("Child device type not supported: %s", info) @@ -90,6 +96,11 @@ async def _initialize_children(self) -> None: async def _initialize_modules(self) -> None: """Initialize modules based on component negotiation response.""" for mod in SmartCameraModule.REGISTERED_MODULES.values(): + if ( + mod.REQUIRED_COMPONENT + and mod.REQUIRED_COMPONENT not in self._components + ): + continue module = mod(self, mod._module_name()) if await module._check_supported(): self._modules[module.name] = module @@ -126,12 +137,21 @@ async def _negotiate(self) -> None: """ initial_query = { "getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}}, - "getChildDeviceList": {"childControl": {"start_index": 0}}, + "getAppComponentList": {"app_component": {"name": "app_component_list"}}, } resp = await self.protocol.query(initial_query) self._last_update.update(resp) self._update_internal_info(resp) - await self._initialize_children() + + self._components = { + comp["name"]: int(comp["version"]) + for comp in resp["getAppComponentList"]["app_component"][ + "app_component_list" + ] + } + + if "childControl" in self._components and not self.children: + await self._initialize_children() def _map_info(self, device_info: dict) -> dict: basic_info = device_info["basic_info"] diff --git a/kasa/experimental/smartcameramodule.py b/kasa/experimental/smartcameramodule.py index bfb42fc05..217b69e71 100644 --- a/kasa/experimental/smartcameramodule.py +++ b/kasa/experimental/smartcameramodule.py @@ -74,7 +74,7 @@ def data(self) -> dict: if isinstance(query_resp, SmartErrorCode): raise DeviceError( f"Error accessing module data in {self._module}", - error_code=SmartErrorCode, + error_code=query_resp, ) if not query_resp: @@ -95,6 +95,6 @@ def data(self) -> dict: if isinstance(found[key], SmartErrorCode): raise DeviceError( f"Error accessing module data {key} in {self._module}", - error_code=SmartErrorCode, + error_code=found[key], ) return found From 9efe8718141e7aecdb17d488d4579589c28886f8 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 13 Nov 2024 14:56:41 +0000 Subject: [PATCH 669/892] Consolidate warnings for fixtures missing child devices (#1251) --- tests/fakeprotocol_smart.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index ce60a61ba..0a761ebc0 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -200,10 +200,9 @@ def try_get_child_fixture_info(child_dev_info): is_child=True, ) else: - warn( - f"Could not find child SMART fixture for {child_info}", - stacklevel=2, - ) + pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] + parent_fixture_name, set() + ).add("child_devices") else: warn( f"Child is a cameraprotocol which needs to be implemented {child_info}", From 157ad8e8071c2ca2fd4c3584660b03b12c445147 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 13 Nov 2024 14:57:42 +0000 Subject: [PATCH 670/892] Update cli energy command to use energy module (#1252) --- kasa/cli/usage.py | 33 ++++++++++++++------------------- tests/test_cli.py | 19 +++++++++++-------- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/kasa/cli/usage.py b/kasa/cli/usage.py index 314182fdd..90a0fa78c 100644 --- a/kasa/cli/usage.py +++ b/kasa/cli/usage.py @@ -9,11 +9,9 @@ from kasa import ( Device, + Module, ) -from kasa.iot import ( - IotDevice, -) -from kasa.iot.iotstrip import IotStripPlug +from kasa.interfaces import Energy from kasa.iot.modules import Usage from .common import ( @@ -49,42 +47,39 @@ async def energy(dev: Device, year, month, erase): Daily and monthly data provided in CSV format. """ echo("[bold]== Emeter ==[/bold]") - if not dev.has_emeter: - error("Device has no emeter") + if not (energy := dev.modules.get(Module.Energy)): + error("Device has no energy module.") return - if (year or month or erase) and not isinstance(dev, IotDevice): - error("Device has no historical statistics") + if (year or month or erase) and not energy.supports( + Energy.ModuleFeature.PERIODIC_STATS + ): + error("Device does not support historical statistics") return - else: - dev = cast(IotDevice, dev) if erase: echo("Erasing emeter statistics..") - return await dev.erase_emeter_stats() + return await energy.erase_stats() if year: echo(f"== For year {year.year} ==") echo("Month, usage (kWh)") - usage_data = await dev.get_emeter_monthly(year=year.year) + usage_data = await energy.get_monthly_stats(year=year.year) elif month: echo(f"== For month {month.month} of {month.year} ==") echo("Day, usage (kWh)") - usage_data = await dev.get_emeter_daily(year=month.year, month=month.month) + usage_data = await energy.get_daily_stats(year=month.year, month=month.month) else: # Call with no argument outputs summary data and returns - if isinstance(dev, IotStripPlug): - emeter_status = await dev.get_emeter_realtime() - else: - emeter_status = dev.emeter_realtime + emeter_status = await energy.get_status() echo("Current: {} A".format(emeter_status["current"])) echo("Voltage: {} V".format(emeter_status["voltage"])) echo("Power: {} W".format(emeter_status["power"])) echo("Total consumption: {} kWh".format(emeter_status["total"])) - echo(f"Today: {dev.emeter_today} kWh") - echo(f"This month: {dev.emeter_this_month} kWh") + echo(f"Today: {energy.consumption_today} kWh") + echo(f"This month: {energy.consumption_this_month} kWh") return emeter_status diff --git a/tests/test_cli.py b/tests/test_cli.py index b6bcdfd4a..24fc916f2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15,6 +15,7 @@ Credentials, Device, DeviceError, + DeviceType, EmeterStatus, KasaException, Module, @@ -424,20 +425,22 @@ async def test_time_set(dev: Device, mocker, runner): async def test_emeter(dev: Device, mocker, runner): res = await runner.invoke(emeter, obj=dev) - if not dev.has_emeter: - assert "Device has no emeter" in res.output + if not (energy := dev.modules.get(Module.Energy)): + assert "Device has no energy module." in res.output return assert "== Emeter ==" in res.output - if not dev.is_strip: + if dev.device_type is not DeviceType.Strip: res = await runner.invoke(emeter, ["--index", "0"], obj=dev) assert f"Device: {dev.host} does not have children" in res.output res = await runner.invoke(emeter, ["--name", "mock"], obj=dev) assert f"Device: {dev.host} does not have children" in res.output - if dev.is_strip and len(dev.children) > 0: - realtime_emeter = mocker.patch.object(dev.children[0], "get_emeter_realtime") + if dev.device_type is DeviceType.Strip and len(dev.children) > 0: + child_energy = dev.children[0].modules.get(Module.Energy) + assert child_energy + realtime_emeter = mocker.patch.object(child_energy, "get_status") realtime_emeter.return_value = EmeterStatus({"voltage_mv": 122066}) res = await runner.invoke(emeter, ["--index", "0"], obj=dev) @@ -450,18 +453,18 @@ async def test_emeter(dev: Device, mocker, runner): assert realtime_emeter.call_count == 2 if isinstance(dev, IotDevice): - monthly = mocker.patch.object(dev, "get_emeter_monthly") + monthly = mocker.patch.object(energy, "get_monthly_stats") monthly.return_value = {1: 1234} res = await runner.invoke(emeter, ["--year", "1900"], obj=dev) if not isinstance(dev, IotDevice): - assert "Device has no historical statistics" in res.output + assert "Device does not support historical statistics" in res.output return assert "For year" in res.output assert "1, 1234" in res.output monthly.assert_called_with(year=1900) if isinstance(dev, IotDevice): - daily = mocker.patch.object(dev, "get_emeter_daily") + daily = mocker.patch.object(energy, "get_daily_stats") daily.return_value = {1: 1234} res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev) if not isinstance(dev, IotDevice): From a82ee56a273130778b064ed6224e8ae891d8afbf Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 13 Nov 2024 17:10:06 +0100 Subject: [PATCH 671/892] Fix warnings in our test suite (#1246) Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- kasa/device.py | 19 +++-- tests/fakeprotocol_smart.py | 13 ++-- tests/smart/modules/test_contact.py | 4 +- tests/smart/modules/test_light_effect.py | 2 +- .../smart/modules/test_light_strip_effect.py | 2 +- tests/smart/modules/test_motionsensor.py | 4 +- tests/test_bulb.py | 77 +++++++++++-------- tests/test_device.py | 26 +++++-- tests/test_dimmer.py | 54 ++++++++----- tests/test_discovery.py | 13 ++-- tests/test_emeter.py | 47 +++++------ tests/test_iotdevice.py | 14 ++-- tests/test_lightstrip.py | 31 ++++---- tests/test_plug.py | 38 ++++----- tests/test_smartdevice.py | 6 +- tests/test_smartprotocol.py | 2 + 16 files changed, 197 insertions(+), 155 deletions(-) diff --git a/kasa/device.py b/kasa/device.py index acb3af8c1..80139b68a 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -484,11 +484,11 @@ def __repr__(self) -> str: _deprecated_device_type_attributes = { # is_type - "is_bulb": (Module.Light, DeviceType.Bulb), - "is_dimmer": (Module.Light, DeviceType.Dimmer), - "is_light_strip": (Module.LightEffect, DeviceType.LightStrip), - "is_plug": (Module.Led, DeviceType.Plug), - "is_wallswitch": (Module.Led, DeviceType.WallSwitch), + "is_bulb": (None, DeviceType.Bulb), + "is_dimmer": (None, DeviceType.Dimmer), + "is_light_strip": (None, DeviceType.LightStrip), + "is_plug": (None, DeviceType.Plug), + "is_wallswitch": (None, DeviceType.WallSwitch), "is_strip": (None, DeviceType.Strip), "is_strip_socket": (None, DeviceType.StripSocket), } @@ -503,7 +503,9 @@ def _get_replacing_attr( return None for attr in attrs: - if hasattr(check, attr): + # Use dir() as opposed to hasattr() to avoid raising exceptions + # from properties + if attr in dir(check): return attr return None @@ -552,10 +554,7 @@ def _get_replacing_attr( def __getattr__(self, name: str) -> Any: # is_device_type if dep_device_type_attr := self._deprecated_device_type_attributes.get(name): - module = dep_device_type_attr[0] - msg = f"{name} is deprecated" - if module: - msg += f", use: {module} in device.modules instead" + msg = f"{name} is deprecated, use device_type property instead" warn(msg, DeprecationWarning, stacklevel=2) return self.device_type == dep_device_type_attr[1] # Other deprecated attributes diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index 0a761ebc0..bde908851 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -33,6 +33,7 @@ def __init__( warn_fixture_missing_methods=True, fix_incomplete_fixture_lists=True, is_child=False, + get_child_fixtures=True, ): super().__init__( config=DeviceConfig( @@ -48,9 +49,10 @@ def __init__( # child are then still reflected on the parent's lis of child device in if not is_child: self.info = copy.deepcopy(info) - self.child_protocols = self._get_child_protocols( - self.info, self.fixture_name, "get_child_device_list" - ) + if get_child_fixtures: + self.child_protocols = self._get_child_protocols( + self.info, self.fixture_name, "get_child_device_list" + ) else: self.info = info if not component_nego_not_included: @@ -220,10 +222,7 @@ async def _handle_control_child(self, params: dict): """Handle control_child command.""" device_id = params.get("device_id") if device_id not in self.child_protocols: - warn( - f"Could not find child fixture {device_id} in {self.fixture_name}", - stacklevel=2, - ) + # no need to warn as the warning was raised during protocol init return self._handle_control_child_missing(params) child_protocol: SmartProtocol = self.child_protocols[device_id] diff --git a/tests/smart/modules/test_contact.py b/tests/smart/modules/test_contact.py index 56287e2a3..c5c4c935f 100644 --- a/tests/smart/modules/test_contact.py +++ b/tests/smart/modules/test_contact.py @@ -1,6 +1,6 @@ import pytest -from kasa import Module, SmartDevice +from kasa import Device, Module from ...device_fixtures import parametrize @@ -16,7 +16,7 @@ ("is_open", bool), ], ) -async def test_contact_features(dev: SmartDevice, feature, type): +async def test_contact_features(dev: Device, feature, type): """Test that features are registered and work as expected.""" contact = dev.modules.get(Module.ContactSensor) assert contact is not None diff --git a/tests/smart/modules/test_light_effect.py b/tests/smart/modules/test_light_effect.py index a48b29add..e4475652c 100644 --- a/tests/smart/modules/test_light_effect.py +++ b/tests/smart/modules/test_light_effect.py @@ -71,7 +71,7 @@ async def test_light_effect_brightness( if effect_active: assert light_effect.is_active - assert light_effect.brightness == dev.brightness + assert light_effect.brightness == light_module.brightness light_effect_set_brightness.assert_called_with(10) mock_light_effect_call.assert_called_with( diff --git a/tests/smart/modules/test_light_strip_effect.py b/tests/smart/modules/test_light_strip_effect.py index a3db847e3..81bc35c83 100644 --- a/tests/smart/modules/test_light_strip_effect.py +++ b/tests/smart/modules/test_light_strip_effect.py @@ -86,7 +86,7 @@ async def test_light_effect_brightness( if effect_active: assert light_effect.is_active - assert light_effect.brightness == dev.brightness + assert light_effect.brightness == light_module.brightness light_effect_set_brightness.assert_called_with(10) mock_light_effect_call.assert_called_with( diff --git a/tests/smart/modules/test_motionsensor.py b/tests/smart/modules/test_motionsensor.py index 91119a759..418ad51a1 100644 --- a/tests/smart/modules/test_motionsensor.py +++ b/tests/smart/modules/test_motionsensor.py @@ -1,6 +1,6 @@ import pytest -from kasa import Module, SmartDevice +from kasa import Device, Module from ...device_fixtures import parametrize @@ -16,7 +16,7 @@ ("motion_detected", bool), ], ) -async def test_motion_features(dev: SmartDevice, feature, type): +async def test_motion_features(dev: Device, feature, type): """Test that features are registered and work as expected.""" motion = dev.modules.get(Module.MotionSensor) assert motion is not None diff --git a/tests/test_bulb.py b/tests/test_bulb.py index 64c012fd7..53a3542a3 100644 --- a/tests/test_bulb.py +++ b/tests/test_bulb.py @@ -13,6 +13,7 @@ from kasa import Device, DeviceType, IotLightPreset, KasaException, LightState, Module from kasa.iot import IotBulb, IotDimmer +from kasa.iot.modules import LightPreset as IotLightPresetModule from .conftest import ( bulb, @@ -39,11 +40,6 @@ async def test_bulb_sysinfo(dev: Device): assert dev.model is not None - # TODO: remove special handling for lightstrip - if not dev.is_light_strip: - assert dev.device_type == DeviceType.Bulb - assert dev.is_bulb - @bulb async def test_state_attributes(dev: Device): @@ -88,7 +84,9 @@ async def test_hsv(dev: Device, turn_on): @color_bulb_iot async def test_set_hsv_transition(dev: IotBulb, mocker): set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") - await dev.set_hsv(10, 10, 100, transition=1000) + light = dev.modules.get(Module.Light) + assert light + await light.set_hsv(10, 10, 100, transition=1000) set_light_state.assert_called_with( {"hue": 10, "saturation": 10, "brightness": 100, "color_temp": 0}, @@ -226,7 +224,9 @@ async def test_try_set_colortemp(dev: Device, turn_on): @variable_temp_iot async def test_set_color_temp_transition(dev: IotBulb, mocker): set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") - await dev.set_color_temp(2700, transition=100) + light = dev.modules.get(Module.Light) + assert light + await light.set_color_temp(2700, transition=100) set_light_state.assert_called_with({"color_temp": 2700}, transition=100) @@ -234,8 +234,9 @@ async def test_set_color_temp_transition(dev: IotBulb, mocker): @variable_temp_iot async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") - - assert dev.valid_temperature_range == (2700, 5000) + light = dev.modules.get(Module.Light) + assert light + assert light.valid_temperature_range == (2700, 5000) assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text @@ -278,19 +279,21 @@ async def test_non_variable_temp(dev: Device): @turn_on async def test_dimmable_brightness(dev: IotBulb, turn_on): assert isinstance(dev, (IotBulb, IotDimmer)) + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) assert dev._is_dimmable - await dev.set_brightness(50) + await light.set_brightness(50) await dev.update() - assert dev.brightness == 50 + assert light.brightness == 50 - await dev.set_brightness(10) + await light.set_brightness(10) await dev.update() - assert dev.brightness == 10 + assert light.brightness == 10 with pytest.raises(TypeError, match="Brightness must be an integer"): - await dev.set_brightness("foo") # type: ignore[arg-type] + await light.set_brightness("foo") # type: ignore[arg-type] @bulb_iot @@ -308,7 +311,9 @@ async def test_turn_on_transition(dev: IotBulb, mocker): @bulb_iot async def test_dimmable_brightness_transition(dev: IotBulb, mocker): set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") - await dev.set_brightness(10, transition=1000) + light = dev.modules.get(Module.Light) + assert light + await light.set_brightness(10, transition=1000) set_light_state.assert_called_with({"brightness": 10, "on_off": 1}, transition=1000) @@ -316,28 +321,30 @@ async def test_dimmable_brightness_transition(dev: IotBulb, mocker): @dimmable_iot async def test_invalid_brightness(dev: IotBulb): assert dev._is_dimmable - + light = dev.modules.get(Module.Light) + assert light with pytest.raises( ValueError, match=re.escape("Invalid brightness value: 110 (valid range: 0-100%)"), ): - await dev.set_brightness(110) + await light.set_brightness(110) with pytest.raises( ValueError, match=re.escape("Invalid brightness value: -100 (valid range: 0-100%)"), ): - await dev.set_brightness(-100) + await light.set_brightness(-100) @non_dimmable_iot async def test_non_dimmable(dev: IotBulb): assert not dev._is_dimmable - + light = dev.modules.get(Module.Light) + assert light with pytest.raises(KasaException): - assert dev.brightness == 0 + assert light.brightness == 0 with pytest.raises(KasaException): - await dev.set_brightness(100) + await light.set_brightness(100) @bulb_iot @@ -357,7 +364,10 @@ async def test_ignore_default_not_set_without_color_mode_change_turn_on( @bulb_iot async def test_list_presets(dev: IotBulb): - presets = dev.presets + light_preset = dev.modules.get(Module.LightPreset) + assert light_preset + assert isinstance(light_preset, IotLightPresetModule) + presets = light_preset._deprecated_presets # Light strip devices may list some light effects along with normal presets but these # are handled by the LightEffect module so exclude preferred states with id raw_presets = [ @@ -376,9 +386,13 @@ async def test_list_presets(dev: IotBulb): @bulb_iot async def test_modify_preset(dev: IotBulb, mocker): """Verify that modifying preset calls the and exceptions are raised properly.""" - if not dev.presets: + if ( + not (light_preset := dev.modules.get(Module.LightPreset)) + or not light_preset._deprecated_presets + ): pytest.skip("Some strips do not support presets") + assert isinstance(light_preset, IotLightPresetModule) data: dict[str, int | None] = { "index": 0, "brightness": 10, @@ -394,12 +408,12 @@ async def test_modify_preset(dev: IotBulb, mocker): assert preset.saturation == 0 assert preset.color_temp == 0 - await dev.save_preset(preset) + await light_preset._deprecated_save_preset(preset) await dev.update() - assert dev.presets[0].brightness == 10 + assert light_preset._deprecated_presets[0].brightness == 10 with pytest.raises(KasaException): - await dev.save_preset( + await light_preset._deprecated_save_preset( IotLightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) # type: ignore[call-arg] ) @@ -420,11 +434,14 @@ async def test_modify_preset(dev: IotBulb, mocker): ) async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): """Test that modify preset payloads ignore none values.""" - if not dev.presets: + if ( + not (light_preset := dev.modules.get(Module.LightPreset)) + or not light_preset._deprecated_presets + ): pytest.skip("Some strips do not support presets") query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") - await dev.save_preset(preset) + await light_preset._deprecated_save_preset(preset) query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload) @@ -476,6 +493,4 @@ async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): @bulb def test_device_type_bulb(dev: Device): - if dev.is_light_strip: - pytest.skip("bulb has also lightstrips to test the api") - assert dev.device_type == DeviceType.Bulb + assert dev.device_type in {DeviceType.Bulb, DeviceType.LightStrip} diff --git a/tests/test_device.py b/tests/test_device.py index 2b9d970a4..5f527287a 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -6,7 +6,7 @@ import inspect import pkgutil import sys -from contextlib import AbstractContextManager +from contextlib import AbstractContextManager, nullcontext from unittest.mock import AsyncMock, patch import pytest @@ -170,15 +170,22 @@ async def _test_attribute( dev: Device, attribute_name, is_expected, module_name, *args, will_raise=False ): if is_expected and will_raise: - ctx: AbstractContextManager = pytest.raises(will_raise) + ctx: AbstractContextManager | nullcontext = pytest.raises(will_raise) + dep_context: pytest.WarningsRecorder | nullcontext = pytest.deprecated_call( + match=(f"{attribute_name} is deprecated, use:") + ) elif is_expected: - ctx = pytest.deprecated_call(match=(f"{attribute_name} is deprecated, use:")) + ctx = nullcontext() + dep_context = pytest.deprecated_call( + match=(f"{attribute_name} is deprecated, use:") + ) else: ctx = pytest.raises( AttributeError, match=f"Device has no attribute '{attribute_name}'" ) + dep_context = nullcontext() - with ctx: + with dep_context, ctx: if args: await getattr(dev, attribute_name)(*args) else: @@ -267,16 +274,19 @@ async def test_deprecated_light_preset_attributes(dev: Device): await _test_attribute(dev, "presets", bool(preset), "LightPreset", will_raise=exc) exc = None + is_expected = bool(preset) # deprecated save_preset not implemented for smart devices as it's unlikely anyone # has an existing reliance on this for the newer devices. - if not preset or isinstance(dev, SmartDevice): - exc = AttributeError - elif len(preset.preset_states_list) == 0: + if isinstance(dev, SmartDevice): + is_expected = False + + if preset and len(preset.preset_states_list) == 0: exc = KasaException + await _test_attribute( dev, "save_preset", - bool(preset), + is_expected, "LightPreset", IotLightPreset(index=0, hue=100, brightness=100, saturation=0, color_temp=0), # type: ignore[call-arg] will_raise=exc, diff --git a/tests/test_dimmer.py b/tests/test_dimmer.py index 5d1d10e53..3505a7c1c 100644 --- a/tests/test_dimmer.py +++ b/tests/test_dimmer.py @@ -1,6 +1,6 @@ import pytest -from kasa import DeviceType +from kasa import DeviceType, Module from kasa.iot import IotDimmer from .conftest import dimmer_iot, handle_turn_on, turn_on @@ -8,28 +8,32 @@ @dimmer_iot async def test_set_brightness(dev): + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, False) await dev.update() assert dev.is_on is False - await dev.set_brightness(99) + await light.set_brightness(99) await dev.update() - assert dev.brightness == 99 + assert light.brightness == 99 assert dev.is_on is True - await dev.set_brightness(0) + await light.set_brightness(0) await dev.update() - assert dev.brightness == 99 + assert light.brightness == 99 assert dev.is_on is False @dimmer_iot @turn_on async def test_set_brightness_transition(dev, turn_on, mocker): + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) query_helper = mocker.spy(IotDimmer, "_query_helper") - await dev.set_brightness(99, transition=1000) + await light.set_brightness(99, transition=1000) query_helper.assert_called_with( mocker.ANY, "smartlife.iot.dimmer", @@ -37,39 +41,45 @@ async def test_set_brightness_transition(dev, turn_on, mocker): {"brightness": 99, "duration": 1000}, ) await dev.update() - assert dev.brightness == 99 + assert light.brightness == 99 assert dev.is_on - await dev.set_brightness(0, transition=1000) + await light.set_brightness(0, transition=1000) await dev.update() assert dev.is_on is False @dimmer_iot async def test_set_brightness_invalid(dev): + light = dev.modules.get(Module.Light) + assert light for invalid_brightness in [-1, 101]: with pytest.raises(ValueError, match="Invalid brightness"): - await dev.set_brightness(invalid_brightness) + await light.set_brightness(invalid_brightness) for invalid_type in [0.5, "foo"]: with pytest.raises(TypeError, match="Brightness must be an integer"): - await dev.set_brightness(invalid_type) + await light.set_brightness(invalid_type) @dimmer_iot async def test_set_brightness_invalid_transition(dev): + light = dev.modules.get(Module.Light) + assert light for invalid_transition in [-1]: with pytest.raises(ValueError, match="Transition value .+? is not valid."): - await dev.set_brightness(1, transition=invalid_transition) + await light.set_brightness(1, transition=invalid_transition) for invalid_type in [0.5, "foo"]: with pytest.raises(TypeError, match="Transition must be integer"): - await dev.set_brightness(1, transition=invalid_type) + await light.set_brightness(1, transition=invalid_type) @dimmer_iot async def test_turn_on_transition(dev, mocker): + light = dev.modules.get(Module.Light) + assert light query_helper = mocker.spy(IotDimmer, "_query_helper") - original_brightness = dev.brightness + original_brightness = light.brightness await dev.turn_on(transition=1000) query_helper.assert_called_with( @@ -80,20 +90,22 @@ async def test_turn_on_transition(dev, mocker): ) await dev.update() assert dev.is_on - assert dev.brightness == original_brightness + assert light.brightness == original_brightness @dimmer_iot async def test_turn_off_transition(dev, mocker): + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, True) query_helper = mocker.spy(IotDimmer, "_query_helper") - original_brightness = dev.brightness + original_brightness = light.brightness await dev.turn_off(transition=1000) await dev.update() assert dev.is_off - assert dev.brightness == original_brightness + assert light.brightness == original_brightness query_helper.assert_called_with( mocker.ANY, "smartlife.iot.dimmer", @@ -105,6 +117,8 @@ async def test_turn_off_transition(dev, mocker): @dimmer_iot @turn_on async def test_set_dimmer_transition(dev, turn_on, mocker): + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) query_helper = mocker.spy(IotDimmer, "_query_helper") @@ -117,21 +131,23 @@ async def test_set_dimmer_transition(dev, turn_on, mocker): ) await dev.update() assert dev.is_on - assert dev.brightness == 99 + assert light.brightness == 99 @dimmer_iot @turn_on async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) - original_brightness = dev.brightness + original_brightness = light.brightness query_helper = mocker.spy(IotDimmer, "_query_helper") await dev.set_dimmer_transition(0, 1000) await dev.update() assert dev.is_off - assert dev.brightness == original_brightness + assert light.brightness == original_brightness query_helper.assert_called_with( mocker.ANY, "smartlife.iot.dimmer", diff --git a/tests/test_discovery.py b/tests/test_discovery.py index aeda423ea..8d4582b06 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -81,14 +81,14 @@ @wallswitch_iot async def test_type_detection_switch(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") - assert d.is_wallswitch - assert d.device_type == DeviceType.WallSwitch + with pytest.deprecated_call(match="use device_type property instead"): + assert d.is_wallswitch + assert d.device_type is DeviceType.WallSwitch @plug_iot async def test_type_detection_plug(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") - assert d.is_plug assert d.device_type == DeviceType.Plug @@ -96,29 +96,26 @@ async def test_type_detection_plug(dev: Device): async def test_type_detection_bulb(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") # TODO: light_strip is a special case for now to force bulb tests on it - if not d.is_light_strip: - assert d.is_bulb + + if d.device_type is not DeviceType.LightStrip: assert d.device_type == DeviceType.Bulb @strip_iot async def test_type_detection_strip(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") - assert d.is_strip assert d.device_type == DeviceType.Strip @dimmer_iot async def test_type_detection_dimmer(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") - assert d.is_dimmer assert d.device_type == DeviceType.Dimmer @lightstrip_iot async def test_type_detection_lightstrip(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") - assert d.is_light_strip assert d.device_type == DeviceType.LightStrip diff --git a/tests/test_emeter.py b/tests/test_emeter.py index d5a35758d..4829ff0ca 100644 --- a/tests/test_emeter.py +++ b/tests/test_emeter.py @@ -10,7 +10,7 @@ Schema, ) -from kasa import Device, EmeterStatus, Module +from kasa import Device, DeviceType, EmeterStatus, Module from kasa.interfaces.energy import Energy from kasa.iot import IotDevice, IotStrip from kasa.iot.modules.emeter import Emeter @@ -61,20 +61,20 @@ async def test_get_emeter_realtime(dev): if not await mod._check_supported(): pytest.skip(f"Energy module not supported for {dev}.") - assert dev.has_emeter + emeter = dev.modules[Module.Energy] - current_emeter = await dev.get_emeter_realtime() + current_emeter = await emeter.get_status() CURRENT_CONSUMPTION_SCHEMA(current_emeter) @has_emeter_iot @pytest.mark.requires_dummy() async def test_get_emeter_daily(dev): - assert dev.has_emeter + emeter = dev.modules[Module.Energy] - assert await dev.get_emeter_daily(year=1900, month=1) == {} + assert await emeter.get_daily_stats(year=1900, month=1) == {} - d = await dev.get_emeter_daily() + d = await emeter.get_daily_stats() assert len(d) > 0 k, v = d.popitem() @@ -82,7 +82,7 @@ async def test_get_emeter_daily(dev): assert isinstance(v, float) # Test kwh (energy, energy_wh) - d = await dev.get_emeter_daily(kwh=False) + d = await emeter.get_daily_stats(kwh=False) k2, v2 = d.popitem() assert v * 1000 == v2 @@ -90,11 +90,11 @@ async def test_get_emeter_daily(dev): @has_emeter_iot @pytest.mark.requires_dummy() async def test_get_emeter_monthly(dev): - assert dev.has_emeter + emeter = dev.modules[Module.Energy] - assert await dev.get_emeter_monthly(year=1900) == {} + assert await emeter.get_monthly_stats(year=1900) == {} - d = await dev.get_emeter_monthly() + d = await emeter.get_monthly_stats() assert len(d) > 0 k, v = d.popitem() @@ -102,23 +102,26 @@ async def test_get_emeter_monthly(dev): assert isinstance(v, float) # Test kwh (energy, energy_wh) - d = await dev.get_emeter_monthly(kwh=False) + d = await emeter.get_monthly_stats(kwh=False) k2, v2 = d.popitem() assert v * 1000 == v2 @has_emeter_iot async def test_emeter_status(dev): - assert dev.has_emeter + emeter = dev.modules[Module.Energy] - d = await dev.get_emeter_realtime() + d = await emeter.get_status() with pytest.raises(KeyError): assert d["foo"] assert d["power_mw"] == d["power"] * 1000 # bulbs have only power according to tplink simulator. - if not dev.is_bulb and not dev.is_light_strip: + if ( + dev.device_type is not DeviceType.Bulb + and dev.device_type is not DeviceType.LightStrip + ): assert d["voltage_mv"] == d["voltage"] * 1000 assert d["current_ma"] == d["current"] * 1000 @@ -128,19 +131,17 @@ async def test_emeter_status(dev): @pytest.mark.skip("not clearing your stats..") @has_emeter async def test_erase_emeter_stats(dev): - assert dev.has_emeter + emeter = dev.modules[Module.Energy] - await dev.erase_emeter() + await emeter.erase_emeter() @has_emeter_iot async def test_current_consumption(dev): - if dev.has_emeter: - x = dev.current_consumption - assert isinstance(x, float) - assert x >= 0.0 - else: - assert dev.current_consumption is None + emeter = dev.modules[Module.Energy] + x = emeter.current_consumption + assert isinstance(x, float) + assert x >= 0.0 async def test_emeterstatus_missing_current(): @@ -180,7 +181,7 @@ def data(self): emeter_data["get_daystat"]["day_list"].append( {"day": now.day, "energy_wh": 500, "month": now.month, "year": now.year} ) - assert emeter.emeter_today == 0.500 + assert emeter.consumption_today == 0.500 @has_emeter diff --git a/tests/test_iotdevice.py b/tests/test_iotdevice.py index dd401ac99..a22ed6cef 100644 --- a/tests/test_iotdevice.py +++ b/tests/test_iotdevice.py @@ -16,7 +16,7 @@ Schema, ) -from kasa import KasaException, Module +from kasa import DeviceType, KasaException, Module from kasa.iot import IotDevice from kasa.iot.iotmodule import _merge_dict @@ -92,10 +92,8 @@ async def test_state_info(dev): @pytest.mark.requires_dummy() @device_iot async def test_invalid_connection(mocker, dev): - with ( - mocker.patch.object(FakeIotProtocol, "query", side_effect=KasaException), - pytest.raises(KasaException), - ): + mocker.patch.object(FakeIotProtocol, "query", side_effect=KasaException) + with pytest.raises(KasaException): await dev.update() @@ -169,7 +167,7 @@ async def test_state(dev, turn_on): async def test_on_since(dev, turn_on): await handle_turn_on(dev, turn_on) orig_state = dev.is_on - if "on_time" not in dev.sys_info and not dev.is_strip: + if "on_time" not in dev.sys_info and dev.device_type is not DeviceType.Strip: assert dev.on_since is None elif orig_state: assert isinstance(dev.on_since, datetime) @@ -179,7 +177,7 @@ async def test_on_since(dev, turn_on): @device_iot async def test_time(dev): - assert isinstance(await dev.get_time(), datetime) + assert isinstance(dev.modules[Module.Time].time, datetime) @device_iot @@ -216,7 +214,7 @@ async def test_representation(dev): @device_iot async def test_children(dev): """Make sure that children property is exposed by every device.""" - if dev.is_strip: + if dev.device_type is DeviceType.Strip: assert len(dev.children) > 0 else: assert len(dev.children) == 0 diff --git a/tests/test_lightstrip.py b/tests/test_lightstrip.py index c72f10ed0..365d0163d 100644 --- a/tests/test_lightstrip.py +++ b/tests/test_lightstrip.py @@ -1,35 +1,37 @@ import pytest -from kasa import DeviceType +from kasa import DeviceType, Module from kasa.iot import IotLightStrip +from kasa.iot.modules import LightEffect from .conftest import lightstrip_iot @lightstrip_iot async def test_lightstrip_length(dev: IotLightStrip): - assert dev.is_light_strip assert dev.device_type == DeviceType.LightStrip assert dev.length == dev.sys_info["length"] @lightstrip_iot async def test_lightstrip_effect(dev: IotLightStrip): - assert isinstance(dev.effect, dict) + le: LightEffect = dev.modules[Module.LightEffect] + assert isinstance(le._deprecated_effect, dict) for k in ["brightness", "custom", "enable", "id", "name"]: - assert k in dev.effect + assert k in le._deprecated_effect @lightstrip_iot async def test_effects_lightstrip_set_effect(dev: IotLightStrip): + le: LightEffect = dev.modules[Module.LightEffect] with pytest.raises( ValueError, match="The effect Not real is not a built in effect" ): - await dev.set_effect("Not real") + await le.set_effect("Not real") - await dev.set_effect("Candy Cane") + await le.set_effect("Candy Cane") await dev.update() - assert dev.effect["name"] == "Candy Cane" + assert le.effect == "Candy Cane" @lightstrip_iot @@ -38,12 +40,13 @@ async def test_effects_lightstrip_set_effect_brightness( dev: IotLightStrip, brightness, mocker ): query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper") + le: LightEffect = dev.modules[Module.LightEffect] # test that default brightness works (100 for candy cane) if brightness == 100: - await dev.set_effect("Candy Cane") + await le.set_effect("Candy Cane") else: - await dev.set_effect("Candy Cane", brightness=brightness) + await le.set_effect("Candy Cane", brightness=brightness) args, kwargs = query_helper.call_args_list[0] payload = args[2] @@ -56,12 +59,13 @@ async def test_effects_lightstrip_set_effect_transition( dev: IotLightStrip, transition, mocker ): query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper") + le: LightEffect = dev.modules[Module.LightEffect] # test that default (500 for candy cane) transition works if transition == 500: - await dev.set_effect("Candy Cane") + await le.set_effect("Candy Cane") else: - await dev.set_effect("Candy Cane", transition=transition) + await le.set_effect("Candy Cane", transition=transition) args, kwargs = query_helper.call_args_list[0] payload = args[2] @@ -70,8 +74,9 @@ async def test_effects_lightstrip_set_effect_transition( @lightstrip_iot async def test_effects_lightstrip_has_effects(dev: IotLightStrip): - assert dev.has_effects is True - assert dev.effect_list + le: LightEffect = dev.modules[Module.LightEffect] + assert le is not None + assert le.effect_list @lightstrip_iot diff --git a/tests/test_plug.py b/tests/test_plug.py index 8989c975f..795ebe55b 100644 --- a/tests/test_plug.py +++ b/tests/test_plug.py @@ -1,3 +1,5 @@ +import pytest + from kasa import DeviceType from .conftest import plug, plug_iot, plug_smart, switch_smart, wallswitch_iot @@ -16,7 +18,6 @@ async def test_plug_sysinfo(dev): assert dev.model is not None assert dev.device_type == DeviceType.Plug or dev.device_type == DeviceType.Strip - assert dev.is_plug or dev.is_strip @wallswitch_iot @@ -27,37 +28,38 @@ async def test_switch_sysinfo(dev): assert dev.model is not None assert dev.device_type == DeviceType.WallSwitch - assert dev.is_wallswitch @plug_iot async def test_plug_led(dev): - original = dev.led + with pytest.deprecated_call(match="use: Module.Led in device.modules instead"): + original = dev.led - await dev.set_led(False) - await dev.update() - assert not dev.led + await dev.set_led(False) + await dev.update() + assert not dev.led - await dev.set_led(True) - await dev.update() - assert dev.led + await dev.set_led(True) + await dev.update() + assert dev.led - await dev.set_led(original) + await dev.set_led(original) @wallswitch_iot async def test_switch_led(dev): - original = dev.led + with pytest.deprecated_call(match="use: Module.Led in device.modules instead"): + original = dev.led - await dev.set_led(False) - await dev.update() - assert not dev.led + await dev.set_led(False) + await dev.update() + assert not dev.led - await dev.set_led(True) - await dev.update() - assert dev.led + await dev.set_led(True) + await dev.update() + assert dev.led - await dev.set_led(original) + await dev.set_led(original) @plug_smart diff --git a/tests/test_smartdevice.py b/tests/test_smartdevice.py index 616db77e7..12c7349af 100644 --- a/tests/test_smartdevice.py +++ b/tests/test_smartdevice.py @@ -45,10 +45,8 @@ async def test_update_no_device_info(dev: SmartDevice, mocker: MockerFixture): "get_device_time": {}, } msg = f"get_device_info not found in {mock_response} for device 127.0.0.123" - with ( - mocker.patch.object(dev.protocol, "query", return_value=mock_response), - pytest.raises(KasaException, match=msg), - ): + mocker.patch.object(dev.protocol, "query", return_value=mock_response) + with pytest.raises(KasaException, match=msg): await dev.update() diff --git a/tests/test_smartprotocol.py b/tests/test_smartprotocol.py index ab68b34b5..c523fcdbc 100644 --- a/tests/test_smartprotocol.py +++ b/tests/test_smartprotocol.py @@ -325,6 +325,7 @@ async def test_smart_protocol_lists_single_request(mocker, list_sum, batch_size) "foobar", list_return_size=batch_size, component_nego_not_included=True, + get_child_fixtures=False, ) protocol = SmartProtocol(transport=ft) query_spy = mocker.spy(protocol, "_execute_query") @@ -357,6 +358,7 @@ async def test_smart_protocol_lists_multiple_request(mocker, list_sum, batch_siz "foobar", list_return_size=batch_size, component_nego_not_included=True, + get_child_fixtures=False, ) protocol = SmartProtocol(transport=ft) query_spy = mocker.spy(protocol, "_execute_query") From 1eaae37c5548e3074edd231ce4d49985399f784d Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 13 Nov 2024 18:42:45 +0100 Subject: [PATCH 672/892] Add linkcheck to readthedocs CI (#1253) --- .readthedocs.yml | 3 +++ README.md | 2 +- docs/source/contribute.md | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index e79a0598b..1d01cf18f 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -6,6 +6,9 @@ build: os: ubuntu-22.04 tools: python: "3" + jobs: + pre_build: + - python -m sphinx -b linkcheck docs/source/ $READTHEDOCS_OUTPUT/linkcheck python: install: diff --git a/README.md b/README.md index 0da595a84..60ff35a20 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ See [supported devices in our documentation](SUPPORTED.md) for more detailed inf ### Developer Resources -* [softScheck's github contains lot of information and wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) +* [softScheck's github contains lot of information and wireshark dissector](https://github.com/softScheck/tplink-smartplug) * [TP-Link Smart Home Device Simulator](https://github.com/plasticrake/tplink-smarthome-simulator) * [Unofficial API documentation](https://github.com/plasticrake/tplink-smarthome-api) * [Another unofficial API documentation](https://github.com/whitslack/kasa) diff --git a/docs/source/contribute.md b/docs/source/contribute.md index 2f735ce18..8a0603838 100644 --- a/docs/source/contribute.md +++ b/docs/source/contribute.md @@ -49,7 +49,7 @@ Note that this will perform state changes on the device. ## Analyzing network captures The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. -After capturing the traffic, you can either use the [softScheck's wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) +After capturing the traffic, you can either use the [softScheck's wireshark dissector](https://github.com/softScheck/tplink-smartplug) or the `parse_pcap.py` script contained inside the `devtools` directory. Note, that this works currently only on kasa-branded devices which use port 9999 for communications. From e55731c110270f58300191909ec4ecc9555c772b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 13 Nov 2024 17:50:21 +0000 Subject: [PATCH 673/892] Move protocol modules into protocols package (#1254) --- devtools/dump_devinfo.py | 2 +- devtools/parse_pcap_klap.py | 3 +-- docs/source/reference.md | 6 ++--- docs/source/topics.md | 32 ++++++++++++------------ kasa/__init__.py | 8 ++---- kasa/credentials.py | 17 +++++++++++++ kasa/device.py | 3 +-- kasa/device_factory.py | 6 ++--- kasa/discover.py | 4 +-- kasa/experimental/smartcameraprotocol.py | 2 +- kasa/experimental/sslaestransport.py | 3 +-- kasa/iot/iotbulb.py | 2 +- kasa/iot/iotdevice.py | 2 +- kasa/iot/iotdimmer.py | 2 +- kasa/iot/iotlightstrip.py | 2 +- kasa/iot/iotplug.py | 2 +- kasa/iot/iotstrip.py | 2 +- kasa/protocols/__init__.py | 12 +++++++++ kasa/{ => protocols}/iotprotocol.py | 10 ++++---- kasa/{ => protocols}/protocol.py | 20 ++------------- kasa/{ => protocols}/smartprotocol.py | 6 ++--- kasa/smart/smartchilddevice.py | 2 +- kasa/smart/smartdevice.py | 2 +- kasa/transports/aestransport.py | 3 +-- kasa/transports/klaptransport.py | 8 ++---- tests/fakeprotocol_iot.py | 2 +- tests/test_childdevice.py | 2 +- tests/test_klapprotocol.py | 6 ++--- tests/test_protocol.py | 4 +-- tests/test_smartdevice.py | 8 +++--- tests/test_smartprotocol.py | 2 +- tests/test_sslaestransport.py | 3 +-- 32 files changed, 94 insertions(+), 94 deletions(-) create mode 100644 kasa/protocols/__init__.py rename kasa/{ => protocols}/iotprotocol.py (96%) rename kasa/{ => protocols}/protocol.py (84%) rename kasa/{ => protocols}/smartprotocol.py (99%) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 83df9dcd3..541d128d1 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -44,8 +44,8 @@ SmartCameraProtocol, _ChildCameraProtocolWrapper, ) +from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.smart import SmartChildDevice -from kasa.smartprotocol import SmartProtocol, _ChildProtocolWrapper Call = namedtuple("Call", "module method") FixtureResult = namedtuple("FixtureResult", "filename, folder, data") diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index 9af590233..0ddbed7fa 100755 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -18,14 +18,13 @@ import pyshark from cryptography.hazmat.primitives import padding -from kasa.credentials import Credentials +from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials from kasa.deviceconfig import ( DeviceConfig, DeviceConnectionParameters, DeviceEncryptionType, DeviceFamily, ) -from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials from kasa.transports.klaptransport import KlapEncryptionSession, KlapTransportV2 diff --git a/docs/source/reference.md b/docs/source/reference.md index b8ebee9f3..f4771ac5d 100644 --- a/docs/source/reference.md +++ b/docs/source/reference.md @@ -86,21 +86,21 @@ ## Protocols and transports ```{eval-rst} -.. autoclass:: kasa.protocol.BaseProtocol +.. autoclass:: kasa.protocols.BaseProtocol :members: :inherited-members: :undoc-members: ``` ```{eval-rst} -.. autoclass:: kasa.iotprotocol.IotProtocol +.. autoclass:: kasa.protocols.IotProtocol :members: :inherited-members: :undoc-members: ``` ```{eval-rst} -.. autoclass:: kasa.smartprotocol.SmartProtocol +.. autoclass:: kasa.protocols.SmartProtocol :members: :inherited-members: :undoc-members: diff --git a/docs/source/topics.md b/docs/source/topics.md index 0ff66ede8..0dcc60d19 100644 --- a/docs/source/topics.md +++ b/docs/source/topics.md @@ -116,15 +116,15 @@ In order to support these different configurations the library migrated from a s to support pluggable transports and protocols. The classes providing this functionality are: -- {class}`BaseProtocol ` -- {class}`IotProtocol ` -- {class}`SmartProtocol ` +- {class}`BaseProtocol ` +- {class}`IotProtocol ` +- {class}`SmartProtocol ` -- {class}`BaseTransport ` -- {class}`XorTransport ` -- {class}`AesTransport ` -- {class}`KlapTransport ` -- {class}`KlapTransportV2 ` +- {class}`BaseTransport ` +- {class}`XorTransport ` +- {class}`AesTransport ` +- {class}`KlapTransport ` +- {class}`KlapTransportV2 ` (topics-errors-and-exceptions)= ## Errors and Exceptions @@ -166,42 +166,42 @@ API documentation for modules and features API documentation for protocols and transports ********************************************** -.. autoclass:: kasa.protocol.BaseProtocol +.. autoclass:: kasa.protocols.BaseProtocol :members: :inherited-members: :undoc-members: -.. autoclass:: kasa.iotprotocol.IotProtocol +.. autoclass:: kasa.protocols.IotProtocol :members: :inherited-members: :undoc-members: -.. autoclass:: kasa.smartprotocol.SmartProtocol +.. autoclass:: kasa.protocols.SmartProtocol :members: :inherited-members: :undoc-members: -.. autoclass:: kasa.protocol.BaseTransport +.. autoclass:: kasa.transports.BaseTransport :members: :inherited-members: :undoc-members: -.. autoclass:: kasa.xortransport.XorTransport +.. autoclass:: kasa.transports.XorTransport :members: :inherited-members: :undoc-members: -.. autoclass:: kasa.klaptransport.KlapTransport +.. autoclass:: kasa.transports.KlapTransport :members: :inherited-members: :undoc-members: -.. autoclass:: kasa.klaptransport.KlapTransportV2 +.. autoclass:: kasa.transports.KlapTransportV2 :members: :inherited-members: :undoc-members: -.. autoclass:: kasa.aestransport.AesTransport +.. autoclass:: kasa.transports.AesTransport :members: :inherited-members: :undoc-members: diff --git a/kasa/__init__.py b/kasa/__init__.py index 49e77966e..7fb80ab57 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -36,13 +36,9 @@ ) from kasa.feature import Feature from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState -from kasa.iotprotocol import ( - IotProtocol, - _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 -) from kasa.module import Module -from kasa.protocol import BaseProtocol -from kasa.smartprotocol import SmartProtocol +from kasa.protocols import BaseProtocol, IotProtocol, SmartProtocol +from kasa.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401 from kasa.transports import BaseTransport __version__ = version("python-kasa") diff --git a/kasa/credentials.py b/kasa/credentials.py index 3cc0b0162..2d6699994 100644 --- a/kasa/credentials.py +++ b/kasa/credentials.py @@ -1,5 +1,8 @@ """Credentials class for username / passwords.""" +from __future__ import annotations + +import base64 from dataclasses import dataclass, field @@ -11,3 +14,17 @@ class Credentials: username: str = field(default="", repr=False) #: Password of the cloud account password: str = field(default="", repr=False) + + +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=="), + "TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="), +} diff --git a/kasa/device.py b/kasa/device.py index 80139b68a..95826ad59 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -125,9 +125,8 @@ ) from .exceptions import KasaException from .feature import Feature -from .iotprotocol import IotProtocol from .module import Module -from .protocol import BaseProtocol +from .protocols import BaseProtocol, IotProtocol from .transports import XorTransport if TYPE_CHECKING: diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 9cdef53e2..887f4b685 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -22,12 +22,12 @@ IotStrip, IotWallSwitch, ) -from .iotprotocol import IotProtocol -from .protocol import ( +from .protocols import ( BaseProtocol, + IotProtocol, + SmartProtocol, ) from .smart import SmartDevice -from .smartprotocol import SmartProtocol from .transports import ( AesTransport, BaseTransport, diff --git a/kasa/discover.py b/kasa/discover.py index d1240aa81..99adf5042 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -131,11 +131,11 @@ ) from kasa.experimental import Experimental from kasa.iot.iotdevice import IotDevice -from kasa.iotprotocol import REDACTORS as IOT_REDACTORS from kasa.json import DataClassJSONMixin from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads -from kasa.protocol import mask_mac, redact_data +from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS +from kasa.protocols.protocol import mask_mac, redact_data from kasa.transports.aestransport import AesEncyptionSession, KeyPair from kasa.transports.xortransport import XorEncryption diff --git a/kasa/experimental/smartcameraprotocol.py b/kasa/experimental/smartcameraprotocol.py index 38530b161..4b9489ae9 100644 --- a/kasa/experimental/smartcameraprotocol.py +++ b/kasa/experimental/smartcameraprotocol.py @@ -14,7 +14,7 @@ _RetryableError, ) from ..json import dumps as json_dumps -from ..smartprotocol import SmartProtocol +from ..protocols import SmartProtocol from .sslaestransport import ( SMART_AUTHENTICATION_ERRORS, SMART_RETRYABLE_ERRORS, diff --git a/kasa/experimental/sslaestransport.py b/kasa/experimental/sslaestransport.py index 6b5144b15..3dff225ae 100644 --- a/kasa/experimental/sslaestransport.py +++ b/kasa/experimental/sslaestransport.py @@ -13,7 +13,7 @@ from yarl import URL -from ..credentials import Credentials +from ..credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials from ..deviceconfig import DeviceConfig from ..exceptions import ( SMART_AUTHENTICATION_ERRORS, @@ -27,7 +27,6 @@ from ..httpclient import HttpClient from ..json import dumps as json_dumps from ..json import loads as json_loads -from ..protocol import DEFAULT_CREDENTIALS, get_default_credentials from ..transports import AesEncyptionSession, BaseTransport _LOGGER = logging.getLogger(__name__) diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 481a9da8f..423e6f386 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -13,7 +13,7 @@ from ..deviceconfig import DeviceConfig from ..interfaces.light import HSV, ColorTempRange from ..module import Module -from ..protocol import BaseProtocol +from ..protocols import BaseProtocol from .iotdevice import IotDevice, KasaException, requires_update from .modules import ( Antitheft, diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 20284c1d8..1fd8ba397 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -28,7 +28,7 @@ from ..feature import Feature from ..module import Module from ..modulemapping import ModuleMapping, ModuleName -from ..protocol import BaseProtocol +from ..protocols import BaseProtocol from .iotmodule import IotModule, merge from .modules import Emeter diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 2cd8de44c..0c9eb3ea7 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -8,7 +8,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..module import Module -from ..protocol import BaseProtocol +from ..protocols import BaseProtocol from .iotdevice import KasaException, requires_update from .iotplug import IotPlug from .modules import AmbientLight, Light, Motion diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 14e98684b..f4107b3c1 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -5,7 +5,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..module import Module -from ..protocol import BaseProtocol +from ..protocols import BaseProtocol from .iotbulb import IotBulb from .iotdevice import requires_update from .modules.lighteffect import LightEffect diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index ab10e9326..288d53763 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -8,7 +8,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..module import Module -from ..protocol import BaseProtocol +from ..protocols import BaseProtocol from .iotdevice import IotDevice, requires_update from .modules import AmbientLight, Antitheft, Cloud, Led, Motion, Schedule, Time, Usage diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index a212dd61c..849f92f23 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -14,7 +14,7 @@ from ..feature import Feature from ..interfaces import Energy from ..module import Module -from ..protocol import BaseProtocol +from ..protocols import BaseProtocol from .iotdevice import ( IotDevice, requires_update, diff --git a/kasa/protocols/__init__.py b/kasa/protocols/__init__.py new file mode 100644 index 000000000..44130d7f2 --- /dev/null +++ b/kasa/protocols/__init__.py @@ -0,0 +1,12 @@ +"""Package containing all supported protocols.""" + +from .iotprotocol import IotProtocol +from .protocol import BaseProtocol +from .smartprotocol import SmartErrorCode, SmartProtocol + +__all__ = [ + "BaseProtocol", + "IotProtocol", + "SmartErrorCode", + "SmartProtocol", +] diff --git a/kasa/iotprotocol.py b/kasa/protocols/iotprotocol.py similarity index 96% rename from kasa/iotprotocol.py rename to kasa/protocols/iotprotocol.py index bb5704989..7e3e45a64 100755 --- a/kasa/iotprotocol.py +++ b/kasa/protocols/iotprotocol.py @@ -7,20 +7,20 @@ from pprint import pformat as pf from typing import TYPE_CHECKING, Any, Callable -from .deviceconfig import DeviceConfig -from .exceptions import ( +from ..deviceconfig import DeviceConfig +from ..exceptions import ( AuthenticationError, KasaException, TimeoutError, _ConnectionError, _RetryableError, ) -from .json import dumps as json_dumps +from ..json import dumps as json_dumps +from ..transports import XorEncryption, XorTransport from .protocol import BaseProtocol, mask_mac, redact_data -from .transports import XorEncryption, XorTransport if TYPE_CHECKING: - from .transports import BaseTransport + from ..transports import BaseTransport _LOGGER = logging.getLogger(__name__) diff --git a/kasa/protocol.py b/kasa/protocols/protocol.py similarity index 84% rename from kasa/protocol.py rename to kasa/protocols/protocol.py index 8e8a2352a..b879f0ae1 100755 --- a/kasa/protocol.py +++ b/kasa/protocols/protocol.py @@ -12,7 +12,6 @@ from __future__ import annotations -import base64 import errno import hashlib import logging @@ -22,8 +21,7 @@ # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout -from .credentials import Credentials -from .deviceconfig import DeviceConfig +from ..deviceconfig import DeviceConfig _LOGGER = logging.getLogger(__name__) _NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} @@ -33,7 +31,7 @@ if TYPE_CHECKING: - from .transports import BaseTransport + from ..transports import BaseTransport def redact_data(data: _T, redactors: dict[str, Callable[[Any], Any] | None]) -> _T: @@ -106,17 +104,3 @@ async def query(self, request: str | dict, retry_count: int = 3) -> dict: @abstractmethod async def close(self) -> None: """Close the protocol. Abstract method to be overriden.""" - - -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=="), - "TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="), -} diff --git a/kasa/smartprotocol.py b/kasa/protocols/smartprotocol.py similarity index 99% rename from kasa/smartprotocol.py rename to kasa/protocols/smartprotocol.py index 7d43bdb45..3df8d9377 100644 --- a/kasa/smartprotocol.py +++ b/kasa/protocols/smartprotocol.py @@ -14,7 +14,7 @@ from pprint import pformat as pf from typing import TYPE_CHECKING, Any, Callable -from .exceptions import ( +from ..exceptions import ( SMART_AUTHENTICATION_ERRORS, SMART_RETRYABLE_ERRORS, AuthenticationError, @@ -25,11 +25,11 @@ _ConnectionError, _RetryableError, ) -from .json import dumps as json_dumps +from ..json import dumps as json_dumps from .protocol import BaseProtocol, mask_mac, md5, redact_data if TYPE_CHECKING: - from .transports import BaseTransport + from ..transports import BaseTransport _LOGGER = logging.getLogger(__name__) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 49c922294..c50f1f2fb 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -8,7 +8,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper +from ..protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from .smartdevice import SmartDevice from .smartmodule import SmartModule diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index e8e2186c4..b92b1c37c 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -16,7 +16,7 @@ from ..feature import Feature from ..module import Module from ..modulemapping import ModuleMapping, ModuleName -from ..smartprotocol import SmartProtocol +from ..protocols import SmartProtocol from ..transports import AesTransport from .modules import ( ChildDevice, diff --git a/kasa/transports/aestransport.py b/kasa/transports/aestransport.py index 61b7c27bd..590c2f72f 100644 --- a/kasa/transports/aestransport.py +++ b/kasa/transports/aestransport.py @@ -20,7 +20,7 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from yarl import URL -from kasa.credentials import Credentials +from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials from kasa.deviceconfig import DeviceConfig from kasa.exceptions import ( SMART_AUTHENTICATION_ERRORS, @@ -36,7 +36,6 @@ from kasa.httpclient import HttpClient from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads -from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials from .basetransport import BaseTransport diff --git a/kasa/transports/klaptransport.py b/kasa/transports/klaptransport.py index d9d5e952b..49de4c54d 100644 --- a/kasa/transports/klaptransport.py +++ b/kasa/transports/klaptransport.py @@ -57,16 +57,12 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from yarl import URL -from kasa.credentials import Credentials +from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials from kasa.deviceconfig import DeviceConfig from kasa.exceptions import AuthenticationError, KasaException, _RetryableError from kasa.httpclient import HttpClient from kasa.json import loads as json_loads -from kasa.protocol import ( - DEFAULT_CREDENTIALS, - get_default_credentials, - md5, -) +from kasa.protocols.protocol import md5 from .basetransport import BaseTransport diff --git a/tests/fakeprotocol_iot.py b/tests/fakeprotocol_iot.py index 1249ec216..2e3d2810d 100644 --- a/tests/fakeprotocol_iot.py +++ b/tests/fakeprotocol_iot.py @@ -2,7 +2,7 @@ import logging from kasa.deviceconfig import DeviceConfig -from kasa.iotprotocol import IotProtocol +from kasa.protocols import IotProtocol from kasa.transports.basetransport import BaseTransport _LOGGER = logging.getLogger(__name__) diff --git a/tests/test_childdevice.py b/tests/test_childdevice.py index 05743abb5..3aa605c40 100644 --- a/tests/test_childdevice.py +++ b/tests/test_childdevice.py @@ -7,9 +7,9 @@ from kasa import Device from kasa.device_type import DeviceType +from kasa.protocols.smartprotocol import _ChildProtocolWrapper from kasa.smart.smartchilddevice import SmartChildDevice from kasa.smart.smartdevice import NON_HUB_PARENT_ONLY_MODULES -from kasa.smartprotocol import _ChildProtocolWrapper from .conftest import ( parametrize, diff --git a/tests/test_klapprotocol.py b/tests/test_klapprotocol.py index bdb054906..a1521ee4d 100644 --- a/tests/test_klapprotocol.py +++ b/tests/test_klapprotocol.py @@ -9,7 +9,7 @@ import pytest from yarl import URL -from kasa.credentials import Credentials +from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials from kasa.deviceconfig import DeviceConfig from kasa.exceptions import ( AuthenticationError, @@ -19,9 +19,7 @@ _RetryableError, ) from kasa.httpclient import HttpClient -from kasa.iotprotocol import IotProtocol -from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials -from kasa.smartprotocol import SmartProtocol +from kasa.protocols import IotProtocol, SmartProtocol from kasa.transports.aestransport import AesTransport from kasa.transports.klaptransport import ( KlapEncryptionSession, diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 11e2afcf8..767d0f102 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -18,8 +18,8 @@ from kasa.deviceconfig import DeviceConfig from kasa.exceptions import KasaException from kasa.iot import IotDevice -from kasa.iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol -from kasa.protocol import ( +from kasa.protocols.iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol +from kasa.protocols.protocol import ( BaseProtocol, mask_mac, redact_data, diff --git a/tests/test_smartdevice.py b/tests/test_smartdevice.py index 12c7349af..657fdd2f1 100644 --- a/tests/test_smartdevice.py +++ b/tests/test_smartdevice.py @@ -13,10 +13,10 @@ from kasa import Device, KasaException, Module from kasa.exceptions import DeviceError, SmartErrorCode +from kasa.protocols.smartprotocol import _ChildProtocolWrapper from kasa.smart import SmartDevice from kasa.smart.modules.energy import Energy from kasa.smart.smartmodule import SmartModule -from kasa.smartprotocol import _ChildProtocolWrapper from .conftest import ( device_smart, @@ -266,7 +266,9 @@ async def _child_query(self, request, *args, **kwargs): mocker.patch.object(new_dev.protocol, "query", side_effect=_query) # children not created yet so cannot patch.object - mocker.patch("kasa.smartprotocol._ChildProtocolWrapper.query", new=_child_query) + mocker.patch( + "kasa.protocols.smartprotocol._ChildProtocolWrapper.query", new=_child_query + ) await new_dev.update() @@ -297,7 +299,7 @@ async def _child_query(self, request, *args, **kwargs): new_dev.protocol, "query", side_effect=new_dev.protocol._query ) mocker.patch( - "kasa.smartprotocol._ChildProtocolWrapper.query", + "kasa.protocols.smartprotocol._ChildProtocolWrapper.query", new=_ChildProtocolWrapper._query, ) diff --git a/tests/test_smartprotocol.py b/tests/test_smartprotocol.py index c523fcdbc..180fb6aa0 100644 --- a/tests/test_smartprotocol.py +++ b/tests/test_smartprotocol.py @@ -9,8 +9,8 @@ KasaException, SmartErrorCode, ) +from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.smart import SmartDevice -from kasa.smartprotocol import SmartProtocol, _ChildProtocolWrapper from .conftest import device_smart from .fakeprotocol_smart import FakeSmartTransport diff --git a/tests/test_sslaestransport.py b/tests/test_sslaestransport.py index 52507892e..2e13f1533 100644 --- a/tests/test_sslaestransport.py +++ b/tests/test_sslaestransport.py @@ -11,7 +11,7 @@ import pytest from yarl import URL -from kasa.credentials import Credentials +from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials from kasa.deviceconfig import DeviceConfig from kasa.exceptions import ( AuthenticationError, @@ -24,7 +24,6 @@ _sha256_hash, ) from kasa.httpclient import HttpClient -from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials from kasa.transports.aestransport import AesEncyptionSession # Transport tests are not designed for real devices From 6213b90f6255d71b627adcf3e1005f8c0bf65604 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 13 Nov 2024 19:59:42 +0000 Subject: [PATCH 674/892] Move TAPO smartcamera out of experimental package (#1255) Co-authored-by: Teemu R. --- devtools/dump_devinfo.py | 2 +- kasa/device_factory.py | 17 ++++++-------- kasa/module.py | 4 ++-- .../smartcameraprotocol.py | 4 ++-- kasa/smartcamera/__init__.py | 5 ++++ .../modules/__init__.py | 0 .../modules/camera.py | 0 .../modules/childdevice.py | 0 .../modules/device.py | 0 .../modules/led.py | 0 .../modules/time.py | 0 .../smartcamera.py | 5 ++-- .../smartcameramodule.py | 0 .../sslaestransport.py | 2 +- tests/device_fixtures.py | 2 +- tests/discovery_fixtures.py | 23 ++++++++++++------- tests/fakeprotocol_smartcamera.py | 8 ++++++- tests/fixtureinfo.py | 4 ++-- tests/test_cli.py | 8 ++++++- tests/test_device_factory.py | 5 +++- tests/test_sslaestransport.py | 6 ++--- 21 files changed, 59 insertions(+), 36 deletions(-) rename kasa/{experimental => protocols}/smartcameraprotocol.py (99%) create mode 100644 kasa/smartcamera/__init__.py rename kasa/{experimental => smartcamera}/modules/__init__.py (100%) rename kasa/{experimental => smartcamera}/modules/camera.py (100%) rename kasa/{experimental => smartcamera}/modules/childdevice.py (100%) rename kasa/{experimental => smartcamera}/modules/device.py (100%) rename kasa/{experimental => smartcamera}/modules/led.py (100%) rename kasa/{experimental => smartcamera}/modules/time.py (100%) rename kasa/{experimental => smartcamera}/smartcamera.py (98%) rename kasa/{experimental => smartcamera}/smartcameramodule.py (100%) rename kasa/{experimental => transports}/sslaestransport.py (99%) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 541d128d1..7316809a1 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -40,7 +40,7 @@ from kasa.deviceconfig import DeviceEncryptionType, DeviceFamily from kasa.discover import DiscoveryResult from kasa.exceptions import SmartErrorCode -from kasa.experimental.smartcameraprotocol import ( +from kasa.protocols.smartcameraprotocol import ( SmartCameraProtocol, _ChildCameraProtocolWrapper, ) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 887f4b685..d32f73a07 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -10,9 +10,6 @@ from .device_type import DeviceType from .deviceconfig import DeviceConfig from .exceptions import KasaException, UnsupportedDeviceError -from .experimental.smartcamera import SmartCamera -from .experimental.smartcameraprotocol import SmartCameraProtocol -from .experimental.sslaestransport import SslAesTransport from .iot import ( IotBulb, IotDevice, @@ -27,7 +24,9 @@ IotProtocol, SmartProtocol, ) +from .protocols.smartcameraprotocol import SmartCameraProtocol from .smart import SmartDevice +from .smartcamera.smartcamera import SmartCamera from .transports import ( AesTransport, BaseTransport, @@ -35,6 +34,7 @@ KlapTransportV2, XorTransport, ) +from .transports.sslaestransport import SslAesTransport _LOGGER = logging.getLogger(__name__) @@ -217,12 +217,9 @@ def get_protocol( "IOT.KLAP": (IotProtocol, KlapTransport), "SMART.AES": (SmartProtocol, AesTransport), "SMART.KLAP": (SmartProtocol, KlapTransportV2), + "SMART.AES.HTTPS": (SmartCameraProtocol, SslAesTransport), } if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)): - from .experimental import Experimental - - if Experimental.enabled() and protocol_transport_key == "SMART.AES.HTTPS": - prot_tran_cls = (SmartCameraProtocol, SslAesTransport) - else: - return None - return prot_tran_cls[0](transport=prot_tran_cls[1](config=config)) + return None + protocol_cls, transport_cls = prot_tran_cls + return protocol_cls(transport=transport_cls(config=config)) diff --git a/kasa/module.py b/kasa/module.py index c4e9f9a11..edd264770 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -55,9 +55,9 @@ if TYPE_CHECKING: from . import interfaces from .device import Device - from .experimental import modules as experimental from .iot import modules as iot from .smart import modules as smart + from .smartcamera import modules as smartcamera _LOGGER = logging.getLogger(__name__) @@ -133,7 +133,7 @@ class Module(ABC): TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs") # SMARTCAMERA only modules - Camera: Final[ModuleName[experimental.Camera]] = ModuleName("Camera") + Camera: Final[ModuleName[smartcamera.Camera]] = ModuleName("Camera") def __init__(self, device: Device, module: str) -> None: self._device = device diff --git a/kasa/experimental/smartcameraprotocol.py b/kasa/protocols/smartcameraprotocol.py similarity index 99% rename from kasa/experimental/smartcameraprotocol.py rename to kasa/protocols/smartcameraprotocol.py index 4b9489ae9..57f78d408 100644 --- a/kasa/experimental/smartcameraprotocol.py +++ b/kasa/protocols/smartcameraprotocol.py @@ -14,12 +14,12 @@ _RetryableError, ) from ..json import dumps as json_dumps -from ..protocols import SmartProtocol -from .sslaestransport import ( +from ..transports.sslaestransport import ( SMART_AUTHENTICATION_ERRORS, SMART_RETRYABLE_ERRORS, SmartErrorCode, ) +from . import SmartProtocol _LOGGER = logging.getLogger(__name__) diff --git a/kasa/smartcamera/__init__.py b/kasa/smartcamera/__init__.py new file mode 100644 index 000000000..0d6052ea1 --- /dev/null +++ b/kasa/smartcamera/__init__.py @@ -0,0 +1,5 @@ +"""Package for supporting tapo-branded cameras.""" + +from .smartcamera import SmartCamera + +__all__ = ["SmartCamera"] diff --git a/kasa/experimental/modules/__init__.py b/kasa/smartcamera/modules/__init__.py similarity index 100% rename from kasa/experimental/modules/__init__.py rename to kasa/smartcamera/modules/__init__.py diff --git a/kasa/experimental/modules/camera.py b/kasa/smartcamera/modules/camera.py similarity index 100% rename from kasa/experimental/modules/camera.py rename to kasa/smartcamera/modules/camera.py diff --git a/kasa/experimental/modules/childdevice.py b/kasa/smartcamera/modules/childdevice.py similarity index 100% rename from kasa/experimental/modules/childdevice.py rename to kasa/smartcamera/modules/childdevice.py diff --git a/kasa/experimental/modules/device.py b/kasa/smartcamera/modules/device.py similarity index 100% rename from kasa/experimental/modules/device.py rename to kasa/smartcamera/modules/device.py diff --git a/kasa/experimental/modules/led.py b/kasa/smartcamera/modules/led.py similarity index 100% rename from kasa/experimental/modules/led.py rename to kasa/smartcamera/modules/led.py diff --git a/kasa/experimental/modules/time.py b/kasa/smartcamera/modules/time.py similarity index 100% rename from kasa/experimental/modules/time.py rename to kasa/smartcamera/modules/time.py diff --git a/kasa/experimental/smartcamera.py b/kasa/smartcamera/smartcamera.py similarity index 98% rename from kasa/experimental/smartcamera.py rename to kasa/smartcamera/smartcamera.py index feadf87bb..d94d35d33 100644 --- a/kasa/experimental/smartcamera.py +++ b/kasa/smartcamera/smartcamera.py @@ -7,11 +7,10 @@ from ..device_type import DeviceType from ..module import Module +from ..protocols.smartcameraprotocol import _ChildCameraProtocolWrapper from ..smart import SmartChildDevice, SmartDevice -from .modules.childdevice import ChildDevice -from .modules.device import DeviceModule +from .modules import ChildDevice, DeviceModule from .smartcameramodule import SmartCameraModule -from .smartcameraprotocol import _ChildCameraProtocolWrapper _LOGGER = logging.getLogger(__name__) diff --git a/kasa/experimental/smartcameramodule.py b/kasa/smartcamera/smartcameramodule.py similarity index 100% rename from kasa/experimental/smartcameramodule.py rename to kasa/smartcamera/smartcameramodule.py diff --git a/kasa/experimental/sslaestransport.py b/kasa/transports/sslaestransport.py similarity index 99% rename from kasa/experimental/sslaestransport.py rename to kasa/transports/sslaestransport.py index 3dff225ae..4f3cd4cc5 100644 --- a/kasa/experimental/sslaestransport.py +++ b/kasa/transports/sslaestransport.py @@ -27,7 +27,7 @@ from ..httpclient import HttpClient from ..json import dumps as json_dumps from ..json import loads as json_loads -from ..transports import AesEncyptionSession, BaseTransport +from . import AesEncyptionSession, BaseTransport _LOGGER = logging.getLogger(__name__) diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 4d335d5c5..359d71648 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -11,9 +11,9 @@ DeviceType, Discover, ) -from kasa.experimental.smartcamera import SmartCamera from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch from kasa.smart import SmartDevice +from kasa.smartcamera.smartcamera import SmartCamera from .fakeprotocol_iot import FakeIotProtocol from .fakeprotocol_smart import FakeSmartProtocol diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index e69a8b73c..e272cef81 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -10,6 +10,7 @@ from .fakeprotocol_iot import FakeIotProtocol from .fakeprotocol_smart import FakeSmartProtocol, FakeSmartTransport +from .fakeprotocol_smartcamera import FakeSmartCameraProtocol from .fixtureinfo import FixtureInfo, filter_fixtures, idgenerator DISCOVERY_MOCK_IP = "127.0.0.123" @@ -126,12 +127,14 @@ def _datagram(self) -> bytes: if "discovery_result" in fixture_data: discovery_data = {"result": fixture_data["discovery_result"].copy()} - device_type = fixture_data["discovery_result"]["device_type"] - encrypt_type = fixture_data["discovery_result"]["mgt_encrypt_schm"][ - "encrypt_type" - ] - login_version = fixture_data["discovery_result"]["mgt_encrypt_schm"].get("lv") - https = fixture_data["discovery_result"]["mgt_encrypt_schm"]["is_support_https"] + discovery_result = fixture_data["discovery_result"] + device_type = discovery_result["device_type"] + encrypt_type = discovery_result["mgt_encrypt_schm"].get( + "encrypt_type", discovery_result.get("encrypt_info", {}).get("sym_schm") + ) + + login_version = discovery_result["mgt_encrypt_schm"].get("lv") + https = discovery_result["mgt_encrypt_schm"]["is_support_https"] dm = _DiscoveryMock( ip, 80, @@ -172,7 +175,9 @@ def patch_discovery(fixture_infos: dict[str, FixtureInfo], mocker): } protos = { ip: FakeSmartProtocol(fixture_info.data, fixture_info.name) - if "SMART" in fixture_info.protocol + if fixture_info.protocol in {"SMART", "SMART.CHILD"} + else FakeSmartCameraProtocol(fixture_info.data, fixture_info.name) + if fixture_info.protocol in {"SMARTCAMERA", "SMARTCAMERA.CHILD"} else FakeIotProtocol(fixture_info.data, fixture_info.name) for ip, fixture_info in fixture_infos.items() } @@ -197,7 +202,9 @@ async def mock_discover(self): # update the protos for any host testing or the test overriding the first ip protos[host] = ( FakeSmartProtocol(fixture_info.data, fixture_info.name) - if "SMART" in fixture_info.protocol + if fixture_info.protocol in {"SMART", "SMART.CHILD"} + else FakeSmartCameraProtocol(fixture_info.data, fixture_info.name) + if fixture_info.protocol in {"SMARTCAMERA", "SMARTCAMERA.CHILD"} else FakeIotProtocol(fixture_info.data, fixture_info.name) ) port = ( diff --git a/tests/fakeprotocol_smartcamera.py b/tests/fakeprotocol_smartcamera.py index 7ff0bab22..d6751f7d9 100644 --- a/tests/fakeprotocol_smartcamera.py +++ b/tests/fakeprotocol_smartcamera.py @@ -4,7 +4,7 @@ from json import loads as json_loads from kasa import Credentials, DeviceConfig, SmartProtocol -from kasa.experimental.smartcameraprotocol import SmartCameraProtocol +from kasa.protocols.smartcameraprotocol import SmartCameraProtocol from kasa.transports.basetransport import BaseTransport from .fakeprotocol_smart import FakeSmartTransport @@ -136,6 +136,12 @@ def _get_param_set_value(info: dict, set_keys: list[str], value): "basic", "zone_id", ], + ("led", "config", "enabled"): [ + "getLedStatus", + "led", + "config", + "enabled", + ], } async def _send_request(self, request_dict: dict): diff --git a/tests/fixtureinfo.py b/tests/fixtureinfo.py index cb75b4232..7d7607efc 100644 --- a/tests/fixtureinfo.py +++ b/tests/fixtureinfo.py @@ -8,8 +8,8 @@ from kasa.device_factory import _get_device_type_from_sys_info from kasa.device_type import DeviceType -from kasa.experimental.smartcamera import SmartCamera from kasa.smart.smartdevice import SmartDevice +from kasa.smartcamera.smartcamera import SmartCamera class FixtureInfo(NamedTuple): @@ -179,7 +179,7 @@ def _device_type_match(fixture_data: FixtureInfo, device_type): filtered = [] if protocol_filter is None: - protocol_filter = {"IOT", "SMART"} + protocol_filter = {"IOT", "SMART", "SMARTCAMERA"} for fixture_data in fixture_list: if data_root_filter and data_root_filter not in fixture_data.data: continue diff --git a/tests/test_cli.py b/tests/test_cli.py index 24fc916f2..1ab0dc31b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,6 +43,7 @@ from kasa.discover import Discover, DiscoveryResult from kasa.iot import IotDevice from kasa.smart import SmartDevice +from kasa.smartcamera import SmartCamera from .conftest import ( device_smart, @@ -178,6 +179,9 @@ async def test_state(dev, turn_on, runner): @turn_on async def test_toggle(dev, turn_on, runner): + if isinstance(dev, SmartCamera) and dev.device_type == DeviceType.Hub: + pytest.skip(reason="Hub cannot toggle state") + await handle_turn_on(dev, turn_on) await dev.update() assert dev.is_on == turn_on @@ -208,7 +212,9 @@ async def test_raw_command(dev, mocker, runner): update = mocker.patch.object(dev, "update") from kasa.smart import SmartDevice - if isinstance(dev, SmartDevice): + if isinstance(dev, SmartCamera): + params = ["na", "getDeviceInfo"] + elif isinstance(dev, SmartDevice): params = ["na", "get_device_info"] else: params = ["system", "get_sysinfo"] diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 0042d6e28..4f71888ba 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -19,6 +19,7 @@ ) from kasa.device_factory import ( Device, + SmartCamera, SmartDevice, _get_device_type_from_sys_info, connect, @@ -177,7 +178,9 @@ async def test_connect_http_client(discovery_mock, mocker): async def test_device_types(dev: Device): await dev.update() - if isinstance(dev, SmartDevice): + if isinstance(dev, SmartCamera): + res = SmartCamera._get_device_type_from_sysinfo(dev.sys_info) + elif isinstance(dev, SmartDevice): assert dev._discovery_info device_type = cast(str, dev._discovery_info["result"]["device_type"]) res = SmartDevice._get_device_type_from_components( diff --git a/tests/test_sslaestransport.py b/tests/test_sslaestransport.py index 2e13f1533..0d8fac9cf 100644 --- a/tests/test_sslaestransport.py +++ b/tests/test_sslaestransport.py @@ -18,13 +18,13 @@ KasaException, SmartErrorCode, ) -from kasa.experimental.sslaestransport import ( +from kasa.httpclient import HttpClient +from kasa.transports.aestransport import AesEncyptionSession +from kasa.transports.sslaestransport import ( SslAesTransport, TransportState, _sha256_hash, ) -from kasa.httpclient import HttpClient -from kasa.transports.aestransport import AesEncyptionSession # Transport tests are not designed for real devices pytestmark = [pytest.mark.requires_dummy] From b8f6651d9b8ac61e5cb7b228d9d1d2f0957b6539 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 14 Nov 2024 14:55:02 +0000 Subject: [PATCH 675/892] Remove experimental support (#1256) --- devtools/dump_devinfo.py | 7 +----- kasa/cli/main.py | 20 ---------------- kasa/discover.py | 4 +--- kasa/experimental/__init__.py | 28 ----------------------- pyproject.toml | 3 --- tests/test_cli.py | 43 +++-------------------------------- 6 files changed, 5 insertions(+), 100 deletions(-) delete mode 100644 kasa/experimental/__init__.py diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 7316809a1..fca2d35cb 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -309,10 +309,6 @@ async def cli( if debug: logging.basicConfig(level=logging.DEBUG) - from kasa.experimental import Experimental - - Experimental.set_enabled(True) - credentials = Credentials(username=username, password=password) if host is not None: if discovery_info: @@ -356,8 +352,7 @@ async def cli( await handle_device(basedir, autosave, protocol, batch_size=batch_size) else: raise KasaException( - "Could not find a protocol for the given parameters. " - + "Maybe you need to enable --experimental." + "Could not find a protocol for the given parameters." ) else: click.echo("Host given, performing discovery on %s." % host) diff --git a/kasa/cli/main.py b/kasa/cli/main.py index d6b9fa9d7..f20642b84 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -16,7 +16,6 @@ from kasa import Device from kasa.deviceconfig import DeviceEncryptionType -from kasa.experimental import Experimental from .common import ( SKIP_UPDATE_COMMANDS, @@ -220,14 +219,6 @@ def _legacy_type_to_class(_type: str) -> Any: envvar="KASA_CREDENTIALS_HASH", help="Hashed credentials used to authenticate to the device.", ) -@click.option( - "--experimental/--no-experimental", - default=None, - is_flag=True, - type=bool, - envvar=Experimental.ENV_VAR, - help="Enable experimental mode for devices not yet fully supported.", -) @click.version_option(package_name="python-kasa") @click.pass_context async def cli( @@ -249,7 +240,6 @@ async def cli( username, password, credentials_hash, - experimental, ): """A tool for controlling TP-Link smart home devices.""" # noqa # no need to perform any checks if we are just displaying the help @@ -261,12 +251,6 @@ async def cli( if target != DEFAULT_TARGET and host: error("--target is not a valid option for single host discovery") - if experimental is not None: - Experimental.set_enabled(experimental) - - if Experimental.enabled(): - echo("Experimental support is enabled") - logging_config: dict[str, Any] = { "level": logging.DEBUG if debug > 0 else logging.INFO } @@ -332,10 +316,6 @@ async def cli( dev = _legacy_type_to_class(type)(host, config=config) elif type in {"smart", "camera"} or (device_family and encrypt_type): if type == "camera": - if not experimental: - error( - "Camera is an experimental type, please enable with --experimental" - ) encrypt_type = "AES" https = True device_family = "SMART.IPCAMERA" diff --git a/kasa/discover.py b/kasa/discover.py index 99adf5042..14a324720 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -129,7 +129,6 @@ TimeoutError, UnsupportedDeviceError, ) -from kasa.experimental import Experimental from kasa.iot.iotdevice import IotDevice from kasa.json import DataClassJSONMixin from kasa.json import dumps as json_dumps @@ -589,9 +588,8 @@ async def try_connect_all( main_device_families = { Device.Family.SmartTapoPlug, Device.Family.IotSmartPlugSwitch, + Device.Family.SmartIpCamera, } - if Experimental.enabled(): - main_device_families.add(Device.Family.SmartIpCamera) candidates: dict[ tuple[type[BaseProtocol], type[BaseTransport], type[Device]], tuple[BaseProtocol, DeviceConfig], diff --git a/kasa/experimental/__init__.py b/kasa/experimental/__init__.py deleted file mode 100644 index a866787e2..000000000 --- a/kasa/experimental/__init__.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Package for experimental.""" - -from __future__ import annotations - -import os - - -class Experimental: - """Class for enabling experimental functionality.""" - - _enabled: bool | None = None - ENV_VAR = "KASA_EXPERIMENTAL" - - @classmethod - def set_enabled(cls, enabled: bool) -> None: - """Set the enabled value.""" - cls._enabled = enabled - - @classmethod - def enabled(cls) -> bool: - """Get the enabled value.""" - if cls._enabled is not None: - return cls._enabled - - if env_var := os.getenv(cls.ENV_VAR): - return env_var.lower() in {"true", "1", "t", "on"} - - return False diff --git a/pyproject.toml b/pyproject.toml index 44959c6fb..fde4a7c63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,9 +84,6 @@ include = [ [tool.coverage.run] source = ["kasa"] branch = true -omit = [ - "kasa/experimental/*" -] [tool.coverage.report] exclude_lines = [ diff --git a/tests/test_cli.py b/tests/test_cli.py index 1ab0dc31b..78cfb34c3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -861,9 +861,6 @@ async def test_host_auth_failed(discovery_mock, mocker, runner): @pytest.mark.parametrize("device_type", TYPES) async def test_type_param(device_type, mocker, runner): """Test for handling only one of username or password supplied.""" - if device_type == "camera": - pytest.skip(reason="camera is experimental") - result_device = FileNotFoundError pass_dev = click.make_pass_decorator(Device) @@ -873,7 +870,9 @@ async def _state(dev: Device): result_device = dev mocker.patch("kasa.cli.device.state", new=_state) - if device_type == "smart": + if device_type == "camera": + expected_type = SmartCamera + elif device_type == "smart": expected_type = SmartDevice else: expected_type = _legacy_type_to_class(device_type) @@ -1253,39 +1252,3 @@ async def test_discover_config_invalid(mocker, runner): ) assert res.exit_code == 1 assert "--target is not a valid option for single host discovery" in res.output - - -@pytest.mark.parametrize( - ("option", "env_var_value", "expectation"), - [ - pytest.param("--experimental", None, True), - pytest.param("--experimental", "false", True), - pytest.param(None, None, False), - pytest.param(None, "true", True), - pytest.param(None, "false", False), - pytest.param("--no-experimental", "true", False), - ], -) -async def test_experimental_flags(mocker, option, env_var_value, expectation): - """Test the experimental flag is set correctly.""" - mocker.patch("kasa.discover.Discover.try_connect_all", return_value=None) - - # reset the class internal variable - from kasa.experimental import Experimental - - Experimental._enabled = None - - KASA_VARS = {k: None for k, v in os.environ.items() if k.startswith("KASA_")} - if env_var_value: - KASA_VARS["KASA_EXPERIMENTAL"] = env_var_value - args = [ - "--host", - "127.0.0.2", - "discover", - "config", - ] - if option: - args.insert(0, option) - runner = CliRunner(env=KASA_VARS) - res = await runner.invoke(cli, args) - assert ("Experimental support is enabled" in res.output) is expectation From 5fe75cada93b88c6d3f13b130f1025b8783fef0c Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 14 Nov 2024 18:28:30 +0000 Subject: [PATCH 676/892] Add smartcamera devices to supported docs (#1257) The library now officially supports H200, C200 and TC65 --- README.md | 3 ++- SUPPORTED.md | 11 +++++++++++ devtools/dump_devinfo.py | 15 +++++++-------- devtools/generate_supported.py | 29 ++++++++++++++++++++++++++++- kasa/device.py | 16 ++++++++++++++++ kasa/smartcamera/smartcamera.py | 25 +++++++++++++++++++++++++ 6 files changed, 89 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 60ff35a20..6cd7a21ac 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,8 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Hubs**: H100 +- **Cameras**: C210, TC65 +- **Hubs**: H100, H200 - **Hub-Connected Devices\*\*\***: S200B, S200D, T100, T110, T300, T310, T315 diff --git a/SUPPORTED.md b/SUPPORTED.md index 7452b69a4..2da28b444 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -246,12 +246,23 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **L930-5** - Hardware: 1.0 (US) / Firmware: 1.1.2 +### Cameras + +- **C210** + - Hardware: 2.0 (EU) / Firmware: 1.4.2 + - Hardware: 2.0 (EU) / Firmware: 1.4.3 +- **TC65** + - Hardware: 1.0 / Firmware: 1.3.9 + ### Hubs - **H100** - Hardware: 1.0 (EU) / Firmware: 1.2.3 - Hardware: 1.0 (EU) / Firmware: 1.5.10 - Hardware: 1.0 (EU) / Firmware: 1.5.5 +- **H200** + - Hardware: 1.0 (EU) / Firmware: 1.3.2 + - Hardware: 1.0 (US) / Firmware: 1.3.6 ### Hub-Connected Devices diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index fca2d35cb..36ddaaee7 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -46,6 +46,7 @@ ) from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.smart import SmartChildDevice +from kasa.smartcamera import SmartCamera Call = namedtuple("Call", "module method") FixtureResult = namedtuple("FixtureResult", "filename, folder, data") @@ -973,14 +974,12 @@ async def get_smart_fixtures( copy_folder = SMART_FOLDER else: # smart camera protocol - basic_info = final["getDeviceInfo"]["device_info"]["basic_info"] - hw_version = basic_info["hw_version"] - sw_version = basic_info["sw_version"] - model = basic_info["device_model"] - region = basic_info.get("region") - sw_version = sw_version.split(" ", maxsplit=1)[0] - if region is not None: - model = f"{model}({region})" + model_info = SmartCamera._get_device_info(final, discovery_info) + model = model_info.long_name + hw_version = model_info.hardware_version + sw_version = model_info.firmare_version + if model_info.region is not None: + model = f"{model}({model_info.region})" copy_folder = SMARTCAMERA_FOLDER save_filename = f"{model}_{hw_version}_{sw_version}.json" diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py index 43a69455b..e7ae732d4 100755 --- a/devtools/generate_supported.py +++ b/devtools/generate_supported.py @@ -10,7 +10,8 @@ from kasa.device_factory import _get_device_type_from_sys_info from kasa.device_type import DeviceType -from kasa.smart.smartdevice import SmartDevice +from kasa.smart import SmartDevice +from kasa.smartcamera import SmartCamera class SupportedVersion(NamedTuple): @@ -32,6 +33,7 @@ class SupportedVersion(NamedTuple): DeviceType.Fan: "Wall Switches", DeviceType.Bulb: "Bulbs", DeviceType.LightStrip: "Light Strips", + DeviceType.Camera: "Cameras", DeviceType.Hub: "Hubs", DeviceType.Sensor: "Hub-Connected Devices", DeviceType.Thermostat: "Hub-Connected Devices", @@ -43,6 +45,7 @@ class SupportedVersion(NamedTuple): IOT_FOLDER = "tests/fixtures/" SMART_FOLDER = "tests/fixtures/smart/" +SMARTCAMERA_FOLDER = "tests/fixtures/smartcamera/" def generate_supported(args): @@ -58,6 +61,7 @@ def generate_supported(args): _get_iot_supported(supported) _get_smart_supported(supported) + _get_smartcamera_supported(supported) readme_updated = _update_supported_file( README_FILENAME, _supported_summary(supported), print_diffs @@ -234,6 +238,29 @@ def _get_smart_supported(supported): ) +def _get_smartcamera_supported(supported): + for file in Path(SMARTCAMERA_FOLDER).glob("**/*.json"): + with file.open() as f: + fixture_data = json.load(f) + + model_info = SmartCamera._get_device_info( + fixture_data, fixture_data.get("discovery_result") + ) + + supported_type = DEVICE_TYPE_TO_PRODUCT_GROUP[model_info.device_type] + + stype = supported[model_info.brand].setdefault(supported_type, {}) + smodel = stype.setdefault(model_info.long_name, []) + smodel.append( + SupportedVersion( + region=model_info.region, + hw=model_info.hardware_version, + fw=model_info.firmare_version, + auth=model_info.requires_auth, + ) + ) + + def _get_iot_supported(supported): for file in Path(IOT_FOLDER).glob("*.json"): with file.open() as f: diff --git a/kasa/device.py b/kasa/device.py index 95826ad59..79bc5182c 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -152,6 +152,22 @@ class WifiNetwork: _LOGGER = logging.getLogger(__name__) +@dataclass +class _DeviceInfo: + """Device Model Information.""" + + short_name: str + long_name: str + brand: str + device_family: str + device_type: DeviceType + hardware_version: str + firmare_version: str + firmware_build: str + requires_auth: bool + region: str | None + + class Device(ABC): """Common device interface. diff --git a/kasa/smartcamera/smartcamera.py b/kasa/smartcamera/smartcamera.py index d94d35d33..ee804ab6c 100644 --- a/kasa/smartcamera/smartcamera.py +++ b/kasa/smartcamera/smartcamera.py @@ -5,6 +5,7 @@ import logging from typing import Any +from ..device import _DeviceInfo from ..device_type import DeviceType from ..module import Module from ..protocols.smartcameraprotocol import _ChildCameraProtocolWrapper @@ -29,6 +30,30 @@ def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType: return DeviceType.Hub return DeviceType.Camera + @staticmethod + def _get_device_info( + info: dict[str, Any], discovery_info: dict[str, Any] | None + ) -> _DeviceInfo: + """Get model information for a device.""" + basic_info = info["getDeviceInfo"]["device_info"]["basic_info"] + short_name = basic_info["device_model"] + long_name = discovery_info["device_model"] if discovery_info else short_name + device_type = SmartCamera._get_device_type_from_sysinfo(basic_info) + fw_version_full = basic_info["sw_version"] + firmare_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + return _DeviceInfo( + short_name=basic_info["device_model"], + long_name=long_name, + brand="tapo", + device_family=basic_info["device_type"], + device_type=device_type, + hardware_version=basic_info["hw_version"], + firmare_version=firmare_version, + firmware_build=firmware_build, + requires_auth=True, + region=basic_info.get("region"), + ) + def _update_internal_info(self, info_resp: dict) -> None: """Update the internal device info.""" info = self._try_get_response(info_resp, "getDeviceInfo") From cf77128853d75dc51e8721d109b8bc8797f33b4d Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 15 Nov 2024 10:19:40 +0000 Subject: [PATCH 677/892] Add alarm module for smartcamera hubs (#1258) --- kasa/smartcamera/modules/__init__.py | 2 + kasa/smartcamera/modules/alarm.py | 166 ++++++++++++++++++++++++ kasa/smartcamera/smartcameramodule.py | 17 ++- tests/fakeprotocol_smartcamera.py | 71 +++++----- tests/smartcamera/modules/__init__.py | 0 tests/smartcamera/modules/test_alarm.py | 159 +++++++++++++++++++++++ 6 files changed, 372 insertions(+), 43 deletions(-) create mode 100644 kasa/smartcamera/modules/alarm.py create mode 100644 tests/smartcamera/modules/__init__.py create mode 100644 tests/smartcamera/modules/test_alarm.py diff --git a/kasa/smartcamera/modules/__init__.py b/kasa/smartcamera/modules/__init__.py index cf2b43777..f3e36cc37 100644 --- a/kasa/smartcamera/modules/__init__.py +++ b/kasa/smartcamera/modules/__init__.py @@ -1,5 +1,6 @@ """Modules for SMARTCAMERA devices.""" +from .alarm import Alarm from .camera import Camera from .childdevice import ChildDevice from .device import DeviceModule @@ -7,6 +8,7 @@ from .time import Time __all__ = [ + "Alarm", "Camera", "ChildDevice", "DeviceModule", diff --git a/kasa/smartcamera/modules/alarm.py b/kasa/smartcamera/modules/alarm.py new file mode 100644 index 000000000..bf7ce1a58 --- /dev/null +++ b/kasa/smartcamera/modules/alarm.py @@ -0,0 +1,166 @@ +"""Implementation of alarm module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartcameramodule import SmartCameraModule + +DURATION_MIN = 0 +DURATION_MAX = 6000 + +VOLUME_MIN = 0 +VOLUME_MAX = 10 + + +class Alarm(SmartCameraModule): + """Implementation of alarm module.""" + + # Needs a different name to avoid clashing with SmartAlarm + NAME = "SmartCameraAlarm" + + REQUIRED_COMPONENT = "siren" + QUERY_GETTER_NAME = "getSirenStatus" + QUERY_MODULE_NAME = "siren" + + def query(self) -> dict: + """Query to execute during the update cycle.""" + q = super().query() + q["getSirenConfig"] = {self.QUERY_MODULE_NAME: {}} + q["getSirenTypeList"] = {self.QUERY_MODULE_NAME: {}} + + return q + + def _initialize_features(self) -> None: + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device, + id="alarm", + name="Alarm", + container=self, + attribute_getter="active", + icon="mdi:bell", + category=Feature.Category.Debug, + type=Feature.Type.BinarySensor, + ) + ) + self._add_feature( + Feature( + device, + id="alarm_sound", + name="Alarm sound", + container=self, + attribute_getter="alarm_sound", + attribute_setter="set_alarm_sound", + category=Feature.Category.Config, + type=Feature.Type.Choice, + choices_getter="alarm_sounds", + ) + ) + self._add_feature( + Feature( + device, + id="alarm_volume", + name="Alarm volume", + container=self, + attribute_getter="alarm_volume", + attribute_setter="set_alarm_volume", + category=Feature.Category.Config, + type=Feature.Type.Number, + range_getter=lambda: (VOLUME_MIN, VOLUME_MAX), + ) + ) + self._add_feature( + Feature( + device, + id="alarm_duration", + name="Alarm duration", + container=self, + attribute_getter="alarm_duration", + attribute_setter="set_alarm_duration", + category=Feature.Category.Config, + type=Feature.Type.Number, + range_getter=lambda: (DURATION_MIN, DURATION_MAX), + ) + ) + self._add_feature( + Feature( + device, + id="test_alarm", + name="Test alarm", + container=self, + attribute_setter="play", + type=Feature.Type.Action, + ) + ) + self._add_feature( + Feature( + device, + id="stop_alarm", + name="Stop alarm", + container=self, + attribute_setter="stop", + type=Feature.Type.Action, + ) + ) + + @property + def alarm_sound(self) -> str: + """Return current alarm sound.""" + return self.data["getSirenConfig"]["siren_type"] + + async def set_alarm_sound(self, sound: str) -> dict: + """Set alarm sound. + + See *alarm_sounds* for list of available sounds. + """ + if sound not in self.alarm_sounds: + raise ValueError( + f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}" + ) + return await self.call("setSirenConfig", {"siren": {"siren_type": sound}}) + + @property + def alarm_sounds(self) -> list[str]: + """Return list of available alarm sounds.""" + return self.data["getSirenTypeList"]["siren_type_list"] + + @property + def alarm_volume(self) -> int: + """Return alarm volume. + + Unlike duration the device expects/returns a string for volume. + """ + return int(self.data["getSirenConfig"]["volume"]) + + async def set_alarm_volume(self, volume: int) -> dict: + """Set alarm volume.""" + if volume < VOLUME_MIN or volume > VOLUME_MAX: + raise ValueError(f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}") + return await self.call("setSirenConfig", {"siren": {"volume": str(volume)}}) + + @property + def alarm_duration(self) -> int: + """Return alarm duration.""" + return self.data["getSirenConfig"]["duration"] + + async def set_alarm_duration(self, duration: int) -> dict: + """Set alarm volume.""" + if duration < DURATION_MIN or duration > DURATION_MAX: + msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}" + raise ValueError(msg) + return await self.call("setSirenConfig", {"siren": {"duration": duration}}) + + @property + def active(self) -> bool: + """Return true if alarm is active.""" + return self.data["getSirenStatus"]["status"] != "off" + + async def play(self) -> dict: + """Play alarm.""" + return await self.call("setSirenStatus", {"siren": {"status": "on"}}) + + async def stop(self) -> dict: + """Stop alarm.""" + return await self.call("setSirenStatus", {"siren": {"status": "off"}}) diff --git a/kasa/smartcamera/smartcameramodule.py b/kasa/smartcamera/smartcameramodule.py index 217b69e71..4b1bd36e3 100644 --- a/kasa/smartcamera/smartcameramodule.py +++ b/kasa/smartcamera/smartcameramodule.py @@ -3,12 +3,14 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Final, cast from ..exceptions import DeviceError, KasaException, SmartErrorCode +from ..modulemapping import ModuleName from ..smart.smartmodule import SmartModule if TYPE_CHECKING: + from . import modules from .smartcamera import SmartCamera _LOGGER = logging.getLogger(__name__) @@ -17,12 +19,14 @@ class SmartCameraModule(SmartModule): """Base class for SMARTCAMERA modules.""" + SmartCameraAlarm: Final[ModuleName[modules.Alarm]] = ModuleName("SmartCameraAlarm") + #: Query to execute during the main update cycle QUERY_GETTER_NAME: str #: Module name to be queried QUERY_MODULE_NAME: str #: Section name or names to be queried - QUERY_SECTION_NAMES: str | list[str] + QUERY_SECTION_NAMES: str | list[str] | None = None REGISTERED_MODULES = {} @@ -33,11 +37,10 @@ def query(self) -> dict: Default implementation uses the raw query getter w/o parameters. """ - return { - self.QUERY_GETTER_NAME: { - self.QUERY_MODULE_NAME: {"name": self.QUERY_SECTION_NAMES} - } - } + section_names = ( + {"name": self.QUERY_SECTION_NAMES} if self.QUERY_SECTION_NAMES else {} + ) + return {self.QUERY_GETTER_NAME: {self.QUERY_MODULE_NAME: section_names}} async def call(self, method: str, params: dict | None = None) -> dict: """Call a method. diff --git a/tests/fakeprotocol_smartcamera.py b/tests/fakeprotocol_smartcamera.py index d6751f7d9..e84b5bf99 100644 --- a/tests/fakeprotocol_smartcamera.py +++ b/tests/fakeprotocol_smartcamera.py @@ -105,6 +105,7 @@ def _get_param_set_value(info: dict, set_keys: list[str], value): info = info[key] info[set_keys[-1]] = value + # Setters for when there's not a simple mapping of setters to getters SETTERS = { ("system", "sys", "dev_alias"): [ "getDeviceInfo", @@ -112,36 +113,20 @@ def _get_param_set_value(info: dict, set_keys: list[str], value): "basic_info", "device_alias", ], - ("lens_mask", "lens_mask_info", "enabled"): [ - "getLensMaskConfig", - "lens_mask", - "lens_mask_info", - "enabled", - ], + # setTimezone maps to getClockStatus ("system", "clock_status", "seconds_from_1970"): [ "getClockStatus", "system", "clock_status", "seconds_from_1970", ], + # setTimezone maps to getClockStatus ("system", "clock_status", "local_time"): [ "getClockStatus", "system", "clock_status", "local_time", ], - ("system", "basic", "zone_id"): [ - "getTimezone", - "system", - "basic", - "zone_id", - ], - ("led", "config", "enabled"): [ - "getLedStatus", - "led", - "config", - "enabled", - ], } async def _send_request(self, request_dict: dict): @@ -154,27 +139,41 @@ async def _send_request(self, request_dict: dict): ) if method[:3] == "set": + get_method = "g" + method[1:] for key, val in request_dict.items(): - if key != "method": - # key is params for multi request and the actual params - # for single requests - if key == "params": - module = next(iter(val)) - val = val[module] + if key == "method": + continue + # key is params for multi request and the actual params + # for single requests + if key == "params": + module = next(iter(val)) + val = val[module] + else: + module = key + section = next(iter(val)) + skey_val = val[section] + if not isinstance(skey_val, dict): # single level query + section_key = section + section_val = skey_val + if (get_info := info.get(get_method)) and section_key in get_info: + get_info[section_key] = section_val else: - module = key - section = next(iter(val)) - skey_val = val[section] - for skey, sval in skey_val.items(): - section_key = skey - section_value = sval - if setter_keys := self.SETTERS.get( - (module, section, section_key) - ): - self._get_param_set_value(info, setter_keys, section_value) - else: - return {"error_code": -1} + return {"error_code": -1} break + for skey, sval in skey_val.items(): + section_key = skey + section_value = sval + if setter_keys := self.SETTERS.get((module, section, section_key)): + self._get_param_set_value(info, setter_keys, section_value) + elif ( + section := info.get(get_method, {}) + .get(module, {}) + .get(section, {}) + ) and section_key in section: + section[section_key] = section_value + else: + return {"error_code": -1} + break return {"error_code": 0} elif method[:3] == "get": params = request_dict.get("params") diff --git a/tests/smartcamera/modules/__init__.py b/tests/smartcamera/modules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/smartcamera/modules/test_alarm.py b/tests/smartcamera/modules/test_alarm.py new file mode 100644 index 000000000..2301a2be3 --- /dev/null +++ b/tests/smartcamera/modules/test_alarm.py @@ -0,0 +1,159 @@ +"""Tests for smart camera devices.""" + +from __future__ import annotations + +import pytest + +from kasa import Device +from kasa.smartcamera.modules.alarm import ( + DURATION_MAX, + DURATION_MIN, + VOLUME_MAX, + VOLUME_MIN, +) +from kasa.smartcamera.smartcameramodule import SmartCameraModule + +from ...conftest import hub_smartcamera + + +@hub_smartcamera +async def test_alarm(dev: Device): + """Test device alarm.""" + alarm = dev.modules.get(SmartCameraModule.SmartCameraAlarm) + assert alarm + + original_duration = alarm.alarm_duration + assert original_duration is not None + original_volume = alarm.alarm_volume + assert original_volume is not None + original_sound = alarm.alarm_sound + + try: + # test volume + new_volume = original_volume - 1 if original_volume > 1 else original_volume + 1 + await alarm.set_alarm_volume(new_volume) # type: ignore[arg-type] + await dev.update() + assert alarm.alarm_volume == new_volume + + # test duration + new_duration = ( + original_duration - 1 if original_duration > 1 else original_duration + 1 + ) + await alarm.set_alarm_duration(new_duration) + await dev.update() + assert alarm.alarm_duration == new_duration + + # test start + await alarm.play() + await dev.update() + assert alarm.active + + # test stop + await alarm.stop() + await dev.update() + assert not alarm.active + + # test set sound + new_sound = ( + alarm.alarm_sounds[0] + if alarm.alarm_sound != alarm.alarm_sounds[0] + else alarm.alarm_sounds[1] + ) + await alarm.set_alarm_sound(new_sound) + await dev.update() + assert alarm.alarm_sound == new_sound + + finally: + await alarm.set_alarm_volume(original_volume) + await alarm.set_alarm_duration(original_duration) + await alarm.set_alarm_sound(original_sound) + await dev.update() + + +@hub_smartcamera +async def test_alarm_invalid_setters(dev: Device): + """Test device alarm invalid setter values.""" + alarm = dev.modules.get(SmartCameraModule.SmartCameraAlarm) + assert alarm + + # test set sound invalid + msg = f"sound must be one of {', '.join(alarm.alarm_sounds)}: foobar" + with pytest.raises(ValueError, match=msg): + await alarm.set_alarm_sound("foobar") + + # test volume invalid + msg = f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}" + with pytest.raises(ValueError, match=msg): + await alarm.set_alarm_volume(-3) + + # test duration invalid + msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}" + with pytest.raises(ValueError, match=msg): + await alarm.set_alarm_duration(-3) + + +@hub_smartcamera +async def test_alarm_features(dev: Device): + """Test device alarm features.""" + alarm = dev.modules.get(SmartCameraModule.SmartCameraAlarm) + assert alarm + + original_duration = alarm.alarm_duration + assert original_duration is not None + original_volume = alarm.alarm_volume + assert original_volume is not None + original_sound = alarm.alarm_sound + + try: + # test volume + new_volume = original_volume - 1 if original_volume > 1 else original_volume + 1 + feature = dev.features.get("alarm_volume") + assert feature + await feature.set_value(new_volume) # type: ignore[arg-type] + await dev.update() + assert feature.value == new_volume + + # test duration + feature = dev.features.get("alarm_duration") + assert feature + new_duration = ( + original_duration - 1 if original_duration > 1 else original_duration + 1 + ) + await feature.set_value(new_duration) + await dev.update() + assert feature.value == new_duration + + # test start + feature = dev.features.get("test_alarm") + assert feature + await feature.set_value(None) + await dev.update() + feature = dev.features.get("alarm") + assert feature + assert feature.value is True + + # test stop + feature = dev.features.get("stop_alarm") + assert feature + await feature.set_value(None) + await dev.update() + assert dev.features["alarm"].value is False + + # test set sound + feature = dev.features.get("alarm_sound") + assert feature + new_sound = ( + alarm.alarm_sounds[0] + if alarm.alarm_sound != alarm.alarm_sounds[0] + else alarm.alarm_sounds[1] + ) + await feature.set_value(new_sound) + await alarm.set_alarm_sound(new_sound) + await dev.update() + assert feature.value == new_sound + + finally: + await alarm.set_alarm_volume(original_volume) + await alarm.set_alarm_duration(original_duration) + await alarm.set_alarm_sound(original_sound) + await dev.update() From 0d1193ac71b05ecd498491a4f199d1a2340c450c Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 15 Nov 2024 14:38:41 +0000 Subject: [PATCH 678/892] Update cli feature command for actions not to require a value (#1264) --- kasa/cli/feature.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/kasa/cli/feature.py b/kasa/cli/feature.py index 2c5fa0456..522dee7f3 100644 --- a/kasa/cli/feature.py +++ b/kasa/cli/feature.py @@ -122,6 +122,12 @@ async def feature( feat = dev.features[name] + if value is None and feat.type is Feature.Type.Action: + echo(f"Executing action {name}") + response = await dev.features[name].set_value(value) + echo(response) + return response + if value is None: unit = f" {feat.unit}" if feat.unit else "" echo(f"{feat.name} ({name}): {feat.value}{unit}") From 410c3d2623de8d8d7e6beec54bab3e1436f8f7a0 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 18 Nov 2024 11:49:44 +0000 Subject: [PATCH 679/892] Fix deprecated SSLContext() usage (#1271) --- kasa/transports/sslaestransport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/transports/sslaestransport.py b/kasa/transports/sslaestransport.py index 4f3cd4cc5..16054833e 100644 --- a/kasa/transports/sslaestransport.py +++ b/kasa/transports/sslaestransport.py @@ -173,7 +173,7 @@ def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: raise DeviceError(msg, error_code=error_code) def _create_ssl_context(self) -> ssl.SSLContext: - context = ssl.SSLContext() + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) context.set_ciphers(self.CIPHERS) context.check_hostname = False context.verify_mode = ssl.CERT_NONE From fd5258c28b18bd92ddc4431498b126b9fc35febe Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 18 Nov 2024 13:03:13 +0000 Subject: [PATCH 680/892] Fix discovery by alias for smart devices (#1260) Fixes #1259 --- kasa/cli/discover.py | 53 ++++++++++++++++++++++++++++++++++++-------- kasa/cli/main.py | 28 ++++++++++++----------- 2 files changed, 59 insertions(+), 22 deletions(-) diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index 3ebb4a9f6..82b09db53 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -239,13 +239,48 @@ def _conditional_echo(label, value): _conditional_echo("Decrypted", pf(dr.decrypted_data) if dr.decrypted_data else None) -async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3): +async def find_dev_from_alias( + alias: str, + credentials: Credentials | None, + target: str = "255.255.255.255", + timeout: int = 5, + attempts: int = 3, +) -> Device | None: """Discover a device identified by its alias.""" - for _attempt in range(1, attempts): - found_devs = await Discover.discover(target=target, timeout=timeout) - for _ip, dev in found_devs.items(): - if dev.alias.lower() == alias.lower(): - host = dev.host - return host - - return None + found_event = asyncio.Event() + found_device = [] + seen_hosts = set() + + async def on_discovered(dev: Device): + if dev.host in seen_hosts: + return + seen_hosts.add(dev.host) + try: + await dev.update() + except Exception as ex: + echo(f"Error querying device {dev.host}: {ex}") + return + finally: + await dev.protocol.close() + if not dev.alias: + echo(f"Skipping device {dev.host} with no alias") + return + if dev.alias.lower() == alias.lower(): + found_device.append(dev) + found_event.set() + + async def do_discover(): + for _ in range(1, attempts): + await Discover.discover( + target=target, + timeout=timeout, + credentials=credentials, + on_discovered=on_discovered, + ) + if found_event.is_set(): + break + found_event.set() + + asyncio.create_task(do_discover()) + await found_event.wait() + return found_device[0] if found_device else None diff --git a/kasa/cli/main.py b/kasa/cli/main.py index f20642b84..4db9bd9d8 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -275,18 +275,6 @@ async def cli( if alias is not None and host is not None: raise click.BadOptionUsage("alias", "Use either --alias or --host, not both.") - if alias is not None and host is None: - echo(f"Alias is given, using discovery to find host {alias}") - - from .discover import find_host_from_alias - - host = await find_host_from_alias(alias=alias, target=target) - if host: - echo(f"Found hostname is {host}") - else: - echo(f"No device with name {alias} found") - return - if bool(password) != bool(username): raise click.BadOptionUsage( "username", "Using authentication requires both --username and --password" @@ -299,7 +287,7 @@ async def cli( else: credentials = None - if host is None: + if host is None and alias is None: if ctx.invoked_subcommand and ctx.invoked_subcommand != "discover": error("Only discover is available without --host or --alias") @@ -309,6 +297,7 @@ async def cli( return await ctx.invoke(discover) device_updated = False + if type is not None and type not in {"smart", "camera"}: from kasa.deviceconfig import DeviceConfig @@ -347,6 +336,19 @@ async def cli( ) dev = await Device.connect(config=config) device_updated = True + elif alias: + echo(f"Alias is given, using discovery to find host {alias}") + + from .discover import find_dev_from_alias + + dev = await find_dev_from_alias( + alias=alias, target=target, credentials=credentials + ) + if not dev: + echo(f"No device with name {alias} found") + return + echo(f"Found hostname by alias: {dev.host}") + device_updated = True else: from .discover import discover From 9d46996e9be067d6f7ac1524a31d5197892d933a Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 18 Nov 2024 13:14:39 +0000 Subject: [PATCH 681/892] Fix repr for device created with no sysinfo or discovery info" (#1266) Co-authored-by: Teemu R. --- kasa/device.py | 6 +++-- kasa/iot/iotdevice.py | 8 ++++-- kasa/smart/smartchilddevice.py | 30 ++++++++++++++------- kasa/smart/smartdevice.py | 4 +++ kasa/smartcamera/smartcamera.py | 7 +++-- tests/test_childdevice.py | 18 ++++++++++++- tests/test_device.py | 46 ++++++++++++++++++++++++++++++++- 7 files changed, 101 insertions(+), 18 deletions(-) diff --git a/kasa/device.py b/kasa/device.py index 79bc5182c..ecd3b0528 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -210,12 +210,12 @@ def __init__( self.protocol: BaseProtocol = protocol or IotProtocol( transport=XorTransport(config=config or DeviceConfig(host=host)), ) - _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) + self._last_update: Any = None + _LOGGER.debug("Initializing %s of type %s", host, type(self)) self._device_type = DeviceType.Unknown # TODO: typing Any is just as using Optional[Dict] would require separate # checks in accessors. the @updated_required decorator does not ensure # mypy that these are not accessed incorrectly. - self._last_update: Any = None self._discovery_info: dict[str, Any] | None = None self._features: dict[str, Feature] = {} @@ -492,6 +492,8 @@ async def factory_reset(self) -> None: def __repr__(self) -> str: update_needed = " - update() needed" if not self._last_update else "" + if not self._last_update and not self._discovery_info: + return f"<{self.device_type} at {self.host}{update_needed}>" return ( f"<{self.device_type} at {self.host} -" f" {self.alias} ({self.model}){update_needed}>" diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 1fd8ba397..37f00ae6f 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -42,7 +42,9 @@ def requires_update(f: Callable) -> Any: @functools.wraps(f) async def wrapped(*args: Any, **kwargs: Any) -> Any: self = args[0] - if self._last_update is None and f.__name__ not in self._sys_info: + if self._last_update is None and ( + self._sys_info is None or f.__name__ not in self._sys_info + ): raise KasaException("You need to await update() to access the data") return await f(*args, **kwargs) @@ -51,7 +53,9 @@ async def wrapped(*args: Any, **kwargs: Any) -> Any: @functools.wraps(f) def wrapped(*args: Any, **kwargs: Any) -> Any: self = args[0] - if self._last_update is None and f.__name__ not in self._sys_info: + if self._last_update is None and ( + self._sys_info is None or f.__name__ not in self._sys_info + ): raise KasaException("You need to await update() to access the data") return f(*args, **kwargs) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index c50f1f2fb..db3319f3c 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -107,16 +107,26 @@ async def create( @property def device_type(self) -> DeviceType: """Return child device type.""" - category = self.sys_info["category"] - dev_type = self.CHILD_DEVICE_TYPE_MAP.get(category) - if dev_type is None: - _LOGGER.warning( - "Unknown child device type %s for model %s, please open issue", - category, - self.model, - ) - dev_type = DeviceType.Unknown - return dev_type + if self._device_type is not DeviceType.Unknown: + return self._device_type + + if self.sys_info and (category := self.sys_info.get("category")): + dev_type = self.CHILD_DEVICE_TYPE_MAP.get(category) + if dev_type is None: + _LOGGER.warning( + "Unknown child device type %s for model %s, please open issue", + category, + self.model, + ) + self._device_type = DeviceType.Unknown + else: + self._device_type = dev_type + + return self._device_type def __repr__(self) -> str: + if not self._parent: + return f"<{self.device_type}(child) without parent>" + if not self._parent._last_update: + return f"<{self.device_type}(child) of {self._parent}>" return f"<{self.device_type} {self.alias} ({self.model}) of {self._parent}>" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index b92b1c37c..270b29593 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -757,6 +757,10 @@ def device_type(self) -> DeviceType: # Fallback to device_type (from disco info) type_str = self._info.get("type", self._info.get("device_type")) + + if not type_str: # no update or discovery info + return self._device_type + self._device_type = self._get_device_type_from_components( list(self._components.keys()), type_str ) diff --git a/kasa/smartcamera/smartcamera.py b/kasa/smartcamera/smartcamera.py index ee804ab6c..b99945b38 100644 --- a/kasa/smartcamera/smartcamera.py +++ b/kasa/smartcamera/smartcamera.py @@ -25,8 +25,11 @@ class SmartCamera(SmartDevice): @staticmethod def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType: """Find type to be displayed as a supported device category.""" - device_type = sysinfo["device_type"] - if device_type.endswith("HUB"): + if ( + sysinfo + and (device_type := sysinfo.get("device_type")) + and device_type.endswith("HUB") + ): return DeviceType.Hub return DeviceType.Camera diff --git a/tests/test_childdevice.py b/tests/test_childdevice.py index 3aa605c40..a2d780471 100644 --- a/tests/test_childdevice.py +++ b/tests/test_childdevice.py @@ -9,7 +9,7 @@ from kasa.device_type import DeviceType from kasa.protocols.smartprotocol import _ChildProtocolWrapper from kasa.smart.smartchilddevice import SmartChildDevice -from kasa.smart.smartdevice import NON_HUB_PARENT_ONLY_MODULES +from kasa.smart.smartdevice import NON_HUB_PARENT_ONLY_MODULES, SmartDevice from .conftest import ( parametrize, @@ -139,3 +139,19 @@ async def test_child_time(dev: Device, freezer: FrozenDateTimeFactory): assert dev.parent is None for child in dev.children: assert child.time != fallback_time + + +async def test_child_device_type_unknown(caplog): + """Test for device type when category is unknown.""" + + class DummyDevice(SmartChildDevice): + def __init__(self): + super().__init__( + SmartDevice("127.0.0.1"), + {"device_id": "1", "category": "foobar"}, + {"device", 1}, + ) + + assert DummyDevice().device_type is DeviceType.Unknown + msg = "Unknown child device type foobar for model None, please open issue" + assert msg in caplog.text diff --git a/tests/test_device.py b/tests/test_device.py index 5f527287a..1b943fa44 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -14,7 +14,15 @@ import kasa from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module -from kasa.iot import IotDevice +from kasa.iot import ( + IotBulb, + IotDevice, + IotDimmer, + IotLightStrip, + IotPlug, + IotStrip, + IotWallSwitch, +) from kasa.iot.iottimezone import ( TIMEZONE_INDEX, get_timezone, @@ -22,6 +30,7 @@ ) from kasa.iot.modules import IotLightPreset from kasa.smart import SmartChildDevice, SmartDevice +from kasa.smartcamera import SmartCamera def _get_subclasses(of_class): @@ -80,6 +89,41 @@ async def test_device_class_ctors(device_class_name_obj): assert dev.credentials == credentials +@device_classes +async def test_device_class_repr(device_class_name_obj): + """Test device repr when update() not called and no discovery info.""" + host = "127.0.0.2" + port = 1234 + credentials = Credentials("foo", "bar") + config = DeviceConfig(host, port_override=port, credentials=credentials) + klass = device_class_name_obj[1] + if issubclass(klass, SmartChildDevice): + parent = SmartDevice(host, config=config) + dev = klass( + parent, {"dummy": "info", "device_id": "dummy"}, {"dummy": "components"} + ) + else: + dev = klass(host, config=config) + + CLASS_TO_DEFAULT_TYPE = { + IotDevice: DeviceType.Unknown, + IotBulb: DeviceType.Bulb, + IotPlug: DeviceType.Plug, + IotDimmer: DeviceType.Dimmer, + IotStrip: DeviceType.Strip, + IotWallSwitch: DeviceType.WallSwitch, + IotLightStrip: DeviceType.LightStrip, + SmartChildDevice: DeviceType.Unknown, + SmartDevice: DeviceType.Unknown, + SmartCamera: DeviceType.Camera, + } + type_ = CLASS_TO_DEFAULT_TYPE[klass] + child_repr = ">" + not_child_repr = f"<{type_} at 127.0.0.2 - update() needed>" + expected_repr = child_repr if klass is SmartChildDevice else not_child_repr + assert repr(dev) == expected_repr + + async def test_create_device_with_timeout(): """Make sure timeout is passed to the protocol.""" host = "127.0.0.1" From e209d40a6d51e21caa493a125723c99902d25e19 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:53:11 +0000 Subject: [PATCH 682/892] Use _get_device_info methods for smart and iot devs in devtools (#1265) --- SUPPORTED.md | 6 +- devtools/dump_devinfo.py | 58 +++++----- devtools/generate_supported.py | 83 +++----------- kasa/device.py | 2 +- kasa/device_factory.py | 30 +---- kasa/iot/iotdevice.py | 66 ++++++++++- kasa/smart/smartdevice.py | 47 +++++++- kasa/smartcamera/smartcamera.py | 4 +- tests/conftest.py | 1 + tests/device_fixtures.py | 22 ++-- tests/fakeprotocol_iot.py | 39 +++++-- tests/fakeprotocol_smart.py | 24 +++- tests/fakeprotocol_smartcamera.py | 32 +++++- tests/fixtureinfo.py | 21 +++- ...0_1.1.3.json => P100(US)_1.0.0_1.1.3.json} | 0 ...0_1.3.7.json => P100(US)_1.0.0_1.3.7.json} | 0 ...0_1.4.0.json => P100(US)_1.0.0_1.4.0.json} | 0 tests/test_device_factory.py | 6 +- tests/test_devtools.py | 103 ++++++++++++++++++ tests/test_discovery.py | 10 +- 20 files changed, 386 insertions(+), 168 deletions(-) rename tests/fixtures/smart/{P100_1.0.0_1.1.3.json => P100(US)_1.0.0_1.1.3.json} (100%) rename tests/fixtures/smart/{P100_1.0.0_1.3.7.json => P100(US)_1.0.0_1.3.7.json} (100%) rename tests/fixtures/smart/{P100_1.0.0_1.4.0.json => P100(US)_1.0.0_1.4.0.json} (100%) create mode 100644 tests/test_devtools.py diff --git a/SUPPORTED.md b/SUPPORTED.md index 2da28b444..ca207a03b 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -179,9 +179,9 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros ### Plugs - **P100** - - Hardware: 1.0.0 / Firmware: 1.1.3 - - Hardware: 1.0.0 / Firmware: 1.3.7 - - Hardware: 1.0.0 / Firmware: 1.4.0 + - Hardware: 1.0.0 (US) / Firmware: 1.1.3 + - Hardware: 1.0.0 (US) / Firmware: 1.3.7 + - Hardware: 1.0.0 (US) / Firmware: 1.4.0 - **P110** - Hardware: 1.0 (EU) / Firmware: 1.0.7 - Hardware: 1.0 (EU) / Firmware: 1.2.3 diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 36ddaaee7..2650882eb 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -21,6 +21,7 @@ from collections import defaultdict, namedtuple from pathlib import Path from pprint import pprint +from typing import Any import asyncclick as click @@ -40,12 +41,13 @@ from kasa.deviceconfig import DeviceEncryptionType, DeviceFamily from kasa.discover import DiscoveryResult from kasa.exceptions import SmartErrorCode +from kasa.protocols import IotProtocol from kasa.protocols.smartcameraprotocol import ( SmartCameraProtocol, _ChildCameraProtocolWrapper, ) from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper -from kasa.smart import SmartChildDevice +from kasa.smart import SmartChildDevice, SmartDevice from kasa.smartcamera import SmartCamera Call = namedtuple("Call", "module method") @@ -389,7 +391,9 @@ async def cli( ) -async def get_legacy_fixture(protocol, *, discovery_info): +async def get_legacy_fixture( + protocol: IotProtocol, *, discovery_info: dict[str, Any] | None +) -> FixtureResult: """Get fixture for legacy IOT style protocol.""" items = [ Call(module="system", method="get_sysinfo"), @@ -422,8 +426,8 @@ async def get_legacy_fixture(protocol, *, discovery_info): finally: await protocol.close() - final_query = defaultdict(defaultdict) - final = defaultdict(defaultdict) + final_query: dict = defaultdict(defaultdict) + final: dict = defaultdict(defaultdict) for succ, resp in successes: final_query[succ.module][succ.method] = {} final[succ.module][succ.method] = resp @@ -433,16 +437,14 @@ async def get_legacy_fixture(protocol, *, discovery_info): try: final = await protocol.query(final_query) except Exception as ex: - _echo_error(f"Unable to query all successes at once: {ex}", bold=True, fg="red") + _echo_error(f"Unable to query all successes at once: {ex}") finally: await protocol.close() if discovery_info and not discovery_info.get("system"): # 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. - dr = DiscoveryResult.from_dict(protocol._discovery_info) - final["discovery_result"] = dr.dict( - by_alias=False, exclude_unset=True, exclude_none=True, exclude_defaults=True - ) + dr = DiscoveryResult.from_dict(discovery_info) + final["discovery_result"] = dr.to_dict() click.echo("Got %s successes" % len(successes)) click.echo(click.style("## device info file ##", bold=True)) @@ -817,23 +819,21 @@ async def get_smart_test_calls(protocol: SmartProtocol): def get_smart_child_fixture(response): """Get a seperate fixture for the child device.""" - info = response["get_device_info"] - hw_version = info["hw_ver"] - sw_version = info["fw_ver"] - sw_version = sw_version.split(" ", maxsplit=1)[0] - model = info["model"] - if region := info.get("specs"): - model += f"({region})" - - save_filename = f"{model}_{hw_version}_{sw_version}.json" + model_info = SmartDevice._get_device_info(response, None) + hw_version = model_info.hardware_version + fw_version = model_info.firmware_version + model = model_info.long_name + if model_info.region is not None: + model = f"{model}({model_info.region})" + save_filename = f"{model}_{hw_version}_{fw_version}.json" return FixtureResult( filename=save_filename, folder=SMART_CHILD_FOLDER, data=response ) async def get_smart_fixtures( - protocol: SmartProtocol, *, discovery_info=None, batch_size: int -): + protocol: SmartProtocol, *, discovery_info: dict[str, Any] | None, batch_size: int +) -> list[FixtureResult]: """Get fixture for new TAPO style protocol.""" if isinstance(protocol, SmartCameraProtocol): test_calls, successes = await get_smart_camera_test_calls(protocol) @@ -964,23 +964,17 @@ async def get_smart_fixtures( if "get_device_info" in final: # smart protocol - hw_version = final["get_device_info"]["hw_ver"] - sw_version = final["get_device_info"]["fw_ver"] - if discovery_info: - model = discovery_info["device_model"] - else: - model = final["get_device_info"]["model"] + "(XX)" - sw_version = sw_version.split(" ", maxsplit=1)[0] + model_info = SmartDevice._get_device_info(final, discovery_info) copy_folder = SMART_FOLDER else: # smart camera protocol model_info = SmartCamera._get_device_info(final, discovery_info) - model = model_info.long_name - hw_version = model_info.hardware_version - sw_version = model_info.firmare_version - if model_info.region is not None: - model = f"{model}({model_info.region})" copy_folder = SMARTCAMERA_FOLDER + hw_version = model_info.hardware_version + sw_version = model_info.firmware_version + model = model_info.long_name + if model_info.region is not None: + model = f"{model}({model_info.region})" save_filename = f"{model}_{hw_version}_{sw_version}.json" diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py index e7ae732d4..499f073c3 100755 --- a/devtools/generate_supported.py +++ b/devtools/generate_supported.py @@ -1,15 +1,17 @@ #!/usr/bin/env python """Script that checks supported devices and updates README.md and SUPPORTED.md.""" +from __future__ import annotations + import json import os import sys from pathlib import Path from string import Template -from typing import NamedTuple +from typing import Any, NamedTuple -from kasa.device_factory import _get_device_type_from_sys_info from kasa.device_type import DeviceType +from kasa.iot import IotDevice from kasa.smart import SmartDevice from kasa.smartcamera import SmartCamera @@ -17,7 +19,7 @@ class SupportedVersion(NamedTuple): """Supported version.""" - region: str + region: str | None hw: str fw: str auth: bool @@ -45,6 +47,7 @@ class SupportedVersion(NamedTuple): IOT_FOLDER = "tests/fixtures/" SMART_FOLDER = "tests/fixtures/smart/" +SMART_CHILD_FOLDER = "tests/fixtures/smart/child" SMARTCAMERA_FOLDER = "tests/fixtures/smartcamera/" @@ -59,9 +62,10 @@ def generate_supported(args): supported = {"kasa": {}, "tapo": {}} - _get_iot_supported(supported) - _get_smart_supported(supported) - _get_smartcamera_supported(supported) + _get_supported_devices(supported, IOT_FOLDER, IotDevice) + _get_supported_devices(supported, SMART_FOLDER, SmartDevice) + _get_supported_devices(supported, SMART_CHILD_FOLDER, SmartDevice) + _get_supported_devices(supported, SMARTCAMERA_FOLDER, SmartCamera) readme_updated = _update_supported_file( README_FILENAME, _supported_summary(supported), print_diffs @@ -201,49 +205,16 @@ def _supported_text( return brands -def _get_smart_supported(supported): - for file in Path(SMART_FOLDER).glob("**/*.json"): - with file.open() as f: - fixture_data = json.load(f) - - if "discovery_result" in fixture_data: - model, _, region = fixture_data["discovery_result"][ - "device_model" - ].partition("(") - device_type = fixture_data["discovery_result"]["device_type"] - else: # child devices of hubs do not have discovery result - model = fixture_data["get_device_info"]["model"] - region = fixture_data["get_device_info"].get("specs") - device_type = fixture_data["get_device_info"]["type"] - # P100 doesn't have region HW - region = region.replace(")", "") if region else "" - - _protocol, devicetype = device_type.split(".") - brand = devicetype[:4].lower() - components = [ - component["id"] - for component in fixture_data["component_nego"]["component_list"] - ] - dt = SmartDevice._get_device_type_from_components(components, device_type) - supported_type = DEVICE_TYPE_TO_PRODUCT_GROUP[dt] - - hw_version = fixture_data["get_device_info"]["hw_ver"] - fw_version = fixture_data["get_device_info"]["fw_ver"] - fw_version = fw_version.split(" ", maxsplit=1)[0] - - stype = supported[brand].setdefault(supported_type, {}) - smodel = stype.setdefault(model, []) - smodel.append( - SupportedVersion(region=region, hw=hw_version, fw=fw_version, auth=True) - ) - - -def _get_smartcamera_supported(supported): - for file in Path(SMARTCAMERA_FOLDER).glob("**/*.json"): +def _get_supported_devices( + supported: dict[str, Any], + fixture_location: str, + device_cls: type[IotDevice | SmartDevice | SmartCamera], +): + for file in Path(fixture_location).glob("*.json"): with file.open() as f: fixture_data = json.load(f) - model_info = SmartCamera._get_device_info( + model_info = device_cls._get_device_info( fixture_data, fixture_data.get("discovery_result") ) @@ -255,30 +226,12 @@ def _get_smartcamera_supported(supported): SupportedVersion( region=model_info.region, hw=model_info.hardware_version, - fw=model_info.firmare_version, + fw=model_info.firmware_version, auth=model_info.requires_auth, ) ) -def _get_iot_supported(supported): - for file in Path(IOT_FOLDER).glob("*.json"): - with file.open() as f: - fixture_data = json.load(f) - sysinfo = fixture_data["system"]["get_sysinfo"] - dt = _get_device_type_from_sys_info(fixture_data) - supported_type = DEVICE_TYPE_TO_PRODUCT_GROUP[dt] - - model, _, region = sysinfo["model"][:-1].partition("(") - auth = "discovery_result" in fixture_data - stype = supported["kasa"].setdefault(supported_type, {}) - smodel = stype.setdefault(model, []) - fw = sysinfo["sw_ver"].split(" ", maxsplit=1)[0] - smodel.append( - SupportedVersion(region=region, hw=sysinfo["hw_ver"], fw=fw, auth=auth) - ) - - def main(): """Entry point to module.""" generate_supported(sys.argv[1:]) diff --git a/kasa/device.py b/kasa/device.py index ecd3b0528..755c89efe 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -162,7 +162,7 @@ class _DeviceInfo: device_family: str device_type: DeviceType hardware_version: str - firmare_version: str + firmware_version: str firmware_build: str requires_auth: bool region: str | None diff --git a/kasa/device_factory.py b/kasa/device_factory.py index d32f73a07..dab867997 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -128,34 +128,6 @@ def _perf_log(has_params: bool, perf_type: str) -> None: ) -def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType: - """Find SmartDevice subclass for device described by passed data.""" - if "system" not in info or "get_sysinfo" not in info["system"]: - raise KasaException("No 'system' or 'get_sysinfo' in response") - - sysinfo: dict[str, Any] = info["system"]["get_sysinfo"] - type_: str | None = sysinfo.get("type", sysinfo.get("mic_type")) - if type_ is None: - raise KasaException("Unable to find the device type field!") - - if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: - return DeviceType.Dimmer - - if "smartplug" in type_.lower(): - if "children" in sysinfo: - return DeviceType.Strip - if (dev_name := sysinfo.get("dev_name")) and "light" in dev_name.lower(): - return DeviceType.WallSwitch - return DeviceType.Plug - - if "smartbulb" in type_.lower(): - if "length" in sysinfo: # strips have length - return DeviceType.LightStrip - - return DeviceType.Bulb - raise UnsupportedDeviceError(f"Unknown device type: {type_}") - - def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]: """Find SmartDevice subclass for device described by passed data.""" TYPE_TO_CLASS = { @@ -166,7 +138,7 @@ def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]: DeviceType.WallSwitch: IotWallSwitch, DeviceType.LightStrip: IotLightStrip, } - return TYPE_TO_CLASS[_get_device_type_from_sys_info(sysinfo)] + return TYPE_TO_CLASS[IotDevice._get_device_type_from_sys_info(sysinfo)] def get_device_class_from_family( diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 37f00ae6f..95a774df9 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -22,7 +22,8 @@ from typing import TYPE_CHECKING, Any, Callable, cast from warnings import warn -from ..device import Device, WifiNetwork +from ..device import Device, WifiNetwork, _DeviceInfo +from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..exceptions import KasaException from ..feature import Feature @@ -692,3 +693,66 @@ def internal_state(self) -> Any: This should only be used for debugging purposes. """ return self._last_update or self._discovery_info + + @staticmethod + def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType: + """Find SmartDevice subclass for device described by passed data.""" + if "system" not in info or "get_sysinfo" not in info["system"]: + raise KasaException("No 'system' or 'get_sysinfo' in response") + + sysinfo: dict[str, Any] = info["system"]["get_sysinfo"] + type_: str | None = sysinfo.get("type", sysinfo.get("mic_type")) + if type_ is None: + raise KasaException("Unable to find the device type field!") + + if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: + return DeviceType.Dimmer + + if "smartplug" in type_.lower(): + if "children" in sysinfo: + return DeviceType.Strip + if (dev_name := sysinfo.get("dev_name")) and "light" in dev_name.lower(): + return DeviceType.WallSwitch + return DeviceType.Plug + + if "smartbulb" in type_.lower(): + if "length" in sysinfo: # strips have length + return DeviceType.LightStrip + + return DeviceType.Bulb + _LOGGER.warning("Unknown device type %s, falling back to plug", type_) + return DeviceType.Plug + + @staticmethod + def _get_device_info( + info: dict[str, Any], discovery_info: dict[str, Any] | None + ) -> _DeviceInfo: + """Get model information for a device.""" + sys_info = info["system"]["get_sysinfo"] + + # Get model and region info + region = None + device_model = sys_info["model"] + long_name, _, region = device_model.partition("(") + if region: # All iot devices have region but just in case + region = region.replace(")", "") + + # Get other info + device_family = sys_info.get("type", sys_info.get("mic_type")) + device_type = IotDevice._get_device_type_from_sys_info(info) + fw_version_full = sys_info["sw_ver"] + firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + auth = bool(discovery_info and ("mgt_encrypt_schm" in discovery_info)) + + return _DeviceInfo( + short_name=long_name, + long_name=long_name, + brand="kasa", + device_family=device_family, + device_type=device_type, + hardware_version=sys_info["hw_ver"], + firmware_version=firmware_version, + firmware_build=firmware_build, + requires_auth=auth, + region=region, + ) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 270b29593..64f6aa7c2 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -9,7 +9,7 @@ from datetime import datetime, timedelta, timezone, tzinfo from typing import TYPE_CHECKING, Any, cast -from ..device import Device, WifiNetwork +from ..device import Device, WifiNetwork, _DeviceInfo from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode @@ -794,3 +794,48 @@ def _get_device_type_from_components( return DeviceType.Thermostat _LOGGER.warning("Unknown device type, falling back to plug") return DeviceType.Plug + + @staticmethod + def _get_device_info( + info: dict[str, Any], discovery_info: dict[str, Any] | None + ) -> _DeviceInfo: + """Get model information for a device.""" + di = info["get_device_info"] + components = [comp["id"] for comp in info["component_nego"]["component_list"]] + + # Get model/region info + short_name = di["model"] + region = None + if discovery_info: + device_model = discovery_info["device_model"] + long_name, _, region = device_model.partition("(") + if region: # P100 doesn't have region + region = region.replace(")", "") + else: + long_name = short_name + if not region: # some devices have region in specs + region = di.get("specs") + + # Get other info + device_family = di["type"] + device_type = SmartDevice._get_device_type_from_components( + components, device_family + ) + fw_version_full = di["fw_ver"] + firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + _protocol, devicetype = device_family.split(".") + # Brand inferred from SMART.KASAPLUG/SMART.TAPOPLUG etc. + brand = devicetype[:4].lower() + + return _DeviceInfo( + short_name=short_name, + long_name=long_name, + brand=brand, + device_family=device_family, + device_type=device_type, + hardware_version=di["hw_ver"], + firmware_version=firmware_version, + firmware_build=firmware_build, + requires_auth=True, + region=region, + ) diff --git a/kasa/smartcamera/smartcamera.py b/kasa/smartcamera/smartcamera.py index b99945b38..2c09e4dd8 100644 --- a/kasa/smartcamera/smartcamera.py +++ b/kasa/smartcamera/smartcamera.py @@ -43,7 +43,7 @@ def _get_device_info( long_name = discovery_info["device_model"] if discovery_info else short_name device_type = SmartCamera._get_device_type_from_sysinfo(basic_info) fw_version_full = basic_info["sw_version"] - firmare_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) return _DeviceInfo( short_name=basic_info["device_model"], long_name=long_name, @@ -51,7 +51,7 @@ def _get_device_info( device_family=basic_info["device_type"], device_type=device_type, hardware_version=basic_info["hw_version"], - firmare_version=firmare_version, + firmware_version=firmware_version, firmware_build=firmware_build, requires_auth=True, region=basic_info.get("region"), diff --git a/tests/conftest.py b/tests/conftest.py index c56cba0fc..3ff110967 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,7 @@ from .device_fixtures import * # noqa: F403 from .discovery_fixtures import * # noqa: F403 +from .fixtureinfo import fixture_info # noqa: F401 # Parametrize tests to run with device both on and off turn_on = pytest.mark.parametrize("turn_on", [True, False]) diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 359d71648..faaec64ff 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -188,11 +188,12 @@ def parametrize( data_root_filter=None, device_type_filter=None, ids=None, + fixture_name="dev", ): if ids is None: ids = idgenerator return pytest.mark.parametrize( - "dev", + fixture_name, filter_fixtures( desc, model_filter=model_filter, @@ -407,22 +408,28 @@ async def _discover_update_and_close(ip, username, password) -> Device: return await _update_and_close(d) -async def get_device_for_fixture(fixture_data: FixtureInfo) -> Device: +async def get_device_for_fixture( + fixture_data: FixtureInfo, *, verbatim=False, update_after_init=True +) -> Device: # if the wanted file is not an absolute path, prepend the fixtures directory d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)( host="127.0.0.123" ) if fixture_data.protocol in {"SMART", "SMART.CHILD"}: - d.protocol = FakeSmartProtocol(fixture_data.data, fixture_data.name) + d.protocol = FakeSmartProtocol( + fixture_data.data, fixture_data.name, verbatim=verbatim + ) elif fixture_data.protocol == "SMARTCAMERA": - d.protocol = FakeSmartCameraProtocol(fixture_data.data, fixture_data.name) + d.protocol = FakeSmartCameraProtocol( + fixture_data.data, fixture_data.name, verbatim=verbatim + ) else: - d.protocol = FakeIotProtocol(fixture_data.data) + d.protocol = FakeIotProtocol(fixture_data.data, verbatim=verbatim) discovery_data = None if "discovery_result" in fixture_data.data: - discovery_data = {"result": fixture_data.data["discovery_result"]} + discovery_data = fixture_data.data["discovery_result"] elif "system" in fixture_data.data: discovery_data = { "system": {"get_sysinfo": fixture_data.data["system"]["get_sysinfo"]} @@ -431,7 +438,8 @@ async def get_device_for_fixture(fixture_data: FixtureInfo) -> Device: if discovery_data: # Child devices do not have discovery info d.update_from_discover_info(discovery_data) - await _update_and_close(d) + if update_after_init: + await _update_and_close(d) return d diff --git a/tests/fakeprotocol_iot.py b/tests/fakeprotocol_iot.py index 2e3d2810d..b03564d1d 100644 --- a/tests/fakeprotocol_iot.py +++ b/tests/fakeprotocol_iot.py @@ -177,9 +177,9 @@ def success(res): class FakeIotProtocol(IotProtocol): - def __init__(self, info, fixture_name=None): + def __init__(self, info, fixture_name=None, *, verbatim=False): super().__init__( - transport=FakeIotTransport(info, fixture_name), + transport=FakeIotTransport(info, fixture_name, verbatim=verbatim), ) async def query(self, request, retry_count: int = 3): @@ -189,21 +189,33 @@ async def query(self, request, retry_count: int = 3): class FakeIotTransport(BaseTransport): - def __init__(self, info, fixture_name=None): + def __init__(self, info, fixture_name=None, *, verbatim=False): super().__init__(config=DeviceConfig("127.0.0.123")) info = copy.deepcopy(info) self.discovery_data = info self.fixture_name = fixture_name self.writer = None self.reader = None + self.verbatim = verbatim + + # When True verbatim will bypass any extra processing of missing + # methods and is used to test the fixture creation itself. + if verbatim: + self.proto = copy.deepcopy(info) + else: + self.proto = self._build_fake_proto(info) + + @staticmethod + def _build_fake_proto(info): + """Create an internal protocol with extra data not in the fixture.""" proto = copy.deepcopy(FakeIotTransport.baseproto) for target in info: - # print("target %s" % target) if target != "discovery_result": for cmd in info[target]: # print("initializing tgt %s cmd %s" % (target, cmd)) proto[target][cmd] = info[target][cmd] + # if we have emeter support, we need to add the missing pieces for module in ["emeter", "smartlife.iot.common.emeter"]: if ( @@ -223,10 +235,7 @@ def __init__(self, info, fixture_name=None): dummy_data = emeter_commands[module][etype] # print("got %s %s from dummy: %s" % (module, etype, dummy_data)) proto[module][etype] = dummy_data - - # print("initialized: %s" % proto[module]) - - self.proto = proto + return proto @property def default_port(self) -> int: @@ -421,8 +430,20 @@ def set_time(self, new_state: dict, *args): } async def send(self, request, port=9999): - proto = self.proto + if not self.verbatim: + return await self._send(request, port) + # Simply return whatever is in the fixture + response = {} + for target in request: + if target in self.proto: + response.update({target: self.proto[target]}) + else: + response.update({"err_msg": "module not support"}) + return copy.deepcopy(response) + + async def _send(self, request, port=9999): + proto = self.proto # collect child ids from context try: child_ids = request["context"]["child_ids"] diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index bde908851..99b75a1ac 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -11,9 +11,11 @@ class FakeSmartProtocol(SmartProtocol): - def __init__(self, info, fixture_name, *, is_child=False): + def __init__(self, info, fixture_name, *, is_child=False, verbatim=False): super().__init__( - transport=FakeSmartTransport(info, fixture_name, is_child=is_child), + transport=FakeSmartTransport( + info, fixture_name, is_child=is_child, verbatim=verbatim + ), ) async def query(self, request, retry_count: int = 3): @@ -34,6 +36,7 @@ def __init__( fix_incomplete_fixture_lists=True, is_child=False, get_child_fixtures=True, + verbatim=False, ): super().__init__( config=DeviceConfig( @@ -64,6 +67,13 @@ def __init__( self.warn_fixture_missing_methods = warn_fixture_missing_methods self.fix_incomplete_fixture_lists = fix_incomplete_fixture_lists + # When True verbatim will bypass any extra processing of missing + # methods and is used to test the fixture creation itself. + self.verbatim = verbatim + if verbatim: + self.warn_fixture_missing_methods = False + self.fix_incomplete_fixture_lists = False + @property def default_port(self): """Default port for the transport.""" @@ -444,10 +454,10 @@ async def _send_request(self, request_dict: dict): return await self._handle_control_child(request_dict["params"]) params = request_dict.get("params", {}) - if method == "component_nego" or method[:4] == "get_": + if method in {"component_nego", "qs_component_nego"} or method[:4] == "get_": if method in info: result = copy.deepcopy(info[method]) - if "start_index" in result and "sum" in result: + if result and "start_index" in result and "sum" in result: list_key = next( iter([key for key in result if isinstance(result[key], list)]) ) @@ -473,6 +483,12 @@ async def _send_request(self, request_dict: dict): ] return {"result": result, "error_code": 0} + if self.verbatim: + return { + "error_code": SmartErrorCode.PARAMS_ERROR.value, + "method": method, + } + if ( # FIXTURE_MISSING is for service calls not in place when # SMART fixtures started to be generated diff --git a/tests/fakeprotocol_smartcamera.py b/tests/fakeprotocol_smartcamera.py index e84b5bf99..4059fbfbb 100644 --- a/tests/fakeprotocol_smartcamera.py +++ b/tests/fakeprotocol_smartcamera.py @@ -2,6 +2,7 @@ import copy from json import loads as json_loads +from typing import Any from kasa import Credentials, DeviceConfig, SmartProtocol from kasa.protocols.smartcameraprotocol import SmartCameraProtocol @@ -11,9 +12,11 @@ class FakeSmartCameraProtocol(SmartCameraProtocol): - def __init__(self, info, fixture_name, *, is_child=False): + def __init__(self, info, fixture_name, *, is_child=False, verbatim=False): super().__init__( - transport=FakeSmartCameraTransport(info, fixture_name, is_child=is_child), + transport=FakeSmartCameraTransport( + info, fixture_name, is_child=is_child, verbatim=verbatim + ), ) async def query(self, request, retry_count: int = 3): @@ -30,6 +33,7 @@ def __init__( *, list_return_size=10, is_child=False, + verbatim=False, ): super().__init__( config=DeviceConfig( @@ -41,6 +45,9 @@ def __init__( ), ) self.fixture_name = fixture_name + # When True verbatim will bypass any extra processing of missing + # methods and is used to test the fixture creation itself. + self.verbatim = verbatim if not is_child: self.info = copy.deepcopy(info) self.child_protocols = FakeSmartTransport._get_child_protocols( @@ -70,11 +77,11 @@ async def send(self, request: str): responses = [] for request in params["requests"]: response = await self._send_request(request) # type: ignore[arg-type] + response["method"] = request["method"] # type: ignore[index] + responses.append(response) # Devices do not continue after error if response["error_code"] != 0: break - response["method"] = request["method"] # type: ignore[index] - responses.append(response) return {"result": {"responses": responses}, "error_code": 0} else: return await self._send_request(request_dict) @@ -129,6 +136,15 @@ def _get_param_set_value(info: dict, set_keys: list[str], value): ], } + @staticmethod + def _get_second_key(request_dict: dict[str, Any]) -> str: + assert ( + len(request_dict) == 2 + ), f"Unexpected dict {request_dict}, should be length 2" + it = iter(request_dict) + next(it, None) + return next(it) + async def _send_request(self, request_dict: dict): method = request_dict["method"] @@ -175,6 +191,14 @@ async def _send_request(self, request_dict: dict): return {"error_code": -1} break return {"error_code": 0} + elif method == "get": + module = self._get_second_key(request_dict) + get_method = f"get_{module}" + if get_method in info: + result = copy.deepcopy(info[get_method]["get"]) + return {**result, "error_code": 0} + else: + return {"error_code": -1} elif method[:3] == "get": params = request_dict.get("params") if method in info: diff --git a/tests/fixtureinfo.py b/tests/fixtureinfo.py index 7d7607efc..644a3810d 100644 --- a/tests/fixtureinfo.py +++ b/tests/fixtureinfo.py @@ -1,13 +1,16 @@ from __future__ import annotations +import copy import glob import json import os from pathlib import Path from typing import Iterable, NamedTuple -from kasa.device_factory import _get_device_type_from_sys_info +import pytest + from kasa.device_type import DeviceType +from kasa.iot import IotDevice from kasa.smart.smartdevice import SmartDevice from kasa.smartcamera.smartcamera import SmartCamera @@ -171,7 +174,10 @@ def _device_type_match(fixture_data: FixtureInfo, device_type): in device_type ) elif fixture_data.protocol == "IOT": - return _get_device_type_from_sys_info(fixture_data.data) in device_type + return ( + IotDevice._get_device_type_from_sys_info(fixture_data.data) + in device_type + ) elif fixture_data.protocol == "SMARTCAMERA": info = fixture_data.data["getDeviceInfo"]["device_info"]["basic_info"] return SmartCamera._get_device_type_from_sysinfo(info) in device_type @@ -206,3 +212,14 @@ def _device_type_match(fixture_data: FixtureInfo, device_type): print(f"\t{value.name}") filtered.sort() return filtered + + +@pytest.fixture( + params=filter_fixtures("all fixture infos"), + ids=idgenerator, +) +def fixture_info(request, mocker): + """Return raw discovery file contents as JSON. Used for discovery tests.""" + fixture_info = request.param + fixture_data = copy.deepcopy(fixture_info.data) + return FixtureInfo(fixture_info.name, fixture_info.protocol, fixture_data) diff --git a/tests/fixtures/smart/P100_1.0.0_1.1.3.json b/tests/fixtures/smart/P100(US)_1.0.0_1.1.3.json similarity index 100% rename from tests/fixtures/smart/P100_1.0.0_1.1.3.json rename to tests/fixtures/smart/P100(US)_1.0.0_1.1.3.json diff --git a/tests/fixtures/smart/P100_1.0.0_1.3.7.json b/tests/fixtures/smart/P100(US)_1.0.0_1.3.7.json similarity index 100% rename from tests/fixtures/smart/P100_1.0.0_1.3.7.json rename to tests/fixtures/smart/P100(US)_1.0.0_1.3.7.json diff --git a/tests/fixtures/smart/P100_1.0.0_1.4.0.json b/tests/fixtures/smart/P100(US)_1.0.0_1.4.0.json similarity index 100% rename from tests/fixtures/smart/P100_1.0.0_1.4.0.json rename to tests/fixtures/smart/P100(US)_1.0.0_1.4.0.json diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 4f71888ba..9102a5287 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -19,9 +19,9 @@ ) from kasa.device_factory import ( Device, + IotDevice, SmartCamera, SmartDevice, - _get_device_type_from_sys_info, connect, get_device_class_from_family, get_protocol, @@ -182,12 +182,12 @@ async def test_device_types(dev: Device): res = SmartCamera._get_device_type_from_sysinfo(dev.sys_info) elif isinstance(dev, SmartDevice): assert dev._discovery_info - device_type = cast(str, dev._discovery_info["result"]["device_type"]) + device_type = cast(str, dev._discovery_info["device_type"]) res = SmartDevice._get_device_type_from_components( list(dev._components.keys()), device_type ) else: - res = _get_device_type_from_sys_info(dev._last_update) + res = IotDevice._get_device_type_from_sys_info(dev._last_update) assert dev.device_type == res diff --git a/tests/test_devtools.py b/tests/test_devtools.py new file mode 100644 index 000000000..fa60acd5b --- /dev/null +++ b/tests/test_devtools.py @@ -0,0 +1,103 @@ +"""Module for dump_devinfo tests.""" + +import pytest + +from devtools.dump_devinfo import get_legacy_fixture, get_smart_fixtures +from kasa.iot import IotDevice +from kasa.protocols import IotProtocol +from kasa.smart import SmartDevice +from kasa.smartcamera import SmartCamera + +from .conftest import ( + FixtureInfo, + get_device_for_fixture, + parametrize, +) + +smart_fixtures = parametrize( + "smart fixtures", protocol_filter={"SMART"}, fixture_name="fixture_info" +) +smartcamera_fixtures = parametrize( + "smartcamera fixtures", protocol_filter={"SMARTCAMERA"}, fixture_name="fixture_info" +) +iot_fixtures = parametrize( + "iot fixtures", protocol_filter={"IOT"}, fixture_name="fixture_info" +) + + +async def test_fixture_names(fixture_info: FixtureInfo): + """Test that device info gets the right fixture names.""" + if fixture_info.protocol in {"SMARTCAMERA"}: + device_info = SmartCamera._get_device_info( + fixture_info.data, fixture_info.data.get("discovery_result") + ) + elif fixture_info.protocol in {"SMART"}: + device_info = SmartDevice._get_device_info( + fixture_info.data, fixture_info.data.get("discovery_result") + ) + elif fixture_info.protocol in {"SMART.CHILD"}: + device_info = SmartDevice._get_device_info(fixture_info.data, None) + else: + device_info = IotDevice._get_device_info(fixture_info.data, None) + + region = f"({device_info.region})" if device_info.region else "" + expected = f"{device_info.long_name}{region}_{device_info.hardware_version}_{device_info.firmware_version}.json" + assert fixture_info.name == expected + + +@smart_fixtures +async def test_smart_fixtures(fixture_info: FixtureInfo): + """Test that smart fixtures are created the same.""" + dev = await get_device_for_fixture(fixture_info, verbatim=True) + assert isinstance(dev, SmartDevice) + if dev.children: + pytest.skip("Test not currently implemented for devices with children.") + fixtures = await get_smart_fixtures( + dev.protocol, + discovery_info=fixture_info.data.get("discovery_result"), + batch_size=5, + ) + fixture_result = fixtures[0] + + assert fixture_info.data == fixture_result.data + + +@smartcamera_fixtures +async def test_smartcamera_fixtures(fixture_info: FixtureInfo): + """Test that smartcamera fixtures are created the same.""" + dev = await get_device_for_fixture(fixture_info, verbatim=True) + assert isinstance(dev, SmartCamera) + if dev.children: + pytest.skip("Test not currently implemented for devices with children.") + fixtures = await get_smart_fixtures( + dev.protocol, + discovery_info=fixture_info.data.get("discovery_result"), + batch_size=5, + ) + fixture_result = fixtures[0] + + assert fixture_info.data == fixture_result.data + + +@iot_fixtures +async def test_iot_fixtures(fixture_info: FixtureInfo): + """Test that iot fixtures are created the same.""" + # Iot fixtures often do not have enough data to perform a device update() + # without missing info being added to suppress the update + dev = await get_device_for_fixture( + fixture_info, verbatim=True, update_after_init=False + ) + assert isinstance(dev.protocol, IotProtocol) + + fixture = await get_legacy_fixture( + dev.protocol, discovery_info=fixture_info.data.get("discovery_result") + ) + fixture_result = fixture + + created_fixture = { + key: val for key, val in fixture_result.data.items() if "err_code" not in val + } + saved_fixture = { + key: val for key, val in fixture_info.data.items() if "err_code" not in val + } + assert saved_fixture == created_fixture diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 8d4582b06..cfc85cd14 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -39,7 +39,7 @@ json_dumps, ) from kasa.exceptions import AuthenticationError, UnsupportedDeviceError -from kasa.iot import IotDevice +from kasa.iot import IotDevice, IotPlug from kasa.transports.aestransport import AesEncyptionSession from kasa.transports.xortransport import XorEncryption, XorTransport @@ -119,10 +119,11 @@ async def test_type_detection_lightstrip(dev: Device): assert d.device_type == DeviceType.LightStrip -async def test_type_unknown(): +async def test_type_unknown(caplog): invalid_info = {"system": {"get_sysinfo": {"type": "nosuchtype"}}} - with pytest.raises(UnsupportedDeviceError): - Discover._get_device_class(invalid_info) + assert Discover._get_device_class(invalid_info) is IotPlug + msg = "Unknown device type nosuchtype, falling back to plug" + assert msg in caplog.text @pytest.mark.parametrize("custom_port", [123, None]) @@ -266,7 +267,6 @@ async def test_discover_single_no_response(mocker): "Unable to find the device type field", {"system": {"get_sysinfo": {"missing_type": 1}}}, ), - ("Unknown device type: foo", {"system": {"get_sysinfo": {"type": "foo"}}}), ] From 0c40939624fea4fef2cc482dec45852f2da68e66 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:53:49 +0000 Subject: [PATCH 683/892] Allow callable coroutines for feature setters (#1272) --- kasa/feature.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index e61cba07c..bbf473758 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -71,7 +71,7 @@ from dataclasses import dataclass from enum import Enum, auto from functools import cached_property -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, Coroutine if TYPE_CHECKING: from .device import Device @@ -136,10 +136,10 @@ class Category(Enum): name: str #: Type of the feature type: Feature.Type - #: Name of the property that allows accessing the value + #: Callable or name of the property that allows accessing the value attribute_getter: str | Callable | None = None - #: Name of the method that allows changing the value - attribute_setter: str | None = None + #: Callable coroutine or name of the method that allows changing the value + attribute_setter: str | Callable[..., Coroutine[Any, Any, Any]] | None = None #: Container storing the data, this overrides 'device' for getters container: Any = None #: Icon suggestion @@ -258,11 +258,16 @@ async def set_value(self, value: int | float | bool | str | Enum | None) -> Any: f" - allowed: {self.choices}" ) - container = self.container if self.container is not None else self.device + if callable(self.attribute_setter): + attribute_setter = self.attribute_setter + else: + container = self.container if self.container is not None else self.device + attribute_setter = getattr(container, self.attribute_setter) + if self.type == Feature.Type.Action: - return await getattr(container, self.attribute_setter)() + return await attribute_setter() - return await getattr(container, self.attribute_setter)(value) + return await attribute_setter(value) def __repr__(self) -> str: try: From a01247d48f411786f17eb36e97d9def2a69aa9a8 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 18 Nov 2024 18:46:36 +0000 Subject: [PATCH 684/892] Remove support for python <3.11 (#1273) Python 3.11 ships with latest Debian Bookworm. pypy is not that widely used with this library based on statistics. It could be added back when pypy supports python 3.11. --- .github/workflows/ci.yml | 30 +- .pre-commit-config.yaml | 2 +- devtools/bench/utils/original.py | 2 +- devtools/dump_devinfo.py | 14 +- devtools/parse_pcap.py | 2 +- kasa/cachedzoneinfo.py | 1 - kasa/cli/common.py | 3 +- kasa/cli/time.py | 2 +- kasa/device.py | 6 +- kasa/deviceconfig.py | 38 ++- kasa/discover.py | 45 ++- kasa/exceptions.py | 2 +- kasa/feature.py | 5 +- kasa/httpclient.py | 6 +- kasa/iot/iotbulb.py | 4 +- kasa/iot/iotdevice.py | 4 +- kasa/iot/iottimezone.py | 1 - kasa/iot/modules/lightpreset.py | 19 +- kasa/iot/modules/rulemodule.py | 9 +- kasa/iot/modules/time.py | 4 +- kasa/json.py | 3 +- kasa/module.py | 2 +- kasa/modulemapping.pyi | 4 +- kasa/protocols/iotprotocol.py | 3 +- kasa/protocols/protocol.py | 7 +- kasa/protocols/smartprotocol.py | 3 +- kasa/smart/modules/firmware.py | 22 +- kasa/smart/modules/time.py | 5 +- kasa/smart/smartdevice.py | 4 +- kasa/smart/smartmodule.py | 4 +- kasa/smartcamera/modules/time.py | 5 +- kasa/transports/aestransport.py | 6 +- kasa/transports/klaptransport.py | 3 +- kasa/transports/sslaestransport.py | 6 +- kasa/transports/xortransport.py | 5 +- pyproject.toml | 9 +- tests/conftest.py | 4 +- tests/fixtureinfo.py | 3 +- tests/smart/modules/test_autooff.py | 3 +- tests/smart/modules/test_firmware.py | 2 +- tests/smart/modules/test_waterleak.py | 3 +- tests/smartcamera/test_smartcamera.py | 4 +- tests/test_bulb.py | 4 +- tests/test_childdevice.py | 11 +- tests/test_cli.py | 4 +- tests/test_common_modules.py | 2 +- tests/test_device.py | 2 +- tests/test_discovery.py | 2 +- tests/test_emeter.py | 4 +- tests/test_feature.py | 7 +- tests/test_httpclient.py | 3 +- tests/test_iotdevice.py | 2 +- tests/test_readme_examples.py | 2 +- tests/test_smartdevice.py | 4 +- uv.lock | 440 ++------------------------ 55 files changed, 176 insertions(+), 620 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3e81ec1d..c8c145cc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: - python-version: ["3.12"] + python-version: ["3.13"] steps: - name: "Checkout source files" @@ -39,11 +39,10 @@ jobs: name: Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} needs: linting runs-on: ${{ matrix.os }} - continue-on-error: ${{ startsWith(matrix.python-version, 'pypy') }} strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.9", "pypy-3.10"] + python-version: ["3.11", "3.12", "3.13"] os: [ubuntu-latest, macos-latest, windows-latest] extras: [false, true] exclude: @@ -51,25 +50,6 @@ jobs: extras: true - os: windows-latest extras: true - - os: ubuntu-latest - python-version: "pypy-3.9" - extras: true - - os: ubuntu-latest - python-version: "pypy-3.10" - extras: true - - os: ubuntu-latest - python-version: "3.9" - extras: true - - os: ubuntu-latest - python-version: "3.10" - extras: true - # Exclude pypy on windows due to significant performance issues - # running pytest requires ~12 min instead of 2 min on other platforms - - os: windows-latest - python-version: "pypy-3.9" - - os: windows-latest - python-version: "pypy-3.10" - steps: - uses: "actions/checkout@v4" @@ -79,16 +59,10 @@ jobs: python-version: ${{ matrix.python-version }} uv-version: ${{ env.UV_VERSION }} uv-install-options: ${{ matrix.extras == true && '--all-extras' || '' }} - - name: "Run tests (no coverage)" - if: ${{ startsWith(matrix.python-version, 'pypy') }} - run: | - uv run pytest -n auto - name: "Run tests (with coverage)" - if: ${{ !startsWith(matrix.python-version, 'pypy') }} run: | uv run pytest -n auto --cov kasa --cov-report xml - name: "Upload coverage to Codecov" - if: ${{ !startsWith(matrix.python-version, 'pypy') }} uses: "codecov/codecov-action@v4" with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4ac50dfa8..adcad8e4e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: - id: check-ast - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.7 + rev: v0.7.4 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/devtools/bench/utils/original.py b/devtools/bench/utils/original.py index d3543afd4..27e9088a8 100644 --- a/devtools/bench/utils/original.py +++ b/devtools/bench/utils/original.py @@ -1,7 +1,7 @@ """Original implementation of the TP-Link Smart Home protocol.""" import struct -from typing import Generator +from collections.abc import Generator class OriginalTPLinkSmartHomeProtocol: diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 2650882eb..13c597e0b 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -315,7 +315,7 @@ async def cli( credentials = Credentials(username=username, password=password) if host is not None: if discovery_info: - click.echo("Host and discovery info given, trying connect on %s." % host) + click.echo(f"Host and discovery info given, trying connect on {host}.") di = json.loads(discovery_info) dr = DiscoveryResult.from_dict(di) @@ -358,7 +358,7 @@ async def cli( "Could not find a protocol for the given parameters." ) else: - click.echo("Host given, performing discovery on %s." % host) + click.echo(f"Host given, performing discovery on {host}.") device = await Discover.discover_single( host, credentials=credentials, @@ -374,13 +374,13 @@ async def cli( ) else: click.echo( - "No --host given, performing discovery on %s. Use --target to override." - % target + "No --host given, performing discovery on" + f" {target}. Use --target to override." ) devices = await Discover.discover( target=target, credentials=credentials, discovery_timeout=discovery_timeout ) - click.echo("Detected %s devices" % len(devices)) + click.echo(f"Detected {len(devices)} devices") for dev in devices.values(): await handle_device( basedir, @@ -446,7 +446,7 @@ async def get_legacy_fixture( dr = DiscoveryResult.from_dict(discovery_info) final["discovery_result"] = dr.to_dict() - click.echo("Got %s successes" % len(successes)) + click.echo(f"Got {len(successes)} successes") click.echo(click.style("## device info file ##", bold=True)) sysinfo = final["system"]["get_sysinfo"] @@ -959,7 +959,7 @@ async def get_smart_fixtures( dr = DiscoveryResult.from_dict(discovery_info) # type: ignore final["discovery_result"] = dr.to_dict() - click.echo("Got %s successes" % len(successes)) + click.echo(f"Got {len(successes)} successes") click.echo(click.style("## device info file ##", bold=True)) if "get_device_info" in final: diff --git a/devtools/parse_pcap.py b/devtools/parse_pcap.py index f08e4dd3a..f21897552 100644 --- a/devtools/parse_pcap.py +++ b/devtools/parse_pcap.py @@ -67,7 +67,7 @@ def parse_pcap(file): for module, cmds in json_payload.items(): seen_items["modules"][module] += 1 if "err_code" in cmds: - echo("[red]Got error for module: %s[/red]" % cmds) + echo(f"[red]Got error for module: {cmds}[/red]") continue for cmd, response in cmds.items(): diff --git a/kasa/cachedzoneinfo.py b/kasa/cachedzoneinfo.py index c70e83097..f3f5f4412 100644 --- a/kasa/cachedzoneinfo.py +++ b/kasa/cachedzoneinfo.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio - from zoneinfo import ZoneInfo diff --git a/kasa/cli/common.py b/kasa/cli/common.py index fe7be761a..649df0655 100644 --- a/kasa/cli/common.py +++ b/kasa/cli/common.py @@ -5,9 +5,10 @@ import json import re import sys +from collections.abc import Callable from contextlib import contextmanager from functools import singledispatch, update_wrapper, wraps -from typing import TYPE_CHECKING, Any, Callable, Final +from typing import TYPE_CHECKING, Any, Final import asyncclick as click diff --git a/kasa/cli/time.py b/kasa/cli/time.py index 9e9301087..e2cb4c16c 100644 --- a/kasa/cli/time.py +++ b/kasa/cli/time.py @@ -2,10 +2,10 @@ from __future__ import annotations +import zoneinfo from datetime import datetime import asyncclick as click -import zoneinfo from kasa import ( Device, diff --git a/kasa/device.py b/kasa/device.py index 755c89efe..b0f110cbd 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -110,11 +110,9 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime, tzinfo -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypeAlias from warnings import warn -from typing_extensions import TypeAlias - from .credentials import Credentials as _Credentials from .device_type import DeviceType from .deviceconfig import ( @@ -213,7 +211,7 @@ def __init__( self._last_update: Any = None _LOGGER.debug("Initializing %s of type %s", host, type(self)) self._device_type = DeviceType.Unknown - # TODO: typing Any is just as using Optional[Dict] would require separate + # TODO: typing Any is just as using dict | None would require separate # checks in accessors. the @updated_required decorator does not ensure # mypy that these are not accessed incorrectly. self._discovery_info: dict[str, Any] | None = None diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index f4a5f2a30..ede3f5955 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -27,14 +27,12 @@ """ -# Note that this module does not work with from __future__ import annotations -# due to it's use of type returned by fields() which becomes a string with the import. -# https://bugs.python.org/issue39442 -# ruff: noqa: FA100 +# Module cannot use from __future__ import annotations until migrated to mashumaru +# as dataclass.fields() will not resolve the type. import logging from dataclasses import asdict, dataclass, field, fields, is_dataclass from enum import Enum -from typing import TYPE_CHECKING, Any, Dict, Optional, TypedDict, Union +from typing import TYPE_CHECKING, Any, Optional, TypedDict from .credentials import Credentials from .exceptions import KasaException @@ -118,15 +116,15 @@ class DeviceConnectionParameters: device_family: DeviceFamily encryption_type: DeviceEncryptionType - login_version: Optional[int] = None + login_version: int | None = None https: bool = False @staticmethod def from_values( device_family: str, encryption_type: str, - login_version: Optional[int] = None, - https: Optional[bool] = None, + login_version: int | None = None, + https: bool | None = None, ) -> "DeviceConnectionParameters": """Return connection parameters from string values.""" try: @@ -145,7 +143,7 @@ def from_values( ) from ex @staticmethod - def from_dict(connection_type_dict: Dict[str, Any]) -> "DeviceConnectionParameters": + def from_dict(connection_type_dict: dict[str, Any]) -> "DeviceConnectionParameters": """Return connection parameters from dict.""" if ( isinstance(connection_type_dict, dict) @@ -163,9 +161,9 @@ def from_dict(connection_type_dict: Dict[str, Any]) -> "DeviceConnectionParamete raise KasaException(f"Invalid connection type data for {connection_type_dict}") - def to_dict(self) -> Dict[str, Union[str, int, bool]]: + def to_dict(self) -> dict[str, str | int | bool]: """Convert connection params to dict.""" - result: Dict[str, Union[str, int]] = { + result: dict[str, str | int] = { "device_family": self.device_family.value, "encryption_type": self.encryption_type.value, "https": self.https, @@ -183,17 +181,17 @@ class DeviceConfig: #: IP address or hostname host: str #: Timeout for querying the device - timeout: Optional[int] = DEFAULT_TIMEOUT + timeout: int | None = DEFAULT_TIMEOUT #: Override the default 9999 port to support port forwarding - port_override: Optional[int] = None + port_override: int | None = None #: Credentials for devices requiring authentication - credentials: Optional[Credentials] = None + credentials: Credentials | None = 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:`Device.credentials_hash` - credentials_hash: Optional[str] = None + credentials_hash: str | None = None #: The protocol specific type of connection. Defaults to the legacy type. - batch_size: Optional[int] = None + batch_size: int | None = None #: The batch size for protoools supporting multiple request batches. connection_type: DeviceConnectionParameters = field( default_factory=lambda: DeviceConnectionParameters( @@ -208,7 +206,7 @@ class DeviceConfig: #: Set a custom http_client for the device to use. http_client: Optional["ClientSession"] = field(default=None, compare=False) - aes_keys: Optional[KeyPairDict] = None + aes_keys: KeyPairDict | None = None def __post_init__(self) -> None: if self.connection_type is None: @@ -219,9 +217,9 @@ def __post_init__(self) -> None: def to_dict( self, *, - credentials_hash: Optional[str] = None, + credentials_hash: str | None = None, exclude_credentials: bool = False, - ) -> Dict[str, Dict[str, str]]: + ) -> dict[str, dict[str, str]]: """Convert device config to dict.""" if credentials_hash is not None or exclude_credentials: self.credentials = None @@ -230,7 +228,7 @@ def to_dict( return _dataclass_to_dict(self) @staticmethod - def from_dict(config_dict: Dict[str, Dict[str, str]]) -> "DeviceConfig": + def from_dict(config_dict: dict[str, dict[str, str]]) -> "DeviceConfig": """Return device config from dict.""" if isinstance(config_dict, dict): return _dataclass_from_dict(DeviceConfig, config_dict) diff --git a/kasa/discover.py b/kasa/discover.py index 14a324720..b20b9ec33 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -89,26 +89,19 @@ import secrets import socket import struct +from asyncio import timeout as asyncio_timeout from asyncio.transports import DatagramTransport +from collections.abc import Callable, Coroutine from dataclasses import dataclass, field from pprint import pformat as pf from typing import ( TYPE_CHECKING, Any, - Callable, - Coroutine, - Dict, NamedTuple, - Optional, - Type, cast, ) from aiohttp import ClientSession - -# 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 mashumaro import field_options from mashumaro.config import BaseConfig @@ -156,7 +149,7 @@ class ConnectAttempt(NamedTuple): OnDiscoveredCallable = Callable[[Device], Coroutine] OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Coroutine] OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None] -DeviceDict = Dict[str, Device] +DeviceDict = dict[str, Device] NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = { "device_id": lambda x: "REDACTED_" + x[9::], @@ -676,7 +669,7 @@ def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice: data = redact_data(info, IOT_REDACTORS) if Discover._redact_data else info _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) - device_class = cast(Type[IotDevice], Discover._get_device_class(info)) + device_class = cast(type[IotDevice], Discover._get_device_class(info)) device = device_class(config.host, config=config) sys_info = info["system"]["get_sysinfo"] if device_type := sys_info.get("mic_type", sys_info.get("type")): @@ -830,9 +823,9 @@ class EncryptionScheme(_DiscoveryBaseMixin): """Base model for encryption scheme of discovery result.""" is_support_https: bool - encrypt_type: Optional[str] = None # noqa: UP007 - http_port: Optional[int] = None # noqa: UP007 - lv: Optional[int] = None # noqa: UP007 + encrypt_type: str | None = None + http_port: int | None = None + lv: int | None = None @dataclass @@ -854,18 +847,18 @@ class DiscoveryResult(_DiscoveryBaseMixin): ip: str mac: str mgt_encrypt_schm: EncryptionScheme - device_name: Optional[str] = None # noqa: UP007 - encrypt_info: Optional[EncryptionInfo] = None # noqa: UP007 - encrypt_type: Optional[list[str]] = None # noqa: UP007 - decrypted_data: Optional[dict] = None # noqa: UP007 - is_reset_wifi: Optional[bool] = field( # noqa: UP007 + device_name: str | None = None + encrypt_info: EncryptionInfo | None = None + encrypt_type: list[str] | None = None + decrypted_data: dict | None = None + is_reset_wifi: bool | None = field( metadata=field_options(alias="isResetWiFi"), default=None ) - firmware_version: Optional[str] = None # noqa: UP007 - hardware_version: Optional[str] = None # noqa: UP007 - hw_ver: Optional[str] = None # noqa: UP007 - owner: Optional[str] = None # noqa: UP007 - is_support_iot_cloud: Optional[bool] = None # noqa: UP007 - obd_src: Optional[str] = None # noqa: UP007 - factory_default: Optional[bool] = None # noqa: UP007 + firmware_version: str | None = None + hardware_version: str | None = None + hw_ver: str | None = None + owner: str | None = None + is_support_iot_cloud: bool | None = None + obd_src: str | None = None + factory_default: bool | None = None diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 7bc796535..a0ecbf8fe 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -39,7 +39,7 @@ class DeviceError(KasaException): """Base exception for device errors.""" def __init__(self, *args: Any, **kwargs: Any) -> None: - self.error_code: SmartErrorCode | None = kwargs.get("error_code", None) + self.error_code: SmartErrorCode | None = kwargs.get("error_code") super().__init__(*args) def __repr__(self) -> str: diff --git a/kasa/feature.py b/kasa/feature.py index bbf473758..d747338da 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -68,10 +68,11 @@ from __future__ import annotations import logging +from collections.abc import Callable, Coroutine from dataclasses import dataclass from enum import Enum, auto from functools import cached_property -from typing import TYPE_CHECKING, Any, Callable, Coroutine +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from .device import Device @@ -244,7 +245,7 @@ async def set_value(self, value: int | float | bool | str | Enum | None) -> Any: if self.attribute_setter is None: raise ValueError("Tried to set read-only feature.") if self.type == Feature.Type.Number: # noqa: SIM102 - if not isinstance(value, (int, float)): + if not isinstance(value, int | float): raise ValueError("value must be a number") if value < self.minimum_value or value > self.maximum_value: raise ValueError( diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 8b69df52d..87e3626a3 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -6,7 +6,7 @@ import logging import ssl import time -from typing import Any, Dict +from typing import Any import aiohttp from yarl import URL @@ -98,7 +98,7 @@ async def post( # 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): + if json and not isinstance(json, dict): data = json json = None try: @@ -131,7 +131,7 @@ async def post( raise _ConnectionError( f"Device connection error: {self._config.host}: {ex}", ex ) from ex - except (aiohttp.ServerTimeoutError, asyncio.TimeoutError) as ex: + except (aiohttp.ServerTimeoutError, TimeoutError) as ex: raise TimeoutError( "Unable to query the device, " + f"timed out: {self._config.host}: {ex}", diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 423e6f386..e0e95020c 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -5,7 +5,7 @@ import logging import re from enum import Enum -from typing import Optional, cast +from typing import cast from pydantic.v1 import BaseModel, Field, root_validator @@ -49,7 +49,7 @@ class TurnOnBehavior(BaseModel): """ #: Index of preset to use, or ``None`` for the last known state. - preset: Optional[int] = Field(alias="index", default=None) # noqa: UP007 + preset: int | None = Field(alias="index", default=None) #: Wanted behavior mode: BehaviorMode diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 95a774df9..89b7219f4 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -17,9 +17,9 @@ import functools import inspect import logging -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from datetime import datetime, timedelta, tzinfo -from typing import TYPE_CHECKING, Any, Callable, cast +from typing import TYPE_CHECKING, Any, cast from warnings import warn from ..device import Device, WifiNetwork, _DeviceInfo diff --git a/kasa/iot/iottimezone.py b/kasa/iot/iottimezone.py index ddeef0753..65538341b 100644 --- a/kasa/iot/iottimezone.py +++ b/kasa/iot/iottimezone.py @@ -5,7 +5,6 @@ import logging from datetime import datetime, timedelta, tzinfo from typing import cast - from zoneinfo import ZoneInfo from ..cachedzoneinfo import CachedZoneInfo diff --git a/kasa/iot/modules/lightpreset.py b/kasa/iot/modules/lightpreset.py index 13fee33ee..6b46f7535 100644 --- a/kasa/iot/modules/lightpreset.py +++ b/kasa/iot/modules/lightpreset.py @@ -4,7 +4,7 @@ from collections.abc import Sequence from dataclasses import asdict -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from pydantic.v1 import BaseModel, Field @@ -17,22 +17,25 @@ if TYPE_CHECKING: pass +# type ignore can be removed after migration mashumaro: +# error: Signature of "__replace__" incompatible with supertype "LightState" -class IotLightPreset(BaseModel, LightState): + +class IotLightPreset(BaseModel, LightState): # type: ignore[override] """Light configuration preset.""" index: int = Field(kw_only=True) brightness: int = Field(kw_only=True) # These are not available for effect mode presets on light strips - hue: Optional[int] = Field(kw_only=True, default=None) # noqa: UP007 - saturation: Optional[int] = Field(kw_only=True, default=None) # noqa: UP007 - color_temp: Optional[int] = Field(kw_only=True, default=None) # noqa: UP007 + hue: int | None = Field(kw_only=True, default=None) + saturation: int | None = Field(kw_only=True, default=None) + color_temp: int | None = Field(kw_only=True, default=None) # Variables for effect mode presets - custom: Optional[int] = Field(kw_only=True, default=None) # noqa: UP007 - id: Optional[str] = Field(kw_only=True, default=None) # noqa: UP007 - mode: Optional[int] = Field(kw_only=True, default=None) # noqa: UP007 + custom: int | None = Field(kw_only=True, default=None) + id: str | None = Field(kw_only=True, default=None) + mode: int | None = Field(kw_only=True, default=None) class LightPreset(IotModule, LightPresetInterface): diff --git a/kasa/iot/modules/rulemodule.py b/kasa/iot/modules/rulemodule.py index 2515b71bd..fbe3f261e 100644 --- a/kasa/iot/modules/rulemodule.py +++ b/kasa/iot/modules/rulemodule.py @@ -4,7 +4,6 @@ import logging from enum import Enum -from typing import Dict, List, Optional from pydantic.v1 import BaseModel @@ -35,20 +34,20 @@ class Rule(BaseModel): id: str name: str enable: bool - wday: List[int] # noqa: UP006 + wday: list[int] repeat: bool # start action - sact: Optional[Action] # noqa: UP007 + sact: Action | None stime_opt: TimeOption smin: int - eact: Optional[Action] # noqa: UP007 + eact: Action | None etime_opt: TimeOption emin: int # Only on bulbs - s_light: Optional[Dict] # noqa: UP006,UP007 + s_light: dict | None _LOGGER = logging.getLogger(__name__) diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py index f65dd9107..896172de6 100644 --- a/kasa/iot/modules/time.py +++ b/kasa/iot/modules/time.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timezone, tzinfo +from datetime import UTC, datetime, tzinfo from ...exceptions import KasaException from ...interfaces import Time as TimeInterface @@ -13,7 +13,7 @@ class Time(IotModule, TimeInterface): """Implements the timezone settings.""" - _timezone: tzinfo = timezone.utc + _timezone: tzinfo = UTC def query(self) -> dict: """Request time and timezone.""" diff --git a/kasa/json.py b/kasa/json.py index 6f1149fa5..21c6fa00e 100755 --- a/kasa/json.py +++ b/kasa/json.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from typing import Any try: import orjson diff --git a/kasa/module.py b/kasa/module.py index edd264770..f3d0dade8 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -29,7 +29,7 @@ Modules support typing via the Module names in Module: ->>> from typing_extensions import reveal_type, TYPE_CHECKING +>>> from typing import reveal_type, TYPE_CHECKING >>> light_effect = dev.modules.get("LightEffect") >>> light_effect_typed = dev.modules.get(Module.LightEffect) >>> if TYPE_CHECKING: diff --git a/kasa/modulemapping.pyi b/kasa/modulemapping.pyi index 8d110d39f..a49de389d 100644 --- a/kasa/modulemapping.pyi +++ b/kasa/modulemapping.pyi @@ -50,9 +50,7 @@ def _test_module_mapping_typing() -> None: This is tested during the mypy run and needs to be in this file. """ - from typing import Any, NewType, cast - - from typing_extensions import assert_type + from typing import Any, NewType, assert_type, cast from .iot.iotmodule import IotModule from .module import Module diff --git a/kasa/protocols/iotprotocol.py b/kasa/protocols/iotprotocol.py index 7e3e45a64..3bc6c4545 100755 --- a/kasa/protocols/iotprotocol.py +++ b/kasa/protocols/iotprotocol.py @@ -4,8 +4,9 @@ import asyncio import logging +from collections.abc import Callable from pprint import pformat as pf -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any from ..deviceconfig import DeviceConfig from ..exceptions import ( diff --git a/kasa/protocols/protocol.py b/kasa/protocols/protocol.py index b879f0ae1..211a7b5ae 100755 --- a/kasa/protocols/protocol.py +++ b/kasa/protocols/protocol.py @@ -17,10 +17,9 @@ import logging import struct from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, TypeVar, cast -# When support for cpython older than 3.11 is dropped -# async_timeout can be replaced with asyncio.timeout from ..deviceconfig import DeviceConfig _LOGGER = logging.getLogger(__name__) @@ -36,7 +35,7 @@ def redact_data(data: _T, redactors: dict[str, Callable[[Any], Any] | None]) -> _T: """Redact sensitive data for logging.""" - if not isinstance(data, (dict, list)): + if not isinstance(data, dict | list): return data if isinstance(data, list): diff --git a/kasa/protocols/smartprotocol.py b/kasa/protocols/smartprotocol.py index 3df8d9377..d3fd9bfda 100644 --- a/kasa/protocols/smartprotocol.py +++ b/kasa/protocols/smartprotocol.py @@ -11,8 +11,9 @@ import logging import time import uuid +from collections.abc import Callable from pprint import pformat as pf -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any from ..exceptions import ( SMART_AUTHENTICATION_ERRORS, diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index f9e6b0341..5956a3575 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -4,13 +4,11 @@ import asyncio import logging -from collections.abc import Coroutine +from asyncio import timeout as asyncio_timeout +from collections.abc import Callable, Coroutine from datetime import date -from typing import TYPE_CHECKING, Callable, Optional +from typing import TYPE_CHECKING -# 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 pydantic.v1 import BaseModel, Field, validator from ...exceptions import KasaException @@ -41,11 +39,11 @@ class UpdateInfo(BaseModel): """Update info status object.""" status: int = Field(alias="type") - version: Optional[str] = Field(alias="fw_ver", default=None) # noqa: UP007 - release_date: Optional[date] = None # noqa: UP007 - release_notes: Optional[str] = Field(alias="release_note", default=None) # noqa: UP007 - fw_size: Optional[int] = None # noqa: UP007 - oem_id: Optional[str] = None # noqa: UP007 + version: str | None = Field(alias="fw_ver", default=None) + release_date: date | None = None + release_notes: str | None = Field(alias="release_note", default=None) + fw_size: int | None = None + oem_id: str | None = None needs_upgrade: bool = Field(alias="need_to_upgrade") @validator("release_date", pre=True) @@ -58,9 +56,7 @@ def _release_date_optional(cls, v: str) -> str | None: @property def update_available(self) -> bool: """Return True if update available.""" - if self.status != 0: - return True - return False + return self.status != 0 class Firmware(SmartModule): diff --git a/kasa/smart/modules/time.py b/kasa/smart/modules/time.py index d82991c19..f986fa34f 100644 --- a/kasa/smart/modules/time.py +++ b/kasa/smart/modules/time.py @@ -2,9 +2,8 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone, tzinfo +from datetime import UTC, datetime, timedelta, timezone, tzinfo from typing import cast - from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from ...cachedzoneinfo import CachedZoneInfo @@ -19,7 +18,7 @@ class Time(SmartModule, TimeInterface): REQUIRED_COMPONENT = "time" QUERY_GETTER_NAME = "get_device_time" - _timezone: tzinfo = timezone.utc + _timezone: tzinfo = UTC def _initialize_features(self) -> None: """Initialize features after the initial update.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 64f6aa7c2..07c8c154e 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -6,7 +6,7 @@ import logging import time from collections.abc import Mapping, Sequence -from datetime import datetime, timedelta, timezone, tzinfo +from datetime import UTC, datetime, timedelta, tzinfo from typing import TYPE_CHECKING, Any, cast from ..device import Device, WifiNetwork, _DeviceInfo @@ -520,7 +520,7 @@ def time(self) -> datetime: return time_mod.time # We have no device time, use current local time. - return datetime.now(timezone.utc).astimezone().replace(microsecond=0) + return datetime.now(UTC).astimezone().replace(microsecond=0) @property def on_since(self) -> datetime | None: diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index f0b95ecba..c56970438 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -4,9 +4,7 @@ import logging from collections.abc import Awaitable, Callable, Coroutine -from typing import TYPE_CHECKING, Any - -from typing_extensions import Concatenate, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..module import Module diff --git a/kasa/smartcamera/modules/time.py b/kasa/smartcamera/modules/time.py index 33070892d..6f40aafb5 100644 --- a/kasa/smartcamera/modules/time.py +++ b/kasa/smartcamera/modules/time.py @@ -2,9 +2,8 @@ from __future__ import annotations -from datetime import datetime, timezone, tzinfo +from datetime import UTC, datetime, tzinfo from typing import cast - from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from ...cachedzoneinfo import CachedZoneInfo @@ -20,7 +19,7 @@ class Time(SmartCameraModule, TimeInterface): QUERY_MODULE_NAME = "system" QUERY_SECTION_NAMES = "basic" - _timezone: tzinfo = timezone.utc + _timezone: tzinfo = UTC _time: datetime def _initialize_features(self) -> None: diff --git a/kasa/transports/aestransport.py b/kasa/transports/aestransport.py index 590c2f72f..3466ca98e 100644 --- a/kasa/transports/aestransport.py +++ b/kasa/transports/aestransport.py @@ -12,7 +12,7 @@ import time from collections.abc import AsyncGenerator from enum import Enum, auto -from typing import TYPE_CHECKING, Any, Dict, cast +from typing import TYPE_CHECKING, Any, cast from cryptography.hazmat.primitives import hashes, padding, serialization from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding @@ -193,7 +193,7 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]: ) if TYPE_CHECKING: - resp_dict = cast(Dict[str, Any], resp_dict) + resp_dict = cast(dict[str, Any], resp_dict) assert self._encryption_session is not None self._handle_response_error_code( @@ -326,7 +326,7 @@ async def perform_handshake(self) -> None: ) if TYPE_CHECKING: - resp_dict = cast(Dict[str, Any], resp_dict) + resp_dict = cast(dict[str, Any], resp_dict) self._handle_response_error_code(resp_dict, "Unable to complete handshake") diff --git a/kasa/transports/klaptransport.py b/kasa/transports/klaptransport.py index 49de4c54d..8934b2cc8 100644 --- a/kasa/transports/klaptransport.py +++ b/kasa/transports/klaptransport.py @@ -51,7 +51,8 @@ import struct import time from asyncio import Future -from typing import TYPE_CHECKING, Any, Generator, cast +from collections.abc import Generator +from typing import TYPE_CHECKING, Any, cast from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes diff --git a/kasa/transports/sslaestransport.py b/kasa/transports/sslaestransport.py index 16054833e..2061d293a 100644 --- a/kasa/transports/sslaestransport.py +++ b/kasa/transports/sslaestransport.py @@ -9,7 +9,7 @@ import secrets import ssl from enum import Enum, auto -from typing import TYPE_CHECKING, Any, Dict, cast +from typing import TYPE_CHECKING, Any, cast from yarl import URL @@ -227,7 +227,7 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]: ) if TYPE_CHECKING: - resp_dict = cast(Dict[str, Any], resp_dict) + resp_dict = cast(dict[str, Any], resp_dict) assert self._encryption_session is not None if "result" in resp_dict and "response" in resp_dict["result"]: @@ -393,7 +393,7 @@ async def perform_handshake1(self) -> tuple[str, str, str]: raise AuthenticationError(f"Error trying handshake1: {resp_dict}") if TYPE_CHECKING: - resp_dict = cast(Dict[str, Any], resp_dict) + resp_dict = cast(dict[str, Any], resp_dict) server_nonce = resp_dict["result"]["data"]["nonce"] device_confirm = resp_dict["result"]["data"]["device_confirm"] diff --git a/kasa/transports/xortransport.py b/kasa/transports/xortransport.py index 932a9415b..77a232f09 100644 --- a/kasa/transports/xortransport.py +++ b/kasa/transports/xortransport.py @@ -18,12 +18,9 @@ import logging import socket import struct +from asyncio import timeout as asyncio_timeout from collections.abc import Generator -# 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 kasa.deviceconfig import DeviceConfig from kasa.exceptions import KasaException, _RetryableError from kasa.json import loads as json_loads diff --git a/pyproject.toml b/pyproject.toml index fde4a7c63..8c47ad5a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,14 +5,12 @@ description = "Python API for TP-Link Kasa and Tapo devices" license = {text = "GPL-3.0-or-later"} authors = [ { name = "python-kasa developers" }] readme = "README.md" -requires-python = ">=3.9,<4.0" +requires-python = ">=3.11,<4.0" dependencies = [ "asyncclick>=8.1.7", "pydantic>=1.10.15", "cryptography>=1.9", - "async-timeout>=3.0.0", "aiohttp>=3", - "typing-extensions>=4.12.2,<5.0", "tzdata>=2024.2 ; platform_system == 'Windows'", "mashumaro>=3.14", ] @@ -21,8 +19,6 @@ classifiers = [ "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -60,6 +56,7 @@ dev-dependencies = [ "mypy~=1.0", "pytest-xdist>=3.6.1", "pytest-socket>=0.7.0", + "ruff==0.7.4", ] @@ -120,7 +117,7 @@ ignore-path-errors = ["docs/source/index.rst;D000"] [tool.ruff] -target-version = "py38" +target-version = "py311" [tool.ruff.lint] select = [ diff --git a/tests/conftest.py b/tests/conftest.py index 3ff110967..1b11e01bb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,7 +28,7 @@ async def handle_turn_on(dev, turn_on): await dev.turn_off() -@pytest.fixture() +@pytest.fixture def dummy_protocol(): """Return a smart protocol instance with a mocking-ready dummy transport.""" @@ -95,7 +95,7 @@ def pytest_collection_modifyitems(config, items): for item in items: item.add_marker(pytest.mark.enable_socket) else: - print("Running against ip %s" % config.getoption("--ip")) + print("Running against ip {}".format(config.getoption("--ip"))) requires_dummy = pytest.mark.skip( reason="test requires to be run against dummy data" ) diff --git a/tests/fixtureinfo.py b/tests/fixtureinfo.py index 644a3810d..5364e0187 100644 --- a/tests/fixtureinfo.py +++ b/tests/fixtureinfo.py @@ -4,8 +4,9 @@ import glob import json import os +from collections.abc import Iterable from pathlib import Path -from typing import Iterable, NamedTuple +from typing import NamedTuple import pytest diff --git a/tests/smart/modules/test_autooff.py b/tests/smart/modules/test_autooff.py index 412bd68c2..b8042aa60 100644 --- a/tests/smart/modules/test_autooff.py +++ b/tests/smart/modules/test_autooff.py @@ -2,7 +2,6 @@ import sys from datetime import datetime -from typing import Optional import pytest from pytest_mock import MockerFixture @@ -23,7 +22,7 @@ [ ("auto_off_enabled", "enabled", bool), ("auto_off_minutes", "delay", int), - ("auto_off_at", "auto_off_at", Optional[datetime]), + ("auto_off_at", "auto_off_at", datetime | None), ], ) @pytest.mark.skipif( diff --git a/tests/smart/modules/test_firmware.py b/tests/smart/modules/test_firmware.py index 8f6fe6ebf..3115c56f1 100644 --- a/tests/smart/modules/test_firmware.py +++ b/tests/smart/modules/test_firmware.py @@ -74,7 +74,7 @@ async def test_update_available_without_cloud(dev: SmartDevice): pytest.param(False, pytest.raises(KasaException), id="not-available"), ], ) -@pytest.mark.requires_dummy() +@pytest.mark.requires_dummy async def test_firmware_update( dev: SmartDevice, mocker: MockerFixture, diff --git a/tests/smart/modules/test_waterleak.py b/tests/smart/modules/test_waterleak.py index 318973922..1821e6e07 100644 --- a/tests/smart/modules/test_waterleak.py +++ b/tests/smart/modules/test_waterleak.py @@ -17,8 +17,7 @@ ("feature", "prop_name", "type"), [ ("water_alert", "alert", int), - # Can be converted to 'datetime | None' after py3.9 support is dropped - ("water_alert_timestamp", "alert_timestamp", (datetime, type(None))), + ("water_alert_timestamp", "alert_timestamp", datetime | None), ("water_leak", "status", Enum), ], ) diff --git a/tests/smartcamera/test_smartcamera.py b/tests/smartcamera/test_smartcamera.py index 1185943ac..6b69cbb77 100644 --- a/tests/smartcamera/test_smartcamera.py +++ b/tests/smartcamera/test_smartcamera.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import patch import pytest @@ -91,7 +91,7 @@ async def test_hub(dev): @device_smartcamera async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory): """Test a child device gets the time from it's parent module.""" - fallback_time = datetime.now(timezone.utc).astimezone().replace(microsecond=0) + fallback_time = datetime.now(UTC).astimezone().replace(microsecond=0) assert dev.time != fallback_time module = dev.modules[Module.Time] await module.set_time(fallback_time) diff --git a/tests/test_bulb.py b/tests/test_bulb.py index 53a3542a3..ac4400731 100644 --- a/tests/test_bulb.py +++ b/tests/test_bulb.py @@ -278,7 +278,7 @@ async def test_non_variable_temp(dev: Device): @dimmable_iot @turn_on async def test_dimmable_brightness(dev: IotBulb, turn_on): - assert isinstance(dev, (IotBulb, IotDimmer)) + assert isinstance(dev, IotBulb | IotDimmer) light = dev.modules.get(Module.Light) assert light await handle_turn_on(dev, turn_on) @@ -375,7 +375,7 @@ async def test_list_presets(dev: IotBulb): ] assert len(presets) == len(raw_presets) - for preset, raw in zip(presets, raw_presets): + for preset, raw in zip(presets, raw_presets, strict=False): assert preset.index == raw["index"] assert preset.brightness == raw["brightness"] assert preset.hue == raw["hue"] diff --git a/tests/test_childdevice.py b/tests/test_childdevice.py index a2d780471..d734d82c0 100644 --- a/tests/test_childdevice.py +++ b/tests/test_childdevice.py @@ -1,6 +1,5 @@ import inspect -import sys -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest from freezegun.api import FrozenDateTimeFactory @@ -58,10 +57,6 @@ async def test_childdevice_update(dev, dummy_protocol, mocker): @strip_smart -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="exceptiongroup requires python3.11+", -) async def test_childdevice_properties(dev: SmartChildDevice): """Check that accessing childdevice properties do not raise exceptions.""" assert len(dev.children) > 0 @@ -125,7 +120,7 @@ async def test_parent_property(dev: Device): @has_children_smart -@pytest.mark.requires_dummy() +@pytest.mark.requires_dummy async def test_child_time(dev: Device, freezer: FrozenDateTimeFactory): """Test a child device gets the time from it's parent module. @@ -135,7 +130,7 @@ async def test_child_time(dev: Device, freezer: FrozenDateTimeFactory): if not dev.children: pytest.skip(f"Device {dev} fixture does not have any children") - fallback_time = datetime.now(timezone.utc).astimezone().replace(microsecond=0) + fallback_time = datetime.now(UTC).astimezone().replace(microsecond=0) assert dev.parent is None for child in dev.children: assert child.time != fallback_time diff --git a/tests/test_cli.py b/tests/test_cli.py index 78cfb34c3..65710dcff 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,12 +3,12 @@ import re from datetime import datetime from unittest.mock import ANY +from zoneinfo import ZoneInfo import asyncclick as click import pytest from asyncclick.testing import CliRunner from pytest_mock import MockerFixture -from zoneinfo import ZoneInfo from kasa import ( AuthenticationError, @@ -58,7 +58,7 @@ pytestmark = [pytest.mark.requires_dummy] -@pytest.fixture() +@pytest.fixture def runner(): """Runner fixture that unsets the KASA_ environment variables for tests.""" KASA_VARS = {k: None for k, v in os.environ.items() if k.startswith("KASA_")} diff --git a/tests/test_common_modules.py b/tests/test_common_modules.py index 168b4090f..bd6189c51 100644 --- a/tests/test_common_modules.py +++ b/tests/test_common_modules.py @@ -1,8 +1,8 @@ from datetime import datetime +from zoneinfo import ZoneInfo import pytest from pytest_mock import MockerFixture -from zoneinfo import ZoneInfo from kasa import Device, LightState, Module diff --git a/tests/test_device.py b/tests/test_device.py index 1b943fa44..e461033dd 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -6,11 +6,11 @@ import inspect import pkgutil import sys +import zoneinfo from contextlib import AbstractContextManager, nullcontext from unittest.mock import AsyncMock, patch import pytest -import zoneinfo import kasa from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module diff --git a/tests/test_discovery.py b/tests/test_discovery.py index cfc85cd14..787dea0e0 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -7,11 +7,11 @@ import logging import re import socket +from asyncio import timeout as asyncio_timeout 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 cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding diff --git a/tests/test_emeter.py b/tests/test_emeter.py index 4829ff0ca..1c4f51adc 100644 --- a/tests/test_emeter.py +++ b/tests/test_emeter.py @@ -68,7 +68,7 @@ async def test_get_emeter_realtime(dev): @has_emeter_iot -@pytest.mark.requires_dummy() +@pytest.mark.requires_dummy async def test_get_emeter_daily(dev): emeter = dev.modules[Module.Energy] @@ -88,7 +88,7 @@ async def test_get_emeter_daily(dev): @has_emeter_iot -@pytest.mark.requires_dummy() +@pytest.mark.requires_dummy async def test_get_emeter_monthly(dev): emeter = dev.modules[Module.Energy] diff --git a/tests/test_feature.py b/tests/test_feature.py index 938f9547a..0ff6e1be7 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -1,5 +1,4 @@ import logging -import sys from unittest.mock import AsyncMock, patch import pytest @@ -14,7 +13,7 @@ class DummyDevice: pass -@pytest.fixture() +@pytest.fixture def dummy_feature() -> Feature: # create_autospec for device slows tests way too much, so we use a dummy here @@ -159,10 +158,6 @@ async def test_precision_hint(dummy_feature, precision_hint): assert f"{round(dummy_value, precision_hint)} dummyunit" in repr(dummy_feature) -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="exceptiongroup requires python3.11+", -) async def test_feature_setters(dev: Device, mocker: MockerFixture): """Test that all feature setters query something.""" diff --git a/tests/test_httpclient.py b/tests/test_httpclient.py index ed7ce5383..906b39ed9 100644 --- a/tests/test_httpclient.py +++ b/tests/test_httpclient.py @@ -1,4 +1,3 @@ -import asyncio import re import aiohttp @@ -32,7 +31,7 @@ "Unable to query the device, timed out: ", ), ( - asyncio.TimeoutError(), + TimeoutError(), TimeoutError, "Unable to query the device, timed out: ", ), diff --git a/tests/test_iotdevice.py b/tests/test_iotdevice.py index a22ed6cef..68ee7a51a 100644 --- a/tests/test_iotdevice.py +++ b/tests/test_iotdevice.py @@ -89,7 +89,7 @@ async def test_state_info(dev): assert isinstance(dev.state_information, dict) -@pytest.mark.requires_dummy() +@pytest.mark.requires_dummy @device_iot async def test_invalid_connection(mocker, dev): mocker.patch.object(FakeIotProtocol, "query", side_effect=KasaException) diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index f8f433d47..394a3aff7 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -145,7 +145,7 @@ def test_tutorial_examples(readmes_mock): assert not res["failed"] -@pytest.fixture() +@pytest.fixture async def readmes_mock(mocker): fixture_infos = { "127.0.0.1": get_fixture_info("KP303(UK)_1.0_1.0.3.json", "IOT"), # Strip diff --git a/tests/test_smartdevice.py b/tests/test_smartdevice.py index 657fdd2f1..9d5956dca 100644 --- a/tests/test_smartdevice.py +++ b/tests/test_smartdevice.py @@ -26,7 +26,7 @@ @device_smart -@pytest.mark.requires_dummy() +@pytest.mark.requires_dummy async def test_try_get_response(dev: SmartDevice, caplog): mock_response: dict = { "get_device_info": SmartErrorCode.PARAMS_ERROR, @@ -38,7 +38,7 @@ async def test_try_get_response(dev: SmartDevice, caplog): @device_smart -@pytest.mark.requires_dummy() +@pytest.mark.requires_dummy async def test_update_no_device_info(dev: SmartDevice, mocker: MockerFixture): mock_response: dict = { "get_device_usage": {}, diff --git a/uv.lock b/uv.lock index 79a6f9898..bd1b1496a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -requires-python = ">=3.9, <4.0" +requires-python = ">=3.11, <4.0" resolution-markers = [ "python_full_version < '3.13'", "python_full_version >= '3.13'", @@ -21,7 +21,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, - { name = "async-timeout", marker = "python_full_version < '3.11'" }, { name = "attrs" }, { name = "frozenlist" }, { name = "multidict" }, @@ -29,21 +28,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/17/7e/16e57e6cf20eb62481a2f9ce8674328407187950ccc602ad07c685279141/aiohttp-3.10.10.tar.gz", hash = "sha256:0631dd7c9f0822cc61c88586ca76d5b5ada26538097d0f1df510b082bad3411a", size = 7542993 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/dd/3d40c0e67e79c5c42671e3e268742f1ff96c6573ca43823563d01abd9475/aiohttp-3.10.10-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:be7443669ae9c016b71f402e43208e13ddf00912f47f623ee5994e12fc7d4b3f", size = 586969 }, - { url = "https://files.pythonhosted.org/packages/75/64/8de41b5555e5b43ef6d4ed1261891d33fe45ecc6cb62875bfafb90b9ab93/aiohttp-3.10.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b06b7843929e41a94ea09eb1ce3927865387e3e23ebe108e0d0d09b08d25be9", size = 399367 }, - { url = "https://files.pythonhosted.org/packages/96/36/27bd62ea7ce43906d1443a73691823fc82ffb8fa03276b0e2f7e1037c286/aiohttp-3.10.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:333cf6cf8e65f6a1e06e9eb3e643a0c515bb850d470902274239fea02033e9a8", size = 390720 }, - { url = "https://files.pythonhosted.org/packages/e8/4d/d516b050d811ce0dd26325c383013c104ffa8b58bd361b82e52833f68e78/aiohttp-3.10.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:274cfa632350225ce3fdeb318c23b4a10ec25c0e2c880eff951a3842cf358ac1", size = 1228820 }, - { url = "https://files.pythonhosted.org/packages/53/94/964d9327a3e336d89aad52260836e4ec87fdfa1207176550fdf384eaffe7/aiohttp-3.10.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9e5e4a85bdb56d224f412d9c98ae4cbd032cc4f3161818f692cd81766eee65a", size = 1264616 }, - { url = "https://files.pythonhosted.org/packages/0c/20/70ce17764b685ca8f5bf4d568881b4e1f1f4ea5e8170f512fdb1a33859d2/aiohttp-3.10.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b606353da03edcc71130b52388d25f9a30a126e04caef1fd637e31683033abd", size = 1298402 }, - { url = "https://files.pythonhosted.org/packages/d1/d1/5248225ccc687f498d06c3bca5af2647a361c3687a85eb3aedcc247ee1aa/aiohttp-3.10.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab5a5a0c7a7991d90446a198689c0535be89bbd6b410a1f9a66688f0880ec026", size = 1222205 }, - { url = "https://files.pythonhosted.org/packages/f2/a3/9296b27cc5d4feadf970a14d0694902a49a985f3fae71b8322a5f77b0baa/aiohttp-3.10.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:578a4b875af3e0daaf1ac6fa983d93e0bbfec3ead753b6d6f33d467100cdc67b", size = 1193804 }, - { url = "https://files.pythonhosted.org/packages/d9/07/f3760160feb12ac51a6168a6da251a4a8f2a70733d49e6ceb9b3e6ee2f03/aiohttp-3.10.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8105fd8a890df77b76dd3054cddf01a879fc13e8af576805d667e0fa0224c35d", size = 1193544 }, - { url = "https://files.pythonhosted.org/packages/7e/4c/93a70f9a4ba1c30183a6dd68bfa79cddbf9a674f162f9c62e823a74a5515/aiohttp-3.10.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3bcd391d083f636c06a68715e69467963d1f9600f85ef556ea82e9ef25f043f7", size = 1193047 }, - { url = "https://files.pythonhosted.org/packages/ff/a3/36a1e23ff00c7a0cd696c5a28db05db25dc42bfc78c508bd78623ff62a4a/aiohttp-3.10.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fbc6264158392bad9df19537e872d476f7c57adf718944cc1e4495cbabf38e2a", size = 1247201 }, - { url = "https://files.pythonhosted.org/packages/55/ae/95399848557b98bb2c402d640b2276ce3a542b94dba202de5a5a1fe29abe/aiohttp-3.10.10-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e48d5021a84d341bcaf95c8460b152cfbad770d28e5fe14a768988c461b821bc", size = 1264102 }, - { url = "https://files.pythonhosted.org/packages/38/f5/02e5c72c1b60d7cceb30b982679a26167e84ac029fd35a93dd4da52c50a3/aiohttp-3.10.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2609e9ab08474702cc67b7702dbb8a80e392c54613ebe80db7e8dbdb79837c68", size = 1215760 }, - { url = "https://files.pythonhosted.org/packages/30/17/1463840bad10d02d0439068f37ce5af0b383884b0d5838f46fb027e233bf/aiohttp-3.10.10-cp310-cp310-win32.whl", hash = "sha256:84afcdea18eda514c25bc68b9af2a2b1adea7c08899175a51fe7c4fb6d551257", size = 362678 }, - { url = "https://files.pythonhosted.org/packages/dd/01/a0ef707d93e867a43abbffee3a2cdf30559910750b9176b891628c7ad074/aiohttp-3.10.10-cp310-cp310-win_amd64.whl", hash = "sha256:9c72109213eb9d3874f7ac8c0c5fa90e072d678e117d9061c06e30c85b4cf0e6", size = 381097 }, { url = "https://files.pythonhosted.org/packages/72/31/3c351d17596194e5a38ef169a4da76458952b2497b4b54645b9d483cbbb0/aiohttp-3.10.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c30a0eafc89d28e7f959281b58198a9fa5e99405f716c0289b7892ca345fe45f", size = 586501 }, { url = "https://files.pythonhosted.org/packages/a4/a8/a559d09eb08478cdead6b7ce05b0c4a133ba27fcdfa91e05d2e62867300d/aiohttp-3.10.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:258c5dd01afc10015866114e210fb7365f0d02d9d059c3c3415382ab633fcbcb", size = 398993 }, { url = "https://files.pythonhosted.org/packages/c5/47/7736d4174613feef61d25332c3bd1a4f8ff5591fbd7331988238a7299485/aiohttp-3.10.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:15ecd889a709b0080f02721255b3f80bb261c2293d3c748151274dfea93ac871", size = 390647 }, @@ -89,21 +73,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/8c/804bb2e837a175635d2000a0659eafc15b2e9d92d3d81c8f69e141ecd0b0/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:77abf6665ae54000b98b3c742bc6ea1d1fb31c394bcabf8b5d2c1ac3ebfe7f3b", size = 1281546 }, { url = "https://files.pythonhosted.org/packages/89/c0/862e6a9de3d6eeb126cd9d9ea388243b70df9b871ce1a42b193b7a4a77fc/aiohttp-3.10.10-cp313-cp313-win32.whl", hash = "sha256:4470c73c12cd9109db8277287d11f9dd98f77fc54155fc71a7738a83ffcc8ea8", size = 357516 }, { url = "https://files.pythonhosted.org/packages/ae/63/3e1aee3e554263f3f1011cca50d78a4894ae16ce99bf78101ac3a2f0ef74/aiohttp-3.10.10-cp313-cp313-win_amd64.whl", hash = "sha256:486f7aabfa292719a2753c016cc3a8f8172965cabb3ea2e7f7436c7f5a22a151", size = 376785 }, - { url = "https://files.pythonhosted.org/packages/3b/8e/0946283d36f156b0fda6564a97a91f42881d3efcdf236223989a93e7caa0/aiohttp-3.10.10-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:01948b1d570f83ee7bbf5a60ea2375a89dfb09fd419170e7f5af029510033d24", size = 588595 }, - { url = "https://files.pythonhosted.org/packages/05/84/acf2e75f652c02c304d547507597f0e322e43e8531adaba5798b3b90f29e/aiohttp-3.10.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9fc1500fd2a952c5c8e3b29aaf7e3cc6e27e9cfc0a8819b3bce48cc1b849e4cc", size = 400259 }, - { url = "https://files.pythonhosted.org/packages/54/0a/2395fb583fdf490240f6990a3196e8a56d91081ac1dcdca4ca542a013d9b/aiohttp-3.10.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f614ab0c76397661b90b6851a030004dac502e48260ea10f2441abd2207fbcc7", size = 391585 }, - { url = "https://files.pythonhosted.org/packages/4f/1d/d2ecab9d1f71adf073a01233a94500e6416d760ba4b04049d432c8b22589/aiohttp-3.10.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00819de9e45d42584bed046314c40ea7e9aea95411b38971082cad449392b08c", size = 1233673 }, - { url = "https://files.pythonhosted.org/packages/e8/0d/0e198499fdc48b75cca3e32f60a87e1ed9919c51647f1ca87160e27477ac/aiohttp-3.10.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05646ebe6b94cc93407b3bf34b9eb26c20722384d068eb7339de802154d61bc5", size = 1271052 }, - { url = "https://files.pythonhosted.org/packages/df/a3/e5e2061cfeb2e37bc7eeaa1320858194dad3e01127a844036dc1f8af5953/aiohttp-3.10.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:998f3bd3cfc95e9424a6acd7840cbdd39e45bc09ef87533c006f94ac47296090", size = 1304875 }, - { url = "https://files.pythonhosted.org/packages/31/40/ba9e90b88b5e227954858184be687019ba662f072b27ae3b7cba3ae64661/aiohttp-3.10.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9010c31cd6fa59438da4e58a7f19e4753f7f264300cd152e7f90d4602449762", size = 1225430 }, - { url = "https://files.pythonhosted.org/packages/86/5f/8e17c6ba352e654a12d9fc67fadeb89f3f92675aea43e68a0119cd66b3d0/aiohttp-3.10.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ea7ffc6d6d6f8a11e6f40091a1040995cdff02cfc9ba4c2f30a516cb2633554", size = 1196582 }, - { url = "https://files.pythonhosted.org/packages/00/41/ba0f75f356febbe320abc725f1ad2fccb276d38d998f6cd1630de84c963e/aiohttp-3.10.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ef9c33cc5cbca35808f6c74be11eb7f5f6b14d2311be84a15b594bd3e58b5527", size = 1196719 }, - { url = "https://files.pythonhosted.org/packages/5e/d9/f5e686c9891d70190e8162893b97cc7e47b2d2a516da8fb5dadb30995625/aiohttp-3.10.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ce0cdc074d540265bfeb31336e678b4e37316849d13b308607efa527e981f5c2", size = 1197209 }, - { url = "https://files.pythonhosted.org/packages/25/12/c4b1ea70135afe8a03c0519c29421e8b97fc4afeb5c7fc4b583ffb6c620e/aiohttp-3.10.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:597a079284b7ee65ee102bc3a6ea226a37d2b96d0418cc9047490f231dc09fe8", size = 1251306 }, - { url = "https://files.pythonhosted.org/packages/f8/17/4041d26c5d5bddd928a7f3f2972679de59d65044a208bcd026f43c3675f4/aiohttp-3.10.10-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:7789050d9e5d0c309c706953e5e8876e38662d57d45f936902e176d19f1c58ab", size = 1266087 }, - { url = "https://files.pythonhosted.org/packages/16/41/1b0c191c3477e1d6e5313d4a9fefeb436ab649c498622d4c14a9cc9eee6b/aiohttp-3.10.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e7f8b04d83483577fd9200461b057c9f14ced334dcb053090cea1da9c8321a91", size = 1217338 }, - { url = "https://files.pythonhosted.org/packages/4a/4b/4be4ab18675255178acaf18edda4fb19f15debefc873dfcc9ad6b73d3b2c/aiohttp-3.10.10-cp39-cp39-win32.whl", hash = "sha256:c02a30b904282777d872266b87b20ed8cc0d1501855e27f831320f471d54d983", size = 363262 }, - { url = "https://files.pythonhosted.org/packages/f7/54/e1f69b580e11127deb4c18e765bcc4730cd133ab3c75806c62f985af3e1c/aiohttp-3.10.10-cp39-cp39-win_amd64.whl", hash = "sha256:edfe3341033a6b53a5c522c802deb2079eee5cbfbb0af032a55064bd65c73a23", size = 381766 }, ] [[package]] @@ -141,10 +110,8 @@ name = "anyio" version = "4.6.2.post1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422 } wheels = [ @@ -160,15 +127,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566 }, ] -[[package]] -name = "async-timeout" -version = "4.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/87/d6/21b30a550dafea84b1b8eee21b5e23fa16d010ae006011221f33dcd8d7f8/async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", size = 8345 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/fa/e01228c2938de91d47b307831c62ab9e4001e747789d0b05baf779a6488c/async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028", size = 5721 }, -] - [[package]] name = "asyncclick" version = "8.1.7.2" @@ -218,18 +176,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, - { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, - { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, - { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, - { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, - { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, - { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, - { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, - { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, - { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, - { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, - { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, @@ -264,18 +210,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, - { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220 }, - { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605 }, - { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910 }, - { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200 }, - { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565 }, - { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635 }, - { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218 }, - { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486 }, - { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911 }, - { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632 }, - { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820 }, - { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290 }, ] [[package]] @@ -293,21 +227,6 @@ version = "3.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/8b/825cc84cf13a28bfbcba7c416ec22bf85a9584971be15b21dd8300c65b7f/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", size = 196363 }, - { url = "https://files.pythonhosted.org/packages/23/81/d7eef6a99e42c77f444fdd7bc894b0ceca6c3a95c51239e74a722039521c/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", size = 125639 }, - { url = "https://files.pythonhosted.org/packages/21/67/b4564d81f48042f520c948abac7079356e94b30cb8ffb22e747532cf469d/charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", size = 120451 }, - { url = "https://files.pythonhosted.org/packages/c2/72/12a7f0943dd71fb5b4e7b55c41327ac0a1663046a868ee4d0d8e9c369b85/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", size = 140041 }, - { url = "https://files.pythonhosted.org/packages/67/56/fa28c2c3e31217c4c52158537a2cf5d98a6c1e89d31faf476c89391cd16b/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", size = 150333 }, - { url = "https://files.pythonhosted.org/packages/f9/d2/466a9be1f32d89eb1554cf84073a5ed9262047acee1ab39cbaefc19635d2/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", size = 142921 }, - { url = "https://files.pythonhosted.org/packages/f8/01/344ec40cf5d85c1da3c1f57566c59e0c9b56bcc5566c08804a95a6cc8257/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", size = 144785 }, - { url = "https://files.pythonhosted.org/packages/73/8b/2102692cb6d7e9f03b9a33a710e0164cadfce312872e3efc7cfe22ed26b4/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", size = 146631 }, - { url = "https://files.pythonhosted.org/packages/d8/96/cc2c1b5d994119ce9f088a9a0c3ebd489d360a2eb058e2c8049f27092847/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", size = 140867 }, - { url = "https://files.pythonhosted.org/packages/c9/27/cde291783715b8ec30a61c810d0120411844bc4c23b50189b81188b273db/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", size = 149273 }, - { url = "https://files.pythonhosted.org/packages/3a/a4/8633b0fc1a2d1834d5393dafecce4a1cc56727bfd82b4dc18fc92f0d3cc3/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", size = 152437 }, - { url = "https://files.pythonhosted.org/packages/64/ea/69af161062166b5975ccbb0961fd2384853190c70786f288684490913bf5/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", size = 150087 }, - { url = "https://files.pythonhosted.org/packages/3b/fd/e60a9d9fd967f4ad5a92810138192f825d77b4fa2a557990fd575a47695b/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", size = 145142 }, - { url = "https://files.pythonhosted.org/packages/6d/02/8cb0988a1e49ac9ce2eed1e07b77ff118f2923e9ebd0ede41ba85f2dcb04/charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", size = 94701 }, - { url = "https://files.pythonhosted.org/packages/d6/20/f1d4670a8a723c46be695dff449d86d6092916f9e99c53051954ee33a1bc/charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", size = 102191 }, { url = "https://files.pythonhosted.org/packages/9c/61/73589dcc7a719582bf56aae309b6103d2762b526bffe189d635a7fcfd998/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", size = 193339 }, { url = "https://files.pythonhosted.org/packages/77/d5/8c982d58144de49f59571f940e329ad6e8615e1e82ef84584c5eeb5e1d72/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", size = 124366 }, { url = "https://files.pythonhosted.org/packages/bf/19/411a64f01ee971bed3231111b69eb56f9331a769072de479eae7de52296d/charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", size = 118874 }, @@ -353,21 +272,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, - { url = "https://files.pythonhosted.org/packages/54/2f/28659eee7f5d003e0f5a3b572765bf76d6e0fe6601ab1f1b1dd4cba7e4f1/charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", size = 196326 }, - { url = "https://files.pythonhosted.org/packages/d1/18/92869d5c0057baa973a3ee2af71573be7b084b3c3d428fe6463ce71167f8/charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", size = 125614 }, - { url = "https://files.pythonhosted.org/packages/d6/27/327904c5a54a7796bb9f36810ec4173d2df5d88b401d2b95ef53111d214e/charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", size = 120450 }, - { url = "https://files.pythonhosted.org/packages/a4/23/65af317914a0308495133b2d654cf67b11bbd6ca16637c4e8a38f80a5a69/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", size = 140135 }, - { url = "https://files.pythonhosted.org/packages/f2/41/6190102ad521a8aa888519bb014a74251ac4586cde9b38e790901684f9ab/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", size = 150413 }, - { url = "https://files.pythonhosted.org/packages/7b/ab/f47b0159a69eab9bd915591106859f49670c75f9a19082505ff16f50efc0/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", size = 142992 }, - { url = "https://files.pythonhosted.org/packages/28/89/60f51ad71f63aaaa7e51a2a2ad37919985a341a1d267070f212cdf6c2d22/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", size = 144871 }, - { url = "https://files.pythonhosted.org/packages/0c/48/0050550275fea585a6e24460b42465020b53375017d8596c96be57bfabca/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", size = 146756 }, - { url = "https://files.pythonhosted.org/packages/dc/b5/47f8ee91455946f745e6c9ddbb0f8f50314d2416dd922b213e7d5551ad09/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", size = 141034 }, - { url = "https://files.pythonhosted.org/packages/84/79/5c731059ebab43e80bf61fa51666b9b18167974b82004f18c76378ed31a3/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", size = 149434 }, - { url = "https://files.pythonhosted.org/packages/ca/f3/0719cd09fc4dc42066f239cb3c48ced17fc3316afca3e2a30a4756fe49ab/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", size = 152443 }, - { url = "https://files.pythonhosted.org/packages/f7/0e/c6357297f1157c8e8227ff337e93fd0a90e498e3d6ab96b2782204ecae48/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", size = 150294 }, - { url = "https://files.pythonhosted.org/packages/54/9a/acfa96dc4ea8c928040b15822b59d0863d6e1757fba8bd7de3dc4f761c13/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", size = 145314 }, - { url = "https://files.pythonhosted.org/packages/73/1c/b10a63032eaebb8d7bcb8544f12f063f41f5f463778ac61da15d9985e8b6/charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", size = 94724 }, - { url = "https://files.pythonhosted.org/packages/c5/77/3a78bf28bfaa0863f9cfef278dbeadf55efe064eafff8c7c424ae3c4c1bf/charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", size = 102159 }, { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, ] @@ -399,16 +303,6 @@ version = "7.6.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/52/12/3669b6382792783e92046730ad3327f53b2726f0603f4c311c4da4824222/coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73", size = 798716 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/93/4ad92f71e28ece5c0326e5f4a6630aa4928a8846654a65cfff69b49b95b9/coverage-7.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07", size = 206713 }, - { url = "https://files.pythonhosted.org/packages/01/ae/747a580b1eda3f2e431d87de48f0604bd7bc92e52a1a95185a4aa585bc47/coverage-7.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0", size = 207149 }, - { url = "https://files.pythonhosted.org/packages/07/1a/1f573f8a6145f6d4c9130bbc120e0024daf1b24cf2a78d7393fa6eb6aba7/coverage-7.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72", size = 235584 }, - { url = "https://files.pythonhosted.org/packages/40/42/c8523f2e4db34aa9389caee0d3688b6ada7a84fcc782e943a868a7f302bd/coverage-7.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51", size = 233486 }, - { url = "https://files.pythonhosted.org/packages/8d/95/565c310fffa16ede1a042e9ea1ca3962af0d8eb5543bc72df6b91dc0c3d5/coverage-7.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491", size = 234649 }, - { url = "https://files.pythonhosted.org/packages/d5/81/3b550674d98968ec29c92e3e8650682be6c8b1fa7581a059e7e12e74c431/coverage-7.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b", size = 233744 }, - { url = "https://files.pythonhosted.org/packages/0d/70/d66c7f51b3e33aabc5ea9f9624c1c9d9655472962270eb5e7b0d32707224/coverage-7.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea", size = 232204 }, - { url = "https://files.pythonhosted.org/packages/23/2d/2b3a2dbed7a5f40693404c8a09e779d7c1a5fbed089d3e7224c002129ec8/coverage-7.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a", size = 233335 }, - { url = "https://files.pythonhosted.org/packages/5a/4f/92d1d2ad720d698a4e71c176eacf531bfb8e0721d5ad560556f2c484a513/coverage-7.6.4-cp310-cp310-win32.whl", hash = "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa", size = 209435 }, - { url = "https://files.pythonhosted.org/packages/c7/b9/cdf158e7991e2287bcf9082670928badb73d310047facac203ff8dcd5ff3/coverage-7.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172", size = 210243 }, { url = "https://files.pythonhosted.org/packages/87/31/9c0cf84f0dfcbe4215b7eb95c31777cdc0483c13390e69584c8150c85175/coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b", size = 206819 }, { url = "https://files.pythonhosted.org/packages/53/ed/a38401079ad320ad6e054a01ec2b61d270511aeb3c201c80e99c841229d5/coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25", size = 207263 }, { url = "https://files.pythonhosted.org/packages/20/e7/c3ad33b179ab4213f0d70da25a9c214d52464efa11caeab438592eb1d837/coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546", size = 239205 }, @@ -449,17 +343,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/d9/3d820c00066ae55d69e6d0eae11d6149a5ca7546de469ba9d597f01bf2d7/coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef", size = 247510 }, { url = "https://files.pythonhosted.org/packages/8f/c3/4fa1eb412bb288ff6bfcc163c11700ff06e02c5fad8513817186e460ed43/coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e", size = 210353 }, { url = "https://files.pythonhosted.org/packages/7e/77/03fc2979d1538884d921c2013075917fc927f41cd8526909852fe4494112/coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1", size = 211502 }, - { url = "https://files.pythonhosted.org/packages/fb/27/7efede2355bd1417137246246ab0980751b3ba6065102518a2d1eba6a278/coverage-7.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3", size = 206714 }, - { url = "https://files.pythonhosted.org/packages/f3/94/594af55226676d078af72b329372e2d036f9ba1eb6bcf1f81debea2453c7/coverage-7.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c", size = 207146 }, - { url = "https://files.pythonhosted.org/packages/d5/13/19de1c5315b22795dd67dbd9168281632424a344b648d23d146572e42c2b/coverage-7.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076", size = 235180 }, - { url = "https://files.pythonhosted.org/packages/db/26/8fba01ce9f376708c7efed2761cea740f50a1b4138551886213797a4cecd/coverage-7.6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376", size = 233100 }, - { url = "https://files.pythonhosted.org/packages/74/66/4db60266551b89e820b457bc3811a3c5eaad3c1324cef7730c468633387a/coverage-7.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0", size = 234231 }, - { url = "https://files.pythonhosted.org/packages/2a/9b/7b33f0892fccce50fc82ad8da76c7af1731aea48ec71279eef63a9522db7/coverage-7.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858", size = 233383 }, - { url = "https://files.pythonhosted.org/packages/91/49/6ff9c4e8a67d9014e1c434566e9169965f970350f4792a0246cd0d839442/coverage-7.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111", size = 231863 }, - { url = "https://files.pythonhosted.org/packages/81/f9/c9d330dec440676b91504fcceebca0814718fa71c8498cf29d4e21e9dbfc/coverage-7.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901", size = 232854 }, - { url = "https://files.pythonhosted.org/packages/ee/d9/605517a023a0ba8eb1f30d958f0a7ff3a21867b07dcb42618f862695ca0e/coverage-7.6.4-cp39-cp39-win32.whl", hash = "sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09", size = 209437 }, - { url = "https://files.pythonhosted.org/packages/aa/79/2626903efa84e9f5b9c8ee6972de8338673fdb5bb8d8d2797740bf911027/coverage-7.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f", size = 210209 }, - { url = "https://files.pythonhosted.org/packages/cc/56/e1d75e8981a2a92c2a777e67c26efa96c66da59d645423146eb9ff3a851b/coverage-7.6.4-pp39.pp310-none-any.whl", hash = "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e", size = 198954 }, ] [package.optional-dependencies] @@ -494,14 +377,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/33/b3682992ab2e9476b9c81fff22f02c8b0a1e6e1d49ee1750a67d85fd7ed2/cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", size = 4076592 }, { url = "https://files.pythonhosted.org/packages/81/1e/ffcc41b3cebd64ca90b28fd58141c5f68c83d48563c88333ab660e002cd3/cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995", size = 2623145 }, { url = "https://files.pythonhosted.org/packages/87/5c/3dab83cc4aba1f4b0e733e3f0c3e7d4386440d660ba5b1e3ff995feb734d/cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362", size = 3068026 }, - { url = "https://files.pythonhosted.org/packages/6f/db/d8b8a039483f25fc3b70c90bc8f3e1d4497a99358d610c5067bf3bd4f0af/cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c", size = 3144545 }, - { url = "https://files.pythonhosted.org/packages/93/90/116edd5f8ec23b2dc879f7a42443e073cdad22950d3c8ee834e3b8124543/cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3", size = 3679828 }, - { url = "https://files.pythonhosted.org/packages/d8/32/1e1d78b316aa22c0ba6493cc271c1c309969e5aa5c22c830a1d7ce3471e6/cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83", size = 3908132 }, - { url = "https://files.pythonhosted.org/packages/91/bb/cd2c13be3332e7af3cdf16154147952d39075b9f61ea5e6b5241bf4bf436/cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7", size = 2988811 }, - { url = "https://files.pythonhosted.org/packages/cc/fc/ff7c76afdc4f5933b5e99092528d4783d3d1b131960fc8b31eb38e076ca8/cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664", size = 3146844 }, - { url = "https://files.pythonhosted.org/packages/d7/29/a233efb3e98b13d9175dcb3c3146988ec990896c8fa07e8467cce27d5a80/cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08", size = 3681997 }, - { url = "https://files.pythonhosted.org/packages/c0/cf/c9eea7791b961f279fb6db86c3355cfad29a73141f46427af71852b23b95/cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa", size = 3905208 }, - { url = "https://files.pythonhosted.org/packages/21/ea/6c38ca546d5b6dab3874c2b8fc6b1739baac29bacdea31a8c6c0513b3cfa/cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff", size = 2989787 }, ] [[package]] @@ -522,15 +397,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/69/e391bd51bc08ed9141ecd899a0ddb61ab6465309f1eb470905c0c8868081/docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc", size = 570472 }, ] -[[package]] -name = "exceptiongroup" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, -] - [[package]] name = "execnet" version = "2.1.1" @@ -567,21 +433,6 @@ version = "1.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8f/ed/0f4cec13a93c02c47ec32d81d11c0c1efbadf4a471e3f3ce7cad366cbbd3/frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817", size = 39930 } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/79/29d44c4af36b2b240725dce566b20f63f9b36ef267aaaa64ee7466f4f2f8/frozenlist-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a", size = 94451 }, - { url = "https://files.pythonhosted.org/packages/47/47/0c999aeace6ead8a44441b4f4173e2261b18219e4ad1fe9a479871ca02fc/frozenlist-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb", size = 54301 }, - { url = "https://files.pythonhosted.org/packages/8d/60/107a38c1e54176d12e06e9d4b5d755b677d71d1219217cee063911b1384f/frozenlist-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15538c0cbf0e4fa11d1e3a71f823524b0c46299aed6e10ebb4c2089abd8c3bec", size = 52213 }, - { url = "https://files.pythonhosted.org/packages/17/62/594a6829ac5679c25755362a9dc93486a8a45241394564309641425d3ff6/frozenlist-1.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e79225373c317ff1e35f210dd5f1344ff31066ba8067c307ab60254cd3a78ad5", size = 240946 }, - { url = "https://files.pythonhosted.org/packages/7e/75/6c8419d8f92c80dd0ee3f63bdde2702ce6398b0ac8410ff459f9b6f2f9cb/frozenlist-1.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9272fa73ca71266702c4c3e2d4a28553ea03418e591e377a03b8e3659d94fa76", size = 264608 }, - { url = "https://files.pythonhosted.org/packages/88/3e/82a6f0b84bc6fb7e0be240e52863c6d4ab6098cd62e4f5b972cd31e002e8/frozenlist-1.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:498524025a5b8ba81695761d78c8dd7382ac0b052f34e66939c42df860b8ff17", size = 261361 }, - { url = "https://files.pythonhosted.org/packages/fd/85/14e5f9ccac1b64ff2f10c927b3ffdf88772aea875882406f9ba0cec8ad84/frozenlist-1.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92b5278ed9d50fe610185ecd23c55d8b307d75ca18e94c0e7de328089ac5dcba", size = 231649 }, - { url = "https://files.pythonhosted.org/packages/ee/59/928322800306f6529d1852323014ee9008551e9bb027cc38d276cbc0b0e7/frozenlist-1.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3c8c1dacd037df16e85227bac13cca58c30da836c6f936ba1df0c05d046d8d", size = 241853 }, - { url = "https://files.pythonhosted.org/packages/7d/bd/e01fa4f146a6f6c18c5d34cab8abdc4013774a26c4ff851128cd1bd3008e/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2ac49a9bedb996086057b75bf93538240538c6d9b38e57c82d51f75a73409d2", size = 243652 }, - { url = "https://files.pythonhosted.org/packages/a5/bd/e4771fd18a8ec6757033f0fa903e447aecc3fbba54e3630397b61596acf0/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e66cc454f97053b79c2ab09c17fbe3c825ea6b4de20baf1be28919460dd7877f", size = 241734 }, - { url = "https://files.pythonhosted.org/packages/21/13/c83821fa5544af4f60c5d3a65d054af3213c26b14d3f5f48e43e5fb48556/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3ba5f9a0dfed20337d3e966dc359784c9f96503674c2faf015f7fe8e96798c", size = 260959 }, - { url = "https://files.pythonhosted.org/packages/71/f3/1f91c9a9bf7ed0e8edcf52698d23f3c211d8d00291a53c9f115ceb977ab1/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6321899477db90bdeb9299ac3627a6a53c7399c8cd58d25da094007402b039ab", size = 262706 }, - { url = "https://files.pythonhosted.org/packages/4c/22/4a256fdf5d9bcb3ae32622c796ee5ff9451b3a13a68cfe3f68e2c95588ce/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76e4753701248476e6286f2ef492af900ea67d9706a0155335a40ea21bf3b2f5", size = 250401 }, - { url = "https://files.pythonhosted.org/packages/af/89/c48ebe1f7991bd2be6d5f4ed202d94960c01b3017a03d6954dd5fa9ea1e8/frozenlist-1.5.0-cp310-cp310-win32.whl", hash = "sha256:977701c081c0241d0955c9586ffdd9ce44f7a7795df39b9151cd9a6fd0ce4cfb", size = 45498 }, - { url = "https://files.pythonhosted.org/packages/28/2f/cc27d5f43e023d21fe5c19538e08894db3d7e081cbf582ad5ed366c24446/frozenlist-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:189f03b53e64144f90990d29a27ec4f7997d91ed3d01b51fa39d2dbe77540fd4", size = 51622 }, { url = "https://files.pythonhosted.org/packages/79/43/0bed28bf5eb1c9e4301003b74453b8e7aa85fb293b31dde352aac528dafc/frozenlist-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30", size = 94987 }, { url = "https://files.pythonhosted.org/packages/bb/bf/b74e38f09a246e8abbe1e90eb65787ed745ccab6eaa58b9c9308e052323d/frozenlist-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5", size = 54584 }, { url = "https://files.pythonhosted.org/packages/2c/31/ab01375682f14f7613a1ade30149f684c84f9b8823a4391ed950c8285656/frozenlist-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778", size = 52499 }, @@ -627,21 +478,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/29/3b7a0bbbbe5a34833ba26f686aabfe982924adbdcafdc294a7a129c31688/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03", size = 264642 }, { url = "https://files.pythonhosted.org/packages/ab/42/0595b3dbffc2e82d7fe658c12d5a5bafcd7516c6bf2d1d1feb5387caa9c1/frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c", size = 44914 }, { url = "https://files.pythonhosted.org/packages/17/c4/b7db1206a3fea44bf3b838ca61deb6f74424a8a5db1dd53ecb21da669be6/frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28", size = 51167 }, - { url = "https://files.pythonhosted.org/packages/da/4d/d94ff0fb0f5313902c132817c62d19cdc5bdcd0c195d392006ef4b779fc6/frozenlist-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9bbcdfaf4af7ce002694a4e10a0159d5a8d20056a12b05b45cea944a4953f972", size = 95319 }, - { url = "https://files.pythonhosted.org/packages/8c/1b/d90e554ca2b483d31cb2296e393f72c25bdc38d64526579e95576bfda587/frozenlist-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1893f948bf6681733aaccf36c5232c231e3b5166d607c5fa77773611df6dc336", size = 54749 }, - { url = "https://files.pythonhosted.org/packages/f8/66/7fdecc9ef49f8db2aa4d9da916e4ecf357d867d87aea292efc11e1b2e932/frozenlist-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2b5e23253bb709ef57a8e95e6ae48daa9ac5f265637529e4ce6b003a37b2621f", size = 52718 }, - { url = "https://files.pythonhosted.org/packages/08/04/e2fddc92135276e07addbc1cf413acffa0c2d848b3e54cacf684e146df49/frozenlist-1.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f253985bb515ecd89629db13cb58d702035ecd8cfbca7d7a7e29a0e6d39af5f", size = 241756 }, - { url = "https://files.pythonhosted.org/packages/c6/52/be5ff200815d8a341aee5b16b6b707355e0ca3652953852238eb92b120c2/frozenlist-1.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04a5c6babd5e8fb7d3c871dc8b321166b80e41b637c31a995ed844a6139942b6", size = 267718 }, - { url = "https://files.pythonhosted.org/packages/88/be/4bd93a58be57a3722fc544c36debdf9dcc6758f761092e894d78f18b8f20/frozenlist-1.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9fe0f1c29ba24ba6ff6abf688cb0b7cf1efab6b6aa6adc55441773c252f7411", size = 263494 }, - { url = "https://files.pythonhosted.org/packages/32/ba/58348b90193caa096ce9e9befea6ae67f38dabfd3aacb47e46137a6250a8/frozenlist-1.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:226d72559fa19babe2ccd920273e767c96a49b9d3d38badd7c91a0fdeda8ea08", size = 232838 }, - { url = "https://files.pythonhosted.org/packages/f6/33/9f152105227630246135188901373c4f322cc026565ca6215b063f4c82f4/frozenlist-1.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15b731db116ab3aedec558573c1a5eec78822b32292fe4f2f0345b7f697745c2", size = 242912 }, - { url = "https://files.pythonhosted.org/packages/a0/10/3db38fb3ccbafadd80a1b0d6800c987b0e3fe3ef2d117c6ced0246eea17a/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:366d8f93e3edfe5a918c874702f78faac300209a4d5bf38352b2c1bdc07a766d", size = 244763 }, - { url = "https://files.pythonhosted.org/packages/e2/cd/1df468fdce2f66a4608dffe44c40cdc35eeaa67ef7fd1d813f99a9a37842/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1b96af8c582b94d381a1c1f51ffaedeb77c821c690ea5f01da3d70a487dd0a9b", size = 242841 }, - { url = "https://files.pythonhosted.org/packages/ee/5f/16097a5ca0bb6b6779c02cc9379c72fe98d56115d4c54d059fb233168fb6/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c03eff4a41bd4e38415cbed054bbaff4a075b093e2394b6915dca34a40d1e38b", size = 263407 }, - { url = "https://files.pythonhosted.org/packages/0f/f7/58cd220ee1c2248ee65a32f5b4b93689e3fe1764d85537eee9fc392543bc/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:50cf5e7ee9b98f22bdecbabf3800ae78ddcc26e4a435515fc72d97903e8488e0", size = 265083 }, - { url = "https://files.pythonhosted.org/packages/62/b8/49768980caabf81ac4a2d156008f7cbd0107e6b36d08a313bb31035d9201/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e76bfbc72353269c44e0bc2cfe171900fbf7f722ad74c9a7b638052afe6a00c", size = 251564 }, - { url = "https://files.pythonhosted.org/packages/cb/83/619327da3b86ef957ee7a0cbf3c166a09ed1e87a3f7f1ff487d7d0284683/frozenlist-1.5.0-cp39-cp39-win32.whl", hash = "sha256:666534d15ba8f0fda3f53969117383d5dc021266b3c1a42c9ec4855e4b58b9d3", size = 45691 }, - { url = "https://files.pythonhosted.org/packages/8b/28/407bc34a745151ed2322c690b6e7d83d7101472e81ed76e1ebdac0b70a78/frozenlist-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:5c28f4b5dbef8a0d8aad0d4de24d1e9e981728628afaf4ea0792f5d0939372f0", size = 51767 }, { url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901 }, ] @@ -672,18 +508,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769 }, ] -[[package]] -name = "importlib-metadata" -version = "8.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514 }, -] - [[package]] name = "iniconfig" version = "2.0.0" @@ -723,14 +547,6 @@ version = "0.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/54/ba/f78a63c5b55dc18b39099a1a1bf6569c14ccca47dd342cc4f4d774ec5719/kasa_crypt-0.4.4.tar.gz", hash = "sha256:cc31749e44a309459a71802ae8471a9d5ad6a7656938a44af64b93a8c3873ccd", size = 9306 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a4/6e1405a23097c017651c32c91a7ea97b62f079ae31e370378d4d4e1d9928/kasa_crypt-0.4.4-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:c2791be3a7ac64d0de0c4d0ecf85d33fd8aa5bcfce3148ce4558703e721ca16b", size = 25211 }, - { url = "https://files.pythonhosted.org/packages/c2/da/eb878e182e57a40de2731f0c8b63a0715472c9145f1b3734321f948d6df6/kasa_crypt-0.4.4-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:2da1d08151690ab6ade7a80168238964eb7672ddd3defb5188c713411b210a6a", size = 81852 }, - { url = "https://files.pythonhosted.org/packages/bb/21/905fe8d59d9ba34bf405cb14d17a0d7ba2595de81c81e5f22e226e64c08e/kasa_crypt-0.4.4-cp310-cp310-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c8db609ec73173c48519f860b2455b311a098b7203573fb8ae0ab52862d603d", size = 85252 }, - { url = "https://files.pythonhosted.org/packages/28/c6/1ec6c5854192e5dfe88943b0396a30fc0bd0aa0e1d2a6982ebc41149cd48/kasa_crypt-0.4.4-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:599b3eed3cadc79dda4e826f96740ddee1f6fcdd4b52a6a922395afad6154fb7", size = 84423 }, - { url = "https://files.pythonhosted.org/packages/d7/15/a5a99d7c5a2623406f924a2018610b3382a312c4045a5aa9591345cab7e7/kasa_crypt-0.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ca1caa741be2e67fd4c84098ecd8d8c2ce1c19330e737435edaef541b867d34a", size = 85399 }, - { url = "https://files.pythonhosted.org/packages/13/fc/cedfa52dd8a0e2fc12c408f43041462ff2093133bd47ee8c760f5c003b03/kasa_crypt-0.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d027d808e22dc944a23f4f1211fc0fe25e648498ff3817b9d78444bc75cc8d45", size = 88154 }, - { url = "https://files.pythonhosted.org/packages/b9/11/d09794b62ccf5c9a1ba84fafdadfa04ad1ed8654efc765cdc69c35e90e72/kasa_crypt-0.4.4-cp310-cp310-win32.whl", hash = "sha256:28918bb02bd4a87aab3baefe686cc249c9f97f3408dc8e881d120701851d837c", size = 24057 }, - { url = "https://files.pythonhosted.org/packages/f3/8e/e60b7c03442c306fec2dbaf35a697856ea8a4c9baa3d227e46910e2fb970/kasa_crypt-0.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:c442a7db3fd3ff9ad75e6b25ca9a970af800d7968f7187da67207eab136b7f12", size = 70878 }, { url = "https://files.pythonhosted.org/packages/71/43/d9e9b54aad36d8aae9f59adc8ddb27bf7a06f505deffe98f28bc865ba494/kasa_crypt-0.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:04fad5f981e734ab1b269922a1175bc506d5498681778b3d61561422619d6e6d", size = 69934 }, { url = "https://files.pythonhosted.org/packages/15/79/5e94eb76f2935f92de9602b04d0c244653540128eba2be71e6284f9c9997/kasa_crypt-0.4.4-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:a54040539fe8293a7dd20fcf5e613ba4bdcafe15a8d9eeff1cc2805500a0c2d9", size = 133178 }, { url = "https://files.pythonhosted.org/packages/7a/1e/3836b1e69da964e3c8dbf057d82f8f13d277fe9baa6c327400ea5ebc37e1/kasa_crypt-0.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a0a0981255225fd5671ffed85f2bfc68b0ac8525b5d424a703aaa1d0f8f4cc2", size = 136881 }, @@ -745,10 +561,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/62/9bcf83c27ddfaa50353deb4c9793873356d7c4b99c3b073a1c623eda883c/kasa_crypt-0.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:806dd2f7a8c6d2242513a78c144a63664817b3f0b6e149166b87db9a6017d742", size = 146398 }, { url = "https://files.pythonhosted.org/packages/d5/63/ad0de4d97f9ec2e290a9ed37756c70ad5c99403f62399a4f9fafeb3d8c81/kasa_crypt-0.4.4-cp312-cp312-win32.whl", hash = "sha256:791900085be025dbf7052f1e44c176e957556b1d04b6da4a602fc4ddc23f87b0", size = 68951 }, { url = "https://files.pythonhosted.org/packages/44/ce/a843f0a2c3328d792a41ca6261c1564af188a4f15b1af34f83ec8c68c686/kasa_crypt-0.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:9c7d136bfcd74ac30ed5c10cb96c46a4e2eb90bd52974a0dbbc9c6d3e90d7699", size = 71352 }, - { url = "https://files.pythonhosted.org/packages/8f/e5/2d7d825955d4ac0084e195599a42eba5fba6209439a112a49eba8b773aa5/kasa_crypt-0.4.4-pp310-pypy310_pp73-macosx_14_0_arm64.whl", hash = "sha256:b47ecee24bc17bb80ed8c24d8b008d92610a3500c56368b062627ff114688262", size = 66556 }, - { url = "https://files.pythonhosted.org/packages/c1/6e/5dd1081cfaa264cc3ee78ea3771cb9f5b34adb752da586403fab6cb84018/kasa_crypt-0.4.4-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:bd85d206856f866e117186247d161550bf3d5309d1cf07a2e7a3e5785660dd60", size = 70528 }, - { url = "https://files.pythonhosted.org/packages/29/9d/0cb1f3a3f5b764a4f394bf49fd39780aef0284bc9dd63fb3d9fb841d363b/kasa_crypt-0.4.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc37f7302943b5ab0562084df01ec39422e5cd13ba420cbb35895a4bb19ccbb", size = 69994 }, - { url = "https://files.pythonhosted.org/packages/d2/f0/d91e33daa44cf66218e679974900b94d73d840a54e03b81936b9c5b650e0/kasa_crypt-0.4.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ae739287f220e2e1b3349cf1aacd37a8abf701c97755c9bd53d6168ad41df2f1", size = 68343 }, ] [[package]] @@ -769,16 +581,6 @@ version = "3.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 }, - { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 }, - { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 }, - { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 }, - { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 }, - { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 }, - { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 }, - { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 }, - { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 }, - { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 }, { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, @@ -819,16 +621,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, - { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344 }, - { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389 }, - { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607 }, - { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728 }, - { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826 }, - { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843 }, - { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219 }, - { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946 }, - { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063 }, - { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 }, ] [[package]] @@ -868,26 +660,8 @@ wheels = [ name = "multidict" version = "6.1.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/d6/be/504b89a5e9ca731cd47487e91c469064f8ae5af93b7259758dcfc2b9c848/multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a", size = 64002 } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/68/259dee7fd14cf56a17c554125e534f6274c2860159692a414d0b402b9a6d/multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60", size = 48628 }, - { url = "https://files.pythonhosted.org/packages/50/79/53ba256069fe5386a4a9e80d4e12857ced9de295baf3e20c68cdda746e04/multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1", size = 29327 }, - { url = "https://files.pythonhosted.org/packages/ff/10/71f1379b05b196dae749b5ac062e87273e3f11634f447ebac12a571d90ae/multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53", size = 29689 }, - { url = "https://files.pythonhosted.org/packages/71/45/70bac4f87438ded36ad4793793c0095de6572d433d98575a5752629ef549/multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5", size = 126639 }, - { url = "https://files.pythonhosted.org/packages/80/cf/17f35b3b9509b4959303c05379c4bfb0d7dd05c3306039fc79cf035bbac0/multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581", size = 134315 }, - { url = "https://files.pythonhosted.org/packages/ef/1f/652d70ab5effb33c031510a3503d4d6efc5ec93153562f1ee0acdc895a57/multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56", size = 129471 }, - { url = "https://files.pythonhosted.org/packages/a6/64/2dd6c4c681688c0165dea3975a6a4eab4944ea30f35000f8b8af1df3148c/multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429", size = 124585 }, - { url = "https://files.pythonhosted.org/packages/87/56/e6ee5459894c7e554b57ba88f7257dc3c3d2d379cb15baaa1e265b8c6165/multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748", size = 116957 }, - { url = "https://files.pythonhosted.org/packages/36/9e/616ce5e8d375c24b84f14fc263c7ef1d8d5e8ef529dbc0f1df8ce71bb5b8/multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db", size = 128609 }, - { url = "https://files.pythonhosted.org/packages/8c/4f/4783e48a38495d000f2124020dc96bacc806a4340345211b1ab6175a6cb4/multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056", size = 123016 }, - { url = "https://files.pythonhosted.org/packages/3e/b3/4950551ab8fc39862ba5e9907dc821f896aa829b4524b4deefd3e12945ab/multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76", size = 133542 }, - { url = "https://files.pythonhosted.org/packages/96/4d/f0ce6ac9914168a2a71df117935bb1f1781916acdecbb43285e225b484b8/multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160", size = 130163 }, - { url = "https://files.pythonhosted.org/packages/be/72/17c9f67e7542a49dd252c5ae50248607dfb780bcc03035907dafefb067e3/multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7", size = 126832 }, - { url = "https://files.pythonhosted.org/packages/71/9f/72d719e248cbd755c8736c6d14780533a1606ffb3fbb0fbd77da9f0372da/multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0", size = 26402 }, - { url = "https://files.pythonhosted.org/packages/04/5a/d88cd5d00a184e1ddffc82aa2e6e915164a6d2641ed3606e766b5d2f275a/multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d", size = 28800 }, { url = "https://files.pythonhosted.org/packages/93/13/df3505a46d0cd08428e4c8169a196131d1b0c4b515c3649829258843dde6/multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6", size = 48570 }, { url = "https://files.pythonhosted.org/packages/f0/e1/a215908bfae1343cdb72f805366592bdd60487b4232d039c437fe8f5013d/multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156", size = 29316 }, { url = "https://files.pythonhosted.org/packages/70/0f/6dc70ddf5d442702ed74f298d69977f904960b82368532c88e854b79f72b/multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb", size = 29640 }, @@ -933,21 +707,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/a4/63e7cd38ed29dd9f1881d5119f272c898ca92536cdb53ffe0843197f6c85/multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6", size = 125519 }, { url = "https://files.pythonhosted.org/packages/38/e0/4f5855037a72cd8a7a2f60a3952d9aa45feedb37ae7831642102604e8a37/multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81", size = 26426 }, { url = "https://files.pythonhosted.org/packages/7e/a5/17ee3a4db1e310b7405f5d25834460073a8ccd86198ce044dfaf69eac073/multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774", size = 28531 }, - { url = "https://files.pythonhosted.org/packages/e7/c9/9e153a6572b38ac5ff4434113af38acf8d5e9957897cdb1f513b3d6614ed/multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c", size = 48550 }, - { url = "https://files.pythonhosted.org/packages/76/f5/79565ddb629eba6c7f704f09a09df085c8dc04643b12506f10f718cee37a/multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1", size = 29298 }, - { url = "https://files.pythonhosted.org/packages/60/1b/9851878b704bc98e641a3e0bce49382ae9e05743dac6d97748feb5b7baba/multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c", size = 29641 }, - { url = "https://files.pythonhosted.org/packages/89/87/d451d45aab9e422cb0fb2f7720c31a4c1d3012c740483c37f642eba568fb/multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c", size = 126202 }, - { url = "https://files.pythonhosted.org/packages/fa/b4/27cbe9f3e2e469359887653f2e45470272eef7295139916cc21107c6b48c/multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f", size = 133925 }, - { url = "https://files.pythonhosted.org/packages/4d/a3/afc841899face8adfd004235ce759a37619f6ec99eafd959650c5ce4df57/multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875", size = 129039 }, - { url = "https://files.pythonhosted.org/packages/5e/41/0d0fb18c1ad574f807196f5f3d99164edf9de3e169a58c6dc2d6ed5742b9/multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255", size = 124072 }, - { url = "https://files.pythonhosted.org/packages/00/22/defd7a2e71a44e6e5b9a5428f972e5b572e7fe28e404dfa6519bbf057c93/multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30", size = 116532 }, - { url = "https://files.pythonhosted.org/packages/91/25/f7545102def0b1d456ab6449388eed2dfd822debba1d65af60194904a23a/multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057", size = 128173 }, - { url = "https://files.pythonhosted.org/packages/45/79/3dbe8d35fc99f5ea610813a72ab55f426cb9cf482f860fa8496e5409be11/multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657", size = 122654 }, - { url = "https://files.pythonhosted.org/packages/97/cb/209e735eeab96e1b160825b5d0b36c56d3862abff828fc43999bb957dcad/multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28", size = 133197 }, - { url = "https://files.pythonhosted.org/packages/e4/3a/a13808a7ada62808afccea67837a79d00ad6581440015ef00f726d064c2d/multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972", size = 129754 }, - { url = "https://files.pythonhosted.org/packages/77/dd/8540e139eafb240079242da8f8ffdf9d3f4b4ad1aac5a786cd4050923783/multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43", size = 126402 }, - { url = "https://files.pythonhosted.org/packages/86/99/e82e1a275d8b1ea16d3a251474262258dbbe41c05cce0c01bceda1fc8ea5/multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada", size = 26421 }, - { url = "https://files.pythonhosted.org/packages/86/1c/9fa630272355af7e4446a2c7550c259f11ee422ab2d30ff90a0a71cf3d9e/multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a", size = 28791 }, { url = "https://files.pythonhosted.org/packages/99/b7/b9e70fde2c0f0c9af4cc5277782a89b66d35948ea3369ec9f598358c3ac5/multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506", size = 10051 }, ] @@ -957,16 +716,10 @@ version = "1.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/8c/206de95a27722b5b5a8c85ba3100467bd86299d92a4f71c6b9aa448bfa2f/mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a", size = 11020731 }, - { url = "https://files.pythonhosted.org/packages/ab/bb/b31695a29eea76b1569fd28b4ab141a1adc9842edde080d1e8e1776862c7/mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80", size = 10184276 }, - { url = "https://files.pythonhosted.org/packages/a5/2d/4a23849729bb27934a0e079c9c1aad912167d875c7b070382a408d459651/mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7", size = 12587706 }, - { url = "https://files.pythonhosted.org/packages/5c/c3/d318e38ada50255e22e23353a469c791379825240e71b0ad03e76ca07ae6/mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f", size = 13105586 }, - { url = "https://files.pythonhosted.org/packages/4a/25/3918bc64952370c3dbdbd8c82c363804678127815febd2925b7273d9482c/mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372", size = 9632318 }, { url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027 }, { url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699 }, { url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263 }, @@ -982,11 +735,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, - { url = "https://files.pythonhosted.org/packages/5f/d4/b33ddd40dad230efb317898a2d1c267c04edba73bc5086bf77edeb410fb2/mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc", size = 11013906 }, - { url = "https://files.pythonhosted.org/packages/f4/e6/f414bca465b44d01cd5f4a82761e15044bedd1bf8025c5af3cc64518fac5/mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732", size = 10180657 }, - { url = "https://files.pythonhosted.org/packages/38/e9/fc3865e417722f98d58409770be01afb961e2c1f99930659ff4ae7ca8b7e/mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc", size = 12586394 }, - { url = "https://files.pythonhosted.org/packages/2e/35/f4d8b6d2cb0b3dad63e96caf159419dda023f45a358c6c9ac582ccaee354/mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d", size = 13103591 }, - { url = "https://files.pythonhosted.org/packages/22/1d/80594aef135f921dd52e142fa0acd19df197690bd0cde42cea7b88cf5aa2/mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24", size = 9634690 }, { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, ] @@ -1031,16 +779,6 @@ version = "3.10.11" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/3a/10320029954badc7eaa338a15ee279043436f396e965dafc169610e4933f/orjson-3.10.11.tar.gz", hash = "sha256:e35b6d730de6384d5b2dab5fd23f0d76fae8bbc8c353c2f78210aa5fa4beb3ef", size = 5444879 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/63/f7d412e09f6e2c4e2562ddc44e86f2316a7ce9d7f353afa7cbce4f6a78d5/orjson-3.10.11-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6dade64687f2bd7c090281652fe18f1151292d567a9302b34c2dbb92a3872f1f", size = 266434 }, - { url = "https://files.pythonhosted.org/packages/a2/6a/3dfcd3a8c0e588581c8d1f3d9002cca970432da8a8096c1a42b99914a34d/orjson-3.10.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82f07c550a6ccd2b9290849b22316a609023ed851a87ea888c0456485a7d196a", size = 151884 }, - { url = "https://files.pythonhosted.org/packages/41/02/8981bc5ccbc04a2bd49cd86224d5b1e2c7417fb33e83590c66c3a028ede5/orjson-3.10.11-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd9a187742d3ead9df2e49240234d728c67c356516cf4db018833a86f20ec18c", size = 167371 }, - { url = "https://files.pythonhosted.org/packages/df/3f/772a12a417444eccc54fa597955b689848eb121d5e43dd7da9f6658c314d/orjson-3.10.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77b0fed6f209d76c1c39f032a70df2d7acf24b1812ca3e6078fd04e8972685a3", size = 154367 }, - { url = "https://files.pythonhosted.org/packages/8a/63/d0d6ba28410ec603fc31726a49dc782c72c0a64f4cd0a6734a6d8bc07a4a/orjson-3.10.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63fc9d5fe1d4e8868f6aae547a7b8ba0a2e592929245fff61d633f4caccdcdd6", size = 165726 }, - { url = "https://files.pythonhosted.org/packages/97/6e/d291bf382173af7788b368e4c22d02c7bdb9b7ac29b83e92930841321c16/orjson-3.10.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65cd3e3bb4fbb4eddc3c1e8dce10dc0b73e808fcb875f9fab40c81903dd9323e", size = 142522 }, - { url = "https://files.pythonhosted.org/packages/6d/3b/7364c10fcadf7c08e3658fe7103bf3b0408783f91022be4691fbe0b5ba1d/orjson-3.10.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6f67c570602300c4befbda12d153113b8974a3340fdcf3d6de095ede86c06d92", size = 146931 }, - { url = "https://files.pythonhosted.org/packages/95/8c/43f454e642cc85ef845cda6efcfddc6b5fe46b897b692412022012e1272c/orjson-3.10.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1f39728c7f7d766f1f5a769ce4d54b5aaa4c3f92d5b84817053cc9995b977acc", size = 142900 }, - { url = "https://files.pythonhosted.org/packages/bb/29/ca24efe043501b4a4584d728fdc65af5cfc070ab9021a07fb195bce98919/orjson-3.10.11-cp310-none-win32.whl", hash = "sha256:1789d9db7968d805f3d94aae2c25d04014aae3a2fa65b1443117cd462c6da647", size = 144456 }, - { url = "https://files.pythonhosted.org/packages/b7/ec/f15dc012928459cfb96ed86178d92fddb5c01847f2c53fd8be2fa62dee6c/orjson-3.10.11-cp310-none-win_amd64.whl", hash = "sha256:5576b1e5a53a5ba8f8df81872bb0878a112b3ebb1d392155f00f54dd86c83ff6", size = 136442 }, { url = "https://files.pythonhosted.org/packages/1e/25/c869a1fbd481dcb02c70032fd6a7243de7582bc48c7cae03d6f0985a11c0/orjson-3.10.11-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1444f9cb7c14055d595de1036f74ecd6ce15f04a715e73f33bb6326c9cef01b6", size = 266432 }, { url = "https://files.pythonhosted.org/packages/6a/a4/2307155ee92457d28345308f7d8c0e712348404723025613adeffcb531d0/orjson-3.10.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdec57fe3b4bdebcc08a946db3365630332dbe575125ff3d80a3272ebd0ddafe", size = 151884 }, { url = "https://files.pythonhosted.org/packages/aa/82/daf1b2596dd49fe44a1bd92367568faf6966dcb5d7f99fd437c3d0dc2de6/orjson-3.10.11-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4eed32f33a0ea6ef36ccc1d37f8d17f28a1d6e8eefae5928f76aff8f1df85e67", size = 167371 }, @@ -1068,16 +806,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/97/12047b0c0e9b391d589fb76eb40538f522edc664f650f8e352fdaaf77ff5/orjson-3.10.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3f29634260708c200c4fe148e42b4aae97d7b9fee417fbdd74f8cfc265f15b0", size = 142961 }, { url = "https://files.pythonhosted.org/packages/a4/97/d904e26c1cabf2dd6ab1b0909e9b790af28a7f0fcb9d8378d7320d4869eb/orjson-3.10.11-cp313-none-win32.whl", hash = "sha256:1a1222ffcee8a09476bbdd5d4f6f33d06d0d6642df2a3d78b7a195ca880d669b", size = 144486 }, { url = "https://files.pythonhosted.org/packages/42/62/3760bd1e6e949321d99bab238d08db2b1266564d2f708af668f57109bb36/orjson-3.10.11-cp313-none-win_amd64.whl", hash = "sha256:bc274ac261cc69260913b2d1610760e55d3c0801bb3457ba7b9004420b6b4270", size = 136361 }, - { url = "https://files.pythonhosted.org/packages/29/72/e44004a65831ed8c0d0303623744f01abdb41811a483584edad69ca5358d/orjson-3.10.11-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c95f2ecafe709b4e5c733b5e2768ac569bed308623c85806c395d9cca00e08af", size = 266080 }, - { url = "https://files.pythonhosted.org/packages/f9/84/36b6153ec6be55c9068e3df5e76d38712049052f85e4a4ee4eedba9f36c9/orjson-3.10.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80c00d4acded0c51c98754fe8218cb49cb854f0f7eb39ea4641b7f71732d2cb7", size = 151671 }, - { url = "https://files.pythonhosted.org/packages/59/1d/ca3e7e3c166587dfffc5c2c4ce06219f180ef338699d61e5e301dff8cc71/orjson-3.10.11-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:461311b693d3d0a060439aa669c74f3603264d4e7a08faa68c47ae5a863f352d", size = 167130 }, - { url = "https://files.pythonhosted.org/packages/87/22/46fb6668601c86af701ca32ec181f97f8ad5d246bd9713fce34798e2a1d3/orjson-3.10.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52ca832f17d86a78cbab86cdc25f8c13756ebe182b6fc1a97d534051c18a08de", size = 154079 }, - { url = "https://files.pythonhosted.org/packages/35/6b/98d96dd8576cc14779822d03f465acc42ae47a0acb9c7b79555e691d427b/orjson-3.10.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c57ea78a753812f528178aa2f1c57da633754c91d2124cb28991dab4c79a54", size = 165449 }, - { url = "https://files.pythonhosted.org/packages/88/40/ff08c642eb0e226d2bb8e7095c21262802e7f4cf2a492f2635b4bed935bb/orjson-3.10.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7fcfc6f7ca046383fb954ba528587e0f9336828b568282b27579c49f8e16aad", size = 142283 }, - { url = "https://files.pythonhosted.org/packages/86/37/05e39dde53aa53d1172fe6585dde3bc2a4a327cf9a6ba2bc6ac99ed46cf0/orjson-3.10.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:86b9dd983857970c29e4c71bb3e95ff085c07d3e83e7c46ebe959bac07ebd80b", size = 146711 }, - { url = "https://files.pythonhosted.org/packages/36/ac/5c749779eacf60eb02ef5396821dec2c688f9df1bc2c3224e35b67d02335/orjson-3.10.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4d83f87582d223e54efb2242a79547611ba4ebae3af8bae1e80fa9a0af83bb7f", size = 142701 }, - { url = "https://files.pythonhosted.org/packages/db/66/a61cb47eaf4b8afe10907e465d4e38f61f6e694fc982f01261b0020be8ed/orjson-3.10.11-cp39-none-win32.whl", hash = "sha256:9fd0ad1c129bc9beb1154c2655f177620b5beaf9a11e0d10bac63ef3fce96950", size = 144301 }, - { url = "https://files.pythonhosted.org/packages/cb/08/69b1ce42bb7ee604e23270cf46514ea775265960f3fa4b246e1f8bfde081/orjson-3.10.11-cp39-none-win_amd64.whl", hash = "sha256:10f416b2a017c8bd17f325fb9dee1fb5cdd7a54e814284896b7c3f2763faa017", size = 136263 }, ] [[package]] @@ -1150,22 +878,6 @@ version = "0.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a9/4d/5e5a60b78dbc1d464f8a7bbaeb30957257afdc8512cbb9dfd5659304f5cd/propcache-0.2.0.tar.gz", hash = "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70", size = 40951 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/08/1963dfb932b8d74d5b09098507b37e9b96c835ba89ab8aad35aa330f4ff3/propcache-0.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c5869b8fd70b81835a6f187c5fdbe67917a04d7e52b6e7cc4e5fe39d55c39d58", size = 80712 }, - { url = "https://files.pythonhosted.org/packages/e6/59/49072aba9bf8a8ed958e576182d46f038e595b17ff7408bc7e8807e721e1/propcache-0.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:952e0d9d07609d9c5be361f33b0d6d650cd2bae393aabb11d9b719364521984b", size = 46301 }, - { url = "https://files.pythonhosted.org/packages/33/a2/6b1978c2e0d80a678e2c483f45e5443c15fe5d32c483902e92a073314ef1/propcache-0.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33ac8f098df0585c0b53009f039dfd913b38c1d2edafed0cedcc0c32a05aa110", size = 45581 }, - { url = "https://files.pythonhosted.org/packages/43/95/55acc9adff8f997c7572f23d41993042290dfb29e404cdadb07039a4386f/propcache-0.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e48e8875e6c13909c800fa344cd54cc4b2b0db1d5f911f840458a500fde2c2", size = 208659 }, - { url = "https://files.pythonhosted.org/packages/bd/2c/ef7371ff715e6cd19ea03fdd5637ecefbaa0752fee5b0f2fe8ea8407ee01/propcache-0.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:388f3217649d6d59292b722d940d4d2e1e6a7003259eb835724092a1cca0203a", size = 222613 }, - { url = "https://files.pythonhosted.org/packages/5e/1c/fef251f79fd4971a413fa4b1ae369ee07727b4cc2c71e2d90dfcde664fbb/propcache-0.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f571aea50ba5623c308aa146eb650eebf7dbe0fd8c5d946e28343cb3b5aad577", size = 221067 }, - { url = "https://files.pythonhosted.org/packages/8d/e7/22e76ae6fc5a1708bdce92bdb49de5ebe89a173db87e4ef597d6bbe9145a/propcache-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3dfafb44f7bb35c0c06eda6b2ab4bfd58f02729e7c4045e179f9a861b07c9850", size = 208920 }, - { url = "https://files.pythonhosted.org/packages/04/3e/f10aa562781bcd8a1e0b37683a23bef32bdbe501d9cc7e76969becaac30d/propcache-0.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3ebe9a75be7ab0b7da2464a77bb27febcb4fab46a34f9288f39d74833db7f61", size = 200050 }, - { url = "https://files.pythonhosted.org/packages/d0/98/8ac69f638358c5f2a0043809c917802f96f86026e86726b65006830f3dc6/propcache-0.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2f0d0f976985f85dfb5f3d685697ef769faa6b71993b46b295cdbbd6be8cc37", size = 202346 }, - { url = "https://files.pythonhosted.org/packages/ee/78/4acfc5544a5075d8e660af4d4e468d60c418bba93203d1363848444511ad/propcache-0.2.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a3dc1a4b165283bd865e8f8cb5f0c64c05001e0718ed06250d8cac9bec115b48", size = 199750 }, - { url = "https://files.pythonhosted.org/packages/a2/8f/90ada38448ca2e9cf25adc2fe05d08358bda1b9446f54a606ea38f41798b/propcache-0.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9e0f07b42d2a50c7dd2d8675d50f7343d998c64008f1da5fef888396b7f84630", size = 201279 }, - { url = "https://files.pythonhosted.org/packages/08/31/0e299f650f73903da851f50f576ef09bfffc8e1519e6a2f1e5ed2d19c591/propcache-0.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e63e3e1e0271f374ed489ff5ee73d4b6e7c60710e1f76af5f0e1a6117cd26394", size = 211035 }, - { url = "https://files.pythonhosted.org/packages/85/3e/e356cc6b09064bff1c06d0b2413593e7c925726f0139bc7acef8a21e87a8/propcache-0.2.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:56bb5c98f058a41bb58eead194b4db8c05b088c93d94d5161728515bd52b052b", size = 215565 }, - { url = "https://files.pythonhosted.org/packages/8b/54/4ef7236cd657e53098bd05aa59cbc3cbf7018fba37b40eaed112c3921e51/propcache-0.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7665f04d0c7f26ff8bb534e1c65068409bf4687aa2534faf7104d7182debb336", size = 207604 }, - { url = "https://files.pythonhosted.org/packages/1f/27/d01d7799c068443ee64002f0655d82fb067496897bf74b632e28ee6a32cf/propcache-0.2.0-cp310-cp310-win32.whl", hash = "sha256:7cf18abf9764746b9c8704774d8b06714bcb0a63641518a3a89c7f85cc02c2ad", size = 40526 }, - { url = "https://files.pythonhosted.org/packages/bb/44/6c2add5eeafb7f31ff0d25fbc005d930bea040a1364cf0f5768750ddf4d1/propcache-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:cfac69017ef97db2438efb854edf24f5a29fd09a536ff3a992b75990720cdc99", size = 44958 }, { url = "https://files.pythonhosted.org/packages/e0/1c/71eec730e12aec6511e702ad0cd73c2872eccb7cad39de8ba3ba9de693ef/propcache-0.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:63f13bf09cc3336eb04a837490b8f332e0db41da66995c9fd1ba04552e516354", size = 80811 }, { url = "https://files.pythonhosted.org/packages/89/c3/7e94009f9a4934c48a371632197406a8860b9f08e3f7f7d922ab69e57a41/propcache-0.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608cce1da6f2672a56b24a015b42db4ac612ee709f3d29f27a00c943d9e851de", size = 46365 }, { url = "https://files.pythonhosted.org/packages/c0/1d/c700d16d1d6903aeab28372fe9999762f074b80b96a0ccc953175b858743/propcache-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:466c219deee4536fbc83c08d09115249db301550625c7fef1c5563a584c9bc87", size = 45602 }, @@ -1214,22 +926,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/6f/6272ecc7a8daad1d0754cfc6c8846076a8cb13f810005c79b15ce0ef0cf2/propcache-0.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4a91d44379f45f5e540971d41e4626dacd7f01004826a18cb048e7da7e96544", size = 221075 }, { url = "https://files.pythonhosted.org/packages/7c/bd/c7a6a719a6b3dd8b3aeadb3675b5783983529e4a3185946aa444d3e078f6/propcache-0.2.0-cp313-cp313-win32.whl", hash = "sha256:f902804113e032e2cdf8c71015651c97af6418363bea8d78dc0911d56c335032", size = 39654 }, { url = "https://files.pythonhosted.org/packages/88/e7/0eef39eff84fa3e001b44de0bd41c7c0e3432e7648ffd3d64955910f002d/propcache-0.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f188cfcc64fb1266f4684206c9de0e80f54622c3f22a910cbd200478aeae61e", size = 43705 }, - { url = "https://files.pythonhosted.org/packages/38/05/797e6738c9f44ab5039e3ff329540c934eabbe8ad7e63c305c75844bc86f/propcache-0.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:25c8d773a62ce0451b020c7b29a35cfbc05de8b291163a7a0f3b7904f27253e6", size = 81903 }, - { url = "https://files.pythonhosted.org/packages/9f/84/8d5edb9a73e1a56b24dd8f2adb6aac223109ff0e8002313d52e5518258ba/propcache-0.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:375a12d7556d462dc64d70475a9ee5982465fbb3d2b364f16b86ba9135793638", size = 46960 }, - { url = "https://files.pythonhosted.org/packages/e7/77/388697bedda984af0d12d68e536b98129b167282da3401965c8450de510e/propcache-0.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1ec43d76b9677637a89d6ab86e1fef70d739217fefa208c65352ecf0282be957", size = 46133 }, - { url = "https://files.pythonhosted.org/packages/e2/dc/60d444610bc5b1d7a758534f58362b1bcee736a785473f8a39c91f05aad1/propcache-0.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f45eec587dafd4b2d41ac189c2156461ebd0c1082d2fe7013571598abb8505d1", size = 211105 }, - { url = "https://files.pythonhosted.org/packages/bc/c6/40eb0dd1de6f8e84f454615ab61f68eb4a58f9d63d6f6eaf04300ac0cc17/propcache-0.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc092ba439d91df90aea38168e11f75c655880c12782facf5cf9c00f3d42b562", size = 226613 }, - { url = "https://files.pythonhosted.org/packages/de/b6/e078b5e9de58e20db12135eb6a206b4b43cb26c6b62ee0fe36ac40763a64/propcache-0.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa1076244f54bb76e65e22cb6910365779d5c3d71d1f18b275f1dfc7b0d71b4d", size = 225587 }, - { url = "https://files.pythonhosted.org/packages/ce/4e/97059dd24494d1c93d1efb98bb24825e1930265b41858dd59c15cb37a975/propcache-0.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:682a7c79a2fbf40f5dbb1eb6bfe2cd865376deeac65acf9beb607505dced9e12", size = 211826 }, - { url = "https://files.pythonhosted.org/packages/fc/23/4dbf726602a989d2280fe130a9b9dd71faa8d3bb8cd23d3261ff3c23f692/propcache-0.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e40876731f99b6f3c897b66b803c9e1c07a989b366c6b5b475fafd1f7ba3fb8", size = 203140 }, - { url = "https://files.pythonhosted.org/packages/5b/ce/f3bff82c885dbd9ae9e43f134d5b02516c3daa52d46f7a50e4f52ef9121f/propcache-0.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:363ea8cd3c5cb6679f1c2f5f1f9669587361c062e4899fce56758efa928728f8", size = 208841 }, - { url = "https://files.pythonhosted.org/packages/29/d7/19a4d3b4c7e95d08f216da97035d0b103d0c90411c6f739d47088d2da1f0/propcache-0.2.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:140fbf08ab3588b3468932974a9331aff43c0ab8a2ec2c608b6d7d1756dbb6cb", size = 203315 }, - { url = "https://files.pythonhosted.org/packages/db/87/5748212a18beb8d4ab46315c55ade8960d1e2cdc190764985b2d229dd3f4/propcache-0.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e70fac33e8b4ac63dfc4c956fd7d85a0b1139adcfc0d964ce288b7c527537fea", size = 204724 }, - { url = "https://files.pythonhosted.org/packages/84/2a/c3d2f989fc571a5bad0fabcd970669ccb08c8f9b07b037ecddbdab16a040/propcache-0.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b33d7a286c0dc1a15f5fc864cc48ae92a846df287ceac2dd499926c3801054a6", size = 215514 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4c44c133b08bc5f776afcb8f0833889c2636b8a83e07ea1d9096c1e401b0/propcache-0.2.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f6d5749fdd33d90e34c2efb174c7e236829147a2713334d708746e94c4bde40d", size = 220063 }, - { url = "https://files.pythonhosted.org/packages/2e/25/280d0a3bdaee68db74c0acd9a472e59e64b516735b59cffd3a326ff9058a/propcache-0.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22aa8f2272d81d9317ff5756bb108021a056805ce63dd3630e27d042c8092798", size = 211620 }, - { url = "https://files.pythonhosted.org/packages/28/8c/266898981b7883c1563c35954f9ce9ced06019fdcc487a9520150c48dc91/propcache-0.2.0-cp39-cp39-win32.whl", hash = "sha256:73e4b40ea0eda421b115248d7e79b59214411109a5bc47d0d48e4c73e3b8fcf9", size = 41049 }, - { url = "https://files.pythonhosted.org/packages/af/53/a3e5b937f58e757a940716b88105ec4c211c42790c1ea17052b46dc16f16/propcache-0.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:9517d5e9e0731957468c29dbfd0f976736a0e55afaea843726e887f36fe017df", size = 45587 }, { url = "https://files.pythonhosted.org/packages/3d/b6/e6d98278f2d49b22b4d033c9f792eda783b9ab2094b041f013fc69bcde87/propcache-0.2.0-py3-none-any.whl", hash = "sha256:2ccc28197af5313706511fab3a8b66dcd6da067a1331372c82ea1cb74285e036", size = 11603 }, ] @@ -1280,18 +976,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/e2/aa/6b6a9b9f8537b872f552ddd46dd3da230367754b6f707b8e1e963f515ea3/pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863", size = 402156 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/8b/d3ae387f66277bd8104096d6ec0a145f4baa2966ebb2cad746c0920c9526/pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b", size = 1867835 }, - { url = "https://files.pythonhosted.org/packages/46/76/f68272e4c3a7df8777798282c5e47d508274917f29992d84e1898f8908c7/pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166", size = 1776689 }, - { url = "https://files.pythonhosted.org/packages/cc/69/5f945b4416f42ea3f3bc9d2aaec66c76084a6ff4ff27555bf9415ab43189/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb", size = 1800748 }, - { url = "https://files.pythonhosted.org/packages/50/ab/891a7b0054bcc297fb02d44d05c50e68154e31788f2d9d41d0b72c89fdf7/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916", size = 1806469 }, - { url = "https://files.pythonhosted.org/packages/31/7c/6e3fa122075d78f277a8431c4c608f061881b76c2b7faca01d317ee39b5d/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07", size = 2002246 }, - { url = "https://files.pythonhosted.org/packages/ad/6f/22d5692b7ab63fc4acbc74de6ff61d185804a83160adba5e6cc6068e1128/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232", size = 2659404 }, - { url = "https://files.pythonhosted.org/packages/11/ac/1e647dc1121c028b691028fa61a4e7477e6aeb5132628fde41dd34c1671f/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2", size = 2053940 }, - { url = "https://files.pythonhosted.org/packages/91/75/984740c17f12c3ce18b5a2fcc4bdceb785cce7df1511a4ce89bca17c7e2d/pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f", size = 1921437 }, - { url = "https://files.pythonhosted.org/packages/a0/74/13c5f606b64d93f0721e7768cd3e8b2102164866c207b8cd6f90bb15d24f/pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3", size = 1966129 }, - { url = "https://files.pythonhosted.org/packages/18/03/9c4aa5919457c7b57a016c1ab513b1a926ed9b2bb7915bf8e506bf65c34b/pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071", size = 2110908 }, - { url = "https://files.pythonhosted.org/packages/92/2c/053d33f029c5dc65e5cf44ff03ceeefb7cce908f8f3cca9265e7f9b540c8/pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119", size = 1735278 }, - { url = "https://files.pythonhosted.org/packages/de/81/7dfe464eca78d76d31dd661b04b5f2036ec72ea8848dd87ab7375e185c23/pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f", size = 1917453 }, { url = "https://files.pythonhosted.org/packages/5d/30/890a583cd3f2be27ecf32b479d5d615710bb926d92da03e3f7838ff3e58b/pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8", size = 1865160 }, { url = "https://files.pythonhosted.org/packages/1d/9a/b634442e1253bc6889c87afe8bb59447f106ee042140bd57680b3b113ec7/pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d", size = 1776777 }, { url = "https://files.pythonhosted.org/packages/75/9a/7816295124a6b08c24c96f9ce73085032d8bcbaf7e5a781cd41aa910c891/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e", size = 1799244 }, @@ -1328,34 +1012,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/16/b805c74b35607d24d37103007f899abc4880923b04929547ae68d478b7f4/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f", size = 2116814 }, { url = "https://files.pythonhosted.org/packages/d1/58/5305e723d9fcdf1c5a655e6a4cc2a07128bf644ff4b1d98daf7a9dbf57da/pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769", size = 1738360 }, { url = "https://files.pythonhosted.org/packages/a5/ae/e14b0ff8b3f48e02394d8acd911376b7b66e164535687ef7dc24ea03072f/pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", size = 1919411 }, - { url = "https://files.pythonhosted.org/packages/7a/04/2580b2deaae37b3e30fc30c54298be938b973990b23612d6b61c7bdd01c7/pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a", size = 1868200 }, - { url = "https://files.pythonhosted.org/packages/39/6e/e311bd0751505350f0cdcee3077841eb1f9253c5a1ddbad048cd9fbf7c6e/pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36", size = 1749316 }, - { url = "https://files.pythonhosted.org/packages/d0/b4/95b5eb47c6dc8692508c3ca04a1f8d6f0884c9dacb34cf3357595cbe73be/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b", size = 1800880 }, - { url = "https://files.pythonhosted.org/packages/da/79/41c4f817acd7f42d94cd1e16526c062a7b089f66faed4bd30852314d9a66/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323", size = 1807077 }, - { url = "https://files.pythonhosted.org/packages/fb/53/d13d1eb0a97d5c06cf7a225935d471e9c241afd389a333f40c703f214973/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3", size = 2002859 }, - { url = "https://files.pythonhosted.org/packages/53/7d/6b8a1eff453774b46cac8c849e99455b27167971a003212f668e94bc4c9c/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df", size = 2661437 }, - { url = "https://files.pythonhosted.org/packages/6c/ea/8820f57f0b46e6148ee42d8216b15e8fe3b360944284bbc705bf34fac888/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c", size = 2054404 }, - { url = "https://files.pythonhosted.org/packages/0f/36/d4ae869e473c3c7868e1cd1e2a1b9e13bce5cd1a7d287f6ac755a0b1575e/pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55", size = 1921680 }, - { url = "https://files.pythonhosted.org/packages/0d/f8/eed5c65b80c4ac4494117e2101973b45fc655774ef647d17dde40a70f7d2/pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040", size = 1966093 }, - { url = "https://files.pythonhosted.org/packages/e8/c8/1d42ce51d65e571ab53d466cae83434325a126811df7ce4861d9d97bee4b/pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605", size = 2111437 }, - { url = "https://files.pythonhosted.org/packages/aa/c9/7fea9d13383c2ec6865919e09cffe44ab77e911eb281b53a4deaafd4c8e8/pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6", size = 1735049 }, - { url = "https://files.pythonhosted.org/packages/98/95/dd7045c4caa2b73d0bf3b989d66b23cfbb7a0ef14ce99db15677a000a953/pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29", size = 1920180 }, - { url = "https://files.pythonhosted.org/packages/13/a9/5d582eb3204464284611f636b55c0a7410d748ff338756323cb1ce721b96/pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5", size = 1857135 }, - { url = "https://files.pythonhosted.org/packages/2c/57/faf36290933fe16717f97829eabfb1868182ac495f99cf0eda9f59687c9d/pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec", size = 1740583 }, - { url = "https://files.pythonhosted.org/packages/91/7c/d99e3513dc191c4fec363aef1bf4c8af9125d8fa53af7cb97e8babef4e40/pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480", size = 1793637 }, - { url = "https://files.pythonhosted.org/packages/29/18/812222b6d18c2d13eebbb0f7cdc170a408d9ced65794fdb86147c77e1982/pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068", size = 1941963 }, - { url = "https://files.pythonhosted.org/packages/0f/36/c1f3642ac3f05e6bb4aec3ffc399fa3f84895d259cf5f0ce3054b7735c29/pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801", size = 1915332 }, - { url = "https://files.pythonhosted.org/packages/f7/ca/9c0854829311fb446020ebb540ee22509731abad886d2859c855dd29b904/pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728", size = 1957926 }, - { url = "https://files.pythonhosted.org/packages/c0/1c/7836b67c42d0cd4441fcd9fafbf6a027ad4b79b6559f80cf11f89fd83648/pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433", size = 2100342 }, - { url = "https://files.pythonhosted.org/packages/a9/f9/b6bcaf874f410564a78908739c80861a171788ef4d4f76f5009656672dfe/pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753", size = 1920344 }, - { url = "https://files.pythonhosted.org/packages/32/fd/ac9cdfaaa7cf2d32590b807d900612b39acb25e5527c3c7e482f0553025b/pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21", size = 1857850 }, - { url = "https://files.pythonhosted.org/packages/08/fe/038f4b2bcae325ea643c8ad353191187a4c92a9c3b913b139289a6f2ef04/pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb", size = 1740265 }, - { url = "https://files.pythonhosted.org/packages/51/14/b215c9c3cbd1edaaea23014d4b3304260823f712d3fdee52549b19b25d62/pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59", size = 1793912 }, - { url = "https://files.pythonhosted.org/packages/62/de/2c3ad79b63ba564878cbce325be725929ba50089cd5156f89ea5155cb9b3/pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577", size = 1942870 }, - { url = "https://files.pythonhosted.org/packages/cb/55/c222af19e4644c741b3f3fe4fd8bbb6b4cdca87d8a49258b61cf7826b19e/pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744", size = 1915610 }, - { url = "https://files.pythonhosted.org/packages/c4/7a/9a8760692a6f76bb54bcd43f245ff3d8b603db695899bbc624099c00af80/pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef", size = 1958403 }, - { url = "https://files.pythonhosted.org/packages/4c/91/9b03166feb914bb5698e2f6499e07c2617e2eebf69f9374d0358d7eb2009/pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8", size = 2101154 }, - { url = "https://files.pythonhosted.org/packages/1d/d9/1d7ecb98318da4cb96986daaf0e20d66f1651d0aeb9e2d4435b916ce031d/pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e", size = 1920855 }, ] [[package]] @@ -1373,11 +1029,9 @@ version = "8.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } wheels = [ @@ -1503,12 +1157,10 @@ version = "0.7.7" source = { editable = "." } dependencies = [ { name = "aiohttp" }, - { name = "async-timeout" }, { name = "asyncclick" }, { name = "cryptography" }, { name = "mashumaro" }, { name = "pydantic" }, - { name = "typing-extensions" }, { name = "tzdata", marker = "platform_system == 'Windows'" }, ] @@ -1544,6 +1196,7 @@ dev = [ { name = "pytest-sugar" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, + { name = "ruff" }, { name = "toml" }, { name = "voluptuous" }, { name = "xdoctest" }, @@ -1552,7 +1205,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3" }, - { name = "async-timeout", specifier = ">=3.0.0" }, { name = "asyncclick", specifier = ">=8.1.7" }, { name = "cryptography", specifier = ">=1.9" }, { name = "docutils", marker = "extra == 'docs'", specifier = ">=0.17" }, @@ -1566,7 +1218,6 @@ requires-dist = [ { name = "sphinx", marker = "extra == 'docs'", specifier = "~=5.0" }, { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0" }, { name = "sphinxcontrib-programoutput", marker = "extra == 'docs'", specifier = "~=0.0" }, - { name = "typing-extensions", specifier = ">=4.12.2,<5.0" }, { name = "tzdata", marker = "platform_system == 'Windows'", specifier = ">=2024.2" }, ] @@ -1585,6 +1236,7 @@ dev = [ { name = "pytest-sugar" }, { name = "pytest-timeout", specifier = "~=2.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, + { name = "ruff", specifier = "==0.7.4" }, { name = "toml" }, { name = "voluptuous" }, { name = "xdoctest", specifier = ">=1.2.0" }, @@ -1596,15 +1248,6 @@ version = "6.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, - { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, - { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, - { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, - { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, - { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, - { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, - { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, - { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, @@ -1632,15 +1275,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, - { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777 }, - { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318 }, - { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891 }, - { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614 }, - { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360 }, - { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006 }, - { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577 }, - { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593 }, - { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312 }, ] [[package]] @@ -1665,13 +1299,37 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } wheels = [ { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, ] +[[package]] +name = "ruff" +version = "0.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/8b/bc4e0dfa1245b07cf14300e10319b98e958a53ff074c1dd86b35253a8c2a/ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2", size = 3275547 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/4b/f5094719e254829766b807dadb766841124daba75a37da83e292ae5ad12f/ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478", size = 10447512 }, + { url = "https://files.pythonhosted.org/packages/9e/1d/3d2d2c9f601cf6044799c5349ff5267467224cefed9b35edf5f1f36486e9/ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63", size = 10235436 }, + { url = "https://files.pythonhosted.org/packages/62/83/42a6ec6216ded30b354b13e0e9327ef75a3c147751aaf10443756cb690e9/ruff-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20", size = 9888936 }, + { url = "https://files.pythonhosted.org/packages/4d/26/e1e54893b13046a6ad05ee9b89ee6f71542ba250f72b4c7a7d17c3dbf73d/ruff-0.7.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109", size = 10697353 }, + { url = "https://files.pythonhosted.org/packages/21/24/98d2e109c4efc02bfef144ec6ea2c3e1217e7ce0cfddda8361d268dfd499/ruff-0.7.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452", size = 10228078 }, + { url = "https://files.pythonhosted.org/packages/ad/b7/964c75be9bc2945fc3172241b371197bb6d948cc69e28bc4518448c368f3/ruff-0.7.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea", size = 11264823 }, + { url = "https://files.pythonhosted.org/packages/12/8d/20abdbf705969914ce40988fe71a554a918deaab62c38ec07483e77866f6/ruff-0.7.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7", size = 11951855 }, + { url = "https://files.pythonhosted.org/packages/b8/fc/6519ce58c57b4edafcdf40920b7273dfbba64fc6ebcaae7b88e4dc1bf0a8/ruff-0.7.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05", size = 11516580 }, + { url = "https://files.pythonhosted.org/packages/37/1a/5ec1844e993e376a86eb2456496831ed91b4398c434d8244f89094758940/ruff-0.7.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06", size = 12692057 }, + { url = "https://files.pythonhosted.org/packages/50/90/76867152b0d3c05df29a74bb028413e90f704f0f6701c4801174ba47f959/ruff-0.7.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc", size = 11085137 }, + { url = "https://files.pythonhosted.org/packages/c8/eb/0a7cb6059ac3555243bd026bb21785bbc812f7bbfa95a36c101bd72b47ae/ruff-0.7.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172", size = 10681243 }, + { url = "https://files.pythonhosted.org/packages/5e/76/2270719dbee0fd35780b05c08a07b7a726c3da9f67d9ae89ef21fc18e2e5/ruff-0.7.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a", size = 10319187 }, + { url = "https://files.pythonhosted.org/packages/9f/e5/39100f72f8ba70bec1bd329efc880dea8b6c1765ea1cb9d0c1c5f18b8d7f/ruff-0.7.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd", size = 10803715 }, + { url = "https://files.pythonhosted.org/packages/a5/89/40e904784f305fb56850063f70a998a64ebba68796d823dde67e89a24691/ruff-0.7.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a", size = 11162912 }, + { url = "https://files.pythonhosted.org/packages/8d/1b/dd77503b3875c51e3dbc053fd8367b845ab8b01c9ca6d0c237082732856c/ruff-0.7.4-py3-none-win32.whl", hash = "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac", size = 8702767 }, + { url = "https://files.pythonhosted.org/packages/63/76/253ddc3e89e70165bba952ecca424b980b8d3c2598ceb4fc47904f424953/ruff-0.7.4-py3-none-win_amd64.whl", hash = "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6", size = 9497534 }, + { url = "https://files.pythonhosted.org/packages/aa/70/f8724f31abc0b329ca98b33d73c14020168babcf71b0cba3cded5d9d0e66/ruff-0.7.4-py3-none-win_arm64.whl", hash = "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f", size = 8851590 }, +] + [[package]] name = "six" version = "1.16.0" @@ -1709,7 +1367,6 @@ dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "docutils" }, { name = "imagesize" }, - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, { name = "jinja2" }, { name = "packaging" }, { name = "pygments" }, @@ -1925,22 +1582,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/54/9c/9c0a9bfa683fc1be7fdcd9687635151544d992cccd48892dc5e0a5885a29/yarl-1.17.1.tar.gz", hash = "sha256:067a63fcfda82da6b198fa73079b1ca40b7c9b7994995b6ee38acda728b64d47", size = 178163 } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/63/0e1e3626a323f366a8ff8eeb4d2835d403cb505393c2fce00c68c2be9d1a/yarl-1.17.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1794853124e2f663f0ea54efb0340b457f08d40a1cef78edfa086576179c91", size = 140627 }, - { url = "https://files.pythonhosted.org/packages/ff/ef/80c92e43f5ca5dfe964f42080252b669097fdd37d40e8c174e5a10d67d2c/yarl-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fbea1751729afe607d84acfd01efd95e3b31db148a181a441984ce9b3d3469da", size = 93563 }, - { url = "https://files.pythonhosted.org/packages/05/43/add866f8c7e99af126a3ff4a673165537617995a5ae90e86cb95f9a1d4ad/yarl-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ee427208c675f1b6e344a1f89376a9613fc30b52646a04ac0c1f6587c7e46ec", size = 91400 }, - { url = "https://files.pythonhosted.org/packages/b9/44/464aba5761fb7ab448d8854520d98355217481746d2421231b8d07d2de8c/yarl-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b74ff4767d3ef47ffe0cd1d89379dc4d828d4873e5528976ced3b44fe5b0a21", size = 313746 }, - { url = "https://files.pythonhosted.org/packages/c1/0f/3a08d81f1e4ff88b07d62f3bb271603c0e2d063cea12239e500defa800d3/yarl-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62a91aefff3d11bf60e5956d340eb507a983a7ec802b19072bb989ce120cd948", size = 329234 }, - { url = "https://files.pythonhosted.org/packages/7d/0f/98f29b8637cf13d7589bb7a1fdc4357bcfc0cfc3f20bc65a6970b71a22ec/yarl-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:846dd2e1243407133d3195d2d7e4ceefcaa5f5bf7278f0a9bda00967e6326b04", size = 325776 }, - { url = "https://files.pythonhosted.org/packages/3c/8c/f383fc542a3d2a1837fb0543ce698653f1760cc18954c29e6d6d49713376/yarl-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e844be8d536afa129366d9af76ed7cb8dfefec99f5f1c9e4f8ae542279a6dc3", size = 318659 }, - { url = "https://files.pythonhosted.org/packages/2b/35/742b4a03ca90e116f70a44b24a36d2138f1b1d776a532ddfece4d60cd93d/yarl-1.17.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc7c92c1baa629cb03ecb0c3d12564f172218fb1739f54bf5f3881844daadc6d", size = 310172 }, - { url = "https://files.pythonhosted.org/packages/9b/fc/f1aba4194861f44673d9b432310cbee2e7c3ffa8ff9bdf165c7eaa9c6e38/yarl-1.17.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ae3476e934b9d714aa8000d2e4c01eb2590eee10b9d8cd03e7983ad65dfbfcba", size = 318283 }, - { url = "https://files.pythonhosted.org/packages/27/0f/2b20100839064d1c75fb85fa6b5cbd68249d96a4b06a5cf25f9eaaf9b32a/yarl-1.17.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c7e177c619342e407415d4f35dec63d2d134d951e24b5166afcdfd1362828e17", size = 317599 }, - { url = "https://files.pythonhosted.org/packages/7b/da/3f2d6643d8cf3003c72587f28a9d9c76829a5b45186cae8f978bac113fc5/yarl-1.17.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64cc6e97f14cf8a275d79c5002281f3040c12e2e4220623b5759ea7f9868d6a5", size = 323398 }, - { url = "https://files.pythonhosted.org/packages/9e/f8/881c97cc35603ec63b48875d47e36e1b984648826b36ce7affac16e08261/yarl-1.17.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:84c063af19ef5130084db70ada40ce63a84f6c1ef4d3dbc34e5e8c4febb20822", size = 337601 }, - { url = "https://files.pythonhosted.org/packages/81/da/049b354e00b33019c32126f2a40ecbcc320859f619c4304c556cf23a5dc3/yarl-1.17.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:482c122b72e3c5ec98f11457aeb436ae4aecca75de19b3d1de7cf88bc40db82f", size = 338975 }, - { url = "https://files.pythonhosted.org/packages/26/64/e36e808b249d64cfc33caca7e9ef2d7e636e4f9e8529e4fe5ed4813ac5b0/yarl-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:380e6c38ef692b8fd5a0f6d1fa8774d81ebc08cfbd624b1bca62a4d4af2f9931", size = 331078 }, - { url = "https://files.pythonhosted.org/packages/82/cb/6fe205b528cc889f8e13d6d180adbc8721a21a6aac67fc3158294575add3/yarl-1.17.1-cp310-cp310-win32.whl", hash = "sha256:16bca6678a83657dd48df84b51bd56a6c6bd401853aef6d09dc2506a78484c7b", size = 83573 }, - { url = "https://files.pythonhosted.org/packages/55/96/4dcb7110ae4cd53768254fb50ace7bca00e110459e6eff1d16983c513219/yarl-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:561c87fea99545ef7d692403c110b2f99dced6dff93056d6e04384ad3bc46243", size = 89761 }, { url = "https://files.pythonhosted.org/packages/ec/0f/ce6a2c8aab9946446fb27f1e28f0fd89ce84ae913ab18a92d18078a1c7ed/yarl-1.17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cbad927ea8ed814622305d842c93412cb47bd39a496ed0f96bfd42b922b4a217", size = 140727 }, { url = "https://files.pythonhosted.org/packages/9d/df/204f7a502bdc3973cd9fc29e7dfad18ae48b3acafdaaf1ae07c0f41025aa/yarl-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fca4b4307ebe9c3ec77a084da3a9d1999d164693d16492ca2b64594340999988", size = 93560 }, { url = "https://files.pythonhosted.org/packages/a2/e1/f4d522ae0560c91a4ea31113a50f00f85083be885e1092fc6e74eb43cb1d/yarl-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff5c6771c7e3511a06555afa317879b7db8d640137ba55d6ab0d0c50425cab75", size = 91497 }, @@ -1989,30 +1630,5 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/87/756e05c74cd8bf9e71537df4a2cae7e8211a9ebe0d2350a3e26949e1e41c/yarl-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0341e6d9a0c0e3cdc65857ef518bb05b410dbd70d749a0d33ac0f39e81a4258", size = 359452 }, { url = "https://files.pythonhosted.org/packages/06/b2/b2bb09c1e6d59e1c9b1b36a86caa473e22c3dbf26d1032c030e9bfb554dc/yarl-1.17.1-cp313-cp313-win32.whl", hash = "sha256:2e7ba4c9377e48fb7b20dedbd473cbcbc13e72e1826917c185157a137dac9df2", size = 308904 }, { url = "https://files.pythonhosted.org/packages/f3/27/f084d9a5668853c1f3b246620269b14ee871ef3c3cc4f3a1dd53645b68ec/yarl-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:949681f68e0e3c25377462be4b658500e85ca24323d9619fdc41f68d46a1ffda", size = 314637 }, - { url = "https://files.pythonhosted.org/packages/8b/1d/715a116e42ecd31f515b268c1a0237a9d8771622cdfc1b4a4216f7854d16/yarl-1.17.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8994b29c462de9a8fce2d591028b986dbbe1b32f3ad600b2d3e1c482c93abad6", size = 141924 }, - { url = "https://files.pythonhosted.org/packages/f4/fa/50c9ac90ce17b6161bd815967f3d40304945353da831c9746bbac3bb0369/yarl-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f9cbfbc5faca235fbdf531b93aa0f9f005ec7d267d9d738761a4d42b744ea159", size = 94156 }, - { url = "https://files.pythonhosted.org/packages/ff/a6/3f7c41d7c63d1e7819871ac1c6c3b94af27b359e162f4769ffe613e3c43c/yarl-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b40d1bf6e6f74f7c0a567a9e5e778bbd4699d1d3d2c0fe46f4b717eef9e96b95", size = 91989 }, - { url = "https://files.pythonhosted.org/packages/12/5d/8bd30a5d2269b0f4062ce10804c79c2bdffde6be4c0501d1761ee99e9bc7/yarl-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5efe0661b9fcd6246f27957f6ae1c0eb29bc60552820f01e970b4996e016004", size = 316098 }, - { url = "https://files.pythonhosted.org/packages/95/70/2bca909b53502ffa2b46695ece4e893eb2a7d6e6628e82741c3b518fb5d0/yarl-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5c4804e4039f487e942c13381e6c27b4b4e66066d94ef1fae3f6ba8b953f383", size = 333170 }, - { url = "https://files.pythonhosted.org/packages/d9/1b/ef6d740e96f555a9c96572367f53b8e853e511d6dbfc228d4e09b7217b8d/yarl-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5d6a6c9602fd4598fa07e0389e19fe199ae96449008d8304bf5d47cb745462e", size = 328992 }, - { url = "https://files.pythonhosted.org/packages/02/d7/4b7877b277ba46dc571de11f0e9df9a9f3ea1548d6125b66541277b68e15/yarl-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4c9156c4d1eb490fe374fb294deeb7bc7eaccda50e23775b2354b6a6739934", size = 320752 }, - { url = "https://files.pythonhosted.org/packages/ae/da/d6ba097b6c78dadf3b9b40f13f0bf19fd9084b95c42611e90b6938d132a3/yarl-1.17.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6324274b4e0e2fa1b3eccb25997b1c9ed134ff61d296448ab8269f5ac068c4c", size = 313372 }, - { url = "https://files.pythonhosted.org/packages/0b/18/39e7c0d57d2d132e1e5d2dd3e11cb5acf6cc87fa7b9a58b947c005c7d858/yarl-1.17.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d8a8b74d843c2638f3864a17d97a4acda58e40d3e44b6303b8cc3d3c44ae2d29", size = 321654 }, - { url = "https://files.pythonhosted.org/packages/fd/ac/3e8e22eaec701ca15a5f236c62c6fc5303aff78beb9c49d15307843abdcc/yarl-1.17.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:7fac95714b09da9278a0b52e492466f773cfe37651cf467a83a1b659be24bf71", size = 323298 }, - { url = "https://files.pythonhosted.org/packages/5d/44/f4aa2bbf3d62b8de8a9e9987256ba1be9e05c6fc4b34ef5d286a8364ad38/yarl-1.17.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c180ac742a083e109c1a18151f4dd8675f32679985a1c750d2ff806796165b55", size = 326736 }, - { url = "https://files.pythonhosted.org/packages/36/65/0c0245b826ca27c6a9ab7887749de10560a75734d124515f7992a311c0c7/yarl-1.17.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:578d00c9b7fccfa1745a44f4eddfdc99d723d157dad26764538fbdda37209857", size = 338987 }, - { url = "https://files.pythonhosted.org/packages/75/65/32115ff01b61f6f492b0e588c7b698be1f58941a7ad52789886f7713d732/yarl-1.17.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1a3b91c44efa29e6c8ef8a9a2b583347998e2ba52c5d8280dbd5919c02dfc3b5", size = 339352 }, - { url = "https://files.pythonhosted.org/packages/f0/04/f7c2d9cb220e4d179f1d7be2319d55bacf3ab088e66d3cbf7f0c258f97df/yarl-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a7ac5b4984c468ce4f4a553df281450df0a34aefae02e58d77a0847be8d1e11f", size = 334126 }, - { url = "https://files.pythonhosted.org/packages/69/6d/838a7b90f441d5111374ded683ba64f93fbac591a799c12cc0e722be61bf/yarl-1.17.1-cp39-cp39-win32.whl", hash = "sha256:7294e38f9aa2e9f05f765b28ffdc5d81378508ce6dadbe93f6d464a8c9594473", size = 84113 }, - { url = "https://files.pythonhosted.org/packages/5b/60/f93718008e232747ceed89f2cd7b7d67b180478020c3d18a795d36291bae/yarl-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:eb6dce402734575e1a8cc0bb1509afca508a400a57ce13d306ea2c663bad1138", size = 90234 }, { url = "https://files.pythonhosted.org/packages/52/ad/1fe7ff5f3e8869d4c5070f47b96bac2b4d15e67c100a8278d8e7876329fc/yarl-1.17.1-py3-none-any.whl", hash = "sha256:f1790a4b1e8e8e028c391175433b9c8122c39b46e1663228158e61e6f915bf06", size = 44352 }, ] - -[[package]] -name = "zipp" -version = "3.20.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/bf/5c0000c44ebc80123ecbdddba1f5dcd94a5ada602a9c225d84b5aaa55e86/zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29", size = 24199 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/8b/5ba542fa83c90e09eac972fc9baca7a88e7e7ca4b221a89251954019308b/zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350", size = 9200 }, -] From 5b5a148f9a5f70452fc4478cb36c8cd986216164 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 19 Nov 2024 10:11:51 +0000 Subject: [PATCH 685/892] Add pan tilt camera module (#1261) Add ptz controls for smartcameras. --------- Co-authored-by: Teemu R. --- kasa/smartcamera/modules/__init__.py | 2 + kasa/smartcamera/modules/pantilt.py | 107 +++++++++++++++++++++++++++ tests/test_feature.py | 4 +- 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 kasa/smartcamera/modules/pantilt.py diff --git a/kasa/smartcamera/modules/__init__.py b/kasa/smartcamera/modules/__init__.py index f3e36cc37..462241e80 100644 --- a/kasa/smartcamera/modules/__init__.py +++ b/kasa/smartcamera/modules/__init__.py @@ -5,6 +5,7 @@ from .childdevice import ChildDevice from .device import DeviceModule from .led import Led +from .pantilt import PanTilt from .time import Time __all__ = [ @@ -13,5 +14,6 @@ "ChildDevice", "DeviceModule", "Led", + "PanTilt", "Time", ] diff --git a/kasa/smartcamera/modules/pantilt.py b/kasa/smartcamera/modules/pantilt.py new file mode 100644 index 000000000..d1882927a --- /dev/null +++ b/kasa/smartcamera/modules/pantilt.py @@ -0,0 +1,107 @@ +"""Implementation of time module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartcameramodule import SmartCameraModule + +DEFAULT_PAN_STEP = 30 +DEFAULT_TILT_STEP = 10 + + +class PanTilt(SmartCameraModule): + """Implementation of device_local_time.""" + + REQUIRED_COMPONENT = "ptz" + _pan_step = DEFAULT_PAN_STEP + _tilt_step = DEFAULT_TILT_STEP + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + + async def set_pan_step(value: int) -> None: + self._pan_step = value + + async def set_tilt_step(value: int) -> None: + self._tilt_step = value + + self._add_feature( + Feature( + self._device, + "pan_right", + "Pan right", + container=self, + attribute_setter=lambda: self.pan(self._pan_step * -1), + type=Feature.Type.Action, + ) + ) + self._add_feature( + Feature( + self._device, + "pan_left", + "Pan left", + container=self, + attribute_setter=lambda: self.pan(self._pan_step), + type=Feature.Type.Action, + ) + ) + self._add_feature( + Feature( + self._device, + "pan_step", + "Pan step", + container=self, + attribute_getter="_pan_step", + attribute_setter=set_pan_step, + type=Feature.Type.Number, + ) + ) + self._add_feature( + Feature( + self._device, + "tilt_up", + "Tilt up", + container=self, + attribute_setter=lambda: self.tilt(self._tilt_step), + type=Feature.Type.Action, + ) + ) + self._add_feature( + Feature( + self._device, + "tilt_down", + "Tilt down", + container=self, + attribute_setter=lambda: self.tilt(self._tilt_step * -1), + type=Feature.Type.Action, + ) + ) + self._add_feature( + Feature( + self._device, + "tilt_step", + "Tilt step", + container=self, + attribute_getter="_tilt_step", + attribute_setter=set_tilt_step, + type=Feature.Type.Number, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + async def pan(self, pan: int) -> dict: + """Pan horizontally.""" + return await self.move(pan=pan, tilt=0) + + async def tilt(self, tilt: int) -> dict: + """Tilt vertically.""" + return await self.move(pan=0, tilt=tilt) + + async def move(self, *, pan: int, tilt: int) -> dict: + """Pan and tilt camera.""" + return await self._device._raw_query( + {"do": {"motor": {"move": {"x_coord": str(pan), "y_coord": str(tilt)}}}} + ) diff --git a/tests/test_feature.py b/tests/test_feature.py index 0ff6e1be7..79560b1ae 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -160,12 +160,14 @@ async def test_precision_hint(dummy_feature, precision_hint): async def test_feature_setters(dev: Device, mocker: MockerFixture): """Test that all feature setters query something.""" + # setters that do not call set on the device itself. + internal_setters = {"pan_step", "tilt_step"} async def _test_feature(feat, query_mock): if feat.attribute_setter is None: return - expecting_call = True + expecting_call = feat.id not in internal_setters if feat.type == Feature.Type.Number: await feat.set_value(feat.minimum_value) From e1e6d722225a6a02d5aae55ec3657d7614cb1086 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 19 Nov 2024 19:05:11 +0000 Subject: [PATCH 686/892] Update sphinx dependency to 6.2 to fix docs build (#1280) --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8c47ad5a4..e43c9ecf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ [project.optional-dependencies] speedups = ["orjson>=3.9.1", "kasa-crypt>=0.2.0"] -docs = ["sphinx~=5.0", "sphinx_rtd_theme~=2.0", "sphinxcontrib-programoutput~=0.0", "myst-parser", "docutils>=0.17"] +docs = ["sphinx~=6.2", "sphinx_rtd_theme~=2.0", "sphinxcontrib-programoutput~=0.0", "myst-parser", "docutils>=0.17"] shell = ["ptpython", "rich"] [project.urls] diff --git a/uv.lock b/uv.lock index bd1b1496a..bbef66362 100644 --- a/uv.lock +++ b/uv.lock @@ -1215,7 +1215,7 @@ requires-dist = [ { name = "ptpython", marker = "extra == 'shell'" }, { name = "pydantic", specifier = ">=1.10.15" }, { name = "rich", marker = "extra == 'shell'" }, - { name = "sphinx", marker = "extra == 'docs'", specifier = "~=5.0" }, + { name = "sphinx", marker = "extra == 'docs'", specifier = "~=6.2" }, { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0" }, { name = "sphinxcontrib-programoutput", marker = "extra == 'docs'", specifier = "~=0.0" }, { name = "tzdata", marker = "platform_system == 'Windows'", specifier = ">=2024.2" }, @@ -1359,7 +1359,7 @@ wheels = [ [[package]] name = "sphinx" -version = "5.3.0" +version = "6.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alabaster" }, @@ -1379,9 +1379,9 @@ dependencies = [ { name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-serializinghtml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/b2/02a43597980903483fe5eb081ee8e0ba2bb62ea43a70499484343795f3bf/Sphinx-5.3.0.tar.gz", hash = "sha256:51026de0a9ff9fc13c05d74913ad66047e104f56a129ff73e174eb5c3ee794b5", size = 6811365 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/6d/392defcc95ca48daf62aecb89550143e97a4651275e62a3d7755efe35a3a/Sphinx-6.2.1.tar.gz", hash = "sha256:6d56a34697bb749ffa0152feafc4b19836c755d90a7c59b72bc7dfd371b9cc6b", size = 6681092 } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/a7/01dd6fd9653c056258d65032aa09a615b5d7b07dd840845a9f41a8860fbc/sphinx-5.3.0-py3-none-any.whl", hash = "sha256:060ca5c9f7ba57a08a1219e547b269fadf125ae25b06b9fa7f66768efb652d6d", size = 3183160 }, + { url = "https://files.pythonhosted.org/packages/5f/d8/45ba6097c39ba44d9f0e1462fb232e13ca4ddb5aea93a385dcfa964687da/sphinx-6.2.1-py3-none-any.whl", hash = "sha256:97787ff1fa3256a3eef9eda523a63dbf299f7b47e053cfcf684a1c2a8380c912", size = 3024615 }, ] [[package]] From 2683623997c9c5948253f267a5fc8c76dbea97e6 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 19 Nov 2024 19:09:50 +0000 Subject: [PATCH 687/892] Update DiscoveryResult to use mashu Annotated Alias (#1279) --- kasa/discover.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index b20b9ec33..74b663e8d 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -92,18 +92,19 @@ from asyncio import timeout as asyncio_timeout from asyncio.transports import DatagramTransport from collections.abc import Callable, Coroutine -from dataclasses import dataclass, field +from dataclasses import dataclass from pprint import pformat as pf from typing import ( TYPE_CHECKING, + Annotated, Any, NamedTuple, cast, ) from aiohttp import ClientSession -from mashumaro import field_options from mashumaro.config import BaseConfig +from mashumaro.types import Alias from kasa import Device from kasa.credentials import Credentials @@ -851,9 +852,7 @@ class DiscoveryResult(_DiscoveryBaseMixin): encrypt_info: EncryptionInfo | None = None encrypt_type: list[str] | None = None decrypted_data: dict | None = None - is_reset_wifi: bool | None = field( - metadata=field_options(alias="isResetWiFi"), default=None - ) + is_reset_wifi: Annotated[bool | None, Alias("isResetWiFi")] = None firmware_version: str | None = None hardware_version: str | None = None From bf23f73cced59946f3a7fb2a9573f616b31b309e Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 19 Nov 2024 23:36:16 +0000 Subject: [PATCH 688/892] Extend dump_devinfo iot queries (#1278) --- devtools/dump_devinfo.py | 17 ++++- tests/fakeprotocol_iot.py | 5 +- tests/fixtures/KP105(UK)_1.0_1.0.5.json | 89 +++++++++++++++++++++++-- 3 files changed, 101 insertions(+), 10 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 13c597e0b..d69c8c7e9 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -114,6 +114,7 @@ def scrub(res): "connect_ssid", "encrypt_info", "local_ip", + "username", ] for k, v in res.items(): @@ -152,7 +153,7 @@ def scrub(res): v = base64.b64encode(b"#MASKED_SSID#").decode() elif k in ["nickname"]: v = base64.b64encode(b"#MASKED_NAME#").decode() - elif k in ["alias", "device_alias", "device_name"]: + elif k in ["alias", "device_alias", "device_name", "username"]: v = "#MASKED_NAME#" elif isinstance(res[k], int): v = 0 @@ -398,11 +399,25 @@ async def get_legacy_fixture( items = [ Call(module="system", method="get_sysinfo"), Call(module="emeter", method="get_realtime"), + Call(module="cnCloud", method="get_info"), + Call(module="cnCloud", method="get_intl_fw_list"), + Call(module="smartlife.iot.common.schedule", method="get_next_action"), + Call(module="smartlife.iot.common.schedule", method="get_rules"), + Call(module="schedule", method="get_next_action"), + Call(module="schedule", method="get_rules"), Call(module="smartlife.iot.dimmer", method="get_dimmer_parameters"), + Call(module="smartlife.iot.dimmer", method="get_default_behavior"), Call(module="smartlife.iot.common.emeter", method="get_realtime"), Call( module="smartlife.iot.smartbulb.lightingservice", method="get_light_state" ), + Call( + module="smartlife.iot.smartbulb.lightingservice", + method="get_default_behavior", + ), + Call( + module="smartlife.iot.smartbulb.lightingservice", method="get_light_details" + ), Call(module="smartlife.iot.LAS", method="get_config"), Call(module="smartlife.iot.LAS", method="get_current_brt"), Call(module="smartlife.iot.PIR", method="get_config"), diff --git a/tests/fakeprotocol_iot.py b/tests/fakeprotocol_iot.py index b03564d1d..8c4e4057c 100644 --- a/tests/fakeprotocol_iot.py +++ b/tests/fakeprotocol_iot.py @@ -213,8 +213,9 @@ def _build_fake_proto(info): for target in info: if target != "discovery_result": for cmd in info[target]: - # print("initializing tgt %s cmd %s" % (target, cmd)) - proto[target][cmd] = info[target][cmd] + # Use setdefault in case the fixture has modules not yet + # part of the baseproto. + proto.setdefault(target, {})[cmd] = info[target][cmd] # if we have emeter support, we need to add the missing pieces for module in ["emeter", "smartlife.iot.common.emeter"]: diff --git a/tests/fixtures/KP105(UK)_1.0_1.0.5.json b/tests/fixtures/KP105(UK)_1.0_1.0.5.json index 71ec3b7bf..ce1943752 100644 --- a/tests/fixtures/KP105(UK)_1.0_1.0.5.json +++ b/tests/fixtures/KP105(UK)_1.0_1.0.5.json @@ -1,7 +1,82 @@ { + "cnCloud": { + "get_info": { + "binded": 1, + "cld_connection": 0, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "#MASKED_NAME#" + }, + "get_intl_fw_list": { + "err_code": -7, + "err_msg": "unknown error" + } + }, + "schedule": { + "get_next_action": { + "action": 1, + "err_code": 0, + "id": "0794F4729DB271627D1CF35A9A854030", + "schd_time": 68927, + "type": 2 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [ + { + "eact": -1, + "enable": 1, + "id": "8AA75A50A8440B17941D192BD9E01FFA", + "name": "name", + "repeat": 1, + "sact": 1, + "smin": 1027, + "soffset": 0, + "stime_opt": 2, + "wday": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "eact": -1, + "enable": 1, + "id": "9F62073CF69D8645173412283AD63A2C", + "name": "name", + "repeat": 1, + "sact": 0, + "smin": 504, + "soffset": 0, + "stime_opt": 1, + "wday": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + ], + "version": 2 + } + }, "system": { "get_sysinfo": { - "active_mode": "schedule", + "active_mode": "count_down", "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug", "deviceId": "0000000000000000000000000000000000000000", @@ -18,16 +93,16 @@ "model": "KP105(UK)", "next_action": { "action": 1, - "id": "8AA75A50A8440B17941D192BD9E01FFA", - "schd_sec": 59160, - "type": 1 + "id": "0794F4729DB271627D1CF35A9A854030", + "schd_sec": 68927, + "type": 2 }, "ntc_state": 0, "obd_src": "tplink", "oemId": "00000000000000000000000000000000", - "on_time": 0, - "relay_state": 0, - "rssi": -66, + "on_time": 7138, + "relay_state": 1, + "rssi": -77, "status": "configured", "sw_ver": "1.0.5 Build 191209 Rel.094735", "updating": 0 From 79ac9547e8493fb245808db29a93e99061013500 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 20 Nov 2024 08:35:32 +0000 Subject: [PATCH 689/892] Replace custom deviceconfig serialization with mashumaru (#1274) --- kasa/deviceconfig.py | 142 +++++++----------- tests/conftest.py | 8 + .../deviceconfig_camera-aes-https.json | 10 ++ .../serialization/deviceconfig_plug-klap.json | 11 ++ .../serialization/deviceconfig_plug-xor.json | 10 ++ tests/test_deviceconfig.py | 80 ++++++++-- 6 files changed, 165 insertions(+), 96 deletions(-) create mode 100644 tests/fixtures/serialization/deviceconfig_camera-aes-https.json create mode 100644 tests/fixtures/serialization/deviceconfig_plug-klap.json create mode 100644 tests/fixtures/serialization/deviceconfig_plug-xor.json diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index ede3f5955..56e97f5ec 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -17,9 +17,10 @@ >>> config_dict = device.config.to_dict() >>> # DeviceConfig.to_dict() can be used to store for later >>> print(config_dict) -{'host': '127.0.0.3', 'timeout': 5, 'credentials': Credentials(), 'connection_type'\ -: {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'https': False, \ -'login_version': 2}, 'uses_http': True} +{'host': '127.0.0.3', 'timeout': 5, 'credentials': {'username': 'user@example.com', \ +'password': 'great_password'}, 'connection_type'\ +: {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'login_version': 2, \ +'https': False}, 'uses_http': True} >>> later_device = await Device.connect(config=Device.Config.from_dict(config_dict)) >>> print(later_device.alias) # Alias is available as connect() calls update() @@ -27,15 +28,21 @@ """ -# Module cannot use from __future__ import annotations until migrated to mashumaru -# as dataclass.fields() will not resolve the type. +from __future__ import annotations + import logging -from dataclasses import asdict, dataclass, field, fields, is_dataclass +from dataclasses import dataclass, field, replace from enum import Enum -from typing import TYPE_CHECKING, Any, Optional, TypedDict +from typing import TYPE_CHECKING, Any, Self, TypedDict + +from aiohttp import ClientSession +from mashumaro import field_options +from mashumaro.config import BaseConfig +from mashumaro.types import SerializationStrategy from .credentials import Credentials from .exceptions import KasaException +from .json import DataClassJSONMixin if TYPE_CHECKING: from aiohttp import ClientSession @@ -73,45 +80,17 @@ class DeviceFamily(Enum): SmartIpCamera = "SMART.IPCAMERA" -def _dataclass_from_dict(klass: Any, in_val: dict) -> Any: - if is_dataclass(klass): - fieldtypes = {f.name: f.type for f in fields(klass)} - val = {} - for dict_key in in_val: - if dict_key in fieldtypes: - if hasattr(fieldtypes[dict_key], "from_dict"): - val[dict_key] = fieldtypes[dict_key].from_dict(in_val[dict_key]) # type: ignore[union-attr] - else: - val[dict_key] = _dataclass_from_dict( - fieldtypes[dict_key], in_val[dict_key] - ) - else: - raise KasaException( - f"Cannot create dataclass from dict, unknown key: {dict_key}" - ) - return klass(**val) # type: ignore[operator] - else: - return in_val - - -def _dataclass_to_dict(in_val: Any) -> dict: - fieldtypes = {f.name: f.type for f in fields(in_val) if f.compare} - out_val = {} - for field_name in fieldtypes: - val = getattr(in_val, field_name) - if val is None: - continue - elif hasattr(val, "to_dict"): - out_val[field_name] = val.to_dict() - elif is_dataclass(fieldtypes[field_name]): - out_val[field_name] = asdict(val) - else: - out_val[field_name] = val - return out_val +class _DeviceConfigBaseMixin(DataClassJSONMixin): + """Base class for serialization mixin.""" + + class Config(BaseConfig): + """Serialization config.""" + + omit_none = True @dataclass -class DeviceConnectionParameters: +class DeviceConnectionParameters(_DeviceConfigBaseMixin): """Class to hold the the parameters determining connection type.""" device_family: DeviceFamily @@ -125,7 +104,7 @@ def from_values( encryption_type: str, login_version: int | None = None, https: bool | None = None, - ) -> "DeviceConnectionParameters": + ) -> DeviceConnectionParameters: """Return connection parameters from string values.""" try: if https is None: @@ -142,39 +121,17 @@ def from_values( + f"{encryption_type}.{login_version}" ) from ex - @staticmethod - def from_dict(connection_type_dict: dict[str, Any]) -> "DeviceConnectionParameters": - """Return connection parameters from dict.""" - if ( - isinstance(connection_type_dict, dict) - and (device_family := connection_type_dict.get("device_family")) - and (encryption_type := connection_type_dict.get("encryption_type")) - ): - if login_version := connection_type_dict.get("login_version"): - login_version = int(login_version) # type: ignore[assignment] - return DeviceConnectionParameters.from_values( - device_family, - encryption_type, - login_version, # type: ignore[arg-type] - connection_type_dict.get("https", False), - ) - raise KasaException(f"Invalid connection type data for {connection_type_dict}") +class _DoNotSerialize(SerializationStrategy): + def serialize(self, value: Any) -> None: + return None # pragma: no cover - def to_dict(self) -> dict[str, str | int | bool]: - """Convert connection params to dict.""" - result: dict[str, str | int] = { - "device_family": self.device_family.value, - "encryption_type": self.encryption_type.value, - "https": self.https, - } - if self.login_version: - result["login_version"] = self.login_version - return result + def deserialize(self, value: Any) -> None: + return None # pragma: no cover @dataclass -class DeviceConfig: +class DeviceConfig(_DeviceConfigBaseMixin): """Class to represent paramaters that determine how to connect to devices.""" DEFAULT_TIMEOUT = 5 @@ -202,9 +159,12 @@ class DeviceConfig: #: 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["ClientSession"] = field(default=None, compare=False) + http_client: ClientSession | None = field( + default=None, + compare=False, + metadata=field_options(serialization_strategy=_DoNotSerialize()), + ) aes_keys: KeyPairDict | None = None @@ -214,22 +174,30 @@ def __post_init__(self) -> None: DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor ) - def to_dict( + def __pre_serialize__(self) -> Self: + return replace(self, http_client=None) + + def to_dict_control_credentials( self, *, credentials_hash: str | None = None, exclude_credentials: bool = False, ) -> dict[str, dict[str, str]]: - """Convert device config to dict.""" - if credentials_hash is not None or exclude_credentials: - self.credentials = None - if credentials_hash: - self.credentials_hash = credentials_hash - return _dataclass_to_dict(self) + """Convert deviceconfig to dict controlling how to serialize credentials. + + If credentials_hash is provided credentials will be None. + If credentials_hash is '' credentials_hash and credentials will be None. + exclude credentials controls whether to include credentials. + The defaults are the same as calling to_dict(). + """ + if credentials_hash is None: + if not exclude_credentials: + return self.to_dict() + else: + return replace(self, credentials=None).to_dict() - @staticmethod - def from_dict(config_dict: dict[str, dict[str, str]]) -> "DeviceConfig": - """Return device config from dict.""" - if isinstance(config_dict, dict): - return _dataclass_from_dict(DeviceConfig, config_dict) - raise KasaException(f"Invalid device config data: {config_dict}") + return replace( + self, + credentials_hash=credentials_hash if credentials_hash else None, + credentials=None, + ).to_dict() diff --git a/tests/conftest.py b/tests/conftest.py index 1b11e01bb..3da689c5b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import asyncio import sys import warnings +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -21,6 +22,13 @@ turn_on = pytest.mark.parametrize("turn_on", [True, False]) +def load_fixture(foldername, filename): + """Load a fixture.""" + path = Path(Path(__file__).parent / "fixtures" / foldername / filename) + with path.open() as fdp: + return fdp.read() + + async def handle_turn_on(dev, turn_on): if turn_on: await dev.turn_on() diff --git a/tests/fixtures/serialization/deviceconfig_camera-aes-https.json b/tests/fixtures/serialization/deviceconfig_camera-aes-https.json new file mode 100644 index 000000000..559e834b2 --- /dev/null +++ b/tests/fixtures/serialization/deviceconfig_camera-aes-https.json @@ -0,0 +1,10 @@ +{ + "host": "127.0.0.1", + "timeout": 5, + "connection_type": { + "device_family": "SMART.IPCAMERA", + "encryption_type": "AES", + "https": true + }, + "uses_http": false +} diff --git a/tests/fixtures/serialization/deviceconfig_plug-klap.json b/tests/fixtures/serialization/deviceconfig_plug-klap.json new file mode 100644 index 000000000..ef42bb2f9 --- /dev/null +++ b/tests/fixtures/serialization/deviceconfig_plug-klap.json @@ -0,0 +1,11 @@ +{ + "host": "127.0.0.1", + "timeout": 5, + "connection_type": { + "device_family": "SMART.TAPOPLUG", + "encryption_type": "KLAP", + "https": false, + "login_version": 2 + }, + "uses_http": false +} diff --git a/tests/fixtures/serialization/deviceconfig_plug-xor.json b/tests/fixtures/serialization/deviceconfig_plug-xor.json new file mode 100644 index 000000000..78cc05a96 --- /dev/null +++ b/tests/fixtures/serialization/deviceconfig_plug-xor.json @@ -0,0 +1,10 @@ +{ + "host": "127.0.0.1", + "timeout": 5, + "connection_type": { + "device_family": "IOT.SMARTPLUGSWITCH", + "encryption_type": "XOR", + "https": false + }, + "uses_http": false +} diff --git a/tests/test_deviceconfig.py b/tests/test_deviceconfig.py index cefc6179c..aebdd3a61 100644 --- a/tests/test_deviceconfig.py +++ b/tests/test_deviceconfig.py @@ -1,35 +1,97 @@ +import json +from dataclasses import replace from json import dumps as json_dumps from json import loads as json_loads import aiohttp import pytest +from mashumaro import MissingField from kasa.credentials import Credentials from kasa.deviceconfig import ( DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) + +from .conftest import load_fixture + +PLUG_XOR_CONFIG = DeviceConfig(host="127.0.0.1") +PLUG_KLAP_CONFIG = DeviceConfig( + host="127.0.0.1", + connection_type=DeviceConnectionParameters( + DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Klap, login_version=2 + ), +) +CAMERA_AES_CONFIG = DeviceConfig( + host="127.0.0.1", + connection_type=DeviceConnectionParameters( + DeviceFamily.SmartIpCamera, DeviceEncryptionType.Aes, https=True + ), ) -from kasa.exceptions import KasaException async def test_serialization(): + """Test device config serialization.""" config = DeviceConfig(host="Foo", http_client=aiohttp.ClientSession()) config_dict = config.to_dict() config_json = json_dumps(config_dict) config2_dict = json_loads(config_json) config2 = DeviceConfig.from_dict(config2_dict) assert config == config2 + assert config.to_dict_control_credentials() == config.to_dict() + + +@pytest.mark.parametrize( + ("fixture_name", "expected_value"), + [ + ("deviceconfig_plug-xor.json", PLUG_XOR_CONFIG), + ("deviceconfig_plug-klap.json", PLUG_KLAP_CONFIG), + ("deviceconfig_camera-aes-https.json", CAMERA_AES_CONFIG), + ], + ids=lambda arg: arg.split("_")[-1] if isinstance(arg, str) else "", +) +async def test_deserialization(fixture_name: str, expected_value: DeviceConfig): + """Test device config deserialization.""" + dict_val = json.loads(load_fixture("serialization", fixture_name)) + config = DeviceConfig.from_dict(dict_val) + assert config == expected_value + assert expected_value.to_dict() == dict_val + + +async def test_serialization_http_client(): + """Test that the http client does not try to serialize.""" + dict_val = json.loads(load_fixture("serialization", "deviceconfig_plug-klap.json")) + + config = replace(PLUG_KLAP_CONFIG, http_client=object()) + assert config.http_client + + assert config.to_dict() == dict_val + + +async def test_conn_param_no_https(): + """Test no https in connection param defaults to False.""" + dict_val = { + "device_family": "SMART.TAPOPLUG", + "encryption_type": "KLAP", + "login_version": 2, + } + param = DeviceConnectionParameters.from_dict(dict_val) + assert param.https is False + assert param.to_dict() == {**dict_val, "https": False} @pytest.mark.parametrize( - ("input_value", "expected_msg"), + ("input_value", "expected_error"), [ - ({"Foo": "Bar"}, "Cannot create dataclass from dict, unknown key: Foo"), - ("foobar", "Invalid device config data: foobar"), + ({"Foo": "Bar"}, MissingField), + ("foobar", ValueError), ], ids=["invalid-dict", "not-dict"], ) -def test_deserialization_errors(input_value, expected_msg): - with pytest.raises(KasaException, match=expected_msg): +def test_deserialization_errors(input_value, expected_error): + with pytest.raises(expected_error): DeviceConfig.from_dict(input_value) @@ -39,7 +101,7 @@ async def test_credentials_hash(): http_client=aiohttp.ClientSession(), credentials=Credentials("foo", "bar"), ) - config_dict = config.to_dict(credentials_hash="credhash") + config_dict = config.to_dict_control_credentials(credentials_hash="credhash") config_json = json_dumps(config_dict) config2_dict = json_loads(config_json) config2 = DeviceConfig.from_dict(config2_dict) @@ -53,7 +115,7 @@ async def test_blank_credentials_hash(): http_client=aiohttp.ClientSession(), credentials=Credentials("foo", "bar"), ) - config_dict = config.to_dict(credentials_hash="") + config_dict = config.to_dict_control_credentials(credentials_hash="") config_json = json_dumps(config_dict) config2_dict = json_loads(config_json) config2 = DeviceConfig.from_dict(config2_dict) @@ -67,7 +129,7 @@ async def test_exclude_credentials(): http_client=aiohttp.ClientSession(), credentials=Credentials("foo", "bar"), ) - config_dict = config.to_dict(exclude_credentials=True) + config_dict = config.to_dict_control_credentials(exclude_credentials=True) config_json = json_dumps(config_dict) config2_dict = json_loads(config_json) config2 = DeviceConfig.from_dict(config2_dict) From 03c073c2936e2ecaf3f47682e13271cf4426c9e1 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 20 Nov 2024 08:37:04 +0000 Subject: [PATCH 690/892] Migrate IotLightPreset to mashumaru (#1275) --- kasa/iot/iotbulb.py | 4 ++-- kasa/iot/modules/lightpreset.py | 33 ++++++++++++++++++++------------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index e0e95020c..14c711031 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -178,8 +178,8 @@ class IotBulb(IotDevice): Bulb configuration presets can be accessed using the :func:`presets` property: - >>> bulb.presets - [IotLightPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), IotLightPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), IotLightPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), IotLightPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] + >>> [ preset.to_dict() for preset in bulb.presets } + [{'brightness': 50, 'hue': 0, 'saturation': 0, 'color_temp': 2700, 'index': 0}, {'brightness': 100, 'hue': 0, 'saturation': 75, 'color_temp': 0, 'index': 1}, {'brightness': 100, 'hue': 120, 'saturation': 75, 'color_temp': 0, 'index': 2}, {'brightness': 100, 'hue': 240, 'saturation': 75, 'color_temp': 0, 'index': 3}] To modify an existing preset, pass :class:`~kasa.interfaces.light.LightPreset` instance to :func:`save_preset` method: diff --git a/kasa/iot/modules/lightpreset.py b/kasa/iot/modules/lightpreset.py index 6b46f7535..d97bfc4a8 100644 --- a/kasa/iot/modules/lightpreset.py +++ b/kasa/iot/modules/lightpreset.py @@ -3,14 +3,15 @@ from __future__ import annotations from collections.abc import Sequence -from dataclasses import asdict +from dataclasses import asdict, dataclass from typing import TYPE_CHECKING -from pydantic.v1 import BaseModel, Field +from mashumaro.config import BaseConfig from ...exceptions import KasaException from ...interfaces import LightPreset as LightPresetInterface from ...interfaces import LightState +from ...json import DataClassJSONMixin from ...module import Module from ..iotmodule import IotModule @@ -21,21 +22,27 @@ # error: Signature of "__replace__" incompatible with supertype "LightState" -class IotLightPreset(BaseModel, LightState): # type: ignore[override] +@dataclass(kw_only=True, repr=False) +class IotLightPreset(DataClassJSONMixin, LightState): # type: ignore[override] """Light configuration preset.""" - index: int = Field(kw_only=True) - brightness: int = Field(kw_only=True) + class Config(BaseConfig): + """Config class.""" + + omit_none = True + + index: int + brightness: int # These are not available for effect mode presets on light strips - hue: int | None = Field(kw_only=True, default=None) - saturation: int | None = Field(kw_only=True, default=None) - color_temp: int | None = Field(kw_only=True, default=None) + hue: int | None = None + saturation: int | None = None + color_temp: int | None = None # Variables for effect mode presets - custom: int | None = Field(kw_only=True, default=None) - id: str | None = Field(kw_only=True, default=None) - mode: int | None = Field(kw_only=True, default=None) + custom: int | None = None + id: str | None = None + mode: int | None = None class LightPreset(IotModule, LightPresetInterface): @@ -47,7 +54,7 @@ class LightPreset(IotModule, LightPresetInterface): async def _post_update_hook(self) -> None: """Update the internal presets.""" self._presets = { - f"Light preset {index+1}": IotLightPreset(**vals) + f"Light preset {index+1}": IotLightPreset.from_dict(vals) for index, vals in enumerate(self.data["preferred_state"]) # Devices may list some light effects along with normal presets but these # are handled by the LightEffect module so exclude preferred states with id @@ -157,4 +164,4 @@ async def _deprecated_save_preset(self, preset: IotLightPreset) -> dict: if preset.index >= len(self._presets): raise KasaException("Invalid preset index") - return await self.call("set_preferred_state", preset.dict(exclude_none=True)) + return await self.call("set_preferred_state", preset.to_dict()) From 999e84d2de5620dd2fe051f64e71a81a02483459 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 20 Nov 2024 11:54:13 +0000 Subject: [PATCH 691/892] Migrate smart firmware module to mashumaro (#1276) --- kasa/smart/modules/firmware.py | 42 +++++++++++++++------------- tests/smart/modules/test_firmware.py | 27 ++++++++++++++---- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 5956a3575..8dd3a6b32 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -6,10 +6,12 @@ import logging from asyncio import timeout as asyncio_timeout from collections.abc import Callable, Coroutine +from dataclasses import dataclass, field from datetime import date -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Annotated -from pydantic.v1 import BaseModel, Field, validator +from mashumaro import DataClassDictMixin, field_options +from mashumaro.types import Alias from ...exceptions import KasaException from ...feature import Feature @@ -22,36 +24,36 @@ _LOGGER = logging.getLogger(__name__) -class DownloadState(BaseModel): +@dataclass +class DownloadState(DataClassDictMixin): """Download state.""" # Example: # {'status': 0, 'download_progress': 0, 'reboot_time': 5, # 'upgrade_time': 5, 'auto_upgrade': False} status: int - progress: int = Field(alias="download_progress") + progress: Annotated[int, Alias("download_progress")] reboot_time: int upgrade_time: int auto_upgrade: bool -class UpdateInfo(BaseModel): +@dataclass +class UpdateInfo(DataClassDictMixin): """Update info status object.""" - status: int = Field(alias="type") - version: str | None = Field(alias="fw_ver", default=None) - release_date: date | None = None - release_notes: str | None = Field(alias="release_note", default=None) + status: Annotated[int, Alias("type")] + needs_upgrade: Annotated[bool, Alias("need_to_upgrade")] + version: Annotated[str | None, Alias("fw_ver")] = None + release_date: date | None = field( + default=None, + metadata=field_options( + deserialize=lambda x: date.fromisoformat(x) if x else None + ), + ) + release_notes: Annotated[str | None, Alias("release_note")] = None fw_size: int | None = None oem_id: str | None = None - needs_upgrade: bool = Field(alias="need_to_upgrade") - - @validator("release_date", pre=True) - def _release_date_optional(cls, v: str) -> str | None: - if not v: - return None - - return v @property def update_available(self) -> bool: @@ -139,7 +141,7 @@ async def check_latest_firmware(self) -> UpdateInfo | None: """Check for the latest firmware for the device.""" try: fw = await self.call("get_latest_fw") - self._firmware_update_info = UpdateInfo.parse_obj(fw["get_latest_fw"]) + self._firmware_update_info = UpdateInfo.from_dict(fw["get_latest_fw"]) return self._firmware_update_info except Exception: _LOGGER.exception("Error getting latest firmware for %s:", self._device) @@ -174,7 +176,7 @@ async def get_update_state(self) -> DownloadState: """Return update state.""" resp = await self.call("get_fw_download_state") state = resp["get_fw_download_state"] - return DownloadState(**state) + return DownloadState.from_dict(state) @allow_update_after async def update( @@ -232,7 +234,7 @@ async def update( else: _LOGGER.warning("Unhandled state code: %s", state) - return state.dict() + return state.to_dict() @property def auto_update_enabled(self) -> bool: diff --git a/tests/smart/modules/test_firmware.py b/tests/smart/modules/test_firmware.py index 3115c56f1..0bc0a4eab 100644 --- a/tests/smart/modules/test_firmware.py +++ b/tests/smart/modules/test_firmware.py @@ -3,6 +3,7 @@ import asyncio import logging from contextlib import nullcontext +from datetime import date from typing import TypedDict import pytest @@ -52,6 +53,20 @@ async def test_firmware_features( assert isinstance(feat.value, type) +@firmware +async def test_firmware_update_info(dev: SmartDevice): + """Test that the firmware UpdateInfo object deserializes correctly.""" + fw = dev.modules.get(Module.Firmware) + assert fw + + if not dev.is_cloud_connected: + pytest.skip("Device is not cloud connected, skipping test") + assert fw.firmware_update_info is None + await fw.check_latest_firmware() + assert fw.firmware_update_info is not None + assert isinstance(fw.firmware_update_info.release_date, date | None) + + @firmware async def test_update_available_without_cloud(dev: SmartDevice): """Test that update_available returns None when disconnected.""" @@ -105,15 +120,15 @@ class Extras(TypedDict): } update_states = [ # Unknown 1 - DownloadState(status=1, download_progress=0, **extras), + DownloadState(status=1, progress=0, **extras), # Downloading - DownloadState(status=2, download_progress=10, **extras), - DownloadState(status=2, download_progress=100, **extras), + DownloadState(status=2, progress=10, **extras), + DownloadState(status=2, progress=100, **extras), # Flashing - DownloadState(status=3, download_progress=100, **extras), - DownloadState(status=3, download_progress=100, **extras), + DownloadState(status=3, progress=100, **extras), + DownloadState(status=3, progress=100, **extras), # Done - DownloadState(status=0, download_progress=100, **extras), + DownloadState(status=0, progress=100, **extras), ] asyncio_sleep = asyncio.sleep From bbe68a5fe9a8f71bf037766ab2e9142979faece1 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 20 Nov 2024 14:07:02 +0100 Subject: [PATCH 692/892] dump_devinfo: query smartlife.iot.common.cloud for fw updates (#1284) --- devtools/dump_devinfo.py | 2 + tests/fixtures/KL130(EU)_1.0_1.8.8.json | 85 ++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index d69c8c7e9..0f30f96dd 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -401,6 +401,8 @@ async def get_legacy_fixture( Call(module="emeter", method="get_realtime"), Call(module="cnCloud", method="get_info"), Call(module="cnCloud", method="get_intl_fw_list"), + Call(module="smartlife.iot.common.cloud", method="get_info"), + Call(module="smartlife.iot.common.cloud", method="get_intl_fw_list"), Call(module="smartlife.iot.common.schedule", method="get_next_action"), Call(module="smartlife.iot.common.schedule", method="get_rules"), Call(module="schedule", method="get_next_action"), diff --git a/tests/fixtures/KL130(EU)_1.0_1.8.8.json b/tests/fixtures/KL130(EU)_1.0_1.8.8.json index 98714cfde..f15e3602d 100644 --- a/tests/fixtures/KL130(EU)_1.0_1.8.8.json +++ b/tests/fixtures/KL130(EU)_1.0_1.8.8.json @@ -1,14 +1,79 @@ { + "smartlife.iot.common.cloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": 0, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "#MASKED_NAME#" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [ + { + "fwLocation": 0, + "fwReleaseDate": "2019-11-27", + "fwReleaseLog": "New Features/Enhancements:\n1. Added the offset feature when scheduling sunset/sunrise.\n2. Improved the overall performance of schedule feature.", + "fwReleaseLogUrl": "undefined yet", + "fwTitle": "Hi, a new firmware with bug fixes is available for your product.", + "fwType": 0, + "fwUrl": "http://download.tplinkcloud.com/firmware/smartBulb_FCC_1.8.11_Build_191113_Rel.105336__1574839035801.bin", + "fwVer": "1.8.11 Build 191113 Rel.105336" + } + ] + } + }, "smartlife.iot.common.emeter": { "get_realtime": { "err_code": 0, - "power_mw": 2500 + "power_mw": 10800 + } + }, + "smartlife.iot.common.schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [] } }, "smartlife.iot.smartbulb.lightingservice": { + "get_default_behavior": { + "err_code": 0, + "hard_on": { + "mode": "circadian" + }, + "soft_on": { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "index": 0, + "mode": "customize_preset", + "saturation": 0 + } + }, + "get_light_details": { + "color_rendering_index": 80, + "err_code": 0, + "incandescent_equivalent": 60, + "lamp_beam_angle": 150, + "max_lumens": 800, + "max_voltage": 120, + "min_voltage": 110, + "wattage": 10 + }, "get_light_state": { - "brightness": 17, - "color_temp": 2500, + "brightness": 100, + "color_temp": 2700, "err_code": 0, "hue": 0, "mode": "normal", @@ -29,7 +94,7 @@ "deviceId": "0000000000000000000000000000000000000000", "disco_ver": "1.0", "err_code": 0, - "heapsize": 334708, + "heapsize": 308144, "hwId": "00000000000000000000000000000000", "hw_ver": "1.0", "is_color": 1, @@ -37,8 +102,8 @@ "is_factory": false, "is_variable_color_temp": 1, "light_state": { - "brightness": 17, - "color_temp": 2500, + "brightness": 100, + "color_temp": 2700, "hue": 0, "mode": "normal", "on_off": 1, @@ -51,17 +116,17 @@ "preferred_state": [ { "brightness": 50, - "color_temp": 2500, + "color_temp": 2700, "hue": 0, "index": 0, "saturation": 0 }, { - "brightness": 100, + "brightness": 20, "color_temp": 0, - "hue": 299, + "hue": 0, "index": 1, - "saturation": 95 + "saturation": 75 }, { "brightness": 100, From df48c21900f83b6469394d7b281190955edaad57 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 20 Nov 2024 13:21:08 +0000 Subject: [PATCH 693/892] Migrate triggerlogs to mashumaru (#1277) --- kasa/smart/modules/triggerlogs.py | 15 +++++++++------ tests/smart/modules/test_triggerlogs.py | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 tests/smart/modules/test_triggerlogs.py diff --git a/kasa/smart/modules/triggerlogs.py b/kasa/smart/modules/triggerlogs.py index 480c72f5e..be63ff698 100644 --- a/kasa/smart/modules/triggerlogs.py +++ b/kasa/smart/modules/triggerlogs.py @@ -2,19 +2,22 @@ from __future__ import annotations -from datetime import datetime +from dataclasses import dataclass +from typing import Annotated -from pydantic.v1 import BaseModel, Field, parse_obj_as +from mashumaro import DataClassDictMixin +from mashumaro.types import Alias from ..smartmodule import SmartModule -class LogEntry(BaseModel): +@dataclass +class LogEntry(DataClassDictMixin): """Presentation of a single log entry.""" id: int - event_id: str = Field(alias="eventId") - timestamp: datetime + event_id: Annotated[str, Alias("eventId")] + timestamp: int event: str @@ -31,4 +34,4 @@ def query(self) -> dict: @property def logs(self) -> list[LogEntry]: """Return logs.""" - return parse_obj_as(list[LogEntry], self.data["logs"]) + return [LogEntry.from_dict(log) for log in self.data["logs"]] diff --git a/tests/smart/modules/test_triggerlogs.py b/tests/smart/modules/test_triggerlogs.py new file mode 100644 index 000000000..c1d957217 --- /dev/null +++ b/tests/smart/modules/test_triggerlogs.py @@ -0,0 +1,22 @@ +from kasa import Device, Module + +from ...device_fixtures import parametrize + +triggerlogs = parametrize( + "has trigger_logs", + component_filter="trigger_log", + protocol_filter={"SMART", "SMART.CHILD"}, +) + + +@triggerlogs +async def test_trigger_logs(dev: Device): + """Test that features are registered and work as expected.""" + triggerlogs = dev.modules.get(Module.TriggerLogs) + assert triggerlogs is not None + if logs := triggerlogs.logs: + first = logs[0] + assert isinstance(first.id, int) + assert isinstance(first.timestamp, int) + assert isinstance(first.event, str) + assert isinstance(first.event_id, str) From 5eca487bcb8663313f8c56838a168e892fbfab92 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 20 Nov 2024 13:34:26 +0000 Subject: [PATCH 694/892] Migrate iot cloud module to mashumaro (#1282) Breaking change as the CloudInfo interface is changing to snake case for consistency with the rest of the library. --- kasa/iot/modules/cloud.py | 29 +++++++++++++++++------------ tests/fakeprotocol_iot.py | 1 + tests/iot/modules/test_cloud.py | 13 +++++++++++++ 3 files changed, 31 insertions(+), 12 deletions(-) create mode 100644 tests/iot/modules/test_cloud.py diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index 10097e646..d4e91a071 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -1,23 +1,28 @@ """Cloud module implementation.""" -from pydantic.v1 import BaseModel +from dataclasses import dataclass +from typing import Annotated + +from mashumaro import DataClassDictMixin +from mashumaro.types import Alias from ...feature import Feature from ..iotmodule import IotModule -class CloudInfo(BaseModel): +@dataclass +class CloudInfo(DataClassDictMixin): """Container for cloud settings.""" - binded: bool - cld_connection: int - fwDlPage: str - fwNotifyType: int - illegalType: int + provisioned: Annotated[int, Alias("binded")] + cloud_connected: Annotated[int, Alias("cld_connection")] + firmware_download_page: Annotated[str, Alias("fwDlPage")] + firmware_notify_type: Annotated[int, Alias("fwNotifyType")] + illegal_type: Annotated[int, Alias("illegalType")] server: str - stopConnect: int - tcspInfo: str - tcspStatus: int + stop_connect: Annotated[int, Alias("stopConnect")] + tcsp_info: Annotated[str, Alias("tcspInfo")] + tcsp_status: Annotated[int, Alias("tcspStatus")] username: str @@ -42,7 +47,7 @@ def _initialize_features(self) -> None: @property def is_connected(self) -> bool: """Return true if device is connected to the cloud.""" - return self.info.binded + return bool(self.info.cloud_connected) def query(self) -> dict: """Request cloud connectivity info.""" @@ -51,7 +56,7 @@ def query(self) -> dict: @property def info(self) -> CloudInfo: """Return information about the cloud connectivity.""" - return CloudInfo.parse_obj(self.data["get_info"]) + return CloudInfo.from_dict(self.data["get_info"]) def get_available_firmwares(self) -> dict: """Return list of available firmwares.""" diff --git a/tests/fakeprotocol_iot.py b/tests/fakeprotocol_iot.py index 8c4e4057c..b4ce4b94f 100644 --- a/tests/fakeprotocol_iot.py +++ b/tests/fakeprotocol_iot.py @@ -125,6 +125,7 @@ def success(res): "username": "", "server": "devs.tplinkcloud.com", "binded": 0, + "err_code": 0, "cld_connection": 0, "illegalType": -1, "stopConnect": -1, diff --git a/tests/iot/modules/test_cloud.py b/tests/iot/modules/test_cloud.py new file mode 100644 index 000000000..ec7f8f834 --- /dev/null +++ b/tests/iot/modules/test_cloud.py @@ -0,0 +1,13 @@ +from kasa import Device, Module + +from ...device_fixtures import device_iot + + +@device_iot +def test_cloud(dev: Device): + cloud = dev.modules.get(Module.IotCloud) + assert cloud + info = cloud.info + assert info + assert isinstance(info.provisioned, int) + assert cloud.is_connected == bool(info.cloud_connected) From 0e5013d4b40f623bd46d3faae855b9a2b7e2b911 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 20 Nov 2024 14:06:59 +0000 Subject: [PATCH 695/892] dump_devinfo: iot light strip commands (#1286) --- devtools/dump_devinfo.py | 3 + tests/fixtures/KL430(US)_2.0_1.0.11.json | 96 ++++++++++++++++++++++-- tests/test_bulb.py | 3 + 3 files changed, 95 insertions(+), 7 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 0f30f96dd..3522ef14c 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -420,6 +420,9 @@ async def get_legacy_fixture( Call( module="smartlife.iot.smartbulb.lightingservice", method="get_light_details" ), + Call(module="smartlife.iot.lightStrip", method="get_default_behavior"), + Call(module="smartlife.iot.lightStrip", method="get_light_state"), + Call(module="smartlife.iot.lightStrip", method="get_light_details"), Call(module="smartlife.iot.LAS", method="get_config"), Call(module="smartlife.iot.LAS", method="get_current_brt"), Call(module="smartlife.iot.PIR", method="get_config"), diff --git a/tests/fixtures/KL430(US)_2.0_1.0.11.json b/tests/fixtures/KL430(US)_2.0_1.0.11.json index cf54d6ebf..f39c55193 100644 --- a/tests/fixtures/KL430(US)_2.0_1.0.11.json +++ b/tests/fixtures/KL430(US)_2.0_1.0.11.json @@ -1,4 +1,34 @@ { + "smartlife.iot.common.cloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "#MASKED_NAME#" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [ + { + "fwLocation": 0, + "fwReleaseDate": "2024-06-28", + "fwReleaseLog": "Modifications and Bug Fixes:\n1. Enhanced device stability.\n2. Fixed the problem that Color Painting doesn't work properly in some cases.\n3. Fixed some minor bugs.", + "fwReleaseLogUrl": "undefined yet", + "fwTitle": "Hi, a new firmware with bug fixes is available for your product.", + "fwType": 1, + "fwUrl": "http://download.tplinkcloud.com/firmware/KLM430v2_FCC_KL430_1.0.12_Build_240227_Rel.160022_2024-02-27_16.01.59_1719559326313.bin", + "fwVer": "1.0.12 Build 240227 Rel.160022" + } + ] + } + }, "smartlife.iot.common.emeter": { "get_realtime": { "err_code": 0, @@ -6,6 +36,58 @@ "total_wh": 0 } }, + "smartlife.iot.common.schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 0, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "smartlife.iot.lightStrip": { + "get_default_behavior": { + "err_code": 0, + "hard_on": { + "mode": "last_status" + }, + "soft_on": { + "mode": "last_status" + } + }, + "get_light_details": { + "color_rendering_index": 80, + "err_code": 0, + "incandescent_equivalent": 60, + "lamp_beam_angle": 180, + "max_lumens": 800, + "max_voltage": 120, + "min_voltage": 100, + "wattage": 10 + }, + "get_light_state": { + "dft_on_state": { + "groups": [ + [ + 0, + 15, + 0, + 0, + 100, + 3842 + ] + ], + "mode": "normal" + }, + "err_code": 0, + "length": 16, + "on_off": 0, + "transition": 500 + } + }, "system": { "get_sysinfo": { "LEF": 1, @@ -31,19 +113,19 @@ "light_state": { "dft_on_state": { "brightness": 100, - "color_temp": 9000, - "hue": 9, + "color_temp": 3842, + "hue": 0, "mode": "normal", - "saturation": 67 + "saturation": 0 }, "on_off": 0 }, "lighting_effect_state": { - "brightness": 70, + "brightness": 100, "custom": 0, "enable": 0, - "id": "joqVjlaTsgzmuQQBAlHRkkPAqkBUiqeb", - "name": "Icicle" + "id": "bCTItKETDFfrKANolgldxfgOakaarARs", + "name": "Flicker" }, "longitude_i": 0, "mic_mac": "E8:48:B8:00:00:00", @@ -51,7 +133,7 @@ "model": "KL430(US)", "oemId": "00000000000000000000000000000000", "preferred_state": [], - "rssi": -43, + "rssi": -35, "status": "new", "sw_ver": "1.0.11 Build 220812 Rel.153345" } diff --git a/tests/test_bulb.py b/tests/test_bulb.py index ac4400731..1589e9199 100644 --- a/tests/test_bulb.py +++ b/tests/test_bulb.py @@ -453,6 +453,8 @@ async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): "mode": str, "on_off": Boolean, "saturation": All(int, Range(min=0, max=100)), + "length": Optional(int), + "transition": Optional(int), "dft_on_state": Optional( { "brightness": All(int, Range(min=0, max=100)), @@ -460,6 +462,7 @@ async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): "hue": All(int, Range(min=0, max=360)), "mode": str, "saturation": All(int, Range(min=0, max=100)), + "groups": Optional(list[int]), } ), "err_code": int, From 955e7ab4d0830a084aae870d72920c02e1e08d03 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 20 Nov 2024 14:35:51 +0000 Subject: [PATCH 696/892] Migrate TurnOnBehaviours to mashumaro (#1285) --- kasa/iot/iotbulb.py | 52 ++++++++++++++++++++------------------- tests/fakeprotocol_iot.py | 21 ++++++++++++++++ tests/test_bulb.py | 6 +++++ 3 files changed, 54 insertions(+), 25 deletions(-) diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 14c711031..cb2e858cd 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -4,10 +4,13 @@ import logging import re +from dataclasses import dataclass from enum import Enum -from typing import cast +from typing import Annotated, cast -from pydantic.v1 import BaseModel, Field, root_validator +from mashumaro import DataClassDictMixin +from mashumaro.config import BaseConfig +from mashumaro.types import Alias from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -35,9 +38,12 @@ class BehaviorMode(str, Enum): Last = "last_status" #: Use chosen preset. Preset = "customize_preset" + #: Circadian + Circadian = "circadian" -class TurnOnBehavior(BaseModel): +@dataclass +class TurnOnBehavior(DataClassDictMixin): """Model to present a single turn on behavior. :param int preset: the index number of wanted preset. @@ -48,34 +54,30 @@ class TurnOnBehavior(BaseModel): to contain either the preset index, or ``None`` for the last known state. """ - #: Index of preset to use, or ``None`` for the last known state. - preset: int | None = Field(alias="index", default=None) - #: Wanted behavior - mode: BehaviorMode - - @root_validator - def _mode_based_on_preset(cls, values: dict) -> dict: - """Set the mode based on the preset value.""" - if values["preset"] is not None: - values["mode"] = BehaviorMode.Preset - else: - values["mode"] = BehaviorMode.Last + class Config(BaseConfig): + """Serialization config.""" - return values + omit_none = True + serialize_by_alias = True - class Config: - """Configuration to make the validator run when changing the values.""" - - validate_assignment = True + #: Wanted behavior + mode: BehaviorMode + #: Index of preset to use, or ``None`` for the last known state. + preset: Annotated[int | None, Alias("index")] = None + brightness: int | None = None + color_temp: int | None = None + hue: int | None = None + saturation: int | None = None -class TurnOnBehaviors(BaseModel): +@dataclass +class TurnOnBehaviors(DataClassDictMixin): """Model to contain turn on behaviors.""" #: The behavior when the bulb is turned on programmatically. - soft: TurnOnBehavior = Field(alias="soft_on") + soft: Annotated[TurnOnBehavior, Alias("soft_on")] #: The behavior when the bulb has been off from mains power. - hard: TurnOnBehavior = Field(alias="hard_on") + hard: Annotated[TurnOnBehavior, Alias("hard_on")] TPLINK_KELVIN = { @@ -303,7 +305,7 @@ async def get_light_details(self) -> dict[str, int]: async def get_turn_on_behavior(self) -> TurnOnBehaviors: """Return the behavior for turning the bulb on.""" - return TurnOnBehaviors.parse_obj( + return TurnOnBehaviors.from_dict( await self._query_helper(self.LIGHT_SERVICE, "get_default_behavior") ) @@ -314,7 +316,7 @@ async def set_turn_on_behavior(self, behavior: TurnOnBehaviors) -> dict: you should use :func:`get_turn_on_behavior` to get the current settings. """ return await self._query_helper( - self.LIGHT_SERVICE, "set_default_behavior", behavior.dict(by_alias=True) + self.LIGHT_SERVICE, "set_default_behavior", behavior.to_dict() ) async def get_light_state(self) -> dict[str, dict]: diff --git a/tests/fakeprotocol_iot.py b/tests/fakeprotocol_iot.py index b4ce4b94f..8a9667d55 100644 --- a/tests/fakeprotocol_iot.py +++ b/tests/fakeprotocol_iot.py @@ -176,6 +176,23 @@ def success(res): } } +LIGHT_DETAILS = { + "color_rendering_index": 80, + "err_code": 0, + "incandescent_equivalent": 60, + "lamp_beam_angle": 150, + "max_lumens": 800, + "max_voltage": 120, + "min_voltage": 110, + "wattage": 10, +} + +DEFAULT_BEHAVIOR = { + "err_code": 0, + "hard_on": {"mode": "circadian"}, + "soft_on": {"mode": "last_status"}, +} + class FakeIotProtocol(IotProtocol): def __init__(self, info, fixture_name=None, *, verbatim=False): @@ -399,6 +416,8 @@ def set_time(self, new_state: dict, *args): }, "smartlife.iot.smartbulb.lightingservice": { "get_light_state": light_state, + "get_light_details": LIGHT_DETAILS, + "get_default_behavior": DEFAULT_BEHAVIOR, "transition_light_state": transition_light_state, "set_preferred_state": set_preferred_state, }, @@ -409,6 +428,8 @@ def set_time(self, new_state: dict, *args): "smartlife.iot.lightStrip": { "set_light_state": transition_light_state, "get_light_state": light_state, + "get_light_details": LIGHT_DETAILS, + "get_default_behavior": DEFAULT_BEHAVIOR, "set_preferred_state": set_preferred_state, }, "smartlife.iot.common.system": { diff --git a/tests/test_bulb.py b/tests/test_bulb.py index 1589e9199..4a547522f 100644 --- a/tests/test_bulb.py +++ b/tests/test_bulb.py @@ -497,3 +497,9 @@ async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): @bulb def test_device_type_bulb(dev: Device): assert dev.device_type in {DeviceType.Bulb, DeviceType.LightStrip} + + +@bulb_iot +async def test_turn_on_behaviours(dev: IotBulb): + behavior = await dev.get_turn_on_behavior() + assert behavior From a4258cc75b05761fdb3201b47f4aa2f0d158eb37 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 20 Nov 2024 14:42:56 +0000 Subject: [PATCH 697/892] Do not print out all the fixture names at the start of test runs (#1287) --- tests/fixtureinfo.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/fixtureinfo.py b/tests/fixtureinfo.py index 5364e0187..d16c09f2d 100644 --- a/tests/fixtureinfo.py +++ b/tests/fixtureinfo.py @@ -207,10 +207,6 @@ def _device_type_match(fixture_data: FixtureInfo, device_type): filtered.append(fixture_data) - if desc: - print(f"# {desc}") - for value in filtered: - print(f"\t{value.name}") filtered.sort() return filtered From f7778aaa53c537f774acaeb52d8cb0f8b2e09ddf Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 20 Nov 2024 14:59:32 +0000 Subject: [PATCH 698/892] Migrate RuleModule to mashumaro (#1283) Also fixes a bug whereby multiple queries for the same module would overwrite each other. --- kasa/iot/iotdevice.py | 10 +++++++++- kasa/iot/modules/rulemodule.py | 26 ++++++++++++++------------ tests/fakeprotocol_iot.py | 30 ++++++++++++++++++++++++++++++ tests/iot/modules/test_schedule.py | 17 +++++++++++++++++ 4 files changed, 70 insertions(+), 13 deletions(-) create mode 100644 tests/iot/modules/test_schedule.py diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 89b7219f4..f23ebc8bd 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -412,7 +412,15 @@ async def _modular_update(self, req: dict) -> None: # every other update will query for them update: dict = self._last_update.copy() if self._last_update else {} for response in responses: - update = {**update, **response} + for k, v in response.items(): + # The same module could have results in different responses + # i.e. smartlife.iot.common.schedule for Usage and + # Schedule, so need to call update(**v) here. If a module is + # not supported the response + # {'err_code': -1, 'err_msg': 'module not support'} + # become top level key/values of the response so check for dict + if isinstance(v, dict): + update.setdefault(k, {}).update(**v) self._last_update = update # IOT modules are added as default but could be unsupported post first update diff --git a/kasa/iot/modules/rulemodule.py b/kasa/iot/modules/rulemodule.py index fbe3f261e..ba08b366b 100644 --- a/kasa/iot/modules/rulemodule.py +++ b/kasa/iot/modules/rulemodule.py @@ -3,9 +3,10 @@ from __future__ import annotations import logging +from dataclasses import dataclass from enum import Enum -from pydantic.v1 import BaseModel +from mashumaro import DataClassDictMixin from ..iotmodule import IotModule, merge @@ -28,26 +29,27 @@ class TimeOption(Enum): AtSunset = 2 -class Rule(BaseModel): +@dataclass +class Rule(DataClassDictMixin): """Representation of a rule.""" id: str name: str - enable: bool + enable: int wday: list[int] - repeat: bool + repeat: int # start action - sact: Action | None - stime_opt: TimeOption - smin: int + sact: Action | None = None + stime_opt: TimeOption | None = None + smin: int | None = None - eact: Action | None - etime_opt: TimeOption - emin: int + eact: Action | None = None + etime_opt: TimeOption | None = None + emin: int | None = None # Only on bulbs - s_light: dict | None + s_light: dict | None = None _LOGGER = logging.getLogger(__name__) @@ -66,7 +68,7 @@ def rules(self) -> list[Rule]: """Return the list of rules for the service.""" try: return [ - Rule.parse_obj(rule) for rule in self.data["get_rules"]["rule_list"] + Rule.from_dict(rule) for rule in self.data["get_rules"]["rule_list"] ] except Exception as ex: _LOGGER.error("Unable to read rule list: %s (data: %s)", ex, self.data) diff --git a/tests/fakeprotocol_iot.py b/tests/fakeprotocol_iot.py index 8a9667d55..88e34647a 100644 --- a/tests/fakeprotocol_iot.py +++ b/tests/fakeprotocol_iot.py @@ -136,6 +136,34 @@ def success(res): } } +SCHEDULE_MODULE = { + "get_next_action": { + "action": 1, + "err_code": 0, + "id": "0794F4729DB271627D1CF35A9A854030", + "schd_time": 68927, + "type": 2, + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [ + { + "eact": -1, + "enable": 1, + "id": "8AA75A50A8440B17941D192BD9E01FFA", + "name": "name", + "repeat": 1, + "sact": 1, + "smin": 1027, + "soffset": 0, + "stime_opt": 2, + "wday": [1, 1, 1, 1, 1, 1, 1], + }, + ], + "version": 2, + }, +} AMBIENT_MODULE = { "get_current_brt": {"value": 26, "err_code": 0}, @@ -450,6 +478,8 @@ def set_time(self, new_state: dict, *args): "smartlife.iot.PIR": MOTION_MODULE, "cnCloud": CLOUD_MODULE, "smartlife.iot.common.cloud": CLOUD_MODULE, + "schedule": SCHEDULE_MODULE, + "smartlife.iot.common.schedule": SCHEDULE_MODULE, } async def send(self, request, port=9999): diff --git a/tests/iot/modules/test_schedule.py b/tests/iot/modules/test_schedule.py new file mode 100644 index 000000000..152aaac85 --- /dev/null +++ b/tests/iot/modules/test_schedule.py @@ -0,0 +1,17 @@ +import pytest + +from kasa import Device, Module +from kasa.iot.modules.rulemodule import Action, TimeOption + +from ...device_fixtures import device_iot + + +@device_iot +def test_schedule(dev: Device, caplog: pytest.LogCaptureFixture): + schedule = dev.modules.get(Module.IotSchedule) + assert schedule + if rules := schedule.rules: + first = rules[0] + assert isinstance(first.sact, Action) + assert isinstance(first.stime_opt, TimeOption) + assert "Unable to read rule list" not in caplog.text From 0058ad9f2e8d62949717c7624b6c641949660c7c Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 20 Nov 2024 15:19:12 +0000 Subject: [PATCH 699/892] Remove pydantic dependency (#1289) Remove pydantic dependency in favor of mashumaro. --- kasa/cli/discover.py | 3 +- pyproject.toml | 1 - uv.lock | 72 -------------------------------------------- 3 files changed, 1 insertion(+), 75 deletions(-) diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index 82b09db53..e472edae7 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -6,7 +6,6 @@ from pprint import pformat as pf import asyncclick as click -from pydantic.v1 import ValidationError from kasa import ( AuthenticationError, @@ -208,7 +207,7 @@ def _echo_discovery_info(discovery_info) -> None: try: dr = DiscoveryResult.from_dict(discovery_info) - except ValidationError: + except Exception: _echo_dictionary(discovery_info) return diff --git a/pyproject.toml b/pyproject.toml index e43c9ecf6..9fdc888d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,6 @@ readme = "README.md" requires-python = ">=3.11,<4.0" dependencies = [ "asyncclick>=8.1.7", - "pydantic>=1.10.15", "cryptography>=1.9", "aiohttp>=3", "tzdata>=2024.2 ; platform_system == 'Windows'", diff --git a/uv.lock b/uv.lock index bbef66362..e84a5c356 100644 --- a/uv.lock +++ b/uv.lock @@ -96,15 +96,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511 }, ] -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, -] - [[package]] name = "anyio" version = "4.6.2.post1" @@ -953,67 +944,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, ] -[[package]] -name = "pydantic" -version = "2.9.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/b7/d9e3f12af310e1120c21603644a1cd86f59060e040ec5c3a80b8f05fae30/pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f", size = 769917 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/e4/ba44652d562cbf0bf320e0f3810206149c8a4e99cdbf66da82e97ab53a15/pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12", size = 434928 }, -] - -[[package]] -name = "pydantic-core" -version = "2.23.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e2/aa/6b6a9b9f8537b872f552ddd46dd3da230367754b6f707b8e1e963f515ea3/pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863", size = 402156 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/30/890a583cd3f2be27ecf32b479d5d615710bb926d92da03e3f7838ff3e58b/pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8", size = 1865160 }, - { url = "https://files.pythonhosted.org/packages/1d/9a/b634442e1253bc6889c87afe8bb59447f106ee042140bd57680b3b113ec7/pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d", size = 1776777 }, - { url = "https://files.pythonhosted.org/packages/75/9a/7816295124a6b08c24c96f9ce73085032d8bcbaf7e5a781cd41aa910c891/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e", size = 1799244 }, - { url = "https://files.pythonhosted.org/packages/a9/8f/89c1405176903e567c5f99ec53387449e62f1121894aa9fc2c4fdc51a59b/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607", size = 1805307 }, - { url = "https://files.pythonhosted.org/packages/d5/a5/1a194447d0da1ef492e3470680c66048fef56fc1f1a25cafbea4bc1d1c48/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd", size = 2000663 }, - { url = "https://files.pythonhosted.org/packages/13/a5/1df8541651de4455e7d587cf556201b4f7997191e110bca3b589218745a5/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea", size = 2655941 }, - { url = "https://files.pythonhosted.org/packages/44/31/a3899b5ce02c4316865e390107f145089876dff7e1dfc770a231d836aed8/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e", size = 2052105 }, - { url = "https://files.pythonhosted.org/packages/1b/aa/98e190f8745d5ec831f6d5449344c48c0627ac5fed4e5340a44b74878f8e/pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b", size = 1919967 }, - { url = "https://files.pythonhosted.org/packages/ae/35/b6e00b6abb2acfee3e8f85558c02a0822e9a8b2f2d812ea8b9079b118ba0/pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0", size = 1964291 }, - { url = "https://files.pythonhosted.org/packages/13/46/7bee6d32b69191cd649bbbd2361af79c472d72cb29bb2024f0b6e350ba06/pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64", size = 2109666 }, - { url = "https://files.pythonhosted.org/packages/39/ef/7b34f1b122a81b68ed0a7d0e564da9ccdc9a2924c8d6c6b5b11fa3a56970/pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f", size = 1732940 }, - { url = "https://files.pythonhosted.org/packages/2f/76/37b7e76c645843ff46c1d73e046207311ef298d3f7b2f7d8f6ac60113071/pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3", size = 1916804 }, - { url = "https://files.pythonhosted.org/packages/74/7b/8e315f80666194b354966ec84b7d567da77ad927ed6323db4006cf915f3f/pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231", size = 1856459 }, - { url = "https://files.pythonhosted.org/packages/14/de/866bdce10ed808323d437612aca1ec9971b981e1c52e5e42ad9b8e17a6f6/pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee", size = 1770007 }, - { url = "https://files.pythonhosted.org/packages/dc/69/8edd5c3cd48bb833a3f7ef9b81d7666ccddd3c9a635225214e044b6e8281/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87", size = 1790245 }, - { url = "https://files.pythonhosted.org/packages/80/33/9c24334e3af796ce80d2274940aae38dd4e5676298b4398eff103a79e02d/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8", size = 1801260 }, - { url = "https://files.pythonhosted.org/packages/a5/6f/e9567fd90104b79b101ca9d120219644d3314962caa7948dd8b965e9f83e/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327", size = 1996872 }, - { url = "https://files.pythonhosted.org/packages/2d/ad/b5f0fe9e6cfee915dd144edbd10b6e9c9c9c9d7a56b69256d124b8ac682e/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2", size = 2661617 }, - { url = "https://files.pythonhosted.org/packages/06/c8/7d4b708f8d05a5cbfda3243aad468052c6e99de7d0937c9146c24d9f12e9/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36", size = 2071831 }, - { url = "https://files.pythonhosted.org/packages/89/4d/3079d00c47f22c9a9a8220db088b309ad6e600a73d7a69473e3a8e5e3ea3/pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126", size = 1917453 }, - { url = "https://files.pythonhosted.org/packages/e9/88/9df5b7ce880a4703fcc2d76c8c2d8eb9f861f79d0c56f4b8f5f2607ccec8/pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e", size = 1968793 }, - { url = "https://files.pythonhosted.org/packages/e3/b9/41f7efe80f6ce2ed3ee3c2dcfe10ab7adc1172f778cc9659509a79518c43/pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24", size = 2116872 }, - { url = "https://files.pythonhosted.org/packages/63/08/b59b7a92e03dd25554b0436554bf23e7c29abae7cce4b1c459cd92746811/pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84", size = 1738535 }, - { url = "https://files.pythonhosted.org/packages/88/8d/479293e4d39ab409747926eec4329de5b7129beaedc3786eca070605d07f/pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9", size = 1917992 }, - { url = "https://files.pythonhosted.org/packages/ad/ef/16ee2df472bf0e419b6bc68c05bf0145c49247a1095e85cee1463c6a44a1/pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc", size = 1856143 }, - { url = "https://files.pythonhosted.org/packages/da/fa/bc3dbb83605669a34a93308e297ab22be82dfb9dcf88c6cf4b4f264e0a42/pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd", size = 1770063 }, - { url = "https://files.pythonhosted.org/packages/4e/48/e813f3bbd257a712303ebdf55c8dc46f9589ec74b384c9f652597df3288d/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05", size = 1790013 }, - { url = "https://files.pythonhosted.org/packages/b4/e0/56eda3a37929a1d297fcab1966db8c339023bcca0b64c5a84896db3fcc5c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d", size = 1801077 }, - { url = "https://files.pythonhosted.org/packages/04/be/5e49376769bfbf82486da6c5c1683b891809365c20d7c7e52792ce4c71f3/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510", size = 1996782 }, - { url = "https://files.pythonhosted.org/packages/bc/24/e3ee6c04f1d58cc15f37bcc62f32c7478ff55142b7b3e6d42ea374ea427c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6", size = 2661375 }, - { url = "https://files.pythonhosted.org/packages/c1/f8/11a9006de4e89d016b8de74ebb1db727dc100608bb1e6bbe9d56a3cbbcce/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b", size = 2071635 }, - { url = "https://files.pythonhosted.org/packages/7c/45/bdce5779b59f468bdf262a5bc9eecbae87f271c51aef628d8c073b4b4b4c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327", size = 1916994 }, - { url = "https://files.pythonhosted.org/packages/d8/fa/c648308fe711ee1f88192cad6026ab4f925396d1293e8356de7e55be89b5/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6", size = 1968877 }, - { url = "https://files.pythonhosted.org/packages/16/16/b805c74b35607d24d37103007f899abc4880923b04929547ae68d478b7f4/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f", size = 2116814 }, - { url = "https://files.pythonhosted.org/packages/d1/58/5305e723d9fcdf1c5a655e6a4cc2a07128bf644ff4b1d98daf7a9dbf57da/pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769", size = 1738360 }, - { url = "https://files.pythonhosted.org/packages/a5/ae/e14b0ff8b3f48e02394d8acd911376b7b66e164535687ef7dc24ea03072f/pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", size = 1919411 }, -] - [[package]] name = "pygments" version = "2.18.0" @@ -1160,7 +1090,6 @@ dependencies = [ { name = "asyncclick" }, { name = "cryptography" }, { name = "mashumaro" }, - { name = "pydantic" }, { name = "tzdata", marker = "platform_system == 'Windows'" }, ] @@ -1213,7 +1142,6 @@ requires-dist = [ { name = "myst-parser", marker = "extra == 'docs'" }, { name = "orjson", marker = "extra == 'speedups'", specifier = ">=3.9.1" }, { name = "ptpython", marker = "extra == 'shell'" }, - { name = "pydantic", specifier = ">=1.10.15" }, { name = "rich", marker = "extra == 'shell'" }, { name = "sphinx", marker = "extra == 'docs'", specifier = "~=6.2" }, { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0" }, From 59b047f485d73c5d529c0d3128e8aff24de8ac41 Mon Sep 17 00:00:00 2001 From: Ryan Nitcher Date: Wed, 20 Nov 2024 10:59:09 -0700 Subject: [PATCH 700/892] Add SMART Voltage Monitoring to Fixtures (#1290) --- devtools/helpers/smartrequests.py | 2 + .../fixtures/smart/KP125M(US)_1.0_1.2.3.json | 332 ++++++++++-------- 2 files changed, 191 insertions(+), 143 deletions(-) diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 4ad7407d2..18ae00e2b 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -262,6 +262,8 @@ def energy_monitoring_list() -> list[SmartRequest]: """Get energy usage.""" return [ SmartRequest("get_energy_usage"), + SmartRequest("get_emeter_data"), + SmartRequest("get_emeter_vgain_igain"), SmartRequest.get_raw_request("get_electricity_price_config"), ] diff --git a/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json b/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json index 7605af0f2..710febeb2 100644 --- a/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json +++ b/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json @@ -94,7 +94,7 @@ "factory_default": false, "ip": "127.0.0.123", "is_support_iot_cloud": true, - "mac": "48-22-54-00-00-00", + "mac": "78-8C-B5-00-00-00", "mgt_encrypt_schm": { "encrypt_type": "KLAP", "http_port": 80, @@ -127,17 +127,15 @@ "rule_list": [] }, "get_current_power": { - "current_power": 69 + "current_power": 1 }, "get_device_info": { "auto_off_remain_time": 0, "auto_off_status": "off", - "avatar": "coffee_maker", + "avatar": "egg_boiler", "default_states": { - "state": { - "on": true - }, - "type": "custom" + "state": {}, + "type": "last_states" }, "device_id": "0000000000000000000000000000000000000000", "device_on": true, @@ -150,17 +148,17 @@ "lang": "en_US", "latitude": 0, "longitude": 0, - "mac": "48-22-54-00-00-00", + "mac": "78-8C-B5-00-00-00", "model": "KP125M", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", - "on_time": 8818511, + "on_time": 936394, "overcurrent_status": "normal", "overheat_status": "normal", "power_protection_status": "normal", "region": "America/Denver", - "rssi": -40, - "signal_level": 3, + "rssi": -50, + "signal_level": 2, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", "time_diff": -420, @@ -169,169 +167,179 @@ "get_device_time": { "region": "America/Denver", "time_diff": -420, - "timestamp": 1730957712 + "timestamp": 1732069933 }, "get_device_usage": { "power_usage": { - "past30": 46786, - "past7": 10896, - "today": 1507 + "past30": 971, + "past7": 442, + "today": 20 }, "saved_power": { - "past30": 0, - "past7": 0, - "today": 0 + "past30": 14896, + "past7": 9370, + "today": 1152 }, "time_usage": { - "past30": 43175, - "past7": 10055, - "today": 1355 + "past30": 15867, + "past7": 9812, + "today": 1172 } }, "get_electricity_price_config": { "constant_price": 0, "time_of_use_config": { "summer": { - "midpeak": 120, - "offpeak": 60, - "onpeak": 170, + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, "period": [ - 5, - 1, - 10, - 31 - ], - "weekday_config": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, 0, 0, 0, + 0 + ], + "weekday_config": [ 1, 1, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 ], "weekend_config": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 + 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": 90, - "offpeak": 60, - "onpeak": 110, + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, "period": [ - 11, - 1, - 4, - 30 - ], - "weekday_config": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, 0, 0, 0, + 0 + ], + "weekday_config": [ 1, 1, - 2, - 2, - 2, - 2, - 0, - 0, - 0, - 0, - 0 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 ], "weekend_config": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 + 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": "time_of_use" + "type": "constant" + }, + "get_emeter_data": { + "current_ma": 33, + "energy_wh": 971, + "power_mw": 1003, + "voltage_mv": 121215 + }, + "get_emeter_vgain_igain": { + "igain": 10861, + "vgain": 118657 }, "get_energy_usage": { - "current_power": 69737, + "current_power": 1003, "electricity_charge": [ - 2901, - 3351, - 536 + 0, + 0, + 0 ], - "local_time": "2024-11-06 22:35:14", - "month_energy": 9332, - "month_runtime": 8615, - "today_energy": 1507, - "today_runtime": 1355 + "local_time": "2024-11-19 19:32:14", + "month_energy": 971, + "month_runtime": 15867, + "today_energy": 20, + "today_runtime": 1172 }, "get_fw_download_state": { "auto_upgrade": false, @@ -358,19 +366,17 @@ "led_rule": "always", "led_status": true, "night_mode": { - "end_time": 396, - "night_mode_type": "sunrise_sunset", - "start_time": 1013, - "sunrise_offset": 0, - "sunset_offset": 0 + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 } }, "get_matter_setup_info": { "setup_code": "00000000000", - "setup_payload": "00:000000-000000000000" + "setup_payload": "00:000000000000-000000" }, "get_max_power": { - "max_power": 1537 + "max_power": 1542 }, "get_next_event": {}, "get_protection_power": { @@ -434,6 +440,46 @@ "signal_level": 1, "ssid": "I01BU0tFRF9TU0lEIw==" }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, { "bssid": "000000000000", "channel": 0, @@ -444,7 +490,7 @@ } ], "start_index": 0, - "sum": 7, + "sum": 12, "wep_supported": false }, "qs_component_nego": { From dab64e5d48b72f733b4e70b6893c2e312a85871a Mon Sep 17 00:00:00 2001 From: Ryan Nitcher Date: Wed, 20 Nov 2024 11:18:30 -0700 Subject: [PATCH 701/892] Add voltage and current monitoring to smart Devices (#1281) --- kasa/smart/modules/energy.py | 23 +++++++++++++++++++---- tests/fakeprotocol_smart.py | 13 +++++++++++++ tests/test_emeter.py | 5 ++++- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/kasa/smart/modules/energy.py b/kasa/smart/modules/energy.py index 16a4890ec..611e88857 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -15,6 +15,12 @@ class Energy(SmartModule, EnergyInterface): REQUIRED_COMPONENT = "energy_monitoring" + async def _post_update_hook(self) -> None: + if "voltage_mv" in self.data.get("get_emeter_data", {}): + self._supported = ( + self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT + ) + def query(self) -> dict: """Query to execute during the update cycle.""" req = { @@ -22,13 +28,17 @@ def query(self) -> dict: } if self.supported_version > 1: req["get_current_power"] = None + req["get_emeter_data"] = None + req["get_emeter_vgain_igain"] = None return req @property @raise_if_update_error def current_consumption(self) -> float | None: """Current power in watts.""" - if (power := self.energy.get("current_power")) is not None: + if (power := self.energy.get("current_power")) is not None or ( + power := self.data.get("get_emeter_data", {}).get("power_mw") + ) is not None: return power / 1_000 # Fallback if get_energy_usage does not provide current_power, # which can happen on some newer devices (e.g. P304M). @@ -58,7 +68,10 @@ def _get_status_from_energy(self, energy: dict) -> EmeterStatus: @raise_if_update_error def status(self) -> EmeterStatus: """Get the emeter status.""" - return self._get_status_from_energy(self.energy) + if "get_emeter_data" in self.data: + return EmeterStatus(self.data["get_emeter_data"]) + else: + return self._get_status_from_energy(self.energy) async def get_status(self) -> EmeterStatus: """Return real-time statistics.""" @@ -87,13 +100,15 @@ def consumption_total(self) -> float | None: @raise_if_update_error def current(self) -> float | None: """Return the current in A.""" - return None + ma = self.data.get("get_emeter_data", {}).get("current_ma") + return ma / 1000 if ma else None @property @raise_if_update_error def voltage(self) -> float | None: """Get the current voltage in V.""" - return None + mv = self.data.get("get_emeter_data", {}).get("voltage_mv") + return mv / 1000 if mv else None async def _deprecated_get_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index 99b75a1ac..2f4e6ec2f 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -138,6 +138,19 @@ def credentials_hash(self): ), "get_device_usage": ("device", {}), "get_connect_cloud_state": ("cloud_connect", {"status": 0}), + "get_emeter_data": ( + "energy_monitoring", + { + "current_ma": 33, + "energy_wh": 971, + "power_mw": 1003, + "voltage_mv": 121215, + }, + ), + "get_emeter_vgain_igain": ( + "energy_monitoring", + {"igain": 10861, "vgain": 118657}, + ), } async def send(self, request: str): diff --git a/tests/test_emeter.py b/tests/test_emeter.py index 1c4f51adc..ad5ab190a 100644 --- a/tests/test_emeter.py +++ b/tests/test_emeter.py @@ -211,5 +211,8 @@ async def test_supported(dev: Device): assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is True else: assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is False - assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is False + if energy_module.supported_version < 2: + assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False + else: + assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is True From 879aca77d1cbd5a2976a33d5d50daddebdd3cde8 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 21 Nov 2024 18:10:18 +0000 Subject: [PATCH 702/892] Update cli modify presets to support smart devices (#1295) --- kasa/cli/light.py | 50 ++++++++++++++++++++++++++++++----------------- tests/test_cli.py | 46 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/kasa/cli/light.py b/kasa/cli/light.py index 6b342c3da..f142478c3 100644 --- a/kasa/cli/light.py +++ b/kasa/cli/light.py @@ -127,13 +127,15 @@ async def presets(ctx, dev): def presets_list(dev: Device): """List presets.""" if not (light_preset := dev.modules.get(Module.LightPreset)): - error("Presets not supported on device") + error("Device does not support light presets") return for idx, preset in enumerate(light_preset.preset_states_list): echo( - f"[{idx}] Hue: {preset.hue:3} Saturation: {preset.saturation:3} " - f"Brightness/Value: {preset.brightness:3} Temp: {preset.color_temp:4}" + f"[{idx}] Hue: {preset.hue or '':3} " + f"Saturation: {preset.saturation or '':3} " + f"Brightness/Value: {preset.brightness or '':3} " + f"Temp: {preset.color_temp or '':4}" ) return light_preset.preset_states_list @@ -141,32 +143,44 @@ def presets_list(dev: Device): @presets.command(name="modify") @click.argument("index", type=int) -@click.option("--brightness", type=int) -@click.option("--hue", type=int) -@click.option("--saturation", type=int) -@click.option("--temperature", type=int) +@click.option("--brightness", type=int, required=False, default=None) +@click.option("--hue", type=int, required=False, default=None) +@click.option("--saturation", type=int, required=False, default=None) +@click.option("--temperature", type=int, required=False, default=None) @pass_dev_or_child async def presets_modify(dev: Device, index, brightness, hue, saturation, temperature): """Modify a preset.""" - for preset in dev.presets: - if preset.index == index: - break - else: - error(f"No preset found for index {index}") + if not (light_preset := dev.modules.get(Module.LightPreset)): + error("Device does not support light presets") + return + + max_index = len(light_preset.preset_states_list) - 1 + if index > len(light_preset.preset_states_list) - 1: + error(f"Invalid index, must be between 0 and {max_index}") return - if brightness is not None: + if all([val is None for val in {brightness, hue, saturation, temperature}]): + error("Need to supply at least one option to modify.") + return + + # Preset names have `Not set`` as the first value + preset_name = light_preset.preset_list[index + 1] + preset = light_preset.preset_states_list[index] + + echo(f"Preset {preset_name} currently: {preset}") + + if brightness is not None and preset.brightness is not None: preset.brightness = brightness - if hue is not None: + if hue is not None and preset.hue is not None: preset.hue = hue - if saturation is not None: + if saturation is not None and preset.saturation is not None: preset.saturation = saturation - if temperature is not None: + if temperature is not None and preset.temperature is not None: preset.color_temp = temperature - echo(f"Going to save preset: {preset}") + echo(f"Updating preset {preset_name} to: {preset}") - return await dev.save_preset(preset) + return await light_preset.save_preset(preset_name, preset) @light.command() diff --git a/tests/test_cli.py b/tests/test_cli.py index 65710dcff..a31a9fa6b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -34,6 +34,8 @@ brightness, effect, hsv, + presets, + presets_modify, temperature, ) from kasa.cli.main import TYPES, _legacy_type_to_class, cli, cmd_command, raw_command @@ -575,6 +577,50 @@ async def test_light_effect(dev: Device, runner: CliRunner): assert res.exit_code == 2 +async def test_light_preset(dev: Device, runner: CliRunner): + res = await runner.invoke(presets, obj=dev) + if not (light_preset := dev.modules.get(Module.LightPreset)): + assert "Device does not support light presets" in res.output + return + + if len(light_preset.preset_states_list) == 0: + pytest.skip( + "Some fixtures do not have presets and" + " the api doesn'tsupport creating them" + ) + # Start off with a known state + first_name = light_preset.preset_list[1] + await light_preset.set_preset(first_name) + await dev.update() + assert light_preset.preset == first_name + + res = await runner.invoke(presets, obj=dev) + assert "Brightness" in res.output + assert res.exit_code == 0 + + res = await runner.invoke( + presets_modify, + [ + "0", + "--brightness", + "12", + ], + obj=dev, + ) + await dev.update() + assert light_preset.preset_states_list[0].brightness == 12 + + res = await runner.invoke( + presets_modify, + [ + "0", + ], + obj=dev, + ) + await dev.update() + assert "Need to supply at least one option to modify." in res.output + + async def test_led(dev: Device, runner: CliRunner): res = await runner.invoke(led, obj=dev) if not (led_module := dev.modules.get(Module.Led)): From 5221fc07ca26153584bb88d59ef46a567225e1e3 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 21 Nov 2024 18:18:04 +0000 Subject: [PATCH 703/892] Simplify omit http_client in DeviceConfig serialization (#1292) Related explanation: https://github.com/Fatal1ty/mashumaro/issues/264 --- kasa/deviceconfig.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 56e97f5ec..1156cf257 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -33,12 +33,11 @@ import logging from dataclasses import dataclass, field, replace from enum import Enum -from typing import TYPE_CHECKING, Any, Self, TypedDict +from typing import TYPE_CHECKING, TypedDict from aiohttp import ClientSession -from mashumaro import field_options +from mashumaro import field_options, pass_through from mashumaro.config import BaseConfig -from mashumaro.types import SerializationStrategy from .credentials import Credentials from .exceptions import KasaException @@ -122,14 +121,6 @@ def from_values( ) from ex -class _DoNotSerialize(SerializationStrategy): - def serialize(self, value: Any) -> None: - return None # pragma: no cover - - def deserialize(self, value: Any) -> None: - return None # pragma: no cover - - @dataclass class DeviceConfig(_DeviceConfigBaseMixin): """Class to represent paramaters that determine how to connect to devices.""" @@ -163,7 +154,7 @@ class DeviceConfig(_DeviceConfigBaseMixin): http_client: ClientSession | None = field( default=None, compare=False, - metadata=field_options(serialization_strategy=_DoNotSerialize()), + metadata=field_options(serialize="omit", deserialize=pass_through), ) aes_keys: KeyPairDict | None = None @@ -174,9 +165,6 @@ def __post_init__(self) -> None: DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor ) - def __pre_serialize__(self) -> Self: - return replace(self, http_client=None) - def to_dict_control_credentials( self, *, From f2ba23301a7bc3005e6acc04feda10e54497333b Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Thu, 21 Nov 2024 19:22:54 +0100 Subject: [PATCH 704/892] Make discovery on unsupported devices less noisy (#1291) --- kasa/device_factory.py | 2 +- kasa/discover.py | 11 +++++++++-- tests/discovery_fixtures.py | 24 +++++++++++++++++++++--- tests/test_device_factory.py | 2 +- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index dab867997..f0b90b6ef 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -164,7 +164,7 @@ def get_device_class_from_family( and device_type.startswith("SMART.") and not require_exact ): - _LOGGER.warning("Unknown SMART device with %s, using SmartDevice", device_type) + _LOGGER.debug("Unknown SMART device with %s, using SmartDevice", device_type) cls = SmartDevice return cls diff --git a/kasa/discover.py b/kasa/discover.py index 74b663e8d..75651b7ff 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -715,6 +715,7 @@ def _get_device_instance( raise KasaException( f"Unable to read response from device: {config.host}: {ex}" ) from ex + try: discovery_result = DiscoveryResult.from_dict(info["result"]) except Exception as ex: @@ -733,6 +734,7 @@ def _get_device_instance( f"Unable to parse discovery from device: {config.host}: {ex}", host=config.host, ) from ex + # Decrypt the data if ( encrypt_info := discovery_result.encrypt_info @@ -746,11 +748,13 @@ def _get_device_instance( type_ = discovery_result.device_type encrypt_schm = discovery_result.mgt_encrypt_schm + try: if not (encrypt_type := encrypt_schm.encrypt_type) and ( encrypt_info := discovery_result.encrypt_info ): encrypt_type = encrypt_info.sym_schm + if not encrypt_type: raise UnsupportedDeviceError( f"Unsupported device {config.host} of type {type_} " @@ -771,19 +775,21 @@ def _get_device_instance( discovery_result=discovery_result.to_dict(), host=config.host, ) from ex + if ( device_class := get_device_class_from_family( type_, https=encrypt_schm.is_support_https ) ) is None: - _LOGGER.warning("Got unsupported device type: %s", type_) + _LOGGER.debug("Got unsupported device type: %s", type_) raise UnsupportedDeviceError( f"Unsupported device {config.host} of type {type_}: {info}", discovery_result=discovery_result.to_dict(), host=config.host, ) + if (protocol := get_protocol(config)) is None: - _LOGGER.warning( + _LOGGER.debug( "Got unsupported connection type: %s", config.connection_type.to_dict() ) raise UnsupportedDeviceError( @@ -800,6 +806,7 @@ def _get_device_instance( else info ) _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) + device = device_class(config.host, protocol=protocol) di = discovery_result.to_dict() diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index e272cef81..f8df43975 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -3,6 +3,7 @@ import copy from dataclasses import dataclass from json import dumps as json_dumps +from typing import Any, TypedDict import pytest @@ -16,10 +17,21 @@ DISCOVERY_MOCK_IP = "127.0.0.123" -def _make_unsupported(device_family, encrypt_type, *, omit_keys=None): +class DiscoveryResponse(TypedDict): + result: dict[str, Any] + error_code: int + + +def _make_unsupported( + device_family, + encrypt_type, + *, + https: bool = False, + omit_keys: dict[str, Any] | None = None, +) -> DiscoveryResponse: if omit_keys is None: omit_keys = {"encrypt_info": None} - result = { + result: DiscoveryResponse = { "result": { "device_id": "xx", "owner": "xx", @@ -31,7 +43,7 @@ def _make_unsupported(device_family, encrypt_type, *, omit_keys=None): "obd_src": "tplink", "factory_default": False, "mgt_encrypt_schm": { - "is_support_https": False, + "is_support_https": https, "encrypt_type": encrypt_type, "http_port": 80, "lv": 2, @@ -51,6 +63,7 @@ def _make_unsupported(device_family, encrypt_type, *, omit_keys=None): UNSUPPORTED_DEVICES = { "unknown_device_family": _make_unsupported("SMART.TAPOXMASTREE", "AES"), + "unknown_iot_device_family": _make_unsupported("IOT.IOTXMASTREE", "AES"), "wrong_encryption_iot": _make_unsupported("IOT.SMARTPLUGSWITCH", "AES"), "wrong_encryption_smart": _make_unsupported("SMART.TAPOBULB", "IOT"), "unknown_encryption": _make_unsupported("IOT.SMARTPLUGSWITCH", "FOO"), @@ -64,6 +77,11 @@ def _make_unsupported(device_family, encrypt_type, *, omit_keys=None): "FOO", omit_keys={"mgt_encrypt_schm": None}, ), + "invalidinstance": _make_unsupported( + "IOT.SMARTPLUGSWITCH", + "KLAP", + https=True, + ), } diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 9102a5287..8f9f635ae 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -195,6 +195,6 @@ async def test_device_types(dev: Device): async def test_device_class_from_unknown_family(caplog): """Verify that unknown SMART devices yield a warning and fallback to SmartDevice.""" dummy_name = "SMART.foo" - with caplog.at_level(logging.WARNING): + with caplog.at_level(logging.DEBUG): assert get_device_class_from_family(dummy_name, https=False) == SmartDevice assert f"Unknown SMART device with {dummy_name}" in caplog.text From 652b4e0bd7b7ef39c421bae17e677e8e33713068 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 21 Nov 2024 18:39:15 +0000 Subject: [PATCH 705/892] Use credentials_hash for smartcamera rtsp url (#1293) --- kasa/smartcamera/modules/camera.py | 29 ++++++++++++++++++- tests/smartcamera/test_smartcamera.py | 40 +++++++++++++++++++++------ 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/kasa/smartcamera/modules/camera.py b/kasa/smartcamera/modules/camera.py index ecd7fff70..65f434d15 100644 --- a/kasa/smartcamera/modules/camera.py +++ b/kasa/smartcamera/modules/camera.py @@ -2,13 +2,18 @@ from __future__ import annotations +import base64 +import logging from urllib.parse import quote_plus from ...credentials import Credentials from ...device_type import DeviceType from ...feature import Feature +from ...json import loads as json_loads from ..smartcameramodule import SmartCameraModule +_LOGGER = logging.getLogger(__name__) + LOCAL_STREAMING_PORT = 554 @@ -38,6 +43,27 @@ def is_on(self) -> bool: """Return the device id.""" return self.data["lens_mask_info"]["enabled"] == "off" + def _get_credentials(self) -> Credentials | None: + """Get credentials from .""" + config = self._device.config + if credentials := config.credentials: + return credentials + + if credentials_hash := config.credentials_hash: + try: + decoded = json_loads( + base64.b64decode(credentials_hash.encode()).decode() + ) + except Exception: + _LOGGER.warning( + "Unable to deserialize credentials_hash: %s", credentials_hash + ) + return None + if (username := decoded.get("un")) and (password := decoded.get("pwd")): + return Credentials(username, password) + + return None + def stream_rtsp_url(self, credentials: Credentials | None = None) -> str | None: """Return the local rtsp streaming url. @@ -51,7 +77,8 @@ def stream_rtsp_url(self, credentials: Credentials | None = None) -> str | None: return None dev = self._device if not credentials: - credentials = dev.credentials + credentials = self._get_credentials() + if not credentials or not credentials.username or not credentials.password: return None username = quote_plus(credentials.username) diff --git a/tests/smartcamera/test_smartcamera.py b/tests/smartcamera/test_smartcamera.py index 6b69cbb77..6f1d82d35 100644 --- a/tests/smartcamera/test_smartcamera.py +++ b/tests/smartcamera/test_smartcamera.py @@ -2,6 +2,8 @@ from __future__ import annotations +import base64 +import json from datetime import UTC, datetime from unittest.mock import patch @@ -35,17 +37,41 @@ async def test_stream_rtsp_url(dev: Device): url = camera_module.stream_rtsp_url(Credentials("foo", "bar")) assert url == "rtsp://foo:bar@127.0.0.123:554/stream1" - with patch.object( - dev.protocol._transport, "_credentials", Credentials("bar", "foo") - ): + with patch.object(dev.config, "credentials", Credentials("bar", "foo")): url = camera_module.stream_rtsp_url() assert url == "rtsp://bar:foo@127.0.0.123:554/stream1" - with patch.object(dev.protocol._transport, "_credentials", Credentials("bar", "")): + with patch.object(dev.config, "credentials", Credentials("bar", "")): url = camera_module.stream_rtsp_url() assert url is None - with patch.object(dev.protocol._transport, "_credentials", Credentials("", "Foo")): + with patch.object(dev.config, "credentials", Credentials("", "Foo")): + url = camera_module.stream_rtsp_url() + assert url is None + + # Test with credentials_hash + cred = json.dumps({"un": "bar", "pwd": "foobar"}) + cred_hash = base64.b64encode(cred.encode()).decode() + with ( + patch.object(dev.config, "credentials", None), + patch.object(dev.config, "credentials_hash", cred_hash), + ): + url = camera_module.stream_rtsp_url() + assert url == "rtsp://bar:foobar@127.0.0.123:554/stream1" + + # Test with invalid credentials_hash + with ( + patch.object(dev.config, "credentials", None), + patch.object(dev.config, "credentials_hash", b"238472871"), + ): + url = camera_module.stream_rtsp_url() + assert url is None + + # Test with no credentials + with ( + patch.object(dev.config, "credentials", None), + patch.object(dev.config, "credentials_hash", None), + ): url = camera_module.stream_rtsp_url() assert url is None @@ -54,9 +80,7 @@ async def test_stream_rtsp_url(dev: Device): await dev.update() url = camera_module.stream_rtsp_url(Credentials("foo", "bar")) assert url is None - with patch.object( - dev.protocol._transport, "_credentials", Credentials("bar", "foo") - ): + with patch.object(dev.config, "credentials", Credentials("bar", "foo")): url = camera_module.stream_rtsp_url() assert url is None From cae9decb02531ee2d364e5e156392ea558cc5a23 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 21 Nov 2024 18:40:13 +0000 Subject: [PATCH 706/892] Exclude __getattr__ for deprecated attributes from type checkers (#1294) --- kasa/__init__.py | 45 ++++++++++++++++++++------------------- kasa/cli/light.py | 2 +- kasa/device.py | 40 ++++++++++++++++++---------------- kasa/interfaces/energy.py | 16 ++++++++------ kasa/iot/iotdimmer.py | 2 +- kasa/iot/iotmodule.py | 7 +++++- kasa/iot/iotstrip.py | 4 +++- kasa/iot/modules/light.py | 2 ++ kasa/smart/smartdevice.py | 4 ++++ tests/test_emeter.py | 3 +++ 10 files changed, 74 insertions(+), 51 deletions(-) diff --git a/kasa/__init__.py b/kasa/__init__.py index 7fb80ab57..059e093e2 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -97,28 +97,29 @@ "DeviceFamilyType": DeviceFamily, } - -def __getattr__(name: str) -> Any: - if name in deprecated_names: - warn(f"{name} is deprecated", DeprecationWarning, stacklevel=2) - return globals()[f"_deprecated_{name}"] - if name in deprecated_smart_devices: - new_class = deprecated_smart_devices[name] - package_name = ".".join(new_class.__module__.split(".")[:-1]) - warn( - f"{name} is deprecated, use {new_class.__name__} " - + f"from package {package_name} instead or use Discover.discover_single()" - + " and Device.connect() to support new protocols", - DeprecationWarning, - stacklevel=2, - ) - return new_class - if name in deprecated_classes: - new_class = deprecated_classes[name] # type: ignore[assignment] - msg = f"{name} is deprecated, use {new_class.__name__} instead" - warn(msg, DeprecationWarning, stacklevel=2) - return new_class - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +if not TYPE_CHECKING: + + def __getattr__(name: str) -> Any: + if name in deprecated_names: + warn(f"{name} is deprecated", DeprecationWarning, stacklevel=2) + return globals()[f"_deprecated_{name}"] + if name in deprecated_smart_devices: + new_class = deprecated_smart_devices[name] + package_name = ".".join(new_class.__module__.split(".")[:-1]) + warn( + f"{name} is deprecated, use {new_class.__name__} from " + + f"package {package_name} instead or use Discover.discover_single()" + + " and Device.connect() to support new protocols", + DeprecationWarning, + stacklevel=2, + ) + return new_class + if name in deprecated_classes: + new_class = deprecated_classes[name] # type: ignore[assignment] + msg = f"{name} is deprecated, use {new_class.__name__} instead" + warn(msg, DeprecationWarning, stacklevel=2) + return new_class + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") if TYPE_CHECKING: diff --git a/kasa/cli/light.py b/kasa/cli/light.py index f142478c3..b2909c59e 100644 --- a/kasa/cli/light.py +++ b/kasa/cli/light.py @@ -190,7 +190,7 @@ async def presets_modify(dev: Device, index, brightness, hue, saturation, temper @click.option("--preset", type=int) async def turn_on_behavior(dev: Device, type, last, preset): """Modify bulb turn-on behavior.""" - if not dev.is_bulb or not isinstance(dev, IotBulb): + if dev.device_type is not Device.Type.Bulb or not isinstance(dev, IotBulb): error("Presets only supported on iot bulbs") return settings = await dev.get_turn_on_behavior() diff --git a/kasa/device.py b/kasa/device.py index b0f110cbd..76d7a7c59 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -566,21 +566,25 @@ def _get_replacing_attr( "supported_modules": (None, ["modules"]), } - def __getattr__(self, name: str) -> Any: - # is_device_type - if dep_device_type_attr := self._deprecated_device_type_attributes.get(name): - msg = f"{name} is deprecated, use device_type property instead" - warn(msg, DeprecationWarning, stacklevel=2) - return self.device_type == dep_device_type_attr[1] - # Other deprecated attributes - if (dep_attr := self._deprecated_other_attributes.get(name)) and ( - (replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1])) - is not None - ): - mod = dep_attr[0] - dev_or_mod = self.modules[mod] if mod else self - replacing = f"Module.{mod} in device.modules" if mod else replacing_attr - msg = f"{name} is deprecated, use: {replacing} instead" - warn(msg, DeprecationWarning, stacklevel=2) - return getattr(dev_or_mod, replacing_attr) - raise AttributeError(f"Device has no attribute {name!r}") + if not TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: + # is_device_type + if dep_device_type_attr := self._deprecated_device_type_attributes.get( + name + ): + msg = f"{name} is deprecated, use device_type property instead" + warn(msg, DeprecationWarning, stacklevel=2) + return self.device_type == dep_device_type_attr[1] + # Other deprecated attributes + if (dep_attr := self._deprecated_other_attributes.get(name)) and ( + (replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1])) + is not None + ): + mod = dep_attr[0] + dev_or_mod = self.modules[mod] if mod else self + replacing = f"Module.{mod} in device.modules" if mod else replacing_attr + msg = f"{name} is deprecated, use: {replacing} instead" + warn(msg, DeprecationWarning, stacklevel=2) + return getattr(dev_or_mod, replacing_attr) + raise AttributeError(f"Device has no attribute {name!r}") diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py index 7092788ec..c57a3ed80 100644 --- a/kasa/interfaces/energy.py +++ b/kasa/interfaces/energy.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from enum import IntFlag, auto -from typing import Any +from typing import TYPE_CHECKING, Any from warnings import warn from ..emeterstatus import EmeterStatus @@ -184,9 +184,11 @@ async def get_monthly_stats( "get_monthstat": "get_monthly_stats", } - def __getattr__(self, name: str) -> Any: - if attr := self._deprecated_attributes.get(name): - msg = f"{name} is deprecated, use {attr} instead" - warn(msg, DeprecationWarning, stacklevel=2) - return getattr(self, attr) - raise AttributeError(f"Energy module has no attribute {name!r}") + if not TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: + if attr := self._deprecated_attributes.get(name): + msg = f"{name} is deprecated, use {attr} instead" + warn(msg, DeprecationWarning, stacklevel=2) + return getattr(self, attr) + raise AttributeError(f"Energy module has no attribute {name!r}") diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 0c9eb3ea7..3960e641b 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -154,7 +154,7 @@ async def turn_on(self, *, transition: int | None = None, **kwargs) -> dict: """ if transition is not None: return await self.set_dimmer_transition( - brightness=self.brightness, transition=transition + brightness=self._brightness, transition=transition ) return await super().turn_on() diff --git a/kasa/iot/iotmodule.py b/kasa/iot/iotmodule.py index ddb0da2c1..115e9e823 100644 --- a/kasa/iot/iotmodule.py +++ b/kasa/iot/iotmodule.py @@ -3,13 +3,16 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any from ..exceptions import KasaException from ..module import Module _LOGGER = logging.getLogger(__name__) +if TYPE_CHECKING: + from .iotdevice import IotDevice + def _merge_dict(dest: dict, source: dict) -> dict: """Update dict recursively.""" @@ -27,6 +30,8 @@ def _merge_dict(dest: dict, source: dict) -> dict: class IotModule(Module): """Base class implemention for all IOT modules.""" + _device: IotDevice + async def call(self, method: str, params: dict | None = None) -> dict: """Call the given method with the given parameters.""" return await self._device._query_helper(self._module, method, params) diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 849f92f23..a4b2ab996 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -5,7 +5,7 @@ import logging from collections import defaultdict from datetime import datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING, Any from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -145,6 +145,8 @@ async def update(self, update_children: bool = True) -> None: if update_children: for plug in self.children: + if TYPE_CHECKING: + assert isinstance(plug, IotStripPlug) await plug._update() if not self.features: diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 7c9342c9d..5fdbf014d 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -207,6 +207,8 @@ async def set_state(self, state: LightState) -> dict: # iot protocol Dimmers and smart protocol devices do not support # brightness of 0 so 0 will turn off all devices for consistency if (bulb := self._get_bulb_device()) is None: # Dimmer + if TYPE_CHECKING: + assert isinstance(self._device, IotDimmer) if state.brightness == 0 or state.light_on is False: return await self._device.turn_off(transition=state.transition) elif state.brightness: diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 07c8c154e..bd0ea7c5c 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -28,6 +28,8 @@ ) from .smartmodule import SmartModule +if TYPE_CHECKING: + from .smartchilddevice import SmartChildDevice _LOGGER = logging.getLogger(__name__) @@ -196,6 +198,8 @@ async def update(self, update_children: bool = False) -> None: # child modules have access to their sysinfo. if update_children or self.device_type != DeviceType.Hub: for child in self._children.values(): + if TYPE_CHECKING: + assert isinstance(child, SmartChildDevice) await child._update() # We can first initialize the features after the first update. diff --git a/tests/test_emeter.py b/tests/test_emeter.py index ad5ab190a..7eb16f8bd 100644 --- a/tests/test_emeter.py +++ b/tests/test_emeter.py @@ -16,6 +16,7 @@ from kasa.iot.modules.emeter import Emeter from kasa.smart import SmartDevice from kasa.smart.modules import Energy as SmartEnergyModule +from kasa.smart.smartmodule import SmartModule from .conftest import has_emeter, has_emeter_iot, no_emeter @@ -192,6 +193,7 @@ async def test_supported(dev: Device): pytest.skip(f"Energy module not supported for {dev}.") energy_module = dev.modules.get(Module.Energy) assert energy_module + if isinstance(dev, IotDevice): info = ( dev._last_update @@ -210,6 +212,7 @@ async def test_supported(dev: Device): ) assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is True else: + assert isinstance(energy_module, SmartModule) assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is False assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is False if energy_module.supported_version < 2: From 37cc4da7b6b536df3e67bda99d9ccd2532a58472 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 22 Nov 2024 07:52:23 +0000 Subject: [PATCH 707/892] Allow getting Annotated features from modules (#1018) Co-authored-by: Teemu R. --- kasa/module.py | 72 +++++++++++++++++++++++++++++++++ kasa/smart/modules/fan.py | 13 ++++-- tests/smart/modules/test_fan.py | 41 +++++++++++++++++-- 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/kasa/module.py b/kasa/module.py index f3d0dade8..ccd22d4e0 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -42,10 +42,13 @@ import logging from abc import ABC, abstractmethod +from collections.abc import Callable +from functools import cache from typing import ( TYPE_CHECKING, Final, TypeVar, + get_type_hints, ) from .exceptions import KasaException @@ -64,6 +67,10 @@ ModuleT = TypeVar("ModuleT", bound="Module") +class FeatureAttribute: + """Class for annotating attributes bound to feature.""" + + class Module(ABC): """Base class implemention for all modules. @@ -140,6 +147,14 @@ def __init__(self, device: Device, module: str) -> None: self._module = module self._module_features: dict[str, Feature] = {} + def has_feature(self, attribute: str | property | Callable) -> bool: + """Return True if the module attribute feature is supported.""" + return bool(self.get_feature(attribute)) + + def get_feature(self, attribute: str | property | Callable) -> Feature | None: + """Get Feature for a module attribute or None if not supported.""" + return _get_bound_feature(self, attribute) + @abstractmethod def query(self) -> dict: """Query to execute during the update cycle. @@ -183,3 +198,60 @@ def __repr__(self) -> str: f"" ) + + +def _is_bound_feature(attribute: property | Callable) -> bool: + """Check if an attribute is bound to a feature with FeatureAttribute.""" + if isinstance(attribute, property): + hints = get_type_hints(attribute.fget, include_extras=True) + else: + hints = get_type_hints(attribute, include_extras=True) + + if (return_hints := hints.get("return")) and hasattr(return_hints, "__metadata__"): + metadata = hints["return"].__metadata__ + for meta in metadata: + if isinstance(meta, FeatureAttribute): + return True + + return False + + +@cache +def _get_bound_feature( + module: Module, attribute: str | property | Callable +) -> Feature | None: + """Get Feature for a bound property or None if not supported.""" + if not isinstance(attribute, str): + if isinstance(attribute, property): + # Properties have __name__ in 3.13 so this could be simplified + # when only 3.13 supported + attribute_name = attribute.fget.__name__ # type: ignore[union-attr] + else: + attribute_name = attribute.__name__ + attribute_callable = attribute + else: + if TYPE_CHECKING: + assert isinstance(attribute, str) + attribute_name = attribute + attribute_callable = getattr(module.__class__, attribute, None) # type: ignore[assignment] + if not attribute_callable: + raise KasaException( + f"No attribute named {attribute_name} in " + f"module {module.__class__.__name__}" + ) + + if not _is_bound_feature(attribute_callable): + raise KasaException( + f"Attribute {attribute_name} of module {module.__class__.__name__}" + " is not bound to a feature" + ) + + check = {attribute_name, attribute_callable} + for feature in module._module_features.values(): + if (getter := feature.attribute_getter) and getter in check: + return feature + + if (setter := feature.attribute_setter) and setter in check: + return feature + + return None diff --git a/kasa/smart/modules/fan.py b/kasa/smart/modules/fan.py index 36b3aadfa..6443cbacb 100644 --- a/kasa/smart/modules/fan.py +++ b/kasa/smart/modules/fan.py @@ -2,8 +2,11 @@ from __future__ import annotations +from typing import Annotated + from ...feature import Feature from ...interfaces.fan import Fan as FanInterface +from ...module import FeatureAttribute from ..smartmodule import SmartModule @@ -46,11 +49,13 @@ def query(self) -> dict: return {} @property - def fan_speed_level(self) -> int: + def fan_speed_level(self) -> Annotated[int, FeatureAttribute()]: """Return fan speed level.""" return 0 if self.data["device_on"] is False else self.data["fan_speed_level"] - async def set_fan_speed_level(self, level: int) -> dict: + async def set_fan_speed_level( + self, level: int + ) -> Annotated[dict, FeatureAttribute()]: """Set fan speed level, 0 for off, 1-4 for on.""" if level < 0 or level > 4: raise ValueError("Invalid level, should be in range 0-4.") @@ -61,11 +66,11 @@ async def set_fan_speed_level(self, level: int) -> dict: ) @property - def sleep_mode(self) -> bool: + def sleep_mode(self) -> Annotated[bool, FeatureAttribute()]: """Return sleep mode status.""" return self.data["fan_sleep_mode_on"] - async def set_sleep_mode(self, on: bool) -> dict: + async def set_sleep_mode(self, on: bool) -> Annotated[dict, FeatureAttribute()]: """Set sleep mode.""" return await self.call("set_device_info", {"fan_sleep_mode_on": on}) diff --git a/tests/smart/modules/test_fan.py b/tests/smart/modules/test_fan.py index a032794cb..9a6878e5b 100644 --- a/tests/smart/modules/test_fan.py +++ b/tests/smart/modules/test_fan.py @@ -1,8 +1,9 @@ import pytest from pytest_mock import MockerFixture -from kasa import Module +from kasa import KasaException, Module from kasa.smart import SmartDevice +from kasa.smart.modules import Fan from ...device_fixtures import get_parent_and_child_modules, parametrize @@ -77,8 +78,42 @@ async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): await dev.update() assert not device.is_on + fan_speed_level_feature = fan._module_features["fan_speed_level"] + max_level = fan_speed_level_feature.maximum_value + min_level = fan_speed_level_feature.minimum_value with pytest.raises(ValueError, match="Invalid level"): - await fan.set_fan_speed_level(-1) + await fan.set_fan_speed_level(min_level - 1) with pytest.raises(ValueError, match="Invalid level"): - await fan.set_fan_speed_level(5) + await fan.set_fan_speed_level(max_level - 5) + + +@fan +async def test_fan_features(dev: SmartDevice, mocker: MockerFixture): + """Test fan speed on device interface.""" + assert isinstance(dev, SmartDevice) + fan = next(get_parent_and_child_modules(dev, Module.Fan)) + assert fan + expected_feature = fan._module_features["fan_speed_level"] + + fan_speed_level_feature = fan.get_feature(Fan.set_fan_speed_level) + assert expected_feature == fan_speed_level_feature + + fan_speed_level_feature = fan.get_feature(fan.set_fan_speed_level) + assert expected_feature == fan_speed_level_feature + + fan_speed_level_feature = fan.get_feature(Fan.fan_speed_level) + assert expected_feature == fan_speed_level_feature + + fan_speed_level_feature = fan.get_feature("fan_speed_level") + assert expected_feature == fan_speed_level_feature + + assert fan.has_feature(Fan.fan_speed_level) + + msg = "Attribute _check_supported of module Fan is not bound to a feature" + with pytest.raises(KasaException, match=msg): + assert fan.has_feature(fan._check_supported) + + msg = "No attribute named foobar in module Fan" + with pytest.raises(KasaException, match=msg): + assert fan.has_feature("foobar") From c5830a4cdcdf71f63a97c824dc598a2ee27d546b Mon Sep 17 00:00:00 2001 From: Ryan Nitcher Date: Fri, 22 Nov 2024 00:59:17 -0700 Subject: [PATCH 708/892] Add PIR ADC Values to Test Fixtures (#1296) --- devtools/dump_devinfo.py | 3 + tests/fixtures/ES20M(US)_1.0_1.0.11.json | 71 +++++++++++++++--- tests/fixtures/KL135(US)_1.0_1.0.15.json | 89 +++++++++++++++++------ tests/fixtures/KS200M(US)_1.0_1.0.10.json | 63 +++++++++++++++- tests/fixtures/KS220(US)_1.0_1.0.13.json | 50 ++++++++++++- 5 files changed, 242 insertions(+), 34 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 3522ef14c..e0ada6796 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -425,7 +425,10 @@ async def get_legacy_fixture( Call(module="smartlife.iot.lightStrip", method="get_light_details"), Call(module="smartlife.iot.LAS", method="get_config"), Call(module="smartlife.iot.LAS", method="get_current_brt"), + Call(module="smartlife.iot.LAS", method="get_dark_status"), + Call(module="smartlife.iot.LAS", method="get_adc_value"), Call(module="smartlife.iot.PIR", method="get_config"), + Call(module="smartlife.iot.PIR", method="get_adc_value"), ] successes = [] diff --git a/tests/fixtures/ES20M(US)_1.0_1.0.11.json b/tests/fixtures/ES20M(US)_1.0_1.0.11.json index f87a0a2b1..99ecdaa57 100644 --- a/tests/fixtures/ES20M(US)_1.0_1.0.11.json +++ b/tests/fixtures/ES20M(US)_1.0_1.0.11.json @@ -1,5 +1,41 @@ { + "cnCloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "#MASKED_NAME#" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, "smartlife.iot.LAS": { + "get_adc_value": { + "err_code": 0, + "type": 2, + "value": 0 + }, "get_config": { "devs": [ { @@ -47,10 +83,18 @@ }, "get_current_brt": { "err_code": 0, - "value": 16 + "value": 0 + }, + "get_dark_status": { + "bDark": 1, + "err_code": 0 } }, "smartlife.iot.PIR": { + "get_adc_value": { + "err_code": 0, + "value": 2107 + }, "get_config": { "array": [ 80, @@ -59,15 +103,24 @@ 0 ], "cold_time": 120000, - "enable": 1, + "enable": 0, "err_code": 0, "max_adc": 4095, "min_adc": 0, - "trigger_index": 0, + "trigger_index": 1, "version": "1.0" } }, "smartlife.iot.dimmer": { + "get_default_behavior": { + "double_click": { + "mode": "unknown" + }, + "err_code": 0, + "long_press": { + "mode": "unknown" + } + }, "get_dimmer_parameters": { "bulb_type": 1, "err_code": 0, @@ -75,7 +128,7 @@ "fadeOnTime": 0, "gentleOffTime": 10000, "gentleOnTime": 3000, - "minThreshold": 17, + "minThreshold": 5, "rampRate": 30 } }, @@ -92,9 +145,9 @@ "hw_ver": "1.0", "icon_hash": "", "latitude_i": 0, - "led_off": 1, + "led_off": 0, "longitude_i": 0, - "mac": "B0:A7:B9:00:00:00", + "mac": "28:87:BA:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "ES20M(US)", "next_action": { @@ -102,7 +155,7 @@ }, "obd_src": "tplink", "oemId": "00000000000000000000000000000000", - "on_time": 6, + "on_time": 0, "preferred_state": [ { "brightness": 100, @@ -121,8 +174,8 @@ "index": 3 } ], - "relay_state": 1, - "rssi": -40, + "relay_state": 0, + "rssi": -57, "status": "new", "sw_ver": "1.0.11 Build 240514 Rel.110351", "updating": 0 diff --git a/tests/fixtures/KL135(US)_1.0_1.0.15.json b/tests/fixtures/KL135(US)_1.0_1.0.15.json index 8d8aa1fe9..b6670a7ae 100644 --- a/tests/fixtures/KL135(US)_1.0_1.0.15.json +++ b/tests/fixtures/KL135(US)_1.0_1.0.15.json @@ -1,22 +1,71 @@ { + "smartlife.iot.common.cloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "#MASKED_NAME#" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, "smartlife.iot.common.emeter": { "get_realtime": { "err_code": 0, - "power_mw": 0, - "total_wh": 25 + "power_mw": 10800, + "total_wh": 48 + } + }, + "smartlife.iot.common.schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 0, + "err_code": 0, + "rule_list": [], + "version": 2 } }, "smartlife.iot.smartbulb.lightingservice": { - "get_light_state": { - "dft_on_state": { - "brightness": 98, - "color_temp": 6500, - "hue": 28, - "mode": "normal", - "saturation": 72 + "get_default_behavior": { + "err_code": 0, + "hard_on": { + "mode": "last_status" }, + "re_power_type": "always_on", + "soft_on": { + "mode": "last_status" + } + }, + "get_light_details": { + "color_rendering_index": 90, + "err_code": 0, + "incandescent_equivalent": 60, + "lamp_beam_angle": 220, + "max_lumens": 800, + "max_voltage": 120, + "min_voltage": 100, + "wattage": 10 + }, + "get_light_state": { + "brightness": 100, + "color_temp": 2700, "err_code": 0, - "on_off": 0 + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 } }, "system": { @@ -40,24 +89,22 @@ "is_variable_color_temp": 1, "latitude_i": 0, "light_state": { - "dft_on_state": { - "brightness": 98, - "color_temp": 6500, - "hue": 28, - "mode": "normal", - "saturation": 72 - }, - "on_off": 0 + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 }, "longitude_i": 0, - "mic_mac": "000000000000", + "mic_mac": "54AF97000000", "mic_type": "IOT.SMARTBULB", "model": "KL135(US)", "obd_src": "tplink", "oemId": "00000000000000000000000000000000", "preferred_state": [ { - "brightness": 50, + "brightness": 100, "color_temp": 2700, "hue": 0, "index": 0, @@ -85,7 +132,7 @@ "saturation": 100 } ], - "rssi": -41, + "rssi": -69, "status": "new", "sw_ver": "1.0.15 Build 240429 Rel.154143" } diff --git a/tests/fixtures/KS200M(US)_1.0_1.0.10.json b/tests/fixtures/KS200M(US)_1.0_1.0.10.json index 87c64f4bb..24acdb976 100644 --- a/tests/fixtures/KS200M(US)_1.0_1.0.10.json +++ b/tests/fixtures/KS200M(US)_1.0_1.0.10.json @@ -1,5 +1,52 @@ { + "cnCloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "#MASKED_NAME#" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [ + { + "fwLocation": 0, + "fwReleaseDate": "2024-06-19", + "fwReleaseLog": "Modifications and Bug Fixes:\n1. Added \"Hold on\" feature, now you can quickly double-click the switch to keep it on without being affected by the Smart Control rules.\n2. Fixed a bug where Motion & Dark rules could still be triggered under bright light conditions.\n3. Fixed some minor bugs.", + "fwReleaseLogUrl": "undefined yet", + "fwTitle": "Hi, a new firmware with bug fixes and performance improvement is available for your KS200M.", + "fwType": 2, + "fwUrl": "http://download.tplinkcloud.com/firmware/KS200M_FCC_1.0.12_Build_240507_Rel.143458_2024-05-07_14.37.42_1718767325443.bin", + "fwVer": "1.0.12 Build 240507 Rel.143458" + } + ] + } + }, + "schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, "smartlife.iot.LAS": { + "get_adc_value": { + "err_code": 0, + "type": 2, + "value": 0 + }, "get_config": { "devs": [ { @@ -44,9 +91,21 @@ ], "err_code": 0, "ver": "1.0" + }, + "get_current_brt": { + "err_code": 0, + "value": 0 + }, + "get_dark_status": { + "bDark": 1, + "err_code": 0 } }, "smartlife.iot.PIR": { + "get_adc_value": { + "err_code": 0, + "value": 2025 + }, "get_config": { "array": [ 80, @@ -59,7 +118,7 @@ "err_code": 0, "max_adc": 4095, "min_adc": 0, - "trigger_index": 0, + "trigger_index": 2, "version": "1.0" } }, @@ -87,7 +146,7 @@ "oemId": "00000000000000000000000000000000", "on_time": 0, "relay_state": 0, - "rssi": -39, + "rssi": -38, "status": "new", "sw_ver": "1.0.10 Build 221019 Rel.194527", "updating": 0 diff --git a/tests/fixtures/KS220(US)_1.0_1.0.13.json b/tests/fixtures/KS220(US)_1.0_1.0.13.json index 86ee9d3e7..f5c8c1dd1 100644 --- a/tests/fixtures/KS220(US)_1.0_1.0.13.json +++ b/tests/fixtures/KS220(US)_1.0_1.0.13.json @@ -1,5 +1,51 @@ { + "cnCloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "#MASKED_NAME#" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 0, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, "smartlife.iot.dimmer": { + "get_default_behavior": { + "double_click": { + "mode": "gentle_on_off" + }, + "err_code": 0, + "hard_on": { + "mode": "last_status" + }, + "long_press": { + "mode": "instant_on_off" + }, + "soft_on": { + "mode": "last_status" + } + }, "get_dimmer_parameters": { "bulb_type": 1, "calibration_type": 1, @@ -8,7 +54,7 @@ "fadeOnTime": 1000, "gentleOffTime": 10000, "gentleOnTime": 3000, - "minThreshold": 1, + "minThreshold": 9, "rampRate": 30 } }, @@ -56,7 +102,7 @@ } ], "relay_state": 0, - "rssi": -47, + "rssi": -50, "status": "configured", "sw_ver": "1.0.13 Build 240424 Rel.102214", "updating": 0 From f4316110c958ab5b612fd010ae4d944b3d261856 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 22 Nov 2024 20:19:33 +0000 Subject: [PATCH 709/892] Move iot fixtures into iot subfolder (#1299) --- devtools/dump_devinfo.py | 2 +- devtools/generate_supported.py | 2 +- tests/fixtureinfo.py | 2 +- tests/fixtures/{ => iot}/EP10(US)_1.0_1.0.2.json | 0 tests/fixtures/{ => iot}/EP40(US)_1.0_1.0.2.json | 0 tests/fixtures/{ => iot}/ES20M(US)_1.0_1.0.11.json | 0 tests/fixtures/{ => iot}/ES20M(US)_1.0_1.0.8.json | 0 tests/fixtures/{ => iot}/HS100(UK)_1.0_1.2.6.json | 0 tests/fixtures/{ => iot}/HS100(UK)_4.1_1.1.0.json | 0 tests/fixtures/{ => iot}/HS100(US)_1.0_1.2.5.json | 0 tests/fixtures/{ => iot}/HS100(US)_2.0_1.5.6.json | 0 tests/fixtures/{ => iot}/HS103(US)_1.0_1.5.7.json | 0 tests/fixtures/{ => iot}/HS103(US)_2.1_1.1.2.json | 0 tests/fixtures/{ => iot}/HS103(US)_2.1_1.1.4.json | 0 tests/fixtures/{ => iot}/HS105(US)_1.0_1.5.6.json | 0 tests/fixtures/{ => iot}/HS107(US)_1.0_1.0.8.json | 0 tests/fixtures/{ => iot}/HS110(EU)_1.0_1.2.5.json | 0 tests/fixtures/{ => iot}/HS110(EU)_4.0_1.0.4.json | 0 tests/fixtures/{ => iot}/HS110(US)_1.0_1.2.6.json | 0 tests/fixtures/{ => iot}/HS200(US)_2.0_1.5.7.json | 0 tests/fixtures/{ => iot}/HS200(US)_3.0_1.1.5.json | 0 tests/fixtures/{ => iot}/HS200(US)_5.0_1.0.11.json | 0 tests/fixtures/{ => iot}/HS200(US)_5.0_1.0.2.json | 0 tests/fixtures/{ => iot}/HS210(US)_1.0_1.5.8.json | 0 tests/fixtures/{ => iot}/HS210(US)_2.0_1.1.5.json | 0 tests/fixtures/{ => iot}/HS220(US)_1.0_1.5.7.json | 0 tests/fixtures/{ => iot}/HS220(US)_2.0_1.0.3.json | 0 tests/fixtures/{ => iot}/HS300(US)_1.0_1.0.10.json | 0 tests/fixtures/{ => iot}/HS300(US)_1.0_1.0.21.json | 0 tests/fixtures/{ => iot}/HS300(US)_2.0_1.0.12.json | 0 tests/fixtures/{ => iot}/HS300(US)_2.0_1.0.3.json | 0 tests/fixtures/{ => iot}/KL110(US)_1.0_1.8.11.json | 0 tests/fixtures/{ => iot}/KL120(US)_1.0_1.8.11.json | 0 tests/fixtures/{ => iot}/KL120(US)_1.0_1.8.6.json | 0 tests/fixtures/{ => iot}/KL125(US)_1.20_1.0.5.json | 0 tests/fixtures/{ => iot}/KL125(US)_2.0_1.0.7.json | 0 tests/fixtures/{ => iot}/KL125(US)_4.0_1.0.5.json | 0 tests/fixtures/{ => iot}/KL130(EU)_1.0_1.8.8.json | 0 tests/fixtures/{ => iot}/KL130(US)_1.0_1.8.11.json | 0 tests/fixtures/{ => iot}/KL135(US)_1.0_1.0.15.json | 0 tests/fixtures/{ => iot}/KL135(US)_1.0_1.0.6.json | 0 tests/fixtures/{ => iot}/KL400L5(US)_1.0_1.0.5.json | 0 tests/fixtures/{ => iot}/KL400L5(US)_1.0_1.0.8.json | 0 tests/fixtures/{ => iot}/KL420L5(US)_1.0_1.0.2.json | 0 tests/fixtures/{ => iot}/KL430(UN)_2.0_1.0.8.json | 0 tests/fixtures/{ => iot}/KL430(US)_1.0_1.0.10.json | 0 tests/fixtures/{ => iot}/KL430(US)_2.0_1.0.11.json | 0 tests/fixtures/{ => iot}/KL430(US)_2.0_1.0.8.json | 0 tests/fixtures/{ => iot}/KL430(US)_2.0_1.0.9.json | 0 tests/fixtures/{ => iot}/KL50(US)_1.0_1.1.13.json | 0 tests/fixtures/{ => iot}/KL60(UN)_1.0_1.1.4.json | 0 tests/fixtures/{ => iot}/KL60(US)_1.0_1.1.13.json | 0 tests/fixtures/{ => iot}/KP100(US)_3.0_1.0.1.json | 0 tests/fixtures/{ => iot}/KP105(UK)_1.0_1.0.5.json | 0 tests/fixtures/{ => iot}/KP105(UK)_1.0_1.0.7.json | 0 tests/fixtures/{ => iot}/KP115(EU)_1.0_1.0.16.json | 0 tests/fixtures/{ => iot}/KP115(US)_1.0_1.0.17.json | 0 tests/fixtures/{ => iot}/KP115(US)_1.0_1.0.21.json | 0 tests/fixtures/{ => iot}/KP125(US)_1.0_1.0.6.json | 0 tests/fixtures/{ => iot}/KP200(US)_3.0_1.0.3.json | 0 tests/fixtures/{ => iot}/KP303(UK)_1.0_1.0.3.json | 0 tests/fixtures/{ => iot}/KP303(US)_2.0_1.0.3.json | 0 tests/fixtures/{ => iot}/KP303(US)_2.0_1.0.9.json | 0 tests/fixtures/{ => iot}/KP400(US)_1.0_1.0.10.json | 0 tests/fixtures/{ => iot}/KP400(US)_2.0_1.0.6.json | 0 tests/fixtures/{ => iot}/KP400(US)_3.0_1.0.3.json | 0 tests/fixtures/{ => iot}/KP400(US)_3.0_1.0.4.json | 0 tests/fixtures/{ => iot}/KP401(US)_1.0_1.0.0.json | 0 tests/fixtures/{ => iot}/KP405(US)_1.0_1.0.5.json | 0 tests/fixtures/{ => iot}/KP405(US)_1.0_1.0.6.json | 0 tests/fixtures/{ => iot}/KS200M(US)_1.0_1.0.10.json | 0 tests/fixtures/{ => iot}/KS200M(US)_1.0_1.0.11.json | 0 tests/fixtures/{ => iot}/KS200M(US)_1.0_1.0.12.json | 0 tests/fixtures/{ => iot}/KS200M(US)_1.0_1.0.8.json | 0 tests/fixtures/{ => iot}/KS220(US)_1.0_1.0.13.json | 0 tests/fixtures/{ => iot}/KS220M(US)_1.0_1.0.4.json | 0 tests/fixtures/{ => iot}/KS230(US)_1.0_1.0.14.json | 0 tests/fixtures/{ => iot}/LB110(US)_1.0_1.8.11.json | 0 78 files changed, 3 insertions(+), 3 deletions(-) rename tests/fixtures/{ => iot}/EP10(US)_1.0_1.0.2.json (100%) rename tests/fixtures/{ => iot}/EP40(US)_1.0_1.0.2.json (100%) rename tests/fixtures/{ => iot}/ES20M(US)_1.0_1.0.11.json (100%) rename tests/fixtures/{ => iot}/ES20M(US)_1.0_1.0.8.json (100%) rename tests/fixtures/{ => iot}/HS100(UK)_1.0_1.2.6.json (100%) rename tests/fixtures/{ => iot}/HS100(UK)_4.1_1.1.0.json (100%) rename tests/fixtures/{ => iot}/HS100(US)_1.0_1.2.5.json (100%) rename tests/fixtures/{ => iot}/HS100(US)_2.0_1.5.6.json (100%) rename tests/fixtures/{ => iot}/HS103(US)_1.0_1.5.7.json (100%) rename tests/fixtures/{ => iot}/HS103(US)_2.1_1.1.2.json (100%) rename tests/fixtures/{ => iot}/HS103(US)_2.1_1.1.4.json (100%) rename tests/fixtures/{ => iot}/HS105(US)_1.0_1.5.6.json (100%) rename tests/fixtures/{ => iot}/HS107(US)_1.0_1.0.8.json (100%) rename tests/fixtures/{ => iot}/HS110(EU)_1.0_1.2.5.json (100%) rename tests/fixtures/{ => iot}/HS110(EU)_4.0_1.0.4.json (100%) rename tests/fixtures/{ => iot}/HS110(US)_1.0_1.2.6.json (100%) rename tests/fixtures/{ => iot}/HS200(US)_2.0_1.5.7.json (100%) rename tests/fixtures/{ => iot}/HS200(US)_3.0_1.1.5.json (100%) rename tests/fixtures/{ => iot}/HS200(US)_5.0_1.0.11.json (100%) rename tests/fixtures/{ => iot}/HS200(US)_5.0_1.0.2.json (100%) rename tests/fixtures/{ => iot}/HS210(US)_1.0_1.5.8.json (100%) rename tests/fixtures/{ => iot}/HS210(US)_2.0_1.1.5.json (100%) rename tests/fixtures/{ => iot}/HS220(US)_1.0_1.5.7.json (100%) rename tests/fixtures/{ => iot}/HS220(US)_2.0_1.0.3.json (100%) rename tests/fixtures/{ => iot}/HS300(US)_1.0_1.0.10.json (100%) rename tests/fixtures/{ => iot}/HS300(US)_1.0_1.0.21.json (100%) rename tests/fixtures/{ => iot}/HS300(US)_2.0_1.0.12.json (100%) rename tests/fixtures/{ => iot}/HS300(US)_2.0_1.0.3.json (100%) rename tests/fixtures/{ => iot}/KL110(US)_1.0_1.8.11.json (100%) rename tests/fixtures/{ => iot}/KL120(US)_1.0_1.8.11.json (100%) rename tests/fixtures/{ => iot}/KL120(US)_1.0_1.8.6.json (100%) rename tests/fixtures/{ => iot}/KL125(US)_1.20_1.0.5.json (100%) rename tests/fixtures/{ => iot}/KL125(US)_2.0_1.0.7.json (100%) rename tests/fixtures/{ => iot}/KL125(US)_4.0_1.0.5.json (100%) rename tests/fixtures/{ => iot}/KL130(EU)_1.0_1.8.8.json (100%) rename tests/fixtures/{ => iot}/KL130(US)_1.0_1.8.11.json (100%) rename tests/fixtures/{ => iot}/KL135(US)_1.0_1.0.15.json (100%) rename tests/fixtures/{ => iot}/KL135(US)_1.0_1.0.6.json (100%) rename tests/fixtures/{ => iot}/KL400L5(US)_1.0_1.0.5.json (100%) rename tests/fixtures/{ => iot}/KL400L5(US)_1.0_1.0.8.json (100%) rename tests/fixtures/{ => iot}/KL420L5(US)_1.0_1.0.2.json (100%) rename tests/fixtures/{ => iot}/KL430(UN)_2.0_1.0.8.json (100%) rename tests/fixtures/{ => iot}/KL430(US)_1.0_1.0.10.json (100%) rename tests/fixtures/{ => iot}/KL430(US)_2.0_1.0.11.json (100%) rename tests/fixtures/{ => iot}/KL430(US)_2.0_1.0.8.json (100%) rename tests/fixtures/{ => iot}/KL430(US)_2.0_1.0.9.json (100%) rename tests/fixtures/{ => iot}/KL50(US)_1.0_1.1.13.json (100%) rename tests/fixtures/{ => iot}/KL60(UN)_1.0_1.1.4.json (100%) rename tests/fixtures/{ => iot}/KL60(US)_1.0_1.1.13.json (100%) rename tests/fixtures/{ => iot}/KP100(US)_3.0_1.0.1.json (100%) rename tests/fixtures/{ => iot}/KP105(UK)_1.0_1.0.5.json (100%) rename tests/fixtures/{ => iot}/KP105(UK)_1.0_1.0.7.json (100%) rename tests/fixtures/{ => iot}/KP115(EU)_1.0_1.0.16.json (100%) rename tests/fixtures/{ => iot}/KP115(US)_1.0_1.0.17.json (100%) rename tests/fixtures/{ => iot}/KP115(US)_1.0_1.0.21.json (100%) rename tests/fixtures/{ => iot}/KP125(US)_1.0_1.0.6.json (100%) rename tests/fixtures/{ => iot}/KP200(US)_3.0_1.0.3.json (100%) rename tests/fixtures/{ => iot}/KP303(UK)_1.0_1.0.3.json (100%) rename tests/fixtures/{ => iot}/KP303(US)_2.0_1.0.3.json (100%) rename tests/fixtures/{ => iot}/KP303(US)_2.0_1.0.9.json (100%) rename tests/fixtures/{ => iot}/KP400(US)_1.0_1.0.10.json (100%) rename tests/fixtures/{ => iot}/KP400(US)_2.0_1.0.6.json (100%) rename tests/fixtures/{ => iot}/KP400(US)_3.0_1.0.3.json (100%) rename tests/fixtures/{ => iot}/KP400(US)_3.0_1.0.4.json (100%) rename tests/fixtures/{ => iot}/KP401(US)_1.0_1.0.0.json (100%) rename tests/fixtures/{ => iot}/KP405(US)_1.0_1.0.5.json (100%) rename tests/fixtures/{ => iot}/KP405(US)_1.0_1.0.6.json (100%) rename tests/fixtures/{ => iot}/KS200M(US)_1.0_1.0.10.json (100%) rename tests/fixtures/{ => iot}/KS200M(US)_1.0_1.0.11.json (100%) rename tests/fixtures/{ => iot}/KS200M(US)_1.0_1.0.12.json (100%) rename tests/fixtures/{ => iot}/KS200M(US)_1.0_1.0.8.json (100%) rename tests/fixtures/{ => iot}/KS220(US)_1.0_1.0.13.json (100%) rename tests/fixtures/{ => iot}/KS220M(US)_1.0_1.0.4.json (100%) rename tests/fixtures/{ => iot}/KS230(US)_1.0_1.0.14.json (100%) rename tests/fixtures/{ => iot}/LB110(US)_1.0_1.8.11.json (100%) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index e0ada6796..3306387a2 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -56,7 +56,7 @@ SMART_FOLDER = "tests/fixtures/smart/" SMARTCAMERA_FOLDER = "tests/fixtures/smartcamera/" SMART_CHILD_FOLDER = "tests/fixtures/smart/child/" -IOT_FOLDER = "tests/fixtures/" +IOT_FOLDER = "tests/fixtures/iot/" ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py index 499f073c3..61e2e0fbe 100755 --- a/devtools/generate_supported.py +++ b/devtools/generate_supported.py @@ -45,7 +45,7 @@ class SupportedVersion(NamedTuple): SUPPORTED_FILENAME = "SUPPORTED.md" README_FILENAME = "README.md" -IOT_FOLDER = "tests/fixtures/" +IOT_FOLDER = "tests/fixtures/iot/" SMART_FOLDER = "tests/fixtures/smart/" SMART_CHILD_FOLDER = "tests/fixtures/smart/child" SMARTCAMERA_FOLDER = "tests/fixtures/smartcamera/" diff --git a/tests/fixtureinfo.py b/tests/fixtureinfo.py index d16c09f2d..cc7a5df4c 100644 --- a/tests/fixtureinfo.py +++ b/tests/fixtureinfo.py @@ -35,7 +35,7 @@ class ComponentFilter(NamedTuple): SUPPORTED_IOT_DEVICES = [ (device, "IOT") for device in glob.glob( - os.path.dirname(os.path.abspath(__file__)) + "/fixtures/*.json" + os.path.dirname(os.path.abspath(__file__)) + "/fixtures/iot/*.json" ) ] diff --git a/tests/fixtures/EP10(US)_1.0_1.0.2.json b/tests/fixtures/iot/EP10(US)_1.0_1.0.2.json similarity index 100% rename from tests/fixtures/EP10(US)_1.0_1.0.2.json rename to tests/fixtures/iot/EP10(US)_1.0_1.0.2.json diff --git a/tests/fixtures/EP40(US)_1.0_1.0.2.json b/tests/fixtures/iot/EP40(US)_1.0_1.0.2.json similarity index 100% rename from tests/fixtures/EP40(US)_1.0_1.0.2.json rename to tests/fixtures/iot/EP40(US)_1.0_1.0.2.json diff --git a/tests/fixtures/ES20M(US)_1.0_1.0.11.json b/tests/fixtures/iot/ES20M(US)_1.0_1.0.11.json similarity index 100% rename from tests/fixtures/ES20M(US)_1.0_1.0.11.json rename to tests/fixtures/iot/ES20M(US)_1.0_1.0.11.json diff --git a/tests/fixtures/ES20M(US)_1.0_1.0.8.json b/tests/fixtures/iot/ES20M(US)_1.0_1.0.8.json similarity index 100% rename from tests/fixtures/ES20M(US)_1.0_1.0.8.json rename to tests/fixtures/iot/ES20M(US)_1.0_1.0.8.json diff --git a/tests/fixtures/HS100(UK)_1.0_1.2.6.json b/tests/fixtures/iot/HS100(UK)_1.0_1.2.6.json similarity index 100% rename from tests/fixtures/HS100(UK)_1.0_1.2.6.json rename to tests/fixtures/iot/HS100(UK)_1.0_1.2.6.json diff --git a/tests/fixtures/HS100(UK)_4.1_1.1.0.json b/tests/fixtures/iot/HS100(UK)_4.1_1.1.0.json similarity index 100% rename from tests/fixtures/HS100(UK)_4.1_1.1.0.json rename to tests/fixtures/iot/HS100(UK)_4.1_1.1.0.json diff --git a/tests/fixtures/HS100(US)_1.0_1.2.5.json b/tests/fixtures/iot/HS100(US)_1.0_1.2.5.json similarity index 100% rename from tests/fixtures/HS100(US)_1.0_1.2.5.json rename to tests/fixtures/iot/HS100(US)_1.0_1.2.5.json diff --git a/tests/fixtures/HS100(US)_2.0_1.5.6.json b/tests/fixtures/iot/HS100(US)_2.0_1.5.6.json similarity index 100% rename from tests/fixtures/HS100(US)_2.0_1.5.6.json rename to tests/fixtures/iot/HS100(US)_2.0_1.5.6.json diff --git a/tests/fixtures/HS103(US)_1.0_1.5.7.json b/tests/fixtures/iot/HS103(US)_1.0_1.5.7.json similarity index 100% rename from tests/fixtures/HS103(US)_1.0_1.5.7.json rename to tests/fixtures/iot/HS103(US)_1.0_1.5.7.json diff --git a/tests/fixtures/HS103(US)_2.1_1.1.2.json b/tests/fixtures/iot/HS103(US)_2.1_1.1.2.json similarity index 100% rename from tests/fixtures/HS103(US)_2.1_1.1.2.json rename to tests/fixtures/iot/HS103(US)_2.1_1.1.2.json diff --git a/tests/fixtures/HS103(US)_2.1_1.1.4.json b/tests/fixtures/iot/HS103(US)_2.1_1.1.4.json similarity index 100% rename from tests/fixtures/HS103(US)_2.1_1.1.4.json rename to tests/fixtures/iot/HS103(US)_2.1_1.1.4.json diff --git a/tests/fixtures/HS105(US)_1.0_1.5.6.json b/tests/fixtures/iot/HS105(US)_1.0_1.5.6.json similarity index 100% rename from tests/fixtures/HS105(US)_1.0_1.5.6.json rename to tests/fixtures/iot/HS105(US)_1.0_1.5.6.json diff --git a/tests/fixtures/HS107(US)_1.0_1.0.8.json b/tests/fixtures/iot/HS107(US)_1.0_1.0.8.json similarity index 100% rename from tests/fixtures/HS107(US)_1.0_1.0.8.json rename to tests/fixtures/iot/HS107(US)_1.0_1.0.8.json diff --git a/tests/fixtures/HS110(EU)_1.0_1.2.5.json b/tests/fixtures/iot/HS110(EU)_1.0_1.2.5.json similarity index 100% rename from tests/fixtures/HS110(EU)_1.0_1.2.5.json rename to tests/fixtures/iot/HS110(EU)_1.0_1.2.5.json diff --git a/tests/fixtures/HS110(EU)_4.0_1.0.4.json b/tests/fixtures/iot/HS110(EU)_4.0_1.0.4.json similarity index 100% rename from tests/fixtures/HS110(EU)_4.0_1.0.4.json rename to tests/fixtures/iot/HS110(EU)_4.0_1.0.4.json diff --git a/tests/fixtures/HS110(US)_1.0_1.2.6.json b/tests/fixtures/iot/HS110(US)_1.0_1.2.6.json similarity index 100% rename from tests/fixtures/HS110(US)_1.0_1.2.6.json rename to tests/fixtures/iot/HS110(US)_1.0_1.2.6.json diff --git a/tests/fixtures/HS200(US)_2.0_1.5.7.json b/tests/fixtures/iot/HS200(US)_2.0_1.5.7.json similarity index 100% rename from tests/fixtures/HS200(US)_2.0_1.5.7.json rename to tests/fixtures/iot/HS200(US)_2.0_1.5.7.json diff --git a/tests/fixtures/HS200(US)_3.0_1.1.5.json b/tests/fixtures/iot/HS200(US)_3.0_1.1.5.json similarity index 100% rename from tests/fixtures/HS200(US)_3.0_1.1.5.json rename to tests/fixtures/iot/HS200(US)_3.0_1.1.5.json diff --git a/tests/fixtures/HS200(US)_5.0_1.0.11.json b/tests/fixtures/iot/HS200(US)_5.0_1.0.11.json similarity index 100% rename from tests/fixtures/HS200(US)_5.0_1.0.11.json rename to tests/fixtures/iot/HS200(US)_5.0_1.0.11.json diff --git a/tests/fixtures/HS200(US)_5.0_1.0.2.json b/tests/fixtures/iot/HS200(US)_5.0_1.0.2.json similarity index 100% rename from tests/fixtures/HS200(US)_5.0_1.0.2.json rename to tests/fixtures/iot/HS200(US)_5.0_1.0.2.json diff --git a/tests/fixtures/HS210(US)_1.0_1.5.8.json b/tests/fixtures/iot/HS210(US)_1.0_1.5.8.json similarity index 100% rename from tests/fixtures/HS210(US)_1.0_1.5.8.json rename to tests/fixtures/iot/HS210(US)_1.0_1.5.8.json diff --git a/tests/fixtures/HS210(US)_2.0_1.1.5.json b/tests/fixtures/iot/HS210(US)_2.0_1.1.5.json similarity index 100% rename from tests/fixtures/HS210(US)_2.0_1.1.5.json rename to tests/fixtures/iot/HS210(US)_2.0_1.1.5.json diff --git a/tests/fixtures/HS220(US)_1.0_1.5.7.json b/tests/fixtures/iot/HS220(US)_1.0_1.5.7.json similarity index 100% rename from tests/fixtures/HS220(US)_1.0_1.5.7.json rename to tests/fixtures/iot/HS220(US)_1.0_1.5.7.json diff --git a/tests/fixtures/HS220(US)_2.0_1.0.3.json b/tests/fixtures/iot/HS220(US)_2.0_1.0.3.json similarity index 100% rename from tests/fixtures/HS220(US)_2.0_1.0.3.json rename to tests/fixtures/iot/HS220(US)_2.0_1.0.3.json diff --git a/tests/fixtures/HS300(US)_1.0_1.0.10.json b/tests/fixtures/iot/HS300(US)_1.0_1.0.10.json similarity index 100% rename from tests/fixtures/HS300(US)_1.0_1.0.10.json rename to tests/fixtures/iot/HS300(US)_1.0_1.0.10.json diff --git a/tests/fixtures/HS300(US)_1.0_1.0.21.json b/tests/fixtures/iot/HS300(US)_1.0_1.0.21.json similarity index 100% rename from tests/fixtures/HS300(US)_1.0_1.0.21.json rename to tests/fixtures/iot/HS300(US)_1.0_1.0.21.json diff --git a/tests/fixtures/HS300(US)_2.0_1.0.12.json b/tests/fixtures/iot/HS300(US)_2.0_1.0.12.json similarity index 100% rename from tests/fixtures/HS300(US)_2.0_1.0.12.json rename to tests/fixtures/iot/HS300(US)_2.0_1.0.12.json diff --git a/tests/fixtures/HS300(US)_2.0_1.0.3.json b/tests/fixtures/iot/HS300(US)_2.0_1.0.3.json similarity index 100% rename from tests/fixtures/HS300(US)_2.0_1.0.3.json rename to tests/fixtures/iot/HS300(US)_2.0_1.0.3.json diff --git a/tests/fixtures/KL110(US)_1.0_1.8.11.json b/tests/fixtures/iot/KL110(US)_1.0_1.8.11.json similarity index 100% rename from tests/fixtures/KL110(US)_1.0_1.8.11.json rename to tests/fixtures/iot/KL110(US)_1.0_1.8.11.json diff --git a/tests/fixtures/KL120(US)_1.0_1.8.11.json b/tests/fixtures/iot/KL120(US)_1.0_1.8.11.json similarity index 100% rename from tests/fixtures/KL120(US)_1.0_1.8.11.json rename to tests/fixtures/iot/KL120(US)_1.0_1.8.11.json diff --git a/tests/fixtures/KL120(US)_1.0_1.8.6.json b/tests/fixtures/iot/KL120(US)_1.0_1.8.6.json similarity index 100% rename from tests/fixtures/KL120(US)_1.0_1.8.6.json rename to tests/fixtures/iot/KL120(US)_1.0_1.8.6.json diff --git a/tests/fixtures/KL125(US)_1.20_1.0.5.json b/tests/fixtures/iot/KL125(US)_1.20_1.0.5.json similarity index 100% rename from tests/fixtures/KL125(US)_1.20_1.0.5.json rename to tests/fixtures/iot/KL125(US)_1.20_1.0.5.json diff --git a/tests/fixtures/KL125(US)_2.0_1.0.7.json b/tests/fixtures/iot/KL125(US)_2.0_1.0.7.json similarity index 100% rename from tests/fixtures/KL125(US)_2.0_1.0.7.json rename to tests/fixtures/iot/KL125(US)_2.0_1.0.7.json diff --git a/tests/fixtures/KL125(US)_4.0_1.0.5.json b/tests/fixtures/iot/KL125(US)_4.0_1.0.5.json similarity index 100% rename from tests/fixtures/KL125(US)_4.0_1.0.5.json rename to tests/fixtures/iot/KL125(US)_4.0_1.0.5.json diff --git a/tests/fixtures/KL130(EU)_1.0_1.8.8.json b/tests/fixtures/iot/KL130(EU)_1.0_1.8.8.json similarity index 100% rename from tests/fixtures/KL130(EU)_1.0_1.8.8.json rename to tests/fixtures/iot/KL130(EU)_1.0_1.8.8.json diff --git a/tests/fixtures/KL130(US)_1.0_1.8.11.json b/tests/fixtures/iot/KL130(US)_1.0_1.8.11.json similarity index 100% rename from tests/fixtures/KL130(US)_1.0_1.8.11.json rename to tests/fixtures/iot/KL130(US)_1.0_1.8.11.json diff --git a/tests/fixtures/KL135(US)_1.0_1.0.15.json b/tests/fixtures/iot/KL135(US)_1.0_1.0.15.json similarity index 100% rename from tests/fixtures/KL135(US)_1.0_1.0.15.json rename to tests/fixtures/iot/KL135(US)_1.0_1.0.15.json diff --git a/tests/fixtures/KL135(US)_1.0_1.0.6.json b/tests/fixtures/iot/KL135(US)_1.0_1.0.6.json similarity index 100% rename from tests/fixtures/KL135(US)_1.0_1.0.6.json rename to tests/fixtures/iot/KL135(US)_1.0_1.0.6.json diff --git a/tests/fixtures/KL400L5(US)_1.0_1.0.5.json b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json similarity index 100% rename from tests/fixtures/KL400L5(US)_1.0_1.0.5.json rename to tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json diff --git a/tests/fixtures/KL400L5(US)_1.0_1.0.8.json b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.8.json similarity index 100% rename from tests/fixtures/KL400L5(US)_1.0_1.0.8.json rename to tests/fixtures/iot/KL400L5(US)_1.0_1.0.8.json diff --git a/tests/fixtures/KL420L5(US)_1.0_1.0.2.json b/tests/fixtures/iot/KL420L5(US)_1.0_1.0.2.json similarity index 100% rename from tests/fixtures/KL420L5(US)_1.0_1.0.2.json rename to tests/fixtures/iot/KL420L5(US)_1.0_1.0.2.json diff --git a/tests/fixtures/KL430(UN)_2.0_1.0.8.json b/tests/fixtures/iot/KL430(UN)_2.0_1.0.8.json similarity index 100% rename from tests/fixtures/KL430(UN)_2.0_1.0.8.json rename to tests/fixtures/iot/KL430(UN)_2.0_1.0.8.json diff --git a/tests/fixtures/KL430(US)_1.0_1.0.10.json b/tests/fixtures/iot/KL430(US)_1.0_1.0.10.json similarity index 100% rename from tests/fixtures/KL430(US)_1.0_1.0.10.json rename to tests/fixtures/iot/KL430(US)_1.0_1.0.10.json diff --git a/tests/fixtures/KL430(US)_2.0_1.0.11.json b/tests/fixtures/iot/KL430(US)_2.0_1.0.11.json similarity index 100% rename from tests/fixtures/KL430(US)_2.0_1.0.11.json rename to tests/fixtures/iot/KL430(US)_2.0_1.0.11.json diff --git a/tests/fixtures/KL430(US)_2.0_1.0.8.json b/tests/fixtures/iot/KL430(US)_2.0_1.0.8.json similarity index 100% rename from tests/fixtures/KL430(US)_2.0_1.0.8.json rename to tests/fixtures/iot/KL430(US)_2.0_1.0.8.json diff --git a/tests/fixtures/KL430(US)_2.0_1.0.9.json b/tests/fixtures/iot/KL430(US)_2.0_1.0.9.json similarity index 100% rename from tests/fixtures/KL430(US)_2.0_1.0.9.json rename to tests/fixtures/iot/KL430(US)_2.0_1.0.9.json diff --git a/tests/fixtures/KL50(US)_1.0_1.1.13.json b/tests/fixtures/iot/KL50(US)_1.0_1.1.13.json similarity index 100% rename from tests/fixtures/KL50(US)_1.0_1.1.13.json rename to tests/fixtures/iot/KL50(US)_1.0_1.1.13.json diff --git a/tests/fixtures/KL60(UN)_1.0_1.1.4.json b/tests/fixtures/iot/KL60(UN)_1.0_1.1.4.json similarity index 100% rename from tests/fixtures/KL60(UN)_1.0_1.1.4.json rename to tests/fixtures/iot/KL60(UN)_1.0_1.1.4.json diff --git a/tests/fixtures/KL60(US)_1.0_1.1.13.json b/tests/fixtures/iot/KL60(US)_1.0_1.1.13.json similarity index 100% rename from tests/fixtures/KL60(US)_1.0_1.1.13.json rename to tests/fixtures/iot/KL60(US)_1.0_1.1.13.json diff --git a/tests/fixtures/KP100(US)_3.0_1.0.1.json b/tests/fixtures/iot/KP100(US)_3.0_1.0.1.json similarity index 100% rename from tests/fixtures/KP100(US)_3.0_1.0.1.json rename to tests/fixtures/iot/KP100(US)_3.0_1.0.1.json diff --git a/tests/fixtures/KP105(UK)_1.0_1.0.5.json b/tests/fixtures/iot/KP105(UK)_1.0_1.0.5.json similarity index 100% rename from tests/fixtures/KP105(UK)_1.0_1.0.5.json rename to tests/fixtures/iot/KP105(UK)_1.0_1.0.5.json diff --git a/tests/fixtures/KP105(UK)_1.0_1.0.7.json b/tests/fixtures/iot/KP105(UK)_1.0_1.0.7.json similarity index 100% rename from tests/fixtures/KP105(UK)_1.0_1.0.7.json rename to tests/fixtures/iot/KP105(UK)_1.0_1.0.7.json diff --git a/tests/fixtures/KP115(EU)_1.0_1.0.16.json b/tests/fixtures/iot/KP115(EU)_1.0_1.0.16.json similarity index 100% rename from tests/fixtures/KP115(EU)_1.0_1.0.16.json rename to tests/fixtures/iot/KP115(EU)_1.0_1.0.16.json diff --git a/tests/fixtures/KP115(US)_1.0_1.0.17.json b/tests/fixtures/iot/KP115(US)_1.0_1.0.17.json similarity index 100% rename from tests/fixtures/KP115(US)_1.0_1.0.17.json rename to tests/fixtures/iot/KP115(US)_1.0_1.0.17.json diff --git a/tests/fixtures/KP115(US)_1.0_1.0.21.json b/tests/fixtures/iot/KP115(US)_1.0_1.0.21.json similarity index 100% rename from tests/fixtures/KP115(US)_1.0_1.0.21.json rename to tests/fixtures/iot/KP115(US)_1.0_1.0.21.json diff --git a/tests/fixtures/KP125(US)_1.0_1.0.6.json b/tests/fixtures/iot/KP125(US)_1.0_1.0.6.json similarity index 100% rename from tests/fixtures/KP125(US)_1.0_1.0.6.json rename to tests/fixtures/iot/KP125(US)_1.0_1.0.6.json diff --git a/tests/fixtures/KP200(US)_3.0_1.0.3.json b/tests/fixtures/iot/KP200(US)_3.0_1.0.3.json similarity index 100% rename from tests/fixtures/KP200(US)_3.0_1.0.3.json rename to tests/fixtures/iot/KP200(US)_3.0_1.0.3.json diff --git a/tests/fixtures/KP303(UK)_1.0_1.0.3.json b/tests/fixtures/iot/KP303(UK)_1.0_1.0.3.json similarity index 100% rename from tests/fixtures/KP303(UK)_1.0_1.0.3.json rename to tests/fixtures/iot/KP303(UK)_1.0_1.0.3.json diff --git a/tests/fixtures/KP303(US)_2.0_1.0.3.json b/tests/fixtures/iot/KP303(US)_2.0_1.0.3.json similarity index 100% rename from tests/fixtures/KP303(US)_2.0_1.0.3.json rename to tests/fixtures/iot/KP303(US)_2.0_1.0.3.json diff --git a/tests/fixtures/KP303(US)_2.0_1.0.9.json b/tests/fixtures/iot/KP303(US)_2.0_1.0.9.json similarity index 100% rename from tests/fixtures/KP303(US)_2.0_1.0.9.json rename to tests/fixtures/iot/KP303(US)_2.0_1.0.9.json diff --git a/tests/fixtures/KP400(US)_1.0_1.0.10.json b/tests/fixtures/iot/KP400(US)_1.0_1.0.10.json similarity index 100% rename from tests/fixtures/KP400(US)_1.0_1.0.10.json rename to tests/fixtures/iot/KP400(US)_1.0_1.0.10.json diff --git a/tests/fixtures/KP400(US)_2.0_1.0.6.json b/tests/fixtures/iot/KP400(US)_2.0_1.0.6.json similarity index 100% rename from tests/fixtures/KP400(US)_2.0_1.0.6.json rename to tests/fixtures/iot/KP400(US)_2.0_1.0.6.json diff --git a/tests/fixtures/KP400(US)_3.0_1.0.3.json b/tests/fixtures/iot/KP400(US)_3.0_1.0.3.json similarity index 100% rename from tests/fixtures/KP400(US)_3.0_1.0.3.json rename to tests/fixtures/iot/KP400(US)_3.0_1.0.3.json diff --git a/tests/fixtures/KP400(US)_3.0_1.0.4.json b/tests/fixtures/iot/KP400(US)_3.0_1.0.4.json similarity index 100% rename from tests/fixtures/KP400(US)_3.0_1.0.4.json rename to tests/fixtures/iot/KP400(US)_3.0_1.0.4.json diff --git a/tests/fixtures/KP401(US)_1.0_1.0.0.json b/tests/fixtures/iot/KP401(US)_1.0_1.0.0.json similarity index 100% rename from tests/fixtures/KP401(US)_1.0_1.0.0.json rename to tests/fixtures/iot/KP401(US)_1.0_1.0.0.json diff --git a/tests/fixtures/KP405(US)_1.0_1.0.5.json b/tests/fixtures/iot/KP405(US)_1.0_1.0.5.json similarity index 100% rename from tests/fixtures/KP405(US)_1.0_1.0.5.json rename to tests/fixtures/iot/KP405(US)_1.0_1.0.5.json diff --git a/tests/fixtures/KP405(US)_1.0_1.0.6.json b/tests/fixtures/iot/KP405(US)_1.0_1.0.6.json similarity index 100% rename from tests/fixtures/KP405(US)_1.0_1.0.6.json rename to tests/fixtures/iot/KP405(US)_1.0_1.0.6.json diff --git a/tests/fixtures/KS200M(US)_1.0_1.0.10.json b/tests/fixtures/iot/KS200M(US)_1.0_1.0.10.json similarity index 100% rename from tests/fixtures/KS200M(US)_1.0_1.0.10.json rename to tests/fixtures/iot/KS200M(US)_1.0_1.0.10.json diff --git a/tests/fixtures/KS200M(US)_1.0_1.0.11.json b/tests/fixtures/iot/KS200M(US)_1.0_1.0.11.json similarity index 100% rename from tests/fixtures/KS200M(US)_1.0_1.0.11.json rename to tests/fixtures/iot/KS200M(US)_1.0_1.0.11.json diff --git a/tests/fixtures/KS200M(US)_1.0_1.0.12.json b/tests/fixtures/iot/KS200M(US)_1.0_1.0.12.json similarity index 100% rename from tests/fixtures/KS200M(US)_1.0_1.0.12.json rename to tests/fixtures/iot/KS200M(US)_1.0_1.0.12.json diff --git a/tests/fixtures/KS200M(US)_1.0_1.0.8.json b/tests/fixtures/iot/KS200M(US)_1.0_1.0.8.json similarity index 100% rename from tests/fixtures/KS200M(US)_1.0_1.0.8.json rename to tests/fixtures/iot/KS200M(US)_1.0_1.0.8.json diff --git a/tests/fixtures/KS220(US)_1.0_1.0.13.json b/tests/fixtures/iot/KS220(US)_1.0_1.0.13.json similarity index 100% rename from tests/fixtures/KS220(US)_1.0_1.0.13.json rename to tests/fixtures/iot/KS220(US)_1.0_1.0.13.json diff --git a/tests/fixtures/KS220M(US)_1.0_1.0.4.json b/tests/fixtures/iot/KS220M(US)_1.0_1.0.4.json similarity index 100% rename from tests/fixtures/KS220M(US)_1.0_1.0.4.json rename to tests/fixtures/iot/KS220M(US)_1.0_1.0.4.json diff --git a/tests/fixtures/KS230(US)_1.0_1.0.14.json b/tests/fixtures/iot/KS230(US)_1.0_1.0.14.json similarity index 100% rename from tests/fixtures/KS230(US)_1.0_1.0.14.json rename to tests/fixtures/iot/KS230(US)_1.0_1.0.14.json diff --git a/tests/fixtures/LB110(US)_1.0_1.8.11.json b/tests/fixtures/iot/LB110(US)_1.0_1.8.11.json similarity index 100% rename from tests/fixtures/LB110(US)_1.0_1.8.11.json rename to tests/fixtures/iot/LB110(US)_1.0_1.8.11.json From b525d6a35c370a441535df172bdc522726af574e Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 22 Nov 2024 20:21:29 +0000 Subject: [PATCH 710/892] Annotate fan_speed_level of Fan interface (#1298) --- kasa/interfaces/fan.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/kasa/interfaces/fan.py b/kasa/interfaces/fan.py index ade009286..9462ad882 100644 --- a/kasa/interfaces/fan.py +++ b/kasa/interfaces/fan.py @@ -3,8 +3,9 @@ from __future__ import annotations from abc import ABC, abstractmethod +from typing import Annotated -from ..module import Module +from ..module import FeatureAttribute, Module class Fan(Module, ABC): @@ -12,9 +13,11 @@ class Fan(Module, ABC): @property @abstractmethod - def fan_speed_level(self) -> int: + def fan_speed_level(self) -> Annotated[int, FeatureAttribute()]: """Return fan speed level.""" @abstractmethod - async def set_fan_speed_level(self, level: int) -> dict: + async def set_fan_speed_level( + self, level: int + ) -> Annotated[dict, FeatureAttribute()]: """Set fan speed level.""" From 2bda54fcb15ecb8d590b4c5046daea04951c0ba7 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sat, 23 Nov 2024 08:07:47 +0000 Subject: [PATCH 711/892] Rename smartcamera to smartcam (#1300) --- devtools/dump_devinfo.py | 24 ++++++------- devtools/generate_supported.py | 8 ++--- ...tcamerarequests.py => smartcamrequests.py} | 2 +- kasa/device_factory.py | 10 +++--- kasa/module.py | 6 ++-- ...tcameraprotocol.py => smartcamprotocol.py} | 8 ++--- kasa/protocols/smartprotocol.py | 2 +- kasa/smartcam/__init__.py | 5 +++ .../modules/__init__.py | 2 +- .../modules/alarm.py | 6 ++-- .../modules/camera.py | 4 +-- .../modules/childdevice.py | 4 +-- .../modules/device.py | 4 +-- kasa/{smartcamera => smartcam}/modules/led.py | 4 +-- .../modules/pantilt.py | 4 +-- .../{smartcamera => smartcam}/modules/time.py | 4 +-- .../smartcamdevice.py} | 14 ++++---- .../smartcammodule.py} | 10 +++--- kasa/smartcamera/__init__.py | 5 --- tests/device_fixtures.py | 34 +++++++++---------- tests/discovery_fixtures.py | 10 +++--- ...martcamera.py => fakeprotocol_smartcam.py} | 8 ++--- tests/fixtureinfo.py | 16 ++++----- .../C210(EU)_2.0_1.4.2.json | 0 .../C210(EU)_2.0_1.4.3.json | 0 .../H200(EU)_1.0_1.3.2.json | 0 .../H200(US)_1.0_1.3.6.json | 0 .../TC65_1.0_1.3.9.json | 0 tests/smartcamera/modules/test_alarm.py | 18 +++++----- tests/smartcamera/test_smartcamera.py | 12 +++---- tests/test_cli.py | 8 ++--- tests/test_device.py | 4 +-- tests/test_device_factory.py | 6 ++-- tests/test_devtools.py | 18 +++++----- 34 files changed, 130 insertions(+), 130 deletions(-) rename devtools/helpers/{smartcamerarequests.py => smartcamrequests.py} (98%) rename kasa/protocols/{smartcameraprotocol.py => smartcamprotocol.py} (97%) create mode 100644 kasa/smartcam/__init__.py rename kasa/{smartcamera => smartcam}/modules/__init__.py (88%) rename kasa/{smartcamera => smartcam}/modules/alarm.py (97%) rename kasa/{smartcamera => smartcam}/modules/camera.py (97%) rename kasa/{smartcamera => smartcam}/modules/childdevice.py (90%) rename kasa/{smartcamera => smartcam}/modules/device.py (92%) rename kasa/{smartcamera => smartcam}/modules/led.py (88%) rename kasa/{smartcamera => smartcam}/modules/pantilt.py (97%) rename kasa/{smartcamera => smartcam}/modules/time.py (96%) rename kasa/{smartcamera/smartcamera.py => smartcam/smartcamdevice.py} (96%) rename kasa/{smartcamera/smartcameramodule.py => smartcam/smartcammodule.py} (92%) delete mode 100644 kasa/smartcamera/__init__.py rename tests/{fakeprotocol_smartcamera.py => fakeprotocol_smartcam.py} (97%) rename tests/fixtures/{smartcamera => smartcam}/C210(EU)_2.0_1.4.2.json (100%) rename tests/fixtures/{smartcamera => smartcam}/C210(EU)_2.0_1.4.3.json (100%) rename tests/fixtures/{smartcamera => smartcam}/H200(EU)_1.0_1.3.2.json (100%) rename tests/fixtures/{smartcamera => smartcam}/H200(US)_1.0_1.3.6.json (100%) rename tests/fixtures/{smartcamera => smartcam}/TC65_1.0_1.3.9.json (100%) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 3306387a2..18005990f 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -25,7 +25,7 @@ import asyncclick as click -from devtools.helpers.smartcamerarequests import SMARTCAMERA_REQUESTS +from devtools.helpers.smartcamrequests import SMARTCAM_REQUESTS from devtools.helpers.smartrequests import SmartRequest, get_component_requests from kasa import ( AuthenticationError, @@ -42,19 +42,19 @@ from kasa.discover import DiscoveryResult from kasa.exceptions import SmartErrorCode from kasa.protocols import IotProtocol -from kasa.protocols.smartcameraprotocol import ( - SmartCameraProtocol, +from kasa.protocols.smartcamprotocol import ( + SmartCamProtocol, _ChildCameraProtocolWrapper, ) from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.smart import SmartChildDevice, SmartDevice -from kasa.smartcamera import SmartCamera +from kasa.smartcam import SmartCamDevice Call = namedtuple("Call", "module method") FixtureResult = namedtuple("FixtureResult", "filename, folder, data") SMART_FOLDER = "tests/fixtures/smart/" -SMARTCAMERA_FOLDER = "tests/fixtures/smartcamera/" +SMARTCAM_FOLDER = "tests/fixtures/smartcam/" SMART_CHILD_FOLDER = "tests/fixtures/smart/child/" IOT_FOLDER = "tests/fixtures/iot/" @@ -65,7 +65,7 @@ @dataclasses.dataclass class SmartCall: - """Class for smart and smartcamera calls.""" + """Class for smart and smartcam calls.""" module: str request: dict @@ -562,7 +562,7 @@ async def _make_requests_or_exit( # Calling close on child protocol wrappers is a noop protocol_to_close = protocol if child_device_id: - if isinstance(protocol, SmartCameraProtocol): + if isinstance(protocol, SmartCamProtocol): protocol = _ChildCameraProtocolWrapper(child_device_id, protocol) else: protocol = _ChildProtocolWrapper(child_device_id, protocol) @@ -608,7 +608,7 @@ async def get_smart_camera_test_calls(protocol: SmartProtocol): successes: list[SmartCall] = [] test_calls = [] - for request in SMARTCAMERA_REQUESTS: + for request in SMARTCAM_REQUESTS: method = next(iter(request)) if method == "get": module = method + "_" + next(iter(request[method])) @@ -693,7 +693,7 @@ async def get_smart_camera_test_calls(protocol: SmartProtocol): click.echo(f"Skipping {component_id}..", nl=False) click.echo(click.style("UNSUPPORTED", fg="yellow")) else: # Not a smart protocol device so assume camera protocol - for request in SMARTCAMERA_REQUESTS: + for request in SMARTCAM_REQUESTS: method = next(iter(request)) if method == "get": method = method + "_" + next(iter(request[method])) @@ -858,7 +858,7 @@ async def get_smart_fixtures( protocol: SmartProtocol, *, discovery_info: dict[str, Any] | None, batch_size: int ) -> list[FixtureResult]: """Get fixture for new TAPO style protocol.""" - if isinstance(protocol, SmartCameraProtocol): + if isinstance(protocol, SmartCamProtocol): test_calls, successes = await get_smart_camera_test_calls(protocol) child_wrapper: type[_ChildProtocolWrapper | _ChildCameraProtocolWrapper] = ( _ChildCameraProtocolWrapper @@ -991,8 +991,8 @@ async def get_smart_fixtures( copy_folder = SMART_FOLDER else: # smart camera protocol - model_info = SmartCamera._get_device_info(final, discovery_info) - copy_folder = SMARTCAMERA_FOLDER + model_info = SmartCamDevice._get_device_info(final, discovery_info) + copy_folder = SMARTCAM_FOLDER hw_version = model_info.hardware_version sw_version = model_info.firmware_version model = model_info.long_name diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py index 61e2e0fbe..90e16e073 100755 --- a/devtools/generate_supported.py +++ b/devtools/generate_supported.py @@ -13,7 +13,7 @@ from kasa.device_type import DeviceType from kasa.iot import IotDevice from kasa.smart import SmartDevice -from kasa.smartcamera import SmartCamera +from kasa.smartcam import SmartCamDevice class SupportedVersion(NamedTuple): @@ -48,7 +48,7 @@ class SupportedVersion(NamedTuple): IOT_FOLDER = "tests/fixtures/iot/" SMART_FOLDER = "tests/fixtures/smart/" SMART_CHILD_FOLDER = "tests/fixtures/smart/child" -SMARTCAMERA_FOLDER = "tests/fixtures/smartcamera/" +SMARTCAM_FOLDER = "tests/fixtures/smartcam/" def generate_supported(args): @@ -65,7 +65,7 @@ def generate_supported(args): _get_supported_devices(supported, IOT_FOLDER, IotDevice) _get_supported_devices(supported, SMART_FOLDER, SmartDevice) _get_supported_devices(supported, SMART_CHILD_FOLDER, SmartDevice) - _get_supported_devices(supported, SMARTCAMERA_FOLDER, SmartCamera) + _get_supported_devices(supported, SMARTCAM_FOLDER, SmartCamDevice) readme_updated = _update_supported_file( README_FILENAME, _supported_summary(supported), print_diffs @@ -208,7 +208,7 @@ def _supported_text( def _get_supported_devices( supported: dict[str, Any], fixture_location: str, - device_cls: type[IotDevice | SmartDevice | SmartCamera], + device_cls: type[IotDevice | SmartDevice | SmartCamDevice], ): for file in Path(fixture_location).glob("*.json"): with file.open() as f: diff --git a/devtools/helpers/smartcamerarequests.py b/devtools/helpers/smartcamrequests.py similarity index 98% rename from devtools/helpers/smartcamerarequests.py rename to devtools/helpers/smartcamrequests.py index 2779ac0e5..074b5774d 100644 --- a/devtools/helpers/smartcamerarequests.py +++ b/devtools/helpers/smartcamrequests.py @@ -2,7 +2,7 @@ from __future__ import annotations -SMARTCAMERA_REQUESTS: list[dict] = [ +SMARTCAM_REQUESTS: list[dict] = [ {"getAlertTypeList": {"msg_alarm": {"name": "alert_type"}}}, {"getNightVisionCapability": {"image_capability": {"name": ["supplement_lamp"]}}}, {"getDeviceInfo": {"device_info": {"name": ["basic_info"]}}}, diff --git a/kasa/device_factory.py b/kasa/device_factory.py index f0b90b6ef..d7ba5b532 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -24,9 +24,9 @@ IotProtocol, SmartProtocol, ) -from .protocols.smartcameraprotocol import SmartCameraProtocol +from .protocols.smartcamprotocol import SmartCamProtocol from .smart import SmartDevice -from .smartcamera.smartcamera import SmartCamera +from .smartcam import SmartCamDevice from .transports import ( AesTransport, BaseTransport, @@ -151,10 +151,10 @@ def get_device_class_from_family( "SMART.TAPOSWITCH": SmartDevice, "SMART.KASAPLUG": SmartDevice, "SMART.TAPOHUB": SmartDevice, - "SMART.TAPOHUB.HTTPS": SmartCamera, + "SMART.TAPOHUB.HTTPS": SmartCamDevice, "SMART.KASAHUB": SmartDevice, "SMART.KASASWITCH": SmartDevice, - "SMART.IPCAMERA.HTTPS": SmartCamera, + "SMART.IPCAMERA.HTTPS": SmartCamDevice, "IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTBULB": IotBulb, } @@ -189,7 +189,7 @@ def get_protocol( "IOT.KLAP": (IotProtocol, KlapTransport), "SMART.AES": (SmartProtocol, AesTransport), "SMART.KLAP": (SmartProtocol, KlapTransportV2), - "SMART.AES.HTTPS": (SmartCameraProtocol, SslAesTransport), + "SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport), } if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)): return None diff --git a/kasa/module.py b/kasa/module.py index ccd22d4e0..9f2685778 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -60,7 +60,7 @@ from .device import Device from .iot import modules as iot from .smart import modules as smart - from .smartcamera import modules as smartcamera + from .smartcam import modules as smartcam _LOGGER = logging.getLogger(__name__) @@ -139,8 +139,8 @@ class Module(ABC): ) TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs") - # SMARTCAMERA only modules - Camera: Final[ModuleName[smartcamera.Camera]] = ModuleName("Camera") + # SMARTCAM only modules + Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera") def __init__(self, device: Device, module: str) -> None: self._device = device diff --git a/kasa/protocols/smartcameraprotocol.py b/kasa/protocols/smartcamprotocol.py similarity index 97% rename from kasa/protocols/smartcameraprotocol.py rename to kasa/protocols/smartcamprotocol.py index 57f78d408..12caa207b 100644 --- a/kasa/protocols/smartcameraprotocol.py +++ b/kasa/protocols/smartcamprotocol.py @@ -1,4 +1,4 @@ -"""Module for SmartCamera Protocol.""" +"""Module for SmartCamProtocol.""" from __future__ import annotations @@ -46,8 +46,8 @@ class SingleRequest: request: dict[str, Any] -class SmartCameraProtocol(SmartProtocol): - """Class for SmartCamera Protocol.""" +class SmartCamProtocol(SmartProtocol): + """Class for SmartCam Protocol.""" async def _handle_response_lists( self, response_result: dict[str, Any], method: str, retry_count: int @@ -123,7 +123,7 @@ def _make_smart_camera_single_request( """ method = request method_type = request[:3] - snake_name = SmartCameraProtocol._make_snake_name(request) + snake_name = SmartCamProtocol._make_snake_name(request) param = snake_name[4:] if ( (short_method := method[:3]) diff --git a/kasa/protocols/smartprotocol.py b/kasa/protocols/smartprotocol.py index d3fd9bfda..80e76ca6e 100644 --- a/kasa/protocols/smartprotocol.py +++ b/kasa/protocols/smartprotocol.py @@ -168,7 +168,7 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic ] end = len(multi_requests) - # The SmartCameraProtocol sends requests with a length 1 as a + # The SmartCamProtocol sends requests with a length 1 as a # multipleRequest. The SmartProtocol doesn't so will never # raise_on_error raise_on_error = end == 1 diff --git a/kasa/smartcam/__init__.py b/kasa/smartcam/__init__.py new file mode 100644 index 000000000..574459f46 --- /dev/null +++ b/kasa/smartcam/__init__.py @@ -0,0 +1,5 @@ +"""Package for supporting tapo-branded cameras.""" + +from .smartcamdevice import SmartCamDevice + +__all__ = ["SmartCamDevice"] diff --git a/kasa/smartcamera/modules/__init__.py b/kasa/smartcam/modules/__init__.py similarity index 88% rename from kasa/smartcamera/modules/__init__.py rename to kasa/smartcam/modules/__init__.py index 462241e80..16d595811 100644 --- a/kasa/smartcamera/modules/__init__.py +++ b/kasa/smartcam/modules/__init__.py @@ -1,4 +1,4 @@ -"""Modules for SMARTCAMERA devices.""" +"""Modules for SMARTCAM devices.""" from .alarm import Alarm from .camera import Camera diff --git a/kasa/smartcamera/modules/alarm.py b/kasa/smartcam/modules/alarm.py similarity index 97% rename from kasa/smartcamera/modules/alarm.py rename to kasa/smartcam/modules/alarm.py index bf7ce1a58..12d434645 100644 --- a/kasa/smartcamera/modules/alarm.py +++ b/kasa/smartcam/modules/alarm.py @@ -3,7 +3,7 @@ from __future__ import annotations from ...feature import Feature -from ..smartcameramodule import SmartCameraModule +from ..smartcammodule import SmartCamModule DURATION_MIN = 0 DURATION_MAX = 6000 @@ -12,11 +12,11 @@ VOLUME_MAX = 10 -class Alarm(SmartCameraModule): +class Alarm(SmartCamModule): """Implementation of alarm module.""" # Needs a different name to avoid clashing with SmartAlarm - NAME = "SmartCameraAlarm" + NAME = "SmartCamAlarm" REQUIRED_COMPONENT = "siren" QUERY_GETTER_NAME = "getSirenStatus" diff --git a/kasa/smartcamera/modules/camera.py b/kasa/smartcam/modules/camera.py similarity index 97% rename from kasa/smartcamera/modules/camera.py rename to kasa/smartcam/modules/camera.py index 65f434d15..815db62bb 100644 --- a/kasa/smartcamera/modules/camera.py +++ b/kasa/smartcam/modules/camera.py @@ -10,14 +10,14 @@ from ...device_type import DeviceType from ...feature import Feature from ...json import loads as json_loads -from ..smartcameramodule import SmartCameraModule +from ..smartcammodule import SmartCamModule _LOGGER = logging.getLogger(__name__) LOCAL_STREAMING_PORT = 554 -class Camera(SmartCameraModule): +class Camera(SmartCamModule): """Implementation of device module.""" QUERY_GETTER_NAME = "getLensMaskConfig" diff --git a/kasa/smartcamera/modules/childdevice.py b/kasa/smartcam/modules/childdevice.py similarity index 90% rename from kasa/smartcamera/modules/childdevice.py rename to kasa/smartcam/modules/childdevice.py index 81905fbfc..c4de58385 100644 --- a/kasa/smartcamera/modules/childdevice.py +++ b/kasa/smartcam/modules/childdevice.py @@ -1,10 +1,10 @@ """Module for child devices.""" from ...device_type import DeviceType -from ..smartcameramodule import SmartCameraModule +from ..smartcammodule import SmartCamModule -class ChildDevice(SmartCameraModule): +class ChildDevice(SmartCamModule): """Implementation for child devices.""" REQUIRED_COMPONENT = "childControl" diff --git a/kasa/smartcamera/modules/device.py b/kasa/smartcam/modules/device.py similarity index 92% rename from kasa/smartcamera/modules/device.py rename to kasa/smartcam/modules/device.py index 34474ef2b..0541d75c6 100644 --- a/kasa/smartcamera/modules/device.py +++ b/kasa/smartcam/modules/device.py @@ -3,10 +3,10 @@ from __future__ import annotations from ...feature import Feature -from ..smartcameramodule import SmartCameraModule +from ..smartcammodule import SmartCamModule -class DeviceModule(SmartCameraModule): +class DeviceModule(SmartCamModule): """Implementation of device module.""" NAME = "devicemodule" diff --git a/kasa/smartcamera/modules/led.py b/kasa/smartcam/modules/led.py similarity index 88% rename from kasa/smartcamera/modules/led.py rename to kasa/smartcam/modules/led.py index 0443d320a..fb62c52dd 100644 --- a/kasa/smartcamera/modules/led.py +++ b/kasa/smartcam/modules/led.py @@ -3,10 +3,10 @@ from __future__ import annotations from ...interfaces.led import Led as LedInterface -from ..smartcameramodule import SmartCameraModule +from ..smartcammodule import SmartCamModule -class Led(SmartCameraModule, LedInterface): +class Led(SmartCamModule, LedInterface): """Implementation of led controls.""" REQUIRED_COMPONENT = "led" diff --git a/kasa/smartcamera/modules/pantilt.py b/kasa/smartcam/modules/pantilt.py similarity index 97% rename from kasa/smartcamera/modules/pantilt.py rename to kasa/smartcam/modules/pantilt.py index d1882927a..fb647f6f1 100644 --- a/kasa/smartcamera/modules/pantilt.py +++ b/kasa/smartcam/modules/pantilt.py @@ -3,13 +3,13 @@ from __future__ import annotations from ...feature import Feature -from ..smartcameramodule import SmartCameraModule +from ..smartcammodule import SmartCamModule DEFAULT_PAN_STEP = 30 DEFAULT_TILT_STEP = 10 -class PanTilt(SmartCameraModule): +class PanTilt(SmartCamModule): """Implementation of device_local_time.""" REQUIRED_COMPONENT = "ptz" diff --git a/kasa/smartcamera/modules/time.py b/kasa/smartcam/modules/time.py similarity index 96% rename from kasa/smartcamera/modules/time.py rename to kasa/smartcam/modules/time.py index 6f40aafb5..4e5cb8df2 100644 --- a/kasa/smartcamera/modules/time.py +++ b/kasa/smartcam/modules/time.py @@ -9,10 +9,10 @@ from ...cachedzoneinfo import CachedZoneInfo from ...feature import Feature from ...interfaces import Time as TimeInterface -from ..smartcameramodule import SmartCameraModule +from ..smartcammodule import SmartCamModule -class Time(SmartCameraModule, TimeInterface): +class Time(SmartCamModule, TimeInterface): """Implementation of device_local_time.""" QUERY_GETTER_NAME = "getTimezone" diff --git a/kasa/smartcamera/smartcamera.py b/kasa/smartcam/smartcamdevice.py similarity index 96% rename from kasa/smartcamera/smartcamera.py rename to kasa/smartcam/smartcamdevice.py index 2c09e4dd8..4cbf3bbed 100644 --- a/kasa/smartcamera/smartcamera.py +++ b/kasa/smartcam/smartcamdevice.py @@ -1,4 +1,4 @@ -"""Module for smartcamera.""" +"""Module for SmartCamDevice.""" from __future__ import annotations @@ -8,15 +8,15 @@ from ..device import _DeviceInfo from ..device_type import DeviceType from ..module import Module -from ..protocols.smartcameraprotocol import _ChildCameraProtocolWrapper +from ..protocols.smartcamprotocol import _ChildCameraProtocolWrapper from ..smart import SmartChildDevice, SmartDevice from .modules import ChildDevice, DeviceModule -from .smartcameramodule import SmartCameraModule +from .smartcammodule import SmartCamModule _LOGGER = logging.getLogger(__name__) -class SmartCamera(SmartDevice): +class SmartCamDevice(SmartDevice): """Class for smart cameras.""" # Modules that are called as part of the init procedure on first update @@ -41,7 +41,7 @@ def _get_device_info( basic_info = info["getDeviceInfo"]["device_info"]["basic_info"] short_name = basic_info["device_model"] long_name = discovery_info["device_model"] if discovery_info else short_name - device_type = SmartCamera._get_device_type_from_sysinfo(basic_info) + device_type = SmartCamDevice._get_device_type_from_sysinfo(basic_info) fw_version_full = basic_info["sw_version"] firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) return _DeviceInfo( @@ -73,7 +73,7 @@ def _update_children_info(self) -> None: async def _initialize_smart_child( self, info: dict, child_components: dict ) -> SmartDevice: - """Initialize a smart child device attached to a smartcamera.""" + """Initialize a smart child device attached to a smartcam device.""" child_id = info["device_id"] child_protocol = _ChildCameraProtocolWrapper(child_id, self.protocol) try: @@ -122,7 +122,7 @@ async def _initialize_children(self) -> None: async def _initialize_modules(self) -> None: """Initialize modules based on component negotiation response.""" - for mod in SmartCameraModule.REGISTERED_MODULES.values(): + for mod in SmartCamModule.REGISTERED_MODULES.values(): if ( mod.REQUIRED_COMPONENT and mod.REQUIRED_COMPONENT not in self._components diff --git a/kasa/smartcamera/smartcameramodule.py b/kasa/smartcam/smartcammodule.py similarity index 92% rename from kasa/smartcamera/smartcameramodule.py rename to kasa/smartcam/smartcammodule.py index 4b1bd36e3..ca1a3b824 100644 --- a/kasa/smartcamera/smartcameramodule.py +++ b/kasa/smartcam/smartcammodule.py @@ -11,15 +11,15 @@ if TYPE_CHECKING: from . import modules - from .smartcamera import SmartCamera + from .smartcamdevice import SmartCamDevice _LOGGER = logging.getLogger(__name__) -class SmartCameraModule(SmartModule): - """Base class for SMARTCAMERA modules.""" +class SmartCamModule(SmartModule): + """Base class for SMARTCAM modules.""" - SmartCameraAlarm: Final[ModuleName[modules.Alarm]] = ModuleName("SmartCameraAlarm") + SmartCamAlarm: Final[ModuleName[modules.Alarm]] = ModuleName("SmartCamAlarm") #: Query to execute during the main update cycle QUERY_GETTER_NAME: str @@ -30,7 +30,7 @@ class SmartCameraModule(SmartModule): REGISTERED_MODULES = {} - _device: SmartCamera + _device: SmartCamDevice def query(self) -> dict: """Query to execute during the update cycle. diff --git a/kasa/smartcamera/__init__.py b/kasa/smartcamera/__init__.py deleted file mode 100644 index 0d6052ea1..000000000 --- a/kasa/smartcamera/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Package for supporting tapo-branded cameras.""" - -from .smartcamera import SmartCamera - -__all__ = ["SmartCamera"] diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index faaec64ff..917f19980 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -13,11 +13,11 @@ ) from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch from kasa.smart import SmartDevice -from kasa.smartcamera.smartcamera import SmartCamera +from kasa.smartcam import SmartCamDevice from .fakeprotocol_iot import FakeIotProtocol from .fakeprotocol_smart import FakeSmartProtocol -from .fakeprotocol_smartcamera import FakeSmartCameraProtocol +from .fakeprotocol_smartcam import FakeSmartCamProtocol from .fixtureinfo import ( FIXTURE_DATA, ComponentFilter, @@ -317,16 +317,16 @@ def parametrize( device_iot = parametrize( "devices iot", model_filter=ALL_DEVICES_IOT, protocol_filter={"IOT"} ) -device_smartcamera = parametrize("devices smartcamera", protocol_filter={"SMARTCAMERA"}) -camera_smartcamera = parametrize( - "camera smartcamera", +device_smartcam = parametrize("devices smartcam", protocol_filter={"SMARTCAM"}) +camera_smartcam = parametrize( + "camera smartcam", device_type_filter=[DeviceType.Camera], - protocol_filter={"SMARTCAMERA"}, + protocol_filter={"SMARTCAM"}, ) -hub_smartcamera = parametrize( - "hub smartcamera", +hub_smartcam = parametrize( + "hub smartcam", device_type_filter=[DeviceType.Hub], - protocol_filter={"SMARTCAMERA"}, + protocol_filter={"SMARTCAM"}, ) @@ -344,8 +344,8 @@ def check_categories(): + hubs_smart.args[1] + sensors_smart.args[1] + thermostats_smart.args[1] - + camera_smartcamera.args[1] - + hub_smartcamera.args[1] + + camera_smartcam.args[1] + + hub_smartcam.args[1] ) diffs: set[FixtureInfo] = set(FIXTURE_DATA) - set(categorized_fixtures) if diffs: @@ -363,8 +363,8 @@ def check_categories(): def device_for_fixture_name(model, protocol): if protocol in {"SMART", "SMART.CHILD"}: return SmartDevice - elif protocol == "SMARTCAMERA": - return SmartCamera + elif protocol == "SMARTCAM": + return SmartCamDevice else: for d in STRIPS_IOT: if d in model: @@ -420,8 +420,8 @@ async def get_device_for_fixture( d.protocol = FakeSmartProtocol( fixture_data.data, fixture_data.name, verbatim=verbatim ) - elif fixture_data.protocol == "SMARTCAMERA": - d.protocol = FakeSmartCameraProtocol( + elif fixture_data.protocol == "SMARTCAM": + d.protocol = FakeSmartCamProtocol( fixture_data.data, fixture_data.name, verbatim=verbatim ) else: @@ -460,8 +460,8 @@ def get_fixture_info(fixture, protocol): def get_nearest_fixture_to_ip(dev): if isinstance(dev, SmartDevice): protocol_fixtures = filter_fixtures("", protocol_filter={"SMART"}) - elif isinstance(dev, SmartCamera): - protocol_fixtures = filter_fixtures("", protocol_filter={"SMARTCAMERA"}) + elif isinstance(dev, SmartCamDevice): + protocol_fixtures = filter_fixtures("", protocol_filter={"SMARTCAM"}) else: protocol_fixtures = filter_fixtures("", protocol_filter={"IOT"}) assert protocol_fixtures, "Unknown device type" diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index f8df43975..15109b3bf 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -11,7 +11,7 @@ from .fakeprotocol_iot import FakeIotProtocol from .fakeprotocol_smart import FakeSmartProtocol, FakeSmartTransport -from .fakeprotocol_smartcamera import FakeSmartCameraProtocol +from .fakeprotocol_smartcam import FakeSmartCamProtocol from .fixtureinfo import FixtureInfo, filter_fixtures, idgenerator DISCOVERY_MOCK_IP = "127.0.0.123" @@ -194,8 +194,8 @@ def patch_discovery(fixture_infos: dict[str, FixtureInfo], mocker): protos = { ip: FakeSmartProtocol(fixture_info.data, fixture_info.name) if fixture_info.protocol in {"SMART", "SMART.CHILD"} - else FakeSmartCameraProtocol(fixture_info.data, fixture_info.name) - if fixture_info.protocol in {"SMARTCAMERA", "SMARTCAMERA.CHILD"} + else FakeSmartCamProtocol(fixture_info.data, fixture_info.name) + if fixture_info.protocol in {"SMARTCAM", "SMARTCAM.CHILD"} else FakeIotProtocol(fixture_info.data, fixture_info.name) for ip, fixture_info in fixture_infos.items() } @@ -221,8 +221,8 @@ async def mock_discover(self): protos[host] = ( FakeSmartProtocol(fixture_info.data, fixture_info.name) if fixture_info.protocol in {"SMART", "SMART.CHILD"} - else FakeSmartCameraProtocol(fixture_info.data, fixture_info.name) - if fixture_info.protocol in {"SMARTCAMERA", "SMARTCAMERA.CHILD"} + else FakeSmartCamProtocol(fixture_info.data, fixture_info.name) + if fixture_info.protocol in {"SMARTCAM", "SMARTCAM.CHILD"} else FakeIotProtocol(fixture_info.data, fixture_info.name) ) port = ( diff --git a/tests/fakeprotocol_smartcamera.py b/tests/fakeprotocol_smartcam.py similarity index 97% rename from tests/fakeprotocol_smartcamera.py rename to tests/fakeprotocol_smartcam.py index 4059fbfbb..d110e7845 100644 --- a/tests/fakeprotocol_smartcamera.py +++ b/tests/fakeprotocol_smartcam.py @@ -5,16 +5,16 @@ from typing import Any from kasa import Credentials, DeviceConfig, SmartProtocol -from kasa.protocols.smartcameraprotocol import SmartCameraProtocol +from kasa.protocols.smartcamprotocol import SmartCamProtocol from kasa.transports.basetransport import BaseTransport from .fakeprotocol_smart import FakeSmartTransport -class FakeSmartCameraProtocol(SmartCameraProtocol): +class FakeSmartCamProtocol(SmartCamProtocol): def __init__(self, info, fixture_name, *, is_child=False, verbatim=False): super().__init__( - transport=FakeSmartCameraTransport( + transport=FakeSmartCamTransport( info, fixture_name, is_child=is_child, verbatim=verbatim ), ) @@ -25,7 +25,7 @@ async def query(self, request, retry_count: int = 3): return resp_dict -class FakeSmartCameraTransport(BaseTransport): +class FakeSmartCamTransport(BaseTransport): def __init__( self, info, diff --git a/tests/fixtureinfo.py b/tests/fixtureinfo.py index cc7a5df4c..fc1dd1fb8 100644 --- a/tests/fixtureinfo.py +++ b/tests/fixtureinfo.py @@ -13,7 +13,7 @@ from kasa.device_type import DeviceType from kasa.iot import IotDevice from kasa.smart.smartdevice import SmartDevice -from kasa.smartcamera.smartcamera import SmartCamera +from kasa.smartcam import SmartCamDevice class FixtureInfo(NamedTuple): @@ -53,10 +53,10 @@ class ComponentFilter(NamedTuple): ) ] -SUPPORTED_SMARTCAMERA_DEVICES = [ - (device, "SMARTCAMERA") +SUPPORTED_SMARTCAM_DEVICES = [ + (device, "SMARTCAM") for device in glob.glob( - os.path.dirname(os.path.abspath(__file__)) + "/fixtures/smartcamera/*.json" + os.path.dirname(os.path.abspath(__file__)) + "/fixtures/smartcam/*.json" ) ] @@ -64,7 +64,7 @@ class ComponentFilter(NamedTuple): SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES + SUPPORTED_SMART_CHILD_DEVICES - + SUPPORTED_SMARTCAMERA_DEVICES + + SUPPORTED_SMARTCAM_DEVICES ) @@ -179,14 +179,14 @@ def _device_type_match(fixture_data: FixtureInfo, device_type): IotDevice._get_device_type_from_sys_info(fixture_data.data) in device_type ) - elif fixture_data.protocol == "SMARTCAMERA": + elif fixture_data.protocol == "SMARTCAM": info = fixture_data.data["getDeviceInfo"]["device_info"]["basic_info"] - return SmartCamera._get_device_type_from_sysinfo(info) in device_type + return SmartCamDevice._get_device_type_from_sysinfo(info) in device_type return False filtered = [] if protocol_filter is None: - protocol_filter = {"IOT", "SMART", "SMARTCAMERA"} + protocol_filter = {"IOT", "SMART", "SMARTCAM"} for fixture_data in fixture_list: if data_root_filter and data_root_filter not in fixture_data.data: continue diff --git a/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json b/tests/fixtures/smartcam/C210(EU)_2.0_1.4.2.json similarity index 100% rename from tests/fixtures/smartcamera/C210(EU)_2.0_1.4.2.json rename to tests/fixtures/smartcam/C210(EU)_2.0_1.4.2.json diff --git a/tests/fixtures/smartcamera/C210(EU)_2.0_1.4.3.json b/tests/fixtures/smartcam/C210(EU)_2.0_1.4.3.json similarity index 100% rename from tests/fixtures/smartcamera/C210(EU)_2.0_1.4.3.json rename to tests/fixtures/smartcam/C210(EU)_2.0_1.4.3.json diff --git a/tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json b/tests/fixtures/smartcam/H200(EU)_1.0_1.3.2.json similarity index 100% rename from tests/fixtures/smartcamera/H200(EU)_1.0_1.3.2.json rename to tests/fixtures/smartcam/H200(EU)_1.0_1.3.2.json diff --git a/tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json b/tests/fixtures/smartcam/H200(US)_1.0_1.3.6.json similarity index 100% rename from tests/fixtures/smartcamera/H200(US)_1.0_1.3.6.json rename to tests/fixtures/smartcam/H200(US)_1.0_1.3.6.json diff --git a/tests/fixtures/smartcamera/TC65_1.0_1.3.9.json b/tests/fixtures/smartcam/TC65_1.0_1.3.9.json similarity index 100% rename from tests/fixtures/smartcamera/TC65_1.0_1.3.9.json rename to tests/fixtures/smartcam/TC65_1.0_1.3.9.json diff --git a/tests/smartcamera/modules/test_alarm.py b/tests/smartcamera/modules/test_alarm.py index 2301a2be3..50e0b5b3a 100644 --- a/tests/smartcamera/modules/test_alarm.py +++ b/tests/smartcamera/modules/test_alarm.py @@ -5,21 +5,21 @@ import pytest from kasa import Device -from kasa.smartcamera.modules.alarm import ( +from kasa.smartcam.modules.alarm import ( DURATION_MAX, DURATION_MIN, VOLUME_MAX, VOLUME_MIN, ) -from kasa.smartcamera.smartcameramodule import SmartCameraModule +from kasa.smartcam.smartcammodule import SmartCamModule -from ...conftest import hub_smartcamera +from ...conftest import hub_smartcam -@hub_smartcamera +@hub_smartcam async def test_alarm(dev: Device): """Test device alarm.""" - alarm = dev.modules.get(SmartCameraModule.SmartCameraAlarm) + alarm = dev.modules.get(SmartCamModule.SmartCamAlarm) assert alarm original_duration = alarm.alarm_duration @@ -70,10 +70,10 @@ async def test_alarm(dev: Device): await dev.update() -@hub_smartcamera +@hub_smartcam async def test_alarm_invalid_setters(dev: Device): """Test device alarm invalid setter values.""" - alarm = dev.modules.get(SmartCameraModule.SmartCameraAlarm) + alarm = dev.modules.get(SmartCamModule.SmartCamAlarm) assert alarm # test set sound invalid @@ -92,10 +92,10 @@ async def test_alarm_invalid_setters(dev: Device): await alarm.set_alarm_duration(-3) -@hub_smartcamera +@hub_smartcam async def test_alarm_features(dev: Device): """Test device alarm features.""" - alarm = dev.modules.get(SmartCameraModule.SmartCameraAlarm) + alarm = dev.modules.get(SmartCamModule.SmartCamAlarm) assert alarm original_duration = alarm.alarm_duration diff --git a/tests/smartcamera/test_smartcamera.py b/tests/smartcamera/test_smartcamera.py index 6f1d82d35..ccb4fbc1a 100644 --- a/tests/smartcamera/test_smartcamera.py +++ b/tests/smartcamera/test_smartcamera.py @@ -12,10 +12,10 @@ from kasa import Credentials, Device, DeviceType, Module -from ..conftest import camera_smartcamera, device_smartcamera, hub_smartcamera +from ..conftest import camera_smartcam, device_smartcam, hub_smartcam -@device_smartcamera +@device_smartcam async def test_state(dev: Device): if dev.device_type is DeviceType.Hub: pytest.skip("Hubs cannot be switched on and off") @@ -26,7 +26,7 @@ async def test_state(dev: Device): assert dev.is_on is not state -@camera_smartcamera +@camera_smartcam async def test_stream_rtsp_url(dev: Device): camera_module = dev.modules.get(Module.Camera) assert camera_module @@ -85,7 +85,7 @@ async def test_stream_rtsp_url(dev: Device): assert url is None -@device_smartcamera +@device_smartcam async def test_alias(dev): test_alias = "TEST1234" original = dev.alias @@ -100,7 +100,7 @@ async def test_alias(dev): assert dev.alias == original -@hub_smartcamera +@hub_smartcam async def test_hub(dev): assert dev.children for child in dev.children: @@ -112,7 +112,7 @@ async def test_hub(dev): assert child.time -@device_smartcamera +@device_smartcam async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory): """Test a child device gets the time from it's parent module.""" fallback_time = datetime.now(UTC).astimezone().replace(microsecond=0) diff --git a/tests/test_cli.py b/tests/test_cli.py index a31a9fa6b..52f5ff93c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -45,7 +45,7 @@ from kasa.discover import Discover, DiscoveryResult from kasa.iot import IotDevice from kasa.smart import SmartDevice -from kasa.smartcamera import SmartCamera +from kasa.smartcam import SmartCamDevice from .conftest import ( device_smart, @@ -181,7 +181,7 @@ async def test_state(dev, turn_on, runner): @turn_on async def test_toggle(dev, turn_on, runner): - if isinstance(dev, SmartCamera) and dev.device_type == DeviceType.Hub: + if isinstance(dev, SmartCamDevice) and dev.device_type == DeviceType.Hub: pytest.skip(reason="Hub cannot toggle state") await handle_turn_on(dev, turn_on) @@ -214,7 +214,7 @@ async def test_raw_command(dev, mocker, runner): update = mocker.patch.object(dev, "update") from kasa.smart import SmartDevice - if isinstance(dev, SmartCamera): + if isinstance(dev, SmartCamDevice): params = ["na", "getDeviceInfo"] elif isinstance(dev, SmartDevice): params = ["na", "get_device_info"] @@ -917,7 +917,7 @@ async def _state(dev: Device): mocker.patch("kasa.cli.device.state", new=_state) if device_type == "camera": - expected_type = SmartCamera + expected_type = SmartCamDevice elif device_type == "smart": expected_type = SmartDevice else: diff --git a/tests/test_device.py b/tests/test_device.py index e461033dd..1d780c32a 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -30,7 +30,7 @@ ) from kasa.iot.modules import IotLightPreset from kasa.smart import SmartChildDevice, SmartDevice -from kasa.smartcamera import SmartCamera +from kasa.smartcam import SmartCamDevice def _get_subclasses(of_class): @@ -115,7 +115,7 @@ async def test_device_class_repr(device_class_name_obj): IotLightStrip: DeviceType.LightStrip, SmartChildDevice: DeviceType.Unknown, SmartDevice: DeviceType.Unknown, - SmartCamera: DeviceType.Camera, + SmartCamDevice: DeviceType.Camera, } type_ = CLASS_TO_DEFAULT_TYPE[klass] child_repr = ">" diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 8f9f635ae..a0f501c39 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -20,7 +20,7 @@ from kasa.device_factory import ( Device, IotDevice, - SmartCamera, + SmartCamDevice, SmartDevice, connect, get_device_class_from_family, @@ -178,8 +178,8 @@ async def test_connect_http_client(discovery_mock, mocker): async def test_device_types(dev: Device): await dev.update() - if isinstance(dev, SmartCamera): - res = SmartCamera._get_device_type_from_sysinfo(dev.sys_info) + if isinstance(dev, SmartCamDevice): + res = SmartCamDevice._get_device_type_from_sysinfo(dev.sys_info) elif isinstance(dev, SmartDevice): assert dev._discovery_info device_type = cast(str, dev._discovery_info["device_type"]) diff --git a/tests/test_devtools.py b/tests/test_devtools.py index fa60acd5b..e18243afa 100644 --- a/tests/test_devtools.py +++ b/tests/test_devtools.py @@ -6,7 +6,7 @@ from kasa.iot import IotDevice from kasa.protocols import IotProtocol from kasa.smart import SmartDevice -from kasa.smartcamera import SmartCamera +from kasa.smartcam import SmartCamDevice from .conftest import ( FixtureInfo, @@ -17,8 +17,8 @@ smart_fixtures = parametrize( "smart fixtures", protocol_filter={"SMART"}, fixture_name="fixture_info" ) -smartcamera_fixtures = parametrize( - "smartcamera fixtures", protocol_filter={"SMARTCAMERA"}, fixture_name="fixture_info" +smartcam_fixtures = parametrize( + "smartcam fixtures", protocol_filter={"SMARTCAM"}, fixture_name="fixture_info" ) iot_fixtures = parametrize( "iot fixtures", protocol_filter={"IOT"}, fixture_name="fixture_info" @@ -27,8 +27,8 @@ async def test_fixture_names(fixture_info: FixtureInfo): """Test that device info gets the right fixture names.""" - if fixture_info.protocol in {"SMARTCAMERA"}: - device_info = SmartCamera._get_device_info( + if fixture_info.protocol in {"SMARTCAM"}: + device_info = SmartCamDevice._get_device_info( fixture_info.data, fixture_info.data.get("discovery_result") ) elif fixture_info.protocol in {"SMART"}: @@ -62,11 +62,11 @@ async def test_smart_fixtures(fixture_info: FixtureInfo): assert fixture_info.data == fixture_result.data -@smartcamera_fixtures -async def test_smartcamera_fixtures(fixture_info: FixtureInfo): - """Test that smartcamera fixtures are created the same.""" +@smartcam_fixtures +async def test_smartcam_fixtures(fixture_info: FixtureInfo): + """Test that smartcam fixtures are created the same.""" dev = await get_device_for_fixture(fixture_info, verbatim=True) - assert isinstance(dev, SmartCamera) + assert isinstance(dev, SmartCamDevice) if dev.children: pytest.skip("Test not currently implemented for devices with children.") fixtures = await get_smart_fixtures( From 412c65c428604c826e5d19ee1ac5d79e3525a795 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sat, 23 Nov 2024 12:20:51 +0000 Subject: [PATCH 712/892] Run tests with caplog in a single worker (#1304) --- pyproject.toml | 4 +++- tests/iot/modules/test_schedule.py | 1 + tests/smart/modules/test_firmware.py | 1 + tests/smart/modules/test_temperaturecontrol.py | 1 + tests/test_aestransport.py | 1 + tests/test_bulb.py | 1 + tests/test_childdevice.py | 1 + tests/test_device_factory.py | 2 ++ tests/test_discovery.py | 2 ++ tests/test_feature.py | 1 + tests/test_klapprotocol.py | 1 + tests/test_protocol.py | 2 ++ tests/test_smartdevice.py | 3 +++ tests/test_smartprotocol.py | 2 ++ tests/test_sslaestransport.py | 1 + 15 files changed, 23 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9fdc888d1..f9dfbf875 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,7 +107,9 @@ markers = [ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" timeout = 10 -addopts = "--disable-socket --allow-unix-socket" +# dist=loadgroup enables grouping of tests into single worker. +# required as caplog doesn't play nicely with multiple workers. +addopts = "--disable-socket --allow-unix-socket --dist=loadgroup" [tool.doc8] paths = ["docs"] diff --git a/tests/iot/modules/test_schedule.py b/tests/iot/modules/test_schedule.py index 152aaac85..4a4ffdee6 100644 --- a/tests/iot/modules/test_schedule.py +++ b/tests/iot/modules/test_schedule.py @@ -7,6 +7,7 @@ @device_iot +@pytest.mark.xdist_group(name="caplog") def test_schedule(dev: Device, caplog: pytest.LogCaptureFixture): schedule = dev.modules.get(Module.IotSchedule) assert schedule diff --git a/tests/smart/modules/test_firmware.py b/tests/smart/modules/test_firmware.py index 0bc0a4eab..e3fe5bb36 100644 --- a/tests/smart/modules/test_firmware.py +++ b/tests/smart/modules/test_firmware.py @@ -90,6 +90,7 @@ async def test_update_available_without_cloud(dev: SmartDevice): ], ) @pytest.mark.requires_dummy +@pytest.mark.xdist_group(name="caplog") async def test_firmware_update( dev: SmartDevice, mocker: MockerFixture, diff --git a/tests/smart/modules/test_temperaturecontrol.py b/tests/smart/modules/test_temperaturecontrol.py index 2653c53e1..d47f19ee6 100644 --- a/tests/smart/modules/test_temperaturecontrol.py +++ b/tests/smart/modules/test_temperaturecontrol.py @@ -137,6 +137,7 @@ async def test_thermostat_mode(dev, mode, states, frost_protection): ), ], ) +@pytest.mark.xdist_group(name="caplog") async def test_thermostat_mode_warnings(dev, mode, states, msg, caplog): """Test thermostat modes that should log a warning.""" temp_module: TemperatureControl = dev.modules["TemperatureControl"] diff --git a/tests/test_aestransport.py b/tests/test_aestransport.py index 4c95289a3..64bc8d4e4 100644 --- a/tests/test_aestransport.py +++ b/tests/test_aestransport.py @@ -216,6 +216,7 @@ async def test_send(mocker, status_code, error_code, inner_error_code, expectati assert "result" in res +@pytest.mark.xdist_group(name="caplog") async def test_unencrypted_response(mocker, caplog): host = "127.0.0.1" mock_aes_device = MockAesDevice(host, 200, 0, 0, do_not_encrypt_response=True) diff --git a/tests/test_bulb.py b/tests/test_bulb.py index 4a547522f..3ae1328f6 100644 --- a/tests/test_bulb.py +++ b/tests/test_bulb.py @@ -232,6 +232,7 @@ async def test_set_color_temp_transition(dev: IotBulb, mocker): @variable_temp_iot +@pytest.mark.xdist_group(name="caplog") async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") light = dev.modules.get(Module.Light) diff --git a/tests/test_childdevice.py b/tests/test_childdevice.py index d734d82c0..1e525efb0 100644 --- a/tests/test_childdevice.py +++ b/tests/test_childdevice.py @@ -136,6 +136,7 @@ async def test_child_time(dev: Device, freezer: FrozenDateTimeFactory): assert child.time != fallback_time +@pytest.mark.xdist_group(name="caplog") async def test_child_device_type_unknown(caplog): """Test for device type when category is unknown.""" diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index a0f501c39..860037445 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -109,6 +109,7 @@ async def test_connect_custom_port(discovery_mock, mocker, custom_port): assert dev.port == custom_port or dev.port == default_port +@pytest.mark.xdist_group(name="caplog") async def test_connect_logs_connect_time( discovery_mock, caplog: pytest.LogCaptureFixture, @@ -192,6 +193,7 @@ async def test_device_types(dev: Device): assert dev.device_type == res +@pytest.mark.xdist_group(name="caplog") async def test_device_class_from_unknown_family(caplog): """Verify that unknown SMART devices yield a warning and fallback to SmartDevice.""" dummy_name = "SMART.foo" diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 787dea0e0..7069e32f6 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -119,6 +119,7 @@ async def test_type_detection_lightstrip(dev: Device): assert d.device_type == DeviceType.LightStrip +@pytest.mark.xdist_group(name="caplog") async def test_type_unknown(caplog): invalid_info = {"system": {"get_sysinfo": {"type": "nosuchtype"}}} assert Discover._get_device_class(invalid_info) is IotPlug @@ -586,6 +587,7 @@ async def test_do_discover_external_cancel(mocker): await dp.wait_for_discovery_to_complete() +@pytest.mark.xdist_group(name="caplog") async def test_discovery_redaction(discovery_mock, caplog: pytest.LogCaptureFixture): """Test query sensitive info redaction.""" mac = "12:34:56:78:9A:BC" diff --git a/tests/test_feature.py b/tests/test_feature.py index 79560b1ae..46cdd116c 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -127,6 +127,7 @@ async def test_feature_action(mocker): mock_call_action.assert_called() +@pytest.mark.xdist_group(name="caplog") async def test_feature_choice_list(dummy_feature, caplog, mocker: MockerFixture): """Test the choice feature type.""" dummy_feature.type = Feature.Type.Choice diff --git a/tests/test_klapprotocol.py b/tests/test_klapprotocol.py index a1521ee4d..26d9f57a4 100644 --- a/tests/test_klapprotocol.py +++ b/tests/test_klapprotocol.py @@ -184,6 +184,7 @@ def _fail_one_less_than_retry_count(*_, **__): @pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG]) +@pytest.mark.xdist_group(name="caplog") async def test_protocol_logging(mocker, caplog, log_level): caplog.set_level(log_level) logging.getLogger("kasa").setLevel(log_level) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 767d0f102..09134e851 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -307,6 +307,7 @@ def aio_mock_writer(_, __): ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) @pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG]) +@pytest.mark.xdist_group(name="caplog") async def test_protocol_logging( mocker, caplog, log_level, protocol_class, transport_class, encryption_class ): @@ -685,6 +686,7 @@ def test_deprecated_protocol(): @device_iot +@pytest.mark.xdist_group(name="caplog") async def test_iot_queries_redaction(dev: IotDevice, caplog: pytest.LogCaptureFixture): """Test query sensitive info redaction.""" if isinstance(dev.protocol._transport, FakeIotTransport): diff --git a/tests/test_smartdevice.py b/tests/test_smartdevice.py index 9d5956dca..a89b1098d 100644 --- a/tests/test_smartdevice.py +++ b/tests/test_smartdevice.py @@ -27,6 +27,7 @@ @device_smart @pytest.mark.requires_dummy +@pytest.mark.xdist_group(name="caplog") async def test_try_get_response(dev: SmartDevice, caplog): mock_response: dict = { "get_device_info": SmartErrorCode.PARAMS_ERROR, @@ -143,6 +144,7 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): @device_smart +@pytest.mark.xdist_group(name="caplog") async def test_update_module_update_delays( dev: SmartDevice, mocker: MockerFixture, @@ -203,6 +205,7 @@ async def test_update_module_update_delays( ], ) @device_smart +@pytest.mark.xdist_group(name="caplog") async def test_update_module_query_errors( dev: SmartDevice, mocker: MockerFixture, diff --git a/tests/test_smartprotocol.py b/tests/test_smartprotocol.py index 180fb6aa0..fce6cd070 100644 --- a/tests/test_smartprotocol.py +++ b/tests/test_smartprotocol.py @@ -54,6 +54,7 @@ async def test_smart_device_errors(dummy_protocol, mocker, error_code): @pytest.mark.parametrize("error_code", [-13333, 13333]) +@pytest.mark.xdist_group(name="caplog") async def test_smart_device_unknown_errors( dummy_protocol, mocker, error_code, caplog: pytest.LogCaptureFixture ): @@ -417,6 +418,7 @@ async def test_incomplete_list(mocker, caplog): @device_smart +@pytest.mark.xdist_group(name="caplog") async def test_smart_queries_redaction( dev: SmartDevice, caplog: pytest.LogCaptureFixture ): diff --git a/tests/test_sslaestransport.py b/tests/test_sslaestransport.py index 0d8fac9cf..6816fa35d 100644 --- a/tests/test_sslaestransport.py +++ b/tests/test_sslaestransport.py @@ -175,6 +175,7 @@ async def test_send(mocker): assert "result" in res +@pytest.mark.xdist_group(name="caplog") async def test_unencrypted_response(mocker, caplog): host = "127.0.0.1" mock_ssl_aes_device = MockSslAesDevice(host, do_not_encrypt_response=True) From d0a2ed738869d9bbaabcaa7fed39d66b363bc709 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sat, 23 Nov 2024 13:30:39 +0100 Subject: [PATCH 713/892] Add P110M(AU) fixture (#1244) --- README.md | 2 +- SUPPORTED.md | 2 + tests/device_fixtures.py | 3 +- tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json | 418 ++++++++++++++++++ 4 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json diff --git a/README.md b/README.md index 6cd7a21ac..e123373bc 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ The following devices have been tested and confirmed as working. If your device ### Supported Tapo\* devices -- **Plugs**: P100, P110, P115, P125M, P135, TP15 +- **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15 - **Power Strips**: P300, P304M, TP25 - **Wall Switches**: S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E, L630 diff --git a/SUPPORTED.md b/SUPPORTED.md index ca207a03b..066e6a660 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -186,6 +186,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.0.7 - Hardware: 1.0 (EU) / Firmware: 1.2.3 - Hardware: 1.0 (UK) / Firmware: 1.3.0 +- **P110M** + - Hardware: 1.0 (AU) / Firmware: 1.2.3 - **P115** - Hardware: 1.0 (EU) / Firmware: 1.2.3 - **P125M** diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 917f19980..9be780495 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -84,6 +84,7 @@ PLUGS_SMART = { "P100", "P110", + "P110M", "P115", "KP125M", "EP25", @@ -124,7 +125,7 @@ THERMOSTATS_SMART = {"KE100"} WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} -WITH_EMETER_SMART = {"P110", "P115", "KP125M", "EP25", "P304M"} +WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"} WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} DIMMABLE = {*BULBS, *DIMMERS} diff --git a/tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json b/tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json new file mode 100644 index 000000000..efb88c85e --- /dev/null +++ b/tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json @@ -0,0 +1,418 @@ +{ + "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": "localSmart", + "ver_code": 1 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110M(AU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-09-0D-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "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": 1 + }, + "get_energy_usage": { + "today_runtime": 306, + "month_runtime": 12572, + "today_energy": 173, + "month_energy": 6110, + "local_time": "2024-11-22 21:03:25", + "electricity_charge": [ + 0, + 0, + 0 + ], + "current_power": 74116 + }, + "get_current_power": { + "current_power": 74 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "charging_status": "normal", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.3 Build 240617 Rel.153525", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "F0-09-0D-00-00-00", + "model": "P110M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 186533, + "overcurrent_status": "normal", + "overheat_status": "normal", + "power_protection_status": "normal", + "region": "Australia/Sydney", + "rssi": -53, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 600, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Australia/Sydney", + "time_diff": 600, + "timestamp": 946958455 + }, + "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_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_max_power": { + "max_power": 2465 + }, + "get_next_event": {}, + "get_protection_power": { + "enabled": true, + "protection_power": 1120 + }, + "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": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 3, + "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": "P110M", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} From ea73b858e84aae9862cf2d22be75658adbe34aea Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Sat, 23 Nov 2024 07:31:27 -0500 Subject: [PATCH 714/892] Add HS200 (US) Smart Fixture (#1303) --- README.md | 2 +- SUPPORTED.md | 1 + tests/device_fixtures.py | 1 + .../fixtures/smart/HS200(US)_5.26_1.0.3.json | 379 ++++++++++++++++++ 4 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smart/HS200(US)_5.26_1.0.3.json diff --git a/README.md b/README.md index e123373bc..13ceebe59 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: EP10, EP25\*, HS100\*\*, HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M\*, KP401 - **Power Strips**: EP40, EP40M\*, HS107, HS300, KP200, KP303, KP400 -- **Wall Switches**: ES20M, HS200, HS210, HS220\*\*, KP405, KS200M, KS205\*, KS220, KS220M, KS225\*, KS230, KS240\* +- **Wall Switches**: ES20M, HS200\*\*, HS210, HS220\*\*, KP405, KS200M, KS205\*, KS220, KS220M, KS225\*, KS230, KS240\* - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 - **Light Strips**: KL400L5, KL420L5, KL430 - **Hubs**: KH100\* diff --git a/SUPPORTED.md b/SUPPORTED.md index 066e6a660..349bed956 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -86,6 +86,7 @@ Some newer Kasa devices require authentication. These are marked with *\* - **HS210** - Hardware: 1.0 (US) / Firmware: 1.5.8 - Hardware: 2.0 (US) / Firmware: 1.1.5 diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 9be780495..2af0ca065 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -101,6 +101,7 @@ "KS200M", } SWITCHES_SMART = { + "HS200", "KS205", "KS225", "KS240", diff --git a/tests/fixtures/smart/HS200(US)_5.26_1.0.3.json b/tests/fixtures/smart/HS200(US)_5.26_1.0.3.json new file mode 100644 index 000000000..e67435a9b --- /dev/null +++ b/tests/fixtures/smart/HS200(US)_5.26_1.0.3.json @@ -0,0 +1,379 @@ +{ + "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": 3 + }, + { + "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": "delay_action", + "ver_code": 2 + }, + { + "id": "smart_switch", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "HS200(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "74-FE-CE-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_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_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "hang_lamp_1", + "default_states": { + "state": { + "on": false + }, + "type": "custom" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240723 Rel.192622", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "5.26", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "74-FE-CE-00-00-00", + "model": "HS200", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/New_York", + "rssi": -56, + "signal_level": 2, + "smart_switch_state": false, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/New_York", + "time_diff": -300, + "timestamp": 1732300703 + }, + "get_device_usage": { + "time_usage": { + "past30": 185, + "past7": 185, + "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": 0, + "fw_ver": "1.0.3 Build 240723 Rel.192622", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "toggle", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "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": "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": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 17, + "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": "HS200", + "device_type": "SMART.KASASWITCH", + "is_klap": true + } + } +} From 15ecf320d9998f9305c0828647ff1a2649d47202 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sat, 23 Nov 2024 12:38:42 +0000 Subject: [PATCH 715/892] Add P110M(EU) fixture (#1305) --- SUPPORTED.md | 1 + tests/fixtures/smart/P110M(EU)_1.0_1.2.3.json | 614 ++++++++++++++++++ 2 files changed, 615 insertions(+) create mode 100644 tests/fixtures/smart/P110M(EU)_1.0_1.2.3.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 349bed956..ec0c1d5e2 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -189,6 +189,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (UK) / Firmware: 1.3.0 - **P110M** - Hardware: 1.0 (AU) / Firmware: 1.2.3 + - Hardware: 1.0 (EU) / Firmware: 1.2.3 - **P115** - Hardware: 1.0 (EU) / Firmware: 1.2.3 - **P125M** diff --git a/tests/fixtures/smart/P110M(EU)_1.0_1.2.3.json b/tests/fixtures/smart/P110M(EU)_1.0_1.2.3.json new file mode 100644 index 000000000..d8453319f --- /dev/null +++ b/tests/fixtures/smart/P110M(EU)_1.0_1.2.3.json @@ -0,0 +1,614 @@ +{ + "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": "localSmart", + "ver_code": 1 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110M(EU)", + "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": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "matter", + "owner": "" + }, + "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": 1 + }, + "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": "", + "charging_status": "normal", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.3 Build 240617 Rel.153525", + "has_set_location_info": false, + "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": "P110M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overcurrent_status": "normal", + "overheat_status": "normal", + "power_protection_status": "normal", + "region": "CET", + "rssi": -33, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "CET", + "time_diff": 60, + "timestamp": 1732361090 + }, + "get_device_usage": { + "power_usage": { + "past30": 7892, + "past7": 1549, + "today": 0 + }, + "saved_power": { + "past30": 9381, + "past7": 1362, + "today": 0 + }, + "time_usage": { + "past30": 17273, + "past7": 2911, + "today": 0 + } + }, + "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_emeter_data": { + "current_ma": 0, + "energy_wh": 1469, + "power_mw": 0, + "voltage_mv": 233509 + }, + "get_emeter_vgain_igain": { + "igain": 11299, + "vgain": 124300 + }, + "get_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-11-23 12:24:51", + "month_energy": 6266, + "month_runtime": 12705, + "today_energy": 0, + "today_runtime": 0 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": false, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:000000-000000000000" + }, + "get_max_power": { + "max_power": 3896 + }, + "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": [ + { + "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": 0, + "key_type": "none", + "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": 0, + "key_type": "none", + "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": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 22, + "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": "P110M", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} From fe53cd7d9c489aeac35fada48badd7af5d8a6e6c Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 25 Nov 2024 17:02:12 +0000 Subject: [PATCH 716/892] Use markdown footnotes in supported.md (#1310) Brings our markdown inline with the [HA markdown](https://github.com/home-assistant/home-assistant.io/pull/33342#discussion_r1653484233) --- README.md | 20 +++++++-------- SUPPORTED.md | 45 +++++++++++++++++----------------- devtools/generate_supported.py | 20 ++++----------- 3 files changed, 38 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 13ceebe59..f59f36770 100644 --- a/README.md +++ b/README.md @@ -182,15 +182,15 @@ The following devices have been tested and confirmed as working. If your device ### Supported Kasa devices -- **Plugs**: EP10, EP25\*, HS100\*\*, HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M\*, KP401 -- **Power Strips**: EP40, EP40M\*, HS107, HS300, KP200, KP303, KP400 -- **Wall Switches**: ES20M, HS200\*\*, HS210, HS220\*\*, KP405, KS200M, KS205\*, KS220, KS220M, KS225\*, KS230, KS240\* +- **Plugs**: EP10, EP25[^1], HS100[^2], HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M[^1], KP401 +- **Power Strips**: EP40, EP40M[^1], HS107, HS300, KP200, KP303, KP400 +- **Wall Switches**: ES20M, HS200[^2], HS210, HS220[^2], KP405, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1] - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 - **Light Strips**: KL400L5, KL420L5, KL430 -- **Hubs**: KH100\* -- **Hub-Connected Devices\*\*\***: KE100\* +- **Hubs**: KH100[^1] +- **Hub-Connected Devices[^3]**: KE100[^1] -### Supported Tapo\* devices +### Supported Tapo[^1] devices - **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15 - **Power Strips**: P300, P304M, TP25 @@ -199,12 +199,12 @@ The following devices have been tested and confirmed as working. If your device - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Cameras**: C210, TC65 - **Hubs**: H100, H200 -- **Hub-Connected Devices\*\*\***: S200B, S200D, T100, T110, T300, T310, T315 +- **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 -\*   Model requires authentication
-\*\*  Newer versions require authentication
-\*\*\* Devices may work across TAPO/KASA branded hubs +[^1]: Model requires authentication +[^2]: Newer versions require authentication +[^3]: Devices may work across TAPO/KASA branded hubs See [supported devices in our documentation](SUPPORTED.md) for more detailed information about tested hardware and software versions. diff --git a/SUPPORTED.md b/SUPPORTED.md index ec0c1d5e2..034372b0e 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -10,18 +10,18 @@ The following devices have been tested and confirmed as working. If your device ## Kasa devices -Some newer Kasa devices require authentication. These are marked with * in the list below.
Hub-Connected Devices may work across TAPO/KASA branded hubs even if they don't work across the native apps. +Some newer Kasa devices require authentication. These are marked with [^1] in the list below.
Hub-Connected Devices may work across TAPO/KASA branded hubs even if they don't work across the native apps. ### Plugs - **EP10** - Hardware: 1.0 (US) / Firmware: 1.0.2 - **EP25** - - Hardware: 2.6 (US) / Firmware: 1.0.1\* - - Hardware: 2.6 (US) / Firmware: 1.0.2\* + - Hardware: 2.6 (US) / Firmware: 1.0.1[^1] + - Hardware: 2.6 (US) / Firmware: 1.0.2[^1] - **HS100** - Hardware: 1.0 (UK) / Firmware: 1.2.6 - - Hardware: 4.1 (UK) / Firmware: 1.1.0\* + - Hardware: 4.1 (UK) / Firmware: 1.1.0[^1] - Hardware: 1.0 (US) / Firmware: 1.2.5 - Hardware: 2.0 (US) / Firmware: 1.5.6 - **HS103** @@ -46,8 +46,8 @@ Some newer Kasa devices require authentication. These are marked with *\* - - Hardware: 1.0 (US) / Firmware: 1.2.3\* + - Hardware: 1.0 (US) / Firmware: 1.1.3[^1] + - Hardware: 1.0 (US) / Firmware: 1.2.3[^1] - **KP401** - Hardware: 1.0 (US) / Firmware: 1.0.0 @@ -56,7 +56,7 @@ Some newer Kasa devices require authentication. These are marked with *\* + - Hardware: 1.0 (US) / Firmware: 1.1.0[^1] - **HS107** - Hardware: 1.0 (US) / Firmware: 1.0.8 - **HS300** @@ -86,14 +86,14 @@ Some newer Kasa devices require authentication. These are marked with *\* + - Hardware: 5.26 (US) / Firmware: 1.0.3[^1] - **HS210** - Hardware: 1.0 (US) / Firmware: 1.5.8 - Hardware: 2.0 (US) / Firmware: 1.1.5 - **HS220** - Hardware: 1.0 (US) / Firmware: 1.5.7 - Hardware: 2.0 (US) / Firmware: 1.0.3 - - Hardware: 3.26 (US) / Firmware: 1.0.1\* + - Hardware: 3.26 (US) / Firmware: 1.0.1[^1] - **KP405** - Hardware: 1.0 (US) / Firmware: 1.0.5 - Hardware: 1.0 (US) / Firmware: 1.0.6 @@ -103,21 +103,21 @@ Some newer Kasa devices require authentication. These are marked with *\* - - Hardware: 1.0 (US) / Firmware: 1.1.0\* + - Hardware: 1.0 (US) / Firmware: 1.0.2[^1] + - Hardware: 1.0 (US) / Firmware: 1.1.0[^1] - **KS220** - Hardware: 1.0 (US) / Firmware: 1.0.13 - **KS220M** - Hardware: 1.0 (US) / Firmware: 1.0.4 - **KS225** - - Hardware: 1.0 (US) / Firmware: 1.0.2\* - - Hardware: 1.0 (US) / Firmware: 1.1.0\* + - Hardware: 1.0 (US) / Firmware: 1.0.2[^1] + - Hardware: 1.0 (US) / Firmware: 1.1.0[^1] - **KS230** - Hardware: 1.0 (US) / Firmware: 1.0.14 - **KS240** - - Hardware: 1.0 (US) / Firmware: 1.0.4\* - - Hardware: 1.0 (US) / Firmware: 1.0.5\* - - Hardware: 1.0 (US) / Firmware: 1.0.7\* + - Hardware: 1.0 (US) / Firmware: 1.0.4[^1] + - Hardware: 1.0 (US) / Firmware: 1.0.5[^1] + - Hardware: 1.0 (US) / Firmware: 1.0.7[^1] ### Bulbs @@ -161,16 +161,16 @@ Some newer Kasa devices require authentication. These are marked with *\* - - Hardware: 1.0 (EU) / Firmware: 1.5.12\* - - Hardware: 1.0 (UK) / Firmware: 1.5.6\* + - Hardware: 1.0 (EU) / Firmware: 1.2.3[^1] + - Hardware: 1.0 (EU) / Firmware: 1.5.12[^1] + - Hardware: 1.0 (UK) / Firmware: 1.5.6[^1] ### Hub-Connected Devices - **KE100** - - Hardware: 1.0 (EU) / Firmware: 2.4.0\* - - Hardware: 1.0 (EU) / Firmware: 2.8.0\* - - Hardware: 1.0 (UK) / Firmware: 2.8.0\* + - Hardware: 1.0 (EU) / Firmware: 2.4.0[^1] + - Hardware: 1.0 (EU) / Firmware: 2.8.0[^1] + - Hardware: 1.0 (UK) / Firmware: 2.8.0[^1] ## Tapo devices @@ -293,3 +293,4 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros +[^1]: Model requires authentication diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py index 90e16e073..532c7e6a3 100755 --- a/devtools/generate_supported.py +++ b/devtools/generate_supported.py @@ -142,7 +142,7 @@ def _supported_text( for brand, types in supported.items(): preamble_text = ( "Some newer Kasa devices require authentication. " - + "These are marked with * in the list below." + + "These are marked with [^1] in the list below." if brand == "kasa" else "All Tapo devices require authentication." ) @@ -151,7 +151,7 @@ def _supported_text( + "hubs even if they don't work across the native apps." ) brand_text = brand.capitalize() - brand_auth = r"\*" if brand == "tapo" else "" + brand_auth = r"[^1]" if brand == "tapo" else "" types_text = "" for supported_type, models in sorted( # Sort by device type order in the enum @@ -166,9 +166,7 @@ def _supported_text( for version in sorted(versions): region_text = f" ({version.region})" if version.region else "" auth_count += 1 if version.auth else 0 - vauth_flag = ( - r"\*" if version.auth and brand == "kasa" else "" - ) + vauth_flag = r"[^1]" if version.auth and brand == "kasa" else "" if version_template: versions_text += versst.substitute( hw=version.hw, @@ -177,11 +175,7 @@ def _supported_text( auth_flag=vauth_flag, ) if brand == "kasa" and auth_count > 0: - auth_flag = ( - r"\*" - if auth_count == len(versions) - else r"\*\*" - ) + auth_flag = r"[^1]" if auth_count == len(versions) else r"[^2]" else: auth_flag = "" if model_template: @@ -191,11 +185,7 @@ def _supported_text( else: models_list.append(f"{model}{auth_flag}") models_text = models_text if models_text else ", ".join(models_list) - type_asterix = ( - r"\*\*\*" - if supported_type == "Hub-Connected Devices" - else "" - ) + type_asterix = r"[^3]" if supported_type == "Hub-Connected Devices" else "" types_text += typest.substitute( type_=supported_type, type_asterix=type_asterix, models=models_text ) From cb4e28394de609dbe777686356fe820d3b23fd64 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 26 Nov 2024 08:38:20 +0000 Subject: [PATCH 717/892] Update docs for the new module attributes has/get feature (#1301) --- kasa/interfaces/light.py | 16 ++++++++-------- kasa/module.py | 20 ++++++++++++++++++-- kasa/smart/modules/light.py | 28 +++++++++++++++++++++------- pyproject.toml | 8 +++++++- uv.lock | 32 ++++++++++++++++---------------- 5 files changed, 70 insertions(+), 34 deletions(-) diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py index 298ad1f8e..1d99f846c 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -64,9 +64,9 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import NamedTuple +from typing import Annotated, NamedTuple -from ..module import Module +from ..module import FeatureAttribute, Module @dataclass @@ -129,7 +129,7 @@ def has_effects(self) -> bool: @property @abstractmethod - def hsv(self) -> HSV: + def hsv(self) -> Annotated[HSV, FeatureAttribute()]: """Return the current HSV state of the bulb. :return: hue, saturation and value (degrees, %, %) @@ -137,12 +137,12 @@ def hsv(self) -> HSV: @property @abstractmethod - def color_temp(self) -> int: + def color_temp(self) -> Annotated[int, FeatureAttribute()]: """Whether the bulb supports color temperature changes.""" @property @abstractmethod - def brightness(self) -> int: + def brightness(self) -> Annotated[int, FeatureAttribute()]: """Return the current brightness in percentage.""" @abstractmethod @@ -153,7 +153,7 @@ async def set_hsv( value: int | None = None, *, transition: int | None = None, - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set new HSV. Note, transition is not supported and will be ignored. @@ -167,7 +167,7 @@ async def set_hsv( @abstractmethod async def set_color_temp( self, temp: int, *, brightness: int | None = None, transition: int | None = None - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set the color temperature of the device in kelvin. Note, transition is not supported and will be ignored. @@ -179,7 +179,7 @@ async def set_color_temp( @abstractmethod async def set_brightness( self, brightness: int, *, transition: int | None = None - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set the brightness in percentage. Note, transition is not supported and will be ignored. diff --git a/kasa/module.py b/kasa/module.py index 9f2685778..ba6791b0f 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -14,9 +14,17 @@ >>> print(dev.alias) Living Room Bulb -To see whether a device supports functionality check for the existence of the module: +To see whether a device supports a group of functionality +check for the existence of the module: >>> if light := dev.modules.get("Light"): +>>> print(light.brightness) +100 + +To see whether a device supports specific functionality, you can check whether the +module has that feature: + +>>> if light.has_feature("hsv"): >>> print(light.hsv) HSV(hue=0, saturation=100, value=100) @@ -70,6 +78,9 @@ class FeatureAttribute: """Class for annotating attributes bound to feature.""" + def __repr__(self) -> str: + return "FeatureAttribute" + class Module(ABC): """Base class implemention for all modules. @@ -147,6 +158,11 @@ def __init__(self, device: Device, module: str) -> None: self._module = module self._module_features: dict[str, Feature] = {} + @property + def _all_features(self) -> dict[str, Feature]: + """Get the features for this module and any sub modules.""" + return self._module_features + def has_feature(self, attribute: str | property | Callable) -> bool: """Return True if the module attribute feature is supported.""" return bool(self.get_feature(attribute)) @@ -247,7 +263,7 @@ def _get_bound_feature( ) check = {attribute_name, attribute_callable} - for feature in module._module_features.values(): + for feature in module._all_features.values(): if (getter := feature.attribute_getter) and getter in check: return feature diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py index e637b6075..730988750 100644 --- a/kasa/smart/modules/light.py +++ b/kasa/smart/modules/light.py @@ -3,11 +3,13 @@ from __future__ import annotations from dataclasses import asdict +from typing import Annotated from ...exceptions import KasaException +from ...feature import Feature from ...interfaces.light import HSV, ColorTempRange, LightState from ...interfaces.light import Light as LightInterface -from ...module import Module +from ...module import FeatureAttribute, Module from ..smartmodule import SmartModule @@ -16,6 +18,18 @@ class Light(SmartModule, LightInterface): _light_state: LightState + @property + def _all_features(self) -> dict[str, Feature]: + """Get the features for this module and any sub modules.""" + ret: dict[str, Feature] = {} + if brightness := self._device.modules.get(Module.Brightness): + ret.update(**brightness._module_features) + if color := self._device.modules.get(Module.Color): + ret.update(**color._module_features) + if temp := self._device.modules.get(Module.ColorTemperature): + ret.update(**temp._module_features) + return ret + def query(self) -> dict: """Query to execute during the update cycle.""" return {} @@ -47,7 +61,7 @@ def valid_temperature_range(self) -> ColorTempRange: return self._device.modules[Module.ColorTemperature].valid_temperature_range @property - def hsv(self) -> HSV: + def hsv(self) -> Annotated[HSV, FeatureAttribute()]: """Return the current HSV state of the bulb. :return: hue, saturation and value (degrees, %, %) @@ -58,7 +72,7 @@ def hsv(self) -> HSV: return self._device.modules[Module.Color].hsv @property - def color_temp(self) -> int: + def color_temp(self) -> Annotated[int, FeatureAttribute()]: """Whether the bulb supports color temperature changes.""" if not self.is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") @@ -66,7 +80,7 @@ def color_temp(self) -> int: return self._device.modules[Module.ColorTemperature].color_temp @property - def brightness(self) -> int: + def brightness(self) -> Annotated[int, FeatureAttribute()]: """Return the current brightness in percentage.""" if not self.is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") @@ -80,7 +94,7 @@ async def set_hsv( value: int | None = None, *, transition: int | None = None, - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set new HSV. Note, transition is not supported and will be ignored. @@ -97,7 +111,7 @@ async def set_hsv( async def set_color_temp( self, temp: int, *, brightness: int | None = None, transition: int | None = None - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set the color temperature of the device in kelvin. Note, transition is not supported and will be ignored. @@ -113,7 +127,7 @@ async def set_color_temp( async def set_brightness( self, brightness: int, *, transition: int | None = None - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set the brightness in percentage. Note, transition is not supported and will be ignored. diff --git a/pyproject.toml b/pyproject.toml index f9dfbf875..bce90cb0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,13 @@ classifiers = [ [project.optional-dependencies] speedups = ["orjson>=3.9.1", "kasa-crypt>=0.2.0"] -docs = ["sphinx~=6.2", "sphinx_rtd_theme~=2.0", "sphinxcontrib-programoutput~=0.0", "myst-parser", "docutils>=0.17"] +docs = [ + "sphinx_rtd_theme~=2.0", + "sphinxcontrib-programoutput~=0.0", + "myst-parser", + "docutils>=0.17", + "sphinx>=7.4.7", +] shell = ["ptpython", "rich"] [project.urls] diff --git a/uv.lock b/uv.lock index e84a5c356..0ae238c23 100644 --- a/uv.lock +++ b/uv.lock @@ -381,11 +381,11 @@ wheels = [ [[package]] name = "docutils" -version = "0.19" +version = "0.20.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/330ea8d383eb2ce973df34d1239b3b21e91cd8c865d21ff82902d952f91f/docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6", size = 2056383 } +sdist = { url = "https://files.pythonhosted.org/packages/1f/53/a5da4f2c5739cf66290fac1431ee52aff6851c7c8ffd8264f13affd7bcdd/docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b", size = 2058365 } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/69/e391bd51bc08ed9141ecd899a0ddb61ab6465309f1eb470905c0c8868081/docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc", size = 570472 }, + { url = "https://files.pythonhosted.org/packages/26/87/f238c0670b94533ac0353a4e2a1a771a0cc73277b88bff23d3ae35a256c1/docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", size = 572666 }, ] [[package]] @@ -556,14 +556,14 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "2.2.0" +version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/c0/59bd6d0571986f72899288a95d9d6178d0eebd70b6650f1bb3f0da90f8f7/markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1", size = 67120 } +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/25/2d88e8feee8e055d015343f9b86e370a1ccbec546f2865c98397aaef24af/markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30", size = 84466 }, + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, ] [[package]] @@ -628,14 +628,14 @@ wheels = [ [[package]] name = "mdit-py-plugins" -version = "0.3.5" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/e7/cc2720da8a32724b36d04c6dba5644154cdf883a1482b3bbb81959a642ed/mdit-py-plugins-0.3.5.tar.gz", hash = "sha256:eee0adc7195e5827e17e02d2a258a2ba159944a0748f59c5099a4a27f78fcf6a", size = 39871 } +sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/4c/a9b222f045f98775034d243198212cbea36d3524c3ee1e8ab8c0346d6953/mdit_py_plugins-0.3.5-py3-none-any.whl", hash = "sha256:ca9a0714ea59a24b2b044a1831f48d817dd0c817e84339f20e7889f392d77c4e", size = 52087 }, + { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316 }, ] [[package]] @@ -740,7 +740,7 @@ wheels = [ [[package]] name = "myst-parser" -version = "1.0.0" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, @@ -750,9 +750,9 @@ dependencies = [ { name = "pyyaml" }, { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/69/fbddb50198c6b0901a981e72ae30f1b7769d2dfac88071f7df41c946d133/myst-parser-1.0.0.tar.gz", hash = "sha256:502845659313099542bd38a2ae62f01360e7dd4b1310f025dd014dfc0439cdae", size = 84224 } +sdist = { url = "https://files.pythonhosted.org/packages/85/55/6d1741a1780e5e65038b74bce6689da15f620261c490c3511eb4c12bac4b/myst_parser-4.0.0.tar.gz", hash = "sha256:851c9dfb44e36e56d15d05e72f02b80da21a9e0d07cba96baf5e2d476bb91531", size = 93858 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/1f/1621ef434ac5da26c30d31fcca6d588e3383344902941713640ba717fa87/myst_parser-1.0.0-py3-none-any.whl", hash = "sha256:69fb40a586c6fa68995e6521ac0a525793935db7e724ca9bac1d33be51be9a4c", size = 77312 }, + { url = "https://files.pythonhosted.org/packages/ca/b4/b036f8fdb667587bb37df29dc6644681dd78b7a2a6321a34684b79412b28/myst_parser-4.0.0-py3-none-any.whl", hash = "sha256:b9317997552424448c6096c2558872fdb6f81d3ecb3a40ce84a7518798f3f28d", size = 84563 }, ] [[package]] @@ -1143,7 +1143,7 @@ requires-dist = [ { name = "orjson", marker = "extra == 'speedups'", specifier = ">=3.9.1" }, { name = "ptpython", marker = "extra == 'shell'" }, { name = "rich", marker = "extra == 'shell'" }, - { name = "sphinx", marker = "extra == 'docs'", specifier = "~=6.2" }, + { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.4.7" }, { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0" }, { name = "sphinxcontrib-programoutput", marker = "extra == 'docs'", specifier = "~=0.0" }, { name = "tzdata", marker = "platform_system == 'Windows'", specifier = ">=2024.2" }, @@ -1287,7 +1287,7 @@ wheels = [ [[package]] name = "sphinx" -version = "6.2.1" +version = "7.4.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alabaster" }, @@ -1307,9 +1307,9 @@ dependencies = [ { name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-serializinghtml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/6d/392defcc95ca48daf62aecb89550143e97a4651275e62a3d7755efe35a3a/Sphinx-6.2.1.tar.gz", hash = "sha256:6d56a34697bb749ffa0152feafc4b19836c755d90a7c59b72bc7dfd371b9cc6b", size = 6681092 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/d8/45ba6097c39ba44d9f0e1462fb232e13ca4ddb5aea93a385dcfa964687da/sphinx-6.2.1-py3-none-any.whl", hash = "sha256:97787ff1fa3256a3eef9eda523a63dbf299f7b47e053cfcf684a1c2a8380c912", size = 3024615 }, + { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624 }, ] [[package]] From 3dfada757518d0c19612e124213ac232f1ece8fb Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 26 Nov 2024 09:37:15 +0000 Subject: [PATCH 718/892] Add common Thermostat module (#977) --- kasa/__init__.py | 3 + kasa/interfaces/__init__.py | 3 + kasa/interfaces/thermostat.py | 65 +++++++++++++++++++++ kasa/module.py | 1 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/temperaturecontrol.py | 14 +---- kasa/smart/modules/thermostat.py | 74 ++++++++++++++++++++++++ kasa/smart/smartdevice.py | 6 ++ tests/fakeprotocol_smart.py | 13 +++++ tests/test_common_modules.py | 41 ++++++++++++- 10 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 kasa/interfaces/thermostat.py create mode 100644 kasa/smart/modules/thermostat.py diff --git a/kasa/__init__.py b/kasa/__init__.py index 059e093e2..d4a5022e3 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -36,6 +36,7 @@ ) from kasa.feature import Feature from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState +from kasa.interfaces.thermostat import Thermostat, ThermostatState from kasa.module import Module from kasa.protocols import BaseProtocol, IotProtocol, SmartProtocol from kasa.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401 @@ -72,6 +73,8 @@ "DeviceConnectionParameters", "DeviceEncryptionType", "DeviceFamily", + "ThermostatState", + "Thermostat", ] from . import iot diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index c83e56c77..e5fd4caee 100644 --- a/kasa/interfaces/__init__.py +++ b/kasa/interfaces/__init__.py @@ -6,6 +6,7 @@ from .light import Light, LightState from .lighteffect import LightEffect from .lightpreset import LightPreset +from .thermostat import Thermostat, ThermostatState from .time import Time __all__ = [ @@ -16,5 +17,7 @@ "LightEffect", "LightState", "LightPreset", + "Thermostat", + "ThermostatState", "Time", ] diff --git a/kasa/interfaces/thermostat.py b/kasa/interfaces/thermostat.py new file mode 100644 index 000000000..de7831b06 --- /dev/null +++ b/kasa/interfaces/thermostat.py @@ -0,0 +1,65 @@ +"""Interact with a TPLink Thermostat.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from enum import Enum +from typing import Annotated, Literal + +from ..module import FeatureAttribute, Module + + +class ThermostatState(Enum): + """Thermostat state.""" + + Heating = "heating" + Calibrating = "progress_calibration" + Idle = "idle" + Off = "off" + Unknown = "unknown" + + +class Thermostat(Module, ABC): + """Base class for TP-Link Thermostat.""" + + @property + @abstractmethod + def state(self) -> bool: + """Return thermostat state.""" + + @abstractmethod + async def set_state(self, enabled: bool) -> dict: + """Set thermostat state.""" + + @property + @abstractmethod + def mode(self) -> ThermostatState: + """Return thermostat state.""" + + @property + @abstractmethod + def target_temperature(self) -> Annotated[float, FeatureAttribute()]: + """Return target temperature.""" + + @abstractmethod + async def set_target_temperature( + self, target: float + ) -> Annotated[dict, FeatureAttribute()]: + """Set target temperature.""" + + @property + @abstractmethod + def temperature(self) -> Annotated[float, FeatureAttribute()]: + """Return current humidity in percentage.""" + return self._device.sys_info["current_temp"] + + @property + @abstractmethod + def temperature_unit(self) -> Literal["celsius", "fahrenheit"]: + """Return current temperature unit.""" + + @abstractmethod + async def set_temperature_unit( + self, unit: Literal["celsius", "fahrenheit"] + ) -> dict: + """Set the device temperature unit.""" diff --git a/kasa/module.py b/kasa/module.py index ba6791b0f..2b2e65f93 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -96,6 +96,7 @@ class Module(ABC): Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") LightPreset: Final[ModuleName[interfaces.LightPreset]] = ModuleName("LightPreset") + Thermostat: Final[ModuleName[interfaces.Thermostat]] = ModuleName("Thermostat") Time: Final[ModuleName[interfaces.Time]] = ModuleName("Time") # IOT only Modules diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index efe17aa4c..99820cfaf 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -27,6 +27,7 @@ from .reportmode import ReportMode from .temperaturecontrol import TemperatureControl from .temperaturesensor import TemperatureSensor +from .thermostat import Thermostat from .time import Time from .triggerlogs import TriggerLogs from .waterleaksensor import WaterleakSensor @@ -61,5 +62,6 @@ "MotionSensor", "TriggerLogs", "FrostProtection", + "Thermostat", "SmartLightEffect", ] diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index 138c3d2e3..5b0804614 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -3,24 +3,14 @@ from __future__ import annotations import logging -from enum import Enum from ...feature import Feature +from ...interfaces.thermostat import ThermostatState from ..smartmodule import SmartModule _LOGGER = logging.getLogger(__name__) -class ThermostatState(Enum): - """Thermostat state.""" - - Heating = "heating" - Calibrating = "progress_calibration" - Idle = "idle" - Off = "off" - Unknown = "unknown" - - class TemperatureControl(SmartModule): """Implementation of temperature module.""" @@ -56,7 +46,6 @@ def _initialize_features(self) -> None: category=Feature.Category.Config, ) ) - self._add_feature( Feature( self._device, @@ -69,7 +58,6 @@ def _initialize_features(self) -> None: type=Feature.Type.Switch, ) ) - self._add_feature( Feature( self._device, diff --git a/kasa/smart/modules/thermostat.py b/kasa/smart/modules/thermostat.py new file mode 100644 index 000000000..74aad4be1 --- /dev/null +++ b/kasa/smart/modules/thermostat.py @@ -0,0 +1,74 @@ +"""Module for a Thermostat.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from ...feature import Feature +from ...interfaces.thermostat import Thermostat as ThermostatInterface +from ...interfaces.thermostat import ThermostatState +from ...module import FeatureAttribute, Module +from ..smartmodule import SmartModule + + +class Thermostat(SmartModule, ThermostatInterface): + """Implementation of a Thermostat.""" + + @property + def _all_features(self) -> dict[str, Feature]: + """Get the features for this module and any sub modules.""" + ret: dict[str, Feature] = {} + if temp_control := self._device.modules.get(Module.TemperatureControl): + ret.update(**temp_control._module_features) + if temp_sensor := self._device.modules.get(Module.TemperatureSensor): + ret.update(**temp_sensor._module_features) + return ret + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def state(self) -> bool: + """Return thermostat state.""" + return self._device.modules[Module.TemperatureControl].state + + async def set_state(self, enabled: bool) -> dict: + """Set thermostat state.""" + return await self._device.modules[Module.TemperatureControl].set_state(enabled) + + @property + def mode(self) -> ThermostatState: + """Return thermostat state.""" + return self._device.modules[Module.TemperatureControl].mode + + @property + def target_temperature(self) -> Annotated[float, FeatureAttribute()]: + """Return target temperature.""" + return self._device.modules[Module.TemperatureControl].target_temperature + + async def set_target_temperature( + self, target: float + ) -> Annotated[dict, FeatureAttribute()]: + """Set target temperature.""" + return await self._device.modules[ + Module.TemperatureControl + ].set_target_temperature(target) + + @property + def temperature(self) -> Annotated[float, FeatureAttribute()]: + """Return current humidity in percentage.""" + return self._device.modules[Module.TemperatureSensor].temperature + + @property + def temperature_unit(self) -> Literal["celsius", "fahrenheit"]: + """Return current temperature unit.""" + return self._device.modules[Module.TemperatureSensor].temperature_unit + + async def set_temperature_unit( + self, unit: Literal["celsius", "fahrenheit"] + ) -> dict: + """Set the device temperature unit.""" + return await self._device.modules[ + Module.TemperatureSensor + ].set_temperature_unit(unit) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index bd0ea7c5c..0989842ab 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -24,6 +24,7 @@ DeviceModule, Firmware, Light, + Thermostat, Time, ) from .smartmodule import SmartModule @@ -361,6 +362,11 @@ async def _initialize_modules(self) -> None: or Module.ColorTemperature in self._modules ): self._modules[Light.__name__] = Light(self, "light") + if ( + Module.TemperatureControl in self._modules + and Module.TemperatureSensor in self._modules + ): + self._modules[Thermostat.__name__] = Thermostat(self, "thermostat") async def _initialize_features(self) -> None: """Initialize device features.""" diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index 2f4e6ec2f..448729ca7 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -449,6 +449,17 @@ def _edit_preset_rules(self, info, params): info["get_preset_rules"]["states"][params["index"]] = params["state"] return {"error_code": 0} + def _set_temperature_unit(self, info, params): + """Set or remove values as per the device behaviour.""" + unit = params["temp_unit"] + if unit not in {"celsius", "fahrenheit"}: + raise ValueError(f"Invalid value for temperature unit {unit}") + if "temp_unit" not in info["get_device_info"]: + return {"error_code": SmartErrorCode.UNKNOWN_METHOD_ERROR} + else: + info["get_device_info"]["temp_unit"] = unit + return {"error_code": 0} + def _update_sysinfo_key(self, info: dict, key: str, value: str) -> dict: """Update a single key in the main system info. @@ -551,6 +562,8 @@ async def _send_request(self, request_dict: dict): return self._set_preset_rules(info, params) elif method == "edit_preset_rules": return self._edit_preset_rules(info, params) + elif method == "set_temperature_unit": + return self._set_temperature_unit(info, params) elif method == "set_on_off_gradually_info": return self._set_on_off_gradually_info(info, params) elif method == "set_child_protection": diff --git a/tests/test_common_modules.py b/tests/test_common_modules.py index bd6189c51..32863604f 100644 --- a/tests/test_common_modules.py +++ b/tests/test_common_modules.py @@ -4,7 +4,7 @@ import pytest from pytest_mock import MockerFixture -from kasa import Device, LightState, Module +from kasa import Device, LightState, Module, ThermostatState from .device_fixtures import ( bulb_iot, @@ -57,6 +57,12 @@ light = parametrize_combine([bulb_smart, bulb_iot, dimmable]) +temp_control_smart = parametrize( + "has temp control smart", + component_filter="temp_control", + protocol_filter={"SMART.CHILD"}, +) + @led async def test_led_module(dev: Device, mocker: MockerFixture): @@ -325,6 +331,39 @@ async def test_light_preset_save(dev: Device, mocker: MockerFixture): assert new_preset_state.color_temp == new_preset.color_temp +@temp_control_smart +async def test_thermostat(dev: Device, mocker: MockerFixture): + """Test saving a new preset value.""" + therm_mod = next(get_parent_and_child_modules(dev, Module.Thermostat)) + assert therm_mod + + await therm_mod.set_state(False) + await dev.update() + assert therm_mod.state is False + assert therm_mod.mode is ThermostatState.Off + + await therm_mod.set_target_temperature(10) + await dev.update() + assert therm_mod.state is True + assert therm_mod.mode is ThermostatState.Heating + assert therm_mod.target_temperature == 10 + + target_temperature_feature = therm_mod.get_feature(therm_mod.set_target_temperature) + temp_control = dev.modules.get(Module.TemperatureControl) + assert temp_control + allowed_range = temp_control.allowed_temperature_range + assert target_temperature_feature.minimum_value == allowed_range[0] + assert target_temperature_feature.maximum_value == allowed_range[1] + + await therm_mod.set_temperature_unit("celsius") + await dev.update() + assert therm_mod.temperature_unit == "celsius" + + await therm_mod.set_temperature_unit("fahrenheit") + await dev.update() + assert therm_mod.temperature_unit == "fahrenheit" + + async def test_set_time(dev: Device): """Test setting the device time.""" time_mod = dev.modules[Module.Time] From 69e08c23856b994ce89aaf25c1b92a83e1d37434 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 26 Nov 2024 10:42:55 +0100 Subject: [PATCH 719/892] Expose energy command to cli (#1307) Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- kasa/cli/main.py | 1 + kasa/cli/usage.py | 20 ++---------------- kasa/smart/modules/energy.py | 8 +++++-- tests/test_cli.py | 41 +++++++++++++++++++++--------------- tests/test_emeter.py | 16 +++++++++----- 5 files changed, 44 insertions(+), 42 deletions(-) diff --git a/kasa/cli/main.py b/kasa/cli/main.py index 4db9bd9d8..d0efc73fe 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -75,6 +75,7 @@ def _legacy_type_to_class(_type: str) -> Any: "time": None, "schedule": None, "usage": None, + "energy": "usage", # device commands runnnable at top level "state": "device", "on": "device", diff --git a/kasa/cli/usage.py b/kasa/cli/usage.py index 90a0fa78c..c383f7697 100644 --- a/kasa/cli/usage.py +++ b/kasa/cli/usage.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import cast import asyncclick as click @@ -21,21 +20,6 @@ ) -@click.command() -@click.option("--index", type=int, required=False) -@click.option("--name", type=str, required=False) -@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) -@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) -@click.option("--erase", is_flag=True) -@click.pass_context -async def emeter(ctx: click.Context, index, name, year, month, erase): - """Query emeter for historical consumption.""" - logging.warning("Deprecated, use 'kasa energy'") - return await ctx.invoke( - energy, child_index=index, child=name, year=year, month=month, erase=erase - ) - - @click.command() @click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) @click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) @@ -46,7 +30,7 @@ async def energy(dev: Device, year, month, erase): Daily and monthly data provided in CSV format. """ - echo("[bold]== Emeter ==[/bold]") + echo("[bold]== Energy ==[/bold]") if not (energy := dev.modules.get(Module.Energy)): error("Device has no energy module.") return @@ -71,7 +55,7 @@ async def energy(dev: Device, year, month, erase): usage_data = await energy.get_daily_stats(year=month.year, month=month.month) else: # Call with no argument outputs summary data and returns - emeter_status = await energy.get_status() + emeter_status = energy.status echo("Current: {} A".format(emeter_status["current"])) echo("Voltage: {} V".format(emeter_status["voltage"])) diff --git a/kasa/smart/modules/energy.py b/kasa/smart/modules/energy.py index 611e88857..6b5bdb579 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -75,8 +75,12 @@ def status(self) -> EmeterStatus: async def get_status(self) -> EmeterStatus: """Return real-time statistics.""" - res = await self.call("get_energy_usage") - return self._get_status_from_energy(res["get_energy_usage"]) + if "get_emeter_data" in self.data: + res = await self.call("get_emeter_data") + return EmeterStatus(res["get_emeter_data"]) + else: + res = await self.call("get_energy_usage") + return self._get_status_from_energy(res["get_energy_usage"]) @property @raise_if_update_error diff --git a/tests/test_cli.py b/tests/test_cli.py index 52f5ff93c..bb707bb6a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,7 +2,7 @@ import os import re from datetime import datetime -from unittest.mock import ANY +from unittest.mock import ANY, PropertyMock, patch from zoneinfo import ZoneInfo import asyncclick as click @@ -40,7 +40,7 @@ ) from kasa.cli.main import TYPES, _legacy_type_to_class, cli, cmd_command, raw_command from kasa.cli.time import time -from kasa.cli.usage import emeter, energy +from kasa.cli.usage import energy from kasa.cli.wifi import wifi from kasa.discover import Discover, DiscoveryResult from kasa.iot import IotDevice @@ -432,38 +432,45 @@ async def test_time_set(dev: Device, mocker, runner): async def test_emeter(dev: Device, mocker, runner): - res = await runner.invoke(emeter, obj=dev) + mocker.patch("kasa.Discover.discover_single", return_value=dev) + base_cmd = ["--host", "dummy", "energy"] + res = await runner.invoke(cli, base_cmd, obj=dev) if not (energy := dev.modules.get(Module.Energy)): assert "Device has no energy module." in res.output return - assert "== Emeter ==" in res.output + assert "== Energy ==" in res.output if dev.device_type is not DeviceType.Strip: - res = await runner.invoke(emeter, ["--index", "0"], obj=dev) + res = await runner.invoke(cli, [*base_cmd, "--index", "0"], obj=dev) assert f"Device: {dev.host} does not have children" in res.output - res = await runner.invoke(emeter, ["--name", "mock"], obj=dev) + res = await runner.invoke(cli, [*base_cmd, "--name", "mock"], obj=dev) assert f"Device: {dev.host} does not have children" in res.output if dev.device_type is DeviceType.Strip and len(dev.children) > 0: child_energy = dev.children[0].modules.get(Module.Energy) assert child_energy - realtime_emeter = mocker.patch.object(child_energy, "get_status") - realtime_emeter.return_value = EmeterStatus({"voltage_mv": 122066}) - res = await runner.invoke(emeter, ["--index", "0"], obj=dev) - assert "Voltage: 122.066 V" in res.output - realtime_emeter.assert_called() - assert realtime_emeter.call_count == 1 + with patch.object( + type(child_energy), "status", new_callable=PropertyMock + ) as child_status: + child_status.return_value = EmeterStatus({"voltage_mv": 122066}) + + res = await runner.invoke(cli, [*base_cmd, "--index", "0"], obj=dev) + assert "Voltage: 122.066 V" in res.output + child_status.assert_called() + assert child_status.call_count == 1 - res = await runner.invoke(emeter, ["--name", dev.children[0].alias], obj=dev) - assert "Voltage: 122.066 V" in res.output - assert realtime_emeter.call_count == 2 + res = await runner.invoke( + cli, [*base_cmd, "--name", dev.children[0].alias], obj=dev + ) + assert "Voltage: 122.066 V" in res.output + assert child_status.call_count == 2 if isinstance(dev, IotDevice): monthly = mocker.patch.object(energy, "get_monthly_stats") monthly.return_value = {1: 1234} - res = await runner.invoke(emeter, ["--year", "1900"], obj=dev) + res = await runner.invoke(cli, [*base_cmd, "--year", "1900"], obj=dev) if not isinstance(dev, IotDevice): assert "Device does not support historical statistics" in res.output return @@ -474,7 +481,7 @@ async def test_emeter(dev: Device, mocker, runner): if isinstance(dev, IotDevice): daily = mocker.patch.object(energy, "get_daily_stats") daily.return_value = {1: 1234} - res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev) + res = await runner.invoke(cli, [*base_cmd, "--month", "1900-12"], obj=dev) if not isinstance(dev, IotDevice): assert "Device has no historical statistics" in res.output return diff --git a/tests/test_emeter.py b/tests/test_emeter.py index 7eb16f8bd..e796ffee1 100644 --- a/tests/test_emeter.py +++ b/tests/test_emeter.py @@ -23,14 +23,16 @@ CURRENT_CONSUMPTION_SCHEMA = Schema( Any( { - "voltage": Any(All(float, Range(min=0, max=300)), None), - "power": Any(Coerce(float), None), - "total": Any(Coerce(float), None), - "current": Any(All(float), None), "voltage_mv": Any(All(float, Range(min=0, max=300000)), int, None), "power_mw": Any(Coerce(float), None), - "total_wh": Any(Coerce(float), None), "current_ma": Any(All(float), int, None), + "energy_wh": Any(Coerce(float), None), + "total_wh": Any(Coerce(float), None), + "voltage": Any(All(float, Range(min=0, max=300)), None), + "power": Any(Coerce(float), None), + "current": Any(All(float), None), + "total": Any(Coerce(float), None), + "energy": Any(Coerce(float), None), "slot_id": Any(Coerce(int), None), }, None, @@ -65,6 +67,10 @@ async def test_get_emeter_realtime(dev): emeter = dev.modules[Module.Energy] current_emeter = await emeter.get_status() + # Check realtime query gets the same value as status property + # iot _query_helper strips out the error code from module responses. + # but it's not stripped out of the _modular_update queries. + assert current_emeter == {k: v for k, v in emeter.status.items() if k != "err_code"} CURRENT_CONSUMPTION_SCHEMA(current_emeter) From 0c755f7120a27425aa0111288dcd1f1f26ddf99b Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 26 Nov 2024 11:39:31 +0100 Subject: [PATCH 720/892] Include duration when disabling smooth transition on/off (#1313) Fixes #1309 --- kasa/smart/modules/lighttransition.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index 68c4af233..e623108fe 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -24,6 +24,7 @@ class LightTransition(SmartModule): REQUIRED_COMPONENT = "on_off_gradually" QUERY_GETTER_NAME = "get_on_off_gradually_info" MINIMUM_UPDATE_INTERVAL_SECS = 60 + # v3 added max_duration, we default to 60 when it's not available MAXIMUM_DURATION = 60 # Key in sysinfo that indicates state can be retrieved from there. @@ -144,10 +145,22 @@ async def set_enabled(self, enable: bool) -> dict: return await self.call("set_on_off_gradually_info", {"enable": enable}) else: on = await self.call( - "set_on_off_gradually_info", {"on_state": {"enable": enable}} + "set_on_off_gradually_info", + { + "on_state": { + "enable": enable, + "duration": self._on_state["duration"], + } + }, ) off = await self.call( - "set_on_off_gradually_info", {"off_state": {"enable": enable}} + "set_on_off_gradually_info", + { + "off_state": { + "enable": enable, + "duration": self._off_state["duration"], + } + }, ) return {**on, **off} @@ -167,7 +180,6 @@ def turn_on_transition(self) -> int: @property def _turn_on_transition_max(self) -> int: """Maximum turn on duration.""" - # v3 added max_duration, we default to 60 when it's not available return self._on_state["max_duration"] @allow_update_after @@ -184,7 +196,7 @@ async def set_turn_on_transition(self, seconds: int) -> dict: if seconds <= 0: return await self.call( "set_on_off_gradually_info", - {"on_state": {"enable": False}}, + {"on_state": {"enable": False, "duration": self._on_state["duration"]}}, ) return await self.call( @@ -220,7 +232,12 @@ async def set_turn_off_transition(self, seconds: int) -> dict: if seconds <= 0: return await self.call( "set_on_off_gradually_info", - {"off_state": {"enable": False}}, + { + "off_state": { + "enable": False, + "duration": self._off_state["duration"], + } + }, ) return await self.call( From 6c42b36865727b623f6a7f7fc1757f0b21bbf756 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 26 Nov 2024 11:36:30 +0000 Subject: [PATCH 721/892] Rename tests/smartcamera to tests/smartcam (#1315) --- tests/{smartcamera => smartcam}/__init__.py | 0 tests/{smartcamera => smartcam}/modules/__init__.py | 0 tests/{smartcamera => smartcam}/modules/test_alarm.py | 0 tests/{smartcamera => smartcam}/test_smartcamera.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename tests/{smartcamera => smartcam}/__init__.py (100%) rename tests/{smartcamera => smartcam}/modules/__init__.py (100%) rename tests/{smartcamera => smartcam}/modules/test_alarm.py (100%) rename tests/{smartcamera => smartcam}/test_smartcamera.py (100%) diff --git a/tests/smartcamera/__init__.py b/tests/smartcam/__init__.py similarity index 100% rename from tests/smartcamera/__init__.py rename to tests/smartcam/__init__.py diff --git a/tests/smartcamera/modules/__init__.py b/tests/smartcam/modules/__init__.py similarity index 100% rename from tests/smartcamera/modules/__init__.py rename to tests/smartcam/modules/__init__.py diff --git a/tests/smartcamera/modules/test_alarm.py b/tests/smartcam/modules/test_alarm.py similarity index 100% rename from tests/smartcamera/modules/test_alarm.py rename to tests/smartcam/modules/test_alarm.py diff --git a/tests/smartcamera/test_smartcamera.py b/tests/smartcam/test_smartcamera.py similarity index 100% rename from tests/smartcamera/test_smartcamera.py rename to tests/smartcam/test_smartcamera.py From f71450b8801323398196d6597a38e0a3dce559e6 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 26 Nov 2024 11:37:14 +0000 Subject: [PATCH 722/892] Do not error on smartcam hub attached smartcam child devices (#1314) --- kasa/smartcam/smartcamdevice.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index 4cbf3bbed..0e49be264 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -100,20 +100,29 @@ async def _initialize_children(self) -> None: resp = await self.protocol.query(child_info_query) self.internal_state.update(resp) - children_components = { + smart_children_components = { child["device_id"]: { - comp["id"]: int(comp["ver_code"]) for comp in child["component_list"] + comp["id"]: int(comp["ver_code"]) for comp in component_list } for child in resp["getChildDeviceComponentList"]["child_component_list"] + if (component_list := child.get("component_list")) + # Child camera devices will have a different component schema so only + # extract smart values. + and (first_comp := next(iter(component_list), None)) + and isinstance(first_comp, dict) + and "id" in first_comp + and "ver_code" in first_comp } children = {} for info in resp["getChildDeviceList"]["child_device_list"]: if ( - category := info.get("category") - ) and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP: - child_id = info["device_id"] + (category := info.get("category")) + and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP + and (child_id := info.get("device_id")) + and (child_components := smart_children_components.get(child_id)) + ): children[child_id] = await self._initialize_smart_child( - info, children_components[child_id] + info, child_components ) else: _LOGGER.debug("Child device type not supported: %s", info) From 6adb2b5c285fc71ec0ae40c3b60da29b0e57c6bf Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:10:02 +0000 Subject: [PATCH 723/892] Prepare 0.8.0 (#1312) ## [0.8.0](https://github.com/python-kasa/python-kasa/tree/0.8.0) (2024-11-26) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.7...0.8.0) **Release highlights:** - **Initial support for devices using the Tapo camera protocol, i.e. Tapo cameras and the Tapo H200 hub.** - New camera functionality such as exposing RTSP streaming urls and camera pan/tilt. - New way of testing module support for individual features with `has_feature` and `get_feature`. - Adding voltage and current monitoring to `smart` devices. - Migration from pydantic to mashumaro for serialization. Special thanks to @ryenitcher and @Puxtril for their new contributions to the improvement of the project! Also thanks to everyone who has helped with testing, contributing fixtures, and reporting issues! **Breaking change notes:** - Removed support for python <3.11. If you haven't got a compatible version try [uv](https://docs.astral.sh/uv/). - Renamed `device_config.to_dict()` to `device_config.to_dict_control_credentials()`. `to_dict()` is still available but takes no parameters. - From the `iot.Cloud` module the `iot.CloudInfo` class attributes have been converted to snake case. **Breaking changes:** - Migrate iot cloud module to mashumaro [\#1282](https://github.com/python-kasa/python-kasa/pull/1282) (@sdb9696) - Replace custom deviceconfig serialization with mashumaru [\#1274](https://github.com/python-kasa/python-kasa/pull/1274) (@sdb9696) - Remove support for python \<3.11 [\#1273](https://github.com/python-kasa/python-kasa/pull/1273) (@sdb9696) **Implemented enhancements:** - Update cli modify presets to support smart devices [\#1295](https://github.com/python-kasa/python-kasa/pull/1295) (@sdb9696) - Use credentials\_hash for smartcamera rtsp url [\#1293](https://github.com/python-kasa/python-kasa/pull/1293) (@sdb9696) - Add voltage and current monitoring to smart Devices [\#1281](https://github.com/python-kasa/python-kasa/pull/1281) (@ryenitcher) - Update cli feature command for actions not to require a value [\#1264](https://github.com/python-kasa/python-kasa/pull/1264) (@sdb9696) - Add pan tilt camera module [\#1261](https://github.com/python-kasa/python-kasa/pull/1261) (@sdb9696) - Add alarm module for smartcamera hubs [\#1258](https://github.com/python-kasa/python-kasa/pull/1258) (@sdb9696) - Move TAPO smartcamera out of experimental package [\#1255](https://github.com/python-kasa/python-kasa/pull/1255) (@sdb9696) - Add SmartCamera Led Module [\#1249](https://github.com/python-kasa/python-kasa/pull/1249) (@sdb9696) - Use component queries to select smartcamera modules [\#1248](https://github.com/python-kasa/python-kasa/pull/1248) (@sdb9696) - Print formatting for IotLightPreset [\#1216](https://github.com/python-kasa/python-kasa/pull/1216) (@Puxtril) - Allow getting Annotated features from modules [\#1018](https://github.com/python-kasa/python-kasa/pull/1018) (@sdb9696) - Add common Thermostat module [\#977](https://github.com/python-kasa/python-kasa/pull/977) (@sdb9696) **Fixed bugs:** - TP-Link Tapo S505D cannot disable gradual on/off [\#1309](https://github.com/python-kasa/python-kasa/issues/1309) - Inconsistent emeter information between features and emeter cli [\#1308](https://github.com/python-kasa/python-kasa/issues/1308) - How to dump power usage after latest updates? [\#1306](https://github.com/python-kasa/python-kasa/issues/1306) - kasa.discover: Got unsupported connection type: 'device\_family': 'SMART.IPCAMERA' [\#1267](https://github.com/python-kasa/python-kasa/issues/1267) - device \_\_repr\_\_ fails if no sys\_info [\#1262](https://github.com/python-kasa/python-kasa/issues/1262) - Tapo P110M: Error processing Energy for device, module will be unavailable: get\_energy\_usage for Energy [\#1243](https://github.com/python-kasa/python-kasa/issues/1243) - Listing light presets throws error [\#1201](https://github.com/python-kasa/python-kasa/issues/1201) - Include duration when disabling smooth transition on/off [\#1313](https://github.com/python-kasa/python-kasa/pull/1313) (@rytilahti) - Expose energy command to cli [\#1307](https://github.com/python-kasa/python-kasa/pull/1307) (@rytilahti) - Make discovery on unsupported devices less noisy [\#1291](https://github.com/python-kasa/python-kasa/pull/1291) (@rytilahti) - Fix repr for device created with no sysinfo or discovery info" [\#1266](https://github.com/python-kasa/python-kasa/pull/1266) (@sdb9696) - Fix discovery by alias for smart devices [\#1260](https://github.com/python-kasa/python-kasa/pull/1260) (@sdb9696) - Make \_\_repr\_\_ work on discovery info [\#1233](https://github.com/python-kasa/python-kasa/pull/1233) (@rytilahti) **Added support for devices:** - Add HS200 \(US\) Smart Fixture [\#1303](https://github.com/python-kasa/python-kasa/pull/1303) (@ZeliardM) - Add smartcamera devices to supported docs [\#1257](https://github.com/python-kasa/python-kasa/pull/1257) (@sdb9696) - Add P110M\(AU\) fixture [\#1244](https://github.com/python-kasa/python-kasa/pull/1244) (@rytilahti) - Add L630 fixture [\#1240](https://github.com/python-kasa/python-kasa/pull/1240) (@rytilahti) - Add EP40M Fixture [\#1238](https://github.com/python-kasa/python-kasa/pull/1238) (@ryenitcher) - Add KS220 Fixture [\#1237](https://github.com/python-kasa/python-kasa/pull/1237) (@ryenitcher) **Documentation updates:** - Use markdown footnotes in supported.md [\#1310](https://github.com/python-kasa/python-kasa/pull/1310) (@sdb9696) - Update docs for the new module attributes has/get feature [\#1301](https://github.com/python-kasa/python-kasa/pull/1301) (@sdb9696) - Fixup contributing.md for running test against a real device [\#1236](https://github.com/python-kasa/python-kasa/pull/1236) (@sdb9696) **Project maintenance:** - Rename tests/smartcamera to tests/smartcam [\#1315](https://github.com/python-kasa/python-kasa/pull/1315) (@sdb9696) - Do not error on smartcam hub attached smartcam child devices [\#1314](https://github.com/python-kasa/python-kasa/pull/1314) (@sdb9696) - Add P110M\(EU\) fixture [\#1305](https://github.com/python-kasa/python-kasa/pull/1305) (@sdb9696) - Run tests with caplog in a single worker [\#1304](https://github.com/python-kasa/python-kasa/pull/1304) (@sdb9696) - Rename smartcamera to smartcam [\#1300](https://github.com/python-kasa/python-kasa/pull/1300) (@sdb9696) - Move iot fixtures into iot subfolder [\#1299](https://github.com/python-kasa/python-kasa/pull/1299) (@sdb9696) - Annotate fan\_speed\_level of Fan interface [\#1298](https://github.com/python-kasa/python-kasa/pull/1298) (@sdb9696) - Add PIR ADC Values to Test Fixtures [\#1296](https://github.com/python-kasa/python-kasa/pull/1296) (@ryenitcher) - Exclude \_\_getattr\_\_ for deprecated attributes from type checkers [\#1294](https://github.com/python-kasa/python-kasa/pull/1294) (@sdb9696) - Simplify omit http\_client in DeviceConfig serialization [\#1292](https://github.com/python-kasa/python-kasa/pull/1292) (@sdb9696) - Add SMART Voltage Monitoring to Fixtures [\#1290](https://github.com/python-kasa/python-kasa/pull/1290) (@ryenitcher) - Remove pydantic dependency [\#1289](https://github.com/python-kasa/python-kasa/pull/1289) (@sdb9696) - Do not print out all the fixture names at the start of test runs [\#1287](https://github.com/python-kasa/python-kasa/pull/1287) (@sdb9696) - dump\_devinfo: iot light strip commands [\#1286](https://github.com/python-kasa/python-kasa/pull/1286) (@sdb9696) - Migrate TurnOnBehaviours to mashumaro [\#1285](https://github.com/python-kasa/python-kasa/pull/1285) (@sdb9696) - dump\_devinfo: query smartlife.iot.common.cloud for fw updates [\#1284](https://github.com/python-kasa/python-kasa/pull/1284) (@rytilahti) - Migrate RuleModule to mashumaro [\#1283](https://github.com/python-kasa/python-kasa/pull/1283) (@sdb9696) - Update sphinx dependency to 6.2 to fix docs build [\#1280](https://github.com/python-kasa/python-kasa/pull/1280) (@sdb9696) - Update DiscoveryResult to use mashu Annotated Alias [\#1279](https://github.com/python-kasa/python-kasa/pull/1279) (@sdb9696) - Extend dump\_devinfo iot queries [\#1278](https://github.com/python-kasa/python-kasa/pull/1278) (@sdb9696) - Migrate triggerlogs to mashumaru [\#1277](https://github.com/python-kasa/python-kasa/pull/1277) (@sdb9696) - Migrate smart firmware module to mashumaro [\#1276](https://github.com/python-kasa/python-kasa/pull/1276) (@sdb9696) - Migrate IotLightPreset to mashumaru [\#1275](https://github.com/python-kasa/python-kasa/pull/1275) (@sdb9696) - Allow callable coroutines for feature setters [\#1272](https://github.com/python-kasa/python-kasa/pull/1272) (@sdb9696) - Fix deprecated SSLContext\(\) usage [\#1271](https://github.com/python-kasa/python-kasa/pull/1271) (@sdb9696) - Use \_get\_device\_info methods for smart and iot devs in devtools [\#1265](https://github.com/python-kasa/python-kasa/pull/1265) (@sdb9696) - Remove experimental support [\#1256](https://github.com/python-kasa/python-kasa/pull/1256) (@sdb9696) - Move protocol modules into protocols package [\#1254](https://github.com/python-kasa/python-kasa/pull/1254) (@sdb9696) - Add linkcheck to readthedocs CI [\#1253](https://github.com/python-kasa/python-kasa/pull/1253) (@rytilahti) - Update cli energy command to use energy module [\#1252](https://github.com/python-kasa/python-kasa/pull/1252) (@sdb9696) - Consolidate warnings for fixtures missing child devices [\#1251](https://github.com/python-kasa/python-kasa/pull/1251) (@sdb9696) - Update smartcamera fixtures with components [\#1250](https://github.com/python-kasa/python-kasa/pull/1250) (@sdb9696) - Move transports into their own package [\#1247](https://github.com/python-kasa/python-kasa/pull/1247) (@rytilahti) - Fix warnings in our test suite [\#1246](https://github.com/python-kasa/python-kasa/pull/1246) (@rytilahti) - Move tests folder to top level of project [\#1242](https://github.com/python-kasa/python-kasa/pull/1242) (@sdb9696) - Fix test framework running against real devices [\#1235](https://github.com/python-kasa/python-kasa/pull/1235) (@sdb9696) - Add Additional Firmware Test Fixures [\#1234](https://github.com/python-kasa/python-kasa/pull/1234) (@ryenitcher) - Update DiscoveryResult to use Mashumaro instead of pydantic [\#1231](https://github.com/python-kasa/python-kasa/pull/1231) (@sdb9696) - Update fixture for ES20M 1.0.11 [\#1215](https://github.com/python-kasa/python-kasa/pull/1215) (@rytilahti) - Enable ruff check for ANN [\#1139](https://github.com/python-kasa/python-kasa/pull/1139) (@rytilahti) **Closed issues:** - Expose Fan speed range from the library [\#1008](https://github.com/python-kasa/python-kasa/issues/1008) - \[META\] 0.7 series - module support for SMART devices, support for introspectable device features and refactoring the library [\#783](https://github.com/python-kasa/python-kasa/issues/783) --- CHANGELOG.md | 121 +++++++++++++++ pyproject.toml | 2 +- uv.lock | 401 +++++++++++++++++++++++++------------------------ 3 files changed, 325 insertions(+), 199 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86c3aa84c..3e64db281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,126 @@ # Changelog +## [0.8.0](https://github.com/python-kasa/python-kasa/tree/0.8.0) (2024-11-26) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.7...0.8.0) + +**Release highlights:** + +- **Initial support for devices using the Tapo camera protocol, i.e. Tapo cameras and the Tapo H200 hub.** +- New camera functionality such as exposing RTSP streaming urls and camera pan/tilt. +- New way of testing module support for individual features with `has_feature` and `get_feature`. +- Adding voltage and current monitoring to `smart` devices. +- Migration from pydantic to mashumaro for serialization. + +Special thanks to @ryenitcher and @Puxtril for their new contributions to the improvement of the project! Also thanks to everyone who has helped with testing, contributing fixtures, and reporting issues! + +**Breaking change notes:** + +- Removed support for python <3.11. If you haven't got a compatible version try [uv](https://docs.astral.sh/uv/). +- Renamed `device_config.to_dict()` to `device_config.to_dict_control_credentials()`. `to_dict()` is still available but takes no parameters. +- From the `iot.Cloud` module the `iot.CloudInfo` class attributes have been converted to snake case. + + +**Breaking changes:** + +- Migrate iot cloud module to mashumaro [\#1282](https://github.com/python-kasa/python-kasa/pull/1282) (@sdb9696) +- Replace custom deviceconfig serialization with mashumaru [\#1274](https://github.com/python-kasa/python-kasa/pull/1274) (@sdb9696) +- Remove support for python \<3.11 [\#1273](https://github.com/python-kasa/python-kasa/pull/1273) (@sdb9696) + +**Implemented enhancements:** + +- Update cli modify presets to support smart devices [\#1295](https://github.com/python-kasa/python-kasa/pull/1295) (@sdb9696) +- Use credentials\_hash for smartcamera rtsp url [\#1293](https://github.com/python-kasa/python-kasa/pull/1293) (@sdb9696) +- Add voltage and current monitoring to smart Devices [\#1281](https://github.com/python-kasa/python-kasa/pull/1281) (@ryenitcher) +- Update cli feature command for actions not to require a value [\#1264](https://github.com/python-kasa/python-kasa/pull/1264) (@sdb9696) +- Add pan tilt camera module [\#1261](https://github.com/python-kasa/python-kasa/pull/1261) (@sdb9696) +- Add alarm module for smartcamera hubs [\#1258](https://github.com/python-kasa/python-kasa/pull/1258) (@sdb9696) +- Move TAPO smartcamera out of experimental package [\#1255](https://github.com/python-kasa/python-kasa/pull/1255) (@sdb9696) +- Add SmartCamera Led Module [\#1249](https://github.com/python-kasa/python-kasa/pull/1249) (@sdb9696) +- Use component queries to select smartcamera modules [\#1248](https://github.com/python-kasa/python-kasa/pull/1248) (@sdb9696) +- Print formatting for IotLightPreset [\#1216](https://github.com/python-kasa/python-kasa/pull/1216) (@Puxtril) +- Allow getting Annotated features from modules [\#1018](https://github.com/python-kasa/python-kasa/pull/1018) (@sdb9696) +- Add common Thermostat module [\#977](https://github.com/python-kasa/python-kasa/pull/977) (@sdb9696) + +**Fixed bugs:** + +- TP-Link Tapo S505D cannot disable gradual on/off [\#1309](https://github.com/python-kasa/python-kasa/issues/1309) +- Inconsistent emeter information between features and emeter cli [\#1308](https://github.com/python-kasa/python-kasa/issues/1308) +- How to dump power usage after latest updates? [\#1306](https://github.com/python-kasa/python-kasa/issues/1306) +- kasa.discover: Got unsupported connection type: 'device\_family': 'SMART.IPCAMERA' [\#1267](https://github.com/python-kasa/python-kasa/issues/1267) +- device \_\_repr\_\_ fails if no sys\_info [\#1262](https://github.com/python-kasa/python-kasa/issues/1262) +- Tapo P110M: Error processing Energy for device, module will be unavailable: get\_energy\_usage for Energy [\#1243](https://github.com/python-kasa/python-kasa/issues/1243) +- Listing light presets throws error [\#1201](https://github.com/python-kasa/python-kasa/issues/1201) +- Include duration when disabling smooth transition on/off [\#1313](https://github.com/python-kasa/python-kasa/pull/1313) (@rytilahti) +- Expose energy command to cli [\#1307](https://github.com/python-kasa/python-kasa/pull/1307) (@rytilahti) +- Make discovery on unsupported devices less noisy [\#1291](https://github.com/python-kasa/python-kasa/pull/1291) (@rytilahti) +- Fix repr for device created with no sysinfo or discovery info" [\#1266](https://github.com/python-kasa/python-kasa/pull/1266) (@sdb9696) +- Fix discovery by alias for smart devices [\#1260](https://github.com/python-kasa/python-kasa/pull/1260) (@sdb9696) +- Make \_\_repr\_\_ work on discovery info [\#1233](https://github.com/python-kasa/python-kasa/pull/1233) (@rytilahti) + +**Added support for devices:** + +- Add HS200 \(US\) Smart Fixture [\#1303](https://github.com/python-kasa/python-kasa/pull/1303) (@ZeliardM) +- Add smartcamera devices to supported docs [\#1257](https://github.com/python-kasa/python-kasa/pull/1257) (@sdb9696) +- Add P110M\(AU\) fixture [\#1244](https://github.com/python-kasa/python-kasa/pull/1244) (@rytilahti) +- Add L630 fixture [\#1240](https://github.com/python-kasa/python-kasa/pull/1240) (@rytilahti) +- Add EP40M Fixture [\#1238](https://github.com/python-kasa/python-kasa/pull/1238) (@ryenitcher) +- Add KS220 Fixture [\#1237](https://github.com/python-kasa/python-kasa/pull/1237) (@ryenitcher) + +**Documentation updates:** + +- Use markdown footnotes in supported.md [\#1310](https://github.com/python-kasa/python-kasa/pull/1310) (@sdb9696) +- Update docs for the new module attributes has/get feature [\#1301](https://github.com/python-kasa/python-kasa/pull/1301) (@sdb9696) +- Fixup contributing.md for running test against a real device [\#1236](https://github.com/python-kasa/python-kasa/pull/1236) (@sdb9696) + +**Project maintenance:** + +- Rename tests/smartcamera to tests/smartcam [\#1315](https://github.com/python-kasa/python-kasa/pull/1315) (@sdb9696) +- Do not error on smartcam hub attached smartcam child devices [\#1314](https://github.com/python-kasa/python-kasa/pull/1314) (@sdb9696) +- Add P110M\(EU\) fixture [\#1305](https://github.com/python-kasa/python-kasa/pull/1305) (@sdb9696) +- Run tests with caplog in a single worker [\#1304](https://github.com/python-kasa/python-kasa/pull/1304) (@sdb9696) +- Rename smartcamera to smartcam [\#1300](https://github.com/python-kasa/python-kasa/pull/1300) (@sdb9696) +- Move iot fixtures into iot subfolder [\#1299](https://github.com/python-kasa/python-kasa/pull/1299) (@sdb9696) +- Annotate fan\_speed\_level of Fan interface [\#1298](https://github.com/python-kasa/python-kasa/pull/1298) (@sdb9696) +- Add PIR ADC Values to Test Fixtures [\#1296](https://github.com/python-kasa/python-kasa/pull/1296) (@ryenitcher) +- Exclude \_\_getattr\_\_ for deprecated attributes from type checkers [\#1294](https://github.com/python-kasa/python-kasa/pull/1294) (@sdb9696) +- Simplify omit http\_client in DeviceConfig serialization [\#1292](https://github.com/python-kasa/python-kasa/pull/1292) (@sdb9696) +- Add SMART Voltage Monitoring to Fixtures [\#1290](https://github.com/python-kasa/python-kasa/pull/1290) (@ryenitcher) +- Remove pydantic dependency [\#1289](https://github.com/python-kasa/python-kasa/pull/1289) (@sdb9696) +- Do not print out all the fixture names at the start of test runs [\#1287](https://github.com/python-kasa/python-kasa/pull/1287) (@sdb9696) +- dump\_devinfo: iot light strip commands [\#1286](https://github.com/python-kasa/python-kasa/pull/1286) (@sdb9696) +- Migrate TurnOnBehaviours to mashumaro [\#1285](https://github.com/python-kasa/python-kasa/pull/1285) (@sdb9696) +- dump\_devinfo: query smartlife.iot.common.cloud for fw updates [\#1284](https://github.com/python-kasa/python-kasa/pull/1284) (@rytilahti) +- Migrate RuleModule to mashumaro [\#1283](https://github.com/python-kasa/python-kasa/pull/1283) (@sdb9696) +- Update sphinx dependency to 6.2 to fix docs build [\#1280](https://github.com/python-kasa/python-kasa/pull/1280) (@sdb9696) +- Update DiscoveryResult to use mashu Annotated Alias [\#1279](https://github.com/python-kasa/python-kasa/pull/1279) (@sdb9696) +- Extend dump\_devinfo iot queries [\#1278](https://github.com/python-kasa/python-kasa/pull/1278) (@sdb9696) +- Migrate triggerlogs to mashumaru [\#1277](https://github.com/python-kasa/python-kasa/pull/1277) (@sdb9696) +- Migrate smart firmware module to mashumaro [\#1276](https://github.com/python-kasa/python-kasa/pull/1276) (@sdb9696) +- Migrate IotLightPreset to mashumaru [\#1275](https://github.com/python-kasa/python-kasa/pull/1275) (@sdb9696) +- Allow callable coroutines for feature setters [\#1272](https://github.com/python-kasa/python-kasa/pull/1272) (@sdb9696) +- Fix deprecated SSLContext\(\) usage [\#1271](https://github.com/python-kasa/python-kasa/pull/1271) (@sdb9696) +- Use \_get\_device\_info methods for smart and iot devs in devtools [\#1265](https://github.com/python-kasa/python-kasa/pull/1265) (@sdb9696) +- Remove experimental support [\#1256](https://github.com/python-kasa/python-kasa/pull/1256) (@sdb9696) +- Move protocol modules into protocols package [\#1254](https://github.com/python-kasa/python-kasa/pull/1254) (@sdb9696) +- Add linkcheck to readthedocs CI [\#1253](https://github.com/python-kasa/python-kasa/pull/1253) (@rytilahti) +- Update cli energy command to use energy module [\#1252](https://github.com/python-kasa/python-kasa/pull/1252) (@sdb9696) +- Consolidate warnings for fixtures missing child devices [\#1251](https://github.com/python-kasa/python-kasa/pull/1251) (@sdb9696) +- Update smartcamera fixtures with components [\#1250](https://github.com/python-kasa/python-kasa/pull/1250) (@sdb9696) +- Move transports into their own package [\#1247](https://github.com/python-kasa/python-kasa/pull/1247) (@rytilahti) +- Fix warnings in our test suite [\#1246](https://github.com/python-kasa/python-kasa/pull/1246) (@rytilahti) +- Move tests folder to top level of project [\#1242](https://github.com/python-kasa/python-kasa/pull/1242) (@sdb9696) +- Fix test framework running against real devices [\#1235](https://github.com/python-kasa/python-kasa/pull/1235) (@sdb9696) +- Add Additional Firmware Test Fixures [\#1234](https://github.com/python-kasa/python-kasa/pull/1234) (@ryenitcher) +- Update DiscoveryResult to use Mashumaro instead of pydantic [\#1231](https://github.com/python-kasa/python-kasa/pull/1231) (@sdb9696) +- Update fixture for ES20M 1.0.11 [\#1215](https://github.com/python-kasa/python-kasa/pull/1215) (@rytilahti) +- Enable ruff check for ANN [\#1139](https://github.com/python-kasa/python-kasa/pull/1139) (@rytilahti) + +**Closed issues:** + +- Expose Fan speed range from the library [\#1008](https://github.com/python-kasa/python-kasa/issues/1008) +- \[META\] 0.7 series - module support for SMART devices, support for introspectable device features and refactoring the library [\#783](https://github.com/python-kasa/python-kasa/issues/783) + ## [0.7.7](https://github.com/python-kasa/python-kasa/tree/0.7.7) (2024-11-04) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.6...0.7.7) diff --git a/pyproject.toml b/pyproject.toml index bce90cb0a..506888cdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-kasa" -version = "0.7.7" +version = "0.8.0" description = "Python API for TP-Link Kasa and Tapo devices" license = {text = "GPL-3.0-or-later"} authors = [ { name = "python-kasa developers" }] diff --git a/uv.lock b/uv.lock index 0ae238c23..12e2cb812 100644 --- a/uv.lock +++ b/uv.lock @@ -1,9 +1,5 @@ version = 1 requires-python = ">=3.11, <4.0" -resolution-markers = [ - "python_full_version < '3.13'", - "python_full_version >= '3.13'", -] [[package]] name = "aiohappyeyeballs" @@ -16,7 +12,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.10.10" +version = "3.11.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -24,55 +20,56 @@ dependencies = [ { name = "attrs" }, { name = "frozenlist" }, { name = "multidict" }, + { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/7e/16e57e6cf20eb62481a2f9ce8674328407187950ccc602ad07c685279141/aiohttp-3.10.10.tar.gz", hash = "sha256:0631dd7c9f0822cc61c88586ca76d5b5ada26538097d0f1df510b082bad3411a", size = 7542993 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/31/3c351d17596194e5a38ef169a4da76458952b2497b4b54645b9d483cbbb0/aiohttp-3.10.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c30a0eafc89d28e7f959281b58198a9fa5e99405f716c0289b7892ca345fe45f", size = 586501 }, - { url = "https://files.pythonhosted.org/packages/a4/a8/a559d09eb08478cdead6b7ce05b0c4a133ba27fcdfa91e05d2e62867300d/aiohttp-3.10.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:258c5dd01afc10015866114e210fb7365f0d02d9d059c3c3415382ab633fcbcb", size = 398993 }, - { url = "https://files.pythonhosted.org/packages/c5/47/7736d4174613feef61d25332c3bd1a4f8ff5591fbd7331988238a7299485/aiohttp-3.10.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:15ecd889a709b0080f02721255b3f80bb261c2293d3c748151274dfea93ac871", size = 390647 }, - { url = "https://files.pythonhosted.org/packages/27/21/e9ba192a04b7160f5a8952c98a1de7cf8072ad150fa3abd454ead1ab1d7f/aiohttp-3.10.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3935f82f6f4a3820270842e90456ebad3af15810cf65932bd24da4463bc0a4c", size = 1306481 }, - { url = "https://files.pythonhosted.org/packages/cf/50/f364c01c8d0def1dc34747b2470969e216f5a37c7ece00fe558810f37013/aiohttp-3.10.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:413251f6fcf552a33c981c4709a6bba37b12710982fec8e558ae944bfb2abd38", size = 1344652 }, - { url = "https://files.pythonhosted.org/packages/1d/c2/74f608e984e9b585649e2e83883facad6fa3fc1d021de87b20cc67e8e5ae/aiohttp-3.10.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1720b4f14c78a3089562b8875b53e36b51c97c51adc53325a69b79b4b48ebcb", size = 1378498 }, - { url = "https://files.pythonhosted.org/packages/9f/a7/05a48c7c0a7a80a5591b1203bf1b64ca2ed6a2050af918d09c05852dc42b/aiohttp-3.10.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:679abe5d3858b33c2cf74faec299fda60ea9de62916e8b67e625d65bf069a3b7", size = 1292718 }, - { url = "https://files.pythonhosted.org/packages/7d/78/a925655018747e9790350180330032e27d6e0d7ed30bde545fae42f8c49c/aiohttp-3.10.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79019094f87c9fb44f8d769e41dbb664d6e8fcfd62f665ccce36762deaa0e911", size = 1251776 }, - { url = "https://files.pythonhosted.org/packages/47/9d/85c6b69f702351d1236594745a4fdc042fc43f494c247a98dac17e004026/aiohttp-3.10.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe2fb38c2ed905a2582948e2de560675e9dfbee94c6d5ccdb1301c6d0a5bf092", size = 1271716 }, - { url = "https://files.pythonhosted.org/packages/7f/a7/55fc805ff9b14af818903882ece08e2235b12b73b867b521b92994c52b14/aiohttp-3.10.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a3f00003de6eba42d6e94fabb4125600d6e484846dbf90ea8e48a800430cc142", size = 1266263 }, - { url = "https://files.pythonhosted.org/packages/1f/ec/d2be2ca7b063e4f91519d550dbc9c1cb43040174a322470deed90b3d3333/aiohttp-3.10.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1bbb122c557a16fafc10354b9d99ebf2f2808a660d78202f10ba9d50786384b9", size = 1321617 }, - { url = "https://files.pythonhosted.org/packages/c9/a3/b29f7920e1cd0a9a68a45dd3eb16140074d2efb1518d2e1f3e140357dc37/aiohttp-3.10.10-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:30ca7c3b94708a9d7ae76ff281b2f47d8eaf2579cd05971b5dc681db8caac6e1", size = 1339227 }, - { url = "https://files.pythonhosted.org/packages/8a/81/34b67235c47e232d807b4bbc42ba9b927c7ce9476872372fddcfd1e41b3d/aiohttp-3.10.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df9270660711670e68803107d55c2b5949c2e0f2e4896da176e1ecfc068b974a", size = 1299068 }, - { url = "https://files.pythonhosted.org/packages/04/1f/26a7fe11b6ad3184f214733428353c89ae9fe3e4f605a657f5245c5e720c/aiohttp-3.10.10-cp311-cp311-win32.whl", hash = "sha256:aafc8ee9b742ce75044ae9a4d3e60e3d918d15a4c2e08a6c3c3e38fa59b92d94", size = 362223 }, - { url = "https://files.pythonhosted.org/packages/10/91/85dcd93f64011434359ce2666bece981f08d31bc49df33261e625b28595d/aiohttp-3.10.10-cp311-cp311-win_amd64.whl", hash = "sha256:362f641f9071e5f3ee6f8e7d37d5ed0d95aae656adf4ef578313ee585b585959", size = 381576 }, - { url = "https://files.pythonhosted.org/packages/ae/99/4c5aefe5ad06a1baf206aed6598c7cdcbc7c044c46801cd0d1ecb758cae3/aiohttp-3.10.10-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9294bbb581f92770e6ed5c19559e1e99255e4ca604a22c5c6397b2f9dd3ee42c", size = 583536 }, - { url = "https://files.pythonhosted.org/packages/a9/36/8b3bc49b49cb6d2da40ee61ff15dbcc44fd345a3e6ab5bb20844df929821/aiohttp-3.10.10-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a8fa23fe62c436ccf23ff930149c047f060c7126eae3ccea005f0483f27b2e28", size = 395693 }, - { url = "https://files.pythonhosted.org/packages/e1/77/0aa8660dcf11fa65d61712dbb458c4989de220a844bd69778dff25f2d50b/aiohttp-3.10.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c6a5b8c7926ba5d8545c7dd22961a107526562da31a7a32fa2456baf040939f", size = 390898 }, - { url = "https://files.pythonhosted.org/packages/38/d2/b833d95deb48c75db85bf6646de0a697e7fb5d87bd27cbade4f9746b48b1/aiohttp-3.10.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:007ec22fbc573e5eb2fb7dec4198ef8f6bf2fe4ce20020798b2eb5d0abda6138", size = 1312060 }, - { url = "https://files.pythonhosted.org/packages/aa/5f/29fd5113165a0893de8efedf9b4737e0ba92dfcd791415a528f947d10299/aiohttp-3.10.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9627cc1a10c8c409b5822a92d57a77f383b554463d1884008e051c32ab1b3742", size = 1350553 }, - { url = "https://files.pythonhosted.org/packages/ad/cc/f835f74b7d344428469200105236d44606cfa448be1e7c95ca52880d9bac/aiohttp-3.10.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50edbcad60d8f0e3eccc68da67f37268b5144ecc34d59f27a02f9611c1d4eec7", size = 1392646 }, - { url = "https://files.pythonhosted.org/packages/bf/fe/1332409d845ca601893bbf2d76935e0b93d41686e5f333841c7d7a4a770d/aiohttp-3.10.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a45d85cf20b5e0d0aa5a8dca27cce8eddef3292bc29d72dcad1641f4ed50aa16", size = 1306310 }, - { url = "https://files.pythonhosted.org/packages/e4/a1/25a7633a5a513278a9892e333501e2e69c83e50be4b57a62285fb7a008c3/aiohttp-3.10.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b00807e2605f16e1e198f33a53ce3c4523114059b0c09c337209ae55e3823a8", size = 1260255 }, - { url = "https://files.pythonhosted.org/packages/f2/39/30eafe89e0e2a06c25e4762844c8214c0c0cd0fd9ffc3471694a7986f421/aiohttp-3.10.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f2d4324a98062be0525d16f768a03e0bbb3b9fe301ceee99611dc9a7953124e6", size = 1271141 }, - { url = "https://files.pythonhosted.org/packages/5b/fc/33125df728b48391ef1fcb512dfb02072158cc10d041414fb79803463020/aiohttp-3.10.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:438cd072f75bb6612f2aca29f8bd7cdf6e35e8f160bc312e49fbecab77c99e3a", size = 1280244 }, - { url = "https://files.pythonhosted.org/packages/3b/61/e42bf2c2934b5caa4e2ec0b5e5fd86989adb022b5ee60c2572a9d77cf6fe/aiohttp-3.10.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:baa42524a82f75303f714108fea528ccacf0386af429b69fff141ffef1c534f9", size = 1316805 }, - { url = "https://files.pythonhosted.org/packages/18/32/f52a5e2ae9ad3bba10e026a63a7a23abfa37c7d97aeeb9004eaa98df3ce3/aiohttp-3.10.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a7d8d14fe962153fc681f6366bdec33d4356f98a3e3567782aac1b6e0e40109a", size = 1343930 }, - { url = "https://files.pythonhosted.org/packages/05/be/6a403b464dcab3631fe8e27b0f1d906d9e45c5e92aca97ee007e5a895560/aiohttp-3.10.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1277cd707c465cd09572a774559a3cc7c7a28802eb3a2a9472588f062097205", size = 1306186 }, - { url = "https://files.pythonhosted.org/packages/8e/fd/bb50fe781068a736a02bf5c7ad5f3ab53e39f1d1e63110da6d30f7605edc/aiohttp-3.10.10-cp312-cp312-win32.whl", hash = "sha256:59bb3c54aa420521dc4ce3cc2c3fe2ad82adf7b09403fa1f48ae45c0cbde6628", size = 359289 }, - { url = "https://files.pythonhosted.org/packages/70/9e/5add7e240f77ef67c275c82cc1d08afbca57b77593118c1f6e920ae8ad3f/aiohttp-3.10.10-cp312-cp312-win_amd64.whl", hash = "sha256:0e1b370d8007c4ae31ee6db7f9a2fe801a42b146cec80a86766e7ad5c4a259cf", size = 379313 }, - { url = "https://files.pythonhosted.org/packages/b1/eb/618b1b76c7fe8082a71c9d62e3fe84c5b9af6703078caa9ec57850a12080/aiohttp-3.10.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ad7593bb24b2ab09e65e8a1d385606f0f47c65b5a2ae6c551db67d6653e78c28", size = 576114 }, - { url = "https://files.pythonhosted.org/packages/aa/37/3126995d7869f8b30d05381b81a2d4fb4ec6ad313db788e009bc6d39c211/aiohttp-3.10.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1eb89d3d29adaf533588f209768a9c02e44e4baf832b08118749c5fad191781d", size = 391901 }, - { url = "https://files.pythonhosted.org/packages/3e/f2/8fdfc845be1f811c31ceb797968523813f8e1263ee3e9120d61253f6848f/aiohttp-3.10.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3fe407bf93533a6fa82dece0e74dbcaaf5d684e5a51862887f9eaebe6372cd79", size = 387418 }, - { url = "https://files.pythonhosted.org/packages/60/d5/33d2061d36bf07e80286e04b7e0a4de37ce04b5ebfed72dba67659a05250/aiohttp-3.10.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50aed5155f819873d23520919e16703fc8925e509abbb1a1491b0087d1cd969e", size = 1287073 }, - { url = "https://files.pythonhosted.org/packages/00/52/affb55be16a4747740bd630b4c002dac6c5eac42f9bb64202fc3cf3f1930/aiohttp-3.10.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f05e9727ce409358baa615dbeb9b969db94324a79b5a5cea45d39bdb01d82e6", size = 1323612 }, - { url = "https://files.pythonhosted.org/packages/94/f2/cddb69b975387daa2182a8442566971d6410b8a0179bb4540d81c97b1611/aiohttp-3.10.10-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dffb610a30d643983aeb185ce134f97f290f8935f0abccdd32c77bed9388b42", size = 1368406 }, - { url = "https://files.pythonhosted.org/packages/c1/e4/afba7327da4d932da8c6e29aecaf855f9d52dace53ac15bfc8030a246f1b/aiohttp-3.10.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa6658732517ddabe22c9036479eabce6036655ba87a0224c612e1ae6af2087e", size = 1282761 }, - { url = "https://files.pythonhosted.org/packages/9f/6b/364856faa0c9031ea76e24ef0f7fef79cddd9fa8e7dba9a1771c6acc56b5/aiohttp-3.10.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:741a46d58677d8c733175d7e5aa618d277cd9d880301a380fd296975a9cdd7bc", size = 1236518 }, - { url = "https://files.pythonhosted.org/packages/46/af/c382846f8356fe64a7b5908bb9b477457aa23b71be7ed551013b7b7d4d87/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e00e3505cd80440f6c98c6d69269dcc2a119f86ad0a9fd70bccc59504bebd68a", size = 1250344 }, - { url = "https://files.pythonhosted.org/packages/87/53/294f87fc086fd0772d0ab82497beb9df67f0f27a8b3dd5742a2656db2bc6/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ffe595f10566f8276b76dc3a11ae4bb7eba1aac8ddd75811736a15b0d5311414", size = 1248956 }, - { url = "https://files.pythonhosted.org/packages/86/30/7d746717fe11bdfefb88bb6c09c5fc985d85c4632da8bb6018e273899254/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdfcf6443637c148c4e1a20c48c566aa694fa5e288d34b20fcdc58507882fed3", size = 1293379 }, - { url = "https://files.pythonhosted.org/packages/48/b9/45d670a834458db67a24258e9139ba61fa3bd7d69b98ecf3650c22806f8f/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d183cf9c797a5291e8301790ed6d053480ed94070637bfaad914dd38b0981f67", size = 1320108 }, - { url = "https://files.pythonhosted.org/packages/72/8c/804bb2e837a175635d2000a0659eafc15b2e9d92d3d81c8f69e141ecd0b0/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:77abf6665ae54000b98b3c742bc6ea1d1fb31c394bcabf8b5d2c1ac3ebfe7f3b", size = 1281546 }, - { url = "https://files.pythonhosted.org/packages/89/c0/862e6a9de3d6eeb126cd9d9ea388243b70df9b871ce1a42b193b7a4a77fc/aiohttp-3.10.10-cp313-cp313-win32.whl", hash = "sha256:4470c73c12cd9109db8277287d11f9dd98f77fc54155fc71a7738a83ffcc8ea8", size = 357516 }, - { url = "https://files.pythonhosted.org/packages/ae/63/3e1aee3e554263f3f1011cca50d78a4894ae16ce99bf78101ac3a2f0ef74/aiohttp-3.10.10-cp313-cp313-win_amd64.whl", hash = "sha256:486f7aabfa292719a2753c016cc3a8f8172965cabb3ea2e7f7436c7f5a22a151", size = 376785 }, +sdist = { url = "https://files.pythonhosted.org/packages/4b/cb/f9bb10e0cf6f01730b27d370b10cc15822bea4395acd687abc8cc5fed3ed/aiohttp-3.11.7.tar.gz", hash = "sha256:01a8aca4af3da85cea5c90141d23f4b0eee3cbecfd33b029a45a80f28c66c668", size = 7666482 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/7f/272fa1adf68fe2fbebfe686a67b50cfb40d86dfe47d0441aff6f0b7c4c0e/aiohttp-3.11.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cea52d11e02123f125f9055dfe0ccf1c3857225fb879e4a944fae12989e2aef2", size = 706820 }, + { url = "https://files.pythonhosted.org/packages/79/3c/6d612ef77cdba75364393f04c5c577481e3b5123a774eea447ada1ddd14f/aiohttp-3.11.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3ce18f703b7298e7f7633efd6a90138d99a3f9a656cb52c1201e76cb5d79cf08", size = 466654 }, + { url = "https://files.pythonhosted.org/packages/4f/b8/1052667d4800cd49bb4f869f1ed42f5e9d5acd4676275e64ccc244c9c040/aiohttp-3.11.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:670847ee6aeb3a569cd7cdfbe0c3bec1d44828bbfbe78c5d305f7f804870ef9e", size = 454041 }, + { url = "https://files.pythonhosted.org/packages/9f/07/80fa7302314a6ee1c9278550e9d95b77a4c895999bfbc5364ed0ee28dc7c/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dda726f89bfa5c465ba45b76515135a3ece0088dfa2da49b8bb278f3bdeea12", size = 1684778 }, + { url = "https://files.pythonhosted.org/packages/2e/30/a71eb45197ad6bb6af87dfb39be8b56417d24d916047d35ef3f164af87f4/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c25b74a811dba37c7ea6a14d99eb9402d89c8d739d50748a75f3cf994cf19c43", size = 1740992 }, + { url = "https://files.pythonhosted.org/packages/22/74/0f9394429f3c4197129333a150a85cb2a642df30097a39dd41257f0b3bdc/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5522ee72f95661e79db691310290c4618b86dff2d9b90baedf343fd7a08bf79", size = 1781816 }, + { url = "https://files.pythonhosted.org/packages/7f/1a/1e256b39179c98d16d53ac62f64bfcfe7c5b2c1e68b83cddd4165854524f/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fbf41a6bbc319a7816ae0f0177c265b62f2a59ad301a0e49b395746eb2a9884", size = 1676692 }, + { url = "https://files.pythonhosted.org/packages/9b/37/f19d2e00efcabb9183b16bd91244de1d9c4ff7bf0fb5b8302e29a78f3286/aiohttp-3.11.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59ee1925b5a5efdf6c4e7be51deee93984d0ac14a6897bd521b498b9916f1544", size = 1619523 }, + { url = "https://files.pythonhosted.org/packages/ae/3c/af50cf5e06b98783fd776f17077f7b7e755d461114af5d6744dc037fc3b0/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:24054fce8c6d6f33a3e35d1c603ef1b91bbcba73e3f04a22b4f2f27dac59b347", size = 1644084 }, + { url = "https://files.pythonhosted.org/packages/c0/a6/4e0233b085cbf2b6de573515c1eddde82f1c1f17e69347e32a5a5f2617ff/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:351849aca2c6f814575c1a485c01c17a4240413f960df1bf9f5deb0003c61a53", size = 1648332 }, + { url = "https://files.pythonhosted.org/packages/06/20/7062e76e7817318c421c0f9d7b650fb81aaecf6d2f3a9833805b45ec2ea8/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:12724f3a211fa243570e601f65a8831372caf1a149d2f1859f68479f07efec3d", size = 1730912 }, + { url = "https://files.pythonhosted.org/packages/6c/1c/ff6ae4b1789894e6faf8a4e260cd3861cad618dc80ad15326789a7765750/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7ea4490360b605804bea8173d2d086b6c379d6bb22ac434de605a9cbce006e7d", size = 1752619 }, + { url = "https://files.pythonhosted.org/packages/33/58/ddd5cba5ca245c00b04e9d28a7988b0f0eda02de494f8e62ecd2780655c2/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e0bf378db07df0a713a1e32381a1b277e62ad106d0dbe17b5479e76ec706d720", size = 1692801 }, + { url = "https://files.pythonhosted.org/packages/b2/fc/32d5e2070b43d3722b7ea65ddc6b03ffa39bcc4b5ab6395a825cde0872ad/aiohttp-3.11.7-cp311-cp311-win32.whl", hash = "sha256:cd8d62cab363dfe713067027a5adb4907515861f1e4ce63e7be810b83668b847", size = 414899 }, + { url = "https://files.pythonhosted.org/packages/ec/7e/50324c6d3df4540f5963def810b9927f220c99864065849a1dfcae77a6ce/aiohttp-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:bf0e6cce113596377cadda4e3ac5fb89f095bd492226e46d91b4baef1dd16f60", size = 440938 }, + { url = "https://files.pythonhosted.org/packages/bf/1e/2e96b2526c590dcb99db0b94ac4f9b927ecc07f94735a8a941dee143d48b/aiohttp-3.11.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4bb7493c3e3a36d3012b8564bd0e2783259ddd7ef3a81a74f0dbfa000fce48b7", size = 702326 }, + { url = "https://files.pythonhosted.org/packages/b5/ce/b5d7f3e68849f1f5e0b85af4ac9080b9d3c0a600857140024603653c2209/aiohttp-3.11.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e143b0ef9cb1a2b4f74f56d4fbe50caa7c2bb93390aff52f9398d21d89bc73ea", size = 461944 }, + { url = "https://files.pythonhosted.org/packages/28/fa/f4d98db1b7f8f0c3f74bdbd6d0d98cfc89984205cd33f1b8ee3f588ee5ad/aiohttp-3.11.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7c58a240260822dc07f6ae32a0293dd5bccd618bb2d0f36d51c5dbd526f89c0", size = 454348 }, + { url = "https://files.pythonhosted.org/packages/04/f0/c238dda5dc9a3d12b76636e2cf0ea475890ac3a1c7e4ff0fd6c3cea2fc2d/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d20cfe63a1c135d26bde8c1d0ea46fd1200884afbc523466d2f1cf517d1fe33", size = 1678795 }, + { url = "https://files.pythonhosted.org/packages/79/ee/3a18f792247e6d95dba13aaedc9dc317c3c6e75f4b88c2dd4b960d20ad2f/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12e4d45847a174f77b2b9919719203769f220058f642b08504cf8b1cf185dacf", size = 1734411 }, + { url = "https://files.pythonhosted.org/packages/f5/79/3eb84243087a9a32cae821622c935107b4b55a5b21b76772e8e6c41092e9/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf4efa2d01f697a7dbd0509891a286a4af0d86902fc594e20e3b1712c28c0106", size = 1788959 }, + { url = "https://files.pythonhosted.org/packages/91/93/ad77782c5edfa17aafc070bef978fbfb8459b2f150595ffb01b559c136f9/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee6a4cdcbf54b8083dc9723cdf5f41f722c00db40ccf9ec2616e27869151129", size = 1687463 }, + { url = "https://files.pythonhosted.org/packages/ba/48/db35bd21b7877efa0be5f28385d8978c55323c5ce7685712e53f3f6c0bd9/aiohttp-3.11.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c6095aaf852c34f42e1bd0cf0dc32d1e4b48a90bfb5054abdbb9d64b36acadcb", size = 1618374 }, + { url = "https://files.pythonhosted.org/packages/ba/77/30f87db55c79fd145ed5fd15b92f2e820ce81065d41ae437797aaa550e3b/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1cf03d27885f8c5ebf3993a220cc84fc66375e1e6e812731f51aab2b2748f4a6", size = 1637021 }, + { url = "https://files.pythonhosted.org/packages/af/76/10b188b78ee18d0595af156d6a238bc60f9d8571f0f546027eb7eaf65b25/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1a17f6a230f81eb53282503823f59d61dff14fb2a93847bf0399dc8e87817307", size = 1650792 }, + { url = "https://files.pythonhosted.org/packages/fa/33/4411bbb8ad04c47d0f4c7bd53332aaf350e49469cf6b65b132d4becafe27/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:481f10a1a45c5f4c4a578bbd74cff22eb64460a6549819242a87a80788461fba", size = 1696248 }, + { url = "https://files.pythonhosted.org/packages/fe/2d/6135d0dc1851a33d3faa937b20fef81340bc95e8310536d4c7f1f8ecc026/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:db37248535d1ae40735d15bdf26ad43be19e3d93ab3f3dad8507eb0f85bb8124", size = 1729188 }, + { url = "https://files.pythonhosted.org/packages/f5/76/a57ceff577ae26fe9a6f31ac799bc638ecf26e4acdf04295290b9929b349/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d18a8b44ec8502a7fde91446cd9c9b95ce7c49f1eacc1fb2358b8907d4369fd", size = 1690038 }, + { url = "https://files.pythonhosted.org/packages/4b/81/b20e09003b6989a7f23a721692137a6143420a151063c750ab2a04878e3c/aiohttp-3.11.7-cp312-cp312-win32.whl", hash = "sha256:3d1c9c15d3999107cbb9b2d76ca6172e6710a12fda22434ee8bd3f432b7b17e8", size = 409887 }, + { url = "https://files.pythonhosted.org/packages/b7/0b/607c98bff1d07bb21e0c39e7711108ef9ff4f2a361a3ec1ce8dce93623a5/aiohttp-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:018f1b04883a12e77e7fc161934c0f298865d3a484aea536a6a2ca8d909f0ba0", size = 436462 }, + { url = "https://files.pythonhosted.org/packages/7a/53/8d77186c6a33bd087714df18274cdcf6e36fd69a9e841c85b7e81a20b18e/aiohttp-3.11.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:241a6ca732d2766836d62c58c49ca7a93d08251daef0c1e3c850df1d1ca0cbc4", size = 695811 }, + { url = "https://files.pythonhosted.org/packages/62/b6/4c3d107a5406aa6f99f618afea82783f54ce2d9644020f50b9c88f6e823d/aiohttp-3.11.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:aa3705a8d14de39898da0fbad920b2a37b7547c3afd2a18b9b81f0223b7d0f68", size = 458530 }, + { url = "https://files.pythonhosted.org/packages/d9/05/dbf0bd3966be8ebed3beb4007a2d1356d79af4fe7c93e54f984df6385193/aiohttp-3.11.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9acfc7f652b31853eed3b92095b0acf06fd5597eeea42e939bd23a17137679d5", size = 451371 }, + { url = "https://files.pythonhosted.org/packages/19/6a/2198580314617b6cf9c4b813b84df5832b5f8efedcb8a7e8b321a187233c/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcefcf2915a2dbdbce37e2fc1622129a1918abfe3d06721ce9f6cdac9b6d2eaa", size = 1662905 }, + { url = "https://files.pythonhosted.org/packages/2b/65/08696fd7503f6a6f9f782bd012bf47f36d4ed179a7d8c95dba4726d5cc67/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c1f6490dd1862af5aae6cfcf2a274bffa9a5b32a8f5acb519a7ecf5a99a88866", size = 1713794 }, + { url = "https://files.pythonhosted.org/packages/c8/a3/b9a72dce6f15e2efbc09fa67c1067c4f3a3bb05661c0ae7b40799cde02b7/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac5462582d6561c1c1708853a9faf612ff4e5ea5e679e99be36143d6eabd8e", size = 1770757 }, + { url = "https://files.pythonhosted.org/packages/78/7e/8fb371b5f8c4c1eaa0d0a50750c0dd68059f86794aeb36919644815486f5/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1a6309005acc4b2bcc577ba3b9169fea52638709ffacbd071f3503264620da", size = 1673136 }, + { url = "https://files.pythonhosted.org/packages/2f/0f/09685d13d2c7634cb808868ea29c170d4dcde4215a4a90fb86491cd3ae25/aiohttp-3.11.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b973cce96793725ef63eb449adfb74f99c043c718acb76e0d2a447ae369962", size = 1600370 }, + { url = "https://files.pythonhosted.org/packages/00/2e/18fd38b117f9b3a375166ccb70ed43cf7e3dfe2cc947139acc15feefc5a2/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ce91a24aac80de6be8512fb1c4838a9881aa713f44f4e91dd7bb3b34061b497d", size = 1613459 }, + { url = "https://files.pythonhosted.org/packages/2c/94/10a82abc680d753be33506be699aaa330152ecc4f316eaf081f996ee56c2/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:875f7100ce0e74af51d4139495eec4025affa1a605280f23990b6434b81df1bd", size = 1613924 }, + { url = "https://files.pythonhosted.org/packages/e9/58/897c0561f5c522dda6e173192f1e4f10144e1a7126096f17a3f12b7aa168/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c171fc35d3174bbf4787381716564042a4cbc008824d8195eede3d9b938e29a8", size = 1681164 }, + { url = "https://files.pythonhosted.org/packages/8b/8b/3a48b1cdafa612679d976274355f6a822de90b85d7dba55654ecfb01c979/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ee9afa1b0d2293c46954f47f33e150798ad68b78925e3710044e0d67a9487791", size = 1712139 }, + { url = "https://files.pythonhosted.org/packages/aa/9d/70ab5b4dd7900db04af72840e033aee06e472b1343e372ea256ed675511c/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8360c7cc620abb320e1b8d603c39095101391a82b1d0be05fb2225471c9c5c52", size = 1667446 }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5fbcc8f6056f0c56001c75227e6b7ca9ee4f2e5572feca82ff3d65d485d/aiohttp-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7a9318da4b4ada9a67c1dd84d1c0834123081e746bee311a16bb449f363d965e", size = 408689 }, + { url = "https://files.pythonhosted.org/packages/ef/07/4d1504577fa6349dd2e3839e89fb56e5dee38d64efe3d4366e9fcfda0cdb/aiohttp-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:fc6da202068e0a268e298d7cd09b6e9f3997736cd9b060e2750963754552a0a9", size = 434809 }, ] [[package]] @@ -290,50 +287,50 @@ wheels = [ [[package]] name = "coverage" -version = "7.6.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/12/3669b6382792783e92046730ad3327f53b2726f0603f4c311c4da4824222/coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73", size = 798716 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/31/9c0cf84f0dfcbe4215b7eb95c31777cdc0483c13390e69584c8150c85175/coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b", size = 206819 }, - { url = "https://files.pythonhosted.org/packages/53/ed/a38401079ad320ad6e054a01ec2b61d270511aeb3c201c80e99c841229d5/coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25", size = 207263 }, - { url = "https://files.pythonhosted.org/packages/20/e7/c3ad33b179ab4213f0d70da25a9c214d52464efa11caeab438592eb1d837/coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546", size = 239205 }, - { url = "https://files.pythonhosted.org/packages/36/91/fc02e8d8e694f557752120487fd982f654ba1421bbaa5560debf96ddceda/coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b", size = 236612 }, - { url = "https://files.pythonhosted.org/packages/cc/57/cb08f0eda0389a9a8aaa4fc1f9fec7ac361c3e2d68efd5890d7042c18aa3/coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e", size = 238479 }, - { url = "https://files.pythonhosted.org/packages/d5/c9/2c7681a9b3ca6e6f43d489c2e6653a53278ed857fd6e7010490c307b0a47/coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718", size = 237405 }, - { url = "https://files.pythonhosted.org/packages/b5/4e/ebfc6944b96317df8b537ae875d2e57c27b84eb98820bc0a1055f358f056/coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db", size = 236038 }, - { url = "https://files.pythonhosted.org/packages/13/f2/3a0bf1841a97c0654905e2ef531170f02c89fad2555879db8fe41a097871/coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522", size = 236812 }, - { url = "https://files.pythonhosted.org/packages/b9/9c/66bf59226b52ce6ed9541b02d33e80a6e816a832558fbdc1111a7bd3abd4/coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf", size = 209400 }, - { url = "https://files.pythonhosted.org/packages/2a/a0/b0790934c04dfc8d658d4a62acb8f7ca0efdf3818456fcad757b11c6479d/coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19", size = 210243 }, - { url = "https://files.pythonhosted.org/packages/7d/e7/9291de916d084f41adddfd4b82246e68d61d6a75747f075f7e64628998d2/coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2", size = 207013 }, - { url = "https://files.pythonhosted.org/packages/27/03/932c2c5717a7fa80cd43c6a07d3177076d97b79f12f40f882f9916db0063/coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117", size = 207251 }, - { url = "https://files.pythonhosted.org/packages/d5/3f/0af47dcb9327f65a45455fbca846fe96eb57c153af46c4754a3ba678938a/coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613", size = 240268 }, - { url = "https://files.pythonhosted.org/packages/8a/3c/37a9d81bbd4b23bc7d46ca820e16174c613579c66342faa390a271d2e18b/coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27", size = 237298 }, - { url = "https://files.pythonhosted.org/packages/c0/70/6b0627e5bd68204ee580126ed3513140b2298995c1233bd67404b4e44d0e/coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52", size = 239367 }, - { url = "https://files.pythonhosted.org/packages/3c/eb/634d7dfab24ac3b790bebaf9da0f4a5352cbc125ce6a9d5c6cf4c6cae3c7/coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2", size = 238853 }, - { url = "https://files.pythonhosted.org/packages/d9/0d/8e3ed00f1266ef7472a4e33458f42e39492e01a64281084fb3043553d3f1/coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1", size = 237160 }, - { url = "https://files.pythonhosted.org/packages/ce/9c/4337f468ef0ab7a2e0887a9c9da0e58e2eada6fc6cbee637a4acd5dfd8a9/coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5", size = 238824 }, - { url = "https://files.pythonhosted.org/packages/5e/09/3e94912b8dd37251377bb02727a33a67ee96b84bbbe092f132b401ca5dd9/coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17", size = 209639 }, - { url = "https://files.pythonhosted.org/packages/01/69/d4f3a4101171f32bc5b3caec8ff94c2c60f700107a6aaef7244b2c166793/coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08", size = 210428 }, - { url = "https://files.pythonhosted.org/packages/c2/4d/2dede4f7cb5a70fb0bb40a57627fddf1dbdc6b9c1db81f7c4dcdcb19e2f4/coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9", size = 207039 }, - { url = "https://files.pythonhosted.org/packages/3f/f9/d86368ae8c79e28f1fb458ebc76ae9ff3e8bd8069adc24e8f2fed03c58b7/coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba", size = 207298 }, - { url = "https://files.pythonhosted.org/packages/64/c5/b4cc3c3f64622c58fbfd4d8b9a7a8ce9d355f172f91fcabbba1f026852f6/coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c", size = 239813 }, - { url = "https://files.pythonhosted.org/packages/8a/86/14c42e60b70a79b26099e4d289ccdfefbc68624d096f4481163085aa614c/coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06", size = 236959 }, - { url = "https://files.pythonhosted.org/packages/7f/f8/4436a643631a2fbab4b44d54f515028f6099bfb1cd95b13cfbf701e7f2f2/coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f", size = 238950 }, - { url = "https://files.pythonhosted.org/packages/49/50/1571810ddd01f99a0a8be464a4ac8b147f322cd1e8e296a1528984fc560b/coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b", size = 238610 }, - { url = "https://files.pythonhosted.org/packages/f3/8c/6312d241fe7cbd1f0cade34a62fea6f333d1a261255d76b9a87074d8703c/coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21", size = 236697 }, - { url = "https://files.pythonhosted.org/packages/ce/5f/fef33dfd05d87ee9030f614c857deb6df6556b8f6a1c51bbbb41e24ee5ac/coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a", size = 238541 }, - { url = "https://files.pythonhosted.org/packages/a9/64/6a984b6e92e1ea1353b7ffa08e27f707a5e29b044622445859200f541e8c/coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e", size = 209707 }, - { url = "https://files.pythonhosted.org/packages/5c/60/ce5a9e942e9543783b3db5d942e0578b391c25cdd5e7f342d854ea83d6b7/coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963", size = 210439 }, - { url = "https://files.pythonhosted.org/packages/78/53/6719677e92c308207e7f10561a1b16ab8b5c00e9328efc9af7cfd6fb703e/coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f", size = 207784 }, - { url = "https://files.pythonhosted.org/packages/fa/dd/7054928930671fcb39ae6a83bb71d9ab5f0afb733172543ced4b09a115ca/coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806", size = 208058 }, - { url = "https://files.pythonhosted.org/packages/b5/7d/fd656ddc2b38301927b9eb3aae3fe827e7aa82e691923ed43721fd9423c9/coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11", size = 250772 }, - { url = "https://files.pythonhosted.org/packages/90/d0/eb9a3cc2100b83064bb086f18aedde3afffd7de6ead28f69736c00b7f302/coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3", size = 246490 }, - { url = "https://files.pythonhosted.org/packages/45/44/3f64f38f6faab8a0cfd2c6bc6eb4c6daead246b97cf5f8fc23bf3788f841/coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a", size = 248848 }, - { url = "https://files.pythonhosted.org/packages/5d/11/4c465a5f98656821e499f4b4619929bd5a34639c466021740ecdca42aa30/coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc", size = 248340 }, - { url = "https://files.pythonhosted.org/packages/f1/96/ebecda2d016cce9da812f404f720ca5df83c6b29f65dc80d2000d0078741/coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70", size = 246229 }, - { url = "https://files.pythonhosted.org/packages/16/d9/3d820c00066ae55d69e6d0eae11d6149a5ca7546de469ba9d597f01bf2d7/coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef", size = 247510 }, - { url = "https://files.pythonhosted.org/packages/8f/c3/4fa1eb412bb288ff6bfcc163c11700ff06e02c5fad8513817186e460ed43/coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e", size = 210353 }, - { url = "https://files.pythonhosted.org/packages/7e/77/03fc2979d1538884d921c2013075917fc927f41cd8526909852fe4494112/coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1", size = 211502 }, +version = "7.6.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/75/aecfd0a3adbec6e45753976bc2a9fed62b42cea9a206d10fd29244a77953/coverage-7.6.8.tar.gz", hash = "sha256:8b2b8503edb06822c86d82fa64a4a5cb0760bb8f31f26e138ec743f422f37cfc", size = 801425 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/9f/e98211980f6e2f439e251737482aa77906c9b9c507824c71a2ce7eea0402/coverage-7.6.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:86cffe9c6dfcfe22e28027069725c7f57f4b868a3f86e81d1c62462764dc46d4", size = 207093 }, + { url = "https://files.pythonhosted.org/packages/fd/c7/8bab83fb9c20f7f8163c5a20dcb62d591b906a214a6dc6b07413074afc80/coverage-7.6.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d82ab6816c3277dc962cfcdc85b1efa0e5f50fb2c449432deaf2398a2928ab94", size = 207536 }, + { url = "https://files.pythonhosted.org/packages/1e/d6/00243df625f1b282bb25c83ce153ae2c06f8e7a796a8d833e7235337b4d9/coverage-7.6.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13690e923a3932e4fad4c0ebfb9cb5988e03d9dcb4c5150b5fcbf58fd8bddfc4", size = 239482 }, + { url = "https://files.pythonhosted.org/packages/1e/07/faf04b3eeb55ffc2a6f24b65dffe6e0359ec3b283e6efb5050ea0707446f/coverage-7.6.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4be32da0c3827ac9132bb488d331cb32e8d9638dd41a0557c5569d57cf22c9c1", size = 236886 }, + { url = "https://files.pythonhosted.org/packages/43/23/c79e497bf4d8fcacd316bebe1d559c765485b8ec23ac4e23025be6bfce09/coverage-7.6.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44e6c85bbdc809383b509d732b06419fb4544dca29ebe18480379633623baafb", size = 238749 }, + { url = "https://files.pythonhosted.org/packages/b5/e5/791bae13be3c6451e32ef7af1192e711c6a319f3c597e9b218d148fd0633/coverage-7.6.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:768939f7c4353c0fac2f7c37897e10b1414b571fd85dd9fc49e6a87e37a2e0d8", size = 237679 }, + { url = "https://files.pythonhosted.org/packages/05/c6/bbfdfb03aada601fb8993ced17468c8c8e0b4aafb3097026e680fabb7ce1/coverage-7.6.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e44961e36cb13c495806d4cac67640ac2866cb99044e210895b506c26ee63d3a", size = 236317 }, + { url = "https://files.pythonhosted.org/packages/67/f9/f8e5a4b2ce96d1b0e83ae6246369eb8437001dc80ec03bb51c87ff557cd8/coverage-7.6.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ea8bb1ab9558374c0ab591783808511d135a833c3ca64a18ec927f20c4030f0", size = 237084 }, + { url = "https://files.pythonhosted.org/packages/f0/70/b05328901e4debe76e033717e1452d00246c458c44e9dbd893e7619c2967/coverage-7.6.8-cp311-cp311-win32.whl", hash = "sha256:629a1ba2115dce8bf75a5cce9f2486ae483cb89c0145795603d6554bdc83e801", size = 209638 }, + { url = "https://files.pythonhosted.org/packages/70/55/1efa24f960a2fa9fbc44a9523d3f3c50ceb94dd1e8cd732168ab2dc41b07/coverage-7.6.8-cp311-cp311-win_amd64.whl", hash = "sha256:fb9fc32399dca861584d96eccd6c980b69bbcd7c228d06fb74fe53e007aa8ef9", size = 210506 }, + { url = "https://files.pythonhosted.org/packages/76/ce/3edf581c8fe429ed8ced6e6d9ac693c25975ef9093413276dab6ed68a80a/coverage-7.6.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e683e6ecc587643f8cde8f5da6768e9d165cd31edf39ee90ed7034f9ca0eefee", size = 207285 }, + { url = "https://files.pythonhosted.org/packages/09/9c/cf102ab046c9cf8895c3f7aadcde6f489a4b2ec326757e8c6e6581829b5e/coverage-7.6.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1defe91d41ce1bd44b40fabf071e6a01a5aa14de4a31b986aa9dfd1b3e3e414a", size = 207522 }, + { url = "https://files.pythonhosted.org/packages/39/06/42aa6dd13dbfca72e1fd8ffccadbc921b6e75db34545ebab4d955d1e7ad3/coverage-7.6.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7ad66e8e50225ebf4236368cc43c37f59d5e6728f15f6e258c8639fa0dd8e6d", size = 240543 }, + { url = "https://files.pythonhosted.org/packages/a0/20/2932971dc215adeca8eeff446266a7fef17a0c238e881ffedebe7bfa0669/coverage-7.6.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fe47da3e4fda5f1abb5709c156eca207eacf8007304ce3019eb001e7a7204cb", size = 237577 }, + { url = "https://files.pythonhosted.org/packages/ac/85/4323ece0cd5452c9522f4b6e5cc461e6c7149a4b1887c9e7a8b1f4e51146/coverage-7.6.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:202a2d645c5a46b84992f55b0a3affe4f0ba6b4c611abec32ee88358db4bb649", size = 239646 }, + { url = "https://files.pythonhosted.org/packages/77/52/b2537487d8f36241e518e84db6f79e26bc3343b14844366e35b090fae0d4/coverage-7.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4674f0daa1823c295845b6a740d98a840d7a1c11df00d1fd62614545c1583787", size = 239128 }, + { url = "https://files.pythonhosted.org/packages/7c/99/7f007762012186547d0ecc3d328da6b6f31a8c99f05dc1e13dcd929918cd/coverage-7.6.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:74610105ebd6f33d7c10f8907afed696e79c59e3043c5f20eaa3a46fddf33b4c", size = 237434 }, + { url = "https://files.pythonhosted.org/packages/97/53/e9b5cf0682a1cab9352adfac73caae0d77ae1d65abc88975d510f7816389/coverage-7.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cda8712145917105e07aab96388ae76e787270ec04bcb9d5cc786d7cbb8443", size = 239095 }, + { url = "https://files.pythonhosted.org/packages/0c/50/054f0b464fbae0483217186478eefa2e7df3a79917ed7f1d430b6da2cf0d/coverage-7.6.8-cp312-cp312-win32.whl", hash = "sha256:9e89d5c8509fbd6c03d0dd1972925b22f50db0792ce06324ba069f10787429ad", size = 209895 }, + { url = "https://files.pythonhosted.org/packages/df/d0/09ba870360a27ecf09e177ca2ff59d4337fc7197b456f22ceff85cffcfa5/coverage-7.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:379c111d3558272a2cae3d8e57e6b6e6f4fe652905692d54bad5ea0ca37c5ad4", size = 210684 }, + { url = "https://files.pythonhosted.org/packages/9a/84/6f0ccf94a098ac3d6d6f236bd3905eeac049a9e0efcd9a63d4feca37ac4b/coverage-7.6.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b0c69f4f724c64dfbfe79f5dfb503b42fe6127b8d479b2677f2b227478db2eb", size = 207313 }, + { url = "https://files.pythonhosted.org/packages/db/2b/e3b3a3a12ebec738c545897ac9f314620470fcbc368cdac88cf14974ba20/coverage-7.6.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c15b32a7aca8038ed7644f854bf17b663bc38e1671b5d6f43f9a2b2bd0c46f63", size = 207574 }, + { url = "https://files.pythonhosted.org/packages/db/c0/5bf95d42b6a8d21dfce5025ce187f15db57d6460a59b67a95fe8728162f1/coverage-7.6.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63068a11171e4276f6ece913bde059e77c713b48c3a848814a6537f35afb8365", size = 240090 }, + { url = "https://files.pythonhosted.org/packages/57/b8/d6fd17d1a8e2b0e1a4e8b9cb1f0f261afd422570735899759c0584236916/coverage-7.6.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4548c5ead23ad13fb7a2c8ea541357474ec13c2b736feb02e19a3085fac002", size = 237237 }, + { url = "https://files.pythonhosted.org/packages/d4/e4/a91e9bb46809c8b63e68fc5db5c4d567d3423b6691d049a4f950e38fbe9d/coverage-7.6.8-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4b4299dd0d2c67caaaf286d58aef5e75b125b95615dda4542561a5a566a1e3", size = 239225 }, + { url = "https://files.pythonhosted.org/packages/31/9c/9b99b0591ec4555b7292d271e005f27b465388ce166056c435b288db6a69/coverage-7.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9ebfb2507751f7196995142f057d1324afdab56db1d9743aab7f50289abd022", size = 238888 }, + { url = "https://files.pythonhosted.org/packages/a6/85/285c2df9a04bc7c31f21fd9d4a24d19e040ec5e2ff06e572af1f6514c9e7/coverage-7.6.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c1b4474beee02ede1eef86c25ad4600a424fe36cff01a6103cb4533c6bf0169e", size = 236974 }, + { url = "https://files.pythonhosted.org/packages/cb/a1/95ec8522206f76cdca033bf8bb61fff56429fb414835fc4d34651dfd29fc/coverage-7.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9fd2547e6decdbf985d579cf3fc78e4c1d662b9b0ff7cc7862baaab71c9cc5b", size = 238815 }, + { url = "https://files.pythonhosted.org/packages/8d/ac/687e9ba5e6d0979e9dab5c02e01c4f24ac58260ef82d88d3b433b3f84f1e/coverage-7.6.8-cp313-cp313-win32.whl", hash = "sha256:8aae5aea53cbfe024919715eca696b1a3201886ce83790537d1c3668459c7146", size = 209957 }, + { url = "https://files.pythonhosted.org/packages/2f/a3/b61cc8e3fcf075293fb0f3dee405748453c5ba28ac02ceb4a87f52bdb105/coverage-7.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:ae270e79f7e169ccfe23284ff5ea2d52a6f401dc01b337efb54b3783e2ce3f28", size = 210711 }, + { url = "https://files.pythonhosted.org/packages/ee/4b/891c8b9acf1b62c85e4a71dac142ab9284e8347409b7355de02e3f38306f/coverage-7.6.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de38add67a0af869b0d79c525d3e4588ac1ffa92f39116dbe0ed9753f26eba7d", size = 208053 }, + { url = "https://files.pythonhosted.org/packages/18/a9/9e330409b291cc002723d339346452800e78df1ce50774ca439ade1d374f/coverage-7.6.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b07c25d52b1c16ce5de088046cd2432b30f9ad5e224ff17c8f496d9cb7d1d451", size = 208329 }, + { url = "https://files.pythonhosted.org/packages/9c/0d/33635fd429f6589c6e1cdfc7bf581aefe4c1792fbff06383f9d37f59db60/coverage-7.6.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62a66ff235e4c2e37ed3b6104d8b478d767ff73838d1222132a7a026aa548764", size = 251052 }, + { url = "https://files.pythonhosted.org/packages/23/32/8a08da0e46f3830bbb9a5b40614241b2e700f27a9c2889f53122486443ed/coverage-7.6.8-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b9f848b28081e7b975a3626e9081574a7b9196cde26604540582da60235fdf", size = 246765 }, + { url = "https://files.pythonhosted.org/packages/56/3f/3b86303d2c14350fdb1c6c4dbf9bc76000af2382f42ca1d4d99c6317666e/coverage-7.6.8-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:093896e530c38c8e9c996901858ac63f3d4171268db2c9c8b373a228f459bbc5", size = 249125 }, + { url = "https://files.pythonhosted.org/packages/36/cb/c4f081b9023f9fd8646dbc4ef77be0df090263e8f66f4ea47681e0dc2cff/coverage-7.6.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a7b8ac36fd688c8361cbc7bf1cb5866977ece6e0b17c34aa0df58bda4fa18a4", size = 248615 }, + { url = "https://files.pythonhosted.org/packages/32/ee/53bdbf67760928c44b57b2c28a8c0a4bf544f85a9ee129a63ba5c78fdee4/coverage-7.6.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:38c51297b35b3ed91670e1e4efb702b790002e3245a28c76e627478aa3c10d83", size = 246507 }, + { url = "https://files.pythonhosted.org/packages/57/49/5a57910bd0af6d8e802b4ca65292576d19b54b49f81577fd898505dee075/coverage-7.6.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2e4e0f60cb4bd7396108823548e82fdab72d4d8a65e58e2c19bbbc2f1e2bfa4b", size = 247785 }, + { url = "https://files.pythonhosted.org/packages/bd/37/e450c9f6b297c79bb9858407396ed3e084dcc22990dd110ab01d5ceb9770/coverage-7.6.8-cp313-cp313t-win32.whl", hash = "sha256:6535d996f6537ecb298b4e287a855f37deaf64ff007162ec0afb9ab8ba3b8b71", size = 210605 }, + { url = "https://files.pythonhosted.org/packages/44/79/7d0c7dd237c6905018e2936cd1055fe1d42e7eba2ebab3c00f4aad2a27d7/coverage-7.6.8-cp313-cp313t-win_amd64.whl", hash = "sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc", size = 211777 }, ] [package.optional-dependencies] @@ -474,11 +471,11 @@ wheels = [ [[package]] name = "identify" -version = "2.6.1" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bb/25024dbcc93516c492b75919e76f389bac754a3e4248682fba32b250c880/identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98", size = 99097 } +sdist = { url = "https://files.pythonhosted.org/packages/1a/5f/05f0d167be94585d502b4adf8c7af31f1dc0b1c7e14f9938a88fdbbcf4a7/identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02", size = 99179 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/0c/4ef72754c050979fdcc06c744715ae70ea37e734816bb6514f79df77a42f/identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0", size = 98972 }, + { url = "https://files.pythonhosted.org/packages/c9/f5/09644a3ad803fae9eca8efa17e1f2aef380c7f0b02f7ec4e8d446e51d64a/identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd", size = 99049 }, ] [[package]] @@ -510,14 +507,14 @@ wheels = [ [[package]] name = "jedi" -version = "0.19.1" +version = "0.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parso" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/99/99b493cec4bf43176b678de30f81ed003fd6a647a301b9c927280c600f0a/jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd", size = 1227821 } +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/9f/bc63f0f0737ad7a60800bfd472a4836661adae21f9c2535f3957b1e54ceb/jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0", size = 1569361 }, + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, ] [[package]] @@ -616,14 +613,14 @@ wheels = [ [[package]] name = "mashumaro" -version = "3.14" +version = "3.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/47/0a450b281bef2d7e97ec02c8e1168d821e283f58e02e6c403b2bb4d73c1c/mashumaro-3.14.tar.gz", hash = "sha256:5ef6f2b963892cbe9a4ceb3441dfbea37f8c3412523f25d42e9b3a7186555f1d", size = 166160 } +sdist = { url = "https://files.pythonhosted.org/packages/f3/c1/7b687c8b993202e2eb49e559b25599d8e85f1b6d92ce676c8801226b8bdf/mashumaro-3.15.tar.gz", hash = "sha256:32a2a38a1e942a07f2cbf9c3061cb2a247714ee53e36a5958548b66bd116d0a9", size = 188646 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/35/8d63733a2c12149d0c7663c29bf626bdbeea5f0ff963afe58a42b4810981/mashumaro-3.14-py3-none-any.whl", hash = "sha256:c12a649599a8f7b1a0b35d18f12e678423c3066189f7bc7bd8dd431c5c8132c3", size = 92183 }, + { url = "https://files.pythonhosted.org/packages/f9/59/595eabaa779c87a72d65864351e0fdd2359d7d73967d5ed9f2f0c6186fa3/mashumaro-3.15-py3-none-any.whl", hash = "sha256:cdd45ef5a4d09860846a3ee37a4c2f5f4bc70eb158caa55648c4c99451ca6c4c", size = 93761 }, ] [[package]] @@ -766,46 +763,54 @@ wheels = [ [[package]] name = "orjson" -version = "3.10.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/3a/10320029954badc7eaa338a15ee279043436f396e965dafc169610e4933f/orjson-3.10.11.tar.gz", hash = "sha256:e35b6d730de6384d5b2dab5fd23f0d76fae8bbc8c353c2f78210aa5fa4beb3ef", size = 5444879 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/25/c869a1fbd481dcb02c70032fd6a7243de7582bc48c7cae03d6f0985a11c0/orjson-3.10.11-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1444f9cb7c14055d595de1036f74ecd6ce15f04a715e73f33bb6326c9cef01b6", size = 266432 }, - { url = "https://files.pythonhosted.org/packages/6a/a4/2307155ee92457d28345308f7d8c0e712348404723025613adeffcb531d0/orjson-3.10.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdec57fe3b4bdebcc08a946db3365630332dbe575125ff3d80a3272ebd0ddafe", size = 151884 }, - { url = "https://files.pythonhosted.org/packages/aa/82/daf1b2596dd49fe44a1bd92367568faf6966dcb5d7f99fd437c3d0dc2de6/orjson-3.10.11-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4eed32f33a0ea6ef36ccc1d37f8d17f28a1d6e8eefae5928f76aff8f1df85e67", size = 167371 }, - { url = "https://files.pythonhosted.org/packages/63/a8/680578e4589be5fdcfe0186bdd7dc6fe4a39d30e293a9da833cbedd5a56e/orjson-3.10.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80df27dd8697242b904f4ea54820e2d98d3f51f91e97e358fc13359721233e4b", size = 154368 }, - { url = "https://files.pythonhosted.org/packages/6e/ce/9cb394b5b01ef34579eeca6d704b21f97248f607067ce95a24ba9ea2698e/orjson-3.10.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:705f03cee0cb797256d54de6695ef219e5bc8c8120b6654dd460848d57a9af3d", size = 165725 }, - { url = "https://files.pythonhosted.org/packages/49/24/55eeb05cfb36b9e950d05743e6f6fdb7d5f33ca951a27b06ea6d03371aed/orjson-3.10.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03246774131701de8e7059b2e382597da43144a9a7400f178b2a32feafc54bd5", size = 142522 }, - { url = "https://files.pythonhosted.org/packages/94/0c/3a6a289e56dcc9fe67dc6b6d33c91dc5491f9ec4a03745efd739d2acf0ff/orjson-3.10.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8b5759063a6c940a69c728ea70d7c33583991c6982915a839c8da5f957e0103a", size = 146934 }, - { url = "https://files.pythonhosted.org/packages/1d/5c/a08c0e90a91e2526029a4681ff8c6fc4495b8bab77d48801144e378c7da9/orjson-3.10.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:677f23e32491520eebb19c99bb34675daf5410c449c13416f7f0d93e2cf5f981", size = 142904 }, - { url = "https://files.pythonhosted.org/packages/2c/c9/710286a60b14e88288ca014d43befb08bb0a4a6a0f51b875f8c2f05e8205/orjson-3.10.11-cp311-none-win32.whl", hash = "sha256:a11225d7b30468dcb099498296ffac36b4673a8398ca30fdaec1e6c20df6aa55", size = 144459 }, - { url = "https://files.pythonhosted.org/packages/7d/68/ef7b920e0a09e02b1a30daca1b4864938463797995c2fabe457c1500220a/orjson-3.10.11-cp311-none-win_amd64.whl", hash = "sha256:df8c677df2f9f385fcc85ab859704045fa88d4668bc9991a527c86e710392bec", size = 136444 }, - { url = "https://files.pythonhosted.org/packages/78/f2/a712dbcef6d84ff53e13056e7dc69d9d4844bd1e35e51b7431679ddd154d/orjson-3.10.11-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:360a4e2c0943da7c21505e47cf6bd725588962ff1d739b99b14e2f7f3545ba51", size = 266505 }, - { url = "https://files.pythonhosted.org/packages/94/54/53970831786d71f98fdc13c0f80451324c9b5c20fbf42f42ef6147607ee7/orjson-3.10.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:496e2cb45de21c369079ef2d662670a4892c81573bcc143c4205cae98282ba97", size = 151745 }, - { url = "https://files.pythonhosted.org/packages/35/38/482667da1ca7ef95d44d4d2328257a144fd2752383e688637c53ed474d2a/orjson-3.10.11-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7dfa8db55c9792d53c5952900c6a919cfa377b4f4534c7a786484a6a4a350c19", size = 167274 }, - { url = "https://files.pythonhosted.org/packages/23/2f/5bb0a03e819781d82dadb733fde8ebbe20d1777d1a33715d45ada4d82ce8/orjson-3.10.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51f3382415747e0dbda9dade6f1e1a01a9d37f630d8c9049a8ed0e385b7a90c0", size = 154605 }, - { url = "https://files.pythonhosted.org/packages/49/e9/14cc34d45c7bd51665aff9b1bb6b83475a61c52edb0d753fffe1adc97764/orjson-3.10.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f35a1b9f50a219f470e0e497ca30b285c9f34948d3c8160d5ad3a755d9299433", size = 165874 }, - { url = "https://files.pythonhosted.org/packages/7b/61/c2781ecf90f99623e97c67a31e8553f38a1ecebaf3189485726ac8641576/orjson-3.10.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f3b7c5803138e67028dde33450e054c87e0703afbe730c105f1fcd873496d5", size = 142813 }, - { url = "https://files.pythonhosted.org/packages/4d/4f/18c83f78b501b6608569b1610fcb5a25c9bb9ab6a7eb4b3a55131e0fba37/orjson-3.10.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f91d9eb554310472bd09f5347950b24442600594c2edc1421403d7610a0998fd", size = 146762 }, - { url = "https://files.pythonhosted.org/packages/ba/19/ea80d5b575abd3f76a790409c2b7b8a60f3fc9447965c27d09613b8bddf4/orjson-3.10.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dfbb2d460a855c9744bbc8e36f9c3a997c4b27d842f3d5559ed54326e6911f9b", size = 143186 }, - { url = "https://files.pythonhosted.org/packages/2c/f5/d835fee01a0284d4b78effc24d16e7609daac2ff6b6851ca1bdd3b6194fc/orjson-3.10.11-cp312-none-win32.whl", hash = "sha256:d4a62c49c506d4d73f59514986cadebb7e8d186ad510c518f439176cf8d5359d", size = 144489 }, - { url = "https://files.pythonhosted.org/packages/03/60/748e0e205060dec74328dfd835e47902eb5522ae011766da76bfff64e2f4/orjson-3.10.11-cp312-none-win_amd64.whl", hash = "sha256:f1eec3421a558ff7a9b010a6c7effcfa0ade65327a71bb9b02a1c3b77a247284", size = 136614 }, - { url = "https://files.pythonhosted.org/packages/13/92/400970baf46b987c058469e9e779fb7a40d54a5754914d3634cca417e054/orjson-3.10.11-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c46294faa4e4d0eb73ab68f1a794d2cbf7bab33b1dda2ac2959ffb7c61591899", size = 266402 }, - { url = "https://files.pythonhosted.org/packages/3c/fa/f126fc2d817552bd1f67466205abdcbff64eab16f6844fe6df2853528675/orjson-3.10.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52e5834d7d6e58a36846e059d00559cb9ed20410664f3ad156cd2cc239a11230", size = 140826 }, - { url = "https://files.pythonhosted.org/packages/ad/18/9b9664d7d4af5b4fe9fe6600b7654afc0684bba528260afdde10c4a530aa/orjson-3.10.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2fc947e5350fdce548bfc94f434e8760d5cafa97fb9c495d2fef6757aa02ec0", size = 142593 }, - { url = "https://files.pythonhosted.org/packages/20/f9/a30c68f12778d5e58e6b5cdd26f86ee2d0babce1a475073043f46fdd8402/orjson-3.10.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0efabbf839388a1dab5b72b5d3baedbd6039ac83f3b55736eb9934ea5494d258", size = 146777 }, - { url = "https://files.pythonhosted.org/packages/f2/97/12047b0c0e9b391d589fb76eb40538f522edc664f650f8e352fdaaf77ff5/orjson-3.10.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3f29634260708c200c4fe148e42b4aae97d7b9fee417fbdd74f8cfc265f15b0", size = 142961 }, - { url = "https://files.pythonhosted.org/packages/a4/97/d904e26c1cabf2dd6ab1b0909e9b790af28a7f0fcb9d8378d7320d4869eb/orjson-3.10.11-cp313-none-win32.whl", hash = "sha256:1a1222ffcee8a09476bbdd5d4f6f33d06d0d6642df2a3d78b7a195ca880d669b", size = 144486 }, - { url = "https://files.pythonhosted.org/packages/42/62/3760bd1e6e949321d99bab238d08db2b1266564d2f708af668f57109bb36/orjson-3.10.11-cp313-none-win_amd64.whl", hash = "sha256:bc274ac261cc69260913b2d1610760e55d3c0801bb3457ba7b9004420b6b4270", size = 136361 }, +version = "3.10.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/04/bb9f72987e7f62fb591d6c880c0caaa16238e4e530cbc3bdc84a7372d75f/orjson-3.10.12.tar.gz", hash = "sha256:0a78bbda3aea0f9f079057ee1ee8a1ecf790d4f1af88dd67493c6b8ee52506ff", size = 5438647 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/48/7c3cd094488f5a3bc58488555244609a8c4d105bc02f2b77e509debf0450/orjson-3.10.12-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a734c62efa42e7df94926d70fe7d37621c783dea9f707a98cdea796964d4cf74", size = 248687 }, + { url = "https://files.pythonhosted.org/packages/ff/90/e55f0e25c7fdd1f82551fe787f85df6f378170caca863c04c810cd8f2730/orjson-3.10.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:750f8b27259d3409eda8350c2919a58b0cfcd2054ddc1bd317a643afc646ef23", size = 136953 }, + { url = "https://files.pythonhosted.org/packages/2a/b3/109c020cf7fee747d400de53b43b183ca9d3ebda3906ad0b858eb5479718/orjson-3.10.12-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb52c22bfffe2857e7aa13b4622afd0dd9d16ea7cc65fd2bf318d3223b1b6252", size = 149090 }, + { url = "https://files.pythonhosted.org/packages/96/d4/35c0275dc1350707d182a1b5da16d1184b9439848060af541285407f18f9/orjson-3.10.12-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:440d9a337ac8c199ff8251e100c62e9488924c92852362cd27af0e67308c16ef", size = 140480 }, + { url = "https://files.pythonhosted.org/packages/3b/79/f863ff460c291ad2d882cc3b580cc444bd4ec60c9df55f6901e6c9a3f519/orjson-3.10.12-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9e15c06491c69997dfa067369baab3bf094ecb74be9912bdc4339972323f252", size = 156564 }, + { url = "https://files.pythonhosted.org/packages/98/7e/8d5835449ddd873424ee7b1c4ba73a0369c1055750990d824081652874d6/orjson-3.10.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:362d204ad4b0b8724cf370d0cd917bb2dc913c394030da748a3bb632445ce7c4", size = 131279 }, + { url = "https://files.pythonhosted.org/packages/46/f5/d34595b6d7f4f984c6fef289269a7f98abcdc2445ebdf90e9273487dda6b/orjson-3.10.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b57cbb4031153db37b41622eac67329c7810e5f480fda4cfd30542186f006ae", size = 139764 }, + { url = "https://files.pythonhosted.org/packages/b3/5b/ee6e9ddeab54a7b7806768151c2090a2d36025bc346a944f51cf172ef7f7/orjson-3.10.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:165c89b53ef03ce0d7c59ca5c82fa65fe13ddf52eeb22e859e58c237d4e33b9b", size = 131915 }, + { url = "https://files.pythonhosted.org/packages/c4/45/febee5951aef6db5cd8cdb260548101d7ece0ca9d4ddadadf1766306b7a4/orjson-3.10.12-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5dee91b8dfd54557c1a1596eb90bcd47dbcd26b0baaed919e6861f076583e9da", size = 415783 }, + { url = "https://files.pythonhosted.org/packages/27/a5/5a8569e49f3a6c093bee954a3de95062a231196f59e59df13a48e2420081/orjson-3.10.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77a4e1cfb72de6f905bdff061172adfb3caf7a4578ebf481d8f0530879476c07", size = 142387 }, + { url = "https://files.pythonhosted.org/packages/6e/05/02550fb38c5bf758f3994f55401233a2ef304e175f473f2ac6dbf464cc8b/orjson-3.10.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:038d42c7bc0606443459b8fe2d1f121db474c49067d8d14c6a075bbea8bf14dd", size = 130664 }, + { url = "https://files.pythonhosted.org/packages/8c/f4/ba31019d0646ce51f7ac75af6dabf98fd89dbf8ad87a9086da34710738e7/orjson-3.10.12-cp311-none-win32.whl", hash = "sha256:03b553c02ab39bed249bedd4abe37b2118324d1674e639b33fab3d1dafdf4d79", size = 143623 }, + { url = "https://files.pythonhosted.org/packages/83/fe/babf08842b989acf4c46103fefbd7301f026423fab47e6f3ba07b54d7837/orjson-3.10.12-cp311-none-win_amd64.whl", hash = "sha256:8b8713b9e46a45b2af6b96f559bfb13b1e02006f4242c156cbadef27800a55a8", size = 135074 }, + { url = "https://files.pythonhosted.org/packages/a1/2f/989adcafad49afb535da56b95d8f87d82e748548b2a86003ac129314079c/orjson-3.10.12-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:53206d72eb656ca5ac7d3a7141e83c5bbd3ac30d5eccfe019409177a57634b0d", size = 248678 }, + { url = "https://files.pythonhosted.org/packages/69/b9/8c075e21a50c387649db262b618ebb7e4d40f4197b949c146fc225dd23da/orjson-3.10.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac8010afc2150d417ebda810e8df08dd3f544e0dd2acab5370cfa6bcc0662f8f", size = 136763 }, + { url = "https://files.pythonhosted.org/packages/87/d3/78edf10b4ab14c19f6d918cf46a145818f4aca2b5a1773c894c5490d3a4c/orjson-3.10.12-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed459b46012ae950dd2e17150e838ab08215421487371fa79d0eced8d1461d70", size = 149137 }, + { url = "https://files.pythonhosted.org/packages/16/81/5db8852bdf990a0ddc997fa8f16b80895b8cc77c0fe3701569ed2b4b9e78/orjson-3.10.12-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dcb9673f108a93c1b52bfc51b0af422c2d08d4fc710ce9c839faad25020bb69", size = 140567 }, + { url = "https://files.pythonhosted.org/packages/fa/a6/9ce1e3e3db918512efadad489630c25841eb148513d21dab96f6b4157fa1/orjson-3.10.12-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:22a51ae77680c5c4652ebc63a83d5255ac7d65582891d9424b566fb3b5375ee9", size = 156620 }, + { url = "https://files.pythonhosted.org/packages/47/d4/05133d6bea24e292d2f7628b1e19986554f7d97b6412b3e51d812e38db2d/orjson-3.10.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:910fdf2ac0637b9a77d1aad65f803bac414f0b06f720073438a7bd8906298192", size = 131555 }, + { url = "https://files.pythonhosted.org/packages/b9/7a/b3fbffda8743135c7811e95dc2ab7cdbc5f04999b83c2957d046f1b3fac9/orjson-3.10.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:24ce85f7100160936bc2116c09d1a8492639418633119a2224114f67f63a4559", size = 139743 }, + { url = "https://files.pythonhosted.org/packages/b5/13/95bbcc9a6584aa083da5ce5004ce3d59ea362a542a0b0938d884fd8790b6/orjson-3.10.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a76ba5fc8dd9c913640292df27bff80a685bed3a3c990d59aa6ce24c352f8fc", size = 131733 }, + { url = "https://files.pythonhosted.org/packages/e8/29/dddbb2ea6e7af426fcc3da65a370618a88141de75c6603313d70768d1df1/orjson-3.10.12-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ff70ef093895fd53f4055ca75f93f047e088d1430888ca1229393a7c0521100f", size = 415788 }, + { url = "https://files.pythonhosted.org/packages/53/df/4aea59324ac539975919b4705ee086aced38e351a6eb3eea0f5071dd5661/orjson-3.10.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f4244b7018b5753ecd10a6d324ec1f347da130c953a9c88432c7fbc8875d13be", size = 142347 }, + { url = "https://files.pythonhosted.org/packages/55/55/a52d83d7c49f8ff44e0daab10554490447d6c658771569e1c662aa7057fe/orjson-3.10.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16135ccca03445f37921fa4b585cff9a58aa8d81ebcb27622e69bfadd220b32c", size = 130829 }, + { url = "https://files.pythonhosted.org/packages/a1/8b/b1beb1624dd4adf7d72e2d9b73c4b529e7851c0c754f17858ea13e368b33/orjson-3.10.12-cp312-none-win32.whl", hash = "sha256:2d879c81172d583e34153d524fcba5d4adafbab8349a7b9f16ae511c2cee8708", size = 143659 }, + { url = "https://files.pythonhosted.org/packages/13/91/634c9cd0bfc6a857fc8fab9bf1a1bd9f7f3345e0d6ca5c3d4569ceb6dcfa/orjson-3.10.12-cp312-none-win_amd64.whl", hash = "sha256:fc23f691fa0f5c140576b8c365bc942d577d861a9ee1142e4db468e4e17094fb", size = 135221 }, + { url = "https://files.pythonhosted.org/packages/1b/bb/3f560735f46fa6f875a9d7c4c2171a58cfb19f56a633d5ad5037a924f35f/orjson-3.10.12-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:47962841b2a8aa9a258b377f5188db31ba49af47d4003a32f55d6f8b19006543", size = 248662 }, + { url = "https://files.pythonhosted.org/packages/a3/df/54817902350636cc9270db20486442ab0e4db33b38555300a1159b439d16/orjson-3.10.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6334730e2532e77b6054e87ca84f3072bee308a45a452ea0bffbbbc40a67e296", size = 126055 }, + { url = "https://files.pythonhosted.org/packages/2e/77/55835914894e00332601a74540840f7665e81f20b3e2b9a97614af8565ed/orjson-3.10.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:accfe93f42713c899fdac2747e8d0d5c659592df2792888c6c5f829472e4f85e", size = 131507 }, + { url = "https://files.pythonhosted.org/packages/33/9e/b91288361898e3158062a876b5013c519a5d13e692ac7686e3486c4133ab/orjson-3.10.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a7974c490c014c48810d1dede6c754c3cc46598da758c25ca3b4001ac45b703f", size = 131686 }, + { url = "https://files.pythonhosted.org/packages/b2/15/08ce117d60a4d2d3fd24e6b21db463139a658e9f52d22c9c30af279b4187/orjson-3.10.12-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3f250ce7727b0b2682f834a3facff88e310f52f07a5dcfd852d99637d386e79e", size = 415710 }, + { url = "https://files.pythonhosted.org/packages/71/af/c09da5ed58f9c002cf83adff7a4cdf3e6cee742aa9723395f8dcdb397233/orjson-3.10.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f31422ff9486ae484f10ffc51b5ab2a60359e92d0716fcce1b3593d7bb8a9af6", size = 142305 }, + { url = "https://files.pythonhosted.org/packages/17/d1/8612038d44f33fae231e9ba480d273bac2b0383ce9e77cb06bede1224ae3/orjson-3.10.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5f29c5d282bb2d577c2a6bbde88d8fdcc4919c593f806aac50133f01b733846e", size = 130815 }, + { url = "https://files.pythonhosted.org/packages/67/2c/d5f87834be3591555cfaf9aecdf28f480a6f0b4afeaac53bad534bf9518f/orjson-3.10.12-cp313-none-win32.whl", hash = "sha256:f45653775f38f63dc0e6cd4f14323984c3149c05d6007b58cb154dd080ddc0dc", size = 143664 }, + { url = "https://files.pythonhosted.org/packages/6a/05/7d768fa3ca23c9b3e1e09117abeded1501119f1d8de0ab722938c91ab25d/orjson-3.10.12-cp313-none-win_amd64.whl", hash = "sha256:229994d0c376d5bdc91d92b3c9e6be2f1fbabd4cc1b59daae1443a46ee5e9825", size = 134944 }, ] [[package]] name = "packaging" -version = "24.1" +version = "24.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] [[package]] @@ -1083,7 +1088,7 @@ wheels = [ [[package]] name = "python-kasa" -version = "0.7.7" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -1424,11 +1429,11 @@ wheels = [ [[package]] name = "tomli" -version = "2.0.2" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/b9/de2a5c0144d7d75a57ff355c0c24054f965b2dc3036456ae03a51ea6264b/tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed", size = 16096 } +sdist = { url = "https://files.pythonhosted.org/packages/1e/e4/1b6cbcc82d8832dd0ce34767d5c560df8a3547ad8cbc427f34601415930a/tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8", size = 16622 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/db/ce8eda256fa131af12e0a76d481711abe4681b6923c27efb9a255c9e4594/tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", size = 13237 }, + { url = "https://files.pythonhosted.org/packages/de/f7/4da0ffe1892122c9ea096c57f64c2753ae5dd3ce85488802d11b0992cc6d/tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391", size = 13750 }, ] [[package]] @@ -1460,16 +1465,16 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.27.1" +version = "20.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/b3/7b6a79c5c8cf6d90ea681310e169cf2db2884f4d583d16c6e1d5a75a4e04/virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba", size = 6491145 } +sdist = { url = "https://files.pythonhosted.org/packages/bf/75/53316a5a8050069228a2f6d11f32046cfa94fbb6cc3f08703f59b873de2e/virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa", size = 7650368 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/92/78324ff89391e00c8f4cf6b8526c41c6ef36b4ea2d2c132250b1a6fc2b8d/virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4", size = 3117838 }, + { url = "https://files.pythonhosted.org/packages/10/f9/0919cf6f1432a8c4baa62511f8f8da8225432d22e83e3476f5be1a1edc6e/virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0", size = 4276702 }, ] [[package]] @@ -1501,62 +1506,62 @@ wheels = [ [[package]] name = "yarl" -version = "1.17.1" +version = "1.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/9c/9c0a9bfa683fc1be7fdcd9687635151544d992cccd48892dc5e0a5885a29/yarl-1.17.1.tar.gz", hash = "sha256:067a63fcfda82da6b198fa73079b1ca40b7c9b7994995b6ee38acda728b64d47", size = 178163 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/0f/ce6a2c8aab9946446fb27f1e28f0fd89ce84ae913ab18a92d18078a1c7ed/yarl-1.17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cbad927ea8ed814622305d842c93412cb47bd39a496ed0f96bfd42b922b4a217", size = 140727 }, - { url = "https://files.pythonhosted.org/packages/9d/df/204f7a502bdc3973cd9fc29e7dfad18ae48b3acafdaaf1ae07c0f41025aa/yarl-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fca4b4307ebe9c3ec77a084da3a9d1999d164693d16492ca2b64594340999988", size = 93560 }, - { url = "https://files.pythonhosted.org/packages/a2/e1/f4d522ae0560c91a4ea31113a50f00f85083be885e1092fc6e74eb43cb1d/yarl-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff5c6771c7e3511a06555afa317879b7db8d640137ba55d6ab0d0c50425cab75", size = 91497 }, - { url = "https://files.pythonhosted.org/packages/f1/82/783d97bf4a226f1a2e59b1966f2752244c2bf4dc89bc36f61d597b8e34e5/yarl-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b29beab10211a746f9846baa39275e80034e065460d99eb51e45c9a9495bcca", size = 339446 }, - { url = "https://files.pythonhosted.org/packages/e5/ff/615600647048d81289c80907165de713fbc566d1e024789863a2f6563ba3/yarl-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a52a1ffdd824fb1835272e125385c32fd8b17fbdefeedcb4d543cc23b332d74", size = 354616 }, - { url = "https://files.pythonhosted.org/packages/a5/04/bfb7adb452bd19dfe0c35354ffce8ebc3086e028e5f8270e409d17da5466/yarl-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58c8e9620eb82a189c6c40cb6b59b4e35b2ee68b1f2afa6597732a2b467d7e8f", size = 351801 }, - { url = "https://files.pythonhosted.org/packages/10/e0/efe21edacdc4a638ce911f8cabf1c77cac3f60e9819ba7d891b9ceb6e1d4/yarl-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d216e5d9b8749563c7f2c6f7a0831057ec844c68b4c11cb10fc62d4fd373c26d", size = 343381 }, - { url = "https://files.pythonhosted.org/packages/63/f9/7bc7e69857d6fc3920ecd173592f921d5701f4a0dd3f2ae293b386cfa3bf/yarl-1.17.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:881764d610e3269964fc4bb3c19bb6fce55422828e152b885609ec176b41cf11", size = 337093 }, - { url = "https://files.pythonhosted.org/packages/93/52/99da61947466275ff17d7bc04b0ac31dfb7ec699bd8d8985dffc34c3a913/yarl-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8c79e9d7e3d8a32d4824250a9c6401194fb4c2ad9a0cec8f6a96e09a582c2cc0", size = 346619 }, - { url = "https://files.pythonhosted.org/packages/91/8a/8aaad86a35a16e485ba0e5de0d2ae55bf8dd0c9f1cccac12be4c91366b1d/yarl-1.17.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:299f11b44d8d3a588234adbe01112126010bd96d9139c3ba7b3badd9829261c3", size = 344347 }, - { url = "https://files.pythonhosted.org/packages/af/b6/97f29f626b4a1768ffc4b9b489533612cfcb8905c90f745aade7b2eaf75e/yarl-1.17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cc7d768260f4ba4ea01741c1b5fe3d3a6c70eb91c87f4c8761bbcce5181beafe", size = 350316 }, - { url = "https://files.pythonhosted.org/packages/d7/98/8e0e8b812479569bdc34d66dd3e2471176ca33be4ff5c272a01333c4b269/yarl-1.17.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:de599af166970d6a61accde358ec9ded821234cbbc8c6413acfec06056b8e860", size = 361336 }, - { url = "https://files.pythonhosted.org/packages/9e/d3/d1507efa0a85c25285f8eb51df9afa1ba1b6e446dda781d074d775b6a9af/yarl-1.17.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2b24ec55fad43e476905eceaf14f41f6478780b870eda5d08b4d6de9a60b65b4", size = 365350 }, - { url = "https://files.pythonhosted.org/packages/22/ba/ee7f1830449c96bae6f33210b7d89e8aaf3079fbdaf78ac398e50a9da404/yarl-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9fb815155aac6bfa8d86184079652c9715c812d506b22cfa369196ef4e99d1b4", size = 357689 }, - { url = "https://files.pythonhosted.org/packages/a0/85/321c563dc5afe1661108831b965c512d185c61785400f5606006507d2e18/yarl-1.17.1-cp311-cp311-win32.whl", hash = "sha256:7615058aabad54416ddac99ade09a5510cf77039a3b903e94e8922f25ed203d7", size = 83635 }, - { url = "https://files.pythonhosted.org/packages/bc/da/543a32c00860588ff1235315b68f858cea30769099c32cd22b7bb266411b/yarl-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:14bc88baa44e1f84164a392827b5defb4fa8e56b93fecac3d15315e7c8e5d8b3", size = 90218 }, - { url = "https://files.pythonhosted.org/packages/5d/af/e25615c7920396219b943b9ff8b34636ae3e1ad30777649371317d7f05f8/yarl-1.17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:327828786da2006085a4d1feb2594de6f6d26f8af48b81eb1ae950c788d97f61", size = 141839 }, - { url = "https://files.pythonhosted.org/packages/83/5e/363d9de3495c7c66592523f05d21576a811015579e0c87dd38c7b5788afd/yarl-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cc353841428d56b683a123a813e6a686e07026d6b1c5757970a877195f880c2d", size = 94125 }, - { url = "https://files.pythonhosted.org/packages/e3/a2/b65447626227ebe36f18f63ac551790068bf42c69bb22dfa3ae986170728/yarl-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73df5b6e8fabe2ddb74876fb82d9dd44cbace0ca12e8861ce9155ad3c886139", size = 92048 }, - { url = "https://files.pythonhosted.org/packages/a1/f5/2ef86458446f85cde10582054fd5113495ef8ce8477da35aaaf26d2970ef/yarl-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bdff5e0995522706c53078f531fb586f56de9c4c81c243865dd5c66c132c3b5", size = 331472 }, - { url = "https://files.pythonhosted.org/packages/f3/6b/1ba79758ba352cdf2ad4c20cab1b982dd369aa595bb0d7601fc89bf82bee/yarl-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06157fb3c58f2736a5e47c8fcbe1afc8b5de6fb28b14d25574af9e62150fcaac", size = 341260 }, - { url = "https://files.pythonhosted.org/packages/2d/41/4e07c2afca3f9ed3da5b0e38d43d0280d9b624a3d5c478c425e5ce17775c/yarl-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1654ec814b18be1af2c857aa9000de7a601400bd4c9ca24629b18486c2e35463", size = 340882 }, - { url = "https://files.pythonhosted.org/packages/c3/c0/cd8e94618983c1b811af082e1a7ad7764edb3a6af2bc6b468e0e686238ba/yarl-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f6595c852ca544aaeeb32d357e62c9c780eac69dcd34e40cae7b55bc4fb1147", size = 336648 }, - { url = "https://files.pythonhosted.org/packages/ac/fc/73ec4340d391ffbb8f34eb4c55429784ec9f5bd37973ce86d52d67135418/yarl-1.17.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:459e81c2fb920b5f5df744262d1498ec2c8081acdcfe18181da44c50f51312f7", size = 325019 }, - { url = "https://files.pythonhosted.org/packages/57/48/da3ebf418fc239d0a156b3bdec6b17a5446f8d2dea752299c6e47b143a85/yarl-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e48cdb8226644e2fbd0bdb0a0f87906a3db07087f4de77a1b1b1ccfd9e93685", size = 342841 }, - { url = "https://files.pythonhosted.org/packages/5d/79/107272745a470a8167924e353a5312eb52b5a9bb58e22686adc46c94f7ec/yarl-1.17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d9b6b28a57feb51605d6ae5e61a9044a31742db557a3b851a74c13bc61de5172", size = 341433 }, - { url = "https://files.pythonhosted.org/packages/30/9c/6459668b3b8dcc11cd061fc53e12737e740fb6b1575b49c84cbffb387b3a/yarl-1.17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e594b22688d5747b06e957f1ef822060cb5cb35b493066e33ceac0cf882188b7", size = 344927 }, - { url = "https://files.pythonhosted.org/packages/c5/0b/93a17ed733aca8164fc3a01cb7d47b3f08854ce4f957cce67a6afdb388a0/yarl-1.17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5f236cb5999ccd23a0ab1bd219cfe0ee3e1c1b65aaf6dd3320e972f7ec3a39da", size = 355732 }, - { url = "https://files.pythonhosted.org/packages/9a/63/ead2ed6aec3c59397e135cadc66572330325a0c24cd353cd5c94f5e63463/yarl-1.17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a2a64e62c7a0edd07c1c917b0586655f3362d2c2d37d474db1a509efb96fea1c", size = 362123 }, - { url = "https://files.pythonhosted.org/packages/89/bf/f6b75b4c2fcf0e7bb56edc0ed74e33f37fac45dc40e5a52a3be66b02587a/yarl-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d0eea830b591dbc68e030c86a9569826145df485b2b4554874b07fea1275a199", size = 356355 }, - { url = "https://files.pythonhosted.org/packages/45/1f/50a0257cd07eef65c8c65ad6a21f5fb230012d659e021aeb6ac8a7897bf6/yarl-1.17.1-cp312-cp312-win32.whl", hash = "sha256:46ddf6e0b975cd680eb83318aa1d321cb2bf8d288d50f1754526230fcf59ba96", size = 83279 }, - { url = "https://files.pythonhosted.org/packages/bc/82/fafb2c1268d63d54ec08b3a254fbe51f4ef098211501df646026717abee3/yarl-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:117ed8b3732528a1e41af3aa6d4e08483c2f0f2e3d3d7dca7cf538b3516d93df", size = 89590 }, - { url = "https://files.pythonhosted.org/packages/06/1e/5a93e3743c20eefbc68bd89334d9c9f04f3f2334380f7bbf5e950f29511b/yarl-1.17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5d1d42556b063d579cae59e37a38c61f4402b47d70c29f0ef15cee1acaa64488", size = 139974 }, - { url = "https://files.pythonhosted.org/packages/a1/be/4e0f6919013c7c5eaea5c31811c551ccd599d2fc80aa3dd6962f1bbdcddd/yarl-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0167540094838ee9093ef6cc2c69d0074bbf84a432b4995835e8e5a0d984374", size = 93364 }, - { url = "https://files.pythonhosted.org/packages/73/f0/650f994bc491d0cb85df8bb45392780b90eab1e175f103a5edc61445ff67/yarl-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2f0a6423295a0d282d00e8701fe763eeefba8037e984ad5de44aa349002562ac", size = 91177 }, - { url = "https://files.pythonhosted.org/packages/f3/e8/9945ed555d14b43ede3ae8b1bd73e31068a694cad2b9d3cad0a28486c2eb/yarl-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5b078134f48552c4d9527db2f7da0b5359abd49393cdf9794017baec7506170", size = 333086 }, - { url = "https://files.pythonhosted.org/packages/a6/c0/7d167e48e14d26639ca066825af8da7df1d2fcdba827e3fd6341aaf22a3b/yarl-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d401f07261dc5aa36c2e4efc308548f6ae943bfff20fcadb0a07517a26b196d8", size = 343661 }, - { url = "https://files.pythonhosted.org/packages/fa/81/80a266517531d4e3553aecd141800dbf48d02e23ebd52909e63598a80134/yarl-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5f1ac7359e17efe0b6e5fec21de34145caef22b260e978336f325d5c84e6938", size = 345196 }, - { url = "https://files.pythonhosted.org/packages/b0/77/6adc482ba7f2dc6c0d9b3b492e7cd100edfac4cfc3849c7ffa26fd7beb1a/yarl-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f63d176a81555984e91f2c84c2a574a61cab7111cc907e176f0f01538e9ff6e", size = 338743 }, - { url = "https://files.pythonhosted.org/packages/6d/cc/f0c4c0b92ff3ada517ffde2b127406c001504b225692216d969879ada89a/yarl-1.17.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e275792097c9f7e80741c36de3b61917aebecc08a67ae62899b074566ff8556", size = 326719 }, - { url = "https://files.pythonhosted.org/packages/18/3b/7bfc80d3376b5fa162189993a87a5a6a58057f88315bd0ea00610055b57a/yarl-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81713b70bea5c1386dc2f32a8f0dab4148a2928c7495c808c541ee0aae614d67", size = 345826 }, - { url = "https://files.pythonhosted.org/packages/2e/66/cf0b0338107a5c370205c1a572432af08f36ca12ecce127f5b558398b4fd/yarl-1.17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:aa46dce75078fceaf7cecac5817422febb4355fbdda440db55206e3bd288cfb8", size = 340335 }, - { url = "https://files.pythonhosted.org/packages/2f/52/b084b0eec0fd4d2490e1d33ace3320fad704c5f1f3deaa709f929d2d87fc/yarl-1.17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1ce36ded585f45b1e9bb36d0ae94765c6608b43bd2e7f5f88079f7a85c61a4d3", size = 345301 }, - { url = "https://files.pythonhosted.org/packages/ef/38/9e2036d948efd3bafcdb4976cb212166fded76615f0dfc6c1492c4ce4784/yarl-1.17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2d374d70fdc36f5863b84e54775452f68639bc862918602d028f89310a034ab0", size = 354205 }, - { url = "https://files.pythonhosted.org/packages/81/c1/13dfe1e70b86811733316221c696580725ceb1c46d4e4db852807e134310/yarl-1.17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2d9f0606baaec5dd54cb99667fcf85183a7477f3766fbddbe3f385e7fc253299", size = 360501 }, - { url = "https://files.pythonhosted.org/packages/91/87/756e05c74cd8bf9e71537df4a2cae7e8211a9ebe0d2350a3e26949e1e41c/yarl-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0341e6d9a0c0e3cdc65857ef518bb05b410dbd70d749a0d33ac0f39e81a4258", size = 359452 }, - { url = "https://files.pythonhosted.org/packages/06/b2/b2bb09c1e6d59e1c9b1b36a86caa473e22c3dbf26d1032c030e9bfb554dc/yarl-1.17.1-cp313-cp313-win32.whl", hash = "sha256:2e7ba4c9377e48fb7b20dedbd473cbcbc13e72e1826917c185157a137dac9df2", size = 308904 }, - { url = "https://files.pythonhosted.org/packages/f3/27/f084d9a5668853c1f3b246620269b14ee871ef3c3cc4f3a1dd53645b68ec/yarl-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:949681f68e0e3c25377462be4b658500e85ca24323d9619fdc41f68d46a1ffda", size = 314637 }, - { url = "https://files.pythonhosted.org/packages/52/ad/1fe7ff5f3e8869d4c5070f47b96bac2b4d15e67c100a8278d8e7876329fc/yarl-1.17.1-py3-none-any.whl", hash = "sha256:f1790a4b1e8e8e028c391175433b9c8122c39b46e1663228158e61e6f915bf06", size = 44352 }, +sdist = { url = "https://files.pythonhosted.org/packages/5e/4b/53db4ecad4d54535aff3dfda1f00d6363d79455f62b11b8ca97b82746bd2/yarl-1.18.0.tar.gz", hash = "sha256:20d95535e7d833889982bfe7cc321b7f63bf8879788fee982c76ae2b24cfb715", size = 180098 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/45/6ad7135d1c4ad3a6a49e2c37dc78a1805a7871879c03c3495d64c9605d49/yarl-1.18.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b8e8c516dc4e1a51d86ac975b0350735007e554c962281c432eaa5822aa9765c", size = 141283 }, + { url = "https://files.pythonhosted.org/packages/45/6d/24b70ae33107d6eba303ed0ebfdf1164fe2219656e7594ca58628ebc0f1d/yarl-1.18.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e6b4466714a73f5251d84b471475850954f1fa6acce4d3f404da1d55d644c34", size = 94082 }, + { url = "https://files.pythonhosted.org/packages/8a/0e/da720989be11b662ca847ace58f468b52310a9b03e52ac62c144755f9d75/yarl-1.18.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c893f8c1a6d48b25961e00922724732d00b39de8bb0b451307482dc87bddcd74", size = 92017 }, + { url = "https://files.pythonhosted.org/packages/f5/76/e5c91681fa54658943cb88673fb19b3355c3a8ae911a33a2621b6320990d/yarl-1.18.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13aaf2bdbc8c86ddce48626b15f4987f22e80d898818d735b20bd58f17292ee8", size = 340359 }, + { url = "https://files.pythonhosted.org/packages/cf/77/02cf72f09dea20980dea4ebe40dfb2c24916b864aec869a19f715428e0f0/yarl-1.18.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd21c0128e301851de51bc607b0a6da50e82dc34e9601f4b508d08cc89ee7929", size = 356336 }, + { url = "https://files.pythonhosted.org/packages/17/66/83a88d04e4fc243dd26109f3e3d6412f67819ab1142dadbce49706ef4df4/yarl-1.18.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:205de377bd23365cd85562c9c6c33844050a93661640fda38e0567d2826b50df", size = 353730 }, + { url = "https://files.pythonhosted.org/packages/76/77/0b205a532d22756ab250ab21924d362f910a23d641c82faec1c4ad7f6077/yarl-1.18.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed69af4fe2a0949b1ea1d012bf065c77b4c7822bad4737f17807af2adb15a73c", size = 343882 }, + { url = "https://files.pythonhosted.org/packages/0b/47/2081ddce3da6096889c3947bdc21907d0fa15939909b10219254fe116841/yarl-1.18.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e1c18890091aa3cc8a77967943476b729dc2016f4cfe11e45d89b12519d4a93", size = 335873 }, + { url = "https://files.pythonhosted.org/packages/25/3c/437304394494e757ae927c9a81bacc4bcdf7351a1d4e811d95b02cb6dbae/yarl-1.18.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:91b8fb9427e33f83ca2ba9501221ffaac1ecf0407f758c4d2f283c523da185ee", size = 347725 }, + { url = "https://files.pythonhosted.org/packages/c6/fb/fa6c642bc052fbe6370ed5da765579650510157dea354fe9e8177c3bc34a/yarl-1.18.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:536a7a8a53b75b2e98ff96edb2dfb91a26b81c4fed82782035767db5a465be46", size = 346161 }, + { url = "https://files.pythonhosted.org/packages/b0/09/8c0cf68a0fcfe3b060c9e5857bb35735bc72a4cf4075043632c636d007e9/yarl-1.18.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a64619a9c47c25582190af38e9eb382279ad42e1f06034f14d794670796016c0", size = 349924 }, + { url = "https://files.pythonhosted.org/packages/bf/4b/1efe10fd51e2cedf53195d688fa270efbcd64a015c61d029d49c20bf0af7/yarl-1.18.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c73a6bbc97ba1b5a0c3c992ae93d721c395bdbb120492759b94cc1ac71bc6350", size = 361865 }, + { url = "https://files.pythonhosted.org/packages/0b/1b/2b5efd6df06bf938f7e154dee8e2ab22d148f3311a92bf4da642aaaf2fc5/yarl-1.18.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a173401d7821a2a81c7b47d4e7d5c4021375a1441af0c58611c1957445055056", size = 366030 }, + { url = "https://files.pythonhosted.org/packages/f8/db/786a5684f79278e62271038a698f56a51960f9e643be5d3eff82712f0b1c/yarl-1.18.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7520e799b1f84e095cce919bd6c23c9d49472deeef25fe1ef960b04cca51c3fc", size = 358902 }, + { url = "https://files.pythonhosted.org/packages/91/2f/437d0de062f1a3e3cb17573971b3832232443241133580c2ba3da5001d06/yarl-1.18.0-cp311-cp311-win32.whl", hash = "sha256:c4cb992d8090d5ae5f7afa6754d7211c578be0c45f54d3d94f7781c495d56716", size = 84138 }, + { url = "https://files.pythonhosted.org/packages/9d/85/035719a9266bce85ecde820aa3f8c46f3b18c3d7ba9ff51367b2fa4ae2a2/yarl-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:52c136f348605974c9b1c878addd6b7a60e3bf2245833e370862009b86fa4689", size = 90765 }, + { url = "https://files.pythonhosted.org/packages/23/36/c579b80a5c76c0d41c8e08baddb3e6940dfc20569db579a5691392c52afa/yarl-1.18.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1ece25e2251c28bab737bdf0519c88189b3dd9492dc086a1d77336d940c28ced", size = 142376 }, + { url = "https://files.pythonhosted.org/packages/0c/5f/e247dc7c0607a0c505fea6c839721844bee55686dfb183c7d7b8ef8a9cb1/yarl-1.18.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:454902dc1830d935c90b5b53c863ba2a98dcde0fbaa31ca2ed1ad33b2a7171c6", size = 94692 }, + { url = "https://files.pythonhosted.org/packages/eb/e1/3081b578a6f21961711b9a1c49c2947abb3b0d0dd9537378fb06777ce8ee/yarl-1.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01be8688fc211dc237e628fcc209dda412d35de7642453059a0553747018d075", size = 92527 }, + { url = "https://files.pythonhosted.org/packages/2f/fa/d9e1b9fbafa4cc82cd3980b5314741b33c2fe16308d725449a23aed32021/yarl-1.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d26f1fa9fa2167bb238f6f4b20218eb4e88dd3ef21bb8f97439fa6b5313e30d", size = 332096 }, + { url = "https://files.pythonhosted.org/packages/93/b6/dd27165114317875838e216214fb86338dc63d2e50855a8f2a12de2a7fe5/yarl-1.18.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b234a4a9248a9f000b7a5dfe84b8cb6210ee5120ae70eb72a4dcbdb4c528f72f", size = 342047 }, + { url = "https://files.pythonhosted.org/packages/fc/9f/bad434b5279ae7a356844e14dc771c3d29eb928140bbc01621af811c8a27/yarl-1.18.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe94d1de77c4cd8caff1bd5480e22342dbd54c93929f5943495d9c1e8abe9f42", size = 341712 }, + { url = "https://files.pythonhosted.org/packages/9a/9f/63864f43d131ba8c8cdf1bde5dd3f02f0eff8a7c883a5d7fad32f204fda5/yarl-1.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4c90c5363c6b0a54188122b61edb919c2cd1119684999d08cd5e538813a28e", size = 336654 }, + { url = "https://files.pythonhosted.org/packages/20/30/b4542bbd9be73de155213207eec019f6fe6495885f7dd59aa1ff705a041b/yarl-1.18.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49a98ecadc5a241c9ba06de08127ee4796e1009555efd791bac514207862b43d", size = 325484 }, + { url = "https://files.pythonhosted.org/packages/69/bc/e2a9808ec26989cf0d1b98fe7b3cc45c1c6506b5ea4fe43ece5991f28f34/yarl-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9106025c7f261f9f5144f9aa7681d43867eed06349a7cfb297a1bc804de2f0d1", size = 344213 }, + { url = "https://files.pythonhosted.org/packages/e2/17/0ee5a68886aca1a8071b0d24a1e1c0fd9970dead2ef2d5e26e027fb7ce88/yarl-1.18.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f275ede6199d0f1ed4ea5d55a7b7573ccd40d97aee7808559e1298fe6efc8dbd", size = 340517 }, + { url = "https://files.pythonhosted.org/packages/fd/db/1fe4ef38ee852bff5ec8f5367d718b3a7dac7520f344b8e50306f68a2940/yarl-1.18.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f7edeb1dcc7f50a2c8e08b9dc13a413903b7817e72273f00878cb70e766bdb3b", size = 346234 }, + { url = "https://files.pythonhosted.org/packages/b4/ee/5e5bccdb821eb9949ba66abb4d19e3299eee00282e37b42f65236120e892/yarl-1.18.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c083f6dd6951b86e484ebfc9c3524b49bcaa9c420cb4b2a78ef9f7a512bfcc85", size = 359625 }, + { url = "https://files.pythonhosted.org/packages/3f/43/95a64d9e7ab4aa1c34fc5ea0edb35b581bc6ad33fd960a8ae34c2040b319/yarl-1.18.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:80741ec5b471fbdfb997821b2842c59660a1c930ceb42f8a84ba8ca0f25a66aa", size = 364239 }, + { url = "https://files.pythonhosted.org/packages/40/19/09ce976c624c9d3cc898f0be5035ddef0c0759d85b2313321cfe77b69915/yarl-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1a3297b9cad594e1ff0c040d2881d7d3a74124a3c73e00c3c71526a1234a9f7", size = 357599 }, + { url = "https://files.pythonhosted.org/packages/7d/35/6f33fd29791af2ec161aebe8abe63e788c2b74a6c7e8f29c92e5f5e96849/yarl-1.18.0-cp312-cp312-win32.whl", hash = "sha256:cd6ab7d6776c186f544f893b45ee0c883542b35e8a493db74665d2e594d3ca75", size = 83832 }, + { url = "https://files.pythonhosted.org/packages/4e/8e/cdb40ef98597be107de67b11e2f1f23f911e0f1416b938885d17a338e304/yarl-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:039c299a0864d1f43c3e31570045635034ea7021db41bf4842693a72aca8df3a", size = 90132 }, + { url = "https://files.pythonhosted.org/packages/2b/77/2196b657c66f97adaef0244e9e015f30eac0df59c31ad540f79ce328feed/yarl-1.18.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6fb64dd45453225f57d82c4764818d7a205ee31ce193e9f0086e493916bd4f72", size = 140512 }, + { url = "https://files.pythonhosted.org/packages/0e/d8/2bb6e26fddba5c01bad284e4571178c651b97e8e06318efcaa16e07eb9fd/yarl-1.18.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3adaaf9c6b1b4fc258584f4443f24d775a2086aee82d1387e48a8b4f3d6aecf6", size = 93875 }, + { url = "https://files.pythonhosted.org/packages/54/e4/99fbb884dd9f814fb0037dc1783766bb9edcd57b32a76f3ec5ac5c5772d7/yarl-1.18.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:da206d1ec78438a563c5429ab808a2b23ad7bc025c8adbf08540dde202be37d5", size = 91705 }, + { url = "https://files.pythonhosted.org/packages/3b/a2/5bd86eca9449e6b15d3b08005cf4e58e3da972240c2bee427b358c311549/yarl-1.18.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:576d258b21c1db4c6449b1c572c75d03f16a482eb380be8003682bdbe7db2f28", size = 333325 }, + { url = "https://files.pythonhosted.org/packages/94/50/a218da5f159cd985685bc72c500bb1a7fd2d60035d2339b8a9d9e1f99194/yarl-1.18.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e547c0a375c4bfcdd60eef82e7e0e8698bf84c239d715f5c1278a73050393", size = 344121 }, + { url = "https://files.pythonhosted.org/packages/a4/e3/830ae465811198b4b5ebecd674b5b3dca4d222af2155eb2144bfe190bbb8/yarl-1.18.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3818eabaefb90adeb5e0f62f047310079d426387991106d4fbf3519eec7d90a", size = 345163 }, + { url = "https://files.pythonhosted.org/packages/7a/74/05c4326877ca541eee77b1ef74b7ac8081343d3957af8f9291ca6eca6fec/yarl-1.18.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5f72421246c21af6a92fbc8c13b6d4c5427dfd949049b937c3b731f2f9076bd", size = 339130 }, + { url = "https://files.pythonhosted.org/packages/29/42/842f35aa1dae25d132119ee92185e8c75d8b9b7c83346506bd31e9fa217f/yarl-1.18.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fa7d37f2ada0f42e0723632993ed422f2a679af0e200874d9d861720a54f53e", size = 326418 }, + { url = "https://files.pythonhosted.org/packages/f9/ed/65c0514f2d1e8b92a61f564c914381d078766cab38b5fbde355b3b3af1fb/yarl-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:42ba84e2ac26a3f252715f8ec17e6fdc0cbf95b9617c5367579fafcd7fba50eb", size = 345204 }, + { url = "https://files.pythonhosted.org/packages/23/31/351f64f0530c372fa01160f38330f44478e7bf3092f5ce2bfcb91605561d/yarl-1.18.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6a49ad0102c0f0ba839628d0bf45973c86ce7b590cdedf7540d5b1833ddc6f00", size = 341652 }, + { url = "https://files.pythonhosted.org/packages/49/aa/0c6e666c218d567727c1d040d01575685e7f9b18052fd68a59c9f61fe5d9/yarl-1.18.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:96404e8d5e1bbe36bdaa84ef89dc36f0e75939e060ca5cd45451aba01db02902", size = 347257 }, + { url = "https://files.pythonhosted.org/packages/36/0b/33a093b0e13bb8cd0f27301779661ff325270b6644929001f8f33307357d/yarl-1.18.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a0509475d714df8f6d498935b3f307cd122c4ca76f7d426c7e1bb791bcd87eda", size = 359735 }, + { url = "https://files.pythonhosted.org/packages/a8/92/dcc0b37c48632e71ffc2b5f8b0509347a0bde55ab5862ff755dce9dd56c4/yarl-1.18.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1ff116f0285b5c8b3b9a2680aeca29a858b3b9e0402fc79fd850b32c2bcb9f8b", size = 365982 }, + { url = "https://files.pythonhosted.org/packages/0e/39/30e2a24a7a6c628dccb13eb6c4a03db5f6cd1eb2c6cda56a61ddef764c11/yarl-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2580c1d7e66e6d29d6e11855e3b1c6381971e0edd9a5066e6c14d79bc8967af", size = 360128 }, + { url = "https://files.pythonhosted.org/packages/76/13/12b65dca23b1fb8ae44269a4d24048fd32ac90b445c985b0a46fdfa30cfe/yarl-1.18.0-cp313-cp313-win32.whl", hash = "sha256:14408cc4d34e202caba7b5ac9cc84700e3421a9e2d1b157d744d101b061a4a88", size = 309888 }, + { url = "https://files.pythonhosted.org/packages/f6/60/478d3d41a4bf0b9e7dca74d870d114e775d1ff7156b7d1e0e9972e8f97fd/yarl-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db1537e9cb846eb0ff206eac667f627794be8b71368c1ab3207ec7b6f8c5afc", size = 315459 }, + { url = "https://files.pythonhosted.org/packages/30/9c/3f7ab894a37b1520291247cbc9ea6756228d098dae5b37eec848d404a204/yarl-1.18.0-py3-none-any.whl", hash = "sha256:dbf53db46f7cf176ee01d8d98c39381440776fcda13779d269a8ba664f69bec0", size = 44840 }, ] From fcb604e43548830e68318e253267efc8b094a02c Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Thu, 28 Nov 2024 17:56:20 +0100 Subject: [PATCH 724/892] Follow main package structure for tests (#1317) * Transport tests under tests/transports/ * Protocol tests under tests/protocols/ * IOT tests under iot/ * Plus some minor cleanups, most code changes are related to splitting up smart & iot tests --- tests/device_fixtures.py | 3 + tests/{ => iot/modules}/test_emeter.py | 84 ++--- tests/{ => iot/modules}/test_usage.py | 0 tests/iot/test_iotbulb.py | 320 +++++++++++++++++ tests/{ => iot}/test_iotdevice.py | 7 +- .../{test_dimmer.py => iot/test_iotdimmer.py} | 3 +- .../test_iotlightstrip.py} | 3 +- tests/protocols/__init__.py | 0 .../test_iotprotocol.py} | 4 +- tests/{ => protocols}/test_smartprotocol.py | 4 +- tests/smart/modules/test_energy.py | 21 ++ tests/{ => smart}/test_smartdevice.py | 11 +- tests/test_bulb.py | 327 +----------------- tests/test_plug.py | 2 +- tests/transports/__init__.py | 0 tests/{ => transports}/test_aestransport.py | 0 .../test_klaptransport.py} | 0 .../{ => transports}/test_sslaestransport.py | 0 18 files changed, 395 insertions(+), 394 deletions(-) rename tests/{ => iot/modules}/test_emeter.py (67%) rename tests/{ => iot/modules}/test_usage.py (100%) create mode 100644 tests/iot/test_iotbulb.py rename tests/{ => iot}/test_iotdevice.py (97%) rename tests/{test_dimmer.py => iot/test_iotdimmer.py} (98%) rename tests/{test_lightstrip.py => iot/test_iotlightstrip.py} (98%) create mode 100644 tests/protocols/__init__.py rename tests/{test_protocol.py => protocols/test_iotprotocol.py} (99%) rename tests/{ => protocols}/test_smartprotocol.py (99%) create mode 100644 tests/smart/modules/test_energy.py rename tests/{ => smart}/test_smartdevice.py (98%) create mode 100644 tests/transports/__init__.py rename tests/{ => transports}/test_aestransport.py (100%) rename tests/{test_klapprotocol.py => transports/test_klaptransport.py} (100%) rename tests/{ => transports}/test_sslaestransport.py (100%) diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 2af0ca065..d206b714a 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -217,6 +217,9 @@ def parametrize( model_filter=ALL_DEVICES - WITH_EMETER, protocol_filter={"SMART", "IOT"}, ) +has_emeter_smart = parametrize( + "has emeter smart", model_filter=WITH_EMETER_SMART, protocol_filter={"SMART"} +) has_emeter_iot = parametrize( "has emeter iot", model_filter=WITH_EMETER_IOT, protocol_filter={"IOT"} ) diff --git a/tests/test_emeter.py b/tests/iot/modules/test_emeter.py similarity index 67% rename from tests/test_emeter.py rename to tests/iot/modules/test_emeter.py index e796ffee1..54fd02b2e 100644 --- a/tests/test_emeter.py +++ b/tests/iot/modules/test_emeter.py @@ -12,13 +12,9 @@ from kasa import Device, DeviceType, EmeterStatus, Module from kasa.interfaces.energy import Energy -from kasa.iot import IotDevice, IotStrip +from kasa.iot import IotStrip from kasa.iot.modules.emeter import Emeter -from kasa.smart import SmartDevice -from kasa.smart.modules import Energy as SmartEnergyModule -from kasa.smart.smartmodule import SmartModule - -from .conftest import has_emeter, has_emeter_iot, no_emeter +from tests.conftest import has_emeter_iot, no_emeter_iot CURRENT_CONSUMPTION_SCHEMA = Schema( Any( @@ -40,30 +36,23 @@ ) -@no_emeter +@no_emeter_iot async def test_no_emeter(dev): assert not dev.has_emeter with pytest.raises(AttributeError): await dev.get_emeter_realtime() - # Only iot devices support the historical stats so other - # devices will not implement the methods below - if isinstance(dev, IotDevice): - with pytest.raises(AttributeError): - await dev.get_emeter_daily() - with pytest.raises(AttributeError): - await dev.get_emeter_monthly() - with pytest.raises(AttributeError): - await dev.erase_emeter_stats() - - -@has_emeter -async def test_get_emeter_realtime(dev): - if isinstance(dev, SmartDevice): - mod = SmartEnergyModule(dev, str(Module.Energy)) - if not await mod._check_supported(): - pytest.skip(f"Energy module not supported for {dev}.") + with pytest.raises(AttributeError): + await dev.get_emeter_daily() + with pytest.raises(AttributeError): + await dev.get_emeter_monthly() + with pytest.raises(AttributeError): + await dev.erase_emeter_stats() + + +@has_emeter_iot +async def test_get_emeter_realtime(dev): emeter = dev.modules[Module.Energy] current_emeter = await emeter.get_status() @@ -136,7 +125,7 @@ async def test_emeter_status(dev): @pytest.mark.skip("not clearing your stats..") -@has_emeter +@has_emeter_iot async def test_erase_emeter_stats(dev): emeter = dev.modules[Module.Energy] @@ -191,37 +180,22 @@ def data(self): assert emeter.consumption_today == 0.500 -@has_emeter +@has_emeter_iot async def test_supported(dev: Device): - if isinstance(dev, SmartDevice): - mod = SmartEnergyModule(dev, str(Module.Energy)) - if not await mod._check_supported(): - pytest.skip(f"Energy module not supported for {dev}.") energy_module = dev.modules.get(Module.Energy) assert energy_module - if isinstance(dev, IotDevice): - info = ( - dev._last_update - if not isinstance(dev, IotStrip) - else dev.children[0].internal_state - ) - emeter = info[energy_module._module]["get_realtime"] - has_total = "total" in emeter or "total_wh" in emeter - has_voltage_current = "voltage" in emeter or "voltage_mv" in emeter - assert ( - energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is has_total - ) - assert ( - energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) - is has_voltage_current - ) - assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is True - else: - assert isinstance(energy_module, SmartModule) - assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is False - assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is False - if energy_module.supported_version < 2: - assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False - else: - assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is True + info = ( + dev._last_update + if not isinstance(dev, IotStrip) + else dev.children[0].internal_state + ) + emeter = info[energy_module._module]["get_realtime"] + has_total = "total" in emeter or "total_wh" in emeter + has_voltage_current = "voltage" in emeter or "voltage_mv" in emeter + assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is has_total + assert ( + energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) + is has_voltage_current + ) + assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is True diff --git a/tests/test_usage.py b/tests/iot/modules/test_usage.py similarity index 100% rename from tests/test_usage.py rename to tests/iot/modules/test_usage.py diff --git a/tests/iot/test_iotbulb.py b/tests/iot/test_iotbulb.py new file mode 100644 index 000000000..b573a5454 --- /dev/null +++ b/tests/iot/test_iotbulb.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import re + +import pytest +from voluptuous import ( + All, + Boolean, + Optional, + Range, + Schema, +) + +from kasa import Device, IotLightPreset, KasaException, LightState, Module +from kasa.iot import IotBulb, IotDimmer +from kasa.iot.modules import LightPreset as IotLightPresetModule +from tests.conftest import ( + bulb_iot, + color_bulb_iot, + dimmable_iot, + handle_turn_on, + non_dimmable_iot, + turn_on, + variable_temp_iot, +) +from tests.iot.test_iotdevice import SYSINFO_SCHEMA + + +@bulb_iot +async def test_bulb_sysinfo(dev: Device): + assert dev.sys_info is not None + SYSINFO_SCHEMA_BULB(dev.sys_info) + + assert dev.model is not None + + +@bulb_iot +async def test_light_state_without_update(dev: IotBulb, monkeypatch): + monkeypatch.setitem(dev._last_update["system"]["get_sysinfo"], "light_state", None) + with pytest.raises(KasaException): + print(dev.light_state) + + +@bulb_iot +async def test_get_light_state(dev: IotBulb): + LIGHT_STATE_SCHEMA(await dev.get_light_state()) + + +@color_bulb_iot +async def test_set_hsv_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") + light = dev.modules.get(Module.Light) + assert light + await light.set_hsv(10, 10, 100, transition=1000) + + set_light_state.assert_called_with( + {"hue": 10, "saturation": 10, "brightness": 100, "color_temp": 0}, + transition=1000, + ) + + +@bulb_iot +async def test_light_set_state(dev: IotBulb, mocker): + """Testing setting LightState on the light module.""" + light = dev.modules.get(Module.Light) + assert light + set_light_state = mocker.spy(dev, "_set_light_state") + state = LightState(light_on=True) + await light.set_state(state) + + set_light_state.assert_called_with({"on_off": 1}, transition=None) + state = LightState(light_on=False) + await light.set_state(state) + + set_light_state.assert_called_with({"on_off": 0}, transition=None) + + +@variable_temp_iot +async def test_set_color_temp_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") + light = dev.modules.get(Module.Light) + assert light + await light.set_color_temp(2700, transition=100) + + set_light_state.assert_called_with({"color_temp": 2700}, transition=100) + + +@variable_temp_iot +@pytest.mark.xdist_group(name="caplog") +async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): + monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") + light = dev.modules.get(Module.Light) + assert light + assert light.valid_temperature_range == (2700, 5000) + assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text + + +@dimmable_iot +@turn_on +async def test_dimmable_brightness(dev: IotBulb, turn_on): + assert isinstance(dev, IotBulb | IotDimmer) + light = dev.modules.get(Module.Light) + assert light + await handle_turn_on(dev, turn_on) + assert dev._is_dimmable + + await light.set_brightness(50) + await dev.update() + assert light.brightness == 50 + + await light.set_brightness(10) + await dev.update() + assert light.brightness == 10 + + with pytest.raises(TypeError, match="Brightness must be an integer"): + await light.set_brightness("foo") # type: ignore[arg-type] + + +@bulb_iot +async def test_turn_on_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") + await dev.turn_on(transition=1000) + + set_light_state.assert_called_with({"on_off": 1}, transition=1000) + + await dev.turn_off(transition=100) + + set_light_state.assert_called_with({"on_off": 0}, transition=100) + + +@bulb_iot +async def test_dimmable_brightness_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") + light = dev.modules.get(Module.Light) + assert light + await light.set_brightness(10, transition=1000) + + set_light_state.assert_called_with({"brightness": 10, "on_off": 1}, transition=1000) + + +@dimmable_iot +async def test_invalid_brightness(dev: IotBulb): + assert dev._is_dimmable + light = dev.modules.get(Module.Light) + assert light + with pytest.raises( + ValueError, + match=re.escape("Invalid brightness value: 110 (valid range: 0-100%)"), + ): + await light.set_brightness(110) + + with pytest.raises( + ValueError, + match=re.escape("Invalid brightness value: -100 (valid range: 0-100%)"), + ): + await light.set_brightness(-100) + + +@non_dimmable_iot +async def test_non_dimmable(dev: IotBulb): + assert not dev._is_dimmable + light = dev.modules.get(Module.Light) + assert light + with pytest.raises(KasaException): + assert light.brightness == 0 + with pytest.raises(KasaException): + await light.set_brightness(100) + + +@bulb_iot +async def test_ignore_default_not_set_without_color_mode_change_turn_on( + dev: IotBulb, mocker +): + query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") + # When turning back without settings, ignore default to restore the state + await dev.turn_on() + args, kwargs = query_helper.call_args_list[0] + assert args[2] == {"on_off": 1, "ignore_default": 0} + + await dev.turn_off() + args, kwargs = query_helper.call_args_list[1] + assert args[2] == {"on_off": 0, "ignore_default": 1} + + +@bulb_iot +async def test_list_presets(dev: IotBulb): + light_preset = dev.modules.get(Module.LightPreset) + assert light_preset + assert isinstance(light_preset, IotLightPresetModule) + presets = light_preset._deprecated_presets + # Light strip devices may list some light effects along with normal presets but these + # are handled by the LightEffect module so exclude preferred states with id + raw_presets = [ + pstate for pstate in dev.sys_info["preferred_state"] if "id" not in pstate + ] + assert len(presets) == len(raw_presets) + + for preset, raw in zip(presets, raw_presets, strict=False): + assert preset.index == raw["index"] + assert preset.brightness == raw["brightness"] + assert preset.hue == raw["hue"] + assert preset.saturation == raw["saturation"] + assert preset.color_temp == raw["color_temp"] + + +@bulb_iot +async def test_modify_preset(dev: IotBulb, mocker): + """Verify that modifying preset calls the and exceptions are raised properly.""" + if ( + not (light_preset := dev.modules.get(Module.LightPreset)) + or not light_preset._deprecated_presets + ): + pytest.skip("Some strips do not support presets") + + assert isinstance(light_preset, IotLightPresetModule) + data: dict[str, int | None] = { + "index": 0, + "brightness": 10, + "hue": 0, + "saturation": 0, + "color_temp": 0, + } + preset = IotLightPreset(**data) # type: ignore[call-arg, arg-type] + + assert preset.index == 0 + assert preset.brightness == 10 + assert preset.hue == 0 + assert preset.saturation == 0 + assert preset.color_temp == 0 + + await light_preset._deprecated_save_preset(preset) + await dev.update() + assert light_preset._deprecated_presets[0].brightness == 10 + + with pytest.raises(KasaException): + await light_preset._deprecated_save_preset( + IotLightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) # type: ignore[call-arg] + ) + + +@bulb_iot +@pytest.mark.parametrize( + ("preset", "payload"), + [ + ( + IotLightPreset(index=0, hue=0, brightness=1, saturation=0), # type: ignore[call-arg] + {"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, + ), + ( + IotLightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), # type: ignore[call-arg] + {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, + ), + ], +) +async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): + """Test that modify preset payloads ignore none values.""" + if ( + not (light_preset := dev.modules.get(Module.LightPreset)) + or not light_preset._deprecated_presets + ): + pytest.skip("Some strips do not support presets") + + query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") + await light_preset._deprecated_save_preset(preset) + query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload) + + +LIGHT_STATE_SCHEMA = Schema( + { + "brightness": All(int, Range(min=0, max=100)), + "color_temp": int, + "hue": All(int, Range(min=0, max=360)), + "mode": str, + "on_off": Boolean, + "saturation": All(int, Range(min=0, max=100)), + "length": Optional(int), + "transition": Optional(int), + "dft_on_state": Optional( + { + "brightness": All(int, Range(min=0, max=100)), + "color_temp": All(int, Range(min=0, max=9000)), + "hue": All(int, Range(min=0, max=360)), + "mode": str, + "saturation": All(int, Range(min=0, max=100)), + "groups": Optional(list[int]), + } + ), + "err_code": int, + } +) + +SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend( + { + "ctrl_protocols": Optional(dict), + "description": Optional(str), # Seen on LBxxx, similar to dev_name + "dev_state": str, + "disco_ver": str, + "heapsize": int, + "is_color": Boolean, + "is_dimmable": Boolean, + "is_factory": Boolean, + "is_variable_color_temp": Boolean, + "light_state": LIGHT_STATE_SCHEMA, + "preferred_state": [ + { + "brightness": All(int, Range(min=0, max=100)), + "color_temp": int, + "hue": All(int, Range(min=0, max=360)), + "index": int, + "saturation": All(int, Range(min=0, max=100)), + } + ], + } +) + + +@bulb_iot +async def test_turn_on_behaviours(dev: IotBulb): + behavior = await dev.get_turn_on_behavior() + assert behavior diff --git a/tests/test_iotdevice.py b/tests/iot/test_iotdevice.py similarity index 97% rename from tests/test_iotdevice.py rename to tests/iot/test_iotdevice.py index 68ee7a51a..124910b79 100644 --- a/tests/test_iotdevice.py +++ b/tests/iot/test_iotdevice.py @@ -19,10 +19,9 @@ from kasa import DeviceType, KasaException, Module from kasa.iot import IotDevice from kasa.iot.iotmodule import _merge_dict - -from .conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on -from .device_fixtures import device_iot, has_emeter_iot, no_emeter_iot -from .fakeprotocol_iot import FakeIotProtocol +from tests.conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on +from tests.device_fixtures import device_iot, has_emeter_iot, no_emeter_iot +from tests.fakeprotocol_iot import FakeIotProtocol TZ_SCHEMA = Schema( {"zone_str": str, "dst_offset": int, "index": All(int, Range(min=0)), "tz_str": str} diff --git a/tests/test_dimmer.py b/tests/iot/test_iotdimmer.py similarity index 98% rename from tests/test_dimmer.py rename to tests/iot/test_iotdimmer.py index 3505a7c1c..38f440e70 100644 --- a/tests/test_dimmer.py +++ b/tests/iot/test_iotdimmer.py @@ -2,8 +2,7 @@ from kasa import DeviceType, Module from kasa.iot import IotDimmer - -from .conftest import dimmer_iot, handle_turn_on, turn_on +from tests.conftest import dimmer_iot, handle_turn_on, turn_on @dimmer_iot diff --git a/tests/test_lightstrip.py b/tests/iot/test_iotlightstrip.py similarity index 98% rename from tests/test_lightstrip.py rename to tests/iot/test_iotlightstrip.py index 365d0163d..23eb61dc9 100644 --- a/tests/test_lightstrip.py +++ b/tests/iot/test_iotlightstrip.py @@ -3,8 +3,7 @@ from kasa import DeviceType, Module from kasa.iot import IotLightStrip from kasa.iot.modules import LightEffect - -from .conftest import lightstrip_iot +from tests.conftest import lightstrip_iot @lightstrip_iot diff --git a/tests/protocols/__init__.py b/tests/protocols/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_protocol.py b/tests/protocols/test_iotprotocol.py similarity index 99% rename from tests/test_protocol.py rename to tests/protocols/test_iotprotocol.py index 09134e851..a2feaae38 100644 --- a/tests/test_protocol.py +++ b/tests/protocols/test_iotprotocol.py @@ -29,8 +29,8 @@ from kasa.transports.klaptransport import KlapTransport, KlapTransportV2 from kasa.transports.xortransport import XorEncryption, XorTransport -from .conftest import device_iot -from .fakeprotocol_iot import FakeIotTransport +from ..conftest import device_iot +from ..fakeprotocol_iot import FakeIotTransport @pytest.mark.parametrize( diff --git a/tests/test_smartprotocol.py b/tests/protocols/test_smartprotocol.py similarity index 99% rename from tests/test_smartprotocol.py rename to tests/protocols/test_smartprotocol.py index fce6cd070..988c95eb2 100644 --- a/tests/test_smartprotocol.py +++ b/tests/protocols/test_smartprotocol.py @@ -12,8 +12,8 @@ from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.smart import SmartDevice -from .conftest import device_smart -from .fakeprotocol_smart import FakeSmartTransport +from ..conftest import device_smart +from ..fakeprotocol_smart import FakeSmartTransport DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} DUMMY_MULTIPLE_QUERY = { diff --git a/tests/smart/modules/test_energy.py b/tests/smart/modules/test_energy.py new file mode 100644 index 000000000..fdbea88bb --- /dev/null +++ b/tests/smart/modules/test_energy.py @@ -0,0 +1,21 @@ +import pytest + +from kasa import Module, SmartDevice +from kasa.interfaces.energy import Energy +from kasa.smart.modules import Energy as SmartEnergyModule +from tests.conftest import has_emeter_smart + + +@has_emeter_smart +async def test_supported(dev: SmartDevice): + energy_module = dev.modules.get(Module.Energy) + if not energy_module: + pytest.skip(f"Energy module not supported for {dev}.") + + assert isinstance(energy_module, SmartEnergyModule) + assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is False + assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is False + if energy_module.supported_version < 2: + assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False + else: + assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is True diff --git a/tests/test_smartdevice.py b/tests/smart/test_smartdevice.py similarity index 98% rename from tests/test_smartdevice.py rename to tests/smart/test_smartdevice.py index a89b1098d..c53193a32 100644 --- a/tests/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -17,12 +17,12 @@ from kasa.smart import SmartDevice from kasa.smart.modules.energy import Energy from kasa.smart.smartmodule import SmartModule - -from .conftest import ( +from tests.conftest import ( device_smart, get_device_for_fixture_protocol, get_parent_and_child_modules, ) +from tests.device_fixtures import variable_temp_smart @device_smart @@ -435,3 +435,10 @@ async def side_effect_func(*args, **kwargs): ): await new_dev.update() assert new_dev.is_cloud_connected is False + + +@variable_temp_smart +async def test_smart_temp_range(dev: Device): + light = dev.modules.get(Module.Light) + assert light + assert light.valid_temperature_range diff --git a/tests/test_bulb.py b/tests/test_bulb.py index 3ae1328f6..6956c4e8d 100644 --- a/tests/test_bulb.py +++ b/tests/test_bulb.py @@ -1,44 +1,16 @@ from __future__ import annotations -import re - import pytest -from voluptuous import ( - All, - Boolean, - Optional, - Range, - Schema, -) -from kasa import Device, DeviceType, IotLightPreset, KasaException, LightState, Module -from kasa.iot import IotBulb, IotDimmer -from kasa.iot.modules import LightPreset as IotLightPresetModule - -from .conftest import ( +from kasa import Device, DeviceType, KasaException, Module +from tests.conftest import handle_turn_on, turn_on +from tests.device_fixtures import ( bulb, - bulb_iot, color_bulb, - color_bulb_iot, - dimmable_iot, - handle_turn_on, non_color_bulb, - non_dimmable_iot, non_variable_temp, - turn_on, variable_temp, - variable_temp_iot, - variable_temp_smart, ) -from .test_iotdevice import SYSINFO_SCHEMA - - -@bulb_iot -async def test_bulb_sysinfo(dev: Device): - assert dev.sys_info is not None - SYSINFO_SCHEMA_BULB(dev.sys_info) - - assert dev.model is not None @bulb @@ -47,18 +19,6 @@ async def test_state_attributes(dev: Device): assert isinstance(dev.state_information["Cloud connection"], bool) -@bulb_iot -async def test_light_state_without_update(dev: IotBulb, monkeypatch): - monkeypatch.setitem(dev._last_update["system"]["get_sysinfo"], "light_state", None) - with pytest.raises(KasaException): - print(dev.light_state) - - -@bulb_iot -async def test_get_light_state(dev: IotBulb): - LIGHT_STATE_SCHEMA(await dev.get_light_state()) - - @color_bulb @turn_on async def test_hsv(dev: Device, turn_on): @@ -81,35 +41,6 @@ async def test_hsv(dev: Device, turn_on): assert brightness == 1 -@color_bulb_iot -async def test_set_hsv_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") - light = dev.modules.get(Module.Light) - assert light - await light.set_hsv(10, 10, 100, transition=1000) - - set_light_state.assert_called_with( - {"hue": 10, "saturation": 10, "brightness": 100, "color_temp": 0}, - transition=1000, - ) - - -@bulb_iot -async def test_light_set_state(dev: IotBulb, mocker): - """Testing setting LightState on the light module.""" - light = dev.modules.get(Module.Light) - assert light - set_light_state = mocker.spy(dev, "_set_light_state") - state = LightState(light_on=True) - await light.set_state(state) - - set_light_state.assert_called_with({"on_off": 1}, transition=None) - state = LightState(light_on=False) - await light.set_state(state) - - set_light_state.assert_called_with({"on_off": 0}, transition=None) - - @color_bulb @turn_on @pytest.mark.parametrize( @@ -221,33 +152,6 @@ async def test_try_set_colortemp(dev: Device, turn_on): assert light.color_temp == 2700 -@variable_temp_iot -async def test_set_color_temp_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") - light = dev.modules.get(Module.Light) - assert light - await light.set_color_temp(2700, transition=100) - - set_light_state.assert_called_with({"color_temp": 2700}, transition=100) - - -@variable_temp_iot -@pytest.mark.xdist_group(name="caplog") -async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): - monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") - light = dev.modules.get(Module.Light) - assert light - assert light.valid_temperature_range == (2700, 5000) - assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text - - -@variable_temp_smart -async def test_smart_temp_range(dev: Device): - light = dev.modules.get(Module.Light) - assert light - assert light.valid_temperature_range - - @variable_temp async def test_out_of_range_temperature(dev: Device): light = dev.modules.get(Module.Light) @@ -276,231 +180,6 @@ async def test_non_variable_temp(dev: Device): print(light.color_temp) -@dimmable_iot -@turn_on -async def test_dimmable_brightness(dev: IotBulb, turn_on): - assert isinstance(dev, IotBulb | IotDimmer) - light = dev.modules.get(Module.Light) - assert light - await handle_turn_on(dev, turn_on) - assert dev._is_dimmable - - await light.set_brightness(50) - await dev.update() - assert light.brightness == 50 - - await light.set_brightness(10) - await dev.update() - assert light.brightness == 10 - - with pytest.raises(TypeError, match="Brightness must be an integer"): - await light.set_brightness("foo") # type: ignore[arg-type] - - -@bulb_iot -async def test_turn_on_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") - await dev.turn_on(transition=1000) - - set_light_state.assert_called_with({"on_off": 1}, transition=1000) - - await dev.turn_off(transition=100) - - set_light_state.assert_called_with({"on_off": 0}, transition=100) - - -@bulb_iot -async def test_dimmable_brightness_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") - light = dev.modules.get(Module.Light) - assert light - await light.set_brightness(10, transition=1000) - - set_light_state.assert_called_with({"brightness": 10, "on_off": 1}, transition=1000) - - -@dimmable_iot -async def test_invalid_brightness(dev: IotBulb): - assert dev._is_dimmable - light = dev.modules.get(Module.Light) - assert light - with pytest.raises( - ValueError, - match=re.escape("Invalid brightness value: 110 (valid range: 0-100%)"), - ): - await light.set_brightness(110) - - with pytest.raises( - ValueError, - match=re.escape("Invalid brightness value: -100 (valid range: 0-100%)"), - ): - await light.set_brightness(-100) - - -@non_dimmable_iot -async def test_non_dimmable(dev: IotBulb): - assert not dev._is_dimmable - light = dev.modules.get(Module.Light) - assert light - with pytest.raises(KasaException): - assert light.brightness == 0 - with pytest.raises(KasaException): - await light.set_brightness(100) - - -@bulb_iot -async def test_ignore_default_not_set_without_color_mode_change_turn_on( - dev: IotBulb, mocker -): - query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") - # When turning back without settings, ignore default to restore the state - await dev.turn_on() - args, kwargs = query_helper.call_args_list[0] - assert args[2] == {"on_off": 1, "ignore_default": 0} - - await dev.turn_off() - args, kwargs = query_helper.call_args_list[1] - assert args[2] == {"on_off": 0, "ignore_default": 1} - - -@bulb_iot -async def test_list_presets(dev: IotBulb): - light_preset = dev.modules.get(Module.LightPreset) - assert light_preset - assert isinstance(light_preset, IotLightPresetModule) - presets = light_preset._deprecated_presets - # Light strip devices may list some light effects along with normal presets but these - # are handled by the LightEffect module so exclude preferred states with id - raw_presets = [ - pstate for pstate in dev.sys_info["preferred_state"] if "id" not in pstate - ] - assert len(presets) == len(raw_presets) - - for preset, raw in zip(presets, raw_presets, strict=False): - assert preset.index == raw["index"] - assert preset.brightness == raw["brightness"] - assert preset.hue == raw["hue"] - assert preset.saturation == raw["saturation"] - assert preset.color_temp == raw["color_temp"] - - -@bulb_iot -async def test_modify_preset(dev: IotBulb, mocker): - """Verify that modifying preset calls the and exceptions are raised properly.""" - if ( - not (light_preset := dev.modules.get(Module.LightPreset)) - or not light_preset._deprecated_presets - ): - pytest.skip("Some strips do not support presets") - - assert isinstance(light_preset, IotLightPresetModule) - data: dict[str, int | None] = { - "index": 0, - "brightness": 10, - "hue": 0, - "saturation": 0, - "color_temp": 0, - } - preset = IotLightPreset(**data) # type: ignore[call-arg, arg-type] - - assert preset.index == 0 - assert preset.brightness == 10 - assert preset.hue == 0 - assert preset.saturation == 0 - assert preset.color_temp == 0 - - await light_preset._deprecated_save_preset(preset) - await dev.update() - assert light_preset._deprecated_presets[0].brightness == 10 - - with pytest.raises(KasaException): - await light_preset._deprecated_save_preset( - IotLightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) # type: ignore[call-arg] - ) - - -@bulb_iot -@pytest.mark.parametrize( - ("preset", "payload"), - [ - ( - IotLightPreset(index=0, hue=0, brightness=1, saturation=0), # type: ignore[call-arg] - {"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, - ), - ( - IotLightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), # type: ignore[call-arg] - {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, - ), - ], -) -async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): - """Test that modify preset payloads ignore none values.""" - if ( - not (light_preset := dev.modules.get(Module.LightPreset)) - or not light_preset._deprecated_presets - ): - pytest.skip("Some strips do not support presets") - - query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") - await light_preset._deprecated_save_preset(preset) - query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload) - - -LIGHT_STATE_SCHEMA = Schema( - { - "brightness": All(int, Range(min=0, max=100)), - "color_temp": int, - "hue": All(int, Range(min=0, max=360)), - "mode": str, - "on_off": Boolean, - "saturation": All(int, Range(min=0, max=100)), - "length": Optional(int), - "transition": Optional(int), - "dft_on_state": Optional( - { - "brightness": All(int, Range(min=0, max=100)), - "color_temp": All(int, Range(min=0, max=9000)), - "hue": All(int, Range(min=0, max=360)), - "mode": str, - "saturation": All(int, Range(min=0, max=100)), - "groups": Optional(list[int]), - } - ), - "err_code": int, - } -) - -SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend( - { - "ctrl_protocols": Optional(dict), - "description": Optional(str), # Seen on LBxxx, similar to dev_name - "dev_state": str, - "disco_ver": str, - "heapsize": int, - "is_color": Boolean, - "is_dimmable": Boolean, - "is_factory": Boolean, - "is_variable_color_temp": Boolean, - "light_state": LIGHT_STATE_SCHEMA, - "preferred_state": [ - { - "brightness": All(int, Range(min=0, max=100)), - "color_temp": int, - "hue": All(int, Range(min=0, max=360)), - "index": int, - "saturation": All(int, Range(min=0, max=100)), - } - ], - } -) - - @bulb def test_device_type_bulb(dev: Device): assert dev.device_type in {DeviceType.Bulb, DeviceType.LightStrip} - - -@bulb_iot -async def test_turn_on_behaviours(dev: IotBulb): - behavior = await dev.get_turn_on_behavior() - assert behavior diff --git a/tests/test_plug.py b/tests/test_plug.py index 795ebe55b..25be910bd 100644 --- a/tests/test_plug.py +++ b/tests/test_plug.py @@ -1,9 +1,9 @@ import pytest from kasa import DeviceType +from tests.iot.test_iotdevice import SYSINFO_SCHEMA from .conftest import plug, plug_iot, plug_smart, switch_smart, wallswitch_iot -from .test_iotdevice import SYSINFO_SCHEMA # these schemas should go to the mainlib as # they can be useful when adding support for new features/devices diff --git a/tests/transports/__init__.py b/tests/transports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_aestransport.py b/tests/transports/test_aestransport.py similarity index 100% rename from tests/test_aestransport.py rename to tests/transports/test_aestransport.py diff --git a/tests/test_klapprotocol.py b/tests/transports/test_klaptransport.py similarity index 100% rename from tests/test_klapprotocol.py rename to tests/transports/test_klaptransport.py diff --git a/tests/test_sslaestransport.py b/tests/transports/test_sslaestransport.py similarity index 100% rename from tests/test_sslaestransport.py rename to tests/transports/test_sslaestransport.py From 5ef8f21b4d61888c9290a872dce831f282370a1b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 29 Nov 2024 15:23:16 +0000 Subject: [PATCH 725/892] Handle missing mgt_encryption_schm in discovery (#1318) --- kasa/cli/discover.py | 8 +++++--- kasa/discover.py | 25 +++++++++++++++++++------ tests/discovery_fixtures.py | 26 +++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index e472edae7..377d75e8f 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -230,10 +230,12 @@ def _conditional_echo(label, value): _conditional_echo("Supports IOT Cloud", dr.is_support_iot_cloud) _conditional_echo("OBD Src", dr.owner) _conditional_echo("Factory Default", dr.factory_default) - _conditional_echo("Encrypt Type", dr.mgt_encrypt_schm.encrypt_type) _conditional_echo("Encrypt Type", dr.encrypt_type) - _conditional_echo("Supports HTTPS", dr.mgt_encrypt_schm.is_support_https) - _conditional_echo("HTTP Port", dr.mgt_encrypt_schm.http_port) + if mgt_encrypt_schm := dr.mgt_encrypt_schm: + _conditional_echo("Encrypt Type", mgt_encrypt_schm.encrypt_type) + _conditional_echo("Supports HTTPS", mgt_encrypt_schm.is_support_https) + _conditional_echo("HTTP Port", mgt_encrypt_schm.http_port) + _conditional_echo("Login version", mgt_encrypt_schm.lv) _conditional_echo("Encrypt info", pf(dr.encrypt_info) if dr.encrypt_info else None) _conditional_echo("Decrypted", pf(dr.decrypted_data) if dr.decrypted_data else None) diff --git a/kasa/discover.py b/kasa/discover.py index 75651b7ff..f89999f45 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -156,6 +156,9 @@ class ConnectAttempt(NamedTuple): "device_id": lambda x: "REDACTED_" + x[9::], "owner": lambda x: "REDACTED_" + x[9::], "mac": mask_mac, + "master_device_id": lambda x: "REDACTED_" + x[9::], + "group_id": lambda x: "REDACTED_" + x[9::], + "group_name": lambda x: "I01BU0tFRF9TU0lEIw==", } @@ -643,7 +646,11 @@ def _get_device_class(info: dict) -> type[Device]: """Find SmartDevice subclass for device described by passed data.""" if "result" in info: discovery_result = DiscoveryResult.from_dict(info["result"]) - https = discovery_result.mgt_encrypt_schm.is_support_https + https = ( + discovery_result.mgt_encrypt_schm.is_support_https + if discovery_result.mgt_encrypt_schm + else False + ) dev_class = get_device_class_from_family( discovery_result.device_type, https=https ) @@ -747,7 +754,13 @@ def _get_device_instance( ) type_ = discovery_result.device_type - encrypt_schm = discovery_result.mgt_encrypt_schm + if (encrypt_schm := discovery_result.mgt_encrypt_schm) is None: + raise UnsupportedDeviceError( + f"Unsupported device {config.host} of type {type_} " + "with no mgt_encrypt_schm", + discovery_result=discovery_result.to_dict(), + host=config.host, + ) try: if not (encrypt_type := encrypt_schm.encrypt_type) and ( @@ -765,13 +778,13 @@ def _get_device_instance( config.connection_type = DeviceConnectionParameters.from_values( type_, encrypt_type, - discovery_result.mgt_encrypt_schm.lv, - discovery_result.mgt_encrypt_schm.is_support_https, + encrypt_schm.lv, + encrypt_schm.is_support_https, ) except KasaException as ex: raise UnsupportedDeviceError( f"Unsupported device {config.host} of type {type_} " - + f"with encrypt_type {discovery_result.mgt_encrypt_schm.encrypt_type}", + + f"with encrypt_type {encrypt_schm.encrypt_type}", discovery_result=discovery_result.to_dict(), host=config.host, ) from ex @@ -854,7 +867,7 @@ class DiscoveryResult(_DiscoveryBaseMixin): device_id: str ip: str mac: str - mgt_encrypt_schm: EncryptionScheme + mgt_encrypt_schm: EncryptionScheme | None = None device_name: str | None = None encrypt_info: EncryptionInfo | None = None encrypt_type: list[str] | None = None diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index 15109b3bf..c65d47bda 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -22,6 +22,29 @@ class DiscoveryResponse(TypedDict): error_code: int +UNSUPPORTED_HOMEWIFISYSTEM = { + "error_code": 0, + "result": { + "channel_2g": "10", + "channel_5g": "44", + "device_id": "REDACTED_51f72a752213a6c45203530", + "device_model": "X20", + "device_type": "HOMEWIFISYSTEM", + "factory_default": False, + "group_id": "REDACTED_07d902da02fa9beab8a64", + "group_name": "I01BU0tFRF9TU0lEIw==", # '#MASKED_SSID#' + "hardware_version": "3.0", + "ip": "192.168.1.192", + "mac": "24:2F:D0:00:00:00", + "master_device_id": "REDACTED_51f72a752213a6c45203530", + "need_account_digest": True, + "owner": "REDACTED_341c020d7e8bda184e56a90", + "role": "master", + "tmp_port": [20001], + }, +} + + def _make_unsupported( device_family, encrypt_type, @@ -75,13 +98,14 @@ def _make_unsupported( "unable_to_parse": _make_unsupported( "SMART.TAPOBULB", "FOO", - omit_keys={"mgt_encrypt_schm": None}, + omit_keys={"device_id": None}, ), "invalidinstance": _make_unsupported( "IOT.SMARTPLUGSWITCH", "KLAP", https=True, ), + "homewifi": UNSUPPORTED_HOMEWIFISYSTEM, } From d122b4878828f3acaa5b1cbeb8d94c0a2728c5ec Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 29 Nov 2024 20:02:04 +0100 Subject: [PATCH 726/892] Add vacuum component queries to dump_devinfo (#1320) --- devtools/dump_devinfo.py | 2 ++ devtools/helpers/smartrequests.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 18005990f..b6c44fe52 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -157,6 +157,8 @@ def scrub(res): v = "#MASKED_NAME#" elif isinstance(res[k], int): v = 0 + elif k in ["map_data"]: # + v = "#SCRUBBED_MAPDATA#" elif k in ["device_id", "dev_id"] and "SCRUBBED" in v: pass # already scrubbed elif k == ["device_id", "dev_id"] and len(v) > 40: diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 18ae00e2b..20b1300e7 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -425,4 +425,28 @@ def get_component_requests(component_id, ver_code): "dimmer_calibration": [], "fan_control": [], "overheat_protection": [], + # Vacuum components + "clean": [ + SmartRequest.get_raw_request("get_clean_records"), + SmartRequest.get_raw_request("get_vac_state"), + ], + "battery": [SmartRequest.get_raw_request("get_battery_info")], + "consumables": [SmartRequest.get_raw_request("get_consumables_info")], + "direction_control": [], + "button_and_led": [], + "speaker": [ + SmartRequest.get_raw_request("get_support_voice_language"), + SmartRequest.get_raw_request("get_current_voice_language"), + ], + "map": [ + SmartRequest.get_raw_request("get_map_info"), + SmartRequest.get_raw_request("get_map_data"), + ], + "auto_change_map": [SmartRequest.get_raw_request("get_auto_change_map")], + "dust_bucket": [SmartRequest.get_raw_request("get_auto_dust_collection")], + "mop": [SmartRequest.get_raw_request("get_mop_state")], + "do_not_disturb": [SmartRequest.get_raw_request("get_do_not_disturb")], + "charge_pose_clean": [], + "continue_breakpoint_sweep": [], + "goto_point": [], } From 9a52056522ff33ffc98091ced3496236c1cfc640 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sat, 30 Nov 2024 16:35:38 +0100 Subject: [PATCH 727/892] Remove unnecessary check for python <3.10 (#1326) --- tests/smart/modules/test_autooff.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/smart/modules/test_autooff.py b/tests/smart/modules/test_autooff.py index b8042aa60..9bdf9e564 100644 --- a/tests/smart/modules/test_autooff.py +++ b/tests/smart/modules/test_autooff.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys from datetime import datetime import pytest @@ -25,10 +24,6 @@ ("auto_off_at", "auto_off_at", datetime | None), ], ) -@pytest.mark.skipif( - sys.version_info < (3, 10), - reason="Subscripted generics cannot be used with class and instance checks", -) async def test_autooff_features( dev: SmartDevice, feature: str, prop_name: str, type: type ): From 9966c6094ae281229224c811f8bcba34f7b9d9dd Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sun, 1 Dec 2024 18:06:48 +0100 Subject: [PATCH 728/892] Add ssltransport for robovacs (#943) This PR implements a clear-text, token-based transport protocol seen on RV30 Plus (#937). - Client sends `{"username": "email@example.com", "password": md5(password)}` and gets back a token in the response - Rest of the communications are done with POST at `/app?token=` --------- Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --- devtools/helpers/smartrequests.py | 24 +- kasa/cli/main.py | 1 + kasa/device_factory.py | 16 +- kasa/device_type.py | 1 + kasa/deviceconfig.py | 1 + kasa/discover.py | 11 +- kasa/smart/smartdevice.py | 2 + kasa/transports/__init__.py | 2 + kasa/transports/ssltransport.py | 233 ++++++++++++++++ tests/test_cli.py | 2 + tests/test_device_factory.py | 5 +- tests/transports/test_ssltransport.py | 374 ++++++++++++++++++++++++++ 12 files changed, 656 insertions(+), 16 deletions(-) create mode 100644 kasa/transports/ssltransport.py create mode 100644 tests/transports/test_ssltransport.py diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 20b1300e7..6ab53937f 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -427,25 +427,25 @@ def get_component_requests(component_id, ver_code): "overheat_protection": [], # Vacuum components "clean": [ - SmartRequest.get_raw_request("get_clean_records"), - SmartRequest.get_raw_request("get_vac_state"), + SmartRequest.get_raw_request("getCleanRecords"), + SmartRequest.get_raw_request("getVacStatus"), ], - "battery": [SmartRequest.get_raw_request("get_battery_info")], - "consumables": [SmartRequest.get_raw_request("get_consumables_info")], + "battery": [SmartRequest.get_raw_request("getBatteryInfo")], + "consumables": [SmartRequest.get_raw_request("getConsumablesInfo")], "direction_control": [], "button_and_led": [], "speaker": [ - SmartRequest.get_raw_request("get_support_voice_language"), - SmartRequest.get_raw_request("get_current_voice_language"), + SmartRequest.get_raw_request("getSupportVoiceLanguage"), + SmartRequest.get_raw_request("getCurrentVoiceLanguage"), ], "map": [ - SmartRequest.get_raw_request("get_map_info"), - SmartRequest.get_raw_request("get_map_data"), + SmartRequest.get_raw_request("getMapInfo"), + SmartRequest.get_raw_request("getMapData"), ], - "auto_change_map": [SmartRequest.get_raw_request("get_auto_change_map")], - "dust_bucket": [SmartRequest.get_raw_request("get_auto_dust_collection")], - "mop": [SmartRequest.get_raw_request("get_mop_state")], - "do_not_disturb": [SmartRequest.get_raw_request("get_do_not_disturb")], + "auto_change_map": [SmartRequest.get_raw_request("getAutoChangeMap")], + "dust_bucket": [SmartRequest.get_raw_request("getAutoDustCollection")], + "mop": [SmartRequest.get_raw_request("getMopState")], + "do_not_disturb": [SmartRequest.get_raw_request("getDoNotDisturb")], "charge_pose_clean": [], "continue_breakpoint_sweep": [], "goto_point": [], diff --git a/kasa/cli/main.py b/kasa/cli/main.py index d0efc73fe..fbcdf3911 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -308,6 +308,7 @@ async def cli( if type == "camera": encrypt_type = "AES" https = True + login_version = 2 device_family = "SMART.IPCAMERA" from kasa.device import Device diff --git a/kasa/device_factory.py b/kasa/device_factory.py index d7ba5b532..be3c6ca05 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -32,6 +32,7 @@ BaseTransport, KlapTransport, KlapTransportV2, + SslTransport, XorTransport, ) from .transports.sslaestransport import SslAesTransport @@ -155,6 +156,7 @@ def get_device_class_from_family( "SMART.KASAHUB": SmartDevice, "SMART.KASASWITCH": SmartDevice, "SMART.IPCAMERA.HTTPS": SmartCamDevice, + "SMART.TAPOROBOVAC": SmartDevice, "IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTBULB": IotBulb, } @@ -176,20 +178,30 @@ def get_protocol( """Return the protocol from the connection name.""" protocol_name = config.connection_type.device_family.value.split(".")[0] ctype = config.connection_type + protocol_transport_key = ( protocol_name + "." + ctype.encryption_type.value + (".HTTPS" if ctype.https else "") + + ( + f".{ctype.login_version}" + if ctype.login_version and ctype.login_version > 1 + else "" + ) ) + + _LOGGER.debug("Finding transport for %s", protocol_transport_key) supported_device_protocols: dict[ str, tuple[type[BaseProtocol], type[BaseTransport]] ] = { "IOT.XOR": (IotProtocol, XorTransport), "IOT.KLAP": (IotProtocol, KlapTransport), "SMART.AES": (SmartProtocol, AesTransport), - "SMART.KLAP": (SmartProtocol, KlapTransportV2), - "SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport), + "SMART.AES.2": (SmartProtocol, AesTransport), + "SMART.KLAP.2": (SmartProtocol, KlapTransportV2), + "SMART.AES.HTTPS.2": (SmartCamProtocol, SslAesTransport), + "SMART.AES.HTTPS": (SmartProtocol, SslTransport), } if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)): return None diff --git a/kasa/device_type.py b/kasa/device_type.py index b690f1f10..7fe485d33 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -21,6 +21,7 @@ class DeviceType(Enum): Hub = "hub" Fan = "fan" Thermostat = "thermostat" + Vacuum = "vacuum" Unknown = "unknown" @staticmethod diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 1156cf257..6f9176f57 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -77,6 +77,7 @@ class DeviceFamily(Enum): SmartTapoHub = "SMART.TAPOHUB" SmartKasaHub = "SMART.KASAHUB" SmartIpCamera = "SMART.IPCAMERA" + SmartTapoRobovac = "SMART.TAPOROBOVAC" class _DeviceConfigBaseMixin(DataClassJSONMixin): diff --git a/kasa/discover.py b/kasa/discover.py index f89999f45..771c3f5c1 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -598,10 +598,12 @@ async def try_connect_all( for encrypt in Device.EncryptionType for device_family in main_device_families for https in (True, False) + for login_version in (None, 2) if ( conn_params := DeviceConnectionParameters( device_family=device_family, encryption_type=encrypt, + login_version=login_version, https=https, ) ) @@ -768,6 +770,13 @@ def _get_device_instance( ): encrypt_type = encrypt_info.sym_schm + if ( + not (login_version := encrypt_schm.lv) + and (et := discovery_result.encrypt_type) + and et == ["3"] + ): + login_version = 2 + if not encrypt_type: raise UnsupportedDeviceError( f"Unsupported device {config.host} of type {type_} " @@ -778,7 +787,7 @@ def _get_device_instance( config.connection_type = DeviceConnectionParameters.from_values( type_, encrypt_type, - encrypt_schm.lv, + login_version, encrypt_schm.is_support_https, ) except KasaException as ex: diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 0989842ab..adb4829d5 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -802,6 +802,8 @@ def _get_device_type_from_components( return DeviceType.Sensor if "ENERGY" in device_type: return DeviceType.Thermostat + if "ROBOVAC" in device_type: + return DeviceType.Vacuum _LOGGER.warning("Unknown device type, falling back to plug") return DeviceType.Plug diff --git a/kasa/transports/__init__.py b/kasa/transports/__init__.py index 8ccdae65d..3438aab79 100644 --- a/kasa/transports/__init__.py +++ b/kasa/transports/__init__.py @@ -3,11 +3,13 @@ from .aestransport import AesEncyptionSession, AesTransport from .basetransport import BaseTransport from .klaptransport import KlapTransport, KlapTransportV2 +from .ssltransport import SslTransport from .xortransport import XorEncryption, XorTransport __all__ = [ "AesTransport", "AesEncyptionSession", + "SslTransport", "BaseTransport", "KlapTransport", "KlapTransportV2", diff --git a/kasa/transports/ssltransport.py b/kasa/transports/ssltransport.py new file mode 100644 index 000000000..5ffc935f9 --- /dev/null +++ b/kasa/transports/ssltransport.py @@ -0,0 +1,233 @@ +"""Implementation of the clear-text passthrough ssl transport. + +This transport does not encrypt the passthrough payloads at all, but requires a login. +This has been seen on some devices (like robovacs). +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import logging +import time +from enum import Enum, auto +from typing import TYPE_CHECKING, Any, cast + +from yarl import URL + +from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import ( + SMART_AUTHENTICATION_ERRORS, + SMART_RETRYABLE_ERRORS, + AuthenticationError, + DeviceError, + KasaException, + SmartErrorCode, + _RetryableError, +) +from kasa.httpclient import HttpClient +from kasa.json import dumps as json_dumps +from kasa.json import loads as json_loads +from kasa.transports import BaseTransport + +_LOGGER = logging.getLogger(__name__) + + +ONE_DAY_SECONDS = 86400 +SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20 + + +def _md5_hash(payload: bytes) -> str: + return hashlib.md5(payload).hexdigest().upper() # noqa: S324 + + +class TransportState(Enum): + """Enum for transport state.""" + + LOGIN_REQUIRED = auto() # Login needed + ESTABLISHED = auto() # Ready to send requests + + +class SslTransport(BaseTransport): + """Implementation of the cleartext transport protocol. + + This transport uses HTTPS without any further payload encryption. + """ + + DEFAULT_PORT: int = 4433 + COMMON_HEADERS = { + "Content-Type": "application/json", + } + BACKOFF_SECONDS_AFTER_LOGIN_ERROR = 1 + + def __init__( + self, + *, + config: DeviceConfig, + ) -> None: + super().__init__(config=config) + + if ( + not self._credentials or self._credentials.username is None + ) and not self._credentials_hash: + self._credentials = Credentials() + + if self._credentials: + 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: Credentials | None = None + self._http_client: HttpClient = HttpClient(config) + + self._state = TransportState.LOGIN_REQUIRED + self._session_expire_at: float | None = None + + self._app_url = URL(f"https://{self._host}:{self._port}/app") + + _LOGGER.debug("Created ssltransport for %s", self._host) + + @property + def default_port(self) -> int: + """Default port for the transport.""" + return self.DEFAULT_PORT + + @property + def credentials_hash(self) -> str: + """The hashed credentials used by the transport.""" + return base64.b64encode(json_dumps(self._login_params).encode()).decode() + + def _get_login_params(self, credentials: Credentials) -> dict[str, str]: + """Get the login parameters based on the login_version.""" + un, pw = self.hash_credentials(credentials) + return {"password": pw, "username": un} + + @staticmethod + def hash_credentials(credentials: Credentials) -> tuple[str, str]: + """Hash the credentials.""" + un = credentials.username + pw = _md5_hash(credentials.password.encode()) + return un, pw + + async def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: + """Handle response errors to request reauth etc.""" + error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] + if error_code == SmartErrorCode.SUCCESS: + return + + msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})" + + if error_code in SMART_RETRYABLE_ERRORS: + raise _RetryableError(msg, error_code=error_code) + + if error_code in SMART_AUTHENTICATION_ERRORS: + await self.reset() + raise AuthenticationError(msg, error_code=error_code) + + raise DeviceError(msg, error_code=error_code) + + async def send_request(self, request: str) -> dict[str, Any]: + """Send request.""" + url = self._app_url + + _LOGGER.debug("Sending %s to %s", request, url) + + status_code, resp_dict = await self._http_client.post( + url, + json=request, + headers=self.COMMON_HEADERS, + ) + + if status_code != 200: + raise KasaException( + f"{self._host} responded with an unexpected " + + f"status code {status_code}" + ) + + _LOGGER.debug("Response with %s: %r", status_code, resp_dict) + + await self._handle_response_error_code(resp_dict, "Error sending request") + + if TYPE_CHECKING: + resp_dict = cast(dict[str, Any], resp_dict) + + return resp_dict + + async def perform_login(self) -> None: + """Login to the device.""" + try: + await self.try_login(self._login_params) + except AuthenticationError as aex: + try: + if aex.error_code is not SmartErrorCode.LOGIN_ERROR: + raise aex + + _LOGGER.debug("Login failed, going to try default credentials") + if self._default_credentials is None: + self._default_credentials = get_default_credentials( + DEFAULT_CREDENTIALS["TAPO"] + ) + await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_LOGIN_ERROR) + + await self.try_login(self._get_login_params(self._default_credentials)) + _LOGGER.debug( + "%s: logged in with default credentials", + self._host, + ) + except AuthenticationError: + raise + except Exception as ex: + raise KasaException( + "Unable to login and trying default " + + f"login raised another exception: {ex}", + 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", + "params": login_params, + } + request = json_dumps(login_request) + _LOGGER.debug("Going to send login request") + + resp_dict = await self.send_request(request) + await self._handle_response_error_code(resp_dict, "Error logging in") + + login_token = resp_dict["result"]["token"] + self._app_url = self._app_url.with_query(f"token={login_token}") + self._state = TransportState.ESTABLISHED + self._session_expire_at = ( + time.time() + ONE_DAY_SECONDS - SESSION_EXPIRE_BUFFER_SECONDS + ) + + def _session_expired(self) -> bool: + """Return true if session has expired.""" + return ( + self._session_expire_at is None + or self._session_expire_at - time.time() <= 0 + ) + + async def send(self, request: str) -> dict[str, Any]: + """Send the request.""" + _LOGGER.info("Going to send %s", request) + if self._state is not TransportState.ESTABLISHED or self._session_expired(): + _LOGGER.debug("Transport not established or session expired, logging in") + await self.perform_login() + + return await self.send_request(request) + + async def close(self) -> None: + """Close the http client and reset internal state.""" + await self.reset() + await self._http_client.close() + + async def reset(self) -> None: + """Reset internal login state.""" + self._state = TransportState.LOGIN_REQUIRED + self._app_url = URL(f"https://{self._host}:{self._port}/app") diff --git a/tests/test_cli.py b/tests/test_cli.py index bb707bb6a..d1fc330c9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -692,6 +692,8 @@ async def _state(dev: Device): dr.device_type, "--encrypt-type", dr.mgt_encrypt_schm.encrypt_type, + "--login-version", + dr.mgt_encrypt_schm.lv or 1, ], ) assert res.exit_code == 0 diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 860037445..ed73b3a38 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -47,7 +47,10 @@ def _get_connection_type_device_class(discovery_info): dr = DiscoveryResult.from_dict(discovery_info["result"]) connection_type = DeviceConnectionParameters.from_values( - dr.device_type, dr.mgt_encrypt_schm.encrypt_type + dr.device_type, + dr.mgt_encrypt_schm.encrypt_type, + dr.mgt_encrypt_schm.lv, + dr.mgt_encrypt_schm.is_support_https, ) else: connection_type = DeviceConnectionParameters.from_values( diff --git a/tests/transports/test_ssltransport.py b/tests/transports/test_ssltransport.py new file mode 100644 index 000000000..37b797254 --- /dev/null +++ b/tests/transports/test_ssltransport.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +import logging +from base64 import b64encode +from contextlib import nullcontext as does_not_raise +from typing import Any + +import aiohttp +import pytest +from yarl import URL + +from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import ( + AuthenticationError, + DeviceError, + KasaException, + SmartErrorCode, + _RetryableError, +) +from kasa.httpclient import HttpClient +from kasa.json import dumps as json_dumps +from kasa.json import loads as json_loads +from kasa.transports import SslTransport +from kasa.transports.ssltransport import TransportState, _md5_hash + +# Transport tests are not designed for real devices +pytestmark = [pytest.mark.requires_dummy] + +MOCK_PWD = "correct_pwd" # noqa: S105 +MOCK_USER = "mock@example.com" +MOCK_BAD_USER_OR_PWD = "foobar" # noqa: S105 +MOCK_TOKEN = "abcdefghijklmnopqrstuvwxyz1234)(" # noqa: S105 + +DEFAULT_CREDS = get_default_credentials(DEFAULT_CREDENTIALS["TAPO"]) + + +_LOGGER = logging.getLogger(__name__) + + +@pytest.mark.parametrize( + ( + "status_code", + "error_code", + "username", + "password", + "expectation", + ), + [ + pytest.param( + 200, + SmartErrorCode.SUCCESS, + MOCK_USER, + MOCK_PWD, + does_not_raise(), + id="success", + ), + pytest.param( + 200, + SmartErrorCode.UNSPECIFIC_ERROR, + MOCK_USER, + MOCK_PWD, + pytest.raises(_RetryableError), + id="test retry", + ), + pytest.param( + 200, + SmartErrorCode.DEVICE_BLOCKED, + MOCK_USER, + MOCK_PWD, + pytest.raises(DeviceError), + id="test regular error", + ), + pytest.param( + 400, + SmartErrorCode.INTERNAL_UNKNOWN_ERROR, + MOCK_USER, + MOCK_PWD, + pytest.raises(KasaException), + id="400 error", + ), + pytest.param( + 200, + SmartErrorCode.LOGIN_ERROR, + MOCK_BAD_USER_OR_PWD, + MOCK_PWD, + pytest.raises(AuthenticationError), + id="bad-username", + ), + pytest.param( + 200, + [SmartErrorCode.LOGIN_ERROR, SmartErrorCode.SUCCESS], + MOCK_BAD_USER_OR_PWD, + "", + does_not_raise(), + id="working-fallback", + ), + pytest.param( + 200, + [SmartErrorCode.LOGIN_ERROR, SmartErrorCode.LOGIN_ERROR], + MOCK_BAD_USER_OR_PWD, + "", + pytest.raises(AuthenticationError), + id="fallback-fail", + ), + pytest.param( + 200, + SmartErrorCode.LOGIN_ERROR, + MOCK_USER, + MOCK_BAD_USER_OR_PWD, + pytest.raises(AuthenticationError), + id="bad-password", + ), + pytest.param( + 200, + SmartErrorCode.TRANSPORT_UNKNOWN_CREDENTIALS_ERROR, + MOCK_USER, + MOCK_PWD, + pytest.raises(AuthenticationError), + id="auth-error != login_error", + ), + ], +) +async def test_login( + mocker, + status_code, + error_code, + username, + password, + expectation, +): + host = "127.0.0.1" + mock_ssl_aes_device = MockSslDevice( + host, + status_code=status_code, + send_error_code=error_code, + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslTransport( + config=DeviceConfig(host, credentials=Credentials(username, password)) + ) + + assert transport._state is TransportState.LOGIN_REQUIRED + with expectation: + await transport.perform_login() + assert transport._state is TransportState.ESTABLISHED + + await transport.close() + + +async def test_credentials_hash(mocker): + host = "127.0.0.1" + mock_ssl_aes_device = MockSslDevice(host) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + creds = Credentials(MOCK_USER, MOCK_PWD) + + data = {"password": _md5_hash(MOCK_PWD.encode()), "username": MOCK_USER} + + creds_hash = b64encode(json_dumps(data).encode()).decode() + + # Test with credentials input + transport = SslTransport(config=DeviceConfig(host, credentials=creds)) + assert transport.credentials_hash == creds_hash + + # Test with credentials_hash input + transport = SslTransport(config=DeviceConfig(host, credentials_hash=creds_hash)) + assert transport.credentials_hash == creds_hash + + await transport.close() + + +async def test_send(mocker): + host = "127.0.0.1" + mock_ssl_aes_device = MockSslDevice(host, send_error_code=SmartErrorCode.SUCCESS) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + try_login_spy = mocker.spy(transport, "try_login") + request = { + "method": "get_device_info", + "params": None, + } + assert transport._state is TransportState.LOGIN_REQUIRED + + res = await transport.send(json_dumps(request)) + assert "result" in res + try_login_spy.assert_called_once() + assert transport._state is TransportState.ESTABLISHED + + # Second request does not + res = await transport.send(json_dumps(request)) + try_login_spy.assert_called_once() + + await transport.close() + + +async def test_no_credentials(mocker): + """Test transport without credentials.""" + host = "127.0.0.1" + mock_ssl_aes_device = MockSslDevice( + host, send_error_code=SmartErrorCode.LOGIN_ERROR + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslTransport(config=DeviceConfig(host)) + try_login_spy = mocker.spy(transport, "try_login") + + with pytest.raises(AuthenticationError): + await transport.send('{"method": "dummy"}') + + # We get called twice + assert try_login_spy.call_count == 2 + + await transport.close() + + +async def test_reset(mocker): + """Test that transport state adjusts correctly for reset.""" + host = "127.0.0.1" + mock_ssl_aes_device = MockSslDevice(host, send_error_code=SmartErrorCode.SUCCESS) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + + assert transport._state is TransportState.LOGIN_REQUIRED + assert str(transport._app_url) == "https://127.0.0.1:4433/app" + + await transport.perform_login() + assert transport._state is TransportState.ESTABLISHED + assert str(transport._app_url).startswith("https://127.0.0.1:4433/app?token=") + + await transport.close() + assert transport._state is TransportState.LOGIN_REQUIRED + assert str(transport._app_url) == "https://127.0.0.1:4433/app" + + +async def test_port_override(): + """Test that port override sets the app_url.""" + host = "127.0.0.1" + port_override = 12345 + config = DeviceConfig( + host, credentials=Credentials("foo", "bar"), port_override=port_override + ) + transport = SslTransport(config=config) + + assert str(transport._app_url) == f"https://127.0.0.1:{port_override}/app" + + await transport.close() + + +class MockSslDevice: + """Based on MockAesSslDevice.""" + + class _mock_response: + def __init__(self, status, request: dict): + self.status = status + self._json = request + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_t, exc_v, exc_tb): + pass + + async def read(self): + if isinstance(self._json, dict): + return json_dumps(self._json).encode() + return self._json + + def __init__( + self, + host, + *, + status_code=200, + send_error_code=SmartErrorCode.INTERNAL_UNKNOWN_ERROR, + ): + self.host = host + self.http_client = HttpClient(DeviceConfig(self.host)) + + self._state = TransportState.LOGIN_REQUIRED + + # test behaviour attributes + self.status_code = status_code + self.send_error_code = send_error_code + + async def post(self, url: URL, params=None, json=None, data=None, *_, **__): + if data: + json = json_loads(data) + _LOGGER.debug("Request %s: %s", url, json) + res = self._post(url, json) + _LOGGER.debug("Response %s, data: %s", res, await res.read()) + return res + + def _post(self, url: URL, json: dict[str, Any]): + method = json["method"] + + if method == "login": + if self._state is TransportState.LOGIN_REQUIRED: + assert json.get("token") is None + assert url == URL(f"https://{self.host}:4433/app") + return self._return_login_response(url, json) + else: + _LOGGER.warning("Received login although already logged in") + pytest.fail("non-handled re-login logic") + + assert url == URL(f"https://{self.host}:4433/app?token={MOCK_TOKEN}") + return self._return_send_response(url, json) + + def _return_login_response(self, url: URL, request: dict[str, Any]): + request_username = request["params"].get("username") + request_password = request["params"].get("password") + + # Handle multiple error codes + if isinstance(self.send_error_code, list): + error_code = self.send_error_code.pop(0) + else: + error_code = self.send_error_code + + _LOGGER.debug("Using error code %s", error_code) + + def _return_login_error(): + resp = { + "error_code": error_code.value, + "result": {"unknown": "payload"}, + } + + _LOGGER.debug("Returning login error with status %s", self.status_code) + return self._mock_response(self.status_code, resp) + + if error_code is not SmartErrorCode.SUCCESS: + # Bad username + if request_username == MOCK_BAD_USER_OR_PWD: + return _return_login_error() + + # Bad password + if request_password == _md5_hash(MOCK_BAD_USER_OR_PWD.encode()): + return _return_login_error() + + # Empty password + if request_password == _md5_hash(b""): + return _return_login_error() + + self._state = TransportState.ESTABLISHED + resp = { + "error_code": error_code.value, + "result": { + "token": MOCK_TOKEN, + }, + } + _LOGGER.debug("Returning login success with status %s", self.status_code) + return self._mock_response(self.status_code, resp) + + def _return_send_response(self, url: URL, json: dict[str, Any]): + method = json["method"] + result = { + "result": {method: {"dummy": "response"}}, + "error_code": self.send_error_code.value, + } + return self._mock_response(self.status_code, result) From 74b59d7f9880d55586f991e459dc759bb4814fb2 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sun, 1 Dec 2024 18:07:05 +0100 Subject: [PATCH 729/892] Scrub more vacuum keys (#1328) --- devtools/dump_devinfo.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index b6c44fe52..7760b6cb9 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -115,6 +115,10 @@ def scrub(res): "encrypt_info", "local_ip", "username", + # vacuum + "board_sn", + "custom_sn", + "location", ] for k, v in res.items(): @@ -153,7 +157,13 @@ def scrub(res): v = base64.b64encode(b"#MASKED_SSID#").decode() elif k in ["nickname"]: v = base64.b64encode(b"#MASKED_NAME#").decode() - elif k in ["alias", "device_alias", "device_name", "username"]: + elif k in [ + "alias", + "device_alias", + "device_name", + "username", + "location", + ]: v = "#MASKED_NAME#" elif isinstance(res[k], int): v = 0 From 123ea107b1e7536bc5dfc8b93111cc5c7e8d066b Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Mon, 2 Dec 2024 16:38:20 +0100 Subject: [PATCH 730/892] Add link to related homeassistant-tapo-control (#1333) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f59f36770..3595dd19e 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ See [supported devices in our documentation](SUPPORTED.md) for more detailed inf ### Other related projects * [PyTapo - Python library for communication with Tapo Cameras](https://github.com/JurajNyiri/pytapo) + * [Home Assistant integration](https://github.com/JurajNyiri/HomeAssistant-Tapo-Control) * [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) From 4eed945e002623a82e35005085d6e1e4ad79c68f Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 5 Dec 2024 09:14:45 +0000 Subject: [PATCH 731/892] Do not error when accessing smart device_type before update (#1319) --- kasa/smart/smartdevice.py | 9 +++++---- tests/discovery_fixtures.py | 2 ++ tests/smart/test_smartdevice.py | 30 +++++++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index adb4829d5..176efb710 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -765,10 +765,11 @@ def device_type(self) -> DeviceType: if self._device_type is not DeviceType.Unknown: return self._device_type - # Fallback to device_type (from disco info) - type_str = self._info.get("type", self._info.get("device_type")) - - if not type_str: # no update or discovery info + if ( + not (type_str := self._info.get("type", self._info.get("device_type"))) + or not self._components + ): + # no update or discovery info return self._device_type self._device_type = self._get_device_type_from_components( diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index c65d47bda..939215365 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -130,6 +130,8 @@ def parametrize_discovery( "new discovery", data_root_filter="discovery_result" ) +smart_discovery = parametrize_discovery("smart discovery", protocol_filter={"SMART"}) + @pytest.fixture( params=filter_fixtures("discoverable", protocol_filter={"SMART", "IOT"}), diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index c53193a32..81707a11a 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import logging import time from typing import Any, cast @@ -11,16 +12,18 @@ from freezegun.api import FrozenDateTimeFactory from pytest_mock import MockerFixture -from kasa import Device, KasaException, Module +from kasa import Device, DeviceType, KasaException, Module from kasa.exceptions import DeviceError, SmartErrorCode from kasa.protocols.smartprotocol import _ChildProtocolWrapper from kasa.smart import SmartDevice from kasa.smart.modules.energy import Energy from kasa.smart.smartmodule import SmartModule from tests.conftest import ( + DISCOVERY_MOCK_IP, device_smart, get_device_for_fixture_protocol, get_parent_and_child_modules, + smart_discovery, ) from tests.device_fixtures import variable_temp_smart @@ -51,6 +54,31 @@ async def test_update_no_device_info(dev: SmartDevice, mocker: MockerFixture): await dev.update() +@smart_discovery +async def test_device_type_no_update(discovery_mock, caplog: pytest.LogCaptureFixture): + """Test device type and repr when device not updated.""" + dev = SmartDevice(DISCOVERY_MOCK_IP) + assert dev.device_type is DeviceType.Unknown + assert repr(dev) == f"" + + discovery_result = copy.deepcopy(discovery_mock.discovery_data["result"]) + dev.update_from_discover_info(discovery_result) + assert dev.device_type is DeviceType.Unknown + assert ( + repr(dev) + == f"" + ) + discovery_result["device_type"] = "SMART.FOOBAR" + dev.update_from_discover_info(discovery_result) + dev._components = {"dummy": 1} + assert dev.device_type is DeviceType.Plug + assert ( + repr(dev) + == f"" + ) + assert "Unknown device type, falling back to plug" in caplog.text + + @device_smart async def test_initial_update(dev: SmartDevice, mocker: MockerFixture): """Test the initial update cycle.""" From 8814d9498937efdc9892c445f4c251f69755434b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:49:35 +0000 Subject: [PATCH 732/892] Provide alternative camera urls (#1316) --- kasa/__init__.py | 2 + kasa/smartcam/modules/camera.py | 32 +++++++-- .../test_camera.py} | 67 ++++++------------- tests/smartcam/test_smartcamdevice.py | 61 +++++++++++++++++ 4 files changed, 110 insertions(+), 52 deletions(-) rename tests/smartcam/{test_smartcamera.py => modules/test_camera.py} (57%) create mode 100644 tests/smartcam/test_smartcamdevice.py diff --git a/kasa/__init__.py b/kasa/__init__.py index d4a5022e3..ee52eb3af 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -40,6 +40,7 @@ from kasa.module import Module from kasa.protocols import BaseProtocol, IotProtocol, SmartProtocol from kasa.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401 +from kasa.smartcam.modules.camera import StreamResolution from kasa.transports import BaseTransport __version__ = version("python-kasa") @@ -75,6 +76,7 @@ "DeviceFamily", "ThermostatState", "Thermostat", + "StreamResolution", ] from . import iot diff --git a/kasa/smartcam/modules/camera.py b/kasa/smartcam/modules/camera.py index 815db62bb..e96794c29 100644 --- a/kasa/smartcam/modules/camera.py +++ b/kasa/smartcam/modules/camera.py @@ -4,6 +4,7 @@ import base64 import logging +from enum import StrEnum from urllib.parse import quote_plus from ...credentials import Credentials @@ -15,6 +16,14 @@ _LOGGER = logging.getLogger(__name__) LOCAL_STREAMING_PORT = 554 +ONVIF_PORT = 2020 + + +class StreamResolution(StrEnum): + """Class for stream resolution.""" + + HD = "HD" + SD = "SD" class Camera(SmartCamModule): @@ -64,7 +73,12 @@ def _get_credentials(self) -> Credentials | None: return None - def stream_rtsp_url(self, credentials: Credentials | None = None) -> str | None: + def stream_rtsp_url( + self, + credentials: Credentials | None = None, + *, + stream_resolution: StreamResolution = StreamResolution.HD, + ) -> str | None: """Return the local rtsp streaming url. :param credentials: Credentials for camera account. @@ -73,17 +87,27 @@ def stream_rtsp_url(self, credentials: Credentials | None = None) -> str | None: :return: rtsp url with escaped credentials or None if no credentials or camera is off. """ - if not self.is_on: + streams = { + StreamResolution.HD: "stream1", + StreamResolution.SD: "stream2", + } + if (stream := streams.get(stream_resolution)) is None: return None - dev = self._device + if not credentials: credentials = self._get_credentials() if not credentials or not credentials.username or not credentials.password: return None + username = quote_plus(credentials.username) password = quote_plus(credentials.password) - return f"rtsp://{username}:{password}@{dev.host}:{LOCAL_STREAMING_PORT}/stream1" + + return f"rtsp://{username}:{password}@{self._device.host}:{LOCAL_STREAMING_PORT}/{stream}" + + def onvif_url(self) -> str | None: + """Return the onvif url.""" + return f"http://{self._device.host}:{ONVIF_PORT}/onvif/device_service" async def set_state(self, on: bool) -> dict: """Set the device state.""" diff --git a/tests/smartcam/test_smartcamera.py b/tests/smartcam/modules/test_camera.py similarity index 57% rename from tests/smartcam/test_smartcamera.py rename to tests/smartcam/modules/test_camera.py index ccb4fbc1a..ebc08101c 100644 --- a/tests/smartcam/test_smartcamera.py +++ b/tests/smartcam/modules/test_camera.py @@ -4,15 +4,13 @@ import base64 import json -from datetime import UTC, datetime from unittest.mock import patch import pytest -from freezegun.api import FrozenDateTimeFactory -from kasa import Credentials, Device, DeviceType, Module +from kasa import Credentials, Device, DeviceType, Module, StreamResolution -from ..conftest import camera_smartcam, device_smartcam, hub_smartcam +from ...conftest import camera_smartcam, device_smartcam @device_smartcam @@ -37,6 +35,16 @@ async def test_stream_rtsp_url(dev: Device): url = camera_module.stream_rtsp_url(Credentials("foo", "bar")) assert url == "rtsp://foo:bar@127.0.0.123:554/stream1" + url = camera_module.stream_rtsp_url( + Credentials("foo", "bar"), stream_resolution=StreamResolution.HD + ) + assert url == "rtsp://foo:bar@127.0.0.123:554/stream1" + + url = camera_module.stream_rtsp_url( + Credentials("foo", "bar"), stream_resolution=StreamResolution.SD + ) + assert url == "rtsp://foo:bar@127.0.0.123:554/stream2" + with patch.object(dev.config, "credentials", Credentials("bar", "foo")): url = camera_module.stream_rtsp_url() assert url == "rtsp://bar:foo@127.0.0.123:554/stream1" @@ -75,49 +83,12 @@ async def test_stream_rtsp_url(dev: Device): url = camera_module.stream_rtsp_url() assert url is None - # Test with camera off - await camera_module.set_state(False) - await dev.update() - url = camera_module.stream_rtsp_url(Credentials("foo", "bar")) - assert url is None - with patch.object(dev.config, "credentials", Credentials("bar", "foo")): - url = camera_module.stream_rtsp_url() - assert url is None - - -@device_smartcam -async def test_alias(dev): - test_alias = "TEST1234" - original = dev.alias - - assert isinstance(original, str) - await dev.set_alias(test_alias) - await dev.update() - assert dev.alias == test_alias - - await dev.set_alias(original) - await dev.update() - assert dev.alias == original - - -@hub_smartcam -async def test_hub(dev): - assert dev.children - for child in dev.children: - assert "Cloud" in child.modules - assert child.modules["Cloud"].data - assert child.alias - await child.update() - assert "Time" not in child.modules - assert child.time +@camera_smartcam +async def test_onvif_url(dev: Device): + """Test the onvif url.""" + camera_module = dev.modules.get(Module.Camera) + assert camera_module -@device_smartcam -async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory): - """Test a child device gets the time from it's parent module.""" - fallback_time = datetime.now(UTC).astimezone().replace(microsecond=0) - assert dev.time != fallback_time - module = dev.modules[Module.Time] - await module.set_time(fallback_time) - await dev.update() - assert dev.time == fallback_time + url = camera_module.onvif_url() + assert url == "http://127.0.0.123:2020/onvif/device_service" diff --git a/tests/smartcam/test_smartcamdevice.py b/tests/smartcam/test_smartcamdevice.py new file mode 100644 index 000000000..438737eb9 --- /dev/null +++ b/tests/smartcam/test_smartcamdevice.py @@ -0,0 +1,61 @@ +"""Tests for smart camera devices.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from freezegun.api import FrozenDateTimeFactory + +from kasa import Device, DeviceType, Module + +from ..conftest import device_smartcam, hub_smartcam + + +@device_smartcam +async def test_state(dev: Device): + if dev.device_type is DeviceType.Hub: + pytest.skip("Hubs cannot be switched on and off") + + state = dev.is_on + await dev.set_state(not state) + await dev.update() + assert dev.is_on is not state + + +@device_smartcam +async def test_alias(dev): + test_alias = "TEST1234" + original = dev.alias + + assert isinstance(original, str) + await dev.set_alias(test_alias) + await dev.update() + assert dev.alias == test_alias + + await dev.set_alias(original) + await dev.update() + assert dev.alias == original + + +@hub_smartcam +async def test_hub(dev): + assert dev.children + for child in dev.children: + assert "Cloud" in child.modules + assert child.modules["Cloud"].data + assert child.alias + await child.update() + assert "Time" not in child.modules + assert child.time + + +@device_smartcam +async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory): + """Test a child device gets the time from it's parent module.""" + fallback_time = datetime.now(UTC).astimezone().replace(microsecond=0) + assert dev.time != fallback_time + module = dev.modules[Module.Time] + await module.set_time(fallback_time) + await dev.update() + assert dev.time == fallback_time From 1c9ee4d53729f66b3d560de4d7eb9ceacd7d503e Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 6 Dec 2024 09:40:44 +0000 Subject: [PATCH 733/892] Fix smartcam missing device id (#1343) --- kasa/smartcam/smartcamdevice.py | 1 + tests/test_device.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index 0e49be264..d75c378b0 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -200,6 +200,7 @@ def _map_info(self, device_info: dict) -> dict: "mac": basic_info["mac"], "hwId": basic_info.get("hw_id"), "oem_id": basic_info["oem_id"], + "device_id": basic_info["dev_id"], } @property diff --git a/tests/test_device.py b/tests/test_device.py index 1d780c32a..5cf75a61b 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -55,6 +55,11 @@ def _get_subclasses(of_class): ) +async def test_device_id(dev: Device): + """Test all devices have a device id.""" + assert dev.device_id + + async def test_alias(dev): test_alias = "TEST1234" original = dev.alias From be8b7139b8fcc8adf8056ba3b3bd176a607b2eae Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 6 Dec 2024 11:01:44 +0000 Subject: [PATCH 734/892] Fix update errors on hubs with unsupported children (#1344) --- kasa/smart/smartdevice.py | 9 ++++++++- kasa/smartcam/smartcamdevice.py | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 176efb710..48f50c0e8 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -167,7 +167,14 @@ def _update_children_info(self) -> None: self._last_update, "get_child_device_list", {} ): for info in child_info["child_device_list"]: - self._children[info["device_id"]]._update_internal_state(info) + child_id = info["device_id"] + if child_id not in self._children: + _LOGGER.debug( + "Skipping child update for %s, probably unsupported device", + child_id, + ) + continue + self._children[child_id]._update_internal_state(info) def _update_internal_info(self, info_resp: dict) -> None: """Update the internal device info.""" diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index d75c378b0..0090117ed 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -68,7 +68,14 @@ def _update_children_info(self) -> None: self._last_update, "getChildDeviceList", {} ): for info in child_info["child_device_list"]: - self._children[info["device_id"]]._update_internal_state(info) + child_id = info["device_id"] + if child_id not in self._children: + _LOGGER.debug( + "Skipping child update for %s, probably unsupported device", + child_id, + ) + continue + self._children[child_id]._update_internal_state(info) async def _initialize_smart_child( self, info: dict, child_components: dict From 7e8b83edb95953f40130e86e9f435abf7cb73d64 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 6 Dec 2024 09:40:44 +0000 Subject: [PATCH 735/892] Fix smartcam missing device id (#1343) --- kasa/smartcam/smartcamdevice.py | 1 + tests/test_device.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index 0e49be264..d75c378b0 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -200,6 +200,7 @@ def _map_info(self, device_info: dict) -> dict: "mac": basic_info["mac"], "hwId": basic_info.get("hw_id"), "oem_id": basic_info["oem_id"], + "device_id": basic_info["dev_id"], } @property diff --git a/tests/test_device.py b/tests/test_device.py index 1d780c32a..5cf75a61b 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -55,6 +55,11 @@ def _get_subclasses(of_class): ) +async def test_device_id(dev: Device): + """Test all devices have a device id.""" + assert dev.device_id + + async def test_alias(dev): test_alias = "TEST1234" original = dev.alias From 5465b66dee3969ffd24be6b64dd294f0a6a978ab Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 6 Dec 2024 11:01:44 +0000 Subject: [PATCH 736/892] Fix update errors on hubs with unsupported children (#1344) --- kasa/smart/smartdevice.py | 9 ++++++++- kasa/smartcam/smartcamdevice.py | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 0989842ab..2ded9f144 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -167,7 +167,14 @@ def _update_children_info(self) -> None: self._last_update, "get_child_device_list", {} ): for info in child_info["child_device_list"]: - self._children[info["device_id"]]._update_internal_state(info) + child_id = info["device_id"] + if child_id not in self._children: + _LOGGER.debug( + "Skipping child update for %s, probably unsupported device", + child_id, + ) + continue + self._children[child_id]._update_internal_state(info) def _update_internal_info(self, info_resp: dict) -> None: """Update the internal device info.""" diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index d75c378b0..0090117ed 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -68,7 +68,14 @@ def _update_children_info(self) -> None: self._last_update, "getChildDeviceList", {} ): for info in child_info["child_device_list"]: - self._children[info["device_id"]]._update_internal_state(info) + child_id = info["device_id"] + if child_id not in self._children: + _LOGGER.debug( + "Skipping child update for %s, probably unsupported device", + child_id, + ) + continue + self._children[child_id]._update_internal_state(info) async def _initialize_smart_child( self, info: dict, child_components: dict From 5a596dbcc9e787a7cd034e60e61b951021b25e46 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 6 Dec 2024 11:18:48 +0000 Subject: [PATCH 737/892] Prepare 0.8.1 --- CHANGELOG.md | 11 +++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e64db281..2ef0873f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [0.8.1](https://github.com/python-kasa/python-kasa/tree/0.8.1) (2024-12-06) + +This patch release fixes some issues with newly supported smartcam devices. + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.8.0...0.8.1) + +**Fixed bugs:** + +- Fix update errors on hubs with unsupported children [\#1344](https://github.com/python-kasa/python-kasa/pull/1344) (@sdb9696) +- Fix smartcam missing device id [\#1343](https://github.com/python-kasa/python-kasa/pull/1343) (@sdb9696) + ## [0.8.0](https://github.com/python-kasa/python-kasa/tree/0.8.0) (2024-11-26) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.7...0.8.0) diff --git a/pyproject.toml b/pyproject.toml index 506888cdc..9dc265c8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-kasa" -version = "0.8.0" +version = "0.8.1" description = "Python API for TP-Link Kasa and Tapo devices" license = {text = "GPL-3.0-or-later"} authors = [ { name = "python-kasa developers" }] diff --git a/uv.lock b/uv.lock index 12e2cb812..c68023301 100644 --- a/uv.lock +++ b/uv.lock @@ -1088,7 +1088,7 @@ wheels = [ [[package]] name = "python-kasa" -version = "0.8.0" +version = "0.8.1" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From cb89342be1c9fc0d07c6cc2b553a2e4b5c16a874 Mon Sep 17 00:00:00 2001 From: Puxtril Date: Fri, 6 Dec 2024 18:06:58 -0500 Subject: [PATCH 738/892] Add LinkieTransportV2 and basic IOT.IPCAMERA support (#1270) Add LinkieTransportV2 transport used by kasa cameras and a basic implementation for IOT.IPCAMERA (kasacam) devices. --------- Co-authored-by: Zach Price Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> Co-authored-by: Teemu Rytilahti --- kasa/cli/discover.py | 5 +- kasa/credentials.py | 1 + kasa/device_factory.py | 5 + kasa/deviceconfig.py | 1 + kasa/discover.py | 19 ++- kasa/iot/__init__.py | 2 + kasa/iot/iotcamera.py | 42 +++++ kasa/iot/iotdevice.py | 22 ++- kasa/transports/__init__.py | 2 + kasa/transports/linkietransport.py | 143 +++++++++++++++++ .../fixtures/iotcam/EC60(US)_4.0_2.3.22.json | 86 +++++++++++ tests/test_device.py | 2 + tests/transports/test_linkietransport.py | 144 ++++++++++++++++++ 13 files changed, 461 insertions(+), 13 deletions(-) mode change 100755 => 100644 kasa/device_factory.py create mode 100644 kasa/iot/iotcamera.py create mode 100644 kasa/transports/linkietransport.py create mode 100644 tests/fixtures/iotcam/EC60(US)_4.0_2.3.22.json create mode 100644 tests/transports/test_linkietransport.py diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index 377d75e8f..f89670669 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -15,6 +15,7 @@ UnsupportedDeviceError, ) from kasa.discover import ConnectAttempt, DiscoveryResult +from kasa.iot.iotdevice import _extract_sys_info from .common import echo, error @@ -201,8 +202,8 @@ def _echo_discovery_info(discovery_info) -> None: if discovery_info is None: return - if "system" in discovery_info and "get_sysinfo" in discovery_info["system"]: - _echo_dictionary(discovery_info["system"]["get_sysinfo"]) + if sysinfo := _extract_sys_info(discovery_info): + _echo_dictionary(sysinfo) return try: diff --git a/kasa/credentials.py b/kasa/credentials.py index 2d6699994..66dd11742 100644 --- a/kasa/credentials.py +++ b/kasa/credentials.py @@ -25,6 +25,7 @@ def get_default_credentials(tuple: tuple[str, str]) -> Credentials: DEFAULT_CREDENTIALS = { "KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"), + "KASACAMERA": ("YWRtaW4=", "MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="), "TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="), "TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="), } diff --git a/kasa/device_factory.py b/kasa/device_factory.py old mode 100755 new mode 100644 index be3c6ca05..a10155705 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -12,6 +12,7 @@ from .exceptions import KasaException, UnsupportedDeviceError from .iot import ( IotBulb, + IotCamera, IotDevice, IotDimmer, IotLightStrip, @@ -32,6 +33,7 @@ BaseTransport, KlapTransport, KlapTransportV2, + LinkieTransportV2, SslTransport, XorTransport, ) @@ -138,6 +140,7 @@ def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]: DeviceType.Strip: IotStrip, DeviceType.WallSwitch: IotWallSwitch, DeviceType.LightStrip: IotLightStrip, + DeviceType.Camera: IotCamera, } return TYPE_TO_CLASS[IotDevice._get_device_type_from_sys_info(sysinfo)] @@ -159,6 +162,7 @@ def get_device_class_from_family( "SMART.TAPOROBOVAC": SmartDevice, "IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTBULB": IotBulb, + "IOT.IPCAMERA": IotCamera, } lookup_key = f"{device_type}{'.HTTPS' if https else ''}" if ( @@ -197,6 +201,7 @@ def get_protocol( ] = { "IOT.XOR": (IotProtocol, XorTransport), "IOT.KLAP": (IotProtocol, KlapTransport), + "IOT.XOR.HTTPS.2": (IotProtocol, LinkieTransportV2), "SMART.AES": (SmartProtocol, AesTransport), "SMART.AES.2": (SmartProtocol, AesTransport), "SMART.KLAP.2": (SmartProtocol, KlapTransportV2), diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 6f9176f57..d2fb3e45b 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -69,6 +69,7 @@ class DeviceFamily(Enum): IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH" IotSmartBulb = "IOT.SMARTBULB" + IotIpCamera = "IOT.IPCAMERA" SmartKasaPlug = "SMART.KASAPLUG" SmartKasaSwitch = "SMART.KASASWITCH" SmartTapoPlug = "SMART.TAPOPLUG" diff --git a/kasa/discover.py b/kasa/discover.py index 771c3f5c1..9cb0808db 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -123,7 +123,7 @@ TimeoutError, UnsupportedDeviceError, ) -from kasa.iot.iotdevice import IotDevice +from kasa.iot.iotdevice import IotDevice, _extract_sys_info from kasa.json import DataClassJSONMixin from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads @@ -681,12 +681,17 @@ def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice: device_class = cast(type[IotDevice], Discover._get_device_class(info)) device = device_class(config.host, config=config) - sys_info = info["system"]["get_sysinfo"] - if device_type := sys_info.get("mic_type", sys_info.get("type")): - config.connection_type = DeviceConnectionParameters.from_values( - device_family=device_type, - encryption_type=DeviceEncryptionType.Xor.value, - ) + sys_info = _extract_sys_info(info) + device_type = sys_info.get("mic_type", sys_info.get("type")) + login_version = ( + sys_info.get("stream_version") if device_type == "IOT.IPCAMERA" else None + ) + config.connection_type = DeviceConnectionParameters.from_values( + device_family=device_type, + encryption_type=DeviceEncryptionType.Xor.value, + https=device_type == "IOT.IPCAMERA", + login_version=login_version, + ) device.protocol = get_protocol(config) # type: ignore[assignment] device.update_from_discover_info(info) return device diff --git a/kasa/iot/__init__.py b/kasa/iot/__init__.py index 536679ca3..3b5b01c64 100644 --- a/kasa/iot/__init__.py +++ b/kasa/iot/__init__.py @@ -1,6 +1,7 @@ """Package for supporting legacy kasa devices.""" from .iotbulb import IotBulb +from .iotcamera import IotCamera from .iotdevice import IotDevice from .iotdimmer import IotDimmer from .iotlightstrip import IotLightStrip @@ -15,4 +16,5 @@ "IotDimmer", "IotLightStrip", "IotWallSwitch", + "IotCamera", ] diff --git a/kasa/iot/iotcamera.py b/kasa/iot/iotcamera.py new file mode 100644 index 000000000..8965948ce --- /dev/null +++ b/kasa/iot/iotcamera.py @@ -0,0 +1,42 @@ +"""Module for cameras.""" + +from __future__ import annotations + +import logging +from datetime import datetime, tzinfo + +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..protocols import BaseProtocol +from .iotdevice import IotDevice + +_LOGGER = logging.getLogger(__name__) + + +class IotCamera(IotDevice): + """Representation of a TP-Link Camera.""" + + def __init__( + self, + host: str, + *, + config: DeviceConfig | None = None, + protocol: BaseProtocol | None = None, + ) -> None: + super().__init__(host=host, config=config, protocol=protocol) + self._device_type = DeviceType.Camera + + @property + def time(self) -> datetime: + """Get the camera's time.""" + return datetime.fromtimestamp(self.sys_info["system_time"]) + + @property + def timezone(self) -> tzinfo: + """Get the camera's timezone.""" + return None # type: ignore + + @property # type: ignore + def is_on(self) -> bool: + """Return whether device is on.""" + return True diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index f23ebc8bd..90f63c973 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -70,6 +70,16 @@ def _parse_features(features: str) -> set[str]: return set(features.split(":")) +def _extract_sys_info(info: dict[str, Any]) -> dict[str, Any]: + """Return the system info structure.""" + sysinfo_default = info.get("system", {}).get("get_sysinfo", {}) + sysinfo_nest = sysinfo_default.get("system", {}) + + if len(sysinfo_nest) > len(sysinfo_default) and isinstance(sysinfo_nest, dict): + return sysinfo_nest + return sysinfo_default + + class IotDevice(Device): """Base class for all supported device types. @@ -304,14 +314,14 @@ async def update(self, update_children: bool = True) -> None: _LOGGER.debug("Performing the initial update to obtain sysinfo") response = await self.protocol.query(req) self._last_update = response - self._set_sys_info(response["system"]["get_sysinfo"]) + self._set_sys_info(_extract_sys_info(response)) if not self._modules: await self._initialize_modules() await self._modular_update(req) - self._set_sys_info(self._last_update["system"]["get_sysinfo"]) + self._set_sys_info(_extract_sys_info(self._last_update)) for module in self._modules.values(): await module._post_update_hook() @@ -705,10 +715,13 @@ def internal_state(self) -> Any: @staticmethod def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType: """Find SmartDevice subclass for device described by passed data.""" + if "system" in info.get("system", {}).get("get_sysinfo", {}): + return DeviceType.Camera + if "system" not in info or "get_sysinfo" not in info["system"]: raise KasaException("No 'system' or 'get_sysinfo' in response") - sysinfo: dict[str, Any] = info["system"]["get_sysinfo"] + sysinfo: dict[str, Any] = _extract_sys_info(info) type_: str | None = sysinfo.get("type", sysinfo.get("mic_type")) if type_ is None: raise KasaException("Unable to find the device type field!") @@ -728,6 +741,7 @@ def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType: return DeviceType.LightStrip return DeviceType.Bulb + _LOGGER.warning("Unknown device type %s, falling back to plug", type_) return DeviceType.Plug @@ -736,7 +750,7 @@ def _get_device_info( info: dict[str, Any], discovery_info: dict[str, Any] | None ) -> _DeviceInfo: """Get model information for a device.""" - sys_info = info["system"]["get_sysinfo"] + sys_info = _extract_sys_info(info) # Get model and region info region = None diff --git a/kasa/transports/__init__.py b/kasa/transports/__init__.py index 3438aab79..602d0cca1 100644 --- a/kasa/transports/__init__.py +++ b/kasa/transports/__init__.py @@ -3,6 +3,7 @@ from .aestransport import AesEncyptionSession, AesTransport from .basetransport import BaseTransport from .klaptransport import KlapTransport, KlapTransportV2 +from .linkietransport import LinkieTransportV2 from .ssltransport import SslTransport from .xortransport import XorEncryption, XorTransport @@ -13,6 +14,7 @@ "BaseTransport", "KlapTransport", "KlapTransportV2", + "LinkieTransportV2", "XorTransport", "XorEncryption", ] diff --git a/kasa/transports/linkietransport.py b/kasa/transports/linkietransport.py new file mode 100644 index 000000000..779d182e0 --- /dev/null +++ b/kasa/transports/linkietransport.py @@ -0,0 +1,143 @@ +"""Implementation of the linkie kasa camera transport.""" + +from __future__ import annotations + +import asyncio +import base64 +import logging +import ssl +from typing import TYPE_CHECKING, cast +from urllib.parse import quote + +from yarl import URL + +from kasa.credentials import DEFAULT_CREDENTIALS, get_default_credentials +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import KasaException, _RetryableError +from kasa.httpclient import HttpClient +from kasa.json import loads as json_loads +from kasa.transports.xortransport import XorEncryption + +from .basetransport import BaseTransport + +_LOGGER = logging.getLogger(__name__) + + +class LinkieTransportV2(BaseTransport): + """Implementation of the Linkie encryption protocol. + + Linkie is used as the endpoint for TP-Link's camera encryption + protocol, used by newer firmware versions. + """ + + DEFAULT_PORT: int = 10443 + CIPHERS = ":".join( + [ + "AES256-GCM-SHA384", + "AES256-SHA256", + "AES128-GCM-SHA256", + "AES128-SHA256", + "AES256-SHA", + ] + ) + + def __init__(self, *, config: DeviceConfig) -> None: + super().__init__(config=config) + self._http_client = HttpClient(config) + self._ssl_context: ssl.SSLContext | None = None + self._app_url = URL(f"https://{self._host}:{self._port}/data/LINKIE2.json") + + self._headers = { + "Authorization": f"Basic {self.credentials_hash}", + "Content-Type": "application/x-www-form-urlencoded", + } + + @property + def default_port(self) -> int: + """Default port for the transport.""" + return self.DEFAULT_PORT + + @property + def credentials_hash(self) -> str | None: + """The hashed credentials used by the transport.""" + creds = get_default_credentials(DEFAULT_CREDENTIALS["KASACAMERA"]) + creds_combined = f"{creds.username}:{creds.password}" + return base64.b64encode(creds_combined.encode()).decode() + + async def _execute_send(self, request: str) -> dict: + """Execute a query on the device and wait for the response.""" + _LOGGER.debug("%s >> %s", self._host, request) + + encrypted_cmd = XorEncryption.encrypt(request)[4:] + b64_cmd = base64.b64encode(encrypted_cmd).decode() + url_safe_cmd = quote(b64_cmd, safe="!~*'()") + + status_code, response = await self._http_client.post( + self._app_url, + headers=self._headers, + data=f"content={url_safe_cmd}".encode(), + ssl=await self._get_ssl_context(), + ) + + if TYPE_CHECKING: + response = cast(bytes, response) + + if status_code != 200: + raise KasaException( + f"{self._host} responded with an unexpected " + + f"status code {status_code} to passthrough" + ) + + # Expected response + try: + json_payload: dict = json_loads( + XorEncryption.decrypt(base64.b64decode(response)) + ) + _LOGGER.debug("%s << %s", self._host, json_payload) + return json_payload + except Exception: # noqa: S110 + pass + + # Device returned error as json plaintext + to_raise: KasaException | None = None + try: + error_payload: dict = json_loads(response) + to_raise = KasaException(f"Device {self._host} send error: {error_payload}") + except Exception as ex: + raise KasaException("Unable to read response") from ex + raise to_raise + + async def close(self) -> None: + """Close the http client and reset internal state.""" + await self._http_client.close() + + async def reset(self) -> None: + """Reset the transport. + + NOOP for this transport. + """ + + async def send(self, request: str) -> dict: + """Send a message to the device and return a response.""" + try: + return await self._execute_send(request) + except Exception as ex: + await self.reset() + raise _RetryableError( + f"Unable to query the device {self._host}:{self._port}: {ex}" + ) from ex + + async def _get_ssl_context(self) -> ssl.SSLContext: + if not self._ssl_context: + loop = asyncio.get_running_loop() + self._ssl_context = await loop.run_in_executor( + None, self._create_ssl_context + ) + return self._ssl_context + + def _create_ssl_context(self) -> ssl.SSLContext: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.set_ciphers(self.CIPHERS) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context diff --git a/tests/fixtures/iotcam/EC60(US)_4.0_2.3.22.json b/tests/fixtures/iotcam/EC60(US)_4.0_2.3.22.json new file mode 100644 index 000000000..2da0d5f34 --- /dev/null +++ b/tests/fixtures/iotcam/EC60(US)_4.0_2.3.22.json @@ -0,0 +1,86 @@ +{ + "emeter": { + "get_realtime": { + "err_code": -10008, + "err_msg": "Unsupported API call." + } + }, + "smartlife.iot.LAS": { + "get_current_brt": { + "err_code": -10008, + "err_msg": "Unsupported API call." + } + }, + "smartlife.iot.PIR": { + "get_config": { + "err_code": -10008, + "err_msg": "Unsupported API call." + } + }, + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": -10008, + "err_msg": "Unsupported API call." + } + }, + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "err_code": -10008, + "err_msg": "Unsupported API call." + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "err_code": -10008, + "err_msg": "Unsupported API call." + } + }, + "system": { + "get_sysinfo": { + "err_code": 0, + "system": { + "a_type": 2, + "alias": "#MASKED_NAME#", + "bind_status": false, + "c_opt": [ + 0, + 1 + ], + "camera_switch": "on", + "dev_name": "Kasa Spot, 24/7 Recording", + "deviceId": "0000000000000000000000000000000000000000", + "f_list": [ + 1, + 2 + ], + "hwId": "00000000000000000000000000000000", + "hw_ver": "4.0", + "is_cal": 1, + "last_activity_timestamp": 0, + "latitude": 0, + "led_status": "on", + "longitude": 0, + "mac": "74:FE:CE:00:00:00", + "mic_mac": "74FECE000000", + "model": "EC60(US)", + "new_feature": [ + 2, + 3, + 4, + 5, + 7, + 9 + ], + "oemId": "00000000000000000000000000000000", + "resolution": "720P", + "rssi": -28, + "status": "new", + "stream_version": 2, + "sw_ver": "2.3.22 Build 20230731 rel.69808", + "system_time": 1690827820, + "type": "IOT.IPCAMERA", + "updating": false + } + } + } +} diff --git a/tests/test_device.py b/tests/test_device.py index 5cf75a61b..0764acfbf 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -16,6 +16,7 @@ from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module from kasa.iot import ( IotBulb, + IotCamera, IotDevice, IotDimmer, IotLightStrip, @@ -118,6 +119,7 @@ async def test_device_class_repr(device_class_name_obj): IotStrip: DeviceType.Strip, IotWallSwitch: DeviceType.WallSwitch, IotLightStrip: DeviceType.LightStrip, + IotCamera: DeviceType.Camera, SmartChildDevice: DeviceType.Unknown, SmartDevice: DeviceType.Unknown, SmartCamDevice: DeviceType.Camera, diff --git a/tests/transports/test_linkietransport.py b/tests/transports/test_linkietransport.py new file mode 100644 index 000000000..1ac8dba5d --- /dev/null +++ b/tests/transports/test_linkietransport.py @@ -0,0 +1,144 @@ +import base64 +from unittest.mock import ANY + +import aiohttp +import pytest +from yarl import URL + +from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import KasaException +from kasa.httpclient import HttpClient +from kasa.json import dumps as json_dumps +from kasa.transports.linkietransport import LinkieTransportV2 + +KASACAM_REQUEST_PLAINTEXT = '{"smartlife.cam.ipcamera.dateTime":{"get_status":{}}}' +KASACAM_RESPONSE_ENCRYPTED = "0PKG74LnnfKc+dvhw5bCgaycqZOjk7Gdv96syaiKsJLTvtupwKPC7aPGse632KrB48/tiPiX9JzDsNW2lK6fqZCgmKuZoZGh3A==" +KASACAM_RESPONSE_ERROR = '{"smartlife.cam.ipcamera.cloud": {"get_inf": {"err_code": -10008, "err_msg": "Unsupported API call."}}}' +KASA_DEFAULT_CREDENTIALS_HASH = "YWRtaW46MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM=" + + +async def test_working(mocker): + """No errors with an expected request/response.""" + host = "127.0.0.1" + mock_linkie_device = MockLinkieDevice(host) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_linkie_device.post + ) + transport_no_creds = LinkieTransportV2(config=DeviceConfig(host)) + + response = await transport_no_creds.send(KASACAM_REQUEST_PLAINTEXT) + assert response == { + "timezone": "UTC-05:00", + "area": "America/New_York", + "epoch_sec": 1690832800, + } + + +async def test_credentials_hash(mocker): + """Ensure the default credentials are always passed as Basic Auth.""" + # Test without credentials input + + host = "127.0.0.1" + mock_linkie_device = MockLinkieDevice(host) + mock_post = mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_linkie_device.post + ) + transport_no_creds = LinkieTransportV2(config=DeviceConfig(host)) + await transport_no_creds.send(KASACAM_REQUEST_PLAINTEXT) + mock_post.assert_called_once_with( + URL(f"https://{host}:10443/data/LINKIE2.json"), + params=None, + data=ANY, + json=None, + timeout=ANY, + cookies=None, + headers={ + "Authorization": "Basic " + _generate_kascam_basic_auth(), + "Content-Type": "application/x-www-form-urlencoded", + }, + ssl=ANY, + ) + + assert transport_no_creds.credentials_hash == KASA_DEFAULT_CREDENTIALS_HASH + # Test with credentials input + + transport_with_creds = LinkieTransportV2( + config=DeviceConfig(host, credentials=Credentials("Admin", "password")) + ) + mock_post.reset_mock() + + await transport_with_creds.send(KASACAM_REQUEST_PLAINTEXT) + mock_post.assert_called_once_with( + URL(f"https://{host}:10443/data/LINKIE2.json"), + params=None, + data=ANY, + json=None, + timeout=ANY, + cookies=None, + headers={ + "Authorization": "Basic " + _generate_kascam_basic_auth(), + "Content-Type": "application/x-www-form-urlencoded", + }, + ssl=ANY, + ) + + +@pytest.mark.parametrize( + ("return_status", "return_data", "expected"), + [ + (500, KASACAM_RESPONSE_ENCRYPTED, "500"), + (200, "AAAAAAAAAAAAAAAAAAAAAAAA", "Unable to read response"), + (200, KASACAM_RESPONSE_ERROR, "Unsupported API call"), + ], +) +async def test_exceptions(mocker, return_status, return_data, expected): + """Test a variety of possible responses from the device.""" + host = "127.0.0.1" + transport = LinkieTransportV2(config=DeviceConfig(host)) + mock_linkie_device = MockLinkieDevice( + host, status_code=return_status, response=return_data + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_linkie_device.post + ) + + with pytest.raises(KasaException, match=expected): + await transport.send(KASACAM_REQUEST_PLAINTEXT) + + +def _generate_kascam_basic_auth(): + creds = get_default_credentials(DEFAULT_CREDENTIALS["KASACAMERA"]) + creds_combined = f"{creds.username}:{creds.password}" + return base64.b64encode(creds_combined.encode()).decode() + + +class MockLinkieDevice: + """Based on MockSslDevice.""" + + class _mock_response: + def __init__(self, status, request: dict): + self.status = status + self._json = request + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_t, exc_v, exc_tb): + pass + + async def read(self): + if isinstance(self._json, dict): + return json_dumps(self._json).encode() + return self._json + + def __init__(self, host, *, status_code=200, response=KASACAM_RESPONSE_ENCRYPTED): + self.host = host + self.http_client = HttpClient(DeviceConfig(self.host)) + self.status_code = status_code + self.response = response + + async def post( + self, url: URL, *, headers=None, params=None, json=None, data=None, **__ + ): + return self._mock_response(self.status_code, self.response) From fd74b07e2c5a8242d2ea06a4eb90e25ef7f0f3cf Mon Sep 17 00:00:00 2001 From: Happy-Cadaver Date: Mon, 9 Dec 2024 18:24:27 -0500 Subject: [PATCH 739/892] Add C520WS camera fixture (#1352) Adding the C520WS fixture file --------- Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- README.md | 2 +- SUPPORTED.md | 2 + .../smartcam/C520WS(US)_1.0_1.2.8.json | 1026 +++++++++++++++++ 3 files changed, 1029 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smartcam/C520WS(US)_1.0_1.2.8.json diff --git a/README.md b/README.md index 3595dd19e..ad9b43ebe 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Cameras**: C210, TC65 +- **Cameras**: C210, C520WS, TC65 - **Hubs**: H100, H200 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 diff --git a/SUPPORTED.md b/SUPPORTED.md index 034372b0e..e5f01f9f9 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -255,6 +255,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **C210** - Hardware: 2.0 (EU) / Firmware: 1.4.2 - Hardware: 2.0 (EU) / Firmware: 1.4.3 +- **C520WS** + - Hardware: 1.0 (US) / Firmware: 1.2.8 - **TC65** - Hardware: 1.0 / Firmware: 1.3.9 diff --git a/tests/fixtures/smartcam/C520WS(US)_1.0_1.2.8.json b/tests/fixtures/smartcam/C520WS(US)_1.0_1.2.8.json new file mode 100644 index 000000000..072fea80b --- /dev/null +++ b/tests/fixtures/smartcam/C520WS(US)_1.0_1.2.8.json @@ -0,0 +1,1026 @@ +{ + "discovery_result": { + "decrypted_data": { + "connect_ssid": "000 000000 0000000000", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "0", + "last_alarm_type": "", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C520WS", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.2.8 Build 240606 Rel.39146n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "5", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 4 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 3 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "linecrossingDetection", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "patrol", + "version": 1 + }, + { + "name": "nightVisionMode", + "version": 3 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "whiteLamp", + "version": 1 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "panoramicView", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "smartTrack", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "off", + "sampling_rate": "8", + "volume": "81" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-12-02 13:12:15", + "seconds_from_1970": 1733163135 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -47, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c520ws", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C520WS 1.0 IPC", + "device_model": "C520WS", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "US", + "sw_version": "1.2.8 Build 240606 Rel.39146n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "off", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "0", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "50", + "wtl_manual_start_flag": "off" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "off", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision", + "wtl_night_vision", + "md_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp", + "white_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "50", + "wtl_manual_start_flag": "off" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [ + "1", + "2", + "3", + "4" + ], + "name": [ + "Doorbell", + "Packages", + "Street", + "Arm" + ], + "position_pan": [ + "-0.328380", + "0.010401", + "0.010401", + "0.066865" + ], + "position_tilt": [ + "-0.062500", + "0.828125", + "-0.285156", + "0.160156" + ], + "position_zoom": [], + "read_only": [ + "0", + "0", + "0", + "0" + ] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "50", + "wtl_manual_start_flag": "off" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "total_space_accurate": "0B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "0B", + "video_total_space_accurate": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-05:00", + "timing_mode": "manual", + "zone_id": "America/New_York" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1536", + "2048", + "2560" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561" + ], + "minor_stream_support": "0", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2560*1440", + "1920*1080", + "1280*720" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "2560", + "bitrate_type": "vbr", + "default_bitrate": "2560", + "encode_type": "H264", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2560*1440", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "50", + "wtl_manual_start_flag": "off" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "off", + "sampling_rate": "8", + "volume": "81" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "audio_alarm_clock": "1", + "audio_lib": "1", + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "image", + "OSD", + "video" + ], + "custom_area_compensation": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "diagnose": "1", + "diagnose_capability": "1", + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "gb28181": "1", + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "playback_version": "1.1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "tums": "1", + "tums_config": "1", + "tums_msg_push": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "video_message": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "0", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + } +} From 2f87ccd20191cb13aa9971323ec16ad3a69f71a6 Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Tue, 10 Dec 2024 01:14:17 -0500 Subject: [PATCH 740/892] Add KS200 (US) IOT Fixture and P115 (US) Smart Fixture (#1355) --- README.md | 2 +- SUPPORTED.md | 3 + tests/device_fixtures.py | 1087 +++++++++--------- tests/fixtures/iot/KS200(US)_1.0_1.0.8.json | 63 + tests/fixtures/smart/P115(US)_1.0_1.1.3.json | 640 +++++++++++ 5 files changed, 1251 insertions(+), 544 deletions(-) create mode 100644 tests/fixtures/iot/KS200(US)_1.0_1.0.8.json create mode 100644 tests/fixtures/smart/P115(US)_1.0_1.1.3.json diff --git a/README.md b/README.md index ad9b43ebe..90c9ac0f3 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: EP10, EP25[^1], HS100[^2], HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M[^1], KP401 - **Power Strips**: EP40, EP40M[^1], HS107, HS300, KP200, KP303, KP400 -- **Wall Switches**: ES20M, HS200[^2], HS210, HS220[^2], KP405, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1] +- **Wall Switches**: ES20M, HS200[^2], HS210, HS220[^2], KP405, KS200, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1] - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 - **Light Strips**: KL400L5, KL420L5, KL430 - **Hubs**: KH100[^1] diff --git a/SUPPORTED.md b/SUPPORTED.md index e5f01f9f9..ba7726cc3 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -97,6 +97,8 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - **KP405** - Hardware: 1.0 (US) / Firmware: 1.0.5 - Hardware: 1.0 (US) / Firmware: 1.0.6 +- **KS200** + - Hardware: 1.0 (US) / Firmware: 1.0.8 - **KS200M** - Hardware: 1.0 (US) / Firmware: 1.0.10 - Hardware: 1.0 (US) / Firmware: 1.0.11 @@ -192,6 +194,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.2.3 - **P115** - Hardware: 1.0 (EU) / Firmware: 1.2.3 + - Hardware: 1.0 (US) / Firmware: 1.1.3 - **P125M** - Hardware: 1.0 (US) / Firmware: 1.1.0 - **P135** diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index d206b714a..3ab96c18b 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -1,543 +1,544 @@ -from __future__ import annotations - -import os -from collections.abc import AsyncGenerator - -import pytest - -from kasa import ( - Credentials, - Device, - DeviceType, - Discover, -) -from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch -from kasa.smart import SmartDevice -from kasa.smartcam import SmartCamDevice - -from .fakeprotocol_iot import FakeIotProtocol -from .fakeprotocol_smart import FakeSmartProtocol -from .fakeprotocol_smartcam import FakeSmartCamProtocol -from .fixtureinfo import ( - FIXTURE_DATA, - ComponentFilter, - FixtureInfo, - filter_fixtures, - idgenerator, -) - -# Tapo bulbs -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 = {"L510B", "L510E"} -BULBS_SMART = ( - BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) - .union(BULBS_SMART_DIMMABLE) - .union(BULBS_SMART_LIGHT_STRIP) -) - -# Kasa (IOT-prefixed) bulbs -BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL430", "KL420L5"} -BULBS_IOT_VARIABLE_TEMP = { - "LB120", - "LB130", - "KL120", - "KL125", - "KL130", - "KL135", - "KL430", -} -BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} -BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"} -BULBS_IOT = ( - BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR) - .union(BULBS_IOT_DIMMABLE) - .union(BULBS_IOT_LIGHT_STRIP) -) - -BULBS_VARIABLE_TEMP = {*BULBS_SMART_VARIABLE_TEMP, *BULBS_IOT_VARIABLE_TEMP} -BULBS_COLOR = {*BULBS_SMART_COLOR, *BULBS_IOT_COLOR} - - -LIGHT_STRIPS = {*BULBS_SMART_LIGHT_STRIP, *BULBS_IOT_LIGHT_STRIP} -BULBS = { - *BULBS_IOT, - *BULBS_SMART, -} - - -PLUGS_IOT = { - "HS100", - "HS103", - "HS105", - "HS110", - "EP10", - "KP100", - "KP105", - "KP115", - "KP125", - "KP401", -} -# P135 supports dimming, but its not currently support -# by the library -PLUGS_SMART = { - "P100", - "P110", - "P110M", - "P115", - "KP125M", - "EP25", - "P125M", - "TP15", -} -PLUGS = { - *PLUGS_IOT, - *PLUGS_SMART, -} -SWITCHES_IOT = { - "HS200", - "HS210", - "KS200M", -} -SWITCHES_SMART = { - "HS200", - "KS205", - "KS225", - "KS240", - "S500D", - "S505", - "S505D", -} -SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} -STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} -STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M"} -STRIPS = {*STRIPS_IOT, *STRIPS_SMART} - -DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"} -DIMMERS_SMART = {"HS220", "KS225", "S500D", "P135"} -DIMMERS = { - *DIMMERS_IOT, - *DIMMERS_SMART, -} - -HUBS_SMART = {"H100", "KH100"} -SENSORS_SMART = {"T310", "T315", "T300", "T100", "T110", "S200B", "S200D"} -THERMOSTATS_SMART = {"KE100"} - -WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} -WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"} -WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} - -DIMMABLE = {*BULBS, *DIMMERS} - -ALL_DEVICES_IOT = ( - BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT).union(SWITCHES_IOT) -) -ALL_DEVICES_SMART = ( - BULBS_SMART.union(PLUGS_SMART) - .union(STRIPS_SMART) - .union(DIMMERS_SMART) - .union(HUBS_SMART) - .union(SENSORS_SMART) - .union(SWITCHES_SMART) - .union(THERMOSTATS_SMART) -) -ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) - -IP_FIXTURE_CACHE: dict[str, FixtureInfo] = {} - - -def parametrize_combine(parametrized: list[pytest.MarkDecorator]): - """Combine multiple pytest parametrize dev marks into one set of fixtures.""" - fixtures = set() - for param in parametrized: - if param.args[0] != "dev": - raise Exception(f"Supplied mark is not for dev fixture: {param.args[0]}") - fixtures.update(param.args[1]) - return pytest.mark.parametrize( - "dev", - sorted(list(fixtures)), - indirect=True, - ids=idgenerator, - ) - - -def parametrize_subtract(params: pytest.MarkDecorator, subtract: pytest.MarkDecorator): - """Combine multiple pytest parametrize dev marks into one set of fixtures.""" - if params.args[0] != "dev" or subtract.args[0] != "dev": - raise Exception( - f"Supplied mark is not for dev fixture: {params.args[0]} {subtract.args[0]}" - ) - fixtures = [] - for param in params.args[1]: - if param not in subtract.args[1]: - fixtures.append(param) - return pytest.mark.parametrize( - "dev", - sorted(fixtures), - indirect=True, - ids=idgenerator, - ) - - -def parametrize( - desc, - *, - model_filter=None, - protocol_filter=None, - component_filter: str | ComponentFilter | None = None, - data_root_filter=None, - device_type_filter=None, - ids=None, - fixture_name="dev", -): - if ids is None: - ids = idgenerator - return pytest.mark.parametrize( - fixture_name, - filter_fixtures( - desc, - model_filter=model_filter, - protocol_filter=protocol_filter, - component_filter=component_filter, - data_root_filter=data_root_filter, - device_type_filter=device_type_filter, - ), - indirect=True, - ids=ids, - ) - - -has_emeter = parametrize( - "has emeter", model_filter=WITH_EMETER, protocol_filter={"SMART", "IOT"} -) -no_emeter = parametrize( - "no emeter", - model_filter=ALL_DEVICES - WITH_EMETER, - protocol_filter={"SMART", "IOT"}, -) -has_emeter_smart = parametrize( - "has emeter smart", model_filter=WITH_EMETER_SMART, protocol_filter={"SMART"} -) -has_emeter_iot = parametrize( - "has emeter iot", model_filter=WITH_EMETER_IOT, protocol_filter={"IOT"} -) -no_emeter_iot = parametrize( - "no emeter iot", - model_filter=ALL_DEVICES_IOT - WITH_EMETER_IOT, - protocol_filter={"IOT"}, -) - -plug = parametrize("plugs", model_filter=PLUGS, protocol_filter={"IOT", "SMART"}) -plug_iot = parametrize("plugs iot", model_filter=PLUGS, protocol_filter={"IOT"}) -wallswitch = parametrize( - "wall switches", model_filter=SWITCHES, protocol_filter={"IOT", "SMART"} -) -wallswitch_iot = parametrize( - "wall switches iot", model_filter=SWITCHES, protocol_filter={"IOT"} -) -strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"}) -dimmer_iot = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) -lightstrip_iot = parametrize( - "lightstrips", model_filter=LIGHT_STRIPS, protocol_filter={"IOT"} -) - -# bulb types -dimmable_iot = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) -non_dimmable_iot = parametrize( - "non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"} -) -variable_temp = parametrize( - "variable color temp", - model_filter=BULBS_VARIABLE_TEMP, - protocol_filter={"SMART", "IOT"}, -) -non_variable_temp = parametrize( - "non-variable color temp", - model_filter=BULBS - BULBS_VARIABLE_TEMP, - protocol_filter={"SMART", "IOT"}, -) -color_bulb = parametrize( - "color bulbs", model_filter=BULBS_COLOR, protocol_filter={"SMART", "IOT"} -) -non_color_bulb = parametrize( - "non-color bulbs", - model_filter=BULBS - BULBS_COLOR, - protocol_filter={"SMART", "IOT"}, -) - -color_bulb_iot = parametrize( - "color bulbs iot", model_filter=BULBS_IOT_COLOR, protocol_filter={"IOT"} -) -variable_temp_iot = parametrize( - "variable color temp iot", - model_filter=BULBS_IOT_VARIABLE_TEMP, - protocol_filter={"IOT"}, -) -variable_temp_smart = parametrize( - "variable color temp smart", - model_filter=BULBS_SMART_VARIABLE_TEMP, - protocol_filter={"SMART"}, -) - -bulb_smart = parametrize( - "bulb devices smart", - device_type_filter=[DeviceType.Bulb, DeviceType.LightStrip], - protocol_filter={"SMART"}, -) -bulb_iot = parametrize( - "bulb devices iot", model_filter=BULBS_IOT, protocol_filter={"IOT"} -) -bulb = parametrize_combine([bulb_smart, bulb_iot]) - -strip_iot = parametrize( - "strip devices iot", model_filter=STRIPS_IOT, protocol_filter={"IOT"} -) -strip_smart = parametrize( - "strip devices smart", model_filter=STRIPS_SMART, protocol_filter={"SMART"} -) - -plug_smart = parametrize( - "plug devices smart", model_filter=PLUGS_SMART, protocol_filter={"SMART"} -) -switch_smart = parametrize( - "switch devices smart", model_filter=SWITCHES_SMART, protocol_filter={"SMART"} -) -dimmers_smart = parametrize( - "dimmer devices smart", model_filter=DIMMERS_SMART, protocol_filter={"SMART"} -) -hubs_smart = parametrize( - "hubs smart", model_filter=HUBS_SMART, protocol_filter={"SMART"} -) -sensors_smart = parametrize( - "sensors smart", model_filter=SENSORS_SMART, protocol_filter={"SMART.CHILD"} -) -thermostats_smart = parametrize( - "thermostats smart", model_filter=THERMOSTATS_SMART, protocol_filter={"SMART.CHILD"} -) -device_smart = parametrize( - "devices smart", model_filter=ALL_DEVICES_SMART, protocol_filter={"SMART"} -) -device_iot = parametrize( - "devices iot", model_filter=ALL_DEVICES_IOT, protocol_filter={"IOT"} -) -device_smartcam = parametrize("devices smartcam", protocol_filter={"SMARTCAM"}) -camera_smartcam = parametrize( - "camera smartcam", - device_type_filter=[DeviceType.Camera], - protocol_filter={"SMARTCAM"}, -) -hub_smartcam = parametrize( - "hub smartcam", - device_type_filter=[DeviceType.Hub], - protocol_filter={"SMARTCAM"}, -) - - -def check_categories(): - """Check that every fixture file is categorized.""" - categorized_fixtures = set( - dimmer_iot.args[1] - + strip.args[1] - + plug.args[1] - + bulb.args[1] - + wallswitch.args[1] - + lightstrip_iot.args[1] - + bulb_smart.args[1] - + dimmers_smart.args[1] - + hubs_smart.args[1] - + sensors_smart.args[1] - + thermostats_smart.args[1] - + camera_smartcam.args[1] - + hub_smartcam.args[1] - ) - diffs: set[FixtureInfo] = set(FIXTURE_DATA) - set(categorized_fixtures) - if diffs: - print(diffs) - for diff in diffs: - print( - f"No category for file {diff.name} protocol {diff.protocol}, add to the corresponding set (BULBS, PLUGS, ..)" - ) - raise Exception(f"Missing category for {diff.name}") - - -check_categories() - - -def device_for_fixture_name(model, protocol): - if protocol in {"SMART", "SMART.CHILD"}: - return SmartDevice - elif protocol == "SMARTCAM": - return SmartCamDevice - else: - for d in STRIPS_IOT: - if d in model: - return IotStrip - - for d in PLUGS_IOT: - if d in model: - return IotPlug - for d in SWITCHES_IOT: - if d in model: - return IotWallSwitch - - # Light strips are recognized also as bulbs, so this has to go first - for d in BULBS_IOT_LIGHT_STRIP: - if d in model: - return IotLightStrip - - for d in BULBS_IOT: - if d in model: - return IotBulb - - for d in DIMMERS_IOT: - if d in model: - return IotDimmer - - raise Exception("Unable to find type for %s", model) - - -async def _update_and_close(d) -> Device: - await d.update() - await d.protocol.close() - return d - - -async def _discover_update_and_close(ip, username, password) -> Device: - if username and password: - credentials = Credentials(username=username, password=password) - else: - credentials = None - d = await Discover.discover_single(ip, timeout=10, credentials=credentials) - return await _update_and_close(d) - - -async def get_device_for_fixture( - fixture_data: FixtureInfo, *, verbatim=False, update_after_init=True -) -> Device: - # if the wanted file is not an absolute path, prepend the fixtures directory - - d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)( - host="127.0.0.123" - ) - if fixture_data.protocol in {"SMART", "SMART.CHILD"}: - d.protocol = FakeSmartProtocol( - fixture_data.data, fixture_data.name, verbatim=verbatim - ) - elif fixture_data.protocol == "SMARTCAM": - d.protocol = FakeSmartCamProtocol( - fixture_data.data, fixture_data.name, verbatim=verbatim - ) - else: - d.protocol = FakeIotProtocol(fixture_data.data, verbatim=verbatim) - - discovery_data = None - if "discovery_result" in fixture_data.data: - discovery_data = fixture_data.data["discovery_result"] - elif "system" in fixture_data.data: - discovery_data = { - "system": {"get_sysinfo": fixture_data.data["system"]["get_sysinfo"]} - } - - if discovery_data: # Child devices do not have discovery info - d.update_from_discover_info(discovery_data) - - if update_after_init: - await _update_and_close(d) - return d - - -async def get_device_for_fixture_protocol(fixture, protocol): - finfo = FixtureInfo(name=fixture, protocol=protocol, data={}) - for fixture_info in FIXTURE_DATA: - if finfo == fixture_info: - return await get_device_for_fixture(fixture_info) - - -def get_fixture_info(fixture, protocol): - finfo = FixtureInfo(name=fixture, protocol=protocol, data={}) - for fixture_info in FIXTURE_DATA: - if finfo == fixture_info: - return fixture_info - - -def get_nearest_fixture_to_ip(dev): - if isinstance(dev, SmartDevice): - protocol_fixtures = filter_fixtures("", protocol_filter={"SMART"}) - elif isinstance(dev, SmartCamDevice): - protocol_fixtures = filter_fixtures("", protocol_filter={"SMARTCAM"}) - else: - protocol_fixtures = filter_fixtures("", protocol_filter={"IOT"}) - assert protocol_fixtures, "Unknown device type" - - # This will get the best fixture with a match on model region - if model_region_fixtures := filter_fixtures( - "", model_filter={dev._model_region}, fixture_list=protocol_fixtures - ): - return next(iter(model_region_fixtures)) - - # This will get the best fixture based on model starting with the name. - if "(" in dev.model: - model, _, _ = dev.model.partition("(") - else: - model = dev.model - if model_fixtures := filter_fixtures( - "", model_startswith_filter=model, fixture_list=protocol_fixtures - ): - return next(iter(model_fixtures)) - - if device_type_fixtures := filter_fixtures( - "", device_type_filter={dev.device_type}, fixture_list=protocol_fixtures - ): - return next(iter(device_type_fixtures)) - - return next(iter(protocol_fixtures)) - - -@pytest.fixture(params=filter_fixtures("main devices"), ids=idgenerator) -async def dev(request) -> AsyncGenerator[Device, None]: - """Device fixture. - - Provides a device (given --ip) or parametrized fixture for the supported devices. - The initial update is called automatically before returning the device. - """ - fixture_data: FixtureInfo = request.param - dev: Device - - ip = request.config.getoption("--ip") - username = request.config.getoption("--username") or os.environ.get("KASA_USERNAME") - password = request.config.getoption("--password") or os.environ.get("KASA_PASSWORD") - if ip: - fixture = IP_FIXTURE_CACHE.get(ip) - - d = None - if not fixture: - d = await _discover_update_and_close(ip, username, password) - IP_FIXTURE_CACHE[ip] = fixture = get_nearest_fixture_to_ip(d) - assert fixture - if fixture.name != fixture_data.name: - pytest.skip(f"skipping file {fixture_data.name}") - dev = None - else: - dev = d if d else await _discover_update_and_close(ip, username, password) - else: - dev = await get_device_for_fixture(fixture_data) - - yield dev - - if dev: - await dev.disconnect() - - -def get_parent_and_child_modules(device: Device, module_name): - """Return iterator of module if exists on parent and children. - - Useful for testing devices that have components listed on the parent that are only - supported on the children, i.e. ks240. - """ - if module_name in device.modules: - yield device.modules[module_name] - for child in device.children: - if module_name in child.modules: - yield child.modules[module_name] +from __future__ import annotations + +import os +from collections.abc import AsyncGenerator + +import pytest + +from kasa import ( + Credentials, + Device, + DeviceType, + Discover, +) +from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch +from kasa.smart import SmartDevice +from kasa.smartcam import SmartCamDevice + +from .fakeprotocol_iot import FakeIotProtocol +from .fakeprotocol_smart import FakeSmartProtocol +from .fakeprotocol_smartcam import FakeSmartCamProtocol +from .fixtureinfo import ( + FIXTURE_DATA, + ComponentFilter, + FixtureInfo, + filter_fixtures, + idgenerator, +) + +# Tapo bulbs +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 = {"L510B", "L510E"} +BULBS_SMART = ( + BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) + .union(BULBS_SMART_DIMMABLE) + .union(BULBS_SMART_LIGHT_STRIP) +) + +# Kasa (IOT-prefixed) bulbs +BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL430", "KL420L5"} +BULBS_IOT_VARIABLE_TEMP = { + "LB120", + "LB130", + "KL120", + "KL125", + "KL130", + "KL135", + "KL430", +} +BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} +BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"} +BULBS_IOT = ( + BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR) + .union(BULBS_IOT_DIMMABLE) + .union(BULBS_IOT_LIGHT_STRIP) +) + +BULBS_VARIABLE_TEMP = {*BULBS_SMART_VARIABLE_TEMP, *BULBS_IOT_VARIABLE_TEMP} +BULBS_COLOR = {*BULBS_SMART_COLOR, *BULBS_IOT_COLOR} + + +LIGHT_STRIPS = {*BULBS_SMART_LIGHT_STRIP, *BULBS_IOT_LIGHT_STRIP} +BULBS = { + *BULBS_IOT, + *BULBS_SMART, +} + + +PLUGS_IOT = { + "HS100", + "HS103", + "HS105", + "HS110", + "EP10", + "KP100", + "KP105", + "KP115", + "KP125", + "KP401", +} +# P135 supports dimming, but its not currently support +# by the library +PLUGS_SMART = { + "P100", + "P110", + "P110M", + "P115", + "KP125M", + "EP25", + "P125M", + "TP15", +} +PLUGS = { + *PLUGS_IOT, + *PLUGS_SMART, +} +SWITCHES_IOT = { + "HS200", + "HS210", + "KS200", + "KS200M", +} +SWITCHES_SMART = { + "HS200", + "KS205", + "KS225", + "KS240", + "S500D", + "S505", + "S505D", +} +SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} +STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} +STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M"} +STRIPS = {*STRIPS_IOT, *STRIPS_SMART} + +DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"} +DIMMERS_SMART = {"HS220", "KS225", "S500D", "P135"} +DIMMERS = { + *DIMMERS_IOT, + *DIMMERS_SMART, +} + +HUBS_SMART = {"H100", "KH100"} +SENSORS_SMART = {"T310", "T315", "T300", "T100", "T110", "S200B", "S200D"} +THERMOSTATS_SMART = {"KE100"} + +WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} +WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"} +WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} + +DIMMABLE = {*BULBS, *DIMMERS} + +ALL_DEVICES_IOT = ( + BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT).union(SWITCHES_IOT) +) +ALL_DEVICES_SMART = ( + BULBS_SMART.union(PLUGS_SMART) + .union(STRIPS_SMART) + .union(DIMMERS_SMART) + .union(HUBS_SMART) + .union(SENSORS_SMART) + .union(SWITCHES_SMART) + .union(THERMOSTATS_SMART) +) +ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) + +IP_FIXTURE_CACHE: dict[str, FixtureInfo] = {} + + +def parametrize_combine(parametrized: list[pytest.MarkDecorator]): + """Combine multiple pytest parametrize dev marks into one set of fixtures.""" + fixtures = set() + for param in parametrized: + if param.args[0] != "dev": + raise Exception(f"Supplied mark is not for dev fixture: {param.args[0]}") + fixtures.update(param.args[1]) + return pytest.mark.parametrize( + "dev", + sorted(list(fixtures)), + indirect=True, + ids=idgenerator, + ) + + +def parametrize_subtract(params: pytest.MarkDecorator, subtract: pytest.MarkDecorator): + """Combine multiple pytest parametrize dev marks into one set of fixtures.""" + if params.args[0] != "dev" or subtract.args[0] != "dev": + raise Exception( + f"Supplied mark is not for dev fixture: {params.args[0]} {subtract.args[0]}" + ) + fixtures = [] + for param in params.args[1]: + if param not in subtract.args[1]: + fixtures.append(param) + return pytest.mark.parametrize( + "dev", + sorted(fixtures), + indirect=True, + ids=idgenerator, + ) + + +def parametrize( + desc, + *, + model_filter=None, + protocol_filter=None, + component_filter: str | ComponentFilter | None = None, + data_root_filter=None, + device_type_filter=None, + ids=None, + fixture_name="dev", +): + if ids is None: + ids = idgenerator + return pytest.mark.parametrize( + fixture_name, + filter_fixtures( + desc, + model_filter=model_filter, + protocol_filter=protocol_filter, + component_filter=component_filter, + data_root_filter=data_root_filter, + device_type_filter=device_type_filter, + ), + indirect=True, + ids=ids, + ) + + +has_emeter = parametrize( + "has emeter", model_filter=WITH_EMETER, protocol_filter={"SMART", "IOT"} +) +no_emeter = parametrize( + "no emeter", + model_filter=ALL_DEVICES - WITH_EMETER, + protocol_filter={"SMART", "IOT"}, +) +has_emeter_smart = parametrize( + "has emeter smart", model_filter=WITH_EMETER_SMART, protocol_filter={"SMART"} +) +has_emeter_iot = parametrize( + "has emeter iot", model_filter=WITH_EMETER_IOT, protocol_filter={"IOT"} +) +no_emeter_iot = parametrize( + "no emeter iot", + model_filter=ALL_DEVICES_IOT - WITH_EMETER_IOT, + protocol_filter={"IOT"}, +) + +plug = parametrize("plugs", model_filter=PLUGS, protocol_filter={"IOT", "SMART"}) +plug_iot = parametrize("plugs iot", model_filter=PLUGS, protocol_filter={"IOT"}) +wallswitch = parametrize( + "wall switches", model_filter=SWITCHES, protocol_filter={"IOT", "SMART"} +) +wallswitch_iot = parametrize( + "wall switches iot", model_filter=SWITCHES, protocol_filter={"IOT"} +) +strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"}) +dimmer_iot = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) +lightstrip_iot = parametrize( + "lightstrips", model_filter=LIGHT_STRIPS, protocol_filter={"IOT"} +) + +# bulb types +dimmable_iot = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) +non_dimmable_iot = parametrize( + "non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"} +) +variable_temp = parametrize( + "variable color temp", + model_filter=BULBS_VARIABLE_TEMP, + protocol_filter={"SMART", "IOT"}, +) +non_variable_temp = parametrize( + "non-variable color temp", + model_filter=BULBS - BULBS_VARIABLE_TEMP, + protocol_filter={"SMART", "IOT"}, +) +color_bulb = parametrize( + "color bulbs", model_filter=BULBS_COLOR, protocol_filter={"SMART", "IOT"} +) +non_color_bulb = parametrize( + "non-color bulbs", + model_filter=BULBS - BULBS_COLOR, + protocol_filter={"SMART", "IOT"}, +) + +color_bulb_iot = parametrize( + "color bulbs iot", model_filter=BULBS_IOT_COLOR, protocol_filter={"IOT"} +) +variable_temp_iot = parametrize( + "variable color temp iot", + model_filter=BULBS_IOT_VARIABLE_TEMP, + protocol_filter={"IOT"}, +) +variable_temp_smart = parametrize( + "variable color temp smart", + model_filter=BULBS_SMART_VARIABLE_TEMP, + protocol_filter={"SMART"}, +) + +bulb_smart = parametrize( + "bulb devices smart", + device_type_filter=[DeviceType.Bulb, DeviceType.LightStrip], + protocol_filter={"SMART"}, +) +bulb_iot = parametrize( + "bulb devices iot", model_filter=BULBS_IOT, protocol_filter={"IOT"} +) +bulb = parametrize_combine([bulb_smart, bulb_iot]) + +strip_iot = parametrize( + "strip devices iot", model_filter=STRIPS_IOT, protocol_filter={"IOT"} +) +strip_smart = parametrize( + "strip devices smart", model_filter=STRIPS_SMART, protocol_filter={"SMART"} +) + +plug_smart = parametrize( + "plug devices smart", model_filter=PLUGS_SMART, protocol_filter={"SMART"} +) +switch_smart = parametrize( + "switch devices smart", model_filter=SWITCHES_SMART, protocol_filter={"SMART"} +) +dimmers_smart = parametrize( + "dimmer devices smart", model_filter=DIMMERS_SMART, protocol_filter={"SMART"} +) +hubs_smart = parametrize( + "hubs smart", model_filter=HUBS_SMART, protocol_filter={"SMART"} +) +sensors_smart = parametrize( + "sensors smart", model_filter=SENSORS_SMART, protocol_filter={"SMART.CHILD"} +) +thermostats_smart = parametrize( + "thermostats smart", model_filter=THERMOSTATS_SMART, protocol_filter={"SMART.CHILD"} +) +device_smart = parametrize( + "devices smart", model_filter=ALL_DEVICES_SMART, protocol_filter={"SMART"} +) +device_iot = parametrize( + "devices iot", model_filter=ALL_DEVICES_IOT, protocol_filter={"IOT"} +) +device_smartcam = parametrize("devices smartcam", protocol_filter={"SMARTCAM"}) +camera_smartcam = parametrize( + "camera smartcam", + device_type_filter=[DeviceType.Camera], + protocol_filter={"SMARTCAM"}, +) +hub_smartcam = parametrize( + "hub smartcam", + device_type_filter=[DeviceType.Hub], + protocol_filter={"SMARTCAM"}, +) + + +def check_categories(): + """Check that every fixture file is categorized.""" + categorized_fixtures = set( + dimmer_iot.args[1] + + strip.args[1] + + plug.args[1] + + bulb.args[1] + + wallswitch.args[1] + + lightstrip_iot.args[1] + + bulb_smart.args[1] + + dimmers_smart.args[1] + + hubs_smart.args[1] + + sensors_smart.args[1] + + thermostats_smart.args[1] + + camera_smartcam.args[1] + + hub_smartcam.args[1] + ) + diffs: set[FixtureInfo] = set(FIXTURE_DATA) - set(categorized_fixtures) + if diffs: + print(diffs) + for diff in diffs: + print( + f"No category for file {diff.name} protocol {diff.protocol}, add to the corresponding set (BULBS, PLUGS, ..)" + ) + raise Exception(f"Missing category for {diff.name}") + + +check_categories() + + +def device_for_fixture_name(model, protocol): + if protocol in {"SMART", "SMART.CHILD"}: + return SmartDevice + elif protocol == "SMARTCAM": + return SmartCamDevice + else: + for d in STRIPS_IOT: + if d in model: + return IotStrip + + for d in PLUGS_IOT: + if d in model: + return IotPlug + for d in SWITCHES_IOT: + if d in model: + return IotWallSwitch + + # Light strips are recognized also as bulbs, so this has to go first + for d in BULBS_IOT_LIGHT_STRIP: + if d in model: + return IotLightStrip + + for d in BULBS_IOT: + if d in model: + return IotBulb + + for d in DIMMERS_IOT: + if d in model: + return IotDimmer + + raise Exception("Unable to find type for %s", model) + + +async def _update_and_close(d) -> Device: + await d.update() + await d.protocol.close() + return d + + +async def _discover_update_and_close(ip, username, password) -> Device: + if username and password: + credentials = Credentials(username=username, password=password) + else: + credentials = None + d = await Discover.discover_single(ip, timeout=10, credentials=credentials) + return await _update_and_close(d) + + +async def get_device_for_fixture( + fixture_data: FixtureInfo, *, verbatim=False, update_after_init=True +) -> Device: + # if the wanted file is not an absolute path, prepend the fixtures directory + + d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)( + host="127.0.0.123" + ) + if fixture_data.protocol in {"SMART", "SMART.CHILD"}: + d.protocol = FakeSmartProtocol( + fixture_data.data, fixture_data.name, verbatim=verbatim + ) + elif fixture_data.protocol == "SMARTCAM": + d.protocol = FakeSmartCamProtocol( + fixture_data.data, fixture_data.name, verbatim=verbatim + ) + else: + d.protocol = FakeIotProtocol(fixture_data.data, verbatim=verbatim) + + discovery_data = None + if "discovery_result" in fixture_data.data: + discovery_data = fixture_data.data["discovery_result"] + elif "system" in fixture_data.data: + discovery_data = { + "system": {"get_sysinfo": fixture_data.data["system"]["get_sysinfo"]} + } + + if discovery_data: # Child devices do not have discovery info + d.update_from_discover_info(discovery_data) + + if update_after_init: + await _update_and_close(d) + return d + + +async def get_device_for_fixture_protocol(fixture, protocol): + finfo = FixtureInfo(name=fixture, protocol=protocol, data={}) + for fixture_info in FIXTURE_DATA: + if finfo == fixture_info: + return await get_device_for_fixture(fixture_info) + + +def get_fixture_info(fixture, protocol): + finfo = FixtureInfo(name=fixture, protocol=protocol, data={}) + for fixture_info in FIXTURE_DATA: + if finfo == fixture_info: + return fixture_info + + +def get_nearest_fixture_to_ip(dev): + if isinstance(dev, SmartDevice): + protocol_fixtures = filter_fixtures("", protocol_filter={"SMART"}) + elif isinstance(dev, SmartCamDevice): + protocol_fixtures = filter_fixtures("", protocol_filter={"SMARTCAM"}) + else: + protocol_fixtures = filter_fixtures("", protocol_filter={"IOT"}) + assert protocol_fixtures, "Unknown device type" + + # This will get the best fixture with a match on model region + if model_region_fixtures := filter_fixtures( + "", model_filter={dev._model_region}, fixture_list=protocol_fixtures + ): + return next(iter(model_region_fixtures)) + + # This will get the best fixture based on model starting with the name. + if "(" in dev.model: + model, _, _ = dev.model.partition("(") + else: + model = dev.model + if model_fixtures := filter_fixtures( + "", model_startswith_filter=model, fixture_list=protocol_fixtures + ): + return next(iter(model_fixtures)) + + if device_type_fixtures := filter_fixtures( + "", device_type_filter={dev.device_type}, fixture_list=protocol_fixtures + ): + return next(iter(device_type_fixtures)) + + return next(iter(protocol_fixtures)) + + +@pytest.fixture(params=filter_fixtures("main devices"), ids=idgenerator) +async def dev(request) -> AsyncGenerator[Device, None]: + """Device fixture. + + Provides a device (given --ip) or parametrized fixture for the supported devices. + The initial update is called automatically before returning the device. + """ + fixture_data: FixtureInfo = request.param + dev: Device + + ip = request.config.getoption("--ip") + username = request.config.getoption("--username") or os.environ.get("KASA_USERNAME") + password = request.config.getoption("--password") or os.environ.get("KASA_PASSWORD") + if ip: + fixture = IP_FIXTURE_CACHE.get(ip) + + d = None + if not fixture: + d = await _discover_update_and_close(ip, username, password) + IP_FIXTURE_CACHE[ip] = fixture = get_nearest_fixture_to_ip(d) + assert fixture + if fixture.name != fixture_data.name: + pytest.skip(f"skipping file {fixture_data.name}") + dev = None + else: + dev = d if d else await _discover_update_and_close(ip, username, password) + else: + dev = await get_device_for_fixture(fixture_data) + + yield dev + + if dev: + await dev.disconnect() + + +def get_parent_and_child_modules(device: Device, module_name): + """Return iterator of module if exists on parent and children. + + Useful for testing devices that have components listed on the parent that are only + supported on the children, i.e. ks240. + """ + if module_name in device.modules: + yield device.modules[module_name] + for child in device.children: + if module_name in child.modules: + yield child.modules[module_name] diff --git a/tests/fixtures/iot/KS200(US)_1.0_1.0.8.json b/tests/fixtures/iot/KS200(US)_1.0_1.0.8.json new file mode 100644 index 000000000..58971dd0e --- /dev/null +++ b/tests/fixtures/iot/KS200(US)_1.0_1.0.8.json @@ -0,0 +1,63 @@ +{ + "cnCloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "#MASKED_NAME#" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Light Switch", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "A8:42:A1:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS200(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -46, + "status": "new", + "sw_ver": "1.0.8 Build 240424 Rel.101842", + "updating": 0 + } + } +} diff --git a/tests/fixtures/smart/P115(US)_1.0_1.1.3.json b/tests/fixtures/smart/P115(US)_1.0_1.1.3.json new file mode 100644 index 000000000..70035368c --- /dev/null +++ b/tests/fixtures/smart/P115(US)_1.0_1.1.3.json @@ -0,0 +1,640 @@ +{ + "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": "localSmart", + "ver_code": 1 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "current_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P115(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "B0-19-21-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_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", + "charging_status": "normal", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.3 Build 240523 Rel.175054", + "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": "B0-19-21-00-00-00", + "model": "P115", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overcurrent_status": "normal", + "overheat_status": "normal", + "power_protection_status": "normal", + "region": "America/Indiana/Indianapolis", + "rssi": -54, + "signal_level": 2, + "specs": "US", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/Indiana/Indianapolis", + "time_diff": -300, + "timestamp": 1733673137 + }, + "get_device_usage": { + "power_usage": { + "past30": 4376, + "past7": 1879, + "today": 0 + }, + "saved_power": { + "past30": 8618, + "past7": 69, + "today": 0 + }, + "time_usage": { + "past30": 12994, + "past7": 1948, + "today": 0 + } + }, + "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_emeter_data": { + "current_ma": 30, + "energy_wh": 1465, + "power_mw": 0, + "voltage_mv": 122133 + }, + "get_emeter_vgain_igain": { + "igain": 11101, + "vgain": 125071 + }, + "get_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-12-08 10:52:19", + "month_energy": 2532, + "month_runtime": 2630, + "today_energy": 0, + "today_runtime": 0 + }, + "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.1.3 Build 240523 Rel.175054", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": false, + "night_mode": { + "end_time": 476, + "night_mode_type": "sunrise_sunset", + "start_time": 1040, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_max_power": { + "max_power": 1934 + }, + "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": [ + { + "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": 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==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 25, + "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": "P115", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} From ed0481918c707a9cceb7855c56ced73c9010e5fb Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 10 Dec 2024 08:37:57 +0000 Subject: [PATCH 741/892] Fix line endings in device_fixtures.py (#1361) --- tests/device_fixtures.py | 1088 +++++++++++++++++++------------------- 1 file changed, 544 insertions(+), 544 deletions(-) diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 3ab96c18b..b1756572b 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -1,544 +1,544 @@ -from __future__ import annotations - -import os -from collections.abc import AsyncGenerator - -import pytest - -from kasa import ( - Credentials, - Device, - DeviceType, - Discover, -) -from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch -from kasa.smart import SmartDevice -from kasa.smartcam import SmartCamDevice - -from .fakeprotocol_iot import FakeIotProtocol -from .fakeprotocol_smart import FakeSmartProtocol -from .fakeprotocol_smartcam import FakeSmartCamProtocol -from .fixtureinfo import ( - FIXTURE_DATA, - ComponentFilter, - FixtureInfo, - filter_fixtures, - idgenerator, -) - -# Tapo bulbs -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 = {"L510B", "L510E"} -BULBS_SMART = ( - BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) - .union(BULBS_SMART_DIMMABLE) - .union(BULBS_SMART_LIGHT_STRIP) -) - -# Kasa (IOT-prefixed) bulbs -BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL430", "KL420L5"} -BULBS_IOT_VARIABLE_TEMP = { - "LB120", - "LB130", - "KL120", - "KL125", - "KL130", - "KL135", - "KL430", -} -BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} -BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"} -BULBS_IOT = ( - BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR) - .union(BULBS_IOT_DIMMABLE) - .union(BULBS_IOT_LIGHT_STRIP) -) - -BULBS_VARIABLE_TEMP = {*BULBS_SMART_VARIABLE_TEMP, *BULBS_IOT_VARIABLE_TEMP} -BULBS_COLOR = {*BULBS_SMART_COLOR, *BULBS_IOT_COLOR} - - -LIGHT_STRIPS = {*BULBS_SMART_LIGHT_STRIP, *BULBS_IOT_LIGHT_STRIP} -BULBS = { - *BULBS_IOT, - *BULBS_SMART, -} - - -PLUGS_IOT = { - "HS100", - "HS103", - "HS105", - "HS110", - "EP10", - "KP100", - "KP105", - "KP115", - "KP125", - "KP401", -} -# P135 supports dimming, but its not currently support -# by the library -PLUGS_SMART = { - "P100", - "P110", - "P110M", - "P115", - "KP125M", - "EP25", - "P125M", - "TP15", -} -PLUGS = { - *PLUGS_IOT, - *PLUGS_SMART, -} -SWITCHES_IOT = { - "HS200", - "HS210", - "KS200", - "KS200M", -} -SWITCHES_SMART = { - "HS200", - "KS205", - "KS225", - "KS240", - "S500D", - "S505", - "S505D", -} -SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} -STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} -STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M"} -STRIPS = {*STRIPS_IOT, *STRIPS_SMART} - -DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"} -DIMMERS_SMART = {"HS220", "KS225", "S500D", "P135"} -DIMMERS = { - *DIMMERS_IOT, - *DIMMERS_SMART, -} - -HUBS_SMART = {"H100", "KH100"} -SENSORS_SMART = {"T310", "T315", "T300", "T100", "T110", "S200B", "S200D"} -THERMOSTATS_SMART = {"KE100"} - -WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} -WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"} -WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} - -DIMMABLE = {*BULBS, *DIMMERS} - -ALL_DEVICES_IOT = ( - BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT).union(SWITCHES_IOT) -) -ALL_DEVICES_SMART = ( - BULBS_SMART.union(PLUGS_SMART) - .union(STRIPS_SMART) - .union(DIMMERS_SMART) - .union(HUBS_SMART) - .union(SENSORS_SMART) - .union(SWITCHES_SMART) - .union(THERMOSTATS_SMART) -) -ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) - -IP_FIXTURE_CACHE: dict[str, FixtureInfo] = {} - - -def parametrize_combine(parametrized: list[pytest.MarkDecorator]): - """Combine multiple pytest parametrize dev marks into one set of fixtures.""" - fixtures = set() - for param in parametrized: - if param.args[0] != "dev": - raise Exception(f"Supplied mark is not for dev fixture: {param.args[0]}") - fixtures.update(param.args[1]) - return pytest.mark.parametrize( - "dev", - sorted(list(fixtures)), - indirect=True, - ids=idgenerator, - ) - - -def parametrize_subtract(params: pytest.MarkDecorator, subtract: pytest.MarkDecorator): - """Combine multiple pytest parametrize dev marks into one set of fixtures.""" - if params.args[0] != "dev" or subtract.args[0] != "dev": - raise Exception( - f"Supplied mark is not for dev fixture: {params.args[0]} {subtract.args[0]}" - ) - fixtures = [] - for param in params.args[1]: - if param not in subtract.args[1]: - fixtures.append(param) - return pytest.mark.parametrize( - "dev", - sorted(fixtures), - indirect=True, - ids=idgenerator, - ) - - -def parametrize( - desc, - *, - model_filter=None, - protocol_filter=None, - component_filter: str | ComponentFilter | None = None, - data_root_filter=None, - device_type_filter=None, - ids=None, - fixture_name="dev", -): - if ids is None: - ids = idgenerator - return pytest.mark.parametrize( - fixture_name, - filter_fixtures( - desc, - model_filter=model_filter, - protocol_filter=protocol_filter, - component_filter=component_filter, - data_root_filter=data_root_filter, - device_type_filter=device_type_filter, - ), - indirect=True, - ids=ids, - ) - - -has_emeter = parametrize( - "has emeter", model_filter=WITH_EMETER, protocol_filter={"SMART", "IOT"} -) -no_emeter = parametrize( - "no emeter", - model_filter=ALL_DEVICES - WITH_EMETER, - protocol_filter={"SMART", "IOT"}, -) -has_emeter_smart = parametrize( - "has emeter smart", model_filter=WITH_EMETER_SMART, protocol_filter={"SMART"} -) -has_emeter_iot = parametrize( - "has emeter iot", model_filter=WITH_EMETER_IOT, protocol_filter={"IOT"} -) -no_emeter_iot = parametrize( - "no emeter iot", - model_filter=ALL_DEVICES_IOT - WITH_EMETER_IOT, - protocol_filter={"IOT"}, -) - -plug = parametrize("plugs", model_filter=PLUGS, protocol_filter={"IOT", "SMART"}) -plug_iot = parametrize("plugs iot", model_filter=PLUGS, protocol_filter={"IOT"}) -wallswitch = parametrize( - "wall switches", model_filter=SWITCHES, protocol_filter={"IOT", "SMART"} -) -wallswitch_iot = parametrize( - "wall switches iot", model_filter=SWITCHES, protocol_filter={"IOT"} -) -strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"}) -dimmer_iot = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) -lightstrip_iot = parametrize( - "lightstrips", model_filter=LIGHT_STRIPS, protocol_filter={"IOT"} -) - -# bulb types -dimmable_iot = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) -non_dimmable_iot = parametrize( - "non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"} -) -variable_temp = parametrize( - "variable color temp", - model_filter=BULBS_VARIABLE_TEMP, - protocol_filter={"SMART", "IOT"}, -) -non_variable_temp = parametrize( - "non-variable color temp", - model_filter=BULBS - BULBS_VARIABLE_TEMP, - protocol_filter={"SMART", "IOT"}, -) -color_bulb = parametrize( - "color bulbs", model_filter=BULBS_COLOR, protocol_filter={"SMART", "IOT"} -) -non_color_bulb = parametrize( - "non-color bulbs", - model_filter=BULBS - BULBS_COLOR, - protocol_filter={"SMART", "IOT"}, -) - -color_bulb_iot = parametrize( - "color bulbs iot", model_filter=BULBS_IOT_COLOR, protocol_filter={"IOT"} -) -variable_temp_iot = parametrize( - "variable color temp iot", - model_filter=BULBS_IOT_VARIABLE_TEMP, - protocol_filter={"IOT"}, -) -variable_temp_smart = parametrize( - "variable color temp smart", - model_filter=BULBS_SMART_VARIABLE_TEMP, - protocol_filter={"SMART"}, -) - -bulb_smart = parametrize( - "bulb devices smart", - device_type_filter=[DeviceType.Bulb, DeviceType.LightStrip], - protocol_filter={"SMART"}, -) -bulb_iot = parametrize( - "bulb devices iot", model_filter=BULBS_IOT, protocol_filter={"IOT"} -) -bulb = parametrize_combine([bulb_smart, bulb_iot]) - -strip_iot = parametrize( - "strip devices iot", model_filter=STRIPS_IOT, protocol_filter={"IOT"} -) -strip_smart = parametrize( - "strip devices smart", model_filter=STRIPS_SMART, protocol_filter={"SMART"} -) - -plug_smart = parametrize( - "plug devices smart", model_filter=PLUGS_SMART, protocol_filter={"SMART"} -) -switch_smart = parametrize( - "switch devices smart", model_filter=SWITCHES_SMART, protocol_filter={"SMART"} -) -dimmers_smart = parametrize( - "dimmer devices smart", model_filter=DIMMERS_SMART, protocol_filter={"SMART"} -) -hubs_smart = parametrize( - "hubs smart", model_filter=HUBS_SMART, protocol_filter={"SMART"} -) -sensors_smart = parametrize( - "sensors smart", model_filter=SENSORS_SMART, protocol_filter={"SMART.CHILD"} -) -thermostats_smart = parametrize( - "thermostats smart", model_filter=THERMOSTATS_SMART, protocol_filter={"SMART.CHILD"} -) -device_smart = parametrize( - "devices smart", model_filter=ALL_DEVICES_SMART, protocol_filter={"SMART"} -) -device_iot = parametrize( - "devices iot", model_filter=ALL_DEVICES_IOT, protocol_filter={"IOT"} -) -device_smartcam = parametrize("devices smartcam", protocol_filter={"SMARTCAM"}) -camera_smartcam = parametrize( - "camera smartcam", - device_type_filter=[DeviceType.Camera], - protocol_filter={"SMARTCAM"}, -) -hub_smartcam = parametrize( - "hub smartcam", - device_type_filter=[DeviceType.Hub], - protocol_filter={"SMARTCAM"}, -) - - -def check_categories(): - """Check that every fixture file is categorized.""" - categorized_fixtures = set( - dimmer_iot.args[1] - + strip.args[1] - + plug.args[1] - + bulb.args[1] - + wallswitch.args[1] - + lightstrip_iot.args[1] - + bulb_smart.args[1] - + dimmers_smart.args[1] - + hubs_smart.args[1] - + sensors_smart.args[1] - + thermostats_smart.args[1] - + camera_smartcam.args[1] - + hub_smartcam.args[1] - ) - diffs: set[FixtureInfo] = set(FIXTURE_DATA) - set(categorized_fixtures) - if diffs: - print(diffs) - for diff in diffs: - print( - f"No category for file {diff.name} protocol {diff.protocol}, add to the corresponding set (BULBS, PLUGS, ..)" - ) - raise Exception(f"Missing category for {diff.name}") - - -check_categories() - - -def device_for_fixture_name(model, protocol): - if protocol in {"SMART", "SMART.CHILD"}: - return SmartDevice - elif protocol == "SMARTCAM": - return SmartCamDevice - else: - for d in STRIPS_IOT: - if d in model: - return IotStrip - - for d in PLUGS_IOT: - if d in model: - return IotPlug - for d in SWITCHES_IOT: - if d in model: - return IotWallSwitch - - # Light strips are recognized also as bulbs, so this has to go first - for d in BULBS_IOT_LIGHT_STRIP: - if d in model: - return IotLightStrip - - for d in BULBS_IOT: - if d in model: - return IotBulb - - for d in DIMMERS_IOT: - if d in model: - return IotDimmer - - raise Exception("Unable to find type for %s", model) - - -async def _update_and_close(d) -> Device: - await d.update() - await d.protocol.close() - return d - - -async def _discover_update_and_close(ip, username, password) -> Device: - if username and password: - credentials = Credentials(username=username, password=password) - else: - credentials = None - d = await Discover.discover_single(ip, timeout=10, credentials=credentials) - return await _update_and_close(d) - - -async def get_device_for_fixture( - fixture_data: FixtureInfo, *, verbatim=False, update_after_init=True -) -> Device: - # if the wanted file is not an absolute path, prepend the fixtures directory - - d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)( - host="127.0.0.123" - ) - if fixture_data.protocol in {"SMART", "SMART.CHILD"}: - d.protocol = FakeSmartProtocol( - fixture_data.data, fixture_data.name, verbatim=verbatim - ) - elif fixture_data.protocol == "SMARTCAM": - d.protocol = FakeSmartCamProtocol( - fixture_data.data, fixture_data.name, verbatim=verbatim - ) - else: - d.protocol = FakeIotProtocol(fixture_data.data, verbatim=verbatim) - - discovery_data = None - if "discovery_result" in fixture_data.data: - discovery_data = fixture_data.data["discovery_result"] - elif "system" in fixture_data.data: - discovery_data = { - "system": {"get_sysinfo": fixture_data.data["system"]["get_sysinfo"]} - } - - if discovery_data: # Child devices do not have discovery info - d.update_from_discover_info(discovery_data) - - if update_after_init: - await _update_and_close(d) - return d - - -async def get_device_for_fixture_protocol(fixture, protocol): - finfo = FixtureInfo(name=fixture, protocol=protocol, data={}) - for fixture_info in FIXTURE_DATA: - if finfo == fixture_info: - return await get_device_for_fixture(fixture_info) - - -def get_fixture_info(fixture, protocol): - finfo = FixtureInfo(name=fixture, protocol=protocol, data={}) - for fixture_info in FIXTURE_DATA: - if finfo == fixture_info: - return fixture_info - - -def get_nearest_fixture_to_ip(dev): - if isinstance(dev, SmartDevice): - protocol_fixtures = filter_fixtures("", protocol_filter={"SMART"}) - elif isinstance(dev, SmartCamDevice): - protocol_fixtures = filter_fixtures("", protocol_filter={"SMARTCAM"}) - else: - protocol_fixtures = filter_fixtures("", protocol_filter={"IOT"}) - assert protocol_fixtures, "Unknown device type" - - # This will get the best fixture with a match on model region - if model_region_fixtures := filter_fixtures( - "", model_filter={dev._model_region}, fixture_list=protocol_fixtures - ): - return next(iter(model_region_fixtures)) - - # This will get the best fixture based on model starting with the name. - if "(" in dev.model: - model, _, _ = dev.model.partition("(") - else: - model = dev.model - if model_fixtures := filter_fixtures( - "", model_startswith_filter=model, fixture_list=protocol_fixtures - ): - return next(iter(model_fixtures)) - - if device_type_fixtures := filter_fixtures( - "", device_type_filter={dev.device_type}, fixture_list=protocol_fixtures - ): - return next(iter(device_type_fixtures)) - - return next(iter(protocol_fixtures)) - - -@pytest.fixture(params=filter_fixtures("main devices"), ids=idgenerator) -async def dev(request) -> AsyncGenerator[Device, None]: - """Device fixture. - - Provides a device (given --ip) or parametrized fixture for the supported devices. - The initial update is called automatically before returning the device. - """ - fixture_data: FixtureInfo = request.param - dev: Device - - ip = request.config.getoption("--ip") - username = request.config.getoption("--username") or os.environ.get("KASA_USERNAME") - password = request.config.getoption("--password") or os.environ.get("KASA_PASSWORD") - if ip: - fixture = IP_FIXTURE_CACHE.get(ip) - - d = None - if not fixture: - d = await _discover_update_and_close(ip, username, password) - IP_FIXTURE_CACHE[ip] = fixture = get_nearest_fixture_to_ip(d) - assert fixture - if fixture.name != fixture_data.name: - pytest.skip(f"skipping file {fixture_data.name}") - dev = None - else: - dev = d if d else await _discover_update_and_close(ip, username, password) - else: - dev = await get_device_for_fixture(fixture_data) - - yield dev - - if dev: - await dev.disconnect() - - -def get_parent_and_child_modules(device: Device, module_name): - """Return iterator of module if exists on parent and children. - - Useful for testing devices that have components listed on the parent that are only - supported on the children, i.e. ks240. - """ - if module_name in device.modules: - yield device.modules[module_name] - for child in device.children: - if module_name in child.modules: - yield child.modules[module_name] +from __future__ import annotations + +import os +from collections.abc import AsyncGenerator + +import pytest + +from kasa import ( + Credentials, + Device, + DeviceType, + Discover, +) +from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch +from kasa.smart import SmartDevice +from kasa.smartcam import SmartCamDevice + +from .fakeprotocol_iot import FakeIotProtocol +from .fakeprotocol_smart import FakeSmartProtocol +from .fakeprotocol_smartcam import FakeSmartCamProtocol +from .fixtureinfo import ( + FIXTURE_DATA, + ComponentFilter, + FixtureInfo, + filter_fixtures, + idgenerator, +) + +# Tapo bulbs +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 = {"L510B", "L510E"} +BULBS_SMART = ( + BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) + .union(BULBS_SMART_DIMMABLE) + .union(BULBS_SMART_LIGHT_STRIP) +) + +# Kasa (IOT-prefixed) bulbs +BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL430", "KL420L5"} +BULBS_IOT_VARIABLE_TEMP = { + "LB120", + "LB130", + "KL120", + "KL125", + "KL130", + "KL135", + "KL430", +} +BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} +BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"} +BULBS_IOT = ( + BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR) + .union(BULBS_IOT_DIMMABLE) + .union(BULBS_IOT_LIGHT_STRIP) +) + +BULBS_VARIABLE_TEMP = {*BULBS_SMART_VARIABLE_TEMP, *BULBS_IOT_VARIABLE_TEMP} +BULBS_COLOR = {*BULBS_SMART_COLOR, *BULBS_IOT_COLOR} + + +LIGHT_STRIPS = {*BULBS_SMART_LIGHT_STRIP, *BULBS_IOT_LIGHT_STRIP} +BULBS = { + *BULBS_IOT, + *BULBS_SMART, +} + + +PLUGS_IOT = { + "HS100", + "HS103", + "HS105", + "HS110", + "EP10", + "KP100", + "KP105", + "KP115", + "KP125", + "KP401", +} +# P135 supports dimming, but its not currently support +# by the library +PLUGS_SMART = { + "P100", + "P110", + "P110M", + "P115", + "KP125M", + "EP25", + "P125M", + "TP15", +} +PLUGS = { + *PLUGS_IOT, + *PLUGS_SMART, +} +SWITCHES_IOT = { + "HS200", + "HS210", + "KS200", + "KS200M", +} +SWITCHES_SMART = { + "HS200", + "KS205", + "KS225", + "KS240", + "S500D", + "S505", + "S505D", +} +SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} +STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} +STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M"} +STRIPS = {*STRIPS_IOT, *STRIPS_SMART} + +DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"} +DIMMERS_SMART = {"HS220", "KS225", "S500D", "P135"} +DIMMERS = { + *DIMMERS_IOT, + *DIMMERS_SMART, +} + +HUBS_SMART = {"H100", "KH100"} +SENSORS_SMART = {"T310", "T315", "T300", "T100", "T110", "S200B", "S200D"} +THERMOSTATS_SMART = {"KE100"} + +WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} +WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"} +WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} + +DIMMABLE = {*BULBS, *DIMMERS} + +ALL_DEVICES_IOT = ( + BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT).union(SWITCHES_IOT) +) +ALL_DEVICES_SMART = ( + BULBS_SMART.union(PLUGS_SMART) + .union(STRIPS_SMART) + .union(DIMMERS_SMART) + .union(HUBS_SMART) + .union(SENSORS_SMART) + .union(SWITCHES_SMART) + .union(THERMOSTATS_SMART) +) +ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) + +IP_FIXTURE_CACHE: dict[str, FixtureInfo] = {} + + +def parametrize_combine(parametrized: list[pytest.MarkDecorator]): + """Combine multiple pytest parametrize dev marks into one set of fixtures.""" + fixtures = set() + for param in parametrized: + if param.args[0] != "dev": + raise Exception(f"Supplied mark is not for dev fixture: {param.args[0]}") + fixtures.update(param.args[1]) + return pytest.mark.parametrize( + "dev", + sorted(list(fixtures)), + indirect=True, + ids=idgenerator, + ) + + +def parametrize_subtract(params: pytest.MarkDecorator, subtract: pytest.MarkDecorator): + """Combine multiple pytest parametrize dev marks into one set of fixtures.""" + if params.args[0] != "dev" or subtract.args[0] != "dev": + raise Exception( + f"Supplied mark is not for dev fixture: {params.args[0]} {subtract.args[0]}" + ) + fixtures = [] + for param in params.args[1]: + if param not in subtract.args[1]: + fixtures.append(param) + return pytest.mark.parametrize( + "dev", + sorted(fixtures), + indirect=True, + ids=idgenerator, + ) + + +def parametrize( + desc, + *, + model_filter=None, + protocol_filter=None, + component_filter: str | ComponentFilter | None = None, + data_root_filter=None, + device_type_filter=None, + ids=None, + fixture_name="dev", +): + if ids is None: + ids = idgenerator + return pytest.mark.parametrize( + fixture_name, + filter_fixtures( + desc, + model_filter=model_filter, + protocol_filter=protocol_filter, + component_filter=component_filter, + data_root_filter=data_root_filter, + device_type_filter=device_type_filter, + ), + indirect=True, + ids=ids, + ) + + +has_emeter = parametrize( + "has emeter", model_filter=WITH_EMETER, protocol_filter={"SMART", "IOT"} +) +no_emeter = parametrize( + "no emeter", + model_filter=ALL_DEVICES - WITH_EMETER, + protocol_filter={"SMART", "IOT"}, +) +has_emeter_smart = parametrize( + "has emeter smart", model_filter=WITH_EMETER_SMART, protocol_filter={"SMART"} +) +has_emeter_iot = parametrize( + "has emeter iot", model_filter=WITH_EMETER_IOT, protocol_filter={"IOT"} +) +no_emeter_iot = parametrize( + "no emeter iot", + model_filter=ALL_DEVICES_IOT - WITH_EMETER_IOT, + protocol_filter={"IOT"}, +) + +plug = parametrize("plugs", model_filter=PLUGS, protocol_filter={"IOT", "SMART"}) +plug_iot = parametrize("plugs iot", model_filter=PLUGS, protocol_filter={"IOT"}) +wallswitch = parametrize( + "wall switches", model_filter=SWITCHES, protocol_filter={"IOT", "SMART"} +) +wallswitch_iot = parametrize( + "wall switches iot", model_filter=SWITCHES, protocol_filter={"IOT"} +) +strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"}) +dimmer_iot = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) +lightstrip_iot = parametrize( + "lightstrips", model_filter=LIGHT_STRIPS, protocol_filter={"IOT"} +) + +# bulb types +dimmable_iot = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) +non_dimmable_iot = parametrize( + "non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"} +) +variable_temp = parametrize( + "variable color temp", + model_filter=BULBS_VARIABLE_TEMP, + protocol_filter={"SMART", "IOT"}, +) +non_variable_temp = parametrize( + "non-variable color temp", + model_filter=BULBS - BULBS_VARIABLE_TEMP, + protocol_filter={"SMART", "IOT"}, +) +color_bulb = parametrize( + "color bulbs", model_filter=BULBS_COLOR, protocol_filter={"SMART", "IOT"} +) +non_color_bulb = parametrize( + "non-color bulbs", + model_filter=BULBS - BULBS_COLOR, + protocol_filter={"SMART", "IOT"}, +) + +color_bulb_iot = parametrize( + "color bulbs iot", model_filter=BULBS_IOT_COLOR, protocol_filter={"IOT"} +) +variable_temp_iot = parametrize( + "variable color temp iot", + model_filter=BULBS_IOT_VARIABLE_TEMP, + protocol_filter={"IOT"}, +) +variable_temp_smart = parametrize( + "variable color temp smart", + model_filter=BULBS_SMART_VARIABLE_TEMP, + protocol_filter={"SMART"}, +) + +bulb_smart = parametrize( + "bulb devices smart", + device_type_filter=[DeviceType.Bulb, DeviceType.LightStrip], + protocol_filter={"SMART"}, +) +bulb_iot = parametrize( + "bulb devices iot", model_filter=BULBS_IOT, protocol_filter={"IOT"} +) +bulb = parametrize_combine([bulb_smart, bulb_iot]) + +strip_iot = parametrize( + "strip devices iot", model_filter=STRIPS_IOT, protocol_filter={"IOT"} +) +strip_smart = parametrize( + "strip devices smart", model_filter=STRIPS_SMART, protocol_filter={"SMART"} +) + +plug_smart = parametrize( + "plug devices smart", model_filter=PLUGS_SMART, protocol_filter={"SMART"} +) +switch_smart = parametrize( + "switch devices smart", model_filter=SWITCHES_SMART, protocol_filter={"SMART"} +) +dimmers_smart = parametrize( + "dimmer devices smart", model_filter=DIMMERS_SMART, protocol_filter={"SMART"} +) +hubs_smart = parametrize( + "hubs smart", model_filter=HUBS_SMART, protocol_filter={"SMART"} +) +sensors_smart = parametrize( + "sensors smart", model_filter=SENSORS_SMART, protocol_filter={"SMART.CHILD"} +) +thermostats_smart = parametrize( + "thermostats smart", model_filter=THERMOSTATS_SMART, protocol_filter={"SMART.CHILD"} +) +device_smart = parametrize( + "devices smart", model_filter=ALL_DEVICES_SMART, protocol_filter={"SMART"} +) +device_iot = parametrize( + "devices iot", model_filter=ALL_DEVICES_IOT, protocol_filter={"IOT"} +) +device_smartcam = parametrize("devices smartcam", protocol_filter={"SMARTCAM"}) +camera_smartcam = parametrize( + "camera smartcam", + device_type_filter=[DeviceType.Camera], + protocol_filter={"SMARTCAM"}, +) +hub_smartcam = parametrize( + "hub smartcam", + device_type_filter=[DeviceType.Hub], + protocol_filter={"SMARTCAM"}, +) + + +def check_categories(): + """Check that every fixture file is categorized.""" + categorized_fixtures = set( + dimmer_iot.args[1] + + strip.args[1] + + plug.args[1] + + bulb.args[1] + + wallswitch.args[1] + + lightstrip_iot.args[1] + + bulb_smart.args[1] + + dimmers_smart.args[1] + + hubs_smart.args[1] + + sensors_smart.args[1] + + thermostats_smart.args[1] + + camera_smartcam.args[1] + + hub_smartcam.args[1] + ) + diffs: set[FixtureInfo] = set(FIXTURE_DATA) - set(categorized_fixtures) + if diffs: + print(diffs) + for diff in diffs: + print( + f"No category for file {diff.name} protocol {diff.protocol}, add to the corresponding set (BULBS, PLUGS, ..)" + ) + raise Exception(f"Missing category for {diff.name}") + + +check_categories() + + +def device_for_fixture_name(model, protocol): + if protocol in {"SMART", "SMART.CHILD"}: + return SmartDevice + elif protocol == "SMARTCAM": + return SmartCamDevice + else: + for d in STRIPS_IOT: + if d in model: + return IotStrip + + for d in PLUGS_IOT: + if d in model: + return IotPlug + for d in SWITCHES_IOT: + if d in model: + return IotWallSwitch + + # Light strips are recognized also as bulbs, so this has to go first + for d in BULBS_IOT_LIGHT_STRIP: + if d in model: + return IotLightStrip + + for d in BULBS_IOT: + if d in model: + return IotBulb + + for d in DIMMERS_IOT: + if d in model: + return IotDimmer + + raise Exception("Unable to find type for %s", model) + + +async def _update_and_close(d) -> Device: + await d.update() + await d.protocol.close() + return d + + +async def _discover_update_and_close(ip, username, password) -> Device: + if username and password: + credentials = Credentials(username=username, password=password) + else: + credentials = None + d = await Discover.discover_single(ip, timeout=10, credentials=credentials) + return await _update_and_close(d) + + +async def get_device_for_fixture( + fixture_data: FixtureInfo, *, verbatim=False, update_after_init=True +) -> Device: + # if the wanted file is not an absolute path, prepend the fixtures directory + + d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)( + host="127.0.0.123" + ) + if fixture_data.protocol in {"SMART", "SMART.CHILD"}: + d.protocol = FakeSmartProtocol( + fixture_data.data, fixture_data.name, verbatim=verbatim + ) + elif fixture_data.protocol == "SMARTCAM": + d.protocol = FakeSmartCamProtocol( + fixture_data.data, fixture_data.name, verbatim=verbatim + ) + else: + d.protocol = FakeIotProtocol(fixture_data.data, verbatim=verbatim) + + discovery_data = None + if "discovery_result" in fixture_data.data: + discovery_data = fixture_data.data["discovery_result"] + elif "system" in fixture_data.data: + discovery_data = { + "system": {"get_sysinfo": fixture_data.data["system"]["get_sysinfo"]} + } + + if discovery_data: # Child devices do not have discovery info + d.update_from_discover_info(discovery_data) + + if update_after_init: + await _update_and_close(d) + return d + + +async def get_device_for_fixture_protocol(fixture, protocol): + finfo = FixtureInfo(name=fixture, protocol=protocol, data={}) + for fixture_info in FIXTURE_DATA: + if finfo == fixture_info: + return await get_device_for_fixture(fixture_info) + + +def get_fixture_info(fixture, protocol): + finfo = FixtureInfo(name=fixture, protocol=protocol, data={}) + for fixture_info in FIXTURE_DATA: + if finfo == fixture_info: + return fixture_info + + +def get_nearest_fixture_to_ip(dev): + if isinstance(dev, SmartDevice): + protocol_fixtures = filter_fixtures("", protocol_filter={"SMART"}) + elif isinstance(dev, SmartCamDevice): + protocol_fixtures = filter_fixtures("", protocol_filter={"SMARTCAM"}) + else: + protocol_fixtures = filter_fixtures("", protocol_filter={"IOT"}) + assert protocol_fixtures, "Unknown device type" + + # This will get the best fixture with a match on model region + if model_region_fixtures := filter_fixtures( + "", model_filter={dev._model_region}, fixture_list=protocol_fixtures + ): + return next(iter(model_region_fixtures)) + + # This will get the best fixture based on model starting with the name. + if "(" in dev.model: + model, _, _ = dev.model.partition("(") + else: + model = dev.model + if model_fixtures := filter_fixtures( + "", model_startswith_filter=model, fixture_list=protocol_fixtures + ): + return next(iter(model_fixtures)) + + if device_type_fixtures := filter_fixtures( + "", device_type_filter={dev.device_type}, fixture_list=protocol_fixtures + ): + return next(iter(device_type_fixtures)) + + return next(iter(protocol_fixtures)) + + +@pytest.fixture(params=filter_fixtures("main devices"), ids=idgenerator) +async def dev(request) -> AsyncGenerator[Device, None]: + """Device fixture. + + Provides a device (given --ip) or parametrized fixture for the supported devices. + The initial update is called automatically before returning the device. + """ + fixture_data: FixtureInfo = request.param + dev: Device + + ip = request.config.getoption("--ip") + username = request.config.getoption("--username") or os.environ.get("KASA_USERNAME") + password = request.config.getoption("--password") or os.environ.get("KASA_PASSWORD") + if ip: + fixture = IP_FIXTURE_CACHE.get(ip) + + d = None + if not fixture: + d = await _discover_update_and_close(ip, username, password) + IP_FIXTURE_CACHE[ip] = fixture = get_nearest_fixture_to_ip(d) + assert fixture + if fixture.name != fixture_data.name: + pytest.skip(f"skipping file {fixture_data.name}") + dev = None + else: + dev = d if d else await _discover_update_and_close(ip, username, password) + else: + dev = await get_device_for_fixture(fixture_data) + + yield dev + + if dev: + await dev.disconnect() + + +def get_parent_and_child_modules(device: Device, module_name): + """Return iterator of module if exists on parent and children. + + Useful for testing devices that have components listed on the parent that are only + supported on the children, i.e. ks240. + """ + if module_name in device.modules: + yield device.modules[module_name] + for child in device.children: + if module_name in child.modules: + yield child.modules[module_name] From 464683e09baa5302b7a43df533dd090b1aeaa792 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 10 Dec 2024 21:23:04 +0000 Subject: [PATCH 742/892] Tweak RELEASING.md instructions for patch releases (#1347) --- RELEASING.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/RELEASING.md b/RELEASING.md index 032aeb0c5..e3527ceaf 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -283,9 +283,12 @@ git rebase upstream/master git checkout -b janitor/merge_patch git fetch upstream patch git merge upstream/patch --no-commit +# If there are any merge conflicts run the following command which will simply make master win +# Do not run it if there are no conflicts as it will end up checking out upstream/master git diff --name-only --diff-filter=U | xargs git checkout upstream/master +# Check the diff is as expected git diff --staged -# The only diff should be the version in pyproject.toml and CHANGELOG.md +# The only diff should be the version in pyproject.toml and uv.lock, and CHANGELOG.md # unless a change made on patch that was not part of a cherry-pick commit # If there are any other unexpected diffs `git checkout upstream/master [thefilename]` git commit -m "Merge patch into local master" -S From bf8f0adabe1ba1711bf76e9da18efa1f0554cf57 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 10 Dec 2024 22:42:14 +0000 Subject: [PATCH 743/892] Return raw discovery result in cli discover raw (#1342) Add `on_discovered_raw` callback to Discover and adds a cli command `discover raw` which returns the raw json before serializing to a `DiscoveryResult` and attempting to create a device class. --- kasa/cli/discover.py | 54 +++++++++++++++++++++++++++--- kasa/discover.py | 79 ++++++++++++++++++++++++++++++++++++-------- kasa/json.py | 14 +++++--- tests/test_cli.py | 34 ++++++++++++++++++- 4 files changed, 158 insertions(+), 23 deletions(-) diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index f89670669..5e676a1dc 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -14,9 +14,17 @@ Discover, UnsupportedDeviceError, ) -from kasa.discover import ConnectAttempt, DiscoveryResult +from kasa.discover import ( + NEW_DISCOVERY_REDACTORS, + ConnectAttempt, + DiscoveredRaw, + DiscoveryResult, +) from kasa.iot.iotdevice import _extract_sys_info +from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS +from kasa.protocols.protocol import redact_data +from ..json import dumps as json_dumps from .common import echo, error @@ -64,7 +72,9 @@ async def print_discovered(dev: Device) -> None: await ctx.parent.invoke(state) echo() - discovered = await _discover(ctx, print_discovered, print_unsupported) + discovered = await _discover( + ctx, print_discovered=print_discovered, print_unsupported=print_unsupported + ) if ctx.parent.parent.params["host"]: return discovered @@ -77,6 +87,33 @@ async def print_discovered(dev: Device) -> None: return discovered +@discover.command() +@click.option( + "--redact/--no-redact", + default=False, + is_flag=True, + type=bool, + help="Set flag to redact sensitive data from raw output.", +) +@click.pass_context +async def raw(ctx, redact: bool): + """Return raw discovery data returned from devices.""" + + def print_raw(discovered: DiscoveredRaw): + if redact: + redactors = ( + NEW_DISCOVERY_REDACTORS + if discovered["meta"]["port"] == Discover.DISCOVERY_PORT_2 + else IOT_REDACTORS + ) + discovered["discovery_response"] = redact_data( + discovered["discovery_response"], redactors + ) + echo(json_dumps(discovered, indent=True)) + + return await _discover(ctx, print_raw=print_raw, do_echo=False) + + @discover.command() @click.pass_context async def list(ctx): @@ -102,10 +139,17 @@ async def print_unsupported(unsupported_exception: UnsupportedDeviceError): echo(f"{host:<15} UNSUPPORTED DEVICE") echo(f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}") - return await _discover(ctx, print_discovered, print_unsupported, do_echo=False) + return await _discover( + ctx, + print_discovered=print_discovered, + print_unsupported=print_unsupported, + do_echo=False, + ) -async def _discover(ctx, print_discovered, print_unsupported, *, do_echo=True): +async def _discover( + ctx, *, print_discovered=None, print_unsupported=None, print_raw=None, do_echo=True +): params = ctx.parent.parent.params target = params["target"] username = params["username"] @@ -126,6 +170,7 @@ async def _discover(ctx, print_discovered, print_unsupported, *, do_echo=True): timeout=timeout, discovery_timeout=discovery_timeout, on_unsupported=print_unsupported, + on_discovered_raw=print_raw, ) if do_echo: echo(f"Discovering devices on {target} for {discovery_timeout} seconds") @@ -137,6 +182,7 @@ async def _discover(ctx, print_discovered, print_unsupported, *, do_echo=True): port=port, timeout=timeout, credentials=credentials, + on_discovered_raw=print_raw, ) for device in discovered_devices.values(): diff --git a/kasa/discover.py b/kasa/discover.py index 9cb0808db..d88fcc093 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -99,6 +99,7 @@ Annotated, Any, NamedTuple, + TypedDict, cast, ) @@ -147,18 +148,35 @@ class ConnectAttempt(NamedTuple): device: type +class DiscoveredMeta(TypedDict): + """Meta info about discovery response.""" + + ip: str + port: int + + +class DiscoveredRaw(TypedDict): + """Try to connect attempt.""" + + meta: DiscoveredMeta + discovery_response: dict + + OnDiscoveredCallable = Callable[[Device], Coroutine] +OnDiscoveredRawCallable = Callable[[DiscoveredRaw], None] OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Coroutine] OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None] DeviceDict = dict[str, Device] NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = { "device_id": lambda x: "REDACTED_" + x[9::], + "device_name": lambda x: "#MASKED_NAME#" if x else "", "owner": lambda x: "REDACTED_" + x[9::], "mac": mask_mac, "master_device_id": lambda x: "REDACTED_" + x[9::], "group_id": lambda x: "REDACTED_" + x[9::], "group_name": lambda x: "I01BU0tFRF9TU0lEIw==", + "encrypt_info": lambda x: {**x, "key": "", "data": ""}, } @@ -216,6 +234,7 @@ def __init__( self, *, on_discovered: OnDiscoveredCallable | None = None, + on_discovered_raw: OnDiscoveredRawCallable | None = None, target: str = "255.255.255.255", discovery_packets: int = 3, discovery_timeout: int = 5, @@ -240,6 +259,7 @@ def __init__( self.unsupported_device_exceptions: dict = {} self.invalid_device_exceptions: dict = {} self.on_unsupported = on_unsupported + self.on_discovered_raw = on_discovered_raw self.credentials = credentials self.timeout = timeout self.discovery_timeout = discovery_timeout @@ -329,12 +349,23 @@ def datagram_received( config.timeout = self.timeout try: if port == self.discovery_port: - device = Discover._get_device_instance_legacy(data, config) + json_func = Discover._get_discovery_json_legacy + device_func = Discover._get_device_instance_legacy elif port == Discover.DISCOVERY_PORT_2: config.uses_http = True - device = Discover._get_device_instance(data, config) + json_func = Discover._get_discovery_json + device_func = Discover._get_device_instance else: return + info = json_func(data, ip) + if self.on_discovered_raw is not None: + self.on_discovered_raw( + { + "discovery_response": info, + "meta": {"ip": ip, "port": port}, + } + ) + device = device_func(info, config) except UnsupportedDeviceError as udex: _LOGGER.debug("Unsupported device found at %s << %s", ip, udex) self.unsupported_device_exceptions[ip] = udex @@ -391,6 +422,7 @@ async def discover( *, target: str = "255.255.255.255", on_discovered: OnDiscoveredCallable | None = None, + on_discovered_raw: OnDiscoveredRawCallable | None = None, discovery_timeout: int = 5, discovery_packets: int = 3, interface: str | None = None, @@ -421,6 +453,8 @@ async def 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 on_discovered_raw: Optional callback once discovered json is loaded + before any attempt to deserialize it and create devices :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 @@ -443,6 +477,7 @@ async def discover( discovery_packets=discovery_packets, interface=interface, on_unsupported=on_unsupported, + on_discovered_raw=on_discovered_raw, credentials=credentials, timeout=timeout, discovery_timeout=discovery_timeout, @@ -476,6 +511,7 @@ async def discover_single( credentials: Credentials | None = None, username: str | None = None, password: str | None = None, + on_discovered_raw: OnDiscoveredRawCallable | None = None, on_unsupported: OnUnsupportedCallable | None = None, ) -> Device | None: """Discover a single device by the given IP address. @@ -493,6 +529,9 @@ async def discover_single( username and password are ignored if provided. :param username: Username for devices that require authentication :param password: Password for devices that require authentication + :param on_discovered_raw: Optional callback once discovered json is loaded + before any attempt to deserialize it and create devices + :param on_unsupported: Optional callback when unsupported devices are discovered :rtype: SmartDevice :return: Object for querying/controlling found device. """ @@ -529,6 +568,7 @@ async def discover_single( credentials=credentials, timeout=timeout, discovery_timeout=discovery_timeout, + on_discovered_raw=on_discovered_raw, ), local_addr=("0.0.0.0", 0), # noqa: S104 ) @@ -666,15 +706,19 @@ def _get_device_class(info: dict) -> type[Device]: return get_device_class_from_sys_info(info) @staticmethod - def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice: - """Get SmartDevice from legacy 9999 response.""" + def _get_discovery_json_legacy(data: bytes, ip: str) -> dict: + """Get discovery json from legacy 9999 response.""" try: info = json_loads(XorEncryption.decrypt(data)) except Exception as ex: raise KasaException( - f"Unable to read response from device: {config.host}: {ex}" + f"Unable to read response from device: {ip}: {ex}" ) from ex + return info + @staticmethod + def _get_device_instance_legacy(info: dict, config: DeviceConfig) -> Device: + """Get IotDevice from legacy 9999 response.""" if _LOGGER.isEnabledFor(logging.DEBUG): data = redact_data(info, IOT_REDACTORS) if Discover._redact_data else info _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) @@ -716,19 +760,24 @@ def _decrypt_discovery_data(discovery_result: DiscoveryResult) -> None: discovery_result.decrypted_data = json_loads(decrypted_data) @staticmethod - def _get_device_instance( - data: bytes, - config: DeviceConfig, - ) -> Device: - """Get SmartDevice from the new 20002 response.""" - debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + def _get_discovery_json(data: bytes, ip: str) -> dict: + """Get discovery json from the new 20002 response.""" try: info = json_loads(data[16:]) except Exception as ex: - _LOGGER.debug("Got invalid response from device %s: %s", config.host, data) + _LOGGER.debug("Got invalid response from device %s: %s", ip, data) raise KasaException( - f"Unable to read response from device: {config.host}: {ex}" + f"Unable to read response from device: {ip}: {ex}" ) from ex + return info + + @staticmethod + def _get_device_instance( + info: dict, + config: DeviceConfig, + ) -> Device: + """Get SmartDevice from the new 20002 response.""" + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) try: discovery_result = DiscoveryResult.from_dict(info["result"]) @@ -757,7 +806,9 @@ def _get_device_instance( Discover._decrypt_discovery_data(discovery_result) except Exception: _LOGGER.exception( - "Unable to decrypt discovery data %s: %s", config.host, data + "Unable to decrypt discovery data %s: %s", + config.host, + redact_data(info, NEW_DISCOVERY_REDACTORS), ) type_ = discovery_result.device_type diff --git a/kasa/json.py b/kasa/json.py index 21c6fa00e..8a0eab7b4 100755 --- a/kasa/json.py +++ b/kasa/json.py @@ -8,18 +8,24 @@ try: import orjson - def dumps(obj: Any, *, default: Callable | None = None) -> str: + def dumps( + obj: Any, *, default: Callable | None = None, indent: bool = False + ) -> str: """Dump JSON.""" - return orjson.dumps(obj).decode() + return orjson.dumps( + obj, option=orjson.OPT_INDENT_2 if indent else None + ).decode() loads = orjson.loads except ImportError: import json - def dumps(obj: Any, *, default: Callable | None = None) -> str: + def dumps( + obj: Any, *, default: Callable | None = None, indent: bool = False + ) -> str: """Dump JSON.""" # Separators specified for consistency with orjson - return json.dumps(obj, separators=(",", ":")) + return json.dumps(obj, separators=(",", ":"), indent=2 if indent else None) loads = json.loads diff --git a/tests/test_cli.py b/tests/test_cli.py index d1fc330c9..4391b9981 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -42,8 +42,9 @@ from kasa.cli.time import time from kasa.cli.usage import energy from kasa.cli.wifi import wifi -from kasa.discover import Discover, DiscoveryResult +from kasa.discover import Discover, DiscoveryResult, redact_data from kasa.iot import IotDevice +from kasa.json import dumps as json_dumps from kasa.smart import SmartDevice from kasa.smartcam import SmartCamDevice @@ -126,6 +127,36 @@ async def test_list_devices(discovery_mock, runner): assert row in res.output +async def test_discover_raw(discovery_mock, runner, mocker): + """Test the discover raw command.""" + redact_spy = mocker.patch( + "kasa.protocols.protocol.redact_data", side_effect=redact_data + ) + res = await runner.invoke( + cli, + ["--username", "foo", "--password", "bar", "discover", "raw"], + catch_exceptions=False, + ) + assert res.exit_code == 0 + + expected = { + "discovery_response": discovery_mock.discovery_data, + "meta": {"ip": "127.0.0.123", "port": discovery_mock.discovery_port}, + } + assert res.output == json_dumps(expected, indent=True) + "\n" + + redact_spy.assert_not_called() + + res = await runner.invoke( + cli, + ["--username", "foo", "--password", "bar", "discover", "raw", "--redact"], + catch_exceptions=False, + ) + assert res.exit_code == 0 + + redact_spy.assert_called() + + @new_discovery async def test_list_auth_failed(discovery_mock, mocker, runner): """Test that device update is called on main.""" @@ -731,6 +762,7 @@ async def test_without_device_type(dev, mocker, runner): timeout=5, discovery_timeout=7, on_unsupported=ANY, + on_discovered_raw=ANY, ) From 032cd5d2cc2004a3b161f124c441432a42b1bf1c Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 11 Dec 2024 01:01:36 +0100 Subject: [PATCH 744/892] Improve overheat reporting (#1335) Different devices and different firmwares report overheated status in different ways. Some devices indicate support for `overheat_protect` component, but there are devices that report `overheat_status` even when it is not listed. Some other devices use `overheated` boolean that was already previously supported, but this PR adds support for much more devices that use `overheat_status` for reporting. The "overheated" feature is moved into its own module, and uses either of the ways to report this information. This will also rename `REQUIRED_KEY_ON_PARENT` to `SYSINFO_LOOKUP_KEYS` and change its logic to check if any of the keys in the list are found in the sysinfo. ``` tpr@lumipyry ~/c/p/tests (fix/overheated)> ag 'overheat_protect' -c|wc -l 15 tpr@lumipyry ~/c/p/tests (fix/overheated)> ag 'overheated' -c|wc -l 38 tpr@lumipyry ~/c/p/tests (fix/overheated)> ag 'overheat_status' -c|wc -l 20 ``` --------- Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --- docs/tutorial.py | 2 +- kasa/feature.py | 2 +- kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/contactsensor.py | 2 +- kasa/smart/modules/overheatprotection.py | 41 +++++++++++++++++ kasa/smart/smartdevice.py | 18 +------- kasa/smart/smartmodule.py | 4 +- tests/smart/test_smartdevice.py | 58 ++++++++++++++++++++++++ 8 files changed, 108 insertions(+), 21 deletions(-) create mode 100644 kasa/smart/modules/overheatprotection.py diff --git a/docs/tutorial.py b/docs/tutorial.py index 8d0a14354..f5cb9dea6 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -91,5 +91,5 @@ True >>> for feat in dev.features.values(): >>> print(f"{feat.name}: {feat.value}") -Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nReboot: \nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: \nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nDevice time: 2024-02-23 02:40:15+01:00 +Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nReboot: \nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: \nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nOverheated: False\nDevice time: 2024-02-23 02:40:15+01:00 """ diff --git a/kasa/feature.py b/kasa/feature.py index d747338da..ff19baf97 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -24,7 +24,6 @@ Signal Level (signal_level): 2 RSSI (rssi): -52 SSID (ssid): #MASKED_SSID# -Overheated (overheated): False Reboot (reboot): Brightness (brightness): 100 Cloud connection (cloud_connection): True @@ -39,6 +38,7 @@ Light preset (light_preset): Not set Smooth transition on (smooth_transition_on): 2 Smooth transition off (smooth_transition_off): 2 +Overheated (overheated): False Device time (device_time): 2024-02-23 02:40:15+01:00 To see whether a device supports a feature, check for the existence of it: diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 99820cfaf..367548019 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -24,6 +24,7 @@ from .lightstripeffect import LightStripEffect from .lighttransition import LightTransition from .motionsensor import MotionSensor +from .overheatprotection import OverheatProtection from .reportmode import ReportMode from .temperaturecontrol import TemperatureControl from .temperaturesensor import TemperatureSensor @@ -64,4 +65,5 @@ "FrostProtection", "Thermostat", "SmartLightEffect", + "OverheatProtection", ] diff --git a/kasa/smart/modules/contactsensor.py b/kasa/smart/modules/contactsensor.py index f388b781d..d0bebb077 100644 --- a/kasa/smart/modules/contactsensor.py +++ b/kasa/smart/modules/contactsensor.py @@ -10,7 +10,7 @@ class ContactSensor(SmartModule): """Implementation of contact sensor module.""" REQUIRED_COMPONENT = None # we depend on availability of key - REQUIRED_KEY_ON_PARENT = "open" + SYSINFO_LOOKUP_KEYS = ["open"] def _initialize_features(self) -> None: """Initialize features after the initial update.""" diff --git a/kasa/smart/modules/overheatprotection.py b/kasa/smart/modules/overheatprotection.py new file mode 100644 index 000000000..cdaba4e82 --- /dev/null +++ b/kasa/smart/modules/overheatprotection.py @@ -0,0 +1,41 @@ +"""Overheat module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class OverheatProtection(SmartModule): + """Implementation for overheat_protection.""" + + SYSINFO_LOOKUP_KEYS = ["overheated", "overheat_status"] + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + container=self, + id="overheated", + name="Overheated", + attribute_getter="overheated", + icon="mdi:heat-wave", + type=Feature.Type.BinarySensor, + category=Feature.Category.Info, + ) + ) + + @property + def overheated(self) -> bool: + """Return True if device reports overheating.""" + if (value := self._device.sys_info.get("overheat_status")) is not None: + # Value can be normal, cooldown, or overheated. + # We report all but normal as overheated. + return value != "normal" + + return self._device.sys_info["overheated"] + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 48f50c0e8..ed5a4eec5 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -349,9 +349,8 @@ async def _initialize_modules(self) -> None: ) or mod.__name__ in child_modules_to_skip: continue required_component = cast(str, mod.REQUIRED_COMPONENT) - if required_component in self._components or ( - mod.REQUIRED_KEY_ON_PARENT - and self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None + if required_component in self._components or any( + self.sys_info.get(key) is not None for key in mod.SYSINFO_LOOKUP_KEYS ): _LOGGER.debug( "Device %s, found required %s, adding %s to modules.", @@ -440,19 +439,6 @@ async def _initialize_features(self) -> None: ) ) - if "overheated" in self._info: - self._add_feature( - Feature( - self, - id="overheated", - name="Overheated", - attribute_getter=lambda x: x._info["overheated"], - icon="mdi:heat-wave", - type=Feature.Type.BinarySensor, - category=Feature.Category.Info, - ) - ) - # We check for the key available, and not for the property truthiness, # as the value is falsy when the device is off. if "on_time" in self._info: diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index c56970438..ab6ae667d 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -54,8 +54,8 @@ class SmartModule(Module): NAME: str #: Module is initialized, if the given component is available REQUIRED_COMPONENT: str | None = None - #: Module is initialized, if the given key available in the main sysinfo - REQUIRED_KEY_ON_PARENT: str | None = None + #: Module is initialized, if any of the given keys exists in the sysinfo + SYSINFO_LOOKUP_KEYS: list[str] = [] #: Query to execute during the main update cycle QUERY_GETTER_NAME: str diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index 81707a11a..25addcfc3 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -470,3 +470,61 @@ async def test_smart_temp_range(dev: Device): light = dev.modules.get(Module.Light) assert light assert light.valid_temperature_range + + +@device_smart +async def test_initialize_modules_sysinfo_lookup_keys( + dev: SmartDevice, mocker: MockerFixture +): + """Test that matching modules using SYSINFO_LOOKUP_KEYS are initialized correctly.""" + + class AvailableKey(SmartModule): + SYSINFO_LOOKUP_KEYS = ["device_id"] + + class NonExistingKey(SmartModule): + SYSINFO_LOOKUP_KEYS = ["this_does_not_exist"] + + # The __init_subclass__ hook in smartmodule checks the path, + # so we have to manually add these for testing. + mocker.patch.dict( + "kasa.smart.smartmodule.SmartModule.REGISTERED_MODULES", + { + AvailableKey._module_name(): AvailableKey, + NonExistingKey._module_name(): NonExistingKey, + }, + ) + + # We have an already initialized device, so we try to initialize the modules again + await dev._initialize_modules() + + assert "AvailableKey" in dev.modules + assert "NonExistingKey" not in dev.modules + + +@device_smart +async def test_initialize_modules_required_component( + dev: SmartDevice, mocker: MockerFixture +): + """Test that matching modules using REQUIRED_COMPONENT are initialized correctly.""" + + class AvailableComponent(SmartModule): + REQUIRED_COMPONENT = "device" + + class NonExistingComponent(SmartModule): + REQUIRED_COMPONENT = "this_does_not_exist" + + # The __init_subclass__ hook in smartmodule checks the path, + # so we have to manually add these for testing. + mocker.patch.dict( + "kasa.smart.smartmodule.SmartModule.REGISTERED_MODULES", + { + AvailableComponent._module_name(): AvailableComponent, + NonExistingComponent._module_name(): NonExistingComponent, + }, + ) + + # We have an already initialized device, so we try to initialize the modules again + await dev._initialize_modules() + + assert "AvailableComponent" in dev.modules + assert "NonExistingComponent" not in dev.modules From 8cb5c2e180ef8113bed9360bd29e88837f911d0f Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 11 Dec 2024 13:18:44 +0000 Subject: [PATCH 745/892] Update dump_devinfo for raw discovery json and common redactors (#1358) This PR does a few related things to dump_devinfo: - Store the raw discovery result in the fixture. - Consolidate redaction logic so it's not duplicated in dump_devinfo. - Update existing fixtures to: - Store raw discovery result under `result` - Use `SCRUBBED_CHILD_DEVICE_ID` everywhere - Have correct values as per the consolidated redactors. --- devtools/dump_devinfo.py | 220 ++++++++---------- devtools/generate_supported.py | 2 +- devtools/update_fixtures.py | 128 ++++++++++ kasa/discover.py | 23 +- kasa/protocols/iotprotocol.py | 18 +- kasa/protocols/protocol.py | 2 + kasa/protocols/smartprotocol.py | 27 ++- tests/device_fixtures.py | 2 +- tests/discovery_fixtures.py | 9 +- tests/fixtures/iot/EP10(US)_1.0_1.0.2.json | 2 +- tests/fixtures/iot/EP40(US)_1.0_1.0.2.json | 10 +- tests/fixtures/iot/ES20M(US)_1.0_1.0.11.json | 2 +- tests/fixtures/iot/ES20M(US)_1.0_1.0.8.json | 2 +- tests/fixtures/iot/HS100(UK)_4.1_1.1.0.json | 29 +-- tests/fixtures/iot/HS100(US)_1.0_1.2.5.json | 2 +- tests/fixtures/iot/HS100(US)_2.0_1.5.6.json | 2 +- tests/fixtures/iot/HS103(US)_1.0_1.5.7.json | 2 +- tests/fixtures/iot/HS103(US)_2.1_1.1.2.json | 2 +- tests/fixtures/iot/HS103(US)_2.1_1.1.4.json | 2 +- tests/fixtures/iot/HS105(US)_1.0_1.5.6.json | 2 +- tests/fixtures/iot/HS107(US)_1.0_1.0.8.json | 12 +- tests/fixtures/iot/HS110(EU)_1.0_1.2.5.json | 2 +- tests/fixtures/iot/HS110(US)_1.0_1.2.6.json | 2 +- tests/fixtures/iot/HS200(US)_2.0_1.5.7.json | 2 +- tests/fixtures/iot/HS200(US)_5.0_1.0.2.json | 2 +- tests/fixtures/iot/HS210(US)_1.0_1.5.8.json | 2 +- tests/fixtures/iot/HS220(US)_1.0_1.5.7.json | 6 +- tests/fixtures/iot/HS220(US)_2.0_1.0.3.json | 2 +- tests/fixtures/iot/HS300(US)_1.0_1.0.10.json | 28 +-- tests/fixtures/iot/HS300(US)_1.0_1.0.21.json | 26 +-- tests/fixtures/iot/HS300(US)_2.0_1.0.12.json | 24 +- tests/fixtures/iot/HS300(US)_2.0_1.0.3.json | 26 +-- tests/fixtures/iot/KL110(US)_1.0_1.8.11.json | 2 +- tests/fixtures/iot/KL120(US)_1.0_1.8.11.json | 2 +- tests/fixtures/iot/KL120(US)_1.0_1.8.6.json | 8 +- tests/fixtures/iot/KL125(US)_1.20_1.0.5.json | 2 +- tests/fixtures/iot/KL125(US)_2.0_1.0.7.json | 2 +- tests/fixtures/iot/KL130(EU)_1.0_1.8.8.json | 2 +- tests/fixtures/iot/KL130(US)_1.0_1.8.11.json | 2 +- tests/fixtures/iot/KL135(US)_1.0_1.0.15.json | 2 +- tests/fixtures/iot/KL135(US)_1.0_1.0.6.json | 2 +- tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json | 2 +- tests/fixtures/iot/KL400L5(US)_1.0_1.0.8.json | 2 +- tests/fixtures/iot/KL420L5(US)_1.0_1.0.2.json | 2 +- tests/fixtures/iot/KL430(UN)_2.0_1.0.8.json | 2 +- tests/fixtures/iot/KL430(US)_1.0_1.0.10.json | 2 +- tests/fixtures/iot/KL430(US)_2.0_1.0.11.json | 2 +- tests/fixtures/iot/KL430(US)_2.0_1.0.8.json | 2 +- tests/fixtures/iot/KL430(US)_2.0_1.0.9.json | 2 +- tests/fixtures/iot/KL50(US)_1.0_1.1.13.json | 2 +- tests/fixtures/iot/KL60(UN)_1.0_1.1.4.json | 4 +- tests/fixtures/iot/KL60(US)_1.0_1.1.13.json | 2 +- tests/fixtures/iot/KP100(US)_3.0_1.0.1.json | 2 +- tests/fixtures/iot/KP105(UK)_1.0_1.0.5.json | 2 +- tests/fixtures/iot/KP115(US)_1.0_1.0.17.json | 2 +- tests/fixtures/iot/KP125(US)_1.0_1.0.6.json | 2 +- tests/fixtures/iot/KP200(US)_3.0_1.0.3.json | 10 +- tests/fixtures/iot/KP303(UK)_1.0_1.0.3.json | 14 +- tests/fixtures/iot/KP303(US)_2.0_1.0.3.json | 14 +- tests/fixtures/iot/KP303(US)_2.0_1.0.9.json | 12 +- tests/fixtures/iot/KP400(US)_1.0_1.0.10.json | 10 +- tests/fixtures/iot/KP400(US)_2.0_1.0.6.json | 10 +- tests/fixtures/iot/KP400(US)_3.0_1.0.3.json | 8 +- tests/fixtures/iot/KP400(US)_3.0_1.0.4.json | 8 +- tests/fixtures/iot/KP401(US)_1.0_1.0.0.json | 2 +- tests/fixtures/iot/KP405(US)_1.0_1.0.5.json | 2 +- tests/fixtures/iot/KS200(US)_1.0_1.0.8.json | 2 +- tests/fixtures/iot/KS200M(US)_1.0_1.0.10.json | 2 +- tests/fixtures/iot/KS200M(US)_1.0_1.0.8.json | 2 +- tests/fixtures/iot/KS220(US)_1.0_1.0.13.json | 2 +- tests/fixtures/iot/KS220M(US)_1.0_1.0.4.json | 2 +- tests/fixtures/iot/KS230(US)_1.0_1.0.14.json | 2 +- tests/fixtures/iot/LB110(US)_1.0_1.8.11.json | 2 +- tests/fixtures/smart/EP25(US)_2.6_1.0.1.json | 33 +-- tests/fixtures/smart/EP25(US)_2.6_1.0.2.json | 33 +-- tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json | 33 +-- tests/fixtures/smart/H100(EU)_1.0_1.2.3.json | 33 +-- tests/fixtures/smart/H100(EU)_1.0_1.5.10.json | 33 +-- tests/fixtures/smart/H100(EU)_1.0_1.5.5.json | 37 +-- .../fixtures/smart/HS200(US)_5.26_1.0.3.json | 33 +-- .../fixtures/smart/HS220(US)_3.26_1.0.1.json | 31 +-- tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json | 33 +-- .../fixtures/smart/KH100(EU)_1.0_1.5.12.json | 33 +-- tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json | 35 +-- .../fixtures/smart/KP125M(US)_1.0_1.1.3.json | 37 +-- .../fixtures/smart/KP125M(US)_1.0_1.2.3.json | 33 +-- tests/fixtures/smart/KS205(US)_1.0_1.0.2.json | 33 +-- tests/fixtures/smart/KS205(US)_1.0_1.1.0.json | 33 +-- tests/fixtures/smart/KS225(US)_1.0_1.0.2.json | 33 +-- tests/fixtures/smart/KS225(US)_1.0_1.1.0.json | 33 +-- tests/fixtures/smart/KS240(US)_1.0_1.0.4.json | 33 +-- tests/fixtures/smart/KS240(US)_1.0_1.0.5.json | 41 ++-- tests/fixtures/smart/KS240(US)_1.0_1.0.7.json | 33 +-- tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json | 33 +-- tests/fixtures/smart/L510E(US)_3.0_1.0.5.json | 33 +-- tests/fixtures/smart/L510E(US)_3.0_1.1.2.json | 33 +-- tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json | 33 +-- tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json | 33 +-- tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json | 35 +-- tests/fixtures/smart/L530E(US)_2.0_1.1.0.json | 33 +-- tests/fixtures/smart/L630(EU)_1.0_1.1.2.json | 33 +-- .../smart/L900-10(EU)_1.0_1.0.17.json | 33 +-- .../smart/L900-10(US)_1.0_1.0.11.json | 31 +-- .../fixtures/smart/L900-5(EU)_1.0_1.0.17.json | 33 +-- .../fixtures/smart/L900-5(EU)_1.0_1.1.0.json | 33 +-- .../fixtures/smart/L920-5(EU)_1.0_1.0.7.json | 31 +-- .../fixtures/smart/L920-5(EU)_1.0_1.1.3.json | 33 +-- .../fixtures/smart/L920-5(US)_1.0_1.1.0.json | 33 +-- .../fixtures/smart/L920-5(US)_1.0_1.1.3.json | 33 +-- .../fixtures/smart/L930-5(US)_1.0_1.1.2.json | 33 +-- .../fixtures/smart/P100(US)_1.0.0_1.1.3.json | 29 +-- .../fixtures/smart/P100(US)_1.0.0_1.3.7.json | 33 +-- .../fixtures/smart/P100(US)_1.0.0_1.4.0.json | 31 +-- tests/fixtures/smart/P110(EU)_1.0_1.0.7.json | 29 +-- tests/fixtures/smart/P110(EU)_1.0_1.2.3.json | 33 +-- tests/fixtures/smart/P110(UK)_1.0_1.3.0.json | 33 +-- tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json | 59 ++--- tests/fixtures/smart/P110M(EU)_1.0_1.2.3.json | 33 +-- tests/fixtures/smart/P115(EU)_1.0_1.2.3.json | 33 +-- tests/fixtures/smart/P115(US)_1.0_1.1.3.json | 33 +-- tests/fixtures/smart/P125M(US)_1.0_1.1.0.json | 33 +-- tests/fixtures/smart/P135(US)_1.0_1.0.5.json | 33 +-- tests/fixtures/smart/P300(EU)_1.0_1.0.13.json | 45 ++-- tests/fixtures/smart/P300(EU)_1.0_1.0.15.json | 33 +-- tests/fixtures/smart/P300(EU)_1.0_1.0.7.json | 51 ++-- tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json | 33 +-- tests/fixtures/smart/S500D(US)_1.0_1.0.5.json | 33 +-- tests/fixtures/smart/S505(US)_1.0_1.0.2.json | 33 +-- tests/fixtures/smart/S505D(US)_1.0_1.1.0.json | 33 +-- tests/fixtures/smart/TP15(US)_1.0_1.0.3.json | 33 +-- tests/fixtures/smart/TP25(US)_1.0_1.0.2.json | 41 ++-- .../fixtures/smartcam/C210(EU)_2.0_1.4.2.json | 63 ++--- .../fixtures/smartcam/C210(EU)_2.0_1.4.3.json | 63 ++--- .../smartcam/C520WS(US)_1.0_1.2.8.json | 65 +++--- .../fixtures/smartcam/H200(EU)_1.0_1.3.2.json | 59 ++--- .../fixtures/smartcam/H200(US)_1.0_1.3.6.json | 61 ++--- tests/fixtures/smartcam/TC65_1.0_1.3.9.json | 63 ++--- tests/test_cli.py | 2 +- tests/test_devtools.py | 6 +- tests/test_readme_examples.py | 32 ++- 140 files changed, 1771 insertions(+), 1407 deletions(-) create mode 100644 devtools/update_fixtures.py diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 7760b6cb9..02aebae76 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -10,8 +10,6 @@ from __future__ import annotations -import base64 -import collections.abc import dataclasses import json import logging @@ -19,6 +17,7 @@ import sys import traceback from collections import defaultdict, namedtuple +from collections.abc import Callable from pathlib import Path from pprint import pprint from typing import Any @@ -39,13 +38,20 @@ ) from kasa.device_factory import get_protocol from kasa.deviceconfig import DeviceEncryptionType, DeviceFamily -from kasa.discover import DiscoveryResult +from kasa.discover import ( + NEW_DISCOVERY_REDACTORS, + DiscoveredRaw, + DiscoveryResult, +) from kasa.exceptions import SmartErrorCode from kasa.protocols import IotProtocol +from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS +from kasa.protocols.protocol import redact_data from kasa.protocols.smartcamprotocol import ( SmartCamProtocol, _ChildCameraProtocolWrapper, ) +from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.smart import SmartChildDevice, SmartDevice from kasa.smartcam import SmartCamDevice @@ -63,6 +69,42 @@ _LOGGER = logging.getLogger(__name__) +def _wrap_redactors(redactors: dict[str, Callable[[Any], Any] | None]): + """Wrap the redactors for dump_devinfo. + + Will replace all partial REDACT_ values with zeros. + If the data item is already scrubbed by dump_devinfo will leave as-is. + """ + + def _wrap(key: str) -> Any: + def _wrapped(redactor: Callable[[Any], Any] | None) -> Any | None: + if redactor is None: + return lambda x: "**SCRUBBED**" + + def _redact_to_zeros(x: Any) -> Any: + if isinstance(x, str) and "REDACT" in x: + return re.sub(r"\w", "0", x) + if isinstance(x, dict): + for k, v in x.items(): + x[k] = _redact_to_zeros(v) + return x + + def _scrub(x: Any) -> Any: + if key in {"ip", "local_ip"}: + return "127.0.0.123" + # Already scrubbed by dump_devinfo + if isinstance(x, str) and "SCRUBBED" in x: + return x + default = redactor(x) + return _redact_to_zeros(default) + + return _scrub + + return _wrapped(redactors[key]) + + return {key: _wrap(key) for key in redactors} + + @dataclasses.dataclass class SmartCall: """Class for smart and smartcam calls.""" @@ -74,115 +116,6 @@ class SmartCall: supports_multiple: bool = True -def scrub(res): - """Remove identifiers from the given dict.""" - keys_to_scrub = [ - "deviceId", - "fwId", - "hwId", - "oemId", - "mac", - "mic_mac", - "latitude_i", - "longitude_i", - "latitude", - "longitude", - "la", # lat on ks240 - "lo", # lon on ks240 - "owner", - "device_id", - "ip", - "ssid", - "hw_id", - "fw_id", - "oem_id", - "nickname", - "alias", - "bssid", - "channel", - "original_device_id", # for child devices on strips - "parent_device_id", # for hub children - "setup_code", # matter - "setup_payload", # matter - "mfi_setup_code", # mfi_ for homekit - "mfi_setup_id", - "mfi_token_token", - "mfi_token_uuid", - "dev_id", - "device_name", - "device_alias", - "connect_ssid", - "encrypt_info", - "local_ip", - "username", - # vacuum - "board_sn", - "custom_sn", - "location", - ] - - for k, v in res.items(): - if isinstance(v, collections.abc.Mapping): - if k == "encrypt_info": - if "data" in v: - v["data"] = "" - if "key" in v: - v["key"] = "" - else: - res[k] = scrub(res.get(k)) - elif ( - isinstance(v, list) - and len(v) > 0 - and isinstance(v[0], collections.abc.Mapping) - ): - res[k] = [scrub(vi) for vi in v] - else: - if k in keys_to_scrub: - if k in ["mac", "mic_mac"]: - # Some macs have : or - as a separator and others do not - if len(v) == 12: - v = f"{v[:6]}000000" - else: - delim = ":" if ":" in v else "-" - rest = delim.join( - format(s, "02x") for s in bytes.fromhex("000000") - ) - v = f"{v[:8]}{delim}{rest}" - elif k in ["latitude", "latitude_i", "longitude", "longitude_i"]: - v = 0 - elif k in ["ip", "local_ip"]: - v = "127.0.0.123" - elif k in ["ssid"]: - # Need a valid base64 value here - v = base64.b64encode(b"#MASKED_SSID#").decode() - elif k in ["nickname"]: - v = base64.b64encode(b"#MASKED_NAME#").decode() - elif k in [ - "alias", - "device_alias", - "device_name", - "username", - "location", - ]: - v = "#MASKED_NAME#" - elif isinstance(res[k], int): - v = 0 - elif k in ["map_data"]: # - v = "#SCRUBBED_MAPDATA#" - elif k in ["device_id", "dev_id"] and "SCRUBBED" in v: - pass # already scrubbed - elif k == ["device_id", "dev_id"] and len(v) > 40: - # retain the last two chars when scrubbing child ids - end = v[-2:] - v = re.sub(r"\w", "0", v) - v = v[:40] + end - else: - v = re.sub(r"\w", "0", v) - - res[k] = v - return res - - def default_to_regular(d): """Convert nested defaultdicts to regular ones. @@ -209,7 +142,7 @@ async def handle_device( for fixture_result in fixture_results: save_filename = Path(basedir) / fixture_result.folder / fixture_result.filename - pprint(scrub(fixture_result.data)) + pprint(fixture_result.data) if autosave: save = "y" else: @@ -325,6 +258,11 @@ async def cli( if debug: logging.basicConfig(level=logging.DEBUG) + raw_discovery = {} + + def capture_raw(discovered: DiscoveredRaw): + raw_discovery[discovered["meta"]["ip"]] = discovered["discovery_response"] + credentials = Credentials(username=username, password=password) if host is not None: if discovery_info: @@ -377,12 +315,16 @@ async def cli( credentials=credentials, port=port, discovery_timeout=discovery_timeout, + on_discovered_raw=capture_raw, ) + discovery_info = raw_discovery[device.host] + if decrypted_data := device._discovery_info.get("decrypted_data"): + discovery_info["decrypted_data"] = decrypted_data await handle_device( basedir, autosave, device.protocol, - discovery_info=device._discovery_info, + discovery_info=discovery_info, batch_size=batch_size, ) else: @@ -391,21 +333,28 @@ async def cli( f" {target}. Use --target to override." ) devices = await Discover.discover( - target=target, credentials=credentials, discovery_timeout=discovery_timeout + target=target, + credentials=credentials, + discovery_timeout=discovery_timeout, + on_discovered_raw=capture_raw, ) click.echo(f"Detected {len(devices)} devices") for dev in devices.values(): + discovery_info = raw_discovery[dev.host] + if decrypted_data := dev._discovery_info.get("decrypted_data"): + discovery_info["decrypted_data"] = decrypted_data + await handle_device( basedir, autosave, dev.protocol, - discovery_info=dev._discovery_info, + discovery_info=discovery_info, batch_size=batch_size, ) async def get_legacy_fixture( - protocol: IotProtocol, *, discovery_info: dict[str, Any] | None + protocol: IotProtocol, *, discovery_info: dict[str, dict[str, Any]] | None ) -> FixtureResult: """Get fixture for legacy IOT style protocol.""" items = [ @@ -475,11 +424,21 @@ async def get_legacy_fixture( _echo_error(f"Unable to query all successes at once: {ex}") finally: await protocol.close() + + final = redact_data(final, _wrap_redactors(IOT_REDACTORS)) + + # Scrub the child device ids + if children := final.get("system", {}).get("get_sysinfo", {}).get("children"): + for index, child in enumerate(children): + if "id" not in child: + _LOGGER.error("Could not find a device for the child device: %s", child) + else: + child["id"] = f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}" + if discovery_info and not discovery_info.get("system"): - # 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. - dr = DiscoveryResult.from_dict(discovery_info) - final["discovery_result"] = dr.to_dict() + final["discovery_result"] = redact_data( + discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS) + ) click.echo(f"Got {len(successes)} successes") click.echo(click.style("## device info file ##", bold=True)) @@ -867,7 +826,10 @@ def get_smart_child_fixture(response): async def get_smart_fixtures( - protocol: SmartProtocol, *, discovery_info: dict[str, Any] | None, batch_size: int + protocol: SmartProtocol, + *, + discovery_info: dict[str, dict[str, Any]] | None, + batch_size: int, ) -> list[FixtureResult]: """Get fixture for new TAPO style protocol.""" if isinstance(protocol, SmartCamProtocol): @@ -988,22 +950,24 @@ async def get_smart_fixtures( continue _LOGGER.error("Could not find a device for the child device: %s", child) - # 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. + final = redact_data(final, _wrap_redactors(SMART_REDACTORS)) + discovery_result = None if discovery_info: - dr = DiscoveryResult.from_dict(discovery_info) # type: ignore - final["discovery_result"] = dr.to_dict() + final["discovery_result"] = redact_data( + discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS) + ) + discovery_result = discovery_info["result"] click.echo(f"Got {len(successes)} successes") click.echo(click.style("## device info file ##", bold=True)) if "get_device_info" in final: # smart protocol - model_info = SmartDevice._get_device_info(final, discovery_info) + model_info = SmartDevice._get_device_info(final, discovery_result) copy_folder = SMART_FOLDER else: # smart camera protocol - model_info = SmartCamDevice._get_device_info(final, discovery_info) + model_info = SmartCamDevice._get_device_info(final, discovery_result) copy_folder = SMARTCAM_FOLDER hw_version = model_info.hardware_version sw_version = model_info.firmware_version diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py index 532c7e6a3..7b4e9787d 100755 --- a/devtools/generate_supported.py +++ b/devtools/generate_supported.py @@ -205,7 +205,7 @@ def _get_supported_devices( fixture_data = json.load(f) model_info = device_cls._get_device_info( - fixture_data, fixture_data.get("discovery_result") + fixture_data, fixture_data.get("discovery_result", {}).get("result") ) supported_type = DEVICE_TYPE_TO_PRODUCT_GROUP[model_info.device_type] diff --git a/devtools/update_fixtures.py b/devtools/update_fixtures.py new file mode 100644 index 000000000..13b9996ef --- /dev/null +++ b/devtools/update_fixtures.py @@ -0,0 +1,128 @@ +"""Module to mass update fixture files.""" + +import json +import logging +from collections.abc import Callable +from pathlib import Path + +import asyncclick as click + +from devtools.dump_devinfo import _wrap_redactors +from kasa.discover import NEW_DISCOVERY_REDACTORS, redact_data +from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS +from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS + +FIXTURE_FOLDER = "tests/fixtures/" + +_LOGGER = logging.getLogger(__name__) + + +def update_fixtures(update_func: Callable[[dict], bool], *, dry_run: bool) -> None: + """Run the update function against the fixtures.""" + for file in Path(FIXTURE_FOLDER).glob("**/*.json"): + with file.open("r") as f: + fixture_data = json.load(f) + + if file.parent.name == "serialization": + continue + changed = update_func(fixture_data) + if changed: + click.echo(f"Will update {file.name}\n") + if changed and not dry_run: + with file.open("w") as f: + json.dump(fixture_data, f, sort_keys=True, indent=4) + f.write("\n") + + +def _discovery_result_update(info) -> bool: + """Update discovery_result to be the raw result and error_code.""" + if (disco_result := info.get("discovery_result")) and "result" not in disco_result: + info["discovery_result"] = { + "result": disco_result, + "error_code": 0, + } + return True + return False + + +def _child_device_id_update(info) -> bool: + """Update child device ids to be the scrubbed ids from dump_devinfo.""" + changed = False + if get_child_device_list := info.get("get_child_device_list"): + child_device_list = get_child_device_list["child_device_list"] + child_component_list = info["get_child_device_component_list"][ + "child_component_list" + ] + for index, child_device in enumerate(child_device_list): + child_component = child_component_list[index] + if "SCRUBBED" not in child_device["device_id"]: + dev_id = f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}" + click.echo( + f"child_device_id{index}: {child_device['device_id']} -> {dev_id}" + ) + child_device["device_id"] = dev_id + child_component["device_id"] = dev_id + changed = True + + if children := info.get("system", {}).get("get_sysinfo", {}).get("children"): + for index, child_device in enumerate(children): + if "SCRUBBED" not in child_device["id"]: + dev_id = f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}" + click.echo(f"child_device_id{index}: {child_device['id']} -> {dev_id}") + child_device["id"] = dev_id + changed = True + + return changed + + +def _diff_data(fullkey, data1, data2, diffs): + if isinstance(data1, dict): + for k, v in data1.items(): + _diff_data(fullkey + "/" + k, v, data2[k], diffs) + elif isinstance(data1, list): + for index, item in enumerate(data1): + _diff_data(fullkey + "/" + str(index), item, data2[index], diffs) + elif data1 != data2: + diffs[fullkey] = (data1, data2) + + +def _redactor_result_update(info) -> bool: + """Update fixtures with the output using the common redactors.""" + changed = False + + redactors = IOT_REDACTORS if "system" in info else SMART_REDACTORS + + for key, val in info.items(): + if not isinstance(val, dict): + continue + if key == "discovery_result": + info[key] = redact_data(val, _wrap_redactors(NEW_DISCOVERY_REDACTORS)) + else: + info[key] = redact_data(val, _wrap_redactors(redactors)) + diffs: dict[str, tuple[str, str]] = {} + _diff_data(key, val, info[key], diffs) + if diffs: + for k, v in diffs.items(): + click.echo(f"{k}: {v[0]} -> {v[1]}") + changed = True + + return changed + + +@click.option( + "--dry-run/--no-dry-run", + default=False, + is_flag=True, + type=bool, + help="Perform a dry run without saving.", +) +@click.command() +async def cli(dry_run: bool) -> None: + """Cli method fo rupdating fixtures.""" + update_fixtures(_discovery_result_update, dry_run=dry_run) + update_fixtures(_child_device_id_update, dry_run=dry_run) + update_fixtures(_redactor_result_update, dry_run=dry_run) + + +if __name__ == "__main__": + cli() diff --git a/kasa/discover.py b/kasa/discover.py index d88fcc093..b7c545a2f 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -168,6 +168,12 @@ class DiscoveredRaw(TypedDict): OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None] DeviceDict = dict[str, Device] +DECRYPTED_REDACTORS: dict[str, Callable[[Any], Any] | None] = { + "connect_ssid": lambda x: "#MASKED_SSID#" if x else "", + "device_id": lambda x: "REDACTED_" + x[9::], + "owner": lambda x: "REDACTED_" + x[9::], +} + NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = { "device_id": lambda x: "REDACTED_" + x[9::], "device_name": lambda x: "#MASKED_NAME#" if x else "", @@ -177,6 +183,8 @@ class DiscoveredRaw(TypedDict): "group_id": lambda x: "REDACTED_" + x[9::], "group_name": lambda x: "I01BU0tFRF9TU0lEIw==", "encrypt_info": lambda x: {**x, "key": "", "data": ""}, + "ip": lambda x: x, # don't redact but keep listed here for dump_devinfo + "decrypted_data": lambda x: redact_data(x, DECRYPTED_REDACTORS), } @@ -742,6 +750,7 @@ def _get_device_instance_legacy(info: dict, config: DeviceConfig) -> Device: @staticmethod def _decrypt_discovery_data(discovery_result: DiscoveryResult) -> None: + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) if TYPE_CHECKING: assert discovery_result.encrypt_info assert _AesDiscoveryQuery.keypair @@ -757,7 +766,19 @@ def _decrypt_discovery_data(discovery_result: DiscoveryResult) -> None: session = AesEncyptionSession(key, iv) decrypted_data = session.decrypt(encrypted_data) - discovery_result.decrypted_data = json_loads(decrypted_data) + result = json_loads(decrypted_data) + if debug_enabled: + data = ( + redact_data(result, DECRYPTED_REDACTORS) + if Discover._redact_data + else result + ) + _LOGGER.debug( + "Decrypted encrypt_info for %s: %s", + discovery_result.ip, + pf(data), + ) + discovery_result.decrypted_data = result @staticmethod def _get_discovery_json(data: bytes, ip: str) -> dict: diff --git a/kasa/protocols/iotprotocol.py b/kasa/protocols/iotprotocol.py index 3bc6c4545..b58e57ae7 100755 --- a/kasa/protocols/iotprotocol.py +++ b/kasa/protocols/iotprotocol.py @@ -25,19 +25,35 @@ _LOGGER = logging.getLogger(__name__) + +def _mask_children(children: list[dict[str, Any]]) -> list[dict[str, Any]]: + def mask_child(child: dict[str, Any], index: int) -> dict[str, Any]: + result = { + **child, + "id": f"SCRUBBED_CHILD_DEVICE_ID_{index+1}", + } + # Will leave empty aliases as blank + if child.get("alias"): + result["alias"] = f"#MASKED_NAME# {index + 1}" + return result + + return [mask_child(child, index) for index, child in enumerate(children)] + + REDACTORS: dict[str, Callable[[Any], Any] | None] = { "latitude": lambda x: 0, "longitude": lambda x: 0, "latitude_i": lambda x: 0, "longitude_i": lambda x: 0, "deviceId": lambda x: "REDACTED_" + x[9::], - "id": lambda x: "REDACTED_" + x[9::], + "children": _mask_children, "alias": lambda x: "#MASKED_NAME#" if x else "", "mac": mask_mac, "mic_mac": mask_mac, "ssid": lambda x: "#MASKED_SSID#" if x else "", "oemId": lambda x: "REDACTED_" + x[9::], "username": lambda _: "user@example.com", # cnCloud + "hwId": lambda x: "REDACTED_" + x[9::], } diff --git a/kasa/protocols/protocol.py b/kasa/protocols/protocol.py index 211a7b5ae..fb09b8828 100755 --- a/kasa/protocols/protocol.py +++ b/kasa/protocols/protocol.py @@ -66,6 +66,8 @@ def redact_data(data: _T, redactors: dict[str, Callable[[Any], Any] | None]) -> def mask_mac(mac: str) -> str: """Return mac address with last two octects blanked.""" + if len(mac) == 12: + return f"{mac[:6]}000000" delim = ":" if ":" in mac else "-" rest = delim.join(format(s, "02x") for s in bytes.fromhex("000000")) return f"{mac[:8]}{delim}{rest}" diff --git a/kasa/protocols/smartprotocol.py b/kasa/protocols/smartprotocol.py index 80e76ca6e..0e092547f 100644 --- a/kasa/protocols/smartprotocol.py +++ b/kasa/protocols/smartprotocol.py @@ -9,6 +9,7 @@ import asyncio import base64 import logging +import re import time import uuid from collections.abc import Callable @@ -45,15 +46,27 @@ "original_device_id": lambda x: "REDACTED_" + x[9::], # Strip children "nickname": lambda x: "I01BU0tFRF9OQU1FIw==" if x else "", "mac": mask_mac, - "ssid": lambda x: "I01BU0tFRF9TU0lEIw=" if x else "", + "ssid": lambda x: "I01BU0tFRF9TU0lEIw==" if x else "", "bssid": lambda _: "000000000000", + "channel": lambda _: 0, "oem_id": lambda x: "REDACTED_" + x[9::], - "setup_code": None, # matter - "setup_payload": None, # matter - "mfi_setup_code": None, # mfi_ for homekit - "mfi_setup_id": None, - "mfi_token_token": None, - "mfi_token_uuid": None, + "setup_code": lambda x: re.sub(r"\w", "0", x), # matter + "setup_payload": lambda x: re.sub(r"\w", "0", x), # matter + "mfi_setup_code": lambda x: re.sub(r"\w", "0", x), # mfi_ for homekit + "mfi_setup_id": lambda x: re.sub(r"\w", "0", x), + "mfi_token_token": lambda x: re.sub(r"\w", "0", x), + "mfi_token_uuid": lambda x: re.sub(r"\w", "0", x), + "ip": lambda x: x, # don't redact but keep listed here for dump_devinfo + # smartcam + "dev_id": lambda x: "REDACTED_" + x[9::], + "device_name": lambda x: "#MASKED_NAME#" if x else "", + "device_alias": lambda x: "#MASKED_NAME#" if x else "", + "local_ip": lambda x: x, # don't redact but keep listed here for dump_devinfo + # robovac + "board_sn": lambda _: "000000000000", + "custom_sn": lambda _: "000000000000", + "location": lambda x: "#MASKED_NAME#" if x else "", + "map_data": lambda x: "#SCRUBBED_MAPDATA#" if x else "", } diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index b1756572b..dd35cf8f0 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -435,7 +435,7 @@ async def get_device_for_fixture( discovery_data = None if "discovery_result" in fixture_data.data: - discovery_data = fixture_data.data["discovery_result"] + discovery_data = fixture_data.data["discovery_result"]["result"] elif "system" in fixture_data.data: discovery_data = { "system": {"get_sysinfo": fixture_data.data["system"]["get_sysinfo"]} diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index 939215365..87541effe 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -139,7 +139,8 @@ def parametrize_discovery( ) async def discovery_mock(request, mocker): """Mock discovery and patch protocol queries to use Fake protocols.""" - fixture_info: FixtureInfo = request.param + fi: FixtureInfo = request.param + fixture_info = FixtureInfo(fi.name, fi.protocol, copy.deepcopy(fi.data)) return patch_discovery({DISCOVERY_MOCK_IP: fixture_info}, mocker) @@ -170,8 +171,8 @@ def _datagram(self) -> bytes: ) if "discovery_result" in fixture_data: - discovery_data = {"result": fixture_data["discovery_result"].copy()} - discovery_result = fixture_data["discovery_result"] + discovery_data = fixture_data["discovery_result"].copy() + discovery_result = fixture_data["discovery_result"]["result"] device_type = discovery_result["device_type"] encrypt_type = discovery_result["mgt_encrypt_schm"].get( "encrypt_type", discovery_result.get("encrypt_info", {}).get("sym_schm") @@ -305,7 +306,7 @@ def discovery_data(request, mocker): mocker.patch("kasa.IotProtocol.query", return_value=fixture_data) mocker.patch("kasa.SmartProtocol.query", return_value=fixture_data) if "discovery_result" in fixture_data: - return {"result": fixture_data["discovery_result"]} + return fixture_data["discovery_result"].copy() else: return {"system": {"get_sysinfo": fixture_data["system"]["get_sysinfo"]}} diff --git a/tests/fixtures/iot/EP10(US)_1.0_1.0.2.json b/tests/fixtures/iot/EP10(US)_1.0_1.0.2.json index e40543d6b..11cafb870 100644 --- a/tests/fixtures/iot/EP10(US)_1.0_1.0.2.json +++ b/tests/fixtures/iot/EP10(US)_1.0_1.0.2.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "167 lamp", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/EP40(US)_1.0_1.0.2.json b/tests/fixtures/iot/EP40(US)_1.0_1.0.2.json index 238265a2a..5be97e874 100644 --- a/tests/fixtures/iot/EP40(US)_1.0_1.0.2.json +++ b/tests/fixtures/iot/EP40(US)_1.0_1.0.2.json @@ -1,12 +1,12 @@ { "system": { "get_sysinfo": { - "alias": "TP-LINK_Smart Plug_004F", + "alias": "#MASKED_NAME#", "child_num": 2, "children": [ { - "alias": "Zombie", - "id": "8006231E1499BAC4D4BC7EFCD4B075181E6393F200", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 0 }, { - "alias": "Magic", - "id": "8006231E1499BAC4D4BC7EFCD4B075181E6393F201", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/ES20M(US)_1.0_1.0.11.json b/tests/fixtures/iot/ES20M(US)_1.0_1.0.11.json index 99ecdaa57..6d15034f1 100644 --- a/tests/fixtures/iot/ES20M(US)_1.0_1.0.11.json +++ b/tests/fixtures/iot/ES20M(US)_1.0_1.0.11.json @@ -11,7 +11,7 @@ "stopConnect": 0, "tcspInfo": "", "tcspStatus": 1, - "username": "#MASKED_NAME#" + "username": "user@example.com" }, "get_intl_fw_list": { "err_code": 0, diff --git a/tests/fixtures/iot/ES20M(US)_1.0_1.0.8.json b/tests/fixtures/iot/ES20M(US)_1.0_1.0.8.json index bb316b830..e28301d5a 100644 --- a/tests/fixtures/iot/ES20M(US)_1.0_1.0.8.json +++ b/tests/fixtures/iot/ES20M(US)_1.0_1.0.8.json @@ -78,7 +78,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test ES20M", + "alias": "#MASKED_NAME#", "brightness": 35, "dev_name": "Wi-Fi Smart Dimmer with sensor", "deviceId": "0000000000000000000000000000000000000000", diff --git a/tests/fixtures/iot/HS100(UK)_4.1_1.1.0.json b/tests/fixtures/iot/HS100(UK)_4.1_1.1.0.json index 6e33fd7dc..324e193a7 100644 --- a/tests/fixtures/iot/HS100(UK)_4.1_1.1.0.json +++ b/tests/fixtures/iot/HS100(UK)_4.1_1.1.0.json @@ -1,18 +1,21 @@ { "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "HS100(UK)", - "device_type": "IOT.SMARTPLUGSWITCH", - "factory_default": true, - "hw_ver": "4.1", - "ip": "127.0.0.123", - "mac": "CC-32-E5-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false - }, - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "HS100(UK)", + "device_type": "IOT.SMARTPLUGSWITCH", + "factory_default": true, + "hw_ver": "4.1", + "ip": "127.0.0.123", + "mac": "CC-32-E5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false + }, + "owner": "00000000000000000000000000000000" + } }, "system": { "get_sysinfo": { diff --git a/tests/fixtures/iot/HS100(US)_1.0_1.2.5.json b/tests/fixtures/iot/HS100(US)_1.0_1.2.5.json index 1bbe29d4c..1f2cad626 100644 --- a/tests/fixtures/iot/HS100(US)_1.0_1.2.5.json +++ b/tests/fixtures/iot/HS100(US)_1.0_1.2.5.json @@ -18,7 +18,7 @@ "system": { "get_sysinfo": { "active_mode": "schedule", - "alias": "Unused 3", + "alias": "#MASKED_NAME#", "dev_name": "Wi-Fi Smart Plug", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS100(US)_2.0_1.5.6.json b/tests/fixtures/iot/HS100(US)_2.0_1.5.6.json index 03dd42d57..f73d62331 100644 --- a/tests/fixtures/iot/HS100(US)_2.0_1.5.6.json +++ b/tests/fixtures/iot/HS100(US)_2.0_1.5.6.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "3D Printer", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS103(US)_1.0_1.5.7.json b/tests/fixtures/iot/HS103(US)_1.0_1.5.7.json index e5928c3dc..ec388dd33 100644 --- a/tests/fixtures/iot/HS103(US)_1.0_1.5.7.json +++ b/tests/fixtures/iot/HS103(US)_1.0_1.5.7.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "schedule", - "alias": "Night lite", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Lite", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS103(US)_2.1_1.1.2.json b/tests/fixtures/iot/HS103(US)_2.1_1.1.2.json index 664845f6a..a9064ac74 100644 --- a/tests/fixtures/iot/HS103(US)_2.1_1.1.2.json +++ b/tests/fixtures/iot/HS103(US)_2.1_1.1.2.json @@ -18,7 +18,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Corner", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Lite", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS103(US)_2.1_1.1.4.json b/tests/fixtures/iot/HS103(US)_2.1_1.1.4.json index 819c5bdd6..cf7cb9355 100644 --- a/tests/fixtures/iot/HS103(US)_2.1_1.1.4.json +++ b/tests/fixtures/iot/HS103(US)_2.1_1.1.4.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Plug", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Lite", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS105(US)_1.0_1.5.6.json b/tests/fixtures/iot/HS105(US)_1.0_1.5.6.json index 796910043..a84c0f49b 100644 --- a/tests/fixtures/iot/HS105(US)_1.0_1.5.6.json +++ b/tests/fixtures/iot/HS105(US)_1.0_1.5.6.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Unused 1", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS107(US)_1.0_1.0.8.json b/tests/fixtures/iot/HS107(US)_1.0_1.0.8.json index 046a89e97..ddc61ef80 100644 --- a/tests/fixtures/iot/HS107(US)_1.0_1.0.8.json +++ b/tests/fixtures/iot/HS107(US)_1.0_1.0.8.json @@ -17,12 +17,12 @@ }, "system": { "get_sysinfo": { - "alias": "TP-LINK_Smart Plug_D310", + "alias": "#MASKED_NAME#", "child_num": 2, "children": [ { - "alias": "Garage Charger 1", - "id": "00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -30,8 +30,8 @@ "state": 0 }, { - "alias": "Garage Charger 2", - "id": "01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -46,7 +46,7 @@ "hw_ver": "1.0", "latitude_i": 0, "led_off": 0, - "longitude_i": -0, + "longitude_i": 0, "mac": "00:00:00:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "HS107(US)", diff --git a/tests/fixtures/iot/HS110(EU)_1.0_1.2.5.json b/tests/fixtures/iot/HS110(EU)_1.0_1.2.5.json index 99cba2880..e75b18bc5 100644 --- a/tests/fixtures/iot/HS110(EU)_1.0_1.2.5.json +++ b/tests/fixtures/iot/HS110(EU)_1.0_1.2.5.json @@ -11,7 +11,7 @@ "system": { "get_sysinfo": { "active_mode": "schedule", - "alias": "Bedroom Lamp Plug", + "alias": "#MASKED_NAME#", "dev_name": "Wi-Fi Smart Plug With Energy Monitoring", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS110(US)_1.0_1.2.6.json b/tests/fixtures/iot/HS110(US)_1.0_1.2.6.json index 5e285e729..cf5ac0654 100644 --- a/tests/fixtures/iot/HS110(US)_1.0_1.2.6.json +++ b/tests/fixtures/iot/HS110(US)_1.0_1.2.6.json @@ -11,7 +11,7 @@ "system": { "get_sysinfo": { "active_mode": "schedule", - "alias": "Home Google WiFi HS110", + "alias": "#MASKED_NAME#", "dev_name": "Wi-Fi Smart Plug With Energy Monitoring", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS200(US)_2.0_1.5.7.json b/tests/fixtures/iot/HS200(US)_2.0_1.5.7.json index 2fbcc65cb..31e4a5f90 100644 --- a/tests/fixtures/iot/HS200(US)_2.0_1.5.7.json +++ b/tests/fixtures/iot/HS200(US)_2.0_1.5.7.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Master Bedroom Fan", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Light Switch", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS200(US)_5.0_1.0.2.json b/tests/fixtures/iot/HS200(US)_5.0_1.0.2.json index fc09e6f55..44370f2ed 100644 --- a/tests/fixtures/iot/HS200(US)_5.0_1.0.2.json +++ b/tests/fixtures/iot/HS200(US)_5.0_1.0.2.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "House Fan", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Light Switch", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS210(US)_1.0_1.5.8.json b/tests/fixtures/iot/HS210(US)_1.0_1.5.8.json index ced3e8914..b286c53f2 100644 --- a/tests/fixtures/iot/HS210(US)_1.0_1.5.8.json +++ b/tests/fixtures/iot/HS210(US)_1.0_1.5.8.json @@ -21,7 +21,7 @@ "get_sysinfo": { "abnormal_detect": 1, "active_mode": "none", - "alias": "Garage Light", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi 3-Way Light Switch", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS220(US)_1.0_1.5.7.json b/tests/fixtures/iot/HS220(US)_1.0_1.5.7.json index eef806fb4..3826d198d 100644 --- a/tests/fixtures/iot/HS220(US)_1.0_1.5.7.json +++ b/tests/fixtures/iot/HS220(US)_1.0_1.5.7.json @@ -28,7 +28,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Living Room Dimmer Switch", + "alias": "#MASKED_NAME#", "brightness": 25, "dev_name": "Smart Wi-Fi Dimmer", "deviceId": "000000000000000000000000000000000000000", @@ -38,9 +38,9 @@ "hwId": "00000000000000000000000000000000", "hw_ver": "1.0", "icon_hash": "", - "latitude_i": 11.6210, + "latitude_i": 0, "led_off": 0, - "longitude_i": 42.2074, + "longitude_i": 0, "mac": "00:00:00:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "HS220(US)", diff --git a/tests/fixtures/iot/HS220(US)_2.0_1.0.3.json b/tests/fixtures/iot/HS220(US)_2.0_1.0.3.json index 61e3d84e7..d7d0a5a24 100644 --- a/tests/fixtures/iot/HS220(US)_2.0_1.0.3.json +++ b/tests/fixtures/iot/HS220(US)_2.0_1.0.3.json @@ -17,7 +17,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Living Room Dimmer Switch", + "alias": "#MASKED_NAME#", "brightness": 100, "dev_name": "Wi-Fi Smart Dimmer", "deviceId": "0000000000000000000000000000000000000000", diff --git a/tests/fixtures/iot/HS300(US)_1.0_1.0.10.json b/tests/fixtures/iot/HS300(US)_1.0_1.0.10.json index a6d34957d..0fc22a399 100644 --- a/tests/fixtures/iot/HS300(US)_1.0_1.0.10.json +++ b/tests/fixtures/iot/HS300(US)_1.0_1.0.10.json @@ -22,12 +22,12 @@ }, "system": { "get_sysinfo": { - "alias": "TP-LINK_Power Strip_DAE1", + "alias": "#MASKED_NAME#", "child_num": 6, "children": [ { - "alias": "Office Monitor 1", - "id": "00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -35,8 +35,8 @@ "state": 0 }, { - "alias": "Office Monitor 2", - "id": "01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -44,8 +44,8 @@ "state": 0 }, { - "alias": "Office Monitor 3", - "id": "02", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, @@ -53,8 +53,8 @@ "state": 0 }, { - "alias": "Office Laptop Dock", - "id": "03", + "alias": "#MASKED_NAME# 4", + "id": "SCRUBBED_CHILD_DEVICE_ID_4", "next_action": { "type": -1 }, @@ -62,8 +62,8 @@ "state": 0 }, { - "alias": "Office Desk Light", - "id": "04", + "alias": "#MASKED_NAME# 5", + "id": "SCRUBBED_CHILD_DEVICE_ID_5", "next_action": { "type": -1 }, @@ -71,8 +71,8 @@ "state": 0 }, { - "alias": "Laptop", - "id": "05", + "alias": "#MASKED_NAME# 6", + "id": "SCRUBBED_CHILD_DEVICE_ID_6", "next_action": { "type": -1 }, @@ -87,7 +87,7 @@ "hw_ver": "1.0", "latitude_i": 0, "led_off": 0, - "longitude_i": -0, + "longitude_i": 0, "mac": "00:00:00:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "HS300(US)", diff --git a/tests/fixtures/iot/HS300(US)_1.0_1.0.21.json b/tests/fixtures/iot/HS300(US)_1.0_1.0.21.json index 388fadf35..a174027ca 100644 --- a/tests/fixtures/iot/HS300(US)_1.0_1.0.21.json +++ b/tests/fixtures/iot/HS300(US)_1.0_1.0.21.json @@ -10,12 +10,12 @@ }, "system": { "get_sysinfo": { - "alias": "TP-LINK_Power Strip_2CA9", + "alias": "#MASKED_NAME#", "child_num": 6, "children": [ { - "alias": "Home CameraPC", - "id": "800623145DFF1AA096363EFD161C2E661A9D8DED00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -23,8 +23,8 @@ "state": 1 }, { - "alias": "Home Firewalla", - "id": "800623145DFF1AA096363EFD161C2E661A9D8DED01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -32,8 +32,8 @@ "state": 1 }, { - "alias": "Home Cox modem", - "id": "800623145DFF1AA096363EFD161C2E661A9D8DED02", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, @@ -41,8 +41,8 @@ "state": 1 }, { - "alias": "Home rpi3-2", - "id": "800623145DFF1AA096363EFD161C2E661A9D8DED03", + "alias": "#MASKED_NAME# 4", + "id": "SCRUBBED_CHILD_DEVICE_ID_4", "next_action": { "type": -1 }, @@ -50,8 +50,8 @@ "state": 1 }, { - "alias": "Home Camera Switch", - "id": "800623145DFF1AA096363EFD161C2E661A9D8DED05", + "alias": "#MASKED_NAME# 5", + "id": "SCRUBBED_CHILD_DEVICE_ID_5", "next_action": { "type": -1 }, @@ -59,8 +59,8 @@ "state": 1 }, { - "alias": "Home Network Switch", - "id": "800623145DFF1AA096363EFD161C2E661A9D8DED04", + "alias": "#MASKED_NAME# 6", + "id": "SCRUBBED_CHILD_DEVICE_ID_6", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/HS300(US)_2.0_1.0.12.json b/tests/fixtures/iot/HS300(US)_2.0_1.0.12.json index bdab432e2..bca720892 100644 --- a/tests/fixtures/iot/HS300(US)_2.0_1.0.12.json +++ b/tests/fixtures/iot/HS300(US)_2.0_1.0.12.json @@ -15,8 +15,8 @@ "child_num": 6, "children": [ { - "alias": "#MASKED_NAME#", - "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -24,8 +24,8 @@ "state": 1 }, { - "alias": "#MASKED_NAME#", - "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -33,8 +33,8 @@ "state": 1 }, { - "alias": "#MASKED_NAME#", - "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D02", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, @@ -42,8 +42,8 @@ "state": 1 }, { - "alias": "#MASKED_NAME#", - "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D03", + "alias": "#MASKED_NAME# 4", + "id": "SCRUBBED_CHILD_DEVICE_ID_4", "next_action": { "type": -1 }, @@ -51,8 +51,8 @@ "state": 1 }, { - "alias": "#MASKED_NAME#", - "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D04", + "alias": "#MASKED_NAME# 5", + "id": "SCRUBBED_CHILD_DEVICE_ID_5", "next_action": { "type": -1 }, @@ -60,8 +60,8 @@ "state": 1 }, { - "alias": "#MASKED_NAME#", - "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D05", + "alias": "#MASKED_NAME# 6", + "id": "SCRUBBED_CHILD_DEVICE_ID_6", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/HS300(US)_2.0_1.0.3.json b/tests/fixtures/iot/HS300(US)_2.0_1.0.3.json index 3b99cf36e..8a5b22c46 100644 --- a/tests/fixtures/iot/HS300(US)_2.0_1.0.3.json +++ b/tests/fixtures/iot/HS300(US)_2.0_1.0.3.json @@ -11,12 +11,12 @@ }, "system": { "get_sysinfo": { - "alias": "TP-LINK_Power Strip_5C33", + "alias": "#MASKED_NAME#", "child_num": 6, "children": [ { - "alias": "Plug 1", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031900", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -24,8 +24,8 @@ "state": 0 }, { - "alias": "Plug 2", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031901", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -33,8 +33,8 @@ "state": 0 }, { - "alias": "Plug 3", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031902", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, @@ -42,8 +42,8 @@ "state": 0 }, { - "alias": "Plug 4", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031903", + "alias": "#MASKED_NAME# 4", + "id": "SCRUBBED_CHILD_DEVICE_ID_4", "next_action": { "type": -1 }, @@ -51,8 +51,8 @@ "state": 0 }, { - "alias": "Plug 5", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031904", + "alias": "#MASKED_NAME# 5", + "id": "SCRUBBED_CHILD_DEVICE_ID_5", "next_action": { "type": -1 }, @@ -60,8 +60,8 @@ "state": 0 }, { - "alias": "Plug 6", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031905", + "alias": "#MASKED_NAME# 6", + "id": "SCRUBBED_CHILD_DEVICE_ID_6", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KL110(US)_1.0_1.8.11.json b/tests/fixtures/iot/KL110(US)_1.0_1.8.11.json index 94c388580..89b623bdf 100644 --- a/tests/fixtures/iot/KL110(US)_1.0_1.8.11.json +++ b/tests/fixtures/iot/KL110(US)_1.0_1.8.11.json @@ -21,7 +21,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Bulb3", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL120(US)_1.0_1.8.11.json b/tests/fixtures/iot/KL120(US)_1.0_1.8.11.json index 1d8e1fce9..0bbc9886b 100644 --- a/tests/fixtures/iot/KL120(US)_1.0_1.8.11.json +++ b/tests/fixtures/iot/KL120(US)_1.0_1.8.11.json @@ -19,7 +19,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Home Family Room Table", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL120(US)_1.0_1.8.6.json b/tests/fixtures/iot/KL120(US)_1.0_1.8.6.json index c251f2fa6..50bd202ee 100644 --- a/tests/fixtures/iot/KL120(US)_1.0_1.8.6.json +++ b/tests/fixtures/iot/KL120(US)_1.0_1.8.6.json @@ -34,11 +34,11 @@ }, "description": "Smart Wi-Fi LED Bulb with Tunable White Light", "dev_state": "normal", - "deviceId": "801200814AD69370AC59DE5501319C051AF409C3", + "deviceId": "0000000000000000000000000000000000000000", "disco_ver": "1.0", "err_code": 0, "heapsize": 290784, - "hwId": "111E35908497A05512E259BB76801E10", + "hwId": "00000000000000000000000000000000", "hw_ver": "1.0", "is_color": 0, "is_dimmable": 1, @@ -52,10 +52,10 @@ "on_off": 1, "saturation": 0 }, - "mic_mac": "D80D17150474", + "mic_mac": "D80D17000000", "mic_type": "IOT.SMARTBULB", "model": "KL120(US)", - "oemId": "1210657CD7FBDC72895644388EEFAE8B", + "oemId": "00000000000000000000000000000000", "preferred_state": [ { "brightness": 100, diff --git a/tests/fixtures/iot/KL125(US)_1.20_1.0.5.json b/tests/fixtures/iot/KL125(US)_1.20_1.0.5.json index 1fca69246..aedcb1f68 100644 --- a/tests/fixtures/iot/KL125(US)_1.20_1.0.5.json +++ b/tests/fixtures/iot/KL125(US)_1.20_1.0.5.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "kasa-bc01", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL125(US)_2.0_1.0.7.json b/tests/fixtures/iot/KL125(US)_2.0_1.0.7.json index b7fa640bf..9d19ca576 100644 --- a/tests/fixtures/iot/KL125(US)_2.0_1.0.7.json +++ b/tests/fixtures/iot/KL125(US)_2.0_1.0.7.json @@ -22,7 +22,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test bulb 6", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL130(EU)_1.0_1.8.8.json b/tests/fixtures/iot/KL130(EU)_1.0_1.8.8.json index f15e3602d..ce3034629 100644 --- a/tests/fixtures/iot/KL130(EU)_1.0_1.8.8.json +++ b/tests/fixtures/iot/KL130(EU)_1.0_1.8.8.json @@ -11,7 +11,7 @@ "stopConnect": 0, "tcspInfo": "", "tcspStatus": 1, - "username": "#MASKED_NAME#" + "username": "user@example.com" }, "get_intl_fw_list": { "err_code": 0, diff --git a/tests/fixtures/iot/KL130(US)_1.0_1.8.11.json b/tests/fixtures/iot/KL130(US)_1.0_1.8.11.json index 3ee4cb2e7..d9eaaca16 100644 --- a/tests/fixtures/iot/KL130(US)_1.0_1.8.11.json +++ b/tests/fixtures/iot/KL130(US)_1.0_1.8.11.json @@ -21,7 +21,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Bulb2", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL135(US)_1.0_1.0.15.json b/tests/fixtures/iot/KL135(US)_1.0_1.0.15.json index b6670a7ae..38a8805d0 100644 --- a/tests/fixtures/iot/KL135(US)_1.0_1.0.15.json +++ b/tests/fixtures/iot/KL135(US)_1.0_1.0.15.json @@ -11,7 +11,7 @@ "stopConnect": 0, "tcspInfo": "", "tcspStatus": 1, - "username": "#MASKED_NAME#" + "username": "user@example.com" }, "get_intl_fw_list": { "err_code": 0, diff --git a/tests/fixtures/iot/KL135(US)_1.0_1.0.6.json b/tests/fixtures/iot/KL135(US)_1.0_1.0.6.json index dc0ef45ab..be34f9c5b 100644 --- a/tests/fixtures/iot/KL135(US)_1.0_1.0.6.json +++ b/tests/fixtures/iot/KL135(US)_1.0_1.0.6.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "KL135 Bulb", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json index 64adf5555..1bcd088b7 100644 --- a/tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json +++ b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 0, "active_mode": "none", - "alias": "Kl400", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL400L5(US)_1.0_1.0.8.json b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.8.json index a737cd2a1..6a15c16c3 100644 --- a/tests/fixtures/iot/KL400L5(US)_1.0_1.0.8.json +++ b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.8.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 0, "active_mode": "none", - "alias": "Kl400", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL420L5(US)_1.0_1.0.2.json b/tests/fixtures/iot/KL420L5(US)_1.0_1.0.2.json index 0d19e7949..2d16adba5 100644 --- a/tests/fixtures/iot/KL420L5(US)_1.0_1.0.2.json +++ b/tests/fixtures/iot/KL420L5(US)_1.0_1.0.2.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 1, "active_mode": "none", - "alias": "Kl420 test", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL430(UN)_2.0_1.0.8.json b/tests/fixtures/iot/KL430(UN)_2.0_1.0.8.json index a956575be..8a924c197 100644 --- a/tests/fixtures/iot/KL430(UN)_2.0_1.0.8.json +++ b/tests/fixtures/iot/KL430(UN)_2.0_1.0.8.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 1, "active_mode": "none", - "alias": "Bedroom light strip", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL430(US)_1.0_1.0.10.json b/tests/fixtures/iot/KL430(US)_1.0_1.0.10.json index 9b6d84136..5bda57627 100644 --- a/tests/fixtures/iot/KL430(US)_1.0_1.0.10.json +++ b/tests/fixtures/iot/KL430(US)_1.0_1.0.10.json @@ -23,7 +23,7 @@ "system": { "get_sysinfo": { "active_mode": "schedule", - "alias": "Bedroom Lightstrip", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL430(US)_2.0_1.0.11.json b/tests/fixtures/iot/KL430(US)_2.0_1.0.11.json index f39c55193..380250ff3 100644 --- a/tests/fixtures/iot/KL430(US)_2.0_1.0.11.json +++ b/tests/fixtures/iot/KL430(US)_2.0_1.0.11.json @@ -11,7 +11,7 @@ "stopConnect": 0, "tcspInfo": "", "tcspStatus": 1, - "username": "#MASKED_NAME#" + "username": "user@example.com" }, "get_intl_fw_list": { "err_code": 0, diff --git a/tests/fixtures/iot/KL430(US)_2.0_1.0.8.json b/tests/fixtures/iot/KL430(US)_2.0_1.0.8.json index e69a9dc1f..c5cf550bd 100644 --- a/tests/fixtures/iot/KL430(US)_2.0_1.0.8.json +++ b/tests/fixtures/iot/KL430(US)_2.0_1.0.8.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 1, "active_mode": "none", - "alias": "89 strip", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL430(US)_2.0_1.0.9.json b/tests/fixtures/iot/KL430(US)_2.0_1.0.9.json index d5f2eafbc..2d9f7535f 100644 --- a/tests/fixtures/iot/KL430(US)_2.0_1.0.9.json +++ b/tests/fixtures/iot/KL430(US)_2.0_1.0.9.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 1, "active_mode": "none", - "alias": "kl430 updated", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL50(US)_1.0_1.1.13.json b/tests/fixtures/iot/KL50(US)_1.0_1.1.13.json index f3e43c9a5..6e30c136d 100644 --- a/tests/fixtures/iot/KL50(US)_1.0_1.1.13.json +++ b/tests/fixtures/iot/KL50(US)_1.0_1.1.13.json @@ -22,7 +22,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Kl50", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL60(UN)_1.0_1.1.4.json b/tests/fixtures/iot/KL60(UN)_1.0_1.1.4.json index fa842b47c..22dadaee2 100644 --- a/tests/fixtures/iot/KL60(UN)_1.0_1.1.4.json +++ b/tests/fixtures/iot/KL60(UN)_1.0_1.1.4.json @@ -32,7 +32,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "TP-LINK_Smart Bulb_9179", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" @@ -60,7 +60,7 @@ "on_off": 0 }, "longitude_i": 0, - "mic_mac": "74DA88C89179", + "mic_mac": "74DA88000000", "mic_type": "IOT.SMARTBULB", "model": "KL60(UN)", "oemId": "00000000000000000000000000000000", diff --git a/tests/fixtures/iot/KL60(US)_1.0_1.1.13.json b/tests/fixtures/iot/KL60(US)_1.0_1.1.13.json index e52cb85c5..6834d925d 100644 --- a/tests/fixtures/iot/KL60(US)_1.0_1.1.13.json +++ b/tests/fixtures/iot/KL60(US)_1.0_1.1.13.json @@ -22,7 +22,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Gold fil", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KP100(US)_3.0_1.0.1.json b/tests/fixtures/iot/KP100(US)_3.0_1.0.1.json index fb62654b5..46e9ec4ee 100644 --- a/tests/fixtures/iot/KP100(US)_3.0_1.0.1.json +++ b/tests/fixtures/iot/KP100(US)_3.0_1.0.1.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Kasa", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/KP105(UK)_1.0_1.0.5.json b/tests/fixtures/iot/KP105(UK)_1.0_1.0.5.json index ce1943752..91e310d3c 100644 --- a/tests/fixtures/iot/KP105(UK)_1.0_1.0.5.json +++ b/tests/fixtures/iot/KP105(UK)_1.0_1.0.5.json @@ -11,7 +11,7 @@ "stopConnect": 0, "tcspInfo": "", "tcspStatus": 1, - "username": "#MASKED_NAME#" + "username": "user@example.com" }, "get_intl_fw_list": { "err_code": -7, diff --git a/tests/fixtures/iot/KP115(US)_1.0_1.0.17.json b/tests/fixtures/iot/KP115(US)_1.0_1.0.17.json index afb5a5fe4..fb5efac81 100644 --- a/tests/fixtures/iot/KP115(US)_1.0_1.0.17.json +++ b/tests/fixtures/iot/KP115(US)_1.0_1.0.17.json @@ -11,7 +11,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test plug", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/KP125(US)_1.0_1.0.6.json b/tests/fixtures/iot/KP125(US)_1.0_1.0.6.json index cb32e7c6c..2bb0d21e3 100644 --- a/tests/fixtures/iot/KP125(US)_1.0_1.0.6.json +++ b/tests/fixtures/iot/KP125(US)_1.0_1.0.6.json @@ -11,7 +11,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test plug", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/KP200(US)_3.0_1.0.3.json b/tests/fixtures/iot/KP200(US)_3.0_1.0.3.json index fef495d65..40a57fd5e 100644 --- a/tests/fixtures/iot/KP200(US)_3.0_1.0.3.json +++ b/tests/fixtures/iot/KP200(US)_3.0_1.0.3.json @@ -1,12 +1,12 @@ { "system": { "get_sysinfo": { - "alias": "TP-LINK_Smart Plug_C2D6", + "alias": "#MASKED_NAME#", "child_num": 2, "children": [ { - "alias": "One ", - "id": "80066788DFFFD572D9F2E4A5A6847669213E039F00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 1 }, { - "alias": "Two ", - "id": "80066788DFFFD572D9F2E4A5A6847669213E039F01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP303(UK)_1.0_1.0.3.json b/tests/fixtures/iot/KP303(UK)_1.0_1.0.3.json index d02d766b6..b5c6a1050 100644 --- a/tests/fixtures/iot/KP303(UK)_1.0_1.0.3.json +++ b/tests/fixtures/iot/KP303(UK)_1.0_1.0.3.json @@ -1,12 +1,12 @@ { "system": { "get_sysinfo": { - "alias": "Bedroom Power Strip", + "alias": "#MASKED_NAME#", "child_num": 3, "children": [ { - "alias": "Plug 1", - "id": "8006E9854025B67C3F9D99BA1E66223D1C9A8A7700", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 1 }, { - "alias": "Plug 2", - "id": "8006E9854025B67C3F9D99BA1E66223D1C9A8A7701", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -23,8 +23,8 @@ "state": 0 }, { - "alias": "Plug 3", - "id": "8006E9854025B67C3F9D99BA1E66223D1C9A8A7702", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP303(US)_2.0_1.0.3.json b/tests/fixtures/iot/KP303(US)_2.0_1.0.3.json index 96c2f8c96..a95905579 100644 --- a/tests/fixtures/iot/KP303(US)_2.0_1.0.3.json +++ b/tests/fixtures/iot/KP303(US)_2.0_1.0.3.json @@ -1,12 +1,12 @@ { "system": { "get_sysinfo": { - "alias": "TP-LINK_Power Strip_BDF6", + "alias": "#MASKED_NAME#", "child_num": 3, "children": [ { - "alias": "Plug 1", - "id": "800681855E0E9AEF096F4891B3DC88C71E59F42E00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 0 }, { - "alias": "Plug 2", - "id": "800681855E0E9AEF096F4891B3DC88C71E59F42E01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -23,8 +23,8 @@ "state": 0 }, { - "alias": "Plug 3", - "id": "800681855E0E9AEF096F4891B3DC88C71E59F42E02", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP303(US)_2.0_1.0.9.json b/tests/fixtures/iot/KP303(US)_2.0_1.0.9.json index d500ebb8f..333df3f6c 100644 --- a/tests/fixtures/iot/KP303(US)_2.0_1.0.9.json +++ b/tests/fixtures/iot/KP303(US)_2.0_1.0.9.json @@ -5,8 +5,8 @@ "child_num": 3, "children": [ { - "alias": "#MASKED_NAME#", - "id": "800639AA097730E58235162FCDA301CE1F038F9101", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 1 }, { - "alias": "#MASKED_NAME#", - "id": "800639AA097730E58235162FCDA301CE1F038F9102", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -23,8 +23,8 @@ "state": 0 }, { - "alias": "#MASKED_NAME#", - "id": "800639AA097730E58235162FCDA301CE1F038F9100", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP400(US)_1.0_1.0.10.json b/tests/fixtures/iot/KP400(US)_1.0_1.0.10.json index afdb7bfcd..cd09a434c 100644 --- a/tests/fixtures/iot/KP400(US)_1.0_1.0.10.json +++ b/tests/fixtures/iot/KP400(US)_1.0_1.0.10.json @@ -17,12 +17,12 @@ }, "system": { "get_sysinfo": { - "alias": "TP-LINK_Smart Plug_2ECE", + "alias": "#MASKED_NAME#", "child_num": 2, "children": [ { - "alias": "Rope", - "id": "00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "action": 1, "schd_sec": 69240, @@ -32,8 +32,8 @@ "state": 0 }, { - "alias": "Plug 2", - "id": "01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP400(US)_2.0_1.0.6.json b/tests/fixtures/iot/KP400(US)_2.0_1.0.6.json index 23cd22d11..3f838a91c 100644 --- a/tests/fixtures/iot/KP400(US)_2.0_1.0.6.json +++ b/tests/fixtures/iot/KP400(US)_2.0_1.0.6.json @@ -1,12 +1,12 @@ { "system": { "get_sysinfo": { - "alias": "TP-LINK_Smart Plug_DC2A", + "alias": "#MASKED_NAME#", "child_num": 2, "children": [ { - "alias": "Anc ", - "id": "8006B8E953CC4149E2B13AA27E0D18EF1DCFBF3400", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 1 }, { - "alias": "Plug 2", - "id": "8006B8E953CC4149E2B13AA27E0D18EF1DCFBF3401", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP400(US)_3.0_1.0.3.json b/tests/fixtures/iot/KP400(US)_3.0_1.0.3.json index e93eea8f8..ec1c37f36 100644 --- a/tests/fixtures/iot/KP400(US)_3.0_1.0.3.json +++ b/tests/fixtures/iot/KP400(US)_3.0_1.0.3.json @@ -5,8 +5,8 @@ "child_num": 2, "children": [ { - "alias": "#MASKED_NAME#", - "id": "8006521377E30159055A751347B5A5E321A8D0A100", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 1 }, { - "alias": "#MASKED_NAME#", - "id": "8006521377E30159055A751347B5A5E321A8D0A101", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP400(US)_3.0_1.0.4.json b/tests/fixtures/iot/KP400(US)_3.0_1.0.4.json index 18580f4ea..5a60a4003 100644 --- a/tests/fixtures/iot/KP400(US)_3.0_1.0.4.json +++ b/tests/fixtures/iot/KP400(US)_3.0_1.0.4.json @@ -5,8 +5,8 @@ "child_num": 2, "children": [ { - "alias": "#MASKED_NAME#", - "id": "8006521377E30159055A751347B5A5E321A8D0A100", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 0 }, { - "alias": "#MASKED_NAME#", - "id": "8006521377E30159055A751347B5A5E321A8D0A101", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP401(US)_1.0_1.0.0.json b/tests/fixtures/iot/KP401(US)_1.0_1.0.0.json index 644c4e5f4..f3006cf49 100644 --- a/tests/fixtures/iot/KP401(US)_1.0_1.0.0.json +++ b/tests/fixtures/iot/KP401(US)_1.0_1.0.0.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Kp401", + "alias": "#MASKED_NAME#", "dev_name": "Smart Outdoor Plug", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/KP405(US)_1.0_1.0.5.json b/tests/fixtures/iot/KP405(US)_1.0_1.0.5.json index ad6357f3c..806bdc27b 100644 --- a/tests/fixtures/iot/KP405(US)_1.0_1.0.5.json +++ b/tests/fixtures/iot/KP405(US)_1.0_1.0.5.json @@ -15,7 +15,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Porch Lights", + "alias": "#MASKED_NAME#", "brightness": 50, "dev_name": "Kasa Smart Wi-Fi Outdoor Plug-In Dimmer", "deviceId": "0000000000000000000000000000000000000000", diff --git a/tests/fixtures/iot/KS200(US)_1.0_1.0.8.json b/tests/fixtures/iot/KS200(US)_1.0_1.0.8.json index 58971dd0e..4fc94890f 100644 --- a/tests/fixtures/iot/KS200(US)_1.0_1.0.8.json +++ b/tests/fixtures/iot/KS200(US)_1.0_1.0.8.json @@ -11,7 +11,7 @@ "stopConnect": 0, "tcspInfo": "", "tcspStatus": 1, - "username": "#MASKED_NAME#" + "username": "user@example.com" }, "get_intl_fw_list": { "err_code": 0, diff --git a/tests/fixtures/iot/KS200M(US)_1.0_1.0.10.json b/tests/fixtures/iot/KS200M(US)_1.0_1.0.10.json index 24acdb976..f9498ae90 100644 --- a/tests/fixtures/iot/KS200M(US)_1.0_1.0.10.json +++ b/tests/fixtures/iot/KS200M(US)_1.0_1.0.10.json @@ -11,7 +11,7 @@ "stopConnect": 0, "tcspInfo": "", "tcspStatus": 1, - "username": "#MASKED_NAME#" + "username": "user@example.com" }, "get_intl_fw_list": { "err_code": 0, diff --git a/tests/fixtures/iot/KS200M(US)_1.0_1.0.8.json b/tests/fixtures/iot/KS200M(US)_1.0_1.0.8.json index 3806895bb..719dab2ed 100644 --- a/tests/fixtures/iot/KS200M(US)_1.0_1.0.8.json +++ b/tests/fixtures/iot/KS200M(US)_1.0_1.0.8.json @@ -66,7 +66,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test KS200M", + "alias": "#MASKED_NAME#", "dev_name": "Smart Light Switch with PIR", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/KS220(US)_1.0_1.0.13.json b/tests/fixtures/iot/KS220(US)_1.0_1.0.13.json index f5c8c1dd1..debdd722e 100644 --- a/tests/fixtures/iot/KS220(US)_1.0_1.0.13.json +++ b/tests/fixtures/iot/KS220(US)_1.0_1.0.13.json @@ -11,7 +11,7 @@ "stopConnect": 0, "tcspInfo": "", "tcspStatus": 1, - "username": "#MASKED_NAME#" + "username": "user@example.com" }, "get_intl_fw_list": { "err_code": 0, diff --git a/tests/fixtures/iot/KS220M(US)_1.0_1.0.4.json b/tests/fixtures/iot/KS220M(US)_1.0_1.0.4.json index 40da46fdd..3dceb3222 100644 --- a/tests/fixtures/iot/KS220M(US)_1.0_1.0.4.json +++ b/tests/fixtures/iot/KS220M(US)_1.0_1.0.4.json @@ -78,7 +78,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Garage Entryway Lights", + "alias": "#MASKED_NAME#", "brightness": 100, "dev_name": "Wi-Fi Smart Dimmer with sensor", "deviceId": "0000000000000000000000000000000000000000", diff --git a/tests/fixtures/iot/KS230(US)_1.0_1.0.14.json b/tests/fixtures/iot/KS230(US)_1.0_1.0.14.json index a9e529bcc..8876a1af6 100644 --- a/tests/fixtures/iot/KS230(US)_1.0_1.0.14.json +++ b/tests/fixtures/iot/KS230(US)_1.0_1.0.14.json @@ -14,7 +14,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test KS230", + "alias": "#MASKED_NAME#", "brightness": 60, "dc_state": 0, "dev_name": "Wi-Fi Smart 3-Way Dimmer", diff --git a/tests/fixtures/iot/LB110(US)_1.0_1.8.11.json b/tests/fixtures/iot/LB110(US)_1.0_1.8.11.json index ec49e91bf..8df62f234 100644 --- a/tests/fixtures/iot/LB110(US)_1.0_1.8.11.json +++ b/tests/fixtures/iot/LB110(US)_1.0_1.8.11.json @@ -21,7 +21,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "TP-LINK_Smart Bulb_43EC", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json b/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json index 61e12b253..e83c6221d 100644 --- a/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json +++ b/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json @@ -84,21 +84,24 @@ ] }, "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": "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" + "error_code": 0, + "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": "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, diff --git a/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json b/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json index 2d3e2e5ea..4aebbe0e7 100644 --- a/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json +++ b/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json @@ -88,21 +88,24 @@ ] }, "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" + "error_code": 0, + "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, diff --git a/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json b/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json index 1126fad50..9eef29dc7 100644 --- a/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json +++ b/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json @@ -379,21 +379,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "EP40M(US)", - "device_type": "SMART.KASAPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "F0-09-0D-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "matter", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "EP40M(US)", + "device_type": "SMART.KASAPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-09-0D-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "matter", + "owner": "00000000000000000000000000000000" + } }, "get_auto_update_info": { "enable": true, diff --git a/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json b/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json index 4d4936c6c..ba09016a3 100644 --- a/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json +++ b/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json @@ -84,21 +84,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "H100(EU)", - "device_type": "SMART.TAPOHUB", - "factory_default": true, - "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": "" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "H100(EU)", + "device_type": "SMART.TAPOHUB", + "factory_default": true, + "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": "" + } }, "get_auto_update_info": { "enable": true, diff --git a/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json b/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json index 021309c78..8173333a7 100644 --- a/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json +++ b/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json @@ -96,21 +96,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "H100(EU)", - "device_type": "SMART.TAPOHUB", - "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" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "H100(EU)", + "device_type": "SMART.TAPOHUB", + "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_alarm_configure": { "duration": 10, diff --git a/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json b/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json index 639122bd0..fadb35d25 100644 --- a/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json +++ b/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json @@ -92,21 +92,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "H100(EU)", - "device_type": "SMART.TAPOHUB", - "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" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "H100(EU)", + "device_type": "SMART.TAPOHUB", + "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_alarm_configure": { "duration": 10, @@ -195,7 +198,7 @@ "ver_code": 1 } ], - "device_id": "0000000000000000000000000000000000000000" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" } ], "start_index": 0, @@ -213,7 +216,7 @@ "current_humidity_exception": -34, "current_temp": 22.2, "current_temp_exception": 0, - "device_id": "0000000000000000000000000000000000000000", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", "fw_ver": "1.7.0 Build 230424 Rel.170332", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", diff --git a/tests/fixtures/smart/HS200(US)_5.26_1.0.3.json b/tests/fixtures/smart/HS200(US)_5.26_1.0.3.json index e67435a9b..f17269cc9 100644 --- a/tests/fixtures/smart/HS200(US)_5.26_1.0.3.json +++ b/tests/fixtures/smart/HS200(US)_5.26_1.0.3.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "HS200(US)", - "device_type": "SMART.KASASWITCH", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "74-FE-CE-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "HS200(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "74-FE-CE-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, diff --git a/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json b/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json index 63ec680b4..998189846 100644 --- a/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json +++ b/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json @@ -100,20 +100,23 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "owner": "00000000000000000000000000000000", - "device_type": "SMART.KASASWITCH", - "device_model": "HS220(US)", - "ip": "127.0.0.123", - "mac": "24-2F-D0-00-00-00", - "is_support_iot_cloud": true, - "obd_src": "tplink", - "factory_default": false, - "mgt_encrypt_schm": { - "is_support_https": false, - "encrypt_type": "AES", - "http_port": 80, - "lv": 2 + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "HS220(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "24-2F-D0-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": { diff --git a/tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json b/tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json index 4ef13a07d..0f24be148 100644 --- a/tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json +++ b/tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json @@ -84,21 +84,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KH100(EU)", - "device_type": "SMART.KASAHUB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-42-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KH100(EU)", + "device_type": "SMART.KASAHUB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-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_alarm_configure": { "duration": 300, diff --git a/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json b/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json index 937fe36cc..53684a580 100644 --- a/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json +++ b/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json @@ -88,21 +88,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KH100(EU)", - "device_type": "SMART.KASAHUB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-42-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KH100(EU)", + "device_type": "SMART.KASAHUB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-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_alarm_configure": { "duration": 300, diff --git a/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json b/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json index 33e4cec68..c0eeb89b1 100644 --- a/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json +++ b/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json @@ -1,4 +1,4 @@ - { +{ "component_nego": { "component_list": [ { @@ -88,21 +88,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KH100(UK)", - "device_type": "SMART.KASAHUB", - "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" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KH100(UK)", + "device_type": "SMART.KASAHUB", + "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_alarm_configure": { "duration": 300, diff --git a/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json b/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json index c7b6ecb9d..41a34cb33 100644 --- a/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json +++ b/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json @@ -84,21 +84,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KP125M(US)", - "device_type": "SMART.KASAPLUG", - "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" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KP125M(US)", + "device_type": "SMART.KASAPLUG", + "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_current_power": { "current_power": 17 @@ -124,7 +127,7 @@ "longitude": 0, "mac": "00-00-00-00-00-00", "model": "KP125M", - "nickname": "IyNNQVNLRUROQU1FIyM=", + "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "on_time": 5332, "overheated": false, @@ -133,7 +136,7 @@ "rssi": -62, "signal_level": 2, "specs": "", - "ssid": "IyNNQVNLRUROQU1FIyM=", + "ssid": "I01BU0tFRF9TU0lEIw==", "time_diff": -360, "type": "SMART.KASAPLUG" }, diff --git a/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json b/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json index 710febeb2..9878b65b7 100644 --- a/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json +++ b/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json @@ -88,21 +88,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KP125M(US)", - "device_type": "SMART.KASAPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "78-8C-B5-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KP125M(US)", + "device_type": "SMART.KASAPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "78-8C-B5-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, diff --git a/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json b/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json index c94d4f2a8..60611f333 100644 --- a/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json +++ b/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS205(US)", - "device_type": "SMART.KASASWITCH", - "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" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS205(US)", + "device_type": "SMART.KASASWITCH", + "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, diff --git a/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json b/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json index f9ac5af95..9f7419ec5 100644 --- a/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json +++ b/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json @@ -76,21 +76,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS205(US)", - "device_type": "SMART.KASASWITCH", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "40-ED-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS205(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-ED-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json b/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json index e6945cb88..1f2d9d2bc 100644 --- a/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json +++ b/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json @@ -96,21 +96,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS225(US)", - "device_type": "SMART.KASASWITCH", - "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" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS225(US)", + "device_type": "SMART.KASASWITCH", + "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, diff --git a/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json b/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json index 798642d3e..61ead9294 100644 --- a/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json +++ b/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json @@ -96,21 +96,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS225(US)", - "device_type": "SMART.KASASWITCH", - "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": "" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS225(US)", + "device_type": "SMART.KASASWITCH", + "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": "" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json b/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json index 2775ee7c2..15092b858 100644 --- a/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json +++ b/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json @@ -414,21 +414,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS240(US)", - "device_type": "SMART.KASASWITCH", - "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" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS240(US)", + "device_type": "SMART.KASASWITCH", + "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_auto_update_info": { "enable": false, diff --git a/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json b/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json index 6d14f7bfc..fb6c667dd 100644 --- a/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json +++ b/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS240(US)", - "device_type": "SMART.KASASWITCH", - "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" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS240(US)", + "device_type": "SMART.KASASWITCH", + "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_auto_update_info": { "enable": false, @@ -206,7 +209,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000001" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" }, { "component_list": [ @@ -267,7 +270,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000000" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" } ], "start_index": 0, @@ -279,7 +282,7 @@ "avatar": "switch_ks240", "bind_count": 1, "category": "kasa.switch.outlet.sub-fan", - "device_id": "000000000000000000000000000000000000000000", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", "device_on": true, "fan_sleep_mode_on": false, "fan_speed_level": 1, @@ -317,7 +320,7 @@ ], "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000001", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", "device_on": false, "fade_off_time": 1, "fade_on_time": 1, diff --git a/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json b/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json index a3f28309f..4630a977c 100644 --- a/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json +++ b/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json @@ -425,21 +425,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS240(US)", - "device_type": "SMART.KASASWITCH", - "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": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS240(US)", + "device_type": "SMART.KASASWITCH", + "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": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_auto_update_info": { "enable": true, diff --git a/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json b/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json index a53e93bb2..f89dfc698 100644 --- a/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json +++ b/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L510B(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "5C-E9-31-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L510B(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-E9-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, diff --git a/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json b/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json index 9a51ea45b..a81222e4c 100644 --- a/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json +++ b/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json @@ -80,21 +80,24 @@ ] }, "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": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "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": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json b/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json index 055674d28..523d49925 100644 --- a/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json +++ b/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json @@ -88,21 +88,24 @@ ] }, "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" + "error_code": 0, + "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, diff --git a/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json b/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json index 10b9d3002..05c04522f 100644 --- a/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json +++ b/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json @@ -100,21 +100,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L530E(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "5C-E9-31-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530E(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-E9-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, diff --git a/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json b/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json index b5b90d32d..a32c0463d 100644 --- a/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json +++ b/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L530E(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "5C-E9-31-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530E(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-E9-31-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, diff --git a/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json b/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json index 0e0ad2fa6..8da76d78b 100644 --- a/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json +++ b/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L530E(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "5C-E9-31-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530E(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-E9-31-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, @@ -175,7 +178,7 @@ "longitude": 0, "mac": "5C-E9-31-00-00-00", "model": "L530", - "nickname": "TGl2aW5nIFJvb20gQnVsYg==", + "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Europe/Berlin", diff --git a/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json b/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json index 6dac10489..0c80d3a52 100644 --- a/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json +++ b/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json @@ -104,21 +104,24 @@ ] }, "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" + "error_code": 0, + "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, diff --git a/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json b/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json index 4ca91c9b4..3fb263be7 100644 --- a/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json +++ b/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L630(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "40-AE-30-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L630(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-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, diff --git a/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json b/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json index 5d05bc94b..816cf8964 100644 --- a/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json +++ b/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L900-10(EU)", - "device_type": "SMART.TAPOBULB", - "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" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L900-10(EU)", + "device_type": "SMART.TAPOBULB", + "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, diff --git a/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json b/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json index 8665c8f31..5c81fd322 100644 --- a/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json +++ b/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json @@ -100,20 +100,23 @@ ] }, "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" + "error_code": 0, + "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, diff --git a/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json b/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json index a281f2ec4..7c7ac420c 100644 --- a/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json +++ b/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L900-5(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-42-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L900-5(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-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, diff --git a/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json b/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json index 136d3a0f3..98980a4c8 100644 --- a/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json +++ b/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json @@ -108,21 +108,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L900-5(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-42-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L900-5(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-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, diff --git a/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json b/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json index a55707aeb..3315b19b6 100644 --- a/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json +++ b/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json @@ -104,20 +104,23 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L920-5(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": true, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "1C-61-B4-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false - }, - "obd_src": "tplink", - "owner": "" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L920-5(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": true, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "1C-61-B4-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false + }, + "obd_src": "tplink", + "owner": "" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json b/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json index 5f03b5b64..0f845bf3c 100644 --- a/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json +++ b/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json @@ -116,21 +116,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L920-5(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "B4-B0-24-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L920-5(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "B4-B0-24-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, diff --git a/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json b/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json index 2ea0c69f5..95e8f969e 100644 --- a/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json +++ b/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json @@ -112,21 +112,24 @@ ] }, "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": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "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": "00-00-00-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, diff --git a/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json b/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json index 5463944dd..992f63999 100644 --- a/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json +++ b/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json @@ -116,21 +116,24 @@ ] }, "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" + "error_code": 0, + "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, diff --git a/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json b/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json index de7ae2c79..c374ebc5c 100644 --- a/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json +++ b/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json @@ -124,21 +124,24 @@ ] }, "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" + "error_code": 0, + "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, diff --git a/tests/fixtures/smart/P100(US)_1.0.0_1.1.3.json b/tests/fixtures/smart/P100(US)_1.0.0_1.1.3.json index 337c6f2c9..2ae738cdc 100644 --- a/tests/fixtures/smart/P100(US)_1.0.0_1.1.3.json +++ b/tests/fixtures/smart/P100(US)_1.0.0_1.1.3.json @@ -56,18 +56,21 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P100", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "mac": "1C-3B-F3-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false - }, - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P100", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "mac": "1C-3B-F3-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false + }, + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, @@ -93,7 +96,7 @@ "hw_ver": "1.0.0", "ip": "127.0.0.123", "latitude": 0, - "location": "hallway", + "location": "#MASKED_NAME#", "longitude": 0, "mac": "1C-3B-F3-00-00-00", "model": "P100", diff --git a/tests/fixtures/smart/P100(US)_1.0.0_1.3.7.json b/tests/fixtures/smart/P100(US)_1.0.0_1.3.7.json index cdddc72e0..5347d070b 100644 --- a/tests/fixtures/smart/P100(US)_1.0.0_1.3.7.json +++ b/tests/fixtures/smart/P100(US)_1.0.0_1.3.7.json @@ -64,20 +64,23 @@ ] }, "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" + "error_code": 0, + "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, @@ -108,7 +111,7 @@ "ip": "127.0.0.123", "lang": "en_US", "latitude": 0, - "location": "bedroom", + "location": "#MASKED_NAME#", "longitude": 0, "mac": "CC-32-E5-00-00-00", "model": "P100", diff --git a/tests/fixtures/smart/P100(US)_1.0.0_1.4.0.json b/tests/fixtures/smart/P100(US)_1.0.0_1.4.0.json index 5ec333435..ab75faf5d 100644 --- a/tests/fixtures/smart/P100(US)_1.0.0_1.4.0.json +++ b/tests/fixtures/smart/P100(US)_1.0.0_1.4.0.json @@ -64,20 +64,23 @@ ] }, "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": "74-DA-88-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "owner": "00000000000000000000000000000000" + "error_code": 0, + "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": "74-DA-88-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json b/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json index 6332f259e..dd7a0360d 100644 --- a/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json +++ b/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json @@ -72,19 +72,22 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P110(EU)", - "device_type": "SMART.TAPOPLUG", - "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": "AES", - "http_port": 80, - "is_support_https": false - }, - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110(EU)", + "device_type": "SMART.TAPOPLUG", + "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": "AES", + "http_port": 80, + "is_support_https": false + }, + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json b/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json index 415e8ce67..62e580fcd 100644 --- a/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json +++ b/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P110(EU)", - "device_type": "SMART.TAPOPLUG", - "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" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110(EU)", + "device_type": "SMART.TAPOPLUG", + "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, diff --git a/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json b/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json index 339c5fb26..0c7f6e83a 100644 --- a/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json +++ b/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json @@ -88,21 +88,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P110(UK)", - "device_type": "SMART.TAPOPLUG", - "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": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110(UK)", + "device_type": "SMART.TAPOPLUG", + "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": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json b/tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json index efb88c85e..2fea43797 100644 --- a/tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json +++ b/tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json @@ -96,21 +96,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P110M(AU)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "F0-09-0D-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110M(AU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-09-0D-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_auto_off_config": { "delay_min": 120, @@ -124,19 +127,6 @@ "get_connect_cloud_state": { "status": 1 }, - "get_energy_usage": { - "today_runtime": 306, - "month_runtime": 12572, - "today_energy": 173, - "month_energy": 6110, - "local_time": "2024-11-22 21:03:25", - "electricity_charge": [ - 0, - 0, - 0 - ], - "current_power": 74116 - }, "get_current_power": { "current_power": 74 }, @@ -313,6 +303,19 @@ }, "type": "constant" }, + "get_energy_usage": { + "current_power": 74116, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-11-22 21:03:25", + "month_energy": 6110, + "month_runtime": 12572, + "today_energy": 173, + "today_runtime": 306 + }, "get_fw_download_state": { "auto_upgrade": false, "download_progress": 0, diff --git a/tests/fixtures/smart/P110M(EU)_1.0_1.2.3.json b/tests/fixtures/smart/P110M(EU)_1.0_1.2.3.json index d8453319f..81174d7b7 100644 --- a/tests/fixtures/smart/P110M(EU)_1.0_1.2.3.json +++ b/tests/fixtures/smart/P110M(EU)_1.0_1.2.3.json @@ -96,21 +96,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P110M(EU)", - "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": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "matter", - "owner": "" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110M(EU)", + "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": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "matter", + "owner": "" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json b/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json index 48cd46f2e..33d7465cc 100644 --- a/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json +++ b/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P115(EU)", - "device_type": "SMART.TAPOPLUG", - "factory_default": true, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-42-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P115(EU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": true, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/P115(US)_1.0_1.1.3.json b/tests/fixtures/smart/P115(US)_1.0_1.1.3.json index 70035368c..151f7300e 100644 --- a/tests/fixtures/smart/P115(US)_1.0_1.1.3.json +++ b/tests/fixtures/smart/P115(US)_1.0_1.1.3.json @@ -96,21 +96,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P115(US)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "B0-19-21-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P115(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "B0-19-21-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, diff --git a/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json b/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json index 78e876d73..1e0cf7e2b 100644 --- a/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json +++ b/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P125M(US)", - "device_type": "SMART.TAPOPLUG", - "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": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P125M(US)", + "device_type": "SMART.TAPOPLUG", + "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": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/P135(US)_1.0_1.0.5.json b/tests/fixtures/smart/P135(US)_1.0_1.0.5.json index 9f6c3b034..f1099cc77 100644 --- a/tests/fixtures/smart/P135(US)_1.0_1.0.5.json +++ b/tests/fixtures/smart/P135(US)_1.0_1.0.5.json @@ -96,21 +96,24 @@ ] }, "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" + "error_code": 0, + "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, diff --git a/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json b/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json index 0d7d4a3bd..73f76e83c 100644 --- a/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json +++ b/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json @@ -84,21 +84,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P300(EU)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "78-8C-B5-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P300(EU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "78-8C-B5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_auto_update_info": { "enable": false, @@ -170,7 +173,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000002" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" }, { "component_list": [ @@ -235,7 +238,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000001" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" }, { "component_list": [ @@ -300,7 +303,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000000" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" } ], "start_index": 0, @@ -318,7 +321,7 @@ }, "type": "custom" }, - "device_id": "000000000000000000000000000000000000000002", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.13 Build 230925 Rel.150200", @@ -347,7 +350,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000001", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.13 Build 230925 Rel.150200", @@ -379,7 +382,7 @@ }, "type": "custom" }, - "device_id": "000000000000000000000000000000000000000000", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.13 Build 230925 Rel.150200", diff --git a/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json b/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json index dd40708e2..e9d4b54ff 100644 --- a/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json +++ b/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json @@ -495,21 +495,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P300(EU)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "78-8C-B5-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P300(EU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "78-8C-B5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_auto_update_info": { "enable": false, diff --git a/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json b/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json index 17df5ac5e..eaa03a35e 100644 --- a/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json +++ b/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json @@ -84,21 +84,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P300(EU)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "78-8C-B5-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P300(EU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "78-8C-B5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_auto_update_info": { "enable": true, @@ -170,7 +173,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000001" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" }, { "component_list": [ @@ -235,7 +238,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000002" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" }, { "component_list": [ @@ -300,7 +303,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000003" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" } ], "start_index": 0, @@ -315,7 +318,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000001", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", "device_on": true, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.7 Build 220715 Rel.200458", @@ -329,7 +332,7 @@ "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "on_time": 366, - "original_device_id": "8022852468EC205A8178C7CBE81FC119213BC020", + "original_device_id": "0000000000000000000000000000000000000000", "overheat_status": "normal", "position": 1, "region": "Europe/Berlin", @@ -344,7 +347,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000002", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", "device_on": true, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.7 Build 220715 Rel.200458", @@ -358,7 +361,7 @@ "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "on_time": 366, - "original_device_id": "8022852468EC205A8178C7CBE81FC119213BC020", + "original_device_id": "0000000000000000000000000000000000000000", "overheat_status": "normal", "position": 2, "region": "Europe/Berlin", @@ -373,7 +376,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000003", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", "device_on": true, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.7 Build 220715 Rel.200458", @@ -387,7 +390,7 @@ "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "on_time": 366, - "original_device_id": "8022852468EC205A8178C7CBE81FC119213BC020", + "original_device_id": "0000000000000000000000000000000000000000", "overheat_status": "normal", "position": 3, "region": "Europe/Berlin", diff --git a/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json b/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json index 4e67f482c..398977ada 100644 --- a/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json +++ b/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json @@ -1385,21 +1385,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P304M(UK)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-6E-84-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P304M(UK)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-6E-84-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_auto_update_info": { "enable": true, diff --git a/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json b/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json index a141e7003..3e6ec48df 100644 --- a/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json +++ b/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json @@ -92,21 +92,24 @@ ] }, "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" + "error_code": 0, + "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, diff --git a/tests/fixtures/smart/S505(US)_1.0_1.0.2.json b/tests/fixtures/smart/S505(US)_1.0_1.0.2.json index c9c63cd7f..340bd3a1e 100644 --- a/tests/fixtures/smart/S505(US)_1.0_1.0.2.json +++ b/tests/fixtures/smart/S505(US)_1.0_1.0.2.json @@ -80,21 +80,24 @@ ] }, "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" + "error_code": 0, + "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, diff --git a/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json b/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json index 6adac9865..0c990d758 100644 --- a/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json +++ b/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json @@ -100,21 +100,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "S505D(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": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "matter", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "S505D(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": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "matter", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/TP15(US)_1.0_1.0.3.json b/tests/fixtures/smart/TP15(US)_1.0_1.0.3.json index 404bfe2fc..8d0964b36 100644 --- a/tests/fixtures/smart/TP15(US)_1.0_1.0.3.json +++ b/tests/fixtures/smart/TP15(US)_1.0_1.0.3.json @@ -76,21 +76,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "TP15(US)", - "device_type": "SMART.TAPOPLUG", - "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": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "TP15(US)", + "device_type": "SMART.TAPOPLUG", + "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": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json b/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json index 1e3321f8f..b91654149 100644 --- a/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json +++ b/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json @@ -84,21 +84,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "TP25(US)", - "device_type": "SMART.TAPOPLUG", - "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" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "TP25(US)", + "device_type": "SMART.TAPOPLUG", + "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_auto_update_info": { "enable": false, @@ -170,7 +173,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000000" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" }, { "component_list": [ @@ -235,7 +238,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000001" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" } ], "start_index": 0, @@ -250,7 +253,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000000", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.2 Build 230206 Rel.095245", @@ -279,7 +282,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000001", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", "device_on": true, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.2 Build 230206 Rel.095245", diff --git a/tests/fixtures/smartcam/C210(EU)_2.0_1.4.2.json b/tests/fixtures/smartcam/C210(EU)_2.0_1.4.2.json index ba2e00108..609c46bec 100644 --- a/tests/fixtures/smartcam/C210(EU)_2.0_1.4.2.json +++ b/tests/fixtures/smartcam/C210(EU)_2.0_1.4.2.json @@ -1,35 +1,38 @@ { "discovery_result": { - "decrypted_data": { - "connect_ssid": "0000000000", - "connect_type": "wireless", - "device_id": "0000000000000000000000000000000000000000", - "http_port": 443, - "last_alarm_time": "1729264456", - "last_alarm_type": "motion", - "owner": "00000000000000000000000000000000", - "sd_status": "offline" - }, - "device_id": "00000000000000000000000000000000", - "device_model": "C210", - "device_name": "#MASKED_NAME#", - "device_type": "SMART.IPCAMERA", - "encrypt_info": { - "data": "", - "key": "", - "sym_schm": "AES" - }, - "encrypt_type": [ - "3" - ], - "factory_default": false, - "firmware_version": "1.4.2 Build 240829 Rel.54953n", - "hardware_version": "2.0", - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "40-AE-30-00-00-00", - "mgt_encrypt_schm": { - "is_support_https": true + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1729264456", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.4.2 Build 240829 Rel.54953n", + "hardware_version": "2.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } } }, "getAlertConfig": { diff --git a/tests/fixtures/smartcam/C210(EU)_2.0_1.4.3.json b/tests/fixtures/smartcam/C210(EU)_2.0_1.4.3.json index a2f7666ed..b62801183 100644 --- a/tests/fixtures/smartcam/C210(EU)_2.0_1.4.3.json +++ b/tests/fixtures/smartcam/C210(EU)_2.0_1.4.3.json @@ -1,35 +1,38 @@ { "discovery_result": { - "decrypted_data": { - "connect_ssid": "0000000000", - "connect_type": "wireless", - "device_id": "0000000000000000000000000000000000000000", - "http_port": 443, - "last_alarm_time": "0", - "last_alarm_type": "", - "owner": "00000000000000000000000000000000", - "sd_status": "offline" - }, - "device_id": "00000000000000000000000000000000", - "device_model": "C210", - "device_name": "#MASKED_NAME#", - "device_type": "SMART.IPCAMERA", - "encrypt_info": { - "data": "", - "key": "", - "sym_schm": "AES" - }, - "encrypt_type": [ - "3" - ], - "factory_default": false, - "firmware_version": "1.4.3 Build 241010 Rel.33858n", - "hardware_version": "2.0", - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "40-AE-30-00-00-00", - "mgt_encrypt_schm": { - "is_support_https": true + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "0", + "last_alarm_type": "", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.4.3 Build 241010 Rel.33858n", + "hardware_version": "2.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } } }, "getAlertConfig": { diff --git a/tests/fixtures/smartcam/C520WS(US)_1.0_1.2.8.json b/tests/fixtures/smartcam/C520WS(US)_1.0_1.2.8.json index 072fea80b..4f156070d 100644 --- a/tests/fixtures/smartcam/C520WS(US)_1.0_1.2.8.json +++ b/tests/fixtures/smartcam/C520WS(US)_1.0_1.2.8.json @@ -1,36 +1,39 @@ { "discovery_result": { - "decrypted_data": { - "connect_ssid": "000 000000 0000000000", - "connect_type": "wireless", - "device_id": "0000000000000000000000000000000000000000", - "http_port": 443, - "last_alarm_time": "0", - "last_alarm_type": "", - "owner": "00000000000000000000000000000000", - "sd_status": "offline" - }, - "device_id": "00000000000000000000000000000000", - "device_model": "C520WS", - "device_name": "#MASKED_NAME#", - "device_type": "SMART.IPCAMERA", - "encrypt_info": { - "data": "", - "key": "", - "sym_schm": "AES" - }, - "encrypt_type": [ - "3" - ], - "factory_default": false, - "firmware_version": "1.2.8 Build 240606 Rel.39146n", - "hardware_version": "1.0", - "ip": "127.0.0.123", - "isResetWiFi": false, - "is_support_iot_cloud": true, - "mac": "F0-A7-31-00-00-00", - "mgt_encrypt_schm": { - "is_support_https": true + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "0", + "last_alarm_type": "", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C520WS", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.2.8 Build 240606 Rel.39146n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } } }, "getAlertConfig": { diff --git a/tests/fixtures/smartcam/H200(EU)_1.0_1.3.2.json b/tests/fixtures/smartcam/H200(EU)_1.0_1.3.2.json index 04bcc262c..9ccaa7e0e 100644 --- a/tests/fixtures/smartcam/H200(EU)_1.0_1.3.2.json +++ b/tests/fixtures/smartcam/H200(EU)_1.0_1.3.2.json @@ -1,33 +1,36 @@ { "discovery_result": { - "decrypted_data": { - "connect_ssid": "", - "connect_type": "wired", - "device_id": "0000000000000000000000000000000000000000", - "http_port": 443, - "owner": "00000000000000000000000000000000", - "sd_status": "offline" - }, - "device_id": "00000000000000000000000000000000", - "device_model": "H200", - "device_name": "#MASKED_NAME#", - "device_type": "SMART.TAPOHUB", - "encrypt_info": { - "data": "", - "key": "", - "sym_schm": "AES" - }, - "encrypt_type": [ - "3" - ], - "factory_default": false, - "firmware_version": "1.3.2 Build 20240424 rel.75425", - "hardware_version": "1.0", - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-6E-84-00-00-00", - "mgt_encrypt_schm": { - "is_support_https": true + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "", + "connect_type": "wired", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "H200", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.2 Build 20240424 rel.75425", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-6E-84-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } } }, "getAlertConfig": {}, diff --git a/tests/fixtures/smartcam/H200(US)_1.0_1.3.6.json b/tests/fixtures/smartcam/H200(US)_1.0_1.3.6.json index f1a6ae157..26c037936 100644 --- a/tests/fixtures/smartcam/H200(US)_1.0_1.3.6.json +++ b/tests/fixtures/smartcam/H200(US)_1.0_1.3.6.json @@ -1,34 +1,37 @@ { "discovery_result": { - "decrypted_data": { - "connect_ssid": "", - "connect_type": "wired", - "device_id": "0000000000000000000000000000000000000000", - "http_port": 443, - "owner": "00000000000000000000000000000000", - "sd_status": "offline" - }, - "device_id": "00000000000000000000000000000000", - "device_model": "H200", - "device_name": "#MASKED_NAME#", - "device_type": "SMART.TAPOHUB", - "encrypt_info": { - "data": "", - "key": "", - "sym_schm": "AES" - }, - "encrypt_type": [ - "3" - ], - "factory_default": false, - "firmware_version": "1.3.6 Build 20240829 rel.71119", - "hardware_version": "1.0", - "ip": "127.0.0.123", - "isResetWiFi": false, - "is_support_iot_cloud": true, - "mac": "24-2F-D0-00-00-00", - "mgt_encrypt_schm": { - "is_support_https": true + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "", + "connect_type": "wired", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "H200", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.6 Build 20240829 rel.71119", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "24-2F-D0-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } } }, "getAlertConfig": {}, diff --git a/tests/fixtures/smartcam/TC65_1.0_1.3.9.json b/tests/fixtures/smartcam/TC65_1.0_1.3.9.json index 5b05a1b3d..cec6b7595 100644 --- a/tests/fixtures/smartcam/TC65_1.0_1.3.9.json +++ b/tests/fixtures/smartcam/TC65_1.0_1.3.9.json @@ -1,35 +1,38 @@ { "discovery_result": { - "decrypted_data": { - "connect_ssid": "0000000", - "connect_type": "wireless", - "device_id": "0000000000000000000000000000000000000000", - "http_port": 443, - "last_alarm_time": "1698149810", - "last_alarm_type": "motion", - "owner": "00000000000000000000000000000000", - "sd_status": "offline" - }, - "device_id": "00000000000000000000000000000000", - "device_model": "TC65", - "device_name": "#MASKED_NAME#", - "device_type": "SMART.IPCAMERA", - "encrypt_info": { - "data": "", - "key": "", - "sym_schm": "AES" - }, - "encrypt_type": [ - "3" - ], - "factory_default": false, - "firmware_version": "1.3.9 Build 231024 Rel.72919n(4555)", - "hardware_version": "1.0", - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-6E-84-00-00-00", - "mgt_encrypt_schm": { - "is_support_https": true + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1698149810", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "TC65", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.9 Build 231024 Rel.72919n(4555)", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-6E-84-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } } }, "getAlertPlan": { diff --git a/tests/test_cli.py b/tests/test_cli.py index 4391b9981..c14f6c8b4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1126,7 +1126,7 @@ async def test_feature_set_child(mocker, runner): mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) get_child_device = mocker.spy(dummy_device, "get_child_device") - child_id = "000000000000000000000000000000000000000001" + child_id = "SCRUBBED_CHILD_DEVICE_ID_1" res = await runner.invoke( cli, diff --git a/tests/test_devtools.py b/tests/test_devtools.py index e18243afa..8bdd5746b 100644 --- a/tests/test_devtools.py +++ b/tests/test_devtools.py @@ -29,11 +29,13 @@ async def test_fixture_names(fixture_info: FixtureInfo): """Test that device info gets the right fixture names.""" if fixture_info.protocol in {"SMARTCAM"}: device_info = SmartCamDevice._get_device_info( - fixture_info.data, fixture_info.data.get("discovery_result") + fixture_info.data, + fixture_info.data.get("discovery_result", {}).get("result"), ) elif fixture_info.protocol in {"SMART"}: device_info = SmartDevice._get_device_info( - fixture_info.data, fixture_info.data.get("discovery_result") + fixture_info.data, + fixture_info.data.get("discovery_result", {}).get("result"), ) elif fixture_info.protocol in {"SMART.CHILD"}: device_info = SmartDevice._get_device_info(fixture_info.data, None) diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index 394a3aff7..01294399b 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -22,6 +22,9 @@ def test_bulb_examples(mocker): def test_smartdevice_examples(mocker): """Use HS110 for emeter examples.""" p = asyncio.run(get_device_for_fixture_protocol("HS110(EU)_1.0_1.2.5.json", "IOT")) + asyncio.run(p.set_alias("Bedroom Lamp Plug")) + asyncio.run(p.update()) + mocker.patch("kasa.iot.iotdevice.IotDevice", return_value=p) mocker.patch("kasa.iot.iotdevice.IotDevice.update") res = xdoctest.doctest_module("kasa.iot.iotdevice", "all") @@ -31,18 +34,16 @@ def test_smartdevice_examples(mocker): def test_plug_examples(mocker): """Test plug examples.""" p = asyncio.run(get_device_for_fixture_protocol("HS110(EU)_1.0_1.2.5.json", "IOT")) - # p = await get_device_for_fixture_protocol("HS110(EU)_1.0_1.2.5.json", "IOT") + asyncio.run(p.set_alias("Bedroom Lamp Plug")) + asyncio.run(p.update()) mocker.patch("kasa.iot.iotplug.IotPlug", return_value=p) mocker.patch("kasa.iot.iotplug.IotPlug.update") res = xdoctest.doctest_module("kasa.iot.iotplug", "all") assert not res["failed"] -def test_strip_examples(mocker): +def test_strip_examples(readmes_mock): """Test strip examples.""" - p = asyncio.run(get_device_for_fixture_protocol("KP303(UK)_1.0_1.0.3.json", "IOT")) - mocker.patch("kasa.iot.iotstrip.IotStrip", return_value=p) - mocker.patch("kasa.iot.iotstrip.IotStrip.update") res = xdoctest.doctest_module("kasa.iot.iotstrip", "all") assert not res["failed"] @@ -59,6 +60,8 @@ def test_dimmer_examples(mocker): def test_lightstrip_examples(mocker): """Test lightstrip examples.""" p = asyncio.run(get_device_for_fixture_protocol("KL430(US)_1.0_1.0.10.json", "IOT")) + asyncio.run(p.set_alias("Bedroom Lightstrip")) + asyncio.run(p.update()) mocker.patch("kasa.iot.iotlightstrip.IotLightStrip", return_value=p) mocker.patch("kasa.iot.iotlightstrip.IotLightStrip.update") res = xdoctest.doctest_module("kasa.iot.iotlightstrip", "all") @@ -154,4 +157,23 @@ async def readmes_mock(mocker): "127.0.0.4": get_fixture_info("KL430(US)_1.0_1.0.10.json", "IOT"), # Lightstrip "127.0.0.5": get_fixture_info("HS220(US)_1.0_1.5.7.json", "IOT"), # Dimmer } + fixture_infos["127.0.0.1"].data["system"]["get_sysinfo"]["alias"] = ( + "Bedroom Power Strip" + ) + for index, child in enumerate( + fixture_infos["127.0.0.1"].data["system"]["get_sysinfo"]["children"] + ): + child["alias"] = f"Plug {index + 1}" + fixture_infos["127.0.0.2"].data["system"]["get_sysinfo"]["alias"] = ( + "Bedroom Lamp Plug" + ) + fixture_infos["127.0.0.3"].data["get_device_info"]["nickname"] = ( + "TGl2aW5nIFJvb20gQnVsYg==" # Living Room Bulb + ) + fixture_infos["127.0.0.4"].data["system"]["get_sysinfo"]["alias"] = ( + "Bedroom Lightstrip" + ) + fixture_infos["127.0.0.5"].data["system"]["get_sysinfo"]["alias"] = ( + "Living Room Dimmer Switch" + ) return patch_discovery(fixture_infos, mocker) From f8a46f74cda2c77be004eb41302c7dbc33fede74 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:38:38 +0000 Subject: [PATCH 746/892] Pass raw components to SmartChildDevice init (#1363) Clean up and consolidate the processing of raw component query responses and simplify the code paths for creating smartcam child devices when supported. --- kasa/smart/smartchilddevice.py | 11 ++++++----- kasa/smart/smartdevice.py | 28 +++++++++++++++----------- kasa/smartcam/smartcamdevice.py | 35 +++++++++++++++------------------ tests/test_childdevice.py | 4 +++- tests/test_device.py | 12 +++++++++-- 5 files changed, 51 insertions(+), 39 deletions(-) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index db3319f3c..d49e814c8 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -9,7 +9,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper -from .smartdevice import SmartDevice +from .smartdevice import ComponentsRaw, SmartDevice from .smartmodule import SmartModule _LOGGER = logging.getLogger(__name__) @@ -37,7 +37,7 @@ def __init__( self, parent: SmartDevice, info: dict, - component_info: dict, + component_info_raw: ComponentsRaw, *, config: DeviceConfig | None = None, protocol: SmartProtocol | None = None, @@ -47,7 +47,8 @@ def __init__( super().__init__(parent.host, config=parent.config, protocol=_protocol) self._parent = parent self._update_internal_state(info) - self._components = component_info + self._components_raw = component_info_raw + self._components = self._parse_components(self._components_raw) async def update(self, update_children: bool = True) -> None: """Update child module info. @@ -84,7 +85,7 @@ async def create( cls, parent: SmartDevice, child_info: dict, - child_components: dict, + child_components_raw: ComponentsRaw, protocol: SmartProtocol | None = None, *, last_update: dict | None = None, @@ -97,7 +98,7 @@ async def create( derived from the parent. """ child: SmartChildDevice = cls( - parent, child_info, child_components, protocol=protocol + parent, child_info, child_components_raw, protocol=protocol ) if last_update: child._last_update = last_update diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index ed5a4eec5..b95503522 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -7,7 +7,7 @@ import time from collections.abc import Mapping, Sequence from datetime import UTC, datetime, timedelta, tzinfo -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, TypeAlias, cast from ..device import Device, WifiNetwork, _DeviceInfo from ..device_type import DeviceType @@ -40,6 +40,8 @@ # same issue, homekit perhaps? NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] +ComponentsRaw: TypeAlias = dict[str, list[dict[str, int | str]]] + # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. @@ -61,7 +63,7 @@ def __init__( ) super().__init__(host=host, config=config, protocol=_protocol) self.protocol: SmartProtocol - self._components_raw: dict[str, Any] | None = None + self._components_raw: ComponentsRaw | None = None self._components: dict[str, int] = {} self._state_information: dict[str, Any] = {} self._modules: dict[str | ModuleName[Module], SmartModule] = {} @@ -82,10 +84,8 @@ async def _initialize_children(self) -> None: self.internal_state.update(resp) children = self.internal_state["get_child_device_list"]["child_device_list"] - children_components = { - child["device_id"]: { - comp["id"]: int(comp["ver_code"]) for comp in child["component_list"] - } + children_components_raw = { + child["device_id"]: child for child in self.internal_state["get_child_device_component_list"][ "child_component_list" ] @@ -96,7 +96,7 @@ async def _initialize_children(self) -> None: child_info["device_id"]: await SmartChildDevice.create( parent=self, child_info=child_info, - child_components=children_components[child_info["device_id"]], + child_components_raw=children_components_raw[child_info["device_id"]], ) for child_info in children } @@ -131,6 +131,13 @@ def _try_get_response( f"{request} not found in {responses} for device {self.host}" ) + @staticmethod + def _parse_components(components_raw: ComponentsRaw) -> dict[str, int]: + return { + str(comp["id"]): int(comp["ver_code"]) + for comp in components_raw["component_list"] + } + async def _negotiate(self) -> None: """Perform initialization. @@ -151,12 +158,9 @@ async def _negotiate(self) -> None: self._info = self._try_get_response(resp, "get_device_info") # Create our internal presentation of available components - self._components_raw = cast(dict, resp["component_nego"]) + self._components_raw = cast(ComponentsRaw, resp["component_nego"]) - self._components = { - comp["id"]: int(comp["ver_code"]) - for comp in self._components_raw["component_list"] - } + self._components = self._parse_components(self._components_raw) if "child_device" in self._components and not self.children: await self._initialize_children() diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index 0090117ed..b383a4b46 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -3,13 +3,14 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast from ..device import _DeviceInfo from ..device_type import DeviceType from ..module import Module from ..protocols.smartcamprotocol import _ChildCameraProtocolWrapper from ..smart import SmartChildDevice, SmartDevice +from ..smart.smartdevice import ComponentsRaw from .modules import ChildDevice, DeviceModule from .smartcammodule import SmartCamModule @@ -78,7 +79,7 @@ def _update_children_info(self) -> None: self._children[child_id]._update_internal_state(info) async def _initialize_smart_child( - self, info: dict, child_components: dict + self, info: dict, child_components_raw: ComponentsRaw ) -> SmartDevice: """Initialize a smart child device attached to a smartcam device.""" child_id = info["device_id"] @@ -93,7 +94,7 @@ async def _initialize_smart_child( return await SmartChildDevice.create( parent=self, child_info=info, - child_components=child_components, + child_components_raw=child_components_raw, protocol=child_protocol, last_update=initial_response, ) @@ -108,17 +109,8 @@ async def _initialize_children(self) -> None: self.internal_state.update(resp) smart_children_components = { - child["device_id"]: { - comp["id"]: int(comp["ver_code"]) for comp in component_list - } + child["device_id"]: child for child in resp["getChildDeviceComponentList"]["child_component_list"] - if (component_list := child.get("component_list")) - # Child camera devices will have a different component schema so only - # extract smart values. - and (first_comp := next(iter(component_list), None)) - and isinstance(first_comp, dict) - and "id" in first_comp - and "ver_code" in first_comp } children = {} for info in resp["getChildDeviceList"]["child_device_list"]: @@ -172,6 +164,13 @@ async def _query_getter_helper( return res + @staticmethod + def _parse_components(components_raw: ComponentsRaw) -> dict[str, int]: + return { + str(comp["name"]): int(comp["version"]) + for comp in components_raw["app_component_list"] + } + async def _negotiate(self) -> None: """Perform initialization. @@ -186,12 +185,10 @@ async def _negotiate(self) -> None: self._last_update.update(resp) self._update_internal_info(resp) - self._components = { - comp["name"]: int(comp["version"]) - for comp in resp["getAppComponentList"]["app_component"][ - "app_component_list" - ] - } + self._components_raw = cast( + ComponentsRaw, resp["getAppComponentList"]["app_component"] + ) + self._components = self._parse_components(self._components_raw) if "childControl" in self._components and not self.children: await self._initialize_children() diff --git a/tests/test_childdevice.py b/tests/test_childdevice.py index 1e525efb0..8bcc05db4 100644 --- a/tests/test_childdevice.py +++ b/tests/test_childdevice.py @@ -145,7 +145,9 @@ def __init__(self): super().__init__( SmartDevice("127.0.0.1"), {"device_id": "1", "category": "foobar"}, - {"device", 1}, + { + "component_list": [{"id": "device", "ver_code": 1}], + }, ) assert DummyDevice().device_type is DeviceType.Unknown diff --git a/tests/test_device.py b/tests/test_device.py index 0764acfbf..45de4a287 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -86,7 +86,11 @@ async def test_device_class_ctors(device_class_name_obj): if issubclass(klass, SmartChildDevice): parent = SmartDevice(host, config=config) dev = klass( - parent, {"dummy": "info", "device_id": "dummy"}, {"dummy": "components"} + parent, + {"dummy": "info", "device_id": "dummy"}, + { + "component_list": [{"id": "device", "ver_code": 1}], + }, ) else: dev = klass(host, config=config) @@ -106,7 +110,11 @@ async def test_device_class_repr(device_class_name_obj): if issubclass(klass, SmartChildDevice): parent = SmartDevice(host, config=config) dev = klass( - parent, {"dummy": "info", "device_id": "dummy"}, {"dummy": "components"} + parent, + {"dummy": "info", "device_id": "dummy"}, + { + "component_list": [{"id": "device", "ver_code": 1}], + }, ) else: dev = klass(host, config=config) From 7709bb967f217b7c11d7a4e3d743aa9cca4b97d4 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 11 Dec 2024 15:53:35 +0000 Subject: [PATCH 747/892] Update cli, light modules, and docs to use FeatureAttributes (#1364) --- docs/tutorial.py | 6 +++--- kasa/cli/light.py | 14 +++++++++----- kasa/interfaces/light.py | 13 +++++++------ kasa/interfaces/lighteffect.py | 3 +-- kasa/iot/modules/light.py | 30 ++++++++++++++++-------------- kasa/iot/modules/lightpreset.py | 18 ++++++++++-------- kasa/smart/modules/light.py | 23 ++++++++++++----------- kasa/smart/modules/lightpreset.py | 13 +++++++++---- tests/iot/test_iotbulb.py | 4 +++- tests/smart/test_smartdevice.py | 4 +++- tests/test_bulb.py | 9 +++------ tests/test_cli.py | 14 ++++++++++---- tests/test_common_modules.py | 2 +- tests/test_device.py | 6 +++--- 14 files changed, 90 insertions(+), 69 deletions(-) diff --git a/docs/tutorial.py b/docs/tutorial.py index f5cb9dea6..76094abb9 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -40,7 +40,7 @@ key from :class:`~kasa.Module`. Modules will only be available on the device if they are supported but some individual features of a module may not be available for your device. -You can check the availability using ``is_``-prefixed properties like `is_color`. +You can check the availability using ``has_feature()`` method. >>> from kasa import Module >>> Module.Light in dev.modules @@ -52,9 +52,9 @@ >>> await dev.update() >>> light.brightness 50 ->>> light.is_color +>>> light.has_feature("hsv") True ->>> if light.is_color: +>>> if light.has_feature("hsv"): >>> print(light.hsv) HSV(hue=0, saturation=100, value=50) diff --git a/kasa/cli/light.py b/kasa/cli/light.py index b2909c59e..a77855633 100644 --- a/kasa/cli/light.py +++ b/kasa/cli/light.py @@ -25,7 +25,9 @@ def light(dev) -> None: @pass_dev_or_child async def brightness(dev: Device, brightness: int, transition: int): """Get or set brightness.""" - if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: + if not (light := dev.modules.get(Module.Light)) or not light.has_feature( + "brightness" + ): error("This device does not support brightness.") return @@ -45,13 +47,15 @@ async def brightness(dev: Device, brightness: int, transition: int): @pass_dev_or_child async def temperature(dev: Device, temperature: int, transition: int): """Get or set color temperature.""" - if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: + if not (light := dev.modules.get(Module.Light)) or not ( + color_temp_feat := light.get_feature("color_temp") + ): error("Device does not support color temperature") return if temperature is None: echo(f"Color temperature: {light.color_temp}") - valid_temperature_range = light.valid_temperature_range + valid_temperature_range = color_temp_feat.range if valid_temperature_range != (0, 0): echo("(min: {}, max: {})".format(*valid_temperature_range)) else: @@ -59,7 +63,7 @@ async def temperature(dev: Device, temperature: int, transition: int): "Temperature range unknown, please open a github issue" f" or a pull request for model '{dev.model}'" ) - return light.valid_temperature_range + return color_temp_feat.range else: echo(f"Setting color temperature to {temperature}") return await light.set_color_temp(temperature, transition=transition) @@ -99,7 +103,7 @@ async def effect(dev: Device, ctx, effect): @pass_dev_or_child async def hsv(dev: Device, ctx, h, s, v, transition): """Get or set color in HSV.""" - if not (light := dev.modules.get(Module.Light)) or not light.is_color: + if not (light := dev.modules.get(Module.Light)) or not light.has_feature("hsv"): error("Device does not support colors") return diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py index 1d99f846c..89058f98d 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -23,13 +23,13 @@ >>> light = dev.modules[Module.Light] -You can use the ``is_``-prefixed properties to check for supported features: +You can use the ``has_feature()`` method to check for supported features: ->>> light.is_dimmable +>>> light.has_feature("brightness") True ->>> light.is_color +>>> light.has_feature("hsv") True ->>> light.is_variable_color_temp +>>> light.has_feature("color_temp") True All known bulbs support changing the brightness: @@ -43,8 +43,9 @@ Bulbs supporting color temperature can be queried for the supported range: ->>> light.valid_temperature_range -ColorTempRange(min=2500, max=6500) +>>> if color_temp_feature := light.get_feature("color_temp"): +>>> print(f"{color_temp_feature.minimum_value}, {color_temp_feature.maximum_value}") +2500, 6500 >>> await light.set_color_temp(3000) >>> await dev.update() >>> light.color_temp diff --git a/kasa/interfaces/lighteffect.py b/kasa/interfaces/lighteffect.py index 9a69f2d09..fa50dd3eb 100644 --- a/kasa/interfaces/lighteffect.py +++ b/kasa/interfaces/lighteffect.py @@ -13,8 +13,7 @@ Light effects are accessed via the LightPreset module. To list available presets ->>> if dev.modules[Module.Light].has_effects: ->>> light_effect = dev.modules[Module.LightEffect] +>>> light_effect = dev.modules[Module.LightEffect] >>> light_effect.effect_list ['Off', 'Party', 'Relax'] diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 5fdbf014d..5f5c34b92 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -3,13 +3,14 @@ from __future__ import annotations from dataclasses import asdict -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Annotated, cast from ...device_type import DeviceType from ...exceptions import KasaException from ...feature import Feature from ...interfaces.light import HSV, ColorTempRange, LightState from ...interfaces.light import Light as LightInterface +from ...module import FeatureAttribute from ..iotmodule import IotModule if TYPE_CHECKING: @@ -32,7 +33,7 @@ def _initialize_features(self) -> None: super()._initialize_features() device = self._device - if self._device._is_dimmable: + if device._is_dimmable: self._add_feature( Feature( device, @@ -46,7 +47,7 @@ def _initialize_features(self) -> None: category=Feature.Category.Primary, ) ) - if self._device._is_variable_color_temp: + if device._is_variable_color_temp: self._add_feature( Feature( device=device, @@ -60,7 +61,7 @@ def _initialize_features(self) -> None: type=Feature.Type.Number, ) ) - if self._device._is_color: + if device._is_color: self._add_feature( Feature( device=device, @@ -95,13 +96,13 @@ def is_dimmable(self) -> int: return self._device._is_dimmable @property # type: ignore - def brightness(self) -> int: + def brightness(self) -> Annotated[int, FeatureAttribute()]: """Return the current brightness in percentage.""" return self._device._brightness async def set_brightness( self, brightness: int, *, transition: int | None = None - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set the brightness in percentage. A value of 0 will turn off the light. :param int brightness: brightness in percent @@ -133,7 +134,7 @@ def has_effects(self) -> bool: return bulb._has_effects @property - def hsv(self) -> HSV: + def hsv(self) -> Annotated[HSV, FeatureAttribute()]: """Return the current HSV state of the bulb. :return: hue, saturation and value (degrees, %, %) @@ -149,7 +150,7 @@ async def set_hsv( value: int | None = None, *, transition: int | None = None, - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set new HSV. Note, transition is not supported and will be ignored. @@ -176,7 +177,7 @@ def valid_temperature_range(self) -> ColorTempRange: return bulb._valid_temperature_range @property - def color_temp(self) -> int: + def color_temp(self) -> Annotated[int, FeatureAttribute()]: """Whether the bulb supports color temperature changes.""" if ( bulb := self._get_bulb_device() @@ -186,7 +187,7 @@ def color_temp(self) -> int: async def set_color_temp( self, temp: int, *, brightness: int | None = None, transition: int | None = None - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set the color temperature of the device in kelvin. Note, transition is not supported and will be ignored. @@ -242,17 +243,18 @@ def state(self) -> LightState: return self._light_state async def _post_update_hook(self) -> None: - if self._device.is_on is False: + device = self._device + if device.is_on is False: state = LightState(light_on=False) else: state = LightState(light_on=True) - if self.is_dimmable: + if device._is_dimmable: state.brightness = self.brightness - if self.is_color: + if device._is_color: hsv = self.hsv state.hue = hsv.hue state.saturation = hsv.saturation - if self.is_variable_color_temp: + if device._is_variable_color_temp: state.color_temp = self.color_temp self._light_state = state diff --git a/kasa/iot/modules/lightpreset.py b/kasa/iot/modules/lightpreset.py index d97bfc4a8..76d398600 100644 --- a/kasa/iot/modules/lightpreset.py +++ b/kasa/iot/modules/lightpreset.py @@ -85,17 +85,19 @@ def preset_states_list(self) -> Sequence[IotLightPreset]: def preset(self) -> str: """Return current preset name.""" light = self._device.modules[Module.Light] + is_color = light.has_feature("hsv") + is_variable_color_temp = light.has_feature("color_temp") + brightness = light.brightness - color_temp = light.color_temp if light.is_variable_color_temp else None - h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None) + color_temp = light.color_temp if is_variable_color_temp else None + + h, s = (light.hsv.hue, light.hsv.saturation) if is_color else (None, None) for preset_name, preset in self._presets.items(): if ( preset.brightness == brightness - and ( - preset.color_temp == color_temp or not light.is_variable_color_temp - ) - and (preset.hue == h or not light.is_color) - and (preset.saturation == s or not light.is_color) + and (preset.color_temp == color_temp or not is_variable_color_temp) + and (preset.hue == h or not is_color) + and (preset.saturation == s or not is_color) ): return preset_name return self.PRESET_NOT_SET @@ -107,7 +109,7 @@ async def set_preset( """Set a light preset for the device.""" light = self._device.modules[Module.Light] if preset_name == self.PRESET_NOT_SET: - if light.is_color: + if light.has_feature("hsv"): preset = LightState(hue=0, saturation=0, brightness=100) else: preset = LightState(brightness=100) diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py index 730988750..804198979 100644 --- a/kasa/smart/modules/light.py +++ b/kasa/smart/modules/light.py @@ -55,7 +55,7 @@ def valid_temperature_range(self) -> ColorTempRange: :return: White temperature range in Kelvin (minimum, maximum) """ - if not self.is_variable_color_temp: + if Module.ColorTemperature not in self._device.modules: raise KasaException("Color temperature not supported") return self._device.modules[Module.ColorTemperature].valid_temperature_range @@ -66,7 +66,7 @@ def hsv(self) -> Annotated[HSV, FeatureAttribute()]: :return: hue, saturation and value (degrees, %, %) """ - if not self.is_color: + if Module.Color not in self._device.modules: raise KasaException("Bulb does not support color.") return self._device.modules[Module.Color].hsv @@ -74,7 +74,7 @@ def hsv(self) -> Annotated[HSV, FeatureAttribute()]: @property def color_temp(self) -> Annotated[int, FeatureAttribute()]: """Whether the bulb supports color temperature changes.""" - if not self.is_variable_color_temp: + if Module.ColorTemperature not in self._device.modules: raise KasaException("Bulb does not support colortemp.") return self._device.modules[Module.ColorTemperature].color_temp @@ -82,7 +82,7 @@ def color_temp(self) -> Annotated[int, FeatureAttribute()]: @property def brightness(self) -> Annotated[int, FeatureAttribute()]: """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover + if Module.Brightness not in self._device.modules: raise KasaException("Bulb is not dimmable.") return self._device.modules[Module.Brightness].brightness @@ -104,7 +104,7 @@ async def set_hsv( :param int value: value between 1 and 100 :param int transition: transition in milliseconds. """ - if not self.is_color: + if Module.Color not in self._device.modules: raise KasaException("Bulb does not support color.") return await self._device.modules[Module.Color].set_hsv(hue, saturation, value) @@ -119,7 +119,7 @@ async def set_color_temp( :param int temp: The new color temperature, in Kelvin :param int transition: transition in milliseconds. """ - if not self.is_variable_color_temp: + if Module.ColorTemperature not in self._device.modules: raise KasaException("Bulb does not support colortemp.") return await self._device.modules[Module.ColorTemperature].set_color_temp( temp, brightness=brightness @@ -135,7 +135,7 @@ async def set_brightness( :param int brightness: brightness in percent :param int transition: transition in milliseconds. """ - if not self.is_dimmable: # pragma: no cover + if Module.Brightness not in self._device.modules: raise KasaException("Bulb is not dimmable.") return await self._device.modules[Module.Brightness].set_brightness(brightness) @@ -167,16 +167,17 @@ def state(self) -> LightState: return self._light_state async def _post_update_hook(self) -> None: - if self._device.is_on is False: + device = self._device + if device.is_on is False: state = LightState(light_on=False) else: state = LightState(light_on=True) - if self.is_dimmable: + if Module.Brightness in device.modules: state.brightness = self.brightness - if self.is_color: + if Module.Color in device.modules: hsv = self.hsv state.hue = hsv.hue state.saturation = hsv.saturation - if self.is_variable_color_temp: + if Module.ColorTemperature in device.modules: state.color_temp = self.color_temp self._light_state = state diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 2eba75725..87e96eaee 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -96,13 +96,18 @@ def preset(self) -> str: """Return current preset name.""" light = self._device.modules[SmartModule.Light] brightness = light.brightness - color_temp = light.color_temp if light.is_variable_color_temp else None - h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None) + color_temp = light.color_temp if light.has_feature("color_temp") else None + h, s = ( + (light.hsv.hue, light.hsv.saturation) + if light.has_feature("hsv") + else (None, None) + ) for preset_name, preset in self._presets.items(): if ( preset.brightness == brightness and ( - preset.color_temp == color_temp or not light.is_variable_color_temp + preset.color_temp == color_temp + or not light.has_feature("color_temp") ) and preset.hue == h and preset.saturation == s @@ -117,7 +122,7 @@ async def set_preset( """Set a light preset for the device.""" light = self._device.modules[SmartModule.Light] if preset_name == self.PRESET_NOT_SET: - if light.is_color: + if light.has_feature("hsv"): preset = LightState(hue=0, saturation=0, brightness=100) else: preset = LightState(brightness=100) diff --git a/tests/iot/test_iotbulb.py b/tests/iot/test_iotbulb.py index b573a5454..5b759c588 100644 --- a/tests/iot/test_iotbulb.py +++ b/tests/iot/test_iotbulb.py @@ -91,7 +91,9 @@ async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") light = dev.modules.get(Module.Light) assert light - assert light.valid_temperature_range == (2700, 5000) + color_temp_feat = light.get_feature("color_temp") + assert color_temp_feat + assert color_temp_feat.range == (2700, 5000) assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index 25addcfc3..ed97a8cf2 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -469,7 +469,9 @@ async def side_effect_func(*args, **kwargs): async def test_smart_temp_range(dev: Device): light = dev.modules.get(Module.Light) assert light - assert light.valid_temperature_range + color_temp_feat = light.get_feature("color_temp") + assert color_temp_feat + assert color_temp_feat.range @device_smart diff --git a/tests/test_bulb.py b/tests/test_bulb.py index 6956c4e8d..f7a77a8d2 100644 --- a/tests/test_bulb.py +++ b/tests/test_bulb.py @@ -25,7 +25,7 @@ async def test_hsv(dev: Device, turn_on): light = dev.modules.get(Module.Light) assert light await handle_turn_on(dev, turn_on) - assert light.is_color + assert light.has_feature("hsv") hue, saturation, brightness = light.hsv assert 0 <= hue <= 360 @@ -106,7 +106,7 @@ async def test_invalid_hsv( light = dev.modules.get(Module.Light) assert light await handle_turn_on(dev, turn_on) - assert light.is_color + assert light.has_feature("hsv") with pytest.raises(exception_cls, match=error): await light.set_hsv(hue, sat, brightness) @@ -124,7 +124,7 @@ async def test_color_state_information(dev: Device): async def test_hsv_on_non_color(dev: Device): light = dev.modules.get(Module.Light) assert light - assert not light.is_color + assert not light.has_feature("hsv") with pytest.raises(KasaException): await light.set_hsv(0, 0, 0) @@ -173,9 +173,6 @@ async def test_non_variable_temp(dev: Device): with pytest.raises(KasaException): await light.set_color_temp(2700) - with pytest.raises(KasaException): - print(light.valid_temperature_range) - with pytest.raises(KasaException): print(light.color_temp) diff --git a/tests/test_cli.py b/tests/test_cli.py index c14f6c8b4..42f6e12b0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,7 @@ from kasa import ( AuthenticationError, + ColorTempRange, Credentials, Device, DeviceError, @@ -523,7 +524,9 @@ async def test_emeter(dev: Device, mocker, runner): async def test_brightness(dev: Device, runner): res = await runner.invoke(brightness, obj=dev) - if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: + if not (light := dev.modules.get(Module.Light)) or not light.has_feature( + "brightness" + ): assert "This device does not support brightness." in res.output return @@ -540,13 +543,16 @@ async def test_brightness(dev: Device, runner): async def test_color_temperature(dev: Device, runner): res = await runner.invoke(temperature, obj=dev) - if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: + if not (light := dev.modules.get(Module.Light)) or not ( + color_temp_feat := light.get_feature("color_temp") + ): assert "Device does not support color temperature" in res.output return res = await runner.invoke(temperature, obj=dev) assert f"Color temperature: {light.color_temp}" in res.output - valid_range = light.valid_temperature_range + valid_range = color_temp_feat.range + assert isinstance(valid_range, ColorTempRange) assert f"(min: {valid_range.min}, max: {valid_range.max})" in res.output val = int((valid_range.min + valid_range.max) / 2) @@ -572,7 +578,7 @@ async def test_color_temperature(dev: Device, runner): async def test_color_hsv(dev: Device, runner: CliRunner): res = await runner.invoke(hsv, obj=dev) - if not (light := dev.modules.get(Module.Light)) or not light.is_color: + if not (light := dev.modules.get(Module.Light)) or not light.has_feature("hsv"): assert "Device does not support colors" in res.output return diff --git a/tests/test_common_modules.py b/tests/test_common_modules.py index 32863604f..cba1ef878 100644 --- a/tests/test_common_modules.py +++ b/tests/test_common_modules.py @@ -198,7 +198,7 @@ async def test_light_color_temp(dev: Device): light = next(get_parent_and_child_modules(dev, Module.Light)) assert light - if not light.is_variable_color_temp: + if not light.has_feature("color_temp"): pytest.skip( "Some smart light strips have color_temperature" " component but min and max are the same" diff --git a/tests/test_device.py b/tests/test_device.py index 45de4a287..7547182bd 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -280,19 +280,19 @@ async def test_deprecated_light_attributes(dev: Device): await _test_attribute(dev, "is_color", bool(light), "Light") await _test_attribute(dev, "is_variable_color_temp", bool(light), "Light") - exc = KasaException if light and not light.is_dimmable else None + exc = KasaException if light and not light.has_feature("brightness") else None await _test_attribute(dev, "brightness", bool(light), "Light", will_raise=exc) await _test_attribute( dev, "set_brightness", bool(light), "Light", 50, will_raise=exc ) - exc = KasaException if light and not light.is_color else None + exc = KasaException if light and not light.has_feature("hsv") else None await _test_attribute(dev, "hsv", bool(light), "Light", will_raise=exc) await _test_attribute( dev, "set_hsv", bool(light), "Light", 50, 50, 50, will_raise=exc ) - exc = KasaException if light and not light.is_variable_color_temp else None + exc = KasaException if light and not light.has_feature("color_temp") else None await _test_attribute(dev, "color_temp", bool(light), "Light", will_raise=exc) await _test_attribute( dev, "set_color_temp", bool(light), "Light", 2700, will_raise=exc From 5f84c69774d8cadfa9391fc80a42b61e6cfde787 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Thu, 12 Dec 2024 10:51:45 +0100 Subject: [PATCH 748/892] Add homebridge-kasa-python link to README (#1367) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 90c9ac0f3..b99970bbe 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,7 @@ See [supported devices in our documentation](SUPPORTED.md) for more detailed inf * [Home Assistant](https://www.home-assistant.io/integrations/tplink/) * [MQTT access to TP-Link devices, using python-kasa](https://github.com/flavio-fernandes/mqtt2kasa) +* [Homebridge Kasa Python Plug-In](https://github.com/ZeliardM/homebridge-kasa-python) ### Other related projects From 223f3318ea81fd143dc53c1b94efbcd8e2565012 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 13 Dec 2024 12:37:13 +0000 Subject: [PATCH 749/892] Use DeviceInfo consistently across devices (#1338) - Make model exclude region for `iot` devices. This is consistent with `smart` and `smartcam` devices. - Make region it's own attribute on `Device`. - Ensure that devices consistently use `_get_device_info` static methods for all information relating to device models. - Fix issue with firmware and hardware being the wrong way round for `smartcam` devices. --- kasa/cli/device.py | 10 ++++++++-- kasa/device.py | 22 +++++++++++++++++----- kasa/discover.py | 12 ++++++------ kasa/iot/iotdevice.py | 33 +++++++++++++++------------------ kasa/smart/smartchilddevice.py | 17 +++++++++++++++++ kasa/smart/smartdevice.py | 24 +++++++++--------------- kasa/smartcam/smartcamdevice.py | 10 +++++----- tests/device_fixtures.py | 8 ++++++-- tests/iot/test_iotdevice.py | 4 ++-- tests/smart/test_smartdevice.py | 7 +++++-- tests/test_discovery.py | 11 +++++------ tests/test_readme_examples.py | 2 +- 12 files changed, 96 insertions(+), 64 deletions(-) diff --git a/kasa/cli/device.py b/kasa/cli/device.py index 2e621368e..0ef8a76f8 100644 --- a/kasa/cli/device.py +++ b/kasa/cli/device.py @@ -41,8 +41,14 @@ async def state(ctx, dev: Device): echo(f"Device state: {dev.is_on}") echo(f"Time: {dev.time} (tz: {dev.timezone})") - echo(f"Hardware: {dev.hw_info['hw_ver']}") - echo(f"Software: {dev.hw_info['sw_ver']}") + echo( + f"Hardware: {dev.device_info.hardware_version}" + f"{' (' + dev.region + ')' if dev.region else ''}" + ) + echo( + f"Firmware: {dev.device_info.firmware_version}" + f" {dev.device_info.firmware_build}" + ) echo(f"MAC (rssi): {dev.mac} ({dev.rssi})") if verbose: echo(f"Location: {dev.location}") diff --git a/kasa/device.py b/kasa/device.py index 76d7a7c59..360682323 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -29,7 +29,7 @@ >>> dev.alias Bedroom Lamp Plug >>> dev.model -HS110(EU) +HS110 >>> dev.rssi -71 >>> dev.mac @@ -151,7 +151,7 @@ class WifiNetwork: @dataclass -class _DeviceInfo: +class DeviceInfo: """Device Model Information.""" short_name: str @@ -208,7 +208,7 @@ def __init__( self.protocol: BaseProtocol = protocol or IotProtocol( transport=XorTransport(config=config or DeviceConfig(host=host)), ) - self._last_update: Any = None + self._last_update: dict[str, Any] = {} _LOGGER.debug("Initializing %s of type %s", host, type(self)) self._device_type = DeviceType.Unknown # TODO: typing Any is just as using dict | None would require separate @@ -334,9 +334,21 @@ def model(self) -> str: """Returns the device model.""" @property + def region(self) -> str | None: + """Returns the device region.""" + return self.device_info.region + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + return self._get_device_info(self._last_update, self._discovery_info) + + @staticmethod @abstractmethod - def _model_region(self) -> str: - """Return device full model name and region.""" + def _get_device_info( + info: dict[str, Any], discovery_info: dict[str, Any] | None + ) -> DeviceInfo: + """Get device info.""" @property @abstractmethod diff --git a/kasa/discover.py b/kasa/discover.py index b7c545a2f..2bd988158 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -22,7 +22,7 @@ >>> >>> found_devices = await Discover.discover() >>> [dev.model for dev in found_devices.values()] -['KP303(UK)', 'HS110(EU)', 'L530E', 'KL430(US)', 'HS220(US)'] +['KP303', 'HS110', 'L530E', 'KL430', 'HS220'] You can pass username and password for devices requiring authentication @@ -65,17 +65,17 @@ >>> print(f"Discovered {dev.alias} (model: {dev.model})") >>> >>> devices = await Discover.discover(on_discovered=print_dev_info, credentials=creds) -Discovered Bedroom Power Strip (model: KP303(UK)) -Discovered Bedroom Lamp Plug (model: HS110(EU)) +Discovered Bedroom Power Strip (model: KP303) +Discovered Bedroom Lamp Plug (model: HS110) Discovered Living Room Bulb (model: L530) -Discovered Bedroom Lightstrip (model: KL430(US)) -Discovered Living Room Dimmer Switch (model: HS220(US)) +Discovered Bedroom Lightstrip (model: KL430) +Discovered Living Room Dimmer Switch (model: HS220) Discovering a single device returns a kasa.Device object. >>> device = await Discover.discover_single("127.0.0.1", credentials=creds) >>> device.model -'KP303(UK)' +'KP303' """ diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 90f63c973..851f21ccc 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any, cast from warnings import warn -from ..device import Device, WifiNetwork, _DeviceInfo +from ..device import Device, DeviceInfo, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..exceptions import KasaException @@ -43,7 +43,7 @@ def requires_update(f: Callable) -> Any: @functools.wraps(f) async def wrapped(*args: Any, **kwargs: Any) -> Any: self = args[0] - if self._last_update is None and ( + if not self._last_update and ( self._sys_info is None or f.__name__ not in self._sys_info ): raise KasaException("You need to await update() to access the data") @@ -54,7 +54,7 @@ async def wrapped(*args: Any, **kwargs: Any) -> Any: @functools.wraps(f) def wrapped(*args: Any, **kwargs: Any) -> Any: self = args[0] - if self._last_update is None and ( + if not self._last_update and ( self._sys_info is None or f.__name__ not in self._sys_info ): raise KasaException("You need to await update() to access the data") @@ -112,7 +112,7 @@ class IotDevice(Device): >>> dev.alias Bedroom Lamp Plug >>> dev.model - HS110(EU) + HS110 >>> dev.rssi -71 >>> dev.mac @@ -310,7 +310,7 @@ async def update(self, update_children: bool = True) -> None: # If this is the initial update, check only for the sysinfo # This is necessary as some devices crash on unexpected modules # See #105, #120, #161 - if self._last_update is None: + if not self._last_update: _LOGGER.debug("Performing the initial update to obtain sysinfo") response = await self.protocol.query(req) self._last_update = response @@ -452,7 +452,9 @@ def update_from_discover_info(self, info: dict[str, Any]) -> None: # This allows setting of some info properties directly # from partial discovery info that will then be found # by the requires_update decorator - self._set_sys_info(info) + discovery_model = info["device_model"] + no_region_model, _, _ = discovery_model.partition("(") + self._set_sys_info({**info, "model": no_region_model}) def _set_sys_info(self, sys_info: dict[str, Any]) -> None: """Set sys_info.""" @@ -471,18 +473,13 @@ class itself as @requires_update will be affected for other properties. """ return self._sys_info # type: ignore - @property # type: ignore - @requires_update - def model(self) -> str: - """Return device model.""" - sys_info = self._sys_info - return str(sys_info["model"]) - @property @requires_update - def _model_region(self) -> str: - """Return device full model name and region.""" - return self.model + def model(self) -> str: + """Returns the device model.""" + if self._last_update: + return self.device_info.short_name + return self._sys_info["model"] @property # type: ignore def alias(self) -> str | None: @@ -748,7 +745,7 @@ def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType: @staticmethod def _get_device_info( info: dict[str, Any], discovery_info: dict[str, Any] | None - ) -> _DeviceInfo: + ) -> DeviceInfo: """Get model information for a device.""" sys_info = _extract_sys_info(info) @@ -766,7 +763,7 @@ def _get_device_info( firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) auth = bool(discovery_info and ("mgt_encrypt_schm" in discovery_info)) - return _DeviceInfo( + return DeviceInfo( short_name=long_name, long_name=long_name, brand="kasa", diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index d49e814c8..2ef0454fe 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -6,6 +6,7 @@ import time from typing import Any +from ..device import DeviceInfo from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper @@ -50,6 +51,22 @@ def __init__( self._components_raw = component_info_raw self._components = self._parse_components(self._components_raw) + @property + def device_info(self) -> DeviceInfo: + """Return device info. + + Child device does not have it info and components in _last_update so + this overrides the base implementation to call _get_device_info with + info and components combined as they would be in _last_update. + """ + return self._get_device_info( + { + "get_device_info": self._info, + "component_nego": self._components_raw, + }, + None, + ) + async def update(self, update_children: bool = True) -> None: """Update child module info. diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index b95503522..5fd221157 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -9,7 +9,7 @@ from datetime import UTC, datetime, timedelta, tzinfo from typing import TYPE_CHECKING, Any, TypeAlias, cast -from ..device import Device, WifiNetwork, _DeviceInfo +from ..device import Device, DeviceInfo, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode @@ -69,7 +69,6 @@ def __init__( self._modules: dict[str | ModuleName[Module], SmartModule] = {} self._parent: SmartDevice | None = None self._children: Mapping[str, SmartDevice] = {} - self._last_update = {} self._last_update_time: float | None = None self._on_since: datetime | None = None self._info: dict[str, Any] = {} @@ -497,18 +496,13 @@ def sys_info(self) -> dict[str, Any]: @property def model(self) -> str: """Returns the device model.""" - return str(self._info.get("model")) + # If update hasn't been called self._device_info can't be used + if self._last_update: + return self.device_info.short_name - @property - def _model_region(self) -> str: - """Return device full model name and region.""" - if (disco := self._discovery_info) and ( - disco_model := disco.get("device_model") - ): - return disco_model - # Some devices have the region in the specs element. - region = f"({specs})" if (specs := self._info.get("specs")) else "" - return f"{self.model}{region}" + disco_model = str(self._info.get("device_model")) + long_name, _, _ = disco_model.partition("(") + return long_name @property def alias(self) -> str | None: @@ -808,7 +802,7 @@ def _get_device_type_from_components( @staticmethod def _get_device_info( info: dict[str, Any], discovery_info: dict[str, Any] | None - ) -> _DeviceInfo: + ) -> DeviceInfo: """Get model information for a device.""" di = info["get_device_info"] components = [comp["id"] for comp in info["component_nego"]["component_list"]] @@ -837,7 +831,7 @@ def _get_device_info( # Brand inferred from SMART.KASAPLUG/SMART.TAPOPLUG etc. brand = devicetype[:4].lower() - return _DeviceInfo( + return DeviceInfo( short_name=short_name, long_name=long_name, brand=brand, diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index b383a4b46..b3058ab33 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -5,7 +5,7 @@ import logging from typing import Any, cast -from ..device import _DeviceInfo +from ..device import DeviceInfo from ..device_type import DeviceType from ..module import Module from ..protocols.smartcamprotocol import _ChildCameraProtocolWrapper @@ -37,7 +37,7 @@ def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType: @staticmethod def _get_device_info( info: dict[str, Any], discovery_info: dict[str, Any] | None - ) -> _DeviceInfo: + ) -> DeviceInfo: """Get model information for a device.""" basic_info = info["getDeviceInfo"]["device_info"]["basic_info"] short_name = basic_info["device_model"] @@ -45,7 +45,7 @@ def _get_device_info( device_type = SmartCamDevice._get_device_type_from_sysinfo(basic_info) fw_version_full = basic_info["sw_version"] firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) - return _DeviceInfo( + return DeviceInfo( short_name=basic_info["device_model"], long_name=long_name, brand="tapo", @@ -248,8 +248,8 @@ async def set_alias(self, alias: str) -> dict: def hw_info(self) -> dict: """Return hardware info for the device.""" return { - "sw_ver": self._info.get("hw_ver"), - "hw_ver": self._info.get("fw_ver"), + "sw_ver": self._info.get("fw_ver"), + "hw_ver": self._info.get("hw_ver"), "mac": self._info.get("mac"), "type": self._info.get("type"), "hwId": self._info.get("hwId"), diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index dd35cf8f0..6679d0a5c 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -473,8 +473,12 @@ def get_nearest_fixture_to_ip(dev): assert protocol_fixtures, "Unknown device type" # This will get the best fixture with a match on model region - if model_region_fixtures := filter_fixtures( - "", model_filter={dev._model_region}, fixture_list=protocol_fixtures + if (di := dev.device_info) and ( + model_region_fixtures := filter_fixtures( + "", + model_filter={di.long_name + (f"({di.region})" if di.region else "")}, + fixture_list=protocol_fixtures, + ) ): return next(iter(model_region_fixtures)) diff --git a/tests/iot/test_iotdevice.py b/tests/iot/test_iotdevice.py index 124910b79..858c5fbcf 100644 --- a/tests/iot/test_iotdevice.py +++ b/tests/iot/test_iotdevice.py @@ -99,7 +99,7 @@ async def test_invalid_connection(mocker, dev): @has_emeter_iot async def test_initial_update_emeter(dev, mocker): """Test that the initial update performs second query if emeter is available.""" - dev._last_update = None + dev._last_update = {} dev._legacy_features = set() spy = mocker.spy(dev.protocol, "query") await dev.update() @@ -111,7 +111,7 @@ async def test_initial_update_emeter(dev, mocker): @no_emeter_iot async def test_initial_update_no_emeter(dev, mocker): """Test that the initial update performs second query if emeter is available.""" - dev._last_update = None + dev._last_update = {} dev._legacy_features = set() spy = mocker.spy(dev.protocol, "query") await dev.update() diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index ed97a8cf2..a7d831e05 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -62,11 +62,14 @@ async def test_device_type_no_update(discovery_mock, caplog: pytest.LogCaptureFi assert repr(dev) == f"" discovery_result = copy.deepcopy(discovery_mock.discovery_data["result"]) + + disco_model = discovery_result["device_model"] + short_model, _, _ = disco_model.partition("(") dev.update_from_discover_info(discovery_result) assert dev.device_type is DeviceType.Unknown assert ( repr(dev) - == f"" + == f"" ) discovery_result["device_type"] = "SMART.FOOBAR" dev.update_from_discover_info(discovery_result) @@ -74,7 +77,7 @@ async def test_device_type_no_update(discovery_mock, caplog: pytest.LogCaptureFi assert dev.device_type is DeviceType.Plug assert ( repr(dev) - == f"" + == f"" ) assert "Unknown device type, falling back to plug" in caplog.text diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 7069e32f6..59a337d2e 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -390,13 +390,12 @@ async def test_device_update_from_new_discovery_info(discovery_mock): device_class = Discover._get_device_class(discovery_data) device = device_class("127.0.0.1") discover_info = DiscoveryResult.from_dict(discovery_data["result"]) - discover_dump = discover_info.to_dict() - model, _, _ = discover_dump["device_model"].partition("(") - discover_dump["model"] = model - device.update_from_discover_info(discover_dump) - assert device.mac == discover_dump["mac"].replace("-", ":") - assert device.model == model + device.update_from_discover_info(discovery_data["result"]) + + assert device.mac == discover_info.mac.replace("-", ":") + no_region_model, _, _ = discover_info.device_model.partition("(") + assert device.model == no_region_model # TODO implement requires_update for SmartDevice if isinstance(device, IotDevice): diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index 01294399b..b6513476f 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -19,7 +19,7 @@ def test_bulb_examples(mocker): assert not res["failed"] -def test_smartdevice_examples(mocker): +def test_iotdevice_examples(mocker): """Use HS110 for emeter examples.""" p = asyncio.run(get_device_for_fixture_protocol("HS110(EU)_1.0_1.2.5.json", "IOT")) asyncio.run(p.set_alias("Bedroom Lamp Plug")) From 2ca6d3ebe9b5b78719299b1fd69827581829a50f Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 13 Dec 2024 19:45:38 +0000 Subject: [PATCH 750/892] Add bare-bones matter modules to smart and smartcam devices (#1371) --- kasa/module.py | 2 ++ kasa/smart/modules/__init__.py | 2 ++ kasa/smart/modules/matter.py | 43 +++++++++++++++++++++++++++++ kasa/smart/smartmodule.py | 6 ++-- kasa/smartcam/modules/__init__.py | 2 ++ kasa/smartcam/modules/matter.py | 44 ++++++++++++++++++++++++++++++ kasa/smartcam/smartcammodule.py | 7 +++-- tests/fakeprotocol_smart.py | 7 +++++ tests/fakeprotocol_smartcam.py | 30 ++++++++++++++++++-- tests/fixtureinfo.py | 19 +++++++++---- tests/smart/modules/test_matter.py | 20 ++++++++++++++ tests/smart/test_smartdevice.py | 13 +++++++++ 12 files changed, 183 insertions(+), 12 deletions(-) create mode 100644 kasa/smart/modules/matter.py create mode 100644 kasa/smartcam/modules/matter.py create mode 100644 tests/smart/modules/test_matter.py diff --git a/kasa/module.py b/kasa/module.py index 2b2e65f93..b86d15210 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -151,6 +151,8 @@ class Module(ABC): ) TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs") + Matter: Final[ModuleName[smart.Matter]] = ModuleName("Matter") + # SMARTCAM only modules Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera") diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 367548019..fd93c7c06 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -23,6 +23,7 @@ from .lightpreset import LightPreset from .lightstripeffect import LightStripEffect from .lighttransition import LightTransition +from .matter import Matter from .motionsensor import MotionSensor from .overheatprotection import OverheatProtection from .reportmode import ReportMode @@ -66,4 +67,5 @@ "Thermostat", "SmartLightEffect", "OverheatProtection", + "Matter", ] diff --git a/kasa/smart/modules/matter.py b/kasa/smart/modules/matter.py new file mode 100644 index 000000000..c6bfe2d85 --- /dev/null +++ b/kasa/smart/modules/matter.py @@ -0,0 +1,43 @@ +"""Implementation of matter module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class Matter(SmartModule): + """Implementation of matter module.""" + + QUERY_GETTER_NAME: str = "get_matter_setup_info" + REQUIRED_COMPONENT = "matter" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="matter_setup_code", + name="Matter setup code", + container=self, + attribute_getter=lambda x: x.info["setup_code"], + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + self._add_feature( + Feature( + self._device, + id="matter_setup_payload", + name="Matter setup payload", + container=self, + attribute_getter=lambda x: x.info["setup_payload"], + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + + @property + def info(self) -> dict[str, str]: + """Matter setup info.""" + return self.data diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index ab6ae667d..31fc8f353 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -57,7 +57,7 @@ class SmartModule(Module): #: Module is initialized, if any of the given keys exists in the sysinfo SYSINFO_LOOKUP_KEYS: list[str] = [] #: Query to execute during the main update cycle - QUERY_GETTER_NAME: str + QUERY_GETTER_NAME: str = "" REGISTERED_MODULES: dict[str, type[SmartModule]] = {} @@ -138,7 +138,9 @@ def query(self) -> dict: Default implementation uses the raw query getter w/o parameters. """ - return {self.QUERY_GETTER_NAME: None} + if self.QUERY_GETTER_NAME: + return {self.QUERY_GETTER_NAME: None} + return {} async def call(self, method: str, params: dict | None = None) -> dict: """Call a method. diff --git a/kasa/smartcam/modules/__init__.py b/kasa/smartcam/modules/__init__.py index 16d595811..5ac375843 100644 --- a/kasa/smartcam/modules/__init__.py +++ b/kasa/smartcam/modules/__init__.py @@ -5,6 +5,7 @@ from .childdevice import ChildDevice from .device import DeviceModule from .led import Led +from .matter import Matter from .pantilt import PanTilt from .time import Time @@ -16,4 +17,5 @@ "Led", "PanTilt", "Time", + "Matter", ] diff --git a/kasa/smartcam/modules/matter.py b/kasa/smartcam/modules/matter.py new file mode 100644 index 000000000..8ea0e4cf8 --- /dev/null +++ b/kasa/smartcam/modules/matter.py @@ -0,0 +1,44 @@ +"""Implementation of matter module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + + +class Matter(SmartCamModule): + """Implementation of matter module.""" + + QUERY_GETTER_NAME = "getMatterSetupInfo" + QUERY_MODULE_NAME = "matter" + REQUIRED_COMPONENT = "matter" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="matter_setup_code", + name="Matter setup code", + container=self, + attribute_getter=lambda x: x.info["setup_code"], + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + self._add_feature( + Feature( + self._device, + id="matter_setup_payload", + name="Matter setup payload", + container=self, + attribute_getter=lambda x: x.info["setup_payload"], + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + + @property + def info(self) -> dict[str, str]: + """Matter setup info.""" + return self.data diff --git a/kasa/smartcam/smartcammodule.py b/kasa/smartcam/smartcammodule.py index ca1a3b824..390335974 100644 --- a/kasa/smartcam/smartcammodule.py +++ b/kasa/smartcam/smartcammodule.py @@ -21,8 +21,6 @@ class SmartCamModule(SmartModule): SmartCamAlarm: Final[ModuleName[modules.Alarm]] = ModuleName("SmartCamAlarm") - #: Query to execute during the main update cycle - QUERY_GETTER_NAME: str #: Module name to be queried QUERY_MODULE_NAME: str #: Section name or names to be queried @@ -37,6 +35,8 @@ def query(self) -> dict: Default implementation uses the raw query getter w/o parameters. """ + if not self.QUERY_GETTER_NAME: + return {} section_names = ( {"name": self.QUERY_SECTION_NAMES} if self.QUERY_SECTION_NAMES else {} ) @@ -86,7 +86,8 @@ def data(self) -> dict: f" for '{self._module}'" ) - return query_resp.get(self.QUERY_MODULE_NAME) + # Some calls return the data under the module, others not + return query_resp.get(self.QUERY_MODULE_NAME, query_resp) else: found = {key: val for key, val in dev._last_update.items() if key in q} for key in q: diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index 448729ca7..a34384a2d 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -151,6 +151,13 @@ def credentials_hash(self): "energy_monitoring", {"igain": 10861, "vgain": 118657}, ), + "get_matter_setup_info": ( + "matter", + { + "setup_code": "00000000000", + "setup_payload": "00:0000000-0000.00.000", + }, + ), } async def send(self, request: str): diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index d110e7845..68cebd1e0 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -44,6 +44,7 @@ def __init__( ), ), ) + self.fixture_name = fixture_name # When True verbatim will bypass any extra processing of missing # methods and is used to test the fixture creation itself. @@ -58,6 +59,13 @@ def __init__( # self.child_protocols = self._get_child_protocols() self.list_return_size = list_return_size + self.components = { + comp["name"]: comp["version"] + for comp in self.info["getAppComponentList"]["app_component"][ + "app_component_list" + ] + } + @property def default_port(self): """Default port for the transport.""" @@ -112,6 +120,15 @@ def _get_param_set_value(info: dict, set_keys: list[str], value): info = info[key] info[set_keys[-1]] = value + FIXTURE_MISSING_MAP = { + "getMatterSetupInfo": ( + "matter", + { + "setup_code": "00000000000", + "setup_payload": "00:0000000-0000.00.000", + }, + ) + } # Setters for when there's not a simple mapping of setters to getters SETTERS = { ("system", "sys", "dev_alias"): [ @@ -217,8 +234,17 @@ async def _send_request(self, request_dict: dict): start_index : start_index + self.list_return_size ] return {"result": result, "error_code": 0} - else: - return {"error_code": -1} + if ( + # FIXTURE_MISSING is for service calls not in place when + # SMART fixtures started to be generated + missing_result := self.FIXTURE_MISSING_MAP.get(method) + ) and missing_result[0] in self.components: + # Copy to info so it will work with update methods + info[method] = copy.deepcopy(missing_result[1]) + result = copy.deepcopy(info[method]) + return {"result": result, "error_code": 0} + + return {"error_code": -1} return {"error_code": -1} async def close(self) -> None: diff --git a/tests/fixtureinfo.py b/tests/fixtureinfo.py index fc1dd1fb8..62b712283 100644 --- a/tests/fixtureinfo.py +++ b/tests/fixtureinfo.py @@ -145,12 +145,21 @@ def _model_startswith_match(fixture_data: FixtureInfo, starts_with: str): def _component_match( fixture_data: FixtureInfo, component_filter: str | ComponentFilter ): - if (component_nego := fixture_data.data.get("component_nego")) is None: + components = {} + if component_nego := fixture_data.data.get("component_nego"): + components = { + component["id"]: component["ver_code"] + for component in component_nego["component_list"] + } + if get_app_component_list := fixture_data.data.get("getAppComponentList"): + components = { + component["name"]: component["version"] + for component in get_app_component_list["app_component"][ + "app_component_list" + ] + } + if not components: return False - components = { - component["id"]: component["ver_code"] - for component in component_nego["component_list"] - } if isinstance(component_filter, str): return component_filter in components else: diff --git a/tests/smart/modules/test_matter.py b/tests/smart/modules/test_matter.py new file mode 100644 index 000000000..d3ff80730 --- /dev/null +++ b/tests/smart/modules/test_matter.py @@ -0,0 +1,20 @@ +from kasa import Module +from kasa.smart import SmartDevice + +from ...device_fixtures import parametrize + +matter = parametrize( + "has matter", component_filter="matter", protocol_filter={"SMART", "SMARTCAM"} +) + + +@matter +async def test_info(dev: SmartDevice): + """Test matter info.""" + matter = dev.modules.get(Module.Matter) + assert matter + assert matter.info + setup_code = dev.features.get("matter_setup_code") + assert setup_code + setup_payload = dev.features.get("matter_setup_payload") + assert setup_payload diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index a7d831e05..83635d8ed 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -533,3 +533,16 @@ class NonExistingComponent(SmartModule): assert "AvailableComponent" in dev.modules assert "NonExistingComponent" not in dev.modules + + +async def test_smartmodule_query(): + """Test that a module that doesn't set QUERY_GETTER_NAME has empty query.""" + + class DummyModule(SmartModule): + pass + + dummy_device = await get_device_for_fixture_protocol( + "KS240(US)_1.0_1.0.5.json", "SMART" + ) + mod = DummyModule(dummy_device, "dummy") + assert mod.query() == {} From 59e5073509945ce83282ba6404df283576b61899 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 13 Dec 2024 21:23:58 +0000 Subject: [PATCH 751/892] Update docs for new FeatureAttribute behaviour (#1365) Co-authored-by: Teemu R. --- docs/source/featureattributes.md | 13 ++++ docs/source/reference.md | 60 ++++------------- docs/source/topics.md | 110 +++---------------------------- kasa/module.py | 3 + 4 files changed, 37 insertions(+), 149 deletions(-) create mode 100644 docs/source/featureattributes.md diff --git a/docs/source/featureattributes.md b/docs/source/featureattributes.md new file mode 100644 index 000000000..69285ad46 --- /dev/null +++ b/docs/source/featureattributes.md @@ -0,0 +1,13 @@ +Some modules have attributes that may not be supported by the device. +These attributes will be annotated with a `FeatureAttribute` return type. +For example: + +```py + @property + def hsv(self) -> Annotated[HSV, FeatureAttribute()]: + """Return the current HSV state of the bulb.""" +``` + +You can test whether a `FeatureAttribute` is supported by the device with {meth}`kasa.Module.has_feature` +or {meth}`kasa.Module.get_feature` which will return `None` if not supported. +Calling these methods on attributes not annotated with a `FeatureAttribute` return type will return an error. diff --git a/docs/source/reference.md b/docs/source/reference.md index f4771ac5d..90493c9c2 100644 --- a/docs/source/reference.md +++ b/docs/source/reference.md @@ -13,11 +13,13 @@ ## Device +% N.B. Credentials clashes with autodoc ```{eval-rst} .. autoclass:: Device :members: :undoc-members: + :exclude-members: Credentials ``` @@ -28,7 +30,6 @@ .. autoclass:: Credentials :members: :undoc-members: - :noindex: ``` @@ -61,15 +62,11 @@ ```{eval-rst} .. autoclass:: Module - :noindex: :members: - :inherited-members: - :undoc-members: ``` ```{eval-rst} .. autoclass:: Feature - :noindex: :members: :inherited-members: :undoc-members: @@ -77,7 +74,6 @@ ```{eval-rst} .. automodule:: kasa.interfaces - :noindex: :members: :inherited-members: :undoc-members: @@ -85,63 +81,28 @@ ## Protocols and transports -```{eval-rst} -.. autoclass:: kasa.protocols.BaseProtocol - :members: - :inherited-members: - :undoc-members: -``` - -```{eval-rst} -.. autoclass:: kasa.protocols.IotProtocol - :members: - :inherited-members: - :undoc-members: -``` ```{eval-rst} -.. autoclass:: kasa.protocols.SmartProtocol +.. automodule:: kasa.protocols :members: - :inherited-members: + :imported-members: :undoc-members: + :exclude-members: SmartErrorCode + :no-index: ``` ```{eval-rst} -.. autoclass:: kasa.transports.BaseTransport +.. automodule:: kasa.transports :members: - :inherited-members: + :imported-members: :undoc-members: + :no-index: ``` -```{eval-rst} -.. autoclass:: kasa.transports.XorTransport - :members: - :inherited-members: - :undoc-members: -``` -```{eval-rst} -.. autoclass:: kasa.transports.KlapTransport - :members: - :inherited-members: - :undoc-members: -``` - -```{eval-rst} -.. autoclass:: kasa.transports.KlapTransportV2 - :members: - :inherited-members: - :undoc-members: -``` +## Errors and exceptions -```{eval-rst} -.. autoclass:: kasa.transports.AesTransport - :members: - :inherited-members: - :undoc-members: -``` -## Errors and exceptions ```{eval-rst} .. autoclass:: kasa.exceptions.KasaException @@ -171,3 +132,4 @@ .. autoclass:: kasa.exceptions.TimeoutError :members: :undoc-members: +``` diff --git a/docs/source/topics.md b/docs/source/topics.md index 0dcc60d19..f7d0cdd50 100644 --- a/docs/source/topics.md +++ b/docs/source/topics.md @@ -80,14 +80,17 @@ This can be done using the {attr}`~kasa.Device.internal_state` property. ## Modules and Features The functionality provided by all {class}`~kasa.Device` instances is (mostly) done inside separate modules. -While the individual device-type specific classes provide an easy access for the most import features, -you can also access individual modules through {attr}`kasa.Device.modules`. -You can get the list of supported modules for a given device instance using {attr}`~kasa.Device.supported_modules`. +While the device class provides easy access for most device related attributes, +for components like `light` and `camera` you can access the module through {attr}`kasa.Device.modules`. +The module names are handily available as constants on {class}`~kasa.Module` and will return type aware values from the collection. -```{note} -If you only need some module-specific information, -you can call the wanted method on the module to avoid using {meth}`~kasa.Device.update`. -``` +Features represent individual pieces of functionality within a module like brightness, hsv and temperature within a light module. +They allow for instrospection and can be accessed through {attr}`kasa.Device.features`. +Attributes can be accessed via a `Feature` or a module attribute depending on the use case. +Modules tend to provide richer functionality but using the features does not require an understanding of the module api. + +:::{include} featureattributes.md +::: (topics-protocols-and-transports)= ## Protocols and Transports @@ -137,96 +140,3 @@ The base exception for all library errors is {class}`KasaException `. - If the device fails to respond within a timeout the library raises a {class}`TimeoutError `. - All other failures will raise the base {class}`KasaException ` class. - - diff --git a/kasa/module.py b/kasa/module.py index b86d15210..2ca293071 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -21,6 +21,9 @@ >>> print(light.brightness) 100 +.. include:: ../featureattributes.md + :parser: myst_parser.sphinx_ + To see whether a device supports specific functionality, you can check whether the module has that feature: From c439530f9328fe47d36ad5d1f905b8bffc3cd2cf Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sat, 14 Dec 2024 13:34:58 +0000 Subject: [PATCH 752/892] Add bare bones homekit modules smart and smartcam devices (#1370) --- kasa/module.py | 1 + kasa/smart/modules/__init__.py | 2 ++ kasa/smart/modules/homekit.py | 32 +++++++++++++++++++++++++++++ kasa/smartcam/modules/__init__.py | 2 ++ kasa/smartcam/modules/homekit.py | 16 +++++++++++++++ tests/fakeprotocol_smart.py | 9 ++++++++ tests/smart/modules/test_homekit.py | 16 +++++++++++++++ 7 files changed, 78 insertions(+) create mode 100644 kasa/smart/modules/homekit.py create mode 100644 kasa/smartcam/modules/homekit.py create mode 100644 tests/smart/modules/test_homekit.py diff --git a/kasa/module.py b/kasa/module.py index 2ca293071..754814ecd 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -154,6 +154,7 @@ class Module(ABC): ) TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs") + HomeKit: Final[ModuleName[smart.HomeKit]] = ModuleName("HomeKit") Matter: Final[ModuleName[smart.Matter]] = ModuleName("Matter") # SMARTCAM only modules diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index fd93c7c06..ae9fb68f3 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -16,6 +16,7 @@ from .fan import Fan from .firmware import Firmware from .frostprotection import FrostProtection +from .homekit import HomeKit from .humiditysensor import HumiditySensor from .led import Led from .light import Light @@ -67,5 +68,6 @@ "Thermostat", "SmartLightEffect", "OverheatProtection", + "HomeKit", "Matter", ] diff --git a/kasa/smart/modules/homekit.py b/kasa/smart/modules/homekit.py new file mode 100644 index 000000000..2df8db1f5 --- /dev/null +++ b/kasa/smart/modules/homekit.py @@ -0,0 +1,32 @@ +"""Implementation of homekit module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class HomeKit(SmartModule): + """Implementation of homekit module.""" + + QUERY_GETTER_NAME: str = "get_homekit_info" + REQUIRED_COMPONENT = "homekit" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="homekit_setup_code", + name="Homekit setup code", + container=self, + attribute_getter=lambda x: x.info["mfi_setup_code"], + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + + @property + def info(self) -> dict[str, str]: + """Homekit mfi setup info.""" + return self.data diff --git a/kasa/smartcam/modules/__init__.py b/kasa/smartcam/modules/__init__.py index 5ac375843..a3f51c872 100644 --- a/kasa/smartcam/modules/__init__.py +++ b/kasa/smartcam/modules/__init__.py @@ -4,6 +4,7 @@ from .camera import Camera from .childdevice import ChildDevice from .device import DeviceModule +from .homekit import HomeKit from .led import Led from .matter import Matter from .pantilt import PanTilt @@ -17,5 +18,6 @@ "Led", "PanTilt", "Time", + "HomeKit", "Matter", ] diff --git a/kasa/smartcam/modules/homekit.py b/kasa/smartcam/modules/homekit.py new file mode 100644 index 000000000..a35de4f96 --- /dev/null +++ b/kasa/smartcam/modules/homekit.py @@ -0,0 +1,16 @@ +"""Implementation of homekit module.""" + +from __future__ import annotations + +from ..smartcammodule import SmartCamModule + + +class HomeKit(SmartCamModule): + """Implementation of homekit module.""" + + REQUIRED_COMPONENT = "homekit" + + @property + def info(self) -> dict[str, str]: + """Not supported, return empty dict.""" + return {} diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index a34384a2d..c0222b995 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -114,6 +114,15 @@ def credentials_hash(self): "type": 0, }, ), + "get_homekit_info": ( + "homekit", + { + "mfi_setup_code": "000-00-000", + "mfi_setup_id": "0000", + "mfi_token_token": "000000000000000000000000000000000", + "mfi_token_uuid": "00000000-0000-0000-0000-000000000000", + }, + ), "get_auto_update_info": ( "firmware", {"enable": True, "random_range": 120, "time": 180}, diff --git a/tests/smart/modules/test_homekit.py b/tests/smart/modules/test_homekit.py new file mode 100644 index 000000000..819923986 --- /dev/null +++ b/tests/smart/modules/test_homekit.py @@ -0,0 +1,16 @@ +from kasa import Module +from kasa.smart import SmartDevice + +from ...device_fixtures import parametrize + +homekit = parametrize( + "has homekit", component_filter="homekit", protocol_filter={"SMART"} +) + + +@homekit +async def test_info(dev: SmartDevice): + """Test homekit info.""" + homekit = dev.modules.get(Module.HomeKit) + assert homekit + assert homekit.info From f8503e4df6fdab56ae4dbe6d8eceaca6bef281d7 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sun, 15 Dec 2024 16:03:12 +0000 Subject: [PATCH 753/892] Force single for some smartcam requests (#1374) `onboarding` requests do not return the method key and need to be sent as single requests. --- kasa/protocols/smartprotocol.py | 28 +++++++++- tests/fakeprotocol_smartcam.py | 17 ++++-- tests/protocols/test_smartprotocol.py | 80 +++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 8 deletions(-) diff --git a/kasa/protocols/smartprotocol.py b/kasa/protocols/smartprotocol.py index 0e092547f..deb1a4051 100644 --- a/kasa/protocols/smartprotocol.py +++ b/kasa/protocols/smartprotocol.py @@ -69,6 +69,13 @@ "map_data": lambda x: "#SCRUBBED_MAPDATA#" if x else "", } +# Queries that are known not to work properly when sent as a +# multiRequest. They will not return the `method` key. +FORCE_SINGLE_REQUEST = { + "getConnectStatus", + "scanApList", +} + class SmartProtocol(BaseProtocol): """Class for the new TPLink SMART protocol.""" @@ -89,6 +96,7 @@ def __init__( self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE ) self._redact_data = True + self._method_missing_logged = False def get_smart_request(self, method: str, params: dict | None = None) -> str: """Get a request message as a string.""" @@ -178,6 +186,7 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic multi_requests = [ {"method": method, "params": params} if params else {"method": method} for method, params in requests.items() + if method not in FORCE_SINGLE_REQUEST ] end = len(multi_requests) @@ -246,7 +255,20 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic responses = response_step["result"]["responses"] for response in responses: - method = response["method"] + # some smartcam devices calls do not populate the method key + # these should be defined in DO_NOT_SEND_AS_MULTI_REQUEST. + if not (method := response.get("method")): + if not self._method_missing_logged: + # Avoid spamming the logs + self._method_missing_logged = True + _LOGGER.error( + "No method key in response for %s, skipping: %s", + self._host, + response_step, + ) + # These will end up being queried individually + continue + self._handle_response_error_code( response, method, raise_on_error=raise_on_error ) @@ -255,7 +277,9 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic result, method, retry_count=retry_count ) multi_result[method] = result - # Multi requests don't continue after errors so requery any missing + + # Multi requests don't continue after errors so requery any missing. + # Will also query individually any DO_NOT_SEND_AS_MULTI_REQUEST. for method, params in requests.items(): if method not in multi_result: resp = await self._transport.send( diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index 68cebd1e0..e8cc6f301 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -34,6 +34,7 @@ def __init__( list_return_size=10, is_child=False, verbatim=False, + components_not_included=False, ): super().__init__( config=DeviceConfig( @@ -59,12 +60,16 @@ def __init__( # self.child_protocols = self._get_child_protocols() self.list_return_size = list_return_size - self.components = { - comp["name"]: comp["version"] - for comp in self.info["getAppComponentList"]["app_component"][ - "app_component_list" - ] - } + # Setting this flag allows tests to create dummy transports without + # full fixture info for testing specific cases like list handling etc + self.components_not_included = (components_not_included,) + if not components_not_included: + self.components = { + comp["name"]: comp["version"] + for comp in self.info["getAppComponentList"]["app_component"][ + "app_component_list" + ] + } @property def default_port(self): diff --git a/tests/protocols/test_smartprotocol.py b/tests/protocols/test_smartprotocol.py index 988c95eb2..7961df68d 100644 --- a/tests/protocols/test_smartprotocol.py +++ b/tests/protocols/test_smartprotocol.py @@ -2,6 +2,7 @@ import pytest import pytest_mock +from pytest_mock import MockerFixture from kasa.exceptions import ( SMART_RETRYABLE_ERRORS, @@ -14,6 +15,7 @@ from ..conftest import device_smart from ..fakeprotocol_smart import FakeSmartTransport +from ..fakeprotocol_smartcam import FakeSmartCamTransport DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} DUMMY_MULTIPLE_QUERY = { @@ -448,3 +450,81 @@ async def test_smart_queries_redaction( await dev.update() assert device_id not in caplog.text assert "REDACTED_" + device_id[9::] in caplog.text + + +async def test_no_method_returned_multiple( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """Test protocol handles multiple requests that don't return the method.""" + req = { + "getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}}, + "getAppComponentList": {"app_component": {"name": "app_component_list"}}, + } + res = { + "result": { + "responses": [ + { + "method": "getDeviceInfo", + "result": { + "device_info": { + "basic_info": { + "device_model": "C210", + }, + } + }, + "error_code": 0, + }, + { + "result": {"app_component": {"app_component_list": []}}, + "error_code": 0, + }, + ] + }, + "error_code": 0, + } + + transport = FakeSmartCamTransport( + {}, + "dummy-name", + components_not_included=True, + ) + protocol = SmartProtocol(transport=transport) + mocker.patch.object(protocol._transport, "send", return_value=res) + await protocol.query(req) + assert "No method key in response" in caplog.text + caplog.clear() + await protocol.query(req) + assert "No method key in response" not in caplog.text + + +async def test_no_multiple_methods( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """Test protocol sends NO_MULTI methods as single call.""" + req = { + "getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}}, + "getConnectStatus": {"onboarding": {"get_connect_status": {}}}, + } + info = { + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "Home", + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": {"current_ssid": "", "err_code": 0, "status": 0} + } + }, + } + transport = FakeSmartCamTransport( + info, + "dummy-name", + components_not_included=True, + ) + protocol = SmartProtocol(transport=transport) + send_spy = mocker.spy(protocol._transport, "send") + await protocol.query(req) + assert send_spy.call_count == 2 From 031ebcd97f5e9a23bdd96c430db14fd0a085624a Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 16 Dec 2024 12:19:25 +0000 Subject: [PATCH 754/892] Update docs for Tapo Lab Third-Party compatibility (#1380) --- README.md | 4 ++++ SUPPORTED.md | 3 +++ 2 files changed, 7 insertions(+) diff --git a/README.md b/README.md index b99970bbe..58f3c826c 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,10 @@ The following devices have been tested and confirmed as working. If your device > [!NOTE] > The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed. +> [!NOTE] +> Some firmware versions of Tapo Cameras will not authenticate unless you enable "Tapo Lab" > "Third-Party Compatibility" in the native Tapo app. +> Alternatively, you can factory reset and then prevent the device from accessing the internet. + ### Supported Kasa devices diff --git a/SUPPORTED.md b/SUPPORTED.md index ba7726cc3..d3c1f1e61 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -5,6 +5,9 @@ The following devices have been tested and confirmed as working. If your device > [!NOTE] > The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed. +> [!NOTE] +> Some firmware versions of Tapo Cameras will not authenticate unless you enable "Tapo Lab" > "Third-Party Compatibility" in the native Tapo app. +> Alternatively, you can factory reset and then prevent the device from accessing the internet. From e9109447a7818bedfb1b34fd574e76511c0adf54 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 16 Dec 2024 12:20:26 +0000 Subject: [PATCH 755/892] Add smartcam modules to package inits (#1376) --- kasa/__init__.py | 3 ++- kasa/protocols/__init__.py | 2 ++ kasa/protocols/smartcamprotocol.py | 2 +- kasa/transports/__init__.py | 2 ++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/kasa/__init__.py b/kasa/__init__.py index ee52eb3af..b8871f997 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -38,7 +38,7 @@ from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState from kasa.interfaces.thermostat import Thermostat, ThermostatState from kasa.module import Module -from kasa.protocols import BaseProtocol, IotProtocol, SmartProtocol +from kasa.protocols import BaseProtocol, IotProtocol, SmartCamProtocol, SmartProtocol from kasa.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401 from kasa.smartcam.modules.camera import StreamResolution from kasa.transports import BaseTransport @@ -52,6 +52,7 @@ "BaseTransport", "IotProtocol", "SmartProtocol", + "SmartCamProtocol", "LightState", "TurnOnBehaviors", "TurnOnBehavior", diff --git a/kasa/protocols/__init__.py b/kasa/protocols/__init__.py index 44130d7f2..b994d7324 100644 --- a/kasa/protocols/__init__.py +++ b/kasa/protocols/__init__.py @@ -2,6 +2,7 @@ from .iotprotocol import IotProtocol from .protocol import BaseProtocol +from .smartcamprotocol import SmartCamProtocol from .smartprotocol import SmartErrorCode, SmartProtocol __all__ = [ @@ -9,4 +10,5 @@ "IotProtocol", "SmartErrorCode", "SmartProtocol", + "SmartCamProtocol", ] diff --git a/kasa/protocols/smartcamprotocol.py b/kasa/protocols/smartcamprotocol.py index 12caa207b..324f80563 100644 --- a/kasa/protocols/smartcamprotocol.py +++ b/kasa/protocols/smartcamprotocol.py @@ -19,7 +19,7 @@ SMART_RETRYABLE_ERRORS, SmartErrorCode, ) -from . import SmartProtocol +from .smartprotocol import SmartProtocol _LOGGER = logging.getLogger(__name__) diff --git a/kasa/transports/__init__.py b/kasa/transports/__init__.py index 602d0cca1..192b4156a 100644 --- a/kasa/transports/__init__.py +++ b/kasa/transports/__init__.py @@ -4,6 +4,7 @@ from .basetransport import BaseTransport from .klaptransport import KlapTransport, KlapTransportV2 from .linkietransport import LinkieTransportV2 +from .sslaestransport import SslAesTransport from .ssltransport import SslTransport from .xortransport import XorEncryption, XorTransport @@ -11,6 +12,7 @@ "AesTransport", "AesEncyptionSession", "SslTransport", + "SslAesTransport", "BaseTransport", "KlapTransport", "KlapTransportV2", From 62345be916adaf589a64c2cf9eae8f1b40340a9b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 16 Dec 2024 12:48:27 +0000 Subject: [PATCH 756/892] Add timeout parameter to dump_devinfo (#1381) --- devtools/dump_devinfo.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 02aebae76..cb0032d27 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -233,6 +233,12 @@ async def handle_device( type=bool, help="Set flag if the device encryption uses https.", ) +@click.option( + "--timeout", + required=False, + default=15, + help="Timeout for queries.", +) @click.option("--port", help="Port override", type=int) async def cli( host, @@ -250,6 +256,7 @@ async def cli( device_family, login_version, port, + timeout, ): """Generate devinfo files for devices. @@ -280,6 +287,7 @@ def capture_raw(discovered: DiscoveredRaw): connection_type=connection_type, port_override=port, credentials=credentials, + timeout=timeout, ) device = await Device.connect(config=dc) await handle_device( @@ -301,6 +309,7 @@ def capture_raw(discovered: DiscoveredRaw): port_override=port, credentials=credentials, connection_type=ctype, + timeout=timeout, ) if protocol := get_protocol(config): await handle_device(basedir, autosave, protocol, batch_size=batch_size) @@ -315,6 +324,7 @@ def capture_raw(discovered: DiscoveredRaw): credentials=credentials, port=port, discovery_timeout=discovery_timeout, + timeout=timeout, on_discovered_raw=capture_raw, ) discovery_info = raw_discovery[device.host] @@ -336,6 +346,7 @@ def capture_raw(discovered: DiscoveredRaw): target=target, credentials=credentials, discovery_timeout=discovery_timeout, + timeout=timeout, on_discovered_raw=capture_raw, ) click.echo(f"Detected {len(devices)} devices") From e206d9b4df92938ab2dd73c3ac9e542fe3a30615 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:00:28 +0000 Subject: [PATCH 757/892] Miscellaneous minor fixes to dump_devinfo (#1382) Fixes: - Decrypted discovery data saved under `discovery_result` instead of `result` - `smart` child data not redacted - `smartcam` child component list `device_id` not `SCRUBBED` --- devtools/dump_devinfo.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index cb0032d27..698515750 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -329,7 +329,7 @@ def capture_raw(discovered: DiscoveredRaw): ) discovery_info = raw_discovery[device.host] if decrypted_data := device._discovery_info.get("decrypted_data"): - discovery_info["decrypted_data"] = decrypted_data + discovery_info["result"]["decrypted_data"] = decrypted_data await handle_device( basedir, autosave, @@ -353,7 +353,7 @@ def capture_raw(discovered: DiscoveredRaw): for dev in devices.values(): discovery_info = raw_discovery[dev.host] if decrypted_data := dev._discovery_info.get("decrypted_data"): - discovery_info["decrypted_data"] = decrypted_data + discovery_info["result"]["decrypted_data"] = decrypted_data await handle_device( basedir, @@ -936,6 +936,7 @@ async def get_smart_fixtures( and (child_model := response["get_device_info"].get("model")) and child_model != parent_model ): + response = redact_data(response, _wrap_redactors(SMART_REDACTORS)) fixture_results.append(get_smart_child_fixture(response)) else: cd = final.setdefault("child_devices", {}) @@ -951,13 +952,16 @@ async def get_smart_fixtures( child["device_id"] = scrubbed_device_ids[device_id] # Scrub the device ids in the parent for the smart camera protocol - if gc := final.get("getChildDeviceList"): - for child in gc["child_device_list"]: + if gc := final.get("getChildDeviceComponentList"): + for child in gc["child_component_list"]: + device_id = child["device_id"] + child["device_id"] = scrubbed_device_ids[device_id] + for child in final["getChildDeviceList"]["child_device_list"]: if device_id := child.get("device_id"): child["device_id"] = scrubbed_device_ids[device_id] continue - if device_id := child.get("dev_id"): - child["dev_id"] = scrubbed_device_ids[device_id] + elif dev_id := child.get("dev_id"): + child["dev_id"] = scrubbed_device_ids[dev_id] continue _LOGGER.error("Could not find a device for the child device: %s", child) From d03a387a748d12ecb8fbcc00390b496352f52c34 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:06:26 +0000 Subject: [PATCH 758/892] Add new methods to dump_devinfo (#1373) Adds `getMatterSetupInfo`, `getConnectStatus` and `scanApList` --- devtools/helpers/smartcamrequests.py | 3 + kasa/protocols/smartprotocol.py | 18 +-- tests/fakeprotocol_smartcam.py | 55 ++++---- .../fixtures/smartcam/C210(EU)_2.0_1.4.3.json | 56 +++++++-- .../fixtures/smartcam/H200(EU)_1.0_1.3.2.json | 119 +++++++++++++++++- 5 files changed, 203 insertions(+), 48 deletions(-) diff --git a/devtools/helpers/smartcamrequests.py b/devtools/helpers/smartcamrequests.py index 074b5774d..5759a44b5 100644 --- a/devtools/helpers/smartcamrequests.py +++ b/devtools/helpers/smartcamrequests.py @@ -60,4 +60,7 @@ {"get": {"motor": {"name": ["capability"]}}}, {"get": {"audio_capability": {"name": ["device_speaker", "device_microphone"]}}}, {"get": {"audio_config": {"name": ["speaker", "microphone"]}}}, + {"getMatterSetupInfo": {"matter": {}}}, + {"getConnectStatus": {"onboarding": {"get_connect_status": {}}}}, + {"scanApList": {"onboarding": {"scan": {}}}}, ] diff --git a/kasa/protocols/smartprotocol.py b/kasa/protocols/smartprotocol.py index deb1a4051..7f02b45e7 100644 --- a/kasa/protocols/smartprotocol.py +++ b/kasa/protocols/smartprotocol.py @@ -50,6 +50,8 @@ "bssid": lambda _: "000000000000", "channel": lambda _: 0, "oem_id": lambda x: "REDACTED_" + x[9::], + "hw_id": lambda x: "REDACTED_" + x[9::], + "fw_id": lambda x: "REDACTED_" + x[9::], "setup_code": lambda x: re.sub(r"\w", "0", x), # matter "setup_payload": lambda x: re.sub(r"\w", "0", x), # matter "mfi_setup_code": lambda x: re.sub(r"\w", "0", x), # mfi_ for homekit @@ -183,18 +185,18 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic multi_result: dict[str, Any] = {} smart_method = "multipleRequest" + end = len(requests) + # The SmartCamProtocol sends requests with a length 1 as a + # multipleRequest. The SmartProtocol doesn't so will never + # raise_on_error + raise_on_error = end == 1 + multi_requests = [ {"method": method, "params": params} if params else {"method": method} for method, params in requests.items() if method not in FORCE_SINGLE_REQUEST ] - end = len(multi_requests) - # The SmartCamProtocol sends requests with a length 1 as a - # multipleRequest. The SmartProtocol doesn't so will never - # raise_on_error - raise_on_error = end == 1 - # Break the requests down as there can be a size limit step = self._multi_request_batch_size if step == 1: @@ -285,7 +287,9 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic resp = await self._transport.send( self.get_smart_request(method, params) ) - self._handle_response_error_code(resp, method, raise_on_error=False) + self._handle_response_error_code( + resp, method, raise_on_error=raise_on_error + ) multi_result[method] = resp.get("result") return multi_result diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index e8cc6f301..381a0a89c 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -221,35 +221,38 @@ async def _send_request(self, request_dict: dict): return {**result, "error_code": 0} else: return {"error_code": -1} - elif method[:3] == "get": + + if method in info: params = request_dict.get("params") - if method in info: - result = copy.deepcopy(info[method]) - if "start_index" in result and "sum" in result: - list_key = next( - iter([key for key in result if isinstance(result[key], list)]) - ) - start_index = ( - start_index - if (params and (start_index := params.get("start_index"))) - else 0 - ) - - result[list_key] = result[list_key][ - start_index : start_index + self.list_return_size - ] - return {"result": result, "error_code": 0} - if ( - # FIXTURE_MISSING is for service calls not in place when - # SMART fixtures started to be generated - missing_result := self.FIXTURE_MISSING_MAP.get(method) - ) and missing_result[0] in self.components: - # Copy to info so it will work with update methods - info[method] = copy.deepcopy(missing_result[1]) - result = copy.deepcopy(info[method]) - return {"result": result, "error_code": 0} + result = copy.deepcopy(info[method]) + if "start_index" in result and "sum" in result: + list_key = next( + iter([key for key in result if isinstance(result[key], list)]) + ) + start_index = ( + start_index + if (params and (start_index := params.get("start_index"))) + else 0 + ) + + result[list_key] = result[list_key][ + start_index : start_index + self.list_return_size + ] + return {"result": result, "error_code": 0} + if self.verbatim: return {"error_code": -1} + + if ( + # FIXTURE_MISSING is for service calls not in place when + # SMART fixtures started to be generated + missing_result := self.FIXTURE_MISSING_MAP.get(method) + ) and missing_result[0] in self.components: + # Copy to info so it will work with update methods + info[method] = copy.deepcopy(missing_result[1]) + result = copy.deepcopy(info[method]) + return {"result": result, "error_code": 0} + return {"error_code": -1} async def close(self) -> None: diff --git a/tests/fixtures/smartcam/C210(EU)_2.0_1.4.3.json b/tests/fixtures/smartcam/C210(EU)_2.0_1.4.3.json index b62801183..d4de5b9f2 100644 --- a/tests/fixtures/smartcam/C210(EU)_2.0_1.4.3.json +++ b/tests/fixtures/smartcam/C210(EU)_2.0_1.4.3.json @@ -7,8 +7,8 @@ "connect_type": "wireless", "device_id": "0000000000000000000000000000000000000000", "http_port": 443, - "last_alarm_time": "0", - "last_alarm_type": "", + "last_alarm_time": "1733422805", + "last_alarm_type": "motion", "owner": "00000000000000000000000000000000", "sd_status": "offline" }, @@ -32,7 +32,8 @@ "mac": "40-AE-30-00-00-00", "mgt_encrypt_schm": { "is_support_https": true - } + }, + "protocol_version": 1 } }, "getAlertConfig": { @@ -266,15 +267,22 @@ "getClockStatus": { "system": { "clock_status": { - "local_time": "2024-11-01 13:58:50", - "seconds_from_1970": 1730469530 + "local_time": "2024-12-15 11:28:40", + "seconds_from_1970": 1734262120 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 } } }, "getConnectionType": { "link_type": "wifi", "rssi": "3", - "rssiValue": -57, + "rssiValue": -61, "ssid": "I01BU0tFRF9TU0lEIw==" }, "getDetectionConfig": { @@ -321,7 +329,7 @@ "getFirmwareAutoUpgradeConfig": { "auto_upgrade": { "common": { - "enabled": "on", + "enabled": "off", "random_range": "120", "time": "03:00" } @@ -338,8 +346,8 @@ "getLastAlarmInfo": { "system": { "last_alarm_info": { - "last_alarm_time": "0", - "last_alarm_type": "" + "last_alarm_time": "1733422805", + "last_alarm_type": "motion" } } }, @@ -961,5 +969,35 @@ } } } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } } } diff --git a/tests/fixtures/smartcam/H200(EU)_1.0_1.3.2.json b/tests/fixtures/smartcam/H200(EU)_1.0_1.3.2.json index 9ccaa7e0e..4ef99fae2 100644 --- a/tests/fixtures/smartcam/H200(EU)_1.0_1.3.2.json +++ b/tests/fixtures/smartcam/H200(EU)_1.0_1.3.2.json @@ -26,6 +26,7 @@ "firmware_version": "1.3.2 Build 20240424 rel.75425", "hardware_version": "1.0", "ip": "127.0.0.123", + "isResetWiFi": false, "is_support_iot_cloud": true, "mac": "A8-6E-84-00-00-00", "mgt_encrypt_schm": { @@ -214,8 +215,8 @@ "fw_ver": "1.11.0 Build 230821 Rel.113553", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -108, - "jamming_signal_level": 2, + "jamming_rssi": -119, + "jamming_signal_level": 1, "lastOnboardingTimestamp": 1714016798, "mac": "202351000000", "model": "S200B", @@ -224,7 +225,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Europe/London", "report_interval": 16, - "rssi": -66, + "rssi": -60, "signal_level": 3, "specs": "EU", "status": "online", @@ -245,8 +246,17 @@ "getClockStatus": { "system": { "clock_status": { - "local_time": "2024-11-01 13:56:27", - "seconds_from_1970": 1730469387 + "local_time": "1984-10-21 23:48:23", + "seconds_from_1970": 467246903 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "current_ssid": "", + "err_code": 0, + "status": 0 } } }, @@ -329,6 +339,10 @@ } } }, + "getMatterSetupInfo": { + "setup_code": "00000000000", + "setup_payload": "00:000000-000000000000" + }, "getMediaEncrypt": { "cet": { "media_encrypt": { @@ -353,7 +367,7 @@ "getSirenConfig": { "duration": 300, "siren_type": "Doorbell Ring 1", - "volume": "6" + "volume": "1" }, "getSirenStatus": { "status": "off", @@ -389,5 +403,98 @@ "zone_id": "Europe/London" } } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 0, + "bssid": "000000000000", + "encryption": 0, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } } } From 5918e4daa7caf49e9c364087ec4c421bf93a1def Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:42:42 +0000 Subject: [PATCH 759/892] Enable saving of fixture files without git clone (#1375) Allows `dump_devinfo` to be run without fixture subfolders present from cloned repository --- devtools/dump_devinfo.py | 49 +++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 698515750..e985ab40f 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -57,13 +57,20 @@ from kasa.smartcam import SmartCamDevice Call = namedtuple("Call", "module method") -FixtureResult = namedtuple("FixtureResult", "filename, folder, data") +FixtureResult = namedtuple("FixtureResult", "filename, folder, data, protocol_suffix") SMART_FOLDER = "tests/fixtures/smart/" SMARTCAM_FOLDER = "tests/fixtures/smartcam/" SMART_CHILD_FOLDER = "tests/fixtures/smart/child/" IOT_FOLDER = "tests/fixtures/iot/" +SMART_PROTOCOL_SUFFIX = "SMART" +SMARTCAM_SUFFIX = "SMARTCAM" +SMART_CHILD_SUFFIX = "SMART.CHILD" +IOT_SUFFIX = "IOT" + +NO_GIT_FIXTURE_FOLDER = "kasa-fixtures" + ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] _LOGGER = logging.getLogger(__name__) @@ -140,7 +147,17 @@ async def handle_device( ] for fixture_result in fixture_results: - save_filename = Path(basedir) / fixture_result.folder / fixture_result.filename + save_folder = Path(basedir) / fixture_result.folder + if save_folder.exists(): + save_filename = save_folder / f"{fixture_result.filename}.json" + else: + # If being run without git clone + save_folder = Path(basedir) / NO_GIT_FIXTURE_FOLDER + save_folder.mkdir(exist_ok=True) + save_filename = ( + save_folder + / f"{fixture_result.filename}-{fixture_result.protocol_suffix}.json" + ) pprint(fixture_result.data) if autosave: @@ -459,9 +476,14 @@ async def get_legacy_fixture( hw_version = sysinfo["hw_ver"] sw_version = sysinfo["sw_ver"] sw_version = sw_version.split(" ", maxsplit=1)[0] - save_filename = f"{model}_{hw_version}_{sw_version}.json" + save_filename = f"{model}_{hw_version}_{sw_version}" copy_folder = IOT_FOLDER - return FixtureResult(filename=save_filename, folder=copy_folder, data=final) + return FixtureResult( + filename=save_filename, + folder=copy_folder, + data=final, + protocol_suffix=IOT_SUFFIX, + ) def _echo_error(msg: str): @@ -830,9 +852,12 @@ def get_smart_child_fixture(response): model = model_info.long_name if model_info.region is not None: model = f"{model}({model_info.region})" - save_filename = f"{model}_{hw_version}_{fw_version}.json" + save_filename = f"{model}_{hw_version}_{fw_version}" return FixtureResult( - filename=save_filename, folder=SMART_CHILD_FOLDER, data=response + filename=save_filename, + folder=SMART_CHILD_FOLDER, + data=response, + protocol_suffix=SMART_CHILD_SUFFIX, ) @@ -980,20 +1005,28 @@ async def get_smart_fixtures( # smart protocol model_info = SmartDevice._get_device_info(final, discovery_result) copy_folder = SMART_FOLDER + protocol_suffix = SMART_PROTOCOL_SUFFIX else: # smart camera protocol model_info = SmartCamDevice._get_device_info(final, discovery_result) copy_folder = SMARTCAM_FOLDER + protocol_suffix = SMARTCAM_SUFFIX hw_version = model_info.hardware_version sw_version = model_info.firmware_version model = model_info.long_name if model_info.region is not None: model = f"{model}({model_info.region})" - save_filename = f"{model}_{hw_version}_{sw_version}.json" + save_filename = f"{model}_{hw_version}_{sw_version}" fixture_results.insert( - 0, FixtureResult(filename=save_filename, folder=copy_folder, data=final) + 0, + FixtureResult( + filename=save_filename, + folder=copy_folder, + data=final, + protocol_suffix=protocol_suffix, + ), ) return fixture_results From fe072657b492353525aa5a09c9ffd679eea8ca0c Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 17 Dec 2024 07:39:17 +0000 Subject: [PATCH 760/892] Simplify get_protocol to prevent clashes with smartcam and robovac (#1377) --- kasa/device_factory.py | 34 +++++++++------ kasa/discover.py | 10 ++--- tests/test_device_factory.py | 85 ++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 18 deletions(-) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index a10155705..99654a0c4 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -8,7 +8,7 @@ from .device import Device from .device_type import DeviceType -from .deviceconfig import DeviceConfig +from .deviceconfig import DeviceConfig, DeviceFamily from .exceptions import KasaException, UnsupportedDeviceError from .iot import ( IotBulb, @@ -179,20 +179,29 @@ def get_device_class_from_family( def get_protocol( config: DeviceConfig, ) -> BaseProtocol | None: - """Return the protocol from the connection name.""" - protocol_name = config.connection_type.device_family.value.split(".")[0] + """Return the protocol from the connection name. + + For cameras and vacuums the device family is a simple mapping to + the protocol/transport. For other device types the transport varies + based on the discovery information. + """ ctype = config.connection_type + protocol_name = ctype.device_family.value.split(".")[0] + + if ctype.device_family is DeviceFamily.SmartIpCamera: + return SmartCamProtocol(transport=SslAesTransport(config=config)) + + if ctype.device_family is DeviceFamily.IotIpCamera: + return IotProtocol(transport=LinkieTransportV2(config=config)) + + if ctype.device_family is DeviceFamily.SmartTapoRobovac: + return SmartProtocol(transport=SslTransport(config=config)) protocol_transport_key = ( protocol_name + "." + ctype.encryption_type.value + (".HTTPS" if ctype.https else "") - + ( - f".{ctype.login_version}" - if ctype.login_version and ctype.login_version > 1 - else "" - ) ) _LOGGER.debug("Finding transport for %s", protocol_transport_key) @@ -201,12 +210,11 @@ def get_protocol( ] = { "IOT.XOR": (IotProtocol, XorTransport), "IOT.KLAP": (IotProtocol, KlapTransport), - "IOT.XOR.HTTPS.2": (IotProtocol, LinkieTransportV2), "SMART.AES": (SmartProtocol, AesTransport), - "SMART.AES.2": (SmartProtocol, AesTransport), - "SMART.KLAP.2": (SmartProtocol, KlapTransportV2), - "SMART.AES.HTTPS.2": (SmartCamProtocol, SslAesTransport), - "SMART.AES.HTTPS": (SmartProtocol, SslTransport), + "SMART.KLAP": (SmartProtocol, KlapTransportV2), + # H200 is device family SMART.TAPOHUB and uses SmartCamProtocol so use + # https to distuingish from SmartProtocol devices + "SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport), } if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)): return None diff --git a/kasa/discover.py b/kasa/discover.py index 2bd988158..77ef80be1 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -847,12 +847,12 @@ def _get_device_instance( ): encrypt_type = encrypt_info.sym_schm - if ( - not (login_version := encrypt_schm.lv) - and (et := discovery_result.encrypt_type) - and et == ["3"] + if not (login_version := encrypt_schm.lv) and ( + et := discovery_result.encrypt_type ): - login_version = 2 + # Known encrypt types are ["1","2"] and ["3"] + # Reuse the login_version attribute to pass the max to transport + login_version = max([int(i) for i in et]) if not encrypt_type: raise UnsupportedDeviceError( diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index ed73b3a38..66e243246 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -13,9 +13,13 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( + BaseProtocol, Credentials, Discover, + IotProtocol, KasaException, + SmartCamProtocol, + SmartProtocol, ) from kasa.device_factory import ( Device, @@ -33,6 +37,16 @@ DeviceFamily, ) from kasa.discover import DiscoveryResult +from kasa.transports import ( + AesTransport, + BaseTransport, + KlapTransport, + KlapTransportV2, + LinkieTransportV2, + SslAesTransport, + SslTransport, + XorTransport, +) from .conftest import DISCOVERY_MOCK_IP @@ -203,3 +217,74 @@ async def test_device_class_from_unknown_family(caplog): with caplog.at_level(logging.DEBUG): assert get_device_class_from_family(dummy_name, https=False) == SmartDevice assert f"Unknown SMART device with {dummy_name}" in caplog.text + + +# Aliases to make the test params more readable +CP = DeviceConnectionParameters +DF = DeviceFamily +ET = DeviceEncryptionType + + +@pytest.mark.parametrize( + ("conn_params", "expected_protocol", "expected_transport"), + [ + pytest.param( + CP(DF.SmartIpCamera, ET.Aes, https=True), + SmartCamProtocol, + SslAesTransport, + id="smartcam", + ), + pytest.param( + CP(DF.SmartTapoHub, ET.Aes, https=True), + SmartCamProtocol, + SslAesTransport, + id="smartcam-hub", + ), + pytest.param( + CP(DF.IotIpCamera, ET.Aes, https=True), + IotProtocol, + LinkieTransportV2, + id="kasacam", + ), + pytest.param( + CP(DF.SmartTapoRobovac, ET.Aes, https=True), + SmartProtocol, + SslTransport, + id="robovac", + ), + pytest.param( + CP(DF.IotSmartPlugSwitch, ET.Klap, https=False), + IotProtocol, + KlapTransport, + id="iot-klap", + ), + pytest.param( + CP(DF.IotSmartPlugSwitch, ET.Xor, https=False), + IotProtocol, + XorTransport, + id="iot-xor", + ), + pytest.param( + CP(DF.SmartTapoPlug, ET.Aes, https=False), + SmartProtocol, + AesTransport, + id="smart-aes", + ), + pytest.param( + CP(DF.SmartTapoPlug, ET.Klap, https=False), + SmartProtocol, + KlapTransportV2, + id="smart-klap", + ), + ], +) +async def test_get_protocol( + conn_params: DeviceConnectionParameters, + expected_protocol: type[BaseProtocol], + expected_transport: type[BaseTransport], +): + """Test get_protocol returns the right protocol.""" + config = DeviceConfig("127.0.0.1", connection_type=conn_params) + protocol = get_protocol(config) + assert isinstance(protocol, expected_protocol) + assert isinstance(protocol._transport, expected_transport) From c6c4490a49f3f3869658db7e54e31b9ba6c7f23e Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 17 Dec 2024 10:59:24 +0000 Subject: [PATCH 761/892] Add C100 4.0 1.3.14 Fixture (#1378) --- README.md | 2 +- SUPPORTED.md | 2 + tests/fixtures/smartcam/C100_4.0_1.3.14.json | 779 +++++++++++++++++++ 3 files changed, 782 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smartcam/C100_4.0_1.3.14.json diff --git a/README.md b/README.md index 58f3c826c..1be3a2227 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Cameras**: C210, C520WS, TC65 +- **Cameras**: C100, C210, C520WS, TC65 - **Hubs**: H100, H200 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 diff --git a/SUPPORTED.md b/SUPPORTED.md index d3c1f1e61..aa043e1a3 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -258,6 +258,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros ### Cameras +- **C100** + - Hardware: 4.0 / Firmware: 1.3.14 - **C210** - Hardware: 2.0 (EU) / Firmware: 1.4.2 - Hardware: 2.0 (EU) / Firmware: 1.4.3 diff --git a/tests/fixtures/smartcam/C100_4.0_1.3.14.json b/tests/fixtures/smartcam/C100_4.0_1.3.14.json new file mode 100644 index 000000000..144cf5f69 --- /dev/null +++ b/tests/fixtures/smartcam/C100_4.0_1.3.14.json @@ -0,0 +1,779 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C100", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.14 Build 240513 Rel.43631n(5553)", + "hardware_version": "4.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + ".name": "chn1_msg_alarm_plan", + ".type": "plan", + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 4 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 2 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "staticIp", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + ".name": "microphone", + ".type": "audio_config", + "channels": "1", + "encode_type": "G711alaw", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + ".name": "speaker", + ".type": "audio_config", + "volume": "40" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + ".name": "bcd", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + ".name": "harddisk", + ".type": "storage", + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-12-15 11:11:55", + "seconds_from_1970": 1734279115 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -15, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + ".name": "motion_det", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c100", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C100 4.0 IPC", + "device_model": "C100", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": "3", + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_version": "4.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "oem_id": "00000000000000000000000000000000", + "sw_version": "1.3.14 Build 240513 Rel.43631n(5553)" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + ".name": "common", + ".type": "on_off", + "enabled": "off", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + ".name": "common", + ".type": "para", + "area_compensation": "default", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "exp_gain": "0", + "exp_type": "auto", + "focus_limited": "600", + "focus_type": "semi_auto", + "high_light_compensation": "off", + "inf_delay": "10", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_start_time": "64800", + "inf_type": "auto", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off" + }, + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getLedStatus": { + "led": { + "config": { + ".name": "config", + ".type": "led", + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + ".name": "lens_mask_info", + ".type": "lens_mask_info", + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + ".name": "common", + ".type": "para", + "area_compensation": "default", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "exp_gain": "0", + "exp_type": "auto", + "focus_limited": "600", + "focus_type": "semi_auto", + "high_light_compensation": "off", + "inf_delay": "10", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_start_time": "64800", + "inf_type": "auto", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + ".name": "media_encrypt", + ".type": "on_off", + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + ".name": "chn1_msg_push_info", + ".type": "on_off", + "notification_enabled": "off", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + ".name": "detection", + ".type": "on_off", + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + ".name": "chn1_channel", + ".type": "plan", + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_total_space": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_total_space": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "type": "local", + "video_free_space": "0B", + "video_total_space": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + ".name": "tamper_det", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTimezone": { + "system": { + "basic": { + ".name": "basic", + ".type": "setting", + "timezone": "UTC-05:00", + "timing_mode": "manual", + "zone_id": "America/New_York" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + ".name": "main", + ".type": "capability", + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "2048" + ], + "encode_types": [ + "H264" + ], + "frame_rates": [ + "65537", + "65546", + "65551" + ], + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "1920*1080", + "1280*720", + "640*360" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + ".name": "main", + ".type": "stream", + "bitrate": "1024", + "bitrate_type": "vbr", + "encode_type": "H264", + "frame_rate": "65551", + "gop_factor": "2", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "1920*1080", + "stream_type": "general" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + ".name": "device_microphone", + ".type": "capability", + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + ".name": "device_speaker", + ".type": "capability", + "channels": "1", + "decode_type": [ + "G711" + ], + "mute": "0", + "sampling_rate": [ + "8" + ], + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + ".name": "microphone", + ".type": "audio_config", + "channels": "1", + "encode_type": "G711alaw", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + ".name": "speaker", + ".type": "audio_config", + "volume": "40" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + ".name": "vhttpd", + ".type": "server", + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + ".name": "module_spec", + ".type": "module-spec", + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "audioexception_detection": "0", + "auth_encrypt": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "0", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "greeter": "1.0", + "http_system_state_audio_support": "1", + "intrusion_detection": "1", + "led": "1", + "lens_mask": "1", + "linecrossing_detection": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_alarm_separate_list": [ + "light", + "sound" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi" + ], + "ota_upgrade": "1", + "p2p_support_versions": [ + "1.1" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "1.3" + ], + "reonboarding": "1", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "1.0" + ], + "target_track": "0", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264" + ], + "video_detection_digital_sensitivity": "1", + "wifi_cascade_connection": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "1" + } + } + } + } +} From 14d5629de1b72ab4348eafd1b54ddb75d9c23940 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 17 Dec 2024 10:59:57 +0000 Subject: [PATCH 762/892] Update C520WS fixture with new methods (#1384) --- .../smartcam/C520WS(US)_1.0_1.2.8.json | 135 +++++++++++++++++- 1 file changed, 128 insertions(+), 7 deletions(-) diff --git a/tests/fixtures/smartcam/C520WS(US)_1.0_1.2.8.json b/tests/fixtures/smartcam/C520WS(US)_1.0_1.2.8.json index 4f156070d..c425da795 100644 --- a/tests/fixtures/smartcam/C520WS(US)_1.0_1.2.8.json +++ b/tests/fixtures/smartcam/C520WS(US)_1.0_1.2.8.json @@ -7,8 +7,8 @@ "connect_type": "wireless", "device_id": "0000000000000000000000000000000000000000", "http_port": 443, - "last_alarm_time": "0", - "last_alarm_type": "", + "last_alarm_time": "1734386954", + "last_alarm_type": "motion", "owner": "00000000000000000000000000000000", "sd_status": "offline" }, @@ -283,15 +283,22 @@ "getClockStatus": { "system": { "clock_status": { - "local_time": "2024-12-02 13:12:15", - "seconds_from_1970": 1733163135 + "local_time": "2024-12-16 17:09:43", + "seconds_from_1970": 1734386983 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 } } }, "getConnectionType": { "link_type": "wifi", "rssi": "4", - "rssiValue": -47, + "rssiValue": -45, "ssid": "I01BU0tFRF9TU0lEIw==" }, "getDetectionConfig": { @@ -355,8 +362,8 @@ "getLastAlarmInfo": { "system": { "last_alarm_info": { - "last_alarm_time": "0", - "last_alarm_type": "" + "last_alarm_time": "1734386954", + "last_alarm_type": "motion" } } }, @@ -1025,5 +1032,119 @@ } } } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } } } From 37ef7b04632120bbe743d6fca256e8e9e2704b8a Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 17 Dec 2024 21:09:17 +0100 Subject: [PATCH 763/892] cli: print model, https, and lv for discover list (#1339) ``` kasa --target 192.168.xx.xx discover list HOST MODEL DEVICE FAMILY ENCRYPT HTTPS LV ALIAS 192.168.xxx.xxx KP115(EU) IOT.SMARTPLUGSWITCH XOR 0 - Fridge 192.168.xxx.xxx L900-5 SMART.TAPOBULB KLAP 0 2 L900 192.168.xxx.xxx P115 SMART.TAPOPLUG AES 0 2 Nightdesk 192.168.xxx.xxx TC65 SMART.IPCAMERA AES 1 2 Tapo_TC65_B593 ``` Also handles `TimeoutError` and `Exception` during `update()` --------- Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- kasa/cli/discover.py | 14 ++++++++--- tests/discovery_fixtures.py | 16 ++++++++++++- tests/test_cli.py | 47 ++++++++++++++++++++++++++++++------- 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index 5e676a1dc..2470434b7 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -123,14 +123,19 @@ async def list(ctx): async def print_discovered(dev: Device): cparams = dev.config.connection_type infostr = ( - f"{dev.host:<15} {cparams.device_family.value:<20} " - f"{cparams.encryption_type.value:<7}" + f"{dev.host:<15} {dev.model:<9} {cparams.device_family.value:<20} " + f"{cparams.encryption_type.value:<7} {cparams.https:<5} " + f"{cparams.login_version or '-':<3}" ) async with sem: try: await dev.update() except AuthenticationError: echo(f"{infostr} - Authentication failed") + except TimeoutError: + echo(f"{infostr} - Timed out") + except Exception as ex: + echo(f"{infostr} - Error: {ex}") else: echo(f"{infostr} {dev.alias}") @@ -138,7 +143,10 @@ async def print_unsupported(unsupported_exception: UnsupportedDeviceError): if host := unsupported_exception.host: echo(f"{host:<15} UNSUPPORTED DEVICE") - echo(f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}") + echo( + f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} " + f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}" + ) return await _discover( ctx, print_discovered=print_discovered, diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index 87541effe..eb843f1a0 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -160,6 +160,17 @@ class _DiscoveryMock: login_version: int | None = None port_override: int | None = None + @property + def model(self) -> str: + dd = self.discovery_data + model_region = ( + dd["result"]["device_model"] + if self.discovery_port == 20002 + else dd["system"]["get_sysinfo"]["model"] + ) + model, _, _ = model_region.partition("(") + return model + @property def _datagram(self) -> bytes: if self.default_port == 9999: @@ -178,7 +189,10 @@ def _datagram(self) -> bytes: "encrypt_type", discovery_result.get("encrypt_info", {}).get("sym_schm") ) - login_version = discovery_result["mgt_encrypt_schm"].get("lv") + if not (login_version := discovery_result["mgt_encrypt_schm"].get("lv")) and ( + et := discovery_result.get("encrypt_type") + ): + login_version = max([int(i) for i in et]) https = discovery_result["mgt_encrypt_schm"]["is_support_https"] dm = _DiscoveryMock( ip, diff --git a/tests/test_cli.py b/tests/test_cli.py index 42f6e12b0..3621ef203 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -122,8 +122,15 @@ async def test_list_devices(discovery_mock, runner): catch_exceptions=False, ) assert res.exit_code == 0 - header = f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}" - row = f"{discovery_mock.ip:<15} {discovery_mock.device_type:<20} {discovery_mock.encrypt_type:<7}" + header = ( + f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} " + f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}" + ) + row = ( + f"{discovery_mock.ip:<15} {discovery_mock.model:<9} {discovery_mock.device_type:<20} " + f"{discovery_mock.encrypt_type:<7} {discovery_mock.https:<5} " + f"{discovery_mock.login_version or '-':<3}" + ) assert header in res.output assert row in res.output @@ -158,14 +165,26 @@ async def test_discover_raw(discovery_mock, runner, mocker): redact_spy.assert_called() +@pytest.mark.parametrize( + ("exception", "expected"), + [ + pytest.param( + AuthenticationError("Failed to authenticate"), + "Authentication failed", + id="auth", + ), + pytest.param(TimeoutError(), "Timed out", id="timeout"), + pytest.param(Exception("Foobar"), "Error: Foobar", id="other-error"), + ], +) @new_discovery -async def test_list_auth_failed(discovery_mock, mocker, runner): +async def test_list_update_failed(discovery_mock, mocker, runner, exception, expected): """Test that device update is called on main.""" device_class = Discover._get_device_class(discovery_mock.discovery_data) mocker.patch.object( device_class, "update", - side_effect=AuthenticationError("Failed to authenticate"), + side_effect=exception, ) res = await runner.invoke( cli, @@ -173,10 +192,17 @@ async def test_list_auth_failed(discovery_mock, mocker, runner): catch_exceptions=False, ) assert res.exit_code == 0 - header = f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}" - row = f"{discovery_mock.ip:<15} {discovery_mock.device_type:<20} {discovery_mock.encrypt_type:<7} - Authentication failed" - assert header in res.output - assert row in res.output + header = ( + f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} " + f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}" + ) + row = ( + f"{discovery_mock.ip:<15} {discovery_mock.model:<9} {discovery_mock.device_type:<20} " + f"{discovery_mock.encrypt_type:<7} {discovery_mock.https:<5} " + f"{discovery_mock.login_version or '-':<3} - {expected}" + ) + assert header in res.output.replace("\n", "") + assert row in res.output.replace("\n", "") async def test_list_unsupported(unsupported_device_info, runner): @@ -187,7 +213,10 @@ async def test_list_unsupported(unsupported_device_info, runner): catch_exceptions=False, ) assert res.exit_code == 0 - header = f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}" + header = ( + f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} " + f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}" + ) row = f"{'127.0.0.1':<15} UNSUPPORTED DEVICE" assert header in res.output assert row in res.output From ba273f308ea50bc3484f9f19151c8d36e667f25a Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 17 Dec 2024 20:15:42 +0000 Subject: [PATCH 764/892] Add LensMask module to smartcam (#1385) Ensures no error with devices that do not have the `lens_mask` component. --- kasa/module.py | 1 + kasa/smartcam/modules/__init__.py | 2 + kasa/smartcam/modules/camera.py | 53 ++++++++++++++------------- kasa/smartcam/modules/lensmask.py | 29 +++++++++++++++ kasa/smartcam/smartcamdevice.py | 5 +++ tests/smartcam/test_smartcamdevice.py | 16 ++++++-- 6 files changed, 78 insertions(+), 28 deletions(-) create mode 100644 kasa/smartcam/modules/lensmask.py diff --git a/kasa/module.py b/kasa/module.py index 754814ecd..2870b661a 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -159,6 +159,7 @@ class Module(ABC): # SMARTCAM only modules Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera") + LensMask: Final[ModuleName[smartcam.LensMask]] = ModuleName("LensMask") def __init__(self, device: Device, module: str) -> None: self._device = device diff --git a/kasa/smartcam/modules/__init__.py b/kasa/smartcam/modules/__init__.py index a3f51c872..fae5923fa 100644 --- a/kasa/smartcam/modules/__init__.py +++ b/kasa/smartcam/modules/__init__.py @@ -6,6 +6,7 @@ from .device import DeviceModule from .homekit import HomeKit from .led import Led +from .lensmask import LensMask from .matter import Matter from .pantilt import PanTilt from .time import Time @@ -20,4 +21,5 @@ "Time", "HomeKit", "Matter", + "LensMask", ] diff --git a/kasa/smartcam/modules/camera.py b/kasa/smartcam/modules/camera.py index e96794c29..1e1f45701 100644 --- a/kasa/smartcam/modules/camera.py +++ b/kasa/smartcam/modules/camera.py @@ -1,16 +1,18 @@ -"""Implementation of device module.""" +"""Implementation of camera module.""" from __future__ import annotations import base64 import logging from enum import StrEnum +from typing import Annotated from urllib.parse import quote_plus from ...credentials import Credentials from ...device_type import DeviceType from ...feature import Feature from ...json import loads as json_loads +from ...module import FeatureAttribute, Module from ..smartcammodule import SmartCamModule _LOGGER = logging.getLogger(__name__) @@ -29,28 +31,37 @@ class StreamResolution(StrEnum): class Camera(SmartCamModule): """Implementation of device module.""" - QUERY_GETTER_NAME = "getLensMaskConfig" - QUERY_MODULE_NAME = "lens_mask" - QUERY_SECTION_NAMES = "lens_mask_info" - def _initialize_features(self) -> None: """Initialize features after the initial update.""" - self._add_feature( - Feature( - self._device, - id="state", - name="State", - attribute_getter="is_on", - attribute_setter="set_state", - type=Feature.Type.Switch, - category=Feature.Category.Primary, + if Module.LensMask in self._device.modules: + self._add_feature( + Feature( + self._device, + id="state", + name="State", + attribute_getter="is_on", + attribute_setter="set_state", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) ) - ) @property def is_on(self) -> bool: - """Return the device id.""" - return self.data["lens_mask_info"]["enabled"] == "off" + """Return the device on state.""" + if lens_mask := self._device.modules.get(Module.LensMask): + return lens_mask.state + return True + + async def set_state(self, on: bool) -> Annotated[dict, FeatureAttribute()]: + """Set the device on state. + + If the device does not support setting state will do nothing. + """ + if lens_mask := self._device.modules.get(Module.LensMask): + # Turning off enables the privacy mask which is why value is reversed. + return await lens_mask.set_state(not on) + return {} def _get_credentials(self) -> Credentials | None: """Get credentials from .""" @@ -109,14 +120,6 @@ def onvif_url(self) -> str | None: """Return the onvif url.""" return f"http://{self._device.host}:{ONVIF_PORT}/onvif/device_service" - async def set_state(self, on: bool) -> dict: - """Set the device state.""" - # Turning off enables the privacy mask which is why value is reversed. - params = {"enabled": "off" if on else "on"} - return await self._device._query_setter_helper( - "setLensMaskConfig", self.QUERY_MODULE_NAME, "lens_mask_info", params - ) - async def _check_supported(self) -> bool: """Additional check to see if the module is supported by the device.""" return self._device.device_type is DeviceType.Camera diff --git a/kasa/smartcam/modules/lensmask.py b/kasa/smartcam/modules/lensmask.py new file mode 100644 index 000000000..7a54beb18 --- /dev/null +++ b/kasa/smartcam/modules/lensmask.py @@ -0,0 +1,29 @@ +"""Implementation of lens mask privacy module.""" + +from __future__ import annotations + +import logging + +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class LensMask(SmartCamModule): + """Implementation of lens mask module.""" + + QUERY_GETTER_NAME = "getLensMaskConfig" + QUERY_MODULE_NAME = "lens_mask" + QUERY_SECTION_NAMES = "lens_mask_info" + + @property + def state(self) -> bool: + """Return the lens mask state.""" + return self.data["lens_mask_info"]["enabled"] == "off" + + async def set_state(self, state: bool) -> dict: + """Set the lens mask state.""" + params = {"enabled": "on" if state else "off"} + return await self._device._query_setter_helper( + "setLensMaskConfig", self.QUERY_MODULE_NAME, "lens_mask_info", params + ) diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index b3058ab33..6bc4963a6 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -134,6 +134,11 @@ async def _initialize_modules(self) -> None: if ( mod.REQUIRED_COMPONENT and mod.REQUIRED_COMPONENT not in self._components + # Always add Camera module to cameras + and ( + mod._module_name() != Module.Camera + or self._device_type is not DeviceType.Camera + ) ): continue module = mod(self, mod._module_name()) diff --git a/tests/smartcam/test_smartcamdevice.py b/tests/smartcam/test_smartcamdevice.py index 438737eb9..3355d2f03 100644 --- a/tests/smartcam/test_smartcamdevice.py +++ b/tests/smartcam/test_smartcamdevice.py @@ -17,10 +17,20 @@ async def test_state(dev: Device): if dev.device_type is DeviceType.Hub: pytest.skip("Hubs cannot be switched on and off") - state = dev.is_on - await dev.set_state(not state) + if Module.LensMask in dev.modules: + state = dev.is_on + await dev.set_state(not state) + await dev.update() + assert dev.is_on is not state + + dev.modules.pop(Module.LensMask) # type: ignore[attr-defined] + + # Test with no lens mask module. Device is always on. + assert dev.is_on is True + res = await dev.set_state(False) + assert res == {} await dev.update() - assert dev.is_on is not state + assert dev.is_on is True @device_smartcam From 47934dbf966eb8feb55a72f3a0560132c542e7a4 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:43:20 +0000 Subject: [PATCH 765/892] Add C325WB(EU) 1.0 1.1.17 Fixture (#1379) --- README.md | 2 +- SUPPORTED.md | 2 + .../smartcam/C325WB(EU)_1.0_1.1.17.json | 1065 +++++++++++++++++ 3 files changed, 1068 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smartcam/C325WB(EU)_1.0_1.1.17.json diff --git a/README.md b/README.md index 1be3a2227..ec41b495d 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Cameras**: C100, C210, C520WS, TC65 +- **Cameras**: C100, C210, C325WB, C520WS, TC65 - **Hubs**: H100, H200 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 diff --git a/SUPPORTED.md b/SUPPORTED.md index aa043e1a3..8e13b6566 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -263,6 +263,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **C210** - Hardware: 2.0 (EU) / Firmware: 1.4.2 - Hardware: 2.0 (EU) / Firmware: 1.4.3 +- **C325WB** + - Hardware: 1.0 (EU) / Firmware: 1.1.17 - **C520WS** - Hardware: 1.0 (US) / Firmware: 1.2.8 - **TC65** diff --git a/tests/fixtures/smartcam/C325WB(EU)_1.0_1.1.17.json b/tests/fixtures/smartcam/C325WB(EU)_1.0_1.1.17.json new file mode 100644 index 000000000..b04cbd06f --- /dev/null +++ b/tests/fixtures/smartcam/C325WB(EU)_1.0_1.1.17.json @@ -0,0 +1,1065 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1734490369", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "normal" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C325WB", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.1.17 Build 240529 Rel.57938n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "2", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 4 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 1 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "linecrossingDetection", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "darkLightNightVision", + "version": 3 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "whiteLamp", + "version": 1 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "snapshot", + "version": 2 + }, + { + "name": "upnpc", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "0" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-12-18 12:59:13", + "seconds_from_1970": 1734490753 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "2", + "rssiValue": -63, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c100", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C325WB 1.0 IPC", + "device_model": "C325WB", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.1.17 Build 240529 Rel.57938n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1734490369", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "off", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "wtl_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "30" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "off", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "off", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "wtl_night_vision", + "md_night_vision", + "shed_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp", + "white_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "wtl_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "30" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "wtl_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "30" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "normal", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "1", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "1733281333", + "rw_attr": "rw", + "status": "normal", + "total_space": "118.8GB", + "total_space_accurate": "127565725696B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "114.0GB", + "video_total_space_accurate": "122406567936B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+10:00", + "timing_mode": "ntp", + "zone_id": "Australia/Brisbane" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1536", + "3072" + ], + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65537", + "65546", + "65551", + "65556" + ], + "hdrs": [ + "0", + "1" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2688*1520", + "1920*1080", + "1280*720" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1536", + "bitrate_type": "vbr", + "default_bitrate": "3072", + "encode_type": "H264", + "frame_rate": "65556", + "hdr": "0", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2688*1520", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "wtl_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "30" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "95", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "0" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "audio_alarm_clock": "1", + "audio_lib": "1", + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "image", + "OSD", + "video" + ], + "custom_area_compensation": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "diagnose": "1", + "diagnose_capability": "1", + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "gb28181": "1", + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "playback_version": "1.1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "tums": "1", + "tums_config": "1", + "tums_msg_push": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "video_message": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "0", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 0, + "bssid": "000000000000", + "encryption": 0, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 0, + "bssid": "000000000000", + "encryption": 0, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 5, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } + } +} From b78e09caa0ce371d9ca7d93372b995ceccb75e7e Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 19 Dec 2024 13:48:03 +0000 Subject: [PATCH 766/892] Add TC70 3.0 1.3.11 fixture (#1390) Many thanks to @allanbeth for the fixture! --- README.md | 2 +- SUPPORTED.md | 2 + tests/fixtures/smartcam/TC70_3.0_1.3.11.json | 870 +++++++++++++++++++ 3 files changed, 873 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smartcam/TC70_3.0_1.3.11.json diff --git a/README.md b/README.md index ec41b495d..c286ba3f5 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Cameras**: C100, C210, C325WB, C520WS, TC65 +- **Cameras**: C100, C210, C325WB, C520WS, TC65, TC70 - **Hubs**: H100, H200 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 diff --git a/SUPPORTED.md b/SUPPORTED.md index 8e13b6566..5d26f8e99 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -269,6 +269,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (US) / Firmware: 1.2.8 - **TC65** - Hardware: 1.0 / Firmware: 1.3.9 +- **TC70** + - Hardware: 3.0 / Firmware: 1.3.11 ### Hubs diff --git a/tests/fixtures/smartcam/TC70_3.0_1.3.11.json b/tests/fixtures/smartcam/TC70_3.0_1.3.11.json new file mode 100644 index 000000000..b57269820 --- /dev/null +++ b/tests/fixtures/smartcam/TC70_3.0_1.3.11.json @@ -0,0 +1,870 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1734271551", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "TC70", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.11 Build 231121 Rel.39429n(4555)", + "hardware_version": "3.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-E9-31-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + ".name": "chn1_msg_alarm_plan", + ".type": "plan", + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 4 + }, + { + "name": "detection", + "version": 1 + }, + { + "name": "alert", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 1 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 2 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + ".name": "microphone", + ".type": "audio_config", + "channels": "1", + "encode_type": "G711alaw", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + ".name": "speaker", + ".type": "audio_config", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + ".name": "bcd", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + ".name": "harddisk", + ".type": "storage", + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-12-18 22:59:11", + "seconds_from_1970": 1734562751 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -50, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + ".name": "motion_det", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "on", + "sensitivity": "medium" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c212", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "TC70 3.0 IPC", + "device_model": "TC70", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": "3", + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_version": "3.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "5C-E9-31-00-00-00", + "oem_id": "00000000000000000000000000000000", + "sw_version": "1.3.11 Build 231121 Rel.39429n(4555)" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + ".name": "common", + ".type": "on_off", + "enabled": "off", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": false, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1734271551", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + ".name": "common", + ".type": "para", + "area_compensation": "default", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "exp_gain": "0", + "exp_type": "auto", + "focus_limited": "600", + "focus_type": "semi_auto", + "high_light_compensation": "off", + "inf_delay": "10", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_start_time": "64800", + "inf_type": "auto", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off" + }, + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getLedStatus": { + "led": { + "config": { + ".name": "config", + ".type": "led", + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + ".name": "lens_mask_info", + ".type": "lens_mask_info", + "enabled": "on" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + ".name": "common", + ".type": "para", + "area_compensation": "default", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "exp_gain": "0", + "exp_type": "auto", + "focus_limited": "600", + "focus_type": "semi_auto", + "high_light_compensation": "off", + "inf_delay": "10", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_start_time": "64800", + "inf_type": "auto", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + ".name": "media_encrypt", + ".type": "on_off", + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + ".name": "chn1_msg_push_info", + ".type": "on_off", + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + ".name": "detection", + ".type": "on_off", + "enabled": "off" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [ + "1" + ], + "name": [ + "Viewpoint 1" + ], + "position_pan": [ + "0.088935" + ], + "position_tilt": [ + "-1.000000" + ], + "read_only": [ + "0" + ] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + ".name": "chn1_channel", + ".type": "plan", + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_total_space": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_total_space": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "type": "local", + "video_free_space": "0B", + "video_total_space": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + ".name": "tamper_det", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + ".name": "target_track_info", + ".type": "target_track_info", + "enabled": "off" + } + } + }, + "getTimezone": { + "system": { + "basic": { + ".name": "basic", + ".type": "setting", + "timezone": "UTC-00:00", + "timing_mode": "ntp", + "zone_id": "Europe/London" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + ".name": "main", + ".type": "capability", + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "2048" + ], + "encode_types": [ + "H264" + ], + "frame_rates": [ + "65537", + "65546", + "65551" + ], + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "1920*1080", + "1280*720", + "640*360" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + ".name": "main", + ".type": "stream", + "bitrate": "1024", + "bitrate_type": "vbr", + "encode_type": "H264", + "frame_rate": "65551", + "gop_factor": "2", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "1280*720", + "stream_type": "general" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + ".name": "device_microphone", + ".type": "capability", + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + ".name": "device_speaker", + ".type": "capability", + "channels": "1", + "decode_type": [ + "G711" + ], + "mute": "0", + "sampling_rate": [ + "8" + ], + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + ".name": "microphone", + ".type": "audio_config", + "channels": "1", + "encode_type": "G711alaw", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + ".name": "speaker", + ".type": "audio_config", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + ".name": "vhttpd", + ".type": "server", + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + ".name": "module_spec", + ".type": "module-spec", + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "audioexception_detection": "0", + "auth_encrypt": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "0", + "device_share": [ + "preview", + "playback", + "voice", + "motor", + "cloud_storage" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "greeter": "1.0", + "http_system_state_audio_support": "1", + "intrusion_detection": "1", + "led": "1", + "lens_mask": "1", + "linecrossing_detection": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_alarm_separate_list": [ + "light", + "sound" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi" + ], + "ota_upgrade": "1", + "p2p_support_versions": [ + "1.1" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "1.3" + ], + "reonboarding": "1", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "1.0" + ], + "target_track": "1.0", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264" + ], + "video_detection_digital_sensitivity": "1", + "wifi_cascade_connection": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "1" + } + } + } + }, + "get_motor": { + "get": { + "motor": { + "capability": { + ".name": "capability", + ".type": "ptz", + "absolute_move_supported": "1", + "calibrate_supported": "1", + "continuous_move_supported": "1", + "eflip_mode": [ + "off", + "on" + ], + "home_position_mode": "none", + "limit_supported": "0", + "manual_control_level": [ + "low", + "normal", + "high" + ], + "manual_control_mode": [ + "compatible", + "pedestrian", + "motor_vehicle", + "non_motor_vehicle", + "self_adaptive" + ], + "park_supported": "0", + "pattern_supported": "0", + "plan_supported": "0", + "position_pan_range": [ + "-1.000000", + "1.000000" + ], + "position_tilt_range": [ + "-1.000000", + "1.000000" + ], + "poweroff_save_supported": "1", + "poweroff_save_time_range": [ + "10", + "600" + ], + "preset_number_max": "8", + "preset_supported": "1", + "relative_move_supported": "1", + "reverse_mode": [ + "off", + "on", + "auto" + ], + "scan_supported": "0", + "speed_pan_max": "1.00000", + "speed_tilt_max": "1.000000", + "tour_supported": "0" + } + } + } + } +} From b5f49a3c8a482ea4255b8ae86e8d909b6a737664 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 19 Dec 2024 13:52:25 +0000 Subject: [PATCH 767/892] Fix lens mask required component and state (#1386) Fixes a few issues with the lens mask since migrating it into its own module: - The module didn't provide itself as the container and hence the feature was accessing the same properties on the device. - `enabled` getter on the module incorrect but not picked up due to the previous issue. - No `REQUIRED_COMPONENT` set to ensure the module only created if available. Also changes attribute names to `enabled` from `state` to avoid confusion with device states. --- kasa/smartcam/modules/camera.py | 5 +++-- kasa/smartcam/modules/lensmask.py | 10 ++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/kasa/smartcam/modules/camera.py b/kasa/smartcam/modules/camera.py index 1e1f45701..f1eda0f93 100644 --- a/kasa/smartcam/modules/camera.py +++ b/kasa/smartcam/modules/camera.py @@ -39,6 +39,7 @@ def _initialize_features(self) -> None: self._device, id="state", name="State", + container=self, attribute_getter="is_on", attribute_setter="set_state", type=Feature.Type.Switch, @@ -50,7 +51,7 @@ def _initialize_features(self) -> None: def is_on(self) -> bool: """Return the device on state.""" if lens_mask := self._device.modules.get(Module.LensMask): - return lens_mask.state + return not lens_mask.enabled return True async def set_state(self, on: bool) -> Annotated[dict, FeatureAttribute()]: @@ -60,7 +61,7 @@ async def set_state(self, on: bool) -> Annotated[dict, FeatureAttribute()]: """ if lens_mask := self._device.modules.get(Module.LensMask): # Turning off enables the privacy mask which is why value is reversed. - return await lens_mask.set_state(not on) + return await lens_mask.set_enabled(not on) return {} def _get_credentials(self) -> Credentials | None: diff --git a/kasa/smartcam/modules/lensmask.py b/kasa/smartcam/modules/lensmask.py index 7a54beb18..9257b3060 100644 --- a/kasa/smartcam/modules/lensmask.py +++ b/kasa/smartcam/modules/lensmask.py @@ -12,18 +12,20 @@ class LensMask(SmartCamModule): """Implementation of lens mask module.""" + REQUIRED_COMPONENT = "lensMask" + QUERY_GETTER_NAME = "getLensMaskConfig" QUERY_MODULE_NAME = "lens_mask" QUERY_SECTION_NAMES = "lens_mask_info" @property - def state(self) -> bool: + def enabled(self) -> bool: """Return the lens mask state.""" - return self.data["lens_mask_info"]["enabled"] == "off" + return self.data["lens_mask_info"]["enabled"] == "on" - async def set_state(self, state: bool) -> dict: + async def set_enabled(self, enable: bool) -> dict: """Set the lens mask state.""" - params = {"enabled": "on" if state else "off"} + params = {"enabled": "on" if enable else "off"} return await self._device._query_setter_helper( "setLensMaskConfig", self.QUERY_MODULE_NAME, "lens_mask_info", params ) From d890b0a3acd0948bb8c82a47b8a58e3dac92d5e5 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 19 Dec 2024 23:22:08 +0000 Subject: [PATCH 768/892] Add smartcam detection modules (#1389) - Motion detection - Person detection - Tamper detection - Baby Cry Detection --- kasa/smartcam/modules/__init__.py | 8 ++++ kasa/smartcam/modules/babycrydetection.py | 47 +++++++++++++++++++ kasa/smartcam/modules/motiondetection.py | 47 +++++++++++++++++++ kasa/smartcam/modules/persondetection.py | 47 +++++++++++++++++++ kasa/smartcam/modules/tamperdetection.py | 47 +++++++++++++++++++ kasa/smartcam/smartcammodule.py | 12 +++++ .../smartcam/modules/test_babycrydetection.py | 45 ++++++++++++++++++ .../smartcam/modules/test_motiondetection.py | 43 +++++++++++++++++ .../smartcam/modules/test_persondetection.py | 45 ++++++++++++++++++ .../smartcam/modules/test_tamperdetection.py | 45 ++++++++++++++++++ 10 files changed, 386 insertions(+) create mode 100644 kasa/smartcam/modules/babycrydetection.py create mode 100644 kasa/smartcam/modules/motiondetection.py create mode 100644 kasa/smartcam/modules/persondetection.py create mode 100644 kasa/smartcam/modules/tamperdetection.py create mode 100644 tests/smartcam/modules/test_babycrydetection.py create mode 100644 tests/smartcam/modules/test_motiondetection.py create mode 100644 tests/smartcam/modules/test_persondetection.py create mode 100644 tests/smartcam/modules/test_tamperdetection.py diff --git a/kasa/smartcam/modules/__init__.py b/kasa/smartcam/modules/__init__.py index fae5923fa..3ea4bb6a0 100644 --- a/kasa/smartcam/modules/__init__.py +++ b/kasa/smartcam/modules/__init__.py @@ -1,6 +1,7 @@ """Modules for SMARTCAM devices.""" from .alarm import Alarm +from .babycrydetection import BabyCryDetection from .camera import Camera from .childdevice import ChildDevice from .device import DeviceModule @@ -8,18 +9,25 @@ from .led import Led from .lensmask import LensMask from .matter import Matter +from .motiondetection import MotionDetection from .pantilt import PanTilt +from .persondetection import PersonDetection +from .tamperdetection import TamperDetection from .time import Time __all__ = [ "Alarm", + "BabyCryDetection", "Camera", "ChildDevice", "DeviceModule", "Led", "PanTilt", + "PersonDetection", "Time", "HomeKit", "Matter", + "MotionDetection", "LensMask", + "TamperDetection", ] diff --git a/kasa/smartcam/modules/babycrydetection.py b/kasa/smartcam/modules/babycrydetection.py new file mode 100644 index 000000000..e9e323717 --- /dev/null +++ b/kasa/smartcam/modules/babycrydetection.py @@ -0,0 +1,47 @@ +"""Implementation of baby cry detection module.""" + +from __future__ import annotations + +import logging + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class BabyCryDetection(SmartCamModule): + """Implementation of baby cry detection module.""" + + REQUIRED_COMPONENT = "babyCryDetection" + + QUERY_GETTER_NAME = "getBCDConfig" + QUERY_MODULE_NAME = "sound_detection" + QUERY_SECTION_NAMES = "bcd" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="baby_cry_detection", + name="Baby cry detection", + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) + ) + + @property + def enabled(self) -> bool: + """Return the baby cry detection enabled state.""" + return self.data["bcd"]["enabled"] == "on" + + async def set_enabled(self, enable: bool) -> dict: + """Set the baby cry detection enabled state.""" + params = {"enabled": "on" if enable else "off"} + return await self._device._query_setter_helper( + "setBCDConfig", self.QUERY_MODULE_NAME, "bcd", params + ) diff --git a/kasa/smartcam/modules/motiondetection.py b/kasa/smartcam/modules/motiondetection.py new file mode 100644 index 000000000..33067bdff --- /dev/null +++ b/kasa/smartcam/modules/motiondetection.py @@ -0,0 +1,47 @@ +"""Implementation of motion detection module.""" + +from __future__ import annotations + +import logging + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class MotionDetection(SmartCamModule): + """Implementation of motion detection module.""" + + REQUIRED_COMPONENT = "detection" + + QUERY_GETTER_NAME = "getDetectionConfig" + QUERY_MODULE_NAME = "motion_detection" + QUERY_SECTION_NAMES = "motion_det" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="motion_detection", + name="Motion detection", + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) + ) + + @property + def enabled(self) -> bool: + """Return the motion detection enabled state.""" + return self.data["motion_det"]["enabled"] == "on" + + async def set_enabled(self, enable: bool) -> dict: + """Set the motion detection enabled state.""" + params = {"enabled": "on" if enable else "off"} + return await self._device._query_setter_helper( + "setDetectionConfig", self.QUERY_MODULE_NAME, "motion_det", params + ) diff --git a/kasa/smartcam/modules/persondetection.py b/kasa/smartcam/modules/persondetection.py new file mode 100644 index 000000000..641609d54 --- /dev/null +++ b/kasa/smartcam/modules/persondetection.py @@ -0,0 +1,47 @@ +"""Implementation of person detection module.""" + +from __future__ import annotations + +import logging + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class PersonDetection(SmartCamModule): + """Implementation of person detection module.""" + + REQUIRED_COMPONENT = "personDetection" + + QUERY_GETTER_NAME = "getPersonDetectionConfig" + QUERY_MODULE_NAME = "people_detection" + QUERY_SECTION_NAMES = "detection" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="person_detection", + name="Person detection", + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) + ) + + @property + def enabled(self) -> bool: + """Return the person detection enabled state.""" + return self.data["detection"]["enabled"] == "on" + + async def set_enabled(self, enable: bool) -> dict: + """Set the person detection enabled state.""" + params = {"enabled": "on" if enable else "off"} + return await self._device._query_setter_helper( + "setPersonDetectionConfig", self.QUERY_MODULE_NAME, "detection", params + ) diff --git a/kasa/smartcam/modules/tamperdetection.py b/kasa/smartcam/modules/tamperdetection.py new file mode 100644 index 000000000..32b352f79 --- /dev/null +++ b/kasa/smartcam/modules/tamperdetection.py @@ -0,0 +1,47 @@ +"""Implementation of tamper detection module.""" + +from __future__ import annotations + +import logging + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class TamperDetection(SmartCamModule): + """Implementation of tamper detection module.""" + + REQUIRED_COMPONENT = "tamperDetection" + + QUERY_GETTER_NAME = "getTamperDetectionConfig" + QUERY_MODULE_NAME = "tamper_detection" + QUERY_SECTION_NAMES = "tamper_det" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="tamper_detection", + name="Tamper detection", + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) + ) + + @property + def enabled(self) -> bool: + """Return the tamper detection enabled state.""" + return self.data["tamper_det"]["enabled"] == "on" + + async def set_enabled(self, enable: bool) -> dict: + """Set the tamper detection enabled state.""" + params = {"enabled": "on" if enable else "off"} + return await self._device._query_setter_helper( + "setTamperDetectionConfig", self.QUERY_MODULE_NAME, "tamper_det", params + ) diff --git a/kasa/smartcam/smartcammodule.py b/kasa/smartcam/smartcammodule.py index 390335974..467d18c02 100644 --- a/kasa/smartcam/smartcammodule.py +++ b/kasa/smartcam/smartcammodule.py @@ -20,6 +20,18 @@ class SmartCamModule(SmartModule): """Base class for SMARTCAM modules.""" SmartCamAlarm: Final[ModuleName[modules.Alarm]] = ModuleName("SmartCamAlarm") + SmartCamMotionDetection: Final[ModuleName[modules.MotionDetection]] = ModuleName( + "MotionDetection" + ) + SmartCamPersonDetection: Final[ModuleName[modules.PersonDetection]] = ModuleName( + "PersonDetection" + ) + SmartCamTamperDetection: Final[ModuleName[modules.TamperDetection]] = ModuleName( + "TamperDetection" + ) + SmartCamBabyCryDetection: Final[ModuleName[modules.BabyCryDetection]] = ModuleName( + "BabyCryDetection" + ) #: Module name to be queried QUERY_MODULE_NAME: str diff --git a/tests/smartcam/modules/test_babycrydetection.py b/tests/smartcam/modules/test_babycrydetection.py new file mode 100644 index 000000000..89ff5ac43 --- /dev/null +++ b/tests/smartcam/modules/test_babycrydetection.py @@ -0,0 +1,45 @@ +"""Tests for smartcam baby cry detection module.""" + +from __future__ import annotations + +from kasa import Device +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...device_fixtures import parametrize + +babycrydetection = parametrize( + "has babycry detection", + component_filter="babyCryDetection", + protocol_filter={"SMARTCAM"}, +) + + +@babycrydetection +async def test_babycrydetection(dev: Device): + """Test device babycry detection.""" + babycry = dev.modules.get(SmartCamModule.SmartCamBabyCryDetection) + assert babycry + + bcde_feat = dev.features.get("baby_cry_detection") + assert bcde_feat + + original_enabled = babycry.enabled + + try: + await babycry.set_enabled(not original_enabled) + await dev.update() + assert babycry.enabled is not original_enabled + assert bcde_feat.value is not original_enabled + + await babycry.set_enabled(original_enabled) + await dev.update() + assert babycry.enabled is original_enabled + assert bcde_feat.value is original_enabled + + await bcde_feat.set_value(not original_enabled) + await dev.update() + assert babycry.enabled is not original_enabled + assert bcde_feat.value is not original_enabled + + finally: + await babycry.set_enabled(original_enabled) diff --git a/tests/smartcam/modules/test_motiondetection.py b/tests/smartcam/modules/test_motiondetection.py new file mode 100644 index 000000000..c4ff98079 --- /dev/null +++ b/tests/smartcam/modules/test_motiondetection.py @@ -0,0 +1,43 @@ +"""Tests for smartcam motion detection module.""" + +from __future__ import annotations + +from kasa import Device +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...device_fixtures import parametrize + +motiondetection = parametrize( + "has motion detection", component_filter="detection", protocol_filter={"SMARTCAM"} +) + + +@motiondetection +async def test_motiondetection(dev: Device): + """Test device motion detection.""" + motion = dev.modules.get(SmartCamModule.SmartCamMotionDetection) + assert motion + + mde_feat = dev.features.get("motion_detection") + assert mde_feat + + original_enabled = motion.enabled + + try: + await motion.set_enabled(not original_enabled) + await dev.update() + assert motion.enabled is not original_enabled + assert mde_feat.value is not original_enabled + + await motion.set_enabled(original_enabled) + await dev.update() + assert motion.enabled is original_enabled + assert mde_feat.value is original_enabled + + await mde_feat.set_value(not original_enabled) + await dev.update() + assert motion.enabled is not original_enabled + assert mde_feat.value is not original_enabled + + finally: + await motion.set_enabled(original_enabled) diff --git a/tests/smartcam/modules/test_persondetection.py b/tests/smartcam/modules/test_persondetection.py new file mode 100644 index 000000000..341375878 --- /dev/null +++ b/tests/smartcam/modules/test_persondetection.py @@ -0,0 +1,45 @@ +"""Tests for smartcam person detection module.""" + +from __future__ import annotations + +from kasa import Device +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...device_fixtures import parametrize + +persondetection = parametrize( + "has person detection", + component_filter="personDetection", + protocol_filter={"SMARTCAM"}, +) + + +@persondetection +async def test_persondetection(dev: Device): + """Test device person detection.""" + person = dev.modules.get(SmartCamModule.SmartCamPersonDetection) + assert person + + pde_feat = dev.features.get("person_detection") + assert pde_feat + + original_enabled = person.enabled + + try: + await person.set_enabled(not original_enabled) + await dev.update() + assert person.enabled is not original_enabled + assert pde_feat.value is not original_enabled + + await person.set_enabled(original_enabled) + await dev.update() + assert person.enabled is original_enabled + assert pde_feat.value is original_enabled + + await pde_feat.set_value(not original_enabled) + await dev.update() + assert person.enabled is not original_enabled + assert pde_feat.value is not original_enabled + + finally: + await person.set_enabled(original_enabled) diff --git a/tests/smartcam/modules/test_tamperdetection.py b/tests/smartcam/modules/test_tamperdetection.py new file mode 100644 index 000000000..ab2f851d5 --- /dev/null +++ b/tests/smartcam/modules/test_tamperdetection.py @@ -0,0 +1,45 @@ +"""Tests for smartcam tamper detection module.""" + +from __future__ import annotations + +from kasa import Device +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...device_fixtures import parametrize + +tamperdetection = parametrize( + "has tamper detection", + component_filter="tamperDetection", + protocol_filter={"SMARTCAM"}, +) + + +@tamperdetection +async def test_tamperdetection(dev: Device): + """Test device tamper detection.""" + tamper = dev.modules.get(SmartCamModule.SmartCamTamperDetection) + assert tamper + + tde_feat = dev.features.get("tamper_detection") + assert tde_feat + + original_enabled = tamper.enabled + + try: + await tamper.set_enabled(not original_enabled) + await dev.update() + assert tamper.enabled is not original_enabled + assert tde_feat.value is not original_enabled + + await tamper.set_enabled(original_enabled) + await dev.update() + assert tamper.enabled is original_enabled + assert tde_feat.value is original_enabled + + await tde_feat.set_value(not original_enabled) + await dev.update() + assert tamper.enabled is not original_enabled + assert tde_feat.value is not original_enabled + + finally: + await tamper.set_enabled(original_enabled) From 83eb73cc7f0f473550fa1148a709fcb9ef551b6c Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 20 Dec 2024 06:16:18 +0000 Subject: [PATCH 769/892] Add rssi and signal_level to smartcam (#1392) --- kasa/smartcam/modules/device.py | 45 ++++++++++++++++++++++++++++++++- kasa/smartcam/smartcamdevice.py | 6 +++++ kasa/smartcam/smartcammodule.py | 4 +++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/kasa/smartcam/modules/device.py b/kasa/smartcam/modules/device.py index 0541d75c6..655a92daf 100644 --- a/kasa/smartcam/modules/device.py +++ b/kasa/smartcam/modules/device.py @@ -14,6 +14,13 @@ class DeviceModule(SmartCamModule): QUERY_MODULE_NAME = "device_info" QUERY_SECTION_NAMES = ["basic_info", "info"] + def query(self) -> dict: + """Query to execute during the update cycle.""" + q = super().query() + q["getConnectionType"] = {"network": {"get_connection_type": []}} + + return q + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( @@ -26,6 +33,32 @@ def _initialize_features(self) -> None: type=Feature.Type.Sensor, ) ) + if self.rssi is not None: + self._add_feature( + Feature( + self._device, + container=self, + id="rssi", + name="RSSI", + attribute_getter="rssi", + icon="mdi:signal", + unit_getter=lambda: "dBm", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + container=self, + id="signal_level", + name="Signal Level", + attribute_getter="signal_level", + icon="mdi:signal", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) async def _post_update_hook(self) -> None: """Overriden to prevent module disabling. @@ -37,4 +70,14 @@ async def _post_update_hook(self) -> None: @property def device_id(self) -> str: """Return the device id.""" - return self.data["basic_info"]["dev_id"] + return self.data[self.QUERY_GETTER_NAME]["basic_info"]["dev_id"] + + @property + def rssi(self) -> int | None: + """Return the device id.""" + return self.data["getConnectionType"].get("rssiValue") + + @property + def signal_level(self) -> int | None: + """Return the device id.""" + return self.data["getConnectionType"].get("rssi") diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index 6bc4963a6..fdae3140b 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -185,6 +185,7 @@ async def _negotiate(self) -> None: initial_query = { "getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}}, "getAppComponentList": {"app_component": {"name": "app_component_list"}}, + "getConnectionType": {"network": {"get_connection_type": {}}}, } resp = await self.protocol.query(initial_query) self._last_update.update(resp) @@ -261,3 +262,8 @@ def hw_info(self) -> dict: "dev_name": self.alias, "oemId": self._info.get("oem_id"), } + + @property + def rssi(self) -> int | None: + """Return the device id.""" + return self.modules[SmartCamModule.SmartCamDeviceModule].rssi diff --git a/kasa/smartcam/smartcammodule.py b/kasa/smartcam/smartcammodule.py index 467d18c02..85addd65c 100644 --- a/kasa/smartcam/smartcammodule.py +++ b/kasa/smartcam/smartcammodule.py @@ -33,6 +33,10 @@ class SmartCamModule(SmartModule): "BabyCryDetection" ) + SmartCamDeviceModule: Final[ModuleName[modules.DeviceModule]] = ModuleName( + "devicemodule" + ) + #: Module name to be queried QUERY_MODULE_NAME: str #: Section name or names to be queried From fe88b52e19534ad84e5595070136bcf1e1adb0be Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 20 Dec 2024 09:53:07 +0100 Subject: [PATCH 770/892] Fallback to other module data on get_energy_usage errors (#1245) - The `get_energy_usage` query can fail if the device time is not set because the response includes the device time. - Make `get_energy_usage` an optional query response so the energy module can fall back to getting the power from `get_emeter_data` or `get_current_power` on error. - Devices on `energy_monitoring` version 1 still fail as they have no additional queries to fall back to. --- kasa/smart/modules/energy.py | 68 ++++++++++++++-------- kasa/smart/smartmodule.py | 35 +++++++++++- tests/smart/modules/test_energy.py | 90 +++++++++++++++++++++++++++++- tests/smart/test_smartdevice.py | 4 +- 4 files changed, 168 insertions(+), 29 deletions(-) diff --git a/kasa/smart/modules/energy.py b/kasa/smart/modules/energy.py index 6b5bdb579..0cfdc92c2 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -2,10 +2,10 @@ from __future__ import annotations -from typing import NoReturn +from typing import Any, NoReturn from ...emeterstatus import EmeterStatus -from ...exceptions import KasaException +from ...exceptions import DeviceError, KasaException from ...interfaces.energy import Energy as EnergyInterface from ..smartmodule import SmartModule, raise_if_update_error @@ -15,12 +15,39 @@ class Energy(SmartModule, EnergyInterface): REQUIRED_COMPONENT = "energy_monitoring" + _energy: dict[str, Any] + _current_consumption: float | None + async def _post_update_hook(self) -> None: - if "voltage_mv" in self.data.get("get_emeter_data", {}): + try: + data = self.data + except DeviceError as de: + self._energy = {} + self._current_consumption = None + raise de + + # If version is 1 then data is get_energy_usage + self._energy = data.get("get_energy_usage", data) + + if "voltage_mv" in data.get("get_emeter_data", {}): self._supported = ( self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT ) + if (power := self._energy.get("current_power")) is not None or ( + power := data.get("get_emeter_data", {}).get("power_mw") + ) is not None: + self._current_consumption = power / 1_000 + # Fallback if get_energy_usage does not provide current_power, + # which can happen on some newer devices (e.g. P304M). + # This may not be valid scenario as it pre-dates trying get_emeter_data + elif ( + power := self.data.get("get_current_power", {}).get("current_power") + ) is not None: + self._current_consumption = power + else: + self._current_consumption = None + def query(self) -> dict: """Query to execute during the update cycle.""" req = { @@ -33,28 +60,21 @@ def query(self) -> dict: return req @property - @raise_if_update_error + def optional_response_keys(self) -> list[str]: + """Return optional response keys for the module.""" + if self.supported_version > 1: + return ["get_energy_usage"] + return [] + + @property def current_consumption(self) -> float | None: """Current power in watts.""" - if (power := self.energy.get("current_power")) is not None or ( - power := self.data.get("get_emeter_data", {}).get("power_mw") - ) is not None: - return power / 1_000 - # Fallback if get_energy_usage does not provide current_power, - # which can happen on some newer devices (e.g. P304M). - elif ( - power := self.data.get("get_current_power", {}).get("current_power") - ) is not None: - return power - return None + return self._current_consumption @property - @raise_if_update_error def energy(self) -> dict: """Return get_energy_usage results.""" - if en := self.data.get("get_energy_usage"): - return en - return self.data + return self._energy def _get_status_from_energy(self, energy: dict) -> EmeterStatus: return EmeterStatus( @@ -83,16 +103,18 @@ async def get_status(self) -> EmeterStatus: return self._get_status_from_energy(res["get_energy_usage"]) @property - @raise_if_update_error def consumption_this_month(self) -> float | None: """Get the emeter value for this month in kWh.""" - return self.energy.get("month_energy", 0) / 1_000 + if (month := self.energy.get("month_energy")) is not None: + return month / 1_000 + return None @property - @raise_if_update_error def consumption_today(self) -> float | None: """Get the emeter value for today in kWh.""" - return self.energy.get("today_energy", 0) / 1_000 + if (today := self.energy.get("today_energy")) is not None: + return today / 1_000 + return None @property @raise_if_update_error diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 31fc8f353..a5666f632 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -72,6 +72,7 @@ def __init__(self, device: SmartDevice, module: str) -> None: self._last_update_time: float | None = None self._last_update_error: KasaException | None = None self._error_count = 0 + self._logged_remove_keys: list[str] = [] def __init_subclass__(cls, **kwargs) -> None: # We only want to register submodules in a modules package so that @@ -149,6 +150,15 @@ async def call(self, method: str, params: dict | None = None) -> dict: """ return await self._device._query_helper(method, params) + @property + def optional_response_keys(self) -> list[str]: + """Return optional response keys for the module. + + Defaults to no keys. Overriding this and providing keys will remove + instead of raise on error. + """ + return [] + @property def data(self) -> dict[str, Any]: """Return response data for the module. @@ -181,12 +191,31 @@ def data(self) -> dict[str, Any]: filtered_data = {k: v for k, v in dev._last_update.items() if k in q_keys} + remove_keys: list[str] = [] for data_item in filtered_data: if isinstance(filtered_data[data_item], SmartErrorCode): - raise DeviceError( - f"{data_item} for {self.name}", error_code=filtered_data[data_item] + if data_item in self.optional_response_keys: + remove_keys.append(data_item) + else: + raise DeviceError( + f"{data_item} for {self.name}", + error_code=filtered_data[data_item], + ) + + for key in remove_keys: + if key not in self._logged_remove_keys: + self._logged_remove_keys.append(key) + _LOGGER.debug( + "Removed key %s from response for device %s as it returned " + "error: %s. This message will only be logged once per key.", + key, + self._device.host, + filtered_data[key], ) - if len(filtered_data) == 1: + + filtered_data.pop(key) + + if len(filtered_data) == 1 and not remove_keys: return next(iter(filtered_data.values())) return filtered_data diff --git a/tests/smart/modules/test_energy.py b/tests/smart/modules/test_energy.py index fdbea88bb..7b31d74bf 100644 --- a/tests/smart/modules/test_energy.py +++ b/tests/smart/modules/test_energy.py @@ -1,7 +1,14 @@ +import copy +import logging +from contextlib import nullcontext as does_not_raise +from unittest.mock import patch + import pytest -from kasa import Module, SmartDevice +from kasa import DeviceError, Module +from kasa.exceptions import SmartErrorCode from kasa.interfaces.energy import Energy +from kasa.smart import SmartDevice from kasa.smart.modules import Energy as SmartEnergyModule from tests.conftest import has_emeter_smart @@ -19,3 +26,84 @@ async def test_supported(dev: SmartDevice): assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False else: assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is True + + +@has_emeter_smart +async def test_get_energy_usage_error( + dev: SmartDevice, caplog: pytest.LogCaptureFixture +): + """Test errors on get_energy_usage.""" + caplog.set_level(logging.DEBUG) + + energy_module = dev.modules.get(Module.Energy) + if not energy_module: + pytest.skip(f"Energy module not supported for {dev}.") + + version = dev._components["energy_monitoring"] + + expected_raise = does_not_raise() if version > 1 else pytest.raises(DeviceError) + if version > 1: + expected = "get_energy_usage" + expected_current_consumption = 2.002 + else: + expected = "current_power" + expected_current_consumption = None + + assert expected in energy_module.data + assert energy_module.current_consumption is not None + assert energy_module.consumption_today is not None + assert energy_module.consumption_this_month is not None + + last_update = copy.deepcopy(dev._last_update) + resp = copy.deepcopy(last_update) + + if ed := resp.get("get_emeter_data"): + ed["power_mw"] = 2002 + if cp := resp.get("get_current_power"): + cp["current_power"] = 2.002 + resp["get_energy_usage"] = SmartErrorCode.JSON_DECODE_FAIL_ERROR + + # version 1 only has get_energy_usage so module should raise an error if + # version 1 and get_energy_usage is in error + with patch.object(dev.protocol, "query", return_value=resp): + await dev.update() + + with expected_raise: + assert "get_energy_usage" not in energy_module.data + + assert energy_module.current_consumption == expected_current_consumption + assert energy_module.consumption_today is None + assert energy_module.consumption_this_month is None + + msg = ( + f"Removed key get_energy_usage from response for device {dev.host}" + " as it returned error: JSON_DECODE_FAIL_ERROR" + ) + if version > 1: + assert msg in caplog.text + + # Now test with no get_emeter_data + # This may not be valid scenario but we have a fallback to get_current_power + # just in case that should be tested. + caplog.clear() + resp = copy.deepcopy(last_update) + + if cp := resp.get("get_current_power"): + cp["current_power"] = 2.002 + resp["get_energy_usage"] = SmartErrorCode.JSON_DECODE_FAIL_ERROR + + # Remove get_emeter_data from the response and from the device which will + # remember it otherwise. + resp.pop("get_emeter_data", None) + dev._last_update.pop("get_emeter_data", None) + + with patch.object(dev.protocol, "query", return_value=resp): + await dev.update() + + with expected_raise: + assert "get_energy_usage" not in energy_module.data + + assert energy_module.current_consumption == expected_current_consumption + + # message should only be logged once + assert msg not in caplog.text diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index 83635d8ed..549eb8add 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -355,7 +355,7 @@ async def _child_query(self, request, *args, **kwargs): if mod.name == "Energy": emod = cast(Energy, mod) with pytest.raises(KasaException, match="Module update error"): - assert emod.current_consumption is not None + assert emod.status is not None else: assert mod.disabled is False assert mod._error_count == 0 @@ -363,7 +363,7 @@ async def _child_query(self, request, *args, **kwargs): # Test one of the raise_if_update_error doesn't raise if mod.name == "Energy": emod = cast(Energy, mod) - assert emod.current_consumption is not None + assert emod.status is not None async def test_get_modules(): From 296af3192e10718648069deb29c66175bb0fbc1b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 20 Dec 2024 13:21:38 +0000 Subject: [PATCH 771/892] Handle KeyboardInterrupts in the cli better (#1391) Addresses an issue with how `asyncclick` deals with `KeyboardInterrupt` errors. Instead of the `click.main` receiving `KeyboardInterrupt` it receives `CancelledError` because it's a task running inside the loop. Also ensures that discovery catches the `CancelledError` and closes the http clients. --- kasa/cli/common.py | 17 +++++++++++++++++ kasa/discover.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/kasa/cli/common.py b/kasa/cli/common.py index 649df0655..5114f7af7 100644 --- a/kasa/cli/common.py +++ b/kasa/cli/common.py @@ -2,12 +2,14 @@ from __future__ import annotations +import asyncio import json import re import sys from collections.abc import Callable from contextlib import contextmanager from functools import singledispatch, update_wrapper, wraps +from gettext import gettext from typing import TYPE_CHECKING, Any, Final import asyncclick as click @@ -238,4 +240,19 @@ async def invoke(self, ctx): except Exception as exc: _handle_exception(self._debug, exc) + def __call__(self, *args, **kwargs): + """Run the coroutine in the event loop and print any exceptions. + + python click catches KeyboardInterrupt in main, raises Abort() + and does sys.exit. asyncclick doesn't properly handle a coroutine + receiving CancelledError on a KeyboardInterrupt, so we catch the + KeyboardInterrupt here once asyncio.run has re-raised it. This + avoids large stacktraces when a user presses Ctrl-C. + """ + try: + asyncio.run(self.main(*args, **kwargs)) + except KeyboardInterrupt: + click.echo(gettext("\nAborted!"), file=sys.stderr) + sys.exit(1) + return _CommandCls diff --git a/kasa/discover.py b/kasa/discover.py index 77ef80be1..b696c3708 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -498,7 +498,7 @@ async def discover( try: _LOGGER.debug("Waiting %s seconds for responses...", discovery_timeout) await protocol.wait_for_discovery_to_complete() - except KasaException as ex: + except (KasaException, asyncio.CancelledError) as ex: for device in protocol.discovered_devices.values(): await device.protocol.close() raise ex From 93ca3ad2e10194a13dcee843c6deab369930a672 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 20 Dec 2024 14:55:15 +0000 Subject: [PATCH 772/892] Handle smartcam device blocked response (#1393) Devices that have failed authentication multiple times due to bad credentials go into a blocked state for 30 mins. Handle that as a different error type instead of treating it as a normal `AuthenticationError`. --- kasa/transports/sslaestransport.py | 31 +++++++++++++++++++++++- tests/transports/test_sslaestransport.py | 27 +++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/kasa/transports/sslaestransport.py b/kasa/transports/sslaestransport.py index 2061d293a..6e6ec0db0 100644 --- a/kasa/transports/sslaestransport.py +++ b/kasa/transports/sslaestransport.py @@ -160,6 +160,19 @@ def _get_response_error(self, resp_dict: Any) -> SmartErrorCode: error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR return error_code + def _get_response_inner_error(self, resp_dict: Any) -> SmartErrorCode | None: + error_code_raw = resp_dict.get("data", {}).get("code") + if error_code_raw is None: + return None + try: + error_code = SmartErrorCode.from_int(error_code_raw) + except ValueError: + _LOGGER.warning( + "Device %s received unknown error code: %s", self._host, error_code_raw + ) + error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR + return error_code + def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: error_code = self._get_response_error(resp_dict) if error_code is SmartErrorCode.SUCCESS: @@ -383,13 +396,29 @@ async def perform_handshake1(self) -> tuple[str, str, str]: error_code = default_error_code resp_dict = default_resp_dict + # If the default login worked it's ok not to provide credentials but if + # it didn't raise auth error here. if not self._username: raise AuthenticationError( f"Credentials must be supplied to connect to {self._host}" ) + + # Device responds with INVALID_NONCE and a "nonce" to indicate ready + # for secure login. Otherwise error. if error_code is not SmartErrorCode.INVALID_NONCE or ( - resp_dict and "nonce" not in resp_dict["result"].get("data", {}) + resp_dict and "nonce" not in resp_dict.get("result", {}).get("data", {}) ): + if ( + resp_dict + and self._get_response_inner_error(resp_dict) + is SmartErrorCode.DEVICE_BLOCKED + ): + sec_left = resp_dict.get("data", {}).get("sec_left") + msg = "Device blocked" + ( + f" for {sec_left} seconds" if sec_left else "" + ) + raise DeviceError(msg, error_code=SmartErrorCode.DEVICE_BLOCKED) + raise AuthenticationError(f"Error trying handshake1: {resp_dict}") if TYPE_CHECKING: diff --git a/tests/transports/test_sslaestransport.py b/tests/transports/test_sslaestransport.py index 6816fa35d..00c54a54b 100644 --- a/tests/transports/test_sslaestransport.py +++ b/tests/transports/test_sslaestransport.py @@ -15,6 +15,7 @@ from kasa.deviceconfig import DeviceConfig from kasa.exceptions import ( AuthenticationError, + DeviceError, KasaException, SmartErrorCode, ) @@ -200,6 +201,22 @@ async def test_unencrypted_response(mocker, caplog): ) +async def test_device_blocked_response(mocker): + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice(host, device_blocked=True) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + msg = "Device blocked for 1685 seconds" + + with pytest.raises(DeviceError, match=msg): + await transport.perform_handshake() + + async def test_port_override(): """Test that port override sets the app_url.""" host = "127.0.0.1" @@ -235,6 +252,11 @@ class MockSslAesDevice: }, } + DEVICE_BLOCKED_RESP = { + "data": {"code": SmartErrorCode.DEVICE_BLOCKED.value, "sec_left": 1685}, + "error_code": SmartErrorCode.SESSION_EXPIRED.value, + } + class _mock_response: def __init__(self, status, request: dict): self.status = status @@ -263,6 +285,7 @@ def __init__( send_error_code=0, secure_passthrough_error_code=0, digest_password_fail=False, + device_blocked=False, ): self.host = host self.http_client = HttpClient(DeviceConfig(self.host)) @@ -277,6 +300,7 @@ def __init__( self.do_not_encrypt_response = do_not_encrypt_response self.want_default_username = want_default_username self.digest_password_fail = digest_password_fail + self.device_blocked = device_blocked async def post(self, url: URL, params=None, json=None, data=None, *_, **__): if data: @@ -303,6 +327,9 @@ async def _return_handshake1_response(self, url: URL, request: dict[str, Any]): request_nonce = request["params"].get("cnonce") request_username = request["params"].get("username") + if self.device_blocked: + return self._mock_response(self.status_code, self.DEVICE_BLOCKED_RESP) + if (self.want_default_username and request_username != MOCK_ADMIN_USER) or ( not self.want_default_username and request_username != MOCK_USER ): From 8418ba3eefdfb167c5ecdb2204516b1533b54a89 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 20 Dec 2024 19:23:18 +0000 Subject: [PATCH 773/892] Treat smartcam 500 errors after handshake as retryable (#1395) `smartcam` devices can respond with 500 if another session is created from the same host --- kasa/httpclient.py | 19 +++++-- kasa/transports/sslaestransport.py | 27 +++++++++- tests/transports/test_sslaestransport.py | 69 ++++++++++++++++++++++-- 3 files changed, 107 insertions(+), 8 deletions(-) diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 87e3626a3..31d8dfbb6 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -113,10 +113,23 @@ async def post( ssl=ssl, ) async with resp: - if resp.status == 200: - response_data = await resp.read() - if return_json: + response_data = await resp.read() + + if resp.status == 200: + if return_json: + response_data = json_loads(response_data.decode()) + else: + _LOGGER.debug( + "Device %s received status code %s with response %s", + self._config.host, + resp.status, + str(response_data), + ) + if response_data and return_json: + try: response_data = json_loads(response_data.decode()) + except Exception: + _LOGGER.debug("Device %s response could not be parsed as json") except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex: if not self._wait_between_requests: diff --git a/kasa/transports/sslaestransport.py b/kasa/transports/sslaestransport.py index 6e6ec0db0..500d9422d 100644 --- a/kasa/transports/sslaestransport.py +++ b/kasa/transports/sslaestransport.py @@ -8,6 +8,7 @@ import logging import secrets import ssl +from contextlib import suppress from enum import Enum, auto from typing import TYPE_CHECKING, Any, cast @@ -229,6 +230,31 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]: ssl=await self._get_ssl_context(), ) + if TYPE_CHECKING: + assert self._encryption_session is not None + + # Devices can respond with 500 if another session is created from + # the same host. Decryption may not succeed after that + if status_code == 500: + msg = ( + f"Device {self._host} replied with status 500 after handshake, " + f"response: " + ) + decrypted = None + if isinstance(resp_dict, dict) and ( + response := resp_dict.get("result", {}).get("response") + ): + with suppress(Exception): + decrypted = self._encryption_session.decrypt(response.encode()) + + if decrypted: + msg += decrypted + else: + msg += str(resp_dict) + + _LOGGER.debug(msg) + raise _RetryableError(msg) + if status_code != 200: raise KasaException( f"{self._host} responded with an unexpected " @@ -241,7 +267,6 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]: if TYPE_CHECKING: resp_dict = cast(dict[str, Any], resp_dict) - assert self._encryption_session is not None if "result" in resp_dict and "response" in resp_dict["result"]: raw_response: str = resp_dict["result"]["response"] diff --git a/tests/transports/test_sslaestransport.py b/tests/transports/test_sslaestransport.py index 00c54a54b..39469967a 100644 --- a/tests/transports/test_sslaestransport.py +++ b/tests/transports/test_sslaestransport.py @@ -18,6 +18,7 @@ DeviceError, KasaException, SmartErrorCode, + _RetryableError, ) from kasa.httpclient import HttpClient from kasa.transports.aestransport import AesEncyptionSession @@ -217,6 +218,48 @@ async def test_device_blocked_response(mocker): await transport.perform_handshake() +@pytest.mark.parametrize( + ("response", "expected_msg"), + [ + pytest.param( + {"error_code": -1, "msg": "Check tapo tag failed"}, + '{"error_code": -1, "msg": "Check tapo tag failed"}', + id="can-decrypt", + ), + pytest.param( + b"12345678", + str({"result": {"response": "12345678"}, "error_code": 0}), + id="cannot-decrypt", + ), + ], +) +async def test_device_500_error(mocker, response, expected_msg): + """Test 500 error raises retryable exception.""" + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice(host) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + + request = { + "method": "getDeviceInfo", + "params": None, + } + + await transport.perform_handshake() + + mock_ssl_aes_device.put_next_response(response) + mock_ssl_aes_device.status_code = 500 + + msg = f"Device 127.0.0.1 replied with status 500 after handshake, response: {expected_msg}" + with pytest.raises(_RetryableError, match=msg): + await transport.send(json_dumps(request)) + + async def test_port_override(): """Test that port override sets the app_url.""" host = "127.0.0.1" @@ -302,6 +345,8 @@ def __init__( self.digest_password_fail = digest_password_fail self.device_blocked = device_blocked + self._next_responses: list[dict | bytes] = [] + async def post(self, url: URL, params=None, json=None, data=None, *_, **__): if data: json = json_loads(data) @@ -386,11 +431,24 @@ async def _return_secure_passthrough_response(self, url: URL, json: dict[str, An assert self.encryption_session decrypted_request = self.encryption_session.decrypt(encrypted_request.encode()) decrypted_request_dict = json_loads(decrypted_request) - decrypted_response = await self._post(url, decrypted_request_dict) - async with decrypted_response: - decrypted_response_data = await decrypted_response.read() - encrypted_response = self.encryption_session.encrypt(decrypted_response_data) + if self._next_responses: + next_response = self._next_responses.pop(0) + if isinstance(next_response, dict): + decrypted_response_data = json_dumps(next_response).encode() + encrypted_response = self.encryption_session.encrypt( + decrypted_response_data + ) + else: + encrypted_response = next_response + else: + decrypted_response = await self._post(url, decrypted_request_dict) + async with decrypted_response: + decrypted_response_data = await decrypted_response.read() + encrypted_response = self.encryption_session.encrypt( + decrypted_response_data + ) + response = ( decrypted_response_data if self.do_not_encrypt_response @@ -405,3 +463,6 @@ async def _return_secure_passthrough_response(self, url: URL, json: dict[str, An async def _return_send_response(self, url: URL, json: dict[str, Any]): result = {"result": {"method": None}, "error_code": self.send_error_code} return self._mock_response(self.status_code, result) + + def put_next_response(self, request: dict | bytes) -> None: + self._next_responses.append(request) From 522c78350ead19c01e2c0a36d3aecaf3c4488b8d Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sat, 21 Dec 2024 09:17:00 +0000 Subject: [PATCH 774/892] Add P135 1.0 1.2.0 fixture (#1397) --- SUPPORTED.md | 1 + tests/fixtures/smart/P135(US)_1.0_1.2.0.json | 419 +++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 tests/fixtures/smart/P135(US)_1.0_1.2.0.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 5d26f8e99..1bc23a785 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -202,6 +202,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (US) / Firmware: 1.1.0 - **P135** - Hardware: 1.0 (US) / Firmware: 1.0.5 + - Hardware: 1.0 (US) / Firmware: 1.2.0 - **TP15** - Hardware: 1.0 (US) / Firmware: 1.0.3 diff --git a/tests/fixtures/smart/P135(US)_1.0_1.2.0.json b/tests/fixtures/smart/P135(US)_1.0_1.2.0.json new file mode 100644 index 000000000..ec1930378 --- /dev/null +++ b/tests/fixtures/smart/P135(US)_1.0_1.2.0.json @@ -0,0 +1,419 @@ +{ + "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": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "error_code": 0, + "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-09-0D-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "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": { + "accessory_at_low_battery": false, + "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": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.0 Build 240415 Rel.171222", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "F0-09-0D-00-00-00", + "model": "P135", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 3428, + "overheat_status": "normal", + "region": "America/Los_Angeles", + "rssi": -35, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -480, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/Los_Angeles", + "time_diff": -480, + "timestamp": 1734735856 + }, + "get_device_usage": { + "time_usage": { + "past30": 57, + "past7": 57, + "today": 57 + } + }, + "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.2.0 Build 240415 Rel.171222", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + }, + "on_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + } + }, + "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": "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": 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": 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==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 15, + "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", + "is_klap": true + } + } +} From cef0e571a0921e67ac51e1813dec8507eaa94a0f Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sat, 21 Dec 2024 09:17:50 +0000 Subject: [PATCH 775/892] Add C225(US) 2.0 1.0.11 fixture (#1398) --- README.md | 2 +- SUPPORTED.md | 2 + .../smartcam/C225(US)_2.0_1.0.11.json | 1283 +++++++++++++++++ 3 files changed, 1286 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smartcam/C225(US)_2.0_1.0.11.json diff --git a/README.md b/README.md index c286ba3f5..262d1d4a5 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Cameras**: C100, C210, C325WB, C520WS, TC65, TC70 +- **Cameras**: C100, C210, C225, C325WB, C520WS, TC65, TC70 - **Hubs**: H100, H200 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 diff --git a/SUPPORTED.md b/SUPPORTED.md index 1bc23a785..68fc1baf6 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -264,6 +264,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **C210** - Hardware: 2.0 (EU) / Firmware: 1.4.2 - Hardware: 2.0 (EU) / Firmware: 1.4.3 +- **C225** + - Hardware: 2.0 (US) / Firmware: 1.0.11 - **C325WB** - Hardware: 1.0 (EU) / Firmware: 1.1.17 - **C520WS** diff --git a/tests/fixtures/smartcam/C225(US)_2.0_1.0.11.json b/tests/fixtures/smartcam/C225(US)_2.0_1.0.11.json new file mode 100644 index 000000000..24227c41b --- /dev/null +++ b/tests/fixtures/smartcam/C225(US)_2.0_1.0.11.json @@ -0,0 +1,1283 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1734729039", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "normal" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C225", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.0.11 Build 240826 Rel.62730n", + "hardware_version": "2.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-A1-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "obd_src": "tplink" + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "15", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "light", + "sound" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "linecrossingDetection", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "patrol", + "version": 1 + }, + { + "name": "panoramicView", + "version": 1 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "meowDetection", + "version": 1 + }, + { + "name": "barkDetection", + "version": 1 + }, + { + "name": "glassDetection", + "version": 1 + }, + { + "name": "alarmDetection", + "version": 1 + }, + { + "name": "infLamp", + "version": 1 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "hdr", + "version": 1 + }, + { + "name": "homekit", + "version": 1 + }, + { + "name": "upnpc", + "version": 2 + }, + { + "name": "bleOnboarding", + "version": 2 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "smartTrack", + "version": 1 + }, + { + "name": "encryption", + "version": 3 + }, + { + "name": "staticIp", + "version": 2 + }, + { + "name": "detectionRegion", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "80" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "80" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getBarkDetectionConfig": { + "bark_detection": { + "detection": { + "enabled": "off", + "sensitivity": "50" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-12-20 15:15:46", + "seconds_from_1970": 1734736546 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -9, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "off", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c225", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C225 2.0 IPC", + "device_model": "C225", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 0, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "2.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "A8-42-A1-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "no_rtsp_constrain": 1, + "oem_id": "00000000000000000000000000000000", + "region": "US", + "sw_version": "1.0.11 Build 240826 Rel.62730n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getGlassDetectionConfig": { + "glass_detection": { + "detection": { + "enabled": "off", + "sensitivity": "50" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1734729039", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMeowDetectionConfig": { + "meow_detection": { + "detection": { + "enabled": "off", + "sensitivity": "50" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [], + "name": [], + "position_pan": [], + "position_tilt": [], + "position_zoom": [], + "read_only": [] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "normal", + "disk_name": "1", + "free_space": "98.6GB", + "free_space_accurate": "105903970616B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "100", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "1729454840", + "rw_attr": "rw", + "status": "normal", + "total_space": "118.8GB", + "total_space_accurate": "127531646976B", + "type": "local", + "video_free_space": "98.6GB", + "video_free_space_accurate": "105903970616B", + "video_total_space": "114.0GB", + "video_total_space_accurate": "122406567936B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-08:00", + "timing_mode": "ntp", + "zone_id": "America/Los_Angeles" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1536", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561", + "65566" + ], + "hdrs": [ + "0", + "1" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2688*1520", + "1920*1080" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "2048", + "bitrate_type": "vbr", + "default_bitrate": "2048", + "encode_type": "H264", + "frame_rate": "65551", + "hdr": "0", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2688*1520", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8" + ], + "system_volume": "80", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "80" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "80" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "ptz": "1", + "record_max_slot_cnt": "6", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "0", + "smart_codec": "0", + "smart_detection": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 5, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "true" + } + } + } +} From d81cf1b3b6a332a66fb527307c346aa207dcaa7a Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sat, 21 Dec 2024 09:20:12 +0000 Subject: [PATCH 776/892] Add P210M(US) 1.0 1.0.3 fixture (#1399) --- README.md | 2 +- SUPPORTED.md | 2 + tests/device_fixtures.py | 2 +- tests/fixtures/smart/P210M(US)_1.0_1.0.3.json | 1585 +++++++++++++++++ tests/test_cli.py | 3 +- tests/test_device.py | 5 +- 6 files changed, 1594 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/smart/P210M(US)_1.0_1.0.3.json diff --git a/README.md b/README.md index 262d1d4a5..29a7684b1 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ The following devices have been tested and confirmed as working. If your device ### Supported Tapo[^1] devices - **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15 -- **Power Strips**: P300, P304M, TP25 +- **Power Strips**: P210M, P300, P304M, TP25 - **Wall Switches**: S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 diff --git a/SUPPORTED.md b/SUPPORTED.md index 68fc1baf6..c0d5c1cb3 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -208,6 +208,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros ### Power Strips +- **P210M** + - Hardware: 1.0 (US) / Firmware: 1.0.3 - **P300** - Hardware: 1.0 (EU) / Firmware: 1.0.13 - Hardware: 1.0 (EU) / Firmware: 1.0.15 diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 6679d0a5c..58f0e9b35 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -111,7 +111,7 @@ "S505D", } SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} -STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} +STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40", "P210M"} STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M"} STRIPS = {*STRIPS_IOT, *STRIPS_SMART} diff --git a/tests/fixtures/smart/P210M(US)_1.0_1.0.3.json b/tests/fixtures/smart/P210M(US)_1.0_1.0.3.json new file mode 100644 index 000000000..61ac47627 --- /dev/null +++ b/tests/fixtures/smart/P210M(US)_1.0_1.0.3.json @@ -0,0 +1,1585 @@ +{ + "child_devices": { + "SCRUBBED_CHILD_DEVICE_ID_1": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 68 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240703 Rel.114246", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "DC6279000000", + "model": "P210M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 170204, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 1, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "America/Los_Angeles", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 275, + "past7": 275, + "today": 168 + }, + "saved_power": { + "past30": 3289, + "past7": 3289, + "today": 745 + }, + "time_usage": { + "past30": 3564, + "past7": 3564, + "today": 913 + } + }, + "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_emeter_data": { + "current_ma": 589, + "energy_wh": 249, + "power_mw": 68325, + "voltage_mv": 120254 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3788, + "slot_id": 0, + "vgain": 30382 + }, + { + "igain": 3833, + "slot_id": 1, + "vgain": 30253 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-12-20 15:13:58", + "month_energy": 275, + "month_runtime": 3564, + "today_energy": 168, + "today_runtime": 913 + }, + "get_max_power": { + "max_power": 1835 + }, + "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 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_2": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240703 Rel.114246", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "DC6279000000", + "model": "P210M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 170204, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 2, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "America/Los_Angeles", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 3564, + "past7": 3564, + "today": 913 + }, + "time_usage": { + "past30": 3564, + "past7": 3564, + "today": 913 + } + }, + "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_emeter_data": { + "current_ma": 0, + "energy_wh": 0, + "power_mw": 0, + "voltage_mv": 119720 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3788, + "slot_id": 0, + "vgain": 30382 + }, + { + "igain": 3833, + "slot_id": 1, + "vgain": 30253 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-12-20 15:13:58", + "month_energy": 0, + "month_runtime": 3564, + "today_energy": 0, + "today_runtime": 913 + }, + "get_max_power": { + "max_power": 1827 + }, + "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 + } + } + }, + "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": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P210M(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "DC-62-79-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240703 Rel.114246", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "DC6279000000", + "model": "P210M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 170202, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 1, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "America/Los_Angeles", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240703 Rel.114246", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "DC6279000000", + "model": "P210M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 170202, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 2, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "America/Los_Angeles", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240703 Rel.114246", + "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": "DC-62-79-00-00-00", + "model": "P210M", + "nickname": "", + "oem_id": "00000000000000000000000000000000", + "region": "America/Los_Angeles", + "rssi": -53, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -480, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/Los_Angeles", + "time_diff": -480, + "timestamp": 1734736436 + }, + "get_device_usage": { + "power_usage": { + "past30": 275, + "past7": 275, + "today": 168 + }, + "saved_power": { + "past30": 3289, + "past7": 3289, + "today": 745 + }, + "time_usage": { + "past30": 3564, + "past7": 3564, + "today": 913 + } + }, + "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_emeter_vgain_igain": { + "data": [ + { + "igain": 3788, + "slot_id": 0, + "vgain": 30382 + }, + { + "igain": 3833, + "slot_id": 1, + "vgain": 30253 + } + ] + }, + "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.3 Build 240703 Rel.114246", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:000000000000-000000" + }, + "get_protection_power": { + "enabled": false, + "protection_power": 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": 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": 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": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 29, + "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 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P210M", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} diff --git a/tests/test_cli.py b/tests/test_cli.py index 3621ef203..1b589f5c8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -268,7 +268,8 @@ async def test_alias(dev, runner): res = await runner.invoke(alias, obj=dev) assert f"Alias: {new_alias}" in res.output - await dev.set_alias(old_alias) + # If alias is None set it back to empty string + await dev.set_alias(old_alias or "") async def test_raw_command(dev, mocker, runner): diff --git a/tests/test_device.py b/tests/test_device.py index 7547182bd..20e5bef89 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -65,12 +65,13 @@ async def test_alias(dev): test_alias = "TEST1234" original = dev.alias - assert isinstance(original, str) + assert isinstance(original, str | None) await dev.set_alias(test_alias) await dev.update() assert dev.alias == test_alias - await dev.set_alias(original) + # If alias is None set it back to empty string + await dev.set_alias(original or "") await dev.update() assert dev.alias == original From 9b1be1c0b228a445bc1a879fb0ba4944955f9a78 Mon Sep 17 00:00:00 2001 From: Bipolar Chemist <45445972+nakanaela@users.noreply.github.com> Date: Sat, 21 Dec 2024 01:36:57 -0800 Subject: [PATCH 777/892] Add P306(US) 1.0 1.1.2 fixture (#1396) --- README.md | 2 +- SUPPORTED.md | 2 + tests/device_fixtures.py | 6 +- tests/fixtures/smart/P306(US)_1.0_1.1.2.json | 1708 ++++++++++++++++++ 4 files changed, 1713 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/smart/P306(US)_1.0_1.1.2.json diff --git a/README.md b/README.md index 29a7684b1..cac047963 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ The following devices have been tested and confirmed as working. If your device ### Supported Tapo[^1] devices - **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15 -- **Power Strips**: P210M, P300, P304M, TP25 +- **Power Strips**: P210M, P300, P304M, P306, TP25 - **Wall Switches**: S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 diff --git a/SUPPORTED.md b/SUPPORTED.md index c0d5c1cb3..795d13464 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -216,6 +216,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.0.7 - **P304M** - Hardware: 1.0 (UK) / Firmware: 1.0.3 +- **P306** + - Hardware: 1.0 (US) / Firmware: 1.1.2 - **TP25** - Hardware: 1.0 (US) / Firmware: 1.0.2 diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 58f0e9b35..e2041ca90 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -79,8 +79,6 @@ "KP125", "KP401", } -# P135 supports dimming, but its not currently support -# by the library PLUGS_SMART = { "P100", "P110", @@ -111,8 +109,8 @@ "S505D", } SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} -STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40", "P210M"} -STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M"} +STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} +STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M", "P210M", "P306"} STRIPS = {*STRIPS_IOT, *STRIPS_SMART} DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"} diff --git a/tests/fixtures/smart/P306(US)_1.0_1.1.2.json b/tests/fixtures/smart/P306(US)_1.0_1.1.2.json new file mode 100644 index 000000000..a5fcb1e8f --- /dev/null +++ b/tests/fixtures/smart/P306(US)_1.0_1.1.2.json @@ -0,0 +1,1708 @@ +{ + "child_devices": { + "SCRUBBED_CHILD_DEVICE_ID_1": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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": "usb", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": true, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169807, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 4, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 3561, + "past7": 3561, + "today": 907 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_2": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169808, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 3, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 3561, + "past7": 3561, + "today": 907 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_3": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169808, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 3561, + "past7": 3561, + "today": 907 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_4": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169808, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 3561, + "past7": 3561, + "today": 907 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_5": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + } + ] + }, + "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": "bedside_lamp_1", + "bind_count": 1, + "brightness": 100, + "category": "plug.powerstrip.sub-bulb", + "default_states": { + "brightness": { + "type": "last_states", + "value": 100 + }, + "re_power_type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 7169, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 5, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOBULB" + }, + "get_device_usage": { + "time_usage": { + "past30": 2425, + "past7": 2425, + "today": 758 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + } + }, + "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": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P306(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-6E-84-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5" + } + ], + "start_index": 0, + "sum": 5 + }, + "get_child_device_list": { + "child_device_list": [ + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "usb", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": true, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169805, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 4, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169805, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 3, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169805, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169805, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "avatar": "bedside_lamp_1", + "bind_count": 1, + "brightness": 100, + "category": "plug.powerstrip.sub-bulb", + "default_states": { + "brightness": { + "type": "last_states", + "value": 100 + }, + "re_power_type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 7166, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 5, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOBULB" + } + ], + "start_index": 0, + "sum": 5 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "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": "A8-6E-84-00-00-00", + "model": "P306", + "nickname": "", + "oem_id": "00000000000000000000000000000000", + "region": "America/Los_Angeles", + "rssi": -46, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -480, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/Los_Angeles", + "time_diff": -480, + "timestamp": 1734736024 + }, + "get_device_usage": { + "time_usage": { + "past30": 3561, + "past7": 3561, + "today": 907 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_homekit_info": { + "mfi_setup_code": "000-00-000", + "mfi_setup_id": "0000", + "mfi_token_token": "000000000000000000000000000000000000000000000000/00000000000000000000000000000000000000000000000000000000/0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000/0000000000000000000", + "mfi_token_uuid": "00000000-0000-0000-0000-000000000000" + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "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": 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": 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==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 24, + "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 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P306", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} From 63f4f8279183e38f8d6dcd016c3d0dc99c67a2c4 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sat, 21 Dec 2024 16:47:46 +0000 Subject: [PATCH 778/892] Prepare 0.9.0 (#1401) ## [0.9.0](https://github.com/python-kasa/python-kasa/tree/0.9.0) (2024-12-21) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.8.1...0.9.0) **Release highlights:** - Improvements to Tapo camera support: - C100, C225, C325WB, C520WS and TC70 now supported. - Support for motion, person, tamper, and baby cry detection. - Initial support for Tapo robovacs. - API extended with `FeatureAttributes` for consumers to test for [supported features](https://python-kasa.readthedocs.io/en/stable/topics.html#modules-and-features). - Experimental support for Kasa cameras[^1] [^1]: Currently limited to devices not yet provisioned via the Tapo app - Many thanks to @puxtril! **Breaking changes:** - Use DeviceInfo consistently across devices [\#1338](https://github.com/python-kasa/python-kasa/pull/1338) (@sdb9696) **Implemented enhancements:** - Add rssi and signal\_level to smartcam [\#1392](https://github.com/python-kasa/python-kasa/pull/1392) (@sdb9696) - Add smartcam detection modules [\#1389](https://github.com/python-kasa/python-kasa/pull/1389) (@sdb9696) - Add bare-bones matter modules to smart and smartcam devices [\#1371](https://github.com/python-kasa/python-kasa/pull/1371) (@sdb9696) - Add bare bones homekit modules smart and smartcam devices [\#1370](https://github.com/python-kasa/python-kasa/pull/1370) (@sdb9696) - Return raw discovery result in cli discover raw [\#1342](https://github.com/python-kasa/python-kasa/pull/1342) (@sdb9696) - cli: print model, https, and lv for discover list [\#1339](https://github.com/python-kasa/python-kasa/pull/1339) (@rytilahti) - Improve overheat reporting [\#1335](https://github.com/python-kasa/python-kasa/pull/1335) (@rytilahti) - Provide alternative camera urls [\#1316](https://github.com/python-kasa/python-kasa/pull/1316) (@sdb9696) - Add LinkieTransportV2 and basic IOT.IPCAMERA support [\#1270](https://github.com/python-kasa/python-kasa/pull/1270) (@Puxtril) - Add ssltransport for robovacs [\#943](https://github.com/python-kasa/python-kasa/pull/943) (@rytilahti) **Fixed bugs:** - Tapo H200 Hub does not work with python-kasa [\#1149](https://github.com/python-kasa/python-kasa/issues/1149) - Treat smartcam 500 errors after handshake as retryable [\#1395](https://github.com/python-kasa/python-kasa/pull/1395) (@sdb9696) - Fix lens mask required component and state [\#1386](https://github.com/python-kasa/python-kasa/pull/1386) (@sdb9696) - Add LensMask module to smartcam [\#1385](https://github.com/python-kasa/python-kasa/pull/1385) (@sdb9696) - Do not error when accessing smart device\_type before update [\#1319](https://github.com/python-kasa/python-kasa/pull/1319) (@sdb9696) - Fallback to other module data on get\_energy\_usage errors [\#1245](https://github.com/python-kasa/python-kasa/pull/1245) (@rytilahti) **Added support for devices:** - Add P210M\(US\) 1.0 1.0.3 fixture [\#1399](https://github.com/python-kasa/python-kasa/pull/1399) (@sdb9696) - Add C225\(US\) 2.0 1.0.11 fixture [\#1398](https://github.com/python-kasa/python-kasa/pull/1398) (@sdb9696) - Add P306\(US\) 1.0 1.1.2 fixture [\#1396](https://github.com/python-kasa/python-kasa/pull/1396) (@nakanaela) - Add TC70 3.0 1.3.11 fixture [\#1390](https://github.com/python-kasa/python-kasa/pull/1390) (@sdb9696) - Add C325WB\(EU\) 1.0 1.1.17 Fixture [\#1379](https://github.com/python-kasa/python-kasa/pull/1379) (@sdb9696) - Add C100 4.0 1.3.14 Fixture [\#1378](https://github.com/python-kasa/python-kasa/pull/1378) (@sdb9696) - Add KS200 \(US\) IOT Fixture and P115 \(US\) Smart Fixture [\#1355](https://github.com/python-kasa/python-kasa/pull/1355) (@ZeliardM) - Add C520WS camera fixture [\#1352](https://github.com/python-kasa/python-kasa/pull/1352) (@Happy-Cadaver) **Documentation updates:** - Update docs for Tapo Lab Third-Party compatibility [\#1380](https://github.com/python-kasa/python-kasa/pull/1380) (@sdb9696) - Add homebridge-kasa-python link to README [\#1367](https://github.com/python-kasa/python-kasa/pull/1367) (@rytilahti) - Update docs for new FeatureAttribute behaviour [\#1365](https://github.com/python-kasa/python-kasa/pull/1365) (@sdb9696) - Add link to related homeassistant-tapo-control [\#1333](https://github.com/python-kasa/python-kasa/pull/1333) (@rytilahti) **Project maintenance:** - Add P135 1.0 1.2.0 fixture [\#1397](https://github.com/python-kasa/python-kasa/pull/1397) (@sdb9696) - Handle smartcam device blocked response [\#1393](https://github.com/python-kasa/python-kasa/pull/1393) (@sdb9696) - Handle KeyboardInterrupts in the cli better [\#1391](https://github.com/python-kasa/python-kasa/pull/1391) (@sdb9696) - Update C520WS fixture with new methods [\#1384](https://github.com/python-kasa/python-kasa/pull/1384) (@sdb9696) - Miscellaneous minor fixes to dump\_devinfo [\#1382](https://github.com/python-kasa/python-kasa/pull/1382) (@sdb9696) - Add timeout parameter to dump\_devinfo [\#1381](https://github.com/python-kasa/python-kasa/pull/1381) (@sdb9696) - Simplify get\_protocol to prevent clashes with smartcam and robovac [\#1377](https://github.com/python-kasa/python-kasa/pull/1377) (@sdb9696) - Add smartcam modules to package inits [\#1376](https://github.com/python-kasa/python-kasa/pull/1376) (@sdb9696) - Enable saving of fixture files without git clone [\#1375](https://github.com/python-kasa/python-kasa/pull/1375) (@sdb9696) - Force single for some smartcam requests [\#1374](https://github.com/python-kasa/python-kasa/pull/1374) (@sdb9696) - Add new methods to dump\_devinfo [\#1373](https://github.com/python-kasa/python-kasa/pull/1373) (@sdb9696) - Update cli, light modules, and docs to use FeatureAttributes [\#1364](https://github.com/python-kasa/python-kasa/pull/1364) (@sdb9696) - Pass raw components to SmartChildDevice init [\#1363](https://github.com/python-kasa/python-kasa/pull/1363) (@sdb9696) - Fix line endings in device\_fixtures.py [\#1361](https://github.com/python-kasa/python-kasa/pull/1361) (@sdb9696) - Update dump\_devinfo for raw discovery json and common redactors [\#1358](https://github.com/python-kasa/python-kasa/pull/1358) (@sdb9696) - Tweak RELEASING.md instructions for patch releases [\#1347](https://github.com/python-kasa/python-kasa/pull/1347) (@sdb9696) - Scrub more vacuum keys [\#1328](https://github.com/python-kasa/python-kasa/pull/1328) (@rytilahti) - Remove unnecessary check for python \<3.10 [\#1326](https://github.com/python-kasa/python-kasa/pull/1326) (@rytilahti) - Add vacuum component queries to dump\_devinfo [\#1320](https://github.com/python-kasa/python-kasa/pull/1320) (@rytilahti) - Handle missing mgt\_encryption\_schm in discovery [\#1318](https://github.com/python-kasa/python-kasa/pull/1318) (@sdb9696) - Follow main package structure for tests [\#1317](https://github.com/python-kasa/python-kasa/pull/1317) (@rytilahti) --- CHANGELOG.md | 109 ++++++++-- pyproject.toml | 2 +- uv.lock | 577 ++++++++++++++++++++++++++----------------------- 3 files changed, 401 insertions(+), 287 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ef0873f0..6b002704e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,89 @@ # Changelog -## [0.8.1](https://github.com/python-kasa/python-kasa/tree/0.8.1) (2024-12-06) +## [0.9.0](https://github.com/python-kasa/python-kasa/tree/0.9.0) (2024-12-21) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.8.1...0.9.0) + +**Release highlights:** -This patch release fixes some issues with newly supported smartcam devices. +- Improvements to Tapo camera support: + - C100, C225, C325WB, C520WS and TC70 now supported. + - Support for motion, person, tamper, and baby cry detection. +- Initial support for Tapo robovacs. +- API extended with `FeatureAttributes` for consumers to test for [supported features](https://python-kasa.readthedocs.io/en/stable/topics.html#modules-and-features). +- Experimental support for Kasa cameras[^1] + +[^1]: Currently limited to devices not yet provisioned via the Tapo app - Many thanks to @puxtril! + +**Breaking changes:** + +- Use DeviceInfo consistently across devices [\#1338](https://github.com/python-kasa/python-kasa/pull/1338) (@sdb9696) + +**Implemented enhancements:** + +- Add rssi and signal\_level to smartcam [\#1392](https://github.com/python-kasa/python-kasa/pull/1392) (@sdb9696) +- Add smartcam detection modules [\#1389](https://github.com/python-kasa/python-kasa/pull/1389) (@sdb9696) +- Add bare-bones matter modules to smart and smartcam devices [\#1371](https://github.com/python-kasa/python-kasa/pull/1371) (@sdb9696) +- Add bare bones homekit modules smart and smartcam devices [\#1370](https://github.com/python-kasa/python-kasa/pull/1370) (@sdb9696) +- Return raw discovery result in cli discover raw [\#1342](https://github.com/python-kasa/python-kasa/pull/1342) (@sdb9696) +- cli: print model, https, and lv for discover list [\#1339](https://github.com/python-kasa/python-kasa/pull/1339) (@rytilahti) +- Improve overheat reporting [\#1335](https://github.com/python-kasa/python-kasa/pull/1335) (@rytilahti) +- Provide alternative camera urls [\#1316](https://github.com/python-kasa/python-kasa/pull/1316) (@sdb9696) +- Add LinkieTransportV2 and basic IOT.IPCAMERA support [\#1270](https://github.com/python-kasa/python-kasa/pull/1270) (@Puxtril) +- Add ssltransport for robovacs [\#943](https://github.com/python-kasa/python-kasa/pull/943) (@rytilahti) + +**Fixed bugs:** + +- Tapo H200 Hub does not work with python-kasa [\#1149](https://github.com/python-kasa/python-kasa/issues/1149) +- Treat smartcam 500 errors after handshake as retryable [\#1395](https://github.com/python-kasa/python-kasa/pull/1395) (@sdb9696) +- Fix lens mask required component and state [\#1386](https://github.com/python-kasa/python-kasa/pull/1386) (@sdb9696) +- Add LensMask module to smartcam [\#1385](https://github.com/python-kasa/python-kasa/pull/1385) (@sdb9696) +- Do not error when accessing smart device\_type before update [\#1319](https://github.com/python-kasa/python-kasa/pull/1319) (@sdb9696) +- Fallback to other module data on get\_energy\_usage errors [\#1245](https://github.com/python-kasa/python-kasa/pull/1245) (@rytilahti) + +**Added support for devices:** + +- Add P210M\(US\) 1.0 1.0.3 fixture [\#1399](https://github.com/python-kasa/python-kasa/pull/1399) (@sdb9696) +- Add C225\(US\) 2.0 1.0.11 fixture [\#1398](https://github.com/python-kasa/python-kasa/pull/1398) (@sdb9696) +- Add P306\(US\) 1.0 1.1.2 fixture [\#1396](https://github.com/python-kasa/python-kasa/pull/1396) (@nakanaela) +- Add TC70 3.0 1.3.11 fixture [\#1390](https://github.com/python-kasa/python-kasa/pull/1390) (@sdb9696) +- Add C325WB\(EU\) 1.0 1.1.17 Fixture [\#1379](https://github.com/python-kasa/python-kasa/pull/1379) (@sdb9696) +- Add C100 4.0 1.3.14 Fixture [\#1378](https://github.com/python-kasa/python-kasa/pull/1378) (@sdb9696) +- Add KS200 \(US\) IOT Fixture and P115 \(US\) Smart Fixture [\#1355](https://github.com/python-kasa/python-kasa/pull/1355) (@ZeliardM) +- Add C520WS camera fixture [\#1352](https://github.com/python-kasa/python-kasa/pull/1352) (@Happy-Cadaver) + +**Documentation updates:** + +- Update docs for Tapo Lab Third-Party compatibility [\#1380](https://github.com/python-kasa/python-kasa/pull/1380) (@sdb9696) +- Add homebridge-kasa-python link to README [\#1367](https://github.com/python-kasa/python-kasa/pull/1367) (@rytilahti) +- Update docs for new FeatureAttribute behaviour [\#1365](https://github.com/python-kasa/python-kasa/pull/1365) (@sdb9696) +- Add link to related homeassistant-tapo-control [\#1333](https://github.com/python-kasa/python-kasa/pull/1333) (@rytilahti) + +**Project maintenance:** + +- Add P135 1.0 1.2.0 fixture [\#1397](https://github.com/python-kasa/python-kasa/pull/1397) (@sdb9696) +- Handle smartcam device blocked response [\#1393](https://github.com/python-kasa/python-kasa/pull/1393) (@sdb9696) +- Handle KeyboardInterrupts in the cli better [\#1391](https://github.com/python-kasa/python-kasa/pull/1391) (@sdb9696) +- Update C520WS fixture with new methods [\#1384](https://github.com/python-kasa/python-kasa/pull/1384) (@sdb9696) +- Miscellaneous minor fixes to dump\_devinfo [\#1382](https://github.com/python-kasa/python-kasa/pull/1382) (@sdb9696) +- Add timeout parameter to dump\_devinfo [\#1381](https://github.com/python-kasa/python-kasa/pull/1381) (@sdb9696) +- Simplify get\_protocol to prevent clashes with smartcam and robovac [\#1377](https://github.com/python-kasa/python-kasa/pull/1377) (@sdb9696) +- Add smartcam modules to package inits [\#1376](https://github.com/python-kasa/python-kasa/pull/1376) (@sdb9696) +- Enable saving of fixture files without git clone [\#1375](https://github.com/python-kasa/python-kasa/pull/1375) (@sdb9696) +- Force single for some smartcam requests [\#1374](https://github.com/python-kasa/python-kasa/pull/1374) (@sdb9696) +- Add new methods to dump\_devinfo [\#1373](https://github.com/python-kasa/python-kasa/pull/1373) (@sdb9696) +- Update cli, light modules, and docs to use FeatureAttributes [\#1364](https://github.com/python-kasa/python-kasa/pull/1364) (@sdb9696) +- Pass raw components to SmartChildDevice init [\#1363](https://github.com/python-kasa/python-kasa/pull/1363) (@sdb9696) +- Fix line endings in device\_fixtures.py [\#1361](https://github.com/python-kasa/python-kasa/pull/1361) (@sdb9696) +- Update dump\_devinfo for raw discovery json and common redactors [\#1358](https://github.com/python-kasa/python-kasa/pull/1358) (@sdb9696) +- Tweak RELEASING.md instructions for patch releases [\#1347](https://github.com/python-kasa/python-kasa/pull/1347) (@sdb9696) +- Scrub more vacuum keys [\#1328](https://github.com/python-kasa/python-kasa/pull/1328) (@rytilahti) +- Remove unnecessary check for python \<3.10 [\#1326](https://github.com/python-kasa/python-kasa/pull/1326) (@rytilahti) +- Add vacuum component queries to dump\_devinfo [\#1320](https://github.com/python-kasa/python-kasa/pull/1320) (@rytilahti) +- Handle missing mgt\_encryption\_schm in discovery [\#1318](https://github.com/python-kasa/python-kasa/pull/1318) (@sdb9696) +- Follow main package structure for tests [\#1317](https://github.com/python-kasa/python-kasa/pull/1317) (@rytilahti) + +## [0.8.1](https://github.com/python-kasa/python-kasa/tree/0.8.1) (2024-12-06) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.8.0...0.8.1) @@ -46,28 +127,28 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im - Update cli feature command for actions not to require a value [\#1264](https://github.com/python-kasa/python-kasa/pull/1264) (@sdb9696) - Add pan tilt camera module [\#1261](https://github.com/python-kasa/python-kasa/pull/1261) (@sdb9696) - Add alarm module for smartcamera hubs [\#1258](https://github.com/python-kasa/python-kasa/pull/1258) (@sdb9696) -- Move TAPO smartcamera out of experimental package [\#1255](https://github.com/python-kasa/python-kasa/pull/1255) (@sdb9696) - Add SmartCamera Led Module [\#1249](https://github.com/python-kasa/python-kasa/pull/1249) (@sdb9696) -- Use component queries to select smartcamera modules [\#1248](https://github.com/python-kasa/python-kasa/pull/1248) (@sdb9696) - Print formatting for IotLightPreset [\#1216](https://github.com/python-kasa/python-kasa/pull/1216) (@Puxtril) - Allow getting Annotated features from modules [\#1018](https://github.com/python-kasa/python-kasa/pull/1018) (@sdb9696) +- Move TAPO smartcamera out of experimental package [\#1255](https://github.com/python-kasa/python-kasa/pull/1255) (@sdb9696) +- Use component queries to select smartcamera modules [\#1248](https://github.com/python-kasa/python-kasa/pull/1248) (@sdb9696) - Add common Thermostat module [\#977](https://github.com/python-kasa/python-kasa/pull/977) (@sdb9696) **Fixed bugs:** - TP-Link Tapo S505D cannot disable gradual on/off [\#1309](https://github.com/python-kasa/python-kasa/issues/1309) -- Inconsistent emeter information between features and emeter cli [\#1308](https://github.com/python-kasa/python-kasa/issues/1308) - How to dump power usage after latest updates? [\#1306](https://github.com/python-kasa/python-kasa/issues/1306) - kasa.discover: Got unsupported connection type: 'device\_family': 'SMART.IPCAMERA' [\#1267](https://github.com/python-kasa/python-kasa/issues/1267) - device \_\_repr\_\_ fails if no sys\_info [\#1262](https://github.com/python-kasa/python-kasa/issues/1262) - Tapo P110M: Error processing Energy for device, module will be unavailable: get\_energy\_usage for Energy [\#1243](https://github.com/python-kasa/python-kasa/issues/1243) - Listing light presets throws error [\#1201](https://github.com/python-kasa/python-kasa/issues/1201) -- Include duration when disabling smooth transition on/off [\#1313](https://github.com/python-kasa/python-kasa/pull/1313) (@rytilahti) -- Expose energy command to cli [\#1307](https://github.com/python-kasa/python-kasa/pull/1307) (@rytilahti) +- Inconsistent emeter information between features and emeter cli [\#1308](https://github.com/python-kasa/python-kasa/issues/1308) - Make discovery on unsupported devices less noisy [\#1291](https://github.com/python-kasa/python-kasa/pull/1291) (@rytilahti) - Fix repr for device created with no sysinfo or discovery info" [\#1266](https://github.com/python-kasa/python-kasa/pull/1266) (@sdb9696) - Fix discovery by alias for smart devices [\#1260](https://github.com/python-kasa/python-kasa/pull/1260) (@sdb9696) - Make \_\_repr\_\_ work on discovery info [\#1233](https://github.com/python-kasa/python-kasa/pull/1233) (@rytilahti) +- Include duration when disabling smooth transition on/off [\#1313](https://github.com/python-kasa/python-kasa/pull/1313) (@rytilahti) +- Expose energy command to cli [\#1307](https://github.com/python-kasa/python-kasa/pull/1307) (@rytilahti) **Added support for devices:** @@ -81,13 +162,11 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im **Documentation updates:** - Use markdown footnotes in supported.md [\#1310](https://github.com/python-kasa/python-kasa/pull/1310) (@sdb9696) -- Update docs for the new module attributes has/get feature [\#1301](https://github.com/python-kasa/python-kasa/pull/1301) (@sdb9696) - Fixup contributing.md for running test against a real device [\#1236](https://github.com/python-kasa/python-kasa/pull/1236) (@sdb9696) +- Update docs for the new module attributes has/get feature [\#1301](https://github.com/python-kasa/python-kasa/pull/1301) (@sdb9696) **Project maintenance:** -- Rename tests/smartcamera to tests/smartcam [\#1315](https://github.com/python-kasa/python-kasa/pull/1315) (@sdb9696) -- Do not error on smartcam hub attached smartcam child devices [\#1314](https://github.com/python-kasa/python-kasa/pull/1314) (@sdb9696) - Add P110M\(EU\) fixture [\#1305](https://github.com/python-kasa/python-kasa/pull/1305) (@sdb9696) - Run tests with caplog in a single worker [\#1304](https://github.com/python-kasa/python-kasa/pull/1304) (@sdb9696) - Rename smartcamera to smartcam [\#1300](https://github.com/python-kasa/python-kasa/pull/1300) (@sdb9696) @@ -117,15 +196,17 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im - Add linkcheck to readthedocs CI [\#1253](https://github.com/python-kasa/python-kasa/pull/1253) (@rytilahti) - Update cli energy command to use energy module [\#1252](https://github.com/python-kasa/python-kasa/pull/1252) (@sdb9696) - Consolidate warnings for fixtures missing child devices [\#1251](https://github.com/python-kasa/python-kasa/pull/1251) (@sdb9696) -- Update smartcamera fixtures with components [\#1250](https://github.com/python-kasa/python-kasa/pull/1250) (@sdb9696) - Move transports into their own package [\#1247](https://github.com/python-kasa/python-kasa/pull/1247) (@rytilahti) -- Fix warnings in our test suite [\#1246](https://github.com/python-kasa/python-kasa/pull/1246) (@rytilahti) - Move tests folder to top level of project [\#1242](https://github.com/python-kasa/python-kasa/pull/1242) (@sdb9696) -- Fix test framework running against real devices [\#1235](https://github.com/python-kasa/python-kasa/pull/1235) (@sdb9696) - Add Additional Firmware Test Fixures [\#1234](https://github.com/python-kasa/python-kasa/pull/1234) (@ryenitcher) -- Update DiscoveryResult to use Mashumaro instead of pydantic [\#1231](https://github.com/python-kasa/python-kasa/pull/1231) (@sdb9696) - Update fixture for ES20M 1.0.11 [\#1215](https://github.com/python-kasa/python-kasa/pull/1215) (@rytilahti) - Enable ruff check for ANN [\#1139](https://github.com/python-kasa/python-kasa/pull/1139) (@rytilahti) +- Rename tests/smartcamera to tests/smartcam [\#1315](https://github.com/python-kasa/python-kasa/pull/1315) (@sdb9696) +- Do not error on smartcam hub attached smartcam child devices [\#1314](https://github.com/python-kasa/python-kasa/pull/1314) (@sdb9696) +- Update smartcamera fixtures with components [\#1250](https://github.com/python-kasa/python-kasa/pull/1250) (@sdb9696) +- Fix warnings in our test suite [\#1246](https://github.com/python-kasa/python-kasa/pull/1246) (@rytilahti) +- Fix test framework running against real devices [\#1235](https://github.com/python-kasa/python-kasa/pull/1235) (@sdb9696) +- Update DiscoveryResult to use Mashumaro instead of pydantic [\#1231](https://github.com/python-kasa/python-kasa/pull/1231) (@sdb9696) **Closed issues:** diff --git a/pyproject.toml b/pyproject.toml index 9dc265c8b..2ad192e4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-kasa" -version = "0.8.1" +version = "0.9.0" description = "Python API for TP-Link Kasa and Tapo devices" license = {text = "GPL-3.0-or-later"} authors = [ { name = "python-kasa developers" }] diff --git a/uv.lock b/uv.lock index c68023301..e8ca1c4b7 100644 --- a/uv.lock +++ b/uv.lock @@ -3,16 +3,16 @@ requires-python = ">=3.11, <4.0" [[package]] name = "aiohappyeyeballs" -version = "2.4.3" +version = "2.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/69/2f6d5a019bd02e920a3417689a89887b39ad1e350b562f9955693d900c40/aiohappyeyeballs-2.4.3.tar.gz", hash = "sha256:75cf88a15106a5002a8eb1dab212525c00d1f4c0fa96e551c9fbe6f09a621586", size = 21809 } +sdist = { url = "https://files.pythonhosted.org/packages/7f/55/e4373e888fdacb15563ef6fa9fa8c8252476ea071e96fb46defac9f18bf2/aiohappyeyeballs-2.4.4.tar.gz", hash = "sha256:5fdd7d87889c63183afc18ce9271f9b0a7d32c2303e394468dd45d514a757745", size = 21977 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/d8/120cd0fe3e8530df0539e71ba9683eade12cae103dd7543e50d15f737917/aiohappyeyeballs-2.4.3-py3-none-any.whl", hash = "sha256:8a7a83727b2756f394ab2895ea0765a0a8c475e3c71e98d43d76f22b4b435572", size = 14742 }, + { url = "https://files.pythonhosted.org/packages/b9/74/fbb6559de3607b3300b9be3cc64e97548d55678e44623db17820dbd20002/aiohappyeyeballs-2.4.4-py3-none-any.whl", hash = "sha256:a980909d50efcd44795c4afeca523296716d50cd756ddca6af8c65b996e27de8", size = 14756 }, ] [[package]] name = "aiohttp" -version = "3.11.7" +version = "3.11.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -23,65 +23,65 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/cb/f9bb10e0cf6f01730b27d370b10cc15822bea4395acd687abc8cc5fed3ed/aiohttp-3.11.7.tar.gz", hash = "sha256:01a8aca4af3da85cea5c90141d23f4b0eee3cbecfd33b029a45a80f28c66c668", size = 7666482 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/7f/272fa1adf68fe2fbebfe686a67b50cfb40d86dfe47d0441aff6f0b7c4c0e/aiohttp-3.11.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cea52d11e02123f125f9055dfe0ccf1c3857225fb879e4a944fae12989e2aef2", size = 706820 }, - { url = "https://files.pythonhosted.org/packages/79/3c/6d612ef77cdba75364393f04c5c577481e3b5123a774eea447ada1ddd14f/aiohttp-3.11.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3ce18f703b7298e7f7633efd6a90138d99a3f9a656cb52c1201e76cb5d79cf08", size = 466654 }, - { url = "https://files.pythonhosted.org/packages/4f/b8/1052667d4800cd49bb4f869f1ed42f5e9d5acd4676275e64ccc244c9c040/aiohttp-3.11.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:670847ee6aeb3a569cd7cdfbe0c3bec1d44828bbfbe78c5d305f7f804870ef9e", size = 454041 }, - { url = "https://files.pythonhosted.org/packages/9f/07/80fa7302314a6ee1c9278550e9d95b77a4c895999bfbc5364ed0ee28dc7c/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dda726f89bfa5c465ba45b76515135a3ece0088dfa2da49b8bb278f3bdeea12", size = 1684778 }, - { url = "https://files.pythonhosted.org/packages/2e/30/a71eb45197ad6bb6af87dfb39be8b56417d24d916047d35ef3f164af87f4/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c25b74a811dba37c7ea6a14d99eb9402d89c8d739d50748a75f3cf994cf19c43", size = 1740992 }, - { url = "https://files.pythonhosted.org/packages/22/74/0f9394429f3c4197129333a150a85cb2a642df30097a39dd41257f0b3bdc/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5522ee72f95661e79db691310290c4618b86dff2d9b90baedf343fd7a08bf79", size = 1781816 }, - { url = "https://files.pythonhosted.org/packages/7f/1a/1e256b39179c98d16d53ac62f64bfcfe7c5b2c1e68b83cddd4165854524f/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fbf41a6bbc319a7816ae0f0177c265b62f2a59ad301a0e49b395746eb2a9884", size = 1676692 }, - { url = "https://files.pythonhosted.org/packages/9b/37/f19d2e00efcabb9183b16bd91244de1d9c4ff7bf0fb5b8302e29a78f3286/aiohttp-3.11.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59ee1925b5a5efdf6c4e7be51deee93984d0ac14a6897bd521b498b9916f1544", size = 1619523 }, - { url = "https://files.pythonhosted.org/packages/ae/3c/af50cf5e06b98783fd776f17077f7b7e755d461114af5d6744dc037fc3b0/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:24054fce8c6d6f33a3e35d1c603ef1b91bbcba73e3f04a22b4f2f27dac59b347", size = 1644084 }, - { url = "https://files.pythonhosted.org/packages/c0/a6/4e0233b085cbf2b6de573515c1eddde82f1c1f17e69347e32a5a5f2617ff/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:351849aca2c6f814575c1a485c01c17a4240413f960df1bf9f5deb0003c61a53", size = 1648332 }, - { url = "https://files.pythonhosted.org/packages/06/20/7062e76e7817318c421c0f9d7b650fb81aaecf6d2f3a9833805b45ec2ea8/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:12724f3a211fa243570e601f65a8831372caf1a149d2f1859f68479f07efec3d", size = 1730912 }, - { url = "https://files.pythonhosted.org/packages/6c/1c/ff6ae4b1789894e6faf8a4e260cd3861cad618dc80ad15326789a7765750/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7ea4490360b605804bea8173d2d086b6c379d6bb22ac434de605a9cbce006e7d", size = 1752619 }, - { url = "https://files.pythonhosted.org/packages/33/58/ddd5cba5ca245c00b04e9d28a7988b0f0eda02de494f8e62ecd2780655c2/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e0bf378db07df0a713a1e32381a1b277e62ad106d0dbe17b5479e76ec706d720", size = 1692801 }, - { url = "https://files.pythonhosted.org/packages/b2/fc/32d5e2070b43d3722b7ea65ddc6b03ffa39bcc4b5ab6395a825cde0872ad/aiohttp-3.11.7-cp311-cp311-win32.whl", hash = "sha256:cd8d62cab363dfe713067027a5adb4907515861f1e4ce63e7be810b83668b847", size = 414899 }, - { url = "https://files.pythonhosted.org/packages/ec/7e/50324c6d3df4540f5963def810b9927f220c99864065849a1dfcae77a6ce/aiohttp-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:bf0e6cce113596377cadda4e3ac5fb89f095bd492226e46d91b4baef1dd16f60", size = 440938 }, - { url = "https://files.pythonhosted.org/packages/bf/1e/2e96b2526c590dcb99db0b94ac4f9b927ecc07f94735a8a941dee143d48b/aiohttp-3.11.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4bb7493c3e3a36d3012b8564bd0e2783259ddd7ef3a81a74f0dbfa000fce48b7", size = 702326 }, - { url = "https://files.pythonhosted.org/packages/b5/ce/b5d7f3e68849f1f5e0b85af4ac9080b9d3c0a600857140024603653c2209/aiohttp-3.11.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e143b0ef9cb1a2b4f74f56d4fbe50caa7c2bb93390aff52f9398d21d89bc73ea", size = 461944 }, - { url = "https://files.pythonhosted.org/packages/28/fa/f4d98db1b7f8f0c3f74bdbd6d0d98cfc89984205cd33f1b8ee3f588ee5ad/aiohttp-3.11.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7c58a240260822dc07f6ae32a0293dd5bccd618bb2d0f36d51c5dbd526f89c0", size = 454348 }, - { url = "https://files.pythonhosted.org/packages/04/f0/c238dda5dc9a3d12b76636e2cf0ea475890ac3a1c7e4ff0fd6c3cea2fc2d/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d20cfe63a1c135d26bde8c1d0ea46fd1200884afbc523466d2f1cf517d1fe33", size = 1678795 }, - { url = "https://files.pythonhosted.org/packages/79/ee/3a18f792247e6d95dba13aaedc9dc317c3c6e75f4b88c2dd4b960d20ad2f/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12e4d45847a174f77b2b9919719203769f220058f642b08504cf8b1cf185dacf", size = 1734411 }, - { url = "https://files.pythonhosted.org/packages/f5/79/3eb84243087a9a32cae821622c935107b4b55a5b21b76772e8e6c41092e9/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf4efa2d01f697a7dbd0509891a286a4af0d86902fc594e20e3b1712c28c0106", size = 1788959 }, - { url = "https://files.pythonhosted.org/packages/91/93/ad77782c5edfa17aafc070bef978fbfb8459b2f150595ffb01b559c136f9/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee6a4cdcbf54b8083dc9723cdf5f41f722c00db40ccf9ec2616e27869151129", size = 1687463 }, - { url = "https://files.pythonhosted.org/packages/ba/48/db35bd21b7877efa0be5f28385d8978c55323c5ce7685712e53f3f6c0bd9/aiohttp-3.11.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c6095aaf852c34f42e1bd0cf0dc32d1e4b48a90bfb5054abdbb9d64b36acadcb", size = 1618374 }, - { url = "https://files.pythonhosted.org/packages/ba/77/30f87db55c79fd145ed5fd15b92f2e820ce81065d41ae437797aaa550e3b/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1cf03d27885f8c5ebf3993a220cc84fc66375e1e6e812731f51aab2b2748f4a6", size = 1637021 }, - { url = "https://files.pythonhosted.org/packages/af/76/10b188b78ee18d0595af156d6a238bc60f9d8571f0f546027eb7eaf65b25/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1a17f6a230f81eb53282503823f59d61dff14fb2a93847bf0399dc8e87817307", size = 1650792 }, - { url = "https://files.pythonhosted.org/packages/fa/33/4411bbb8ad04c47d0f4c7bd53332aaf350e49469cf6b65b132d4becafe27/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:481f10a1a45c5f4c4a578bbd74cff22eb64460a6549819242a87a80788461fba", size = 1696248 }, - { url = "https://files.pythonhosted.org/packages/fe/2d/6135d0dc1851a33d3faa937b20fef81340bc95e8310536d4c7f1f8ecc026/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:db37248535d1ae40735d15bdf26ad43be19e3d93ab3f3dad8507eb0f85bb8124", size = 1729188 }, - { url = "https://files.pythonhosted.org/packages/f5/76/a57ceff577ae26fe9a6f31ac799bc638ecf26e4acdf04295290b9929b349/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d18a8b44ec8502a7fde91446cd9c9b95ce7c49f1eacc1fb2358b8907d4369fd", size = 1690038 }, - { url = "https://files.pythonhosted.org/packages/4b/81/b20e09003b6989a7f23a721692137a6143420a151063c750ab2a04878e3c/aiohttp-3.11.7-cp312-cp312-win32.whl", hash = "sha256:3d1c9c15d3999107cbb9b2d76ca6172e6710a12fda22434ee8bd3f432b7b17e8", size = 409887 }, - { url = "https://files.pythonhosted.org/packages/b7/0b/607c98bff1d07bb21e0c39e7711108ef9ff4f2a361a3ec1ce8dce93623a5/aiohttp-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:018f1b04883a12e77e7fc161934c0f298865d3a484aea536a6a2ca8d909f0ba0", size = 436462 }, - { url = "https://files.pythonhosted.org/packages/7a/53/8d77186c6a33bd087714df18274cdcf6e36fd69a9e841c85b7e81a20b18e/aiohttp-3.11.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:241a6ca732d2766836d62c58c49ca7a93d08251daef0c1e3c850df1d1ca0cbc4", size = 695811 }, - { url = "https://files.pythonhosted.org/packages/62/b6/4c3d107a5406aa6f99f618afea82783f54ce2d9644020f50b9c88f6e823d/aiohttp-3.11.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:aa3705a8d14de39898da0fbad920b2a37b7547c3afd2a18b9b81f0223b7d0f68", size = 458530 }, - { url = "https://files.pythonhosted.org/packages/d9/05/dbf0bd3966be8ebed3beb4007a2d1356d79af4fe7c93e54f984df6385193/aiohttp-3.11.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9acfc7f652b31853eed3b92095b0acf06fd5597eeea42e939bd23a17137679d5", size = 451371 }, - { url = "https://files.pythonhosted.org/packages/19/6a/2198580314617b6cf9c4b813b84df5832b5f8efedcb8a7e8b321a187233c/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcefcf2915a2dbdbce37e2fc1622129a1918abfe3d06721ce9f6cdac9b6d2eaa", size = 1662905 }, - { url = "https://files.pythonhosted.org/packages/2b/65/08696fd7503f6a6f9f782bd012bf47f36d4ed179a7d8c95dba4726d5cc67/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c1f6490dd1862af5aae6cfcf2a274bffa9a5b32a8f5acb519a7ecf5a99a88866", size = 1713794 }, - { url = "https://files.pythonhosted.org/packages/c8/a3/b9a72dce6f15e2efbc09fa67c1067c4f3a3bb05661c0ae7b40799cde02b7/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac5462582d6561c1c1708853a9faf612ff4e5ea5e679e99be36143d6eabd8e", size = 1770757 }, - { url = "https://files.pythonhosted.org/packages/78/7e/8fb371b5f8c4c1eaa0d0a50750c0dd68059f86794aeb36919644815486f5/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1a6309005acc4b2bcc577ba3b9169fea52638709ffacbd071f3503264620da", size = 1673136 }, - { url = "https://files.pythonhosted.org/packages/2f/0f/09685d13d2c7634cb808868ea29c170d4dcde4215a4a90fb86491cd3ae25/aiohttp-3.11.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b973cce96793725ef63eb449adfb74f99c043c718acb76e0d2a447ae369962", size = 1600370 }, - { url = "https://files.pythonhosted.org/packages/00/2e/18fd38b117f9b3a375166ccb70ed43cf7e3dfe2cc947139acc15feefc5a2/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ce91a24aac80de6be8512fb1c4838a9881aa713f44f4e91dd7bb3b34061b497d", size = 1613459 }, - { url = "https://files.pythonhosted.org/packages/2c/94/10a82abc680d753be33506be699aaa330152ecc4f316eaf081f996ee56c2/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:875f7100ce0e74af51d4139495eec4025affa1a605280f23990b6434b81df1bd", size = 1613924 }, - { url = "https://files.pythonhosted.org/packages/e9/58/897c0561f5c522dda6e173192f1e4f10144e1a7126096f17a3f12b7aa168/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c171fc35d3174bbf4787381716564042a4cbc008824d8195eede3d9b938e29a8", size = 1681164 }, - { url = "https://files.pythonhosted.org/packages/8b/8b/3a48b1cdafa612679d976274355f6a822de90b85d7dba55654ecfb01c979/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ee9afa1b0d2293c46954f47f33e150798ad68b78925e3710044e0d67a9487791", size = 1712139 }, - { url = "https://files.pythonhosted.org/packages/aa/9d/70ab5b4dd7900db04af72840e033aee06e472b1343e372ea256ed675511c/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8360c7cc620abb320e1b8d603c39095101391a82b1d0be05fb2225471c9c5c52", size = 1667446 }, - { url = "https://files.pythonhosted.org/packages/cb/98/b5fbcc8f6056f0c56001c75227e6b7ca9ee4f2e5572feca82ff3d65d485d/aiohttp-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7a9318da4b4ada9a67c1dd84d1c0834123081e746bee311a16bb449f363d965e", size = 408689 }, - { url = "https://files.pythonhosted.org/packages/ef/07/4d1504577fa6349dd2e3839e89fb56e5dee38d64efe3d4366e9fcfda0cdb/aiohttp-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:fc6da202068e0a268e298d7cd09b6e9f3997736cd9b060e2750963754552a0a9", size = 434809 }, +sdist = { url = "https://files.pythonhosted.org/packages/fe/ed/f26db39d29cd3cb2f5a3374304c713fe5ab5a0e4c8ee25a0c45cc6adf844/aiohttp-3.11.11.tar.gz", hash = "sha256:bb49c7f1e6ebf3821a42d81d494f538107610c3a705987f53068546b0e90303e", size = 7669618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/ae/e8806a9f054e15f1d18b04db75c23ec38ec954a10c0a68d3bd275d7e8be3/aiohttp-3.11.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ba74ec819177af1ef7f59063c6d35a214a8fde6f987f7661f4f0eecc468a8f76", size = 708624 }, + { url = "https://files.pythonhosted.org/packages/c7/e0/313ef1a333fb4d58d0c55a6acb3cd772f5d7756604b455181049e222c020/aiohttp-3.11.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4af57160800b7a815f3fe0eba9b46bf28aafc195555f1824555fa2cfab6c1538", size = 468507 }, + { url = "https://files.pythonhosted.org/packages/a9/60/03455476bf1f467e5b4a32a465c450548b2ce724eec39d69f737191f936a/aiohttp-3.11.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ffa336210cf9cd8ed117011085817d00abe4c08f99968deef0013ea283547204", size = 455571 }, + { url = "https://files.pythonhosted.org/packages/be/f9/469588603bd75bf02c8ffb8c8a0d4b217eed446b49d4a767684685aa33fd/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b8fe282183e4a3c7a1b72f5ade1094ed1c6345a8f153506d114af5bf8accd9", size = 1685694 }, + { url = "https://files.pythonhosted.org/packages/88/b9/1b7fa43faf6c8616fa94c568dc1309ffee2b6b68b04ac268e5d64b738688/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af41686ccec6a0f2bdc66686dc0f403c41ac2089f80e2214a0f82d001052c03", size = 1743660 }, + { url = "https://files.pythonhosted.org/packages/2a/8b/0248d19dbb16b67222e75f6aecedd014656225733157e5afaf6a6a07e2e8/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70d1f9dde0e5dd9e292a6d4d00058737052b01f3532f69c0c65818dac26dc287", size = 1785421 }, + { url = "https://files.pythonhosted.org/packages/c4/11/f478e071815a46ca0a5ae974651ff0c7a35898c55063305a896e58aa1247/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:249cc6912405917344192b9f9ea5cd5b139d49e0d2f5c7f70bdfaf6b4dbf3a2e", size = 1675145 }, + { url = "https://files.pythonhosted.org/packages/26/5d/284d182fecbb5075ae10153ff7374f57314c93a8681666600e3a9e09c505/aiohttp-3.11.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0eb98d90b6690827dcc84c246811feeb4e1eea683c0eac6caed7549be9c84665", size = 1619804 }, + { url = "https://files.pythonhosted.org/packages/1b/78/980064c2ad685c64ce0e8aeeb7ef1e53f43c5b005edcd7d32e60809c4992/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec82bf1fda6cecce7f7b915f9196601a1bd1a3079796b76d16ae4cce6d0ef89b", size = 1654007 }, + { url = "https://files.pythonhosted.org/packages/21/8d/9e658d63b1438ad42b96f94da227f2e2c1d5c6001c9e8ffcc0bfb22e9105/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9fd46ce0845cfe28f108888b3ab17abff84ff695e01e73657eec3f96d72eef34", size = 1650022 }, + { url = "https://files.pythonhosted.org/packages/85/fd/a032bf7f2755c2df4f87f9effa34ccc1ef5cea465377dbaeef93bb56bbd6/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd176afcf8f5d2aed50c3647d4925d0db0579d96f75a31e77cbaf67d8a87742d", size = 1732899 }, + { url = "https://files.pythonhosted.org/packages/c5/0c/c2b85fde167dd440c7ba50af2aac20b5a5666392b174df54c00f888c5a75/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ec2aa89305006fba9ffb98970db6c8221541be7bee4c1d027421d6f6df7d1ce2", size = 1755142 }, + { url = "https://files.pythonhosted.org/packages/bc/78/91ae1a3b3b3bed8b893c5d69c07023e151b1c95d79544ad04cf68f596c2f/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:92cde43018a2e17d48bb09c79e4d4cb0e236de5063ce897a5e40ac7cb4878773", size = 1692736 }, + { url = "https://files.pythonhosted.org/packages/77/89/a7ef9c4b4cdb546fcc650ca7f7395aaffbd267f0e1f648a436bec33c9b95/aiohttp-3.11.11-cp311-cp311-win32.whl", hash = "sha256:aba807f9569455cba566882c8938f1a549f205ee43c27b126e5450dc9f83cc62", size = 416418 }, + { url = "https://files.pythonhosted.org/packages/fc/db/2192489a8a51b52e06627506f8ac8df69ee221de88ab9bdea77aa793aa6a/aiohttp-3.11.11-cp311-cp311-win_amd64.whl", hash = "sha256:ae545f31489548c87b0cced5755cfe5a5308d00407000e72c4fa30b19c3220ac", size = 442509 }, + { url = "https://files.pythonhosted.org/packages/69/cf/4bda538c502f9738d6b95ada11603c05ec260807246e15e869fc3ec5de97/aiohttp-3.11.11-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e595c591a48bbc295ebf47cb91aebf9bd32f3ff76749ecf282ea7f9f6bb73886", size = 704666 }, + { url = "https://files.pythonhosted.org/packages/46/7b/87fcef2cad2fad420ca77bef981e815df6904047d0a1bd6aeded1b0d1d66/aiohttp-3.11.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ea1b59dc06396b0b424740a10a0a63974c725b1c64736ff788a3689d36c02d2", size = 464057 }, + { url = "https://files.pythonhosted.org/packages/5a/a6/789e1f17a1b6f4a38939fbc39d29e1d960d5f89f73d0629a939410171bc0/aiohttp-3.11.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8811f3f098a78ffa16e0ea36dffd577eb031aea797cbdba81be039a4169e242c", size = 455996 }, + { url = "https://files.pythonhosted.org/packages/b7/dd/485061fbfef33165ce7320db36e530cd7116ee1098e9c3774d15a732b3fd/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7227b87a355ce1f4bf83bfae4399b1f5bb42e0259cb9405824bd03d2f4336a", size = 1682367 }, + { url = "https://files.pythonhosted.org/packages/e9/d7/9ec5b3ea9ae215c311d88b2093e8da17e67b8856673e4166c994e117ee3e/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d40f9da8cabbf295d3a9dae1295c69975b86d941bc20f0a087f0477fa0a66231", size = 1736989 }, + { url = "https://files.pythonhosted.org/packages/d6/fb/ea94927f7bfe1d86178c9d3e0a8c54f651a0a655214cce930b3c679b8f64/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffb3dc385f6bb1568aa974fe65da84723210e5d9707e360e9ecb51f59406cd2e", size = 1793265 }, + { url = "https://files.pythonhosted.org/packages/40/7f/6de218084f9b653026bd7063cd8045123a7ba90c25176465f266976d8c82/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8f5f7515f3552d899c61202d99dcb17d6e3b0de777900405611cd747cecd1b8", size = 1691841 }, + { url = "https://files.pythonhosted.org/packages/77/e2/992f43d87831cbddb6b09c57ab55499332f60ad6fdbf438ff4419c2925fc/aiohttp-3.11.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3499c7ffbfd9c6a3d8d6a2b01c26639da7e43d47c7b4f788016226b1e711caa8", size = 1619317 }, + { url = "https://files.pythonhosted.org/packages/96/74/879b23cdd816db4133325a201287c95bef4ce669acde37f8f1b8669e1755/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8e2bf8029dbf0810c7bfbc3e594b51c4cc9101fbffb583a3923aea184724203c", size = 1641416 }, + { url = "https://files.pythonhosted.org/packages/30/98/b123f6b15d87c54e58fd7ae3558ff594f898d7f30a90899718f3215ad328/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b6212a60e5c482ef90f2d788835387070a88d52cf6241d3916733c9176d39eab", size = 1646514 }, + { url = "https://files.pythonhosted.org/packages/d7/38/257fda3dc99d6978ab943141d5165ec74fd4b4164baa15e9c66fa21da86b/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d119fafe7b634dbfa25a8c597718e69a930e4847f0b88e172744be24515140da", size = 1702095 }, + { url = "https://files.pythonhosted.org/packages/0c/f4/ddab089053f9fb96654df5505c0a69bde093214b3c3454f6bfdb1845f558/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:6fba278063559acc730abf49845d0e9a9e1ba74f85f0ee6efd5803f08b285853", size = 1734611 }, + { url = "https://files.pythonhosted.org/packages/c3/d6/f30b2bc520c38c8aa4657ed953186e535ae84abe55c08d0f70acd72ff577/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:92fc484e34b733704ad77210c7957679c5c3877bd1e6b6d74b185e9320cc716e", size = 1694576 }, + { url = "https://files.pythonhosted.org/packages/bc/97/b0a88c3f4c6d0020b34045ee6d954058abc870814f6e310c4c9b74254116/aiohttp-3.11.11-cp312-cp312-win32.whl", hash = "sha256:9f5b3c1ed63c8fa937a920b6c1bec78b74ee09593b3f5b979ab2ae5ef60d7600", size = 411363 }, + { url = "https://files.pythonhosted.org/packages/7f/23/cc36d9c398980acaeeb443100f0216f50a7cfe20c67a9fd0a2f1a5a846de/aiohttp-3.11.11-cp312-cp312-win_amd64.whl", hash = "sha256:1e69966ea6ef0c14ee53ef7a3d68b564cc408121ea56c0caa2dc918c1b2f553d", size = 437666 }, + { url = "https://files.pythonhosted.org/packages/49/d1/d8af164f400bad432b63e1ac857d74a09311a8334b0481f2f64b158b50eb/aiohttp-3.11.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:541d823548ab69d13d23730a06f97460f4238ad2e5ed966aaf850d7c369782d9", size = 697982 }, + { url = "https://files.pythonhosted.org/packages/92/d1/faad3bf9fa4bfd26b95c69fc2e98937d52b1ff44f7e28131855a98d23a17/aiohttp-3.11.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:929f3ed33743a49ab127c58c3e0a827de0664bfcda566108989a14068f820194", size = 460662 }, + { url = "https://files.pythonhosted.org/packages/db/61/0d71cc66d63909dabc4590f74eba71f91873a77ea52424401c2498d47536/aiohttp-3.11.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0882c2820fd0132240edbb4a51eb8ceb6eef8181db9ad5291ab3332e0d71df5f", size = 452950 }, + { url = "https://files.pythonhosted.org/packages/07/db/6d04bc7fd92784900704e16b745484ef45b77bd04e25f58f6febaadf7983/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63de12e44935d5aca7ed7ed98a255a11e5cb47f83a9fded7a5e41c40277d104", size = 1665178 }, + { url = "https://files.pythonhosted.org/packages/54/5c/e95ade9ae29f375411884d9fd98e50535bf9fe316c9feb0f30cd2ac8f508/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa54f8ef31d23c506910c21163f22b124facb573bff73930735cf9fe38bf7dff", size = 1717939 }, + { url = "https://files.pythonhosted.org/packages/6f/1c/1e7d5c5daea9e409ed70f7986001b8c9e3a49a50b28404498d30860edab6/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a344d5dc18074e3872777b62f5f7d584ae4344cd6006c17ba12103759d407af3", size = 1775125 }, + { url = "https://files.pythonhosted.org/packages/5d/66/890987e44f7d2f33a130e37e01a164168e6aff06fce15217b6eaf14df4f6/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7fb429ab1aafa1f48578eb315ca45bd46e9c37de11fe45c7f5f4138091e2f1", size = 1677176 }, + { url = "https://files.pythonhosted.org/packages/8f/dc/e2ba57d7a52df6cdf1072fd5fa9c6301a68e1cd67415f189805d3eeb031d/aiohttp-3.11.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c341c7d868750e31961d6d8e60ff040fb9d3d3a46d77fd85e1ab8e76c3e9a5c4", size = 1603192 }, + { url = "https://files.pythonhosted.org/packages/6c/9e/8d08a57de79ca3a358da449405555e668f2c8871a7777ecd2f0e3912c272/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed9ee95614a71e87f1a70bc81603f6c6760128b140bc4030abe6abaa988f1c3d", size = 1618296 }, + { url = "https://files.pythonhosted.org/packages/56/51/89822e3ec72db352c32e7fc1c690370e24e231837d9abd056490f3a49886/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de8d38f1c2810fa2a4f1d995a2e9c70bb8737b18da04ac2afbf3971f65781d87", size = 1616524 }, + { url = "https://files.pythonhosted.org/packages/2c/fa/e2e6d9398f462ffaa095e84717c1732916a57f1814502929ed67dd7568ef/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a9b7371665d4f00deb8f32208c7c5e652059b0fda41cf6dbcac6114a041f1cc2", size = 1685471 }, + { url = "https://files.pythonhosted.org/packages/ae/5f/6bb976e619ca28a052e2c0ca7b0251ccd893f93d7c24a96abea38e332bf6/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:620598717fce1b3bd14dd09947ea53e1ad510317c85dda2c9c65b622edc96b12", size = 1715312 }, + { url = "https://files.pythonhosted.org/packages/79/c1/756a7e65aa087c7fac724d6c4c038f2faaa2a42fe56dbc1dd62a33ca7213/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf8d9bfee991d8acc72d060d53860f356e07a50f0e0d09a8dfedea1c554dd0d5", size = 1672783 }, + { url = "https://files.pythonhosted.org/packages/73/ba/a6190ebb02176c7f75e6308da31f5d49f6477b651a3dcfaaaca865a298e2/aiohttp-3.11.11-cp313-cp313-win32.whl", hash = "sha256:9d73ee3725b7a737ad86c2eac5c57a4a97793d9f442599bea5ec67ac9f4bdc3d", size = 410229 }, + { url = "https://files.pythonhosted.org/packages/b8/62/c9fa5bafe03186a0e4699150a7fed9b1e73240996d0d2f0e5f70f3fdf471/aiohttp-3.11.11-cp313-cp313-win_amd64.whl", hash = "sha256:c7a06301c2fb096bdb0bd25fe2011531c1453b9f2c163c8031600ec73af1cc99", size = 436081 }, ] [[package]] name = "aiosignal" -version = "1.3.1" +version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/67/0952ed97a9793b4958e5736f6d2b346b414a2cd63e82d05940032f45b32f/aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", size = 19422 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424 } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/ac/a7305707cb852b7e16ff80eaf5692309bde30e2b1100a1fcacdc8f731d97/aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17", size = 7617 }, + { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597 }, ] [[package]] @@ -95,15 +95,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.6.2.post1" +version = "4.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422 } +sdist = { url = "https://files.pythonhosted.org/packages/f6/40/318e58f669b1a9e00f5c4453910682e2d9dd594334539c7b7817dabb765f/anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48", size = 177076 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377 }, + { url = "https://files.pythonhosted.org/packages/a0/7a/4daaf3b6c08ad7ceffea4634ec206faeff697526421c20f07628c7372156/anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352", size = 93052 }, ] [[package]] @@ -130,11 +131,11 @@ wheels = [ [[package]] name = "attrs" -version = "24.2.0" +version = "24.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/0f/aafca9af9315aee06a89ffde799a10a582fe8de76c563ee80bbcdc08b3fb/attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", size = 792678 } +sdist = { url = "https://files.pythonhosted.org/packages/48/c8/6260f8ccc11f0917360fc0da435c5c9c7504e3db174d5a12a1494887b045/attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 }, + { url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 }, ] [[package]] @@ -148,11 +149,11 @@ wheels = [ [[package]] name = "certifi" -version = "2024.8.30" +version = "2024.12.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, + { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, ] [[package]] @@ -287,50 +288,50 @@ wheels = [ [[package]] name = "coverage" -version = "7.6.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ab/75/aecfd0a3adbec6e45753976bc2a9fed62b42cea9a206d10fd29244a77953/coverage-7.6.8.tar.gz", hash = "sha256:8b2b8503edb06822c86d82fa64a4a5cb0760bb8f31f26e138ec743f422f37cfc", size = 801425 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/9f/e98211980f6e2f439e251737482aa77906c9b9c507824c71a2ce7eea0402/coverage-7.6.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:86cffe9c6dfcfe22e28027069725c7f57f4b868a3f86e81d1c62462764dc46d4", size = 207093 }, - { url = "https://files.pythonhosted.org/packages/fd/c7/8bab83fb9c20f7f8163c5a20dcb62d591b906a214a6dc6b07413074afc80/coverage-7.6.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d82ab6816c3277dc962cfcdc85b1efa0e5f50fb2c449432deaf2398a2928ab94", size = 207536 }, - { url = "https://files.pythonhosted.org/packages/1e/d6/00243df625f1b282bb25c83ce153ae2c06f8e7a796a8d833e7235337b4d9/coverage-7.6.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13690e923a3932e4fad4c0ebfb9cb5988e03d9dcb4c5150b5fcbf58fd8bddfc4", size = 239482 }, - { url = "https://files.pythonhosted.org/packages/1e/07/faf04b3eeb55ffc2a6f24b65dffe6e0359ec3b283e6efb5050ea0707446f/coverage-7.6.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4be32da0c3827ac9132bb488d331cb32e8d9638dd41a0557c5569d57cf22c9c1", size = 236886 }, - { url = "https://files.pythonhosted.org/packages/43/23/c79e497bf4d8fcacd316bebe1d559c765485b8ec23ac4e23025be6bfce09/coverage-7.6.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44e6c85bbdc809383b509d732b06419fb4544dca29ebe18480379633623baafb", size = 238749 }, - { url = "https://files.pythonhosted.org/packages/b5/e5/791bae13be3c6451e32ef7af1192e711c6a319f3c597e9b218d148fd0633/coverage-7.6.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:768939f7c4353c0fac2f7c37897e10b1414b571fd85dd9fc49e6a87e37a2e0d8", size = 237679 }, - { url = "https://files.pythonhosted.org/packages/05/c6/bbfdfb03aada601fb8993ced17468c8c8e0b4aafb3097026e680fabb7ce1/coverage-7.6.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e44961e36cb13c495806d4cac67640ac2866cb99044e210895b506c26ee63d3a", size = 236317 }, - { url = "https://files.pythonhosted.org/packages/67/f9/f8e5a4b2ce96d1b0e83ae6246369eb8437001dc80ec03bb51c87ff557cd8/coverage-7.6.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ea8bb1ab9558374c0ab591783808511d135a833c3ca64a18ec927f20c4030f0", size = 237084 }, - { url = "https://files.pythonhosted.org/packages/f0/70/b05328901e4debe76e033717e1452d00246c458c44e9dbd893e7619c2967/coverage-7.6.8-cp311-cp311-win32.whl", hash = "sha256:629a1ba2115dce8bf75a5cce9f2486ae483cb89c0145795603d6554bdc83e801", size = 209638 }, - { url = "https://files.pythonhosted.org/packages/70/55/1efa24f960a2fa9fbc44a9523d3f3c50ceb94dd1e8cd732168ab2dc41b07/coverage-7.6.8-cp311-cp311-win_amd64.whl", hash = "sha256:fb9fc32399dca861584d96eccd6c980b69bbcd7c228d06fb74fe53e007aa8ef9", size = 210506 }, - { url = "https://files.pythonhosted.org/packages/76/ce/3edf581c8fe429ed8ced6e6d9ac693c25975ef9093413276dab6ed68a80a/coverage-7.6.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e683e6ecc587643f8cde8f5da6768e9d165cd31edf39ee90ed7034f9ca0eefee", size = 207285 }, - { url = "https://files.pythonhosted.org/packages/09/9c/cf102ab046c9cf8895c3f7aadcde6f489a4b2ec326757e8c6e6581829b5e/coverage-7.6.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1defe91d41ce1bd44b40fabf071e6a01a5aa14de4a31b986aa9dfd1b3e3e414a", size = 207522 }, - { url = "https://files.pythonhosted.org/packages/39/06/42aa6dd13dbfca72e1fd8ffccadbc921b6e75db34545ebab4d955d1e7ad3/coverage-7.6.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7ad66e8e50225ebf4236368cc43c37f59d5e6728f15f6e258c8639fa0dd8e6d", size = 240543 }, - { url = "https://files.pythonhosted.org/packages/a0/20/2932971dc215adeca8eeff446266a7fef17a0c238e881ffedebe7bfa0669/coverage-7.6.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fe47da3e4fda5f1abb5709c156eca207eacf8007304ce3019eb001e7a7204cb", size = 237577 }, - { url = "https://files.pythonhosted.org/packages/ac/85/4323ece0cd5452c9522f4b6e5cc461e6c7149a4b1887c9e7a8b1f4e51146/coverage-7.6.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:202a2d645c5a46b84992f55b0a3affe4f0ba6b4c611abec32ee88358db4bb649", size = 239646 }, - { url = "https://files.pythonhosted.org/packages/77/52/b2537487d8f36241e518e84db6f79e26bc3343b14844366e35b090fae0d4/coverage-7.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4674f0daa1823c295845b6a740d98a840d7a1c11df00d1fd62614545c1583787", size = 239128 }, - { url = "https://files.pythonhosted.org/packages/7c/99/7f007762012186547d0ecc3d328da6b6f31a8c99f05dc1e13dcd929918cd/coverage-7.6.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:74610105ebd6f33d7c10f8907afed696e79c59e3043c5f20eaa3a46fddf33b4c", size = 237434 }, - { url = "https://files.pythonhosted.org/packages/97/53/e9b5cf0682a1cab9352adfac73caae0d77ae1d65abc88975d510f7816389/coverage-7.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cda8712145917105e07aab96388ae76e787270ec04bcb9d5cc786d7cbb8443", size = 239095 }, - { url = "https://files.pythonhosted.org/packages/0c/50/054f0b464fbae0483217186478eefa2e7df3a79917ed7f1d430b6da2cf0d/coverage-7.6.8-cp312-cp312-win32.whl", hash = "sha256:9e89d5c8509fbd6c03d0dd1972925b22f50db0792ce06324ba069f10787429ad", size = 209895 }, - { url = "https://files.pythonhosted.org/packages/df/d0/09ba870360a27ecf09e177ca2ff59d4337fc7197b456f22ceff85cffcfa5/coverage-7.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:379c111d3558272a2cae3d8e57e6b6e6f4fe652905692d54bad5ea0ca37c5ad4", size = 210684 }, - { url = "https://files.pythonhosted.org/packages/9a/84/6f0ccf94a098ac3d6d6f236bd3905eeac049a9e0efcd9a63d4feca37ac4b/coverage-7.6.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b0c69f4f724c64dfbfe79f5dfb503b42fe6127b8d479b2677f2b227478db2eb", size = 207313 }, - { url = "https://files.pythonhosted.org/packages/db/2b/e3b3a3a12ebec738c545897ac9f314620470fcbc368cdac88cf14974ba20/coverage-7.6.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c15b32a7aca8038ed7644f854bf17b663bc38e1671b5d6f43f9a2b2bd0c46f63", size = 207574 }, - { url = "https://files.pythonhosted.org/packages/db/c0/5bf95d42b6a8d21dfce5025ce187f15db57d6460a59b67a95fe8728162f1/coverage-7.6.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63068a11171e4276f6ece913bde059e77c713b48c3a848814a6537f35afb8365", size = 240090 }, - { url = "https://files.pythonhosted.org/packages/57/b8/d6fd17d1a8e2b0e1a4e8b9cb1f0f261afd422570735899759c0584236916/coverage-7.6.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4548c5ead23ad13fb7a2c8ea541357474ec13c2b736feb02e19a3085fac002", size = 237237 }, - { url = "https://files.pythonhosted.org/packages/d4/e4/a91e9bb46809c8b63e68fc5db5c4d567d3423b6691d049a4f950e38fbe9d/coverage-7.6.8-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4b4299dd0d2c67caaaf286d58aef5e75b125b95615dda4542561a5a566a1e3", size = 239225 }, - { url = "https://files.pythonhosted.org/packages/31/9c/9b99b0591ec4555b7292d271e005f27b465388ce166056c435b288db6a69/coverage-7.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9ebfb2507751f7196995142f057d1324afdab56db1d9743aab7f50289abd022", size = 238888 }, - { url = "https://files.pythonhosted.org/packages/a6/85/285c2df9a04bc7c31f21fd9d4a24d19e040ec5e2ff06e572af1f6514c9e7/coverage-7.6.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c1b4474beee02ede1eef86c25ad4600a424fe36cff01a6103cb4533c6bf0169e", size = 236974 }, - { url = "https://files.pythonhosted.org/packages/cb/a1/95ec8522206f76cdca033bf8bb61fff56429fb414835fc4d34651dfd29fc/coverage-7.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9fd2547e6decdbf985d579cf3fc78e4c1d662b9b0ff7cc7862baaab71c9cc5b", size = 238815 }, - { url = "https://files.pythonhosted.org/packages/8d/ac/687e9ba5e6d0979e9dab5c02e01c4f24ac58260ef82d88d3b433b3f84f1e/coverage-7.6.8-cp313-cp313-win32.whl", hash = "sha256:8aae5aea53cbfe024919715eca696b1a3201886ce83790537d1c3668459c7146", size = 209957 }, - { url = "https://files.pythonhosted.org/packages/2f/a3/b61cc8e3fcf075293fb0f3dee405748453c5ba28ac02ceb4a87f52bdb105/coverage-7.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:ae270e79f7e169ccfe23284ff5ea2d52a6f401dc01b337efb54b3783e2ce3f28", size = 210711 }, - { url = "https://files.pythonhosted.org/packages/ee/4b/891c8b9acf1b62c85e4a71dac142ab9284e8347409b7355de02e3f38306f/coverage-7.6.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de38add67a0af869b0d79c525d3e4588ac1ffa92f39116dbe0ed9753f26eba7d", size = 208053 }, - { url = "https://files.pythonhosted.org/packages/18/a9/9e330409b291cc002723d339346452800e78df1ce50774ca439ade1d374f/coverage-7.6.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b07c25d52b1c16ce5de088046cd2432b30f9ad5e224ff17c8f496d9cb7d1d451", size = 208329 }, - { url = "https://files.pythonhosted.org/packages/9c/0d/33635fd429f6589c6e1cdfc7bf581aefe4c1792fbff06383f9d37f59db60/coverage-7.6.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62a66ff235e4c2e37ed3b6104d8b478d767ff73838d1222132a7a026aa548764", size = 251052 }, - { url = "https://files.pythonhosted.org/packages/23/32/8a08da0e46f3830bbb9a5b40614241b2e700f27a9c2889f53122486443ed/coverage-7.6.8-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b9f848b28081e7b975a3626e9081574a7b9196cde26604540582da60235fdf", size = 246765 }, - { url = "https://files.pythonhosted.org/packages/56/3f/3b86303d2c14350fdb1c6c4dbf9bc76000af2382f42ca1d4d99c6317666e/coverage-7.6.8-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:093896e530c38c8e9c996901858ac63f3d4171268db2c9c8b373a228f459bbc5", size = 249125 }, - { url = "https://files.pythonhosted.org/packages/36/cb/c4f081b9023f9fd8646dbc4ef77be0df090263e8f66f4ea47681e0dc2cff/coverage-7.6.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a7b8ac36fd688c8361cbc7bf1cb5866977ece6e0b17c34aa0df58bda4fa18a4", size = 248615 }, - { url = "https://files.pythonhosted.org/packages/32/ee/53bdbf67760928c44b57b2c28a8c0a4bf544f85a9ee129a63ba5c78fdee4/coverage-7.6.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:38c51297b35b3ed91670e1e4efb702b790002e3245a28c76e627478aa3c10d83", size = 246507 }, - { url = "https://files.pythonhosted.org/packages/57/49/5a57910bd0af6d8e802b4ca65292576d19b54b49f81577fd898505dee075/coverage-7.6.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2e4e0f60cb4bd7396108823548e82fdab72d4d8a65e58e2c19bbbc2f1e2bfa4b", size = 247785 }, - { url = "https://files.pythonhosted.org/packages/bd/37/e450c9f6b297c79bb9858407396ed3e084dcc22990dd110ab01d5ceb9770/coverage-7.6.8-cp313-cp313t-win32.whl", hash = "sha256:6535d996f6537ecb298b4e287a855f37deaf64ff007162ec0afb9ab8ba3b8b71", size = 210605 }, - { url = "https://files.pythonhosted.org/packages/44/79/7d0c7dd237c6905018e2936cd1055fe1d42e7eba2ebab3c00f4aad2a27d7/coverage-7.6.8-cp313-cp313t-win_amd64.whl", hash = "sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc", size = 211777 }, +version = "7.6.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/d2/c25011f4d036cf7e8acbbee07a8e09e9018390aee25ba085596c4b83d510/coverage-7.6.9.tar.gz", hash = "sha256:4a8d8977b0c6ef5aeadcb644da9e69ae0dcfe66ec7f368c89c72e058bd71164d", size = 801710 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/91/b3dc2f7f38b5cca1236ab6bbb03e84046dd887707b4ec1db2baa47493b3b/coverage-7.6.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:932fc826442132dde42ee52cf66d941f581c685a6313feebed358411238f60f9", size = 207133 }, + { url = "https://files.pythonhosted.org/packages/0d/2b/53fd6cb34d443429a92b3ec737f4953627e38b3bee2a67a3c03425ba8573/coverage-7.6.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:085161be5f3b30fd9b3e7b9a8c301f935c8313dcf928a07b116324abea2c1c2c", size = 207577 }, + { url = "https://files.pythonhosted.org/packages/74/f2/68edb1e6826f980a124f21ea5be0d324180bf11de6fd1defcf9604f76df0/coverage-7.6.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc660a77e1c2bf24ddbce969af9447a9474790160cfb23de6be4fa88e3951c7", size = 239524 }, + { url = "https://files.pythonhosted.org/packages/d3/83/8fec0ee68c2c4a5ab5f0f8527277f84ed6f2bd1310ae8a19d0c5532253ab/coverage-7.6.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c69e42c892c018cd3c8d90da61d845f50a8243062b19d228189b0224150018a9", size = 236925 }, + { url = "https://files.pythonhosted.org/packages/8b/20/8f50e7c7ad271144afbc2c1c6ec5541a8c81773f59352f8db544cad1a0ec/coverage-7.6.9-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0824a28ec542a0be22f60c6ac36d679e0e262e5353203bea81d44ee81fe9c6d4", size = 238792 }, + { url = "https://files.pythonhosted.org/packages/6f/62/4ac2e5ad9e7a5c9ec351f38947528e11541f1f00e8a0cdce56f1ba7ae301/coverage-7.6.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4401ae5fc52ad8d26d2a5d8a7428b0f0c72431683f8e63e42e70606374c311a1", size = 237682 }, + { url = "https://files.pythonhosted.org/packages/58/2f/9d2203f012f3b0533c73336c74134b608742be1ce475a5c72012573cfbb4/coverage-7.6.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98caba4476a6c8d59ec1eb00c7dd862ba9beca34085642d46ed503cc2d440d4b", size = 236310 }, + { url = "https://files.pythonhosted.org/packages/33/6d/31f6ab0b4f0f781636075f757eb02141ea1b34466d9d1526dbc586ed7078/coverage-7.6.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ee5defd1733fd6ec08b168bd4f5387d5b322f45ca9e0e6c817ea6c4cd36313e3", size = 237096 }, + { url = "https://files.pythonhosted.org/packages/7d/fb/e14c38adebbda9ed8b5f7f8e03340ac05d68d27b24397f8d47478927a333/coverage-7.6.9-cp311-cp311-win32.whl", hash = "sha256:f2d1ec60d6d256bdf298cb86b78dd715980828f50c46701abc3b0a2b3f8a0dc0", size = 209682 }, + { url = "https://files.pythonhosted.org/packages/a4/11/a782af39b019066af83fdc0e8825faaccbe9d7b19a803ddb753114b429cc/coverage-7.6.9-cp311-cp311-win_amd64.whl", hash = "sha256:0d59fd927b1f04de57a2ba0137166d31c1a6dd9e764ad4af552912d70428c92b", size = 210542 }, + { url = "https://files.pythonhosted.org/packages/60/52/b16af8989a2daf0f80a88522bd8e8eed90b5fcbdecf02a6888f3e80f6ba7/coverage-7.6.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:99e266ae0b5d15f1ca8d278a668df6f51cc4b854513daab5cae695ed7b721cf8", size = 207325 }, + { url = "https://files.pythonhosted.org/packages/0f/79/6b7826fca8846c1216a113227b9f114ac3e6eacf168b4adcad0cb974aaca/coverage-7.6.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9901d36492009a0a9b94b20e52ebfc8453bf49bb2b27bca2c9706f8b4f5a554a", size = 207563 }, + { url = "https://files.pythonhosted.org/packages/a7/07/0bc73da0ccaf45d0d64ef86d33b7d7fdeef84b4c44bf6b85fb12c215c5a6/coverage-7.6.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abd3e72dd5b97e3af4246cdada7738ef0e608168de952b837b8dd7e90341f015", size = 240580 }, + { url = "https://files.pythonhosted.org/packages/71/8a/9761f409910961647d892454687cedbaccb99aae828f49486734a82ede6e/coverage-7.6.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff74026a461eb0660366fb01c650c1d00f833a086b336bdad7ab00cc952072b3", size = 237613 }, + { url = "https://files.pythonhosted.org/packages/8b/10/ee7d696a17ac94f32f2dbda1e17e730bf798ae9931aec1fc01c1944cd4de/coverage-7.6.9-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65dad5a248823a4996724a88eb51d4b31587aa7aa428562dbe459c684e5787ae", size = 239684 }, + { url = "https://files.pythonhosted.org/packages/16/60/aa1066040d3c52fff051243c2d6ccda264da72dc6d199d047624d395b2b2/coverage-7.6.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22be16571504c9ccea919fcedb459d5ab20d41172056206eb2994e2ff06118a4", size = 239112 }, + { url = "https://files.pythonhosted.org/packages/4e/e5/69f35344c6f932ba9028bf168d14a79fedb0dd4849b796d43c81ce75a3c9/coverage-7.6.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f957943bc718b87144ecaee70762bc2bc3f1a7a53c7b861103546d3a403f0a6", size = 237428 }, + { url = "https://files.pythonhosted.org/packages/32/20/adc895523c4a28f63441b8ac645abd74f9bdd499d2d175bef5b41fc7f92d/coverage-7.6.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae1387db4aecb1f485fb70a6c0148c6cdaebb6038f1d40089b1fc84a5db556f", size = 239098 }, + { url = "https://files.pythonhosted.org/packages/a9/a6/e0e74230c9bb3549ec8ffc137cfd16ea5d56e993d6bffed2218bff6187e3/coverage-7.6.9-cp312-cp312-win32.whl", hash = "sha256:1a330812d9cc7ac2182586f6d41b4d0fadf9be9049f350e0efb275c8ee8eb692", size = 209940 }, + { url = "https://files.pythonhosted.org/packages/3e/18/cb5b88349d4aa2f41ec78d65f92ea32572b30b3f55bc2b70e87578b8f434/coverage-7.6.9-cp312-cp312-win_amd64.whl", hash = "sha256:b12c6b18269ca471eedd41c1b6a1065b2f7827508edb9a7ed5555e9a56dcfc97", size = 210726 }, + { url = "https://files.pythonhosted.org/packages/35/26/9abab6539d2191dbda2ce8c97b67d74cbfc966cc5b25abb880ffc7c459bc/coverage-7.6.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:899b8cd4781c400454f2f64f7776a5d87bbd7b3e7f7bda0cb18f857bb1334664", size = 207356 }, + { url = "https://files.pythonhosted.org/packages/44/da/d49f19402240c93453f606e660a6676a2a1fbbaa6870cc23207790aa9697/coverage-7.6.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:61f70dc68bd36810972e55bbbe83674ea073dd1dcc121040a08cdf3416c5349c", size = 207614 }, + { url = "https://files.pythonhosted.org/packages/da/e6/93bb9bf85497816082ec8da6124c25efa2052bd4c887dd3b317b91990c9e/coverage-7.6.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a289d23d4c46f1a82d5db4abeb40b9b5be91731ee19a379d15790e53031c014", size = 240129 }, + { url = "https://files.pythonhosted.org/packages/df/65/6a824b9406fe066835c1274a9949e06f084d3e605eb1a602727a27ec2fe3/coverage-7.6.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e216d8044a356fc0337c7a2a0536d6de07888d7bcda76febcb8adc50bdbbd00", size = 237276 }, + { url = "https://files.pythonhosted.org/packages/9f/79/6c7a800913a9dd23ac8c8da133ebb556771a5a3d4df36b46767b1baffd35/coverage-7.6.9-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c026eb44f744acaa2bda7493dad903aa5bf5fc4f2554293a798d5606710055d", size = 239267 }, + { url = "https://files.pythonhosted.org/packages/57/e7/834d530293fdc8a63ba8ff70033d5182022e569eceb9aec7fc716b678a39/coverage-7.6.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e77363e8425325384f9d49272c54045bbed2f478e9dd698dbc65dbc37860eb0a", size = 238887 }, + { url = "https://files.pythonhosted.org/packages/15/05/ec9d6080852984f7163c96984444e7cd98b338fd045b191064f943ee1c08/coverage-7.6.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:777abfab476cf83b5177b84d7486497e034eb9eaea0d746ce0c1268c71652077", size = 236970 }, + { url = "https://files.pythonhosted.org/packages/0a/d8/775937670b93156aec29f694ce37f56214ed7597e1a75b4083ee4c32121c/coverage-7.6.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:447af20e25fdbe16f26e84eb714ba21d98868705cb138252d28bc400381f6ffb", size = 238831 }, + { url = "https://files.pythonhosted.org/packages/f4/58/88551cb7fdd5ec98cb6044e8814e38583436b14040a5ece15349c44c8f7c/coverage-7.6.9-cp313-cp313-win32.whl", hash = "sha256:d872ec5aeb086cbea771c573600d47944eea2dcba8be5f3ee649bfe3cb8dc9ba", size = 210000 }, + { url = "https://files.pythonhosted.org/packages/b7/12/cfbf49b95120872785ff8d56ab1c7fe3970a65e35010c311d7dd35c5fd00/coverage-7.6.9-cp313-cp313-win_amd64.whl", hash = "sha256:fd1213c86e48dfdc5a0cc676551db467495a95a662d2396ecd58e719191446e1", size = 210753 }, + { url = "https://files.pythonhosted.org/packages/7c/68/c1cb31445599b04bde21cbbaa6d21b47c5823cdfef99eae470dfce49c35a/coverage-7.6.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9e7484d286cd5a43744e5f47b0b3fb457865baf07bafc6bee91896364e1419", size = 208091 }, + { url = "https://files.pythonhosted.org/packages/11/73/84b02c6b19c4a11eb2d5b5eabe926fb26c21c080e0852f5e5a4f01165f9e/coverage-7.6.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1cf0872ee455c03e5674b5bca5e3e68e159379c1af0903e89f5eba9ccc3a", size = 208369 }, + { url = "https://files.pythonhosted.org/packages/de/e0/ae5d878b72ff26df2e994a5c5b1c1f6a7507d976b23beecb1ed4c85411ef/coverage-7.6.9-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d10e07aa2b91835d6abec555ec8b2733347956991901eea6ffac295f83a30e4", size = 251089 }, + { url = "https://files.pythonhosted.org/packages/ab/9c/0aaac011aef95a93ef3cb2fba3fde30bc7e68a6635199ed469b1f5ea355a/coverage-7.6.9-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13a9e2d3ee855db3dd6ea1ba5203316a1b1fd8eaeffc37c5b54987e61e4194ae", size = 246806 }, + { url = "https://files.pythonhosted.org/packages/f8/19/4d5d3ae66938a7dcb2f58cef3fa5386f838f469575b0bb568c8cc9e3a33d/coverage-7.6.9-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c38bf15a40ccf5619fa2fe8f26106c7e8e080d7760aeccb3722664c8656b030", size = 249164 }, + { url = "https://files.pythonhosted.org/packages/b3/0b/4ee8a7821f682af9ad440ae3c1e379da89a998883271f088102d7ca2473d/coverage-7.6.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d5275455b3e4627c8e7154feaf7ee0743c2e7af82f6e3b561967b1cca755a0be", size = 248642 }, + { url = "https://files.pythonhosted.org/packages/8a/12/36ff1d52be18a16b4700f561852e7afd8df56363a5edcfb04cf26a0e19e0/coverage-7.6.9-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8f8770dfc6e2c6a2d4569f411015c8d751c980d17a14b0530da2d7f27ffdd88e", size = 246516 }, + { url = "https://files.pythonhosted.org/packages/43/d0/8e258f6c3a527c1655602f4f576215e055ac704de2d101710a71a2affac2/coverage-7.6.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8d2dfa71665a29b153a9681edb1c8d9c1ea50dfc2375fb4dac99ea7e21a0bcd9", size = 247783 }, + { url = "https://files.pythonhosted.org/packages/a9/0d/1e4a48d289429d38aae3babdfcadbf35ca36bdcf3efc8f09b550a845bdb5/coverage-7.6.9-cp313-cp313t-win32.whl", hash = "sha256:5e6b86b5847a016d0fbd31ffe1001b63355ed309651851295315031ea7eb5a9b", size = 210646 }, + { url = "https://files.pythonhosted.org/packages/26/74/b0729f196f328ac55e42b1e22ec2f16d8bcafe4b8158a26ec9f1cdd1d93e/coverage-7.6.9-cp313-cp313t-win_amd64.whl", hash = "sha256:97ddc94d46088304772d21b060041c97fc16bdda13c6c7f9d8fcd8d5ae0d8611", size = 211815 }, ] [package.optional-dependencies] @@ -340,31 +341,33 @@ toml = [ [[package]] name = "cryptography" -version = "43.0.3" +version = "44.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/05/07b55d1fa21ac18c3a8c79f764e2514e6f6a9698f1be44994f5adf0d29db/cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805", size = 686989 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/f3/01fdf26701a26f4b4dbc337a26883ad5bccaa6f1bbbdd29cd89e22f18a1c/cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e", size = 6225303 }, - { url = "https://files.pythonhosted.org/packages/a3/01/4896f3d1b392025d4fcbecf40fdea92d3df8662123f6835d0af828d148fd/cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e", size = 3760905 }, - { url = "https://files.pythonhosted.org/packages/0a/be/f9a1f673f0ed4b7f6c643164e513dbad28dd4f2dcdf5715004f172ef24b6/cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f", size = 3977271 }, - { url = "https://files.pythonhosted.org/packages/4e/49/80c3a7b5514d1b416d7350830e8c422a4d667b6d9b16a9392ebfd4a5388a/cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6", size = 3746606 }, - { url = "https://files.pythonhosted.org/packages/0e/16/a28ddf78ac6e7e3f25ebcef69ab15c2c6be5ff9743dd0709a69a4f968472/cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18", size = 3986484 }, - { url = "https://files.pythonhosted.org/packages/01/f5/69ae8da70c19864a32b0315049866c4d411cce423ec169993d0434218762/cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd", size = 3852131 }, - { url = "https://files.pythonhosted.org/packages/fd/db/e74911d95c040f9afd3612b1f732e52b3e517cb80de8bf183be0b7d413c6/cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73", size = 4075647 }, - { url = "https://files.pythonhosted.org/packages/56/48/7b6b190f1462818b324e674fa20d1d5ef3e24f2328675b9b16189cbf0b3c/cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2", size = 2623873 }, - { url = "https://files.pythonhosted.org/packages/eb/b1/0ebff61a004f7f89e7b65ca95f2f2375679d43d0290672f7713ee3162aff/cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd", size = 3068039 }, - { url = "https://files.pythonhosted.org/packages/30/d5/c8b32c047e2e81dd172138f772e81d852c51f0f2ad2ae8a24f1122e9e9a7/cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984", size = 6222984 }, - { url = "https://files.pythonhosted.org/packages/2f/78/55356eb9075d0be6e81b59f45c7b48df87f76a20e73893872170471f3ee8/cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5", size = 3762968 }, - { url = "https://files.pythonhosted.org/packages/2a/2c/488776a3dc843f95f86d2f957ca0fc3407d0242b50bede7fad1e339be03f/cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4", size = 3977754 }, - { url = "https://files.pythonhosted.org/packages/7c/04/2345ca92f7a22f601a9c62961741ef7dd0127c39f7310dffa0041c80f16f/cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7", size = 3749458 }, - { url = "https://files.pythonhosted.org/packages/ac/25/e715fa0bc24ac2114ed69da33adf451a38abb6f3f24ec207908112e9ba53/cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405", size = 3988220 }, - { url = "https://files.pythonhosted.org/packages/21/ce/b9c9ff56c7164d8e2edfb6c9305045fbc0df4508ccfdb13ee66eb8c95b0e/cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16", size = 3853898 }, - { url = "https://files.pythonhosted.org/packages/2a/33/b3682992ab2e9476b9c81fff22f02c8b0a1e6e1d49ee1750a67d85fd7ed2/cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", size = 4076592 }, - { url = "https://files.pythonhosted.org/packages/81/1e/ffcc41b3cebd64ca90b28fd58141c5f68c83d48563c88333ab660e002cd3/cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995", size = 2623145 }, - { url = "https://files.pythonhosted.org/packages/87/5c/3dab83cc4aba1f4b0e733e3f0c3e7d4386440d660ba5b1e3ff995feb734d/cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362", size = 3068026 }, +sdist = { url = "https://files.pythonhosted.org/packages/91/4c/45dfa6829acffa344e3967d6006ee4ae8be57af746ae2eba1c431949b32c/cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02", size = 710657 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/09/8cc67f9b84730ad330b3b72cf867150744bf07ff113cda21a15a1c6d2c7c/cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123", size = 6541833 }, + { url = "https://files.pythonhosted.org/packages/7e/5b/3759e30a103144e29632e7cb72aec28cedc79e514b2ea8896bb17163c19b/cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092", size = 3922710 }, + { url = "https://files.pythonhosted.org/packages/5f/58/3b14bf39f1a0cfd679e753e8647ada56cddbf5acebffe7db90e184c76168/cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f", size = 4137546 }, + { url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420 }, + { url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498 }, + { url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569 }, + { url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721 }, + { url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915 }, + { url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925 }, + { url = "https://files.pythonhosted.org/packages/64/b1/50d7739254d2002acae64eed4fc43b24ac0cc44bf0a0d388d1ca06ec5bb1/cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd", size = 3202055 }, + { url = "https://files.pythonhosted.org/packages/11/18/61e52a3d28fc1514a43b0ac291177acd1b4de00e9301aaf7ef867076ff8a/cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591", size = 6542801 }, + { url = "https://files.pythonhosted.org/packages/1a/07/5f165b6c65696ef75601b781a280fc3b33f1e0cd6aa5a92d9fb96c410e97/cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7", size = 3922613 }, + { url = "https://files.pythonhosted.org/packages/28/34/6b3ac1d80fc174812486561cf25194338151780f27e438526f9c64e16869/cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc", size = 4137925 }, + { url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417 }, + { url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160 }, + { url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331 }, + { url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372 }, + { url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657 }, + { url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672 }, + { url = "https://files.pythonhosted.org/packages/97/9b/443270b9210f13f6ef240eff73fd32e02d381e7103969dc66ce8e89ee901/cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede", size = 3202071 }, ] [[package]] @@ -700,30 +703,30 @@ wheels = [ [[package]] name = "mypy" -version = "1.13.0" +version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } +sdist = { url = "https://files.pythonhosted.org/packages/8c/7b/08046ef9330735f536a09a2e31b00f42bccdb2795dcd979636ba43bb2d63/mypy-1.14.0.tar.gz", hash = "sha256:822dbd184d4a9804df5a7d5335a68cf7662930e70b8c1bc976645d1509f9a9d6", size = 3215684 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027 }, - { url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699 }, - { url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263 }, - { url = "https://files.pythonhosted.org/packages/3f/1d/676e76f07f7d5ddcd4227af3938a9c9640f293b7d8a44dd4ff41d4db25c1/mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", size = 12984688 }, - { url = "https://files.pythonhosted.org/packages/9c/03/5a85a30ae5407b1d28fab51bd3e2103e52ad0918d1e68f02a7778669a307/mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", size = 9626811 }, - { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, - { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, - { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, - { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, - { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, - { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, - { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, - { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, - { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, - { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, - { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, + { url = "https://files.pythonhosted.org/packages/34/c1/b9dd3e955953aec1c728992545b7877c9f6fa742a623ce4c200da0f62540/mypy-1.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6e73c8a154eed31db3445fe28f63ad2d97b674b911c00191416cf7f6459fd49a", size = 11121032 }, + { url = "https://files.pythonhosted.org/packages/ee/96/c52d5d516819ab95bf41f4a1ada828a3decc302f8c152ff4fc5feb0e4529/mypy-1.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:273e70fcb2e38c5405a188425aa60b984ffdcef65d6c746ea5813024b68c73dc", size = 10286294 }, + { url = "https://files.pythonhosted.org/packages/69/2c/3dbe51877a24daa467f8d8631f9ffd1aabbf0f6d9367a01c44a59df81fe0/mypy-1.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1daca283d732943731a6a9f20fdbcaa927f160bc51602b1d4ef880a6fb252015", size = 12746528 }, + { url = "https://files.pythonhosted.org/packages/a1/a8/eb20cde4ba9c4c3e20d958918a7c5d92210f4d1a0200c27de9a641f70996/mypy-1.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7e68047bedb04c1c25bba9901ea46ff60d5eaac2d71b1f2161f33107e2b368eb", size = 12883489 }, + { url = "https://files.pythonhosted.org/packages/91/17/a1fc6c70f31d52c99299320cf81c3cb2c6b91ec7269414e0718a6d138e34/mypy-1.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:7a52f26b9c9b1664a60d87675f3bae00b5c7f2806e0c2800545a32c325920bcc", size = 9780113 }, + { url = "https://files.pythonhosted.org/packages/fe/d8/0e72175ee0253217f5c44524f5e95251c02e95ba9749fb87b0e2074d203a/mypy-1.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d5326ab70a6db8e856d59ad4cb72741124950cbbf32e7b70e30166ba7bbf61dd", size = 11269011 }, + { url = "https://files.pythonhosted.org/packages/e9/6d/4ea13839dabe5db588dc6a1b766da16f420d33cf118a7b7172cdf6c7fcb2/mypy-1.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf4ec4980bec1e0e24e5075f449d014011527ae0055884c7e3abc6a99cd2c7f1", size = 10253076 }, + { url = "https://files.pythonhosted.org/packages/3e/38/7db2c5d0f4d290e998f7a52b2e2616c7bbad96b8e04278ab09d11978a29e/mypy-1.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:390dfb898239c25289495500f12fa73aa7f24a4c6d90ccdc165762462b998d63", size = 12862786 }, + { url = "https://files.pythonhosted.org/packages/bf/4b/62d59c801b34141040989949c2b5c157d0408b45357335d3ec5b2845b0f6/mypy-1.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e026d55ddcd76e29e87865c08cbe2d0104e2b3153a523c529de584759379d3d", size = 12971568 }, + { url = "https://files.pythonhosted.org/packages/f1/9c/e0f281b32d70c87b9e4d2939e302b1ff77ada4d7b0f2fb32890c144bc1d6/mypy-1.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:585ed36031d0b3ee362e5107ef449a8b5dfd4e9c90ccbe36414ee405ee6b32ba", size = 9879477 }, + { url = "https://files.pythonhosted.org/packages/13/33/8380efd0ebdfdfac7fc0bf065f03a049800ca1e6c296ec1afc634340d992/mypy-1.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9f6f4c0b27401d14c483c622bc5105eff3911634d576bbdf6695b9a7c1ba741", size = 11251509 }, + { url = "https://files.pythonhosted.org/packages/15/6d/4e1c21c60fee11af7d8e4f2902a29886d1387d6a836be16229eb3982a963/mypy-1.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b2280cedcb312c7a79f5001ae5325582d0d339bce684e4a529069d0e7ca1e7", size = 10244282 }, + { url = "https://files.pythonhosted.org/packages/8b/cf/7a8ae5c0161edae15d25c2c67c68ce8b150cbdc45aefc13a8be271ee80b2/mypy-1.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:342de51c48bab326bfc77ce056ba08c076d82ce4f5a86621f972ed39970f94d8", size = 12867676 }, + { url = "https://files.pythonhosted.org/packages/9c/d0/71f7bbdcc7cfd0f2892db5b13b1e8857673f2cc9e0c30e3e4340523dc186/mypy-1.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00df23b42e533e02a6f0055e54de9a6ed491cd8b7ea738647364fd3a39ea7efc", size = 12964189 }, + { url = "https://files.pythonhosted.org/packages/a7/40/fb4ad65d6d5f8c51396ecf6305ec0269b66013a5bf02d0e9528053640b4a/mypy-1.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e8c8387e5d9dff80e7daf961df357c80e694e942d9755f3ad77d69b0957b8e3f", size = 9888247 }, + { url = "https://files.pythonhosted.org/packages/39/32/0214608af400cdf8f5102144bb8af10d880675c65ed0b58f7e0e77175d50/mypy-1.14.0-py3-none-any.whl", hash = "sha256:2238d7f93fc4027ed1efc944507683df3ba406445a2b6c96e79666a045aadfab", size = 2752803 }, ] [[package]] @@ -870,59 +873,59 @@ wheels = [ [[package]] name = "propcache" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/4d/5e5a60b78dbc1d464f8a7bbaeb30957257afdc8512cbb9dfd5659304f5cd/propcache-0.2.0.tar.gz", hash = "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70", size = 40951 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/1c/71eec730e12aec6511e702ad0cd73c2872eccb7cad39de8ba3ba9de693ef/propcache-0.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:63f13bf09cc3336eb04a837490b8f332e0db41da66995c9fd1ba04552e516354", size = 80811 }, - { url = "https://files.pythonhosted.org/packages/89/c3/7e94009f9a4934c48a371632197406a8860b9f08e3f7f7d922ab69e57a41/propcache-0.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608cce1da6f2672a56b24a015b42db4ac612ee709f3d29f27a00c943d9e851de", size = 46365 }, - { url = "https://files.pythonhosted.org/packages/c0/1d/c700d16d1d6903aeab28372fe9999762f074b80b96a0ccc953175b858743/propcache-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:466c219deee4536fbc83c08d09115249db301550625c7fef1c5563a584c9bc87", size = 45602 }, - { url = "https://files.pythonhosted.org/packages/2e/5e/4a3e96380805bf742712e39a4534689f4cddf5fa2d3a93f22e9fd8001b23/propcache-0.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2db02409338bf36590aa985a461b2c96fce91f8e7e0f14c50c5fcc4f229016", size = 236161 }, - { url = "https://files.pythonhosted.org/packages/a5/85/90132481183d1436dff6e29f4fa81b891afb6cb89a7306f32ac500a25932/propcache-0.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6ed8db0a556343d566a5c124ee483ae113acc9a557a807d439bcecc44e7dfbb", size = 244938 }, - { url = "https://files.pythonhosted.org/packages/4a/89/c893533cb45c79c970834274e2d0f6d64383ec740be631b6a0a1d2b4ddc0/propcache-0.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91997d9cb4a325b60d4e3f20967f8eb08dfcb32b22554d5ef78e6fd1dda743a2", size = 243576 }, - { url = "https://files.pythonhosted.org/packages/8c/56/98c2054c8526331a05f205bf45cbb2cda4e58e56df70e76d6a509e5d6ec6/propcache-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c7dde9e533c0a49d802b4f3f218fa9ad0a1ce21f2c2eb80d5216565202acab4", size = 236011 }, - { url = "https://files.pythonhosted.org/packages/2d/0c/8b8b9f8a6e1abd869c0fa79b907228e7abb966919047d294ef5df0d136cf/propcache-0.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffcad6c564fe6b9b8916c1aefbb37a362deebf9394bd2974e9d84232e3e08504", size = 224834 }, - { url = "https://files.pythonhosted.org/packages/18/bb/397d05a7298b7711b90e13108db697732325cafdcd8484c894885c1bf109/propcache-0.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:97a58a28bcf63284e8b4d7b460cbee1edaab24634e82059c7b8c09e65284f178", size = 224946 }, - { url = "https://files.pythonhosted.org/packages/25/19/4fc08dac19297ac58135c03770b42377be211622fd0147f015f78d47cd31/propcache-0.2.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:945db8ee295d3af9dbdbb698cce9bbc5c59b5c3fe328bbc4387f59a8a35f998d", size = 217280 }, - { url = "https://files.pythonhosted.org/packages/7e/76/c79276a43df2096ce2aba07ce47576832b1174c0c480fe6b04bd70120e59/propcache-0.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39e104da444a34830751715f45ef9fc537475ba21b7f1f5b0f4d71a3b60d7fe2", size = 220088 }, - { url = "https://files.pythonhosted.org/packages/c3/9a/8a8cf428a91b1336b883f09c8b884e1734c87f724d74b917129a24fe2093/propcache-0.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c5ecca8f9bab618340c8e848d340baf68bcd8ad90a8ecd7a4524a81c1764b3db", size = 233008 }, - { url = "https://files.pythonhosted.org/packages/25/7b/768a8969abd447d5f0f3333df85c6a5d94982a1bc9a89c53c154bf7a8b11/propcache-0.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c436130cc779806bdf5d5fae0d848713105472b8566b75ff70048c47d3961c5b", size = 237719 }, - { url = "https://files.pythonhosted.org/packages/ed/0d/e5d68ccc7976ef8b57d80613ac07bbaf0614d43f4750cf953f0168ef114f/propcache-0.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:191db28dc6dcd29d1a3e063c3be0b40688ed76434622c53a284e5427565bbd9b", size = 227729 }, - { url = "https://files.pythonhosted.org/packages/05/64/17eb2796e2d1c3d0c431dc5f40078d7282f4645af0bb4da9097fbb628c6c/propcache-0.2.0-cp311-cp311-win32.whl", hash = "sha256:5f2564ec89058ee7c7989a7b719115bdfe2a2fb8e7a4543b8d1c0cc4cf6478c1", size = 40473 }, - { url = "https://files.pythonhosted.org/packages/83/c5/e89fc428ccdc897ade08cd7605f174c69390147526627a7650fb883e0cd0/propcache-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e2e54267980349b723cff366d1e29b138b9a60fa376664a157a342689553f71", size = 44921 }, - { url = "https://files.pythonhosted.org/packages/7c/46/a41ca1097769fc548fc9216ec4c1471b772cc39720eb47ed7e38ef0006a9/propcache-0.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ee7606193fb267be4b2e3b32714f2d58cad27217638db98a60f9efb5efeccc2", size = 80800 }, - { url = "https://files.pythonhosted.org/packages/75/4f/93df46aab9cc473498ff56be39b5f6ee1e33529223d7a4d8c0a6101a9ba2/propcache-0.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:91ee8fc02ca52e24bcb77b234f22afc03288e1dafbb1f88fe24db308910c4ac7", size = 46443 }, - { url = "https://files.pythonhosted.org/packages/0b/17/308acc6aee65d0f9a8375e36c4807ac6605d1f38074b1581bd4042b9fb37/propcache-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e900bad2a8456d00a113cad8c13343f3b1f327534e3589acc2219729237a2e8", size = 45676 }, - { url = "https://files.pythonhosted.org/packages/65/44/626599d2854d6c1d4530b9a05e7ff2ee22b790358334b475ed7c89f7d625/propcache-0.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f52a68c21363c45297aca15561812d542f8fc683c85201df0bebe209e349f793", size = 246191 }, - { url = "https://files.pythonhosted.org/packages/f2/df/5d996d7cb18df076debae7d76ac3da085c0575a9f2be6b1f707fe227b54c/propcache-0.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e41d67757ff4fbc8ef2af99b338bfb955010444b92929e9e55a6d4dcc3c4f09", size = 251791 }, - { url = "https://files.pythonhosted.org/packages/2e/6d/9f91e5dde8b1f662f6dd4dff36098ed22a1ef4e08e1316f05f4758f1576c/propcache-0.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a64e32f8bd94c105cc27f42d3b658902b5bcc947ece3c8fe7bc1b05982f60e89", size = 253434 }, - { url = "https://files.pythonhosted.org/packages/3c/e9/1b54b7e26f50b3e0497cd13d3483d781d284452c2c50dd2a615a92a087a3/propcache-0.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55346705687dbd7ef0d77883ab4f6fabc48232f587925bdaf95219bae072491e", size = 248150 }, - { url = "https://files.pythonhosted.org/packages/a7/ef/a35bf191c8038fe3ce9a414b907371c81d102384eda5dbafe6f4dce0cf9b/propcache-0.2.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00181262b17e517df2cd85656fcd6b4e70946fe62cd625b9d74ac9977b64d8d9", size = 233568 }, - { url = "https://files.pythonhosted.org/packages/97/d9/d00bb9277a9165a5e6d60f2142cd1a38a750045c9c12e47ae087f686d781/propcache-0.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6994984550eaf25dd7fc7bd1b700ff45c894149341725bb4edc67f0ffa94efa4", size = 229874 }, - { url = "https://files.pythonhosted.org/packages/8e/78/c123cf22469bdc4b18efb78893e69c70a8b16de88e6160b69ca6bdd88b5d/propcache-0.2.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:56295eb1e5f3aecd516d91b00cfd8bf3a13991de5a479df9e27dd569ea23959c", size = 225857 }, - { url = "https://files.pythonhosted.org/packages/31/1b/fd6b2f1f36d028820d35475be78859d8c89c8f091ad30e377ac49fd66359/propcache-0.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:439e76255daa0f8151d3cb325f6dd4a3e93043e6403e6491813bcaaaa8733887", size = 227604 }, - { url = "https://files.pythonhosted.org/packages/99/36/b07be976edf77a07233ba712e53262937625af02154353171716894a86a6/propcache-0.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f6475a1b2ecb310c98c28d271a30df74f9dd436ee46d09236a6b750a7599ce57", size = 238430 }, - { url = "https://files.pythonhosted.org/packages/0d/64/5822f496c9010e3966e934a011ac08cac8734561842bc7c1f65586e0683c/propcache-0.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3444cdba6628accf384e349014084b1cacd866fbb88433cd9d279d90a54e0b23", size = 244814 }, - { url = "https://files.pythonhosted.org/packages/fd/bd/8657918a35d50b18a9e4d78a5df7b6c82a637a311ab20851eef4326305c1/propcache-0.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a9d9b4d0a9b38d1c391bb4ad24aa65f306c6f01b512e10a8a34a2dc5675d348", size = 235922 }, - { url = "https://files.pythonhosted.org/packages/a8/6f/ec0095e1647b4727db945213a9f395b1103c442ef65e54c62e92a72a3f75/propcache-0.2.0-cp312-cp312-win32.whl", hash = "sha256:69d3a98eebae99a420d4b28756c8ce6ea5a29291baf2dc9ff9414b42676f61d5", size = 40177 }, - { url = "https://files.pythonhosted.org/packages/20/a2/bd0896fdc4f4c1db46d9bc361c8c79a9bf08ccc08ba054a98e38e7ba1557/propcache-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad9c9b99b05f163109466638bd30ada1722abb01bbb85c739c50b6dc11f92dc3", size = 44446 }, - { url = "https://files.pythonhosted.org/packages/a8/a7/5f37b69197d4f558bfef5b4bceaff7c43cc9b51adf5bd75e9081d7ea80e4/propcache-0.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecddc221a077a8132cf7c747d5352a15ed763b674c0448d811f408bf803d9ad7", size = 78120 }, - { url = "https://files.pythonhosted.org/packages/c8/cd/48ab2b30a6b353ecb95a244915f85756d74f815862eb2ecc7a518d565b48/propcache-0.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0e53cb83fdd61cbd67202735e6a6687a7b491c8742dfc39c9e01e80354956763", size = 45127 }, - { url = "https://files.pythonhosted.org/packages/a5/ba/0a1ef94a3412aab057bd996ed5f0ac7458be5bf469e85c70fa9ceb43290b/propcache-0.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92fe151145a990c22cbccf9ae15cae8ae9eddabfc949a219c9f667877e40853d", size = 44419 }, - { url = "https://files.pythonhosted.org/packages/b4/6c/ca70bee4f22fa99eacd04f4d2f1699be9d13538ccf22b3169a61c60a27fa/propcache-0.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a21ef516d36909931a2967621eecb256018aeb11fc48656e3257e73e2e247a", size = 229611 }, - { url = "https://files.pythonhosted.org/packages/19/70/47b872a263e8511ca33718d96a10c17d3c853aefadeb86dc26e8421184b9/propcache-0.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f88a4095e913f98988f5b338c1d4d5d07dbb0b6bad19892fd447484e483ba6b", size = 234005 }, - { url = "https://files.pythonhosted.org/packages/4f/be/3b0ab8c84a22e4a3224719099c1229ddfdd8a6a1558cf75cb55ee1e35c25/propcache-0.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a5b3bb545ead161be780ee85a2b54fdf7092815995661947812dde94a40f6fb", size = 237270 }, - { url = "https://files.pythonhosted.org/packages/04/d8/f071bb000d4b8f851d312c3c75701e586b3f643fe14a2e3409b1b9ab3936/propcache-0.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67aeb72e0f482709991aa91345a831d0b707d16b0257e8ef88a2ad246a7280bf", size = 231877 }, - { url = "https://files.pythonhosted.org/packages/93/e7/57a035a1359e542bbb0a7df95aad6b9871ebee6dce2840cb157a415bd1f3/propcache-0.2.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c997f8c44ec9b9b0bcbf2d422cc00a1d9b9c681f56efa6ca149a941e5560da2", size = 217848 }, - { url = "https://files.pythonhosted.org/packages/f0/93/d1dea40f112ec183398fb6c42fde340edd7bab202411c4aa1a8289f461b6/propcache-0.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a66df3d4992bc1d725b9aa803e8c5a66c010c65c741ad901e260ece77f58d2f", size = 216987 }, - { url = "https://files.pythonhosted.org/packages/62/4c/877340871251145d3522c2b5d25c16a1690ad655fbab7bb9ece6b117e39f/propcache-0.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3ebbcf2a07621f29638799828b8d8668c421bfb94c6cb04269130d8de4fb7136", size = 212451 }, - { url = "https://files.pythonhosted.org/packages/7c/bb/a91b72efeeb42906ef58ccf0cdb87947b54d7475fee3c93425d732f16a61/propcache-0.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1235c01ddaa80da8235741e80815ce381c5267f96cc49b1477fdcf8c047ef325", size = 212879 }, - { url = "https://files.pythonhosted.org/packages/9b/7f/ee7fea8faac57b3ec5d91ff47470c6c5d40d7f15d0b1fccac806348fa59e/propcache-0.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3947483a381259c06921612550867b37d22e1df6d6d7e8361264b6d037595f44", size = 222288 }, - { url = "https://files.pythonhosted.org/packages/ff/d7/acd67901c43d2e6b20a7a973d9d5fd543c6e277af29b1eb0e1f7bd7ca7d2/propcache-0.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d5bed7f9805cc29c780f3aee05de3262ee7ce1f47083cfe9f77471e9d6777e83", size = 228257 }, - { url = "https://files.pythonhosted.org/packages/8d/6f/6272ecc7a8daad1d0754cfc6c8846076a8cb13f810005c79b15ce0ef0cf2/propcache-0.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4a91d44379f45f5e540971d41e4626dacd7f01004826a18cb048e7da7e96544", size = 221075 }, - { url = "https://files.pythonhosted.org/packages/7c/bd/c7a6a719a6b3dd8b3aeadb3675b5783983529e4a3185946aa444d3e078f6/propcache-0.2.0-cp313-cp313-win32.whl", hash = "sha256:f902804113e032e2cdf8c71015651c97af6418363bea8d78dc0911d56c335032", size = 39654 }, - { url = "https://files.pythonhosted.org/packages/88/e7/0eef39eff84fa3e001b44de0bd41c7c0e3432e7648ffd3d64955910f002d/propcache-0.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f188cfcc64fb1266f4684206c9de0e80f54622c3f22a910cbd200478aeae61e", size = 43705 }, - { url = "https://files.pythonhosted.org/packages/3d/b6/e6d98278f2d49b22b4d033c9f792eda783b9ab2094b041f013fc69bcde87/propcache-0.2.0-py3-none-any.whl", hash = "sha256:2ccc28197af5313706511fab3a8b66dcd6da067a1331372c82ea1cb74285e036", size = 11603 }, +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/c8/2a13f78d82211490855b2fb303b6721348d0787fdd9a12ac46d99d3acde1/propcache-0.2.1.tar.gz", hash = "sha256:3f77ce728b19cb537714499928fe800c3dda29e8d9428778fc7c186da4c09a64", size = 41735 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/0f/2913b6791ebefb2b25b4efd4bb2299c985e09786b9f5b19184a88e5778dd/propcache-0.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ffc3cca89bb438fb9c95c13fc874012f7b9466b89328c3c8b1aa93cdcfadd16", size = 79297 }, + { url = "https://files.pythonhosted.org/packages/cf/73/af2053aeccd40b05d6e19058419ac77674daecdd32478088b79375b9ab54/propcache-0.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f174bbd484294ed9fdf09437f889f95807e5f229d5d93588d34e92106fbf6717", size = 45611 }, + { url = "https://files.pythonhosted.org/packages/3c/09/8386115ba7775ea3b9537730e8cf718d83bbf95bffe30757ccf37ec4e5da/propcache-0.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:70693319e0b8fd35dd863e3e29513875eb15c51945bf32519ef52927ca883bc3", size = 45146 }, + { url = "https://files.pythonhosted.org/packages/03/7a/793aa12f0537b2e520bf09f4c6833706b63170a211ad042ca71cbf79d9cb/propcache-0.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b480c6a4e1138e1aa137c0079b9b6305ec6dcc1098a8ca5196283e8a49df95a9", size = 232136 }, + { url = "https://files.pythonhosted.org/packages/f1/38/b921b3168d72111769f648314100558c2ea1d52eb3d1ba7ea5c4aa6f9848/propcache-0.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d27b84d5880f6d8aa9ae3edb253c59d9f6642ffbb2c889b78b60361eed449787", size = 239706 }, + { url = "https://files.pythonhosted.org/packages/14/29/4636f500c69b5edea7786db3c34eb6166f3384b905665ce312a6e42c720c/propcache-0.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:857112b22acd417c40fa4595db2fe28ab900c8c5fe4670c7989b1c0230955465", size = 238531 }, + { url = "https://files.pythonhosted.org/packages/85/14/01fe53580a8e1734ebb704a3482b7829a0ef4ea68d356141cf0994d9659b/propcache-0.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf6c4150f8c0e32d241436526f3c3f9cbd34429492abddbada2ffcff506c51af", size = 231063 }, + { url = "https://files.pythonhosted.org/packages/33/5c/1d961299f3c3b8438301ccfbff0143b69afcc30c05fa28673cface692305/propcache-0.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66d4cfda1d8ed687daa4bc0274fcfd5267873db9a5bc0418c2da19273040eeb7", size = 220134 }, + { url = "https://files.pythonhosted.org/packages/00/d0/ed735e76db279ba67a7d3b45ba4c654e7b02bc2f8050671ec365d8665e21/propcache-0.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2f992c07c0fca81655066705beae35fc95a2fa7366467366db627d9f2ee097f", size = 220009 }, + { url = "https://files.pythonhosted.org/packages/75/90/ee8fab7304ad6533872fee982cfff5a53b63d095d78140827d93de22e2d4/propcache-0.2.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4a571d97dbe66ef38e472703067021b1467025ec85707d57e78711c085984e54", size = 212199 }, + { url = "https://files.pythonhosted.org/packages/eb/ec/977ffaf1664f82e90737275873461695d4c9407d52abc2f3c3e24716da13/propcache-0.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bb6178c241278d5fe853b3de743087be7f5f4c6f7d6d22a3b524d323eecec505", size = 214827 }, + { url = "https://files.pythonhosted.org/packages/57/48/031fb87ab6081764054821a71b71942161619549396224cbb242922525e8/propcache-0.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ad1af54a62ffe39cf34db1aa6ed1a1873bd548f6401db39d8e7cd060b9211f82", size = 228009 }, + { url = "https://files.pythonhosted.org/packages/1a/06/ef1390f2524850838f2390421b23a8b298f6ce3396a7cc6d39dedd4047b0/propcache-0.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e7048abd75fe40712005bcfc06bb44b9dfcd8e101dda2ecf2f5aa46115ad07ca", size = 231638 }, + { url = "https://files.pythonhosted.org/packages/38/2a/101e6386d5a93358395da1d41642b79c1ee0f3b12e31727932b069282b1d/propcache-0.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:160291c60081f23ee43d44b08a7e5fb76681221a8e10b3139618c5a9a291b84e", size = 222788 }, + { url = "https://files.pythonhosted.org/packages/db/81/786f687951d0979007e05ad9346cd357e50e3d0b0f1a1d6074df334b1bbb/propcache-0.2.1-cp311-cp311-win32.whl", hash = "sha256:819ce3b883b7576ca28da3861c7e1a88afd08cc8c96908e08a3f4dd64a228034", size = 40170 }, + { url = "https://files.pythonhosted.org/packages/cf/59/7cc7037b295d5772eceb426358bb1b86e6cab4616d971bd74275395d100d/propcache-0.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:edc9fc7051e3350643ad929df55c451899bb9ae6d24998a949d2e4c87fb596d3", size = 44404 }, + { url = "https://files.pythonhosted.org/packages/4c/28/1d205fe49be8b1b4df4c50024e62480a442b1a7b818e734308bb0d17e7fb/propcache-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:081a430aa8d5e8876c6909b67bd2d937bfd531b0382d3fdedb82612c618bc41a", size = 79588 }, + { url = "https://files.pythonhosted.org/packages/21/ee/fc4d893f8d81cd4971affef2a6cb542b36617cd1d8ce56b406112cb80bf7/propcache-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ccec9ac47cf4e04897619c0e0c1a48c54a71bdf045117d3a26f80d38ab1fb0", size = 45825 }, + { url = "https://files.pythonhosted.org/packages/4a/de/bbe712f94d088da1d237c35d735f675e494a816fd6f54e9db2f61ef4d03f/propcache-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14d86fe14b7e04fa306e0c43cdbeebe6b2c2156a0c9ce56b815faacc193e320d", size = 45357 }, + { url = "https://files.pythonhosted.org/packages/7f/14/7ae06a6cf2a2f1cb382586d5a99efe66b0b3d0c6f9ac2f759e6f7af9d7cf/propcache-0.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:049324ee97bb67285b49632132db351b41e77833678432be52bdd0289c0e05e4", size = 241869 }, + { url = "https://files.pythonhosted.org/packages/cc/59/227a78be960b54a41124e639e2c39e8807ac0c751c735a900e21315f8c2b/propcache-0.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1cd9a1d071158de1cc1c71a26014dcdfa7dd3d5f4f88c298c7f90ad6f27bb46d", size = 247884 }, + { url = "https://files.pythonhosted.org/packages/84/58/f62b4ffaedf88dc1b17f04d57d8536601e4e030feb26617228ef930c3279/propcache-0.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98110aa363f1bb4c073e8dcfaefd3a5cea0f0834c2aab23dda657e4dab2f53b5", size = 248486 }, + { url = "https://files.pythonhosted.org/packages/1c/07/ebe102777a830bca91bbb93e3479cd34c2ca5d0361b83be9dbd93104865e/propcache-0.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:647894f5ae99c4cf6bb82a1bb3a796f6e06af3caa3d32e26d2350d0e3e3faf24", size = 243649 }, + { url = "https://files.pythonhosted.org/packages/ed/bc/4f7aba7f08f520376c4bb6a20b9a981a581b7f2e385fa0ec9f789bb2d362/propcache-0.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfd3223c15bebe26518d58ccf9a39b93948d3dcb3e57a20480dfdd315356baff", size = 229103 }, + { url = "https://files.pythonhosted.org/packages/fe/d5/04ac9cd4e51a57a96f78795e03c5a0ddb8f23ec098b86f92de028d7f2a6b/propcache-0.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d71264a80f3fcf512eb4f18f59423fe82d6e346ee97b90625f283df56aee103f", size = 226607 }, + { url = "https://files.pythonhosted.org/packages/e3/f0/24060d959ea41d7a7cc7fdbf68b31852331aabda914a0c63bdb0e22e96d6/propcache-0.2.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e73091191e4280403bde6c9a52a6999d69cdfde498f1fdf629105247599b57ec", size = 221153 }, + { url = "https://files.pythonhosted.org/packages/77/a7/3ac76045a077b3e4de4859a0753010765e45749bdf53bd02bc4d372da1a0/propcache-0.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3935bfa5fede35fb202c4b569bb9c042f337ca4ff7bd540a0aa5e37131659348", size = 222151 }, + { url = "https://files.pythonhosted.org/packages/e7/af/5e29da6f80cebab3f5a4dcd2a3240e7f56f2c4abf51cbfcc99be34e17f0b/propcache-0.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f508b0491767bb1f2b87fdfacaba5f7eddc2f867740ec69ece6d1946d29029a6", size = 233812 }, + { url = "https://files.pythonhosted.org/packages/8c/89/ebe3ad52642cc5509eaa453e9f4b94b374d81bae3265c59d5c2d98efa1b4/propcache-0.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1672137af7c46662a1c2be1e8dc78cb6d224319aaa40271c9257d886be4363a6", size = 238829 }, + { url = "https://files.pythonhosted.org/packages/e9/2f/6b32f273fa02e978b7577159eae7471b3cfb88b48563b1c2578b2d7ca0bb/propcache-0.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b74c261802d3d2b85c9df2dfb2fa81b6f90deeef63c2db9f0e029a3cac50b518", size = 230704 }, + { url = "https://files.pythonhosted.org/packages/5c/2e/f40ae6ff5624a5f77edd7b8359b208b5455ea113f68309e2b00a2e1426b6/propcache-0.2.1-cp312-cp312-win32.whl", hash = "sha256:d09c333d36c1409d56a9d29b3a1b800a42c76a57a5a8907eacdbce3f18768246", size = 40050 }, + { url = "https://files.pythonhosted.org/packages/3b/77/a92c3ef994e47180862b9d7d11e37624fb1c00a16d61faf55115d970628b/propcache-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:c214999039d4f2a5b2073ac506bba279945233da8c786e490d411dfc30f855c1", size = 44117 }, + { url = "https://files.pythonhosted.org/packages/0f/2a/329e0547cf2def8857157f9477669043e75524cc3e6251cef332b3ff256f/propcache-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aca405706e0b0a44cc6bfd41fbe89919a6a56999157f6de7e182a990c36e37bc", size = 77002 }, + { url = "https://files.pythonhosted.org/packages/12/2d/c4df5415e2382f840dc2ecbca0eeb2293024bc28e57a80392f2012b4708c/propcache-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:12d1083f001ace206fe34b6bdc2cb94be66d57a850866f0b908972f90996b3e9", size = 44639 }, + { url = "https://files.pythonhosted.org/packages/d0/5a/21aaa4ea2f326edaa4e240959ac8b8386ea31dedfdaa636a3544d9e7a408/propcache-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d93f3307ad32a27bda2e88ec81134b823c240aa3abb55821a8da553eed8d9439", size = 44049 }, + { url = "https://files.pythonhosted.org/packages/4e/3e/021b6cd86c0acc90d74784ccbb66808b0bd36067a1bf3e2deb0f3845f618/propcache-0.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba278acf14471d36316159c94a802933d10b6a1e117b8554fe0d0d9b75c9d536", size = 224819 }, + { url = "https://files.pythonhosted.org/packages/3c/57/c2fdeed1b3b8918b1770a133ba5c43ad3d78e18285b0c06364861ef5cc38/propcache-0.2.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4e6281aedfca15301c41f74d7005e6e3f4ca143584ba696ac69df4f02f40d629", size = 229625 }, + { url = "https://files.pythonhosted.org/packages/9d/81/70d4ff57bf2877b5780b466471bebf5892f851a7e2ca0ae7ffd728220281/propcache-0.2.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b750a8e5a1262434fb1517ddf64b5de58327f1adc3524a5e44c2ca43305eb0b", size = 232934 }, + { url = "https://files.pythonhosted.org/packages/3c/b9/bb51ea95d73b3fb4100cb95adbd4e1acaf2cbb1fd1083f5468eeb4a099a8/propcache-0.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf72af5e0fb40e9babf594308911436c8efde3cb5e75b6f206c34ad18be5c052", size = 227361 }, + { url = "https://files.pythonhosted.org/packages/f1/20/3c6d696cd6fd70b29445960cc803b1851a1131e7a2e4ee261ee48e002bcd/propcache-0.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2d0a12018b04f4cb820781ec0dffb5f7c7c1d2a5cd22bff7fb055a2cb19ebce", size = 213904 }, + { url = "https://files.pythonhosted.org/packages/a1/cb/1593bfc5ac6d40c010fa823f128056d6bc25b667f5393781e37d62f12005/propcache-0.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e800776a79a5aabdb17dcc2346a7d66d0777e942e4cd251defeb084762ecd17d", size = 212632 }, + { url = "https://files.pythonhosted.org/packages/6d/5c/e95617e222be14a34c709442a0ec179f3207f8a2b900273720501a70ec5e/propcache-0.2.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4160d9283bd382fa6c0c2b5e017acc95bc183570cd70968b9202ad6d8fc48dce", size = 207897 }, + { url = "https://files.pythonhosted.org/packages/8e/3b/56c5ab3dc00f6375fbcdeefdede5adf9bee94f1fab04adc8db118f0f9e25/propcache-0.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b43e74f1359353341a7adb783c8f1b1c676367b011709f466f42fda2045e95", size = 208118 }, + { url = "https://files.pythonhosted.org/packages/86/25/d7ef738323fbc6ebcbce33eb2a19c5e07a89a3df2fded206065bd5e868a9/propcache-0.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:58791550b27d5488b1bb52bc96328456095d96206a250d28d874fafe11b3dfaf", size = 217851 }, + { url = "https://files.pythonhosted.org/packages/b3/77/763e6cef1852cf1ba740590364ec50309b89d1c818e3256d3929eb92fabf/propcache-0.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0f022d381747f0dfe27e99d928e31bc51a18b65bb9e481ae0af1380a6725dd1f", size = 222630 }, + { url = "https://files.pythonhosted.org/packages/4f/e9/0f86be33602089c701696fbed8d8c4c07b6ee9605c5b7536fd27ed540c5b/propcache-0.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:297878dc9d0a334358f9b608b56d02e72899f3b8499fc6044133f0d319e2ec30", size = 216269 }, + { url = "https://files.pythonhosted.org/packages/cc/02/5ac83217d522394b6a2e81a2e888167e7ca629ef6569a3f09852d6dcb01a/propcache-0.2.1-cp313-cp313-win32.whl", hash = "sha256:ddfab44e4489bd79bda09d84c430677fc7f0a4939a73d2bba3073036f487a0a6", size = 39472 }, + { url = "https://files.pythonhosted.org/packages/f4/33/d6f5420252a36034bc8a3a01171bc55b4bff5df50d1c63d9caa50693662f/propcache-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:556fc6c10989f19a179e4321e5d678db8eb2924131e64652a51fe83e4c3db0e1", size = 43363 }, + { url = "https://files.pythonhosted.org/packages/41/b6/c5319caea262f4821995dca2107483b94a3345d4607ad797c76cb9c36bcc/propcache-0.2.1-py3-none-any.whl", hash = "sha256:52277518d6aae65536e9cea52d4e7fd2f7a66f4aa2d30ed3f2fcea620ace3c54", size = 11818 }, ] [[package]] @@ -960,7 +963,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.3.3" +version = "8.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -968,21 +971,21 @@ dependencies = [ { name = "packaging" }, { name = "pluggy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, ] [[package]] name = "pytest-asyncio" -version = "0.24.0" +version = "0.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855 } +sdist = { url = "https://files.pythonhosted.org/packages/94/18/82fcb4ee47d66d99f6cd1efc0b11b2a25029f303c599a5afda7c1bca4254/pytest_asyncio-0.25.0.tar.gz", hash = "sha256:8c0610303c9e0442a5db8604505fc0f545456ba1528824842b37b4a626cbf609", size = 53298 } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 }, + { url = "https://files.pythonhosted.org/packages/88/56/2ee0cab25c11d4e38738a2a98c645a8f002e2ecf7b5ed774c70d53b92bb1/pytest_asyncio-0.25.0-py3-none-any.whl", hash = "sha256:db5432d18eac6b7e28b46dcd9b69921b55c3b1086e85febfe04e70b18d9e81b3", size = 19245 }, ] [[package]] @@ -1000,15 +1003,15 @@ wheels = [ [[package]] name = "pytest-freezer" -version = "0.4.8" +version = "0.4.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "freezegun" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/fa/a93d40dd50f712c276a5a15f9c075bee932cc4d28c376e60b4a35904976d/pytest_freezer-0.4.8.tar.gz", hash = "sha256:8ee2f724b3ff3540523fa355958a22e6f4c1c819928b78a7a183ae4248ce6ee6", size = 3212 } +sdist = { url = "https://files.pythonhosted.org/packages/81/f0/98dcbc5324064360b19850b14c84cea9ca50785d921741dbfc442346e925/pytest_freezer-0.4.9.tar.gz", hash = "sha256:21bf16bc9cc46bf98f94382c4b5c3c389be7056ff0be33029111ae11b3f1c82a", size = 3177 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/4e/ba488639516a341810aeaeb4b32b70abb0923e53f7c4d14d673dc114d35a/pytest_freezer-0.4.8-py3-none-any.whl", hash = "sha256:644ce7ddb8ba52b92a1df0a80a699bad2b93514c55cf92e9f2517b68ebe74814", size = 3228 }, + { url = "https://files.pythonhosted.org/packages/c1/e9/30252bc05bcf67200a17f4f0b4cc7598f0a68df4fa9fa356193aa899f145/pytest_freezer-0.4.9-py3-none-any.whl", hash = "sha256:8b6c50523b7d4aec4590b52bfa5ff766d772ce506e2bf4846c88041ea9ccae59", size = 3192 }, ] [[package]] @@ -1088,7 +1091,7 @@ wheels = [ [[package]] name = "python-kasa" -version = "0.8.1" +version = "0.9.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -1265,11 +1268,11 @@ wheels = [ [[package]] name = "six" -version = "1.16.0" +version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, ] [[package]] @@ -1381,14 +1384,14 @@ wheels = [ [[package]] name = "sphinxcontrib-programoutput" -version = "0.17" +version = "0.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/fe/8a6d8763674b3d3814a6008a83eb8002b6da188710dd7f4654ec77b4a8ac/sphinxcontrib-programoutput-0.17.tar.gz", hash = "sha256:300ee9b8caee8355d25cc74b4d1c7efd12e608d2ad165e3141d31e6fbc152b7f", size = 24067 } +sdist = { url = "https://files.pythonhosted.org/packages/3f/c0/834af2290f8477213ec0dd60e90104f5644aa0c37b1a0d6f0a2b5efe03c4/sphinxcontrib_programoutput-0.18.tar.gz", hash = "sha256:09e68b6411d937a80b6085f4fdeaa42e0dc5555480385938465f410589d2eed8", size = 26333 } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/ee/b7be4b3f45f4e36bfa6c444cd234098e0d09880379c67a43e6bb9ab99a86/sphinxcontrib_programoutput-0.17-py2.py3-none-any.whl", hash = "sha256:0ef1c1d9159dbe7103077748214305eb4e0138e861feb71c0c346afc5fe97f84", size = 22131 }, + { url = "https://files.pythonhosted.org/packages/04/2c/7aec6e0580f666d4f61474a50c4995a98abfff27d827f0e7bc8c4fa528f5/sphinxcontrib_programoutput-0.18-py3-none-any.whl", hash = "sha256:8a651bc85de69a808a064ff0e48d06c12b9347da4fe5fdb1e94914b01e1b0c36", size = 20346 }, ] [[package]] @@ -1429,11 +1432,41 @@ wheels = [ [[package]] name = "tomli" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1e/e4/1b6cbcc82d8832dd0ce34767d5c560df8a3547ad8cbc427f34601415930a/tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8", size = 16622 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/f7/4da0ffe1892122c9ea096c57f64c2753ae5dd3ce85488802d11b0992cc6d/tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391", size = 13750 }, +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] [[package]] @@ -1506,62 +1539,62 @@ wheels = [ [[package]] name = "yarl" -version = "1.18.0" +version = "1.18.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/4b/53db4ecad4d54535aff3dfda1f00d6363d79455f62b11b8ca97b82746bd2/yarl-1.18.0.tar.gz", hash = "sha256:20d95535e7d833889982bfe7cc321b7f63bf8879788fee982c76ae2b24cfb715", size = 180098 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/45/6ad7135d1c4ad3a6a49e2c37dc78a1805a7871879c03c3495d64c9605d49/yarl-1.18.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b8e8c516dc4e1a51d86ac975b0350735007e554c962281c432eaa5822aa9765c", size = 141283 }, - { url = "https://files.pythonhosted.org/packages/45/6d/24b70ae33107d6eba303ed0ebfdf1164fe2219656e7594ca58628ebc0f1d/yarl-1.18.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e6b4466714a73f5251d84b471475850954f1fa6acce4d3f404da1d55d644c34", size = 94082 }, - { url = "https://files.pythonhosted.org/packages/8a/0e/da720989be11b662ca847ace58f468b52310a9b03e52ac62c144755f9d75/yarl-1.18.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c893f8c1a6d48b25961e00922724732d00b39de8bb0b451307482dc87bddcd74", size = 92017 }, - { url = "https://files.pythonhosted.org/packages/f5/76/e5c91681fa54658943cb88673fb19b3355c3a8ae911a33a2621b6320990d/yarl-1.18.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13aaf2bdbc8c86ddce48626b15f4987f22e80d898818d735b20bd58f17292ee8", size = 340359 }, - { url = "https://files.pythonhosted.org/packages/cf/77/02cf72f09dea20980dea4ebe40dfb2c24916b864aec869a19f715428e0f0/yarl-1.18.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd21c0128e301851de51bc607b0a6da50e82dc34e9601f4b508d08cc89ee7929", size = 356336 }, - { url = "https://files.pythonhosted.org/packages/17/66/83a88d04e4fc243dd26109f3e3d6412f67819ab1142dadbce49706ef4df4/yarl-1.18.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:205de377bd23365cd85562c9c6c33844050a93661640fda38e0567d2826b50df", size = 353730 }, - { url = "https://files.pythonhosted.org/packages/76/77/0b205a532d22756ab250ab21924d362f910a23d641c82faec1c4ad7f6077/yarl-1.18.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed69af4fe2a0949b1ea1d012bf065c77b4c7822bad4737f17807af2adb15a73c", size = 343882 }, - { url = "https://files.pythonhosted.org/packages/0b/47/2081ddce3da6096889c3947bdc21907d0fa15939909b10219254fe116841/yarl-1.18.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e1c18890091aa3cc8a77967943476b729dc2016f4cfe11e45d89b12519d4a93", size = 335873 }, - { url = "https://files.pythonhosted.org/packages/25/3c/437304394494e757ae927c9a81bacc4bcdf7351a1d4e811d95b02cb6dbae/yarl-1.18.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:91b8fb9427e33f83ca2ba9501221ffaac1ecf0407f758c4d2f283c523da185ee", size = 347725 }, - { url = "https://files.pythonhosted.org/packages/c6/fb/fa6c642bc052fbe6370ed5da765579650510157dea354fe9e8177c3bc34a/yarl-1.18.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:536a7a8a53b75b2e98ff96edb2dfb91a26b81c4fed82782035767db5a465be46", size = 346161 }, - { url = "https://files.pythonhosted.org/packages/b0/09/8c0cf68a0fcfe3b060c9e5857bb35735bc72a4cf4075043632c636d007e9/yarl-1.18.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a64619a9c47c25582190af38e9eb382279ad42e1f06034f14d794670796016c0", size = 349924 }, - { url = "https://files.pythonhosted.org/packages/bf/4b/1efe10fd51e2cedf53195d688fa270efbcd64a015c61d029d49c20bf0af7/yarl-1.18.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c73a6bbc97ba1b5a0c3c992ae93d721c395bdbb120492759b94cc1ac71bc6350", size = 361865 }, - { url = "https://files.pythonhosted.org/packages/0b/1b/2b5efd6df06bf938f7e154dee8e2ab22d148f3311a92bf4da642aaaf2fc5/yarl-1.18.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a173401d7821a2a81c7b47d4e7d5c4021375a1441af0c58611c1957445055056", size = 366030 }, - { url = "https://files.pythonhosted.org/packages/f8/db/786a5684f79278e62271038a698f56a51960f9e643be5d3eff82712f0b1c/yarl-1.18.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7520e799b1f84e095cce919bd6c23c9d49472deeef25fe1ef960b04cca51c3fc", size = 358902 }, - { url = "https://files.pythonhosted.org/packages/91/2f/437d0de062f1a3e3cb17573971b3832232443241133580c2ba3da5001d06/yarl-1.18.0-cp311-cp311-win32.whl", hash = "sha256:c4cb992d8090d5ae5f7afa6754d7211c578be0c45f54d3d94f7781c495d56716", size = 84138 }, - { url = "https://files.pythonhosted.org/packages/9d/85/035719a9266bce85ecde820aa3f8c46f3b18c3d7ba9ff51367b2fa4ae2a2/yarl-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:52c136f348605974c9b1c878addd6b7a60e3bf2245833e370862009b86fa4689", size = 90765 }, - { url = "https://files.pythonhosted.org/packages/23/36/c579b80a5c76c0d41c8e08baddb3e6940dfc20569db579a5691392c52afa/yarl-1.18.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1ece25e2251c28bab737bdf0519c88189b3dd9492dc086a1d77336d940c28ced", size = 142376 }, - { url = "https://files.pythonhosted.org/packages/0c/5f/e247dc7c0607a0c505fea6c839721844bee55686dfb183c7d7b8ef8a9cb1/yarl-1.18.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:454902dc1830d935c90b5b53c863ba2a98dcde0fbaa31ca2ed1ad33b2a7171c6", size = 94692 }, - { url = "https://files.pythonhosted.org/packages/eb/e1/3081b578a6f21961711b9a1c49c2947abb3b0d0dd9537378fb06777ce8ee/yarl-1.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01be8688fc211dc237e628fcc209dda412d35de7642453059a0553747018d075", size = 92527 }, - { url = "https://files.pythonhosted.org/packages/2f/fa/d9e1b9fbafa4cc82cd3980b5314741b33c2fe16308d725449a23aed32021/yarl-1.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d26f1fa9fa2167bb238f6f4b20218eb4e88dd3ef21bb8f97439fa6b5313e30d", size = 332096 }, - { url = "https://files.pythonhosted.org/packages/93/b6/dd27165114317875838e216214fb86338dc63d2e50855a8f2a12de2a7fe5/yarl-1.18.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b234a4a9248a9f000b7a5dfe84b8cb6210ee5120ae70eb72a4dcbdb4c528f72f", size = 342047 }, - { url = "https://files.pythonhosted.org/packages/fc/9f/bad434b5279ae7a356844e14dc771c3d29eb928140bbc01621af811c8a27/yarl-1.18.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe94d1de77c4cd8caff1bd5480e22342dbd54c93929f5943495d9c1e8abe9f42", size = 341712 }, - { url = "https://files.pythonhosted.org/packages/9a/9f/63864f43d131ba8c8cdf1bde5dd3f02f0eff8a7c883a5d7fad32f204fda5/yarl-1.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4c90c5363c6b0a54188122b61edb919c2cd1119684999d08cd5e538813a28e", size = 336654 }, - { url = "https://files.pythonhosted.org/packages/20/30/b4542bbd9be73de155213207eec019f6fe6495885f7dd59aa1ff705a041b/yarl-1.18.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49a98ecadc5a241c9ba06de08127ee4796e1009555efd791bac514207862b43d", size = 325484 }, - { url = "https://files.pythonhosted.org/packages/69/bc/e2a9808ec26989cf0d1b98fe7b3cc45c1c6506b5ea4fe43ece5991f28f34/yarl-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9106025c7f261f9f5144f9aa7681d43867eed06349a7cfb297a1bc804de2f0d1", size = 344213 }, - { url = "https://files.pythonhosted.org/packages/e2/17/0ee5a68886aca1a8071b0d24a1e1c0fd9970dead2ef2d5e26e027fb7ce88/yarl-1.18.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f275ede6199d0f1ed4ea5d55a7b7573ccd40d97aee7808559e1298fe6efc8dbd", size = 340517 }, - { url = "https://files.pythonhosted.org/packages/fd/db/1fe4ef38ee852bff5ec8f5367d718b3a7dac7520f344b8e50306f68a2940/yarl-1.18.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f7edeb1dcc7f50a2c8e08b9dc13a413903b7817e72273f00878cb70e766bdb3b", size = 346234 }, - { url = "https://files.pythonhosted.org/packages/b4/ee/5e5bccdb821eb9949ba66abb4d19e3299eee00282e37b42f65236120e892/yarl-1.18.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c083f6dd6951b86e484ebfc9c3524b49bcaa9c420cb4b2a78ef9f7a512bfcc85", size = 359625 }, - { url = "https://files.pythonhosted.org/packages/3f/43/95a64d9e7ab4aa1c34fc5ea0edb35b581bc6ad33fd960a8ae34c2040b319/yarl-1.18.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:80741ec5b471fbdfb997821b2842c59660a1c930ceb42f8a84ba8ca0f25a66aa", size = 364239 }, - { url = "https://files.pythonhosted.org/packages/40/19/09ce976c624c9d3cc898f0be5035ddef0c0759d85b2313321cfe77b69915/yarl-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1a3297b9cad594e1ff0c040d2881d7d3a74124a3c73e00c3c71526a1234a9f7", size = 357599 }, - { url = "https://files.pythonhosted.org/packages/7d/35/6f33fd29791af2ec161aebe8abe63e788c2b74a6c7e8f29c92e5f5e96849/yarl-1.18.0-cp312-cp312-win32.whl", hash = "sha256:cd6ab7d6776c186f544f893b45ee0c883542b35e8a493db74665d2e594d3ca75", size = 83832 }, - { url = "https://files.pythonhosted.org/packages/4e/8e/cdb40ef98597be107de67b11e2f1f23f911e0f1416b938885d17a338e304/yarl-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:039c299a0864d1f43c3e31570045635034ea7021db41bf4842693a72aca8df3a", size = 90132 }, - { url = "https://files.pythonhosted.org/packages/2b/77/2196b657c66f97adaef0244e9e015f30eac0df59c31ad540f79ce328feed/yarl-1.18.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6fb64dd45453225f57d82c4764818d7a205ee31ce193e9f0086e493916bd4f72", size = 140512 }, - { url = "https://files.pythonhosted.org/packages/0e/d8/2bb6e26fddba5c01bad284e4571178c651b97e8e06318efcaa16e07eb9fd/yarl-1.18.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3adaaf9c6b1b4fc258584f4443f24d775a2086aee82d1387e48a8b4f3d6aecf6", size = 93875 }, - { url = "https://files.pythonhosted.org/packages/54/e4/99fbb884dd9f814fb0037dc1783766bb9edcd57b32a76f3ec5ac5c5772d7/yarl-1.18.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:da206d1ec78438a563c5429ab808a2b23ad7bc025c8adbf08540dde202be37d5", size = 91705 }, - { url = "https://files.pythonhosted.org/packages/3b/a2/5bd86eca9449e6b15d3b08005cf4e58e3da972240c2bee427b358c311549/yarl-1.18.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:576d258b21c1db4c6449b1c572c75d03f16a482eb380be8003682bdbe7db2f28", size = 333325 }, - { url = "https://files.pythonhosted.org/packages/94/50/a218da5f159cd985685bc72c500bb1a7fd2d60035d2339b8a9d9e1f99194/yarl-1.18.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e547c0a375c4bfcdd60eef82e7e0e8698bf84c239d715f5c1278a73050393", size = 344121 }, - { url = "https://files.pythonhosted.org/packages/a4/e3/830ae465811198b4b5ebecd674b5b3dca4d222af2155eb2144bfe190bbb8/yarl-1.18.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3818eabaefb90adeb5e0f62f047310079d426387991106d4fbf3519eec7d90a", size = 345163 }, - { url = "https://files.pythonhosted.org/packages/7a/74/05c4326877ca541eee77b1ef74b7ac8081343d3957af8f9291ca6eca6fec/yarl-1.18.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5f72421246c21af6a92fbc8c13b6d4c5427dfd949049b937c3b731f2f9076bd", size = 339130 }, - { url = "https://files.pythonhosted.org/packages/29/42/842f35aa1dae25d132119ee92185e8c75d8b9b7c83346506bd31e9fa217f/yarl-1.18.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fa7d37f2ada0f42e0723632993ed422f2a679af0e200874d9d861720a54f53e", size = 326418 }, - { url = "https://files.pythonhosted.org/packages/f9/ed/65c0514f2d1e8b92a61f564c914381d078766cab38b5fbde355b3b3af1fb/yarl-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:42ba84e2ac26a3f252715f8ec17e6fdc0cbf95b9617c5367579fafcd7fba50eb", size = 345204 }, - { url = "https://files.pythonhosted.org/packages/23/31/351f64f0530c372fa01160f38330f44478e7bf3092f5ce2bfcb91605561d/yarl-1.18.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6a49ad0102c0f0ba839628d0bf45973c86ce7b590cdedf7540d5b1833ddc6f00", size = 341652 }, - { url = "https://files.pythonhosted.org/packages/49/aa/0c6e666c218d567727c1d040d01575685e7f9b18052fd68a59c9f61fe5d9/yarl-1.18.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:96404e8d5e1bbe36bdaa84ef89dc36f0e75939e060ca5cd45451aba01db02902", size = 347257 }, - { url = "https://files.pythonhosted.org/packages/36/0b/33a093b0e13bb8cd0f27301779661ff325270b6644929001f8f33307357d/yarl-1.18.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a0509475d714df8f6d498935b3f307cd122c4ca76f7d426c7e1bb791bcd87eda", size = 359735 }, - { url = "https://files.pythonhosted.org/packages/a8/92/dcc0b37c48632e71ffc2b5f8b0509347a0bde55ab5862ff755dce9dd56c4/yarl-1.18.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1ff116f0285b5c8b3b9a2680aeca29a858b3b9e0402fc79fd850b32c2bcb9f8b", size = 365982 }, - { url = "https://files.pythonhosted.org/packages/0e/39/30e2a24a7a6c628dccb13eb6c4a03db5f6cd1eb2c6cda56a61ddef764c11/yarl-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2580c1d7e66e6d29d6e11855e3b1c6381971e0edd9a5066e6c14d79bc8967af", size = 360128 }, - { url = "https://files.pythonhosted.org/packages/76/13/12b65dca23b1fb8ae44269a4d24048fd32ac90b445c985b0a46fdfa30cfe/yarl-1.18.0-cp313-cp313-win32.whl", hash = "sha256:14408cc4d34e202caba7b5ac9cc84700e3421a9e2d1b157d744d101b061a4a88", size = 309888 }, - { url = "https://files.pythonhosted.org/packages/f6/60/478d3d41a4bf0b9e7dca74d870d114e775d1ff7156b7d1e0e9972e8f97fd/yarl-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db1537e9cb846eb0ff206eac667f627794be8b71368c1ab3207ec7b6f8c5afc", size = 315459 }, - { url = "https://files.pythonhosted.org/packages/30/9c/3f7ab894a37b1520291247cbc9ea6756228d098dae5b37eec848d404a204/yarl-1.18.0-py3-none-any.whl", hash = "sha256:dbf53db46f7cf176ee01d8d98c39381440776fcda13779d269a8ba664f69bec0", size = 44840 }, +sdist = { url = "https://files.pythonhosted.org/packages/b7/9d/4b94a8e6d2b51b599516a5cb88e5bc99b4d8d4583e468057eaa29d5f0918/yarl-1.18.3.tar.gz", hash = "sha256:ac1801c45cbf77b6c99242eeff4fffb5e4e73a800b5c4ad4fc0be5def634d2e1", size = 181062 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/93/282b5f4898d8e8efaf0790ba6d10e2245d2c9f30e199d1a85cae9356098c/yarl-1.18.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8503ad47387b8ebd39cbbbdf0bf113e17330ffd339ba1144074da24c545f0069", size = 141555 }, + { url = "https://files.pythonhosted.org/packages/6d/9c/0a49af78df099c283ca3444560f10718fadb8a18dc8b3edf8c7bd9fd7d89/yarl-1.18.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:02ddb6756f8f4517a2d5e99d8b2f272488e18dd0bfbc802f31c16c6c20f22193", size = 94351 }, + { url = "https://files.pythonhosted.org/packages/5a/a1/205ab51e148fdcedad189ca8dd587794c6f119882437d04c33c01a75dece/yarl-1.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:67a283dd2882ac98cc6318384f565bffc751ab564605959df4752d42483ad889", size = 92286 }, + { url = "https://files.pythonhosted.org/packages/ed/fe/88b690b30f3f59275fb674f5f93ddd4a3ae796c2b62e5bb9ece8a4914b83/yarl-1.18.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d980e0325b6eddc81331d3f4551e2a333999fb176fd153e075c6d1c2530aa8a8", size = 340649 }, + { url = "https://files.pythonhosted.org/packages/07/eb/3b65499b568e01f36e847cebdc8d7ccb51fff716dbda1ae83c3cbb8ca1c9/yarl-1.18.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b643562c12680b01e17239be267bc306bbc6aac1f34f6444d1bded0c5ce438ca", size = 356623 }, + { url = "https://files.pythonhosted.org/packages/33/46/f559dc184280b745fc76ec6b1954de2c55595f0ec0a7614238b9ebf69618/yarl-1.18.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c017a3b6df3a1bd45b9fa49a0f54005e53fbcad16633870104b66fa1a30a29d8", size = 354007 }, + { url = "https://files.pythonhosted.org/packages/af/ba/1865d85212351ad160f19fb99808acf23aab9a0f8ff31c8c9f1b4d671fc9/yarl-1.18.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75674776d96d7b851b6498f17824ba17849d790a44d282929c42dbb77d4f17ae", size = 344145 }, + { url = "https://files.pythonhosted.org/packages/94/cb/5c3e975d77755d7b3d5193e92056b19d83752ea2da7ab394e22260a7b824/yarl-1.18.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccaa3a4b521b780a7e771cc336a2dba389a0861592bbce09a476190bb0c8b4b3", size = 336133 }, + { url = "https://files.pythonhosted.org/packages/19/89/b77d3fd249ab52a5c40859815765d35c91425b6bb82e7427ab2f78f5ff55/yarl-1.18.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d06d3005e668744e11ed80812e61efd77d70bb7f03e33c1598c301eea20efbb", size = 347967 }, + { url = "https://files.pythonhosted.org/packages/35/bd/f6b7630ba2cc06c319c3235634c582a6ab014d52311e7d7c22f9518189b5/yarl-1.18.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9d41beda9dc97ca9ab0b9888cb71f7539124bc05df02c0cff6e5acc5a19dcc6e", size = 346397 }, + { url = "https://files.pythonhosted.org/packages/18/1a/0b4e367d5a72d1f095318344848e93ea70da728118221f84f1bf6c1e39e7/yarl-1.18.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ba23302c0c61a9999784e73809427c9dbedd79f66a13d84ad1b1943802eaaf59", size = 350206 }, + { url = "https://files.pythonhosted.org/packages/b5/cf/320fff4367341fb77809a2d8d7fe75b5d323a8e1b35710aafe41fdbf327b/yarl-1.18.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6748dbf9bfa5ba1afcc7556b71cda0d7ce5f24768043a02a58846e4a443d808d", size = 362089 }, + { url = "https://files.pythonhosted.org/packages/57/cf/aadba261d8b920253204085268bad5e8cdd86b50162fcb1b10c10834885a/yarl-1.18.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0b0cad37311123211dc91eadcb322ef4d4a66008d3e1bdc404808992260e1a0e", size = 366267 }, + { url = "https://files.pythonhosted.org/packages/54/58/fb4cadd81acdee6dafe14abeb258f876e4dd410518099ae9a35c88d8097c/yarl-1.18.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fb2171a4486bb075316ee754c6d8382ea6eb8b399d4ec62fde2b591f879778a", size = 359141 }, + { url = "https://files.pythonhosted.org/packages/9a/7a/4c571597589da4cd5c14ed2a0b17ac56ec9ee7ee615013f74653169e702d/yarl-1.18.3-cp311-cp311-win32.whl", hash = "sha256:61b1a825a13bef4a5f10b1885245377d3cd0bf87cba068e1d9a88c2ae36880e1", size = 84402 }, + { url = "https://files.pythonhosted.org/packages/ae/7b/8600250b3d89b625f1121d897062f629883c2f45339623b69b1747ec65fa/yarl-1.18.3-cp311-cp311-win_amd64.whl", hash = "sha256:b9d60031cf568c627d028239693fd718025719c02c9f55df0a53e587aab951b5", size = 91030 }, + { url = "https://files.pythonhosted.org/packages/33/85/bd2e2729752ff4c77338e0102914897512e92496375e079ce0150a6dc306/yarl-1.18.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1dd4bdd05407ced96fed3d7f25dbbf88d2ffb045a0db60dbc247f5b3c5c25d50", size = 142644 }, + { url = "https://files.pythonhosted.org/packages/ff/74/1178322cc0f10288d7eefa6e4a85d8d2e28187ccab13d5b844e8b5d7c88d/yarl-1.18.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7c33dd1931a95e5d9a772d0ac5e44cac8957eaf58e3c8da8c1414de7dd27c576", size = 94962 }, + { url = "https://files.pythonhosted.org/packages/be/75/79c6acc0261e2c2ae8a1c41cf12265e91628c8c58ae91f5ff59e29c0787f/yarl-1.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b411eddcfd56a2f0cd6a384e9f4f7aa3efee14b188de13048c25b5e91f1640", size = 92795 }, + { url = "https://files.pythonhosted.org/packages/6b/32/927b2d67a412c31199e83fefdce6e645247b4fb164aa1ecb35a0f9eb2058/yarl-1.18.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436c4fc0a4d66b2badc6c5fc5ef4e47bb10e4fd9bf0c79524ac719a01f3607c2", size = 332368 }, + { url = "https://files.pythonhosted.org/packages/19/e5/859fca07169d6eceeaa4fde1997c91d8abde4e9a7c018e371640c2da2b71/yarl-1.18.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e35ef8683211db69ffe129a25d5634319a677570ab6b2eba4afa860f54eeaf75", size = 342314 }, + { url = "https://files.pythonhosted.org/packages/08/75/76b63ccd91c9e03ab213ef27ae6add2e3400e77e5cdddf8ed2dbc36e3f21/yarl-1.18.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84b2deecba4a3f1a398df819151eb72d29bfeb3b69abb145a00ddc8d30094512", size = 341987 }, + { url = "https://files.pythonhosted.org/packages/1a/e1/a097d5755d3ea8479a42856f51d97eeff7a3a7160593332d98f2709b3580/yarl-1.18.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e5a1fea0fd4f5bfa7440a47eff01d9822a65b4488f7cff83155a0f31a2ecba", size = 336914 }, + { url = "https://files.pythonhosted.org/packages/0b/42/e1b4d0e396b7987feceebe565286c27bc085bf07d61a59508cdaf2d45e63/yarl-1.18.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0e883008013c0e4aef84dcfe2a0b172c4d23c2669412cf5b3371003941f72bb", size = 325765 }, + { url = "https://files.pythonhosted.org/packages/7e/18/03a5834ccc9177f97ca1bbb245b93c13e58e8225276f01eedc4cc98ab820/yarl-1.18.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a3f356548e34a70b0172d8890006c37be92995f62d95a07b4a42e90fba54272", size = 344444 }, + { url = "https://files.pythonhosted.org/packages/c8/03/a713633bdde0640b0472aa197b5b86e90fbc4c5bc05b727b714cd8a40e6d/yarl-1.18.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccd17349166b1bee6e529b4add61727d3f55edb7babbe4069b5764c9587a8cc6", size = 340760 }, + { url = "https://files.pythonhosted.org/packages/eb/99/f6567e3f3bbad8fd101886ea0276c68ecb86a2b58be0f64077396cd4b95e/yarl-1.18.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b958ddd075ddba5b09bb0be8a6d9906d2ce933aee81100db289badbeb966f54e", size = 346484 }, + { url = "https://files.pythonhosted.org/packages/8e/a9/84717c896b2fc6cb15bd4eecd64e34a2f0a9fd6669e69170c73a8b46795a/yarl-1.18.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c7d79f7d9aabd6011004e33b22bc13056a3e3fb54794d138af57f5ee9d9032cb", size = 359864 }, + { url = "https://files.pythonhosted.org/packages/1e/2e/d0f5f1bef7ee93ed17e739ec8dbcb47794af891f7d165fa6014517b48169/yarl-1.18.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4891ed92157e5430874dad17b15eb1fda57627710756c27422200c52d8a4e393", size = 364537 }, + { url = "https://files.pythonhosted.org/packages/97/8a/568d07c5d4964da5b02621a517532adb8ec5ba181ad1687191fffeda0ab6/yarl-1.18.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce1af883b94304f493698b00d0f006d56aea98aeb49d75ec7d98cd4a777e9285", size = 357861 }, + { url = "https://files.pythonhosted.org/packages/7d/e3/924c3f64b6b3077889df9a1ece1ed8947e7b61b0a933f2ec93041990a677/yarl-1.18.3-cp312-cp312-win32.whl", hash = "sha256:f91c4803173928a25e1a55b943c81f55b8872f0018be83e3ad4938adffb77dd2", size = 84097 }, + { url = "https://files.pythonhosted.org/packages/34/45/0e055320daaabfc169b21ff6174567b2c910c45617b0d79c68d7ab349b02/yarl-1.18.3-cp312-cp312-win_amd64.whl", hash = "sha256:7e2ee16578af3b52ac2f334c3b1f92262f47e02cc6193c598502bd46f5cd1477", size = 90399 }, + { url = "https://files.pythonhosted.org/packages/30/c7/c790513d5328a8390be8f47be5d52e141f78b66c6c48f48d241ca6bd5265/yarl-1.18.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90adb47ad432332d4f0bc28f83a5963f426ce9a1a8809f5e584e704b82685dcb", size = 140789 }, + { url = "https://files.pythonhosted.org/packages/30/aa/a2f84e93554a578463e2edaaf2300faa61c8701f0898725842c704ba5444/yarl-1.18.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:913829534200eb0f789d45349e55203a091f45c37a2674678744ae52fae23efa", size = 94144 }, + { url = "https://files.pythonhosted.org/packages/c6/fc/d68d8f83714b221a85ce7866832cba36d7c04a68fa6a960b908c2c84f325/yarl-1.18.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9f7768395923c3039055c14334ba4d926f3baf7b776c923c93d80195624782", size = 91974 }, + { url = "https://files.pythonhosted.org/packages/56/4e/d2563d8323a7e9a414b5b25341b3942af5902a2263d36d20fb17c40411e2/yarl-1.18.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a19f62ff30117e706ebc9090b8ecc79aeb77d0b1f5ec10d2d27a12bc9f66d0", size = 333587 }, + { url = "https://files.pythonhosted.org/packages/25/c9/cfec0bc0cac8d054be223e9f2c7909d3e8442a856af9dbce7e3442a8ec8d/yarl-1.18.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e17c9361d46a4d5addf777c6dd5eab0715a7684c2f11b88c67ac37edfba6c482", size = 344386 }, + { url = "https://files.pythonhosted.org/packages/ab/5d/4c532190113b25f1364d25f4c319322e86232d69175b91f27e3ebc2caf9a/yarl-1.18.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a74a13a4c857a84a845505fd2d68e54826a2cd01935a96efb1e9d86c728e186", size = 345421 }, + { url = "https://files.pythonhosted.org/packages/23/d1/6cdd1632da013aa6ba18cee4d750d953104a5e7aac44e249d9410a972bf5/yarl-1.18.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41f7ce59d6ee7741af71d82020346af364949314ed3d87553763a2df1829cc58", size = 339384 }, + { url = "https://files.pythonhosted.org/packages/9a/c4/6b3c39bec352e441bd30f432cda6ba51681ab19bb8abe023f0d19777aad1/yarl-1.18.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f52a265001d830bc425f82ca9eabda94a64a4d753b07d623a9f2863fde532b53", size = 326689 }, + { url = "https://files.pythonhosted.org/packages/23/30/07fb088f2eefdc0aa4fc1af4e3ca4eb1a3aadd1ce7d866d74c0f124e6a85/yarl-1.18.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:82123d0c954dc58db301f5021a01854a85bf1f3bb7d12ae0c01afc414a882ca2", size = 345453 }, + { url = "https://files.pythonhosted.org/packages/63/09/d54befb48f9cd8eec43797f624ec37783a0266855f4930a91e3d5c7717f8/yarl-1.18.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2ec9bbba33b2d00999af4631a3397d1fd78290c48e2a3e52d8dd72db3a067ac8", size = 341872 }, + { url = "https://files.pythonhosted.org/packages/91/26/fd0ef9bf29dd906a84b59f0cd1281e65b0c3e08c6aa94b57f7d11f593518/yarl-1.18.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbd6748e8ab9b41171bb95c6142faf068f5ef1511935a0aa07025438dd9a9bc1", size = 347497 }, + { url = "https://files.pythonhosted.org/packages/d9/b5/14ac7a256d0511b2ac168d50d4b7d744aea1c1aa20c79f620d1059aab8b2/yarl-1.18.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:877d209b6aebeb5b16c42cbb377f5f94d9e556626b1bfff66d7b0d115be88d0a", size = 359981 }, + { url = "https://files.pythonhosted.org/packages/ca/b3/d493221ad5cbd18bc07e642894030437e405e1413c4236dd5db6e46bcec9/yarl-1.18.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b464c4ab4bfcb41e3bfd3f1c26600d038376c2de3297760dfe064d2cb7ea8e10", size = 366229 }, + { url = "https://files.pythonhosted.org/packages/04/56/6a3e2a5d9152c56c346df9b8fb8edd2c8888b1e03f96324d457e5cf06d34/yarl-1.18.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d39d351e7faf01483cc7ff7c0213c412e38e5a340238826be7e0e4da450fdc8", size = 360383 }, + { url = "https://files.pythonhosted.org/packages/fd/b7/4b3c7c7913a278d445cc6284e59b2e62fa25e72758f888b7a7a39eb8423f/yarl-1.18.3-cp313-cp313-win32.whl", hash = "sha256:61ee62ead9b68b9123ec24bc866cbef297dd266175d53296e2db5e7f797f902d", size = 310152 }, + { url = "https://files.pythonhosted.org/packages/f5/d5/688db678e987c3e0fb17867970700b92603cadf36c56e5fb08f23e822a0c/yarl-1.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c", size = 315723 }, + { url = "https://files.pythonhosted.org/packages/f5/4b/a06e0ec3d155924f77835ed2d167ebd3b211a7b0853da1cf8d8414d784ef/yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b", size = 45109 }, ] From d0aba68e7a901135868ffbf3cfd1543b8bd4a65b Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Tue, 24 Dec 2024 10:56:14 -0500 Subject: [PATCH 779/892] Add HS210(US) 3.0 1.0.10 IOT Fixture (#1405) --- SUPPORTED.md | 1 + tests/fixtures/iot/HS210(US)_3.0_1.0.10.json | 63 ++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 tests/fixtures/iot/HS210(US)_3.0_1.0.10.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 795d13464..4187bc51a 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -93,6 +93,7 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - **HS210** - Hardware: 1.0 (US) / Firmware: 1.5.8 - Hardware: 2.0 (US) / Firmware: 1.1.5 + - Hardware: 3.0 (US) / Firmware: 1.0.10 - **HS220** - Hardware: 1.0 (US) / Firmware: 1.5.7 - Hardware: 2.0 (US) / Firmware: 1.0.3 diff --git a/tests/fixtures/iot/HS210(US)_3.0_1.0.10.json b/tests/fixtures/iot/HS210(US)_3.0_1.0.10.json new file mode 100644 index 000000000..30a401e97 --- /dev/null +++ b/tests/fixtures/iot/HS210(US)_3.0_1.0.10.json @@ -0,0 +1,63 @@ +{ + "cnCloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 0, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi 3-Way Light Switch", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "3.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "60:83:E7:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS210(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 6525, + "relay_state": 1, + "rssi": -31, + "status": "new", + "sw_ver": "1.0.10 Build 240122 Rel.193635", + "updating": 0 + } + } +} From 5d49623d5d9cc517880ae63c50facf8867f51da1 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 3 Jan 2025 06:55:55 +0000 Subject: [PATCH 780/892] Add C210 2.0 1.3.11 fixture (#1406) --- SUPPORTED.md | 1 + devtools/generate_supported.py | 2 +- tests/fixtures/smartcam/C210_2.0_1.3.11.json | 870 +++++++++++++++++++ 3 files changed, 872 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smartcam/C210_2.0_1.3.11.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 4187bc51a..666aa9d41 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -267,6 +267,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **C100** - Hardware: 4.0 / Firmware: 1.3.14 - **C210** + - Hardware: 2.0 / Firmware: 1.3.11 - Hardware: 2.0 (EU) / Firmware: 1.4.2 - Hardware: 2.0 (EU) / Firmware: 1.4.3 - **C225** diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py index 7b4e9787d..7e946e1ae 100755 --- a/devtools/generate_supported.py +++ b/devtools/generate_supported.py @@ -214,7 +214,7 @@ def _get_supported_devices( smodel = stype.setdefault(model_info.long_name, []) smodel.append( SupportedVersion( - region=model_info.region, + region=model_info.region if model_info.region else "", hw=model_info.hardware_version, fw=model_info.firmware_version, auth=model_info.requires_auth, diff --git a/tests/fixtures/smartcam/C210_2.0_1.3.11.json b/tests/fixtures/smartcam/C210_2.0_1.3.11.json new file mode 100644 index 000000000..9e53bf053 --- /dev/null +++ b/tests/fixtures/smartcam/C210_2.0_1.3.11.json @@ -0,0 +1,870 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1734967724", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.11 Build 240110 Rel.64341n(4555)", + "hardware_version": "2.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + ".name": "chn1_msg_alarm_plan", + ".type": "plan", + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 4 + }, + { + "name": "detection", + "version": 1 + }, + { + "name": "alert", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 1 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 2 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + ".name": "microphone", + ".type": "audio_config", + "channels": "1", + "encode_type": "G711alaw", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + ".name": "speaker", + ".type": "audio_config", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + ".name": "bcd", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + ".name": "harddisk", + ".type": "storage", + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-12-24 00:19:08", + "seconds_from_1970": 1734999548 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -39, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + ".name": "motion_det", + ".type": "on_off", + "digital_sensitivity": "60", + "enabled": "on", + "sensitivity": "medium" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c212", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C210 2.0 IPC", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": "3", + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_version": "2.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "40-AE-30-00-00-00", + "oem_id": "00000000000000000000000000000000", + "sw_version": "1.3.11 Build 240110 Rel.64341n(4555)" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + ".name": "common", + ".type": "on_off", + "enabled": "off", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": false, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1734967724", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + ".name": "common", + ".type": "para", + "area_compensation": "default", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "exp_gain": "0", + "exp_type": "auto", + "focus_limited": "600", + "focus_type": "semi_auto", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_start_time": "64800", + "inf_type": "auto", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off" + }, + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getLedStatus": { + "led": { + "config": { + ".name": "config", + ".type": "led", + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + ".name": "lens_mask_info", + ".type": "lens_mask_info", + "enabled": "on" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + ".name": "common", + ".type": "para", + "area_compensation": "default", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "exp_gain": "0", + "exp_type": "auto", + "focus_limited": "600", + "focus_type": "semi_auto", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_start_time": "64800", + "inf_type": "auto", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + ".name": "media_encrypt", + ".type": "on_off", + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + ".name": "chn1_msg_push_info", + ".type": "on_off", + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + ".name": "detection", + ".type": "on_off", + "enabled": "on" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [ + "1" + ], + "name": [ + "Viewpoint 1" + ], + "position_pan": [ + "-0.176836" + ], + "position_tilt": [ + "-0.859297" + ], + "read_only": [ + "0" + ] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + ".name": "chn1_channel", + ".type": "plan", + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_total_space": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_total_space": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "type": "local", + "video_free_space": "0B", + "video_total_space": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + ".name": "tamper_det", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + ".name": "target_track_info", + ".type": "target_track_info", + "enabled": "off" + } + } + }, + "getTimezone": { + "system": { + "basic": { + ".name": "basic", + ".type": "setting", + "timezone": "UTC-00:00", + "timing_mode": "ntp", + "zone_id": "Europe/London" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + ".name": "main", + ".type": "capability", + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "2048" + ], + "encode_types": [ + "H264" + ], + "frame_rates": [ + "65537", + "65546", + "65551" + ], + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2304*1296", + "1920*1080", + "1280*720" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + ".name": "main", + ".type": "stream", + "bitrate": "2048", + "bitrate_type": "vbr", + "encode_type": "H264", + "frame_rate": "65551", + "gop_factor": "2", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "1920*1080", + "stream_type": "general" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + ".name": "device_microphone", + ".type": "capability", + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + ".name": "device_speaker", + ".type": "capability", + "channels": "1", + "decode_type": [ + "G711" + ], + "mute": "0", + "sampling_rate": [ + "8" + ], + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + ".name": "microphone", + ".type": "audio_config", + "channels": "1", + "encode_type": "G711alaw", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + ".name": "speaker", + ".type": "audio_config", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + ".name": "vhttpd", + ".type": "server", + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + ".name": "module_spec", + ".type": "module-spec", + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "audioexception_detection": "0", + "auth_encrypt": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "0", + "device_share": [ + "preview", + "playback", + "voice", + "motor", + "cloud_storage" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "greeter": "1.0", + "http_system_state_audio_support": "1", + "intrusion_detection": "1", + "led": "1", + "lens_mask": "1", + "linecrossing_detection": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_alarm_separate_list": [ + "light", + "sound" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi" + ], + "ota_upgrade": "1", + "p2p_support_versions": [ + "1.1" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "1.3" + ], + "reonboarding": "1", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "1.0" + ], + "target_track": "1.0", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264" + ], + "video_detection_digital_sensitivity": "1", + "wifi_cascade_connection": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "1" + } + } + } + }, + "get_motor": { + "get": { + "motor": { + "capability": { + ".name": "capability", + ".type": "ptz", + "absolute_move_supported": "1", + "calibrate_supported": "1", + "continuous_move_supported": "1", + "eflip_mode": [ + "off", + "on" + ], + "home_position_mode": "none", + "limit_supported": "0", + "manual_control_level": [ + "low", + "normal", + "high" + ], + "manual_control_mode": [ + "compatible", + "pedestrian", + "motor_vehicle", + "non_motor_vehicle", + "self_adaptive" + ], + "park_supported": "0", + "pattern_supported": "0", + "plan_supported": "0", + "position_pan_range": [ + "-1.000000", + "1.000000" + ], + "position_tilt_range": [ + "-1.000000", + "1.000000" + ], + "poweroff_save_supported": "1", + "poweroff_save_time_range": [ + "10", + "600" + ], + "preset_number_max": "8", + "preset_supported": "1", + "relative_move_supported": "1", + "reverse_mode": [ + "off", + "on", + "auto" + ], + "scan_supported": "0", + "speed_pan_max": "1.00000", + "speed_tilt_max": "1.000000", + "tour_supported": "0" + } + } + } + } +} From 361697a2392f141cb529a5fcf2488430dd335007 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 3 Jan 2025 07:08:23 +0000 Subject: [PATCH 781/892] Change smartcam detection features to category config (#1402) --- kasa/smartcam/modules/babycrydetection.py | 2 +- kasa/smartcam/modules/motiondetection.py | 2 +- kasa/smartcam/modules/persondetection.py | 2 +- kasa/smartcam/modules/tamperdetection.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/kasa/smartcam/modules/babycrydetection.py b/kasa/smartcam/modules/babycrydetection.py index e9e323717..ecad1e830 100644 --- a/kasa/smartcam/modules/babycrydetection.py +++ b/kasa/smartcam/modules/babycrydetection.py @@ -30,7 +30,7 @@ def _initialize_features(self) -> None: attribute_getter="enabled", attribute_setter="set_enabled", type=Feature.Type.Switch, - category=Feature.Category.Primary, + category=Feature.Category.Config, ) ) diff --git a/kasa/smartcam/modules/motiondetection.py b/kasa/smartcam/modules/motiondetection.py index 33067bdff..a30448f8a 100644 --- a/kasa/smartcam/modules/motiondetection.py +++ b/kasa/smartcam/modules/motiondetection.py @@ -30,7 +30,7 @@ def _initialize_features(self) -> None: attribute_getter="enabled", attribute_setter="set_enabled", type=Feature.Type.Switch, - category=Feature.Category.Primary, + category=Feature.Category.Config, ) ) diff --git a/kasa/smartcam/modules/persondetection.py b/kasa/smartcam/modules/persondetection.py index 641609d54..5d40ce519 100644 --- a/kasa/smartcam/modules/persondetection.py +++ b/kasa/smartcam/modules/persondetection.py @@ -30,7 +30,7 @@ def _initialize_features(self) -> None: attribute_getter="enabled", attribute_setter="set_enabled", type=Feature.Type.Switch, - category=Feature.Category.Primary, + category=Feature.Category.Config, ) ) diff --git a/kasa/smartcam/modules/tamperdetection.py b/kasa/smartcam/modules/tamperdetection.py index 32b352f79..4705d36c1 100644 --- a/kasa/smartcam/modules/tamperdetection.py +++ b/kasa/smartcam/modules/tamperdetection.py @@ -30,7 +30,7 @@ def _initialize_features(self) -> None: attribute_getter="enabled", attribute_setter="set_enabled", type=Feature.Type.Switch, - category=Feature.Category.Primary, + category=Feature.Category.Config, ) ) From 883d52209e4db28aa676dd66198d497784a4fab0 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 3 Jan 2025 19:07:46 +0100 Subject: [PATCH 782/892] Fix incorrect obd src echo (#1412) --- kasa/cli/discover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index 2470434b7..ff201ce67 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -283,7 +283,7 @@ def _conditional_echo(label, value): _conditional_echo("HW Ver", dr.hw_ver) _conditional_echo("HW Ver", dr.hardware_version) _conditional_echo("Supports IOT Cloud", dr.is_support_iot_cloud) - _conditional_echo("OBD Src", dr.owner) + _conditional_echo("OBD Src", dr.obd_src) _conditional_echo("Factory Default", dr.factory_default) _conditional_echo("Encrypt Type", dr.encrypt_type) if mgt_encrypt_schm := dr.mgt_encrypt_schm: From 0a95a41ab6a03e96799e0d46fc85b77473b83484 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 3 Jan 2025 20:00:57 +0000 Subject: [PATCH 783/892] Update SslAesTransport for older firmware versions (#1362) Older firmware versions do not encrypt the payload. Tested to work with C110 hw 2.0 fw 1.3.7 Build 230823 Rel.57279n(5553) --------- Co-authored-by: Teemu R. --- kasa/exceptions.py | 1 + kasa/transports/sslaestransport.py | 159 ++++++++++++++-- tests/transports/test_sslaestransport.py | 226 ++++++++++++++++++++++- 3 files changed, 363 insertions(+), 23 deletions(-) diff --git a/kasa/exceptions.py b/kasa/exceptions.py index a0ecbf8fe..f23602a5a 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -132,6 +132,7 @@ def from_int(value: int) -> SmartErrorCode: # Camera error codes SESSION_EXPIRED = -40401 + BAD_USERNAME = -40411 # determined from testing HOMEKIT_LOGIN_FAIL = -40412 DEVICE_BLOCKED = -40404 DEVICE_FACTORY = -40405 diff --git a/kasa/transports/sslaestransport.py b/kasa/transports/sslaestransport.py index 500d9422d..3ea331451 100644 --- a/kasa/transports/sslaestransport.py +++ b/kasa/transports/sslaestransport.py @@ -126,6 +126,7 @@ def __init__( self._password = ch["pwd"] self._username = ch["un"] self._local_nonce: str | None = None + self._send_secure = True _LOGGER.debug("Created AES transport for %s", self._host) @@ -162,7 +163,13 @@ def _get_response_error(self, resp_dict: Any) -> SmartErrorCode: return error_code def _get_response_inner_error(self, resp_dict: Any) -> SmartErrorCode | None: + # Device blocked errors have 'data' element at the root level, other inner + # errors are inside 'result' error_code_raw = resp_dict.get("data", {}).get("code") + + if error_code_raw is None: + error_code_raw = resp_dict.get("result", {}).get("data", {}).get("code") + if error_code_raw is None: return None try: @@ -208,6 +215,10 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]: else: url = self._app_url + _LOGGER.debug( + "Sending secure passthrough from %s", + self._host, + ) encrypted_payload = self._encryption_session.encrypt(request.encode()) # type: ignore passthrough_request = { "method": "securePassthrough", @@ -292,6 +303,34 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]: ) from ex return ret_val # type: ignore[return-value] + async def send_unencrypted(self, request: str) -> dict[str, Any]: + """Send encrypted message as passthrough.""" + url = cast(URL, self._token_url) + + _LOGGER.debug( + "Sending unencrypted to %s", + self._host, + ) + + status_code, resp_dict = await self._http_client.post( + url, + json=request, + headers=self._headers, + ssl=await self._get_ssl_context(), + ) + + if status_code != 200: + raise KasaException( + f"{self._host} responded with an unexpected " + + f"status code {status_code} to unencrypted send" + ) + + self._handle_response_error_code(resp_dict, "Error sending message") + + if TYPE_CHECKING: + resp_dict = cast(dict[str, Any], resp_dict) + return resp_dict + @staticmethod def generate_confirm_hash( local_nonce: str, server_nonce: str, pwd_hash: str @@ -340,8 +379,50 @@ def generate_tag(request: str, local_nonce: str, pwd_hash: str, seq: int) -> str async def perform_handshake(self) -> None: """Perform the handshake.""" - local_nonce, server_nonce, pwd_hash = await self.perform_handshake1() - await self.perform_handshake2(local_nonce, server_nonce, pwd_hash) + result = await self.perform_handshake1() + if result: + local_nonce, server_nonce, pwd_hash = result + await self.perform_handshake2(local_nonce, server_nonce, pwd_hash) + + async def try_perform_less_secure_login(self, username: str, password: str) -> bool: + """Perform the md5 login.""" + _LOGGER.debug("Performing less secure login...") + + pwd_hash = _md5_hash(password.encode()) + body = { + "method": "login", + "params": { + "hashed": True, + "password": pwd_hash, + "username": username, + }, + } + + status_code, resp_dict = await self._http_client.post( + self._app_url, + json=body, + headers=self._headers, + ssl=await self._get_ssl_context(), + ) + if status_code != 200: + raise KasaException( + f"{self._host} responded with an unexpected " + + f"status code {status_code} to login" + ) + resp_dict = cast(dict, resp_dict) + if resp_dict.get("error_code") == 0 and ( + stok := resp_dict.get("result", {}).get("stok") + ): + _LOGGER.debug( + "Succesfully logged in to %s with less secure passthrough", self._host + ) + self._send_secure = False + self._token_url = URL(f"{str(self._app_url)}/stok={stok}/ds") + self._pwd_hash = pwd_hash + return True + + _LOGGER.debug("Unable to log in to %s with less secure login", self._host) + return False async def perform_handshake2( self, local_nonce: str, server_nonce: str, pwd_hash: str @@ -393,13 +474,50 @@ async def perform_handshake2( self._state = TransportState.ESTABLISHED _LOGGER.debug("Handshake2 complete ...") - async def perform_handshake1(self) -> tuple[str, str, str]: + def _pwd_to_hash(self) -> str: + """Return the password to hash.""" + if self._credentials and self._credentials != Credentials(): + return self._credentials.password + + if self._username and self._password: + return self._password + + return self._default_credentials.password + + def _is_less_secure_login(self, resp_dict: dict[str, Any]) -> bool: + result = ( + self._get_response_error(resp_dict) is SmartErrorCode.SESSION_EXPIRED + and (data := resp_dict.get("result", {}).get("data", {})) + and (encrypt_type := data.get("encrypt_type")) + and (encrypt_type != ["3"]) + ) + if result: + _LOGGER.debug( + "Received encrypt_type %s for %s, trying less secure login", + encrypt_type, + self._host, + ) + return result + + async def perform_handshake1(self) -> tuple[str, str, str] | None: """Perform the handshake1.""" resp_dict = None if self._username: local_nonce = secrets.token_bytes(8).hex().upper() resp_dict = await self.try_send_handshake1(self._username, local_nonce) + if ( + resp_dict + and self._is_less_secure_login(resp_dict) + and self._get_response_inner_error(resp_dict) + is not SmartErrorCode.BAD_USERNAME + and await self.try_perform_less_secure_login( + cast(str, self._username), self._pwd_to_hash() + ) + ): + self._state = TransportState.ESTABLISHED + return None + # Try the default username. If it fails raise the original error_code if ( not resp_dict @@ -407,19 +525,30 @@ async def perform_handshake1(self) -> tuple[str, str, str]: is not SmartErrorCode.INVALID_NONCE or "nonce" not in resp_dict["result"].get("data", {}) ): + _LOGGER.debug("Trying default credentials to %s", self._host) local_nonce = secrets.token_bytes(8).hex().upper() default_resp_dict = await self.try_send_handshake1( self._default_credentials.username, local_nonce ) + # INVALID_NONCE means device should perform secure login if ( default_error_code := self._get_response_error(default_resp_dict) ) is SmartErrorCode.INVALID_NONCE and "nonce" in default_resp_dict[ "result" ].get("data", {}): - _LOGGER.debug("Connected to {self._host} with default username") + _LOGGER.debug("Connected to %s with default username", self._host) self._username = self._default_credentials.username error_code = default_error_code resp_dict = default_resp_dict + # Otherwise could be less secure login + elif self._is_less_secure_login( + default_resp_dict + ) and await self.try_perform_less_secure_login( + self._default_credentials.username, self._pwd_to_hash() + ): + self._username = self._default_credentials.username + self._state = TransportState.ESTABLISHED + return None # If the default login worked it's ok not to provide credentials but if # it didn't raise auth error here. @@ -451,12 +580,8 @@ async def perform_handshake1(self) -> tuple[str, str, str]: server_nonce = resp_dict["result"]["data"]["nonce"] device_confirm = resp_dict["result"]["data"]["device_confirm"] - if self._credentials and self._credentials != Credentials(): - pwd_hash = _sha256_hash(self._credentials.password.encode()) - elif self._username and self._password: - pwd_hash = _sha256_hash(self._password.encode()) - else: - pwd_hash = _sha256_hash(self._default_credentials.password.encode()) + + pwd_hash = _sha256_hash(self._pwd_to_hash().encode()) expected_confirm_sha256 = self.generate_confirm_hash( local_nonce, server_nonce, pwd_hash @@ -468,7 +593,9 @@ async def perform_handshake1(self) -> tuple[str, str, str]: if TYPE_CHECKING: assert self._credentials assert self._credentials.password - pwd_hash = _md5_hash(self._credentials.password.encode()) + + pwd_hash = _md5_hash(self._pwd_to_hash().encode()) + expected_confirm_md5 = self.generate_confirm_hash( local_nonce, server_nonce, pwd_hash ) @@ -478,11 +605,12 @@ async def perform_handshake1(self) -> tuple[str, str, str]: msg = f"Server response doesn't match our challenge on ip {self._host}" _LOGGER.debug(msg) + raise AuthenticationError(msg) async def try_send_handshake1(self, username: str, local_nonce: str) -> dict: """Perform the handshake.""" - _LOGGER.debug("Will to send handshake1...") + _LOGGER.debug("Sending handshake1...") body = { "method": "login", @@ -501,7 +629,7 @@ async def try_send_handshake1(self, username: str, local_nonce: str) -> dict: ssl=await self._get_ssl_context(), ) - _LOGGER.debug("Device responded with: %s", resp_dict) + _LOGGER.debug("Device responded with status %s: %s", status_code, resp_dict) if status_code != 200: raise KasaException( @@ -516,7 +644,10 @@ async def send(self, request: str) -> dict[str, Any]: if self._state is TransportState.HANDSHAKE_REQUIRED: await self.perform_handshake() - return await self.send_secure_passthrough(request) + if self._send_secure: + return await self.send_secure_passthrough(request) + + return await self.send_unencrypted(request) async def close(self) -> None: """Close the http client and reset internal state.""" diff --git a/tests/transports/test_sslaestransport.py b/tests/transports/test_sslaestransport.py index 39469967a..e8ff9e527 100644 --- a/tests/transports/test_sslaestransport.py +++ b/tests/transports/test_sslaestransport.py @@ -25,16 +25,19 @@ from kasa.transports.sslaestransport import ( SslAesTransport, TransportState, + _md5_hash, _sha256_hash, ) # Transport tests are not designed for real devices -pytestmark = [pytest.mark.requires_dummy] +# SslAesTransport use a socket to get it's own ip address +pytestmark = [pytest.mark.requires_dummy, pytest.mark.enable_socket] MOCK_ADMIN_USER = get_default_credentials(DEFAULT_CREDENTIALS["TAPOCAMERA"]).username MOCK_PWD = "correct_pwd" # noqa: S105 MOCK_USER = "mock@example.com" MOCK_STOCK = "abcdefghijklmnopqrstuvwxyz1234)(" +MOCK_UNENCRYPTED_PASSTHROUGH_STOK = "32charLowerCaseHexStok" @pytest.mark.parametrize( @@ -202,6 +205,124 @@ async def test_unencrypted_response(mocker, caplog): ) +@pytest.mark.parametrize(("want_default"), [True, False]) +@pytest.mark.xdist_group(name="caplog") +async def test_unencrypted_passthrough(mocker, caplog, want_default): + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice( + host, unencrypted_passthrough=True, want_default_username=want_default + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + + request = { + "method": "getDeviceInfo", + "params": None, + } + caplog.set_level(logging.DEBUG) + res = await transport.send(json_dumps(request)) + assert "result" in res + assert ( + f"Succesfully logged in to {host} with less secure passthrough" in caplog.text + ) + + +@pytest.mark.parametrize(("want_default"), [True, False]) +@pytest.mark.xdist_group(name="caplog") +async def test_unencrypted_passthrough_errors(mocker, caplog, want_default): + host = "127.0.0.1" + request = { + "method": "getDeviceInfo", + "params": None, + } + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + caplog.set_level(logging.DEBUG) + + # Test bad password + mock_ssl_aes_device = MockSslAesDevice( + host, + unencrypted_passthrough=True, + want_default_username=want_default, + digest_password_fail=True, + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + msg = f"Unable to log in to {host} with less secure login" + with pytest.raises(AuthenticationError): + await transport.send(json_dumps(request)) + + assert msg in caplog.text + + # Test bad status code in handshake + mock_ssl_aes_device = MockSslAesDevice( + host, + unencrypted_passthrough=True, + want_default_username=want_default, + status_code=401, + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + msg = f"{host} responded with an unexpected " f"status code 401 to handshake1" + with pytest.raises(KasaException, match=msg): + await transport.send(json_dumps(request)) + + # Test bad status code in login + mock_ssl_aes_device = MockSslAesDevice( + host, + unencrypted_passthrough=True, + want_default_username=want_default, + status_code_list=[200, 401], + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + msg = f"{host} responded with an unexpected " f"status code 401 to login" + with pytest.raises(KasaException, match=msg): + await transport.send(json_dumps(request)) + + # Test bad status code in send + mock_ssl_aes_device = MockSslAesDevice( + host, + unencrypted_passthrough=True, + want_default_username=want_default, + status_code_list=[200, 200, 401], + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + msg = f"{host} responded with an unexpected " f"status code 401 to unencrypted send" + with pytest.raises(KasaException, match=msg): + await transport.send(json_dumps(request)) + + # Test error code in send response + mock_ssl_aes_device = MockSslAesDevice( + host, + unencrypted_passthrough=True, + want_default_username=want_default, + send_error_code=SmartErrorCode.BAD_USERNAME.value, + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + msg = f"Error sending message: {host}:" + with pytest.raises(KasaException, match=msg): + await transport.send(json_dumps(request)) + + async def test_device_blocked_response(mocker): host = "127.0.0.1" mock_ssl_aes_device = MockSslAesDevice(host, device_blocked=True) @@ -300,6 +421,38 @@ class MockSslAesDevice: "error_code": SmartErrorCode.SESSION_EXPIRED.value, } + UNENCRYPTED_PASSTHROUGH_BAD_USER_RESP = { + "error_code": SmartErrorCode.SESSION_EXPIRED.value, + "result": { + "data": { + "code": SmartErrorCode.BAD_USERNAME.value, + "encrypt_type": ["1", "2"], + "key": "Someb64keyWithUnknownPurpose", + "nonce": "MixedCaseAlphaNumericWithUnknownPurpose", + } + }, + } + + UNENCRYPTED_PASSTHROUGH_HANDSHAKE_RESP = { + "error_code": SmartErrorCode.SESSION_EXPIRED.value, + "result": { + "data": { + "code": SmartErrorCode.SESSION_EXPIRED.value, + "time": 9, + "max_time": 10, + "sec_left": 0, + "encrypt_type": ["1", "2"], + "key": "Someb64keyWithUnknownPurpose", + "nonce": "MixedCaseAlphaNumericWithUnknownPurpose", + } + }, + } + + UNENCRYPTED_PASSTHROUGH_GOOD_LOGIN_RESPONSE = { + "error_code": 0, + "result": {"stok": MOCK_UNENCRYPTED_PASSTHROUGH_STOK, "user_group": "root"}, + } + class _mock_response: def __init__(self, status, request: dict): self.status = status @@ -321,6 +474,7 @@ def __init__( host, *, status_code=200, + status_code_list=None, want_default_username: bool = False, do_not_encrypt_response=False, send_response=None, @@ -329,6 +483,7 @@ def __init__( secure_passthrough_error_code=0, digest_password_fail=False, device_blocked=False, + unencrypted_passthrough=False, ): self.host = host self.http_client = HttpClient(DeviceConfig(self.host)) @@ -338,15 +493,22 @@ def __init__( # test behaviour attributes self.status_code = status_code + self.status_code_list = status_code_list if status_code_list else [] self.send_error_code = send_error_code self.secure_passthrough_error_code = secure_passthrough_error_code self.do_not_encrypt_response = do_not_encrypt_response self.want_default_username = want_default_username self.digest_password_fail = digest_password_fail self.device_blocked = device_blocked + self.unencrypted_passthrough = unencrypted_passthrough self._next_responses: list[dict | bytes] = [] + def _get_status_code(self): + if self.status_code_list: + return self.status_code_list.pop(0) + return self.status_code + async def post(self, url: URL, params=None, json=None, data=None, *_, **__): if data: json = json_loads(data) @@ -360,12 +522,25 @@ async def _post(self, url: URL, json: dict[str, Any]): return await self._return_handshake1_response(url, json) if method == "login" and self.handshake1_complete: + if self.unencrypted_passthrough: + return await self._return_unencrypted_passthrough_login_response( + url, json + ) + return await self._return_handshake2_response(url, json) elif method == "securePassthrough": assert url == URL(f"https://{self.host}/stok={MOCK_STOCK}/ds") return await self._return_secure_passthrough_response(url, json) else: - assert url == URL(f"https://{self.host}/stok={MOCK_STOCK}/ds") + # The unencrypted passthrough with have actual query method names. + # This path is also used by the mock class to return unencrypted + # responses to single 'get' queries which the secure fw returns as unencrypted + stok = ( + MOCK_UNENCRYPTED_PASSTHROUGH_STOK + if self.unencrypted_passthrough + else MOCK_STOCK + ) + assert url == URL(f"https://{self.host}/stok={stok}/ds") return await self._return_send_response(url, json) async def _return_handshake1_response(self, url: URL, request: dict[str, Any]): @@ -378,12 +553,23 @@ async def _return_handshake1_response(self, url: URL, request: dict[str, Any]): if (self.want_default_username and request_username != MOCK_ADMIN_USER) or ( not self.want_default_username and request_username != MOCK_USER ): - return self._mock_response(self.status_code, self.BAD_USER_RESP) + resp = ( + self.UNENCRYPTED_PASSTHROUGH_BAD_USER_RESP + if self.unencrypted_passthrough + else self.BAD_USER_RESP + ) + return self._mock_response(self.status_code, resp) device_confirm = SslAesTransport.generate_confirm_hash( request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) ) self.handshake1_complete = True + + if self.unencrypted_passthrough: + return self._mock_response( + self._get_status_code(), self.UNENCRYPTED_PASSTHROUGH_HANDSHAKE_RESP + ) + resp = { "error_code": SmartErrorCode.INVALID_NONCE.value, "result": { @@ -396,7 +582,29 @@ async def _return_handshake1_response(self, url: URL, request: dict[str, Any]): } }, } - return self._mock_response(self.status_code, resp) + return self._mock_response(self._get_status_code(), resp) + + async def _return_unencrypted_passthrough_login_response( + self, url: URL, request: dict[str, Any] + ): + request_username = request["params"].get("username") + request_password = request["params"].get("password") + if (self.want_default_username and request_username != MOCK_ADMIN_USER) or ( + not self.want_default_username and request_username != MOCK_USER + ): + return self._mock_response( + self._get_status_code(), self.UNENCRYPTED_PASSTHROUGH_BAD_USER_RESP + ) + + expected_pwd = _md5_hash(MOCK_PWD.encode()) + if request_password != expected_pwd or self.digest_password_fail: + return self._mock_response( + self._get_status_code(), self.UNENCRYPTED_PASSTHROUGH_HANDSHAKE_RESP + ) + + return self._mock_response( + self._get_status_code(), self.UNENCRYPTED_PASSTHROUGH_GOOD_LOGIN_RESPONSE + ) async def _return_handshake2_response(self, url: URL, request: dict[str, Any]): request_nonce = request["params"].get("cnonce") @@ -404,14 +612,14 @@ async def _return_handshake2_response(self, url: URL, request: dict[str, Any]): if (self.want_default_username and request_username != MOCK_ADMIN_USER) or ( not self.want_default_username and request_username != MOCK_USER ): - return self._mock_response(self.status_code, self.BAD_USER_RESP) + return self._mock_response(self._get_status_code(), self.BAD_USER_RESP) request_password = request["params"].get("digest_passwd") expected_pwd = SslAesTransport.generate_digest_password( request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) ) if request_password != expected_pwd or self.digest_password_fail: - return self._mock_response(self.status_code, self.BAD_PWD_RESP) + return self._mock_response(self._get_status_code(), self.BAD_PWD_RESP) lsk = SslAesTransport.generate_encryption_token( "lsk", request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) @@ -424,7 +632,7 @@ async def _return_handshake2_response(self, url: URL, request: dict[str, Any]): "error_code": 0, "result": {"stok": MOCK_STOCK, "user_group": "root", "start_seq": 100}, } - return self._mock_response(self.status_code, resp) + return self._mock_response(self._get_status_code(), resp) async def _return_secure_passthrough_response(self, url: URL, json: dict[str, Any]): encrypted_request = json["params"]["request"] @@ -458,11 +666,11 @@ async def _return_secure_passthrough_response(self, url: URL, json: dict[str, An "result": {"response": response.decode()}, "error_code": self.secure_passthrough_error_code, } - return self._mock_response(self.status_code, result) + return self._mock_response(self._get_status_code(), result) async def _return_send_response(self, url: URL, json: dict[str, Any]): result = {"result": {"method": None}, "error_code": self.send_error_code} - return self._mock_response(self.status_code, result) + return self._mock_response(self._get_status_code(), result) def put_next_response(self, request: dict | bytes) -> None: self._next_responses.append(request) From e097b45984db82d6bdba99924225ebcae8c25cc1 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sat, 4 Jan 2025 11:06:26 +0100 Subject: [PATCH 784/892] Improve exception messages on credential mismatches (#1417) --- kasa/transports/klaptransport.py | 13 ++++++++----- kasa/transports/sslaestransport.py | 5 ++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/kasa/transports/klaptransport.py b/kasa/transports/klaptransport.py index 8934b2cc8..508bba09b 100644 --- a/kasa/transports/klaptransport.py +++ b/kasa/transports/klaptransport.py @@ -214,8 +214,8 @@ async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: if default_credentials_seed_auth_hash == server_hash: _LOGGER.debug( - "Server response doesn't match our expected hash on ip %s, " - "but an authentication with %s default credentials matched", + "Device response did not match our expected hash on ip %s," + "but an authentication with %s default credentials worked", self._host, key, ) @@ -235,13 +235,16 @@ async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: if blank_seed_auth_hash == server_hash: _LOGGER.debug( - "Server response doesn't match our expected hash on ip %s, " - "but an authentication with blank credentials matched", + "Device response did not match our expected hash on ip %s, " + "but an authentication with blank credentials worked", self._host, ) return local_seed, remote_seed, self._blank_auth_hash # type: ignore - msg = f"Server response doesn't match our challenge on ip {self._host}" + msg = ( + f"Device response did not match our challenge on ip {self._host}, " + f"check that your e-mail and password (both case-sensitive) are correct. " + ) _LOGGER.debug(msg) raise AuthenticationError(msg) diff --git a/kasa/transports/sslaestransport.py b/kasa/transports/sslaestransport.py index 3ea331451..eb67eda8e 100644 --- a/kasa/transports/sslaestransport.py +++ b/kasa/transports/sslaestransport.py @@ -603,7 +603,10 @@ async def perform_handshake1(self) -> tuple[str, str, str] | None: _LOGGER.debug("Credentials match") return local_nonce, server_nonce, pwd_hash - msg = f"Server response doesn't match our challenge on ip {self._host}" + msg = ( + f"Device response did not match our challenge on ip {self._host}, " + f"check that your e-mail and password (both case-sensitive) are correct. " + ) _LOGGER.debug(msg) raise AuthenticationError(msg) From 6e0be2ea1f10854580ceb2f0c035d11335cf2bf0 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sat, 4 Jan 2025 13:20:06 +0000 Subject: [PATCH 785/892] Add support for Tapo hub-attached switch devices (#1421) Required for #1419 and #1418 --- kasa/smart/smartchilddevice.py | 1 + 1 file changed, 1 insertion(+) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 2ef0454fe..5ed7feb6c 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -24,6 +24,7 @@ class SmartChildDevice(SmartDevice): CHILD_DEVICE_TYPE_MAP = { "plug.powerstrip.sub-plug": DeviceType.Plug, + "subg.plugswitch.switch": DeviceType.WallSwitch, "subg.trigger.contact-sensor": DeviceType.Sensor, "subg.trigger.temp-hmdt-sensor": DeviceType.Sensor, "subg.trigger.water-leak-sensor": DeviceType.Sensor, From 08639a3a7b6d445a79b81b75b6899b85f02ec608 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sat, 4 Jan 2025 19:47:12 +0100 Subject: [PATCH 786/892] Add S220 fixture (#1419) Add S220 (hub-connected) fixture, thanks to @chrisnewmanuk. Drafted as requires adding `subg.plugswitch.switch` as a supported child device category. ref https://github.com/home-assistant/core/issues/133973#issuecomment-2569967648 --- README.md | 2 +- SUPPORTED.md | 2 + tests/device_fixtures.py | 2 +- .../smart/child/S220(EU)_1.0_1.9.0.json | 158 ++++++++++++++++++ 4 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/smart/child/S220(EU)_1.0_1.9.0.json diff --git a/README.md b/README.md index cac047963..c45acb807 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15 - **Power Strips**: P210M, P300, P304M, P306, TP25 -- **Wall Switches**: S500D, S505, S505D +- **Wall Switches**: S220, S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Cameras**: C100, C210, C225, C325WB, C520WS, TC65, TC70 diff --git a/SUPPORTED.md b/SUPPORTED.md index 666aa9d41..e62917c16 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -224,6 +224,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros ### Wall Switches +- **S220** + - Hardware: 1.0 (EU) / Firmware: 1.9.0 - **S500D** - Hardware: 1.0 (US) / Firmware: 1.0.5 - **S505** diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index e2041ca90..a1b868355 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -121,7 +121,7 @@ } HUBS_SMART = {"H100", "KH100"} -SENSORS_SMART = {"T310", "T315", "T300", "T100", "T110", "S200B", "S200D"} +SENSORS_SMART = {"T310", "T315", "T300", "T100", "T110", "S200B", "S200D", "S220"} THERMOSTATS_SMART = {"KE100"} WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} diff --git a/tests/fixtures/smart/child/S220(EU)_1.0_1.9.0.json b/tests/fixtures/smart/child/S220(EU)_1.0_1.9.0.json new file mode 100644 index 000000000..ee8e63e6d --- /dev/null +++ b/tests/fixtures/smart/child/S220(EU)_1.0_1.9.0.json @@ -0,0 +1,158 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "delay_action", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "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": "switch", + "battery_percentage": 100, + "bind_count": 2, + "category": "subg.plugswitch.switch", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "device_on": false, + "fw_ver": "1.9.0 Build 231106 Rel.164353", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_low": false, + "jamming_rssi": -103, + "jamming_signal_level": 2, + "lastOnboardingTimestamp": 1733332989, + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "D84489000000", + "model": "S220", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "position": 1, + "region": "Europe/London", + "rssi": -42, + "signal_level": 3, + "slot_number": 2, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSWITCH" + }, + "get_device_usage": { + "time_usage": { + "past30": 1124, + "past7": 0, + "today": 0 + } + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.9.0 Build 231106 Rel.164353", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_trigger_logs": { + "logs": [], + "start_id": 0, + "sum": 0 + } +} From 1f45f425a07aa622fc458123023782d7d4695025 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sat, 4 Jan 2025 20:09:58 +0100 Subject: [PATCH 787/892] Add S210 fixture (#1418) --- README.md | 2 +- SUPPORTED.md | 2 + tests/device_fixtures.py | 12 +- .../smart/child/S210(EU)_1.0_1.9.0.json | 168 ++++++++++++++++++ 4 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/smart/child/S210(EU)_1.0_1.9.0.json diff --git a/README.md b/README.md index c45acb807..8016e8c4e 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15 - **Power Strips**: P210M, P300, P304M, P306, TP25 -- **Wall Switches**: S220, S500D, S505, S505D +- **Wall Switches**: S210, S220, S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Cameras**: C100, C210, C225, C325WB, C520WS, TC65, TC70 diff --git a/SUPPORTED.md b/SUPPORTED.md index e62917c16..81469347c 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -224,6 +224,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros ### Wall Switches +- **S210** + - Hardware: 1.0 (EU) / Firmware: 1.9.0 - **S220** - Hardware: 1.0 (EU) / Firmware: 1.9.0 - **S500D** diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index a1b868355..af9b52cc4 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -121,7 +121,17 @@ } HUBS_SMART = {"H100", "KH100"} -SENSORS_SMART = {"T310", "T315", "T300", "T100", "T110", "S200B", "S200D", "S220"} +SENSORS_SMART = { + "T310", + "T315", + "T300", + "T100", + "T110", + "S200B", + "S200D", + "S210", + "S220", +} THERMOSTATS_SMART = {"KE100"} WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} diff --git a/tests/fixtures/smart/child/S210(EU)_1.0_1.9.0.json b/tests/fixtures/smart/child/S210(EU)_1.0_1.9.0.json new file mode 100644 index 000000000..201612cd7 --- /dev/null +++ b/tests/fixtures/smart/child/S210(EU)_1.0_1.9.0.json @@ -0,0 +1,168 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "delay_action", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "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": "switch_s210", + "battery_percentage": 100, + "bind_count": 2, + "category": "subg.plugswitch.switch", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_ver": "1.9.0 Build 231106 Rel.164425", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_low": false, + "jamming_rssi": -111, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1733332893, + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "DC6279000000", + "model": "S210", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "position": 1, + "region": "Europe/London", + "rssi": -34, + "signal_level": 3, + "slot_number": 1, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSWITCH" + }, + "get_device_usage": { + "time_usage": { + "past30": 12634, + "past7": 4388, + "today": 17 + } + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.9.0 Build 231106 Rel.164425", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_trigger_logs": { + "logs": [ + { + "event": "singleClick", + "eventId": "85caedf6-73b1-50a8-5cae-df673b150a85", + "id": 20079, + "params": { + "on_off": false + }, + "timestamp": 1735898135 + } + ], + "start_id": 20079, + "sum": 1 + } +} From 6aa019280ba248f318776d65441eefaad3f3b322 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 6 Jan 2025 09:23:46 +0000 Subject: [PATCH 788/892] Handle smartcam partial list responses (#1411) --- kasa/protocols/smartcamprotocol.py | 17 +++++++---- kasa/protocols/smartprotocol.py | 32 +++++++++++++++------ tests/fakeprotocol_smartcam.py | 19 ++++++++++--- tests/protocols/test_smartprotocol.py | 41 +++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 18 deletions(-) diff --git a/kasa/protocols/smartcamprotocol.py b/kasa/protocols/smartcamprotocol.py index 324f80563..a1d6ae9c8 100644 --- a/kasa/protocols/smartcamprotocol.py +++ b/kasa/protocols/smartcamprotocol.py @@ -5,7 +5,7 @@ import logging from dataclasses import dataclass from pprint import pformat as pf -from typing import Any +from typing import Any, cast from ..exceptions import ( AuthenticationError, @@ -49,10 +49,13 @@ class SingleRequest: class SmartCamProtocol(SmartProtocol): """Class for SmartCam Protocol.""" - async def _handle_response_lists( - self, response_result: dict[str, Any], method: str, retry_count: int - ) -> None: - pass + def _get_list_request( + self, method: str, params: dict | None, start_index: int + ) -> dict: + # All smartcam requests have params + params = cast(dict, params) + module_name = next(iter(params)) + return {method: {module_name: {"start_index": start_index}}} def _handle_response_error_code( self, resp_dict: dict, method: str, raise_on_error: bool = True @@ -147,7 +150,9 @@ async def _execute_query( if len(request) == 1 and method in {"get", "set", "do", "multipleRequest"}: single_request = self._get_smart_camera_single_request(request) else: - return await self._execute_multiple_query(request, retry_count) + return await self._execute_multiple_query( + request, retry_count, iterate_list_pages + ) else: single_request = self._make_smart_camera_single_request(request) diff --git a/kasa/protocols/smartprotocol.py b/kasa/protocols/smartprotocol.py index 7f02b45e7..28a20641e 100644 --- a/kasa/protocols/smartprotocol.py +++ b/kasa/protocols/smartprotocol.py @@ -180,7 +180,9 @@ async def _query(self, request: str | dict, retry_count: int = 3) -> dict: # make mypy happy, this should never be reached.. raise KasaException("Query reached somehow to unreachable") - async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dict: + async def _execute_multiple_query( + self, requests: dict, retry_count: int, iterate_list_pages: bool + ) -> dict: debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) multi_result: dict[str, Any] = {} smart_method = "multipleRequest" @@ -275,9 +277,11 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic response, method, raise_on_error=raise_on_error ) result = response.get("result", None) - await self._handle_response_lists( - result, method, retry_count=retry_count - ) + request_params = rp if (rp := requests.get(method)) else None + if iterate_list_pages and result: + await self._handle_response_lists( + result, method, request_params, retry_count=retry_count + ) multi_result[method] = result # Multi requests don't continue after errors so requery any missing. @@ -303,7 +307,9 @@ async def _execute_query( smart_method = next(iter(request)) smart_params = request[smart_method] else: - return await self._execute_multiple_query(request, retry_count) + return await self._execute_multiple_query( + request, retry_count, iterate_list_pages + ) else: smart_method = request smart_params = None @@ -330,12 +336,21 @@ async def _execute_query( result = response_data.get("result") if iterate_list_pages and result: await self._handle_response_lists( - result, smart_method, retry_count=retry_count + result, smart_method, smart_params, retry_count=retry_count ) return {smart_method: result} + def _get_list_request( + self, method: str, params: dict | None, start_index: int + ) -> dict: + return {method: {"start_index": start_index}} + async def _handle_response_lists( - self, response_result: dict[str, Any], method: str, retry_count: int + self, + response_result: dict[str, Any], + method: str, + params: dict | None, + retry_count: int, ) -> None: if ( response_result is None @@ -355,8 +370,9 @@ async def _handle_response_lists( ) ) while (list_length := len(response_result[response_list_name])) < list_sum: + request = self._get_list_request(method, params, list_length) response = await self._execute_query( - {method: {"start_index": list_length}}, + request, retry_count=retry_count, iterate_list_pages=False, ) diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index 381a0a89c..eee014e8f 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -33,6 +33,7 @@ def __init__( *, list_return_size=10, is_child=False, + get_child_fixtures=True, verbatim=False, components_not_included=False, ): @@ -52,9 +53,12 @@ def __init__( self.verbatim = verbatim if not is_child: self.info = copy.deepcopy(info) - self.child_protocols = FakeSmartTransport._get_child_protocols( - self.info, self.fixture_name, "getChildDeviceList" - ) + # We don't need to get the child fixtures if testing things like + # lists + if get_child_fixtures: + self.child_protocols = FakeSmartTransport._get_child_protocols( + self.info, self.fixture_name, "getChildDeviceList" + ) else: self.info = info # self.child_protocols = self._get_child_protocols() @@ -229,9 +233,16 @@ async def _send_request(self, request_dict: dict): list_key = next( iter([key for key in result if isinstance(result[key], list)]) ) + assert isinstance(params, dict) + module_name = next(iter(params)) + start_index = ( start_index - if (params and (start_index := params.get("start_index"))) + if ( + params + and module_name + and (start_index := params[module_name].get("start_index")) + ) else 0 ) diff --git a/tests/protocols/test_smartprotocol.py b/tests/protocols/test_smartprotocol.py index 7961df68d..514926353 100644 --- a/tests/protocols/test_smartprotocol.py +++ b/tests/protocols/test_smartprotocol.py @@ -10,6 +10,7 @@ KasaException, SmartErrorCode, ) +from kasa.protocols.smartcamprotocol import SmartCamProtocol from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.smart import SmartDevice @@ -373,6 +374,46 @@ async def test_smart_protocol_lists_multiple_request(mocker, list_sum, batch_siz assert resp == response +@pytest.mark.parametrize("list_sum", [5, 10, 30]) +@pytest.mark.parametrize("batch_size", [1, 2, 3, 50]) +async def test_smartcam_protocol_list_request(mocker, list_sum, batch_size): + """Test smartcam protocol list handling for lists.""" + child_list = [{"foo": i} for i in range(list_sum)] + + response = { + "getChildDeviceList": { + "child_device_list": child_list, + "start_index": 0, + "sum": list_sum, + }, + "getChildDeviceComponentList": { + "child_component_list": child_list, + "start_index": 0, + "sum": list_sum, + }, + } + request = { + "getChildDeviceList": {"childControl": {"start_index": 0}}, + "getChildDeviceComponentList": {"childControl": {"start_index": 0}}, + } + + ft = FakeSmartCamTransport( + response, + "foobar", + list_return_size=batch_size, + components_not_included=True, + get_child_fixtures=False, + ) + protocol = SmartCamProtocol(transport=ft) + query_spy = mocker.spy(protocol, "_execute_query") + resp = await protocol.query(request) + expected_count = 1 + 2 * ( + int(list_sum / batch_size) + (0 if list_sum % batch_size else -1) + ) + assert query_spy.call_count == expected_count + assert resp == response + + async def test_incomplete_list(mocker, caplog): """Test for handling incomplete lists returned from queries.""" info = { From 48a07a29709774f6ddb4c1e0cd8689dba4bbba18 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Mon, 6 Jan 2025 13:23:02 +0100 Subject: [PATCH 789/892] Use repr() for enum values in Feature.__repr__ (#1414) Instead of simply displaying the enum value, use repr to get a nicer output for the cli. Was: `Error (vacuum_error): 14` Now: `Error (vacuum_error): ` --- kasa/feature.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/kasa/feature.py b/kasa/feature.py index ff19baf97..456a3e631 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -295,6 +295,8 @@ def __repr__(self) -> str: if self.precision_hint is not None and isinstance(value, float): value = round(value, self.precision_hint) + if isinstance(value, Enum): + value = repr(value) s = f"{self.name} ({self.id}): {value}" if self.unit is not None: s += f" {self.unit}" From 7d508b5092428f752368908cb4cb3d0e00402e57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jan 2025 04:00:23 -1000 Subject: [PATCH 790/892] Backoff after xor timeout and improve error reporting (#1424) --- kasa/protocols/iotprotocol.py | 14 ++ kasa/transports/xortransport.py | 13 ++ tests/protocols/test_iotprotocol.py | 206 +++++++++++++++++++++++++++- 3 files changed, 232 insertions(+), 1 deletion(-) diff --git a/kasa/protocols/iotprotocol.py b/kasa/protocols/iotprotocol.py index b58e57ae7..1af4ae59c 100755 --- a/kasa/protocols/iotprotocol.py +++ b/kasa/protocols/iotprotocol.py @@ -98,12 +98,26 @@ async def _query(self, request: str, retry_count: int = 3) -> dict: ) raise auex except _RetryableError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a retryable error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex continue except TimeoutError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a timeout error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) diff --git a/kasa/transports/xortransport.py b/kasa/transports/xortransport.py index 77a232f09..8cce6eb50 100644 --- a/kasa/transports/xortransport.py +++ b/kasa/transports/xortransport.py @@ -23,6 +23,7 @@ from kasa.deviceconfig import DeviceConfig from kasa.exceptions import KasaException, _RetryableError +from kasa.exceptions import TimeoutError as KasaTimeoutError from kasa.json import loads as json_loads from .basetransport import BaseTransport @@ -126,6 +127,12 @@ async def send(self, request: str) -> dict: # This is especially import when there are multiple tplink devices being polled. try: await self._connect(self._timeout) + except TimeoutError as ex: + await self.reset() + raise KasaTimeoutError( + f"Timeout after {self._timeout} seconds connecting to the device:" + f" {self._host}:{self._port}: {ex}" + ) from ex except ConnectionRefusedError as ex: await self.reset() raise KasaException( @@ -159,6 +166,12 @@ async def send(self, request: str) -> dict: assert self.writer is not None # noqa: S101 async with asyncio_timeout(self._timeout): return await self._execute_send(request) + except TimeoutError as ex: + await self.reset() + raise KasaTimeoutError( + f"Timeout after {self._timeout} seconds sending request to the device" + f" {self._host}:{self._port}: {ex}" + ) from ex except Exception as ex: await self.reset() raise _RetryableError( diff --git a/tests/protocols/test_iotprotocol.py b/tests/protocols/test_iotprotocol.py index a2feaae38..fd8facc9e 100644 --- a/tests/protocols/test_iotprotocol.py +++ b/tests/protocols/test_iotprotocol.py @@ -16,7 +16,7 @@ from kasa.credentials import Credentials from kasa.device import Device from kasa.deviceconfig import DeviceConfig -from kasa.exceptions import KasaException +from kasa.exceptions import KasaException, TimeoutError from kasa.iot import IotDevice from kasa.protocols.iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol from kasa.protocols.protocol import ( @@ -294,6 +294,210 @@ def aio_mock_writer(_, __): assert response == {"great": "success"} +@pytest.mark.parametrize( + ("protocol_class", "transport_class", "encryption_class"), + [ + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_handles_timeout_during_write( + mocker, protocol_class, transport_class, encryption_class +): + attempts = 0 + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + def _timeout_first_attempt(*_): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise TimeoutError("Simulated timeout") + + async def _mock_read(byte_count): + nonlocal encrypted + if byte_count == transport_class.BLOCK_SIZE: + return struct.pack(">I", len(encrypted)) + if byte_count == len(encrypted): + return encrypted + + raise ValueError(f"No mock for {byte_count}") + + def aio_mock_writer(_, __): + reader = mocker.patch("asyncio.StreamReader") + writer = mocker.patch("asyncio.StreamWriter") + mocker.patch.object(writer, "write", _timeout_first_attempt) + mocker.patch.object(reader, "readexactly", _mock_read) + mocker.patch.object(writer, "drain", new_callable=AsyncMock) + return reader, writer + + config = DeviceConfig("127.0.0.1") + protocol = protocol_class(transport=transport_class(config=config)) + mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + await protocol.query({}) + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + assert writer_obj.writer is not None + response = await protocol.query({}) + assert response == {"great": "success"} + + +@pytest.mark.parametrize( + ("protocol_class", "transport_class", "encryption_class"), + [ + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_handles_timeout_during_connection( + mocker, protocol_class, transport_class, encryption_class +): + attempts = 0 + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + async def _mock_read(byte_count): + nonlocal encrypted + if byte_count == transport_class.BLOCK_SIZE: + return struct.pack(">I", len(encrypted)) + if byte_count == len(encrypted): + return encrypted + + raise ValueError(f"No mock for {byte_count}") + + def aio_mock_writer(_, __): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise TimeoutError("Simulated timeout") + reader = mocker.patch("asyncio.StreamReader") + writer = mocker.patch("asyncio.StreamWriter") + mocker.patch.object(reader, "readexactly", _mock_read) + mocker.patch.object(writer, "drain", new_callable=AsyncMock) + return reader, writer + + config = DeviceConfig("127.0.0.1") + protocol = protocol_class(transport=transport_class(config=config)) + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + await writer_obj.close() + + mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + await protocol.query({"any": "thing"}) + + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + assert writer_obj.writer is not None + response = await protocol.query({}) + assert response == {"great": "success"} + + +@pytest.mark.parametrize( + ("protocol_class", "transport_class", "encryption_class"), + [ + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_handles_timeout_failure_during_write( + mocker, protocol_class, transport_class, encryption_class +): + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + def _timeout_all_attempts(*_): + raise TimeoutError("Simulated timeout") + + async def _mock_read(byte_count): + nonlocal encrypted + if byte_count == transport_class.BLOCK_SIZE: + return struct.pack(">I", len(encrypted)) + if byte_count == len(encrypted): + return encrypted + + raise ValueError(f"No mock for {byte_count}") + + def aio_mock_writer(_, __): + reader = mocker.patch("asyncio.StreamReader") + writer = mocker.patch("asyncio.StreamWriter") + mocker.patch.object(writer, "write", _timeout_all_attempts) + mocker.patch.object(reader, "readexactly", _mock_read) + mocker.patch.object(writer, "drain", new_callable=AsyncMock) + return reader, writer + + config = DeviceConfig("127.0.0.1") + protocol = protocol_class(transport=transport_class(config=config)) + mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + with pytest.raises( + TimeoutError, + match="Timeout after 5 seconds sending request to the device 127.0.0.1:9999: Simulated timeout", + ): + await protocol.query({}) + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + assert writer_obj.writer is None + + +@pytest.mark.parametrize( + ("protocol_class", "transport_class", "encryption_class"), + [ + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_handles_timeout_failure_during_connection( + mocker, protocol_class, transport_class, encryption_class +): + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + async def _mock_read(byte_count): + nonlocal encrypted + if byte_count == transport_class.BLOCK_SIZE: + return struct.pack(">I", len(encrypted)) + if byte_count == len(encrypted): + return encrypted + + raise ValueError(f"No mock for {byte_count}") + + def aio_mock_writer(_, __): + raise TimeoutError("Simulated timeout") + + config = DeviceConfig("127.0.0.1") + protocol = protocol_class(transport=transport_class(config=config)) + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + await writer_obj.close() + + mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + with pytest.raises( + TimeoutError, + match="Timeout after 5 seconds connecting to the device: 127.0.0.1:9999: Simulated timeout", + ): + await protocol.query({}) + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + assert writer_obj.writer is None + + @pytest.mark.parametrize( ("protocol_class", "transport_class", "encryption_class"), [ From 40886ef24d939e12e614792cd4889bfc137a68fe Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 6 Jan 2025 14:24:54 +0000 Subject: [PATCH 791/892] Prepare 0.9.1 (#1426) ## [0.9.1](https://github.com/python-kasa/python-kasa/tree/0.9.1) (2025-01-06) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.9.0...0.9.1) **Release summary:** - Support for hub-attached wall switches S210 and S220 - Support for older firmware on Tapo cameras - Bugfixes and improvements **Implemented enhancements:** - Add support for Tapo hub-attached switch devices [\#1421](https://github.com/python-kasa/python-kasa/pull/1421) (@sdb9696) - Use repr\(\) for enum values in Feature.\_\_repr\_\_ [\#1414](https://github.com/python-kasa/python-kasa/pull/1414) (@rytilahti) - Update SslAesTransport for older firmware versions [\#1362](https://github.com/python-kasa/python-kasa/pull/1362) (@sdb9696) **Fixed bugs:** - T310 not detected with H200 Hub [\#1409](https://github.com/python-kasa/python-kasa/issues/1409) - Backoff after xor timeout and improve error reporting [\#1424](https://github.com/python-kasa/python-kasa/pull/1424) (@bdraco) - Fix incorrect obd src echo [\#1412](https://github.com/python-kasa/python-kasa/pull/1412) (@rytilahti) - Handle smartcam partial list responses [\#1411](https://github.com/python-kasa/python-kasa/pull/1411) (@sdb9696) **Added support for devices:** - Add S220 fixture [\#1419](https://github.com/python-kasa/python-kasa/pull/1419) (@rytilahti) - Add S210 fixture [\#1418](https://github.com/python-kasa/python-kasa/pull/1418) (@rytilahti) **Documentation updates:** - Improve exception messages on credential mismatches [\#1417](https://github.com/python-kasa/python-kasa/pull/1417) (@rytilahti) **Project maintenance:** - Add C210 2.0 1.3.11 fixture [\#1406](https://github.com/python-kasa/python-kasa/pull/1406) (@sdb9696) - Add HS210\(US\) 3.0 1.0.10 IOT Fixture [\#1405](https://github.com/python-kasa/python-kasa/pull/1405) (@ZeliardM) - Change smartcam detection features to category config [\#1402](https://github.com/python-kasa/python-kasa/pull/1402) (@sdb9696) --- CHANGELOG.md | 72 +++++++--- pyproject.toml | 2 +- uv.lock | 352 ++++++++++++++++++++++++------------------------- 3 files changed, 231 insertions(+), 195 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b002704e..fefd3fa2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## [0.9.1](https://github.com/python-kasa/python-kasa/tree/0.9.1) (2025-01-06) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.9.0...0.9.1) + +**Release summary:** + +- Support for hub-attached wall switches S210 and S220 +- Support for older firmware on Tapo cameras +- Bugfixes and improvements + +**Implemented enhancements:** + +- Add support for Tapo hub-attached switch devices [\#1421](https://github.com/python-kasa/python-kasa/pull/1421) (@sdb9696) +- Use repr\(\) for enum values in Feature.\_\_repr\_\_ [\#1414](https://github.com/python-kasa/python-kasa/pull/1414) (@rytilahti) +- Update SslAesTransport for older firmware versions [\#1362](https://github.com/python-kasa/python-kasa/pull/1362) (@sdb9696) + +**Fixed bugs:** + +- T310 not detected with H200 Hub [\#1409](https://github.com/python-kasa/python-kasa/issues/1409) +- Backoff after xor timeout and improve error reporting [\#1424](https://github.com/python-kasa/python-kasa/pull/1424) (@bdraco) +- Fix incorrect obd src echo [\#1412](https://github.com/python-kasa/python-kasa/pull/1412) (@rytilahti) +- Handle smartcam partial list responses [\#1411](https://github.com/python-kasa/python-kasa/pull/1411) (@sdb9696) + +**Added support for devices:** + +- Add S220 fixture [\#1419](https://github.com/python-kasa/python-kasa/pull/1419) (@rytilahti) +- Add S210 fixture [\#1418](https://github.com/python-kasa/python-kasa/pull/1418) (@rytilahti) + +**Documentation updates:** + +- Improve exception messages on credential mismatches [\#1417](https://github.com/python-kasa/python-kasa/pull/1417) (@rytilahti) + +**Project maintenance:** + +- Add C210 2.0 1.3.11 fixture [\#1406](https://github.com/python-kasa/python-kasa/pull/1406) (@sdb9696) +- Add HS210\(US\) 3.0 1.0.10 IOT Fixture [\#1405](https://github.com/python-kasa/python-kasa/pull/1405) (@ZeliardM) +- Change smartcam detection features to category config [\#1402](https://github.com/python-kasa/python-kasa/pull/1402) (@sdb9696) + ## [0.9.0](https://github.com/python-kasa/python-kasa/tree/0.9.0) (2024-12-21) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.8.1...0.9.0) @@ -21,23 +59,23 @@ **Implemented enhancements:** -- Add rssi and signal\_level to smartcam [\#1392](https://github.com/python-kasa/python-kasa/pull/1392) (@sdb9696) -- Add smartcam detection modules [\#1389](https://github.com/python-kasa/python-kasa/pull/1389) (@sdb9696) - Add bare-bones matter modules to smart and smartcam devices [\#1371](https://github.com/python-kasa/python-kasa/pull/1371) (@sdb9696) - Add bare bones homekit modules smart and smartcam devices [\#1370](https://github.com/python-kasa/python-kasa/pull/1370) (@sdb9696) -- Return raw discovery result in cli discover raw [\#1342](https://github.com/python-kasa/python-kasa/pull/1342) (@sdb9696) - cli: print model, https, and lv for discover list [\#1339](https://github.com/python-kasa/python-kasa/pull/1339) (@rytilahti) -- Improve overheat reporting [\#1335](https://github.com/python-kasa/python-kasa/pull/1335) (@rytilahti) -- Provide alternative camera urls [\#1316](https://github.com/python-kasa/python-kasa/pull/1316) (@sdb9696) - Add LinkieTransportV2 and basic IOT.IPCAMERA support [\#1270](https://github.com/python-kasa/python-kasa/pull/1270) (@Puxtril) - Add ssltransport for robovacs [\#943](https://github.com/python-kasa/python-kasa/pull/943) (@rytilahti) +- Add rssi and signal\_level to smartcam [\#1392](https://github.com/python-kasa/python-kasa/pull/1392) (@sdb9696) +- Add smartcam detection modules [\#1389](https://github.com/python-kasa/python-kasa/pull/1389) (@sdb9696) +- Return raw discovery result in cli discover raw [\#1342](https://github.com/python-kasa/python-kasa/pull/1342) (@sdb9696) +- Improve overheat reporting [\#1335](https://github.com/python-kasa/python-kasa/pull/1335) (@rytilahti) +- Provide alternative camera urls [\#1316](https://github.com/python-kasa/python-kasa/pull/1316) (@sdb9696) **Fixed bugs:** - Tapo H200 Hub does not work with python-kasa [\#1149](https://github.com/python-kasa/python-kasa/issues/1149) -- Treat smartcam 500 errors after handshake as retryable [\#1395](https://github.com/python-kasa/python-kasa/pull/1395) (@sdb9696) - Fix lens mask required component and state [\#1386](https://github.com/python-kasa/python-kasa/pull/1386) (@sdb9696) - Add LensMask module to smartcam [\#1385](https://github.com/python-kasa/python-kasa/pull/1385) (@sdb9696) +- Treat smartcam 500 errors after handshake as retryable [\#1395](https://github.com/python-kasa/python-kasa/pull/1395) (@sdb9696) - Do not error when accessing smart device\_type before update [\#1319](https://github.com/python-kasa/python-kasa/pull/1319) (@sdb9696) - Fallback to other module data on get\_energy\_usage errors [\#1245](https://github.com/python-kasa/python-kasa/pull/1245) (@rytilahti) @@ -47,41 +85,41 @@ - Add C225\(US\) 2.0 1.0.11 fixture [\#1398](https://github.com/python-kasa/python-kasa/pull/1398) (@sdb9696) - Add P306\(US\) 1.0 1.1.2 fixture [\#1396](https://github.com/python-kasa/python-kasa/pull/1396) (@nakanaela) - Add TC70 3.0 1.3.11 fixture [\#1390](https://github.com/python-kasa/python-kasa/pull/1390) (@sdb9696) -- Add C325WB\(EU\) 1.0 1.1.17 Fixture [\#1379](https://github.com/python-kasa/python-kasa/pull/1379) (@sdb9696) -- Add C100 4.0 1.3.14 Fixture [\#1378](https://github.com/python-kasa/python-kasa/pull/1378) (@sdb9696) - Add KS200 \(US\) IOT Fixture and P115 \(US\) Smart Fixture [\#1355](https://github.com/python-kasa/python-kasa/pull/1355) (@ZeliardM) - Add C520WS camera fixture [\#1352](https://github.com/python-kasa/python-kasa/pull/1352) (@Happy-Cadaver) +- Add C325WB\(EU\) 1.0 1.1.17 Fixture [\#1379](https://github.com/python-kasa/python-kasa/pull/1379) (@sdb9696) +- Add C100 4.0 1.3.14 Fixture [\#1378](https://github.com/python-kasa/python-kasa/pull/1378) (@sdb9696) **Documentation updates:** - Update docs for Tapo Lab Third-Party compatibility [\#1380](https://github.com/python-kasa/python-kasa/pull/1380) (@sdb9696) - Add homebridge-kasa-python link to README [\#1367](https://github.com/python-kasa/python-kasa/pull/1367) (@rytilahti) -- Update docs for new FeatureAttribute behaviour [\#1365](https://github.com/python-kasa/python-kasa/pull/1365) (@sdb9696) - Add link to related homeassistant-tapo-control [\#1333](https://github.com/python-kasa/python-kasa/pull/1333) (@rytilahti) +- Update docs for new FeatureAttribute behaviour [\#1365](https://github.com/python-kasa/python-kasa/pull/1365) (@sdb9696) **Project maintenance:** - Add P135 1.0 1.2.0 fixture [\#1397](https://github.com/python-kasa/python-kasa/pull/1397) (@sdb9696) -- Handle smartcam device blocked response [\#1393](https://github.com/python-kasa/python-kasa/pull/1393) (@sdb9696) -- Handle KeyboardInterrupts in the cli better [\#1391](https://github.com/python-kasa/python-kasa/pull/1391) (@sdb9696) - Update C520WS fixture with new methods [\#1384](https://github.com/python-kasa/python-kasa/pull/1384) (@sdb9696) - Miscellaneous minor fixes to dump\_devinfo [\#1382](https://github.com/python-kasa/python-kasa/pull/1382) (@sdb9696) - Add timeout parameter to dump\_devinfo [\#1381](https://github.com/python-kasa/python-kasa/pull/1381) (@sdb9696) -- Simplify get\_protocol to prevent clashes with smartcam and robovac [\#1377](https://github.com/python-kasa/python-kasa/pull/1377) (@sdb9696) -- Add smartcam modules to package inits [\#1376](https://github.com/python-kasa/python-kasa/pull/1376) (@sdb9696) -- Enable saving of fixture files without git clone [\#1375](https://github.com/python-kasa/python-kasa/pull/1375) (@sdb9696) - Force single for some smartcam requests [\#1374](https://github.com/python-kasa/python-kasa/pull/1374) (@sdb9696) -- Add new methods to dump\_devinfo [\#1373](https://github.com/python-kasa/python-kasa/pull/1373) (@sdb9696) -- Update cli, light modules, and docs to use FeatureAttributes [\#1364](https://github.com/python-kasa/python-kasa/pull/1364) (@sdb9696) - Pass raw components to SmartChildDevice init [\#1363](https://github.com/python-kasa/python-kasa/pull/1363) (@sdb9696) - Fix line endings in device\_fixtures.py [\#1361](https://github.com/python-kasa/python-kasa/pull/1361) (@sdb9696) -- Update dump\_devinfo for raw discovery json and common redactors [\#1358](https://github.com/python-kasa/python-kasa/pull/1358) (@sdb9696) - Tweak RELEASING.md instructions for patch releases [\#1347](https://github.com/python-kasa/python-kasa/pull/1347) (@sdb9696) - Scrub more vacuum keys [\#1328](https://github.com/python-kasa/python-kasa/pull/1328) (@rytilahti) - Remove unnecessary check for python \<3.10 [\#1326](https://github.com/python-kasa/python-kasa/pull/1326) (@rytilahti) - Add vacuum component queries to dump\_devinfo [\#1320](https://github.com/python-kasa/python-kasa/pull/1320) (@rytilahti) - Handle missing mgt\_encryption\_schm in discovery [\#1318](https://github.com/python-kasa/python-kasa/pull/1318) (@sdb9696) - Follow main package structure for tests [\#1317](https://github.com/python-kasa/python-kasa/pull/1317) (@rytilahti) +- Handle smartcam device blocked response [\#1393](https://github.com/python-kasa/python-kasa/pull/1393) (@sdb9696) +- Handle KeyboardInterrupts in the cli better [\#1391](https://github.com/python-kasa/python-kasa/pull/1391) (@sdb9696) +- Simplify get\_protocol to prevent clashes with smartcam and robovac [\#1377](https://github.com/python-kasa/python-kasa/pull/1377) (@sdb9696) +- Add smartcam modules to package inits [\#1376](https://github.com/python-kasa/python-kasa/pull/1376) (@sdb9696) +- Enable saving of fixture files without git clone [\#1375](https://github.com/python-kasa/python-kasa/pull/1375) (@sdb9696) +- Add new methods to dump\_devinfo [\#1373](https://github.com/python-kasa/python-kasa/pull/1373) (@sdb9696) +- Update cli, light modules, and docs to use FeatureAttributes [\#1364](https://github.com/python-kasa/python-kasa/pull/1364) (@sdb9696) +- Update dump\_devinfo for raw discovery json and common redactors [\#1358](https://github.com/python-kasa/python-kasa/pull/1358) (@sdb9696) ## [0.8.1](https://github.com/python-kasa/python-kasa/tree/0.8.1) (2024-12-06) diff --git a/pyproject.toml b/pyproject.toml index 2ad192e4c..e0905917c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-kasa" -version = "0.9.0" +version = "0.9.1" description = "Python API for TP-Link Kasa and Tapo devices" license = {text = "GPL-3.0-or-later"} authors = [ { name = "python-kasa developers" }] diff --git a/uv.lock b/uv.lock index e8ca1c4b7..df6132cab 100644 --- a/uv.lock +++ b/uv.lock @@ -95,16 +95,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.7.0" +version = "4.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/40/318e58f669b1a9e00f5c4453910682e2d9dd594334539c7b7817dabb765f/anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48", size = 177076 } +sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/7a/4daaf3b6c08ad7ceffea4634ec206faeff697526421c20f07628c7372156/anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352", size = 93052 }, + { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, ] [[package]] @@ -118,15 +118,16 @@ wheels = [ [[package]] name = "asyncclick" -version = "8.1.7.2" +version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "colorama", marker = "platform_system == 'Windows'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/bf/59d836c3433d7aa07f76c2b95c4eb763195ea8a5d7f9ad3311ed30c2af61/asyncclick-8.1.7.2.tar.gz", hash = "sha256:219ea0f29ccdc1bb4ff43bcab7ce0769ac6d48a04f997b43ec6bee99a222daa0", size = 349073 } +sdist = { url = "https://files.pythonhosted.org/packages/cb/b5/e1e5fdf1c1bb7e6e614987c120a98d9324bf8edfaa5f5cd16a6235c9d91b/asyncclick-8.1.8.tar.gz", hash = "sha256:0f0eb0f280e04919d67cf71b9fcdfb4db2d9ff7203669c40284485c149578e4c", size = 232900 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/6e/9acdbb25733e1de411663b59abe521bec738e72fe4e85843f6ff8b212832/asyncclick-8.1.7.2-py3-none-any.whl", hash = "sha256:1ab940b04b22cb89b5b400725132b069d01b0c3472a9702c7a2c9d5d007ded02", size = 99191 }, + { url = "https://files.pythonhosted.org/packages/14/cc/a436f0fc2d04e57a0697e0f87a03b9eaed03ad043d2d5f887f8eebcec95f/asyncclick-8.1.8-py3-none-any.whl", hash = "sha256:eb1ccb44bc767f8f0695d592c7806fdf5bd575605b4ee246ffd5fadbcfdbd7c6", size = 99093 }, + { url = "https://files.pythonhosted.org/packages/92/c4/ae9e9d25522c6dc96ff167903880a0fe94d7bd31ed999198ee5017d977ed/asyncclick-8.1.8.0-py3-none-any.whl", hash = "sha256:be146a2d8075d4fe372ff4e877f23c8b5af269d16705c1948123b9415f6fd678", size = 99115 }, ] [[package]] @@ -212,56 +213,50 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/61/73589dcc7a719582bf56aae309b6103d2762b526bffe189d635a7fcfd998/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", size = 193339 }, - { url = "https://files.pythonhosted.org/packages/77/d5/8c982d58144de49f59571f940e329ad6e8615e1e82ef84584c5eeb5e1d72/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", size = 124366 }, - { url = "https://files.pythonhosted.org/packages/bf/19/411a64f01ee971bed3231111b69eb56f9331a769072de479eae7de52296d/charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", size = 118874 }, - { url = "https://files.pythonhosted.org/packages/4c/92/97509850f0d00e9f14a46bc751daabd0ad7765cff29cdfb66c68b6dad57f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", size = 138243 }, - { url = "https://files.pythonhosted.org/packages/e2/29/d227805bff72ed6d6cb1ce08eec707f7cfbd9868044893617eb331f16295/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", size = 148676 }, - { url = "https://files.pythonhosted.org/packages/13/bc/87c2c9f2c144bedfa62f894c3007cd4530ba4b5351acb10dc786428a50f0/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", size = 141289 }, - { url = "https://files.pythonhosted.org/packages/eb/5b/6f10bad0f6461fa272bfbbdf5d0023b5fb9bc6217c92bf068fa5a99820f5/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", size = 142585 }, - { url = "https://files.pythonhosted.org/packages/3b/a0/a68980ab8a1f45a36d9745d35049c1af57d27255eff8c907e3add84cf68f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", size = 144408 }, - { url = "https://files.pythonhosted.org/packages/d7/a1/493919799446464ed0299c8eef3c3fad0daf1c3cd48bff9263c731b0d9e2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", size = 139076 }, - { url = "https://files.pythonhosted.org/packages/fb/9d/9c13753a5a6e0db4a0a6edb1cef7aee39859177b64e1a1e748a6e3ba62c2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", size = 146874 }, - { url = "https://files.pythonhosted.org/packages/75/d2/0ab54463d3410709c09266dfb416d032a08f97fd7d60e94b8c6ef54ae14b/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", size = 150871 }, - { url = "https://files.pythonhosted.org/packages/8d/c9/27e41d481557be53d51e60750b85aa40eaf52b841946b3cdeff363105737/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", size = 148546 }, - { url = "https://files.pythonhosted.org/packages/ee/44/4f62042ca8cdc0cabf87c0fc00ae27cd8b53ab68be3605ba6d071f742ad3/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", size = 143048 }, - { url = "https://files.pythonhosted.org/packages/01/f8/38842422988b795220eb8038745d27a675ce066e2ada79516c118f291f07/charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", size = 94389 }, - { url = "https://files.pythonhosted.org/packages/0b/6e/b13bd47fa9023b3699e94abf565b5a2f0b0be6e9ddac9812182596ee62e4/charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", size = 101752 }, - { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 }, - { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 }, - { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 }, - { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 }, - { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 }, - { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 }, - { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 }, - { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 }, - { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 }, - { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 }, - { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 }, - { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 }, - { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 }, - { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 }, - { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 }, - { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, - { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, - { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, - { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, - { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, - { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, - { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, - { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, - { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, - { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, - { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, - { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, - { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, - { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, - { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, - { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, + { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, + { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, + { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, + { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, + { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, + { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, + { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, + { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, + { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, + { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, + { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, + { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, ] [[package]] @@ -288,50 +283,50 @@ wheels = [ [[package]] name = "coverage" -version = "7.6.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/d2/c25011f4d036cf7e8acbbee07a8e09e9018390aee25ba085596c4b83d510/coverage-7.6.9.tar.gz", hash = "sha256:4a8d8977b0c6ef5aeadcb644da9e69ae0dcfe66ec7f368c89c72e058bd71164d", size = 801710 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/91/b3dc2f7f38b5cca1236ab6bbb03e84046dd887707b4ec1db2baa47493b3b/coverage-7.6.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:932fc826442132dde42ee52cf66d941f581c685a6313feebed358411238f60f9", size = 207133 }, - { url = "https://files.pythonhosted.org/packages/0d/2b/53fd6cb34d443429a92b3ec737f4953627e38b3bee2a67a3c03425ba8573/coverage-7.6.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:085161be5f3b30fd9b3e7b9a8c301f935c8313dcf928a07b116324abea2c1c2c", size = 207577 }, - { url = "https://files.pythonhosted.org/packages/74/f2/68edb1e6826f980a124f21ea5be0d324180bf11de6fd1defcf9604f76df0/coverage-7.6.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc660a77e1c2bf24ddbce969af9447a9474790160cfb23de6be4fa88e3951c7", size = 239524 }, - { url = "https://files.pythonhosted.org/packages/d3/83/8fec0ee68c2c4a5ab5f0f8527277f84ed6f2bd1310ae8a19d0c5532253ab/coverage-7.6.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c69e42c892c018cd3c8d90da61d845f50a8243062b19d228189b0224150018a9", size = 236925 }, - { url = "https://files.pythonhosted.org/packages/8b/20/8f50e7c7ad271144afbc2c1c6ec5541a8c81773f59352f8db544cad1a0ec/coverage-7.6.9-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0824a28ec542a0be22f60c6ac36d679e0e262e5353203bea81d44ee81fe9c6d4", size = 238792 }, - { url = "https://files.pythonhosted.org/packages/6f/62/4ac2e5ad9e7a5c9ec351f38947528e11541f1f00e8a0cdce56f1ba7ae301/coverage-7.6.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4401ae5fc52ad8d26d2a5d8a7428b0f0c72431683f8e63e42e70606374c311a1", size = 237682 }, - { url = "https://files.pythonhosted.org/packages/58/2f/9d2203f012f3b0533c73336c74134b608742be1ce475a5c72012573cfbb4/coverage-7.6.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98caba4476a6c8d59ec1eb00c7dd862ba9beca34085642d46ed503cc2d440d4b", size = 236310 }, - { url = "https://files.pythonhosted.org/packages/33/6d/31f6ab0b4f0f781636075f757eb02141ea1b34466d9d1526dbc586ed7078/coverage-7.6.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ee5defd1733fd6ec08b168bd4f5387d5b322f45ca9e0e6c817ea6c4cd36313e3", size = 237096 }, - { url = "https://files.pythonhosted.org/packages/7d/fb/e14c38adebbda9ed8b5f7f8e03340ac05d68d27b24397f8d47478927a333/coverage-7.6.9-cp311-cp311-win32.whl", hash = "sha256:f2d1ec60d6d256bdf298cb86b78dd715980828f50c46701abc3b0a2b3f8a0dc0", size = 209682 }, - { url = "https://files.pythonhosted.org/packages/a4/11/a782af39b019066af83fdc0e8825faaccbe9d7b19a803ddb753114b429cc/coverage-7.6.9-cp311-cp311-win_amd64.whl", hash = "sha256:0d59fd927b1f04de57a2ba0137166d31c1a6dd9e764ad4af552912d70428c92b", size = 210542 }, - { url = "https://files.pythonhosted.org/packages/60/52/b16af8989a2daf0f80a88522bd8e8eed90b5fcbdecf02a6888f3e80f6ba7/coverage-7.6.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:99e266ae0b5d15f1ca8d278a668df6f51cc4b854513daab5cae695ed7b721cf8", size = 207325 }, - { url = "https://files.pythonhosted.org/packages/0f/79/6b7826fca8846c1216a113227b9f114ac3e6eacf168b4adcad0cb974aaca/coverage-7.6.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9901d36492009a0a9b94b20e52ebfc8453bf49bb2b27bca2c9706f8b4f5a554a", size = 207563 }, - { url = "https://files.pythonhosted.org/packages/a7/07/0bc73da0ccaf45d0d64ef86d33b7d7fdeef84b4c44bf6b85fb12c215c5a6/coverage-7.6.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abd3e72dd5b97e3af4246cdada7738ef0e608168de952b837b8dd7e90341f015", size = 240580 }, - { url = "https://files.pythonhosted.org/packages/71/8a/9761f409910961647d892454687cedbaccb99aae828f49486734a82ede6e/coverage-7.6.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff74026a461eb0660366fb01c650c1d00f833a086b336bdad7ab00cc952072b3", size = 237613 }, - { url = "https://files.pythonhosted.org/packages/8b/10/ee7d696a17ac94f32f2dbda1e17e730bf798ae9931aec1fc01c1944cd4de/coverage-7.6.9-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65dad5a248823a4996724a88eb51d4b31587aa7aa428562dbe459c684e5787ae", size = 239684 }, - { url = "https://files.pythonhosted.org/packages/16/60/aa1066040d3c52fff051243c2d6ccda264da72dc6d199d047624d395b2b2/coverage-7.6.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22be16571504c9ccea919fcedb459d5ab20d41172056206eb2994e2ff06118a4", size = 239112 }, - { url = "https://files.pythonhosted.org/packages/4e/e5/69f35344c6f932ba9028bf168d14a79fedb0dd4849b796d43c81ce75a3c9/coverage-7.6.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f957943bc718b87144ecaee70762bc2bc3f1a7a53c7b861103546d3a403f0a6", size = 237428 }, - { url = "https://files.pythonhosted.org/packages/32/20/adc895523c4a28f63441b8ac645abd74f9bdd499d2d175bef5b41fc7f92d/coverage-7.6.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae1387db4aecb1f485fb70a6c0148c6cdaebb6038f1d40089b1fc84a5db556f", size = 239098 }, - { url = "https://files.pythonhosted.org/packages/a9/a6/e0e74230c9bb3549ec8ffc137cfd16ea5d56e993d6bffed2218bff6187e3/coverage-7.6.9-cp312-cp312-win32.whl", hash = "sha256:1a330812d9cc7ac2182586f6d41b4d0fadf9be9049f350e0efb275c8ee8eb692", size = 209940 }, - { url = "https://files.pythonhosted.org/packages/3e/18/cb5b88349d4aa2f41ec78d65f92ea32572b30b3f55bc2b70e87578b8f434/coverage-7.6.9-cp312-cp312-win_amd64.whl", hash = "sha256:b12c6b18269ca471eedd41c1b6a1065b2f7827508edb9a7ed5555e9a56dcfc97", size = 210726 }, - { url = "https://files.pythonhosted.org/packages/35/26/9abab6539d2191dbda2ce8c97b67d74cbfc966cc5b25abb880ffc7c459bc/coverage-7.6.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:899b8cd4781c400454f2f64f7776a5d87bbd7b3e7f7bda0cb18f857bb1334664", size = 207356 }, - { url = "https://files.pythonhosted.org/packages/44/da/d49f19402240c93453f606e660a6676a2a1fbbaa6870cc23207790aa9697/coverage-7.6.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:61f70dc68bd36810972e55bbbe83674ea073dd1dcc121040a08cdf3416c5349c", size = 207614 }, - { url = "https://files.pythonhosted.org/packages/da/e6/93bb9bf85497816082ec8da6124c25efa2052bd4c887dd3b317b91990c9e/coverage-7.6.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a289d23d4c46f1a82d5db4abeb40b9b5be91731ee19a379d15790e53031c014", size = 240129 }, - { url = "https://files.pythonhosted.org/packages/df/65/6a824b9406fe066835c1274a9949e06f084d3e605eb1a602727a27ec2fe3/coverage-7.6.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e216d8044a356fc0337c7a2a0536d6de07888d7bcda76febcb8adc50bdbbd00", size = 237276 }, - { url = "https://files.pythonhosted.org/packages/9f/79/6c7a800913a9dd23ac8c8da133ebb556771a5a3d4df36b46767b1baffd35/coverage-7.6.9-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c026eb44f744acaa2bda7493dad903aa5bf5fc4f2554293a798d5606710055d", size = 239267 }, - { url = "https://files.pythonhosted.org/packages/57/e7/834d530293fdc8a63ba8ff70033d5182022e569eceb9aec7fc716b678a39/coverage-7.6.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e77363e8425325384f9d49272c54045bbed2f478e9dd698dbc65dbc37860eb0a", size = 238887 }, - { url = "https://files.pythonhosted.org/packages/15/05/ec9d6080852984f7163c96984444e7cd98b338fd045b191064f943ee1c08/coverage-7.6.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:777abfab476cf83b5177b84d7486497e034eb9eaea0d746ce0c1268c71652077", size = 236970 }, - { url = "https://files.pythonhosted.org/packages/0a/d8/775937670b93156aec29f694ce37f56214ed7597e1a75b4083ee4c32121c/coverage-7.6.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:447af20e25fdbe16f26e84eb714ba21d98868705cb138252d28bc400381f6ffb", size = 238831 }, - { url = "https://files.pythonhosted.org/packages/f4/58/88551cb7fdd5ec98cb6044e8814e38583436b14040a5ece15349c44c8f7c/coverage-7.6.9-cp313-cp313-win32.whl", hash = "sha256:d872ec5aeb086cbea771c573600d47944eea2dcba8be5f3ee649bfe3cb8dc9ba", size = 210000 }, - { url = "https://files.pythonhosted.org/packages/b7/12/cfbf49b95120872785ff8d56ab1c7fe3970a65e35010c311d7dd35c5fd00/coverage-7.6.9-cp313-cp313-win_amd64.whl", hash = "sha256:fd1213c86e48dfdc5a0cc676551db467495a95a662d2396ecd58e719191446e1", size = 210753 }, - { url = "https://files.pythonhosted.org/packages/7c/68/c1cb31445599b04bde21cbbaa6d21b47c5823cdfef99eae470dfce49c35a/coverage-7.6.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9e7484d286cd5a43744e5f47b0b3fb457865baf07bafc6bee91896364e1419", size = 208091 }, - { url = "https://files.pythonhosted.org/packages/11/73/84b02c6b19c4a11eb2d5b5eabe926fb26c21c080e0852f5e5a4f01165f9e/coverage-7.6.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1cf0872ee455c03e5674b5bca5e3e68e159379c1af0903e89f5eba9ccc3a", size = 208369 }, - { url = "https://files.pythonhosted.org/packages/de/e0/ae5d878b72ff26df2e994a5c5b1c1f6a7507d976b23beecb1ed4c85411ef/coverage-7.6.9-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d10e07aa2b91835d6abec555ec8b2733347956991901eea6ffac295f83a30e4", size = 251089 }, - { url = "https://files.pythonhosted.org/packages/ab/9c/0aaac011aef95a93ef3cb2fba3fde30bc7e68a6635199ed469b1f5ea355a/coverage-7.6.9-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13a9e2d3ee855db3dd6ea1ba5203316a1b1fd8eaeffc37c5b54987e61e4194ae", size = 246806 }, - { url = "https://files.pythonhosted.org/packages/f8/19/4d5d3ae66938a7dcb2f58cef3fa5386f838f469575b0bb568c8cc9e3a33d/coverage-7.6.9-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c38bf15a40ccf5619fa2fe8f26106c7e8e080d7760aeccb3722664c8656b030", size = 249164 }, - { url = "https://files.pythonhosted.org/packages/b3/0b/4ee8a7821f682af9ad440ae3c1e379da89a998883271f088102d7ca2473d/coverage-7.6.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d5275455b3e4627c8e7154feaf7ee0743c2e7af82f6e3b561967b1cca755a0be", size = 248642 }, - { url = "https://files.pythonhosted.org/packages/8a/12/36ff1d52be18a16b4700f561852e7afd8df56363a5edcfb04cf26a0e19e0/coverage-7.6.9-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8f8770dfc6e2c6a2d4569f411015c8d751c980d17a14b0530da2d7f27ffdd88e", size = 246516 }, - { url = "https://files.pythonhosted.org/packages/43/d0/8e258f6c3a527c1655602f4f576215e055ac704de2d101710a71a2affac2/coverage-7.6.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8d2dfa71665a29b153a9681edb1c8d9c1ea50dfc2375fb4dac99ea7e21a0bcd9", size = 247783 }, - { url = "https://files.pythonhosted.org/packages/a9/0d/1e4a48d289429d38aae3babdfcadbf35ca36bdcf3efc8f09b550a845bdb5/coverage-7.6.9-cp313-cp313t-win32.whl", hash = "sha256:5e6b86b5847a016d0fbd31ffe1001b63355ed309651851295315031ea7eb5a9b", size = 210646 }, - { url = "https://files.pythonhosted.org/packages/26/74/b0729f196f328ac55e42b1e22ec2f16d8bcafe4b8158a26ec9f1cdd1d93e/coverage-7.6.9-cp313-cp313t-win_amd64.whl", hash = "sha256:97ddc94d46088304772d21b060041c97fc16bdda13c6c7f9d8fcd8d5ae0d8611", size = 211815 }, +version = "7.6.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/ba/ac14d281f80aab516275012e8875991bb06203957aa1e19950139238d658/coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23", size = 803868 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/d2/5e175fcf6766cf7501a8541d81778fd2f52f4870100e791f5327fd23270b/coverage-7.6.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ea3c8f04b3e4af80e17bab607c386a830ffc2fb88a5484e1df756478cf70d1d3", size = 208088 }, + { url = "https://files.pythonhosted.org/packages/4b/6f/06db4dc8fca33c13b673986e20e466fd936235a6ec1f0045c3853ac1b593/coverage-7.6.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:507a20fc863cae1d5720797761b42d2d87a04b3e5aeb682ef3b7332e90598f43", size = 208536 }, + { url = "https://files.pythonhosted.org/packages/0d/62/c6a0cf80318c1c1af376d52df444da3608eafc913b82c84a4600d8349472/coverage-7.6.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d37a84878285b903c0fe21ac8794c6dab58150e9359f1aaebbeddd6412d53132", size = 240474 }, + { url = "https://files.pythonhosted.org/packages/a3/59/750adafc2e57786d2e8739a46b680d4fb0fbc2d57fbcb161290a9f1ecf23/coverage-7.6.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a534738b47b0de1995f85f582d983d94031dffb48ab86c95bdf88dc62212142f", size = 237880 }, + { url = "https://files.pythonhosted.org/packages/2c/f8/ef009b3b98e9f7033c19deb40d629354aab1d8b2d7f9cfec284dbedf5096/coverage-7.6.10-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d7a2bf79378d8fb8afaa994f91bfd8215134f8631d27eba3e0e2c13546ce994", size = 239750 }, + { url = "https://files.pythonhosted.org/packages/a6/e2/6622f3b70f5f5b59f705e680dae6db64421af05a5d1e389afd24dae62e5b/coverage-7.6.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6713ba4b4ebc330f3def51df1d5d38fad60b66720948112f114968feb52d3f99", size = 238642 }, + { url = "https://files.pythonhosted.org/packages/2d/10/57ac3f191a3c95c67844099514ff44e6e19b2915cd1c22269fb27f9b17b6/coverage-7.6.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab32947f481f7e8c763fa2c92fd9f44eeb143e7610c4ca9ecd6a36adab4081bd", size = 237266 }, + { url = "https://files.pythonhosted.org/packages/ee/2d/7016f4ad9d553cabcb7333ed78ff9d27248ec4eba8dd21fa488254dff894/coverage-7.6.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7bbd8c8f1b115b892e34ba66a097b915d3871db7ce0e6b9901f462ff3a975377", size = 238045 }, + { url = "https://files.pythonhosted.org/packages/a7/fe/45af5c82389a71e0cae4546413266d2195c3744849669b0bab4b5f2c75da/coverage-7.6.10-cp311-cp311-win32.whl", hash = "sha256:299e91b274c5c9cdb64cbdf1b3e4a8fe538a7a86acdd08fae52301b28ba297f8", size = 210647 }, + { url = "https://files.pythonhosted.org/packages/db/11/3f8e803a43b79bc534c6a506674da9d614e990e37118b4506faf70d46ed6/coverage-7.6.10-cp311-cp311-win_amd64.whl", hash = "sha256:489a01f94aa581dbd961f306e37d75d4ba16104bbfa2b0edb21d29b73be83609", size = 211508 }, + { url = "https://files.pythonhosted.org/packages/86/77/19d09ea06f92fdf0487499283b1b7af06bc422ea94534c8fe3a4cd023641/coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853", size = 208281 }, + { url = "https://files.pythonhosted.org/packages/b6/67/5479b9f2f99fcfb49c0d5cf61912a5255ef80b6e80a3cddba39c38146cf4/coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078", size = 208514 }, + { url = "https://files.pythonhosted.org/packages/15/d1/febf59030ce1c83b7331c3546d7317e5120c5966471727aa7ac157729c4b/coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0", size = 241537 }, + { url = "https://files.pythonhosted.org/packages/4b/7e/5ac4c90192130e7cf8b63153fe620c8bfd9068f89a6d9b5f26f1550f7a26/coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50", size = 238572 }, + { url = "https://files.pythonhosted.org/packages/dc/03/0334a79b26ecf59958f2fe9dd1f5ab3e2f88db876f5071933de39af09647/coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022", size = 240639 }, + { url = "https://files.pythonhosted.org/packages/d7/45/8a707f23c202208d7b286d78ad6233f50dcf929319b664b6cc18a03c1aae/coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b", size = 240072 }, + { url = "https://files.pythonhosted.org/packages/66/02/603ce0ac2d02bc7b393279ef618940b4a0535b0868ee791140bda9ecfa40/coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0", size = 238386 }, + { url = "https://files.pythonhosted.org/packages/04/62/4e6887e9be060f5d18f1dd58c2838b2d9646faf353232dec4e2d4b1c8644/coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852", size = 240054 }, + { url = "https://files.pythonhosted.org/packages/5c/74/83ae4151c170d8bd071924f212add22a0e62a7fe2b149edf016aeecad17c/coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359", size = 210904 }, + { url = "https://files.pythonhosted.org/packages/c3/54/de0893186a221478f5880283119fc40483bc460b27c4c71d1b8bba3474b9/coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247", size = 211692 }, + { url = "https://files.pythonhosted.org/packages/25/6d/31883d78865529257bf847df5789e2ae80e99de8a460c3453dbfbe0db069/coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9", size = 208308 }, + { url = "https://files.pythonhosted.org/packages/70/22/3f2b129cc08de00c83b0ad6252e034320946abfc3e4235c009e57cfeee05/coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b", size = 208565 }, + { url = "https://files.pythonhosted.org/packages/97/0a/d89bc2d1cc61d3a8dfe9e9d75217b2be85f6c73ebf1b9e3c2f4e797f4531/coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690", size = 241083 }, + { url = "https://files.pythonhosted.org/packages/4c/81/6d64b88a00c7a7aaed3a657b8eaa0931f37a6395fcef61e53ff742b49c97/coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18", size = 238235 }, + { url = "https://files.pythonhosted.org/packages/9a/0b/7797d4193f5adb4b837207ed87fecf5fc38f7cc612b369a8e8e12d9fa114/coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c", size = 240220 }, + { url = "https://files.pythonhosted.org/packages/65/4d/6f83ca1bddcf8e51bf8ff71572f39a1c73c34cf50e752a952c34f24d0a60/coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd", size = 239847 }, + { url = "https://files.pythonhosted.org/packages/30/9d/2470df6aa146aff4c65fee0f87f58d2164a67533c771c9cc12ffcdb865d5/coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e", size = 237922 }, + { url = "https://files.pythonhosted.org/packages/08/dd/723fef5d901e6a89f2507094db66c091449c8ba03272861eaefa773ad95c/coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694", size = 239783 }, + { url = "https://files.pythonhosted.org/packages/3d/f7/64d3298b2baf261cb35466000628706ce20a82d42faf9b771af447cd2b76/coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6", size = 210965 }, + { url = "https://files.pythonhosted.org/packages/d5/58/ec43499a7fc681212fe7742fe90b2bc361cdb72e3181ace1604247a5b24d/coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e", size = 211719 }, + { url = "https://files.pythonhosted.org/packages/ab/c9/f2857a135bcff4330c1e90e7d03446b036b2363d4ad37eb5e3a47bbac8a6/coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe", size = 209050 }, + { url = "https://files.pythonhosted.org/packages/aa/b3/f840e5bd777d8433caa9e4a1eb20503495709f697341ac1a8ee6a3c906ad/coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273", size = 209321 }, + { url = "https://files.pythonhosted.org/packages/85/7d/125a5362180fcc1c03d91850fc020f3831d5cda09319522bcfa6b2b70be7/coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8", size = 252039 }, + { url = "https://files.pythonhosted.org/packages/a9/9c/4358bf3c74baf1f9bddd2baf3756b54c07f2cfd2535f0a47f1e7757e54b3/coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098", size = 247758 }, + { url = "https://files.pythonhosted.org/packages/cf/c7/de3eb6fc5263b26fab5cda3de7a0f80e317597a4bad4781859f72885f300/coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb", size = 250119 }, + { url = "https://files.pythonhosted.org/packages/3e/e6/43de91f8ba2ec9140c6a4af1102141712949903dc732cf739167cfa7a3bc/coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0", size = 249597 }, + { url = "https://files.pythonhosted.org/packages/08/40/61158b5499aa2adf9e37bc6d0117e8f6788625b283d51e7e0c53cf340530/coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf", size = 247473 }, + { url = "https://files.pythonhosted.org/packages/50/69/b3f2416725621e9f112e74e8470793d5b5995f146f596f133678a633b77e/coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2", size = 248737 }, + { url = "https://files.pythonhosted.org/packages/3c/6e/fe899fb937657db6df31cc3e61c6968cb56d36d7326361847440a430152e/coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312", size = 211611 }, + { url = "https://files.pythonhosted.org/packages/1c/55/52f5e66142a9d7bc93a15192eba7a78513d2abf6b3558d77b4ca32f5f424/coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d", size = 212781 }, ] [package.optional-dependencies] @@ -474,11 +469,11 @@ wheels = [ [[package]] name = "identify" -version = "2.6.3" +version = "2.6.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/5f/05f0d167be94585d502b4adf8c7af31f1dc0b1c7e14f9938a88fdbbcf4a7/identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02", size = 99179 } +sdist = { url = "https://files.pythonhosted.org/packages/cf/92/69934b9ef3c31ca2470980423fda3d00f0460ddefdf30a67adf7f17e2e00/identify-2.6.5.tar.gz", hash = "sha256:c10b33f250e5bba374fae86fb57f3adcebf1161bce7cdf92031915fd480c13bc", size = 99213 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/f5/09644a3ad803fae9eca8efa17e1f2aef380c7f0b02f7ec4e8d446e51d64a/identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd", size = 99049 }, + { url = "https://files.pythonhosted.org/packages/ec/fa/dce098f4cdf7621aa8f7b4f919ce545891f489482f0bfa5102f3eca8608b/identify-2.6.5-py2.py3-none-any.whl", hash = "sha256:14181a47091eb75b337af4c23078c9d09225cd4c48929f521f3bf16b09d02566", size = 99078 }, ] [[package]] @@ -522,14 +517,14 @@ wheels = [ [[package]] name = "jinja2" -version = "3.1.4" +version = "3.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245 } +sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674 } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 }, + { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596 }, ] [[package]] @@ -703,30 +698,33 @@ wheels = [ [[package]] name = "mypy" -version = "1.14.0" +version = "1.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/7b/08046ef9330735f536a09a2e31b00f42bccdb2795dcd979636ba43bb2d63/mypy-1.14.0.tar.gz", hash = "sha256:822dbd184d4a9804df5a7d5335a68cf7662930e70b8c1bc976645d1509f9a9d6", size = 3215684 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/c1/b9dd3e955953aec1c728992545b7877c9f6fa742a623ce4c200da0f62540/mypy-1.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6e73c8a154eed31db3445fe28f63ad2d97b674b911c00191416cf7f6459fd49a", size = 11121032 }, - { url = "https://files.pythonhosted.org/packages/ee/96/c52d5d516819ab95bf41f4a1ada828a3decc302f8c152ff4fc5feb0e4529/mypy-1.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:273e70fcb2e38c5405a188425aa60b984ffdcef65d6c746ea5813024b68c73dc", size = 10286294 }, - { url = "https://files.pythonhosted.org/packages/69/2c/3dbe51877a24daa467f8d8631f9ffd1aabbf0f6d9367a01c44a59df81fe0/mypy-1.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1daca283d732943731a6a9f20fdbcaa927f160bc51602b1d4ef880a6fb252015", size = 12746528 }, - { url = "https://files.pythonhosted.org/packages/a1/a8/eb20cde4ba9c4c3e20d958918a7c5d92210f4d1a0200c27de9a641f70996/mypy-1.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7e68047bedb04c1c25bba9901ea46ff60d5eaac2d71b1f2161f33107e2b368eb", size = 12883489 }, - { url = "https://files.pythonhosted.org/packages/91/17/a1fc6c70f31d52c99299320cf81c3cb2c6b91ec7269414e0718a6d138e34/mypy-1.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:7a52f26b9c9b1664a60d87675f3bae00b5c7f2806e0c2800545a32c325920bcc", size = 9780113 }, - { url = "https://files.pythonhosted.org/packages/fe/d8/0e72175ee0253217f5c44524f5e95251c02e95ba9749fb87b0e2074d203a/mypy-1.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d5326ab70a6db8e856d59ad4cb72741124950cbbf32e7b70e30166ba7bbf61dd", size = 11269011 }, - { url = "https://files.pythonhosted.org/packages/e9/6d/4ea13839dabe5db588dc6a1b766da16f420d33cf118a7b7172cdf6c7fcb2/mypy-1.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf4ec4980bec1e0e24e5075f449d014011527ae0055884c7e3abc6a99cd2c7f1", size = 10253076 }, - { url = "https://files.pythonhosted.org/packages/3e/38/7db2c5d0f4d290e998f7a52b2e2616c7bbad96b8e04278ab09d11978a29e/mypy-1.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:390dfb898239c25289495500f12fa73aa7f24a4c6d90ccdc165762462b998d63", size = 12862786 }, - { url = "https://files.pythonhosted.org/packages/bf/4b/62d59c801b34141040989949c2b5c157d0408b45357335d3ec5b2845b0f6/mypy-1.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e026d55ddcd76e29e87865c08cbe2d0104e2b3153a523c529de584759379d3d", size = 12971568 }, - { url = "https://files.pythonhosted.org/packages/f1/9c/e0f281b32d70c87b9e4d2939e302b1ff77ada4d7b0f2fb32890c144bc1d6/mypy-1.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:585ed36031d0b3ee362e5107ef449a8b5dfd4e9c90ccbe36414ee405ee6b32ba", size = 9879477 }, - { url = "https://files.pythonhosted.org/packages/13/33/8380efd0ebdfdfac7fc0bf065f03a049800ca1e6c296ec1afc634340d992/mypy-1.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9f6f4c0b27401d14c483c622bc5105eff3911634d576bbdf6695b9a7c1ba741", size = 11251509 }, - { url = "https://files.pythonhosted.org/packages/15/6d/4e1c21c60fee11af7d8e4f2902a29886d1387d6a836be16229eb3982a963/mypy-1.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b2280cedcb312c7a79f5001ae5325582d0d339bce684e4a529069d0e7ca1e7", size = 10244282 }, - { url = "https://files.pythonhosted.org/packages/8b/cf/7a8ae5c0161edae15d25c2c67c68ce8b150cbdc45aefc13a8be271ee80b2/mypy-1.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:342de51c48bab326bfc77ce056ba08c076d82ce4f5a86621f972ed39970f94d8", size = 12867676 }, - { url = "https://files.pythonhosted.org/packages/9c/d0/71f7bbdcc7cfd0f2892db5b13b1e8857673f2cc9e0c30e3e4340523dc186/mypy-1.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00df23b42e533e02a6f0055e54de9a6ed491cd8b7ea738647364fd3a39ea7efc", size = 12964189 }, - { url = "https://files.pythonhosted.org/packages/a7/40/fb4ad65d6d5f8c51396ecf6305ec0269b66013a5bf02d0e9528053640b4a/mypy-1.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e8c8387e5d9dff80e7daf961df357c80e694e942d9755f3ad77d69b0957b8e3f", size = 9888247 }, - { url = "https://files.pythonhosted.org/packages/39/32/0214608af400cdf8f5102144bb8af10d880675c65ed0b58f7e0e77175d50/mypy-1.14.0-py3-none-any.whl", hash = "sha256:2238d7f93fc4027ed1efc944507683df3ba406445a2b6c96e79666a045aadfab", size = 2752803 }, +sdist = { url = "https://files.pythonhosted.org/packages/b9/eb/2c92d8ea1e684440f54fa49ac5d9a5f19967b7b472a281f419e69a8d228e/mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6", size = 3216051 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/11/a9422850fd506edbcdc7f6090682ecceaf1f87b9dd847f9df79942da8506/mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c", size = 11120432 }, + { url = "https://files.pythonhosted.org/packages/b6/9e/47e450fd39078d9c02d620545b2cb37993a8a8bdf7db3652ace2f80521ca/mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1", size = 10279515 }, + { url = "https://files.pythonhosted.org/packages/01/b5/6c8d33bd0f851a7692a8bfe4ee75eb82b6983a3cf39e5e32a5d2a723f0c1/mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8", size = 12025791 }, + { url = "https://files.pythonhosted.org/packages/f0/4c/e10e2c46ea37cab5c471d0ddaaa9a434dc1d28650078ac1b56c2d7b9b2e4/mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f", size = 12749203 }, + { url = "https://files.pythonhosted.org/packages/88/55/beacb0c69beab2153a0f57671ec07861d27d735a0faff135a494cd4f5020/mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1", size = 12885900 }, + { url = "https://files.pythonhosted.org/packages/a2/75/8c93ff7f315c4d086a2dfcde02f713004357d70a163eddb6c56a6a5eff40/mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae", size = 9777869 }, + { url = "https://files.pythonhosted.org/packages/43/1b/b38c079609bb4627905b74fc6a49849835acf68547ac33d8ceb707de5f52/mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14", size = 11266668 }, + { url = "https://files.pythonhosted.org/packages/6b/75/2ed0d2964c1ffc9971c729f7a544e9cd34b2cdabbe2d11afd148d7838aa2/mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9", size = 10254060 }, + { url = "https://files.pythonhosted.org/packages/a1/5f/7b8051552d4da3c51bbe8fcafffd76a6823779101a2b198d80886cd8f08e/mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11", size = 11933167 }, + { url = "https://files.pythonhosted.org/packages/04/90/f53971d3ac39d8b68bbaab9a4c6c58c8caa4d5fd3d587d16f5927eeeabe1/mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e", size = 12864341 }, + { url = "https://files.pythonhosted.org/packages/03/d2/8bc0aeaaf2e88c977db41583559319f1821c069e943ada2701e86d0430b7/mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89", size = 12972991 }, + { url = "https://files.pythonhosted.org/packages/6f/17/07815114b903b49b0f2cf7499f1c130e5aa459411596668267535fe9243c/mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b", size = 9879016 }, + { url = "https://files.pythonhosted.org/packages/9e/15/bb6a686901f59222275ab228453de741185f9d54fecbaacec041679496c6/mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255", size = 11252097 }, + { url = "https://files.pythonhosted.org/packages/f8/b3/8b0f74dfd072c802b7fa368829defdf3ee1566ba74c32a2cb2403f68024c/mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34", size = 10239728 }, + { url = "https://files.pythonhosted.org/packages/c5/9b/4fd95ab20c52bb5b8c03cc49169be5905d931de17edfe4d9d2986800b52e/mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a", size = 11924965 }, + { url = "https://files.pythonhosted.org/packages/56/9d/4a236b9c57f5d8f08ed346914b3f091a62dd7e19336b2b2a0d85485f82ff/mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9", size = 12867660 }, + { url = "https://files.pythonhosted.org/packages/40/88/a61a5497e2f68d9027de2bb139c7bb9abaeb1be1584649fa9d807f80a338/mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd", size = 12969198 }, + { url = "https://files.pythonhosted.org/packages/54/da/3d6fc5d92d324701b0c23fb413c853892bfe0e1dbe06c9138037d459756b/mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107", size = 9885276 }, + { url = "https://files.pythonhosted.org/packages/a0/b5/32dd67b69a16d088e533962e5044e51004176a9952419de0370cdaead0f8/mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1", size = 2752905 }, ] [[package]] @@ -766,45 +764,45 @@ wheels = [ [[package]] name = "orjson" -version = "3.10.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/04/bb9f72987e7f62fb591d6c880c0caaa16238e4e530cbc3bdc84a7372d75f/orjson-3.10.12.tar.gz", hash = "sha256:0a78bbda3aea0f9f079057ee1ee8a1ecf790d4f1af88dd67493c6b8ee52506ff", size = 5438647 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/48/7c3cd094488f5a3bc58488555244609a8c4d105bc02f2b77e509debf0450/orjson-3.10.12-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a734c62efa42e7df94926d70fe7d37621c783dea9f707a98cdea796964d4cf74", size = 248687 }, - { url = "https://files.pythonhosted.org/packages/ff/90/e55f0e25c7fdd1f82551fe787f85df6f378170caca863c04c810cd8f2730/orjson-3.10.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:750f8b27259d3409eda8350c2919a58b0cfcd2054ddc1bd317a643afc646ef23", size = 136953 }, - { url = "https://files.pythonhosted.org/packages/2a/b3/109c020cf7fee747d400de53b43b183ca9d3ebda3906ad0b858eb5479718/orjson-3.10.12-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb52c22bfffe2857e7aa13b4622afd0dd9d16ea7cc65fd2bf318d3223b1b6252", size = 149090 }, - { url = "https://files.pythonhosted.org/packages/96/d4/35c0275dc1350707d182a1b5da16d1184b9439848060af541285407f18f9/orjson-3.10.12-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:440d9a337ac8c199ff8251e100c62e9488924c92852362cd27af0e67308c16ef", size = 140480 }, - { url = "https://files.pythonhosted.org/packages/3b/79/f863ff460c291ad2d882cc3b580cc444bd4ec60c9df55f6901e6c9a3f519/orjson-3.10.12-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9e15c06491c69997dfa067369baab3bf094ecb74be9912bdc4339972323f252", size = 156564 }, - { url = "https://files.pythonhosted.org/packages/98/7e/8d5835449ddd873424ee7b1c4ba73a0369c1055750990d824081652874d6/orjson-3.10.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:362d204ad4b0b8724cf370d0cd917bb2dc913c394030da748a3bb632445ce7c4", size = 131279 }, - { url = "https://files.pythonhosted.org/packages/46/f5/d34595b6d7f4f984c6fef289269a7f98abcdc2445ebdf90e9273487dda6b/orjson-3.10.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b57cbb4031153db37b41622eac67329c7810e5f480fda4cfd30542186f006ae", size = 139764 }, - { url = "https://files.pythonhosted.org/packages/b3/5b/ee6e9ddeab54a7b7806768151c2090a2d36025bc346a944f51cf172ef7f7/orjson-3.10.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:165c89b53ef03ce0d7c59ca5c82fa65fe13ddf52eeb22e859e58c237d4e33b9b", size = 131915 }, - { url = "https://files.pythonhosted.org/packages/c4/45/febee5951aef6db5cd8cdb260548101d7ece0ca9d4ddadadf1766306b7a4/orjson-3.10.12-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5dee91b8dfd54557c1a1596eb90bcd47dbcd26b0baaed919e6861f076583e9da", size = 415783 }, - { url = "https://files.pythonhosted.org/packages/27/a5/5a8569e49f3a6c093bee954a3de95062a231196f59e59df13a48e2420081/orjson-3.10.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77a4e1cfb72de6f905bdff061172adfb3caf7a4578ebf481d8f0530879476c07", size = 142387 }, - { url = "https://files.pythonhosted.org/packages/6e/05/02550fb38c5bf758f3994f55401233a2ef304e175f473f2ac6dbf464cc8b/orjson-3.10.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:038d42c7bc0606443459b8fe2d1f121db474c49067d8d14c6a075bbea8bf14dd", size = 130664 }, - { url = "https://files.pythonhosted.org/packages/8c/f4/ba31019d0646ce51f7ac75af6dabf98fd89dbf8ad87a9086da34710738e7/orjson-3.10.12-cp311-none-win32.whl", hash = "sha256:03b553c02ab39bed249bedd4abe37b2118324d1674e639b33fab3d1dafdf4d79", size = 143623 }, - { url = "https://files.pythonhosted.org/packages/83/fe/babf08842b989acf4c46103fefbd7301f026423fab47e6f3ba07b54d7837/orjson-3.10.12-cp311-none-win_amd64.whl", hash = "sha256:8b8713b9e46a45b2af6b96f559bfb13b1e02006f4242c156cbadef27800a55a8", size = 135074 }, - { url = "https://files.pythonhosted.org/packages/a1/2f/989adcafad49afb535da56b95d8f87d82e748548b2a86003ac129314079c/orjson-3.10.12-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:53206d72eb656ca5ac7d3a7141e83c5bbd3ac30d5eccfe019409177a57634b0d", size = 248678 }, - { url = "https://files.pythonhosted.org/packages/69/b9/8c075e21a50c387649db262b618ebb7e4d40f4197b949c146fc225dd23da/orjson-3.10.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac8010afc2150d417ebda810e8df08dd3f544e0dd2acab5370cfa6bcc0662f8f", size = 136763 }, - { url = "https://files.pythonhosted.org/packages/87/d3/78edf10b4ab14c19f6d918cf46a145818f4aca2b5a1773c894c5490d3a4c/orjson-3.10.12-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed459b46012ae950dd2e17150e838ab08215421487371fa79d0eced8d1461d70", size = 149137 }, - { url = "https://files.pythonhosted.org/packages/16/81/5db8852bdf990a0ddc997fa8f16b80895b8cc77c0fe3701569ed2b4b9e78/orjson-3.10.12-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dcb9673f108a93c1b52bfc51b0af422c2d08d4fc710ce9c839faad25020bb69", size = 140567 }, - { url = "https://files.pythonhosted.org/packages/fa/a6/9ce1e3e3db918512efadad489630c25841eb148513d21dab96f6b4157fa1/orjson-3.10.12-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:22a51ae77680c5c4652ebc63a83d5255ac7d65582891d9424b566fb3b5375ee9", size = 156620 }, - { url = "https://files.pythonhosted.org/packages/47/d4/05133d6bea24e292d2f7628b1e19986554f7d97b6412b3e51d812e38db2d/orjson-3.10.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:910fdf2ac0637b9a77d1aad65f803bac414f0b06f720073438a7bd8906298192", size = 131555 }, - { url = "https://files.pythonhosted.org/packages/b9/7a/b3fbffda8743135c7811e95dc2ab7cdbc5f04999b83c2957d046f1b3fac9/orjson-3.10.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:24ce85f7100160936bc2116c09d1a8492639418633119a2224114f67f63a4559", size = 139743 }, - { url = "https://files.pythonhosted.org/packages/b5/13/95bbcc9a6584aa083da5ce5004ce3d59ea362a542a0b0938d884fd8790b6/orjson-3.10.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a76ba5fc8dd9c913640292df27bff80a685bed3a3c990d59aa6ce24c352f8fc", size = 131733 }, - { url = "https://files.pythonhosted.org/packages/e8/29/dddbb2ea6e7af426fcc3da65a370618a88141de75c6603313d70768d1df1/orjson-3.10.12-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ff70ef093895fd53f4055ca75f93f047e088d1430888ca1229393a7c0521100f", size = 415788 }, - { url = "https://files.pythonhosted.org/packages/53/df/4aea59324ac539975919b4705ee086aced38e351a6eb3eea0f5071dd5661/orjson-3.10.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f4244b7018b5753ecd10a6d324ec1f347da130c953a9c88432c7fbc8875d13be", size = 142347 }, - { url = "https://files.pythonhosted.org/packages/55/55/a52d83d7c49f8ff44e0daab10554490447d6c658771569e1c662aa7057fe/orjson-3.10.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16135ccca03445f37921fa4b585cff9a58aa8d81ebcb27622e69bfadd220b32c", size = 130829 }, - { url = "https://files.pythonhosted.org/packages/a1/8b/b1beb1624dd4adf7d72e2d9b73c4b529e7851c0c754f17858ea13e368b33/orjson-3.10.12-cp312-none-win32.whl", hash = "sha256:2d879c81172d583e34153d524fcba5d4adafbab8349a7b9f16ae511c2cee8708", size = 143659 }, - { url = "https://files.pythonhosted.org/packages/13/91/634c9cd0bfc6a857fc8fab9bf1a1bd9f7f3345e0d6ca5c3d4569ceb6dcfa/orjson-3.10.12-cp312-none-win_amd64.whl", hash = "sha256:fc23f691fa0f5c140576b8c365bc942d577d861a9ee1142e4db468e4e17094fb", size = 135221 }, - { url = "https://files.pythonhosted.org/packages/1b/bb/3f560735f46fa6f875a9d7c4c2171a58cfb19f56a633d5ad5037a924f35f/orjson-3.10.12-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:47962841b2a8aa9a258b377f5188db31ba49af47d4003a32f55d6f8b19006543", size = 248662 }, - { url = "https://files.pythonhosted.org/packages/a3/df/54817902350636cc9270db20486442ab0e4db33b38555300a1159b439d16/orjson-3.10.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6334730e2532e77b6054e87ca84f3072bee308a45a452ea0bffbbbc40a67e296", size = 126055 }, - { url = "https://files.pythonhosted.org/packages/2e/77/55835914894e00332601a74540840f7665e81f20b3e2b9a97614af8565ed/orjson-3.10.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:accfe93f42713c899fdac2747e8d0d5c659592df2792888c6c5f829472e4f85e", size = 131507 }, - { url = "https://files.pythonhosted.org/packages/33/9e/b91288361898e3158062a876b5013c519a5d13e692ac7686e3486c4133ab/orjson-3.10.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a7974c490c014c48810d1dede6c754c3cc46598da758c25ca3b4001ac45b703f", size = 131686 }, - { url = "https://files.pythonhosted.org/packages/b2/15/08ce117d60a4d2d3fd24e6b21db463139a658e9f52d22c9c30af279b4187/orjson-3.10.12-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3f250ce7727b0b2682f834a3facff88e310f52f07a5dcfd852d99637d386e79e", size = 415710 }, - { url = "https://files.pythonhosted.org/packages/71/af/c09da5ed58f9c002cf83adff7a4cdf3e6cee742aa9723395f8dcdb397233/orjson-3.10.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f31422ff9486ae484f10ffc51b5ab2a60359e92d0716fcce1b3593d7bb8a9af6", size = 142305 }, - { url = "https://files.pythonhosted.org/packages/17/d1/8612038d44f33fae231e9ba480d273bac2b0383ce9e77cb06bede1224ae3/orjson-3.10.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5f29c5d282bb2d577c2a6bbde88d8fdcc4919c593f806aac50133f01b733846e", size = 130815 }, - { url = "https://files.pythonhosted.org/packages/67/2c/d5f87834be3591555cfaf9aecdf28f480a6f0b4afeaac53bad534bf9518f/orjson-3.10.12-cp313-none-win32.whl", hash = "sha256:f45653775f38f63dc0e6cd4f14323984c3149c05d6007b58cb154dd080ddc0dc", size = 143664 }, - { url = "https://files.pythonhosted.org/packages/6a/05/7d768fa3ca23c9b3e1e09117abeded1501119f1d8de0ab722938c91ab25d/orjson-3.10.12-cp313-none-win_amd64.whl", hash = "sha256:229994d0c376d5bdc91d92b3c9e6be2f1fbabd4cc1b59daae1443a46ee5e9825", size = 134944 }, +version = "3.10.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/0b/8c7eaf1e2152f1e0fb28ae7b22e2b35a6b1992953a1ebe0371ba4d41d3ad/orjson-3.10.13.tar.gz", hash = "sha256:eb9bfb14ab8f68d9d9492d4817ae497788a15fd7da72e14dfabc289c3bb088ec", size = 5438389 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/44/7a047e47779953e3f657a612ad36f71a0bca02cf57ff490c427e22b01833/orjson-3.10.13-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a36c0d48d2f084c800763473020a12976996f1109e2fcb66cfea442fdf88047f", size = 248732 }, + { url = "https://files.pythonhosted.org/packages/d6/e9/54976977aaacc5030fdd8012479638bb8d4e2a16519b516ac2bd03a48eab/orjson-3.10.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0065896f85d9497990731dfd4a9991a45b0a524baec42ef0a63c34630ee26fd6", size = 136954 }, + { url = "https://files.pythonhosted.org/packages/7f/a7/663fb04e031d5c80a348aeb7271c6042d13f80393c4951b8801a703b89c0/orjson-3.10.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92b4ec30d6025a9dcdfe0df77063cbce238c08d0404471ed7a79f309364a3d19", size = 149101 }, + { url = "https://files.pythonhosted.org/packages/f9/f1/5f2a4bf7525ef4acf48902d2df2bcc1c5aa38f6cc17ee0729a1d3e110ddb/orjson-3.10.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a94542d12271c30044dadad1125ee060e7a2048b6c7034e432e116077e1d13d2", size = 140445 }, + { url = "https://files.pythonhosted.org/packages/12/d3/e68afa1db9860880e59260348b54c0518d8dfe2297e932f8e333ace878fa/orjson-3.10.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3723e137772639af8adb68230f2aa4bcb27c48b3335b1b1e2d49328fed5e244c", size = 156530 }, + { url = "https://files.pythonhosted.org/packages/77/ee/492b198c77b9985ae28e0c6b8092c2994cd18d6be40dc7cb7f9a385b7096/orjson-3.10.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f00c7fb18843bad2ac42dc1ce6dd214a083c53f1e324a0fd1c8137c6436269b", size = 131260 }, + { url = "https://files.pythonhosted.org/packages/57/d2/5167cc1ccbe56bacdd9fc79e6a3276cba6aa90057305e8485db58b8250c4/orjson-3.10.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0e2759d3172300b2f892dee85500b22fca5ac49e0c42cfff101aaf9c12ac9617", size = 139821 }, + { url = "https://files.pythonhosted.org/packages/74/f0/c1cf568e0f90d812e00c77da2db04a13e94afe639665b9a09c271456dc41/orjson-3.10.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee948c6c01f6b337589c88f8e0bb11e78d32a15848b8b53d3f3b6fea48842c12", size = 131904 }, + { url = "https://files.pythonhosted.org/packages/55/7d/a611542afbbacca4693a2319744944134df62957a1f206303d5b3160e349/orjson-3.10.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa6fe68f0981fba0d4bf9cdc666d297a7cdba0f1b380dcd075a9a3dd5649a69e", size = 415733 }, + { url = "https://files.pythonhosted.org/packages/64/3f/e8182716695cd8d5ebec49d283645b8c7b1de7ed1c27db2891b6957e71f6/orjson-3.10.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbcd7aad6bcff258f6896abfbc177d54d9b18149c4c561114f47ebfe74ae6bfd", size = 142456 }, + { url = "https://files.pythonhosted.org/packages/dc/10/e4b40f15be7e4e991737d77062399c7f67da9b7e3bc28bbcb25de1717df3/orjson-3.10.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2149e2fcd084c3fd584881c7f9d7f9e5ad1e2e006609d8b80649655e0d52cd02", size = 130676 }, + { url = "https://files.pythonhosted.org/packages/ad/b1/8b9fb36d470fe8ff99727972c77846673ebc962cb09a5af578804f9f2408/orjson-3.10.13-cp311-cp311-win32.whl", hash = "sha256:89367767ed27b33c25c026696507c76e3d01958406f51d3a2239fe9e91959df2", size = 143672 }, + { url = "https://files.pythonhosted.org/packages/b5/15/90b3711f40d27aff80dd42c1eec2f0ed704a1fa47eef7120350e2797892d/orjson-3.10.13-cp311-cp311-win_amd64.whl", hash = "sha256:dca1d20f1af0daff511f6e26a27354a424f0b5cf00e04280279316df0f604a6f", size = 135082 }, + { url = "https://files.pythonhosted.org/packages/35/84/adf8842cf36904e6200acff76156862d48d39705054c1e7c5fa98fe14417/orjson-3.10.13-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a3614b00621c77f3f6487792238f9ed1dd8a42f2ec0e6540ee34c2d4e6db813a", size = 248778 }, + { url = "https://files.pythonhosted.org/packages/69/2f/22ac0c5f46748e9810287a5abaeabdd67f1120a74140db7d529582c92342/orjson-3.10.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c976bad3996aa027cd3aef78aa57873f3c959b6c38719de9724b71bdc7bd14b", size = 136759 }, + { url = "https://files.pythonhosted.org/packages/39/67/6f05de77dd383cb623e2807bceae13f136e9f179cd32633b7a27454e953f/orjson-3.10.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f74d878d1efb97a930b8a9f9898890067707d683eb5c7e20730030ecb3fb930", size = 149123 }, + { url = "https://files.pythonhosted.org/packages/f8/5c/b5e144e9adbb1dc7d1fdf54af9510756d09b65081806f905d300a926a755/orjson-3.10.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:33ef84f7e9513fb13b3999c2a64b9ca9c8143f3da9722fbf9c9ce51ce0d8076e", size = 140557 }, + { url = "https://files.pythonhosted.org/packages/91/fd/7bdbc0aa374d49cdb917ee51c80851c99889494be81d5e7ec9f5f9cbe149/orjson-3.10.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd2bcde107221bb9c2fa0c4aaba735a537225104173d7e19cf73f70b3126c993", size = 156626 }, + { url = "https://files.pythonhosted.org/packages/48/90/e583d6e29937ec30a164f1d86a0439c1a2477b5aae9f55d94b37a4f5b5f0/orjson-3.10.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:064b9dbb0217fd64a8d016a8929f2fae6f3312d55ab3036b00b1d17399ab2f3e", size = 131551 }, + { url = "https://files.pythonhosted.org/packages/47/0b/838c00ec7f048527aa0382299cd178bbe07c2cb1024b3111883e85d56d1f/orjson-3.10.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0044b0b8c85a565e7c3ce0a72acc5d35cda60793edf871ed94711e712cb637d", size = 139790 }, + { url = "https://files.pythonhosted.org/packages/ac/90/df06ac390f319a61d55a7a4efacb5d7082859f6ea33f0fdd5181ad0dde0c/orjson-3.10.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7184f608ad563032e398f311910bc536e62b9fbdca2041be889afcbc39500de8", size = 131717 }, + { url = "https://files.pythonhosted.org/packages/ea/68/eafb5e2fc84aafccfbd0e9e0552ff297ef5f9b23c7f2600cc374095a50de/orjson-3.10.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d36f689e7e1b9b6fb39dbdebc16a6f07cbe994d3644fb1c22953020fc575935f", size = 415690 }, + { url = "https://files.pythonhosted.org/packages/b8/cf/aa93b48801b2e42da223ef5a99b3e4970b02e7abea8509dd2a6a083e27fa/orjson-3.10.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:54433e421618cd5873e51c0e9d0b9fb35f7bf76eb31c8eab20b3595bb713cd3d", size = 142396 }, + { url = "https://files.pythonhosted.org/packages/8b/50/fb1a7060b79231c60a688037c2c8e9fe289b5a4378ec1f32cf8d33d9adf8/orjson-3.10.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e1ba0c5857dd743438acecc1cd0e1adf83f0a81fee558e32b2b36f89e40cee8b", size = 130842 }, + { url = "https://files.pythonhosted.org/packages/94/e6/44067052e28a13176da874ca53419b43cf0f6f01f4bf0539f2f70d8eacf6/orjson-3.10.13-cp312-cp312-win32.whl", hash = "sha256:a42b9fe4b0114b51eb5cdf9887d8c94447bc59df6dbb9c5884434eab947888d8", size = 143773 }, + { url = "https://files.pythonhosted.org/packages/f2/7d/510939d1b7f8ba387849e83666e898f214f38baa46c5efde94561453974d/orjson-3.10.13-cp312-cp312-win_amd64.whl", hash = "sha256:3a7df63076435f39ec024bdfeb4c9767ebe7b49abc4949068d61cf4857fa6d6c", size = 135234 }, + { url = "https://files.pythonhosted.org/packages/ef/42/482fced9a135c798f31e1088f608fa16735fdc484eb8ffdd29aa32d4e842/orjson-3.10.13-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2cdaf8b028a976ebab837a2c27b82810f7fc76ed9fb243755ba650cc83d07730", size = 248726 }, + { url = "https://files.pythonhosted.org/packages/00/e7/6345653906ee6d2d6eabb767cdc4482c7809572dbda59224f40e48931efa/orjson-3.10.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48a946796e390cbb803e069472de37f192b7a80f4ac82e16d6eb9909d9e39d56", size = 126032 }, + { url = "https://files.pythonhosted.org/packages/ad/b8/0d2a2c739458ff7f9917a132225365d72d18f4b65c50cb8ebb5afb6fe184/orjson-3.10.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7d64f1db5ecbc21eb83097e5236d6ab7e86092c1cd4c216c02533332951afc", size = 131547 }, + { url = "https://files.pythonhosted.org/packages/8d/ac/a1dc389cf364d576cf587a6f78dac6c905c5cac31b9dbd063bbb24335bf7/orjson-3.10.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:711878da48f89df194edd2ba603ad42e7afed74abcd2bac164685e7ec15f96de", size = 131682 }, + { url = "https://files.pythonhosted.org/packages/43/6c/debab76b830aba6449ec8a75ac77edebb0e7decff63eb3ecfb2cf6340a2e/orjson-3.10.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cf16f06cb77ce8baf844bc222dbcb03838f61d0abda2c3341400c2b7604e436e", size = 415621 }, + { url = "https://files.pythonhosted.org/packages/c2/32/106e605db5369a6717036065e2b41ac52bd0d2712962edb3e026a452dc07/orjson-3.10.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8257c3fb8dd7b0b446b5e87bf85a28e4071ac50f8c04b6ce2d38cb4abd7dff57", size = 142388 }, + { url = "https://files.pythonhosted.org/packages/a3/02/6b2103898d60c2565bf97abffdf3a4cf338920b9feb55eec1fd791ab10ee/orjson-3.10.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9c3a87abe6f849a4a7ac8a8a1dede6320a4303d5304006b90da7a3cd2b70d2c", size = 130825 }, + { url = "https://files.pythonhosted.org/packages/87/7c/db115e2380435da569732999d5c4c9b9868efe72e063493cb73c36bb649a/orjson-3.10.13-cp313-cp313-win32.whl", hash = "sha256:527afb6ddb0fa3fe02f5d9fba4920d9d95da58917826a9be93e0242da8abe94a", size = 143723 }, + { url = "https://files.pythonhosted.org/packages/cc/5e/c2b74a0b38ec561a322d8946663924556c1f967df2eefe1b9e0b98a33950/orjson-3.10.13-cp313-cp313-win_amd64.whl", hash = "sha256:b5f7c298d4b935b222f52d6c7f2ba5eafb59d690d9a3840b7b5c5cda97f6ec5c", size = 134968 }, ] [[package]] @@ -954,11 +952,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.18.0" +version = "2.19.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +sdist = { url = "https://files.pythonhosted.org/packages/d3/c0/9c9832e5be227c40e1ce774d493065f83a91d6430baa7e372094e9683a45/pygments-2.19.0.tar.gz", hash = "sha256:afc4146269910d4bdfabcd27c24923137a74d562a23a320a41a55ad303e19783", size = 4967733 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, + { url = "https://files.pythonhosted.org/packages/20/dc/fde3e7ac4d279a331676829af4afafd113b34272393d73f610e8f0329221/pygments-2.19.0-py3-none-any.whl", hash = "sha256:4755e6e64d22161d5b61432c0600c923c5927214e7c956e31c23923c89251a9b", size = 1225305 }, ] [[package]] @@ -978,14 +976,14 @@ wheels = [ [[package]] name = "pytest-asyncio" -version = "0.25.0" +version = "0.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/18/82fcb4ee47d66d99f6cd1efc0b11b2a25029f303c599a5afda7c1bca4254/pytest_asyncio-0.25.0.tar.gz", hash = "sha256:8c0610303c9e0442a5db8604505fc0f545456ba1528824842b37b4a626cbf609", size = 53298 } +sdist = { url = "https://files.pythonhosted.org/packages/4b/04/0477a4bdd176ad678d148c075f43620b3f7a060ff61c7da48500b1fa8a75/pytest_asyncio-0.25.1.tar.gz", hash = "sha256:79be8a72384b0c917677e00daa711e07db15259f4d23203c59012bcd989d4aee", size = 53760 } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/56/2ee0cab25c11d4e38738a2a98c645a8f002e2ecf7b5ed774c70d53b92bb1/pytest_asyncio-0.25.0-py3-none-any.whl", hash = "sha256:db5432d18eac6b7e28b46dcd9b69921b55c3b1086e85febfe04e70b18d9e81b3", size = 19245 }, + { url = "https://files.pythonhosted.org/packages/81/fb/efc7226b384befd98d0e00d8c4390ad57f33c8fde00094b85c5e07897def/pytest_asyncio-0.25.1-py3-none-any.whl", hash = "sha256:c84878849ec63ff2ca509423616e071ef9cd8cc93c053aa33b5b8fb70a990671", size = 19357 }, ] [[package]] @@ -1091,7 +1089,7 @@ wheels = [ [[package]] name = "python-kasa" -version = "0.9.0" +version = "0.9.1" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -1489,25 +1487,25 @@ wheels = [ [[package]] name = "urllib3" -version = "2.2.3" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, ] [[package]] name = "virtualenv" -version = "20.28.0" +version = "20.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/75/53316a5a8050069228a2f6d11f32046cfa94fbb6cc3f08703f59b873de2e/virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa", size = 7650368 } +sdist = { url = "https://files.pythonhosted.org/packages/50/39/689abee4adc85aad2af8174bb195a819d0be064bf55fcc73b49d2b28ae77/virtualenv-20.28.1.tar.gz", hash = "sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329", size = 7650532 } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/f9/0919cf6f1432a8c4baa62511f8f8da8225432d22e83e3476f5be1a1edc6e/virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0", size = 4276702 }, + { url = "https://files.pythonhosted.org/packages/51/8f/dfb257ca6b4e27cb990f1631142361e4712badab8e3ca8dc134d96111515/virtualenv-20.28.1-py3-none-any.whl", hash = "sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb", size = 4276719 }, ] [[package]] From 7b3dde9aa0b2580c5b4a77e5934f6c9377c44443 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 6 Jan 2025 16:11:43 +0000 Subject: [PATCH 792/892] Raise errors on single smartcam child requests (#1427) --- kasa/protocols/smartcamprotocol.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/kasa/protocols/smartcamprotocol.py b/kasa/protocols/smartcamprotocol.py index a1d6ae9c8..9bf40f7d1 100644 --- a/kasa/protocols/smartcamprotocol.py +++ b/kasa/protocols/smartcamprotocol.py @@ -244,11 +244,15 @@ async def _query(self, request: str | dict, retry_count: int = 3) -> dict: responses = response["multipleRequest"]["responses"] response_dict = {} + + # Raise errors for single calls + raise_on_error = len(requests) == 1 + for index_id, response in enumerate(responses): response_data = response["result"]["response_data"] method = methods[index_id] self._handle_response_error_code( - response_data, method, raise_on_error=False + response_data, method, raise_on_error=raise_on_error ) response_dict[method] = response_data.get("result") From 3c038fc13b1a470a0d87bf7c0ae94a63a210f337 Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Tue, 7 Jan 2025 10:40:37 -0500 Subject: [PATCH 793/892] Add KS230(US) 2.0 1.0.11 IOT Fixture (#1430) --- SUPPORTED.md | 1 + tests/fixtures/iot/KS230(US)_2.0_1.0.11.json | 112 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 tests/fixtures/iot/KS230(US)_2.0_1.0.11.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 81469347c..841bbe01a 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -120,6 +120,7 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - Hardware: 1.0 (US) / Firmware: 1.1.0[^1] - **KS230** - Hardware: 1.0 (US) / Firmware: 1.0.14 + - Hardware: 2.0 (US) / Firmware: 1.0.11 - **KS240** - Hardware: 1.0 (US) / Firmware: 1.0.4[^1] - Hardware: 1.0 (US) / Firmware: 1.0.5[^1] diff --git a/tests/fixtures/iot/KS230(US)_2.0_1.0.11.json b/tests/fixtures/iot/KS230(US)_2.0_1.0.11.json new file mode 100644 index 000000000..213f24602 --- /dev/null +++ b/tests/fixtures/iot/KS230(US)_2.0_1.0.11.json @@ -0,0 +1,112 @@ +{ + "cnCloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "smartlife.iot.dimmer": { + "get_default_behavior": { + "double_click": { + "mode": "none" + }, + "err_code": 0, + "hard_on": { + "mode": "last_status" + }, + "long_press": { + "mode": "instant_on_off" + }, + "soft_on": { + "mode": "last_status" + } + }, + "get_dimmer_parameters": { + "bulb_type": 1, + "calibration_type": 0, + "err_code": 0, + "fadeOffTime": 1000, + "fadeOnTime": 1000, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 11, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "brightness": 100, + "dc_state": 0, + "dev_name": "Wi-Fi Smart 3-Way Dimmer", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "5C:E9:31:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS230(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "preferred_state": [ + { + "brightness": 100, + "index": 0 + }, + { + "brightness": 75, + "index": 1 + }, + { + "brightness": 50, + "index": 2 + }, + { + "brightness": 25, + "index": 3 + } + ], + "relay_state": 0, + "rssi": -41, + "status": "new", + "sw_ver": "1.0.11 Build 240516 Rel.104458", + "updating": 0 + } + } +} From debcff9f9b37efc849f8043ab86fa4b9baa1bced Mon Sep 17 00:00:00 2001 From: steveredden <35814432+steveredden@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:22:26 -0600 Subject: [PATCH 794/892] Add fixture for C720 camera (#1433) --- README.md | 2 +- SUPPORTED.md | 2 + .../fixtures/smartcam/C720(US)_1.0_1.2.3.json | 1039 +++++++++++++++++ 3 files changed, 1042 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smartcam/C720(US)_1.0_1.2.3.json diff --git a/README.md b/README.md index 8016e8c4e..b0bf575a9 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: S210, S220, S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Cameras**: C100, C210, C225, C325WB, C520WS, TC65, TC70 +- **Cameras**: C100, C210, C225, C325WB, C520WS, C720, TC65, TC70 - **Hubs**: H100, H200 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 diff --git a/SUPPORTED.md b/SUPPORTED.md index 841bbe01a..c2495ef22 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -281,6 +281,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.1.17 - **C520WS** - Hardware: 1.0 (US) / Firmware: 1.2.8 +- **C720** + - Hardware: 1.0 (US) / Firmware: 1.2.3 - **TC65** - Hardware: 1.0 / Firmware: 1.3.9 - **TC70** diff --git a/tests/fixtures/smartcam/C720(US)_1.0_1.2.3.json b/tests/fixtures/smartcam/C720(US)_1.0_1.2.3.json new file mode 100644 index 000000000..e31bee028 --- /dev/null +++ b/tests/fixtures/smartcam/C720(US)_1.0_1.2.3.json @@ -0,0 +1,1039 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1736360289", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "normal" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C720", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.2.3 Build 240823 Rel.40327n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "98-25-4A-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "15", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "linecrossingDetection", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "pirDetection", + "version": 1 + }, + { + "name": "lightsensor", + "version": 1 + }, + { + "name": "floodlight", + "version": 2 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "upnpc", + "version": 2 + }, + { + "name": "staticIp", + "version": 2 + }, + { + "name": "manualAlarm", + "version": 1 + }, + { + "name": "snapshot", + "version": 2 + }, + { + "name": "timeFormat", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "factory_noise_cancelling": "off", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-01-08 12:24:34", + "seconds_from_1970": 1736360674 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "3", + "rssiValue": -55, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c720", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C720 1.0 IPC", + "device_model": "C720", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "98-25-4A-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "no_rtsp_constrain": 1, + "oem_id": "00000000000000000000000000000000", + "region": "US", + "sw_version": "1.2.3 Build 240823 Rel.40327n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "off", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1736360661", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "manual_exp_iso_gain": "0", + "manual_exp_us": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "center", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "manual_exp_iso_gain": "0", + "manual_exp_us": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp", + "white_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "center", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "center", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "normal", + "disk_name": "1", + "free_space": "6.5GB", + "free_space_accurate": "6945154936B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "100", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "1706216554", + "rw_attr": "rw", + "status": "normal", + "total_space": "119.1GB", + "total_space_accurate": "127878135808B", + "type": "local", + "video_free_space": "6.5GB", + "video_free_space_accurate": "6945154936B", + "video_total_space": "114.2GB", + "video_total_space_accurate": "122675003392B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "on", + "sensitivity": "medium" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-06:00", + "timing_mode": "ntp", + "zone_id": "America/Chicago" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1536", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2560*1440", + "1920*1080" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "2048", + "bitrate_type": "vbr", + "default_bitrate": "2048", + "encode_type": "H264", + "frame_rate": "65561", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2560*1440", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "center", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8" + ], + "system_volume": "80", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "factory_noise_cancelling": "off", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm_list": [ + "sound" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "ptz": "0", + "record_max_slot_cnt": "6", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "0", + "smart_codec": "0", + "smart_detection": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "0", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 0, + "bssid": "000000000000", + "encryption": 0, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "true" + } + } + } +} From 2e3b1bc376a99a89af14bac24f7270d033b0cfa2 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 8 Jan 2025 21:51:35 +0000 Subject: [PATCH 795/892] Add tests for dump_devinfo parent/child smartcam fixture generation (#1428) Currently the dump_devinfo fixture generation tests do not test generation for hub and their children. This PR enables tests for `smartcam` hubs and their child fixtures. It does not enable support for `smart` hub fixtures as not all the fixtures currently have the required info. This can be addressed in a subsequent PR. --- tests/fakeprotocol_smart.py | 52 ++++++++++++++++++++++++---------- tests/fakeprotocol_smartcam.py | 4 +-- tests/fixtureinfo.py | 4 +-- tests/test_devtools.py | 43 +++++++++++++++++++++++++--- 4 files changed, 80 insertions(+), 23 deletions(-) diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index c0222b995..a2fc39261 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -48,13 +48,18 @@ def __init__( ), ) self.fixture_name = fixture_name + + # When True verbatim will bypass any extra processing of missing + # methods and is used to test the fixture creation itself. + self.verbatim = verbatim + # Don't copy the dict if the device is a child so that updates on the # child are then still reflected on the parent's lis of child device in if not is_child: self.info = copy.deepcopy(info) if get_child_fixtures: self.child_protocols = self._get_child_protocols( - self.info, self.fixture_name, "get_child_device_list" + self.info, self.fixture_name, "get_child_device_list", self.verbatim ) else: self.info = info @@ -67,9 +72,6 @@ def __init__( self.warn_fixture_missing_methods = warn_fixture_missing_methods self.fix_incomplete_fixture_lists = fix_incomplete_fixture_lists - # When True verbatim will bypass any extra processing of missing - # methods and is used to test the fixture creation itself. - self.verbatim = verbatim if verbatim: self.warn_fixture_missing_methods = False self.fix_incomplete_fixture_lists = False @@ -124,7 +126,7 @@ def credentials_hash(self): }, ), "get_auto_update_info": ( - "firmware", + ("firmware", 2), {"enable": True, "random_range": 120, "time": 180}, ), "get_alarm_configure": ( @@ -169,6 +171,30 @@ def credentials_hash(self): ), } + def _missing_result(self, method): + """Check the FIXTURE_MISSING_MAP for responses. + + Fixtures generated prior to a query being supported by dump_devinfo + do not have the response so this method checks whether the component + is supported and fills in the missing response. + If the first value of the lookup value is a tuple it will also check + the version, i.e. (component_name, component_version). + """ + if not (missing := self.FIXTURE_MISSING_MAP.get(method)): + return None + condition = missing[0] + if ( + isinstance(condition, tuple) + and (version := self.components.get(condition[0])) + and version >= condition[1] + ): + return copy.deepcopy(missing[1]) + + if condition in self.components: + return copy.deepcopy(missing[1]) + + return None + async def send(self, request: str): request_dict = json_loads(request) method = request_dict["method"] @@ -189,7 +215,7 @@ async def send(self, request: str): @staticmethod def _get_child_protocols( - parent_fixture_info, parent_fixture_name, child_devices_key + parent_fixture_info, parent_fixture_name, child_devices_key, verbatim ): child_infos = parent_fixture_info.get(child_devices_key, {}).get( "child_device_list", [] @@ -251,7 +277,7 @@ def try_get_child_fixture_info(child_dev_info): ) # Replace parent child infos with the infos from the child fixtures so # that updates update both - if child_infos and found_child_fixture_infos: + if not verbatim and child_infos and found_child_fixture_infos: parent_fixture_info[child_devices_key]["child_device_list"] = ( found_child_fixture_infos ) @@ -318,13 +344,11 @@ def _handle_control_child_missing(self, params: dict): elif child_method in child_device_calls: result = copy.deepcopy(child_device_calls[child_method]) return {"result": result, "error_code": 0} - elif ( + elif missing_result := self._missing_result(child_method): # FIXTURE_MISSING is for service calls not in place when # SMART fixtures started to be generated - missing_result := self.FIXTURE_MISSING_MAP.get(child_method) - ) and missing_result[0] in self.components: # Copy to info so it will work with update methods - child_device_calls[child_method] = copy.deepcopy(missing_result[1]) + child_device_calls[child_method] = missing_result result = copy.deepcopy(info[child_method]) retval = {"result": result, "error_code": 0} return retval @@ -529,13 +553,11 @@ async def _send_request(self, request_dict: dict): "method": method, } - if ( + if missing_result := self._missing_result(method): # FIXTURE_MISSING is for service calls not in place when # SMART fixtures started to be generated - missing_result := self.FIXTURE_MISSING_MAP.get(method) - ) and missing_result[0] in self.components: # Copy to info so it will work with update methods - info[method] = copy.deepcopy(missing_result[1]) + info[method] = missing_result result = copy.deepcopy(info[method]) retval = {"result": result, "error_code": 0} elif ( diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index eee014e8f..17b149792 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -57,11 +57,11 @@ def __init__( # lists if get_child_fixtures: self.child_protocols = FakeSmartTransport._get_child_protocols( - self.info, self.fixture_name, "getChildDeviceList" + self.info, self.fixture_name, "getChildDeviceList", self.verbatim ) else: self.info = info - # self.child_protocols = self._get_child_protocols() + self.list_return_size = list_return_size # Setting this flag allows tests to create dummy transports without diff --git a/tests/fixtureinfo.py b/tests/fixtureinfo.py index 62b712283..8988be1d2 100644 --- a/tests/fixtureinfo.py +++ b/tests/fixtureinfo.py @@ -77,7 +77,7 @@ def idgenerator(paramtuple: FixtureInfo): return None -def get_fixture_info() -> list[FixtureInfo]: +def get_fixture_infos() -> list[FixtureInfo]: """Return raw discovery file contents as JSON. Used for discovery tests.""" fixture_data = [] for file, protocol in SUPPORTED_DEVICES: @@ -99,7 +99,7 @@ def get_fixture_info() -> list[FixtureInfo]: return fixture_data -FIXTURE_DATA: list[FixtureInfo] = get_fixture_info() +FIXTURE_DATA: list[FixtureInfo] = get_fixture_infos() def filter_fixtures( diff --git a/tests/test_devtools.py b/tests/test_devtools.py index 8bdd5746b..3af20035e 100644 --- a/tests/test_devtools.py +++ b/tests/test_devtools.py @@ -1,5 +1,7 @@ """Module for dump_devinfo tests.""" +import copy + import pytest from devtools.dump_devinfo import get_legacy_fixture, get_smart_fixtures @@ -11,6 +13,7 @@ from .conftest import ( FixtureInfo, get_device_for_fixture, + get_fixture_info, parametrize, ) @@ -64,22 +67,54 @@ async def test_smart_fixtures(fixture_info: FixtureInfo): assert fixture_info.data == fixture_result.data +def _normalize_child_device_ids(info: dict): + """Scrubbed child device ids in hubs may not match ids in child fixtures. + + Different hub fixtures could create the same child fixture so we scrub + them again for the purpose of the test. + """ + if dev_info := info.get("get_device_info"): + dev_info["device_id"] = "SCRUBBED" + elif ( + dev_info := info.get("getDeviceInfo", {}) + .get("device_info", {}) + .get("basic_info") + ): + dev_info["dev_id"] = "SCRUBBED" + + @smartcam_fixtures async def test_smartcam_fixtures(fixture_info: FixtureInfo): """Test that smartcam fixtures are created the same.""" dev = await get_device_for_fixture(fixture_info, verbatim=True) assert isinstance(dev, SmartCamDevice) - if dev.children: - pytest.skip("Test not currently implemented for devices with children.") - fixtures = await get_smart_fixtures( + + created_fixtures = await get_smart_fixtures( dev.protocol, discovery_info=fixture_info.data.get("discovery_result"), batch_size=5, ) - fixture_result = fixtures[0] + fixture_result = created_fixtures.pop(0) assert fixture_info.data == fixture_result.data + for created_child_fixture in created_fixtures: + child_fixture_info = get_fixture_info( + created_child_fixture.filename + ".json", + created_child_fixture.protocol_suffix, + ) + + assert child_fixture_info + + _normalize_child_device_ids(created_child_fixture.data) + + saved_fixture_data = copy.deepcopy(child_fixture_info.data) + _normalize_child_device_ids(saved_fixture_data) + saved_fixture_data = { + key: val for key, val in saved_fixture_data.items() if val != -1001 + } + assert saved_fixture_data == created_child_fixture.data + @iot_fixtures async def test_iot_fixtures(fixture_info: FixtureInfo): From 660b9f81defceb65a28d7a99a2a7a9d4f8d4168d Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 10 Jan 2025 18:34:11 +0000 Subject: [PATCH 796/892] Add more redactors for smartcams (#1439) `alias` and `ext_addr` are new fields found on `smartcam` child devices --- kasa/protocols/smartprotocol.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/kasa/protocols/smartprotocol.py b/kasa/protocols/smartprotocol.py index 28a20641e..5af7a81b3 100644 --- a/kasa/protocols/smartprotocol.py +++ b/kasa/protocols/smartprotocol.py @@ -61,8 +61,10 @@ "ip": lambda x: x, # don't redact but keep listed here for dump_devinfo # smartcam "dev_id": lambda x: "REDACTED_" + x[9::], + "ext_addr": lambda x: "REDACTED_" + x[9::], "device_name": lambda x: "#MASKED_NAME#" if x else "", "device_alias": lambda x: "#MASKED_NAME#" if x else "", + "alias": lambda x: "#MASKED_NAME#" if x else "", # child info on parent uses alias "local_ip": lambda x: x, # don't redact but keep listed here for dump_devinfo # robovac "board_sn": lambda _: "000000000000", From 6420d7635175ab8f1a31b1102a75720f0c2372c9 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sun, 12 Jan 2025 17:06:48 +0100 Subject: [PATCH 797/892] ssltransport: use debug logger for sending requests (#1443) --- kasa/transports/ssltransport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/transports/ssltransport.py b/kasa/transports/ssltransport.py index 5ffc935f9..4471dccb9 100644 --- a/kasa/transports/ssltransport.py +++ b/kasa/transports/ssltransport.py @@ -215,7 +215,7 @@ def _session_expired(self) -> bool: async def send(self, request: str) -> dict[str, Any]: """Send the request.""" - _LOGGER.info("Going to send %s", request) + _LOGGER.debug("Going to send %s", request) if self._state is not TransportState.ESTABLISHED or self._session_expired(): _LOGGER.debug("Transport not established or session expired, logging in") await self.perform_login() From 333a36bf423c1705bb07dc3bd1ac2ddd391dd322 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Mon, 13 Jan 2025 16:55:52 +0100 Subject: [PATCH 798/892] Add required sphinx.configuration (#1446) --- .readthedocs.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.readthedocs.yml b/.readthedocs.yml index 1d01cf18f..17b68ff4b 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -2,6 +2,10 @@ version: 2 formats: all +sphinx: + configuration: docs/source/conf.py + + build: os: ubuntu-22.04 tools: From a211cc0af5de8c2b2b2021ee0400cf83bb2a1fab Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 13 Jan 2025 17:19:40 +0000 Subject: [PATCH 799/892] Update hub children on first update and delay subsequent updates (#1438) --- kasa/smart/modules/devicemodule.py | 5 +- kasa/smart/smartchilddevice.py | 15 +- kasa/smart/smartdevice.py | 10 +- kasa/smart/smartmodule.py | 19 +- kasa/smartcam/modules/device.py | 11 +- tests/smart/test_smartdevice.py | 347 +++++++++++++++++++++++------ 6 files changed, 326 insertions(+), 81 deletions(-) diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py index bf112e2dd..692745bb4 100644 --- a/kasa/smart/modules/devicemodule.py +++ b/kasa/smart/modules/devicemodule.py @@ -19,12 +19,15 @@ async def _post_update_hook(self) -> None: def query(self) -> dict: """Query to execute during the update cycle.""" + if self._device._is_hub_child: + # Child devices get their device info updated by the parent device. + return {} query = { "get_device_info": None, } # Device usage is not available on older firmware versions # or child devices of hubs - if self.supported_version >= 2 and not self._device._is_hub_child: + if self.supported_version >= 2: query["get_device_usage"] = None return query diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 5ed7feb6c..760a18a1e 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -86,11 +86,22 @@ async def _update(self, update_children: bool = True) -> None: module_queries: list[SmartModule] = [] req: dict[str, Any] = {} for module in self.modules.values(): - if module.disabled is False and (mod_query := module.query()): + if ( + module.disabled is False + and (mod_query := module.query()) + and module._should_update(now) + ): module_queries.append(module) req.update(mod_query) if req: - self._last_update = await self.protocol.query(req) + first_update = self._last_update != {} + try: + resp = await self.protocol.query(req) + except Exception as ex: + resp = await self._handle_modular_update_error( + ex, first_update, ", ".join(mod.name for mod in module_queries), req + ) + self._last_update = resp for module in self.modules.values(): await self._handle_module_post_update( diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 5fd221157..89f2f9506 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -183,7 +183,7 @@ def _update_internal_info(self, info_resp: dict) -> None: """Update the internal device info.""" self._info = self._try_get_response(info_resp, "get_device_info") - async def update(self, update_children: bool = False) -> None: + async def update(self, update_children: bool = True) -> None: """Update the device.""" if self.credentials is None and self.credentials_hash is None: raise AuthenticationError("Tapo plug requires authentication.") @@ -207,7 +207,7 @@ async def update(self, update_children: bool = False) -> None: # devices will always update children to prevent errors on module access. # This needs to go after updating the internal state of the children so that # child modules have access to their sysinfo. - if update_children or self.device_type != DeviceType.Hub: + if first_update or update_children or self.device_type != DeviceType.Hub: for child in self._children.values(): if TYPE_CHECKING: assert isinstance(child, SmartChildDevice) @@ -260,11 +260,7 @@ async def _modular_update( if first_update and module.__class__ in self.FIRST_UPDATE_MODULES: module._last_update_time = update_time continue - if ( - not module.update_interval - or not module._last_update_time - or (update_time - module._last_update_time) >= module.update_interval - ): + if module._should_update(update_time): module_queries.append(module) req.update(query) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index a5666f632..243852e06 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -62,6 +62,8 @@ class SmartModule(Module): REGISTERED_MODULES: dict[str, type[SmartModule]] = {} MINIMUM_UPDATE_INTERVAL_SECS = 0 + MINIMUM_HUB_CHILD_UPDATE_INTERVAL_SECS = 60 * 60 * 24 + UPDATE_INTERVAL_AFTER_ERROR_SECS = 30 DISABLE_AFTER_ERROR_COUNT = 10 @@ -107,16 +109,27 @@ def _set_error(self, err: Exception | None) -> None: @property def update_interval(self) -> int: """Time to wait between updates.""" - if self._last_update_error is None: - return self.MINIMUM_UPDATE_INTERVAL_SECS + if self._last_update_error: + return self.UPDATE_INTERVAL_AFTER_ERROR_SECS * self._error_count + + if self._device._is_hub_child: + return self.MINIMUM_HUB_CHILD_UPDATE_INTERVAL_SECS - return self.UPDATE_INTERVAL_AFTER_ERROR_SECS * self._error_count + return self.MINIMUM_UPDATE_INTERVAL_SECS @property def disabled(self) -> bool: """Return true if the module is disabled due to errors.""" return self._error_count >= self.DISABLE_AFTER_ERROR_COUNT + def _should_update(self, update_time: float) -> bool: + """Return true if module should update based on delay parameters.""" + return ( + not self.update_interval + or not self._last_update_time + or (update_time - self._last_update_time) >= self.update_interval + ) + @classmethod def _module_name(cls) -> str: return getattr(cls, "NAME", cls.__name__) diff --git a/kasa/smartcam/modules/device.py b/kasa/smartcam/modules/device.py index 655a92daf..7f84de1e5 100644 --- a/kasa/smartcam/modules/device.py +++ b/kasa/smartcam/modules/device.py @@ -16,6 +16,11 @@ class DeviceModule(SmartCamModule): def query(self) -> dict: """Query to execute during the update cycle.""" + if self._device._is_hub_child: + # Child devices get their device info updated by the parent device. + # and generally don't support connection type as they're not + # connected to the network + return {} q = super().query() q["getConnectionType"] = {"network": {"get_connection_type": []}} @@ -70,14 +75,14 @@ async def _post_update_hook(self) -> None: @property def device_id(self) -> str: """Return the device id.""" - return self.data[self.QUERY_GETTER_NAME]["basic_info"]["dev_id"] + return self._device._info["device_id"] @property def rssi(self) -> int | None: """Return the device id.""" - return self.data["getConnectionType"].get("rssiValue") + return self.data.get("getConnectionType", {}).get("rssiValue") @property def signal_level(self) -> int | None: """Return the device id.""" - return self.data["getConnectionType"].get("rssi") + return self.data.get("getConnectionType", {}).get("rssi") diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index 549eb8add..1cae0abc4 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -5,7 +5,7 @@ import copy import logging import time -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from unittest.mock import patch import pytest @@ -14,7 +14,6 @@ from kasa import Device, DeviceType, KasaException, Module from kasa.exceptions import DeviceError, SmartErrorCode -from kasa.protocols.smartprotocol import _ChildProtocolWrapper from kasa.smart import SmartDevice from kasa.smart.modules.energy import Energy from kasa.smart.smartmodule import SmartModule @@ -25,7 +24,16 @@ get_parent_and_child_modules, smart_discovery, ) -from tests.device_fixtures import variable_temp_smart +from tests.device_fixtures import ( + hub_smartcam, + hubs_smart, + parametrize_combine, + variable_temp_smart, +) + +DUMMY_CHILD_REQUEST_PREFIX = "get_dummy_" + +hub_all = parametrize_combine([hubs_smart, hub_smartcam]) @device_smart @@ -214,6 +222,166 @@ async def test_update_module_update_delays( ), f"Expected update time {expected_update_time} after {seconds} seconds for {module.name} with delay {mod_delay} got {module._last_update_time}" +async def _get_child_responses(child_requests: list[dict[str, Any]], child_protocol): + """Get dummy responses for testing all child modules. + + Even if they don't return really return query. + """ + child_req = {item["method"]: item.get("params") for item in child_requests} + child_resp = {k: v for k, v in child_req.items() if k.startswith("get_dummy")} + child_req = { + k: v for k, v in child_req.items() if k.startswith("get_dummy") is False + } + resp = await child_protocol._query(child_req) + resp = {**child_resp, **resp} + return [ + {"method": k, "error_code": 0, "result": v or {"dummy": "dummy"}} + for k, v in resp.items() + ] + + +@hub_all +@pytest.mark.xdist_group(name="caplog") +async def test_hub_children_update_delays( + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +): + """Test that hub children use the correct delay.""" + if not dev.children: + pytest.skip(f"Device {dev.model} does not have children.") + # We need to have some modules initialized by now + assert dev._modules + + new_dev = type(dev)("127.0.0.1", protocol=dev.protocol) + module_queries: dict[str, dict[str, dict]] = {} + + # children should always update on first update + await new_dev.update(update_children=False) + + if TYPE_CHECKING: + from ..fakeprotocol_smart import FakeSmartTransport + + assert isinstance(dev.protocol._transport, FakeSmartTransport) + if dev.protocol._transport.child_protocols: + for child in new_dev.children: + for modname, module in child._modules.items(): + if ( + not (q := module.query()) + and modname not in {"DeviceModule", "Light"} + and not module.SYSINFO_LOOKUP_KEYS + ): + q = {f"get_dummy_{modname}": {}} + mocker.patch.object(module, "query", return_value=q) + if q: + queries = module_queries.setdefault(child.device_id, {}) + queries[cast(str, modname)] = q + module._last_update_time = None + + module_queries[""] = { + cast(str, modname): q + for modname, module in dev._modules.items() + if (q := module.query()) + } + + async def _query(request, *args, **kwargs): + # If this is a child multipleRequest query return the error wrapped + child_id = None + # smart hub + if ( + (cc := request.get("control_child")) + and (child_id := cc.get("device_id")) + and (requestData := cc["requestData"]) + and requestData["method"] == "multipleRequest" + and (child_requests := requestData["params"]["requests"]) + ): + child_protocol = dev.protocol._transport.child_protocols[child_id] + resp = await _get_child_responses(child_requests, child_protocol) + return {"control_child": {"responseData": {"result": {"responses": resp}}}} + # smartcam hub + if ( + (mr := request.get("multipleRequest")) + and (requests := mr.get("requests")) + # assumes all requests for the same child + and ( + child_id := next(iter(requests)) + .get("params", {}) + .get("childControl", {}) + .get("device_id") + ) + and ( + child_requests := [ + cc["request_data"] + for req in requests + if (cc := req["params"].get("childControl")) + ] + ) + ): + child_protocol = dev.protocol._transport.child_protocols[child_id] + resp = await _get_child_responses(child_requests, child_protocol) + resp = [{"result": {"response_data": resp}} for resp in resp] + return {"multipleRequest": {"responses": resp}} + + if child_id: # child single query + child_protocol = dev.protocol._transport.child_protocols[child_id] + resp_list = await _get_child_responses([requestData], child_protocol) + resp = {"control_child": {"responseData": resp_list[0]}} + else: + resp = await dev.protocol._query(request, *args, **kwargs) + + return resp + + mocker.patch.object(new_dev.protocol, "query", side_effect=_query) + + first_update_time = time.monotonic() + assert new_dev._last_update_time == first_update_time + + await new_dev.update() + + for dev_id, modqueries in module_queries.items(): + check_dev = new_dev._children[dev_id] if dev_id else new_dev + for modname in modqueries: + mod = cast(SmartModule, check_dev.modules[modname]) + assert mod._last_update_time == first_update_time + + for mod in new_dev.modules.values(): + mod.MINIMUM_UPDATE_INTERVAL_SECS = 5 + freezer.tick(180) + + now = time.monotonic() + await new_dev.update() + + child_tick = max( + module.MINIMUM_HUB_CHILD_UPDATE_INTERVAL_SECS + for child in new_dev.children + for module in child.modules.values() + ) + + for dev_id, modqueries in module_queries.items(): + check_dev = new_dev._children[dev_id] if dev_id else new_dev + for modname in modqueries: + if modname in {"Firmware"}: + continue + mod = cast(SmartModule, check_dev.modules[modname]) + expected_update_time = first_update_time if dev_id else now + assert mod._last_update_time == expected_update_time + + freezer.tick(child_tick) + + now = time.monotonic() + await new_dev.update() + + for dev_id, modqueries in module_queries.items(): + check_dev = new_dev._children[dev_id] if dev_id else new_dev + for modname in modqueries: + if modname in {"Firmware"}: + continue + mod = cast(SmartModule, check_dev.modules[modname]) + + assert mod._last_update_time == now + + @pytest.mark.parametrize( ("first_update"), [ @@ -261,25 +429,77 @@ async def test_update_module_query_errors( new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) if not first_update: await new_dev.update() - freezer.tick( - max(module.MINIMUM_UPDATE_INTERVAL_SECS for module in dev._modules.values()) - ) - - module_queries = { - modname: q + freezer.tick(max(module.update_interval for module in dev._modules.values())) + + module_queries: dict[str, dict[str, dict]] = {} + if TYPE_CHECKING: + from ..fakeprotocol_smart import FakeSmartTransport + + assert isinstance(dev.protocol._transport, FakeSmartTransport) + if dev.protocol._transport.child_protocols: + for child in new_dev.children: + for modname, module in child._modules.items(): + if ( + not (q := module.query()) + and modname not in {"DeviceModule", "Light"} + and not module.SYSINFO_LOOKUP_KEYS + ): + q = {f"get_dummy_{modname}": {}} + mocker.patch.object(module, "query", return_value=q) + if q: + queries = module_queries.setdefault(child.device_id, {}) + queries[cast(str, modname)] = q + + module_queries[""] = { + cast(str, modname): q for modname, module in dev._modules.items() if (q := module.query()) and modname not in critical_modules } + raise_error = True + async def _query(request, *args, **kwargs): + pass + # If this is a childmultipleRequest query return the error wrapped + child_id = None if ( - "component_nego" in request + (cc := request.get("control_child")) + and (child_id := cc.get("device_id")) + and (requestData := cc["requestData"]) + and requestData["method"] == "multipleRequest" + and (child_requests := requestData["params"]["requests"]) + ): + if raise_error: + if not isinstance(error_type, SmartErrorCode): + raise TimeoutError() + if len(child_requests) > 1: + raise TimeoutError() + + if raise_error: + resp = { + "method": child_requests[0]["method"], + "error_code": error_type.value, + } + else: + child_protocol = dev.protocol._transport.child_protocols[child_id] + resp = await _get_child_responses(child_requests, child_protocol) + return {"control_child": {"responseData": {"result": {"responses": resp}}}} + + if ( + not raise_error + or "component_nego" in request or "get_child_device_component_list" in request - or "control_child" in request ): - resp = await dev.protocol._query(request, *args, **kwargs) - resp["get_connect_cloud_state"] = SmartErrorCode.CLOUD_FAILED_ERROR + if child_id: # child single query + child_protocol = dev.protocol._transport.child_protocols[child_id] + resp_list = await _get_child_responses([requestData], child_protocol) + resp = {"control_child": {"responseData": resp_list[0]}} + else: + resp = await dev.protocol._query(request, *args, **kwargs) + if raise_error: + resp["get_connect_cloud_state"] = SmartErrorCode.CLOUD_FAILED_ERROR return resp + # Don't test for errors on get_device_info as that is likely terminal if len(request) == 1 and "get_device_info" in request: return await dev.protocol._query(request, *args, **kwargs) @@ -290,80 +510,77 @@ async def _query(request, *args, **kwargs): raise TimeoutError("Dummy timeout") raise error_type - child_protocols = { - cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol - for child in dev.children - } - - async def _child_query(self, request, *args, **kwargs): - return await child_protocols[self._device_id]._query(request, *args, **kwargs) - mocker.patch.object(new_dev.protocol, "query", side_effect=_query) - # children not created yet so cannot patch.object - mocker.patch( - "kasa.protocols.smartprotocol._ChildProtocolWrapper.query", new=_child_query - ) await new_dev.update() msg = f"Error querying {new_dev.host} for modules" assert msg in caplog.text - for modname in module_queries: - mod = cast(SmartModule, new_dev.modules[modname]) - assert mod.disabled is False, f"{modname} disabled" - assert mod.update_interval == mod.UPDATE_INTERVAL_AFTER_ERROR_SECS - for mod_query in module_queries[modname]: - if not first_update or mod_query not in first_update_queries: - msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" - assert msg in caplog.text + for dev_id, modqueries in module_queries.items(): + check_dev = new_dev._children[dev_id] if dev_id else new_dev + for modname in modqueries: + mod = cast(SmartModule, check_dev.modules[modname]) + if modname in {"DeviceModule"} or ( + hasattr(mod, "_state_in_sysinfo") and mod._state_in_sysinfo is True + ): + continue + assert mod.disabled is False, f"{modname} disabled" + assert mod.update_interval == mod.UPDATE_INTERVAL_AFTER_ERROR_SECS + for mod_query in modqueries[modname]: + if not first_update or mod_query not in first_update_queries: + msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" + assert msg in caplog.text # Query again should not run for the modules caplog.clear() await new_dev.update() - for modname in module_queries: - mod = cast(SmartModule, new_dev.modules[modname]) - assert mod.disabled is False, f"{modname} disabled" + for dev_id, modqueries in module_queries.items(): + check_dev = new_dev._children[dev_id] if dev_id else new_dev + for modname in modqueries: + mod = cast(SmartModule, check_dev.modules[modname]) + assert mod.disabled is False, f"{modname} disabled" freezer.tick(SmartModule.UPDATE_INTERVAL_AFTER_ERROR_SECS) caplog.clear() if recover: - mocker.patch.object( - new_dev.protocol, "query", side_effect=new_dev.protocol._query - ) - mocker.patch( - "kasa.protocols.smartprotocol._ChildProtocolWrapper.query", - new=_ChildProtocolWrapper._query, - ) + raise_error = False await new_dev.update() msg = f"Error querying {new_dev.host} for modules" if not recover: assert msg in caplog.text - for modname in module_queries: - mod = cast(SmartModule, new_dev.modules[modname]) - if not recover: - assert mod.disabled is True, f"{modname} not disabled" - assert mod._error_count == 2 - assert mod._last_update_error - for mod_query in module_queries[modname]: - if not first_update or mod_query not in first_update_queries: - msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" - assert msg in caplog.text - # Test one of the raise_if_update_error - if mod.name == "Energy": - emod = cast(Energy, mod) - with pytest.raises(KasaException, match="Module update error"): + + for dev_id, modqueries in module_queries.items(): + check_dev = new_dev._children[dev_id] if dev_id else new_dev + for modname in modqueries: + mod = cast(SmartModule, check_dev.modules[modname]) + if modname in {"DeviceModule"} or ( + hasattr(mod, "_state_in_sysinfo") and mod._state_in_sysinfo is True + ): + continue + if not recover: + assert mod.disabled is True, f"{modname} not disabled" + assert mod._error_count == 2 + assert mod._last_update_error + for mod_query in modqueries[modname]: + if not first_update or mod_query not in first_update_queries: + msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" + assert msg in caplog.text + # Test one of the raise_if_update_error + if mod.name == "Energy": + emod = cast(Energy, mod) + with pytest.raises(KasaException, match="Module update error"): + assert emod.status is not None + else: + assert mod.disabled is False + assert mod._error_count == 0 + assert mod._last_update_error is None + # Test one of the raise_if_update_error doesn't raise + if mod.name == "Energy": + emod = cast(Energy, mod) assert emod.status is not None - else: - assert mod.disabled is False - assert mod._error_count == 0 - assert mod._last_update_error is None - # Test one of the raise_if_update_error doesn't raise - if mod.name == "Energy": - emod = cast(Energy, mod) - assert emod.status is not None async def test_get_modules(): From 589d15091a051b29688f207e146dff8096536ccc Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 14 Jan 2025 08:38:04 +0000 Subject: [PATCH 800/892] Add smartcam child device support for smartcam hubs (#1413) --- devtools/dump_devinfo.py | 185 +++++++++++++++++++------- devtools/generate_supported.py | 4 +- kasa/smartcam/__init__.py | 3 +- kasa/smartcam/smartcamchild.py | 115 ++++++++++++++++ kasa/smartcam/smartcamdevice.py | 50 ++++++- tests/device_fixtures.py | 6 +- tests/fakeprotocol_smart.py | 47 +++++-- tests/fakeprotocol_smartcam.py | 17 +++ tests/fixtureinfo.py | 20 +-- tests/smartcam/modules/test_camera.py | 12 +- tests/smartcam/test_smartcamdevice.py | 8 +- tests/test_device.py | 37 +++++- tests/test_devtools.py | 21 ++- 13 files changed, 432 insertions(+), 93 deletions(-) create mode 100644 kasa/smartcam/smartcamchild.py diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index e985ab40f..cee7a7bff 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -54,7 +54,8 @@ from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.smart import SmartChildDevice, SmartDevice -from kasa.smartcam import SmartCamDevice +from kasa.smartcam import SmartCamChild, SmartCamDevice +from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT Call = namedtuple("Call", "module method") FixtureResult = namedtuple("FixtureResult", "filename, folder, data, protocol_suffix") @@ -62,11 +63,13 @@ SMART_FOLDER = "tests/fixtures/smart/" SMARTCAM_FOLDER = "tests/fixtures/smartcam/" SMART_CHILD_FOLDER = "tests/fixtures/smart/child/" +SMARTCAM_CHILD_FOLDER = "tests/fixtures/smartcam/child/" IOT_FOLDER = "tests/fixtures/iot/" SMART_PROTOCOL_SUFFIX = "SMART" SMARTCAM_SUFFIX = "SMARTCAM" SMART_CHILD_SUFFIX = "SMART.CHILD" +SMARTCAM_CHILD_SUFFIX = "SMARTCAM.CHILD" IOT_SUFFIX = "IOT" NO_GIT_FIXTURE_FOLDER = "kasa-fixtures" @@ -844,9 +847,8 @@ async def get_smart_test_calls(protocol: SmartProtocol): return test_calls, successes -def get_smart_child_fixture(response): +def get_smart_child_fixture(response, model_info, folder, suffix): """Get a seperate fixture for the child device.""" - model_info = SmartDevice._get_device_info(response, None) hw_version = model_info.hardware_version fw_version = model_info.firmware_version model = model_info.long_name @@ -855,12 +857,68 @@ def get_smart_child_fixture(response): save_filename = f"{model}_{hw_version}_{fw_version}" return FixtureResult( filename=save_filename, - folder=SMART_CHILD_FOLDER, + folder=folder, data=response, - protocol_suffix=SMART_CHILD_SUFFIX, + protocol_suffix=suffix, ) +def scrub_child_device_ids( + main_response: dict, child_responses: dict +) -> dict[str, str]: + """Scrub all the child device ids in the responses.""" + # Make the scrubbed id map + scrubbed_child_id_map = { + device_id: f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}" + for index, device_id in enumerate(child_responses.keys()) + if device_id != "" + } + + for child_id, response in child_responses.items(): + scrubbed_child_id = scrubbed_child_id_map[child_id] + # scrub the device id in the child's get info response + # The checks for the device_id will ensure we can get a fixture + # even if the data is unexpectedly not available although it should + # always be there + if "get_device_info" in response and "device_id" in response["get_device_info"]: + response["get_device_info"]["device_id"] = scrubbed_child_id + elif ( + basic_info := response.get("getDeviceInfo", {}) + .get("device_info", {}) + .get("basic_info") + ) and "dev_id" in basic_info: + basic_info["dev_id"] = scrubbed_child_id + else: + _LOGGER.error( + "Cannot find device id in child get device info: %s", child_id + ) + + # Scrub the device ids in the parent for smart protocol + if gc := main_response.get("get_child_device_component_list"): + for child in gc["child_component_list"]: + device_id = child["device_id"] + child["device_id"] = scrubbed_child_id_map[device_id] + for child in main_response["get_child_device_list"]["child_device_list"]: + device_id = child["device_id"] + child["device_id"] = scrubbed_child_id_map[device_id] + + # Scrub the device ids in the parent for the smart camera protocol + if gc := main_response.get("getChildDeviceComponentList"): + for child in gc["child_component_list"]: + device_id = child["device_id"] + child["device_id"] = scrubbed_child_id_map[device_id] + for child in main_response["getChildDeviceList"]["child_device_list"]: + if device_id := child.get("device_id"): + child["device_id"] = scrubbed_child_id_map[device_id] + continue + elif dev_id := child.get("dev_id"): + child["dev_id"] = scrubbed_child_id_map[dev_id] + continue + _LOGGER.error("Could not find a device id for the child device: %s", child) + + return scrubbed_child_id_map + + async def get_smart_fixtures( protocol: SmartProtocol, *, @@ -917,21 +975,19 @@ async def get_smart_fixtures( finally: await protocol.close() + # Put all the successes into a dict[child_device_id or "", successes[]] device_requests: dict[str, list[SmartCall]] = {} for success in successes: device_request = device_requests.setdefault(success.child_device_id, []) device_request.append(success) - scrubbed_device_ids = { - device_id: f"SCRUBBED_CHILD_DEVICE_ID_{index}" - for index, device_id in enumerate(device_requests.keys()) - if device_id != "" - } - final = await _make_final_calls( protocol, device_requests[""], "All successes", batch_size, child_device_id="" ) fixture_results = [] + + # Make the final child calls + child_responses = {} for child_device_id, requests in device_requests.items(): if child_device_id == "": continue @@ -942,55 +998,82 @@ async def get_smart_fixtures( batch_size, child_device_id=child_device_id, ) + child_responses[child_device_id] = response - scrubbed = scrubbed_device_ids[child_device_id] - if "get_device_info" in response and "device_id" in response["get_device_info"]: - response["get_device_info"]["device_id"] = scrubbed - # If the child is a different model to the parent create a seperate fixture - if "get_device_info" in final: - parent_model = final["get_device_info"]["model"] - elif "getDeviceInfo" in final: - parent_model = final["getDeviceInfo"]["device_info"]["basic_info"][ - "device_model" - ] + # scrub the child ids + scrubbed_child_id_map = scrub_child_device_ids(final, child_responses) + + # Redact data from the main device response. _wrap_redactors ensure we do + # not redact the scrubbed child device ids and replaces REDACTED_partial_id + # with zeros + final = redact_data(final, _wrap_redactors(SMART_REDACTORS)) + + # smart cam child devices provide more information in getChildDeviceList on the + # parent than they return when queried directly for getDeviceInfo so we will store + # it in the child fixture. + if smart_cam_child_list := final.get("getChildDeviceList"): + child_infos_on_parent = { + info["device_id"]: info + for info in smart_cam_child_list["child_device_list"] + } + + for child_id, response in child_responses.items(): + scrubbed_child_id = scrubbed_child_id_map[child_id] + + # Get the parent model for checking whether to create a seperate child fixture + if model := final.get("get_device_info", {}).get("model"): + parent_model = model + elif ( + device_model := final.get("getDeviceInfo", {}) + .get("device_info", {}) + .get("basic_info", {}) + .get("device_model") + ): + parent_model = device_model else: - raise KasaException("Cannot determine parent device model.") + parent_model = None + _LOGGER.error("Cannot determine parent device model.") + + # different model smart child device if ( - "component_nego" in response - and "get_device_info" in response - and (child_model := response["get_device_info"].get("model")) + (child_model := response.get("get_device_info", {}).get("model")) + and parent_model + and child_model != parent_model + ): + response = redact_data(response, _wrap_redactors(SMART_REDACTORS)) + model_info = SmartDevice._get_device_info(response, None) + fixture_results.append( + get_smart_child_fixture( + response, model_info, SMART_CHILD_FOLDER, SMART_CHILD_SUFFIX + ) + ) + # different model smartcam child device + elif ( + ( + child_model := response.get("getDeviceInfo", {}) + .get("device_info", {}) + .get("basic_info", {}) + .get("device_model") + ) + and parent_model and child_model != parent_model ): response = redact_data(response, _wrap_redactors(SMART_REDACTORS)) - fixture_results.append(get_smart_child_fixture(response)) + # There is more info in the childDeviceList on the parent + # particularly the region is needed here. + child_info_from_parent = child_infos_on_parent[scrubbed_child_id] + response[CHILD_INFO_FROM_PARENT] = child_info_from_parent + model_info = SmartCamChild._get_device_info(response, None) + fixture_results.append( + get_smart_child_fixture( + response, model_info, SMARTCAM_CHILD_FOLDER, SMARTCAM_CHILD_SUFFIX + ) + ) + # same model child device else: cd = final.setdefault("child_devices", {}) - cd[scrubbed] = response + cd[scrubbed_child_id] = response - # Scrub the device ids in the parent for smart protocol - if gc := final.get("get_child_device_component_list"): - for child in gc["child_component_list"]: - device_id = child["device_id"] - child["device_id"] = scrubbed_device_ids[device_id] - for child in final["get_child_device_list"]["child_device_list"]: - device_id = child["device_id"] - child["device_id"] = scrubbed_device_ids[device_id] - - # Scrub the device ids in the parent for the smart camera protocol - if gc := final.get("getChildDeviceComponentList"): - for child in gc["child_component_list"]: - device_id = child["device_id"] - child["device_id"] = scrubbed_device_ids[device_id] - for child in final["getChildDeviceList"]["child_device_list"]: - if device_id := child.get("device_id"): - child["device_id"] = scrubbed_device_ids[device_id] - continue - elif dev_id := child.get("dev_id"): - child["dev_id"] = scrubbed_device_ids[dev_id] - continue - _LOGGER.error("Could not find a device for the child device: %s", child) - - final = redact_data(final, _wrap_redactors(SMART_REDACTORS)) discovery_result = None if discovery_info: final["discovery_result"] = redact_data( diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py index 7e946e1ae..f97c01c1d 100755 --- a/devtools/generate_supported.py +++ b/devtools/generate_supported.py @@ -13,7 +13,7 @@ from kasa.device_type import DeviceType from kasa.iot import IotDevice from kasa.smart import SmartDevice -from kasa.smartcam import SmartCamDevice +from kasa.smartcam import SmartCamChild, SmartCamDevice class SupportedVersion(NamedTuple): @@ -49,6 +49,7 @@ class SupportedVersion(NamedTuple): SMART_FOLDER = "tests/fixtures/smart/" SMART_CHILD_FOLDER = "tests/fixtures/smart/child" SMARTCAM_FOLDER = "tests/fixtures/smartcam/" +SMARTCAM_CHILD_FOLDER = "tests/fixtures/smartcam/child" def generate_supported(args): @@ -66,6 +67,7 @@ def generate_supported(args): _get_supported_devices(supported, SMART_FOLDER, SmartDevice) _get_supported_devices(supported, SMART_CHILD_FOLDER, SmartDevice) _get_supported_devices(supported, SMARTCAM_FOLDER, SmartCamDevice) + _get_supported_devices(supported, SMARTCAM_CHILD_FOLDER, SmartCamChild) readme_updated = _update_supported_file( README_FILENAME, _supported_summary(supported), print_diffs diff --git a/kasa/smartcam/__init__.py b/kasa/smartcam/__init__.py index 574459f46..21cbeb50b 100644 --- a/kasa/smartcam/__init__.py +++ b/kasa/smartcam/__init__.py @@ -1,5 +1,6 @@ """Package for supporting tapo-branded cameras.""" +from .smartcamchild import SmartCamChild from .smartcamdevice import SmartCamDevice -__all__ = ["SmartCamDevice"] +__all__ = ["SmartCamDevice", "SmartCamChild"] diff --git a/kasa/smartcam/smartcamchild.py b/kasa/smartcam/smartcamchild.py new file mode 100644 index 000000000..f02f21c97 --- /dev/null +++ b/kasa/smartcam/smartcamchild.py @@ -0,0 +1,115 @@ +"""Child device implementation.""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..device import DeviceInfo +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..protocols.smartcamprotocol import _ChildCameraProtocolWrapper +from ..protocols.smartprotocol import SmartProtocol +from ..smart.smartchilddevice import SmartChildDevice +from ..smart.smartdevice import ComponentsRaw, SmartDevice +from .smartcamdevice import SmartCamDevice + +_LOGGER = logging.getLogger(__name__) + +# SmartCamChild devices have a different info format from getChildDeviceInfo +# than when querying getDeviceInfo directly on the child. +# As _get_device_info is also called by dump_devtools and generate_supported +# this key will be expected by _get_device_info +CHILD_INFO_FROM_PARENT = "child_info_from_parent" + + +class SmartCamChild(SmartChildDevice, SmartCamDevice): + """Presentation of a child device. + + This wraps the protocol communications and sets internal data for the child. + """ + + CHILD_DEVICE_TYPE_MAP = { + "camera": DeviceType.Camera, + } + + def __init__( + self, + parent: SmartDevice, + info: dict, + component_info_raw: ComponentsRaw, + *, + config: DeviceConfig | None = None, + protocol: SmartProtocol | None = None, + ) -> None: + _protocol = protocol or _ChildCameraProtocolWrapper( + info["device_id"], parent.protocol + ) + super().__init__(parent, info, component_info_raw, protocol=_protocol) + self._child_info_from_parent: dict = {} + + @property + def device_info(self) -> DeviceInfo: + """Return device info. + + Child device does not have it info and components in _last_update so + this overrides the base implementation to call _get_device_info with + info and components combined as they would be in _last_update. + """ + return self._get_device_info( + { + CHILD_INFO_FROM_PARENT: self._child_info_from_parent, + }, + None, + ) + + def _map_child_info_from_parent(self, device_info: dict) -> dict: + return { + "model": device_info["device_model"], + "device_type": device_info["device_type"], + "alias": device_info["alias"], + "fw_ver": device_info["sw_ver"], + "hw_ver": device_info["hw_ver"], + "mac": device_info["mac"], + "hwId": device_info.get("hw_id"), + "oem_id": device_info["oem_id"], + "device_id": device_info["device_id"], + } + + def _update_internal_state(self, info: dict[str, Any]) -> None: + """Update the internal info state. + + This is used by the parent to push updates to its children. + """ + # smartcam children have info with different keys to their own + # getDeviceInfo queries + self._child_info_from_parent = info + + # self._info will have the values normalized across smart and smartcam + # devices + self._info = self._map_child_info_from_parent(info) + + @staticmethod + def _get_device_info( + info: dict[str, Any], discovery_info: dict[str, Any] | None + ) -> DeviceInfo: + """Get model information for a device.""" + if not (cifp := info.get(CHILD_INFO_FROM_PARENT)): + return SmartCamDevice._get_device_info(info, discovery_info) + + model = cifp["device_model"] + device_type = SmartCamDevice._get_device_type_from_sysinfo(cifp) + fw_version_full = cifp["sw_ver"] + firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + return DeviceInfo( + short_name=model, + long_name=model, + brand="tapo", + device_family=cifp["device_type"], + device_type=device_type, + hardware_version=cifp["hw_ver"], + firmware_version=firmware_version, + firmware_build=firmware_build, + requires_auth=True, + region=cifp.get("region"), + ) diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index fdae3140b..066296788 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -63,6 +63,13 @@ def _update_internal_info(self, info_resp: dict) -> None: info = self._try_get_response(info_resp, "getDeviceInfo") self._info = self._map_info(info["device_info"]) + def _update_internal_state(self, info: dict[str, Any]) -> None: + """Update the internal info state. + + This is used by the parent to push updates to its children. + """ + self._info = self._map_info(info) + def _update_children_info(self) -> None: """Update the internal child device info from the parent info.""" if child_info := self._try_get_response( @@ -99,6 +106,27 @@ async def _initialize_smart_child( last_update=initial_response, ) + async def _initialize_smartcam_child( + self, info: dict, child_components_raw: ComponentsRaw + ) -> SmartDevice: + """Initialize a smart child device attached to a smartcam device.""" + child_id = info["device_id"] + child_protocol = _ChildCameraProtocolWrapper(child_id, self.protocol) + + last_update = {"getDeviceInfo": {"device_info": {"basic_info": info}}} + app_component_list = { + "app_component_list": child_components_raw["component_list"] + } + from .smartcamchild import SmartCamChild + + return await SmartCamChild.create( + parent=self, + child_info=info, + child_components_raw=app_component_list, + protocol=child_protocol, + last_update=last_update, + ) + async def _initialize_children(self) -> None: """Initialize children for hubs.""" child_info_query = { @@ -113,18 +141,28 @@ async def _initialize_children(self) -> None: for child in resp["getChildDeviceComponentList"]["child_component_list"] } children = {} + from .smartcamchild import SmartCamChild + for info in resp["getChildDeviceList"]["child_device_list"]: if ( (category := info.get("category")) - and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP and (child_id := info.get("device_id")) and (child_components := smart_children_components.get(child_id)) ): - children[child_id] = await self._initialize_smart_child( - info, child_components - ) - else: - _LOGGER.debug("Child device type not supported: %s", info) + # Smart + if category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP: + children[child_id] = await self._initialize_smart_child( + info, child_components + ) + continue + # Smartcam + if category in SmartCamChild.CHILD_DEVICE_TYPE_MAP: + children[child_id] = await self._initialize_smartcam_child( + info, child_components + ) + continue + + _LOGGER.debug("Child device type not supported: %s", info) self._children = children diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index af9b52cc4..295e66abd 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -335,7 +335,7 @@ def parametrize( camera_smartcam = parametrize( "camera smartcam", device_type_filter=[DeviceType.Camera], - protocol_filter={"SMARTCAM"}, + protocol_filter={"SMARTCAM", "SMARTCAM.CHILD"}, ) hub_smartcam = parametrize( "hub smartcam", @@ -377,7 +377,7 @@ def check_categories(): def device_for_fixture_name(model, protocol): if protocol in {"SMART", "SMART.CHILD"}: return SmartDevice - elif protocol == "SMARTCAM": + elif protocol in {"SMARTCAM", "SMARTCAM.CHILD"}: return SmartCamDevice else: for d in STRIPS_IOT: @@ -434,7 +434,7 @@ async def get_device_for_fixture( d.protocol = FakeSmartProtocol( fixture_data.data, fixture_data.name, verbatim=verbatim ) - elif fixture_data.protocol == "SMARTCAM": + elif fixture_data.protocol in {"SMARTCAM", "SMARTCAM.CHILD"}: d.protocol = FakeSmartCamProtocol( fixture_data.data, fixture_data.name, verbatim=verbatim ) diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index a2fc39261..7e4774b6f 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -7,6 +7,8 @@ from kasa import Credentials, DeviceConfig, SmartProtocol from kasa.exceptions import SmartErrorCode from kasa.smart import SmartChildDevice +from kasa.smartcam import SmartCamChild +from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT from kasa.transports.basetransport import BaseTransport @@ -227,16 +229,20 @@ def _get_child_protocols( # imported here to avoid circular import from .conftest import filter_fixtures - def try_get_child_fixture_info(child_dev_info): + def try_get_child_fixture_info(child_dev_info, protocol): hw_version = child_dev_info["hw_ver"] - sw_version = child_dev_info["fw_ver"] + sw_version = child_dev_info.get("sw_ver", child_dev_info.get("fw_ver")) sw_version = sw_version.split(" ")[0] - model = child_dev_info["model"] - region = child_dev_info.get("specs", "XX") - child_fixture_name = f"{model}({region})_{hw_version}_{sw_version}" + model = child_dev_info.get("device_model", child_dev_info.get("model")) + assert sw_version + assert model + + region = child_dev_info.get("specs", child_dev_info.get("region")) + region = f"({region})" if region else "" + child_fixture_name = f"{model}{region}_{hw_version}_{sw_version}" child_fixtures = filter_fixtures( "Child fixture", - protocol_filter={"SMART.CHILD"}, + protocol_filter={protocol}, model_filter={child_fixture_name}, ) if child_fixtures: @@ -249,7 +255,9 @@ def try_get_child_fixture_info(child_dev_info): and (category := child_info.get("category")) and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP ): - if fixture_info_tuple := try_get_child_fixture_info(child_info): + if fixture_info_tuple := try_get_child_fixture_info( + child_info, "SMART.CHILD" + ): child_fixture = copy.deepcopy(fixture_info_tuple.data) child_fixture["get_device_info"]["device_id"] = device_id found_child_fixture_infos.append(child_fixture["get_device_info"]) @@ -270,9 +278,32 @@ def try_get_child_fixture_info(child_dev_info): pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] parent_fixture_name, set() ).add("child_devices") + elif ( + (device_id := child_info.get("device_id")) + and (category := child_info.get("category")) + and category in SmartCamChild.CHILD_DEVICE_TYPE_MAP + and ( + fixture_info_tuple := try_get_child_fixture_info( + child_info, "SMARTCAM.CHILD" + ) + ) + ): + from .fakeprotocol_smartcam import FakeSmartCamProtocol + + child_fixture = copy.deepcopy(fixture_info_tuple.data) + child_fixture["getDeviceInfo"]["device_info"]["basic_info"][ + "dev_id" + ] = device_id + child_fixture[CHILD_INFO_FROM_PARENT]["device_id"] = device_id + # We copy the child device info to the parent getChildDeviceInfo + # list for smartcam children in order for updates to work. + found_child_fixture_infos.append(child_fixture[CHILD_INFO_FROM_PARENT]) + child_protocols[device_id] = FakeSmartCamProtocol( + child_fixture, fixture_info_tuple.name, is_child=True + ) else: warn( - f"Child is a cameraprotocol which needs to be implemented {child_info}", + f"Child is a protocol which needs to be implemented {child_info}", stacklevel=2, ) # Replace parent child infos with the infos from the child fixtures so diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index 17b149792..431a761d5 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -6,6 +6,7 @@ from kasa import Credentials, DeviceConfig, SmartProtocol from kasa.protocols.smartcamprotocol import SmartCamProtocol +from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT from kasa.transports.basetransport import BaseTransport from .fakeprotocol_smart import FakeSmartTransport @@ -125,10 +126,26 @@ async def _handle_control_child(self, params: dict): @staticmethod def _get_param_set_value(info: dict, set_keys: list[str], value): + cifp = info.get(CHILD_INFO_FROM_PARENT) + for key in set_keys[:-1]: info = info[key] info[set_keys[-1]] = value + if ( + cifp + and set_keys[0] == "getDeviceInfo" + and ( + child_info_parent_key + := FakeSmartCamTransport.CHILD_INFO_SETTER_MAP.get(set_keys[-1]) + ) + ): + cifp[child_info_parent_key] = value + + CHILD_INFO_SETTER_MAP = { + "device_alias": "alias", + } + FIXTURE_MISSING_MAP = { "getMatterSetupInfo": ( "matter", diff --git a/tests/fixtureinfo.py b/tests/fixtureinfo.py index 8988be1d2..fbfe6ff80 100644 --- a/tests/fixtureinfo.py +++ b/tests/fixtureinfo.py @@ -60,11 +60,19 @@ class ComponentFilter(NamedTuple): ) ] +SUPPORTED_SMARTCAM_CHILD_DEVICES = [ + (device, "SMARTCAM.CHILD") + for device in glob.glob( + os.path.dirname(os.path.abspath(__file__)) + "/fixtures/smartcam/child/*.json" + ) +] + SUPPORTED_DEVICES = ( SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES + SUPPORTED_SMART_CHILD_DEVICES + SUPPORTED_SMARTCAM_DEVICES + + SUPPORTED_SMARTCAM_CHILD_DEVICES ) @@ -82,14 +90,8 @@ def get_fixture_infos() -> list[FixtureInfo]: fixture_data = [] for file, protocol in SUPPORTED_DEVICES: p = Path(file) - folder = Path(__file__).parent / "fixtures" - if protocol == "SMART": - folder = folder / "smart" - if protocol == "SMART.CHILD": - folder = folder / "smart/child" - p = folder / file - - with open(p) as f: + + with open(file) as f: data = json.load(f) fixture_name = p.name @@ -188,7 +190,7 @@ def _device_type_match(fixture_data: FixtureInfo, device_type): IotDevice._get_device_type_from_sys_info(fixture_data.data) in device_type ) - elif fixture_data.protocol == "SMARTCAM": + elif fixture_data.protocol in {"SMARTCAM", "SMARTCAM.CHILD"}: info = fixture_data.data["getDeviceInfo"]["device_info"]["basic_info"] return SmartCamDevice._get_device_type_from_sysinfo(info) in device_type return False diff --git a/tests/smartcam/modules/test_camera.py b/tests/smartcam/modules/test_camera.py index ebc08101c..d668f9f46 100644 --- a/tests/smartcam/modules/test_camera.py +++ b/tests/smartcam/modules/test_camera.py @@ -10,7 +10,13 @@ from kasa import Credentials, Device, DeviceType, Module, StreamResolution -from ...conftest import camera_smartcam, device_smartcam +from ...conftest import device_smartcam, parametrize + +not_child_camera_smartcam = parametrize( + "not child camera smartcam", + device_type_filter=[DeviceType.Camera], + protocol_filter={"SMARTCAM"}, +) @device_smartcam @@ -24,7 +30,7 @@ async def test_state(dev: Device): assert dev.is_on is not state -@camera_smartcam +@not_child_camera_smartcam async def test_stream_rtsp_url(dev: Device): camera_module = dev.modules.get(Module.Camera) assert camera_module @@ -84,7 +90,7 @@ async def test_stream_rtsp_url(dev: Device): assert url is None -@camera_smartcam +@not_child_camera_smartcam async def test_onvif_url(dev: Device): """Test the onvif url.""" camera_module = dev.modules.get(Module.Camera) diff --git a/tests/smartcam/test_smartcamdevice.py b/tests/smartcam/test_smartcamdevice.py index 3355d2f03..8675b6934 100644 --- a/tests/smartcam/test_smartcamdevice.py +++ b/tests/smartcam/test_smartcamdevice.py @@ -52,12 +52,12 @@ async def test_alias(dev): async def test_hub(dev): assert dev.children for child in dev.children: - assert "Cloud" in child.modules - assert child.modules["Cloud"].data + assert child.modules + assert child.device_info + assert child.alias await child.update() - assert "Time" not in child.modules - assert child.time + assert child.device_id @device_smartcam diff --git a/tests/test_device.py b/tests/test_device.py index 20e5bef89..4f74e89cf 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -31,7 +31,7 @@ ) from kasa.iot.modules import IotLightPreset from kasa.smart import SmartChildDevice, SmartDevice -from kasa.smartcam import SmartCamDevice +from kasa.smartcam import SmartCamChild, SmartCamDevice def _get_subclasses(of_class): @@ -84,13 +84,24 @@ async def test_device_class_ctors(device_class_name_obj): credentials = Credentials("foo", "bar") config = DeviceConfig(host, port_override=port, credentials=credentials) klass = device_class_name_obj[1] - if issubclass(klass, SmartChildDevice): + if issubclass(klass, SmartChildDevice | SmartCamChild): parent = SmartDevice(host, config=config) + smartcam_required = { + "device_model": "foo", + "device_type": "SMART.TAPODOORBELL", + "alias": "Foo", + "sw_ver": "1.1", + "hw_ver": "1.0", + "mac": "1.2.3.4", + "hwId": "hw_id", + "oem_id": "oem_id", + } dev = klass( parent, - {"dummy": "info", "device_id": "dummy"}, + {"dummy": "info", "device_id": "dummy", **smartcam_required}, { "component_list": [{"id": "device", "ver_code": 1}], + "app_component_list": [{"name": "device", "version": 1}], }, ) else: @@ -108,13 +119,24 @@ async def test_device_class_repr(device_class_name_obj): credentials = Credentials("foo", "bar") config = DeviceConfig(host, port_override=port, credentials=credentials) klass = device_class_name_obj[1] - if issubclass(klass, SmartChildDevice): + if issubclass(klass, SmartChildDevice | SmartCamChild): parent = SmartDevice(host, config=config) + smartcam_required = { + "device_model": "foo", + "device_type": "SMART.TAPODOORBELL", + "alias": "Foo", + "sw_ver": "1.1", + "hw_ver": "1.0", + "mac": "1.2.3.4", + "hwId": "hw_id", + "oem_id": "oem_id", + } dev = klass( parent, - {"dummy": "info", "device_id": "dummy"}, + {"dummy": "info", "device_id": "dummy", **smartcam_required}, { "component_list": [{"id": "device", "ver_code": 1}], + "app_component_list": [{"name": "device", "version": 1}], }, ) else: @@ -132,11 +154,14 @@ async def test_device_class_repr(device_class_name_obj): SmartChildDevice: DeviceType.Unknown, SmartDevice: DeviceType.Unknown, SmartCamDevice: DeviceType.Camera, + SmartCamChild: DeviceType.Camera, } type_ = CLASS_TO_DEFAULT_TYPE[klass] child_repr = ">" not_child_repr = f"<{type_} at 127.0.0.2 - update() needed>" - expected_repr = child_repr if klass is SmartChildDevice else not_child_repr + expected_repr = ( + child_repr if klass in {SmartChildDevice, SmartCamChild} else not_child_repr + ) assert repr(dev) == expected_repr diff --git a/tests/test_devtools.py b/tests/test_devtools.py index 3af20035e..b49268d33 100644 --- a/tests/test_devtools.py +++ b/tests/test_devtools.py @@ -4,11 +4,18 @@ import pytest -from devtools.dump_devinfo import get_legacy_fixture, get_smart_fixtures +from devtools.dump_devinfo import ( + _wrap_redactors, + get_legacy_fixture, + get_smart_fixtures, +) from kasa.iot import IotDevice from kasa.protocols import IotProtocol +from kasa.protocols.protocol import redact_data +from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS from kasa.smart import SmartDevice from kasa.smartcam import SmartCamDevice +from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT from .conftest import ( FixtureInfo, @@ -113,6 +120,18 @@ async def test_smartcam_fixtures(fixture_info: FixtureInfo): saved_fixture_data = { key: val for key, val in saved_fixture_data.items() if val != -1001 } + + # Remove the child info from parent from the comparison because the + # child may have been created by a different parent fixture + saved_fixture_data.pop(CHILD_INFO_FROM_PARENT, None) + created_cifp = created_child_fixture.data.pop(CHILD_INFO_FROM_PARENT, None) + + # Still check that the created child info from parent was redacted. + # only smartcam children generate child_info_from_parent + if created_cifp: + redacted_cifp = redact_data(created_cifp, _wrap_redactors(SMART_REDACTORS)) + assert created_cifp == redacted_cifp + assert saved_fixture_data == created_child_fixture.data From 57f6c4138af890cd518ea23444dacf75da7b925a Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 14 Jan 2025 08:46:29 +0000 Subject: [PATCH 801/892] Add D230(EU) 1.20 1.1.19 fixture (#1448) --- README.md | 2 +- SUPPORTED.md | 3 + .../fixtures/smartcam/H200(EU)_1.0_1.3.6.json | 556 ++++++++++++++++++ .../smartcam/child/D230(EU)_1.20_1.1.19.json | 525 +++++++++++++++++ 4 files changed, 1085 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smartcam/H200(EU)_1.0_1.3.6.json create mode 100644 tests/fixtures/smartcam/child/D230(EU)_1.20_1.1.19.json diff --git a/README.md b/README.md index b0bf575a9..a450c606c 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: S210, S220, S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Cameras**: C100, C210, C225, C325WB, C520WS, C720, TC65, TC70 +- **Cameras**: C100, C210, C225, C325WB, C520WS, C720, D230, TC65, TC70 - **Hubs**: H100, H200 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 diff --git a/SUPPORTED.md b/SUPPORTED.md index c2495ef22..a48c56619 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -283,6 +283,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (US) / Firmware: 1.2.8 - **C720** - Hardware: 1.0 (US) / Firmware: 1.2.3 +- **D230** + - Hardware: 1.20 (EU) / Firmware: 1.1.19 - **TC65** - Hardware: 1.0 / Firmware: 1.3.9 - **TC70** @@ -296,6 +298,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.5.5 - **H200** - Hardware: 1.0 (EU) / Firmware: 1.3.2 + - Hardware: 1.0 (EU) / Firmware: 1.3.6 - Hardware: 1.0 (US) / Firmware: 1.3.6 ### Hub-Connected Devices diff --git a/tests/fixtures/smartcam/H200(EU)_1.0_1.3.6.json b/tests/fixtures/smartcam/H200(EU)_1.0_1.3.6.json new file mode 100644 index 000000000..99460fe18 --- /dev/null +++ b/tests/fixtures/smartcam/H200(EU)_1.0_1.3.6.json @@ -0,0 +1,556 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wired", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "H200", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.6 Build 20240829 rel.71119", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "F0-09-0D-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + } + }, + "getAlertConfig": {}, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 2 + }, + { + "name": "dateTime", + "version": 1 + }, + { + "name": "system", + "version": 4 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 1 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "hubRecord", + "version": 1 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "siren", + "version": 2 + }, + { + "name": "childControl", + "version": 1 + }, + { + "name": "childQuickSetup", + "version": 1 + }, + { + "name": "childInherit", + "version": 1 + }, + { + "name": "deviceLoad", + "version": 1 + }, + { + "name": "subg", + "version": 2 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "preWakeUp", + "version": 1 + }, + { + "name": "supportRE", + "version": 1 + }, + { + "name": "testSignal", + "version": 1 + }, + { + "name": "dataDownload", + "version": 1 + }, + { + "name": "testChildSignal", + "version": 1 + }, + { + "name": "ringLog", + "version": 1 + }, + { + "name": "matter", + "version": 1 + }, + { + "name": "localSmart", + "version": 1 + }, + { + "name": "generalCameraManage", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "hubPlayback", + "version": 1 + } + ] + } + }, + "getChildDeviceComponentList": { + "child_component_list": [ + { + "component_list": [ + { + "name": "sdCard", + "version": 2 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "batCamSystem", + "version": 1 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 3 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "firmware", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 2 + }, + { + "name": "dayNightMode", + "version": 2 + }, + { + "name": "nightVisionMode", + "version": 3 + }, + { + "name": "batCamOsd", + "version": 1 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "pir", + "version": 1 + }, + { + "name": "battery", + "version": 3 + }, + { + "name": "clips", + "version": 1 + }, + { + "name": "batCamRelay", + "version": 1 + }, + { + "name": "batCamP2p", + "version": 1 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "whiteLamp", + "version": 1 + }, + { + "name": "infLamp", + "version": 1 + }, + { + "name": "packageDetection", + "version": 3 + }, + { + "name": "wakeUp", + "version": 1 + }, + { + "name": "ring", + "version": 1 + }, + { + "name": "antiTheft", + "version": 3 + }, + { + "name": "quickResponse", + "version": 2 + }, + { + "name": "doorbellNightVision", + "version": 1 + }, + { + "name": "dataDownload", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "streamGrab", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "batCamPreRelay", + "version": 1 + }, + { + "name": "batCamStatistics", + "version": 1 + }, + { + "name": "batCamNodeRelay", + "version": 1 + }, + { + "name": "batCamRtsp", + "version": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + } + ], + "start_index": 0, + "sum": 1 + }, + "getChildDeviceList": { + "child_device_list": [ + { + "alias": "#MASKED_NAME#", + "anti_theft_status": 0, + "avatar": "camera d210", + "battery_charging": "NO", + "battery_installed": 1, + "battery_percent": 90, + "battery_temperature": 3, + "battery_voltage": 4022, + "cam_uptime": 5378, + "category": "camera", + "dev_name": "Tapo Smart Doorbell", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_model": "D230", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPODOORBELL", + "ext_addr": "0000000000000000", + "firmware_status": "OK", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.20", + "ipaddr": "172.23.30.2", + "led_status": "on", + "low_battery": false, + "mac": "F0:09:0D:00:00:00", + "oem_id": "00000000000000000000000000000000", + "onboarding_timestamp": 1732920657, + "online": true, + "parent_device_id": "0000000000000000000000000000000000000000", + "power": "BATTERY", + "power_save_mode": "off", + "power_save_status": "off", + "region": "EU", + "rssi": -46, + "short_addr": 0, + "status": "configured", + "subg_cam_rssi": 0, + "subg_hub_rssi": 0, + "sw_ver": "1.1.19 Build 20241011 rel.67020", + "system_time": 1735995953, + "updating": false, + "uptime": 3061186 + } + ], + "start_index": 0, + "sum": 1 + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-01-04 14:05:53", + "seconds_from_1970": 1735995953 + } + } + }, + "getConnectionType": { + "link_type": "ethernet" + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "hub_h200", + "bind_status": true, + "child_num": 1, + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "H200 1.0", + "device_model": "H200", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "has_set_location_info": 1, + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "latitude": 0, + "local_ip": "127.0.0.123", + "longitude": 0, + "mac": "F0-09-0D-00-00-00", + "need_sync_sha1_password": 0, + "oem_id": "00000000000000000000000000000000", + "product_name": "Tapo Smart Hub", + "region": "EU", + "status": "configured", + "sw_version": "1.3.6 Build 20240829 rel.71119" + }, + "info": { + "avatar": "hub_h200", + "bind_status": true, + "child_num": 1, + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "H200 1.0", + "device_model": "H200", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "has_set_location_info": 1, + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "latitude": 0, + "local_ip": "127.0.0.123", + "longitude": 0, + "mac": "F0-09-0D-00-00-00", + "need_sync_sha1_password": 0, + "oem_id": "00000000000000000000000000000000", + "product_name": "Tapo Smart Hub", + "region": "EU", + "status": "configured", + "sw_version": "1.3.6 Build 20240829 rel.71119" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": 30, + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLedStatus": { + "led": { + "config": { + ".name": "config", + ".type": "led", + "enabled": "on" + } + } + }, + "getMatterSetupInfo": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "detect_status": "offline", + "disk_name": "1", + "loop_record_status": "1", + "status": "offline" + } + } + ] + } + }, + "getSirenConfig": { + "duration": 30, + "siren_type": "Doorbell Ring 3", + "volume": "10" + }, + "getSirenStatus": { + "status": "off", + "time_left": 0 + }, + "getSirenTypeList": { + "siren_type_list": [ + "Doorbell Ring 1", + "Doorbell Ring 2", + "Doorbell Ring 3", + "Doorbell Ring 4", + "Doorbell Ring 5", + "Doorbell Ring 6", + "Doorbell Ring 7", + "Doorbell Ring 8", + "Doorbell Ring 9", + "Doorbell Ring 10", + "Phone Ring", + "Alarm 1", + "Alarm 2", + "Alarm 3", + "Alarm 4", + "Dripping Tap", + "Alarm 5", + "Connection 1", + "Connection 2" + ] + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+01:00", + "zone_id": "Europe/Amsterdam" + } + } + } +} diff --git a/tests/fixtures/smartcam/child/D230(EU)_1.20_1.1.19.json b/tests/fixtures/smartcam/child/D230(EU)_1.20_1.1.19.json new file mode 100644 index 000000000..83ed36c17 --- /dev/null +++ b/tests/fixtures/smartcam/child/D230(EU)_1.20_1.1.19.json @@ -0,0 +1,525 @@ +{ + "child_info_from_parent": { + "alias": "#MASKED_NAME#", + "anti_theft_status": 0, + "avatar": "camera d210", + "battery_charging": "NO", + "battery_installed": 1, + "battery_percent": 90, + "battery_temperature": 5, + "battery_voltage": 4073, + "cam_uptime": 5420, + "category": "camera", + "dev_name": "Tapo Smart Doorbell", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_model": "D230", + "device_name": "D230 1.20", + "device_type": "SMART.TAPODOORBELL", + "ext_addr": "0000000000000000", + "firmware_status": "OK", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.20", + "ipaddr": "172.23.30.2", + "led_status": "on", + "low_battery": false, + "mac": "F0:09:0D:00:00:00", + "oem_id": "00000000000000000000000000000000", + "onboarding_timestamp": 1732920657, + "online": true, + "parent_device_id": "0000000000000000000000000000000000000000", + "power": "BATTERY", + "power_save_mode": "off", + "power_save_status": "off", + "region": "EU", + "rssi": -43, + "short_addr": 0, + "status": "configured", + "subg_cam_rssi": 0, + "subg_hub_rssi": 0, + "sw_ver": "1.1.19 Build 20241011 rel.67020", + "system_time": 1735996806, + "updating": false, + "uptime": 3062029 + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 2 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "batCamSystem", + "version": 1 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 3 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "firmware", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 2 + }, + { + "name": "dayNightMode", + "version": 2 + }, + { + "name": "nightVisionMode", + "version": 3 + }, + { + "name": "batCamOsd", + "version": 1 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "pir", + "version": 1 + }, + { + "name": "battery", + "version": 3 + }, + { + "name": "clips", + "version": 1 + }, + { + "name": "batCamRelay", + "version": 1 + }, + { + "name": "batCamP2p", + "version": 1 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "whiteLamp", + "version": 1 + }, + { + "name": "infLamp", + "version": 1 + }, + { + "name": "packageDetection", + "version": 3 + }, + { + "name": "wakeUp", + "version": 1 + }, + { + "name": "ring", + "version": 1 + }, + { + "name": "antiTheft", + "version": 3 + }, + { + "name": "quickResponse", + "version": 2 + }, + { + "name": "doorbellNightVision", + "version": 1 + }, + { + "name": "dataDownload", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "streamGrab", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "batCamPreRelay", + "version": 1 + }, + { + "name": "batCamStatistics", + "version": 1 + }, + { + "name": "batCamNodeRelay", + "version": 1 + }, + { + "name": "batCamRtsp", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "channels": "1", + "encode_type": "G711ulaw", + "sampling_rate": "8", + "volume": "58" + }, + "microphone_algo": { + "aec": "on", + "hs": "off", + "ns": "off", + "sys_aec": "on" + }, + "record_audio": { + "enabled": "on" + }, + "speaker": { + "volume": "80" + }, + "speaker_algo": { + "hs": "off", + "ns": "off" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-01-04 14:20:10", + "seconds_from_1970": 1735996810 + } + } + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "30", + "enabled": "on", + "sensitivity": "low" + }, + "region_info": [] + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "a_type": 3, + "anti_theft_status": 0, + "avatar": "camera d210", + "battery_charging": "NO", + "battery_overheated": false, + "battery_percent": 90, + "c_opt": [ + 0, + 1 + ], + "camera_switch": "on", + "dev_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_alias": "#MASKED_NAME#", + "device_model": "D230", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPODOORBELL", + "firmware_status": "OK", + "hw_version": "1.20", + "last_activity_timestamp": 1735996775, + "led_status": "on", + "low_battery": false, + "mac": "F0-09-0D-00-00-00", + "oem_id": "00000000000000000000000000000000", + "online": true, + "parent_device_id": "0000000000000000000000000000000000000000", + "parent_link_type": "ethernet", + "power": "BATTERY", + "power_save_mode": "off", + "resolution": "2560*1920", + "rssi": -43, + "status": "configured", + "sw_version": "1.1.19 Build 20241011 rel.67020", + "system_time": 1735996808, + "updating": false + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1735996775", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "switch": { + "ldc": "off" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "light_freq_mode": "50" + } + } + }, + "getLightTypeList": { + "light_type_list": [ + "flicker" + ] + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision", + "wtl_night_vision", + "dbl_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp", + "white_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "night_vision_mode": "dbl_night_vision" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "flip_type": "off" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "detect_status": "offline", + "disk_name": "1", + "loop_record_status": "1", + "status": "offline" + } + } + ] + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+01:00", + "timing_mode": "ntp", + "zone_id": "Europe/Amsterdam" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "vbr" + ], + "bitrates": [ + "1457" + ], + "change_fps_support": "0", + "encode_types": [ + "H264" + ], + "frame_rates": [ + 65551 + ], + "minor_stream_support": "1", + "qualities": [ + "5" + ], + "resolutions": [ + "2560*1920" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1943", + "bitrate_type": "vbr", + "encode_type": "H264", + "frame_rate": "65551", + "quality": "5", + "resolution": "2560*1920" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + } +} From be34dbd387e211148fbe3370f734ea979cbf4925 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:20:53 +0000 Subject: [PATCH 802/892] Make uses_http a readonly property of device config (#1449) `uses_http` will no longer be included in `DeviceConfig.to_dict()` --- kasa/device_factory.py | 17 ++++++++++++----- kasa/deviceconfig.py | 11 +++++++---- kasa/discover.py | 6 +++--- .../deviceconfig_camera-aes-https.json | 3 +-- .../serialization/deviceconfig_plug-klap.json | 3 +-- .../serialization/deviceconfig_plug-xor.json | 3 +-- tests/test_discovery.py | 2 -- 7 files changed, 25 insertions(+), 20 deletions(-) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 99654a0c4..3eb6419ab 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -8,7 +8,7 @@ from .device import Device from .device_type import DeviceType -from .deviceconfig import DeviceConfig, DeviceFamily +from .deviceconfig import DeviceConfig, DeviceEncryptionType, DeviceFamily from .exceptions import KasaException, UnsupportedDeviceError from .iot import ( IotBulb, @@ -176,25 +176,32 @@ def get_device_class_from_family( return cls -def get_protocol( - config: DeviceConfig, -) -> BaseProtocol | None: - """Return the protocol from the connection name. +def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol | None: + """Return the protocol from the device config. For cameras and vacuums the device family is a simple mapping to the protocol/transport. For other device types the transport varies based on the discovery information. + + :param config: Device config to derive protocol + :param strict: Require exact match on encrypt type """ ctype = config.connection_type protocol_name = ctype.device_family.value.split(".")[0] if ctype.device_family is DeviceFamily.SmartIpCamera: + if strict and ctype.encryption_type is not DeviceEncryptionType.Aes: + return None return SmartCamProtocol(transport=SslAesTransport(config=config)) if ctype.device_family is DeviceFamily.IotIpCamera: + if strict and ctype.encryption_type is not DeviceEncryptionType.Xor: + return None return IotProtocol(transport=LinkieTransportV2(config=config)) if ctype.device_family is DeviceFamily.SmartTapoRobovac: + if strict and ctype.encryption_type is not DeviceEncryptionType.Aes: + return None return SmartProtocol(transport=SslTransport(config=config)) protocol_transport_key = ( diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index d2fb3e45b..c5d5b1d57 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -20,7 +20,7 @@ {'host': '127.0.0.3', 'timeout': 5, 'credentials': {'username': 'user@example.com', \ 'password': 'great_password'}, 'connection_type'\ : {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'login_version': 2, \ -'https': False}, 'uses_http': True} +'https': False}} >>> later_device = await Device.connect(config=Device.Config.from_dict(config_dict)) >>> print(later_device.alias) # Alias is available as connect() calls update() @@ -148,9 +148,12 @@ class DeviceConfig(_DeviceConfigBaseMixin): DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor ) ) - #: 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 + + @property + def uses_http(self) -> bool: + """True if the device uses http.""" + ctype = self.connection_type + return ctype.encryption_type is not DeviceEncryptionType.Xor or ctype.https #: Set a custom http_client for the device to use. http_client: ClientSession | None = field( diff --git a/kasa/discover.py b/kasa/discover.py index b696c3708..9ed4d4cf7 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -360,7 +360,6 @@ def datagram_received( json_func = Discover._get_discovery_json_legacy device_func = Discover._get_device_instance_legacy elif port == Discover.DISCOVERY_PORT_2: - config.uses_http = True json_func = Discover._get_discovery_json device_func = Discover._get_device_instance else: @@ -634,6 +633,8 @@ async def try_connect_all( Device.Family.SmartTapoPlug, Device.Family.IotSmartPlugSwitch, Device.Family.SmartIpCamera, + Device.Family.SmartTapoRobovac, + Device.Family.IotIpCamera, } candidates: dict[ tuple[type[BaseProtocol], type[BaseTransport], type[Device]], @@ -663,10 +664,9 @@ async def try_connect_all( port_override=port, credentials=credentials, http_client=http_client, - uses_http=encrypt is not Device.EncryptionType.Xor, ) ) - and (protocol := get_protocol(config)) + and (protocol := get_protocol(config, strict=True)) and ( device_class := get_device_class_from_family( device_family.value, https=https, require_exact=True diff --git a/tests/fixtures/serialization/deviceconfig_camera-aes-https.json b/tests/fixtures/serialization/deviceconfig_camera-aes-https.json index 559e834b2..361ec6ecf 100644 --- a/tests/fixtures/serialization/deviceconfig_camera-aes-https.json +++ b/tests/fixtures/serialization/deviceconfig_camera-aes-https.json @@ -5,6 +5,5 @@ "device_family": "SMART.IPCAMERA", "encryption_type": "AES", "https": true - }, - "uses_http": false + } } diff --git a/tests/fixtures/serialization/deviceconfig_plug-klap.json b/tests/fixtures/serialization/deviceconfig_plug-klap.json index ef42bb2f9..fa7a6ba85 100644 --- a/tests/fixtures/serialization/deviceconfig_plug-klap.json +++ b/tests/fixtures/serialization/deviceconfig_plug-klap.json @@ -6,6 +6,5 @@ "encryption_type": "KLAP", "https": false, "login_version": 2 - }, - "uses_http": false + } } diff --git a/tests/fixtures/serialization/deviceconfig_plug-xor.json b/tests/fixtures/serialization/deviceconfig_plug-xor.json index 78cc05a96..5cb0222af 100644 --- a/tests/fixtures/serialization/deviceconfig_plug-xor.json +++ b/tests/fixtures/serialization/deviceconfig_plug-xor.json @@ -5,6 +5,5 @@ "device_family": "IOT.SMARTPLUGSWITCH", "encryption_type": "XOR", "https": false - }, - "uses_http": false + } } diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 59a337d2e..07553e741 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -154,12 +154,10 @@ async def test_discover_single(discovery_mock, custom_port, mocker): discovery_mock.encrypt_type, discovery_mock.login_version, ) - uses_http = discovery_mock.default_port == 80 config = DeviceConfig( host=host, port_override=custom_port, connection_type=ct, - uses_http=uses_http, credentials=Credentials(), ) assert x.config == config From 1be87674bf3a3317517734b60851c8cbb3a5a698 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 14 Jan 2025 15:35:09 +0100 Subject: [PATCH 803/892] Initial support for vacuums (clean module) (#944) Adds support for clean module: - Show current vacuum state - Start cleaning (all rooms) - Return to dock - Pausing & unpausing - Controlling the fan speed --------- Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- README.md | 1 + SUPPORTED.md | 5 + devtools/generate_supported.py | 1 + devtools/helpers/smartrequests.py | 12 + kasa/device_factory.py | 6 +- kasa/discover.py | 10 +- kasa/exceptions.py | 2 + kasa/module.py | 3 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/clean.py | 267 +++++++++++++++ tests/device_fixtures.py | 5 + tests/fakeprotocol_smart.py | 15 +- .../smart/RV20 Max Plus(EU)_1.0_1.0.7.json | 310 ++++++++++++++++++ tests/smart/modules/test_clean.py | 146 +++++++++ tests/test_device_factory.py | 6 +- tests/test_discovery.py | 21 +- 16 files changed, 799 insertions(+), 13 deletions(-) create mode 100644 kasa/smart/modules/clean.py create mode 100644 tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json create mode 100644 tests/smart/modules/test_clean.py diff --git a/README.md b/README.md index a450c606c..32d7c6a0a 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ The following devices have been tested and confirmed as working. If your device - **Cameras**: C100, C210, C225, C325WB, C520WS, C720, D230, TC65, TC70 - **Hubs**: H100, H200 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 +- **Vacuums**: RV20 Max Plus [^1]: Model requires authentication diff --git a/SUPPORTED.md b/SUPPORTED.md index a48c56619..8dc319d2d 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -324,6 +324,11 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.7.0 - Hardware: 1.0 (US) / Firmware: 1.8.0 +### Vacuums + +- **RV20 Max Plus** + - Hardware: 1.0 (EU) / Firmware: 1.0.7 + [^1]: Model requires authentication diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py index f97c01c1d..8aba9b214 100755 --- a/devtools/generate_supported.py +++ b/devtools/generate_supported.py @@ -39,6 +39,7 @@ class SupportedVersion(NamedTuple): DeviceType.Hub: "Hubs", DeviceType.Sensor: "Hub-Connected Devices", DeviceType.Thermostat: "Hub-Connected Devices", + DeviceType.Vacuum: "Vacuums", } diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 6ab53937f..c81d8ee88 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -118,6 +118,16 @@ class DynamicLightEffectParams(SmartRequestParams): enable: bool id: str | None = None + @dataclass + class GetCleanAttrParams(SmartRequestParams): + """CleanAttr params. + + Decides which cleaning settings are requested + """ + + #: type can be global or pose + type: str = "global" + @staticmethod def get_raw_request( method: str, params: SmartRequestParams | None = None @@ -429,6 +439,8 @@ def get_component_requests(component_id, ver_code): "clean": [ SmartRequest.get_raw_request("getCleanRecords"), SmartRequest.get_raw_request("getVacStatus"), + SmartRequest.get_raw_request("getCleanStatus"), + SmartRequest("getCleanAttr", SmartRequest.GetCleanAttrParams()), ], "battery": [SmartRequest.get_raw_request("getBatteryInfo")], "consumables": [SmartRequest.get_raw_request("getConsumablesInfo")], diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 3eb6419ab..b09cf655d 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -159,7 +159,7 @@ def get_device_class_from_family( "SMART.KASAHUB": SmartDevice, "SMART.KASASWITCH": SmartDevice, "SMART.IPCAMERA.HTTPS": SmartCamDevice, - "SMART.TAPOROBOVAC": SmartDevice, + "SMART.TAPOROBOVAC.HTTPS": SmartDevice, "IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTBULB": IotBulb, "IOT.IPCAMERA": IotCamera, @@ -173,6 +173,9 @@ def get_device_class_from_family( _LOGGER.debug("Unknown SMART device with %s, using SmartDevice", device_type) cls = SmartDevice + if cls is not None: + _LOGGER.debug("Using %s for %s", cls.__name__, device_type) + return cls @@ -188,6 +191,7 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol """ ctype = config.connection_type protocol_name = ctype.device_family.value.split(".")[0] + _LOGGER.debug("Finding protocol for %s", ctype.device_family) if ctype.device_family is DeviceFamily.SmartIpCamera: if strict and ctype.encryption_type is not DeviceEncryptionType.Aes: diff --git a/kasa/discover.py b/kasa/discover.py index 9ed4d4cf7..abcd7d5fa 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -676,9 +676,14 @@ async def try_connect_all( for key, val in candidates.items(): try: prot, config = val + _LOGGER.debug("Trying to connect with %s", prot.__class__.__name__) dev = await _connect(config, prot) - except Exception: - _LOGGER.debug("Unable to connect with %s", prot) + except Exception as ex: + _LOGGER.debug( + "Unable to connect with %s: %s", + prot.__class__.__name__, + ex, + ) if on_attempt: ca = tuple.__new__(ConnectAttempt, key) on_attempt(ca, False) @@ -686,6 +691,7 @@ async def try_connect_all( if on_attempt: ca = tuple.__new__(ConnectAttempt, key) on_attempt(ca, True) + _LOGGER.debug("Found working protocol %s", prot.__class__.__name__) return dev finally: await prot.close() diff --git a/kasa/exceptions.py b/kasa/exceptions.py index f23602a5a..1c764ad7a 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -127,6 +127,8 @@ def from_int(value: int) -> SmartErrorCode: DST_ERROR = -2301 DST_SAVE_ERROR = -2302 + VACUUM_BATTERY_LOW = -3001 + SYSTEM_ERROR = -40101 INVALID_ARGUMENTS = -40209 diff --git a/kasa/module.py b/kasa/module.py index 2870b661a..9222e077f 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -161,6 +161,9 @@ class Module(ABC): Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera") LensMask: Final[ModuleName[smartcam.LensMask]] = ModuleName("LensMask") + # Vacuum modules + Clean: Final[ModuleName[smart.Clean]] = ModuleName("Clean") + def __init__(self, device: Device, module: str) -> None: self._device = device self._module = module diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index ae9fb68f3..862422d70 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -7,6 +7,7 @@ from .brightness import Brightness from .childdevice import ChildDevice from .childprotection import ChildProtection +from .clean import Clean from .cloud import Cloud from .color import Color from .colortemperature import ColorTemperature @@ -66,6 +67,7 @@ "TriggerLogs", "FrostProtection", "Thermostat", + "Clean", "SmartLightEffect", "OverheatProtection", "HomeKit", diff --git a/kasa/smart/modules/clean.py b/kasa/smart/modules/clean.py new file mode 100644 index 000000000..6b78d048c --- /dev/null +++ b/kasa/smart/modules/clean.py @@ -0,0 +1,267 @@ +"""Implementation of vacuum clean module.""" + +from __future__ import annotations + +import logging +from enum import IntEnum +from typing import Annotated + +from ...feature import Feature +from ...module import FeatureAttribute +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +class Status(IntEnum): + """Status of vacuum.""" + + Idle = 0 + Cleaning = 1 + Mapping = 2 + GoingHome = 4 + Charging = 5 + Charged = 6 + Paused = 7 + Undocked = 8 + Error = 100 + + UnknownInternal = -1000 + + +class ErrorCode(IntEnum): + """Error codes for vacuum.""" + + Ok = 0 + SideBrushStuck = 2 + MainBrushStuck = 3 + WheelBlocked = 4 + DustBinRemoved = 14 + UnableToMove = 15 + LidarBlocked = 16 + UnableToFindDock = 21 + BatteryLow = 22 + + UnknownInternal = -1000 + + +class FanSpeed(IntEnum): + """Fan speed level.""" + + Quiet = 1 + Standard = 2 + Turbo = 3 + Max = 4 + + +class Clean(SmartModule): + """Implementation of vacuum clean module.""" + + REQUIRED_COMPONENT = "clean" + _error_code = ErrorCode.Ok + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="vacuum_return_home", + name="Return home", + container=self, + attribute_setter="return_home", + category=Feature.Category.Primary, + type=Feature.Action, + ) + ) + self._add_feature( + Feature( + self._device, + id="vacuum_start", + name="Start cleaning", + container=self, + attribute_setter="start", + category=Feature.Category.Primary, + type=Feature.Action, + ) + ) + self._add_feature( + Feature( + self._device, + id="vacuum_pause", + name="Pause", + container=self, + attribute_setter="pause", + category=Feature.Category.Primary, + type=Feature.Action, + ) + ) + self._add_feature( + Feature( + self._device, + id="vacuum_status", + name="Vacuum status", + container=self, + attribute_getter="status", + category=Feature.Category.Primary, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="vacuum_error", + name="Error", + container=self, + attribute_getter="error", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="battery_level", + name="Battery level", + container=self, + attribute_getter="battery", + icon="mdi:battery", + unit_getter=lambda: "%", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + + self._add_feature( + Feature( + self._device, + id="vacuum_fan_speed", + name="Fan speed", + container=self, + attribute_getter="fan_speed_preset", + attribute_setter="set_fan_speed_preset", + icon="mdi:fan", + choices_getter=lambda: list(FanSpeed.__members__), + category=Feature.Category.Primary, + type=Feature.Type.Choice, + ) + ) + + async def _post_update_hook(self) -> None: + """Set error code after update.""" + errors = self._vac_status.get("err_status") + if errors is None or not errors: + self._error_code = ErrorCode.Ok + return + + if len(errors) > 1: + _LOGGER.warning( + "Multiple error codes, using the first one only: %s", errors + ) + + error = errors.pop(0) + try: + self._error_code = ErrorCode(error) + except ValueError: + _LOGGER.warning( + "Unknown error code, please create an issue describing the error: %s", + error, + ) + self._error_code = ErrorCode.UnknownInternal + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return { + "getVacStatus": None, + "getBatteryInfo": None, + "getCleanStatus": None, + "getCleanAttr": {"type": "global"}, + } + + async def start(self) -> dict: + """Start cleaning.""" + # If we are paused, do not restart cleaning + + if self.status is Status.Paused: + return await self.resume() + + return await self.call( + "setSwitchClean", + { + "clean_mode": 0, + "clean_on": True, + "clean_order": True, + "force_clean": False, + }, + ) + + async def pause(self) -> dict: + """Pause cleaning.""" + if self.status is Status.GoingHome: + return await self.set_return_home(False) + + return await self.set_pause(True) + + async def resume(self) -> dict: + """Resume cleaning.""" + return await self.set_pause(False) + + async def set_pause(self, enabled: bool) -> dict: + """Pause or resume cleaning.""" + return await self.call("setRobotPause", {"pause": enabled}) + + async def return_home(self) -> dict: + """Return home.""" + return await self.set_return_home(True) + + async def set_return_home(self, enabled: bool) -> dict: + """Return home / pause returning.""" + return await self.call("setSwitchCharge", {"switch_charge": enabled}) + + @property + def error(self) -> ErrorCode: + """Return error.""" + return self._error_code + + @property + def fan_speed_preset(self) -> Annotated[str, FeatureAttribute()]: + """Return fan speed preset.""" + return FanSpeed(self._settings["suction"]).name + + async def set_fan_speed_preset( + self, speed: str + ) -> Annotated[dict, FeatureAttribute]: + """Set fan speed preset.""" + name_to_value = {x.name: x.value for x in FanSpeed} + if speed not in name_to_value: + raise ValueError("Invalid fan speed %s, available %s", speed, name_to_value) + return await self.call( + "setCleanAttr", {"suction": name_to_value[speed], "type": "global"} + ) + + @property + def battery(self) -> int: + """Return battery level.""" + return self.data["getBatteryInfo"]["battery_percentage"] + + @property + def _vac_status(self) -> dict: + """Return vac status container.""" + return self.data["getVacStatus"] + + @property + def _settings(self) -> dict: + """Return cleaning settings.""" + return self.data["getCleanAttr"] + + @property + def status(self) -> Status: + """Return current status.""" + if self._error_code is not ErrorCode.Ok: + return Status.Error + + status_code = self._vac_status["status"] + try: + return Status(status_code) + except ValueError: + _LOGGER.warning("Got unknown status code: %s (%s)", status_code, self.data) + return Status.UnknownInternal diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 295e66abd..77e31ceb1 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -134,6 +134,8 @@ } THERMOSTATS_SMART = {"KE100"} +VACUUMS_SMART = {"RV20"} + WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"} WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} @@ -151,6 +153,7 @@ .union(SENSORS_SMART) .union(SWITCHES_SMART) .union(THERMOSTATS_SMART) + .union(VACUUMS_SMART) ) ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) @@ -342,6 +345,7 @@ def parametrize( device_type_filter=[DeviceType.Hub], protocol_filter={"SMARTCAM"}, ) +vacuum = parametrize("vacuums", device_type_filter=[DeviceType.Vacuum]) def check_categories(): @@ -360,6 +364,7 @@ def check_categories(): + thermostats_smart.args[1] + camera_smartcam.args[1] + hub_smartcam.args[1] + + vacuum.args[1] ) diffs: set[FixtureInfo] = set(FIXTURE_DATA) - set(categorized_fixtures) if diffs: diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index 7e4774b6f..e05fbf569 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -383,8 +383,8 @@ def _handle_control_child_missing(self, params: dict): result = copy.deepcopy(info[child_method]) retval = {"result": result, "error_code": 0} return retval - elif child_method[:4] == "set_": - target_method = f"get_{child_method[4:]}" + elif child_method[:3] == "set": + target_method = f"get{child_method[3:]}" if target_method not in child_device_calls: raise RuntimeError( f"No {target_method} in child info, calling set before get not supported." @@ -549,7 +549,7 @@ async def _send_request(self, request_dict: dict): return await self._handle_control_child(request_dict["params"]) params = request_dict.get("params", {}) - if method in {"component_nego", "qs_component_nego"} or method[:4] == "get_": + if method in {"component_nego", "qs_component_nego"} or method[:3] == "get": if method in info: result = copy.deepcopy(info[method]) if result and "start_index" in result and "sum" in result: @@ -637,9 +637,14 @@ async def _send_request(self, request_dict: dict): return self._set_on_off_gradually_info(info, params) elif method == "set_child_protection": return self._update_sysinfo_key(info, "child_protection", params["enable"]) - elif method[:4] == "set_": - target_method = f"get_{method[4:]}" + elif method[:3] == "set": + target_method = f"get{method[3:]}" + # Some vacuum commands do not have a getter + if method in ["setRobotPause", "setSwitchClean", "setSwitchCharge"]: + return {"error_code": 0} + info[target_method].update(params) + return {"error_code": 0} async def close(self) -> None: diff --git a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json new file mode 100644 index 000000000..c43c554bf --- /dev/null +++ b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json @@ -0,0 +1,310 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "quick_setup", + "ver_code": 1 + }, + { + "id": "clean", + "ver_code": 3 + }, + { + "id": "battery", + "ver_code": 1 + }, + { + "id": "consumables", + "ver_code": 2 + }, + { + "id": "direction_control", + "ver_code": 1 + }, + { + "id": "button_and_led", + "ver_code": 1 + }, + { + "id": "speaker", + "ver_code": 3 + }, + { + "id": "schedule", + "ver_code": 3 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "map", + "ver_code": 2 + }, + { + "id": "auto_change_map", + "ver_code": -1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "dust_bucket", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "mop", + "ver_code": 1 + }, + { + "id": "do_not_disturb", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "charge_pose_clean", + "ver_code": 1 + }, + { + "id": "continue_breakpoint_sweep", + "ver_code": 1 + }, + { + "id": "goto_point", + "ver_code": 1 + }, + { + "id": "furniture", + "ver_code": 1 + }, + { + "id": "map_cloud_backup", + "ver_code": 1 + }, + { + "id": "dev_log", + "ver_code": 1 + }, + { + "id": "map_lock", + "ver_code": 1 + }, + { + "id": "carpet_area", + "ver_code": 1 + }, + { + "id": "clean_angle", + "ver_code": 1 + }, + { + "id": "clean_percent", + "ver_code": 1 + }, + { + "id": "no_pose_config", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "RV20 Max Plus(EU)", + "device_type": "SMART.TAPOROBOVAC", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "B0-19-21-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 4433, + "is_support_https": true + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } + }, + "getAutoChangeMap": { + "auto_change_map": false + }, + "getAutoDustCollection": { + "auto_dust_collection": 1 + }, + "getBatteryInfo": { + "battery_percentage": 75 + }, + "getCleanAttr": {"suction": 2, "cistern": 2, "clean_number": 1}, + "getCleanStatus": {"getCleanStatus": {"clean_status": 0, "is_working": false, "is_mapping": false, "is_relocating": false}}, + "getCleanRecords": { + "lastest_day_record": [ + 0, + 0, + 0, + 0 + ], + "record_list": [], + "record_list_num": 0, + "total_area": 0, + "total_number": 0, + "total_time": 0 + }, + "getConsumablesInfo": { + "charge_contact_time": 0, + "edge_brush_time": 0, + "filter_time": 0, + "main_brush_lid_time": 0, + "rag_time": 0, + "roll_brush_time": 0, + "sensor_time": 0 + }, + "getCurrentVoiceLanguage": { + "name": "2", + "version": 1 + }, + "getDoNotDisturb": { + "do_not_disturb": true, + "e_min": 480, + "s_min": 1320 + }, + "getMapInfo": { + "auto_change_map": false, + "current_map_id": 0, + "map_list": [], + "map_num": 0, + "version": "LDS" + }, + "getMopState": { + "mop_state": false + }, + "getVacStatus": { + "err_status": [ + 0 + ], + "errorCode_id": [ + 0 + ], + "prompt": [], + "promptCode_id": [], + "status": 5 + }, + "get_device_info": { + "auto_pack_ver": "0.0.1.1771", + "avatar": "", + "board_sn": "000000000000", + "custom_sn": "000000000000", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 240828 Rel.205951", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "linux_ver": "V21.198.1708420747", + "location": "", + "longitude": 0, + "mac": "B0-19-21-00-00-00", + "mcu_ver": "1.1.2563.5", + "model": "RV20 Max Plus", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -59, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "sub_ver": "0.0.1.1771-1.1.34", + "time_diff": 60, + "total_ver": "1.1.34", + "type": "SMART.TAPOROBOVAC" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1736598518 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "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": [ + { + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 1, + "wep_supported": true + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "RV20 Max Plus", + "device_type": "SMART.TAPOROBOVAC" + } + } +} diff --git a/tests/smart/modules/test_clean.py b/tests/smart/modules/test_clean.py new file mode 100644 index 000000000..b9f902c2c --- /dev/null +++ b/tests/smart/modules/test_clean.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import logging + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice +from kasa.smart.modules.clean import ErrorCode, Status + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +clean = parametrize("clean module", component_filter="clean", protocol_filter={"SMART"}) + + +@clean +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("vacuum_status", "status", Status), + ("vacuum_error", "error", ErrorCode), + ("vacuum_fan_speed", "fan_speed_preset", str), + ("battery_level", "battery", int), + ], +) +async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type): + """Test that features are registered and work as expected.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + assert clean is not None + + prop = getattr(clean, prop_name) + assert isinstance(prop, type) + + feat = clean._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@pytest.mark.parametrize( + ("feature", "value", "method", "params"), + [ + pytest.param( + "vacuum_start", + 1, + "setSwitchClean", + { + "clean_mode": 0, + "clean_on": True, + "clean_order": True, + "force_clean": False, + }, + id="vacuum_start", + ), + pytest.param( + "vacuum_pause", 1, "setRobotPause", {"pause": True}, id="vacuum_pause" + ), + pytest.param( + "vacuum_return_home", + 1, + "setSwitchCharge", + {"switch_charge": True}, + id="vacuum_return_home", + ), + pytest.param( + "vacuum_fan_speed", + "Quiet", + "setCleanAttr", + {"suction": 1, "type": "global"}, + id="vacuum_fan_speed", + ), + ], +) +@clean +async def test_actions( + dev: SmartDevice, + mocker: MockerFixture, + feature: str, + value: str | int, + method: str, + params: dict, +): + """Test the clean actions.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + call = mocker.spy(clean, "call") + + await dev.features[feature].set_value(value) + call.assert_called_with(method, params) + + +@pytest.mark.parametrize( + ("err_status", "error"), + [ + pytest.param([], ErrorCode.Ok, id="empty error"), + pytest.param([0], ErrorCode.Ok, id="no error"), + pytest.param([3], ErrorCode.MainBrushStuck, id="known error"), + pytest.param([123], ErrorCode.UnknownInternal, id="unknown error"), + pytest.param([3, 4], ErrorCode.MainBrushStuck, id="multi-error"), + ], +) +@clean +async def test_post_update_hook(dev: SmartDevice, err_status: list, error: ErrorCode): + """Test that post update hook sets error states correctly.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + clean.data["getVacStatus"]["err_status"] = err_status + + await clean._post_update_hook() + + assert clean._error_code is error + + if error is not ErrorCode.Ok: + assert clean.status is Status.Error + + +@clean +async def test_resume(dev: SmartDevice, mocker: MockerFixture): + """Test that start calls resume if the state is paused.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + + call = mocker.spy(clean, "call") + resume = mocker.spy(clean, "resume") + + mocker.patch.object( + type(clean), + "status", + new_callable=mocker.PropertyMock, + return_value=Status.Paused, + ) + await clean.start() + + call.assert_called_with("setRobotPause", {"pause": False}) + resume.assert_awaited() + + +@clean +async def test_unknown_status( + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """Test that unknown status is logged.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + + caplog.set_level(logging.DEBUG) + clean.data["getVacStatus"]["status"] = 123 + + assert clean.status is Status.UnknownInternal + assert "Got unknown status code: 123" in caplog.text diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 66e243246..c21c8fe93 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -117,7 +117,11 @@ async def test_connect_custom_port(discovery_mock, mocker, custom_port): connection_type=ctype, credentials=Credentials("dummy_user", "dummy_password"), ) - default_port = 80 if "result" in discovery_data else 9999 + default_port = ( + DiscoveryResult.from_dict(discovery_data["result"]).mgt_encrypt_schm.http_port + if "result" in discovery_data + else 9999 + ) ctype, _ = _get_connection_type_device_class(discovery_data) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 07553e741..fbbed879f 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -134,7 +134,14 @@ async def test_discover_single(discovery_mock, custom_port, mocker): discovery_mock.ip = host discovery_mock.port_override = custom_port - device_class = Discover._get_device_class(discovery_mock.discovery_data) + disco_data = discovery_mock.discovery_data + device_class = Discover._get_device_class(disco_data) + http_port = ( + DiscoveryResult.from_dict(disco_data["result"]).mgt_encrypt_schm.http_port + if "result" in disco_data + else None + ) + # discovery_mock patches protocol query methods so use spy here. update_mock = mocker.spy(device_class, "update") @@ -143,7 +150,11 @@ async def test_discover_single(discovery_mock, custom_port, mocker): ) assert issubclass(x.__class__, Device) assert x._discovery_info is not None - assert x.port == custom_port or x.port == discovery_mock.default_port + assert ( + x.port == custom_port + or x.port == discovery_mock.default_port + or x.port == http_port + ) # Make sure discovery does not call update() assert update_mock.call_count == 0 if discovery_mock.default_port == 80: @@ -153,6 +164,7 @@ async def test_discover_single(discovery_mock, custom_port, mocker): discovery_mock.device_type, discovery_mock.encrypt_type, discovery_mock.login_version, + discovery_mock.https, ) config = DeviceConfig( host=host, @@ -681,7 +693,7 @@ async def _query(self, *args, **kwargs): and self._transport.__class__ is transport_class ): return discovery_mock.query_data - raise KasaException() + raise KasaException("Unable to execute query") async def _update(self, *args, **kwargs): if ( @@ -689,7 +701,8 @@ async def _update(self, *args, **kwargs): and self.protocol._transport.__class__ is transport_class ): return - raise KasaException() + + raise KasaException("Unable to execute update") mocker.patch("kasa.IotProtocol.query", new=_query) mocker.patch("kasa.SmartProtocol.query", new=_query) From d03f535568ca22e32b51c1e1f9f703d12489130e Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:47:52 +0000 Subject: [PATCH 804/892] Fix discover cli command with host (#1437) --- kasa/cli/common.py | 33 ++++++++++++++++++-- kasa/cli/device.py | 3 ++ kasa/cli/discover.py | 74 ++++++++++++++++++++++++++++++++++---------- kasa/cli/main.py | 17 +++++++--- 4 files changed, 103 insertions(+), 24 deletions(-) diff --git a/kasa/cli/common.py b/kasa/cli/common.py index 5114f7af7..d0ef9dc30 100644 --- a/kasa/cli/common.py +++ b/kasa/cli/common.py @@ -10,7 +10,7 @@ from contextlib import contextmanager from functools import singledispatch, update_wrapper, wraps from gettext import gettext -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, Final, NoReturn import asyncclick as click @@ -57,7 +57,7 @@ def echo(*args, **kwargs) -> None: _echo(*args, **kwargs) -def error(msg: str) -> None: +def error(msg: str) -> NoReturn: """Print an error and exit.""" echo(f"[bold red]{msg}[/bold red]") sys.exit(1) @@ -68,6 +68,16 @@ def json_formatter_cb(result: Any, **kwargs) -> None: if not kwargs.get("json"): return + # Calling the discover command directly always returns a DeviceDict so if host + # was specified just format the device json + if ( + (host := kwargs.get("host")) + and isinstance(result, dict) + and (dev := result.get(host)) + and isinstance(dev, Device) + ): + result = dev + @singledispatch def to_serializable(val): """Regular obj-to-string for json serialization. @@ -85,6 +95,25 @@ def _device_to_serializable(val: Device): print(json_content) +async def invoke_subcommand( + command: click.BaseCommand, + ctx: click.Context, + args: list[str] | None = None, + **extra: Any, +) -> Any: + """Invoke a click subcommand. + + Calling ctx.Invoke() treats the command like a simple callback and doesn't + process any result_callbacks so we use this pattern from the click docs + https://click.palletsprojects.com/en/stable/exceptions/#what-if-i-don-t-want-that. + """ + if args is None: + args = [] + sub_ctx = await command.make_context(command.name, args, parent=ctx, **extra) + async with sub_ctx: + return await command.invoke(sub_ctx) + + def pass_dev_or_child(wrapped_function: Callable) -> Callable: """Pass the device or child to the click command based on the child options.""" child_help = ( diff --git a/kasa/cli/device.py b/kasa/cli/device.py index 0ef8a76f8..a10f485d4 100644 --- a/kasa/cli/device.py +++ b/kasa/cli/device.py @@ -3,6 +3,7 @@ from __future__ import annotations from pprint import pformat as pf +from typing import TYPE_CHECKING import asyncclick as click @@ -82,6 +83,8 @@ async def state(ctx, dev: Device): echo() from .discover import _echo_discovery_info + if TYPE_CHECKING: + assert dev._discovery_info _echo_discovery_info(dev._discovery_info) return dev.internal_state diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index ff201ce67..07500f3ba 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -4,6 +4,7 @@ import asyncio from pprint import pformat as pf +from typing import TYPE_CHECKING, cast import asyncclick as click @@ -17,8 +18,12 @@ from kasa.discover import ( NEW_DISCOVERY_REDACTORS, ConnectAttempt, + DeviceDict, DiscoveredRaw, DiscoveryResult, + OnDiscoveredCallable, + OnDiscoveredRawCallable, + OnUnsupportedCallable, ) from kasa.iot.iotdevice import _extract_sys_info from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS @@ -30,15 +35,33 @@ @click.group(invoke_without_command=True) @click.pass_context -async def discover(ctx): +async def discover(ctx: click.Context): """Discover devices in the network.""" if ctx.invoked_subcommand is None: return await ctx.invoke(detail) +@discover.result_callback() +@click.pass_context +async def _close_protocols(ctx: click.Context, discovered: DeviceDict): + """Close all the device protocols if discover was invoked directly by the user.""" + if _discover_is_root_cmd(ctx): + for dev in discovered.values(): + await dev.disconnect() + return discovered + + +def _discover_is_root_cmd(ctx: click.Context) -> bool: + """Will return true if discover was invoked directly by the user.""" + root_ctx = ctx.find_root() + return ( + root_ctx.invoked_subcommand is None or root_ctx.invoked_subcommand == "discover" + ) + + @discover.command() @click.pass_context -async def detail(ctx): +async def detail(ctx: click.Context) -> DeviceDict: """Discover devices in the network using udp broadcasts.""" unsupported = [] auth_failed = [] @@ -59,10 +82,14 @@ async def print_unsupported(unsupported_exception: UnsupportedDeviceError) -> No from .device import state async def print_discovered(dev: Device) -> None: + if TYPE_CHECKING: + assert ctx.parent async with sem: try: await dev.update() except AuthenticationError: + if TYPE_CHECKING: + assert dev._discovery_info auth_failed.append(dev._discovery_info) echo("== Authentication failed for device ==") _echo_discovery_info(dev._discovery_info) @@ -73,9 +100,11 @@ async def print_discovered(dev: Device) -> None: echo() discovered = await _discover( - ctx, print_discovered=print_discovered, print_unsupported=print_unsupported + ctx, + print_discovered=print_discovered if _discover_is_root_cmd(ctx) else None, + print_unsupported=print_unsupported, ) - if ctx.parent.parent.params["host"]: + if ctx.find_root().params["host"]: return discovered echo(f"Found {len(discovered)} devices") @@ -96,7 +125,7 @@ async def print_discovered(dev: Device) -> None: help="Set flag to redact sensitive data from raw output.", ) @click.pass_context -async def raw(ctx, redact: bool): +async def raw(ctx: click.Context, redact: bool) -> DeviceDict: """Return raw discovery data returned from devices.""" def print_raw(discovered: DiscoveredRaw): @@ -116,7 +145,7 @@ def print_raw(discovered: DiscoveredRaw): @discover.command() @click.pass_context -async def list(ctx): +async def list(ctx: click.Context) -> DeviceDict: """List devices in the network in a table using udp broadcasts.""" sem = asyncio.Semaphore() @@ -147,18 +176,24 @@ async def print_unsupported(unsupported_exception: UnsupportedDeviceError): f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} " f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}" ) - return await _discover( + discovered = await _discover( ctx, print_discovered=print_discovered, print_unsupported=print_unsupported, do_echo=False, ) + return discovered async def _discover( - ctx, *, print_discovered=None, print_unsupported=None, print_raw=None, do_echo=True -): - params = ctx.parent.parent.params + ctx: click.Context, + *, + print_discovered: OnDiscoveredCallable | None = None, + print_unsupported: OnUnsupportedCallable | None = None, + print_raw: OnDiscoveredRawCallable | None = None, + do_echo=True, +) -> DeviceDict: + params = ctx.find_root().params target = params["target"] username = params["username"] password = params["password"] @@ -170,8 +205,9 @@ async def _discover( credentials = Credentials(username, password) if username and password else None if host: + host = cast(str, host) echo(f"Discovering device {host} for {discovery_timeout} seconds") - return await Discover.discover_single( + dev = await Discover.discover_single( host, port=port, credentials=credentials, @@ -180,6 +216,12 @@ async def _discover( on_unsupported=print_unsupported, on_discovered_raw=print_raw, ) + if dev: + if print_discovered: + await print_discovered(dev) + return {host: dev} + else: + return {} if do_echo: echo(f"Discovering devices on {target} for {discovery_timeout} seconds") discovered_devices = await Discover.discover( @@ -193,21 +235,18 @@ async def _discover( on_discovered_raw=print_raw, ) - for device in discovered_devices.values(): - await device.protocol.close() - return discovered_devices @discover.command() @click.pass_context -async def config(ctx): +async def config(ctx: click.Context) -> DeviceDict: """Bypass udp discovery and try to show connection config for a device. Bypasses udp discovery and shows the parameters required to connect directly to the device. """ - params = ctx.parent.parent.params + params = ctx.find_root().params username = params["username"] password = params["password"] timeout = params["timeout"] @@ -239,6 +278,7 @@ def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None: f"--encrypt-type {cparams.encryption_type.value} " f"{'--https' if cparams.https else '--no-https'}" ) + return {host: dev} else: error(f"Unable to connect to {host}") @@ -251,7 +291,7 @@ def _echo_dictionary(discovery_info: dict) -> None: echo(f"\t{key_name_and_spaces}{value}") -def _echo_discovery_info(discovery_info) -> None: +def _echo_discovery_info(discovery_info: dict) -> None: # We don't have discovery info when all connection params are passed manually if discovery_info is None: return diff --git a/kasa/cli/main.py b/kasa/cli/main.py index fbcdf3911..debde60c4 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -22,6 +22,7 @@ CatchAllExceptions, echo, error, + invoke_subcommand, json_formatter_cb, pass_dev_or_child, ) @@ -295,9 +296,10 @@ async def cli( echo("No host name given, trying discovery..") from .discover import discover - return await ctx.invoke(discover) + return await invoke_subcommand(discover, ctx) device_updated = False + device_discovered = False if type is not None and type not in {"smart", "camera"}: from kasa.deviceconfig import DeviceConfig @@ -351,12 +353,14 @@ async def cli( return echo(f"Found hostname by alias: {dev.host}") device_updated = True - else: + else: # host will be set from .discover import discover - dev = await ctx.invoke(discover) - if not dev: + discovered = await invoke_subcommand(discover, ctx) + if not discovered: error(f"Unable to create device for {host}") + dev = discovered[host] + device_discovered = True # Skip update on specific commands, or if device factory, # that performs an update was used for the device. @@ -372,11 +376,14 @@ async def async_wrapped_device(device: Device): ctx.obj = await ctx.with_async_resource(async_wrapped_device(dev)) - if ctx.invoked_subcommand is None: + # discover command has already invoked state + if ctx.invoked_subcommand is None and not device_discovered: from .device import state return await ctx.invoke(state) + return dev + @cli.command() @pass_dev_or_child From 68f50aa763cb7199a31d942c96a7e0d95b3687d4 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 14 Jan 2025 15:11:12 +0000 Subject: [PATCH 805/892] Allow update of camera modules after setting values (#1450) --- kasa/smartcam/modules/alarm.py | 4 ++++ kasa/smartcam/modules/babycrydetection.py | 2 ++ kasa/smartcam/modules/camera.py | 6 ++++++ kasa/smartcam/modules/led.py | 2 ++ kasa/smartcam/modules/lensmask.py | 2 ++ kasa/smartcam/modules/motiondetection.py | 2 ++ kasa/smartcam/modules/persondetection.py | 2 ++ kasa/smartcam/modules/tamperdetection.py | 2 ++ kasa/smartcam/modules/time.py | 2 ++ 9 files changed, 24 insertions(+) diff --git a/kasa/smartcam/modules/alarm.py b/kasa/smartcam/modules/alarm.py index 12d434645..5330f309c 100644 --- a/kasa/smartcam/modules/alarm.py +++ b/kasa/smartcam/modules/alarm.py @@ -3,6 +3,7 @@ from __future__ import annotations from ...feature import Feature +from ...smart.smartmodule import allow_update_after from ..smartcammodule import SmartCamModule DURATION_MIN = 0 @@ -110,6 +111,7 @@ def alarm_sound(self) -> str: """Return current alarm sound.""" return self.data["getSirenConfig"]["siren_type"] + @allow_update_after async def set_alarm_sound(self, sound: str) -> dict: """Set alarm sound. @@ -134,6 +136,7 @@ def alarm_volume(self) -> int: """ return int(self.data["getSirenConfig"]["volume"]) + @allow_update_after async def set_alarm_volume(self, volume: int) -> dict: """Set alarm volume.""" if volume < VOLUME_MIN or volume > VOLUME_MAX: @@ -145,6 +148,7 @@ def alarm_duration(self) -> int: """Return alarm duration.""" return self.data["getSirenConfig"]["duration"] + @allow_update_after async def set_alarm_duration(self, duration: int) -> dict: """Set alarm volume.""" if duration < DURATION_MIN or duration > DURATION_MAX: diff --git a/kasa/smartcam/modules/babycrydetection.py b/kasa/smartcam/modules/babycrydetection.py index ecad1e830..753998854 100644 --- a/kasa/smartcam/modules/babycrydetection.py +++ b/kasa/smartcam/modules/babycrydetection.py @@ -5,6 +5,7 @@ import logging from ...feature import Feature +from ...smart.smartmodule import allow_update_after from ..smartcammodule import SmartCamModule _LOGGER = logging.getLogger(__name__) @@ -39,6 +40,7 @@ def enabled(self) -> bool: """Return the baby cry detection enabled state.""" return self.data["bcd"]["enabled"] == "on" + @allow_update_after async def set_enabled(self, enable: bool) -> dict: """Set the baby cry detection enabled state.""" params = {"enabled": "on" if enable else "off"} diff --git a/kasa/smartcam/modules/camera.py b/kasa/smartcam/modules/camera.py index f1eda0f93..9a339120f 100644 --- a/kasa/smartcam/modules/camera.py +++ b/kasa/smartcam/modules/camera.py @@ -99,6 +99,9 @@ def stream_rtsp_url( :return: rtsp url with escaped credentials or None if no credentials or camera is off. """ + if self._device._is_hub_child: + return None + streams = { StreamResolution.HD: "stream1", StreamResolution.SD: "stream2", @@ -119,6 +122,9 @@ def stream_rtsp_url( def onvif_url(self) -> str | None: """Return the onvif url.""" + if self._device._is_hub_child: + return None + return f"http://{self._device.host}:{ONVIF_PORT}/onvif/device_service" async def _check_supported(self) -> bool: diff --git a/kasa/smartcam/modules/led.py b/kasa/smartcam/modules/led.py index fb62c52dd..5b0912e7e 100644 --- a/kasa/smartcam/modules/led.py +++ b/kasa/smartcam/modules/led.py @@ -3,6 +3,7 @@ from __future__ import annotations from ...interfaces.led import Led as LedInterface +from ...smart.smartmodule import allow_update_after from ..smartcammodule import SmartCamModule @@ -19,6 +20,7 @@ def led(self) -> bool: """Return current led status.""" return self.data["config"]["enabled"] == "on" + @allow_update_after async def set_led(self, enable: bool) -> dict: """Set led. diff --git a/kasa/smartcam/modules/lensmask.py b/kasa/smartcam/modules/lensmask.py index 9257b3060..22ae0ab32 100644 --- a/kasa/smartcam/modules/lensmask.py +++ b/kasa/smartcam/modules/lensmask.py @@ -4,6 +4,7 @@ import logging +from ...smart.smartmodule import allow_update_after from ..smartcammodule import SmartCamModule _LOGGER = logging.getLogger(__name__) @@ -23,6 +24,7 @@ def enabled(self) -> bool: """Return the lens mask state.""" return self.data["lens_mask_info"]["enabled"] == "on" + @allow_update_after async def set_enabled(self, enable: bool) -> dict: """Set the lens mask state.""" params = {"enabled": "on" if enable else "off"} diff --git a/kasa/smartcam/modules/motiondetection.py b/kasa/smartcam/modules/motiondetection.py index a30448f8a..dd3c168e9 100644 --- a/kasa/smartcam/modules/motiondetection.py +++ b/kasa/smartcam/modules/motiondetection.py @@ -5,6 +5,7 @@ import logging from ...feature import Feature +from ...smart.smartmodule import allow_update_after from ..smartcammodule import SmartCamModule _LOGGER = logging.getLogger(__name__) @@ -39,6 +40,7 @@ def enabled(self) -> bool: """Return the motion detection enabled state.""" return self.data["motion_det"]["enabled"] == "on" + @allow_update_after async def set_enabled(self, enable: bool) -> dict: """Set the motion detection enabled state.""" params = {"enabled": "on" if enable else "off"} diff --git a/kasa/smartcam/modules/persondetection.py b/kasa/smartcam/modules/persondetection.py index 5d40ce519..96b31dc42 100644 --- a/kasa/smartcam/modules/persondetection.py +++ b/kasa/smartcam/modules/persondetection.py @@ -5,6 +5,7 @@ import logging from ...feature import Feature +from ...smart.smartmodule import allow_update_after from ..smartcammodule import SmartCamModule _LOGGER = logging.getLogger(__name__) @@ -39,6 +40,7 @@ def enabled(self) -> bool: """Return the person detection enabled state.""" return self.data["detection"]["enabled"] == "on" + @allow_update_after async def set_enabled(self, enable: bool) -> dict: """Set the person detection enabled state.""" params = {"enabled": "on" if enable else "off"} diff --git a/kasa/smartcam/modules/tamperdetection.py b/kasa/smartcam/modules/tamperdetection.py index 4705d36c1..f572ded6f 100644 --- a/kasa/smartcam/modules/tamperdetection.py +++ b/kasa/smartcam/modules/tamperdetection.py @@ -5,6 +5,7 @@ import logging from ...feature import Feature +from ...smart.smartmodule import allow_update_after from ..smartcammodule import SmartCamModule _LOGGER = logging.getLogger(__name__) @@ -39,6 +40,7 @@ def enabled(self) -> bool: """Return the tamper detection enabled state.""" return self.data["tamper_det"]["enabled"] == "on" + @allow_update_after async def set_enabled(self, enable: bool) -> dict: """Set the tamper detection enabled state.""" params = {"enabled": "on" if enable else "off"} diff --git a/kasa/smartcam/modules/time.py b/kasa/smartcam/modules/time.py index 4e5cb8df2..54ee30e53 100644 --- a/kasa/smartcam/modules/time.py +++ b/kasa/smartcam/modules/time.py @@ -9,6 +9,7 @@ from ...cachedzoneinfo import CachedZoneInfo from ...feature import Feature from ...interfaces import Time as TimeInterface +from ...smart.smartmodule import allow_update_after from ..smartcammodule import SmartCamModule @@ -73,6 +74,7 @@ def time(self) -> datetime: """Return device's current datetime.""" return self._time + @allow_update_after async def set_time(self, dt: datetime) -> dict: """Set device time.""" if not dt.tzinfo: From 3c98efb01534b129be09726100ceb8dda9a58cbb Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 14 Jan 2025 17:30:18 +0100 Subject: [PATCH 806/892] Implement vacuum dustbin module (dust_bucket) (#1423) Initial implementation for dustbin auto-emptying. New features: - `dustbin_empty` action to empty the dustbin immediately - `dustbin_autocollection_enabled` to toggle the auto collection - `dustbin_mode` to choose how often the auto collection is performed --- devtools/helpers/smartrequests.py | 5 +- kasa/module.py | 1 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/dustbin.py | 117 ++++++++++++++++++ pyproject.toml | 2 +- tests/fakeprotocol_smart.py | 7 +- .../smart/RV20 Max Plus(EU)_1.0_1.0.7.json | 4 + tests/smart/modules/test_dustbin.py | 92 ++++++++++++++ 8 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 kasa/smart/modules/dustbin.py create mode 100644 tests/smart/modules/test_dustbin.py diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index c81d8ee88..3cc82aa8c 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -455,7 +455,10 @@ def get_component_requests(component_id, ver_code): SmartRequest.get_raw_request("getMapData"), ], "auto_change_map": [SmartRequest.get_raw_request("getAutoChangeMap")], - "dust_bucket": [SmartRequest.get_raw_request("getAutoDustCollection")], + "dust_bucket": [ + SmartRequest.get_raw_request("getAutoDustCollection"), + SmartRequest.get_raw_request("getDustCollectionInfo"), + ], "mop": [SmartRequest.get_raw_request("getMopState")], "do_not_disturb": [SmartRequest.get_raw_request("getDoNotDisturb")], "charge_pose_clean": [], diff --git a/kasa/module.py b/kasa/module.py index 9222e077f..cda8188b7 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -163,6 +163,7 @@ class Module(ABC): # Vacuum modules Clean: Final[ModuleName[smart.Clean]] = ModuleName("Clean") + Dustbin: Final[ModuleName[smart.Dustbin]] = ModuleName("Dustbin") def __init__(self, device: Device, module: str) -> None: self._device = device diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 862422d70..2945ffdd2 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -13,6 +13,7 @@ from .colortemperature import ColorTemperature from .contactsensor import ContactSensor from .devicemodule import DeviceModule +from .dustbin import Dustbin from .energy import Energy from .fan import Fan from .firmware import Firmware @@ -72,4 +73,5 @@ "OverheatProtection", "HomeKit", "Matter", + "Dustbin", ] diff --git a/kasa/smart/modules/dustbin.py b/kasa/smart/modules/dustbin.py new file mode 100644 index 000000000..08c35d5e1 --- /dev/null +++ b/kasa/smart/modules/dustbin.py @@ -0,0 +1,117 @@ +"""Implementation of vacuum dustbin.""" + +from __future__ import annotations + +import logging +from enum import IntEnum + +from ...feature import Feature +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +class Mode(IntEnum): + """Dust collection modes.""" + + Smart = 0 + Light = 1 + Balanced = 2 + Max = 3 + + +class Dustbin(SmartModule): + """Implementation of vacuum dustbin.""" + + REQUIRED_COMPONENT = "dust_bucket" + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="dustbin_empty", + name="Empty dustbin", + container=self, + attribute_setter="start_emptying", + category=Feature.Category.Primary, + type=Feature.Action, + ) + ) + + self._add_feature( + Feature( + self._device, + id="dustbin_autocollection_enabled", + name="Automatic emptying enabled", + container=self, + attribute_getter="auto_collection", + attribute_setter="set_auto_collection", + category=Feature.Category.Config, + type=Feature.Switch, + ) + ) + + self._add_feature( + Feature( + self._device, + id="dustbin_mode", + name="Automatic emptying mode", + container=self, + attribute_getter="mode", + attribute_setter="set_mode", + icon="mdi:fan", + choices_getter=lambda: list(Mode.__members__), + category=Feature.Category.Config, + type=Feature.Type.Choice, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return { + "getAutoDustCollection": {}, + "getDustCollectionInfo": {}, + } + + async def start_emptying(self) -> dict: + """Start emptying the bin.""" + return await self.call( + "setSwitchDustCollection", + { + "switch_dust_collection": True, + }, + ) + + @property + def _settings(self) -> dict: + """Return auto-empty settings.""" + return self.data["getDustCollectionInfo"] + + @property + def mode(self) -> str: + """Return auto-emptying mode.""" + return Mode(self._settings["dust_collection_mode"]).name + + async def set_mode(self, mode: str) -> dict: + """Set auto-emptying mode.""" + name_to_value = {x.name: x.value for x in Mode} + if mode not in name_to_value: + raise ValueError( + "Invalid auto/emptying mode speed %s, available %s", mode, name_to_value + ) + + settings = self._settings.copy() + settings["dust_collection_mode"] = name_to_value[mode] + return await self.call("setDustCollectionInfo", settings) + + @property + def auto_collection(self) -> dict: + """Return auto-emptying config.""" + return self._settings["auto_dust_collection"] + + async def set_auto_collection(self, on: bool) -> dict: + """Toggle auto-emptying.""" + settings = self._settings.copy() + settings["auto_dust_collection"] = on + return await self.call("setDustCollectionInfo", settings) diff --git a/pyproject.toml b/pyproject.toml index e0905917c..eed43e2bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,7 +112,7 @@ markers = [ ] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" -timeout = 10 +#timeout = 10 # dist=loadgroup enables grouping of tests into single worker. # required as caplog doesn't play nicely with multiple workers. addopts = "--disable-socket --allow-unix-socket --dist=loadgroup" diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index e05fbf569..bebe68e75 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -640,7 +640,12 @@ async def _send_request(self, request_dict: dict): elif method[:3] == "set": target_method = f"get{method[3:]}" # Some vacuum commands do not have a getter - if method in ["setRobotPause", "setSwitchClean", "setSwitchCharge"]: + if method in [ + "setRobotPause", + "setSwitchClean", + "setSwitchCharge", + "setSwitchDustCollection", + ]: return {"error_code": 0} info[target_method].update(params) diff --git a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json index c43c554bf..cc3b3331a 100644 --- a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json +++ b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json @@ -202,6 +202,10 @@ "getMopState": { "mop_state": false }, + "getDustCollectionInfo": { + "auto_dust_collection": true, + "dust_collection_mode": 0 + }, "getVacStatus": { "err_status": [ 0 diff --git a/tests/smart/modules/test_dustbin.py b/tests/smart/modules/test_dustbin.py new file mode 100644 index 000000000..d30d2459b --- /dev/null +++ b/tests/smart/modules/test_dustbin.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice +from kasa.smart.modules.dustbin import Mode + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +dustbin = parametrize( + "has dustbin", component_filter="dust_bucket", protocol_filter={"SMART"} +) + + +@dustbin +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("dustbin_autocollection_enabled", "auto_collection", bool), + ("dustbin_mode", "mode", str), + ], +) +async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type): + """Test that features are registered and work as expected.""" + dustbin = next(get_parent_and_child_modules(dev, Module.Dustbin)) + assert dustbin is not None + + prop = getattr(dustbin, prop_name) + assert isinstance(prop, type) + + feat = dustbin._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@dustbin +async def test_dustbin_mode(dev: SmartDevice, mocker: MockerFixture): + """Test dust mode.""" + dustbin = next(get_parent_and_child_modules(dev, Module.Dustbin)) + call = mocker.spy(dustbin, "call") + + mode_feature = dustbin._device.features["dustbin_mode"] + assert dustbin.mode == mode_feature.value + + new_mode = Mode.Max + await dustbin.set_mode(new_mode.name) + + params = dustbin._settings.copy() + params["dust_collection_mode"] = new_mode.value + + call.assert_called_with("setDustCollectionInfo", params) + + await dev.update() + + assert dustbin.mode == new_mode.name + + with pytest.raises(ValueError, match="Invalid auto/emptying mode speed"): + await dustbin.set_mode("invalid") + + +@dustbin +async def test_autocollection(dev: SmartDevice, mocker: MockerFixture): + """Test autocollection switch.""" + dustbin = next(get_parent_and_child_modules(dev, Module.Dustbin)) + call = mocker.spy(dustbin, "call") + + auto_collection = dustbin._device.features["dustbin_autocollection_enabled"] + assert dustbin.auto_collection == auto_collection.value + + await auto_collection.set_value(True) + + params = dustbin._settings.copy() + params["auto_dust_collection"] = True + + call.assert_called_with("setDustCollectionInfo", params) + + await dev.update() + + assert dustbin.auto_collection is True + + +@dustbin +async def test_empty_dustbin(dev: SmartDevice, mocker: MockerFixture): + """Test the empty dustbin feature.""" + dustbin = next(get_parent_and_child_modules(dev, Module.Dustbin)) + call = mocker.spy(dustbin, "call") + + await dustbin.start_emptying() + + call.assert_called_with("setSwitchDustCollection", {"switch_dust_collection": True}) From 25425160099241c59fd214006f2e84debdd2d51e Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 14 Jan 2025 17:48:34 +0100 Subject: [PATCH 807/892] Add vacuum speaker controls (#1332) Implements `speaker` and adds the following features: * `volume` to control the speaker volume * `locate` to play "I'm here sound" --- devtools/helpers/smartrequests.py | 1 + kasa/module.py | 1 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/speaker.py | 67 +++++++++++++++++ tests/fakeprotocol_smart.py | 3 + .../smart/RV20 Max Plus(EU)_1.0_1.0.7.json | 3 + tests/smart/modules/test_speaker.py | 71 +++++++++++++++++++ 7 files changed, 148 insertions(+) create mode 100644 kasa/smart/modules/speaker.py create mode 100644 tests/smart/modules/test_speaker.py diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 3cc82aa8c..ffaa73fb6 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -449,6 +449,7 @@ def get_component_requests(component_id, ver_code): "speaker": [ SmartRequest.get_raw_request("getSupportVoiceLanguage"), SmartRequest.get_raw_request("getCurrentVoiceLanguage"), + SmartRequest.get_raw_request("getVolume"), ], "map": [ SmartRequest.get_raw_request("getMapInfo"), diff --git a/kasa/module.py b/kasa/module.py index cda8188b7..0c5a0489f 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -164,6 +164,7 @@ class Module(ABC): # Vacuum modules Clean: Final[ModuleName[smart.Clean]] = ModuleName("Clean") Dustbin: Final[ModuleName[smart.Dustbin]] = ModuleName("Dustbin") + Speaker: Final[ModuleName[smart.Speaker]] = ModuleName("Speaker") def __init__(self, device: Device, module: str) -> None: self._device = device diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 2945ffdd2..deb09f4f4 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -30,6 +30,7 @@ from .motionsensor import MotionSensor from .overheatprotection import OverheatProtection from .reportmode import ReportMode +from .speaker import Speaker from .temperaturecontrol import TemperatureControl from .temperaturesensor import TemperatureSensor from .thermostat import Thermostat @@ -71,6 +72,7 @@ "Clean", "SmartLightEffect", "OverheatProtection", + "Speaker", "HomeKit", "Matter", "Dustbin", diff --git a/kasa/smart/modules/speaker.py b/kasa/smart/modules/speaker.py new file mode 100644 index 000000000..e36758b40 --- /dev/null +++ b/kasa/smart/modules/speaker.py @@ -0,0 +1,67 @@ +"""Implementation of vacuum speaker.""" + +from __future__ import annotations + +import logging +from typing import Annotated + +from ...feature import Feature +from ...module import FeatureAttribute +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +class Speaker(SmartModule): + """Implementation of vacuum speaker.""" + + REQUIRED_COMPONENT = "speaker" + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="locate", + name="Locate device", + container=self, + attribute_setter="locate", + category=Feature.Category.Primary, + type=Feature.Action, + ) + ) + self._add_feature( + Feature( + self._device, + id="volume", + name="Volume", + container=self, + attribute_getter="volume", + attribute_setter="set_volume", + range_getter=lambda: (0, 100), + category=Feature.Category.Config, + type=Feature.Type.Number, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return { + "getVolume": None, + } + + @property + def volume(self) -> Annotated[str, FeatureAttribute()]: + """Return volume.""" + return self.data["volume"] + + async def set_volume(self, volume: int) -> Annotated[dict, FeatureAttribute()]: + """Set volume.""" + if volume < 0 or volume > 100: + raise ValueError("Volume must be between 0 and 100") + + return await self.call("setVolume", {"volume": volume}) + + async def locate(self) -> dict: + """Play sound to locate the device.""" + return await self.call("playSelectAudio", {"audio_type": "seek_me"}) diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index bebe68e75..393b5f318 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -637,6 +637,9 @@ async def _send_request(self, request_dict: dict): return self._set_on_off_gradually_info(info, params) elif method == "set_child_protection": return self._update_sysinfo_key(info, "child_protection", params["enable"]) + # Vacuum special actions + elif method in ["playSelectAudio"]: + return {"error_code": 0} elif method[:3] == "set": target_method = f"get{method[3:]}" # Some vacuum commands do not have a getter diff --git a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json index cc3b3331a..c321488c1 100644 --- a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json +++ b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json @@ -187,6 +187,9 @@ "name": "2", "version": 1 }, + "getVolume": { + "volume": 84 + }, "getDoNotDisturb": { "do_not_disturb": true, "e_min": 480, diff --git a/tests/smart/modules/test_speaker.py b/tests/smart/modules/test_speaker.py new file mode 100644 index 000000000..e11741da0 --- /dev/null +++ b/tests/smart/modules/test_speaker.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +speaker = parametrize( + "has speaker", component_filter="speaker", protocol_filter={"SMART"} +) + + +@speaker +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("volume", "volume", int), + ], +) +async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type): + """Test that features are registered and work as expected.""" + speaker = next(get_parent_and_child_modules(dev, Module.Speaker)) + assert speaker is not None + + prop = getattr(speaker, prop_name) + assert isinstance(prop, type) + + feat = speaker._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@speaker +async def test_set_volume(dev: SmartDevice, mocker: MockerFixture): + """Test speaker settings.""" + speaker = next(get_parent_and_child_modules(dev, Module.Speaker)) + assert speaker is not None + + call = mocker.spy(speaker, "call") + + volume = speaker._device.features["volume"] + assert speaker.volume == volume.value + + new_volume = 15 + await speaker.set_volume(new_volume) + + call.assert_called_with("setVolume", {"volume": new_volume}) + + await dev.update() + + assert speaker.volume == new_volume + + with pytest.raises(ValueError, match="Volume must be between 0 and 100"): + await speaker.set_volume(-10) + + with pytest.raises(ValueError, match="Volume must be between 0 and 100"): + await speaker.set_volume(110) + + +@speaker +async def test_locate(dev: SmartDevice, mocker: MockerFixture): + """Test the locate method.""" + speaker = next(get_parent_and_child_modules(dev, Module.Speaker)) + call = mocker.spy(speaker, "call") + + await speaker.locate() + + call.assert_called_with("playSelectAudio", {"audio_type": "seek_me"}) From 4e7e18cef1326cab3594790718a7f34db9955c67 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 14 Jan 2025 21:57:35 +0000 Subject: [PATCH 808/892] Add battery module to smartcam devices (#1452) --- kasa/smartcam/modules/__init__.py | 2 + kasa/smartcam/modules/battery.py | 113 +++++++++++++++++++++++++ kasa/smartcam/smartcamchild.py | 18 ++-- kasa/smartcam/smartcamdevice.py | 19 ++--- kasa/smartcam/smartcammodule.py | 2 + tests/device_fixtures.py | 9 ++ tests/fakeprotocol_smart.py | 11 ++- tests/fakeprotocol_smartcam.py | 16 +++- tests/smart/test_smartdevice.py | 2 +- tests/smartcam/modules/test_battery.py | 33 ++++++++ 10 files changed, 200 insertions(+), 25 deletions(-) create mode 100644 kasa/smartcam/modules/battery.py create mode 100644 tests/smartcam/modules/test_battery.py diff --git a/kasa/smartcam/modules/__init__.py b/kasa/smartcam/modules/__init__.py index 3ea4bb6a0..06130a374 100644 --- a/kasa/smartcam/modules/__init__.py +++ b/kasa/smartcam/modules/__init__.py @@ -2,6 +2,7 @@ from .alarm import Alarm from .babycrydetection import BabyCryDetection +from .battery import Battery from .camera import Camera from .childdevice import ChildDevice from .device import DeviceModule @@ -18,6 +19,7 @@ __all__ = [ "Alarm", "BabyCryDetection", + "Battery", "Camera", "ChildDevice", "DeviceModule", diff --git a/kasa/smartcam/modules/battery.py b/kasa/smartcam/modules/battery.py new file mode 100644 index 000000000..d6bd97f3f --- /dev/null +++ b/kasa/smartcam/modules/battery.py @@ -0,0 +1,113 @@ +"""Implementation of baby cry detection module.""" + +from __future__ import annotations + +import logging + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class Battery(SmartCamModule): + """Implementation of a battery module.""" + + REQUIRED_COMPONENT = "battery" + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + "battery_low", + "Battery low", + container=self, + attribute_getter="battery_low", + icon="mdi:alert", + type=Feature.Type.BinarySensor, + category=Feature.Category.Debug, + ) + ) + + self._add_feature( + Feature( + self._device, + "battery_level", + "Battery level", + container=self, + attribute_getter="battery_percent", + icon="mdi:battery", + unit_getter=lambda: "%", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + + self._add_feature( + Feature( + self._device, + "battery_temperature", + "Battery temperature", + container=self, + attribute_getter="battery_temperature", + icon="mdi:battery", + unit_getter=lambda: "celsius", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + "battery_voltage", + "Battery voltage", + container=self, + attribute_getter="battery_voltage", + icon="mdi:battery", + unit_getter=lambda: "V", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + "battery_charging", + "Battery charging", + container=self, + attribute_getter="battery_charging", + icon="mdi:alert", + type=Feature.Type.BinarySensor, + category=Feature.Category.Debug, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def battery_percent(self) -> int: + """Return battery level.""" + return self._device.sys_info["battery_percent"] + + @property + def battery_low(self) -> bool: + """Return True if battery is low.""" + return self._device.sys_info["low_battery"] + + @property + def battery_temperature(self) -> bool: + """Return battery voltage in C.""" + return self._device.sys_info["battery_temperature"] + + @property + def battery_voltage(self) -> bool: + """Return battery voltage in V.""" + return self._device.sys_info["battery_voltage"] / 1_000 + + @property + def battery_charging(self) -> bool: + """Return True if battery is charging.""" + return self._device.sys_info["battery_voltage"] != "NO" diff --git a/kasa/smartcam/smartcamchild.py b/kasa/smartcam/smartcamchild.py index f02f21c97..d1b263b49 100644 --- a/kasa/smartcam/smartcamchild.py +++ b/kasa/smartcam/smartcamchild.py @@ -63,18 +63,14 @@ def device_info(self) -> DeviceInfo: None, ) - def _map_child_info_from_parent(self, device_info: dict) -> dict: - return { - "model": device_info["device_model"], - "device_type": device_info["device_type"], - "alias": device_info["alias"], - "fw_ver": device_info["sw_ver"], - "hw_ver": device_info["hw_ver"], - "mac": device_info["mac"], - "hwId": device_info.get("hw_id"), - "oem_id": device_info["oem_id"], - "device_id": device_info["device_id"], + @staticmethod + def _map_child_info_from_parent(device_info: dict) -> dict: + mappings = { + "device_model": "model", + "sw_ver": "fw_ver", + "hw_id": "hwId", } + return {mappings.get(k, k): v for k, v in device_info.items()} def _update_internal_state(self, info: dict[str, Any]) -> None: """Update the internal info state. diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index 066296788..b8d2cf800 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -238,18 +238,17 @@ async def _negotiate(self) -> None: await self._initialize_children() def _map_info(self, device_info: dict) -> dict: + """Map the basic keys to the keys used by SmartDevices.""" basic_info = device_info["basic_info"] - return { - "model": basic_info["device_model"], - "device_type": basic_info["device_type"], - "alias": basic_info["device_alias"], - "fw_ver": basic_info["sw_version"], - "hw_ver": basic_info["hw_version"], - "mac": basic_info["mac"], - "hwId": basic_info.get("hw_id"), - "oem_id": basic_info["oem_id"], - "device_id": basic_info["dev_id"], + mappings = { + "device_model": "model", + "device_alias": "alias", + "sw_version": "fw_ver", + "hw_version": "hw_ver", + "hw_id": "hwId", + "dev_id": "device_id", } + return {mappings.get(k, k): v for k, v in basic_info.items()} @property def is_on(self) -> bool: diff --git a/kasa/smartcam/smartcammodule.py b/kasa/smartcam/smartcammodule.py index 85addd65c..7b85680e5 100644 --- a/kasa/smartcam/smartcammodule.py +++ b/kasa/smartcam/smartcammodule.py @@ -33,6 +33,8 @@ class SmartCamModule(SmartModule): "BabyCryDetection" ) + SmartCamBattery: Final[ModuleName[modules.Battery]] = ModuleName("Battery") + SmartCamDeviceModule: Final[ModuleName[modules.DeviceModule]] = ModuleName( "devicemodule" ) diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 77e31ceb1..f28b17e3d 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -435,6 +435,15 @@ async def get_device_for_fixture( d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)( host="127.0.0.123" ) + + # smart child devices sometimes check _is_hub_child which needs a parent + # of DeviceType.Hub + class DummyParent: + device_type = DeviceType.Hub + + if fixture_data.protocol in {"SMARTCAM.CHILD"}: + d._parent = DummyParent() + if fixture_data.protocol in {"SMART", "SMART.CHILD"}: d.protocol = FakeSmartProtocol( fixture_data.data, fixture_data.name, verbatim=verbatim diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index 393b5f318..27b994380 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -262,7 +262,10 @@ def try_get_child_fixture_info(child_dev_info, protocol): child_fixture["get_device_info"]["device_id"] = device_id found_child_fixture_infos.append(child_fixture["get_device_info"]) child_protocols[device_id] = FakeSmartProtocol( - child_fixture, fixture_info_tuple.name, is_child=True + child_fixture, + fixture_info_tuple.name, + is_child=True, + verbatim=verbatim, ) # Look for fixture inline elif (child_fixtures := parent_fixture_info.get("child_devices")) and ( @@ -273,6 +276,7 @@ def try_get_child_fixture_info(child_dev_info, protocol): child_fixture, f"{parent_fixture_name}-{device_id}", is_child=True, + verbatim=verbatim, ) else: pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] @@ -299,7 +303,10 @@ def try_get_child_fixture_info(child_dev_info, protocol): # list for smartcam children in order for updates to work. found_child_fixture_infos.append(child_fixture[CHILD_INFO_FROM_PARENT]) child_protocols[device_id] = FakeSmartCamProtocol( - child_fixture, fixture_info_tuple.name, is_child=True + child_fixture, + fixture_info_tuple.name, + is_child=True, + verbatim=verbatim, ) else: warn( diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index 431a761d5..53a9ec17d 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -6,7 +6,7 @@ from kasa import Credentials, DeviceConfig, SmartProtocol from kasa.protocols.smartcamprotocol import SmartCamProtocol -from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT +from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT, SmartCamChild from kasa.transports.basetransport import BaseTransport from .fakeprotocol_smart import FakeSmartTransport @@ -243,6 +243,20 @@ async def _send_request(self, request_dict: dict): else: return {"error_code": -1} + # smartcam child devices do not make requests for getDeviceInfo as they + # get updated from the parent's query. If this is being called from a + # child it must be because the fixture has been created directly on the + # child device with a dummy parent. In this case return the child info + # from parent that's inside the fixture. + if ( + not self.verbatim + and method == "getDeviceInfo" + and (cifp := info.get(CHILD_INFO_FROM_PARENT)) + ): + mapped = SmartCamChild._map_child_info_from_parent(cifp) + result = {"device_info": {"basic_info": mapped}} + return {"result": result, "error_code": 0} + if method in info: params = request_dict.get("params") result = copy.deepcopy(info[method]) diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index 1cae0abc4..0cc38a71b 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -269,7 +269,7 @@ async def test_hub_children_update_delays( for modname, module in child._modules.items(): if ( not (q := module.query()) - and modname not in {"DeviceModule", "Light"} + and modname not in {"DeviceModule", "Light", "Battery", "Camera"} and not module.SYSINFO_LOOKUP_KEYS ): q = {f"get_dummy_{modname}": {}} diff --git a/tests/smartcam/modules/test_battery.py b/tests/smartcam/modules/test_battery.py new file mode 100644 index 000000000..12cab14bd --- /dev/null +++ b/tests/smartcam/modules/test_battery.py @@ -0,0 +1,33 @@ +"""Tests for smartcam battery module.""" + +from __future__ import annotations + +from kasa import Device +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...device_fixtures import parametrize + +battery_smartcam = parametrize( + "has battery", + component_filter="battery", + protocol_filter={"SMARTCAM", "SMARTCAM.CHILD"}, +) + + +@battery_smartcam +async def test_battery(dev: Device): + """Test device battery.""" + battery = dev.modules.get(SmartCamModule.SmartCamBattery) + assert battery + + feat_ids = { + "battery_level", + "battery_low", + "battery_temperature", + "battery_voltage", + "battery_charging", + } + for feat_id in feat_ids: + feat = dev.features.get(feat_id) + assert feat + assert feat.value is not None From 1355e85f8e63626cfae4f99f5ae8661915c11b19 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 15 Jan 2025 14:20:19 +0100 Subject: [PATCH 809/892] Expose current cleaning information (#1453) Add new sensors to show the current cleaning state: ``` Cleaning area (clean_area): 0 0 Cleaning time (clean_time): 0:00:00 Cleaning progress (clean_progress): 100 % ``` --- devtools/helpers/smartrequests.py | 2 + kasa/smart/modules/clean.py | 80 ++++++++++++++++++- .../smart/RV20 Max Plus(EU)_1.0_1.0.7.json | 2 + 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index ffaa73fb6..695f4a5bf 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -439,6 +439,8 @@ def get_component_requests(component_id, ver_code): "clean": [ SmartRequest.get_raw_request("getCleanRecords"), SmartRequest.get_raw_request("getVacStatus"), + SmartRequest.get_raw_request("getAreaUnit"), + SmartRequest.get_raw_request("getCleanInfo"), SmartRequest.get_raw_request("getCleanStatus"), SmartRequest("getCleanAttr", SmartRequest.GetCleanAttrParams()), ], diff --git a/kasa/smart/modules/clean.py b/kasa/smart/modules/clean.py index 6b78d048c..4d513a3a6 100644 --- a/kasa/smart/modules/clean.py +++ b/kasa/smart/modules/clean.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from datetime import timedelta from enum import IntEnum from typing import Annotated @@ -54,6 +55,17 @@ class FanSpeed(IntEnum): Max = 4 +class AreaUnit(IntEnum): + """Area unit.""" + + #: Square meter + Sqm = 0 + #: Square feet + Sqft = 1 + #: Taiwanese unit: https://en.wikipedia.org/wiki/Taiwanese_units_of_measurement#Area + Ping = 2 + + class Clean(SmartModule): """Implementation of vacuum clean module.""" @@ -145,6 +157,41 @@ def _initialize_features(self) -> None: type=Feature.Type.Choice, ) ) + self._add_feature( + Feature( + self._device, + id="clean_area", + name="Cleaning area", + container=self, + attribute_getter="clean_area", + unit_getter="area_unit", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="clean_time", + name="Cleaning time", + container=self, + attribute_getter="clean_time", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="clean_progress", + name="Cleaning progress", + container=self, + attribute_getter="clean_progress", + unit_getter=lambda: "%", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) async def _post_update_hook(self) -> None: """Set error code after update.""" @@ -171,9 +218,11 @@ async def _post_update_hook(self) -> None: def query(self) -> dict: """Query to execute during the update cycle.""" return { - "getVacStatus": None, - "getBatteryInfo": None, - "getCleanStatus": None, + "getVacStatus": {}, + "getCleanInfo": {}, + "getAreaUnit": {}, + "getBatteryInfo": {}, + "getCleanStatus": {}, "getCleanAttr": {"type": "global"}, } @@ -248,6 +297,11 @@ def _vac_status(self) -> dict: """Return vac status container.""" return self.data["getVacStatus"] + @property + def _info(self) -> dict: + """Return current cleaning info.""" + return self.data["getCleanInfo"] + @property def _settings(self) -> dict: """Return cleaning settings.""" @@ -265,3 +319,23 @@ def status(self) -> Status: except ValueError: _LOGGER.warning("Got unknown status code: %s (%s)", status_code, self.data) return Status.UnknownInternal + + @property + def area_unit(self) -> AreaUnit: + """Return area unit.""" + return AreaUnit(self.data["getAreaUnit"]["area_unit"]) + + @property + def clean_area(self) -> Annotated[int, FeatureAttribute()]: + """Return currently cleaned area.""" + return self._info["clean_area"] + + @property + def clean_time(self) -> timedelta: + """Return current cleaning time.""" + return timedelta(minutes=self._info["clean_time"]) + + @property + def clean_progress(self) -> int: + """Return amount of currently cleaned area.""" + return self._info["clean_percent"] diff --git a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json index c321488c1..d312a1987 100644 --- a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json +++ b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json @@ -159,7 +159,9 @@ "getBatteryInfo": { "battery_percentage": 75 }, + "getAreaUnit": {"area_unit": 0}, "getCleanAttr": {"suction": 2, "cistern": 2, "clean_number": 1}, + "getCleanInfo": {"clean_time": 5, "clean_area": 5, "clean_percent": 1}, "getCleanStatus": {"getCleanStatus": {"clean_status": 0, "is_working": false, "is_mapping": false, "is_relocating": false}}, "getCleanRecords": { "lastest_day_record": [ From 2ab42f59b3c488f3ed41234bf46c875970ea88d9 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 15 Jan 2025 14:33:05 +0100 Subject: [PATCH 810/892] Fallback to is_low for batterysensor's battery_low (#1420) Fallback to `is_low` if `at_low_battery` is not available. --- kasa/smart/modules/batterysensor.py | 42 +++++++++++++++++++---------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/kasa/smart/modules/batterysensor.py b/kasa/smart/modules/batterysensor.py index 87072b104..aef100fc5 100644 --- a/kasa/smart/modules/batterysensor.py +++ b/kasa/smart/modules/batterysensor.py @@ -2,7 +2,11 @@ from __future__ import annotations +from typing import Annotated + +from ...exceptions import KasaException from ...feature import Feature +from ...module import FeatureAttribute from ..smartmodule import SmartModule @@ -14,18 +18,22 @@ class BatterySensor(SmartModule): def _initialize_features(self) -> None: """Initialize features.""" - self._add_feature( - Feature( - self._device, - "battery_low", - "Battery low", - container=self, - attribute_getter="battery_low", - icon="mdi:alert", - type=Feature.Type.BinarySensor, - category=Feature.Category.Debug, + if ( + "at_low_battery" in self._device.sys_info + or "is_low" in self._device.sys_info + ): + self._add_feature( + Feature( + self._device, + "battery_low", + "Battery low", + container=self, + attribute_getter="battery_low", + icon="mdi:alert", + type=Feature.Type.BinarySensor, + category=Feature.Category.Debug, + ) ) - ) # Some devices, like T110 contact sensor do not report the battery percentage if "battery_percentage" in self._device.sys_info: @@ -48,11 +56,17 @@ def query(self) -> dict: return {} @property - def battery(self) -> int: + def battery(self) -> Annotated[int, FeatureAttribute()]: """Return battery level.""" return self._device.sys_info["battery_percentage"] @property - def battery_low(self) -> bool: + def battery_low(self) -> Annotated[bool, FeatureAttribute()]: """Return True if battery is low.""" - return self._device.sys_info["at_low_battery"] + is_low = self._device.sys_info.get( + "at_low_battery", self._device.sys_info.get("is_low") + ) + if is_low is None: + raise KasaException("Device does not report battery low status") + + return is_low From 0f185f1905906c97430144875b7cc02efe64da14 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 15 Jan 2025 16:06:52 +0100 Subject: [PATCH 811/892] Add commit-hook to prettify JSON files (#1455) --- .pre-commit-config.yaml | 4 + .../deviceconfig_camera-aes-https.json | 6 +- .../serialization/deviceconfig_plug-klap.json | 6 +- .../serialization/deviceconfig_plug-xor.json | 6 +- .../smart/RV20 Max Plus(EU)_1.0_1.0.7.json | 39 +- .../smart/child/T310(EU)_1.0_1.5.0.json | 2 +- .../smart/child/T315(EU)_1.0_1.7.0.json | 1070 ++++++++--------- 7 files changed, 577 insertions(+), 556 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index adcad8e4e..182ec765b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,6 +16,10 @@ repos: - id: check-yaml - id: debug-statements - id: check-ast + - id: pretty-format-json + args: + - "--autofix" + - "--indent=4" - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.7.4 diff --git a/tests/fixtures/serialization/deviceconfig_camera-aes-https.json b/tests/fixtures/serialization/deviceconfig_camera-aes-https.json index 361ec6ecf..40543d2d0 100644 --- a/tests/fixtures/serialization/deviceconfig_camera-aes-https.json +++ b/tests/fixtures/serialization/deviceconfig_camera-aes-https.json @@ -1,9 +1,9 @@ { - "host": "127.0.0.1", - "timeout": 5, "connection_type": { "device_family": "SMART.IPCAMERA", "encryption_type": "AES", "https": true - } + }, + "host": "127.0.0.1", + "timeout": 5 } diff --git a/tests/fixtures/serialization/deviceconfig_plug-klap.json b/tests/fixtures/serialization/deviceconfig_plug-klap.json index fa7a6ba85..f78918021 100644 --- a/tests/fixtures/serialization/deviceconfig_plug-klap.json +++ b/tests/fixtures/serialization/deviceconfig_plug-klap.json @@ -1,10 +1,10 @@ { - "host": "127.0.0.1", - "timeout": 5, "connection_type": { "device_family": "SMART.TAPOPLUG", "encryption_type": "KLAP", "https": false, "login_version": 2 - } + }, + "host": "127.0.0.1", + "timeout": 5 } diff --git a/tests/fixtures/serialization/deviceconfig_plug-xor.json b/tests/fixtures/serialization/deviceconfig_plug-xor.json index 5cb0222af..04e436399 100644 --- a/tests/fixtures/serialization/deviceconfig_plug-xor.json +++ b/tests/fixtures/serialization/deviceconfig_plug-xor.json @@ -1,9 +1,9 @@ { - "host": "127.0.0.1", - "timeout": 5, "connection_type": { "device_family": "IOT.SMARTPLUGSWITCH", "encryption_type": "XOR", "https": false - } + }, + "host": "127.0.0.1", + "timeout": 5 } diff --git a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json index d312a1987..92b8e85b2 100644 --- a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json +++ b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json @@ -150,6 +150,9 @@ "owner": "00000000000000000000000000000000" } }, + "getAreaUnit": { + "area_unit": 0 + }, "getAutoChangeMap": { "auto_change_map": false }, @@ -159,10 +162,16 @@ "getBatteryInfo": { "battery_percentage": 75 }, - "getAreaUnit": {"area_unit": 0}, - "getCleanAttr": {"suction": 2, "cistern": 2, "clean_number": 1}, - "getCleanInfo": {"clean_time": 5, "clean_area": 5, "clean_percent": 1}, - "getCleanStatus": {"getCleanStatus": {"clean_status": 0, "is_working": false, "is_mapping": false, "is_relocating": false}}, + "getCleanAttr": { + "cistern": 2, + "clean_number": 1, + "suction": 2 + }, + "getCleanInfo": { + "clean_area": 5, + "clean_percent": 1, + "clean_time": 5 + }, "getCleanRecords": { "lastest_day_record": [ 0, @@ -176,6 +185,14 @@ "total_number": 0, "total_time": 0 }, + "getCleanStatus": { + "getCleanStatus": { + "clean_status": 0, + "is_mapping": false, + "is_relocating": false, + "is_working": false + } + }, "getConsumablesInfo": { "charge_contact_time": 0, "edge_brush_time": 0, @@ -189,14 +206,15 @@ "name": "2", "version": 1 }, - "getVolume": { - "volume": 84 - }, "getDoNotDisturb": { "do_not_disturb": true, "e_min": 480, "s_min": 1320 }, + "getDustCollectionInfo": { + "auto_dust_collection": true, + "dust_collection_mode": 0 + }, "getMapInfo": { "auto_change_map": false, "current_map_id": 0, @@ -207,10 +225,6 @@ "getMopState": { "mop_state": false }, - "getDustCollectionInfo": { - "auto_dust_collection": true, - "dust_collection_mode": 0 - }, "getVacStatus": { "err_status": [ 0 @@ -222,6 +236,9 @@ "promptCode_id": [], "status": 5 }, + "getVolume": { + "volume": 84 + }, "get_device_info": { "auto_pack_ver": "0.0.1.1771", "avatar": "", diff --git a/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json b/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json index d48875e5f..0d9108eef 100644 --- a/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json +++ b/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json @@ -1,5 +1,5 @@ { - "component_nego" : { + "component_nego": { "component_list": [ { "id": "device", diff --git a/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json b/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json index 4fc49b0e8..a9fd67e38 100644 --- a/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json +++ b/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json @@ -1,537 +1,537 @@ { - "component_nego" : { - "component_list" : [ - { - "id" : "device", - "ver_code" : 2 - }, - { - "id" : "quick_setup", - "ver_code" : 3 - }, - { - "id" : "trigger_log", - "ver_code" : 1 - }, - { - "id" : "time", - "ver_code" : 1 - }, - { - "id" : "device_local_time", - "ver_code" : 1 - }, - { - "id" : "account", - "ver_code" : 1 - }, - { - "id" : "synchronize", - "ver_code" : 1 - }, - { - "id" : "cloud_connect", - "ver_code" : 1 - }, - { - "id" : "iot_cloud", - "ver_code" : 1 - }, - { - "id" : "firmware", - "ver_code" : 1 - }, - { - "id" : "localSmart", - "ver_code" : 1 - }, - { - "id" : "battery_detect", - "ver_code" : 1 - }, - { - "id" : "temperature", - "ver_code" : 1 - }, - { - "id" : "humidity", - "ver_code" : 1 - }, - { - "id" : "temp_humidity_record", - "ver_code" : 1 - }, - { - "id" : "comfort_temperature", - "ver_code" : 1 - }, - { - "id" : "comfort_humidity", - "ver_code" : 1 - }, - { - "id" : "report_mode", - "ver_code" : 1 - } - ] - }, - "get_connect_cloud_state" : { - "status" : 0 - }, - "get_device_info" : { - "at_low_battery" : false, - "avatar" : "", - "battery_percentage" : 100, - "bind_count" : 1, - "category" : "subg.trigger.temp-hmdt-sensor", - "current_humidity" : 61, - "current_humidity_exception" : 1, - "current_temp" : 21.4, - "current_temp_exception" : 0, - "device_id" : "SCRUBBED_CHILD_DEVICE_ID_1", - "fw_ver" : "1.7.0 Build 230424 Rel.170332", - "hw_id" : "00000000000000000000000000000000", - "hw_ver" : "1.0", - "jamming_rssi" : -122, - "jamming_signal_level" : 1, - "lastOnboardingTimestamp" : 1706990901, - "mac" : "F0A731000000", - "model" : "T315", - "nickname" : "I01BU0tFRF9OQU1FIw==", - "oem_id" : "00000000000000000000000000000000", - "parent_device_id" : "0000000000000000000000000000000000000000", - "region" : "Europe/Berlin", - "report_interval" : 16, - "rssi" : -56, - "signal_level" : 3, - "specs" : "EU", - "status" : "online", - "status_follow_edge" : false, - "temp_unit" : "celsius", - "type" : "SMART.TAPOSENSOR" - }, - "get_fw_download_state" : { - "cloud_cache_seconds" : 1, - "download_progress" : 0, - "reboot_time" : 5, - "status" : 0, - "upgrade_time" : 5 - }, - "get_latest_fw" : { - "fw_ver" : "1.8.0 Build 230921 Rel.091446", - "hw_id" : "00000000000000000000000000000000", - "need_to_upgrade" : true, - "oem_id" : "00000000000000000000000000000000", - "release_date" : "2023-12-01", - "release_note" : "Modifications and Bug Fixes:\nEnhance the stability of the sensor.", - "type" : 2 - }, - "get_temp_humidity_records" : { - "local_time" : 1709061516, - "past24h_humidity" : [ - 60, - 60, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 58, - 59, - 59, - 58, - 59, - 59, - 59, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 59, - 59, - 59, - 59, - 59, - 59, - 60, - 60, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 64, - 56, - 53, - 55, - 56, - 57, - 57, - 58, - 59, - 63, - 63, - 62, - 62, - 62, - 62, - 61, - 62, - 62, - 61, - 61 - ], - "past24h_humidity_exception" : [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 4, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 3, - 3, - 2, - 2, - 2, - 2, - 1, - 2, - 2, - 1, - 1 - ], - "past24h_temp" : [ - 217, - 216, - 215, - 214, - 214, - 214, - 214, - 214, - 214, - 213, - 213, - 213, - 213, - 213, - 212, - 212, - 211, - 211, - 211, - 211, - 211, - 211, - 212, - 212, - 212, - 211, - 211, - 211, - 211, - 212, - 212, - 212, - 212, - 212, - 211, - 211, - 211, - 212, - 213, - 214, - 214, - 214, - 213, - 212, - 212, - 212, - 212, - 212, - 212, - 212, - 212, - 212, - 212, - 213, - 213, - 213, - 213, - 213, - 213, - 213, - 213, - 213, - 213, - 214, - 214, - 215, - 215, - 215, - 214, - 215, - 216, - 216, - 216, - 216, - 216, - 216, - 216, - 205, - 196, - 210, - 213, - 213, - 213, - 213, - 213, - 214, - 215, - 214, - 214, - 213, - 213, - 214, - 214, - 214, - 213, - 213 - ], - "past24h_temp_exception" : [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - -4, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "temp_unit" : "celsius" - }, - "get_trigger_logs" : { - "logs" : [ - { - "event" : "tooDry", - "eventId" : "118040a8-5422-1100-0804-0a8542211000", - "id" : 1, - "timestamp" : 1706996915 - } - ], - "start_id" : 1, - "sum" : 1 - } + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 61, + "current_humidity_exception": 1, + "current_temp": 21.4, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.7.0 Build 230424 Rel.170332", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -122, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1706990901, + "mac": "F0A731000000", + "model": "T315", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 16, + "rssi": -56, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_ver": "1.8.0 Build 230921 Rel.091446", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-12-01", + "release_note": "Modifications and Bug Fixes:\nEnhance the stability of the sensor.", + "type": 2 + }, + "get_temp_humidity_records": { + "local_time": 1709061516, + "past24h_humidity": [ + 60, + 60, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 58, + 59, + 59, + 58, + 59, + 59, + 59, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 59, + 59, + 59, + 59, + 59, + 59, + 60, + 60, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 64, + 56, + 53, + 55, + 56, + 57, + 57, + 58, + 59, + 63, + 63, + 62, + 62, + 62, + 62, + 61, + 62, + 62, + 61, + 61 + ], + "past24h_humidity_exception": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 3, + 2, + 2, + 2, + 2, + 1, + 2, + 2, + 1, + 1 + ], + "past24h_temp": [ + 217, + 216, + 215, + 214, + 214, + 214, + 214, + 214, + 214, + 213, + 213, + 213, + 213, + 213, + 212, + 212, + 211, + 211, + 211, + 211, + 211, + 211, + 212, + 212, + 212, + 211, + 211, + 211, + 211, + 212, + 212, + 212, + 212, + 212, + 211, + 211, + 211, + 212, + 213, + 214, + 214, + 214, + 213, + 212, + 212, + 212, + 212, + 212, + 212, + 212, + 212, + 212, + 212, + 213, + 213, + 213, + 213, + 213, + 213, + 213, + 213, + 213, + 213, + 214, + 214, + 215, + 215, + 215, + 214, + 215, + 216, + 216, + 216, + 216, + 216, + 216, + 216, + 205, + 196, + 210, + 213, + 213, + 213, + 213, + 213, + 214, + 215, + 214, + 214, + 213, + 213, + 214, + 214, + 214, + 213, + 213 + ], + "past24h_temp_exception": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + -4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "tooDry", + "eventId": "118040a8-5422-1100-0804-0a8542211000", + "id": 1, + "timestamp": 1706996915 + } + ], + "start_id": 1, + "sum": 1 + } } From bc97c0794a1bfd79b9216bfc8266b915bf92a7bd Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 15 Jan 2025 19:11:33 +0100 Subject: [PATCH 812/892] Add setting to change clean count (#1457) Adds a setting to change the number of times to clean: ``` == Configuration == Clean count (clean_count): 1 (range: 1-3) ``` --- kasa/smart/modules/clean.py | 38 +++++++++++++++++++++++++++---- tests/smart/modules/test_clean.py | 7 ++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/kasa/smart/modules/clean.py b/kasa/smart/modules/clean.py index 4d513a3a6..f44fe7e64 100644 --- a/kasa/smart/modules/clean.py +++ b/kasa/smart/modules/clean.py @@ -5,7 +5,7 @@ import logging from datetime import timedelta from enum import IntEnum -from typing import Annotated +from typing import Annotated, Literal from ...feature import Feature from ...module import FeatureAttribute @@ -157,6 +157,19 @@ def _initialize_features(self) -> None: type=Feature.Type.Choice, ) ) + self._add_feature( + Feature( + self._device, + id="clean_count", + name="Clean count", + container=self, + attribute_getter="clean_count", + attribute_setter="set_clean_count", + range_getter=lambda: (1, 3), + category=Feature.Category.Config, + type=Feature.Type.Number, + ) + ) self._add_feature( Feature( self._device, @@ -283,9 +296,17 @@ async def set_fan_speed_preset( name_to_value = {x.name: x.value for x in FanSpeed} if speed not in name_to_value: raise ValueError("Invalid fan speed %s, available %s", speed, name_to_value) - return await self.call( - "setCleanAttr", {"suction": name_to_value[speed], "type": "global"} - ) + return await self._change_setting("suction", name_to_value[speed]) + + async def _change_setting( + self, name: str, value: int, *, scope: Literal["global", "pose"] = "global" + ) -> dict: + """Change device setting.""" + params = { + name: value, + "type": scope, + } + return await self.call("setCleanAttr", params) @property def battery(self) -> int: @@ -339,3 +360,12 @@ def clean_time(self) -> timedelta: def clean_progress(self) -> int: """Return amount of currently cleaned area.""" return self._info["clean_percent"] + + @property + def clean_count(self) -> Annotated[int, FeatureAttribute()]: + """Return number of times to clean.""" + return self._settings["clean_number"] + + async def set_clean_count(self, count: int) -> Annotated[dict, FeatureAttribute()]: + """Set number of times to clean.""" + return await self._change_setting("clean_number", count) diff --git a/tests/smart/modules/test_clean.py b/tests/smart/modules/test_clean.py index b9f902c2c..2a2d2884a 100644 --- a/tests/smart/modules/test_clean.py +++ b/tests/smart/modules/test_clean.py @@ -69,6 +69,13 @@ async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: ty {"suction": 1, "type": "global"}, id="vacuum_fan_speed", ), + pytest.param( + "clean_count", + 2, + "setCleanAttr", + {"clean_number": 2, "type": "global"}, + id="clean_count", + ), ], ) @clean From 17356c10f1dca2c5cbdd54a8007d5e41469ccad1 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 15 Jan 2025 19:12:33 +0100 Subject: [PATCH 813/892] Add mop module (#1456) Adds the following new features: a setting to control water level and a sensor if the mop is attached: ``` Mop water level (mop_waterlevel): *Disable* Low Medium High Mop attached (mop_attached): True ``` --- kasa/module.py | 1 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/mop.py | 90 +++++++++++++++++++++++++++++++++ tests/smart/modules/test_mop.py | 58 +++++++++++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 kasa/smart/modules/mop.py create mode 100644 tests/smart/modules/test_mop.py diff --git a/kasa/module.py b/kasa/module.py index 0c5a0489f..c477dbedc 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -165,6 +165,7 @@ class Module(ABC): Clean: Final[ModuleName[smart.Clean]] = ModuleName("Clean") Dustbin: Final[ModuleName[smart.Dustbin]] = ModuleName("Dustbin") Speaker: Final[ModuleName[smart.Speaker]] = ModuleName("Speaker") + Mop: Final[ModuleName[smart.Mop]] = ModuleName("Mop") def __init__(self, device: Device, module: str) -> None: self._device = device diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index deb09f4f4..48378a575 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -27,6 +27,7 @@ from .lightstripeffect import LightStripEffect from .lighttransition import LightTransition from .matter import Matter +from .mop import Mop from .motionsensor import MotionSensor from .overheatprotection import OverheatProtection from .reportmode import ReportMode @@ -76,4 +77,5 @@ "HomeKit", "Matter", "Dustbin", + "Mop", ] diff --git a/kasa/smart/modules/mop.py b/kasa/smart/modules/mop.py new file mode 100644 index 000000000..851279e97 --- /dev/null +++ b/kasa/smart/modules/mop.py @@ -0,0 +1,90 @@ +"""Implementation of vacuum mop.""" + +from __future__ import annotations + +import logging +from enum import IntEnum +from typing import Annotated + +from ...feature import Feature +from ...module import FeatureAttribute +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +class Waterlevel(IntEnum): + """Water level for mopping.""" + + Disable = 0 + Low = 1 + Medium = 2 + High = 3 + + +class Mop(SmartModule): + """Implementation of vacuum mop.""" + + REQUIRED_COMPONENT = "mop" + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="mop_attached", + name="Mop attached", + container=self, + icon="mdi:square-rounded", + attribute_getter="mop_attached", + category=Feature.Category.Info, + type=Feature.BinarySensor, + ) + ) + + self._add_feature( + Feature( + self._device, + id="mop_waterlevel", + name="Mop water level", + container=self, + attribute_getter="waterlevel", + attribute_setter="set_waterlevel", + icon="mdi:water", + choices_getter=lambda: list(Waterlevel.__members__), + category=Feature.Category.Config, + type=Feature.Type.Choice, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return { + "getMopState": {}, + "getCleanAttr": {"type": "global"}, + } + + @property + def mop_attached(self) -> bool: + """Return True if mop is attached.""" + return self.data["getMopState"]["mop_state"] + + @property + def _settings(self) -> dict: + """Return settings settings.""" + return self.data["getCleanAttr"] + + @property + def waterlevel(self) -> Annotated[str, FeatureAttribute()]: + """Return water level.""" + return Waterlevel(int(self._settings["cistern"])).name + + async def set_waterlevel(self, mode: str) -> Annotated[dict, FeatureAttribute()]: + """Set waterlevel mode.""" + name_to_value = {x.name: x.value for x in Waterlevel} + if mode not in name_to_value: + raise ValueError("Invalid waterlevel %s, available %s", mode, name_to_value) + + settings = self._settings.copy() + settings["cistern"] = name_to_value[mode] + return await self.call("setCleanAttr", settings) diff --git a/tests/smart/modules/test_mop.py b/tests/smart/modules/test_mop.py new file mode 100644 index 000000000..0c638ca3a --- /dev/null +++ b/tests/smart/modules/test_mop.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice +from kasa.smart.modules.mop import Waterlevel + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +mop = parametrize("has mop", component_filter="mop", protocol_filter={"SMART"}) + + +@mop +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("mop_attached", "mop_attached", bool), + ("mop_waterlevel", "waterlevel", str), + ], +) +async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type): + """Test that features are registered and work as expected.""" + mod = next(get_parent_and_child_modules(dev, Module.Mop)) + assert mod is not None + + prop = getattr(mod, prop_name) + assert isinstance(prop, type) + + feat = mod._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@mop +async def test_mop_waterlevel(dev: SmartDevice, mocker: MockerFixture): + """Test dust mode.""" + mop_module = next(get_parent_and_child_modules(dev, Module.Mop)) + call = mocker.spy(mop_module, "call") + + waterlevel = mop_module._device.features["mop_waterlevel"] + assert mop_module.waterlevel == waterlevel.value + + new_level = Waterlevel.High + await mop_module.set_waterlevel(new_level.name) + + params = mop_module._settings.copy() + params["cistern"] = new_level.value + + call.assert_called_with("setCleanAttr", params) + + await dev.update() + + assert mop_module.waterlevel == new_level.name + + with pytest.raises(ValueError, match="Invalid waterlevel"): + await mop_module.set_waterlevel("invalid") From b23019e748a9c73baed1a325b8bb007c894544f7 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 15 Jan 2025 19:10:32 +0000 Subject: [PATCH 814/892] Enable dynamic hub child creation and deletion on update (#1454) --- kasa/smart/modules/childdevice.py | 8 + kasa/smart/smartchilddevice.py | 5 + kasa/smart/smartdevice.py | 124 +++++++++++---- kasa/smartcam/modules/childdevice.py | 5 +- kasa/smartcam/smartcamdevice.py | 66 ++++---- tests/fakeprotocol_smart.py | 66 +++++--- tests/fakeprotocol_smartcam.py | 59 ++++--- tests/smart/test_smartdevice.py | 227 ++++++++++++++++++++++++++- 8 files changed, 445 insertions(+), 115 deletions(-) diff --git a/kasa/smart/modules/childdevice.py b/kasa/smart/modules/childdevice.py index 4c3b99ded..e816e3f1c 100644 --- a/kasa/smart/modules/childdevice.py +++ b/kasa/smart/modules/childdevice.py @@ -38,6 +38,7 @@ True """ +from ...device_type import DeviceType from ..smartmodule import SmartModule @@ -46,3 +47,10 @@ class ChildDevice(SmartModule): REQUIRED_COMPONENT = "child_device" QUERY_GETTER_NAME = "get_child_device_list" + + def query(self) -> dict: + """Query to execute during the update cycle.""" + q = super().query() + if self._device.device_type is DeviceType.Hub: + q["get_child_device_component_list"] = None + return q diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 760a18a1e..3f730f0e6 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -109,6 +109,11 @@ async def _update(self, update_children: bool = True) -> None: ) self._last_update_time = now + # We can first initialize the features after the first update. + # We make here an assumption that every device has at least a single feature. + if not self._features: + await self._initialize_features() + @classmethod async def create( cls, diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 89f2f9506..6c2e2227a 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -5,7 +5,7 @@ import base64 import logging import time -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from datetime import UTC, datetime, timedelta, tzinfo from typing import TYPE_CHECKING, Any, TypeAlias, cast @@ -68,10 +68,11 @@ def __init__( self._state_information: dict[str, Any] = {} self._modules: dict[str | ModuleName[Module], SmartModule] = {} self._parent: SmartDevice | None = None - self._children: Mapping[str, SmartDevice] = {} + self._children: dict[str, SmartDevice] = {} self._last_update_time: float | None = None self._on_since: datetime | None = None self._info: dict[str, Any] = {} + self._logged_missing_child_ids: set[str] = set() async def _initialize_children(self) -> None: """Initialize children for power strips.""" @@ -82,23 +83,86 @@ async def _initialize_children(self) -> None: resp = await self.protocol.query(child_info_query) self.internal_state.update(resp) - children = self.internal_state["get_child_device_list"]["child_device_list"] - children_components_raw = { - child["device_id"]: child - for child in self.internal_state["get_child_device_component_list"][ - "child_component_list" - ] - } + async def _try_create_child( + self, info: dict, child_components: dict + ) -> SmartDevice | None: from .smartchilddevice import SmartChildDevice - self._children = { - child_info["device_id"]: await SmartChildDevice.create( - parent=self, - child_info=child_info, - child_components_raw=children_components_raw[child_info["device_id"]], - ) - for child_info in children + return await SmartChildDevice.create( + parent=self, + child_info=info, + child_components_raw=child_components, + ) + + async def _create_delete_children( + self, + child_device_resp: dict[str, list], + child_device_components_resp: dict[str, list], + ) -> bool: + """Create and delete children. Return True if children changed. + + Adds newly found children and deletes children that are no longer + reported by the device. It will only log once per child_id that + can't be created to avoid spamming the logs on every update. + """ + changed = False + smart_children_components = { + child["device_id"]: child + for child in child_device_components_resp["child_component_list"] } + children = self._children + child_ids: set[str] = set() + existing_child_ids = set(self._children.keys()) + + for info in child_device_resp["child_device_list"]: + if (child_id := info.get("device_id")) and ( + child_components := smart_children_components.get(child_id) + ): + child_ids.add(child_id) + + if child_id in existing_child_ids: + continue + + child = await self._try_create_child(info, child_components) + if child: + _LOGGER.debug("Created child device %s for %s", child, self.host) + changed = True + children[child_id] = child + continue + + if child_id not in self._logged_missing_child_ids: + self._logged_missing_child_ids.add(child_id) + _LOGGER.debug("Child device type not supported: %s", info) + continue + + if child_id: + if child_id not in self._logged_missing_child_ids: + self._logged_missing_child_ids.add(child_id) + _LOGGER.debug( + "Could not find child components for device %s, " + "child_id %s, components: %s: ", + self.host, + child_id, + smart_children_components, + ) + continue + + # If we couldn't get a child device id we still only want to + # log once to avoid spamming the logs on every update cycle + # so store it under an empty string + if "" not in self._logged_missing_child_ids: + self._logged_missing_child_ids.add("") + _LOGGER.debug( + "Could not find child id for device %s, info: %s", self.host, info + ) + + removed_ids = existing_child_ids - child_ids + for removed_id in removed_ids: + changed = True + removed = children.pop(removed_id) + _LOGGER.debug("Removed child device %s from %s", removed, self.host) + + return changed @property def children(self) -> Sequence[SmartDevice]: @@ -164,21 +228,29 @@ async def _negotiate(self) -> None: if "child_device" in self._components and not self.children: await self._initialize_children() - def _update_children_info(self) -> None: - """Update the internal child device info from the parent info.""" + async def _update_children_info(self) -> bool: + """Update the internal child device info from the parent info. + + Return true if children added or deleted. + """ + changed = False if child_info := self._try_get_response( self._last_update, "get_child_device_list", {} ): + changed = await self._create_delete_children( + child_info, self._last_update["get_child_device_component_list"] + ) + for info in child_info["child_device_list"]: - child_id = info["device_id"] + child_id = info.get("device_id") if child_id not in self._children: - _LOGGER.debug( - "Skipping child update for %s, probably unsupported device", - child_id, - ) + # _create_delete_children has already logged a message continue + self._children[child_id]._update_internal_state(info) + return changed + def _update_internal_info(self, info_resp: dict) -> None: """Update the internal device info.""" self._info = self._try_get_response(info_resp, "get_device_info") @@ -201,13 +273,13 @@ async def update(self, update_children: bool = True) -> None: resp = await self._modular_update(first_update, now) - self._update_children_info() + children_changed = await self._update_children_info() # Call child update which will only update module calls, info is updated # from get_child_device_list. update_children only affects hub devices, other # devices will always update children to prevent errors on module access. # This needs to go after updating the internal state of the children so that # child modules have access to their sysinfo. - if first_update or update_children or self.device_type != DeviceType.Hub: + if children_changed or update_children or self.device_type != DeviceType.Hub: for child in self._children.values(): if TYPE_CHECKING: assert isinstance(child, SmartChildDevice) @@ -469,8 +541,6 @@ async def _initialize_features(self) -> None: module._initialize_features() for feat in module._module_features.values(): self._add_feature(feat) - for child in self._children.values(): - await child._initialize_features() @property def _is_hub_child(self) -> bool: diff --git a/kasa/smartcam/modules/childdevice.py b/kasa/smartcam/modules/childdevice.py index c4de58385..812fd0c1b 100644 --- a/kasa/smartcam/modules/childdevice.py +++ b/kasa/smartcam/modules/childdevice.py @@ -19,7 +19,10 @@ def query(self) -> dict: Default implementation uses the raw query getter w/o parameters. """ - return {self.QUERY_GETTER_NAME: {"childControl": {"start_index": 0}}} + q = {self.QUERY_GETTER_NAME: {"childControl": {"start_index": 0}}} + if self._device.device_type is DeviceType.Hub: + q["getChildDeviceComponentList"] = {"childControl": {"start_index": 0}} + return q async def _check_supported(self) -> bool: """Additional check to see if the module is supported by the device.""" diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index b8d2cf800..d096fb5b5 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -70,21 +70,29 @@ def _update_internal_state(self, info: dict[str, Any]) -> None: """ self._info = self._map_info(info) - def _update_children_info(self) -> None: - """Update the internal child device info from the parent info.""" + async def _update_children_info(self) -> bool: + """Update the internal child device info from the parent info. + + Return true if children added or deleted. + """ + changed = False if child_info := self._try_get_response( self._last_update, "getChildDeviceList", {} ): + changed = await self._create_delete_children( + child_info, self._last_update["getChildDeviceComponentList"] + ) + for info in child_info["child_device_list"]: - child_id = info["device_id"] + child_id = info.get("device_id") if child_id not in self._children: - _LOGGER.debug( - "Skipping child update for %s, probably unsupported device", - child_id, - ) + # _create_delete_children has already logged a message continue + self._children[child_id]._update_internal_state(info) + return changed + async def _initialize_smart_child( self, info: dict, child_components_raw: ComponentsRaw ) -> SmartDevice: @@ -113,7 +121,6 @@ async def _initialize_smartcam_child( child_id = info["device_id"] child_protocol = _ChildCameraProtocolWrapper(child_id, self.protocol) - last_update = {"getDeviceInfo": {"device_info": {"basic_info": info}}} app_component_list = { "app_component_list": child_components_raw["component_list"] } @@ -124,7 +131,6 @@ async def _initialize_smartcam_child( child_info=info, child_components_raw=app_component_list, protocol=child_protocol, - last_update=last_update, ) async def _initialize_children(self) -> None: @@ -136,35 +142,22 @@ async def _initialize_children(self) -> None: resp = await self.protocol.query(child_info_query) self.internal_state.update(resp) - smart_children_components = { - child["device_id"]: child - for child in resp["getChildDeviceComponentList"]["child_component_list"] - } - children = {} - from .smartcamchild import SmartCamChild + async def _try_create_child( + self, info: dict, child_components: dict + ) -> SmartDevice | None: + if not (category := info.get("category")): + return None - for info in resp["getChildDeviceList"]["child_device_list"]: - if ( - (category := info.get("category")) - and (child_id := info.get("device_id")) - and (child_components := smart_children_components.get(child_id)) - ): - # Smart - if category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP: - children[child_id] = await self._initialize_smart_child( - info, child_components - ) - continue - # Smartcam - if category in SmartCamChild.CHILD_DEVICE_TYPE_MAP: - children[child_id] = await self._initialize_smartcam_child( - info, child_components - ) - continue + # Smart + if category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP: + return await self._initialize_smart_child(info, child_components) + # Smartcam + from .smartcamchild import SmartCamChild - _LOGGER.debug("Child device type not supported: %s", info) + if category in SmartCamChild.CHILD_DEVICE_TYPE_MAP: + return await self._initialize_smartcam_child(info, child_components) - self._children = children + return None async def _initialize_modules(self) -> None: """Initialize modules based on component negotiation response.""" @@ -190,9 +183,6 @@ async def _initialize_features(self) -> None: for feat in module._module_features.values(): self._add_feature(feat) - for child in self._children.values(): - await child._initialize_features() - async def _query_setter_helper( self, method: str, module: str, section: str, params: dict | None = None ) -> dict: diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index 27b994380..532328153 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -548,6 +548,37 @@ def _update_sysinfo_key(self, info: dict, key: str, value: str) -> dict: return {"error_code": 0} + def get_child_device_queries(self, method, params): + return self._get_method_from_info(method, params) + + def _get_method_from_info(self, method, params): + result = copy.deepcopy(self.info[method]) + if result and "start_index" in result and "sum" in result: + list_key = next( + iter([key for key in result if isinstance(result[key], list)]) + ) + start_index = ( + start_index + if (params and (start_index := params.get("start_index"))) + else 0 + ) + # Fixtures generated before _handle_response_lists was implemented + # could have incomplete lists. + if ( + len(result[list_key]) < result["sum"] + and self.fix_incomplete_fixture_lists + ): + result["sum"] = len(result[list_key]) + if self.warn_fixture_missing_methods: + pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] + self.fixture_name, set() + ).add(f"{method} (incomplete '{list_key}' list)") + + result[list_key] = result[list_key][ + start_index : start_index + self.list_return_size + ] + return {"result": result, "error_code": 0} + async def _send_request(self, request_dict: dict): method = request_dict["method"] @@ -557,33 +588,16 @@ async def _send_request(self, request_dict: dict): params = request_dict.get("params", {}) if method in {"component_nego", "qs_component_nego"} or method[:3] == "get": + # These methods are handled in get_child_device_query so it can be + # patched for tests to simulate dynamic devices. + if ( + method in ("get_child_device_list", "get_child_device_component_list") + and method in info + ): + return self.get_child_device_queries(method, params) + if method in info: - result = copy.deepcopy(info[method]) - if result and "start_index" in result and "sum" in result: - list_key = next( - iter([key for key in result if isinstance(result[key], list)]) - ) - start_index = ( - start_index - if (params and (start_index := params.get("start_index"))) - else 0 - ) - # Fixtures generated before _handle_response_lists was implemented - # could have incomplete lists. - if ( - len(result[list_key]) < result["sum"] - and self.fix_incomplete_fixture_lists - ): - result["sum"] = len(result[list_key]) - if self.warn_fixture_missing_methods: - pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] - self.fixture_name, set() - ).add(f"{method} (incomplete '{list_key}' list)") - - result[list_key] = result[list_key][ - start_index : start_index + self.list_return_size - ] - return {"result": result, "error_code": 0} + return self._get_method_from_info(method, params) if self.verbatim: return { diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index 53a9ec17d..11a879b4a 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -188,6 +188,33 @@ def _get_second_key(request_dict: dict[str, Any]) -> str: next(it, None) return next(it) + def get_child_device_queries(self, method, params): + return self._get_method_from_info(method, params) + + def _get_method_from_info(self, method, params): + result = copy.deepcopy(self.info[method]) + if "start_index" in result and "sum" in result: + list_key = next( + iter([key for key in result if isinstance(result[key], list)]) + ) + assert isinstance(params, dict) + module_name = next(iter(params)) + + start_index = ( + start_index + if ( + params + and module_name + and (start_index := params[module_name].get("start_index")) + ) + else 0 + ) + + result[list_key] = result[list_key][ + start_index : start_index + self.list_return_size + ] + return {"result": result, "error_code": 0} + async def _send_request(self, request_dict: dict): method = request_dict["method"] @@ -257,30 +284,18 @@ async def _send_request(self, request_dict: dict): result = {"device_info": {"basic_info": mapped}} return {"result": result, "error_code": 0} - if method in info: + # These methods are handled in get_child_device_query so it can be + # patched for tests to simulate dynamic devices. + if ( + method in ("getChildDeviceList", "getChildDeviceComponentList") + and method in info + ): params = request_dict.get("params") - result = copy.deepcopy(info[method]) - if "start_index" in result and "sum" in result: - list_key = next( - iter([key for key in result if isinstance(result[key], list)]) - ) - assert isinstance(params, dict) - module_name = next(iter(params)) - - start_index = ( - start_index - if ( - params - and module_name - and (start_index := params[module_name].get("start_index")) - ) - else 0 - ) + return self.get_child_device_queries(method, params) - result[list_key] = result[list_key][ - start_index : start_index + self.list_return_size - ] - return {"result": result, "error_code": 0} + if method in info: + params = request_dict.get("params") + return self._get_method_from_info(method, params) if self.verbatim: return {"error_code": -1} diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index 0cc38a71b..00d432724 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -17,6 +17,7 @@ from kasa.smart import SmartDevice from kasa.smart.modules.energy import Energy from kasa.smart.smartmodule import SmartModule +from kasa.smartcam import SmartCamDevice from tests.conftest import ( DISCOVERY_MOCK_IP, device_smart, @@ -31,6 +32,9 @@ variable_temp_smart, ) +from ..fakeprotocol_smart import FakeSmartTransport +from ..fakeprotocol_smartcam import FakeSmartCamTransport + DUMMY_CHILD_REQUEST_PREFIX = "get_dummy_" hub_all = parametrize_combine([hubs_smart, hub_smartcam]) @@ -148,6 +152,7 @@ async def test_negotiate(dev: SmartDevice, mocker: MockerFixture): "get_child_device_list": None, } ) + await dev.update() assert len(dev._children) == dev.internal_state["get_child_device_list"]["sum"] @@ -488,7 +493,12 @@ async def _query(request, *args, **kwargs): if ( not raise_error or "component_nego" in request - or "get_child_device_component_list" in request + # allow the initial child device query + or ( + "get_child_device_component_list" in request + and "get_child_device_list" in request + and len(request) == 2 + ) ): if child_id: # child single query child_protocol = dev.protocol._transport.child_protocols[child_id] @@ -763,3 +773,218 @@ class DummyModule(SmartModule): ) mod = DummyModule(dummy_device, "dummy") assert mod.query() == {} + + +@hub_all +@pytest.mark.xdist_group(name="caplog") +@pytest.mark.requires_dummy +async def test_dynamic_devices(dev: Device, caplog: pytest.LogCaptureFixture): + """Test dynamic child devices.""" + if not dev.children: + pytest.skip(f"Device {dev.model} does not have children.") + + transport = dev.protocol._transport + assert isinstance(transport, FakeSmartCamTransport | FakeSmartTransport) + + lu = dev._last_update + assert lu + child_device_info = lu.get("getChildDeviceList", lu.get("get_child_device_list")) + assert child_device_info + + child_device_components = lu.get( + "getChildDeviceComponentList", lu.get("get_child_device_component_list") + ) + assert child_device_components + + mock_child_device_info = copy.deepcopy(child_device_info) + mock_child_device_components = copy.deepcopy(child_device_components) + + first_child = child_device_info["child_device_list"][0] + first_child_device_id = first_child["device_id"] + + first_child_components = next( + iter( + [ + cc + for cc in child_device_components["child_component_list"] + if cc["device_id"] == first_child_device_id + ] + ) + ) + + first_child_fake_transport = transport.child_protocols[first_child_device_id] + + # Test adding devices + start_child_count = len(dev.children) + added_ids = [] + for i in range(1, 3): + new_child = copy.deepcopy(first_child) + new_child_components = copy.deepcopy(first_child_components) + + mock_device_id = f"mock_child_device_id_{i}" + + transport.child_protocols[mock_device_id] = first_child_fake_transport + new_child["device_id"] = mock_device_id + new_child_components["device_id"] = mock_device_id + + added_ids.append(mock_device_id) + mock_child_device_info["child_device_list"].append(new_child) + mock_child_device_components["child_component_list"].append( + new_child_components + ) + + def mock_get_child_device_queries(method, params): + if method in {"getChildDeviceList", "get_child_device_list"}: + result = mock_child_device_info + if method in {"getChildDeviceComponentList", "get_child_device_component_list"}: + result = mock_child_device_components + return {"result": result, "error_code": 0} + + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + for added_id in added_ids: + assert added_id in dev._children + expected_new_length = start_child_count + len(added_ids) + assert len(dev.children) == expected_new_length + + # Test removing devices + mock_child_device_info["child_device_list"] = [ + info + for info in mock_child_device_info["child_device_list"] + if info["device_id"] != first_child_device_id + ] + mock_child_device_components["child_component_list"] = [ + cc + for cc in mock_child_device_components["child_component_list"] + if cc["device_id"] != first_child_device_id + ] + + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + expected_new_length -= 1 + assert len(dev.children) == expected_new_length + + # Test no child devices + + mock_child_device_info["child_device_list"] = [] + mock_child_device_components["child_component_list"] = [] + mock_child_device_info["sum"] = 0 + mock_child_device_components["sum"] = 0 + + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert len(dev.children) == 0 + + # Logging tests are only for smartcam hubs as smart hubs do not test categories + if not isinstance(dev, SmartCamDevice): + return + + # setup + mock_child = copy.deepcopy(first_child) + mock_components = copy.deepcopy(first_child_components) + + mock_child_device_info["child_device_list"] = [mock_child] + mock_child_device_components["child_component_list"] = [mock_components] + mock_child_device_info["sum"] = 1 + mock_child_device_components["sum"] = 1 + + # Test can't find matching components + + mock_child["device_id"] = "no_comps_1" + mock_components["device_id"] = "no_comps_2" + + caplog.set_level("DEBUG") + caplog.clear() + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Could not find child components for device" in caplog.text + + caplog.clear() + + # Test doesn't log multiple + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Could not find child components for device" not in caplog.text + + # Test invalid category + + mock_child["device_id"] = "invalid_cat" + mock_components["device_id"] = "invalid_cat" + mock_child["category"] = "foobar" + + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Child device type not supported" in caplog.text + + caplog.clear() + + # Test doesn't log multiple + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Child device type not supported" not in caplog.text + + # Test no category + + mock_child["device_id"] = "no_cat" + mock_components["device_id"] = "no_cat" + mock_child.pop("category") + + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Child device type not supported" in caplog.text + + # Test only log once + + caplog.clear() + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Child device type not supported" not in caplog.text + + # Test no device_id + + mock_child.pop("device_id") + + caplog.clear() + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Could not find child id for device" in caplog.text + + # Test only log once + + caplog.clear() + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Could not find child id for device" not in caplog.text From d27697c50f853c8ae9e4cb42d7a3cdacf8455801 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 15 Jan 2025 20:11:10 +0100 Subject: [PATCH 815/892] Add ultra mode (fanspeed = 5) for vacuums (#1459) --- kasa/smart/modules/clean.py | 1 + 1 file changed, 1 insertion(+) diff --git a/kasa/smart/modules/clean.py b/kasa/smart/modules/clean.py index f44fe7e64..761fdccd0 100644 --- a/kasa/smart/modules/clean.py +++ b/kasa/smart/modules/clean.py @@ -53,6 +53,7 @@ class FanSpeed(IntEnum): Standard = 2 Turbo = 3 Max = 4 + Ultra = 5 class AreaUnit(IntEnum): From 773801cad5238157305ec35755b49198799f1067 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 15 Jan 2025 20:35:41 +0100 Subject: [PATCH 816/892] Add setting to change carpet clean mode (#1458) Add new setting to control carpet clean mode: ``` == Configuration == Carpet clean mode (carpet_clean_mode): Normal *Boost* ``` --- devtools/helpers/smartrequests.py | 1 + kasa/smart/modules/clean.py | 43 +++++++++++++++- .../smart/RV20 Max Plus(EU)_1.0_1.0.7.json | 3 ++ tests/smart/modules/test_clean.py | 49 +++++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 695f4a5bf..3db1a2c99 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -437,6 +437,7 @@ def get_component_requests(component_id, ver_code): "overheat_protection": [], # Vacuum components "clean": [ + SmartRequest.get_raw_request("getCarpetClean"), SmartRequest.get_raw_request("getCleanRecords"), SmartRequest.get_raw_request("getVacStatus"), SmartRequest.get_raw_request("getAreaUnit"), diff --git a/kasa/smart/modules/clean.py b/kasa/smart/modules/clean.py index 761fdccd0..a2812c329 100644 --- a/kasa/smart/modules/clean.py +++ b/kasa/smart/modules/clean.py @@ -4,7 +4,7 @@ import logging from datetime import timedelta -from enum import IntEnum +from enum import IntEnum, StrEnum from typing import Annotated, Literal from ...feature import Feature @@ -56,6 +56,13 @@ class FanSpeed(IntEnum): Ultra = 5 +class CarpetCleanMode(StrEnum): + """Carpet clean mode.""" + + Normal = "normal" + Boost = "boost" + + class AreaUnit(IntEnum): """Area unit.""" @@ -143,7 +150,6 @@ def _initialize_features(self) -> None: type=Feature.Type.Sensor, ) ) - self._add_feature( Feature( self._device, @@ -171,6 +177,20 @@ def _initialize_features(self) -> None: type=Feature.Type.Number, ) ) + self._add_feature( + Feature( + self._device, + id="carpet_clean_mode", + name="Carpet clean mode", + container=self, + attribute_getter="carpet_clean_mode", + attribute_setter="set_carpet_clean_mode", + icon="mdi:rug", + choices_getter=lambda: list(CarpetCleanMode.__members__), + category=Feature.Category.Config, + type=Feature.Type.Choice, + ) + ) self._add_feature( Feature( self._device, @@ -234,6 +254,7 @@ def query(self) -> dict: return { "getVacStatus": {}, "getCleanInfo": {}, + "getCarpetClean": {}, "getAreaUnit": {}, "getBatteryInfo": {}, "getCleanStatus": {}, @@ -342,6 +363,24 @@ def status(self) -> Status: _LOGGER.warning("Got unknown status code: %s (%s)", status_code, self.data) return Status.UnknownInternal + @property + def carpet_clean_mode(self) -> Annotated[str, FeatureAttribute()]: + """Return carpet clean mode.""" + return CarpetCleanMode(self.data["getCarpetClean"]["carpet_clean_prefer"]).name + + async def set_carpet_clean_mode( + self, mode: str + ) -> Annotated[dict, FeatureAttribute()]: + """Set carpet clean mode.""" + name_to_value = {x.name: x.value for x in CarpetCleanMode} + if mode not in name_to_value: + raise ValueError( + "Invalid carpet clean mode %s, available %s", mode, name_to_value + ) + return await self.call( + "setCarpetClean", {"carpet_clean_prefer": name_to_value[mode]} + ) + @property def area_unit(self) -> AreaUnit: """Return area unit.""" diff --git a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json index 92b8e85b2..2f945c948 100644 --- a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json +++ b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json @@ -162,6 +162,9 @@ "getBatteryInfo": { "battery_percentage": 75 }, + "getCarpetClean": { + "carpet_clean_prefer": "boost" + }, "getCleanAttr": { "cistern": 2, "clean_number": 1, diff --git a/tests/smart/modules/test_clean.py b/tests/smart/modules/test_clean.py index 2a2d2884a..beae01436 100644 --- a/tests/smart/modules/test_clean.py +++ b/tests/smart/modules/test_clean.py @@ -21,6 +21,7 @@ ("vacuum_status", "status", Status), ("vacuum_error", "error", ErrorCode), ("vacuum_fan_speed", "fan_speed_preset", str), + ("carpet_clean_mode", "carpet_clean_mode", str), ("battery_level", "battery", int), ], ) @@ -69,6 +70,13 @@ async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: ty {"suction": 1, "type": "global"}, id="vacuum_fan_speed", ), + pytest.param( + "carpet_clean_mode", + "Boost", + "setCarpetClean", + {"carpet_clean_prefer": "boost"}, + id="carpet_clean_mode", + ), pytest.param( "clean_count", 2, @@ -151,3 +159,44 @@ async def test_unknown_status( assert clean.status is Status.UnknownInternal assert "Got unknown status code: 123" in caplog.text + + +@clean +@pytest.mark.parametrize( + ("setting", "value", "exc", "exc_message"), + [ + pytest.param( + "vacuum_fan_speed", + "invalid speed", + ValueError, + "Invalid fan speed", + id="vacuum_fan_speed", + ), + pytest.param( + "carpet_clean_mode", + "invalid mode", + ValueError, + "Invalid carpet clean mode", + id="carpet_clean_mode", + ), + ], +) +async def test_invalid_settings( + dev: SmartDevice, + mocker: MockerFixture, + setting: str, + value: str, + exc: type[Exception], + exc_message: str, +): + """Test invalid settings.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + + # Not using feature.set_value() as it checks for valid values + setter_name = dev.features[setting].attribute_setter + assert isinstance(setter_name, str) + + setter = getattr(clean, setter_name) + + with pytest.raises(exc, match=exc_message): + await setter(value) From 980f6a38ca805866eed80e56ca2d6c4411b142f9 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 17 Jan 2025 13:15:51 +0100 Subject: [PATCH 817/892] Add childlock module for vacuums (#1461) Add new configuration feature: ``` Child lock (child_lock): False ``` --------- Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --- devtools/helpers/smartrequests.py | 2 +- kasa/module.py | 1 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/childlock.py | 37 ++++++++++++++++ .../smart/RV20 Max Plus(EU)_1.0_1.0.7.json | 3 ++ tests/smart/modules/test_childlock.py | 44 +++++++++++++++++++ 6 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 kasa/smart/modules/childlock.py create mode 100644 tests/smart/modules/test_childlock.py diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 3db1a2c99..3756cb956 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -448,7 +448,7 @@ def get_component_requests(component_id, ver_code): "battery": [SmartRequest.get_raw_request("getBatteryInfo")], "consumables": [SmartRequest.get_raw_request("getConsumablesInfo")], "direction_control": [], - "button_and_led": [], + "button_and_led": [SmartRequest.get_raw_request("getChildLockInfo")], "speaker": [ SmartRequest.get_raw_request("getSupportVoiceLanguage"), SmartRequest.get_raw_request("getCurrentVoiceLanguage"), diff --git a/kasa/module.py b/kasa/module.py index c477dbedc..506509654 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -152,6 +152,7 @@ class Module(ABC): ChildProtection: Final[ModuleName[smart.ChildProtection]] = ModuleName( "ChildProtection" ) + ChildLock: Final[ModuleName[smart.ChildLock]] = ModuleName("ChildLock") TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs") HomeKit: Final[ModuleName[smart.HomeKit]] = ModuleName("HomeKit") diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 48378a575..a17859e4a 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -6,6 +6,7 @@ from .batterysensor import BatterySensor from .brightness import Brightness from .childdevice import ChildDevice +from .childlock import ChildLock from .childprotection import ChildProtection from .clean import Clean from .cloud import Cloud @@ -45,6 +46,7 @@ "Energy", "DeviceModule", "ChildDevice", + "ChildLock", "BatterySensor", "HumiditySensor", "TemperatureSensor", diff --git a/kasa/smart/modules/childlock.py b/kasa/smart/modules/childlock.py new file mode 100644 index 000000000..1c5e72d9e --- /dev/null +++ b/kasa/smart/modules/childlock.py @@ -0,0 +1,37 @@ +"""Child lock module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class ChildLock(SmartModule): + """Implementation for child lock.""" + + REQUIRED_COMPONENT = "button_and_led" + QUERY_GETTER_NAME = "getChildLockInfo" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + device=self._device, + id="child_lock", + name="Child lock", + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Config, + ) + ) + + @property + def enabled(self) -> bool: + """Return True if child lock is enabled.""" + return self.data["child_lock_status"] + + async def set_enabled(self, enabled: bool) -> dict: + """Set child lock.""" + return await self.call("setChildLockInfo", {"child_lock_status": enabled}) diff --git a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json index 2f945c948..c978f89c9 100644 --- a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json +++ b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json @@ -165,6 +165,9 @@ "getCarpetClean": { "carpet_clean_prefer": "boost" }, + "getChildLockInfo": { + "child_lock_status": false + }, "getCleanAttr": { "cistern": 2, "clean_number": 1, diff --git a/tests/smart/modules/test_childlock.py b/tests/smart/modules/test_childlock.py new file mode 100644 index 000000000..2ffa91045 --- /dev/null +++ b/tests/smart/modules/test_childlock.py @@ -0,0 +1,44 @@ +import pytest + +from kasa import Module +from kasa.smart.modules import ChildLock + +from ...device_fixtures import parametrize + +childlock = parametrize( + "has child lock", + component_filter="button_and_led", + protocol_filter={"SMART"}, +) + + +@childlock +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("child_lock", "enabled", bool), + ], +) +async def test_features(dev, feature, prop_name, type): + """Test that features are registered and work as expected.""" + protect: ChildLock = dev.modules[Module.ChildLock] + assert protect is not None + + prop = getattr(protect, prop_name) + assert isinstance(prop, type) + + feat = protect._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@childlock +async def test_enabled(dev): + """Test the API.""" + protect: ChildLock = dev.modules[Module.ChildLock] + assert protect is not None + + assert isinstance(protect.enabled, bool) + await protect.set_enabled(False) + await dev.update() + assert protect.enabled is False From fd6067e5a0d6b9dd7c9429ca4577912327d77d16 Mon Sep 17 00:00:00 2001 From: DawidPietrykowski <53954695+DawidPietrykowski@users.noreply.github.com> Date: Sat, 18 Jan 2025 13:58:26 +0100 Subject: [PATCH 818/892] Add smartcam pet detection toggle module (#1465) --- kasa/smartcam/modules/__init__.py | 2 + kasa/smartcam/modules/petdetection.py | 49 +++++++++++++++++++++ kasa/smartcam/smartcammodule.py | 3 ++ tests/smartcam/modules/test_petdetection.py | 45 +++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 kasa/smartcam/modules/petdetection.py create mode 100644 tests/smartcam/modules/test_petdetection.py diff --git a/kasa/smartcam/modules/__init__.py b/kasa/smartcam/modules/__init__.py index 06130a374..14bd24f1e 100644 --- a/kasa/smartcam/modules/__init__.py +++ b/kasa/smartcam/modules/__init__.py @@ -13,6 +13,7 @@ from .motiondetection import MotionDetection from .pantilt import PanTilt from .persondetection import PersonDetection +from .petdetection import PetDetection from .tamperdetection import TamperDetection from .time import Time @@ -26,6 +27,7 @@ "Led", "PanTilt", "PersonDetection", + "PetDetection", "Time", "HomeKit", "Matter", diff --git a/kasa/smartcam/modules/petdetection.py b/kasa/smartcam/modules/petdetection.py new file mode 100644 index 000000000..2c7162304 --- /dev/null +++ b/kasa/smartcam/modules/petdetection.py @@ -0,0 +1,49 @@ +"""Implementation of pet detection module.""" + +from __future__ import annotations + +import logging + +from ...feature import Feature +from ...smart.smartmodule import allow_update_after +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class PetDetection(SmartCamModule): + """Implementation of pet detection module.""" + + REQUIRED_COMPONENT = "petDetection" + + QUERY_GETTER_NAME = "getPetDetectionConfig" + QUERY_MODULE_NAME = "pet_detection" + QUERY_SECTION_NAMES = "detection" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="pet_detection", + name="Pet detection", + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Config, + ) + ) + + @property + def enabled(self) -> bool: + """Return the pet detection enabled state.""" + return self.data["detection"]["enabled"] == "on" + + @allow_update_after + async def set_enabled(self, enable: bool) -> dict: + """Set the pet detection enabled state.""" + params = {"enabled": "on" if enable else "off"} + return await self._device._query_setter_helper( + "setPetDetectionConfig", self.QUERY_MODULE_NAME, "detection", params + ) diff --git a/kasa/smartcam/smartcammodule.py b/kasa/smartcam/smartcammodule.py index 7b85680e5..ef00d47dc 100644 --- a/kasa/smartcam/smartcammodule.py +++ b/kasa/smartcam/smartcammodule.py @@ -26,6 +26,9 @@ class SmartCamModule(SmartModule): SmartCamPersonDetection: Final[ModuleName[modules.PersonDetection]] = ModuleName( "PersonDetection" ) + SmartCamPetDetection: Final[ModuleName[modules.PetDetection]] = ModuleName( + "PetDetection" + ) SmartCamTamperDetection: Final[ModuleName[modules.TamperDetection]] = ModuleName( "TamperDetection" ) diff --git a/tests/smartcam/modules/test_petdetection.py b/tests/smartcam/modules/test_petdetection.py new file mode 100644 index 000000000..6eff0c8af --- /dev/null +++ b/tests/smartcam/modules/test_petdetection.py @@ -0,0 +1,45 @@ +"""Tests for smartcam pet detection module.""" + +from __future__ import annotations + +from kasa import Device +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...device_fixtures import parametrize + +petdetection = parametrize( + "has pet detection", + component_filter="petDetection", + protocol_filter={"SMARTCAM"}, +) + + +@petdetection +async def test_petdetection(dev: Device): + """Test device pet detection.""" + pet = dev.modules.get(SmartCamModule.SmartCamPetDetection) + assert pet + + pde_feat = dev.features.get("pet_detection") + assert pde_feat + + original_enabled = pet.enabled + + try: + await pet.set_enabled(not original_enabled) + await dev.update() + assert pet.enabled is not original_enabled + assert pde_feat.value is not original_enabled + + await pet.set_enabled(original_enabled) + await dev.update() + assert pet.enabled is original_enabled + assert pde_feat.value is original_enabled + + await pde_feat.set_value(not original_enabled) + await dev.update() + assert pet.enabled is not original_enabled + assert pde_feat.value is not original_enabled + + finally: + await pet.set_enabled(original_enabled) From 2d26f919813bfa792bee398c12eec847339eb7d7 Mon Sep 17 00:00:00 2001 From: DawidPietrykowski <53954695+DawidPietrykowski@users.noreply.github.com> Date: Sat, 18 Jan 2025 14:22:53 +0100 Subject: [PATCH 819/892] Add C220(EU) 1.0 1.2.2 camera fixture (#1466) --- README.md | 2 +- SUPPORTED.md | 2 + .../fixtures/smartcam/C220(EU)_1.0_1.2.2.json | 1234 +++++++++++++++++ 3 files changed, 1237 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smartcam/C220(EU)_1.0_1.2.2.json diff --git a/README.md b/README.md index 32d7c6a0a..c40e66663 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: S210, S220, S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Cameras**: C100, C210, C225, C325WB, C520WS, C720, D230, TC65, TC70 +- **Cameras**: C100, C210, C220, C225, C325WB, C520WS, C720, D230, TC65, TC70 - **Hubs**: H100, H200 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 - **Vacuums**: RV20 Max Plus diff --git a/SUPPORTED.md b/SUPPORTED.md index 8dc319d2d..8785d48ef 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -275,6 +275,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 2.0 / Firmware: 1.3.11 - Hardware: 2.0 (EU) / Firmware: 1.4.2 - Hardware: 2.0 (EU) / Firmware: 1.4.3 +- **C220** + - Hardware: 1.0 (EU) / Firmware: 1.2.2 - **C225** - Hardware: 2.0 (US) / Firmware: 1.0.11 - **C325WB** diff --git a/tests/fixtures/smartcam/C220(EU)_1.0_1.2.2.json b/tests/fixtures/smartcam/C220(EU)_1.0_1.2.2.json new file mode 100644 index 000000000..617acd742 --- /dev/null +++ b/tests/fixtures/smartcam/C220(EU)_1.0_1.2.2.json @@ -0,0 +1,1234 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "0", + "last_alarm_type": "", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C220", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.2.2 Build 240914 Rel.55174n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "B0-19-21-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "2", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "light", + "sound" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "linecrossingDetection", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "patrol", + "version": 1 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "meowDetection", + "version": 1 + }, + { + "name": "barkDetection", + "version": 1 + }, + { + "name": "glassDetection", + "version": 1 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "panoramicView", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "smartTrack", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "staticIp", + "version": 2 + }, + { + "name": "snapshot", + "version": 2 + }, + { + "name": "timeFormat", + "version": 1 + }, + { + "name": "upnpc", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "factory_noise_cancelling": "off", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getBarkDetectionConfig": { + "bark_detection": { + "detection": { + "enabled": "off", + "sensitivity": "50" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-01-18 13:54:46", + "seconds_from_1970": 1737204886 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -37, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "high", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c212", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C220 1.0 IPC", + "device_model": "C220", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "B0-19-21-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "no_rtsp_constrain": 1, + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.2.2 Build 240914 Rel.55174n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getGlassDetectionConfig": { + "glass_detection": { + "detection": { + "enabled": "off", + "sensitivity": "50" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "0", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMeowDetectionConfig": { + "meow_detection": { + "detection": { + "enabled": "on", + "sensitivity": "50" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [ + "1", + "2" + ], + "name": [ + "Viewpoint 1", + "Viewpoint 2" + ], + "position_pan": [ + "-0.122544", + "0.172182" + ], + "position_tilt": [ + "1.000000", + "1.000000" + ], + "position_zoom": [], + "read_only": [ + "0", + "0" + ] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "total_space_accurate": "0B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "0B", + "video_total_space_accurate": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+01:00", + "timing_mode": "manual", + "zone_id": "Europe/Sarajevo" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1536", + "2048", + "2560" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2560*1440", + "1920*1080" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "2560", + "bitrate_type": "vbr", + "default_bitrate": "2560", + "encode_type": "H264", + "frame_rate": "65561", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2560*1440", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8" + ], + "system_volume": "80", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "factory_noise_cancelling": "off", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "ptz": "1", + "record_max_slot_cnt": "6", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "0", + "smart_codec": "0", + "smart_detection": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "true" + } + } + } +} From bca5576425082812cf1f02633cd67ee406d211c1 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Mon, 20 Jan 2025 11:36:06 +0100 Subject: [PATCH 820/892] Add support for pairing devices with hubs (#859) Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --- kasa/cli/hub.py | 96 +++++++++++++++++++ kasa/cli/main.py | 1 + kasa/feature.py | 3 +- kasa/module.py | 1 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/childsetup.py | 84 ++++++++++++++++ kasa/smart/smartdevice.py | 15 +++ tests/cli/__init__.py | 0 tests/cli/test_hub.py | 53 ++++++++++ tests/conftest.py | 13 +++ tests/fakeprotocol_smart.py | 32 ++++++- tests/fixtures/smart/H100(EU)_1.0_1.5.10.json | 18 ++++ tests/smart/modules/test_childsetup.py | 69 +++++++++++++ tests/smart/test_smartdevice.py | 21 ++++ tests/test_cli.py | 10 -- tests/test_feature.py | 9 +- 16 files changed, 412 insertions(+), 15 deletions(-) create mode 100644 kasa/cli/hub.py create mode 100644 kasa/smart/modules/childsetup.py create mode 100644 tests/cli/__init__.py create mode 100644 tests/cli/test_hub.py create mode 100644 tests/smart/modules/test_childsetup.py diff --git a/kasa/cli/hub.py b/kasa/cli/hub.py new file mode 100644 index 000000000..444781326 --- /dev/null +++ b/kasa/cli/hub.py @@ -0,0 +1,96 @@ +"""Hub-specific commands.""" + +import asyncio + +import asyncclick as click + +from kasa import DeviceType, Module, SmartDevice +from kasa.smart import SmartChildDevice + +from .common import ( + echo, + error, + pass_dev, +) + + +def pretty_category(cat: str): + """Return pretty category for paired devices.""" + return SmartChildDevice.CHILD_DEVICE_TYPE_MAP.get(cat) + + +@click.group() +@pass_dev +async def hub(dev: SmartDevice): + """Commands controlling hub child device pairing.""" + if dev.device_type is not DeviceType.Hub: + error(f"{dev} is not a hub.") + + if dev.modules.get(Module.ChildSetup) is None: + error(f"{dev} does not have child setup module.") + + +@hub.command(name="list") +@pass_dev +async def hub_list(dev: SmartDevice): + """List hub paired child devices.""" + for c in dev.children: + echo(f"{c.device_id}: {c}") + + +@hub.command(name="supported") +@pass_dev +async def hub_supported(dev: SmartDevice): + """List supported hub child device categories.""" + cs = dev.modules[Module.ChildSetup] + + cats = [cat["category"] for cat in await cs.get_supported_device_categories()] + for cat in cats: + echo(f"Supports: {cat}") + + +@hub.command(name="pair") +@click.option("--timeout", default=10) +@pass_dev +async def hub_pair(dev: SmartDevice, timeout: int): + """Pair all pairable device. + + This will pair any child devices currently in pairing mode. + """ + cs = dev.modules[Module.ChildSetup] + + echo(f"Finding new devices for {timeout} seconds...") + + pair_res = await cs.pair(timeout=timeout) + if not pair_res: + echo("No devices found.") + + for child in pair_res: + echo( + f'Paired {child["name"]} ({child["device_model"]}, ' + f'{pretty_category(child["category"])}) with id {child["device_id"]}' + ) + + +@hub.command(name="unpair") +@click.argument("device_id") +@pass_dev +async def hub_unpair(dev, device_id: str): + """Unpair given device.""" + cs = dev.modules[Module.ChildSetup] + + # Accessing private here, as the property exposes only values + if device_id not in dev._children: + error(f"{dev} does not have children with identifier {device_id}") + + res = await cs.unpair(device_id=device_id) + # Give the device some time to update its internal state, just in case. + await asyncio.sleep(1) + await dev.update() + + if device_id not in dev._children: + echo(f"Unpaired {device_id}") + else: + error(f"Failed to unpair {device_id}") + + return res diff --git a/kasa/cli/main.py b/kasa/cli/main.py index debde60c4..9e0487dab 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -93,6 +93,7 @@ def _legacy_type_to_class(_type: str) -> Any: "hsv": "light", "temperature": "light", "effect": "light", + "hub": "hub", }, result_callback=json_formatter_cb, ) diff --git a/kasa/feature.py b/kasa/feature.py index 456a3e631..ad9187392 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -76,6 +76,7 @@ if TYPE_CHECKING: from .device import Device + from .module import Module _LOGGER = logging.getLogger(__name__) @@ -142,7 +143,7 @@ class Category(Enum): #: Callable coroutine or name of the method that allows changing the value attribute_setter: str | Callable[..., Coroutine[Any, Any, Any]] | None = None #: Container storing the data, this overrides 'device' for getters - container: Any = None + container: Device | Module | None = None #: Icon suggestion icon: str | None = None #: Attribute containing the name of the unit getter property. diff --git a/kasa/module.py b/kasa/module.py index 506509654..8a7603317 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -154,6 +154,7 @@ class Module(ABC): ) ChildLock: Final[ModuleName[smart.ChildLock]] = ModuleName("ChildLock") TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs") + ChildSetup: Final[ModuleName[smart.ChildSetup]] = ModuleName("ChildSetup") HomeKit: Final[ModuleName[smart.HomeKit]] = ModuleName("HomeKit") Matter: Final[ModuleName[smart.Matter]] = ModuleName("Matter") diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index a17859e4a..e0da95a7a 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -8,6 +8,7 @@ from .childdevice import ChildDevice from .childlock import ChildLock from .childprotection import ChildProtection +from .childsetup import ChildSetup from .clean import Clean from .cloud import Cloud from .color import Color @@ -47,6 +48,7 @@ "DeviceModule", "ChildDevice", "ChildLock", + "ChildSetup", "BatterySensor", "HumiditySensor", "TemperatureSensor", diff --git a/kasa/smart/modules/childsetup.py b/kasa/smart/modules/childsetup.py new file mode 100644 index 000000000..04444e2e9 --- /dev/null +++ b/kasa/smart/modules/childsetup.py @@ -0,0 +1,84 @@ +"""Implementation for child device setup. + +This module allows pairing and disconnecting child devices. +""" + +from __future__ import annotations + +import asyncio +import logging + +from ...feature import Feature +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +class ChildSetup(SmartModule): + """Implementation for child device setup.""" + + REQUIRED_COMPONENT = "child_quick_setup" + QUERY_GETTER_NAME = "get_support_child_device_category" + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="pair", + name="Pair", + container=self, + attribute_setter="pair", + category=Feature.Category.Config, + type=Feature.Type.Action, + ) + ) + + async def get_supported_device_categories(self) -> list[dict]: + """Get supported device categories.""" + categories = await self.call("get_support_child_device_category") + return categories["get_support_child_device_category"]["device_category_list"] + + async def pair(self, *, timeout: int = 10) -> list[dict]: + """Scan for new devices and pair after discovering first new device.""" + await self.call("begin_scanning_child_device") + + _LOGGER.info("Waiting %s seconds for discovering new devices", timeout) + await asyncio.sleep(timeout) + detected = await self._get_detected_devices() + + if not detected["child_device_list"]: + _LOGGER.info("No devices found.") + return [] + + _LOGGER.info( + "Discovery done, found %s devices: %s", + len(detected["child_device_list"]), + detected, + ) + + await self._add_devices(detected) + + return detected["child_device_list"] + + async def unpair(self, device_id: str) -> dict: + """Remove device from the hub.""" + _LOGGER.debug("Going to unpair %s from %s", device_id, self) + + payload = {"child_device_list": [{"device_id": device_id}]} + return await self.call("remove_child_device_list", payload) + + async def _add_devices(self, devices: dict) -> dict: + """Add devices based on get_detected_device response. + + Pass the output from :ref:_get_detected_devices: as a parameter. + """ + res = await self.call("add_child_device_list", devices) + return res + + async def _get_detected_devices(self) -> dict: + """Return list of devices detected during scanning.""" + param = {"scan_list": await self.get_supported_device_categories()} + res = await self.call("get_scan_child_device_list", param) + _LOGGER.debug("Scan status: %s", res) + return res["get_scan_child_device_list"] diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 6c2e2227a..6f9ebd80e 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -537,6 +537,21 @@ async def _initialize_features(self) -> None: ) ) + if self.parent is not None and ( + cs := self.parent.modules.get(Module.ChildSetup) + ): + self._add_feature( + Feature( + device=self, + id="unpair", + name="Unpair device", + container=cs, + attribute_setter=lambda: cs.unpair(self.device_id), + category=Feature.Category.Debug, + type=Feature.Type.Action, + ) + ) + for module in self.modules.values(): module._initialize_features() for feat in module._module_features.values(): diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cli/test_hub.py b/tests/cli/test_hub.py new file mode 100644 index 000000000..5236f4cda --- /dev/null +++ b/tests/cli/test_hub.py @@ -0,0 +1,53 @@ +import pytest +from pytest_mock import MockerFixture + +from kasa import DeviceType, Module +from kasa.cli.hub import hub + +from ..device_fixtures import HUBS_SMART, hubs_smart, parametrize, plug_iot + + +@hubs_smart +async def test_hub_pair(dev, mocker: MockerFixture, runner, caplog): + """Test that pair calls the expected methods.""" + cs = dev.modules.get(Module.ChildSetup) + # Patch if the device supports the module + if cs is not None: + mock_pair = mocker.patch.object(cs, "pair") + + res = await runner.invoke(hub, ["pair"], obj=dev, catch_exceptions=False) + if cs is None: + assert "is not a hub" in res.output + return + + mock_pair.assert_awaited() + assert "Finding new devices for 10 seconds" in res.output + assert res.exit_code == 0 + + +@parametrize("hubs smart", model_filter=HUBS_SMART, protocol_filter={"SMART"}) +async def test_hub_unpair(dev, mocker: MockerFixture, runner): + """Test that unpair calls the expected method.""" + if not dev.children: + pytest.skip("Cannot test without child devices") + + id_ = next(iter(dev.children)).device_id + + cs = dev.modules.get(Module.ChildSetup) + mock_unpair = mocker.spy(cs, "unpair") + + res = await runner.invoke(hub, ["unpair", id_], obj=dev, catch_exceptions=False) + + mock_unpair.assert_awaited() + assert f"Unpaired {id_}" in res.output + assert res.exit_code == 0 + + +@plug_iot +async def test_non_hub(dev, mocker: MockerFixture, runner): + """Test that hub commands return an error if executed on a non-hub.""" + assert dev.device_type is not DeviceType.Hub + res = await runner.invoke( + hub, ["unpair", "dummy_id"], obj=dev, catch_exceptions=False + ) + assert "is not a hub" in res.output diff --git a/tests/conftest.py b/tests/conftest.py index 3da689c5b..6162d3af2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import os import sys import warnings from pathlib import Path @@ -8,6 +9,9 @@ import pytest +# TODO: this and runner fixture could be moved to tests/cli/conftest.py +from asyncclick.testing import CliRunner + from kasa import ( DeviceConfig, SmartProtocol, @@ -149,3 +153,12 @@ async def _create_datagram_endpoint(protocol_factory, *_, **__): side_effect=_create_datagram_endpoint, ): yield + + +@pytest.fixture +def runner(): + """Runner fixture that unsets the KASA_ environment variables for tests.""" + KASA_VARS = {k: None for k, v in os.environ.items() if k.startswith("KASA_")} + runner = CliRunner(env=KASA_VARS) + + return runner diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index 532328153..d8d8cb40c 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -171,6 +171,16 @@ def credentials_hash(self): "setup_payload": "00:0000000-0000.00.000", }, ), + # child setup + "get_support_child_device_category": ( + "child_quick_setup", + {"device_category_list": [{"category": "subg.trv"}]}, + ), + # no devices found + "get_scan_child_device_list": ( + "child_quick_setup", + {"child_device_list": [{"dummy": "response"}], "scan_status": "idle"}, + ), } def _missing_result(self, method): @@ -548,6 +558,17 @@ def _update_sysinfo_key(self, info: dict, key: str, value: str) -> dict: return {"error_code": 0} + def _hub_remove_device(self, info, params): + """Remove hub device.""" + items_to_remove = [dev["device_id"] for dev in params["child_device_list"]] + children = info["get_child_device_list"]["child_device_list"] + new_children = [ + dev for dev in children if dev["device_id"] not in items_to_remove + ] + info["get_child_device_list"]["child_device_list"] = new_children + + return {"error_code": 0} + def get_child_device_queries(self, method, params): return self._get_method_from_info(method, params) @@ -658,8 +679,15 @@ async def _send_request(self, request_dict: dict): return self._set_on_off_gradually_info(info, params) elif method == "set_child_protection": return self._update_sysinfo_key(info, "child_protection", params["enable"]) - # Vacuum special actions - elif method in ["playSelectAudio"]: + elif method == "remove_child_device_list": + return self._hub_remove_device(info, params) + # actions + elif method in [ + "begin_scanning_child_device", # hub pairing + "add_child_device_list", # hub pairing + "remove_child_device_list", # hub pairing + "playSelectAudio", # vacuum special actions + ]: return {"error_code": 0} elif method[:3] == "set": target_method = f"get{method[3:]}" diff --git a/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json b/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json index 8173333a7..4e0e5258f 100644 --- a/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json +++ b/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json @@ -472,6 +472,24 @@ "setup_code": "00000000000", "setup_payload": "00:0000000000000000000" }, + "get_scan_child_device_list": { + "child_device_list": [ + { + "category": "subg.trigger.temp-hmdt-sensor", + "device_id": "REDACTED_1", + "device_model": "T315", + "name": "REDACTED_1" + }, + { + "category": "subg.trigger.contact-sensor", + "device_id": "REDACTED_2", + "device_model": "T110", + "name": "REDACTED_2" + } + ], + "scan_status": "scanning", + "scan_wait_time": 28 + }, "get_support_alarm_type_list": { "alarm_type_list": [ "Doorbell Ring 1", diff --git a/tests/smart/modules/test_childsetup.py b/tests/smart/modules/test_childsetup.py new file mode 100644 index 000000000..df3905a64 --- /dev/null +++ b/tests/smart/modules/test_childsetup.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import logging + +import pytest +from pytest_mock import MockerFixture + +from kasa import Feature, Module, SmartDevice + +from ...device_fixtures import parametrize + +childsetup = parametrize( + "supports pairing", component_filter="child_quick_setup", protocol_filter={"SMART"} +) + + +@childsetup +async def test_childsetup_features(dev: SmartDevice): + """Test the exposed features.""" + cs = dev.modules.get(Module.ChildSetup) + assert cs + + assert "pair" in cs._module_features + pair = cs._module_features["pair"] + assert pair.type == Feature.Type.Action + + +@childsetup +async def test_childsetup_pair( + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """Test device pairing.""" + caplog.set_level(logging.INFO) + mock_query_helper = mocker.spy(dev, "_query_helper") + mocker.patch("asyncio.sleep") + + cs = dev.modules.get(Module.ChildSetup) + assert cs + + await cs.pair() + + mock_query_helper.assert_has_awaits( + [ + mocker.call("begin_scanning_child_device", None), + mocker.call("get_support_child_device_category", None), + mocker.call("get_scan_child_device_list", params=mocker.ANY), + mocker.call("add_child_device_list", params=mocker.ANY), + ] + ) + assert "Discovery done" in caplog.text + + +@childsetup +async def test_childsetup_unpair( + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """Test unpair.""" + mock_query_helper = mocker.spy(dev, "_query_helper") + DUMMY_ID = "dummy_id" + + cs = dev.modules.get(Module.ChildSetup) + assert cs + + await cs.unpair(DUMMY_ID) + + mock_query_helper.assert_awaited_with( + "remove_child_device_list", + params={"child_device_list": [{"device_id": DUMMY_ID}]}, + ) diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index 00d432724..8a540e7d4 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -988,3 +988,24 @@ def mock_get_child_device_queries(method, params): await dev.update() assert "Could not find child id for device" not in caplog.text + + +@hubs_smart +async def test_unpair(dev: SmartDevice, mocker: MockerFixture): + """Verify that unpair calls childsetup module.""" + if not dev.children: + pytest.skip("device has no children") + + child = dev.children[0] + + assert child.parent is not None + assert Module.ChildSetup in dev.modules + cs = dev.modules[Module.ChildSetup] + + unpair_call = mocker.spy(cs, "unpair") + + unpair_feat = child.features.get("unpair") + assert unpair_feat + await unpair_feat.set_value(None) + + unpair_call.assert_called_with(child.device_id) diff --git a/tests/test_cli.py b/tests/test_cli.py index 1b589f5c8..2f9075028 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,4 @@ import json -import os import re from datetime import datetime from unittest.mock import ANY, PropertyMock, patch @@ -62,15 +61,6 @@ pytestmark = [pytest.mark.requires_dummy] -@pytest.fixture -def runner(): - """Runner fixture that unsets the KASA_ environment variables for tests.""" - KASA_VARS = {k: None for k, v in os.environ.items() if k.startswith("KASA_")} - runner = CliRunner(env=KASA_VARS) - - return runner - - async def test_help(runner): """Test that all the lazy modules are correctly names.""" res = await runner.invoke(cli, ["--help"]) diff --git a/tests/test_feature.py b/tests/test_feature.py index 46cdd116c..33a07106c 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -74,7 +74,7 @@ class DummyContainer: def test_prop(self): return "dummy" - dummy_feature.container = DummyContainer() + dummy_feature.container = DummyContainer() # type: ignore[assignment] dummy_feature.attribute_getter = "test_prop" mock_dev_prop = mocker.patch.object( @@ -191,7 +191,12 @@ async def _test_features(dev): exceptions = [] for feat in dev.features.values(): try: - with patch.object(feat.device.protocol, "query") as query: + prot = ( + feat.container._device.protocol + if feat.container + else feat.device.protocol + ) + with patch.object(prot, "query", name=feat.id) as query: await _test_feature(feat, query) # we allow our own exceptions to avoid mocking valid responses except KasaException: From 05085462d3949e27893c93df1c8537c6814943ca Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Mon, 20 Jan 2025 12:41:56 +0100 Subject: [PATCH 821/892] Add support for cleaning records (#945) Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --- docs/tutorial.py | 2 +- kasa/cli/main.py | 1 + kasa/cli/vacuum.py | 53 +++++ kasa/feature.py | 8 +- kasa/module.py | 1 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/cleanrecords.py | 205 ++++++++++++++++++ kasa/smart/smartdevice.py | 10 +- tests/cli/test_vacuum.py | 61 ++++++ .../smart/RV20 Max Plus(EU)_1.0_1.0.7.json | 58 ++++- tests/smart/modules/test_cleanrecords.py | 59 +++++ tests/smart/test_smartdevice.py | 3 +- 12 files changed, 448 insertions(+), 15 deletions(-) create mode 100644 kasa/cli/vacuum.py create mode 100644 kasa/smart/modules/cleanrecords.py create mode 100644 tests/cli/test_vacuum.py create mode 100644 tests/smart/modules/test_cleanrecords.py diff --git a/docs/tutorial.py b/docs/tutorial.py index 76094abb9..fddcc79a6 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -91,5 +91,5 @@ True >>> for feat in dev.features.values(): >>> print(f"{feat.name}: {feat.value}") -Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nReboot: \nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: \nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nOverheated: False\nDevice time: 2024-02-23 02:40:15+01:00 +Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nReboot: \nDevice time: 2024-02-23 02:40:15+01:00\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: \nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nOverheated: False """ diff --git a/kasa/cli/main.py b/kasa/cli/main.py index 9e0487dab..4f1eccda9 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -93,6 +93,7 @@ def _legacy_type_to_class(_type: str) -> Any: "hsv": "light", "temperature": "light", "effect": "light", + "vacuum": "vacuum", "hub": "hub", }, result_callback=json_formatter_cb, diff --git a/kasa/cli/vacuum.py b/kasa/cli/vacuum.py new file mode 100644 index 000000000..cb0aaad51 --- /dev/null +++ b/kasa/cli/vacuum.py @@ -0,0 +1,53 @@ +"""Module for cli vacuum commands..""" + +from __future__ import annotations + +import asyncclick as click + +from kasa import ( + Device, + Module, +) + +from .common import ( + error, + pass_dev_or_child, +) + + +@click.group(invoke_without_command=False) +@click.pass_context +async def vacuum(ctx: click.Context) -> None: + """Vacuum commands.""" + + +@vacuum.group(invoke_without_command=True, name="records") +@pass_dev_or_child +async def records_group(dev: Device) -> None: + """Access cleaning records.""" + if not (rec := dev.modules.get(Module.CleanRecords)): + error("This device does not support records.") + + data = rec.parsed_data + latest = data.last_clean + click.echo( + f"Totals: {rec.total_clean_area} {rec.area_unit} in {rec.total_clean_time} " + f"(cleaned {rec.total_clean_count} times)" + ) + click.echo(f"Last clean: {latest.clean_area} {rec.area_unit} @ {latest.clean_time}") + click.echo("Execute `kasa vacuum records list` to list all records.") + + +@records_group.command(name="list") +@pass_dev_or_child +async def records_list(dev: Device) -> None: + """List all cleaning records.""" + if not (rec := dev.modules.get(Module.CleanRecords)): + error("This device does not support records.") + + data = rec.parsed_data + for record in data.records: + click.echo( + f"* {record.timestamp}: cleaned {record.clean_area} {rec.area_unit}" + f" in {record.clean_time}" + ) diff --git a/kasa/feature.py b/kasa/feature.py index ad9187392..3c6beb0de 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -25,6 +25,7 @@ RSSI (rssi): -52 SSID (ssid): #MASKED_SSID# Reboot (reboot): +Device time (device_time): 2024-02-23 02:40:15+01:00 Brightness (brightness): 100 Cloud connection (cloud_connection): True HSV (hsv): HSV(hue=0, saturation=100, value=100) @@ -39,7 +40,6 @@ Smooth transition on (smooth_transition_on): 2 Smooth transition off (smooth_transition_off): 2 Overheated (overheated): False -Device time (device_time): 2024-02-23 02:40:15+01:00 To see whether a device supports a feature, check for the existence of it: @@ -299,8 +299,10 @@ def __repr__(self) -> str: if isinstance(value, Enum): value = repr(value) s = f"{self.name} ({self.id}): {value}" - if self.unit is not None: - s += f" {self.unit}" + if (unit := self.unit) is not None: + if isinstance(unit, Enum): + unit = repr(unit) + s += f" {unit}" if self.type == Feature.Type.Number: s += f" (range: {self.minimum_value}-{self.maximum_value})" diff --git a/kasa/module.py b/kasa/module.py index 8a7603317..f18dc6b12 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -168,6 +168,7 @@ class Module(ABC): Dustbin: Final[ModuleName[smart.Dustbin]] = ModuleName("Dustbin") Speaker: Final[ModuleName[smart.Speaker]] = ModuleName("Speaker") Mop: Final[ModuleName[smart.Mop]] = ModuleName("Mop") + CleanRecords: Final[ModuleName[smart.CleanRecords]] = ModuleName("CleanRecords") def __init__(self, device: Device, module: str) -> None: self._device = device diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index e0da95a7a..6717fcc34 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -10,6 +10,7 @@ from .childprotection import ChildProtection from .childsetup import ChildSetup from .clean import Clean +from .cleanrecords import CleanRecords from .cloud import Cloud from .color import Color from .colortemperature import ColorTemperature @@ -75,6 +76,7 @@ "FrostProtection", "Thermostat", "Clean", + "CleanRecords", "SmartLightEffect", "OverheatProtection", "Speaker", diff --git a/kasa/smart/modules/cleanrecords.py b/kasa/smart/modules/cleanrecords.py new file mode 100644 index 000000000..fdd0daeec --- /dev/null +++ b/kasa/smart/modules/cleanrecords.py @@ -0,0 +1,205 @@ +"""Implementation of vacuum cleaning records.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import datetime, timedelta, tzinfo +from typing import Annotated, cast + +from mashumaro import DataClassDictMixin, field_options +from mashumaro.config import ADD_DIALECT_SUPPORT +from mashumaro.dialect import Dialect +from mashumaro.types import SerializationStrategy + +from ...feature import Feature +from ...module import FeatureAttribute +from ..smartmodule import Module, SmartModule +from .clean import AreaUnit, Clean + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class Record(DataClassDictMixin): + """Historical cleanup result.""" + + class Config: + """Configuration class.""" + + code_generation_options = [ADD_DIALECT_SUPPORT] + + #: Total time cleaned (in minutes) + clean_time: timedelta = field( + metadata=field_options(deserialize=lambda x: timedelta(minutes=x)) + ) + #: Total area cleaned + clean_area: int + dust_collection: bool + timestamp: datetime + + info_num: int | None = None + message: int | None = None + map_id: int | None = None + start_type: int | None = None + task_type: int | None = None + record_index: int | None = None + + #: Error code from cleaning + error: int = field(default=0) + + +class _DateTimeSerializationStrategy(SerializationStrategy): + def __init__(self, tz: tzinfo) -> None: + self.tz = tz + + def deserialize(self, value: float) -> datetime: + return datetime.fromtimestamp(value, self.tz) + + +def _get_tz_strategy(tz: tzinfo) -> type[Dialect]: + """Return a timezone aware de-serialization strategy.""" + + class TimezoneDialect(Dialect): + serialization_strategy = {datetime: _DateTimeSerializationStrategy(tz)} + + return TimezoneDialect + + +@dataclass +class Records(DataClassDictMixin): + """Response payload for getCleanRecords.""" + + class Config: + """Configuration class.""" + + code_generation_options = [ADD_DIALECT_SUPPORT] + + total_time: timedelta = field( + metadata=field_options(deserialize=lambda x: timedelta(minutes=x)) + ) + total_area: int + total_count: int = field(metadata=field_options(alias="total_number")) + + records: list[Record] = field(metadata=field_options(alias="record_list")) + last_clean: Record = field(metadata=field_options(alias="lastest_day_record")) + + @classmethod + def __pre_deserialize__(cls, d: dict) -> dict: + if ldr := d.get("lastest_day_record"): + d["lastest_day_record"] = { + "timestamp": ldr[0], + "clean_time": ldr[1], + "clean_area": ldr[2], + "dust_collection": ldr[3], + } + return d + + +class CleanRecords(SmartModule): + """Implementation of vacuum cleaning records.""" + + REQUIRED_COMPONENT = "clean_percent" + _parsed_data: Records + + async def _post_update_hook(self) -> None: + """Cache parsed data after an update.""" + self._parsed_data = Records.from_dict( + self.data, dialect=_get_tz_strategy(self._device.timezone) + ) + + def _initialize_features(self) -> None: + """Initialize features.""" + for type_ in ["total", "last"]: + self._add_feature( + Feature( + self._device, + id=f"{type_}_clean_area", + name=f"{type_.capitalize()} area cleaned", + container=self, + attribute_getter=f"{type_}_clean_area", + unit_getter="area_unit", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id=f"{type_}_clean_time", + name=f"{type_.capitalize()} time cleaned", + container=self, + attribute_getter=f"{type_}_clean_time", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="total_clean_count", + name="Total clean count", + container=self, + attribute_getter="total_clean_count", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="last_clean_timestamp", + name="Last clean timestamp", + container=self, + attribute_getter="last_clean_timestamp", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return { + "getCleanRecords": {}, + } + + @property + def total_clean_area(self) -> Annotated[int, FeatureAttribute()]: + """Return total cleaning area.""" + return self._parsed_data.total_area + + @property + def total_clean_time(self) -> timedelta: + """Return total cleaning time.""" + return self._parsed_data.total_time + + @property + def total_clean_count(self) -> int: + """Return total clean count.""" + return self._parsed_data.total_count + + @property + def last_clean_area(self) -> Annotated[int, FeatureAttribute()]: + """Return latest cleaning area.""" + return self._parsed_data.last_clean.clean_area + + @property + def last_clean_time(self) -> timedelta: + """Return total cleaning time.""" + return self._parsed_data.last_clean.clean_time + + @property + def last_clean_timestamp(self) -> datetime: + """Return latest cleaning timestamp.""" + return self._parsed_data.last_clean.timestamp + + @property + def area_unit(self) -> AreaUnit: + """Return area unit.""" + clean = cast(Clean, self._device.modules[Module.Clean]) + return clean.area_unit + + @property + def parsed_data(self) -> Records: + """Return parsed records data.""" + return self._parsed_data diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 6f9ebd80e..ee86b0e2a 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -5,6 +5,7 @@ import base64 import logging import time +from collections import OrderedDict from collections.abc import Sequence from datetime import UTC, datetime, timedelta, tzinfo from typing import TYPE_CHECKING, Any, TypeAlias, cast @@ -66,7 +67,9 @@ def __init__( self._components_raw: ComponentsRaw | None = None self._components: dict[str, int] = {} self._state_information: dict[str, Any] = {} - self._modules: dict[str | ModuleName[Module], SmartModule] = {} + self._modules: OrderedDict[str | ModuleName[Module], SmartModule] = ( + OrderedDict() + ) self._parent: SmartDevice | None = None self._children: dict[str, SmartDevice] = {} self._last_update_time: float | None = None @@ -445,6 +448,11 @@ async def _initialize_modules(self) -> None: ): self._modules[Thermostat.__name__] = Thermostat(self, "thermostat") + # We move time to the beginning so other modules can access the + # time and timezone after update if required. e.g. cleanrecords + if Time.__name__ in self._modules: + self._modules.move_to_end(Time.__name__, last=False) + async def _initialize_features(self) -> None: """Initialize device features.""" self._add_feature( diff --git a/tests/cli/test_vacuum.py b/tests/cli/test_vacuum.py new file mode 100644 index 000000000..e5f3e68ea --- /dev/null +++ b/tests/cli/test_vacuum.py @@ -0,0 +1,61 @@ +from pytest_mock import MockerFixture + +from kasa import DeviceType, Module +from kasa.cli.vacuum import vacuum + +from ..device_fixtures import plug_iot +from ..device_fixtures import vacuum as vacuum_devices + + +@vacuum_devices +async def test_vacuum_records_group(dev, mocker: MockerFixture, runner): + """Test that vacuum records calls the expected methods.""" + rec = dev.modules.get(Module.CleanRecords) + assert rec + + res = await runner.invoke(vacuum, ["records"], obj=dev, catch_exceptions=False) + + latest = rec.parsed_data.last_clean + expected = ( + f"Totals: {rec.total_clean_area} {rec.area_unit} in {rec.total_clean_time} " + f"(cleaned {rec.total_clean_count} times)\n" + f"Last clean: {latest.clean_area} {rec.area_unit} @ {latest.clean_time}" + ) + assert expected in res.output + assert res.exit_code == 0 + + +@vacuum_devices +async def test_vacuum_records_list(dev, mocker: MockerFixture, runner): + """Test that vacuum records list calls the expected methods.""" + rec = dev.modules.get(Module.CleanRecords) + assert rec + + res = await runner.invoke( + vacuum, ["records", "list"], obj=dev, catch_exceptions=False + ) + + data = rec.parsed_data + for record in data.records: + expected = ( + f"* {record.timestamp}: cleaned {record.clean_area} {rec.area_unit}" + f" in {record.clean_time}" + ) + assert expected in res.output + assert res.exit_code == 0 + + +@plug_iot +async def test_non_vacuum(dev, mocker: MockerFixture, runner): + """Test that vacuum commands return an error if executed on a non-vacuum.""" + assert dev.device_type is not DeviceType.Vacuum + + res = await runner.invoke(vacuum, ["records"], obj=dev, catch_exceptions=False) + assert "This device does not support records" in res.output + assert res.exit_code != 0 + + res = await runner.invoke( + vacuum, ["records", "list"], obj=dev, catch_exceptions=False + ) + assert "This device does not support records" in res.output + assert res.exit_code != 0 diff --git a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json index c978f89c9..5a09c155f 100644 --- a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json +++ b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json @@ -180,16 +180,56 @@ }, "getCleanRecords": { "lastest_day_record": [ - 0, - 0, - 0, - 0 + 1736797545, + 25, + 16, + 1 + ], + "record_list": [ + { + "clean_area": 17, + "clean_time": 27, + "dust_collection": false, + "error": 0, + "info_num": 1, + "map_id": 1736598799, + "message": 1, + "record_index": 0, + "start_type": 1, + "task_type": 0, + "timestamp": 1736601522 + }, + { + "clean_area": 14, + "clean_time": 25, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1736598799, + "message": 0, + "record_index": 1, + "start_type": 1, + "task_type": 0, + "timestamp": 1736684961 + }, + { + "clean_area": 16, + "clean_time": 25, + "dust_collection": true, + "error": 0, + "info_num": 3, + "map_id": 1736598799, + "message": 0, + "record_index": 2, + "start_type": 1, + "task_type": 0, + "timestamp": 1736797545 + } ], - "record_list": [], - "record_list_num": 0, - "total_area": 0, - "total_number": 0, - "total_time": 0 + "record_list_num": 3, + "total_area": 47, + "total_number": 3, + "total_time": 77 }, "getCleanStatus": { "getCleanStatus": { diff --git a/tests/smart/modules/test_cleanrecords.py b/tests/smart/modules/test_cleanrecords.py new file mode 100644 index 000000000..cef692868 --- /dev/null +++ b/tests/smart/modules/test_cleanrecords.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +import pytest + +from kasa import Module +from kasa.smart import SmartDevice + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +cleanrecords = parametrize( + "has clean records", component_filter="clean_percent", protocol_filter={"SMART"} +) + + +@cleanrecords +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("total_clean_area", "total_clean_area", int), + ("total_clean_time", "total_clean_time", timedelta), + ("last_clean_area", "last_clean_area", int), + ("last_clean_time", "last_clean_time", timedelta), + ("total_clean_count", "total_clean_count", int), + ("last_clean_timestamp", "last_clean_timestamp", datetime), + ], +) +async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type): + """Test that features are registered and work as expected.""" + records = next(get_parent_and_child_modules(dev, Module.CleanRecords)) + assert records is not None + + prop = getattr(records, prop_name) + assert isinstance(prop, type) + + feat = records._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@cleanrecords +async def test_timezone(dev: SmartDevice): + """Test that timezone is added to timestamps.""" + clean_records = next(get_parent_and_child_modules(dev, Module.CleanRecords)) + assert clean_records is not None + + assert isinstance(clean_records.last_clean_timestamp, datetime) + assert clean_records.last_clean_timestamp.tzinfo + + # Check for zone info to ensure that this wasn't picking upthe default + # of utc before the time module is updated. + assert isinstance(clean_records.last_clean_timestamp.tzinfo, ZoneInfo) + + for record in clean_records.parsed_data.records: + assert isinstance(record.timestamp, datetime) + assert record.timestamp.tzinfo + assert isinstance(record.timestamp.tzinfo, ZoneInfo) diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index 8a540e7d4..bb6f13934 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -5,6 +5,7 @@ import copy import logging import time +from collections import OrderedDict from typing import TYPE_CHECKING, Any, cast from unittest.mock import patch @@ -100,7 +101,7 @@ async def test_initial_update(dev: SmartDevice, mocker: MockerFixture): # As the fixture data is already initialized, we reset the state for testing dev._components_raw = None dev._components = {} - dev._modules = {} + dev._modules = OrderedDict() dev._features = {} dev._children = {} dev._last_update = {} From a03a4b1d63f9cb01c0b44b9f5e3a93db7c12f968 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Mon, 20 Jan 2025 13:50:39 +0100 Subject: [PATCH 822/892] Add consumables module for vacuums (#1327) Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- kasa/cli/vacuum.py | 31 +++++ kasa/module.py | 1 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/consumables.py | 170 ++++++++++++++++++++++++ tests/cli/test_vacuum.py | 53 ++++++++ tests/fakeprotocol_smart.py | 1 + tests/smart/modules/test_consumables.py | 53 ++++++++ 7 files changed, 311 insertions(+) create mode 100644 kasa/smart/modules/consumables.py create mode 100644 tests/smart/modules/test_consumables.py diff --git a/kasa/cli/vacuum.py b/kasa/cli/vacuum.py index cb0aaad51..d0ccc55a9 100644 --- a/kasa/cli/vacuum.py +++ b/kasa/cli/vacuum.py @@ -51,3 +51,34 @@ async def records_list(dev: Device) -> None: f"* {record.timestamp}: cleaned {record.clean_area} {rec.area_unit}" f" in {record.clean_time}" ) + + +@vacuum.group(invoke_without_command=True, name="consumables") +@pass_dev_or_child +@click.pass_context +async def consumables(ctx: click.Context, dev: Device) -> None: + """List device consumables.""" + if not (cons := dev.modules.get(Module.Consumables)): + error("This device does not support consumables.") + + if not ctx.invoked_subcommand: + for c in cons.consumables.values(): + click.echo(f"{c.name} ({c.id}): {c.used} used, {c.remaining} remaining") + + +@consumables.command(name="reset") +@click.argument("consumable_id", required=True) +@pass_dev_or_child +async def reset_consumable(dev: Device, consumable_id: str) -> None: + """Reset the consumable used/remaining time.""" + cons = dev.modules[Module.Consumables] + + if consumable_id not in cons.consumables: + error( + f"Consumable {consumable_id} not found in " + f"device consumables: {', '.join(cons.consumables.keys())}." + ) + + await cons.reset_consumable(consumable_id) + + click.echo(f"Consumable {consumable_id} reset") diff --git a/kasa/module.py b/kasa/module.py index f18dc6b12..6f188b305 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -165,6 +165,7 @@ class Module(ABC): # Vacuum modules Clean: Final[ModuleName[smart.Clean]] = ModuleName("Clean") + Consumables: Final[ModuleName[smart.Consumables]] = ModuleName("Consumables") Dustbin: Final[ModuleName[smart.Dustbin]] = ModuleName("Dustbin") Speaker: Final[ModuleName[smart.Speaker]] = ModuleName("Speaker") Mop: Final[ModuleName[smart.Mop]] = ModuleName("Mop") diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 6717fcc34..9215277e4 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -14,6 +14,7 @@ from .cloud import Cloud from .color import Color from .colortemperature import ColorTemperature +from .consumables import Consumables from .contactsensor import ContactSensor from .devicemodule import DeviceModule from .dustbin import Dustbin @@ -76,6 +77,7 @@ "FrostProtection", "Thermostat", "Clean", + "Consumables", "CleanRecords", "SmartLightEffect", "OverheatProtection", diff --git a/kasa/smart/modules/consumables.py b/kasa/smart/modules/consumables.py new file mode 100644 index 000000000..10de583e8 --- /dev/null +++ b/kasa/smart/modules/consumables.py @@ -0,0 +1,170 @@ +"""Implementation of vacuum consumables.""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import timedelta + +from ...feature import Feature +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class _ConsumableMeta: + """Consumable meta container.""" + + #: Name of the consumable. + name: str + #: Internal id of the consumable + id: str + #: Data key in the device reported data + data_key: str + #: Lifetime + lifetime: timedelta + + +@dataclass +class Consumable: + """Consumable container.""" + + #: Name of the consumable. + name: str + #: Id of the consumable + id: str + #: Lifetime + lifetime: timedelta + #: Used + used: timedelta + #: Remaining + remaining: timedelta + #: Device data key + _data_key: str + + +CONSUMABLE_METAS = [ + _ConsumableMeta( + "Main brush", + id="main_brush", + data_key="roll_brush_time", + lifetime=timedelta(hours=400), + ), + _ConsumableMeta( + "Side brush", + id="side_brush", + data_key="edge_brush_time", + lifetime=timedelta(hours=200), + ), + _ConsumableMeta( + "Filter", + id="filter", + data_key="filter_time", + lifetime=timedelta(hours=200), + ), + _ConsumableMeta( + "Sensor", + id="sensor", + data_key="sensor_time", + lifetime=timedelta(hours=30), + ), + _ConsumableMeta( + "Charging contacts", + id="charging_contacts", + data_key="charge_contact_time", + lifetime=timedelta(hours=30), + ), + # Unknown keys: main_brush_lid_time, rag_time +] + + +class Consumables(SmartModule): + """Implementation of vacuum consumables.""" + + REQUIRED_COMPONENT = "consumables" + QUERY_GETTER_NAME = "getConsumablesInfo" + + _consumables: dict[str, Consumable] = {} + + def _initialize_features(self) -> None: + """Initialize features.""" + for c_meta in CONSUMABLE_METAS: + if c_meta.data_key not in self.data: + continue + + self._add_feature( + Feature( + self._device, + id=f"{c_meta.id}_used", + name=f"{c_meta.name} used", + container=self, + attribute_getter=lambda _, c_id=c_meta.id: self._consumables[ + c_id + ].used, + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + self._add_feature( + Feature( + self._device, + id=f"{c_meta.id}_remaining", + name=f"{c_meta.name} remaining", + container=self, + attribute_getter=lambda _, c_id=c_meta.id: self._consumables[ + c_id + ].remaining, + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + + self._add_feature( + Feature( + self._device, + id=f"{c_meta.id}_reset", + name=f"Reset {c_meta.name.lower()} consumable", + container=self, + attribute_setter=lambda c_id=c_meta.id: self.reset_consumable(c_id), + category=Feature.Category.Debug, + type=Feature.Type.Action, + ) + ) + + async def _post_update_hook(self) -> None: + """Update the consumables.""" + if not self._consumables: + for consumable_meta in CONSUMABLE_METAS: + if consumable_meta.data_key not in self.data: + continue + used = timedelta(minutes=self.data[consumable_meta.data_key]) + consumable = Consumable( + id=consumable_meta.id, + name=consumable_meta.name, + lifetime=consumable_meta.lifetime, + used=used, + remaining=consumable_meta.lifetime - used, + _data_key=consumable_meta.data_key, + ) + self._consumables[consumable_meta.id] = consumable + else: + for consumable in self._consumables.values(): + consumable.used = timedelta(minutes=self.data[consumable._data_key]) + consumable.remaining = consumable.lifetime - consumable.used + + async def reset_consumable(self, consumable_id: str) -> dict: + """Reset consumable stats.""" + consumable_name = self._consumables[consumable_id]._data_key.removesuffix( + "_time" + ) + return await self.call( + "resetConsumablesTime", {"reset_list": [consumable_name]} + ) + + @property + def consumables(self) -> Mapping[str, Consumable]: + """Get list of consumables on the device.""" + return self._consumables diff --git a/tests/cli/test_vacuum.py b/tests/cli/test_vacuum.py index e5f3e68ea..a790286e6 100644 --- a/tests/cli/test_vacuum.py +++ b/tests/cli/test_vacuum.py @@ -45,6 +45,49 @@ async def test_vacuum_records_list(dev, mocker: MockerFixture, runner): assert res.exit_code == 0 +@vacuum_devices +async def test_vacuum_consumables(dev, runner): + """Test that vacuum consumables calls the expected methods.""" + cons = dev.modules.get(Module.Consumables) + assert cons + + res = await runner.invoke(vacuum, ["consumables"], obj=dev, catch_exceptions=False) + + expected = "" + for c in cons.consumables.values(): + expected += f"{c.name} ({c.id}): {c.used} used, {c.remaining} remaining\n" + + assert expected in res.output + assert res.exit_code == 0 + + +@vacuum_devices +async def test_vacuum_consumables_reset(dev, mocker: MockerFixture, runner): + """Test that vacuum consumables reset calls the expected methods.""" + cons = dev.modules.get(Module.Consumables) + assert cons + + reset_consumable_mock = mocker.spy(cons, "reset_consumable") + for c_id in cons.consumables: + reset_consumable_mock.reset_mock() + res = await runner.invoke( + vacuum, ["consumables", "reset", c_id], obj=dev, catch_exceptions=False + ) + reset_consumable_mock.assert_awaited_once_with(c_id) + assert f"Consumable {c_id} reset" in res.output + assert res.exit_code == 0 + + res = await runner.invoke( + vacuum, ["consumables", "reset", "foobar"], obj=dev, catch_exceptions=False + ) + expected = ( + "Consumable foobar not found in " + f"device consumables: {', '.join(cons.consumables.keys())}." + ) + assert expected in res.output.replace("\n", "") + assert res.exit_code != 0 + + @plug_iot async def test_non_vacuum(dev, mocker: MockerFixture, runner): """Test that vacuum commands return an error if executed on a non-vacuum.""" @@ -59,3 +102,13 @@ async def test_non_vacuum(dev, mocker: MockerFixture, runner): ) assert "This device does not support records" in res.output assert res.exit_code != 0 + + res = await runner.invoke(vacuum, ["consumables"], obj=dev, catch_exceptions=False) + assert "This device does not support consumables" in res.output + assert res.exit_code != 0 + + res = await runner.invoke( + vacuum, ["consumables", "reset", "foobar"], obj=dev, catch_exceptions=False + ) + assert "This device does not support consumables" in res.output + assert res.exit_code != 0 diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index d8d8cb40c..ba47f0d55 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -687,6 +687,7 @@ async def _send_request(self, request_dict: dict): "add_child_device_list", # hub pairing "remove_child_device_list", # hub pairing "playSelectAudio", # vacuum special actions + "resetConsumablesTime", # vacuum special actions ]: return {"error_code": 0} elif method[:3] == "set": diff --git a/tests/smart/modules/test_consumables.py b/tests/smart/modules/test_consumables.py new file mode 100644 index 000000000..7a28f3be9 --- /dev/null +++ b/tests/smart/modules/test_consumables.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from datetime import timedelta + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice +from kasa.smart.modules.consumables import CONSUMABLE_METAS + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +consumables = parametrize( + "has consumables", component_filter="consumables", protocol_filter={"SMART"} +) + + +@consumables +@pytest.mark.parametrize( + "consumable_name", [consumable.id for consumable in CONSUMABLE_METAS] +) +@pytest.mark.parametrize("postfix", ["used", "remaining"]) +async def test_features(dev: SmartDevice, consumable_name: str, postfix: str): + """Test that features are registered and work as expected.""" + consumables = next(get_parent_and_child_modules(dev, Module.Consumables)) + assert consumables is not None + + feature_name = f"{consumable_name}_{postfix}" + + feat = consumables._device.features[feature_name] + assert isinstance(feat.value, timedelta) + + +@consumables +@pytest.mark.parametrize( + ("consumable_name", "data_key"), + [(consumable.id, consumable.data_key) for consumable in CONSUMABLE_METAS], +) +async def test_erase( + dev: SmartDevice, mocker: MockerFixture, consumable_name: str, data_key: str +): + """Test autocollection switch.""" + consumables = next(get_parent_and_child_modules(dev, Module.Consumables)) + call = mocker.spy(consumables, "call") + + feature_name = f"{consumable_name}_reset" + feat = dev._features[feature_name] + await feat.set_value(True) + + call.assert_called_with( + "resetConsumablesTime", {"reset_list": [data_key.removesuffix("_time")]} + ) From fa0f7157c6ed677182c550ae25f124a7e42a22de Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 22 Jan 2025 10:26:37 +0000 Subject: [PATCH 823/892] Deprecate legacy light module is_capability checks (#1297) Deprecate the `is_color`, `is_dimmable`, `is_variable_color_temp`, `valid_temperate_range`, and `has_effects` attributes from the `Light` module, as consumers should use `has_feature("hsv")`, `has_feature("brightness")`, `has_feature("color_temp")`, `get_feature("color_temp").range`, and `Module.LightEffect in dev.modules` respectively. Calling the deprecated attributes will emit a `DeprecationWarning` and type checkers will fail them. --- kasa/device.py | 48 +++++++++++++++++++++--- kasa/interfaces/light.py | 73 ++++++++++++++++++++++--------------- kasa/iot/modules/light.py | 44 ++-------------------- kasa/smart/modules/light.py | 37 ++----------------- tests/test_bulb.py | 68 ++++++++++++++++++++++++++++++++++ 5 files changed, 161 insertions(+), 109 deletions(-) diff --git a/kasa/device.py b/kasa/device.py index 360682323..d86a565e4 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -107,7 +107,7 @@ import logging from abc import ABC, abstractmethod -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from datetime import datetime, tzinfo from typing import TYPE_CHECKING, Any, TypeAlias @@ -537,19 +537,52 @@ def _get_replacing_attr( return None + def _get_deprecated_callable_attribute(self, name: str) -> Any | None: + vals: dict[str, tuple[ModuleName, Callable[[Any], Any], str]] = { + "is_dimmable": ( + Module.Light, + lambda c: c.has_feature("brightness"), + 'light_module.has_feature("brightness")', + ), + "is_color": ( + Module.Light, + lambda c: c.has_feature("hsv"), + 'light_module.has_feature("hsv")', + ), + "is_variable_color_temp": ( + Module.Light, + lambda c: c.has_feature("color_temp"), + 'light_module.has_feature("color_temp")', + ), + "valid_temperature_range": ( + Module.Light, + lambda c: c._deprecated_valid_temperature_range(), + 'minimum and maximum value of get_feature("color_temp")', + ), + "has_effects": ( + Module.Light, + lambda c: Module.LightEffect in c._device.modules, + "Module.LightEffect in device.modules", + ), + } + if mod_call_msg := vals.get(name): + mod, call, msg = mod_call_msg + msg = f"{name} is deprecated, use: {msg} instead" + warn(msg, DeprecationWarning, stacklevel=2) + if (module := self.modules.get(mod)) is None: + raise AttributeError(f"Device has no attribute {name!r}") + return call(module) + + return None + _deprecated_other_attributes = { # light attributes - "is_color": (Module.Light, ["is_color"]), - "is_dimmable": (Module.Light, ["is_dimmable"]), - "is_variable_color_temp": (Module.Light, ["is_variable_color_temp"]), "brightness": (Module.Light, ["brightness"]), "set_brightness": (Module.Light, ["set_brightness"]), "hsv": (Module.Light, ["hsv"]), "set_hsv": (Module.Light, ["set_hsv"]), "color_temp": (Module.Light, ["color_temp"]), "set_color_temp": (Module.Light, ["set_color_temp"]), - "valid_temperature_range": (Module.Light, ["valid_temperature_range"]), - "has_effects": (Module.Light, ["has_effects"]), "_deprecated_set_light_state": (Module.Light, ["has_effects"]), # led attributes "led": (Module.Led, ["led"]), @@ -588,6 +621,9 @@ def __getattr__(self, name: str) -> Any: msg = f"{name} is deprecated, use device_type property instead" warn(msg, DeprecationWarning, stacklevel=2) return self.device_type == dep_device_type_attr[1] + # callable + if (result := self._get_deprecated_callable_attribute(name)) is not None: + return result # Other deprecated attributes if (dep_attr := self._deprecated_other_attributes.get(name)) and ( (replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1])) diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py index 89058f98d..fdcfe46dc 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -65,8 +65,10 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Annotated, NamedTuple +from typing import TYPE_CHECKING, Annotated, Any, NamedTuple +from warnings import warn +from ..exceptions import KasaException from ..module import FeatureAttribute, Module @@ -100,34 +102,6 @@ class HSV(NamedTuple): class Light(Module, ABC): """Base class for TP-Link Light.""" - @property - @abstractmethod - def is_dimmable(self) -> bool: - """Whether the light supports brightness changes.""" - - @property - @abstractmethod - def is_color(self) -> bool: - """Whether the bulb supports color changes.""" - - @property - @abstractmethod - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - - @property - @abstractmethod - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - - @property - @abstractmethod - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - @property @abstractmethod def hsv(self) -> Annotated[HSV, FeatureAttribute()]: @@ -197,3 +171,44 @@ def state(self) -> LightState: @abstractmethod async def set_state(self, state: LightState) -> dict: """Set the light state.""" + + def _deprecated_valid_temperature_range(self) -> ColorTempRange: + if not (temp := self.get_feature("color_temp")): + raise KasaException("Color temperature not supported") + return ColorTempRange(temp.minimum_value, temp.maximum_value) + + def _deprecated_attributes(self, dep_name: str) -> str | None: + map: dict[str, str] = { + "is_color": "hsv", + "is_dimmable": "brightness", + "is_variable_color_temp": "color_temp", + } + return map.get(dep_name) + + if not TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: + if name == "valid_temperature_range": + msg = ( + "valid_temperature_range is deprecated, use " + 'get_feature("color_temp") minimum_value ' + " and maximum_value instead" + ) + warn(msg, DeprecationWarning, stacklevel=2) + res = self._deprecated_valid_temperature_range() + return res + + if name == "has_effects": + msg = ( + "has_effects is deprecated, check `Module.LightEffect " + "in device.modules` instead" + ) + warn(msg, DeprecationWarning, stacklevel=2) + return Module.LightEffect in self._device.modules + + if attr := self._deprecated_attributes(name): + msg = f'{name} is deprecated, use has_feature("{attr}") instead' + warn(msg, DeprecationWarning, stacklevel=2) + return self.has_feature(attr) + + raise AttributeError(f"Energy module has no attribute {name!r}") diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 5f5c34b92..fa9535908 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -8,7 +8,7 @@ from ...device_type import DeviceType from ...exceptions import KasaException from ...feature import Feature -from ...interfaces.light import HSV, ColorTempRange, LightState +from ...interfaces.light import HSV, LightState from ...interfaces.light import Light as LightInterface from ...module import FeatureAttribute from ..iotmodule import IotModule @@ -48,6 +48,8 @@ def _initialize_features(self) -> None: ) ) if device._is_variable_color_temp: + if TYPE_CHECKING: + assert isinstance(device, IotBulb) self._add_feature( Feature( device=device, @@ -56,7 +58,7 @@ def _initialize_features(self) -> None: container=self, attribute_getter="color_temp", attribute_setter="set_color_temp", - range_getter="valid_temperature_range", + range_getter=lambda: device._valid_temperature_range, category=Feature.Category.Primary, type=Feature.Type.Number, ) @@ -90,11 +92,6 @@ def _get_bulb_device(self) -> IotBulb | None: return cast("IotBulb", self._device) return None - @property # type: ignore - def is_dimmable(self) -> int: - """Whether the bulb supports brightness changes.""" - return self._device._is_dimmable - @property # type: ignore def brightness(self) -> Annotated[int, FeatureAttribute()]: """Return the current brightness in percentage.""" @@ -112,27 +109,6 @@ async def set_brightness( LightState(brightness=brightness, transition=transition) ) - @property - def is_color(self) -> bool: - """Whether the light supports color changes.""" - if (bulb := self._get_bulb_device()) is None: - return False - return bulb._is_color - - @property - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - if (bulb := self._get_bulb_device()) is None: - return False - return bulb._is_variable_color_temp - - @property - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - if (bulb := self._get_bulb_device()) is None: - return False - return bulb._has_effects - @property def hsv(self) -> Annotated[HSV, FeatureAttribute()]: """Return the current HSV state of the bulb. @@ -164,18 +140,6 @@ async def set_hsv( raise KasaException("Light does not support color.") return await bulb._set_hsv(hue, saturation, value, transition=transition) - @property - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - if ( - bulb := self._get_bulb_device() - ) is None or not bulb._is_variable_color_temp: - raise KasaException("Light does not support colortemp.") - return bulb._valid_temperature_range - @property def color_temp(self) -> Annotated[int, FeatureAttribute()]: """Whether the bulb supports color temperature changes.""" diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py index 804198979..d548811f5 100644 --- a/kasa/smart/modules/light.py +++ b/kasa/smart/modules/light.py @@ -7,7 +7,7 @@ from ...exceptions import KasaException from ...feature import Feature -from ...interfaces.light import HSV, ColorTempRange, LightState +from ...interfaces.light import HSV, LightState from ...interfaces.light import Light as LightInterface from ...module import FeatureAttribute, Module from ..smartmodule import SmartModule @@ -34,32 +34,6 @@ def query(self) -> dict: """Query to execute during the update cycle.""" return {} - @property - def is_color(self) -> bool: - """Whether the bulb supports color changes.""" - return Module.Color in self._device.modules - - @property - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - return Module.Brightness in self._device.modules - - @property - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - return Module.ColorTemperature in self._device.modules - - @property - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - if Module.ColorTemperature not in self._device.modules: - raise KasaException("Color temperature not supported") - - return self._device.modules[Module.ColorTemperature].valid_temperature_range - @property def hsv(self) -> Annotated[HSV, FeatureAttribute()]: """Return the current HSV state of the bulb. @@ -82,7 +56,7 @@ def color_temp(self) -> Annotated[int, FeatureAttribute()]: @property def brightness(self) -> Annotated[int, FeatureAttribute()]: """Return the current brightness in percentage.""" - if Module.Brightness not in self._device.modules: + if Module.Brightness not in self._device.modules: # pragma: no cover raise KasaException("Bulb is not dimmable.") return self._device.modules[Module.Brightness].brightness @@ -135,16 +109,11 @@ async def set_brightness( :param int brightness: brightness in percent :param int transition: transition in milliseconds. """ - if Module.Brightness not in self._device.modules: + if Module.Brightness not in self._device.modules: # pragma: no cover raise KasaException("Bulb is not dimmable.") return await self._device.modules[Module.Brightness].set_brightness(brightness) - @property - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - return Module.LightEffect in self._device.modules - async def set_state(self, state: LightState) -> dict: """Set the light state.""" state_dict = asdict(state) diff --git a/tests/test_bulb.py b/tests/test_bulb.py index f7a77a8d2..14a2ca35d 100644 --- a/tests/test_bulb.py +++ b/tests/test_bulb.py @@ -1,5 +1,9 @@ from __future__ import annotations +import re +from collections.abc import Callable +from contextlib import nullcontext + import pytest from kasa import Device, DeviceType, KasaException, Module @@ -180,3 +184,67 @@ async def test_non_variable_temp(dev: Device): @bulb def test_device_type_bulb(dev: Device): assert dev.device_type in {DeviceType.Bulb, DeviceType.LightStrip} + + +@pytest.mark.parametrize( + ("attribute", "use_msg", "use_fn"), + [ + pytest.param( + "is_color", + 'use has_feature("hsv") instead', + lambda device, mod: mod.has_feature("hsv"), + id="is_color", + ), + pytest.param( + "is_dimmable", + 'use has_feature("brightness") instead', + lambda device, mod: mod.has_feature("brightness"), + id="is_dimmable", + ), + pytest.param( + "is_variable_color_temp", + 'use has_feature("color_temp") instead', + lambda device, mod: mod.has_feature("color_temp"), + id="is_variable_color_temp", + ), + pytest.param( + "has_effects", + "check `Module.LightEffect in device.modules` instead", + lambda device, mod: Module.LightEffect in device.modules, + id="has_effects", + ), + ], +) +@bulb +async def test_deprecated_light_is_has_attributes( + dev: Device, attribute: str, use_msg: str, use_fn: Callable[[Device, Module], bool] +): + light = dev.modules.get(Module.Light) + assert light + + msg = f"{attribute} is deprecated, {use_msg}" + with pytest.deprecated_call(match=(re.escape(msg))): + result = getattr(light, attribute) + + assert result == use_fn(dev, light) + + +@bulb +async def test_deprecated_light_valid_temperature_range(dev: Device): + light = dev.modules.get(Module.Light) + assert light + + color_temp = light.has_feature("color_temp") + dep_msg = ( + "valid_temperature_range is deprecated, use " + 'get_feature("color_temp") minimum_value ' + " and maximum_value instead" + ) + exc_context = pytest.raises(KasaException, match="Color temperature not supported") + expected_context = nullcontext() if color_temp else exc_context + + with ( + expected_context, + pytest.deprecated_call(match=(re.escape(dep_msg))), + ): + assert light.valid_temperature_range # type: ignore[attr-defined] From 7b1b14d1e6d2486f3e3d4ed8022d234112bdcd5e Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 22 Jan 2025 11:54:32 +0100 Subject: [PATCH 824/892] Allow https for klaptransport (#1415) Later firmware versions on robovacs use `KLAP` over https instead of ssltransport (reported as AES) --- README.md | 2 +- SUPPORTED.md | 2 + devtools/dump_devinfo.py | 4 +- kasa/cli/discover.py | 7 +- kasa/device_factory.py | 10 +- kasa/deviceconfig.py | 6 +- kasa/discover.py | 10 +- kasa/protocols/smartprotocol.py | 16 + kasa/transports/aestransport.py | 2 + kasa/transports/klaptransport.py | 47 +- kasa/transports/linkietransport.py | 2 + kasa/transports/sslaestransport.py | 2 + kasa/transports/ssltransport.py | 2 + tests/discovery_fixtures.py | 10 +- .../smart/RV30 Max(US)_1.0_1.2.0.json | 888 ++++++++++++++++++ tests/smart/modules/test_clean.py | 4 + tests/test_cli.py | 4 +- tests/test_device_factory.py | 5 +- tests/test_discovery.py | 24 +- 19 files changed, 1019 insertions(+), 28 deletions(-) create mode 100644 tests/fixtures/smart/RV30 Max(US)_1.0_1.2.0.json diff --git a/README.md b/README.md index c40e66663..8c7ac09a3 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ The following devices have been tested and confirmed as working. If your device - **Cameras**: C100, C210, C220, C225, C325WB, C520WS, C720, D230, TC65, TC70 - **Hubs**: H100, H200 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 -- **Vacuums**: RV20 Max Plus +- **Vacuums**: RV20 Max Plus, RV30 Max [^1]: Model requires authentication diff --git a/SUPPORTED.md b/SUPPORTED.md index 8785d48ef..905f7ab3f 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -330,6 +330,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **RV20 Max Plus** - Hardware: 1.0 (EU) / Firmware: 1.0.7 +- **RV30 Max** + - Hardware: 1.0 (US) / Firmware: 1.2.0 diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index cee7a7bff..a0fff0e5c 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -300,7 +300,9 @@ def capture_raw(discovered: DiscoveredRaw): connection_type = DeviceConnectionParameters.from_values( dr.device_type, dr.mgt_encrypt_schm.encrypt_type, - dr.mgt_encrypt_schm.lv, + login_version=dr.mgt_encrypt_schm.lv, + https=dr.mgt_encrypt_schm.is_support_https, + http_port=dr.mgt_encrypt_schm.http_port, ) dc = DeviceConfig( host=host, diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index 07500f3ba..af367e32b 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -261,8 +261,11 @@ async def config(ctx: click.Context) -> DeviceDict: host_port = host + (f":{port}" if port else "") def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None: - prot, tran, dev = connect_attempt - key_str = f"{prot.__name__} + {tran.__name__} + {dev.__name__}" + prot, tran, dev, https = connect_attempt + key_str = ( + f"{prot.__name__} + {tran.__name__} + {dev.__name__}" + f" + {'https' if https else 'http'}" + ) result = "succeeded" if success else "failed" msg = f"Attempt to connect to {host_port} with {key_str} {result}" echo(msg) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index b09cf655d..83661038b 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -189,6 +189,7 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol :param config: Device config to derive protocol :param strict: Require exact match on encrypt type """ + _LOGGER.debug("Finding protocol for %s", config.host) ctype = config.connection_type protocol_name = ctype.device_family.value.split(".")[0] _LOGGER.debug("Finding protocol for %s", ctype.device_family) @@ -203,9 +204,11 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol return None return IotProtocol(transport=LinkieTransportV2(config=config)) - if ctype.device_family is DeviceFamily.SmartTapoRobovac: - if strict and ctype.encryption_type is not DeviceEncryptionType.Aes: - return None + # Older FW used a different transport + if ( + ctype.device_family is DeviceFamily.SmartTapoRobovac + and ctype.encryption_type is DeviceEncryptionType.Aes + ): return SmartProtocol(transport=SslTransport(config=config)) protocol_transport_key = ( @@ -223,6 +226,7 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol "IOT.KLAP": (IotProtocol, KlapTransport), "SMART.AES": (SmartProtocol, AesTransport), "SMART.KLAP": (SmartProtocol, KlapTransportV2), + "SMART.KLAP.HTTPS": (SmartProtocol, KlapTransportV2), # H200 is device family SMART.TAPOHUB and uses SmartCamProtocol so use # https to distuingish from SmartProtocol devices "SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport), diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index c5d5b1d57..b63255701 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -20,7 +20,7 @@ {'host': '127.0.0.3', 'timeout': 5, 'credentials': {'username': 'user@example.com', \ 'password': 'great_password'}, 'connection_type'\ : {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'login_version': 2, \ -'https': False}} +'https': False, 'http_port': 80}} >>> later_device = await Device.connect(config=Device.Config.from_dict(config_dict)) >>> print(later_device.alias) # Alias is available as connect() calls update() @@ -98,13 +98,16 @@ class DeviceConnectionParameters(_DeviceConfigBaseMixin): encryption_type: DeviceEncryptionType login_version: int | None = None https: bool = False + http_port: int | None = None @staticmethod def from_values( device_family: str, encryption_type: str, + *, login_version: int | None = None, https: bool | None = None, + http_port: int | None = None, ) -> DeviceConnectionParameters: """Return connection parameters from string values.""" try: @@ -115,6 +118,7 @@ def from_values( DeviceEncryptionType(encryption_type), login_version, https, + http_port=http_port, ) except (ValueError, TypeError) as ex: raise KasaException( diff --git a/kasa/discover.py b/kasa/discover.py index abcd7d5fa..36d6f2773 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -146,6 +146,7 @@ class ConnectAttempt(NamedTuple): protocol: type transport: type device: type + https: bool class DiscoveredMeta(TypedDict): @@ -637,10 +638,10 @@ async def try_connect_all( Device.Family.IotIpCamera, } candidates: dict[ - tuple[type[BaseProtocol], type[BaseTransport], type[Device]], + tuple[type[BaseProtocol], type[BaseTransport], type[Device], bool], tuple[BaseProtocol, DeviceConfig], ] = { - (type(protocol), type(protocol._transport), device_class): ( + (type(protocol), type(protocol._transport), device_class, https): ( protocol, config, ) @@ -870,8 +871,9 @@ def _get_device_instance( config.connection_type = DeviceConnectionParameters.from_values( type_, encrypt_type, - login_version, - encrypt_schm.is_support_https, + login_version=login_version, + https=encrypt_schm.is_support_https, + http_port=encrypt_schm.http_port, ) except KasaException as ex: raise UnsupportedDeviceError( diff --git a/kasa/protocols/smartprotocol.py b/kasa/protocols/smartprotocol.py index 5af7a81b3..6b3b03be1 100644 --- a/kasa/protocols/smartprotocol.py +++ b/kasa/protocols/smartprotocol.py @@ -36,6 +36,18 @@ _LOGGER = logging.getLogger(__name__) + +def _mask_area_list(area_list: list[dict[str, Any]]) -> list[dict[str, Any]]: + def mask_area(area: dict[str, Any]) -> dict[str, Any]: + result = {**area} + # Will leave empty names as blank + if area.get("name"): + result["name"] = "I01BU0tFRF9OQU1FIw==" # #MASKED_NAME# + return result + + return [mask_area(area) for area in area_list] + + REDACTORS: dict[str, Callable[[Any], Any] | None] = { "latitude": lambda x: 0, "longitude": lambda x: 0, @@ -71,6 +83,10 @@ "custom_sn": lambda _: "000000000000", "location": lambda x: "#MASKED_NAME#" if x else "", "map_data": lambda x: "#SCRUBBED_MAPDATA#" if x else "", + "map_name": lambda x: "I01BU0tFRF9OQU1FIw==", # #MASKED_NAME# + "area_list": _mask_area_list, + # unknown robovac binary blob in get_device_info + "cd": lambda x: "I01BU0tFRF9CSU5BUlkj", # #MASKED_BINARY# } # Queries that are known not to work properly when sent as a diff --git a/kasa/transports/aestransport.py b/kasa/transports/aestransport.py index 3466ca98e..45b963fe8 100644 --- a/kasa/transports/aestransport.py +++ b/kasa/transports/aestransport.py @@ -120,6 +120,8 @@ def __init__( @property def default_port(self) -> int: """Default port for the transport.""" + if port := self._config.connection_type.http_port: + return port return self.DEFAULT_PORT @property diff --git a/kasa/transports/klaptransport.py b/kasa/transports/klaptransport.py index 508bba09b..8253e0aef 100644 --- a/kasa/transports/klaptransport.py +++ b/kasa/transports/klaptransport.py @@ -48,6 +48,7 @@ import hashlib import logging import secrets +import ssl import struct import time from asyncio import Future @@ -92,8 +93,21 @@ class KlapTransport(BaseTransport): """ DEFAULT_PORT: int = 80 + DEFAULT_HTTPS_PORT: int = 4433 + SESSION_COOKIE_NAME = "TP_SESSIONID" TIMEOUT_COOKIE_NAME = "TIMEOUT" + # Copy & paste from sslaestransport + CIPHERS = ":".join( + [ + "AES256-GCM-SHA384", + "AES256-SHA256", + "AES128-GCM-SHA256", + "AES128-SHA256", + "AES256-SHA", + ] + ) + _ssl_context: ssl.SSLContext | None = None def __init__( self, @@ -125,12 +139,20 @@ def __init__( self._session_cookie: dict[str, Any] | None = None _LOGGER.debug("Created KLAP transport for %s", self._host) - self._app_url = URL(f"http://{self._host}:{self._port}/app") + protocol = "https" if config.connection_type.https else "http" + self._app_url = URL(f"{protocol}://{self._host}:{self._port}/app") self._request_url = self._app_url / "request" @property def default_port(self) -> int: """Default port for the transport.""" + config = self._config + if port := config.connection_type.http_port: + return port + + if config.connection_type.https: + return self.DEFAULT_HTTPS_PORT + return self.DEFAULT_PORT @property @@ -152,7 +174,9 @@ async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: url = self._app_url / "handshake1" - response_status, response_data = await self._http_client.post(url, data=payload) + response_status, response_data = await self._http_client.post( + url, data=payload, ssl=await self._get_ssl_context() + ) if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( @@ -263,6 +287,7 @@ async def perform_handshake2( url, data=payload, cookies_dict=self._session_cookie, + ssl=await self._get_ssl_context(), ) if _LOGGER.isEnabledFor(logging.DEBUG): @@ -337,6 +362,7 @@ async def send(self, request: str) -> Generator[Future, None, dict[str, str]]: params={"seq": seq}, data=payload, cookies_dict=self._session_cookie, + ssl=await self._get_ssl_context(), ) msg = ( @@ -413,6 +439,23 @@ def generate_owner_hash(creds: Credentials) -> bytes: un = creds.username return md5(un.encode()) + # Copy & paste from sslaestransport. + def _create_ssl_context(self) -> ssl.SSLContext: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.set_ciphers(self.CIPHERS) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context + + # Copy & paste from sslaestransport. + async def _get_ssl_context(self) -> ssl.SSLContext: + if not self._ssl_context: + loop = asyncio.get_running_loop() + self._ssl_context = await loop.run_in_executor( + None, self._create_ssl_context + ) + return self._ssl_context + class KlapTransportV2(KlapTransport): """Implementation of the KLAP encryption protocol with v2 hanshake hashes.""" diff --git a/kasa/transports/linkietransport.py b/kasa/transports/linkietransport.py index 779d182e0..b817373c3 100644 --- a/kasa/transports/linkietransport.py +++ b/kasa/transports/linkietransport.py @@ -55,6 +55,8 @@ def __init__(self, *, config: DeviceConfig) -> None: @property def default_port(self) -> int: """Default port for the transport.""" + if port := self._config.connection_type.http_port: + return port return self.DEFAULT_PORT @property diff --git a/kasa/transports/sslaestransport.py b/kasa/transports/sslaestransport.py index eb67eda8e..eeb298099 100644 --- a/kasa/transports/sslaestransport.py +++ b/kasa/transports/sslaestransport.py @@ -133,6 +133,8 @@ def __init__( @property def default_port(self) -> int: """Default port for the transport.""" + if port := self._config.connection_type.http_port: + return port return self.DEFAULT_PORT @staticmethod diff --git a/kasa/transports/ssltransport.py b/kasa/transports/ssltransport.py index 4471dccb9..e4fef9a31 100644 --- a/kasa/transports/ssltransport.py +++ b/kasa/transports/ssltransport.py @@ -94,6 +94,8 @@ def __init__( @property def default_port(self) -> int: """Default port for the transport.""" + if port := self._config.connection_type.http_port: + return port return self.DEFAULT_PORT @property diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index eb843f1a0..2db79e913 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -159,6 +159,7 @@ class _DiscoveryMock: https: bool login_version: int | None = None port_override: int | None = None + http_port: int | None = None @property def model(self) -> str: @@ -194,9 +195,15 @@ def _datagram(self) -> bytes: ): login_version = max([int(i) for i in et]) https = discovery_result["mgt_encrypt_schm"]["is_support_https"] + http_port = discovery_result["mgt_encrypt_schm"].get("http_port") + if not http_port: # noqa: SIM108 + # Not all discovery responses set the http port, i.e. smartcam. + default_port = 443 if https else 80 + else: + default_port = http_port dm = _DiscoveryMock( ip, - 80, + default_port, 20002, discovery_data, fixture_data, @@ -204,6 +211,7 @@ def _datagram(self) -> bytes: encrypt_type, https, login_version, + http_port=http_port, ) else: sys_info = fixture_data["system"]["get_sysinfo"] diff --git a/tests/fixtures/smart/RV30 Max(US)_1.0_1.2.0.json b/tests/fixtures/smart/RV30 Max(US)_1.0_1.2.0.json new file mode 100644 index 000000000..9b6484da8 --- /dev/null +++ b/tests/fixtures/smart/RV30 Max(US)_1.0_1.2.0.json @@ -0,0 +1,888 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "quick_setup", + "ver_code": 1 + }, + { + "id": "clean", + "ver_code": 3 + }, + { + "id": "battery", + "ver_code": 1 + }, + { + "id": "consumables", + "ver_code": 2 + }, + { + "id": "direction_control", + "ver_code": 1 + }, + { + "id": "button_and_led", + "ver_code": 1 + }, + { + "id": "speaker", + "ver_code": 3 + }, + { + "id": "schedule", + "ver_code": 3 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "map", + "ver_code": 2 + }, + { + "id": "auto_change_map", + "ver_code": 2 + }, + { + "id": "mop", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "do_not_disturb", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "charge_pose_clean", + "ver_code": 1 + }, + { + "id": "continue_breakpoint_sweep", + "ver_code": 1 + }, + { + "id": "goto_point", + "ver_code": 1 + }, + { + "id": "furniture", + "ver_code": 1 + }, + { + "id": "map_cloud_backup", + "ver_code": 1 + }, + { + "id": "dev_log", + "ver_code": 1 + }, + { + "id": "map_lock", + "ver_code": 1 + }, + { + "id": "carpet_area", + "ver_code": 1 + }, + { + "id": "clean_angle", + "ver_code": 1 + }, + { + "id": "clean_percent", + "ver_code": 1 + }, + { + "id": "no_pose_config", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "RV30 Max(US)", + "device_type": "SMART.TAPOROBOVAC", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "7C-F1-7E-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 4433, + "is_support_https": true + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "getAreaUnit": { + "area_unit": 1 + }, + "getAutoChangeMap": { + "auto_change_map": true + }, + "getBatteryInfo": { + "battery_percentage": 100 + }, + "getCarpetClean": { + "carpet_clean_prefer": "boost" + }, + "getChildLockInfo": { + "child_lock_status": false + }, + "getCleanAttr": { + "cistern": 1, + "clean_number": 1, + "suction": 2 + }, + "getCleanInfo": { + "clean_area": 59, + "clean_percent": 100, + "clean_time": 56 + }, + "getCleanRecords": { + "lastest_day_record": [ + 1737387294, + 56, + 59, + 1 + ], + "record_list": [ + { + "clean_area": 59, + "clean_time": 57, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 0, + "start_type": 4, + "task_type": 0, + "timestamp": 1737041654 + }, + { + "clean_area": 39, + "clean_time": 58, + "dust_collection": false, + "error": 0, + "info_num": 1, + "map_id": 1736541042, + "message": 0, + "record_index": 1, + "start_type": 1, + "task_type": 0, + "timestamp": 1737055944 + }, + { + "clean_area": 1, + "clean_time": 3, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 2, + "start_type": 1, + "task_type": 4, + "timestamp": 1737074472 + }, + { + "clean_area": 59, + "clean_time": 58, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 3, + "start_type": 4, + "task_type": 0, + "timestamp": 1737128195 + }, + { + "clean_area": 68, + "clean_time": 78, + "dust_collection": false, + "error": 0, + "info_num": 2, + "map_id": 1736541042, + "message": 0, + "record_index": 4, + "start_type": 1, + "task_type": 1, + "timestamp": 1737216716 + }, + { + "clean_area": 3, + "clean_time": 3, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734742958, + "message": 0, + "record_index": 5, + "start_type": 1, + "task_type": 3, + "timestamp": 1737300731 + }, + { + "clean_area": 20, + "clean_time": 16, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734742958, + "message": 0, + "record_index": 6, + "start_type": 1, + "task_type": 3, + "timestamp": 1737304391 + }, + { + "clean_area": 59, + "clean_time": 56, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 7, + "start_type": 4, + "task_type": 0, + "timestamp": 1737387294 + }, + { + "clean_area": 17, + "clean_time": 16, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 8, + "start_type": 1, + "task_type": 3, + "timestamp": 1736707487 + }, + { + "clean_area": 8, + "clean_time": 10, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 9, + "start_type": 1, + "task_type": 4, + "timestamp": 1736708425 + }, + { + "clean_area": 59, + "clean_time": 54, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 10, + "start_type": 4, + "task_type": 0, + "timestamp": 1736782261 + }, + { + "clean_area": 60, + "clean_time": 56, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 11, + "start_type": 4, + "task_type": 0, + "timestamp": 1736868752 + }, + { + "clean_area": 58, + "clean_time": 68, + "dust_collection": true, + "error": 1, + "info_num": 0, + "map_id": 1736541042, + "message": 0, + "record_index": 12, + "start_type": 1, + "task_type": 1, + "timestamp": 1736881428 + }, + { + "clean_area": 59, + "clean_time": 59, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 13, + "start_type": 4, + "task_type": 0, + "timestamp": 1736955682 + }, + { + "clean_area": 36, + "clean_time": 33, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 14, + "start_type": 1, + "task_type": 4, + "timestamp": 1736960713 + } + ], + "record_list_num": 15, + "total_area": 2304, + "total_number": 85, + "total_time": 2510 + }, + "getCleanStatus": { + "clean_status": 0, + "is_mapping": false, + "is_relocating": false, + "is_working": false + }, + "getConsumablesInfo": { + "charge_contact_time": 660, + "edge_brush_time": 2743, + "filter_time": 287, + "main_brush_lid_time": 2462, + "rag_time": 0, + "roll_brush_time": 2719, + "sensor_time": 935 + }, + "getCurrentVoiceLanguage": { + "name": "bb053ca2c5605a55090fcdb952f3902b", + "version": 2 + }, + "getDoNotDisturb": { + "do_not_disturb": true, + "e_min": 480, + "s_min": 1320 + }, + "getMapData": { + "area_list": [ + { + "cistern": 1, + "clean_number": 1, + "color": 3, + "floor_texture": -1, + "id": 5, + "name": "I01BU0tFRF9OQU1FIw==", + "suction": 2, + "type": "room" + }, + { + "cistern": 1, + "clean_number": 1, + "color": 4, + "floor_texture": -1, + "id": 6, + "name": "I01BU0tFRF9OQU1FIw==", + "suction": 2, + "type": "room" + }, + { + "cistern": 1, + "clean_number": 1, + "color": 1, + "floor_texture": 0, + "id": 2, + "name": "I01BU0tFRF9OQU1FIw==", + "suction": 2, + "type": "room" + }, + { + "cistern": 1, + "clean_number": 1, + "color": 5, + "floor_texture": 90, + "id": 3, + "name": "I01BU0tFRF9OQU1FIw==", + "suction": 2, + "type": "room" + }, + { + "cistern": 1, + "clean_number": 1, + "color": 2, + "floor_texture": -1, + "id": 4, + "name": "I01BU0tFRF9OQU1FIw==", + "suction": 2, + "type": "room" + }, + { + "id": 401, + "type": "virtual_wall", + "vertexs": [ + [ + 4711, + 985 + ], + [ + 4717, + -404 + ] + ] + }, + { + "id": 301, + "type": "forbid", + "vertexs": [ + [ + 3061, + -3027 + ], + [ + 3580, + -3027 + ], + [ + 3580, + -3692 + ], + [ + 3061, + -3692 + ] + ] + }, + { + "id": 402, + "type": "virtual_wall", + "vertexs": [ + [ + 5302, + 6816 + ], + [ + 5304, + 4924 + ] + ] + }, + { + "cistern": -1, + "clean_number": 1, + "id": 501, + "suction": -1, + "type": "area", + "vertexs": [ + [ + 2889, + 6241 + ], + [ + 3721, + 6241 + ], + [ + 3721, + 4919 + ], + [ + 2889, + 4919 + ] + ] + }, + { + "carpet_strategy": 11, + "id": 101, + "type": "carpet_rectangle", + "vertexs": [ + [ + 20, + -2012 + ], + [ + 2857, + -2012 + ], + [ + 2857, + -4122 + ], + [ + 20, + -4122 + ] + ] + }, + { + "carpet_strategy": 11, + "id": 102, + "type": "carpet_rectangle", + "vertexs": [ + [ + 1327, + 3064 + ], + [ + 2428, + 3064 + ], + [ + 2428, + 2258 + ], + [ + 1327, + 2258 + ] + ] + }, + { + "carpet_strategy": 11, + "id": 103, + "type": "carpet_rectangle", + "vertexs": [ + [ + 4458, + 5974 + ], + [ + 5336, + 5974 + ], + [ + 5336, + 4903 + ], + [ + 4458, + 4903 + ] + ] + }, + { + "carpet_strategy": 11, + "id": 104, + "type": "carpet_rectangle", + "vertexs": [ + [ + -1383, + 2730 + ], + [ + -761, + 2730 + ], + [ + -761, + 1587 + ], + [ + -1383, + 1587 + ] + ] + } + ], + "auto_area_flag": true, + "bit_list": { + "auto_area": [ + 0, + 100 + ], + "barrier": 0, + "clean": 255, + "none": 127 + }, + "bitnum": 8, + "charge_coor": [ + 65, + 134, + 272 + ], + "furniture_list": [], + "height": 303, + "map_data": "#SCRUBBED_MAPDATA#", + "map_hash": "A5D8FA4487CC40312EF58D8123F0A4CC", + "map_id": 1734727686, + "map_locked": 0, + "map_name": "I01BU0tFRF9OQU1FIw==", + "origin_coor": [ + -33, + -108, + 270 + ], + "path_id": 122, + "pix_len": 66660, + "pix_lz4len": 6826, + "real_charge_coor": [ + 1599, + 1295, + 272 + ], + "real_origin_coor": [ + -1674, + -5424, + 270 + ], + "real_vac_coor": [ + 1599, + 1076, + 272 + ], + "resolution": 50, + "resolution_unit": "mm", + "vac_coor": [ + 65, + 130, + 272 + ], + "version": "LDS", + "width": 220 + }, + "getMapInfo": { + "auto_change_map": true, + "current_map_id": 1734727686, + "map_list": [ + { + "auto_area_flag": true, + "global_cleaned": -1, + "is_saved": true, + "map_id": 1734727686, + "map_locked": 0, + "map_name": "I01BU0tFRF9OQU1FIw==", + "rotate_angle": 270, + "update_time": 1737387285 + }, + { + "auto_area_flag": true, + "global_cleaned": -1, + "is_saved": true, + "map_id": 1734742958, + "map_locked": 0, + "map_name": "I01BU0tFRF9OQU1FIw==", + "rotate_angle": 0, + "update_time": 1737304392 + }, + { + "auto_area_flag": true, + "global_cleaned": -1, + "is_saved": true, + "map_id": 1736541042, + "map_locked": 0, + "map_name": "I01BU0tFRF9OQU1FIw==", + "rotate_angle": 270, + "update_time": 1737216718 + } + ], + "map_num": 3, + "version": "LDS" + }, + "getMopState": { + "mop_state": false + }, + "getVacStatus": { + "err_status": [ + 0 + ], + "errorCode_id": [ + 1144500830 + ], + "prompt": [], + "promptCode_id": [], + "status": 6 + }, + "getVolume": { + "volume": 60 + }, + "get_device_info": { + "auto_pack_ver": "0.0.131.1852", + "avatar": "", + "board_sn": "000000000000", + "cd": "I01BU0tFRF9CSU5BUlkj", + "custom_sn": "000000000000", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.0 Build 241219 Rel.163928", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "linux_ver": "V21.198.1708420747", + "location": "", + "longitude": 0, + "mac": "7C-F1-7E-00-00-00", + "mcu_ver": "1.1.2724.442", + "model": "RV30 Max", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "product_id": "1794", + "region": "America/Chicago", + "rssi": -38, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "sub_ver": "0.0.131.1852-1.4.40", + "time_diff": -360, + "total_ver": "1.4.40", + "type": "SMART.TAPOROBOVAC" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1737399953 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": { + "inherit_status": true + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.2.0 Build 241219 Rel.163928", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_schedule_rules": { + "enable": true, + "rule_list": [ + { + "alarm_min": 0, + "cancel": false, + "clean_attr": { + "cistern": 2, + "clean_mode": 0, + "clean_number": 1, + "clean_order": false, + "suction": 2 + }, + "day": 21, + "enable": true, + "id": "S1", + "invalid": 0, + "mode": "repeat", + "month": 1, + "s_min": 515, + "start_remind": true, + "week_day": 62, + "year": 2025 + } + ], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 1 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 5, + "wep_supported": true + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "RV30 Max", + "device_type": "SMART.TAPOROBOVAC" + } + } +} diff --git a/tests/smart/modules/test_clean.py b/tests/smart/modules/test_clean.py index beae01436..70cbcb158 100644 --- a/tests/smart/modules/test_clean.py +++ b/tests/smart/modules/test_clean.py @@ -117,6 +117,10 @@ async def test_actions( async def test_post_update_hook(dev: SmartDevice, err_status: list, error: ErrorCode): """Test that post update hook sets error states correctly.""" clean = next(get_parent_and_child_modules(dev, Module.Clean)) + assert clean + + # _post_update_hook will pop an item off the status list so create a copy. + err_status = [e for e in err_status] clean.data["getVacStatus"]["err_status"] = err_status await clean._post_update_hook() diff --git a/tests/test_cli.py b/tests/test_cli.py index 2f9075028..19958d552 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1308,11 +1308,11 @@ async def test_discover_config(dev: Device, mocker, runner): expected = f"--device-family {cparam.device_family.value} --encrypt-type {cparam.encryption_type.value} {'--https' if cparam.https else '--no-https'}" assert expected in res.output assert re.search( - r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ failed", + r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ \+ \w+ failed", res.output.replace("\n", ""), ) assert re.search( - r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ succeeded", + r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ \+ \w+ succeeded", res.output.replace("\n", ""), ) diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index c21c8fe93..d6bdaedf1 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -63,8 +63,9 @@ def _get_connection_type_device_class(discovery_info): connection_type = DeviceConnectionParameters.from_values( dr.device_type, dr.mgt_encrypt_schm.encrypt_type, - dr.mgt_encrypt_schm.lv, - dr.mgt_encrypt_schm.is_support_https, + login_version=dr.mgt_encrypt_schm.lv, + https=dr.mgt_encrypt_schm.is_support_https, + http_port=dr.mgt_encrypt_schm.http_port, ) else: connection_type = DeviceConnectionParameters.from_values( diff --git a/tests/test_discovery.py b/tests/test_discovery.py index fbbed879f..96c9e9c6b 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -157,14 +157,15 @@ async def test_discover_single(discovery_mock, custom_port, mocker): ) # Make sure discovery does not call update() assert update_mock.call_count == 0 - if discovery_mock.default_port == 80: + if discovery_mock.default_port != 9999: assert x.alias is None ct = DeviceConnectionParameters.from_values( discovery_mock.device_type, discovery_mock.encrypt_type, - discovery_mock.login_version, - discovery_mock.https, + login_version=discovery_mock.login_version, + https=discovery_mock.https, + http_port=discovery_mock.http_port, ) config = DeviceConfig( host=host, @@ -425,9 +426,9 @@ async def test_discover_single_http_client(discovery_mock, mocker): x: Device = await Discover.discover_single(host) - assert x.config.uses_http == (discovery_mock.default_port == 80) + assert x.config.uses_http == (discovery_mock.default_port != 9999) - if discovery_mock.default_port == 80: + if discovery_mock.default_port != 9999: assert x.protocol._transport._http_client.client != http_client x.config.http_client = http_client assert x.protocol._transport._http_client.client == http_client @@ -442,9 +443,9 @@ async def test_discover_http_client(discovery_mock, mocker): devices = await Discover.discover(discovery_timeout=0) x: Device = devices[host] - assert x.config.uses_http == (discovery_mock.default_port == 80) + assert x.config.uses_http == (discovery_mock.default_port != 9999) - if discovery_mock.default_port == 80: + if discovery_mock.default_port != 9999: assert x.protocol._transport._http_client.client != http_client x.config.http_client = http_client assert x.protocol._transport._http_client.client == http_client @@ -674,8 +675,9 @@ async def test_discover_try_connect_all(discovery_mock, mocker): cparams = DeviceConnectionParameters.from_values( discovery_mock.device_type, discovery_mock.encrypt_type, - discovery_mock.login_version, - discovery_mock.https, + login_version=discovery_mock.login_version, + https=discovery_mock.https, + http_port=discovery_mock.http_port, ) protocol = get_protocol( DeviceConfig(discovery_mock.ip, connection_type=cparams) @@ -687,10 +689,13 @@ async def test_discover_try_connect_all(discovery_mock, mocker): protocol_class = IotProtocol transport_class = XorTransport + default_port = discovery_mock.default_port + async def _query(self, *args, **kwargs): if ( self.__class__ is protocol_class and self._transport.__class__ is transport_class + and self._transport._port == default_port ): return discovery_mock.query_data raise KasaException("Unable to execute query") @@ -699,6 +704,7 @@ async def _update(self, *args, **kwargs): if ( self.protocol.__class__ is protocol_class and self.protocol._transport.__class__ is transport_class + and self.protocol._transport._port == default_port ): return From 307173487abd119c1bbd6bc84ea5ee50b4770b62 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 22 Jan 2025 17:58:04 +0100 Subject: [PATCH 825/892] Only log one warning per unknown clean error code and status (#1462) Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --- kasa/smart/modules/clean.py | 27 +++++++++++---- tests/smart/modules/test_clean.py | 56 +++++++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/kasa/smart/modules/clean.py b/kasa/smart/modules/clean.py index a2812c329..2764e8a15 100644 --- a/kasa/smart/modules/clean.py +++ b/kasa/smart/modules/clean.py @@ -37,6 +37,7 @@ class ErrorCode(IntEnum): SideBrushStuck = 2 MainBrushStuck = 3 WheelBlocked = 4 + Trapped = 6 DustBinRemoved = 14 UnableToMove = 15 LidarBlocked = 16 @@ -79,6 +80,8 @@ class Clean(SmartModule): REQUIRED_COMPONENT = "clean" _error_code = ErrorCode.Ok + _logged_error_code_warnings: set | None = None + _logged_status_code_warnings: set def _initialize_features(self) -> None: """Initialize features.""" @@ -229,12 +232,17 @@ def _initialize_features(self) -> None: async def _post_update_hook(self) -> None: """Set error code after update.""" + if self._logged_error_code_warnings is None: + self._logged_error_code_warnings = set() + self._logged_status_code_warnings = set() + errors = self._vac_status.get("err_status") if errors is None or not errors: self._error_code = ErrorCode.Ok return - if len(errors) > 1: + if len(errors) > 1 and "multiple" not in self._logged_error_code_warnings: + self._logged_error_code_warnings.add("multiple") _LOGGER.warning( "Multiple error codes, using the first one only: %s", errors ) @@ -243,10 +251,13 @@ async def _post_update_hook(self) -> None: try: self._error_code = ErrorCode(error) except ValueError: - _LOGGER.warning( - "Unknown error code, please create an issue describing the error: %s", - error, - ) + if error not in self._logged_error_code_warnings: + self._logged_error_code_warnings.add(error) + _LOGGER.warning( + "Unknown error code, please create an issue " + "describing the error: %s", + error, + ) self._error_code = ErrorCode.UnknownInternal def query(self) -> dict: @@ -360,7 +371,11 @@ def status(self) -> Status: try: return Status(status_code) except ValueError: - _LOGGER.warning("Got unknown status code: %s (%s)", status_code, self.data) + if status_code not in self._logged_status_code_warnings: + self._logged_status_code_warnings.add(status_code) + _LOGGER.warning( + "Got unknown status code: %s (%s)", status_code, self.data + ) return Status.UnknownInternal @property diff --git a/tests/smart/modules/test_clean.py b/tests/smart/modules/test_clean.py index 70cbcb158..f4c2813c4 100644 --- a/tests/smart/modules/test_clean.py +++ b/tests/smart/modules/test_clean.py @@ -104,21 +104,39 @@ async def test_actions( @pytest.mark.parametrize( - ("err_status", "error"), + ("err_status", "error", "warning_msg"), [ - pytest.param([], ErrorCode.Ok, id="empty error"), - pytest.param([0], ErrorCode.Ok, id="no error"), - pytest.param([3], ErrorCode.MainBrushStuck, id="known error"), - pytest.param([123], ErrorCode.UnknownInternal, id="unknown error"), - pytest.param([3, 4], ErrorCode.MainBrushStuck, id="multi-error"), + pytest.param([], ErrorCode.Ok, None, id="empty error"), + pytest.param([0], ErrorCode.Ok, None, id="no error"), + pytest.param([3], ErrorCode.MainBrushStuck, None, id="known error"), + pytest.param( + [123], + ErrorCode.UnknownInternal, + "Unknown error code, please create an issue describing the error: 123", + id="unknown error", + ), + pytest.param( + [3, 4], + ErrorCode.MainBrushStuck, + "Multiple error codes, using the first one only: [3, 4]", + id="multi-error", + ), ], ) @clean -async def test_post_update_hook(dev: SmartDevice, err_status: list, error: ErrorCode): +async def test_post_update_hook( + dev: SmartDevice, + err_status: list, + error: ErrorCode, + warning_msg: str | None, + caplog: pytest.LogCaptureFixture, +): """Test that post update hook sets error states correctly.""" clean = next(get_parent_and_child_modules(dev, Module.Clean)) assert clean + caplog.set_level(logging.DEBUG) + # _post_update_hook will pop an item off the status list so create a copy. err_status = [e for e in err_status] clean.data["getVacStatus"]["err_status"] = err_status @@ -130,6 +148,16 @@ async def test_post_update_hook(dev: SmartDevice, err_status: list, error: Error if error is not ErrorCode.Ok: assert clean.status is Status.Error + if warning_msg: + assert warning_msg in caplog.text + + # Check doesn't log twice + caplog.clear() + await clean._post_update_hook() + + if warning_msg: + assert warning_msg not in caplog.text + @clean async def test_resume(dev: SmartDevice, mocker: MockerFixture): @@ -164,6 +192,20 @@ async def test_unknown_status( assert clean.status is Status.UnknownInternal assert "Got unknown status code: 123" in caplog.text + # Check only logs once + caplog.clear() + + assert clean.status is Status.UnknownInternal + assert "Got unknown status code: 123" not in caplog.text + + # Check logs again for other errors + + caplog.clear() + clean.data["getVacStatus"]["status"] = 123456 + + assert clean.status is Status.UnknownInternal + assert "Got unknown status code: 123456" in caplog.text + @clean @pytest.mark.parametrize( From acc0e9a80adf1be0e07719888da6fbe6a309d9e3 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 22 Jan 2025 21:41:52 +0000 Subject: [PATCH 826/892] Enable CI workflow on PRs to feat/ fix/ and janitor/ (#1471) This will enable for PRs that we create to other branches. --- .github/workflows/ci.yml | 11 +++++++++-- .github/workflows/codeql-analysis.yml | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8c145cc1..0c3643b1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,16 @@ name: CI on: push: - branches: ["master", "patch"] + branches: + - master + - patch pull_request: - branches: ["master", "patch"] + branches: + - master + - patch + - 'feat/**' + - 'fix/**' + - 'janitor/**' workflow_dispatch: # to allow manual re-runs env: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 29d533581..9edba4839 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -2,9 +2,16 @@ name: "CodeQL checks" on: push: - branches: [ "master", "patch" ] + branches: + - master + - patch pull_request: - branches: [ master, "patch" ] + branches: + - master + - patch + - 'feat/**' + - 'fix/**' + - 'janitor/**' schedule: - cron: '44 17 * * 3' From 54bb53899e4300b57847c59b8cd715e416912c3e Mon Sep 17 00:00:00 2001 From: steveredden <35814432+steveredden@users.noreply.github.com> Date: Thu, 23 Jan 2025 03:22:41 -0600 Subject: [PATCH 827/892] Add support for doorbells and chimes (#1435) Add support for `smart` chimes and `smartcam` doorbells that are not hub child devices. Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- README.md | 5 +++-- SUPPORTED.md | 21 ++++++++++++--------- devtools/generate_supported.py | 4 +++- kasa/device_factory.py | 6 +++++- kasa/device_type.py | 2 ++ kasa/deviceconfig.py | 2 ++ kasa/smart/smartdevice.py | 2 ++ kasa/smartcam/modules/camera.py | 7 ++----- kasa/smartcam/smartcamchild.py | 7 +++++++ kasa/smartcam/smartcamdevice.py | 20 +++++++++----------- tests/device_fixtures.py | 13 +++++++++++++ tests/test_device.py | 16 +++------------- tests/test_device_factory.py | 12 ++++++++++++ 13 files changed, 75 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 8c7ac09a3..9761684f3 100644 --- a/README.md +++ b/README.md @@ -201,10 +201,11 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: S210, S220, S500D, S505, S505D - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Cameras**: C100, C210, C220, C225, C325WB, C520WS, C720, D230, TC65, TC70 +- **Cameras**: C100, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70 +- **Doorbells and chimes**: D230 +- **Vacuums**: RV20 Max Plus, RV30 Max - **Hubs**: H100, H200 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 -- **Vacuums**: RV20 Max Plus, RV30 Max [^1]: Model requires authentication diff --git a/SUPPORTED.md b/SUPPORTED.md index 905f7ab3f..01d2d63e4 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -285,13 +285,23 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (US) / Firmware: 1.2.8 - **C720** - Hardware: 1.0 (US) / Firmware: 1.2.3 -- **D230** - - Hardware: 1.20 (EU) / Firmware: 1.1.19 - **TC65** - Hardware: 1.0 / Firmware: 1.3.9 - **TC70** - Hardware: 3.0 / Firmware: 1.3.11 +### Doorbells and chimes + +- **D230** + - Hardware: 1.20 (EU) / Firmware: 1.1.19 + +### Vacuums + +- **RV20 Max Plus** + - Hardware: 1.0 (EU) / Firmware: 1.0.7 +- **RV30 Max** + - Hardware: 1.0 (US) / Firmware: 1.2.0 + ### Hubs - **H100** @@ -326,13 +336,6 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.7.0 - Hardware: 1.0 (US) / Firmware: 1.8.0 -### Vacuums - -- **RV20 Max Plus** - - Hardware: 1.0 (EU) / Firmware: 1.0.7 -- **RV30 Max** - - Hardware: 1.0 (US) / Firmware: 1.2.0 - [^1]: Model requires authentication diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py index 8aba9b214..669a2de2e 100755 --- a/devtools/generate_supported.py +++ b/devtools/generate_supported.py @@ -36,10 +36,12 @@ class SupportedVersion(NamedTuple): DeviceType.Bulb: "Bulbs", DeviceType.LightStrip: "Light Strips", DeviceType.Camera: "Cameras", + DeviceType.Doorbell: "Doorbells and chimes", + DeviceType.Chime: "Doorbells and chimes", + DeviceType.Vacuum: "Vacuums", DeviceType.Hub: "Hubs", DeviceType.Sensor: "Hub-Connected Devices", DeviceType.Thermostat: "Hub-Connected Devices", - DeviceType.Vacuum: "Vacuums", } diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 83661038b..53ceba178 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -159,6 +159,7 @@ def get_device_class_from_family( "SMART.KASAHUB": SmartDevice, "SMART.KASASWITCH": SmartDevice, "SMART.IPCAMERA.HTTPS": SmartCamDevice, + "SMART.TAPODOORBELL.HTTPS": SmartCamDevice, "SMART.TAPOROBOVAC.HTTPS": SmartDevice, "IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTBULB": IotBulb, @@ -194,7 +195,10 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol protocol_name = ctype.device_family.value.split(".")[0] _LOGGER.debug("Finding protocol for %s", ctype.device_family) - if ctype.device_family is DeviceFamily.SmartIpCamera: + if ctype.device_family in { + DeviceFamily.SmartIpCamera, + DeviceFamily.SmartTapoDoorbell, + }: if strict and ctype.encryption_type is not DeviceEncryptionType.Aes: return None return SmartCamProtocol(transport=SslAesTransport(config=config)) diff --git a/kasa/device_type.py b/kasa/device_type.py index 7fe485d33..d39962179 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -22,6 +22,8 @@ class DeviceType(Enum): Fan = "fan" Thermostat = "thermostat" Vacuum = "vacuum" + Chime = "chime" + Doorbell = "doorbell" Unknown = "unknown" @staticmethod diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index b63255701..2b669f809 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -79,6 +79,8 @@ class DeviceFamily(Enum): SmartKasaHub = "SMART.KASAHUB" SmartIpCamera = "SMART.IPCAMERA" SmartTapoRobovac = "SMART.TAPOROBOVAC" + SmartTapoChime = "SMART.TAPOCHIME" + SmartTapoDoorbell = "SMART.TAPODOORBELL" class _DeviceConfigBaseMixin(DataClassJSONMixin): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index ee86b0e2a..c668a208c 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -885,6 +885,8 @@ def _get_device_type_from_components( return DeviceType.Thermostat if "ROBOVAC" in device_type: return DeviceType.Vacuum + if "TAPOCHIME" in device_type: + return DeviceType.Chime _LOGGER.warning("Unknown device type, falling back to plug") return DeviceType.Plug diff --git a/kasa/smartcam/modules/camera.py b/kasa/smartcam/modules/camera.py index 9a339120f..bd4b28086 100644 --- a/kasa/smartcam/modules/camera.py +++ b/kasa/smartcam/modules/camera.py @@ -9,7 +9,6 @@ from urllib.parse import quote_plus from ...credentials import Credentials -from ...device_type import DeviceType from ...feature import Feature from ...json import loads as json_loads from ...module import FeatureAttribute, Module @@ -31,6 +30,8 @@ class StreamResolution(StrEnum): class Camera(SmartCamModule): """Implementation of device module.""" + REQUIRED_COMPONENT = "video" + def _initialize_features(self) -> None: """Initialize features after the initial update.""" if Module.LensMask in self._device.modules: @@ -126,7 +127,3 @@ def onvif_url(self) -> str | None: return None return f"http://{self._device.host}:{ONVIF_PORT}/onvif/device_service" - - async def _check_supported(self) -> bool: - """Additional check to see if the module is supported by the device.""" - return self._device.device_type is DeviceType.Camera diff --git a/kasa/smartcam/smartcamchild.py b/kasa/smartcam/smartcamchild.py index d1b263b49..d26144647 100644 --- a/kasa/smartcam/smartcamchild.py +++ b/kasa/smartcam/smartcamchild.py @@ -85,6 +85,13 @@ def _update_internal_state(self, info: dict[str, Any]) -> None: # devices self._info = self._map_child_info_from_parent(info) + @property + def device_type(self) -> DeviceType: + """Return the device type.""" + if self._device_type == DeviceType.Unknown and self._info: + self._device_type = self._get_device_type_from_sysinfo(self._info) + return self._device_type + @staticmethod def _get_device_info( info: dict[str, Any], discovery_info: dict[str, Any] | None diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index d096fb5b5..fc9d0b92a 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -26,12 +26,15 @@ class SmartCamDevice(SmartDevice): @staticmethod def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType: """Find type to be displayed as a supported device category.""" - if ( - sysinfo - and (device_type := sysinfo.get("device_type")) - and device_type.endswith("HUB") - ): + if not (device_type := sysinfo.get("device_type")): + return DeviceType.Unknown + + if device_type.endswith("HUB"): return DeviceType.Hub + + if "DOORBELL" in device_type: + return DeviceType.Doorbell + return DeviceType.Camera @staticmethod @@ -165,11 +168,6 @@ async def _initialize_modules(self) -> None: if ( mod.REQUIRED_COMPONENT and mod.REQUIRED_COMPONENT not in self._components - # Always add Camera module to cameras - and ( - mod._module_name() != Module.Camera - or self._device_type is not DeviceType.Camera - ) ): continue module = mod(self, mod._module_name()) @@ -258,7 +256,7 @@ async def set_state(self, on: bool) -> dict: @property def device_type(self) -> DeviceType: """Return the device type.""" - if self._device_type == DeviceType.Unknown: + if self._device_type == DeviceType.Unknown and self._info: self._device_type = self._get_device_type_from_sysinfo(self._info) return self._device_type diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index f28b17e3d..f6a2dfe45 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -131,6 +131,7 @@ "S200D", "S210", "S220", + "D100C", # needs a home category? } THERMOSTATS_SMART = {"KE100"} @@ -345,6 +346,16 @@ def parametrize( device_type_filter=[DeviceType.Hub], protocol_filter={"SMARTCAM"}, ) +doobell_smartcam = parametrize( + "doorbell smartcam", + device_type_filter=[DeviceType.Doorbell], + protocol_filter={"SMARTCAM", "SMARTCAM.CHILD"}, +) +chime_smart = parametrize( + "chime smart", + device_type_filter=[DeviceType.Chime], + protocol_filter={"SMART"}, +) vacuum = parametrize("vacuums", device_type_filter=[DeviceType.Vacuum]) @@ -362,7 +373,9 @@ def check_categories(): + hubs_smart.args[1] + sensors_smart.args[1] + thermostats_smart.args[1] + + chime_smart.args[1] + camera_smartcam.args[1] + + doobell_smartcam.args[1] + hub_smartcam.args[1] + vacuum.args[1] ) diff --git a/tests/test_device.py b/tests/test_device.py index 4f74e89cf..2c001bc63 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -121,19 +121,9 @@ async def test_device_class_repr(device_class_name_obj): klass = device_class_name_obj[1] if issubclass(klass, SmartChildDevice | SmartCamChild): parent = SmartDevice(host, config=config) - smartcam_required = { - "device_model": "foo", - "device_type": "SMART.TAPODOORBELL", - "alias": "Foo", - "sw_ver": "1.1", - "hw_ver": "1.0", - "mac": "1.2.3.4", - "hwId": "hw_id", - "oem_id": "oem_id", - } dev = klass( parent, - {"dummy": "info", "device_id": "dummy", **smartcam_required}, + {"dummy": "info", "device_id": "dummy"}, { "component_list": [{"id": "device", "ver_code": 1}], "app_component_list": [{"name": "device", "version": 1}], @@ -153,8 +143,8 @@ async def test_device_class_repr(device_class_name_obj): IotCamera: DeviceType.Camera, SmartChildDevice: DeviceType.Unknown, SmartDevice: DeviceType.Unknown, - SmartCamDevice: DeviceType.Camera, - SmartCamChild: DeviceType.Camera, + SmartCamDevice: DeviceType.Unknown, + SmartCamChild: DeviceType.Unknown, } type_ = CLASS_TO_DEFAULT_TYPE[klass] child_repr = ">" diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index d6bdaedf1..539609c38 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -245,6 +245,12 @@ async def test_device_class_from_unknown_family(caplog): SslAesTransport, id="smartcam-hub", ), + pytest.param( + CP(DF.SmartTapoDoorbell, ET.Aes, https=True), + SmartCamProtocol, + SslAesTransport, + id="smartcam-doorbell", + ), pytest.param( CP(DF.IotIpCamera, ET.Aes, https=True), IotProtocol, @@ -281,6 +287,12 @@ async def test_device_class_from_unknown_family(caplog): KlapTransportV2, id="smart-klap", ), + pytest.param( + CP(DF.SmartTapoChime, ET.Klap, https=False), + SmartProtocol, + KlapTransportV2, + id="smart-chime", + ), ], ) async def test_get_protocol( From 57c4ffa8a385430c1c840bd84d8ac9a4ba3945e2 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 23 Jan 2025 09:29:25 +0000 Subject: [PATCH 828/892] Add D100C(US) 1.0 1.1.3 fixture (#1475) --- README.md | 2 +- SUPPORTED.md | 2 + tests/fixtures/smart/D100C(US)_1.0_1.1.3.json | 258 ++++++++++++++++++ 3 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smart/D100C(US)_1.0_1.1.3.json diff --git a/README.md b/README.md index 9761684f3..aad0c0c0b 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ The following devices have been tested and confirmed as working. If your device - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Cameras**: C100, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70 -- **Doorbells and chimes**: D230 +- **Doorbells and chimes**: D100C, D230 - **Vacuums**: RV20 Max Plus, RV30 Max - **Hubs**: H100, H200 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 diff --git a/SUPPORTED.md b/SUPPORTED.md index 01d2d63e4..fb70db365 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -292,6 +292,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros ### Doorbells and chimes +- **D100C** + - Hardware: 1.0 (US) / Firmware: 1.1.3 - **D230** - Hardware: 1.20 (EU) / Firmware: 1.1.19 diff --git a/tests/fixtures/smart/D100C(US)_1.0_1.1.3.json b/tests/fixtures/smart/D100C(US)_1.0_1.1.3.json new file mode 100644 index 000000000..25d598603 --- /dev/null +++ b/tests/fixtures/smart/D100C(US)_1.0_1.1.3.json @@ -0,0 +1,258 @@ +{ + "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 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "D100C(US)", + "device_type": "SMART.TAPOCHIME", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.3 Build 231221 Rel.154700", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "40-AE-30-00-00-00", + "model": "D100C", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "America/Chicago", + "rssi": -24, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.TAPOCHIME" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1736433406 + }, + "get_device_usage": {}, + "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.1.3 Build 231221 Rel.154700", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 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": 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": 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==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 9, + "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": "D100C", + "device_type": "SMART.TAPOCHIME", + "is_klap": true + } + } +} From bd43e0f7d23d1afb78fa5aa883071a0f3bc03ad7 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 23 Jan 2025 09:35:54 +0000 Subject: [PATCH 829/892] Add D130(US) 1.0 1.1.9 fixture (#1476) --- README.md | 2 +- SUPPORTED.md | 2 + .../fixtures/smartcam/D130(US)_1.0_1.1.9.json | 986 ++++++++++++++++++ 3 files changed, 989 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smartcam/D130(US)_1.0_1.1.9.json diff --git a/README.md b/README.md index aad0c0c0b..b4bbf81bd 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ The following devices have been tested and confirmed as working. If your device - **Bulbs**: L510B, L510E, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Cameras**: C100, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70 -- **Doorbells and chimes**: D100C, D230 +- **Doorbells and chimes**: D100C, D130, D230 - **Vacuums**: RV20 Max Plus, RV30 Max - **Hubs**: H100, H200 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 diff --git a/SUPPORTED.md b/SUPPORTED.md index fb70db365..876566cd6 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -294,6 +294,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **D100C** - Hardware: 1.0 (US) / Firmware: 1.1.3 +- **D130** + - Hardware: 1.0 (US) / Firmware: 1.1.9 - **D230** - Hardware: 1.20 (EU) / Firmware: 1.1.19 diff --git a/tests/fixtures/smartcam/D130(US)_1.0_1.1.9.json b/tests/fixtures/smartcam/D130(US)_1.0_1.1.9.json new file mode 100644 index 000000000..7cd498f7f --- /dev/null +++ b/tests/fixtures/smartcam/D130(US)_1.0_1.1.9.json @@ -0,0 +1,986 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "D130", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPODOORBELL", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.1.9 Build 240716 Rel.51615n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "15", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "light", + "sound" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Tone" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 2 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 1 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 3 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "linecrossingDetection", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "nightVisionMode", + "version": 3 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "packageDetection", + "version": 3 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "whiteLamp", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "quickResponse", + "version": 1 + }, + { + "name": "ldc", + "version": 1 + }, + { + "name": "upnpc", + "version": 2 + }, + { + "name": "chimeCtrl", + "version": 1 + }, + { + "name": "ring", + "version": 3 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "staticIp", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "80" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "80" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-01-09 08:38:30", + "seconds_from_1970": 1736433510 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -46, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "off", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera d130", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "D130 1.0 IPC", + "device_model": "D130", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPODOORBELL", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "40-AE-30-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "no_rtsp_constrain": 1, + "oem_id": "00000000000000000000000000000000", + "region": "US", + "sw_version": "1.1.9 Build 240716 Rel.51615n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "off", + "random_range": "120", + "time": "15:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1736432241", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "on", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "auto" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision", + "md_night_vision", + "dbl_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp", + "white_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "on", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "on", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "normal", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "1", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "1723813993", + "rw_attr": "rw", + "status": "normal", + "total_space": "119.1GB", + "total_space_accurate": "127878135808B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "114.3GB", + "video_total_space_accurate": "122675003392B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-06:00", + "timing_mode": "ntp", + "zone_id": "America/Chicago" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1536", + "2048", + "2560", + "3072" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561", + "65566" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2560*1920" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "3072", + "bitrate_type": "vbr", + "default_bitrate": "3072", + "encode_type": "H264", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2560*1920", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "on", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8" + ], + "system_volume": "80", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "80" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "80" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "ptz": "0", + "record_max_slot_cnt": "6", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "0", + "smart_codec": "0", + "smart_detection": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "0", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "true" + } + } + } +} From 5e57f8bd6c2f7bc1529e57c7efef31d19afafff0 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 23 Jan 2025 09:42:37 +0000 Subject: [PATCH 830/892] Add childsetup module to smartcam hubs (#1469) Add the `childsetup` module for `smartcam` hubs to allow pairing and unpairing child devices. --- kasa/smart/modules/childsetup.py | 7 +- kasa/smart/smartdevice.py | 8 +- kasa/smartcam/modules/__init__.py | 2 + kasa/smartcam/modules/childsetup.py | 107 ++++++++++++++++++++++ kasa/smartcam/smartcamdevice.py | 7 -- kasa/smartcam/smartcammodule.py | 18 +--- tests/fakeprotocol_smartcam.py | 47 +++++++++- tests/smartcam/modules/test_childsetup.py | 103 +++++++++++++++++++++ tests/test_cli.py | 6 +- tests/test_feature.py | 12 +-- 10 files changed, 278 insertions(+), 39 deletions(-) create mode 100644 kasa/smartcam/modules/childsetup.py create mode 100644 tests/smartcam/modules/test_childsetup.py diff --git a/kasa/smart/modules/childsetup.py b/kasa/smart/modules/childsetup.py index 04444e2e9..b1a171021 100644 --- a/kasa/smart/modules/childsetup.py +++ b/kasa/smart/modules/childsetup.py @@ -48,7 +48,10 @@ async def pair(self, *, timeout: int = 10) -> list[dict]: detected = await self._get_detected_devices() if not detected["child_device_list"]: - _LOGGER.info("No devices found.") + _LOGGER.warning( + "No devices found, make sure to activate pairing " + "mode on the devices to be added." + ) return [] _LOGGER.info( @@ -63,7 +66,7 @@ async def pair(self, *, timeout: int = 10) -> list[dict]: async def unpair(self, device_id: str) -> dict: """Remove device from the hub.""" - _LOGGER.debug("Going to unpair %s from %s", device_id, self) + _LOGGER.info("Going to unpair %s from %s", device_id, self) payload = {"child_device_list": [{"device_id": device_id}]} return await self.call("remove_child_device_list", payload) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index c668a208c..f2daf0d79 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -691,12 +691,8 @@ def _update_internal_state(self, info: dict[str, Any]) -> None: """ self._info = info - async def _query_helper( - self, method: str, params: dict | None = None, child_ids: None = None - ) -> dict: - res = await self.protocol.query({method: params}) - - return res + async def _query_helper(self, method: str, params: dict | None = None) -> dict: + return await self.protocol.query({method: params}) @property def ssid(self) -> str: diff --git a/kasa/smartcam/modules/__init__.py b/kasa/smartcam/modules/__init__.py index 14bd24f1e..4f6ed866a 100644 --- a/kasa/smartcam/modules/__init__.py +++ b/kasa/smartcam/modules/__init__.py @@ -5,6 +5,7 @@ from .battery import Battery from .camera import Camera from .childdevice import ChildDevice +from .childsetup import ChildSetup from .device import DeviceModule from .homekit import HomeKit from .led import Led @@ -23,6 +24,7 @@ "Battery", "Camera", "ChildDevice", + "ChildSetup", "DeviceModule", "Led", "PanTilt", diff --git a/kasa/smartcam/modules/childsetup.py b/kasa/smartcam/modules/childsetup.py new file mode 100644 index 000000000..d54bce4e9 --- /dev/null +++ b/kasa/smartcam/modules/childsetup.py @@ -0,0 +1,107 @@ +"""Implementation for child device setup. + +This module allows pairing and disconnecting child devices. +""" + +from __future__ import annotations + +import asyncio +import logging + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class ChildSetup(SmartCamModule): + """Implementation for child device setup.""" + + REQUIRED_COMPONENT = "childQuickSetup" + QUERY_GETTER_NAME = "getSupportChildDeviceCategory" + QUERY_MODULE_NAME = "childControl" + _categories: list[str] = [] + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="pair", + name="Pair", + container=self, + attribute_setter="pair", + category=Feature.Category.Config, + type=Feature.Type.Action, + ) + ) + + async def _post_update_hook(self) -> None: + if not self._categories: + self._categories = [ + cat["category"].replace("ipcamera", "camera") + for cat in self.data["device_category_list"] + ] + + @property + def supported_child_device_categories(self) -> list[str]: + """Supported child device categories.""" + return self._categories + + async def pair(self, *, timeout: int = 10) -> list[dict]: + """Scan for new devices and pair after discovering first new device.""" + await self.call( + "startScanChildDevice", {"childControl": {"category": self._categories}} + ) + + _LOGGER.info("Waiting %s seconds for discovering new devices", timeout) + + await asyncio.sleep(timeout) + res = await self.call( + "getScanChildDeviceList", {"childControl": {"category": self._categories}} + ) + + detected_list = res["getScanChildDeviceList"]["child_device_list"] + if not detected_list: + _LOGGER.warning( + "No devices found, make sure to activate pairing " + "mode on the devices to be added." + ) + return [] + + _LOGGER.info( + "Discovery done, found %s devices: %s", + len(detected_list), + detected_list, + ) + return await self._add_devices(detected_list) + + async def _add_devices(self, detected_list: list[dict]) -> list: + """Add devices based on getScanChildDeviceList response.""" + await self.call( + "addScanChildDeviceList", + {"childControl": {"child_device_list": detected_list}}, + ) + + await self._device.update() + + successes = [] + for detected in detected_list: + device_id = detected["device_id"] + + result = "not added" + if device_id in self._device._children: + result = "added" + successes.append(detected) + + msg = f"{detected['device_model']} - {device_id} - {result}" + _LOGGER.info("Adding child to %s: %s", self._device.host, msg) + + return successes + + async def unpair(self, device_id: str) -> dict: + """Remove device from the hub.""" + _LOGGER.info("Going to unpair %s from %s", device_id, self) + + payload = {"childControl": {"child_device_list": [{"device_id": device_id}]}} + return await self.call("removeChildDeviceList", payload) diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index fc9d0b92a..1bf58532f 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -188,13 +188,6 @@ async def _query_setter_helper( return res - async def _query_getter_helper( - self, method: str, module: str, sections: str | list[str] - ) -> Any: - res = await self.protocol.query({method: {module: {"name": sections}}}) - - return res - @staticmethod def _parse_components(components_raw: ComponentsRaw) -> dict[str, int]: return { diff --git a/kasa/smartcam/smartcammodule.py b/kasa/smartcam/smartcammodule.py index ef00d47dc..400b16740 100644 --- a/kasa/smartcam/smartcammodule.py +++ b/kasa/smartcam/smartcammodule.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Final, cast +from typing import TYPE_CHECKING, Final from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..modulemapping import ModuleName @@ -68,21 +68,7 @@ async def call(self, method: str, params: dict | None = None) -> dict: Just a helper method. """ - if params: - module = next(iter(params)) - section = next(iter(params[module])) - else: - module = "system" - section = "null" - - if method[:3] == "get": - return await self._device._query_getter_helper(method, module, section) - - if TYPE_CHECKING: - params = cast(dict[str, dict[str, Any]], params) - return await self._device._query_setter_helper( - method, module, section, params[module][section] - ) + return await self._device._query_helper(method, params) @property def data(self) -> dict: diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index 11a879b4a..5e4396261 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -153,7 +153,33 @@ def _get_param_set_value(info: dict, set_keys: list[str], value): "setup_code": "00000000000", "setup_payload": "00:0000000-0000.00.000", }, - ) + ), + "getSupportChildDeviceCategory": ( + "childQuickSetup", + { + "device_category_list": [ + {"category": "ipcamera"}, + {"category": "subg.trv"}, + {"category": "subg.trigger"}, + {"category": "subg.plugswitch"}, + ] + }, + ), + "getScanChildDeviceList": ( + "childQuickSetup", + { + "child_device_list": [ + { + "device_id": "0000000000000000000000000000000000000000", + "category": "subg.trigger.button", + "device_model": "S200B", + "name": "I01BU0tFRF9OQU1FIw====", + } + ], + "scan_wait_time": 55, + "scan_status": "scanning", + }, + ), } # Setters for when there's not a simple mapping of setters to getters SETTERS = { @@ -179,6 +205,17 @@ def _get_param_set_value(info: dict, set_keys: list[str], value): ], } + def _hub_remove_device(self, info, params): + """Remove hub device.""" + items_to_remove = [dev["device_id"] for dev in params["child_device_list"]] + children = info["getChildDeviceList"]["child_device_list"] + new_children = [ + dev for dev in children if dev["device_id"] not in items_to_remove + ] + info["getChildDeviceList"]["child_device_list"] = new_children + + return {"result": {}, "error_code": 0} + @staticmethod def _get_second_key(request_dict: dict[str, Any]) -> str: assert ( @@ -269,6 +306,14 @@ async def _send_request(self, request_dict: dict): return {**result, "error_code": 0} else: return {"error_code": -1} + elif method == "removeChildDeviceList": + return self._hub_remove_device(info, request_dict["params"]["childControl"]) + # actions + elif method in [ + "addScanChildDeviceList", + "startScanChildDevice", + ]: + return {"result": {}, "error_code": 0} # smartcam child devices do not make requests for getDeviceInfo as they # get updated from the parent's query. If this is being called from a diff --git a/tests/smartcam/modules/test_childsetup.py b/tests/smartcam/modules/test_childsetup.py new file mode 100644 index 000000000..a419393dd --- /dev/null +++ b/tests/smartcam/modules/test_childsetup.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import logging + +import pytest +from pytest_mock import MockerFixture + +from kasa import Feature, Module, SmartDevice + +from ...device_fixtures import parametrize + +childsetup = parametrize( + "supports pairing", component_filter="childQuickSetup", protocol_filter={"SMARTCAM"} +) + + +@childsetup +async def test_childsetup_features(dev: SmartDevice): + """Test the exposed features.""" + cs = dev.modules[Module.ChildSetup] + + assert "pair" in cs._module_features + pair = cs._module_features["pair"] + assert pair.type == Feature.Type.Action + + +@childsetup +async def test_childsetup_pair( + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """Test device pairing.""" + caplog.set_level(logging.INFO) + mock_query_helper = mocker.spy(dev, "_query_helper") + mocker.patch("asyncio.sleep") + + cs = dev.modules[Module.ChildSetup] + + await cs.pair() + + mock_query_helper.assert_has_awaits( + [ + mocker.call( + "startScanChildDevice", + params={ + "childControl": { + "category": [ + "camera", + "subg.trv", + "subg.trigger", + "subg.plugswitch", + ] + } + }, + ), + mocker.call( + "getScanChildDeviceList", + { + "childControl": { + "category": [ + "camera", + "subg.trv", + "subg.trigger", + "subg.plugswitch", + ] + } + }, + ), + mocker.call( + "addScanChildDeviceList", + { + "childControl": { + "child_device_list": [ + { + "device_id": "0000000000000000000000000000000000000000", + "category": "subg.trigger.button", + "device_model": "S200B", + "name": "I01BU0tFRF9OQU1FIw====", + } + ] + } + }, + ), + ] + ) + assert "Discovery done" in caplog.text + + +@childsetup +async def test_childsetup_unpair( + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """Test unpair.""" + mock_query_helper = mocker.spy(dev, "_query_helper") + DUMMY_ID = "dummy_id" + + cs = dev.modules[Module.ChildSetup] + + await cs.unpair(DUMMY_ID) + + mock_query_helper.assert_awaited_with( + "removeChildDeviceList", + params={"childControl": {"child_device_list": [{"device_id": DUMMY_ID}]}}, + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 19958d552..269bc7aa0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -267,7 +267,11 @@ async def test_raw_command(dev, mocker, runner): from kasa.smart import SmartDevice if isinstance(dev, SmartCamDevice): - params = ["na", "getDeviceInfo"] + params = [ + "na", + "getDeviceInfo", + '{"device_info": {"name": ["basic_info", "info"]}}', + ] elif isinstance(dev, SmartDevice): params = ["na", "get_device_info"] else: diff --git a/tests/test_feature.py b/tests/test_feature.py index 33a07106c..3ccabeb46 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -191,12 +191,12 @@ async def _test_features(dev): exceptions = [] for feat in dev.features.values(): try: - prot = ( - feat.container._device.protocol - if feat.container - else feat.device.protocol - ) - with patch.object(prot, "query", name=feat.id) as query: + patch_dev = feat.container._device if feat.container else feat.device + with ( + patch.object(patch_dev.protocol, "query", name=feat.id) as query, + # patch update in case feature setter does an update + patch.object(patch_dev, "update"), + ): await _test_feature(feat, query) # we allow our own exceptions to avoid mocking valid responses except KasaException: From 988eb96bd177e283ffd5515e9f135b39f4d57237 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:26:55 +0000 Subject: [PATCH 831/892] Update test framework to support smartcam device discovery. (#1477) Update test framework to support `smartcam` device discovery: - Add `SMARTCAM` to the default `discovery_mock` filter - Make connection parameter derivation a self contained static method in `Discover` - Introduce a queue to the `discovery_mock` to ensure the discovery callbacks complete in the same order that they started. - Patch `Discover._decrypt_discovery_data` in `discovery_mock` so it doesn't error trying to decrypt empty fixture data --- kasa/discover.py | 86 ++++++++++++++++++++---------------- tests/discovery_fixtures.py | 69 ++++++++++++++++++++++++----- tests/test_device_factory.py | 14 +----- 3 files changed, 107 insertions(+), 62 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index 36d6f2773..a943ddd40 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -799,6 +799,47 @@ def _get_discovery_json(data: bytes, ip: str) -> dict: ) from ex return info + @staticmethod + def _get_connection_parameters( + discovery_result: DiscoveryResult, + ) -> DeviceConnectionParameters: + """Get connection parameters from the discovery result.""" + type_ = discovery_result.device_type + if (encrypt_schm := discovery_result.mgt_encrypt_schm) is None: + raise UnsupportedDeviceError( + f"Unsupported device {discovery_result.ip} of type {type_} " + "with no mgt_encrypt_schm", + discovery_result=discovery_result.to_dict(), + host=discovery_result.ip, + ) + + if not (encrypt_type := encrypt_schm.encrypt_type) and ( + encrypt_info := discovery_result.encrypt_info + ): + encrypt_type = encrypt_info.sym_schm + + if not (login_version := encrypt_schm.lv) and ( + et := discovery_result.encrypt_type + ): + # Known encrypt types are ["1","2"] and ["3"] + # Reuse the login_version attribute to pass the max to transport + login_version = max([int(i) for i in et]) + + if not encrypt_type: + raise UnsupportedDeviceError( + f"Unsupported device {discovery_result.ip} of type {type_} " + + "with no encryption type", + discovery_result=discovery_result.to_dict(), + host=discovery_result.ip, + ) + return DeviceConnectionParameters.from_values( + type_, + encrypt_type, + login_version=login_version, + https=encrypt_schm.is_support_https, + http_port=encrypt_schm.http_port, + ) + @staticmethod def _get_device_instance( info: dict, @@ -838,55 +879,22 @@ def _get_device_instance( config.host, redact_data(info, NEW_DISCOVERY_REDACTORS), ) - type_ = discovery_result.device_type - if (encrypt_schm := discovery_result.mgt_encrypt_schm) is None: - raise UnsupportedDeviceError( - f"Unsupported device {config.host} of type {type_} " - "with no mgt_encrypt_schm", - discovery_result=discovery_result.to_dict(), - host=config.host, - ) - try: - if not (encrypt_type := encrypt_schm.encrypt_type) and ( - encrypt_info := discovery_result.encrypt_info - ): - encrypt_type = encrypt_info.sym_schm - - if not (login_version := encrypt_schm.lv) and ( - et := discovery_result.encrypt_type - ): - # Known encrypt types are ["1","2"] and ["3"] - # Reuse the login_version attribute to pass the max to transport - login_version = max([int(i) for i in et]) - - if not encrypt_type: - raise UnsupportedDeviceError( - f"Unsupported device {config.host} of type {type_} " - + "with no encryption type", - discovery_result=discovery_result.to_dict(), - host=config.host, - ) - config.connection_type = DeviceConnectionParameters.from_values( - type_, - encrypt_type, - login_version=login_version, - https=encrypt_schm.is_support_https, - http_port=encrypt_schm.http_port, - ) + conn_params = Discover._get_connection_parameters(discovery_result) + config.connection_type = conn_params except KasaException as ex: + if isinstance(ex, UnsupportedDeviceError): + raise raise UnsupportedDeviceError( f"Unsupported device {config.host} of type {type_} " - + f"with encrypt_type {encrypt_schm.encrypt_type}", + + f"with encrypt_scheme {discovery_result.mgt_encrypt_schm}", discovery_result=discovery_result.to_dict(), host=config.host, ) from ex if ( - device_class := get_device_class_from_family( - type_, https=encrypt_schm.is_support_https - ) + device_class := get_device_class_from_family(type_, https=conn_params.https) ) is None: _LOGGER.debug("Got unsupported device type: %s", type_) raise UnsupportedDeviceError( diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index 2db79e913..3cf726f48 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -1,6 +1,8 @@ from __future__ import annotations +import asyncio import copy +from collections.abc import Coroutine from dataclasses import dataclass from json import dumps as json_dumps from typing import Any, TypedDict @@ -34,7 +36,7 @@ class DiscoveryResponse(TypedDict): "group_id": "REDACTED_07d902da02fa9beab8a64", "group_name": "I01BU0tFRF9TU0lEIw==", # '#MASKED_SSID#' "hardware_version": "3.0", - "ip": "192.168.1.192", + "ip": "127.0.0.1", "mac": "24:2F:D0:00:00:00", "master_device_id": "REDACTED_51f72a752213a6c45203530", "need_account_digest": True, @@ -134,7 +136,9 @@ def parametrize_discovery( @pytest.fixture( - params=filter_fixtures("discoverable", protocol_filter={"SMART", "IOT"}), + params=filter_fixtures( + "discoverable", protocol_filter={"SMART", "SMARTCAM", "IOT"} + ), ids=idgenerator, ) async def discovery_mock(request, mocker): @@ -251,12 +255,46 @@ def patch_discovery(fixture_infos: dict[str, FixtureInfo], mocker): first_ip = list(fixture_infos.keys())[0] first_host = None + # Mock _run_callback_task so the tasks complete in the order they started. + # Otherwise test output is non-deterministic which affects readme examples. + callback_queue: asyncio.Queue = asyncio.Queue() + exception_queue: asyncio.Queue = asyncio.Queue() + + async def process_callback_queue(finished_event: asyncio.Event) -> None: + while (finished_event.is_set() is False) or callback_queue.qsize(): + coro = await callback_queue.get() + try: + await coro + except Exception as ex: + await exception_queue.put(ex) + else: + await exception_queue.put(None) + callback_queue.task_done() + + async def wait_for_coro(): + await callback_queue.join() + if ex := exception_queue.get_nowait(): + raise ex + + def _run_callback_task(self, coro: Coroutine) -> None: + callback_queue.put_nowait(coro) + task = asyncio.create_task(wait_for_coro()) + self.callback_tasks.append(task) + + mocker.patch( + "kasa.discover._DiscoverProtocol._run_callback_task", _run_callback_task + ) + + # do_discover_mock async def mock_discover(self): """Call datagram_received for all mock fixtures. Handles test cases modifying the ip and hostname of the first fixture for discover_single testing. """ + finished_event = asyncio.Event() + asyncio.create_task(process_callback_queue(finished_event)) + for ip, dm in discovery_mocks.items(): first_ip = list(discovery_mocks.values())[0].ip fixture_info = fixture_infos[ip] @@ -283,10 +321,18 @@ async def mock_discover(self): dm._datagram, (dm.ip, port), ) + # Setting this event will stop the processing of callbacks + finished_event.set() + + mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) + # query_mock async def _query(self, request, retry_count: int = 3): return await protos[self._host].query(request) + mocker.patch("kasa.IotProtocol.query", _query) + mocker.patch("kasa.SmartProtocol.query", _query) + def _getaddrinfo(host, *_, **__): nonlocal first_host, first_ip first_host = host # Store the hostname used by discover single @@ -295,20 +341,21 @@ def _getaddrinfo(host, *_, **__): ].ip # ip could have been overridden in test return [(None, None, None, None, (first_ip, 0))] - mocker.patch("kasa.IotProtocol.query", _query) - mocker.patch("kasa.SmartProtocol.query", _query) - mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) - mocker.patch( - "socket.getaddrinfo", - # side_effect=lambda *_, **__: [(None, None, None, None, (first_ip, 0))], - side_effect=_getaddrinfo, - ) + mocker.patch("socket.getaddrinfo", side_effect=_getaddrinfo) + + # Mock decrypt so it doesn't error with unencryptable empty data in the + # fixtures. The discovery result will already contain the decrypted data + # deserialized from the fixture + mocker.patch("kasa.discover.Discover._decrypt_discovery_data") + # Only return the first discovery mock to be used for testing discover single return discovery_mocks[first_ip] @pytest.fixture( - params=filter_fixtures("discoverable", protocol_filter={"SMART", "IOT"}), + params=filter_fixtures( + "discoverable", protocol_filter={"SMART", "SMARTCAM", "IOT"} + ), ids=idgenerator, ) def discovery_data(request, mocker): diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 539609c38..19ccfb73d 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -60,13 +60,7 @@ def _get_connection_type_device_class(discovery_info): device_class = Discover._get_device_class(discovery_info) dr = DiscoveryResult.from_dict(discovery_info["result"]) - connection_type = DeviceConnectionParameters.from_values( - dr.device_type, - dr.mgt_encrypt_schm.encrypt_type, - login_version=dr.mgt_encrypt_schm.lv, - https=dr.mgt_encrypt_schm.is_support_https, - http_port=dr.mgt_encrypt_schm.http_port, - ) + connection_type = Discover._get_connection_parameters(dr) else: connection_type = DeviceConnectionParameters.from_values( DeviceFamily.IotSmartPlugSwitch.value, DeviceEncryptionType.Xor.value @@ -118,11 +112,7 @@ async def test_connect_custom_port(discovery_mock, mocker, custom_port): connection_type=ctype, credentials=Credentials("dummy_user", "dummy_password"), ) - default_port = ( - DiscoveryResult.from_dict(discovery_data["result"]).mgt_encrypt_schm.http_port - if "result" in discovery_data - else 9999 - ) + default_port = discovery_mock.default_port ctype, _ = _get_connection_type_device_class(discovery_data) From b6a584971a18767de88901a887ef5854d2f92c01 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Thu, 23 Jan 2025 12:43:02 +0100 Subject: [PATCH 832/892] Add error code 7 for clean module (#1474) --- kasa/smart/modules/clean.py | 1 + 1 file changed, 1 insertion(+) diff --git a/kasa/smart/modules/clean.py b/kasa/smart/modules/clean.py index 2764e8a15..393a4f293 100644 --- a/kasa/smart/modules/clean.py +++ b/kasa/smart/modules/clean.py @@ -38,6 +38,7 @@ class ErrorCode(IntEnum): MainBrushStuck = 3 WheelBlocked = 4 Trapped = 6 + TrappedCliff = 7 DustBinRemoved = 14 UnableToMove = 15 LidarBlocked = 16 From b70144121541b46c835b5b0f61c67bce42074936 Mon Sep 17 00:00:00 2001 From: Nathan Wreggit Date: Thu, 23 Jan 2025 07:05:38 -0800 Subject: [PATCH 833/892] Fix iot strip turn on and off from parent (#639) Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- kasa/iot/iotstrip.py | 10 ++++++++-- tests/fakeprotocol_iot.py | 4 ---- tests/iot/test_iotdevice.py | 3 ++- tests/test_feature.py | 6 +++++- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index a4b2ab996..a63b3e17c 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -161,11 +161,17 @@ async def _initialize_features(self) -> None: async def turn_on(self, **kwargs) -> dict: """Turn the strip on.""" - return await self._query_helper("system", "set_relay_state", {"state": 1}) + for plug in self.children: + if plug.is_off: + await plug.turn_on() + return {} async def turn_off(self, **kwargs) -> dict: """Turn the strip off.""" - return await self._query_helper("system", "set_relay_state", {"state": 0}) + for plug in self.children: + if plug.is_on: + await plug.turn_off() + return {} @property # type: ignore @requires_update diff --git a/tests/fakeprotocol_iot.py b/tests/fakeprotocol_iot.py index 88e34647a..23ce78279 100644 --- a/tests/fakeprotocol_iot.py +++ b/tests/fakeprotocol_iot.py @@ -308,10 +308,6 @@ def set_relay_state(self, x, child_ids=None): child_ids = [] _LOGGER.debug("Setting relay state to %s", x["state"]) - if not child_ids and "children" in self.proto["system"]["get_sysinfo"]: - for child in self.proto["system"]["get_sysinfo"]["children"]: - child_ids.append(child["id"]) - _LOGGER.info("child_ids: %s", child_ids) if child_ids: for child in self.proto["system"]["get_sysinfo"]["children"]: diff --git a/tests/iot/test_iotdevice.py b/tests/iot/test_iotdevice.py index 858c5fbcf..0b8228590 100644 --- a/tests/iot/test_iotdevice.py +++ b/tests/iot/test_iotdevice.py @@ -137,8 +137,9 @@ async def test_query_helper(dev): @device_iot @turn_on async def test_state(dev, turn_on): - await handle_turn_on(dev, turn_on) orig_state = dev.is_on + await handle_turn_on(dev, turn_on) + await dev.update() if orig_state: await dev.turn_off() await dev.update() diff --git a/tests/test_feature.py b/tests/test_feature.py index 3ccabeb46..0d6210327 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -5,6 +5,7 @@ from pytest_mock import MockerFixture from kasa import Device, Feature, KasaException +from kasa.iot import IotStrip _LOGGER = logging.getLogger(__name__) @@ -168,7 +169,10 @@ async def _test_feature(feat, query_mock): if feat.attribute_setter is None: return - expecting_call = feat.id not in internal_setters + # IotStrip makes calls via it's children + expecting_call = feat.id not in internal_setters and not isinstance( + dev, IotStrip + ) if feat.type == Feature.Type.Number: await feat.set_value(feat.minimum_value) From 09fce3f426ead6fc0a619b9fbb86ab75923d5358 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 24 Jan 2025 08:08:04 +0000 Subject: [PATCH 834/892] Add common childsetup interface (#1470) Add a common interface for the `childsetup` module across `smart` and `smartcam` hubs. Co-authored-by: Teemu R. --- docs/source/guides/strip.md | 7 +++ docs/tutorial.py | 1 + kasa/cli/hub.py | 3 +- kasa/discover.py | 9 +-- kasa/interfaces/__init__.py | 2 + kasa/interfaces/childsetup.py | 70 +++++++++++++++++++++++ kasa/module.py | 2 +- kasa/smart/modules/childsetup.py | 53 ++++++++++++----- kasa/smartcam/modules/childsetup.py | 25 ++++---- tests/cli/test_hub.py | 6 +- tests/device_fixtures.py | 1 + tests/fakeprotocol_smart.py | 13 ++++- tests/smart/modules/test_childsetup.py | 1 - tests/smartcam/modules/test_childsetup.py | 30 ++-------- tests/test_readme_examples.py | 23 ++++++++ 15 files changed, 185 insertions(+), 61 deletions(-) create mode 100644 kasa/interfaces/childsetup.py diff --git a/docs/source/guides/strip.md b/docs/source/guides/strip.md index d1377eab8..b6e914cc4 100644 --- a/docs/source/guides/strip.md +++ b/docs/source/guides/strip.md @@ -8,3 +8,10 @@ .. automodule:: kasa.smart.modules.childdevice :noindex: ``` + +## Pairing and unpairing + +```{eval-rst} +.. automodule:: kasa.interfaces.childsetup + :noindex: +``` diff --git a/docs/tutorial.py b/docs/tutorial.py index fddcc79a6..1f27ddc17 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -13,6 +13,7 @@ 127.0.0.3 127.0.0.4 127.0.0.5 +127.0.0.6 :meth:`~kasa.Discover.discover_single` returns a single device by hostname: diff --git a/kasa/cli/hub.py b/kasa/cli/hub.py index 444781326..3add28149 100644 --- a/kasa/cli/hub.py +++ b/kasa/cli/hub.py @@ -44,8 +44,7 @@ async def hub_supported(dev: SmartDevice): """List supported hub child device categories.""" cs = dev.modules[Module.ChildSetup] - cats = [cat["category"] for cat in await cs.get_supported_device_categories()] - for cat in cats: + for cat in cs.supported_categories: echo(f"Supports: {cat}") diff --git a/kasa/discover.py b/kasa/discover.py index a943ddd40..8e2b981af 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -22,7 +22,7 @@ >>> >>> found_devices = await Discover.discover() >>> [dev.model for dev in found_devices.values()] -['KP303', 'HS110', 'L530E', 'KL430', 'HS220'] +['KP303', 'HS110', 'L530E', 'KL430', 'HS220', 'H200'] You can pass username and password for devices requiring authentication @@ -31,21 +31,21 @@ >>> password="great_password", >>> ) >>> print(len(devices)) -5 +6 You can also pass a :class:`kasa.Credentials` >>> creds = Credentials("user@example.com", "great_password") >>> devices = await Discover.discover(credentials=creds) >>> print(len(devices)) -5 +6 Discovery can also be targeted to a specific broadcast address instead of the default 255.255.255.255: >>> found_devices = await Discover.discover(target="127.0.0.255", credentials=creds) >>> print(len(found_devices)) -5 +6 Basic information is available on the device from the discovery broadcast response but it is important to call device.update() after discovery if you want to access @@ -70,6 +70,7 @@ Discovered Living Room Bulb (model: L530) Discovered Bedroom Lightstrip (model: KL430) Discovered Living Room Dimmer Switch (model: HS220) +Discovered Tapo Hub (model: H200) Discovering a single device returns a kasa.Device object. diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index e5fd4caee..fc82ee0bc 100644 --- a/kasa/interfaces/__init__.py +++ b/kasa/interfaces/__init__.py @@ -1,5 +1,6 @@ """Package for interfaces.""" +from .childsetup import ChildSetup from .energy import Energy from .fan import Fan from .led import Led @@ -10,6 +11,7 @@ from .time import Time __all__ = [ + "ChildSetup", "Fan", "Energy", "Led", diff --git a/kasa/interfaces/childsetup.py b/kasa/interfaces/childsetup.py new file mode 100644 index 000000000..f91a8383c --- /dev/null +++ b/kasa/interfaces/childsetup.py @@ -0,0 +1,70 @@ +"""Module for childsetup interface. + +The childsetup module allows pairing and unpairing of supported child device types to +hubs. + +>>> from kasa import Discover, Module, LightState +>>> +>>> dev = await Discover.discover_single( +>>> "127.0.0.6", +>>> username="user@example.com", +>>> password="great_password" +>>> ) +>>> await dev.update() +>>> print(dev.alias) +Tapo Hub + +>>> childsetup = dev.modules[Module.ChildSetup] +>>> childsetup.supported_categories +['camera', 'subg.trv', 'subg.trigger', 'subg.plugswitch'] + +Put child devices in pairing mode. +The hub will pair with all supported devices in pairing mode: + +>>> added = await childsetup.pair() +>>> added +[{'device_id': 'SCRUBBED_CHILD_DEVICE_ID_5', 'category': 'subg.trigger.button', \ +'device_model': 'S200B', 'name': 'I01BU0tFRF9OQU1FIw===='}] + +>>> for child in dev.children: +>>> print(f"{child.device_id} - {child.model}") +SCRUBBED_CHILD_DEVICE_ID_1 - T310 +SCRUBBED_CHILD_DEVICE_ID_2 - T315 +SCRUBBED_CHILD_DEVICE_ID_3 - T110 +SCRUBBED_CHILD_DEVICE_ID_4 - S200B +SCRUBBED_CHILD_DEVICE_ID_5 - S200B + +Unpair with the child `device_id`: + +>>> await childsetup.unpair("SCRUBBED_CHILD_DEVICE_ID_4") +>>> for child in dev.children: +>>> print(f"{child.device_id} - {child.model}") +SCRUBBED_CHILD_DEVICE_ID_1 - T310 +SCRUBBED_CHILD_DEVICE_ID_2 - T315 +SCRUBBED_CHILD_DEVICE_ID_3 - T110 +SCRUBBED_CHILD_DEVICE_ID_5 - S200B + +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from ..module import Module + + +class ChildSetup(Module, ABC): + """Interface for child setup on hubs.""" + + @property + @abstractmethod + def supported_categories(self) -> list[str]: + """Supported child device categories.""" + + @abstractmethod + async def pair(self, *, timeout: int = 10) -> list[dict]: + """Scan for new devices and pair them.""" + + @abstractmethod + async def unpair(self, device_id: str) -> dict: + """Remove device from the hub.""" diff --git a/kasa/module.py b/kasa/module.py index 6f188b305..107ce1e60 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -93,6 +93,7 @@ class Module(ABC): """ # Common Modules + ChildSetup: Final[ModuleName[interfaces.ChildSetup]] = ModuleName("ChildSetup") Energy: Final[ModuleName[interfaces.Energy]] = ModuleName("Energy") Fan: Final[ModuleName[interfaces.Fan]] = ModuleName("Fan") LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect") @@ -154,7 +155,6 @@ class Module(ABC): ) ChildLock: Final[ModuleName[smart.ChildLock]] = ModuleName("ChildLock") TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs") - ChildSetup: Final[ModuleName[smart.ChildSetup]] = ModuleName("ChildSetup") HomeKit: Final[ModuleName[smart.HomeKit]] = ModuleName("HomeKit") Matter: Final[ModuleName[smart.Matter]] = ModuleName("Matter") diff --git a/kasa/smart/modules/childsetup.py b/kasa/smart/modules/childsetup.py index b1a171021..f3bf88c8d 100644 --- a/kasa/smart/modules/childsetup.py +++ b/kasa/smart/modules/childsetup.py @@ -9,16 +9,21 @@ import logging from ...feature import Feature +from ...interfaces.childsetup import ChildSetup as ChildSetupInterface from ..smartmodule import SmartModule _LOGGER = logging.getLogger(__name__) -class ChildSetup(SmartModule): +class ChildSetup(SmartModule, ChildSetupInterface): """Implementation for child device setup.""" REQUIRED_COMPONENT = "child_quick_setup" QUERY_GETTER_NAME = "get_support_child_device_category" + _categories: list[str] = [] + + # Supported child device categories will hardly ever change + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 def _initialize_features(self) -> None: """Initialize features.""" @@ -34,13 +39,18 @@ def _initialize_features(self) -> None: ) ) - async def get_supported_device_categories(self) -> list[dict]: - """Get supported device categories.""" - categories = await self.call("get_support_child_device_category") - return categories["get_support_child_device_category"]["device_category_list"] + async def _post_update_hook(self) -> None: + self._categories = [ + cat["category"] for cat in self.data["device_category_list"] + ] + + @property + def supported_categories(self) -> list[str]: + """Supported child device categories.""" + return self._categories async def pair(self, *, timeout: int = 10) -> list[dict]: - """Scan for new devices and pair after discovering first new device.""" + """Scan for new devices and pair them.""" await self.call("begin_scanning_child_device") _LOGGER.info("Waiting %s seconds for discovering new devices", timeout) @@ -60,28 +70,43 @@ async def pair(self, *, timeout: int = 10) -> list[dict]: detected, ) - await self._add_devices(detected) - - return detected["child_device_list"] + return await self._add_devices(detected) async def unpair(self, device_id: str) -> dict: """Remove device from the hub.""" _LOGGER.info("Going to unpair %s from %s", device_id, self) payload = {"child_device_list": [{"device_id": device_id}]} - return await self.call("remove_child_device_list", payload) + res = await self.call("remove_child_device_list", payload) + await self._device.update() + return res - async def _add_devices(self, devices: dict) -> dict: + async def _add_devices(self, devices: dict) -> list[dict]: """Add devices based on get_detected_device response. Pass the output from :ref:_get_detected_devices: as a parameter. """ - res = await self.call("add_child_device_list", devices) - return res + await self.call("add_child_device_list", devices) + + await self._device.update() + + successes = [] + for detected in devices["child_device_list"]: + device_id = detected["device_id"] + + result = "not added" + if device_id in self._device._children: + result = "added" + successes.append(detected) + + msg = f"{detected['device_model']} - {device_id} - {result}" + _LOGGER.info("Added child to %s: %s", self._device.host, msg) + + return successes async def _get_detected_devices(self) -> dict: """Return list of devices detected during scanning.""" - param = {"scan_list": await self.get_supported_device_categories()} + param = {"scan_list": self.data["device_category_list"]} res = await self.call("get_scan_child_device_list", param) _LOGGER.debug("Scan status: %s", res) return res["get_scan_child_device_list"] diff --git a/kasa/smartcam/modules/childsetup.py b/kasa/smartcam/modules/childsetup.py index d54bce4e9..676bd6368 100644 --- a/kasa/smartcam/modules/childsetup.py +++ b/kasa/smartcam/modules/childsetup.py @@ -9,12 +9,13 @@ import logging from ...feature import Feature +from ...interfaces.childsetup import ChildSetup as ChildSetupInterface from ..smartcammodule import SmartCamModule _LOGGER = logging.getLogger(__name__) -class ChildSetup(SmartCamModule): +class ChildSetup(SmartCamModule, ChildSetupInterface): """Implementation for child device setup.""" REQUIRED_COMPONENT = "childQuickSetup" @@ -22,6 +23,9 @@ class ChildSetup(SmartCamModule): QUERY_MODULE_NAME = "childControl" _categories: list[str] = [] + # Supported child device categories will hardly ever change + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 + def _initialize_features(self) -> None: """Initialize features.""" self._add_feature( @@ -37,19 +41,18 @@ def _initialize_features(self) -> None: ) async def _post_update_hook(self) -> None: - if not self._categories: - self._categories = [ - cat["category"].replace("ipcamera", "camera") - for cat in self.data["device_category_list"] - ] + self._categories = [ + cat["category"].replace("ipcamera", "camera") + for cat in self.data["device_category_list"] + ] @property - def supported_child_device_categories(self) -> list[str]: + def supported_categories(self) -> list[str]: """Supported child device categories.""" return self._categories async def pair(self, *, timeout: int = 10) -> list[dict]: - """Scan for new devices and pair after discovering first new device.""" + """Scan for new devices and pair them.""" await self.call( "startScanChildDevice", {"childControl": {"category": self._categories}} ) @@ -76,7 +79,7 @@ async def pair(self, *, timeout: int = 10) -> list[dict]: ) return await self._add_devices(detected_list) - async def _add_devices(self, detected_list: list[dict]) -> list: + async def _add_devices(self, detected_list: list[dict]) -> list[dict]: """Add devices based on getScanChildDeviceList response.""" await self.call( "addScanChildDeviceList", @@ -104,4 +107,6 @@ async def unpair(self, device_id: str) -> dict: _LOGGER.info("Going to unpair %s from %s", device_id, self) payload = {"childControl": {"child_device_list": [{"device_id": device_id}]}} - return await self.call("removeChildDeviceList", payload) + res = await self.call("removeChildDeviceList", payload) + await self._device.update() + return res diff --git a/tests/cli/test_hub.py b/tests/cli/test_hub.py index 5236f4cda..00c3645ed 100644 --- a/tests/cli/test_hub.py +++ b/tests/cli/test_hub.py @@ -4,10 +4,10 @@ from kasa import DeviceType, Module from kasa.cli.hub import hub -from ..device_fixtures import HUBS_SMART, hubs_smart, parametrize, plug_iot +from ..device_fixtures import hubs, plug_iot -@hubs_smart +@hubs async def test_hub_pair(dev, mocker: MockerFixture, runner, caplog): """Test that pair calls the expected methods.""" cs = dev.modules.get(Module.ChildSetup) @@ -25,7 +25,7 @@ async def test_hub_pair(dev, mocker: MockerFixture, runner, caplog): assert res.exit_code == 0 -@parametrize("hubs smart", model_filter=HUBS_SMART, protocol_filter={"SMART"}) +@hubs async def test_hub_unpair(dev, mocker: MockerFixture, runner): """Test that unpair calls the expected method.""" if not dev.children: diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index f6a2dfe45..f9511a1c8 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -346,6 +346,7 @@ def parametrize( device_type_filter=[DeviceType.Hub], protocol_filter={"SMARTCAM"}, ) +hubs = parametrize_combine([hubs_smart, hub_smartcam]) doobell_smartcam = parametrize( "doorbell smartcam", device_type_filter=[DeviceType.Doorbell], diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index ba47f0d55..d2367d9fa 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -176,10 +176,19 @@ def credentials_hash(self): "child_quick_setup", {"device_category_list": [{"category": "subg.trv"}]}, ), - # no devices found "get_scan_child_device_list": ( "child_quick_setup", - {"child_device_list": [{"dummy": "response"}], "scan_status": "idle"}, + { + "child_device_list": [ + { + "device_id": "0000000000000000000000000000000000000000", + "category": "subg.trigger.button", + "device_model": "S200B", + "name": "I01BU0tFRF9OQU1FIw==", + } + ], + "scan_status": "idle", + }, ), } diff --git a/tests/smart/modules/test_childsetup.py b/tests/smart/modules/test_childsetup.py index df3905a64..6f31a9488 100644 --- a/tests/smart/modules/test_childsetup.py +++ b/tests/smart/modules/test_childsetup.py @@ -42,7 +42,6 @@ async def test_childsetup_pair( mock_query_helper.assert_has_awaits( [ mocker.call("begin_scanning_child_device", None), - mocker.call("get_support_child_device_category", None), mocker.call("get_scan_child_device_list", params=mocker.ANY), mocker.call("add_child_device_list", params=mocker.ANY), ] diff --git a/tests/smartcam/modules/test_childsetup.py b/tests/smartcam/modules/test_childsetup.py index a419393dd..5b8a7c494 100644 --- a/tests/smartcam/modules/test_childsetup.py +++ b/tests/smartcam/modules/test_childsetup.py @@ -41,29 +41,11 @@ async def test_childsetup_pair( [ mocker.call( "startScanChildDevice", - params={ - "childControl": { - "category": [ - "camera", - "subg.trv", - "subg.trigger", - "subg.plugswitch", - ] - } - }, + params={"childControl": {"category": cs.supported_categories}}, ), mocker.call( "getScanChildDeviceList", - { - "childControl": { - "category": [ - "camera", - "subg.trv", - "subg.trigger", - "subg.plugswitch", - ] - } - }, + {"childControl": {"category": cs.supported_categories}}, ), mocker.call( "addScanChildDeviceList", @@ -71,10 +53,10 @@ async def test_childsetup_pair( "childControl": { "child_device_list": [ { - "device_id": "0000000000000000000000000000000000000000", - "category": "subg.trigger.button", - "device_model": "S200B", - "name": "I01BU0tFRF9OQU1FIw====", + "device_id": mocker.ANY, + "category": mocker.ANY, + "device_model": mocker.ANY, + "name": mocker.ANY, } ] } diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index b6513476f..2431127c7 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -148,6 +148,25 @@ def test_tutorial_examples(readmes_mock): assert not res["failed"] +def test_childsetup_examples(readmes_mock, mocker): + """Test device examples.""" + pair_resp = [ + { + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "category": "subg.trigger.button", + "device_model": "S200B", + "name": "I01BU0tFRF9OQU1FIw====", + } + ] + mocker.patch( + "kasa.smartcam.modules.childsetup.ChildSetup.pair", return_value=pair_resp + ) + res = xdoctest.doctest_module("kasa.interfaces.childsetup", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + @pytest.fixture async def readmes_mock(mocker): fixture_infos = { @@ -156,6 +175,7 @@ async def readmes_mock(mocker): "127.0.0.3": get_fixture_info("L530E(EU)_3.0_1.1.6.json", "SMART"), # Bulb "127.0.0.4": get_fixture_info("KL430(US)_1.0_1.0.10.json", "IOT"), # Lightstrip "127.0.0.5": get_fixture_info("HS220(US)_1.0_1.5.7.json", "IOT"), # Dimmer + "127.0.0.6": get_fixture_info("H200(US)_1.0_1.3.6.json", "SMARTCAM"), # Hub } fixture_infos["127.0.0.1"].data["system"]["get_sysinfo"]["alias"] = ( "Bedroom Power Strip" @@ -176,4 +196,7 @@ async def readmes_mock(mocker): fixture_infos["127.0.0.5"].data["system"]["get_sysinfo"]["alias"] = ( "Living Room Dimmer Switch" ) + fixture_infos["127.0.0.6"].data["getDeviceInfo"]["device_info"]["basic_info"][ + "device_alias" + ] = "Tapo Hub" return patch_discovery(fixture_infos, mocker) From 9b7bf367ae72f69bd7a5bab282d73f5f5f47da43 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 24 Jan 2025 10:53:27 +0000 Subject: [PATCH 835/892] Update ruff to 0.9 (#1482) Ruff 0.9 contains a number of formatter changes for the 2025 style guide. Update to `ruff>=0.9.0` and apply the formatter fixes. https://astral.sh/blog/ruff-v0.9.0 --- .pre-commit-config.yaml | 2 +- devtools/parse_pcap_klap.py | 3 +- kasa/cli/hub.py | 4 +-- kasa/cli/lazygroup.py | 3 +- kasa/iot/iotdimmer.py | 4 +-- kasa/iot/modules/lightpreset.py | 2 +- kasa/protocols/iotprotocol.py | 2 +- kasa/protocols/smartprotocol.py | 2 +- kasa/transports/xortransport.py | 8 ++--- pyproject.toml | 4 +-- tests/fakeprotocol_smartcam.py | 6 ++-- tests/smart/test_smartdevice.py | 6 ++-- tests/test_cli.py | 3 +- tests/transports/test_aestransport.py | 2 +- tests/transports/test_sslaestransport.py | 6 ++-- uv.lock | 44 ++++++++++++------------ 16 files changed, 46 insertions(+), 55 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 182ec765b..d191280cd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: - "--indent=4" - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.4 + rev: v0.9.3 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index 0ddbed7fa..848e33dc6 100755 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -286,8 +286,7 @@ def main( operator.local_seed = message response = None print( - f"got handshake1 in {packet_number}, " - f"looking for the response" + f"got handshake1 in {packet_number}, looking for the response" ) while ( True diff --git a/kasa/cli/hub.py b/kasa/cli/hub.py index 3add28149..de4b60715 100644 --- a/kasa/cli/hub.py +++ b/kasa/cli/hub.py @@ -66,8 +66,8 @@ async def hub_pair(dev: SmartDevice, timeout: int): for child in pair_res: echo( - f'Paired {child["name"]} ({child["device_model"]}, ' - f'{pretty_category(child["category"])}) with id {child["device_id"]}' + f"Paired {child['name']} ({child['device_model']}, " + f"{pretty_category(child['category'])}) with id {child['device_id']}" ) diff --git a/kasa/cli/lazygroup.py b/kasa/cli/lazygroup.py index a28586346..0e9435db2 100644 --- a/kasa/cli/lazygroup.py +++ b/kasa/cli/lazygroup.py @@ -66,7 +66,6 @@ def _lazy_load(self, cmd_name): # check the result to make debugging easier if not isinstance(cmd_object, click.BaseCommand): raise ValueError( - f"Lazy loading of {cmd_name} failed by returning " - "a non-command object" + f"Lazy loading of {cmd_name} failed by returning a non-command object" ) return cmd_object diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 3960e641b..1631fbba9 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -115,9 +115,7 @@ async def _set_brightness( raise KasaException("Device is not dimmable.") if not isinstance(brightness, int): - raise ValueError( - "Brightness must be integer, " "not of %s.", type(brightness) - ) + raise ValueError("Brightness must be integer, not of %s.", type(brightness)) if not 0 <= brightness <= 100: raise ValueError( diff --git a/kasa/iot/modules/lightpreset.py b/kasa/iot/modules/lightpreset.py index 76d398600..3330af69f 100644 --- a/kasa/iot/modules/lightpreset.py +++ b/kasa/iot/modules/lightpreset.py @@ -54,7 +54,7 @@ class LightPreset(IotModule, LightPresetInterface): async def _post_update_hook(self) -> None: """Update the internal presets.""" self._presets = { - f"Light preset {index+1}": IotLightPreset.from_dict(vals) + f"Light preset {index + 1}": IotLightPreset.from_dict(vals) for index, vals in enumerate(self.data["preferred_state"]) # Devices may list some light effects along with normal presets but these # are handled by the LightEffect module so exclude preferred states with id diff --git a/kasa/protocols/iotprotocol.py b/kasa/protocols/iotprotocol.py index 1af4ae59c..7ca02e0ca 100755 --- a/kasa/protocols/iotprotocol.py +++ b/kasa/protocols/iotprotocol.py @@ -30,7 +30,7 @@ def _mask_children(children: list[dict[str, Any]]) -> list[dict[str, Any]]: def mask_child(child: dict[str, Any], index: int) -> dict[str, Any]: result = { **child, - "id": f"SCRUBBED_CHILD_DEVICE_ID_{index+1}", + "id": f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}", } # Will leave empty aliases as blank if child.get("alias"): diff --git a/kasa/protocols/smartprotocol.py b/kasa/protocols/smartprotocol.py index 6b3b03be1..5539de778 100644 --- a/kasa/protocols/smartprotocol.py +++ b/kasa/protocols/smartprotocol.py @@ -236,7 +236,7 @@ async def _execute_multiple_query( smart_params = {"requests": requests_step} smart_request = self.get_smart_request(smart_method, smart_params) - batch_name = f"multi-request-batch-{batch_num+1}-of-{int(end/step)+1}" + batch_name = f"multi-request-batch-{batch_num + 1}-of-{int(end / step) + 1}" if debug_enabled: _LOGGER.debug( "%s %s >> %s", diff --git a/kasa/transports/xortransport.py b/kasa/transports/xortransport.py index 8cce6eb50..84fba0a57 100644 --- a/kasa/transports/xortransport.py +++ b/kasa/transports/xortransport.py @@ -142,18 +142,16 @@ async def send(self, request: str) -> dict: await self.reset() if ex.errno in _NO_RETRY_ERRORS: raise KasaException( - f"Unable to connect to the device:" - f" {self._host}:{self._port}: {ex}" + f"Unable to connect to the device: {self._host}:{self._port}: {ex}" ) from ex else: raise _RetryableError( - f"Unable to connect to the device:" - f" {self._host}:{self._port}: {ex}" + f"Unable to connect to the device: {self._host}:{self._port}: {ex}" ) from ex except Exception as ex: await self.reset() raise _RetryableError( - f"Unable to connect to the device:" f" {self._host}:{self._port}: {ex}" + f"Unable to connect to the device: {self._host}:{self._port}: {ex}" ) from ex except BaseException: # Likely something cancelled the task so we need to close the connection diff --git a/pyproject.toml b/pyproject.toml index eed43e2bb..7f6021c88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ dev-dependencies = [ "mypy~=1.0", "pytest-xdist>=3.6.1", "pytest-socket>=0.7.0", - "ruff==0.7.4", + "ruff>=0.9.0", ] @@ -146,8 +146,6 @@ select = [ ignore = [ "D105", # Missing docstring in magic method "D107", # Missing docstring in `__init__` - "ANN101", # Missing type annotation for `self` - "ANN102", # Missing type annotation for `cls` in classmethod "ANN003", # Missing type annotation for `**kwargs` "ANN401", # allow any ] diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index 5e4396261..311a1742c 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -218,9 +218,9 @@ def _hub_remove_device(self, info, params): @staticmethod def _get_second_key(request_dict: dict[str, Any]) -> str: - assert ( - len(request_dict) == 2 - ), f"Unexpected dict {request_dict}, should be length 2" + assert len(request_dict) == 2, ( + f"Unexpected dict {request_dict}, should be length 2" + ) it = iter(request_dict) next(it, None) return next(it) diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index bb6f13934..2cf87d06b 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -223,9 +223,9 @@ async def test_update_module_update_delays( now if mod_delay == 0 else now - (seconds % mod_delay) ) - assert ( - module._last_update_time == expected_update_time - ), f"Expected update time {expected_update_time} after {seconds} seconds for {module.name} with delay {mod_delay} got {module._last_update_time}" + assert module._last_update_time == expected_update_time, ( + f"Expected update time {expected_update_time} after {seconds} seconds for {module.name} with delay {mod_delay} got {module._last_update_time}" + ) async def _get_child_responses(child_requests: list[dict[str, Any]], child_protocol): diff --git a/tests/test_cli.py b/tests/test_cli.py index 269bc7aa0..c7a939705 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -653,8 +653,7 @@ async def test_light_preset(dev: Device, runner: CliRunner): if len(light_preset.preset_states_list) == 0: pytest.skip( - "Some fixtures do not have presets and" - " the api doesn'tsupport creating them" + "Some fixtures do not have presets and the api doesn'tsupport creating them" ) # Start off with a known state first_name = light_preset.preset_list[1] diff --git a/tests/transports/test_aestransport.py b/tests/transports/test_aestransport.py index 64bc8d4e4..793352965 100644 --- a/tests/transports/test_aestransport.py +++ b/tests/transports/test_aestransport.py @@ -56,7 +56,7 @@ def test_encrypt(): status_parameters = pytest.mark.parametrize( - "status_code, error_code, inner_error_code, expectation", + ("status_code", "error_code", "inner_error_code", "expectation"), [ (200, 0, 0, does_not_raise()), (400, 0, 0, pytest.raises(KasaException)), diff --git a/tests/transports/test_sslaestransport.py b/tests/transports/test_sslaestransport.py index e8ff9e527..2974a9148 100644 --- a/tests/transports/test_sslaestransport.py +++ b/tests/transports/test_sslaestransport.py @@ -273,7 +273,7 @@ async def test_unencrypted_passthrough_errors(mocker, caplog, want_default): aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post ) - msg = f"{host} responded with an unexpected " f"status code 401 to handshake1" + msg = f"{host} responded with an unexpected status code 401 to handshake1" with pytest.raises(KasaException, match=msg): await transport.send(json_dumps(request)) @@ -288,7 +288,7 @@ async def test_unencrypted_passthrough_errors(mocker, caplog, want_default): aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post ) - msg = f"{host} responded with an unexpected " f"status code 401 to login" + msg = f"{host} responded with an unexpected status code 401 to login" with pytest.raises(KasaException, match=msg): await transport.send(json_dumps(request)) @@ -303,7 +303,7 @@ async def test_unencrypted_passthrough_errors(mocker, caplog, want_default): aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post ) - msg = f"{host} responded with an unexpected " f"status code 401 to unencrypted send" + msg = f"{host} responded with an unexpected status code 401 to unencrypted send" with pytest.raises(KasaException, match=msg): await transport.send(json_dumps(request)) diff --git a/uv.lock b/uv.lock index df6132cab..26e49f931 100644 --- a/uv.lock +++ b/uv.lock @@ -1170,7 +1170,7 @@ dev = [ { name = "pytest-sugar" }, { name = "pytest-timeout", specifier = "~=2.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, - { name = "ruff", specifier = "==0.7.4" }, + { name = "ruff", specifier = ">=0.9.0" }, { name = "toml" }, { name = "voluptuous" }, { name = "xdoctest", specifier = ">=1.2.0" }, @@ -1241,27 +1241,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.7.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/8b/bc4e0dfa1245b07cf14300e10319b98e958a53ff074c1dd86b35253a8c2a/ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2", size = 3275547 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/4b/f5094719e254829766b807dadb766841124daba75a37da83e292ae5ad12f/ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478", size = 10447512 }, - { url = "https://files.pythonhosted.org/packages/9e/1d/3d2d2c9f601cf6044799c5349ff5267467224cefed9b35edf5f1f36486e9/ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63", size = 10235436 }, - { url = "https://files.pythonhosted.org/packages/62/83/42a6ec6216ded30b354b13e0e9327ef75a3c147751aaf10443756cb690e9/ruff-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20", size = 9888936 }, - { url = "https://files.pythonhosted.org/packages/4d/26/e1e54893b13046a6ad05ee9b89ee6f71542ba250f72b4c7a7d17c3dbf73d/ruff-0.7.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109", size = 10697353 }, - { url = "https://files.pythonhosted.org/packages/21/24/98d2e109c4efc02bfef144ec6ea2c3e1217e7ce0cfddda8361d268dfd499/ruff-0.7.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452", size = 10228078 }, - { url = "https://files.pythonhosted.org/packages/ad/b7/964c75be9bc2945fc3172241b371197bb6d948cc69e28bc4518448c368f3/ruff-0.7.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea", size = 11264823 }, - { url = "https://files.pythonhosted.org/packages/12/8d/20abdbf705969914ce40988fe71a554a918deaab62c38ec07483e77866f6/ruff-0.7.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7", size = 11951855 }, - { url = "https://files.pythonhosted.org/packages/b8/fc/6519ce58c57b4edafcdf40920b7273dfbba64fc6ebcaae7b88e4dc1bf0a8/ruff-0.7.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05", size = 11516580 }, - { url = "https://files.pythonhosted.org/packages/37/1a/5ec1844e993e376a86eb2456496831ed91b4398c434d8244f89094758940/ruff-0.7.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06", size = 12692057 }, - { url = "https://files.pythonhosted.org/packages/50/90/76867152b0d3c05df29a74bb028413e90f704f0f6701c4801174ba47f959/ruff-0.7.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc", size = 11085137 }, - { url = "https://files.pythonhosted.org/packages/c8/eb/0a7cb6059ac3555243bd026bb21785bbc812f7bbfa95a36c101bd72b47ae/ruff-0.7.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172", size = 10681243 }, - { url = "https://files.pythonhosted.org/packages/5e/76/2270719dbee0fd35780b05c08a07b7a726c3da9f67d9ae89ef21fc18e2e5/ruff-0.7.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a", size = 10319187 }, - { url = "https://files.pythonhosted.org/packages/9f/e5/39100f72f8ba70bec1bd329efc880dea8b6c1765ea1cb9d0c1c5f18b8d7f/ruff-0.7.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd", size = 10803715 }, - { url = "https://files.pythonhosted.org/packages/a5/89/40e904784f305fb56850063f70a998a64ebba68796d823dde67e89a24691/ruff-0.7.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a", size = 11162912 }, - { url = "https://files.pythonhosted.org/packages/8d/1b/dd77503b3875c51e3dbc053fd8367b845ab8b01c9ca6d0c237082732856c/ruff-0.7.4-py3-none-win32.whl", hash = "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac", size = 8702767 }, - { url = "https://files.pythonhosted.org/packages/63/76/253ddc3e89e70165bba952ecca424b980b8d3c2598ceb4fc47904f424953/ruff-0.7.4-py3-none-win_amd64.whl", hash = "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6", size = 9497534 }, - { url = "https://files.pythonhosted.org/packages/aa/70/f8724f31abc0b329ca98b33d73c14020168babcf71b0cba3cded5d9d0e66/ruff-0.7.4-py3-none-win_arm64.whl", hash = "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f", size = 8851590 }, +version = "0.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7f/60fda2eec81f23f8aa7cbbfdf6ec2ca11eb11c273827933fb2541c2ce9d8/ruff-0.9.3.tar.gz", hash = "sha256:8293f89985a090ebc3ed1064df31f3b4b56320cdfcec8b60d3295bddb955c22a", size = 3586740 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/77/4fb790596d5d52c87fd55b7160c557c400e90f6116a56d82d76e95d9374a/ruff-0.9.3-py3-none-linux_armv6l.whl", hash = "sha256:7f39b879064c7d9670197d91124a75d118d00b0990586549949aae80cdc16624", size = 11656815 }, + { url = "https://files.pythonhosted.org/packages/a2/a8/3338ecb97573eafe74505f28431df3842c1933c5f8eae615427c1de32858/ruff-0.9.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a187171e7c09efa4b4cc30ee5d0d55a8d6c5311b3e1b74ac5cb96cc89bafc43c", size = 11594821 }, + { url = "https://files.pythonhosted.org/packages/8e/89/320223c3421962762531a6b2dd58579b858ca9916fb2674874df5e97d628/ruff-0.9.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c59ab92f8e92d6725b7ded9d4a31be3ef42688a115c6d3da9457a5bda140e2b4", size = 11040475 }, + { url = "https://files.pythonhosted.org/packages/b2/bd/1d775eac5e51409535804a3a888a9623e87a8f4b53e2491580858a083692/ruff-0.9.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc153c25e715be41bb228bc651c1e9b1a88d5c6e5ed0194fa0dfea02b026439", size = 11856207 }, + { url = "https://files.pythonhosted.org/packages/7f/c6/3e14e09be29587393d188454064a4aa85174910d16644051a80444e4fd88/ruff-0.9.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:646909a1e25e0dc28fbc529eab8eb7bb583079628e8cbe738192853dbbe43af5", size = 11420460 }, + { url = "https://files.pythonhosted.org/packages/ef/42/b7ca38ffd568ae9b128a2fa76353e9a9a3c80ef19746408d4ce99217ecc1/ruff-0.9.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a5a46e09355695fbdbb30ed9889d6cf1c61b77b700a9fafc21b41f097bfbba4", size = 12605472 }, + { url = "https://files.pythonhosted.org/packages/a6/a1/3167023f23e3530fde899497ccfe239e4523854cb874458ac082992d206c/ruff-0.9.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c4bb09d2bbb394e3730d0918c00276e79b2de70ec2a5231cd4ebb51a57df9ba1", size = 13243123 }, + { url = "https://files.pythonhosted.org/packages/d0/b4/3c600758e320f5bf7de16858502e849f4216cb0151f819fa0d1154874802/ruff-0.9.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96a87ec31dc1044d8c2da2ebbed1c456d9b561e7d087734336518181b26b3aa5", size = 12744650 }, + { url = "https://files.pythonhosted.org/packages/be/38/266fbcbb3d0088862c9bafa8b1b99486691d2945a90b9a7316336a0d9a1b/ruff-0.9.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb7554aca6f842645022fe2d301c264e6925baa708b392867b7a62645304df4", size = 14458585 }, + { url = "https://files.pythonhosted.org/packages/63/a6/47fd0e96990ee9b7a4abda62de26d291bd3f7647218d05b7d6d38af47c30/ruff-0.9.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabc332b7075a914ecea912cd1f3d4370489c8018f2c945a30bcc934e3bc06a6", size = 12419624 }, + { url = "https://files.pythonhosted.org/packages/84/5d/de0b7652e09f7dda49e1a3825a164a65f4998175b6486603c7601279baad/ruff-0.9.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:33866c3cc2a575cbd546f2cd02bdd466fed65118e4365ee538a3deffd6fcb730", size = 11843238 }, + { url = "https://files.pythonhosted.org/packages/9e/be/3f341ceb1c62b565ec1fb6fd2139cc40b60ae6eff4b6fb8f94b1bb37c7a9/ruff-0.9.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:006e5de2621304c8810bcd2ee101587712fa93b4f955ed0985907a36c427e0c2", size = 11484012 }, + { url = "https://files.pythonhosted.org/packages/a3/c8/ff8acbd33addc7e797e702cf00bfde352ab469723720c5607b964491d5cf/ruff-0.9.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ba6eea4459dbd6b1be4e6bfc766079fb9b8dd2e5a35aff6baee4d9b1514ea519", size = 12038494 }, + { url = "https://files.pythonhosted.org/packages/73/b1/8d9a2c0efbbabe848b55f877bc10c5001a37ab10aca13c711431673414e5/ruff-0.9.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:90230a6b8055ad47d3325e9ee8f8a9ae7e273078a66401ac66df68943ced029b", size = 12473639 }, + { url = "https://files.pythonhosted.org/packages/cb/44/a673647105b1ba6da9824a928634fe23186ab19f9d526d7bdf278cd27bc3/ruff-0.9.3-py3-none-win32.whl", hash = "sha256:eabe5eb2c19a42f4808c03b82bd313fc84d4e395133fb3fc1b1516170a31213c", size = 9834353 }, + { url = "https://files.pythonhosted.org/packages/c3/01/65cadb59bf8d4fbe33d1a750103e6883d9ef302f60c28b73b773092fbde5/ruff-0.9.3-py3-none-win_amd64.whl", hash = "sha256:040ceb7f20791dfa0e78b4230ee9dce23da3b64dd5848e40e3bf3ab76468dcf4", size = 10821444 }, + { url = "https://files.pythonhosted.org/packages/69/cb/b3fe58a136a27d981911cba2f18e4b29f15010623b79f0f2510fd0d31fd3/ruff-0.9.3-py3-none-win_arm64.whl", hash = "sha256:800d773f6d4d33b0a3c60e2c6ae8f4c202ea2de056365acfa519aa48acf28e0b", size = 10038168 }, ] [[package]] From 5b9b89769ac718df9cd4cf2f45411be522ea7671 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 24 Jan 2025 18:45:14 +0000 Subject: [PATCH 836/892] Cancel in progress CI workflows after new pushes (#1481) Create a concurreny group which will cancel in progress workflows after new pushes to pull requests or python-kasa branches. --- .github/workflows/ci.yml | 4 ++++ .github/workflows/codeql-analysis.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c3643b1a..abe016518 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,10 @@ on: - 'janitor/**' workflow_dispatch: # to allow manual re-runs +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + env: UV_VERSION: 0.4.16 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 9edba4839..016ff0c30 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -15,6 +15,10 @@ on: schedule: - cron: '44 17 * * 3' +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: analyze: name: Analyze From 0aa1242a00695b8f1eb2aab09623f1b8e1d5a7ef Mon Sep 17 00:00:00 2001 From: Ryan Nitcher Date: Sat, 25 Jan 2025 02:22:00 -0700 Subject: [PATCH 837/892] Report 0 for instead of None for zero current and voltage (#1483) - Report `0` instead of `None` for current when current is zero. - Report `0` instead of `None` for voltage when voltage is zero --- kasa/smart/modules/energy.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/kasa/smart/modules/energy.py b/kasa/smart/modules/energy.py index 0cfdc92c2..03df6d11c 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -126,15 +126,17 @@ def consumption_total(self) -> float | None: @raise_if_update_error def current(self) -> float | None: """Return the current in A.""" - ma = self.data.get("get_emeter_data", {}).get("current_ma") - return ma / 1000 if ma else None + if (ma := self.data.get("get_emeter_data", {}).get("current_ma")) is not None: + return ma / 1_000 + return None @property @raise_if_update_error def voltage(self) -> float | None: """Get the current voltage in V.""" - mv = self.data.get("get_emeter_data", {}).get("voltage_mv") - return mv / 1000 if mv else None + if (mv := self.data.get("get_emeter_data", {}).get("voltage_mv")) is not None: + return mv / 1_000 + return None async def _deprecated_get_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" From 7f2a1be392d6634faae250f71c9d4b3e9edc57fe Mon Sep 17 00:00:00 2001 From: Ryan Nitcher Date: Sat, 25 Jan 2025 03:45:48 -0700 Subject: [PATCH 838/892] Add ADC Value to PIR Enabled Switches (#1263) --- kasa/cli/feature.py | 22 +- kasa/feature.py | 23 ++- kasa/iot/modules/motion.py | 342 +++++++++++++++++++++++++++++-- tests/fakeprotocol_iot.py | 3 +- tests/iot/modules/test_motion.py | 78 ++++++- tests/test_cli.py | 57 ++++++ tests/test_feature.py | 5 +- 7 files changed, 491 insertions(+), 39 deletions(-) diff --git a/kasa/cli/feature.py b/kasa/cli/feature.py index 522dee7f3..a4c739f6b 100644 --- a/kasa/cli/feature.py +++ b/kasa/cli/feature.py @@ -6,10 +6,7 @@ import asyncclick as click -from kasa import ( - Device, - Feature, -) +from kasa import Device, Feature from .common import ( echo, @@ -133,7 +130,22 @@ async def feature( echo(f"{feat.name} ({name}): {feat.value}{unit}") return feat.value - value = ast.literal_eval(value) + try: + # Attempt to parse as python literal. + value = ast.literal_eval(value) + except ValueError: + # The value is probably an unquoted string, so we'll raise an error, + # and tell the user to quote the string. + raise click.exceptions.BadParameter( + f'{repr(value)} for {name} (Perhaps you forgot to "quote" the value?)' + ) from SyntaxError + except SyntaxError: + # There are likely miss-matched quotes or odd characters in the input, + # so abort and complain to the user. + raise click.exceptions.BadParameter( + f"{repr(value)} for {name}" + ) from SyntaxError + echo(f"Changing {name} from {feat.value} to {value}") response = await dev.features[name].set_value(value) await dev.update() diff --git a/kasa/feature.py b/kasa/feature.py index 3c6beb0de..0c4c6e230 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -256,7 +256,7 @@ async def set_value(self, value: int | float | bool | str | Enum | None) -> Any: elif self.type == Feature.Type.Choice: # noqa: SIM102 if not self.choices or value not in self.choices: raise ValueError( - f"Unexpected value for {self.name}: {value}" + f"Unexpected value for {self.name}: '{value}'" f" - allowed: {self.choices}" ) @@ -279,7 +279,18 @@ def __repr__(self) -> str: return f"Unable to read value ({self.id}): {ex}" if self.type == Feature.Type.Choice: - if not isinstance(choices, list) or value not in choices: + if not isinstance(choices, list): + _LOGGER.error( + "Choices are not properly defined for %s (%s). Type: <%s> Value: %s", # noqa: E501 + self.name, + self.id, + type(choices), + choices, + ) + return f"{self.name} ({self.id}): improperly defined choice set." + if (value not in choices) and ( + isinstance(value, Enum) and value.name not in choices + ): _LOGGER.warning( "Invalid value for for choice %s (%s): %s not in %s", self.name, @@ -291,7 +302,13 @@ def __repr__(self) -> str: f"{self.name} ({self.id}): invalid value '{value}' not in {choices}" ) value = " ".join( - [f"*{choice}*" if choice == value else choice for choice in choices] + [ + f"*{choice}*" + if choice == value + or (isinstance(value, Enum) and choice == value.name) + else f"{choice}" + for choice in choices + ] ) if self.precision_hint is not None and isinstance(value, float): value = round(value, self.precision_hint) diff --git a/kasa/iot/modules/motion.py b/kasa/iot/modules/motion.py index e65cbd93b..a795b449a 100644 --- a/kasa/iot/modules/motion.py +++ b/kasa/iot/modules/motion.py @@ -3,11 +3,13 @@ from __future__ import annotations import logging +import math +from dataclasses import dataclass from enum import Enum from ...exceptions import KasaException from ...feature import Feature -from ..iotmodule import IotModule +from ..iotmodule import IotModule, merge _LOGGER = logging.getLogger(__name__) @@ -20,6 +22,71 @@ class Range(Enum): Near = 2 Custom = 3 + def __str__(self) -> str: + return self.name + + +@dataclass +class PIRConfig: + """Dataclass representing a PIR sensor configuration.""" + + enabled: bool + adc_min: int + adc_max: int + range: Range + threshold: int + + @property + def adc_mid(self) -> int: + """Compute the ADC midpoint from the configured ADC Max and Min values.""" + return math.floor(abs(self.adc_max - self.adc_min) / 2) + + +@dataclass +class PIRStatus: + """Dataclass representing the current trigger state of an ADC PIR sensor.""" + + pir_config: PIRConfig + adc_value: int + + @property + def pir_value(self) -> int: + """ + Get the PIR status value in integer form. + + Computes the PIR status value that this object represents, + using the given PIR configuration. + """ + return self.pir_config.adc_mid - self.adc_value + + @property + def pir_percent(self) -> float: + """ + Get the PIR status value in percentile form. + + Computes the PIR status percentage that this object represents, + using the given PIR configuration. + """ + value = self.pir_value + divisor = ( + (self.pir_config.adc_mid - self.pir_config.adc_min) + if (value < 0) + else (self.pir_config.adc_max - self.pir_config.adc_mid) + ) + return (float(value) / divisor) * 100 + + @property + def pir_triggered(self) -> bool: + """ + Get the PIR status trigger state. + + Compute the PIR trigger state this object represents, + using the given PIR configuration. + """ + return (self.pir_config.enabled) and ( + abs(self.pir_percent) > (100 - self.pir_config.threshold) + ) + class Motion(IotModule): """Implements the motion detection (PIR) module.""" @@ -30,6 +97,11 @@ def _initialize_features(self) -> None: if "get_config" not in self.data: return + # Require that ADC value is also present. + if "get_adc_value" not in self.data: + _LOGGER.warning("%r initialized, but no get_adc_value in response") + return + if "enable" not in self.config: _LOGGER.warning("%r initialized, but no enable in response") return @@ -48,9 +120,143 @@ def _initialize_features(self) -> None: ) ) + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_range", + name="Motion Sensor Range", + icon="mdi:motion-sensor", + attribute_getter="range", + attribute_setter="_set_range_from_str", + type=Feature.Type.Choice, + choices_getter="ranges", + category=Feature.Category.Config, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_threshold", + name="Motion Sensor Threshold", + icon="mdi:motion-sensor", + attribute_getter="threshold", + attribute_setter="set_threshold", + type=Feature.Type.Number, + category=Feature.Category.Config, + range_getter=lambda: (0, 100), + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_triggered", + name="PIR Triggered", + icon="mdi:motion-sensor", + attribute_getter="pir_triggered", + attribute_setter=None, + type=Feature.Type.Sensor, + category=Feature.Category.Primary, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_value", + name="PIR Value", + icon="mdi:motion-sensor", + attribute_getter="pir_value", + attribute_setter=None, + type=Feature.Type.Sensor, + category=Feature.Category.Info, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_adc_value", + name="PIR ADC Value", + icon="mdi:motion-sensor", + attribute_getter="adc_value", + attribute_setter=None, + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_adc_min", + name="PIR ADC Min", + icon="mdi:motion-sensor", + attribute_getter="adc_min", + attribute_setter=None, + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_adc_mid", + name="PIR ADC Mid", + icon="mdi:motion-sensor", + attribute_getter="adc_mid", + attribute_setter=None, + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_adc_max", + name="PIR ADC Max", + icon="mdi:motion-sensor", + attribute_getter="adc_max", + attribute_setter=None, + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_percent", + name="PIR Percentile", + icon="mdi:motion-sensor", + attribute_getter="pir_percent", + attribute_setter=None, + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + unit_getter=lambda: "%", + ) + ) + def query(self) -> dict: """Request PIR configuration.""" - return self.query_for_command("get_config") + req = merge( + self.query_for_command("get_config"), + self.query_for_command("get_adc_value"), + ) + + return req @property def config(self) -> dict: @@ -58,34 +264,103 @@ def config(self) -> dict: return self.data["get_config"] @property - def range(self) -> Range: - """Return motion detection range.""" - return Range(self.config["trigger_index"]) + def pir_config(self) -> PIRConfig: + """Return PIR sensor configuration.""" + pir_range = Range(self.config["trigger_index"]) + return PIRConfig( + enabled=bool(self.config["enable"]), + adc_min=int(self.config["min_adc"]), + adc_max=int(self.config["max_adc"]), + range=pir_range, + threshold=self.get_range_threshold(pir_range), + ) @property def enabled(self) -> bool: """Return True if module is enabled.""" - return bool(self.config["enable"]) + return self.pir_config.enabled + + @property + def adc_min(self) -> int: + """Return minimum ADC sensor value.""" + return self.pir_config.adc_min + + @property + def adc_max(self) -> int: + """Return maximum ADC sensor value.""" + return self.pir_config.adc_max + + @property + def adc_mid(self) -> int: + """ + Return the midpoint for the ADC. + + The midpoint represents the zero point for the PIR sensor waveform. + + Currently this is estimated by: + math.floor(abs(adc_max - adc_min) / 2) + """ + return self.pir_config.adc_mid async def set_enabled(self, state: bool) -> dict: """Enable/disable PIR.""" return await self.call("set_enable", {"enable": int(state)}) - async def set_range( - self, *, range: Range | None = None, custom_range: int | None = None - ) -> dict: - """Set the range for the sensor. + @property + def ranges(self) -> list[str]: + """Return set of supported range classes.""" + range_min = 0 + range_max = len(self.config["array"]) + valid_ranges = list() + for r in Range: + if (r.value >= range_min) and (r.value < range_max): + valid_ranges.append(r.name) + return valid_ranges + + @property + def range(self) -> Range: + """Return motion detection Range.""" + return self.pir_config.range - :param range: for using standard ranges - :param custom_range: range in decimeters, overrides the range parameter + async def set_range(self, range: Range) -> dict: + """Set the Range for the sensor. + + :param Range: the range class to use. """ - if custom_range is not None: - payload = {"index": Range.Custom.value, "value": custom_range} - elif range is not None: - payload = {"index": range.value} - else: - raise KasaException("Either range or custom_range need to be defined") + payload = {"index": range.value} + return await self.call("set_trigger_sens", payload) + def _parse_range_value(self, value: str) -> Range: + """Attempt to parse a range value from the given string.""" + value = value.strip().capitalize() + try: + return Range[value] + except KeyError: + raise KasaException( + f"Invalid range value: '{value}'." + f" Valid options are: {Range._member_names_}" + ) from KeyError + + async def _set_range_from_str(self, input: str) -> dict: + value = self._parse_range_value(input) + return await self.set_range(range=value) + + def get_range_threshold(self, range_type: Range) -> int: + """Get the distance threshold at which the PIR sensor is will trigger.""" + if range_type.value < 0 or range_type.value >= len(self.config["array"]): + raise KasaException( + "Range type is outside the bounds of the configured device ranges." + ) + return int(self.config["array"][range_type.value]) + + @property + def threshold(self) -> int: + """Return motion detection Range.""" + return self.pir_config.threshold + + async def set_threshold(self, value: int) -> dict: + """Set the distance threshold at which the PIR sensor is will trigger.""" + payload = {"index": Range.Custom.value, "value": value} return await self.call("set_trigger_sens", payload) @property @@ -100,3 +375,34 @@ async def set_inactivity_timeout(self, timeout: int) -> dict: to avoid reverting this back to 60 seconds after a period of time. """ return await self.call("set_cold_time", {"cold_time": timeout}) + + @property + def pir_state(self) -> PIRStatus: + """Return cached PIR status.""" + return PIRStatus(self.pir_config, self.data["get_adc_value"]["value"]) + + async def get_pir_state(self) -> PIRStatus: + """Return real-time PIR status.""" + latest = await self.call("get_adc_value") + self.data["get_adc_value"] = latest + return PIRStatus(self.pir_config, latest["value"]) + + @property + def adc_value(self) -> int: + """Return motion adc value.""" + return self.pir_state.adc_value + + @property + def pir_value(self) -> int: + """Return the computed PIR sensor value.""" + return self.pir_state.pir_value + + @property + def pir_percent(self) -> float: + """Return the computed PIR sensor value, in percentile form.""" + return self.pir_state.pir_percent + + @property + def pir_triggered(self) -> bool: + """Return if the motion sensor has been triggered.""" + return self.pir_state.pir_triggered diff --git a/tests/fakeprotocol_iot.py b/tests/fakeprotocol_iot.py index 23ce78279..238e555ce 100644 --- a/tests/fakeprotocol_iot.py +++ b/tests/fakeprotocol_iot.py @@ -192,6 +192,7 @@ def success(res): MOTION_MODULE = { + "get_adc_value": {"value": 50, "err_code": 0}, "get_config": { "enable": 0, "version": "1.0", @@ -201,7 +202,7 @@ def success(res): "max_adc": 4095, "array": [80, 50, 20, 0], "err_code": 0, - } + }, } LIGHT_DETAILS = { diff --git a/tests/iot/modules/test_motion.py b/tests/iot/modules/test_motion.py index a2b32a877..2d1ccbcc7 100644 --- a/tests/iot/modules/test_motion.py +++ b/tests/iot/modules/test_motion.py @@ -1,6 +1,7 @@ +import pytest from pytest_mock import MockerFixture -from kasa import Module +from kasa import KasaException, Module from kasa.iot import IotDimmer from kasa.iot.modules.motion import Motion, Range @@ -36,17 +37,72 @@ async def test_motion_range(dev: IotDimmer, mocker: MockerFixture): motion: Motion = dev.modules[Module.IotMotion] query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") - await motion.set_range(custom_range=123) - query_helper.assert_called_with( - "smartlife.iot.PIR", - "set_trigger_sens", - {"index": Range.Custom.value, "value": 123}, - ) + for range in Range: + await motion.set_range(range) + query_helper.assert_called_with( + "smartlife.iot.PIR", + "set_trigger_sens", + {"index": range.value}, + ) - await motion.set_range(range=Range.Far) - query_helper.assert_called_with( - "smartlife.iot.PIR", "set_trigger_sens", {"index": Range.Far.value} - ) + +@dimmer_iot +async def test_motion_range_from_string(dev: IotDimmer, mocker: MockerFixture): + motion: Motion = dev.modules[Module.IotMotion] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + ranges_good = { + "near": Range.Near, + "MID": Range.Mid, + "fAr": Range.Far, + " Custom ": Range.Custom, + } + for range_str, range in ranges_good.items(): + await motion._set_range_from_str(range_str) + query_helper.assert_called_with( + "smartlife.iot.PIR", + "set_trigger_sens", + {"index": range.value}, + ) + + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + ranges_bad = ["near1", "MD", "F\nAR", "Custom Near", '"FAR"', "'FAR'"] + for range_str in ranges_bad: + with pytest.raises(KasaException): + await motion._set_range_from_str(range_str) + query_helper.assert_not_called() + + +@dimmer_iot +async def test_motion_threshold(dev: IotDimmer, mocker: MockerFixture): + motion: Motion = dev.modules[Module.IotMotion] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + for range in Range: + # Switch to a given range. + await motion.set_range(range) + query_helper.assert_called_with( + "smartlife.iot.PIR", + "set_trigger_sens", + {"index": range.value}, + ) + + # Assert that the range always goes to custom, regardless of current range. + await motion.set_threshold(123) + query_helper.assert_called_with( + "smartlife.iot.PIR", + "set_trigger_sens", + {"index": Range.Custom.value, "value": 123}, + ) + + +@dimmer_iot +async def test_motion_realtime(dev: IotDimmer, mocker: MockerFixture): + motion: Motion = dev.modules[Module.IotMotion] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + await motion.get_pir_state() + query_helper.assert_called_with("smartlife.iot.PIR", "get_adc_value", None) @dimmer_iot diff --git a/tests/test_cli.py b/tests/test_cli.py index c7a939705..627959e74 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1180,6 +1180,63 @@ async def test_feature_set_child(mocker, runner): assert res.exit_code == 0 +async def test_feature_set_unquoted(mocker, runner): + """Test feature command's set value.""" + dummy_device = await get_device_for_fixture_protocol( + "ES20M(US)_1.0_1.0.11.json", "IOT" + ) + range_setter = mocker.patch("kasa.iot.modules.motion.Motion._set_range_from_str") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature", "pir_range", "Far"], + catch_exceptions=False, + ) + + range_setter.assert_not_called() + assert "Error: Invalid value: " in res.output + assert res.exit_code != 0 + + +async def test_feature_set_badquoted(mocker, runner): + """Test feature command's set value.""" + dummy_device = await get_device_for_fixture_protocol( + "ES20M(US)_1.0_1.0.11.json", "IOT" + ) + range_setter = mocker.patch("kasa.iot.modules.motion.Motion._set_range_from_str") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature", "pir_range", "`Far"], + catch_exceptions=False, + ) + + range_setter.assert_not_called() + assert "Error: Invalid value: " in res.output + assert res.exit_code != 0 + + +async def test_feature_set_goodquoted(mocker, runner): + """Test feature command's set value.""" + dummy_device = await get_device_for_fixture_protocol( + "ES20M(US)_1.0_1.0.11.json", "IOT" + ) + range_setter = mocker.patch("kasa.iot.modules.motion.Motion._set_range_from_str") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature", "pir_range", "'Far'"], + catch_exceptions=False, + ) + + range_setter.assert_called() + assert "Error: Invalid value: " not in res.output + assert res.exit_code == 0 + + async def test_cli_child_commands( dev: Device, runner: CliRunner, mocker: MockerFixture ): diff --git a/tests/test_feature.py b/tests/test_feature.py index 0d6210327..bb707688e 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -141,7 +141,10 @@ async def test_feature_choice_list(dummy_feature, caplog, mocker: MockerFixture) mock_setter.assert_called_with("first") mock_setter.reset_mock() - with pytest.raises(ValueError, match="Unexpected value for dummy_feature: invalid"): # noqa: PT012 + with pytest.raises( # noqa: PT012 + ValueError, + match="Unexpected value for dummy_feature: 'invalid' (?: - allowed: .*)?", + ): await dummy_feature.set_value("invalid") assert "Unexpected value" in caplog.text From ba6d6560f4d7f8d23c81efed635501b7bbc736ee Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sat, 25 Jan 2025 23:19:29 +0000 Subject: [PATCH 839/892] Disable iot camera creation until more complete (#1480) Should address [HA Issue 135648](https://github.com/home-assistant/core/issues/https://github.com/home-assistant/core/issues/135648) --- kasa/device_factory.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 53ceba178..ecb0d0a13 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -12,7 +12,6 @@ from .exceptions import KasaException, UnsupportedDeviceError from .iot import ( IotBulb, - IotCamera, IotDevice, IotDimmer, IotLightStrip, @@ -140,7 +139,8 @@ def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]: DeviceType.Strip: IotStrip, DeviceType.WallSwitch: IotWallSwitch, DeviceType.LightStrip: IotLightStrip, - DeviceType.Camera: IotCamera, + # Disabled until properly implemented + # DeviceType.Camera: IotCamera, } return TYPE_TO_CLASS[IotDevice._get_device_type_from_sys_info(sysinfo)] @@ -163,7 +163,8 @@ def get_device_class_from_family( "SMART.TAPOROBOVAC.HTTPS": SmartDevice, "IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTBULB": IotBulb, - "IOT.IPCAMERA": IotCamera, + # Disabled until properly implemented + # "IOT.IPCAMERA": IotCamera, } lookup_key = f"{device_type}{'.HTTPS' if https else ''}" if ( From 62c1dd87dc9e41cdc893ac1db42805afbfca3069 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sun, 26 Jan 2025 01:43:02 +0100 Subject: [PATCH 840/892] Add powerprotection module (#1337) Implements power protection on supported devices. If the power usage is above the given threshold and the feature is enabled, the device will be turned off. Adds the following features: * `overloaded` binary sensor * `power_protection_threshold` number, setting this to `0` turns the feature off. --------- Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- kasa/module.py | 19 ++- kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/powerprotection.py | 124 ++++++++++++++++++++ tests/fakeprotocol_smart.py | 8 ++ tests/smart/modules/test_powerprotection.py | 98 ++++++++++++++++ 5 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 kasa/smart/modules/powerprotection.py create mode 100644 tests/smart/modules/test_powerprotection.py diff --git a/kasa/module.py b/kasa/module.py index 107ce1e60..c58c6b401 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -81,6 +81,9 @@ class FeatureAttribute: """Class for annotating attributes bound to feature.""" + def __init__(self, feature_name: str | None = None) -> None: + self.feature_name = feature_name + def __repr__(self) -> str: return "FeatureAttribute" @@ -155,6 +158,9 @@ class Module(ABC): ) ChildLock: Final[ModuleName[smart.ChildLock]] = ModuleName("ChildLock") TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs") + PowerProtection: Final[ModuleName[smart.PowerProtection]] = ModuleName( + "PowerProtection" + ) HomeKit: Final[ModuleName[smart.HomeKit]] = ModuleName("HomeKit") Matter: Final[ModuleName[smart.Matter]] = ModuleName("Matter") @@ -234,7 +240,7 @@ def __repr__(self) -> str: ) -def _is_bound_feature(attribute: property | Callable) -> bool: +def _get_feature_attribute(attribute: property | Callable) -> FeatureAttribute | None: """Check if an attribute is bound to a feature with FeatureAttribute.""" if isinstance(attribute, property): hints = get_type_hints(attribute.fget, include_extras=True) @@ -245,9 +251,9 @@ def _is_bound_feature(attribute: property | Callable) -> bool: metadata = hints["return"].__metadata__ for meta in metadata: if isinstance(meta, FeatureAttribute): - return True + return meta - return False + return None @cache @@ -274,12 +280,17 @@ def _get_bound_feature( f"module {module.__class__.__name__}" ) - if not _is_bound_feature(attribute_callable): + if not (fa := _get_feature_attribute(attribute_callable)): raise KasaException( f"Attribute {attribute_name} of module {module.__class__.__name__}" " is not bound to a feature" ) + # If a feature_name was passed to the FeatureAttribute use that to check + # for the feature. Otherwise check the getters and setters in the features + if fa.feature_name: + return module._all_features.get(fa.feature_name) + check = {attribute_name, attribute_callable} for feature in module._all_features.values(): if (getter := feature.attribute_getter) and getter in check: diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 9215277e4..154042398 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -34,6 +34,7 @@ from .mop import Mop from .motionsensor import MotionSensor from .overheatprotection import OverheatProtection +from .powerprotection import PowerProtection from .reportmode import ReportMode from .speaker import Speaker from .temperaturecontrol import TemperatureControl @@ -80,6 +81,7 @@ "Consumables", "CleanRecords", "SmartLightEffect", + "PowerProtection", "OverheatProtection", "Speaker", "HomeKit", diff --git a/kasa/smart/modules/powerprotection.py b/kasa/smart/modules/powerprotection.py new file mode 100644 index 000000000..ff7e726d5 --- /dev/null +++ b/kasa/smart/modules/powerprotection.py @@ -0,0 +1,124 @@ +"""Power protection module.""" + +from __future__ import annotations + +from typing import Annotated + +from ...feature import Feature +from ...module import FeatureAttribute +from ..smartmodule import SmartModule + + +class PowerProtection(SmartModule): + """Implementation for power_protection.""" + + REQUIRED_COMPONENT = "power_protection" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + device=self._device, + id="overloaded", + name="Overloaded", + container=self, + attribute_getter="overloaded", + type=Feature.Type.BinarySensor, + category=Feature.Category.Info, + ) + ) + self._add_feature( + Feature( + device=self._device, + id="power_protection_threshold", + name="Power protection threshold", + container=self, + attribute_getter="_threshold_or_zero", + attribute_setter="_set_threshold_auto_enable", + unit_getter=lambda: "W", + type=Feature.Type.Number, + range_getter=lambda: (0, self._max_power), + category=Feature.Category.Config, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {"get_protection_power": {}, "get_max_power": {}} + + @property + def overloaded(self) -> bool: + """Return True is power protection has been triggered. + + This value remains True until the device is turned on again. + """ + return self._device.sys_info["power_protection_status"] == "overloaded" + + @property + def enabled(self) -> bool: + """Return True if child protection is enabled.""" + return self.data["get_protection_power"]["enabled"] + + async def set_enabled(self, enabled: bool, *, threshold: int | None = None) -> dict: + """Set power protection enabled. + + If power protection has never been enabled before the threshold will + be 0 so if threshold is not provided it will be set to half the max. + """ + if threshold is None and enabled and self.protection_threshold == 0: + threshold = int(self._max_power / 2) + + if threshold and (threshold < 0 or threshold > self._max_power): + raise ValueError( + "Threshold out of range: %s (%s)", threshold, self.protection_threshold + ) + + params = {**self.data["get_protection_power"], "enabled": enabled} + if threshold is not None: + params["protection_power"] = threshold + return await self.call("set_protection_power", params) + + async def _set_threshold_auto_enable(self, threshold: int) -> dict: + """Set power protection and enable.""" + if threshold == 0: + return await self.set_enabled(False) + else: + return await self.set_enabled(True, threshold=threshold) + + @property + def _threshold_or_zero(self) -> int: + """Get power protection threshold. 0 if not enabled.""" + return self.protection_threshold if self.enabled else 0 + + @property + def _max_power(self) -> int: + """Return max power.""" + return self.data["get_max_power"]["max_power"] + + @property + def protection_threshold( + self, + ) -> Annotated[int, FeatureAttribute("power_protection_threshold")]: + """Return protection threshold in watts.""" + # If never configured, there is no value set. + return self.data["get_protection_power"].get("protection_power", 0) + + async def set_protection_threshold(self, threshold: int) -> dict: + """Set protection threshold.""" + if threshold < 0 or threshold > self._max_power: + raise ValueError( + "Threshold out of range: %s (%s)", threshold, self.protection_threshold + ) + + params = { + **self.data["get_protection_power"], + "protection_power": threshold, + } + return await self.call("set_protection_power", params) + + async def _check_supported(self) -> bool: + """Return True if module is supported. + + This is needed, as strips like P304M report the status only for children. + """ + return "power_protection_status" in self._device.sys_info diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index d2367d9fa..2006b52e8 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -164,6 +164,14 @@ def credentials_hash(self): "energy_monitoring", {"igain": 10861, "vgain": 118657}, ), + "get_protection_power": ( + "power_protection", + {"enabled": False, "protection_power": 0}, + ), + "get_max_power": ( + "power_protection", + {"max_power": 3904}, + ), "get_matter_setup_info": ( "matter", { diff --git a/tests/smart/modules/test_powerprotection.py b/tests/smart/modules/test_powerprotection.py new file mode 100644 index 000000000..7f03c0e9a --- /dev/null +++ b/tests/smart/modules/test_powerprotection.py @@ -0,0 +1,98 @@ +import pytest +from pytest_mock import MockerFixture + +from kasa import Module, SmartDevice + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +powerprotection = parametrize( + "has powerprotection", + component_filter="power_protection", + protocol_filter={"SMART"}, +) + + +@powerprotection +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("overloaded", "overloaded", bool), + ("power_protection_threshold", "protection_threshold", int), + ], +) +async def test_features(dev, feature, prop_name, type): + """Test that features are registered and work as expected.""" + powerprot = next(get_parent_and_child_modules(dev, Module.PowerProtection)) + assert powerprot + device = powerprot._device + + prop = getattr(powerprot, prop_name) + assert isinstance(prop, type) + + feat = device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@powerprotection +async def test_set_enable(dev: SmartDevice, mocker: MockerFixture): + """Test enable.""" + powerprot = next(get_parent_and_child_modules(dev, Module.PowerProtection)) + assert powerprot + device = powerprot._device + + original_enabled = powerprot.enabled + original_threshold = powerprot.protection_threshold + + try: + # Simple enable with an existing threshold + call_spy = mocker.spy(powerprot, "call") + await powerprot.set_enabled(True) + params = { + "enabled": True, + "protection_power": mocker.ANY, + } + call_spy.assert_called_with("set_protection_power", params) + + # Enable with no threshold param when 0 + call_spy.reset_mock() + await powerprot.set_protection_threshold(0) + await device.update() + await powerprot.set_enabled(True) + params = { + "enabled": True, + "protection_power": int(powerprot._max_power / 2), + } + call_spy.assert_called_with("set_protection_power", params) + + # Enable false should not update the threshold + call_spy.reset_mock() + await powerprot.set_protection_threshold(0) + await device.update() + await powerprot.set_enabled(False) + params = { + "enabled": False, + "protection_power": 0, + } + call_spy.assert_called_with("set_protection_power", params) + + finally: + await powerprot.set_enabled(original_enabled, threshold=original_threshold) + + +@powerprotection +async def test_set_threshold(dev: SmartDevice, mocker: MockerFixture): + """Test enable.""" + powerprot = next(get_parent_and_child_modules(dev, Module.PowerProtection)) + assert powerprot + + call_spy = mocker.spy(powerprot, "call") + await powerprot.set_protection_threshold(123) + params = { + "enabled": mocker.ANY, + "protection_power": 123, + } + call_spy.assert_called_with("set_protection_power", params) + + with pytest.raises(ValueError, match="Threshold out of range"): + await powerprot.set_protection_threshold(-10) From d857cc68bb2afa6551096ef8347de0e614008e7a Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sun, 26 Jan 2025 14:13:09 +0100 Subject: [PATCH 841/892] Allow passing alarm parameter overrides (#1340) Allows specifying alarm parameters duration, volume and sound. Adds new feature: `alarm_duration`. Breaking change to `alarm_volume' on the `smart.Alarm` module is changed from `str` to `int` Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- kasa/smart/modules/alarm.py | 144 +++++++++++++++++++++++++++--- tests/fakeprotocol_smart.py | 10 +-- tests/smart/modules/test_alarm.py | 124 +++++++++++++++++++++++++ 3 files changed, 258 insertions(+), 20 deletions(-) create mode 100644 tests/smart/modules/test_alarm.py diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index f1bf72363..d645d3c95 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -2,11 +2,27 @@ from __future__ import annotations -from typing import Literal +from typing import TYPE_CHECKING, Annotated, Literal, TypeAlias from ...feature import Feature +from ...module import FeatureAttribute from ..smartmodule import SmartModule +DURATION_MAX = 10 * 60 + +VOLUME_INT_TO_STR = { + 0: "mute", + 1: "low", + 2: "normal", + 3: "high", +} + +VOLUME_STR_LIST = [v for v in VOLUME_INT_TO_STR.values()] +VOLUME_INT_RANGE = (min(VOLUME_INT_TO_STR.keys()), max(VOLUME_INT_TO_STR.keys())) +VOLUME_STR_TO_INT = {v: k for k, v in VOLUME_INT_TO_STR.items()} + +AlarmVolume: TypeAlias = Literal["mute", "low", "normal", "high"] + class Alarm(SmartModule): """Implementation of alarm module.""" @@ -21,10 +37,7 @@ def query(self) -> dict: } def _initialize_features(self) -> None: - """Initialize features. - - This is implemented as some features depend on device responses. - """ + """Initialize features.""" device = self._device self._add_feature( Feature( @@ -67,11 +80,37 @@ def _initialize_features(self) -> None: id="alarm_volume", name="Alarm volume", container=self, - attribute_getter="alarm_volume", + attribute_getter="_alarm_volume_str", attribute_setter="set_alarm_volume", category=Feature.Category.Config, type=Feature.Type.Choice, - choices_getter=lambda: ["low", "normal", "high"], + choices_getter=lambda: VOLUME_STR_LIST, + ) + ) + self._add_feature( + Feature( + device, + id="alarm_volume_level", + name="Alarm volume", + container=self, + attribute_getter="alarm_volume", + attribute_setter="set_alarm_volume", + category=Feature.Category.Config, + type=Feature.Type.Number, + range_getter=lambda: VOLUME_INT_RANGE, + ) + ) + self._add_feature( + Feature( + device, + id="alarm_duration", + name="Alarm duration", + container=self, + attribute_getter="alarm_duration", + attribute_setter="set_alarm_duration", + category=Feature.Category.Config, + type=Feature.Type.Number, + range_getter=lambda: (1, DURATION_MAX), ) ) self._add_feature( @@ -96,15 +135,16 @@ def _initialize_features(self) -> None: ) @property - def alarm_sound(self) -> str: + def alarm_sound(self) -> Annotated[str, FeatureAttribute()]: """Return current alarm sound.""" return self.data["get_alarm_configure"]["type"] - async def set_alarm_sound(self, sound: str) -> dict: + async def set_alarm_sound(self, sound: str) -> Annotated[dict, FeatureAttribute()]: """Set alarm sound. See *alarm_sounds* for list of available sounds. """ + self._check_sound(sound) payload = self.data["get_alarm_configure"].copy() payload["type"] = sound return await self.call("set_alarm_configure", payload) @@ -115,16 +155,40 @@ def alarm_sounds(self) -> list[str]: return self.data["get_support_alarm_type_list"]["alarm_type_list"] @property - def alarm_volume(self) -> Literal["low", "normal", "high"]: + def alarm_volume(self) -> Annotated[int, FeatureAttribute("alarm_volume_level")]: + """Return alarm volume.""" + return VOLUME_STR_TO_INT[self._alarm_volume_str] + + @property + def _alarm_volume_str( + self, + ) -> Annotated[AlarmVolume, FeatureAttribute("alarm_volume")]: """Return alarm volume.""" return self.data["get_alarm_configure"]["volume"] - async def set_alarm_volume(self, volume: Literal["low", "normal", "high"]) -> dict: + async def set_alarm_volume( + self, volume: AlarmVolume | int + ) -> Annotated[dict, FeatureAttribute()]: """Set alarm volume.""" + self._check_and_convert_volume(volume) payload = self.data["get_alarm_configure"].copy() payload["volume"] = volume return await self.call("set_alarm_configure", payload) + @property + def alarm_duration(self) -> Annotated[int, FeatureAttribute()]: + """Return alarm duration.""" + return self.data["get_alarm_configure"]["duration"] + + async def set_alarm_duration( + self, duration: int + ) -> Annotated[dict, FeatureAttribute()]: + """Set alarm duration.""" + self._check_duration(duration) + payload = self.data["get_alarm_configure"].copy() + payload["duration"] = duration + return await self.call("set_alarm_configure", payload) + @property def active(self) -> bool: """Return true if alarm is active.""" @@ -136,10 +200,62 @@ def source(self) -> str | None: src = self._device.sys_info["in_alarm_source"] return src if src else None - async def play(self) -> dict: - """Play alarm.""" - return await self.call("play_alarm") + async def play( + self, + *, + duration: int | None = None, + volume: int | AlarmVolume | None = None, + sound: str | None = None, + ) -> dict: + """Play alarm. + + The optional *duration*, *volume*, and *sound* to override the device settings. + *volume* can be set to 'mute', 'low', 'normal', or 'high'. + *duration* is in seconds. + See *alarm_sounds* for the list of sounds available for the device. + """ + params: dict[str, str | int] = {} + + if duration is not None: + self._check_duration(duration) + params["alarm_duration"] = duration + + if volume is not None: + target_volume = self._check_and_convert_volume(volume) + params["alarm_volume"] = target_volume + + if sound is not None: + self._check_sound(sound) + params["alarm_type"] = sound + + return await self.call("play_alarm", params) async def stop(self) -> dict: """Stop alarm.""" return await self.call("stop_alarm") + + def _check_and_convert_volume(self, volume: str | int) -> str: + """Raise an exception on invalid volume.""" + if isinstance(volume, int): + volume = VOLUME_INT_TO_STR.get(volume, "invalid") + + if TYPE_CHECKING: + assert isinstance(volume, str) + + if volume not in VOLUME_INT_TO_STR.values(): + raise ValueError( + f"Invalid volume {volume} " + f"available: {VOLUME_INT_TO_STR.keys()}, {VOLUME_INT_TO_STR.values()}" + ) + + return volume + + def _check_duration(self, duration: int) -> None: + """Raise an exception on invalid duration.""" + if duration < 1 or duration > DURATION_MAX: + raise ValueError(f"Invalid duration {duration} available: 1-600") + + def _check_sound(self, sound: str) -> None: + """Raise an exception on invalid sound.""" + if sound not in self.alarm_sounds: + raise ValueError(f"Invalid sound {sound} available: {self.alarm_sounds}") diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index 2006b52e8..257e07ea2 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -134,11 +134,9 @@ def credentials_hash(self): "get_alarm_configure": ( "alarm", { - "get_alarm_configure": { - "duration": 10, - "type": "Doorbell Ring 2", - "volume": "low", - } + "duration": 10, + "type": "Doorbell Ring 2", + "volume": "low", }, ), "get_support_alarm_type_list": ( @@ -672,7 +670,7 @@ async def _send_request(self, request_dict: dict): self.fixture_name, set() ).add(method) return retval - elif method in ["set_qs_info", "fw_download"]: + elif method in ["set_qs_info", "fw_download", "play_alarm", "stop_alarm"]: return {"error_code": 0} elif method == "set_dynamic_light_effect_rule_enable": self._set_dynamic_light_effect(info, params) diff --git a/tests/smart/modules/test_alarm.py b/tests/smart/modules/test_alarm.py new file mode 100644 index 000000000..25d24a588 --- /dev/null +++ b/tests/smart/modules/test_alarm.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice +from kasa.smart.modules import Alarm + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +alarm = parametrize("has alarm", component_filter="alarm", protocol_filter={"SMART"}) + + +@alarm +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("alarm", "active", bool), + ("alarm_source", "source", str | None), + ("alarm_sound", "alarm_sound", str), + ("alarm_volume", "_alarm_volume_str", str), + ("alarm_volume_level", "alarm_volume", int), + ], +) +async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type): + """Test that features are registered and work as expected.""" + alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) + assert alarm is not None + + prop = getattr(alarm, prop_name) + assert isinstance(prop, type) + + feat = alarm._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@alarm +async def test_volume_feature(dev: SmartDevice): + """Test that volume features have correct choices and range.""" + alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) + assert alarm is not None + + volume_str_feat = alarm.get_feature("_alarm_volume_str") + assert volume_str_feat + + assert volume_str_feat.choices == ["mute", "low", "normal", "high"] + + volume_int_feat = alarm.get_feature("alarm_volume") + assert volume_int_feat.minimum_value == 0 + assert volume_int_feat.maximum_value == 3 + + +@alarm +@pytest.mark.parametrize( + ("kwargs", "request_params"), + [ + pytest.param({"volume": "low"}, {"alarm_volume": "low"}, id="volume"), + pytest.param({"volume": 0}, {"alarm_volume": "mute"}, id="volume-integer"), + pytest.param({"duration": 1}, {"alarm_duration": 1}, id="duration"), + pytest.param( + {"sound": "Doorbell Ring 1"}, {"alarm_type": "Doorbell Ring 1"}, id="sound" + ), + ], +) +async def test_play(dev: SmartDevice, kwargs, request_params, mocker: MockerFixture): + """Test that play parameters are handled correctly.""" + alarm: Alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) + call_spy = mocker.spy(alarm, "call") + await alarm.play(**kwargs) + + call_spy.assert_called_with("play_alarm", request_params) + + with pytest.raises(ValueError, match="Invalid duration"): + await alarm.play(duration=-1) + + with pytest.raises(ValueError, match="Invalid sound"): + await alarm.play(sound="unknown") + + with pytest.raises(ValueError, match="Invalid volume"): + await alarm.play(volume="unknown") # type: ignore[arg-type] + + with pytest.raises(ValueError, match="Invalid volume"): + await alarm.play(volume=-1) + + +@alarm +async def test_stop(dev: SmartDevice, mocker: MockerFixture): + """Test that stop creates the correct call.""" + alarm: Alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) + call_spy = mocker.spy(alarm, "call") + await alarm.stop() + + call_spy.assert_called_with("stop_alarm") + + +@alarm +@pytest.mark.parametrize( + ("method", "value", "target_key"), + [ + pytest.param( + "set_alarm_sound", "Doorbell Ring 1", "type", id="set_alarm_sound" + ), + pytest.param("set_alarm_volume", "low", "volume", id="set_alarm_volume"), + pytest.param("set_alarm_duration", 10, "duration", id="set_alarm_duration"), + ], +) +async def test_set_alarm_configure( + dev: SmartDevice, + mocker: MockerFixture, + method: str, + value: str | int, + target_key: str, +): + """Test that set_alarm_sound creates the correct call.""" + alarm: Alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) + call_spy = mocker.spy(alarm, "call") + await getattr(alarm, method)(value) + + expected_params = {"duration": mocker.ANY, "type": mocker.ANY, "volume": mocker.ANY} + expected_params[target_key] = value + + call_spy.assert_called_with("set_alarm_configure", expected_params) From 656c88771a380ab6d059bb22e09f447bc755e2c7 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sun, 26 Jan 2025 13:33:13 +0000 Subject: [PATCH 842/892] Add common alarm interface (#1479) Add a common interface for the `alarm` module across `smart` and `smartcam` devices. --- kasa/interfaces/__init__.py | 2 + kasa/interfaces/alarm.py | 75 ++++++++++++++++++++++++++++ kasa/module.py | 2 +- kasa/smart/modules/alarm.py | 3 +- kasa/smartcam/modules/alarm.py | 75 +++++++++++++++++++++------- tests/fakeprotocol_smartcam.py | 12 +++-- tests/smartcam/modules/test_alarm.py | 22 ++++++-- 7 files changed, 161 insertions(+), 30 deletions(-) create mode 100644 kasa/interfaces/alarm.py diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index fc82ee0bc..ac5e00da0 100644 --- a/kasa/interfaces/__init__.py +++ b/kasa/interfaces/__init__.py @@ -1,5 +1,6 @@ """Package for interfaces.""" +from .alarm import Alarm from .childsetup import ChildSetup from .energy import Energy from .fan import Fan @@ -11,6 +12,7 @@ from .time import Time __all__ = [ + "Alarm", "ChildSetup", "Fan", "Energy", diff --git a/kasa/interfaces/alarm.py b/kasa/interfaces/alarm.py new file mode 100644 index 000000000..1a50b1ef7 --- /dev/null +++ b/kasa/interfaces/alarm.py @@ -0,0 +1,75 @@ +"""Module for base alarm module.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Annotated + +from ..module import FeatureAttribute, Module + + +class Alarm(Module, ABC): + """Base interface to represent an alarm module.""" + + @property + @abstractmethod + def alarm_sound(self) -> Annotated[str, FeatureAttribute()]: + """Return current alarm sound.""" + + @abstractmethod + async def set_alarm_sound(self, sound: str) -> Annotated[dict, FeatureAttribute()]: + """Set alarm sound. + + See *alarm_sounds* for list of available sounds. + """ + + @property + @abstractmethod + def alarm_sounds(self) -> list[str]: + """Return list of available alarm sounds.""" + + @property + @abstractmethod + def alarm_volume(self) -> Annotated[int, FeatureAttribute()]: + """Return alarm volume.""" + + @abstractmethod + async def set_alarm_volume( + self, volume: int + ) -> Annotated[dict, FeatureAttribute()]: + """Set alarm volume.""" + + @property + @abstractmethod + def alarm_duration(self) -> Annotated[int, FeatureAttribute()]: + """Return alarm duration.""" + + @abstractmethod + async def set_alarm_duration( + self, duration: int + ) -> Annotated[dict, FeatureAttribute()]: + """Set alarm duration.""" + + @property + @abstractmethod + def active(self) -> bool: + """Return true if alarm is active.""" + + @abstractmethod + async def play( + self, + *, + duration: int | None = None, + volume: int | None = None, + sound: str | None = None, + ) -> dict: + """Play alarm. + + The optional *duration*, *volume*, and *sound* to override the device settings. + *duration* is in seconds. + See *alarm_sounds* for the list of sounds available for the device. + """ + + @abstractmethod + async def stop(self) -> dict: + """Stop alarm.""" diff --git a/kasa/module.py b/kasa/module.py index c58c6b401..8fdff7c34 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -96,6 +96,7 @@ class Module(ABC): """ # Common Modules + Alarm: Final[ModuleName[interfaces.Alarm]] = ModuleName("Alarm") ChildSetup: Final[ModuleName[interfaces.ChildSetup]] = ModuleName("ChildSetup") Energy: Final[ModuleName[interfaces.Energy]] = ModuleName("Energy") Fan: Final[ModuleName[interfaces.Fan]] = ModuleName("Fan") @@ -116,7 +117,6 @@ class Module(ABC): IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud") # SMART only Modules - Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm") AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff") BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor") Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness") diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index d645d3c95..cd6021829 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Annotated, Literal, TypeAlias from ...feature import Feature +from ...interfaces import Alarm as AlarmInterface from ...module import FeatureAttribute from ..smartmodule import SmartModule @@ -24,7 +25,7 @@ AlarmVolume: TypeAlias = Literal["mute", "low", "normal", "high"] -class Alarm(SmartModule): +class Alarm(SmartModule, AlarmInterface): """Implementation of alarm module.""" REQUIRED_COMPONENT = "alarm" diff --git a/kasa/smartcam/modules/alarm.py b/kasa/smartcam/modules/alarm.py index 5330f309c..18833d822 100644 --- a/kasa/smartcam/modules/alarm.py +++ b/kasa/smartcam/modules/alarm.py @@ -3,6 +3,7 @@ from __future__ import annotations from ...feature import Feature +from ...interfaces import Alarm as AlarmInterface from ...smart.smartmodule import allow_update_after from ..smartcammodule import SmartCamModule @@ -13,12 +14,9 @@ VOLUME_MAX = 10 -class Alarm(SmartCamModule): +class Alarm(SmartCamModule, AlarmInterface): """Implementation of alarm module.""" - # Needs a different name to avoid clashing with SmartAlarm - NAME = "SmartCamAlarm" - REQUIRED_COMPONENT = "siren" QUERY_GETTER_NAME = "getSirenStatus" QUERY_MODULE_NAME = "siren" @@ -117,11 +115,8 @@ async def set_alarm_sound(self, sound: str) -> dict: See *alarm_sounds* for list of available sounds. """ - if sound not in self.alarm_sounds: - raise ValueError( - f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}" - ) - return await self.call("setSirenConfig", {"siren": {"siren_type": sound}}) + config = self._validate_and_get_config(sound=sound) + return await self.call("setSirenConfig", {"siren": config}) @property def alarm_sounds(self) -> list[str]: @@ -139,9 +134,8 @@ def alarm_volume(self) -> int: @allow_update_after async def set_alarm_volume(self, volume: int) -> dict: """Set alarm volume.""" - if volume < VOLUME_MIN or volume > VOLUME_MAX: - raise ValueError(f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}") - return await self.call("setSirenConfig", {"siren": {"volume": str(volume)}}) + config = self._validate_and_get_config(volume=volume) + return await self.call("setSirenConfig", {"siren": config}) @property def alarm_duration(self) -> int: @@ -151,20 +145,65 @@ def alarm_duration(self) -> int: @allow_update_after async def set_alarm_duration(self, duration: int) -> dict: """Set alarm volume.""" - if duration < DURATION_MIN or duration > DURATION_MAX: - msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}" - raise ValueError(msg) - return await self.call("setSirenConfig", {"siren": {"duration": duration}}) + config = self._validate_and_get_config(duration=duration) + return await self.call("setSirenConfig", {"siren": config}) @property def active(self) -> bool: """Return true if alarm is active.""" return self.data["getSirenStatus"]["status"] != "off" - async def play(self) -> dict: - """Play alarm.""" + async def play( + self, + *, + duration: int | None = None, + volume: int | None = None, + sound: str | None = None, + ) -> dict: + """Play alarm. + + The optional *duration*, *volume*, and *sound* to override the device settings. + *duration* is in seconds. + See *alarm_sounds* for the list of sounds available for the device. + """ + if config := self._validate_and_get_config( + duration=duration, volume=volume, sound=sound + ): + await self.call("setSirenConfig", {"siren": config}) + return await self.call("setSirenStatus", {"siren": {"status": "on"}}) async def stop(self) -> dict: """Stop alarm.""" return await self.call("setSirenStatus", {"siren": {"status": "off"}}) + + def _validate_and_get_config( + self, + *, + duration: int | None = None, + volume: int | None = None, + sound: str | None = None, + ) -> dict: + if sound and sound not in self.alarm_sounds: + raise ValueError( + f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}" + ) + + if duration is not None and ( + duration < DURATION_MIN or duration > DURATION_MAX + ): + msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}" + raise ValueError(msg) + + if volume is not None and (volume < VOLUME_MIN or volume > VOLUME_MAX): + raise ValueError(f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}") + + config: dict[str, str | int] = {} + if sound: + config["siren_type"] = sound + if duration is not None: + config["duration"] = duration + if volume is not None: + config["volume"] = str(volume) + + return config diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index 311a1742c..d531e910b 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -276,12 +276,14 @@ async def _send_request(self, request_dict: dict): section = next(iter(val)) skey_val = val[section] if not isinstance(skey_val, dict): # single level query - section_key = section - section_val = skey_val - if (get_info := info.get(get_method)) and section_key in get_info: - get_info[section_key] = section_val - else: + updates = { + k: v for k, v in val.items() if k in info.get(get_method, {}) + } + if len(updates) != len(val): + # All keys to update must already be in the getter return {"error_code": -1} + info[get_method] = {**info[get_method], **updates} + break for skey, sval in skey_val.items(): section_key = skey diff --git a/tests/smartcam/modules/test_alarm.py b/tests/smartcam/modules/test_alarm.py index 50e0b5b3a..0a176650f 100644 --- a/tests/smartcam/modules/test_alarm.py +++ b/tests/smartcam/modules/test_alarm.py @@ -4,14 +4,13 @@ import pytest -from kasa import Device +from kasa import Device, Module from kasa.smartcam.modules.alarm import ( DURATION_MAX, DURATION_MIN, VOLUME_MAX, VOLUME_MIN, ) -from kasa.smartcam.smartcammodule import SmartCamModule from ...conftest import hub_smartcam @@ -19,7 +18,7 @@ @hub_smartcam async def test_alarm(dev: Device): """Test device alarm.""" - alarm = dev.modules.get(SmartCamModule.SmartCamAlarm) + alarm = dev.modules.get(Module.Alarm) assert alarm original_duration = alarm.alarm_duration @@ -63,6 +62,19 @@ async def test_alarm(dev: Device): await dev.update() assert alarm.alarm_sound == new_sound + # Test play parameters + await alarm.play( + duration=original_duration, volume=original_volume, sound=original_sound + ) + await dev.update() + assert alarm.active + assert alarm.alarm_sound == original_sound + assert alarm.alarm_duration == original_duration + assert alarm.alarm_volume == original_volume + await alarm.stop() + await dev.update() + assert not alarm.active + finally: await alarm.set_alarm_volume(original_volume) await alarm.set_alarm_duration(original_duration) @@ -73,7 +85,7 @@ async def test_alarm(dev: Device): @hub_smartcam async def test_alarm_invalid_setters(dev: Device): """Test device alarm invalid setter values.""" - alarm = dev.modules.get(SmartCamModule.SmartCamAlarm) + alarm = dev.modules.get(Module.Alarm) assert alarm # test set sound invalid @@ -95,7 +107,7 @@ async def test_alarm_invalid_setters(dev: Device): @hub_smartcam async def test_alarm_features(dev: Device): """Test device alarm features.""" - alarm = dev.modules.get(SmartCamModule.SmartCamAlarm) + alarm = dev.modules.get(Module.Alarm) assert alarm original_duration = alarm.alarm_duration From 1df05af2085dec558b72feabff50173936bdc95a Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sun, 26 Jan 2025 17:14:45 +0100 Subject: [PATCH 843/892] Change category for empty dustbin feature from Primary to Config (#1485) --- kasa/smart/modules/dustbin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/smart/modules/dustbin.py b/kasa/smart/modules/dustbin.py index 08c35d5e1..33aecd8f7 100644 --- a/kasa/smart/modules/dustbin.py +++ b/kasa/smart/modules/dustbin.py @@ -34,7 +34,7 @@ def _initialize_features(self) -> None: name="Empty dustbin", container=self, attribute_setter="start_emptying", - category=Feature.Category.Primary, + category=Feature.Category.Config, type=Feature.Action, ) ) From 781d07f6a2615d363d13e06cc40c2dc044629fec Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sun, 26 Jan 2025 17:16:24 +0100 Subject: [PATCH 844/892] Convert carpet_clean_mode to carpet_boost switch (#1486) --- kasa/smart/modules/clean.py | 40 ++++++++++--------------------- tests/smart/modules/test_clean.py | 15 ++++-------- 2 files changed, 16 insertions(+), 39 deletions(-) diff --git a/kasa/smart/modules/clean.py b/kasa/smart/modules/clean.py index 393a4f293..376e0d398 100644 --- a/kasa/smart/modules/clean.py +++ b/kasa/smart/modules/clean.py @@ -4,7 +4,7 @@ import logging from datetime import timedelta -from enum import IntEnum, StrEnum +from enum import IntEnum from typing import Annotated, Literal from ...feature import Feature @@ -58,13 +58,6 @@ class FanSpeed(IntEnum): Ultra = 5 -class CarpetCleanMode(StrEnum): - """Carpet clean mode.""" - - Normal = "normal" - Boost = "boost" - - class AreaUnit(IntEnum): """Area unit.""" @@ -184,15 +177,14 @@ def _initialize_features(self) -> None: self._add_feature( Feature( self._device, - id="carpet_clean_mode", - name="Carpet clean mode", + id="carpet_boost", + name="Carpet boost", container=self, - attribute_getter="carpet_clean_mode", - attribute_setter="set_carpet_clean_mode", + attribute_getter="carpet_boost", + attribute_setter="set_carpet_boost", icon="mdi:rug", - choices_getter=lambda: list(CarpetCleanMode.__members__), category=Feature.Category.Config, - type=Feature.Type.Choice, + type=Feature.Type.Switch, ) ) self._add_feature( @@ -380,22 +372,14 @@ def status(self) -> Status: return Status.UnknownInternal @property - def carpet_clean_mode(self) -> Annotated[str, FeatureAttribute()]: - """Return carpet clean mode.""" - return CarpetCleanMode(self.data["getCarpetClean"]["carpet_clean_prefer"]).name + def carpet_boost(self) -> bool: + """Return carpet boost mode.""" + return self.data["getCarpetClean"]["carpet_clean_prefer"] == "boost" - async def set_carpet_clean_mode( - self, mode: str - ) -> Annotated[dict, FeatureAttribute()]: + async def set_carpet_boost(self, on: bool) -> dict: """Set carpet clean mode.""" - name_to_value = {x.name: x.value for x in CarpetCleanMode} - if mode not in name_to_value: - raise ValueError( - "Invalid carpet clean mode %s, available %s", mode, name_to_value - ) - return await self.call( - "setCarpetClean", {"carpet_clean_prefer": name_to_value[mode]} - ) + mode = "boost" if on else "normal" + return await self.call("setCarpetClean", {"carpet_clean_prefer": mode}) @property def area_unit(self) -> AreaUnit: diff --git a/tests/smart/modules/test_clean.py b/tests/smart/modules/test_clean.py index f4c2813c4..0f935959e 100644 --- a/tests/smart/modules/test_clean.py +++ b/tests/smart/modules/test_clean.py @@ -21,7 +21,7 @@ ("vacuum_status", "status", Status), ("vacuum_error", "error", ErrorCode), ("vacuum_fan_speed", "fan_speed_preset", str), - ("carpet_clean_mode", "carpet_clean_mode", str), + ("carpet_boost", "carpet_boost", bool), ("battery_level", "battery", int), ], ) @@ -71,11 +71,11 @@ async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: ty id="vacuum_fan_speed", ), pytest.param( - "carpet_clean_mode", - "Boost", + "carpet_boost", + True, "setCarpetClean", {"carpet_clean_prefer": "boost"}, - id="carpet_clean_mode", + id="carpet_boost", ), pytest.param( "clean_count", @@ -218,13 +218,6 @@ async def test_unknown_status( "Invalid fan speed", id="vacuum_fan_speed", ), - pytest.param( - "carpet_clean_mode", - "invalid mode", - ValueError, - "Invalid carpet clean mode", - id="carpet_clean_mode", - ), ], ) async def test_invalid_settings( From 09e73faca3398822eac9b255b1fd2e127bb8bbfe Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sun, 26 Jan 2025 17:15:00 +0000 Subject: [PATCH 845/892] Prepare 0.10.0 (#1473) ## [0.10.0](https://github.com/python-kasa/python-kasa/tree/0.10.0) (2025-01-26) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.9.1...0.10.0) **Release summary:** This release brings support for many new devices, including completely new device types: - Support for Tapo robot vacuums. Special thanks to @steveredden, @MAXIGAMESSUPPER, and veep60 for helping to get this implemented! - Support for hub attached cameras and doorbells (H200) - Improved support for hubs (including pairing & better chime controls) - Support for many new camera and doorbell device models, including C220, C720, D100C, D130, and D230 Many thanks to testers and new contributors - @steveredden, @DawidPietrykowski, @Obbay2, @andrewome, @ryenitcher and @etmmvdp! **Breaking changes:** - `uses_http` is now a readonly property of device config. Consumers that relied on `uses_http` to be persisted with `DeviceConfig.to_dict()` will need to store the value separately. - `is_color`, `is_dimmable`, `is_variable_color_temp`, `valid_temperate_range`, and `has_effects` attributes from the `Light` module are deprecated, consumers should use `has_feature("hsv")`, `has_feature("brightness")`, `has_feature("color_temp")`, `get_feature("color_temp").range`, and `Module.LightEffect in dev.modules` respectively. Calling the deprecated attributes will emit a `DeprecationWarning` and type checkers will fail them. - `alarm_volume` on the `smart.Alarm` module is changed from `str` to `int` **Breaking changes:** - Make uses\_http a readonly property of device config [\#1449](https://github.com/python-kasa/python-kasa/pull/1449) (@sdb9696) - Allow passing alarm parameter overrides [\#1340](https://github.com/python-kasa/python-kasa/pull/1340) (@rytilahti) - Deprecate legacy light module is\_capability checks [\#1297](https://github.com/python-kasa/python-kasa/pull/1297) (@sdb9696) **Implemented enhancements:** - Expose more battery sensors for D230 [\#1451](https://github.com/python-kasa/python-kasa/issues/1451) - dumping HTTP POST Body for Tapo Vacuum \(RV30 Plus\) [\#937](https://github.com/python-kasa/python-kasa/issues/937) - Add common alarm interface [\#1479](https://github.com/python-kasa/python-kasa/pull/1479) (@sdb9696) - Add common childsetup interface [\#1470](https://github.com/python-kasa/python-kasa/pull/1470) (@sdb9696) - Add childsetup module to smartcam hubs [\#1469](https://github.com/python-kasa/python-kasa/pull/1469) (@sdb9696) - Add smartcam pet detection toggle module [\#1465](https://github.com/python-kasa/python-kasa/pull/1465) (@DawidPietrykowski) - Only log one warning per unknown clean error code and status [\#1462](https://github.com/python-kasa/python-kasa/pull/1462) (@rytilahti) - Add childlock module for vacuums [\#1461](https://github.com/python-kasa/python-kasa/pull/1461) (@rytilahti) - Add ultra mode \(fanspeed = 5\) for vacuums [\#1459](https://github.com/python-kasa/python-kasa/pull/1459) (@rytilahti) - Add setting to change carpet clean mode [\#1458](https://github.com/python-kasa/python-kasa/pull/1458) (@rytilahti) - Add setting to change clean count [\#1457](https://github.com/python-kasa/python-kasa/pull/1457) (@rytilahti) - Add mop module [\#1456](https://github.com/python-kasa/python-kasa/pull/1456) (@rytilahti) - Enable dynamic hub child creation and deletion on update [\#1454](https://github.com/python-kasa/python-kasa/pull/1454) (@sdb9696) - Expose current cleaning information [\#1453](https://github.com/python-kasa/python-kasa/pull/1453) (@rytilahti) - Add battery module to smartcam devices [\#1452](https://github.com/python-kasa/python-kasa/pull/1452) (@sdb9696) - Allow update of camera modules after setting values [\#1450](https://github.com/python-kasa/python-kasa/pull/1450) (@sdb9696) - Update hub children on first update and delay subsequent updates [\#1438](https://github.com/python-kasa/python-kasa/pull/1438) (@sdb9696) - Add support for doorbells and chimes [\#1435](https://github.com/python-kasa/python-kasa/pull/1435) (@steveredden) - Implement vacuum dustbin module \(dust\_bucket\) [\#1423](https://github.com/python-kasa/python-kasa/pull/1423) (@rytilahti) - Allow https for klaptransport [\#1415](https://github.com/python-kasa/python-kasa/pull/1415) (@rytilahti) - Add smartcam child device support for smartcam hubs [\#1413](https://github.com/python-kasa/python-kasa/pull/1413) (@sdb9696) - Add powerprotection module [\#1337](https://github.com/python-kasa/python-kasa/pull/1337) (@rytilahti) - Add vacuum speaker controls [\#1332](https://github.com/python-kasa/python-kasa/pull/1332) (@rytilahti) - Add consumables module for vacuums [\#1327](https://github.com/python-kasa/python-kasa/pull/1327) (@rytilahti) - Add ADC Value to PIR Enabled Switches [\#1263](https://github.com/python-kasa/python-kasa/pull/1263) (@ryenitcher) - Add support for cleaning records [\#945](https://github.com/python-kasa/python-kasa/pull/945) (@rytilahti) - Initial support for vacuums \(clean module\) [\#944](https://github.com/python-kasa/python-kasa/pull/944) (@rytilahti) - Add support for pairing devices with hubs [\#859](https://github.com/python-kasa/python-kasa/pull/859) (@rytilahti) **Fixed bugs:** - TP-Link HS300 Wi-Fi Power-Strip - "Parent On/Off" not functioning. [\#637](https://github.com/python-kasa/python-kasa/issues/637) - Convert carpet\_clean\_mode to carpet\_boost switch [\#1486](https://github.com/python-kasa/python-kasa/pull/1486) (@rytilahti) - Change category for empty dustbin feature from Primary to Config [\#1485](https://github.com/python-kasa/python-kasa/pull/1485) (@rytilahti) - Report 0 for instead of None for zero current and voltage [\#1483](https://github.com/python-kasa/python-kasa/pull/1483) (@ryenitcher) - Disable iot camera creation until more complete [\#1480](https://github.com/python-kasa/python-kasa/pull/1480) (@sdb9696) - ssltransport: use debug logger for sending requests [\#1443](https://github.com/python-kasa/python-kasa/pull/1443) (@rytilahti) - Fix discover cli command with host [\#1437](https://github.com/python-kasa/python-kasa/pull/1437) (@sdb9696) - Fallback to is\_low for batterysensor's battery\_low [\#1420](https://github.com/python-kasa/python-kasa/pull/1420) (@rytilahti) - Fix iot strip turn on and off from parent [\#639](https://github.com/python-kasa/python-kasa/pull/639) (@Obbay2) **Added support for devices:** - Add D130\(US\) 1.0 1.1.9 fixture [\#1476](https://github.com/python-kasa/python-kasa/pull/1476) (@sdb9696) - Add D100C\(US\) 1.0 1.1.3 fixture [\#1475](https://github.com/python-kasa/python-kasa/pull/1475) (@sdb9696) - Add C220\(EU\) 1.0 1.2.2 camera fixture [\#1466](https://github.com/python-kasa/python-kasa/pull/1466) (@DawidPietrykowski) - Add D230\(EU\) 1.20 1.1.19 fixture [\#1448](https://github.com/python-kasa/python-kasa/pull/1448) (@sdb9696) - Add fixture for C720 camera [\#1433](https://github.com/python-kasa/python-kasa/pull/1433) (@steveredden) **Project maintenance:** - Update ruff to 0.9 [\#1482](https://github.com/python-kasa/python-kasa/pull/1482) (@sdb9696) - Cancel in progress CI workflows after new pushes [\#1481](https://github.com/python-kasa/python-kasa/pull/1481) (@sdb9696) - Update test framework to support smartcam device discovery. [\#1477](https://github.com/python-kasa/python-kasa/pull/1477) (@sdb9696) - Add error code 7 for clean module [\#1474](https://github.com/python-kasa/python-kasa/pull/1474) (@rytilahti) - Enable CI workflow on PRs to feat/ fix/ and janitor/ [\#1471](https://github.com/python-kasa/python-kasa/pull/1471) (@sdb9696) - Add commit-hook to prettify JSON files [\#1455](https://github.com/python-kasa/python-kasa/pull/1455) (@rytilahti) - Add required sphinx.configuration [\#1446](https://github.com/python-kasa/python-kasa/pull/1446) (@rytilahti) - Add more redactors for smartcams [\#1439](https://github.com/python-kasa/python-kasa/pull/1439) (@sdb9696) - Add KS230\(US\) 2.0 1.0.11 IOT Fixture [\#1430](https://github.com/python-kasa/python-kasa/pull/1430) (@ZeliardM) - Add tests for dump\_devinfo parent/child smartcam fixture generation [\#1428](https://github.com/python-kasa/python-kasa/pull/1428) (@sdb9696) - Raise errors on single smartcam child requests [\#1427](https://github.com/python-kasa/python-kasa/pull/1427) (@sdb9696) --- .pre-commit-config.yaml | 6 +- CHANGELOG.md | 96 ++++++++++++++++++++- RELEASING.md | 9 +- pyproject.toml | 2 +- uv.lock | 181 ++++++++++++++++++++++------------------ 5 files changed, 205 insertions(+), 89 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d191280cd..9aeb80965 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,13 +2,13 @@ repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.4.16 + rev: 0.5.24 hooks: # Update the uv lockfile - id: uv-lock - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -29,7 +29,7 @@ repos: - id: ruff-format - repo: https://github.com/PyCQA/doc8 - rev: 'v1.1.1' + rev: 'v1.1.2' hooks: - id: doc8 additional_dependencies: [tomli] diff --git a/CHANGELOG.md b/CHANGELOG.md index fefd3fa2f..53a86b8af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,97 @@ # Changelog +## [0.10.0](https://github.com/python-kasa/python-kasa/tree/0.10.0) (2025-01-26) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.9.1...0.10.0) + +**Release summary:** + +This release brings support for many new devices, including completely new device types: + +- Support for Tapo robot vacuums. Special thanks to @steveredden, @MAXIGAMESSUPPER, and veep60 for helping to get this implemented! +- Support for hub attached cameras and doorbells (H200) +- Improved support for hubs (including pairing & better chime controls) +- Support for many new camera and doorbell device models, including C220, C720, D100C, D130, and D230 + +Many thanks to testers and new contributors - @steveredden, @DawidPietrykowski, @Obbay2, @andrewome, @ryenitcher and @etmmvdp! + +**Breaking changes:** + +- `uses_http` is now a readonly property of device config. Consumers that relied on `uses_http` to be persisted with `DeviceConfig.to_dict()` will need to store the value separately. +- `is_color`, `is_dimmable`, `is_variable_color_temp`, `valid_temperate_range`, and `has_effects` attributes from the `Light` module are deprecated, consumers should use `has_feature("hsv")`, `has_feature("brightness")`, `has_feature("color_temp")`, `get_feature("color_temp").range`, and `Module.LightEffect in dev.modules` respectively. Calling the deprecated attributes will emit a `DeprecationWarning` and type checkers will fail them. +- `alarm_volume` on the `smart.Alarm` module is changed from `str` to `int` + +**Breaking changes:** + +- Make uses\_http a readonly property of device config [\#1449](https://github.com/python-kasa/python-kasa/pull/1449) (@sdb9696) +- Allow passing alarm parameter overrides [\#1340](https://github.com/python-kasa/python-kasa/pull/1340) (@rytilahti) +- Deprecate legacy light module is\_capability checks [\#1297](https://github.com/python-kasa/python-kasa/pull/1297) (@sdb9696) + +**Implemented enhancements:** + +- Expose more battery sensors for D230 [\#1451](https://github.com/python-kasa/python-kasa/issues/1451) +- dumping HTTP POST Body for Tapo Vacuum \(RV30 Plus\) [\#937](https://github.com/python-kasa/python-kasa/issues/937) +- Add common alarm interface [\#1479](https://github.com/python-kasa/python-kasa/pull/1479) (@sdb9696) +- Add common childsetup interface [\#1470](https://github.com/python-kasa/python-kasa/pull/1470) (@sdb9696) +- Add childsetup module to smartcam hubs [\#1469](https://github.com/python-kasa/python-kasa/pull/1469) (@sdb9696) +- Add smartcam pet detection toggle module [\#1465](https://github.com/python-kasa/python-kasa/pull/1465) (@DawidPietrykowski) +- Only log one warning per unknown clean error code and status [\#1462](https://github.com/python-kasa/python-kasa/pull/1462) (@rytilahti) +- Add childlock module for vacuums [\#1461](https://github.com/python-kasa/python-kasa/pull/1461) (@rytilahti) +- Add ultra mode \(fanspeed = 5\) for vacuums [\#1459](https://github.com/python-kasa/python-kasa/pull/1459) (@rytilahti) +- Add setting to change carpet clean mode [\#1458](https://github.com/python-kasa/python-kasa/pull/1458) (@rytilahti) +- Add setting to change clean count [\#1457](https://github.com/python-kasa/python-kasa/pull/1457) (@rytilahti) +- Add mop module [\#1456](https://github.com/python-kasa/python-kasa/pull/1456) (@rytilahti) +- Enable dynamic hub child creation and deletion on update [\#1454](https://github.com/python-kasa/python-kasa/pull/1454) (@sdb9696) +- Expose current cleaning information [\#1453](https://github.com/python-kasa/python-kasa/pull/1453) (@rytilahti) +- Add battery module to smartcam devices [\#1452](https://github.com/python-kasa/python-kasa/pull/1452) (@sdb9696) +- Allow update of camera modules after setting values [\#1450](https://github.com/python-kasa/python-kasa/pull/1450) (@sdb9696) +- Update hub children on first update and delay subsequent updates [\#1438](https://github.com/python-kasa/python-kasa/pull/1438) (@sdb9696) +- Add support for doorbells and chimes [\#1435](https://github.com/python-kasa/python-kasa/pull/1435) (@steveredden) +- Implement vacuum dustbin module \(dust\_bucket\) [\#1423](https://github.com/python-kasa/python-kasa/pull/1423) (@rytilahti) +- Allow https for klaptransport [\#1415](https://github.com/python-kasa/python-kasa/pull/1415) (@rytilahti) +- Add smartcam child device support for smartcam hubs [\#1413](https://github.com/python-kasa/python-kasa/pull/1413) (@sdb9696) +- Add powerprotection module [\#1337](https://github.com/python-kasa/python-kasa/pull/1337) (@rytilahti) +- Add vacuum speaker controls [\#1332](https://github.com/python-kasa/python-kasa/pull/1332) (@rytilahti) +- Add consumables module for vacuums [\#1327](https://github.com/python-kasa/python-kasa/pull/1327) (@rytilahti) +- Add ADC Value to PIR Enabled Switches [\#1263](https://github.com/python-kasa/python-kasa/pull/1263) (@ryenitcher) +- Add support for cleaning records [\#945](https://github.com/python-kasa/python-kasa/pull/945) (@rytilahti) +- Initial support for vacuums \(clean module\) [\#944](https://github.com/python-kasa/python-kasa/pull/944) (@rytilahti) +- Add support for pairing devices with hubs [\#859](https://github.com/python-kasa/python-kasa/pull/859) (@rytilahti) + +**Fixed bugs:** + +- TP-Link HS300 Wi-Fi Power-Strip - "Parent On/Off" not functioning. [\#637](https://github.com/python-kasa/python-kasa/issues/637) +- Convert carpet\_clean\_mode to carpet\_boost switch [\#1486](https://github.com/python-kasa/python-kasa/pull/1486) (@rytilahti) +- Change category for empty dustbin feature from Primary to Config [\#1485](https://github.com/python-kasa/python-kasa/pull/1485) (@rytilahti) +- Report 0 for instead of None for zero current and voltage [\#1483](https://github.com/python-kasa/python-kasa/pull/1483) (@ryenitcher) +- Disable iot camera creation until more complete [\#1480](https://github.com/python-kasa/python-kasa/pull/1480) (@sdb9696) +- ssltransport: use debug logger for sending requests [\#1443](https://github.com/python-kasa/python-kasa/pull/1443) (@rytilahti) +- Fix discover cli command with host [\#1437](https://github.com/python-kasa/python-kasa/pull/1437) (@sdb9696) +- Fallback to is\_low for batterysensor's battery\_low [\#1420](https://github.com/python-kasa/python-kasa/pull/1420) (@rytilahti) +- Fix iot strip turn on and off from parent [\#639](https://github.com/python-kasa/python-kasa/pull/639) (@Obbay2) + +**Added support for devices:** + +- Add D130\(US\) 1.0 1.1.9 fixture [\#1476](https://github.com/python-kasa/python-kasa/pull/1476) (@sdb9696) +- Add D100C\(US\) 1.0 1.1.3 fixture [\#1475](https://github.com/python-kasa/python-kasa/pull/1475) (@sdb9696) +- Add C220\(EU\) 1.0 1.2.2 camera fixture [\#1466](https://github.com/python-kasa/python-kasa/pull/1466) (@DawidPietrykowski) +- Add D230\(EU\) 1.20 1.1.19 fixture [\#1448](https://github.com/python-kasa/python-kasa/pull/1448) (@sdb9696) +- Add fixture for C720 camera [\#1433](https://github.com/python-kasa/python-kasa/pull/1433) (@steveredden) + +**Project maintenance:** + +- Update ruff to 0.9 [\#1482](https://github.com/python-kasa/python-kasa/pull/1482) (@sdb9696) +- Cancel in progress CI workflows after new pushes [\#1481](https://github.com/python-kasa/python-kasa/pull/1481) (@sdb9696) +- Update test framework to support smartcam device discovery. [\#1477](https://github.com/python-kasa/python-kasa/pull/1477) (@sdb9696) +- Add error code 7 for clean module [\#1474](https://github.com/python-kasa/python-kasa/pull/1474) (@rytilahti) +- Enable CI workflow on PRs to feat/ fix/ and janitor/ [\#1471](https://github.com/python-kasa/python-kasa/pull/1471) (@sdb9696) +- Add commit-hook to prettify JSON files [\#1455](https://github.com/python-kasa/python-kasa/pull/1455) (@rytilahti) +- Add required sphinx.configuration [\#1446](https://github.com/python-kasa/python-kasa/pull/1446) (@rytilahti) +- Add more redactors for smartcams [\#1439](https://github.com/python-kasa/python-kasa/pull/1439) (@sdb9696) +- Add KS230\(US\) 2.0 1.0.11 IOT Fixture [\#1430](https://github.com/python-kasa/python-kasa/pull/1430) (@ZeliardM) +- Add tests for dump\_devinfo parent/child smartcam fixture generation [\#1428](https://github.com/python-kasa/python-kasa/pull/1428) (@sdb9696) +- Raise errors on single smartcam child requests [\#1427](https://github.com/python-kasa/python-kasa/pull/1427) (@sdb9696) + ## [0.9.1](https://github.com/python-kasa/python-kasa/tree/0.9.1) (2025-01-06) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.9.0...0.9.1) @@ -19,8 +111,8 @@ **Fixed bugs:** - T310 not detected with H200 Hub [\#1409](https://github.com/python-kasa/python-kasa/issues/1409) -- Backoff after xor timeout and improve error reporting [\#1424](https://github.com/python-kasa/python-kasa/pull/1424) (@bdraco) - Fix incorrect obd src echo [\#1412](https://github.com/python-kasa/python-kasa/pull/1412) (@rytilahti) +- Backoff after xor timeout and improve error reporting [\#1424](https://github.com/python-kasa/python-kasa/pull/1424) (@bdraco) - Handle smartcam partial list responses [\#1411](https://github.com/python-kasa/python-kasa/pull/1411) (@sdb9696) **Added support for devices:** @@ -34,8 +126,8 @@ **Project maintenance:** -- Add C210 2.0 1.3.11 fixture [\#1406](https://github.com/python-kasa/python-kasa/pull/1406) (@sdb9696) - Add HS210\(US\) 3.0 1.0.10 IOT Fixture [\#1405](https://github.com/python-kasa/python-kasa/pull/1405) (@ZeliardM) +- Add C210 2.0 1.3.11 fixture [\#1406](https://github.com/python-kasa/python-kasa/pull/1406) (@sdb9696) - Change smartcam detection features to category config [\#1402](https://github.com/python-kasa/python-kasa/pull/1402) (@sdb9696) ## [0.9.0](https://github.com/python-kasa/python-kasa/tree/0.9.0) (2024-12-21) diff --git a/RELEASING.md b/RELEASING.md index e3527ceaf..b5587d601 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -44,9 +44,10 @@ uv lock --upgrade uv sync --all-extras ``` -### Run pre-commit and tests +### Update and run pre-commit and tests ```bash +pre-commit autoupdate uv run pre-commit run --all-files uv run pytest -n auto ``` @@ -124,6 +125,12 @@ git push upstream release/$NEW_RELEASE -u gh pr create --title "Prepare $NEW_RELEASE" --body "$RELEASE_NOTES" --label release-prep --base master ``` +To update the PR after refreshing the changelog: + +``` +gh pr edit --body "$RELEASE_NOTES" +``` + #### Merge the PR once the CI passes Create a squash commit and add the markdown from the PR description to the commit description. diff --git a/pyproject.toml b/pyproject.toml index 7f6021c88..cf8fabf7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-kasa" -version = "0.9.1" +version = "0.10.0" description = "Python API for TP-Link Kasa and Tapo devices" license = {text = "GPL-3.0-or-later"} authors = [ { name = "python-kasa developers" }] diff --git a/uv.lock b/uv.lock index 26e49f931..1c57719e4 100644 --- a/uv.lock +++ b/uv.lock @@ -394,11 +394,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.16.1" +version = "3.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 } +sdist = { url = "https://files.pythonhosted.org/packages/dc/9c/0b15fb47b464e1b663b1acd1253a062aa5feecb07d4e597daea542ebd2b5/filelock-3.17.0.tar.gz", hash = "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e", size = 18027 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, + { url = "https://files.pythonhosted.org/packages/89/ec/00d68c4ddfedfe64159999e5f8a98fb8442729a63e2077eb9dcd89623d27/filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338", size = 16164 }, ] [[package]] @@ -469,11 +469,11 @@ wheels = [ [[package]] name = "identify" -version = "2.6.5" +version = "2.6.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/92/69934b9ef3c31ca2470980423fda3d00f0460ddefdf30a67adf7f17e2e00/identify-2.6.5.tar.gz", hash = "sha256:c10b33f250e5bba374fae86fb57f3adcebf1161bce7cdf92031915fd480c13bc", size = 99213 } +sdist = { url = "https://files.pythonhosted.org/packages/82/bf/c68c46601bacd4c6fb4dd751a42b6e7087240eaabc6487f2ef7a48e0e8fc/identify-2.6.6.tar.gz", hash = "sha256:7bec12768ed44ea4761efb47806f0a41f86e7c0a5fdf5950d4648c90eca7e251", size = 99217 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/fa/dce098f4cdf7621aa8f7b4f919ce545891f489482f0bfa5102f3eca8608b/identify-2.6.5-py2.py3-none-any.whl", hash = "sha256:14181a47091eb75b337af4c23078c9d09225cd4c48929f521f3bf16b09d02566", size = 99078 }, + { url = "https://files.pythonhosted.org/packages/74/a1/68a395c17eeefb04917034bd0a1bfa765e7654fa150cca473d669aa3afb5/identify-2.6.6-py2.py3-none-any.whl", hash = "sha256:cbd1810bce79f8b671ecb20f53ee0ae8e86ae84b557de31d89709dc2a48ba881", size = 99083 }, ] [[package]] @@ -529,24 +529,37 @@ wheels = [ [[package]] name = "kasa-crypt" -version = "0.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ba/f78a63c5b55dc18b39099a1a1bf6569c14ccca47dd342cc4f4d774ec5719/kasa_crypt-0.4.4.tar.gz", hash = "sha256:cc31749e44a309459a71802ae8471a9d5ad6a7656938a44af64b93a8c3873ccd", size = 9306 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/43/d9e9b54aad36d8aae9f59adc8ddb27bf7a06f505deffe98f28bc865ba494/kasa_crypt-0.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:04fad5f981e734ab1b269922a1175bc506d5498681778b3d61561422619d6e6d", size = 69934 }, - { url = "https://files.pythonhosted.org/packages/15/79/5e94eb76f2935f92de9602b04d0c244653540128eba2be71e6284f9c9997/kasa_crypt-0.4.4-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:a54040539fe8293a7dd20fcf5e613ba4bdcafe15a8d9eeff1cc2805500a0c2d9", size = 133178 }, - { url = "https://files.pythonhosted.org/packages/7a/1e/3836b1e69da964e3c8dbf057d82f8f13d277fe9baa6c327400ea5ebc37e1/kasa_crypt-0.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a0a0981255225fd5671ffed85f2bfc68b0ac8525b5d424a703aaa1d0f8f4cc2", size = 136881 }, - { url = "https://files.pythonhosted.org/packages/aa/24/eeafbbdc5a914abdd8911108eab7fe3ddf5bfdd1e14d3d43f5874a936863/kasa_crypt-0.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fa2bcbf7c4bb2af4a86c553fb8df47466c06f5060d5c21253a4ecd9ee2237ef4", size = 136189 }, - { url = "https://files.pythonhosted.org/packages/69/23/6c0604c093f69f80d00b8953ec7ac0cfc4db2504db7cddf7be26f6ed582d/kasa_crypt-0.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:99518489cb93d93c6c2e5ac4e30ad6838bb64c8365e8c3a37204e7f4228805ca", size = 139644 }, - { url = "https://files.pythonhosted.org/packages/c4/54/13e48c5b280600c966cba23b1940d38ec2847db909f060224c902af33c5c/kasa_crypt-0.4.4-cp311-cp311-win32.whl", hash = "sha256:431223a614f868a253786da7b137a8597c8ce83ed71a8bc10ffe9e56f7a8ba4d", size = 68754 }, - { url = "https://files.pythonhosted.org/packages/02/eb/aa085ddebda8c1d2912e5c6196f3c9106595c6dae2098bcb5df602db978f/kasa_crypt-0.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:c3d60a642985c3c7c9b598e19da537566803d2f78a42d0be5a7231d717239f11", size = 70959 }, - { url = "https://files.pythonhosted.org/packages/aa/f6/de1ecffa3b69200a9ebeb423f8bdb3a46987508865c906c50c09f18e311f/kasa_crypt-0.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:038a16270b15d9a9845ad4ba66f76cbf05109855e40afb6a62d7b99e73ba55a3", size = 70165 }, - { url = "https://files.pythonhosted.org/packages/8a/9a/a43be44b356bb97f7a6213c7a87863c4f7f85c9137e75fb95d66e3f04d9b/kasa_crypt-0.4.4-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:5cc150ef1bd2a330903557f806e7b671fe59f15fd37337f69ea0d7872cbffdde", size = 139126 }, - { url = "https://files.pythonhosted.org/packages/0a/52/b6e8ee4bb8aea9735da157918342baa98bf3cc8e725d74315cd33a62374a/kasa_crypt-0.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c45838d4b361f76615be72ee9b238681c47330f09cc3b0eb830095b063a262c2", size = 143953 }, - { url = "https://files.pythonhosted.org/packages/b0/cb/2c10cb2534a1237c46f4e9d764e74f5f8e3eb84862fa656629e8f1b3ebb9/kasa_crypt-0.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:138479985246ebc6be5d9bb896e48860d72a280e068d798af93acd2a210031c1", size = 141496 }, - { url = "https://files.pythonhosted.org/packages/38/62/9bcf83c27ddfaa50353deb4c9793873356d7c4b99c3b073a1c623eda883c/kasa_crypt-0.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:806dd2f7a8c6d2242513a78c144a63664817b3f0b6e149166b87db9a6017d742", size = 146398 }, - { url = "https://files.pythonhosted.org/packages/d5/63/ad0de4d97f9ec2e290a9ed37756c70ad5c99403f62399a4f9fafeb3d8c81/kasa_crypt-0.4.4-cp312-cp312-win32.whl", hash = "sha256:791900085be025dbf7052f1e44c176e957556b1d04b6da4a602fc4ddc23f87b0", size = 68951 }, - { url = "https://files.pythonhosted.org/packages/44/ce/a843f0a2c3328d792a41ca6261c1564af188a4f15b1af34f83ec8c68c686/kasa_crypt-0.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:9c7d136bfcd74ac30ed5c10cb96c46a4e2eb90bd52974a0dbbc9c6d3e90d7699", size = 71352 }, +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/ab/64fe21b3fa73c31f936468f010c77077c5a3f14e8eae1ff09ccee0d2ed24/kasa_crypt-0.5.0.tar.gz", hash = "sha256:0617e2cbe77d14283769a2290c580cac722ffffa3f8a2fe013492a066810a983", size = 9044 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/e1/ff9231de11fe66bafa8ed4e8fc16d00f8fc95aa1d8d4098bf9b2b4579e6e/kasa_crypt-0.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19ebd2416b50ac8738dab7c2996c21e03685d5a95de4d03230eb9f17f5b6321e", size = 70144 }, + { url = "https://files.pythonhosted.org/packages/08/68/5da1c2b7aa5c7069a1534634c7196083d003e56c9dc9bd20c61c5ed6071b/kasa_crypt-0.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77820e50f04230b25500d5760385bf71e5192f6c142ee28ebdfb5c8ae194aecd", size = 137598 }, + { url = "https://files.pythonhosted.org/packages/a1/c5/99c3d32f614a8d2179f66effe40d5f3ced88346dc556150716786ee0f686/kasa_crypt-0.5.0-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:23b934578408e6fe7a21c86eba6f9210b46763b9e8f9c5cbbd125e35d9ced746", size = 133041 }, + { url = "https://files.pythonhosted.org/packages/b9/77/68cdc119269ccd594abf322ddb079d048d1da498e3a973582178ff2d18cd/kasa_crypt-0.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4bb5aa54080b3dd8ad0b8d0835a291f8997875440a76f202979503d7629220e", size = 136752 }, + { url = "https://files.pythonhosted.org/packages/48/82/fc61569666ba1000cc0e8a91fd05a70d92b75d000668bdec87901e775dab/kasa_crypt-0.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f78185cb15992d90abdcef45b87823398b8f37293677a5ae3cac6b68f1c55c93", size = 135209 }, + { url = "https://files.pythonhosted.org/packages/f7/37/d7240f200cb4974afdb8aca6cbaf0e0bec05e9b6b76b0d3e21d355ac4fda/kasa_crypt-0.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2214d8e9807c63ce3b1a505e7169326301b35db6b583a726b0c99c9a3547ee87", size = 133486 }, + { url = "https://files.pythonhosted.org/packages/ec/1a/ef9ad625f237b5deaa5c38053b78a240f6fa45372616306ef174943b8faa/kasa_crypt-0.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4875b09d834ed2ea1bf87bfe9bb18b84f3d5373204df210d12eb9476625ed8a4", size = 135660 }, + { url = "https://files.pythonhosted.org/packages/0d/2a/02b34ff817dc91c09e7f05991f574411f67ca70a1e318cffd9e6f17a5cfe/kasa_crypt-0.5.0-cp311-cp311-win32.whl", hash = "sha256:45a04d4fa16a4ab92978e451a306e9112036cea81f8a42a0090be9c1a6aa77e6", size = 68686 }, + { url = "https://files.pythonhosted.org/packages/08/f1/889620c2dbe417e29e43d4709e408173f3627ce85252ba998602be0f1201/kasa_crypt-0.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:018baf4c123a889a9dfe181354f6a3ce53cf2341d986bb63104a4b91e871a0b6", size = 71022 }, + { url = "https://files.pythonhosted.org/packages/b1/0d/b9f4b21ae5d3c483195675b5be8d859ec0bfa975d794138f294e6bce337a/kasa_crypt-0.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:56a98a13a8e1d5bb73cd21e71f83d02930fd20507da2fa8062e15342116120ad", size = 70374 }, + { url = "https://files.pythonhosted.org/packages/49/de/6143ab15ef50a4b5cdfbad1e2c6b7b89ccd82b55ad119cc5f3b04a591d41/kasa_crypt-0.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:757e273c76483b936382b2d60cf5b8bc75d47b37fe463907be8cf2483a8c68d0", size = 143469 }, + { url = "https://files.pythonhosted.org/packages/82/e7/203f752a33dc4518121e08adc87e5c363b103e4ed3c6f6fd0fa7e8f92271/kasa_crypt-0.5.0-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:aa3e482244b107e6eabbd0c8a0ddbc36d5f07648b2075204172cc5a9f7823bea", size = 138802 }, + { url = "https://files.pythonhosted.org/packages/38/d3/e6f10bec474a889138deff95471e7da8d03a78121bb76bf95fee77585435/kasa_crypt-0.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d29acf928ad85f3e3ff0b758d848719cc62f39c92d9da7ddc91a2cb25e70fa", size = 143670 }, + { url = "https://files.pythonhosted.org/packages/20/70/e3bdb987fbb44887465b2d21a3c3691b6b03674ce1d9bf5d08daa6cf2883/kasa_crypt-0.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a58a04b39292f96b69107ed1aeb21b3259493dc1d799d717ee503e24e290cbc0", size = 140185 }, + { url = "https://files.pythonhosted.org/packages/34/4b/c5841eceb5f35a2c2e72fadae17357ee235b24717a24f4eb98bf1b6d675e/kasa_crypt-0.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b6ede15c4db1c54854afdd565d84d7d48dba90c181abf5ec235ee05e4f42659e", size = 138956 }, + { url = "https://files.pythonhosted.org/packages/88/3f/ac8cb266e8790df5a55d15f89d6d9ee1d3de92b6795a53b758660a8b798a/kasa_crypt-0.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:10c4cde5554ea0ced9b01949ce3c05dde98b73d18bb24c8dc780db607a749cbb", size = 141592 }, + { url = "https://files.pythonhosted.org/packages/b5/75/c70182cb1b14ee43fe38e2ba97bba381dae212d3c3520c16dc6db51572a8/kasa_crypt-0.5.0-cp312-cp312-win32.whl", hash = "sha256:a7bea471d8e08e3f618b99c3721a9dcf492038a3261755275bd67e91ec736ab7", size = 68930 }, + { url = "https://files.pythonhosted.org/packages/af/6b/5bf37d3320d462b57ce7c1e2ac381265067a16ecb4ce5840b29868efad00/kasa_crypt-0.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:36c4cdafe0d73c636ff3beb9f9850a14989800b6e927157bbc34e6f20d39c6a7", size = 71335 }, + { url = "https://files.pythonhosted.org/packages/7a/78/f865240de111154666e9c10785b06c235c0e19c237449e65ae73bab68320/kasa_crypt-0.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23070ff05e127e2a53820e08c28accd171e8189fe93ef3d61d3f553ed3756334", size = 69653 }, + { url = "https://files.pythonhosted.org/packages/ae/6e/fb3fcb634d483748042712529fb2a464a21b5d87efb62fe4f0b43c1dea60/kasa_crypt-0.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e02da1f89d4e85371532a38ba533f910be7423a3d60fe0556c1ce67e71d64115", size = 138348 }, + { url = "https://files.pythonhosted.org/packages/38/da/50f026c21a90b545ef7e0044c45f615c10cb7e819f0d4581659889f6759d/kasa_crypt-0.5.0-cp313-cp313-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:837f9087dbc86b6417965e1cbe2df173a2a4c31fd8c93af8ccf73bd74bc4434e", size = 133713 }, + { url = "https://files.pythonhosted.org/packages/63/43/24500819c29d2129d2699adbdd99e59147339ae66a7a26863a87b71bdf47/kasa_crypt-0.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36ebb8a724c2a1b98688c5d35c20d4236fb7b027948aa46d2991539fddfd884d", size = 138460 }, + { url = "https://files.pythonhosted.org/packages/82/3a/c1a20c2d9ba9ca148477aa71e634bd34545ed81bd5feddbc88201454372d/kasa_crypt-0.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:28f2f36a2c279af1cbf2ee261570ce7fca651cce72bb5954200b1be53ae8ef84", size = 135412 }, + { url = "https://files.pythonhosted.org/packages/02/e4/fb439c4862e258272b813e42fe292cea5c7b6a98ea20bf5bfb45b857d021/kasa_crypt-0.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6a0183ac7128fffe5600a161ef63ab86adc51efc587765c2b48f3f50ec7467ac", size = 133794 }, + { url = "https://files.pythonhosted.org/packages/b1/e1/7f990f6f6e2fd53f48fa3739a11d8a5435f4d6847000febac2b9dc746cf8/kasa_crypt-0.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51ed2bf8575f051dc7e9d2e7e126ce57468df0d6d410dfa227157802e5094dbe", size = 136888 }, + { url = "https://files.pythonhosted.org/packages/1e/a5/7b8c52532d54bc93bcb212fae284d810b0483b46401d8d70c69d0f9584a6/kasa_crypt-0.5.0-cp313-cp313-win32.whl", hash = "sha256:6bdf19dedee9454b3c4ef3874399e99bcdc908c047dfbb01165842eca5773512", size = 68283 }, + { url = "https://files.pythonhosted.org/packages/9b/48/399d7c1933c51c821a8d51837b335720d1d6d4e35bd24f74ced69c3ab937/kasa_crypt-0.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:8909208e4c038518b33f7a9e757accd6793cc5f0490370aeef0a3d9e1705f5c4", size = 70493 }, ] [[package]] @@ -764,45 +777,49 @@ wheels = [ [[package]] name = "orjson" -version = "3.10.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/0b/8c7eaf1e2152f1e0fb28ae7b22e2b35a6b1992953a1ebe0371ba4d41d3ad/orjson-3.10.13.tar.gz", hash = "sha256:eb9bfb14ab8f68d9d9492d4817ae497788a15fd7da72e14dfabc289c3bb088ec", size = 5438389 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/44/7a047e47779953e3f657a612ad36f71a0bca02cf57ff490c427e22b01833/orjson-3.10.13-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a36c0d48d2f084c800763473020a12976996f1109e2fcb66cfea442fdf88047f", size = 248732 }, - { url = "https://files.pythonhosted.org/packages/d6/e9/54976977aaacc5030fdd8012479638bb8d4e2a16519b516ac2bd03a48eab/orjson-3.10.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0065896f85d9497990731dfd4a9991a45b0a524baec42ef0a63c34630ee26fd6", size = 136954 }, - { url = "https://files.pythonhosted.org/packages/7f/a7/663fb04e031d5c80a348aeb7271c6042d13f80393c4951b8801a703b89c0/orjson-3.10.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92b4ec30d6025a9dcdfe0df77063cbce238c08d0404471ed7a79f309364a3d19", size = 149101 }, - { url = "https://files.pythonhosted.org/packages/f9/f1/5f2a4bf7525ef4acf48902d2df2bcc1c5aa38f6cc17ee0729a1d3e110ddb/orjson-3.10.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a94542d12271c30044dadad1125ee060e7a2048b6c7034e432e116077e1d13d2", size = 140445 }, - { url = "https://files.pythonhosted.org/packages/12/d3/e68afa1db9860880e59260348b54c0518d8dfe2297e932f8e333ace878fa/orjson-3.10.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3723e137772639af8adb68230f2aa4bcb27c48b3335b1b1e2d49328fed5e244c", size = 156530 }, - { url = "https://files.pythonhosted.org/packages/77/ee/492b198c77b9985ae28e0c6b8092c2994cd18d6be40dc7cb7f9a385b7096/orjson-3.10.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f00c7fb18843bad2ac42dc1ce6dd214a083c53f1e324a0fd1c8137c6436269b", size = 131260 }, - { url = "https://files.pythonhosted.org/packages/57/d2/5167cc1ccbe56bacdd9fc79e6a3276cba6aa90057305e8485db58b8250c4/orjson-3.10.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0e2759d3172300b2f892dee85500b22fca5ac49e0c42cfff101aaf9c12ac9617", size = 139821 }, - { url = "https://files.pythonhosted.org/packages/74/f0/c1cf568e0f90d812e00c77da2db04a13e94afe639665b9a09c271456dc41/orjson-3.10.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee948c6c01f6b337589c88f8e0bb11e78d32a15848b8b53d3f3b6fea48842c12", size = 131904 }, - { url = "https://files.pythonhosted.org/packages/55/7d/a611542afbbacca4693a2319744944134df62957a1f206303d5b3160e349/orjson-3.10.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa6fe68f0981fba0d4bf9cdc666d297a7cdba0f1b380dcd075a9a3dd5649a69e", size = 415733 }, - { url = "https://files.pythonhosted.org/packages/64/3f/e8182716695cd8d5ebec49d283645b8c7b1de7ed1c27db2891b6957e71f6/orjson-3.10.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbcd7aad6bcff258f6896abfbc177d54d9b18149c4c561114f47ebfe74ae6bfd", size = 142456 }, - { url = "https://files.pythonhosted.org/packages/dc/10/e4b40f15be7e4e991737d77062399c7f67da9b7e3bc28bbcb25de1717df3/orjson-3.10.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2149e2fcd084c3fd584881c7f9d7f9e5ad1e2e006609d8b80649655e0d52cd02", size = 130676 }, - { url = "https://files.pythonhosted.org/packages/ad/b1/8b9fb36d470fe8ff99727972c77846673ebc962cb09a5af578804f9f2408/orjson-3.10.13-cp311-cp311-win32.whl", hash = "sha256:89367767ed27b33c25c026696507c76e3d01958406f51d3a2239fe9e91959df2", size = 143672 }, - { url = "https://files.pythonhosted.org/packages/b5/15/90b3711f40d27aff80dd42c1eec2f0ed704a1fa47eef7120350e2797892d/orjson-3.10.13-cp311-cp311-win_amd64.whl", hash = "sha256:dca1d20f1af0daff511f6e26a27354a424f0b5cf00e04280279316df0f604a6f", size = 135082 }, - { url = "https://files.pythonhosted.org/packages/35/84/adf8842cf36904e6200acff76156862d48d39705054c1e7c5fa98fe14417/orjson-3.10.13-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a3614b00621c77f3f6487792238f9ed1dd8a42f2ec0e6540ee34c2d4e6db813a", size = 248778 }, - { url = "https://files.pythonhosted.org/packages/69/2f/22ac0c5f46748e9810287a5abaeabdd67f1120a74140db7d529582c92342/orjson-3.10.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c976bad3996aa027cd3aef78aa57873f3c959b6c38719de9724b71bdc7bd14b", size = 136759 }, - { url = "https://files.pythonhosted.org/packages/39/67/6f05de77dd383cb623e2807bceae13f136e9f179cd32633b7a27454e953f/orjson-3.10.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f74d878d1efb97a930b8a9f9898890067707d683eb5c7e20730030ecb3fb930", size = 149123 }, - { url = "https://files.pythonhosted.org/packages/f8/5c/b5e144e9adbb1dc7d1fdf54af9510756d09b65081806f905d300a926a755/orjson-3.10.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:33ef84f7e9513fb13b3999c2a64b9ca9c8143f3da9722fbf9c9ce51ce0d8076e", size = 140557 }, - { url = "https://files.pythonhosted.org/packages/91/fd/7bdbc0aa374d49cdb917ee51c80851c99889494be81d5e7ec9f5f9cbe149/orjson-3.10.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd2bcde107221bb9c2fa0c4aaba735a537225104173d7e19cf73f70b3126c993", size = 156626 }, - { url = "https://files.pythonhosted.org/packages/48/90/e583d6e29937ec30a164f1d86a0439c1a2477b5aae9f55d94b37a4f5b5f0/orjson-3.10.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:064b9dbb0217fd64a8d016a8929f2fae6f3312d55ab3036b00b1d17399ab2f3e", size = 131551 }, - { url = "https://files.pythonhosted.org/packages/47/0b/838c00ec7f048527aa0382299cd178bbe07c2cb1024b3111883e85d56d1f/orjson-3.10.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0044b0b8c85a565e7c3ce0a72acc5d35cda60793edf871ed94711e712cb637d", size = 139790 }, - { url = "https://files.pythonhosted.org/packages/ac/90/df06ac390f319a61d55a7a4efacb5d7082859f6ea33f0fdd5181ad0dde0c/orjson-3.10.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7184f608ad563032e398f311910bc536e62b9fbdca2041be889afcbc39500de8", size = 131717 }, - { url = "https://files.pythonhosted.org/packages/ea/68/eafb5e2fc84aafccfbd0e9e0552ff297ef5f9b23c7f2600cc374095a50de/orjson-3.10.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d36f689e7e1b9b6fb39dbdebc16a6f07cbe994d3644fb1c22953020fc575935f", size = 415690 }, - { url = "https://files.pythonhosted.org/packages/b8/cf/aa93b48801b2e42da223ef5a99b3e4970b02e7abea8509dd2a6a083e27fa/orjson-3.10.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:54433e421618cd5873e51c0e9d0b9fb35f7bf76eb31c8eab20b3595bb713cd3d", size = 142396 }, - { url = "https://files.pythonhosted.org/packages/8b/50/fb1a7060b79231c60a688037c2c8e9fe289b5a4378ec1f32cf8d33d9adf8/orjson-3.10.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e1ba0c5857dd743438acecc1cd0e1adf83f0a81fee558e32b2b36f89e40cee8b", size = 130842 }, - { url = "https://files.pythonhosted.org/packages/94/e6/44067052e28a13176da874ca53419b43cf0f6f01f4bf0539f2f70d8eacf6/orjson-3.10.13-cp312-cp312-win32.whl", hash = "sha256:a42b9fe4b0114b51eb5cdf9887d8c94447bc59df6dbb9c5884434eab947888d8", size = 143773 }, - { url = "https://files.pythonhosted.org/packages/f2/7d/510939d1b7f8ba387849e83666e898f214f38baa46c5efde94561453974d/orjson-3.10.13-cp312-cp312-win_amd64.whl", hash = "sha256:3a7df63076435f39ec024bdfeb4c9767ebe7b49abc4949068d61cf4857fa6d6c", size = 135234 }, - { url = "https://files.pythonhosted.org/packages/ef/42/482fced9a135c798f31e1088f608fa16735fdc484eb8ffdd29aa32d4e842/orjson-3.10.13-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2cdaf8b028a976ebab837a2c27b82810f7fc76ed9fb243755ba650cc83d07730", size = 248726 }, - { url = "https://files.pythonhosted.org/packages/00/e7/6345653906ee6d2d6eabb767cdc4482c7809572dbda59224f40e48931efa/orjson-3.10.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48a946796e390cbb803e069472de37f192b7a80f4ac82e16d6eb9909d9e39d56", size = 126032 }, - { url = "https://files.pythonhosted.org/packages/ad/b8/0d2a2c739458ff7f9917a132225365d72d18f4b65c50cb8ebb5afb6fe184/orjson-3.10.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7d64f1db5ecbc21eb83097e5236d6ab7e86092c1cd4c216c02533332951afc", size = 131547 }, - { url = "https://files.pythonhosted.org/packages/8d/ac/a1dc389cf364d576cf587a6f78dac6c905c5cac31b9dbd063bbb24335bf7/orjson-3.10.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:711878da48f89df194edd2ba603ad42e7afed74abcd2bac164685e7ec15f96de", size = 131682 }, - { url = "https://files.pythonhosted.org/packages/43/6c/debab76b830aba6449ec8a75ac77edebb0e7decff63eb3ecfb2cf6340a2e/orjson-3.10.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cf16f06cb77ce8baf844bc222dbcb03838f61d0abda2c3341400c2b7604e436e", size = 415621 }, - { url = "https://files.pythonhosted.org/packages/c2/32/106e605db5369a6717036065e2b41ac52bd0d2712962edb3e026a452dc07/orjson-3.10.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8257c3fb8dd7b0b446b5e87bf85a28e4071ac50f8c04b6ce2d38cb4abd7dff57", size = 142388 }, - { url = "https://files.pythonhosted.org/packages/a3/02/6b2103898d60c2565bf97abffdf3a4cf338920b9feb55eec1fd791ab10ee/orjson-3.10.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9c3a87abe6f849a4a7ac8a8a1dede6320a4303d5304006b90da7a3cd2b70d2c", size = 130825 }, - { url = "https://files.pythonhosted.org/packages/87/7c/db115e2380435da569732999d5c4c9b9868efe72e063493cb73c36bb649a/orjson-3.10.13-cp313-cp313-win32.whl", hash = "sha256:527afb6ddb0fa3fe02f5d9fba4920d9d95da58917826a9be93e0242da8abe94a", size = 143723 }, - { url = "https://files.pythonhosted.org/packages/cc/5e/c2b74a0b38ec561a322d8946663924556c1f967df2eefe1b9e0b98a33950/orjson-3.10.13-cp313-cp313-win_amd64.whl", hash = "sha256:b5f7c298d4b935b222f52d6c7f2ba5eafb59d690d9a3840b7b5c5cda97f6ec5c", size = 134968 }, +version = "3.10.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/5dea21763eeff8c1590076918a446ea3d6140743e0e36f58f369928ed0f4/orjson-3.10.15.tar.gz", hash = "sha256:05ca7fe452a2e9d8d9d706a2984c95b9c2ebc5db417ce0b7a49b91d50642a23e", size = 5282482 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/a2/21b25ce4a2c71dbb90948ee81bd7a42b4fbfc63162e57faf83157d5540ae/orjson-3.10.15-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c4cc83960ab79a4031f3119cc4b1a1c627a3dc09df125b27c4201dff2af7eaa6", size = 249533 }, + { url = "https://files.pythonhosted.org/packages/b2/85/2076fc12d8225698a51278009726750c9c65c846eda741e77e1761cfef33/orjson-3.10.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ddbeef2481d895ab8be5185f2432c334d6dec1f5d1933a9c83014d188e102cef", size = 125230 }, + { url = "https://files.pythonhosted.org/packages/06/df/a85a7955f11274191eccf559e8481b2be74a7c6d43075d0a9506aa80284d/orjson-3.10.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e590a0477b23ecd5b0ac865b1b907b01b3c5535f5e8a8f6ab0e503efb896334", size = 150148 }, + { url = "https://files.pythonhosted.org/packages/37/b3/94c55625a29b8767c0eed194cb000b3787e3c23b4cdd13be17bae6ccbb4b/orjson-3.10.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6be38bd103d2fd9bdfa31c2720b23b5d47c6796bcb1d1b598e3924441b4298d", size = 139749 }, + { url = "https://files.pythonhosted.org/packages/53/ba/c608b1e719971e8ddac2379f290404c2e914cf8e976369bae3cad88768b1/orjson-3.10.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ff4f6edb1578960ed628a3b998fa54d78d9bb3e2eb2cfc5c2a09732431c678d0", size = 154558 }, + { url = "https://files.pythonhosted.org/packages/b2/c4/c1fb835bb23ad788a39aa9ebb8821d51b1c03588d9a9e4ca7de5b354fdd5/orjson-3.10.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0482b21d0462eddd67e7fce10b89e0b6ac56570424662b685a0d6fccf581e13", size = 130349 }, + { url = "https://files.pythonhosted.org/packages/78/14/bb2b48b26ab3c570b284eb2157d98c1ef331a8397f6c8bd983b270467f5c/orjson-3.10.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bb5cc3527036ae3d98b65e37b7986a918955f85332c1ee07f9d3f82f3a6899b5", size = 138513 }, + { url = "https://files.pythonhosted.org/packages/4a/97/d5b353a5fe532e92c46467aa37e637f81af8468aa894cd77d2ec8a12f99e/orjson-3.10.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d569c1c462912acdd119ccbf719cf7102ea2c67dd03b99edcb1a3048651ac96b", size = 130942 }, + { url = "https://files.pythonhosted.org/packages/b5/5d/a067bec55293cca48fea8b9928cfa84c623be0cce8141d47690e64a6ca12/orjson-3.10.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1e6d33efab6b71d67f22bf2962895d3dc6f82a6273a965fab762e64fa90dc399", size = 414717 }, + { url = "https://files.pythonhosted.org/packages/6f/9a/1485b8b05c6b4c4db172c438cf5db5dcfd10e72a9bc23c151a1137e763e0/orjson-3.10.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c33be3795e299f565681d69852ac8c1bc5c84863c0b0030b2b3468843be90388", size = 141033 }, + { url = "https://files.pythonhosted.org/packages/f8/d2/fc67523656e43a0c7eaeae9007c8b02e86076b15d591e9be11554d3d3138/orjson-3.10.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eea80037b9fae5339b214f59308ef0589fc06dc870578b7cce6d71eb2096764c", size = 129720 }, + { url = "https://files.pythonhosted.org/packages/79/42/f58c7bd4e5b54da2ce2ef0331a39ccbbaa7699b7f70206fbf06737c9ed7d/orjson-3.10.15-cp311-cp311-win32.whl", hash = "sha256:d5ac11b659fd798228a7adba3e37c010e0152b78b1982897020a8e019a94882e", size = 142473 }, + { url = "https://files.pythonhosted.org/packages/00/f8/bb60a4644287a544ec81df1699d5b965776bc9848d9029d9f9b3402ac8bb/orjson-3.10.15-cp311-cp311-win_amd64.whl", hash = "sha256:cf45e0214c593660339ef63e875f32ddd5aa3b4adc15e662cdb80dc49e194f8e", size = 133570 }, + { url = "https://files.pythonhosted.org/packages/66/85/22fe737188905a71afcc4bf7cc4c79cd7f5bbe9ed1fe0aac4ce4c33edc30/orjson-3.10.15-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9d11c0714fc85bfcf36ada1179400862da3288fc785c30e8297844c867d7505a", size = 249504 }, + { url = "https://files.pythonhosted.org/packages/48/b7/2622b29f3afebe938a0a9037e184660379797d5fd5234e5998345d7a5b43/orjson-3.10.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dba5a1e85d554e3897fa9fe6fbcff2ed32d55008973ec9a2b992bd9a65d2352d", size = 125080 }, + { url = "https://files.pythonhosted.org/packages/ce/8f/0b72a48f4403d0b88b2a41450c535b3e8989e8a2d7800659a967efc7c115/orjson-3.10.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7723ad949a0ea502df656948ddd8b392780a5beaa4c3b5f97e525191b102fff0", size = 150121 }, + { url = "https://files.pythonhosted.org/packages/06/ec/acb1a20cd49edb2000be5a0404cd43e3c8aad219f376ac8c60b870518c03/orjson-3.10.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6fd9bc64421e9fe9bd88039e7ce8e58d4fead67ca88e3a4014b143cec7684fd4", size = 139796 }, + { url = "https://files.pythonhosted.org/packages/33/e1/f7840a2ea852114b23a52a1c0b2bea0a1ea22236efbcdb876402d799c423/orjson-3.10.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dadba0e7b6594216c214ef7894c4bd5f08d7c0135f4dd0145600be4fbcc16767", size = 154636 }, + { url = "https://files.pythonhosted.org/packages/fa/da/31543337febd043b8fa80a3b67de627669b88c7b128d9ad4cc2ece005b7a/orjson-3.10.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48f59114fe318f33bbaee8ebeda696d8ccc94c9e90bc27dbe72153094e26f41", size = 130621 }, + { url = "https://files.pythonhosted.org/packages/ed/78/66115dc9afbc22496530d2139f2f4455698be444c7c2475cb48f657cefc9/orjson-3.10.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:035fb83585e0f15e076759b6fedaf0abb460d1765b6a36f48018a52858443514", size = 138516 }, + { url = "https://files.pythonhosted.org/packages/22/84/cd4f5fb5427ffcf823140957a47503076184cb1ce15bcc1165125c26c46c/orjson-3.10.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d13b7fe322d75bf84464b075eafd8e7dd9eae05649aa2a5354cfa32f43c59f17", size = 130762 }, + { url = "https://files.pythonhosted.org/packages/93/1f/67596b711ba9f56dd75d73b60089c5c92057f1130bb3a25a0f53fb9a583b/orjson-3.10.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7066b74f9f259849629e0d04db6609db4cf5b973248f455ba5d3bd58a4daaa5b", size = 414700 }, + { url = "https://files.pythonhosted.org/packages/7c/0c/6a3b3271b46443d90efb713c3e4fe83fa8cd71cda0d11a0f69a03f437c6e/orjson-3.10.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88dc3f65a026bd3175eb157fea994fca6ac7c4c8579fc5a86fc2114ad05705b7", size = 141077 }, + { url = "https://files.pythonhosted.org/packages/3b/9b/33c58e0bfc788995eccd0d525ecd6b84b40d7ed182dd0751cd4c1322ac62/orjson-3.10.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b342567e5465bd99faa559507fe45e33fc76b9fb868a63f1642c6bc0735ad02a", size = 129898 }, + { url = "https://files.pythonhosted.org/packages/01/c1/d577ecd2e9fa393366a1ea0a9267f6510d86e6c4bb1cdfb9877104cac44c/orjson-3.10.15-cp312-cp312-win32.whl", hash = "sha256:0a4f27ea5617828e6b58922fdbec67b0aa4bb844e2d363b9244c47fa2180e665", size = 142566 }, + { url = "https://files.pythonhosted.org/packages/ed/eb/a85317ee1732d1034b92d56f89f1de4d7bf7904f5c8fb9dcdd5b1c83917f/orjson-3.10.15-cp312-cp312-win_amd64.whl", hash = "sha256:ef5b87e7aa9545ddadd2309efe6824bd3dd64ac101c15dae0f2f597911d46eaa", size = 133732 }, + { url = "https://files.pythonhosted.org/packages/06/10/fe7d60b8da538e8d3d3721f08c1b7bff0491e8fa4dd3bf11a17e34f4730e/orjson-3.10.15-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bae0e6ec2b7ba6895198cd981b7cca95d1487d0147c8ed751e5632ad16f031a6", size = 249399 }, + { url = "https://files.pythonhosted.org/packages/6b/83/52c356fd3a61abd829ae7e4366a6fe8e8863c825a60d7ac5156067516edf/orjson-3.10.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f93ce145b2db1252dd86af37d4165b6faa83072b46e3995ecc95d4b2301b725a", size = 125044 }, + { url = "https://files.pythonhosted.org/packages/55/b2/d06d5901408e7ded1a74c7c20d70e3a127057a6d21355f50c90c0f337913/orjson-3.10.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c203f6f969210128af3acae0ef9ea6aab9782939f45f6fe02d05958fe761ef9", size = 150066 }, + { url = "https://files.pythonhosted.org/packages/75/8c/60c3106e08dc593a861755781c7c675a566445cc39558677d505878d879f/orjson-3.10.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8918719572d662e18b8af66aef699d8c21072e54b6c82a3f8f6404c1f5ccd5e0", size = 139737 }, + { url = "https://files.pythonhosted.org/packages/6a/8c/ae00d7d0ab8a4490b1efeb01ad4ab2f1982e69cc82490bf8093407718ff5/orjson-3.10.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f71eae9651465dff70aa80db92586ad5b92df46a9373ee55252109bb6b703307", size = 154804 }, + { url = "https://files.pythonhosted.org/packages/22/86/65dc69bd88b6dd254535310e97bc518aa50a39ef9c5a2a5d518e7a223710/orjson-3.10.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e117eb299a35f2634e25ed120c37c641398826c2f5a3d3cc39f5993b96171b9e", size = 130583 }, + { url = "https://files.pythonhosted.org/packages/bb/00/6fe01ededb05d52be42fabb13d93a36e51f1fd9be173bd95707d11a8a860/orjson-3.10.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:13242f12d295e83c2955756a574ddd6741c81e5b99f2bef8ed8d53e47a01e4b7", size = 138465 }, + { url = "https://files.pythonhosted.org/packages/db/2f/4cc151c4b471b0cdc8cb29d3eadbce5007eb0475d26fa26ed123dca93b33/orjson-3.10.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7946922ada8f3e0b7b958cc3eb22cfcf6c0df83d1fe5521b4a100103e3fa84c8", size = 130742 }, + { url = "https://files.pythonhosted.org/packages/9f/13/8a6109e4b477c518498ca37963d9c0eb1508b259725553fb53d53b20e2ea/orjson-3.10.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b7155eb1623347f0f22c38c9abdd738b287e39b9982e1da227503387b81b34ca", size = 414669 }, + { url = "https://files.pythonhosted.org/packages/22/7b/1d229d6d24644ed4d0a803de1b0e2df832032d5beda7346831c78191b5b2/orjson-3.10.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:208beedfa807c922da4e81061dafa9c8489c6328934ca2a562efa707e049e561", size = 141043 }, + { url = "https://files.pythonhosted.org/packages/cc/d3/6dc91156cf12ed86bed383bcb942d84d23304a1e57b7ab030bf60ea130d6/orjson-3.10.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eca81f83b1b8c07449e1d6ff7074e82e3fd6777e588f1a6632127f286a968825", size = 129826 }, + { url = "https://files.pythonhosted.org/packages/b3/38/c47c25b86f6996f1343be721b6ea4367bc1c8bc0fc3f6bbcd995d18cb19d/orjson-3.10.15-cp313-cp313-win32.whl", hash = "sha256:c03cd6eea1bd3b949d0d007c8d57049aa2b39bd49f58b4b2af571a5d3833d890", size = 142542 }, + { url = "https://files.pythonhosted.org/packages/27/f1/1d7ec15b20f8ce9300bc850de1e059132b88990e46cd0ccac29cbf11e4f9/orjson-3.10.15-cp313-cp313-win_amd64.whl", hash = "sha256:fd56a26a04f6ba5fb2045b0acc487a63162a958ed837648c5781e1fe3316cfbf", size = 133444 }, ] [[package]] @@ -843,7 +860,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.0.1" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -852,21 +869,21 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/c8/e22c292035f1bac8b9f5237a2622305bc0304e776080b246f3df57c4ff9f/pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", size = 191678 } +sdist = { url = "https://files.pythonhosted.org/packages/2a/13/b62d075317d8686071eb843f0bb1f195eb332f48869d3c31a4c6f1e063ac/pre_commit-4.1.0.tar.gz", hash = "sha256:ae3f018575a588e30dfddfab9a05448bfbd6b73d78709617b5a2b853549716d4", size = 193330 } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713 }, + { url = "https://files.pythonhosted.org/packages/43/b3/df14c580d82b9627d173ceea305ba898dca135feb360b6d84019d0803d3b/pre_commit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d29e7cb346295bcc1cc75fc3e92e343495e3ea0196c9ec6ba53f49f10ab6ae7b", size = 220560 }, ] [[package]] name = "prompt-toolkit" -version = "3.0.48" +version = "3.0.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/4f/feb5e137aff82f7c7f3248267b97451da3644f6cdc218edfe549fb354127/prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90", size = 424684 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/e1/bd15cb8ffdcfeeb2bdc215de3c3cffca11408d829e4b8416dcfe71ba8854/prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab", size = 429087 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/6a/fd08d94654f7e67c52ca30523a178b3f8ccc4237fce4be90d39c938a831a/prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e", size = 386595 }, + { url = "https://files.pythonhosted.org/packages/e4/ea/d836f008d33151c7a1f62caf3d8dd782e4d15f6a43897f64480c2b8de2ad/prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198", size = 387816 }, ] [[package]] @@ -952,11 +969,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.0" +version = "2.19.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/c0/9c9832e5be227c40e1ce774d493065f83a91d6430baa7e372094e9683a45/pygments-2.19.0.tar.gz", hash = "sha256:afc4146269910d4bdfabcd27c24923137a74d562a23a320a41a55ad303e19783", size = 4967733 } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/dc/fde3e7ac4d279a331676829af4afafd113b34272393d73f610e8f0329221/pygments-2.19.0-py3-none-any.whl", hash = "sha256:4755e6e64d22161d5b61432c0600c923c5927214e7c956e31c23923c89251a9b", size = 1225305 }, + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, ] [[package]] @@ -976,14 +993,14 @@ wheels = [ [[package]] name = "pytest-asyncio" -version = "0.25.1" +version = "0.25.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/04/0477a4bdd176ad678d148c075f43620b3f7a060ff61c7da48500b1fa8a75/pytest_asyncio-0.25.1.tar.gz", hash = "sha256:79be8a72384b0c917677e00daa711e07db15259f4d23203c59012bcd989d4aee", size = 53760 } +sdist = { url = "https://files.pythonhosted.org/packages/72/df/adcc0d60f1053d74717d21d58c0048479e9cab51464ce0d2965b086bd0e2/pytest_asyncio-0.25.2.tar.gz", hash = "sha256:3f8ef9a98f45948ea91a0ed3dc4268b5326c0e7bce73892acc654df4262ad45f", size = 53950 } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/fb/efc7226b384befd98d0e00d8c4390ad57f33c8fde00094b85c5e07897def/pytest_asyncio-0.25.1-py3-none-any.whl", hash = "sha256:c84878849ec63ff2ca509423616e071ef9cd8cc93c053aa33b5b8fb70a990671", size = 19357 }, + { url = "https://files.pythonhosted.org/packages/61/d8/defa05ae50dcd6019a95527200d3b3980043df5aa445d40cb0ef9f7f98ab/pytest_asyncio-0.25.2-py3-none-any.whl", hash = "sha256:0d0bb693f7b99da304a0634afc0a4b19e49d5e0de2d670f38dc4bfa5727c5075", size = 19400 }, ] [[package]] @@ -1089,7 +1106,7 @@ wheels = [ [[package]] name = "python-kasa" -version = "0.9.1" +version = "0.10.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -1478,11 +1495,11 @@ wheels = [ [[package]] name = "tzdata" -version = "2024.2" +version = "2025.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/34/943888654477a574a86a98e9896bae89c7aa15078ec29f490fef2f1e5384/tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc", size = 193282 } +sdist = { url = "https://files.pythonhosted.org/packages/43/0f/fa4723f22942480be4ca9527bbde8d43f6c3f2fe8412f00e7f5f6746bc8b/tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694", size = 194950 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586 }, + { url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762 }, ] [[package]] @@ -1496,16 +1513,16 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.28.1" +version = "20.29.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/39/689abee4adc85aad2af8174bb195a819d0be064bf55fcc73b49d2b28ae77/virtualenv-20.28.1.tar.gz", hash = "sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329", size = 7650532 } +sdist = { url = "https://files.pythonhosted.org/packages/a7/ca/f23dcb02e161a9bba141b1c08aa50e8da6ea25e6d780528f1d385a3efe25/virtualenv-20.29.1.tar.gz", hash = "sha256:b8b8970138d32fb606192cb97f6cd4bb644fa486be9308fb9b63f81091b5dc35", size = 7658028 } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/8f/dfb257ca6b4e27cb990f1631142361e4712badab8e3ca8dc134d96111515/virtualenv-20.28.1-py3-none-any.whl", hash = "sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb", size = 4276719 }, + { url = "https://files.pythonhosted.org/packages/89/9b/599bcfc7064fbe5740919e78c5df18e5dceb0887e676256a1061bb5ae232/virtualenv-20.29.1-py3-none-any.whl", hash = "sha256:4e4cb403c0b0da39e13b46b1b2476e505cb0046b25f242bee80f62bf990b2779", size = 4282379 }, ] [[package]] From 82fbe1226ebb295ac87d949753dd4d58ef7f3668 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 29 Jan 2025 18:49:06 +0000 Subject: [PATCH 846/892] Do not return empty string for custom light effect name (#1491) --- kasa/interfaces/lighteffect.py | 3 ++- kasa/iot/modules/lighteffect.py | 13 ++----------- kasa/smart/modules/lightstripeffect.py | 12 +++--------- 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/kasa/interfaces/lighteffect.py b/kasa/interfaces/lighteffect.py index fa50dd3eb..bfcd9be36 100644 --- a/kasa/interfaces/lighteffect.py +++ b/kasa/interfaces/lighteffect.py @@ -51,6 +51,7 @@ class LightEffect(Module, ABC): """Interface to represent a light effect module.""" LIGHT_EFFECTS_OFF = "Off" + LIGHT_EFFECTS_UNNAMED_CUSTOM = "Custom" def _initialize_features(self) -> None: """Initialize features.""" @@ -77,7 +78,7 @@ def has_custom_effects(self) -> bool: @property @abstractmethod def effect(self) -> str: - """Return effect state or name.""" + """Return effect name.""" @property @abstractmethod diff --git a/kasa/iot/modules/lighteffect.py b/kasa/iot/modules/lighteffect.py index cdfaaae16..3a41fb5f6 100644 --- a/kasa/iot/modules/lighteffect.py +++ b/kasa/iot/modules/lighteffect.py @@ -12,20 +12,11 @@ class LightEffect(IotModule, LightEffectInterface): @property def effect(self) -> str: - """Return effect state. - - Example: - {'brightness': 50, - 'custom': 0, - 'enable': 0, - 'id': '', - 'name': ''} - """ + """Return effect name.""" eff = self.data["lighting_effect_state"] name = eff["name"] if eff["enable"]: - return name - + return name or self.LIGHT_EFFECTS_UNNAMED_CUSTOM return self.LIGHT_EFFECTS_OFF @property diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index 91d891887..34c1c20c2 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -37,20 +37,14 @@ def name(self) -> str: @property def effect(self) -> str: - """Return effect state. - - Example: - {'brightness': 50, - 'custom': 0, - 'enable': 0, - 'id': '', - 'name': ''} - """ + """Return effect name.""" eff = self.data["lighting_effect"] name = eff["name"] # When devices are unpaired effect name is softAP which is not in our list if eff["enable"] and name in self._effect_list: return name + if eff["enable"] and eff["custom"]: + return name or self.LIGHT_EFFECTS_UNNAMED_CUSTOM return self.LIGHT_EFFECTS_OFF @property From ebd370da74636c0b768449a90329456ddccae084 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 29 Jan 2025 18:49:38 +0000 Subject: [PATCH 847/892] Add module.device to the public api (#1478) --- kasa/module.py | 5 +++++ tests/iot/test_iotdevice.py | 4 ++-- tests/smart/modules/test_fan.py | 2 +- tests/smart/test_smartdevice.py | 6 +++--- tests/test_common_modules.py | 8 ++++---- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/kasa/module.py b/kasa/module.py index 8fdff7c34..8ca259fc8 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -182,6 +182,11 @@ def __init__(self, device: Device, module: str) -> None: self._module = module self._module_features: dict[str, Feature] = {} + @property + def device(self) -> Device: + """Return the device exposing the module.""" + return self._device + @property def _all_features(self) -> dict[str, Feature]: """Get the features for this module and any sub modules.""" diff --git a/tests/iot/test_iotdevice.py b/tests/iot/test_iotdevice.py index 0b8228590..16dac35ff 100644 --- a/tests/iot/test_iotdevice.py +++ b/tests/iot/test_iotdevice.py @@ -277,12 +277,12 @@ async def test_get_modules(): # Modules on device module = dummy_device.modules.get("cloud") assert module - assert module._device == dummy_device + assert module.device == dummy_device assert isinstance(module, Cloud) module = dummy_device.modules.get(Module.IotCloud) assert module - assert module._device == dummy_device + assert module.device == dummy_device assert isinstance(module, Cloud) # Invalid modules diff --git a/tests/smart/modules/test_fan.py b/tests/smart/modules/test_fan.py index 9a6878e5b..5f505e747 100644 --- a/tests/smart/modules/test_fan.py +++ b/tests/smart/modules/test_fan.py @@ -58,7 +58,7 @@ async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): assert isinstance(dev, SmartDevice) fan = next(get_parent_and_child_modules(dev, Module.Fan)) assert fan - device = fan._device + device = fan.device await fan.set_fan_speed_level(1) await dev.update() diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index 2cf87d06b..155c2bdf7 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -604,7 +604,7 @@ async def test_get_modules(): # Modules on device module = dummy_device.modules.get("Cloud") assert module - assert module._device == dummy_device + assert module.device == dummy_device assert isinstance(module, Cloud) module = dummy_device.modules.get(Module.Cloud) @@ -617,8 +617,8 @@ async def test_get_modules(): assert module is None module = next(get_parent_and_child_modules(dummy_device, "Fan")) assert module - assert module._device != dummy_device - assert module._device._parent == dummy_device + assert module.device != dummy_device + assert module.device.parent == dummy_device # Invalid modules module = dummy_device.modules.get("DummyModule") diff --git a/tests/test_common_modules.py b/tests/test_common_modules.py index cba1ef878..3b1d89885 100644 --- a/tests/test_common_modules.py +++ b/tests/test_common_modules.py @@ -176,7 +176,7 @@ async def test_light_brightness(dev: Device): assert light # Test getting the value - feature = light._device.features["brightness"] + feature = light.device.features["brightness"] assert feature.minimum_value == 0 assert feature.maximum_value == 100 @@ -205,7 +205,7 @@ async def test_light_color_temp(dev: Device): ) # Test getting the value - feature = light._device.features["color_temperature"] + feature = light.device.features["color_temperature"] assert isinstance(feature.minimum_value, int) assert isinstance(feature.maximum_value, int) @@ -237,7 +237,7 @@ async def test_light_set_state(dev: Device): light = next(get_parent_and_child_modules(dev, Module.Light)) assert light # For fixtures that have a light effect active switch off - if light_effect := light._device.modules.get(Module.LightEffect): + if light_effect := light.device.modules.get(Module.LightEffect): await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF) await light.set_state(LightState(light_on=False)) @@ -264,7 +264,7 @@ async def test_light_preset_module(dev: Device, mocker: MockerFixture): assert preset_mod light_mod = next(get_parent_and_child_modules(dev, Module.Light)) assert light_mod - feat = preset_mod._device.features["light_preset"] + feat = preset_mod.device.features["light_preset"] preset_list = preset_mod.preset_list assert "Not set" in preset_list From 44c561b04d77dd590f235118929f889da4f3b80e Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 29 Jan 2025 19:32:01 +0000 Subject: [PATCH 848/892] Add FeatureAttributes to smartcam Alarm (#1489) Co-authored-by: Teemu R. --- kasa/interfaces/energy.py | 2 +- kasa/smart/smartmodule.py | 11 ++++--- kasa/smartcam/modules/alarm.py | 19 ++++++++---- tests/test_common_modules.py | 57 ++++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py index c57a3ed80..b6cc203fa 100644 --- a/kasa/interfaces/energy.py +++ b/kasa/interfaces/energy.py @@ -28,7 +28,7 @@ class ModuleFeature(IntFlag): _supported: ModuleFeature = ModuleFeature(0) - def supports(self, module_feature: ModuleFeature) -> bool: + def supports(self, module_feature: Energy.ModuleFeature) -> bool: """Return True if module supports the feature.""" return module_feature in self._supported diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 243852e06..91efa33dc 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -3,7 +3,8 @@ from __future__ import annotations import logging -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Callable, Coroutine +from functools import wraps from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar from ..exceptions import DeviceError, KasaException, SmartErrorCode @@ -20,15 +21,16 @@ def allow_update_after( - func: Callable[Concatenate[_T, _P], Awaitable[dict]], -) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, dict]]: + func: Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]: """Define a wrapper to set _last_update_time to None. This will ensure that a module is updated in the next update cycle after a value has been changed. """ - async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> dict: + @wraps(func) + async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> _R: try: return await func(self, *args, **kwargs) finally: @@ -40,6 +42,7 @@ async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> dict: def raise_if_update_error(func: Callable[[_T], _R]) -> Callable[[_T], _R]: """Define a wrapper to raise an error if the last module update was an error.""" + @wraps(func) def _wrap(self: _T) -> _R: if err := self._last_update_error: raise err diff --git a/kasa/smartcam/modules/alarm.py b/kasa/smartcam/modules/alarm.py index 18833d822..df1891ecf 100644 --- a/kasa/smartcam/modules/alarm.py +++ b/kasa/smartcam/modules/alarm.py @@ -2,8 +2,11 @@ from __future__ import annotations +from typing import Annotated + from ...feature import Feature from ...interfaces import Alarm as AlarmInterface +from ...module import FeatureAttribute from ...smart.smartmodule import allow_update_after from ..smartcammodule import SmartCamModule @@ -105,12 +108,12 @@ def _initialize_features(self) -> None: ) @property - def alarm_sound(self) -> str: + def alarm_sound(self) -> Annotated[str, FeatureAttribute()]: """Return current alarm sound.""" return self.data["getSirenConfig"]["siren_type"] @allow_update_after - async def set_alarm_sound(self, sound: str) -> dict: + async def set_alarm_sound(self, sound: str) -> Annotated[dict, FeatureAttribute()]: """Set alarm sound. See *alarm_sounds* for list of available sounds. @@ -124,7 +127,7 @@ def alarm_sounds(self) -> list[str]: return self.data["getSirenTypeList"]["siren_type_list"] @property - def alarm_volume(self) -> int: + def alarm_volume(self) -> Annotated[int, FeatureAttribute()]: """Return alarm volume. Unlike duration the device expects/returns a string for volume. @@ -132,18 +135,22 @@ def alarm_volume(self) -> int: return int(self.data["getSirenConfig"]["volume"]) @allow_update_after - async def set_alarm_volume(self, volume: int) -> dict: + async def set_alarm_volume( + self, volume: int + ) -> Annotated[dict, FeatureAttribute()]: """Set alarm volume.""" config = self._validate_and_get_config(volume=volume) return await self.call("setSirenConfig", {"siren": config}) @property - def alarm_duration(self) -> int: + def alarm_duration(self) -> Annotated[int, FeatureAttribute()]: """Return alarm duration.""" return self.data["getSirenConfig"]["duration"] @allow_update_after - async def set_alarm_duration(self, duration: int) -> dict: + async def set_alarm_duration( + self, duration: int + ) -> Annotated[dict, FeatureAttribute()]: """Set alarm volume.""" config = self._validate_and_get_config(duration=duration) return await self.call("setSirenConfig", {"siren": config}) diff --git a/tests/test_common_modules.py b/tests/test_common_modules.py index 3b1d89885..869ba27d1 100644 --- a/tests/test_common_modules.py +++ b/tests/test_common_modules.py @@ -1,10 +1,16 @@ +import importlib +import inspect +import pkgutil +import sys from datetime import datetime from zoneinfo import ZoneInfo import pytest from pytest_mock import MockerFixture +import kasa.interfaces from kasa import Device, LightState, Module, ThermostatState +from kasa.module import _get_feature_attribute from .device_fixtures import ( bulb_iot, @@ -64,6 +70,57 @@ ) +interfaces = pytest.mark.parametrize("interface", kasa.interfaces.__all__) + + +def _get_subclasses(of_class, package): + """Get all the subclasses of a given class.""" + subclasses = set() + # iter_modules returns ModuleInfo: (module_finder, name, ispkg) + for _, modname, ispkg in pkgutil.iter_modules(package.__path__): + importlib.import_module("." + modname, package=package.__name__) + module = sys.modules[package.__name__ + "." + modname] + for _, obj in inspect.getmembers(module): + if ( + inspect.isclass(obj) + and issubclass(obj, of_class) + and obj is not of_class + ): + subclasses.add(obj) + + if ispkg: + res = _get_subclasses(of_class, module) + subclasses.update(res) + + return subclasses + + +@interfaces +def test_feature_attributes(interface): + """Test that all common derived classes define the FeatureAttributes.""" + klass = getattr(kasa.interfaces, interface) + + package = sys.modules["kasa"] + sub_classes = _get_subclasses(klass, package) + + feat_attributes: set[str] = set() + attribute_names = [ + k + for k, v in vars(klass).items() + if (callable(v) and not inspect.isclass(v)) or isinstance(v, property) + ] + for attr_name in attribute_names: + attribute = getattr(klass, attr_name) + if _get_feature_attribute(attribute): + feat_attributes.add(attr_name) + + for sub_class in sub_classes: + for attr_name in feat_attributes: + attribute = getattr(sub_class, attr_name) + fa = _get_feature_attribute(attribute) + assert fa, f"{attr_name} is not a defined module feature for {sub_class}" + + @led async def test_led_module(dev: Device, mocker: MockerFixture): """Test fan speed feature.""" From 8259d28b12a2387ca529a588a4f2668ff979540f Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sun, 2 Feb 2025 14:00:49 +0100 Subject: [PATCH 849/892] dustbin_mode: add 'off' mode for cleaner downstream impl (#1488) Adds a new artificial "Off" mode for dustbin_mode, which will allow avoiding the need to expose both a toggle and a select in homeassistant. This changes the behavior of the existing mode selection, as it is not anymore possible to change the mode without activating the auto collection. * Mode is Off, if auto collection has been disabled * When setting mode to "Off", this will disable the auto collection * When setting mode to anything else than "Off", the auto collection will be automatically enabled. --- kasa/smart/modules/dustbin.py | 10 ++++++++++ tests/smart/modules/test_dustbin.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/kasa/smart/modules/dustbin.py b/kasa/smart/modules/dustbin.py index 33aecd8f7..b2b4d1ef4 100644 --- a/kasa/smart/modules/dustbin.py +++ b/kasa/smart/modules/dustbin.py @@ -19,6 +19,8 @@ class Mode(IntEnum): Balanced = 2 Max = 3 + Off = -1_000 + class Dustbin(SmartModule): """Implementation of vacuum dustbin.""" @@ -91,6 +93,8 @@ def _settings(self) -> dict: @property def mode(self) -> str: """Return auto-emptying mode.""" + if self.auto_collection is False: + return Mode.Off.name return Mode(self._settings["dust_collection_mode"]).name async def set_mode(self, mode: str) -> dict: @@ -101,8 +105,14 @@ async def set_mode(self, mode: str) -> dict: "Invalid auto/emptying mode speed %s, available %s", mode, name_to_value ) + if mode == Mode.Off.name: + return await self.set_auto_collection(False) + + # Make a copy just in case, even when we are overriding both settings settings = self._settings.copy() + settings["auto_dust_collection"] = True settings["dust_collection_mode"] = name_to_value[mode] + return await self.call("setDustCollectionInfo", settings) @property diff --git a/tests/smart/modules/test_dustbin.py b/tests/smart/modules/test_dustbin.py index d30d2459b..ecc68b6b2 100644 --- a/tests/smart/modules/test_dustbin.py +++ b/tests/smart/modules/test_dustbin.py @@ -60,6 +60,25 @@ async def test_dustbin_mode(dev: SmartDevice, mocker: MockerFixture): await dustbin.set_mode("invalid") +@dustbin +async def test_dustbin_mode_off(dev: SmartDevice, mocker: MockerFixture): + """Test dustbin_mode == Off.""" + dustbin = next(get_parent_and_child_modules(dev, Module.Dustbin)) + call = mocker.spy(dustbin, "call") + + auto_collection = dustbin._device.features["dustbin_mode"] + await auto_collection.set_value(Mode.Off.name) + + params = dustbin._settings.copy() + params["auto_dust_collection"] = False + + call.assert_called_with("setDustCollectionInfo", params) + + await dev.update() + assert dustbin.auto_collection is False + assert dustbin.mode is Mode.Off.name + + @dustbin async def test_autocollection(dev: SmartDevice, mocker: MockerFixture): """Test autocollection switch.""" From bff5409d2265a238479fdb69217c3421791f3804 Mon Sep 17 00:00:00 2001 From: Ryan Nitcher Date: Sun, 2 Feb 2025 06:48:34 -0700 Subject: [PATCH 850/892] Add Dimmer Configuration Support (#1484) --- kasa/iot/iotdimmer.py | 3 +- kasa/iot/modules/__init__.py | 2 + kasa/iot/modules/dimmer.py | 270 +++++++++++++++++++++++++++++++ kasa/module.py | 1 + tests/iot/modules/test_dimmer.py | 204 +++++++++++++++++++++++ 5 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 kasa/iot/modules/dimmer.py create mode 100644 tests/iot/modules/test_dimmer.py diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 1631fbba9..6b22d640b 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -11,7 +11,7 @@ from ..protocols import BaseProtocol from .iotdevice import KasaException, requires_update from .iotplug import IotPlug -from .modules import AmbientLight, Light, Motion +from .modules import AmbientLight, Dimmer, Light, Motion class ButtonAction(Enum): @@ -87,6 +87,7 @@ async def _initialize_modules(self) -> None: # TODO: need to be figured out what's the best approach to detect support self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR")) self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS")) + self.add_module(Module.IotDimmer, Dimmer(self, "smartlife.iot.dimmer")) self.add_module(Module.Light, Light(self, "light")) @property # type: ignore diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index 6fd63a706..ef7adf689 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -4,6 +4,7 @@ from .antitheft import Antitheft from .cloud import Cloud from .countdown import Countdown +from .dimmer import Dimmer from .emeter import Emeter from .led import Led from .light import Light @@ -20,6 +21,7 @@ "Antitheft", "Cloud", "Countdown", + "Dimmer", "Emeter", "Led", "Light", diff --git a/kasa/iot/modules/dimmer.py b/kasa/iot/modules/dimmer.py new file mode 100644 index 000000000..42a93ce56 --- /dev/null +++ b/kasa/iot/modules/dimmer.py @@ -0,0 +1,270 @@ +"""Implementation of the dimmer config module found in dimmers.""" + +from __future__ import annotations + +import logging +from datetime import timedelta +from typing import Any, Final, cast + +from ...exceptions import KasaException +from ...feature import Feature +from ..iotmodule import IotModule, merge + +_LOGGER = logging.getLogger(__name__) + + +def _td_to_ms(td: timedelta) -> int: + """ + Convert timedelta to integer milliseconds. + + Uses default float to integer rounding. + """ + return int(td / timedelta(milliseconds=1)) + + +class Dimmer(IotModule): + """Implements the dimmer config module.""" + + THRESHOLD_ABS_MIN: Final[int] = 0 + # Strange value, but verified against hardware (KS220). + THRESHOLD_ABS_MAX: Final[int] = 51 + FADE_TIME_ABS_MIN: Final[timedelta] = timedelta(seconds=0) + # Arbitrary, but set low intending GENTLE FADE for longer fades. + FADE_TIME_ABS_MAX: Final[timedelta] = timedelta(seconds=10) + GENTLE_TIME_ABS_MIN: Final[timedelta] = timedelta(seconds=0) + # Arbitrary, but reasonable default. + GENTLE_TIME_ABS_MAX: Final[timedelta] = timedelta(seconds=120) + # Verified against KS220. + RAMP_RATE_ABS_MIN: Final[int] = 10 + # Verified against KS220. + RAMP_RATE_ABS_MAX: Final[int] = 50 + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + device=self._device, + container=self, + id="dimmer_threshold_min", + name="Minimum dimming level", + icon="mdi:lightbulb-on-20", + attribute_getter="threshold_min", + attribute_setter="set_threshold_min", + range_getter=lambda: (self.THRESHOLD_ABS_MIN, self.THRESHOLD_ABS_MAX), + type=Feature.Type.Number, + category=Feature.Category.Config, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="dimmer_fade_off_time", + name="Dimmer fade off time", + icon="mdi:clock-in", + attribute_getter="fade_off_time", + attribute_setter="set_fade_off_time", + range_getter=lambda: ( + _td_to_ms(self.FADE_TIME_ABS_MIN), + _td_to_ms(self.FADE_TIME_ABS_MAX), + ), + type=Feature.Type.Number, + category=Feature.Category.Config, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="dimmer_fade_on_time", + name="Dimmer fade on time", + icon="mdi:clock-out", + attribute_getter="fade_on_time", + attribute_setter="set_fade_on_time", + range_getter=lambda: ( + _td_to_ms(self.FADE_TIME_ABS_MIN), + _td_to_ms(self.FADE_TIME_ABS_MAX), + ), + type=Feature.Type.Number, + category=Feature.Category.Config, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="dimmer_gentle_off_time", + name="Dimmer gentle off time", + icon="mdi:clock-in", + attribute_getter="gentle_off_time", + attribute_setter="set_gentle_off_time", + range_getter=lambda: ( + _td_to_ms(self.GENTLE_TIME_ABS_MIN), + _td_to_ms(self.GENTLE_TIME_ABS_MAX), + ), + type=Feature.Type.Number, + category=Feature.Category.Config, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="dimmer_gentle_on_time", + name="Dimmer gentle on time", + icon="mdi:clock-out", + attribute_getter="gentle_on_time", + attribute_setter="set_gentle_on_time", + range_getter=lambda: ( + _td_to_ms(self.GENTLE_TIME_ABS_MIN), + _td_to_ms(self.GENTLE_TIME_ABS_MAX), + ), + type=Feature.Type.Number, + category=Feature.Category.Config, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="dimmer_ramp_rate", + name="Dimmer ramp rate", + icon="mdi:clock-fast", + attribute_getter="ramp_rate", + attribute_setter="set_ramp_rate", + range_getter=lambda: (self.RAMP_RATE_ABS_MIN, self.RAMP_RATE_ABS_MAX), + type=Feature.Type.Number, + category=Feature.Category.Config, + ) + ) + + def query(self) -> dict: + """Request Dimming configuration.""" + req = merge( + self.query_for_command("get_dimmer_parameters"), + self.query_for_command("get_default_behavior"), + ) + + return req + + @property + def config(self) -> dict[str, Any]: + """Return current configuration.""" + return self.data["get_dimmer_parameters"] + + @property + def threshold_min(self) -> int: + """Return the minimum dimming level for this dimmer.""" + return self.config["minThreshold"] + + async def set_threshold_min(self, min: int) -> dict: + """Set the minimum dimming level for this dimmer. + + The value will depend on the luminaries connected to the dimmer. + + :param min: The minimum dimming level, in the range 0-51. + """ + if min < self.THRESHOLD_ABS_MIN or min > self.THRESHOLD_ABS_MAX: + raise KasaException( + "Minimum dimming threshold is outside the supported range: " + f"{self.THRESHOLD_ABS_MIN}-{self.THRESHOLD_ABS_MAX}" + ) + return await self.call("calibrate_brightness", {"minThreshold": min}) + + @property + def fade_off_time(self) -> timedelta: + """Return the fade off animation duration.""" + return timedelta(milliseconds=cast(int, self.config["fadeOffTime"])) + + async def set_fade_off_time(self, time: int | timedelta) -> dict: + """Set the duration of the fade off animation. + + :param time: The animation duration, in ms. + """ + if isinstance(time, int): + time = timedelta(milliseconds=time) + if time < self.FADE_TIME_ABS_MIN or time > self.FADE_TIME_ABS_MAX: + raise KasaException( + "Fade time is outside the bounds of the supported range:" + f"{self.FADE_TIME_ABS_MIN}-{self.FADE_TIME_ABS_MAX}" + ) + return await self.call("set_fade_off_time", {"fadeTime": _td_to_ms(time)}) + + @property + def fade_on_time(self) -> timedelta: + """Return the fade on animation duration.""" + return timedelta(milliseconds=cast(int, self.config["fadeOnTime"])) + + async def set_fade_on_time(self, time: int | timedelta) -> dict: + """Set the duration of the fade on animation. + + :param time: The animation duration, in ms. + """ + if isinstance(time, int): + time = timedelta(milliseconds=time) + if time < self.FADE_TIME_ABS_MIN or time > self.FADE_TIME_ABS_MAX: + raise KasaException( + "Fade time is outside the bounds of the supported range:" + f"{self.FADE_TIME_ABS_MIN}-{self.FADE_TIME_ABS_MAX}" + ) + return await self.call("set_fade_on_time", {"fadeTime": _td_to_ms(time)}) + + @property + def gentle_off_time(self) -> timedelta: + """Return the gentle fade off animation duration.""" + return timedelta(milliseconds=cast(int, self.config["gentleOffTime"])) + + async def set_gentle_off_time(self, time: int | timedelta) -> dict: + """Set the duration of the gentle fade off animation. + + :param time: The animation duration, in ms. + """ + if isinstance(time, int): + time = timedelta(milliseconds=time) + if time < self.GENTLE_TIME_ABS_MIN or time > self.GENTLE_TIME_ABS_MAX: + raise KasaException( + "Gentle off time is outside the bounds of the supported range: " + f"{self.GENTLE_TIME_ABS_MIN}-{self.GENTLE_TIME_ABS_MAX}." + ) + return await self.call("set_gentle_off_time", {"duration": _td_to_ms(time)}) + + @property + def gentle_on_time(self) -> timedelta: + """Return the gentle fade on animation duration.""" + return timedelta(milliseconds=cast(int, self.config["gentleOnTime"])) + + async def set_gentle_on_time(self, time: int | timedelta) -> dict: + """Set the duration of the gentle fade on animation. + + :param time: The animation duration, in ms. + """ + if isinstance(time, int): + time = timedelta(milliseconds=time) + if time < self.GENTLE_TIME_ABS_MIN or time > self.GENTLE_TIME_ABS_MAX: + raise KasaException( + "Gentle off time is outside the bounds of the supported range: " + f"{self.GENTLE_TIME_ABS_MIN}-{self.GENTLE_TIME_ABS_MAX}." + ) + return await self.call("set_gentle_on_time", {"duration": _td_to_ms(time)}) + + @property + def ramp_rate(self) -> int: + """Return the rate that the dimmer buttons increment the dimmer level.""" + return self.config["rampRate"] + + async def set_ramp_rate(self, rate: int) -> dict: + """Set how quickly to ramp the dimming level when using the dimmer buttons. + + :param rate: The rate to increment the dimming level with each press. + """ + if rate < self.RAMP_RATE_ABS_MIN or rate > self.RAMP_RATE_ABS_MAX: + raise KasaException( + "Gentle off time is outside the bounds of the supported range:" + f"{self.RAMP_RATE_ABS_MIN}-{self.RAMP_RATE_ABS_MAX}" + ) + return await self.call("set_button_ramp_rate", {"rampRate": rate}) diff --git a/kasa/module.py b/kasa/module.py index 8ca259fc8..afd1e1274 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -111,6 +111,7 @@ class Module(ABC): IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft") IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown") + IotDimmer: Final[ModuleName[iot.Dimmer]] = ModuleName("dimmer") IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion") IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule") IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage") diff --git a/tests/iot/modules/test_dimmer.py b/tests/iot/modules/test_dimmer.py new file mode 100644 index 000000000..e4b267610 --- /dev/null +++ b/tests/iot/modules/test_dimmer.py @@ -0,0 +1,204 @@ +from datetime import timedelta +from typing import Final + +import pytest +from pytest_mock import MockerFixture + +from kasa import KasaException, Module +from kasa.iot import IotDimmer +from kasa.iot.modules.dimmer import Dimmer + +from ...device_fixtures import dimmer_iot + +_TD_ONE_MS: Final[timedelta] = timedelta(milliseconds=1) + + +@dimmer_iot +def test_dimmer_getters(dev: IotDimmer): + assert Module.IotDimmer in dev.modules + dimmer: Dimmer = dev.modules[Module.IotDimmer] + + assert dimmer.threshold_min == dimmer.config["minThreshold"] + assert int(dimmer.fade_off_time / _TD_ONE_MS) == dimmer.config["fadeOffTime"] + assert int(dimmer.fade_on_time / _TD_ONE_MS) == dimmer.config["fadeOnTime"] + assert int(dimmer.gentle_off_time / _TD_ONE_MS) == dimmer.config["gentleOffTime"] + assert int(dimmer.gentle_on_time / _TD_ONE_MS) == dimmer.config["gentleOnTime"] + assert dimmer.ramp_rate == dimmer.config["rampRate"] + + +@dimmer_iot +async def test_dimmer_setters(dev: IotDimmer, mocker: MockerFixture): + dimmer: Dimmer = dev.modules[Module.IotDimmer] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + test_threshold = 10 + await dimmer.set_threshold_min(test_threshold) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "calibrate_brightness", {"minThreshold": test_threshold} + ) + + test_time = 100 + await dimmer.set_fade_off_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_fade_off_time", {"fadeTime": test_time} + ) + await dimmer.set_fade_on_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_fade_on_time", {"fadeTime": test_time} + ) + + test_time = 1000 + await dimmer.set_gentle_off_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_gentle_off_time", {"duration": test_time} + ) + await dimmer.set_gentle_on_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_gentle_on_time", {"duration": test_time} + ) + + test_rate = 30 + await dimmer.set_ramp_rate(test_rate) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_button_ramp_rate", {"rampRate": test_rate} + ) + + +@dimmer_iot +async def test_dimmer_setter_min(dev: IotDimmer, mocker: MockerFixture): + dimmer: Dimmer = dev.modules[Module.IotDimmer] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + test_threshold = dimmer.THRESHOLD_ABS_MIN + await dimmer.set_threshold_min(test_threshold) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "calibrate_brightness", {"minThreshold": test_threshold} + ) + + test_time = int(dimmer.FADE_TIME_ABS_MIN / _TD_ONE_MS) + await dimmer.set_fade_off_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_fade_off_time", {"fadeTime": test_time} + ) + await dimmer.set_fade_on_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_fade_on_time", {"fadeTime": test_time} + ) + + test_time = int(dimmer.GENTLE_TIME_ABS_MIN / _TD_ONE_MS) + await dimmer.set_gentle_off_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_gentle_off_time", {"duration": test_time} + ) + await dimmer.set_gentle_on_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_gentle_on_time", {"duration": test_time} + ) + + test_rate = dimmer.RAMP_RATE_ABS_MIN + await dimmer.set_ramp_rate(test_rate) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_button_ramp_rate", {"rampRate": test_rate} + ) + + +@dimmer_iot +async def test_dimmer_setter_max(dev: IotDimmer, mocker: MockerFixture): + dimmer: Dimmer = dev.modules[Module.IotDimmer] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + test_threshold = dimmer.THRESHOLD_ABS_MAX + await dimmer.set_threshold_min(test_threshold) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "calibrate_brightness", {"minThreshold": test_threshold} + ) + + test_time = int(dimmer.FADE_TIME_ABS_MAX / _TD_ONE_MS) + await dimmer.set_fade_off_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_fade_off_time", {"fadeTime": test_time} + ) + await dimmer.set_fade_on_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_fade_on_time", {"fadeTime": test_time} + ) + + test_time = int(dimmer.GENTLE_TIME_ABS_MAX / _TD_ONE_MS) + await dimmer.set_gentle_off_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_gentle_off_time", {"duration": test_time} + ) + await dimmer.set_gentle_on_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_gentle_on_time", {"duration": test_time} + ) + + test_rate = dimmer.RAMP_RATE_ABS_MAX + await dimmer.set_ramp_rate(test_rate) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_button_ramp_rate", {"rampRate": test_rate} + ) + + +@dimmer_iot +async def test_dimmer_setters_min_oob(dev: IotDimmer, mocker: MockerFixture): + dimmer: Dimmer = dev.modules[Module.IotDimmer] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + test_threshold = dimmer.THRESHOLD_ABS_MIN - 1 + with pytest.raises(KasaException): + await dimmer.set_threshold_min(test_threshold) + query_helper.assert_not_called() + + test_time = dimmer.FADE_TIME_ABS_MIN - _TD_ONE_MS + with pytest.raises(KasaException): + await dimmer.set_fade_off_time(test_time) + query_helper.assert_not_called() + with pytest.raises(KasaException): + await dimmer.set_fade_on_time(test_time) + query_helper.assert_not_called() + + test_time = dimmer.GENTLE_TIME_ABS_MIN - _TD_ONE_MS + with pytest.raises(KasaException): + await dimmer.set_gentle_off_time(test_time) + query_helper.assert_not_called() + with pytest.raises(KasaException): + await dimmer.set_gentle_on_time(test_time) + query_helper.assert_not_called() + + test_rate = dimmer.RAMP_RATE_ABS_MIN - 1 + with pytest.raises(KasaException): + await dimmer.set_ramp_rate(test_rate) + query_helper.assert_not_called() + + +@dimmer_iot +async def test_dimmer_setters_max_oob(dev: IotDimmer, mocker: MockerFixture): + dimmer: Dimmer = dev.modules[Module.IotDimmer] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + test_threshold = dimmer.THRESHOLD_ABS_MAX + 1 + with pytest.raises(KasaException): + await dimmer.set_threshold_min(test_threshold) + query_helper.assert_not_called() + + test_time = dimmer.FADE_TIME_ABS_MAX + _TD_ONE_MS + with pytest.raises(KasaException): + await dimmer.set_fade_off_time(test_time) + query_helper.assert_not_called() + with pytest.raises(KasaException): + await dimmer.set_fade_on_time(test_time) + query_helper.assert_not_called() + + test_time = dimmer.GENTLE_TIME_ABS_MAX + _TD_ONE_MS + with pytest.raises(KasaException): + await dimmer.set_gentle_off_time(test_time) + query_helper.assert_not_called() + with pytest.raises(KasaException): + await dimmer.set_gentle_on_time(test_time) + query_helper.assert_not_called() + + test_rate = dimmer.RAMP_RATE_ABS_MAX + 1 + with pytest.raises(KasaException): + await dimmer.set_ramp_rate(test_rate) + query_helper.assert_not_called() From cbab40a59ed1765157b2c4b6db599a29b33d1ae1 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sun, 2 Feb 2025 14:02:19 +0000 Subject: [PATCH 851/892] Prepare 0.10.1 (#1494) ## [0.10.1](https://github.com/python-kasa/python-kasa/tree/0.10.1) (2025-02-02) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.10.0...0.10.1) **Release summary:** Small patch release for bugfixes **Implemented enhancements:** - dustbin\_mode: add 'off' mode for cleaner downstream impl [\#1488](https://github.com/python-kasa/python-kasa/pull/1488) (@rytilahti) - Add Dimmer Configuration Support [\#1484](https://github.com/python-kasa/python-kasa/pull/1484) (@ryenitcher) **Fixed bugs:** - Do not return empty string for custom light effect name [\#1491](https://github.com/python-kasa/python-kasa/pull/1491) (@sdb9696) - Add FeatureAttributes to smartcam Alarm [\#1489](https://github.com/python-kasa/python-kasa/pull/1489) (@sdb9696) **Project maintenance:** - Add module.device to the public api [\#1478](https://github.com/python-kasa/python-kasa/pull/1478) (@sdb9696) --- .pre-commit-config.yaml | 4 +-- CHANGELOG.md | 54 ++++++++++++++++++++++---------- pyproject.toml | 2 +- uv.lock | 68 ++++++++++++++++++++--------------------- 4 files changed, 75 insertions(+), 53 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9aeb80965..ae2847180 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.5.24 + rev: 0.5.26 hooks: # Update the uv lockfile - id: uv-lock @@ -22,7 +22,7 @@ repos: - "--indent=4" - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.3 + rev: v0.9.4 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/CHANGELOG.md b/CHANGELOG.md index 53a86b8af..5e40772cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [0.10.1](https://github.com/python-kasa/python-kasa/tree/0.10.1) (2025-02-02) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.10.0...0.10.1) + +**Release summary:** + +Small patch release for bugfixes + +**Implemented enhancements:** + +- dustbin\_mode: add 'off' mode for cleaner downstream impl [\#1488](https://github.com/python-kasa/python-kasa/pull/1488) (@rytilahti) +- Add Dimmer Configuration Support [\#1484](https://github.com/python-kasa/python-kasa/pull/1484) (@ryenitcher) + +**Fixed bugs:** + +- Do not return empty string for custom light effect name [\#1491](https://github.com/python-kasa/python-kasa/pull/1491) (@sdb9696) +- Add FeatureAttributes to smartcam Alarm [\#1489](https://github.com/python-kasa/python-kasa/pull/1489) (@sdb9696) + +**Project maintenance:** + +- Add module.device to the public api [\#1478](https://github.com/python-kasa/python-kasa/pull/1478) (@sdb9696) + ## [0.10.0](https://github.com/python-kasa/python-kasa/tree/0.10.0) (2025-01-26) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.9.1...0.10.0) @@ -31,11 +53,7 @@ Many thanks to testers and new contributors - @steveredden, @DawidPietrykowski, - Expose more battery sensors for D230 [\#1451](https://github.com/python-kasa/python-kasa/issues/1451) - dumping HTTP POST Body for Tapo Vacuum \(RV30 Plus\) [\#937](https://github.com/python-kasa/python-kasa/issues/937) -- Add common alarm interface [\#1479](https://github.com/python-kasa/python-kasa/pull/1479) (@sdb9696) -- Add common childsetup interface [\#1470](https://github.com/python-kasa/python-kasa/pull/1470) (@sdb9696) -- Add childsetup module to smartcam hubs [\#1469](https://github.com/python-kasa/python-kasa/pull/1469) (@sdb9696) - Add smartcam pet detection toggle module [\#1465](https://github.com/python-kasa/python-kasa/pull/1465) (@DawidPietrykowski) -- Only log one warning per unknown clean error code and status [\#1462](https://github.com/python-kasa/python-kasa/pull/1462) (@rytilahti) - Add childlock module for vacuums [\#1461](https://github.com/python-kasa/python-kasa/pull/1461) (@rytilahti) - Add ultra mode \(fanspeed = 5\) for vacuums [\#1459](https://github.com/python-kasa/python-kasa/pull/1459) (@rytilahti) - Add setting to change carpet clean mode [\#1458](https://github.com/python-kasa/python-kasa/pull/1458) (@rytilahti) @@ -46,49 +64,53 @@ Many thanks to testers and new contributors - @steveredden, @DawidPietrykowski, - Add battery module to smartcam devices [\#1452](https://github.com/python-kasa/python-kasa/pull/1452) (@sdb9696) - Allow update of camera modules after setting values [\#1450](https://github.com/python-kasa/python-kasa/pull/1450) (@sdb9696) - Update hub children on first update and delay subsequent updates [\#1438](https://github.com/python-kasa/python-kasa/pull/1438) (@sdb9696) -- Add support for doorbells and chimes [\#1435](https://github.com/python-kasa/python-kasa/pull/1435) (@steveredden) - Implement vacuum dustbin module \(dust\_bucket\) [\#1423](https://github.com/python-kasa/python-kasa/pull/1423) (@rytilahti) -- Allow https for klaptransport [\#1415](https://github.com/python-kasa/python-kasa/pull/1415) (@rytilahti) - Add smartcam child device support for smartcam hubs [\#1413](https://github.com/python-kasa/python-kasa/pull/1413) (@sdb9696) -- Add powerprotection module [\#1337](https://github.com/python-kasa/python-kasa/pull/1337) (@rytilahti) - Add vacuum speaker controls [\#1332](https://github.com/python-kasa/python-kasa/pull/1332) (@rytilahti) - Add consumables module for vacuums [\#1327](https://github.com/python-kasa/python-kasa/pull/1327) (@rytilahti) - Add ADC Value to PIR Enabled Switches [\#1263](https://github.com/python-kasa/python-kasa/pull/1263) (@ryenitcher) - Add support for cleaning records [\#945](https://github.com/python-kasa/python-kasa/pull/945) (@rytilahti) - Initial support for vacuums \(clean module\) [\#944](https://github.com/python-kasa/python-kasa/pull/944) (@rytilahti) - Add support for pairing devices with hubs [\#859](https://github.com/python-kasa/python-kasa/pull/859) (@rytilahti) +- Add common alarm interface [\#1479](https://github.com/python-kasa/python-kasa/pull/1479) (@sdb9696) +- Add common childsetup interface [\#1470](https://github.com/python-kasa/python-kasa/pull/1470) (@sdb9696) +- Add childsetup module to smartcam hubs [\#1469](https://github.com/python-kasa/python-kasa/pull/1469) (@sdb9696) +- Only log one warning per unknown clean error code and status [\#1462](https://github.com/python-kasa/python-kasa/pull/1462) (@rytilahti) +- Add support for doorbells and chimes [\#1435](https://github.com/python-kasa/python-kasa/pull/1435) (@steveredden) +- Allow https for klaptransport [\#1415](https://github.com/python-kasa/python-kasa/pull/1415) (@rytilahti) +- Add powerprotection module [\#1337](https://github.com/python-kasa/python-kasa/pull/1337) (@rytilahti) **Fixed bugs:** - TP-Link HS300 Wi-Fi Power-Strip - "Parent On/Off" not functioning. [\#637](https://github.com/python-kasa/python-kasa/issues/637) -- Convert carpet\_clean\_mode to carpet\_boost switch [\#1486](https://github.com/python-kasa/python-kasa/pull/1486) (@rytilahti) -- Change category for empty dustbin feature from Primary to Config [\#1485](https://github.com/python-kasa/python-kasa/pull/1485) (@rytilahti) - Report 0 for instead of None for zero current and voltage [\#1483](https://github.com/python-kasa/python-kasa/pull/1483) (@ryenitcher) -- Disable iot camera creation until more complete [\#1480](https://github.com/python-kasa/python-kasa/pull/1480) (@sdb9696) - ssltransport: use debug logger for sending requests [\#1443](https://github.com/python-kasa/python-kasa/pull/1443) (@rytilahti) - Fix discover cli command with host [\#1437](https://github.com/python-kasa/python-kasa/pull/1437) (@sdb9696) - Fallback to is\_low for batterysensor's battery\_low [\#1420](https://github.com/python-kasa/python-kasa/pull/1420) (@rytilahti) +- Convert carpet\_clean\_mode to carpet\_boost switch [\#1486](https://github.com/python-kasa/python-kasa/pull/1486) (@rytilahti) +- Change category for empty dustbin feature from Primary to Config [\#1485](https://github.com/python-kasa/python-kasa/pull/1485) (@rytilahti) +- Disable iot camera creation until more complete [\#1480](https://github.com/python-kasa/python-kasa/pull/1480) (@sdb9696) +- Add error code 7 for clean module [\#1474](https://github.com/python-kasa/python-kasa/pull/1474) (@rytilahti) - Fix iot strip turn on and off from parent [\#639](https://github.com/python-kasa/python-kasa/pull/639) (@Obbay2) **Added support for devices:** -- Add D130\(US\) 1.0 1.1.9 fixture [\#1476](https://github.com/python-kasa/python-kasa/pull/1476) (@sdb9696) -- Add D100C\(US\) 1.0 1.1.3 fixture [\#1475](https://github.com/python-kasa/python-kasa/pull/1475) (@sdb9696) - Add C220\(EU\) 1.0 1.2.2 camera fixture [\#1466](https://github.com/python-kasa/python-kasa/pull/1466) (@DawidPietrykowski) - Add D230\(EU\) 1.20 1.1.19 fixture [\#1448](https://github.com/python-kasa/python-kasa/pull/1448) (@sdb9696) - Add fixture for C720 camera [\#1433](https://github.com/python-kasa/python-kasa/pull/1433) (@steveredden) +- Add D130\(US\) 1.0 1.1.9 fixture [\#1476](https://github.com/python-kasa/python-kasa/pull/1476) (@sdb9696) +- Add D100C\(US\) 1.0 1.1.3 fixture [\#1475](https://github.com/python-kasa/python-kasa/pull/1475) (@sdb9696) **Project maintenance:** -- Update ruff to 0.9 [\#1482](https://github.com/python-kasa/python-kasa/pull/1482) (@sdb9696) -- Cancel in progress CI workflows after new pushes [\#1481](https://github.com/python-kasa/python-kasa/pull/1481) (@sdb9696) -- Update test framework to support smartcam device discovery. [\#1477](https://github.com/python-kasa/python-kasa/pull/1477) (@sdb9696) -- Add error code 7 for clean module [\#1474](https://github.com/python-kasa/python-kasa/pull/1474) (@rytilahti) - Enable CI workflow on PRs to feat/ fix/ and janitor/ [\#1471](https://github.com/python-kasa/python-kasa/pull/1471) (@sdb9696) - Add commit-hook to prettify JSON files [\#1455](https://github.com/python-kasa/python-kasa/pull/1455) (@rytilahti) - Add required sphinx.configuration [\#1446](https://github.com/python-kasa/python-kasa/pull/1446) (@rytilahti) - Add more redactors for smartcams [\#1439](https://github.com/python-kasa/python-kasa/pull/1439) (@sdb9696) - Add KS230\(US\) 2.0 1.0.11 IOT Fixture [\#1430](https://github.com/python-kasa/python-kasa/pull/1430) (@ZeliardM) +- Update ruff to 0.9 [\#1482](https://github.com/python-kasa/python-kasa/pull/1482) (@sdb9696) +- Cancel in progress CI workflows after new pushes [\#1481](https://github.com/python-kasa/python-kasa/pull/1481) (@sdb9696) +- Update test framework to support smartcam device discovery. [\#1477](https://github.com/python-kasa/python-kasa/pull/1477) (@sdb9696) - Add tests for dump\_devinfo parent/child smartcam fixture generation [\#1428](https://github.com/python-kasa/python-kasa/pull/1428) (@sdb9696) - Raise errors on single smartcam child requests [\#1427](https://github.com/python-kasa/python-kasa/pull/1427) (@sdb9696) diff --git a/pyproject.toml b/pyproject.toml index cf8fabf7d..c73907767 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-kasa" -version = "0.10.0" +version = "0.10.1" description = "Python API for TP-Link Kasa and Tapo devices" license = {text = "GPL-3.0-or-later"} authors = [ { name = "python-kasa developers" }] diff --git a/uv.lock b/uv.lock index 1c57719e4..ec695f50b 100644 --- a/uv.lock +++ b/uv.lock @@ -132,29 +132,29 @@ wheels = [ [[package]] name = "attrs" -version = "24.3.0" +version = "25.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/c8/6260f8ccc11f0917360fc0da435c5c9c7504e3db174d5a12a1494887b045/attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984 } +sdist = { url = "https://files.pythonhosted.org/packages/49/7c/fdf464bcc51d23881d110abd74b512a42b3d5d376a55a831b44c603ae17f/attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e", size = 810562 } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 }, + { url = "https://files.pythonhosted.org/packages/fc/30/d4986a882011f9df997a55e6becd864812ccfcd821d64aac8570ee39f719/attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a", size = 63152 }, ] [[package]] name = "babel" -version = "2.16.0" +version = "2.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2a/74/f1bc80f23eeba13393b7222b11d95ca3af2c1e28edca18af487137eefed9/babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316", size = 9348104 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/20/bc79bc575ba2e2a7f70e8a1155618bb1301eaa5132a8271373a6903f73f8/babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", size = 9587599 }, + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, ] [[package]] name = "certifi" -version = "2024.12.14" +version = "2025.1.31" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, ] [[package]] @@ -993,14 +993,14 @@ wheels = [ [[package]] name = "pytest-asyncio" -version = "0.25.2" +version = "0.25.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/df/adcc0d60f1053d74717d21d58c0048479e9cab51464ce0d2965b086bd0e2/pytest_asyncio-0.25.2.tar.gz", hash = "sha256:3f8ef9a98f45948ea91a0ed3dc4268b5326c0e7bce73892acc654df4262ad45f", size = 53950 } +sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239 } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/d8/defa05ae50dcd6019a95527200d3b3980043df5aa445d40cb0ef9f7f98ab/pytest_asyncio-0.25.2-py3-none-any.whl", hash = "sha256:0d0bb693f7b99da304a0634afc0a4b19e49d5e0de2d670f38dc4bfa5727c5075", size = 19400 }, + { url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467 }, ] [[package]] @@ -1106,7 +1106,7 @@ wheels = [ [[package]] name = "python-kasa" -version = "0.10.0" +version = "0.10.1" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -1258,27 +1258,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.9.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1e/7f/60fda2eec81f23f8aa7cbbfdf6ec2ca11eb11c273827933fb2541c2ce9d8/ruff-0.9.3.tar.gz", hash = "sha256:8293f89985a090ebc3ed1064df31f3b4b56320cdfcec8b60d3295bddb955c22a", size = 3586740 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/77/4fb790596d5d52c87fd55b7160c557c400e90f6116a56d82d76e95d9374a/ruff-0.9.3-py3-none-linux_armv6l.whl", hash = "sha256:7f39b879064c7d9670197d91124a75d118d00b0990586549949aae80cdc16624", size = 11656815 }, - { url = "https://files.pythonhosted.org/packages/a2/a8/3338ecb97573eafe74505f28431df3842c1933c5f8eae615427c1de32858/ruff-0.9.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a187171e7c09efa4b4cc30ee5d0d55a8d6c5311b3e1b74ac5cb96cc89bafc43c", size = 11594821 }, - { url = "https://files.pythonhosted.org/packages/8e/89/320223c3421962762531a6b2dd58579b858ca9916fb2674874df5e97d628/ruff-0.9.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c59ab92f8e92d6725b7ded9d4a31be3ef42688a115c6d3da9457a5bda140e2b4", size = 11040475 }, - { url = "https://files.pythonhosted.org/packages/b2/bd/1d775eac5e51409535804a3a888a9623e87a8f4b53e2491580858a083692/ruff-0.9.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc153c25e715be41bb228bc651c1e9b1a88d5c6e5ed0194fa0dfea02b026439", size = 11856207 }, - { url = "https://files.pythonhosted.org/packages/7f/c6/3e14e09be29587393d188454064a4aa85174910d16644051a80444e4fd88/ruff-0.9.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:646909a1e25e0dc28fbc529eab8eb7bb583079628e8cbe738192853dbbe43af5", size = 11420460 }, - { url = "https://files.pythonhosted.org/packages/ef/42/b7ca38ffd568ae9b128a2fa76353e9a9a3c80ef19746408d4ce99217ecc1/ruff-0.9.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a5a46e09355695fbdbb30ed9889d6cf1c61b77b700a9fafc21b41f097bfbba4", size = 12605472 }, - { url = "https://files.pythonhosted.org/packages/a6/a1/3167023f23e3530fde899497ccfe239e4523854cb874458ac082992d206c/ruff-0.9.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c4bb09d2bbb394e3730d0918c00276e79b2de70ec2a5231cd4ebb51a57df9ba1", size = 13243123 }, - { url = "https://files.pythonhosted.org/packages/d0/b4/3c600758e320f5bf7de16858502e849f4216cb0151f819fa0d1154874802/ruff-0.9.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96a87ec31dc1044d8c2da2ebbed1c456d9b561e7d087734336518181b26b3aa5", size = 12744650 }, - { url = "https://files.pythonhosted.org/packages/be/38/266fbcbb3d0088862c9bafa8b1b99486691d2945a90b9a7316336a0d9a1b/ruff-0.9.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb7554aca6f842645022fe2d301c264e6925baa708b392867b7a62645304df4", size = 14458585 }, - { url = "https://files.pythonhosted.org/packages/63/a6/47fd0e96990ee9b7a4abda62de26d291bd3f7647218d05b7d6d38af47c30/ruff-0.9.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabc332b7075a914ecea912cd1f3d4370489c8018f2c945a30bcc934e3bc06a6", size = 12419624 }, - { url = "https://files.pythonhosted.org/packages/84/5d/de0b7652e09f7dda49e1a3825a164a65f4998175b6486603c7601279baad/ruff-0.9.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:33866c3cc2a575cbd546f2cd02bdd466fed65118e4365ee538a3deffd6fcb730", size = 11843238 }, - { url = "https://files.pythonhosted.org/packages/9e/be/3f341ceb1c62b565ec1fb6fd2139cc40b60ae6eff4b6fb8f94b1bb37c7a9/ruff-0.9.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:006e5de2621304c8810bcd2ee101587712fa93b4f955ed0985907a36c427e0c2", size = 11484012 }, - { url = "https://files.pythonhosted.org/packages/a3/c8/ff8acbd33addc7e797e702cf00bfde352ab469723720c5607b964491d5cf/ruff-0.9.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ba6eea4459dbd6b1be4e6bfc766079fb9b8dd2e5a35aff6baee4d9b1514ea519", size = 12038494 }, - { url = "https://files.pythonhosted.org/packages/73/b1/8d9a2c0efbbabe848b55f877bc10c5001a37ab10aca13c711431673414e5/ruff-0.9.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:90230a6b8055ad47d3325e9ee8f8a9ae7e273078a66401ac66df68943ced029b", size = 12473639 }, - { url = "https://files.pythonhosted.org/packages/cb/44/a673647105b1ba6da9824a928634fe23186ab19f9d526d7bdf278cd27bc3/ruff-0.9.3-py3-none-win32.whl", hash = "sha256:eabe5eb2c19a42f4808c03b82bd313fc84d4e395133fb3fc1b1516170a31213c", size = 9834353 }, - { url = "https://files.pythonhosted.org/packages/c3/01/65cadb59bf8d4fbe33d1a750103e6883d9ef302f60c28b73b773092fbde5/ruff-0.9.3-py3-none-win_amd64.whl", hash = "sha256:040ceb7f20791dfa0e78b4230ee9dce23da3b64dd5848e40e3bf3ab76468dcf4", size = 10821444 }, - { url = "https://files.pythonhosted.org/packages/69/cb/b3fe58a136a27d981911cba2f18e4b29f15010623b79f0f2510fd0d31fd3/ruff-0.9.3-py3-none-win_arm64.whl", hash = "sha256:800d773f6d4d33b0a3c60e2c6ae8f4c202ea2de056365acfa519aa48acf28e0b", size = 10038168 }, +version = "0.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/17/529e78f49fc6f8076f50d985edd9a2cf011d1dbadb1cdeacc1d12afc1d26/ruff-0.9.4.tar.gz", hash = "sha256:6907ee3529244bb0ed066683e075f09285b38dd5b4039370df6ff06041ca19e7", size = 3599458 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/f8/3fafb7804d82e0699a122101b5bee5f0d6e17c3a806dcbc527bb7d3f5b7a/ruff-0.9.4-py3-none-linux_armv6l.whl", hash = "sha256:64e73d25b954f71ff100bb70f39f1ee09e880728efb4250c632ceed4e4cdf706", size = 11668400 }, + { url = "https://files.pythonhosted.org/packages/2e/a6/2efa772d335da48a70ab2c6bb41a096c8517ca43c086ea672d51079e3d1f/ruff-0.9.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6ce6743ed64d9afab4fafeaea70d3631b4d4b28b592db21a5c2d1f0ef52934bf", size = 11628395 }, + { url = "https://files.pythonhosted.org/packages/dc/d7/cd822437561082f1c9d7225cc0d0fbb4bad117ad7ac3c41cd5d7f0fa948c/ruff-0.9.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:54499fb08408e32b57360f6f9de7157a5fec24ad79cb3f42ef2c3f3f728dfe2b", size = 11090052 }, + { url = "https://files.pythonhosted.org/packages/9e/67/3660d58e893d470abb9a13f679223368ff1684a4ef40f254a0157f51b448/ruff-0.9.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37c892540108314a6f01f105040b5106aeb829fa5fb0561d2dcaf71485021137", size = 11882221 }, + { url = "https://files.pythonhosted.org/packages/79/d1/757559995c8ba5f14dfec4459ef2dd3fcea82ac43bc4e7c7bf47484180c0/ruff-0.9.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de9edf2ce4b9ddf43fd93e20ef635a900e25f622f87ed6e3047a664d0e8f810e", size = 11424862 }, + { url = "https://files.pythonhosted.org/packages/c0/96/7915a7c6877bb734caa6a2af424045baf6419f685632469643dbd8eb2958/ruff-0.9.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87c90c32357c74f11deb7fbb065126d91771b207bf9bfaaee01277ca59b574ec", size = 12626735 }, + { url = "https://files.pythonhosted.org/packages/0e/cc/dadb9b35473d7cb17c7ffe4737b4377aeec519a446ee8514123ff4a26091/ruff-0.9.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:56acd6c694da3695a7461cc55775f3a409c3815ac467279dfa126061d84b314b", size = 13255976 }, + { url = "https://files.pythonhosted.org/packages/5f/c3/ad2dd59d3cabbc12df308cced780f9c14367f0321e7800ca0fe52849da4c/ruff-0.9.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0c93e7d47ed951b9394cf352d6695b31498e68fd5782d6cbc282425655f687a", size = 12752262 }, + { url = "https://files.pythonhosted.org/packages/c7/17/5f1971e54bd71604da6788efd84d66d789362b1105e17e5ccc53bba0289b/ruff-0.9.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d4c8772670aecf037d1bf7a07c39106574d143b26cfe5ed1787d2f31e800214", size = 14401648 }, + { url = "https://files.pythonhosted.org/packages/30/24/6200b13ea611b83260501b6955b764bb320e23b2b75884c60ee7d3f0b68e/ruff-0.9.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfc5f1d7afeda8d5d37660eeca6d389b142d7f2b5a1ab659d9214ebd0e025231", size = 12414702 }, + { url = "https://files.pythonhosted.org/packages/34/cb/f5d50d0c4ecdcc7670e348bd0b11878154bc4617f3fdd1e8ad5297c0d0ba/ruff-0.9.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:faa935fc00ae854d8b638c16a5f1ce881bc3f67446957dd6f2af440a5fc8526b", size = 11859608 }, + { url = "https://files.pythonhosted.org/packages/d6/f4/9c8499ae8426da48363bbb78d081b817b0f64a9305f9b7f87eab2a8fb2c1/ruff-0.9.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6c634fc6f5a0ceae1ab3e13c58183978185d131a29c425e4eaa9f40afe1e6d6", size = 11485702 }, + { url = "https://files.pythonhosted.org/packages/18/59/30490e483e804ccaa8147dd78c52e44ff96e1c30b5a95d69a63163cdb15b/ruff-0.9.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:433dedf6ddfdec7f1ac7575ec1eb9844fa60c4c8c2f8887a070672b8d353d34c", size = 12067782 }, + { url = "https://files.pythonhosted.org/packages/3d/8c/893fa9551760b2f8eb2a351b603e96f15af167ceaf27e27ad873570bc04c/ruff-0.9.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d612dbd0f3a919a8cc1d12037168bfa536862066808960e0cc901404b77968f0", size = 12483087 }, + { url = "https://files.pythonhosted.org/packages/23/15/f6751c07c21ca10e3f4a51ea495ca975ad936d780c347d9808bcedbd7182/ruff-0.9.4-py3-none-win32.whl", hash = "sha256:db1192ddda2200671f9ef61d9597fcef89d934f5d1705e571a93a67fb13a4402", size = 9852302 }, + { url = "https://files.pythonhosted.org/packages/12/41/2d2d2c6a72e62566f730e49254f602dfed23019c33b5b21ea8f8917315a1/ruff-0.9.4-py3-none-win_amd64.whl", hash = "sha256:05bebf4cdbe3ef75430d26c375773978950bbf4ee3c95ccb5448940dc092408e", size = 10850051 }, + { url = "https://files.pythonhosted.org/packages/c6/e6/3d6ec3bc3d254e7f005c543a661a41c3e788976d0e52a1ada195bd664344/ruff-0.9.4-py3-none-win_arm64.whl", hash = "sha256:585792f1e81509e38ac5123492f8875fbc36f3ede8185af0a26df348e5154f41", size = 10078251 }, ] [[package]] From d5187dc6f120c3265d350f449a418ffeda6de6bb Mon Sep 17 00:00:00 2001 From: EdwardWu Date: Fri, 7 Feb 2025 16:02:21 +0800 Subject: [PATCH 852/892] Add L530E(TW) 2.0 1.1.1 fixture (#1497) --- SUPPORTED.md | 1 + tests/fixtures/smart/L530E(TW)_2.0_1.1.1.json | 616 ++++++++++++++++++ 2 files changed, 617 insertions(+) create mode 100644 tests/fixtures/smart/L530E(TW)_2.0_1.1.1.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 876566cd6..e631d640b 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -247,6 +247,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 3.0 (EU) / Firmware: 1.0.6 - Hardware: 3.0 (EU) / Firmware: 1.1.0 - Hardware: 3.0 (EU) / Firmware: 1.1.6 + - Hardware: 2.0 (TW) / Firmware: 1.1.1 - Hardware: 2.0 (US) / Firmware: 1.1.0 - **L630** - Hardware: 1.0 (EU) / Firmware: 1.1.2 diff --git a/tests/fixtures/smart/L530E(TW)_2.0_1.1.1.json b/tests/fixtures/smart/L530E(TW)_2.0_1.1.1.json new file mode 100644 index 000000000..145c93f42 --- /dev/null +++ b/tests/fixtures/smart/L530E(TW)_2.0_1.1.1.json @@ -0,0 +1,616 @@ +{ + "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": 3 + }, + { + "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": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530E(TW)", + "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", + "protocol_version": 1 + } + }, + "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": 6500, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 100, + "color_temp": 6500, + "hue": 0, + "saturation": 0 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.1 Build 240623 Rel.114041", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "2.0", + "ip": "127.0.0.123", + "lang": "zh_TW", + "latitude": 0, + "longitude": 0, + "mac": "5C-62-8B-00-00-00", + "model": "L530", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Asia/Taipei", + "rssi": -44, + "saturation": 0, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 480, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Asia/Taipei", + "time_diff": 480, + "timestamp": 1738811667 + }, + "get_device_usage": { + "power_usage": { + "past30": 17, + "past7": 17, + "today": 17 + }, + "saved_power": { + "past30": 416, + "past7": 416, + "today": 416 + }, + "time_usage": { + "past30": 433, + "past7": 433, + "today": 433 + } + }, + "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.1 Build 240623 Rel.114041", + "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": { + "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": [ + { + "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==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 20, + "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", + "is_klap": true + } + } +} From 668e32d3a5ab66b17126d043dccb3b487e096a2b Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Mon, 10 Feb 2025 12:13:01 +0100 Subject: [PATCH 853/892] Do not crash on missing build number in fw version (#1500) Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- devtools/dump_devinfo.py | 17 - devtools/helpers/smartrequests.py | 1 + kasa/cli/device.py | 2 +- kasa/device.py | 2 +- kasa/iot/iotdevice.py | 5 +- kasa/smart/smartdevice.py | 5 +- kasa/smartcam/smartcamchild.py | 5 +- kasa/smartcam/smartcamdevice.py | 5 +- .../smart/child/T310(US)_1.0_1.5.0.json | 426 +++++++++++++++++- 9 files changed, 428 insertions(+), 40 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index a0fff0e5c..bbe1e8130 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -725,15 +725,6 @@ async def get_smart_test_calls(protocol: SmartProtocol): successes = [] child_device_components = {} - extra_test_calls = [ - SmartCall( - module="temp_humidity_records", - request=SmartRequest.get_raw_request("get_temp_humidity_records").to_dict(), - should_succeed=False, - child_device_id="", - ), - ] - click.echo("Testing component_nego call ..", nl=False) responses = await _make_requests_or_exit( protocol, @@ -812,8 +803,6 @@ async def get_smart_test_calls(protocol: SmartProtocol): click.echo(f"Skipping {component_id}..", nl=False) click.echo(click.style("UNSUPPORTED", fg="yellow")) - test_calls.extend(extra_test_calls) - # Child component calls for child_device_id, child_components in child_device_components.items(): test_calls.append( @@ -839,12 +828,6 @@ async def get_smart_test_calls(protocol: SmartProtocol): else: click.echo(f"Skipping {component_id}..", nl=False) click.echo(click.style("UNSUPPORTED", fg="yellow")) - # Add the extra calls for each child - for extra_call in extra_test_calls: - extra_child_call = dataclasses.replace( - extra_call, child_device_id=child_device_id - ) - test_calls.append(extra_child_call) return test_calls, successes diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 3756cb956..1ff379160 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -425,6 +425,7 @@ def get_component_requests(component_id, ver_code): "get_trigger_logs", SmartRequest.GetTriggerLogsParams() ) ], + "temp_humidity_record": [SmartRequest.get_raw_request("get_temp_humidity_records")], "double_click": [SmartRequest.get_raw_request("get_double_click_info")], "child_device": [ SmartRequest.get_raw_request("get_child_device_list"), diff --git a/kasa/cli/device.py b/kasa/cli/device.py index a10f485d4..7610a7cdf 100644 --- a/kasa/cli/device.py +++ b/kasa/cli/device.py @@ -48,7 +48,7 @@ async def state(ctx, dev: Device): ) echo( f"Firmware: {dev.device_info.firmware_version}" - f" {dev.device_info.firmware_build}" + f"{' ' + build if (build := dev.device_info.firmware_build) else ''}" ) echo(f"MAC (rssi): {dev.mac} ({dev.rssi})") if verbose: diff --git a/kasa/device.py b/kasa/device.py index d86a565e4..c4ea41e2e 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -161,7 +161,7 @@ class DeviceInfo: device_type: DeviceType hardware_version: str firmware_version: str - firmware_build: str + firmware_build: str | None requires_auth: bool region: str | None diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 851f21ccc..d1de7f9e6 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -760,7 +760,10 @@ def _get_device_info( device_family = sys_info.get("type", sys_info.get("mic_type")) device_type = IotDevice._get_device_type_from_sys_info(info) fw_version_full = sys_info["sw_ver"] - firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + if " " in fw_version_full: + firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + else: + firmware_version, firmware_build = fw_version_full, None auth = bool(discovery_info and ("mgt_encrypt_schm" in discovery_info)) return DeviceInfo( diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index f2daf0d79..2e2dc7cd5 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -913,7 +913,10 @@ def _get_device_info( components, device_family ) fw_version_full = di["fw_ver"] - firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + if " " in fw_version_full: + firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + else: + firmware_version, firmware_build = fw_version_full, None _protocol, devicetype = device_family.split(".") # Brand inferred from SMART.KASAPLUG/SMART.TAPOPLUG etc. brand = devicetype[:4].lower() diff --git a/kasa/smartcam/smartcamchild.py b/kasa/smartcam/smartcamchild.py index d26144647..cb9d8e989 100644 --- a/kasa/smartcam/smartcamchild.py +++ b/kasa/smartcam/smartcamchild.py @@ -103,7 +103,10 @@ def _get_device_info( model = cifp["device_model"] device_type = SmartCamDevice._get_device_type_from_sysinfo(cifp) fw_version_full = cifp["sw_ver"] - firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + if " " in fw_version_full: + firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + else: + firmware_version, firmware_build = fw_version_full, None return DeviceInfo( short_name=model, long_name=model, diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index 1bf58532f..3beda36bc 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -47,7 +47,10 @@ def _get_device_info( long_name = discovery_info["device_model"] if discovery_info else short_name device_type = SmartCamDevice._get_device_type_from_sysinfo(basic_info) fw_version_full = basic_info["sw_version"] - firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + if " " in fw_version_full: + firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + else: + firmware_version, firmware_build = fw_version_full, None return DeviceInfo( short_name=basic_info["device_model"], long_name=long_name, diff --git a/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json b/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json index bdc4eef69..c06ff49f1 100644 --- a/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json +++ b/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json @@ -75,7 +75,6 @@ } ] }, - "get_auto_update_info": -1001, "get_connect_cloud_state": { "status": 0 }, @@ -84,25 +83,25 @@ "avatar": "sensor_t310", "bind_count": 1, "category": "subg.trigger.temp-hmdt-sensor", - "current_humidity": 49, - "current_humidity_exception": 0, - "current_temp": 21.7, + "current_humidity": 61, + "current_humidity_exception": 1, + "current_temp": 21.0, "current_temp_exception": 0, "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", - "fw_ver": "1.5.0 Build 230105 Rel.180832", + "fw_ver": "1.5.0", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -111, - "jamming_signal_level": 1, - "lastOnboardingTimestamp": 1724637745, - "mac": "F0A731000000", + "jamming_rssi": -108, + "jamming_signal_level": 2, + "lastOnboardingTimestamp": 1690859014, + "mac": "788CB5000000", "model": "T310", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "parent_device_id": "0000000000000000000000000000000000000000", - "region": "Australia/Canberra", - "report_interval": 16, - "rssi": -46, + "region": "Pacific/Auckland", + "report_interval": 8, + "rssi": -56, "signal_level": 3, "specs": "US", "status": "online", @@ -110,8 +109,6 @@ "temp_unit": "celsius", "type": "SMART.TAPOSENSOR" }, - "get_device_time": -1001, - "get_device_usage": -1001, "get_fw_download_state": { "cloud_cache_seconds": 1, "download_progress": 0, @@ -121,7 +118,7 @@ }, "get_latest_fw": { "fw_size": 0, - "fw_ver": "1.5.0 Build 230105 Rel.180832", + "fw_ver": "1.5.0", "hw_id": "", "need_to_upgrade": false, "oem_id": "", @@ -129,10 +126,405 @@ "release_note": "", "type": 0 }, + "get_temp_humidity_records": { + "local_time": 1739107441, + "past24h_humidity": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + 58, + 57, + 57, + 57, + 56, + 56, + 55, + 55, + 55, + 55, + 54, + 54, + 55, + 56, + 57, + 57, + 58, + 58, + 58, + 58, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 61, + 82, + 59, + 60, + 61, + 61, + 61, + 61 + ], + "past24h_humidity_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 22, + 0, + 0, + 1, + 1, + 1, + 1 + ], + "past24h_temp": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + 213, + 213, + 212, + 211, + 210, + 208, + 207, + 206, + 205, + 204, + 203, + 202, + 201, + 202, + 203, + 205, + 206, + 208, + 209, + 210, + 210, + 211, + 211, + 212, + 212, + 212, + 212, + 212, + 212, + 212, + 213, + 213, + 213, + 213, + 213, + 213, + 213, + 215, + 254, + 221, + 214, + 212, + 211, + 210, + 210 + ], + "past24h_temp_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "temp_unit": "celsius" + }, "get_trigger_logs": { "logs": [], "start_id": 0, "sum": 0 - }, - "qs_component_nego": -1001 + } } From ad8a0eebece489414be3fbdac6d39287e043139b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 12 Feb 2025 11:38:39 +0000 Subject: [PATCH 854/892] Add L530B(EU) 3.0 1.1.9 fixture (#1502) --- README.md | 2 +- SUPPORTED.md | 2 + tests/fixtures/smart/L530B(EU)_3.0_1.1.9.json | 480 ++++++++++++++++++ 3 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smart/L530B(EU)_3.0_1.1.9.json diff --git a/README.md b/README.md index b4bbf81bd..497175516 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15 - **Power Strips**: P210M, P300, P304M, P306, TP25 - **Wall Switches**: S210, S220, S500D, S505, S505D -- **Bulbs**: L510B, L510E, L530E, L630 +- **Bulbs**: L510B, L510E, L530B, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Cameras**: C100, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70 - **Doorbells and chimes**: D100C, D130, D230 diff --git a/SUPPORTED.md b/SUPPORTED.md index e631d640b..57ff6609c 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -243,6 +243,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **L510E** - Hardware: 3.0 (US) / Firmware: 1.0.5 - Hardware: 3.0 (US) / Firmware: 1.1.2 +- **L530B** + - Hardware: 3.0 (EU) / Firmware: 1.1.9 - **L530E** - Hardware: 3.0 (EU) / Firmware: 1.0.6 - Hardware: 3.0 (EU) / Firmware: 1.1.0 diff --git a/tests/fixtures/smart/L530B(EU)_3.0_1.1.9.json b/tests/fixtures/smart/L530B(EU)_3.0_1.1.9.json new file mode 100644 index 000000000..4199077cb --- /dev/null +++ b/tests/fixtures/smart/L530B(EU)_3.0_1.1.9.json @@ -0,0 +1,480 @@ +{ + "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": 3 + }, + { + "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": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530B(EU)", + "device_type": "SMART.TAPOBULB", + "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": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "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": 10, + "color_temp": 4000, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 10, + "color_temp": 4000, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.9 Build 240524 Rel.144922", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "model": "L530", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Auckland", + "rssi": -55, + "saturation": 100, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 720, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Pacific/Auckland", + "time_diff": 720, + "timestamp": 1739230276 + }, + "get_device_usage": { + "power_usage": { + "past30": 437, + "past7": 88, + "today": 2 + }, + "saved_power": { + "past30": 7987, + "past7": 2005, + "today": 62 + }, + "time_usage": { + "past30": 8424, + "past7": 2093, + "today": 64 + } + }, + "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.9 Build 240524 Rel.144922", + "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": { + "states": [ + { + "brightness": 100, + "color_temp": 4000, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 50, + "color_temp": 4000, + "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": 240, + "saturation": 100 + }, + { + "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": [ + { + "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==" + } + ], + "start_index": 0, + "sum": 3, + "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", + "is_klap": true + } + } +} From 8b138698b8de21a1e493ca2183f0e97fdb9a8af7 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 12 Feb 2025 11:41:16 +0000 Subject: [PATCH 855/892] Add C110(EU) 2.0 1.4.3 fixture (#1503) --- README.md | 2 +- SUPPORTED.md | 2 + .../fixtures/smartcam/C110(EU)_2.0_1.4.3.json | 960 ++++++++++++++++++ 3 files changed, 963 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smartcam/C110(EU)_2.0_1.4.3.json diff --git a/README.md b/README.md index 497175516..da2c0ce43 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: S210, S220, S500D, S505, S505D - **Bulbs**: L510B, L510E, L530B, L530E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Cameras**: C100, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70 +- **Cameras**: C100, C110, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70 - **Doorbells and chimes**: D100C, D130, D230 - **Vacuums**: RV20 Max Plus, RV30 Max - **Hubs**: H100, H200 diff --git a/SUPPORTED.md b/SUPPORTED.md index 57ff6609c..7526f8d5f 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -274,6 +274,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **C100** - Hardware: 4.0 / Firmware: 1.3.14 +- **C110** + - Hardware: 2.0 (EU) / Firmware: 1.4.3 - **C210** - Hardware: 2.0 / Firmware: 1.3.11 - Hardware: 2.0 (EU) / Firmware: 1.4.2 diff --git a/tests/fixtures/smartcam/C110(EU)_2.0_1.4.3.json b/tests/fixtures/smartcam/C110(EU)_2.0_1.4.3.json new file mode 100644 index 000000000..2e78ceb6a --- /dev/null +++ b/tests/fixtures/smartcam/C110(EU)_2.0_1.4.3.json @@ -0,0 +1,960 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "0", + "last_alarm_type": "", + "owner": "00000000000000000000000000000000", + "sd_status": "normal" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C110", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.4.3 Build 240919 Rel.70035n", + "hardware_version": "2.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "98-25-4A-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "15", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Tone" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 2 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "staticIp", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-02-11 12:32:27", + "seconds_from_1970": 1739230347 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "3", + "rssiValue": -51, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c100", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C110 2.0 IPC", + "device_model": "C110", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "2.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "98-25-4A-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.4.3 Build 240919 Rel.70035n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "0", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "off" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "off" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "normal", + "disk_name": "1", + "free_space": "113.3GB", + "free_space_accurate": "121601261568B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "100", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "1734403667", + "rw_attr": "rw", + "status": "normal", + "total_space": "113.5GB", + "total_space_accurate": "121869697024B", + "type": "local", + "video_free_space": "113.3GB", + "video_free_space_accurate": "121601261568B", + "video_total_space": "113.5GB", + "video_total_space_accurate": "121869697024B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "on", + "sensitivity": "medium" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+12:00", + "timing_mode": "ntp", + "zone_id": "Pacific/Auckland" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1382", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561" + ], + "minor_stream_support": "0", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2304*1296", + "1920*1080", + "1280*720" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1382", + "bitrate_type": "vbr", + "default_bitrate": "1382", + "encode_type": "H264", + "frame_rate": "65566", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2304*1296", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8", + "16" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "1.1" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "1.3" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "1.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "0", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "1" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } + } +} From 29195fa639aadc5b85c898cfe8bb9a3cb76e4fad Mon Sep 17 00:00:00 2001 From: Alex Thomson <10443061+LXGaming@users.noreply.github.com> Date: Thu, 13 Feb 2025 00:45:53 +1300 Subject: [PATCH 856/892] Add fixtures for new versions of H100, P110, and T100 devices (#1501) --- SUPPORTED.md | 3 + tests/fixtures/smart/H100(AU)_1.0_1.5.23.json | 513 ++++++++++++++++++ tests/fixtures/smart/P110(AU)_1.0_1.3.1.json | 460 ++++++++++++++++ .../smart/child/T100(US)_1.0_1.12.0.json | 141 +++++ 4 files changed, 1117 insertions(+) create mode 100644 tests/fixtures/smart/H100(AU)_1.0_1.5.23.json create mode 100644 tests/fixtures/smart/P110(AU)_1.0_1.3.1.json create mode 100644 tests/fixtures/smart/child/T100(US)_1.0_1.12.0.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 7526f8d5f..813fa65c3 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -191,6 +191,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0.0 (US) / Firmware: 1.3.7 - Hardware: 1.0.0 (US) / Firmware: 1.4.0 - **P110** + - Hardware: 1.0 (AU) / Firmware: 1.3.1 - Hardware: 1.0 (EU) / Firmware: 1.0.7 - Hardware: 1.0 (EU) / Firmware: 1.2.3 - Hardware: 1.0 (UK) / Firmware: 1.3.0 @@ -314,6 +315,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros ### Hubs - **H100** + - Hardware: 1.0 (AU) / Firmware: 1.5.23 - Hardware: 1.0 (EU) / Firmware: 1.2.3 - Hardware: 1.0 (EU) / Firmware: 1.5.10 - Hardware: 1.0 (EU) / Firmware: 1.5.5 @@ -332,6 +334,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.12.0 - **T100** - Hardware: 1.0 (EU) / Firmware: 1.12.0 + - Hardware: 1.0 (US) / Firmware: 1.12.0 - **T110** - Hardware: 1.0 (EU) / Firmware: 1.8.0 - Hardware: 1.0 (EU) / Firmware: 1.9.0 diff --git a/tests/fixtures/smart/H100(AU)_1.0_1.5.23.json b/tests/fixtures/smart/H100(AU)_1.0_1.5.23.json new file mode 100644 index 000000000..69bad6ded --- /dev/null +++ b/tests/fixtures/smart/H100(AU)_1.0_1.5.23.json @@ -0,0 +1,513 @@ +{ + "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": "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": "child_device", + "ver_code": 1 + }, + { + "id": "child_quick_setup", + "ver_code": 1 + }, + { + "id": "child_inherit", + "ver_code": 1 + }, + { + "id": "control_child", + "ver_code": 1 + }, + { + "id": "alarm", + "ver_code": 1 + }, + { + "id": "device_load", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "alarm_logs", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 3 + }, + { + "id": "chime", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "H100(AU)", + "device_type": "SMART.TAPOHUB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "9C-A2-F4-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_alarm_configure": { + "duration": 300, + "type": "Connection 2", + "volume": "low" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "sensitivity", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 61, + "current_humidity_exception": 1, + "current_temp": 19.5, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.5.0", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -105, + "jamming_signal_level": 2, + "lastOnboardingTimestamp": 1690859014, + "mac": "788CB5000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Pacific/Auckland", + "report_interval": 8, + "rssi": -57, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "sensor", + "bind_count": 1, + "category": "subg.trigger.motion-sensor", + "detected": true, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.12.0 Build 230512 Rel.103040", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -115, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1734051318, + "mac": "E4FAC4000000", + "model": "T100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Pacific/Auckland", + "report_interval": 16, + "rssi": -59, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.5.23 Build 241106 Rel.093525", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "in_alarm_source": "", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "9C-A2-F4-00-00-00", + "model": "H100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Auckland", + "rssi": -52, + "signal_level": 2, + "specs": "AU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 720, + "type": "SMART.TAPOHUB" + }, + "get_device_load_info": { + "cur_load_num": 3, + "load_level": "light", + "max_load_num": 64, + "total_memory": 4352, + "used_memory": 1433 + }, + "get_device_time": { + "region": "Pacific/Auckland", + "time_diff": 720, + "timestamp": 1739230245 + }, + "get_device_usage": {}, + "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.5.23 Build 241106 Rel.093525", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "never", + "led_status": false, + "night_mode": { + "end_time": 410, + "night_mode_type": "sunrise_sunset", + "start_time": 1252, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_support_alarm_type_list": { + "alarm_type_list": [ + "Doorbell Ring 1", + "Doorbell Ring 2", + "Doorbell Ring 3", + "Doorbell Ring 4", + "Doorbell Ring 5", + "Doorbell Ring 6", + "Doorbell Ring 7", + "Doorbell Ring 8", + "Doorbell Ring 9", + "Doorbell Ring 10", + "Phone Ring", + "Alarm 1", + "Alarm 2", + "Alarm 3", + "Alarm 4", + "Dripping Tap", + "Alarm 5", + "Connection 1", + "Connection 2" + ] + }, + "get_support_child_device_category": { + "device_category_list": [ + { + "category": "subg.trv" + }, + { + "category": "subg.trigger" + }, + { + "category": "subg.plugswitch" + } + ] + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "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==" + } + ], + "start_index": 0, + "sum": 3, + "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 + }, + { + "id": "matter", + "ver_code": 3 + } + ], + "extra_info": { + "device_model": "H100", + "device_type": "SMART.TAPOHUB", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/P110(AU)_1.0_1.3.1.json b/tests/fixtures/smart/P110(AU)_1.0_1.3.1.json new file mode 100644 index 000000000..bfd5d7854 --- /dev/null +++ b/tests/fixtures/smart/P110(AU)_1.0_1.3.1.json @@ -0,0 +1,460 @@ +{ + "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": "localSmart", + "ver_code": 1 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110(AU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "9C-53-22-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "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", + "charging_status": "normal", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.3.1 Build 240621 Rel.162048", + "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": "9C-53-22-00-00-00", + "model": "P110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overcurrent_status": "normal", + "overheat_status": "normal", + "power_protection_status": "normal", + "region": "Pacific/Auckland", + "rssi": -53, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 720, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Pacific/Auckland", + "time_diff": 720, + "timestamp": 1739230299 + }, + "get_device_usage": { + "power_usage": { + "past30": 11, + "past7": 2, + "today": 0 + }, + "saved_power": { + "past30": 0, + "past7": 8, + "today": 0 + }, + "time_usage": { + "past30": 10, + "past7": 10, + "today": 0 + } + }, + "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_emeter_data": { + "current_ma": 0, + "energy_wh": 0, + "power_mw": 0, + "voltage_mv": 238609 + }, + "get_emeter_vgain_igain": { + "igain": 11437, + "vgain": 127146 + }, + "get_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-02-11 12:31:41", + "month_energy": 4, + "month_runtime": 10, + "today_energy": 0, + "today_runtime": 0 + }, + "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.3.1 Build 240621 Rel.162048", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": false, + "night_mode": { + "end_time": 410, + "night_mode_type": "sunrise_sunset", + "start_time": 1252, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_max_power": { + "max_power": 2541 + }, + "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": [ + { + "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==" + } + ], + "start_index": 0, + "sum": 3, + "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": "P110", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/child/T100(US)_1.0_1.12.0.json b/tests/fixtures/smart/child/T100(US)_1.0_1.12.0.json new file mode 100644 index 000000000..e5d7915e2 --- /dev/null +++ b/tests/fixtures/smart/child/T100(US)_1.0_1.12.0.json @@ -0,0 +1,141 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "sensitivity", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor", + "bind_count": 1, + "category": "subg.trigger.motion-sensor", + "detected": true, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.12.0 Build 230512 Rel.103040", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -115, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1734051318, + "mac": "E4FAC4000000", + "model": "T100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Pacific/Auckland", + "report_interval": 16, + "rssi": -59, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.12.0 Build 230512 Rel.103040", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_trigger_logs": { + "logs": [ + { + "event": "motion", + "eventId": "51281c8e-c763-3914-0281-c8ec76339140", + "id": 24, + "timestamp": 1739230242 + }, + { + "event": "motion", + "eventId": "120180c0-e874-b251-2018-0c0e874b2512", + "id": 23, + "timestamp": 1739230209 + }, + { + "event": "motion", + "eventId": "752388d5-7ba4-c378-adc7-72a845b3c875", + "id": 22, + "timestamp": 1739230188 + }, + { + "event": "motion", + "eventId": "efa20c53-74e7-264e-fa20-c5374e7264ef", + "id": 21, + "timestamp": 1739230153 + }, + { + "event": "motion", + "eventId": "962d70de-0962-df09-62d7-0de0962df096", + "id": 20, + "timestamp": 1739230137 + } + ], + "start_id": 24, + "sum": 24 + } +} From f488492c7d2b4dfe76341652d9b16f4a458b6fb9 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 12 Feb 2025 19:29:44 +0000 Subject: [PATCH 857/892] Prepare 0.10.2 (#1505) ## [0.10.2](https://github.com/python-kasa/python-kasa/tree/0.10.2) (2025-02-12) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.10.1...0.10.2) **Release summary:** - Bugfix for [#1499](https://github.com/python-kasa/python-kasa/issues/1499). - Support for L530B and C110 devices. **Fixed bugs:** - H100 - Raised error: not enough values to unpack \(expected 2, got 1\) [\#1499](https://github.com/python-kasa/python-kasa/issues/1499) - Do not crash on missing build number in fw version [\#1500](https://github.com/python-kasa/python-kasa/pull/1500) (@rytilahti) **Added support for devices:** - Add C110\(EU\) 2.0 1.4.3 fixture [\#1503](https://github.com/python-kasa/python-kasa/pull/1503) (@sdb9696) - Add L530B\(EU\) 3.0 1.1.9 fixture [\#1502](https://github.com/python-kasa/python-kasa/pull/1502) (@sdb9696) **Project maintenance:** - Add fixtures for new versions of H100, P110, and T100 devices [\#1501](https://github.com/python-kasa/python-kasa/pull/1501) (@LXGaming) - Add L530E\(TW\) 2.0 1.1.1 fixture [\#1497](https://github.com/python-kasa/python-kasa/pull/1497) (@bluehomewu) --- .pre-commit-config.yaml | 4 +- CHANGELOG.md | 24 +++ pyproject.toml | 2 +- uv.lock | 350 ++++++++++++++++++++-------------------- 4 files changed, 206 insertions(+), 174 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ae2847180..efaefc970 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.5.26 + rev: 0.5.30 hooks: # Update the uv lockfile - id: uv-lock @@ -22,7 +22,7 @@ repos: - "--indent=4" - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.4 + rev: v0.9.6 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e40772cf..68ddd4fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [0.10.2](https://github.com/python-kasa/python-kasa/tree/0.10.2) (2025-02-12) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.10.1...0.10.2) + +**Release summary:** + +- Bugfix for [#1499](https://github.com/python-kasa/python-kasa/issues/1499). +- Support for L530B and C110 devices. + +**Fixed bugs:** + +- H100 - Raised error: not enough values to unpack \(expected 2, got 1\) [\#1499](https://github.com/python-kasa/python-kasa/issues/1499) +- Do not crash on missing build number in fw version [\#1500](https://github.com/python-kasa/python-kasa/pull/1500) (@rytilahti) + +**Added support for devices:** + +- Add C110\(EU\) 2.0 1.4.3 fixture [\#1503](https://github.com/python-kasa/python-kasa/pull/1503) (@sdb9696) +- Add L530B\(EU\) 3.0 1.1.9 fixture [\#1502](https://github.com/python-kasa/python-kasa/pull/1502) (@sdb9696) + +**Project maintenance:** + +- Add fixtures for new versions of H100, P110, and T100 devices [\#1501](https://github.com/python-kasa/python-kasa/pull/1501) (@LXGaming) +- Add L530E\(TW\) 2.0 1.1.1 fixture [\#1497](https://github.com/python-kasa/python-kasa/pull/1497) (@bluehomewu) + ## [0.10.1](https://github.com/python-kasa/python-kasa/tree/0.10.1) (2025-02-02) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.10.0...0.10.1) diff --git a/pyproject.toml b/pyproject.toml index c73907767..a7ea0ad20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-kasa" -version = "0.10.1" +version = "0.10.2" description = "Python API for TP-Link Kasa and Tapo devices" license = {text = "GPL-3.0-or-later"} authors = [ { name = "python-kasa developers" }] diff --git a/uv.lock b/uv.lock index ec695f50b..fb140077e 100644 --- a/uv.lock +++ b/uv.lock @@ -3,16 +3,16 @@ requires-python = ">=3.11, <4.0" [[package]] name = "aiohappyeyeballs" -version = "2.4.4" +version = "2.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7f/55/e4373e888fdacb15563ef6fa9fa8c8252476ea071e96fb46defac9f18bf2/aiohappyeyeballs-2.4.4.tar.gz", hash = "sha256:5fdd7d87889c63183afc18ce9271f9b0a7d32c2303e394468dd45d514a757745", size = 21977 } +sdist = { url = "https://files.pythonhosted.org/packages/08/07/508f9ebba367fc3370162e53a3cfd12f5652ad79f0e0bfdf9f9847c6f159/aiohappyeyeballs-2.4.6.tar.gz", hash = "sha256:9b05052f9042985d32ecbe4b59a77ae19c006a78f1344d7fdad69d28ded3d0b0", size = 21726 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/74/fbb6559de3607b3300b9be3cc64e97548d55678e44623db17820dbd20002/aiohappyeyeballs-2.4.4-py3-none-any.whl", hash = "sha256:a980909d50efcd44795c4afeca523296716d50cd756ddca6af8c65b996e27de8", size = 14756 }, + { url = "https://files.pythonhosted.org/packages/44/4c/03fb05f56551828ec67ceb3665e5dc51638042d204983a03b0a1541475b6/aiohappyeyeballs-2.4.6-py3-none-any.whl", hash = "sha256:147ec992cf873d74f5062644332c539fcd42956dc69453fe5204195e560517e1", size = 14543 }, ] [[package]] name = "aiohttp" -version = "3.11.11" +version = "3.11.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -23,53 +23,56 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/ed/f26db39d29cd3cb2f5a3374304c713fe5ab5a0e4c8ee25a0c45cc6adf844/aiohttp-3.11.11.tar.gz", hash = "sha256:bb49c7f1e6ebf3821a42d81d494f538107610c3a705987f53068546b0e90303e", size = 7669618 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/ae/e8806a9f054e15f1d18b04db75c23ec38ec954a10c0a68d3bd275d7e8be3/aiohttp-3.11.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ba74ec819177af1ef7f59063c6d35a214a8fde6f987f7661f4f0eecc468a8f76", size = 708624 }, - { url = "https://files.pythonhosted.org/packages/c7/e0/313ef1a333fb4d58d0c55a6acb3cd772f5d7756604b455181049e222c020/aiohttp-3.11.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4af57160800b7a815f3fe0eba9b46bf28aafc195555f1824555fa2cfab6c1538", size = 468507 }, - { url = "https://files.pythonhosted.org/packages/a9/60/03455476bf1f467e5b4a32a465c450548b2ce724eec39d69f737191f936a/aiohttp-3.11.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ffa336210cf9cd8ed117011085817d00abe4c08f99968deef0013ea283547204", size = 455571 }, - { url = "https://files.pythonhosted.org/packages/be/f9/469588603bd75bf02c8ffb8c8a0d4b217eed446b49d4a767684685aa33fd/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b8fe282183e4a3c7a1b72f5ade1094ed1c6345a8f153506d114af5bf8accd9", size = 1685694 }, - { url = "https://files.pythonhosted.org/packages/88/b9/1b7fa43faf6c8616fa94c568dc1309ffee2b6b68b04ac268e5d64b738688/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af41686ccec6a0f2bdc66686dc0f403c41ac2089f80e2214a0f82d001052c03", size = 1743660 }, - { url = "https://files.pythonhosted.org/packages/2a/8b/0248d19dbb16b67222e75f6aecedd014656225733157e5afaf6a6a07e2e8/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70d1f9dde0e5dd9e292a6d4d00058737052b01f3532f69c0c65818dac26dc287", size = 1785421 }, - { url = "https://files.pythonhosted.org/packages/c4/11/f478e071815a46ca0a5ae974651ff0c7a35898c55063305a896e58aa1247/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:249cc6912405917344192b9f9ea5cd5b139d49e0d2f5c7f70bdfaf6b4dbf3a2e", size = 1675145 }, - { url = "https://files.pythonhosted.org/packages/26/5d/284d182fecbb5075ae10153ff7374f57314c93a8681666600e3a9e09c505/aiohttp-3.11.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0eb98d90b6690827dcc84c246811feeb4e1eea683c0eac6caed7549be9c84665", size = 1619804 }, - { url = "https://files.pythonhosted.org/packages/1b/78/980064c2ad685c64ce0e8aeeb7ef1e53f43c5b005edcd7d32e60809c4992/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec82bf1fda6cecce7f7b915f9196601a1bd1a3079796b76d16ae4cce6d0ef89b", size = 1654007 }, - { url = "https://files.pythonhosted.org/packages/21/8d/9e658d63b1438ad42b96f94da227f2e2c1d5c6001c9e8ffcc0bfb22e9105/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9fd46ce0845cfe28f108888b3ab17abff84ff695e01e73657eec3f96d72eef34", size = 1650022 }, - { url = "https://files.pythonhosted.org/packages/85/fd/a032bf7f2755c2df4f87f9effa34ccc1ef5cea465377dbaeef93bb56bbd6/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd176afcf8f5d2aed50c3647d4925d0db0579d96f75a31e77cbaf67d8a87742d", size = 1732899 }, - { url = "https://files.pythonhosted.org/packages/c5/0c/c2b85fde167dd440c7ba50af2aac20b5a5666392b174df54c00f888c5a75/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ec2aa89305006fba9ffb98970db6c8221541be7bee4c1d027421d6f6df7d1ce2", size = 1755142 }, - { url = "https://files.pythonhosted.org/packages/bc/78/91ae1a3b3b3bed8b893c5d69c07023e151b1c95d79544ad04cf68f596c2f/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:92cde43018a2e17d48bb09c79e4d4cb0e236de5063ce897a5e40ac7cb4878773", size = 1692736 }, - { url = "https://files.pythonhosted.org/packages/77/89/a7ef9c4b4cdb546fcc650ca7f7395aaffbd267f0e1f648a436bec33c9b95/aiohttp-3.11.11-cp311-cp311-win32.whl", hash = "sha256:aba807f9569455cba566882c8938f1a549f205ee43c27b126e5450dc9f83cc62", size = 416418 }, - { url = "https://files.pythonhosted.org/packages/fc/db/2192489a8a51b52e06627506f8ac8df69ee221de88ab9bdea77aa793aa6a/aiohttp-3.11.11-cp311-cp311-win_amd64.whl", hash = "sha256:ae545f31489548c87b0cced5755cfe5a5308d00407000e72c4fa30b19c3220ac", size = 442509 }, - { url = "https://files.pythonhosted.org/packages/69/cf/4bda538c502f9738d6b95ada11603c05ec260807246e15e869fc3ec5de97/aiohttp-3.11.11-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e595c591a48bbc295ebf47cb91aebf9bd32f3ff76749ecf282ea7f9f6bb73886", size = 704666 }, - { url = "https://files.pythonhosted.org/packages/46/7b/87fcef2cad2fad420ca77bef981e815df6904047d0a1bd6aeded1b0d1d66/aiohttp-3.11.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ea1b59dc06396b0b424740a10a0a63974c725b1c64736ff788a3689d36c02d2", size = 464057 }, - { url = "https://files.pythonhosted.org/packages/5a/a6/789e1f17a1b6f4a38939fbc39d29e1d960d5f89f73d0629a939410171bc0/aiohttp-3.11.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8811f3f098a78ffa16e0ea36dffd577eb031aea797cbdba81be039a4169e242c", size = 455996 }, - { url = "https://files.pythonhosted.org/packages/b7/dd/485061fbfef33165ce7320db36e530cd7116ee1098e9c3774d15a732b3fd/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7227b87a355ce1f4bf83bfae4399b1f5bb42e0259cb9405824bd03d2f4336a", size = 1682367 }, - { url = "https://files.pythonhosted.org/packages/e9/d7/9ec5b3ea9ae215c311d88b2093e8da17e67b8856673e4166c994e117ee3e/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d40f9da8cabbf295d3a9dae1295c69975b86d941bc20f0a087f0477fa0a66231", size = 1736989 }, - { url = "https://files.pythonhosted.org/packages/d6/fb/ea94927f7bfe1d86178c9d3e0a8c54f651a0a655214cce930b3c679b8f64/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffb3dc385f6bb1568aa974fe65da84723210e5d9707e360e9ecb51f59406cd2e", size = 1793265 }, - { url = "https://files.pythonhosted.org/packages/40/7f/6de218084f9b653026bd7063cd8045123a7ba90c25176465f266976d8c82/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8f5f7515f3552d899c61202d99dcb17d6e3b0de777900405611cd747cecd1b8", size = 1691841 }, - { url = "https://files.pythonhosted.org/packages/77/e2/992f43d87831cbddb6b09c57ab55499332f60ad6fdbf438ff4419c2925fc/aiohttp-3.11.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3499c7ffbfd9c6a3d8d6a2b01c26639da7e43d47c7b4f788016226b1e711caa8", size = 1619317 }, - { url = "https://files.pythonhosted.org/packages/96/74/879b23cdd816db4133325a201287c95bef4ce669acde37f8f1b8669e1755/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8e2bf8029dbf0810c7bfbc3e594b51c4cc9101fbffb583a3923aea184724203c", size = 1641416 }, - { url = "https://files.pythonhosted.org/packages/30/98/b123f6b15d87c54e58fd7ae3558ff594f898d7f30a90899718f3215ad328/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b6212a60e5c482ef90f2d788835387070a88d52cf6241d3916733c9176d39eab", size = 1646514 }, - { url = "https://files.pythonhosted.org/packages/d7/38/257fda3dc99d6978ab943141d5165ec74fd4b4164baa15e9c66fa21da86b/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d119fafe7b634dbfa25a8c597718e69a930e4847f0b88e172744be24515140da", size = 1702095 }, - { url = "https://files.pythonhosted.org/packages/0c/f4/ddab089053f9fb96654df5505c0a69bde093214b3c3454f6bfdb1845f558/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:6fba278063559acc730abf49845d0e9a9e1ba74f85f0ee6efd5803f08b285853", size = 1734611 }, - { url = "https://files.pythonhosted.org/packages/c3/d6/f30b2bc520c38c8aa4657ed953186e535ae84abe55c08d0f70acd72ff577/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:92fc484e34b733704ad77210c7957679c5c3877bd1e6b6d74b185e9320cc716e", size = 1694576 }, - { url = "https://files.pythonhosted.org/packages/bc/97/b0a88c3f4c6d0020b34045ee6d954058abc870814f6e310c4c9b74254116/aiohttp-3.11.11-cp312-cp312-win32.whl", hash = "sha256:9f5b3c1ed63c8fa937a920b6c1bec78b74ee09593b3f5b979ab2ae5ef60d7600", size = 411363 }, - { url = "https://files.pythonhosted.org/packages/7f/23/cc36d9c398980acaeeb443100f0216f50a7cfe20c67a9fd0a2f1a5a846de/aiohttp-3.11.11-cp312-cp312-win_amd64.whl", hash = "sha256:1e69966ea6ef0c14ee53ef7a3d68b564cc408121ea56c0caa2dc918c1b2f553d", size = 437666 }, - { url = "https://files.pythonhosted.org/packages/49/d1/d8af164f400bad432b63e1ac857d74a09311a8334b0481f2f64b158b50eb/aiohttp-3.11.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:541d823548ab69d13d23730a06f97460f4238ad2e5ed966aaf850d7c369782d9", size = 697982 }, - { url = "https://files.pythonhosted.org/packages/92/d1/faad3bf9fa4bfd26b95c69fc2e98937d52b1ff44f7e28131855a98d23a17/aiohttp-3.11.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:929f3ed33743a49ab127c58c3e0a827de0664bfcda566108989a14068f820194", size = 460662 }, - { url = "https://files.pythonhosted.org/packages/db/61/0d71cc66d63909dabc4590f74eba71f91873a77ea52424401c2498d47536/aiohttp-3.11.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0882c2820fd0132240edbb4a51eb8ceb6eef8181db9ad5291ab3332e0d71df5f", size = 452950 }, - { url = "https://files.pythonhosted.org/packages/07/db/6d04bc7fd92784900704e16b745484ef45b77bd04e25f58f6febaadf7983/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63de12e44935d5aca7ed7ed98a255a11e5cb47f83a9fded7a5e41c40277d104", size = 1665178 }, - { url = "https://files.pythonhosted.org/packages/54/5c/e95ade9ae29f375411884d9fd98e50535bf9fe316c9feb0f30cd2ac8f508/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa54f8ef31d23c506910c21163f22b124facb573bff73930735cf9fe38bf7dff", size = 1717939 }, - { url = "https://files.pythonhosted.org/packages/6f/1c/1e7d5c5daea9e409ed70f7986001b8c9e3a49a50b28404498d30860edab6/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a344d5dc18074e3872777b62f5f7d584ae4344cd6006c17ba12103759d407af3", size = 1775125 }, - { url = "https://files.pythonhosted.org/packages/5d/66/890987e44f7d2f33a130e37e01a164168e6aff06fce15217b6eaf14df4f6/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7fb429ab1aafa1f48578eb315ca45bd46e9c37de11fe45c7f5f4138091e2f1", size = 1677176 }, - { url = "https://files.pythonhosted.org/packages/8f/dc/e2ba57d7a52df6cdf1072fd5fa9c6301a68e1cd67415f189805d3eeb031d/aiohttp-3.11.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c341c7d868750e31961d6d8e60ff040fb9d3d3a46d77fd85e1ab8e76c3e9a5c4", size = 1603192 }, - { url = "https://files.pythonhosted.org/packages/6c/9e/8d08a57de79ca3a358da449405555e668f2c8871a7777ecd2f0e3912c272/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed9ee95614a71e87f1a70bc81603f6c6760128b140bc4030abe6abaa988f1c3d", size = 1618296 }, - { url = "https://files.pythonhosted.org/packages/56/51/89822e3ec72db352c32e7fc1c690370e24e231837d9abd056490f3a49886/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de8d38f1c2810fa2a4f1d995a2e9c70bb8737b18da04ac2afbf3971f65781d87", size = 1616524 }, - { url = "https://files.pythonhosted.org/packages/2c/fa/e2e6d9398f462ffaa095e84717c1732916a57f1814502929ed67dd7568ef/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a9b7371665d4f00deb8f32208c7c5e652059b0fda41cf6dbcac6114a041f1cc2", size = 1685471 }, - { url = "https://files.pythonhosted.org/packages/ae/5f/6bb976e619ca28a052e2c0ca7b0251ccd893f93d7c24a96abea38e332bf6/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:620598717fce1b3bd14dd09947ea53e1ad510317c85dda2c9c65b622edc96b12", size = 1715312 }, - { url = "https://files.pythonhosted.org/packages/79/c1/756a7e65aa087c7fac724d6c4c038f2faaa2a42fe56dbc1dd62a33ca7213/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf8d9bfee991d8acc72d060d53860f356e07a50f0e0d09a8dfedea1c554dd0d5", size = 1672783 }, - { url = "https://files.pythonhosted.org/packages/73/ba/a6190ebb02176c7f75e6308da31f5d49f6477b651a3dcfaaaca865a298e2/aiohttp-3.11.11-cp313-cp313-win32.whl", hash = "sha256:9d73ee3725b7a737ad86c2eac5c57a4a97793d9f442599bea5ec67ac9f4bdc3d", size = 410229 }, - { url = "https://files.pythonhosted.org/packages/b8/62/c9fa5bafe03186a0e4699150a7fed9b1e73240996d0d2f0e5f70f3fdf471/aiohttp-3.11.11-cp313-cp313-win_amd64.whl", hash = "sha256:c7a06301c2fb096bdb0bd25fe2011531c1453b9f2c163c8031600ec73af1cc99", size = 436081 }, +sdist = { url = "https://files.pythonhosted.org/packages/37/4b/952d49c73084fb790cb5c6ead50848c8e96b4980ad806cf4d2ad341eaa03/aiohttp-3.11.12.tar.gz", hash = "sha256:7603ca26d75b1b86160ce1bbe2787a0b706e592af5b2504e12caa88a217767b0", size = 7673175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/38/35311e70196b6a63cfa033a7f741f800aa8a93f57442991cbe51da2394e7/aiohttp-3.11.12-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:87a2e00bf17da098d90d4145375f1d985a81605267e7f9377ff94e55c5d769eb", size = 708797 }, + { url = "https://files.pythonhosted.org/packages/44/3e/46c656e68cbfc4f3fc7cb5d2ba4da6e91607fe83428208028156688f6201/aiohttp-3.11.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b34508f1cd928ce915ed09682d11307ba4b37d0708d1f28e5774c07a7674cac9", size = 468669 }, + { url = "https://files.pythonhosted.org/packages/a0/d6/2088fb4fd1e3ac2bfb24bc172223babaa7cdbb2784d33c75ec09e66f62f8/aiohttp-3.11.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:936d8a4f0f7081327014742cd51d320296b56aa6d324461a13724ab05f4b2933", size = 455739 }, + { url = "https://files.pythonhosted.org/packages/e7/dc/c443a6954a56f4a58b5efbfdf23cc6f3f0235e3424faf5a0c56264d5c7bb/aiohttp-3.11.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de1378f72def7dfb5dbd73d86c19eda0ea7b0a6873910cc37d57e80f10d64e1", size = 1685858 }, + { url = "https://files.pythonhosted.org/packages/25/67/2d5b3aaade1d5d01c3b109aa76e3aa9630531252cda10aa02fb99b0b11a1/aiohttp-3.11.12-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9d45dbb3aaec05cf01525ee1a7ac72de46a8c425cb75c003acd29f76b1ffe94", size = 1743829 }, + { url = "https://files.pythonhosted.org/packages/90/9b/9728fe9a3e1b8521198455d027b0b4035522be18f504b24c5d38d59e7278/aiohttp-3.11.12-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:930ffa1925393381e1e0a9b82137fa7b34c92a019b521cf9f41263976666a0d6", size = 1785587 }, + { url = "https://files.pythonhosted.org/packages/ce/cf/28fbb43d4ebc1b4458374a3c7b6db3b556a90e358e9bbcfe6d9339c1e2b6/aiohttp-3.11.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8340def6737118f5429a5df4e88f440746b791f8f1c4ce4ad8a595f42c980bd5", size = 1675319 }, + { url = "https://files.pythonhosted.org/packages/e5/d2/006c459c11218cabaa7bca401f965c9cc828efbdea7e1615d4644eaf23f7/aiohttp-3.11.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4016e383f91f2814e48ed61e6bda7d24c4d7f2402c75dd28f7e1027ae44ea204", size = 1619982 }, + { url = "https://files.pythonhosted.org/packages/9d/83/ca425891ebd37bee5d837110f7fddc4d808a7c6c126a7d1b5c3ad72fc6ba/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c0600bcc1adfaaac321422d615939ef300df81e165f6522ad096b73439c0f58", size = 1654176 }, + { url = "https://files.pythonhosted.org/packages/25/df/047b1ce88514a1b4915d252513640184b63624e7914e41d846668b8edbda/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0450ada317a65383b7cce9576096150fdb97396dcfe559109b403c7242faffef", size = 1660198 }, + { url = "https://files.pythonhosted.org/packages/d3/cc/6ecb8e343f0902528620b9dbd567028a936d5489bebd7dbb0dd0914f4fdb/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:850ff6155371fd802a280f8d369d4e15d69434651b844bde566ce97ee2277420", size = 1650186 }, + { url = "https://files.pythonhosted.org/packages/f8/f8/453df6dd69256ca8c06c53fc8803c9056e2b0b16509b070f9a3b4bdefd6c/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8fd12d0f989c6099e7b0f30dc6e0d1e05499f3337461f0b2b0dadea6c64b89df", size = 1733063 }, + { url = "https://files.pythonhosted.org/packages/55/f8/540160787ff3000391de0e5d0d1d33be4c7972f933c21991e2ea105b2d5e/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:76719dd521c20a58a6c256d058547b3a9595d1d885b830013366e27011ffe804", size = 1755306 }, + { url = "https://files.pythonhosted.org/packages/30/7d/49f3bfdfefd741576157f8f91caa9ff61a6f3d620ca6339268327518221b/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97fe431f2ed646a3b56142fc81d238abcbaff08548d6912acb0b19a0cadc146b", size = 1692909 }, + { url = "https://files.pythonhosted.org/packages/40/9c/8ce00afd6f6112ce9a2309dc490fea376ae824708b94b7b5ea9cba979d1d/aiohttp-3.11.12-cp311-cp311-win32.whl", hash = "sha256:e10c440d142fa8b32cfdb194caf60ceeceb3e49807072e0dc3a8887ea80e8c16", size = 416584 }, + { url = "https://files.pythonhosted.org/packages/35/97/4d3c5f562f15830de472eb10a7a222655d750839943e0e6d915ef7e26114/aiohttp-3.11.12-cp311-cp311-win_amd64.whl", hash = "sha256:246067ba0cf5560cf42e775069c5d80a8989d14a7ded21af529a4e10e3e0f0e6", size = 442674 }, + { url = "https://files.pythonhosted.org/packages/4d/d0/94346961acb476569fca9a644cc6f9a02f97ef75961a6b8d2b35279b8d1f/aiohttp-3.11.12-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e392804a38353900c3fd8b7cacbea5132888f7129f8e241915e90b85f00e3250", size = 704837 }, + { url = "https://files.pythonhosted.org/packages/a9/af/05c503f1cc8f97621f199ef4b8db65fb88b8bc74a26ab2adb74789507ad3/aiohttp-3.11.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8fa1510b96c08aaad49303ab11f8803787c99222288f310a62f493faf883ede1", size = 464218 }, + { url = "https://files.pythonhosted.org/packages/f2/48/b9949eb645b9bd699153a2ec48751b985e352ab3fed9d98c8115de305508/aiohttp-3.11.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dc065a4285307607df3f3686363e7f8bdd0d8ab35f12226362a847731516e42c", size = 456166 }, + { url = "https://files.pythonhosted.org/packages/14/fb/980981807baecb6f54bdd38beb1bd271d9a3a786e19a978871584d026dcf/aiohttp-3.11.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddb31f8474695cd61fc9455c644fc1606c164b93bff2490390d90464b4655df", size = 1682528 }, + { url = "https://files.pythonhosted.org/packages/90/cb/77b1445e0a716914e6197b0698b7a3640590da6c692437920c586764d05b/aiohttp-3.11.12-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9dec0000d2d8621d8015c293e24589d46fa218637d820894cb7356c77eca3259", size = 1737154 }, + { url = "https://files.pythonhosted.org/packages/ff/24/d6fb1f4cede9ccbe98e4def6f3ed1e1efcb658871bbf29f4863ec646bf38/aiohttp-3.11.12-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3552fe98e90fdf5918c04769f338a87fa4f00f3b28830ea9b78b1bdc6140e0d", size = 1793435 }, + { url = "https://files.pythonhosted.org/packages/17/e2/9f744cee0861af673dc271a3351f59ebd5415928e20080ab85be25641471/aiohttp-3.11.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dfe7f984f28a8ae94ff3a7953cd9678550dbd2a1f9bda5dd9c5ae627744c78e", size = 1692010 }, + { url = "https://files.pythonhosted.org/packages/90/c4/4a1235c1df544223eb57ba553ce03bc706bdd065e53918767f7fa1ff99e0/aiohttp-3.11.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a481a574af914b6e84624412666cbfbe531a05667ca197804ecc19c97b8ab1b0", size = 1619481 }, + { url = "https://files.pythonhosted.org/packages/60/70/cf12d402a94a33abda86dd136eb749b14c8eb9fec1e16adc310e25b20033/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1987770fb4887560363b0e1a9b75aa303e447433c41284d3af2840a2f226d6e0", size = 1641578 }, + { url = "https://files.pythonhosted.org/packages/1b/25/7211973fda1f5e833fcfd98ccb7f9ce4fbfc0074e3e70c0157a751d00db8/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a4ac6a0f0f6402854adca4e3259a623f5c82ec3f0c049374133bcb243132baf9", size = 1684463 }, + { url = "https://files.pythonhosted.org/packages/93/60/b5905b4d0693f6018b26afa9f2221fefc0dcbd3773fe2dff1a20fb5727f1/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c96a43822f1f9f69cc5c3706af33239489a6294be486a0447fb71380070d4d5f", size = 1646691 }, + { url = "https://files.pythonhosted.org/packages/b4/fc/ba1b14d6fdcd38df0b7c04640794b3683e949ea10937c8a58c14d697e93f/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a5e69046f83c0d3cb8f0d5bd9b8838271b1bc898e01562a04398e160953e8eb9", size = 1702269 }, + { url = "https://files.pythonhosted.org/packages/5e/39/18c13c6f658b2ba9cc1e0c6fb2d02f98fd653ad2addcdf938193d51a9c53/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:68d54234c8d76d8ef74744f9f9fc6324f1508129e23da8883771cdbb5818cbef", size = 1734782 }, + { url = "https://files.pythonhosted.org/packages/9f/d2/ccc190023020e342419b265861877cd8ffb75bec37b7ddd8521dd2c6deb8/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9fd9dcf9c91affe71654ef77426f5cf8489305e1c66ed4816f5a21874b094b9", size = 1694740 }, + { url = "https://files.pythonhosted.org/packages/3f/54/186805bcada64ea90ea909311ffedcd74369bfc6e880d39d2473314daa36/aiohttp-3.11.12-cp312-cp312-win32.whl", hash = "sha256:0ed49efcd0dc1611378beadbd97beb5d9ca8fe48579fc04a6ed0844072261b6a", size = 411530 }, + { url = "https://files.pythonhosted.org/packages/3d/63/5eca549d34d141bcd9de50d4e59b913f3641559460c739d5e215693cb54a/aiohttp-3.11.12-cp312-cp312-win_amd64.whl", hash = "sha256:54775858c7f2f214476773ce785a19ee81d1294a6bedc5cc17225355aab74802", size = 437860 }, + { url = "https://files.pythonhosted.org/packages/c3/9b/cea185d4b543ae08ee478373e16653722c19fcda10d2d0646f300ce10791/aiohttp-3.11.12-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:413ad794dccb19453e2b97c2375f2ca3cdf34dc50d18cc2693bd5aed7d16f4b9", size = 698148 }, + { url = "https://files.pythonhosted.org/packages/91/5c/80d47fe7749fde584d1404a68ade29bcd7e58db8fa11fa38e8d90d77e447/aiohttp-3.11.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a93d28ed4b4b39e6f46fd240896c29b686b75e39cc6992692e3922ff6982b4c", size = 460831 }, + { url = "https://files.pythonhosted.org/packages/8e/f9/de568f8a8ca6b061d157c50272620c53168d6e3eeddae78dbb0f7db981eb/aiohttp-3.11.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d589264dbba3b16e8951b6f145d1e6b883094075283dafcab4cdd564a9e353a0", size = 453122 }, + { url = "https://files.pythonhosted.org/packages/8b/fd/b775970a047543bbc1d0f66725ba72acef788028fce215dc959fd15a8200/aiohttp-3.11.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5148ca8955affdfeb864aca158ecae11030e952b25b3ae15d4e2b5ba299bad2", size = 1665336 }, + { url = "https://files.pythonhosted.org/packages/82/9b/aff01d4f9716245a1b2965f02044e4474fadd2bcfe63cf249ca788541886/aiohttp-3.11.12-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:525410e0790aab036492eeea913858989c4cb070ff373ec3bc322d700bdf47c1", size = 1718111 }, + { url = "https://files.pythonhosted.org/packages/e0/a9/166fd2d8b2cc64f08104aa614fad30eee506b563154081bf88ce729bc665/aiohttp-3.11.12-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bd8695be2c80b665ae3f05cb584093a1e59c35ecb7d794d1edd96e8cc9201d7", size = 1775293 }, + { url = "https://files.pythonhosted.org/packages/13/c5/0d3c89bd9e36288f10dc246f42518ce8e1c333f27636ac78df091c86bb4a/aiohttp-3.11.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0203433121484b32646a5f5ea93ae86f3d9559d7243f07e8c0eab5ff8e3f70e", size = 1677338 }, + { url = "https://files.pythonhosted.org/packages/72/b2/017db2833ef537be284f64ead78725984db8a39276c1a9a07c5c7526e238/aiohttp-3.11.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40cd36749a1035c34ba8d8aaf221b91ca3d111532e5ccb5fa8c3703ab1b967ed", size = 1603365 }, + { url = "https://files.pythonhosted.org/packages/fc/72/b66c96a106ec7e791e29988c222141dd1219d7793ffb01e72245399e08d2/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a7442662afebbf7b4c6d28cb7aab9e9ce3a5df055fc4116cc7228192ad6cb484", size = 1618464 }, + { url = "https://files.pythonhosted.org/packages/3f/50/e68a40f267b46a603bab569d48d57f23508801614e05b3369898c5b2910a/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8a2fb742ef378284a50766e985804bd6adb5adb5aa781100b09befdbfa757b65", size = 1657827 }, + { url = "https://files.pythonhosted.org/packages/c5/1d/aafbcdb1773d0ba7c20793ebeedfaba1f3f7462f6fc251f24983ed738aa7/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cee3b117a8d13ab98b38d5b6bdcd040cfb4181068d05ce0c474ec9db5f3c5bb", size = 1616700 }, + { url = "https://files.pythonhosted.org/packages/b0/5e/6cd9724a2932f36e2a6b742436a36d64784322cfb3406ca773f903bb9a70/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f6a19bcab7fbd8f8649d6595624856635159a6527861b9cdc3447af288a00c00", size = 1685643 }, + { url = "https://files.pythonhosted.org/packages/8b/38/ea6c91d5c767fd45a18151675a07c710ca018b30aa876a9f35b32fa59761/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e4cecdb52aaa9994fbed6b81d4568427b6002f0a91c322697a4bfcc2b2363f5a", size = 1715487 }, + { url = "https://files.pythonhosted.org/packages/8e/24/e9edbcb7d1d93c02e055490348df6f955d675e85a028c33babdcaeda0853/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:30f546358dfa0953db92ba620101fefc81574f87b2346556b90b5f3ef16e55ce", size = 1672948 }, + { url = "https://files.pythonhosted.org/packages/25/be/0b1fb737268e003198f25c3a68c2135e76e4754bf399a879b27bd508a003/aiohttp-3.11.12-cp313-cp313-win32.whl", hash = "sha256:ce1bb21fc7d753b5f8a5d5a4bae99566386b15e716ebdb410154c16c91494d7f", size = 410396 }, + { url = "https://files.pythonhosted.org/packages/68/fd/677def96a75057b0a26446b62f8fbb084435b20a7d270c99539c26573bfd/aiohttp-3.11.12-cp313-cp313-win_amd64.whl", hash = "sha256:f7914ab70d2ee8ab91c13e5402122edbc77821c66d2758abb53aabe87f013287", size = 436234 }, ] [[package]] @@ -283,50 +286,51 @@ wheels = [ [[package]] name = "coverage" -version = "7.6.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/ba/ac14d281f80aab516275012e8875991bb06203957aa1e19950139238d658/coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23", size = 803868 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/d2/5e175fcf6766cf7501a8541d81778fd2f52f4870100e791f5327fd23270b/coverage-7.6.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ea3c8f04b3e4af80e17bab607c386a830ffc2fb88a5484e1df756478cf70d1d3", size = 208088 }, - { url = "https://files.pythonhosted.org/packages/4b/6f/06db4dc8fca33c13b673986e20e466fd936235a6ec1f0045c3853ac1b593/coverage-7.6.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:507a20fc863cae1d5720797761b42d2d87a04b3e5aeb682ef3b7332e90598f43", size = 208536 }, - { url = "https://files.pythonhosted.org/packages/0d/62/c6a0cf80318c1c1af376d52df444da3608eafc913b82c84a4600d8349472/coverage-7.6.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d37a84878285b903c0fe21ac8794c6dab58150e9359f1aaebbeddd6412d53132", size = 240474 }, - { url = "https://files.pythonhosted.org/packages/a3/59/750adafc2e57786d2e8739a46b680d4fb0fbc2d57fbcb161290a9f1ecf23/coverage-7.6.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a534738b47b0de1995f85f582d983d94031dffb48ab86c95bdf88dc62212142f", size = 237880 }, - { url = "https://files.pythonhosted.org/packages/2c/f8/ef009b3b98e9f7033c19deb40d629354aab1d8b2d7f9cfec284dbedf5096/coverage-7.6.10-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d7a2bf79378d8fb8afaa994f91bfd8215134f8631d27eba3e0e2c13546ce994", size = 239750 }, - { url = "https://files.pythonhosted.org/packages/a6/e2/6622f3b70f5f5b59f705e680dae6db64421af05a5d1e389afd24dae62e5b/coverage-7.6.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6713ba4b4ebc330f3def51df1d5d38fad60b66720948112f114968feb52d3f99", size = 238642 }, - { url = "https://files.pythonhosted.org/packages/2d/10/57ac3f191a3c95c67844099514ff44e6e19b2915cd1c22269fb27f9b17b6/coverage-7.6.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab32947f481f7e8c763fa2c92fd9f44eeb143e7610c4ca9ecd6a36adab4081bd", size = 237266 }, - { url = "https://files.pythonhosted.org/packages/ee/2d/7016f4ad9d553cabcb7333ed78ff9d27248ec4eba8dd21fa488254dff894/coverage-7.6.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7bbd8c8f1b115b892e34ba66a097b915d3871db7ce0e6b9901f462ff3a975377", size = 238045 }, - { url = "https://files.pythonhosted.org/packages/a7/fe/45af5c82389a71e0cae4546413266d2195c3744849669b0bab4b5f2c75da/coverage-7.6.10-cp311-cp311-win32.whl", hash = "sha256:299e91b274c5c9cdb64cbdf1b3e4a8fe538a7a86acdd08fae52301b28ba297f8", size = 210647 }, - { url = "https://files.pythonhosted.org/packages/db/11/3f8e803a43b79bc534c6a506674da9d614e990e37118b4506faf70d46ed6/coverage-7.6.10-cp311-cp311-win_amd64.whl", hash = "sha256:489a01f94aa581dbd961f306e37d75d4ba16104bbfa2b0edb21d29b73be83609", size = 211508 }, - { url = "https://files.pythonhosted.org/packages/86/77/19d09ea06f92fdf0487499283b1b7af06bc422ea94534c8fe3a4cd023641/coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853", size = 208281 }, - { url = "https://files.pythonhosted.org/packages/b6/67/5479b9f2f99fcfb49c0d5cf61912a5255ef80b6e80a3cddba39c38146cf4/coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078", size = 208514 }, - { url = "https://files.pythonhosted.org/packages/15/d1/febf59030ce1c83b7331c3546d7317e5120c5966471727aa7ac157729c4b/coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0", size = 241537 }, - { url = "https://files.pythonhosted.org/packages/4b/7e/5ac4c90192130e7cf8b63153fe620c8bfd9068f89a6d9b5f26f1550f7a26/coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50", size = 238572 }, - { url = "https://files.pythonhosted.org/packages/dc/03/0334a79b26ecf59958f2fe9dd1f5ab3e2f88db876f5071933de39af09647/coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022", size = 240639 }, - { url = "https://files.pythonhosted.org/packages/d7/45/8a707f23c202208d7b286d78ad6233f50dcf929319b664b6cc18a03c1aae/coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b", size = 240072 }, - { url = "https://files.pythonhosted.org/packages/66/02/603ce0ac2d02bc7b393279ef618940b4a0535b0868ee791140bda9ecfa40/coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0", size = 238386 }, - { url = "https://files.pythonhosted.org/packages/04/62/4e6887e9be060f5d18f1dd58c2838b2d9646faf353232dec4e2d4b1c8644/coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852", size = 240054 }, - { url = "https://files.pythonhosted.org/packages/5c/74/83ae4151c170d8bd071924f212add22a0e62a7fe2b149edf016aeecad17c/coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359", size = 210904 }, - { url = "https://files.pythonhosted.org/packages/c3/54/de0893186a221478f5880283119fc40483bc460b27c4c71d1b8bba3474b9/coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247", size = 211692 }, - { url = "https://files.pythonhosted.org/packages/25/6d/31883d78865529257bf847df5789e2ae80e99de8a460c3453dbfbe0db069/coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9", size = 208308 }, - { url = "https://files.pythonhosted.org/packages/70/22/3f2b129cc08de00c83b0ad6252e034320946abfc3e4235c009e57cfeee05/coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b", size = 208565 }, - { url = "https://files.pythonhosted.org/packages/97/0a/d89bc2d1cc61d3a8dfe9e9d75217b2be85f6c73ebf1b9e3c2f4e797f4531/coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690", size = 241083 }, - { url = "https://files.pythonhosted.org/packages/4c/81/6d64b88a00c7a7aaed3a657b8eaa0931f37a6395fcef61e53ff742b49c97/coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18", size = 238235 }, - { url = "https://files.pythonhosted.org/packages/9a/0b/7797d4193f5adb4b837207ed87fecf5fc38f7cc612b369a8e8e12d9fa114/coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c", size = 240220 }, - { url = "https://files.pythonhosted.org/packages/65/4d/6f83ca1bddcf8e51bf8ff71572f39a1c73c34cf50e752a952c34f24d0a60/coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd", size = 239847 }, - { url = "https://files.pythonhosted.org/packages/30/9d/2470df6aa146aff4c65fee0f87f58d2164a67533c771c9cc12ffcdb865d5/coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e", size = 237922 }, - { url = "https://files.pythonhosted.org/packages/08/dd/723fef5d901e6a89f2507094db66c091449c8ba03272861eaefa773ad95c/coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694", size = 239783 }, - { url = "https://files.pythonhosted.org/packages/3d/f7/64d3298b2baf261cb35466000628706ce20a82d42faf9b771af447cd2b76/coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6", size = 210965 }, - { url = "https://files.pythonhosted.org/packages/d5/58/ec43499a7fc681212fe7742fe90b2bc361cdb72e3181ace1604247a5b24d/coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e", size = 211719 }, - { url = "https://files.pythonhosted.org/packages/ab/c9/f2857a135bcff4330c1e90e7d03446b036b2363d4ad37eb5e3a47bbac8a6/coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe", size = 209050 }, - { url = "https://files.pythonhosted.org/packages/aa/b3/f840e5bd777d8433caa9e4a1eb20503495709f697341ac1a8ee6a3c906ad/coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273", size = 209321 }, - { url = "https://files.pythonhosted.org/packages/85/7d/125a5362180fcc1c03d91850fc020f3831d5cda09319522bcfa6b2b70be7/coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8", size = 252039 }, - { url = "https://files.pythonhosted.org/packages/a9/9c/4358bf3c74baf1f9bddd2baf3756b54c07f2cfd2535f0a47f1e7757e54b3/coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098", size = 247758 }, - { url = "https://files.pythonhosted.org/packages/cf/c7/de3eb6fc5263b26fab5cda3de7a0f80e317597a4bad4781859f72885f300/coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb", size = 250119 }, - { url = "https://files.pythonhosted.org/packages/3e/e6/43de91f8ba2ec9140c6a4af1102141712949903dc732cf739167cfa7a3bc/coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0", size = 249597 }, - { url = "https://files.pythonhosted.org/packages/08/40/61158b5499aa2adf9e37bc6d0117e8f6788625b283d51e7e0c53cf340530/coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf", size = 247473 }, - { url = "https://files.pythonhosted.org/packages/50/69/b3f2416725621e9f112e74e8470793d5b5995f146f596f133678a633b77e/coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2", size = 248737 }, - { url = "https://files.pythonhosted.org/packages/3c/6e/fe899fb937657db6df31cc3e61c6968cb56d36d7326361847440a430152e/coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312", size = 211611 }, - { url = "https://files.pythonhosted.org/packages/1c/55/52f5e66142a9d7bc93a15192eba7a78513d2abf6b3558d77b4ca32f5f424/coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d", size = 212781 }, +version = "7.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/d6/2b53ab3ee99f2262e6f0b8369a43f6d66658eab45510331c0b3d5c8c4272/coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2", size = 805941 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/2d/da78abbfff98468c91fd63a73cccdfa0e99051676ded8dd36123e3a2d4d5/coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015", size = 208464 }, + { url = "https://files.pythonhosted.org/packages/31/f2/c269f46c470bdabe83a69e860c80a82e5e76840e9f4bbd7f38f8cebbee2f/coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45", size = 208893 }, + { url = "https://files.pythonhosted.org/packages/47/63/5682bf14d2ce20819998a49c0deadb81e608a59eed64d6bc2191bc8046b9/coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702", size = 241545 }, + { url = "https://files.pythonhosted.org/packages/6a/b6/6b6631f1172d437e11067e1c2edfdb7238b65dff965a12bce3b6d1bf2be2/coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0", size = 239230 }, + { url = "https://files.pythonhosted.org/packages/c7/01/9cd06cbb1be53e837e16f1b4309f6357e2dfcbdab0dd7cd3b1a50589e4e1/coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f", size = 241013 }, + { url = "https://files.pythonhosted.org/packages/4b/26/56afefc03c30871326e3d99709a70d327ac1f33da383cba108c79bd71563/coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f", size = 239750 }, + { url = "https://files.pythonhosted.org/packages/dd/ea/88a1ff951ed288f56aa561558ebe380107cf9132facd0b50bced63ba7238/coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d", size = 238462 }, + { url = "https://files.pythonhosted.org/packages/6e/d4/1d9404566f553728889409eff82151d515fbb46dc92cbd13b5337fa0de8c/coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba", size = 239307 }, + { url = "https://files.pythonhosted.org/packages/12/c1/e453d3b794cde1e232ee8ac1d194fde8e2ba329c18bbf1b93f6f5eef606b/coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f", size = 211117 }, + { url = "https://files.pythonhosted.org/packages/d5/db/829185120c1686fa297294f8fcd23e0422f71070bf85ef1cc1a72ecb2930/coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558", size = 212019 }, + { url = "https://files.pythonhosted.org/packages/e2/7f/4af2ed1d06ce6bee7eafc03b2ef748b14132b0bdae04388e451e4b2c529b/coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad", size = 208645 }, + { url = "https://files.pythonhosted.org/packages/dc/60/d19df912989117caa95123524d26fc973f56dc14aecdec5ccd7d0084e131/coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3", size = 208898 }, + { url = "https://files.pythonhosted.org/packages/bd/10/fecabcf438ba676f706bf90186ccf6ff9f6158cc494286965c76e58742fa/coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574", size = 242987 }, + { url = "https://files.pythonhosted.org/packages/4c/53/4e208440389e8ea936f5f2b0762dcd4cb03281a7722def8e2bf9dc9c3d68/coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985", size = 239881 }, + { url = "https://files.pythonhosted.org/packages/c4/47/2ba744af8d2f0caa1f17e7746147e34dfc5f811fb65fc153153722d58835/coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750", size = 242142 }, + { url = "https://files.pythonhosted.org/packages/e9/90/df726af8ee74d92ee7e3bf113bf101ea4315d71508952bd21abc3fae471e/coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea", size = 241437 }, + { url = "https://files.pythonhosted.org/packages/f6/af/995263fd04ae5f9cf12521150295bf03b6ba940d0aea97953bb4a6db3e2b/coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3", size = 239724 }, + { url = "https://files.pythonhosted.org/packages/1c/8e/5bb04f0318805e190984c6ce106b4c3968a9562a400180e549855d8211bd/coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a", size = 241329 }, + { url = "https://files.pythonhosted.org/packages/9e/9d/fa04d9e6c3f6459f4e0b231925277cfc33d72dfab7fa19c312c03e59da99/coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95", size = 211289 }, + { url = "https://files.pythonhosted.org/packages/53/40/53c7ffe3c0c3fff4d708bc99e65f3d78c129110d6629736faf2dbd60ad57/coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288", size = 212079 }, + { url = "https://files.pythonhosted.org/packages/76/89/1adf3e634753c0de3dad2f02aac1e73dba58bc5a3a914ac94a25b2ef418f/coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1", size = 208673 }, + { url = "https://files.pythonhosted.org/packages/ce/64/92a4e239d64d798535c5b45baac6b891c205a8a2e7c9cc8590ad386693dc/coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd", size = 208945 }, + { url = "https://files.pythonhosted.org/packages/b4/d0/4596a3ef3bca20a94539c9b1e10fd250225d1dec57ea78b0867a1cf9742e/coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9", size = 242484 }, + { url = "https://files.pythonhosted.org/packages/1c/ef/6fd0d344695af6718a38d0861408af48a709327335486a7ad7e85936dc6e/coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e", size = 239525 }, + { url = "https://files.pythonhosted.org/packages/0c/4b/373be2be7dd42f2bcd6964059fd8fa307d265a29d2b9bcf1d044bcc156ed/coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4", size = 241545 }, + { url = "https://files.pythonhosted.org/packages/a6/7d/0e83cc2673a7790650851ee92f72a343827ecaaea07960587c8f442b5cd3/coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6", size = 241179 }, + { url = "https://files.pythonhosted.org/packages/ff/8c/566ea92ce2bb7627b0900124e24a99f9244b6c8c92d09ff9f7633eb7c3c8/coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3", size = 239288 }, + { url = "https://files.pythonhosted.org/packages/7d/e4/869a138e50b622f796782d642c15fb5f25a5870c6d0059a663667a201638/coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc", size = 241032 }, + { url = "https://files.pythonhosted.org/packages/ae/28/a52ff5d62a9f9e9fe9c4f17759b98632edd3a3489fce70154c7d66054dd3/coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3", size = 211315 }, + { url = "https://files.pythonhosted.org/packages/bc/17/ab849b7429a639f9722fa5628364c28d675c7ff37ebc3268fe9840dda13c/coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef", size = 212099 }, + { url = "https://files.pythonhosted.org/packages/d2/1c/b9965bf23e171d98505eb5eb4fb4d05c44efd256f2e0f19ad1ba8c3f54b0/coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e", size = 209511 }, + { url = "https://files.pythonhosted.org/packages/57/b3/119c201d3b692d5e17784fee876a9a78e1b3051327de2709392962877ca8/coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703", size = 209729 }, + { url = "https://files.pythonhosted.org/packages/52/4e/a7feb5a56b266304bc59f872ea07b728e14d5a64f1ad3a2cc01a3259c965/coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0", size = 253988 }, + { url = "https://files.pythonhosted.org/packages/65/19/069fec4d6908d0dae98126aa7ad08ce5130a6decc8509da7740d36e8e8d2/coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924", size = 249697 }, + { url = "https://files.pythonhosted.org/packages/1c/da/5b19f09ba39df7c55f77820736bf17bbe2416bbf5216a3100ac019e15839/coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b", size = 252033 }, + { url = "https://files.pythonhosted.org/packages/1e/89/4c2750df7f80a7872267f7c5fe497c69d45f688f7b3afe1297e52e33f791/coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d", size = 251535 }, + { url = "https://files.pythonhosted.org/packages/78/3b/6d3ae3c1cc05f1b0460c51e6f6dcf567598cbd7c6121e5ad06643974703c/coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827", size = 249192 }, + { url = "https://files.pythonhosted.org/packages/6e/8e/c14a79f535ce41af7d436bbad0d3d90c43d9e38ec409b4770c894031422e/coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9", size = 250627 }, + { url = "https://files.pythonhosted.org/packages/cb/79/b7cee656cfb17a7f2c1b9c3cee03dd5d8000ca299ad4038ba64b61a9b044/coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3", size = 212033 }, + { url = "https://files.pythonhosted.org/packages/b6/c3/f7aaa3813f1fa9a4228175a7bd368199659d392897e184435a3b66408dd3/coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f", size = 213240 }, + { url = "https://files.pythonhosted.org/packages/fb/b2/f655700e1024dec98b10ebaafd0cedbc25e40e4abe62a3c8e2ceef4f8f0a/coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953", size = 200552 }, ] [package.optional-dependencies] @@ -336,33 +340,37 @@ toml = [ [[package]] name = "cryptography" -version = "44.0.0" +version = "44.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/4c/45dfa6829acffa344e3967d6006ee4ae8be57af746ae2eba1c431949b32c/cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02", size = 710657 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/09/8cc67f9b84730ad330b3b72cf867150744bf07ff113cda21a15a1c6d2c7c/cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123", size = 6541833 }, - { url = "https://files.pythonhosted.org/packages/7e/5b/3759e30a103144e29632e7cb72aec28cedc79e514b2ea8896bb17163c19b/cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092", size = 3922710 }, - { url = "https://files.pythonhosted.org/packages/5f/58/3b14bf39f1a0cfd679e753e8647ada56cddbf5acebffe7db90e184c76168/cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f", size = 4137546 }, - { url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420 }, - { url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498 }, - { url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569 }, - { url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721 }, - { url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915 }, - { url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925 }, - { url = "https://files.pythonhosted.org/packages/64/b1/50d7739254d2002acae64eed4fc43b24ac0cc44bf0a0d388d1ca06ec5bb1/cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd", size = 3202055 }, - { url = "https://files.pythonhosted.org/packages/11/18/61e52a3d28fc1514a43b0ac291177acd1b4de00e9301aaf7ef867076ff8a/cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591", size = 6542801 }, - { url = "https://files.pythonhosted.org/packages/1a/07/5f165b6c65696ef75601b781a280fc3b33f1e0cd6aa5a92d9fb96c410e97/cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7", size = 3922613 }, - { url = "https://files.pythonhosted.org/packages/28/34/6b3ac1d80fc174812486561cf25194338151780f27e438526f9c64e16869/cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc", size = 4137925 }, - { url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417 }, - { url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160 }, - { url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331 }, - { url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372 }, - { url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657 }, - { url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672 }, - { url = "https://files.pythonhosted.org/packages/97/9b/443270b9210f13f6ef240eff73fd32e02d381e7103969dc66ce8e89ee901/cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede", size = 3202071 }, +sdist = { url = "https://files.pythonhosted.org/packages/c7/67/545c79fe50f7af51dbad56d16b23fe33f63ee6a5d956b3cb68ea110cbe64/cryptography-44.0.1.tar.gz", hash = "sha256:f51f5705ab27898afda1aaa430f34ad90dc117421057782022edf0600bec5f14", size = 710819 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/27/5e3524053b4c8889da65cf7814a9d0d8514a05194a25e1e34f46852ee6eb/cryptography-44.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf688f615c29bfe9dfc44312ca470989279f0e94bb9f631f85e3459af8efc009", size = 6642022 }, + { url = "https://files.pythonhosted.org/packages/34/b9/4d1fa8d73ae6ec350012f89c3abfbff19fc95fe5420cf972e12a8d182986/cryptography-44.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd7c7e2d71d908dc0f8d2027e1604102140d84b155e658c20e8ad1304317691f", size = 3943865 }, + { url = "https://files.pythonhosted.org/packages/6e/57/371a9f3f3a4500807b5fcd29fec77f418ba27ffc629d88597d0d1049696e/cryptography-44.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887143b9ff6bad2b7570da75a7fe8bbf5f65276365ac259a5d2d5147a73775f2", size = 4162562 }, + { url = "https://files.pythonhosted.org/packages/c5/1d/5b77815e7d9cf1e3166988647f336f87d5634a5ccecec2ffbe08ef8dd481/cryptography-44.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:322eb03ecc62784536bc173f1483e76747aafeb69c8728df48537eb431cd1911", size = 3951923 }, + { url = "https://files.pythonhosted.org/packages/28/01/604508cd34a4024467cd4105887cf27da128cba3edd435b54e2395064bfb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:21377472ca4ada2906bc313168c9dc7b1d7ca417b63c1c3011d0c74b7de9ae69", size = 3685194 }, + { url = "https://files.pythonhosted.org/packages/c6/3d/d3c55d4f1d24580a236a6753902ef6d8aafd04da942a1ee9efb9dc8fd0cb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:df978682c1504fc93b3209de21aeabf2375cb1571d4e61907b3e7a2540e83026", size = 4187790 }, + { url = "https://files.pythonhosted.org/packages/ea/a6/44d63950c8588bfa8594fd234d3d46e93c3841b8e84a066649c566afb972/cryptography-44.0.1-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:eb3889330f2a4a148abead555399ec9a32b13b7c8ba969b72d8e500eb7ef84cd", size = 3951343 }, + { url = "https://files.pythonhosted.org/packages/c1/17/f5282661b57301204cbf188254c1a0267dbd8b18f76337f0a7ce1038888c/cryptography-44.0.1-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6a85a93d0642bd774460a86513c5d9d80b5c002ca9693e63f6e540f1815ed0", size = 4187127 }, + { url = "https://files.pythonhosted.org/packages/f3/68/abbae29ed4f9d96596687f3ceea8e233f65c9645fbbec68adb7c756bb85a/cryptography-44.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6f76fdd6fd048576a04c5210d53aa04ca34d2ed63336d4abd306d0cbe298fddf", size = 4070666 }, + { url = "https://files.pythonhosted.org/packages/0f/10/cf91691064a9e0a88ae27e31779200b1505d3aee877dbe1e4e0d73b4f155/cryptography-44.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6c8acf6f3d1f47acb2248ec3ea261171a671f3d9428e34ad0357148d492c7864", size = 4288811 }, + { url = "https://files.pythonhosted.org/packages/38/78/74ea9eb547d13c34e984e07ec8a473eb55b19c1451fe7fc8077c6a4b0548/cryptography-44.0.1-cp37-abi3-win32.whl", hash = "sha256:24979e9f2040c953a94bf3c6782e67795a4c260734e5264dceea65c8f4bae64a", size = 2771882 }, + { url = "https://files.pythonhosted.org/packages/cf/6c/3907271ee485679e15c9f5e93eac6aa318f859b0aed8d369afd636fafa87/cryptography-44.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:fd0ee90072861e276b0ff08bd627abec29e32a53b2be44e41dbcdf87cbee2b00", size = 3206989 }, + { url = "https://files.pythonhosted.org/packages/9f/f1/676e69c56a9be9fd1bffa9bc3492366901f6e1f8f4079428b05f1414e65c/cryptography-44.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a2d8a7045e1ab9b9f803f0d9531ead85f90c5f2859e653b61497228b18452008", size = 6643714 }, + { url = "https://files.pythonhosted.org/packages/ba/9f/1775600eb69e72d8f9931a104120f2667107a0ee478f6ad4fe4001559345/cryptography-44.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8272f257cf1cbd3f2e120f14c68bff2b6bdfcc157fafdee84a1b795efd72862", size = 3943269 }, + { url = "https://files.pythonhosted.org/packages/25/ba/e00d5ad6b58183829615be7f11f55a7b6baa5a06910faabdc9961527ba44/cryptography-44.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e8d181e90a777b63f3f0caa836844a1182f1f265687fac2115fcf245f5fbec3", size = 4166461 }, + { url = "https://files.pythonhosted.org/packages/b3/45/690a02c748d719a95ab08b6e4decb9d81e0ec1bac510358f61624c86e8a3/cryptography-44.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:436df4f203482f41aad60ed1813811ac4ab102765ecae7a2bbb1dbb66dcff5a7", size = 3950314 }, + { url = "https://files.pythonhosted.org/packages/e6/50/bf8d090911347f9b75adc20f6f6569ed6ca9b9bff552e6e390f53c2a1233/cryptography-44.0.1-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4f422e8c6a28cf8b7f883eb790695d6d45b0c385a2583073f3cec434cc705e1a", size = 3686675 }, + { url = "https://files.pythonhosted.org/packages/e1/e7/cfb18011821cc5f9b21efb3f94f3241e3a658d267a3bf3a0f45543858ed8/cryptography-44.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:72198e2b5925155497a5a3e8c216c7fb3e64c16ccee11f0e7da272fa93b35c4c", size = 4190429 }, + { url = "https://files.pythonhosted.org/packages/07/ef/77c74d94a8bfc1a8a47b3cafe54af3db537f081742ee7a8a9bd982b62774/cryptography-44.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a46a89ad3e6176223b632056f321bc7de36b9f9b93b2cc1cccf935a3849dc62", size = 3950039 }, + { url = "https://files.pythonhosted.org/packages/6d/b9/8be0ff57c4592382b77406269b1e15650c9f1a167f9e34941b8515b97159/cryptography-44.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:53f23339864b617a3dfc2b0ac8d5c432625c80014c25caac9082314e9de56f41", size = 4189713 }, + { url = "https://files.pythonhosted.org/packages/78/e1/4b6ac5f4100545513b0847a4d276fe3c7ce0eacfa73e3b5ebd31776816ee/cryptography-44.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:888fcc3fce0c888785a4876ca55f9f43787f4c5c1cc1e2e0da71ad481ff82c5b", size = 4071193 }, + { url = "https://files.pythonhosted.org/packages/3d/cb/afff48ceaed15531eab70445abe500f07f8f96af2bb35d98af6bfa89ebd4/cryptography-44.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00918d859aa4e57db8299607086f793fa7813ae2ff5a4637e318a25ef82730f7", size = 4289566 }, + { url = "https://files.pythonhosted.org/packages/30/6f/4eca9e2e0f13ae459acd1ca7d9f0257ab86e68f44304847610afcb813dc9/cryptography-44.0.1-cp39-abi3-win32.whl", hash = "sha256:9b336599e2cb77b1008cb2ac264b290803ec5e8e89d618a5e978ff5eb6f715d9", size = 2772371 }, + { url = "https://files.pythonhosted.org/packages/d2/05/5533d30f53f10239616a357f080892026db2d550a40c393d0a8a7af834a9/cryptography-44.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:e403f7f766ded778ecdb790da786b418a9f2394f36e8cc8b796cc056ab05f44f", size = 3207303 }, ] [[package]] @@ -469,11 +477,11 @@ wheels = [ [[package]] name = "identify" -version = "2.6.6" +version = "2.6.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/bf/c68c46601bacd4c6fb4dd751a42b6e7087240eaabc6487f2ef7a48e0e8fc/identify-2.6.6.tar.gz", hash = "sha256:7bec12768ed44ea4761efb47806f0a41f86e7c0a5fdf5950d4648c90eca7e251", size = 99217 } +sdist = { url = "https://files.pythonhosted.org/packages/83/d1/524aa3350f78bcd714d148ade6133d67d6b7de2cdbae7d99039c024c9a25/identify-2.6.7.tar.gz", hash = "sha256:3fa266b42eba321ee0b2bb0936a6a6b9e36a1351cbb69055b3082f4193035684", size = 99260 } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/a1/68a395c17eeefb04917034bd0a1bfa765e7654fa150cca473d669aa3afb5/identify-2.6.6-py2.py3-none-any.whl", hash = "sha256:cbd1810bce79f8b671ecb20f53ee0ae8e86ae84b557de31d89709dc2a48ba881", size = 99083 }, + { url = "https://files.pythonhosted.org/packages/03/00/1fd4a117c6c93f2dcc5b7edaeaf53ea45332ef966429be566ca16c2beb94/identify-2.6.7-py2.py3-none-any.whl", hash = "sha256:155931cb617a401807b09ecec6635d6c692d180090a1cedca8ef7d58ba5b6aa0", size = 99097 }, ] [[package]] @@ -711,33 +719,33 @@ wheels = [ [[package]] name = "mypy" -version = "1.14.1" +version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/eb/2c92d8ea1e684440f54fa49ac5d9a5f19967b7b472a281f419e69a8d228e/mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6", size = 3216051 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/11/a9422850fd506edbcdc7f6090682ecceaf1f87b9dd847f9df79942da8506/mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c", size = 11120432 }, - { url = "https://files.pythonhosted.org/packages/b6/9e/47e450fd39078d9c02d620545b2cb37993a8a8bdf7db3652ace2f80521ca/mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1", size = 10279515 }, - { url = "https://files.pythonhosted.org/packages/01/b5/6c8d33bd0f851a7692a8bfe4ee75eb82b6983a3cf39e5e32a5d2a723f0c1/mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8", size = 12025791 }, - { url = "https://files.pythonhosted.org/packages/f0/4c/e10e2c46ea37cab5c471d0ddaaa9a434dc1d28650078ac1b56c2d7b9b2e4/mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f", size = 12749203 }, - { url = "https://files.pythonhosted.org/packages/88/55/beacb0c69beab2153a0f57671ec07861d27d735a0faff135a494cd4f5020/mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1", size = 12885900 }, - { url = "https://files.pythonhosted.org/packages/a2/75/8c93ff7f315c4d086a2dfcde02f713004357d70a163eddb6c56a6a5eff40/mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae", size = 9777869 }, - { url = "https://files.pythonhosted.org/packages/43/1b/b38c079609bb4627905b74fc6a49849835acf68547ac33d8ceb707de5f52/mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14", size = 11266668 }, - { url = "https://files.pythonhosted.org/packages/6b/75/2ed0d2964c1ffc9971c729f7a544e9cd34b2cdabbe2d11afd148d7838aa2/mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9", size = 10254060 }, - { url = "https://files.pythonhosted.org/packages/a1/5f/7b8051552d4da3c51bbe8fcafffd76a6823779101a2b198d80886cd8f08e/mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11", size = 11933167 }, - { url = "https://files.pythonhosted.org/packages/04/90/f53971d3ac39d8b68bbaab9a4c6c58c8caa4d5fd3d587d16f5927eeeabe1/mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e", size = 12864341 }, - { url = "https://files.pythonhosted.org/packages/03/d2/8bc0aeaaf2e88c977db41583559319f1821c069e943ada2701e86d0430b7/mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89", size = 12972991 }, - { url = "https://files.pythonhosted.org/packages/6f/17/07815114b903b49b0f2cf7499f1c130e5aa459411596668267535fe9243c/mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b", size = 9879016 }, - { url = "https://files.pythonhosted.org/packages/9e/15/bb6a686901f59222275ab228453de741185f9d54fecbaacec041679496c6/mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255", size = 11252097 }, - { url = "https://files.pythonhosted.org/packages/f8/b3/8b0f74dfd072c802b7fa368829defdf3ee1566ba74c32a2cb2403f68024c/mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34", size = 10239728 }, - { url = "https://files.pythonhosted.org/packages/c5/9b/4fd95ab20c52bb5b8c03cc49169be5905d931de17edfe4d9d2986800b52e/mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a", size = 11924965 }, - { url = "https://files.pythonhosted.org/packages/56/9d/4a236b9c57f5d8f08ed346914b3f091a62dd7e19336b2b2a0d85485f82ff/mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9", size = 12867660 }, - { url = "https://files.pythonhosted.org/packages/40/88/a61a5497e2f68d9027de2bb139c7bb9abaeb1be1584649fa9d807f80a338/mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd", size = 12969198 }, - { url = "https://files.pythonhosted.org/packages/54/da/3d6fc5d92d324701b0c23fb413c853892bfe0e1dbe06c9138037d459756b/mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107", size = 9885276 }, - { url = "https://files.pythonhosted.org/packages/a0/b5/32dd67b69a16d088e533962e5044e51004176a9952419de0370cdaead0f8/mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1", size = 2752905 }, +sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/bc/f6339726c627bd7ca1ce0fa56c9ae2d0144604a319e0e339bdadafbbb599/mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f", size = 10662338 }, + { url = "https://files.pythonhosted.org/packages/e2/90/8dcf506ca1a09b0d17555cc00cd69aee402c203911410136cd716559efe7/mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5", size = 9787540 }, + { url = "https://files.pythonhosted.org/packages/05/05/a10f9479681e5da09ef2f9426f650d7b550d4bafbef683b69aad1ba87457/mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e", size = 11538051 }, + { url = "https://files.pythonhosted.org/packages/e9/9a/1f7d18b30edd57441a6411fcbc0c6869448d1a4bacbaee60656ac0fc29c8/mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c", size = 12286751 }, + { url = "https://files.pythonhosted.org/packages/72/af/19ff499b6f1dafcaf56f9881f7a965ac2f474f69f6f618b5175b044299f5/mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f", size = 12421783 }, + { url = "https://files.pythonhosted.org/packages/96/39/11b57431a1f686c1aed54bf794870efe0f6aeca11aca281a0bd87a5ad42c/mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", size = 9265618 }, + { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981 }, + { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175 }, + { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675 }, + { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020 }, + { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582 }, + { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614 }, + { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592 }, + { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611 }, + { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443 }, + { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541 }, + { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348 }, + { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648 }, + { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 }, ] [[package]] @@ -751,7 +759,7 @@ wheels = [ [[package]] name = "myst-parser" -version = "4.0.0" +version = "4.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, @@ -761,9 +769,9 @@ dependencies = [ { name = "pyyaml" }, { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/55/6d1741a1780e5e65038b74bce6689da15f620261c490c3511eb4c12bac4b/myst_parser-4.0.0.tar.gz", hash = "sha256:851c9dfb44e36e56d15d05e72f02b80da21a9e0d07cba96baf5e2d476bb91531", size = 93858 } +sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/b4/b036f8fdb667587bb37df29dc6644681dd78b7a2a6321a34684b79412b28/myst_parser-4.0.0-py3-none-any.whl", hash = "sha256:b9317997552424448c6096c2558872fdb6f81d3ecb3a40ce84a7518798f3f28d", size = 84563 }, + { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579 }, ] [[package]] @@ -1106,7 +1114,7 @@ wheels = [ [[package]] name = "python-kasa" -version = "0.10.1" +version = "0.10.2" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -1258,27 +1266,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.9.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/17/529e78f49fc6f8076f50d985edd9a2cf011d1dbadb1cdeacc1d12afc1d26/ruff-0.9.4.tar.gz", hash = "sha256:6907ee3529244bb0ed066683e075f09285b38dd5b4039370df6ff06041ca19e7", size = 3599458 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/f8/3fafb7804d82e0699a122101b5bee5f0d6e17c3a806dcbc527bb7d3f5b7a/ruff-0.9.4-py3-none-linux_armv6l.whl", hash = "sha256:64e73d25b954f71ff100bb70f39f1ee09e880728efb4250c632ceed4e4cdf706", size = 11668400 }, - { url = "https://files.pythonhosted.org/packages/2e/a6/2efa772d335da48a70ab2c6bb41a096c8517ca43c086ea672d51079e3d1f/ruff-0.9.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6ce6743ed64d9afab4fafeaea70d3631b4d4b28b592db21a5c2d1f0ef52934bf", size = 11628395 }, - { url = "https://files.pythonhosted.org/packages/dc/d7/cd822437561082f1c9d7225cc0d0fbb4bad117ad7ac3c41cd5d7f0fa948c/ruff-0.9.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:54499fb08408e32b57360f6f9de7157a5fec24ad79cb3f42ef2c3f3f728dfe2b", size = 11090052 }, - { url = "https://files.pythonhosted.org/packages/9e/67/3660d58e893d470abb9a13f679223368ff1684a4ef40f254a0157f51b448/ruff-0.9.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37c892540108314a6f01f105040b5106aeb829fa5fb0561d2dcaf71485021137", size = 11882221 }, - { url = "https://files.pythonhosted.org/packages/79/d1/757559995c8ba5f14dfec4459ef2dd3fcea82ac43bc4e7c7bf47484180c0/ruff-0.9.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de9edf2ce4b9ddf43fd93e20ef635a900e25f622f87ed6e3047a664d0e8f810e", size = 11424862 }, - { url = "https://files.pythonhosted.org/packages/c0/96/7915a7c6877bb734caa6a2af424045baf6419f685632469643dbd8eb2958/ruff-0.9.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87c90c32357c74f11deb7fbb065126d91771b207bf9bfaaee01277ca59b574ec", size = 12626735 }, - { url = "https://files.pythonhosted.org/packages/0e/cc/dadb9b35473d7cb17c7ffe4737b4377aeec519a446ee8514123ff4a26091/ruff-0.9.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:56acd6c694da3695a7461cc55775f3a409c3815ac467279dfa126061d84b314b", size = 13255976 }, - { url = "https://files.pythonhosted.org/packages/5f/c3/ad2dd59d3cabbc12df308cced780f9c14367f0321e7800ca0fe52849da4c/ruff-0.9.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0c93e7d47ed951b9394cf352d6695b31498e68fd5782d6cbc282425655f687a", size = 12752262 }, - { url = "https://files.pythonhosted.org/packages/c7/17/5f1971e54bd71604da6788efd84d66d789362b1105e17e5ccc53bba0289b/ruff-0.9.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d4c8772670aecf037d1bf7a07c39106574d143b26cfe5ed1787d2f31e800214", size = 14401648 }, - { url = "https://files.pythonhosted.org/packages/30/24/6200b13ea611b83260501b6955b764bb320e23b2b75884c60ee7d3f0b68e/ruff-0.9.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfc5f1d7afeda8d5d37660eeca6d389b142d7f2b5a1ab659d9214ebd0e025231", size = 12414702 }, - { url = "https://files.pythonhosted.org/packages/34/cb/f5d50d0c4ecdcc7670e348bd0b11878154bc4617f3fdd1e8ad5297c0d0ba/ruff-0.9.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:faa935fc00ae854d8b638c16a5f1ce881bc3f67446957dd6f2af440a5fc8526b", size = 11859608 }, - { url = "https://files.pythonhosted.org/packages/d6/f4/9c8499ae8426da48363bbb78d081b817b0f64a9305f9b7f87eab2a8fb2c1/ruff-0.9.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6c634fc6f5a0ceae1ab3e13c58183978185d131a29c425e4eaa9f40afe1e6d6", size = 11485702 }, - { url = "https://files.pythonhosted.org/packages/18/59/30490e483e804ccaa8147dd78c52e44ff96e1c30b5a95d69a63163cdb15b/ruff-0.9.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:433dedf6ddfdec7f1ac7575ec1eb9844fa60c4c8c2f8887a070672b8d353d34c", size = 12067782 }, - { url = "https://files.pythonhosted.org/packages/3d/8c/893fa9551760b2f8eb2a351b603e96f15af167ceaf27e27ad873570bc04c/ruff-0.9.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d612dbd0f3a919a8cc1d12037168bfa536862066808960e0cc901404b77968f0", size = 12483087 }, - { url = "https://files.pythonhosted.org/packages/23/15/f6751c07c21ca10e3f4a51ea495ca975ad936d780c347d9808bcedbd7182/ruff-0.9.4-py3-none-win32.whl", hash = "sha256:db1192ddda2200671f9ef61d9597fcef89d934f5d1705e571a93a67fb13a4402", size = 9852302 }, - { url = "https://files.pythonhosted.org/packages/12/41/2d2d2c6a72e62566f730e49254f602dfed23019c33b5b21ea8f8917315a1/ruff-0.9.4-py3-none-win_amd64.whl", hash = "sha256:05bebf4cdbe3ef75430d26c375773978950bbf4ee3c95ccb5448940dc092408e", size = 10850051 }, - { url = "https://files.pythonhosted.org/packages/c6/e6/3d6ec3bc3d254e7f005c543a661a41c3e788976d0e52a1ada195bd664344/ruff-0.9.4-py3-none-win_arm64.whl", hash = "sha256:585792f1e81509e38ac5123492f8875fbc36f3ede8185af0a26df348e5154f41", size = 10078251 }, +version = "0.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/e1/e265aba384343dd8ddd3083f5e33536cd17e1566c41453a5517b5dd443be/ruff-0.9.6.tar.gz", hash = "sha256:81761592f72b620ec8fa1068a6fd00e98a5ebee342a3642efd84454f3031dca9", size = 3639454 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/e3/3d2c022e687e18cf5d93d6bfa2722d46afc64eaa438c7fbbdd603b3597be/ruff-0.9.6-py3-none-linux_armv6l.whl", hash = "sha256:2f218f356dd2d995839f1941322ff021c72a492c470f0b26a34f844c29cdf5ba", size = 11714128 }, + { url = "https://files.pythonhosted.org/packages/e1/22/aff073b70f95c052e5c58153cba735748c9e70107a77d03420d7850710a0/ruff-0.9.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b908ff4df65dad7b251c9968a2e4560836d8f5487c2f0cc238321ed951ea0504", size = 11682539 }, + { url = "https://files.pythonhosted.org/packages/75/a7/f5b7390afd98a7918582a3d256cd3e78ba0a26165a467c1820084587cbf9/ruff-0.9.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b109c0ad2ececf42e75fa99dc4043ff72a357436bb171900714a9ea581ddef83", size = 11132512 }, + { url = "https://files.pythonhosted.org/packages/a6/e3/45de13ef65047fea2e33f7e573d848206e15c715e5cd56095589a7733d04/ruff-0.9.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de4367cca3dac99bcbd15c161404e849bb0bfd543664db39232648dc00112dc", size = 11929275 }, + { url = "https://files.pythonhosted.org/packages/7d/f2/23d04cd6c43b2e641ab961ade8d0b5edb212ecebd112506188c91f2a6e6c/ruff-0.9.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3ee4d7c2c92ddfdaedf0bf31b2b176fa7aa8950efc454628d477394d35638b", size = 11466502 }, + { url = "https://files.pythonhosted.org/packages/b5/6f/3a8cf166f2d7f1627dd2201e6cbc4cb81f8b7d58099348f0c1ff7b733792/ruff-0.9.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dc1edd1775270e6aa2386119aea692039781429f0be1e0949ea5884e011aa8e", size = 12676364 }, + { url = "https://files.pythonhosted.org/packages/f5/c4/db52e2189983c70114ff2b7e3997e48c8318af44fe83e1ce9517570a50c6/ruff-0.9.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4a091729086dffa4bd070aa5dab7e39cc6b9d62eb2bef8f3d91172d30d599666", size = 13335518 }, + { url = "https://files.pythonhosted.org/packages/66/44/545f8a4d136830f08f4d24324e7db957c5374bf3a3f7a6c0bc7be4623a37/ruff-0.9.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1bbc6808bf7b15796cef0815e1dfb796fbd383e7dbd4334709642649625e7c5", size = 12823287 }, + { url = "https://files.pythonhosted.org/packages/c5/26/8208ef9ee7431032c143649a9967c3ae1aae4257d95e6f8519f07309aa66/ruff-0.9.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:589d1d9f25b5754ff230dce914a174a7c951a85a4e9270613a2b74231fdac2f5", size = 14592374 }, + { url = "https://files.pythonhosted.org/packages/31/70/e917781e55ff39c5b5208bda384fd397ffd76605e68544d71a7e40944945/ruff-0.9.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc61dd5131742e21103fbbdcad683a8813be0e3c204472d520d9a5021ca8b217", size = 12500173 }, + { url = "https://files.pythonhosted.org/packages/84/f5/e4ddee07660f5a9622a9c2b639afd8f3104988dc4f6ba0b73ffacffa9a8c/ruff-0.9.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5e2d9126161d0357e5c8f30b0bd6168d2c3872372f14481136d13de9937f79b6", size = 11906555 }, + { url = "https://files.pythonhosted.org/packages/f1/2b/6ff2fe383667075eef8656b9892e73dd9b119b5e3add51298628b87f6429/ruff-0.9.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:68660eab1a8e65babb5229a1f97b46e3120923757a68b5413d8561f8a85d4897", size = 11538958 }, + { url = "https://files.pythonhosted.org/packages/3c/db/98e59e90de45d1eb46649151c10a062d5707b5b7f76f64eb1e29edf6ebb1/ruff-0.9.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4cae6c4cc7b9b4017c71114115db0445b00a16de3bcde0946273e8392856f08", size = 12117247 }, + { url = "https://files.pythonhosted.org/packages/ec/bc/54e38f6d219013a9204a5a2015c09e7a8c36cedcd50a4b01ac69a550b9d9/ruff-0.9.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19f505b643228b417c1111a2a536424ddde0db4ef9023b9e04a46ed8a1cb4656", size = 12554647 }, + { url = "https://files.pythonhosted.org/packages/a5/7d/7b461ab0e2404293c0627125bb70ac642c2e8d55bf590f6fce85f508f1b2/ruff-0.9.6-py3-none-win32.whl", hash = "sha256:194d8402bceef1b31164909540a597e0d913c0e4952015a5b40e28c146121b5d", size = 9949214 }, + { url = "https://files.pythonhosted.org/packages/ee/30/c3cee10f915ed75a5c29c1e57311282d1a15855551a64795c1b2bbe5cf37/ruff-0.9.6-py3-none-win_amd64.whl", hash = "sha256:03482d5c09d90d4ee3f40d97578423698ad895c87314c4de39ed2af945633caa", size = 10999914 }, + { url = "https://files.pythonhosted.org/packages/e8/a8/d71f44b93e3aa86ae232af1f2126ca7b95c0f515ec135462b3e1f351441c/ruff-0.9.6-py3-none-win_arm64.whl", hash = "sha256:0e2bb706a2be7ddfea4a4af918562fdc1bcb16df255e5fa595bbd800ce322a5a", size = 10177499 }, ] [[package]] @@ -1513,16 +1521,16 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.29.1" +version = "20.29.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/ca/f23dcb02e161a9bba141b1c08aa50e8da6ea25e6d780528f1d385a3efe25/virtualenv-20.29.1.tar.gz", hash = "sha256:b8b8970138d32fb606192cb97f6cd4bb644fa486be9308fb9b63f81091b5dc35", size = 7658028 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/88/dacc875dd54a8acadb4bcbfd4e3e86df8be75527116c91d8f9784f5e9cab/virtualenv-20.29.2.tar.gz", hash = "sha256:fdaabebf6d03b5ba83ae0a02cfe96f48a716f4fae556461d180825866f75b728", size = 4320272 } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/9b/599bcfc7064fbe5740919e78c5df18e5dceb0887e676256a1061bb5ae232/virtualenv-20.29.1-py3-none-any.whl", hash = "sha256:4e4cb403c0b0da39e13b46b1b2476e505cb0046b25f242bee80f62bf990b2779", size = 4282379 }, + { url = "https://files.pythonhosted.org/packages/93/fa/849483d56773ae29740ae70043ad88e068f98a6401aa819b5d6bee604683/virtualenv-20.29.2-py3-none-any.whl", hash = "sha256:febddfc3d1ea571bdb1dc0f98d7b45d24def7428214d4fb73cc486c9568cce6a", size = 4301478 }, ] [[package]] From f0abc2800dc7127034713e5ac2862592f536ba2b Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:42:12 -0500 Subject: [PATCH 858/892] Add KS225(US)_1.0_1.1.1 and L930-5(EU)_1.0_1.2.5 (#1509) Added fixture files, one device was added directly into HomeKit, and one device was added through Matter from the obd_src. I'm not sure if that makes a difference for you, but the devices are working correctly through my plug-in with the latest python-kasa 0.10.2. --- SUPPORTED.md | 2 + tests/fixtures/smart/KS225(US)_1.0_1.1.1.json | 304 ++++++++++ .../fixtures/smart/L930-5(EU)_1.0_1.2.5.json | 528 ++++++++++++++++++ 3 files changed, 834 insertions(+) create mode 100644 tests/fixtures/smart/KS225(US)_1.0_1.1.1.json create mode 100644 tests/fixtures/smart/L930-5(EU)_1.0_1.2.5.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 813fa65c3..ac317f6c8 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -118,6 +118,7 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - **KS225** - Hardware: 1.0 (US) / Firmware: 1.0.2[^1] - Hardware: 1.0 (US) / Firmware: 1.1.0[^1] + - Hardware: 1.0 (US) / Firmware: 1.1.1[^1] - **KS230** - Hardware: 1.0 (US) / Firmware: 1.0.14 - Hardware: 2.0 (US) / Firmware: 1.0.11 @@ -269,6 +270,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (US) / Firmware: 1.1.0 - Hardware: 1.0 (US) / Firmware: 1.1.3 - **L930-5** + - Hardware: 1.0 (EU) / Firmware: 1.2.5 - Hardware: 1.0 (US) / Firmware: 1.1.2 ### Cameras diff --git a/tests/fixtures/smart/KS225(US)_1.0_1.1.1.json b/tests/fixtures/smart/KS225(US)_1.0_1.1.1.json new file mode 100644 index 000000000..bb0bb6d60 --- /dev/null +++ b/tests/fixtures/smart/KS225(US)_1.0_1.1.1.json @@ -0,0 +1,304 @@ +{ + "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": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS225(US)", + "device_type": "SMART.KASASWITCH", + "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": "matter", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "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": "switch_s500d", + "brightness": 25, + "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.1.1 Build 240626 Rel.175125", + "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": "KS225", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/Toronto", + "rssi": -38, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Toronto", + "time_diff": -300, + "timestamp": 1739199350 + }, + "get_device_usage": { + "time_usage": { + "past30": 2189, + "past7": 705, + "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.1.1 Build 240626 Rel.175125", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 423, + "night_mode_type": "sunrise_sunset", + "start_time": 1036, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:000000-000000000000" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 3, + "enable": true, + "max_duration": 60 + }, + "on_state": { + "duration": 3, + "enable": true, + "max_duration": 60 + } + }, + "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": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 19, + "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": "KS225", + "device_type": "SMART.KASASWITCH", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/L930-5(EU)_1.0_1.2.5.json b/tests/fixtures/smart/L930-5(EU)_1.0_1.2.5.json new file mode 100644 index 000000000..298e961eb --- /dev/null +++ b/tests/fixtures/smart/L930-5(EU)_1.0_1.2.5.json @@ -0,0 +1,528 @@ +{ + "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": "music_rhythm_v2", + "ver_code": 4 + }, + { + "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": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L930-5(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "78-8C-B5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "apple", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "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": 0, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 0, + "hue": 255, + "saturation": 68 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.5 Build 240727 Rel.102843", + "has_set_location_info": false, + "hue": 255, + "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": "78-8C-B5-00-00-00", + "model": "L930", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/London", + "rssi": -73, + "saturation": 68, + "segment_effect": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "signal_level": 1, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1739740342 + }, + "get_device_usage": { + "power_usage": { + "past30": 3515, + "past7": 314, + "today": 229 + }, + "saved_power": { + "past30": 31361, + "past7": 1442, + "today": 1043 + }, + "time_usage": { + "past30": 34876, + "past7": 1756, + "today": 1272 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_homekit_info": { + "mfi_setup_code": "000-00-000", + "mfi_setup_id": "0000", + "mfi_token_token": "000000000000000000000000000/000000000000000000/000000+00000000/00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000+000000000000000000000000000000000000000000000000/0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000+00000000000000000000=", + "mfi_token_uuid": "00000000-0000-0000-0000-000000000000" + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.2.5 Build 240727 Rel.102843", + "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 + }, + { + "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 + } + ], + "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": [ + { + "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==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 8, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L930", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} From 8501390c6114d0be1508763e392da11b17390b24 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 25 Feb 2025 23:44:44 +0100 Subject: [PATCH 859/892] Add a note to emeter guide being kasa-only (#1512) Related to #1511 --- docs/source/guides/energy.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/source/guides/energy.md b/docs/source/guides/energy.md index d7b5727c3..a177cd1ad 100644 --- a/docs/source/guides/energy.md +++ b/docs/source/guides/energy.md @@ -1,6 +1,10 @@ # Get Energy Consumption and Usage Statistics +:::{note} +The documentation on this page applies only to KASA-branded devices. +::: + :::{note} In order to use the helper methods to calculate the statistics correctly, your devices need to have correct time set. The devices use NTP (123/UDP) and public servers from [NTP Pool Project](https://www.ntppool.org/) to synchronize their time. From 579fd5aa2add10a2f3cc3ce21cb6a006e11de318 Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Tue, 4 Mar 2025 02:16:47 -0500 Subject: [PATCH 860/892] Add LB100(US)_1.0_1.8.11 fixture file (#1515) The LB100 was already in the device_fixtures.py for tests, but was not listed in the supported devices nor did it have a fixture file. --- README.md | 2 +- SUPPORTED.md | 2 + tests/fixtures/iot/LB100(US)_1.0_1.8.11.json | 135 +++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/iot/LB100(US)_1.0_1.8.11.json diff --git a/README.md b/README.md index da2c0ce43..dcafc5502 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: EP10, EP25[^1], HS100[^2], HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M[^1], KP401 - **Power Strips**: EP40, EP40M[^1], HS107, HS300, KP200, KP303, KP400 - **Wall Switches**: ES20M, HS200[^2], HS210, HS220[^2], KP405, KS200, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1] -- **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 +- **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110 - **Light Strips**: KL400L5, KL420L5, KL430 - **Hubs**: KH100[^1] - **Hub-Connected Devices[^3]**: KE100[^1] diff --git a/SUPPORTED.md b/SUPPORTED.md index ac317f6c8..d23de70e0 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -149,6 +149,8 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - **KL60** - Hardware: 1.0 (UN) / Firmware: 1.1.4 - Hardware: 1.0 (US) / Firmware: 1.1.13 +- **LB100** + - Hardware: 1.0 (US) / Firmware: 1.8.11 - **LB110** - Hardware: 1.0 (US) / Firmware: 1.8.11 diff --git a/tests/fixtures/iot/LB100(US)_1.0_1.8.11.json b/tests/fixtures/iot/LB100(US)_1.0_1.8.11.json new file mode 100644 index 000000000..b290a93b2 --- /dev/null +++ b/tests/fixtures/iot/LB100(US)_1.0_1.8.11.json @@ -0,0 +1,135 @@ +{ + "smartlife.iot.common.cloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": 0, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 4400 + } + }, + "smartlife.iot.common.schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_default_behavior": { + "err_code": 0, + "hard_on": { + "mode": "last_status" + }, + "soft_on": { + "mode": "last_status" + } + }, + "get_light_details": { + "color_rendering_index": 80, + "err_code": 0, + "incandescent_equivalent": 50, + "lamp_beam_angle": 270, + "max_lumens": 600, + "max_voltage": 120, + "min_voltage": 110, + "wattage": 7 + }, + "get_light_state": { + "brightness": 50, + "color_temp": 2700, + "err_code": 0, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Smart Wi-Fi LED Bulb with Dimmable Light", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "heapsize": 291960, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 0, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "light_state": { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "mic_mac": "50C7BF000000", + "mic_type": "IOT.SMARTBULB", + "model": "LB100(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 75, + "color_temp": 2700, + "hue": 0, + "index": 1, + "saturation": 0 + }, + { + "brightness": 25, + "color_temp": 2700, + "hue": 0, + "index": 2, + "saturation": 0 + }, + { + "brightness": 1, + "color_temp": 2700, + "hue": 0, + "index": 3, + "saturation": 0 + } + ], + "rssi": -46, + "sw_ver": "1.8.11 Build 191113 Rel.105336" + } + } +} From d60dedd880d21ff85bd527aaeb838899570ce838 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Wed, 21 May 2025 18:46:16 +0200 Subject: [PATCH 861/892] Add TP10(IT) 1.0 1.2.5 fixture (#1538) --- README.md | 2 +- SUPPORTED.md | 2 + tests/device_fixtures.py | 1 + tests/fixtures/smart/TP10(IT)_1.0_1.2.5.json | 346 +++++++++++++++++++ 4 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smart/TP10(IT)_1.0_1.2.5.json diff --git a/README.md b/README.md index dcafc5502..897370104 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ The following devices have been tested and confirmed as working. If your device ### Supported Tapo[^1] devices -- **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15 +- **Plugs**: P100, P110, P110M, P115, P125M, P135, TP10, TP15 - **Power Strips**: P210M, P300, P304M, P306, TP25 - **Wall Switches**: S210, S220, S500D, S505, S505D - **Bulbs**: L510B, L510E, L530B, L530E, L630 diff --git a/SUPPORTED.md b/SUPPORTED.md index d23de70e0..2cb00bcb8 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -209,6 +209,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **P135** - Hardware: 1.0 (US) / Firmware: 1.0.5 - Hardware: 1.0 (US) / Firmware: 1.2.0 +- **TP10** + - Hardware: 1.0 (IT) / Firmware: 1.2.5 - **TP15** - Hardware: 1.0 (US) / Firmware: 1.0.3 diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index f9511a1c8..297b497b8 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -87,6 +87,7 @@ "KP125M", "EP25", "P125M", + "TP10", "TP15", } PLUGS = { diff --git a/tests/fixtures/smart/TP10(IT)_1.0_1.2.5.json b/tests/fixtures/smart/TP10(IT)_1.0_1.2.5.json new file mode 100644 index 000000000..5892e12b4 --- /dev/null +++ b/tests/fixtures/smart/TP10(IT)_1.0_1.2.5.json @@ -0,0 +1,346 @@ +{ + "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": "localSmart", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "TP10(IT)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "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_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.5 Build 240411 Rel.143808", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "it_IT", + "latitude": 0, + "longitude": 0, + "mac": "40-AE-30-00-00-00", + "model": "TP10", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "Europe/Rome", + "rssi": -54, + "signal_level": 2, + "specs": "IT", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/Rome", + "time_diff": 60, + "timestamp": 1747840143 + }, + "get_device_usage": { + "time_usage": { + "past30": 32, + "past7": 32, + "today": 32 + } + }, + "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.2.5 Build 240411 Rel.143808", + "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": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "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": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 13, + "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": "TP10", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} From a0e4976c893b2cc31bed8f92264477bf55b2856b Mon Sep 17 00:00:00 2001 From: Andrea Fantaccione Date: Sun, 1 Jun 2025 12:47:30 +0200 Subject: [PATCH 862/892] Add L535E(EU) 3.0 1.1.8 fixture (#1545) Add fixture for [Tapo L535E](https://www.tp-link.com/en/home-networking/smart-bulb/tapo-l535e/v3/) Support tested also with Home Assistant 2025.5.3 both with [TP-Link](https://www.home-assistant.io/integrations/tplink) and [Matter](https://www.home-assistant.io/integrations/matter) integrations. --- README.md | 2 +- SUPPORTED.md | 2 + tests/device_fixtures.py | 4 +- tests/fixtures/smart/L535E(EU)_3.0_1.1.8.json | 559 ++++++++++++++++++ 4 files changed, 564 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/smart/L535E(EU)_3.0_1.1.8.json diff --git a/README.md b/README.md index 897370104..c9247b401 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: P100, P110, P110M, P115, P125M, P135, TP10, TP15 - **Power Strips**: P210M, P300, P304M, P306, TP25 - **Wall Switches**: S210, S220, S500D, S505, S505D -- **Bulbs**: L510B, L510E, L530B, L530E, L630 +- **Bulbs**: L510B, L510E, L530B, L530E, L535E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Cameras**: C100, C110, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70 - **Doorbells and chimes**: D100C, D130, D230 diff --git a/SUPPORTED.md b/SUPPORTED.md index 2cb00bcb8..00de4d75b 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -257,6 +257,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 3.0 (EU) / Firmware: 1.1.6 - Hardware: 2.0 (TW) / Firmware: 1.1.1 - Hardware: 2.0 (US) / Firmware: 1.1.0 +- **L535E** + - Hardware: 3.0 (EU) / Firmware: 1.1.8 - **L630** - Hardware: 1.0 (EU) / Firmware: 1.1.2 diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 297b497b8..4cae98f13 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -27,9 +27,9 @@ ) # Tapo bulbs -BULBS_SMART_VARIABLE_TEMP = {"L530E", "L930-5"} +BULBS_SMART_VARIABLE_TEMP = {"L530E", "L535E", "L930-5"} BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5", "L930-5"} -BULBS_SMART_COLOR = {"L530E", *BULBS_SMART_LIGHT_STRIP} +BULBS_SMART_COLOR = {"L530E", "L535E", *BULBS_SMART_LIGHT_STRIP} BULBS_SMART_DIMMABLE = {"L510B", "L510E"} BULBS_SMART = ( BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) diff --git a/tests/fixtures/smart/L535E(EU)_3.0_1.1.8.json b/tests/fixtures/smart/L535E(EU)_3.0_1.1.8.json new file mode 100644 index 000000000..9e7a6a8a4 --- /dev/null +++ b/tests/fixtures/smart/L535E(EU)_3.0_1.1.8.json @@ -0,0 +1,559 @@ +{ + "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": 3 + }, + { + "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 + }, + { + "id": "matter", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L535E(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "BC-07-1D-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": "floor_lamp_3", + "brightness": 50, + "color_temp": 2700, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "saturation": 0 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.8 Build 240708 Rel.165207", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "BC-07-1D-00-00-00", + "model": "L535", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Paris", + "rssi": -29, + "saturation": 0, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/Paris", + "time_diff": 60, + "timestamp": 1748538888 + }, + "get_device_usage": { + "power_usage": { + "past30": 43, + "past7": 43, + "today": 6 + }, + "saved_power": { + "past30": 573, + "past7": 573, + "today": 87 + }, + "time_usage": { + "past30": 616, + "past7": 616, + "today": 93 + } + }, + "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": "Party" + }, + { + "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": "Relax" + } + ], + "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.8 Build 240708 Rel.165207", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "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": { + "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": [ + { + "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": 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==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 11, + "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 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L535", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} From e21ab90e96d696d924bdbfec1ec4d8cf9f7c8ee0 Mon Sep 17 00:00:00 2001 From: mjbohr Date: Sun, 1 Jun 2025 14:26:34 -0400 Subject: [PATCH 863/892] Adding KL400L10(US)_1.0_1.0.10 fixture (#1539) Adding KL400L10(US)_1.0_1.0.10 fixture --------- Co-authored-by: Teemu Rytilahti --- README.md | 2 +- SUPPORTED.md | 2 + tests/device_fixtures.py | 2 +- .../fixtures/iot/KL400L10(US)_1.0_1.0.10.json | 144 ++++++++++++++++++ tests/iot/test_iotbulb.py | 1 + 5 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/iot/KL400L10(US)_1.0_1.0.10.json diff --git a/README.md b/README.md index c9247b401..c422c14d1 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ The following devices have been tested and confirmed as working. If your device - **Power Strips**: EP40, EP40M[^1], HS107, HS300, KP200, KP303, KP400 - **Wall Switches**: ES20M, HS200[^2], HS210, HS220[^2], KP405, KS200, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1] - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110 -- **Light Strips**: KL400L5, KL420L5, KL430 +- **Light Strips**: KL400L10, KL400L5, KL420L5, KL430 - **Hubs**: KH100[^1] - **Hub-Connected Devices[^3]**: KE100[^1] diff --git a/SUPPORTED.md b/SUPPORTED.md index 00de4d75b..200689b60 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -156,6 +156,8 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th ### Light Strips +- **KL400L10** + - Hardware: 1.0 (US) / Firmware: 1.0.10 - **KL400L5** - Hardware: 1.0 (US) / Firmware: 1.0.5 - Hardware: 1.0 (US) / Firmware: 1.0.8 diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 4cae98f13..4d6a2d807 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -38,7 +38,7 @@ ) # Kasa (IOT-prefixed) bulbs -BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL430", "KL420L5"} +BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL400L10", "KL430", "KL420L5"} BULBS_IOT_VARIABLE_TEMP = { "LB120", "LB130", diff --git a/tests/fixtures/iot/KL400L10(US)_1.0_1.0.10.json b/tests/fixtures/iot/KL400L10(US)_1.0_1.0.10.json new file mode 100644 index 000000000..db4496552 --- /dev/null +++ b/tests/fixtures/iot/KL400L10(US)_1.0_1.0.10.json @@ -0,0 +1,144 @@ +{ + "smartlife.iot.common.cloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 1800, + "total_wh": 443 + } + }, + "smartlife.iot.common.schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 0, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "smartlife.iot.lightStrip": { + "get_default_behavior": { + "err_code": 0, + "hard_on": { + "mode": "last_status" + }, + "soft_on": { + "mode": "last_status" + } + }, + "get_light_details": { + "color_rendering_index": 90, + "err_code": 0, + "incandescent_equivalent": 60, + "lamp_beam_angle": 220, + "max_lumens": 800, + "max_voltage": 120, + "min_voltage": 100, + "wattage": 10 + }, + "get_light_state": { + "err_code": 0, + "groups": [ + [ + 0, + 0, + 251, + 0, + 10, + 0 + ] + ], + "length": 1, + "mode": "normal", + "on_off": 1, + "transition": 500 + } + }, + "system": { + "get_sysinfo": { + "LEF": 0, + "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": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "latitude_i": 0, + "length": 1, + "light_state": { + "brightness": 10, + "color_temp": 0, + "hue": 251, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "lighting_effect_state": { + "brightness": 10, + "custom": 0, + "enable": 0, + "id": "ojqpUUxdGHoIugGPknrUcRoyJiItsjuE", + "name": "Lightning" + }, + "longitude_i": 0, + "mic_mac": "D8:44:89:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL400L10(US)", + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 0, + "hue": 1, + "index": 0, + "mode": 1, + "saturation": 100 + }, + { + "brightness": 15, + "color_temp": 0, + "hue": 251, + "index": 1, + "mode": 1, + "saturation": 0 + } + ], + "rssi": -38, + "status": "new", + "sw_ver": "1.0.10 Build 220929 Rel.170054" + } + } +} diff --git a/tests/iot/test_iotbulb.py b/tests/iot/test_iotbulb.py index 5b759c588..4d40dff67 100644 --- a/tests/iot/test_iotbulb.py +++ b/tests/iot/test_iotbulb.py @@ -277,6 +277,7 @@ async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): "saturation": All(int, Range(min=0, max=100)), "length": Optional(int), "transition": Optional(int), + "groups": Optional(list[int]), "dft_on_state": Optional( { "brightness": All(int, Range(min=0, max=100)), From 0cd0434160d2387d770af5a570daf5a004691ff9 Mon Sep 17 00:00:00 2001 From: Gabriele Pongelli Date: Mon, 25 Aug 2025 15:20:33 +0200 Subject: [PATCH 864/892] Extend smartcam detection support (#1552) New detection added with tests: - bark detection - glass detection - line crossing detection - meow detection - vehicle detection --- SUPPORTED.md | 1 + devtools/helpers/smartcamrequests.py | 5 + kasa/smartcam/__init__.py | 3 +- kasa/smartcam/detectionmodule.py | 58 + kasa/smartcam/modules/__init__.py | 10 + kasa/smartcam/modules/babycrydetection.py | 37 +- kasa/smartcam/modules/barkdetection.py | 24 + kasa/smartcam/modules/glassdetection.py | 24 + .../smartcam/modules/linecrossingdetection.py | 24 + kasa/smartcam/modules/meowdetection.py | 24 + kasa/smartcam/modules/motiondetection.py | 37 +- kasa/smartcam/modules/persondetection.py | 37 +- kasa/smartcam/modules/petdetection.py | 37 +- kasa/smartcam/modules/tamperdetection.py | 37 +- kasa/smartcam/modules/vehicledetection.py | 24 + kasa/smartcam/smartcammodule.py | 16 + .../fixtures/smartcam/C220(EU)_1.0_1.2.5.json | 1139 +++++++++++++++++ .../smartcam/modules/test_babycrydetection.py | 45 - tests/smartcam/modules/test_detections.py | 168 +++ .../smartcam/modules/test_motiondetection.py | 43 - .../smartcam/modules/test_persondetection.py | 45 - tests/smartcam/modules/test_petdetection.py | 45 - .../smartcam/modules/test_tamperdetection.py | 45 - 23 files changed, 1549 insertions(+), 379 deletions(-) create mode 100644 kasa/smartcam/detectionmodule.py create mode 100644 kasa/smartcam/modules/barkdetection.py create mode 100644 kasa/smartcam/modules/glassdetection.py create mode 100644 kasa/smartcam/modules/linecrossingdetection.py create mode 100644 kasa/smartcam/modules/meowdetection.py create mode 100644 kasa/smartcam/modules/vehicledetection.py create mode 100644 tests/fixtures/smartcam/C220(EU)_1.0_1.2.5.json delete mode 100644 tests/smartcam/modules/test_babycrydetection.py create mode 100644 tests/smartcam/modules/test_detections.py delete mode 100644 tests/smartcam/modules/test_motiondetection.py delete mode 100644 tests/smartcam/modules/test_persondetection.py delete mode 100644 tests/smartcam/modules/test_petdetection.py delete mode 100644 tests/smartcam/modules/test_tamperdetection.py diff --git a/SUPPORTED.md b/SUPPORTED.md index 200689b60..935b25e12 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -293,6 +293,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 2.0 (EU) / Firmware: 1.4.3 - **C220** - Hardware: 1.0 (EU) / Firmware: 1.2.2 + - Hardware: 1.0 (EU) / Firmware: 1.2.5 - **C225** - Hardware: 2.0 (US) / Firmware: 1.0.11 - **C325WB** diff --git a/devtools/helpers/smartcamrequests.py b/devtools/helpers/smartcamrequests.py index 5759a44b5..6c60b12a7 100644 --- a/devtools/helpers/smartcamrequests.py +++ b/devtools/helpers/smartcamrequests.py @@ -44,6 +44,11 @@ {"getWhitelampConfig": {"image": {"name": "switch"}}}, {"getMsgPushConfig": {"msg_push": {"name": ["chn1_msg_push_info"]}}}, {"getSdCardStatus": {"harddisk_manage": {"table": ["hd_info"]}}}, + { + "getLinecrossingDetectionConfig": { + "linecrossing_detection": {"name": ["detection", "arming_schedule"]} + } + }, {"getCircularRecordingConfig": {"harddisk_manage": {"name": "harddisk"}}}, {"getRecordPlan": {"record_plan": {"name": ["chn1_channel"]}}}, {"getAudioConfig": {"audio_config": {"name": ["speaker", "microphone"]}}}, diff --git a/kasa/smartcam/__init__.py b/kasa/smartcam/__init__.py index 21cbeb50b..b16e5410b 100644 --- a/kasa/smartcam/__init__.py +++ b/kasa/smartcam/__init__.py @@ -1,6 +1,7 @@ """Package for supporting tapo-branded cameras.""" +from .detectionmodule import DetectionModule from .smartcamchild import SmartCamChild from .smartcamdevice import SmartCamDevice -__all__ = ["SmartCamDevice", "SmartCamChild"] +__all__ = ["SmartCamDevice", "SmartCamChild", "DetectionModule"] diff --git a/kasa/smartcam/detectionmodule.py b/kasa/smartcam/detectionmodule.py new file mode 100644 index 000000000..9a147f99b --- /dev/null +++ b/kasa/smartcam/detectionmodule.py @@ -0,0 +1,58 @@ +"""SmartCamModule base class for all detections.""" + +from __future__ import annotations + +import logging + +from kasa.feature import Feature +from kasa.smart.smartmodule import allow_update_after +from kasa.smartcam.smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class DetectionModule(SmartCamModule): + """SmartCamModule base class for all detections.""" + + #: Feature ID, filled by inheriting class + DETECTION_FEATURE_ID: str = "" + + #: User-friendly short description, filled by inheriting class + DETECTION_FEATURE_NAME: str = "" + + #: Feature setter method name, filled by inheriting class + QUERY_SETTER_NAME: str = "" + + #: Feature section name, filled by inheriting class + QUERY_SET_SECTION_NAME: str = "" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id=self.DETECTION_FEATURE_ID, + name=self.DETECTION_FEATURE_NAME, + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Config, + ) + ) + + @property + def enabled(self) -> bool: + """Return the detection enabled state.""" + return self.data[self.QUERY_SECTION_NAMES]["enabled"] == "on" + + @allow_update_after + async def set_enabled(self, enable: bool) -> dict: + """Set the detection enabled state.""" + params = {"enabled": "on" if enable else "off"} + return await self._device._query_setter_helper( + self.QUERY_SETTER_NAME, + self.QUERY_MODULE_NAME, + self.QUERY_SET_SECTION_NAME, + params, + ) diff --git a/kasa/smartcam/modules/__init__.py b/kasa/smartcam/modules/__init__.py index 4f6ed866a..3434bddbf 100644 --- a/kasa/smartcam/modules/__init__.py +++ b/kasa/smartcam/modules/__init__.py @@ -2,31 +2,40 @@ from .alarm import Alarm from .babycrydetection import BabyCryDetection +from .barkdetection import BarkDetection from .battery import Battery from .camera import Camera from .childdevice import ChildDevice from .childsetup import ChildSetup from .device import DeviceModule +from .glassdetection import GlassDetection from .homekit import HomeKit from .led import Led from .lensmask import LensMask +from .linecrossingdetection import LineCrossingDetection from .matter import Matter +from .meowdetection import MeowDetection from .motiondetection import MotionDetection from .pantilt import PanTilt from .persondetection import PersonDetection from .petdetection import PetDetection from .tamperdetection import TamperDetection from .time import Time +from .vehicledetection import VehicleDetection __all__ = [ "Alarm", "BabyCryDetection", + "BarkDetection", "Battery", "Camera", "ChildDevice", "ChildSetup", "DeviceModule", + "GlassDetection", "Led", + "LineCrossingDetection", + "MeowDetection", "PanTilt", "PersonDetection", "PetDetection", @@ -36,4 +45,5 @@ "MotionDetection", "LensMask", "TamperDetection", + "VehicleDetection", ] diff --git a/kasa/smartcam/modules/babycrydetection.py b/kasa/smartcam/modules/babycrydetection.py index 753998854..6289b5177 100644 --- a/kasa/smartcam/modules/babycrydetection.py +++ b/kasa/smartcam/modules/babycrydetection.py @@ -4,14 +4,12 @@ import logging -from ...feature import Feature -from ...smart.smartmodule import allow_update_after -from ..smartcammodule import SmartCamModule +from kasa.smartcam.detectionmodule import DetectionModule _LOGGER = logging.getLogger(__name__) -class BabyCryDetection(SmartCamModule): +class BabyCryDetection(DetectionModule): """Implementation of baby cry detection module.""" REQUIRED_COMPONENT = "babyCryDetection" @@ -20,30 +18,7 @@ class BabyCryDetection(SmartCamModule): QUERY_MODULE_NAME = "sound_detection" QUERY_SECTION_NAMES = "bcd" - def _initialize_features(self) -> None: - """Initialize features after the initial update.""" - self._add_feature( - Feature( - self._device, - id="baby_cry_detection", - name="Baby cry detection", - container=self, - attribute_getter="enabled", - attribute_setter="set_enabled", - type=Feature.Type.Switch, - category=Feature.Category.Config, - ) - ) - - @property - def enabled(self) -> bool: - """Return the baby cry detection enabled state.""" - return self.data["bcd"]["enabled"] == "on" - - @allow_update_after - async def set_enabled(self, enable: bool) -> dict: - """Set the baby cry detection enabled state.""" - params = {"enabled": "on" if enable else "off"} - return await self._device._query_setter_helper( - "setBCDConfig", self.QUERY_MODULE_NAME, "bcd", params - ) + DETECTION_FEATURE_ID = "baby_cry_detection" + DETECTION_FEATURE_NAME = "Baby cry detection" + QUERY_SETTER_NAME = "setBCDConfig" + QUERY_SET_SECTION_NAME = "bcd" diff --git a/kasa/smartcam/modules/barkdetection.py b/kasa/smartcam/modules/barkdetection.py new file mode 100644 index 000000000..8c3b3b823 --- /dev/null +++ b/kasa/smartcam/modules/barkdetection.py @@ -0,0 +1,24 @@ +"""Implementation of bark detection module.""" + +from __future__ import annotations + +import logging + +from kasa.smartcam.detectionmodule import DetectionModule + +_LOGGER = logging.getLogger(__name__) + + +class BarkDetection(DetectionModule): + """Implementation of bark detection module.""" + + REQUIRED_COMPONENT = "barkDetection" + + QUERY_GETTER_NAME = "getBarkDetectionConfig" + QUERY_MODULE_NAME = "bark_detection" + QUERY_SECTION_NAMES = "detection" + + DETECTION_FEATURE_ID = "bark_detection" + DETECTION_FEATURE_NAME = "Bark detection" + QUERY_SETTER_NAME = "setBarkDetectionConfig" + QUERY_SET_SECTION_NAME = "detection" diff --git a/kasa/smartcam/modules/glassdetection.py b/kasa/smartcam/modules/glassdetection.py new file mode 100644 index 000000000..bd0c7ea90 --- /dev/null +++ b/kasa/smartcam/modules/glassdetection.py @@ -0,0 +1,24 @@ +"""Implementation of glass detection module.""" + +from __future__ import annotations + +import logging + +from kasa.smartcam.detectionmodule import DetectionModule + +_LOGGER = logging.getLogger(__name__) + + +class GlassDetection(DetectionModule): + """Implementation of glass detection module.""" + + REQUIRED_COMPONENT = "glassDetection" + + QUERY_GETTER_NAME = "getGlassDetectionConfig" + QUERY_MODULE_NAME = "glass_detection" + QUERY_SECTION_NAMES = "detection" + + DETECTION_FEATURE_ID = "glass_detection" + DETECTION_FEATURE_NAME = "Glass detection" + QUERY_SETTER_NAME = "setGlassDetectionConfig" + QUERY_SET_SECTION_NAME = "detection" diff --git a/kasa/smartcam/modules/linecrossingdetection.py b/kasa/smartcam/modules/linecrossingdetection.py new file mode 100644 index 000000000..2fc0de146 --- /dev/null +++ b/kasa/smartcam/modules/linecrossingdetection.py @@ -0,0 +1,24 @@ +"""Implementation of line crossing detection module.""" + +from __future__ import annotations + +import logging + +from kasa.smartcam.detectionmodule import DetectionModule + +_LOGGER = logging.getLogger(__name__) + + +class LineCrossingDetection(DetectionModule): + """Implementation of line crossing detection module.""" + + REQUIRED_COMPONENT = "linecrossingDetection" + + QUERY_GETTER_NAME = "getLinecrossingDetectionConfig" + QUERY_MODULE_NAME = "linecrossing_detection" + QUERY_SECTION_NAMES = "detection" + + DETECTION_FEATURE_ID = "line_crossing_detection" + DETECTION_FEATURE_NAME = "Line crossing detection" + QUERY_SETTER_NAME = "setLinecrossingDetectionConfig" + QUERY_SET_SECTION_NAME = "detection" diff --git a/kasa/smartcam/modules/meowdetection.py b/kasa/smartcam/modules/meowdetection.py new file mode 100644 index 000000000..6c02bfbae --- /dev/null +++ b/kasa/smartcam/modules/meowdetection.py @@ -0,0 +1,24 @@ +"""Implementation of meow detection module.""" + +from __future__ import annotations + +import logging + +from kasa.smartcam.detectionmodule import DetectionModule + +_LOGGER = logging.getLogger(__name__) + + +class MeowDetection(DetectionModule): + """Implementation of meow detection module.""" + + REQUIRED_COMPONENT = "meowDetection" + + QUERY_GETTER_NAME = "getMeowDetectionConfig" + QUERY_MODULE_NAME = "meow_detection" + QUERY_SECTION_NAMES = "detection" + + DETECTION_FEATURE_ID = "meow_detection" + DETECTION_FEATURE_NAME = "Meow detection" + QUERY_SETTER_NAME = "setMeowDetectionConfig" + QUERY_SET_SECTION_NAME = "detection" diff --git a/kasa/smartcam/modules/motiondetection.py b/kasa/smartcam/modules/motiondetection.py index dd3c168e9..df9b1863a 100644 --- a/kasa/smartcam/modules/motiondetection.py +++ b/kasa/smartcam/modules/motiondetection.py @@ -4,14 +4,12 @@ import logging -from ...feature import Feature -from ...smart.smartmodule import allow_update_after -from ..smartcammodule import SmartCamModule +from kasa.smartcam.detectionmodule import DetectionModule _LOGGER = logging.getLogger(__name__) -class MotionDetection(SmartCamModule): +class MotionDetection(DetectionModule): """Implementation of motion detection module.""" REQUIRED_COMPONENT = "detection" @@ -20,30 +18,7 @@ class MotionDetection(SmartCamModule): QUERY_MODULE_NAME = "motion_detection" QUERY_SECTION_NAMES = "motion_det" - def _initialize_features(self) -> None: - """Initialize features after the initial update.""" - self._add_feature( - Feature( - self._device, - id="motion_detection", - name="Motion detection", - container=self, - attribute_getter="enabled", - attribute_setter="set_enabled", - type=Feature.Type.Switch, - category=Feature.Category.Config, - ) - ) - - @property - def enabled(self) -> bool: - """Return the motion detection enabled state.""" - return self.data["motion_det"]["enabled"] == "on" - - @allow_update_after - async def set_enabled(self, enable: bool) -> dict: - """Set the motion detection enabled state.""" - params = {"enabled": "on" if enable else "off"} - return await self._device._query_setter_helper( - "setDetectionConfig", self.QUERY_MODULE_NAME, "motion_det", params - ) + DETECTION_FEATURE_ID = "motion_detection" + DETECTION_FEATURE_NAME = "Motion detection" + QUERY_SETTER_NAME = "setDetectionConfig" + QUERY_SET_SECTION_NAME = "motion_det" diff --git a/kasa/smartcam/modules/persondetection.py b/kasa/smartcam/modules/persondetection.py index 96b31dc42..3b1213e88 100644 --- a/kasa/smartcam/modules/persondetection.py +++ b/kasa/smartcam/modules/persondetection.py @@ -4,14 +4,12 @@ import logging -from ...feature import Feature -from ...smart.smartmodule import allow_update_after -from ..smartcammodule import SmartCamModule +from kasa.smartcam.detectionmodule import DetectionModule _LOGGER = logging.getLogger(__name__) -class PersonDetection(SmartCamModule): +class PersonDetection(DetectionModule): """Implementation of person detection module.""" REQUIRED_COMPONENT = "personDetection" @@ -20,30 +18,7 @@ class PersonDetection(SmartCamModule): QUERY_MODULE_NAME = "people_detection" QUERY_SECTION_NAMES = "detection" - def _initialize_features(self) -> None: - """Initialize features after the initial update.""" - self._add_feature( - Feature( - self._device, - id="person_detection", - name="Person detection", - container=self, - attribute_getter="enabled", - attribute_setter="set_enabled", - type=Feature.Type.Switch, - category=Feature.Category.Config, - ) - ) - - @property - def enabled(self) -> bool: - """Return the person detection enabled state.""" - return self.data["detection"]["enabled"] == "on" - - @allow_update_after - async def set_enabled(self, enable: bool) -> dict: - """Set the person detection enabled state.""" - params = {"enabled": "on" if enable else "off"} - return await self._device._query_setter_helper( - "setPersonDetectionConfig", self.QUERY_MODULE_NAME, "detection", params - ) + DETECTION_FEATURE_ID = "person_detection" + DETECTION_FEATURE_NAME = "Person detection" + QUERY_SETTER_NAME = "setPersonDetectionConfig" + QUERY_SET_SECTION_NAME = "detection" diff --git a/kasa/smartcam/modules/petdetection.py b/kasa/smartcam/modules/petdetection.py index 2c7162304..58ff5cc4f 100644 --- a/kasa/smartcam/modules/petdetection.py +++ b/kasa/smartcam/modules/petdetection.py @@ -4,14 +4,12 @@ import logging -from ...feature import Feature -from ...smart.smartmodule import allow_update_after -from ..smartcammodule import SmartCamModule +from kasa.smartcam.detectionmodule import DetectionModule _LOGGER = logging.getLogger(__name__) -class PetDetection(SmartCamModule): +class PetDetection(DetectionModule): """Implementation of pet detection module.""" REQUIRED_COMPONENT = "petDetection" @@ -20,30 +18,7 @@ class PetDetection(SmartCamModule): QUERY_MODULE_NAME = "pet_detection" QUERY_SECTION_NAMES = "detection" - def _initialize_features(self) -> None: - """Initialize features after the initial update.""" - self._add_feature( - Feature( - self._device, - id="pet_detection", - name="Pet detection", - container=self, - attribute_getter="enabled", - attribute_setter="set_enabled", - type=Feature.Type.Switch, - category=Feature.Category.Config, - ) - ) - - @property - def enabled(self) -> bool: - """Return the pet detection enabled state.""" - return self.data["detection"]["enabled"] == "on" - - @allow_update_after - async def set_enabled(self, enable: bool) -> dict: - """Set the pet detection enabled state.""" - params = {"enabled": "on" if enable else "off"} - return await self._device._query_setter_helper( - "setPetDetectionConfig", self.QUERY_MODULE_NAME, "detection", params - ) + DETECTION_FEATURE_ID = "pet_detection" + DETECTION_FEATURE_NAME = "Pet detection" + QUERY_SETTER_NAME = "setPetDetectionConfig" + QUERY_SET_SECTION_NAME = "detection" diff --git a/kasa/smartcam/modules/tamperdetection.py b/kasa/smartcam/modules/tamperdetection.py index f572ded6f..aa1cc4745 100644 --- a/kasa/smartcam/modules/tamperdetection.py +++ b/kasa/smartcam/modules/tamperdetection.py @@ -4,14 +4,12 @@ import logging -from ...feature import Feature -from ...smart.smartmodule import allow_update_after -from ..smartcammodule import SmartCamModule +from kasa.smartcam.detectionmodule import DetectionModule _LOGGER = logging.getLogger(__name__) -class TamperDetection(SmartCamModule): +class TamperDetection(DetectionModule): """Implementation of tamper detection module.""" REQUIRED_COMPONENT = "tamperDetection" @@ -20,30 +18,7 @@ class TamperDetection(SmartCamModule): QUERY_MODULE_NAME = "tamper_detection" QUERY_SECTION_NAMES = "tamper_det" - def _initialize_features(self) -> None: - """Initialize features after the initial update.""" - self._add_feature( - Feature( - self._device, - id="tamper_detection", - name="Tamper detection", - container=self, - attribute_getter="enabled", - attribute_setter="set_enabled", - type=Feature.Type.Switch, - category=Feature.Category.Config, - ) - ) - - @property - def enabled(self) -> bool: - """Return the tamper detection enabled state.""" - return self.data["tamper_det"]["enabled"] == "on" - - @allow_update_after - async def set_enabled(self, enable: bool) -> dict: - """Set the tamper detection enabled state.""" - params = {"enabled": "on" if enable else "off"} - return await self._device._query_setter_helper( - "setTamperDetectionConfig", self.QUERY_MODULE_NAME, "tamper_det", params - ) + DETECTION_FEATURE_ID = "tamper_detection" + DETECTION_FEATURE_NAME = "Tamper detection" + QUERY_SETTER_NAME = "setTamperDetectionConfig" + QUERY_SET_SECTION_NAME = "tamper_det" diff --git a/kasa/smartcam/modules/vehicledetection.py b/kasa/smartcam/modules/vehicledetection.py new file mode 100644 index 000000000..f5da9b0c7 --- /dev/null +++ b/kasa/smartcam/modules/vehicledetection.py @@ -0,0 +1,24 @@ +"""Implementation of vehicle detection module.""" + +from __future__ import annotations + +import logging + +from kasa.smartcam.detectionmodule import DetectionModule + +_LOGGER = logging.getLogger(__name__) + + +class VehicleDetection(DetectionModule): + """Implementation of vehicle detection module.""" + + REQUIRED_COMPONENT = "vehicleDetection" + + QUERY_GETTER_NAME = "getVehicleDetectionConfig" + QUERY_MODULE_NAME = "vehicle_detection" + QUERY_SECTION_NAMES = "detection" + + DETECTION_FEATURE_ID = "vehicle_detection" + DETECTION_FEATURE_NAME = "Vehicle detection" + QUERY_SETTER_NAME = "setVehicleDetectionConfig" + QUERY_SET_SECTION_NAME = "detection" diff --git a/kasa/smartcam/smartcammodule.py b/kasa/smartcam/smartcammodule.py index 400b16740..7402b8cb8 100644 --- a/kasa/smartcam/smartcammodule.py +++ b/kasa/smartcam/smartcammodule.py @@ -36,6 +36,22 @@ class SmartCamModule(SmartModule): "BabyCryDetection" ) + SmartCamLineCrossingDetection: Final[ModuleName[modules.LineCrossingDetection]] = ( + ModuleName("LineCrossingDetection") + ) + SmartCamBarkDetection: Final[ModuleName[modules.BarkDetection]] = ModuleName( + "BarkDetection" + ) + SmartCamGlassDetection: Final[ModuleName[modules.GlassDetection]] = ModuleName( + "GlassDetection" + ) + SmartCamMeowDetection: Final[ModuleName[modules.MeowDetection]] = ModuleName( + "MeowDetection" + ) + SmartCamVehicleDetection: Final[ModuleName[modules.VehicleDetection]] = ModuleName( + "VehicleDetection" + ) + SmartCamBattery: Final[ModuleName[modules.Battery]] = ModuleName("Battery") SmartCamDeviceModule: Final[ModuleName[modules.DeviceModule]] = ModuleName( diff --git a/tests/fixtures/smartcam/C220(EU)_1.0_1.2.5.json b/tests/fixtures/smartcam/C220(EU)_1.0_1.2.5.json new file mode 100644 index 000000000..5fc86df9b --- /dev/null +++ b/tests/fixtures/smartcam/C220(EU)_1.0_1.2.5.json @@ -0,0 +1,1139 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1750109746", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C220", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.2.5 Build 241224 Rel.40956n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "7C-F1-7E-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_func": [ + "sound", + "light" + ], + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "2", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "light", + "sound" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "linecrossingDetection", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "patrol", + "version": 1 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "meowDetection", + "version": 1 + }, + { + "name": "barkDetection", + "version": 1 + }, + { + "name": "glassDetection", + "version": 1 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "panoramicView", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "smartTrack", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "staticIp", + "version": 2 + }, + { + "name": "snapshot", + "version": 2 + }, + { + "name": "timeFormat", + "version": 1 + }, + { + "name": "upnpc", + "version": 2 + }, + { + "name": "relayPreConnection", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "factory_noise_cancelling": "off", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getBarkDetectionConfig": { + "bark_detection": { + "detection": { + "enabled": "off", + "sensitivity": "50" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-06-16 23:47:00", + "seconds_from_1970": 1750110420 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "3", + "rssiValue": -54, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "off", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c212", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C220 1.0 IPC", + "device_model": "C220", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "7C-F1-7E-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "no_rtsp_constrain": 1, + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.2.5 Build 241224 Rel.40956n", + "tss": false + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getGlassDetectionConfig": { + "glass_detection": { + "detection": { + "enabled": "on", + "sensitivity": "50" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1750109746", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "50", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "off" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "50", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getLinecrossingDetectionConfig": { + "linecrossing_detection": { + "arming_schedule": { + "friday": "[\"0000-2400\"]", + "monday": "[\"0000-2400\"]", + "saturday": "[\"0000-2400\"]", + "sunday": "[\"0000-2400\"]", + "thursday": "[\"0000-2400\"]", + "tuesday": "[\"0000-2400\"]", + "wednesday": "[\"0000-2400\"]" + }, + "detection": { + "enabled": "on" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMeowDetectionConfig": { + "meow_detection": { + "detection": { + "enabled": "off", + "sensitivity": "50" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [], + "name": [], + "position_pan": [], + "position_tilt": [], + "position_zoom": [], + "read_only": [] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "total_space_accurate": "0B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "0B", + "video_total_space_accurate": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "on", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+01:00", + "timing_mode": "ntp", + "zone_id": "Europe/Amsterdam" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1536", + "2048", + "2560" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2560*1440", + "1920*1080" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "2560", + "bitrate_type": "vbr", + "default_bitrate": "2560", + "encode_type": "H264", + "frame_rate": "65561", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2560*1440", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8" + ], + "system_volume": "80", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "factory_noise_cancelling": "off", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "ptz": "1", + "record_max_slot_cnt": "6", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "0", + "smart_codec": "0", + "smart_detection": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "true" + } + } + } +} diff --git a/tests/smartcam/modules/test_babycrydetection.py b/tests/smartcam/modules/test_babycrydetection.py deleted file mode 100644 index 89ff5ac43..000000000 --- a/tests/smartcam/modules/test_babycrydetection.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Tests for smartcam baby cry detection module.""" - -from __future__ import annotations - -from kasa import Device -from kasa.smartcam.smartcammodule import SmartCamModule - -from ...device_fixtures import parametrize - -babycrydetection = parametrize( - "has babycry detection", - component_filter="babyCryDetection", - protocol_filter={"SMARTCAM"}, -) - - -@babycrydetection -async def test_babycrydetection(dev: Device): - """Test device babycry detection.""" - babycry = dev.modules.get(SmartCamModule.SmartCamBabyCryDetection) - assert babycry - - bcde_feat = dev.features.get("baby_cry_detection") - assert bcde_feat - - original_enabled = babycry.enabled - - try: - await babycry.set_enabled(not original_enabled) - await dev.update() - assert babycry.enabled is not original_enabled - assert bcde_feat.value is not original_enabled - - await babycry.set_enabled(original_enabled) - await dev.update() - assert babycry.enabled is original_enabled - assert bcde_feat.value is original_enabled - - await bcde_feat.set_value(not original_enabled) - await dev.update() - assert babycry.enabled is not original_enabled - assert bcde_feat.value is not original_enabled - - finally: - await babycry.set_enabled(original_enabled) diff --git a/tests/smartcam/modules/test_detections.py b/tests/smartcam/modules/test_detections.py new file mode 100644 index 000000000..c4659f7b1 --- /dev/null +++ b/tests/smartcam/modules/test_detections.py @@ -0,0 +1,168 @@ +"""Tests for smartcam detections.""" + +from __future__ import annotations + +from typing import NamedTuple + +import pytest + +from kasa import Device +from kasa.modulemapping import ModuleName +from kasa.smartcam import DetectionModule +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...fixtureinfo import filter_fixtures, idgenerator + + +class Detection(NamedTuple): + desc: str + module: ModuleName[DetectionModule] + feature_name: str + component_filter: str + model_filter: str | None = None + + +def parametrize_detection( + *, + model_filter=None, + protocol_filter=None, + fixture_name="dev", + extra_params_names: list[str], + extra_params_values: list[Detection], +): + _pytest_parameters = [] + + _arg_names = fixture_name + if extra_params_names: + _arg_names = f"{fixture_name},{','.join(extra_params_names)}" + + _model_filter = model_filter + + for _detection in extra_params_values: + if _detection.model_filter: + _model_filter = _detection.model_filter + + extra_values = list(map(lambda x: _detection._asdict()[x], extra_params_names)) + _pytest_parameters.extend( + [ + (i, *extra_values) + for i in filter_fixtures( + _detection.desc, + model_filter=_model_filter, + protocol_filter=protocol_filter, + component_filter=_detection.component_filter, + data_root_filter=None, + device_type_filter=None, + ) + ] + ) + + return pytest.mark.parametrize( + _arg_names, + _pytest_parameters, + indirect=[fixture_name], + ids=idgenerator, + ) + + +detections = [ + Detection( + desc="has baby cry detection", + module=SmartCamModule.SmartCamBabyCryDetection, + feature_name="baby_cry_detection", + component_filter="babyCryDetection", + ), + Detection( + desc="has bark detection", + module=SmartCamModule.SmartCamBarkDetection, + feature_name="bark_detection", + component_filter="barkDetection", + ), + Detection( + desc="has glass detection", + module=SmartCamModule.SmartCamGlassDetection, + feature_name="glass_detection", + component_filter="glassDetection", + ), + Detection( + desc="has line crossing detection", + module=SmartCamModule.SmartCamLineCrossingDetection, + feature_name="line_crossing_detection", + component_filter="linecrossingDetection", + model_filter="C220(EU)_1.0_1.2.5", + ), + Detection( + desc="has meow detection", + module=SmartCamModule.SmartCamMeowDetection, + feature_name="meow_detection", + component_filter="meowDetection", + ), + Detection( + desc="has motion detection", + module=SmartCamModule.SmartCamMotionDetection, + feature_name="motion_detection", + component_filter="detection", + ), + Detection( + desc="has person detection", + module=SmartCamModule.SmartCamPersonDetection, + feature_name="person_detection", + component_filter="personDetection", + ), + Detection( + desc="has pet detection", + module=SmartCamModule.SmartCamPetDetection, + feature_name="pet_detection", + component_filter="petDetection", + ), + Detection( + desc="has tamper detection", + module=SmartCamModule.SmartCamTamperDetection, + feature_name="tamper_detection", + component_filter="tamperDetection", + ), + Detection( + desc="has vehicle detection", + module=SmartCamModule.SmartCamVehicleDetection, + feature_name="vehicle_detection", + component_filter="vehicleDetection", + ), +] + +params_detections = parametrize_detection( + protocol_filter={"SMARTCAM"}, + extra_params_names=["module", "feature_name"], + extra_params_values=detections, +) + + +@params_detections +async def test_detections( + dev: Device, module: ModuleName[DetectionModule], feature_name: str +): + detection = dev.modules.get(module) + assert detection + + detection_feat = dev.features.get(feature_name) + assert detection_feat + + original_enabled = detection.enabled + + try: + await detection.set_enabled(not original_enabled) + await dev.update() + assert detection.enabled is not original_enabled + assert detection_feat.value is not original_enabled + + await detection.set_enabled(original_enabled) + await dev.update() + assert detection.enabled is original_enabled + assert detection_feat.value is original_enabled + + await detection_feat.set_value(not original_enabled) + await dev.update() + assert detection.enabled is not original_enabled + assert detection_feat.value is not original_enabled + + finally: + await detection.set_enabled(original_enabled) diff --git a/tests/smartcam/modules/test_motiondetection.py b/tests/smartcam/modules/test_motiondetection.py deleted file mode 100644 index c4ff98079..000000000 --- a/tests/smartcam/modules/test_motiondetection.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Tests for smartcam motion detection module.""" - -from __future__ import annotations - -from kasa import Device -from kasa.smartcam.smartcammodule import SmartCamModule - -from ...device_fixtures import parametrize - -motiondetection = parametrize( - "has motion detection", component_filter="detection", protocol_filter={"SMARTCAM"} -) - - -@motiondetection -async def test_motiondetection(dev: Device): - """Test device motion detection.""" - motion = dev.modules.get(SmartCamModule.SmartCamMotionDetection) - assert motion - - mde_feat = dev.features.get("motion_detection") - assert mde_feat - - original_enabled = motion.enabled - - try: - await motion.set_enabled(not original_enabled) - await dev.update() - assert motion.enabled is not original_enabled - assert mde_feat.value is not original_enabled - - await motion.set_enabled(original_enabled) - await dev.update() - assert motion.enabled is original_enabled - assert mde_feat.value is original_enabled - - await mde_feat.set_value(not original_enabled) - await dev.update() - assert motion.enabled is not original_enabled - assert mde_feat.value is not original_enabled - - finally: - await motion.set_enabled(original_enabled) diff --git a/tests/smartcam/modules/test_persondetection.py b/tests/smartcam/modules/test_persondetection.py deleted file mode 100644 index 341375878..000000000 --- a/tests/smartcam/modules/test_persondetection.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Tests for smartcam person detection module.""" - -from __future__ import annotations - -from kasa import Device -from kasa.smartcam.smartcammodule import SmartCamModule - -from ...device_fixtures import parametrize - -persondetection = parametrize( - "has person detection", - component_filter="personDetection", - protocol_filter={"SMARTCAM"}, -) - - -@persondetection -async def test_persondetection(dev: Device): - """Test device person detection.""" - person = dev.modules.get(SmartCamModule.SmartCamPersonDetection) - assert person - - pde_feat = dev.features.get("person_detection") - assert pde_feat - - original_enabled = person.enabled - - try: - await person.set_enabled(not original_enabled) - await dev.update() - assert person.enabled is not original_enabled - assert pde_feat.value is not original_enabled - - await person.set_enabled(original_enabled) - await dev.update() - assert person.enabled is original_enabled - assert pde_feat.value is original_enabled - - await pde_feat.set_value(not original_enabled) - await dev.update() - assert person.enabled is not original_enabled - assert pde_feat.value is not original_enabled - - finally: - await person.set_enabled(original_enabled) diff --git a/tests/smartcam/modules/test_petdetection.py b/tests/smartcam/modules/test_petdetection.py deleted file mode 100644 index 6eff0c8af..000000000 --- a/tests/smartcam/modules/test_petdetection.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Tests for smartcam pet detection module.""" - -from __future__ import annotations - -from kasa import Device -from kasa.smartcam.smartcammodule import SmartCamModule - -from ...device_fixtures import parametrize - -petdetection = parametrize( - "has pet detection", - component_filter="petDetection", - protocol_filter={"SMARTCAM"}, -) - - -@petdetection -async def test_petdetection(dev: Device): - """Test device pet detection.""" - pet = dev.modules.get(SmartCamModule.SmartCamPetDetection) - assert pet - - pde_feat = dev.features.get("pet_detection") - assert pde_feat - - original_enabled = pet.enabled - - try: - await pet.set_enabled(not original_enabled) - await dev.update() - assert pet.enabled is not original_enabled - assert pde_feat.value is not original_enabled - - await pet.set_enabled(original_enabled) - await dev.update() - assert pet.enabled is original_enabled - assert pde_feat.value is original_enabled - - await pde_feat.set_value(not original_enabled) - await dev.update() - assert pet.enabled is not original_enabled - assert pde_feat.value is not original_enabled - - finally: - await pet.set_enabled(original_enabled) diff --git a/tests/smartcam/modules/test_tamperdetection.py b/tests/smartcam/modules/test_tamperdetection.py deleted file mode 100644 index ab2f851d5..000000000 --- a/tests/smartcam/modules/test_tamperdetection.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Tests for smartcam tamper detection module.""" - -from __future__ import annotations - -from kasa import Device -from kasa.smartcam.smartcammodule import SmartCamModule - -from ...device_fixtures import parametrize - -tamperdetection = parametrize( - "has tamper detection", - component_filter="tamperDetection", - protocol_filter={"SMARTCAM"}, -) - - -@tamperdetection -async def test_tamperdetection(dev: Device): - """Test device tamper detection.""" - tamper = dev.modules.get(SmartCamModule.SmartCamTamperDetection) - assert tamper - - tde_feat = dev.features.get("tamper_detection") - assert tde_feat - - original_enabled = tamper.enabled - - try: - await tamper.set_enabled(not original_enabled) - await dev.update() - assert tamper.enabled is not original_enabled - assert tde_feat.value is not original_enabled - - await tamper.set_enabled(original_enabled) - await dev.update() - assert tamper.enabled is original_enabled - assert tde_feat.value is original_enabled - - await tde_feat.set_value(not original_enabled) - await dev.update() - assert tamper.enabled is not original_enabled - assert tde_feat.value is not original_enabled - - finally: - await tamper.set_enabled(original_enabled) From 08e7ab51205d3bf125f2ad5f9519b98d410c507c Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Sat, 30 Aug 2025 09:45:18 -0400 Subject: [PATCH 865/892] Add S500(US)_1.0_1.2.0 fixture (#1569) --- README.md | 2 +- SUPPORTED.md | 2 + tests/device_fixtures.py | 1 + tests/fixtures/smart/S500(US)_1.0_1.2.0.json | 363 +++++++++++++++++++ 4 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smart/S500(US)_1.0_1.2.0.json diff --git a/README.md b/README.md index c422c14d1..778ef21eb 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: P100, P110, P110M, P115, P125M, P135, TP10, TP15 - **Power Strips**: P210M, P300, P304M, P306, TP25 -- **Wall Switches**: S210, S220, S500D, S505, S505D +- **Wall Switches**: S210, S220, S500, S500D, S505, S505D - **Bulbs**: L510B, L510E, L530B, L530E, L535E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Cameras**: C100, C110, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70 diff --git a/SUPPORTED.md b/SUPPORTED.md index 935b25e12..5b65aa606 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -237,6 +237,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.9.0 - **S220** - Hardware: 1.0 (EU) / Firmware: 1.9.0 +- **S500** + - Hardware: 1.0 (US) / Firmware: 1.2.0 - **S500D** - Hardware: 1.0 (US) / Firmware: 1.0.5 - **S505** diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 4d6a2d807..507982bd5 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -105,6 +105,7 @@ "KS205", "KS225", "KS240", + "S500", "S500D", "S505", "S505D", diff --git a/tests/fixtures/smart/S500(US)_1.0_1.2.0.json b/tests/fixtures/smart/S500(US)_1.0_1.2.0.json new file mode 100644 index 000000000..c3ef8b911 --- /dev/null +++ b/tests/fixtures/smart/S500(US)_1.0_1.2.0.json @@ -0,0 +1,363 @@ +{ + "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": "localSmart", + "ver_code": 1 + }, + { + "id": "delay_action", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "S500(US)", + "device_type": "SMART.TAPOSWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "98-03-8E-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_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_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.2.0 Build 230906 Rel.140001", + "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": "98-03-8E-00-00-00", + "model": "S500", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "America/New_York", + "rssi": -41, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.TAPOSWITCH" + }, + "get_device_time": { + "region": "America/New_York", + "time_diff": -300, + "timestamp": 1755999484 + }, + "get_device_usage": { + "time_usage": { + "past30": 63, + "past7": 63, + "today": 63 + } + }, + "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.2.0 Build 230906 Rel.140001", + "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": 418, + "night_mode_type": "sunrise_sunset", + "start_time": 1190, + "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": "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": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 15, + "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": "S500", + "device_type": "SMART.TAPOSWITCH", + "is_klap": true + } + } +} From 2b881cfd7b3ab19952a56b4e5e7c1523a171e76c Mon Sep 17 00:00:00 2001 From: Giovanni Date: Sat, 30 Aug 2025 10:42:53 -0400 Subject: [PATCH 866/892] Add device fixture for P316M(US) (#1568) Adds device fixture and updates powerprotection module to accept the changed enabled key. --------- Co-authored-by: komodo Co-authored-by: Teemu Rytilahti --- README.md | 2 +- SUPPORTED.md | 2 + kasa/smart/modules/powerprotection.py | 9 +- tests/device_fixtures.py | 2 +- tests/fixtures/smart/P316M(US)_1.6_1.0.5.json | 3485 +++++++++++++++++ tests/smart/modules/test_powerprotection.py | 48 +- 6 files changed, 3524 insertions(+), 24 deletions(-) create mode 100644 tests/fixtures/smart/P316M(US)_1.6_1.0.5.json diff --git a/README.md b/README.md index 778ef21eb..39c2d4099 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ The following devices have been tested and confirmed as working. If your device ### Supported Tapo[^1] devices - **Plugs**: P100, P110, P110M, P115, P125M, P135, TP10, TP15 -- **Power Strips**: P210M, P300, P304M, P306, TP25 +- **Power Strips**: P210M, P300, P304M, P306, P316M, TP25 - **Wall Switches**: S210, S220, S500, S500D, S505, S505D - **Bulbs**: L510B, L510E, L530B, L530E, L535E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 diff --git a/SUPPORTED.md b/SUPPORTED.md index 5b65aa606..9abbefed3 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -228,6 +228,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (UK) / Firmware: 1.0.3 - **P306** - Hardware: 1.0 (US) / Firmware: 1.1.2 +- **P316M** + - Hardware: 1.6 (US) / Firmware: 1.0.5 - **TP25** - Hardware: 1.0 (US) / Firmware: 1.0.2 diff --git a/kasa/smart/modules/powerprotection.py b/kasa/smart/modules/powerprotection.py index ff7e726d5..9e6498ced 100644 --- a/kasa/smart/modules/powerprotection.py +++ b/kasa/smart/modules/powerprotection.py @@ -57,7 +57,9 @@ def overloaded(self) -> bool: @property def enabled(self) -> bool: """Return True if child protection is enabled.""" - return self.data["get_protection_power"]["enabled"] + settings = self.data["get_protection_power"] + enabled_key = next(k for k in settings if "enabled" in k) + return settings[enabled_key] async def set_enabled(self, enabled: bool, *, threshold: int | None = None) -> dict: """Set power protection enabled. @@ -73,7 +75,10 @@ async def set_enabled(self, enabled: bool, *, threshold: int | None = None) -> d "Threshold out of range: %s (%s)", threshold, self.protection_threshold ) - params = {**self.data["get_protection_power"], "enabled": enabled} + enabled_key = next( + k for k in self.data["get_protection_power"] if "enabled" in k + ) + params = {**self.data["get_protection_power"], enabled_key: enabled} if threshold is not None: params["protection_power"] = threshold return await self.call("set_protection_power", params) diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 507982bd5..8b53446fa 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -112,7 +112,7 @@ } SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} -STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M", "P210M", "P306"} +STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M", "P210M", "P306", "P316M"} STRIPS = {*STRIPS_IOT, *STRIPS_SMART} DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"} diff --git a/tests/fixtures/smart/P316M(US)_1.6_1.0.5.json b/tests/fixtures/smart/P316M(US)_1.6_1.0.5.json new file mode 100644 index 000000000..98f33d3d0 --- /dev/null +++ b/tests/fixtures/smart/P316M(US)_1.6_1.0.5.json @@ -0,0 +1,3485 @@ +{ + "child_devices": { + "SCRUBBED_CHILD_DEVICE_ID_1": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "2379878FD20427DA56CDE7809167A79E", + "hw_ver": "1.6", + "is_usb": false, + "latitude": -1879048193, + "longitude": -1879048193, + "mac": "A829481CEB5D", + "model": "P316M", + "nickname": "U21hcnQgUGx1ZyAx", + "oem_id": "B956FCA0B4CAEE8BE9E3E923D29DC8A0", + "on_off": 1, + "on_time": 2255, + "original_device_id": "8022F9F21DB6889D90BF8E7AAB04CD47242AAAF9", + "overcurrent_status": "normal", + "position": 1, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 36, + "past7": 36, + "today": 36 + }, + "time_usage": { + "past30": 36, + "past7": 36, + "today": 36 + } + }, + "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_emeter_data": { + "current_ma": 0, + "energy_wh": 0, + "power_mw": 0, + "voltage_mv": 120209 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3821, + "slot_id": 0, + "vgain": 30247 + }, + { + "igain": 3821, + "slot_id": 1, + "vgain": 30224 + }, + { + "igain": 3821, + "slot_id": 2, + "vgain": 29991 + }, + { + "igain": 3803, + "slot_id": 3, + "vgain": 30096 + }, + { + "igain": 3779, + "slot_id": 4, + "vgain": 30203 + }, + { + "igain": 3852, + "slot_id": 5, + "vgain": 30158 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-07-23 18:19:43", + "month_energy": 0, + "month_runtime": 36, + "today_energy": 0, + "today_runtime": 36 + }, + "get_max_power": { + "max_power": 1644 + }, + "get_next_event": {}, + "get_protection_power": { + "protection_enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_2": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "2379878FD20427DA56CDE7809167A79E", + "hw_ver": "1.6", + "is_usb": false, + "latitude": -1879048193, + "longitude": -1879048193, + "mac": "A829481CEB5D", + "model": "P316M", + "nickname": "U21hcnQgUGx1ZyAy", + "oem_id": "B956FCA0B4CAEE8BE9E3E923D29DC8A0", + "on_off": 1, + "on_time": 2256, + "original_device_id": "8022F9F21DB6889D90BF8E7AAB04CD47242AAAF9", + "overcurrent_status": "normal", + "position": 2, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 36, + "past7": 36, + "today": 36 + }, + "time_usage": { + "past30": 36, + "past7": 36, + "today": 36 + } + }, + "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_emeter_data": { + "current_ma": 0, + "energy_wh": 0, + "power_mw": 0, + "voltage_mv": 120118 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3821, + "slot_id": 0, + "vgain": 30247 + }, + { + "igain": 3821, + "slot_id": 1, + "vgain": 30224 + }, + { + "igain": 3821, + "slot_id": 2, + "vgain": 29991 + }, + { + "igain": 3803, + "slot_id": 3, + "vgain": 30096 + }, + { + "igain": 3779, + "slot_id": 4, + "vgain": 30203 + }, + { + "igain": 3852, + "slot_id": 5, + "vgain": 30158 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-07-23 18:19:44", + "month_energy": 0, + "month_runtime": 36, + "today_energy": 0, + "today_runtime": 36 + }, + "get_max_power": { + "max_power": 1643 + }, + "get_next_event": {}, + "get_protection_power": { + "protection_enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_3": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 38 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "2379878FD20427DA56CDE7809167A79E", + "hw_ver": "1.6", + "is_usb": false, + "latitude": -1879048193, + "longitude": -1879048193, + "mac": "A829481CEB5D", + "model": "P316M", + "nickname": "U21hcnQgUGx1ZyAz", + "oem_id": "B956FCA0B4CAEE8BE9E3E923D29DC8A0", + "on_off": 1, + "on_time": 2256, + "original_device_id": "8022F9F21DB6889D90BF8E7AAB04CD47242AAAF9", + "overcurrent_status": "normal", + "position": 3, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 23, + "past7": 23, + "today": 23 + }, + "saved_power": { + "past30": 13, + "past7": 13, + "today": 13 + }, + "time_usage": { + "past30": 36, + "past7": 36, + "today": 36 + } + }, + "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_emeter_data": { + "current_ma": 335, + "energy_wh": 23, + "power_mw": 38617, + "voltage_mv": 119299 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3821, + "slot_id": 0, + "vgain": 30247 + }, + { + "igain": 3821, + "slot_id": 1, + "vgain": 30224 + }, + { + "igain": 3821, + "slot_id": 2, + "vgain": 29991 + }, + { + "igain": 3803, + "slot_id": 3, + "vgain": 30096 + }, + { + "igain": 3779, + "slot_id": 4, + "vgain": 30203 + }, + { + "igain": 3852, + "slot_id": 5, + "vgain": 30158 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-07-23 18:19:44", + "month_energy": 23, + "month_runtime": 36, + "today_energy": 23, + "today_runtime": 36 + }, + "get_max_power": { + "max_power": 1633 + }, + "get_next_event": {}, + "get_protection_power": { + "protection_enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_4": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 1 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "2379878FD20427DA56CDE7809167A79E", + "hw_ver": "1.6", + "is_usb": false, + "latitude": -1879048193, + "longitude": -1879048193, + "mac": "A829481CEB5D", + "model": "P316M", + "nickname": "U21hcnQgUGx1ZyA0", + "oem_id": "B956FCA0B4CAEE8BE9E3E923D29DC8A0", + "on_off": 1, + "on_time": 2257, + "original_device_id": "8022F9F21DB6889D90BF8E7AAB04CD47242AAAF9", + "overcurrent_status": "normal", + "position": 4, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 36, + "past7": 36, + "today": 36 + }, + "time_usage": { + "past30": 36, + "past7": 36, + "today": 36 + } + }, + "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_emeter_data": { + "current_ma": 85, + "energy_wh": 0, + "power_mw": 1999, + "voltage_mv": 119801 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3821, + "slot_id": 0, + "vgain": 30247 + }, + { + "igain": 3821, + "slot_id": 1, + "vgain": 30224 + }, + { + "igain": 3821, + "slot_id": 2, + "vgain": 29991 + }, + { + "igain": 3803, + "slot_id": 3, + "vgain": 30096 + }, + { + "igain": 3779, + "slot_id": 4, + "vgain": 30203 + }, + { + "igain": 3852, + "slot_id": 5, + "vgain": 30158 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-07-23 18:19:45", + "month_energy": 0, + "month_runtime": 36, + "today_energy": 0, + "today_runtime": 36 + }, + "get_max_power": { + "max_power": 1638 + }, + "get_next_event": {}, + "get_protection_power": { + "protection_enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_5": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "2379878FD20427DA56CDE7809167A79E", + "hw_ver": "1.6", + "is_usb": false, + "latitude": -1879048193, + "longitude": -1879048193, + "mac": "A829481CEB5D", + "model": "P316M", + "nickname": "U21hcnQgUGx1ZyA1", + "oem_id": "B956FCA0B4CAEE8BE9E3E923D29DC8A0", + "on_off": 1, + "on_time": 2257, + "original_device_id": "8022F9F21DB6889D90BF8E7AAB04CD47242AAAF9", + "overcurrent_status": "normal", + "position": 5, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 36, + "past7": 36, + "today": 36 + }, + "time_usage": { + "past30": 36, + "past7": 36, + "today": 36 + } + }, + "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_emeter_data": { + "current_ma": 0, + "energy_wh": 0, + "power_mw": 0, + "voltage_mv": 120047 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3821, + "slot_id": 0, + "vgain": 30247 + }, + { + "igain": 3821, + "slot_id": 1, + "vgain": 30224 + }, + { + "igain": 3821, + "slot_id": 2, + "vgain": 29991 + }, + { + "igain": 3803, + "slot_id": 3, + "vgain": 30096 + }, + { + "igain": 3779, + "slot_id": 4, + "vgain": 30203 + }, + { + "igain": 3852, + "slot_id": 5, + "vgain": 30158 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-07-23 18:19:45", + "month_energy": 0, + "month_runtime": 36, + "today_energy": 0, + "today_runtime": 36 + }, + "get_max_power": { + "max_power": 1642 + }, + "get_next_event": {}, + "get_protection_power": { + "protection_enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_6": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "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", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_6", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "2379878FD20427DA56CDE7809167A79E", + "hw_ver": "1.6", + "is_usb": false, + "latitude": -1879048193, + "longitude": -1879048193, + "mac": "A829481CEB5D", + "model": "P316M", + "nickname": "U21hcnQgUGx1ZyA2", + "oem_id": "B956FCA0B4CAEE8BE9E3E923D29DC8A0", + "on_off": 1, + "on_time": 2257, + "original_device_id": "8022F9F21DB6889D90BF8E7AAB04CD47242AAAF9", + "overcurrent_status": "normal", + "position": 6, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 36, + "past7": 36, + "today": 36 + }, + "time_usage": { + "past30": 36, + "past7": 36, + "today": 36 + } + }, + "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_emeter_data": { + "current_ma": 0, + "energy_wh": 0, + "power_mw": 0, + "voltage_mv": 119868 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3821, + "slot_id": 0, + "vgain": 30247 + }, + { + "igain": 3821, + "slot_id": 1, + "vgain": 30224 + }, + { + "igain": 3821, + "slot_id": 2, + "vgain": 29991 + }, + { + "igain": 3803, + "slot_id": 3, + "vgain": 30096 + }, + { + "igain": 3779, + "slot_id": 4, + "vgain": 30203 + }, + { + "igain": 3852, + "slot_id": 5, + "vgain": 30158 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-07-23 18:19:45", + "month_energy": 0, + "month_runtime": 36, + "today_energy": 0, + "today_runtime": 36 + }, + "get_max_power": { + "max_power": 1639 + }, + "get_next_event": {}, + "get_protection_power": { + "protection_enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + } + }, + "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": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 3 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P316M(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-29-48-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_6" + } + ], + "start_index": 0, + "sum": 6 + }, + "get_child_device_list": { + "child_device_list": [ + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.6", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A82948000000", + "model": "P316M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 1, + "on_time": 2253, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "position": 1, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.6", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A82948000000", + "model": "P316M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 1, + "on_time": 2253, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "position": 2, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.6", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A82948000000", + "model": "P316M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 1, + "on_time": 2253, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "position": 3, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.6", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A82948000000", + "model": "P316M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 1, + "on_time": 2253, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "position": 4, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.6", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A82948000000", + "model": "P316M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 1, + "on_time": 2253, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "position": 5, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_6", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.6", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A82948000000", + "model": "P316M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 1, + "on_time": 2253, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "position": 6, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + } + ], + "start_index": 0, + "sum": 6 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_current_power": { + "current_power": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.6", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "A8-29-48-00-00-00", + "model": "P316M", + "nickname": "", + "oem_id": "00000000000000000000000000000000", + "region": "America/New_York", + "rssi": -47, + "signal_level": 3, + "specs": "US", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/New_York", + "time_diff": -300, + "timestamp": 1753309182 + }, + "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_emeter_data": { + "current_ma": 0, + "energy_wh": 0, + "power_mw": 0, + "voltage_mv": 120209 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3821, + "slot_id": 0, + "vgain": 30247 + }, + { + "igain": 3821, + "slot_id": 1, + "vgain": 30224 + }, + { + "igain": 3821, + "slot_id": 2, + "vgain": 29991 + }, + { + "igain": 3803, + "slot_id": 3, + "vgain": 30096 + }, + { + "igain": 3779, + "slot_id": 4, + "vgain": 30203 + }, + { + "igain": 3852, + "slot_id": 5, + "vgain": 30158 + } + ] + }, + "get_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-07-23 18:19:43", + "month_energy": 0, + "month_runtime": 36, + "today_energy": 0, + "today_runtime": 36 + }, + "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.5 Build 250306 Rel.151943", + "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": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_max_power": { + "max_power": 1644 + }, + "get_protection_power": { + "enabled": false, + "protection_power": 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": 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": 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==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 21, + "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": 3 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P316M", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} diff --git a/tests/smart/modules/test_powerprotection.py b/tests/smart/modules/test_powerprotection.py index 7f03c0e9a..b536ee1d6 100644 --- a/tests/smart/modules/test_powerprotection.py +++ b/tests/smart/modules/test_powerprotection.py @@ -48,33 +48,40 @@ async def test_set_enable(dev: SmartDevice, mocker: MockerFixture): # Simple enable with an existing threshold call_spy = mocker.spy(powerprot, "call") await powerprot.set_enabled(True) - params = { - "enabled": True, - "protection_power": mocker.ANY, - } - call_spy.assert_called_with("set_protection_power", params) + + args, kwargs = call_spy.call_args + method, params = args + assert method == "set_protection_power" + + enabled_key = next( + k for k in powerprot.data["get_protection_power"] if "enabled" in k + ) + assert params[enabled_key] is True + assert params["protection_power"] is not None # Enable with no threshold param when 0 call_spy.reset_mock() await powerprot.set_protection_threshold(0) await device.update() await powerprot.set_enabled(True) - params = { - "enabled": True, - "protection_power": int(powerprot._max_power / 2), - } - call_spy.assert_called_with("set_protection_power", params) + + args, kwargs = call_spy.call_args + method, params = args + assert method == "set_protection_power" + assert "enabled" in params or "protection_enabled" in params + assert params["protection_power"] == int(powerprot._max_power / 2) # Enable false should not update the threshold call_spy.reset_mock() await powerprot.set_protection_threshold(0) await device.update() await powerprot.set_enabled(False) - params = { - "enabled": False, - "protection_power": 0, - } - call_spy.assert_called_with("set_protection_power", params) + + args, kwargs = call_spy.call_args + method, params = args + assert method == "set_protection_power" + assert "enabled" in params or "protection_enabled" in params + assert params["protection_power"] == 0 finally: await powerprot.set_enabled(original_enabled, threshold=original_threshold) @@ -88,11 +95,12 @@ async def test_set_threshold(dev: SmartDevice, mocker: MockerFixture): call_spy = mocker.spy(powerprot, "call") await powerprot.set_protection_threshold(123) - params = { - "enabled": mocker.ANY, - "protection_power": 123, - } - call_spy.assert_called_with("set_protection_power", params) + + args, kwargs = call_spy.call_args + method, params = args + assert method == "set_protection_power" + assert "enabled" in params or "protection_enabled" in params + assert params["protection_power"] == 123 with pytest.raises(ValueError, match="Threshold out of range"): await powerprot.set_protection_threshold(-10) From 0f6fc9c4d1f96bde0e0bc7e15437877b0bbfca2d Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Fri, 10 Oct 2025 10:45:16 -0400 Subject: [PATCH 867/892] Add bare bones homekit module for iot devices (#1566) Based on the existing smart HomeKit module, this has been tested with a real device that supports this module. --------- Co-authored-by: Teemu Rytilahti --- README.md | 2 +- SUPPORTED.md | 1 + devtools/dump_devinfo.py | 3 +- kasa/device.py | 2 + kasa/iot/iotdevice.py | 4 +- kasa/iot/iotstrip.py | 13 +++- kasa/iot/modules/__init__.py | 2 + kasa/iot/modules/homekit.py | 53 ++++++++++++++ kasa/module.py | 1 + kasa/protocols/iotprotocol.py | 3 + tests/device_fixtures.py | 3 +- tests/fixtures/iot/EP25(US)_1.0_1.0.14.json | 80 +++++++++++++++++++++ tests/iot/modules/test_homekit.py | 59 +++++++++++++++ 13 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 kasa/iot/modules/homekit.py create mode 100644 tests/fixtures/iot/EP25(US)_1.0_1.0.14.json create mode 100644 tests/iot/modules/test_homekit.py diff --git a/README.md b/README.md index 39c2d4099..aca294e22 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ The following devices have been tested and confirmed as working. If your device ### Supported Kasa devices -- **Plugs**: EP10, EP25[^1], HS100[^2], HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M[^1], KP401 +- **Plugs**: EP10, EP25[^2], HS100[^2], HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M[^1], KP401 - **Power Strips**: EP40, EP40M[^1], HS107, HS300, KP200, KP303, KP400 - **Wall Switches**: ES20M, HS200[^2], HS210, HS220[^2], KP405, KS200, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1] - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110 diff --git a/SUPPORTED.md b/SUPPORTED.md index 9abbefed3..8f4c1c102 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -20,6 +20,7 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - **EP10** - Hardware: 1.0 (US) / Firmware: 1.0.2 - **EP25** + - Hardware: 1.0 (US) / Firmware: 1.0.14 - Hardware: 2.6 (US) / Firmware: 1.0.1[^1] - Hardware: 2.6 (US) / Firmware: 1.0.2[^1] - **HS100** diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index bbe1e8130..027b28bdd 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -80,7 +80,7 @@ def _wrap_redactors(redactors: dict[str, Callable[[Any], Any] | None]): - """Wrap the redactors for dump_devinfo. + """Wrap the redactors for dump_devinfo. Will replace all partial REDACT_ values with zeros. If the data item is already scrubbed by dump_devinfo will leave as-is. @@ -423,6 +423,7 @@ async def get_legacy_fixture( Call(module="smartlife.iot.LAS", method="get_adc_value"), Call(module="smartlife.iot.PIR", method="get_config"), Call(module="smartlife.iot.PIR", method="get_adc_value"), + Call(module="smartlife.iot.homekit", method="setup_info_get"), ] successes = [] diff --git a/kasa/device.py b/kasa/device.py index c4ea41e2e..45763db3e 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -47,6 +47,7 @@ >>> for module_name in dev.modules: >>> print(module_name) +homekit Energy schedule usage @@ -85,6 +86,7 @@ rssi on_since reboot +... current_consumption consumption_today consumption_this_month diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index d1de7f9e6..36aba3e56 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -31,7 +31,7 @@ from ..modulemapping import ModuleMapping, ModuleName from ..protocols import BaseProtocol from .iotmodule import IotModule, merge -from .modules import Emeter +from .modules import Emeter, HomeKit _LOGGER = logging.getLogger(__name__) @@ -330,6 +330,8 @@ async def update(self, update_children: bool = True) -> None: async def _initialize_modules(self) -> None: """Initialize modules not added in init.""" + self.add_module(Module.IotHomeKit, HomeKit(self, "smartlife.iot.homekit")) + if self.has_emeter: _LOGGER.debug( "The device has emeter, querying its information along sysinfo" diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index a63b3e17c..64ddf0a03 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -21,7 +21,17 @@ ) from .iotmodule import IotModule from .iotplug import IotPlug -from .modules import Antitheft, Cloud, Countdown, Emeter, Led, Schedule, Time, Usage +from .modules import ( + Antitheft, + Cloud, + Countdown, + Emeter, + HomeKit, + Led, + Schedule, + Time, + Usage, +) _LOGGER = logging.getLogger(__name__) @@ -109,6 +119,7 @@ async def _initialize_modules(self) -> None: self.add_module(Module.IotCountdown, Countdown(self, "countdown")) self.add_module(Module.Led, Led(self, "system")) self.add_module(Module.IotCloud, Cloud(self, "cnCloud")) + self.add_module(Module.IotHomeKit, HomeKit(self, "smartlife.iot.homekit")) if self.has_emeter: _LOGGER.debug( "The device has emeter, querying its information along sysinfo" diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index ef7adf689..207839e4b 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -6,6 +6,7 @@ from .countdown import Countdown from .dimmer import Dimmer from .emeter import Emeter +from .homekit import HomeKit from .led import Led from .light import Light from .lighteffect import LightEffect @@ -34,4 +35,5 @@ "Schedule", "Time", "Usage", + "HomeKit", ] diff --git a/kasa/iot/modules/homekit.py b/kasa/iot/modules/homekit.py new file mode 100644 index 000000000..935f87f9f --- /dev/null +++ b/kasa/iot/modules/homekit.py @@ -0,0 +1,53 @@ +"""Implementation of HomeKit module for IOT devices that natively support HomeKit.""" + +from __future__ import annotations + +from typing import Any + +from ...feature import Feature +from ..iotmodule import IotModule + + +class HomeKit(IotModule): + """Implementation of HomeKit module for IOT devices.""" + + def query(self) -> dict: + """Request HomeKit setup info.""" + return {"smartlife.iot.homekit": {"setup_info_get": {}}} + + @property + def info(self) -> dict[str, Any]: + """Return the HomeKit setup info.""" + # Only return info if the module has data + if self._module not in self._device._last_update: + return {} + return self.data.get("setup_info_get", {}) + + @property + def setup_code(self) -> str: + """Return the HomeKit setup code.""" + return self.info["setup_code"] + + @property + def setup_payload(self) -> str: + """Return the HomeKit setup payload.""" + return self.info["setup_payload"] + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + # Only add features if the device supports the module + data = self._device._last_update.get(self._module, {}) + if not data or "setup_info_get" not in data: + return + + self._add_feature( + Feature( + self._device, + container=self, + id="homekit_setup_code", + name="HomeKit setup code", + attribute_getter="setup_code", + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) diff --git a/kasa/module.py b/kasa/module.py index afd1e1274..57ee321f1 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -116,6 +116,7 @@ class Module(ABC): IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule") IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage") IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud") + IotHomeKit: Final[ModuleName[iot.HomeKit]] = ModuleName("homekit") # SMART only Modules AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff") diff --git a/kasa/protocols/iotprotocol.py b/kasa/protocols/iotprotocol.py index 7ca02e0ca..8d85733e2 100755 --- a/kasa/protocols/iotprotocol.py +++ b/kasa/protocols/iotprotocol.py @@ -4,6 +4,7 @@ import asyncio import logging +import re from collections.abc import Callable from pprint import pformat as pf from typing import TYPE_CHECKING, Any @@ -54,6 +55,8 @@ def mask_child(child: dict[str, Any], index: int) -> dict[str, Any]: "oemId": lambda x: "REDACTED_" + x[9::], "username": lambda _: "user@example.com", # cnCloud "hwId": lambda x: "REDACTED_" + x[9::], + "setup_code": lambda x: re.sub(r"\w", "0", x), # homekit + "setup_payload": lambda x: re.sub(r"\w", "0", x), # homekit } diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 8b53446fa..a6cef66cf 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -73,6 +73,7 @@ "HS105", "HS110", "EP10", + "EP25", "KP100", "KP105", "KP115", @@ -139,7 +140,7 @@ VACUUMS_SMART = {"RV20"} -WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} +WITH_EMETER_IOT = {"EP25", "HS110", "HS300", "KP115", "KP125", *BULBS_IOT} WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"} WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} diff --git a/tests/fixtures/iot/EP25(US)_1.0_1.0.14.json b/tests/fixtures/iot/EP25(US)_1.0_1.0.14.json new file mode 100644 index 000000000..9b592fbb8 --- /dev/null +++ b/tests/fixtures/iot/EP25(US)_1.0_1.0.14.json @@ -0,0 +1,80 @@ +{ + "cnCloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 0, + "total_wh": 50, + "voltage_mv": 125403 + } + }, + "schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 0, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "smartlife.iot.homekit": { + "setup_info_get": { + "err_code": 0, + "setup_code": "000-00-000", + "setup_payload": "0-00://0000000000000" + } + }, + "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": "AC:15:A2:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "EP25(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "apple", + "oemId": "00000000000000000000000000000000", + "on_time": 495961, + "relay_state": 1, + "rssi": -37, + "status": "configured", + "sw_ver": "1.0.14 Build 240424 Rel.094105", + "updating": 0 + } + } +} diff --git a/tests/iot/modules/test_homekit.py b/tests/iot/modules/test_homekit.py new file mode 100644 index 000000000..29436785f --- /dev/null +++ b/tests/iot/modules/test_homekit.py @@ -0,0 +1,59 @@ +from unittest.mock import PropertyMock, patch + +import pytest + +from kasa import Module +from kasa.iot import IotDevice +from kasa.iot.modules.homekit import HomeKit + +from ...device_fixtures import device_iot + + +@device_iot +def test_homekit_getters(dev: IotDevice): + # HomeKit can be present on any IOT device + if Module.IotHomeKit not in dev.modules: + pytest.skip("HomeKit module not present on this device") + homekit: HomeKit = dev.modules[Module.IotHomeKit] + info = homekit.info + if not info: + pytest.skip("No HomeKit data present for this fixture") + assert "setup_code" in info + assert "setup_payload" in info + assert "err_code" in info + # Check that the setup_code and setup_payload are strings + assert isinstance(info["setup_code"], str) + assert isinstance(info["setup_payload"], str) + assert isinstance(info["err_code"], int) + # Check that the HomeKit module properties match + assert info["setup_code"] == homekit.setup_code + assert info["setup_payload"] == homekit.setup_payload + + +@device_iot +def test_homekit_feature(dev: IotDevice): + if Module.IotHomeKit not in dev.modules: + pytest.skip("HomeKit module not present on this device") + homekit: HomeKit = dev.modules[Module.IotHomeKit] + if not homekit.info: + pytest.skip("No HomeKit data present for this device") + feature = homekit._all_features.get("homekit_setup_code") + assert feature is not None + assert isinstance(feature.attribute_getter, str) + value = getattr(homekit, feature.attribute_getter) + assert value == homekit.setup_code + + +@device_iot +def test_initialize_features_skips_when_no_data(dev: IotDevice): + if Module.IotHomeKit not in dev.modules: + pytest.skip("HomeKit module not present on this device") + homekit: HomeKit = dev.modules[Module.IotHomeKit] + if "homekit_setup_code" in homekit._all_features: + pytest.skip("HomeKit feature already present on this device") + # Patch .data so it looks like no homekit data is present + with patch.object(HomeKit, "data", new_callable=PropertyMock) as mock_data: + mock_data.return_value = {} + homekit._initialize_features() + # Since there was no data, no features should be added + assert "homekit_setup_code" not in homekit._all_features From 1a89a1c1b9a3e874d31b0c0a265c4a5337c2302b Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Fri, 10 Oct 2025 12:26:30 -0400 Subject: [PATCH 868/892] Use Device instead of SmartDevice where feasible (#1585) --- kasa/cli/hub.py | 10 +++++----- tests/fakeprotocol_iot.py | 1 - tests/smart/modules/test_childsetup.py | 8 ++++---- tests/smart/modules/test_powerprotection.py | 6 +++--- tests/smartcam/modules/test_childsetup.py | 8 ++++---- 5 files changed, 16 insertions(+), 17 deletions(-) diff --git a/kasa/cli/hub.py b/kasa/cli/hub.py index de4b60715..0bc45bf5a 100644 --- a/kasa/cli/hub.py +++ b/kasa/cli/hub.py @@ -4,7 +4,7 @@ import asyncclick as click -from kasa import DeviceType, Module, SmartDevice +from kasa import Device, DeviceType, Module from kasa.smart import SmartChildDevice from .common import ( @@ -21,7 +21,7 @@ def pretty_category(cat: str): @click.group() @pass_dev -async def hub(dev: SmartDevice): +async def hub(dev: Device): """Commands controlling hub child device pairing.""" if dev.device_type is not DeviceType.Hub: error(f"{dev} is not a hub.") @@ -32,7 +32,7 @@ async def hub(dev: SmartDevice): @hub.command(name="list") @pass_dev -async def hub_list(dev: SmartDevice): +async def hub_list(dev: Device): """List hub paired child devices.""" for c in dev.children: echo(f"{c.device_id}: {c}") @@ -40,7 +40,7 @@ async def hub_list(dev: SmartDevice): @hub.command(name="supported") @pass_dev -async def hub_supported(dev: SmartDevice): +async def hub_supported(dev: Device): """List supported hub child device categories.""" cs = dev.modules[Module.ChildSetup] @@ -51,7 +51,7 @@ async def hub_supported(dev: SmartDevice): @hub.command(name="pair") @click.option("--timeout", default=10) @pass_dev -async def hub_pair(dev: SmartDevice, timeout: int): +async def hub_pair(dev: Device, timeout: int): """Pair all pairable device. This will pair any child devices currently in pairing mode. diff --git a/tests/fakeprotocol_iot.py b/tests/fakeprotocol_iot.py index 238e555ce..ed3b5b102 100644 --- a/tests/fakeprotocol_iot.py +++ b/tests/fakeprotocol_iot.py @@ -190,7 +190,6 @@ def success(res): }, } - MOTION_MODULE = { "get_adc_value": {"value": 50, "err_code": 0}, "get_config": { diff --git a/tests/smart/modules/test_childsetup.py b/tests/smart/modules/test_childsetup.py index 6f31a9488..afe36b0c0 100644 --- a/tests/smart/modules/test_childsetup.py +++ b/tests/smart/modules/test_childsetup.py @@ -5,7 +5,7 @@ import pytest from pytest_mock import MockerFixture -from kasa import Feature, Module, SmartDevice +from kasa import Device, Feature, Module from ...device_fixtures import parametrize @@ -15,7 +15,7 @@ @childsetup -async def test_childsetup_features(dev: SmartDevice): +async def test_childsetup_features(dev: Device): """Test the exposed features.""" cs = dev.modules.get(Module.ChildSetup) assert cs @@ -27,7 +27,7 @@ async def test_childsetup_features(dev: SmartDevice): @childsetup async def test_childsetup_pair( - dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture + dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ): """Test device pairing.""" caplog.set_level(logging.INFO) @@ -51,7 +51,7 @@ async def test_childsetup_pair( @childsetup async def test_childsetup_unpair( - dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture + dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ): """Test unpair.""" mock_query_helper = mocker.spy(dev, "_query_helper") diff --git a/tests/smart/modules/test_powerprotection.py b/tests/smart/modules/test_powerprotection.py index b536ee1d6..215df2be5 100644 --- a/tests/smart/modules/test_powerprotection.py +++ b/tests/smart/modules/test_powerprotection.py @@ -1,7 +1,7 @@ import pytest from pytest_mock import MockerFixture -from kasa import Module, SmartDevice +from kasa import Device, Module from ...device_fixtures import get_parent_and_child_modules, parametrize @@ -35,7 +35,7 @@ async def test_features(dev, feature, prop_name, type): @powerprotection -async def test_set_enable(dev: SmartDevice, mocker: MockerFixture): +async def test_set_enable(dev: Device, mocker: MockerFixture): """Test enable.""" powerprot = next(get_parent_and_child_modules(dev, Module.PowerProtection)) assert powerprot @@ -88,7 +88,7 @@ async def test_set_enable(dev: SmartDevice, mocker: MockerFixture): @powerprotection -async def test_set_threshold(dev: SmartDevice, mocker: MockerFixture): +async def test_set_threshold(dev: Device, mocker: MockerFixture): """Test enable.""" powerprot = next(get_parent_and_child_modules(dev, Module.PowerProtection)) assert powerprot diff --git a/tests/smartcam/modules/test_childsetup.py b/tests/smartcam/modules/test_childsetup.py index 5b8a7c494..090ea0338 100644 --- a/tests/smartcam/modules/test_childsetup.py +++ b/tests/smartcam/modules/test_childsetup.py @@ -5,7 +5,7 @@ import pytest from pytest_mock import MockerFixture -from kasa import Feature, Module, SmartDevice +from kasa import Device, Feature, Module from ...device_fixtures import parametrize @@ -15,7 +15,7 @@ @childsetup -async def test_childsetup_features(dev: SmartDevice): +async def test_childsetup_features(dev: Device): """Test the exposed features.""" cs = dev.modules[Module.ChildSetup] @@ -26,7 +26,7 @@ async def test_childsetup_features(dev: SmartDevice): @childsetup async def test_childsetup_pair( - dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture + dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ): """Test device pairing.""" caplog.set_level(logging.INFO) @@ -69,7 +69,7 @@ async def test_childsetup_pair( @childsetup async def test_childsetup_unpair( - dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture + dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ): """Test unpair.""" mock_query_helper = mocker.spy(dev, "_query_helper") From c6dd715763842e5aed67ffec44735739858c085c Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sun, 12 Oct 2025 17:51:52 +0200 Subject: [PATCH 869/892] Use log-level debug for smartdevice query error reporting (#1587) --- kasa/smart/smartdevice.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 2e2dc7cd5..87aa628d8 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -378,7 +378,7 @@ async def _handle_modular_update_error( """ msg_part = "on first update" if first_update else "after first update" - _LOGGER.error( + _LOGGER.debug( "Error querying %s for modules '%s' %s: %s", self.host, module_names, @@ -391,7 +391,7 @@ async def _handle_modular_update_error( resp = await self.protocol.query({meth: params}) responses[meth] = resp[meth] except Exception as iex: - _LOGGER.error( + _LOGGER.debug( "Error querying %s individually for module query '%s' %s: %s", self.host, meth, From 358454e516448c89ad7c225e1f3742f280a2c9fc Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sun, 12 Oct 2025 19:59:29 +0200 Subject: [PATCH 870/892] Add shutdown (summer mode) to thermostatstate (#1588) According to https://github.com/home-assistant/core/issues/151870, thermostats report "shutdown" when they are set into the summer mode. --- kasa/interfaces/thermostat.py | 1 + 1 file changed, 1 insertion(+) diff --git a/kasa/interfaces/thermostat.py b/kasa/interfaces/thermostat.py index de7831b06..c5cc512cc 100644 --- a/kasa/interfaces/thermostat.py +++ b/kasa/interfaces/thermostat.py @@ -16,6 +16,7 @@ class ThermostatState(Enum): Calibrating = "progress_calibration" Idle = "idle" Off = "off" + Shutdown = "shutdown" Unknown = "unknown" From f30f38b887d1c65f9b102e969ae085cf5c681ee4 Mon Sep 17 00:00:00 2001 From: Sameer Alam <31905246+alams154@users.noreply.github.com> Date: Mon, 27 Oct 2025 15:55:34 -0500 Subject: [PATCH 871/892] Add discovery port 20004 (#1595) Adds additional discovery port for Tapo Doorbells (D210 and D225) --------- Co-authored-by: Sameer Alam --- kasa/discover.py | 5 ++++- tests/test_discovery.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index 8e2b981af..e03f7187f 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -264,6 +264,7 @@ def __init__( self.target = target self.target_1 = (target, self.discovery_port) self.target_2 = (target, Discover.DISCOVERY_PORT_2) + self.target_3 = (target, Discover.DISCOVERY_PORT_3) self.discovered_devices = {} self.unsupported_device_exceptions: dict = {} @@ -333,6 +334,7 @@ async def do_discover(self) -> None: break self.transport.sendto(encrypted_req[4:], self.target_1) # type: ignore self.transport.sendto(aes_discovery_query, self.target_2) # type: ignore + self.transport.sendto(aes_discovery_query, self.target_3) # type: ignore await asyncio.sleep(sleep_between_packets) def datagram_received( @@ -361,7 +363,7 @@ def datagram_received( if port == self.discovery_port: json_func = Discover._get_discovery_json_legacy device_func = Discover._get_device_instance_legacy - elif port == Discover.DISCOVERY_PORT_2: + elif port in (Discover.DISCOVERY_PORT_2, Discover.DISCOVERY_PORT_3): json_func = Discover._get_discovery_json device_func = Discover._get_device_instance else: @@ -422,6 +424,7 @@ class Discover: } DISCOVERY_PORT_2 = 20002 + DISCOVERY_PORT_3 = 20004 DISCOVERY_QUERY_2 = binascii.unhexlify("020000010000000000000000463cb5d3") _redact_data = True diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 96c9e9c6b..6fc521b09 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -301,12 +301,13 @@ async def mock_discover(self): async def test_discover_send(mocker): """Test discovery parameters.""" discovery_timeout = 0 + discovery_ports = 3 proto = _DiscoverProtocol(discovery_timeout=discovery_timeout) assert proto.discovery_packets == 3 assert proto.target_1 == ("255.255.255.255", 9999) transport = mocker.patch.object(proto, "transport") await proto.do_discover() - assert transport.sendto.call_count == proto.discovery_packets * 2 + assert transport.sendto.call_count == proto.discovery_packets * discovery_ports async def test_discover_datagram_received(mocker, discovery_data): From adc291b62eac39afacd254e5781215cccfe36267 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 31 Oct 2025 11:13:30 +0100 Subject: [PATCH 872/892] Fix thermostat idle reporting on low battery (#1598) The `low_battery` state needs to be discarded before checking for an empty list of states to avoid incorrect reporting when the battery is low and there is no specific state set. --- kasa/smart/modules/temperaturecontrol.py | 6 ++++-- tests/smart/modules/test_temperaturecontrol.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index 5b0804614..b8db7b84f 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -93,12 +93,14 @@ def mode(self) -> ThermostatState: states = self.states + # Discard known non-mode states + states.discard("low_battery") + # If the states is empty, the device is idling if not states: return ThermostatState.Idle - # Discard known extra states, and report on unknown extra states - states.discard("low_battery") + # Report on unknown extra states if len(states) > 1: _LOGGER.warning("Got multiple states: %s", states) diff --git a/tests/smart/modules/test_temperaturecontrol.py b/tests/smart/modules/test_temperaturecontrol.py index d47f19ee6..4bcf218fd 100644 --- a/tests/smart/modules/test_temperaturecontrol.py +++ b/tests/smart/modules/test_temperaturecontrol.py @@ -154,3 +154,13 @@ async def test_thermostat_heating_with_low_battery(dev): temp_module: TemperatureControl = dev.modules["TemperatureControl"] temp_module.data["trv_states"] = ["low_battery", "heating"] assert temp_module.mode is ThermostatState.Heating + + +@thermostats_smart +async def test_thermostat_idle_with_low_battery(dev, caplog): + """Test that mode is reported correctly with extra states.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + temp_module.data["trv_states"] = ["low_battery"] + with caplog.at_level(logging.WARNING): + assert temp_module.mode is ThermostatState.Idle + assert not caplog.records From 29007e10795bbe7edb72111c599f41d8553c5f8b Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:32:13 -0400 Subject: [PATCH 873/892] Fix iotstrip child device time handling (#1584) This fixes the time handling of the child devices for iotstrip to pull from the parent device time module instead of having each child with its own time module. --- kasa/iot/iotstrip.py | 17 +++++++++++++++ tests/device_fixtures.py | 5 +++++ tests/iot/test_iotstrip.py | 44 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 tests/iot/test_iotstrip.py diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 64ddf0a03..7984ffb7c 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -7,6 +7,9 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + from datetime import tzinfo + from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus @@ -349,6 +352,8 @@ async def _initialize_modules(self) -> None: self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) self.add_module(Module.IotSchedule, Schedule(self, "schedule")) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) + # Note: do not add a Time module to the child; time is device-level. + # Child exposes time/timezone by delegating to the parent. async def _initialize_features(self) -> None: """Initialize common features.""" @@ -441,6 +446,18 @@ def led(self) -> bool: """ return False + @property # type: ignore + @requires_update + def time(self) -> datetime: + """Return current time, delegated from the parent strip.""" + return self._parent.time + + @property # type: ignore + @requires_update + def timezone(self) -> tzinfo: + """Return timezone, delegated from the parent strip.""" + return self._parent.timezone + @property # type: ignore @requires_update def device_id(self) -> str: diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index a6cef66cf..7c9ab2a2f 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -310,6 +310,11 @@ def parametrize( strip_iot = parametrize( "strip devices iot", model_filter=STRIPS_IOT, protocol_filter={"IOT"} ) +strip_emeter_iot = parametrize( + "strip devices iot with emeter", + model_filter=STRIPS_IOT & WITH_EMETER_IOT, + protocol_filter={"IOT"}, +) strip_smart = parametrize( "strip devices smart", model_filter=STRIPS_SMART, protocol_filter={"SMART"} ) diff --git a/tests/iot/test_iotstrip.py b/tests/iot/test_iotstrip.py new file mode 100644 index 000000000..50c1df618 --- /dev/null +++ b/tests/iot/test_iotstrip.py @@ -0,0 +1,44 @@ +from unittest.mock import AsyncMock + +from kasa import Module +from tests.conftest import strip_emeter_iot, strip_iot + + +@strip_iot +async def test_strip_update_and_child_update_behaviors(dev): + await dev.update() + await dev.update(update_children=False) + + assert dev.children, "Expected strip device to have children" + + child = dev.children[0] + await child.update(update_children=False) + + assert getattr(child, "_features", None) + + +@strip_iot +async def test_strip_child_delegated_properties(dev): + await dev.update() + child = dev.children[0] + + assert child.led is False + assert child.time == dev.time + assert child.timezone == dev.timezone + + na = child.next_action + assert isinstance(na, dict) + assert "type" in na + + +@strip_emeter_iot +async def test_strip_emeter_erase_stats(dev, mocker): + await dev.update() + + for child in dev.children: + energy = child.modules.get(Module.Energy) + if energy: + mocker.patch.object(energy, "erase_stats", AsyncMock(return_value={})) + + res = await dev.modules[Module.Energy].erase_stats() + assert res == {} From 77d2ca6a83d302833af3917688482dadcff55ddc Mon Sep 17 00:00:00 2001 From: Lilly Rose Berner Date: Mon, 10 Nov 2025 22:17:54 +0100 Subject: [PATCH 874/892] Add L430P(EU) device fixture (#1607) Signed-off-by: Lilly Rose Berner --- README.md | 2 +- SUPPORTED.md | 2 + tests/device_fixtures.py | 4 +- tests/fixtures/smart/L430P(EU)_1.0_1.0.9.json | 368 ++++++++++++++++++ 4 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/smart/L430P(EU)_1.0_1.0.9.json diff --git a/README.md b/README.md index aca294e22..2f3834d64 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: P100, P110, P110M, P115, P125M, P135, TP10, TP15 - **Power Strips**: P210M, P300, P304M, P306, P316M, TP25 - **Wall Switches**: S210, S220, S500, S500D, S505, S505D -- **Bulbs**: L510B, L510E, L530B, L530E, L535E, L630 +- **Bulbs**: L430P, L510B, L510E, L530B, L530E, L535E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Cameras**: C100, C110, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70 - **Doorbells and chimes**: D100C, D130, D230 diff --git a/SUPPORTED.md b/SUPPORTED.md index 8f4c1c102..f081b1945 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -251,6 +251,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros ### Bulbs +- **L430P** + - Hardware: 1.0 (EU) / Firmware: 1.0.9 - **L510B** - Hardware: 3.0 (EU) / Firmware: 1.0.5 - **L510E** diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 7c9ab2a2f..aee794e28 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -27,9 +27,9 @@ ) # Tapo bulbs -BULBS_SMART_VARIABLE_TEMP = {"L530E", "L535E", "L930-5"} +BULBS_SMART_VARIABLE_TEMP = {"L430P", "L530E", "L535E", "L930-5"} BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5", "L930-5"} -BULBS_SMART_COLOR = {"L530E", "L535E", *BULBS_SMART_LIGHT_STRIP} +BULBS_SMART_COLOR = {"L430P", "L530E", "L535E", *BULBS_SMART_LIGHT_STRIP} BULBS_SMART_DIMMABLE = {"L510B", "L510E"} BULBS_SMART = ( BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) diff --git a/tests/fixtures/smart/L430P(EU)_1.0_1.0.9.json b/tests/fixtures/smart/L430P(EU)_1.0_1.0.9.json new file mode 100644 index 000000000..411ee84bc --- /dev/null +++ b/tests/fixtures/smart/L430P(EU)_1.0_1.0.9.json @@ -0,0 +1,368 @@ +{ + "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": 3 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "auto_light", + "ver_code": 3 + }, + { + "id": "on_off_gradually", + "ver_code": 3 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "segment_effect", + "ver_code": 2 + }, + { + "id": "music_rhythm_bulb", + "ver_code": 4 + }, + { + "id": "bccp", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L430P(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "8C-86-DD-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "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": { + "auto_biorhythm_light_enable": false, + "avatar": "bulb", + "brightness": 100, + "color_temp": 2700, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.9 Build 250718 Rel.165940", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "8C-86-DD-00-00-00", + "model": "L430P", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -72, + "saturation": 100, + "segment_effect": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "" + }, + "signal_level": 1, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1762775418 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 1, + "past7": 1, + "today": 1 + }, + "time_usage": { + "past30": 1, + "past7": 1, + "today": 1 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": { + "inherit_status": false + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.9 Build 250718 Rel.165940", + "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": 1, + "enable": true, + "max_duration": 60 + }, + "on_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + } + }, + "get_preset_rules": { + "start_index": 0, + "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 + } + ], + "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": "" + }, + "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 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "bccp", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "L430P", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} From 347bf9a419d20089227cc4b9eaa2af257ef32b82 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 26 Nov 2025 15:47:26 +0100 Subject: [PATCH 875/892] Add hold state to thermostatstate (#1609) This adds `hold_on` to the known states to allow downstreams handle the hold mode (idle, but heating if the temperature gets lower than the target). --- kasa/interfaces/thermostat.py | 1 + 1 file changed, 1 insertion(+) diff --git a/kasa/interfaces/thermostat.py b/kasa/interfaces/thermostat.py index c5cc512cc..1d2ed28b2 100644 --- a/kasa/interfaces/thermostat.py +++ b/kasa/interfaces/thermostat.py @@ -15,6 +15,7 @@ class ThermostatState(Enum): Heating = "heating" Calibrating = "progress_calibration" Idle = "idle" + Hold = "hold_on" Off = "off" Shutdown = "shutdown" Unknown = "unknown" From a87926f9d73262b9a8763ce342d46ec685108d7d Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Sun, 30 Nov 2025 20:07:54 +0100 Subject: [PATCH 876/892] waterleaksensor: use parent's Time for alert timestamp (#1614) We do not, by design, add Time module for hub's children. This has a side-effect that we need to fallback to the parent's time module to allow presenting the correct timestamp for the last alert. --- kasa/smart/modules/waterleaksensor.py | 15 ++++++++++++++- tests/smart/modules/test_waterleak.py | 16 ++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/kasa/smart/modules/waterleaksensor.py b/kasa/smart/modules/waterleaksensor.py index b6f010174..fbf9e4040 100644 --- a/kasa/smart/modules/waterleaksensor.py +++ b/kasa/smart/modules/waterleaksensor.py @@ -4,8 +4,10 @@ from datetime import datetime from enum import Enum +from typing import TYPE_CHECKING from ...feature import Feature +from ...interfaces.time import Time from ..smartmodule import Module, SmartModule @@ -76,6 +78,17 @@ def alert(self) -> bool: """Return true if alarm is active.""" return self._device.sys_info["in_alarm"] + @property + def _time_module(self) -> Time: + """Return time module from the parent for timestamp calculation.""" + parent = self._device.parent + if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + assert isinstance(parent, SmartDevice) + + return parent.modules[Module.Time] + @property def alert_timestamp(self) -> datetime | None: """Return timestamp of the last leak trigger.""" @@ -84,5 +97,5 @@ def alert_timestamp(self) -> datetime | None: return None ts = self._device.sys_info["trigger_timestamp"] - tz = self._device.modules[Module.Time].timezone + tz = self._time_module.timezone return datetime.fromtimestamp(ts, tz=tz) diff --git a/tests/smart/modules/test_waterleak.py b/tests/smart/modules/test_waterleak.py index 1821e6e07..afae7dda9 100644 --- a/tests/smart/modules/test_waterleak.py +++ b/tests/smart/modules/test_waterleak.py @@ -5,6 +5,7 @@ from kasa.smart.modules import WaterleakSensor +from ...conftest import get_device_for_fixture_protocol from ...device_fixtures import parametrize waterleak = parametrize( @@ -12,6 +13,12 @@ ) +@pytest.fixture +async def parent(request): + """Get a dummy parent for tz tests.""" + return await get_device_for_fixture_protocol("H100(EU)_1.0_1.5.5.json", "SMART") + + @waterleak @pytest.mark.parametrize( ("feature", "prop_name", "type"), @@ -21,8 +28,9 @@ ("water_leak", "status", Enum), ], ) -async def test_waterleak_properties(dev, feature, prop_name, type): +async def test_waterleak_properties(dev, parent, feature, prop_name, type): """Test that features are registered and work as expected.""" + dev._parent = parent waterleak: WaterleakSensor = dev.modules["WaterleakSensor"] prop = getattr(waterleak, prop_name) @@ -34,8 +42,9 @@ async def test_waterleak_properties(dev, feature, prop_name, type): @waterleak -async def test_waterleak_features(dev): +async def test_waterleak_features(dev, parent): """Test waterleak features.""" + dev._parent = parent waterleak: WaterleakSensor = dev.modules["WaterleakSensor"] assert "water_leak" in dev.features @@ -43,3 +52,6 @@ async def test_waterleak_features(dev): assert "water_alert" in dev.features assert dev.features["water_alert"].value == waterleak.alert + + assert "water_alert_timestamp" in dev.features + assert dev.features["water_alert_timestamp"].value == waterleak.alert_timestamp From 25ff6976746b48baad1f868512acf902ea469eec Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:54:26 -0500 Subject: [PATCH 877/892] Add TS15(US) device fixture (#1649) Add TS15(US) device fixture --- README.md | 2 +- SUPPORTED.md | 2 + tests/device_fixtures.py | 1 + tests/fixtures/smart/TS15(US)_1.0_1.2.2.json | 336 +++++++++++++++++++ 4 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smart/TS15(US)_1.0_1.2.2.json diff --git a/README.md b/README.md index 2f3834d64..3aebb1cfe 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: P100, P110, P110M, P115, P125M, P135, TP10, TP15 - **Power Strips**: P210M, P300, P304M, P306, P316M, TP25 -- **Wall Switches**: S210, S220, S500, S500D, S505, S505D +- **Wall Switches**: S210, S220, S500, S500D, S505, S505D, TS15 - **Bulbs**: L430P, L510B, L510E, L530B, L530E, L535E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Cameras**: C100, C110, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70 diff --git a/SUPPORTED.md b/SUPPORTED.md index f081b1945..faf5ced53 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -248,6 +248,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (US) / Firmware: 1.0.2 - **S505D** - Hardware: 1.0 (US) / Firmware: 1.1.0 +- **TS15** + - Hardware: 1.0 (US) / Firmware: 1.2.2 ### Bulbs diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index aee794e28..dd56f9aa3 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -110,6 +110,7 @@ "S500D", "S505", "S505D", + "TS15", } SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} diff --git a/tests/fixtures/smart/TS15(US)_1.0_1.2.2.json b/tests/fixtures/smart/TS15(US)_1.0_1.2.2.json new file mode 100644 index 000000000..80daaa40d --- /dev/null +++ b/tests/fixtures/smart/TS15(US)_1.0_1.2.2.json @@ -0,0 +1,336 @@ +{ + "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": "localSmart", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "delay_action", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "TS15(US)", + "device_type": "SMART.TAPOSWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "B0-19-21-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 20, + "enable": true + }, + "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": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "hang_lamp_1", + "default_states": { + "state": { + "on": false + }, + "type": "custom" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.2 Build 240604 Rel.122252", + "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": "B0-19-21-00-00-00", + "model": "TS15", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/Chicago", + "rssi": -38, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.TAPOSWITCH" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1768406162 + }, + "get_device_usage": { + "time_usage": { + "past30": 4024, + "past7": 759, + "today": 37 + } + }, + "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.2.2 Build 240604 Rel.122252", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "never", + "led_status": false, + "night_mode": { + "end_time": 470, + "night_mode_type": "sunrise_sunset", + "start_time": 1017, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "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": "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": 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": 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": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 9, + "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": "TS15", + "device_type": "SMART.TAPOSWITCH", + "is_klap": true + } + } +} From 14584a371dcb64350af14aa059f5314e422d577b Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:01:38 -0500 Subject: [PATCH 878/892] Add P105(US) device fixture (#1644) Add P105(US) device fixture --- README.md | 2 +- SUPPORTED.md | 2 + tests/device_fixtures.py | 1 + tests/fixtures/smart/P105(US)_1.0_1.2.5.json | 354 +++++++++++++++++++ 4 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smart/P105(US)_1.0_1.2.5.json diff --git a/README.md b/README.md index 3aebb1cfe..7df4523c0 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ The following devices have been tested and confirmed as working. If your device ### Supported Tapo[^1] devices -- **Plugs**: P100, P110, P110M, P115, P125M, P135, TP10, TP15 +- **Plugs**: P100, P105, P110, P110M, P115, P125M, P135, TP10, TP15 - **Power Strips**: P210M, P300, P304M, P306, P316M, TP25 - **Wall Switches**: S210, S220, S500, S500D, S505, S505D, TS15 - **Bulbs**: L430P, L510B, L510E, L530B, L530E, L535E, L630 diff --git a/SUPPORTED.md b/SUPPORTED.md index faf5ced53..e51dc9106 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -196,6 +196,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0.0 (US) / Firmware: 1.1.3 - Hardware: 1.0.0 (US) / Firmware: 1.3.7 - Hardware: 1.0.0 (US) / Firmware: 1.4.0 +- **P105** + - Hardware: 1.0 (US) / Firmware: 1.2.5 - **P110** - Hardware: 1.0 (AU) / Firmware: 1.3.1 - Hardware: 1.0 (EU) / Firmware: 1.0.7 diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index dd56f9aa3..dc99f1a7a 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -81,6 +81,7 @@ "KP401", } PLUGS_SMART = { + "P105", "P100", "P110", "P110M", diff --git a/tests/fixtures/smart/P105(US)_1.0_1.2.5.json b/tests/fixtures/smart/P105(US)_1.0_1.2.5.json new file mode 100644 index 000000000..415b9a2e0 --- /dev/null +++ b/tests/fixtures/smart/P105(US)_1.0_1.2.5.json @@ -0,0 +1,354 @@ +{ + "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": "localSmart", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P105(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "98-BA-5F-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "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_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.5 Build 240411 Rel.143808", + "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": "98-BA-5F-00-00-00", + "model": "P105", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "America/Vancouver", + "rssi": -51, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -480, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/Vancouver", + "time_diff": -480, + "timestamp": 1767608579 + }, + "get_device_usage": { + "time_usage": { + "past30": 4113, + "past7": 2443, + "today": 3 + } + }, + "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.2.5 Build 240411 Rel.143808", + "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": 488, + "night_mode_type": "sunrise_sunset", + "start_time": 990, + "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": "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": 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==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 14, + "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": "P105", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} From 122849f784570c9b43a568d5828ee9476f918ed8 Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:14:53 -0500 Subject: [PATCH 879/892] Add L430C(EU) device fixture (#1643) --- README.md | 2 +- SUPPORTED.md | 2 + tests/device_fixtures.py | 4 +- tests/fixtures/smart/L430C(EU)_1.0_1.0.4.json | 469 ++++++++++++++++++ 4 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/smart/L430C(EU)_1.0_1.0.4.json diff --git a/README.md b/README.md index 7df4523c0..1847e1392 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: P100, P105, P110, P110M, P115, P125M, P135, TP10, TP15 - **Power Strips**: P210M, P300, P304M, P306, P316M, TP25 - **Wall Switches**: S210, S220, S500, S500D, S505, S505D, TS15 -- **Bulbs**: L430P, L510B, L510E, L530B, L530E, L535E, L630 +- **Bulbs**: L430C, L430P, L510B, L510E, L530B, L530E, L535E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Cameras**: C100, C110, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70 - **Doorbells and chimes**: D100C, D130, D230 diff --git a/SUPPORTED.md b/SUPPORTED.md index e51dc9106..0f5163ab6 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -255,6 +255,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros ### Bulbs +- **L430C** + - Hardware: 1.0 (EU) / Firmware: 1.0.4 - **L430P** - Hardware: 1.0 (EU) / Firmware: 1.0.9 - **L510B** diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index dc99f1a7a..5d8271288 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -27,9 +27,9 @@ ) # Tapo bulbs -BULBS_SMART_VARIABLE_TEMP = {"L430P", "L530E", "L535E", "L930-5"} +BULBS_SMART_VARIABLE_TEMP = {"L430C", "L430P", "L530E", "L535E", "L930-5"} BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5", "L930-5"} -BULBS_SMART_COLOR = {"L430P", "L530E", "L535E", *BULBS_SMART_LIGHT_STRIP} +BULBS_SMART_COLOR = {"L430C", "L430P", "L530E", "L535E", *BULBS_SMART_LIGHT_STRIP} BULBS_SMART_DIMMABLE = {"L510B", "L510E"} BULBS_SMART = ( BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) diff --git a/tests/fixtures/smart/L430C(EU)_1.0_1.0.4.json b/tests/fixtures/smart/L430C(EU)_1.0_1.0.4.json new file mode 100644 index 000000000..c4f591c65 --- /dev/null +++ b/tests/fixtures/smart/L430C(EU)_1.0_1.0.4.json @@ -0,0 +1,469 @@ +{ + "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": 3 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "auto_light", + "ver_code": 3 + }, + { + "id": "on_off_gradually", + "ver_code": 3 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "segment_effect", + "ver_code": 2 + }, + { + "id": "music_rhythm_bulb", + "ver_code": 4 + }, + { + "id": "bccp", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L430C(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "8C-86-DD-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "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": { + "auto_biorhythm_light_enable": false, + "avatar": "bulb", + "brightness": 50, + "color_temp": 2700, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 50, + "color_temp": 2700, + "hue": 50, + "saturation": 50 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.4 Build 241018 Rel.092847", + "has_set_location_info": true, + "hue": 50, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "8C-86-DD-00-00-00", + "model": "L430C", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Asia/Nicosia", + "rssi": -53, + "saturation": 50, + "segment_effect": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "" + }, + "signal_level": 2, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 120, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Asia/Nicosia", + "time_diff": 120, + "timestamp": 1768224264 + }, + "get_device_usage": { + "power_usage": { + "past30": 1719, + "past7": 494, + "today": 17 + }, + "saved_power": { + "past30": 10718141, + "past7": 0, + "today": 33862137 + }, + "time_usage": { + "past30": 1513958414, + "past7": 135451055, + "today": 1107603978 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": { + "inherit_status": false + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.4 Build 241018 Rel.092847", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": { + "desired_states": { + "auto": false, + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "on": true, + "saturation": 0, + "transition_period": 1800000 + }, + "e_time": 0, + "id": "S4", + "s_time": 1768228140, + "type": 1 + }, + "get_on_off_gradually_info": { + "off_state": { + "duration": 10, + "enable": false, + "max_duration": 60 + }, + "on_state": { + "duration": 5, + "enable": false, + "max_duration": 60 + } + }, + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 52, + "color_temp": 3000, + "hue": 20, + "saturation": 30 + }, + { + "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 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": true, + "rule_list": [ + { + "day": 13, + "desired_states": { + "auto": false, + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "on": true, + "saturation": 0, + "transition_period": 1800000 + }, + "e_action": "none", + "e_min": 0, + "e_type": "normal", + "enable": true, + "id": "S1", + "mode": "repeat", + "month": 1, + "s_min": 330, + "s_type": "normal", + "time_offset": 0, + "week_day": 127, + "year": 2026 + }, + { + "day": 12, + "desired_states": { + "on": false, + "transition_period": 1800000 + }, + "e_action": "none", + "e_min": 0, + "e_type": "normal", + "enable": true, + "id": "S2", + "mode": "repeat", + "month": 1, + "s_min": 1350, + "s_type": "normal", + "time_offset": 0, + "week_day": 127, + "year": 2026 + }, + { + "day": 13, + "desired_states": { + "on": false, + "transition_period": 0 + }, + "e_action": "none", + "e_min": 0, + "e_type": "normal", + "enable": true, + "id": "S3", + "mode": "repeat", + "month": 1, + "s_min": 420, + "s_type": "normal", + "time_offset": 0, + "week_day": 127, + "year": 2026 + }, + { + "day": 12, + "desired_states": { + "auto": false, + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "on": true, + "saturation": 0, + "transition_period": 1800000 + }, + "e_action": "none", + "e_min": 0, + "e_type": "normal", + "enable": true, + "id": "S4", + "mode": "repeat", + "month": 1, + "s_min": 989, + "s_type": "sunset", + "time_offset": -30, + "week_day": 127, + "year": 2026 + } + ], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 4 + }, + "get_segment_effect_rule": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "" + }, + "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 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "bccp", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "L430C", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} From a8e398403cfc8eac9f9869be1fc65b2bc524ae0b Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Sat, 21 Feb 2026 09:01:08 -0500 Subject: [PATCH 880/892] Add KL110B(UN) device fixture (#1657) --- README.md | 2 +- SUPPORTED.md | 2 + tests/device_fixtures.py | 2 +- tests/fixtures/iot/KL110B(UN)_1.0_1.8.11.json | 288 ++++++++++++++++++ 4 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/iot/KL110B(UN)_1.0_1.8.11.json diff --git a/README.md b/README.md index 1847e1392..585b8f47b 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: EP10, EP25[^2], HS100[^2], HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M[^1], KP401 - **Power Strips**: EP40, EP40M[^1], HS107, HS300, KP200, KP303, KP400 - **Wall Switches**: ES20M, HS200[^2], HS210, HS220[^2], KP405, KS200, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1] -- **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110 +- **Bulbs**: KL110, KL110B, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110 - **Light Strips**: KL400L10, KL400L5, KL420L5, KL430 - **Hubs**: KH100[^1] - **Hub-Connected Devices[^3]**: KE100[^1] diff --git a/SUPPORTED.md b/SUPPORTED.md index 0f5163ab6..b6911d887 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -132,6 +132,8 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - **KL110** - Hardware: 1.0 (US) / Firmware: 1.8.11 +- **KL110B** + - Hardware: 1.0 (UN) / Firmware: 1.8.11 - **KL120** - Hardware: 1.0 (US) / Firmware: 1.8.11 - Hardware: 1.0 (US) / Firmware: 1.8.6 diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 5d8271288..992cbfd97 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -49,7 +49,7 @@ "KL430", } BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} -BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"} +BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110", "KL110B"} BULBS_IOT = ( BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR) .union(BULBS_IOT_DIMMABLE) diff --git a/tests/fixtures/iot/KL110B(UN)_1.0_1.8.11.json b/tests/fixtures/iot/KL110B(UN)_1.0_1.8.11.json new file mode 100644 index 000000000..75427f79c --- /dev/null +++ b/tests/fixtures/iot/KL110B(UN)_1.0_1.8.11.json @@ -0,0 +1,288 @@ +{ + "smartlife.iot.common.cloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": 0, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 0 + } + }, + "smartlife.iot.common.schedule": { + "get_next_action": { + "action": 2, + "err_code": 0, + "id": "CAD48466AAF086367653A7AAB3A69ED4", + "light": { + "brightness": 15, + "color_temp": 0, + "hue": 0, + "on_off": 1, + "saturation": 0 + }, + "schd_time": 69000, + "type": 1 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [ + { + "eact": -1, + "emin": 0, + "enable": 1, + "eoffset": 0, + "etime_opt": -1, + "id": "CAD48466AAF086367653A7AAB3A69ED4", + "name": "Schedule Rule", + "repeat": 1, + "s_light": { + "brightness": 15, + "color_temp": 0, + "hue": 0, + "mode": "customize_preset", + "on_off": 1, + "saturation": 0, + "transition_period": 5000 + }, + "sact": 2, + "smin": 1150, + "soffset": -59, + "stime_opt": 2, + "wday": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "eact": -1, + "emin": 0, + "enable": 1, + "eoffset": 0, + "etime_opt": -1, + "id": "FBF05A0A587AC23BCADE2F5EEC72DE56", + "name": "Schedule Rule", + "repeat": 1, + "s_light": { + "brightness": 0, + "color_temp": 0, + "hue": 0, + "mode": "last_status", + "on_off": 0, + "saturation": 0, + "transition_period": 0 + }, + "sact": 2, + "smin": 0, + "soffset": 0, + "stime_opt": 0, + "wday": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "eact": -1, + "emin": 0, + "enable": 1, + "eoffset": 0, + "etime_opt": -1, + "id": "8E3EA003DF7BC480EA09C332CD2DA06A", + "name": "Schedule Rule", + "repeat": 1, + "s_light": { + "brightness": 0, + "color_temp": 0, + "hue": 0, + "mode": "last_status", + "on_off": 0, + "saturation": 0, + "transition_period": 30000 + }, + "sact": 2, + "smin": 415, + "soffset": 0, + "stime_opt": 1, + "wday": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "eact": -1, + "emin": 0, + "enable": 1, + "eoffset": 0, + "etime_opt": -1, + "id": "9FBD711FDCC696E33B9CF3E523559C64", + "name": "name", + "repeat": 1, + "s_light": { + "brightness": 1, + "color_temp": 0, + "hue": 0, + "mode": "customize_preset", + "on_off": 1, + "saturation": 0, + "transition_period": 300000 + }, + "sact": 2, + "smin": 1320, + "soffset": 0, + "stime_opt": 0, + "wday": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + ], + "version": 2 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_default_behavior": { + "err_code": 0, + "hard_on": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "index": 0, + "mode": "customize_preset", + "saturation": 0 + }, + "soft_on": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "index": 0, + "mode": "customize_preset", + "saturation": 0 + } + }, + "get_light_details": { + "color_rendering_index": 80, + "err_code": 0, + "incandescent_equivalent": 60, + "lamp_beam_angle": 270, + "max_lumens": 800, + "max_voltage": 120, + "min_voltage": 110, + "wattage": 10 + }, + "get_light_state": { + "dft_on_state": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "mode": "normal", + "saturation": 0 + }, + "err_code": 0, + "on_off": 0 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "schedule", + "alias": "#MASKED_NAME#", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Smart Wi-Fi LED Bulb with Dimmable Light", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "heapsize": 291800, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 0, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "light_state": { + "dft_on_state": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "mode": "normal", + "saturation": 0 + }, + "on_off": 0 + }, + "mic_mac": "D84732000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL110B(UN)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "index": 1, + "saturation": 0 + }, + { + "brightness": 15, + "color_temp": 2700, + "hue": 0, + "index": 2, + "saturation": 0 + }, + { + "brightness": 1, + "color_temp": 2700, + "hue": 0, + "index": 3, + "saturation": 0 + } + ], + "rssi": -65, + "sw_ver": "1.8.11 Build 191113 Rel.105336" + } + } +} From b22917a888269fc83974d5c41396330f7b339300 Mon Sep 17 00:00:00 2001 From: "Alexander D. Kanevskiy" Date: Sat, 21 Feb 2026 16:04:55 +0200 Subject: [PATCH 881/892] Allow SSL connections to older devices (#1654) With recent firmware updates for older devices (like C120) the embedded SSL certificate on the deivce is RSA 1024 type, which is considered insecure nowadays. 0 s:CN=TPRI-DEVICE, O=TPRI, C=US i:CN=TPRI-DEVICE, O=TPRI, C=US a:PKEY: RSA, 1024 (bit); sigalg: sha256WithRSAEncryption v:NotBefore: Jan 1 00:00:00 2001 GMT; NotAfter: Dec 31 23:59:59 2070 GMT This fix add cipher used by TP-Link to list of allowed ones. Related: https://github.com/home-assistant/core/issues/162498 Signed-off-by: Alexander D. Kanevskiy --- kasa/transports/sslaestransport.py | 1 + 1 file changed, 1 insertion(+) diff --git a/kasa/transports/sslaestransport.py b/kasa/transports/sslaestransport.py index eeb298099..6473aa1e3 100644 --- a/kasa/transports/sslaestransport.py +++ b/kasa/transports/sslaestransport.py @@ -73,6 +73,7 @@ class SslAesTransport(BaseTransport): } CIPHERS = ":".join( [ + "ECDHE-RSA-AES128-GCM-SHA256", "AES256-GCM-SHA384", "AES256-SHA256", "AES128-GCM-SHA256", From 55f9959777c5248a98bf671ea8470f379d58bef4 Mon Sep 17 00:00:00 2001 From: Wojciech Guziak Date: Sat, 21 Feb 2026 16:11:28 +0100 Subject: [PATCH 882/892] Add preset support for Tapo cameras (#1615) --- SUPPORTED.md | 1 + kasa/module.py | 1 + kasa/smartcam/modules/pantilt.py | 76 +- tests/fakeprotocol_smartcam.py | 2 + .../fixtures/smartcam/C210(EU)_1.0_1.4.7.json | 1060 +++++++++++++++++ tests/smartcam/modules/test_pantilt.py | 212 ++++ 6 files changed, 1348 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/smartcam/C210(EU)_1.0_1.4.7.json create mode 100644 tests/smartcam/modules/test_pantilt.py diff --git a/SUPPORTED.md b/SUPPORTED.md index b6911d887..66697138f 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -304,6 +304,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 2.0 (EU) / Firmware: 1.4.3 - **C210** - Hardware: 2.0 / Firmware: 1.3.11 + - Hardware: 1.0 (EU) / Firmware: 1.4.7 - Hardware: 2.0 (EU) / Firmware: 1.4.2 - Hardware: 2.0 (EU) / Firmware: 1.4.3 - **C220** diff --git a/kasa/module.py b/kasa/module.py index 57ee321f1..097bac617 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -170,6 +170,7 @@ class Module(ABC): # SMARTCAM only modules Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera") LensMask: Final[ModuleName[smartcam.LensMask]] = ModuleName("LensMask") + PanTilt: Final[ModuleName[smartcam.PanTilt]] = ModuleName("PanTilt") # Vacuum modules Clean: Final[ModuleName[smart.Clean]] = ModuleName("Clean") diff --git a/kasa/smartcam/modules/pantilt.py b/kasa/smartcam/modules/pantilt.py index fb647f6f1..52b2db0d7 100644 --- a/kasa/smartcam/modules/pantilt.py +++ b/kasa/smartcam/modules/pantilt.py @@ -1,4 +1,4 @@ -"""Implementation of time module.""" +"""Implementation of pan/tilt module.""" from __future__ import annotations @@ -10,9 +10,13 @@ class PanTilt(SmartCamModule): - """Implementation of device_local_time.""" + """Implementation of pan/tilt module for PTZ cameras.""" REQUIRED_COMPONENT = "ptz" + QUERY_GETTER_NAME = "getPresetConfig" + QUERY_MODULE_NAME = "preset" + QUERY_SECTION_NAMES = ["preset"] + _pan_step = DEFAULT_PAN_STEP _tilt_step = DEFAULT_TILT_STEP @@ -88,10 +92,52 @@ async def set_tilt_step(value: int) -> None: ) ) - def query(self) -> dict: - """Query to execute during the update cycle.""" + if self._presets: + self._add_feature( + Feature( + self._device, + "ptz_preset", + "PTZ Preset", + container=self, + attribute_getter="preset", + attribute_setter="set_preset", + choices_getter=lambda: list(self._presets.keys()), + type=Feature.Type.Choice, + ) + ) + + @property + def _presets(self) -> dict[str, str]: + """Return presets from device data.""" + if "preset" not in self.data: + return {} + preset_info = self.data["preset"] + return { + name: preset_id + for preset_id, name in zip( + preset_info.get("id", []), preset_info.get("name", []), strict=False + ) + } + + @property + def preset(self) -> str | None: + """Return first preset name as current value.""" + return next(iter(self._presets.keys()), None) + + async def set_preset(self, preset: str) -> dict: + """Set preset by name or ID.""" + preset_id = self._presets.get(preset) + if preset_id: + return await self.goto_preset(preset_id) + if preset in self._presets.values(): + return await self.goto_preset(preset) return {} + @property + def presets(self) -> dict[str, str]: + """Return available presets as dict of name -> id.""" + return self._presets + async def pan(self, pan: int) -> dict: """Pan horizontally.""" return await self.move(pan=pan, tilt=0) @@ -105,3 +151,25 @@ async def move(self, *, pan: int, tilt: int) -> dict: return await self._device._raw_query( {"do": {"motor": {"move": {"x_coord": str(pan), "y_coord": str(tilt)}}}} ) + + async def get_presets(self) -> dict: + """Get presets.""" + return await self._device._raw_query( + {"getPresetConfig": {"preset": {"name": ["preset"]}}} + ) + + async def goto_preset(self, preset_id: str) -> dict: + """Go to preset.""" + return await self._device._raw_query( + {"motorMoveToPreset": {"preset": {"goto_preset": {"id": preset_id}}}} + ) + + async def save_preset(self, name: str) -> dict: + """Save preset.""" + return await self._device._raw_query( + { + "addMotorPostion": { # Note: API has typo in method name + "preset": {"set_preset": {"name": name, "save_ptz": "1"}} + } + } + ) diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index d531e910b..5cd291b3e 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -314,6 +314,8 @@ async def _send_request(self, request_dict: dict): elif method in [ "addScanChildDeviceList", "startScanChildDevice", + "motorMoveToPreset", + "addMotorPostion", # Note: API has typo in method name ]: return {"result": {}, "error_code": 0} diff --git a/tests/fixtures/smartcam/C210(EU)_1.0_1.4.7.json b/tests/fixtures/smartcam/C210(EU)_1.0_1.4.7.json new file mode 100644 index 000000000..b9e6640b7 --- /dev/null +++ b/tests/fixtures/smartcam/C210(EU)_1.0_1.4.7.json @@ -0,0 +1,1060 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1763150321", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "normal" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.4.7 Build 250625 Rel.58841n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "9C-A2-F4-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "15", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Tone" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 2 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "panoramicView", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "staticIp", + "version": 2 + }, + { + "name": "timeFormat", + "version": 1 + }, + { + "name": "relayPreConnection", + "version": 1 + }, + { + "name": "hubManage", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-11-28 00:22:51", + "seconds_from_1970": 1764285771 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -49, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "50", + "enabled": "off", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "high", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "room", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C210 1.0 IPC", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "9C-A2-F4-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.4.7 Build 250625 Rel.58841n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1763150321", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "on", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "off" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "on", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "off" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "off", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [ + "1", + "2", + "3" + ], + "name": [ + "Default", + "Door", + "Mid" + ], + "position_pan": [ + "-0.278697", + "-0.277663", + "-0.319545" + ], + "position_tilt": [ + "1.000000", + "-0.040201", + "0.366834" + ], + "position_zoom": [], + "read_only": [ + "0", + "0", + "0" + ] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:1\"]", + "monday": "[\"0000-2400:1\"]", + "saturday": "[\"0000-2400:1\"]", + "sunday": "[\"0000-2400:1\"]", + "thursday": "[\"0000-2400:1\"]", + "tuesday": "[\"0000-2400:1\"]", + "wednesday": "[\"0000-2400:1\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "normal", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "1", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "100", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "1763278827", + "rw_attr": "rw", + "status": "normal", + "total_space": "29.3GB", + "total_space_accurate": "31443156992B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "28.3GB", + "video_total_space_accurate": "30333206528B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+01:00", + "timing_mode": "ntp", + "zone_id": "Europe/Amsterdam" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1382", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561", + "65566" + ], + "minor_stream_support": "0", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2304*1296", + "1920*1080", + "1280*720" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1382", + "bitrate_type": "vbr", + "default_bitrate": "1382", + "encode_type": "H264", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "1920*1080", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8", + "16" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "1.1" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "1.3" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "1.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "1" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } + } +} diff --git a/tests/smartcam/modules/test_pantilt.py b/tests/smartcam/modules/test_pantilt.py new file mode 100644 index 000000000..fb58dc66a --- /dev/null +++ b/tests/smartcam/modules/test_pantilt.py @@ -0,0 +1,212 @@ +"""Tests for PanTilt module.""" + +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, Module + +from ...device_fixtures import parametrize + +pantilt = parametrize( + "has pantilt", component_filter="ptz", protocol_filter={"SMARTCAM"} +) + + +@pantilt +async def test_pantilt_presets(dev: Device, mocker: MockerFixture): + """Test PanTilt module preset functionality.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + presets = pantilt_mod.presets + if not presets: + pytest.skip("Device has no presets configured") + + assert "ptz_preset" in dev.features + preset_feature = dev.features["ptz_preset"] + assert preset_feature is not None + + first_preset_name = next(iter(presets.keys())) + assert preset_feature.value == first_preset_name + + mock_protocol_query = mocker.patch.object(dev.protocol, "query") + mock_protocol_query.return_value = {} + + await preset_feature.set_value(first_preset_name) + + mock_protocol_query.assert_called_once() + call_args = mock_protocol_query.call_args + assert "motorMoveToPreset" in str(call_args) + + +@pantilt +async def test_pantilt_save_preset(dev: Device, mocker: MockerFixture): + """Test PanTilt save_preset functionality.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + mock_protocol_query = mocker.patch.object(dev.protocol, "query") + mock_protocol_query.return_value = {} + + await pantilt_mod.save_preset("NewPreset") + + mock_protocol_query.assert_called_with( + request={ + "addMotorPostion": { + "preset": {"set_preset": {"name": "NewPreset", "save_ptz": "1"}} + } + } + ) + + +@pantilt +async def test_pantilt_invalid_preset(dev: Device, mocker: MockerFixture): + """Test set_preset with invalid preset name raises ValueError.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + if not pantilt_mod.presets: + pytest.skip("Device has no presets configured") + + preset_feature = dev.features.get("ptz_preset") + if not preset_feature: + pytest.skip("Device has no preset feature") + + mocker.patch.object(dev.protocol, "query", return_value={}) + + with pytest.raises(ValueError, match="Unexpected value"): + await preset_feature.set_value("NonExistentPreset12345") + + +@pantilt +async def test_pantilt_move(dev: Device, mocker: MockerFixture): + """Test PanTilt move commands.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + mock_protocol_query = mocker.patch.object(dev.protocol, "query") + mock_protocol_query.return_value = {} + + await pantilt_mod.pan(30) + call_args = mock_protocol_query.call_args + assert "motor" in str(call_args) + assert "move" in str(call_args) + + mock_protocol_query.reset_mock() + + await pantilt_mod.tilt(10) + call_args = mock_protocol_query.call_args + assert "motor" in str(call_args) + assert "move" in str(call_args) + + +@pantilt +async def test_pantilt_goto_preset(dev: Device, mocker: MockerFixture): + """Test PanTilt goto_preset command.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + mock_protocol_query = mocker.patch.object(dev.protocol, "query") + mock_protocol_query.return_value = {} + + await pantilt_mod.goto_preset("1") + + mock_protocol_query.assert_called_with( + request={"motorMoveToPreset": {"preset": {"goto_preset": {"id": "1"}}}} + ) + + +@pantilt +async def test_pantilt_get_presets(dev: Device, mocker: MockerFixture): + """Test PanTilt get_presets command.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + mock_protocol_query = mocker.patch.object(dev.protocol, "query") + mock_protocol_query.return_value = {} + + await pantilt_mod.get_presets() + + mock_protocol_query.assert_called_with( + request={"getPresetConfig": {"preset": {"name": ["preset"]}}} + ) + + +@pantilt +async def test_pantilt_set_preset_by_id(dev: Device, mocker: MockerFixture): + """Test set_preset with preset ID instead of name.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + if not pantilt_mod.presets: + pytest.skip("Device has no presets configured") + + mock_protocol_query = mocker.patch.object(dev.protocol, "query") + mock_protocol_query.return_value = {} + + # Get the first preset ID + first_preset_id = next(iter(pantilt_mod.presets.values())) + + # Call set_preset with ID instead of name + await pantilt_mod.set_preset(first_preset_id) + + mock_protocol_query.assert_called_with( + request={ + "motorMoveToPreset": {"preset": {"goto_preset": {"id": first_preset_id}}} + } + ) + + +@pantilt +async def test_pantilt_set_preset_not_found(dev: Device, mocker: MockerFixture): + """Test set_preset with non-existent preset returns empty dict.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + mock_protocol_query = mocker.patch.object(dev.protocol, "query") + mock_protocol_query.return_value = {} + + # Call set_preset with a non-existent preset + result = await pantilt_mod.set_preset("NonExistentPreset99999") + + # Should return empty dict and not call API + assert result == {} + mock_protocol_query.assert_not_called() + + +@pantilt +async def test_pantilt_step_features(dev: Device, mocker: MockerFixture): + """Test pan/tilt step features.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + # Test pan_step feature + pan_step_feature = dev.features.get("pan_step") + assert pan_step_feature is not None + assert pan_step_feature.value == 30 # DEFAULT_PAN_STEP + + await pan_step_feature.set_value(45) + assert pantilt_mod._pan_step == 45 + + # Test tilt_step feature + tilt_step_feature = dev.features.get("tilt_step") + assert tilt_step_feature is not None + assert tilt_step_feature.value == 10 # DEFAULT_TILT_STEP + + await tilt_step_feature.set_value(20) + assert pantilt_mod._tilt_step == 20 + + +@pantilt +async def test_pantilt_no_presets_in_data(dev: Device, mocker: MockerFixture): + """Test _presets returns empty dict when no preset data.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + # Mock data property to return empty dict (no preset key) + mocker.patch.object(type(pantilt_mod), "data", property(lambda self: {})) + + assert pantilt_mod._presets == {} + assert pantilt_mod.presets == {} From ade64c64af54485fa2dc936a5ea8e096f08b4d7a Mon Sep 17 00:00:00 2001 From: Mark Van Praet <57235007+markvanpraet@users.noreply.github.com> Date: Sat, 21 Feb 2026 10:20:01 -0500 Subject: [PATCH 883/892] Add Tapo C460 support (#1645) Changes include: - New smartcam fixture for C460 - Battery module updates to safely handle optional temperature and voltage fields --- README.md | 2 +- SUPPORTED.md | 2 + kasa/smartcam/modules/battery.py | 88 +- .../fixtures/smartcam/C460(CA)_1.0_1.2.0.json | 1056 +++++++++++++++++ tests/smartcam/modules/test_battery.py | 73 +- 5 files changed, 1180 insertions(+), 41 deletions(-) create mode 100644 tests/fixtures/smartcam/C460(CA)_1.0_1.2.0.json diff --git a/README.md b/README.md index 585b8f47b..e1d5c198c 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: S210, S220, S500, S500D, S505, S505D, TS15 - **Bulbs**: L430C, L430P, L510B, L510E, L530B, L530E, L535E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Cameras**: C100, C110, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70 +- **Cameras**: C100, C110, C210, C220, C225, C325WB, C460, C520WS, C720, TC65, TC70 - **Doorbells and chimes**: D100C, D130, D230 - **Vacuums**: RV20 Max Plus, RV30 Max - **Hubs**: H100, H200 diff --git a/SUPPORTED.md b/SUPPORTED.md index 66697138f..b464aad1d 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -314,6 +314,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 2.0 (US) / Firmware: 1.0.11 - **C325WB** - Hardware: 1.0 (EU) / Firmware: 1.1.17 +- **C460** + - Hardware: 1.0 (CA) / Firmware: 1.2.0 - **C520WS** - Hardware: 1.0 (US) / Firmware: 1.2.8 - **C720** diff --git a/kasa/smartcam/modules/battery.py b/kasa/smartcam/modules/battery.py index d6bd97f3f..b1e25f256 100644 --- a/kasa/smartcam/modules/battery.py +++ b/kasa/smartcam/modules/battery.py @@ -1,8 +1,9 @@ -"""Implementation of baby cry detection module.""" +"""Implementation of smartcam battery module.""" from __future__ import annotations import logging +from typing import Any from ...feature import Feature from ..smartcammodule import SmartCamModule @@ -44,32 +45,37 @@ def _initialize_features(self) -> None: ) ) - self._add_feature( - Feature( - self._device, - "battery_temperature", - "Battery temperature", - container=self, - attribute_getter="battery_temperature", - icon="mdi:battery", - unit_getter=lambda: "celsius", - category=Feature.Category.Debug, - type=Feature.Type.Sensor, + # Optional on some battery cameras (e.g., C460). + if self._optional_float_sysinfo("battery_temperature") is not None: + self._add_feature( + Feature( + self._device, + "battery_temperature", + "Battery temperature", + container=self, + attribute_getter="battery_temperature", + icon="mdi:battery", + unit_getter=lambda: "celsius", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) ) - ) - self._add_feature( - Feature( - self._device, - "battery_voltage", - "Battery voltage", - container=self, - attribute_getter="battery_voltage", - icon="mdi:battery", - unit_getter=lambda: "V", - category=Feature.Category.Debug, - type=Feature.Type.Sensor, + + if self._optional_float_sysinfo("battery_voltage") is not None: + self._add_feature( + Feature( + self._device, + "battery_voltage", + "Battery voltage", + container=self, + attribute_getter="battery_voltage", + icon="mdi:battery", + unit_getter=lambda: "V", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) ) - ) + self._add_feature( Feature( self._device, @@ -83,6 +89,18 @@ def _initialize_features(self) -> None: ) ) + def _optional_float_sysinfo(self, key: str) -> float | None: + """Return sys_info[key] as float, or None if not available or invalid.""" + v_any: Any = self._device.sys_info.get(key) + if v_any in (None, "NO"): + return None + + try: + # Accept ints/floats and numeric strings. + return float(v_any) + except (TypeError, ValueError): + return None + def query(self) -> dict: """Query to execute during the update cycle.""" return {} @@ -98,16 +116,22 @@ def battery_low(self) -> bool: return self._device.sys_info["low_battery"] @property - def battery_temperature(self) -> bool: - """Return battery voltage in C.""" - return self._device.sys_info["battery_temperature"] + def battery_temperature(self) -> float | None: + """Return battery temperature in °C (if available).""" + return self._optional_float_sysinfo("battery_temperature") @property - def battery_voltage(self) -> bool: - """Return battery voltage in V.""" - return self._device.sys_info["battery_voltage"] / 1_000 + def battery_voltage(self) -> float | None: + """Return battery voltage in V (if available).""" + v = self._optional_float_sysinfo("battery_voltage") + return None if v is None else v / 1_000 @property def battery_charging(self) -> bool: """Return True if battery is charging.""" - return self._device.sys_info["battery_voltage"] != "NO" + v = self._device.sys_info.get("battery_charging") + if isinstance(v, bool): + return v + if v is None: + return False + return str(v).strip().lower() in ("yes", "true", "1", "charging", "on") diff --git a/tests/fixtures/smartcam/C460(CA)_1.0_1.2.0.json b/tests/fixtures/smartcam/C460(CA)_1.0_1.2.0.json new file mode 100644 index 000000000..be5456b72 --- /dev/null +++ b/tests/fixtures/smartcam/C460(CA)_1.0_1.2.0.json @@ -0,0 +1,1056 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1734967724", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C460", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.2.0 Build 250910 Rel.70120n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_func": [ + "sound", + "light" + ], + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "5", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 4 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "imageStyle", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 3 + }, + { + "name": "dayNightMode", + "version": 2 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "nightVisionMode", + "version": 3 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "infLamp", + "version": 1 + }, + { + "name": "whiteLamp", + "version": 1 + }, + { + "name": "pir", + "version": 3 + }, + { + "name": "battery", + "version": 3 + }, + { + "name": "clips", + "version": 2 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "streamGrab", + "version": 1 + }, + { + "name": "antiTheft", + "version": 3 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "ldc", + "version": 1 + }, + { + "name": "noHubBatteryCam", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "smartAutoExposure", + "version": 1 + }, + { + "name": "snapshot", + "version": 2 + }, + { + "name": "timeFormat", + "version": 1 + }, + { + "name": "aov", + "version": 1 + }, + { + "name": "hubManage", + "version": 1 + }, + { + "name": "aovSupportHub", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "80" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2026-01-12 15:01:13", + "seconds_from_1970": 1768248073 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -26, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "det_sensitivity": "70", + "digital_sensitivity": "60", + "enabled": "off", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "Swimming pool", + "barcode": "", + "battery_charging": "NO", + "battery_overheated": false, + "battery_percent": 100, + "channel_plan_code": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C460 1.0 IPC", + "device_model": "C460", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "low_battery": false, + "mac": "3C-78-95-00-00-00", + "manufacturer_name": "TP-Link", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "power": "BATTERY", + "power_save_mode": "off", + "power_save_status": "off", + "region": "CA", + "sw_version": "1.2.0 Build 250910 Rel.70120n", + "tss": false + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "0", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "1", + "inf_end_time": "21600", + "inf_sensitivity": "-1", + "inf_sensitivity_boot": "180", + "inf_sensitivity_day2night": "131", + "inf_sensitivity_night2day": "1674", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "original", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_boot": "0", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "4" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "1", + "inf_end_time": "21600", + "inf_sensitivity": "-1", + "inf_sensitivity_boot": "180", + "inf_sensitivity_day2night": "131", + "inf_sensitivity_night2day": "1674", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "original", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_boot": "0", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "off" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision", + "wtl_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp", + "white_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "4" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "4" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "normal", + "disk_name": "1", + "free_space": "56.7GB", + "free_space_accurate": "60924595724B", + "hardware_security_config": "0", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "password": "", + "percent": "100", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "1768153168", + "rw_attr": "rw", + "security_status": "2", + "status": "normal", + "total_space": "59.2GB", + "total_space_accurate": "63580504064B", + "type": "local", + "video_free_space": "56.7GB", + "video_free_space_accurate": "60924595724B", + "video_total_space": "56.8GB", + "video_total_space_accurate": "60934848512B", + "write_protect": "0" + } + } + ] + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-05:00", + "timing_mode": "ntp", + "zone_id": "America/New_York" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "vbr" + ], + "bitrates": [ + "1024", + "1800", + "2048", + "2400", + "4096", + "6144" + ], + "codec_switch_support": "0", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "3840*2160", + "1920*1080" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "2400", + "bitrate_type": "vbr", + "codec_switch_trigger": "none", + "default_bitrate": [ + 4096, + 2400 + ], + "encode_type": "H265", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "5", + "resolution": "3840*2160", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "4" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "80" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "image_style_capability": "1", + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "ptz": "0", + "record_max_slot_cnt": "6", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "0", + "smart_codec": "0", + "smart_detection": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "0", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 3, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 3, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 3, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "public_key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDAXfqAdngV6AVbE2CMG8f2I9OM\n/Xh/yWq4usOIqEhGW36Zq+mA2jVlH86hLqPwMeRXJO1teHYd53TVUAgk0US43GkS\n8uSFe9K5PXWt5TeDvLmBw3J85dj/sIDVxNvLrmwUD+Djqo2DLdW8HYvN83HN8Sf+\nLVWsnyRlVXjRjT5zDQIDAQAB\n-----END PUBLIC KEY-----\n", + "wpa3_supported": "true" + } + } + } +} diff --git a/tests/smartcam/modules/test_battery.py b/tests/smartcam/modules/test_battery.py index 12cab14bd..723cb22b3 100644 --- a/tests/smartcam/modules/test_battery.py +++ b/tests/smartcam/modules/test_battery.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from kasa import Device from kasa.smartcam.smartcammodule import SmartCamModule @@ -20,14 +22,69 @@ async def test_battery(dev: Device): battery = dev.modules.get(SmartCamModule.SmartCamBattery) assert battery - feat_ids = { - "battery_level", - "battery_low", - "battery_temperature", - "battery_voltage", - "battery_charging", - } - for feat_id in feat_ids: + required = {"battery_level", "battery_low", "battery_charging"} + optional = {"battery_temperature", "battery_voltage"} + + for feat_id in required: feat = dev.features.get(feat_id) assert feat assert feat.value is not None + + for feat_id in optional: + feat = dev.features.get(feat_id) + if feat is not None: + assert feat.value is not None + + +@battery_smartcam +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (None, None), # covers: v in (None, "NO") -> return None + ("NO", None), # covers: v in (None, "NO") -> return None + ("nonsense", None), # covers: ValueError -> except -> return None + ("12.3", 12.3), # sanity: happy path + ], +) +async def test_battery_temperature_edge_cases(dev: Device, raw, expected): + battery = dev.modules.get(SmartCamModule.SmartCamBattery) + assert battery + + dev.sys_info["battery_temperature"] = raw + assert battery.battery_temperature == expected + + +@battery_smartcam +@pytest.mark.parametrize( + ("voltage_raw", "expected_v"), + [ + (None, None), # covers: battery_voltage -> return None + ("NO", None), # covers: battery_voltage -> return None + ("12000", 12.0), # sanity: parses string -> float(...) / 1000 + ], +) +async def test_battery_voltage_edge_cases(dev: Device, voltage_raw, expected_v): + battery = dev.modules.get(SmartCamModule.SmartCamBattery) + assert battery + + dev.sys_info["battery_voltage"] = voltage_raw + assert battery.battery_voltage == expected_v + + +@battery_smartcam +@pytest.mark.parametrize( + ("charging_raw", "expected"), + [ + (True, True), # covers: isinstance(v, bool) -> return v + (False, False), # covers: isinstance(v, bool) -> return v + (None, False), # covers: v is None -> return False + ("yes", True), # sanity: string normalization path + ("NO", False), # sanity: string normalization path + ], +) +async def test_battery_charging_edge_cases(dev: Device, charging_raw, expected): + battery = dev.modules.get(SmartCamModule.SmartCamBattery) + assert battery + + dev.sys_info["battery_charging"] = charging_raw + assert battery.battery_charging is expected From 494db73fa8239b5ed72fe7c69e30281eefe5aae4 Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:09:57 -0500 Subject: [PATCH 884/892] Update GitHub Workflows and Actions (#1622) This PR is to update the GitHub Workflows and Actions to resolve residual warnings and errors and using the latest versions available. --- .github/actions/setup/action.yaml | 27 +++++++------- .github/workflows/ci.yml | 54 ++++++++++++++++----------- .github/workflows/codeql-analysis.yml | 16 +++++--- .github/workflows/publish.yml | 35 ++++++++++------- .github/workflows/stale.yml | 13 ++++--- 5 files changed, 84 insertions(+), 61 deletions(-) diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml index 490adaef0..b075d4514 100644 --- a/.github/actions/setup/action.yaml +++ b/.github/actions/setup/action.yaml @@ -1,49 +1,50 @@ --- name: Setup Environment description: Install uv, configure the system python, and the package dependencies - inputs: uv-install-options: default: "" uv-version: - default: 0.4.16 + default: 0.9.16 python-version: required: true cache-pre-commit: default: false cache-version: default: "v0.1" - runs: using: composite steps: - - name: Install uv - uses: astral-sh/setup-uv@v3 + - name: Setup uv + id: setup-uv + uses: astral-sh/setup-uv@v7 with: enable-cache: true - - name: "Setup python" - uses: "actions/setup-python@v5" + - name: Setup Python id: setup-python + uses: actions/setup-python@v6 with: - python-version: "${{ inputs.python-version }}" + python-version: ${{ inputs.python-version }} allow-prereleases: true - - name: "Install project" + - name: Install Project Dependencies + id: install-project-dependencies shell: bash run: | uv sync ${{ inputs.uv-install-options }} - - name: Read pre-commit version - if: inputs.cache-pre-commit == 'true' + - name: Read pre-commit Version id: pre-commit-version + if: inputs.cache-pre-commit == 'true' shell: bash run: >- echo "pre-commit-version=$(uv run pre-commit -V | awk '{print $2}')" >> $GITHUB_OUTPUT - - uses: actions/cache@v4 + - name: pre-commit Cache + id: pre-commit-cache if: inputs.cache-pre-commit == 'true' - name: Pre-commit cache + uses: actions/cache@v4 with: path: ~/.cache/pre-commit/ key: cache-${{ inputs.cache-version }}-${{ runner.os }}-${{ runner.arch }}-pre-commit-${{ steps.pre-commit-version.outputs.pre-commit-version }}-python-${{ inputs.python-version }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abe016518..6fea377e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,4 @@ +--- name: CI on: @@ -12,48 +13,49 @@ on: - 'feat/**' - 'fix/**' - 'janitor/**' - workflow_dispatch: # to allow manual re-runs + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: - UV_VERSION: 0.4.16 + UV_VERSION: 0.9.16 jobs: - linting: - name: "Perform linting checks" + lint: + name: Perform Lint Checks runs-on: ubuntu-latest - strategy: matrix: - python-version: ["3.13"] - + python-version: [3.13] steps: - - name: "Checkout source files" - uses: "actions/checkout@v4" - - name: Setup environment + - name: Checkout Source Files + id: checkout + uses: actions/checkout@v6 + + - name: Setup Environment + id: setup-environment uses: ./.github/actions/setup with: python-version: ${{ matrix.python-version }} cache-pre-commit: true uv-version: ${{ env.UV_VERSION }} - uv-install-options: "--all-extras" + uv-install-options: --all-extras - - name: "Run pre-commit checks" + - name: Run pre-commit Checks + id: run-pre-commit + shell: bash run: | uv run pre-commit run --all-files --verbose - tests: name: Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} - needs: linting + needs: lint runs-on: ${{ matrix.os }} - strategy: matrix: - python-version: ["3.11", "3.12", "3.13"] + python-version: [3.11, 3.12, 3.13] os: [ubuntu-latest, macos-latest, windows-latest] extras: [false, true] exclude: @@ -61,19 +63,27 @@ jobs: extras: true - os: windows-latest extras: true - steps: - - uses: "actions/checkout@v4" - - name: Setup environment + - name: Checkout Source Files + id: checkout + uses: actions/checkout@v6 + + - name: Setup Environment + id: setup-environment uses: ./.github/actions/setup with: python-version: ${{ matrix.python-version }} uv-version: ${{ env.UV_VERSION }} uv-install-options: ${{ matrix.extras == true && '--all-extras' || '' }} - - name: "Run tests (with coverage)" + + - name: Run PyTests with Code Coverage + id: run-pytests-with-code-coverage + shell: bash run: | uv run pytest -n auto --cov kasa --cov-report xml - - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v4" + + - name: Upload Code Coverage to Codecov + id: upload-code-coverage-to-codecov + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 016ff0c30..b9e1479cf 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,4 +1,5 @@ -name: "CodeQL checks" +--- +name: CodeQL Checks on: push: @@ -31,16 +32,19 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'python' ] + language: [python] steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout Source Files + id: checkout + uses: actions/checkout@v6 - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + id: init-codeql + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + id: perform-codeql-analysis + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9c1837f0c..92a9b319d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,33 +1,40 @@ -name: Publish packages +--- +name: Publish Packages + on: release: types: [published] env: - UV_VERSION: 0.4.16 - PYTHON_VERSION: 3.12 + UV_VERSION: 0.9.16 + PYTHON_VERSION: 3.13 jobs: build-n-publish: - name: Build release packages + name: Build Release Packages runs-on: ubuntu-latest - permissions: # for trusted publishing + permissions: id-token: write - steps: - - name: Checkout source files - uses: actions/checkout@v4 + - name: Checkout Source Files + id: checkout + uses: actions/checkout@v6 - - name: Install uv - uses: astral-sh/setup-uv@v3 + - name: Setup uv + id: setup-uv + uses: astral-sh/setup-uv@v7 - - name: Setup python - uses: actions/setup-python@v4 + - name: Setup Python + id: setup-python + uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} - - name: Build a binary wheel and a source tarball + - name: Build Packages + id: build-packages + shell: bash run: uv build - - name: Publish release on pypi + - name: Publish Release on PyPI + id: publish-release-on-pypi uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 294b4e1f8..cafa2ce92 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,6 +1,6 @@ +--- name: Stale -# yamllint disable-line rule:truthy on: schedule: - cron: "0 0 * * *" @@ -11,8 +11,9 @@ jobs: if: github.repository_owner == 'python-kasa' runs-on: ubuntu-latest steps: - - name: Stale issues and prs - uses: actions/stale@v9.0.0 + - name: Stale Issues and PRs Policy + id: stale-issues-and-prs-policy + uses: actions/stale@v10 with: repo-token: ${{ github.token }} days-before-stale: 90 @@ -43,9 +44,9 @@ jobs: Thank you for your contributions. - - - name: Needs-more-information and waiting-for-reporter stale issues policy - uses: actions/stale@v9.0.0 + - name: needs-more-information and waiting-for-reporter Stale Issues Policy + id: specific-stale-issues-policy + uses: actions/stale@v10 with: repo-token: ${{ github.token }} only-labels: "needs-more-information,waiting-for-reporter" From 30a8fd45a878998a382e646fa35312b182d162d8 Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:03:52 -0500 Subject: [PATCH 885/892] New Wi-Fi handling for SMARTCAM devices (#1639) Updated scanning and joining Wi-Fi for SMARTCAM devices that may use a newer connection process. --- kasa/cli/wifi.py | 18 +++- kasa/device.py | 11 ++- kasa/iot/iotdevice.py | 3 + kasa/protocols/smartprotocol.py | 1 + kasa/smart/smartdevice.py | 3 + kasa/smartcam/smartcamdevice.py | 104 ++++++++++++++++++++- tests/fakeprotocol_smartcam.py | 46 +++++++++ tests/smartcam/test_smartcamdevice.py | 130 +++++++++++++++++++++++++- tests/test_cli.py | 42 ++++++++- 9 files changed, 345 insertions(+), 13 deletions(-) diff --git a/kasa/cli/wifi.py b/kasa/cli/wifi.py index 924e83f1f..0fc7bdd62 100644 --- a/kasa/cli/wifi.py +++ b/kasa/cli/wifi.py @@ -6,6 +6,7 @@ from kasa import ( Device, + KasaException, ) from .common import ( @@ -15,8 +16,7 @@ @click.group() -@pass_dev -def wifi(dev) -> None: +def wifi() -> None: """Commands to control wifi settings.""" @@ -35,13 +35,23 @@ async def scan(dev): @wifi.command() @click.argument("ssid") -@click.option("--keytype", prompt=True) +@click.option( + "--keytype", + default="", + help="KeyType (Not needed for SmartCamDevice).", +) @click.option("--password", prompt=True, hide_input=True) @pass_dev async def join(dev: Device, ssid: str, password: str, keytype: str): """Join the given wifi network.""" echo(f"Asking the device to connect to {ssid}..") - res = await dev.wifi_join(ssid, password, keytype=keytype) + try: + res = await dev.wifi_join(ssid, password, keytype=keytype) + except KasaException as e: + if type(e) is KasaException: + echo(str(e)) + return + raise echo( f"Response: {res} - if the device is not able to join the network, " f"it will revert back to its previous state." diff --git a/kasa/device.py b/kasa/device.py index 45763db3e..efd74c135 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -138,15 +138,18 @@ class WifiNetwork: """Wifi network container.""" ssid: str - key_type: int + # This is available on both netif and on softaponboarding + key_type: int | None = None # These are available only on softaponboarding cipher_type: int | None = None - bssid: str | None = None channel: int | None = None + # These are available on softaponboarding, SMART, and SMARTCAM devices + bssid: str | None = None rssi: int | None = None - - # For SMART devices + # These are available on both SMART and SMARTCAM devices signal_level: int | None = None + auth: int | None = None + encryption: int | None = None _LOGGER = logging.getLogger(__name__) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 36aba3e56..90bac7054 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -688,6 +688,9 @@ async def wifi_join(self, ssid: str, password: str, keytype: str = "3") -> dict: async def _join(target: str, payload: dict) -> dict: return await self._query_helper(target, "set_stainfo", payload) + if not keytype: + raise KasaException("KeyType is required for this device.") + payload = {"ssid": ssid, "password": password, "key_type": int(keytype)} try: return await _join("netif", payload) diff --git a/kasa/protocols/smartprotocol.py b/kasa/protocols/smartprotocol.py index 5539de778..ad3e7331e 100644 --- a/kasa/protocols/smartprotocol.py +++ b/kasa/protocols/smartprotocol.py @@ -92,6 +92,7 @@ def mask_area(area: dict[str, Any]) -> dict[str, Any]: # Queries that are known not to work properly when sent as a # multiRequest. They will not return the `method` key. FORCE_SINGLE_REQUEST = { + "connectAp", "getConnectStatus", "scanApList", } diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 87aa628d8..6be2392ce 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -769,6 +769,9 @@ async def wifi_join( if not self.credentials: raise AuthenticationError("Device requires authentication.") + if not keytype: + raise KasaException("KeyType is required for this device.") + payload = { "account": { "username": base64.b64encode( diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index 3beda36bc..7bc6184f3 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -2,12 +2,20 @@ from __future__ import annotations +import base64 import logging from typing import Any, cast -from ..device import DeviceInfo +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey + +from ..device import DeviceInfo, WifiNetwork from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..exceptions import AuthenticationError, DeviceError, KasaException from ..module import Module +from ..protocols import SmartProtocol from ..protocols.smartcamprotocol import _ChildCameraProtocolWrapper from ..smart import SmartChildDevice, SmartDevice from ..smart.smartdevice import ComponentsRaw @@ -23,6 +31,24 @@ class SmartCamDevice(SmartDevice): # Modules that are called as part of the init procedure on first update FIRST_UPDATE_MODULES = {DeviceModule, ChildDevice} + STATIC_PUBLIC_KEY_B64 = ( + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC4D6i0oD/Ga5qb//RfSe8MrPVI" + "rMIGecCxkcGWGj9kxxk74qQNq8XUuXoy2PczQ30BpiRHrlkbtBEPeWLpq85tfubT" + "UjhBz1NPNvWrC88uaYVGvzNpgzZOqDC35961uPTuvdUa8vztcUQjEZy16WbmetRj" + "URFIiWJgFCmemyYVbQIDAQAB" + ) + + def __init__( + self, + host: str, + *, + config: DeviceConfig | None = None, + protocol: SmartProtocol | None = None, + ) -> None: + super().__init__(host, config=config, protocol=protocol) + self._public_key: str | None = None + self._networks: list[WifiNetwork] = [] + @staticmethod def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType: """Find type to be displayed as a supported device category.""" @@ -288,3 +314,79 @@ def hw_info(self) -> dict: def rssi(self) -> int | None: """Return the device id.""" return self.modules[SmartCamModule.SmartCamDeviceModule].rssi + + async def wifi_scan(self) -> list[WifiNetwork]: + """Scan for available wifi networks.""" + + def _net_for_scan_info(res: dict) -> WifiNetwork: + return WifiNetwork( + ssid=res["ssid"], + auth=res["auth"], + encryption=res["encryption"], + rssi=res["rssi"], + bssid=res["bssid"], + ) + + _LOGGER.debug("Querying networks") + + resp = await self._query_helper("scanApList", {"onboarding": {"scan": {}}}) + scan_data: dict = resp["scanApList"]["onboarding"]["scan"] + self._public_key = scan_data.get("publicKey", "") + self._networks = [_net_for_scan_info(net) for net in scan_data["ap_list"]] + return self._networks + + async def wifi_join( + self, ssid: str, password: str, keytype: str = "wpa2_psk" + ) -> dict: + """Join the given wifi network. + + This method returns nothing as the device tries to activate the new + settings immediately instead of responding to the request. + + If joining the network fails, the device will return to the previous state + after some delay. + """ + if not self.credentials: + raise AuthenticationError("Device requires authentication.") + + if not self._networks: + await self.wifi_scan() + net = next( + (n for n in self._networks if getattr(n, "ssid", None) == ssid), None + ) + if net is None: + raise DeviceError(f"Network with SSID '{ssid}' not found.") + + public_key_b64 = self._public_key or self.STATIC_PUBLIC_KEY_B64 + key_bytes = base64.b64decode(public_key_b64) + public_key = serialization.load_der_public_key(key_bytes) + if not isinstance(public_key, RSAPublicKey): + raise TypeError("Loaded public key is not an RSA public key") + encrypted = public_key.encrypt(password.encode(), padding.PKCS1v15()) + encrypted_password = base64.b64encode(encrypted).decode() + + payload = { + "onboarding": { + "connect": { + "auth": net.auth, + "bssid": net.bssid, + "encryption": net.encryption, + "password": encrypted_password, + "rssi": net.rssi, + "ssid": net.ssid, + } + } + } + + # The device does not respond to the request but changes the settings + # immediately which causes us to timeout. + # Thus, We limit retries and suppress the raised exception as useless. + try: + return await self.protocol.query({"connectAp": payload}, retry_count=0) + except DeviceError: + raise # Re-raise on device-reported errors + except KasaException: + _LOGGER.debug( + "Received a kasa exception for wifi join, but this is expected" + ) + return {} diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index 5cd291b3e..171602330 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -256,6 +256,52 @@ async def _send_request(self, request_dict: dict): method = request_dict["method"] info = self.info + if method == "connectAp": + if self.verbatim: + return {"error_code": -1} + return {"result": {}, "error_code": 0} + if method == "scanApList": + if method in info: + result = self._get_method_from_info(method, request_dict.get("params")) + if not self.verbatim: + scan = ( + result.get("result", {}).get("onboarding", {}).get("scan", {}) + ) + ap_list = scan.get("ap_list") + if isinstance(ap_list, list) and not any( + ap.get("ssid") == "FOOBAR" for ap in ap_list + ): + ap_list.append( + { + "ssid": "FOOBAR", + "auth": 3, + "encryption": 3, + "rssi": -40, + "bssid": "00:00:00:00:00:00", + } + ) + return result + if self.verbatim: + return {"error_code": -1} + return { + "result": { + "onboarding": { + "scan": { + "publicKey": "", + "ap_list": [ + { + "ssid": "FOOBAR", + "auth": 3, + "encryption": 3, + "rssi": -40, + "bssid": "00:00:00:00:00:00", + } + ], + } + } + }, + "error_code": 0, + } if method == "controlChild": return await self._handle_control_child( request_dict["params"]["childControl"] diff --git a/tests/smartcam/test_smartcamdevice.py b/tests/smartcam/test_smartcamdevice.py index 8675b6934..58ab2fd98 100644 --- a/tests/smartcam/test_smartcamdevice.py +++ b/tests/smartcam/test_smartcamdevice.py @@ -2,12 +2,16 @@ from __future__ import annotations +import base64 from datetime import UTC, datetime +from unittest.mock import AsyncMock, PropertyMock, patch import pytest from freezegun.api import FrozenDateTimeFactory from kasa import Device, DeviceType, Module +from kasa.exceptions import AuthenticationError, DeviceError, KasaException +from kasa.smartcam import SmartCamDevice from ..conftest import device_smartcam, hub_smartcam @@ -34,7 +38,7 @@ async def test_state(dev: Device): @device_smartcam -async def test_alias(dev): +async def test_alias(dev: Device): test_alias = "TEST1234" original = dev.alias @@ -49,7 +53,7 @@ async def test_alias(dev): @hub_smartcam -async def test_hub(dev): +async def test_hub(dev: Device): assert dev.children for child in dev.children: assert child.modules @@ -60,6 +64,95 @@ async def test_hub(dev): assert child.device_id +@device_smartcam +async def test_wifi_scan(dev: SmartCamDevice): + fake_scan_data = { + "scanApList": { + "onboarding": { + "scan": { + "publicKey": base64.b64encode(b"fakekey").decode(), + "ap_list": [ + { + "ssid": "TestSSID", + "auth": "WPA2", + "encryption": "AES", + "rssi": -40, + "bssid": "00:11:22:33:44:55", + } + ], + } + } + } + } + with patch.object(dev, "_query_helper", AsyncMock(return_value=fake_scan_data)): + networks = await dev.wifi_scan() + assert len(networks) == 1 + net = networks[0] + assert net.ssid == "TestSSID" + assert net.auth == "WPA2" + assert net.encryption == "AES" + assert net.rssi == -40 + assert net.bssid == "00:11:22:33:44:55" + assert dev._public_key == base64.b64encode(b"fakekey").decode() + + +@device_smartcam +async def test_wifi_join_success_and_errors(dev: SmartCamDevice): + dev._networks = [ + type( + "WifiNetwork", + (), + { + "ssid": "TestSSID", + "auth": "WPA2", + "encryption": "AES", + "rssi": -40, + "bssid": "00:11:22:33:44:55", + }, + )() + ] + with patch.object(type(dev), "credentials", new_callable=PropertyMock) as cred_mock: + cred_mock.return_value = object() + with patch.object(dev.protocol, "query", AsyncMock(return_value={})): + result = await dev.wifi_join("TestSSID", "password123") + assert isinstance(result, dict) + cred_mock.return_value = None + with pytest.raises(AuthenticationError): + await dev.wifi_join("TestSSID", "password123") + cred_mock.return_value = object() + dev._networks = [] + with ( + patch.object(dev, "wifi_scan", AsyncMock(return_value=[])), + pytest.raises(DeviceError), + ): + await dev.wifi_join("TestSSID", "password123") + dev._networks = [ + type( + "WifiNetwork", + (), + { + "ssid": "TestSSID", + "auth": "WPA2", + "encryption": "AES", + "rssi": -40, + "bssid": "00:11:22:33:44:55", + }, + )() + ] + with ( + patch.object( + dev.protocol, "query", AsyncMock(side_effect=DeviceError("fail")) + ), + pytest.raises(DeviceError), + ): + await dev.wifi_join("TestSSID", "password123") + with patch.object( + dev.protocol, "query", AsyncMock(side_effect=KasaException("fail")) + ): + result = await dev.wifi_join("TestSSID", "password123") + assert result == {} + + @device_smartcam async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory): """Test a child device gets the time from it's parent module.""" @@ -69,3 +162,36 @@ async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory): await module.set_time(fallback_time) await dev.update() assert dev.time == fallback_time + + +@device_smartcam +async def test_wifi_join_typeerror_on_non_rsa_key(dev: SmartCamDevice): + dev._networks = [ + type( + "WifiNetwork", + (), + { + "ssid": "TestSSID", + "auth": "WPA2", + "encryption": "AES", + "rssi": -40, + "bssid": "00:11:22:33:44:55", + }, + )() + ] + with patch.object(type(dev), "credentials", new_callable=PropertyMock) as cred_mock: + cred_mock.return_value = object() + with ( + patch( + "cryptography.hazmat.primitives.serialization.load_der_public_key", + return_value=object(), + ), + patch( + "kasa.smartcam.smartcamdevice.RSAPublicKey", + new=type("FakeRSA", (), {}), + ), + pytest.raises( + TypeError, match="Loaded public key is not an RSA public key" + ), + ): + await dev.wifi_join("TestSSID", "password123") diff --git a/tests/test_cli.py b/tests/test_cli.py index 627959e74..c5c06b7bf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -49,10 +49,13 @@ from kasa.smartcam import SmartCamDevice from .conftest import ( + device_iot, device_smart, + device_smartcam, get_device_for_fixture_protocol, handle_turn_on, new_discovery, + parametrize_combine, turn_on, ) @@ -359,12 +362,47 @@ async def test_wifi_scan(dev, runner): assert re.search(r"Found [\d]+ wifi networks!", res.output) -@device_smart +@parametrize_combine([device_smart, device_iot]) async def test_wifi_join(dev, mocker, runner): update = mocker.patch.object(dev, "update") res = await runner.invoke( wifi, - ["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"], + ["join", "FOOBAR", "--keytype", "3", "--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 + + +@parametrize_combine([device_smart, device_iot]) +async def test_wifi_join_missing_keytype(dev, mocker, runner): + """Test that missing keytype raises KasaException and CLI echoes the message.""" + update = mocker.patch.object(dev, "update") + res = await runner.invoke( + wifi, + ["join", "FOOBAR", "--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 "KeyType is required for this device." in res.output + + +@device_smartcam +async def test_wifi_join_smartcam(dev, mocker, runner): + update = mocker.patch.object(dev, "update") + res = await runner.invoke( + wifi, + ["join", "FOOBAR", "--password", "foobar"], obj=dev, ) From eefbf9ec1c8bf57b280f09a13578a83c46b3abc8 Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Sun, 22 Feb 2026 13:56:26 -0500 Subject: [PATCH 886/892] Add support for tapo login_version 3 in sslaestransport (#1638) Updates to get_default_credentials and DEFAULT_CREDENTIALS for handling a new default password for encryption_type 3 in TAPOCAMERA devices that use encryption_type 3. This adds support for devices like TC40. --- README.md | 2 +- SUPPORTED.md | 2 + kasa/credentials.py | 7 +- kasa/transports/sslaestransport.py | 6 +- .../fixtures/smartcam/TC40(EU)_2.0_1.0.4.json | 1037 +++++++++++++++++ tests/transports/test_sslaestransport.py | 55 +- 6 files changed, 1103 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/smartcam/TC40(EU)_2.0_1.0.4.json diff --git a/README.md b/README.md index e1d5c198c..6b3117295 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: S210, S220, S500, S500D, S505, S505D, TS15 - **Bulbs**: L430C, L430P, L510B, L510E, L530B, L530E, L535E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Cameras**: C100, C110, C210, C220, C225, C325WB, C460, C520WS, C720, TC65, TC70 +- **Cameras**: C100, C110, C210, C220, C225, C325WB, C460, C520WS, C720, TC40, TC65, TC70 - **Doorbells and chimes**: D100C, D130, D230 - **Vacuums**: RV20 Max Plus, RV30 Max - **Hubs**: H100, H200 diff --git a/SUPPORTED.md b/SUPPORTED.md index b464aad1d..79260b174 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -320,6 +320,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (US) / Firmware: 1.2.8 - **C720** - Hardware: 1.0 (US) / Firmware: 1.2.3 +- **TC40** + - Hardware: 2.0 (EU) / Firmware: 1.0.4 - **TC65** - Hardware: 1.0 / Firmware: 1.3.9 - **TC70** diff --git a/kasa/credentials.py b/kasa/credentials.py index 66dd11742..3497b76aa 100644 --- a/kasa/credentials.py +++ b/kasa/credentials.py @@ -16,10 +16,10 @@ class Credentials: password: str = field(default="", repr=False) -def get_default_credentials(tuple: tuple[str, str]) -> Credentials: +def get_default_credentials(crdentials: tuple[str, str]) -> Credentials: """Return decoded default credentials.""" - un = base64.b64decode(tuple[0].encode()).decode() - pw = base64.b64decode(tuple[1].encode()).decode() + un = base64.b64decode(crdentials[0].encode()).decode() + pw = base64.b64decode(crdentials[1].encode()).decode() return Credentials(un, pw) @@ -28,4 +28,5 @@ def get_default_credentials(tuple: tuple[str, str]) -> Credentials: "KASACAMERA": ("YWRtaW4=", "MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="), "TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="), "TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="), + "TAPOCAMERA_LV3": ("YWRtaW4=", "VFBMMDc1NTI2NDYwNjAz"), } diff --git a/kasa/transports/sslaestransport.py b/kasa/transports/sslaestransport.py index 6473aa1e3..525085b05 100644 --- a/kasa/transports/sslaestransport.py +++ b/kasa/transports/sslaestransport.py @@ -95,8 +95,12 @@ def __init__( not self._credentials or self._credentials.username is None ) and not self._credentials_hash: self._credentials = Credentials() + if self._login_version == 3: + _default_credentials = DEFAULT_CREDENTIALS["TAPOCAMERA_LV3"] + else: + _default_credentials = DEFAULT_CREDENTIALS["TAPOCAMERA"] self._default_credentials: Credentials = get_default_credentials( - DEFAULT_CREDENTIALS["TAPOCAMERA"] + _default_credentials ) self._http_client: HttpClient = HttpClient(config) diff --git a/tests/fixtures/smartcam/TC40(EU)_2.0_1.0.4.json b/tests/fixtures/smartcam/TC40(EU)_2.0_1.0.4.json new file mode 100644 index 000000000..9d625f735 --- /dev/null +++ b/tests/fixtures/smartcam/TC40(EU)_2.0_1.0.4.json @@ -0,0 +1,1037 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "0", + "last_alarm_type": "", + "owner": "", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "TC40", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": true, + "firmware_version": "1.0.4 Build 240902 Rel.38194n", + "hardware_version": "2.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "3C-64-CF-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "2", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "nightVisionMode", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "patrol", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "whiteLamp", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "upnpc", + "version": 2 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "staticIp", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "90" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-09-02 03:14:19", + "seconds_from_1970": 1725246859 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "-1", + "rssiValue": 0, + "ssid": "" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "room", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "TC40 2.0 IPC", + "device_model": "TC40", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 0, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "2.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "3C-64-CF-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.0.4 Build 240902 Rel.38194n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "0", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3", + "wtl_manual_start_flag": "off" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "off" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision", + "wtl_night_vision", + "md_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp", + "white_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3", + "wtl_manual_start_flag": "off" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [], + "name": [], + "position_pan": [], + "position_tilt": [], + "position_zoom": [], + "read_only": [] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "off", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3", + "wtl_manual_start_flag": "off" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "total_space_accurate": "0B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "0B", + "video_total_space_accurate": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-00:00", + "timing_mode": "ntp", + "zone_id": "Europe/London" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1228", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "1920*1080", + "1280*720", + "640*360" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1228", + "bitrate_type": "vbr", + "default_bitrate": "1228", + "encode_type": "H264", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "5", + "resolution": "1920*1080", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3", + "wtl_manual_start_flag": "off" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "90" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "audio_alarm_clock": "1", + "audio_lib": "1", + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "image", + "OSD", + "video" + ], + "custom_area_compensation": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "diagnose": "1", + "diagnose_capability": "1", + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "gb28181": "1", + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "playback_version": "1.1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "tums": "1", + "tums_config": "1", + "tums_msg_push": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "video_message": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "0", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } + } +} diff --git a/tests/transports/test_sslaestransport.py b/tests/transports/test_sslaestransport.py index 2974a9148..0105205d8 100644 --- a/tests/transports/test_sslaestransport.py +++ b/tests/transports/test_sslaestransport.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import logging import secrets from contextlib import nullcontext as does_not_raise @@ -12,7 +13,12 @@ from yarl import URL from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials -from kasa.deviceconfig import DeviceConfig +from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) from kasa.exceptions import ( AuthenticationError, DeviceError, @@ -393,6 +399,53 @@ async def test_port_override(): assert str(transport._app_url) == f"https://127.0.0.1:{port_override}" +@pytest.mark.parametrize( + ("login_version", "expected_password_b64"), + [ + pytest.param( + 3, + "VFBMMDc1NTI2NDYwNjAz", # noqa: S105 + id="version-3-uses-lv3-credentials", + ), + pytest.param( + 2, + "YWRtaW4=", # noqa: S105 + id="version-2-uses-tapocamera-credentials", + ), + pytest.param( + None, + "YWRtaW4=", # noqa: S105 + id="no-version-uses-tapocamera-credentials", + ), + ], +) +async def test_login_version_default_credentials( + mocker, login_version, expected_password_b64 +): + """Test that login_version=3 uses TAPOCAMERA_LV3 credentials while other versions use TAPOCAMERA.""" + host = "127.0.0.1" + tapo_family = DeviceFamily.SmartIpCamera + aes_type = DeviceEncryptionType.Aes + mock_ssl_aes_device = MockSslAesDevice(host) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + config = DeviceConfig( + host, + credentials=Credentials("foo", "bar"), + connection_type=DeviceConnectionParameters( + tapo_family, aes_type, login_version=login_version + ), + ) + transport = SslAesTransport(config=config) + assert transport._default_credentials.username == "admin" + password_b64 = base64.b64encode( + transport._default_credentials.password.encode() + ).decode() + assert password_b64 == expected_password_b64 + + class MockSslAesDevice: BAD_USER_RESP = { "error_code": SmartErrorCode.SESSION_EXPIRED.value, From 932f3e21e0fe78c0a65535cc1b1624ac383a97bf Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Sun, 22 Feb 2026 13:57:23 -0500 Subject: [PATCH 887/892] Implement IOT Time Module Failover (#1583) Adds a failover in the IOT Time Module to handle issues where the system default time zone files don't have the correct time zone info. --- kasa/iot/iottimezone.py | 118 ++++++++++++++++--- kasa/iot/modules/time.py | 52 +++++++- tests/iot/test_iottimezone.py | 194 ++++++++++++++++++++++++++++++ tests/test_common_modules.py | 216 +++++++++++++++++++++++++++++++++- 4 files changed, 560 insertions(+), 20 deletions(-) create mode 100644 tests/iot/test_iottimezone.py diff --git a/kasa/iot/iottimezone.py b/kasa/iot/iottimezone.py index 65538341b..461540891 100644 --- a/kasa/iot/iottimezone.py +++ b/kasa/iot/iottimezone.py @@ -3,9 +3,9 @@ from __future__ import annotations import logging -from datetime import datetime, timedelta, tzinfo +from datetime import UTC, datetime, timedelta, timezone, tzinfo from typing import cast -from zoneinfo import ZoneInfo +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from ..cachedzoneinfo import CachedZoneInfo @@ -14,7 +14,7 @@ async def get_timezone(index: int) -> tzinfo: """Get the timezone from the index.""" - if index > 109: + if index < 0 or index > 109: _LOGGER.error( "Unexpected index %s not configured as a timezone, defaulting to UTC", index ) @@ -25,7 +25,12 @@ async def get_timezone(index: int) -> tzinfo: async def get_timezone_index(tzone: tzinfo) -> int: - """Return the iot firmware index for a valid IANA timezone key.""" + """Return the iot firmware index for a valid IANA timezone key. + + If tzinfo is a ZoneInfo and its key is in TIMEZONE_INDEX, return that index. + Otherwise, compare annual offset behavior to find the best match. + Indices that cannot be loaded on this host are skipped. + """ if isinstance(tzone, ZoneInfo): name = tzone.key rev = {val: key for key, val in TIMEZONE_INDEX.items()} @@ -33,14 +38,23 @@ async def get_timezone_index(tzone: tzinfo) -> int: return rev[name] for i in range(110): - if _is_same_timezone(tzone, await get_timezone(i)): + try: + cand = await get_timezone(i) + except ZoneInfoNotFoundError: + continue + if _is_same_timezone(tzone, cand): return i - raise ValueError("Device does not support timezone %s", name) + raise ValueError( + f"Device does not support timezone {getattr(tzone, 'key', tzone)!r}" + ) async def get_matching_timezones(tzone: tzinfo) -> list[str]: - """Return the iot firmware index for a valid IANA timezone key.""" - matches = [] + """Return available IANA keys from TIMEZONE_INDEX that match the given tzinfo. + + Skips zones that cannot be resolved on the host. + """ + matches: list[str] = [] if isinstance(tzone, ZoneInfo): name = tzone.key vals = {val for val in TIMEZONE_INDEX.values()} @@ -48,7 +62,10 @@ async def get_matching_timezones(tzone: tzinfo) -> list[str]: matches.append(name) for i in range(110): - fw_tz = await get_timezone(i) + try: + fw_tz = await get_timezone(i) + except ZoneInfoNotFoundError: + continue if _is_same_timezone(tzone, fw_tz): match_key = cast(ZoneInfo, fw_tz).key if match_key not in matches: @@ -57,11 +74,7 @@ async def get_matching_timezones(tzone: tzinfo) -> list[str]: def _is_same_timezone(tzone1: tzinfo, tzone2: tzinfo) -> bool: - """Return true if the timezones have the same utcffset and dst offset. - - Iot devices only support a limited static list of IANA timezones; this is used to - check if a static timezone matches the same utc offset and dst settings. - """ + """Return true if the timezones have the same UTC offset each day of the year.""" now = datetime.now() start_day = datetime(now.year, 1, 1, 12) for i in range(365): @@ -71,6 +84,83 @@ def _is_same_timezone(tzone1: tzinfo, tzone2: tzinfo) -> bool: return True +def _dst_expected_from_key(key: str) -> bool | None: + """Infer if a zone key implies DST behavior (heuristic, no manual map). + + - Posix-style keys with two abbreviations like 'CST6CDT', 'MST7MDT' -> True + - Fixed abbreviation keys like 'EST', 'MST', 'HST' -> False + - 'Etc/*' zones are fixed-offset -> False + - Otherwise unknown -> None + """ + k = key.upper() + if k.startswith("ETC/"): + return False + # Two abbreviations with a number in between (e.g., CST6CDT) + if any(ch.isdigit() for ch in k) and any( + x in k for x in ("CDT", "PDT", "MDT", "EDT") + ): + return True + if k in {"UTC", "UCT", "GMT", "EST", "MST", "HST", "PST"}: + return False + return None + + +def _expected_dst_behavior_for_index(index: int) -> bool | None: + """Return whether the given index implies a DST-observing zone.""" + key = TIMEZONE_INDEX[index] + return _dst_expected_from_key(key) + + +async def _guess_timezone_by_offset( + offset: timedelta, when_utc: datetime, dst_expected: bool | None = None +) -> tzinfo: + """Pick a ZoneInfo from TIMEZONE_INDEX that exists on this host and matches. + + - offset: device's UTC offset at 'when_utc' + - when_utc: reference instant; naive is treated as UTC + - dst_expected: if True/False, prefer candidates that do/do not observe DST annually + + Returns the lowest-index matching ZoneInfo for determinism. + If none match, returns a fixed-offset timezone as a last resort. + """ + if when_utc.tzinfo is None: + when_utc = when_utc.replace(tzinfo=UTC) + else: + when_utc = when_utc.astimezone(UTC) + + year = when_utc.year + # Reference mid-winter and mid-summer dates to detect DST-observing candidates + jan_ref = datetime(year, 1, 15, 12, tzinfo=UTC) + jul_ref = datetime(year, 7, 15, 12, tzinfo=UTC) + + candidates: list[tuple[int, tzinfo, bool]] = [] + for idx, name in TIMEZONE_INDEX.items(): + try: + tz = await CachedZoneInfo.get_cached_zone_info(name) + except ZoneInfoNotFoundError: + continue + + cand_offset_now = when_utc.astimezone(tz).utcoffset() + if cand_offset_now != offset: + continue + + # Determine if this candidate observes DST (offset differs between Jan and Jul) + jan_off = jan_ref.astimezone(tz).utcoffset() + jul_off = jul_ref.astimezone(tz).utcoffset() + cand_observes_dst = jan_off != jul_off + + if dst_expected is None or cand_observes_dst == dst_expected: + candidates.append((idx, tz, cand_observes_dst)) + + if candidates: + candidates.sort(key=lambda it: it[0]) + chosen = candidates[0][1] + return chosen + + # No ZoneInfo matched; return fixed offset as a last resort + return timezone(offset) + + TIMEZONE_INDEX = { 0: "Etc/GMT+12", 1: "Pacific/Samoa", diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py index 896172de6..a9f3704b6 100644 --- a/kasa/iot/modules/time.py +++ b/kasa/iot/modules/time.py @@ -2,12 +2,19 @@ from __future__ import annotations -from datetime import UTC, datetime, tzinfo +import contextlib +from datetime import UTC, datetime, timedelta, tzinfo +from zoneinfo import ZoneInfoNotFoundError from ...exceptions import KasaException from ...interfaces import Time as TimeInterface from ..iotmodule import IotModule, merge -from ..iottimezone import get_timezone, get_timezone_index +from ..iottimezone import ( + _expected_dst_behavior_for_index, + _guess_timezone_by_offset, + get_timezone, + get_timezone_index, +) class Time(IotModule, TimeInterface): @@ -23,9 +30,46 @@ def query(self) -> dict: return q async def _post_update_hook(self) -> None: - """Perform actions after a device update.""" + """Perform actions after a device update. + + If the configured zone is not available on this host, compute the device's + current UTC offset and choose a best-match available zone, preferring DST- + observing candidates when the original index implies DST. As a last resort, + use a fixed-offset timezone. + """ if res := self.data.get("get_timezone"): - self._timezone = await get_timezone(res.get("index")) + idx = res.get("index") + try: + self._timezone = await get_timezone(idx) + return + except ZoneInfoNotFoundError: + pass # fall through to offset-based match + + gt = self.data.get("get_time") + if gt: + device_local = datetime( + gt["year"], + gt["month"], + gt["mday"], + gt["hour"], + gt["min"], + gt["sec"], + ) + now_utc = datetime.now(UTC) + delta = device_local - now_utc.replace(tzinfo=None) + rounded = timedelta(seconds=60 * round(delta.total_seconds() / 60)) + + dst_expected = None + if res := self.data.get("get_timezone"): + idx = res.get("index") + with contextlib.suppress(KeyError): + dst_expected = _expected_dst_behavior_for_index(idx) + + self._timezone = await _guess_timezone_by_offset( + rounded, when_utc=now_utc, dst_expected=dst_expected + ) + else: + self._timezone = UTC @property def time(self) -> datetime: diff --git a/tests/iot/test_iottimezone.py b/tests/iot/test_iottimezone.py new file mode 100644 index 000000000..640603b16 --- /dev/null +++ b/tests/iot/test_iottimezone.py @@ -0,0 +1,194 @@ +from datetime import UTC, datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +import pytest +from pytest_mock import MockerFixture + + +def test_expected_dst_behavior_for_index_cases(): + """Exercise _expected_dst_behavior_for_index for several representative indices.""" + from kasa.iot.iottimezone import _expected_dst_behavior_for_index + + # Posix-style DST zones + assert _expected_dst_behavior_for_index(10) is True # MST7MDT + assert _expected_dst_behavior_for_index(13) is True # CST6CDT + # Fixed-offset or fixed-abbreviation zones + assert _expected_dst_behavior_for_index(34) is False # Etc/GMT+2 + assert _expected_dst_behavior_for_index(18) is False # EST + # Invalid index should raise KeyError + with pytest.raises(KeyError): + _expected_dst_behavior_for_index(999) + + +async def test_guess_timezone_by_offset_fixed_fallback_unit(): + """When no ZoneInfo matches, return a fixed-offset tzinfo.""" + import kasa.iot.iottimezone as tzmod + + year = datetime.now(UTC).year + when = datetime(year, 1, 15, 12, tzinfo=UTC) + offset = timedelta(minutes=2) # unlikely to match any real zone + tz = await tzmod._guess_timezone_by_offset(offset, when_utc=when) + assert tz.utcoffset(when) == offset + + +async def test_guess_timezone_by_offset_candidates_unit(): + """Cover naive when_utc branch and candidate selection path (non-empty candidates).""" + import kasa.iot.iottimezone as tzmod + + # naive datetime hits the 'naive -> UTC' branch + when = datetime(2025, 1, 15, 12) + offset = timedelta(0) + tz = await tzmod._guess_timezone_by_offset(offset, when_utc=when) + + # Should choose a ZoneInfo candidate (not the fixed-offset fallback), with matching offset + assert isinstance(tz, ZoneInfo) + assert tz.utcoffset(when.replace(tzinfo=UTC)) == offset + + +async def test_guess_timezone_by_offset_dst_expected_true_filters( + mocker: MockerFixture, +): + """dst_expected=True should prefer a DST-observing zone when possible.""" + import kasa.iot.iottimezone as tzmod + + when = datetime(datetime.now(UTC).year, 1, 15, 12, tzinfo=UTC) + tz = await tzmod._guess_timezone_by_offset( + timedelta(0), when_utc=when, dst_expected=True + ) + assert tz.utcoffset(when) == timedelta(0) + if isinstance(tz, ZoneInfo): + jan = datetime(when.year, 1, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset() + jul = datetime(when.year, 7, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset() + assert jan != jul # observes DST + + +async def test_guess_timezone_by_offset_dst_expected_false_prefers_non_dst(): + """dst_expected=False should prefer a non-DST zone and skip DST candidates (covers False branch).""" + import kasa.iot.iottimezone as tzmod + + when = datetime(datetime.now(UTC).year, 1, 15, 12, tzinfo=UTC) + tz = await tzmod._guess_timezone_by_offset( + timedelta(0), when_utc=when, dst_expected=False + ) + assert tz.utcoffset(when) == timedelta(0) + if isinstance(tz, ZoneInfo): + jan = datetime(when.year, 1, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset() + jul = datetime(when.year, 7, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset() + assert jan == jul # non-DST zone chosen + + +async def test_guess_timezone_by_offset_handles_missing_zoneinfo_unit( + mocker: MockerFixture, +): + """Cover the ZoneInfoNotFoundError continue path within guess_timezone_by_offset.""" + from zoneinfo import ZoneInfoNotFoundError as ZNF + + import kasa.iot.iottimezone as tzmod + + original = tzmod.CachedZoneInfo.get_cached_zone_info + + async def flaky_get(name: str): + # Force the first entry to raise to exercise the except path (143-144) + first_name = next(iter(tzmod.TIMEZONE_INDEX.values())) + if name == first_name: + raise ZNF("unavailable on host") + return await original(name) + + mocker.patch.object(tzmod.CachedZoneInfo, "get_cached_zone_info", new=flaky_get) + + when = datetime(datetime.now(UTC).year, 1, 15, 12, tzinfo=UTC) + tz = await tzmod._guess_timezone_by_offset(timedelta(0), when_utc=when) + assert tz.utcoffset(when) == timedelta(0) + + +async def test_get_timezone_index_direct_match(): + """If ZoneInfo key is in TIMEZONE_INDEX, return index directly.""" + import kasa.iot.iottimezone as tzmod + + idx = await tzmod.get_timezone_index(ZoneInfo("GB")) + assert idx == 39 # "GB" is mapped to index 39 + + +async def test_get_timezone_index_non_zoneinfo_unit(): + """Exercise get_timezone_index path when input tzinfo is not a ZoneInfo instance.""" + import kasa.iot.iottimezone as tzmod + + # Fixed offset +0 should match a valid index (e.g., UCT/Africa/Monrovia) + idx = await tzmod.get_timezone_index(timezone(timedelta(0))) + assert isinstance(idx, int) + assert 0 <= idx <= 109 + + +async def test_get_timezone_index_skips_missing_unit(mocker: MockerFixture): + """Cover ZoneInfoNotFoundError path in get_timezone_index loop and successful match.""" + from zoneinfo import ZoneInfoNotFoundError as ZNF + + import kasa.iot.iottimezone as tzmod + + original_get_tz = tzmod.get_timezone + + async def side_effect(i: int): + if i < 5: + raise ZNF("unavailable on host") + return await original_get_tz(i) + + mocker.patch("kasa.iot.iottimezone.get_timezone", new=side_effect) + + # Use a ZoneInfo not directly present in TIMEZONE_INDEX values to avoid early return + idx = await tzmod.get_timezone_index(ZoneInfo("Europe/London")) + assert isinstance(idx, int) + assert 0 <= idx <= 109 + assert idx >= 5 + + +async def test_get_timezone_index_raises_for_unmatched_unit(): + """Ensure get_timezone_index completes loop and raises when no match exists (covers raise branch).""" + import kasa.iot.iottimezone as tzmod + + # Uncommon 2-minute offset won't match any real zone in TIMEZONE_INDEX + with pytest.raises(ValueError, match="Device does not support timezone"): + await tzmod.get_timezone_index(timezone(timedelta(minutes=2))) + + +async def test_get_matching_timezones_branches_unit(mocker: MockerFixture): + """Cover initial append, except path, and duplicate suppression in get_matching_timezones.""" + from zoneinfo import ZoneInfoNotFoundError as ZNF + + import kasa.iot.iottimezone as tzmod + + original_get_tz = tzmod.get_timezone + + async def side_effect(i: int): + # Force one miss to hit the except path + if i == 0: + raise ZNF("unavailable on host") + return await original_get_tz(i) + + mocker.patch("kasa.iot.iottimezone.get_timezone", new=side_effect) + + # 'GB' is in TIMEZONE_INDEX; passing ZoneInfo('GB') will trigger initial append + matches = await tzmod.get_matching_timezones(ZoneInfo("GB")) + assert "GB" in matches # initial append done + # Loop should find GB again but not duplicate it + + +async def test_get_matching_timezones_non_zoneinfo_unit(): + """Exercise get_matching_timezones when input tzinfo is not a ZoneInfo (skips initial append).""" + import kasa.iot.iottimezone as tzmod + + matches = await tzmod.get_matching_timezones(timezone(timedelta(0))) + assert isinstance(matches, list) + assert len(matches) > 0 + + +async def test_get_timezone_out_of_range_defaults_to_utc(): + """Out-of-range index should log and default to UTC.""" + import kasa.iot.iottimezone as tzmod + + tz = await tzmod.get_timezone(-1) + assert isinstance(tz, ZoneInfo) + assert tz.key in ("Etc/UTC", "UTC") # platform alias acceptable + + tz2 = await tzmod.get_timezone(999) + assert isinstance(tz2, ZoneInfo) + assert tz2.key in ("Etc/UTC", "UTC") diff --git a/tests/test_common_modules.py b/tests/test_common_modules.py index 869ba27d1..f6b35a81d 100644 --- a/tests/test_common_modules.py +++ b/tests/test_common_modules.py @@ -2,14 +2,15 @@ import inspect import pkgutil import sys -from datetime import datetime +from datetime import UTC, datetime, timedelta, timezone +from unittest.mock import AsyncMock from zoneinfo import ZoneInfo import pytest from pytest_mock import MockerFixture import kasa.interfaces -from kasa import Device, LightState, Module, ThermostatState +from kasa import Device, KasaException, LightState, Module, ThermostatState from kasa.module import _get_feature_attribute from .device_fixtures import ( @@ -456,3 +457,214 @@ async def test_set_time(dev: Device): await time_mod.set_time(original_time) await dev.update() assert time_mod.time == original_time + + +async def test_time_post_update_no_time_uses_utc_unit(monkeypatch: pytest.MonkeyPatch): + """If neither get_timezone nor get_time are present, timezone falls back to UTC.""" + from kasa.iot.modules.time import Time as TimeModule + + inst = object.__new__(TimeModule) + monkeypatch.setattr(TimeModule, "data", property(lambda self: {})) + + await TimeModule._post_update_hook(inst) + assert inst.timezone is UTC + + +async def test_time_post_update_uses_offset_when_index_missing_unit( + monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +): + """When index present but zone not on host, fall back to offset-based guess.""" + from zoneinfo import ZoneInfoNotFoundError + + from kasa.iot.modules.time import Time as TimeModule + + inst = object.__new__(TimeModule) + + now = datetime.now(UTC) + data = { + "get_timezone": {"index": 39}, # any index; we'll force failure to load it + "get_time": { + "year": now.year, + "month": now.month, + "mday": now.day, + "hour": now.hour, + "min": now.minute, + "sec": now.second, + }, + } + monkeypatch.setattr(TimeModule, "data", property(lambda self: data)) + + mocker.patch( + "kasa.iot.modules.time.get_timezone", + new=AsyncMock(side_effect=ZoneInfoNotFoundError("missing on host")), + ) + mock_guess = mocker.patch( + "kasa.iot.modules.time._guess_timezone_by_offset", + new=AsyncMock(return_value=timezone(timedelta(0))), + ) + + await TimeModule._post_update_hook(inst) + mock_guess.assert_awaited_once() + # timezone should be set to a valid tzinfo after fallback + assert inst.timezone.utcoffset(now) == timedelta(0) + + +async def test_time_get_time_exception_returns_none_unit(mocker: MockerFixture): + """Cover Time.get_time exception path (unit test of iot Time).""" + from kasa.iot.modules.time import Time as TimeModule + + inst = object.__new__(TimeModule) + mocker.patch.object(inst, "call", new=AsyncMock(side_effect=KasaException("boom"))) + + assert await TimeModule.get_time(inst) is None + + +async def test_time_get_time_success_unit(mocker: MockerFixture): + """Cover the success path of Time.get_time.""" + from kasa.iot.modules.time import Time as TimeModule + + inst = object.__new__(TimeModule) + # Ensure timezone is available on the instance + inst._timezone = UTC + ret = { + "year": 2024, + "month": 1, + "mday": 2, + "hour": 3, + "min": 4, + "sec": 5, + } + mocker.patch.object(inst, "call", new=AsyncMock(return_value=ret)) + + dt = await TimeModule.get_time(inst) + assert dt is not None + assert (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) == ( + 2024, + 1, + 2, + 3, + 4, + 5, + ) + assert dt.tzinfo == inst.timezone + + +async def test_time_post_update_with_time_no_tz_uses_guess_unit( + monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +): + """When get_time is present but get_timezone is missing, use offset-based guess (dst_expected None).""" + from kasa.iot.modules.time import Time as TimeModule + + inst = object.__new__(TimeModule) + now = datetime.now(UTC) + data = { + "get_time": { + "year": now.year, + "month": now.month, + "mday": now.day, + "hour": now.hour, + "min": now.minute, + "sec": now.second, + } + # Note: no "get_timezone" key + } + monkeypatch.setattr(TimeModule, "data", property(lambda self: data)) + + mock_guess = mocker.patch( + "kasa.iot.modules.time._guess_timezone_by_offset", + new=AsyncMock(return_value=timezone(timedelta(hours=2))), + ) + + await TimeModule._post_update_hook(inst) + mock_guess.assert_awaited_once() + assert inst.timezone.utcoffset(now) == timedelta(hours=2) + + +async def test_time_set_time_wraps_exception_unit( + monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +): + """Cover exception wrapping in Time.set_time (unit test of iot Time).""" + from kasa.iot.modules.time import Time as TimeModule + + inst = object.__new__(TimeModule) + # Keep data empty so set_time path is chosen (no timezone change) + monkeypatch.setattr(TimeModule, "data", property(lambda self: {})) + mocker.patch.object(inst, "call", new=AsyncMock(side_effect=RuntimeError("err"))) + + with pytest.raises(KasaException): + await TimeModule.set_time(inst, datetime.now()) + + +# New tests to cover remaining smart and smartcam time.py branches + + +async def test_smart_time_set_time_no_region_added_when_tzname_none_unit( + mocker: MockerFixture, +): + """In smart Time.set_time, ensure we cover the branch where tzname() returns None, so 'region' is omitted.""" + from datetime import tzinfo as _tzinfo + + from kasa.smart.modules.time import Time as SmartTimeModule + + class NullNameTZ(_tzinfo): + def utcoffset(self, dt): + return timedelta(hours=1) + + def dst(self, dt): + return timedelta(0) + + def tzname(self, dt): + return None + + inst = object.__new__(SmartTimeModule) + call_mock = mocker.patch.object(inst, "call", new=AsyncMock(return_value={})) + + aware_dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo=NullNameTZ()) + await SmartTimeModule.set_time(inst, aware_dt) + + call_mock.assert_awaited_once() + args, _ = call_mock.call_args + assert args[0] == "set_device_time" + params = args[1] + # 'region' must not be present when tzname() is None + assert "region" not in params + # sanity: timestamp and time_diff still provided + assert isinstance(params["timestamp"], int) + assert isinstance(params["time_diff"], int) + + +async def test_smartcam_time_post_update_fallback_parses_timezone_str_unit( + monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +): + """Exercise smartcam Time._post_update_hook fallback when ZoneInfo not found, parsing 'timezone' string.""" + from zoneinfo import ZoneInfoNotFoundError + + from kasa.smartcam.modules.time import Time as CamTimeModule + + inst = object.__new__(CamTimeModule) + # Provide data with an unknown zone_id but with a 'timezone' string like 'UTC+02:00' + ts = 1_700_000_000 + data = { + "getClockStatus": {"system": {"clock_status": {"seconds_from_1970": ts}}}, + "getTimezone": { + "system": {"basic": {"zone_id": "Nowhere/Unknown", "timezone": "UTC+02:00"}} + }, + } + monkeypatch.setattr(CamTimeModule, "data", property(lambda self: data)) + + # Patch directly via the module path instead of sys.modules lookup + mocker.patch( + "kasa.smartcam.modules.time.CachedZoneInfo.get_cached_zone_info", + new=AsyncMock(side_effect=ZoneInfoNotFoundError("missing on host")), + ) + + await CamTimeModule._post_update_hook(inst) + + # Check timezone fallback parsed to +02:00 + now_local = datetime.now(inst.timezone) + assert inst.timezone.utcoffset(now_local) == timedelta(hours=2) + + # Check time set from seconds_from_1970 and is tz-aware with the chosen tz + assert isinstance(inst.time, datetime) + assert inst.time.tzinfo == inst.timezone + assert int(inst.time.timestamp()) == ts From c9c80619d76e21bd1458f10084c6d2ef2daae8f1 Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:46:11 -0500 Subject: [PATCH 888/892] Fix camera login version in CLI (#1658) Fix handling of --login-version/-lv for the CLI with --type "camera". --- kasa/cli/main.py | 1 - tests/test_cli.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/kasa/cli/main.py b/kasa/cli/main.py index 4f1eccda9..15ad211bd 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -312,7 +312,6 @@ async def cli( if type == "camera": encrypt_type = "AES" https = True - login_version = 2 device_family = "SMART.IPCAMERA" from kasa.device import Device diff --git a/tests/test_cli.py b/tests/test_cli.py index c5c06b7bf..6cba5d2a5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1039,6 +1039,43 @@ async def _state(dev: Device): assert isinstance(result_device, expected_type) +@pytest.mark.parametrize( + ("cli_login_version", "expected_login_version"), + [ + pytest.param(None, 2, id="No login-version defaults to 2"), + pytest.param(3, 3, id="Explicit login-version 3 is preserved"), + pytest.param(2, 2, id="Explicit login-version 2 is preserved"), + ], +) +async def test_type_camera_login_version( + cli_login_version, expected_login_version, mocker, runner +): + """Test that --type camera respects an explicitly provided --login-version.""" + from kasa.deviceconfig import DeviceConfig + + captured_config: DeviceConfig | None = None + + mocker.patch("kasa.cli.device.state") + + async def _mock_connect(config: DeviceConfig): + nonlocal captured_config + captured_config = config + dev = SmartCamDevice(host="127.0.0.1", config=config) + return dev + + mocker.patch("kasa.device.Device.connect", side_effect=_mock_connect) + mocker.patch.object(SmartCamDevice, "update") + + args = ["--type", "camera", "--host", "127.0.0.1"] + if cli_login_version is not None: + args += ["--login-version", str(cli_login_version)] + + res = await runner.invoke(cli, args) + assert res.exit_code == 0, res.output + assert captured_config is not None + assert captured_config.connection_type.login_version == expected_login_version + + @pytest.mark.skip( "Skip until pytest-asyncio supports pytest 8.0, https://github.com/pytest-dev/pytest-asyncio/issues/737" ) From be5ce08f4507ba54a52d954fbaf87c31fa6364f3 Mon Sep 17 00:00:00 2001 From: Davide Fiocco Date: Sun, 1 Mar 2026 23:36:49 +0100 Subject: [PATCH 889/892] Fix mop set_waterlevel sending setCleanAttr without type field (#1667) --- kasa/smart/modules/mop.py | 7 ++++--- tests/smart/modules/test_mop.py | 7 +++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/kasa/smart/modules/mop.py b/kasa/smart/modules/mop.py index 851279e97..cc6cb84fc 100644 --- a/kasa/smart/modules/mop.py +++ b/kasa/smart/modules/mop.py @@ -85,6 +85,7 @@ async def set_waterlevel(self, mode: str) -> Annotated[dict, FeatureAttribute()] if mode not in name_to_value: raise ValueError("Invalid waterlevel %s, available %s", mode, name_to_value) - settings = self._settings.copy() - settings["cistern"] = name_to_value[mode] - return await self.call("setCleanAttr", settings) + return await self.call( + "setCleanAttr", + {"cistern": name_to_value[mode], "type": "global"}, + ) diff --git a/tests/smart/modules/test_mop.py b/tests/smart/modules/test_mop.py index 0c638ca3a..b6492aa3a 100644 --- a/tests/smart/modules/test_mop.py +++ b/tests/smart/modules/test_mop.py @@ -45,10 +45,9 @@ async def test_mop_waterlevel(dev: SmartDevice, mocker: MockerFixture): new_level = Waterlevel.High await mop_module.set_waterlevel(new_level.name) - params = mop_module._settings.copy() - params["cistern"] = new_level.value - - call.assert_called_with("setCleanAttr", params) + call.assert_called_with( + "setCleanAttr", {"cistern": new_level.value, "type": "global"} + ) await dev.update() From 99fca940cfd43c5abbf090e7bc070347aed7bd79 Mon Sep 17 00:00:00 2001 From: wh1t3f1r3 <55475321+wh1t3f1r3@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:03:46 -0600 Subject: [PATCH 890/892] Add ENETUNREACH to non-retryable errors in XorTransport (#1668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ENETUNREACH` is semantically equivalent to `EHOSTUNREACH` (which is already in `_NO_RETRY_ERRORS`) — both indicate the destination cannot be reached at the network layer. Retrying immediately will not resolve the condition. In containerized environments (e.g., Home Assistant running in Docker with bridge networking), a single ICMP "Network unreachable" response can temporarily poison the container's routing cache. The unnecessary retries reinforce the poisoned cache entry, causing cascading failures for other devices on the same subnet. --- kasa/transports/xortransport.py | 7 ++++++- tests/protocols/test_iotprotocol.py | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/kasa/transports/xortransport.py b/kasa/transports/xortransport.py index 84fba0a57..da77c899d 100644 --- a/kasa/transports/xortransport.py +++ b/kasa/transports/xortransport.py @@ -29,7 +29,12 @@ from .basetransport import BaseTransport _LOGGER = logging.getLogger(__name__) -_NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} +_NO_RETRY_ERRORS = { + errno.EHOSTDOWN, + errno.EHOSTUNREACH, + errno.ENETUNREACH, + errno.ECONNREFUSED, +} _UNSIGNED_INT_NETWORK_ORDER = struct.Struct(">I") diff --git a/tests/protocols/test_iotprotocol.py b/tests/protocols/test_iotprotocol.py index fd8facc9e..0db91fcab 100644 --- a/tests/protocols/test_iotprotocol.py +++ b/tests/protocols/test_iotprotocol.py @@ -807,10 +807,17 @@ async def test_transport_credentials_hash_from_config(mocker, transport_class): [ (ConnectionRefusedError("dummy exception"), False), (OSError(errno.EHOSTDOWN, os.strerror(errno.EHOSTDOWN)), False), + (OSError(errno.ENETUNREACH, os.strerror(errno.ENETUNREACH)), False), (OSError(errno.ECONNRESET, os.strerror(errno.ECONNRESET)), True), (Exception("dummy exception"), True), ], - ids=("ConnectionRefusedError", "OSErrorNoRetry", "OSErrorRetry", "Exception"), + ids=( + "ConnectionRefusedError", + "OSErrorHostDown", + "OSErrorNetUnreach", + "OSErrorRetry", + "Exception", + ), ) @pytest.mark.parametrize( ("protocol_class", "transport_class"), From 3aedc87c28c0ed11cc3b38e6a094f9f70c6d42a9 Mon Sep 17 00:00:00 2001 From: ijaron Date: Sun, 5 Apr 2026 14:16:58 -0500 Subject: [PATCH 891/892] Add C101 test fixture (#1673) --- README.md | 2 +- SUPPORTED.md | 2 + .../fixtures/smartcam/C101(US)_5.0_1.4.3.json | 1154 +++++++++++++++++ 3 files changed, 1157 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smartcam/C101(US)_5.0_1.4.3.json diff --git a/README.md b/README.md index 6b3117295..6bf6c7d69 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: S210, S220, S500, S500D, S505, S505D, TS15 - **Bulbs**: L430C, L430P, L510B, L510E, L530B, L530E, L535E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Cameras**: C100, C110, C210, C220, C225, C325WB, C460, C520WS, C720, TC40, TC65, TC70 +- **Cameras**: C100, C101, C110, C210, C220, C225, C325WB, C460, C520WS, C720, TC40, TC65, TC70 - **Doorbells and chimes**: D100C, D130, D230 - **Vacuums**: RV20 Max Plus, RV30 Max - **Hubs**: H100, H200 diff --git a/SUPPORTED.md b/SUPPORTED.md index 79260b174..9e79bf3b7 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -300,6 +300,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **C100** - Hardware: 4.0 / Firmware: 1.3.14 +- **C101** + - Hardware: 5.0 (US) / Firmware: 1.4.3 - **C110** - Hardware: 2.0 (EU) / Firmware: 1.4.3 - **C210** diff --git a/tests/fixtures/smartcam/C101(US)_5.0_1.4.3.json b/tests/fixtures/smartcam/C101(US)_5.0_1.4.3.json new file mode 100644 index 000000000..4b5fbce83 --- /dev/null +++ b/tests/fixtures/smartcam/C101(US)_5.0_1.4.3.json @@ -0,0 +1,1154 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "0", + "last_alarm_type": "", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C101", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "4" + ], + "factory_default": false, + "firmware_version": "1.4.3 Build 251128 Rel.63757n", + "hardware_version": "5.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "98-03-8E-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1, + "sv": 1, + "tpap": { + "noc": 1, + "pake": [ + 2 + ], + "port": 443, + "tls": 1 + } + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "2", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound" + ], + "alarm_type": "0", + "alarm_volume": "normal", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "0", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-2359,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "upnpc", + "version": 2 + }, + { + "name": "staticIp", + "version": 2 + }, + { + "name": "timeFormat", + "version": 1 + }, + { + "name": "relayPreConnection", + "version": 1 + }, + { + "name": "hubManage", + "version": 1 + }, + { + "name": "streamCapability", + "version": 1 + }, + { + "name": "localCtrl", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2026-03-13 18:49:34", + "seconds_from_1970": 1773445774 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -44, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "80", + "enabled": "off", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "high", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c100", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C101 5.0 IPC", + "device_model": "C101", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "5.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "98-03-8E-00-00-00", + "manufacturer_name": "TP-Link", + "mobile_access": "0", + "no_rtsp_constrain": 1, + "oem_id": "00000000000000000000000000000000", + "region": "US", + "sw_version": "1.4.3 Build 251128 Rel.63757n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "0", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "original", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "original", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "off" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "on" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "80" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "total_space_accurate": "0B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "0B", + "video_total_space_accurate": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "20", + "enabled": "off", + "sensitivity": "low" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-06:00", + "timing_mode": "ntp", + "zone_id": "America/Chicago" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1280", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561", + "65566" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "1920*1080", + "1280*720", + "640*360" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1280", + "bitrate_type": "vbr", + "default_bitrate": "1024", + "encode_type": "H264", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "5", + "resolution": "1920*1080", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "image", + "OSD", + "video" + ], + "custom_area_compensation": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "diagnose": "1", + "diagnose_capability": "1", + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "playback_version": "1.1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "tums": "1", + "tums_config": "1", + "tums_msg_push": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "video_message": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "0", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 0, + "bssid": "000000000000", + "encryption": 0, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 5, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 0, + "bssid": "000000000000", + "encryption": 0, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 0, + "bssid": "000000000000", + "encryption": 0, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 5, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "public_key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCXl67tCOTpn83kKtYzQIy6F6Q7\nsq5QBEeaO639zeE7eyNnh3ZA+PlCVsoICB7Gl1Yu/PmK0gfk3hujpcD4RO6TGIVU\n5C8jt7Hz8fgyzUJcKp3z+QUrf3oiLrOsRqzgieQdYkFh7LY9tQkzTkkxrJpmKmln\n3254L9dKKG3uqaEFXwIDAQAB\n-----END PUBLIC KEY-----\n", + "wpa3_supported": "false" + } + } + } +} From 76d9f68547ec4ad61b329708debb4fa99dfd8e65 Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:19:25 -0400 Subject: [PATCH 892/892] Add LB130(US) device fixture (#1669) --- README.md | 2 +- SUPPORTED.md | 2 + tests/fixtures/iot/LB130(US)_1.0_1.8.11.json | 139 +++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/iot/LB130(US)_1.0_1.8.11.json diff --git a/README.md b/README.md index 6bf6c7d69..f23922308 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: EP10, EP25[^2], HS100[^2], HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M[^1], KP401 - **Power Strips**: EP40, EP40M[^1], HS107, HS300, KP200, KP303, KP400 - **Wall Switches**: ES20M, HS200[^2], HS210, HS220[^2], KP405, KS200, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1] -- **Bulbs**: KL110, KL110B, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110 +- **Bulbs**: KL110, KL110B, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110, LB130 - **Light Strips**: KL400L10, KL400L5, KL420L5, KL430 - **Hubs**: KH100[^1] - **Hub-Connected Devices[^3]**: KE100[^1] diff --git a/SUPPORTED.md b/SUPPORTED.md index 9e79bf3b7..900c5ef97 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -156,6 +156,8 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - Hardware: 1.0 (US) / Firmware: 1.8.11 - **LB110** - Hardware: 1.0 (US) / Firmware: 1.8.11 +- **LB130** + - Hardware: 1.0 (US) / Firmware: 1.8.11 ### Light Strips diff --git a/tests/fixtures/iot/LB130(US)_1.0_1.8.11.json b/tests/fixtures/iot/LB130(US)_1.0_1.8.11.json new file mode 100644 index 000000000..372c44f29 --- /dev/null +++ b/tests/fixtures/iot/LB130(US)_1.0_1.8.11.json @@ -0,0 +1,139 @@ +{ + "smartlife.iot.common.cloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": 0, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 0 + } + }, + "smartlife.iot.common.schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 0, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_default_behavior": { + "err_code": 0, + "hard_on": { + "mode": "last_status" + }, + "soft_on": { + "mode": "last_status" + } + }, + "get_light_details": { + "color_rendering_index": 80, + "err_code": 0, + "incandescent_equivalent": 60, + "lamp_beam_angle": 150, + "max_lumens": 800, + "max_voltage": 120, + "min_voltage": 110, + "wattage": 10 + }, + "get_light_state": { + "dft_on_state": { + "brightness": 70, + "color_temp": 2732, + "hue": 0, + "mode": "normal", + "saturation": 0 + }, + "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, + "heapsize": 290676, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "light_state": { + "dft_on_state": { + "brightness": 70, + "color_temp": 2732, + "hue": 0, + "mode": "normal", + "saturation": 0 + }, + "on_off": 0 + }, + "mic_mac": "704F57000000", + "mic_type": "IOT.SMARTBULB", + "model": "LB130(US)", + "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": 75 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "index": 2, + "saturation": 75 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "index": 3, + "saturation": 75 + } + ], + "rssi": -52, + "sw_ver": "1.8.11 Build 191113 Rel.105336" + } + } +}